* 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.
282 lines
9.3 KiB
TypeScript
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;
|