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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user