geosphere-maps/app/[locale]/search.tsx

673 lines
22 KiB
TypeScript

import { useRef, useState, useEffect } 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';
// create feaure layer from URL of data index layer
const datenIndexURL = 'https://gis.geosphere.at/maps/rest/services/datenindex/raster_5000/MapServer/0';
const indexLayer = new FeatureLayer({
url: datenIndexURL,
title: 'Datenindex 1:5.000',
opacity: 0,
legendEnabled: false,
});
// 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[];
}
// custom React component
export default function SearchComponent({ view }: { view: MapView }) {
const [currentTarget, setCurrentTarget] = useState<Graphic | null>(null);
const rendered = useRef<boolean>(false);
const currentGraphicRef = useRef<Graphic | null>(null);
currentGraphicRef.current = currentTarget;
const { t } = useTranslation();
// 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;
}
}
}
};
// 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;
}
};
// 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)}`;
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;
}
},
});
// create query from cellcode for 3x3 neighbourhood
const createQueryFromCellcode = (cellcode: string) => {
const { north, east }: any = cellcode.match(/N(?<north>\d+)E(?<east>\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 = '10kmN${northNumber + operation[0]}E${eastNumber + operation[1]}'`
);
return `cellcode = '${cellcode}' OR ` + cellcodeQueries.join(' OR ');
};
useEffect(() => {
if (rendered.current) return;
rendered.current = true;
// 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,
});
view.ui.add(search, 'top-left');
// add event handler for select-result events
search.on('select-result', (event) => {
view.closePopup();
// 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);
}
view.graphics.removeAll();
view.graphics.add(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() : '';
// create UI item for sublayer
const accordionItem = (
<CalciteAccordionItem heading={sublayer?.title} key={sublayer?.id}>
<CalciteAction
slot="actions-end"
icon={sublayer?.visible ? 'view-visible' : 'view-hide'}
text={text}
appearance="transparent"
onClick={() => {
if (sublayer) {
sublayer.visible = !sublayer.visible;
}
}}
></CalciteAction>
<CalciteList>
{relatedFeatureURLs.map((relatedFeatureURL) => {
const regex = /\d+$/g;
const matches = relatedFeatureURL.match(regex);
let featureId;
if (matches && matches.length > 0) featureId = matches[0];
return (
<CalciteListItem key={featureId}>
<CalciteAction
slot="actions-start"
icon="magnifying-glass"
text={relatedFeatureURL}
appearance="transparent"
onClick={handleZoomTo}
></CalciteAction>
<CalciteLink
href={relatedFeatureURL}
target="_blank"
slot="content"
>{`Feature ${featureId}`}</CalciteLink>
</CalciteListItem>
);
})}
</CalciteList>
</CalciteAccordionItem>
);
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'
))) ||
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 (
<CalciteAccordionItem heading={layer.title} key={layer.id}>
<CalciteAction
slot="actions-end"
icon={layer.visible ? 'view-visible' : 'view-hide'}
text={layer.id}
appearance="transparent"
onClick={handleToggleVisibility}
></CalciteAction>
{(layer as CustomGroupLayer).layers &&
(layer as CustomGroupLayer).layers.map((sublayer) => buildLayerTree(sublayer))}
</CalciteAccordionItem>
);
} else if (layer.type === 'map-image') {
return (
<CalciteAccordionItem heading={layer.title} key={layer.id}>
<CalciteAction
slot="actions-end"
icon={layer.visible ? 'view-visible' : 'view-hide'}
text={layer.id}
appearance="transparent"
onClick={handleToggleVisibility}
></CalciteAction>
{relatedFeatures[layer.id]}
</CalciteAccordionItem>
);
}
};
const layertree = (
<CalciteAccordion appearance="transparent" iconPosition="start" scale="s">
{remainingLayers.map((layer: CustomGroupLayer | CustomLayer) => buildLayerTree(layer))}
</CalciteAccordion>
);
// 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;
}