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 = `
+ `;
+
+ 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