diff --git a/src/frontend/src/components/MapComponent/OpenLayersComponent/Layers/ClusterLayer.js b/src/frontend/src/components/MapComponent/OpenLayersComponent/Layers/ClusterLayer.js new file mode 100644 index 0000000000..dbbb4b9c3a --- /dev/null +++ b/src/frontend/src/components/MapComponent/OpenLayersComponent/Layers/ClusterLayer.js @@ -0,0 +1,168 @@ +import { useEffect, useState } from 'react'; +import AnimatedCluster from 'ol-ext/layer/AnimatedCluster'; +import * as olExtent from 'ol/extent'; +import GeoJSON from 'ol/format/GeoJSON'; +import Stroke from 'ol/style/Stroke'; +import Style from 'ol/style/Style'; +import Fill from 'ol/style/Fill'; +import { Cluster, OSM as OSMSource } from 'ol/source'; +import { Text, Circle, Icon } from 'ol/style'; +import VectorSource from 'ol/source/Vector'; +import { hexToRgba } from '../../../MapComponent/OpenLayersComponent/helpers/styleUtils'; + +function setAsyncStyle(style, feature, getIndividualStyle) { + const styleCache = {}; + const size = feature?.get('features')?.length; + let stylex = styleCache[size]; + if (size === 1) { + const featureProperty = feature?.get('features')[0].getProperties(); + stylex = getIndividualStyle(featureProperty); + styleCache[size] = stylex; + return stylex; + } else { + stylex = new Style({ + image: new Circle({ + radius: 23, + stroke: new Stroke({ + color: hexToRgba(style.color, style.opacity || 100), + width: 6, + }), + fill: new Fill({ + color: hexToRgba(style.background_color, style.opacity || 100), + width: 40, + }), + }), + text: new Text({ + text: size.toString(), + fill: new Fill({ + color: '#fff', + }), + font: '16px Arial', + }), + }); + styleCache[size] = stylex; + return stylex; + } +} + +const ClusterLayer = ({ + map, + source: layerSource, + zIndex = 999, + zoomToLayer = true, + visibleOnMap = true, + style, + mapOnClick, + getIndividualStyle, +}) => { + const [vectorLayer, setVectorLayer] = useState(null); + useEffect(() => () => map && vectorLayer && map.removeLayer(vectorLayer), [map, vectorLayer]); + + useEffect(() => { + if (!map || !layerSource || !layerSource.features) return; + const sourceOSM = new OSMSource(); + const vectorSource = new VectorSource({ + features: new GeoJSON().readFeatures(layerSource, { + defaultDataProjection: 'EPSG:3857', + featureProjection: sourceOSM.getProjection(), + }), + }); + + const clusterSource = new Cluster({ + distance: parseInt(50, 10), + source: vectorSource, + }); + + const animatedClusterLayer = new AnimatedCluster({ + source: clusterSource, + animationDuration: 700, + distance: 40, + style: (feature) => setAsyncStyle(style, feature, getIndividualStyle), + }); + + setVectorLayer(animatedClusterLayer); + }, [map, layerSource]); + + useEffect(() => { + if (map && vectorLayer) { + vectorLayer.setZIndex(zIndex); + } + }, [map, vectorLayer, zIndex]); + + useEffect(() => { + if (map && vectorLayer && zoomToLayer) { + setTimeout(() => { + const features = vectorLayer.getSource().getFeatures(); + const extent = olExtent.createEmpty(); + features.forEach((feat) => + feat.values_?.features.forEach((feature) => olExtent.extend(extent, feature.getGeometry().getExtent())), + ); + map.getView().fit(extent, { + padding: [50, 50, 50, 50], + }); + }, 300); + } + }, [map, vectorLayer, zoomToLayer]); + + useEffect(() => { + if (!map) return; + map.on('singleclick', (evt) => { + let area_no_9_extent = null; + map.forEachFeatureAtPixel( + evt.pixel, + (featurex) => { + area_no_9_extent = featurex.getGeometry().getExtent(); + return featurex; + }, + true, + ); + if (area_no_9_extent) { + map.getView().fit(area_no_9_extent, { + duration: 1000, + padding: [50, 50, 50, 50], + // maxZoom: 11, + }); + } + }); + + return () => { + map.un('singleclick', () => {}); + }; + }, [map]); + + useEffect(() => { + if (!map) return; + + map.on('singleclick', (evt) => { + const features = map.getFeaturesAtPixel(evt.pixel); + if (features.length > 0) { + const featureProperties = features[0].getProperties(); + const feature = featureProperties.features[0].getProperties(); + if (featureProperties.features.length === 1) { + mapOnClick(feature); + } else { + return; + } + } + }); + + return () => { + map.un('singleclick', () => {}); + }; + }, [map]); + + useEffect(() => { + if (!map || !vectorLayer) return; + if (visibleOnMap) { + map.addLayer(vectorLayer); + } else { + map.removeLayer(vectorLayer); + } + }, [map, vectorLayer, visibleOnMap]); + + useEffect(() => () => map && map.removeLayer(vectorLayer), [map, vectorLayer]); + + return null; +}; + +export default ClusterLayer; diff --git a/src/frontend/src/components/MapComponent/OpenLayersComponent/Layers/index.js b/src/frontend/src/components/MapComponent/OpenLayersComponent/Layers/index.js index 8292e0e2a4..da4c7b0197 100644 --- a/src/frontend/src/components/MapComponent/OpenLayersComponent/Layers/index.js +++ b/src/frontend/src/components/MapComponent/OpenLayersComponent/Layers/index.js @@ -1,3 +1,4 @@ export { default as VectorTileLayer } from './VectorTileLayer'; export { default as VectorLayer } from './VectorLayer'; +export { default as ClusterLayer } from './ClusterLayer';