explainer-video 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/.claude-plugin/plugin.json +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +152 -2
  4. package/cordis.patch.yml +7 -0
  5. package/lib/index.js +35 -0
  6. package/package.json +56 -4
  7. package/plugin.json +13 -0
  8. package/skills/coursework-author/.synced-from +5 -0
  9. package/skills/coursework-author/SKILL.md +209 -0
  10. package/skills/craft-codeflow-flows/SKILL.md +116 -0
  11. package/skills/craft-codeflow-mine/SKILL.md +207 -0
  12. package/skills/craft-codeflow-plan/SKILL.md +65 -0
  13. package/skills/craft-codeflow-scene/SKILL.md +168 -0
  14. package/skills/craft-video-align/SKILL.md +109 -0
  15. package/skills/craft-video-assemble/SKILL.md +69 -0
  16. package/skills/craft-video-audio/SKILL.md +59 -0
  17. package/skills/craft-video-enrich/SKILL.md +46 -0
  18. package/skills/craft-video-plan/SKILL.md +57 -0
  19. package/skills/craft-video-publish/SKILL.md +79 -0
  20. package/skills/craft-video-render/SKILL.md +68 -0
  21. package/skills/craft-video-scene/SKILL.md +112 -0
  22. package/skills/craft-video-script/SKILL.md +80 -0
  23. package/skills/explainer-video-from-codebase/FLOWS.md +577 -0
  24. package/skills/explainer-video-from-codebase/SKILL.md +659 -0
  25. package/skills/explainer-video-from-codebase/tools/apply-flows.mjs +531 -0
  26. package/skills/explainer-video-from-codebase/tools/approve-flows.mjs +56 -0
  27. package/skills/explainer-video-from-codebase/tools/codemap-md.mjs +116 -0
  28. package/skills/explainer-video-from-codebase/tools/config/dotenv.mjs +13 -0
  29. package/skills/explainer-video-from-codebase/tools/config/json.mjs +14 -0
  30. package/skills/explainer-video-from-codebase/tools/config/redact.mjs +16 -0
  31. package/skills/explainer-video-from-codebase/tools/config/toml-lite.mjs +13 -0
  32. package/skills/explainer-video-from-codebase/tools/config/yaml-lite.mjs +18 -0
  33. package/skills/explainer-video-from-codebase/tools/excerpt-check.mjs +186 -0
  34. package/skills/explainer-video-from-codebase/tools/excerpt.mjs +116 -0
  35. package/skills/explainer-video-from-codebase/tools/flow-diagram.mjs +168 -0
  36. package/skills/explainer-video-from-codebase/tools/flow-plan.mjs +129 -0
  37. package/skills/explainer-video-from-codebase/tools/flows-md.mjs +87 -0
  38. package/skills/explainer-video-from-codebase/tools/lang/c.mjs +684 -0
  39. package/skills/explainer-video-from-codebase/tools/lang/go.mjs +673 -0
  40. package/skills/explainer-video-from-codebase/tools/lang/python-config.mjs +143 -0
  41. package/skills/explainer-video-from-codebase/tools/lang/python-deps.mjs +62 -0
  42. package/skills/explainer-video-from-codebase/tools/lang/python-stdlib.mjs +24 -0
  43. package/skills/explainer-video-from-codebase/tools/lang/python.mjs +609 -0
  44. package/skills/explainer-video-from-codebase/tools/lang/typescript.mjs +963 -0
  45. package/skills/explainer-video-from-codebase/tools/lib/decompose.mjs +102 -0
  46. package/skills/explainer-video-from-codebase/tools/lib/groups.mjs +77 -0
  47. package/skills/explainer-video-from-codebase/tools/lib/observe-coverage.mjs +68 -0
  48. package/skills/explainer-video-from-codebase/tools/lib/observe-v8.mjs +232 -0
  49. package/skills/explainer-video-from-codebase/tools/lib/observe.mjs +162 -0
  50. package/skills/explainer-video-from-codebase/tools/lib/redact.mjs +28 -0
  51. package/skills/explainer-video-from-codebase/tools/lib/sandbox.mjs +97 -0
  52. package/skills/explainer-video-from-codebase/tools/lib/test-links.mjs +28 -0
  53. package/skills/explainer-video-from-codebase/tools/lib/toml.mjs +129 -0
  54. package/skills/explainer-video-from-codebase/tools/lib/trace-c.mjs +95 -0
  55. package/skills/explainer-video-from-codebase/tools/lib/trace-go.mjs +101 -0
  56. package/skills/explainer-video-from-codebase/tools/lib/trace-js.mjs +136 -0
  57. package/skills/explainer-video-from-codebase/tools/lib/tree-sitter.mjs +19 -0
  58. package/skills/explainer-video-from-codebase/tools/lib/walk.mjs +138 -0
  59. package/skills/explainer-video-from-codebase/tools/mine.mjs +409 -0
  60. package/skills/explainer-video-from-codebase/tools/propose.mjs +127 -0
  61. package/skills/explainer-video-from-codebase/tools/rank.mjs +119 -0
  62. package/skills/explainer-video-from-codebase/tools/ranking-md.mjs +76 -0
  63. package/skills/explainer-video-from-codebase/tools/rules/c-handlers.mjs +53 -0
  64. package/skills/explainer-video-from-codebase/tools/rules/celery-taskiq.mjs +48 -0
  65. package/skills/explainer-video-from-codebase/tools/rules/click-typer.mjs +6 -0
  66. package/skills/explainer-video-from-codebase/tools/rules/express.mjs +72 -0
  67. package/skills/explainer-video-from-codebase/tools/rules/fastapi.mjs +99 -0
  68. package/skills/explainer-video-from-codebase/tools/rules/go-http.mjs +173 -0
  69. package/skills/explainer-video-from-codebase/tools/rules/go-test.mjs +108 -0
  70. package/skills/explainer-video-from-codebase/tools/rules/grpc.mjs +57 -0
  71. package/skills/explainer-video-from-codebase/tools/rules/gtest.mjs +97 -0
  72. package/skills/explainer-video-from-codebase/tools/rules/haystack.mjs +55 -0
  73. package/skills/explainer-video-from-codebase/tools/rules/jest-vitest.mjs +100 -0
  74. package/skills/explainer-video-from-codebase/tools/rules/js-http-client.mjs +111 -0
  75. package/skills/explainer-video-from-codebase/tools/rules/nextjs.mjs +53 -0
  76. package/skills/explainer-video-from-codebase/tools/rules/process-spawn.mjs +49 -0
  77. package/skills/explainer-video-from-codebase/tools/rules/py-http-client.mjs +26 -0
  78. package/skills/explainer-video-from-codebase/tools/rules/pytest.mjs +107 -0
  79. package/skills/explainer-video-from-codebase/tools/rules/react-router.mjs +17 -0
  80. package/skills/explainer-video-from-codebase/tools/rules/registry.mjs +31 -0
  81. package/skills/explainer-video-from-codebase/tools/trace/c/fmtrace-sh +14 -0
  82. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-net.cjs +33 -0
  83. package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-srcmap.cjs +41 -0
  84. package/skills/explainer-video-from-codebase/tools/trace/python/fmtrace_plugin.py +87 -0
  85. package/skills/explainer-video-from-codebase/tools/trace/python/sitecustomize.py +330 -0
  86. package/skills/explainer-video-from-codebase/tools/trace/sandbox/linux-bwrap.sh +73 -0
  87. package/skills/explainer-video-from-codebase/tools/trace/sandbox/macos-nonet.sb +41 -0
  88. package/skills/explainer-video-from-codebase/tools/trace.mjs +577 -0
  89. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-c.txt +21 -0
  90. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-cpp.txt +21 -0
  91. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-go.txt +21 -0
  92. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-javascript.txt +21 -0
  93. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-python.txt +21 -0
  94. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-typescript.txt +21 -0
  95. package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/web-tree-sitter.txt +21 -0
  96. package/skills/explainer-video-from-codebase/vendor/tree-sitter/MANIFEST.json +172 -0
  97. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-c.wasm +0 -0
  98. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-cpp.wasm +0 -0
  99. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-go.wasm +0 -0
  100. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-javascript.wasm +0 -0
  101. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-python.wasm +0 -0
  102. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-tsx.wasm +0 -0
  103. package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-typescript.wasm +0 -0
  104. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.js +4081 -0
  105. package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.wasm +0 -0
  106. package/skills/explainer-video-from-coursework/ARCHITECTURE.md +59 -0
  107. package/skills/explainer-video-from-coursework/PIPELINE.md +343 -0
  108. package/skills/explainer-video-from-coursework/ROADMAP.md +725 -0
  109. package/skills/explainer-video-from-coursework/SERIES.md +232 -0
  110. package/skills/explainer-video-from-coursework/SETUP.md +105 -0
  111. package/skills/explainer-video-from-coursework/SKILL.md +858 -0
  112. package/skills/explainer-video-from-coursework/scenes/_pt-chart.js +12 -0
  113. package/skills/explainer-video-from-coursework/scenes/_pt-equation.js +10 -0
  114. package/skills/explainer-video-from-coursework/scenes/_pt-hubspoke.js +7 -0
  115. package/skills/explainer-video-from-coursework/scenes/_pt-loopflow.js +7 -0
  116. package/skills/explainer-video-from-coursework/scenes/_pt-sequence.js +12 -0
  117. package/skills/explainer-video-from-coursework/scenes/_pt-timeline.js +11 -0
  118. package/skills/explainer-video-from-coursework/src/scene-code.html +127 -0
  119. package/skills/explainer-video-from-coursework/src/scene-code.js +379 -0
  120. package/skills/explainer-video-from-coursework/src/scene-core.js +1250 -0
  121. package/skills/explainer-video-from-coursework/src/scene-diagram.html +50 -0
  122. package/skills/explainer-video-from-coursework/src/scene-svg.html +50 -0
  123. package/skills/explainer-video-from-coursework/src/timeline.js +297 -0
  124. package/skills/explainer-video-from-coursework/src/timing-chart.js +160 -0
  125. package/skills/explainer-video-from-coursework/tools/align.mjs +419 -0
  126. package/skills/explainer-video-from-coursework/tools/apply-enrichment.mjs +206 -0
  127. package/skills/explainer-video-from-coursework/tools/assemble.mjs +223 -0
  128. package/skills/explainer-video-from-coursework/tools/build-audio.mjs +234 -0
  129. package/skills/explainer-video-from-coursework/tools/chromium.mjs +73 -0
  130. package/skills/explainer-video-from-coursework/tools/debug-page.mjs +75 -0
  131. package/skills/explainer-video-from-coursework/tools/env.mjs +30 -0
  132. package/skills/explainer-video-from-coursework/tools/frame-dedup.mjs +131 -0
  133. package/skills/explainer-video-from-coursework/tools/key-source.mjs +151 -0
  134. package/skills/explainer-video-from-coursework/tools/model.mjs +21 -0
  135. package/skills/explainer-video-from-coursework/tools/naming.mjs +38 -0
  136. package/skills/explainer-video-from-coursework/tools/onset-audit.mjs +51 -0
  137. package/skills/explainer-video-from-coursework/tools/pacing-check.mjs +350 -0
  138. package/skills/explainer-video-from-coursework/tools/planner.mjs +616 -0
  139. package/skills/explainer-video-from-coursework/tools/publish.mjs +230 -0
  140. package/skills/explainer-video-from-coursework/tools/render.mjs +205 -0
  141. package/skills/explainer-video-from-coursework/tools/resolve-beats.mjs +239 -0
  142. package/skills/explainer-video-from-coursework/tools/script-check.mjs +174 -0
  143. package/skills/explainer-video-from-coursework/tools/stamp.mjs +71 -0
  144. package/skills/explainer-video-from-coursework/tools/timing-report.mjs +299 -0
  145. package/skills/explainer-video-from-coursework/tools/transcribe-gemini.mjs +96 -0
  146. package/skills/explainer-video-from-coursework/tools/tts-gemini.mjs +394 -0
  147. package/skills/explainer-video-from-coursework/tools/verify-audio.mjs +88 -0
  148. package/skills/explainer-video-from-coursework/tools/workdir.mjs +124 -0
  149. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js +8 -0
  150. package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js.LICENSE.txt +16 -0
