Files
meshtastic-web/apps/web/src/pages/Map/index.tsx
T
Ben MeadorsandGitHub bbe9a0d5cd feat(protobufs): sync to firmware-current and consume workspace package (#1097)
* feat(protobufs): sync to firmware-current and consume workspace package

Sync the vendored .proto sources to firmware-current (v2.7.25+48), regenerate the v2 TS bindings, and consume the workspace @meshtastic/protobufs (workspace:*) in place of the stale JSR 2.7.20 — finishing the monorepo migration (core was already workspace:*).

Includes the one required breaking-change fix: admin nodedb_reset changed int32 to bool, so resetNodes() now sends value: true.

* build(protobufs): vendor generated bindings for workspace consumers

The package is consumed via workspace:* — its exports point at the TS source, which imports ./dist/meshtastic/*_pb.ts — so the generated output must exist at build time. CI builds web/core with no codegen step and the runners have no buf CLI, so the bindings are vendored here (kept gitignored; lint/format skip them). Regenerate with: pnpm --filter @meshtastic/protobufs gen

* fix(protobufs): clean script removes the actual generated output dir

buf writes bindings to packages/ts/dist, but clean was removing a non-existent root dist — so it never cleaned stale output. Addresses Copilot review feedback.

* refactor: move web app packages/web -> apps/web

Aligns the web app with the apps/web layout (matching the Vercel web-test Root Directory and the SDK-migration direction). Pure directory move plus root config: pnpm-workspace (adds apps/*), vitest projects, root tsconfig reference, and the pr/release-web/nightly workflows. vercel.json moved with the app. Build + 36 validation tests green.

* feat: config fields, module pages, key verification, telemetry capture

Incorporates the firmware-current feature work onto the protobuf foundation: new config fields (Display message bubbles; LoRa fem_lna_mode + serial_hal_only; Telemetry air_quality_screen_enabled); 4 new ModuleConfig pages (TrafficManagement, StatusMessage, TAK, RemoteHardware); the manual Key Verification flow (sendKeyVerification + ClientNotificationDialog stages + Verify Key button); live telemetry capture (nodeDB addDeviceMetrics) and admin hardening (toggleMutedNode, graceful PortNum default); plus the sdk-preview ConfigEditor demo and store/config tests. Build + lint + format + 131 tests green.

* chore: drop #1062 (unsaved-change-detection) to match upstream revert

#1062 was merged to main by accident (per @danditomaso) and is being reverted. Reverse-applied its diff here via 3-way so #1097 stays consistent with where main is headed, while keeping the feature changes layered on the same files (deviceStore/changeRegistry). Build + 131 tests + lint + format green.

* fix(nodes): clean up SNR display in node table and map popup

SNR is a ratio measured in dB, not dBm (which is absolute power); the
node table and map popup both mislabeled it and crammed three values
together: '0dBm/50%/50raw'. The trailing '%/raw' pair was the same
heuristic ((snr+10)*5) shown twice — once clamped, once not.

Render SNR in dB rounded to one decimal, color-coded by a 0-100%
signal-quality heuristic (green/yellow/red), with the quality percentage
as a muted secondary. Drop the redundant raw value. Adds unit.db; this
matches the existing SNRTooltip, which already renders dB.
2026-06-15 13:23:28 -05:00

282 lines
9.3 KiB
TypeScript

import {
defaultVisibilityState,
MapLayerTool,
type VisibilityState,
} from "@app/components/PageComponents/Map/Tools/MapLayerTool.tsx";
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
import { type FilterState, 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 {
SNRLayer,
SNRTooltip,
type SNRTooltipProps,
} from "@components/PageComponents/Map/Layers/SNRLayer.tsx";
import { WaypointLayer } from "@components/PageComponents/Map/Layers/WaypointLayer.tsx";
import type { PopupState } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx";
import { useMapFitting } from "@core/hooks/useMapFitting.ts";
import { useNodeDB } from "@core/stores";
import { cn } from "@core/utils/cn.ts";
import { hasPos, toLngLat } from "@core/utils/geo.ts";
import type { Protobuf } from "@meshtastic/core";
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
import { FunnelIcon, LocateFixedIcon } from "lucide-react";
import { useCallback, useDeferredValue, useId, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { type MapLayerMouseEvent, useMap } from "react-map-gl/maplibre";
const NODEDB_DEBOUNCE_MS = 250;
const MapPage = () => {
const { t } = useTranslation("map");
const { getNode } = useNodeDB();
const { nodes: validNodes, myNode } = useNodeDB(
(db) => ({
// only nodes with a position
nodes: db.getNodes((n): n is Protobuf.Mesh.NodeInfo => Boolean(n.position?.latitudeI)),
myNode: db.getMyNode(),
// References to cause re-render on change
_errorsRef: db.nodeErrors,
_nodeNumRef: db.myNodeNum,
}),
{ debounce: NODEDB_DEBOUNCE_MS },
);
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();
const { default: mapRef } = useMap();
const { focusLngLat, fitToNodes } = useMapFitting(mapRef);
const hasFitBoundsOnce = useRef(false);
const [snrHover, setSnrHover] = useState<SNRTooltipProps>();
const [expandedCluster, setExpandedCluster] = useState<string | undefined>();
const [popupState, setPopupState] = useState<PopupState | undefined>();
const [visibilityState, setVisibilityState] = useState<VisibilityState>(
() => defaultVisibilityState,
);
const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>("density");
// Filters
const [filterState, setFilterState] = useState<FilterState>(() => defaultFilterValues);
const deferredFilterState = useDeferredValue(filterState);
const filteredNodes = useMemo(
() => validNodes.filter((node) => nodeFilter(node, deferredFilterState)),
[validNodes, deferredFilterState, nodeFilter],
);
// Map fitting
const getMapBounds = useCallback(() => {
if (!hasFitBoundsOnce.current) {
fitToNodes(validNodes);
hasFitBoundsOnce.current = true;
}
}, [fitToNodes, validNodes]);
// SNR lines
const snrLayerElementId = useId();
const snrLayerElement = useMemo(
() => (
<SNRLayer
id={snrLayerElementId}
filteredNodes={filteredNodes}
myNode={myNode}
visibilityState={visibilityState}
/>
),
[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 {
features,
point: { x, y },
} = event;
const hoveredFeature = features?.[0];
if (hoveredFeature) {
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", {
last4: numberToHexUnpadded(from).slice(-4).toUpperCase(),
});
const toLong =
getNode(to)?.user?.longName ??
t("fallbackName", {
last4: numberToHexUnpadded(to).slice(-4).toUpperCase(),
});
setSnrHover({ pos: { x, y }, snr, from: fromLong, to: toLong });
} else {
setSnrHover(undefined);
}
},
[getNode, t, heatmapLayerElementId],
);
// Node markers & clusters
const onMapBackgroundClick = useCallback(() => {
setExpandedCluster(undefined);
}, []);
const markerElements = useMemo(
() => (
<NodesLayer
mapRef={mapRef}
filteredNodes={filteredNodes}
myNode={myNode}
expandedCluster={expandedCluster}
setExpandedCluster={setExpandedCluster}
popupState={popupState}
setPopupState={setPopupState}
isVisible={visibilityState.nodeMarkers}
/>
),
[filteredNodes, expandedCluster, mapRef, myNode, popupState, visibilityState.nodeMarkers],
);
// Precision circles
const precisionCirclesElementId = useId();
const precisionCirclesElement = useMemo(
() => (
<PrecisionLayer
id={precisionCirclesElementId}
filteredNodes={filteredNodes}
isVisible={visibilityState.positionPrecision}
/>
),
[filteredNodes, visibilityState.positionPrecision, precisionCirclesElementId],
);
// Waypoints
const waypointLayerElement = useMemo(
() => (
<WaypointLayer
mapRef={mapRef}
myNode={myNode}
isVisible={visibilityState.waypoints}
popupState={popupState}
setPopupState={setPopupState}
/>
),
[mapRef, myNode, visibilityState.waypoints, popupState],
);
return (
<PageLayout label="Map" noPadding actions={[]} leftBar={<Sidebar />}>
<BaseMap
onLoad={getMapBounds}
onMouseMove={onMouseMove}
onClick={onMapBackgroundClick}
interactiveLayerIds={[snrLayerElementId, `${heatmapLayerElementId}-interaction`]}
>
{heatmapLayerElement}
{markerElements}
{snrLayerElement}
{precisionCirclesElement}
{waypointLayerElement}
{snrHover && (
<SNRTooltip pos={snrHover.pos} snr={snrHover.snr} from={snrHover.from} to={snrHover.to} />
)}
</BaseMap>
<div className="flex flex-col space-y-1 fixed top-35 right-2.5">
{myNode && hasPos(myNode?.position) && (
<button
type="button"
className={cn(
"rounded align-center",
"w-[29px] px-1 py-1 shadow-l outline-[2px] outline-stone-600/20",
"bg-stone-50 hover:bg-stone-200 dark:bg-stone-200 dark:hover:bg-stone-300 ",
"text-slate-600 hover:text-slate-700",
"dark:text-slate-600 hover:dark:text-slate-700",
)}
aria-label={t("mapMenu.locateAria")}
onClick={() => focusLngLat(toLngLat(myNode.position))}
>
{" "}
<LocateFixedIcon className="w-[21px]" />
</button>
)}
<FilterControl
filterState={filterState}
defaultFilterValues={defaultFilterValues}
setFilterState={setFilterState}
isDirty={isFilterDirty(filterState)}
parameters={{
popoverContentProps: {
side: "bottom",
align: "end",
sideOffset: 7,
},
popoverTriggerClassName: cn(
"w-[29px] px-1 py-1 rounded shadow-l outline-[2px] outline-stone-600/20 ",
"dark:text-slate-600 dark:hover:text-slate-700 bg-stone-50 hover:bg-stone-200 dark:bg-stone-200 dark:hover:bg-stone-300 dark:active:bg-stone-300",
isFilterDirty(filterState)
? "text-slate-100 dark:text-slate-100 bg-green-600 dark:bg-green-600 hover:bg-green-700 dark:hover:bg-green-700 hover:text-slate-200 dark:hover:text-slate-200 active:bg-green-800 dark:active:bg-green-800 outline-green-600 dark:outline-green-700"
: "",
),
triggerIcon: <FunnelIcon className="w-[21px]" />,
showTextSearch: true,
}}
/>
<MapLayerTool
visibilityState={visibilityState}
setVisibilityState={setVisibilityState}
heatmapMode={heatmapMode}
setHeatmapMode={setHeatmapMode}
/>
</div>
</PageLayout>
);
};
export default MapPage;