diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 800168d..1da52f6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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, -): [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, - coverage: NodeCoverage[], - linkPairs: Set, - linkMetrics: Map, -): { 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(); - 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(); - - 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): 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 | null>(null); const pathFadeRef = useRef(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') { diff --git a/frontend/src/utils/pathing.ts b/frontend/src/utils/pathing.ts new file mode 100644 index 0000000..cedbacd --- /dev/null +++ b/frontend/src/utils/pathing.ts @@ -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, +): [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, + coverage: NodeCoverage[], + linkPairs: Set, + linkMetrics: Map, +): { 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(); + 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(); + + 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): 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 }; +}