1381 lines
53 KiB
HTML
1381 lines
53 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>D3 Agent Coordination Graph - Claude Code DevTools</title>
|
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
:root {
|
|
--bg-primary: #1a1b26;
|
|
--bg-secondary: #24283b;
|
|
--bg-tertiary: #414868;
|
|
--text-primary: #c0caf5;
|
|
--text-secondary: #a9b1d6;
|
|
--text-muted: #565f89;
|
|
--accent-primary: #7aa2f7;
|
|
--accent-secondary: #bb9af7;
|
|
--accent-success: #9ece6a;
|
|
--accent-warning: #e0af68;
|
|
--accent-error: #f7768e;
|
|
--border-color: #414868;
|
|
--code-bg: #1f2335;
|
|
--agent-color: #7aa2f7;
|
|
--tool-color: #bb9af7;
|
|
--subagent-color: #9ece6a;
|
|
--user-color: #e0af68;
|
|
}
|
|
|
|
body {
|
|
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
line-height: 1.6;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
header {
|
|
background: var(--bg-secondary);
|
|
padding: 2rem;
|
|
border-bottom: 2px solid var(--border-color);
|
|
}
|
|
|
|
h1 {
|
|
font-size: 2rem;
|
|
color: var(--accent-primary);
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.tagline {
|
|
color: var(--text-secondary);
|
|
font-size: 1rem;
|
|
}
|
|
|
|
main {
|
|
max-width: 1600px;
|
|
margin: 0 auto;
|
|
padding: 2rem;
|
|
}
|
|
|
|
.controls {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 1.5rem;
|
|
margin-bottom: 2rem;
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 1rem;
|
|
}
|
|
|
|
.control-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
label {
|
|
color: var(--text-secondary);
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
input[type="range"] {
|
|
width: 100%;
|
|
accent-color: var(--accent-primary);
|
|
}
|
|
|
|
.range-value {
|
|
color: var(--accent-primary);
|
|
font-size: 0.85rem;
|
|
text-align: right;
|
|
}
|
|
|
|
button {
|
|
background: var(--accent-primary);
|
|
color: var(--bg-primary);
|
|
border: none;
|
|
padding: 0.75rem 1.5rem;
|
|
border-radius: 4px;
|
|
font-family: inherit;
|
|
font-size: 0.9rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
button:hover {
|
|
background: var(--accent-secondary);
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
button.secondary {
|
|
background: var(--bg-tertiary);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
button.secondary:hover {
|
|
background: var(--border-color);
|
|
}
|
|
|
|
.button-group {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.graph-container {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
margin-bottom: 2rem;
|
|
position: relative;
|
|
}
|
|
|
|
#graph {
|
|
width: 100%;
|
|
height: 700px;
|
|
border-radius: 4px;
|
|
background: var(--code-bg);
|
|
}
|
|
|
|
.legend {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 1.5rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.legend h3 {
|
|
color: var(--accent-primary);
|
|
margin-bottom: 1rem;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.legend-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 1rem;
|
|
}
|
|
|
|
.legend-section {
|
|
background: var(--code-bg);
|
|
padding: 1rem;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.legend-section h4 {
|
|
color: var(--accent-secondary);
|
|
font-size: 0.9rem;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
margin-bottom: 0.5rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.legend-circle {
|
|
width: 16px;
|
|
height: 16px;
|
|
border-radius: 50%;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.legend-line {
|
|
width: 30px;
|
|
height: 2px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.tooltip {
|
|
position: absolute;
|
|
background: var(--bg-secondary);
|
|
border: 2px solid var(--border-color);
|
|
border-radius: 6px;
|
|
padding: 1rem;
|
|
pointer-events: none;
|
|
opacity: 0;
|
|
transition: opacity 0.2s;
|
|
max-width: 350px;
|
|
z-index: 1000;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
|
}
|
|
|
|
.tooltip.show {
|
|
opacity: 1;
|
|
}
|
|
|
|
.tooltip-title {
|
|
color: var(--accent-primary);
|
|
font-weight: 600;
|
|
font-size: 1rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.tooltip-content {
|
|
color: var(--text-secondary);
|
|
font-size: 0.85rem;
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.tooltip-detail {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.tooltip-label {
|
|
color: var(--text-muted);
|
|
}
|
|
|
|
.tooltip-value {
|
|
color: var(--accent-success);
|
|
font-weight: 600;
|
|
}
|
|
|
|
.timeline-controls {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 1.5rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.timeline-controls h3 {
|
|
color: var(--accent-primary);
|
|
margin-bottom: 1rem;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.timeline-slider {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.timeline-slider input[type="range"] {
|
|
flex: 1;
|
|
}
|
|
|
|
.timeline-label {
|
|
color: var(--text-secondary);
|
|
font-size: 0.85rem;
|
|
min-width: 100px;
|
|
text-align: right;
|
|
}
|
|
|
|
.playback-controls {
|
|
display: flex;
|
|
gap: 0.75rem;
|
|
align-items: center;
|
|
}
|
|
|
|
.stats-panel {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 1.5rem;
|
|
margin-bottom: 2rem;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 1rem;
|
|
}
|
|
|
|
.stat-item {
|
|
background: var(--code-bg);
|
|
padding: 1rem;
|
|
border-radius: 4px;
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-value {
|
|
color: var(--accent-primary);
|
|
font-size: 2rem;
|
|
font-weight: 600;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.stat-label {
|
|
color: var(--text-muted);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.docs {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 2rem;
|
|
}
|
|
|
|
.docs h2 {
|
|
color: var(--accent-primary);
|
|
margin-bottom: 1.5rem;
|
|
font-size: 1.5rem;
|
|
}
|
|
|
|
.docs h3 {
|
|
color: var(--accent-secondary);
|
|
margin-top: 1.5rem;
|
|
margin-bottom: 0.75rem;
|
|
font-size: 1.1rem;
|
|
}
|
|
|
|
.docs p {
|
|
color: var(--text-secondary);
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.docs ul, .docs ol {
|
|
color: var(--text-secondary);
|
|
margin-left: 1.5rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.docs li {
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.docs code {
|
|
background: var(--code-bg);
|
|
color: var(--accent-success);
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.docs strong {
|
|
color: var(--accent-primary);
|
|
}
|
|
|
|
.docs a {
|
|
color: var(--accent-primary);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.docs a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
footer {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
color: var(--text-muted);
|
|
font-size: 0.85rem;
|
|
border-top: 1px solid var(--border-color);
|
|
}
|
|
|
|
footer a {
|
|
color: var(--accent-primary);
|
|
text-decoration: none;
|
|
}
|
|
|
|
footer a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.node {
|
|
cursor: pointer;
|
|
stroke-width: 2;
|
|
}
|
|
|
|
.link {
|
|
stroke-opacity: 0.6;
|
|
}
|
|
|
|
.node-label {
|
|
font-size: 11px;
|
|
pointer-events: none;
|
|
text-anchor: middle;
|
|
fill: var(--text-primary);
|
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.5; }
|
|
}
|
|
|
|
.node.active {
|
|
animation: pulse 1.5s ease-in-out infinite;
|
|
}
|
|
|
|
.file-input-wrapper {
|
|
position: relative;
|
|
display: inline-block;
|
|
}
|
|
|
|
.file-input-wrapper input[type="file"] {
|
|
position: absolute;
|
|
opacity: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
cursor: pointer;
|
|
}
|
|
|
|
input[type="file"]::-webkit-file-upload-button {
|
|
visibility: hidden;
|
|
}
|
|
|
|
.ws-status {
|
|
display: inline-block;
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 4px;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.ws-status.connected {
|
|
background: var(--accent-success);
|
|
color: var(--bg-primary);
|
|
}
|
|
|
|
.ws-status.disconnected {
|
|
background: var(--accent-error);
|
|
color: var(--text-primary);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>D3 Agent Coordination Graph</h1>
|
|
<p class="tagline">Force-directed visualization of multi-agent relationships, tool usage, and coordination patterns</p>
|
|
</header>
|
|
|
|
<main>
|
|
<section class="controls">
|
|
<div class="control-group">
|
|
<label for="charge-strength">Node Repulsion</label>
|
|
<input type="range" id="charge-strength" min="-500" max="-50" value="-150" step="10">
|
|
<div class="range-value" id="charge-value">-150</div>
|
|
</div>
|
|
<div class="control-group">
|
|
<label for="link-distance">Link Distance</label>
|
|
<input type="range" id="link-distance" min="30" max="200" value="80" step="10">
|
|
<div class="range-value" id="link-value">80</div>
|
|
</div>
|
|
<div class="control-group">
|
|
<label for="link-strength">Link Strength</label>
|
|
<input type="range" id="link-strength" min="0" max="1" value="0.5" step="0.1">
|
|
<div class="range-value" id="strength-value">0.5</div>
|
|
</div>
|
|
<div class="control-group">
|
|
<label>Data Source</label>
|
|
<div class="button-group">
|
|
<button onclick="loadSampleData()">Sample Data</button>
|
|
<div class="file-input-wrapper">
|
|
<button class="secondary">Import Events</button>
|
|
<input type="file" id="file-input" accept=".json,.jsonl" onchange="handleFileImport(event)">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="control-group">
|
|
<label>Graph Actions</label>
|
|
<div class="button-group">
|
|
<button class="secondary" onclick="resetSimulation()">Reset Layout</button>
|
|
<button class="secondary" onclick="clearGraph()">Clear</button>
|
|
</div>
|
|
</div>
|
|
<div class="control-group">
|
|
<label>WebSocket Connection</label>
|
|
<div id="ws-status" class="ws-status disconnected">Disconnected</div>
|
|
<button class="secondary" onclick="toggleWebSocket()" id="ws-toggle">Connect</button>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="stats-panel">
|
|
<div class="stats-grid">
|
|
<div class="stat-item">
|
|
<div class="stat-value" id="stat-nodes">0</div>
|
|
<div class="stat-label">Total Nodes</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-value" id="stat-agents">0</div>
|
|
<div class="stat-label">Agents</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-value" id="stat-tools">0</div>
|
|
<div class="stat-label">Tools</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-value" id="stat-subagents">0</div>
|
|
<div class="stat-label">Subagents</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-value" id="stat-links">0</div>
|
|
<div class="stat-label">Connections</div>
|
|
</div>
|
|
<div class="stat-item">
|
|
<div class="stat-value" id="stat-events">0</div>
|
|
<div class="stat-label">Events</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="timeline-controls">
|
|
<h3>Timeline Replay</h3>
|
|
<div class="timeline-slider">
|
|
<span class="timeline-label">Event 0 / 0</span>
|
|
<input type="range" id="timeline-slider" min="0" max="0" value="0" step="1">
|
|
</div>
|
|
<div class="playback-controls">
|
|
<button onclick="playTimeline()">▶ Play</button>
|
|
<button class="secondary" onclick="pauseTimeline()">⏸ Pause</button>
|
|
<button class="secondary" onclick="resetTimeline()">⏮ Reset</button>
|
|
<div class="control-group" style="flex: 1;">
|
|
<label for="playback-speed">Speed</label>
|
|
<input type="range" id="playback-speed" min="100" max="2000" value="500" step="100">
|
|
<div class="range-value" id="speed-value">500ms</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="graph-container">
|
|
<svg id="graph"></svg>
|
|
<div class="tooltip" id="tooltip">
|
|
<div class="tooltip-title" id="tooltip-title"></div>
|
|
<div class="tooltip-content" id="tooltip-content"></div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="legend">
|
|
<h3>Legend</h3>
|
|
<div class="legend-grid">
|
|
<div class="legend-section">
|
|
<h4>Node Types</h4>
|
|
<div class="legend-item">
|
|
<div class="legend-circle" style="background: var(--agent-color);"></div>
|
|
<span>Agent Session</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-circle" style="background: var(--tool-color);"></div>
|
|
<span>Tool</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-circle" style="background: var(--subagent-color);"></div>
|
|
<span>Subagent</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-circle" style="background: var(--user-color);"></div>
|
|
<span>User Prompt</span>
|
|
</div>
|
|
</div>
|
|
<div class="legend-section">
|
|
<h4>Link Types</h4>
|
|
<div class="legend-item">
|
|
<div class="legend-line" style="background: var(--accent-primary);"></div>
|
|
<span>Uses Tool</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-line" style="background: var(--accent-success);"></div>
|
|
<span>Spawns Subagent</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-line" style="background: var(--accent-warning); opacity: 0.5;"></div>
|
|
<span>Temporal Sequence</span>
|
|
</div>
|
|
<div class="legend-item">
|
|
<div class="legend-line" style="background: var(--accent-error);"></div>
|
|
<span>Error Propagation</span>
|
|
</div>
|
|
</div>
|
|
<div class="legend-section">
|
|
<h4>Node Size</h4>
|
|
<p style="font-size: 0.85rem; color: var(--text-muted);">
|
|
Node size represents activity level (event count). Larger nodes indicate more events/interactions.
|
|
</p>
|
|
</div>
|
|
<div class="legend-section">
|
|
<h4>Interactions</h4>
|
|
<p style="font-size: 0.85rem; color: var(--text-muted);">
|
|
• Drag nodes to reposition<br>
|
|
• Scroll to zoom in/out<br>
|
|
• Hover for details<br>
|
|
• Click to focus
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="docs">
|
|
<h2>About This Tool</h2>
|
|
|
|
<h3>Purpose</h3>
|
|
<p>The D3 Agent Coordination Graph visualizes the complex relationships between Claude Code agents, tools, and subagents in multi-agent systems. It uses D3's force-directed graph layout to reveal coordination patterns, tool usage frequencies, and agent spawning hierarchies through an interactive network visualization.</p>
|
|
|
|
<h3>Features</h3>
|
|
<ul>
|
|
<li><strong>Force-Directed Layout</strong> - Automatic physics-based node positioning with configurable forces</li>
|
|
<li><strong>Interactive Node Dragging</strong> - Manually reposition nodes to explore relationships</li>
|
|
<li><strong>Zoom & Pan</strong> - Navigate large coordination graphs with smooth zoom and pan</li>
|
|
<li><strong>Real-time Updates</strong> - Optional WebSocket connection for live event streaming</li>
|
|
<li><strong>Timeline Replay</strong> - Step through events to see graph evolution over time</li>
|
|
<li><strong>Multi-Source Data</strong> - Import from files or connect to live observability server</li>
|
|
<li><strong>Activity-Based Sizing</strong> - Node size reflects event count and usage frequency</li>
|
|
<li><strong>Color-Coded Entities</strong> - Visual distinction between agents, tools, and subagents</li>
|
|
<li><strong>Rich Tooltips</strong> - Detailed information on hover with event counts and metadata</li>
|
|
<li><strong>Configurable Physics</strong> - Adjust repulsion, link distance, and connection strength</li>
|
|
</ul>
|
|
|
|
<h3>Web Research Integration</h3>
|
|
<p><strong>Source:</strong> <a href="https://observablehq.com/@d3/force-directed-graph" target="_blank">D3 Force-Directed Graph - Observable</a></p>
|
|
<p><strong>Techniques Applied:</strong></p>
|
|
<ul>
|
|
<li><strong>Force Simulation Setup</strong> - Implemented <code>d3.forceSimulation()</code> with <code>forceLink()</code> for connection strength, <code>forceManyBody()</code> for node repulsion, and <code>forceCenter()</code> for centering. Each force is independently configurable via UI controls.</li>
|
|
<li><strong>Node Drag Behavior</strong> - Applied <code>d3.drag()</code> with three event handlers: <code>dragstarted</code> fixes node position and restarts simulation alpha, <code>dragged</code> updates coordinates in real-time, and <code>dragended</code> releases constraints while allowing simulation to settle.</li>
|
|
<li><strong>Tick-Based Animation</strong> - Utilized simulation's <code>tick</code> event to continuously update SVG element positions (links via <code>x1, y1, x2, y2</code> attributes and nodes via transform translation), creating smooth physics-based animation.</li>
|
|
</ul>
|
|
|
|
<h3>Graph Data Structure</h3>
|
|
<p>The visualization processes Claude Code hook events into a graph structure:</p>
|
|
<ul>
|
|
<li><strong>Nodes</strong> - Represent entities: Agent sessions (source_app + session_id), Tools (from PreToolUse/PostToolUse), Subagents (from SubagentStop), User prompts (from UserPromptSubmit)</li>
|
|
<li><strong>Links</strong> - Represent relationships: Agent→Tool (usage), Agent→Subagent (spawning), Event→Event (temporal sequence), Agent→Error (error propagation)</li>
|
|
<li><strong>Node Properties</strong> - id (unique identifier), type (agent/tool/subagent/user), label (display name), eventCount (activity level), metadata (additional context)</li>
|
|
<li><strong>Link Properties</strong> - source (from node), target (to node), type (relationship kind), strength (connection weight), events (supporting events)</li>
|
|
</ul>
|
|
|
|
<h3>Usage</h3>
|
|
<ol>
|
|
<li><strong>Load Data</strong> - Click "Sample Data" to load example multi-agent scenario, or use "Import Events" to load .json/.jsonl hook event files</li>
|
|
<li><strong>Adjust Physics</strong> - Tune node repulsion, link distance, and link strength sliders to optimize layout for your data size</li>
|
|
<li><strong>Explore Graph</strong> - Drag nodes to reposition, scroll to zoom, hover for tooltips showing event counts and details</li>
|
|
<li><strong>Timeline Replay</strong> - Use timeline slider to step through events chronologically, or click Play to auto-replay graph evolution</li>
|
|
<li><strong>Connect Live</strong> - Click "Connect" under WebSocket to stream real-time events from observability server (requires server at localhost:4000)</li>
|
|
<li><strong>Analyze Patterns</strong> - Identify heavily-used tools (large purple nodes), agent hierarchies (green subagent connections), and coordination bottlenecks</li>
|
|
</ol>
|
|
|
|
<h3>Hook Event Mapping</h3>
|
|
<p>The tool processes these Claude Code hook events:</p>
|
|
<ul>
|
|
<li><strong>PreToolUse</strong> - Creates Agent→Tool link, increases tool activity count</li>
|
|
<li><strong>PostToolUse</strong> - Updates link with success/error status, adds error propagation links on failure</li>
|
|
<li><strong>SubagentStop</strong> - Creates Subagent node and Agent→Subagent spawn link</li>
|
|
<li><strong>UserPromptSubmit</strong> - Creates User node and Agent→User interaction link</li>
|
|
<li><strong>Notification</strong> - Creates coordination checkpoint markers in timeline</li>
|
|
</ul>
|
|
|
|
<h3>Sample Data Scenario</h3>
|
|
<p>The included sample data demonstrates a realistic multi-agent workflow:</p>
|
|
<ul>
|
|
<li>Main agent (infinite-agents session) orchestrates the workflow</li>
|
|
<li>Uses Read, Write, Bash, and Grep tools extensively</li>
|
|
<li>Spawns 3 parallel subagents for concurrent code generation tasks</li>
|
|
<li>Each subagent uses different tool combinations (Read+Write, Grep+Edit, Bash+Write)</li>
|
|
<li>User provides 2 prompts that trigger agent coordination</li>
|
|
<li>One subagent encounters an error, creating error propagation path</li>
|
|
</ul>
|
|
|
|
<h3>WebSocket Integration</h3>
|
|
<p>Connect to the Claude Code hooks observability server for real-time updates:</p>
|
|
<ol>
|
|
<li>Start the observability server: <code>./scripts/start-system.sh</code></li>
|
|
<li>Click "Connect" in the WebSocket Connection section</li>
|
|
<li>Graph will update automatically as new events arrive</li>
|
|
<li>New nodes animate in with transitions, links strengthen with repeated usage</li>
|
|
<li>Compatible with the multi-agent observability system architecture</li>
|
|
</ol>
|
|
</section>
|
|
</main>
|
|
|
|
<footer>
|
|
<p>Claude Code DevTools | D3 Agent Coordination Graph (Iteration 11)</p>
|
|
<p>Web Source: <a href="https://observablehq.com/@d3/force-directed-graph" target="_blank">D3 Force-Directed Graph - Observable</a></p>
|
|
</footer>
|
|
|
|
<script>
|
|
// Graph state
|
|
let nodes = [];
|
|
let links = [];
|
|
let allEvents = [];
|
|
let simulation;
|
|
let svg;
|
|
let g;
|
|
let nodeElements;
|
|
let linkElements;
|
|
let labelElements;
|
|
let zoom;
|
|
let wsConnection = null;
|
|
let timelineInterval = null;
|
|
let currentTimelineIndex = 0;
|
|
|
|
// Color mapping
|
|
const nodeColors = {
|
|
agent: 'var(--agent-color)',
|
|
tool: 'var(--tool-color)',
|
|
subagent: 'var(--subagent-color)',
|
|
user: 'var(--user-color)'
|
|
};
|
|
|
|
const linkColors = {
|
|
uses: 'var(--accent-primary)',
|
|
spawns: 'var(--accent-success)',
|
|
temporal: 'var(--accent-warning)',
|
|
error: 'var(--accent-error)'
|
|
};
|
|
|
|
// Initialize SVG
|
|
function initializeSVG() {
|
|
const container = document.getElementById('graph');
|
|
const width = container.clientWidth;
|
|
const height = container.clientHeight;
|
|
|
|
svg = d3.select('#graph')
|
|
.attr('width', width)
|
|
.attr('height', height);
|
|
|
|
// Clear existing content
|
|
svg.selectAll('*').remove();
|
|
|
|
// Add zoom behavior
|
|
zoom = d3.zoom()
|
|
.scaleExtent([0.1, 4])
|
|
.on('zoom', (event) => {
|
|
g.attr('transform', event.transform);
|
|
});
|
|
|
|
svg.call(zoom);
|
|
|
|
// Create container group for zoom/pan
|
|
g = svg.append('g');
|
|
|
|
// Initialize force simulation
|
|
initializeSimulation();
|
|
}
|
|
|
|
// Initialize D3 force simulation
|
|
function initializeSimulation() {
|
|
const width = document.getElementById('graph').clientWidth;
|
|
const height = document.getElementById('graph').clientHeight;
|
|
|
|
simulation = d3.forceSimulation(nodes)
|
|
.force('link', d3.forceLink(links)
|
|
.id(d => d.id)
|
|
.distance(parseFloat(document.getElementById('link-distance').value))
|
|
.strength(parseFloat(document.getElementById('link-strength').value)))
|
|
.force('charge', d3.forceManyBody()
|
|
.strength(parseFloat(document.getElementById('charge-strength').value)))
|
|
.force('center', d3.forceCenter(width / 2, height / 2))
|
|
.force('collision', d3.forceCollide().radius(d => getNodeRadius(d) + 5))
|
|
.on('tick', ticked);
|
|
}
|
|
|
|
// Tick function - updates positions on each simulation step
|
|
function ticked() {
|
|
if (linkElements) {
|
|
linkElements
|
|
.attr('x1', d => d.source.x)
|
|
.attr('y1', d => d.source.y)
|
|
.attr('x2', d => d.target.x)
|
|
.attr('y2', d => d.target.y);
|
|
}
|
|
|
|
if (nodeElements) {
|
|
nodeElements
|
|
.attr('cx', d => d.x)
|
|
.attr('cy', d => d.y);
|
|
}
|
|
|
|
if (labelElements) {
|
|
labelElements
|
|
.attr('x', d => d.x)
|
|
.attr('y', d => d.y + 4);
|
|
}
|
|
}
|
|
|
|
// Get node radius based on activity
|
|
function getNodeRadius(node) {
|
|
const baseSize = 8;
|
|
const activityMultiplier = Math.sqrt(node.eventCount || 1);
|
|
return baseSize + activityMultiplier * 2;
|
|
}
|
|
|
|
// Render the graph
|
|
function renderGraph() {
|
|
// Remove existing elements
|
|
g.selectAll('*').remove();
|
|
|
|
// Create links
|
|
linkElements = g.append('g')
|
|
.selectAll('line')
|
|
.data(links)
|
|
.join('line')
|
|
.attr('class', 'link')
|
|
.attr('stroke', d => linkColors[d.type] || 'var(--border-color)')
|
|
.attr('stroke-width', d => Math.sqrt(d.strength || 1) * 2)
|
|
.attr('stroke-opacity', d => d.type === 'temporal' ? 0.3 : 0.6);
|
|
|
|
// Create nodes
|
|
nodeElements = g.append('g')
|
|
.selectAll('circle')
|
|
.data(nodes)
|
|
.join('circle')
|
|
.attr('class', 'node')
|
|
.attr('r', d => getNodeRadius(d))
|
|
.attr('fill', d => nodeColors[d.type] || 'var(--text-muted)')
|
|
.attr('stroke', d => d.active ? 'var(--accent-warning)' : 'var(--bg-primary)')
|
|
.call(dragBehavior())
|
|
.on('mouseover', handleNodeMouseOver)
|
|
.on('mouseout', handleNodeMouseOut)
|
|
.on('click', handleNodeClick);
|
|
|
|
// Create labels
|
|
labelElements = g.append('g')
|
|
.selectAll('text')
|
|
.data(nodes)
|
|
.join('text')
|
|
.attr('class', 'node-label')
|
|
.text(d => d.label)
|
|
.style('font-size', d => `${Math.max(10, getNodeRadius(d))}px`);
|
|
|
|
// Update simulation
|
|
simulation.nodes(nodes);
|
|
simulation.force('link').links(links);
|
|
simulation.alpha(1).restart();
|
|
|
|
// Update stats
|
|
updateStats();
|
|
}
|
|
|
|
// Drag behavior implementation
|
|
function dragBehavior() {
|
|
function dragstarted(event, d) {
|
|
if (!event.active) simulation.alphaTarget(0.3).restart();
|
|
d.fx = d.x;
|
|
d.fy = d.y;
|
|
}
|
|
|
|
function dragged(event, d) {
|
|
d.fx = event.x;
|
|
d.fy = event.y;
|
|
}
|
|
|
|
function dragended(event, d) {
|
|
if (!event.active) simulation.alphaTarget(0);
|
|
d.fx = null;
|
|
d.fy = null;
|
|
}
|
|
|
|
return d3.drag()
|
|
.on('start', dragstarted)
|
|
.on('drag', dragged)
|
|
.on('end', dragended);
|
|
}
|
|
|
|
// Handle node mouse over
|
|
function handleNodeMouseOver(event, d) {
|
|
const tooltip = document.getElementById('tooltip');
|
|
const title = document.getElementById('tooltip-title');
|
|
const content = document.getElementById('tooltip-content');
|
|
|
|
title.textContent = d.label;
|
|
|
|
let details = `
|
|
<div class="tooltip-detail">
|
|
<span class="tooltip-label">Type:</span>
|
|
<span class="tooltip-value">${d.type}</span>
|
|
</div>
|
|
<div class="tooltip-detail">
|
|
<span class="tooltip-label">Events:</span>
|
|
<span class="tooltip-value">${d.eventCount || 0}</span>
|
|
</div>
|
|
`;
|
|
|
|
if (d.metadata) {
|
|
Object.entries(d.metadata).forEach(([key, value]) => {
|
|
details += `
|
|
<div class="tooltip-detail">
|
|
<span class="tooltip-label">${key}:</span>
|
|
<span class="tooltip-value">${value}</span>
|
|
</div>
|
|
`;
|
|
});
|
|
}
|
|
|
|
content.innerHTML = details;
|
|
|
|
tooltip.style.left = `${event.pageX + 10}px`;
|
|
tooltip.style.top = `${event.pageY + 10}px`;
|
|
tooltip.classList.add('show');
|
|
|
|
// Highlight connected nodes
|
|
d3.select(event.currentTarget).attr('stroke-width', 4);
|
|
}
|
|
|
|
// Handle node mouse out
|
|
function handleNodeMouseOut(event, d) {
|
|
const tooltip = document.getElementById('tooltip');
|
|
tooltip.classList.remove('show');
|
|
d3.select(event.currentTarget).attr('stroke-width', 2);
|
|
}
|
|
|
|
// Handle node click
|
|
function handleNodeClick(event, d) {
|
|
console.log('Node clicked:', d);
|
|
|
|
// Highlight connected links
|
|
linkElements
|
|
.attr('stroke-opacity', link =>
|
|
(link.source.id === d.id || link.target.id === d.id) ? 1 : 0.2
|
|
);
|
|
|
|
// Highlight connected nodes
|
|
const connectedIds = new Set();
|
|
links.forEach(link => {
|
|
if (link.source.id === d.id) connectedIds.add(link.target.id);
|
|
if (link.target.id === d.id) connectedIds.add(link.source.id);
|
|
});
|
|
|
|
nodeElements
|
|
.attr('opacity', node =>
|
|
node.id === d.id || connectedIds.has(node.id) ? 1 : 0.3
|
|
);
|
|
|
|
labelElements
|
|
.attr('opacity', node =>
|
|
node.id === d.id || connectedIds.has(node.id) ? 1 : 0.3
|
|
);
|
|
}
|
|
|
|
// Update statistics
|
|
function updateStats() {
|
|
document.getElementById('stat-nodes').textContent = nodes.length;
|
|
document.getElementById('stat-agents').textContent =
|
|
nodes.filter(n => n.type === 'agent').length;
|
|
document.getElementById('stat-tools').textContent =
|
|
nodes.filter(n => n.type === 'tool').length;
|
|
document.getElementById('stat-subagents').textContent =
|
|
nodes.filter(n => n.type === 'subagent').length;
|
|
document.getElementById('stat-links').textContent = links.length;
|
|
document.getElementById('stat-events').textContent = allEvents.length;
|
|
}
|
|
|
|
// Process events into graph structure
|
|
function processEvents(events) {
|
|
const nodeMap = new Map();
|
|
const linkMap = new Map();
|
|
|
|
events.forEach((event, index) => {
|
|
const sessionKey = `${event.source_app}-${event.session_id}`;
|
|
|
|
// Create/update agent node
|
|
if (!nodeMap.has(sessionKey)) {
|
|
nodeMap.set(sessionKey, {
|
|
id: sessionKey,
|
|
type: 'agent',
|
|
label: event.source_app || 'Agent',
|
|
eventCount: 0,
|
|
metadata: { session: event.session_id }
|
|
});
|
|
}
|
|
nodeMap.get(sessionKey).eventCount++;
|
|
|
|
// Process based on event type
|
|
if (event.hook_event_type === 'PreToolUse' || event.hook_event_type === 'PostToolUse') {
|
|
const toolName = event.payload?.tool_name || 'Unknown';
|
|
const toolKey = `tool-${toolName}`;
|
|
|
|
// Create/update tool node
|
|
if (!nodeMap.has(toolKey)) {
|
|
nodeMap.set(toolKey, {
|
|
id: toolKey,
|
|
type: 'tool',
|
|
label: toolName,
|
|
eventCount: 0,
|
|
metadata: {}
|
|
});
|
|
}
|
|
nodeMap.get(toolKey).eventCount++;
|
|
|
|
// Create agent->tool link
|
|
const linkKey = `${sessionKey}-${toolKey}`;
|
|
if (!linkMap.has(linkKey)) {
|
|
linkMap.set(linkKey, {
|
|
source: sessionKey,
|
|
target: toolKey,
|
|
type: 'uses',
|
|
strength: 0,
|
|
events: []
|
|
});
|
|
}
|
|
linkMap.get(linkKey).strength++;
|
|
linkMap.get(linkKey).events.push(event);
|
|
|
|
// Check for errors
|
|
if (event.hook_event_type === 'PostToolUse' && event.payload?.error) {
|
|
const errorKey = `error-${index}`;
|
|
nodeMap.set(errorKey, {
|
|
id: errorKey,
|
|
type: 'error',
|
|
label: 'Error',
|
|
eventCount: 1,
|
|
metadata: { message: event.payload.error }
|
|
});
|
|
|
|
linkMap.set(`${sessionKey}-${errorKey}`, {
|
|
source: sessionKey,
|
|
target: errorKey,
|
|
type: 'error',
|
|
strength: 1,
|
|
events: [event]
|
|
});
|
|
}
|
|
} else if (event.hook_event_type === 'SubagentStop') {
|
|
const subagentKey = `subagent-${event.session_id}-${index}`;
|
|
|
|
nodeMap.set(subagentKey, {
|
|
id: subagentKey,
|
|
type: 'subagent',
|
|
label: event.payload?.task || 'Subagent',
|
|
eventCount: 1,
|
|
metadata: { parent: sessionKey }
|
|
});
|
|
|
|
linkMap.set(`${sessionKey}-${subagentKey}`, {
|
|
source: sessionKey,
|
|
target: subagentKey,
|
|
type: 'spawns',
|
|
strength: 1,
|
|
events: [event]
|
|
});
|
|
} else if (event.hook_event_type === 'UserPromptSubmit') {
|
|
const userKey = `user-${index}`;
|
|
|
|
nodeMap.set(userKey, {
|
|
id: userKey,
|
|
type: 'user',
|
|
label: 'User Prompt',
|
|
eventCount: 1,
|
|
metadata: { prompt: event.payload?.prompt?.substring(0, 50) + '...' }
|
|
});
|
|
|
|
linkMap.set(`${userKey}-${sessionKey}`, {
|
|
source: userKey,
|
|
target: sessionKey,
|
|
type: 'temporal',
|
|
strength: 1,
|
|
events: [event]
|
|
});
|
|
}
|
|
});
|
|
|
|
return {
|
|
nodes: Array.from(nodeMap.values()),
|
|
links: Array.from(linkMap.values())
|
|
};
|
|
}
|
|
|
|
// Load sample data
|
|
function loadSampleData() {
|
|
allEvents = [
|
|
// Main agent session starts
|
|
{ source_app: 'infinite-agents', session_id: 'main-001', hook_event_type: 'UserPromptSubmit',
|
|
payload: { prompt: 'Generate 3 variations of the UI component' }, timestamp: '2025-10-09T10:00:00Z' },
|
|
|
|
// Main agent uses Read tool
|
|
{ source_app: 'infinite-agents', session_id: 'main-001', hook_event_type: 'PreToolUse',
|
|
payload: { tool_name: 'Read', tool_input: { file: 'specs/ui_spec.md' } }, timestamp: '2025-10-09T10:00:05Z' },
|
|
{ source_app: 'infinite-agents', session_id: 'main-001', hook_event_type: 'PostToolUse',
|
|
payload: { tool_name: 'Read' }, timestamp: '2025-10-09T10:00:06Z' },
|
|
|
|
// Main agent spawns 3 subagents
|
|
{ source_app: 'infinite-agents', session_id: 'sub-001', hook_event_type: 'SubagentStop',
|
|
payload: { task: 'Generate variant 1', parent_session: 'main-001' }, timestamp: '2025-10-09T10:00:10Z' },
|
|
{ source_app: 'infinite-agents', session_id: 'sub-002', hook_event_type: 'SubagentStop',
|
|
payload: { task: 'Generate variant 2', parent_session: 'main-001' }, timestamp: '2025-10-09T10:00:11Z' },
|
|
{ source_app: 'infinite-agents', session_id: 'sub-003', hook_event_type: 'SubagentStop',
|
|
payload: { task: 'Generate variant 3', parent_session: 'main-001' }, timestamp: '2025-10-09T10:00:12Z' },
|
|
|
|
// Subagent 1 uses tools
|
|
{ source_app: 'infinite-agents', session_id: 'sub-001', hook_event_type: 'PreToolUse',
|
|
payload: { tool_name: 'Read', tool_input: { file: 'templates/base.html' } }, timestamp: '2025-10-09T10:00:15Z' },
|
|
{ source_app: 'infinite-agents', session_id: 'sub-001', hook_event_type: 'PostToolUse',
|
|
payload: { tool_name: 'Read' }, timestamp: '2025-10-09T10:00:16Z' },
|
|
{ source_app: 'infinite-agents', session_id: 'sub-001', hook_event_type: 'PreToolUse',
|
|
payload: { tool_name: 'Write', tool_input: { file: 'output/variant1.html' } }, timestamp: '2025-10-09T10:00:20Z' },
|
|
{ source_app: 'infinite-agents', session_id: 'sub-001', hook_event_type: 'PostToolUse',
|
|
payload: { tool_name: 'Write' }, timestamp: '2025-10-09T10:00:21Z' },
|
|
|
|
// Subagent 2 uses tools
|
|
{ source_app: 'infinite-agents', session_id: 'sub-002', hook_event_type: 'PreToolUse',
|
|
payload: { tool_name: 'Grep', tool_input: { pattern: 'class=' } }, timestamp: '2025-10-09T10:00:15Z' },
|
|
{ source_app: 'infinite-agents', session_id: 'sub-002', hook_event_type: 'PostToolUse',
|
|
payload: { tool_name: 'Grep' }, timestamp: '2025-10-09T10:00:17Z' },
|
|
{ source_app: 'infinite-agents', session_id: 'sub-002', hook_event_type: 'PreToolUse',
|
|
payload: { tool_name: 'Edit', tool_input: { file: 'output/variant2.html' } }, timestamp: '2025-10-09T10:00:22Z' },
|
|
{ source_app: 'infinite-agents', session_id: 'sub-002', hook_event_type: 'PostToolUse',
|
|
payload: { tool_name: 'Edit' }, timestamp: '2025-10-09T10:00:23Z' },
|
|
|
|
// Subagent 3 uses tools (with error)
|
|
{ source_app: 'infinite-agents', session_id: 'sub-003', hook_event_type: 'PreToolUse',
|
|
payload: { tool_name: 'Bash', tool_input: { command: 'npm test' } }, timestamp: '2025-10-09T10:00:16Z' },
|
|
{ source_app: 'infinite-agents', session_id: 'sub-003', hook_event_type: 'PostToolUse',
|
|
payload: { tool_name: 'Bash', error: 'Command failed' }, timestamp: '2025-10-09T10:00:18Z' },
|
|
{ source_app: 'infinite-agents', session_id: 'sub-003', hook_event_type: 'PreToolUse',
|
|
payload: { tool_name: 'Write', tool_input: { file: 'output/variant3.html' } }, timestamp: '2025-10-09T10:00:25Z' },
|
|
{ source_app: 'infinite-agents', session_id: 'sub-003', hook_event_type: 'PostToolUse',
|
|
payload: { tool_name: 'Write' }, timestamp: '2025-10-09T10:00:26Z' },
|
|
|
|
// Main agent collects results
|
|
{ source_app: 'infinite-agents', session_id: 'main-001', hook_event_type: 'PreToolUse',
|
|
payload: { tool_name: 'Bash', tool_input: { command: 'ls output/' } }, timestamp: '2025-10-09T10:00:30Z' },
|
|
{ source_app: 'infinite-agents', session_id: 'main-001', hook_event_type: 'PostToolUse',
|
|
payload: { tool_name: 'Bash' }, timestamp: '2025-10-09T10:00:31Z' },
|
|
|
|
// User reviews results
|
|
{ source_app: 'infinite-agents', session_id: 'main-001', hook_event_type: 'UserPromptSubmit',
|
|
payload: { prompt: 'Looks good! Generate 2 more variations based on variant 1' }, timestamp: '2025-10-09T10:01:00Z' }
|
|
];
|
|
|
|
const graph = processEvents(allEvents);
|
|
nodes = graph.nodes;
|
|
links = graph.links;
|
|
|
|
// Setup timeline
|
|
const timelineSlider = document.getElementById('timeline-slider');
|
|
timelineSlider.max = allEvents.length;
|
|
timelineSlider.value = allEvents.length;
|
|
currentTimelineIndex = allEvents.length;
|
|
updateTimelineLabel();
|
|
|
|
renderGraph();
|
|
}
|
|
|
|
// Handle file import
|
|
function handleFileImport(event) {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
try {
|
|
const content = e.target.result;
|
|
const events = file.name.endsWith('.jsonl')
|
|
? content.split('\n').filter(l => l.trim()).map(l => JSON.parse(l))
|
|
: JSON.parse(content);
|
|
|
|
allEvents = Array.isArray(events) ? events : [events];
|
|
const graph = processEvents(allEvents);
|
|
nodes = graph.nodes;
|
|
links = graph.links;
|
|
|
|
const timelineSlider = document.getElementById('timeline-slider');
|
|
timelineSlider.max = allEvents.length;
|
|
timelineSlider.value = allEvents.length;
|
|
currentTimelineIndex = allEvents.length;
|
|
updateTimelineLabel();
|
|
|
|
renderGraph();
|
|
} catch (error) {
|
|
alert('Error parsing file: ' + error.message);
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
// Reset simulation
|
|
function resetSimulation() {
|
|
if (simulation) {
|
|
// Clear fixed positions
|
|
nodes.forEach(node => {
|
|
node.fx = null;
|
|
node.fy = null;
|
|
});
|
|
|
|
simulation.alpha(1).restart();
|
|
}
|
|
}
|
|
|
|
// Clear graph
|
|
function clearGraph() {
|
|
nodes = [];
|
|
links = [];
|
|
allEvents = [];
|
|
currentTimelineIndex = 0;
|
|
renderGraph();
|
|
}
|
|
|
|
// Update timeline label
|
|
function updateTimelineLabel() {
|
|
const slider = document.getElementById('timeline-slider');
|
|
const label = document.querySelector('.timeline-label');
|
|
label.textContent = `Event ${currentTimelineIndex} / ${allEvents.length}`;
|
|
}
|
|
|
|
// Timeline controls
|
|
function playTimeline() {
|
|
if (timelineInterval) return;
|
|
|
|
const speed = parseInt(document.getElementById('playback-speed').value);
|
|
|
|
timelineInterval = setInterval(() => {
|
|
if (currentTimelineIndex >= allEvents.length) {
|
|
pauseTimeline();
|
|
return;
|
|
}
|
|
|
|
currentTimelineIndex++;
|
|
const slider = document.getElementById('timeline-slider');
|
|
slider.value = currentTimelineIndex;
|
|
updateTimelineFromSlider();
|
|
}, speed);
|
|
}
|
|
|
|
function pauseTimeline() {
|
|
if (timelineInterval) {
|
|
clearInterval(timelineInterval);
|
|
timelineInterval = null;
|
|
}
|
|
}
|
|
|
|
function resetTimeline() {
|
|
pauseTimeline();
|
|
currentTimelineIndex = 0;
|
|
const slider = document.getElementById('timeline-slider');
|
|
slider.value = 0;
|
|
updateTimelineFromSlider();
|
|
}
|
|
|
|
function updateTimelineFromSlider() {
|
|
const slider = document.getElementById('timeline-slider');
|
|
currentTimelineIndex = parseInt(slider.value);
|
|
updateTimelineLabel();
|
|
|
|
const eventsUpToNow = allEvents.slice(0, currentTimelineIndex);
|
|
const graph = processEvents(eventsUpToNow);
|
|
nodes = graph.nodes;
|
|
links = graph.links;
|
|
renderGraph();
|
|
}
|
|
|
|
// WebSocket connection
|
|
function toggleWebSocket() {
|
|
const button = document.getElementById('ws-toggle');
|
|
const status = document.getElementById('ws-status');
|
|
|
|
if (wsConnection) {
|
|
wsConnection.close();
|
|
wsConnection = null;
|
|
button.textContent = 'Connect';
|
|
status.textContent = 'Disconnected';
|
|
status.className = 'ws-status disconnected';
|
|
} else {
|
|
try {
|
|
wsConnection = new WebSocket('ws://localhost:4000/stream');
|
|
|
|
wsConnection.onopen = () => {
|
|
button.textContent = 'Disconnect';
|
|
status.textContent = 'Connected';
|
|
status.className = 'ws-status connected';
|
|
};
|
|
|
|
wsConnection.onmessage = (event) => {
|
|
try {
|
|
const newEvent = JSON.parse(event.data);
|
|
allEvents.push(newEvent);
|
|
|
|
const graph = processEvents(allEvents);
|
|
nodes = graph.nodes;
|
|
links = graph.links;
|
|
|
|
const timelineSlider = document.getElementById('timeline-slider');
|
|
timelineSlider.max = allEvents.length;
|
|
currentTimelineIndex = allEvents.length;
|
|
updateTimelineLabel();
|
|
|
|
renderGraph();
|
|
} catch (error) {
|
|
console.error('Error processing WebSocket message:', error);
|
|
}
|
|
};
|
|
|
|
wsConnection.onerror = (error) => {
|
|
console.error('WebSocket error:', error);
|
|
alert('WebSocket connection failed. Make sure the server is running at localhost:4000');
|
|
};
|
|
|
|
wsConnection.onclose = () => {
|
|
wsConnection = null;
|
|
button.textContent = 'Connect';
|
|
status.textContent = 'Disconnected';
|
|
status.className = 'ws-status disconnected';
|
|
};
|
|
} catch (error) {
|
|
alert('Failed to connect: ' + error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Control panel event listeners
|
|
document.getElementById('charge-strength').addEventListener('input', (e) => {
|
|
document.getElementById('charge-value').textContent = e.target.value;
|
|
if (simulation) {
|
|
simulation.force('charge').strength(parseFloat(e.target.value));
|
|
simulation.alpha(0.3).restart();
|
|
}
|
|
});
|
|
|
|
document.getElementById('link-distance').addEventListener('input', (e) => {
|
|
document.getElementById('link-value').textContent = e.target.value;
|
|
if (simulation) {
|
|
simulation.force('link').distance(parseFloat(e.target.value));
|
|
simulation.alpha(0.3).restart();
|
|
}
|
|
});
|
|
|
|
document.getElementById('link-strength').addEventListener('input', (e) => {
|
|
document.getElementById('strength-value').textContent = e.target.value;
|
|
if (simulation) {
|
|
simulation.force('link').strength(parseFloat(e.target.value));
|
|
simulation.alpha(0.3).restart();
|
|
}
|
|
});
|
|
|
|
document.getElementById('playback-speed').addEventListener('input', (e) => {
|
|
document.getElementById('speed-value').textContent = e.target.value + 'ms';
|
|
if (timelineInterval) {
|
|
pauseTimeline();
|
|
playTimeline();
|
|
}
|
|
});
|
|
|
|
document.getElementById('timeline-slider').addEventListener('input', updateTimelineFromSlider);
|
|
|
|
// Initialize on load
|
|
window.addEventListener('load', () => {
|
|
initializeSVG();
|
|
loadSampleData();
|
|
});
|
|
|
|
// Handle window resize
|
|
window.addEventListener('resize', () => {
|
|
const container = document.getElementById('graph');
|
|
const width = container.clientWidth;
|
|
const height = container.clientHeight;
|
|
|
|
svg.attr('width', width).attr('height', height);
|
|
|
|
if (simulation) {
|
|
simulation.force('center', d3.forceCenter(width / 2, height / 2));
|
|
simulation.alpha(0.3).restart();
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|