Files
meshtastic-web/packages/web/src/components/PageComponents/Map/Layers/SNRLayer.tsx
T
36443fd838 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>
2025-09-18 08:13:34 -04:00

342 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Mono } from "@app/components/generic/Mono";
import { cn } from "@app/core/utils/cn";
import { getSignalColor } from "@app/core/utils/signalColor";
import type { VisibilityState } from "@components/PageComponents/Map/Tools/MapLayerTool";
import { useDevice } from "@core/stores";
import {
distanceMeters,
hasPos,
type LngLat,
lngLatToMercator,
mercatorToLngLat,
toLngLat,
} from "@core/utils/geo";
import type { Protobuf } from "@meshtastic/core";
import type { Feature, FeatureCollection } from "geojson";
import { useTranslation } from "react-i18next";
import { Layer, Source } from "react-map-gl/maplibre";
const ARC_SEGMENTS = 32;
const ARC_OFFSET = 0.01; // 1% of distance
const MIN_LEN = 1e-3; // meters
export interface SNRLayerProps {
id: string;
filteredNodes: Protobuf.Mesh.NodeInfo[];
myNode: Protobuf.Mesh.NodeInfo | undefined;
visibilityState: VisibilityState;
}
export interface SNRTooltipProps {
pos: { x: number; y: number };
snr: number;
from: string;
to: string;
}
type NeighborPlus = Protobuf.Mesh.Neighbor & {
num: number | undefined;
position: Protobuf.Mesh.Position | undefined;
};
type NeighborInfoPlus = Omit<Protobuf.Mesh.NeighborInfo, "neighbors"> & {
neighbors: NeighborPlus[];
};
type RemoteInfo = {
type: "remote";
node: Protobuf.Mesh.NodeInfo;
neighborInfo: NeighborInfoPlus;
};
type DirectInfo = {
type: "direct";
from: Protobuf.Mesh.NodeInfo;
to: Protobuf.Mesh.NodeInfo;
snr: number;
};
type NeighborInfos = RemoteInfo | DirectInfo;
type Pair = {
a: number;
b: number;
ab?: number; // SNR a->b
ba?: number; // SNR b->a
};
function arcSegment(
aLngLat: LngLat,
bLngLat: LngLat,
curved: boolean,
): LngLat[] | undefined {
if (!curved && distanceMeters(aLngLat, bLngLat) < MIN_LEN) {
// Straight line
return [aLngLat, bLngLat];
}
const [ax, ay] = lngLatToMercator(aLngLat);
const [bx, by] = lngLatToMercator(bLngLat);
const dx = bx - ax,
dy = by - ay;
const len = Math.hypot(dx, dy);
if (len < MIN_LEN) {
return undefined;
}
const offsetMeters = curved ? len * ARC_OFFSET : 0;
// Unit direction A->B and its left-hand normal
const ux = dx / len,
uy = dy / len;
const nx = -uy,
ny = ux;
// Control point at the midpoint, offset to the left by offsetMeters
const mx = (ax + bx) * 0.5,
my = (ay + by) * 0.5;
const cx = mx + nx * offsetMeters,
cy = my + ny * offsetMeters;
const coords: LngLat[] = [];
for (let i = 0; i <= ARC_SEGMENTS; i++) {
const t = i / ARC_SEGMENTS;
const omt = 1 - t;
// Quadratic Bézier: B(t) = (1−t)^2*A + 2(1−t)t*C + t^2*B
const x = omt * omt * ax + 2 * omt * t * cx + t * t * bx;
const y = omt * omt * ay + 2 * omt * t * cy + t * t * by;
coords.push(mercatorToLngLat([x, y]));
}
return coords;
}
function upsertPair(
fromId: number,
toId: number,
snr: number,
pairs: Map<string, Pair>,
idToLngLat: Map<number, LngLat>,
): void {
if (fromId === toId || !idToLngLat.has(fromId) || !idToLngLat.has(toId)) {
return;
}
const a = Math.min(fromId, toId);
const b = Math.max(fromId, toId);
const key = `${a}-${b}`;
let pair = pairs.get(key); // pair might exist
if (!pair) {
pair = { a, b };
}
// Store best SNR for each direction
if (fromId === a) {
pair.ab = pair.ab && pair.ab > snr ? pair.ab : snr;
} else {
pair.ba = pair.ba && pair.ba > snr ? pair.ba : snr;
}
pairs.set(key, pair);
}
function makeFeature(
fromId: number,
toId: number,
fromPos: LngLat,
toPos: LngLat,
snr: number,
curved: boolean,
): Feature | undefined {
const segment = arcSegment(fromPos, toPos, curved);
if (!segment) {
return undefined;
}
return {
type: "Feature",
geometry: { type: "LineString", coordinates: segment },
properties: {
color: getSignalColor(snr),
snr,
from: fromId,
to: toId,
},
};
}
function pushIfFeature(
a: number,
b: number,
aPos: LngLat,
bPos: LngLat,
snr: number,
curved: boolean,
features: Feature[],
) {
const feat = makeFeature(a, b, aPos, bPos, snr, curved);
if (feat) {
features.push(feat);
}
}
function generateNeighborLines(
neighborInfos: NeighborInfos[],
): FeatureCollection {
// Collect positions for all referenced nodes, discard pairs with missing positions
const idToLngLat = new Map<number, LngLat>();
const ensure = (node?: Protobuf.Mesh.NodeInfo | NeighborPlus) => {
if (node?.num && hasPos(node.position) && !idToLngLat.has(node.num)) {
idToLngLat.set(node.num, toLngLat(node.position));
}
};
for (const info of neighborInfos) {
if (info.type === "remote") {
ensure(info.node);
for (const neighbor of info.neighborInfo.neighbors) {
ensure(neighbor);
}
} else {
ensure(info.from);
ensure(info.to);
}
}
// Coalesce into pairs
const pairs = new Map<string, Pair>();
for (const info of neighborInfos) {
if (info.type === "remote") {
// RemoteInfo object
const fromId = info.node.num;
for (const neighbor of info.neighborInfo.neighbors) {
upsertPair(fromId, neighbor.nodeId, neighbor.snr, pairs, idToLngLat);
}
} else {
// DirectInfo object
upsertPair(info.from.num, info.to.num, info.snr, pairs, idToLngLat);
}
}
// Generate features
const features: Feature[] = [];
for (const pair of pairs.values()) {
const aPos = idToLngLat.get(pair.a);
const bPos = idToLngLat.get(pair.b);
if (!aPos || !bPos) {
continue;
}
if (pair.ab && pair.ba) {
// both directions → two arcs
pushIfFeature(pair.a, pair.b, aPos, bPos, pair.ab, true, features);
pushIfFeature(pair.b, pair.a, bPos, aPos, pair.ba, true, features);
} else {
// only one direction → straight
if (pair.ab) {
pushIfFeature(pair.a, pair.b, aPos, bPos, pair.ab, false, features);
}
if (pair.ba) {
pushIfFeature(pair.b, pair.a, bPos, aPos, pair.ba, false, features);
}
}
}
return { type: "FeatureCollection", features };
}
export const SNRTooltip = ({
pos,
snr,
from,
to,
}: Partial<SNRTooltipProps> = {}) => {
const { t } = useTranslation();
if (!pos) {
return undefined;
}
return (
<div
className={cn(
"absolute block p-2 px-3 text-sm bg-white dark:bg-slate-800 rounded-lg shadow",
"",
)}
style={{ left: `${pos.x + 5}px`, top: `${pos.y + 10}px` }}
aria-hidden={!pos}
>
<div>
<strong className="font-bold">{from ?? ""}</strong>
<span className="mx-1">⭢</span>
<strong className="font-bold">{to ?? ""}</strong>
</div>
<div>
SNR: <Mono>{snr?.toFixed?.(2) ?? t("unknown.shortName")}</Mono> dB
</div>
</div>
);
};
export const SNRLayer = ({
id,
filteredNodes,
myNode,
visibilityState,
}: SNRLayerProps): React.ReactNode => {
const { getNeighborInfo } = useDevice();
const remotePairs = visibilityState.remoteNeighbors
? filteredNodes.flatMap((node) => {
const neighborInfo = getNeighborInfo(node.num);
return neighborInfo
? [
{
type: "remote" as const,
node,
neighborInfo: {
...neighborInfo,
neighbors: neighborInfo.neighbors.map((n) => {
const node = filteredNodes.find(
(node) => node.num === n.nodeId,
);
return { ...n, num: node?.num, position: node?.position };
}),
},
} as const,
]
: [];
})
: [];
const directPairs =
visibilityState.directNeighbors && myNode
? filteredNodes
.filter((node) => node.hopsAway === 0 && node.num !== myNode.num)
.map((to) => ({
type: "direct" as const,
from: myNode,
to,
snr: to.snr ?? 0,
}))
: [];
const featureCollection = generateNeighborLines([
...remotePairs,
...directPairs,
]);
return (
<Source type="geojson" data={featureCollection}>
<Layer
id={id}
type="line"
paint={{
"line-color": ["get", "color"],
"line-width": 5,
}}
/>
</Source>
);
};