488 lines
16 KiB
TypeScript
488 lines
16 KiB
TypeScript
|
import { useRef, useEffect, useState, Dispatch, SetStateAction } from 'react';
|
||
|
import { useMediaQuery } from 'react-responsive';
|
||
|
|
||
|
import ArcGISMap from '@arcgis/core/Map';
|
||
|
import Extent from '@arcgis/core/geometry/Extent';
|
||
|
import MapView from '@arcgis/core/views/MapView';
|
||
|
import Basemap from '@arcgis/core/Basemap';
|
||
|
import MapImageLayer from '@arcgis/core/layers/MapImageLayer';
|
||
|
import WMTSLayer from '@arcgis/core/layers/WMTSLayer';
|
||
|
import Search from '@arcgis/core/widgets/Search';
|
||
|
import ScaleBar from '@arcgis/core/widgets/ScaleBar';
|
||
|
import Sketch from '@arcgis/core/widgets/Sketch';
|
||
|
import GraphicsLayer from '@arcgis/core/layers/GraphicsLayer';
|
||
|
import Graphic from '@arcgis/core/Graphic';
|
||
|
import * as geometryEngine from '@arcgis/core/geometry/geometryEngine';
|
||
|
import * as reactiveUtils from '@arcgis/core/core/reactiveUtils';
|
||
|
import Zoom from '@arcgis/core/widgets/Zoom';
|
||
|
import FeatureLayer from '@arcgis/core/layers/FeatureLayer';
|
||
|
import Polygon from '@arcgis/core/geometry/Polygon';
|
||
|
import SimpleFillSymbol from '@arcgis/core/symbols/SimpleFillSymbol';
|
||
|
import SimpleMarkerSymbol from '@arcgis/core/symbols/SimpleMarkerSymbol';
|
||
|
import esriConfig from '@arcgis/core/config';
|
||
|
import Point from '@arcgis/core/geometry/Point';
|
||
|
|
||
|
import { updateComputationResultEWS } from '@/redux/computationsEWSSlice';
|
||
|
import { updateCadastralData } from '@/redux/cadastreSlice';
|
||
|
|
||
|
import identifyAllLayers from '../../utils/identify';
|
||
|
import queryCadastre from '../../utils/queryCadastre';
|
||
|
import getAddress from '../../utils/getAddress';
|
||
|
|
||
|
import './esri-ui-potenzialkarte.css';
|
||
|
import { Vienna, Austria } from '@/public/borders-vienna-austria';
|
||
|
|
||
|
import ReduxProvider from '@/redux/provider';
|
||
|
import { useAppDispatch } from '@/redux/hooks';
|
||
|
|
||
|
import PanelPotenzialkarte from './panel-potenzialkarte';
|
||
|
import CalculationsMenu from './calculations-menu';
|
||
|
|
||
|
// import layer URLs and spatial reference system wkid
|
||
|
import { BASEMAP_AT_URL, AMPEL_EWS_URL, RESOURCES_EWS_URL, SRS } from '@/app/config/config';
|
||
|
|
||
|
// set path for local assets
|
||
|
esriConfig.assetsPath = '/assets';
|
||
|
|
||
|
const scaleLimit = 1000;
|
||
|
const gridSpacing = 10;
|
||
|
|
||
|
// initialize info panel callback functions
|
||
|
let setAddress: Dispatch<SetStateAction<string[]>>,
|
||
|
setClosenessWarning: Dispatch<SetStateAction<boolean>>,
|
||
|
setOutsideWarning: Dispatch<SetStateAction<boolean>>,
|
||
|
setScaleWarning: Dispatch<SetStateAction<boolean>>;
|
||
|
export function initializeInfoPanelHandlers(
|
||
|
setAddressCallback: Dispatch<SetStateAction<string[]>>,
|
||
|
setClosenessWarningCallback: Dispatch<SetStateAction<boolean>>,
|
||
|
setOutsideWarningCallback: Dispatch<SetStateAction<boolean>>,
|
||
|
setScaleWarningCallback: Dispatch<SetStateAction<boolean>>
|
||
|
) {
|
||
|
setAddress = setAddressCallback;
|
||
|
setClosenessWarning = setClosenessWarningCallback;
|
||
|
setOutsideWarning = setOutsideWarningCallback;
|
||
|
setScaleWarning = setScaleWarningCallback;
|
||
|
}
|
||
|
|
||
|
// initialize calculations menu callback functions
|
||
|
let setPoints: Dispatch<SetStateAction<number[][]>>, setPolygon: Dispatch<SetStateAction<Polygon | null>>;
|
||
|
export function initializeCalculationsMenuHandlers(
|
||
|
setPointsCallback: Dispatch<SetStateAction<number[][]>>,
|
||
|
setPolygonCallback: Dispatch<SetStateAction<Polygon | null>>
|
||
|
) {
|
||
|
setPoints = setPointsCallback;
|
||
|
setPolygon = setPolygonCallback;
|
||
|
}
|
||
|
|
||
|
// initialize the map view container
|
||
|
export let view: MapView;
|
||
|
export let pointGraphicsLayer: GraphicsLayer;
|
||
|
export let boundaryGraphicsLayer: GraphicsLayer;
|
||
|
export default function MapComponent() {
|
||
|
const dispatch = useAppDispatch();
|
||
|
const isMobile = useMediaQuery({ maxWidth: 640 });
|
||
|
|
||
|
const mapDiv = useRef<HTMLDivElement | null>(null);
|
||
|
const calculationsMenuRef = useRef<HTMLDivElement | null>(null);
|
||
|
const loaded = useRef<boolean>(false);
|
||
|
|
||
|
const [loading, setLoading] = useState<boolean>(false);
|
||
|
|
||
|
useEffect(() => {
|
||
|
let handle: IHandle;
|
||
|
if (mapDiv.current && calculationsMenuRef.current) {
|
||
|
view = new MapView({
|
||
|
container: mapDiv.current,
|
||
|
extent: new Extent({
|
||
|
xmin: -19000,
|
||
|
ymin: 325000,
|
||
|
xmax: 29000,
|
||
|
ymax: 360000,
|
||
|
spatialReference: { wkid: SRS },
|
||
|
}),
|
||
|
spatialReference: { wkid: SRS },
|
||
|
popupEnabled: false,
|
||
|
popup: {
|
||
|
dockEnabled: false,
|
||
|
dockOptions: {
|
||
|
buttonEnabled: false,
|
||
|
},
|
||
|
collapseEnabled: false,
|
||
|
viewModel: {
|
||
|
includeDefaultActions: false,
|
||
|
},
|
||
|
},
|
||
|
});
|
||
|
|
||
|
// listen to scale changes
|
||
|
handle = reactiveUtils.watch(
|
||
|
() => view.scale,
|
||
|
() => {
|
||
|
if (view.scale < scaleLimit) {
|
||
|
setScaleWarning(false);
|
||
|
} else {
|
||
|
setScaleWarning(true);
|
||
|
}
|
||
|
}
|
||
|
);
|
||
|
|
||
|
// graphic layers
|
||
|
let viennaGraphic = new Graphic({
|
||
|
geometry: new Polygon({ rings: [Austria, Vienna], spatialReference: { wkid: SRS } }),
|
||
|
symbol: {
|
||
|
type: 'simple-fill',
|
||
|
color: [209, 213, 219, 0.65],
|
||
|
style: 'solid',
|
||
|
outline: {
|
||
|
color: 'white',
|
||
|
width: 0,
|
||
|
},
|
||
|
} as unknown as SimpleFillSymbol,
|
||
|
});
|
||
|
const viennaGraphicsLayer = new GraphicsLayer({ title: 'Wien', listMode: 'hide' });
|
||
|
viennaGraphicsLayer.add(viennaGraphic);
|
||
|
|
||
|
pointGraphicsLayer = new GraphicsLayer({
|
||
|
title: 'Planungslayer - Punkte',
|
||
|
listMode: 'hide',
|
||
|
});
|
||
|
|
||
|
boundaryGraphicsLayer = new GraphicsLayer({
|
||
|
title: 'EWS - innere Grenze',
|
||
|
listMode: 'hide',
|
||
|
});
|
||
|
|
||
|
const polygonGraphicsLayer = new GraphicsLayer({
|
||
|
title: 'Grundstücksgrenze',
|
||
|
listMode: 'hide',
|
||
|
});
|
||
|
|
||
|
const highlightGraphicsLayer = new GraphicsLayer({
|
||
|
title: 'Grundstücksgrenze',
|
||
|
listMode: 'hide',
|
||
|
});
|
||
|
|
||
|
// thematic layers
|
||
|
const ampelkarte_ews = new FeatureLayer({
|
||
|
url: AMPEL_EWS_URL + '/0',
|
||
|
title: 'Mögliche Einschränkungen',
|
||
|
visible: false,
|
||
|
listMode: 'show',
|
||
|
opacity: 0.5,
|
||
|
});
|
||
|
|
||
|
const ews = new MapImageLayer({
|
||
|
title: 'Ressourcen',
|
||
|
url: RESOURCES_EWS_URL,
|
||
|
visible: false,
|
||
|
listMode: 'show',
|
||
|
});
|
||
|
|
||
|
// basemap in Viennese coordinate system due to tranformation inaccuracies from MGI to WGS84
|
||
|
// default transformation in ArcGIS API from MGI to WGS84 is 1306
|
||
|
// transformation 1618 is recommended
|
||
|
const basemap_at = new WMTSLayer({
|
||
|
url: BASEMAP_AT_URL,
|
||
|
activeLayer: {
|
||
|
id: 'geolandbasemap',
|
||
|
},
|
||
|
listMode: 'hide',
|
||
|
serviceMode: 'KVP',
|
||
|
});
|
||
|
|
||
|
let basemap = new Basemap({
|
||
|
baseLayers: [basemap_at],
|
||
|
title: 'basemap',
|
||
|
id: 'basemap',
|
||
|
spatialReference: { wkid: SRS },
|
||
|
});
|
||
|
|
||
|
let arcgisMap = new ArcGISMap({
|
||
|
basemap: basemap,
|
||
|
layers: [
|
||
|
ews,
|
||
|
ampelkarte_ews,
|
||
|
pointGraphicsLayer,
|
||
|
boundaryGraphicsLayer,
|
||
|
polygonGraphicsLayer,
|
||
|
highlightGraphicsLayer,
|
||
|
viennaGraphicsLayer,
|
||
|
],
|
||
|
});
|
||
|
|
||
|
const search = new Search({
|
||
|
view,
|
||
|
popupEnabled: true,
|
||
|
});
|
||
|
|
||
|
const scaleBar = new ScaleBar({
|
||
|
view: view,
|
||
|
unit: 'metric',
|
||
|
});
|
||
|
|
||
|
const zoom = new Zoom({
|
||
|
view,
|
||
|
layout: 'horizontal',
|
||
|
});
|
||
|
|
||
|
let sketch = new Sketch({
|
||
|
// container: calculationsMenuRef.current,
|
||
|
layer: pointGraphicsLayer,
|
||
|
view: view,
|
||
|
availableCreateTools: ['point'],
|
||
|
visibleElements: {
|
||
|
selectionTools: {
|
||
|
'lasso-selection': true,
|
||
|
'rectangle-selection': true,
|
||
|
},
|
||
|
settingsMenu: false,
|
||
|
undoRedoMenu: false,
|
||
|
},
|
||
|
creationMode: 'single',
|
||
|
});
|
||
|
|
||
|
if (loaded.current) {
|
||
|
sketch.container = calculationsMenuRef.current;
|
||
|
}
|
||
|
loaded.current = true;
|
||
|
|
||
|
sketch.on('create', (event) => {
|
||
|
if (event.tool === 'point' && event.state === 'complete') {
|
||
|
// point symbol
|
||
|
const symbol = {
|
||
|
type: 'simple-marker',
|
||
|
color: [255, 255, 255, 0.25],
|
||
|
};
|
||
|
|
||
|
// delete default point symbol
|
||
|
pointGraphicsLayer.remove(event.graphic);
|
||
|
|
||
|
let point = event.graphic.geometry as Point;
|
||
|
let points;
|
||
|
|
||
|
// add point to current list of points
|
||
|
setPoints((currentPoints) => {
|
||
|
points = [...currentPoints, [point.x, point.y]];
|
||
|
return points;
|
||
|
});
|
||
|
|
||
|
// check if point is too close to any other point
|
||
|
pointGraphicsLayer.graphics.forEach((graphic) => {
|
||
|
if (geometryEngine.distance(graphic.geometry, point) < 5) {
|
||
|
setClosenessWarning(true);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// check if point is outside the parcel
|
||
|
if (!geometryEngine.intersects(point, boundaryGraphicsLayer.graphics.at(0).geometry)) {
|
||
|
setOutsideWarning(true);
|
||
|
}
|
||
|
|
||
|
// draw point graphic
|
||
|
pointGraphicsLayer.add(
|
||
|
new Graphic({
|
||
|
geometry: point,
|
||
|
symbol,
|
||
|
})
|
||
|
);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
sketch.on('update', (event) => {
|
||
|
if (event.state === 'start') {
|
||
|
// on first load update starts with two identical points
|
||
|
// cancel second update
|
||
|
if (sketch.updateGraphics.length === 2 && sketch.updateGraphics.at(0) === sketch.updateGraphics.at(1)) {
|
||
|
sketch.cancel();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (event.state === 'complete') {
|
||
|
// list of updated points
|
||
|
let allPoints = pointGraphicsLayer.graphics.map((graphic) => graphic.geometry as Point);
|
||
|
|
||
|
// update state of calculations menu
|
||
|
setPoints(allPoints.map((point) => [point.x, point.y]).toArray());
|
||
|
|
||
|
// check if all points are inside the boundary
|
||
|
let allPointsInside = true;
|
||
|
allPoints.forEach((point) => {
|
||
|
if (!geometryEngine.intersects(point, boundaryGraphicsLayer.graphics.at(0).geometry)) {
|
||
|
allPointsInside = false;
|
||
|
return;
|
||
|
}
|
||
|
});
|
||
|
|
||
|
setOutsideWarning(!allPointsInside);
|
||
|
|
||
|
// check if points are too close
|
||
|
let tooClose = false;
|
||
|
allPoints.forEach((firstPoint) => {
|
||
|
allPoints.forEach((secondPoint) => {
|
||
|
if (secondPoint !== firstPoint) {
|
||
|
if (geometryEngine.distance(firstPoint, secondPoint, 'meters') <= 4.9) {
|
||
|
tooClose = true;
|
||
|
return;
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
});
|
||
|
|
||
|
setClosenessWarning(tooClose);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// register event handler for mouse clicks
|
||
|
view.on('click', ({ mapPoint }) => {
|
||
|
// at application start if no polygon is drawn yet
|
||
|
if (polygonGraphicsLayer.graphics.length === 0) {
|
||
|
startNewQuery(mapPoint);
|
||
|
} else {
|
||
|
// check if point is selected
|
||
|
view
|
||
|
.hitTest(view.toScreen(mapPoint), {
|
||
|
include: pointGraphicsLayer,
|
||
|
})
|
||
|
.then(({ results }) => {
|
||
|
if (results && results.length > 0) {
|
||
|
// if point was selected then start update
|
||
|
const pointGraphic = (results.at(0) as any)?.graphic;
|
||
|
sketch.update(pointGraphic);
|
||
|
} else {
|
||
|
if (
|
||
|
!geometryEngine.intersects(mapPoint, polygonGraphicsLayer.graphics.at(0).geometry) &&
|
||
|
view.scale < scaleLimit
|
||
|
) {
|
||
|
// if click was outside parcel and no point was selected then start new query
|
||
|
view.openPopup({
|
||
|
title: 'Wollen Sie ein neues Grundstück auswählen?',
|
||
|
location: mapPoint,
|
||
|
content: getPopupContent(mapPoint),
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
|
||
|
// listen to move event
|
||
|
view.on('pointer-move', (event) => {
|
||
|
let mapPoint = view.toMap({ x: event.x, y: event.y });
|
||
|
view
|
||
|
.hitTest(view.toScreen(mapPoint), {
|
||
|
include: pointGraphicsLayer,
|
||
|
})
|
||
|
.then(({ results }) => {
|
||
|
// if user hovers over a point
|
||
|
if (results.length > 0 && event.buttons === 0 && !sketch.createGraphic) {
|
||
|
const graphicHits = results?.filter((hitResult) => hitResult.type === 'graphic');
|
||
|
|
||
|
let graphic = (graphicHits.at(0) as any)?.graphic;
|
||
|
|
||
|
sketch.update(graphic);
|
||
|
|
||
|
let pointGraphic = new Graphic({
|
||
|
geometry: graphic.geometry,
|
||
|
symbol: {
|
||
|
type: 'simple-marker',
|
||
|
size: '30px',
|
||
|
color: null,
|
||
|
outline: { color: '#00ffff' },
|
||
|
} as unknown as SimpleMarkerSymbol,
|
||
|
});
|
||
|
highlightGraphicsLayer.add(pointGraphic);
|
||
|
document.body.style.cursor = 'pointer';
|
||
|
} else {
|
||
|
highlightGraphicsLayer.removeAll();
|
||
|
document.body.style.cursor = 'default';
|
||
|
}
|
||
|
});
|
||
|
});
|
||
|
|
||
|
const getPopupContent = (mapPoint: Point) => {
|
||
|
const popupContent = document.createElement('div');
|
||
|
popupContent.className = 'flex justify-center';
|
||
|
|
||
|
const popupButton = document.createElement('button');
|
||
|
popupButton.textContent = 'Ja';
|
||
|
popupButton.className = 'text-white bg-gray-700 p-2 border-none outline-none cursor-pointer w-2/3';
|
||
|
|
||
|
popupButton.addEventListener('mouseover', handleMouseOver);
|
||
|
function handleMouseOver(this: HTMLButtonElement) {
|
||
|
this.className = 'text-white bg-gray-300 p-2 border-none outline-none cursor-pointer w-2/3';
|
||
|
}
|
||
|
|
||
|
popupButton.addEventListener('mouseout', handleMouseOut);
|
||
|
function handleMouseOut(this: HTMLButtonElement) {
|
||
|
this.className = 'text-white bg-gray-700 p-2 border-none outline-none cursor-pointer w-2/3';
|
||
|
}
|
||
|
|
||
|
popupContent.append(popupButton);
|
||
|
|
||
|
popupButton.onclick = () => {
|
||
|
startNewQuery(mapPoint);
|
||
|
view.popup.close();
|
||
|
};
|
||
|
|
||
|
return popupContent;
|
||
|
};
|
||
|
|
||
|
const startNewQuery = (mapPoint: Point) => {
|
||
|
// remove existing graphics
|
||
|
polygonGraphicsLayer.removeAll();
|
||
|
boundaryGraphicsLayer.removeAll();
|
||
|
pointGraphicsLayer.removeAll();
|
||
|
|
||
|
// clear state
|
||
|
setClosenessWarning(false);
|
||
|
setOutsideWarning(false);
|
||
|
setPolygon(null);
|
||
|
setPoints([]);
|
||
|
|
||
|
// initialize store in case there was a previous computation
|
||
|
dispatch(updateCadastralData({}));
|
||
|
dispatch(updateComputationResultEWS({}));
|
||
|
|
||
|
// start new layer queries
|
||
|
identifyAllLayers(view, mapPoint, dispatch, 'EWS');
|
||
|
getAddress(mapPoint, setAddress);
|
||
|
|
||
|
if (view.scale < scaleLimit) {
|
||
|
// query cadastral data
|
||
|
queryCadastre(view, polygonGraphicsLayer, mapPoint, dispatch, setPolygon, setPoints, gridSpacing);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
// add map to view
|
||
|
view.map = arcgisMap;
|
||
|
|
||
|
// add UI components
|
||
|
view.ui.components = [];
|
||
|
if (!isMobile) {
|
||
|
view.ui.add([zoom, search], 'top-left');
|
||
|
view.ui.add(scaleBar, 'bottom-left');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return () => {
|
||
|
view?.destroy();
|
||
|
handle?.remove();
|
||
|
};
|
||
|
}, [dispatch, isMobile]);
|
||
|
|
||
|
return (
|
||
|
<div ref={mapDiv} className="absolute top-16 bottom-0 w-full">
|
||
|
<PanelPotenzialkarte></PanelPotenzialkarte>
|
||
|
<ReduxProvider>
|
||
|
<CalculationsMenu isLoading={setLoading} ref={calculationsMenuRef}></CalculationsMenu>
|
||
|
</ReduxProvider>
|
||
|
{loading ? (
|
||
|
<div
|
||
|
className="absolute left-1/2 top-1/2 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-gray-950 border-r-transparent"
|
||
|
role="status"
|
||
|
></div>
|
||
|
) : null}
|
||
|
</div>
|
||
|
);
|
||
|
}
|