diff --git a/backend/src/api/routes.ts b/backend/src/api/routes.ts index da16116..ab4d838 100644 --- a/backend/src/api/routes.ts +++ b/backend/src/api/routes.ts @@ -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 diff --git a/backend/src/path-learning/rebuild.ts b/backend/src/path-learning/rebuild.ts index 284275c..9354b1a 100644 --- a/backend/src/path-learning/rebuild.ts +++ b/backend/src/path-learning/rebuild.ts @@ -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, +): 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; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9fe27c4..8e55dce 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); const [showDisclaimer, setShowDisclaimer] = useState(() => !localStorage.getItem(DISCLAIMER_KEY)); + const clashRestoreRef = useRef<{ links: boolean; coverage: boolean; clientNodes: boolean } | null>(null); + const prevHexClashesRef = useRef(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} /> - + {filters.livePackets && ( 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 = ({ filters, onChange, betaPathConfidence }) => { +export const FilterPanel: React.FC = ({ 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 (
Layers
{filters.betaPaths && (
- Beta Confidence: {confidenceLabel} - {betaPathConfidence != null && ` (${Math.round(betaPathConfidence * 100)}%)`} + Beta Confidence: {betaPathConfidence == null ? 'N/A' : `${Math.round(betaPathConfidence * 100)}%`} +
+ Permutations: {betaPermutationCount == null ? 'N/A' : betaPermutationCount}
)} {FILTER_ROWS.map(({ key, label, color, hollow }) => ( @@ -99,6 +96,22 @@ export const FilterPanel: React.FC = ({ filters, onChange, bet />
)} + {key === 'hexClashes' && filters.hexClashes && ( +
e.stopPropagation()}> + + Hex clash hops: {Math.round(filters.hexClashMaxHops)} + + onChange({ ...filters, hexClashMaxHops: Number(e.target.value) })} + /> +
+ )} ))} diff --git a/frontend/src/components/Map/MapView.tsx b/frontend/src/components/Map/MapView.tsx index bef1e16..ed3fdc2 100644 --- a/frontend/src/components/Map/MapView.tsx +++ b/frontend/src/components/Map/MapView.tsx @@ -88,10 +88,14 @@ interface MapViewProps { showCoverage: boolean; showClientNodes: boolean; showLinks: boolean; + showHexClashes: boolean; + maxHexClashHops: number; viablePairsArr: [string, string][]; linkMetrics: Map; 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 = ({ 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(null); + const [focusedPrefix, setFocusedPrefix] = useState(null); + const [focusedNodeId, setFocusedNodeId] = useState(null); + const [focusedPrefixNodeIds, setFocusedPrefixNodeIds] = useState | null>(null); + const [focusHidePhase, setFocusHidePhase] = useState<'idle' | 'hide' | 'fade'>('idle'); + const hideTimerRef = useRef(null); + const fadeTimerRef = useRef(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 = ({ // Refs to Leaflet Polyline instances for direct SVG attribute animation const regularPathRef = useRef(null); - const betaPathRef = useRef(null); const aniFrameRef = useRef(null); // Animate marching dashes by incrementing stroke-dashoffset directly on the @@ -145,10 +167,9 @@ export const MapView: React.FC = ({ // 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 = ({ 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 = ({ 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 = ({ ), [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(); + 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 = ({ 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 = ({ return '#ef4444'; }; + const clashAdjacency = useMemo(() => { + const adj = new Map>(); + 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([fromId]); + const prev = new Map(); + 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(); + 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(); + 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(); + 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 = ({ {/* 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 && ( = ({ )} {/* Confirmed link lines — ITM-viable node pairs */} - {showLinks && linkLines.length > 0 && ( + {effectiveShowLinks && linkLines.length > 0 && ( {linkLines.map((line) => { const obs = Math.max(1, line.observedCount); @@ -327,27 +541,60 @@ export const MapView: React.FC = ({ )} + {clashModeActive && ( + + {clashPathLines.map((line) => ( + + ))} + + )} + {/* Repeater markers — Leaflet default marker pane at zIndex 600 */} - {nodesWithPos.map((node) => ( - - ))} + {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 ( + + ); + })} {/* Companion radio + room server markers (toggled via filter) */} - {showClientNodes && clientNodesArr.map((node) => ( - - ))} + {showClientNodes && !showHexClashes && clientNodesArr.map((node) => { + const isFocusVisible = clashVisibleNodeIds.has(node.node_id); + if (focusedPrefixNodeIds && focusHidePhase === 'hide' && !isFocusVisible) return null; + return ( + + ); + })} {/* Live packet path — marching dashes from source → observer */} {packetPath && ( @@ -366,16 +613,46 @@ export const MapView: React.FC = ({ {/* Beta path — coverage-validated, unambiguous hop resolution */} {showBetaPaths && betaPath && ( )} + + {showBetaPaths && betaLowPath && ( + + )} + + {showBetaPaths && betaCompletionPaths.length > 0 && ( + + {betaCompletionPaths.map((path, idx) => ( + + ))} + + )} {/* deck.gl overlay — arcs only */} diff --git a/frontend/src/components/Map/NodeMarker.tsx b/frontend/src/components/Map/NodeMarker.tsx index 3a48100..7f26655 100644 --- a/frontend/src/components/Map/NodeMarker.tsx +++ b/frontend/src/components/Map/NodeMarker.tsx @@ -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 = `
@@ -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 = React.memo(({ node, isActive, nodeCoverage, markerSize = 12 }) => { +export const NodeMarker: React.FC = 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(null); const timerRef = useRef | null>(null); @@ -119,12 +142,13 @@ export const NodeMarker: React.FC = 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 ( <> { @@ -173,6 +197,20 @@ export const NodeMarker: React.FC = React.memo(({ node, isActive, nodeCov {Math.round(node.elevation_m)} m ASL
)} + {showSamePrefixRow && ( +
+ Same 2-hex repeaters + + {samePrefixRepeaterCount} + + +
+ )} {nodeCoverage && (