You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
337 lines
8.2 KiB
337 lines
8.2 KiB
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 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
|
|
const feat1 = makeFeature(pair.a, pair.b, aPos, bPos, pair.ab, true);
|
|
const feat2 = makeFeature(pair.b, pair.a, bPos, aPos, pair.ba, true);
|
|
|
|
if (feat1) {
|
|
features.push(feat1);
|
|
}
|
|
if (feat2) {
|
|
features.push(feat2);
|
|
}
|
|
} else if (pair.ab) {
|
|
// only a->b, straight
|
|
const feat = makeFeature(pair.a, pair.b, aPos, bPos, pair.ab, false);
|
|
if (feat) {
|
|
features.push(feat);
|
|
}
|
|
} else if (pair.ba) {
|
|
// only b->a, straight
|
|
const feat = makeFeature(pair.b, pair.a, bPos, aPos, pair.ba, false);
|
|
if (feat) {
|
|
features.push(feat);
|
|
}
|
|
}
|
|
}
|
|
|
|
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>
|
|
);
|
|
};
|
|
|