From ebb582686ffc0ac5a1f6e04add053b4a3eb3fdfe Mon Sep 17 00:00:00 2001 From: Konstantinos Markopoulos Date: Wed, 11 Dec 2024 11:44:07 +0200 Subject: [PATCH 1/8] Add isSelected selector --- .../website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx | 6 +++--- packages/website/src/store/Homepage/homepageSlice.ts | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx b/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx index 0115242f8..ccb4285b0 100644 --- a/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx +++ b/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx @@ -28,14 +28,14 @@ interface SiteMarkerProps { * All in one site marker with icon, offset duplicates, and popup built in. */ export default function SiteMarker({ site, setCenter }: SiteMarkerProps) { - const siteOnMap = useSelector(siteOnMapSelector); + const isSelected = useSelector(isSelectedOnMapSelector(site.id)); const { map } = useLeaflet(); const dispatch = useDispatch(); const { tempWeeklyAlert } = site.collectionData || {}; const markerIcon = useMarkerIcon( hasDeployedSpotter(site), site.hasHobo, - siteOnMap?.id === site.id, + isSelected, alertColorFinder(tempWeeklyAlert), alertIconFinder(tempWeeklyAlert), ); @@ -57,7 +57,7 @@ export default function SiteMarker({ site, setCenter }: SiteMarkerProps) { icon={markerIcon} position={[lat, lng + offset]} > - + {isSelected && } ); })} diff --git a/packages/website/src/store/Homepage/homepageSlice.ts b/packages/website/src/store/Homepage/homepageSlice.ts index d1fd659d7..9cbf47cc6 100644 --- a/packages/website/src/store/Homepage/homepageSlice.ts +++ b/packages/website/src/store/Homepage/homepageSlice.ts @@ -38,6 +38,9 @@ const homepageSlice = createSlice({ }, }); +export const isSelectedOnMapSelector = (id: number) => (state: RootState) => + state.homepage.siteOnMap?.id === id; + export const siteOnMapSelector = ( state: RootState, ): HomePageState['siteOnMap'] => state.homepage.siteOnMap; From 1a1d6e41e6fe1aaae45c1b6192808202cfffd1af Mon Sep 17 00:00:00 2001 From: Konstantinos Markopoulos Date: Wed, 11 Dec 2024 17:38:23 +0200 Subject: [PATCH 2/8] Incrementally mount visible site markers --- .../src/routes/HomeMap/Map/Markers/index.tsx | 88 ++++++++----------- 1 file changed, 36 insertions(+), 52 deletions(-) diff --git a/packages/website/src/routes/HomeMap/Map/Markers/index.tsx b/packages/website/src/routes/HomeMap/Map/Markers/index.tsx index e5e2012fc..b92b564c8 100644 --- a/packages/website/src/routes/HomeMap/Map/Markers/index.tsx +++ b/packages/website/src/routes/HomeMap/Map/Markers/index.tsx @@ -1,11 +1,10 @@ import { useSelector } from 'react-redux'; -import { LayerGroup, useLeaflet } from 'react-leaflet'; +import { useLeaflet } from 'react-leaflet'; import MarkerClusterGroup from 'react-leaflet-markercluster'; -import React, { useCallback, useEffect, useState, useMemo } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import L from 'leaflet'; import { sitesToDisplayListSelector } from 'store/Sites/sitesListSlice'; import { Site } from 'store/Sites/types'; -import { siteOnMapSelector } from 'store/Homepage/homepageSlice'; import 'leaflet/dist/leaflet.css'; import 'react-leaflet-markercluster/dist/styles.min.css'; import { CollectionDetails } from 'store/Collection/types'; @@ -15,7 +14,7 @@ import { getColorByLevel, Interval, } from 'helpers/bleachingAlertIntervals'; -import SiteMarker from './SiteMarker'; +import { SiteMarker } from './SiteMarker'; const clusterIcon = (cluster: any) => { const alerts: Interval[] = cluster.getAllChildMarkers().map((marker: any) => { @@ -38,65 +37,50 @@ export const SiteMarkers = ({ collection }: SiteMarkersProps) => { () => collection?.sites || storedSites || [], [collection?.sites, storedSites], ); - const siteOnMap = useSelector(siteOnMapSelector); const { map } = useLeaflet(); - const [visibleSites, setVisibleSites] = useState(sitesList); - - const setCenter = useCallback( - (inputMap: L.Map, latLng: [number, number], zoom: number) => { - const maxZoom = Math.max(inputMap.getZoom() || 6, zoom); - const pointBounds = L.latLngBounds(latLng, latLng); - inputMap.flyToBounds(pointBounds, { - duration: 2, - maxZoom, - paddingTopLeft: L.point(0, 200), - }); - }, - [], - ); - - const filterSitesByViewport = useCallback(() => { - if (!map) return; - - const bounds = map.getBounds(); - const filtered = sitesList.filter((site: Site) => { - if (!site.polygon || site.polygon.type !== 'Point') return false; - const [lng, lat] = site.polygon.coordinates; - return bounds.contains([lat, lng]); - }); - setVisibleSites(filtered); - }, [map, sitesList]); + const [visibleSitesMap, setVisibleSitesMap] = useState< + Record + >({}); useEffect(() => { + // Incrementally mount visible site markers on map + // Avoid mounting all sites at once, mount only the visible ones and don't umount them + if (!map) return undefined; + const mountSitesInViewport = () => { + if (!map) return; + const bounds = map.getBounds(); + const filtered: Record = {}; + sitesList.forEach((site: Site) => { + if (!site.polygon || site.polygon.type !== 'Point') return; + const [lng, lat] = site.polygon.coordinates; + if (bounds.contains([lat, lng])) { + // eslint-disable-next-line fp/no-mutation + filtered[site.id] = true; + } + }); + // Keep the previous markers and add the new visible sites + setVisibleSitesMap((prev) => ({ ...prev, ...filtered })); + }; - filterSitesByViewport(); - map.on('moveend', filterSitesByViewport); + mountSitesInViewport(); + map.on('moveend', mountSitesInViewport); return () => { - map.off('moveend', filterSitesByViewport); + map.off('moveend', mountSitesInViewport); return undefined; }; - }, [map, filterSitesByViewport]); - - useEffect(() => { - if (map && siteOnMap?.polygon.type === 'Point') { - const [lng, lat] = siteOnMap.polygon.coordinates; - setCenter(map, [lat, lng], 6); - } - }, [map, siteOnMap, setCenter]); + }, [map, sitesList]); return ( - - - {visibleSites.map((site: Site) => ( - - ))} - - + + {sitesList.map( + (site: Site) => + visibleSitesMap[site.id] && ( + + ), + )} + ); }; From 0fd621dbf03d7d461cc9a1059033dd2019ebfa92 Mon Sep 17 00:00:00 2001 From: Konstantinos Markopoulos Date: Wed, 11 Dec 2024 17:39:00 +0200 Subject: [PATCH 3/8] Move fly to selected site logic --- .../website/src/routes/HomeMap/Map/index.tsx | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/website/src/routes/HomeMap/Map/index.tsx b/packages/website/src/routes/HomeMap/Map/index.tsx index 00ebe5be1..7fac505d7 100644 --- a/packages/website/src/routes/HomeMap/Map/index.tsx +++ b/packages/website/src/routes/HomeMap/Map/index.tsx @@ -14,7 +14,10 @@ import { import { Alert } from '@material-ui/lab'; import MyLocationIcon from '@material-ui/icons/MyLocation'; import { sitesListLoadingSelector } from 'store/Sites/sitesListSlice'; -import { searchResultSelector } from 'store/Homepage/homepageSlice'; +import { + searchResultSelector, + siteOnMapSelector, +} from 'store/Homepage/homepageSlice'; import { CollectionDetails } from 'store/Collection/types'; import { MapLayerName } from 'store/Homepage/types'; import { mapConstants } from 'constants/maps'; @@ -68,6 +71,7 @@ const HomepageMap = ({ useState(); const loading = useSelector(sitesListLoadingSelector); const searchResult = useSelector(searchResultSelector); + const siteOnMap = useSelector(siteOnMapSelector); const ref = useRef(null); const onLocationSearch = () => { @@ -116,6 +120,21 @@ const HomepageMap = ({ } }, [searchResult]); + useEffect(() => { + const map = ref.current?.leafletElement; + if (map && siteOnMap?.polygon.type === 'Point') { + const [lng, lat] = siteOnMap.polygon.coordinates; + const latLng = [lat, lng] as [number, number]; + const pointBounds = L.latLngBounds(latLng, latLng); + const maxZoom = Math.max(map.getZoom() || 6); + map.flyToBounds(pointBounds, { + duration: 2, + maxZoom, + paddingTopLeft: L.point(0, 200), + }); + } + }, [siteOnMap]); + const onBaseLayerChange = ({ name }: LayersControlEvent) => { setLegendName(name); }; From 3a84bf75a3101b350ec53af9e2e9237eb451c29f Mon Sep 17 00:00:00 2001 From: Konstantinos Markopoulos Date: Wed, 11 Dec 2024 17:39:47 +0200 Subject: [PATCH 4/8] Memoize sitemarker --- .../routes/HomeMap/Map/Markers/SiteMarker.tsx | 41 ++++++++----------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx b/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx index ccb4285b0..0b149c3ce 100644 --- a/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx +++ b/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx @@ -1,11 +1,11 @@ -import { Marker, useLeaflet } from 'react-leaflet'; +import { Marker } from 'react-leaflet'; import { useDispatch, useSelector } from 'react-redux'; import React from 'react'; import { Site } from 'store/Sites/types'; import { - siteOnMapSelector, setSiteOnMap, setSearchResult, + isSelectedOnMapSelector, } from 'store/Homepage/homepageSlice'; import { useMarkerIcon } from 'helpers/map'; import { hasDeployedSpotter } from 'helpers/siteUtils'; @@ -17,19 +17,18 @@ import Popup from '../Popup'; // To make sure we can see all the sites all the time, and especially // around -180/+180, we create dummy copies of each site. +// TODO: Add back the functionality const LNG_OFFSETS = [-360, 0, 360]; interface SiteMarkerProps { site: Site; - setCenter: (inputMap: L.Map, latLng: [number, number], zoom: number) => void; } /** * All in one site marker with icon, offset duplicates, and popup built in. */ -export default function SiteMarker({ site, setCenter }: SiteMarkerProps) { +export const SiteMarker = React.memo(({ site }: SiteMarkerProps) => { const isSelected = useSelector(isSelectedOnMapSelector(site.id)); - const { map } = useLeaflet(); const dispatch = useDispatch(); const { tempWeeklyAlert } = site.collectionData || {}; const markerIcon = useMarkerIcon( @@ -43,24 +42,18 @@ export default function SiteMarker({ site, setCenter }: SiteMarkerProps) { if (site.polygon.type !== 'Point') return null; const [lng, lat] = site.polygon.coordinates; + return ( - <> - {LNG_OFFSETS.map((offset) => { - return ( - { - if (map) setCenter(map, [lat, lng], 6); - dispatch(setSearchResult()); - dispatch(setSiteOnMap(site)); - }} - key={`${site.id}-${offset}`} - icon={markerIcon} - position={[lat, lng + offset]} - > - {isSelected && } - - ); - })} - + { + dispatch(setSearchResult()); + dispatch(setSiteOnMap(site)); + }} + key={`${site.id}`} + icon={markerIcon} + position={[lat, lng]} + > + {isSelected && } + ); -} +}); From 882c592b94167278221b3e98ac9a69c4f36af25d Mon Sep 17 00:00:00 2001 From: Konstantinos Markopoulos Date: Wed, 11 Dec 2024 18:06:48 +0200 Subject: [PATCH 5/8] fix build --- packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx b/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx index 0b149c3ce..16185b3c8 100644 --- a/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx +++ b/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx @@ -18,7 +18,7 @@ import Popup from '../Popup'; // To make sure we can see all the sites all the time, and especially // around -180/+180, we create dummy copies of each site. // TODO: Add back the functionality -const LNG_OFFSETS = [-360, 0, 360]; +// const LNG_OFFSETS = [-360, 0, 360]; interface SiteMarkerProps { site: Site; From 417c995e37c18bf3a2c5fc3ca4e07ca8adf22d90 Mon Sep 17 00:00:00 2001 From: Konstantinos Markopoulos Date: Thu, 12 Dec 2024 14:39:10 +0200 Subject: [PATCH 6/8] Add site offsets on map --- .../routes/HomeMap/Map/Markers/SiteMarker.tsx | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx b/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx index 16185b3c8..765df1a52 100644 --- a/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx +++ b/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx @@ -17,8 +17,7 @@ import Popup from '../Popup'; // To make sure we can see all the sites all the time, and especially // around -180/+180, we create dummy copies of each site. -// TODO: Add back the functionality -// const LNG_OFFSETS = [-360, 0, 360]; +const LNG_OFFSETS = [-360, 0, 360]; interface SiteMarkerProps { site: Site; @@ -44,16 +43,20 @@ export const SiteMarker = React.memo(({ site }: SiteMarkerProps) => { const [lng, lat] = site.polygon.coordinates; return ( - { - dispatch(setSearchResult()); - dispatch(setSiteOnMap(site)); - }} - key={`${site.id}`} - icon={markerIcon} - position={[lat, lng]} - > - {isSelected && } - + <> + {LNG_OFFSETS.map((offset) => ( + { + dispatch(setSearchResult()); + dispatch(setSiteOnMap(site)); + }} + key={`${site.id}-${offset}`} + icon={markerIcon} + position={[lat, lng + offset]} + > + {isSelected && } + + ))} + ); }); From a62b708c3860c4e3b5ff10c1f0d97d79d5e1ede4 Mon Sep 17 00:00:00 2001 From: Konstantinos Markopoulos Date: Thu, 12 Dec 2024 14:40:20 +0200 Subject: [PATCH 7/8] Fix cluster icon color --- .../src/helpers/bleachingAlertIntervals.ts | 5 ----- .../routes/HomeMap/Map/Markers/SiteMarker.tsx | 1 + .../src/routes/HomeMap/Map/Markers/index.tsx | 18 ++++++------------ 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/packages/website/src/helpers/bleachingAlertIntervals.ts b/packages/website/src/helpers/bleachingAlertIntervals.ts index de7c86b82..bf586f370 100644 --- a/packages/website/src/helpers/bleachingAlertIntervals.ts +++ b/packages/website/src/helpers/bleachingAlertIntervals.ts @@ -84,11 +84,6 @@ export const findIntervalByLevel = ( } }; -export const findMaxLevel = (intervals: Interval[]): number => { - const levels = intervals.map((item) => item.level); - return Math.max(...levels); -}; - export const getColorByLevel = (level: number): string => { return findIntervalByLevel(level).color; }; diff --git a/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx b/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx index 765df1a52..11084dc09 100644 --- a/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx +++ b/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx @@ -53,6 +53,7 @@ export const SiteMarker = React.memo(({ site }: SiteMarkerProps) => { key={`${site.id}-${offset}`} icon={markerIcon} position={[lat, lng + offset]} + data-alert={tempWeeklyAlert} > {isSelected && } diff --git a/packages/website/src/routes/HomeMap/Map/Markers/index.tsx b/packages/website/src/routes/HomeMap/Map/Markers/index.tsx index b92b564c8..5ab8e0bd6 100644 --- a/packages/website/src/routes/HomeMap/Map/Markers/index.tsx +++ b/packages/website/src/routes/HomeMap/Map/Markers/index.tsx @@ -8,21 +8,15 @@ import { Site } from 'store/Sites/types'; import 'leaflet/dist/leaflet.css'; import 'react-leaflet-markercluster/dist/styles.min.css'; import { CollectionDetails } from 'store/Collection/types'; -import { - findIntervalByLevel, - findMaxLevel, - getColorByLevel, - Interval, -} from 'helpers/bleachingAlertIntervals'; +import { getColorByLevel } from 'helpers/bleachingAlertIntervals'; import { SiteMarker } from './SiteMarker'; const clusterIcon = (cluster: any) => { - const alerts: Interval[] = cluster.getAllChildMarkers().map((marker: any) => { - const { site } = marker?.options?.children?.[0]?.props || {}; - const { tempWeeklyAlert } = site?.collectionData || {}; - return findIntervalByLevel(tempWeeklyAlert); - }); - const color = getColorByLevel(findMaxLevel(alerts)); + const alertLevels = cluster + .getAllChildMarkers() + .map((marker: any) => marker?.options?.['data-alert'] ?? 0); + const maxLevel = Math.max(...alertLevels); + const color = getColorByLevel(maxLevel); const count = cluster.getChildCount(); return L.divIcon({ html: `
${count}
`, From 75d5c5c906106318cc6beff3835ed6488b0bfa6c Mon Sep 17 00:00:00 2001 From: Konstantinos Markopoulos Date: Tue, 17 Dec 2024 13:45:00 +0200 Subject: [PATCH 8/8] Change cluster icon --- packages/website/src/helpers/map.ts | 86 +++++++++++++++++++ .../src/routes/HomeMap/Map/Markers/index.tsx | 16 ++-- 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/packages/website/src/helpers/map.ts b/packages/website/src/helpers/map.ts index d4d72b6d3..d2919b7a7 100644 --- a/packages/website/src/helpers/map.ts +++ b/packages/website/src/helpers/map.ts @@ -209,3 +209,89 @@ export const useMarkerIcon = ( if (hasSpotter || hasHobo) return sensorIcon; return buoyIcon(iconUrl); }; + +// Create a donut chart with the given counts and colors +// Source: https://maplibre.org/maplibre-gl-js/docs/examples/cluster-html/ +export function createDonutChart(counts: number[], colors: string[]) { + const offsets: number[] = []; + + let total = 0; + counts.forEach((count) => { + // eslint-disable-next-line fp/no-mutating-methods + offsets.push(total); + // eslint-disable-next-line fp/no-mutation + total += count; + }); + const fontSize = + // eslint-disable-next-line no-nested-ternary + total >= 1000 ? 22 : total >= 100 ? 20 : total >= 10 ? 18 : 16; + // eslint-disable-next-line no-nested-ternary + const r = total >= 1000 ? 50 : total >= 100 ? 32 : total >= 10 ? 24 : 18; + const r0 = Math.round(r * 0.6); + const w = r * 2; + + const segments = counts.map((count, i) => + donutSegment( + offsets[i] / total, + (offsets[i] + count) / total, + r, + r0, + colors[i], + ), + ); + const html = ` + + ${segments.join('')} + + ${total.toLocaleString()} + `; + + return html; +} + +function donutSegment( + start: number, + end: number, + r: number, + r0: number, + color: string, +) { + // eslint-disable-next-line fp/no-mutation, no-param-reassign + if (end - start === 1) end -= 0.00001; + const a0 = 2 * Math.PI * (start - 0.25); + const a1 = 2 * Math.PI * (end - 0.25); + const x0 = Math.cos(a0); + const y0 = Math.sin(a0); + const x1 = Math.cos(a1); + const y1 = Math.sin(a1); + const largeArc = end - start > 0.5 ? 1 : 0; + + return [ + '`, + ].join(' '); +} diff --git a/packages/website/src/routes/HomeMap/Map/Markers/index.tsx b/packages/website/src/routes/HomeMap/Map/Markers/index.tsx index 5ab8e0bd6..396baf5ed 100644 --- a/packages/website/src/routes/HomeMap/Map/Markers/index.tsx +++ b/packages/website/src/routes/HomeMap/Map/Markers/index.tsx @@ -9,19 +9,21 @@ import 'leaflet/dist/leaflet.css'; import 'react-leaflet-markercluster/dist/styles.min.css'; import { CollectionDetails } from 'store/Collection/types'; import { getColorByLevel } from 'helpers/bleachingAlertIntervals'; +import { countBy } from 'lodash'; +import { createDonutChart } from 'helpers/map'; import { SiteMarker } from './SiteMarker'; const clusterIcon = (cluster: any) => { - const alertLevels = cluster + const alertLevels: number[] = cluster .getAllChildMarkers() .map((marker: any) => marker?.options?.['data-alert'] ?? 0); - const maxLevel = Math.max(...alertLevels); - const color = getColorByLevel(maxLevel); - const count = cluster.getChildCount(); + const alertToCountMap = countBy(alertLevels); + const counts = Object.values(alertToCountMap); + const colors = Object.keys(alertToCountMap).map((level) => + getColorByLevel(parseInt(level, 10)), + ); return L.divIcon({ - html: `
${count}
`, - className: `leaflet-marker-icon marker-cluster custom-cluster-icon marker-cluster-small leaflet-zoom-animated leaflet-interactive`, - iconSize: L.point(40, 40, true), + html: createDonutChart(counts, colors), }); };