@@ -0,0 +1,1250 @@
1
+ /* =============================================================================
2
+ scene-core.js — generic animated-diagram interpreter.
3
+
4
+ Consumes a declarative scene config and renders it. The animation is a PURE
5
+ FUNCTION of time: nothing accumulates between frames, so frames may be
6
+ rendered in any order, in parallel, or re-rendered after an edit.
7
+
8
+ Contract with the host page:
9
+ window.SceneCore.createScene({ svg, config, timeline }) -> { renderAt(t) }
10
+
11
+ The config vocabulary is deliberately small:
12
+ layout columns + rows (the grid nodes sit on)
13
+ nodes groups of nodes by kind (client | server | hub)
14
+ edges connections between node groups / nodes
15
+ flows packets travelling routes, with rotation + exclusion rules
16
+ pings health-check probes along edges
17
+ rules time windows that change node/edge state
18
+ banners transient pills / glyphs (badges, question marks)
19
+ overlays title card, header, stage badge, progress (opt-in), cards, closing line
20
+ ========================================================================== */
21
+ (function () {
22
+ 'use strict';
23
+
24
+ const SVGNS = 'http://www.w3.org/2000/svg';
25
+
26
+ /* ---------------- pure math ---------------- */
27
+ const clamp01 = v => (v < 0 ? 0 : v > 1 ? 1 : v);
28
+ const lerp = (a, b, p) => a + (b - a) * p;
29
+ const seg = (t, a, b) => clamp01((t - a) / (b - a || 1e-6));
30
+ const easeOut = p => 1 - Math.pow(1 - p, 3);
31
+ const easeIO = p => (p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2);
32
+ const backOut = p => { const c = 1.70158, c3 = c + 1; return 1 + c3 * Math.pow(p - 1, 3) + c * Math.pow(p - 1, 2); };
33
+ const fadeIn = (t, a, b) => easeOut(seg(t, a, b));
34
+ const fadeOut = (t, a, b) => 1 - easeIO(seg(t, a, b));
35
+ const win = (t, a, b, r) => clamp01((t - (a - r)) / r) * clamp01(((b + r) - t) / r);
36
+ const inWindow = (t, r) => t >= r.from && t < (r.until == null ? Infinity : r.until);
37
+
38
+ /* ---------------- svg helpers ---------------- */
39
+ function el(tag, attrs, parent) {
40
+ const n = document.createElementNS(SVGNS, tag);
41
+ if (attrs) for (const k in attrs) n.setAttribute(k, attrs[k]);
42
+ if (parent) parent.appendChild(n);
43
+ return n;
44
+ }
45
+ function txt(x, y, str, attrs, parent) {
46
+ const n = el('text', Object.assign({ x: x, y: y }, attrs || {}), parent);
47
+ n.textContent = str;
48
+ return n;
49
+ }
50
+ const set = (node, attrs) => { for (const k in attrs) node.setAttribute(k, attrs[k]); };
51
+ const curve = (x1, y1, x2, y2, bend) => {
52
+ const dx = (x2 - x1) * (bend == null ? 0.5 : bend);
53
+ return 'M ' + x1 + ' ' + y1 + ' C ' + (x1 + dx) + ' ' + y1 + ', ' + (x2 - dx) + ' ' + y2 + ', ' + x2 + ' ' + y2;
54
+ };
55
+
56
+ const DEFAULT_PALETTE = {
57
+ bg1: '#080e1a', bg2: '#0e1728', grid: '#182742', gridSoft: '#111d33',
58
+ client: '#38bdf8', clientFill: '#0b2133',
59
+ hub: '#fbbf24', hubFill: '#2a1e07',
60
+ ok: '#34d399', okFill: '#07251d',
61
+ bad: '#fb7185', badFill: '#2c0f17',
62
+ text: '#e8eefc', muted: '#93a7c8', bad2: '#5d6f8c', hair: '#22334f',
63
+ };
64
+
65
+ const BANNER_BG = { bad: '#2c0f17', ok: '#07251d', hub: '#2a1e07', client: '#0b2133', muted: '#111d33', text: '#111d33' };
66
+
67
+ const KIND_DEFAULTS = {
68
+ client: { w: 150, h: 92, accent: 'client', fill: 'clientFill' },
69
+ server: { w: 190, h: 118, accent: 'ok', fill: 'okFill' },
70
+ hub: { w: 252, h: 172, accent: 'hub', fill: 'hubFill' },
71
+ // Generic UML-ish class box: name, optional «stereotype», divider, members.
72
+ // Height is derived from the member count unless an explicit size is given.
73
+ box: { w: 470, h: 132, accent: 'client', fill: 'clientFill' },
74
+ };
75
+
76
+ /* ---------------- factory ---------------- */
77
+ /**
78
+ * Resolve "scene@offset" strings to absolute seconds, recursively.
79
+ *
80
+ * Beats are expressed RELATIVE to a scene start (e.g. "problem@4.6") so the
81
+ * choreography stays locked to the narration. Hardcoded absolute seconds
82
+ * silently drift the moment the audio is re-recorded with different pacing.
83
+ */
84
+ function deepResolve(value, scenes) {
85
+ if (typeof value === 'string') {
86
+ const m = /^([a-z][a-z0-9-]*)@(-?[0-9.]+)$/.exec(value);
87
+ if (m && scenes[m[1]]) return +(scenes[m[1]].start + parseFloat(m[2])).toFixed(4);
88
+ return value;
89
+ }
90
+ if (Array.isArray(value)) return value.map(v => deepResolve(v, scenes));
91
+ if (value && typeof value === 'object') {
92
+ const out = {};
93
+ for (const k in value) out[k] = deepResolve(value[k], scenes);
94
+ return out;
95
+ }
96
+ return value;
97
+ }
98
+
99
+ function createScene(opts) {
100
+ const svg = opts.svg;
101
+ const TL = opts.timeline;
102
+ const scenes = TL.scenes;
103
+ const cfg = deepResolve(opts.config, scenes);
104
+ const P = Object.assign({}, DEFAULT_PALETTE, cfg.palette || {});
105
+ const W = (cfg.meta && cfg.meta.width) || TL.width;
106
+ const H = (cfg.meta && cfg.meta.height) || TL.height;
107
+
108
+ /* ---- defs ---- */
109
+ const defs = el('defs', {}, svg);
110
+ const bgGrad = el('linearGradient', { id: 'bgGrad', x1: '0', y1: '0', x2: '0', y2: '1' }, defs);
111
+ el('stop', { offset: '0%', 'stop-color': P.bg2 }, bgGrad);
112
+ el('stop', { offset: '100%', 'stop-color': P.bg1 }, bgGrad);
113
+ const mkGlow = (id, dev) => {
114
+ const f = el('filter', { id: id, x: '-120%', y: '-120%', width: '340%', height: '340%' }, defs);
115
+ el('feGaussianBlur', { stdDeviation: dev, result: 'b' }, f);
116
+ const m = el('feMerge', {}, f);
117
+ el('feMergeNode', { in: 'b' }, m);
118
+ el('feMergeNode', { in: 'SourceGraphic' }, m);
119
+ };
120
+ // one arrow marker per palette colour (context-stroke is not universally safe)
121
+ const ARROW_IDS = {};
122
+ ['client', 'ok', 'hub', 'bad', 'bad2', 'muted', 'text'].forEach(k => {
123
+ const id = 'arrow-' + k;
124
+ const m = el('marker', { id: id, viewBox: '0 0 10 10', refX: '9', refY: '5',
125
+ markerWidth: '6', markerHeight: '6', orient: 'auto' }, defs);
126
+ el('path', { d: 'M 0 0 L 10 5 L 0 10 z', fill: P[k] }, m);
127
+ ARROW_IDS[k] = id;
128
+ });
129
+ mkGlow('softGlow', '14');
130
+ mkGlow('glow', '6');
131
+
132
+ el('rect', { x: 0, y: 0, width: W, height: H, fill: 'url(#bgGrad)' }, svg);
133
+ const grid = el('g', { opacity: 0.5 }, svg);
134
+ for (let x = 0; x <= W; x += 60) el('line', { x1: x, y1: 0, x2: x, y2: H, stroke: x % 300 === 0 ? P.grid : P.gridSoft, 'stroke-width': 1 }, grid);
135
+ for (let y = 0; y <= H; y += 60) el('line', { x1: 0, y1: y, x2: W, y2: y, stroke: y % 300 === 0 ? P.grid : P.gridSoft, 'stroke-width': 1 }, grid);
136
+
137
+ const world = el('g', {}, svg);
138
+ const edgesG = el('g', {}, world);
139
+ const pingG = el('g', {}, world);
140
+ const packetG = el('g', {}, world);
141
+ const nodesG = el('g', {}, world);
142
+ const fxG = el('g', {}, world);
143
+ const chromeG = el('g', {}, svg);
144
+ const overlayG = el('g', {}, svg);
145
+
146
+ /* ---- resolve layout ---- */
147
+ const rows = cfg.layout.rows;
148
+ const colX = id => cfg.layout.columns[id].x;
149
+ const colY = (id, row) => (cfg.layout.columns[id].y != null ? cfg.layout.columns[id].y : rows[row]);
150
+
151
+ /* ---- instantiate nodes ---- */
152
+ const nodes = {}; // id -> instance
153
+ const groups = {}; // groupId -> [ids]
154
+
155
+ (cfg.nodes || []).forEach(nd => {
156
+ const kind = nd.kind;
157
+ const kd = Object.assign({}, KIND_DEFAULTS[kind] || {}, nd.size || {});
158
+ if (kind === 'box' && !nd.size) {
159
+ kd.h = 76 + ((nd.members || []).length) * 28 + (nd.stereotype ? 22 : 0);
160
+ }
161
+ const rowsList = nd.rows || [nd.row == null ? 0 : nd.row];
162
+ // A single-row node keeps its own id ("hub"); multi-row nodes get suffixes
163
+ // ("clients#0"). Otherwise rules/edges that reference the bare id never match.
164
+ const ids = nd.ids || (rowsList.length === 1 ? [nd.id] : rowsList.map((r, i) => nd.id + '#' + i));
165
+ groups[nd.id] = ids;
166
+ rowsList.forEach((row, i) => {
167
+ const id = ids[i];
168
+ const x = nd.x != null ? nd.x : colX(nd.column);
169
+ const y = nd.y != null ? nd.y : colY(nd.column, row);
170
+ const accent = P[nd.accent || kd.accent];
171
+ const fill = P[nd.fill || kd.fill];
172
+ const g = el('g', { opacity: 0 }, nodesG);
173
+ const inst = { id: id, kind: kind, x: x, y: y, g: g, w: kd.w, h: kd.h, accent: accent, fill: fill, label: (nd.labels && nd.labels[i]) || nd.label || id, sub: nd.sub };
174
+
175
+ // halo (arrival glow)
176
+ inst.halo = el('rect', { x: x - kd.w / 2 - 10, y: y - kd.h / 2 - 10, width: kd.w + 20, height: kd.h + 20, rx: 24, fill: 'none', stroke: accent, 'stroke-width': 4, opacity: 0, filter: 'url(#softGlow)' }, g);
177
+ inst.rect = el('rect', { x: x - kd.w / 2, y: y - kd.h / 2, width: kd.w, height: kd.h, rx: 18, fill: fill, stroke: accent, 'stroke-width': 2.5 }, g);
178
+
179
+ // kind glyph + labels
180
+ const ic = el('g', {}, g);
181
+ inst.ic = ic;
182
+ if (kind === 'client') {
183
+ el('rect', { x: x - 56, y: y - 20, width: 52, height: 40, rx: 6, fill: 'none', stroke: accent, 'stroke-width': 2.5 }, ic);
184
+ el('line', { x1: x - 56, y1: y - 8, x2: x - 4, y2: y - 8, stroke: accent, 'stroke-width': 2.5 }, ic);
185
+ el('circle', { cx: x - 50, cy: y - 14, r: 2.4, fill: accent }, ic);
186
+ el('circle', { cx: x - 42, cy: y - 14, r: 2.4, fill: accent }, ic);
187
+ txt(x + 26, y + 8, nd.label || 'User', { fill: P.text, 'font-size': 23, 'font-weight': 700, 'text-anchor': 'middle' }, g);
188
+ inst.subEl = txt(x, y + kd.h / 2 + 30, (nd.sub || '').replace('{n}', String(i + 1)), { fill: P.muted, 'font-size': 18, 'text-anchor': 'middle' }, g);
189
+ } else if (kind === 'server') {
190
+ inst.bars = [];
191
+ for (let k = 0; k < 3; k++) inst.bars.push(el('rect', { x: x - 34, y: y - 40 + k * 19, width: 60, height: 13, rx: 4, fill: 'none', stroke: accent, 'stroke-width': 2.2 }, ic));
192
+ inst.leds = [];
193
+ for (let k = 0; k < 3; k++) inst.leds.push(el('circle', { cx: x + 38, cy: y - 33.5 + k * 19, r: 3.2, fill: accent }, ic));
194
+ inst.labelEl = txt(x, y + 38, inst.label, { fill: P.text, 'font-size': 24, 'font-weight': 700, 'text-anchor': 'middle' }, g);
195
+ inst.statusEl = txt(x, y + kd.h / 2 + 30, 'healthy', { fill: P.ok, 'font-size': 18, 'font-weight': 600, 'text-anchor': 'middle' }, g);
196
+ // fail marker
197
+ const mark = el('g', { opacity: 0 }, g);
198
+ el('circle', { cx: x + kd.w / 2 - 4, cy: y - kd.h / 2 + 4, r: 20, fill: '#2c0f17', stroke: P.bad, 'stroke-width': 2.5 }, mark);
199
+ el('line', { x1: x + kd.w / 2 - 14, y1: y - kd.h / 2 - 6, x2: x + kd.w / 2 + 6, y2: y - kd.h / 2 + 14, stroke: P.bad, 'stroke-width': 3.6, 'stroke-linecap': 'round' }, mark);
200
+ el('line', { x1: x + kd.w / 2 + 6, y1: y - kd.h / 2 - 6, x2: x + kd.w / 2 - 14, y2: y - kd.h / 2 + 14, stroke: P.bad, 'stroke-width': 3.6, 'stroke-linecap': 'round' }, mark);
201
+ inst.mark = mark;
202
+ // queue chips
203
+ inst.queue = [];
204
+ for (let k = 0; k < (nd.queue || 6); k++) inst.queue.push(el('rect', { x: x - kd.w / 2 - 42, y: y + 46 - k * 19, width: 26, height: 13, rx: 3.5, fill: P.bad, opacity: 0 }, g));
205
+ inst.queueLabel = txt(x - kd.w / 2 - 29, y + 78, '', { fill: P.bad, 'font-size': 15, 'font-weight': 700, 'text-anchor': 'middle' }, g);
206
+ } else if (kind === 'hub') {
207
+ el('circle', { cx: x - 58, cy: y - 28, r: 6.5, fill: accent }, ic);
208
+ for (let k = -1; k <= 1; k++) {
209
+ const ty = y - 28 + k * 30;
210
+ el('path', { d: 'M ' + (x - 46) + ' ' + (y - 28) + ' C ' + (x - 16) + ' ' + (y - 28) + ', ' + (x - 16) + ' ' + ty + ', ' + (x + 14) + ' ' + ty, fill: 'none', stroke: accent, 'stroke-width': 2.6, 'stroke-linecap': 'round' }, ic);
211
+ el('circle', { cx: x + 20, cy: ty, r: 5.2, fill: accent }, ic);
212
+ }
213
+ inst.labelEl = txt(x, y + 44, nd.label || '', { fill: P.text, 'font-size': 26, 'font-weight': 700, 'text-anchor': 'middle' }, g);
214
+ inst.subEl = txt(x, y - kd.h / 2 - 20, nd.sub || '', { fill: P.muted, 'font-size': 17, 'text-anchor': 'middle' }, g);
215
+ } else if (kind === 'box') {
216
+ const hasStereo = !!nd.stereotype;
217
+ if (hasStereo) {
218
+ txt(x, y - kd.h / 2 + 28, nd.stereotype, { fill: P.muted, 'font-size': 16, 'font-style': 'italic', 'text-anchor': 'middle' }, g);
219
+ }
220
+ const nameY = y - kd.h / 2 + (hasStereo ? 58 : 40);
221
+ inst.labelEl = txt(x, nameY, inst.label, { fill: P.text, 'font-size': 27, 'font-weight': 700, 'text-anchor': 'middle' }, g);
222
+ const divY = nameY + 15;
223
+ el('line', { x1: x - kd.w / 2 + 16, y1: divY, x2: x + kd.w / 2 - 16, y2: divY, stroke: accent, 'stroke-width': 1.5, opacity: 0.45 }, g);
224
+ inst.memberEls = (nd.members || []).map((m, k) =>
225
+ txt(x - kd.w / 2 + 24, divY + 30 + k * 28, m,
226
+ { fill: P.muted, 'font-size': 21, 'font-family': 'SF Mono, Menlo, Consolas, monospace' }, g));
227
+ if (nd.sub) inst.subEl = txt(x, y + kd.h / 2 + 32, nd.sub, { fill: P.muted, 'font-size': 19, 'text-anchor': 'middle' }, g);
228
+ // fail marker — same affordance servers have, so rules.failMarker works
229
+ // for class diagrams too (the LSP "broken Penguin" beat depends on it)
230
+ const bx = x + kd.w / 2, by = y - kd.h / 2;
231
+ const mark = el('g', { opacity: 0 }, g);
232
+ el('circle', { cx: bx - 4, cy: by + 4, r: 20, fill: '#2c0f17', stroke: P.bad, 'stroke-width': 2.5 }, mark);
233
+ el('line', { x1: bx - 14, y1: by - 6, x2: bx + 6, y2: by + 14, stroke: P.bad, 'stroke-width': 3.6, 'stroke-linecap': 'round' }, mark);
234
+ el('line', { x1: bx + 6, y1: by - 6, x2: bx - 14, y2: by + 14, stroke: P.bad, 'stroke-width': 3.6, 'stroke-linecap': 'round' }, mark);
235
+ inst.mark = mark;
236
+ }
237
+ nodes[id] = inst;
238
+ });
239
+ });
240
+
241
+ const expand = ref => (groups[ref] ? groups[ref].slice() : [ref]);
242
+
243
+ /* ---- instantiate edges ---- */
244
+ const edges = []; // {id, fromId, toId, el, len}
245
+ function addEdges(spec) {
246
+ const fromIds = expand(spec.from);
247
+ const toIds = expand(spec.to);
248
+ const bend = spec.bend == null ? 0.5 : spec.bend;
249
+ fromIds.forEach(f => {
250
+ toIds.forEach(t => {
251
+ const a = nodes[f], b = nodes[t];
252
+ if (!a || !b) return;
253
+ const ax = a.x + (a.w / 2) * (b.x > a.x ? 1 : -1);
254
+ const bx = b.x - (b.w / 2) * (b.x > a.x ? 1 : -1);
255
+ const p = el('path', { d: curve(ax, a.y, bx, b.y, bend), fill: 'none', stroke: P[spec.color || 'ok'], 'stroke-width': spec.width || 3, 'stroke-linecap': 'round', opacity: 0 }, edgesG);
256
+ if (spec.arrow) p.setAttribute('marker-end', 'url(#' + (ARROW_IDS[spec.color || 'ok'] || ARROW_IDS.ok) + ')');
257
+ edges.push({ id: spec.id, fromId: f, toId: t, el: p, len: 0, key: spec.id + ':' + f + '>' + t, spec: spec });
258
+ });
259
+ });
260
+ }
261
+ (cfg.edges || []).forEach(addEdges);
262
+
263
+ const edgeFor = (id, fromId, toId) => edges.find(e => e.id === id && (!fromId || e.fromId === fromId) && (!toId || e.toId === toId));
264
+ const edgesOf = id => edges.filter(e => e.id === id);
265
+ const geo = e => { if (!e.len) e.len = e.el.getTotalLength(); return e.len; };
266
+ const pointOn = (e, u) => { const L = geo(e); const p = e.el.getPointAtLength(L * clamp01(u)); return { x: p.x, y: p.y }; };
267
+
268
+ /* ---- fx primitives ---- */
269
+ const banners = (cfg.banners || []).map(b => {
270
+ const g = el('g', { opacity: 0 }, fxG);
271
+ if (b.type === 'pill') {
272
+ const col = P[b.color || 'bad'];
273
+ const bg = BANNER_BG[b.color || 'bad'] || '#2c0f17';
274
+ // width follows the text so short notes do not sit in a huge pill
275
+ const wpx = b.width || Math.max(180, Math.round(b.text.length * 11.5) + 56);
276
+ const hpx = b.size || 46;
277
+ el('rect', { x: -wpx / 2, y: -hpx / 2, width: wpx, height: hpx, rx: hpx / 2, fill: bg, stroke: col, 'stroke-width': 2 }, g);
278
+ txt(0, b.size ? 8 : 7, b.text, { fill: col, 'font-size': b.fontSize || 21, 'font-weight': 700, 'text-anchor': 'middle', 'letter-spacing': b.tracking == null ? 0.4 : b.tracking }, g);
279
+ } else {
280
+ txt(0, 0, b.text, { fill: P[b.color || 'hub'], 'font-size': b.size || 156, 'font-weight': 800, 'text-anchor': 'middle', filter: 'url(#glow)' }, g);
281
+ }
282
+ return { spec: b, el: g };
283
+ });
284
+
285
+ // arrival rings + served counters per node that appears as a flow target
286
+ const targetIds = new Set();
287
+ (cfg.flows || []).forEach(f => (f.targets || []).forEach(t => targetIds.add(t)));
288
+
289
+ const rings = {}, counters = {};
290
+ Array.from(targetIds).forEach(id => {
291
+ const n = nodes[id]; if (!n) return;
292
+ rings[id] = el('circle', { cx: n.x, cy: n.y, r: 58, fill: 'none', stroke: P.ok, 'stroke-width': 3, opacity: 0 }, fxG);
293
+ if (cfg.overlays && cfg.overlays.counters !== false) {
294
+ counters[id] = txt(n.x + n.w / 2 + 40, n.y + 9, '0', { fill: P.ok, 'font-size': 27, 'font-weight': 800, 'text-anchor': 'start', opacity: 0 }, fxG);
295
+ }
296
+ });
297
+
298
+ // pools
299
+ const POOL = cfg.poolSize || 44;
300
+ const packets = [];
301
+ for (let i = 0; i < POOL; i++) packets.push(el('circle', { cx: -200, cy: -200, r: 7.5, fill: P.client, opacity: 0 }, packetG));
302
+ const PINGPOOL = cfg.pingPoolSize || 20;
303
+ const pingDots = [];
304
+ for (let i = 0; i < PINGPOOL; i++) pingDots.push(el('circle', { cx: -200, cy: -200, r: 4.8, fill: P.client, opacity: 0 }, pingG));
305
+
306
+ /* ---- chrome ---- */
307
+ const OV = cfg.overlays || {};
308
+ let header = null, stageBadge = null, stageText = null, stageRect = null, progBar = null, titleCard = null, cardsG = null, cardEls = [], finalG = null;
309
+ const headerFade = OV.headerFrom != null ? [OV.headerFrom, OV.headerFrom + 1.0] : null;
310
+
311
+ if (OV.header !== false) {
312
+ header = el('g', { opacity: 0 }, chromeG);
313
+ el('circle', { cx: 74, cy: 62, r: 9, fill: P.hub }, header);
314
+ txt(96, 70, OV.headerText || '', { fill: P.text, 'font-size': 25, 'font-weight': 700 }, header);
315
+ }
316
+ if (OV.stageBadge !== false) {
317
+ stageBadge = el('g', { opacity: 0 }, chromeG);
318
+ // Width is only a default: the pill re-measures and re-centres itself for each
319
+ // stage name (see the render loop), so a long label widens the pill instead of
320
+ // spilling out of it. The right edge stays pinned at W - 74.
321
+ stageRect = el('rect', { x: W - 366, y: 38, width: 292, height: 50, rx: 25, fill: '#0f1c31', stroke: P.hair, 'stroke-width': 1.5 }, stageBadge);
322
+ stageText = txt(W - 220, 70, '', { fill: P.hub, 'font-size': 22, 'font-weight': 700, 'text-anchor': 'middle', 'letter-spacing': '0.8' }, stageBadge);
323
+ }
324
+ /* Opt-in as of 2026-10: the finished MP4 is watched in a player that already
325
+ reports progress, so an in-frame bar duplicates it — and because the bar is
326
+ the ONLY thing that changes on an otherwise static frame, it defeated every
327
+ form of static-frame skipping (82% of frames are byte-identical without it,
328
+ measured on a real scene). Scenes that genuinely want one set
329
+ `overlays.progress: true`. */
330
+ if (OV.progress === true) {
331
+ el('rect', { x: 0, y: H - 6, width: W, height: 6, fill: '#0d1728' }, chromeG);
332
+ progBar = el('rect', { x: 0, y: H - 6, width: 0, height: 6, fill: P.hub, opacity: 0.85 }, chromeG);
333
+ }
334
+
335
+ const ovG = el('g', {}, overlayG);
336
+ if (OV.title) {
337
+ titleCard = el('g', { opacity: 0 }, ovG);
338
+ txt(W / 2, OV.title.y || 452, OV.title.text, { fill: P.text, 'font-size': OV.title.size || 96, 'font-weight': 800, 'text-anchor': 'middle', 'letter-spacing': '-1.5' }, titleCard);
339
+ if (OV.title.sub) txt(W / 2, (OV.title.y || 452) + 70, OV.title.sub, { fill: P.muted, 'font-size': 31, 'text-anchor': 'middle' }, titleCard);
340
+ const rule = el('rect', { x: W / 2, y: (OV.title.y || 452) + 112, width: 0, height: 4, rx: 2, fill: P.hub }, titleCard);
341
+ titleCard._rule = rule;
342
+ // miniature teaser diagram
343
+ if (OV.title.teaser) {
344
+ const t = el('g', { opacity: 0.95 }, titleCard);
345
+ const cy = OV.title.teaserY || 700;
346
+ for (let k = -1; k <= 1; k++) {
347
+ el('circle', { cx: W / 2 - 300, cy: cy + k * 46, r: 11, fill: 'none', stroke: P.client, 'stroke-width': 2.6 }, t);
348
+ el('circle', { cx: W / 2 + 300, cy: cy + k * 46, r: 11, fill: 'none', stroke: P.ok, 'stroke-width': 2.6 }, t);
349
+ el('path', { d: 'M ' + (W / 2 - 288) + ' ' + (cy + k * 46) + ' C ' + (W / 2 - 140) + ' ' + (cy + k * 46) + ', ' + (W / 2 - 140) + ' ' + cy + ', ' + (W / 2 - 30) + ' ' + cy, fill: 'none', stroke: P.client, 'stroke-width': 2, opacity: 0.45 }, t);
350
+ el('path', { d: 'M ' + (W / 2 + 30) + ' ' + cy + ' C ' + (W / 2 + 140) + ' ' + cy + ', ' + (W / 2 + 140) + ' ' + (cy + k * 46) + ', ' + (W / 2 + 288) + ' ' + (cy + k * 46), fill: 'none', stroke: P.ok, 'stroke-width': 2, opacity: 0.45 }, t);
351
+ }
352
+ el('rect', { x: W / 2 - 28, y: cy - 16, width: 56, height: 32, rx: 9, fill: P.hubFill, stroke: P.hub, 'stroke-width': 2.4 }, t);
353
+ }
354
+ }
355
+ if (OV.cards) {
356
+ cardsG = el('g', { opacity: 0 }, ovG);
357
+ OV.cards.items.forEach((it, i) => {
358
+ const cx = W / 2 + (i - (OV.cards.items.length - 1) / 2) * (OV.cards.spacing || 470);
359
+ const cy = OV.cards.y || 560;
360
+ const g = el('g', { opacity: 0 }, cardsG);
361
+ const col = P[it.color || 'client'];
362
+ el('rect', { x: cx - 205, y: cy - 100, width: 410, height: 200, rx: 22, fill: '#0d1a2e', stroke: col, 'stroke-width': 2.5 }, g);
363
+ el('circle', { cx: cx, cy: cy - 34, r: 26, fill: 'none', stroke: col, 'stroke-width': 3 }, g);
364
+ txt(cx, cy - 25, String(i + 1), { fill: col, 'font-size': 28, 'font-weight': 800, 'text-anchor': 'middle' }, g);
365
+ txt(cx, cy + 30, it.label, { fill: P.text, 'font-size': 29, 'font-weight': 700, 'text-anchor': 'middle' }, g);
366
+ if (it.sub) txt(cx, cy + 68, it.sub, { fill: P.muted, 'font-size': 20, 'text-anchor': 'middle' }, g);
367
+ cardEls.push(g);
368
+ });
369
+ }
370
+ let chipsG = null, chipEls = [], codaEl = null;
371
+ if (OV.chips) {
372
+ chipsG = el('g', { opacity: 0 }, ovG);
373
+ const items = OV.chips.items || [];
374
+ const gap = OV.chips.gap == null ? 22 : OV.chips.gap;
375
+ const widths = items.map(t => Math.max(150, Math.round(t.length * 12.5) + 46));
376
+ const totalW = widths.reduce((a, b) => a + b, 0) + gap * (items.length - 1);
377
+ let cx = W / 2 - totalW / 2;
378
+ items.forEach((label, i) => {
379
+ const wpx = widths[i], y = OV.chips.y || 560;
380
+ const g = el('g', { opacity: 0 }, chipsG);
381
+ el('rect', { x: cx, y: y - 30, width: wpx, height: 60, rx: 30,
382
+ fill: P[OV.chips.color || 'client'] === P.client ? '#0b2133' : '#111d33',
383
+ stroke: P[OV.chips.color || 'client'], 'stroke-width': 2 }, g);
384
+ txt(cx + wpx / 2, y + 9, label, { fill: P.text, 'font-size': 22, 'font-weight': 600, 'text-anchor': 'middle' }, g);
385
+ chipEls.push({ el: g, order: i });
386
+ cx += wpx + gap;
387
+ });
388
+ if (OV.chips.coda) {
389
+ codaEl = txt(W / 2, OV.chips.codaY || (OV.chips.y || 560) + 150, OV.chips.coda,
390
+ { fill: P.hub, 'font-size': 40, 'font-weight': 800, 'text-anchor': 'middle', opacity: 0 }, ovG);
391
+ }
392
+ }
393
+
394
+ if (OV.finalLine) {
395
+ finalG = el('g', { opacity: 0 }, ovG);
396
+ txt(W / 2, OV.finalLine.y || 508, OV.finalLine.big, { fill: P.text, 'font-size': OV.finalLine.size || 84, 'font-weight': 800, 'text-anchor': 'middle' }, finalG);
397
+ if (OV.finalLine.small) txt(W / 2, (OV.finalLine.y || 508) + 72, OV.finalLine.small, { fill: P.hub, 'font-size': 30, 'font-weight': 700, 'text-anchor': 'middle', 'letter-spacing': '1.2' }, finalG);
398
+ }
399
+
400
+ /* =========================================================================
401
+ Rules resolution
402
+ ========================================================================= */
403
+ const R = cfg.rules || {};
404
+ // Only nodes named in a state rule get state colouring. Without this, clients
405
+ // and hubs inherit "healthy green" and lose their own accent colour.
406
+ const stateful = new Set((R.nodeStates || []).map(r => r.target));
407
+ const stateAt = (nodeId, t) => {
408
+ const list = R.nodeStates || [];
409
+ let cur = null;
410
+ for (const r of list) if (r.target === nodeId && inWindow(t, r)) cur = r;
411
+ return cur;
412
+ };
413
+ const activeRule = (key, nodeId, t) => {
414
+ const list = R[key] || [];
415
+ let hit = null;
416
+ for (const r of list) if (r.target === nodeId && inWindow(t, r)) hit = r;
417
+ return hit;
418
+ };
419
+ const edgeDisabled = (e, t) =>
420
+ (R.edgeDisable || []).some(r => r.id === e.id && r.toId === e.toId && inWindow(t, r));
421
+
422
+ /* =========================================================================
423
+ Flows
424
+ ========================================================================= */
425
+ function flowPackets(flow, t) {
426
+ const out = [];
427
+ const interval = flow.interval, travel = flow.travel;
428
+ const until = flow.until == null ? Infinity : flow.until;
429
+ const maxK = flow.maxPackets || 260;
430
+ for (let k = 0; k < maxK; k++) {
431
+ const T = flow.from + k * interval;
432
+ if (T > until) break;
433
+ if (T > t + 0.001) break;
434
+ const p = (t - T) / travel;
435
+ if (p < -0.05 || p > 1.3) continue;
436
+ let ti;
437
+ if (flow.strategy === 'rotate') {
438
+ const targets = (flow.targets || []).filter(id => !(flow.exclude || []).some(r => r.target === id && T >= r.from && T < r.until));
439
+ const order = targets.length ? targets : (flow.targets || []);
440
+ ti = order[k % order.length];
441
+ } else {
442
+ ti = (flow.targets || [])[0];
443
+ }
444
+ const srcs = expand(flow.source);
445
+ const si = srcs[k % srcs.length];
446
+ out.push({ flow: flow, k: k, T: T, p: p, si: si, ti: ti, visible: p >= 0 && p <= 1 });
447
+ }
448
+ return out;
449
+ }
450
+
451
+ function allPackets(t) {
452
+ let out = [];
453
+ (cfg.flows || []).forEach(f => { out = out.concat(flowPackets(f, t)); });
454
+ return out;
455
+ }
456
+
457
+ function servedCounts(t) {
458
+ const counts = {};
459
+ Array.from(targetIds).forEach(id => (counts[id] = 0));
460
+ (cfg.flows || []).forEach(flow => {
461
+ if (flow.count === false) return;
462
+ const until = flow.until == null ? Infinity : flow.until;
463
+ for (let k = 0; k < (flow.maxPackets || 260); k++) {
464
+ const T = flow.from + k * flow.interval;
465
+ if (T > until || T > t) break;
466
+ if (t < T + flow.travel) continue;
467
+ let ti;
468
+ if (flow.strategy === 'rotate') {
469
+ const targets = (flow.targets || []).filter(id => !(flow.exclude || []).some(r => r.target === id && T >= r.from && T < r.until));
470
+ const order = targets.length ? targets : (flow.targets || []);
471
+ ti = order[k % order.length];
472
+ } else ti = (flow.targets || [])[0];
473
+ if (counts[ti] != null) counts[ti]++;
474
+ }
475
+ });
476
+ return counts;
477
+ }
478
+
479
+ /* =========================================================================
480
+ render(t)
481
+ ========================================================================= */
482
+ let prevStage = null;
483
+
484
+ function render(t) {
485
+ const pk = allPackets(t);
486
+
487
+ // arrival glow per target
488
+ const ARR = {};
489
+ pk.forEach(q => {
490
+ const key = q.flow.route === 'via-hub' ? q.ti : q.ti;
491
+ const age = t - (q.T + q.flow.travel);
492
+ if (age >= 0 && age < 1.15) ARR[key] = Math.max(ARR[key] || 0, 1 - age / 1.15);
493
+ });
494
+
495
+ // world dimming during a designated scene
496
+ const dim = OV.dimDuring ? OV.dimDuring : null;
497
+ let outroMix = 0;
498
+ if (dim && scenes[dim]) outroMix = easeIO(seg(t, scenes[dim].start + (OV.dimDelay || 0.35), scenes[dim].start + (OV.dimDelay || 0.35) + 1.15));
499
+ set(world, { opacity: (1 - (OV.dimAmount == null ? 0.84 : OV.dimAmount) * outroMix).toFixed(3) });
500
+ set(grid, { opacity: (0.5 * (1 - 0.6 * outroMix)).toFixed(3) });
501
+
502
+ // ---- nodes ----
503
+ for (const id in nodes) {
504
+ const n = nodes[id];
505
+ const rev = (cfg.reveal || {})[id];
506
+ let op = 1;
507
+ if (rev) op = fadeIn(t, rev[0], rev[1]);
508
+ // nodes can also retire — a "before" box dissolving as its replacement appears
509
+ const hid = (cfg.hide || {})[id];
510
+ if (hid) op *= fadeOut(t, hid[0], hid[1]);
511
+ const st = stateAt(id, t);
512
+ const state = st ? st.state : 'ok';
513
+ const isStateful = stateful.has(id);
514
+ const accent = isStateful
515
+ ? (state === 'ok' ? P.ok : state === 'warn' ? P.hub : P.bad)
516
+ : n.accent;
517
+ const fill = isStateful
518
+ ? (state === 'ok' ? P.okFill : state === 'warn' ? P.hubFill : P.badFill)
519
+ : n.fill;
520
+ const ov = activeRule('overload', id, t);
521
+ const overload = ov ? win(t, ov.from, ov.until, ov.ramp || 1.0) : 0;
522
+ const shake = overload * Math.sin(t * 34) * 3.2;
523
+ let tf = '';
524
+ if ((cfg.popReveal || []).indexOf(id) >= 0 && rev) {
525
+ const pop = backOut(seg(t, rev[0], rev[1]));
526
+ tf += 'translate(' + n.x + ' ' + n.y + ') scale(' + (0.55 + 0.45 * pop).toFixed(4) + ') translate(' + (-n.x) + ' ' + (-n.y) + ') ';
527
+ }
528
+ tf += 'translate(' + shake.toFixed(2) + ', 0)';
529
+ set(n.g, { opacity: op.toFixed(3), transform: tf });
530
+ set(n.rect, { stroke: accent, fill: fill });
531
+ if (n.ic) set(n.ic, { opacity: state === 'bad' && n.kind !== 'client' ? 0.55 : 0.95 });
532
+ if (n.bars) n.bars.forEach(b => set(b, { stroke: accent }));
533
+ if (n.leds) n.leds.forEach((l, k) => set(l, { fill: accent, opacity: (0.45 + 0.55 * Math.abs(Math.sin(t * 3 + k))).toFixed(2) }));
534
+ if (n.statusEl) {
535
+ let text = 'healthy', fill2 = P.ok;
536
+ if (state === 'bad') { text = (st && st.status) || 'unhealthy'; fill2 = P.bad; }
537
+ else if (state === 'warn') { text = (st && st.status) || 'failing…'; fill2 = P.hub; }
538
+ else if (overload > 0.05) { text = 'cpu maxed'; fill2 = P.bad; }
539
+ n.statusEl.textContent = text;
540
+ set(n.statusEl, { fill: fill2, opacity: (0.55 + 0.45 * op).toFixed(3) });
541
+ }
542
+ if (n.mark) {
543
+ const mr = activeRule('failMarker', id, t);
544
+ set(n.mark, { opacity: mr ? win(t, mr.from, mr.until, 0.35).toFixed(3) : 0 });
545
+ }
546
+ if (n.queue) {
547
+ n.queue.forEach((q, k) => set(q, { opacity: (clamp01(overload * n.queue.length - k) * 0.9).toFixed(3) }));
548
+ if (n.queueLabel) {
549
+ set(n.queueLabel, { opacity: overload > 0.3 ? 0.95 : 0 });
550
+ n.queueLabel.textContent = overload > 0.3 ? (ov && ov.label) || 'queue' : '';
551
+ }
552
+ }
553
+ if (n.halo) {
554
+ const g = ARR[id] || 0;
555
+ set(n.halo, { opacity: (g * 0.85 * op).toFixed(3), stroke: accent });
556
+ }
557
+ }
558
+
559
+ // ---- edges ----
560
+ edges.forEach(e => {
561
+ const drawSpec = (cfg.draw || {})[e.id];
562
+ let draw = 1;
563
+ if (drawSpec) {
564
+ // stagger along whichever end of the edge varies
565
+ const members = expand(drawSpec.order || e.spec.from);
566
+ let idx = members.indexOf(e.fromId);
567
+ if (idx < 0) idx = members.indexOf(e.toId);
568
+ if (idx < 0) idx = 0;
569
+ draw = fadeIn(t, drawSpec.at[0] + idx * (drawSpec.stagger || 0), drawSpec.at[1] + idx * (drawSpec.stagger || 0));
570
+ }
571
+ const dis = edgeDisabled(e, t);
572
+ const fadeSpec = (cfg.edgeFade || {})[e.id];
573
+ const alive = fadeSpec
574
+ ? (fadeSpec.length > 2
575
+ ? lerp(fadeSpec[2], 1, fadeOut(t, fadeSpec[0], fadeSpec[1]))
576
+ : fadeOut(t, fadeSpec[0], fadeSpec[1]))
577
+ : 1;
578
+ const L = geo(e);
579
+ set(e.el, {
580
+ 'stroke-dasharray': dis ? '9 11' : L,
581
+ 'stroke-dashoffset': dis ? 0 : (L * (1 - draw)).toFixed(2),
582
+ opacity: (0.92 * draw * alive).toFixed(3),
583
+ stroke: dis ? P.bad2 : (e.spec.disabledColor ? P[e.spec.disabledColor] : P[e.spec.color || 'ok']),
584
+ });
585
+ });
586
+
587
+ // ---- packets ----
588
+ let used = 0;
589
+ for (const q of pk) {
590
+ if (!q.visible || used >= POOL) continue;
591
+ const node = packets[used++];
592
+ const flow = q.flow;
593
+ let pos;
594
+ if (flow.route === 'via-hub') {
595
+ const split = flow.split == null ? 0.42 : flow.split;
596
+ const inE = edgeFor(flow.inEdge, q.si, flow.hub);
597
+ const outE = edgeFor(flow.outEdge, flow.hub, q.ti);
598
+ if (q.p <= split && inE) pos = pointOn(inE, q.p / split);
599
+ else if (outE) pos = pointOn(outE, (q.p - split) / (1 - split));
600
+ } else {
601
+ const e = edgeFor(flow.edge, q.si, q.ti);
602
+ if (e) pos = pointOn(e, q.p);
603
+ }
604
+ if (!pos) { used--; continue; }
605
+ const bad = stateAt(q.ti, t) && stateAt(q.ti, t).state === 'bad';
606
+ const split = flow.split == null ? 0.42 : flow.split;
607
+ const overloaded = flow.route !== 'via-hub' && t > 12 && q.p > 0.78;
608
+ set(node, {
609
+ cx: pos.x.toFixed(1), cy: pos.y.toFixed(1),
610
+ fill: bad || overloaded ? P.bad : flow.route === 'via-hub' ? (q.p > split ? P.ok : P.client) : P.client,
611
+ opacity: 0.98,
612
+ });
613
+ }
614
+ for (let i = used; i < POOL; i++) set(packets[i], { opacity: 0, cx: -200, cy: -200 });
615
+
616
+ // ---- pings ----
617
+ let pu = 0;
618
+ (cfg.pings || []).forEach(ps => {
619
+ const travel = ps.travel || 0.85;
620
+ for (let k = 0; k < (ps.maxPackets || 60); k++) {
621
+ const T = ps.from + k * ps.interval;
622
+ if (T > t + 0.001) break;
623
+ const p = (t - T) / travel;
624
+ if (p < 0 || p > 1.35) continue;
625
+ (ps.targets || []).forEach(tid => {
626
+ if (pu >= PINGPOOL) return;
627
+ const node = pingDots[pu++];
628
+ const e = edgeFor(ps.edge, ps.hub, tid);
629
+ if (!e) return;
630
+ const pos = pointOn(e, p);
631
+ const bad = stateAt(tid, T + travel) && stateAt(tid, T + travel).state === 'bad';
632
+ const fade = clamp01(1 - Math.abs(p - 0.5) * 1.4);
633
+ set(node, { cx: pos.x.toFixed(1), cy: pos.y.toFixed(1), r: 4.8, fill: bad ? P.bad : P.client, opacity: (p <= 1 ? fade * 0.85 : 0).toFixed(3) });
634
+ });
635
+ }
636
+ });
637
+ for (let i = pu; i < PINGPOOL; i++) set(pingDots[i], { opacity: 0, cx: -200, cy: -200 });
638
+
639
+ // ---- rings + counters ----
640
+ const counts = OV.counters === false ? {} : servedCounts(t);
641
+ const showCounters = OV.countersAt ? win(t, OV.countersAt[0], OV.countersAt[1], 0.8) : 0;
642
+ Array.from(targetIds).forEach(id => {
643
+ const n = nodes[id]; if (!n) return;
644
+ const g = ARR[id] || 0;
645
+ const st = stateAt(id, t);
646
+ const bad = st && st.state === 'bad';
647
+ if (rings[id]) set(rings[id], { r: lerp(58, 108, 1 - g).toFixed(1), opacity: (g * 0.8 * (bad ? 0 : 1)).toFixed(3), stroke: bad ? P.bad : P.ok });
648
+ if (counters[id]) {
649
+ counters[id].textContent = String(counts[id] || 0);
650
+ set(counters[id], { opacity: (showCounters * (bad ? 0.45 : 0.95)).toFixed(3), fill: bad ? P.bad : P.ok });
651
+ }
652
+ });
653
+
654
+ // ---- banners ----
655
+ banners.forEach(b => {
656
+ const sp = b.spec;
657
+ const o = win(t, sp.from, sp.until, sp.ramp || 0.7) * (sp.pulse ? (0.75 + 0.25 * Math.sin(t * sp.pulse)) : 1);
658
+ let bx = 0, by = 0;
659
+ if (sp.anchor && nodes[sp.anchor]) {
660
+ const n = nodes[sp.anchor];
661
+ bx = n.x + (sp.offset ? sp.offset[0] : 0);
662
+ by = n.y + (sp.offset ? sp.offset[1] : 0);
663
+ } else if (sp.at) { bx = sp.at[0]; by = sp.at[1]; }
664
+ if (sp.wobble) by += Math.sin(t * sp.wobble) * 2.2;
665
+ if (sp.float) by += Math.sin(t * sp.float) * 8;
666
+ set(b.el, { opacity: o.toFixed(3), transform: 'translate(' + bx.toFixed(1) + ' ' + by.toFixed(1) + ')' });
667
+ });
668
+
669
+ // ---- chrome ----
670
+ if (header) {
671
+ let o = 1;
672
+ if (headerFade) o = fadeIn(t, headerFade[0], headerFade[1]);
673
+ if (OV.headerUntil) o *= fadeOut(t, OV.headerUntil[0], OV.headerUntil[1]);
674
+ set(header, { opacity: (o * 0.95).toFixed(3) });
675
+ }
676
+ let active = null;
677
+ for (const k in scenes) if (t >= scenes[k].start) active = k;
678
+ // Before the first scene begins (e.g. during the lead-in silence) no scene
679
+ // is active yet. The load-balancer project never hit this because its first
680
+ // scene started at 0; a measured timeline starts at the narration, not 0.
681
+ if (active === null) active = Object.keys(scenes)[0];
682
+ if (stageBadge) {
683
+ const sc = scenes[active];
684
+ const lt = t - sc.start;
685
+ const name = (OV.stageNames || {})[active];
686
+ const o = name ? clamp01(lt / 0.3) * clamp01((sc.dur - lt) / 0.3) * 0.98 : 0;
687
+ if (active !== prevStage) {
688
+ stageText.textContent = name || '';
689
+ prevStage = active;
690
+ if (stageRect) {
691
+ const label = name || '';
692
+ // textW measures the glyphs; SVG letter-spacing adds 0.8px per character.
693
+ const textPx = label ? textW(label, 22, 700) + label.length * 0.8 : 0;
694
+ const wpx = Math.max(292, Math.round(textPx) + 56);
695
+ const bx = W - 74 - wpx;
696
+ set(stageRect, { x: bx.toFixed(1), width: wpx });
697
+ set(stageText, { x: (bx + wpx / 2).toFixed(1) });
698
+ }
699
+ }
700
+ set(stageBadge, { opacity: o.toFixed(3), transform: 'translate(0 ' + ((1 - o) * 10).toFixed(2) + ')' });
701
+ }
702
+ if (progBar) set(progBar, { width: (W * clamp01(t / TL.total)).toFixed(1) });
703
+
704
+ // ---- overlays ----
705
+ if (titleCard && OV.title) {
706
+ const a = OV.title.from, b = OV.title.until;
707
+ const tcIn = fadeIn(t, a + 0.35, a + 1.35);
708
+ const tcOut = fadeOut(t, b - 0.5, b + 0.15);
709
+ set(titleCard, { opacity: (t < b ? tcIn * tcOut : 0).toFixed(3), transform: 'translate(0 ' + ((1 - tcIn) * 26).toFixed(2) + ')' });
710
+ if (titleCard._rule) {
711
+ const p = fadeIn(t, a + 0.9, a + 1.7);
712
+ set(titleCard._rule, { width: (260 * p).toFixed(1), x: (W / 2 - 130 * p).toFixed(1) });
713
+ }
714
+ }
715
+ if (cardsG && OV.cards) {
716
+ const oB = scenes[OV.cards.scene || 'outro'].start;
717
+ set(cardsG, { opacity: fadeIn(t, oB + (OV.cards.inAt == null ? 0.9 : OV.cards.inAt), oB + (OV.cards.inAt == null ? 0.9 : OV.cards.inAt) + 0.7).toFixed(3) });
718
+ cardEls.forEach((g, i) => {
719
+ const a = oB + (OV.cards.inAt == null ? 1.0 : OV.cards.inAt + 0.1) + i * (OV.cards.stagger || 1.75);
720
+ const o = fadeIn(t, a, a + 0.6) * fadeOut(t, oB + (OV.cards.outAt || 6.9), oB + (OV.cards.outAt || 6.9) + 0.6);
721
+ set(g, { opacity: o.toFixed(3), transform: 'translate(0 ' + ((1 - fadeIn(t, a, a + 0.6)) * 22).toFixed(2) + ')' });
722
+ });
723
+ }
724
+ if (chipsG && OV.chips) {
725
+ const oB = scenes[OV.chips.scene || 'outro'].start;
726
+ const inAt = OV.chips.at == null ? 1.0 : OV.chips.at;
727
+ set(chipsG, { opacity: fadeIn(t, oB + inAt - 0.6, oB + inAt).toFixed(3) });
728
+ chipEls.forEach(c => {
729
+ const a = oB + inAt + c.order * (OV.chips.stagger || 0.5);
730
+ const o = fadeIn(t, a, a + 0.5) * fadeOut(t, oB + (OV.chips.outAt || 6.5), oB + (OV.chips.outAt || 6.5) + 0.5);
731
+ set(c.el, { opacity: o.toFixed(3), transform: 'translate(0 ' + ((1 - fadeIn(t, a, a + 0.5)) * 16).toFixed(1) + ')' });
732
+ });
733
+ if (codaEl) {
734
+ const ca = oB + (OV.chips.codaAt == null ? 5.0 : OV.chips.codaAt);
735
+ set(codaEl, { opacity: fadeIn(t, ca, ca + 0.7).toFixed(3), transform: 'translate(0 ' + ((1 - fadeIn(t, ca, ca + 0.7)) * 14).toFixed(1) + ')' });
736
+ }
737
+ }
738
+ if (finalG && OV.finalLine) {
739
+ const oB = scenes[OV.finalLine.scene || 'outro'].start;
740
+ const at = OV.finalLine.at == null ? 7.6 : OV.finalLine.at;
741
+ set(finalG, { opacity: fadeIn(t, oB + at, oB + at + 0.6).toFixed(3), transform: 'translate(0 ' + ((1 - fadeIn(t, oB + at, oB + at + 0.6)) * 20).toFixed(2) + ')' });
742
+ }
743
+ }
744
+
745
+ render(0);
746
+ return { renderAt: render, nodes: nodes, edges: edges };
747
+ }
748
+
749
+ /* =========================================================================
750
+ Primitives — the library (stage 5's floor)
751
+
752
+ Each primitive is a PURE function of time: prim(parent, params, t) -> void
753
+ It draws the frame for `t` into `parent` and returns; nothing accumulates,
754
+ so frames may be rendered in any order. `at` is the beat's start and `dur`
755
+ its reveal length, in seconds — the beat layer maps authored beats onto them.
756
+
757
+ Usage (the open `svg` lane):
758
+ window.SCENE_CONFIG = { renderAt: function (t, svg) {
759
+ SceneCore.primitives.chart(svg, { type: 'pie', x: 960, y: 520, radius: 220,
760
+ at: 0, data: [{ label: 'A', value: 3 }, { label: 'B', value: 1 }] }, t);
761
+ } };
762
+ ========================================================================== */
763
+
764
+ const pal = over => Object.assign({}, DEFAULT_PALETTE, over || {});
765
+ const prog = (t, at, dur) => easeOut(clamp01((t - (at || 0)) / (dur || 0.001)));
766
+
767
+ /* exact text widths without a DOM round-trip */
768
+ let _mctx = null;
769
+ function textW(s, size, weight, family) {
770
+ if (!_mctx) _mctx = document.createElement('canvas').getContext('2d');
771
+ _mctx.font = (weight || 400) + ' ' + size + 'px ' +
772
+ (family || '"Helvetica Neue", Helvetica, Arial, sans-serif');
773
+ return _mctx.measureText(s).width;
774
+ }
775
+ const arrowHead = (g, x, y, ang, color, op) =>
776
+ el('path', { d: 'M -10 -6 L 0 0 L -10 6 Z', fill: color,
777
+ transform: 'translate(' + x + ' ' + y + ') rotate(' + ang + ')', opacity: op }, g);
778
+
779
+ /* --- pie wedge with a progressive sweep --- */
780
+ function sectorPath(cx, cy, r, a0, a1, p) {
781
+ const span = (a1 - a0) * p;
782
+ if (span <= 0.0001) return '';
783
+ const end = a0 + span;
784
+ const large = span > Math.PI ? 1 : 0;
785
+ return 'M ' + cx + ' ' + cy +
786
+ ' L ' + (cx + Math.cos(a0) * r) + ' ' + (cy + Math.sin(a0) * r) +
787
+ ' A ' + r + ' ' + r + ' 0 ' + large + ' 1 ' +
788
+ (cx + Math.cos(end) * r) + ' ' + (cy + Math.sin(end) * r) + ' Z';
789
+ }
790
+
791
+ /* ---- chart: pie | bar ------------------------------------------------- */
792
+ function primChart(parent, params, t) {
793
+ const C = pal(params.palette);
794
+ const at = params.at || 0;
795
+ if (t < at) return;
796
+ const p = prog(t, at, params.dur == null ? 1.0 : params.dur);
797
+ const data = (params.data || []).filter(d => d.value > 0);
798
+ if (!data.length) return;
799
+ const total = data.reduce((a, d) => a + d.value, 0) || 1;
800
+ const col = d => C[d.color || 'client'];
801
+
802
+ if ((params.type || 'pie') === 'pie') {
803
+ const cx = params.x, cy = params.y, r = params.radius == null ? 200 : params.radius;
804
+ const lp = clamp01((p - 0.55) / 0.45);
805
+ let a0 = -Math.PI / 2; /* start at 12 o'clock */
806
+ data.forEach(d => {
807
+ const a1 = a0 + (d.value / total) * Math.PI * 2;
808
+ el('path', { d: sectorPath(cx, cy, r, a0, a1, p), fill: col(d),
809
+ opacity: 0.88, stroke: C.bg1, 'stroke-width': 3 }, parent);
810
+ if (params.labels !== false) {
811
+ const mid = (a0 + a1) / 2, right = Math.cos(mid) >= 0;
812
+ const lx = cx + Math.cos(mid) * (r + 26);
813
+ const ly = cy + Math.sin(mid) * (r + 26);
814
+ txt(lx, ly + 8, d.label, { fill: C.text, 'font-size': 22, 'font-weight': 700,
815
+ 'text-anchor': right ? 'start' : 'end', opacity: lp.toFixed(3) }, parent);
816
+ txt(lx, ly + 34, Math.round((d.value / total) * 100) + '%',
817
+ { fill: C.muted, 'font-size': 18, 'text-anchor': right ? 'start' : 'end',
818
+ opacity: lp.toFixed(3) }, parent);
819
+ }
820
+ a0 = a1;
821
+ });
822
+ if (params.title) txt(cx, cy - r - 36, params.title,
823
+ { fill: C.muted, 'font-size': 25, 'text-anchor': 'middle' }, parent);
824
+ return;
825
+ }
826
+
827
+ const x = params.x, y = params.y, w = params.w, h = params.h;
828
+ const max = Math.max.apply(null, data.map(d => d.value).concat([1]));
829
+ const slot = w / data.length;
830
+ const bw = Math.min(slot * 0.55, params.barW == null ? 90 : params.barW);
831
+ el('line', { x1: x, y1: y + h, x2: x + w, y2: y + h, stroke: C.hair, 'stroke-width': 2 }, parent);
832
+ data.forEach((d, i) => {
833
+ const bx = x + slot * i + (slot - bw) / 2;
834
+ const bh = (d.value / max) * (h - 20) * p;
835
+ el('rect', { x: bx, y: y + h - bh, width: bw, height: bh, rx: 8,
836
+ fill: col(d), opacity: 0.88 }, parent);
837
+ txt(bx + bw / 2, y + h + 32, d.label,
838
+ { fill: C.muted, 'font-size': 20, 'text-anchor': 'middle' }, parent);
839
+ txt(bx + bw / 2, y + h - bh - 16, String(d.value),
840
+ { fill: C.text, 'font-size': 22, 'font-weight': 700, 'text-anchor': 'middle',
841
+ opacity: clamp01((p - 0.6) / 0.4).toFixed(3) }, parent);
842
+ });
843
+ if (params.title) txt(x + w / 2, y - 26, params.title,
844
+ { fill: C.muted, 'font-size': 25, 'text-anchor': 'middle' }, parent);
845
+ }
846
+
847
+ /* ---- hub-and-spoke: a centre with N surrounding boxes + emanations ----- */
848
+ function primHubSpoke(parent, params, t) {
849
+ const C = pal(params.palette);
850
+ const at = params.at || 0;
851
+ if (t < at) return;
852
+ const cx = params.x, cy = params.y;
853
+ const R = params.radius == null ? 330 : params.radius;
854
+ const spokes = params.spokes || [];
855
+ const n = spokes.length || 1;
856
+ const hubW = params.hubW == null ? 270 : params.hubW;
857
+ const hubH = params.hubH == null ? 112 : params.hubH;
858
+ const sw = params.spokeW == null ? 230 : params.spokeW;
859
+ const sh = params.spokeH == null ? 96 : params.spokeH;
860
+ const stagger = params.stagger == null ? 0.3 : params.stagger;
861
+ const hp = prog(t, at, params.dur == null ? 0.6 : params.dur);
862
+ const step = (Math.PI * 2) / n;
863
+
864
+ spokes.forEach((s, i) => {
865
+ const a = -Math.PI / 2 + i * step;
866
+ const sx = cx + Math.cos(a) * R, sy = cy + Math.sin(a) * R;
867
+ const sp = prog(t, at + 0.5 + i * stagger, 0.7);
868
+ if (sp <= 0) return;
869
+ const col = C[s.color || 'client'];
870
+ const x0 = cx + Math.cos(a) * (hubW / 2), y0 = cy + Math.sin(a) * (hubH / 2);
871
+ const x1 = sx - Math.cos(a) * (sw / 2), y1 = sy - Math.sin(a) * (sh / 2);
872
+ const ang = Math.atan2(y1 - y0, x1 - x0) * 180 / Math.PI;
873
+ el('line', { x1: x0, y1: y0, x2: x0 + (x1 - x0) * sp, y2: y0 + (y1 - y0) * sp,
874
+ stroke: col, 'stroke-width': 3, opacity: 0.85, 'stroke-linecap': 'round' }, parent);
875
+ if (sp > 0.9) arrowHead(parent, x1, y1, ang, col, ((sp - 0.9) / 0.1).toFixed(3));
876
+ el('rect', { x: sx - sw / 2, y: sy - sh / 2, width: sw, height: sh, rx: 14,
877
+ fill: C[s.fill || 'okFill'], stroke: col, 'stroke-width': 2.5, opacity: sp.toFixed(3) }, parent);
878
+ txt(sx, sy + 9, s.label || ('S' + (i + 1)),
879
+ { fill: C.text, 'font-size': 24, 'font-weight': 700, 'text-anchor': 'middle', opacity: sp.toFixed(3) }, parent);
880
+ if (s.sub) txt(sx, sy + sh / 2 + 28, s.sub,
881
+ { fill: C.muted, 'font-size': 18, 'text-anchor': 'middle', opacity: sp.toFixed(3) }, parent);
882
+ });
883
+
884
+ el('rect', { x: cx - hubW / 2, y: cy - hubH / 2, width: hubW, height: hubH, rx: 18,
885
+ fill: C.hubFill, stroke: C.hub, 'stroke-width': 3, opacity: hp.toFixed(3) }, parent);
886
+ txt(cx, cy + 10, params.label || 'Hub',
887
+ { fill: C.text, 'font-size': 30, 'font-weight': 800, 'text-anchor': 'middle', opacity: hp.toFixed(3) }, parent);
888
+ if (params.sub) txt(cx, cy - hubH / 2 - 24, params.sub,
889
+ { fill: C.muted, 'font-size': 20, 'text-anchor': 'middle', opacity: hp.toFixed(3) }, parent);
890
+ }
891
+
892
+ /* ---- loop-flow: N nodes in a cycle, with packets circulating ---------- */
893
+ function primLoopFlow(parent, params, t) {
894
+ const C = pal(params.palette);
895
+ const at = params.at || 0;
896
+ const nodes = params.nodes || [];
897
+ if (t < at || nodes.length < 2) return;
898
+ const n = nodes.length;
899
+ const p = prog(t, at, params.dur == null ? 1.0 : params.dur);
900
+ const closed = params.loop !== false;
901
+ const edgeCount = closed ? n : n - 1;
902
+
903
+ let gx = 0, gy = 0;
904
+ nodes.forEach(nd => { gx += nd.x; gy += nd.y; });
905
+ gx /= n; gy /= n;
906
+ const bow = params.bow == null ? 0.2 : params.bow;
907
+
908
+ const quads = [];
909
+ for (let i = 0; i < edgeCount; i++) {
910
+ const a = nodes[i], b = nodes[(i + 1) % n];
911
+ const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
912
+ const qx = mx + (mx - gx) * bow * 2, qy = my + (my - gy) * bow * 2;
913
+ quads.push({ a: a, b: b, qx: qx, qy: qy });
914
+ const ep = prog(t, at + i * (params.edgeStagger == null ? 0.18 : params.edgeStagger), 0.5);
915
+ if (ep > 0) el('path', { d: 'M ' + a.x + ' ' + a.y + ' Q ' + qx + ' ' + qy + ' ' + b.x + ' ' + b.y,
916
+ fill: 'none', stroke: C[params.edgeColor || 'ok'], 'stroke-width': 3,
917
+ opacity: (0.8 * ep).toFixed(3), 'stroke-linecap': 'round' }, parent);
918
+ }
919
+
920
+ if (params.packets !== false && p > 0.2) {
921
+ const K = params.packetCount == null ? 3 : params.packetCount;
922
+ const lap = params.lap == null ? 6 : params.lap;
923
+ for (let k = 0; k < K; k++) {
924
+ const f = ((((t - at) / lap) + k / K) % 1 + 1) % 1;
925
+ const seg = f * edgeCount;
926
+ const q = quads[Math.floor(seg) % edgeCount];
927
+ if (!q) continue;
928
+ const u = seg - Math.floor(seg), iu = 1 - u;
929
+ el('circle', { cx: iu * iu * q.a.x + 2 * iu * u * q.qx + u * u * q.b.x,
930
+ cy: iu * iu * q.a.y + 2 * iu * u * q.qy + u * u * q.b.y,
931
+ r: params.dot == null ? 9 : params.dot,
932
+ fill: C[params.dotColor || 'client'], opacity: 0.95 }, parent);
933
+ }
934
+ }
935
+
936
+ const nw = params.nodeW == null ? 210 : params.nodeW;
937
+ const nh = params.nodeH == null ? 96 : params.nodeH;
938
+ nodes.forEach((nd, i) => {
939
+ const np = prog(t, at + i * (params.nodeStagger == null ? 0.15 : params.nodeStagger), 0.5);
940
+ if (np <= 0) return;
941
+ el('rect', { x: nd.x - nw / 2, y: nd.y - nh / 2, width: nw, height: nh, rx: 14,
942
+ fill: C[nd.fill || 'okFill'], stroke: C[nd.color || 'ok'], 'stroke-width': 2.5,
943
+ opacity: np.toFixed(3) }, parent);
944
+ txt(nd.x, nd.y + 9, nd.label || ('N' + (i + 1)),
945
+ { fill: C.text, 'font-size': 24, 'font-weight': 700, 'text-anchor': 'middle', opacity: np.toFixed(3) }, parent);
946
+ });
947
+ }
948
+
949
+ /* ---- timeline: tracks, events, and happens-before arrows -------------- */
950
+ function primTimeline(parent, params, t) {
951
+ const C = pal(params.palette);
952
+ const at = params.at || 0;
953
+ if (t < at) return;
954
+ const x = params.x, y = params.y, w = params.w;
955
+ const tracks = params.tracks || [];
956
+ const laneH = params.laneH == null ? 120 : params.laneH;
957
+
958
+ tracks.forEach((tr, i) => {
959
+ const ly = y + i * laneH;
960
+ el('line', { x1: x, y1: ly, x2: x + w, y2: ly, stroke: C.hair, 'stroke-width': 2 }, parent);
961
+ el('circle', { cx: x, cy: ly, r: 6, fill: C[tr.color || 'muted'] }, parent);
962
+ txt(x - 24, ly + 8, tr.label || ('P' + (i + 1)),
963
+ { fill: C.text, 'font-size': 24, 'font-weight': 700, 'text-anchor': 'end' }, parent);
964
+ });
965
+
966
+ const marks = [];
967
+ (params.events || []).forEach((ev, i) => {
968
+ const ex = x + ev.at * w, ey = y + (ev.track || 0) * laneH;
969
+ const ep = prog(t, at + 0.3 + i * (params.eventStagger == null ? 0.25 : params.eventStagger), 0.4);
970
+ marks.push({ x: ex, y: ey });
971
+ if (ep <= 0) return;
972
+ const col = C[ev.color || 'client'];
973
+ const r = params.markR == null ? 11 : params.markR;
974
+ el('circle', { cx: ex, cy: ey, r: (r * ep).toFixed(2), fill: col, opacity: 0.95 }, parent);
975
+ if (ev.label) txt(ex, ey - r - 18, ev.label,
976
+ { fill: C.text, 'font-size': 20, 'font-weight': 700, 'text-anchor': 'middle', opacity: ep.toFixed(3) }, parent);
977
+ });
978
+
979
+ (params.arrows || []).forEach((ar, i) => {
980
+ const a = marks[ar.from], b = marks[ar.to];
981
+ if (!a || !b) return;
982
+ const ap = prog(t, at + 0.9 + i * (params.arrowStagger == null ? 0.3 : params.arrowStagger), 0.6);
983
+ if (ap <= 0) return;
984
+ const lift = params.arrowLift == null ? 52 : params.arrowLift;
985
+ const qx = (a.x + b.x) / 2, qy = Math.min(a.y, b.y) - lift;
986
+ const col = C[ar.color || 'hub'];
987
+ /* stop short of the target marker, or the head lands on top of it */
988
+ const back = (params.markR == null ? 11 : params.markR) + 10;
989
+ const chord = Math.hypot(b.x - a.x, b.y - a.y) || 1;
990
+ const u = ap * Math.max(0.05, 1 - back / chord), iu = 1 - u;
991
+ const ex = iu * iu * a.x + 2 * iu * u * qx + u * u * b.x;
992
+ const ey = iu * iu * a.y + 2 * iu * u * qy + u * u * b.y;
993
+ el('path', { d: 'M ' + a.x + ' ' + a.y + ' Q ' + qx + ' ' + qy + ' ' + ex + ' ' + ey,
994
+ fill: 'none', stroke: col, 'stroke-width': 3, opacity: 0.9, 'stroke-linecap': 'round' }, parent);
995
+ if (ap > 0.97) arrowHead(parent, ex, ey, Math.atan2(ey - qy, ex - qx) * 180 / Math.PI,
996
+ col, ((ap - 0.97) / 0.03).toFixed(3));
997
+ if (ar.label) txt(qx, (a.y + b.y) / 2 - lift + 10, ar.label,
998
+ { fill: C.muted, 'font-size': 19, 'text-anchor': 'middle', opacity: ap.toFixed(3) }, parent);
999
+ });
1000
+
1001
+ if (params.axisLabel) txt(x + w / 2, y + tracks.length * laneH + 28, params.axisLabel,
1002
+ { fill: C.muted, 'font-size': 20, 'text-anchor': 'middle' }, parent);
1003
+ }
1004
+
1005
+ /* ---- sequence: participants with lifelines, calls revealed in order ---- */
1006
+ /* A call sequence across a flow's components (explainer-video-from-codebase's
1007
+ flow-diagram.mjs --as sequence writes the participants and messages). A
1008
+ message's `kind` sets its look: a plain call is solid; a queue, HTTP or spawn
1009
+ crossing, or a return, is dashed. A message to its own participant loops. */
1010
+ const SEQ_COLOR = { call: 'client', queue: 'hub', http: 'ok', spawn: 'bad', return: 'muted' };
1011
+ function primSequence(parent, params, t) {
1012
+ const C = pal(params.palette);
1013
+ const at = params.at || 0;
1014
+ const parts = params.participants || [];
1015
+ if (t < at || !parts.length) return;
1016
+ const msgs = params.messages || [];
1017
+ const x = params.x == null ? 160 : params.x, y = params.y == null ? 170 : params.y;
1018
+ const w = params.w == null ? 1600 : params.w, h = params.h == null ? 800 : params.h;
1019
+ const bh = params.boxH == null ? 74 : params.boxH;
1020
+ const stagger = params.stagger == null ? 0.6 : params.stagger;
1021
+ /* boxes narrow, and labels shrink, until neighbours keep a 16px gap */
1022
+ let bw = params.boxW == null ? 230 : params.boxW;
1023
+ if (parts.length > 1) bw = Math.min(bw, (w - bw) / (parts.length - 1) - 16);
1024
+ const slot = parts.length > 1 ? (w - bw) / (parts.length - 1) : 0;
1025
+ const fit = (s, size) => Math.max(14, Math.min(size, size * (bw - 20) / (textW(s, size, 700) || 1)));
1026
+ const cx = i => (parts.length > 1 ? x + bw / 2 + i * slot : x + w / 2);
1027
+ const idx = {};
1028
+ parts.forEach((pt, i) => { idx[pt.id] = i; });
1029
+ const hp = prog(t, at, params.dur == null ? 0.6 : params.dur);
1030
+
1031
+ parts.forEach((pt, i) => {
1032
+ const col = C[pt.color || 'client'];
1033
+ el('line', { x1: cx(i), y1: y + bh, x2: cx(i), y2: y + bh + (h - bh) * hp, stroke: C.hair, 'stroke-width': 2,
1034
+ 'stroke-dasharray': '6 8', opacity: hp.toFixed(3) }, parent);
1035
+ el('rect', { x: cx(i) - bw / 2, y: y, width: bw, height: bh, rx: 14, fill: C[pt.fill || 'clientFill'],
1036
+ stroke: col, 'stroke-width': 2.5, opacity: hp.toFixed(3) }, parent);
1037
+ txt(cx(i), y + (pt.sub ? 34 : 45), pt.label || pt.id,
1038
+ { fill: C.text, 'font-size': fit(pt.label || pt.id, 23).toFixed(1), 'font-weight': 700, 'text-anchor': 'middle', opacity: hp.toFixed(3) }, parent);
1039
+ if (pt.sub) txt(cx(i), y + 60, pt.sub,
1040
+ { fill: C.muted, 'font-size': fit(pt.sub, 17).toFixed(1), 'text-anchor': 'middle', opacity: hp.toFixed(3) }, parent);
1041
+ });
1042
+
1043
+ const rowH = (h - bh - 30) / Math.max(1, msgs.length);
1044
+ msgs.forEach((m, i) => {
1045
+ const a = idx[m.from], b = idx[m.to];
1046
+ if (a == null || b == null) return;
1047
+ const mp = prog(t, m.at == null ? at + 0.5 + i * stagger : m.at, 0.5);
1048
+ if (mp <= 0) return;
1049
+ const kind = m.kind || 'call';
1050
+ const col = C[m.color || SEQ_COLOR[kind] || 'client'];
1051
+ const my = y + bh + 30 + rowH * (i + 0.5);
1052
+ const dash = kind === 'call' ? null : '12 8';
1053
+ const line = { fill: 'none', stroke: col, 'stroke-width': 3, 'stroke-linecap': 'round', opacity: 0.9 };
1054
+ if (dash) line['stroke-dasharray'] = dash;
1055
+ if (a === b) {
1056
+ const loop = 60, dy = Math.min(rowH * 0.5, 34);
1057
+ const u = mp;
1058
+ el('path', Object.assign({ d: 'M ' + cx(a) + ' ' + (my - dy / 2) + ' h ' + (loop * u) +
1059
+ (u > 0.5 ? ' v ' + dy * (u - 0.5) * 2 : '') + (u > 0.85 ? ' h ' + (-loop) : '') }, line), parent);
1060
+ if (mp > 0.9) arrowHead(parent, cx(a) + 2, my + dy / 2, 180, col, ((mp - 0.9) / 0.1).toFixed(3));
1061
+ if (m.label) txt(cx(a) + loop + 14, my + 7, m.label,
1062
+ { fill: C.text, 'font-size': 20, 'font-weight': 600, opacity: mp.toFixed(3) }, parent);
1063
+ return;
1064
+ }
1065
+ const x0 = cx(a), x1 = cx(b), xe = x0 + (x1 - x0) * mp;
1066
+ el('line', Object.assign({ x1: x0, y1: my, x2: xe, y2: my }, line), parent);
1067
+ if (mp > 0.9) arrowHead(parent, x1, my, x1 > x0 ? 0 : 180, col, ((mp - 0.9) / 0.1).toFixed(3));
1068
+ if (m.label) txt((x0 + x1) / 2, my - 12, m.label,
1069
+ { fill: C.text, 'font-size': 20, 'font-weight': 600, 'text-anchor': 'middle', opacity: mp.toFixed(3) }, parent);
1070
+ if (kind !== 'call' && kind !== 'return') txt((x0 + x1) / 2, my + 26, kind,
1071
+ { fill: col, 'font-size': 16, 'font-weight': 700, 'letter-spacing': 1, 'text-anchor': 'middle', opacity: mp.toFixed(3) }, parent);
1072
+ });
1073
+ }
1074
+
1075
+ /* ---- equation: a small TeX subset ------------------------------------- */
1076
+ const MATH_MACROS = {
1077
+ alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ε', zeta: 'ζ', eta: 'η',
1078
+ theta: 'θ', iota: 'ι', kappa: 'κ', lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', pi: 'π',
1079
+ rho: 'ρ', sigma: 'σ', tau: 'τ', upsilon: 'υ', phi: 'φ', chi: 'χ', psi: 'ψ', omega: 'ω',
1080
+ Gamma: 'Γ', Delta: 'Δ', Theta: 'Θ', Lambda: 'Λ', Xi: 'Ξ', Pi: 'Π', Sigma: 'Σ',
1081
+ Phi: 'Φ', Psi: 'Ψ', Omega: 'Ω',
1082
+ to: '→', rightarrow: '→', leftarrow: '←', leftrightarrow: '↔', Rightarrow: '⇒',
1083
+ le: '≤', leq: '≤', ge: '≥', geq: '≥', ne: '≠', neq: '≠', approx: '≈', equiv: '≡',
1084
+ times: '×', cdot: '·', div: '÷', pm: '±', mp: '∓', propto: '∝',
1085
+ in: '∈', notin: '∉', subset: '⊂', subseteq: '⊆', cup: '∪', cap: '∩', emptyset: '∅',
1086
+ sum: '∑', prod: '∏', int: '∫', partial: '∂', nabla: '∇', infty: '∞',
1087
+ forall: '∀', exists: '∃', lceil: '⌈', rceil: '⌉', lfloor: '⌊', rfloor: '⌋',
1088
+ langle: '⟨', rangle: '⟩', dots: '…', ldots: '…', cdots: '⋯',
1089
+ iff: '⇔', implies: '⟹', therefore: '∴', because: '∵', qed: '∎',
1090
+ };
1091
+
1092
+ /* binary relations/operators get thin space either side, TeX-style */
1093
+ const RELATIONS = '=<>≤≥≠≈≡→←↔⇒⇔∈∉⊂⊆∪∩±∓×÷·∝';
1094
+
1095
+ function parseMath(src) {
1096
+ let i = 0;
1097
+ const isLetter = c => /[A-Za-z]/.test(c);
1098
+ const isWord = c => /[A-Za-z0-9.]/.test(c);
1099
+ function braced() {
1100
+ i++;
1101
+ const items = seq();
1102
+ if (src[i] === '}') i++;
1103
+ return items;
1104
+ }
1105
+ function single() { return src[i] === '{' ? braced() : atom(); }
1106
+ function macro() {
1107
+ i++;
1108
+ if (!isLetter(src[i])) { const ch = src[i++]; return [{ k: 'run', s: ch }]; }
1109
+ let m = '';
1110
+ while (i < src.length && isLetter(src[i])) m += src[i++];
1111
+ if (m === 'frac') return [{ k: 'frac', num: single(), den: single() }];
1112
+ if (m === 'sqrt') return [{ k: 'sqrt', body: single() }];
1113
+ if (m === 'text' || m === 'mathrm' || m === 'operatorname') {
1114
+ return [{ k: 'run', s: single().map(x => x.s || '').join('') }];
1115
+ }
1116
+ if (m === 'left' || m === 'right') return [];
1117
+ if (m === 'mathbb' || m === 'mathbf' || m === 'mathit') return single();
1118
+ return [{ k: 'run', s: MATH_MACROS[m] != null ? MATH_MACROS[m] : m }];
1119
+ }
1120
+ function atom() {
1121
+ const c = src[i];
1122
+ if (c === '\\') return macro();
1123
+ if (c === '{') return braced();
1124
+ if (isWord(c)) {
1125
+ let s = '';
1126
+ while (i < src.length && isWord(src[i])) s += src[i++];
1127
+ return [{ k: 'run', s: s }];
1128
+ }
1129
+ i++;
1130
+ return [{ k: 'run', s: c }];
1131
+ }
1132
+ function seq() {
1133
+ const items = [];
1134
+ while (i < src.length && src[i] !== '}') {
1135
+ if (src[i] === '^' || src[i] === '_') {
1136
+ const which = src[i++], arg = single();
1137
+ items.push({ k: 'script', base: { k: 'run', s: '' },
1138
+ sup: which === '^' ? arg : null, sub: which === '_' ? arg : null });
1139
+ continue;
1140
+ }
1141
+ const at = atom();
1142
+ let sup = null, sub = null;
1143
+ while (src[i] === '^' || src[i] === '_') {
1144
+ const which = src[i++], arg = single();
1145
+ if (which === '^') sup = arg; else sub = arg;
1146
+ }
1147
+ if (sup || sub) {
1148
+ items.push.apply(items, at.slice(0, -1));
1149
+ items.push({ k: 'script', base: at[at.length - 1], sup: sup, sub: sub });
1150
+ } else items.push.apply(items, at);
1151
+ }
1152
+ return items;
1153
+ }
1154
+ return seq();
1155
+ }
1156
+
1157
+ function mathAtom(it, size) {
1158
+ if (it.k === 'run') {
1159
+ const isRel = it.s.length === 1 && RELATIONS.indexOf(it.s) >= 0;
1160
+ const pad = isRel ? size * 0.17 : 0;
1161
+ const w = textW(it.s, size) + pad * 2;
1162
+ return { w: w, asc: size * 0.75, desc: size * 0.25,
1163
+ draw: function (x, base, g, color) {
1164
+ txt(x + pad, base, it.s, { fill: color, 'font-size': size }, g);
1165
+ } };
1166
+ }
1167
+ if (it.k === 'frac') {
1168
+ const num = mathBox(it.num, size * 0.82), den = mathBox(it.den, size * 0.82);
1169
+ const pad = size * 0.2, gap = size * 0.26;
1170
+ const w = Math.max(num.w, den.w) + pad * 2;
1171
+ return { w: w, asc: gap / 2 + num.asc + num.desc, desc: gap / 2 + den.asc + den.desc,
1172
+ draw: function (x, base, g, color) {
1173
+ num.draw(x + (w - num.w) / 2, base - gap / 2 - num.desc, g, color);
1174
+ den.draw(x + (w - den.w) / 2, base + gap / 2 + den.asc, g, color);
1175
+ el('line', { x1: x + pad * 0.35, y1: base, x2: x + w - pad * 0.35, y2: base,
1176
+ stroke: color, 'stroke-width': Math.max(1.5, size * 0.045) }, g);
1177
+ } };
1178
+ }
1179
+ if (it.k === 'script') {
1180
+ const base = mathAtom(it.base, size);
1181
+ const ss = size * 0.66;
1182
+ const sup = it.sup ? mathBox(it.sup, ss) : null;
1183
+ const sub = it.sub ? mathBox(it.sub, ss) : null;
1184
+ const lift = size * 0.42, drop = size * 0.18;
1185
+ return { w: base.w + Math.max(sup ? sup.w : 0, sub ? sub.w : 0),
1186
+ asc: Math.max(base.asc, sup ? lift + sup.asc + sup.desc : 0),
1187
+ desc: Math.max(base.desc, sub ? drop + sub.asc + sub.desc : 0),
1188
+ draw: function (x, b, g, color) {
1189
+ base.draw(x, b, g, color);
1190
+ if (sup) sup.draw(x + base.w, b - lift - sup.desc, g, color);
1191
+ if (sub) sub.draw(x + base.w, b + drop + sub.asc, g, color);
1192
+ } };
1193
+ }
1194
+ const inner = mathBox(it.body, size);
1195
+ const hook = size * 0.5, pad = size * 0.1;
1196
+ const w = inner.w + hook + pad * 2;
1197
+ const asc = inner.asc + size * 0.18, desc = inner.desc;
1198
+ return { w: w, asc: asc, desc: desc,
1199
+ draw: function (x, base, g, color) {
1200
+ const top = base - inner.asc - size * 0.06;
1201
+ el('path', { d: 'M ' + x + ' ' + (base - size * 0.16) +
1202
+ ' L ' + (x + hook * 0.4) + ' ' + (base + size * 0.08) +
1203
+ ' L ' + (x + hook * 0.78) + ' ' + top +
1204
+ ' L ' + (x + w) + ' ' + top,
1205
+ fill: 'none', stroke: color, 'stroke-width': Math.max(1.5, size * 0.05),
1206
+ 'stroke-linecap': 'round', 'stroke-linejoin': 'round' }, g);
1207
+ inner.draw(x + hook + pad, base, g, color);
1208
+ } };
1209
+ }
1210
+
1211
+ function mathBox(items, size) {
1212
+ const parts = (items || []).map(it => mathAtom(it, size));
1213
+ const w = parts.reduce((a, b) => a + b.w, 0);
1214
+ return { w: w,
1215
+ asc: Math.max.apply(null, parts.map(b => b.asc).concat([0])),
1216
+ desc: Math.max.apply(null, parts.map(b => b.desc).concat([0])),
1217
+ draw: function (x, base, g, color) {
1218
+ let cx = x;
1219
+ parts.forEach(b => { b.draw(cx, base, g, color); cx += b.w; });
1220
+ } };
1221
+ }
1222
+
1223
+ function primEquation(parent, params, t) {
1224
+ const C = pal(params.palette);
1225
+ const at = params.at || 0;
1226
+ if (t < at) return;
1227
+ const size = params.size == null ? 46 : params.size;
1228
+ const p = prog(t, at, params.dur == null ? 0.8 : params.dur);
1229
+ const box = mathBox(parseMath(params.tex || ''), size);
1230
+ const centred = params.x == null;
1231
+ const x = centred ? (params.cx == null ? 960 : params.cx) - box.w / 2 : params.x;
1232
+ const base = params.y == null ? 540 : params.y;
1233
+ const g = el('g', { opacity: p.toFixed(3) }, parent);
1234
+ box.draw(x, base, g, C[params.color || 'text']);
1235
+ if (params.label) txt(params.cx == null ? 960 : params.cx, base + box.desc + 48, params.label,
1236
+ { fill: C.muted, 'font-size': 21, 'text-anchor': 'middle', opacity: p.toFixed(3) }, parent);
1237
+ }
1238
+
1239
+ window.SceneCore = {
1240
+ createScene: createScene,
1241
+ primitives: {
1242
+ chart: primChart,
1243
+ hubSpoke: primHubSpoke,
1244
+ loopFlow: primLoopFlow,
1245
+ timeline: primTimeline,
1246
+ equation: primEquation,
1247
+ sequence: primSequence,
1248
+ },
1249
+ };
1250
+ })();