* 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>
342 lines
8.2 KiB
TypeScript
342 lines
8.2 KiB
TypeScript
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>
|
||
);
|
||
};
|