diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index d6cc4ad..68f9381 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -51,6 +51,12 @@ frontend/src/ ├── components/ │ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals │ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/chat/empty) +│ ├── visualizer/ +│ │ ├── useVisualizerData3D.ts # Packet→graph data pipeline, repeat aggregation, simulation state +│ │ ├── useVisualizer3DScene.ts # Three.js scene lifecycle, buffers, hover/pin interaction +│ │ ├── VisualizerControls.tsx # Visualizer legends and control panel overlay +│ │ ├── VisualizerTooltip.tsx # Hover/pin node detail overlay +│ │ └── shared.ts # Graph node/link types and shared rendering helpers │ └── ... ├── utils/ │ ├── urlHash.ts # Hash parsing and encoding @@ -216,6 +222,7 @@ High-level state is delegated to hooks: ### Visualizer behavior - `VisualizerView.tsx` hosts `PacketVisualizer3D.tsx` (desktop split-pane and mobile tabs). +- `PacketVisualizer3D.tsx` is now a thin composition shell over visualizer-specific hooks/components in `components/visualizer/`. - `PacketVisualizer3D` uses persistent Three.js geometries for links/highlights/particles and updates typed-array buffers in-place per frame. - Packet repeat aggregation keys prefer decoder `messageHash` (path-insensitive), with hash fallback for malformed packets. - Raw-packet decoding in `RawPacketList.tsx` and `visualizerUtils.ts` relies on the multibyte-aware decoder fork; keep frontend packet parsing aligned with backend `path_utils.py`. diff --git a/frontend/src/components/AGENTS_packet_visualizer.md b/frontend/src/components/AGENTS_packet_visualizer.md index fa4be89..d37d507 100644 --- a/frontend/src/components/AGENTS_packet_visualizer.md +++ b/frontend/src/components/AGENTS_packet_visualizer.md @@ -12,7 +12,7 @@ The visualizer displays: ## Architecture -### Data Layer (`useVisualizerData3D` hook) +### Data Layer (`components/visualizer/useVisualizerData3D.ts`) The custom hook manages all graph state and simulation logic: @@ -39,6 +39,8 @@ Packets → Parse → Aggregate by key → Observation window → Publish → An ### Rendering Layer (Three.js) +Scene creation, render-loop updates, raycasting hover, and click-to-pin interaction live in `components/visualizer/useVisualizer3DScene.ts`. + - `THREE.WebGLRenderer` + `CSS2DRenderer` (text labels overlaid on 3D scene) - `OrbitControls` for camera interaction (orbit, pan, zoom) - `THREE.Mesh` with `SphereGeometry` per node + `CSS2DObject` labels @@ -46,15 +48,20 @@ Packets → Parse → Aggregate by key → Observation window → Publish → An - `THREE.Points` with vertex colors for particles (persistent geometry + circular sprite texture) - `THREE.Raycaster` for hover/click detection on node spheres -### Shared Utilities (`utils/visualizerUtils.ts`) +### Shared Utilities -Types, constants, and pure functions shared across the codebase: +- `components/visualizer/shared.ts` + - Graph-specific types: `GraphNode`, `GraphLink`, `NodeMeshData` + - Shared rendering helpers: node colors, relative-time formatting, typed-array growth helpers +- `utils/visualizerUtils.ts` + - Packet parsing, identity helpers, ambiguous repeater heuristics, constants shared across visualizer code -- Types: `NodeType`, `PacketLabel`, `Particle`, `ObservedPath`, `PendingPacket`, `ParsedPacket`, `TrafficObservation`, `RepeaterTrafficData`, `RepeaterSplitAnalysis` -- Constants: `COLORS`, `PARTICLE_COLOR_MAP`, `PARTICLE_SPEED`, `DEFAULT_OBSERVATION_WINDOW_SEC`, traffic thresholds, `PACKET_LEGEND_ITEMS` -- Functions: `hashString` (from `utils/contactAvatar.ts`), `parsePacket`, `getPacketLabel`, `generatePacketKey`, `getLinkId`, `getNodeType`, `dedupeConsecutive`, `analyzeRepeaterTraffic`, `recordTrafficObservation` +### UI Overlays -`GraphNode` and `GraphLink` are defined locally in the component — they extend `SimulationNodeDatum3D` and `SimulationLinkDatum` from `d3-force-3d`. +- `components/visualizer/VisualizerControls.tsx` + - Legends, settings toggles, repulsion/speed controls, reset/stretch actions +- `components/visualizer/VisualizerTooltip.tsx` + - Hovered/pinned node metadata and neighbor list ### Type Declarations (`types/d3-force-3d.d.ts`) diff --git a/frontend/src/components/PacketVisualizer3D.tsx b/frontend/src/components/PacketVisualizer3D.tsx index 1025ce5..aa712a0 100644 --- a/frontend/src/components/PacketVisualizer3D.tsx +++ b/frontend/src/components/PacketVisualizer3D.tsx @@ -1,1048 +1,12 @@ -import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; -import * as THREE from 'three'; -import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; -import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; -import { - forceSimulation, - forceLink, - forceManyBody, - forceCenter, - forceX, - forceY, - forceZ, - type Simulation3D, - type SimulationNodeDatum3D, - type ForceLink3D, -} from 'd3-force-3d'; -import type { SimulationLinkDatum } from 'd3-force'; -import { PayloadType } from '@michaelhart/meshcore-decoder'; +import { useEffect, useRef, useState } from 'react'; + import { api } from '../api'; -import { - CONTACT_TYPE_REPEATER, - type Contact, - type RawPacket, - type RadioConfig, - type ContactAdvertPathSummary, -} from '../types'; -import { getRawPacketObservationKey } from '../utils/rawPacketIdentity'; +import type { Contact, ContactAdvertPathSummary, RadioConfig, RawPacket } from '../types'; import { getVisualizerSettings, saveVisualizerSettings } from '../utils/visualizerSettings'; -import { Checkbox } from './ui/checkbox'; -import { - type NodeType, - type Particle, - type PendingPacket, - type RepeaterTrafficData, - buildAmbiguousRepeaterLabel, - buildAmbiguousRepeaterNodeId, - COLORS, - PARTICLE_COLOR_MAP, - PARTICLE_SPEED, - PACKET_LEGEND_ITEMS, - parsePacket, - getPacketLabel, - generatePacketKey, - getLinkId, - getNodeType, - dedupeConsecutive, - analyzeRepeaterTraffic, - recordTrafficObservation, -} from '../utils/visualizerUtils'; - -// ============================================================================= -// TYPES (local — extend d3-force-3d simulation datum types) -// ============================================================================= - -interface GraphNode extends SimulationNodeDatum3D { - id: string; - name: string | null; - type: NodeType; - isAmbiguous: boolean; - lastActivity: number; - lastActivityReason?: string; - lastSeen?: number | null; - probableIdentity?: string | null; - ambiguousNames?: string[]; -} - -interface GraphLink extends SimulationLinkDatum { - source: string | GraphNode; - target: string | GraphNode; - lastActivity: number; -} - -// ============================================================================= -// 3D NODE COLORS -// ============================================================================= - -const NODE_COLORS = { - self: 0x22c55e, // green - repeater: 0x3b82f6, // blue - client: 0xffffff, // white - ambiguous: 0x9ca3af, // gray -} as const; - -const NODE_LEGEND_ITEMS = [ - { color: '#22c55e', label: 'You', size: 14 }, - { color: '#3b82f6', label: 'Repeater', size: 10 }, - { color: '#ffffff', label: 'Node', size: 10 }, - { color: '#9ca3af', label: 'Ambiguous', size: 10 }, -] as const; - -function getBaseNodeColor(node: Pick): number { - if (node.type === 'self') return NODE_COLORS.self; - if (node.type === 'repeater') return NODE_COLORS.repeater; - return node.isAmbiguous ? NODE_COLORS.ambiguous : NODE_COLORS.client; -} - -function growFloat32Buffer( - current: Float32Array, - requiredLength: number -): Float32Array { - let nextLength = Math.max(12, current.length); - while (nextLength < requiredLength) { - nextLength *= 2; - } - return new Float32Array(nextLength); -} - -function arraysEqual(a: string[], b: string[]): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false; - } - return true; -} - -function formatRelativeTime(timestamp: number): string { - const seconds = Math.floor((Date.now() - timestamp) / 1000); - if (seconds < 5) return 'just now'; - if (seconds < 60) return `${seconds}s ago`; - const minutes = Math.floor(seconds / 60); - const secs = seconds % 60; - return secs > 0 ? `${minutes}m ${secs}s ago` : `${minutes}m ago`; -} - -function normalizePacketTimestampMs(timestamp: number | null | undefined): number { - if (!Number.isFinite(timestamp) || !timestamp || timestamp <= 0) { - return Date.now(); - } - const ts = Number(timestamp); - // Backend currently sends Unix seconds; tolerate millis if already provided. - return ts > 1_000_000_000_000 ? ts : ts * 1000; -} - -// ============================================================================= -// DATA LAYER HOOK (3D variant) -// ============================================================================= - -interface UseVisualizerData3DOptions { - packets: RawPacket[]; - contacts: Contact[]; - config: RadioConfig | null; - repeaterAdvertPaths: ContactAdvertPathSummary[]; - showAmbiguousPaths: boolean; - showAmbiguousNodes: boolean; - useAdvertPathHints: boolean; - splitAmbiguousByTraffic: boolean; - chargeStrength: number; - letEmDrift: boolean; - particleSpeedMultiplier: number; - observationWindowSec: number; - pruneStaleNodes: boolean; - pruneStaleMinutes: number; -} - -interface VisualizerData3D { - nodes: Map; - links: Map; - particles: Particle[]; - stats: { processed: number; animated: number; nodes: number; links: number }; - expandContract: () => void; - clearAndReset: () => void; -} - -function useVisualizerData3D({ - packets, - contacts, - config, - repeaterAdvertPaths, - showAmbiguousPaths, - showAmbiguousNodes, - useAdvertPathHints, - splitAmbiguousByTraffic, - chargeStrength, - letEmDrift, - particleSpeedMultiplier, - observationWindowSec, - pruneStaleNodes, - pruneStaleMinutes, -}: UseVisualizerData3DOptions): VisualizerData3D { - const nodesRef = useRef>(new Map()); - const linksRef = useRef>(new Map()); - const particlesRef = useRef([]); - const simulationRef = useRef | null>(null); - 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 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]); - - // Keep refs in sync with props - useEffect(() => { - speedMultiplierRef.current = particleSpeedMultiplier; - }, [particleSpeedMultiplier]); - - useEffect(() => { - observationWindowRef.current = observationWindowSec * 1000; - }, [observationWindowSec]); - - // Initialize simulation (3D — centered at origin) - useEffect(() => { - const sim = forceSimulation([]) - .numDimensions(3) - .force( - 'link', - forceLink([]) - .id((d) => d.id) - .distance(120) - .strength(0.3) - ) - .force( - 'charge', - forceManyBody() - .strength((d) => (d.id === 'self' ? -1200 : -200)) - .distanceMax(800) - ) - .force('center', forceCenter(0, 0, 0)) - .force( - 'selfX', - forceX(0).strength((d) => (d.id === 'self' ? 0.1 : 0)) - ) - .force( - 'selfY', - forceY(0).strength((d) => (d.id === 'self' ? 0.1 : 0)) - ) - .force( - 'selfZ', - forceZ(0).strength((d) => (d.id === 'self' ? 0.1 : 0)) - ) - .alphaDecay(0.02) - .velocityDecay(0.5) - .alphaTarget(0.03); - - simulationRef.current = sim; - return () => { - sim.stop(); - }; - }, []); - - // Update simulation forces when charge changes - useEffect(() => { - const sim = simulationRef.current; - if (!sim) return; - - sim.force( - 'charge', - forceManyBody() - .strength((d) => (d.id === 'self' ? chargeStrength * 6 : chargeStrength)) - .distanceMax(800) - ); - sim.alpha(0.3).restart(); - }, [chargeStrength]); - - // Update alphaTarget when drift preference changes - useEffect(() => { - const sim = simulationRef.current; - if (!sim) return; - sim.alphaTarget(letEmDrift ? 0.05 : 0); - }, [letEmDrift]); - - // Ensure self node exists - 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(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- syncSimulation is stable - }, [config]); - - const syncSimulation = useCallback(() => { - const sim = simulationRef.current; - if (!sim) return; - - const nodes = Array.from(nodesRef.current.values()); - const links = Array.from(linksRef.current.values()); - - sim.nodes(nodes); - const linkForce = sim.force('link') as ForceLink3D | undefined; - linkForce?.links(links); - - sim.alpha(0.15).restart(); - - setStats((prev) => - prev.nodes === nodes.length && prev.links === links.length - ? prev - : { ...prev, nodes: nodes.length, links: links.length } - ); - }, []); - - // Reset on option changes - useEffect(() => { - processedRef.current.clear(); - const selfNode = nodesRef.current.get('self'); - 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.clear(); - trafficPatternsRef.current.clear(); - setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 }); - syncSimulation(); - }, [ - showAmbiguousPaths, - showAmbiguousNodes, - useAdvertPathHints, - splitAmbiguousByTraffic, - syncSimulation, - ]); - - const addNode = useCallback( - ( - id: string, - name: string | null, - type: NodeType, - 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 { - // Initialize in 3D sphere around origin - 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) => { - const activityAt = activityAtMs ?? Date.now(); - const key = [sourceId, targetId].sort().join('->'); - const existing = linksRef.current.get(key); - if (existing) { - existing.lastActivity = Math.max(existing.lastActivity, activityAt); - } else { - linksRef.current.set(key, { source: sourceId, target: targetId, lastActivity: activityAt }); - } - }, []); - - const publishPacket = useCallback((packetKey: string) => { - const pending = pendingRef.current.get(packetKey); - if (!pending) return; - - pendingRef.current.delete(packetKey); - timersRef.current.delete(packetKey); - - // Skip particle creation when tab is hidden — nobody is watching, and - // creating them now would cause a burst of animations when the tab - // becomes visible again (since rAF is paused while hidden). - if (document.hidden) return; - - for (const path of pending.paths) { - const dedupedPath = dedupeConsecutive(path.nodes); - if (dedupedPath.length < 2) continue; - - for (let i = 0; i < dedupedPath.length - 1; i++) { - particlesRef.current.push({ - linkKey: [dedupedPath[i], dedupedPath[i + 1]].sort().join('->'), - progress: -i, - speed: PARTICLE_SPEED * speedMultiplierRef.current, - color: PARTICLE_COLOR_MAP[pending.label], - label: pending.label, - fromNodeId: dedupedPath[i], - toNodeId: dedupedPath[i + 1], - }); - } - } - }, []); - - 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; - - // Require stronger-than-trivial evidence and a clear winner. - 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; - } - - // type === 'prefix' - 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 - ): string[] => { - if (!parsed) return []; - const path: string[] = []; - let packetSource: string | null = null; - - if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) { - const nodeId = resolveNode( - { type: 'pubkey', value: parsed.advertPubkey }, - false, - false, - myPrefix, - activityAtMs - ); - if (nodeId) { - path.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) { - path.push(nodeId); - packetSource = nodeId; - } - } else if (parsed.payloadType === PayloadType.TextMessage && parsed.srcHash) { - if (myPrefix && parsed.srcHash.toLowerCase() === myPrefix) { - path.push('self'); - packetSource = 'self'; - } else { - const nodeId = resolveNode( - { type: 'prefix', value: parsed.srcHash }, - false, - showAmbiguousNodes, - myPrefix, - 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 resolved = resolveNode( - { type: 'name', value: senderName }, - false, - false, - myPrefix, - activityAtMs - ); - if (resolved) { - path.push(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 } - ); - if (nodeId) path.push(nodeId); - } - - if (parsed.payloadType === PayloadType.TextMessage && parsed.dstHash) { - if (myPrefix && parsed.dstHash.toLowerCase() === myPrefix) { - path.push('self'); - } else { - const nodeId = resolveNode( - { type: 'prefix', value: parsed.dstHash }, - false, - showAmbiguousNodes, - myPrefix, - activityAtMs - ); - if (nodeId) path.push(nodeId); - else path.push('self'); - } - } else if (path.length > 0) { - path.push('self'); - } - - if (path.length > 0 && path[path.length - 1] !== 'self') { - path.push('self'); - } - - return dedupeConsecutive(path); - }, - [resolveNode, showAmbiguousPaths, showAmbiguousNodes] - ); - - // Process packets - useEffect(() => { - let newProcessed = 0; - let newAnimated = 0; - let needsUpdate = false; - const myPrefix = config?.public_key?.slice(0, 12).toLowerCase() || null; - - for (const packet of packets) { - const observationKey = getRawPacketObservationKey(packet); - if (processedRef.current.has(observationKey)) continue; - processedRef.current.add(observationKey); - newProcessed++; - - if (processedRef.current.size > 1000) { - processedRef.current = new Set(Array.from(processedRef.current).slice(-500)); - } - - const parsed = parsePacket(packet.data); - if (!parsed) continue; - - const packetActivityAt = normalizePacketTimestampMs(packet.timestamp); - const path = buildPath(parsed, packet, myPrefix, packetActivityAt); - if (path.length < 2) continue; - - // Tag each node with why it's considered active - const label = getPacketLabel(parsed.payloadType); - for (let i = 0; i < path.length; i++) { - const n = nodesRef.current.get(path[i]); - if (n && n.id !== 'self') { - n.lastActivityReason = i === 0 ? `${label} source` : `Relayed ${label}`; - } - } - - for (let i = 0; i < path.length - 1; i++) { - if (path[i] !== path[i + 1]) { - addLink(path[i], path[i + 1], packetActivityAt); - needsUpdate = true; - } - } - - const packetKey = generatePacketKey(parsed, packet); - const now = Date.now(); - const existing = pendingRef.current.get(packetKey); - - if (existing && now < existing.expiresAt) { - existing.paths.push({ nodes: path, snr: packet.snr ?? null, timestamp: now }); - } else { - if (timersRef.current.has(packetKey)) { - clearTimeout(timersRef.current.get(packetKey)); - } - const windowMs = observationWindowRef.current; - pendingRef.current.set(packetKey, { - key: packetKey, - label: getPacketLabel(parsed.payloadType), - paths: [{ nodes: path, snr: packet.snr ?? null, timestamp: now }], - firstSeen: now, - expiresAt: now + windowMs, - }); - timersRef.current.set( - packetKey, - setTimeout(() => publishPacket(packetKey), windowMs) - ); - } - - if (pendingRef.current.size > 100) { - const entries = Array.from(pendingRef.current.entries()) - .sort((a, b) => a[1].firstSeen - b[1].firstSeen) - .slice(0, 50); - for (const [key] of entries) { - clearTimeout(timersRef.current.get(key)); - timersRef.current.delete(key); - pendingRef.current.delete(key); - } - } - - newAnimated++; - } - - if (needsUpdate) syncSimulation(); - if (newProcessed > 0) { - setStats((prev) => ({ - ...prev, - processed: prev.processed + newProcessed, - animated: prev.animated + newAnimated, - })); - } - }, [packets, config, buildPath, addLink, syncSimulation, publishPacket]); - - const expandContract = useCallback(() => { - const sim = simulationRef.current; - if (!sim) return; - - if (stretchRafRef.current !== null) { - cancelAnimationFrame(stretchRafRef.current); - stretchRafRef.current = null; - } - - const startChargeStrength = chargeStrength; - const peakChargeStrength = -5000; - const startLinkStrength = 0.3; - const minLinkStrength = 0.02; - const expandDuration = 1000; - const holdDuration = 2000; - const contractDuration = 1000; - const startTime = performance.now(); - - const animate = (now: number) => { - const elapsed = now - startTime; - let currentChargeStrength: number; - let currentLinkStrength: number; - - if (elapsed < expandDuration) { - const t = elapsed / expandDuration; - currentChargeStrength = - startChargeStrength + (peakChargeStrength - startChargeStrength) * t; - currentLinkStrength = startLinkStrength + (minLinkStrength - startLinkStrength) * t; - } else if (elapsed < expandDuration + holdDuration) { - currentChargeStrength = peakChargeStrength; - currentLinkStrength = minLinkStrength; - } else if (elapsed < expandDuration + holdDuration + contractDuration) { - const t = (elapsed - expandDuration - holdDuration) / contractDuration; - currentChargeStrength = peakChargeStrength + (startChargeStrength - peakChargeStrength) * t; - currentLinkStrength = minLinkStrength + (startLinkStrength - minLinkStrength) * t; - } else { - sim.force( - 'charge', - forceManyBody() - .strength((d) => (d.id === 'self' ? startChargeStrength * 6 : startChargeStrength)) - .distanceMax(800) - ); - sim.force( - 'link', - forceLink(Array.from(linksRef.current.values())) - .id((d) => d.id) - .distance(120) - .strength(startLinkStrength) - ); - sim.alpha(0.3).restart(); - stretchRafRef.current = null; - return; - } - - sim.force( - 'charge', - forceManyBody() - .strength((d) => (d.id === 'self' ? currentChargeStrength * 6 : currentChargeStrength)) - .distanceMax(800) - ); - sim.force( - 'link', - forceLink(Array.from(linksRef.current.values())) - .id((d) => d.id) - .distance(120) - .strength(currentLinkStrength) - ); - sim.alpha(0.5).restart(); - - stretchRafRef.current = requestAnimationFrame(animate); - }; - - stretchRafRef.current = requestAnimationFrame(animate); - }, [chargeStrength]); - - const clearAndReset = useCallback(() => { - if (stretchRafRef.current !== null) { - cancelAnimationFrame(stretchRafRef.current); - stretchRafRef.current = null; - } - - for (const timer of timersRef.current.values()) { - clearTimeout(timer); - } - timersRef.current.clear(); - pendingRef.current.clear(); - processedRef.current.clear(); - trafficPatternsRef.current.clear(); - particlesRef.current.length = 0; - linksRef.current.clear(); - - const selfNode = nodesRef.current.get('self'); - nodesRef.current.clear(); - 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); - } - - const sim = simulationRef.current; - if (sim) { - sim.nodes(Array.from(nodesRef.current.values())); - const linkForce = sim.force('link') as ForceLink3D | undefined; - linkForce?.links([]); - sim.alpha(0.3).restart(); - } - - setStats({ processed: 0, animated: 0, nodes: 1, links: 0 }); - }, []); - - useEffect(() => { - const stretchRaf = stretchRafRef; - const timers = timersRef.current; - const pending = pendingRef.current; - return () => { - if (stretchRaf.current !== null) { - cancelAnimationFrame(stretchRaf.current); - } - for (const timer of timers.values()) { - clearTimeout(timer); - } - timers.clear(); - pending.clear(); - }; - }, []); - - // Prune nodes with no recent activity - useEffect(() => { - if (!pruneStaleNodes) return; - - const STALE_MS = pruneStaleMinutes * 60 * 1000; - const PRUNE_INTERVAL_MS = 1_000; - - const interval = setInterval(() => { - const cutoff = Date.now() - STALE_MS; - 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) { - // Remove links that reference pruned nodes - for (const [key, link] of linksRef.current) { - const { sourceId, targetId } = getLinkId(link); - if (!nodesRef.current.has(sourceId) || !nodesRef.current.has(targetId)) { - linksRef.current.delete(key); - } - } - syncSimulation(); - } - }, PRUNE_INTERVAL_MS); - - return () => clearInterval(interval); - }, [pruneStaleNodes, pruneStaleMinutes, syncSimulation]); - - return useMemo( - () => ({ - nodes: nodesRef.current, - links: linksRef.current, - particles: particlesRef.current, - stats, - expandContract, - clearAndReset, - }), - [stats, expandContract, clearAndReset] - ); -} - -// ============================================================================= -// THREE.JS SCENE MANAGEMENT -// ============================================================================= - -interface NodeMeshData { - mesh: THREE.Mesh; - label: CSS2DObject; - labelDiv: HTMLDivElement; -} - -// ============================================================================= -// MAIN COMPONENT -// ============================================================================= +import { VisualizerControls } from './visualizer/VisualizerControls'; +import { VisualizerTooltip } from './visualizer/VisualizerTooltip'; +import { useVisualizerData3D } from './visualizer/useVisualizerData3D'; +import { useVisualizer3DScene } from './visualizer/useVisualizer3DScene'; interface PacketVisualizer3DProps { packets: RawPacket[]; @@ -1060,25 +24,7 @@ export function PacketVisualizer3D({ onFullScreenChange, }: PacketVisualizer3DProps) { const containerRef = useRef(null); - const rendererRef = useRef(null); - const cssRendererRef = useRef(null); - const sceneRef = useRef(null); - const cameraRef = useRef(null); - const controlsRef = useRef(null); - const nodeMeshesRef = useRef>(new Map()); - const raycastTargetsRef = useRef([]); - const linkLineRef = useRef(null); - const highlightLineRef = useRef(null); - const particlePointsRef = useRef(null); - const particleTextureRef = useRef(null); - const linkPositionBufferRef = useRef>(new Float32Array(0)); - const highlightPositionBufferRef = useRef>(new Float32Array(0)); - const particlePositionBufferRef = useRef>(new Float32Array(0)); - const particleColorBufferRef = useRef>(new Float32Array(0)); - const raycasterRef = useRef(new THREE.Raycaster()); - const mouseRef = useRef(new THREE.Vector2()); - // Options const [savedSettings] = useState(getVisualizerSettings); const [showAmbiguousPaths, setShowAmbiguousPaths] = useState(savedSettings.showAmbiguousPaths); const [showAmbiguousNodes, setShowAmbiguousNodes] = useState(savedSettings.showAmbiguousNodes); @@ -1100,7 +46,6 @@ export function PacketVisualizer3D({ const [pruneStaleMinutes, setPruneStaleMinutes] = useState(savedSettings.pruneStaleMinutes); const [repeaterAdvertPaths, setRepeaterAdvertPaths] = useState([]); - // Persist visualizer controls to localStorage on change useEffect(() => { saveVisualizerSettings({ ...getVisualizerSettings(), @@ -1143,7 +88,6 @@ export function PacketVisualizer3D({ } } catch (error) { if (!cancelled) { - // Best-effort hinting; keep visualizer fully functional without this data. console.debug('Failed to load repeater advert path hints', error); setRepeaterAdvertPaths([]); } @@ -1156,15 +100,6 @@ export function PacketVisualizer3D({ }; }, [contacts.length]); - // Hover & click-to-pin - const [hoveredNodeId, setHoveredNodeId] = useState(null); - const hoveredNodeIdRef = useRef(null); - const [hoveredNeighborIds, setHoveredNeighborIds] = useState([]); - const hoveredNeighborIdsRef = useRef([]); - const pinnedNodeIdRef = useRef(null); - const [pinnedNodeId, setPinnedNodeId] = useState(null); - - // Data layer const data = useVisualizerData3D({ packets, contacts, @@ -1181,549 +116,14 @@ export function PacketVisualizer3D({ pruneStaleNodes, pruneStaleMinutes, }); - const dataRef = useRef(data); - useEffect(() => { - dataRef.current = data; - }, [data]); - // Initialize Three.js scene - useEffect(() => { - const container = containerRef.current; - if (!container) return; + const { hoveredNodeId, hoveredNeighborIds, pinnedNodeId } = useVisualizer3DScene({ + containerRef, + data, + autoOrbit, + }); - // Scene - const scene = new THREE.Scene(); - scene.background = new THREE.Color(COLORS.background); - sceneRef.current = scene; - - // Camera - const camera = new THREE.PerspectiveCamera(60, 1, 1, 5000); - camera.position.set(0, 0, 400); - cameraRef.current = camera; - - // WebGL renderer - const renderer = new THREE.WebGLRenderer({ antialias: true }); - renderer.setPixelRatio(window.devicePixelRatio); - container.appendChild(renderer.domElement); - rendererRef.current = renderer; - - // Circular particle sprite texture (so particles render as circles, not squares) - const texSize = 64; - const texCanvas = document.createElement('canvas'); - texCanvas.width = texSize; - texCanvas.height = texSize; - const texCtx = texCanvas.getContext('2d')!; - const gradient = texCtx.createRadialGradient( - texSize / 2, - texSize / 2, - 0, - texSize / 2, - texSize / 2, - texSize / 2 - ); - gradient.addColorStop(0, 'rgba(255,255,255,1)'); - gradient.addColorStop(0.5, 'rgba(255,255,255,0.8)'); - gradient.addColorStop(1, 'rgba(255,255,255,0)'); - texCtx.fillStyle = gradient; - texCtx.fillRect(0, 0, texSize, texSize); - const particleTexture = new THREE.CanvasTexture(texCanvas); - particleTextureRef.current = particleTexture; - - // CSS2D renderer for text labels - const cssRenderer = new CSS2DRenderer(); - cssRenderer.domElement.style.position = 'absolute'; - cssRenderer.domElement.style.top = '0'; - cssRenderer.domElement.style.left = '0'; - cssRenderer.domElement.style.pointerEvents = 'none'; - cssRenderer.domElement.style.zIndex = '1'; - container.appendChild(cssRenderer.domElement); - cssRendererRef.current = cssRenderer; - - // OrbitControls - const controls = new OrbitControls(camera, renderer.domElement); - controls.enableDamping = true; - controls.dampingFactor = 0.1; - controls.minDistance = 50; - controls.maxDistance = 2000; - controlsRef.current = controls; - - // Persistent line meshes (their buffers are updated in-place each frame) - const linkGeometry = new THREE.BufferGeometry(); - const linkMaterial = new THREE.LineBasicMaterial({ - color: COLORS.link, - transparent: true, - opacity: 0.6, - }); - const linkSegments = new THREE.LineSegments(linkGeometry, linkMaterial); - linkSegments.visible = false; - scene.add(linkSegments); - linkLineRef.current = linkSegments; - - const highlightGeometry = new THREE.BufferGeometry(); - const highlightMaterial = new THREE.LineBasicMaterial({ - color: 0xffd700, - transparent: true, - opacity: 1.0, - linewidth: 2, - }); - const highlightSegments = new THREE.LineSegments(highlightGeometry, highlightMaterial); - highlightSegments.visible = false; - scene.add(highlightSegments); - highlightLineRef.current = highlightSegments; - - const particleGeometry = new THREE.BufferGeometry(); - const particleMaterial = new THREE.PointsMaterial({ - size: 20, - map: particleTexture, - vertexColors: true, - sizeAttenuation: true, - transparent: true, - opacity: 0.9, - depthWrite: false, - }); - const particlePoints = new THREE.Points(particleGeometry, particleMaterial); - particlePoints.visible = false; - scene.add(particlePoints); - particlePointsRef.current = particlePoints; - - // Initial sizing - const rect = container.getBoundingClientRect(); - renderer.setSize(rect.width, rect.height); - cssRenderer.setSize(rect.width, rect.height); - camera.aspect = rect.width / rect.height; - camera.updateProjectionMatrix(); - - // Resize observer - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - const { width, height } = entry.contentRect; - if (width === 0 || height === 0) continue; - renderer.setSize(width, height); - cssRenderer.setSize(width, height); - camera.aspect = width / height; - camera.updateProjectionMatrix(); - } - }); - observer.observe(container); - - const nodeMeshes = nodeMeshesRef.current; - return () => { - observer.disconnect(); - controls.dispose(); - renderer.dispose(); - // Remove renderer DOM elements - if (renderer.domElement.parentNode) { - renderer.domElement.parentNode.removeChild(renderer.domElement); - } - if (cssRenderer.domElement.parentNode) { - cssRenderer.domElement.parentNode.removeChild(cssRenderer.domElement); - } - // Clean up node meshes and their CSS2D label DOM elements - for (const nd of nodeMeshes.values()) { - nd.mesh.remove(nd.label); - nd.labelDiv.remove(); - scene.remove(nd.mesh); - nd.mesh.geometry.dispose(); - (nd.mesh.material as THREE.Material).dispose(); - } - nodeMeshes.clear(); - raycastTargetsRef.current = []; - - if (linkLineRef.current) { - scene.remove(linkLineRef.current); - linkLineRef.current.geometry.dispose(); - (linkLineRef.current.material as THREE.Material).dispose(); - linkLineRef.current = null; - } - if (highlightLineRef.current) { - scene.remove(highlightLineRef.current); - highlightLineRef.current.geometry.dispose(); - (highlightLineRef.current.material as THREE.Material).dispose(); - highlightLineRef.current = null; - } - if (particlePointsRef.current) { - scene.remove(particlePointsRef.current); - particlePointsRef.current.geometry.dispose(); - (particlePointsRef.current.material as THREE.Material).dispose(); - particlePointsRef.current = null; - } - particleTexture.dispose(); - particleTextureRef.current = null; - linkPositionBufferRef.current = new Float32Array(0); - highlightPositionBufferRef.current = new Float32Array(0); - particlePositionBufferRef.current = new Float32Array(0); - particleColorBufferRef.current = new Float32Array(0); - sceneRef.current = null; - cameraRef.current = null; - rendererRef.current = null; - cssRendererRef.current = null; - controlsRef.current = null; - }; - }, []); - - // Sync auto-orbit with OrbitControls - useEffect(() => { - const controls = controlsRef.current; - if (!controls) return; - controls.autoRotate = autoOrbit; - controls.autoRotateSpeed = -0.5; // negative = clockwise from above - }, [autoOrbit]); - - // Mouse handlers for raycasting and click-to-pin - useEffect(() => { - const renderer = rendererRef.current; - const camera = cameraRef.current; - if (!renderer || !camera) return; - - const onMouseMove = (event: MouseEvent) => { - const rect = renderer.domElement.getBoundingClientRect(); - mouseRef.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; - mouseRef.current.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; - }; - - let mouseDownPos = { x: 0, y: 0 }; - - const onMouseDown = (event: MouseEvent) => { - mouseDownPos = { x: event.clientX, y: event.clientY }; - }; - - const onMouseUp = (event: MouseEvent) => { - // Only count as click if mouse didn't move much (not a drag/orbit) - const dx = event.clientX - mouseDownPos.x; - const dy = event.clientY - mouseDownPos.y; - if (dx * dx + dy * dy > 25) return; - - const rect = renderer.domElement.getBoundingClientRect(); - const clickMouse = new THREE.Vector2( - ((event.clientX - rect.left) / rect.width) * 2 - 1, - -((event.clientY - rect.top) / rect.height) * 2 + 1 - ); - - const raycaster = raycasterRef.current; - raycaster.setFromCamera(clickMouse, camera); - const intersects = raycaster.intersectObjects(raycastTargetsRef.current, false); - const clickedObject = intersects[0]?.object as THREE.Mesh | undefined; - const clickedId = (clickedObject?.userData?.nodeId as string | undefined) ?? null; - - if (clickedId === pinnedNodeIdRef.current) { - // Unpin - pinnedNodeIdRef.current = null; - setPinnedNodeId(null); - } else if (clickedId) { - // Pin this node - pinnedNodeIdRef.current = clickedId; - setPinnedNodeId(clickedId); - } else { - // Clicked empty space — unpin - pinnedNodeIdRef.current = null; - setPinnedNodeId(null); - } - }; - - renderer.domElement.addEventListener('mousemove', onMouseMove); - renderer.domElement.addEventListener('mousedown', onMouseDown); - renderer.domElement.addEventListener('mouseup', onMouseUp); - return () => { - renderer.domElement.removeEventListener('mousemove', onMouseMove); - renderer.domElement.removeEventListener('mousedown', onMouseDown); - renderer.domElement.removeEventListener('mouseup', onMouseUp); - }; - }, []); - - // Animation loop - useEffect(() => { - const scene = sceneRef.current; - const camera = cameraRef.current; - const renderer = rendererRef.current; - const cssRenderer = cssRendererRef.current; - const controls = controlsRef.current; - if (!scene || !camera || !renderer || !cssRenderer || !controls) return; - - let running = true; - - const animate = () => { - if (!running) return; - requestAnimationFrame(animate); - - controls.update(); - - const { nodes, links, particles } = dataRef.current; - - // --- Sync node meshes --- - const currentNodeIds = new Set(); - - for (const node of nodes.values()) { - currentNodeIds.add(node.id); - - let nd = nodeMeshesRef.current.get(node.id); - if (!nd) { - const isSelf = node.type === 'self'; - const radius = isSelf ? 12 : 6; - const geometry = new THREE.SphereGeometry(radius, 16, 12); - const material = new THREE.MeshBasicMaterial({ color: getBaseNodeColor(node) }); - const mesh = new THREE.Mesh(geometry, material); - mesh.userData.nodeId = node.id; - scene.add(mesh); - - const labelDiv = document.createElement('div'); - labelDiv.style.color = node.isAmbiguous ? COLORS.ambiguous : '#e5e7eb'; - labelDiv.style.fontSize = '11px'; - labelDiv.style.fontFamily = 'sans-serif'; - labelDiv.style.textAlign = 'center'; - labelDiv.style.whiteSpace = 'nowrap'; - labelDiv.style.textShadow = '0 0 4px #000, 0 0 2px #000'; - const label = new CSS2DObject(labelDiv); - label.position.set(0, -(radius + 6), 0); - mesh.add(label); - - nd = { mesh, label, labelDiv }; - nodeMeshesRef.current.set(node.id, nd); - raycastTargetsRef.current.push(mesh); - } - - nd.mesh.position.set(node.x ?? 0, node.y ?? 0, node.z ?? 0); - const labelColor = node.isAmbiguous ? COLORS.ambiguous : '#e5e7eb'; - if (nd.labelDiv.style.color !== labelColor) { - nd.labelDiv.style.color = labelColor; - } - const labelText = node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8)); - if (nd.labelDiv.textContent !== labelText) { - nd.labelDiv.textContent = labelText; - } - } - - for (const [id, nd] of nodeMeshesRef.current) { - if (!currentNodeIds.has(id)) { - nd.mesh.remove(nd.label); - nd.labelDiv.remove(); - scene.remove(nd.mesh); - nd.mesh.geometry.dispose(); - (nd.mesh.material as THREE.Material).dispose(); - const meshIdx = raycastTargetsRef.current.indexOf(nd.mesh); - if (meshIdx >= 0) raycastTargetsRef.current.splice(meshIdx, 1); - nodeMeshesRef.current.delete(id); - } - } - - // --- Raycasting for hover --- - raycasterRef.current.setFromCamera(mouseRef.current, camera); - const intersects = raycasterRef.current.intersectObjects(raycastTargetsRef.current, false); - const hitObject = intersects[0]?.object as THREE.Mesh | undefined; - const hitId = (hitObject?.userData?.nodeId as string | undefined) ?? null; - if (hitId !== hoveredNodeIdRef.current) { - hoveredNodeIdRef.current = hitId; - setHoveredNodeId(hitId); - } - const activeId = pinnedNodeIdRef.current ?? hoveredNodeIdRef.current; - - // --- Sync links (buffers updated in-place) --- - const visibleLinks: GraphLink[] = []; - for (const link of links.values()) { - const { sourceId, targetId } = getLinkId(link); - if (currentNodeIds.has(sourceId) && currentNodeIds.has(targetId)) { - visibleLinks.push(link); - } - } - - const connectedIds = activeId ? new Set([activeId]) : null; - - const linkLine = linkLineRef.current; - if (linkLine) { - const geometry = linkLine.geometry as THREE.BufferGeometry; - const requiredLength = visibleLinks.length * 6; - if (linkPositionBufferRef.current.length < requiredLength) { - linkPositionBufferRef.current = growFloat32Buffer( - linkPositionBufferRef.current, - requiredLength - ); - geometry.setAttribute( - 'position', - new THREE.BufferAttribute(linkPositionBufferRef.current, 3).setUsage( - THREE.DynamicDrawUsage - ) - ); - } - - const highlightLine = highlightLineRef.current; - if (highlightLine && highlightPositionBufferRef.current.length < requiredLength) { - highlightPositionBufferRef.current = growFloat32Buffer( - highlightPositionBufferRef.current, - requiredLength - ); - (highlightLine.geometry as THREE.BufferGeometry).setAttribute( - 'position', - new THREE.BufferAttribute(highlightPositionBufferRef.current, 3).setUsage( - THREE.DynamicDrawUsage - ) - ); - } - - const positions = linkPositionBufferRef.current; - const hlPositions = highlightPositionBufferRef.current; - let idx = 0; - let hlIdx = 0; - - for (const link of visibleLinks) { - const { sourceId, targetId } = getLinkId(link); - const sNode = nodes.get(sourceId); - const tNode = nodes.get(targetId); - if (!sNode || !tNode) continue; - - const sx = sNode.x ?? 0; - const sy = sNode.y ?? 0; - const sz = sNode.z ?? 0; - const tx = tNode.x ?? 0; - const ty = tNode.y ?? 0; - const tz = tNode.z ?? 0; - - positions[idx++] = sx; - positions[idx++] = sy; - positions[idx++] = sz; - positions[idx++] = tx; - positions[idx++] = ty; - positions[idx++] = tz; - - if (activeId && (sourceId === activeId || targetId === activeId)) { - connectedIds?.add(sourceId === activeId ? targetId : sourceId); - hlPositions[hlIdx++] = sx; - hlPositions[hlIdx++] = sy; - hlPositions[hlIdx++] = sz; - hlPositions[hlIdx++] = tx; - hlPositions[hlIdx++] = ty; - hlPositions[hlIdx++] = tz; - } - } - - const positionAttr = geometry.getAttribute('position') as THREE.BufferAttribute | undefined; - if (positionAttr) { - positionAttr.needsUpdate = true; - } - geometry.setDrawRange(0, idx / 3); - linkLine.visible = idx > 0; - - if (highlightLine) { - const hlGeometry = highlightLine.geometry as THREE.BufferGeometry; - const hlAttr = hlGeometry.getAttribute('position') as THREE.BufferAttribute | undefined; - if (hlAttr) { - hlAttr.needsUpdate = true; - } - hlGeometry.setDrawRange(0, hlIdx / 3); - highlightLine.visible = hlIdx > 0; - } - } - - // --- Sync particles (buffers updated in-place) --- - let writeIdx = 0; - for (let readIdx = 0; readIdx < particles.length; readIdx++) { - const particle = particles[readIdx]; - particle.progress += particle.speed; - if (particle.progress <= 1) { - particles[writeIdx++] = particle; - } - } - particles.length = writeIdx; - - const particlePoints = particlePointsRef.current; - if (particlePoints) { - const geometry = particlePoints.geometry as THREE.BufferGeometry; - const requiredLength = particles.length * 3; - - if (particlePositionBufferRef.current.length < requiredLength) { - particlePositionBufferRef.current = growFloat32Buffer( - particlePositionBufferRef.current, - requiredLength - ); - geometry.setAttribute( - 'position', - new THREE.BufferAttribute(particlePositionBufferRef.current, 3).setUsage( - THREE.DynamicDrawUsage - ) - ); - } - if (particleColorBufferRef.current.length < requiredLength) { - particleColorBufferRef.current = growFloat32Buffer( - particleColorBufferRef.current, - requiredLength - ); - geometry.setAttribute( - 'color', - new THREE.BufferAttribute(particleColorBufferRef.current, 3).setUsage( - THREE.DynamicDrawUsage - ) - ); - } - - const pPositions = particlePositionBufferRef.current; - const pColors = particleColorBufferRef.current; - const color = new THREE.Color(); - let visibleCount = 0; - - for (const p of particles) { - if (p.progress < 0) continue; - if (!currentNodeIds.has(p.fromNodeId) || !currentNodeIds.has(p.toNodeId)) continue; - - const fromNode = nodes.get(p.fromNodeId); - const toNode = nodes.get(p.toNodeId); - if (!fromNode || !toNode) continue; - - const t = p.progress; - const x = (fromNode.x ?? 0) + ((toNode.x ?? 0) - (fromNode.x ?? 0)) * t; - const y = (fromNode.y ?? 0) + ((toNode.y ?? 0) - (fromNode.y ?? 0)) * t; - const z = (fromNode.z ?? 0) + ((toNode.z ?? 0) - (fromNode.z ?? 0)) * t; - - pPositions[visibleCount * 3] = x; - pPositions[visibleCount * 3 + 1] = y; - pPositions[visibleCount * 3 + 2] = z; - - color.set(p.color); - pColors[visibleCount * 3] = color.r; - pColors[visibleCount * 3 + 1] = color.g; - pColors[visibleCount * 3 + 2] = color.b; - visibleCount++; - } - - const posAttr = geometry.getAttribute('position') as THREE.BufferAttribute | undefined; - const colorAttr = geometry.getAttribute('color') as THREE.BufferAttribute | undefined; - if (posAttr) posAttr.needsUpdate = true; - if (colorAttr) colorAttr.needsUpdate = true; - geometry.setDrawRange(0, visibleCount); - particlePoints.visible = visibleCount > 0; - } - - // Sync neighbor info only when changed to avoid re-rendering every frame. - const nextNeighbors = connectedIds - ? Array.from(connectedIds) - .filter((id) => id !== activeId) - .sort() - : []; - if (!arraysEqual(hoveredNeighborIdsRef.current, nextNeighbors)) { - hoveredNeighborIdsRef.current = nextNeighbors; - setHoveredNeighborIds(nextNeighbors); - } - - // Highlight active node and neighbors - for (const [id, nd] of nodeMeshesRef.current) { - const node = nodes.get(id); - if (!node) continue; - const mat = nd.mesh.material as THREE.MeshBasicMaterial; - if (id === activeId) { - mat.color.set(0xffd700); - } else if (connectedIds?.has(id)) { - mat.color.set(0xfff0b3); - } else { - mat.color.set(getBaseNodeColor(node)); - } - } - - renderer.render(scene, camera); - cssRenderer.render(scene, camera); - }; - - animate(); - return () => { - running = false; - }; - }, []); + const tooltipNodeId = pinnedNodeId ?? hoveredNodeId; return (
- {/* Legend */} - {showControls && ( -
-
-
-
Packets
- {PACKET_LEGEND_ITEMS.map((item) => ( -
-
- {item.label} -
- {item.description} -
- ))} -
-
-
Nodes
- {NODE_LEGEND_ITEMS.map((item) => ( -
-
- {item.label} -
- ))} -
-
-
- )} + - {/* Options */} -
-
-
- - {onFullScreenChange && ( - - )} -
- {showControls && ( - <> -
- - - - -
- - - setObservationWindowSec( - Math.max(1, Math.min(60, parseInt(e.target.value) || 1)) - ) - } - className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center" - /> - sec -
-
- - {pruneStaleNodes && ( -
- - { - const v = parseInt(e.target.value, 10); - if (!isNaN(v) && v >= 1 && v <= 60) setPruneStaleMinutes(v); - }} - className="w-14 rounded border border-border bg-background px-2 py-0.5 text-sm" - /> - -
- )} - - -
- - setChargeStrength(-parseInt(e.target.value))} - className="w-full h-2 bg-border rounded-lg appearance-none cursor-pointer accent-primary" - /> -
-
- - setParticleSpeedMultiplier(parseFloat(e.target.value))} - className="w-full h-2 bg-border rounded-lg appearance-none cursor-pointer accent-primary" - /> -
-
- - -
-
-
Nodes: {data.stats.nodes}
-
Links: {data.stats.links}
-
- - )} -
-
- - {/* Hovered/pinned node tooltip */} - {(pinnedNodeId ?? hoveredNodeId) && ( -
- {(() => { - const tooltipNodeId = pinnedNodeId ?? hoveredNodeId; - const node = tooltipNodeId ? data.nodes.get(tooltipNodeId) : null; - if (!node) return null; - const neighbors = hoveredNeighborIds - .map((nid) => { - const n = data.nodes.get(nid); - if (!n) return null; - const displayName = n.name || (n.type === 'self' ? 'Me' : n.id.slice(0, 8)); - return { id: nid, name: displayName, ambiguousNames: n.ambiguousNames }; - }) - .filter(Boolean); - return ( -
-
- {node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8))} -
-
ID: {node.id}
-
- Type: {node.type} - {node.isAmbiguous ? ' (ambiguous)' : ''} -
- {node.probableIdentity && ( -
Probably: {node.probableIdentity}
- )} - {node.ambiguousNames && node.ambiguousNames.length > 0 && ( -
- {node.probableIdentity ? 'Other possible: ' : 'Possible: '} - {node.ambiguousNames.join(', ')} -
- )} - {node.type !== 'self' && ( -
-
Last active: {formatRelativeTime(node.lastActivity)}
- {node.lastActivityReason &&
Reason: {node.lastActivityReason}
} -
- )} - {neighbors.length > 0 && ( -
-
Traffic exchanged with:
-
    - {neighbors.map((nb) => ( -
  • - {nb!.name} - {nb!.ambiguousNames && nb!.ambiguousNames.length > 0 && ( - - {' '} - ({nb!.ambiguousNames.join(', ')}) - - )} -
  • - ))} -
-
- )} -
- ); - })()} -
- )} +
); } diff --git a/frontend/src/components/visualizer/VisualizerControls.tsx b/frontend/src/components/visualizer/VisualizerControls.tsx new file mode 100644 index 0000000..0c475fc --- /dev/null +++ b/frontend/src/components/visualizer/VisualizerControls.tsx @@ -0,0 +1,317 @@ +import { Checkbox } from '../ui/checkbox'; +import { PACKET_LEGEND_ITEMS } from '../../utils/visualizerUtils'; +import { NODE_LEGEND_ITEMS } from './shared'; + +interface VisualizerControlsProps { + showControls: boolean; + setShowControls: (value: boolean) => void; + fullScreen?: boolean; + onFullScreenChange?: (fullScreen: boolean) => void; + showAmbiguousPaths: boolean; + setShowAmbiguousPaths: (value: boolean) => void; + showAmbiguousNodes: boolean; + setShowAmbiguousNodes: (value: boolean) => void; + useAdvertPathHints: boolean; + setUseAdvertPathHints: (value: boolean) => void; + splitAmbiguousByTraffic: boolean; + setSplitAmbiguousByTraffic: (value: boolean) => void; + observationWindowSec: number; + setObservationWindowSec: (value: number) => void; + pruneStaleNodes: boolean; + setPruneStaleNodes: (value: boolean) => void; + pruneStaleMinutes: number; + setPruneStaleMinutes: (value: number) => void; + letEmDrift: boolean; + setLetEmDrift: (value: boolean) => void; + autoOrbit: boolean; + setAutoOrbit: (value: boolean) => void; + chargeStrength: number; + setChargeStrength: (value: number) => void; + particleSpeedMultiplier: number; + setParticleSpeedMultiplier: (value: number) => void; + nodeCount: number; + linkCount: number; + onExpandContract: () => void; + onClearAndReset: () => void; +} + +export function VisualizerControls({ + showControls, + setShowControls, + fullScreen, + onFullScreenChange, + showAmbiguousPaths, + setShowAmbiguousPaths, + showAmbiguousNodes, + setShowAmbiguousNodes, + useAdvertPathHints, + setUseAdvertPathHints, + splitAmbiguousByTraffic, + setSplitAmbiguousByTraffic, + observationWindowSec, + setObservationWindowSec, + pruneStaleNodes, + setPruneStaleNodes, + pruneStaleMinutes, + setPruneStaleMinutes, + letEmDrift, + setLetEmDrift, + autoOrbit, + setAutoOrbit, + chargeStrength, + setChargeStrength, + particleSpeedMultiplier, + setParticleSpeedMultiplier, + nodeCount, + linkCount, + onExpandContract, + onClearAndReset, +}: VisualizerControlsProps) { + return ( + <> + {showControls && ( +
+
+
+
Packets
+ {PACKET_LEGEND_ITEMS.map((item) => ( +
+
+ {item.label} +
+ {item.description} +
+ ))} +
+
+
Nodes
+ {NODE_LEGEND_ITEMS.map((item) => ( +
+
+ {item.label} +
+ ))} +
+
+
+ )} + +
+
+
+ + {onFullScreenChange && ( + + )} +
+ {showControls && ( + <> +
+ + + + +
+ + + setObservationWindowSec( + Math.max(1, Math.min(60, parseInt(e.target.value, 10) || 1)) + ) + } + className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center" + /> + sec +
+
+ + {pruneStaleNodes && ( +
+ + { + const v = parseInt(e.target.value, 10); + if (!isNaN(v) && v >= 1 && v <= 60) setPruneStaleMinutes(v); + }} + className="w-14 rounded border border-border bg-background px-2 py-0.5 text-sm" + /> + +
+ )} + + +
+ + setChargeStrength(-parseInt(e.target.value, 10))} + className="w-full h-2 bg-border rounded-lg appearance-none cursor-pointer accent-primary" + /> +
+
+ + setParticleSpeedMultiplier(parseFloat(e.target.value))} + className="w-full h-2 bg-border rounded-lg appearance-none cursor-pointer accent-primary" + /> +
+
+ + +
+
+
Nodes: {nodeCount}
+
Links: {linkCount}
+
+ + )} +
+
+ + ); +} diff --git a/frontend/src/components/visualizer/VisualizerTooltip.tsx b/frontend/src/components/visualizer/VisualizerTooltip.tsx new file mode 100644 index 0000000..0482a4a --- /dev/null +++ b/frontend/src/components/visualizer/VisualizerTooltip.tsx @@ -0,0 +1,73 @@ +import type { GraphNode } from './shared'; +import { formatRelativeTime } from './shared'; + +interface VisualizerTooltipProps { + activeNodeId: string | null; + nodes: Map; + neighborIds: string[]; +} + +export function VisualizerTooltip({ activeNodeId, nodes, neighborIds }: VisualizerTooltipProps) { + if (!activeNodeId) return null; + + const node = nodes.get(activeNodeId); + if (!node) return null; + + const neighbors = neighborIds + .map((nid) => { + const neighbor = nodes.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 }; + }) + .filter((neighbor): neighbor is NonNullable => neighbor !== null); + + return ( +
+
+
+ {node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8))} +
+
ID: {node.id}
+
+ Type: {node.type} + {node.isAmbiguous ? ' (ambiguous)' : ''} +
+ {node.probableIdentity && ( +
Probably: {node.probableIdentity}
+ )} + {node.ambiguousNames && node.ambiguousNames.length > 0 && ( +
+ {node.probableIdentity ? 'Other possible: ' : 'Possible: '} + {node.ambiguousNames.join(', ')} +
+ )} + {node.type !== 'self' && ( +
+
Last active: {formatRelativeTime(node.lastActivity)}
+ {node.lastActivityReason &&
Reason: {node.lastActivityReason}
} +
+ )} + {neighbors.length > 0 && ( +
+
Traffic exchanged with:
+
    + {neighbors.map((neighbor) => ( +
  • + {neighbor.name} + {neighbor.ambiguousNames && neighbor.ambiguousNames.length > 0 && ( + + {' '} + ({neighbor.ambiguousNames.join(', ')}) + + )} +
  • + ))} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/visualizer/shared.ts b/frontend/src/components/visualizer/shared.ts new file mode 100644 index 0000000..39d153b --- /dev/null +++ b/frontend/src/components/visualizer/shared.ts @@ -0,0 +1,83 @@ +import * as THREE from 'three'; +import type { SimulationLinkDatum } from 'd3-force'; +import type { SimulationNodeDatum3D } from 'd3-force-3d'; +import type { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; + +import type { NodeType } from '../../utils/visualizerUtils'; + +export interface GraphNode extends SimulationNodeDatum3D { + id: string; + name: string | null; + type: NodeType; + isAmbiguous: boolean; + lastActivity: number; + lastActivityReason?: string; + lastSeen?: number | null; + probableIdentity?: string | null; + ambiguousNames?: string[]; +} + +export interface GraphLink extends SimulationLinkDatum { + source: string | GraphNode; + target: string | GraphNode; + lastActivity: number; +} + +export interface NodeMeshData { + mesh: THREE.Mesh; + label: CSS2DObject; + labelDiv: HTMLDivElement; +} + +export const NODE_COLORS = { + self: 0x22c55e, + repeater: 0x3b82f6, + client: 0xffffff, + ambiguous: 0x9ca3af, +} as const; + +export const NODE_LEGEND_ITEMS = [ + { color: '#22c55e', label: 'You', size: 14 }, + { color: '#3b82f6', label: 'Repeater', size: 10 }, + { color: '#ffffff', label: 'Node', size: 10 }, + { color: '#9ca3af', label: 'Ambiguous', size: 10 }, +] as const; + +export function getBaseNodeColor(node: Pick): number { + if (node.type === 'self') return NODE_COLORS.self; + if (node.type === 'repeater') return NODE_COLORS.repeater; + return node.isAmbiguous ? NODE_COLORS.ambiguous : NODE_COLORS.client; +} + +export function growFloat32Buffer(current: Float32Array, requiredLength: number): Float32Array { + let nextLength = Math.max(12, current.length); + while (nextLength < requiredLength) { + nextLength *= 2; + } + return new Float32Array(nextLength); +} + +export function arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +export function formatRelativeTime(timestamp: number): string { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 5) return 'just now'; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + const secs = seconds % 60; + return secs > 0 ? `${minutes}m ${secs}s ago` : `${minutes}m ago`; +} + +export function normalizePacketTimestampMs(timestamp: number | null | undefined): number { + if (!Number.isFinite(timestamp) || !timestamp || timestamp <= 0) { + return Date.now(); + } + const ts = Number(timestamp); + return ts > 1_000_000_000_000 ? ts : ts * 1000; +} diff --git a/frontend/src/components/visualizer/useVisualizer3DScene.ts b/frontend/src/components/visualizer/useVisualizer3DScene.ts new file mode 100644 index 0000000..6bcab6e --- /dev/null +++ b/frontend/src/components/visualizer/useVisualizer3DScene.ts @@ -0,0 +1,578 @@ +import { useEffect, useRef, useState, type RefObject } from 'react'; +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { CSS2DObject, CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer.js'; + +import { COLORS, getLinkId } from '../../utils/visualizerUtils'; +import type { VisualizerData3D } from './useVisualizerData3D'; +import { arraysEqual, getBaseNodeColor, growFloat32Buffer, type NodeMeshData } from './shared'; + +interface UseVisualizer3DSceneArgs { + containerRef: RefObject; + data: VisualizerData3D; + autoOrbit: boolean; +} + +interface UseVisualizer3DSceneResult { + hoveredNodeId: string | null; + hoveredNeighborIds: string[]; + pinnedNodeId: string | null; +} + +export function useVisualizer3DScene({ + containerRef, + data, + autoOrbit, +}: UseVisualizer3DSceneArgs): UseVisualizer3DSceneResult { + const rendererRef = useRef(null); + const cssRendererRef = useRef(null); + const sceneRef = useRef(null); + const cameraRef = useRef(null); + const controlsRef = useRef(null); + const nodeMeshesRef = useRef>(new Map()); + const raycastTargetsRef = useRef([]); + const linkLineRef = useRef(null); + const highlightLineRef = useRef(null); + const particlePointsRef = useRef(null); + const particleTextureRef = useRef(null); + const linkPositionBufferRef = useRef(new Float32Array(0)); + const highlightPositionBufferRef = useRef(new Float32Array(0)); + const particlePositionBufferRef = useRef(new Float32Array(0)); + const particleColorBufferRef = useRef(new Float32Array(0)); + const raycasterRef = useRef(new THREE.Raycaster()); + const mouseRef = useRef(new THREE.Vector2()); + const dataRef = useRef(data); + + const [hoveredNodeId, setHoveredNodeId] = useState(null); + const hoveredNodeIdRef = useRef(null); + const [hoveredNeighborIds, setHoveredNeighborIds] = useState([]); + const hoveredNeighborIdsRef = useRef([]); + const pinnedNodeIdRef = useRef(null); + const [pinnedNodeId, setPinnedNodeId] = useState(null); + + useEffect(() => { + dataRef.current = data; + }, [data]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const scene = new THREE.Scene(); + scene.background = new THREE.Color(COLORS.background); + sceneRef.current = scene; + + const camera = new THREE.PerspectiveCamera(60, 1, 1, 5000); + camera.position.set(0, 0, 400); + cameraRef.current = camera; + + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setPixelRatio(window.devicePixelRatio); + container.appendChild(renderer.domElement); + rendererRef.current = renderer; + + const texSize = 64; + const texCanvas = document.createElement('canvas'); + texCanvas.width = texSize; + texCanvas.height = texSize; + const texCtx = texCanvas.getContext('2d'); + if (!texCtx) { + renderer.dispose(); + if (renderer.domElement.parentNode) { + renderer.domElement.parentNode.removeChild(renderer.domElement); + } + return; + } + const gradient = texCtx.createRadialGradient( + texSize / 2, + texSize / 2, + 0, + texSize / 2, + texSize / 2, + texSize / 2 + ); + gradient.addColorStop(0, 'rgba(255,255,255,1)'); + gradient.addColorStop(0.5, 'rgba(255,255,255,0.8)'); + gradient.addColorStop(1, 'rgba(255,255,255,0)'); + texCtx.fillStyle = gradient; + texCtx.fillRect(0, 0, texSize, texSize); + const particleTexture = new THREE.CanvasTexture(texCanvas); + particleTextureRef.current = particleTexture; + + const cssRenderer = new CSS2DRenderer(); + cssRenderer.domElement.style.position = 'absolute'; + cssRenderer.domElement.style.top = '0'; + cssRenderer.domElement.style.left = '0'; + cssRenderer.domElement.style.pointerEvents = 'none'; + cssRenderer.domElement.style.zIndex = '1'; + container.appendChild(cssRenderer.domElement); + cssRendererRef.current = cssRenderer; + + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.1; + controls.minDistance = 50; + controls.maxDistance = 2000; + controlsRef.current = controls; + + const linkGeometry = new THREE.BufferGeometry(); + const linkMaterial = new THREE.LineBasicMaterial({ + color: COLORS.link, + transparent: true, + opacity: 0.6, + }); + const linkSegments = new THREE.LineSegments(linkGeometry, linkMaterial); + linkSegments.visible = false; + scene.add(linkSegments); + linkLineRef.current = linkSegments; + + const highlightGeometry = new THREE.BufferGeometry(); + const highlightMaterial = new THREE.LineBasicMaterial({ + color: 0xffd700, + transparent: true, + opacity: 1, + linewidth: 2, + }); + const highlightSegments = new THREE.LineSegments(highlightGeometry, highlightMaterial); + highlightSegments.visible = false; + scene.add(highlightSegments); + highlightLineRef.current = highlightSegments; + + const particleGeometry = new THREE.BufferGeometry(); + const particleMaterial = new THREE.PointsMaterial({ + size: 20, + map: particleTexture, + vertexColors: true, + sizeAttenuation: true, + transparent: true, + opacity: 0.9, + depthWrite: false, + }); + const particlePoints = new THREE.Points(particleGeometry, particleMaterial); + particlePoints.visible = false; + scene.add(particlePoints); + particlePointsRef.current = particlePoints; + + const rect = container.getBoundingClientRect(); + renderer.setSize(rect.width, rect.height); + cssRenderer.setSize(rect.width, rect.height); + camera.aspect = rect.width / rect.height; + camera.updateProjectionMatrix(); + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + if (width === 0 || height === 0) continue; + renderer.setSize(width, height); + cssRenderer.setSize(width, height); + camera.aspect = width / height; + camera.updateProjectionMatrix(); + } + }); + observer.observe(container); + + const nodeMeshes = nodeMeshesRef.current; + return () => { + observer.disconnect(); + controls.dispose(); + renderer.dispose(); + if (renderer.domElement.parentNode) { + renderer.domElement.parentNode.removeChild(renderer.domElement); + } + if (cssRenderer.domElement.parentNode) { + cssRenderer.domElement.parentNode.removeChild(cssRenderer.domElement); + } + for (const nd of nodeMeshes.values()) { + nd.mesh.remove(nd.label); + nd.labelDiv.remove(); + scene.remove(nd.mesh); + nd.mesh.geometry.dispose(); + (nd.mesh.material as THREE.Material).dispose(); + } + nodeMeshes.clear(); + raycastTargetsRef.current = []; + + if (linkLineRef.current) { + scene.remove(linkLineRef.current); + linkLineRef.current.geometry.dispose(); + (linkLineRef.current.material as THREE.Material).dispose(); + linkLineRef.current = null; + } + if (highlightLineRef.current) { + scene.remove(highlightLineRef.current); + highlightLineRef.current.geometry.dispose(); + (highlightLineRef.current.material as THREE.Material).dispose(); + highlightLineRef.current = null; + } + if (particlePointsRef.current) { + scene.remove(particlePointsRef.current); + particlePointsRef.current.geometry.dispose(); + (particlePointsRef.current.material as THREE.Material).dispose(); + particlePointsRef.current = null; + } + particleTexture.dispose(); + particleTextureRef.current = null; + linkPositionBufferRef.current = new Float32Array(0); + highlightPositionBufferRef.current = new Float32Array(0); + particlePositionBufferRef.current = new Float32Array(0); + particleColorBufferRef.current = new Float32Array(0); + sceneRef.current = null; + cameraRef.current = null; + rendererRef.current = null; + cssRendererRef.current = null; + controlsRef.current = null; + }; + }, [containerRef]); + + useEffect(() => { + const controls = controlsRef.current; + if (!controls) return; + controls.autoRotate = autoOrbit; + controls.autoRotateSpeed = -0.5; + }, [autoOrbit]); + + useEffect(() => { + const renderer = rendererRef.current; + const camera = cameraRef.current; + if (!renderer || !camera) return; + + const onMouseMove = (event: MouseEvent) => { + const rect = renderer.domElement.getBoundingClientRect(); + mouseRef.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; + mouseRef.current.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; + }; + + let mouseDownPos = { x: 0, y: 0 }; + + const onMouseDown = (event: MouseEvent) => { + mouseDownPos = { x: event.clientX, y: event.clientY }; + }; + + const onMouseUp = (event: MouseEvent) => { + const dx = event.clientX - mouseDownPos.x; + const dy = event.clientY - mouseDownPos.y; + if (dx * dx + dy * dy > 25) return; + + const rect = renderer.domElement.getBoundingClientRect(); + const clickMouse = new THREE.Vector2( + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1 + ); + + const raycaster = raycasterRef.current; + raycaster.setFromCamera(clickMouse, camera); + const intersects = raycaster.intersectObjects(raycastTargetsRef.current, false); + const clickedObject = intersects[0]?.object as THREE.Mesh | undefined; + const clickedId = (clickedObject?.userData?.nodeId as string | undefined) ?? null; + + if (clickedId === pinnedNodeIdRef.current) { + pinnedNodeIdRef.current = null; + setPinnedNodeId(null); + } else if (clickedId) { + pinnedNodeIdRef.current = clickedId; + setPinnedNodeId(clickedId); + } else { + pinnedNodeIdRef.current = null; + setPinnedNodeId(null); + } + }; + + renderer.domElement.addEventListener('mousemove', onMouseMove); + renderer.domElement.addEventListener('mousedown', onMouseDown); + renderer.domElement.addEventListener('mouseup', onMouseUp); + return () => { + renderer.domElement.removeEventListener('mousemove', onMouseMove); + renderer.domElement.removeEventListener('mousedown', onMouseDown); + renderer.domElement.removeEventListener('mouseup', onMouseUp); + }; + }, []); + + useEffect(() => { + const scene = sceneRef.current; + const camera = cameraRef.current; + const renderer = rendererRef.current; + const cssRenderer = cssRendererRef.current; + const controls = controlsRef.current; + if (!scene || !camera || !renderer || !cssRenderer || !controls) return; + + let running = true; + + const animate = () => { + if (!running) return; + requestAnimationFrame(animate); + + controls.update(); + + const { nodes, links, particles } = dataRef.current; + const currentNodeIds = new Set(); + + for (const node of nodes.values()) { + currentNodeIds.add(node.id); + + let nd = nodeMeshesRef.current.get(node.id); + if (!nd) { + const isSelf = node.type === 'self'; + const radius = isSelf ? 12 : 6; + const geometry = new THREE.SphereGeometry(radius, 16, 12); + const material = new THREE.MeshBasicMaterial({ color: getBaseNodeColor(node) }); + const mesh = new THREE.Mesh(geometry, material); + mesh.userData.nodeId = node.id; + scene.add(mesh); + + const labelDiv = document.createElement('div'); + labelDiv.style.color = node.isAmbiguous ? COLORS.ambiguous : '#e5e7eb'; + labelDiv.style.fontSize = '11px'; + labelDiv.style.fontFamily = 'sans-serif'; + labelDiv.style.textAlign = 'center'; + labelDiv.style.whiteSpace = 'nowrap'; + labelDiv.style.textShadow = '0 0 4px #000, 0 0 2px #000'; + const label = new CSS2DObject(labelDiv); + label.position.set(0, -(radius + 6), 0); + mesh.add(label); + + nd = { mesh, label, labelDiv }; + nodeMeshesRef.current.set(node.id, nd); + raycastTargetsRef.current.push(mesh); + } + + nd.mesh.position.set(node.x ?? 0, node.y ?? 0, node.z ?? 0); + const labelColor = node.isAmbiguous ? COLORS.ambiguous : '#e5e7eb'; + if (nd.labelDiv.style.color !== labelColor) { + nd.labelDiv.style.color = labelColor; + } + const labelText = node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8)); + if (nd.labelDiv.textContent !== labelText) { + nd.labelDiv.textContent = labelText; + } + } + + for (const [id, nd] of nodeMeshesRef.current) { + if (!currentNodeIds.has(id)) { + nd.mesh.remove(nd.label); + nd.labelDiv.remove(); + scene.remove(nd.mesh); + nd.mesh.geometry.dispose(); + (nd.mesh.material as THREE.Material).dispose(); + const meshIdx = raycastTargetsRef.current.indexOf(nd.mesh); + if (meshIdx >= 0) raycastTargetsRef.current.splice(meshIdx, 1); + nodeMeshesRef.current.delete(id); + } + } + + raycasterRef.current.setFromCamera(mouseRef.current, camera); + const intersects = raycasterRef.current.intersectObjects(raycastTargetsRef.current, false); + const hitObject = intersects[0]?.object as THREE.Mesh | undefined; + const hitId = (hitObject?.userData?.nodeId as string | undefined) ?? null; + if (hitId !== hoveredNodeIdRef.current) { + hoveredNodeIdRef.current = hitId; + setHoveredNodeId(hitId); + } + const activeId = pinnedNodeIdRef.current ?? hoveredNodeIdRef.current; + + const visibleLinks = []; + for (const link of links.values()) { + const { sourceId, targetId } = getLinkId(link); + if (currentNodeIds.has(sourceId) && currentNodeIds.has(targetId)) { + visibleLinks.push(link); + } + } + + const connectedIds = activeId ? new Set([activeId]) : null; + + const linkLine = linkLineRef.current; + if (linkLine) { + const geometry = linkLine.geometry as THREE.BufferGeometry; + const requiredLength = visibleLinks.length * 6; + if (linkPositionBufferRef.current.length < requiredLength) { + linkPositionBufferRef.current = growFloat32Buffer( + linkPositionBufferRef.current, + requiredLength + ); + geometry.setAttribute( + 'position', + new THREE.BufferAttribute(linkPositionBufferRef.current, 3).setUsage( + THREE.DynamicDrawUsage + ) + ); + } + + const highlightLine = highlightLineRef.current; + if (highlightLine && highlightPositionBufferRef.current.length < requiredLength) { + highlightPositionBufferRef.current = growFloat32Buffer( + highlightPositionBufferRef.current, + requiredLength + ); + (highlightLine.geometry as THREE.BufferGeometry).setAttribute( + 'position', + new THREE.BufferAttribute(highlightPositionBufferRef.current, 3).setUsage( + THREE.DynamicDrawUsage + ) + ); + } + + const positions = linkPositionBufferRef.current; + const hlPositions = highlightPositionBufferRef.current; + let idx = 0; + let hlIdx = 0; + + for (const link of visibleLinks) { + const { sourceId, targetId } = getLinkId(link); + const sNode = nodes.get(sourceId); + const tNode = nodes.get(targetId); + if (!sNode || !tNode) continue; + + const sx = sNode.x ?? 0; + const sy = sNode.y ?? 0; + const sz = sNode.z ?? 0; + const tx = tNode.x ?? 0; + const ty = tNode.y ?? 0; + const tz = tNode.z ?? 0; + + positions[idx++] = sx; + positions[idx++] = sy; + positions[idx++] = sz; + positions[idx++] = tx; + positions[idx++] = ty; + positions[idx++] = tz; + + if (activeId && (sourceId === activeId || targetId === activeId)) { + connectedIds?.add(sourceId === activeId ? targetId : sourceId); + hlPositions[hlIdx++] = sx; + hlPositions[hlIdx++] = sy; + hlPositions[hlIdx++] = sz; + hlPositions[hlIdx++] = tx; + hlPositions[hlIdx++] = ty; + hlPositions[hlIdx++] = tz; + } + } + + const positionAttr = geometry.getAttribute('position') as THREE.BufferAttribute | undefined; + if (positionAttr) { + positionAttr.needsUpdate = true; + } + geometry.setDrawRange(0, idx / 3); + linkLine.visible = idx > 0; + + if (highlightLine) { + const hlGeometry = highlightLine.geometry as THREE.BufferGeometry; + const hlAttr = hlGeometry.getAttribute('position') as THREE.BufferAttribute | undefined; + if (hlAttr) { + hlAttr.needsUpdate = true; + } + hlGeometry.setDrawRange(0, hlIdx / 3); + highlightLine.visible = hlIdx > 0; + } + } + + let writeIdx = 0; + for (let readIdx = 0; readIdx < particles.length; readIdx++) { + const particle = particles[readIdx]; + particle.progress += particle.speed; + if (particle.progress <= 1) { + particles[writeIdx++] = particle; + } + } + particles.length = writeIdx; + + const particlePoints = particlePointsRef.current; + if (particlePoints) { + const geometry = particlePoints.geometry as THREE.BufferGeometry; + const requiredLength = particles.length * 3; + + if (particlePositionBufferRef.current.length < requiredLength) { + particlePositionBufferRef.current = growFloat32Buffer( + particlePositionBufferRef.current, + requiredLength + ); + geometry.setAttribute( + 'position', + new THREE.BufferAttribute(particlePositionBufferRef.current, 3).setUsage( + THREE.DynamicDrawUsage + ) + ); + } + if (particleColorBufferRef.current.length < requiredLength) { + particleColorBufferRef.current = growFloat32Buffer( + particleColorBufferRef.current, + requiredLength + ); + geometry.setAttribute( + 'color', + new THREE.BufferAttribute(particleColorBufferRef.current, 3).setUsage( + THREE.DynamicDrawUsage + ) + ); + } + + const pPositions = particlePositionBufferRef.current; + const pColors = particleColorBufferRef.current; + const color = new THREE.Color(); + let visibleCount = 0; + + for (const p of particles) { + if (p.progress < 0) continue; + if (!currentNodeIds.has(p.fromNodeId) || !currentNodeIds.has(p.toNodeId)) continue; + + const fromNode = nodes.get(p.fromNodeId); + const toNode = nodes.get(p.toNodeId); + if (!fromNode || !toNode) continue; + + const t = p.progress; + const x = (fromNode.x ?? 0) + ((toNode.x ?? 0) - (fromNode.x ?? 0)) * t; + const y = (fromNode.y ?? 0) + ((toNode.y ?? 0) - (fromNode.y ?? 0)) * t; + const z = (fromNode.z ?? 0) + ((toNode.z ?? 0) - (fromNode.z ?? 0)) * t; + + pPositions[visibleCount * 3] = x; + pPositions[visibleCount * 3 + 1] = y; + pPositions[visibleCount * 3 + 2] = z; + + color.set(p.color); + pColors[visibleCount * 3] = color.r; + pColors[visibleCount * 3 + 1] = color.g; + pColors[visibleCount * 3 + 2] = color.b; + visibleCount++; + } + + const posAttr = geometry.getAttribute('position') as THREE.BufferAttribute | undefined; + const colorAttr = geometry.getAttribute('color') as THREE.BufferAttribute | undefined; + if (posAttr) posAttr.needsUpdate = true; + if (colorAttr) colorAttr.needsUpdate = true; + geometry.setDrawRange(0, visibleCount); + particlePoints.visible = visibleCount > 0; + } + + const nextNeighbors = connectedIds + ? Array.from(connectedIds) + .filter((id) => id !== activeId) + .sort() + : []; + if (!arraysEqual(hoveredNeighborIdsRef.current, nextNeighbors)) { + hoveredNeighborIdsRef.current = nextNeighbors; + setHoveredNeighborIds(nextNeighbors); + } + + for (const [id, nd] of nodeMeshesRef.current) { + const node = nodes.get(id); + if (!node) continue; + const mat = nd.mesh.material as THREE.MeshBasicMaterial; + if (id === activeId) { + mat.color.set(0xffd700); + } else if (connectedIds?.has(id)) { + mat.color.set(0xfff0b3); + } else { + mat.color.set(getBaseNodeColor(node)); + } + } + + renderer.render(scene, camera); + cssRenderer.render(scene, camera); + }; + + animate(); + return () => { + running = false; + }; + }, []); + + return { hoveredNodeId, hoveredNeighborIds, pinnedNodeId }; +} diff --git a/frontend/src/components/visualizer/useVisualizerData3D.ts b/frontend/src/components/visualizer/useVisualizerData3D.ts new file mode 100644 index 0000000..8f415e2 --- /dev/null +++ b/frontend/src/components/visualizer/useVisualizerData3D.ts @@ -0,0 +1,922 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + forceCenter, + forceLink, + forceManyBody, + forceSimulation, + forceX, + forceY, + forceZ, + type ForceLink3D, + type Simulation3D, +} from 'd3-force-3d'; +import { PayloadType } from '@michaelhart/meshcore-decoder'; + +import { + CONTACT_TYPE_REPEATER, + type Contact, + type ContactAdvertPathSummary, + type RadioConfig, + type RawPacket, +} from '../../types'; +import { getRawPacketObservationKey } from '../../utils/rawPacketIdentity'; +import { + type Particle, + type PendingPacket, + type RepeaterTrafficData, + PARTICLE_COLOR_MAP, + PARTICLE_SPEED, + analyzeRepeaterTraffic, + buildAmbiguousRepeaterLabel, + buildAmbiguousRepeaterNodeId, + dedupeConsecutive, + generatePacketKey, + getNodeType, + getPacketLabel, + parsePacket, + recordTrafficObservation, +} from '../../utils/visualizerUtils'; +import { type GraphLink, type GraphNode, normalizePacketTimestampMs } from './shared'; + +export interface UseVisualizerData3DOptions { + packets: RawPacket[]; + contacts: Contact[]; + config: RadioConfig | null; + repeaterAdvertPaths: ContactAdvertPathSummary[]; + showAmbiguousPaths: boolean; + showAmbiguousNodes: boolean; + useAdvertPathHints: boolean; + splitAmbiguousByTraffic: boolean; + chargeStrength: number; + letEmDrift: boolean; + particleSpeedMultiplier: number; + observationWindowSec: number; + pruneStaleNodes: boolean; + pruneStaleMinutes: number; +} + +export interface VisualizerData3D { + nodes: Map; + links: Map; + particles: Particle[]; + stats: { processed: number; animated: number; nodes: number; links: number }; + expandContract: () => void; + clearAndReset: () => void; +} + +export function useVisualizerData3D({ + packets, + contacts, + config, + repeaterAdvertPaths, + showAmbiguousPaths, + showAmbiguousNodes, + useAdvertPathHints, + splitAmbiguousByTraffic, + chargeStrength, + letEmDrift, + particleSpeedMultiplier, + observationWindowSec, + pruneStaleNodes, + pruneStaleMinutes, +}: UseVisualizerData3DOptions): VisualizerData3D { + const nodesRef = useRef>(new Map()); + const linksRef = useRef>(new Map()); + const particlesRef = useRef([]); + const simulationRef = useRef | null>(null); + 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 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]); + + useEffect(() => { + speedMultiplierRef.current = particleSpeedMultiplier; + }, [particleSpeedMultiplier]); + + useEffect(() => { + observationWindowRef.current = observationWindowSec * 1000; + }, [observationWindowSec]); + + useEffect(() => { + const sim = forceSimulation([]) + .numDimensions(3) + .force( + 'link', + forceLink([]) + .id((d) => d.id) + .distance(120) + .strength(0.3) + ) + .force( + 'charge', + forceManyBody() + .strength((d) => (d.id === 'self' ? -1200 : -200)) + .distanceMax(800) + ) + .force('center', forceCenter(0, 0, 0)) + .force( + 'selfX', + forceX(0).strength((d) => (d.id === 'self' ? 0.1 : 0)) + ) + .force( + 'selfY', + forceY(0).strength((d) => (d.id === 'self' ? 0.1 : 0)) + ) + .force( + 'selfZ', + forceZ(0).strength((d) => (d.id === 'self' ? 0.1 : 0)) + ) + .alphaDecay(0.02) + .velocityDecay(0.5) + .alphaTarget(0.03); + + simulationRef.current = sim; + return () => { + sim.stop(); + }; + }, []); + + useEffect(() => { + const sim = simulationRef.current; + if (!sim) return; + + sim.force( + 'charge', + forceManyBody() + .strength((d) => (d.id === 'self' ? chargeStrength * 6 : chargeStrength)) + .distanceMax(800) + ); + sim.alpha(0.3).restart(); + }, [chargeStrength]); + + useEffect(() => { + const sim = simulationRef.current; + if (!sim) return; + sim.alphaTarget(letEmDrift ? 0.05 : 0); + }, [letEmDrift]); + + const syncSimulation = useCallback(() => { + const sim = simulationRef.current; + if (!sim) return; + + const nodes = Array.from(nodesRef.current.values()); + const links = Array.from(linksRef.current.values()); + + sim.nodes(nodes); + const linkForce = sim.force('link') as ForceLink3D | undefined; + linkForce?.links(links); + + sim.alpha(0.15).restart(); + + setStats((prev) => + prev.nodes === nodes.length && prev.links === links.length + ? prev + : { ...prev, nodes: nodes.length, links: links.length } + ); + }, []); + + 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(); + } + }, [config, syncSimulation]); + + useEffect(() => { + processedRef.current.clear(); + const selfNode = nodesRef.current.get('self'); + 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.clear(); + trafficPatternsRef.current.clear(); + setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 }); + syncSimulation(); + }, [ + showAmbiguousPaths, + showAmbiguousNodes, + useAdvertPathHints, + splitAmbiguousByTraffic, + syncSimulation, + ]); + + 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) => { + const activityAt = activityAtMs ?? Date.now(); + const key = [sourceId, targetId].sort().join('->'); + const existing = linksRef.current.get(key); + if (existing) { + existing.lastActivity = Math.max(existing.lastActivity, activityAt); + } else { + linksRef.current.set(key, { source: sourceId, target: targetId, lastActivity: activityAt }); + } + }, []); + + const publishPacket = useCallback((packetKey: string) => { + const pending = pendingRef.current.get(packetKey); + if (!pending) return; + + pendingRef.current.delete(packetKey); + timersRef.current.delete(packetKey); + + if (document.hidden) return; + + for (const path of pending.paths) { + const dedupedPath = dedupeConsecutive(path.nodes); + if (dedupedPath.length < 2) continue; + + for (let i = 0; i < dedupedPath.length - 1; i++) { + particlesRef.current.push({ + linkKey: [dedupedPath[i], dedupedPath[i + 1]].sort().join('->'), + progress: -i, + speed: PARTICLE_SPEED * speedMultiplierRef.current, + color: PARTICLE_COLOR_MAP[pending.label], + label: pending.label, + fromNodeId: dedupedPath[i], + toNodeId: dedupedPath[i + 1], + }); + } + } + }, []); + + 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 + ): string[] => { + if (!parsed) return []; + const path: string[] = []; + let packetSource: string | null = null; + + if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) { + const nodeId = resolveNode( + { type: 'pubkey', value: parsed.advertPubkey }, + false, + false, + myPrefix, + activityAtMs + ); + if (nodeId) { + path.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) { + path.push(nodeId); + packetSource = nodeId; + } + } else if (parsed.payloadType === PayloadType.TextMessage && parsed.srcHash) { + if (myPrefix && parsed.srcHash.toLowerCase() === myPrefix) { + path.push('self'); + packetSource = 'self'; + } else { + const nodeId = resolveNode( + { type: 'prefix', value: parsed.srcHash }, + false, + showAmbiguousNodes, + myPrefix, + 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 resolved = resolveNode( + { type: 'name', value: senderName }, + false, + false, + myPrefix, + activityAtMs + ); + if (resolved) { + path.push(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 } + ); + if (nodeId) path.push(nodeId); + } + + if (parsed.payloadType === PayloadType.TextMessage && parsed.dstHash) { + if (myPrefix && parsed.dstHash.toLowerCase() === myPrefix) { + path.push('self'); + } else { + const nodeId = resolveNode( + { type: 'prefix', value: parsed.dstHash }, + false, + showAmbiguousNodes, + myPrefix, + activityAtMs + ); + if (nodeId) path.push(nodeId); + else path.push('self'); + } + } else if (path.length > 0) { + path.push('self'); + } + + if (path.length > 0 && path[path.length - 1] !== 'self') { + path.push('self'); + } + + return dedupeConsecutive(path); + }, + [resolveNode, showAmbiguousPaths, showAmbiguousNodes] + ); + + useEffect(() => { + let newProcessed = 0; + let newAnimated = 0; + let needsUpdate = false; + const myPrefix = config?.public_key?.slice(0, 12).toLowerCase() || null; + + for (const packet of packets) { + const observationKey = getRawPacketObservationKey(packet); + if (processedRef.current.has(observationKey)) continue; + processedRef.current.add(observationKey); + newProcessed++; + + if (processedRef.current.size > 1000) { + processedRef.current = new Set(Array.from(processedRef.current).slice(-500)); + } + + const parsed = parsePacket(packet.data); + if (!parsed) continue; + + const packetActivityAt = normalizePacketTimestampMs(packet.timestamp); + const path = buildPath(parsed, packet, myPrefix, packetActivityAt); + if (path.length < 2) continue; + + const label = getPacketLabel(parsed.payloadType); + for (let i = 0; i < path.length; i++) { + const n = nodesRef.current.get(path[i]); + if (n && n.id !== 'self') { + n.lastActivityReason = i === 0 ? `${label} source` : `Relayed ${label}`; + } + } + + for (let i = 0; i < path.length - 1; i++) { + if (path[i] !== path[i + 1]) { + addLink(path[i], path[i + 1], packetActivityAt); + needsUpdate = true; + } + } + + const packetKey = generatePacketKey(parsed, packet); + const now = Date.now(); + const existing = pendingRef.current.get(packetKey); + + if (existing && now < existing.expiresAt) { + existing.paths.push({ nodes: path, snr: packet.snr ?? null, timestamp: now }); + } else { + const existingTimer = timersRef.current.get(packetKey); + if (existingTimer) { + clearTimeout(existingTimer); + } + const windowMs = observationWindowRef.current; + pendingRef.current.set(packetKey, { + key: packetKey, + label: getPacketLabel(parsed.payloadType), + paths: [{ nodes: path, snr: packet.snr ?? null, timestamp: now }], + firstSeen: now, + expiresAt: now + windowMs, + }); + timersRef.current.set( + packetKey, + setTimeout(() => publishPacket(packetKey), windowMs) + ); + } + + if (pendingRef.current.size > 100) { + const entries = Array.from(pendingRef.current.entries()) + .sort((a, b) => a[1].firstSeen - b[1].firstSeen) + .slice(0, 50); + for (const [key] of entries) { + const timer = timersRef.current.get(key); + if (timer) { + clearTimeout(timer); + } + timersRef.current.delete(key); + pendingRef.current.delete(key); + } + } + + newAnimated++; + } + + if (needsUpdate) syncSimulation(); + if (newProcessed > 0) { + setStats((prev) => ({ + ...prev, + processed: prev.processed + newProcessed, + animated: prev.animated + newAnimated, + })); + } + }, [packets, config, buildPath, addLink, syncSimulation, publishPacket]); + + const expandContract = useCallback(() => { + const sim = simulationRef.current; + if (!sim) return; + + if (stretchRafRef.current !== null) { + cancelAnimationFrame(stretchRafRef.current); + stretchRafRef.current = null; + } + + const startChargeStrength = chargeStrength; + const peakChargeStrength = -5000; + const startLinkStrength = 0.3; + const minLinkStrength = 0.02; + const expandDuration = 1000; + const holdDuration = 2000; + const contractDuration = 1000; + const startTime = performance.now(); + + const animate = (now: number) => { + const elapsed = now - startTime; + let currentChargeStrength: number; + let currentLinkStrength: number; + + if (elapsed < expandDuration) { + const t = elapsed / expandDuration; + currentChargeStrength = + startChargeStrength + (peakChargeStrength - startChargeStrength) * t; + currentLinkStrength = startLinkStrength + (minLinkStrength - startLinkStrength) * t; + } else if (elapsed < expandDuration + holdDuration) { + currentChargeStrength = peakChargeStrength; + currentLinkStrength = minLinkStrength; + } else if (elapsed < expandDuration + holdDuration + contractDuration) { + const t = (elapsed - expandDuration - holdDuration) / contractDuration; + currentChargeStrength = peakChargeStrength + (startChargeStrength - peakChargeStrength) * t; + currentLinkStrength = minLinkStrength + (startLinkStrength - minLinkStrength) * t; + } else { + sim.force( + 'charge', + forceManyBody() + .strength((d) => (d.id === 'self' ? startChargeStrength * 6 : startChargeStrength)) + .distanceMax(800) + ); + sim.force( + 'link', + forceLink(Array.from(linksRef.current.values())) + .id((d) => d.id) + .distance(120) + .strength(startLinkStrength) + ); + sim.alpha(0.3).restart(); + stretchRafRef.current = null; + return; + } + + sim.force( + 'charge', + forceManyBody() + .strength((d) => (d.id === 'self' ? currentChargeStrength * 6 : currentChargeStrength)) + .distanceMax(800) + ); + sim.force( + 'link', + forceLink(Array.from(linksRef.current.values())) + .id((d) => d.id) + .distance(120) + .strength(currentLinkStrength) + ); + sim.alpha(0.5).restart(); + + stretchRafRef.current = requestAnimationFrame(animate); + }; + + stretchRafRef.current = requestAnimationFrame(animate); + }, [chargeStrength]); + + const clearAndReset = useCallback(() => { + if (stretchRafRef.current !== null) { + cancelAnimationFrame(stretchRafRef.current); + stretchRafRef.current = null; + } + + for (const timer of timersRef.current.values()) { + clearTimeout(timer); + } + timersRef.current.clear(); + pendingRef.current.clear(); + processedRef.current.clear(); + trafficPatternsRef.current.clear(); + particlesRef.current.length = 0; + linksRef.current.clear(); + + const selfNode = nodesRef.current.get('self'); + nodesRef.current.clear(); + 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); + } + + const sim = simulationRef.current; + if (sim) { + sim.nodes(Array.from(nodesRef.current.values())); + const linkForce = sim.force('link') as ForceLink3D | undefined; + linkForce?.links([]); + sim.alpha(0.3).restart(); + } + + setStats({ processed: 0, animated: 0, nodes: 1, links: 0 }); + }, []); + + useEffect(() => { + const stretchRaf = stretchRafRef; + const timers = timersRef.current; + const pending = pendingRef.current; + return () => { + if (stretchRaf.current !== null) { + cancelAnimationFrame(stretchRaf.current); + } + for (const timer of timers.values()) { + clearTimeout(timer); + } + timers.clear(); + pending.clear(); + }; + }, []); + + useEffect(() => { + if (!pruneStaleNodes) return; + + const staleMs = pruneStaleMinutes * 60 * 1000; + const pruneIntervalMs = 1000; + + 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(); + } + }, pruneIntervalMs); + + return () => clearInterval(interval); + }, [pruneStaleNodes, pruneStaleMinutes, syncSimulation]); + + return useMemo( + () => ({ + nodes: nodesRef.current, + links: linksRef.current, + particles: particlesRef.current, + stats, + expandContract, + clearAndReset, + }), + [stats, expandContract, clearAndReset] + ); +} diff --git a/frontend/src/test/visualizerTooltip.test.tsx b/frontend/src/test/visualizerTooltip.test.tsx new file mode 100644 index 0000000..ed45851 --- /dev/null +++ b/frontend/src/test/visualizerTooltip.test.tsx @@ -0,0 +1,78 @@ +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'; + +function createNode(overrides: Partial & Pick): GraphNode { + 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, + }; +} + +describe('VisualizerTooltip', () => { + it('renders nothing without an active node', () => { + const { container } = render( + + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders ambiguous node details and neighbors', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-10T22:00:00Z')); + + const node = createNode({ + id: '?32', + type: 'repeater', + name: 'Likely Relay', + isAmbiguous: true, + probableIdentity: 'Likely Relay', + ambiguousNames: ['Relay A', 'Relay B'], + lastActivity: new Date('2026-03-10T21:58:30Z').getTime(), + lastActivityReason: 'Relayed GT', + }); + const neighbor = createNode({ + id: 'abcd1234ef56', + type: 'client', + name: 'Neighbor Node', + ambiguousNames: ['Alt Neighbor'], + }); + + render( + + ); + + expect(screen.getByText('Likely Relay')).toBeInTheDocument(); + expect(screen.getByText('ID: ?32')).toBeInTheDocument(); + expect(screen.getByText('Type: repeater (ambiguous)')).toBeInTheDocument(); + expect(screen.getByText('Probably: Likely Relay')).toBeInTheDocument(); + expect(screen.getByText('Other possible: Relay A, Relay B')).toBeInTheDocument(); + expect(screen.getByText('Last active: 1m 30s ago')).toBeInTheDocument(); + expect(screen.getByText('Reason: Relayed GT')).toBeInTheDocument(); + expect(screen.getByText('Neighbor Node')).toBeInTheDocument(); + expect(screen.getByText('(Alt Neighbor)')).toBeInTheDocument(); + + vi.useRealTimers(); + }); +});