Map improvements - node and neighbor display (#850)

* Improves map node and neighbor display

Updates the map page to enhance the visualization of nodes, waypoints, and neighbor connections.

Adds a map layer tool for toggling the visibility of direct/remote neighbors and position precision indicators.

Introduces clustering to improve map readability when multiple nodes share the exact same location.

Adds SNR lines to visually represent network connections between nodes

Co-Authored-By: jamon <jamon@users.noreply.github.com>

* Clean up

* Update packages/web/src/components/generic/TimeAgo.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/web/src/components/PageComponents/Map/Layers/SNRLayer.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Improve dark mode and expires field

* Review fixes

Co-Authored-By: Dan Ditomaso <dan.ditomaso@gmail.com>

---------

Co-authored-by: philon- <philon-@users.noreply.github.com>
Co-authored-by: jamon <jamon@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Dan Ditomaso <dan.ditomaso@gmail.com>
This commit is contained in:
Jeremy Gallant
2025-09-18 08:13:34 -04:00
committed by GitHub
co-authored by jamon Copilot Dan Ditomaso philon-
parent 8cc451546d
commit 36443fd838
31 changed files with 1881 additions and 186 deletions
@@ -0,0 +1,50 @@
import { boundsFromLngLat, type LngLat, toLngLat } from "@core/utils/geo";
import type { Protobuf } from "@meshtastic/core";
import { useCallback } from "react";
import type { MapRef } from "react-map-gl/maplibre";
export function useMapFitting(map: MapRef | undefined) {
const focusLngLat = useCallback(
(position: LngLat) => {
if (!map) {
return;
}
const [lng, lat] = position;
map.easeTo({
center: [lng, lat],
zoom: map.getZoom(),
});
},
[map],
);
const fitToNodes = useCallback(
(nodes: Protobuf.Mesh.NodeInfo[]) => {
if (!map || nodes.length === 0) {
return;
}
if (nodes.length === 1 && nodes[0]) {
return focusLngLat(toLngLat(nodes[0].position));
}
// Build [lng, lat] coords, then let boundsFromLngLat do the turf dance
const coords = nodes.map((n) => toLngLat(n.position));
const bounds = boundsFromLngLat(coords);
if (!bounds) {
return;
}
const center = map.cameraForBounds(bounds, {
padding: { top: 10, bottom: 10, left: 10, right: 10 },
});
if (center) {
map.easeTo(center);
}
},
[map, focusLngLat],
);
return { focusLngLat, fitToNodes };
}
@@ -22,6 +22,15 @@ export type ValidModuleConfigType = Exclude<
undefined
>;
export type WaypointWithMetadata = Protobuf.Mesh.Waypoint & {
metadata: {
channel: number; // Channel on which the waypoint was received
created: Date; // Timestamp when the waypoint was received
updated?: Date; // Timestamp when the waypoint was last updated
from: number; // Node number of the device that sent the waypoint
};
};
export interface Device {
id: number;
status: Types.DeviceStatusEnum;
@@ -39,7 +48,8 @@ export interface Device {
>;
connection?: MeshDevice;
activeNode: number;
waypoints: Protobuf.Mesh.Waypoint[];
waypoints: WaypointWithMetadata[];
neighborInfo: Map<number, Protobuf.Mesh.NeighborInfo>;
pendingSettingsChanges: boolean;
messageDraft: string;
unreadCounts: Map<number, number>;
@@ -99,7 +109,12 @@ export interface Device {
setActiveNode: (node: number) => void;
setPendingSettingsChanges: (state: boolean) => void;
addChannel: (channel: Protobuf.Channel.Channel) => void;
addWaypoint: (waypoint: Protobuf.Mesh.Waypoint) => void;
addWaypoint: (
waypoint: Protobuf.Mesh.Waypoint,
channel: Types.ChannelNumber,
from: number,
rxTime: Date,
) => void;
addConnection: (connection: MeshDevice) => void;
addTraceRoute: (
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
@@ -120,6 +135,11 @@ export interface Device {
getClientNotification: (
index: number,
) => Protobuf.Mesh.ClientNotification | undefined;
addNeighborInfo: (
nodeNum: number,
neighborInfo: Protobuf.Mesh.NeighborInfo,
) => void;
getNeighborInfo: (nodeNum: number) => Protobuf.Mesh.NeighborInfo | undefined;
}
export interface DeviceState {
@@ -156,6 +176,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
connection: undefined,
activeNode: 0,
waypoints: [],
neighborInfo: new Map(),
dialog: {
import: false,
QR: false,
@@ -543,7 +564,7 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}),
);
},
addWaypoint: (waypoint: Protobuf.Mesh.Waypoint) => {
addWaypoint: (waypoint, channel, from, rxTime) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
@@ -552,9 +573,19 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
(wp) => wp.id === waypoint.id,
);
if (index !== -1) {
device.waypoints[index] = waypoint;
const created =
device.waypoints[index]?.metadata.created ?? new Date();
const updatedWaypoint = {
...waypoint,
metadata: { created, updated: rxTime, from, channel },
};
device.waypoints[index] = updatedWaypoint;
} else {
device.waypoints.push(waypoint);
device.waypoints.push({
...waypoint,
metadata: { created: rxTime, from, channel },
});
}
}
}),
@@ -720,6 +751,30 @@ export const useDeviceStore = createStore<PrivateDeviceState>((set, get) => ({
}
return device.clientNotifications[index];
},
addNeighborInfo: (
nodeId: number,
neighborInfo: Protobuf.Mesh.NeighborInfo,
) => {
set(
produce<PrivateDeviceState>((draft) => {
const device = draft.devices.get(id);
if (!device) {
return;
}
// Replace any existing neighbor info for this nodeId
device.neighborInfo.set(nodeId, neighborInfo);
}),
);
},
getNeighborInfo: (nodeNum: number) => {
const device = get().devices.get(id);
if (!device) {
return;
}
return device.neighborInfo.get(nodeNum);
},
});
}),
);
+1
View File
@@ -16,6 +16,7 @@ export {
useDeviceStore,
type ValidConfigType,
type ValidModuleConfigType,
type WaypointWithMetadata,
} from "@core/stores/deviceStore";
export {
MessageState,
@@ -41,7 +41,7 @@ export interface NodeDB {
filter?: (node: Protobuf.Mesh.NodeInfo) => boolean,
includeSelf?: boolean,
) => Protobuf.Mesh.NodeInfo[];
getMyNode: () => Protobuf.Mesh.NodeInfo;
getMyNode: () => Protobuf.Mesh.NodeInfo | undefined;
getNodeError: (nodeNum: number) => NodeError | undefined;
hasNodeError: (nodeNum: number) => boolean;
@@ -378,13 +378,12 @@ function nodeDBFactory(
if (!nodeDB) {
throw new Error(`No nodeDB found (id: ${id})`);
}
if (!nodeDB.myNodeNum) {
throw new Error(`No myNodeNum set for nodeDB with id: ${id}`);
if (nodeDB.myNodeNum) {
return (
nodeDB.nodeMap.get(nodeDB.myNodeNum) ??
create(Protobuf.Mesh.NodeInfoSchema)
);
}
return (
nodeDB.nodeMap.get(nodeDB.myNodeNum) ??
create(Protobuf.Mesh.NodeInfoSchema)
);
},
getNodeError: (nodeNum) => {
@@ -136,16 +136,16 @@ describe("NodeDB store", () => {
expect(db.getNodeError(10)).toBeUndefined();
});
it("getMyNode throws before setNodeNum; works after", async () => {
it("getMyNode returns undefined before setNodeNum; works after", async () => {
const { useNodeDBStore } = await freshStore();
const db = useNodeDBStore.getState().addNodeDB(1);
db.addNode(makeNode(123));
expect(() => db.getMyNode()).toThrow();
expect(db.getMyNode()).toBeUndefined();
db.setNodeNum(123);
const me = db.getMyNode();
expect(me.num).toBe(123);
expect(me?.num).toBe(123);
});
it("setNodeNum merges with existing DB with same myNodeNum", async () => {
+6 -2
View File
@@ -51,8 +51,8 @@ export const subscribeAll = (
});
connection.events.onWaypointPacket.subscribe((waypoint) => {
const { data } = waypoint;
device.addWaypoint(data);
const { data, channel, from, rxTime } = waypoint;
device.addWaypoint(data, channel, from, rxTime);
});
connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
@@ -127,6 +127,10 @@ export const subscribeAll = (
},
);
connection.events.onNeighborInfoPacket.subscribe((neighborInfo) => {
device.addNeighborInfo(neighborInfo.from, neighborInfo.data);
});
connection.events.onRoutingPacket.subscribe((routingPacket) => {
if (routingPacket.data.variant.case === "errorReason") {
switch (routingPacket.data.variant.value) {
+87
View File
@@ -0,0 +1,87 @@
import { bbox, lineString } from "@turf/turf";
export type LngLat = [number, number];
export type Mercator = [number, number];
export type Bounds = [[number, number], [number, number]];
const INT_DEG = 1e7;
const EARTH_RADIUS = 6378137;
export const toLngLat = (position?: {
latitudeI?: number;
longitudeI?: number;
}): LngLat => [
(position?.longitudeI ?? 0) / INT_DEG,
(position?.latitudeI ?? 0) / INT_DEG,
];
export const hasPos = (position?: {
latitudeI?: number;
longitudeI?: number;
}) =>
Number.isFinite(position?.latitudeI) &&
Number.isFinite(position?.longitudeI) &&
!(position?.latitudeI === 0 && position?.longitudeI === 0);
export const boundsFromLngLat = (coords: LngLat[]): Bounds | undefined => {
if (coords.length === 0) {
return undefined;
}
const turfCoords = coords.map(([lng, lat]) => [lat, lng]);
const [minLat, minLng, maxLat, maxLng] = bbox(lineString(turfCoords));
return [
[minLng, minLat],
[maxLng, maxLat],
];
};
const deg2rad = (d: number) => (d * Math.PI) / 180;
const rad2deg = (r: number) => (r * 180) / Math.PI;
export function lngLatToMercator([lng, lat]: LngLat): Mercator {
return [
EARTH_RADIUS * deg2rad(lng),
EARTH_RADIUS * Math.log(Math.tan(Math.PI / 4 + deg2rad(lat) / 2)),
];
}
export function mercatorToLngLat([x, y]: Mercator): LngLat {
return [
rad2deg(x / EARTH_RADIUS),
rad2deg(2 * Math.atan(Math.exp(y / EARTH_RADIUS)) - Math.PI / 2),
];
}
export function distanceMeters([lng1, lat1]: LngLat, [lng2, lat2]: LngLat) {
const phi1 = deg2rad(lat1),
phi2 = deg2rad(lat2);
const x = deg2rad(lng2 - lng1) * Math.cos((phi1 + phi2) * 0.5);
const y = phi2 - phi1;
return EARTH_RADIUS * Math.hypot(x, y);
}
export function precisionBitsToMeters(precisionBits: number): number {
const M_PER_DEG_EQ = (2 * Math.PI * EARTH_RADIUS) / 360; // ≈ 111_319.490793 m/deg
const stepInt = 2 ** (32 - precisionBits);
const stepDegrees = stepInt / INT_DEG;
return Math.round(0.5 * stepDegrees * M_PER_DEG_EQ);
}
export function bearingDegrees(from: LngLat, to: LngLat): number {
const [lambda1deg, phi1deg] = from;
const [lambda2deg, phi2deg] = to;
const phi1 = deg2rad(phi1deg);
const phi2 = deg2rad(phi2deg);
const deltaLambda = deg2rad(lambda2deg - lambda1deg);
const y = Math.sin(deltaLambda) * Math.cos(phi2);
const x =
Math.cos(phi1) * Math.sin(phi2) -
Math.sin(phi1) * Math.cos(phi2) * Math.cos(deltaLambda);
return (rad2deg(Math.atan2(y, x)) + 360) % 360;
}
@@ -0,0 +1,31 @@
export const SNR_THRESHOLD = {
GOOD: -7,
FAIR: -15,
};
export const RSSI_THRESHOLD = {
GOOD: -115,
FAIR: -126,
};
export const LINE_COLOR = {
GOOD: "#00ff00",
FAIR: "#ffe600",
BAD: "#f7931a",
};
export const getSignalColor = (snr: number, rssi?: number): string => {
if (
snr > SNR_THRESHOLD.GOOD &&
(rssi == null || rssi > RSSI_THRESHOLD.GOOD)
) {
return LINE_COLOR.GOOD;
}
if (
snr > SNR_THRESHOLD.FAIR &&
(rssi == null || rssi > RSSI_THRESHOLD.FAIR)
) {
return LINE_COLOR.FAIR;
}
return LINE_COLOR.BAD;
};