cgcardona / muse public
render_html.py python
1037 lines 33.0 KB
4d0b1365 feat: Tour de Force stress test + shareable D3 visualization Gabriel Cardona <gabriel@tellurstori.com> 3d ago
1 #!/usr/bin/env python3
2 """Muse Tour de Force — HTML renderer.
3
4 Takes the structured TourData dict produced by tour_de_force.py and renders
5 a self-contained, shareable HTML file with an interactive D3 commit DAG,
6 operation log, architecture diagram, and animated replay.
7
8 Stand-alone usage
9 -----------------
10 python tools/render_html.py artifacts/tour_de_force.json
11 python tools/render_html.py artifacts/tour_de_force.json --out custom.html
12 """
13 from __future__ import annotations
14
15 import json
16 import pathlib
17 import sys
18 import urllib.request
19
20
21 # ---------------------------------------------------------------------------
22 # D3.js fetcher
23 # ---------------------------------------------------------------------------
24
25 _D3_CDN = "https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js"
26 _D3_FALLBACK = f'<script src="{_D3_CDN}"></script>'
27
28
29 def _fetch_d3() -> str:
30 """Download D3.js v7 minified. Returns the source or a CDN script tag."""
31 try:
32 with urllib.request.urlopen(_D3_CDN, timeout=15) as resp:
33 src = resp.read().decode("utf-8")
34 print(f" ↓ D3.js fetched ({len(src)//1024}KB)")
35 return f"<script>\n{src}\n</script>"
36 except Exception as exc:
37 print(f" ⚠ Could not fetch D3 ({exc}); using CDN link in HTML")
38 return _D3_FALLBACK
39
40
41 # ---------------------------------------------------------------------------
42 # Architecture SVG
43 # ---------------------------------------------------------------------------
44
45 _ARCH_HTML = """\
46 <div class="arch-flow">
47 <div class="arch-row">
48 <div class="arch-box cli">
49 <div class="box-title">muse CLI</div>
50 <div class="box-sub">14 commands</div>
51 <div class="box-detail">init · commit · log · diff · show · branch<br>
52 checkout · merge · reset · revert · cherry-pick<br>
53 stash · tag · status</div>
54 </div>
55 </div>
56 <div class="arch-connector"><div class="connector-line"></div><div class="connector-arrow">▼</div></div>
57 <div class="arch-row">
58 <div class="arch-box registry">
59 <div class="box-title">Plugin Registry</div>
60 <div class="box-sub">resolve_plugin(root)</div>
61 </div>
62 </div>
63 <div class="arch-connector"><div class="connector-line"></div><div class="connector-arrow">▼</div></div>
64 <div class="arch-row">
65 <div class="arch-box core">
66 <div class="box-title">Core Engine</div>
67 <div class="box-sub">DAG · Content-addressed Objects · Branches · Store · Log Graph · Merge Base</div>
68 </div>
69 </div>
70 <div class="arch-connector"><div class="connector-line"></div><div class="connector-arrow">▼</div></div>
71 <div class="arch-row">
72 <div class="arch-box protocol">
73 <div class="box-title">MuseDomainPlugin Protocol</div>
74 <div class="box-sub">Implement 5 methods → get the full VCS for free</div>
75 </div>
76 </div>
77 <div class="arch-connector"><div class="connector-line"></div><div class="connector-arrow">▼</div></div>
78 <div class="arch-row plugins-row">
79 <div class="arch-box plugin active">
80 <div class="box-title">MusicPlugin</div>
81 <div class="box-sub">reference impl<br>MIDI · notes · CC · pitch</div>
82 </div>
83 <div class="arch-box plugin planned">
84 <div class="box-title">GenomicsPlugin</div>
85 <div class="box-sub">planned<br>sequences · variants</div>
86 </div>
87 <div class="arch-box plugin planned">
88 <div class="box-title">SpacetimePlugin</div>
89 <div class="box-sub">planned<br>3D fields · time-slices</div>
90 </div>
91 <div class="arch-box plugin planned">
92 <div class="box-title">YourPlugin</div>
93 <div class="box-sub">implement 5 methods<br>get VCS for free</div>
94 </div>
95 </div>
96 </div>
97
98 <div class="protocol-table">
99 <div class="proto-row header">
100 <div class="proto-method">Method</div>
101 <div class="proto-sig">Signature</div>
102 <div class="proto-desc">Purpose</div>
103 </div>
104 <div class="proto-row">
105 <div class="proto-method">snapshot</div>
106 <div class="proto-sig">snapshot(live_state) → StateSnapshot</div>
107 <div class="proto-desc">Capture current state as a content-addressable JSON blob</div>
108 </div>
109 <div class="proto-row">
110 <div class="proto-method">diff</div>
111 <div class="proto-sig">diff(base, target) → StateDelta</div>
112 <div class="proto-desc">Compute minimal change between two snapshots (added · removed · modified)</div>
113 </div>
114 <div class="proto-row">
115 <div class="proto-method">merge</div>
116 <div class="proto-sig">merge(base, left, right) → MergeResult</div>
117 <div class="proto-desc">Three-way reconcile divergent state lines; surface conflicts</div>
118 </div>
119 <div class="proto-row">
120 <div class="proto-method">drift</div>
121 <div class="proto-sig">drift(committed, live) → DriftReport</div>
122 <div class="proto-desc">Detect uncommitted changes between HEAD and working state</div>
123 </div>
124 <div class="proto-row">
125 <div class="proto-method">apply</div>
126 <div class="proto-sig">apply(delta, live_state) → LiveState</div>
127 <div class="proto-desc">Apply a delta during checkout to reconstruct historical state</div>
128 </div>
129 </div>
130 """
131
132
133 # ---------------------------------------------------------------------------
134 # HTML template
135 # ---------------------------------------------------------------------------
136
137 _HTML_TEMPLATE = """\
138 <!DOCTYPE html>
139 <html lang="en">
140 <head>
141 <meta charset="utf-8">
142 <meta name="viewport" content="width=device-width, initial-scale=1">
143 <title>Muse — Tour de Force</title>
144 <style>
145 /* ---- Reset & base ---- */
146 *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
147 :root {
148 --bg: #0d1117;
149 --bg2: #161b22;
150 --bg3: #21262d;
151 --border: #30363d;
152 --text: #e6edf3;
153 --text-mute: #8b949e;
154 --text-dim: #484f58;
155 --accent: #4f8ef7;
156 --accent2: #58a6ff;
157 --green: #3fb950;
158 --red: #f85149;
159 --yellow: #d29922;
160 --purple: #bc8cff;
161 --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
162 --font-ui: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
163 --radius: 8px;
164 }
165 html { scroll-behavior: smooth; }
166 body {
167 background: var(--bg);
168 color: var(--text);
169 font-family: var(--font-ui);
170 font-size: 14px;
171 line-height: 1.6;
172 min-height: 100vh;
173 }
174
175 /* ---- Header ---- */
176 header {
177 background: var(--bg2);
178 border-bottom: 1px solid var(--border);
179 padding: 24px 40px;
180 }
181 .header-top {
182 display: flex;
183 align-items: baseline;
184 gap: 16px;
185 flex-wrap: wrap;
186 }
187 header h1 {
188 font-size: 28px;
189 font-weight: 700;
190 letter-spacing: -0.5px;
191 color: var(--accent2);
192 font-family: var(--font-mono);
193 }
194 .tagline {
195 color: var(--text-mute);
196 font-size: 14px;
197 }
198 .stats-bar {
199 display: flex;
200 gap: 24px;
201 margin-top: 14px;
202 flex-wrap: wrap;
203 }
204 .stat {
205 display: flex;
206 flex-direction: column;
207 align-items: center;
208 gap: 2px;
209 }
210 .stat-num {
211 font-size: 22px;
212 font-weight: 700;
213 font-family: var(--font-mono);
214 color: var(--accent2);
215 }
216 .stat-label {
217 font-size: 11px;
218 color: var(--text-mute);
219 text-transform: uppercase;
220 letter-spacing: 0.8px;
221 }
222 .stat-sep { color: var(--border); font-size: 22px; align-self: center; }
223 .version-badge {
224 margin-left: auto;
225 padding: 4px 10px;
226 border: 1px solid var(--border);
227 border-radius: 20px;
228 font-size: 12px;
229 font-family: var(--font-mono);
230 color: var(--text-mute);
231 }
232
233 /* ---- Main layout ---- */
234 .main-container {
235 display: grid;
236 grid-template-columns: 1fr 380px;
237 gap: 0;
238 height: calc(100vh - 130px);
239 min-height: 600px;
240 }
241
242 /* ---- DAG panel ---- */
243 .dag-panel {
244 border-right: 1px solid var(--border);
245 display: flex;
246 flex-direction: column;
247 overflow: hidden;
248 }
249 .dag-header {
250 display: flex;
251 align-items: center;
252 gap: 12px;
253 padding: 12px 20px;
254 border-bottom: 1px solid var(--border);
255 background: var(--bg2);
256 flex-shrink: 0;
257 }
258 .dag-header h2 {
259 font-size: 13px;
260 font-weight: 600;
261 color: var(--text-mute);
262 text-transform: uppercase;
263 letter-spacing: 0.8px;
264 }
265 .controls { display: flex; gap: 8px; margin-left: auto; align-items: center; }
266 .btn {
267 padding: 6px 14px;
268 border-radius: var(--radius);
269 border: 1px solid var(--border);
270 background: var(--bg3);
271 color: var(--text);
272 cursor: pointer;
273 font-size: 12px;
274 font-family: var(--font-ui);
275 transition: all 0.15s;
276 }
277 .btn:hover { background: var(--border); }
278 .btn.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
279 .btn.primary:hover { background: var(--accent2); }
280 .step-counter {
281 font-size: 11px;
282 font-family: var(--font-mono);
283 color: var(--text-mute);
284 min-width: 80px;
285 text-align: right;
286 }
287 .dag-scroll {
288 flex: 1;
289 overflow: auto;
290 padding: 20px;
291 }
292 #dag-svg { display: block; }
293 .branch-legend {
294 display: flex;
295 flex-wrap: wrap;
296 gap: 10px;
297 padding: 8px 20px;
298 border-top: 1px solid var(--border);
299 background: var(--bg2);
300 flex-shrink: 0;
301 }
302 .legend-item {
303 display: flex;
304 align-items: center;
305 gap: 6px;
306 font-size: 11px;
307 color: var(--text-mute);
308 }
309 .legend-dot {
310 width: 10px;
311 height: 10px;
312 border-radius: 50%;
313 flex-shrink: 0;
314 }
315
316 /* ---- Log panel ---- */
317 .log-panel {
318 display: flex;
319 flex-direction: column;
320 overflow: hidden;
321 background: var(--bg);
322 }
323 .log-header {
324 padding: 12px 16px;
325 border-bottom: 1px solid var(--border);
326 background: var(--bg2);
327 flex-shrink: 0;
328 }
329 .log-header h2 {
330 font-size: 13px;
331 font-weight: 600;
332 color: var(--text-mute);
333 text-transform: uppercase;
334 letter-spacing: 0.8px;
335 }
336 .log-scroll {
337 flex: 1;
338 overflow-y: auto;
339 padding: 0;
340 }
341 .act-header {
342 padding: 10px 16px 6px;
343 font-size: 11px;
344 font-weight: 700;
345 text-transform: uppercase;
346 letter-spacing: 1px;
347 color: var(--text-dim);
348 border-top: 1px solid var(--border);
349 margin-top: 4px;
350 position: sticky;
351 top: 0;
352 background: var(--bg);
353 z-index: 1;
354 }
355 .act-header:first-child { border-top: none; margin-top: 0; }
356 .event-item {
357 padding: 8px 16px;
358 border-bottom: 1px solid #1a1f26;
359 opacity: 0.3;
360 transition: opacity 0.3s, background 0.2s;
361 cursor: default;
362 }
363 .event-item.revealed { opacity: 1; }
364 .event-item.active { background: rgba(79,142,247,0.08); border-left: 2px solid var(--accent); }
365 .event-item.failed { border-left: 2px solid var(--red); }
366 .event-cmd {
367 font-family: var(--font-mono);
368 font-size: 12px;
369 color: var(--text);
370 margin-bottom: 3px;
371 }
372 .event-cmd .cmd-prefix { color: var(--text-dim); }
373 .event-cmd .cmd-name { color: var(--accent2); font-weight: 600; }
374 .event-cmd .cmd-args { color: var(--text); }
375 .event-output {
376 font-family: var(--font-mono);
377 font-size: 11px;
378 color: var(--text-mute);
379 white-space: pre-wrap;
380 word-break: break-all;
381 max-height: 80px;
382 overflow: hidden;
383 text-overflow: ellipsis;
384 }
385 .event-output.conflict { color: var(--red); }
386 .event-output.success { color: var(--green); }
387 .event-meta {
388 display: flex;
389 gap: 8px;
390 margin-top: 3px;
391 font-size: 10px;
392 color: var(--text-dim);
393 }
394 .tag-commit { background: rgba(79,142,247,0.15); color: var(--accent2); padding: 1px 5px; border-radius: 3px; font-family: var(--font-mono); }
395 .tag-time { color: var(--text-dim); }
396
397 /* ---- DAG SVG styles ---- */
398 .commit-node { cursor: pointer; }
399 .commit-node:hover circle { filter: brightness(1.3); }
400 .commit-node.highlighted circle { filter: brightness(1.5) drop-shadow(0 0 6px currentColor); }
401 .commit-label { font-size: 10px; fill: var(--text-mute); font-family: var(--font-mono); }
402 .commit-msg { font-size: 10px; fill: var(--text-mute); }
403 .commit-node.highlighted .commit-label,
404 .commit-node.highlighted .commit-msg { fill: var(--text); }
405 text { font-family: -apple-system, system-ui, sans-serif; }
406
407 /* ---- Architecture section ---- */
408 .arch-section {
409 background: var(--bg2);
410 border-top: 1px solid var(--border);
411 padding: 48px 40px;
412 }
413 .arch-inner { max-width: 1100px; margin: 0 auto; }
414 .arch-section h2 {
415 font-size: 22px;
416 font-weight: 700;
417 margin-bottom: 8px;
418 color: var(--text);
419 }
420 .arch-section .section-intro {
421 color: var(--text-mute);
422 max-width: 680px;
423 margin-bottom: 40px;
424 line-height: 1.7;
425 }
426 .arch-section .section-intro strong { color: var(--text); }
427 .arch-content {
428 display: grid;
429 grid-template-columns: 380px 1fr;
430 gap: 48px;
431 align-items: start;
432 }
433
434 /* Architecture flow diagram */
435 .arch-flow {
436 display: flex;
437 flex-direction: column;
438 align-items: center;
439 gap: 0;
440 }
441 .arch-row { width: 100%; display: flex; justify-content: center; }
442 .plugins-row { gap: 8px; flex-wrap: wrap; }
443 .arch-box {
444 border: 1px solid var(--border);
445 border-radius: var(--radius);
446 padding: 12px 16px;
447 background: var(--bg3);
448 width: 100%;
449 max-width: 340px;
450 transition: border-color 0.2s;
451 }
452 .arch-box:hover { border-color: var(--accent); }
453 .arch-box.cli { border-color: rgba(79,142,247,0.4); }
454 .arch-box.registry { border-color: rgba(188,140,255,0.3); }
455 .arch-box.core { border-color: rgba(63,185,80,0.3); background: rgba(63,185,80,0.05); }
456 .arch-box.protocol { border-color: rgba(79,142,247,0.5); background: rgba(79,142,247,0.05); }
457 .arch-box.plugin { max-width: 160px; width: auto; flex: 1; }
458 .arch-box.plugin.active { border-color: rgba(249,168,37,0.5); background: rgba(249,168,37,0.05); }
459 .arch-box.plugin.planned { opacity: 0.6; border-style: dashed; }
460 .box-title { font-weight: 600; font-size: 13px; color: var(--text); }
461 .box-sub { font-size: 11px; color: var(--text-mute); margin-top: 3px; }
462 .box-detail { font-size: 10px; color: var(--text-dim); margin-top: 4px; line-height: 1.5; }
463 .arch-connector {
464 display: flex;
465 flex-direction: column;
466 align-items: center;
467 height: 24px;
468 color: var(--border);
469 }
470 .connector-line { width: 1px; flex: 1; background: var(--border); }
471 .connector-arrow { font-size: 10px; }
472
473 /* Protocol table */
474 .protocol-table { border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
475 .proto-row {
476 display: grid;
477 grid-template-columns: 80px 220px 1fr;
478 gap: 0;
479 border-bottom: 1px solid var(--border);
480 }
481 .proto-row:last-child { border-bottom: none; }
482 .proto-row.header { background: var(--bg3); }
483 .proto-row > div { padding: 10px 14px; }
484 .proto-method {
485 font-family: var(--font-mono);
486 font-size: 12px;
487 color: var(--accent2);
488 font-weight: 600;
489 border-right: 1px solid var(--border);
490 }
491 .proto-sig {
492 font-family: var(--font-mono);
493 font-size: 11px;
494 color: var(--text-mute);
495 border-right: 1px solid var(--border);
496 word-break: break-all;
497 }
498 .proto-desc { font-size: 12px; color: var(--text-mute); }
499 .proto-row.header .proto-method,
500 .proto-row.header .proto-sig,
501 .proto-row.header .proto-desc {
502 font-family: var(--font-ui);
503 font-size: 11px;
504 font-weight: 700;
505 text-transform: uppercase;
506 letter-spacing: 0.6px;
507 color: var(--text-dim);
508 }
509
510 /* ---- Footer ---- */
511 footer {
512 background: var(--bg);
513 border-top: 1px solid var(--border);
514 padding: 16px 40px;
515 display: flex;
516 justify-content: space-between;
517 align-items: center;
518 font-size: 12px;
519 color: var(--text-dim);
520 }
521 footer a { color: var(--accent2); text-decoration: none; }
522 footer a:hover { text-decoration: underline; }
523
524 /* ---- Scrollbar ---- */
525 ::-webkit-scrollbar { width: 6px; height: 6px; }
526 ::-webkit-scrollbar-track { background: var(--bg); }
527 ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
528 ::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
529
530 /* ---- Tooltip ---- */
531 .tooltip {
532 position: fixed;
533 background: var(--bg2);
534 border: 1px solid var(--border);
535 border-radius: var(--radius);
536 padding: 10px 14px;
537 font-size: 12px;
538 pointer-events: none;
539 opacity: 0;
540 transition: opacity 0.15s;
541 z-index: 100;
542 max-width: 280px;
543 box-shadow: 0 8px 24px rgba(0,0,0,0.4);
544 }
545 .tooltip.visible { opacity: 1; }
546 .tip-id { font-family: var(--font-mono); font-size: 11px; color: var(--accent2); margin-bottom: 4px; }
547 .tip-msg { color: var(--text); margin-bottom: 4px; }
548 .tip-branch { font-size: 11px; margin-bottom: 4px; }
549 .tip-files { font-size: 11px; color: var(--text-mute); font-family: var(--font-mono); }
550 </style>
551 </head>
552 <body>
553
554 <header>
555 <div class="header-top">
556 <h1>muse</h1>
557 <span class="tagline">domain-agnostic version control for multidimensional state</span>
558 <span class="version-badge">v{{VERSION}} · {{DOMAIN}} domain · {{ELAPSED}}s</span>
559 </div>
560 <div class="stats-bar">
561 <div class="stat"><span class="stat-num">{{COMMITS}}</span><span class="stat-label">Commits</span></div>
562 <div class="stat-sep">·</div>
563 <div class="stat"><span class="stat-num">{{BRANCHES}}</span><span class="stat-label">Branches</span></div>
564 <div class="stat-sep">·</div>
565 <div class="stat"><span class="stat-num">{{MERGES}}</span><span class="stat-label">Merges</span></div>
566 <div class="stat-sep">·</div>
567 <div class="stat"><span class="stat-num">{{CONFLICTS}}</span><span class="stat-label">Conflicts Resolved</span></div>
568 <div class="stat-sep">·</div>
569 <div class="stat"><span class="stat-num">{{OPS}}</span><span class="stat-label">Operations</span></div>
570 </div>
571 </header>
572
573 <div class="main-container">
574 <div class="dag-panel">
575 <div class="dag-header">
576 <h2>Commit Graph</h2>
577 <div class="controls">
578 <button class="btn primary" id="btn-play">&#9654; Play Tour</button>
579 <button class="btn" id="btn-reset">&#8635; Reset</button>
580 <span class="step-counter" id="step-counter"></span>
581 </div>
582 </div>
583 <div class="dag-scroll" id="dag-scroll">
584 <svg id="dag-svg"></svg>
585 </div>
586 <div class="branch-legend" id="branch-legend"></div>
587 </div>
588
589 <div class="log-panel">
590 <div class="log-header"><h2>Operation Log</h2></div>
591 <div class="log-scroll" id="log-scroll">
592 <div id="event-list"></div>
593 </div>
594 </div>
595 </div>
596
597 <div class="arch-section">
598 <div class="arch-inner">
599 <h2>How Muse Works</h2>
600 <p class="section-intro">
601 Muse is a version control system for <strong>state</strong> — any multidimensional
602 state that can be snapshotted, diffed, and merged. The core engine provides
603 the DAG, content-addressed storage, branching, merging, time-travel, and
604 conflict resolution. A domain plugin implements <strong>5 methods</strong> and
605 gets everything else for free.
606 <br><br>
607 Music is the reference implementation. Genomics sequences, scientific simulation
608 frames, 3D spatial fields, and financial time-series are all the same pattern.
609 </p>
610 <div class="arch-content">
611 {{ARCH_HTML}}
612 </div>
613 </div>
614 </div>
615
616 <footer>
617 <span>Generated {{GENERATED_AT}} · {{ELAPSED}}s · {{OPS}} operations</span>
618 <span><a href="https://github.com/cgcardona/muse">github.com/cgcardona/muse</a></span>
619 </footer>
620
621 <div class="tooltip" id="tooltip">
622 <div class="tip-id" id="tip-id"></div>
623 <div class="tip-msg" id="tip-msg"></div>
624 <div class="tip-branch" id="tip-branch"></div>
625 <div class="tip-files" id="tip-files"></div>
626 </div>
627
628 {{D3_SCRIPT}}
629
630 <script>
631 /* ===== Embedded tour data ===== */
632 const DATA = {{DATA_JSON}};
633
634 /* ===== Constants ===== */
635 const ROW_H = 52;
636 const COL_W = 90;
637 const PAD = { top: 30, left: 55, right: 160 };
638 const R_NODE = 11;
639 const BRANCH_ORDER = ['main','alpha','beta','gamma','conflict/left','conflict/right'];
640 const PLAY_INTERVAL_MS = 1200;
641
642 /* ===== State ===== */
643 let currentStep = -1;
644 let isPlaying = false;
645 let playTimer = null;
646
647 /* ===== Utilities ===== */
648 function escHtml(s) {
649 return String(s)
650 .replace(/&/g,'&amp;')
651 .replace(/</g,'&lt;')
652 .replace(/>/g,'&gt;')
653 .replace(/"/g,'&quot;');
654 }
655
656 /* ===== Topological sort ===== */
657 function topoSort(commits) {
658 const map = new Map(commits.map(c => [c.id, c]));
659 const visited = new Set();
660 const result = [];
661 function visit(id) {
662 if (visited.has(id)) return;
663 visited.add(id);
664 const c = map.get(id);
665 if (!c) return;
666 (c.parents || []).forEach(pid => visit(pid));
667 result.push(c);
668 }
669 commits.forEach(c => visit(c.id));
670 // result is reverse topological (oldest last). Reverse to get oldest first.
671 return result.reverse();
672 }
673
674 /* ===== Layout ===== */
675 function computeLayout(commits) {
676 const sorted = topoSort(commits);
677 const branchCols = {};
678 let nextCol = 0;
679 // Assign columns in BRANCH_ORDER first, then any extras
680 BRANCH_ORDER.forEach(b => { branchCols[b] = nextCol++; });
681 commits.forEach(c => {
682 if (!(c.branch in branchCols)) branchCols[c.branch] = nextCol++;
683 });
684 const numCols = nextCol;
685 const positions = new Map();
686 sorted.forEach((c, i) => {
687 positions.set(c.id, {
688 x: PAD.left + (branchCols[c.branch] || 0) * COL_W,
689 y: PAD.top + i * ROW_H,
690 row: i,
691 col: branchCols[c.branch] || 0,
692 });
693 });
694 const svgW = PAD.left + numCols * COL_W + PAD.right;
695 const svgH = PAD.top + sorted.length * ROW_H + PAD.top;
696 return { sorted, positions, branchCols, svgW, svgH };
697 }
698
699 /* ===== Draw DAG ===== */
700 function drawDAG() {
701 const { dag, dag: { commits, branches } } = DATA;
702 if (!commits.length) return;
703
704 const layout = computeLayout(commits);
705 const { sorted, positions, svgW, svgH } = layout;
706 const branchColor = new Map(branches.map(b => [b.name, b.color]));
707 const commitMap = new Map(commits.map(c => [c.id, c]));
708
709 const svg = d3.select('#dag-svg')
710 .attr('width', svgW)
711 .attr('height', svgH);
712
713 // ---- Edges ----
714 const edgeG = svg.append('g').attr('class', 'edges');
715 sorted.forEach(commit => {
716 const pos = positions.get(commit.id);
717 (commit.parents || []).forEach((pid, pIdx) => {
718 const ppos = positions.get(pid);
719 if (!pos || !ppos) return;
720 const color = pIdx === 0
721 ? (branchColor.get(commit.branch) || '#555')
722 : (branchColor.get(commitMap.get(pid)?.branch || '') || '#555');
723
724 let pathStr;
725 if (Math.abs(pos.x - ppos.x) < 4) {
726 // Same column → straight line
727 pathStr = `M${pos.x},${pos.y} L${ppos.x},${ppos.y}`;
728 } else {
729 // Different columns → S-curve bezier
730 const mid = (pos.y + ppos.y) / 2;
731 pathStr = `M${pos.x},${pos.y} C${pos.x},${mid} ${ppos.x},${mid} ${ppos.x},${ppos.y}`;
732 }
733 edgeG.append('path')
734 .attr('d', pathStr)
735 .attr('stroke', color)
736 .attr('stroke-width', 1.8)
737 .attr('fill', 'none')
738 .attr('opacity', 0.45)
739 .attr('class', `edge-from-${commit.id.slice(0,8)}`);
740 });
741 });
742
743 // ---- Nodes ----
744 const nodeG = svg.append('g').attr('class', 'nodes');
745 const tooltip = document.getElementById('tooltip');
746
747 sorted.forEach(commit => {
748 const pos = positions.get(commit.id);
749 if (!pos) return;
750 const color = branchColor.get(commit.branch) || '#78909c';
751 const isMerge = (commit.parents || []).length >= 2;
752
753 const g = nodeG.append('g')
754 .attr('class', 'commit-node')
755 .attr('data-id', commit.id)
756 .attr('data-short', commit.short)
757 .attr('transform', `translate(${pos.x},${pos.y})`);
758
759 if (isMerge) {
760 g.append('circle')
761 .attr('r', R_NODE + 6)
762 .attr('fill', 'none')
763 .attr('stroke', color)
764 .attr('stroke-width', 1.5)
765 .attr('opacity', 0.35);
766 }
767
768 g.append('circle')
769 .attr('r', R_NODE)
770 .attr('fill', color)
771 .attr('stroke', '#0d1117')
772 .attr('stroke-width', 2);
773
774 // Short ID
775 g.append('text')
776 .attr('x', R_NODE + 7)
777 .attr('y', 0)
778 .attr('dy', '0.35em')
779 .attr('class', 'commit-label')
780 .text(commit.short);
781
782 // Message (truncated)
783 const maxLen = 38;
784 const msg = commit.message.length > maxLen
785 ? commit.message.slice(0, maxLen) + '…'
786 : commit.message;
787 g.append('text')
788 .attr('x', R_NODE + 7)
789 .attr('y', 13)
790 .attr('class', 'commit-msg')
791 .text(msg);
792
793 // Hover tooltip
794 g.on('mousemove', (event) => {
795 tooltip.classList.add('visible');
796 document.getElementById('tip-id').textContent = commit.id;
797 document.getElementById('tip-msg').textContent = commit.message;
798 document.getElementById('tip-branch').innerHTML =
799 `<span style="color:${color}">⬤</span> ${commit.branch}`;
800 document.getElementById('tip-files').textContent =
801 commit.files.length
802 ? commit.files.join('\n')
803 : '(empty snapshot)';
804 tooltip.style.left = (event.clientX + 12) + 'px';
805 tooltip.style.top = (event.clientY - 10) + 'px';
806 }).on('mouseleave', () => {
807 tooltip.classList.remove('visible');
808 });
809 });
810
811 // ---- Branch legend ----
812 const legend = document.getElementById('branch-legend');
813 DATA.dag.branches.forEach(b => {
814 const item = document.createElement('div');
815 item.className = 'legend-item';
816 item.innerHTML =
817 `<span class="legend-dot" style="background:${b.color}"></span>` +
818 `<span>${escHtml(b.name)}</span>`;
819 legend.appendChild(item);
820 });
821 }
822
823 /* ===== Event log ===== */
824 function buildEventLog() {
825 const list = document.getElementById('event-list');
826 let lastAct = -1;
827
828 DATA.events.forEach((ev, idx) => {
829 if (ev.act !== lastAct) {
830 lastAct = ev.act;
831 const hdr = document.createElement('div');
832 hdr.className = 'act-header';
833 hdr.textContent = `Act ${ev.act} — ${ev.act_title}`;
834 list.appendChild(hdr);
835 }
836
837 const item = document.createElement('div');
838 item.className = 'event-item';
839 item.id = `ev-${idx}`;
840 if (ev.exit_code !== 0 && ev.output.toLowerCase().includes('conflict')) {
841 item.classList.add('failed');
842 }
843
844 // Parse cmd into parts
845 const parts = ev.cmd.split(' ');
846 const cmdName = parts.slice(0,2).join(' ');
847 const cmdArgs = parts.slice(2).join(' ');
848
849 // Determine output class
850 let outClass = '';
851 if (ev.output.toLowerCase().includes('conflict')) outClass = 'conflict';
852 else if (ev.exit_code === 0 && ev.commit_id) outClass = 'success';
853
854 // Trim long output
855 const outLines = ev.output.split('\n').slice(0, 5).join('\n');
856
857 item.innerHTML =
858 `<div class="event-cmd">` +
859 `<span class="cmd-prefix">$ </span>` +
860 `<span class="cmd-name">${escHtml(cmdName)}</span>` +
861 (cmdArgs ? ` <span class="cmd-args">${escHtml(cmdArgs.slice(0, 60))}${cmdArgs.length>60?'…':''}</span>` : '') +
862 `</div>` +
863 (outLines
864 ? `<div class="event-output ${outClass}">${escHtml(outLines)}</div>`
865 : '') +
866 `<div class="event-meta">` +
867 (ev.commit_id ? `<span class="tag-commit">${escHtml(ev.commit_id)}</span>` : '') +
868 `<span class="tag-time">${ev.duration_ms}ms</span>` +
869 `</div>`;
870
871 list.appendChild(item);
872 });
873 }
874
875 /* ===== Replay animation ===== */
876 function revealStep(stepIdx) {
877 if (stepIdx < 0 || stepIdx >= DATA.events.length) return;
878
879 const ev = DATA.events[stepIdx];
880
881 // Reveal all events up to this step
882 for (let i = 0; i <= stepIdx; i++) {
883 const el = document.getElementById(`ev-${i}`);
884 if (el) el.classList.add('revealed');
885 }
886
887 // Mark current as active (remove previous)
888 document.querySelectorAll('.event-item.active').forEach(el => el.classList.remove('active'));
889 const cur = document.getElementById(`ev-${stepIdx}`);
890 if (cur) {
891 cur.classList.add('active');
892 cur.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
893 }
894
895 // Highlight commit node
896 document.querySelectorAll('.commit-node.highlighted').forEach(el => el.classList.remove('highlighted'));
897 if (ev.commit_id) {
898 const node = document.querySelector(`.commit-node[data-short="${ev.commit_id}"]`);
899 if (node) {
900 node.classList.add('highlighted');
901 // Scroll DAG to show the node
902 const transform = node.getAttribute('transform');
903 if (transform) {
904 const m = transform.match(/translate\\(([\\d.]+),([\\d.]+)\\)/);
905 if (m) {
906 const scroll = document.getElementById('dag-scroll');
907 const y = parseFloat(m[2]);
908 scroll.scrollTo({ top: Math.max(0, y - 200), behavior: 'smooth' });
909 }
910 }
911 }
912 }
913
914 // Update counter
915 document.getElementById('step-counter').textContent =
916 `Step ${stepIdx + 1} / ${DATA.events.length}`;
917
918 currentStep = stepIdx;
919 }
920
921 function playTour() {
922 if (isPlaying) return;
923 isPlaying = true;
924 document.getElementById('btn-play').textContent = '⏸ Pause';
925
926 function advance() {
927 if (!isPlaying) return;
928 const next = currentStep + 1;
929 if (next >= DATA.events.length) {
930 pauseTour();
931 document.getElementById('btn-play').textContent = '✓ Done';
932 return;
933 }
934 revealStep(next);
935 playTimer = setTimeout(advance, PLAY_INTERVAL_MS);
936 }
937 advance();
938 }
939
940 function pauseTour() {
941 isPlaying = false;
942 clearTimeout(playTimer);
943 document.getElementById('btn-play').textContent = '▶ Play Tour';
944 }
945
946 function resetTour() {
947 pauseTour();
948 currentStep = -1;
949 document.querySelectorAll('.event-item').forEach(el => {
950 el.classList.remove('revealed','active');
951 });
952 document.querySelectorAll('.commit-node.highlighted').forEach(el => {
953 el.classList.remove('highlighted');
954 });
955 document.getElementById('step-counter').textContent = '';
956 document.getElementById('log-scroll').scrollTop = 0;
957 document.getElementById('dag-scroll').scrollTop = 0;
958 document.getElementById('btn-play').textContent = '▶ Play Tour';
959 }
960
961 /* ===== Init ===== */
962 document.addEventListener('DOMContentLoaded', () => {
963 drawDAG();
964 buildEventLog();
965
966 document.getElementById('btn-play').addEventListener('click', () => {
967 if (isPlaying) pauseTour(); else playTour();
968 });
969 document.getElementById('btn-reset').addEventListener('click', resetTour);
970 });
971 </script>
972 </body>
973 </html>
974 """
975
976
977 # ---------------------------------------------------------------------------
978 # Main render function
979 # ---------------------------------------------------------------------------
980
981
982 def render(tour: dict, output_path: pathlib.Path) -> None:
983 """Render the tour data into a self-contained HTML file."""
984 print(" Rendering HTML visualization...")
985 d3_script = _fetch_d3()
986
987 meta = tour.get("meta", {})
988 stats = tour.get("stats", {})
989
990 # Format generated_at nicely
991 gen_raw = meta.get("generated_at", "")
992 try:
993 from datetime import datetime, timezone
994 dt = datetime.fromisoformat(gen_raw).astimezone(timezone.utc)
995 gen_str = dt.strftime("%Y-%m-%d %H:%M UTC")
996 except Exception:
997 gen_str = gen_raw[:19]
998
999 html = _HTML_TEMPLATE
1000 html = html.replace("{{VERSION}}", str(meta.get("muse_version", "0.1.1")))
1001 html = html.replace("{{DOMAIN}}", str(meta.get("domain", "music")))
1002 html = html.replace("{{ELAPSED}}", str(meta.get("elapsed_s", "?")))
1003 html = html.replace("{{GENERATED_AT}}", gen_str)
1004 html = html.replace("{{COMMITS}}", str(stats.get("commits", 0)))
1005 html = html.replace("{{BRANCHES}}", str(stats.get("branches", 0)))
1006 html = html.replace("{{MERGES}}", str(stats.get("merges", 0)))
1007 html = html.replace("{{CONFLICTS}}", str(stats.get("conflicts_resolved", 0)))
1008 html = html.replace("{{OPS}}", str(stats.get("operations", 0)))
1009 html = html.replace("{{ARCH_HTML}}", _ARCH_HTML)
1010 html = html.replace("{{D3_SCRIPT}}", d3_script)
1011 html = html.replace("{{DATA_JSON}}", json.dumps(tour, separators=(",", ":")))
1012
1013 output_path.write_text(html, encoding="utf-8")
1014 size_kb = output_path.stat().st_size // 1024
1015 print(f" HTML written ({size_kb}KB) → {output_path}")
1016
1017
1018 # ---------------------------------------------------------------------------
1019 # Stand-alone entry point
1020 # ---------------------------------------------------------------------------
1021
1022 if __name__ == "__main__":
1023 import argparse
1024 parser = argparse.ArgumentParser(description="Render tour_de_force.json → HTML")
1025 parser.add_argument("json_file", help="Path to tour_de_force.json")
1026 parser.add_argument("--out", default=None, help="Output HTML path")
1027 args = parser.parse_args()
1028
1029 json_path = pathlib.Path(args.json_file)
1030 if not json_path.exists():
1031 print(f"❌ File not found: {json_path}", file=sys.stderr)
1032 sys.exit(1)
1033
1034 data = json.loads(json_path.read_text())
1035 out_path = pathlib.Path(args.out) if args.out else json_path.with_suffix(".html")
1036 render(data, out_path)
1037 print(f"Open: file://{out_path.resolve()}")