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:
co-authored by
jamon
Copilot
Dan Ditomaso
philon-
parent
8cc451546d
commit
36443fd838
@@ -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);
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user