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/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/SiteMarker.tsx b/packages/website/src/routes/HomeMap/Map/Markers/SiteMarker.tsx index 0115242f8..11084dc09 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'; @@ -21,21 +21,19 @@ 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) { - const siteOnMap = useSelector(siteOnMapSelector); - const { map } = useLeaflet(); +export const SiteMarker = React.memo(({ site }: SiteMarkerProps) => { + const isSelected = useSelector(isSelectedOnMapSelector(site.id)); const dispatch = useDispatch(); const { tempWeeklyAlert } = site.collectionData || {}; const markerIcon = useMarkerIcon( hasDeployedSpotter(site), site.hasHobo, - siteOnMap?.id === site.id, + isSelected, alertColorFinder(tempWeeklyAlert), alertIconFinder(tempWeeklyAlert), ); @@ -43,24 +41,23 @@ 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]} - > - - - ); - })} + {LNG_OFFSETS.map((offset) => ( + { + dispatch(setSearchResult()); + dispatch(setSiteOnMap(site)); + }} + 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 e5e2012fc..396baf5ed 100644 --- a/packages/website/src/routes/HomeMap/Map/Markers/index.tsx +++ b/packages/website/src/routes/HomeMap/Map/Markers/index.tsx @@ -1,34 +1,29 @@ 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'; -import { - findIntervalByLevel, - findMaxLevel, - getColorByLevel, - Interval, -} from 'helpers/bleachingAlertIntervals'; -import SiteMarker from './SiteMarker'; +import { getColorByLevel } from 'helpers/bleachingAlertIntervals'; +import { countBy } from 'lodash'; +import { createDonutChart } from 'helpers/map'; +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 count = cluster.getChildCount(); + const alertLevels: number[] = cluster + .getAllChildMarkers() + .map((marker: any) => marker?.options?.['data-alert'] ?? 0); + 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), }); }; @@ -38,65 +33,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] && ( + + ), + )} + ); }; 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); }; 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;