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/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
new file mode 100644
index 000000000..e4ba7467d
--- /dev/null
+++ b/packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx
@@ -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 (
+
+
+
+
+ );
+};
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..3b9b1cef7 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 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 }) => (
-
handleCheckboxChange(key as keyof VisibilityState)}
- />
+
))}
{/* {
const [visibilityState, setVisibilityState] = useState(
() => defaultVisibilityState,
);
+ const [heatmapMode, setHeatmapMode] = useState("density");
// Filters
const [filterState, setFilterState] = useState(
@@ -103,6 +108,25 @@ 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 +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 = () => {