mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-10-22 14:01:27 +00:00
Refine beta path rendering, confidence split, and adverts source anchoring
This commit is contained in:
1 parent
0d77a5027c
commit
fe56f39007
12 files changed
+1152
-98
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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} />
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user