Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/web/public/i18n/locales/en/map.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/components/DeviceInfoPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export const DeviceInfoPanel = ({

{
id: "language",
label: t("languagePickeer.label"),
label: t("languagePicker.label"),
icon: Languages,
render: () => <LanguageSwitcher />,
},
Expand Down
103 changes: 103 additions & 0 deletions packages/web/src/components/PageComponents/Map/Layers/HeatmapLayer.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This use of variable 'to' always evaluates to true.

Copilot uses AI. Check for mistakes.
</>
)}
</div>
<div>
SNR: <Mono>{snr?.toFixed?.(2) ?? t("unknown.shortName")}</Mono> dB
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { HeatmapMode } from "@components/PageComponents/Map/Layers/HeatmapLayer.tsx";
import { Checkbox } from "@components/UI/Checkbox/index.tsx";
import {
Popover,
Expand All @@ -16,6 +17,7 @@ export interface VisibilityState {
positionPrecision: boolean;
traceroutes: boolean;
waypoints: boolean;
heatmap: boolean;
}

export const defaultVisibilityState: VisibilityState = {
Expand All @@ -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 {
Expand Down Expand Up @@ -59,6 +64,8 @@ const CheckboxItem = ({
export function MapLayerTool({
visibilityState,
setVisibilityState,
heatmapMode,
setHeatmapMode,
}: MapLayerToolProps): ReactNode {
const { t } = useTranslation("map");

Expand All @@ -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,
});
Comment on lines +77 to +87
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The behavior of automatically disabling all other layers when enabling the heatmap is a significant UX change that might be unexpected to users. Consider:

  1. Adding a visual indicator or tooltip explaining this behavior
  2. Providing a way to restore previous layer states when disabling the heatmap
  3. Or allowing users to overlay the heatmap with other layers if desired

The current implementation doesn't preserve the previous state, so users lose their layer configuration when toggling the heatmap.

Copilot uses AI. Check for mistakes.
} else {
setVisibilityState({
...visibilityState,
[key]: !visibilityState[key],
});
}
};

const layers = useMemo(
Expand All @@ -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],
Expand Down Expand Up @@ -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>
Comment on lines +157 to +178
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The radio button group lacks proper accessibility attributes. Consider:

  1. Wrapping the radio buttons in a fieldset with a legend
  2. Adding name attributes to group the radio buttons together
  3. Adding aria-label or associating the label elements with the inputs using htmlFor and id

Example:

<fieldset>
  <legend className="sr-only">Heatmap Mode</legend>
  <label className="flex items-center gap-2 cursor-pointer">
    <input
      type="radio"
      name="heatmap-mode"
      value="density"
      checked={heatmapMode === "density"}
      onChange={() => setHeatmapMode("density")}
    />
    ...
  </label>
</fieldset>

Copilot uses AI. Check for mistakes.
)}
</div>
))}
{/*<CheckboxItem
key="traceroutes"
Expand Down
57 changes: 54 additions & 3 deletions packages/web/src/pages/Map/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -69,6 +73,7 @@ const MapPage = () => {
const [visibilityState, setVisibilityState] = useState<VisibilityState>(
() => defaultVisibilityState,
);
const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>("density");

// Filters
const [filterState, setFilterState] = useState<FilterState>(
Expand Down Expand Up @@ -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 {
Expand All @@ -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", {
Expand All @@ -131,7 +176,7 @@ const MapPage = () => {
setSnrHover(undefined);
}
},
[getNode, t],
[getNode, t, heatmapLayerElementId],
);

// Node markers & clusters
Expand Down Expand Up @@ -199,8 +244,12 @@ const MapPage = () => {
onLoad={getMapBounds}
onMouseMove={onMouseMove}
onClick={onMapBackgroundClick}
interactiveLayerIds={[snrLayerElementId]}
interactiveLayerIds={[
snrLayerElementId,
`${heatmapLayerElementId}-interaction`,
]}
>
{heatmapLayerElement}
{markerElements}
{snrLayerElement}
{precisionCirclesElement}
Expand Down Expand Up @@ -260,6 +309,8 @@ const MapPage = () => {
<MapLayerTool
visibilityState={visibilityState}
setVisibilityState={setVisibilityState}
heatmapMode={heatmapMode}
setHeatmapMode={setHeatmapMode}
/>
</div>
</PageLayout>
Expand Down