Skip to main content

phago_viz/
lib.rs

1//! # Phago Viz
2//!
3//! Self-contained HTML visualization for Phago colonies.
4//!
5//! Generates a single HTML file with embedded D3.js that shows:
6//! - Knowledge graph (force-directed network)
7//! - Agent canvas (2D spatial view)
8//! - Event timeline
9//! - Metrics dashboard with tick slider
10
11use phago_runtime::colony::{ColonySnapshot, ColonyEvent};
12use phago_core::types::Tick;
13
14/// Generate a self-contained HTML file with D3.js visualization.
15///
16/// The HTML embeds all data as JSON constants and loads D3.js from CDN.
17/// No server, no npm — just open the file in a browser.
18pub fn generate_html(
19    snapshots: &[ColonySnapshot],
20    events: &[(Tick, ColonyEvent)],
21) -> String {
22    let snapshots_json = serde_json::to_string(snapshots).unwrap_or_else(|_| "[]".to_string());
23    let events_json = serde_json::to_string(events).unwrap_or_else(|_| "[]".to_string());
24
25    format!(
26        r##"<!DOCTYPE html>
27<html lang="en">
28<head>
29<meta charset="UTF-8">
30<meta name="viewport" content="width=device-width, initial-scale=1.0">
31<title>Phago Colony Visualization</title>
32<style>
33* {{ margin: 0; padding: 0; box-sizing: border-box; }}
34body {{ background: #1a1a2e; color: #e0e0e0; font-family: 'Courier New', monospace; overflow: hidden; }}
35.container {{ display: grid; grid-template-columns: 1fr 1fr 280px; grid-template-rows: 1fr 200px 60px; height: 100vh; gap: 2px; background: #0f0f23; }}
36.panel {{ background: #1a1a2e; border: 1px solid #333366; border-radius: 4px; overflow: hidden; position: relative; }}
37.panel-title {{ position: absolute; top: 4px; left: 8px; font-size: 11px; color: #7777aa; text-transform: uppercase; letter-spacing: 1px; z-index: 10; }}
38#graph-panel {{ grid-column: 1; grid-row: 1; }}
39#agent-panel {{ grid-column: 2; grid-row: 1; }}
40#sidebar {{ grid-column: 3; grid-row: 1 / 3; padding: 12px; overflow-y: auto; }}
41#timeline-panel {{ grid-column: 1 / 3; grid-row: 2; }}
42#controls {{ grid-column: 1 / 4; grid-row: 3; display: flex; align-items: center; padding: 8px 16px; gap: 16px; }}
43#tick-slider {{ flex: 1; accent-color: #5555ff; }}
44#tick-label {{ font-size: 14px; color: #aaaadd; min-width: 100px; }}
45.stat-row {{ display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px solid #222244; font-size: 12px; }}
46.stat-label {{ color: #8888bb; }}
47.stat-value {{ color: #ddddff; font-weight: bold; }}
48.section-title {{ color: #9999cc; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; margin: 12px 0 6px 0; }}
49.legend {{ display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }}
50.legend-item {{ display: flex; align-items: center; gap: 4px; font-size: 10px; }}
51.legend-dot {{ width: 8px; height: 8px; border-radius: 50%; }}
52svg {{ width: 100%; height: 100%; }}
53.node-label {{ font-size: 9px; fill: #aaa; pointer-events: none; }}
54.tooltip {{ position: absolute; background: #222244; border: 1px solid #444488; padding: 6px 10px; border-radius: 4px; font-size: 11px; pointer-events: none; z-index: 100; display: none; }}
55h2 {{ font-size: 14px; color: #bbbbee; margin-bottom: 8px; }}
56#play-btn {{ background: #333366; border: 1px solid #5555aa; color: #ddddff; padding: 6px 16px; border-radius: 4px; cursor: pointer; font-family: inherit; }}
57#play-btn:hover {{ background: #444488; }}
58</style>
59</head>
60<body>
61<div class="container">
62  <div class="panel" id="graph-panel">
63    <div class="panel-title">Knowledge Graph</div>
64    <svg id="graph-svg"></svg>
65  </div>
66  <div class="panel" id="agent-panel">
67    <div class="panel-title">Agent Canvas</div>
68    <svg id="agent-svg"></svg>
69  </div>
70  <div class="panel" id="sidebar">
71    <h2>Phago Colony</h2>
72    <div class="section-title">Agents</div>
73    <div class="legend">
74      <div class="legend-item"><div class="legend-dot" style="background:#44cc44"></div> Digester</div>
75      <div class="legend-item"><div class="legend-dot" style="background:#cc4444"></div> Sentinel</div>
76      <div class="legend-item"><div class="legend-dot" style="background:#aa44cc"></div> Synthesizer</div>
77    </div>
78    <div class="section-title">Graph Nodes</div>
79    <div class="legend">
80      <div class="legend-item"><div class="legend-dot" style="background:#4488cc"></div> Concept</div>
81      <div class="legend-item"><div class="legend-dot" style="background:#ccaa22"></div> Insight</div>
82      <div class="legend-item"><div class="legend-dot" style="background:#cc4444"></div> Anomaly</div>
83    </div>
84    <div class="section-title">Metrics</div>
85    <div id="metrics-panel">
86      <div class="stat-row"><span class="stat-label">Tick</span><span class="stat-value" id="m-tick">0</span></div>
87      <div class="stat-row"><span class="stat-label">Nodes</span><span class="stat-value" id="m-nodes">0</span></div>
88      <div class="stat-row"><span class="stat-label">Edges</span><span class="stat-value" id="m-edges">0</span></div>
89      <div class="stat-row"><span class="stat-label">Agents Alive</span><span class="stat-value" id="m-agents">0</span></div>
90      <div class="stat-row"><span class="stat-label">Docs Digested</span><span class="stat-value" id="m-docs">0</span></div>
91    </div>
92    <div class="section-title">Events</div>
93    <div id="event-counts">
94      <div class="stat-row"><span class="stat-label">Transfers</span><span class="stat-value" id="m-transfers">0</span></div>
95      <div class="stat-row"><span class="stat-label">Integrations</span><span class="stat-value" id="m-integrations">0</span></div>
96      <div class="stat-row"><span class="stat-label">Symbioses</span><span class="stat-value" id="m-symbioses">0</span></div>
97      <div class="stat-row"><span class="stat-label">Dissolutions</span><span class="stat-value" id="m-dissolutions">0</span></div>
98      <div class="stat-row"><span class="stat-label">Deaths</span><span class="stat-value" id="m-deaths">0</span></div>
99    </div>
100  </div>
101  <div class="panel" id="timeline-panel">
102    <div class="panel-title">Event Timeline</div>
103    <svg id="timeline-svg"></svg>
104  </div>
105  <div id="controls">
106    <button id="play-btn">&#9654; Play</button>
107    <input type="range" id="tick-slider" min="0" max="0" value="0">
108    <span id="tick-label">Tick 0 / 0</span>
109  </div>
110</div>
111<div class="tooltip" id="tooltip"></div>
112
113<script src="https://d3js.org/d3.v7.min.js"></script>
114<script>
115const SNAPSHOTS = {snapshots};
116const EVENTS = {events};
117
118if (SNAPSHOTS.length === 0) {{
119  document.body.innerHTML = '<div style="padding:40px;color:#888">No snapshots recorded.</div>';
120}}
121
122const slider = document.getElementById('tick-slider');
123const tickLabel = document.getElementById('tick-label');
124const playBtn = document.getElementById('play-btn');
125const tooltip = document.getElementById('tooltip');
126
127slider.max = SNAPSHOTS.length - 1;
128let currentIdx = SNAPSHOTS.length - 1;
129slider.value = currentIdx;
130let playing = false;
131let playInterval = null;
132
133function showTooltip(text, x, y) {{
134  tooltip.style.display = 'block';
135  tooltip.textContent = text;
136  tooltip.style.left = (x + 10) + 'px';
137  tooltip.style.top = (y - 20) + 'px';
138}}
139function hideTooltip() {{ tooltip.style.display = 'none'; }}
140
141// --- Knowledge Graph ---
142const graphSvg = d3.select('#graph-svg');
143const graphG = graphSvg.append('g');
144let graphSim = null;
145
146function updateGraph(snap) {{
147  const width = document.getElementById('graph-panel').clientWidth;
148  const height = document.getElementById('graph-panel').clientHeight;
149
150  const nodeMap = {{}};
151  snap.nodes.forEach((n, i) => {{ nodeMap[n.label] = i; n.index = i; }});
152
153  const links = snap.edges.filter(e => nodeMap[e.from_label] !== undefined && nodeMap[e.to_label] !== undefined)
154    .map(e => ({{ source: nodeMap[e.from_label], target: nodeMap[e.to_label], weight: e.weight, co_activations: e.co_activations }}));
155
156  const nodeColor = d => {{
157    if (d.node_type === 'Insight') return '#ccaa22';
158    if (d.node_type === 'Anomaly') return '#cc4444';
159    return '#4488cc';
160  }};
161
162  // Links
163  const link = graphG.selectAll('line.graph-link').data(links, (d,i) => i);
164  link.exit().remove();
165  const linkEnter = link.enter().append('line').attr('class', 'graph-link');
166  const linkAll = linkEnter.merge(link)
167    .attr('stroke', '#334466').attr('stroke-opacity', d => Math.min(d.weight, 0.8))
168    .attr('stroke-width', d => Math.max(d.weight * 2, 0.5));
169
170  // Nodes
171  const node = graphG.selectAll('circle.graph-node').data(snap.nodes, d => d.label);
172  node.exit().remove();
173  const nodeEnter = node.enter().append('circle').attr('class', 'graph-node')
174    .on('mouseover', (ev, d) => showTooltip(`${{d.label}} (${{d.node_type}}) access:${{d.access_count}}`, ev.pageX, ev.pageY))
175    .on('mouseout', hideTooltip);
176  const nodeAll = nodeEnter.merge(node)
177    .attr('r', d => Math.max(3, Math.min(d.access_count * 1.5, 15)))
178    .attr('fill', nodeColor).attr('opacity', 0.85);
179
180  // Labels
181  const label = graphG.selectAll('text.node-label').data(snap.nodes, d => d.label);
182  label.exit().remove();
183  const labelEnter = label.enter().append('text').attr('class', 'node-label');
184  const labelAll = labelEnter.merge(label).text(d => d.label);
185
186  if (graphSim) graphSim.stop();
187  graphSim = d3.forceSimulation(snap.nodes)
188    .force('link', d3.forceLink(links).distance(60))
189    .force('charge', d3.forceManyBody().strength(-40))
190    .force('center', d3.forceCenter(width / 2, height / 2))
191    .on('tick', () => {{
192      linkAll.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
193             .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
194      nodeAll.attr('cx', d => d.x).attr('cy', d => d.y);
195      labelAll.attr('x', d => d.x + 8).attr('y', d => d.y + 3);
196    }});
197}}
198
199// --- Agent Canvas ---
200const agentSvg = d3.select('#agent-svg');
201
202function updateAgents(snap) {{
203  const width = document.getElementById('agent-panel').clientWidth;
204  const height = document.getElementById('agent-panel').clientHeight;
205
206  // Compute scale from agent positions
207  let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
208  snap.agents.forEach(a => {{
209    minX = Math.min(minX, a.position.x); maxX = Math.max(maxX, a.position.x);
210    minY = Math.min(minY, a.position.y); maxY = Math.max(maxY, a.position.y);
211  }});
212  const pad = 40;
213  const rangeX = Math.max(maxX - minX, 1);
214  const rangeY = Math.max(maxY - minY, 1);
215  const scaleX = d => pad + (d.position.x - minX) / rangeX * (width - 2 * pad);
216  const scaleY = d => pad + (d.position.y - minY) / rangeY * (height - 2 * pad);
217
218  const agentColor = d => {{
219    if (d.agent_type === 'digester') return '#44cc44';
220    if (d.agent_type === 'sentinel') return '#cc4444';
221    if (d.agent_type === 'synthesizer') return '#aa44cc';
222    return '#888888';
223  }};
224
225  const circ = agentSvg.selectAll('circle.agent').data(snap.agents, d => d.id.toString());
226  circ.exit().transition().duration(200).attr('r', 0).remove();
227  const circEnter = circ.enter().append('circle').attr('class', 'agent')
228    .attr('r', 0)
229    .on('mouseover', (ev, d) => showTooltip(`${{d.agent_type}} age:${{d.age}} perm:${{d.permeability.toFixed(2)}} vocab:${{d.vocabulary_size}}`, ev.pageX, ev.pageY))
230    .on('mouseout', hideTooltip);
231  circEnter.merge(circ).transition().duration(300)
232    .attr('cx', scaleX).attr('cy', scaleY)
233    .attr('r', 10)
234    .attr('fill', agentColor)
235    .attr('opacity', d => 0.3 + (1.0 - d.permeability) * 0.7)
236    .attr('stroke', '#ffffff22').attr('stroke-width', 1);
237
238  // Labels
239  const lbl = agentSvg.selectAll('text.agent-label').data(snap.agents, d => d.id.toString());
240  lbl.exit().remove();
241  const lblEnter = lbl.enter().append('text').attr('class', 'agent-label')
242    .attr('font-size', '9px').attr('fill', '#888');
243  lblEnter.merge(lbl).transition().duration(300)
244    .attr('x', d => scaleX(d) + 12).attr('y', d => scaleY(d) + 3)
245    .text(d => d.agent_type.slice(0, 3));
246}}
247
248// --- Timeline ---
249const timelineSvg = d3.select('#timeline-svg');
250
251function initTimeline() {{
252  const width = document.getElementById('timeline-panel').clientWidth;
253  const height = document.getElementById('timeline-panel').clientHeight;
254  const pad = {{ left: 40, right: 20, top: 25, bottom: 20 }};
255
256  if (EVENTS.length === 0) return;
257
258  const maxTick = Math.max(...EVENTS.map(e => e[0]));
259  const x = d3.scaleLinear().domain([0, maxTick]).range([pad.left, width - pad.right]);
260
261  // Color by event type
262  const eventColor = e => {{
263    const t = e[1];
264    if (t.CapabilityExported) return '#4488cc';
265    if (t.CapabilityIntegrated) return '#44aacc';
266    if (t.Symbiosis) return '#44cc44';
267    if (t.Dissolved) return '#ccaa22';
268    if (t.Died) return '#222222';
269    if (t.Presented) return '#666688';
270    return '#444444';
271  }};
272
273  // Y jitter by type
274  const eventY = e => {{
275    const t = e[1];
276    if (t.CapabilityExported || t.CapabilityIntegrated) return 0.2;
277    if (t.Symbiosis) return 0.4;
278    if (t.Dissolved) return 0.6;
279    if (t.Died) return 0.8;
280    return 0.5;
281  }};
282
283  const significant = EVENTS.filter(e => {{
284    const t = e[1];
285    return t.CapabilityExported || t.CapabilityIntegrated || t.Symbiosis || t.Dissolved || t.Died;
286  }});
287
288  const yScale = d3.scaleLinear().domain([0, 1]).range([pad.top, height - pad.bottom]);
289
290  timelineSvg.selectAll('circle.event-dot').data(significant)
291    .enter().append('circle').attr('class', 'event-dot')
292    .attr('cx', d => x(d[0]))
293    .attr('cy', d => yScale(eventY(d)) + (Math.random() - 0.5) * 10)
294    .attr('r', 3)
295    .attr('fill', eventColor)
296    .attr('opacity', 0.7)
297    .on('mouseover', (ev, d) => {{
298      const t = d[1];
299      const type = Object.keys(t)[0] || 'Event';
300      showTooltip(`Tick ${{d[0]}}: ${{type}}`, ev.pageX, ev.pageY);
301    }})
302    .on('mouseout', hideTooltip);
303
304  // Tick cursor line
305  timelineSvg.append('line').attr('id', 'tick-cursor')
306    .attr('y1', pad.top).attr('y2', height - pad.bottom)
307    .attr('stroke', '#ff5555').attr('stroke-width', 1.5).attr('opacity', 0.6);
308
309  // Axis
310  timelineSvg.append('g').attr('transform', `translate(0,${{height - pad.bottom}})`)
311    .call(d3.axisBottom(x).ticks(10)).selectAll('text,line,path').attr('stroke', '#555577').attr('fill', '#555577');
312
313  // Legend
314  const legendData = [
315    ['Transfer', '#4488cc'], ['Symbiosis', '#44cc44'],
316    ['Dissolution', '#ccaa22'], ['Death', '#222222']
317  ];
318  const lg = timelineSvg.append('g').attr('transform', `translate(${{width - 200}}, 8)`);
319  legendData.forEach((d, i) => {{
320    lg.append('circle').attr('cx', i * 50).attr('cy', 0).attr('r', 4).attr('fill', d[1]);
321    lg.append('text').attr('x', i * 50 + 7).attr('y', 3).text(d[0]).attr('fill', '#888').attr('font-size', '9px');
322  }});
323}}
324
325function updateTickCursor(snap) {{
326  const width = document.getElementById('timeline-panel').clientWidth;
327  const pad = {{ left: 40, right: 20 }};
328  if (EVENTS.length === 0) return;
329  const maxTick = Math.max(...EVENTS.map(e => e[0]));
330  const x = d3.scaleLinear().domain([0, maxTick]).range([pad.left, width - pad.right]);
331  d3.select('#tick-cursor').attr('x1', x(snap.tick)).attr('x2', x(snap.tick));
332}}
333
334// --- Metrics ---
335function updateMetrics(snap) {{
336  document.getElementById('m-tick').textContent = snap.tick;
337  document.getElementById('m-nodes').textContent = snap.stats.graph_nodes;
338  document.getElementById('m-edges').textContent = snap.stats.graph_edges;
339  document.getElementById('m-agents').textContent = snap.stats.agents_alive;
340  document.getElementById('m-docs').textContent = snap.stats.documents_digested + ' / ' + snap.stats.documents_total;
341
342  // Count events up to this tick
343  let transfers = 0, integrations = 0, symbioses = 0, dissolutions = 0, deaths = 0;
344  EVENTS.forEach(e => {{
345    if (e[0] > snap.tick) return;
346    const t = e[1];
347    if (t.CapabilityExported) transfers++;
348    if (t.CapabilityIntegrated) integrations++;
349    if (t.Symbiosis) symbioses++;
350    if (t.Dissolved) dissolutions++;
351    if (t.Died) deaths++;
352  }});
353  document.getElementById('m-transfers').textContent = transfers;
354  document.getElementById('m-integrations').textContent = integrations;
355  document.getElementById('m-symbioses').textContent = symbioses;
356  document.getElementById('m-dissolutions').textContent = dissolutions;
357  document.getElementById('m-deaths').textContent = deaths;
358}}
359
360// --- Update all panels ---
361function update(idx) {{
362  if (idx < 0 || idx >= SNAPSHOTS.length) return;
363  currentIdx = idx;
364  slider.value = idx;
365  const snap = SNAPSHOTS[idx];
366  tickLabel.textContent = `Tick ${{snap.tick}} / ${{SNAPSHOTS[SNAPSHOTS.length - 1].tick}}`;
367  updateGraph(snap);
368  updateAgents(snap);
369  updateTickCursor(snap);
370  updateMetrics(snap);
371}}
372
373// --- Controls ---
374slider.addEventListener('input', () => {{
375  update(parseInt(slider.value));
376}});
377
378playBtn.addEventListener('click', () => {{
379  if (playing) {{
380    playing = false;
381    clearInterval(playInterval);
382    playBtn.innerHTML = '&#9654; Play';
383  }} else {{
384    playing = true;
385    playBtn.innerHTML = '&#9646;&#9646; Pause';
386    if (currentIdx >= SNAPSHOTS.length - 1) currentIdx = 0;
387    playInterval = setInterval(() => {{
388      if (currentIdx >= SNAPSHOTS.length - 1) {{
389        playing = false;
390        clearInterval(playInterval);
391        playBtn.innerHTML = '&#9654; Play';
392        return;
393      }}
394      update(currentIdx + 1);
395    }}, 500);
396  }}
397}});
398
399// --- Init ---
400initTimeline();
401update(SNAPSHOTS.length - 1);
402</script>
403</body>
404</html>"##,
405        snapshots = snapshots_json,
406        events = events_json,
407    )
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use phago_runtime::colony::{ColonySnapshot, ColonyStats, AgentSnapshot, NodeSnapshot};
414    use phago_core::types::*;
415
416    #[test]
417    fn html_contains_required_elements() {
418        let snapshot = ColonySnapshot {
419            tick: 10,
420            agents: vec![AgentSnapshot {
421                id: AgentId::new(),
422                agent_type: "digester".to_string(),
423                position: Position::new(1.0, 2.0),
424                age: 10,
425                permeability: 0.3,
426                vocabulary_size: 5,
427            }],
428            nodes: vec![NodeSnapshot {
429                id: NodeId::new(),
430                label: "cell".to_string(),
431                node_type: NodeType::Concept,
432                position: Position::new(0.0, 0.0),
433                access_count: 3,
434            }],
435            edges: vec![],
436            stats: ColonyStats {
437                tick: 10,
438                agents_alive: 1,
439                agents_died: 0,
440                total_spawned: 1,
441                graph_nodes: 1,
442                graph_edges: 0,
443                total_signals: 0,
444                documents_total: 1,
445                documents_digested: 1,
446            },
447        };
448
449        let html = generate_html(&[snapshot], &[]);
450        assert!(html.contains("<html"), "should contain html tag");
451        assert!(html.contains("d3.v7"), "should reference D3 v7");
452        assert!(html.contains("SNAPSHOTS"), "should embed snapshot data");
453        assert!(html.contains("EVENTS"), "should embed event data");
454        assert!(html.contains("digester"), "should contain agent data");
455    }
456
457    #[test]
458    fn html_empty_data_does_not_panic() {
459        let html = generate_html(&[], &[]);
460        assert!(html.contains("<html"), "should produce valid html even with empty data");
461    }
462}