From 7b5e9b95c2a79ad1c7f9df44a4de1ad6d3b808f5 Mon Sep 17 00:00:00 2001 From: Lyn Long Date: Thu, 4 Jul 2024 11:51:05 +1000 Subject: [PATCH 1/7] :sparkles: add popup --- .../map/mapbox/layers/ClusterLayer.tsx | 64 +++++++++++++++---- 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/src/components/map/mapbox/layers/ClusterLayer.tsx b/src/components/map/mapbox/layers/ClusterLayer.tsx index d76aaa75..eaadd1fb 100644 --- a/src/components/map/mapbox/layers/ClusterLayer.tsx +++ b/src/components/map/mapbox/layers/ClusterLayer.tsx @@ -5,6 +5,7 @@ import { FeatureCollection, GeoJsonProperties, Geometry, + Point, } from "geojson"; import { OGCCollection, @@ -13,7 +14,7 @@ import { fetchResultNoStore, } from "../../../common/store/searchReducer"; import { centroid } from "@turf/turf"; -import { GeoJSONSource, MapLayerMouseEvent } from "mapbox-gl"; +import { GeoJSONSource, MapLayerMouseEvent, Popup } from "mapbox-gl"; import { useDispatch } from "react-redux"; import { AppDispatch } from "../../../common/store/store"; @@ -51,18 +52,6 @@ const createClusterDataSource = ( return featureCollections; }; -const unclusterPointLayerMouseEnterEventHandler = ( - ev: MapLayerMouseEvent -): void => { - ev.target.getCanvas().style.cursor = "pointer"; -}; - -const unclusterPointLayerMouseLeaveEventHandler = ( - ev: MapLayerMouseEvent -): void => { - ev.target.getCanvas().style.cursor = ""; -}; - const clusterLayerMouseEnterEventHandler = (ev: MapLayerMouseEvent): void => { ev.target.getCanvas().style.cursor = "pointer"; }; @@ -88,6 +77,51 @@ const ClusterLayer: FC = ({ const { map } = useContext(MapContext); const dispatch = useDispatch(); const [spatialExtentsUUid, setSpatialExtentsUUid] = useState>(); + console.log("spatialExtentsUUid", spatialExtentsUUid); + + const popup = new Popup({ + closeButton: false, + closeOnClick: false, + }); + + const unclusterPointLayerMouseEnterEventHandler = ( + ev: MapLayerMouseEvent + ): void => { + if (!ev.target || !map) return; + + ev.target.getCanvas().style.cursor = "pointer"; + + // Copy coordinates array. + if (ev.features && ev.features.length > 0) { + const feature = ev.features[0] as Feature; + const geometry = feature.geometry; + const coordinates = geometry.coordinates.slice(); + const description = feature.properties?.uuid as string; + + if ( + map && + ["mercator", "equirectangular"].includes(map.getProjection().name) + ) { + while (Math.abs(ev.lngLat.lng - coordinates[0]) > 180) { + coordinates[0] += ev.lngLat.lng > coordinates[0] ? 360 : -360; + } + } + + // Populate the popup and set its coordinates + // based on the feature found. + popup + .setLngLat(coordinates as [number, number]) + .setHTML(description) + .addTo(map); + } + }; + + const unclusterPointLayerMouseLeaveEventHandler = ( + ev: MapLayerMouseEvent + ): void => { + ev.target.getCanvas().style.cursor = ""; + popup.remove(); + }; const updateSource = useCallback(() => { const clusterSourceId = getClusterSourceId( @@ -102,6 +136,7 @@ const ClusterLayer: FC = ({ const unclusterPointLayerMouseClickEventHandler = useCallback( (ev: MapLayerMouseEvent): void => { + console.log("un-clusterLayerMouseClick!!"); // Make sure even same id under same area will be set once. if (ev.features) { const uuids = [ @@ -117,6 +152,7 @@ const ClusterLayer: FC = ({ const clusterLayerMouseClickEventHandler = useCallback( (ev: MapLayerMouseEvent): void => { + console.log("clusterLayerMouseClick!!"); if (ev.lngLat) { map?.easeTo({ center: ev.lngLat, @@ -218,7 +254,7 @@ const ClusterLayer: FC = ({ ); }); }); - + //???? return () => { layerIds.forEach((id) => { try { From 8570fe0bdd2d4f5af813cd952a7df2051a90202b Mon Sep 17 00:00:00 2001 From: Lyn Long Date: Fri, 5 Jul 2024 09:43:21 +1000 Subject: [PATCH 2/7] :art: uncluster point click --- .../map/mapbox/layers/ClusterLayer.tsx | 351 ++++++++++-------- 1 file changed, 186 insertions(+), 165 deletions(-) diff --git a/src/components/map/mapbox/layers/ClusterLayer.tsx b/src/components/map/mapbox/layers/ClusterLayer.tsx index eaadd1fb..240fe7b9 100644 --- a/src/components/map/mapbox/layers/ClusterLayer.tsx +++ b/src/components/map/mapbox/layers/ClusterLayer.tsx @@ -1,4 +1,11 @@ -import React, { FC, useCallback, useContext, useEffect, useState } from "react"; +import React, { + FC, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; import MapContext from "../MapContext"; import { Feature, @@ -52,14 +59,6 @@ const createClusterDataSource = ( return featureCollections; }; -const clusterLayerMouseEnterEventHandler = (ev: MapLayerMouseEvent): void => { - ev.target.getCanvas().style.cursor = "pointer"; -}; - -const clusterLayerMouseLeaveEventHandler = (ev: MapLayerMouseEvent): void => { - ev.target.getCanvas().style.cursor = ""; -}; - // 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) => `cluster-layer-${id}`; @@ -70,6 +69,15 @@ const getclusterLayerId = (layerId: string) => `${layerId}-clusters`; const getUnclusterPointId = (layerId: string) => `${layerId}-unclustered-point`; +const createPointsLayerId = (id: string) => `${id}-points`; + +const createLinesLayerId = (id: string) => `${id}-lines`; + +const createPolygonLayerId = (id: string) => `${id}-polygons`; + +const createSourceId = (layerId: string, uuid: string) => + `${layerId}-${uuid}-source`; + const ClusterLayer: FC = ({ collections, onDatasetSelected, @@ -79,77 +87,105 @@ const ClusterLayer: FC = ({ const [spatialExtentsUUid, setSpatialExtentsUUid] = useState>(); console.log("spatialExtentsUUid", spatialExtentsUUid); - const popup = new Popup({ - closeButton: false, - closeOnClick: false, - }); + const popup = useMemo( + () => + new Popup({ + closeButton: false, + closeOnClick: false, + }), + [] + ); - const unclusterPointLayerMouseEnterEventHandler = ( - ev: MapLayerMouseEvent - ): void => { - if (!ev.target || !map) return; - - ev.target.getCanvas().style.cursor = "pointer"; - - // Copy coordinates array. - if (ev.features && ev.features.length > 0) { - const feature = ev.features[0] as Feature; - const geometry = feature.geometry; - const coordinates = geometry.coordinates.slice(); - const description = feature.properties?.uuid as string; - - if ( - map && - ["mercator", "equirectangular"].includes(map.getProjection().name) - ) { - while (Math.abs(ev.lngLat.lng - coordinates[0]) > 180) { - coordinates[0] += ev.lngLat.lng > coordinates[0] ? 360 : -360; - } - } + const layerId = useMemo(() => getLayerId(map?.getContainer().id), [map]); - // Populate the popup and set its coordinates - // based on the feature found. - popup - .setLngLat(coordinates as [number, number]) - .setHTML(description) - .addTo(map); - } - }; + const clusterSourceId = useMemo(() => getClusterSourceId(layerId), [layerId]); - const unclusterPointLayerMouseLeaveEventHandler = ( - ev: MapLayerMouseEvent - ): void => { - ev.target.getCanvas().style.cursor = ""; - popup.remove(); - }; + const clusterLayer = useMemo(() => getclusterLayerId(layerId), [layerId]); + + const unclusterPointLayer = useMemo( + () => getUnclusterPointId(layerId), + [layerId] + ); const updateSource = useCallback(() => { - const clusterSourceId = getClusterSourceId( - getLayerId(map?.getContainer().id) - ); if (map?.getSource(clusterSourceId)) { (map?.getSource(clusterSourceId) as GeoJSONSource).setData( createClusterDataSource(collections) ); } - }, [map, collections]); + }, [map, clusterSourceId, collections]); + + const unclusterPointLayerMouseEnterEventHandler = useCallback( + (ev: MapLayerMouseEvent): void => { + if (!ev.target || !map) return; + + ev.target.getCanvas().style.cursor = "pointer"; + + // Copy coordinates array. + if (ev.features && ev.features.length > 0) { + const feature = ev.features[0] as Feature; + const geometry = feature.geometry; + const coordinates = geometry.coordinates.slice(); + const description = feature.properties?.uuid as string; + + if ( + ["mercator", "equirectangular"].includes(map.getProjection().name) + ) { + while (Math.abs(ev.lngLat.lng - coordinates[0]) > 180) { + coordinates[0] += ev.lngLat.lng > coordinates[0] ? 360 : -360; + } + } + + // Populate the popup and set its coordinates + // based on the feature found. + popup + .setLngLat(coordinates as [number, number]) + .setHTML(description) + .addTo(map); + } + }, + [map, popup] + ); + + const unclusterPointLayerMouseLeaveEventHandler = useCallback( + (ev: MapLayerMouseEvent) => { + ev.target.getCanvas().style.cursor = ""; + popup.remove(); + }, + [popup] + ); + + const clusterLayerMouseEnterEventHandler = useCallback( + (ev: MapLayerMouseEvent) => { + ev.target.getCanvas().style.cursor = "pointer"; + }, + [] + ); + + const clusterLayerMouseLeaveEventHandler = useCallback( + (ev: MapLayerMouseEvent) => { + ev.target.getCanvas().style.cursor = ""; + }, + [] + ); const unclusterPointLayerMouseClickEventHandler = useCallback( (ev: MapLayerMouseEvent): void => { - console.log("un-clusterLayerMouseClick!!"); // Make sure even same id under same area will be set once. + console.log("uncluster clicked"); if (ev.features) { const uuids = [ ...new Set(ev.features.map((feature) => feature.properties?.uuid)), ]; setSpatialExtentsUUid(uuids); + console.log({ uuids }); // Give time for the state to be updated if (onDatasetSelected) setTimeout(() => onDatasetSelected(uuids), 100); } }, [setSpatialExtentsUUid, onDatasetSelected] ); - + console.log({ spatialExtentsUUid }); const clusterLayerMouseClickEventHandler = useCallback( (ev: MapLayerMouseEvent): void => { console.log("clusterLayerMouseClick!!"); @@ -168,17 +204,8 @@ const ClusterLayer: FC = ({ const sourceIds = new Array(); const layerIds = new Array(); - const createPointsLayerId = (id: string) => `${id}-points`; - const createLinesLayerId = (id: string) => `${id}-lines`; - const createPolygonLayerId = (id: string) => `${id}-polygons`; - const createSourceId = (layerId: string, uuid: string) => - `${layerId}-${uuid}-source`; - spatialExtentsUUid?.forEach((uuid: string) => { - const layerId = getLayerId(map?.getContainer().id); const sourceId = createSourceId(layerId, uuid); - const clusterLayer = getclusterLayerId(layerId); - sourceIds.push(sourceId); const pointLayerId = createPointsLayerId(sourceId); @@ -201,60 +228,67 @@ const ClusterLayer: FC = ({ // Give we use uuid, there will be one record only const collection = collections.collections[0]; - map?.addSource(sourceId, { - type: "geojson", - data: collection.getGeometry(), - }); - + console.log("extent collection", collection); + console.log( + "extent spatial extents", + collection.extent?.getGeojsonExtents(1) + ); + console.log("extent Geometry", collection.getGeometry()); + + if (!map?.getSource(sourceId)) { + map?.addSource(sourceId, { + type: "geojson", + // data: collection.extent?.getGeojsonExtents(1), + data: collection.getGeometry(), + }); + } + + const addLayerIfNotExists = (id: string, layer: any) => { + if (!map?.getLayer(id)) { + map?.addLayer(layer, clusterLayer); + } + }; // Add layers for each geometry type within the GeometryCollection - map?.addLayer( - { - id: pointLayerId, - type: "symbol", - source: sourceId, - filter: ["==", "$type", "Point"], - layout: { - "icon-image": "marker-15", // Built-in icon provided by Mapbox - "icon-size": 1.5, - "text-field": ["get", "title"], - "text-font": ["Open Sans Semibold", "Arial Unicode MS Bold"], - "text-offset": [0, 1.25], - "text-anchor": "top", - }, + // if not exists + addLayerIfNotExists(pointLayerId, { + id: pointLayerId, + type: "symbol", + source: sourceId, + filter: ["==", "$type", "Point"], + layout: { + "icon-image": "marker-15", + "icon-size": 1.5, + "text-field": ["get", "title"], + "text-font": ["Open Sans Semibold", "Arial Unicode MS Bold"], + "text-offset": [0, 1.25], + "text-anchor": "top", }, - clusterLayer - ); + }); - map?.addLayer( - { - id: lineLayerId, - type: "line", - source: sourceId, - filter: ["==", "$type", "LineString"], - paint: { - "line-color": "#ff0000", - "line-width": 2, - }, + addLayerIfNotExists(lineLayerId, { + id: lineLayerId, + type: "line", + source: sourceId, + filter: ["==", "$type", "LineString"], + paint: { + "line-color": "#ff0000", + "line-width": 2, }, - clusterLayer - ); + }); - map?.addLayer( - { - id: polygonLayerId, - type: "fill", - source: sourceId, - filter: ["==", "$type", "Polygon"], - paint: { - "fill-color": "#00ff00", - "fill-opacity": 0.4, - }, + addLayerIfNotExists(polygonLayerId, { + id: polygonLayerId, + type: "fill", + source: sourceId, + filter: ["==", "$type", "Polygon"], + paint: { + "fill-color": "#00ff00", + "fill-opacity": 0.4, }, - clusterLayer - ); + }); }); }); - //???? + return () => { layerIds.forEach((id) => { try { @@ -272,17 +306,12 @@ const ClusterLayer: FC = ({ } }); }; - }, [map, dispatch, spatialExtentsUUid]); + }, [spatialExtentsUUid, layerId, dispatch, map, clusterLayer]); // This is use to render the cluster circle and add event handle to circles useEffect(() => { if (map === null) return; - const layerId = getLayerId(map?.getContainer().id); - const clusterSourceId = getClusterSourceId(layerId); - const clusterLayer = getclusterLayerId(layerId); - const unclusterPointLayer = getUnclusterPointId(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. @@ -375,6 +404,14 @@ const ClusterLayer: FC = ({ unclusterPointLayerMouseLeaveEventHandler ); map?.on("mouseleave", clusterLayer, clusterLayerMouseLeaveEventHandler); + + map?.on("click", clusterLayer, clusterLayerMouseClickEventHandler); + + map?.on( + "click", + unclusterPointLayer, + unclusterPointLayerMouseClickEventHandler + ); }; map?.once("load", createLayers); @@ -400,8 +437,7 @@ const ClusterLayer: FC = ({ // Clean up resource when you click on the next spatial extents, map is // still working in this page. try { - if (map?.getSource(clusterSourceId)) map?.removeSource(clusterSourceId); - + // Remove layers first if (map?.getLayer(clusterLayer)) map?.removeLayer(clusterLayer); if (map?.getLayer(`${layerId}-cluster-count`)) @@ -412,72 +448,57 @@ const ClusterLayer: FC = ({ if (map?.getLayer(`${layerId}-unclustered-count`)) map?.removeLayer(`${layerId}-unclustered-count`); + + // Then remove the source + if (map?.getSource(clusterSourceId)) map?.removeSource(clusterSourceId); } catch (error) { // If source not found and throw exception then layer will not exist } }; // Make sure map is the only dependency so that it will not trigger twice run // where you will add source and remove layer accidentally. - }, [map]); - - useEffect(() => { - const layerId = getLayerId(map?.getContainer().id); - const clusterLayer = getclusterLayerId(layerId); - - // If user click a cluster layer, zoom into it a bit - map?.on("click", clusterLayer, clusterLayerMouseClickEventHandler); - return () => { - map?.off("click", clusterLayer, clusterLayerMouseClickEventHandler); - }; - }, [map, clusterLayerMouseClickEventHandler]); - - useEffect(() => { - addSpatialExtentsLayer(); - - // When user change the map style, for example change base map, all layer will be removed - // as per mapbox design, we need to listen to that even and add data - map?.on("styledata", addSpatialExtentsLayer); - - return () => { - map?.off("styledata", addSpatialExtentsLayer); - }; - }, [map, addSpatialExtentsLayer]); + }, [ + clusterLayer, + clusterLayerMouseClickEventHandler, + clusterLayerMouseEnterEventHandler, + clusterLayerMouseLeaveEventHandler, + clusterSourceId, + layerId, + map, + unclusterPointLayer, + unclusterPointLayerMouseClickEventHandler, + unclusterPointLayerMouseEnterEventHandler, + unclusterPointLayerMouseLeaveEventHandler, + ]); useEffect(() => { updateSource(); - - // When user change the map style, for example change base map, all layer will be removed - // as per mapbox design, we need to listen to that even and add data map?.on("styledata", updateSource); - return () => { map?.off("styledata", updateSource); }; }, [map, updateSource]); - // Setup the event handler useEffect(() => { - const layerId = getLayerId(map?.getContainer().id); - const unclusterPointLayer = getUnclusterPointId(layerId); - - map?.on( - "click", - unclusterPointLayer, - unclusterPointLayerMouseClickEventHandler - ); - + const cleanup = addSpatialExtentsLayer(); return () => { - // Map will be destroy by default, so in theory there is no need - // to clean up resource in this level - map?.off( - "click", - unclusterPointLayer, - unclusterPointLayerMouseClickEventHandler - ); + cleanup(); + // Remove all layers and sources created by addSpatialExtentsLayer + spatialExtentsUUid?.forEach((uuid: string) => { + const sourceId = createSourceId(layerId, uuid); + const pointLayerId = createPointsLayerId(sourceId); + const lineLayerId = createLinesLayerId(sourceId); + const polygonLayerId = createPolygonLayerId(sourceId); + + if (map?.getLayer(pointLayerId)) map.removeLayer(pointLayerId); + if (map?.getLayer(lineLayerId)) map.removeLayer(lineLayerId); + if (map?.getLayer(polygonLayerId)) map.removeLayer(polygonLayerId); + if (map?.getSource(sourceId)) map.removeSource(sourceId); + }); }; - }, [map, unclusterPointLayerMouseClickEventHandler]); + }, [addSpatialExtentsLayer, map, layerId, spatialExtentsUUid]); - return ; + return null; }; export default ClusterLayer; From 6925ed64e17acc254c83117f1207a8d04f44457d Mon Sep 17 00:00:00 2001 From: Lyn Long Date: Fri, 5 Jul 2024 17:16:46 +1000 Subject: [PATCH 3/7] :art: popup content --- .../map/mapbox/layers/ClusterLayer.tsx | 244 ++++++++++-------- src/components/map/mapbox/popup/MapPopup.tsx | 74 ++++++ src/components/result/GridResultCard.tsx | 145 +++++------ src/components/result/ListResultCard.tsx | 25 +- src/components/result/ResultCards.tsx | 4 + src/pages/search-page/SearchPage.tsx | 8 + src/pages/search-page/subpages/MapSection.tsx | 3 + .../search-page/subpages/ResultSection.tsx | 3 + 8 files changed, 311 insertions(+), 195 deletions(-) create mode 100644 src/components/map/mapbox/popup/MapPopup.tsx diff --git a/src/components/map/mapbox/layers/ClusterLayer.tsx b/src/components/map/mapbox/layers/ClusterLayer.tsx index 240fe7b9..9a65470e 100644 --- a/src/components/map/mapbox/layers/ClusterLayer.tsx +++ b/src/components/map/mapbox/layers/ClusterLayer.tsx @@ -1,4 +1,4 @@ -import React, { +import { FC, useCallback, useContext, @@ -24,12 +24,16 @@ import { centroid } from "@turf/turf"; import { GeoJSONSource, MapLayerMouseEvent, Popup } from "mapbox-gl"; import { useDispatch } from "react-redux"; import { AppDispatch } from "../../../common/store/store"; +import { createRoot } from "react-dom/client"; +import { CircularProgress } from "@mui/material"; +import MapPopup from "../popup/MapPopup"; interface ClusterLayerProps { // Vector tile layer should added to map collections: Array; // Event fired when user click on the point layer onDatasetSelected?: (uuid: Array) => void; + onClickPopup?: (uuid: string) => void; } const OPACITY = 0.6; @@ -65,7 +69,7 @@ const getLayerId = (id: string | undefined) => `cluster-layer-${id}`; const getClusterSourceId = (layerId: string) => `${layerId}-source`; -const getclusterLayerId = (layerId: string) => `${layerId}-clusters`; +const getClusterLayerId = (layerId: string) => `${layerId}-clusters`; const getUnclusterPointId = (layerId: string) => `${layerId}-unclustered-point`; @@ -81,11 +85,40 @@ const createSourceId = (layerId: string, uuid: string) => const ClusterLayer: FC = ({ collections, onDatasetSelected, + onClickPopup, }: ClusterLayerProps) => { const { map } = useContext(MapContext); const dispatch = useDispatch(); const [spatialExtentsUUid, setSpatialExtentsUUid] = useState>(); - console.log("spatialExtentsUUid", spatialExtentsUUid); + + const getCollectionData = useCallback( + async ({ + uuid, + forGeometryOnly, + }: { + uuid: string; + forGeometryOnly: boolean; + }) => { + const param: SearchParameters = { + filter: `id='${uuid}'`, + properties: forGeometryOnly ? "id,geometry" : undefined, + }; + + try { + const collections: OGCCollections = await dispatch( + fetchResultNoStore(param) + ).unwrap(); + // Given we use uuid, there will be one record only + const collection = collections.collections[0]; + return collection; + } catch (error) { + // Handle any errors here + console.error("Error fetching collection data:", error); + throw error; + } + }, + [dispatch] + ); const popup = useMemo( () => @@ -96,11 +129,23 @@ const ClusterLayer: FC = ({ [] ); + const renderPopupContent = useCallback( + async (uuid: string) => { + const collection = await getCollectionData({ + uuid, + forGeometryOnly: false, + }); + + return ; + }, + [getCollectionData, onClickPopup] + ); + const layerId = useMemo(() => getLayerId(map?.getContainer().id), [map]); const clusterSourceId = useMemo(() => getClusterSourceId(layerId), [layerId]); - const clusterLayer = useMemo(() => getclusterLayerId(layerId), [layerId]); + const clusterLayer = useMemo(() => getClusterLayerId(layerId), [layerId]); const unclusterPointLayer = useMemo( () => getUnclusterPointId(layerId), @@ -116,7 +161,7 @@ const ClusterLayer: FC = ({ }, [map, clusterSourceId, collections]); const unclusterPointLayerMouseEnterEventHandler = useCallback( - (ev: MapLayerMouseEvent): void => { + async (ev: MapLayerMouseEvent): Promise => { if (!ev.target || !map) return; ev.target.getCanvas().style.cursor = "pointer"; @@ -126,25 +171,35 @@ const ClusterLayer: FC = ({ const feature = ev.features[0] as Feature; const geometry = feature.geometry; const coordinates = geometry.coordinates.slice(); - const description = feature.properties?.uuid as string; - - if ( - ["mercator", "equirectangular"].includes(map.getProjection().name) - ) { - while (Math.abs(ev.lngLat.lng - coordinates[0]) > 180) { - coordinates[0] += ev.lngLat.lng > coordinates[0] ? 360 : -360; - } - } + const uuid = feature.properties?.uuid as string; - // Populate the popup and set its coordinates - // based on the feature found. - popup - .setLngLat(coordinates as [number, number]) - .setHTML(description) - .addTo(map); + try { + // Create a temporary div to render our React component + const popupNode = document.createElement("div"); + const root = createRoot(popupNode); + + // Show loading state + root.render(); + + // Set the popup on the map immediately with loading state + popup + .setLngLat(coordinates as [number, number]) + .setDOMContent(popupNode) + .addTo(map); + + // Fetch and render the content asynchronously + const popupContent = await renderPopupContent(uuid); + root.render(popupContent); + + // Update the popup content + popup.setDOMContent(popupNode); + } catch (error) { + console.error("Error rendering popup:", error); + popup.remove(); + } } }, - [map, popup] + [map, popup, renderPopupContent] ); const unclusterPointLayerMouseLeaveEventHandler = useCallback( @@ -172,23 +227,21 @@ const ClusterLayer: FC = ({ const unclusterPointLayerMouseClickEventHandler = useCallback( (ev: MapLayerMouseEvent): void => { // Make sure even same id under same area will be set once. - console.log("uncluster clicked"); if (ev.features) { const uuids = [ ...new Set(ev.features.map((feature) => feature.properties?.uuid)), ]; setSpatialExtentsUUid(uuids); - console.log({ uuids }); + // Give time for the state to be updated if (onDatasetSelected) setTimeout(() => onDatasetSelected(uuids), 100); } }, [setSpatialExtentsUUid, onDatasetSelected] ); - console.log({ spatialExtentsUUid }); + const clusterLayerMouseClickEventHandler = useCallback( (ev: MapLayerMouseEvent): void => { - console.log("clusterLayerMouseClick!!"); if (ev.lngLat) { map?.easeTo({ center: ev.lngLat, @@ -204,7 +257,7 @@ const ClusterLayer: FC = ({ const sourceIds = new Array(); const layerIds = new Array(); - spatialExtentsUUid?.forEach((uuid: string) => { + spatialExtentsUUid?.forEach(async (uuid: string) => { const sourceId = createSourceId(layerId, uuid); sourceIds.push(sourceId); @@ -217,76 +270,62 @@ const ClusterLayer: FC = ({ const polygonLayerId = createPolygonLayerId(sourceId); layerIds.push(polygonLayerId); - const param: SearchParameters = { - filter: `id='${uuid}'`, - properties: "id,geometry", - }; + const collection = await getCollectionData({ + uuid, + forGeometryOnly: true, + }); - dispatch(fetchResultNoStore(param)) - .unwrap() - .then((collections: OGCCollections) => { - // Give we use uuid, there will be one record only - const collection = collections.collections[0]; - - console.log("extent collection", collection); - console.log( - "extent spatial extents", - collection.extent?.getGeojsonExtents(1) - ); - console.log("extent Geometry", collection.getGeometry()); - - if (!map?.getSource(sourceId)) { - map?.addSource(sourceId, { - type: "geojson", - // data: collection.extent?.getGeojsonExtents(1), - data: collection.getGeometry(), - }); - } - - const addLayerIfNotExists = (id: string, layer: any) => { - if (!map?.getLayer(id)) { - map?.addLayer(layer, clusterLayer); - } - }; - // Add layers for each geometry type within the GeometryCollection - // if not exists - addLayerIfNotExists(pointLayerId, { - id: pointLayerId, - type: "symbol", - source: sourceId, - filter: ["==", "$type", "Point"], - layout: { - "icon-image": "marker-15", - "icon-size": 1.5, - "text-field": ["get", "title"], - "text-font": ["Open Sans Semibold", "Arial Unicode MS Bold"], - "text-offset": [0, 1.25], - "text-anchor": "top", - }, - }); - - addLayerIfNotExists(lineLayerId, { - id: lineLayerId, - type: "line", - source: sourceId, - filter: ["==", "$type", "LineString"], - paint: { - "line-color": "#ff0000", - "line-width": 2, - }, - }); - - addLayerIfNotExists(polygonLayerId, { - id: polygonLayerId, - type: "fill", - source: sourceId, - filter: ["==", "$type", "Polygon"], - paint: { - "fill-color": "#00ff00", - "fill-opacity": 0.4, - }, - }); + if (!map?.getSource(sourceId)) { + map?.addSource(sourceId, { + type: "geojson", + data: collection.getGeometry(), }); + } + + // util function to check if layer exists or not and add a before layer Id + const addLayerIfNotExists = (id: string, layer: any) => { + if (!map?.getLayer(id)) { + map?.addLayer(layer, clusterLayer); + } + }; + + // Add layers for each geometry type within the GeometryCollection if not exists + addLayerIfNotExists(pointLayerId, { + id: pointLayerId, + type: "symbol", + source: sourceId, + filter: ["==", "$type", "Point"], + layout: { + "icon-image": "marker-15", + "icon-size": 1.5, + "text-field": ["get", "title"], + "text-font": ["Open Sans Semibold", "Arial Unicode MS Bold"], + "text-offset": [0, 1.25], + "text-anchor": "top", + }, + }); + + addLayerIfNotExists(lineLayerId, { + id: lineLayerId, + type: "line", + source: sourceId, + filter: ["==", "$type", "LineString"], + paint: { + "line-color": "#ff0000", + "line-width": 2, + }, + }); + + addLayerIfNotExists(polygonLayerId, { + id: polygonLayerId, + type: "fill", + source: sourceId, + filter: ["==", "$type", "Polygon"], + paint: { + "fill-color": "#00ff00", + "fill-opacity": 0.4, + }, + }); }); return () => { @@ -306,7 +345,7 @@ const ClusterLayer: FC = ({ } }); }; - }, [spatialExtentsUUid, layerId, dispatch, map, clusterLayer]); + }, [spatialExtentsUUid, layerId, getCollectionData, map, clusterLayer]); // This is use to render the cluster circle and add event handle to circles useEffect(() => { @@ -457,19 +496,8 @@ const ClusterLayer: FC = ({ }; // Make sure map is the only dependency so that it will not trigger twice run // where you will add source and remove layer accidentally. - }, [ - clusterLayer, - clusterLayerMouseClickEventHandler, - clusterLayerMouseEnterEventHandler, - clusterLayerMouseLeaveEventHandler, - clusterSourceId, - layerId, - map, - unclusterPointLayer, - unclusterPointLayerMouseClickEventHandler, - unclusterPointLayerMouseEnterEventHandler, - unclusterPointLayerMouseLeaveEventHandler, - ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); useEffect(() => { updateSource(); diff --git a/src/components/map/mapbox/popup/MapPopup.tsx b/src/components/map/mapbox/popup/MapPopup.tsx new file mode 100644 index 00000000..4c35b6f5 --- /dev/null +++ b/src/components/map/mapbox/popup/MapPopup.tsx @@ -0,0 +1,74 @@ +import { FC } from "react"; +import { OGCCollection } from "../../../common/store/searchReducer"; +import { ThemeProvider } from "@mui/material/styles"; +import AppTheme from "../../../../utils/AppTheme"; +import ListResultCard from "../../../result/ListResultCard"; +import { + Card, + CardActionArea, + CardContent, + Stack, + Typography, +} from "@mui/material"; + +import { fontWeight } from "../../../../styles/constants"; + +interface MapPopupProps { + collection: OGCCollection; + onClickPopup?: ((uuid: string) => void) | undefined; +} + +const MapPopup: FC = ({ collection, onClickPopup }) => { + const handleClick = () => { + if (onClickPopup) { + onClickPopup(collection.id); + } + }; + return ( + + {/* */} + + + + + + {collection.title} + + + {collection.description} + + + + + + + ); +}; + +export default MapPopup; diff --git a/src/components/result/GridResultCard.tsx b/src/components/result/GridResultCard.tsx index f1491621..83fad26c 100644 --- a/src/components/result/GridResultCard.tsx +++ b/src/components/result/GridResultCard.tsx @@ -32,10 +32,10 @@ interface GridResultCardProps { stac: OGCCollection | undefined ) => void) | undefined; + onClickCard?: ((uuid: string) => void) | undefined; } const GridResultCard: React.FC = (props) => { - const navigate = useNavigate(); // links here may need to be changed, because only html links are wanted const generateLinkText = useCallback((linkLength: number) => { if (linkLength === 0) { @@ -47,88 +47,85 @@ const GridResultCard: React.FC = (props) => { return `${linkLength} Links`; }, []); - if (props.content) { - return ( - - { - if (props.content) { - const searchParams = new URLSearchParams(); - searchParams.append("uuid", props.content.id); - navigate(pageDefault.details + "?" + searchParams.toString()); - } - }} - > - - - - - - - - {trimContent(props.content?.title)} - - - - - - - - - {}} - isbordered="false" + const handleClick = () => { + if (props.onClickCard && props.content && props.content.id) { + props.onClickCard(props.content.id); + } + }; + + if (!props.content) return; + + return ( + + + + + + - - } - onClick={() => {}} - isbordered="false" - /> + + + {trimContent(props.content?.title)} + - {props.content.links && ( - - } - onClick={() => {}} - isbordered="false" - /> - - )} + + + + + + + {}} + isbordered="false" + /> + + + } + onClick={() => {}} + isbordered="false" + /> + + {props.content.links && ( } - onClick={(event) => - props?.onDownload && props.onDownload(event, props.content) - } + text={generateLinkText(props.content.links.length)} + startIcon={} + onClick={() => {}} isbordered="false" /> + )} + + } + onClick={(event) => + props?.onDownload && props.onDownload(event, props.content) + } + isbordered="false" + /> - - - ); - } - return undefined; + + + + ); }; export default GridResultCard; diff --git a/src/components/result/ListResultCard.tsx b/src/components/result/ListResultCard.tsx index 6db895bc..76272690 100644 --- a/src/components/result/ListResultCard.tsx +++ b/src/components/result/ListResultCard.tsx @@ -21,35 +21,34 @@ import { useCallback } from "react"; interface ResultCardProps { content: OGCCollection; - onRemoveLayer: + onRemoveLayer?: | (( event: React.MouseEvent, stac: OGCCollection ) => void) | undefined; - onDownload: + onDownload?: | (( event: React.MouseEvent, stac: OGCCollection ) => void) | undefined; - onTags: + onTags?: | (( event: React.MouseEvent, stac: OGCCollection ) => void) | undefined; - onMore: + onMore?: | (( event: React.MouseEvent, stac: OGCCollection ) => void) | undefined; + onClickCard?: ((uuid: string) => void) | undefined; } const ListResultCard = (props: ResultCardProps) => { - const navigate = useNavigate(); - // links here may need to be changed, because only html links are wanted const generateLinkText = useCallback((linkLength: number) => { if (linkLength === 0) { @@ -61,6 +60,12 @@ const ListResultCard = (props: ResultCardProps) => { return `${linkLength} Links`; }, []); + const handleClick = () => { + if (props.onClickCard && props.content && props.content.id) { + props.onClickCard(props.content.id); + } + }; + // TODO: buttons are changed, but the behaviors are fake / wrong return ( { data-testid="result-card-list" sx={{ width: "99%" }} > - { - const searchParams = new URLSearchParams(); - searchParams.append("uuid", props.content.id); - navigate(pageDefault.details + "?" + searchParams.toString()); - }} - > + void) | undefined; + onClickCard: ((uuid: string) => void) | undefined; } const renderCells = ( @@ -55,6 +56,7 @@ const renderCells = ( content={props.contents.result.collections[leftIndex]} onRemoveLayer={props.onRemoveLayer} onDownload={props.onDownload} + onClickCard={props.onClickCard} data-testid="result-cards-grid" /> @@ -63,6 +65,7 @@ const renderCells = ( content={props.contents.result.collections[rightIndex]} onRemoveLayer={props.onRemoveLayer} onDownload={props.onDownload} + onClickCard={props.onClickCard} /> @@ -85,6 +88,7 @@ const renderRows = ( onDownload={props.onDownload} onTags={props.onTags} onMore={props.onMore} + onClickCard={props.onClickCard} /> ); diff --git a/src/pages/search-page/SearchPage.tsx b/src/pages/search-page/SearchPage.tsx index 9aa7531d..0b03cff8 100644 --- a/src/pages/search-page/SearchPage.tsx +++ b/src/pages/search-page/SearchPage.tsx @@ -170,6 +170,12 @@ const SearchPage = () => { // to this page. useEffect(() => handleNavigation(), [handleNavigation]); + const onClickNavigateToDetailPage = (uuid: string) => { + const searchParams = new URLSearchParams(); + searchParams.append("uuid", uuid); + navigate(pageDefault.details + "?" + searchParams.toString()); + }; + return ( { contents={contents} onRemoveLayer={undefined} onVisibilityChanged={onVisibilityChanged} + onClickCard={onClickNavigateToDetailPage} /> { onMapZoomOrMove={onMapZoomOrMove} onToggleClicked={onToggleDisplay} onDatasetSelected={onDatasetSelected} + onClickPopup={onClickNavigateToDetailPage} /> diff --git a/src/pages/search-page/subpages/MapSection.tsx b/src/pages/search-page/subpages/MapSection.tsx index 69593293..27fbb3e5 100644 --- a/src/pages/search-page/subpages/MapSection.tsx +++ b/src/pages/search-page/subpages/MapSection.tsx @@ -24,6 +24,7 @@ interface MapSectionProps { ) => void; onToggleClicked: (v: boolean) => void; onDatasetSelected?: (uuid: Array) => void; + onClickPopup: (uuid: string) => void; } const MapSection: React.FC = ({ @@ -32,6 +33,7 @@ const MapSection: React.FC = ({ onDatasetSelected, layers, showFullMap, + onClickPopup, }) => { return ( = ({ diff --git a/src/pages/search-page/subpages/ResultSection.tsx b/src/pages/search-page/subpages/ResultSection.tsx index a40505f2..d2c80594 100644 --- a/src/pages/search-page/subpages/ResultSection.tsx +++ b/src/pages/search-page/subpages/ResultSection.tsx @@ -16,6 +16,7 @@ interface SearchResultListProps { collection: OGCCollection | undefined ) => void; onVisibilityChanged?: (v: SearchResultLayoutEnum) => void; + onClickCard?: (uuid: string) => void; } const ResultSection: React.FC = ({ @@ -23,6 +24,7 @@ const ResultSection: React.FC = ({ contents, onRemoveLayer, onVisibilityChanged, + onClickCard, }) => { // Use to remember last layout, it is either LIST or GRID at the moment const [currentLayout, setCurrentLayout] = useState< @@ -65,6 +67,7 @@ const ResultSection: React.FC = ({ onDownload={undefined} onTags={undefined} onMore={undefined} + onClickCard={onClickCard} /> ); From 2a4c3ae6e83b3d1de9a009251e4afed6314bfbcc Mon Sep 17 00:00:00 2001 From: Lyn Long Date: Tue, 9 Jul 2024 10:55:09 +1000 Subject: [PATCH 4/7] :art: improve search page content height and map popup transition --- .../map/mapbox/layers/ClusterLayer.tsx | 76 +++++++------------ src/components/map/mapbox/popup/MapPopup.tsx | 32 +++++++- src/components/result/ResultCards.tsx | 4 +- src/pages/search-page/SearchPage.tsx | 6 +- src/pages/search-page/subpages/MapSection.tsx | 2 +- .../search-page/subpages/ResultSection.tsx | 2 + 6 files changed, 66 insertions(+), 56 deletions(-) diff --git a/src/components/map/mapbox/layers/ClusterLayer.tsx b/src/components/map/mapbox/layers/ClusterLayer.tsx index 9a65470e..b39696fe 100644 --- a/src/components/map/mapbox/layers/ClusterLayer.tsx +++ b/src/components/map/mapbox/layers/ClusterLayer.tsx @@ -25,8 +25,8 @@ import { GeoJSONSource, MapLayerMouseEvent, Popup } from "mapbox-gl"; import { useDispatch } from "react-redux"; import { AppDispatch } from "../../../common/store/store"; import { createRoot } from "react-dom/client"; -import { CircularProgress } from "@mui/material"; import MapPopup from "../popup/MapPopup"; +import { CircularProgress } from "@mui/material"; interface ClusterLayerProps { // Vector tile layer should added to map @@ -91,17 +91,13 @@ const ClusterLayer: FC = ({ const dispatch = useDispatch(); const [spatialExtentsUUid, setSpatialExtentsUUid] = useState>(); + // util function to get collection data given uuid + // and based on boolean indicating whether to fetch only geometry-related properties const getCollectionData = useCallback( - async ({ - uuid, - forGeometryOnly, - }: { - uuid: string; - forGeometryOnly: boolean; - }) => { + async ({ uuid, geometryOnly }: { uuid: string; geometryOnly: boolean }) => { const param: SearchParameters = { filter: `id='${uuid}'`, - properties: forGeometryOnly ? "id,geometry" : undefined, + properties: geometryOnly ? "id,geometry" : undefined, }; try { @@ -125,6 +121,7 @@ const ClusterLayer: FC = ({ new Popup({ closeButton: false, closeOnClick: false, + maxWidth: "none", }), [] ); @@ -133,14 +130,18 @@ const ClusterLayer: FC = ({ async (uuid: string) => { const collection = await getCollectionData({ uuid, - forGeometryOnly: false, + geometryOnly: false, }); - return ; }, [getCollectionData, onClickPopup] ); + const renderLoadingPopup = useCallback( + () => , + [] + ); + const layerId = useMemo(() => getLayerId(map?.getContainer().id), [map]); const clusterSourceId = useMemo(() => getClusterSourceId(layerId), [layerId]); @@ -173,33 +174,25 @@ const ClusterLayer: FC = ({ const coordinates = geometry.coordinates.slice(); const uuid = feature.properties?.uuid as string; - try { - // Create a temporary div to render our React component - const popupNode = document.createElement("div"); - const root = createRoot(popupNode); - - // Show loading state - root.render(); + // Create a new div container for the popup + const popupNode = document.createElement("div"); + const root = createRoot(popupNode); - // Set the popup on the map immediately with loading state - popup - .setLngLat(coordinates as [number, number]) - .setDOMContent(popupNode) - .addTo(map); + // Render a loading state in the popup + root.render(renderLoadingPopup()); - // Fetch and render the content asynchronously - const popupContent = await renderPopupContent(uuid); - root.render(popupContent); + // Set the popup's position and content, then add it to the map + popup + .setLngLat(coordinates as [number, number]) + .setDOMContent(popupNode) + .addTo(map); - // Update the popup content - popup.setDOMContent(popupNode); - } catch (error) { - console.error("Error rendering popup:", error); - popup.remove(); - } + // Fetch and render the actual content for the popup + const content = await renderPopupContent(uuid); + root.render(content); } }, - [map, popup, renderPopupContent] + [map, popup, renderLoadingPopup, renderPopupContent] ); const unclusterPointLayerMouseLeaveEventHandler = useCallback( @@ -272,7 +265,7 @@ const ClusterLayer: FC = ({ const collection = await getCollectionData({ uuid, - forGeometryOnly: true, + geometryOnly: true, }); if (!map?.getSource(sourceId)) { @@ -322,7 +315,7 @@ const ClusterLayer: FC = ({ source: sourceId, filter: ["==", "$type", "Polygon"], paint: { - "fill-color": "#00ff00", + "fill-color": "#fff", "fill-opacity": 0.4, }, }); @@ -507,22 +500,11 @@ const ClusterLayer: FC = ({ }; }, [map, updateSource]); + // Remove all layers and sources created by addSpatialExtentsLayer useEffect(() => { const cleanup = addSpatialExtentsLayer(); return () => { cleanup(); - // Remove all layers and sources created by addSpatialExtentsLayer - spatialExtentsUUid?.forEach((uuid: string) => { - const sourceId = createSourceId(layerId, uuid); - const pointLayerId = createPointsLayerId(sourceId); - const lineLayerId = createLinesLayerId(sourceId); - const polygonLayerId = createPolygonLayerId(sourceId); - - if (map?.getLayer(pointLayerId)) map.removeLayer(pointLayerId); - if (map?.getLayer(lineLayerId)) map.removeLayer(lineLayerId); - if (map?.getLayer(polygonLayerId)) map.removeLayer(polygonLayerId); - if (map?.getSource(sourceId)) map.removeSource(sourceId); - }); }; }, [addSpatialExtentsLayer, map, layerId, spatialExtentsUUid]); diff --git a/src/components/map/mapbox/popup/MapPopup.tsx b/src/components/map/mapbox/popup/MapPopup.tsx index 4c35b6f5..0bc7106f 100644 --- a/src/components/map/mapbox/popup/MapPopup.tsx +++ b/src/components/map/mapbox/popup/MapPopup.tsx @@ -4,26 +4,51 @@ import { ThemeProvider } from "@mui/material/styles"; import AppTheme from "../../../../utils/AppTheme"; import ListResultCard from "../../../result/ListResultCard"; import { + Box, Card, CardActionArea, CardContent, + CircularProgress, Stack, Typography, } from "@mui/material"; - import { fontWeight } from "../../../../styles/constants"; interface MapPopupProps { collection: OGCCollection; onClickPopup?: ((uuid: string) => void) | undefined; + isLoading?: boolean; } -const MapPopup: FC = ({ collection, onClickPopup }) => { +const POPUP_WIDTH = "250px"; +const POPUP_HEIGHT = "180px"; + +const MapPopup: FC = ({ + collection, + onClickPopup, + isLoading = false, +}) => { const handleClick = () => { if (onClickPopup) { onClickPopup(collection.id); } }; + + if (isLoading) + return ( + + + + ); return ( {/* = ({ collection, onClickPopup }) => { /> */} @@ -59,6 +84,7 @@ const MapPopup: FC = ({ collection, onClickPopup }) => { display: "-webkit-box", WebkitLineClamp: "4", WebkitBoxOrient: "vertical", + maxWidth: "100%", }} > {collection.description} diff --git a/src/components/result/ResultCards.tsx b/src/components/result/ResultCards.tsx index 392c1738..f8efe45c 100644 --- a/src/components/result/ResultCards.tsx +++ b/src/components/result/ResultCards.tsx @@ -98,7 +98,7 @@ const ResultCards = (props: ResultCardsProps) => { if (props.layout === SearchResultLayoutEnum.LIST) { return ( { // or else render grid view return ( { // to this page. useEffect(() => handleNavigation(), [handleNavigation]); - const onClickNavigateToDetailPage = (uuid: string) => { + const handleNavigateToDetailPage = (uuid: string) => { const searchParams = new URLSearchParams(); searchParams.append("uuid", uuid); navigate(pageDefault.details + "?" + searchParams.toString()); @@ -204,7 +204,7 @@ const SearchPage = () => { contents={contents} onRemoveLayer={undefined} onVisibilityChanged={onVisibilityChanged} - onClickCard={onClickNavigateToDetailPage} + onClickCard={handleNavigateToDetailPage} /> { onMapZoomOrMove={onMapZoomOrMove} onToggleClicked={onToggleDisplay} onDatasetSelected={onDatasetSelected} - onClickPopup={onClickNavigateToDetailPage} + onClickPopup={handleNavigateToDetailPage} /> diff --git a/src/pages/search-page/subpages/MapSection.tsx b/src/pages/search-page/subpages/MapSection.tsx index 27fbb3e5..949ea431 100644 --- a/src/pages/search-page/subpages/MapSection.tsx +++ b/src/pages/search-page/subpages/MapSection.tsx @@ -45,7 +45,7 @@ const MapSection: React.FC = ({ flex: 1, }} > - + = ({ item sx={{ width: "700px", + height: "81vh", + overflow: "hidden", display: visibility === SearchResultLayoutEnum.VISIBLE ? "block" : "none", }} From b2f255e47f90847fd824e927a5824d94146660ae Mon Sep 17 00:00:00 2001 From: Lyn Long Date: Tue, 9 Jul 2024 11:27:41 +1000 Subject: [PATCH 5/7] :art: improve map cluster size and radius --- .../map/mapbox/layers/ClusterLayer.tsx | 50 +++++++++++++------ 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/src/components/map/mapbox/layers/ClusterLayer.tsx b/src/components/map/mapbox/layers/ClusterLayer.tsx index b39696fe..167a5065 100644 --- a/src/components/map/mapbox/layers/ClusterLayer.tsx +++ b/src/components/map/mapbox/layers/ClusterLayer.tsx @@ -39,6 +39,26 @@ interface ClusterLayerProps { const OPACITY = 0.6; const STROKE_WIDTH = 1; +// Constants for cluster circle sizes +//These define the radius(px) of the circles used to represent clusters on the map. +const DEFAULT_CIRCLE_SIZE = 20; +const MEDIUM_CIRCLE_SIZE = 30; +const LARGE_CIRCLE_SIZE = 40; +const EXTRA_LARGE_CIRCLE_SIZE = 60; + +// Constants for cluster circle colors +// These define the colors used for the circles representing clusters of different sizes. +const DEFAULT_COLOR = "#51bbd6"; +const MEDIUM_COLOR = "#f1f075"; +const LARGE_COLOR = "#f28cb1"; +const EXTRA_LARGE_COLOR = "#fe8cf1"; + +// Constants for point count thresholds +// These define the boundaries between different cluster sizes. +const MEDIUM_POINT_COUNT = 20; +const LARGE_POINT_COUNT = 30; +const EXTRA_LARGE_POINT_COUNT = 50; + // Given an array of OGCCollections, we convert it to a cluster layer by adding all the feature items // in a collection to the FeatureCollection const createClusterDataSource = ( @@ -357,7 +377,7 @@ const ClusterLayer: FC = ({ data: createClusterDataSource(undefined), cluster: true, clusterMaxZoom: 14, - clusterRadius: 10, + clusterRadius: 50, }); // Add layers for multiple items, that is cluster @@ -373,24 +393,24 @@ const ClusterLayer: FC = ({ "circle-color": [ "step", ["get", "point_count"], - "#51bbd6", - 5, - "#f1f075", - 30, - "#f28cb1", - 50, - "#fe8cf1", + DEFAULT_COLOR, + MEDIUM_POINT_COUNT, + MEDIUM_COLOR, + LARGE_POINT_COUNT, + LARGE_COLOR, + EXTRA_LARGE_POINT_COUNT, + EXTRA_LARGE_COLOR, ], "circle-radius": [ "step", ["get", "point_count"], - 10, - 20, - 20, - 30, - 30, - 50, - 50, + DEFAULT_CIRCLE_SIZE, + MEDIUM_POINT_COUNT, + MEDIUM_CIRCLE_SIZE, + LARGE_POINT_COUNT, + LARGE_CIRCLE_SIZE, + EXTRA_LARGE_POINT_COUNT, + EXTRA_LARGE_CIRCLE_SIZE, ], }, }); From a4134b3ac361567ecf9a804aad89cf716e1054d8 Mon Sep 17 00:00:00 2001 From: Lyn Long Date: Wed, 10 Jul 2024 11:55:46 +1000 Subject: [PATCH 6/7] :art: refactor clusterLayer --- .../map/mapbox/layers/ClusterLayer.tsx | 241 +++++++++++------- src/components/map/mapbox/popup/MapPopup.tsx | 10 +- 2 files changed, 147 insertions(+), 104 deletions(-) diff --git a/src/components/map/mapbox/layers/ClusterLayer.tsx b/src/components/map/mapbox/layers/ClusterLayer.tsx index 167a5065..d1b36f8c 100644 --- a/src/components/map/mapbox/layers/ClusterLayer.tsx +++ b/src/components/map/mapbox/layers/ClusterLayer.tsx @@ -26,7 +26,32 @@ import { useDispatch } from "react-redux"; import { AppDispatch } from "../../../common/store/store"; import { createRoot } from "react-dom/client"; import MapPopup from "../popup/MapPopup"; -import { CircularProgress } from "@mui/material"; + +interface ClusterSize { + default?: number | string; + medium?: number | string; + large?: number | string; + extra_large?: number | string; +} + +interface ClusterLayerConfig { + pointCountThresholds?: ClusterSize; + clusterMaxZoom?: number; + clusterRadius?: number; + clusterCircleSize?: ClusterSize; + clusterCircleColor?: ClusterSize; + clusterCircleOpacity?: number; + clusterCircleStrokeWidth?: number; + clusterCircleStrokeColor?: string; + clusterCircleTextSize?: number; + unclusterPointColor?: string; + unclusterPointOpacity?: number; + unclusterPointStrokeWidth?: number; + unclusterPointStrokeColor?: string; + unclusterPointRadius?: number; +} + +interface SpatialExtentsLayerConfig {} interface ClusterLayerProps { // Vector tile layer should added to map @@ -34,30 +59,43 @@ interface ClusterLayerProps { // Event fired when user click on the point layer onDatasetSelected?: (uuid: Array) => void; onClickPopup?: (uuid: string) => void; + clusterLayerConfig?: ClusterLayerConfig; + spatialExtentsLayerConfig?: SpatialExtentsLayerConfig; } -const OPACITY = 0.6; -const STROKE_WIDTH = 1; - -// Constants for cluster circle sizes -//These define the radius(px) of the circles used to represent clusters on the map. -const DEFAULT_CIRCLE_SIZE = 20; -const MEDIUM_CIRCLE_SIZE = 30; -const LARGE_CIRCLE_SIZE = 40; -const EXTRA_LARGE_CIRCLE_SIZE = 60; - -// Constants for cluster circle colors -// These define the colors used for the circles representing clusters of different sizes. -const DEFAULT_COLOR = "#51bbd6"; -const MEDIUM_COLOR = "#f1f075"; -const LARGE_COLOR = "#f28cb1"; -const EXTRA_LARGE_COLOR = "#fe8cf1"; - -// Constants for point count thresholds -// These define the boundaries between different cluster sizes. -const MEDIUM_POINT_COUNT = 20; -const LARGE_POINT_COUNT = 30; -const EXTRA_LARGE_POINT_COUNT = 50; +const defaultClusterLayerConfig: ClusterLayerConfig = { + // point count thresholds define the boundaries between different cluster sizes. + pointCountThresholds: { + medium: 20, + large: 30, + extra_large: 50, + }, + clusterMaxZoom: 14, + clusterRadius: 50, + // circle sizes define the radius(px) of the circles used to represent clusters on the map. + clusterCircleSize: { + default: 20, + medium: 30, + large: 40, + extra_large: 60, + }, + //cluster circle colors define the colors used for the circles representing clusters of different sizes. + clusterCircleColor: { + default: "#51bbd6", + medium: "#f1f075", + large: "#f28cb1", + extra_large: "#fe8cf1", + }, + clusterCircleOpacity: 0.6, + clusterCircleStrokeWidth: 1, + clusterCircleStrokeColor: "#fff", + clusterCircleTextSize: 12, + unclusterPointColor: "#51bbd6", + unclusterPointOpacity: 0.6, + unclusterPointStrokeWidth: 1, + unclusterPointStrokeColor: "#fff", + unclusterPointRadius: 8, +}; // Given an array of OGCCollections, we convert it to a cluster layer by adding all the feature items // in a collection to the FeatureCollection @@ -83,6 +121,34 @@ const createClusterDataSource = ( return featureCollections; }; +// util function for get cluster layer config +// the default cluster layer config will be replaced by given custom cluster layer config +const getClusterLayerConfig = ({ + clusterLayerConfig, + defaultClusterLayerConfig, +}: { + clusterLayerConfig?: ClusterLayerConfig; + defaultClusterLayerConfig: ClusterLayerConfig; +}): ClusterLayerConfig => { + if (!clusterLayerConfig) return defaultClusterLayerConfig; + return { + ...defaultClusterLayerConfig, + ...clusterLayerConfig, + pointCountThresholds: { + ...defaultClusterLayerConfig.pointCountThresholds, + ...clusterLayerConfig.pointCountThresholds, + }, + clusterCircleSize: { + ...defaultClusterLayerConfig.clusterCircleSize, + ...clusterLayerConfig.clusterCircleSize, + }, + clusterCircleColor: { + ...defaultClusterLayerConfig.clusterCircleColor, + ...clusterLayerConfig.clusterCircleColor, + }, + }; +}; + // 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) => `cluster-layer-${id}`; @@ -106,6 +172,7 @@ const ClusterLayer: FC = ({ collections, onDatasetSelected, onClickPopup, + clusterLayerConfig, }: ClusterLayerProps) => { const { map } = useContext(MapContext); const dispatch = useDispatch(); @@ -128,9 +195,8 @@ const ClusterLayer: FC = ({ const collection = collections.collections[0]; return collection; } catch (error) { - // Handle any errors here console.error("Error fetching collection data:", error); - throw error; + // TODO: handle error in ErrorBoundary } }, [dispatch] @@ -152,6 +218,7 @@ const ClusterLayer: FC = ({ uuid, geometryOnly: false, }); + if (!collection) return; return ; }, [getCollectionData, onClickPopup] @@ -181,7 +248,7 @@ const ClusterLayer: FC = ({ } }, [map, clusterSourceId, collections]); - const unclusterPointLayerMouseEnterEventHandler = useCallback( + const onUnclusterPointMouseEnter = useCallback( async (ev: MapLayerMouseEvent): Promise => { if (!ev.target || !map) return; @@ -215,7 +282,7 @@ const ClusterLayer: FC = ({ [map, popup, renderLoadingPopup, renderPopupContent] ); - const unclusterPointLayerMouseLeaveEventHandler = useCallback( + const onUnclusterPointMouseLeave = useCallback( (ev: MapLayerMouseEvent) => { ev.target.getCanvas().style.cursor = ""; popup.remove(); @@ -223,21 +290,15 @@ const ClusterLayer: FC = ({ [popup] ); - const clusterLayerMouseEnterEventHandler = useCallback( - (ev: MapLayerMouseEvent) => { - ev.target.getCanvas().style.cursor = "pointer"; - }, - [] - ); + const onClusterCircleMouseEnter = useCallback((ev: MapLayerMouseEvent) => { + ev.target.getCanvas().style.cursor = "pointer"; + }, []); - const clusterLayerMouseLeaveEventHandler = useCallback( - (ev: MapLayerMouseEvent) => { - ev.target.getCanvas().style.cursor = ""; - }, - [] - ); + const onClusterCircleMouseLeave = useCallback((ev: MapLayerMouseEvent) => { + ev.target.getCanvas().style.cursor = ""; + }, []); - const unclusterPointLayerMouseClickEventHandler = useCallback( + const onUnclusterPointMouseClick = useCallback( (ev: MapLayerMouseEvent): void => { // Make sure even same id under same area will be set once. if (ev.features) { @@ -253,7 +314,7 @@ const ClusterLayer: FC = ({ [setSpatialExtentsUUid, onDatasetSelected] ); - const clusterLayerMouseClickEventHandler = useCallback( + const onClusterCircleMouseClick = useCallback( (ev: MapLayerMouseEvent): void => { if (ev.lngLat) { map?.easeTo({ @@ -291,7 +352,7 @@ const ClusterLayer: FC = ({ if (!map?.getSource(sourceId)) { map?.addSource(sourceId, { type: "geojson", - data: collection.getGeometry(), + data: collection?.getGeometry(), }); } @@ -347,6 +408,8 @@ const ClusterLayer: FC = ({ if (map?.getLayer(id)) map?.removeLayer(id); } catch (error) { // Ok to ignore as map gone if we hit this error + console.log("Ok to ignore remove layer error", error); + // TODO: handle error in ErrorBoundary } }); @@ -355,6 +418,8 @@ const ClusterLayer: FC = ({ if (map?.getSource(id)) map?.removeSource(id); } catch (error) { // Ok to ignore as map gone if we hit this error + console.log("Ok to ignore remove source error", error); + // TODO: handle error in ErrorBoundary } }); }; @@ -372,6 +437,11 @@ const ClusterLayer: FC = ({ // these changes so use this check to avoid duplicate add if (map?.getSource(clusterSourceId)) return; + const config = getClusterLayerConfig({ + clusterLayerConfig, + defaultClusterLayerConfig, + }); + map?.addSource(clusterSourceId, { type: "geojson", data: createClusterDataSource(undefined), @@ -387,30 +457,30 @@ const ClusterLayer: FC = ({ source: clusterSourceId, filter: ["has", "point_count"], paint: { - "circle-stroke-width": STROKE_WIDTH, - "circle-stroke-color": "#fff", - "circle-opacity": OPACITY, + "circle-stroke-width": config.clusterCircleStrokeWidth, + "circle-stroke-color": config.clusterCircleStrokeColor, + "circle-opacity": config.clusterCircleOpacity, "circle-color": [ "step", ["get", "point_count"], - DEFAULT_COLOR, - MEDIUM_POINT_COUNT, - MEDIUM_COLOR, - LARGE_POINT_COUNT, - LARGE_COLOR, - EXTRA_LARGE_POINT_COUNT, - EXTRA_LARGE_COLOR, + config.clusterCircleColor?.default, + config.pointCountThresholds?.medium, + config.clusterCircleColor?.medium, + config.pointCountThresholds?.large, + config.clusterCircleColor?.large, + config.pointCountThresholds?.extra_large, + config.clusterCircleColor?.extra_large, ], "circle-radius": [ "step", ["get", "point_count"], - DEFAULT_CIRCLE_SIZE, - MEDIUM_POINT_COUNT, - MEDIUM_CIRCLE_SIZE, - LARGE_POINT_COUNT, - LARGE_CIRCLE_SIZE, - EXTRA_LARGE_POINT_COUNT, - EXTRA_LARGE_CIRCLE_SIZE, + config.clusterCircleSize?.default, + config.pointCountThresholds?.medium, + config.clusterCircleSize?.medium, + config.pointCountThresholds?.large, + config.clusterCircleSize?.large, + config.pointCountThresholds?.extra_large, + config.clusterCircleSize?.extra_large, ], }, }); @@ -423,7 +493,7 @@ const ClusterLayer: FC = ({ layout: { "text-field": "{point_count_abbreviated}", "text-font": ["DIN Offc Pro Medium", "Arial Unicode MS Bold"], - "text-size": 12, + "text-size": config.clusterCircleTextSize, }, }); // Layer for only 1 item in the circle @@ -433,37 +503,25 @@ const ClusterLayer: FC = ({ source: clusterSourceId, filter: ["!", ["has", "point_count"]], paint: { - "circle-opacity": OPACITY, - "circle-color": "#11b4da", - "circle-radius": 8, - "circle-stroke-width": STROKE_WIDTH, - "circle-stroke-color": "#fff", + "circle-opacity": config.unclusterPointOpacity, + "circle-color": config.unclusterPointColor, + "circle-radius": config.unclusterPointRadius, + "circle-stroke-width": config.unclusterPointStrokeWidth, + "circle-stroke-color": config.unclusterPointStrokeColor, }, }); // Change the cursor to a pointer for uncluster point - map?.on( - "mouseenter", - unclusterPointLayer, - unclusterPointLayerMouseEnterEventHandler - ); - map?.on("mouseenter", clusterLayer, clusterLayerMouseEnterEventHandler); + map?.on("mouseenter", unclusterPointLayer, onUnclusterPointMouseEnter); + map?.on("mouseenter", clusterLayer, onClusterCircleMouseEnter); // Change the cursor back to default when it leaves the unclustered points - map?.on( - "mouseleave", - unclusterPointLayer, - unclusterPointLayerMouseLeaveEventHandler - ); - map?.on("mouseleave", clusterLayer, clusterLayerMouseLeaveEventHandler); + map?.on("mouseleave", unclusterPointLayer, onUnclusterPointMouseLeave); + map?.on("mouseleave", clusterLayer, onClusterCircleMouseLeave); - map?.on("click", clusterLayer, clusterLayerMouseClickEventHandler); + map?.on("click", clusterLayer, onClusterCircleMouseClick); - map?.on( - "click", - unclusterPointLayer, - unclusterPointLayerMouseClickEventHandler - ); + map?.on("click", unclusterPointLayer, onUnclusterPointMouseClick); }; map?.once("load", createLayers); @@ -473,18 +531,10 @@ const ClusterLayer: FC = ({ map?.on("styledata", createLayers); return () => { - map?.off( - "mouseenter", - unclusterPointLayer, - unclusterPointLayerMouseEnterEventHandler - ); - map?.off("mouseenter", clusterLayer, clusterLayerMouseEnterEventHandler); - map?.off( - "mouseleave", - unclusterPointLayer, - unclusterPointLayerMouseLeaveEventHandler - ); - map?.off("mouseleave", clusterLayer, clusterLayerMouseLeaveEventHandler); + map?.off("mouseenter", unclusterPointLayer, onUnclusterPointMouseEnter); + map?.off("mouseenter", clusterLayer, onClusterCircleMouseEnter); + map?.off("mouseleave", unclusterPointLayer, onUnclusterPointMouseLeave); + map?.off("mouseleave", clusterLayer, onClusterCircleMouseLeave); // Clean up resource when you click on the next spatial extents, map is // still working in this page. @@ -505,6 +555,7 @@ const ClusterLayer: FC = ({ if (map?.getSource(clusterSourceId)) map?.removeSource(clusterSourceId); } catch (error) { // If source not found and throw exception then layer will not exist + // TODO: handle error in ErrorBoundary } }; // Make sure map is the only dependency so that it will not trigger twice run diff --git a/src/components/map/mapbox/popup/MapPopup.tsx b/src/components/map/mapbox/popup/MapPopup.tsx index 0bc7106f..69b631ba 100644 --- a/src/components/map/mapbox/popup/MapPopup.tsx +++ b/src/components/map/mapbox/popup/MapPopup.tsx @@ -51,15 +51,7 @@ const MapPopup: FC = ({ ); return ( - {/* */} - + From 8f937412d0e2c91c26113a87c3b24a256caca282 Mon Sep 17 00:00:00 2001 From: Lyn Long Date: Wed, 10 Jul 2024 15:44:21 +1000 Subject: [PATCH 7/7] :art: refactor code and remove unused code --- src/components/map/mapbox/layers/ClusterLayer.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/map/mapbox/layers/ClusterLayer.tsx b/src/components/map/mapbox/layers/ClusterLayer.tsx index d1b36f8c..2250a95e 100644 --- a/src/components/map/mapbox/layers/ClusterLayer.tsx +++ b/src/components/map/mapbox/layers/ClusterLayer.tsx @@ -51,8 +51,6 @@ interface ClusterLayerConfig { unclusterPointRadius?: number; } -interface SpatialExtentsLayerConfig {} - interface ClusterLayerProps { // Vector tile layer should added to map collections: Array; @@ -60,7 +58,6 @@ interface ClusterLayerProps { onDatasetSelected?: (uuid: Array) => void; onClickPopup?: (uuid: string) => void; clusterLayerConfig?: ClusterLayerConfig; - spatialExtentsLayerConfig?: SpatialExtentsLayerConfig; } const defaultClusterLayerConfig: ClusterLayerConfig = { @@ -192,8 +189,7 @@ const ClusterLayer: FC = ({ fetchResultNoStore(param) ).unwrap(); // Given we use uuid, there will be one record only - const collection = collections.collections[0]; - return collection; + return collections.collections[0]; } catch (error) { console.error("Error fetching collection data:", error); // TODO: handle error in ErrorBoundary