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)} - /> +
+ + handleCheckboxChange(key as keyof VisibilityState) + } + /> + {key === "heatmap" && visibilityState.heatmap && ( +
+ + +
+ )} +
))} {/* { 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 = () => {