import { useRef, useState, useEffect, MouseEventHandler } from 'react'; import { Root, createRoot } from 'react-dom/client'; import { useTranslation } from 'react-i18next'; // import Calcite components import '@esri/calcite-components/dist/calcite/calcite.css'; import { setAssetPath } from '@esri/calcite-components/dist/components'; setAssetPath(window.location.href); import '@esri/calcite-components/dist/components/calcite-accordion'; import '@esri/calcite-components/dist/components/calcite-accordion-item'; import '@esri/calcite-components/dist/components/calcite-link'; import '@esri/calcite-components/dist/components/calcite-list'; import '@esri/calcite-components/dist/components/calcite-list-item'; import '@esri/calcite-components/dist/components/calcite-action'; import { CalciteAccordion, CalciteAccordionItem, CalciteLink, CalciteList, CalciteListItem, CalciteAction, } from '@esri/calcite-components-react'; import MapView from '@arcgis/core/views/MapView'; import Search from '@arcgis/core/widgets/Search'; import FeatureLayer from '@arcgis/core/layers/FeatureLayer'; import Graphic from '@arcgis/core/Graphic'; import { eachAlways } from '@arcgis/core/core/promiseUtils.js'; import SearchSource from '@arcgis/core/widgets/Search/SearchSource'; import Point from '@arcgis/core/geometry/Point'; import Polyline from '@arcgis/core/geometry/Polyline'; import Polygon from '@arcgis/core/geometry/Polygon'; import { geodesicBuffer } from '@arcgis/core/geometry/geometryEngine'; import MapImageLayer from '@arcgis/core/layers/MapImageLayer'; import SimpleFillSymbol from '@arcgis/core/symbols/SimpleFillSymbol'; import SimpleLineSymbol from '@arcgis/core/symbols/SimpleLineSymbol'; import SimpleMarkerSymbol from '@arcgis/core/symbols/SimpleMarkerSymbol'; import Sublayer from '@arcgis/core/layers/support/Sublayer'; import PopupTemplate from '@arcgis/core/PopupTemplate'; import { watch } from '@arcgis/core/core/reactiveUtils'; // create feaure layer from URL of data index layer const datenIndexURL = 'https://gis.geosphere.at/maps/rest/services/datenindex/raster_1000/MapServer/0'; const indexLayer = new FeatureLayer({ url: datenIndexURL, title: 'Datenindex 1:1.000', opacity: 1, legendEnabled: false, visible: false, listMode: 'hide', }); // custom type definitions interface CustomLayer { id: string; type: string; title: string; visible: boolean; } interface CustomGroupLayer { id: string; type: string; title: string; visible: boolean; layers: (CustomGroupLayer | CustomLayer)[]; } interface LayerToFeaturesMap { [key: string]: string[]; } // create query from cellcode for 3x3 neighbourhood const createQueryFromCellcode = (cellcode: string) => { const { north, east }: any = cellcode.match(/N(?\d+)E(?\d+)/)?.groups; const northNumber = parseInt(north); const eastNumber = parseInt(east); const operations = [ [1, -1], [1, 0], [1, 1], [0, -1], [0, 1], [-1, -1], [-1, 0], [-1, 1], ]; const cellcodeQueries = operations.map( (operation) => `cellcode = '1kmN${northNumber + operation[0]}E${eastNumber + operation[1]}'` ); return `cellcode = '${cellcode}' OR ` + cellcodeQueries.join(' OR '); }; // remove layers from layer tree by given filter const removeLayers = (layers: (CustomGroupLayer | CustomLayer)[], keepLayer: any): any => { return layers .filter((layer) => keepLayer(layer)) .map((layer) => { if (layer.type === 'group' && (layer as CustomGroupLayer).layers) { return { ...layer, layers: removeLayers((layer as CustomGroupLayer).layers, keepLayer) }; } else { return layer; } }); }; // get custom layer objects from layer tree const getLayerObjects = (layers: any) => { return layers.map((layer: any) => { if (layer.layers) { return { id: layer.id, type: layer.type, title: layer.title, visible: layer.visible, layers: getLayerObjects(layer.layers.toArray()), }; } else { return { id: layer.id, type: layer.type, title: layer.title, visible: layer.visible }; } }); }; // build query string for custom search source (BEV geocoding service) const buildQueryString = (searchTerm: string) => `?term=${encodeURI(searchTerm)}`; // custom React component export default function SearchComponent({ view }: { view: MapView }) { const [currentTarget, setCurrentTarget] = useState(null); const rendered = useRef(false); const currentGraphicRef = useRef(null); currentGraphicRef.current = currentTarget; const { t } = useTranslation(); const url = 'https://kataster.bev.gv.at/api/all4map'; const customSearchSource = new SearchSource({ placeholder: t('search.placeholder'), // provide suggestions to the Search widget getSuggestions: async (params) => { const res = await fetch(url + buildQueryString(params.suggestTerm)); const json = await res.json(); if (json.data && json.data.features?.length > 0) { return json.data.features.map((feature: any) => { return { key: 'bev', text: feature.properties.name, sourceIndex: params.sourceIndex, }; }); } }, // find results from suggestions getResults: async (params) => { const res = await fetch(url + buildQueryString(params.suggestResult.text)); const json = await res.json(); if (json.data && json.data.features?.length > 0) { const searchResults = json.data.features.map((feature: any) => { // create a graphic for the search widget if (feature.geometry.type === 'Point') { const graphic = new Graphic({ geometry: new Point({ x: feature.geometry.coordinates[0], y: feature.geometry.coordinates[1], spatialReference: { wkid: 4326, }, }), symbol: { type: 'simple-marker', style: 'circle', color: [51, 51, 204, 0.5], size: '8px', outline: { color: 'white', width: 1, }, } as unknown as SimpleMarkerSymbol, }); return { extent: null, feature: graphic, target: new Graphic({ // create buffer for point geometries to allow zoom to geometry: geodesicBuffer(graphic.geometry, 1000, 'meters') as Polygon, }), name: feature.properties.name, }; } else { const coords = feature.geometry.coordinates; const graphic = new Graphic({ geometry: new Polygon({ rings: coords, spatialReference: { wkid: 4326, }, }), symbol: { type: 'simple-fill', color: [51, 51, 204, 0.5], style: 'solid', outline: { color: 'white', width: 1, }, } as unknown as SimpleFillSymbol, }); return { extent: graphic.geometry.extent, feature: graphic, target: graphic, name: feature.properties.name, }; } }); // return search results return searchResults; } }, }); // get map image layer from sublayer const getMapImageLayer = (layerURL: string): MapImageLayer | undefined => { if (view) { const filteredLayerViews = view.allLayerViews.filter((layerView) => { const regex = /^https:\/\/.+\/MapServer/g; const matches = layerURL.match(regex); let mapImageLayerURL; if (matches && matches.length > 0) mapImageLayerURL = matches[0]; if (layerView.layer.type === 'map-image') { return (layerView.layer as MapImageLayer).url === mapImageLayerURL; } else { return false; } }); let mapImageLayer; if (filteredLayerViews.length > 0) { mapImageLayer = filteredLayerViews.at(0).layer as MapImageLayer; } return mapImageLayer; } }; // handle toggle layer visibility const handleToggleVisibility = (event: any) => { const layerItem = event.target; const layerId = layerItem.getAttribute('text'); const icon = layerItem.getAttribute('icon'); if (layerId) { const layer = view.map?.findLayerById(layerId); if (icon === 'view-hide') { layerItem.setAttribute('icon', 'view-visible'); if (layer) { layer.visible = true; } } else { layerItem.setAttribute('icon', 'view-hide'); if (layer) { layer.visible = false; } } } }; useEffect(() => { // handle zoom to feature const handleZoomTo = async (event: any) => { const layerURL = event.target.getAttribute('text'); const res = await fetch(layerURL + '?f=json'); const json = await res.json(); const mapImageLayer = getMapImageLayer(layerURL); const sr = mapImageLayer?.spatialReference; const geometry = json.feature?.geometry; view.graphics.removeAll(); const higlightOptions = view.highlightOptions; if (geometry.x && geometry.y && higlightOptions && higlightOptions.color && higlightOptions.fillOpacity) { const point = new Point({ x: geometry.x, y: geometry.y, spatialReference: sr, }); const graphic = new Graphic({ geometry: point, symbol: { type: 'simple-marker', style: 'circle', size: '8px', color: [ higlightOptions.color.r, higlightOptions.color.g, higlightOptions.color.b, higlightOptions.fillOpacity, ], outline: { color: [ higlightOptions.color.r, higlightOptions.color.g, higlightOptions.color.b, higlightOptions.color.a, ], width: 1, }, } as unknown as SimpleMarkerSymbol, }); view.graphics.add(graphic); view.goTo(geodesicBuffer(point, 1000, 'meters')); return; } if (geometry.paths && higlightOptions && higlightOptions.color && higlightOptions.fillOpacity) { const polyline = new Polyline({ paths: geometry.paths, spatialReference: sr, }); const graphic = new Graphic({ geometry: polyline, symbol: { type: 'simple-line', color: [higlightOptions.color.r, higlightOptions.color.g, higlightOptions.color.b], width: '2px', } as unknown as SimpleLineSymbol, }); view.graphics.add(graphic); view.goTo(geodesicBuffer(polyline.extent.center, 5000, 'meters')); return; } if (geometry.rings && higlightOptions && higlightOptions.color && higlightOptions.fillOpacity) { const polygon = new Polygon({ rings: geometry.rings, spatialReference: sr, }); const graphic = new Graphic({ geometry: polygon, symbol: { type: 'simple-fill', color: [ higlightOptions.color.r, higlightOptions.color.g, higlightOptions.color.b, higlightOptions.fillOpacity, ], outline: { color: [ higlightOptions.color.r, higlightOptions.color.g, higlightOptions.color.b, higlightOptions.color.a, ], width: 1, }, } as unknown as SimpleFillSymbol, }); view.graphics.add(graphic); view.goTo(polygon); return; } }; // watch for visibility changes const handle = watch( () => view.map.allLayers.map((layer) => [layer.id, layer.visible]), (newValues, oldValues) => { newValues.forEach((value, key) => { // visibility changed if (oldValues.at(key) && value[1] !== oldValues.at(key)[1]) { const layerId = value[0] as string; const calciteAction = document.querySelector(`calcite-action[text='${layerId}']`); if (calciteAction) { if (value[1]) { calciteAction.setAttribute('icon', 'view-visible'); } else { calciteAction.setAttribute('icon', 'view-hide'); } } } }); } ); // add data index layer view.map.layers.push(indexLayer); // add search widget with custom search source const search = new Search({ view: view, popupEnabled: false, sources: [customSearchSource], includeDefaultSources: false, }); // empty top-left corner of MapView for Search component view.ui.add(search, 'top-left'); // add event handler for select-result events search.on('select-result', (event) => { view.closePopup(); view.graphics.removeAll(); // get selected feature and display it on map const graphic = event.result.feature; if (graphic.geometry.type === 'point') { setCurrentTarget( new Graphic({ // create buffer for point geometries to allow zoom to geometry: geodesicBuffer(graphic.geometry, 1000, 'meters') as Polygon, }) ); } else { setCurrentTarget(graphic); } // query for intersecting features indexLayer .queryFeatures({ geometry: graphic.geometry, spatialRelationship: 'intersects', returnGeometry: false, outFields: ['fid', 'cellcode'], }) .then((featureSet) => { if (featureSet.features.length === 0) { return; } // if feature has an extent just return intersecting raster cells // otherwise create a query including a 3x3 neighbourhood if (event.result.extent) { const objectIds = featureSet.features.map((feature) => feature.attributes['fid'] as number); queryRelatedFeaturesFromOIds(objectIds, false); } else { const cellcode = featureSet.features[0].attributes['cellcode']; const query = createQueryFromCellcode(cellcode); indexLayer .queryFeatures({ where: query, returnGeometry: false, outFields: ['fid'], }) .then((resultSet) => { const objectIds = resultSet.features.map((feature) => feature.attributes['fid']); queryRelatedFeaturesFromOIds(objectIds, false); }); } }); }); // for each raster cell given by its OID and it each relationship class // of the index layer - query related features const queryRelatedFeaturesFromOIds = (objectIds: number[], isPopupContent: boolean) => { const relatedFeaturesByLayer: LayerToFeaturesMap = {}; const promises = indexLayer.relationships.map((relationship) => { return indexLayer .queryRelatedFeatures({ outFields: ['url'], relationshipId: relationship.id, objectIds: objectIds, }) .then((relatedFeatureSets) => { Object.keys(relatedFeatureSets).forEach((objectId) => { if (!relatedFeatureSets[objectId]) { return; } const regex = /^https:\/\/.+\/MapServer\/\d+/g; const relatedFeatures = relatedFeatureSets[objectId].features; // get url of related feature layer let layerURL: string = ''; if (relatedFeatures.length > 0) { const matches = relatedFeatures[0].attributes.url.match(regex); if (matches.length > 0) { layerURL = matches[0]; } } const urls = relatedFeatures.map((feature: any) => feature.attributes.url); if (relatedFeaturesByLayer[layerURL]) { relatedFeaturesByLayer[layerURL] = relatedFeaturesByLayer[layerURL].concat(urls); } else { relatedFeaturesByLayer[layerURL] = urls; } }); }); }); // wait until all promises are fulfilled return eachAlways(promises).then(() => { const relatedFeatures: { [key: string]: JSX.Element[] } = {}; const mapImageLayers: MapImageLayer[] = []; Object.keys(relatedFeaturesByLayer).forEach((layerURL) => { const relatedFeatureURLs = [...new Set(relatedFeaturesByLayer[layerURL])]; const mapImageLayer = getMapImageLayer(layerURL); let mapImageLayerId: string = ''; let sublayer: Sublayer | undefined; if (mapImageLayer) { mapImageLayerId = mapImageLayer.id; mapImageLayers.push(mapImageLayer); mapImageLayer.sublayers.forEach((layer) => { if (layer.url === layerURL) { sublayer = layer; } }); } const text = sublayer?.id ? sublayer?.id.toString() : ''; let sublayerWasVisible: boolean = false; let mapImageLayerWasVisible: boolean = false; const handleMouseEnter: MouseEventHandler = (event) => { (event.target as HTMLElement).classList.add('bg-gray-100'); if (mapImageLayer && !mapImageLayer.visible) { mapImageLayerWasVisible = false; mapImageLayer.visible = true; } else { mapImageLayerWasVisible = true; } if (sublayer && !sublayer.visible) { sublayerWasVisible = false; sublayer.visible = true; } }; const handleMouseLeave: MouseEventHandler = (event) => { (event.target as HTMLElement).classList.remove('bg-gray-100'); if (mapImageLayer && !mapImageLayerWasVisible) { mapImageLayer.visible = false; } if (sublayer && !sublayerWasVisible) { sublayer.visible = false; } }; const handleSublayerVisibilityToggle: MouseEventHandler = (event: any) => { if (mapImageLayer && sublayer) { if (event.target?.getAttribute('icon') === 'view-hide') { mapImageLayerWasVisible = true; mapImageLayer.visible = true; } if (event.target?.getAttribute('icon') === 'view-visible') { event.target?.setAttribute('icon', 'view-hide'); sublayer.visible = false; sublayerWasVisible = false; } else { event.target?.setAttribute('icon', 'view-visible'); sublayer.visible = true; sublayerWasVisible = true; } } }; // create UI item for sublayer const accordionItem = ( {relatedFeatureURLs.map((relatedFeatureURL) => { const regex = /\d+$/g; const matches = relatedFeatureURL.match(regex); let featureId; if (matches && matches.length > 0) featureId = matches[0]; return ( {`Feature ${featureId}`} ); })} ); if (!relatedFeatures[mapImageLayerId]) { relatedFeatures[mapImageLayerId] = [accordionItem]; } else { relatedFeatures[mapImageLayerId].push(accordionItem); } }); // create cutom layer objects tree const layerObjects = getLayerObjects(view.map.layers.toArray()); // remove layers that are not in the seach result let remainingLayers = removeLayers(layerObjects, (layer: CustomGroupLayer | CustomLayer) => { if ( layer.type === 'map-image' && mapImageLayers.findIndex((mapImageLayer) => mapImageLayer.id === layer.id) === -1 ) { return false; } else { return true; } }); remainingLayers = removeLayers(remainingLayers, (layer: CustomGroupLayer | CustomLayer) => { if ( (layer.type === 'group' && ((layer as CustomGroupLayer).layers.length === 0 || (layer as CustomGroupLayer).layers.every( (child) => child.type === 'imagery' || child.type === 'imagery-tile' || child.type === 'tile' ))) || layer.type === 'feature' || layer.type === 'imagery' || layer.type === 'imagery-tile' ) { return false; } else { return true; } }); // recursively build UI from remaining layers const buildLayerTree = (layer: CustomGroupLayer | CustomLayer) => { if (layer.type === 'group') { return ( {(layer as CustomGroupLayer).layers && (layer as CustomGroupLayer).layers.map((sublayer) => buildLayerTree(sublayer))} ); } else if (layer.type === 'map-image') { return ( {relatedFeatures[layer.id]} ); } }; const layertree = ( {remainingLayers.map((layer: CustomGroupLayer | CustomLayer) => buildLayerTree(layer))} ); // render react components into div element const contentDiv = document.createElement('div'); const root: Root = createRoot(contentDiv); root.render(layertree); if (isPopupContent) { return contentDiv; } else { let location; if (currentGraphicRef.current?.geometry?.type === 'polygon') { location = (currentGraphicRef.current?.geometry as Polygon).centroid; } else { location = currentGraphicRef.current?.geometry; } view.openPopup({ title: t('search.popup-title'), content: contentDiv, location: location, }); } }); }; // create popup content for popup template of index layer const createPopupContent = async (target: any) => { const cellcode = target.graphic.attributes['cellcode']; const query = createQueryFromCellcode(cellcode); const objectIds = await indexLayer .queryFeatures({ where: query, returnGeometry: false, outFields: ['fid'], }) .then((resultSet) => { // return Ids of neighbouring features (3x3 neighbourhood) return resultSet.features.map((feature) => feature.attributes['fid']); }); return queryRelatedFeaturesFromOIds(objectIds, true); }; const popupTemplate = { title: t('search.popup-title'), content: createPopupContent, returnGeometry: false, outFields: [], }; indexLayer.popupTemplate = popupTemplate as unknown as PopupTemplate; }, []); return null; }