630 lines
20 KiB
TypeScript
630 lines
20 KiB
TypeScript
import { useRef, useState, useEffect } from 'react';
|
|
import { Root, createRoot } from 'react-dom/client';
|
|
|
|
import { useTranslation } from 'react-i18next';
|
|
import initI18n from './i18n';
|
|
|
|
// 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 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 });
|
|
|
|
// 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[];
|
|
}
|
|
|
|
// load localized strings
|
|
const match = location.pathname.match(/\/(\w+)/);
|
|
if (match && match.length > 1) {
|
|
const locale = match[1];
|
|
initI18n(locale);
|
|
}
|
|
|
|
// 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;
|
|
|
|
if (geometry.x && geometry.y) {
|
|
view.goTo(
|
|
new Point({
|
|
x: geometry.x,
|
|
y: geometry.y,
|
|
spatialReference: sr,
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (geometry.paths) {
|
|
view.goTo(
|
|
new Polyline({
|
|
paths: geometry.paths,
|
|
spatialReference: sr,
|
|
})
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (geometry.rings) {
|
|
view.goTo(
|
|
new Polygon({
|
|
rings: geometry.rings,
|
|
spatialReference: sr,
|
|
})
|
|
);
|
|
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;
|
|
}
|