Add shuffler

This commit is contained in:
Jack Kingsman
2026-01-19 16:37:14 -08:00
parent 5feec085f7
commit 16812f3fb3
8 changed files with 414 additions and 222 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -13,8 +13,8 @@
<link rel="shortcut icon" href="/favicon.ico" /> <link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-C_gPG-ZT.js"></script> <script type="module" crossorigin src="/assets/index-D05AJxJe.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-tIXvIK_l.css"> <link rel="stylesheet" crossorigin href="/assets/index-DgiuGBKg.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+1 -5
View File
@@ -721,11 +721,7 @@ export function App() {
</div> </div>
</> </>
) : activeConversation.type === 'visualizer' ? ( ) : activeConversation.type === 'visualizer' ? (
<VisualizerView <VisualizerView packets={rawPackets} contacts={contacts} config={config} />
packets={rawPackets}
contacts={contacts}
config={config}
/>
) : activeConversation.type === 'raw' ? ( ) : activeConversation.type === 'raw' ? (
<> <>
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg"> <div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg">
+262 -58
View File
@@ -176,14 +176,22 @@ function parsePacket(hexData: string): ParsedPacket | null {
function getPacketLabel(payloadType: number): PacketLabel { function getPacketLabel(payloadType: number): PacketLabel {
switch (payloadType) { switch (payloadType) {
case PayloadType.Advert: return 'AD'; case PayloadType.Advert:
case PayloadType.GroupText: return 'GT'; return 'AD';
case PayloadType.TextMessage: return 'DM'; case PayloadType.GroupText:
case PayloadType.Ack: return 'ACK'; return 'GT';
case PayloadType.Trace: return 'TR'; case PayloadType.TextMessage:
case PayloadType.Request: return 'RQ'; return 'DM';
case PayloadType.Response: return 'RS'; case PayloadType.Ack:
default: return '?'; return 'ACK';
case PayloadType.Trace:
return 'TR';
case PayloadType.Request:
return 'RQ';
case PayloadType.Response:
return 'RS';
default:
return '?';
} }
} }
@@ -255,6 +263,7 @@ interface VisualizerData {
particles: Particle[]; particles: Particle[];
simulation: Simulation<GraphNode, GraphLink> | null; simulation: Simulation<GraphNode, GraphLink> | null;
stats: { processed: number; animated: number; nodes: number; links: number }; stats: { processed: number; animated: number; nodes: number; links: number };
randomizePositions: () => void;
} }
function useVisualizerData({ function useVisualizerData({
@@ -279,18 +288,37 @@ function useVisualizerData({
// Initialize simulation // Initialize simulation
useEffect(() => { useEffect(() => {
const sim = forceSimulation<GraphNode, GraphLink>([]) const sim = forceSimulation<GraphNode, GraphLink>([])
.force('link', forceLink<GraphNode, GraphLink>([]).id((d) => d.id).distance(80).strength(0.3)) .force(
.force('charge', forceManyBody<GraphNode>().strength((d) => (d.id === 'self' ? -1200 : -200)).distanceMax(500)) '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('center', forceCenter(dimensions.width / 2, dimensions.height / 2))
.force('collide', forceCollide(40)) .force('collide', forceCollide(40))
.force('selfX', forceX<GraphNode>(dimensions.width / 2).strength((d) => (d.id === 'self' ? 0.1 : 0))) .force(
.force('selfY', forceY<GraphNode>(dimensions.height / 2).strength((d) => (d.id === 'self' ? 0.1 : 0))) '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) .alphaDecay(0.02)
.velocityDecay(0.5) .velocityDecay(0.5)
.alphaTarget(0.03); .alphaTarget(0.03);
simulationRef.current = sim; simulationRef.current = sim;
return () => { sim.stop(); }; return () => {
sim.stop();
};
}, []); }, []);
// Update simulation forces when dimensions/charge change // Update simulation forces when dimensions/charge change
@@ -299,9 +327,20 @@ function useVisualizerData({
if (!sim) return; if (!sim) return;
sim.force('center', forceCenter(dimensions.width / 2, dimensions.height / 2)); 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(
sim.force('selfY', forceY<GraphNode>(dimensions.height / 2).strength((d) => (d.id === 'self' ? 0.1 : 0))); 'selfX',
sim.force('charge', forceManyBody<GraphNode>().strength((d) => (d.id === 'self' ? chargeStrength * 6 : chargeStrength)).distanceMax(500)); 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(); sim.alpha(0.3).restart();
}, [dimensions, chargeStrength]); }, [dimensions, chargeStrength]);
@@ -353,12 +392,21 @@ function useVisualizerData({
sim.nodes(nodes); sim.nodes(nodes);
const linkForce = sim.force('link') as ReturnType<typeof forceLink<GraphNode, GraphLink>>; const linkForce = sim.force('link') as ReturnType<typeof forceLink<GraphNode, GraphLink>>;
linkForce?.links(links); linkForce?.links(links);
sim.alpha(0.15).restart(); sim.alpha(0.15).restart();
setStats((prev) => ({ ...prev, nodes: nodes.length, links: links.length })); 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 addNode = useCallback(
(
id: string,
name: string | null,
type: NodeType,
isAmbiguous: boolean,
ambiguousNames?: string[],
lastSeen?: number | null
) => {
const existing = nodesRef.current.get(id); const existing = nodesRef.current.get(id);
if (existing) { if (existing) {
existing.lastActivity = Date.now(); existing.lastActivity = Date.now();
@@ -368,12 +416,20 @@ function useVisualizerData({
} else { } else {
const selfNode = nodesRef.current.get('self'); const selfNode = nodesRef.current.get('self');
nodesRef.current.set(id, { nodesRef.current.set(id, {
id, name, type, isAmbiguous, lastActivity: Date.now(), lastSeen, ambiguousNames, id,
name,
type,
isAmbiguous,
lastActivity: Date.now(),
lastSeen,
ambiguousNames,
x: (selfNode?.x ?? 400) + (Math.random() - 0.5) * 100, x: (selfNode?.x ?? 400) + (Math.random() - 0.5) * 100,
y: (selfNode?.y ?? 300) + (Math.random() - 0.5) * 100, y: (selfNode?.y ?? 300) + (Math.random() - 0.5) * 100,
}); });
} }
}, []); },
[]
);
const addLink = useCallback((sourceId: string, targetId: string) => { const addLink = useCallback((sourceId: string, targetId: string) => {
const key = [sourceId, targetId].sort().join('->'); const key = [sourceId, targetId].sort().join('->');
@@ -411,7 +467,8 @@ function useVisualizerData({
}, []); }, []);
// Resolve a node from various sources and add to graph // Resolve a node from various sources and add to graph
const resolveNode = useCallback(( const resolveNode = useCallback(
(
source: { type: 'prefix' | 'pubkey' | 'name'; value: string }, source: { type: 'prefix' | 'pubkey' | 'name'; value: string },
isRepeater: boolean, isRepeater: boolean,
showAmbiguous: boolean showAmbiguous: boolean
@@ -420,7 +477,14 @@ function useVisualizerData({
if (source.value.length < 12) return null; if (source.value.length < 12) return null;
const nodeId = source.value.slice(0, 12).toLowerCase(); const nodeId = source.value.slice(0, 12).toLowerCase();
const contact = contacts.find((c) => c.public_key.toLowerCase().startsWith(nodeId)); const contact = contacts.find((c) => c.public_key.toLowerCase().startsWith(nodeId));
addNode(nodeId, contact?.name || null, getNodeType(contact), false, undefined, contact?.last_seen); addNode(
nodeId,
contact?.name || null,
getNodeType(contact),
false,
undefined,
contact?.last_seen
);
return nodeId; return nodeId;
} }
@@ -450,17 +514,30 @@ function useVisualizerData({
? matches.filter((c) => c.type === CONTACT_TYPE_REPEATER) ? matches.filter((c) => c.type === CONTACT_TYPE_REPEATER)
: matches.filter((c) => c.type !== CONTACT_TYPE_REPEATER); : matches.filter((c) => c.type !== CONTACT_TYPE_REPEATER);
const names = filtered.map((c) => c.name || c.public_key.slice(0, 8)); 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); const lastSeen = filtered.reduce(
(max, c) => (c.last_seen && (!max || c.last_seen > max) ? c.last_seen : max),
null as number | null
);
const nodeId = `?${source.value.toLowerCase()}`; const nodeId = `?${source.value.toLowerCase()}`;
addNode(nodeId, source.value.toUpperCase(), isRepeater ? 'repeater' : 'client', true, names, lastSeen); addNode(
nodeId,
source.value.toUpperCase(),
isRepeater ? 'repeater' : 'client',
true,
names,
lastSeen
);
return nodeId; return nodeId;
} }
return null; return null;
}, [contacts, addNode]); },
[contacts, addNode]
);
// Build path from parsed packet // Build path from parsed packet
const buildPath = useCallback((parsed: ParsedPacket, packet: RawPacket, myPrefix: string | null): string[] => { const buildPath = useCallback(
(parsed: ParsedPacket, packet: RawPacket, myPrefix: string | null): string[] => {
const path: string[] = []; const path: string[] = [];
// Add source // Add source
@@ -471,7 +548,11 @@ function useVisualizerData({
if (myPrefix && parsed.srcHash.toLowerCase() === myPrefix) { if (myPrefix && parsed.srcHash.toLowerCase() === myPrefix) {
path.push('self'); path.push('self');
} else { } else {
const nodeId = resolveNode({ type: 'prefix', value: parsed.srcHash }, false, showAmbiguousNodes); const nodeId = resolveNode(
{ type: 'prefix', value: parsed.srcHash },
false,
showAmbiguousNodes
);
if (nodeId) path.push(nodeId); if (nodeId) path.push(nodeId);
} }
} else if (parsed.payloadType === PayloadType.GroupText) { } else if (parsed.payloadType === PayloadType.GroupText) {
@@ -493,7 +574,11 @@ function useVisualizerData({
if (myPrefix && parsed.dstHash.toLowerCase() === myPrefix) { if (myPrefix && parsed.dstHash.toLowerCase() === myPrefix) {
path.push('self'); path.push('self');
} else { } else {
const nodeId = resolveNode({ type: 'prefix', value: parsed.dstHash }, false, showAmbiguousNodes); const nodeId = resolveNode(
{ type: 'prefix', value: parsed.dstHash },
false,
showAmbiguousNodes
);
if (nodeId) path.push(nodeId); if (nodeId) path.push(nodeId);
else path.push('self'); else path.push('self');
} }
@@ -507,7 +592,9 @@ function useVisualizerData({
} }
return dedupeConsecutive(path); return dedupeConsecutive(path);
}, [resolveNode, showAmbiguousPaths, showAmbiguousNodes]); },
[resolveNode, showAmbiguousPaths, showAmbiguousNodes]
);
// Process packets // Process packets
useEffect(() => { useEffect(() => {
@@ -558,12 +645,17 @@ function useVisualizerData({
firstSeen: now, firstSeen: now,
expiresAt: now + OBSERVATION_WINDOW_MS, expiresAt: now + OBSERVATION_WINDOW_MS,
}); });
timersRef.current.set(packetKey, setTimeout(() => publishPacket(packetKey), OBSERVATION_WINDOW_MS)); timersRef.current.set(
packetKey,
setTimeout(() => publishPacket(packetKey), OBSERVATION_WINDOW_MS)
);
} }
// Limit pending size // Limit pending size
if (pendingRef.current.size > 100) { if (pendingRef.current.size > 100) {
const entries = Array.from(pendingRef.current.entries()).sort((a, b) => a[1].firstSeen - b[1].firstSeen).slice(0, 50); const entries = Array.from(pendingRef.current.entries())
.sort((a, b) => a[1].firstSeen - b[1].firstSeen)
.slice(0, 50);
for (const [key] of entries) { for (const [key] of entries) {
clearTimeout(timersRef.current.get(key)); clearTimeout(timersRef.current.get(key));
timersRef.current.delete(key); timersRef.current.delete(key);
@@ -576,16 +668,51 @@ function useVisualizerData({
if (needsUpdate) syncSimulation(); if (needsUpdate) syncSimulation();
if (newProcessed > 0) { if (newProcessed > 0) {
setStats((prev) => ({ ...prev, processed: prev.processed + newProcessed, animated: prev.animated + newAnimated })); setStats((prev) => ({
...prev,
processed: prev.processed + newProcessed,
animated: prev.animated + newAnimated,
}));
} }
}, [packets, config, buildPath, addLink, syncSimulation, publishPacket]); }, [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]);
return { return {
nodes: nodesRef.current, nodes: nodesRef.current,
links: linksRef.current, links: linksRef.current,
particles: particlesRef.current, particles: particlesRef.current,
simulation: simulationRef.current, simulation: simulationRef.current,
stats, stats,
randomizePositions,
}; };
} }
@@ -626,14 +753,16 @@ function renderParticles(
for (const particle of particles) { for (const particle of particles) {
const fromNode = nodes.get(particle.fromNodeId); const fromNode = nodes.get(particle.fromNodeId);
const toNode = nodes.get(particle.toNodeId); const toNode = nodes.get(particle.toNodeId);
const isVisible = visibleNodeIds.has(particle.fromNodeId) && visibleNodeIds.has(particle.toNodeId); const isVisible =
visibleNodeIds.has(particle.fromNodeId) && visibleNodeIds.has(particle.toNodeId);
particle.progress += particle.speed; particle.progress += particle.speed;
if (particle.progress > 1) continue; if (particle.progress > 1) continue;
active.push(particle); active.push(particle);
if (!isVisible || !fromNode?.x || !toNode?.x || fromNode.y == null || toNode.y == null) continue; if (!isVisible || !fromNode?.x || !toNode?.x || fromNode.y == null || toNode.y == null)
continue;
if (particle.progress < 0) continue; if (particle.progress < 0) continue;
const t = particle.progress; const t = particle.progress;
@@ -672,7 +801,14 @@ function renderNodes(
if (node.x == null || node.y == null) continue; if (node.x == null || node.y == null) continue;
// Emoji // Emoji
const emoji = node.type === 'self' ? '🟢' : node.type === 'repeater' ? '📡' : node.isAmbiguous ? '❓' : '👤'; const emoji =
node.type === 'self'
? '🟢'
: node.type === 'repeater'
? '📡'
: node.isAmbiguous
? '❓'
: '👤';
const size = node.type === 'self' ? 36 : 18; const size = node.type === 'self' ? 36 : 18;
ctx.font = `${size}px sans-serif`; ctx.font = `${size}px sans-serif`;
@@ -681,7 +817,9 @@ function renderNodes(
ctx.fillText(emoji, node.x, node.y); ctx.fillText(emoji, node.x, node.y);
// Label // Label
const label = node.isAmbiguous ? node.id : node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8)); const label = node.isAmbiguous
? node.id
: node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8));
ctx.font = '11px sans-serif'; ctx.font = '11px sans-serif';
ctx.textBaseline = 'top'; ctx.textBaseline = 'top';
ctx.fillStyle = node.isAmbiguous ? COLORS.ambiguous : '#e5e7eb'; ctx.fillStyle = node.isAmbiguous ? COLORS.ambiguous : '#e5e7eb';
@@ -701,7 +839,11 @@ function renderNodes(
} else if (node.ambiguousNames.length === 1) { } else if (node.ambiguousNames.length === 1) {
ctx.fillText(node.ambiguousNames[0], node.x, yOffset); ctx.fillText(node.ambiguousNames[0], node.x, yOffset);
} else { } else {
ctx.fillText(`${node.ambiguousNames[0]} +${node.ambiguousNames.length - 1} more`, node.x, yOffset); ctx.fillText(
`${node.ambiguousNames[0]} +${node.ambiguousNames.length - 1} more`,
node.x,
yOffset
);
} }
} }
} }
@@ -739,7 +881,14 @@ export function PacketVisualizer({ packets, contacts, config }: PacketVisualizer
// Data layer // Data layer
const data = useVisualizerData({ const data = useVisualizerData({
packets, contacts, config, showAmbiguousPaths, showAmbiguousNodes, chargeStrength, letEmDrift, dimensions, packets,
contacts,
config,
showAmbiguousPaths,
showAmbiguousNodes,
chargeStrength,
letEmDrift,
dimensions,
}); });
// Track dimensions // Track dimensions
@@ -805,7 +954,11 @@ export function PacketVisualizer({ packets, contacts, config }: PacketVisualizer
}); });
renderLinks(ctx, visibleLinks, data.nodes); renderLinks(ctx, visibleLinks, data.nodes);
data.particles.splice(0, data.particles.length, ...renderParticles(ctx, data.particles, data.nodes, visibleNodeIds)); data.particles.splice(
0,
data.particles.length,
...renderParticles(ctx, data.particles, data.nodes, visibleNodeIds)
);
renderNodes(ctx, visibleNodes, hoveredNodeId); renderNodes(ctx, visibleNodes, hoveredNodeId);
ctx.restore(); ctx.restore();
@@ -820,31 +973,40 @@ export function PacketVisualizer({ packets, contacts, config }: PacketVisualizer
requestAnimationFrame(animate); requestAnimationFrame(animate);
}; };
animate(); animate();
return () => { running = false; }; return () => {
running = false;
};
}, [render]); }, [render]);
// Mouse handlers // Mouse handlers
const screenToGraph = useCallback((screenX: number, screenY: number) => { const screenToGraph = useCallback(
(screenX: number, screenY: number) => {
const { width, height } = dimensions; const { width, height } = dimensions;
const cx = (screenX - width / 2) / transform.scale - transform.x + width / 2; const cx = (screenX - width / 2) / transform.scale - transform.x + width / 2;
const cy = (screenY - height / 2) / transform.scale - transform.y + height / 2; const cy = (screenY - height / 2) / transform.scale - transform.y + height / 2;
return { x: cx, y: cy }; return { x: cx, y: cy };
}, [dimensions, transform]); },
[dimensions, transform]
);
const findNodeAt = useCallback((gx: number, gy: number) => { const findNodeAt = useCallback(
(gx: number, gy: number) => {
for (const node of data.nodes.values()) { for (const node of data.nodes.values()) {
if (node.x == null || node.y == null) continue; if (node.x == null || node.y == null) continue;
if (Math.hypot(gx - node.x, gy - node.y) < 20) return node; if (Math.hypot(gx - node.x, gy - node.y) < 20) return node;
} }
return null; return null;
}, [data.nodes]); },
[data.nodes]
);
const handleMouseDown = useCallback((e: React.MouseEvent) => { const handleMouseDown = useCallback((e: React.MouseEvent) => {
isDraggingRef.current = true; isDraggingRef.current = true;
lastMouseRef.current = { x: e.clientX, y: e.clientY }; lastMouseRef.current = { x: e.clientX, y: e.clientY };
}, []); }, []);
const handleMouseMove = useCallback((e: React.MouseEvent) => { const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
@@ -857,10 +1019,17 @@ export function PacketVisualizer({ packets, contacts, config }: PacketVisualizer
const dy = e.clientY - lastMouseRef.current.y; const dy = e.clientY - lastMouseRef.current.y;
lastMouseRef.current = { x: e.clientX, y: e.clientY }; lastMouseRef.current = { x: e.clientX, y: e.clientY };
setTransform((t) => ({ ...t, x: t.x + dx / t.scale, y: t.y + dy / t.scale })); setTransform((t) => ({ ...t, x: t.x + dx / t.scale, y: t.y + dy / t.scale }));
}, [screenToGraph, findNodeAt]); },
[screenToGraph, findNodeAt]
);
const handleMouseUp = useCallback(() => { isDraggingRef.current = false; }, []); const handleMouseUp = useCallback(() => {
const handleMouseLeave = useCallback(() => { isDraggingRef.current = false; setHoveredNodeId(null); }, []); isDraggingRef.current = false;
}, []);
const handleMouseLeave = useCallback(() => {
isDraggingRef.current = false;
setHoveredNodeId(null);
}, []);
const handleWheel = useCallback((e: WheelEvent) => { const handleWheel = useCallback((e: WheelEvent) => {
e.preventDefault(); e.preventDefault();
@@ -903,7 +1072,10 @@ export function PacketVisualizer({ packets, contacts, config }: PacketVisualizer
<div className="text-muted-foreground font-medium mb-1">Packets</div> <div className="text-muted-foreground font-medium mb-1">Packets</div>
{PACKET_LEGEND_ITEMS.map((item) => ( {PACKET_LEGEND_ITEMS.map((item) => (
<div key={item.label} className="flex items-center gap-2"> <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 }}> <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} {item.label}
</div> </div>
<span>{item.description}</span> <span>{item.description}</span>
@@ -918,34 +1090,66 @@ export function PacketVisualizer({ packets, contacts, config }: PacketVisualizer
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div>Nodes: {data.stats.nodes}</div> <div>Nodes: {data.stats.nodes}</div>
<div>Links: {data.stats.links}</div> <div>Links: {data.stats.links}</div>
<div className="text-muted-foreground">Processed: {data.stats.processed} | Animated: {data.stats.animated}</div> <div className="text-muted-foreground">
Processed: {data.stats.processed} | Animated: {data.stats.animated}
</div>
<div className="border-t border-border pt-2 mt-1 flex flex-col gap-2"> <div className="border-t border-border pt-2 mt-1 flex flex-col gap-2">
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<Checkbox checked={showAmbiguousPaths} onCheckedChange={(c) => setShowAmbiguousPaths(c === true)} /> <Checkbox
<span title="Show placeholder nodes for repeaters when the 1-byte prefix matches multiple contacts">Ambiguous repeaters</span> 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>
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<Checkbox checked={showAmbiguousNodes} onCheckedChange={(c) => setShowAmbiguousNodes(c === true)} /> <Checkbox
<span title="Show placeholder nodes for senders/recipients when only a 1-byte prefix is known">Ambiguous sender/recipient</span> 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>
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<Checkbox checked={filterOldRepeaters} onCheckedChange={(c) => setFilterOldRepeaters(c === true)} /> <Checkbox
<span title="Hide repeaters not heard within the last 48 hours">Hide repeaters &gt;48hrs heard</span> 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> </label>
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<Checkbox checked={letEmDrift} onCheckedChange={(c) => setLetEmDrift(c === true)} /> <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> <span title="When enabled, the graph continuously reorganizes itself into a better layout">
Let &apos;em drift
</span>
</label> </label>
<div className="flex flex-col gap-1 mt-1"> <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."> <label
className="text-muted-foreground"
title="How strongly nodes repel each other. Higher values spread nodes out more."
>
Repulsion: {Math.abs(chargeStrength)} Repulsion: {Math.abs(chargeStrength)}
</label> </label>
<input <input
type="range" min="50" max="2500" value={Math.abs(chargeStrength)} type="range"
min="50"
max="2500"
value={Math.abs(chargeStrength)}
onChange={(e) => setChargeStrength(-parseInt(e.target.value))} onChange={(e) => setChargeStrength(-parseInt(e.target.value))}
className="w-full h-2 bg-border rounded-lg appearance-none cursor-pointer accent-primary" 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>
</div> </div>
</div> </div>
</div> </div>
+2 -10
View File
@@ -38,11 +38,7 @@ export function VisualizerView({ packets, contacts, config }: VisualizerViewProp
<TabsTrigger value="packets">Packet Feed</TabsTrigger> <TabsTrigger value="packets">Packet Feed</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="visualizer" className="flex-1 m-0 overflow-hidden"> <TabsContent value="visualizer" className="flex-1 m-0 overflow-hidden">
<PacketVisualizer <PacketVisualizer packets={packets} contacts={contacts} config={config} />
packets={packets}
contacts={contacts}
config={config}
/>
</TabsContent> </TabsContent>
<TabsContent value="packets" className="flex-1 m-0 overflow-hidden"> <TabsContent value="packets" className="flex-1 m-0 overflow-hidden">
<RawPacketList packets={packets} /> <RawPacketList packets={packets} />
@@ -59,11 +55,7 @@ export function VisualizerView({ packets, contacts, config }: VisualizerViewProp
fullScreen ? 'flex-1' : 'flex-1 border-r border-border' fullScreen ? 'flex-1' : 'flex-1 border-r border-border'
)} )}
> >
<PacketVisualizer <PacketVisualizer packets={packets} contacts={contacts} config={config} />
packets={packets}
contacts={contacts}
config={config}
/>
</div> </div>
{/* Packet feed panel - hidden when full screen */} {/* Packet feed panel - hidden when full screen */}