From d6bbb25dc5c5f2467b32a94e53e2140ff935b876 Mon Sep 17 00:00:00 2001 From: Dan D Date: Fri, 28 Nov 2025 16:28:21 -0500 Subject: [PATCH 1/7] feat: add heatmap layer --- packages/web/public/i18n/locales/en/map.json | 4 +- .../Map/Layers/HeatmapLayer.tsx | 105 ++++++++++++++++++ .../PageComponents/Map/Layers/SNRLayer.tsx | 8 +- .../PageComponents/Map/Tools/MapLayerTool.tsx | 68 ++++++++++-- packages/web/src/pages/Map/index.tsx | 52 ++++++++- 5 files changed, 222 insertions(+), 15 deletions(-) create mode 100644 packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx diff --git a/packages/web/public/i18n/locales/en/map.json b/packages/web/public/i18n/locales/en/map.json index c17f38b25..c1cf0f63d 100644 --- a/packages/web/public/i18n/locales/en/map.json +++ b/packages/web/public/i18n/locales/en/map.json @@ -13,7 +13,9 @@ "remoteNeighbors": "Show remote connections", "positionPrecision": "Show position precision", "traceroutes": "Show traceroutes", - "waypoints": "Show waypoints" + "waypoints": "Show waypoints", + "heatmap": "Show heatmap", + "density": "Density" }, "mapMenu": { "locateAria": "Locate my node", diff --git a/packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx b/packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx new file mode 100644 index 000000000..ca509b760 --- /dev/null +++ b/packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx @@ -0,0 +1,105 @@ +import { hasPos, toLngLat } from "@core/utils/geo"; +import type { Protobuf } from "@meshtastic/core"; +import type { Feature, FeatureCollection } from "geojson"; +import type { HeatmapLayerSpecification } from "maplibre-gl"; +import { useMemo } from "react"; +import { Layer, Source } from "react-map-gl/maplibre"; + +export type HeatmapMode = "density" | "snr"; + +export interface HeatmapLayerProps { + id: string; + filteredNodes: Protobuf.Mesh.NodeInfo[]; + isVisible: boolean; + mode: HeatmapMode; +} + +export const HeatmapLayer = ({ + id, + filteredNodes, + isVisible, + mode, +}: HeatmapLayerProps) => { + const data: FeatureCollection = useMemo(() => { + const features: Feature[] = filteredNodes + .filter((node) => hasPos(node.position)) + .map((node) => ({ + type: "Feature", + geometry: { + type: "Point", + coordinates: toLngLat(node.position), + }, + properties: { + snr: node.snr ?? -120, + name: node.user?.longName, + shortName: node.user?.shortName, + num: node.num, + }, + })); + + return { + type: "FeatureCollection", + features, + }; + }, [filteredNodes]); + + if (!isVisible) { + return null; + } + + const paintProps: HeatmapLayerSpecification["paint"] = { + "heatmap-weight": + mode === "density" + ? 1 + : ["interpolate", ["linear"], ["get", "snr"], -20, 0, 10, 1], + "heatmap-intensity": ["interpolate", ["linear"], ["zoom"], 0, 1, 15, 3], + // Color ramp for heatmap. Domain is 0 (low) to 1 (high). + // Begin color ramp at 0-stop with a 0-transparancy color + // to create a blur-like effect. + "heatmap-color": [ + "interpolate", + ["linear"], + ["heatmap-density"], + 0, + "rgba(33,102,172,0)", + 0.2, + "rgb(103,169,207)", + 0.4, + "rgb(209,229,240)", + 0.6, + "rgb(253,219,199)", + 0.8, + "rgb(239,138,98)", + 1, + "rgb(178,24,43)", + ], + "heatmap-radius": [ + "interpolate", + ["linear"], + ["zoom"], + 0, + 2, + 9, + 20, + 15, + 30, + ], + // Opacity 0.7 to be visible but not blocking + "heatmap-opacity": 0.7, + }; + + return ( + + + + + ); +}; diff --git a/packages/web/src/components/PageComponents/Map/Layers/SNRLayer.tsx b/packages/web/src/components/PageComponents/Map/Layers/SNRLayer.tsx index febb3a3cd..c361fcda1 100644 --- a/packages/web/src/components/PageComponents/Map/Layers/SNRLayer.tsx +++ b/packages/web/src/components/PageComponents/Map/Layers/SNRLayer.tsx @@ -268,8 +268,12 @@ export const SNRTooltip = ({ >
{from ?? ""} - - {to ?? ""} + {to && ( + <> + + {to ?? ""} + + )}
SNR: {snr?.toFixed?.(2) ?? t("unknown.shortName")} dB diff --git a/packages/web/src/components/PageComponents/Map/Tools/MapLayerTool.tsx b/packages/web/src/components/PageComponents/Map/Tools/MapLayerTool.tsx index e94c36e1d..b6a753c52 100644 --- a/packages/web/src/components/PageComponents/Map/Tools/MapLayerTool.tsx +++ b/packages/web/src/components/PageComponents/Map/Tools/MapLayerTool.tsx @@ -1,3 +1,4 @@ +import type { HeatmapMode } from "@components/PageComponents/Map/Layers/HeatmapLayer.tsx"; import { Checkbox } from "@components/UI/Checkbox/index.tsx"; import { Popover, @@ -16,6 +17,7 @@ export interface VisibilityState { positionPrecision: boolean; traceroutes: boolean; waypoints: boolean; + heatmap: boolean; } export const defaultVisibilityState: VisibilityState = { @@ -25,11 +27,14 @@ export const defaultVisibilityState: VisibilityState = { positionPrecision: false, traceroutes: false, waypoints: true, + heatmap: false, }; interface MapLayerToolProps { visibilityState: VisibilityState; setVisibilityState: (state: VisibilityState) => void; + heatmapMode: HeatmapMode; + setHeatmapMode: (mode: HeatmapMode) => void; } interface CheckboxProps { @@ -59,6 +64,8 @@ const CheckboxItem = ({ export function MapLayerTool({ visibilityState, setVisibilityState, + heatmapMode, + setHeatmapMode, }: MapLayerToolProps): ReactNode { const { t } = useTranslation("map"); @@ -67,10 +74,23 @@ export function MapLayerTool({ }, [visibilityState]); const handleCheckboxChange = (key: keyof VisibilityState) => { - setVisibilityState({ - ...visibilityState, - [key]: !visibilityState[key], - }); + if (key === "heatmap" && !visibilityState.heatmap) { + // If turning heatmap off, turn everything else off so the layer is visible + setVisibilityState({ + nodeMarkers: false, + directNeighbors: false, + remoteNeighbors: false, + positionPrecision: false, + traceroutes: false, + waypoints: false, + heatmap: true, + }); + } else { + setVisibilityState({ + ...visibilityState, + [key]: !visibilityState[key], + }); + } }; const layers = useMemo( @@ -80,6 +100,7 @@ export function MapLayerTool({ { key: "directNeighbors", label: t("layerTool.directNeighbors") }, { key: "remoteNeighbors", label: t("layerTool.remoteNeighbors") }, { key: "positionPrecision", label: t("layerTool.positionPrecision") }, + { key: "heatmap", label: t("layerTool.heatmap") }, // { key: "traceroutes", label: t("layerTool.traceroutes") }, ], [t], @@ -124,12 +145,39 @@ export function MapLayerTool({ sideOffset={7} > {layers.map(({ key, label }) => ( - handleCheckboxChange(key as keyof VisibilityState)} - /> +
+ + handleCheckboxChange(key as keyof VisibilityState) + } + /> + {key === "heatmap" && visibilityState.heatmap && ( +
+ + +
+ )} +
))} {/* { const [visibilityState, setVisibilityState] = useState( () => defaultVisibilityState, ); + const [heatmapMode, setHeatmapMode] = useState("density"); // Filters const [filterState, setFilterState] = useState( @@ -103,6 +108,20 @@ const MapPage = () => { [filteredNodes, myNode, visibilityState, snrLayerElementId], ); + // Heatmap + const heatmapLayerElementId = useId(); + const heatmapLayerElement = useMemo( + () => ( + + ), + [filteredNodes, visibilityState.heatmap, heatmapMode, heatmapLayerElementId], + ); + const onMouseMove = useCallback( (event: MapLayerMouseEvent) => { const { @@ -112,8 +131,31 @@ const MapPage = () => { const hoveredFeature = features?.[0]; if (hoveredFeature) { - const { from, to, snr } = hoveredFeature.properties; + const { from, to, snr, name, shortName, num } = + hoveredFeature.properties; + + // Handle Heatmap Hover + if ( + hoveredFeature.layer.id.includes("interaction") && + name !== undefined + ) { + const displayName = + name || + shortName || + t("fallbackName", { + last4: numberToHexUnpadded(num).slice(-4).toUpperCase(), + }); + + setSnrHover({ + pos: { x, y }, + snr: snr, // Single node SNR + from: displayName, + to: undefined, // Single node + }); + return; + } + // Handle SNR Line Hover const fromLong = getNode(from)?.user?.longName ?? t("fallbackName", { @@ -199,8 +241,12 @@ const MapPage = () => { onLoad={getMapBounds} onMouseMove={onMouseMove} onClick={onMapBackgroundClick} - interactiveLayerIds={[snrLayerElementId]} + interactiveLayerIds={[ + snrLayerElementId, + `${heatmapLayerElementId}-interaction`, + ]} > + {heatmapLayerElement} {markerElements} {snrLayerElement} {precisionCirclesElement} @@ -260,6 +306,8 @@ const MapPage = () => {
From a087f9c87514120641788d6122e0ab2974f6e100 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 28 Nov 2025 18:14:18 -0500 Subject: [PATCH 2/7] Update packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/components/PageComponents/Map/Layers/HeatmapLayer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx b/packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx index ca509b760..d5614b9f9 100644 --- a/packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx +++ b/packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx @@ -54,7 +54,7 @@ export const HeatmapLayer = ({ : ["interpolate", ["linear"], ["get", "snr"], -20, 0, 10, 1], "heatmap-intensity": ["interpolate", ["linear"], ["zoom"], 0, 1, 15, 3], // Color ramp for heatmap. Domain is 0 (low) to 1 (high). - // Begin color ramp at 0-stop with a 0-transparancy color + // Begin color ramp at 0-stop with a 0-transparency color // to create a blur-like effect. "heatmap-color": [ "interpolate", From 1b936e2f89635cac6571f62cb95993b7e2ddec0d Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 28 Nov 2025 18:14:29 -0500 Subject: [PATCH 3/7] Update packages/web/src/components/PageComponents/Map/Tools/MapLayerTool.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/components/PageComponents/Map/Tools/MapLayerTool.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/components/PageComponents/Map/Tools/MapLayerTool.tsx b/packages/web/src/components/PageComponents/Map/Tools/MapLayerTool.tsx index b6a753c52..3b9b1cef7 100644 --- a/packages/web/src/components/PageComponents/Map/Tools/MapLayerTool.tsx +++ b/packages/web/src/components/PageComponents/Map/Tools/MapLayerTool.tsx @@ -75,7 +75,7 @@ export function MapLayerTool({ const handleCheckboxChange = (key: keyof VisibilityState) => { if (key === "heatmap" && !visibilityState.heatmap) { - // If turning heatmap off, turn everything else off so the layer is visible + // If turning heatmap on, turn everything else off so the layer is visible setVisibilityState({ nodeMarkers: false, directNeighbors: false, From 1ba7ee366260f2168ef560a3c5f1cd92590b7bb0 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 28 Nov 2025 18:15:32 -0500 Subject: [PATCH 4/7] Update packages/web/src/pages/Map/index.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/web/src/pages/Map/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/pages/Map/index.tsx b/packages/web/src/pages/Map/index.tsx index d9417fa49..1a7303955 100644 --- a/packages/web/src/pages/Map/index.tsx +++ b/packages/web/src/pages/Map/index.tsx @@ -136,7 +136,7 @@ const MapPage = () => { // Handle Heatmap Hover if ( - hoveredFeature.layer.id.includes("interaction") && + hoveredFeature.layer.id === `${heatmapLayerElementId}-interaction` && name !== undefined ) { const displayName = From d9fce313ea7eaca6fc78f63f2445f2df9c43ab26 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 28 Nov 2025 18:16:11 -0500 Subject: [PATCH 5/7] Update packages/web/src/pages/Map/index.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/web/src/pages/Map/index.tsx | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/packages/web/src/pages/Map/index.tsx b/packages/web/src/pages/Map/index.tsx index 1a7303955..d9f85e654 100644 --- a/packages/web/src/pages/Map/index.tsx +++ b/packages/web/src/pages/Map/index.tsx @@ -139,17 +139,15 @@ const MapPage = () => { hoveredFeature.layer.id === `${heatmapLayerElementId}-interaction` && name !== undefined ) { - const displayName = - name || - shortName || - t("fallbackName", { - last4: numberToHexUnpadded(num).slice(-4).toUpperCase(), - }); - setSnrHover({ pos: { x, y }, snr: snr, // Single node SNR - from: displayName, + from: + name || + shortName || + t("fallbackName", { + last4: numberToHexUnpadded(num).slice(-4).toUpperCase(), + }), to: undefined, // Single node }); return; From e84750a6f2f2b3727a2e2a21ec518165864ca308 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 28 Nov 2025 18:17:07 -0500 Subject: [PATCH 6/7] Update packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Map/Layers/HeatmapLayer.tsx | 83 ++++++++++--------- 1 file changed, 43 insertions(+), 40 deletions(-) diff --git a/packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx b/packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx index d5614b9f9..851b40f70 100644 --- a/packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx +++ b/packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx @@ -47,46 +47,49 @@ export const HeatmapLayer = ({ return null; } - const paintProps: HeatmapLayerSpecification["paint"] = { - "heatmap-weight": - mode === "density" - ? 1 - : ["interpolate", ["linear"], ["get", "snr"], -20, 0, 10, 1], - "heatmap-intensity": ["interpolate", ["linear"], ["zoom"], 0, 1, 15, 3], - // Color ramp for heatmap. Domain is 0 (low) to 1 (high). - // Begin color ramp at 0-stop with a 0-transparency color - // to create a blur-like effect. - "heatmap-color": [ - "interpolate", - ["linear"], - ["heatmap-density"], - 0, - "rgba(33,102,172,0)", - 0.2, - "rgb(103,169,207)", - 0.4, - "rgb(209,229,240)", - 0.6, - "rgb(253,219,199)", - 0.8, - "rgb(239,138,98)", - 1, - "rgb(178,24,43)", - ], - "heatmap-radius": [ - "interpolate", - ["linear"], - ["zoom"], - 0, - 2, - 9, - 20, - 15, - 30, - ], - // Opacity 0.7 to be visible but not blocking - "heatmap-opacity": 0.7, - }; + const paintProps: HeatmapLayerSpecification["paint"] = useMemo( + () => ({ + "heatmap-weight": + mode === "density" + ? 1 + : ["interpolate", ["linear"], ["get", "snr"], -20, 0, 10, 1], + "heatmap-intensity": ["interpolate", ["linear"], ["zoom"], 0, 1, 15, 3], + // Color ramp for heatmap. Domain is 0 (low) to 1 (high). + // Begin color ramp at 0-stop with a 0-transparancy color + // to create a blur-like effect. + "heatmap-color": [ + "interpolate", + ["linear"], + ["heatmap-density"], + 0, + "rgba(33,102,172,0)", + 0.2, + "rgb(103,169,207)", + 0.4, + "rgb(209,229,240)", + 0.6, + "rgb(253,219,199)", + 0.8, + "rgb(239,138,98)", + 1, + "rgb(178,24,43)", + ], + "heatmap-radius": [ + "interpolate", + ["linear"], + ["zoom"], + 0, + 2, + 9, + 20, + 15, + 30, + ], + // Opacity 0.7 to be visible but not blocking + "heatmap-opacity": 0.7, + }), + [mode] + ); return ( From 47d8200892b4d40b3909b85d345839b09b36babd Mon Sep 17 00:00:00 2001 From: Dan D Date: Fri, 28 Nov 2025 18:42:27 -0500 Subject: [PATCH 7/7] lint/formatting fixes --- packages/web/src/components/DeviceInfoPanel.tsx | 2 +- .../PageComponents/Map/Layers/HeatmapLayer.tsx | 13 ++++--------- packages/web/src/pages/Map/index.tsx | 9 +++++++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/web/src/components/DeviceInfoPanel.tsx b/packages/web/src/components/DeviceInfoPanel.tsx index 71d68d2c9..f962a382b 100644 --- a/packages/web/src/components/DeviceInfoPanel.tsx +++ b/packages/web/src/components/DeviceInfoPanel.tsx @@ -135,7 +135,7 @@ export const DeviceInfoPanel = ({ { id: "language", - label: t("languagePickeer.label"), + label: t("languagePicker.label"), icon: Languages, render: () => , }, diff --git a/packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx b/packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx index 851b40f70..e4ba7467d 100644 --- a/packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx +++ b/packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx @@ -10,19 +10,18 @@ export type HeatmapMode = "density" | "snr"; export interface HeatmapLayerProps { id: string; filteredNodes: Protobuf.Mesh.NodeInfo[]; - isVisible: boolean; mode: HeatmapMode; } export const HeatmapLayer = ({ id, filteredNodes, - isVisible, mode, }: HeatmapLayerProps) => { const data: FeatureCollection = useMemo(() => { const features: Feature[] = filteredNodes .filter((node) => hasPos(node.position)) + .filter((node) => mode !== "snr" || node.snr !== undefined) .map((node) => ({ type: "Feature", geometry: { @@ -30,7 +29,7 @@ export const HeatmapLayer = ({ coordinates: toLngLat(node.position), }, properties: { - snr: node.snr ?? -120, + snr: node.snr, name: node.user?.longName, shortName: node.user?.shortName, num: node.num, @@ -41,11 +40,7 @@ export const HeatmapLayer = ({ type: "FeatureCollection", features, }; - }, [filteredNodes]); - - if (!isVisible) { - return null; - } + }, [filteredNodes, mode]); const paintProps: HeatmapLayerSpecification["paint"] = useMemo( () => ({ @@ -88,7 +83,7 @@ export const HeatmapLayer = ({ // Opacity 0.7 to be visible but not blocking "heatmap-opacity": 0.7, }), - [mode] + [mode], ); return ( diff --git a/packages/web/src/pages/Map/index.tsx b/packages/web/src/pages/Map/index.tsx index d9f85e654..dd3441ebe 100644 --- a/packages/web/src/pages/Map/index.tsx +++ b/packages/web/src/pages/Map/index.tsx @@ -119,7 +119,12 @@ const MapPage = () => { mode={heatmapMode} /> ), - [filteredNodes, visibilityState.heatmap, heatmapMode, heatmapLayerElementId], + [ + filteredNodes, + visibilityState.heatmap, + heatmapMode, + heatmapLayerElementId, + ], ); const onMouseMove = useCallback( @@ -171,7 +176,7 @@ const MapPage = () => { setSnrHover(undefined); } }, - [getNode, t], + [getNode, t, heatmapLayerElementId], ); // Node markers & clusters