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 { api } from '../api'; import { CONTACT_TYPE_REPEATER, type Contact, type RawPacket, type RadioConfig, type RepeaterAdvertPathSummary, } from '../types'; import { getRawPacketObservationKey } from '../utils/rawPacketIdentity'; import { Checkbox } from './ui/checkbox'; import { type NodeType, type Particle, type PendingPacket, type RepeaterTrafficData, COLORS, PARTICLE_COLOR_MAP, PARTICLE_SPEED, DEFAULT_OBSERVATION_WINDOW_SEC, 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; 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; } // ============================================================================= // DATA LAYER HOOK (3D variant) // ============================================================================= interface UseVisualizerData3DOptions { packets: RawPacket[]; contacts: Contact[]; config: RadioConfig | null; repeaterAdvertPaths: RepeaterAdvertPathSummary[]; showAmbiguousPaths: boolean; showAmbiguousNodes: boolean; useAdvertPathHints: boolean; splitAmbiguousByTraffic: boolean; chargeStrength: number; letEmDrift: boolean; particleSpeedMultiplier: number; observationWindowSec: number; pruneStaleNodes: boolean; } 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, }: 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.repeater_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 ) => { const existing = nodesRef.current.get(id); if (existing) { existing.lastActivity = Date.now(); 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: Date.now(), 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) => { const key = [sourceId, targetId].sort().join('->'); const existing = linksRef.current.get(key); if (existing) { existing.lastActivity = Date.now(); } else { linksRef.current.set(key, { source: sourceId, target: targetId, lastActivity: Date.now() }); } }, []); const publishPacket = useCallback((packetKey: string) => { const pending = pendingRef.current.get(packetKey); if (!pending) return; pendingRef.current.delete(packetKey); timersRef.current.delete(packetKey); 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, 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 ); 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 ); return nodeId; } const nodeId = `name:${source.value}`; addNode(nodeId, source.value, 'client', false, undefined); return nodeId; } // type === 'prefix' const matches = contactIndex.byPrefix.get(source.value.toLowerCase()) ?? []; 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 ); 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); 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 = `?${source.value.toLowerCase()}`; let displayName = source.value.toUpperCase(); let probableIdentity: string | null = null; let ambiguousNames = names.length > 0 ? names : undefined; if (useAdvertPathHints && isRepeater && trafficContext) { const likely = pickLikelyRepeaterByAdvertPath(filtered, trafficContext.nextPrefix); 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 prefix = source.value.toLowerCase(); if (trafficContext.packetSource) { recordTrafficObservation( trafficPatternsRef.current, prefix, trafficContext.packetSource, trafficContext.nextPrefix ); } const trafficData = trafficPatternsRef.current.get(prefix); if (trafficData) { const analysis = analyzeRepeaterTraffic(trafficData); if (analysis.shouldSplit && trafficContext.nextPrefix) { const nextShort = trafficContext.nextPrefix.slice(0, 2).toLowerCase(); nodeId = `?${prefix}:>${nextShort}`; if (!probableIdentity) { displayName = `${source.value.toUpperCase()}:>${nextShort}`; } } } } addNode( nodeId, displayName, isRepeater ? 'repeater' : 'client', true, probableIdentity, ambiguousNames, lastSeen ); return nodeId; } } return null; }, [ contactIndex, addNode, useAdvertPathHints, pickLikelyRepeaterByAdvertPath, splitAmbiguousByTraffic, ] ); const buildPath = useCallback( ( parsed: ReturnType, packet: RawPacket, myPrefix: string | null ): 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 ); 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 ); 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 ); if (nodeId) { path.push(nodeId); packetSource = nodeId; } } } else if (parsed.payloadType === PayloadType.GroupText) { const senderName = parsed.groupTextSender || packet.decrypted_info?.sender; if (senderName) { const nodeId = resolveNode({ type: 'name', value: senderName }, false, false, myPrefix); if (nodeId) { path.push(nodeId); packetSource = nodeId; } } } 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, { 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 ); 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 path = buildPath(parsed, packet, myPrefix); if (path.length < 2) continue; for (let i = 0; i < path.length - 1; i++) { if (path[i] !== path[i + 1]) { addLink(path[i], path[i + 1]); 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 activity in the last 5 minutes useEffect(() => { if (!pruneStaleNodes) return; const STALE_MS = 5 * 60 * 1000; const PRUNE_INTERVAL_MS = 10_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, 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 // ============================================================================= interface PacketVisualizer3DProps { packets: RawPacket[]; contacts: Contact[]; config: RadioConfig | null; fullScreen?: boolean; onFullScreenChange?: (fullScreen: boolean) => void; } export function PacketVisualizer3D({ packets, contacts, config, fullScreen, 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 [showAmbiguousPaths, setShowAmbiguousPaths] = useState(true); const [showAmbiguousNodes, setShowAmbiguousNodes] = useState(false); const [useAdvertPathHints, setUseAdvertPathHints] = useState(true); const [splitAmbiguousByTraffic, setSplitAmbiguousByTraffic] = useState(true); const [chargeStrength, setChargeStrength] = useState(-200); const [observationWindowSec, setObservationWindowSec] = useState(DEFAULT_OBSERVATION_WINDOW_SEC); const [letEmDrift, setLetEmDrift] = useState(true); const [particleSpeedMultiplier, setParticleSpeedMultiplier] = useState(2); const [showControls, setShowControls] = useState(true); const [autoOrbit, setAutoOrbit] = useState(false); const [pruneStaleNodes, setPruneStaleNodes] = useState(false); const [repeaterAdvertPaths, setRepeaterAdvertPaths] = useState([]); useEffect(() => { let cancelled = false; async function loadRepeaterAdvertPaths() { try { const data = await api.getRepeaterAdvertPaths(10); if (!cancelled) { setRepeaterAdvertPaths(data); } } 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([]); } } } loadRepeaterAdvertPaths(); return () => { cancelled = true; }; }, [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, config, repeaterAdvertPaths, showAmbiguousPaths, showAmbiguousNodes, useAdvertPathHints, splitAmbiguousByTraffic, chargeStrength, letEmDrift, particleSpeedMultiplier, observationWindowSec, pruneStaleNodes, }); const dataRef = useRef(data); useEffect(() => { dataRef.current = data; }, [data]); // Initialize Three.js scene useEffect(() => { const container = containerRef.current; if (!container) return; // 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; }; }, []); return (
{/* Legend */} {showControls && (
Packets
{PACKET_LEGEND_ITEMS.map((item) => (
{item.label}
{item.description}
))}
Nodes
{NODE_LEGEND_ITEMS.map((item) => (
{item.label}
))}
)} {/* Options */}
{showControls && ( <>
Nodes: {data.stats.nodes}
Links: {data.stats.links}
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
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" />
)}
{onFullScreenChange && ( )}
{/* 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(', ')}
)} {neighbors.length > 0 && (
Traffic exchanged with:
    {neighbors.map((nb) => (
  • {nb!.name} {nb!.ambiguousNames && nb!.ambiguousNames.length > 0 && ( {' '} ({nb!.ambiguousNames.join(', ')}) )}
  • ))}
)}
); })()}
)}
); }