Files
Remote-Terminal-for-MeshCore/frontend/src/components/visualizer/useVisualizerData3D.ts
2026-03-09 22:20:21 -07:00

923 lines
28 KiB
TypeScript

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<string, GraphNode>;
links: Map<string, GraphLink>;
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<Map<string, GraphNode>>(new Map());
const linksRef = useRef<Map<string, GraphLink>>(new Map());
const particlesRef = useRef<Particle[]>([]);
const simulationRef = useRef<Simulation3D<GraphNode, GraphLink> | null>(null);
const processedRef = useRef<Set<string>>(new Set());
const pendingRef = useRef<Map<string, PendingPacket>>(new Map());
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
const trafficPatternsRef = useRef<Map<string, RepeaterTrafficData>>(new Map());
const speedMultiplierRef = useRef(particleSpeedMultiplier);
const observationWindowRef = useRef(observationWindowSec * 1000);
const stretchRafRef = useRef<number | null>(null);
const [stats, setStats] = useState({ processed: 0, animated: 0, nodes: 0, links: 0 });
const contactIndex = useMemo(() => {
const byPrefix12 = new Map<string, Contact>();
const byName = new Map<string, Contact>();
const byPrefix = new Map<string, Contact[]>();
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<string, ContactAdvertPathSummary['paths']>();
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<GraphNode, GraphLink>([])
.numDimensions(3)
.force(
'link',
forceLink<GraphNode, GraphLink>([])
.id((d) => d.id)
.distance(120)
.strength(0.3)
)
.force(
'charge',
forceManyBody<GraphNode>()
.strength((d) => (d.id === 'self' ? -1200 : -200))
.distanceMax(800)
)
.force('center', forceCenter(0, 0, 0))
.force(
'selfX',
forceX<GraphNode>(0).strength((d) => (d.id === 'self' ? 0.1 : 0))
)
.force(
'selfY',
forceY<GraphNode>(0).strength((d) => (d.id === 'self' ? 0.1 : 0))
)
.force(
'selfZ',
forceZ<GraphNode>(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<GraphNode>()
.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<GraphNode, GraphLink> | 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<typeof parsePacket>,
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<GraphNode>()
.strength((d) => (d.id === 'self' ? startChargeStrength * 6 : startChargeStrength))
.distanceMax(800)
);
sim.force(
'link',
forceLink<GraphNode, GraphLink>(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<GraphNode>()
.strength((d) => (d.id === 'self' ? currentChargeStrength * 6 : currentChargeStrength))
.distanceMax(800)
);
sim.force(
'link',
forceLink<GraphNode, GraphLink>(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<GraphNode, GraphLink> | 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]
);
}