From 7b0d8a83949c533ab5d43780a738d220a7dca216 Mon Sep 17 00:00:00 2001 From: Lyn Long Date: Fri, 26 Jul 2024 11:41:47 +1000 Subject: [PATCH 01/15] :arrow_up: add spiderifier package --- package.json | 5 ++++- .../map/mapbox/layers/ClusterLayer.tsx | 10 ++++++++++ yarn.lock | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index d032824e..e254f964 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "http-proxy-middleware": "^2.0.6", "lodash": "^4.17.21", "mapbox-gl": "3.4.0", + "mapboxgl-spiderifier": "^1.0.10", "maplibre-gl": "^4.0.0", "media-typer": "^1.1.0", "react": "^18.2.0", @@ -62,6 +63,7 @@ "@commitlint/cli": "^18.6.1", "@commitlint/config-conventional": "^18.6.2", "@emotion/react": "^11.11.3", + "@types/mapboxgl-spiderifier": "^1.0.2", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.18", "@typescript-eslint/eslint-plugin": "^7.2.0", @@ -101,5 +103,6 @@ "engines": { "node": "18.x || >=20.x", "npm": ">=8.0.0" - } + }, + "packageManager": "yarn@4.3.0" } diff --git a/src/components/map/mapbox/layers/ClusterLayer.tsx b/src/components/map/mapbox/layers/ClusterLayer.tsx index 78cddf2a..f44f2ec0 100644 --- a/src/components/map/mapbox/layers/ClusterLayer.tsx +++ b/src/components/map/mapbox/layers/ClusterLayer.tsx @@ -1,4 +1,5 @@ import { FC, useCallback, useContext, useEffect, useMemo } from "react"; +import MapboxglSpiderifier from "mapboxgl-spiderifier"; import MapContext from "../MapContext"; import { GeoJSONSource, MapMouseEvent } from "mapbox-gl"; import MapPopup from "../component/MapPopup"; @@ -98,6 +99,15 @@ const ClusterLayer: FC = ({ const clusterLayer = useMemo(() => getClusterLayerId(layerId), [layerId]); + const spiderifier = useMemo(() => { + if (map) { + return new MapboxglSpiderifier(map, { + customPin: true, + }); + } + return null; + }, [map]); + const unclusterPointLayer = useMemo( () => getUnclusterPointId(layerId), [layerId] diff --git a/yarn.lock b/yarn.lock index 5db8f84d..89f79cfa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3734,6 +3734,15 @@ __metadata: languageName: node linkType: hard +"@types/mapboxgl-spiderifier@npm:^1.0.2": + version: 1.0.2 + resolution: "@types/mapboxgl-spiderifier@npm:1.0.2" + dependencies: + "@types/mapbox-gl": "npm:*" + checksum: 10c0/c00cbbd72147b874d2f0651bbf7008d4f278ec9ba67db2152cba82e802944827a8b413b991465f125ca0aeb8478b2fc21bee6482f9f6f77af72c419cea7801c3 + languageName: node + linkType: hard + "@types/mathjax@npm:^0.0.40": version: 0.0.40 resolution: "@types/mathjax@npm:0.0.40" @@ -4321,6 +4330,7 @@ __metadata: "@types/lodash": "npm:^4.17.0" "@types/mapbox__geo-viewport": "npm:^0.5.3" "@types/mapbox__mapbox-gl-draw": "npm:^1.4.6" + "@types/mapboxgl-spiderifier": "npm:^1.0.2" "@types/media-typer": "npm:^1.1.3" "@types/node": "npm:^20.11.16" "@types/react": "npm:^18.2.55" @@ -4352,6 +4362,7 @@ __metadata: lint-staged: "npm:^15.2.2" lodash: "npm:^4.17.21" mapbox-gl: "npm:3.4.0" + mapboxgl-spiderifier: "npm:^1.0.10" maplibre-gl: "npm:^4.0.0" media-typer: "npm:^1.1.0" msw: "npm:^2.2.14" @@ -8654,6 +8665,13 @@ __metadata: languageName: node linkType: hard +"mapboxgl-spiderifier@npm:^1.0.10": + version: 1.0.10 + resolution: "mapboxgl-spiderifier@npm:1.0.10" + checksum: 10c0/29b60f9c2ecc60a30c1efd3422ed1226d6d87e57096c312b6b7e289953d5381283f1f6cda0131d6b3d7f84fd3e599bc62d97c7c3283b30908096a11b0157b59a + languageName: node + linkType: hard + "maplibre-gl@npm:^4.0.0": version: 4.5.0 resolution: "maplibre-gl@npm:4.5.0" From f53009dc64b40fc84f5407f10576d38ae63c43a9 Mon Sep 17 00:00:00 2001 From: Lyn Long Date: Mon, 29 Jul 2024 17:06:51 +1000 Subject: [PATCH 02/15] :sparkles: expand cluster on click --- src/components/map/mapbox/Map.tsx | 1 + .../map/mapbox/layers/ClusterLayer.tsx | 147 ++++++++++++++++-- 2 files changed, 132 insertions(+), 16 deletions(-) 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/layers/ClusterLayer.tsx b/src/components/map/mapbox/layers/ClusterLayer.tsx index f44f2ec0..ab3334bf 100644 --- a/src/components/map/mapbox/layers/ClusterLayer.tsx +++ b/src/components/map/mapbox/layers/ClusterLayer.tsx @@ -1,7 +1,10 @@ import { FC, useCallback, useContext, useEffect, useMemo } from "react"; -import MapboxglSpiderifier from "mapboxgl-spiderifier"; +import MapboxglSpiderifier, { + popupOffsetForSpiderLeg, + SpiderLeg, +} from "mapboxgl-spiderifier"; import MapContext from "../MapContext"; -import { GeoJSONSource, MapMouseEvent } from "mapbox-gl"; +import { GeoJSONSource, MapLayerMouseEvent, MapMouseEvent } from "mapbox-gl"; import MapPopup from "../component/MapPopup"; import { LayersProps, @@ -11,6 +14,7 @@ import { } from "./Layers"; import { mergeWithDefaults } from "../../../common/utils"; import SpatialExtents from "../component/SpatialExtents"; +import { Feature, Point } from "geojson"; interface ClusterSize { default?: number | string; @@ -68,13 +72,26 @@ const defaultClusterLayerConfig: ClusterLayerConfig = { clusterCircleStrokeWidth: 1, clusterCircleStrokeColor: "#fff", clusterCircleTextSize: 12, - unclusterPointColor: "#51bbd6", + unclusterPointColor: "green", unclusterPointOpacity: 0.6, unclusterPointStrokeWidth: 1, unclusterPointStrokeColor: "#fff", unclusterPointRadius: 8, }; +const spiderPinsConfig = { + position: "absolute", + width: "16px", + height: "16px", + marginLeft: "-8px", + marginTop: "-8px", + backgroundColor: "green", + border: "1px solid #fff", + borderRadius: "50%", + zIndex: "2", + transform: "translate(0, -75%)", +}; + // 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}`; @@ -99,18 +116,53 @@ const ClusterLayer: FC = ({ const clusterLayer = useMemo(() => getClusterLayerId(layerId), [layerId]); + const unclusterPointLayer = useMemo( + () => getUnclusterPointId(layerId), + [layerId] + ); + + const initializeSpiderLeg = useCallback((spiderLeg: SpiderLeg) => { + const pinElem = spiderLeg.elements.pin; + const feature = spiderLeg.feature; + + // Apply CSS styles directly to the pin element + if (pinElem) { + Object.assign(pinElem.style, spiderPinsConfig); + + // Add hover effect + pinElem.addEventListener("mouseenter", () => { + pinElem.style.backgroundColor = "yellow"; + }); + pinElem.addEventListener("mouseleave", () => { + pinElem.style.backgroundColor = "green"; + }); + } + }, []); + const spiderifier = useMemo(() => { if (map) { return new MapboxglSpiderifier(map, { - customPin: true, + animate: false, + animationSpeed: 0, + customPin: false, + onClick: function (e, spiderLeg) { + console.log("click on spiderLeg", spiderLeg); + }, + initializeLeg: initializeSpiderLeg, }); } return null; - }, [map]); - - const unclusterPointLayer = useMemo( - () => getUnclusterPointId(layerId), - [layerId] + }, [initializeSpiderLeg, map]); + + const shouldCreateSpiderDiagram = useCallback( + (features: any[]): boolean => { + const zoom = map?.getZoom() || 0; + const clusterCount = features[0].properties.point_count; + console.log("current zoom", zoom); + console.log("cluster count", clusterCount); + return (!clusterCount && features.length > 1) || zoom > 7; + }, + [map] ); const updateSource = useCallback(() => { @@ -123,15 +175,71 @@ const ClusterLayer: FC = ({ const onClusterCircleMouseClick = useCallback( (ev: MapMouseEvent): void => { - if (ev.lngLat) { - map?.easeTo({ - center: ev.lngLat, - zoom: map?.getZoom() + 1, - duration: 500, + const features = map?.queryRenderedFeatures(ev.point, { + layers: [clusterLayer], + }); + + if (!features || features.length === 0) return; + + const feature = features[0] as Feature; + const clusterId = feature.properties?.cluster_id; + console.log("clusterId===", clusterId); + const coordinate = [ + feature.geometry.coordinates[0], + feature.geometry.coordinates[1], + ] as [number, number]; + const source = map?.getSource(clusterSourceId) as GeoJSONSource; + console.log( + "shouldCreateSpiderDiagram(features)", + shouldCreateSpiderDiagram(features) + ); + if (shouldCreateSpiderDiagram(features)) { + source.getClusterLeaves(clusterId, 100, 0, (err, leaves) => { + if (err) { + console.error("Error getting cluster leaves:", err); + return; + } + const datasets = leaves as Feature[]; + const spiderLegs = datasets.map((dataset) => ({ + ...dataset.geometry.coordinates, + properties: dataset.properties, + })); + + spiderifier?.spiderfy(coordinate, spiderLegs); + }); + } else { + const maxZoomLevel = 4; + source.getClusterExpansionZoom(clusterId, (err, zoom) => { + if (err) return; + + map?.easeTo({ + center: (feature.geometry as any).coordinates, + zoom: zoom <= maxZoomLevel ? maxZoomLevel : zoom, + duration: 500, + }); }); } }, - [map] + [map, clusterLayer, clusterSourceId, shouldCreateSpiderDiagram, spiderifier] + ); + + const onEmptySpaceClick = useCallback( + (ev: MapLayerMouseEvent) => { + const point = map?.project(ev.lngLat); + + // Query for features at the clicked point, but only in the cluster and unclustered point layers + const features = point + ? map?.queryRenderedFeatures(point, { + layers: [clusterLayer, unclusterPointLayer], + }) + : []; + + // If no features are found at the click point (i.e., clicked on empty space) + if (spiderifier && features && features.length === 0) { + spiderifier.unspiderfy(); + } + }, + [map, clusterLayer, unclusterPointLayer, spiderifier] ); // This is use to render the cluster circle and add event handle to circles @@ -227,6 +335,7 @@ const ClusterLayer: FC = ({ map?.on("mouseleave", clusterLayer, defaultMouseLeaveEventHandler); map?.on("click", clusterLayer, onClusterCircleMouseClick); + map?.on("click", onEmptySpaceClick); }; map?.once("load", createLayers); @@ -238,7 +347,8 @@ const ClusterLayer: FC = ({ return () => { map?.off("mouseenter", clusterLayer, defaultMouseEnterEventHandler); map?.off("mouseleave", clusterLayer, defaultMouseLeaveEventHandler); - + map?.off("click", clusterLayer, onClusterCircleMouseClick); + map?.off("click", onEmptySpaceClick); // Clean up resource when you click on the next spatial extents, map is // still working in this page. try { @@ -285,6 +395,11 @@ const ClusterLayer: FC = ({ addedLayerIds={[clusterLayer, unclusterPointLayer]} onDatasetSelected={onDatasetSelected} /> + ); }; From 4d1876b432519312b7dd98c1c8393fd7197fe81e Mon Sep 17 00:00:00 2001 From: Lyn Long Date: Tue, 30 Jul 2024 11:38:06 +1000 Subject: [PATCH 03/15] :fire: remove the use of mapboxgl-spiderifier functions --- .../map/mapbox/layers/ClusterLayer.tsx | 149 ++++++++++-------- 1 file changed, 83 insertions(+), 66 deletions(-) diff --git a/src/components/map/mapbox/layers/ClusterLayer.tsx b/src/components/map/mapbox/layers/ClusterLayer.tsx index ab3334bf..0f2f4b94 100644 --- a/src/components/map/mapbox/layers/ClusterLayer.tsx +++ b/src/components/map/mapbox/layers/ClusterLayer.tsx @@ -1,8 +1,8 @@ import { FC, useCallback, useContext, useEffect, useMemo } from "react"; -import MapboxglSpiderifier, { - popupOffsetForSpiderLeg, - SpiderLeg, -} from "mapboxgl-spiderifier"; +// import MapboxglSpiderifier, { +// popupOffsetForSpiderLeg, +// SpiderLeg, +// } from "mapboxgl-spiderifier"; import MapContext from "../MapContext"; import { GeoJSONSource, MapLayerMouseEvent, MapMouseEvent } from "mapbox-gl"; import MapPopup from "../component/MapPopup"; @@ -79,18 +79,20 @@ const defaultClusterLayerConfig: ClusterLayerConfig = { unclusterPointRadius: 8, }; -const spiderPinsConfig = { - position: "absolute", - width: "16px", - height: "16px", - marginLeft: "-8px", - marginTop: "-8px", - backgroundColor: "green", - border: "1px solid #fff", - borderRadius: "50%", - zIndex: "2", - transform: "translate(0, -75%)", -}; +const spiderifyFromZoomLevel = 10; + +// const spiderPinsConfig = { +// position: "absolute", +// width: "16px", +// height: "16px", +// marginLeft: "-8px", +// marginTop: "-8px", +// backgroundColor: "green", +// border: "1px solid #fff", +// borderRadius: "50%", +// zIndex: "2", +// transform: "translate(0, -75%)", +// }; // These function help to get the correct id and reduce the need to set those id in the // useEffect list @@ -121,58 +123,62 @@ const ClusterLayer: FC = ({ [layerId] ); - const initializeSpiderLeg = useCallback((spiderLeg: SpiderLeg) => { - const pinElem = spiderLeg.elements.pin; - const feature = spiderLeg.feature; - - // Apply CSS styles directly to the pin element - if (pinElem) { - Object.assign(pinElem.style, spiderPinsConfig); - - // Add hover effect - pinElem.addEventListener("mouseenter", () => { - pinElem.style.backgroundColor = "yellow"; - }); - pinElem.addEventListener("mouseleave", () => { - pinElem.style.backgroundColor = "green"; - }); - } - }, []); - - const spiderifier = useMemo(() => { - if (map) { - return new MapboxglSpiderifier(map, { - animate: false, - animationSpeed: 0, - customPin: false, - onClick: function (e, spiderLeg) { - console.log("click on spiderLeg", spiderLeg); - }, - initializeLeg: initializeSpiderLeg, - }); + const updateSource = useCallback(() => { + if (map?.getSource(clusterSourceId)) { + (map?.getSource(clusterSourceId) as GeoJSONSource).setData( + createCentroidDataSource(collections) + ); } - return null; - }, [initializeSpiderLeg, map]); + }, [map, clusterSourceId, collections]); + // const initializeSpiderLeg = useCallback((spiderLeg: SpiderLeg) => { + // const pinElem = spiderLeg.elements.pin; + // const feature = spiderLeg.feature; + + // // Apply CSS styles directly to the pin element + // if (pinElem) { + // Object.assign(pinElem.style, spiderPinsConfig); + + // // Add hover effect + // pinElem.addEventListener("mouseenter", () => { + // pinElem.style.backgroundColor = "yellow"; + // }); + // pinElem.addEventListener("mouseleave", () => { + // pinElem.style.backgroundColor = "green"; + // }); + // } + // }, []); + + // const spiderifier = useMemo(() => { + // if (map) { + // return new MapboxglSpiderifier(map, { + // animate: false, + // animationSpeed: 0, + // customPin: false, + // onClick: function (e, spiderLeg) { + // console.log("click on spiderLeg", spiderLeg); + // }, + // initializeLeg: initializeSpiderLeg, + // }); + // } + // return null; + // }, [initializeSpiderLeg, map]); + + // 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; console.log("current zoom", zoom); console.log("cluster count", clusterCount); - return (!clusterCount && features.length > 1) || zoom > 7; + // TODO: maybe can delete the clusterCount related condition since (!clusterCount && features.length > 1) won't happen + return ( + (!clusterCount && features.length > 1) || zoom >= spiderifyFromZoomLevel + ); }, [map] ); - const updateSource = useCallback(() => { - if (map?.getSource(clusterSourceId)) { - (map?.getSource(clusterSourceId) as GeoJSONSource).setData( - createCentroidDataSource(collections) - ); - } - }, [map, clusterSourceId, collections]); - const onClusterCircleMouseClick = useCallback( (ev: MapMouseEvent): void => { const features = map?.queryRenderedFeatures(ev.point, { @@ -182,47 +188,58 @@ const ClusterLayer: FC = ({ if (!features || features.length === 0) return; const feature = features[0] as Feature; - const clusterId = feature.properties?.cluster_id; - console.log("clusterId===", clusterId); + // 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 cluster source const source = map?.getSource(clusterSourceId) as GeoJSONSource; + console.log( "shouldCreateSpiderDiagram(features)", shouldCreateSpiderDiagram(features) ); 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[]; + + // create spider-legs for spiderifying const spiderLegs = datasets.map((dataset) => ({ ...dataset.geometry.coordinates, properties: dataset.properties, })); - spiderifier?.spiderfy(coordinate, spiderLegs); + // TODO: spiderifying function }); } else { - const maxZoomLevel = 4; 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 map?.easeTo({ center: (feature.geometry as any).coordinates, - zoom: zoom <= maxZoomLevel ? maxZoomLevel : zoom, + zoom: + zoom >= spiderifyFromZoomLevel ? spiderifyFromZoomLevel : zoom, duration: 500, }); }); } }, - [map, clusterLayer, clusterSourceId, shouldCreateSpiderDiagram, spiderifier] + [map, clusterLayer, clusterSourceId, shouldCreateSpiderDiagram] ); + // for clear spider diagram when click on empty space const onEmptySpaceClick = useCallback( (ev: MapLayerMouseEvent) => { const point = map?.project(ev.lngLat); @@ -235,11 +252,11 @@ const ClusterLayer: FC = ({ : []; // If no features are found at the click point (i.e., clicked on empty space) - if (spiderifier && features && features.length === 0) { - spiderifier.unspiderfy(); - } + // if (spiderifier && features && features.length === 0) { + // spiderifier.unspiderfy(); + // } }, - [map, clusterLayer, unclusterPointLayer, spiderifier] + [map, clusterLayer, unclusterPointLayer] ); // This is use to render the cluster circle and add event handle to circles From 05af007d89ac73f481ad17b680b5ffddc4609502 Mon Sep 17 00:00:00 2001 From: Lyn Long Date: Tue, 30 Jul 2024 16:03:07 +1000 Subject: [PATCH 04/15] :sparkles: add spider diagram with hover and click function --- .../map/mapbox/layers/ClusterLayer.tsx | 314 ++++++++++++++---- 1 file changed, 245 insertions(+), 69 deletions(-) diff --git a/src/components/map/mapbox/layers/ClusterLayer.tsx b/src/components/map/mapbox/layers/ClusterLayer.tsx index 0f2f4b94..0cc72a3f 100644 --- a/src/components/map/mapbox/layers/ClusterLayer.tsx +++ b/src/components/map/mapbox/layers/ClusterLayer.tsx @@ -1,8 +1,11 @@ -import { FC, useCallback, useContext, useEffect, useMemo } from "react"; -// import MapboxglSpiderifier, { -// popupOffsetForSpiderLeg, -// SpiderLeg, -// } from "mapboxgl-spiderifier"; +import { + FC, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; import MapContext from "../MapContext"; import { GeoJSONSource, MapLayerMouseEvent, MapMouseEvent } from "mapbox-gl"; import MapPopup from "../component/MapPopup"; @@ -14,7 +17,8 @@ import { } from "./Layers"; import { mergeWithDefaults } from "../../../common/utils"; import SpatialExtents from "../component/SpatialExtents"; -import { Feature, Point } from "geojson"; +import { Feature, FeatureCollection, LineString, Point } from "geojson"; +import { Layers } from "@mui/icons-material"; interface ClusterSize { default?: number | string; @@ -105,6 +109,26 @@ export const getClusterLayerId = (layerId: string) => `${layerId}-clusters`; export const getUnclusterPointId = (layerId: string) => `${layerId}-unclustered-point`; +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 ClusterLayer: FC = ({ collections, onDatasetSelected, @@ -112,6 +136,11 @@ const ClusterLayer: FC = ({ }: ClusterLayerProps) => { const { map } = useContext(MapContext); + const [currentSpiderifiedCluster, setCurrentSpiderifiedCluster] = useState< + string | null + >(null); + console.log("currentSpiderifiedCluster", currentSpiderifiedCluster); + const layerId = useMemo(() => getLayerId(map?.getContainer().id), [map]); const clusterSourceId = useMemo(() => getClusterSourceId(layerId), [layerId]); @@ -131,39 +160,6 @@ const ClusterLayer: FC = ({ } }, [map, clusterSourceId, collections]); - // const initializeSpiderLeg = useCallback((spiderLeg: SpiderLeg) => { - // const pinElem = spiderLeg.elements.pin; - // const feature = spiderLeg.feature; - - // // Apply CSS styles directly to the pin element - // if (pinElem) { - // Object.assign(pinElem.style, spiderPinsConfig); - - // // Add hover effect - // pinElem.addEventListener("mouseenter", () => { - // pinElem.style.backgroundColor = "yellow"; - // }); - // pinElem.addEventListener("mouseleave", () => { - // pinElem.style.backgroundColor = "green"; - // }); - // } - // }, []); - - // const spiderifier = useMemo(() => { - // if (map) { - // return new MapboxglSpiderifier(map, { - // animate: false, - // animationSpeed: 0, - // customPin: false, - // onClick: function (e, spiderLeg) { - // console.log("click on spiderLeg", spiderLeg); - // }, - // initializeLeg: initializeSpiderLeg, - // }); - // } - // return null; - // }, [initializeSpiderLeg, map]); - // util function to check if a cluster can spiderify or not const shouldCreateSpiderDiagram = useCallback( (features: any[]): boolean => { @@ -179,6 +175,158 @@ const ClusterLayer: FC = ({ [map] ); + const unspiderify = useCallback( + (clusterCircleId: string) => { + console.log("Un-spiderifying cluster:", clusterCircleId); + 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); + + console.log( + "Attempting to spiderify cluster:clusterId==", + clusterCircleId + ); + console.log( + "Attempting to spiderify cluster:currentSpiderifiedCluster==", + currentSpiderifiedCluster + ); + + // 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) { + // setCurrentSpiderifiedCluster(null); + return; + } + + const circleRadius = 50; // Adjust this value to change the size of the spider diagram + const angleStep = (2 * Math.PI) / datasets.length; + + const spiderPinsFeatures: Feature[] = []; + const spiderLinesFeatures: Feature[] = []; + + datasets.forEach((dataset, index) => { + const spiderPinId = getSpiderPinId(clusterCircleId, index); + const spiderLineId = getSpiderLineId(spiderPinId); + const angle = index * angleStep; + const x = Math.cos(angle) * circleRadius; + const y = Math.sin(angle) * circleRadius; + + const spiderLegCoordinate: [number, number] = [ + coordinate[0] + x / 5000, + coordinate[1] + y / 5000, + ]; + + // 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: spiderPinsLayerId, + type: "circle", + source: spiderPinsSourceId, + paint: { + "circle-radius": 8, + "circle-color": "green", + "circle-opacity": 0.6, + "circle-stroke-width": 1, + "circle-stroke-color": "#fff", + }, + }); + + map?.addLayer({ + id: spiderLinesLayerId, + type: "line", + source: spiderLinesSourceId, + paint: { + "line-color": "#888", + "line-width": 1, + }, + }); + + setCurrentSpiderifiedCluster(clusterCircleId); + }, + [map, currentSpiderifiedCluster, unspiderify] + ); + const onClusterCircleMouseClick = useCallback( (ev: MapMouseEvent): void => { const features = map?.queryRenderedFeatures(ev.point, { @@ -197,7 +345,7 @@ const ClusterLayer: FC = ({ // get cluster_id from feature for later query const clusterId = feature.properties?.cluster_id; - // get clicked cluster cluster source + // get clicked cluster source const source = map?.getSource(clusterSourceId) as GeoJSONSource; console.log( @@ -212,14 +360,7 @@ const ClusterLayer: FC = ({ return; } const datasets = leaves as Feature[]; - - // create spider-legs for spiderifying - const spiderLegs = datasets.map((dataset) => ({ - ...dataset.geometry.coordinates, - properties: dataset.properties, - })); - - // TODO: spiderifying function + spiderify(coordinate, datasets); }); } else { source.getClusterExpansionZoom(clusterId, (err, zoom) => { @@ -236,27 +377,48 @@ const ClusterLayer: FC = ({ }); } }, - [map, clusterLayer, clusterSourceId, shouldCreateSpiderDiagram] + [map, clusterLayer, clusterSourceId, shouldCreateSpiderDiagram, spiderify] ); // for clear spider diagram when click on empty space const onEmptySpaceClick = useCallback( - (ev: MapLayerMouseEvent) => { - const point = map?.project(ev.lngLat); - - // Query for features at the clicked point, but only in the cluster and unclustered point layers - const features = point - ? map?.queryRenderedFeatures(point, { - layers: [clusterLayer, unclusterPointLayer], - }) - : []; - - // If no features are found at the click point (i.e., clicked on empty space) - // if (spiderifier && features && features.length === 0) { - // spiderifier.unspiderfy(); - // } + (ev: MapMouseEvent) => { + const point = ev.point; + let spiderPinsLayerId; + if (currentSpiderifiedCluster) { + spiderPinsLayerId = getSpiderPinsLayerId(currentSpiderifiedCluster); + } + console.log( + "on empty spaces clicked, find spiderPinsLayerId=", + spiderPinsLayerId + ); + const features = map?.queryRenderedFeatures(point, { + layers: spiderPinsLayerId + ? [clusterLayer, unclusterPointLayer, spiderPinsLayerId] + : [clusterLayer, unclusterPointLayer], + }); + + if (!features || features.length === 0) { + console.log("Clicked on empty space, clearing spider diagram"); + setCurrentSpiderifiedCluster((currentCluster) => { + console.log( + "unspiderify currentSpiderifiedCluster==", + currentCluster + ); + if (currentCluster) { + unspiderify(currentCluster); + } + return null; + }); + } }, - [map, clusterLayer, unclusterPointLayer] + [ + currentSpiderifiedCluster, + map, + clusterLayer, + unclusterPointLayer, + unspiderify, + ] ); // This is use to render the cluster circle and add event handle to circles @@ -412,11 +574,25 @@ const ClusterLayer: FC = ({ addedLayerIds={[clusterLayer, unclusterPointLayer]} onDatasetSelected={onDatasetSelected} /> - + + {currentSpiderifiedCluster && ( + + )} + + {currentSpiderifiedCluster && ( + + )} ); }; From 33d5ac36c06b829fbefa922f73cbf4113200c655 Mon Sep 17 00:00:00 2001 From: Lyn Long Date: Tue, 30 Jul 2024 17:18:59 +1000 Subject: [PATCH 05/15] :bug: fix cluster expansion bug --- .../map/mapbox/layers/ClusterLayer.tsx | 60 +++++++++---------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/src/components/map/mapbox/layers/ClusterLayer.tsx b/src/components/map/mapbox/layers/ClusterLayer.tsx index 0cc72a3f..7273fb66 100644 --- a/src/components/map/mapbox/layers/ClusterLayer.tsx +++ b/src/components/map/mapbox/layers/ClusterLayer.tsx @@ -83,7 +83,7 @@ const defaultClusterLayerConfig: ClusterLayerConfig = { unclusterPointRadius: 8, }; -const spiderifyFromZoomLevel = 10; +const spiderifyFromZoomLevel = 14; // const spiderPinsConfig = { // position: "absolute", @@ -232,7 +232,7 @@ const ClusterLayer: FC = ({ return; } - const circleRadius = 50; // Adjust this value to change the size of the spider diagram + const circleRadius = 20; // Adjust this value to change the size of the spider diagram const angleStep = (2 * Math.PI) / datasets.length; const spiderPinsFeatures: Feature[] = []; @@ -368,10 +368,16 @@ const ClusterLayer: FC = ({ // if expansionZoom level hasn't reach the spiderify-zoomLevel, keep zoom into // else go to the spiderify-zoomLevel + console.log("getClusterExpansionZoom==", zoom); + const currentZoom = map?.getZoom(); map?.easeTo({ center: (feature.geometry as any).coordinates, zoom: - zoom >= spiderifyFromZoomLevel ? spiderifyFromZoomLevel : zoom, + zoom >= spiderifyFromZoomLevel + ? spiderifyFromZoomLevel + : zoom === currentZoom + ? zoom + 1 + : zoom, duration: 500, }); }); @@ -388,37 +394,31 @@ const ClusterLayer: FC = ({ if (currentSpiderifiedCluster) { spiderPinsLayerId = getSpiderPinsLayerId(currentSpiderifiedCluster); } - console.log( - "on empty spaces clicked, find spiderPinsLayerId=", - spiderPinsLayerId - ); - const features = map?.queryRenderedFeatures(point, { - layers: spiderPinsLayerId - ? [clusterLayer, unclusterPointLayer, spiderPinsLayerId] - : [clusterLayer, unclusterPointLayer], - }); + console.log("on map clicked, find spiderPinsLayerId=", spiderPinsLayerId); - if (!features || features.length === 0) { - console.log("Clicked on empty space, clearing spider diagram"); - setCurrentSpiderifiedCluster((currentCluster) => { - console.log( - "unspiderify currentSpiderifiedCluster==", - currentCluster - ); - if (currentCluster) { - unspiderify(currentCluster); - } - return null; + if (spiderPinsLayerId) { + const features = map?.queryRenderedFeatures(point, { + layers: [spiderPinsLayerId], }); + + if (!features || features.length === 0) { + console.log("Clicked outside spider pins, clearing spider diagram"); + setCurrentSpiderifiedCluster((currentCluster) => { + console.log( + "unspiderify currentSpiderifiedCluster==", + currentCluster + ); + if (currentCluster) { + unspiderify(currentCluster); + } + return null; + }); + } else { + console.log("Clicked on a spider pin, keeping spider diagram"); + } } }, - [ - currentSpiderifiedCluster, - map, - clusterLayer, - unclusterPointLayer, - unspiderify, - ] + [currentSpiderifiedCluster, map, unspiderify] ); // This is use to render the cluster circle and add event handle to circles From e8d5132233b5779495e53f5d412431d2f5cec05b Mon Sep 17 00:00:00 2001 From: NekoLyn Date: Wed, 31 Jul 2024 16:02:28 +1000 Subject: [PATCH 06/15] :bug: fix click on empty space to unspiderify bug --- .../map/mapbox/layers/ClusterLayer.tsx | 50 +++++++++++-------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/src/components/map/mapbox/layers/ClusterLayer.tsx b/src/components/map/mapbox/layers/ClusterLayer.tsx index 7273fb66..b43e3a4b 100644 --- a/src/components/map/mapbox/layers/ClusterLayer.tsx +++ b/src/components/map/mapbox/layers/ClusterLayer.tsx @@ -18,7 +18,6 @@ import { import { mergeWithDefaults } from "../../../common/utils"; import SpatialExtents from "../component/SpatialExtents"; import { Feature, FeatureCollection, LineString, Point } from "geojson"; -import { Layers } from "@mui/icons-material"; interface ClusterSize { default?: number | string; @@ -287,6 +286,7 @@ const ClusterLayer: FC = ({ features: spiderLinesFeatures, }; + // TODO: check if source/layer already exists // Add sources map?.addSource(spiderPinsSourceId, { type: "geojson", @@ -390,35 +390,43 @@ const ClusterLayer: FC = ({ const onEmptySpaceClick = useCallback( (ev: MapMouseEvent) => { const point = ev.point; - let spiderPinsLayerId; - if (currentSpiderifiedCluster) { - spiderPinsLayerId = getSpiderPinsLayerId(currentSpiderifiedCluster); - } - console.log("on map clicked, find spiderPinsLayerId=", spiderPinsLayerId); - if (spiderPinsLayerId) { - const features = map?.queryRenderedFeatures(point, { - layers: [spiderPinsLayerId], - }); + setCurrentSpiderifiedCluster((currentCluster) => { + console.log( + "call onEmptySpaceClick, and check currentSpiderifiedCluster ", + currentCluster + ); + + if (currentCluster) { + const spiderPinsLayerId = getSpiderPinsLayerId(currentCluster); + console.log( + "on map clicked, find spiderPinsLayerId=", + spiderPinsLayerId + ); + + if (!map?.getLayer(spiderPinsLayerId)) return null; + const features = map?.queryRenderedFeatures(point, { + layers: [spiderPinsLayerId], + }); - if (!features || features.length === 0) { - console.log("Clicked outside spider pins, clearing spider diagram"); - setCurrentSpiderifiedCluster((currentCluster) => { + if (!features || features.length === 0) { + console.log("Clicked outside spider pins, clearing spider diagram"); console.log( "unspiderify currentSpiderifiedCluster==", currentCluster ); - if (currentCluster) { - unspiderify(currentCluster); - } + unspiderify(currentCluster); return null; - }); - } else { - console.log("Clicked on a spider pin, keeping spider diagram"); + } else { + console.log("Clicked on a spider pin, keeping spider diagram"); + return currentCluster; + } } - } + + return currentCluster; // This line ensures we always return string | null + }); }, - [currentSpiderifiedCluster, map, unspiderify] + [map, unspiderify] ); // This is use to render the cluster circle and add event handle to circles From e51f15a466c4b95268b652f7f803ee42518508f3 Mon Sep 17 00:00:00 2001 From: NekoLyn Date: Wed, 31 Jul 2024 17:21:35 +1000 Subject: [PATCH 07/15] :zap: zoom out end to unspiderify --- .../map/mapbox/layers/ClusterLayer.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/components/map/mapbox/layers/ClusterLayer.tsx b/src/components/map/mapbox/layers/ClusterLayer.tsx index b43e3a4b..755ff67f 100644 --- a/src/components/map/mapbox/layers/ClusterLayer.tsx +++ b/src/components/map/mapbox/layers/ClusterLayer.tsx @@ -327,6 +327,31 @@ const ClusterLayer: FC = ({ [map, currentSpiderifiedCluster, unspiderify] ); + const checkZoomAndUnspiderify = useCallback(() => { + console.log("called when zoom end"); + + if (map) { + const currentZoom = map.getZoom(); + console.log("when zoom end, check current zoom", currentZoom); + + setCurrentSpiderifiedCluster((currentCluster) => { + console.log( + "when zoom end, check currentSpiderifiedCluster=", + currentCluster, + map + ); + + if (currentCluster && currentZoom < spiderifyFromZoomLevel) { + console.log("Zoom level below spiderify threshold, unspiderfying"); + unspiderify(currentCluster); + return null; + } + + return currentCluster; + }); + } + }, [map, unspiderify]); + const onClusterCircleMouseClick = useCallback( (ev: MapMouseEvent): void => { const features = map?.queryRenderedFeatures(ev.point, { @@ -523,6 +548,7 @@ const ClusterLayer: FC = ({ map?.on("click", clusterLayer, onClusterCircleMouseClick); map?.on("click", onEmptySpaceClick); + map?.on("zoomend", checkZoomAndUnspiderify); }; map?.once("load", createLayers); @@ -536,6 +562,8 @@ const ClusterLayer: FC = ({ map?.off("mouseleave", clusterLayer, defaultMouseLeaveEventHandler); map?.off("click", clusterLayer, onClusterCircleMouseClick); map?.off("click", onEmptySpaceClick); + map?.off("zoomend", checkZoomAndUnspiderify); + // Clean up resource when you click on the next spatial extents, map is // still working in this page. try { From 450b1dcd8970a66a5d2545f73bfbebb90fc8e27b Mon Sep 17 00:00:00 2001 From: NekoLyn Date: Wed, 31 Jul 2024 17:35:03 +1000 Subject: [PATCH 08/15] :bug: fix spatial extents disappear on zoom out --- src/components/map/mapbox/layers/ClusterLayer.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/map/mapbox/layers/ClusterLayer.tsx b/src/components/map/mapbox/layers/ClusterLayer.tsx index 755ff67f..021a3534 100644 --- a/src/components/map/mapbox/layers/ClusterLayer.tsx +++ b/src/components/map/mapbox/layers/ClusterLayer.tsx @@ -176,7 +176,6 @@ const ClusterLayer: FC = ({ const unspiderify = useCallback( (clusterCircleId: string) => { - console.log("Un-spiderifying cluster:", clusterCircleId); const spiderPinsSourceId = getSpiderPinsSourceId(clusterCircleId); const spiderLinesSourceId = getSpiderLinesSourceId(clusterCircleId); const spiderPinsLayerId = getSpiderPinsLayerId(clusterCircleId); @@ -198,7 +197,7 @@ const ClusterLayer: FC = ({ map.removeSource(spiderLinesSourceId); } - setCurrentSpiderifiedCluster(null); + // setCurrentSpiderifiedCluster(null); }, [map] ); @@ -344,7 +343,7 @@ const ClusterLayer: FC = ({ if (currentCluster && currentZoom < spiderifyFromZoomLevel) { console.log("Zoom level below spiderify threshold, unspiderfying"); unspiderify(currentCluster); - return null; + return currentCluster; } return currentCluster; From b6685aa2f7f02e38312466d308297d01b141d654 Mon Sep 17 00:00:00 2001 From: NekoLyn Date: Wed, 31 Jul 2024 21:17:05 +1000 Subject: [PATCH 09/15] :bug: fix heatmap cluster circle bug --- .../map/mapbox/layers/HeatmapLayer.tsx | 93 +++++++++++++++---- 1 file changed, 76 insertions(+), 17 deletions(-) diff --git a/src/components/map/mapbox/layers/HeatmapLayer.tsx b/src/components/map/mapbox/layers/HeatmapLayer.tsx index b6ffceb1..36586507 100644 --- a/src/components/map/mapbox/layers/HeatmapLayer.tsx +++ b/src/components/map/mapbox/layers/HeatmapLayer.tsx @@ -70,9 +70,12 @@ 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 getCircleSourceId = (layerId: string) => `${layerId}-circle-source`; +const getCircleLayerId = (layerId: string) => `${layerId}-circle-layer`; +const getUnclusterPointLayerId = (layerId: string) => + `${layerId}-uncluster-point-layer`; const HeatmapLayer: FC = ({ collections, @@ -80,21 +83,29 @@ const HeatmapLayer: FC = ({ heatmapLayerConfig, }: HeatmapLayerProps) => { const { map } = useContext(MapContext); + const layerId = useMemo(() => getLayerId(map?.getContainer().id), [map]); + const sourceId = useMemo(() => getHeatmapSourceId(layerId), [layerId]); + const circleSourceId = useMemo(() => getCircleSourceId(layerId), [layerId]); + + const heatmapLayer = useMemo(() => getHeatmapLayerId(layerId), [layerId]); + const circleLayer = useMemo(() => getCircleLayerId(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, @@ -103,9 +114,15 @@ const HeatmapLayer: FC = ({ map?.addSource(sourceId, { type: "geojson", - data: createCentroidDataSource(undefined), + data: dataSource, cluster: false, - clusterMaxZoom: config.layer.maxZoom - 1, + }); + + map?.addSource(circleSourceId, { + type: "geojson", + data: dataSource, + cluster: true, + clusterMaxZoom: 14, clusterRadius: config.heatmapSourceRadius, }); @@ -143,7 +160,8 @@ const HeatmapLayer: FC = ({ id: circleLayer, type: "circle", minzoom: config.layer.maxZoom - 1, - source: sourceId, + source: circleSourceId, + filter: ["has", "point_count"], paint: { // increase the radius of the circle as the zoom level and dbh value increases "circle-radius": config.circle.radius, @@ -161,6 +179,36 @@ const HeatmapLayer: FC = ({ }, }, }); + + // Add cluster count layer + map?.addLayer({ + id: "cluster-count", + type: "symbol", + source: circleSourceId, + 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, + }, + }); + + // Add unclustered point layer + map?.addLayer({ + id: unClusterPointLayer, + type: "circle", + source: circleSourceId, + filter: ["!", ["has", "point_count"]], + // Individual points appear at max cluster zoom + minzoom: config.layer.maxZoom - 1, + paint: { + "circle-color": "#11b4da", + "circle-radius": 4, + "circle-stroke-width": 1, + "circle-stroke-color": "#fff", + }, + }); }; map?.once("load", createLayers); @@ -178,15 +226,26 @@ const HeatmapLayer: FC = ({ // OK to ignore if no layer then no source as well } }; - }, [map, layerId, sourceId, heatmapLayerConfig]); + }, [ + map, + layerId, + sourceId, + heatmapLayerConfig, + circleSourceId, + heatmapLayer, + circleLayer, + unClusterPointLayer, + ]); const updateSource = useCallback(() => { + const newData = createCentroidDataSource(collections); if (map?.getSource(sourceId)) { - (map?.getSource(sourceId) as GeoJSONSource).setData( - createCentroidDataSource(collections) - ); + (map?.getSource(sourceId) as GeoJSONSource).setData(newData); + } + if (map?.getSource(circleSourceId)) { + (map?.getSource(circleSourceId) as GeoJSONSource).setData(newData); } - }, [map, sourceId, collections]); + }, [map, sourceId, circleSourceId, collections]); useEffect(() => { updateSource(); @@ -199,12 +258,12 @@ const HeatmapLayer: FC = ({ return ( <> From 3d4af93fcc4094c0e20b013b60c534d0412932cb Mon Sep 17 00:00:00 2001 From: Lyn Long Date: Thu, 1 Aug 2024 14:51:54 +1000 Subject: [PATCH 10/15] :bug: fix switch layer bug --- .../map/mapbox/layers/ClusterLayer.tsx | 17 +- .../map/mapbox/layers/HeatmapLayer.tsx | 259 ++++++++++-------- 2 files changed, 152 insertions(+), 124 deletions(-) diff --git a/src/components/map/mapbox/layers/ClusterLayer.tsx b/src/components/map/mapbox/layers/ClusterLayer.tsx index 021a3534..62e3e96a 100644 --- a/src/components/map/mapbox/layers/ClusterLayer.tsx +++ b/src/components/map/mapbox/layers/ClusterLayer.tsx @@ -151,14 +151,6 @@ const ClusterLayer: FC = ({ [layerId] ); - const updateSource = useCallback(() => { - if (map?.getSource(clusterSourceId)) { - (map?.getSource(clusterSourceId) as GeoJSONSource).setData( - createCentroidDataSource(collections) - ); - } - }, [map, clusterSourceId, collections]); - // util function to check if a cluster can spiderify or not const shouldCreateSpiderDiagram = useCallback( (features: any[]): boolean => { @@ -465,6 +457,7 @@ const ClusterLayer: FC = ({ // these changes so use this check to avoid duplicate add if (map?.getSource(clusterSourceId)) return; + console.log("creating layers cluster layer"); const config = mergeWithDefaults( defaultClusterLayerConfig, clusterLayerConfig @@ -590,6 +583,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); diff --git a/src/components/map/mapbox/layers/HeatmapLayer.tsx b/src/components/map/mapbox/layers/HeatmapLayer.tsx index 36586507..cb3f4bc7 100644 --- a/src/components/map/mapbox/layers/HeatmapLayer.tsx +++ b/src/components/map/mapbox/layers/HeatmapLayer.tsx @@ -2,7 +2,12 @@ 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"; @@ -72,8 +77,9 @@ const defaultHeatmapConfig: HeatmapConfig = { const getLayerId = (id: string | undefined) => `heatmap-layer-${id}`; const getHeatmapSourceId = (layerId: string) => `${layerId}-heatmap-source`; const getHeatmapLayerId = (layerId: string) => `${layerId}-heatmap-layer`; -const getCircleSourceId = (layerId: string) => `${layerId}-circle-source`; -const getCircleLayerId = (layerId: string) => `${layerId}-circle-layer`; +const getClusterSourceId = (layerId: string) => `${layerId}-cluster-source`; +const getClusterCircleLayerId = (layerId: string) => + `${layerId}-cluster-circle-layer`; const getUnclusterPointLayerId = (layerId: string) => `${layerId}-uncluster-point-layer`; @@ -86,11 +92,14 @@ const HeatmapLayer: FC = ({ const layerId = useMemo(() => getLayerId(map?.getContainer().id), [map]); - const sourceId = useMemo(() => getHeatmapSourceId(layerId), [layerId]); - const circleSourceId = useMemo(() => getCircleSourceId(layerId), [layerId]); + const heatmapSourceId = useMemo(() => getHeatmapSourceId(layerId), [layerId]); + const clusterSourceId = useMemo(() => getClusterSourceId(layerId), [layerId]); const heatmapLayer = useMemo(() => getHeatmapLayerId(layerId), [layerId]); - const circleLayer = useMemo(() => getCircleLayerId(layerId), [layerId]); + const clusterLayer = useMemo( + () => getClusterCircleLayerId(layerId), + [layerId] + ); const unClusterPointLayer = useMemo( () => getUnclusterPointLayerId(layerId), [layerId] @@ -104,7 +113,6 @@ const HeatmapLayer: FC = ({ // 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( @@ -112,103 +120,120 @@ const HeatmapLayer: FC = ({ heatmapLayerConfig ); - map?.addSource(sourceId, { - type: "geojson", - data: dataSource, - cluster: false, - }); + 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, + }, + }); + } - map?.addSource(circleSourceId, { - type: "geojson", - data: dataSource, - cluster: true, - clusterMaxZoom: 14, - clusterRadius: config.heatmapSourceRadius, - }); + if (!map?.getLayer(clusterLayer)) { + console.log("heatmap creating cluster layer"); + 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], + ], + }, + }, + }); + } - 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, - }, - }); + 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, + }, + }); + } - map?.addLayer({ - id: circleLayer, - type: "circle", - minzoom: config.layer.maxZoom - 1, - source: circleSourceId, - 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(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-color": "#11b4da", + "circle-radius": 6, + "circle-stroke-width": 1, + "circle-stroke-color": "#fff", }, - }, - }); + }); + } - // Add cluster count layer - map?.addLayer({ - id: "cluster-count", - type: "symbol", - source: circleSourceId, - 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, - }, - }); + // Change the cursor to a pointer for uncluster point + map?.on("mouseenter", unClusterPointLayer, defaultMouseEnterEventHandler); - // Add unclustered point layer - map?.addLayer({ - id: unClusterPointLayer, - type: "circle", - source: circleSourceId, - filter: ["!", ["has", "point_count"]], - // Individual points appear at max cluster zoom - minzoom: config.layer.maxZoom - 1, - paint: { - "circle-color": "#11b4da", - "circle-radius": 4, - "circle-stroke-width": 1, - "circle-stroke-color": "#fff", - }, - }); + // Change the cursor back to default when it leaves the unclustered points + map?.on("mouseleave", unClusterPointLayer, defaultMouseLeaveEventHandler); }; map?.once("load", createLayers); @@ -218,34 +243,36 @@ const HeatmapLayer: FC = ({ map?.on("styledata", createLayers); return () => { + map?.off("mouseenter", clusterLayer, defaultMouseEnterEventHandler); + 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, - circleSourceId, - heatmapLayer, - circleLayer, - unClusterPointLayer, - ]); + // 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(() => { const newData = createCentroidDataSource(collections); - if (map?.getSource(sourceId)) { - (map?.getSource(sourceId) as GeoJSONSource).setData(newData); + console.log({ newData }); + if (map?.getSource(heatmapSourceId)) { + (map?.getSource(heatmapSourceId) as GeoJSONSource).setData(newData); } - if (map?.getSource(circleSourceId)) { - (map?.getSource(circleSourceId) as GeoJSONSource).setData(newData); + if (map?.getSource(clusterSourceId)) { + (map?.getSource(clusterSourceId) as GeoJSONSource).setData(newData); } - }, [map, sourceId, circleSourceId, collections]); + }, [map, heatmapSourceId, clusterSourceId, collections]); useEffect(() => { updateSource(); @@ -263,7 +290,7 @@ const HeatmapLayer: FC = ({ /> From b5418941209fc1e9acd81142c7622ea57c386d23 Mon Sep 17 00:00:00 2001 From: Lyn Long Date: Thu, 1 Aug 2024 16:59:02 +1000 Subject: [PATCH 11/15] :art: refactor spider diagram --- .../map/mapbox/component/SpiderDiagram.tsx | 370 +++++++++++++++++ .../map/mapbox/layers/ClusterLayer.tsx | 378 +----------------- .../map/mapbox/layers/HeatmapLayer.tsx | 7 + 3 files changed, 386 insertions(+), 369 deletions(-) create mode 100644 src/components/map/mapbox/component/SpiderDiagram.tsx diff --git a/src/components/map/mapbox/component/SpiderDiagram.tsx b/src/components/map/mapbox/component/SpiderDiagram.tsx new file mode 100644 index 00000000..f8f7b6d4 --- /dev/null +++ b/src/components/map/mapbox/component/SpiderDiagram.tsx @@ -0,0 +1,370 @@ +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; +} + +interface SpiderDiagramProps extends LayersProps { + spiderDiagramConfig?: Partial; + clusterLayer: string; + clusterSourceId: string; + unclusterPointLayer: string; +} + +const defaultSpiderDiagramConfig: SpiderDiagramConfig = { + spiderifyFromZoomLevel: 8, +}; + +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; + } + + //TODO: need to adjust according to zoom level and data.length + const circleRadius = 20; + const angleStep = (2 * Math.PI) / datasets.length; + + const spiderPinsFeatures: Feature[] = []; + const spiderLinesFeatures: Feature[] = []; + + datasets.forEach((dataset, index) => { + const spiderPinId = getSpiderPinId(clusterCircleId, index); + const spiderLineId = getSpiderLineId(spiderPinId); + const angle = index * angleStep; + const x = Math.cos(angle) * circleRadius; + const y = Math.sin(angle) * circleRadius; + + const spiderLegCoordinate: [number, number] = [ + coordinate[0] + x / 5000, + coordinate[1] + y / 5000, + ]; + + // 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: spiderPinsLayerId, + type: "circle", + source: spiderPinsSourceId, + paint: { + "circle-radius": 8, + "circle-color": "green", + "circle-opacity": 0.6, + "circle-stroke-width": 1, + "circle-stroke-color": "#fff", + }, + }); + + map?.addLayer({ + id: spiderLinesLayerId, + type: "line", + source: spiderLinesSourceId, + paint: { + "line-color": "#888", + "line-width": 1, + }, + }); + + setCurrentSpiderifiedCluster(clusterCircleId); + }, + [map, currentSpiderifiedCluster, unspiderify] + ); + + 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 62e3e96a..384ff741 100644 --- a/src/components/map/mapbox/layers/ClusterLayer.tsx +++ b/src/components/map/mapbox/layers/ClusterLayer.tsx @@ -1,13 +1,6 @@ -import { - FC, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from "react"; +import { FC, useCallback, useContext, useEffect, useMemo } from "react"; import MapContext from "../MapContext"; -import { GeoJSONSource, MapLayerMouseEvent, MapMouseEvent } from "mapbox-gl"; +import { GeoJSONSource } from "mapbox-gl"; import MapPopup from "../component/MapPopup"; import { LayersProps, @@ -17,7 +10,7 @@ import { } from "./Layers"; import { mergeWithDefaults } from "../../../common/utils"; import SpatialExtents from "../component/SpatialExtents"; -import { Feature, FeatureCollection, LineString, Point } from "geojson"; +import SpiderDiagram from "../component/SpiderDiagram"; interface ClusterSize { default?: number | string; @@ -84,19 +77,6 @@ const defaultClusterLayerConfig: ClusterLayerConfig = { const spiderifyFromZoomLevel = 14; -// const spiderPinsConfig = { -// position: "absolute", -// width: "16px", -// height: "16px", -// marginLeft: "-8px", -// marginTop: "-8px", -// backgroundColor: "green", -// border: "1px solid #fff", -// borderRadius: "50%", -// zIndex: "2", -// transform: "translate(0, -75%)", -// }; - // 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}`; @@ -108,26 +88,6 @@ export const getClusterLayerId = (layerId: string) => `${layerId}-clusters`; export const getUnclusterPointId = (layerId: string) => `${layerId}-unclustered-point`; -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 ClusterLayer: FC = ({ collections, onDatasetSelected, @@ -135,11 +95,6 @@ const ClusterLayer: FC = ({ }: ClusterLayerProps) => { const { map } = useContext(MapContext); - const [currentSpiderifiedCluster, setCurrentSpiderifiedCluster] = useState< - string | null - >(null); - console.log("currentSpiderifiedCluster", currentSpiderifiedCluster); - const layerId = useMemo(() => getLayerId(map?.getContainer().id), [map]); const clusterSourceId = useMemo(() => getClusterSourceId(layerId), [layerId]); @@ -151,300 +106,6 @@ const ClusterLayer: FC = ({ [layerId] ); - // 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; - console.log("current zoom", zoom); - console.log("cluster count", clusterCount); - // TODO: maybe can delete the clusterCount related condition since (!clusterCount && features.length > 1) won't happen - return ( - (!clusterCount && features.length > 1) || zoom >= spiderifyFromZoomLevel - ); - }, - [map] - ); - - 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); - - console.log( - "Attempting to spiderify cluster:clusterId==", - clusterCircleId - ); - console.log( - "Attempting to spiderify cluster:currentSpiderifiedCluster==", - currentSpiderifiedCluster - ); - - // 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) { - // setCurrentSpiderifiedCluster(null); - return; - } - - const circleRadius = 20; // Adjust this value to change the size of the spider diagram - const angleStep = (2 * Math.PI) / datasets.length; - - const spiderPinsFeatures: Feature[] = []; - const spiderLinesFeatures: Feature[] = []; - - datasets.forEach((dataset, index) => { - const spiderPinId = getSpiderPinId(clusterCircleId, index); - const spiderLineId = getSpiderLineId(spiderPinId); - const angle = index * angleStep; - const x = Math.cos(angle) * circleRadius; - const y = Math.sin(angle) * circleRadius; - - const spiderLegCoordinate: [number, number] = [ - coordinate[0] + x / 5000, - coordinate[1] + y / 5000, - ]; - - // 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, - }; - - // TODO: check if source/layer already exists - // Add sources - map?.addSource(spiderPinsSourceId, { - type: "geojson", - data: spiderPinsFeatureCollection, - }); - - map?.addSource(spiderLinesSourceId, { - type: "geojson", - data: spiderLinesFeatureCollection, - }); - - // Add layers - map?.addLayer({ - id: spiderPinsLayerId, - type: "circle", - source: spiderPinsSourceId, - paint: { - "circle-radius": 8, - "circle-color": "green", - "circle-opacity": 0.6, - "circle-stroke-width": 1, - "circle-stroke-color": "#fff", - }, - }); - - map?.addLayer({ - id: spiderLinesLayerId, - type: "line", - source: spiderLinesSourceId, - paint: { - "line-color": "#888", - "line-width": 1, - }, - }); - - setCurrentSpiderifiedCluster(clusterCircleId); - }, - [map, currentSpiderifiedCluster, unspiderify] - ); - - const checkZoomAndUnspiderify = useCallback(() => { - console.log("called when zoom end"); - - if (map) { - const currentZoom = map.getZoom(); - console.log("when zoom end, check current zoom", currentZoom); - - setCurrentSpiderifiedCluster((currentCluster) => { - console.log( - "when zoom end, check currentSpiderifiedCluster=", - currentCluster, - map - ); - - if (currentCluster && currentZoom < spiderifyFromZoomLevel) { - console.log("Zoom level below spiderify threshold, unspiderfying"); - unspiderify(currentCluster); - return currentCluster; - } - - return currentCluster; - }); - } - }, [map, unspiderify]); - - const onClusterCircleMouseClick = 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; - - console.log( - "shouldCreateSpiderDiagram(features)", - shouldCreateSpiderDiagram(features) - ); - 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 - console.log("getClusterExpansionZoom==", zoom); - 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] - ); - - // for clear spider diagram when click on empty space - const onEmptySpaceClick = useCallback( - (ev: MapMouseEvent) => { - const point = ev.point; - - setCurrentSpiderifiedCluster((currentCluster) => { - console.log( - "call onEmptySpaceClick, and check currentSpiderifiedCluster ", - currentCluster - ); - - if (currentCluster) { - const spiderPinsLayerId = getSpiderPinsLayerId(currentCluster); - console.log( - "on map clicked, find spiderPinsLayerId=", - spiderPinsLayerId - ); - - if (!map?.getLayer(spiderPinsLayerId)) return null; - const features = map?.queryRenderedFeatures(point, { - layers: [spiderPinsLayerId], - }); - - if (!features || features.length === 0) { - console.log("Clicked outside spider pins, clearing spider diagram"); - console.log( - "unspiderify currentSpiderifiedCluster==", - currentCluster - ); - unspiderify(currentCluster); - return null; - } else { - console.log("Clicked on a spider pin, keeping spider diagram"); - return currentCluster; - } - } - - return currentCluster; // This line ensures we always return string | null - }); - }, - [map, unspiderify] - ); - // This is use to render the cluster circle and add event handle to circles useEffect(() => { if (map === null) return; @@ -457,7 +118,6 @@ const ClusterLayer: FC = ({ // these changes so use this check to avoid duplicate add if (map?.getSource(clusterSourceId)) return; - console.log("creating layers cluster layer"); const config = mergeWithDefaults( defaultClusterLayerConfig, clusterLayerConfig @@ -537,10 +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?.on("click", onEmptySpaceClick); - map?.on("zoomend", checkZoomAndUnspiderify); }; map?.once("load", createLayers); @@ -552,9 +208,6 @@ const ClusterLayer: FC = ({ return () => { map?.off("mouseenter", clusterLayer, defaultMouseEnterEventHandler); map?.off("mouseleave", clusterLayer, defaultMouseLeaveEventHandler); - map?.off("click", clusterLayer, onClusterCircleMouseClick); - map?.off("click", onEmptySpaceClick); - map?.off("zoomend", checkZoomAndUnspiderify); // Clean up resource when you click on the next spatial extents, map is // still working in this page. @@ -610,25 +263,12 @@ const ClusterLayer: FC = ({ addedLayerIds={[clusterLayer, unclusterPointLayer]} onDatasetSelected={onDatasetSelected} /> - - {currentSpiderifiedCluster && ( - - )} - - {currentSpiderifiedCluster && ( - - )} + ); }; diff --git a/src/components/map/mapbox/layers/HeatmapLayer.tsx b/src/components/map/mapbox/layers/HeatmapLayer.tsx index cb3f4bc7..d33ccffa 100644 --- a/src/components/map/mapbox/layers/HeatmapLayer.tsx +++ b/src/components/map/mapbox/layers/HeatmapLayer.tsx @@ -11,6 +11,7 @@ import { import { mergeWithDefaults } from "../../../common/utils"; import MapPopup from "../component/MapPopup"; import SpatialExtents from "../component/SpatialExtents"; +import SpiderDiagram from "../component/SpiderDiagram"; interface HeatmapLayer { maxZoom: number; @@ -293,6 +294,12 @@ const HeatmapLayer: FC = ({ addedLayerIds={[clusterLayer, unClusterPointLayer]} onDatasetSelected={onDatasetSelected} /> + ); }; From 1d8881c73836f708a159b23b1a783ce0dc0c1b6e Mon Sep 17 00:00:00 2001 From: Lyn Long Date: Thu, 1 Aug 2024 17:02:03 +1000 Subject: [PATCH 12/15] :heavy_minus_sign: remove unused dep spiderifier --- package.json | 2 -- yarn.lock | 18 ------------------ 2 files changed, 20 deletions(-) diff --git a/package.json b/package.json index 9a6dbd6e..5c1125ed 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "http-proxy-middleware": "^2.0.6", "lodash": "^4.17.21", "mapbox-gl": "3.4.0", - "mapboxgl-spiderifier": "^1.0.10", "maplibre-gl": "^4.0.0", "media-typer": "^1.1.0", "react": "^18.2.0", @@ -63,7 +62,6 @@ "@commitlint/cli": "^18.6.1", "@commitlint/config-conventional": "^18.6.2", "@emotion/react": "^11.11.3", - "@types/mapboxgl-spiderifier": "^1.0.2", "@types/react": "^18.2.55", "@types/react-dom": "^18.2.18", "@typescript-eslint/eslint-plugin": "^7.18.0", diff --git a/yarn.lock b/yarn.lock index 9100059e..7bcec41d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3734,15 +3734,6 @@ __metadata: languageName: node linkType: hard -"@types/mapboxgl-spiderifier@npm:^1.0.2": - version: 1.0.2 - resolution: "@types/mapboxgl-spiderifier@npm:1.0.2" - dependencies: - "@types/mapbox-gl": "npm:*" - checksum: 10c0/c00cbbd72147b874d2f0651bbf7008d4f278ec9ba67db2152cba82e802944827a8b413b991465f125ca0aeb8478b2fc21bee6482f9f6f77af72c419cea7801c3 - languageName: node - linkType: hard - "@types/mathjax@npm:^0.0.40": version: 0.0.40 resolution: "@types/mathjax@npm:0.0.40" @@ -4457,7 +4448,6 @@ __metadata: "@types/lodash": "npm:^4.17.0" "@types/mapbox__geo-viewport": "npm:^0.5.3" "@types/mapbox__mapbox-gl-draw": "npm:^1.4.6" - "@types/mapboxgl-spiderifier": "npm:^1.0.2" "@types/media-typer": "npm:^1.1.3" "@types/node": "npm:^20.11.16" "@types/react": "npm:^18.2.55" @@ -4489,7 +4479,6 @@ __metadata: lint-staged: "npm:^15.2.2" lodash: "npm:^4.17.21" mapbox-gl: "npm:3.4.0" - mapboxgl-spiderifier: "npm:^1.0.10" maplibre-gl: "npm:^4.0.0" media-typer: "npm:^1.1.0" msw: "npm:^2.2.14" @@ -8780,13 +8769,6 @@ __metadata: languageName: node linkType: hard -"mapboxgl-spiderifier@npm:^1.0.10": - version: 1.0.10 - resolution: "mapboxgl-spiderifier@npm:1.0.10" - checksum: 10c0/29b60f9c2ecc60a30c1efd3422ed1226d6d87e57096c312b6b7e289953d5381283f1f6cda0131d6b3d7f84fd3e599bc62d97c7c3283b30908096a11b0157b59a - languageName: node - linkType: hard - "maplibre-gl@npm:^4.0.0": version: 4.5.0 resolution: "maplibre-gl@npm:4.5.0" From f51e2fa79b907e3d8b897da7f1395952face6498 Mon Sep 17 00:00:00 2001 From: Lyn Long Date: Thu, 1 Aug 2024 17:43:45 +1000 Subject: [PATCH 13/15] :art: improve spider diagram shape and radius --- .../map/mapbox/component/SpiderDiagram.tsx | 70 +++++++++++++++---- .../map/mapbox/layers/HeatmapLayer.tsx | 1 - 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/components/map/mapbox/component/SpiderDiagram.tsx b/src/components/map/mapbox/component/SpiderDiagram.tsx index f8f7b6d4..4bef0704 100644 --- a/src/components/map/mapbox/component/SpiderDiagram.tsx +++ b/src/components/map/mapbox/component/SpiderDiagram.tsx @@ -20,7 +20,7 @@ interface SpiderDiagramProps extends LayersProps { } const defaultSpiderDiagramConfig: SpiderDiagramConfig = { - spiderifyFromZoomLevel: 8, + spiderifyFromZoomLevel: 11, }; const getClusterCircleId = (coordinate: [number, number]) => @@ -120,23 +120,63 @@ const SpiderDiagram: FC = ({ return; } - //TODO: need to adjust according to zoom level and data.length - const circleRadius = 20; - const angleStep = (2 * Math.PI) / datasets.length; - const spiderPinsFeatures: Feature[] = []; const spiderLinesFeatures: Feature[] = []; - datasets.forEach((dataset, index) => { - const spiderPinId = getSpiderPinId(clusterCircleId, index); + const currentZoom = map?.getZoom() || 0; + const numPins = datasets.length; + + // Spider configuration + const circleSpiralSwitchover = 9; + const circleFootSeparation = 25; + const spiralFootSeparation = 28; + const spiralLengthStart = 15; + const spiralLengthFactor = 4; + + 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); - const angle = index * angleStep; - const x = Math.cos(angle) * circleRadius; - const y = Math.sin(angle) * circleRadius; - const spiderLegCoordinate: [number, number] = [ - coordinate[0] + x / 5000, - coordinate[1] + y / 5000, + // 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 @@ -213,13 +253,13 @@ const SpiderDiagram: FC = ({ setCurrentSpiderifiedCluster(clusterCircleId); }, - [map, currentSpiderifiedCluster, unspiderify] + [currentSpiderifiedCluster, map, unspiderify, spiderifyFromZoomLevel] ); const checkZoomAndUnspiderify = useCallback(() => { if (map) { const currentZoom = map.getZoom(); - + console.log("currentZoom", currentZoom); setCurrentSpiderifiedCluster((currentCluster) => { if (currentCluster && currentZoom < spiderifyFromZoomLevel) { unspiderify(currentCluster); diff --git a/src/components/map/mapbox/layers/HeatmapLayer.tsx b/src/components/map/mapbox/layers/HeatmapLayer.tsx index d33ccffa..1a10e564 100644 --- a/src/components/map/mapbox/layers/HeatmapLayer.tsx +++ b/src/components/map/mapbox/layers/HeatmapLayer.tsx @@ -266,7 +266,6 @@ const HeatmapLayer: FC = ({ const updateSource = useCallback(() => { const newData = createCentroidDataSource(collections); - console.log({ newData }); if (map?.getSource(heatmapSourceId)) { (map?.getSource(heatmapSourceId) as GeoJSONSource).setData(newData); } From b6245c15dc01825ca22e4faa8c49af5e0f184d73 Mon Sep 17 00:00:00 2001 From: NekoLyn Date: Thu, 1 Aug 2024 23:46:08 +1000 Subject: [PATCH 14/15] :fire: remove package manager --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index 5c1125ed..03d1f233 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,5 @@ "engines": { "node": "18.x || >=20.x", "npm": ">=8.0.0" - }, - "packageManager": "yarn@4.3.0" + } } From d61c2def32a99edcaf99c2c520bee8e895140001 Mon Sep 17 00:00:00 2001 From: Lyn Long Date: Fri, 2 Aug 2024 12:05:42 +1000 Subject: [PATCH 15/15] :art: refactor code --- .../map/mapbox/component/SpiderDiagram.tsx | 71 +++++++++++++------ .../map/mapbox/layers/HeatmapLayer.tsx | 35 ++++++--- 2 files changed, 78 insertions(+), 28 deletions(-) diff --git a/src/components/map/mapbox/component/SpiderDiagram.tsx b/src/components/map/mapbox/component/SpiderDiagram.tsx index 4bef0704..130f01aa 100644 --- a/src/components/map/mapbox/component/SpiderDiagram.tsx +++ b/src/components/map/mapbox/component/SpiderDiagram.tsx @@ -10,6 +10,18 @@ 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 { @@ -21,6 +33,18 @@ interface SpiderDiagramProps extends LayersProps { 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]) => @@ -127,11 +151,13 @@ const SpiderDiagram: FC = ({ const numPins = datasets.length; // Spider configuration - const circleSpiralSwitchover = 9; - const circleFootSeparation = 25; - const spiralFootSeparation = 28; - const spiralLengthStart = 15; - const spiralLengthFactor = 4; + const { + circleSpiralSwitchover, + circleFootSeparation, + spiralFootSeparation, + spiralLengthStart, + spiralLengthFactor, + } = config; const generateSpiderLegParams = (count: number) => { if (count >= circleSpiralSwitchover) { @@ -229,37 +255,42 @@ const SpiderDiagram: FC = ({ // Add layers map?.addLayer({ - id: spiderPinsLayerId, - type: "circle", - source: spiderPinsSourceId, + id: spiderLinesLayerId, + type: "line", + source: spiderLinesSourceId, paint: { - "circle-radius": 8, - "circle-color": "green", - "circle-opacity": 0.6, - "circle-stroke-width": 1, - "circle-stroke-color": "#fff", + "line-color": config.lineColor, + "line-width": config.lineWidth, }, }); map?.addLayer({ - id: spiderLinesLayerId, - type: "line", - source: spiderLinesSourceId, + id: spiderPinsLayerId, + type: "circle", + source: spiderPinsSourceId, paint: { - "line-color": "#888", - "line-width": 1, + "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, unspiderify, spiderifyFromZoomLevel] + [ + currentSpiderifiedCluster, + map, + config, + unspiderify, + spiderifyFromZoomLevel, + ] ); const checkZoomAndUnspiderify = useCallback(() => { if (map) { const currentZoom = map.getZoom(); - console.log("currentZoom", currentZoom); setCurrentSpiderifiedCluster((currentCluster) => { if (currentCluster && currentZoom < spiderifyFromZoomLevel) { unspiderify(currentCluster); diff --git a/src/components/map/mapbox/layers/HeatmapLayer.tsx b/src/components/map/mapbox/layers/HeatmapLayer.tsx index 1a10e564..7cd625d5 100644 --- a/src/components/map/mapbox/layers/HeatmapLayer.tsx +++ b/src/components/map/mapbox/layers/HeatmapLayer.tsx @@ -57,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: [ @@ -172,7 +180,6 @@ const HeatmapLayer: FC = ({ } if (!map?.getLayer(clusterLayer)) { - console.log("heatmap creating cluster layer"); map?.addLayer({ id: clusterLayer, type: "circle", @@ -222,19 +229,21 @@ const HeatmapLayer: FC = ({ // Individual points appear at max cluster zoom minzoom: config.layer.maxZoom - 1, paint: { - "circle-color": "#11b4da", - "circle-radius": 6, - "circle-stroke-width": 1, - "circle-stroke-color": "#fff", + "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); @@ -244,7 +253,17 @@ 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 {