Refactor App pathing logic into dedicated utility module

This commit is contained in:
Ben
2026-03-04 22:43:01 +00:00
parent fb6e1369a7
commit 6653d3f2f8
2 changed files with 308 additions and 348 deletions
+31 -348
View File
@@ -7,7 +7,15 @@ import { StatsPanel } from './components/StatsPanel/StatsPanel.js';
import { PacketFeed } from './components/PacketFeed.js';
import { useWebSocket, type WSMessage, type WSReadyState } from './hooks/useWebSocket.js';
import { useNodes, type LivePacketData, type MeshNode, type AggregatedPacket } from './hooks/useNodes.js';
import { useCoverage, type NodeCoverage } from './hooks/useCoverage.js';
import { useCoverage } from './hooks/useCoverage.js';
import {
hasCoords,
linkKey,
MIN_LINK_OBSERVATIONS,
resolveBetaPath,
resolvePathWaypoints,
type LinkMetrics,
} from './utils/pathing.js';
const DEFAULT_FILTERS: Filters = {
livePackets: true,
@@ -43,339 +51,8 @@ const MeshIcon: React.FC = () => (
);
const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000;
const PATH_TTL = 5_000; // ms to display packet path before auto-clearing
/**
* Given a list of 2-char relay hop prefixes (from decoded.path), resolve each to
* the best-matching node and build a full waypoint array: src → relay... → rx.
* When multiple nodes share the same 2-char hex prefix, pick the one closest to
* the linearly-interpolated expected position along the src→rx line.
*/
function resolvePathWaypoints(
pathHashes: string[],
src: MeshNode | null, // null when sender unknown (e.g. GroupText)
rx: MeshNode,
allNodes: Map<string, MeshNode>,
): [number, number][] {
const waypoints: [number, number][] = src ? [[src.lat!, src.lon!]] : [];
const N = pathHashes.length;
for (let i = 0; i < N; i++) {
const prefix = pathHashes[i]!.toUpperCase();
const candidates = Array.from(allNodes.values()).filter(
(n) => hasCoords(n) && !n.name?.includes('🚫') && n.node_id.toUpperCase().startsWith(prefix),
);
if (candidates.length === 0) continue;
let best = candidates[0]!;
if (candidates.length > 1) {
if (src) {
// Interpolate expected position along src→rx line
const t = (i + 1) / (N + 1);
const expLat = src.lat! + t * (rx.lat! - src.lat!);
const expLon = src.lon! + t * (rx.lon! - src.lon!);
best = candidates.reduce((a, b) => {
const da = Math.hypot(a.lat! - expLat, a.lon! - expLon);
const db = Math.hypot(b.lat! - expLat, b.lon! - expLon);
return da <= db ? a : b;
});
} else {
// No src — pick candidate closest to last placed waypoint, falling back to rx
const [anchorLat, anchorLon] = waypoints.length > 0
? waypoints[waypoints.length - 1]!
: [rx.lat!, rx.lon!];
best = candidates.reduce((a, b) => {
const da = Math.hypot(a.lat! - anchorLat, a.lon! - anchorLon);
const db = Math.hypot(b.lat! - anchorLat, b.lon! - anchorLon);
return da <= db ? a : b;
});
}
}
waypoints.push([best.lat!, best.lon!]);
}
waypoints.push([rx.lat!, rx.lon!]);
return waypoints;
}
// ── Beta path helpers ─────────────────────────────────────────────────────────
const MAX_BETA_HOPS = 15;
const MIN_LINK_OBSERVATIONS = 5; // must match backend db/index.ts
type LinkMetrics = {
observed_count: number;
itm_viable?: boolean | null;
itm_path_loss_db?: number | null;
count_a_to_b?: number;
count_b_to_a?: number;
};
/** Canonical lookup key for a link between two nodes (order-independent). */
function linkKey(a: string, b: string): string {
return a < b ? `${a}:${b}` : `${b}:${a}`;
}
function hasCoords(node: MeshNode | null | undefined): node is MeshNode & { lat: number; lon: number } {
return typeof node?.lat === 'number' && typeof node?.lon === 'number';
}
function distKm(a: MeshNode, b: MeshNode): number {
const midLat = ((a.lat! + b.lat!) / 2) * (Math.PI / 180);
const dlat = (a.lat! - b.lat!) * 111;
const dlon = (a.lon! - b.lon!) * 111 * Math.cos(midLat);
return Math.hypot(dlat, dlon);
}
// ── Line-of-sight check (smooth Earth, k=0.25 refraction) ────────────────────
const R_EFF_M = 6_371_000 / (1 - 0.25); // ~8,495,000 m effective Earth radius
/**
* Smooth-Earth LOS check with atmospheric refraction k=0.25 (equivalent to
* the standard 4/3-Earth model). Uses each node's elevation_m (terrain ASL)
* plus 5 m antenna height. Samples 20 evenly-spaced points along the path
* and rejects the hop if Earth curvature alone would block the ray at any
* point. Does not model terrain between the nodes — confirmed links already
* have full ITM terrain analysis; this guards unconfirmed candidates.
*/
function hasLoS(a: MeshNode, b: MeshNode): boolean {
const hA = (a.elevation_m ?? 0) + 5; // metres ASL at antenna
const hB = (b.elevation_m ?? 0) + 5;
const d = distKm(a, b) * 1000; // metres
if (d < 1) return true;
for (let i = 1; i < 20; i++) {
const t = i / 20;
const x = t * d;
const los = hA + (hB - hA) * t;
const bulge = x * (d - x) / (2 * R_EFF_M);
if (los < bulge) return false;
}
return true;
}
// Fallback range check (used when no ITM data exists for a pair).
function nodeRange(nodeId: string, coverage: NodeCoverage[]): number {
const cov = coverage.find((c) => c.node_id === nodeId);
if (!cov?.radius_m) return 50;
return Math.min(80, Math.max(50, cov.radius_m / 1000));
}
function canReach(a: MeshNode, b: MeshNode, coverage: NodeCoverage[]): boolean {
const threshold = Math.max(nodeRange(a.node_id, coverage), nodeRange(b.node_id, coverage));
return distKm(a, b) < threshold;
}
/**
* Beta path resolver — backtracking DFS working backwards from the receiver.
*
* For each relay prefix (reversed, so we start anchored at a known position):
* 1. Try confirmed neighbours of prevNode first (real observed link data).
* 2. If none, try candidates within mutual radio range (coverage-radius based).
* 3. If a chosen candidate leads to dead ends at subsequent hops, backtrack
* and try the next candidate at this hop.
* 4. If no candidate leads to a valid continuation, skip this hop (up to
* maxSkips total). Skips are bounded so the path can't just drop all hops.
*/
function resolveBetaPath(
pathHashes: string[],
src: MeshNode | null,
rx: MeshNode,
allNodes: Map<string, MeshNode>,
coverage: NodeCoverage[],
linkPairs: Set<string>,
linkMetrics: Map<string, LinkMetrics>,
): { path: [number, number][]; confidence: number } | null {
if (!hasCoords(rx) || pathHashes.length === 0) return null;
if (pathHashes.length >= MAX_BETA_HOPS) return null;
const rxLat = rx.lat;
const rxLon = rx.lon;
type HopResult = { node: MeshNode; conf: number } | null; // null = skipped
const candidatesPool = Array.from(allNodes.values()).filter(
(n) => hasCoords(n) && (n.role === undefined || n.role === 2) && !n.name?.includes('🚫'),
);
const prefixCounts = new Map<string, number>();
for (const n of candidatesPool) {
const p = n.node_id.slice(0, 2).toUpperCase();
prefixCounts.set(p, (prefixCounts.get(p) ?? 0) + 1);
}
const totalDist = hasCoords(src) ? distKm(src, rx) : 0;
const corridorMaxKm = Math.max(8, Math.min(35, totalDist * 0.25));
function inCorridor(candidate: MeshNode, prevNode: MeshNode): boolean {
if (!hasCoords(src)) return true;
const bx = src.lon - rxLon;
const by = src.lat - rxLat;
const segLen2 = bx * bx + by * by;
if (segLen2 < 1e-9) return true;
const px = candidate.lon! - rxLon;
const py = candidate.lat! - rxLat;
const t = (px * bx + py * by) / segLen2;
if (t < -0.15 || t > 1.15) return false;
const projx = rxLon + t * bx;
const projy = rxLat + t * by;
const midLat = ((candidate.lat! + projy) / 2) * (Math.PI / 180);
const kmPerLon = 111 * Math.cos(midLat);
const dxKm = (candidate.lon! - projx) * kmPerLon;
const dyKm = (candidate.lat! - projy) * 111;
const crossTrackKm = Math.hypot(dxKm, dyKm);
if (crossTrackKm > corridorMaxKm) return false;
// Backward search should generally move us toward src, not away.
return distKm(candidate, src) <= distKm(prevNode, src) + 8;
}
function directionalSupport(meta: LinkMetrics | undefined, fromId: string, toId: string): number {
if (!meta || meta.count_a_to_b == null || meta.count_b_to_a == null) return 0.5;
const a = fromId < toId ? fromId : toId;
const forward = fromId === a ? meta.count_a_to_b : meta.count_b_to_a;
const reverse = fromId === a ? meta.count_b_to_a : meta.count_a_to_b;
const total = forward + reverse;
if (total <= 0) return 0.5;
return forward / total;
}
function confirmedConfidence(meta: LinkMetrics | undefined, fromId: string, toId: string): number {
const observed = meta?.observed_count ?? MIN_LINK_OBSERVATIONS;
const obsBoost = Math.min(0.18, Math.log10(1 + observed) * 0.12);
const pathLoss = meta?.itm_path_loss_db;
const plPenalty = pathLoss == null ? 0 : Math.min(0.12, Math.max(0, (pathLoss - 130) / 120));
const dirBoost = (directionalSupport(meta, fromId, toId) - 0.5) * 0.12;
const viableBoost = meta?.itm_viable === false ? -0.1 : 0.05;
const conf = 0.68 + obsBoost + dirBoost + viableBoost - plPenalty;
return Math.max(0.45, Math.min(0.98, conf));
}
/** Ordered candidate list for a single hop: confirmed neighbours first, then reachable,
* then a last-resort closest match within 50 km. A hop is only skipped when no node
* in the DB has the matching 2-char prefix at all. */
function getCandidates(prefix: string, prevNode: MeshNode): Array<{ node: MeshNode; conf: number }> {
const all = candidatesPool.filter(
(n) => n.node_id.toUpperCase().startsWith(prefix),
);
if (all.length === 0) return [];
// Cosine similarity between prevNode→candidate and prevNode→src vectors.
// Returns 1 (perfectly aligned toward src), 0 (perpendicular), -1 (opposite).
// Falls back to 0 when src is unknown.
function align(c: MeshNode): number {
if (!hasCoords(src)) return 0;
const dLat = src.lat! - prevNode.lat!;
const dLon = src.lon! - prevNode.lon!;
const cLat = c.lat! - prevNode.lat!;
const cLon = c.lon! - prevNode.lon!;
const dot = dLat * cLat + dLon * cLon;
const mag = Math.hypot(dLat, dLon) * Math.hypot(cLat, cLon);
return mag > 0 ? dot / mag : 0;
}
// Combined sort score: alignment toward src weighted against distance.
// 50 km normaliser means alignment dominates at short range and distance
// penalises equally at the 50 km fallback boundary.
function sortScore(c: MeshNode): number {
const corridorBonus = inCorridor(c, prevNode) ? 0.25 : -0.6;
return align(c) - distKm(c, prevNode) / 50 + corridorBonus;
}
const usedIds = new Set<string>();
const confirmed = all
.filter((c) => linkPairs.has(linkKey(c.node_id, prevNode.node_id)))
.sort((a, b) => sortScore(b) - sortScore(a))
.slice(0, 4)
.map((c) => {
usedIds.add(c.node_id);
const meta = linkMetrics.get(linkKey(c.node_id, prevNode.node_id));
return { node: c, conf: confirmedConfidence(meta, c.node_id, prevNode.node_id) };
});
const reachable = all
.filter((c) => !usedIds.has(c.node_id) && inCorridor(c, prevNode) && canReach(c, prevNode, coverage) && hasLoS(c, prevNode))
.sort((a, b) => sortScore(b) - sortScore(a))
.slice(0, 3)
.map((c) => {
usedIds.add(c.node_id);
const distancePenalty = Math.min(0.12, distKm(c, prevNode) / 120);
return { node: c, conf: Math.max(0.08, 0.28 - distancePenalty - (all.length - 1) * 0.01) };
});
// Last resort: prefix match within 50 km with LOS clearance, sorted toward src.
const fallback = all
.filter((c) => !usedIds.has(c.node_id) && inCorridor(c, prevNode) && distKm(c, prevNode) < 50 && hasLoS(c, prevNode))
.sort((a, b) => sortScore(b) - sortScore(a))
.slice(0, 1)
.map((c) => ({ node: c, conf: 0.05 / Math.max(1, all.length) }));
return [...confirmed, ...reachable, ...fallback];
}
// Allow at most 2 skips, and no more than 1 per 3 hops.
// Prevents degenerate paths where most relays are skipped due to sparse data.
const maxSkips = Math.min(2, Math.floor(pathHashes.length / 3));
// Dynamic DFS budget: scales with hops + prefix ambiguity while capping worst-case cost.
const ambiguity = pathHashes.reduce(
(sum, h) => sum + (prefixCounts.get(h.slice(0, 2).toUpperCase()) ?? 0),
0,
);
let budget = Math.max(180, Math.min(1600, 90 + pathHashes.length * 30 + ambiguity * 18));
/**
* Recursive DFS. Returns the hop results (rx-to-src order) or null if budget
* was exhausted before a valid path could be found.
*/
function solve(hopIdx: number, prevNode: MeshNode, skipsLeft: number, visited: Set<string>): HopResult[] | null {
if (hopIdx < 0) return [];
if (--budget <= 0) return null;
const prefix = pathHashes[hopIdx]!.slice(0, 2).toUpperCase();
// Exclude nodes already used in this path — MeshCore nodes only relay a packet once.
const options = getCandidates(prefix, prevNode).filter((o) => !visited.has(o.node.node_id));
// Try each candidate. If it leads to a dead end, backtrack and try the next.
for (const opt of options) {
const nextVisited = new Set(visited);
nextVisited.add(opt.node.node_id);
const rest = solve(hopIdx - 1, opt.node, skipsLeft, nextVisited);
if (rest !== null) return [opt, ...rest];
}
// No candidate produced a valid continuation — try skipping this hop.
if (skipsLeft > 0) {
const rest = solve(hopIdx - 1, prevNode, skipsLeft - 1, visited);
if (rest !== null) return [null, ...rest];
}
return null; // truly stuck — caller will try its next candidate
}
const raw = solve(pathHashes.length - 1, rx, maxSkips, new Set([rx.node_id]));
if (!raw) return null;
// raw is in rx→src order; reverse to get src→rx order for rendering.
const hops = [...raw].reverse().filter((r): r is { node: MeshNode; conf: number } => r !== null);
if (hops.length === 0) return null;
const totalHops = raw.length;
const skipped = totalHops - hops.length;
const meanHopConfidence = hops.reduce((sum, h) => sum + h.conf, 0) / hops.length;
const resolvedRatio = hops.length / totalHops;
const skipPenalty = Math.max(0.2, 1 - skipped * 0.28);
const confidence = Math.max(0, Math.min(1, meanHopConfidence * resolvedRatio * skipPenalty));
const pathNodes: MeshNode[] = [
...(hasCoords(src) ? [src] : []),
...hops.map((h) => h.node),
rx,
];
if (pathNodes.length < 2) return null;
return { path: pathNodes.map((n) => [n.lat!, n.lon!]), confidence };
}
const DISCLAIMER_KEY = 'meshcore-disclaimer-dismissed';
const DisclaimerModal: React.FC<{ onClose: () => void }> = ({ onClose }) => (
@@ -428,6 +105,21 @@ export const App: React.FC = () => {
const [pathOpacity, setPathOpacity] = useState(0.75);
const pathTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pathFadeRef = useRef<number | null>(null);
const stopPathTimers = useCallback(() => {
if (pathTimerRef.current) {
clearTimeout(pathTimerRef.current);
pathTimerRef.current = null;
}
if (pathFadeRef.current !== null) {
cancelAnimationFrame(pathFadeRef.current);
pathFadeRef.current = null;
}
}, []);
const clearPathState = useCallback(() => {
setPacketPath(null);
setBetaPacketPath(null);
setPathOpacity(0.75);
}, []);
const {
nodes, packets, arcs, activeNodes,
@@ -478,8 +170,7 @@ export const App: React.FC = () => {
const latestId = packets[0]?.id;
useEffect(() => {
if (pinnedPacketId !== null) return;
if (pathTimerRef.current) clearTimeout(pathTimerRef.current);
if (pathFadeRef.current !== null) { cancelAnimationFrame(pathFadeRef.current); pathFadeRef.current = null; }
stopPathTimers();
const latest = packets[0];
const rx = latest?.rxNodeId ? nodes.get(latest.rxNodeId) : undefined;
@@ -524,9 +215,7 @@ export const App: React.FC = () => {
pathFadeRef.current = requestAnimationFrame(animate);
} else {
pathFadeRef.current = null;
setPacketPath(null);
setBetaPacketPath(null);
setPathOpacity(0.75);
clearPathState();
}
};
pathFadeRef.current = requestAnimationFrame(animate);
@@ -539,17 +228,13 @@ export const App: React.FC = () => {
if (pinnedPacketId === packet.id) {
setPinnedPacketId(null);
if (pinnedTimerRef.current) { clearTimeout(pinnedTimerRef.current); pinnedTimerRef.current = null; }
if (pathTimerRef.current) { clearTimeout(pathTimerRef.current); pathTimerRef.current = null; }
if (pathFadeRef.current !== null) { cancelAnimationFrame(pathFadeRef.current); pathFadeRef.current = null; }
setPacketPath(null);
setBetaPacketPath(null);
setPathOpacity(0.75);
stopPathTimers();
clearPathState();
return;
}
// Clear any running auto timers
if (pathTimerRef.current) { clearTimeout(pathTimerRef.current); pathTimerRef.current = null; }
if (pathFadeRef.current !== null) { cancelAnimationFrame(pathFadeRef.current); pathFadeRef.current = null; }
stopPathTimers();
if (pinnedTimerRef.current) { clearTimeout(pinnedTimerRef.current); pinnedTimerRef.current = null; }
const rx = packet.rxNodeId ? nodes.get(packet.rxNodeId) : undefined;
@@ -581,9 +266,7 @@ export const App: React.FC = () => {
pathFadeRef.current = requestAnimationFrame(animate);
} else {
pathFadeRef.current = null;
setPacketPath(null);
setBetaPacketPath(null);
setPathOpacity(0.75);
clearPathState();
setPinnedPacketId(null);
pinnedTimerRef.current = null;
}
@@ -591,7 +274,7 @@ export const App: React.FC = () => {
pathFadeRef.current = requestAnimationFrame(animate);
}, 30_000);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pinnedPacketId, nodes, coverage, linkPairs, linkMetrics, filters.betaPaths, filters.betaPathThreshold]);
}, [pinnedPacketId, nodes, coverage, linkPairs, linkMetrics, filters.betaPaths, filters.betaPathThreshold, stopPathTimers, clearPathState]);
const handleMessage = useCallback((msg: WSMessage) => {
if (msg.type === 'initial_state') {
+277
View File
@@ -0,0 +1,277 @@
import type { MeshNode } from '../hooks/useNodes.js';
import type { NodeCoverage } from '../hooks/useCoverage.js';
const MAX_BETA_HOPS = 15;
export const MIN_LINK_OBSERVATIONS = 5; // must match backend db/index.ts
export type LinkMetrics = {
observed_count: number;
itm_viable?: boolean | null;
itm_path_loss_db?: number | null;
count_a_to_b?: number;
count_b_to_a?: number;
};
export function linkKey(a: string, b: string): string {
return a < b ? `${a}:${b}` : `${b}:${a}`;
}
export function hasCoords(node: MeshNode | null | undefined): node is MeshNode & { lat: number; lon: number } {
return typeof node?.lat === 'number' && typeof node?.lon === 'number';
}
function distKm(a: MeshNode, b: MeshNode): number {
const midLat = ((a.lat! + b.lat!) / 2) * (Math.PI / 180);
const dlat = (a.lat! - b.lat!) * 111;
const dlon = (a.lon! - b.lon!) * 111 * Math.cos(midLat);
return Math.hypot(dlat, dlon);
}
const R_EFF_M = 6_371_000 / (1 - 0.25); // ~8,495,000 m effective Earth radius
function hasLoS(a: MeshNode, b: MeshNode): boolean {
const hA = (a.elevation_m ?? 0) + 5; // metres ASL at antenna
const hB = (b.elevation_m ?? 0) + 5;
const d = distKm(a, b) * 1000; // metres
if (d < 1) return true;
for (let i = 1; i < 20; i++) {
const t = i / 20;
const x = t * d;
const los = hA + (hB - hA) * t;
const bulge = x * (d - x) / (2 * R_EFF_M);
if (los < bulge) return false;
}
return true;
}
function nodeRange(nodeId: string, coverage: NodeCoverage[]): number {
const cov = coverage.find((c) => c.node_id === nodeId);
if (!cov?.radius_m) return 50;
return Math.min(80, Math.max(50, cov.radius_m / 1000));
}
function canReach(a: MeshNode, b: MeshNode, coverage: NodeCoverage[]): boolean {
const threshold = Math.max(nodeRange(a.node_id, coverage), nodeRange(b.node_id, coverage));
return distKm(a, b) < threshold;
}
export function resolvePathWaypoints(
pathHashes: string[],
src: MeshNode | null,
rx: MeshNode,
allNodes: Map<string, MeshNode>,
): [number, number][] {
const waypoints: [number, number][] = src ? [[src.lat!, src.lon!]] : [];
const N = pathHashes.length;
for (let i = 0; i < N; i++) {
const prefix = pathHashes[i]!.toUpperCase();
const candidates = Array.from(allNodes.values()).filter(
(n) => hasCoords(n) && !n.name?.includes('🚫') && n.node_id.toUpperCase().startsWith(prefix),
);
if (candidates.length === 0) continue;
let best = candidates[0]!;
if (candidates.length > 1) {
if (src) {
const t = (i + 1) / (N + 1);
const expLat = src.lat! + t * (rx.lat! - src.lat!);
const expLon = src.lon! + t * (rx.lon! - src.lon!);
best = candidates.reduce((a, b) => {
const da = Math.hypot(a.lat! - expLat, a.lon! - expLon);
const db = Math.hypot(b.lat! - expLat, b.lon! - expLon);
return da <= db ? a : b;
});
} else {
const [anchorLat, anchorLon] = waypoints.length > 0
? waypoints[waypoints.length - 1]!
: [rx.lat!, rx.lon!];
best = candidates.reduce((a, b) => {
const da = Math.hypot(a.lat! - anchorLat, a.lon! - anchorLon);
const db = Math.hypot(b.lat! - anchorLat, b.lon! - anchorLon);
return da <= db ? a : b;
});
}
}
waypoints.push([best.lat!, best.lon!]);
}
waypoints.push([rx.lat!, rx.lon!]);
return waypoints;
}
export function resolveBetaPath(
pathHashes: string[],
src: MeshNode | null,
rx: MeshNode,
allNodes: Map<string, MeshNode>,
coverage: NodeCoverage[],
linkPairs: Set<string>,
linkMetrics: Map<string, LinkMetrics>,
): { path: [number, number][]; confidence: number } | null {
if (!hasCoords(rx) || pathHashes.length === 0) return null;
if (pathHashes.length >= MAX_BETA_HOPS) return null;
const rxLat = rx.lat;
const rxLon = rx.lon;
type HopResult = { node: MeshNode; conf: number } | null;
const candidatesPool = Array.from(allNodes.values()).filter(
(n) => hasCoords(n) && (n.role === undefined || n.role === 2) && !n.name?.includes('🚫'),
);
const prefixCounts = new Map<string, number>();
for (const n of candidatesPool) {
const p = n.node_id.slice(0, 2).toUpperCase();
prefixCounts.set(p, (prefixCounts.get(p) ?? 0) + 1);
}
const totalDist = hasCoords(src) ? distKm(src, rx) : 0;
const corridorMaxKm = Math.max(8, Math.min(35, totalDist * 0.25));
function inCorridor(candidate: MeshNode, prevNode: MeshNode): boolean {
if (!hasCoords(src)) return true;
const bx = src.lon - rxLon;
const by = src.lat - rxLat;
const segLen2 = bx * bx + by * by;
if (segLen2 < 1e-9) return true;
const px = candidate.lon! - rxLon;
const py = candidate.lat! - rxLat;
const t = (px * bx + py * by) / segLen2;
if (t < -0.15 || t > 1.15) return false;
const projx = rxLon + t * bx;
const projy = rxLat + t * by;
const midLat = ((candidate.lat! + projy) / 2) * (Math.PI / 180);
const kmPerLon = 111 * Math.cos(midLat);
const dxKm = (candidate.lon! - projx) * kmPerLon;
const dyKm = (candidate.lat! - projy) * 111;
const crossTrackKm = Math.hypot(dxKm, dyKm);
if (crossTrackKm > corridorMaxKm) return false;
return distKm(candidate, src) <= distKm(prevNode, src) + 8;
}
function directionalSupport(meta: LinkMetrics | undefined, fromId: string, toId: string): number {
if (!meta || meta.count_a_to_b == null || meta.count_b_to_a == null) return 0.5;
const a = fromId < toId ? fromId : toId;
const forward = fromId === a ? meta.count_a_to_b : meta.count_b_to_a;
const reverse = fromId === a ? meta.count_b_to_a : meta.count_a_to_b;
const total = forward + reverse;
if (total <= 0) return 0.5;
return forward / total;
}
function confirmedConfidence(meta: LinkMetrics | undefined, fromId: string, toId: string): number {
const observed = meta?.observed_count ?? MIN_LINK_OBSERVATIONS;
const obsBoost = Math.min(0.18, Math.log10(1 + observed) * 0.12);
const pathLoss = meta?.itm_path_loss_db;
const plPenalty = pathLoss == null ? 0 : Math.min(0.12, Math.max(0, (pathLoss - 130) / 120));
const dirBoost = (directionalSupport(meta, fromId, toId) - 0.5) * 0.12;
const viableBoost = meta?.itm_viable === false ? -0.1 : 0.05;
const conf = 0.68 + obsBoost + dirBoost + viableBoost - plPenalty;
return Math.max(0.45, Math.min(0.98, conf));
}
function getCandidates(prefix: string, prevNode: MeshNode): Array<{ node: MeshNode; conf: number }> {
const all = candidatesPool.filter((n) => n.node_id.toUpperCase().startsWith(prefix));
if (all.length === 0) return [];
function align(c: MeshNode): number {
if (!hasCoords(src)) return 0;
const dLat = src.lat! - prevNode.lat!;
const dLon = src.lon! - prevNode.lon!;
const cLat = c.lat! - prevNode.lat!;
const cLon = c.lon! - prevNode.lon!;
const dot = dLat * cLat + dLon * cLon;
const mag = Math.hypot(dLat, dLon) * Math.hypot(cLat, cLon);
return mag > 0 ? dot / mag : 0;
}
function sortScore(c: MeshNode): number {
const corridorBonus = inCorridor(c, prevNode) ? 0.25 : -0.6;
return align(c) - distKm(c, prevNode) / 50 + corridorBonus;
}
const usedIds = new Set<string>();
const confirmed = all
.filter((c) => linkPairs.has(linkKey(c.node_id, prevNode.node_id)))
.sort((a, b) => sortScore(b) - sortScore(a))
.slice(0, 4)
.map((c) => {
usedIds.add(c.node_id);
const meta = linkMetrics.get(linkKey(c.node_id, prevNode.node_id));
return { node: c, conf: confirmedConfidence(meta, c.node_id, prevNode.node_id) };
});
const reachable = all
.filter((c) => !usedIds.has(c.node_id) && inCorridor(c, prevNode) && canReach(c, prevNode, coverage) && hasLoS(c, prevNode))
.sort((a, b) => sortScore(b) - sortScore(a))
.slice(0, 3)
.map((c) => {
usedIds.add(c.node_id);
const distancePenalty = Math.min(0.12, distKm(c, prevNode) / 120);
return { node: c, conf: Math.max(0.08, 0.28 - distancePenalty - (all.length - 1) * 0.01) };
});
const fallback = all
.filter((c) => !usedIds.has(c.node_id) && inCorridor(c, prevNode) && distKm(c, prevNode) < 50 && hasLoS(c, prevNode))
.sort((a, b) => sortScore(b) - sortScore(a))
.slice(0, 1)
.map((c) => ({ node: c, conf: 0.05 / Math.max(1, all.length) }));
return [...confirmed, ...reachable, ...fallback];
}
const maxSkips = Math.min(2, Math.floor(pathHashes.length / 3));
const ambiguity = pathHashes.reduce(
(sum, h) => sum + (prefixCounts.get(h.slice(0, 2).toUpperCase()) ?? 0),
0,
);
let budget = Math.max(180, Math.min(1600, 90 + pathHashes.length * 30 + ambiguity * 18));
function solve(hopIdx: number, prevNode: MeshNode, skipsLeft: number, visited: Set<string>): HopResult[] | null {
if (hopIdx < 0) return [];
if (--budget <= 0) return null;
const prefix = pathHashes[hopIdx]!.slice(0, 2).toUpperCase();
const options = getCandidates(prefix, prevNode).filter((o) => !visited.has(o.node.node_id));
for (const opt of options) {
const nextVisited = new Set(visited);
nextVisited.add(opt.node.node_id);
const rest = solve(hopIdx - 1, opt.node, skipsLeft, nextVisited);
if (rest !== null) return [opt, ...rest];
}
if (skipsLeft > 0) {
const rest = solve(hopIdx - 1, prevNode, skipsLeft - 1, visited);
if (rest !== null) return [null, ...rest];
}
return null;
}
const raw = solve(pathHashes.length - 1, rx, maxSkips, new Set([rx.node_id]));
if (!raw) return null;
const hops = [...raw].reverse().filter((r): r is { node: MeshNode; conf: number } => r !== null);
if (hops.length === 0) return null;
const totalHops = raw.length;
const skipped = totalHops - hops.length;
const meanHopConfidence = hops.reduce((sum, h) => sum + h.conf, 0) / hops.length;
const resolvedRatio = hops.length / totalHops;
const skipPenalty = Math.max(0.2, 1 - skipped * 0.28);
const confidence = Math.max(0, Math.min(1, meanHopConfidence * resolvedRatio * skipPenalty));
const pathNodes: MeshNode[] = [
...(hasCoords(src) ? [src] : []),
...hops.map((h) => h.node),
rx,
];
if (pathNodes.length < 2) return null;
return { path: pathNodes.map((n) => [n.lat!, n.lon!]), confidence };
}