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] ); }