feat(map): add heatmap layer (#969)

* feat: add heatmap layer

* Update packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/web/src/components/PageComponents/Map/Tools/MapLayerTool.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/web/src/pages/Map/index.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/web/src/pages/Map/index.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* lint/formatting fixes

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Dan Ditomaso
2025-11-28 19:58:05 -05:00
committed by GitHub
co-authored by Copilot
parent 020e9d6b63
commit 94220e729b
5 changed files with 224 additions and 16 deletions
+3 -1
View File
@@ -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",
@@ -0,0 +1,103 @@
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[];
mode: HeatmapMode;
}
export const HeatmapLayer = ({
id,
filteredNodes,
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: {
type: "Point",
coordinates: toLngLat(node.position),
},
properties: {
snr: node.snr,
name: node.user?.longName,
shortName: node.user?.shortName,
num: node.num,
},
}));
return {
type: "FeatureCollection",
features,
};
}, [filteredNodes, mode]);
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 (
<Source type="geojson" data={data}>
<Layer id={id} type="heatmap" paint={paintProps} />
<Layer
id={`${id}-interaction`}
type="circle"
paint={{
"circle-radius": 15,
"circle-opacity": 0,
"circle-stroke-opacity": 0,
}}
/>
</Source>
);
};
@@ -268,8 +268,12 @@ export const SNRTooltip = ({
>
<div>
<strong className="font-bold">{from ?? ""}</strong>
<span className="mx-1">⭢</span>
<strong className="font-bold">{to ?? ""}</strong>
{to && (
<>
<span className="mx-1">⭢</span>
<strong className="font-bold">{to ?? ""}</strong>
</>
)}
</div>
<div>
SNR: <Mono>{snr?.toFixed?.(2) ?? t("unknown.shortName")}</Mono> dB
@@ -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 on, 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 }) => (
<CheckboxItem
key={key}
label={label}
checked={visibilityState[key as keyof VisibilityState]}
onChange={() => handleCheckboxChange(key as keyof VisibilityState)}
/>
<div key={key}>
<CheckboxItem
label={label}
checked={visibilityState[key as keyof VisibilityState]}
onChange={() =>
handleCheckboxChange(key as keyof VisibilityState)
}
/>
{key === "heatmap" && visibilityState.heatmap && (
<div className="pl-6 pt-2 flex flex-col gap-1">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
className="accent-blue-500"
checked={heatmapMode === "density"}
onChange={() => setHeatmapMode("density")}
/>
<span className="text-sm dark:text-slate-300">
{t("layerTool.density")}
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
className="accent-blue-500"
checked={heatmapMode === "snr"}
onChange={() => setHeatmapMode("snr")}
/>
<span className="text-sm dark:text-slate-300">SNR</span>
</label>
</div>
)}
</div>
))}
{/*<CheckboxItem
key="traceroutes"
+54 -3
View File
@@ -9,6 +9,10 @@ import {
useFilterNode,
} from "@components/generic/Filter/useFilterNode.ts";
import { BaseMap } from "@components/Map.tsx";
import {
HeatmapLayer,
type HeatmapMode,
} from "@components/PageComponents/Map/Layers/HeatmapLayer.tsx";
import { NodesLayer } from "@components/PageComponents/Map/Layers/NodesLayer.tsx";
import { PrecisionLayer } from "@components/PageComponents/Map/Layers/PrecisionLayer.tsx";
import {
@@ -69,6 +73,7 @@ const MapPage = () => {
const [visibilityState, setVisibilityState] = useState<VisibilityState>(
() => defaultVisibilityState,
);
const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>("density");
// Filters
const [filterState, setFilterState] = useState<FilterState>(
@@ -103,6 +108,25 @@ const MapPage = () => {
[filteredNodes, myNode, visibilityState, snrLayerElementId],
);
// Heatmap
const heatmapLayerElementId = useId();
const heatmapLayerElement = useMemo(
() => (
<HeatmapLayer
id={heatmapLayerElementId}
filteredNodes={filteredNodes}
isVisible={visibilityState.heatmap}
mode={heatmapMode}
/>
),
[
filteredNodes,
visibilityState.heatmap,
heatmapMode,
heatmapLayerElementId,
],
);
const onMouseMove = useCallback(
(event: MapLayerMouseEvent) => {
const {
@@ -112,8 +136,29 @@ 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 === `${heatmapLayerElementId}-interaction` &&
name !== undefined
) {
setSnrHover({
pos: { x, y },
snr: snr, // Single node SNR
from:
name ||
shortName ||
t("fallbackName", {
last4: numberToHexUnpadded(num).slice(-4).toUpperCase(),
}),
to: undefined, // Single node
});
return;
}
// Handle SNR Line Hover
const fromLong =
getNode(from)?.user?.longName ??
t("fallbackName", {
@@ -131,7 +176,7 @@ const MapPage = () => {
setSnrHover(undefined);
}
},
[getNode, t],
[getNode, t, heatmapLayerElementId],
);
// Node markers & clusters
@@ -199,8 +244,12 @@ const MapPage = () => {
onLoad={getMapBounds}
onMouseMove={onMouseMove}
onClick={onMapBackgroundClick}
interactiveLayerIds={[snrLayerElementId]}
interactiveLayerIds={[
snrLayerElementId,
`${heatmapLayerElementId}-interaction`,
]}
>
{heatmapLayerElement}
{markerElements}
{snrLayerElement}
{precisionCirclesElement}
@@ -260,6 +309,8 @@ const MapPage = () => {
<MapLayerTool
visibilityState={visibilityState}
setVisibilityState={setVisibilityState}
heatmapMode={heatmapMode}
setHeatmapMode={setHeatmapMode}
/>
</div>
</PageLayout>