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.
- package/.claude-plugin/plugin.json +12 -0
- package/LICENSE +21 -0
- package/README.md +152 -2
- package/cordis.patch.yml +7 -0
- package/lib/index.js +35 -0
- package/package.json +56 -4
- package/plugin.json +13 -0
- package/skills/coursework-author/.synced-from +5 -0
- package/skills/coursework-author/SKILL.md +209 -0
- package/skills/craft-codeflow-flows/SKILL.md +116 -0
- package/skills/craft-codeflow-mine/SKILL.md +207 -0
- package/skills/craft-codeflow-plan/SKILL.md +65 -0
- package/skills/craft-codeflow-scene/SKILL.md +168 -0
- package/skills/craft-video-align/SKILL.md +109 -0
- package/skills/craft-video-assemble/SKILL.md +69 -0
- package/skills/craft-video-audio/SKILL.md +59 -0
- package/skills/craft-video-enrich/SKILL.md +46 -0
- package/skills/craft-video-plan/SKILL.md +57 -0
- package/skills/craft-video-publish/SKILL.md +79 -0
- package/skills/craft-video-render/SKILL.md +68 -0
- package/skills/craft-video-scene/SKILL.md +112 -0
- package/skills/craft-video-script/SKILL.md +80 -0
- package/skills/explainer-video-from-codebase/FLOWS.md +577 -0
- package/skills/explainer-video-from-codebase/SKILL.md +659 -0
- package/skills/explainer-video-from-codebase/tools/apply-flows.mjs +531 -0
- package/skills/explainer-video-from-codebase/tools/approve-flows.mjs +56 -0
- package/skills/explainer-video-from-codebase/tools/codemap-md.mjs +116 -0
- package/skills/explainer-video-from-codebase/tools/config/dotenv.mjs +13 -0
- package/skills/explainer-video-from-codebase/tools/config/json.mjs +14 -0
- package/skills/explainer-video-from-codebase/tools/config/redact.mjs +16 -0
- package/skills/explainer-video-from-codebase/tools/config/toml-lite.mjs +13 -0
- package/skills/explainer-video-from-codebase/tools/config/yaml-lite.mjs +18 -0
- package/skills/explainer-video-from-codebase/tools/excerpt-check.mjs +186 -0
- package/skills/explainer-video-from-codebase/tools/excerpt.mjs +116 -0
- package/skills/explainer-video-from-codebase/tools/flow-diagram.mjs +168 -0
- package/skills/explainer-video-from-codebase/tools/flow-plan.mjs +129 -0
- package/skills/explainer-video-from-codebase/tools/flows-md.mjs +87 -0
- package/skills/explainer-video-from-codebase/tools/lang/c.mjs +684 -0
- package/skills/explainer-video-from-codebase/tools/lang/go.mjs +673 -0
- package/skills/explainer-video-from-codebase/tools/lang/python-config.mjs +143 -0
- package/skills/explainer-video-from-codebase/tools/lang/python-deps.mjs +62 -0
- package/skills/explainer-video-from-codebase/tools/lang/python-stdlib.mjs +24 -0
- package/skills/explainer-video-from-codebase/tools/lang/python.mjs +609 -0
- package/skills/explainer-video-from-codebase/tools/lang/typescript.mjs +963 -0
- package/skills/explainer-video-from-codebase/tools/lib/decompose.mjs +102 -0
- package/skills/explainer-video-from-codebase/tools/lib/groups.mjs +77 -0
- package/skills/explainer-video-from-codebase/tools/lib/observe-coverage.mjs +68 -0
- package/skills/explainer-video-from-codebase/tools/lib/observe-v8.mjs +232 -0
- package/skills/explainer-video-from-codebase/tools/lib/observe.mjs +162 -0
- package/skills/explainer-video-from-codebase/tools/lib/redact.mjs +28 -0
- package/skills/explainer-video-from-codebase/tools/lib/sandbox.mjs +97 -0
- package/skills/explainer-video-from-codebase/tools/lib/test-links.mjs +28 -0
- package/skills/explainer-video-from-codebase/tools/lib/toml.mjs +129 -0
- package/skills/explainer-video-from-codebase/tools/lib/trace-c.mjs +95 -0
- package/skills/explainer-video-from-codebase/tools/lib/trace-go.mjs +101 -0
- package/skills/explainer-video-from-codebase/tools/lib/trace-js.mjs +136 -0
- package/skills/explainer-video-from-codebase/tools/lib/tree-sitter.mjs +19 -0
- package/skills/explainer-video-from-codebase/tools/lib/walk.mjs +138 -0
- package/skills/explainer-video-from-codebase/tools/mine.mjs +409 -0
- package/skills/explainer-video-from-codebase/tools/propose.mjs +127 -0
- package/skills/explainer-video-from-codebase/tools/rank.mjs +119 -0
- package/skills/explainer-video-from-codebase/tools/ranking-md.mjs +76 -0
- package/skills/explainer-video-from-codebase/tools/rules/c-handlers.mjs +53 -0
- package/skills/explainer-video-from-codebase/tools/rules/celery-taskiq.mjs +48 -0
- package/skills/explainer-video-from-codebase/tools/rules/click-typer.mjs +6 -0
- package/skills/explainer-video-from-codebase/tools/rules/express.mjs +72 -0
- package/skills/explainer-video-from-codebase/tools/rules/fastapi.mjs +99 -0
- package/skills/explainer-video-from-codebase/tools/rules/go-http.mjs +173 -0
- package/skills/explainer-video-from-codebase/tools/rules/go-test.mjs +108 -0
- package/skills/explainer-video-from-codebase/tools/rules/grpc.mjs +57 -0
- package/skills/explainer-video-from-codebase/tools/rules/gtest.mjs +97 -0
- package/skills/explainer-video-from-codebase/tools/rules/haystack.mjs +55 -0
- package/skills/explainer-video-from-codebase/tools/rules/jest-vitest.mjs +100 -0
- package/skills/explainer-video-from-codebase/tools/rules/js-http-client.mjs +111 -0
- package/skills/explainer-video-from-codebase/tools/rules/nextjs.mjs +53 -0
- package/skills/explainer-video-from-codebase/tools/rules/process-spawn.mjs +49 -0
- package/skills/explainer-video-from-codebase/tools/rules/py-http-client.mjs +26 -0
- package/skills/explainer-video-from-codebase/tools/rules/pytest.mjs +107 -0
- package/skills/explainer-video-from-codebase/tools/rules/react-router.mjs +17 -0
- package/skills/explainer-video-from-codebase/tools/rules/registry.mjs +31 -0
- package/skills/explainer-video-from-codebase/tools/trace/c/fmtrace-sh +14 -0
- package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-net.cjs +33 -0
- package/skills/explainer-video-from-codebase/tools/trace/js/fmtrace-srcmap.cjs +41 -0
- package/skills/explainer-video-from-codebase/tools/trace/python/fmtrace_plugin.py +87 -0
- package/skills/explainer-video-from-codebase/tools/trace/python/sitecustomize.py +330 -0
- package/skills/explainer-video-from-codebase/tools/trace/sandbox/linux-bwrap.sh +73 -0
- package/skills/explainer-video-from-codebase/tools/trace/sandbox/macos-nonet.sb +41 -0
- package/skills/explainer-video-from-codebase/tools/trace.mjs +577 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-c.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-cpp.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-go.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-javascript.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-python.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/tree-sitter-typescript.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/LICENSES/web-tree-sitter.txt +21 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/MANIFEST.json +172 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-c.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-cpp.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-go.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-javascript.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-python.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-tsx.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/tree-sitter-typescript.wasm +0 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.js +4081 -0
- package/skills/explainer-video-from-codebase/vendor/tree-sitter/web-tree-sitter.wasm +0 -0
- package/skills/explainer-video-from-coursework/ARCHITECTURE.md +59 -0
- package/skills/explainer-video-from-coursework/PIPELINE.md +343 -0
- package/skills/explainer-video-from-coursework/ROADMAP.md +725 -0
- package/skills/explainer-video-from-coursework/SERIES.md +232 -0
- package/skills/explainer-video-from-coursework/SETUP.md +105 -0
- package/skills/explainer-video-from-coursework/SKILL.md +858 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-chart.js +12 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-equation.js +10 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-hubspoke.js +7 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-loopflow.js +7 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-sequence.js +12 -0
- package/skills/explainer-video-from-coursework/scenes/_pt-timeline.js +11 -0
- package/skills/explainer-video-from-coursework/src/scene-code.html +127 -0
- package/skills/explainer-video-from-coursework/src/scene-code.js +379 -0
- package/skills/explainer-video-from-coursework/src/scene-core.js +1250 -0
- package/skills/explainer-video-from-coursework/src/scene-diagram.html +50 -0
- package/skills/explainer-video-from-coursework/src/scene-svg.html +50 -0
- package/skills/explainer-video-from-coursework/src/timeline.js +297 -0
- package/skills/explainer-video-from-coursework/src/timing-chart.js +160 -0
- package/skills/explainer-video-from-coursework/tools/align.mjs +419 -0
- package/skills/explainer-video-from-coursework/tools/apply-enrichment.mjs +206 -0
- package/skills/explainer-video-from-coursework/tools/assemble.mjs +223 -0
- package/skills/explainer-video-from-coursework/tools/build-audio.mjs +234 -0
- package/skills/explainer-video-from-coursework/tools/chromium.mjs +73 -0
- package/skills/explainer-video-from-coursework/tools/debug-page.mjs +75 -0
- package/skills/explainer-video-from-coursework/tools/env.mjs +30 -0
- package/skills/explainer-video-from-coursework/tools/frame-dedup.mjs +131 -0
- package/skills/explainer-video-from-coursework/tools/key-source.mjs +151 -0
- package/skills/explainer-video-from-coursework/tools/model.mjs +21 -0
- package/skills/explainer-video-from-coursework/tools/naming.mjs +38 -0
- package/skills/explainer-video-from-coursework/tools/onset-audit.mjs +51 -0
- package/skills/explainer-video-from-coursework/tools/pacing-check.mjs +350 -0
- package/skills/explainer-video-from-coursework/tools/planner.mjs +616 -0
- package/skills/explainer-video-from-coursework/tools/publish.mjs +230 -0
- package/skills/explainer-video-from-coursework/tools/render.mjs +205 -0
- package/skills/explainer-video-from-coursework/tools/resolve-beats.mjs +239 -0
- package/skills/explainer-video-from-coursework/tools/script-check.mjs +174 -0
- package/skills/explainer-video-from-coursework/tools/stamp.mjs +71 -0
- package/skills/explainer-video-from-coursework/tools/timing-report.mjs +299 -0
- package/skills/explainer-video-from-coursework/tools/transcribe-gemini.mjs +96 -0
- package/skills/explainer-video-from-coursework/tools/tts-gemini.mjs +394 -0
- package/skills/explainer-video-from-coursework/tools/verify-audio.mjs +88 -0
- package/skills/explainer-video-from-coursework/tools/workdir.mjs +124 -0
- package/skills/explainer-video-from-coursework/vendor/plotly-basic.min.js +8 -0
- 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
|
+
})();
|