1use phago_runtime::colony::{ColonySnapshot, ColonyEvent};
12use phago_core::types::Tick;
13
14pub 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">▶ 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 = '▶ Play';
383 }} else {{
384 playing = true;
385 playBtn.innerHTML = '▮▮ 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 = '▶ 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}