Files
Remote-Terminal-for-MeshCore/frontend/src/components/PacketVisualizer.tsx
2026-02-07 21:31:12 -08:00

1711 lines
57 KiB
TypeScript

import { useEffect, useRef, useState, useCallback } from 'react';
import {
forceSimulation,
forceLink,
forceManyBody,
forceCenter,
forceCollide,
forceX,
forceY,
type Simulation,
type SimulationNodeDatum,
type SimulationLinkDatum,
} from 'd3-force';
import { MeshCoreDecoder, PayloadType } from '@michaelhart/meshcore-decoder';
import { CONTACT_TYPE_REPEATER, type Contact, type RawPacket, type RadioConfig } from '../types';
import { Checkbox } from './ui/checkbox';
// =============================================================================
// TYPES
// =============================================================================
type NodeType = 'self' | 'repeater' | 'client';
type PacketLabel = 'AD' | 'GT' | 'DM' | 'ACK' | 'TR' | 'RQ' | 'RS' | '?';
interface GraphNode extends SimulationNodeDatum {
id: string;
name: string | null;
type: NodeType;
isAmbiguous: boolean;
lastActivity: number;
lastSeen?: number | null;
ambiguousNames?: string[];
x?: number;
y?: number;
vx?: number;
vy?: number;
fx?: number | null;
fy?: number | null;
}
interface GraphLink extends SimulationLinkDatum<GraphNode> {
source: string | GraphNode;
target: string | GraphNode;
lastActivity: number;
}
interface Particle {
linkKey: string;
progress: number;
speed: number;
color: string;
label: PacketLabel;
fromNodeId: string;
toNodeId: string;
}
interface ObservedPath {
nodes: string[];
snr: number | null;
timestamp: number;
}
interface PendingPacket {
key: string;
label: PacketLabel;
paths: ObservedPath[];
firstSeen: number;
expiresAt: number;
}
interface ParsedPacket {
payloadType: number;
pathBytes: string[];
srcHash: string | null;
dstHash: string | null;
advertPubkey: string | null;
groupTextSender: string | null;
anonRequestPubkey: string | null;
}
// Traffic pattern tracking for smarter repeater disambiguation
interface TrafficObservation {
source: string; // Node that originated traffic (could be resolved node ID or ambiguous)
nextHop: string | null; // Next hop after this repeater (null if final hop before self)
timestamp: number;
}
interface RepeaterTrafficData {
prefix: string; // The 1-byte hex prefix (e.g., "32")
observations: TrafficObservation[];
}
// Analysis result for whether to split an ambiguous repeater
interface RepeaterSplitAnalysis {
shouldSplit: boolean;
// If shouldSplit, maps nextHop -> the sources that exclusively route through it
disjointGroups: Map<string, Set<string>> | null;
}
// =============================================================================
// CONSTANTS
// =============================================================================
const COLORS = {
background: '#0a0a0a',
link: '#4b5563',
ambiguous: '#9ca3af',
particleAD: '#f59e0b', // amber - advertisements
particleGT: '#06b6d4', // cyan - group text
particleDM: '#8b5cf6', // purple - direct messages
particleACK: '#22c55e', // green - acknowledgments
particleTR: '#f97316', // orange - trace packets
particleRQ: '#ec4899', // pink - requests
particleRS: '#14b8a6', // teal - responses
particleUnknown: '#6b7280', // gray - unknown
} as const;
const PARTICLE_COLOR_MAP: Record<PacketLabel, string> = {
AD: COLORS.particleAD,
GT: COLORS.particleGT,
DM: COLORS.particleDM,
ACK: COLORS.particleACK,
TR: COLORS.particleTR,
RQ: COLORS.particleRQ,
RS: COLORS.particleRS,
'?': COLORS.particleUnknown,
};
const PARTICLE_SPEED = 0.008;
const DEFAULT_OBSERVATION_WINDOW_SEC = 15;
const FORTY_EIGHT_HOURS_MS = 48 * 60 * 60 * 1000;
// Traffic pattern analysis thresholds
// Be conservative - once split, we can't unsplit, so require strong evidence
const MIN_OBSERVATIONS_TO_SPLIT = 20; // Need at least this many unique sources per next-hop group
const MAX_TRAFFIC_OBSERVATIONS = 200; // Per ambiguous prefix, to limit memory
const TRAFFIC_OBSERVATION_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes - old observations are pruned
const LEGEND_ITEMS = [
{ emoji: '🟢', label: 'You', size: 'text-xl' },
{ emoji: '📡', label: 'Repeater', size: 'text-base' },
{ emoji: '👤', label: 'Node', size: 'text-base' },
{ emoji: '❓', label: 'Unknown', size: 'text-base' },
] as const;
const PACKET_LEGEND_ITEMS = [
{ label: 'AD', color: COLORS.particleAD, description: 'Advertisement' },
{ label: 'GT', color: COLORS.particleGT, description: 'Group Text' },
{ label: 'DM', color: COLORS.particleDM, description: 'Direct Message' },
{ label: 'ACK', color: COLORS.particleACK, description: 'Acknowledgment' },
{ label: 'TR', color: COLORS.particleTR, description: 'Trace' },
{ label: 'RQ', color: COLORS.particleRQ, description: 'Request' },
{ label: 'RS', color: COLORS.particleRS, description: 'Response' },
{ label: '?', color: COLORS.particleUnknown, description: 'Other' },
] as const;
// =============================================================================
// UTILITY FUNCTIONS (Data Layer)
// =============================================================================
function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash = hash & hash;
}
return Math.abs(hash).toString(16).padStart(8, '0');
}
function parsePacket(hexData: string): ParsedPacket | null {
try {
const decoded = MeshCoreDecoder.decode(hexData);
if (!decoded.isValid) return null;
const result: ParsedPacket = {
payloadType: decoded.payloadType,
pathBytes: decoded.path || [],
srcHash: null,
dstHash: null,
advertPubkey: null,
groupTextSender: null,
anonRequestPubkey: null,
};
if (decoded.payloadType === PayloadType.TextMessage && decoded.payload.decoded) {
const payload = decoded.payload.decoded as { sourceHash?: string; destinationHash?: string };
result.srcHash = payload.sourceHash || null;
result.dstHash = payload.destinationHash || null;
} else if (decoded.payloadType === PayloadType.Advert && decoded.payload.decoded) {
result.advertPubkey = (decoded.payload.decoded as { publicKey?: string }).publicKey || null;
} else if (decoded.payloadType === PayloadType.GroupText && decoded.payload.decoded) {
const payload = decoded.payload.decoded as { decrypted?: { sender?: string } };
result.groupTextSender = payload.decrypted?.sender || null;
} else if (decoded.payloadType === PayloadType.AnonRequest && decoded.payload.decoded) {
const payload = decoded.payload.decoded as { senderPublicKey?: string };
result.anonRequestPubkey = payload.senderPublicKey || null;
}
return result;
} catch {
return null;
}
}
function getPacketLabel(payloadType: number): PacketLabel {
switch (payloadType) {
case PayloadType.Advert:
return 'AD';
case PayloadType.GroupText:
return 'GT';
case PayloadType.TextMessage:
return 'DM';
case PayloadType.Ack:
return 'ACK';
case PayloadType.Trace:
return 'TR';
case PayloadType.Request:
case PayloadType.AnonRequest:
return 'RQ';
case PayloadType.Response:
return 'RS';
default:
return '?';
}
}
function generatePacketKey(parsed: ParsedPacket, rawPacket: RawPacket): string {
const contentHash = simpleHash(rawPacket.data).slice(0, 8);
if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) {
return `ad:${parsed.advertPubkey.slice(0, 12)}`;
}
if (parsed.payloadType === PayloadType.GroupText) {
const sender = parsed.groupTextSender || rawPacket.decrypted_info?.sender || '?';
const channel = rawPacket.decrypted_info?.channel_name || '?';
return `gt:${channel}:${sender}:${contentHash}`;
}
if (parsed.payloadType === PayloadType.TextMessage) {
return `dm:${parsed.srcHash || '?'}:${parsed.dstHash || '?'}:${contentHash}`;
}
if (parsed.payloadType === PayloadType.AnonRequest && parsed.anonRequestPubkey) {
return `rq:${parsed.anonRequestPubkey.slice(0, 12)}:${contentHash}`;
}
return `other:${contentHash}`;
}
function getLinkId(link: GraphLink): { sourceId: string; targetId: string } {
return {
sourceId: typeof link.source === 'string' ? link.source : link.source.id,
targetId: typeof link.target === 'string' ? link.target : link.target.id,
};
}
function findContactByPrefix(prefix: string, contacts: Contact[]): Contact | null {
const normalized = prefix.toLowerCase();
const matches = contacts.filter((c) => c.public_key.toLowerCase().startsWith(normalized));
return matches.length === 1 ? matches[0] : null;
}
function findContactsByPrefix(prefix: string, contacts: Contact[]): Contact[] {
const normalized = prefix.toLowerCase();
return contacts.filter((c) => c.public_key.toLowerCase().startsWith(normalized));
}
function findContactByName(name: string, contacts: Contact[]): Contact | null {
return contacts.find((c) => c.name === name) || null;
}
function getNodeType(contact: Contact | null | undefined): NodeType {
return contact?.type === CONTACT_TYPE_REPEATER ? 'repeater' : 'client';
}
function dedupeConsecutive<T>(arr: T[]): T[] {
return arr.filter((item, i) => i === 0 || item !== arr[i - 1]);
}
/**
* Analyze traffic patterns for an ambiguous repeater prefix to determine if it
* should be split into multiple nodes.
*
* Logic:
* - Group observations by nextHop
* - For each nextHop group, collect the set of sources
* - If any source appears in multiple nextHop groups → same physical node (hub), don't split
* - If source sets are completely disjoint → likely different physical nodes, split
*
* Returns shouldSplit=true only when we have enough evidence of disjoint routing.
*/
function analyzeRepeaterTraffic(data: RepeaterTrafficData): RepeaterSplitAnalysis {
const now = Date.now();
// Filter out old observations
const recentObservations = data.observations.filter(
(obs) => now - obs.timestamp < TRAFFIC_OBSERVATION_MAX_AGE_MS
);
// Group by nextHop (use "self" for null nextHop - final repeater)
const byNextHop = new Map<string, Set<string>>();
for (const obs of recentObservations) {
const hopKey = obs.nextHop ?? 'self';
if (!byNextHop.has(hopKey)) {
byNextHop.set(hopKey, new Set());
}
byNextHop.get(hopKey)!.add(obs.source);
}
// If only one nextHop group, no need to split
if (byNextHop.size <= 1) {
return { shouldSplit: false, disjointGroups: null };
}
// Check if any source appears in multiple groups (evidence of hub behavior)
const allSources = new Map<string, string[]>(); // source -> list of nextHops it uses
for (const [nextHop, sources] of byNextHop) {
for (const source of sources) {
if (!allSources.has(source)) {
allSources.set(source, []);
}
allSources.get(source)!.push(nextHop);
}
}
// If any source routes to multiple nextHops, this is a hub - don't split
for (const [, nextHops] of allSources) {
if (nextHops.length > 1) {
return { shouldSplit: false, disjointGroups: null };
}
}
// Check if we have enough observations in each group to be confident
for (const [, sources] of byNextHop) {
if (sources.size < MIN_OBSERVATIONS_TO_SPLIT) {
// Not enough evidence yet - be conservative, don't split
return { shouldSplit: false, disjointGroups: null };
}
}
// Source sets are disjoint and we have enough data - split!
return { shouldSplit: true, disjointGroups: byNextHop };
}
/**
* Record a traffic observation for an ambiguous repeater prefix.
* Prunes old observations and limits total count.
*/
function recordTrafficObservation(
trafficData: Map<string, RepeaterTrafficData>,
prefix: string,
source: string,
nextHop: string | null
): void {
const normalizedPrefix = prefix.toLowerCase();
const now = Date.now();
if (!trafficData.has(normalizedPrefix)) {
trafficData.set(normalizedPrefix, { prefix: normalizedPrefix, observations: [] });
}
const data = trafficData.get(normalizedPrefix)!;
// Add new observation
data.observations.push({ source, nextHop, timestamp: now });
// Prune old observations
data.observations = data.observations.filter(
(obs) => now - obs.timestamp < TRAFFIC_OBSERVATION_MAX_AGE_MS
);
// Limit total count
if (data.observations.length > MAX_TRAFFIC_OBSERVATIONS) {
data.observations = data.observations.slice(-MAX_TRAFFIC_OBSERVATIONS);
}
}
// =============================================================================
// DATA LAYER HOOK
// =============================================================================
interface UseVisualizerDataOptions {
packets: RawPacket[];
contacts: Contact[];
config: RadioConfig | null;
showAmbiguousPaths: boolean;
showAmbiguousNodes: boolean;
splitAmbiguousByTraffic: boolean;
chargeStrength: number;
letEmDrift: boolean;
particleSpeedMultiplier: number;
observationWindowSec: number;
dimensions: { width: number; height: number };
}
interface VisualizerData {
nodes: Map<string, GraphNode>;
links: Map<string, GraphLink>;
particles: Particle[];
simulation: Simulation<GraphNode, GraphLink> | null;
stats: { processed: number; animated: number; nodes: number; links: number };
randomizePositions: () => void;
expandContract: () => void;
clearAndReset: () => void;
}
function useVisualizerData({
packets,
contacts,
config,
showAmbiguousPaths,
showAmbiguousNodes,
splitAmbiguousByTraffic,
chargeStrength,
letEmDrift,
particleSpeedMultiplier,
observationWindowSec,
dimensions,
}: UseVisualizerDataOptions): VisualizerData {
const nodesRef = useRef<Map<string, GraphNode>>(new Map());
const linksRef = useRef<Map<string, GraphLink>>(new Map());
const particlesRef = useRef<Particle[]>([]);
const simulationRef = useRef<Simulation<GraphNode, GraphLink> | null>(null);
const processedRef = useRef<Set<number>>(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 [stats, setStats] = useState({ processed: 0, animated: 0, nodes: 0, links: 0 });
// Keep refs in sync with props
useEffect(() => {
speedMultiplierRef.current = particleSpeedMultiplier;
}, [particleSpeedMultiplier]);
useEffect(() => {
observationWindowRef.current = observationWindowSec * 1000;
}, [observationWindowSec]);
// Initialize simulation
useEffect(() => {
const sim = forceSimulation<GraphNode, GraphLink>([])
.force(
'link',
forceLink<GraphNode, GraphLink>([])
.id((d) => d.id)
.distance(80)
.strength(0.3)
)
.force(
'charge',
forceManyBody<GraphNode>()
.strength((d) => (d.id === 'self' ? -1200 : -200))
.distanceMax(500)
)
.force('center', forceCenter(dimensions.width / 2, dimensions.height / 2))
.force('collide', forceCollide(40))
.force(
'selfX',
forceX<GraphNode>(dimensions.width / 2).strength((d) => (d.id === 'self' ? 0.1 : 0))
)
.force(
'selfY',
forceY<GraphNode>(dimensions.height / 2).strength((d) => (d.id === 'self' ? 0.1 : 0))
)
.alphaDecay(0.02)
.velocityDecay(0.5)
.alphaTarget(0.03);
simulationRef.current = sim;
return () => {
sim.stop();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-time init; dimensions/charge handled by the effect below
}, []);
// Update simulation forces when dimensions/charge change
useEffect(() => {
const sim = simulationRef.current;
if (!sim) return;
sim.force('center', forceCenter(dimensions.width / 2, dimensions.height / 2));
sim.force(
'selfX',
forceX<GraphNode>(dimensions.width / 2).strength((d) => (d.id === 'self' ? 0.1 : 0))
);
sim.force(
'selfY',
forceY<GraphNode>(dimensions.height / 2).strength((d) => (d.id === 'self' ? 0.1 : 0))
);
sim.force(
'charge',
forceManyBody<GraphNode>()
.strength((d) => (d.id === 'self' ? chargeStrength * 6 : chargeStrength))
.distanceMax(500)
);
sim.alpha(0.3).restart();
}, [dimensions, 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: dimensions.width / 2,
y: dimensions.height / 2,
});
syncSimulation();
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- syncSimulation is stable (no deps), defined below
}, [config, dimensions]);
// 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 });
}, [showAmbiguousPaths, showAmbiguousNodes, splitAmbiguousByTraffic]);
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 ReturnType<typeof forceLink<GraphNode, GraphLink>>;
linkForce?.links(links);
sim.alpha(0.15).restart();
setStats((prev) => ({ ...prev, nodes: nodes.length, links: links.length }));
}, []);
const addNode = useCallback(
(
id: string,
name: string | null,
type: NodeType,
isAmbiguous: boolean,
ambiguousNames?: string[],
lastSeen?: number | null
) => {
const existing = nodesRef.current.get(id);
if (existing) {
existing.lastActivity = Date.now();
if (name && !existing.name) existing.name = name;
if (ambiguousNames) existing.ambiguousNames = ambiguousNames;
if (lastSeen !== undefined) existing.lastSeen = lastSeen;
} else {
const selfNode = nodesRef.current.get('self');
nodesRef.current.set(id, {
id,
name,
type,
isAmbiguous,
lastActivity: Date.now(),
lastSeen,
ambiguousNames,
x: (selfNode?.x ?? 400) + (Math.random() - 0.5) * 100,
y: (selfNode?.y ?? 300) + (Math.random() - 0.5) * 100,
});
}
},
[]
);
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],
});
}
}
}, []);
// Resolve a node from various sources and add to graph
// trafficContext is used when splitAmbiguousByTraffic is enabled to create
// separate nodes for ambiguous repeaters based on their position in traffic flow
// myPrefix is the user's own 12-char pubkey prefix - if a node matches, return 'self'
// trafficContext.packetSource is the original source of the packet (for traffic analysis)
// trafficContext.nextPrefix is the next hop after this repeater
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();
// Check if this is our own identity - return 'self' instead of creating duplicate node
if (myPrefix && nodeId === myPrefix) {
return 'self';
}
const contact = contacts.find((c) => c.public_key.toLowerCase().startsWith(nodeId));
addNode(
nodeId,
contact?.name || null,
getNodeType(contact),
false,
undefined,
contact?.last_seen
);
return nodeId;
}
if (source.type === 'name') {
const contact = findContactByName(source.value, contacts);
if (contact) {
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
// Check if this is our own identity
if (myPrefix && nodeId === myPrefix) {
return 'self';
}
addNode(nodeId, contact.name, getNodeType(contact), false, undefined, contact.last_seen);
return nodeId;
}
const nodeId = `name:${source.value}`;
addNode(nodeId, source.value, 'client', false);
return nodeId;
}
// type === 'prefix'
const contact = findContactByPrefix(source.value, contacts);
if (contact) {
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
// Check if this is our own identity
if (myPrefix && nodeId === myPrefix) {
return 'self';
}
addNode(nodeId, contact.name, getNodeType(contact), false, undefined, contact.last_seen);
return nodeId;
}
if (showAmbiguous) {
const matches = findContactsByPrefix(source.value, contacts);
const filtered = isRepeater
? matches.filter((c) => c.type === CONTACT_TYPE_REPEATER)
: matches.filter((c) => c.type !== CONTACT_TYPE_REPEATER);
// If exactly one match after filtering, use it directly (not ambiguous)
if (filtered.length === 1) {
const contact = filtered[0];
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
addNode(nodeId, contact.name, getNodeType(contact), false, undefined, contact.last_seen);
return nodeId;
}
// Multiple matches or no matches - create ambiguous node
// When splitAmbiguousByTraffic is enabled for repeaters, use traffic pattern analysis
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
);
// Default: simple ambiguous node ID
let nodeId = `?${source.value.toLowerCase()}`;
let displayName = source.value.toUpperCase();
// When splitAmbiguousByTraffic is enabled, use traffic pattern analysis
if (splitAmbiguousByTraffic && isRepeater && trafficContext) {
const prefix = source.value.toLowerCase();
// Record observation for traffic analysis (only if we have a packet source)
if (trafficContext.packetSource) {
recordTrafficObservation(
trafficPatternsRef.current,
prefix,
trafficContext.packetSource,
trafficContext.nextPrefix
);
}
// Analyze traffic patterns to decide if we should split
const trafficData = trafficPatternsRef.current.get(prefix);
if (trafficData) {
const analysis = analyzeRepeaterTraffic(trafficData);
if (analysis.shouldSplit && trafficContext.nextPrefix) {
// Strong evidence of disjoint routing - split by next hop
const nextShort = trafficContext.nextPrefix.slice(0, 2).toLowerCase();
nodeId = `?${prefix}:>${nextShort}`;
displayName = `${source.value.toUpperCase()}:>${nextShort}`;
}
// If analysis says don't split, or this is the final repeater (nextPrefix=null),
// keep the simple ?XX ID
}
}
addNode(
nodeId,
displayName,
isRepeater ? 'repeater' : 'client',
true,
names.length > 0 ? names : undefined,
lastSeen
);
return nodeId;
}
}
return null;
},
[contacts, addNode, splitAmbiguousByTraffic]
);
// Build path from parsed packet
const buildPath = useCallback(
(parsed: ParsedPacket, packet: RawPacket, myPrefix: string | null): string[] => {
const path: string[] = [];
let packetSource: string | null = null;
// Add source - and track it for traffic pattern analysis
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) {
// AnonRequest packets contain the full sender public key
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;
}
}
}
// Add path bytes (repeaters)
// Pass packetSource for traffic pattern analysis (used to track which sources route through which repeaters)
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);
}
// Add destination
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');
}
// Ensure ends with 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) {
if (processedRef.current.has(packet.id)) continue;
processedRef.current.add(packet.id);
newProcessed++;
// Limit processed set size
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;
// Create links
for (let i = 0; i < path.length - 1; i++) {
if (path[i] !== path[i + 1]) {
addLink(path[i], path[i + 1]);
needsUpdate = true;
}
}
// Queue for animation
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)
);
}
// Limit pending size
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]);
// Randomize all node positions (except self) and reheat simulation
const randomizePositions = useCallback(() => {
const sim = simulationRef.current;
if (!sim) return;
const centerX = dimensions.width / 2;
const centerY = dimensions.height / 2;
const radius = Math.min(dimensions.width, dimensions.height) * 0.4;
for (const node of nodesRef.current.values()) {
if (node.id === 'self') {
// Keep self at center
node.x = centerX;
node.y = centerY;
} else {
// Randomize position in a circle around center
const angle = Math.random() * 2 * Math.PI;
const r = Math.random() * radius;
node.x = centerX + r * Math.cos(angle);
node.y = centerY + r * Math.sin(angle);
}
// Clear velocities
node.vx = 0;
node.vy = 0;
}
// Reheat simulation strongly
sim.alpha(1).restart();
}, [dimensions]);
// Expand to high repulsion, hold, then contract back
// Also weakens link force during expansion so nodes can actually separate
const expandContract = useCallback(() => {
const sim = simulationRef.current;
if (!sim) return;
const startChargeStrength = chargeStrength;
const peakChargeStrength = -5000;
const startLinkStrength = 0.3;
const minLinkStrength = 0.02; // Nearly disable links during expansion
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) {
// Expanding: ramp up repulsion, weaken links
const t = elapsed / expandDuration;
currentChargeStrength =
startChargeStrength + (peakChargeStrength - startChargeStrength) * t;
currentLinkStrength = startLinkStrength + (minLinkStrength - startLinkStrength) * t;
} else if (elapsed < expandDuration + holdDuration) {
// Hold: stay at peak repulsion, links weak
currentChargeStrength = peakChargeStrength;
currentLinkStrength = minLinkStrength;
} else if (elapsed < expandDuration + holdDuration + contractDuration) {
// Contracting: restore both forces
const t = (elapsed - expandDuration - holdDuration) / contractDuration;
currentChargeStrength = peakChargeStrength + (startChargeStrength - peakChargeStrength) * t;
currentLinkStrength = minLinkStrength + (startLinkStrength - minLinkStrength) * t;
} else {
// Done - restore originals
sim.force(
'charge',
forceManyBody<GraphNode>()
.strength((d) => (d.id === 'self' ? startChargeStrength * 6 : startChargeStrength))
.distanceMax(500)
);
sim.force(
'link',
forceLink<GraphNode, GraphLink>(Array.from(linksRef.current.values()))
.id((d) => d.id)
.distance(80)
.strength(startLinkStrength)
);
sim.alpha(0.3).restart();
return;
}
// Apply current strengths
sim.force(
'charge',
forceManyBody<GraphNode>()
.strength((d) => (d.id === 'self' ? currentChargeStrength * 6 : currentChargeStrength))
.distanceMax(500)
);
sim.force(
'link',
forceLink<GraphNode, GraphLink>(Array.from(linksRef.current.values()))
.id((d) => d.id)
.distance(80)
.strength(currentLinkStrength)
);
sim.alpha(0.5).restart();
requestAnimationFrame(animate);
};
requestAnimationFrame(animate);
}, [chargeStrength]);
// Clear all state and reset to initial (keeps self node only)
const clearAndReset = useCallback(() => {
// Clear all pending timers
for (const timer of timersRef.current.values()) {
clearTimeout(timer);
}
timersRef.current.clear();
// Clear pending packets
pendingRef.current.clear();
// Clear processed packet IDs so they can be re-processed if needed
processedRef.current.clear();
// Clear traffic patterns
trafficPatternsRef.current.clear();
// Clear particles
particlesRef.current.length = 0;
// Clear links
linksRef.current.clear();
// Clear nodes except self, then reset self position
const selfNode = nodesRef.current.get('self');
nodesRef.current.clear();
if (selfNode) {
selfNode.x = dimensions.width / 2;
selfNode.y = dimensions.height / 2;
selfNode.vx = 0;
selfNode.vy = 0;
selfNode.lastActivity = Date.now();
nodesRef.current.set('self', selfNode);
}
// Reset simulation with just self node
const sim = simulationRef.current;
if (sim) {
sim.nodes(Array.from(nodesRef.current.values()));
sim.force(
'link',
forceLink<GraphNode, GraphLink>([])
.id((d) => d.id)
.distance(80)
.strength(0.3)
);
sim.alpha(0.3).restart();
}
// Reset stats
setStats({ processed: 0, animated: 0, nodes: 1, links: 0 });
}, [dimensions]);
return {
nodes: nodesRef.current,
links: linksRef.current,
particles: particlesRef.current,
simulation: simulationRef.current,
stats,
randomizePositions,
expandContract,
clearAndReset,
};
}
// =============================================================================
// RENDERING FUNCTIONS
// =============================================================================
function renderLinks(
ctx: CanvasRenderingContext2D,
links: GraphLink[],
nodes: Map<string, GraphNode>
) {
ctx.strokeStyle = COLORS.link;
ctx.lineWidth = 2;
for (const link of links) {
const { sourceId, targetId } = getLinkId(link);
const source = nodes.get(sourceId);
const target = nodes.get(targetId);
if (source?.x != null && source?.y != null && target?.x != null && target?.y != null) {
ctx.beginPath();
ctx.moveTo(source.x, source.y);
ctx.lineTo(target.x, target.y);
ctx.stroke();
}
}
}
function renderParticles(
ctx: CanvasRenderingContext2D,
particles: Particle[],
nodes: Map<string, GraphNode>,
visibleNodeIds: Set<string>
): Particle[] {
const active: Particle[] = [];
for (const particle of particles) {
const fromNode = nodes.get(particle.fromNodeId);
const toNode = nodes.get(particle.toNodeId);
const isVisible =
visibleNodeIds.has(particle.fromNodeId) && visibleNodeIds.has(particle.toNodeId);
particle.progress += particle.speed;
if (particle.progress > 1) continue;
active.push(particle);
if (!isVisible || !fromNode?.x || !toNode?.x || fromNode.y == null || toNode.y == null)
continue;
if (particle.progress < 0) continue;
const t = particle.progress;
const x = fromNode.x + (toNode.x - fromNode.x) * t;
const y = fromNode.y + (toNode.y - fromNode.y) * t;
// Glow
ctx.fillStyle = particle.color + '40';
ctx.beginPath();
ctx.arc(x, y, 14, 0, Math.PI * 2);
ctx.fill();
// Circle
ctx.fillStyle = particle.color;
ctx.beginPath();
ctx.arc(x, y, 10, 0, Math.PI * 2);
ctx.fill();
// Label
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 8px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(particle.label, x, y);
}
return active;
}
function renderNodes(
ctx: CanvasRenderingContext2D,
nodes: GraphNode[],
hoveredNodeId: string | null
) {
for (const node of nodes) {
if (node.x == null || node.y == null) continue;
// Emoji
const emoji =
node.type === 'self'
? '🟢'
: node.type === 'repeater'
? '📡'
: node.isAmbiguous
? '❓'
: '👤';
const size = node.type === 'self' ? 36 : 18;
ctx.font = `${size}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(emoji, node.x, node.y);
// Label
const label = node.isAmbiguous
? node.id
: node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8));
ctx.font = '11px sans-serif';
ctx.textBaseline = 'top';
ctx.fillStyle = node.isAmbiguous ? COLORS.ambiguous : '#e5e7eb';
ctx.fillText(label, node.x, node.y + size / 2 + 4);
// Ambiguous names
if (node.isAmbiguous && node.ambiguousNames?.length) {
ctx.font = '9px sans-serif';
ctx.fillStyle = '#6b7280';
let yOffset = node.y + size / 2 + 18;
if (hoveredNodeId === node.id) {
for (const name of node.ambiguousNames) {
ctx.fillText(name, node.x, yOffset);
yOffset += 11;
}
} else if (node.ambiguousNames.length === 1) {
ctx.fillText(node.ambiguousNames[0], node.x, yOffset);
} else {
ctx.fillText(
`${node.ambiguousNames[0]} +${node.ambiguousNames.length - 1} more`,
node.x,
yOffset
);
}
}
}
}
// =============================================================================
// MAIN COMPONENT
// =============================================================================
interface PacketVisualizerProps {
packets: RawPacket[];
contacts: Contact[];
config: RadioConfig | null;
fullScreen?: boolean;
onFullScreenChange?: (fullScreen: boolean) => void;
onClearPackets?: () => void;
}
export function PacketVisualizer({
packets,
contacts,
config,
fullScreen,
onFullScreenChange,
onClearPackets,
}: PacketVisualizerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
// Options
const [showAmbiguousPaths, setShowAmbiguousPaths] = useState(true);
const [showAmbiguousNodes, setShowAmbiguousNodes] = useState(false);
const [splitAmbiguousByTraffic, setSplitAmbiguousByTraffic] = useState(false);
const [chargeStrength, setChargeStrength] = useState(-200);
const [filterOldRepeaters, setFilterOldRepeaters] = useState(false);
const [observationWindowSec, setObservationWindowSec] = useState(DEFAULT_OBSERVATION_WINDOW_SEC);
const [letEmDrift, setLetEmDrift] = useState(true);
const [particleSpeedMultiplier, setParticleSpeedMultiplier] = useState(2);
const [hideUI, setHideUI] = useState(false);
// Pan/zoom
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 });
const isDraggingRef = useRef(false);
const lastMouseRef = useRef({ x: 0, y: 0 });
const draggedNodeRef = useRef<GraphNode | null>(null);
// Hover
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
// Data layer
const data = useVisualizerData({
packets,
contacts,
config,
showAmbiguousPaths,
showAmbiguousNodes,
splitAmbiguousByTraffic,
chargeStrength,
letEmDrift,
particleSpeedMultiplier,
observationWindowSec,
dimensions,
});
// Track dimensions
useEffect(() => {
const update = () => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setDimensions({ width: rect.width, height: rect.height });
}
};
update();
const observer = new ResizeObserver(update);
if (containerRef.current) observer.observe(containerRef.current);
return () => observer.disconnect();
}, []);
// Render
const render = useCallback(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (!canvas || !ctx) return;
const { width, height } = dimensions;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.scale(dpr, dpr);
ctx.fillStyle = COLORS.background;
ctx.fillRect(0, 0, width, height);
ctx.save();
ctx.translate(width / 2, height / 2);
ctx.scale(transform.scale, transform.scale);
ctx.translate(transform.x - width / 2, transform.y - height / 2);
const now = Date.now();
const allNodes = Array.from(data.nodes.values());
const visibleNodeIds = new Set<string>();
// Filter nodes
const visibleNodes = allNodes.filter((node) => {
if (node.type === 'self' || node.type === 'client') {
visibleNodeIds.add(node.id);
return true;
}
if (filterOldRepeaters && node.type === 'repeater') {
const lastTime = node.lastSeen ? node.lastSeen * 1000 : node.lastActivity;
if (now - lastTime > FORTY_EIGHT_HOURS_MS) return false;
}
visibleNodeIds.add(node.id);
return true;
});
// Filter links
const allLinks = Array.from(data.links.values());
const visibleLinks = allLinks.filter((link) => {
const { sourceId, targetId } = getLinkId(link);
return visibleNodeIds.has(sourceId) && visibleNodeIds.has(targetId);
});
renderLinks(ctx, visibleLinks, data.nodes);
data.particles.splice(
0,
data.particles.length,
...renderParticles(ctx, data.particles, data.nodes, visibleNodeIds)
);
renderNodes(ctx, visibleNodes, hoveredNodeId);
ctx.restore();
}, [dimensions, transform, data, hoveredNodeId, filterOldRepeaters]);
// Animation loop
useEffect(() => {
let running = true;
const animate = () => {
if (!running) return;
render();
requestAnimationFrame(animate);
};
animate();
return () => {
running = false;
};
}, [render]);
// Mouse handlers
const screenToGraph = useCallback(
(screenX: number, screenY: number) => {
const { width, height } = dimensions;
const cx = (screenX - width / 2) / transform.scale - transform.x + width / 2;
const cy = (screenY - height / 2) / transform.scale - transform.y + height / 2;
return { x: cx, y: cy };
},
[dimensions, transform]
);
const findNodeAt = useCallback(
(gx: number, gy: number) => {
for (const node of data.nodes.values()) {
if (node.x == null || node.y == null) continue;
if (Math.hypot(gx - node.x, gy - node.y) < 20) return node;
}
return null;
},
[data.nodes]
);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const pos = screenToGraph(e.clientX - rect.left, e.clientY - rect.top);
const node = findNodeAt(pos.x, pos.y);
if (node) {
// Start dragging this node
draggedNodeRef.current = node;
// Fix the node's position while dragging
node.fx = node.x;
node.fy = node.y;
// Reheat simulation slightly for responsive feedback
data.simulation?.alpha(0.3).restart();
} else {
// Start panning
isDraggingRef.current = true;
}
lastMouseRef.current = { x: e.clientX, y: e.clientY };
},
[screenToGraph, findNodeAt, data.simulation]
);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const pos = screenToGraph(e.clientX - rect.left, e.clientY - rect.top);
// Update hover state
setHoveredNodeId(findNodeAt(pos.x, pos.y)?.id || null);
// Handle node dragging
if (draggedNodeRef.current) {
draggedNodeRef.current.fx = pos.x;
draggedNodeRef.current.fy = pos.y;
return;
}
// Handle canvas panning
if (!isDraggingRef.current) return;
const dx = e.clientX - lastMouseRef.current.x;
const dy = e.clientY - lastMouseRef.current.y;
lastMouseRef.current = { x: e.clientX, y: e.clientY };
setTransform((t) => ({ ...t, x: t.x + dx / t.scale, y: t.y + dy / t.scale }));
},
[screenToGraph, findNodeAt]
);
const handleMouseUp = useCallback(() => {
if (draggedNodeRef.current) {
// Release the node - clear fixed position so it can move freely again
draggedNodeRef.current.fx = null;
draggedNodeRef.current.fy = null;
draggedNodeRef.current = null;
}
isDraggingRef.current = false;
}, []);
const handleMouseLeave = useCallback(() => {
if (draggedNodeRef.current) {
draggedNodeRef.current.fx = null;
draggedNodeRef.current.fy = null;
draggedNodeRef.current = null;
}
isDraggingRef.current = false;
setHoveredNodeId(null);
}, []);
const handleWheel = useCallback((e: WheelEvent) => {
e.preventDefault();
const factor = e.deltaY > 0 ? 1 / 1.1 : 1.1;
setTransform((t) => ({ ...t, scale: Math.min(Math.max(t.scale * factor, 0.1), 5) }));
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
canvas.addEventListener('wheel', handleWheel, { passive: false });
return () => canvas.removeEventListener('wheel', handleWheel);
}, [handleWheel]);
// Determine cursor based on state
const getCursor = () => {
if (draggedNodeRef.current) return 'grabbing';
if (hoveredNodeId) return 'pointer';
return 'grab';
};
return (
<div ref={containerRef} className="w-full h-full bg-background relative overflow-hidden">
<canvas
ref={canvasRef}
className="w-full h-full"
style={{ display: 'block', cursor: getCursor() }}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseLeave}
/>
{/* Legend */}
{!hideUI && (
<div className="absolute bottom-4 left-4 bg-background/80 backdrop-blur-sm rounded-lg p-3 text-xs border border-border">
<div className="flex gap-6">
<div className="flex flex-col gap-1.5">
<div className="text-muted-foreground font-medium mb-1">Packets</div>
{PACKET_LEGEND_ITEMS.map((item) => (
<div key={item.label} className="flex items-center gap-2">
<div
className="w-5 h-5 rounded-full flex items-center justify-center text-[8px] font-bold text-white"
style={{ backgroundColor: item.color }}
>
{item.label}
</div>
<span>{item.description}</span>
</div>
))}
</div>
<div className="flex flex-col gap-1.5">
<div className="text-muted-foreground font-medium mb-1">Nodes</div>
{LEGEND_ITEMS.map((item) => (
<div key={item.label} className="flex items-center gap-2">
<span className={item.size}>{item.emoji}</span>
<span>{item.label}</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Options */}
<div className="absolute top-4 right-4 bg-background/80 backdrop-blur-sm rounded-lg p-3 text-xs border border-border">
<div className="flex flex-col gap-2">
{!hideUI && (
<>
<div>Nodes: {data.stats.nodes}</div>
<div>Links: {data.stats.links}</div>
<div className="border-t border-border pt-2 mt-1 flex flex-col gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={showAmbiguousPaths}
onCheckedChange={(c) => setShowAmbiguousPaths(c === true)}
/>
<span title="Show placeholder nodes for repeaters when the 1-byte prefix matches multiple contacts">
Ambiguous repeaters
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={showAmbiguousNodes}
onCheckedChange={(c) => setShowAmbiguousNodes(c === true)}
/>
<span title="Show placeholder nodes for senders/recipients when only a 1-byte prefix is known">
Ambiguous sender/recipient
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={splitAmbiguousByTraffic}
onCheckedChange={(c) => setSplitAmbiguousByTraffic(c === true)}
disabled={!showAmbiguousPaths}
/>
<span
title="Split ambiguous repeaters into separate nodes based on traffic patterns (prev→next). Helps identify colliding prefixes representing different physical nodes."
className={!showAmbiguousPaths ? 'text-muted-foreground' : ''}
>
Hueristically group repeaters by traffic pattern
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={filterOldRepeaters}
onCheckedChange={(c) => setFilterOldRepeaters(c === true)}
/>
<span title="Hide repeaters not heard within the last 48 hours">
Hide repeaters &gt;48hrs heard
</span>
</label>
<div className="flex items-center gap-2">
<label
className="text-muted-foreground"
title="How long to wait for duplicate packets via different paths before animating"
>
Observation window:
</label>
<input
type="number"
min="1"
max="60"
value={observationWindowSec}
onChange={(e) =>
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"
/>
<span className="text-muted-foreground">sec</span>
</div>
<div className="border-t border-border pt-2 mt-1 flex flex-col gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={letEmDrift}
onCheckedChange={(c) => setLetEmDrift(c === true)}
/>
<span title="When enabled, the graph continuously reorganizes itself into a better layout">
Let &apos;em drift
</span>
</label>
<div className="flex flex-col gap-1 mt-1">
<label
className="text-muted-foreground"
title="How strongly nodes repel each other. Higher values spread nodes out more."
>
Repulsion: {Math.abs(chargeStrength)}
</label>
<input
type="range"
min="50"
max="2500"
value={Math.abs(chargeStrength)}
onChange={(e) => setChargeStrength(-parseInt(e.target.value))}
className="w-full h-2 bg-border rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
<div className="flex flex-col gap-1 mt-1">
<label
className="text-muted-foreground"
title="How fast particles travel along links. Higher values make packets move faster."
>
Packet speed: {particleSpeedMultiplier}x
</label>
<input
type="range"
min="1"
max="5"
step="0.5"
value={particleSpeedMultiplier}
onChange={(e) => setParticleSpeedMultiplier(parseFloat(e.target.value))}
className="w-full h-2 bg-border rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
</div>
<button
onClick={data.randomizePositions}
className="mt-2 px-3 py-1.5 bg-primary/20 hover:bg-primary/30 text-primary rounded text-xs transition-colors"
title="Randomize node positions and let the simulation settle into a new layout"
>
Shuffle layout
</button>
<button
onClick={data.expandContract}
className="mt-1 px-3 py-1.5 bg-primary/20 hover:bg-primary/30 text-primary rounded text-xs transition-colors"
title="Expand nodes apart then contract back - can help untangle the graph"
>
Oooh Big Stretch!
</button>
<button
onClick={() => {
data.clearAndReset();
onClearPackets?.();
}}
className="mt-1 px-3 py-1.5 bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-500 rounded text-xs transition-colors"
title="Clear all nodes, links, and packets - reset to initial state"
>
Clear &amp; Reset
</button>
</div>
</>
)}
<div className={hideUI ? '' : 'border-t border-border pt-2 mt-1 flex flex-col gap-2'}>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox checked={hideUI} onCheckedChange={(c) => setHideUI(c === true)} />
<span title="Hide legends and controls for a cleaner view">Hide UI</span>
</label>
{onFullScreenChange && (
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={fullScreen}
onCheckedChange={(c) => onFullScreenChange(c === true)}
/>
<span title="Hide the packet feed panel">Full screen</span>
</label>
)}
</div>
</div>
</div>
</div>
);
}