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",
|
"remoteNeighbors": "Show remote connections",
|
||||||
"positionPrecision": "Show position precision",
|
"positionPrecision": "Show position precision",
|
||||||
"traceroutes": "Show traceroutes",
|
"traceroutes": "Show traceroutes",
|
||||||
"waypoints": "Show waypoints"
|
"waypoints": "Show waypoints",
|
||||||
|
"heatmap": "Show heatmap",
|
||||||
|
"density": "Density"
|
||||||
},
|
},
|
||||||
"mapMenu": {
|
"mapMenu": {
|
||||||
"locateAria": "Locate my node",
|
"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>
|
<div>
|
||||||
<strong className="font-bold">{from ?? ""}</strong>
|
<strong className="font-bold">{from ?? ""}</strong>
|
||||||
|
{to && (
|
||||||
|
<>
|
||||||
<span className="mx-1">⭢</span>
|
<span className="mx-1">⭢</span>
|
||||||
<strong className="font-bold">{to ?? ""}</strong>
|
<strong className="font-bold">{to ?? ""}</strong>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
SNR: <Mono>{snr?.toFixed?.(2) ?? t("unknown.shortName")}</Mono> dB
|
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 { Checkbox } from "@components/UI/Checkbox/index.tsx";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
@@ -16,6 +17,7 @@ export interface VisibilityState {
|
|||||||
positionPrecision: boolean;
|
positionPrecision: boolean;
|
||||||
traceroutes: boolean;
|
traceroutes: boolean;
|
||||||
waypoints: boolean;
|
waypoints: boolean;
|
||||||
|
heatmap: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultVisibilityState: VisibilityState = {
|
export const defaultVisibilityState: VisibilityState = {
|
||||||
@@ -25,11 +27,14 @@ export const defaultVisibilityState: VisibilityState = {
|
|||||||
positionPrecision: false,
|
positionPrecision: false,
|
||||||
traceroutes: false,
|
traceroutes: false,
|
||||||
waypoints: true,
|
waypoints: true,
|
||||||
|
heatmap: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface MapLayerToolProps {
|
interface MapLayerToolProps {
|
||||||
visibilityState: VisibilityState;
|
visibilityState: VisibilityState;
|
||||||
setVisibilityState: (state: VisibilityState) => void;
|
setVisibilityState: (state: VisibilityState) => void;
|
||||||
|
heatmapMode: HeatmapMode;
|
||||||
|
setHeatmapMode: (mode: HeatmapMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CheckboxProps {
|
interface CheckboxProps {
|
||||||
@@ -59,6 +64,8 @@ const CheckboxItem = ({
|
|||||||
export function MapLayerTool({
|
export function MapLayerTool({
|
||||||
visibilityState,
|
visibilityState,
|
||||||
setVisibilityState,
|
setVisibilityState,
|
||||||
|
heatmapMode,
|
||||||
|
setHeatmapMode,
|
||||||
}: MapLayerToolProps): ReactNode {
|
}: MapLayerToolProps): ReactNode {
|
||||||
const { t } = useTranslation("map");
|
const { t } = useTranslation("map");
|
||||||
|
|
||||||
@@ -67,10 +74,23 @@ export function MapLayerTool({
|
|||||||
}, [visibilityState]);
|
}, [visibilityState]);
|
||||||
|
|
||||||
const handleCheckboxChange = (key: keyof VisibilityState) => {
|
const handleCheckboxChange = (key: keyof VisibilityState) => {
|
||||||
|
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({
|
setVisibilityState({
|
||||||
...visibilityState,
|
...visibilityState,
|
||||||
[key]: !visibilityState[key],
|
[key]: !visibilityState[key],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const layers = useMemo(
|
const layers = useMemo(
|
||||||
@@ -80,6 +100,7 @@ export function MapLayerTool({
|
|||||||
{ key: "directNeighbors", label: t("layerTool.directNeighbors") },
|
{ key: "directNeighbors", label: t("layerTool.directNeighbors") },
|
||||||
{ key: "remoteNeighbors", label: t("layerTool.remoteNeighbors") },
|
{ key: "remoteNeighbors", label: t("layerTool.remoteNeighbors") },
|
||||||
{ key: "positionPrecision", label: t("layerTool.positionPrecision") },
|
{ key: "positionPrecision", label: t("layerTool.positionPrecision") },
|
||||||
|
{ key: "heatmap", label: t("layerTool.heatmap") },
|
||||||
// { key: "traceroutes", label: t("layerTool.traceroutes") },
|
// { key: "traceroutes", label: t("layerTool.traceroutes") },
|
||||||
],
|
],
|
||||||
[t],
|
[t],
|
||||||
@@ -124,12 +145,39 @@ export function MapLayerTool({
|
|||||||
sideOffset={7}
|
sideOffset={7}
|
||||||
>
|
>
|
||||||
{layers.map(({ key, label }) => (
|
{layers.map(({ key, label }) => (
|
||||||
|
<div key={key}>
|
||||||
<CheckboxItem
|
<CheckboxItem
|
||||||
key={key}
|
|
||||||
label={label}
|
label={label}
|
||||||
checked={visibilityState[key as keyof VisibilityState]}
|
checked={visibilityState[key as keyof VisibilityState]}
|
||||||
onChange={() => handleCheckboxChange(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
|
{/*<CheckboxItem
|
||||||
key="traceroutes"
|
key="traceroutes"
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
useFilterNode,
|
useFilterNode,
|
||||||
} from "@components/generic/Filter/useFilterNode.ts";
|
} from "@components/generic/Filter/useFilterNode.ts";
|
||||||
import { BaseMap } from "@components/Map.tsx";
|
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 { NodesLayer } from "@components/PageComponents/Map/Layers/NodesLayer.tsx";
|
||||||
import { PrecisionLayer } from "@components/PageComponents/Map/Layers/PrecisionLayer.tsx";
|
import { PrecisionLayer } from "@components/PageComponents/Map/Layers/PrecisionLayer.tsx";
|
||||||
import {
|
import {
|
||||||
@@ -69,6 +73,7 @@ const MapPage = () => {
|
|||||||
const [visibilityState, setVisibilityState] = useState<VisibilityState>(
|
const [visibilityState, setVisibilityState] = useState<VisibilityState>(
|
||||||
() => defaultVisibilityState,
|
() => defaultVisibilityState,
|
||||||
);
|
);
|
||||||
|
const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>("density");
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const [filterState, setFilterState] = useState<FilterState>(
|
const [filterState, setFilterState] = useState<FilterState>(
|
||||||
@@ -103,6 +108,25 @@ const MapPage = () => {
|
|||||||
[filteredNodes, myNode, visibilityState, snrLayerElementId],
|
[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(
|
const onMouseMove = useCallback(
|
||||||
(event: MapLayerMouseEvent) => {
|
(event: MapLayerMouseEvent) => {
|
||||||
const {
|
const {
|
||||||
@@ -112,8 +136,29 @@ const MapPage = () => {
|
|||||||
const hoveredFeature = features?.[0];
|
const hoveredFeature = features?.[0];
|
||||||
|
|
||||||
if (hoveredFeature) {
|
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 =
|
const fromLong =
|
||||||
getNode(from)?.user?.longName ??
|
getNode(from)?.user?.longName ??
|
||||||
t("fallbackName", {
|
t("fallbackName", {
|
||||||
@@ -131,7 +176,7 @@ const MapPage = () => {
|
|||||||
setSnrHover(undefined);
|
setSnrHover(undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getNode, t],
|
[getNode, t, heatmapLayerElementId],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Node markers & clusters
|
// Node markers & clusters
|
||||||
@@ -199,8 +244,12 @@ const MapPage = () => {
|
|||||||
onLoad={getMapBounds}
|
onLoad={getMapBounds}
|
||||||
onMouseMove={onMouseMove}
|
onMouseMove={onMouseMove}
|
||||||
onClick={onMapBackgroundClick}
|
onClick={onMapBackgroundClick}
|
||||||
interactiveLayerIds={[snrLayerElementId]}
|
interactiveLayerIds={[
|
||||||
|
snrLayerElementId,
|
||||||
|
`${heatmapLayerElementId}-interaction`,
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
|
{heatmapLayerElement}
|
||||||
{markerElements}
|
{markerElements}
|
||||||
{snrLayerElement}
|
{snrLayerElement}
|
||||||
{precisionCirclesElement}
|
{precisionCirclesElement}
|
||||||
@@ -260,6 +309,8 @@ const MapPage = () => {
|
|||||||
<MapLayerTool
|
<MapLayerTool
|
||||||
visibilityState={visibilityState}
|
visibilityState={visibilityState}
|
||||||
setVisibilityState={setVisibilityState}
|
setVisibilityState={setVisibilityState}
|
||||||
|
heatmapMode={heatmapMode}
|
||||||
|
setHeatmapMode={setHeatmapMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
Reference in New Issue
Block a user