diff --git a/src/components/map/mapbox/Map.tsx b/src/components/map/mapbox/Map.tsx index c4c607d0..c68d37d0 100644 --- a/src/components/map/mapbox/Map.tsx +++ b/src/components/map/mapbox/Map.tsx @@ -104,6 +104,7 @@ const ReactMap = ({ style: styles[defaultStyle].style, center: [centerLongitude, centerLatitude], zoom: zoom, + maxZoom: 14, localIdeographFontFamily: "'Noto Sans', 'Noto Sans CJK SC', sans-serif", }) as Map) diff --git a/src/components/map/mapbox/component/SpiderDiagram.tsx b/src/components/map/mapbox/component/SpiderDiagram.tsx new file mode 100644 index 00000000..130f01aa --- /dev/null +++ b/src/components/map/mapbox/component/SpiderDiagram.tsx @@ -0,0 +1,441 @@ +import { FC, useCallback, useContext, useEffect, useState } from "react"; +import MapContext from "../MapContext"; +import { mergeWithDefaults } from "../../../common/utils"; +import { Feature, FeatureCollection, LineString, Point } from "geojson"; +import { createRoot } from "react-dom/client"; +import { GeoJSONSource, MapMouseEvent } from "mapbox-gl"; +import MapPopup from "./MapPopup"; +import SpatialExtents from "./SpatialExtents"; +import { LayersProps } from "../layers/Layers"; + +interface SpiderDiagramConfig { + spiderifyFromZoomLevel: number; + circleSpiralSwitchover: number; + circleFootSeparation: number; + spiralFootSeparation: number; + spiralLengthStart: number; + spiralLengthFactor: number; + lineColor: string; + lineWidth: number; + circleRadius: number; + circleColor: string; + circleOpacity: number; + circleStrokeWidth: number; + circleStrokeColor: string; +} + +interface SpiderDiagramProps extends LayersProps { + spiderDiagramConfig?: Partial; + clusterLayer: string; + clusterSourceId: string; + unclusterPointLayer: string; +} + +const defaultSpiderDiagramConfig: SpiderDiagramConfig = { + spiderifyFromZoomLevel: 11, + circleSpiralSwitchover: 9, + circleFootSeparation: 25, + spiralFootSeparation: 28, + spiralLengthStart: 15, + spiralLengthFactor: 4, + lineColor: "#888", + lineWidth: 1, + circleRadius: 8, + circleColor: "green", + circleOpacity: 1, + circleStrokeWidth: 1, + circleStrokeColor: "#fff", +}; + +const getClusterCircleId = (coordinate: [number, number]) => + `${coordinate[0]},${coordinate[1]}`; + +const getSpiderPinsSourceId = (clusterCircleId: string) => + `spider-pins-source-${clusterCircleId}`; + +const getSpiderLinesSourceId = (clusterCircleId: string) => + `spider-lines-source-${clusterCircleId}`; + +const getSpiderPinsLayerId = (clusterCircleId: string) => + `spider-lines-layer-${clusterCircleId}`; + +const getSpiderLinesLayerId = (clusterCircleId: string) => + `spider-lines-line-${clusterCircleId}`; + +const getSpiderPinId = (clusterId: string, index: number) => + `spider-pin-${clusterId}-${index}`; + +const getSpiderLineId = (spiderPinId: string) => `${spiderPinId}-line`; + +const SpiderDiagram: FC = ({ + spiderDiagramConfig, + clusterLayer, + clusterSourceId, + unclusterPointLayer, + onDatasetSelected, +}) => { + const { map } = useContext(MapContext); + + const config = mergeWithDefaults( + defaultSpiderDiagramConfig, + spiderDiagramConfig + ); + const { spiderifyFromZoomLevel } = config; + + const [currentSpiderifiedCluster, setCurrentSpiderifiedCluster] = useState< + string | null + >(null); + + // util function to check if a cluster can spiderify or not + const shouldCreateSpiderDiagram = useCallback( + (features: any[]): boolean => { + const zoom = map?.getZoom() || 0; + const clusterCount = features[0].properties.point_count; + return ( + (!clusterCount && features.length > 1) || zoom >= spiderifyFromZoomLevel + ); + }, + [map, spiderifyFromZoomLevel] + ); + + const unspiderify = useCallback( + (clusterCircleId: string) => { + const spiderPinsSourceId = getSpiderPinsSourceId(clusterCircleId); + const spiderLinesSourceId = getSpiderLinesSourceId(clusterCircleId); + const spiderPinsLayerId = getSpiderPinsLayerId(clusterCircleId); + const spiderLinesLayerId = getSpiderLinesLayerId(clusterCircleId); + + // Remove layers + if (map?.getLayer(spiderPinsLayerId)) { + map.removeLayer(spiderPinsLayerId); + } + if (map?.getLayer(spiderLinesLayerId)) { + map.removeLayer(spiderLinesLayerId); + } + + // Remove sources + if (map?.getSource(spiderPinsSourceId)) { + map.removeSource(spiderPinsSourceId); + } + if (map?.getSource(spiderLinesSourceId)) { + map.removeSource(spiderLinesSourceId); + } + + // setCurrentSpiderifiedCluster(null); + }, + [map] + ); + + const spiderify = useCallback( + (coordinate: [number, number], datasets: Feature[]) => { + const clusterCircleId = getClusterCircleId(coordinate); + const spiderPinsSourceId = getSpiderPinsSourceId(clusterCircleId); + const spiderLinesSourceId = getSpiderLinesSourceId(clusterCircleId); + const spiderPinsLayerId = getSpiderPinsLayerId(clusterCircleId); + const spiderLinesLayerId = getSpiderLinesLayerId(clusterCircleId); + + // Clear existing spider diagram if there is one + if (currentSpiderifiedCluster) { + unspiderify(currentSpiderifiedCluster); + } + + // If clicking on the same cluster, just clear it and return + if (currentSpiderifiedCluster === clusterCircleId) { + return; + } + + const spiderPinsFeatures: Feature[] = []; + const spiderLinesFeatures: Feature[] = []; + + const currentZoom = map?.getZoom() || 0; + const numPins = datasets.length; + + // Spider configuration + const { + circleSpiralSwitchover, + circleFootSeparation, + spiralFootSeparation, + spiralLengthStart, + spiralLengthFactor, + } = config; + + const generateSpiderLegParams = (count: number) => { + if (count >= circleSpiralSwitchover) { + // Generate spiral + let legLength = spiralLengthStart; + let angle = 0; + return Array.from({ length: count }, (_, index) => { + angle += spiralFootSeparation / legLength + index * 0.0005; + const x = legLength * Math.cos(angle); + const y = legLength * Math.sin(angle); + legLength += (2 * Math.PI * spiralLengthFactor) / angle; + return { x, y, angle, legLength, index }; + }); + } else { + // Generate circle + const circumference = circleFootSeparation * (2 + count); + const legLength = circumference / (2 * Math.PI); + const angleStep = (2 * Math.PI) / count; + return Array.from({ length: count }, (_, index) => { + const angle = index * angleStep; + return { + x: legLength * Math.cos(angle), + y: legLength * Math.sin(angle), + angle, + legLength, + index, + }; + }); + } + }; + + const spiderLegParams = generateSpiderLegParams(numPins); + + datasets.forEach((dataset, i) => { + const spiderLegParam = spiderLegParams[i]; + const spiderPinId = getSpiderPinId(clusterCircleId, i); + const spiderLineId = getSpiderLineId(spiderPinId); + + // Adjust the divisor based on zoom level for finer control + const zoomFactor = Math.pow(2, currentZoom - spiderifyFromZoomLevel); + const coordDivisor = 5000 / zoomFactor; + + const spiderLegCoordinate = [ + coordinate[0] + spiderLegParam.x / coordDivisor, + coordinate[1] + spiderLegParam.y / coordDivisor, + ]; + + // Create spider pin feature + spiderPinsFeatures.push({ + type: "Feature", + geometry: { + type: "Point", + coordinates: spiderLegCoordinate, + }, + properties: { + ...dataset.properties, + spiderPinId: spiderPinId, + }, + }); + + // Create spider line feature + spiderLinesFeatures.push({ + type: "Feature", + geometry: { + type: "LineString", + coordinates: [coordinate, spiderLegCoordinate], + }, + properties: { + spiderLineId: spiderLineId, + }, + }); + }); + + // Create FeatureCollections + const spiderPinsFeatureCollection: FeatureCollection = { + type: "FeatureCollection", + features: spiderPinsFeatures, + }; + + const spiderLinesFeatureCollection: FeatureCollection = { + type: "FeatureCollection", + features: spiderLinesFeatures, + }; + + // Add sources + map?.addSource(spiderPinsSourceId, { + type: "geojson", + data: spiderPinsFeatureCollection, + }); + + map?.addSource(spiderLinesSourceId, { + type: "geojson", + data: spiderLinesFeatureCollection, + }); + + // Add layers + map?.addLayer({ + id: spiderLinesLayerId, + type: "line", + source: spiderLinesSourceId, + paint: { + "line-color": config.lineColor, + "line-width": config.lineWidth, + }, + }); + + map?.addLayer({ + id: spiderPinsLayerId, + type: "circle", + source: spiderPinsSourceId, + paint: { + "circle-radius": config.circleRadius, + "circle-color": config.circleColor, + "circle-opacity": config.circleOpacity, + "circle-stroke-width": config.circleStrokeWidth, + "circle-stroke-color": config.circleStrokeColor, + }, + }); + + setCurrentSpiderifiedCluster(clusterCircleId); + }, + [ + currentSpiderifiedCluster, + map, + config, + unspiderify, + spiderifyFromZoomLevel, + ] + ); + + const checkZoomAndUnspiderify = useCallback(() => { + if (map) { + const currentZoom = map.getZoom(); + setCurrentSpiderifiedCluster((currentCluster) => { + if (currentCluster && currentZoom < spiderifyFromZoomLevel) { + unspiderify(currentCluster); + return currentCluster; + } + + return currentCluster; + }); + } + }, [map, spiderifyFromZoomLevel, unspiderify]); + + // for clear spider diagram when click on empty space + const onEmptySpaceClick = useCallback( + (ev: MapMouseEvent) => { + const point = ev.point; + + setCurrentSpiderifiedCluster((currentCluster) => { + if (currentCluster) { + const spiderPinsLayerId = getSpiderPinsLayerId(currentCluster); + + if (!map?.getLayer(spiderPinsLayerId)) return null; + const features = map?.queryRenderedFeatures(point, { + layers: [spiderPinsLayerId], + }); + + if (!features || features.length === 0) { + unspiderify(currentCluster); + return null; + } else { + return currentCluster; + } + } + + return currentCluster; + }); + }, + [map, unspiderify] + ); + + const onClusterClick = useCallback( + (ev: MapMouseEvent): void => { + const features = map?.queryRenderedFeatures(ev.point, { + layers: [clusterLayer], + }); + + if (!features || features.length === 0) return; + + const feature = features[0] as Feature; + // coordinate of clicked cluster circle + const coordinate = [ + feature.geometry.coordinates[0], + feature.geometry.coordinates[1], + ] as [number, number]; + + // get cluster_id from feature for later query + const clusterId = feature.properties?.cluster_id; + + // get clicked cluster source + const source = map?.getSource(clusterSourceId) as GeoJSONSource; + + if (shouldCreateSpiderDiagram(features)) { + // get all datasets behind the cluster + source.getClusterLeaves(clusterId, 100, 0, (err, leaves) => { + if (err) { + console.error("Error getting cluster leaves:", err); + return; + } + const datasets = leaves as Feature[]; + spiderify(coordinate, datasets); + }); + } else { + source.getClusterExpansionZoom(clusterId, (err, zoom) => { + if (err) return; + + // if expansionZoom level hasn't reach the spiderify-zoomLevel, keep zoom into + // else go to the spiderify-zoomLevel + const currentZoom = map?.getZoom(); + map?.easeTo({ + center: (feature.geometry as any).coordinates, + zoom: + zoom >= spiderifyFromZoomLevel + ? spiderifyFromZoomLevel + : zoom === currentZoom + ? zoom + 1 + : zoom, + duration: 500, + }); + }); + } + }, + [ + map, + clusterLayer, + clusterSourceId, + shouldCreateSpiderDiagram, + spiderify, + spiderifyFromZoomLevel, + ] + ); + + useEffect(() => { + const container = document.createElement("div"); + const root = createRoot(container); + + map?.on("click", clusterLayer, onClusterClick); + map?.on("click", onEmptySpaceClick); + map?.on("zoomend", checkZoomAndUnspiderify); + + return () => { + // Important to free up resources, and must timeout to avoid race condition + setTimeout(() => root.unmount(), 500); + + map?.off("click", clusterLayer, onClusterClick); + map?.off("click", onEmptySpaceClick); + map?.off("zoomend", checkZoomAndUnspiderify); + }; + }, [ + checkZoomAndUnspiderify, + clusterLayer, + map, + onClusterClick, + onEmptySpaceClick, + ]); + + return ( + <> + {currentSpiderifiedCluster && ( + + )} + {currentSpiderifiedCluster && ( + + )} + + ); +}; + +export default SpiderDiagram; diff --git a/src/components/map/mapbox/layers/ClusterLayer.tsx b/src/components/map/mapbox/layers/ClusterLayer.tsx index 78cddf2a..384ff741 100644 --- a/src/components/map/mapbox/layers/ClusterLayer.tsx +++ b/src/components/map/mapbox/layers/ClusterLayer.tsx @@ -1,6 +1,6 @@ import { FC, useCallback, useContext, useEffect, useMemo } from "react"; import MapContext from "../MapContext"; -import { GeoJSONSource, MapMouseEvent } from "mapbox-gl"; +import { GeoJSONSource } from "mapbox-gl"; import MapPopup from "../component/MapPopup"; import { LayersProps, @@ -10,6 +10,7 @@ import { } from "./Layers"; import { mergeWithDefaults } from "../../../common/utils"; import SpatialExtents from "../component/SpatialExtents"; +import SpiderDiagram from "../component/SpiderDiagram"; interface ClusterSize { default?: number | string; @@ -67,13 +68,15 @@ const defaultClusterLayerConfig: ClusterLayerConfig = { clusterCircleStrokeWidth: 1, clusterCircleStrokeColor: "#fff", clusterCircleTextSize: 12, - unclusterPointColor: "#51bbd6", + unclusterPointColor: "green", unclusterPointOpacity: 0.6, unclusterPointStrokeWidth: 1, unclusterPointStrokeColor: "#fff", unclusterPointRadius: 8, }; +const spiderifyFromZoomLevel = 14; + // These function help to get the correct id and reduce the need to set those id in the // useEffect list export const getLayerId = (id: string | undefined) => `cluster-layer-${id}`; @@ -103,27 +106,6 @@ const ClusterLayer: FC = ({ [layerId] ); - const updateSource = useCallback(() => { - if (map?.getSource(clusterSourceId)) { - (map?.getSource(clusterSourceId) as GeoJSONSource).setData( - createCentroidDataSource(collections) - ); - } - }, [map, clusterSourceId, collections]); - - const onClusterCircleMouseClick = useCallback( - (ev: MapMouseEvent): void => { - if (ev.lngLat) { - map?.easeTo({ - center: ev.lngLat, - zoom: map?.getZoom() + 1, - duration: 500, - }); - } - }, - [map] - ); - // This is use to render the cluster circle and add event handle to circles useEffect(() => { if (map === null) return; @@ -215,8 +197,6 @@ const ClusterLayer: FC = ({ // Change the cursor back to default when it leaves the unclustered points map?.on("mouseleave", clusterLayer, defaultMouseLeaveEventHandler); - - map?.on("click", clusterLayer, onClusterCircleMouseClick); }; map?.once("load", createLayers); @@ -256,6 +236,14 @@ const ClusterLayer: FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [map]); + const updateSource = useCallback(() => { + if (map?.getSource(clusterSourceId)) { + (map?.getSource(clusterSourceId) as GeoJSONSource).setData( + createCentroidDataSource(collections) + ); + } + }, [map, clusterSourceId, collections]); + useEffect(() => { updateSource(); map?.on("styledata", updateSource); @@ -275,6 +263,12 @@ const ClusterLayer: FC = ({ addedLayerIds={[clusterLayer, unclusterPointLayer]} onDatasetSelected={onDatasetSelected} /> + ); }; diff --git a/src/components/map/mapbox/layers/HeatmapLayer.tsx b/src/components/map/mapbox/layers/HeatmapLayer.tsx index b6ffceb1..7cd625d5 100644 --- a/src/components/map/mapbox/layers/HeatmapLayer.tsx +++ b/src/components/map/mapbox/layers/HeatmapLayer.tsx @@ -2,10 +2,16 @@ import { FC, useCallback, useContext, useEffect, useMemo } from "react"; import MapContext from "../MapContext"; import { Expression, GeoJSONSource, StyleFunction } from "mapbox-gl"; -import { LayersProps, createCentroidDataSource } from "./Layers"; +import { + LayersProps, + createCentroidDataSource, + defaultMouseEnterEventHandler, + defaultMouseLeaveEventHandler, +} from "./Layers"; import { mergeWithDefaults } from "../../../common/utils"; import MapPopup from "../component/MapPopup"; import SpatialExtents from "../component/SpatialExtents"; +import SpiderDiagram from "../component/SpiderDiagram"; interface HeatmapLayer { maxZoom: number; @@ -51,12 +57,20 @@ const defaultHeatmapConfig: HeatmapConfig = { "rgba(236,222,239,0)", 0.2, "rgb(208,209,230)", - 0.4, + 0.3, "rgb(166,189,219)", - 0.6, + 0.4, "rgb(103,169,207)", + 0.6, + "#ffe69e", + 0.7, + "#ffd991", 0.8, - "rgb(28,144,153)", + "#ffcc84", + 0.9, + "#ffba78", + 1, + "#ffa86b", ], radius: { stops: [ @@ -70,9 +84,13 @@ const defaultHeatmapConfig: HeatmapConfig = { // These function help to get the correct id and reduce the need to set those id in the // useEffect list const getLayerId = (id: string | undefined) => `heatmap-layer-${id}`; -const getHeatmapSourceId = (layerId: string) => `${layerId}-source`; -const getHeatmapLayerId = (layerId: string) => `${layerId}-heatmap`; -const getCircleLayerId = (layerId: string) => `${layerId}-circle`; +const getHeatmapSourceId = (layerId: string) => `${layerId}-heatmap-source`; +const getHeatmapLayerId = (layerId: string) => `${layerId}-heatmap-layer`; +const getClusterSourceId = (layerId: string) => `${layerId}-cluster-source`; +const getClusterCircleLayerId = (layerId: string) => + `${layerId}-cluster-circle-layer`; +const getUnclusterPointLayerId = (layerId: string) => + `${layerId}-uncluster-point-layer`; const HeatmapLayer: FC = ({ collections, @@ -80,87 +98,152 @@ const HeatmapLayer: FC = ({ heatmapLayerConfig, }: HeatmapLayerProps) => { const { map } = useContext(MapContext); + const layerId = useMemo(() => getLayerId(map?.getContainer().id), [map]); - const sourceId = useMemo(() => getHeatmapSourceId(layerId), [layerId]); + + const heatmapSourceId = useMemo(() => getHeatmapSourceId(layerId), [layerId]); + const clusterSourceId = useMemo(() => getClusterSourceId(layerId), [layerId]); + + const heatmapLayer = useMemo(() => getHeatmapLayerId(layerId), [layerId]); + const clusterLayer = useMemo( + () => getClusterCircleLayerId(layerId), + [layerId] + ); + const unClusterPointLayer = useMemo( + () => getUnclusterPointLayerId(layerId), + [layerId] + ); // This is use to render the heatmap and add event handle to circles useEffect(() => { if (map === null) return; - const heatmapLayer = getHeatmapLayerId(layerId); - const circleLayer = getCircleLayerId(layerId); - // This situation is map object created, hence not null, but not completely loaded // and therefore you will have problem setting source and layer. Set-up a listener // to update the state and then this effect can be call again when map loaded. const createLayers = () => { - if (map?.getSource(sourceId)) return; + const dataSource = createCentroidDataSource(undefined); const config = mergeWithDefaults( defaultHeatmapConfig, heatmapLayerConfig ); - map?.addSource(sourceId, { - type: "geojson", - data: createCentroidDataSource(undefined), - cluster: false, - clusterMaxZoom: config.layer.maxZoom - 1, - clusterRadius: config.heatmapSourceRadius, - }); - - map?.addLayer({ - id: heatmapLayer, - type: "heatmap", - source: sourceId, - maxzoom: config.layer.maxZoom, - paint: { - // increase weight as diameter breast height increases - "heatmap-weight": config.layer.weight, - // increase intensity as zoom level increases - "heatmap-intensity": { - stops: [ - [config.layer.maxZoom - 4, 1], - [config.layer.maxZoom, 3], - ], - } as StyleFunction, - // assign color values be applied to points depending on their density - "heatmap-color": config.layer.color, - // increase radius as zoom increases - "heatmap-radius": config.layer.radius, - // decrease opacity to transition into the circle layer - "heatmap-opacity": { - default: 1, - stops: [ - [config.layer.maxZoom - 1, 1], - [config.layer.maxZoom, 0], - ], - } as StyleFunction, - }, - }); - - map?.addLayer({ - id: circleLayer, - type: "circle", - minzoom: config.layer.maxZoom - 1, - source: sourceId, - paint: { - // increase the radius of the circle as the zoom level and dbh value increases - "circle-radius": config.circle.radius, - "circle-color": config.circle.color, - "circle-stroke-color": config.circle.strokeColor, - "circle-stroke-width": config.circle.strokeWidth, - "circle-opacity": { - stops: [ - // You want to make the heatmap totally transparent - // aka looks disapear when the zoom level is hit max - // zoom. Reappear if greater than max zoom - [config.layer.maxZoom - 1, 0], - [config.layer.maxZoom, 1], - ], + if (!map?.getSource(heatmapSourceId)) { + map?.addSource(heatmapSourceId, { + type: "geojson", + data: dataSource, + cluster: false, + }); + } + + if (!map?.getSource(clusterSourceId)) { + map?.addSource(clusterSourceId, { + type: "geojson", + data: dataSource, + cluster: true, + clusterMaxZoom: 14, + clusterRadius: config.heatmapSourceRadius, + }); + } + + if (!map?.getLayer(heatmapLayer)) { + map?.addLayer({ + id: heatmapLayer, + type: "heatmap", + source: heatmapSourceId, + maxzoom: config.layer.maxZoom, + paint: { + // increase weight as diameter breast height increases + "heatmap-weight": config.layer.weight, + // increase intensity as zoom level increases + "heatmap-intensity": { + stops: [ + [config.layer.maxZoom - 4, 1], + [config.layer.maxZoom, 3], + ], + } as StyleFunction, + // assign color values be applied to points depending on their density + "heatmap-color": config.layer.color, + // increase radius as zoom increases + "heatmap-radius": config.layer.radius, + // decrease opacity to transition into the circle layer + "heatmap-opacity": { + default: 1, + stops: [ + [config.layer.maxZoom - 1, 1], + [config.layer.maxZoom, 0], + ], + } as StyleFunction, + }, + }); + } + + if (!map?.getLayer(clusterLayer)) { + map?.addLayer({ + id: clusterLayer, + type: "circle", + minzoom: config.layer.maxZoom - 1, + source: clusterSourceId, + filter: ["has", "point_count"], + paint: { + // increase the radius of the circle as the zoom level and dbh value increases + "circle-radius": config.circle.radius, + "circle-color": config.circle.color, + "circle-stroke-color": config.circle.strokeColor, + "circle-stroke-width": config.circle.strokeWidth, + "circle-opacity": { + stops: [ + // You want to make the heatmap totally transparent + // aka looks disapear when the zoom level is hit max + // zoom. Reappear if greater than max zoom + [config.layer.maxZoom - 1, 0], + [config.layer.maxZoom, 1], + ], + }, }, - }, - }); + }); + } + + if (!map?.getLayer("cluster-count")) { + map?.addLayer({ + id: "cluster-count", + type: "symbol", + source: clusterSourceId, + filter: ["has", "point_count"], + minzoom: config.layer.maxZoom - 1, + layout: { + "text-field": "{point_count_abbreviated}", + "text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"], + "text-size": 12, + }, + }); + } + + if (!map?.getLayer(unClusterPointLayer)) { + map?.addLayer({ + id: unClusterPointLayer, + type: "circle", + source: clusterSourceId, + filter: ["!", ["has", "point_count"]], + // Individual points appear at max cluster zoom + minzoom: config.layer.maxZoom - 1, + paint: { + "circle-radius": config.circle.radius, + "circle-color": config.circle.color, + "circle-stroke-color": config.circle.strokeColor, + "circle-stroke-width": config.circle.strokeWidth, + }, + }); + } + + // Change the cursor to a pointer for uncluster point + map?.on("mouseenter", unClusterPointLayer, defaultMouseEnterEventHandler); + map?.on("mouseenter", clusterLayer, defaultMouseEnterEventHandler); + + // Change the cursor back to default when it leaves the unclustered points + map?.on("mouseleave", unClusterPointLayer, defaultMouseLeaveEventHandler); + map?.on("mouseleave", clusterLayer, defaultMouseLeaveEventHandler); }; map?.once("load", createLayers); @@ -170,23 +253,45 @@ const HeatmapLayer: FC = ({ map?.on("styledata", createLayers); return () => { + map?.off( + "mouseenter", + unClusterPointLayer, + defaultMouseEnterEventHandler + ); + map?.off("mouseenter", clusterLayer, defaultMouseEnterEventHandler); + map?.off( + "mouseleave", + unClusterPointLayer, + defaultMouseLeaveEventHandler + ); + map?.off("mouseleave", clusterLayer, defaultMouseLeaveEventHandler); + try { if (map?.getLayer(heatmapLayer)) map?.removeLayer(heatmapLayer); - if (map?.getLayer(circleLayer)) map?.removeLayer(circleLayer); - if (map?.getSource(sourceId)) map?.removeSource(sourceId); + if (map?.getLayer(clusterLayer)) map?.removeLayer(clusterLayer); + if (map?.getLayer(unClusterPointLayer)) + map?.removeLayer(unClusterPointLayer); + if (map?.getLayer("cluster-count")) map?.removeLayer("cluster-count"); + if (map?.getSource(heatmapSourceId)) map?.removeSource(heatmapSourceId); + if (map?.getSource(clusterSourceId)) map?.removeSource(clusterSourceId); } catch (e) { // OK to ignore if no layer then no source as well } }; - }, [map, layerId, sourceId, heatmapLayerConfig]); + // Make sure map is the only dependency so that it will not trigger twice run + // where you will add source and remove layer accidentally. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); const updateSource = useCallback(() => { - if (map?.getSource(sourceId)) { - (map?.getSource(sourceId) as GeoJSONSource).setData( - createCentroidDataSource(collections) - ); + const newData = createCentroidDataSource(collections); + if (map?.getSource(heatmapSourceId)) { + (map?.getSource(heatmapSourceId) as GeoJSONSource).setData(newData); + } + if (map?.getSource(clusterSourceId)) { + (map?.getSource(clusterSourceId) as GeoJSONSource).setData(newData); } - }, [map, sourceId, collections]); + }, [map, heatmapSourceId, clusterSourceId, collections]); useEffect(() => { updateSource(); @@ -199,12 +304,18 @@ const HeatmapLayer: FC = ({ return ( <> +