diff --git a/frontend/src/components/PacketVisualizer3D.tsx b/frontend/src/components/PacketVisualizer3D.tsx index aa712a0..520d1c8 100644 --- a/frontend/src/components/PacketVisualizer3D.tsx +++ b/frontend/src/components/PacketVisualizer3D.tsx @@ -117,7 +117,7 @@ export function PacketVisualizer3D({ pruneStaleMinutes, }); - const { hoveredNodeId, hoveredNeighborIds, pinnedNodeId } = useVisualizer3DScene({ + const { hoveredNodeId, pinnedNodeId } = useVisualizer3DScene({ containerRef, data, autoOrbit, @@ -167,8 +167,9 @@ export function PacketVisualizer3D({ ); diff --git a/frontend/src/components/visualizer/AGENTS_packet_visualizer.md b/frontend/src/components/visualizer/AGENTS_packet_visualizer.md index d37d507..31dbf93 100644 --- a/frontend/src/components/visualizer/AGENTS_packet_visualizer.md +++ b/frontend/src/components/visualizer/AGENTS_packet_visualizer.md @@ -12,24 +12,41 @@ The visualizer displays: ## Architecture -### Data Layer (`components/visualizer/useVisualizerData3D.ts`) +### Semantic Data Layer (`networkGraph/packetNetworkGraph.ts`) -The custom hook manages all graph state and simulation logic: +The packet-network module owns the canonical mesh representation and the visibility-aware projection logic: ``` -Packets → Parse → Aggregate by key → Observation window → Publish → Animate +Packets → Parse → Canonical observations/adjacency → Projection by settings ``` **Key responsibilities:** -- Maintains node and link maps (`nodesRef`, `linksRef`) +- Resolves packet source / repeater / destination nodes into a canonical path +- Maintains canonical node, link, observation, and neighbor state independent of UI toggles +- Applies ambiguous repeater heuristics and advert-path hints while building canonical data +- Projects canonical paths into rendered links, including dashed bridges over hidden ambiguous runs +- Exposes a reusable semantic surface for other consumers besides the 3D visualizer + +### Visualizer Data Hook (`components/visualizer/useVisualizerData3D.ts`) + +The hook manages render-specific state and animation timing on top of the shared packet-network data layer: + +``` +Canonical projection → Aggregate by key → Observation window → Publish → Animate +``` + +**Key responsibilities:** + +- Adapts semantic packet-network nodes/links into `GraphNode` / `GraphLink` render objects - Runs `d3-force-3d` simulation for 3D layout (`.numDimensions(3)`) -- Processes incoming packets with deduplication -- Aggregates packet repeats across multiple paths +- Processes incoming packets with deduplication and feeds them into the semantic layer +- Aggregates packet repeats across multiple projected paths - Manages particle queue and animation timing **State:** +- `networkStateRef`: Canonical packet-network state (nodes, links, observations, neighbors) - `nodesRef`: Map of node ID → GraphNode - `linksRef`: Map of link key → GraphLink - `particlesRef`: Array of active Particle objects @@ -50,6 +67,8 @@ Scene creation, render-loop updates, raycasting hover, and click-to-pin interact ### Shared Utilities +- `networkGraph/packetNetworkGraph.ts` + - Canonical packet-network types and replay/projection logic - `components/visualizer/shared.ts` - Graph-specific types: `GraphNode`, `GraphLink`, `NodeMeshData` - Shared rendering helpers: node colors, relative-time formatting, typed-array growth helpers @@ -308,7 +327,7 @@ function buildPath(parsed, packet, myPrefix): string[] { | Pan (right-drag) | Pan the camera | | Scroll wheel | Zoom in/out | -**Click-to-pin:** When a node is pinned, hovering other nodes does not change the highlight. The tooltip shows "Traffic exchanged with:" listing all connected neighbors with their possible names. +**Click-to-pin:** When a node is pinned, hovering other nodes does not change the highlight. The tooltip shows "Traffic exchanged with:" using canonical packet-network adjacency, not rendered-link adjacency, so hidden repeaters still appear truthfully as hidden neighbors. ## Configuration Options @@ -333,15 +352,18 @@ function buildPath(parsed, packet, myPrefix): string[] { ``` PacketVisualizer3D.tsx ├── TYPES (GraphNode extends SimulationNodeDatum3D, GraphLink) -├── CONSTANTS (NODE_COLORS, NODE_LEGEND_ITEMS) -├── DATA LAYER HOOK (useVisualizerData3D) -│ ├── Refs (nodes, links, particles, simulation, pending, timers, trafficPatterns, stretchRaf) -│ ├── d3-force-3d simulation initialization (.numDimensions(3)) -│ ├── Contact indexing (byPrefix12 / byName / byPrefix) -│ ├── Node/link management (addNode, addLink, syncSimulation) -│ ├── Path building (resolveNode, buildPath) +├── SEMANTIC DATA LAYER (networkGraph/packetNetworkGraph.ts) +│ ├── Contact/advert indexes +│ ├── Canonical node/link/neighbor/observation state +│ ├── Path building (resolveNode, buildCanonicalPathForPacket) │ ├── Traffic pattern analysis (for repeater disambiguation) -│ └── Packet processing & publishing +│ └── Projection (projectCanonicalPath, projectPacketNetwork) +├── DATA HOOK (useVisualizerData3D) +│ ├── Refs (network state, render nodes, links, particles, simulation, pending, timers, stretchRaf) +│ ├── d3-force-3d simulation initialization (.numDimensions(3)) +│ ├── Semantic→render adaptation +│ ├── Observation-window packet aggregation +│ └── Particle publishing └── MAIN COMPONENT (PacketVisualizer3D) ├── Three.js scene setup (WebGLRenderer, CSS2DRenderer, OrbitControls) ├── Node mesh management (SphereGeometry + CSS2DObject labels) @@ -356,6 +378,13 @@ utils/visualizerUtils.ts ├── Constants (COLORS, PARTICLE_COLOR_MAP, PARTICLE_SPEED, PACKET_LEGEND_ITEMS) └── Functions (parsePacket, generatePacketKey, analyzeRepeaterTraffic, etc.) +networkGraph/packetNetworkGraph.ts +├── Types (PacketNetworkNode, PacketNetworkLink, PacketNetworkObservation, projection types) +├── Context builders (contact and advert-path indexes) +├── Canonical replay (ingestPacketIntoPacketNetwork) +├── Projection helpers (projectCanonicalPath, projectPacketNetwork) +└── State maintenance (clear, prune, neighbor snapshots) + types/d3-force-3d.d.ts └── Type declarations for d3-force-3d (SimulationNodeDatum3D, Simulation3D, forces) ``` diff --git a/frontend/src/components/visualizer/VisualizerTooltip.tsx b/frontend/src/components/visualizer/VisualizerTooltip.tsx index 0482a4a..94df1af 100644 --- a/frontend/src/components/visualizer/VisualizerTooltip.tsx +++ b/frontend/src/components/visualizer/VisualizerTooltip.tsx @@ -1,25 +1,37 @@ -import type { GraphNode } from './shared'; +import type { PacketNetworkNode } from '../../networkGraph/packetNetworkGraph'; import { formatRelativeTime } from './shared'; interface VisualizerTooltipProps { activeNodeId: string | null; - nodes: Map; - neighborIds: string[]; + canonicalNodes: Map; + canonicalNeighborIds: Map; + renderedNodeIds: Set; } -export function VisualizerTooltip({ activeNodeId, nodes, neighborIds }: VisualizerTooltipProps) { +export function VisualizerTooltip({ + activeNodeId, + canonicalNodes, + canonicalNeighborIds, + renderedNodeIds, +}: VisualizerTooltipProps) { if (!activeNodeId) return null; - const node = nodes.get(activeNodeId); + const node = canonicalNodes.get(activeNodeId); if (!node) return null; + const neighborIds = canonicalNeighborIds.get(activeNodeId) ?? []; const neighbors = neighborIds .map((nid) => { - const neighbor = nodes.get(nid); + const neighbor = canonicalNodes.get(nid); if (!neighbor) return null; const displayName = neighbor.name || (neighbor.type === 'self' ? 'Me' : neighbor.id.slice(0, 8)); - return { id: nid, name: displayName, ambiguousNames: neighbor.ambiguousNames }; + return { + id: nid, + name: displayName, + ambiguousNames: neighbor.ambiguousNames, + hidden: !renderedNodeIds.has(nid), + }; }) .filter((neighbor): neighbor is NonNullable => neighbor !== null); @@ -56,6 +68,7 @@ export function VisualizerTooltip({ activeNodeId, nodes, neighborIds }: Visualiz {neighbors.map((neighbor) => (
  • {neighbor.name} + {neighbor.hidden && (hidden)} {neighbor.ambiguousNames && neighbor.ambiguousNames.length > 0 && ( {' '} diff --git a/frontend/src/components/visualizer/shared.ts b/frontend/src/components/visualizer/shared.ts index 0f24c47..5163cb9 100644 --- a/frontend/src/components/visualizer/shared.ts +++ b/frontend/src/components/visualizer/shared.ts @@ -23,6 +23,7 @@ export interface GraphLink extends SimulationLinkDatum { lastActivity: number; hasDirectObservation: boolean; hasHiddenIntermediate: boolean; + hiddenHopLabels: string[]; } export interface NodeMeshData { @@ -76,6 +77,11 @@ export function formatRelativeTime(timestamp: number): string { return secs > 0 ? `${minutes}m ${secs}s ago` : `${minutes}m ago`; } +export function getSceneNodeLabel(node: Pick) { + const baseLabel = node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8)); + return node.isAmbiguous ? `${baseLabel} (?)` : baseLabel; +} + export function normalizePacketTimestampMs(timestamp: number | null | undefined): number { if (!Number.isFinite(timestamp) || !timestamp || timestamp <= 0) { return Date.now(); diff --git a/frontend/src/components/visualizer/useVisualizer3DScene.ts b/frontend/src/components/visualizer/useVisualizer3DScene.ts index 72bea0b..fb52a4b 100644 --- a/frontend/src/components/visualizer/useVisualizer3DScene.ts +++ b/frontend/src/components/visualizer/useVisualizer3DScene.ts @@ -5,7 +5,13 @@ import { CSS2DObject, CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRe import { COLORS, getLinkId } from '../../utils/visualizerUtils'; import type { VisualizerData3D } from './useVisualizerData3D'; -import { arraysEqual, getBaseNodeColor, growFloat32Buffer, type NodeMeshData } from './shared'; +import { + arraysEqual, + getBaseNodeColor, + getSceneNodeLabel, + growFloat32Buffer, + type NodeMeshData, +} from './shared'; interface UseVisualizer3DSceneArgs { containerRef: RefObject; @@ -362,7 +368,7 @@ export function useVisualizer3DScene({ if (nd.labelDiv.style.color !== labelColor) { nd.labelDiv.style.color = labelColor; } - const labelText = node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8)); + const labelText = getSceneNodeLabel(node); if (nd.labelDiv.textContent !== labelText) { nd.labelDiv.textContent = labelText; } diff --git a/frontend/src/components/visualizer/useVisualizerData3D.ts b/frontend/src/components/visualizer/useVisualizerData3D.ts index bd3f53f..c1e3080 100644 --- a/frontend/src/components/visualizer/useVisualizerData3D.ts +++ b/frontend/src/components/visualizer/useVisualizerData3D.ts @@ -10,10 +10,20 @@ import { type ForceLink3D, type Simulation3D, } from 'd3-force-3d'; -import { PayloadType } from '@michaelhart/meshcore-decoder'; +import type { PacketNetworkNode } from '../../networkGraph/packetNetworkGraph'; +import { + buildPacketNetworkContext, + clearPacketNetworkState, + createPacketNetworkState, + ensureSelfNode, + ingestPacketIntoPacketNetwork, + projectCanonicalPath, + projectPacketNetwork, + prunePacketNetworkState, + snapshotNeighborIds, +} from '../../networkGraph/packetNetworkGraph'; import { - CONTACT_TYPE_REPEATER, type Contact, type ContactAdvertPathSummary, type RadioConfig, @@ -22,24 +32,14 @@ import { import { getRawPacketObservationKey } from '../../utils/rawPacketIdentity'; import { buildLinkKey, - type Particle, - type PathStep, - type PendingPacket, - type RepeaterTrafficData, - PARTICLE_COLOR_MAP, - PARTICLE_SPEED, - analyzeRepeaterTraffic, - buildAmbiguousRepeaterLabel, - buildAmbiguousRepeaterNodeId, - compactPathSteps, dedupeConsecutive, generatePacketKey, - getNodeType, - getPacketLabel, - parsePacket, - recordTrafficObservation, + type Particle, + PARTICLE_COLOR_MAP, + PARTICLE_SPEED, + type PendingPacket, } from '../../utils/visualizerUtils'; -import { type GraphLink, type GraphNode, normalizePacketTimestampMs } from './shared'; +import { type GraphLink, type GraphNode } from './shared'; export interface UseVisualizerData3DOptions { packets: RawPacket[]; @@ -61,12 +61,39 @@ export interface UseVisualizerData3DOptions { export interface VisualizerData3D { nodes: Map; links: Map; + canonicalNodes: Map; + canonicalNeighborIds: Map; + renderedNodeIds: Set; particles: Particle[]; stats: { processed: number; animated: number; nodes: number; links: number }; expandContract: () => void; clearAndReset: () => void; } +function buildInitialRenderNode(node: PacketNetworkNode): GraphNode { + if (node.id === 'self') { + return { + ...node, + x: 0, + y: 0, + z: 0, + vx: 0, + vy: 0, + vz: 0, + }; + } + + const theta = Math.random() * Math.PI * 2; + const phi = Math.acos(2 * Math.random() - 1); + const r = 80 + Math.random() * 100; + return { + ...node, + x: r * Math.sin(phi) * Math.cos(theta), + y: r * Math.sin(phi) * Math.sin(theta), + z: r * Math.cos(phi), + }; +} + export function useVisualizerData3D({ packets, contacts, @@ -83,6 +110,7 @@ export function useVisualizerData3D({ pruneStaleNodes, pruneStaleMinutes, }: UseVisualizerData3DOptions): VisualizerData3D { + const networkStateRef = useRef(createPacketNetworkState(config?.name || 'Me')); const nodesRef = useRef>(new Map()); const linksRef = useRef>(new Map()); const particlesRef = useRef([]); @@ -90,47 +118,23 @@ export function useVisualizerData3D({ const processedRef = useRef>(new Set()); const pendingRef = useRef>(new Map()); const timersRef = useRef>>(new Map()); - const trafficPatternsRef = useRef>(new Map()); const speedMultiplierRef = useRef(particleSpeedMultiplier); const observationWindowRef = useRef(observationWindowSec * 1000); const stretchRafRef = useRef(null); const [stats, setStats] = useState({ processed: 0, animated: 0, nodes: 0, links: 0 }); + const [, setProjectionVersion] = useState(0); - const contactIndex = useMemo(() => { - const byPrefix12 = new Map(); - const byName = new Map(); - const byPrefix = new Map(); - - for (const contact of contacts) { - const prefix12 = contact.public_key.slice(0, 12).toLowerCase(); - byPrefix12.set(prefix12, contact); - - if (contact.name && !byName.has(contact.name)) { - byName.set(contact.name, contact); - } - - for (let len = 1; len <= 12; len++) { - const prefix = prefix12.slice(0, len); - const matches = byPrefix.get(prefix); - if (matches) { - matches.push(contact); - } else { - byPrefix.set(prefix, [contact]); - } - } - } - - return { byPrefix12, byName, byPrefix }; - }, [contacts]); - - const advertPathIndex = useMemo(() => { - const byRepeater = new Map(); - for (const summary of repeaterAdvertPaths) { - const key = summary.public_key.slice(0, 12).toLowerCase(); - byRepeater.set(key, summary.paths); - } - return { byRepeater }; - }, [repeaterAdvertPaths]); + const packetNetworkContext = useMemo( + () => + buildPacketNetworkContext({ + contacts, + config, + repeaterAdvertPaths, + splitAmbiguousByTraffic, + useAdvertPathHints, + }), + [contacts, config, repeaterAdvertPaths, splitAmbiguousByTraffic, useAdvertPathHints] + ); useEffect(() => { speedMultiplierRef.current = particleSpeedMultiplier; @@ -216,115 +220,98 @@ export function useVisualizerData3D({ ? prev : { ...prev, nodes: nodes.length, links: links.length } ); + setProjectionVersion((prev) => prev + 1); }, []); - useEffect(() => { - if (!nodesRef.current.has('self')) { - nodesRef.current.set('self', { - id: 'self', - name: config?.name || 'Me', - type: 'self', - isAmbiguous: false, - lastActivity: Date.now(), - x: 0, - y: 0, - z: 0, - }); - syncSimulation(); + const upsertRenderNode = useCallback( + (node: PacketNetworkNode, existing?: GraphNode): GraphNode => { + if (!existing) { + return buildInitialRenderNode(node); + } + + existing.name = node.name; + existing.type = node.type; + existing.isAmbiguous = node.isAmbiguous; + existing.lastActivity = node.lastActivity; + existing.lastActivityReason = node.lastActivityReason; + existing.lastSeen = node.lastSeen; + existing.probableIdentity = node.probableIdentity; + existing.ambiguousNames = node.ambiguousNames; + + if (node.id === 'self') { + existing.x = 0; + existing.y = 0; + existing.z = 0; + existing.vx = 0; + existing.vy = 0; + existing.vz = 0; + } + + return existing; + }, + [] + ); + + const rebuildRenderProjection = useCallback(() => { + const projection = projectPacketNetwork(networkStateRef.current, { + showAmbiguousNodes, + showAmbiguousPaths, + }); + const previousNodes = nodesRef.current; + const nextNodes = new Map(); + + for (const [nodeId, node] of projection.nodes) { + nextNodes.set(nodeId, upsertRenderNode(node, previousNodes.get(nodeId))); } - }, [config, syncSimulation]); + + const nextLinks = new Map(); + for (const [key, link] of projection.links) { + nextLinks.set(key, { + source: link.sourceId, + target: link.targetId, + lastActivity: link.lastActivity, + hasDirectObservation: link.hasDirectObservation, + hasHiddenIntermediate: link.hasHiddenIntermediate, + hiddenHopLabels: [...link.hiddenHopLabels], + }); + } + + nodesRef.current = nextNodes; + linksRef.current = nextLinks; + syncSimulation(); + }, [showAmbiguousNodes, showAmbiguousPaths, syncSimulation, upsertRenderNode]); + + useEffect(() => { + ensureSelfNode(networkStateRef.current, config?.name || 'Me'); + const selfNode = networkStateRef.current.nodes.get('self'); + if (selfNode) { + nodesRef.current.set('self', upsertRenderNode(selfNode, nodesRef.current.get('self'))); + } + syncSimulation(); + }, [config?.name, syncSimulation, upsertRenderNode]); useEffect(() => { processedRef.current.clear(); - const selfNode = nodesRef.current.get('self'); + clearPacketNetworkState(networkStateRef.current, { selfName: config?.name || 'Me' }); nodesRef.current.clear(); - if (selfNode) nodesRef.current.set('self', selfNode); linksRef.current.clear(); particlesRef.current = []; pendingRef.current.clear(); - timersRef.current.forEach((t) => clearTimeout(t)); + timersRef.current.forEach((timer) => clearTimeout(timer)); timersRef.current.clear(); - trafficPatternsRef.current.clear(); + + const selfNode = networkStateRef.current.nodes.get('self'); + if (selfNode) { + nodesRef.current.set('self', upsertRenderNode(selfNode)); + } + setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 }); syncSimulation(); - }, [ - showAmbiguousPaths, - showAmbiguousNodes, - useAdvertPathHints, - splitAmbiguousByTraffic, - syncSimulation, - ]); + }, [config?.name, splitAmbiguousByTraffic, syncSimulation, upsertRenderNode, useAdvertPathHints]); - const addNode = useCallback( - ( - id: string, - name: string | null, - type: GraphNode['type'], - isAmbiguous: boolean, - probableIdentity?: string | null, - ambiguousNames?: string[], - lastSeen?: number | null, - activityAtMs?: number - ) => { - const activityAt = activityAtMs ?? Date.now(); - const existing = nodesRef.current.get(id); - if (existing) { - existing.lastActivity = Math.max(existing.lastActivity, activityAt); - if (name) existing.name = name; - if (probableIdentity !== undefined) existing.probableIdentity = probableIdentity; - if (ambiguousNames) existing.ambiguousNames = ambiguousNames; - if (lastSeen !== undefined) existing.lastSeen = lastSeen; - } else { - const theta = Math.random() * Math.PI * 2; - const phi = Math.acos(2 * Math.random() - 1); - const r = 80 + Math.random() * 100; - nodesRef.current.set(id, { - id, - name, - type, - isAmbiguous, - lastActivity: activityAt, - probableIdentity, - lastSeen, - ambiguousNames, - x: r * Math.sin(phi) * Math.cos(theta), - y: r * Math.sin(phi) * Math.sin(theta), - z: r * Math.cos(phi), - }); - } - }, - [] - ); - - const addLink = useCallback( - ( - sourceId: string, - targetId: string, - activityAtMs?: number, - hiddenIntermediate: boolean = false - ) => { - const activityAt = activityAtMs ?? Date.now(); - const key = buildLinkKey(sourceId, targetId); - const existing = linksRef.current.get(key); - if (existing) { - existing.lastActivity = Math.max(existing.lastActivity, activityAt); - if (hiddenIntermediate) { - existing.hasHiddenIntermediate = true; - } else { - existing.hasDirectObservation = true; - } - } else { - linksRef.current.set(key, { - source: sourceId, - target: targetId, - lastActivity: activityAt, - hasDirectObservation: !hiddenIntermediate, - hasHiddenIntermediate: hiddenIntermediate, - }); - } - }, - [] - ); + useEffect(() => { + rebuildRenderProjection(); + }, [rebuildRenderProjection]); const publishPacket = useCallback((packetKey: string) => { const pending = pendingRef.current.get(packetKey); @@ -353,342 +340,10 @@ export function useVisualizerData3D({ } }, []); - const pickLikelyRepeaterByAdvertPath = useCallback( - (candidates: Contact[], nextPrefix: string | null) => { - const nextHop = nextPrefix?.toLowerCase() ?? null; - const scored = candidates - .map((candidate) => { - const prefix12 = candidate.public_key.slice(0, 12).toLowerCase(); - const paths = advertPathIndex.byRepeater.get(prefix12) ?? []; - let matchScore = 0; - let totalScore = 0; - - for (const path of paths) { - totalScore += path.heard_count; - const pathNextHop = path.next_hop?.toLowerCase() ?? null; - if (pathNextHop === nextHop) { - matchScore += path.heard_count; - } - } - - return { candidate, matchScore, totalScore }; - }) - .filter((entry) => entry.totalScore > 0) - .sort( - (a, b) => - b.matchScore - a.matchScore || - b.totalScore - a.totalScore || - a.candidate.public_key.localeCompare(b.candidate.public_key) - ); - - if (scored.length === 0) return null; - - const top = scored[0]; - const second = scored[1] ?? null; - - if (top.matchScore < 2) return null; - if (second && top.matchScore < second.matchScore * 2) return null; - - return top.candidate; - }, - [advertPathIndex] - ); - - const resolveNode = useCallback( - ( - source: { type: 'prefix' | 'pubkey' | 'name'; value: string }, - isRepeater: boolean, - showAmbiguous: boolean, - myPrefix: string | null, - activityAtMs: number, - trafficContext?: { packetSource: string | null; nextPrefix: string | null } - ): string | null => { - if (source.type === 'pubkey') { - if (source.value.length < 12) return null; - const nodeId = source.value.slice(0, 12).toLowerCase(); - if (myPrefix && nodeId === myPrefix) return 'self'; - const contact = contactIndex.byPrefix12.get(nodeId); - addNode( - nodeId, - contact?.name || null, - getNodeType(contact), - false, - undefined, - undefined, - contact?.last_seen, - activityAtMs - ); - return nodeId; - } - - if (source.type === 'name') { - const contact = contactIndex.byName.get(source.value) ?? null; - if (contact) { - const nodeId = contact.public_key.slice(0, 12).toLowerCase(); - if (myPrefix && nodeId === myPrefix) return 'self'; - addNode( - nodeId, - contact.name, - getNodeType(contact), - false, - undefined, - undefined, - contact.last_seen, - activityAtMs - ); - return nodeId; - } - const nodeId = `name:${source.value}`; - addNode( - nodeId, - source.value, - 'client', - false, - undefined, - undefined, - undefined, - activityAtMs - ); - return nodeId; - } - - const lookupValue = source.value.toLowerCase(); - const matches = contactIndex.byPrefix.get(lookupValue) ?? []; - const contact = matches.length === 1 ? matches[0] : null; - if (contact) { - const nodeId = contact.public_key.slice(0, 12).toLowerCase(); - if (myPrefix && nodeId === myPrefix) return 'self'; - addNode( - nodeId, - contact.name, - getNodeType(contact), - false, - undefined, - undefined, - contact.last_seen, - activityAtMs - ); - return nodeId; - } - - if (showAmbiguous) { - const filtered = isRepeater - ? matches.filter((c) => c.type === CONTACT_TYPE_REPEATER) - : matches.filter((c) => c.type !== CONTACT_TYPE_REPEATER); - - if (filtered.length === 1) { - const c = filtered[0]; - const nodeId = c.public_key.slice(0, 12).toLowerCase(); - addNode( - nodeId, - c.name, - getNodeType(c), - false, - undefined, - undefined, - c.last_seen, - activityAtMs - ); - return nodeId; - } - - if (filtered.length > 1 || (filtered.length === 0 && isRepeater)) { - const names = filtered.map((c) => c.name || c.public_key.slice(0, 8)); - const lastSeen = filtered.reduce( - (max, c) => (c.last_seen && (!max || c.last_seen > max) ? c.last_seen : max), - null as number | null - ); - - let nodeId = buildAmbiguousRepeaterNodeId(lookupValue); - let displayName = buildAmbiguousRepeaterLabel(lookupValue); - let probableIdentity: string | null = null; - let ambiguousNames = names.length > 0 ? names : undefined; - - if (useAdvertPathHints && isRepeater && trafficContext) { - const normalizedNext = trafficContext.nextPrefix?.toLowerCase() ?? null; - const likely = pickLikelyRepeaterByAdvertPath(filtered, normalizedNext); - if (likely) { - const likelyName = likely.name || likely.public_key.slice(0, 12).toUpperCase(); - probableIdentity = likelyName; - displayName = likelyName; - ambiguousNames = filtered - .filter((c) => c.public_key !== likely.public_key) - .map((c) => c.name || c.public_key.slice(0, 8)); - } - } - - if (splitAmbiguousByTraffic && isRepeater && trafficContext) { - const normalizedNext = trafficContext.nextPrefix?.toLowerCase() ?? null; - - if (trafficContext.packetSource) { - recordTrafficObservation( - trafficPatternsRef.current, - lookupValue, - trafficContext.packetSource, - normalizedNext - ); - } - - const trafficData = trafficPatternsRef.current.get(lookupValue); - if (trafficData) { - const analysis = analyzeRepeaterTraffic(trafficData); - if (analysis.shouldSplit && normalizedNext) { - nodeId = buildAmbiguousRepeaterNodeId(lookupValue, normalizedNext); - if (!probableIdentity) { - displayName = buildAmbiguousRepeaterLabel(lookupValue, normalizedNext); - } - } - } - } - - addNode( - nodeId, - displayName, - isRepeater ? 'repeater' : 'client', - true, - probableIdentity, - ambiguousNames, - lastSeen, - activityAtMs - ); - return nodeId; - } - } - - return null; - }, - [ - contactIndex, - addNode, - useAdvertPathHints, - pickLikelyRepeaterByAdvertPath, - splitAmbiguousByTraffic, - ] - ); - - const buildPath = useCallback( - ( - parsed: ReturnType, - packet: RawPacket, - myPrefix: string | null, - activityAtMs: number - ): { nodes: string[]; dashedLinkKeys: Set } => { - if (!parsed) return { nodes: [], dashedLinkKeys: new Set() }; - const steps: PathStep[] = []; - let packetSource: string | null = null; - const isDm = parsed.payloadType === PayloadType.TextMessage; - const isOutgoingDm = isDm && !!myPrefix && parsed.srcHash?.toLowerCase() === myPrefix; - - if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) { - const nodeId = resolveNode( - { type: 'pubkey', value: parsed.advertPubkey }, - false, - false, - myPrefix, - activityAtMs - ); - if (nodeId) { - steps.push({ nodeId }); - packetSource = nodeId; - } - } else if (parsed.payloadType === PayloadType.AnonRequest && parsed.anonRequestPubkey) { - const nodeId = resolveNode( - { type: 'pubkey', value: parsed.anonRequestPubkey }, - false, - false, - myPrefix, - activityAtMs - ); - if (nodeId) { - steps.push({ nodeId }); - packetSource = nodeId; - } - } else if (parsed.payloadType === PayloadType.TextMessage && parsed.srcHash) { - if (myPrefix && parsed.srcHash.toLowerCase() === myPrefix) { - steps.push({ nodeId: 'self' }); - packetSource = 'self'; - } else { - const nodeId = resolveNode( - { type: 'prefix', value: parsed.srcHash }, - false, - showAmbiguousNodes, - myPrefix, - activityAtMs - ); - if (nodeId) { - steps.push({ nodeId }); - packetSource = nodeId; - } - } - } else if (parsed.payloadType === PayloadType.GroupText) { - const senderName = parsed.groupTextSender || packet.decrypted_info?.sender; - if (senderName) { - const resolved = resolveNode( - { type: 'name', value: senderName }, - false, - false, - myPrefix, - activityAtMs - ); - if (resolved) { - steps.push({ nodeId: resolved }); - packetSource = resolved; - } - } - } - - for (let i = 0; i < parsed.pathBytes.length; i++) { - const hexPrefix = parsed.pathBytes[i]; - const nextPrefix = parsed.pathBytes[i + 1] || null; - const nodeId = resolveNode( - { type: 'prefix', value: hexPrefix }, - true, - showAmbiguousPaths, - myPrefix, - activityAtMs, - { packetSource, nextPrefix } - ); - steps.push({ nodeId, markHiddenLinkWhenOmitted: true }); - } - - if (parsed.payloadType === PayloadType.TextMessage && parsed.dstHash) { - if (myPrefix && parsed.dstHash.toLowerCase() === myPrefix) { - steps.push({ nodeId: 'self' }); - } else { - const nodeId = resolveNode( - { type: 'prefix', value: parsed.dstHash }, - false, - showAmbiguousNodes, - myPrefix, - activityAtMs - ); - if (nodeId) { - steps.push({ nodeId }); - } else if (!isOutgoingDm) { - steps.push({ nodeId: 'self' }); - } - } - } else { - const hasVisibleNode = steps.some((step) => step.nodeId !== null); - if (hasVisibleNode) { - steps.push({ nodeId: 'self' }); - } - } - - const compacted = compactPathSteps(steps); - return { - nodes: dedupeConsecutive(compacted.nodes), - dashedLinkKeys: compacted.dashedLinkKeys, - }; - }, - [resolveNode, showAmbiguousPaths, showAmbiguousNodes] - ); - useEffect(() => { let newProcessed = 0; let newAnimated = 0; - let needsUpdate = false; - const myPrefix = config?.public_key?.slice(0, 12).toLowerCase() || null; + let needsProjectionRebuild = false; for (const packet of packets) { const observationKey = getRawPacketObservationKey(packet); @@ -700,40 +355,30 @@ export function useVisualizerData3D({ processedRef.current = new Set(Array.from(processedRef.current).slice(-500)); } - const parsed = parsePacket(packet.data); - if (!parsed) continue; + const ingested = ingestPacketIntoPacketNetwork( + networkStateRef.current, + packetNetworkContext, + packet + ); + if (!ingested) continue; + needsProjectionRebuild = true; - const packetActivityAt = normalizePacketTimestampMs(packet.timestamp); - const builtPath = buildPath(parsed, packet, myPrefix, packetActivityAt); - if (builtPath.nodes.length < 2) continue; + const projectedPath = projectCanonicalPath(networkStateRef.current, ingested.canonicalPath, { + showAmbiguousNodes, + showAmbiguousPaths, + }); + if (projectedPath.nodes.length < 2) continue; - const label = getPacketLabel(parsed.payloadType); - for (let i = 0; i < builtPath.nodes.length; i++) { - const n = nodesRef.current.get(builtPath.nodes[i]); - if (n && n.id !== 'self') { - n.lastActivityReason = i === 0 ? `${label} source` : `Relayed ${label}`; - } - } - - for (let i = 0; i < builtPath.nodes.length - 1; i++) { - if (builtPath.nodes[i] !== builtPath.nodes[i + 1]) { - const linkKey = buildLinkKey(builtPath.nodes[i], builtPath.nodes[i + 1]); - addLink( - builtPath.nodes[i], - builtPath.nodes[i + 1], - packetActivityAt, - builtPath.dashedLinkKeys.has(linkKey) - ); - needsUpdate = true; - } - } - - const packetKey = generatePacketKey(parsed, packet); + const packetKey = generatePacketKey(ingested.parsed, packet); const now = Date.now(); const existing = pendingRef.current.get(packetKey); if (existing && now < existing.expiresAt) { - existing.paths.push({ nodes: builtPath.nodes, snr: packet.snr ?? null, timestamp: now }); + existing.paths.push({ + nodes: projectedPath.nodes, + snr: packet.snr ?? null, + timestamp: now, + }); } else { const existingTimer = timersRef.current.get(packetKey); if (existingTimer) { @@ -742,8 +387,8 @@ export function useVisualizerData3D({ const windowMs = observationWindowRef.current; pendingRef.current.set(packetKey, { key: packetKey, - label: getPacketLabel(parsed.payloadType), - paths: [{ nodes: builtPath.nodes, snr: packet.snr ?? null, timestamp: now }], + label: ingested.label, + paths: [{ nodes: projectedPath.nodes, snr: packet.snr ?? null, timestamp: now }], firstSeen: now, expiresAt: now + windowMs, }); @@ -770,7 +415,9 @@ export function useVisualizerData3D({ newAnimated++; } - if (needsUpdate) syncSimulation(); + if (needsProjectionRebuild) { + rebuildRenderProjection(); + } if (newProcessed > 0) { setStats((prev) => ({ ...prev, @@ -778,7 +425,14 @@ export function useVisualizerData3D({ animated: prev.animated + newAnimated, })); } - }, [packets, config, buildPath, addLink, syncSimulation, publishPacket]); + }, [ + packets, + packetNetworkContext, + publishPacket, + rebuildRenderProjection, + showAmbiguousNodes, + showAmbiguousPaths, + ]); const expandContract = useCallback(() => { const sim = simulationRef.current; @@ -867,21 +521,14 @@ export function useVisualizerData3D({ timersRef.current.clear(); pendingRef.current.clear(); processedRef.current.clear(); - trafficPatternsRef.current.clear(); particlesRef.current.length = 0; - linksRef.current.clear(); + clearPacketNetworkState(networkStateRef.current, { selfName: config?.name || 'Me' }); - const selfNode = nodesRef.current.get('self'); + linksRef.current.clear(); nodesRef.current.clear(); + const selfNode = networkStateRef.current.nodes.get('self'); if (selfNode) { - selfNode.x = 0; - selfNode.y = 0; - selfNode.z = 0; - selfNode.vx = 0; - selfNode.vy = 0; - selfNode.vz = 0; - selfNode.lastActivity = Date.now(); - nodesRef.current.set('self', selfNode); + nodesRef.current.set('self', upsertRenderNode(selfNode)); } const sim = simulationRef.current; @@ -892,8 +539,8 @@ export function useVisualizerData3D({ sim.alpha(0.3).restart(); } - setStats({ processed: 0, animated: 0, nodes: 1, links: 0 }); - }, []); + setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 }); + }, [config?.name, upsertRenderNode]); useEffect(() => { const stretchRaf = stretchRafRef; @@ -919,40 +566,23 @@ export function useVisualizerData3D({ const interval = setInterval(() => { const cutoff = Date.now() - staleMs; - let pruned = false; - - for (const [id, node] of nodesRef.current) { - if (id === 'self') continue; - if (node.lastActivity < cutoff) { - nodesRef.current.delete(id); - pruned = true; - } - } - - if (pruned) { - for (const [key, link] of linksRef.current) { - const sourceId = typeof link.source === 'string' ? link.source : link.source.id; - const targetId = typeof link.target === 'string' ? link.target : link.target.id; - if (!nodesRef.current.has(sourceId) || !nodesRef.current.has(targetId)) { - linksRef.current.delete(key); - } - } - syncSimulation(); + if (prunePacketNetworkState(networkStateRef.current, cutoff)) { + rebuildRenderProjection(); } }, pruneIntervalMs); return () => clearInterval(interval); - }, [pruneStaleNodes, pruneStaleMinutes, syncSimulation]); + }, [pruneStaleMinutes, pruneStaleNodes, rebuildRenderProjection]); - return useMemo( - () => ({ - nodes: nodesRef.current, - links: linksRef.current, - particles: particlesRef.current, - stats, - expandContract, - clearAndReset, - }), - [stats, expandContract, clearAndReset] - ); + return { + nodes: nodesRef.current, + links: linksRef.current, + canonicalNodes: networkStateRef.current.nodes, + canonicalNeighborIds: snapshotNeighborIds(networkStateRef.current), + renderedNodeIds: new Set(nodesRef.current.keys()), + particles: particlesRef.current, + stats, + expandContract, + clearAndReset, + }; } diff --git a/frontend/src/networkGraph/packetNetworkGraph.ts b/frontend/src/networkGraph/packetNetworkGraph.ts new file mode 100644 index 0000000..32cc48b --- /dev/null +++ b/frontend/src/networkGraph/packetNetworkGraph.ts @@ -0,0 +1,774 @@ +import { PayloadType } from '@michaelhart/meshcore-decoder'; + +import { + CONTACT_TYPE_REPEATER, + type Contact, + type ContactAdvertPathSummary, + type RadioConfig, + type RawPacket, +} from '../types'; +import { + analyzeRepeaterTraffic, + buildAmbiguousRepeaterLabel, + buildAmbiguousRepeaterNodeId, + buildLinkKey, + compactPathSteps, + dedupeConsecutive, + getNodeType, + getPacketLabel, + parsePacket, + recordTrafficObservation, + type NodeType, + type ParsedPacket, + type RepeaterTrafficData, +} from '../utils/visualizerUtils'; +import { normalizePacketTimestampMs } from '../components/visualizer/shared'; + +interface ContactIndex { + byPrefix12: Map; + byName: Map; + byPrefix: Map; +} + +interface AdvertPathIndex { + byRepeater: Map; +} + +export interface PacketNetworkContext { + advertPathIndex: AdvertPathIndex; + contactIndex: ContactIndex; + myPrefix: string | null; + splitAmbiguousByTraffic: boolean; + useAdvertPathHints: boolean; +} + +export interface PacketNetworkVisibilityOptions { + showAmbiguousNodes: boolean; + showAmbiguousPaths: boolean; +} + +export interface PacketNetworkNode { + id: string; + name: string | null; + type: NodeType; + isAmbiguous: boolean; + lastActivity: number; + lastActivityReason?: string; + lastSeen?: number | null; + probableIdentity?: string | null; + ambiguousNames?: string[]; +} + +export interface PacketNetworkLink { + lastActivity: number; + sourceId: string; + targetId: string; +} + +export interface ProjectedPacketNetworkLink extends PacketNetworkLink { + hasDirectObservation: boolean; + hasHiddenIntermediate: boolean; + hiddenHopLabels: string[]; +} + +export interface PacketNetworkObservation { + activityAtMs: number; + nodes: string[]; +} + +export interface PacketNetworkState { + links: Map; + neighborIds: Map>; + nodes: Map; + observations: PacketNetworkObservation[]; + trafficPatterns: Map; +} + +export interface PacketNetworkIngestResult { + activityAtMs: number; + canonicalPath: string[]; + label: ReturnType; + parsed: ParsedPacket; +} + +export interface ProjectedPacketNetworkPath { + dashedLinkDetails: Map; + nodes: string[]; +} + +export interface PacketNetworkProjection { + links: Map; + nodes: Map; + renderedNodeIds: Set; +} + +export function buildPacketNetworkContext({ + config, + contacts, + repeaterAdvertPaths, + splitAmbiguousByTraffic, + useAdvertPathHints, +}: { + config: RadioConfig | null; + contacts: Contact[]; + repeaterAdvertPaths: ContactAdvertPathSummary[]; + splitAmbiguousByTraffic: boolean; + useAdvertPathHints: boolean; +}): PacketNetworkContext { + const byPrefix12 = new Map(); + const byName = new Map(); + const byPrefix = new Map(); + + for (const contact of contacts) { + const prefix12 = contact.public_key.slice(0, 12).toLowerCase(); + byPrefix12.set(prefix12, contact); + + if (contact.name && !byName.has(contact.name)) { + byName.set(contact.name, contact); + } + + for (let len = 1; len <= 12; len++) { + const prefix = prefix12.slice(0, len); + const matches = byPrefix.get(prefix); + if (matches) { + matches.push(contact); + } else { + byPrefix.set(prefix, [contact]); + } + } + } + + const byRepeater = new Map(); + for (const summary of repeaterAdvertPaths) { + const key = summary.public_key.slice(0, 12).toLowerCase(); + byRepeater.set(key, summary.paths); + } + + return { + contactIndex: { byPrefix12, byName, byPrefix }, + advertPathIndex: { byRepeater }, + myPrefix: config?.public_key?.slice(0, 12).toLowerCase() || null, + splitAmbiguousByTraffic, + useAdvertPathHints, + }; +} + +export function createPacketNetworkState(selfName: string = 'Me'): PacketNetworkState { + const now = Date.now(); + return { + nodes: new Map([ + [ + 'self', + { + id: 'self', + name: selfName, + type: 'self', + isAmbiguous: false, + lastActivity: now, + }, + ], + ]), + links: new Map(), + neighborIds: new Map(), + observations: [], + trafficPatterns: new Map(), + }; +} + +export function ensureSelfNode(state: PacketNetworkState, selfName: string = 'Me'): void { + const existing = state.nodes.get('self'); + if (existing) { + existing.name = selfName; + return; + } + state.nodes.set('self', { + id: 'self', + name: selfName, + type: 'self', + isAmbiguous: false, + lastActivity: Date.now(), + }); +} + +export function clearPacketNetworkState( + state: PacketNetworkState, + { selfName = 'Me' }: { selfName?: string } = {} +): void { + state.links.clear(); + state.neighborIds.clear(); + state.observations = []; + state.trafficPatterns.clear(); + + const selfNode = state.nodes.get('self'); + state.nodes.clear(); + state.nodes.set('self', { + id: 'self', + name: selfName, + type: 'self', + isAmbiguous: false, + lastActivity: Date.now(), + lastActivityReason: undefined, + lastSeen: null, + probableIdentity: undefined, + ambiguousNames: undefined, + }); + + if (selfNode?.name && selfNode.name !== selfName) { + state.nodes.get('self')!.name = selfName; + } +} + +function addOrUpdateNode( + state: PacketNetworkState, + { + activityAtMs, + ambiguousNames, + id, + isAmbiguous, + lastSeen, + name, + probableIdentity, + type, + }: { + activityAtMs: number; + ambiguousNames?: string[]; + id: string; + isAmbiguous: boolean; + lastSeen?: number | null; + name: string | null; + probableIdentity?: string | null; + type: NodeType; + } +): void { + const existing = state.nodes.get(id); + if (existing) { + existing.lastActivity = Math.max(existing.lastActivity, activityAtMs); + if (name) existing.name = name; + if (probableIdentity !== undefined) existing.probableIdentity = probableIdentity; + if (ambiguousNames) existing.ambiguousNames = ambiguousNames; + if (lastSeen !== undefined) existing.lastSeen = lastSeen; + return; + } + + state.nodes.set(id, { + id, + name, + type, + isAmbiguous, + lastActivity: activityAtMs, + probableIdentity, + ambiguousNames, + lastSeen, + }); +} + +function addCanonicalLink( + state: PacketNetworkState, + sourceId: string, + targetId: string, + activityAtMs: number +): void { + const key = buildLinkKey(sourceId, targetId); + const existing = state.links.get(key); + if (existing) { + existing.lastActivity = Math.max(existing.lastActivity, activityAtMs); + } else { + state.links.set(key, { sourceId, targetId, lastActivity: activityAtMs }); + } +} + +function upsertNeighbor(state: PacketNetworkState, sourceId: string, targetId: string): void { + const ensureSet = (id: string) => { + const existing = state.neighborIds.get(id); + if (existing) return existing; + const created = new Set(); + state.neighborIds.set(id, created); + return created; + }; + + ensureSet(sourceId).add(targetId); + ensureSet(targetId).add(sourceId); +} + +function pickLikelyRepeaterByAdvertPath( + context: PacketNetworkContext, + candidates: Contact[], + nextPrefix: string | null +): Contact | null { + const nextHop = nextPrefix?.toLowerCase() ?? null; + const scored = candidates + .map((candidate) => { + const prefix12 = candidate.public_key.slice(0, 12).toLowerCase(); + const paths = context.advertPathIndex.byRepeater.get(prefix12) ?? []; + let matchScore = 0; + let totalScore = 0; + + for (const path of paths) { + totalScore += path.heard_count; + const pathNextHop = path.next_hop?.toLowerCase() ?? null; + if (pathNextHop === nextHop) { + matchScore += path.heard_count; + } + } + + return { candidate, matchScore, totalScore }; + }) + .filter((entry) => entry.totalScore > 0) + .sort( + (a, b) => + b.matchScore - a.matchScore || + b.totalScore - a.totalScore || + a.candidate.public_key.localeCompare(b.candidate.public_key) + ); + + if (scored.length === 0) return null; + + const top = scored[0]; + const second = scored[1] ?? null; + + if (top.matchScore < 2) return null; + if (second && top.matchScore < second.matchScore * 2) return null; + + return top.candidate; +} + +function resolveNode( + state: PacketNetworkState, + context: PacketNetworkContext, + source: { type: 'prefix' | 'pubkey' | 'name'; value: string }, + isRepeater: boolean, + showAmbiguous: boolean, + activityAtMs: number, + trafficContext?: { packetSource: string | null; nextPrefix: string | null } +): string | null { + if (source.type === 'pubkey') { + if (source.value.length < 12) return null; + const nodeId = source.value.slice(0, 12).toLowerCase(); + if (context.myPrefix && nodeId === context.myPrefix) return 'self'; + const contact = context.contactIndex.byPrefix12.get(nodeId); + addOrUpdateNode(state, { + id: nodeId, + name: contact?.name || null, + type: getNodeType(contact), + isAmbiguous: false, + lastSeen: contact?.last_seen, + activityAtMs, + }); + return nodeId; + } + + if (source.type === 'name') { + const contact = context.contactIndex.byName.get(source.value) ?? null; + if (contact) { + const nodeId = contact.public_key.slice(0, 12).toLowerCase(); + if (context.myPrefix && nodeId === context.myPrefix) return 'self'; + addOrUpdateNode(state, { + id: nodeId, + name: contact.name, + type: getNodeType(contact), + isAmbiguous: false, + lastSeen: contact.last_seen, + activityAtMs, + }); + return nodeId; + } + + const nodeId = `name:${source.value}`; + addOrUpdateNode(state, { + id: nodeId, + name: source.value, + type: 'client', + isAmbiguous: false, + activityAtMs, + }); + return nodeId; + } + + const lookupValue = source.value.toLowerCase(); + const matches = context.contactIndex.byPrefix.get(lookupValue) ?? []; + const contact = matches.length === 1 ? matches[0] : null; + if (contact) { + const nodeId = contact.public_key.slice(0, 12).toLowerCase(); + if (context.myPrefix && nodeId === context.myPrefix) return 'self'; + addOrUpdateNode(state, { + id: nodeId, + name: contact.name, + type: getNodeType(contact), + isAmbiguous: false, + lastSeen: contact.last_seen, + activityAtMs, + }); + return nodeId; + } + + if (!showAmbiguous) { + return null; + } + + const filtered = isRepeater + ? matches.filter((candidate) => candidate.type === CONTACT_TYPE_REPEATER) + : matches.filter((candidate) => candidate.type !== CONTACT_TYPE_REPEATER); + + if (filtered.length === 1) { + const only = filtered[0]; + const nodeId = only.public_key.slice(0, 12).toLowerCase(); + addOrUpdateNode(state, { + id: nodeId, + name: only.name, + type: getNodeType(only), + isAmbiguous: false, + lastSeen: only.last_seen, + activityAtMs, + }); + return nodeId; + } + + if (filtered.length === 0 && !isRepeater) { + return null; + } + + const names = filtered.map((candidate) => candidate.name || candidate.public_key.slice(0, 8)); + const lastSeen = filtered.reduce( + (max, candidate) => + candidate.last_seen && (!max || candidate.last_seen > max) ? candidate.last_seen : max, + null as number | null + ); + + let nodeId = buildAmbiguousRepeaterNodeId(lookupValue); + let displayName = buildAmbiguousRepeaterLabel(lookupValue); + let probableIdentity: string | null = null; + let ambiguousNames = names.length > 0 ? names : undefined; + + if (context.useAdvertPathHints && isRepeater && trafficContext) { + const likely = pickLikelyRepeaterByAdvertPath(context, filtered, trafficContext.nextPrefix); + if (likely) { + const likelyName = likely.name || likely.public_key.slice(0, 12).toUpperCase(); + probableIdentity = likelyName; + displayName = likelyName; + ambiguousNames = filtered + .filter((candidate) => candidate.public_key !== likely.public_key) + .map((candidate) => candidate.name || candidate.public_key.slice(0, 8)); + } + } + + if (context.splitAmbiguousByTraffic && isRepeater && trafficContext) { + const normalizedNext = trafficContext.nextPrefix?.toLowerCase() ?? null; + + if (trafficContext.packetSource) { + recordTrafficObservation( + state.trafficPatterns, + lookupValue, + trafficContext.packetSource, + normalizedNext + ); + } + + const trafficData = state.trafficPatterns.get(lookupValue); + if (trafficData) { + const analysis = analyzeRepeaterTraffic(trafficData); + if (analysis.shouldSplit && normalizedNext) { + nodeId = buildAmbiguousRepeaterNodeId(lookupValue, normalizedNext); + if (!probableIdentity) { + displayName = buildAmbiguousRepeaterLabel(lookupValue, normalizedNext); + } + } + } + } + + addOrUpdateNode(state, { + id: nodeId, + name: displayName, + type: isRepeater ? 'repeater' : 'client', + isAmbiguous: true, + probableIdentity, + ambiguousNames, + lastSeen, + activityAtMs, + }); + return nodeId; +} + +export function buildCanonicalPathForPacket( + state: PacketNetworkState, + context: PacketNetworkContext, + parsed: ParsedPacket, + packet: RawPacket, + activityAtMs: number +): string[] { + const path: string[] = []; + let packetSource: string | null = null; + const isDm = parsed.payloadType === PayloadType.TextMessage; + const isOutgoingDm = + isDm && !!context.myPrefix && parsed.srcHash?.toLowerCase() === context.myPrefix; + + if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) { + const nodeId = resolveNode( + state, + context, + { type: 'pubkey', value: parsed.advertPubkey }, + false, + false, + activityAtMs + ); + if (nodeId) { + path.push(nodeId); + packetSource = nodeId; + } + } else if (parsed.payloadType === PayloadType.AnonRequest && parsed.anonRequestPubkey) { + const nodeId = resolveNode( + state, + context, + { type: 'pubkey', value: parsed.anonRequestPubkey }, + false, + false, + activityAtMs + ); + if (nodeId) { + path.push(nodeId); + packetSource = nodeId; + } + } else if (parsed.payloadType === PayloadType.TextMessage && parsed.srcHash) { + if (context.myPrefix && parsed.srcHash.toLowerCase() === context.myPrefix) { + path.push('self'); + packetSource = 'self'; + } else { + const nodeId = resolveNode( + state, + context, + { type: 'prefix', value: parsed.srcHash }, + false, + true, + activityAtMs + ); + if (nodeId) { + path.push(nodeId); + packetSource = nodeId; + } + } + } else if (parsed.payloadType === PayloadType.GroupText) { + const senderName = parsed.groupTextSender || packet.decrypted_info?.sender; + if (senderName) { + const nodeId = resolveNode( + state, + context, + { type: 'name', value: senderName }, + false, + false, + activityAtMs + ); + if (nodeId) { + path.push(nodeId); + packetSource = nodeId; + } + } + } + + for (let i = 0; i < parsed.pathBytes.length; i++) { + const nodeId = resolveNode( + state, + context, + { type: 'prefix', value: parsed.pathBytes[i] }, + true, + true, + activityAtMs, + { packetSource, nextPrefix: parsed.pathBytes[i + 1] || null } + ); + if (nodeId) { + path.push(nodeId); + } + } + + if (parsed.payloadType === PayloadType.TextMessage && parsed.dstHash) { + if (context.myPrefix && parsed.dstHash.toLowerCase() === context.myPrefix) { + path.push('self'); + } else { + const nodeId = resolveNode( + state, + context, + { type: 'prefix', value: parsed.dstHash }, + false, + true, + activityAtMs + ); + if (nodeId) { + path.push(nodeId); + } else if (!isOutgoingDm) { + path.push('self'); + } + } + } else if (path.length > 0) { + path.push('self'); + } + + return dedupeConsecutive(path); +} + +export function ingestPacketIntoPacketNetwork( + state: PacketNetworkState, + context: PacketNetworkContext, + packet: RawPacket +): PacketNetworkIngestResult | null { + const parsed = parsePacket(packet.data); + if (!parsed) return null; + + const activityAtMs = normalizePacketTimestampMs(packet.timestamp); + const canonicalPath = buildCanonicalPathForPacket(state, context, parsed, packet, activityAtMs); + if (canonicalPath.length < 2) { + return null; + } + + const label = getPacketLabel(parsed.payloadType); + for (let i = 0; i < canonicalPath.length; i++) { + const node = state.nodes.get(canonicalPath[i]); + if (node && node.id !== 'self') { + node.lastActivityReason = i === 0 ? `${label} source` : `Relayed ${label}`; + } + } + + state.observations.push({ nodes: canonicalPath, activityAtMs }); + + for (let i = 0; i < canonicalPath.length - 1; i++) { + if (canonicalPath[i] !== canonicalPath[i + 1]) { + addCanonicalLink(state, canonicalPath[i], canonicalPath[i + 1], activityAtMs); + upsertNeighbor(state, canonicalPath[i], canonicalPath[i + 1]); + } + } + + return { parsed, label, canonicalPath, activityAtMs }; +} + +export function isPacketNetworkNodeVisible( + node: PacketNetworkNode | undefined, + visibility: PacketNetworkVisibilityOptions +): boolean { + if (!node) return false; + if (node.id === 'self') return true; + if (!node.isAmbiguous) return true; + return node.type === 'repeater' ? visibility.showAmbiguousPaths : visibility.showAmbiguousNodes; +} + +export function projectCanonicalPath( + state: PacketNetworkState, + canonicalPath: string[], + visibility: PacketNetworkVisibilityOptions +): ProjectedPacketNetworkPath { + const projected = compactPathSteps( + canonicalPath.map((nodeId) => ({ + nodeId: isPacketNetworkNodeVisible(state.nodes.get(nodeId), visibility) ? nodeId : null, + markHiddenLinkWhenOmitted: true, + hiddenLabel: null, + })) + ); + + return { + nodes: dedupeConsecutive(projected.nodes), + dashedLinkDetails: projected.dashedLinkDetails, + }; +} + +export function projectPacketNetwork( + state: PacketNetworkState, + visibility: PacketNetworkVisibilityOptions +): PacketNetworkProjection { + const nodes = new Map(); + const selfNode = state.nodes.get('self'); + if (selfNode) { + nodes.set('self', selfNode); + } + + const links = new Map(); + + for (const observation of state.observations) { + const projected = projectCanonicalPath(state, observation.nodes, visibility); + if (projected.nodes.length < 2) continue; + + for (const nodeId of projected.nodes) { + const node = state.nodes.get(nodeId); + if (node) { + nodes.set(nodeId, node); + } + } + + for (let i = 0; i < projected.nodes.length - 1; i++) { + const sourceId = projected.nodes[i]; + const targetId = projected.nodes[i + 1]; + if (sourceId === targetId) continue; + + const key = buildLinkKey(sourceId, targetId); + const hiddenIntermediate = projected.dashedLinkDetails.has(key); + const existing = links.get(key); + + if (existing) { + existing.lastActivity = Math.max(existing.lastActivity, observation.activityAtMs); + if (hiddenIntermediate) { + existing.hasHiddenIntermediate = true; + for (const label of projected.dashedLinkDetails.get(key) ?? []) { + if (!existing.hiddenHopLabels.includes(label)) { + existing.hiddenHopLabels.push(label); + } + } + } else { + existing.hasDirectObservation = true; + } + continue; + } + + links.set(key, { + sourceId, + targetId, + lastActivity: observation.activityAtMs, + hasDirectObservation: !hiddenIntermediate, + hasHiddenIntermediate: hiddenIntermediate, + hiddenHopLabels: [...(projected.dashedLinkDetails.get(key) ?? [])], + }); + } + } + + return { + nodes, + links, + renderedNodeIds: new Set(nodes.keys()), + }; +} + +export function prunePacketNetworkState(state: PacketNetworkState, cutoff: number): boolean { + let pruned = false; + + for (const [id, node] of state.nodes) { + if (id === 'self') continue; + if (node.lastActivity < cutoff) { + state.nodes.delete(id); + pruned = true; + } + } + + if (!pruned) { + return false; + } + + for (const [key, link] of state.links) { + if (!state.nodes.has(link.sourceId) || !state.nodes.has(link.targetId)) { + state.links.delete(key); + } + } + + state.observations = state.observations.filter((observation) => + observation.nodes.every((nodeId) => state.nodes.has(nodeId)) + ); + + state.neighborIds.clear(); + for (const link of state.links.values()) { + upsertNeighbor(state, link.sourceId, link.targetId); + } + + return true; +} + +export function snapshotNeighborIds(state: PacketNetworkState): Map { + return new Map( + Array.from(state.neighborIds.entries()).map(([nodeId, neighborIds]) => [ + nodeId, + Array.from(neighborIds).sort(), + ]) + ); +} diff --git a/frontend/src/test/packetNetworkGraph.test.ts b/frontend/src/test/packetNetworkGraph.test.ts new file mode 100644 index 0000000..4f4109c --- /dev/null +++ b/frontend/src/test/packetNetworkGraph.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PayloadType } from '@michaelhart/meshcore-decoder'; + +import { + buildPacketNetworkContext, + createPacketNetworkState, + ingestPacketIntoPacketNetwork, + projectCanonicalPath, + projectPacketNetwork, + snapshotNeighborIds, +} from '../networkGraph/packetNetworkGraph'; +import type { Contact, RadioConfig, RawPacket } from '../types'; +import { CONTACT_TYPE_REPEATER } from '../types'; + +const { packetFixtures } = vi.hoisted(() => ({ + packetFixtures: new Map(), +})); + +vi.mock('../utils/visualizerUtils', async () => { + const actual = await vi.importActual( + '../utils/visualizerUtils' + ); + + return { + ...actual, + parsePacket: vi.fn( + (hexData: string) => packetFixtures.get(hexData) ?? actual.parsePacket(hexData) + ), + }; +}); + +function createConfig(publicKey: string): RadioConfig { + return { + public_key: publicKey, + name: 'Me', + lat: 0, + lon: 0, + tx_power: 0, + max_tx_power: 0, + radio: { freq: 0, bw: 0, sf: 0, cr: 0 }, + path_hash_mode: 0, + path_hash_mode_supported: true, + advert_location_source: 'off', + }; +} + +function createContact(publicKey: string, name: string, type = 1): Contact { + return { + public_key: publicKey, + name, + type, + flags: 0, + last_path: null, + last_path_len: 0, + out_path_hash_mode: 0, + route_override_path: null, + route_override_len: null, + route_override_hash_mode: null, + last_advert: null, + lat: null, + lon: null, + last_seen: null, + on_radio: false, + last_contacted: null, + last_read_at: null, + first_seen: null, + }; +} + +function createPacket(data: string): RawPacket { + return { + id: 1, + observation_id: 1, + timestamp: 1_700_000_000, + data, + payload_type: 'TEXT', + snr: null, + rssi: null, + decrypted: false, + decrypted_info: null, + }; +} + +describe('packetNetworkGraph', () => { + it('preserves canonical adjacency while projection hides ambiguous repeaters', () => { + const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000'; + const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000'; + packetFixtures.set('dm-semantic-hide', { + payloadType: PayloadType.TextMessage, + messageHash: 'dm-semantic-hide', + pathBytes: ['32'], + srcHash: 'aaaaaaaaaaaa', + dstHash: 'ffffffffffff', + advertPubkey: null, + groupTextSender: null, + anonRequestPubkey: null, + }); + + const state = createPacketNetworkState('Me'); + const context = buildPacketNetworkContext({ + contacts: [createContact(aliceKey, 'Alice')], + config: createConfig(selfKey), + repeaterAdvertPaths: [], + splitAmbiguousByTraffic: false, + useAdvertPathHints: false, + }); + + ingestPacketIntoPacketNetwork(state, context, createPacket('dm-semantic-hide')); + + const hiddenProjection = projectPacketNetwork(state, { + showAmbiguousNodes: false, + showAmbiguousPaths: false, + }); + const shownProjection = projectPacketNetwork(state, { + showAmbiguousNodes: false, + showAmbiguousPaths: true, + }); + + expect(snapshotNeighborIds(state)).toEqual( + new Map([ + ['?32', ['aaaaaaaaaaaa', 'self']], + ['aaaaaaaaaaaa', ['?32']], + ['self', ['?32']], + ]) + ); + expect(hiddenProjection.links.has('aaaaaaaaaaaa->self')).toBe(true); + expect(shownProjection.links.has('?32->aaaaaaaaaaaa')).toBe(true); + expect(shownProjection.links.has('?32->self')).toBe(true); + }); + + it('projects hidden ambiguous runs as dashed bridges but keeps later known repeaters visible', () => { + const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000'; + const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000'; + const repeaterKey = '5656565656560000000000000000000000000000000000000000000000000000'; + + packetFixtures.set('dm-hidden-chain', { + payloadType: PayloadType.TextMessage, + messageHash: 'dm-hidden-chain', + pathBytes: ['32', '565656565656'], + srcHash: 'aaaaaaaaaaaa', + dstHash: 'ffffffffffff', + advertPubkey: null, + groupTextSender: null, + anonRequestPubkey: null, + }); + + const state = createPacketNetworkState('Me'); + const context = buildPacketNetworkContext({ + contacts: [ + createContact(aliceKey, 'Alice'), + createContact(repeaterKey, 'Relay B', CONTACT_TYPE_REPEATER), + ], + config: createConfig(selfKey), + repeaterAdvertPaths: [], + splitAmbiguousByTraffic: false, + useAdvertPathHints: false, + }); + + const ingested = ingestPacketIntoPacketNetwork(state, context, createPacket('dm-hidden-chain')); + + expect(ingested?.canonicalPath).toEqual(['aaaaaaaaaaaa', '?32', '565656565656', 'self']); + + const projectedPath = projectCanonicalPath(state, ingested!.canonicalPath, { + showAmbiguousNodes: false, + showAmbiguousPaths: false, + }); + const projection = projectPacketNetwork(state, { + showAmbiguousNodes: false, + showAmbiguousPaths: false, + }); + + expect(projectedPath.nodes).toEqual(['aaaaaaaaaaaa', '565656565656', 'self']); + expect(Array.from(projectedPath.dashedLinkDetails.keys())).toEqual([ + '565656565656->aaaaaaaaaaaa', + ]); + expect(projection.links.get('565656565656->aaaaaaaaaaaa')?.hasHiddenIntermediate).toBe(true); + expect(projection.links.get('565656565656->self')?.hasDirectObservation).toBe(true); + }); + + it('replays real advert packets through the semantic layer', () => { + const state = createPacketNetworkState('Me'); + const context = buildPacketNetworkContext({ + contacts: [], + config: createConfig('ffffffffffff0000000000000000000000000000000000000000000000000000'), + repeaterAdvertPaths: [], + splitAmbiguousByTraffic: false, + useAdvertPathHints: false, + }); + + const packet = createPacket( + '1106538B1CD273868576DC7F679B493F9AB5AC316173E1A56D3388BC3BA75F583F63AB0D1BA2A8ABD0BC6669DBF719E67E4C8517BA4E0D6F8C96A323E9D13A77F2630DED965A5C17C3EC6ED1601EEFE857749DA24E9F39CBEACD722C3708F433DB5FA9BAF0BAF9BC5B1241069290FEEB029A839EF843616E204F204D657368203220F09FA5AB' + ); + packet.payload_type = 'ADVERT'; + + const ingested = ingestPacketIntoPacketNetwork(state, context, packet); + + expect(ingested?.canonicalPath).toEqual([ + '8576dc7f679b', + '?53', + '?8b', + '?1c', + '?d2', + '?73', + '?86', + 'self', + ]); + expect(snapshotNeighborIds(state).get('?73')).toEqual(['?86', '?d2']); + }); +}); diff --git a/frontend/src/test/useVisualizerData3D.test.ts b/frontend/src/test/useVisualizerData3D.test.ts index 45f2e7f..9bb1bf2 100644 --- a/frontend/src/test/useVisualizerData3D.test.ts +++ b/frontend/src/test/useVisualizerData3D.test.ts @@ -113,6 +113,51 @@ afterEach(() => { }); describe('useVisualizerData3D', () => { + it('keeps canonical adjacency stable when ambiguous repeaters are shown or hidden', async () => { + const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000'; + const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000'; + packetFixtures.set('dm-canonical-stable', { + payloadType: PayloadType.TextMessage, + messageHash: 'dm-canonical-stable', + pathBytes: ['32'], + srcHash: 'aaaaaaaaaaaa', + dstHash: 'ffffffffffff', + advertPubkey: null, + groupTextSender: null, + anonRequestPubkey: null, + }); + + const packets = [createPacket('dm-canonical-stable')]; + const contacts = [createContact(aliceKey, 'Alice')]; + + const hidden = renderVisualizerData({ + packets, + contacts, + config: createConfig(selfKey), + showAmbiguousPaths: false, + }); + const shown = renderVisualizerData({ + packets, + contacts, + config: createConfig(selfKey), + showAmbiguousPaths: true, + }); + + await waitFor(() => + expect(hidden.result.current.canonicalNeighborIds.get('aaaaaaaaaaaa')).toEqual(['?32']) + ); + await waitFor(() => + expect(shown.result.current.canonicalNeighborIds.get('aaaaaaaaaaaa')).toEqual(['?32']) + ); + + expect(hidden.result.current.canonicalNeighborIds).toEqual( + shown.result.current.canonicalNeighborIds + ); + expect(hidden.result.current.links.has(buildLinkKey('aaaaaaaaaaaa', 'self'))).toBe(true); + expect(hidden.result.current.links.has(buildLinkKey('aaaaaaaaaaaa', '?32'))).toBe(false); + expect(shown.result.current.links.has(buildLinkKey('aaaaaaaaaaaa', '?32'))).toBe(true); + }); + it('marks compressed hidden-repeater routes as dashed links instead of direct solid links', async () => { const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000'; const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000'; @@ -140,6 +185,9 @@ describe('useVisualizerData3D', () => { expect(link).toBeDefined(); expect(link?.hasHiddenIntermediate).toBe(true); expect(link?.hasDirectObservation).toBe(false); + expect(result.current.canonicalNeighborIds.get('aaaaaaaaaaaa')).toEqual(['?32']); + expect(result.current.canonicalNeighborIds.get('self')).toEqual(['?32']); + expect(result.current.renderedNodeIds.has('?32')).toBe(false); }); it('does not append self after a resolved outgoing DM destination', async () => { @@ -175,6 +223,44 @@ describe('useVisualizerData3D', () => { expect(result.current.links.has(buildLinkKey('self', 'bbbbbbbbbbbb'))).toBe(false); }); + it('picks back up with known repeaters after hiding ambiguous repeater segments', async () => { + const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000'; + const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000'; + const repeaterKey = '5656565656560000000000000000000000000000000000000000000000000000'; + + packetFixtures.set('dm-hidden-then-known', { + payloadType: PayloadType.TextMessage, + messageHash: 'dm-hidden-then-known', + pathBytes: ['32', '565656565656'], + srcHash: 'aaaaaaaaaaaa', + dstHash: 'ffffffffffff', + advertPubkey: null, + groupTextSender: null, + anonRequestPubkey: null, + }); + + const { result } = renderVisualizerData({ + packets: [createPacket('dm-hidden-then-known')], + contacts: [ + createContact(aliceKey, 'Alice'), + createContact(repeaterKey, 'Relay B', CONTACT_TYPE_REPEATER), + ], + config: createConfig(selfKey), + showAmbiguousPaths: false, + }); + + await waitFor(() => expect(result.current.links.size).toBe(2)); + + expect(result.current.links.has(buildLinkKey('aaaaaaaaaaaa', '565656565656'))).toBe(true); + expect(result.current.links.has(buildLinkKey('565656565656', 'self'))).toBe(true); + expect(result.current.links.has(buildLinkKey('aaaaaaaaaaaa', 'self'))).toBe(false); + expect(result.current.renderedNodeIds.has('565656565656')).toBe(true); + expect(result.current.renderedNodeIds.has('?32')).toBe(false); + expect(result.current.canonicalNeighborIds.get('?32')).toEqual( + expect.arrayContaining(['aaaaaaaaaaaa', '565656565656']) + ); + }); + it('does not create a fake self edge for an unresolved outgoing direct DM', async () => { const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000'; diff --git a/frontend/src/test/visualizerShared.test.ts b/frontend/src/test/visualizerShared.test.ts new file mode 100644 index 0000000..8ed23ea --- /dev/null +++ b/frontend/src/test/visualizerShared.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; + +import { getSceneNodeLabel } from '../components/visualizer/shared'; + +describe('visualizer shared label helpers', () => { + it('adds an ambiguity suffix to in-graph labels for ambiguous nodes', () => { + expect( + getSceneNodeLabel({ + id: '?32', + name: 'Likely Relay', + type: 'repeater', + isAmbiguous: true, + }) + ).toBe('Likely Relay (?)'); + }); + + it('does not add an ambiguity suffix to unambiguous nodes', () => { + expect( + getSceneNodeLabel({ + id: 'aaaaaaaaaaaa', + name: 'Alice', + type: 'client', + isAmbiguous: false, + }) + ).toBe('Alice'); + }); +}); diff --git a/frontend/src/test/visualizerTooltip.test.tsx b/frontend/src/test/visualizerTooltip.test.tsx index ed45851..445f348 100644 --- a/frontend/src/test/visualizerTooltip.test.tsx +++ b/frontend/src/test/visualizerTooltip.test.tsx @@ -2,18 +2,17 @@ import { render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { VisualizerTooltip } from '../components/visualizer/VisualizerTooltip'; -import type { GraphNode } from '../components/visualizer/shared'; +import type { PacketNetworkNode } from '../networkGraph/packetNetworkGraph'; -function createNode(overrides: Partial & Pick): GraphNode { +function createNode( + overrides: Partial & Pick +): PacketNetworkNode { return { id: overrides.id, type: overrides.type, name: overrides.name ?? null, isAmbiguous: overrides.isAmbiguous ?? false, lastActivity: overrides.lastActivity ?? Date.now(), - x: overrides.x ?? 0, - y: overrides.y ?? 0, - z: overrides.z ?? 0, probableIdentity: overrides.probableIdentity, ambiguousNames: overrides.ambiguousNames, lastActivityReason: overrides.lastActivityReason, @@ -23,7 +22,12 @@ function createNode(overrides: Partial & Pick { it('renders nothing without an active node', () => { const { container } = render( - + ); expect(container).toBeEmptyDOMElement(); @@ -49,17 +53,25 @@ describe('VisualizerTooltip', () => { name: 'Neighbor Node', ambiguousNames: ['Alt Neighbor'], }); + const hiddenRepeater = createNode({ + id: '?44', + type: 'repeater', + name: '44', + isAmbiguous: true, + }); render( ); @@ -72,6 +84,8 @@ describe('VisualizerTooltip', () => { expect(screen.getByText('Reason: Relayed GT')).toBeInTheDocument(); expect(screen.getByText('Neighbor Node')).toBeInTheDocument(); expect(screen.getByText('(Alt Neighbor)')).toBeInTheDocument(); + expect(screen.getByText('44')).toBeInTheDocument(); + expect(screen.getByText('(hidden)')).toBeInTheDocument(); vi.useRealTimers(); }); diff --git a/frontend/src/utils/visualizerUtils.ts b/frontend/src/utils/visualizerUtils.ts index 2a9bdc9..2c54ec8 100644 --- a/frontend/src/utils/visualizerUtils.ts +++ b/frontend/src/utils/visualizerUtils.ts @@ -33,7 +33,7 @@ export interface PendingPacket { expiresAt: number; } -interface ParsedPacket { +export interface ParsedPacket { payloadType: number; messageHash: string | null; pathBytes: string[]; @@ -113,6 +113,7 @@ export const PACKET_LEGEND_ITEMS = [ export interface PathStep { nodeId: string | null; markHiddenLinkWhenOmitted?: boolean; + hiddenLabel?: string | null; } export function normalizeHopToken(hop: string | null | undefined): string | null { @@ -253,31 +254,43 @@ export function dedupeConsecutive(arr: T[]): T[] { export function compactPathSteps(steps: PathStep[]): { nodes: string[]; - dashedLinkKeys: Set; + dashedLinkDetails: Map; } { const nodes: string[] = []; - const dashedLinkKeys = new Set(); + const dashedLinkDetails = new Map(); let pendingHiddenLink = false; + let pendingHiddenLabels: string[] = []; for (const step of steps) { if (step.nodeId) { const previousNodeId = nodes.length > 0 ? nodes[nodes.length - 1] : null; if (previousNodeId && pendingHiddenLink && previousNodeId !== step.nodeId) { - dashedLinkKeys.add(buildLinkKey(previousNodeId, step.nodeId)); + const key = buildLinkKey(previousNodeId, step.nodeId); + const existing = dashedLinkDetails.get(key) ?? []; + for (const label of pendingHiddenLabels) { + if (!existing.includes(label)) { + existing.push(label); + } + } + dashedLinkDetails.set(key, existing); } if (previousNodeId !== step.nodeId) { nodes.push(step.nodeId); } pendingHiddenLink = false; + pendingHiddenLabels = []; continue; } if (step.markHiddenLinkWhenOmitted && nodes.length > 0) { pendingHiddenLink = true; + if (step.hiddenLabel && !pendingHiddenLabels.includes(step.hiddenLabel)) { + pendingHiddenLabels.push(step.hiddenLabel); + } } } - return { nodes, dashedLinkKeys }; + return { nodes, dashedLinkDetails }; } /**