Refine beta path rendering, confidence split, and adverts source anchoring

This commit is contained in:
Ben committed 2026-03-06 00:44:07 +00:00
1 parent 0d77a5027c
commit fe56f39007
12 files changed
+1152 -98

No files matched your search

+2 -2
View File
@@ -858,14 +858,14 @@ router.get('/stats/charts', async (req, res) => {
${filters.packets}
GROUP BY hop_count ORDER BY hop_count
`, filters.params),
// top repeated first-2-hex prefix collisions across known nodes
// top repeated first-2-hex prefix collisions across known repeaters
query(`
WITH prefix_counts AS (
SELECT SUBSTRING(LOWER(n.node_id) FROM 1 FOR 2) AS prefix, COUNT(*)::int AS node_count
FROM nodes n
WHERE n.node_id ~ '^[0-9A-Fa-f]{64}$'
AND (n.name IS NULL OR n.name NOT LIKE '%🚫%')
AND (n.role IS NULL OR n.role != 4)
AND (n.role IS NULL OR n.role = 2)
${filters.nodesAlias('n')}
GROUP BY 1
HAVING COUNT(*) > 1
+33 -4
View File
@@ -35,6 +35,7 @@ const MAX_EDGE_CHOICES_PER_GROUP = 8;
const MAX_MOTIF2_CHOICES_PER_GROUP = 6;
const MAX_MOTIF3_CHOICES_PER_GROUP = 4;
const HOUR_BUCKET_SIZE = 6;
const PREFIX_AMBIGUITY_RADIUS_KM = 45;
function linkKey(a: string, b: string): string {
return a < b ? `${a}:${b}` : `${b}:${a}`;
@@ -70,6 +71,30 @@ function recencyScore(lastSeenMs: number | undefined, nowMs: number): number {
return clamp(Math.exp(-ageDays / 21), 0.05, 1);
}
function localPrefixAmbiguityPenalty(
anchor: LearningNode,
target: LearningNode,
prefixMap: Map<string, LearningNode[]>,
): number {
const prefix = target.node_id.slice(0, 2).toUpperCase();
const samePrefixNodes = prefixMap.get(prefix) ?? [];
if (samePrefixNodes.length <= 1) return 0;
const targetDist = distKm(anchor, target);
let raw = 0;
for (const candidate of samePrefixNodes) {
if (candidate.node_id === target.node_id) continue;
const candDist = distKm(anchor, candidate);
if (candDist > PREFIX_AMBIGUITY_RADIUS_KM) continue;
const distanceSimilarity = clamp(1 - Math.abs(candDist - targetDist) / PREFIX_AMBIGUITY_RADIUS_KM, 0, 1);
const proximity = clamp(1 - candDist / PREFIX_AMBIGUITY_RADIUS_KM, 0, 1);
raw += distanceSimilarity * proximity;
}
// Keep this bounded so we only nudge confidence down in ambiguous local clusters.
return clamp(raw * 0.12, 0, 0.24);
}
function resolvePathForPacket(
pathHashes: string[],
srcNode: LearningNode | undefined,
@@ -93,7 +118,8 @@ function resolvePathForPacket(
const confirmed = confirmedLinks.has(linkKey(candidate.node_id, prev.node_id)) ? 1 : 0;
const distanceScore = distancePrior(candidate, prev);
const srcScore = srcNode ? (distKm(srcNode, prev) - distKm(srcNode, candidate)) / 100 : 0;
const score = confirmed * 2.5 + distanceScore * 1.3 + srcScore;
const ambiguityPenalty = localPrefixAmbiguityPenalty(prev, candidate, prefixMap);
const score = confirmed * 2.5 + distanceScore * 1.3 + srcScore - ambiguityPenalty;
if (score > bestScore) {
best = candidate;
bestScore = score;
@@ -398,9 +424,12 @@ async function rebuildNetwork(modelNetwork: string, sourceNetwork: string | unde
let consistencyPenalty = 0;
const fromNode = nodesById.get(fromNodeId!);
const toNode = nodesById.get(toNodeId);
if (fromNode && toNode && pathLoss != null) {
const dKm = distKm(fromNode, toNode);
if (dKm > 55 && pathLoss > 150) consistencyPenalty += 0.14;
if (fromNode && toNode) {
if (pathLoss != null) {
const dKm = distKm(fromNode, toNode);
if (dKm > 55 && pathLoss > 150) consistencyPenalty += 0.14;
}
consistencyPenalty += localPrefixAmbiguityPenalty(fromNode, toNode, prefixMap);
}
if (observed < 3 && missing >= 3) consistencyPenalty += 0.1;
if (directional < 0.06 && observed >= 5) consistencyPenalty += 0.06;
+48 -2
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { Map as LeafletMap } from 'leaflet';
import { MapView } from './components/Map/MapView.js';
import { FilterPanel, type Filters } from './components/FilterPanel/FilterPanel.js';
@@ -24,6 +24,8 @@ const DEFAULT_FILTERS: Filters = {
betaPaths: false,
betaPathThreshold: 0.5,
links: false,
hexClashes: false,
hexClashMaxHops: 3,
};
const DISCLAIMER_KEY = 'meshcore-disclaimer-dismissed';
@@ -43,6 +45,8 @@ export const App: React.FC = () => {
});
const [map, setMap] = useState<LeafletMap | null>(null);
const [showDisclaimer, setShowDisclaimer] = useState(() => !localStorage.getItem(DISCLAIMER_KEY));
const clashRestoreRef = useRef<{ links: boolean; coverage: boolean; clientNodes: boolean } | null>(null);
const prevHexClashesRef = useRef<boolean>(DEFAULT_FILTERS.hexClashes);
const {
nodes,
@@ -84,7 +88,10 @@ export const App: React.FC = () => {
const {
packetPath,
betaPacketPath,
betaLowConfidencePath,
betaCompletionPaths,
betaPathConfidence,
betaPermutationCount,
pathOpacity,
pinnedPacketId,
handlePacketPin,
@@ -108,6 +115,36 @@ export const App: React.FC = () => {
localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
}, [filters]);
useEffect(() => {
const wasHexClashes = prevHexClashesRef.current;
const isHexClashes = filters.hexClashes;
if (!wasHexClashes && isHexClashes) {
clashRestoreRef.current = {
links: filters.links,
coverage: filters.coverage,
clientNodes: filters.clientNodes,
};
setFilters((current) => ({
...current,
links: false,
coverage: false,
clientNodes: false,
}));
} else if (wasHexClashes && !isHexClashes && clashRestoreRef.current) {
const restore = clashRestoreRef.current;
clashRestoreRef.current = null;
setFilters((current) => ({
...current,
links: restore.links,
coverage: restore.coverage,
clientNodes: restore.clientNodes,
}));
}
prevHexClashesRef.current = isHexClashes;
}, [filters.hexClashes, filters.links, filters.coverage, filters.clientNodes]);
useEffect(() => {
const postError = (kind: string, message: string, stack?: string) => {
void fetch('/api/telemetry/frontend-error', {
@@ -182,16 +219,25 @@ export const App: React.FC = () => {
showCoverage={filters.coverage}
showClientNodes={filters.clientNodes}
showLinks={filters.links}
showHexClashes={filters.hexClashes}
maxHexClashHops={filters.hexClashMaxHops}
viablePairsArr={viablePairsArr}
linkMetrics={linkMetrics}
packetPath={packetPath}
betaPath={betaPacketPath}
betaLowPath={betaLowConfidencePath}
betaCompletionPaths={betaCompletionPaths}
showBetaPaths={filters.betaPaths || pinnedPacketId !== null}
pathOpacity={pathOpacity}
onMapReady={setMap}
/>
<FilterPanel filters={filters} onChange={setFilters} betaPathConfidence={betaPathConfidence} />
<FilterPanel
filters={filters}
onChange={setFilters}
betaPathConfidence={betaPathConfidence}
betaPermutationCount={betaPermutationCount}
/>
{filters.livePackets && (
<PacketFeed
@@ -8,12 +8,15 @@ export interface Filters {
betaPaths: boolean;
betaPathThreshold: number; // 0–1
links: boolean;
hexClashes: boolean;
hexClashMaxHops: number; // 0–3 (0 = direct only)
}
interface FilterPanelProps {
filters: Filters;
onChange: (f: Filters) => void;
betaPathConfidence?: number | null;
betaPermutationCount?: number | null;
}
export const LinksLegend: React.FC<{ compact?: boolean; muted?: boolean }> = ({ compact = false, muted = false }) => (
@@ -31,30 +34,24 @@ export const FILTER_ROWS: Array<{ key: keyof Filters; label: string; color: stri
{ key: 'packetPaths', label: 'Packet Paths', color: '#00c4ff', hollow: true },
{ key: 'betaPaths', label: 'Paths (Beta)', color: '#a855f7', hollow: true },
{ key: 'links', label: 'Links (Beta)', color: '#fbbf24' },
{ key: 'hexClashes', label: 'Hex Clashes', color: '#f97316' },
{ key: 'coverage', label: 'Coverage', color: '#00e676' },
{ key: 'clientNodes', label: 'Companion / Room', color: '#ff9800' },
];
export const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, betaPathConfidence }) => {
export const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, betaPathConfidence, betaPermutationCount }) => {
const toggle = (key: keyof Filters) => {
onChange({ ...filters, [key]: !filters[key] });
};
const confidenceLabel = betaPathConfidence == null
? 'N/A'
: betaPathConfidence >= 0.75
? 'High'
: betaPathConfidence >= 0.5
? 'Medium'
: 'Low';
return (
<div className="filter-panel">
<div className="filter-panel__title">Layers</div>
{filters.betaPaths && (
<div className="filter-beta-note">
Beta Confidence: <strong>{confidenceLabel}</strong>
{betaPathConfidence != null && ` (${Math.round(betaPathConfidence * 100)}%)`}
Beta Confidence: <strong>{betaPathConfidence == null ? 'N/A' : `${Math.round(betaPathConfidence * 100)}%`}</strong>
<br />
Permutations: <strong>{betaPermutationCount == null ? 'N/A' : betaPermutationCount}</strong>
</div>
)}
{FILTER_ROWS.map(({ key, label, color, hollow }) => (
@@ -99,6 +96,22 @@ export const FilterPanel: React.FC<FilterPanelProps> = ({ filters, onChange, bet
/>
</div>
)}
{key === 'hexClashes' && filters.hexClashes && (
<div className="filter-slider" onClick={(e) => e.stopPropagation()}>
<span className="filter-slider__label">
Hex clash hops: {Math.round(filters.hexClashMaxHops)}
</span>
<input
className="filter-slider__input"
type="range"
min={0}
max={3}
step={1}
value={Math.round(filters.hexClashMaxHops)}
onChange={(e) => onChange({ ...filters, hexClashMaxHops: Number(e.target.value) })}
/>
</div>
)}
</React.Fragment>
))}
<LinksLegend muted={!filters.links} />
+307 -30
View File
@@ -88,10 +88,14 @@ interface MapViewProps {
showCoverage: boolean;
showClientNodes: boolean;
showLinks: boolean;
showHexClashes: boolean;
maxHexClashHops: number;
viablePairsArr: [string, string][];
linkMetrics: Map<string, LinkMetrics>;
packetPath: [number, number][] | null;
betaPath: [number, number][] | null;
betaLowPath: [number, number][] | null;
betaCompletionPaths: [number, number][][];
showBetaPaths: boolean;
pathOpacity: number;
onMapReady?: (m: LeafletMap) => void;
@@ -103,9 +107,28 @@ const DEFAULT_ZOOM = 11;
export const MapView: React.FC<MapViewProps> = ({
nodes, arcs, activeNodes, coverage, showPackets, showCoverage, showClientNodes,
showLinks, viablePairsArr, linkMetrics, packetPath, betaPath, showBetaPaths, pathOpacity, onMapReady,
showLinks, showHexClashes, maxHexClashHops, viablePairsArr, linkMetrics, packetPath, betaPath, betaLowPath, betaCompletionPaths, showBetaPaths, pathOpacity, onMapReady,
}) => {
const [map, setMap] = useState<LeafletMap | null>(null);
const [focusedPrefix, setFocusedPrefix] = useState<string | null>(null);
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(null);
const [focusedPrefixNodeIds, setFocusedPrefixNodeIds] = useState<Set<string> | null>(null);
const [focusHidePhase, setFocusHidePhase] = useState<'idle' | 'hide' | 'fade'>('idle');
const hideTimerRef = useRef<number | null>(null);
const fadeTimerRef = useRef<number | null>(null);
const clearFocusTimers = useCallback(() => {
if (hideTimerRef.current !== null) {
window.clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
if (fadeTimerRef.current !== null) {
window.clearTimeout(fadeTimerRef.current);
fadeTimerRef.current = null;
}
}, []);
useEffect(() => () => clearFocusTimers(), [clearFocusTimers]);
useEffect(() => {
if (map && onMapReady) onMapReady(map);
@@ -137,7 +160,6 @@ export const MapView: React.FC<MapViewProps> = ({
// Refs to Leaflet Polyline instances for direct SVG attribute animation
const regularPathRef = useRef<LeafletPolyline | null>(null);
const betaPathRef = useRef<LeafletPolyline | null>(null);
const aniFrameRef = useRef<number | null>(null);
// Animate marching dashes by incrementing stroke-dashoffset directly on the
@@ -145,10 +167,9 @@ export const MapView: React.FC<MapViewProps> = ({
// calls _updateStyle (setAttribute) on every prop change, which can interrupt
// CSS keyframe animations. Direct DOM manipulation in an rAF loop is stable.
const hasRegular = !!packetPath;
const hasBeta = !!(showBetaPaths && betaPath);
useEffect(() => {
if (!hasRegular && !hasBeta) {
if (!hasRegular) {
if (aniFrameRef.current !== null) {
cancelAnimationFrame(aniFrameRef.current);
aniFrameRef.current = null;
@@ -163,10 +184,7 @@ export const MapView: React.FC<MapViewProps> = ({
const val = String(-offset);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const rp = (regularPathRef.current as any)?._path as SVGPathElement | null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bp = (betaPathRef.current as any)?._path as SVGPathElement | null;
if (hasRegular && rp) rp.setAttribute('stroke-dashoffset', val);
if (hasBeta && bp) bp.setAttribute('stroke-dashoffset', val);
aniFrameRef.current = requestAnimationFrame(tick);
};
@@ -177,7 +195,7 @@ export const MapView: React.FC<MapViewProps> = ({
aniFrameRef.current = null;
}
};
}, [hasRegular, hasBeta]); // eslint-disable-line react-hooks/exhaustive-deps
}, [hasRegular]); // eslint-disable-line react-hooks/exhaustive-deps
const FOURTEEN_DAYS_MS = 14 * 24 * 60 * 60 * 1000;
const allNodesWithPos = useMemo(() => Array.from(nodes.values()).filter(
@@ -187,6 +205,44 @@ export const MapView: React.FC<MapViewProps> = ({
), [nodes]); // eslint-disable-line react-hooks/exhaustive-deps
const nodesWithPos = useMemo(() => allNodesWithPos.filter((n) => n.role === undefined || n.role === 2), [allNodesWithPos]);
const clientNodesArr = useMemo(() => allNodesWithPos.filter((n) => n.role === 1 || n.role === 3), [allNodesWithPos]);
const repeaterPrefixIds = useMemo(() => {
const prefixMap = new Map<string, string[]>();
for (const n of nodesWithPos) {
const prefix = n.node_id.slice(0, 2).toUpperCase();
const existing = prefixMap.get(prefix);
if (existing) existing.push(n.node_id);
else prefixMap.set(prefix, [n.node_id]);
}
return prefixMap;
}, [nodesWithPos]);
const handleToggleSamePrefix = useCallback((nodeId: string, enabled: boolean) => {
if (!enabled) {
clearFocusTimers();
setFocusedPrefix(null);
setFocusedNodeId(null);
setFocusedPrefixNodeIds(null);
setFocusHidePhase('idle');
return;
}
const prefix = nodeId.slice(0, 2).toUpperCase();
const ids = repeaterPrefixIds.get(prefix) ?? [nodeId];
const idSet = new Set(ids);
clearFocusTimers();
setFocusedPrefix(prefix);
setFocusedNodeId(nodeId);
setFocusedPrefixNodeIds(idSet);
setFocusHidePhase('hide');
hideTimerRef.current = window.setTimeout(() => {
setFocusHidePhase('fade');
fadeTimerRef.current = window.setTimeout(() => {
setFocusedPrefix(null);
setFocusedNodeId(null);
setFocusedPrefixNodeIds(null);
setFocusHidePhase('idle');
}, 1200);
}, 10_000);
}, [clearFocusTimers, repeaterPrefixIds]);
const coverageRings = useCoverageDisplayRings(coverage);
const coverageByNodeId = useMemo(() => {
@@ -195,6 +251,34 @@ export const MapView: React.FC<MapViewProps> = ({
return m;
}, [coverage]);
const distKm = useCallback((a: MeshNode, b: MeshNode) => {
if (!hasCoords(a) || !hasCoords(b)) return Number.POSITIVE_INFINITY;
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 nodeRangeKm = useCallback((nodeId: string) => {
const cov = coverageByNodeId.get(nodeId);
if (!cov?.radius_m) return 50;
return Math.min(80, Math.max(50, cov.radius_m / 1000));
}, [coverageByNodeId]);
const pairInReceiveRange = useCallback((a: MeshNode, b: MeshNode) => {
const d = distKm(a, b);
const range = Math.max(nodeRangeKm(a.node_id), nodeRangeKm(b.node_id));
return d <= range;
}, [distKm, nodeRangeKm]);
const clashLinePositions = useCallback((a: MeshNode, b: MeshNode): [number, number][] => {
const d = distKm(a, b);
if (d > 0.02) return [[a.lat!, a.lon!], [b.lat!, b.lon!]];
const off = 0.0018;
return [[a.lat!, a.lon!], [b.lat! + off, b.lon! + off]];
}, [distKm]);
const linkKey = (a: string, b: string) => (a < b ? `${a}:${b}` : `${b}:${a}`);
const linkColor = (pathLossDb: number | null | undefined) => {
@@ -204,6 +288,136 @@ export const MapView: React.FC<MapViewProps> = ({
return '#ef4444';
};
const clashAdjacency = useMemo(() => {
const adj = new Map<string, Set<string>>();
for (const [aId, bId] of viablePairsArr) {
const a = nodes.get(aId);
const b = nodes.get(bId);
if (!hasCoords(a) || !hasCoords(b)) continue;
const key = linkKey(aId, bId);
// Require computed dB (weak or above) for every edge used in clash-hop routing.
const pathLoss = linkMetrics.get(key)?.itm_path_loss_db;
if (pathLoss == null) continue;
if (!pairInReceiveRange(a, b)) continue;
if (!adj.has(aId)) adj.set(aId, new Set());
if (!adj.has(bId)) adj.set(bId, new Set());
adj.get(aId)!.add(bId);
adj.get(bId)!.add(aId);
}
return adj;
}, [viablePairsArr, linkMetrics, nodes, pairInReceiveRange]);
const shortestPathWithinRelayHops = useCallback((fromId: string, toId: string, maxRelayHops: number) => {
if (fromId === toId) return [fromId];
const maxEdges = Math.max(1, Math.floor(maxRelayHops) + 1);
const visited = new Set<string>([fromId]);
const prev = new Map<string, string>();
const queue: Array<{ id: string; edges: number }> = [{ id: fromId, edges: 0 }];
while (queue.length > 0) {
const cur = queue.shift()!;
if (cur.edges >= maxEdges) continue;
for (const next of (clashAdjacency.get(cur.id) ?? [])) {
if (visited.has(next)) continue;
visited.add(next);
prev.set(next, cur.id);
const nextEdges = cur.edges + 1;
if (next === toId) {
const path = [toId];
let p = toId;
while (prev.has(p)) {
p = prev.get(p)!;
path.unshift(p);
}
return path;
}
queue.push({ id: next, edges: nextEdges });
}
}
return null;
}, [clashAdjacency]);
type ClashPath = { key: string; nodeIds: string[]; offenderA: string; offenderB: string };
const clashPaths = useMemo(() => {
const paths: ClashPath[] = [];
for (const [, ids] of repeaterPrefixIds) {
if (ids.length < 2) continue;
for (let i = 0; i < ids.length - 1; i++) {
for (let j = i + 1; j < ids.length; j++) {
const fromId = ids[i]!;
const toId = ids[j]!;
const path = shortestPathWithinRelayHops(fromId, toId, maxHexClashHops);
if (!path || path.length < 2) continue;
paths.push({
key: `clash-${fromId.slice(0, 8)}-${toId.slice(0, 8)}-${path.length}`,
nodeIds: path,
offenderA: fromId,
offenderB: toId,
});
}
}
}
return paths;
}, [repeaterPrefixIds, shortestPathWithinRelayHops, maxHexClashHops]);
const focusedClashPaths = useMemo(() => {
if (!focusedNodeId || !focusedPrefixNodeIds || focusedPrefixNodeIds.size < 2) return [];
const paths: ClashPath[] = [];
for (const targetId of focusedPrefixNodeIds) {
if (targetId === focusedNodeId) continue;
const path = shortestPathWithinRelayHops(focusedNodeId, targetId, maxHexClashHops);
if (!path || path.length < 2) continue;
paths.push({
key: `focus-${focusedNodeId.slice(0, 8)}-${targetId.slice(0, 8)}-${path.length}`,
nodeIds: path,
offenderA: focusedNodeId,
offenderB: targetId,
});
}
return paths;
}, [focusedNodeId, focusedPrefixNodeIds, shortestPathWithinRelayHops, maxHexClashHops]);
const clashPathLines = useMemo(() => {
const chosen = showHexClashes ? clashPaths : focusedClashPaths;
const lines: Array<{ key: string; positions: [number, number][] }> = [];
const edgeKeys = new Set<string>();
for (const path of chosen) {
for (let i = 0; i < path.nodeIds.length - 1; i++) {
const a = nodes.get(path.nodeIds[i]!);
const b = nodes.get(path.nodeIds[i + 1]!);
if (!hasCoords(a) || !hasCoords(b)) continue;
const edgeKey = linkKey(a.node_id, b.node_id);
if (edgeKeys.has(edgeKey)) continue;
edgeKeys.add(edgeKey);
lines.push({ key: `${path.key}-${edgeKey}`, positions: clashLinePositions(a, b) });
}
}
return lines;
}, [showHexClashes, clashPaths, focusedClashPaths, nodes, clashLinePositions]);
const clashOffenderNodeIds = useMemo(() => {
const ids = new Set<string>();
const chosen = showHexClashes ? clashPaths : focusedClashPaths;
for (const path of chosen) {
ids.add(path.offenderA);
ids.add(path.offenderB);
}
return ids;
}, [showHexClashes, clashPaths, focusedClashPaths]);
const clashVisibleNodeIds = useMemo(() => {
const ids = new Set<string>();
const chosen = showHexClashes ? clashPaths : focusedClashPaths;
for (const path of chosen) {
for (const id of path.nodeIds) ids.add(id);
}
return ids;
}, [showHexClashes, clashPaths, focusedClashPaths]);
const clashModeActive = showHexClashes || !!focusedPrefixNodeIds;
const effectiveShowCoverage = showCoverage && !clashModeActive;
const effectiveShowLinks = showLinks && !clashModeActive;
// Resolve viable link pairs to lat/lon polyline positions
const linkLines = useMemo(() => {
if (!showLinks || viablePairsArr.length === 0) return [];
@@ -288,7 +502,7 @@ export const MapView: React.FC<MapViewProps> = ({
{/* Coverage — raw outer rings from each viewshed, fillRule:'nonzero'.
nonzero means overlapping CCW rings sum winding numbers (+1 each)
so all covered areas fill regardless of how many viewsheds overlap. */}
{showCoverage && coverageRings.length > 0 && (
{effectiveShowCoverage && coverageRings.length > 0 && (
<Pane name="coveragePane" style={{ zIndex: 350 }}>
<Polygon
positions={coverageRings as LatLngExpression[][]}
@@ -304,7 +518,7 @@ export const MapView: React.FC<MapViewProps> = ({
)}
{/* Confirmed link lines — ITM-viable node pairs */}
{showLinks && linkLines.length > 0 && (
{effectiveShowLinks && linkLines.length > 0 && (
<Pane name="linksPane" style={{ zIndex: 400 }}>
{linkLines.map((line) => {
const obs = Math.max(1, line.observedCount);
@@ -327,27 +541,60 @@ export const MapView: React.FC<MapViewProps> = ({
</Pane>
)}
{clashModeActive && (
<Pane name="hexClashPane" style={{ zIndex: 405 }}>
{clashPathLines.map((line) => (
<Polyline
key={line.key}
positions={line.positions}
pathOptions={{
color: '#f97316',
weight: 2.2,
opacity: 0.9,
}}
interactive={false}
/>
))}
</Pane>
)}
{/* Repeater markers — Leaflet default marker pane at zIndex 600 */}
{nodesWithPos.map((node) => (
<NodeMarker
key={node.node_id}
node={node}
isActive={activeNodes.has(node.node_id)}
nodeCoverage={coverageByNodeId.get(node.node_id)}
markerSize={markerSize}
/>
))}
{nodesWithPos.map((node) => {
if (showHexClashes && !clashVisibleNodeIds.has(node.node_id)) return null;
const isFocusVisible = clashVisibleNodeIds.has(node.node_id) || (focusedPrefixNodeIds?.has(node.node_id) ?? false);
if (focusedPrefixNodeIds && focusHidePhase === 'hide' && !isFocusVisible) return null;
return (
<NodeMarker
key={node.node_id}
node={node}
isActive={activeNodes.has(node.node_id)}
isHighlighted={!!focusedPrefix && node.node_id.slice(0, 2).toUpperCase() === focusedPrefix}
isRestoring={!!focusedPrefixNodeIds && focusHidePhase === 'fade' && !isFocusVisible}
hexClashState={clashModeActive ? (clashOffenderNodeIds.has(node.node_id) ? 'offender' : clashVisibleNodeIds.has(node.node_id) ? 'clear' : undefined) : undefined}
samePrefixRepeaterCount={repeaterPrefixIds.get(node.node_id.slice(0, 2).toUpperCase())?.length ?? 1}
samePrefixActive={!!focusedPrefix && node.node_id.slice(0, 2).toUpperCase() === focusedPrefix}
onToggleSamePrefix={handleToggleSamePrefix}
nodeCoverage={coverageByNodeId.get(node.node_id)}
markerSize={markerSize}
/>
);
})}
{/* Companion radio + room server markers (toggled via filter) */}
{showClientNodes && clientNodesArr.map((node) => (
<NodeMarker
key={node.node_id}
node={node}
isActive={activeNodes.has(node.node_id)}
nodeCoverage={coverageByNodeId.get(node.node_id)}
markerSize={markerSize}
/>
))}
{showClientNodes && !showHexClashes && clientNodesArr.map((node) => {
const isFocusVisible = clashVisibleNodeIds.has(node.node_id);
if (focusedPrefixNodeIds && focusHidePhase === 'hide' && !isFocusVisible) return null;
return (
<NodeMarker
key={node.node_id}
node={node}
isActive={activeNodes.has(node.node_id)}
isRestoring={!!focusedPrefixNodeIds && focusHidePhase === 'fade' && !isFocusVisible}
nodeCoverage={coverageByNodeId.get(node.node_id)}
markerSize={markerSize}
/>
);
})}
{/* Live packet path — marching dashes from source → observer */}
{packetPath && (
@@ -366,16 +613,46 @@ export const MapView: React.FC<MapViewProps> = ({
{/* Beta path — coverage-validated, unambiguous hop resolution */}
{showBetaPaths && betaPath && (
<Polyline
ref={betaPathRef}
positions={betaPath}
pathOptions={{
color: '#a855f7',
weight: 2,
weight: 2.2,
dashArray: '6 9',
opacity: pathOpacity,
}}
/>
)}
{showBetaPaths && betaLowPath && (
<Polyline
positions={betaLowPath}
pathOptions={{
color: '#ef4444',
weight: 2.4,
dashArray: '6 9',
opacity: Math.min(0.9, pathOpacity),
}}
interactive={false}
/>
)}
{showBetaPaths && betaCompletionPaths.length > 0 && (
<Pane name="betaCompletionsPane" style={{ zIndex: 520 }}>
{betaCompletionPaths.map((path, idx) => (
<Polyline
key={`beta-completion-${idx}`}
positions={path}
pathOptions={{
color: '#ef4444',
weight: 1.2,
dashArray: '3 9',
opacity: Math.min(0.45, pathOpacity * 0.6),
}}
interactive={false}
/>
))}
</Pane>
)}
</MapContainer>
{/* deck.gl overlay — arcs only */}
+40 -2
View File
@@ -9,6 +9,7 @@ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
const PREVIEW_TTL_MS = 20_000;
type MarkerVariant = 'repeater' | 'companion' | 'room';
type HexClashState = 'offender' | 'clear';
// Build a custom Leaflet icon from HTML
function buildIcon(
@@ -17,6 +18,8 @@ function buildIcon(
isStale: boolean,
variant: MarkerVariant,
markerSize: number,
isRestoring: boolean,
hexClashState?: HexClashState,
): L.DivIcon {
const size = Math.max(4, Math.round(markerSize));
const border = size >= 10 ? 2 : 1;
@@ -28,6 +31,9 @@ function buildIcon(
// Colour variant only shown when online and fresh
isOnline && !isStale && variant === 'companion' ? 'node-marker--companion' : '',
isOnline && !isStale && variant === 'room' ? 'node-marker--room' : '',
isRestoring ? 'node-marker--restore' : '',
hexClashState === 'offender' ? 'node-marker--hex-offender' : '',
hexClashState === 'clear' ? 'node-marker--hex-clear' : '',
].filter(Boolean).join(' ');
const html = `
<div class="${classes}" style="--marker-size:${size}px; --marker-border:${border}px;">
@@ -88,9 +94,26 @@ interface Props {
isActive: boolean;
nodeCoverage?: NodeCoverage;
markerSize?: number;
isHighlighted?: boolean;
isRestoring?: boolean;
samePrefixRepeaterCount?: number;
samePrefixActive?: boolean;
onToggleSamePrefix?: (nodeId: string, enabled: boolean) => void;
hexClashState?: HexClashState;
}
export const NodeMarker: React.FC<Props> = React.memo(({ node, isActive, nodeCoverage, markerSize = 12 }) => {
export const NodeMarker: React.FC<Props> = React.memo(({
node,
isActive,
nodeCoverage,
markerSize = 12,
isHighlighted = false,
isRestoring = false,
samePrefixRepeaterCount,
samePrefixActive = false,
onToggleSamePrefix,
hexClashState,
}) => {
const [showPreview, setShowPreview] = useState(false);
const [links, setLinks] = useState<NodeLink[] | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -119,12 +142,13 @@ export const NodeMarker: React.FC<Props> = React.memo(({ node, isActive, nodeCov
: node.is_online ? 'var(--online)' : 'var(--offline)';
const previewRings = showPreview && nodeCoverage ? coverageToRings(nodeCoverage) : [];
const showSamePrefixRow = (node.role === undefined || node.role === 2) && typeof samePrefixRepeaterCount === 'number';
return (
<>
<Marker
position={[node.lat, node.lon]}
icon={buildIcon(node.is_online, isActive, isStale, variant, markerSize)}
icon={buildIcon(node.is_online, isActive || isHighlighted, isStale, variant, markerSize, isRestoring, hexClashState)}
>
<Popup eventHandlers={{
add: () => {
@@ -173,6 +197,20 @@ export const NodeMarker: React.FC<Props> = React.memo(({ node, isActive, nodeCov
<span>{Math.round(node.elevation_m)} m ASL</span>
</div>
)}
{showSamePrefixRow && (
<div className="node-popup__row node-popup__row--inline">
<span>Same 2-hex repeaters</span>
<span className="node-popup__inline-value">
{samePrefixRepeaterCount}
<button
className={`node-popup__inline-btn${samePrefixActive ? ' node-popup__inline-btn--active' : ''}`}
onClick={() => onToggleSamePrefix?.(node.node_id, !samePrefixActive)}
>
{samePrefixActive ? 'Hide' : 'Show locations'}
</button>
</span>
</div>
)}
{nodeCoverage && (
<button
className={`node-popup__coverage-btn${showPreview ? ' node-popup__coverage-btn--active' : ''}`}
@@ -45,6 +45,22 @@ export const MobileControls: React.FC<MobileControlsProps> = ({
</div>
))}
</div>
{filters.hexClashes && (
<div className="filter-slider" style={{ marginTop: 8 }}>
<span className="filter-slider__label">
Hex clash hops: {Math.round(filters.hexClashMaxHops)}
</span>
<input
className="filter-slider__input"
type="range"
min={0}
max={3}
step={1}
value={Math.round(filters.hexClashMaxHops)}
onChange={(e) => onFiltersChange({ ...filters, hexClashMaxHops: Number(e.target.value) })}
/>
</div>
)}
<button
type="button"
className="mobile-legend-toggle"
+209 -18
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import type { AggregatedPacket, MeshNode } from './useNodes.js';
import type { NodeCoverage } from './useCoverage.js';
import { hasCoords, resolvePathWaypoints } from '../utils/pathing.js';
import { resolveBetaPath, type LinkMetrics, type PathLearningModel } from '../utils/betaPathing.js';
import { buildNearestPrefixContinuation, resolveBetaPath, type LinkMetrics, type PathLearningModel } from '../utils/betaPathing.js';
import type { Filters } from '../components/FilterPanel/FilterPanel.js';
const PATH_TTL = 5_000;
@@ -20,12 +20,120 @@ type UsePacketPathOverlayParams = {
type UsePacketPathOverlayResult = {
packetPath: [number, number][] | null;
betaPacketPath: [number, number][] | null;
betaLowConfidencePath: [number, number][] | null;
betaCompletionPaths: [number, number][][];
betaPathConfidence: number | null;
betaPermutationCount: number | null;
pathOpacity: number;
pinnedPacketId: string | null;
handlePacketPin: (packet: AggregatedPacket) => void;
};
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);
}
function buildFallbackPrefixPath(
hopHashes: string[],
src: MeshNode | null,
rx: MeshNode,
nodes: Map<string, MeshNode>,
forceIncludeSource = false,
): [number, number][] | null {
const repeaters = Array.from(nodes.values()).filter(
(n) => hasCoords(n) && (n.role === undefined || n.role === 2) && !n.name?.includes('🚫'),
);
const pickedNearRx: MeshNode[] = [];
const visited = new Set<string>([rx.node_id]);
let prev = rx;
for (const h of [...hopHashes].reverse()) {
const prefix = h.slice(0, 2).toUpperCase();
const candidates = repeaters
.filter((n) => !visited.has(n.node_id) && n.node_id.slice(0, 2).toUpperCase() === prefix)
.sort((a, b) => distKm(a, prev) - distKm(b, prev));
const chosen = candidates[0];
if (!chosen) continue;
pickedNearRx.push(chosen);
visited.add(chosen.node_id);
prev = chosen;
}
const hopsFarToNear = [...pickedNearRx].reverse();
const pathNodes: MeshNode[] = [...(hasCoords(src) && forceIncludeSource ? [src] : []), ...hopsFarToNear, rx];
// Avoid +1 visual hop inflation in low-confidence fallback mode.
if (!forceIncludeSource && hasCoords(src) && pathNodes.length >= 2 && pathNodes[0]?.node_id === src.node_id) {
pathNodes.shift();
}
if (pathNodes.length < 2) return null;
return pathNodes.map((n) => [n.lat!, n.lon!]);
}
function splitResolvedAndAlternatives(
result: ReturnType<typeof resolveBetaPath>,
hopHashes: string[],
srcNodeId: string | undefined,
forceIncludeSource: boolean,
threshold: number,
nodes: Map<string, MeshNode>,
): { resolvedPath: [number, number][] | null; lowPath: [number, number][] | null; completionPaths: [number, number][][] } {
if (!result) return { resolvedPath: null, lowPath: null, completionPaths: [] };
const seg = result.segmentConfidence;
const firstLow = seg.findIndex((v) => v < threshold);
if (firstLow < 0) return { resolvedPath: result.path, lowPath: null, completionPaths: [] };
// Receiver-side confident suffix (contiguous from rx backwards).
let suffixStartEdge = seg.length;
for (let i = seg.length - 1; i >= 0; i--) {
if (seg[i]! >= threshold) suffixStartEdge = i;
else break;
}
const purpleFromStartEdges = Math.max(0, firstLow);
const purpleFromEndEdges = suffixStartEdge < seg.length ? (seg.length - suffixStartEdge) : 0;
const preferReceiverSide = purpleFromEndEdges > purpleFromStartEdges;
if (preferReceiverSide && suffixStartEdge < seg.length) {
const resolvedCandidate = result.path.slice(suffixStartEdge);
const resolvedPath = resolvedCandidate.length >= 2 ? resolvedCandidate : null;
const lowCandidate = result.path.slice(0, suffixStartEdge + 1);
const lowPath = lowCandidate.length >= 2 ? lowCandidate : null;
return { resolvedPath, lowPath, completionPaths: [] };
}
const resolvedCandidate = result.path.slice(0, firstLow + 1);
const resolvedPath = resolvedCandidate.length >= 2 ? resolvedCandidate : null;
const lowCandidate = result.path.slice(Math.max(0, firstLow));
const lowPath = lowCandidate.length >= 2 ? lowCandidate : null;
const stepsRemaining = result.path.length - 1 - firstLow;
const startNodeId = result.nodeIds[firstLow];
const endNodeId = result.nodeIds[result.nodeIds.length - 1];
if (!startNodeId || !endNodeId || stepsRemaining <= 0) {
return { resolvedPath, lowPath, completionPaths: [] };
}
const hasSource = Boolean(srcNodeId && result.nodeIds[0] === srcNodeId);
const confidentRelayCount = Math.max(0, Math.min(
hopHashes.length,
(resolvedPath?.length ?? 0) - (hasSource ? 1 : 0),
));
const remainingPrefixes = hopHashes.slice(confidentRelayCount);
const nearestPrefixPath = buildNearestPrefixContinuation(
startNodeId,
remainingPrefixes,
endNodeId,
nodes,
{ dropStartIfNodeId: forceIncludeSource ? undefined : srcNodeId },
);
if (nearestPrefixPath && nearestPrefixPath.length >= 2) {
return { resolvedPath, lowPath: nearestPrefixPath, completionPaths: [] };
}
return { resolvedPath, lowPath, completionPaths: [] };
}
export function usePacketPathOverlay({
packets,
nodes,
@@ -37,14 +145,17 @@ export function usePacketPathOverlay({
}: UsePacketPathOverlayParams): UsePacketPathOverlayResult {
const [packetPath, setPacketPath] = useState<[number, number][] | null>(null);
const [betaPacketPath, setBetaPacketPath] = useState<[number, number][] | null>(null);
const [betaLowConfidencePath, setBetaLowConfidencePath] = useState<[number, number][] | null>(null);
const [betaCompletionPaths, setBetaCompletionPaths] = useState<[number, number][][]>([]);
const [betaPathConfidence, setBetaPathConfidence] = useState<number | null>(null);
const [betaPermutationCount, setBetaPermutationCount] = useState<number | null>(null);
const [pinnedPacketId, setPinnedPacketId] = useState<string | null>(null);
const [pathOpacity, setPathOpacity] = useState(0.75);
const pinnedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pathTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pathFadeRef = useRef<number | null>(null);
const recentPredictionsRef = useRef<Map<string, { path: [number, number][]; ts: number }>>(new Map());
const recentPredictionsRef = useRef<Map<string, { path: [number, number][]; lowPath: [number, number][] | null; completionPaths: [number, number][][]; confidence: number | null; ts: number }>>(new Map());
const stopPathTimers = useCallback(() => {
if (pathTimerRef.current) {
@@ -60,7 +171,10 @@ export function usePacketPathOverlay({
const clearPathState = useCallback(() => {
setPacketPath(null);
setBetaPacketPath(null);
setBetaLowConfidencePath(null);
setBetaCompletionPaths([]);
setBetaPathConfidence(null);
setBetaPermutationCount(null);
setPathOpacity(0.75);
}, []);
@@ -85,6 +199,7 @@ export function usePacketPathOverlay({
if (filters.betaPaths && latest?.rxNodeId && latest.path?.length && hasCoords(rx)) {
const src = latest.srcNodeId ? (nodes.get(latest.srcNodeId) ?? null) : null;
const forceIncludeSource = latest.packetType === 4; // Advert packets should stay anchored to their source repeater.
const hops = latest.hopCount != null ? latest.path.slice(0, latest.hopCount) : latest.path;
const pairKey = `${src?.node_id ?? 'unknown'}>${rx.node_id}`;
const result = resolveBetaPath(
@@ -96,24 +211,60 @@ export function usePacketPathOverlay({
linkPairs,
linkMetrics,
learningModel,
{ forceIncludeSource },
);
if (result && result.confidence >= filters.betaPathThreshold) {
recentPredictionsRef.current.set(pairKey, { path: result.path, ts: Date.now() });
setBetaPacketPath(result.path);
if (result) {
const split = splitResolvedAndAlternatives(result, hops, src?.node_id, forceIncludeSource, filters.betaPathThreshold, nodes);
recentPredictionsRef.current.set(pairKey, {
path: split.resolvedPath ?? [],
lowPath: split.lowPath,
completionPaths: split.completionPaths,
confidence: result.confidence,
ts: Date.now(),
});
setBetaPacketPath(split.resolvedPath);
setBetaLowConfidencePath(split.lowPath);
setBetaCompletionPaths(split.completionPaths);
setBetaPathConfidence(result.confidence);
setBetaPermutationCount((split.lowPath ? 1 : 0) + split.completionPaths.length);
} else {
const recent = recentPredictionsRef.current.get(pairKey);
if (recent && Date.now() - recent.ts < 45_000) {
setBetaPacketPath(recent.path);
setBetaPathConfidence(null);
} else {
const fallback = buildFallbackPrefixPath(hops, hasCoords(src) ? src : null, rx, nodes, forceIncludeSource);
if (fallback) {
setBetaPacketPath(null);
setBetaLowConfidencePath(fallback);
setBetaCompletionPaths([]);
setBetaPathConfidence(null);
setBetaPermutationCount(1);
recentPredictionsRef.current.set(pairKey, {
path: [],
lowPath: fallback,
completionPaths: [],
confidence: null,
ts: Date.now(),
});
} else {
const recent = recentPredictionsRef.current.get(pairKey);
if (recent && Date.now() - recent.ts < 45_000) {
setBetaPacketPath(recent.path.length > 1 ? recent.path : null);
setBetaLowConfidencePath(recent.lowPath);
setBetaCompletionPaths(recent.completionPaths);
setBetaPathConfidence(recent.confidence);
setBetaPermutationCount((recent.lowPath ? 1 : 0) + recent.completionPaths.length);
} else {
setBetaPacketPath(null);
setBetaLowConfidencePath(null);
setBetaCompletionPaths([]);
setBetaPathConfidence(null);
setBetaPermutationCount(null);
}
}
}
} else {
setBetaPacketPath(null);
setBetaLowConfidencePath(null);
setBetaCompletionPaths([]);
setBetaPathConfidence(null);
setBetaPermutationCount(null);
}
if (!filters.packetPaths && !filters.betaPaths) { setPathOpacity(0.75); return; }
@@ -162,6 +313,7 @@ export function usePacketPathOverlay({
if (packet.rxNodeId && packet.path?.length && hasCoords(rx)) {
const src = packet.srcNodeId ? (nodes.get(packet.srcNodeId) ?? null) : null;
const forceIncludeSource = packet.packetType === 4; // Advert packets should stay anchored to their source repeater.
const hops = packet.hopCount != null ? packet.path.slice(0, packet.hopCount) : packet.path;
const pairKey = `${src?.node_id ?? 'unknown'}>${rx.node_id}`;
const result = resolveBetaPath(
@@ -173,24 +325,60 @@ export function usePacketPathOverlay({
linkPairs,
linkMetrics,
learningModel,
{ forceIncludeSource },
);
if (result && result.confidence >= filters.betaPathThreshold) {
recentPredictionsRef.current.set(pairKey, { path: result.path, ts: Date.now() });
setBetaPacketPath(result.path);
if (result) {
const split = splitResolvedAndAlternatives(result, hops, src?.node_id, forceIncludeSource, filters.betaPathThreshold, nodes);
recentPredictionsRef.current.set(pairKey, {
path: split.resolvedPath ?? [],
lowPath: split.lowPath,
completionPaths: split.completionPaths,
confidence: result.confidence,
ts: Date.now(),
});
setBetaPacketPath(split.resolvedPath);
setBetaLowConfidencePath(split.lowPath);
setBetaCompletionPaths(split.completionPaths);
setBetaPathConfidence(result.confidence);
setBetaPermutationCount((split.lowPath ? 1 : 0) + split.completionPaths.length);
} else {
const recent = recentPredictionsRef.current.get(pairKey);
if (recent && Date.now() - recent.ts < 45_000) {
setBetaPacketPath(recent.path);
setBetaPathConfidence(null);
} else {
const fallback = buildFallbackPrefixPath(hops, hasCoords(src) ? src : null, rx, nodes, forceIncludeSource);
if (fallback) {
setBetaPacketPath(null);
setBetaLowConfidencePath(fallback);
setBetaCompletionPaths([]);
setBetaPathConfidence(null);
setBetaPermutationCount(1);
recentPredictionsRef.current.set(pairKey, {
path: [],
lowPath: fallback,
completionPaths: [],
confidence: null,
ts: Date.now(),
});
} else {
const recent = recentPredictionsRef.current.get(pairKey);
if (recent && Date.now() - recent.ts < 45_000) {
setBetaPacketPath(recent.path.length > 1 ? recent.path : null);
setBetaLowConfidencePath(recent.lowPath);
setBetaCompletionPaths(recent.completionPaths);
setBetaPathConfidence(recent.confidence);
setBetaPermutationCount((recent.lowPath ? 1 : 0) + recent.completionPaths.length);
} else {
setBetaPacketPath(null);
setBetaLowConfidencePath(null);
setBetaCompletionPaths([]);
setBetaPathConfidence(null);
setBetaPermutationCount(null);
}
}
}
} else {
setBetaPacketPath(null);
setBetaLowConfidencePath(null);
setBetaCompletionPaths([]);
setBetaPathConfidence(null);
setBetaPermutationCount(null);
}
setPathOpacity(0.75);
@@ -224,7 +412,10 @@ export function usePacketPathOverlay({
return {
packetPath,
betaPacketPath,
betaLowConfidencePath,
betaCompletionPaths,
betaPathConfidence,
betaPermutationCount,
pathOpacity,
pinnedPacketId,
handlePacketPin,
+55
View File
@@ -408,6 +408,30 @@ html, body, #root {
}
.node-marker--room .node-marker__pulse { background: var(--room-server); }
.node-marker--restore .node-marker__core,
.node-marker--restore .node-marker__pulse {
animation: node-restore-fade 1.2s ease;
}
.node-marker--hex-offender .node-marker__core {
background: #ef4444;
border-color: rgba(239, 68, 68, 0.85);
box-shadow: 0 0 10px rgba(239, 68, 68, 0.45);
}
.node-marker--hex-offender .node-marker__pulse { background: #ef4444; }
.node-marker--hex-clear .node-marker__core {
background: #22c55e;
border-color: rgba(34, 197, 94, 0.85);
box-shadow: 0 0 8px rgba(34, 197, 94, 0.4);
}
.node-marker--hex-clear .node-marker__pulse { background: #22c55e; }
@keyframes node-restore-fade {
0% { opacity: 0; }
100% { opacity: 1; }
}
/* ─── Node Popup ─────────────────────────────────────────────────────────── */
.leaflet-popup-content-wrapper {
background: var(--bg-panel) !important;
@@ -454,6 +478,37 @@ html, body, #root {
font-family: var(--font-mono);
}
.node-popup__row--inline {
align-items: center;
}
.node-popup__inline-value {
display: inline-flex;
align-items: center;
gap: 8px;
}
.node-popup__inline-btn {
border: 1px solid rgba(0, 196, 255, 0.5);
background: transparent;
color: var(--accent);
border-radius: 4px;
padding: 2px 6px;
font-size: 10px;
font-family: var(--font-mono);
letter-spacing: 0.04em;
cursor: pointer;
}
.node-popup__inline-btn:hover {
background: rgba(0, 196, 255, 0.14);
}
.node-popup__inline-btn--active {
border-color: rgba(0, 196, 255, 0.9);
background: rgba(0, 196, 255, 0.16);
}
.node-popup__coverage-btn {
display: block;
width: 100%;
+11 -1
View File
@@ -27,6 +27,7 @@ export function confirmedLinkConfidence(
transition: number;
motif: number;
edge: number;
ambiguity: number;
},
): number {
const observed = meta?.observed_count ?? MIN_LINK_OBSERVATIONS;
@@ -35,6 +36,15 @@ export function confirmedLinkConfidence(
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.66 + obsBoost + dirBoost + viableBoost - plPenalty + boosts.prefix + boosts.transition + boosts.motif + boosts.edge;
const conf = 0.66
+ obsBoost
+ dirBoost
+ viableBoost
- plPenalty
+ boosts.prefix
+ boosts.transition
+ boosts.motif
+ boosts.edge
+ boosts.ambiguity;
return clamp(conf, 0.45, 0.98);
}
+373 -20
View File
@@ -4,8 +4,10 @@ import { hasCoords, linkKey, type LinkMetrics } from './pathing.js';
import { confirmedLinkConfidence, directionalSupport, minimumDirectionalSupport } from './betaLinks.js';
export type { LinkMetrics } from './pathing.js';
const MAX_BETA_HOPS = 15;
const MAX_BETA_HOPS = 25;
const R_EFF_M = 6_371_000 / (1 - 0.25);
const PREFIX_AMBIGUITY_FLOOR_KM = 45;
const WEAK_LINK_PATHLOSS_MAX_DB = 135;
export type PathLearningModel = {
prefixProbabilities: Map<string, number>;
@@ -68,6 +70,45 @@ function motifKey(receiverRegion: string, bucket: number, nodeIds: string[]): st
return `${receiverRegion}|${bucket}|${nodeIds.length}|${nodeIds.join('>')}`;
}
function buildClashAdjacency(
candidates: MeshNode[],
linkPairs: Set<string>,
linkMetrics: Map<string, LinkMetrics>,
): Map<string, Set<string>> {
const byId = new Map<string, MeshNode>();
for (const node of candidates) byId.set(node.node_id, node);
const adjacency = new Map<string, Set<string>>();
for (const key of linkPairs) {
const [aId, bId] = key.split(':');
if (!aId || !bId) continue;
if (!byId.has(aId) || !byId.has(bId)) continue;
const meta = linkMetrics.get(key);
const pathLoss = meta?.itm_path_loss_db;
if (pathLoss == null || pathLoss > WEAK_LINK_PATHLOSS_MAX_DB) continue;
if (!adjacency.has(aId)) adjacency.set(aId, new Set());
if (!adjacency.has(bId)) adjacency.set(bId, new Set());
adjacency.get(aId)!.add(bId);
adjacency.get(bId)!.add(aId);
}
return adjacency;
}
function isWeakOrBetter(meta: LinkMetrics | undefined): boolean {
const pathLoss = meta?.itm_path_loss_db;
return pathLoss != null && pathLoss <= WEAK_LINK_PATHLOSS_MAX_DB;
}
function strongConfirmedFloor(meta: LinkMetrics | undefined): number {
if (!meta) return 0;
const observed = meta.observed_count ?? 0;
const pathLoss = meta.itm_path_loss_db;
if (observed >= 120 && pathLoss != null && pathLoss <= 125) return 0.82;
if (observed >= 70 && pathLoss != null && pathLoss <= 130) return 0.76;
if (observed >= 35 && pathLoss != null && pathLoss <= 133) return 0.70;
return 0;
}
export function resolveBetaPath(
pathHashes: string[],
src: MeshNode | null,
@@ -77,7 +118,8 @@ export function resolveBetaPath(
linkPairs: Set<string>,
linkMetrics: Map<string, LinkMetrics>,
learningModel?: PathLearningModel | null,
): { path: [number, number][]; confidence: number } | null {
options?: { forceIncludeSource?: boolean },
): { path: [number, number][]; confidence: number; segmentConfidence: number[]; nodeIds: string[] } | null {
if (!hasCoords(rx) || pathHashes.length === 0) return null;
if (pathHashes.length >= MAX_BETA_HOPS) return null;
const rxLat = rx.lat;
@@ -88,13 +130,17 @@ export function resolveBetaPath(
(n) => hasCoords(n) && (n.role === undefined || n.role === 2) && !n.name?.includes('🚫'),
);
const prefixCounts = new Map<string, number>();
const prefixBuckets = new Map<string, MeshNode[]>();
for (const n of candidatesPool) {
const p = n.node_id.slice(0, 2).toUpperCase();
prefixCounts.set(p, (prefixCounts.get(p) ?? 0) + 1);
const existing = prefixBuckets.get(p);
if (existing) existing.push(n);
else prefixBuckets.set(p, [n]);
}
const totalDist = hasCoords(src) ? distKm(src, rx) : 0;
const corridorMaxKm = Math.max(8, Math.min(35, totalDist * 0.25));
const corridorMaxKm = Math.max(10, Math.min(45, totalDist * 0.33));
const receiverRegion = rx.iata ?? 'unknown';
const bucketHours = learningModel?.bucketHours ?? 6;
const hourBucket = currentHourBucket(bucketHours);
@@ -156,6 +202,75 @@ export function resolveBetaPath(
return 0.65 * distScore + 0.35 * elevScore;
}
const clashAdjacency = buildClashAdjacency(candidatesPool, linkPairs, linkMetrics);
const hopCache = new Map<string, 1 | 2 | null>();
function twoHopDistance(aId: string, bId: string): 1 | 2 | null {
if (aId === bId) return null;
const key = aId < bId ? `${aId}:${bId}` : `${bId}:${aId}`;
const cached = hopCache.get(key);
if (cached !== undefined) return cached;
const neighbors = clashAdjacency.get(aId);
if (!neighbors || neighbors.size === 0) {
hopCache.set(key, null);
return null;
}
if (neighbors.has(bId)) {
hopCache.set(key, 1);
return 1;
}
for (const mid of neighbors) {
if (clashAdjacency.get(mid)?.has(bId)) {
hopCache.set(key, 2);
return 2;
}
}
hopCache.set(key, null);
return null;
}
function localPrefixAmbiguityPenalty(candidate: MeshNode, prevNode: MeshNode): number {
const prefix = candidate.node_id.slice(0, 2).toUpperCase();
const peers = prefixBuckets.get(prefix) ?? [];
if (peers.length <= 1) return 0;
const inRangeKm = Math.max(PREFIX_AMBIGUITY_FLOOR_KM, nodeRange(candidate.node_id, coverage), nodeRange(prevNode.node_id, coverage));
const candidateDist = distKm(candidate, prevNode);
let localRaw = 0;
let hopRaw = 0;
for (const peer of peers) {
if (peer.node_id === candidate.node_id) continue;
const peerDist = distKm(peer, prevNode);
if (peerDist > inRangeKm) continue;
const distanceSimilarity = clamp(1 - Math.abs(peerDist - candidateDist) / inRangeKm, 0, 1);
const proximity = clamp(1 - peerDist / inRangeKm, 0, 1);
localRaw += distanceSimilarity * proximity;
// Beta clash weighting:
// 1-hop same-prefix neighbors apply full penalty, 2-hop apply half penalty.
const hopDistance = twoHopDistance(candidate.node_id, peer.node_id);
if (hopDistance === 1) hopRaw += 1.0 * distanceSimilarity;
else if (hopDistance === 2) hopRaw += 0.5 * distanceSimilarity;
}
const localPenalty = clamp(localRaw * 0.08, 0, 0.14);
const hopPenalty = clamp(hopRaw * 0.07, 0, 0.16);
return clamp(localPenalty + hopPenalty, 0, 0.30);
}
function clashPressure(candidate: MeshNode): number {
const prefix = candidate.node_id.slice(0, 2).toUpperCase();
const peers = prefixBuckets.get(prefix) ?? [];
if (peers.length <= 1) return 0;
let raw = 0;
for (const peer of peers) {
if (peer.node_id === candidate.node_id) continue;
const hops = twoHopDistance(candidate.node_id, peer.node_id);
if (hops === 1) raw += 1;
else if (hops === 2) raw += 0.5;
}
return clamp(raw / 3, 0, 1);
}
function inCorridor(candidate: MeshNode, prevNode: MeshNode): boolean {
if (!hasCoords(src)) return true;
@@ -167,7 +282,9 @@ export function resolveBetaPath(
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 pressure = clashPressure(candidate);
const tPadding = 0.15 + (1 - pressure) * 0.15;
if (t < -tPadding || t > 1 + tPadding) return false;
const projx = rxLon + t * bx;
const projy = rxLat + t * by;
@@ -176,7 +293,8 @@ export function resolveBetaPath(
const dxKm = (candidate.lon! - projx) * kmPerLon;
const dyKm = (candidate.lat! - projy) * 111;
const crossTrackKm = Math.hypot(dxKm, dyKm);
if (crossTrackKm > corridorMaxKm) return false;
const corridorAllowance = corridorMaxKm * (1 + (1 - pressure) * 0.35);
if (crossTrackKm > corridorAllowance) return false;
return distKm(candidate, src) <= distKm(prevNode, src) + 8;
}
@@ -216,7 +334,8 @@ export function resolveBetaPath(
if (!meta || meta.count_a_to_b == null || meta.count_b_to_a == null) return true;
const dir = directionalSupport(meta, c.node_id, prevNode.node_id);
const observed = meta.observed_count ?? 0;
return dir >= minimumDirectionalSupport(observed);
if (observed < 20) return true;
return dir >= minimumDirectionalSupport(observed) * 0.6;
})
.sort((a, b) => sortScore(b) - sortScore(a))
.slice(0, 4)
@@ -228,19 +347,31 @@ export function resolveBetaPath(
const motifBoost = motifPrior([c.node_id, prevNode.node_id]) * 0.2
+ (nextTowardRx ? motifPrior([c.node_id, prevNode.node_id, nextTowardRx]) * 0.25 : 0);
const edgeBoost = edgePrior(c.node_id, prevNode.node_id) * 0.3;
const ambiguityPenalty = localPrefixAmbiguityPenalty(c, prevNode);
const confirmedFloor = strongConfirmedFloor(meta);
const baseConf = confirmedLinkConfidence(meta, c.node_id, prevNode.node_id, {
prefix: priorBoost,
transition: transitionBoost,
motif: motifBoost,
edge: edgeBoost,
ambiguity: -ambiguityPenalty,
});
return {
node: c,
conf: confirmedLinkConfidence(meta, c.node_id, prevNode.node_id, {
prefix: priorBoost,
transition: transitionBoost,
motif: motifBoost,
edge: edgeBoost,
}),
conf: Math.max(baseConf, confirmedFloor),
};
});
const reachable = all
.filter((c) => !usedIds.has(c.node_id) && inCorridor(c, prevNode) && canReach(c, prevNode, coverage) && hasLoS(c, prevNode))
.filter((c) => {
if (usedIds.has(c.node_id)) return false;
if (!inCorridor(c, prevNode)) return false;
const meta = linkMetrics.get(linkKey(c.node_id, prevNode.node_id));
const reachOk = canReach(c, prevNode, coverage);
const losOk = hasLoS(c, prevNode);
// Loosen: accept if either physical model passes, or a weak-or-better learned link exists.
return (reachOk && losOk) || (reachOk && isWeakOrBetter(meta)) || (losOk && isWeakOrBetter(meta));
})
.sort((a, b) => sortScore(b) - sortScore(a))
.slice(0, 3)
.map((c) => {
@@ -252,14 +383,21 @@ export function resolveBetaPath(
const motifBoost = motifPrior([c.node_id, prevNode.node_id]) * 0.18
+ (nextTowardRx ? motifPrior([c.node_id, prevNode.node_id, nextTowardRx]) * 0.2 : 0);
const edgeBoost = edgePrior(c.node_id, prevNode.node_id) * 0.28;
const ambiguityPenalty = localPrefixAmbiguityPenalty(c, prevNode);
return {
node: c,
conf: Math.max(0.08, 0.2 + prior * 0.34 + prefixBoost + transitionBoost + motifBoost + edgeBoost - distancePenalty - (all.length - 1) * 0.01),
conf: Math.max(0.08, 0.2 + prior * 0.34 + prefixBoost + transitionBoost + motifBoost + edgeBoost - distancePenalty - ambiguityPenalty - (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))
.filter((c) => {
if (usedIds.has(c.node_id)) return false;
if (!inCorridor(c, prevNode)) return false;
if (distKm(c, prevNode) >= 65) return false;
const meta = linkMetrics.get(linkKey(c.node_id, prevNode.node_id));
return hasLoS(c, prevNode) || isWeakOrBetter(meta);
})
.sort((a, b) => sortScore(b) - sortScore(a))
.slice(0, 1)
.map((c) => {
@@ -268,21 +406,22 @@ export function resolveBetaPath(
const transitionBoost = transitionPrior(c.node_id, prevNode.node_id) * 0.16;
const motifBoost = motifPrior([c.node_id, prevNode.node_id]) * 0.12;
const edgeBoost = edgePrior(c.node_id, prevNode.node_id) * 0.18;
const ambiguityPenalty = localPrefixAmbiguityPenalty(c, prevNode);
return {
node: c,
conf: Math.max(0.03, 0.04 + prior * 0.2 + prefixBoost + transitionBoost + motifBoost + edgeBoost) / Math.max(1, all.length),
conf: Math.max(0.03, 0.04 + prior * 0.2 + prefixBoost + transitionBoost + motifBoost + edgeBoost - ambiguityPenalty) / Math.max(1, all.length),
};
});
return [...confirmed, ...reachable, ...fallback];
}
const maxSkips = Math.min(2, Math.floor(pathHashes.length / 3));
const maxSkips = Math.min(3, Math.floor(pathHashes.length / 2));
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));
let budget = Math.max(600, Math.min(12_000, 260 + pathHashes.length * 140 + ambiguity * 42));
function solve(
hopIdx: number,
@@ -328,12 +467,226 @@ export function resolveBetaPath(
const calibratedConfidence = rawConfidence * (learningModel?.confidenceScale ?? 1) + (learningModel?.confidenceBias ?? 0);
const confidence = clamp(calibratedConfidence, 0, 1);
const srcPrefix = hasCoords(src) ? src.node_id.slice(0, 2).toUpperCase() : null;
const firstHopPrefix = pathHashes[0]?.slice(0, 2).toUpperCase() ?? null;
const prependSource = Boolean(options?.forceIncludeSource
? hasCoords(src)
: (hasCoords(src) && srcPrefix && srcPrefix !== firstHopPrefix));
const pathNodes: MeshNode[] = [
...(hasCoords(src) ? [src] : []),
...(prependSource && 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 segmentConfidence: number[] = [];
const hasSource = prependSource;
for (let i = 0; i < pathNodes.length - 1; i++) {
// Use direct per-edge confidence instead of min-adjacent blending.
// This prevents low confidence later in the chain from turning early,
// high-confidence segments red.
if (hasSource && i === 0) {
segmentConfidence.push(hops[0]?.conf ?? confidence); // src -> first hop
continue;
}
const hopIdx = hasSource ? i : i;
segmentConfidence.push(hops[hopIdx]?.conf ?? hops[hops.length - 1]?.conf ?? confidence);
}
return { path: pathNodes.map((n) => [n.lat!, n.lon!]), confidence, segmentConfidence, nodeIds: pathNodes.map((n) => n.node_id) };
}
export function enumerateBetaCompletions(
startNodeId: string,
endNodeId: string,
stepsRemaining: number,
allNodes: Map<string, MeshNode>,
linkPairs: Set<string>,
linkMetrics: Map<string, LinkMetrics>,
maxPaths = 24,
): [number, number][][] {
if (stepsRemaining <= 0 || startNodeId === endNodeId || maxPaths <= 0) return [];
const candidates = Array.from(allNodes.values()).filter(
(n) => hasCoords(n) && (n.role === undefined || n.role === 2) && !n.name?.includes('🚫'),
);
const byId = new Map<string, MeshNode>();
for (const n of candidates) byId.set(n.node_id, n);
if (!byId.has(startNodeId) || !byId.has(endNodeId)) return [];
const adjacency = buildClashAdjacency(candidates, linkPairs, linkMetrics);
const endNode = byId.get(endNodeId)!;
const results: string[][] = [];
let expansions = 0;
const maxExpansions = 8_000;
const dfs = (currentId: string, depth: number, path: string[], visited: Set<string>) => {
if (results.length >= maxPaths || expansions >= maxExpansions) return;
expansions += 1;
if (depth === stepsRemaining) {
if (currentId === endNodeId) results.push([...path]);
return;
}
const neighbors = Array.from(adjacency.get(currentId) ?? [])
.filter((id) => !visited.has(id))
.sort((a, b) => {
const an = byId.get(a);
const bn = byId.get(b);
if (!an || !bn) return 0;
return distKm(an, endNode) - distKm(bn, endNode);
});
for (const nextId of neighbors) {
if (!byId.has(nextId)) continue;
visited.add(nextId);
path.push(nextId);
dfs(nextId, depth + 1, path, visited);
path.pop();
visited.delete(nextId);
if (results.length >= maxPaths || expansions >= maxExpansions) return;
}
};
const startVisited = new Set<string>([startNodeId]);
dfs(startNodeId, 0, [startNodeId], startVisited);
return results
.map((ids) => ids.map((id) => byId.get(id)!).filter((n): n is MeshNode => Boolean(n)))
.filter((nodes) => nodes.length === stepsRemaining + 1)
.map((nodes) => nodes.map((n) => [n.lat!, n.lon!]));
}
export function buildNearestPrefixContinuation(
startNodeId: string,
remainingPrefixes: string[],
endNodeId: string,
allNodes: Map<string, MeshNode>,
options?: { dropStartIfNodeId?: string },
): [number, number][] | null {
const candidates = Array.from(allNodes.values()).filter(
(n) => hasCoords(n) && (n.role === undefined || n.role === 2) && !n.name?.includes('🚫'),
);
const byId = new Map<string, MeshNode>();
for (const n of candidates) byId.set(n.node_id, n);
const start = byId.get(startNodeId);
const end = byId.get(endNodeId);
if (!start || !end) return null;
const pathNodes: MeshNode[] = [start];
const visited = new Set<string>([start.node_id]);
let current = start;
for (let idx = 0; idx < remainingPrefixes.length; idx++) {
const rawPrefix = remainingPrefixes[idx]!;
const isLastPrefix = idx === remainingPrefixes.length - 1;
const prefix = rawPrefix.slice(0, 2).toUpperCase();
const matches = candidates
.filter((n) => {
if (visited.has(n.node_id)) return false;
if (n.node_id.slice(0, 2).toUpperCase() !== prefix) return false;
// Hard rule: do not consume RX as an intermediate node.
if (n.node_id === end.node_id && !isLastPrefix) return false;
return true;
})
.sort((a, b) => distKm(a, current) - distKm(b, current));
const chosen = matches[0];
if (!chosen) continue;
pathNodes.push(chosen);
visited.add(chosen.node_id);
current = chosen;
}
if (pathNodes[pathNodes.length - 1]?.node_id !== end.node_id) {
// Hard rule: never repeat a node in the same rendered path.
if (visited.has(end.node_id)) return null;
pathNodes.push(end);
visited.add(end.node_id);
}
if (new Set(pathNodes.map((n) => n.node_id)).size !== pathNodes.length) return null;
const renderNodes = (options?.dropStartIfNodeId && pathNodes[0]?.node_id === options.dropStartIfNodeId)
? pathNodes.slice(1)
: pathNodes;
if (renderNodes.length < 2) return null;
return renderNodes.map((n) => [n.lat!, n.lon!]);
}
export function enumeratePrefixContinuations(
startNodeId: string,
remainingPrefixes: string[],
endNodeId: string,
allNodes: Map<string, MeshNode>,
options?: { dropStartIfNodeId?: string; maxRenderPaths?: number; maxSearchStates?: number },
): { paths: [number, number][][]; totalCount: number; truncated: boolean } {
const maxRenderPaths = Math.max(1, options?.maxRenderPaths ?? 320);
const maxSearchStates = Math.max(1000, options?.maxSearchStates ?? 120_000);
const candidates = Array.from(allNodes.values()).filter(
(n) => hasCoords(n) && (n.role === undefined || n.role === 2) && !n.name?.includes('🚫'),
);
const byId = new Map<string, MeshNode>();
const byPrefix = new Map<string, MeshNode[]>();
for (const n of candidates) {
byId.set(n.node_id, n);
const p = n.node_id.slice(0, 2).toUpperCase();
const arr = byPrefix.get(p);
if (arr) arr.push(n);
else byPrefix.set(p, [n]);
}
const start = byId.get(startNodeId);
const end = byId.get(endNodeId);
if (!start || !end) return { paths: [], totalCount: 0, truncated: false };
const discovered: string[][] = [];
let totalCount = 0;
let states = 0;
let truncated = false;
const dfs = (idx: number, current: MeshNode, path: string[], visited: Set<string>) => {
if (states++ >= maxSearchStates) {
truncated = true;
return;
}
if (idx >= remainingPrefixes.length) {
if (current.node_id !== end.node_id) {
if (visited.has(end.node_id)) return;
path.push(end.node_id);
}
totalCount += 1;
if (discovered.length < maxRenderPaths) discovered.push([...path]);
if (current.node_id !== end.node_id) path.pop();
return;
}
const prefix = remainingPrefixes[idx]!.slice(0, 2).toUpperCase();
const nodesForPrefix = (byPrefix.get(prefix) ?? [])
.filter((n) => !visited.has(n.node_id) && n.node_id !== end.node_id)
.sort((a, b) => distKm(a, current) - distKm(b, current));
for (const next of nodesForPrefix) {
visited.add(next.node_id);
path.push(next.node_id);
dfs(idx + 1, next, path, visited);
path.pop();
visited.delete(next.node_id);
if (states >= maxSearchStates) {
truncated = true;
return;
}
}
};
const visited = new Set<string>([start.node_id]);
dfs(0, start, [start.node_id], visited);
const paths = discovered
.map((ids) => {
const renderIds = (options?.dropStartIfNodeId && ids[0] === options.dropStartIfNodeId) ? ids.slice(1) : ids;
const nodes = renderIds.map((id) => byId.get(id)).filter((n): n is MeshNode => Boolean(n));
if (new Set(nodes.map((n) => n.node_id)).size !== nodes.length) return null;
if (nodes.length < 2) return null;
return nodes.map((n) => [n.lat!, n.lon!]) as [number, number][];
})
.filter((p): p is [number, number][] => Array.isArray(p));
return { paths, totalCount, truncated };
}
+34 -8
View File
@@ -47,6 +47,7 @@ LIVE_CHANNEL = 'meshcore:live'
ANTENNA_HEIGHT_M = 5 # observer height above ground (m) — fixed 5 m antenna
MIN_LINK_OBSERVATIONS = 5 # must match backend db/index.ts
PREFIX_AMBIGUITY_RADIUS_KM = 45.0 # only penalize same-prefix ambiguity when nodes are realistically in range
MAX_RADIUS_M = 100_000 # absolute cap on viewshed radius (m)
SIMPLIFY_DEG = 0.001 # Douglas-Peucker tolerance (~100 m)
N_RAYS = 720 # number of radial rays cast from the observer
@@ -521,6 +522,23 @@ def process_link_job(db, r_client, job: dict):
((a['lon'] - b['lon']) * 111.32 * cos_m) ** 2
)
def local_prefix_ambiguity_penalty(prefix: str, target_id: str, target_node: dict, anchor_node: dict, pool: list[tuple[str, dict]]) -> float:
target_dist = node_dist(target_node, anchor_node)
raw = 0.0
for cand_id, cand_node in pool:
if cand_id == target_id:
continue
if not cand_id.upper().startswith(prefix):
continue
cand_dist = node_dist(cand_node, anchor_node)
if cand_dist > PREFIX_AMBIGUITY_RADIUS_KM:
continue
dist_similarity = max(0.0, 1.0 - abs(cand_dist - target_dist) / PREFIX_AMBIGUITY_RADIUS_KM)
proximity = max(0.0, 1.0 - cand_dist / PREFIX_AMBIGUITY_RADIUS_KM)
raw += dist_similarity * proximity
# Bound so this is only a modest confidence deduction in clustered regions.
return min(0.24, raw * 0.12)
# Resolve path working backwards from rx (known position anchor).
# Each node can only appear once — MeshCore nodes never relay the same
# packet twice.
@@ -541,14 +559,22 @@ def process_link_job(db, r_client, job: dict):
if not candidates:
continue
# Prefer confirmed neighbours of the previous node; fall back to closest.
confirmed = [(nid, nd) for nid, nd in candidates if confirmed_link(nid, prev_id)]
if confirmed:
confirmed.sort(key=lambda x: node_dist(x[1], prev))
best_id, best = confirmed[0]
else:
candidates.sort(key=lambda x: node_dist(x[1], prev))
best_id, best = candidates[0]
# Prefer confirmed neighbours of the previous node, but deduct confidence
# when same-prefix repeaters cluster near the same anchor (possible ambiguity).
best_id = None
best = None
best_score = float('-inf')
for nid, nd in candidates:
confirmed_bonus = 2.5 if confirmed_link(nid, prev_id) else 0.0
distance_score = -node_dist(nd, prev) / 12.0
ambiguity_penalty = local_prefix_ambiguity_penalty(prefix, nid, nd, prev, candidates)
score = confirmed_bonus + distance_score - ambiguity_penalty
if score > best_score:
best_score = score
best_id, best = nid, nd
if best_id is None or best is None:
continue
resolved.insert(0, (best_id, best))
visited.add(best_id)