@giamme/forge 0.0.0-stage → 0.9.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 (152) hide show
  1. package/CHANGELOG.md +205 -0
  2. package/LICENSE +21 -0
  3. package/README.md +194 -2
  4. package/SKILL.md +123 -0
  5. package/VERSION +1 -0
  6. package/docs/README.md +26 -0
  7. package/docs/add-ons.md +33 -0
  8. package/docs/artifacts.md +69 -0
  9. package/docs/commands.md +315 -0
  10. package/docs/dataset.md +20 -0
  11. package/docs/decomposed-runs.md +268 -0
  12. package/docs/development.md +95 -0
  13. package/docs/forge-ui.md +92 -0
  14. package/docs/fractal.md +181 -0
  15. package/docs/install.md +160 -0
  16. package/docs/memory.md +156 -0
  17. package/docs/models.md +118 -0
  18. package/docs/reviews.md +231 -0
  19. package/docs/safety.md +55 -0
  20. package/docs/troubleshooting.md +54 -0
  21. package/forge +79 -0
  22. package/package.json +27 -4
  23. package/references/dataset.md +329 -0
  24. package/references/decompose.md +667 -0
  25. package/references/fractal-dashboard.md +21 -0
  26. package/references/fractal.md +307 -0
  27. package/references/harnesses.md +365 -0
  28. package/references/jev.md +865 -0
  29. package/references/memory.md +228 -0
  30. package/references/planning.md +42 -0
  31. package/references/ripwire.md +47 -0
  32. package/references/runtime.md +64 -0
  33. package/references/solo.md +113 -0
  34. package/references/ui.md +112 -0
  35. package/registry.tsv +81 -0
  36. package/scripts/forge-artifact.sh +78 -0
  37. package/scripts/forge-contract.py +430 -0
  38. package/scripts/forge-dataset.py +6 -0
  39. package/scripts/forge-dispatch.sh +671 -0
  40. package/scripts/forge-fail-first.py +195 -0
  41. package/scripts/forge-fractal-dispatch.sh +4 -0
  42. package/scripts/forge-fractal-options.sh +54 -0
  43. package/scripts/forge-fractal.py +6 -0
  44. package/scripts/forge-guard.py +266 -0
  45. package/scripts/forge-install-fractal.sh +4 -0
  46. package/scripts/forge-install-ripwire.sh +52 -0
  47. package/scripts/forge-install.sh +135 -0
  48. package/scripts/forge-jev-options.sh +259 -0
  49. package/scripts/forge-jev.py +6 -0
  50. package/scripts/forge-lib-plans.sh +785 -0
  51. package/scripts/forge-lib-review.sh +494 -0
  52. package/scripts/forge-lib-task.sh +302 -0
  53. package/scripts/forge-lib-verify.sh +30 -0
  54. package/scripts/forge-memory.sh +575 -0
  55. package/scripts/forge-metrics.sh +39 -0
  56. package/scripts/forge-parallel-options.sh +76 -0
  57. package/scripts/forge-parallel.sh +1717 -0
  58. package/scripts/forge-plan-check.py +118 -0
  59. package/scripts/forge-prompt.py +45 -0
  60. package/scripts/forge-ripwire.py +136 -0
  61. package/scripts/forge-route.py +344 -0
  62. package/scripts/forge-runs.py +121 -0
  63. package/scripts/forge-runtime.py +586 -0
  64. package/scripts/forge-schedule.py +312 -0
  65. package/scripts/forge-shadow.py +305 -0
  66. package/scripts/forge-solo.sh +347 -0
  67. package/scripts/forge-ui-options.sh +42 -0
  68. package/scripts/forge-ui.py +435 -0
  69. package/scripts/forge-update.sh +71 -0
  70. package/scripts/forge-verify-coverage.py +956 -0
  71. package/scripts/forge-version.py +240 -0
  72. package/scripts/forge_dataset/__init__.py +439 -0
  73. package/scripts/forge_dataset/capture.py +330 -0
  74. package/scripts/forge_dataset/checks.py +109 -0
  75. package/scripts/forge_dataset/cli.py +233 -0
  76. package/scripts/forge_dataset/export.py +1009 -0
  77. package/scripts/forge_dataset/harvest.py +716 -0
  78. package/scripts/forge_dataset/importer.py +412 -0
  79. package/scripts/forge_dataset/outcomes.py +275 -0
  80. package/scripts/forge_dataset/routing.py +278 -0
  81. package/scripts/forge_fractal/__init__.py +84 -0
  82. package/scripts/forge_fractal/bridge.py +46 -0
  83. package/scripts/forge_fractal/cli.py +202 -0
  84. package/scripts/forge_fractal/dashboard.css +138 -0
  85. package/scripts/forge_fractal/dashboard.html +41 -0
  86. package/scripts/forge_fractal/dashboard.js +687 -0
  87. package/scripts/forge_fractal/decomposition.py +262 -0
  88. package/scripts/forge_fractal/execution.py +659 -0
  89. package/scripts/forge_fractal/inspection.py +261 -0
  90. package/scripts/forge_fractal/install.py +158 -0
  91. package/scripts/forge_fractal/invocation.py +94 -0
  92. package/scripts/forge_fractal/pipeline.py +124 -0
  93. package/scripts/forge_fractal/selection.py +179 -0
  94. package/scripts/forge_jev/__init__.py +376 -0
  95. package/scripts/forge_jev/backtest.py +322 -0
  96. package/scripts/forge_jev/cli.py +980 -0
  97. package/scripts/forge_jev/client.py +418 -0
  98. package/scripts/forge_jev/corpus.py +156 -0
  99. package/scripts/forge_jev/coupling.py +244 -0
  100. package/scripts/forge_jev/features.py +24 -0
  101. package/scripts/forge_jev/gates.py +100 -0
  102. package/scripts/forge_jev/memory.py +163 -0
  103. package/scripts/forge_jev/outcome.py +49 -0
  104. package/scripts/forge_jev/predict.py +204 -0
  105. package/scripts/forge_jev/questions.py +661 -0
  106. package/scripts/forge_jev/review.py +73 -0
  107. package/scripts/forge_jev/routing.py +271 -0
  108. package/scripts/forge_jev/subset.py +123 -0
  109. package/scripts/forge_jev/verify.py +453 -0
  110. package/scripts/forge_registry.py +140 -0
  111. package/ui/README.md +347 -0
  112. package/ui/THIRD_PARTY_NOTICES.md +177 -0
  113. package/ui/docs/api.md +970 -0
  114. package/ui/forge_ui/__init__.py +20 -0
  115. package/ui/forge_ui/__main__.py +135 -0
  116. package/ui/forge_ui/diffs.py +454 -0
  117. package/ui/forge_ui/executions.py +436 -0
  118. package/ui/forge_ui/export.py +214 -0
  119. package/ui/forge_ui/findings.py +132 -0
  120. package/ui/forge_ui/fractal.py +582 -0
  121. package/ui/forge_ui/models.py +216 -0
  122. package/ui/forge_ui/reader.py +1424 -0
  123. package/ui/forge_ui/server.py +312 -0
  124. package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
  125. package/ui/forge_ui/static/app.js +1079 -0
  126. package/ui/forge_ui/static/cloud3d.js +647 -0
  127. package/ui/forge_ui/static/cloudmodel.js +375 -0
  128. package/ui/forge_ui/static/cloudui.js +397 -0
  129. package/ui/forge_ui/static/convo.js +295 -0
  130. package/ui/forge_ui/static/datatable.js +232 -0
  131. package/ui/forge_ui/static/diffview.js +235 -0
  132. package/ui/forge_ui/static/favicon.svg +1 -0
  133. package/ui/forge_ui/static/files.js +776 -0
  134. package/ui/forge_ui/static/findings3d.js +134 -0
  135. package/ui/forge_ui/static/gantt.js +97 -0
  136. package/ui/forge_ui/static/graph.js +266 -0
  137. package/ui/forge_ui/static/icons.js +94 -0
  138. package/ui/forge_ui/static/index.html +67 -0
  139. package/ui/forge_ui/static/iso.js +816 -0
  140. package/ui/forge_ui/static/leaderboard.js +311 -0
  141. package/ui/forge_ui/static/map3d.js +45 -0
  142. package/ui/forge_ui/static/modelstats.js +249 -0
  143. package/ui/forge_ui/static/notify.js +179 -0
  144. package/ui/forge_ui/static/panel.js +562 -0
  145. package/ui/forge_ui/static/runmodel.js +667 -0
  146. package/ui/forge_ui/static/scene3d.js +1471 -0
  147. package/ui/forge_ui/static/search.js +120 -0
  148. package/ui/forge_ui/static/shell.js +137 -0
  149. package/ui/forge_ui/static/stagectl.js +150 -0
  150. package/ui/forge_ui/static/style.css +1073 -0
  151. package/ui/forge_ui/static/theme.js +25 -0
  152. package/ui/forge_ui/transcripts.py +608 -0
@@ -0,0 +1,1471 @@
1
+ /* forge-ui stage: forge runs drawn with iso.js.
2
+ - atlas: every run on one plate, one district per repo, one block per run with a lamp per task;
3
+ - plan: one run's waves as districts, a block per task, and the finish line (integration, verification,
4
+ final review); opening a task shows its attempts, each with its dwarf, its review and any shadow reviews;
5
+ - exec: the same run on wall-clock time, one block per attempt in the lane it really ran in;
6
+ - cloud: every run floating in 3D, grouped by project (cloud3d.js), on the same engine and WebGL context.
7
+ The scene logic (blocks, roofs, lamps, opening, cables, beads) is archivision's assets/figure.js (MIT,
8
+ © 2026 Giamme; drawing style iso-glow, MIT © 2026 April Zhu and Tolga Cohce: see the notice in iso.js).
9
+ All text from the API is drawn on canvases or set with textContent, never parsed as HTML. */
10
+ import { createEngine } from './iso.js';
11
+ import { createCloud } from './cloud3d.js';
12
+ import { createBeacons } from './findings3d.js';
13
+ import { createControls } from './stagectl.js';
14
+
15
+ const RM = window.ForgeRunModel;
16
+ const list = v => Array.isArray(v) ? v : [];
17
+ const num = v => typeof v === 'number' && Number.isFinite(v) ? v : null;
18
+ const clamp = v => Math.max(0, Math.min(1, v));
19
+ const segT = (v, a, b) => clamp((v - a) / (b - a));
20
+ const ez = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
21
+ const back = t => t <= 0 ? 0 : t >= 1 ? 1 : 1 + 2.2 * Math.pow(t - 1, 3) + 1.2 * Math.pow(t - 1, 2);
22
+ const MODEL_PALETTE = ['#4e9af1', '#f08c3a', '#6cc070', '#c46fd8', '#e0c341', '#4fc2c0', '#e06a8c', '#9a8cff', '#a3b84a', '#d98f6b'];
23
+ const KIND_H = { dwarf: 24, qa: 13, shadow: 12 };
24
+
25
+ export async function createStage(host, cb = {}) {
26
+ const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
27
+ const E = await createEngine(host, { reduceMotion: reduce });
28
+ const { THREE, W, TS } = E;
29
+ const TMP = new THREE.Color(), TMP2 = new THREE.Color();
30
+ const tip = document.createElement('div');
31
+ tip.className = 'stage-tip'; tip.hidden = true; tip.setAttribute('role', 'tooltip'); host.append(tip);
32
+
33
+ const st = { view: null, mode: 'plan', run: null, runs: [], filter: {}, atlasOpts: {}, sel: null, hover: null, replay: null,
34
+ overlay: { height: 'none', color: 'status', critical: false, arcs: true, ghosts: true },
35
+ // what the stage draws, each switchable (the toolbar's Layers): see applyLayers
36
+ // route: a chosen task's lineage stays lit, what it waits on and what waits on it (I)
37
+ layers: { cables: true, beads: true, lamps: true, roof: true, labels: true, beacons: true, finish: true, districts: true, halos: true, route: true },
38
+ findings: null,
39
+ structure: '', execSig: '', atlasSig: '', rebuilds: 0, fade: null, plan: null, exec: null, atlas: null, crit: null, now: Date.now() / 1000 };
40
+ let world = null; // the group of the current view
41
+ let EL = new Map(), PICK = [], FLOWS = [], PROG = new Map(), DISTRICTS = [], EXEC_BY = null;
42
+ const nowSec = () => st.run ? RM.liveNow(st.run, Date.now() / 1000) : Date.now() / 1000;
43
+
44
+ /* ---------- elements: a block, a part or a lane item, each with its own materials ---------- */
45
+ function newEl(id, kind, g, n, data) {
46
+ const el = { id, kind, g, n, data, status: 'PENDING', col: E.statusColor('pending'), root: new THREE.Group(), cover: new THREE.Group(),
47
+ M: E.mats(), cur: { vis: 1, sel: 0, hov: 0, lit: 0, op: 1, opB: 1, tint: 0, halo: 0, hk: 1, route: 0 }, tgt: { vis: 1, sel: 0, hov: 0, lit: 0, op: 1, opB: 1, tint: 0, halo: 0, hk: 1, route: 0 },
48
+ texts: [], sig: '', pulse: false, offset: new THREE.Vector3(), fadeK: 1 };
49
+ el.root.add(el.cover); g.add(el.root); EL.set(id, el);
50
+ return el;
51
+ }
52
+ function body(el, { r = 9, z = 0, ud = {} } = {}) {
53
+ const { x, y, w, d, h } = el.n;
54
+ const m = E.solid(x, y, z, w, d, h, r, el.M, el.cover, { el: el.id, cover: true, ...ud }); PICK.push(m); el.body = m;
55
+ el.tintMat = new THREE.MeshBasicMaterial({ color: el.col.clone(), toneMapped: false, transparent: true, opacity: 0, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -2, polygonOffsetUnits: -4 });
56
+ E.roofPlate(x + 1, y + 1, z + h + .08, w - 2, d - 2, r - 1, el.tintMat, el.cover);
57
+ el.haloMat = E.softMat(0xffffff, true);
58
+ const hm = E.noRay(new THREE.Mesh(new THREE.PlaneGeometry((w + 80) * TS, (d + 80) * TS), el.haloMat));
59
+ hm.rotation.x = -Math.PI / 2; hm.position.copy(W(x + w / 2, y + d / 2, z + .5)); el.root.add(hm); el.haloMesh = hm;
60
+ return m;
61
+ }
62
+ // a row of status lamps on a roof: one geometry, a colour per lamp
63
+ function leds(el, n, x0, y0, z, { step = 10, s = 3, perRow = 20 } = {}) {
64
+ if (!n) return;
65
+ const pos = [], col = [];
66
+ for (let i = 0; i < n; i++) {
67
+ const lx = x0 + (i % perRow) * step, ly = y0 - Math.floor(i / perRow) * step;
68
+ for (const [a, b] of [[-s, -s], [-s, s], [s, s], [-s, -s], [s, s], [s, -s]]) { pos.push(...W(lx + a, ly + b, z).toArray()); col.push(.5, .5, .5); }
69
+ }
70
+ const geo = new THREE.BufferGeometry();
71
+ geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
72
+ geo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
73
+ const mat = new THREE.MeshBasicMaterial({ vertexColors: true, toneMapped: false, transparent: true, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -12 });
74
+ const mesh = E.noRay(new THREE.Mesh(geo, mat)); el.cover.add(mesh); el.M.labs.push(mat);
75
+ el.leds = { geo, n, mesh };
76
+ }
77
+ function setLeds(el, statuses) {
78
+ if (!el.leds) return;
79
+ const a = el.leds.geo.attributes.color;
80
+ for (let i = 0; i < el.leds.n; i++) {
81
+ const s = statuses[i];
82
+ const c = !s ? E.DETAIL : s.isColor ? s : E.statusColor(s); // a state, or a colour of its own (a severity)
83
+ for (let k = 0; k < 6; k++) a.setXYZ(i * 6 + k, c.r, c.g, c.b);
84
+ }
85
+ a.needsUpdate = true;
86
+ }
87
+ function setStatus(el, status, col) {
88
+ el.status = status || 'PENDING';
89
+ el.col = col || E.statusColor(el.status);
90
+ el.pulse = el.status === 'RUNNING';
91
+ }
92
+ const PLAIN = new Set(['PENDING', 'NONE', 'UNKNOWN']);
93
+ function baseTargets(el) {
94
+ const T = el.tgt, s = el.status, light = E.theme === 'light';
95
+ T.lit = PLAIN.has(s) ? 0 : .32;
96
+ T.tint = PLAIN.has(s) ? 0 : light ? .34 : .1; // a pale panel shows a lit section by its colour: no glow to carry it
97
+ T.vis = 1; T.op = 1; T.opB = 1;
98
+ if (s === 'PENDING' || s === 'NONE') { T.opB = .55; T.op = .8; }
99
+ // parts are small: their status has to read from the colour of the whole piece
100
+ if (el.kind === 'part' && !PLAIN.has(s)) { T.tint = light ? .5 : .32; T.lit = .55; }
101
+ }
102
+ // a label plane on the roof or the front, redrawn only when what it shows changes
103
+ function label(el, w, h, P, draw, where, at) {
104
+ const t = E.tplane(w, h, P, (x, s, C) => draw(x, s, C, el), where === 'root' ? el.root : el.cover, el.M);
105
+ if (at.front) E.frontAt(t, at.x, at.y, at.z); else E.roofAt(t, at.x, at.y, at.z);
106
+ el.texts.push(t); return t;
107
+ }
108
+ const redraw = el => { for (const t of el.texts) if (!t.gone) t.redraw(); };
109
+ function chip(x, s, C, X, Y, text, colHex, { fill = true, f = 6.5 } = {}) {
110
+ E.font(x, f, s, 600);
111
+ const w = text.length * f * E.CW + 6;
112
+ E.rr(x, s, X, Y, w, f * 1.6, f * .5);
113
+ if (fill) { x.fillStyle = colHex; x.globalAlpha = C.light ? .16 : .22; x.fill(); x.globalAlpha = 1; }
114
+ x.strokeStyle = colHex; x.lineWidth = Math.max(1, .35 * s); x.stroke();
115
+ x.fillStyle = colHex; x.textBaseline = 'middle'; x.fillText(text, (X + 3) * s, (Y + f * .82) * s); x.textBaseline = 'alphabetic';
116
+ return w;
117
+ }
118
+ function display(el, text, { w, x, y, z }) {
119
+ return el.disp = label(el, w, 16, 4, (cx, s, C) => {
120
+ cx.fillStyle = C.glassOff; cx.strokeStyle = el.cur.sel > .5 ? C.acc : C.detail; cx.lineWidth = Math.max(1, .5 * s);
121
+ E.rr(cx, s, .5, .5, w - 1, 15, 3); cx.fill(); cx.stroke();
122
+ cx.fillStyle = E.statusHex(el.status); cx.beginPath(); cx.arc(7 * s, 8 * s, 2.6 * s, 0, 7); cx.fill();
123
+ const f = E.fit(text(el), w - 18, 12, 8.5, 1, 4.5);
124
+ if (f) { E.font(cx, f.f, s); cx.fillStyle = C.ink; cx.textBaseline = 'middle'; cx.fillText(f.L[0], 13 * s, 8.3 * s, (w - 18) * s); cx.textBaseline = 'alphabetic'; }
125
+ }, 'root', { front: true, x, y, z });
126
+ }
127
+ function halo(el) { el.haloMat.uniforms.color.value.copy(el.col); }
128
+
129
+ /* ---------- the plate and its districts ---------- */
130
+ function plateFor(g, px, py, districts, names) {
131
+ const plateG = new THREE.Group(); g.add(plateG);
132
+ const p = E.rbox(-34, -34, -16, px + 68, py + 68, 16, 26, E.bodyMat, plateG); p.userData = { plate: true }; PICK.push(p);
133
+ E.loopAt(-22, -22, px + 44, py + 44, 18, .06, E.lineMat, plateG, 12);
134
+ for (const dd of districts) {
135
+ E.inset(dd.x, dd.y, 0, dd.w, dd.d, 16, plateG);
136
+ const name = String(names(dd) || '').toUpperCase(); if (!name) continue;
137
+ const f = Math.max(9, Math.min(26, (dd.w - 60) / (name.length * (E.CW + .14))));
138
+ const tw = Math.min(dd.w - 30, name.length * f * (E.CW + .14) + 6);
139
+ const t = E.tplane(tw, f * 1.4, 4, (x, s, C) => {
140
+ E.font(x, f, s); x.letterSpacing = `${(f * .14 * s).toFixed(1)}px`; x.fillStyle = dd.warn ? E.statusHex('fail') : C.ink;
141
+ x.fillText(name, 0, f * 1.05 * s, tw * s); x.letterSpacing = '0px';
142
+ }, plateG);
143
+ E.roofAt(t, dd.x + 18, dd.y + 12, 0); DISTRICTS.push(t);
144
+ }
145
+ return plateG;
146
+ }
147
+
148
+ /* ---------- cables between blocks ---------- */
149
+ const flowMatFaint = () => E.lmat({ polygonOffsetFactor: -3, polygonOffsetUnits: -8 });
150
+ function cable(g, a, b, { kind = 'dep', planes = null } = {}) {
151
+ const A = [a.x + a.w / 2, a.y + a.d / 2, a.h + 2], B = [b.x + b.w / 2, b.y + b.d / 2, b.h + 2];
152
+ const [ea, eb] = E.ends(a, b, a.h + 2, b.h + 2);
153
+ const pts = E.arc(Math.hypot(A[0] - B[0], A[1] - B[1]) < 400 ? ea : A, Math.hypot(A[0] - B[0], A[1] - B[1]) < 400 ? eb : B, 14, .18, 120);
154
+ const mat = flowMatFaint(), line = E.polyLine(pts, mat, g);
155
+ const am = new THREE.MeshBasicMaterial({ color: 0xffffff, toneMapped: false, transparent: true });
156
+ const cn = E.cone(pts, 5, am, g);
157
+ const f = { kind, line, mat, cone: cn, am, pts, cum: E.cumLen(pts), cur: 0, hot: 0, crit: 0, vis: 1, planes, from: null, to: null };
158
+ FLOWS.push(f); return f;
159
+ }
160
+
161
+ /* ---------- atlas: every run ---------- */
162
+ function runInfo(r) {
163
+ const c = r.counts || {}, total = r.task_count || Object.values(c).reduce((a, n) => a + n, 0);
164
+ const merged = (c.MERGED || 0) + (c.PASS || 0), running = c.RUNNING || 0;
165
+ const bad = Object.entries(c).filter(([s]) => ['FAIL', 'ERROR', 'CONFLICT', 'INFRA', 'TIMEOUT', 'INVALIDATED', 'INTERRUPTED', 'BLOCKED'].includes(s)).reduce((a, [, n]) => a + n, 0);
166
+ const parts = [r.kind === 'solo' ? (Object.keys(c)[0] || 'solo') : `${merged}/${total} done`];
167
+ if (running) parts.push(`${running} running`);
168
+ if (bad) parts.push(`${bad} failed`);
169
+ if (r.final_review) parts.push(`review ${r.final_review}`);
170
+ else if (r.verification_status) parts.push(`verify ${r.verification_status}`);
171
+ return parts.join(' · ');
172
+ }
173
+ const STATE_STATUS = { live: 'RUNNING', failed: 'FAIL', done: 'MERGED', pending: 'PENDING' };
174
+ function buildAtlas() {
175
+ const A = RM.atlasModel(st.runs, st.filter, { now: nowSec(), ...st.atlasOpts });
176
+ st.atlas = A;
177
+ const g = new THREE.Group(); world.add(g);
178
+ const names = new Map(A.layers.map(l => [l.id, l.name]));
179
+ plateFor(g, A.px, A.py, A.districts, d => names.get(d.layer));
180
+ for (const m of A.modules) {
181
+ const p = A.place[m.id]; if (!p) continue;
182
+ const [x, y, w, d, h] = p;
183
+ const el = newEl('r:' + m.id, 'run', g, { x, y, w, d, h }, { run: m.run, module: m });
184
+ setStatus(el, STATE_STATUS[m.state]);
185
+ body(el);
186
+ label(el, w - 24, 48, 4, (cx, s, C, e) => {
187
+ const r = e.data.run, sel = e.cur.sel > .5;
188
+ const nm = E.fit(String(r.goal || 'Untitled run').split('\n')[0], w - 30, 32, 17, 2, 6);
189
+ if (nm) E.lines(cx, s, nm, 2, 2, sel ? C.accHi : C.inkHi, w - 30, 500);
190
+ E.font(cx, 7, s); cx.fillStyle = C.ink;
191
+ cx.fillText(E.ellipsis(`${RM.basename(r.repo)} · ${r.run_id || ''}${r.kind === 'solo' ? ' · solo' : ''}`, Math.floor((w - 30) / (7 * E.CW))), 2 * s, 44 * s);
192
+ }, 'cover', { x: x + 12, y: y + 8, z: h });
193
+ leds(el, Math.min(60, m.tasks.length), x + 17, y + d - 14, h + .16, { step: 8, s: 2.4, perRow: Math.max(4, Math.floor((w - 30) / 8)) });
194
+ setLeds(el, m.tasks.map(t => t.status));
195
+ display(el, e => runInfo(e.data.run), { w: w - 24, x: x + 12, y: y + d, z: h - 8 });
196
+ halo(el);
197
+ }
198
+ st.atlasBox = [-34, -34, -16, A.px + 34, A.py + 34, 80];
199
+ E.frameBox(st.atlasBox, st.atlasFramed ? 700 : 1400, 30);
200
+ st.atlasFramed = true;
201
+ }
202
+ function updateAtlas() {
203
+ const A = RM.atlasModel(st.runs, st.filter, { now: nowSec(), ...st.atlasOpts });
204
+ st.atlas = A;
205
+ for (const m of A.modules) {
206
+ const el = EL.get('r:' + m.id); if (!el) continue;
207
+ el.data.run = m.run; el.data.module = m;
208
+ setStatus(el, STATE_STATUS[m.state]); halo(el);
209
+ setLeds(el, m.tasks.map(t => t.status));
210
+ const sig = JSON.stringify([m.run.goal, m.run.counts, m.run.final_review, m.run.verification_status]);
211
+ if (sig !== el.sig) { el.sig = sig; redraw(el); }
212
+ }
213
+ retarget();
214
+ }
215
+
216
+ /* ---------- plan: waves, tasks, the finish line ---------- */
217
+ function taskStatus(t) {
218
+ if (st.replay !== null && st.replayState) { const s = st.replayState.tasks[t.id]; return s ? s.status : 'PENDING'; }
219
+ return t.status || 'PENDING';
220
+ }
221
+ function taskLine(el) {
222
+ const t = el.data.task, s = el.status;
223
+ const bits = [s];
224
+ if (num(t.attempt) && t.attempt > 1) bits.push('attempt ' + t.attempt);
225
+ if (s === 'RUNNING') {
226
+ const live = list(t.executions).filter(e => e.state === 'running' && (e.role === 'dwarf' || e.role === 'qa')).pop();
227
+ if (live) bits.push(`${live.role === 'qa' ? 'QA' : 'dwarf'} · ${live.phase || ''} ${RM.fmtDur(nowSec() - (num(live.started) || nowSec()))}`.replace(/\s+/g, ' '));
228
+ } else {
229
+ const tt = RM.taskTimes(t, null, nowSec());
230
+ if (tt.busy) bits.push(RM.fmtDur(tt.busy));
231
+ }
232
+ return bits.join(' · ');
233
+ }
234
+ function buildTask(m, p, g) {
235
+ const [x, y, w, d, h] = p, t = m.task;
236
+ const el = newEl('t:' + t.id, 'task', g, { x, y, w, d, h }, { task: t });
237
+ setStatus(el, taskStatus(t));
238
+ body(el);
239
+ el.roof = label(el, w - 24, d - 26, 4, (cx, s, C, e) => {
240
+ const tk = e.data.task, sel = e.cur.sel > .5;
241
+ E.font(cx, 21, s, 600); cx.fillStyle = sel ? C.accHi : C.inkHi; cx.fillText(E.ellipsis(tk.id, Math.floor((w - 30) / (21 * E.CW))), 2 * s, 19 * s);
242
+ const tl = E.fit(tk.title || '', w - 30, 24, 10, 2, 5);
243
+ if (tl) E.lines(cx, s, tl, 2, 25, C.ink, w - 30, 400);
244
+ E.font(cx, 9, s, 500); cx.fillStyle = C.ink;
245
+ cx.fillText(E.ellipsis(`${tk.dwarf || '—'}${tk.explored ? ' *' : ''} → ${tk.qa || '—'}`, Math.floor((w - 30) / (9 * E.CW))), 2 * s, 57 * s);
246
+ let bx = 2;
247
+ const f = tk.findings;
248
+ if (f && (f.confirmed || f.plausible)) {
249
+ for (const sev of ['P0', 'P1', 'P2', 'P3']) if (f[sev]) bx += chip(cx, s, C, bx, 64, `${sev}×${f[sev]}`, E.sevHex(sev)) + 3;
250
+ if (f.unlabelled) bx += chip(cx, s, C, bx, 64, `?×${f.unlabelled}`, E.statusHex('unknown')) + 3;
251
+ }
252
+ if (tk.gate && tk.gate.decision === 'accept') bx += chip(cx, s, C, bx, 64, 'gate ok', E.statusHex('pass')) + 3;
253
+ if (st.overlay.arcs && list(tk.shadow).some(x => x.agreement === 'disagree')) bx += chip(cx, s, C, bx, 64, 'shadow ≠', E.statusHex('conflict')) + 3;
254
+ if (tk.accepted_by) chip(cx, s, C, bx, 64, 'accepted', E.statusHex('nochanges'));
255
+ }, 'cover', { x: x + 12, y: y + 8, z: h });
256
+ const tries = Math.max(1, list(t.attempts).length);
257
+ leds(el, Math.min(20, tries), x + 17, y + d - 13, h + .16, { step: 9, s: 2.8 });
258
+ el.ledCount = tries;
259
+ display(el, taskLine, { w: w - 24, x: x + 12, y: y + d, z: h - 8 });
260
+ // the Clef ring: how likely the first try was to pass, in the colour of what happened
261
+ const p0 = t.clef && t.clef.first_pass && num(t.clef.first_pass.p);
262
+ if (p0 !== null) {
263
+ const cx0 = x + w - 22, cy0 = y + 22, rIn = 8, rOut = 11.5;
264
+ const bgM = new THREE.MeshBasicMaterial({ color: E.DETAIL.clone(), toneMapped: false, transparent: true, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -12 });
265
+ const fgM = new THREE.MeshBasicMaterial({ color: 0xffffff, toneMapped: false, transparent: true, polygonOffset: true, polygonOffsetFactor: -5, polygonOffsetUnits: -14 });
266
+ for (const [m, len] of [[bgM, Math.PI * 2], [fgM, Math.max(.05, Math.min(1, p0)) * Math.PI * 2]]) {
267
+ const ring = E.noRay(new THREE.Mesh(new THREE.RingGeometry(rIn * TS, rOut * TS, 40, 1, Math.PI / 2, len), m));
268
+ ring.rotation.x = -Math.PI / 2; ring.position.copy(W(cx0, cy0, h + .2)); el.cover.add(ring); el.M.labs.push(m);
269
+ }
270
+ el.clef = { fgM, p: p0 };
271
+ }
272
+ halo(el);
273
+ return el;
274
+ }
275
+ function finishInfo(id) {
276
+ const F = st.plan && st.plan.finish, run = st.run; if (!F) return {};
277
+ if (id === 'f:integration') return { title: 'INTEGRATION', sub: `${F.integration.merged}/${F.integration.total} merged`, status: F.integration.status, line: F.integration.accepted ? 'at ' + String(F.integration.accepted).slice(0, 10) : 'into forge/' + (run.run_id || '…') + '-integration' };
278
+ if (id === 'f:verification') return { title: 'VERIFICATION', sub: F.verification.command || 'no command recorded', status: F.verification.status, line: F.verification.runs ? `${F.verification.runs} run${F.verification.runs > 1 ? 's' : ''}` : 'not run yet' };
279
+ const f = F.final.findings;
280
+ return { title: 'FINAL REVIEW', sub: F.final.requested ? (F.final.spec || 'reviewer not chosen') : 'not requested', status: F.final.status, findings: f,
281
+ line: F.final.status === 'NONE' ? 'opt-in: forge review <plan>' : F.final.count > 1 ? `${F.final.count} reviews` : F.final.status };
282
+ }
283
+ function buildFinish(m, p, g) {
284
+ const [x, y, w, d, h] = p;
285
+ const el = newEl(m.id, 'finish', g, { x, y, w, d, h }, { name: m.name });
286
+ const info = finishInfo(m.id);
287
+ setStatus(el, finishStatus(m.id, info));
288
+ body(el, { r: 7 });
289
+ el.roof = label(el, w - 24, d - 28, 4, (cx, s, C, e) => {
290
+ const fi = finishInfo(e.id), sel = e.cur.sel > .5;
291
+ if (fi.status === 'NONE') { cx.setLineDash([2.4 * s, 1.8 * s]); cx.strokeStyle = C.line; cx.lineWidth = Math.max(1, .3 * s); E.rr(cx, s, .5, .5, w - 25, d - 29, 5); cx.stroke(); cx.setLineDash([]); }
292
+ E.font(cx, 15, s, 600); cx.fillStyle = sel ? C.accHi : C.inkHi; cx.fillText(fi.title || '', 4 * s, 17 * s);
293
+ const sub = E.fit(fi.sub || '', w - 34, 22, 9.5, 2, 5); if (sub) E.lines(cx, s, sub, 4, 23, C.ink, w - 34, 400);
294
+ if (fi.findings) { let bx = 4; for (const sev of ['P0', 'P1', 'P2', 'P3']) if (fi.findings[sev]) bx += chip(cx, s, C, bx, 46, `${sev}×${fi.findings[sev]}`, E.sevHex(sev)) + 3;
295
+ if (fi.findings.plausible) chip(cx, s, C, bx, 46, `~${fi.findings.plausible}`, E.statusHex('pending'), { fill: false }); }
296
+ }, 'cover', { x: x + 12, y: y + 8, z: h });
297
+ if (m.id === 'f:final') { leds(el, 4, x + 18, y + d - 13, h + .16, { step: 11, s: 3 }); }
298
+ else if (m.id === 'f:integration') leds(el, Math.min(40, list(st.run.tasks).length), x + 17, y + d - 13, h + .16, { step: 8, s: 2.4, perRow: 22 });
299
+ else leds(el, Math.max(1, Math.min(12, list(st.run.verification && st.run.verification.runs).length)), x + 17, y + d - 13, h + .16, { step: 9, s: 2.8 });
300
+ finishLeds(el);
301
+ display(el, e => finishInfo(e.id).line || '', { w: w - 24, x: x + 12, y: y + d, z: h - 8 });
302
+ halo(el);
303
+ return el;
304
+ }
305
+ function finishStatus(id, info) {
306
+ if (st.replay !== null && st.replayState) return id === 'f:final' ? (st.run.final_review || st.run.settings && st.run.settings.final_qa ? st.replayState.final : 'NONE') : id === 'f:verification' ? st.replayState.verification : st.replayState.integration;
307
+ return info.status || 'PENDING';
308
+ }
309
+ function finishLeds(el) {
310
+ if (el.id === 'f:final') { const f = finishInfo(el.id).findings || {}; setLeds(el, ['P0', 'P1', 'P2', 'P3'].map(s => f[s] ? E.sevColor(s) : null)); }
311
+ else if (el.id === 'f:integration') setLeds(el, list(st.run.tasks).map(t => taskStatus(t) === 'MERGED' ? 'MERGED' : null));
312
+ else setLeds(el, list(st.run.verification && st.run.verification.runs).map(r => r.exit === 0 ? 'PASS' : 'FAIL'));
313
+ }
314
+ function buildPlan(g) {
315
+ const P = RM.planModel(st.run); st.plan = P;
316
+ const names = new Map(P.layers.map(l => [l.id, l.name]));
317
+ P.plateG = plateFor(g, P.px, P.py, P.districts, d => names.get(d.layer));
318
+ for (const m of P.modules) {
319
+ const p = P.place[m.id]; if (!p) continue;
320
+ if (m.kind === 'task') buildTask(m, p, g); else buildFinish(m, p, g);
321
+ }
322
+ for (const f of P.flows) {
323
+ const a = EL.get(f.from), b = EL.get(f.to); if (!a || !b) continue;
324
+ const c = cable(g, a.n, b.n, { kind: f.kind }); c.from = f.from; c.to = f.to; c.g = 'plan';
325
+ }
326
+ for (const el of EL.values()) if (el.g === g && el.kind === 'task') PROG.set(el.id, { p: 0, t: 0 });
327
+ }
328
+
329
+ /* ---------- inside a task: attempts, dwarves, reviews ---------- */
330
+ function buildInner(el) {
331
+ const task = el.data.task, tp = RM.taskParts(task), { x, y, w, d } = el.n;
332
+ const G = el.inner = new THREE.Group(); G.visible = false; el.root.add(G);
333
+ el.innerKey = RM.innerKey(task); el.kids = [];
334
+ el.floorMat = E.recessMat.clone();
335
+ { const sh = new THREE.Shape(); E.roundRect(sh, x, -(y + d), w, d, 9);
336
+ const fg = new THREE.ShapeGeometry(sh, 8); fg.rotateX(-Math.PI / 2); fg.translate(0, .2, 0); fg.scale(TS, TS, TS);
337
+ const floor = new THREE.Mesh(fg, el.floorMat); floor.userData = { el: el.id, floor: true }; G.add(floor); PICK.push(floor); }
338
+ el.innerLine = E.lmat(); E.loopAt(x, y, w, d, 9, .26, el.innerLine, G);
339
+ const C = Math.max(1, tp.zones.length), R = Math.max(1, ...tp.zones.map(z => z.parts.length));
340
+ const padX = 10, padT = 22, padB = 10, gx = 8, gy = 7;
341
+ const cw = (w - 2 * padX - (C - 1) * gx) / C, ch = Math.min(46, (d - padT - padB - (R - 1) * gy) / R);
342
+ el.zoneEls = [];
343
+ tp.zones.forEach((z, ci) => {
344
+ const zx = x + padX + ci * (cw + gx), zy = y + padT;
345
+ const zm = E.lmat({ dashed: true, dashSize: 4 * TS, gapSize: 3 * TS });
346
+ E.loopAt(zx - 3, zy - 13, cw + 6, R * ch + (R - 1) * gy + 16, 5, .32, zm, G, 6).computeLineDistances();
347
+ const nm = z.name, f = Math.min(6.5, cw / (nm.length * (E.CW + .12) + 2));
348
+ const t = E.tplane(Math.min(cw, nm.length * f * (E.CW + .12) + 4), f * 1.5, E.pxIn(w, d), (cx, s, Cc) => {
349
+ E.font(cx, f, s); cx.letterSpacing = `${(f * .12 * s).toFixed(2)}px`; cx.fillStyle = Cc.ink; cx.fillText(nm, 0, f * 1.1 * s); cx.letterSpacing = '0px';
350
+ }, G);
351
+ E.roofAt(t, zx, zy - 11, .3);
352
+ el.zoneEls.push({ mat: zm, lab: t });
353
+ z.parts.forEach((pid, ri) => {
354
+ const part = tp.parts.find(p => p.id === pid);
355
+ const ph = KIND_H[part.kind] || 14;
356
+ const n = { x: zx, y: zy + ri * (ch + gy), w: cw, d: ch, h: ph };
357
+ const k = newEl('p:' + task.id + ':' + part.id, 'part', G, n, { part, task: task.id, parent: el.id, idx: ri + ci * R });
358
+ el.root.remove(k.root); G.add(k.root);
359
+ setStatus(k, part.planned ? 'PENDING' : part.status);
360
+ const r = part.kind === 'qa' ? Math.min(cw, ch) * .3 : Math.min(4, Math.min(cw, ch) * .07);
361
+ body(k, { r });
362
+ k.roof = label(k, cw, ch, E.pxIn(w, d), (cx, s, Cc, e) => {
363
+ const pt = e.data.part, sel = e.cur.sel > .5, pad = Math.max(2, Math.min(cw, ch) * .08);
364
+ if (pt.kind === 'shadow' || pt.planned) { cx.setLineDash([2 * s, 1.5 * s]); cx.strokeStyle = sel ? Cc.acc : Cc.line; cx.lineWidth = Math.max(1, .3 * s); E.rr(cx, s, 1.2, 1.2, cw - 2.4, ch - 2.4, 2); cx.stroke(); cx.setLineDash([]); }
365
+ const f1 = Math.min(7, ch * .2, cw / (pt.label.length * E.CW + 3));
366
+ E.font(cx, f1, s, 600); cx.fillStyle = sel ? Cc.accHi : Cc.inkHi; cx.fillText(pt.label, pad * s, (pad + f1) * s);
367
+ const sp = E.fit(pt.spec || '—', cw - 2 * pad, ch - f1 * 1.6 - pad * 2, 6, 2, 2.5);
368
+ if (sp) E.lines(cx, s, sp, pad, pad + f1 * 1.35, Cc.ink, cw - 2 * pad, 400);
369
+ const fs = pt.exec && pt.exec.findings || pt.shadow && pt.shadow.findings;
370
+ if (fs && fs.worst) chip(cx, s, Cc, pad, ch - pad - 6 * 1.6, `${fs.worst}×${fs[fs.worst]}`, E.sevHex(fs.worst), { f: Math.min(5, ch * .14) });
371
+ }, 'cover', { x: zx, y: n.y, z: ph });
372
+ el.kids.push(k);
373
+ });
374
+ });
375
+ el.innerFlows = [];
376
+ for (const l of tp.links) {
377
+ const a = EL.get('p:' + task.id + ':' + l.from), b = EL.get('p:' + task.id + ':' + l.to); if (!a || !b) continue;
378
+ const [A, B] = E.ends(a.n, b.n, a.n.h, b.n.h);
379
+ const pts = E.curve(A, B, l.kind === 'flow' ? 3 : 6, .14);
380
+ const mat = l.kind === 'escalation' ? E.lmat({ polygonOffsetFactor: -3, polygonOffsetUnits: -8 }) : E.lmat({ polygonOffsetFactor: -3, polygonOffsetUnits: -8, ...(l.kind === 'retry' || l.kind === 'shadow' ? { dashed: true, dashSize: 2.5 * TS, gapSize: 2 * TS } : {}) });
381
+ const line = E.polyLine(pts, mat, G); if (l.kind === 'retry' || l.kind === 'shadow') line.computeLineDistances();
382
+ const am = new THREE.MeshBasicMaterial({ color: 0xffffff, toneMapped: false, transparent: true });
383
+ const cn = E.cone(pts, 1.6, am, G);
384
+ const col = l.kind === 'escalation' ? E.accent : l.kind === 'shadow' ? E.statusColor(l.agree === 'disagree' ? 'conflict' : l.agree === 'agree' ? 'pass' : 'unknown') : null;
385
+ const f = { kind: l.kind, line, mat, cone: cn, am, pts, cum: E.cumLen(pts), col, a: a.id, b: b.id };
386
+ if (l.label) {
387
+ const nm = l.label, fz = 4.2, tw = nm.length * fz * E.CW + 4;
388
+ const mid = pts[Math.floor(pts.length / 2)];
389
+ f.lab = E.tplane(tw, fz * 1.6, E.pxIn(w, d), (cx, s, Cc) => { E.font(cx, fz, s, 600); cx.fillStyle = Cc.accHi; cx.fillText(nm, 2 * s, fz * 1.15 * s); }, G);
390
+ E.roofAt(f.lab, mid[0] - tw / 2, mid[1] - fz, mid[2] + 1);
391
+ }
392
+ el.innerFlows.push(f);
393
+ }
394
+ syncFractal(el);
395
+ applyLayers();
396
+ }
397
+ /* ---------- Fractal: an opened task's child tasks, a small tree floating over it ---------- */
398
+ const FRACTAL = new Map(); // task id → TaskFractal
399
+ const FR_H = { low: 12, medium: 18, high: 26 };
400
+ function syncFractal(el) {
401
+ if (!el || !el.inner || el.kind !== 'task') return;
402
+ const tid = el.data.task.id, data = FRACTAL.get(tid), nodes = data && data.available ? list(data.nodes) : [];
403
+ const sig = JSON.stringify([E.theme, nodes.map(n => [n.id, n.parent, n.ui_status, n.difficulty, n.model, n.reserved ? 1 : 0])]);
404
+ if (sig === el.fractalSig) return;
405
+ el.fractalSig = sig;
406
+ if (el.fractalG) { PICK = PICK.filter(o => !(o.userData.fractal && o.userData.fractal.task === tid)); E.disposeGroup(el.fractalG); el.fractalG = null; }
407
+ if (!nodes.length) return;
408
+ const T = RM.fractalTree(nodes), byId = new Map(nodes.map(n => [n.id, n])), { x, y, w, d, h } = el.n;
409
+ const S = 34, DX = 62, DY = 44, tw = S + (T.cols - 1) * DX, td = S + Math.max(0, T.rows - 1) * DY;
410
+ const x0 = x + w / 2 - tw / 2, y0 = y + d / 2 - td / 2, z0 = h + 70;
411
+ const G = el.fractalG = new THREE.Group(); el.inner.add(G);
412
+ const at = id => { const p = T.pos[id], n = byId.get(id); return { x: x0 + p.col * DX, y: y0 + p.row * DY, w: S, d: S, h: FR_H[String(n.difficulty || '').toLowerCase()] || 16 }; };
413
+ const cable = E.lmat({ polygonOffsetFactor: -3, polygonOffsetUnits: -8 }); cable.color.copy(E.majorMat.color).lerp(E.accent, .45);
414
+ for (const [pa, ch] of T.edges) { const A = at(pa), B = at(ch), [P, Q] = E.ends(A, B, z0 + A.h / 2, z0 + B.h / 2); E.polyLine(E.curve(P, Q, 3, .1), cable, G); }
415
+ // a stem from the task's floor to each root
416
+ const stem = E.lmat({ dashed: true, dashSize: 4 * TS, gapSize: 3 * TS }); stem.color.copy(cable.color);
417
+ for (const n of nodes) if (T.pos[n.id] && T.pos[n.id].depth === 0) { const A = at(n.id); E.polyLine([[x + w / 2, y + d / 2, 1], [A.x + S / 2, A.y + S / 2, z0]], stem, G).computeLineDistances(); }
418
+ for (const n of nodes) {
419
+ const b = at(n.id), M = E.mats(), col = E.statusColor(n.ui_status || 'unknown');
420
+ const m = E.solid(b.x, b.y, z0, b.w, b.d, b.h, 6, M, G, { fractal: { task: tid, node: n.id } });
421
+ E.paint(M, n.reserved ? .6 : 1, .6, 0, n.reserved ? .55 : 1, n.reserved ? .45 : 1, col, .3);
422
+ PICK.push(m);
423
+ const lab = E.tplane(S - 4, 13, E.pxIn(w, d), (cx, sc, C) => {
424
+ E.font(cx, 4.6, sc, 600); cx.fillStyle = C.inkHi; cx.fillText(E.ellipsis(n.id, 12), 1 * sc, 5.4 * sc);
425
+ E.font(cx, 3.6, sc); cx.fillStyle = C.ink; cx.fillText(E.ellipsis(n.reserved ? 'reserved' : n.model || n.status || '', 14), 1 * sc, 11 * sc);
426
+ }, G, M);
427
+ E.roofAt(lab, b.x + 2, b.y + 2, z0 + b.h);
428
+ }
429
+ }
430
+ E.themeHooks.add(() => { for (const el of EL.values()) if (el.fractalG) { el.fractalSig = ''; syncFractal(el); } });
431
+ function closeInner(el) {
432
+ if (!el.inner) return;
433
+ if (el.kind === 'task') { const tid = el.data.task.id; PICK = PICK.filter(o => !(o.userData.fractal && o.userData.fractal.task === tid)); el.fractalG = null; el.fractalSig = ''; }
434
+ for (const k of el.kids || []) { EL.delete(k.id); PICK = PICK.filter(o => o.userData.el !== k.id); }
435
+ PICK = PICK.filter(o => !(o.userData.floor && o.userData.el === el.id));
436
+ E.disposeGroup(el.inner); el.inner = null; el.kids = []; el.innerFlows = [];
437
+ }
438
+
439
+ /* ---------- exec: attempts on wall-clock time ---------- */
440
+ function execSig(X) { return JSON.stringify([X.items.map(i => [i.id, i.lane, i.ghost ? 1 : 0, i.segs.length]), X.extra.map(e => e.id), X.lanes, X.t0, X.t1]); }
441
+ function buildExec(g) {
442
+ const X = RM.execModel(st.run, { now: nowSec(), ghosts: st.overlay.ghosts }); st.exec = X;
443
+ st.execSig = execSig(X);
444
+ const dists = [];
445
+ for (let i = 0; i < X.lanes; i++) dists.push({ layer: 'lane' + i, x: 6, y: 60 + i * X.laneD + 4, w: X.px - 12, d: X.laneD - 8, warn: i >= X.cap, name: `LANE ${i + 1}${i >= X.cap ? ' · over --max-parallel ' + X.cap : ''}` });
446
+ for (const fl of X.finishLanes || []) dists.push({ layer: fl.kind, x: 6, y: fl.y + 4, w: X.px - 12, d: X.laneD - 8, name: fl.name });
447
+ X.plateG = plateFor(g, X.px, X.py, dists, d => d.name);
448
+ // the time ruler along the back edge
449
+ const tk = [];
450
+ for (const r of X.ruler) tk.push([[r.x, 30, .2], [r.x, 50, .2]]);
451
+ if (tk.length) E.seg(tk, X.plateG, E.majorMat);
452
+ for (const r of X.ruler) {
453
+ const t = E.tplane(40, 10, 4, (cx, s, C) => { E.font(cx, 7, s); cx.fillStyle = C.ink; cx.fillText('+' + r.label, 1 * s, 8 * s); }, X.plateG);
454
+ E.roofAt(t, r.x + 2, 30, .1);
455
+ }
456
+ if (!X.items.length && !X.extra.length) {
457
+ const t = E.tplane(400, 20, 3, (cx, s, C) => { E.font(cx, 12, s); cx.fillStyle = C.ink; cx.fillText('Nothing has run yet: no attempt timings recorded.', 4 * s, 14 * s); }, X.plateG);
458
+ E.roofAt(t, 60, 80, .1);
459
+ }
460
+ for (const it of X.items.concat(X.extra)) buildAttempt(it, g);
461
+ // now: a pane of light across the lanes, moving with the clock (or the replay)
462
+ const nowM = new THREE.MeshBasicMaterial({ color: E.accent.clone(), toneMapped: false, transparent: true, opacity: .16, depthWrite: false, side: THREE.DoubleSide });
463
+ const pane = E.noRay(new THREE.Mesh(new THREE.PlaneGeometry(1, 1), nowM));
464
+ pane.rotation.y = Math.PI / 2; pane.scale.set((X.py - 40) * TS, 70 * TS, 1);
465
+ pane.position.copy(W(0, (X.py + 40) / 2, 35)); g.add(pane);
466
+ const lineM = E.accentLineMat;
467
+ const nl = E.polyLine([[0, 40, .4], [0, X.py, .4]], lineM, g);
468
+ X.now = { pane, nl, mat: nowM };
469
+ placeNow();
470
+ }
471
+ function placeNow() {
472
+ const X = st.exec; if (!X || !X.now) return;
473
+ const t = st.replay !== null ? st.replay : nowSec();
474
+ const show = X.t0 !== null && (st.replay !== null || X.live) && t >= X.t0 - 1 && t <= X.t1 + 1;
475
+ X.now.pane.visible = X.now.nl.visible = show;
476
+ if (!show) return;
477
+ const xx = X.x(t);
478
+ X.now.pane.position.x = xx * TS; X.now.nl.position.x = xx * TS;
479
+ }
480
+ function attemptLine(el) {
481
+ const it = el.data.item;
482
+ if (it.ghost) return `projected · ~${RM.fmtDur(it.end - it.start)}`;
483
+ if (it.kind === 'review') return `${el.status} · ${RM.fmtDur(it.end - it.start)}`;
484
+ const bits = [el.status, RM.fmtDur(it.end - it.start)];
485
+ if (it.running && it.role) bits.splice(1, 0, it.role === 'qa' ? 'QA' : 'dwarf');
486
+ return bits.join(' · ');
487
+ }
488
+ function buildAttempt(it, g) {
489
+ const h = it.ghost ? 26 : it.kind ? 30 : 36;
490
+ const n = { x: it.x, y: it.y, w: it.w, d: it.d, h };
491
+ const id = 'x:' + it.id;
492
+ const el = newEl(id, it.ghost ? 'ghost' : it.kind || 'attempt', g, n, { item: it });
493
+ setStatus(el, it.status);
494
+ body(el, { r: Math.min(7, it.w / 4) });
495
+ const lw = Math.max(10, it.w - 10);
496
+ el.roof = label(el, lw, it.d - 14, 4, (cx, s, C, e) => {
497
+ const item = e.data.item, sel = e.cur.sel > .5;
498
+ const title = item.kind === 'verification' ? 'verification' : item.kind === 'review' ? 'final review' : item.task + (item.attempt ? ' · A' + item.attempt : '');
499
+ const f = Math.min(11, Math.max(4, (lw - 4) / Math.max(4, title.length * E.CW)));
500
+ E.font(cx, f, s, 600); cx.fillStyle = sel ? C.accHi : item.ghost ? C.ink : C.inkHi; cx.fillText(E.ellipsis(title, Math.floor((lw - 2) / (f * E.CW))), 1 * s, (f + 1) * s);
501
+ // segments: when the dwarf and the review ran inside this attempt
502
+ const t0 = item.start, span = Math.max(1, item.end - item.start), by = it.d - 34;
503
+ for (const sg of item.segs) {
504
+ const sx = (sg.start - t0) / span * (lw - 2), sw = Math.max(1.5, ((sg.end ?? item.end) - sg.start) / span * (lw - 2));
505
+ cx.fillStyle = E.statusHex(sg.status); cx.globalAlpha = sg.role === 'qa' ? .95 : .6;
506
+ E.rr(cx, s, 1 + sx, by, Math.min(sw, lw - 2 - sx), 8, 2); cx.fill(); cx.globalAlpha = 1;
507
+ if (sw > 26) { E.font(cx, 5.5, s, 600); cx.fillStyle = C.bg; cx.fillText(sg.role === 'qa' ? 'QA' : 'DWARF', (3 + sx) * s, (by + 6) * s); }
508
+ }
509
+ if (item.findings && item.findings.worst) chip(cx, s, C, 1, by - 12, `${item.findings.worst}×${item.findings[item.findings.worst]}`, E.sevHex(item.findings.worst), { f: 5 });
510
+ if (item.ghost) { cx.setLineDash([2 * s, 1.6 * s]); cx.strokeStyle = C.line; cx.lineWidth = Math.max(1, .3 * s); E.rr(cx, s, .5, .5, lw - 1, it.d - 15, 3); cx.stroke(); cx.setLineDash([]); }
511
+ }, 'cover', { x: it.x + 5, y: it.y + 6, z: h });
512
+ if (it.w > 70) display(el, attemptLine, { w: Math.min(it.w - 10, 150), x: it.x + 5, y: it.y + it.d, z: h - 6 });
513
+ halo(el);
514
+ return el;
515
+ }
516
+
517
+ /* ---------- finding beacons: each review finding as a mark over the block it is about ---------- */
518
+ const BEACONS = createBeacons(E, { reduce });
519
+ E.scene.add(BEACONS.group);
520
+ const SEV_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
521
+ const byRank = (a, b) => (SEV_RANK[a.f.severity] ?? 4) - (SEV_RANK[b.f.severity] ?? 4) || (a.f.status === 'CONFIRMED' ? 0 : 1) - (b.f.status === 'CONFIRMED' ? 0 : 1);
522
+ const planOff = el => [el.offset.x / TS, el.offset.z / TS];
523
+ // the i-th of n marks, in a grid of up to five a row centred on (cx, cy)
524
+ function grid(i, n, cx, cy, step) {
525
+ const cols = Math.min(n, 5), rows = Math.ceil(n / cols), c = i % cols, r = Math.floor(i / cols);
526
+ return [cx + (c - (cols - 1) / 2) * step, cy + (r - (rows - 1) / 2) * step];
527
+ }
528
+ // the execution view's block holding an execution (an attempt holds its dwarf's and its review's)
529
+ function execEl(id) {
530
+ if (!EXEC_BY) {
531
+ EXEC_BY = new Map();
532
+ for (const el of EL.values()) { const it = el.data && el.data.item; if (!it || it.ghost) continue;
533
+ EXEC_BY.set(it.id, el); for (const g of list(it.segs)) if (g.id) EXEC_BY.set(g.id, el);
534
+ if (it.kind === 'review' && it.review) EXEC_BY.set('final:' + it.review, el); }
535
+ }
536
+ return EXEC_BY.get(id) || null;
537
+ }
538
+ // a mark's foot on a block's roof: [x, y, z, scale]
539
+ function over(el, i, n, step, scale) {
540
+ // lying flat (top-down) the marks keep to the roof's right edge, off its text
541
+ const cx = BEACONS.flat ? el.n.x + el.n.w - Math.min(el.n.w / 2, step * .7) : el.n.x + el.n.w / 2;
542
+ const [ox, oy] = planOff(el), [x, y] = grid(i, n, cx + ox, el.n.y + el.n.d / 2 + oy, step);
543
+ return [x, y, el.n.h, scale];
544
+ }
545
+ // where a task's finding stands: over the roof (the latest attempt's, while the task is shut), on the review
546
+ // that found it (while it is open), on its attempt (execution); null hides it
547
+ function anchorTask(tid, r, ri, rn, j, jn, shown) {
548
+ if (!st.layers.beacons || st.fade || !shown(r)) return null;
549
+ const u = BEACONS.unit * 1.15; // marks a little more than their width apart
550
+ if (st.mode === 'exec') { const el = execEl(r.execution); return el && el.cur.vis > .5 ? over(el, j, jn, u * .7, .7) : null; }
551
+ const el = EL.get('t:' + tid); if (!el || el.cur.vis < .5) return null;
552
+ const pr = PROG.get(el.id), p = pr ? pr.p : 0;
553
+ if (p > .5) {
554
+ const k = (el.kids || []).find(k => k.data.part.exec && k.data.part.exec.id === r.execution);
555
+ if (!k || (k.q || 0) < .6) return null;
556
+ const [ox, oy] = planOff(el), [x, y] = grid(j, jn, k.n.x + k.n.w / 2 + ox, k.n.y + k.n.d / 2 + oy, u * .5);
557
+ return [x, y, k.n.h * (k.q || 1), .5];
558
+ }
559
+ if (ri < 0 || p > .15) return null;
560
+ const a = over(el, ri, rn, u, 1); a[2] = el.n.h * el.cur.hk + el.cover.position.y / TS;
561
+ return a;
562
+ }
563
+ function anchorFinal(name, i, n, shown, review) {
564
+ if (!st.layers.beacons || !st.layers.finish || st.fade || !shown(review)) return null;
565
+ const el = st.mode === 'exec' ? execEl('final:' + name) : EL.get('f:final');
566
+ const s = st.mode === 'exec' ? .7 : .9;
567
+ return el && el.cur.vis > .5 ? over(el, i, n, BEACONS.unit * 1.15 * s, s) : null;
568
+ }
569
+ function syncBeacons() {
570
+ const F = st.findings, run = st.run;
571
+ if (st.view !== 'run' || !run || !F || F.run !== run.key) { BEACONS.set([]); return; }
572
+ // in a replay a finding shows once its review has finished
573
+ const ends = new Map();
574
+ for (const t of list(run.tasks)) for (const e of list(t.executions)) ends.set(e.id, num(e.ended));
575
+ for (const fr of list(run.final_reviews)) if (fr.execution) ends.set(fr.execution, num(fr.ended));
576
+ const shown = r => st.replay === null || (r.execution && ends.get(r.execution) != null && ends.get(r.execution) <= st.replay);
577
+ const items = [], solo = run.kind === 'solo';
578
+ for (const tf of list(F.tasks)) {
579
+ const tid = solo ? (list(run.tasks)[0] || {}).id : tf.id; if (!tid) continue;
580
+ const latest = Math.max(...tf.reviews.map(r => r.attempt ?? 0));
581
+ const roof = tf.reviews.filter(r => (r.attempt ?? 0) === latest).flatMap(r => r.findings.map(f => ({ f, r }))).sort(byRank);
582
+ for (const r of tf.reviews) {
583
+ const mine = r.findings.map(f => ({ f, r })).sort(byRank);
584
+ mine.forEach(({ f }, j) => {
585
+ const ri = roof.findIndex(x => x.f.id === f.id && x.r === r);
586
+ items.push({ key: `${run.key}:${tid}:${f.id}`, sev: f.severity, plausible: f.status !== 'CONFIRMED', data: { task: tid, review: r, finding: f },
587
+ anchor: () => anchorTask(tid, r, ri, roof.length, j, mine.length, shown) });
588
+ });
589
+ }
590
+ }
591
+ const fr = list(F.final_reviews)[0]; // the newest final review's
592
+ if (fr) {
593
+ const review = { kind: 'final', execution: fr.execution, spec: fr.spec, verdict: fr.verdict };
594
+ fr.findings.map(f => ({ f })).sort(byRank).forEach(({ f }, i, arr) => items.push({ key: `${run.key}:final:${fr.name}:${f.id}`, sev: f.severity,
595
+ plausible: f.status !== 'CONFIRMED', data: { final: fr.name, review, finding: f }, anchor: () => anchorFinal(fr.name, i, arr.length, shown, review) }));
596
+ }
597
+ BEACONS.set(items);
598
+ }
599
+ // the layers the toolbar switches; a frame never turns these back on
600
+ function applyLayers() {
601
+ const L = st.layers, finishOff = id => !L.finish && String(id || '').startsWith('f:');
602
+ for (const f of FLOWS) f.line.visible = f.cone.visible = L.cables && !finishOff(f.from) && !finishOff(f.to);
603
+ for (const el of EL.values()) {
604
+ if (el.leds) el.leds.mesh.visible = L.lamps;
605
+ if (el.roof) el.roof.mesh.visible = L.roof && !el.roofTiny;
606
+ if (el.disp) el.disp.mesh.visible = L.labels;
607
+ if (el.haloMesh) el.haloMesh.visible = L.halos;
608
+ if (el.kind === 'finish') el.root.visible = L.finish;
609
+ if (el.kind === 'review' || el.kind === 'verification') el.root.visible = L.finish;
610
+ }
611
+ for (const t of DISTRICTS) t.mesh.visible = L.districts;
612
+ BEACONS.setVisible(L.beacons && st.view === 'run');
613
+ E.invalidate(100);
614
+ }
615
+
616
+ /* ---------- name tags: a roof too small to read gets its name floating over it, facing the screen ---------- */
617
+ const CM = window.ForgeCloudModel;
618
+ const TAGS = new Map(); // element id → screen sprite
619
+ const ID_FONT = 21, TAG_PX = 20, TINY = 3.5; // the roof id's size in plan units; px: a tag, a roof too small to draw
620
+ const READ = 7 / Math.sqrt(Math.min(2, window.devicePixelRatio || 1)); // px at which a roof's id still reads (sharper on a dense screen)
621
+ let tagAt = 0, tagKey = '';
622
+ const SV = new THREE.Vector3();
623
+ function screenOf(v) { SV.copy(v).project(E.camera); const [VW, VH] = E.size; return { x: (SV.x + 1) / 2 * VW, y: (1 - SV.y) / 2 * VH, on: SV.z < 1 && Math.abs(SV.x) < 1.02 && Math.abs(SV.y) < 1.02 }; }
624
+ function tagText(el) {
625
+ if (el.kind === 'task') return el.data.task.id;
626
+ if (el.kind === 'finish') return el.data.name || '';
627
+ if (el.kind === 'run') return E.ellipsis(String(el.data.run.goal || el.data.run.run_id || '').split('\n')[0], 34);
628
+ const it = el.data.item; return it ? it.task || it.review || it.kind || '' : '';
629
+ }
630
+ // the elements a tag can name in the view shown: plan blocks, the attempts on the time axis, the map's runs
631
+ function taggable(el) {
632
+ if (st.view === 'atlas') return el.kind === 'run';
633
+ if (el.kind === 'part' || el.kind === 'ghost' || !el.root.parent) return false;
634
+ return st.mode === 'plan' ? el.g === st.planG : el.g === st.execG;
635
+ }
636
+ function roofTop(el) { el.root.updateWorldMatrix(true, false); return el.root.localToWorld(W(el.n.x + el.n.w / 2, el.n.y + el.n.d / 2, el.n.h * (el.cur.hk || 1) + el.cover.position.y / TS)); }
637
+ function tagFor(el) {
638
+ let t = TAGS.get(el.id); if (t) return t;
639
+ const id = el.id, text = tagText(el), w = Math.ceil(text.length * 11 * E.CW) + 26;
640
+ t = E.sprite(w, 22, 3, (x, s, C) => {
641
+ const cur = EL.get(id) || el, col = E.statusHex(cur.status);
642
+ E.rr(x, s, 1, 1, w - 2, 20, 6); x.globalAlpha = .92; x.fillStyle = C.bg; x.fill(); x.globalAlpha = 1;
643
+ x.strokeStyle = sameSel(cur) ? C.acc : C.line; x.lineWidth = Math.max(1, s); x.stroke();
644
+ x.fillStyle = col; x.beginPath(); x.arc(10 * s, 11 * s, 3.2 * s, 0, 7); x.fill();
645
+ E.font(x, 11, s, 600); x.fillStyle = C.inkHi; x.fillText(text, 18 * s, 15 * s);
646
+ }, { screen: true, px: TAG_PX });
647
+ t.mesh.center.set(.5, -.2); t.mat.depthTest = false; t.mesh.renderOrder = 6;
648
+ world.add(t.mesh); TAGS.set(id, t); return t;
649
+ }
650
+ const sameSel = el => st.sel && selIds().has(el.id);
651
+ function updateTags() {
652
+ const on = st.layers.roof && (st.view === 'run' || st.view === 'atlas'), cands = [], sel = selIds();
653
+ E.camera.updateMatrixWorld(); // the frame step runs before the render that would update it
654
+ for (const el of EL.values()) {
655
+ if (!on || !taggable(el)) continue;
656
+ const top = roofTop(el), p = screenOf(top);
657
+ const b = screenOf(top.clone().add(W(ID_FONT, 0, 0))), px = Math.hypot(b.x - p.x, b.y - p.y);
658
+ const tiny = px < TINY && st.view === 'run';
659
+ if (el.roof && el.roofTiny !== tiny) { el.roofTiny = tiny; el.roof.mesh.visible = st.layers.roof && !tiny; }
660
+ if (!p.on || px >= READ || el.tgt.vis < .5) continue;
661
+ const w = (Math.ceil(tagText(el).length * 11 * E.CW) + 26) * TAG_PX / 22;
662
+ cands.push({ key: el.id, x: p.x, y: p.y - 4, w: w + 8, h: TAG_PX + 6, pri: (sel.has(el.id) ? 1e9 : 0) + (el.id === st.hover ? 1e8 : 0) + (el.status === 'RUNNING' ? 1e6 : 0) + px });
663
+ }
664
+ // a finding's mark is never covered: its head goes in first, as a place no tag may take
665
+ if (st.view === 'run' && st.layers.beacons) {
666
+ const sz = BEACONS.unit * 2.2 * E.pxPerUnit();
667
+ BEACONS.items.forEach((it, i) => { if (!it.head || it.hidden) return; const h = screenOf(W(...it.head)); if (h.on) cands.push({ key: 'beacon:' + i, x: h.x, y: h.y + sz * .45, w: sz * .8, h: sz, pri: 1e12 }); });
668
+ }
669
+ const keep = new Set(CM ? CM.labelPick(cands, { max: (st.view === 'atlas' ? 10 : 40) + cands.filter(c => c.pri === 1e12).length }).filter(k => !k.startsWith('beacon:')) : []);
670
+ for (const [id, t] of TAGS) if (!keep.has(id)) t.mesh.visible = false;
671
+ for (const id of keep) {
672
+ const el = EL.get(id), t = tagFor(el), look = el.status + '|' + sameSel(el);
673
+ if (t.look !== look) { t.look = look; t.redraw(); }
674
+ t.mesh.visible = true; t.mesh.position.copy(roofTop(el));
675
+ }
676
+ }
677
+ // each frame: the tags follow the camera, at most every 120 ms, and once more when it settles
678
+ function tagsTick(now) {
679
+ const c = E.camera, key = [c.position.x, c.position.y, c.position.z, c.zoom || 1, ...E.size].map(v => v.toFixed(4)).join() + st.view + st.mode + st.hover + JSON.stringify(st.sel) + st.layers.roof + st.rebuilds;
680
+ if (key === tagKey) return false;
681
+ if (now - tagAt < 120) return true;
682
+ tagAt = now; tagKey = key; updateTags(); return false;
683
+ }
684
+
685
+ /* ---------- building a view ---------- */
686
+ function clearWorld() {
687
+ if (world) E.disposeGroup(world);
688
+ world = new THREE.Group(); E.scene.add(world);
689
+ EL = new Map(); PICK = []; FLOWS = []; PROG = new Map(); DISTRICTS = []; EXEC_BY = null; TAGS.clear(); tagKey = '';
690
+ st.plan = st.exec = st.atlas = null; st.fade = null;
691
+ BEACONS.set([]);
692
+ hideTip();
693
+ }
694
+ function buildRun() {
695
+ clearWorld(); st.rebuilds++;
696
+ const pg = new THREE.Group(), xg = new THREE.Group(); world.add(pg, xg);
697
+ st.planG = pg; st.execG = xg;
698
+ buildPlan(pg); buildExec(xg);
699
+ pg.visible = st.mode === 'plan'; xg.visible = st.mode === 'exec';
700
+ computeCritical();
701
+ syncBeacons(); applyLayers();
702
+ retarget();
703
+ }
704
+ function rebuildExec() {
705
+ for (const [id, el] of [...EL]) if (el.g === st.execG) EL.delete(id);
706
+ PICK = PICK.filter(o => { let p = o; while (p) { if (p === st.execG) return false; p = p.parent; } return true; });
707
+ E.disposeGroup(st.execG); st.execG = new THREE.Group(); world.add(st.execG);
708
+ buildExec(st.execG); st.execG.visible = st.mode === 'exec' || !!st.fade; st.rebuilds++; st.execDirty = false;
709
+ EXEC_BY = null; applyLayers();
710
+ }
711
+ function boxOf(which) {
712
+ const P = which === 'exec' ? st.exec : st.plan;
713
+ if (!P) return [-34, -34, -16, 900, 500, 60];
714
+ return [-34, -34, -16, P.px + 34, P.py + 34, 36];
715
+ }
716
+
717
+ /* ---------- selection, opening, statuses → targets ---------- */
718
+ function selIds() {
719
+ const s = st.sel, out = new Set(); if (!s) return out;
720
+ if (st.view === 'atlas') { if (s.kind === 'run') out.add('r:' + s.key); return out; }
721
+ const tid = s.kind === 'task' ? s.id : s.kind === 'execution' || s.kind === 'fractal' ? s.task : null;
722
+ if (tid) {
723
+ out.add('t:' + tid);
724
+ if (s.kind === 'execution') for (const el of EL.values()) if (el.kind === 'part' && el.data.task === tid && el.data.part.exec && el.data.part.exec.id === s.exec) out.add(el.id);
725
+ }
726
+ if (s.kind === 'task' || s.kind === 'execution' || s.kind === 'fractal') {
727
+ for (const el of EL.values()) {
728
+ const it = el.data && el.data.item; if (!it) continue;
729
+ if (s.kind !== 'execution' ? it.task === tid : it.id === s.exec || list(it.segs).some(g => g.id === s.exec)) out.add(el.id);
730
+ }
731
+ }
732
+ if (s.kind === 'final') { out.add('f:final'); for (const el of EL.values()) if (el.data && el.data.item && el.data.item.kind === 'review' && (!s.name || el.data.item.review === s.name)) out.add(el.id); }
733
+ if (s.kind === 'verification') { out.add('f:verification'); for (const el of EL.values()) if (el.data && el.data.item && el.data.item.kind === 'verification') out.add(el.id); }
734
+ if (s.kind === 'integration') out.add('f:integration');
735
+ return out;
736
+ }
737
+ function metricColor(el) {
738
+ const o = st.overlay, t = el.data && el.data.task; if (!t || o.color === 'status') return null;
739
+ if (o.color === 'model') return new THREE.Color(modelHex(RM.alias(t.dwarf) || '?'));
740
+ const v = RM.metricValue(t, o.color, st.run, nowSec()), mx = st.metricMax && st.metricMax[o.color];
741
+ if (v === null || !mx) return E.DETAIL.clone();
742
+ return new THREE.Color().copy(E.statusColor('pending')).lerp(E.accent, Math.sqrt(v / mx));
743
+ }
744
+ function retarget() {
745
+ const sel = selIds(), tsel = st.sel && (st.sel.kind === 'task' ? st.sel.id : st.sel.kind === 'execution' || st.sel.kind === 'fractal' ? st.sel.task : null), open = tsel && st.mode === 'plan' && st.view === 'run' ? 't:' + tsel : null;
746
+ const crit = st.overlay.critical && st.crit ? new Set(st.crit.ids.map(id => 't:' + id)) : new Set();
747
+ // the route: what the chosen task waits on and what waits on it, lit like a route set on a signal box desk
748
+ const route = new Set();
749
+ if (tsel && st.layers.route && st.view === 'run' && st.run) { const L = RM.lineage(st.run, tsel); for (const id of [...L.ancestors, ...L.descendants]) route.add('t:' + id); }
750
+ st.routeSet = route;
751
+ if (st.view === 'run' && st.overlay.height !== 'none') {
752
+ st.metricMax = st.metricMax || {};
753
+ st.metricMax[st.overlay.height] = Math.max(0, ...list(st.run.tasks).map(t => RM.metricValue(t, st.overlay.height, st.run, nowSec()) || 0));
754
+ }
755
+ if (st.view === 'run' && st.overlay.color !== 'status' && st.overlay.color !== 'model') {
756
+ st.metricMax = st.metricMax || {};
757
+ st.metricMax[st.overlay.color] = Math.max(0, ...list(st.run.tasks).map(t => RM.metricValue(t, st.overlay.color, st.run, nowSec()) || 0));
758
+ }
759
+ for (const el of EL.values()) {
760
+ baseTargets(el);
761
+ const T = el.tgt;
762
+ T.sel = sel.has(el.id) ? 1 : 0;
763
+ T.route = route.has(el.id) && st.mode === 'plan' ? 1 : 0;
764
+ T.hov = st.hover === el.id ? 1 : 0;
765
+ T.halo = el.pulse ? .22 : sel.has(el.id) ? .16 : 0;
766
+ if (el.kind === 'run' && el.data.module && !el.data.module.match) { T.vis = .14; T.op = .45; T.halo = 0; }
767
+ if (el.kind === 'ghost') { T.opB = .08; T.op = .7; T.lit = 0; T.tint = 0; }
768
+ if (crit.has(el.id)) { T.lit = Math.max(T.lit, .75); T.halo = Math.max(T.halo, .12); }
769
+ if (el.flash && el.flash > performance.now()) { T.lit = Math.max(T.lit, .9); T.halo = Math.max(T.halo, .3); } // an event, without motion
770
+ if (st.overlay.critical && st.crit && el.kind === 'task' && !crit.has(el.id)) T.vis = Math.min(T.vis, .55);
771
+ el.metricCol = metricColor(el);
772
+ if (el.metricCol) { T.tint = E.theme === 'light' ? .5 : .45; T.lit = .5; }
773
+ if (el.kind === 'task' && st.overlay.height !== 'none') {
774
+ const v = RM.metricValue(el.data.task, st.overlay.height, st.run, nowSec()), mx = st.metricMax[st.overlay.height];
775
+ T.hk = v === null || !mx ? .35 : .4 + 1.4 * Math.sqrt(v / mx);
776
+ } else T.hk = 1;
777
+ if (st.replay !== null && el.data && el.data.item && !el.data.item.ghost) {
778
+ const it = el.data.item;
779
+ if (it.start > st.replay) { T.vis = .22; T.op = .35; T.opB = .25; }
780
+ }
781
+ if (el.kind === 'task') { const pr = PROG.get(el.id); if (pr) pr.t = el.id === open ? 1 : 0; }
782
+ // an open task has the stage: the other blocks sink and fade, as archivision does when a block opens
783
+ // with its route set, the rest of the plan sinks and fades (gentler on the pale panel); the route is lit full
784
+ if (open && route.size && el.id !== open && (el.kind === 'task' || el.kind === 'finish') && !sel.has(el.id)) {
785
+ if (route.has(el.id)) { T.lit = 1; T.halo = Math.max(T.halo, .14); }
786
+ else { T.vis = Math.min(T.vis, E.theme === 'light' ? .5 : .3); T.hk *= .4; T.halo = 0; }
787
+ }
788
+ // on the time axis: the route's attempts stay, the rest dim
789
+ if (tsel && st.layers.route && st.mode === 'exec' && el.data && el.data.item && el.data.item.task && el.data.item.task !== tsel && !route.has('t:' + el.data.item.task)) T.vis = Math.min(T.vis, .35);
790
+ }
791
+ for (const f of FLOWS) {
792
+ f.hot = (st.hover && (f.from === st.hover || f.to === st.hover)) || sel.has(f.from) || sel.has(f.to) ? 1 : 0;
793
+ const onRoute = id => route.has(id) || sel.has(id);
794
+ f.route = route.size && onRoute(f.from) && onRoute(f.to) ? 1 : 0;
795
+ f.crit = st.overlay.critical && st.crit && crit.has(f.from) && crit.has(f.to) && st.crit.ids.indexOf(f.from.slice(2)) === st.crit.ids.indexOf(f.to.slice(2)) - 1 ? 1 : 0;
796
+ const tgtEl = EL.get(f.to);
797
+ f.live = tgtEl && tgtEl.status === 'RUNNING' && f.kind === 'dep';
798
+ }
799
+ E.invalidate(200);
800
+ syncCtl();
801
+ }
802
+ function computeCritical() { st.crit = st.view === 'run' && st.run && st.run.kind !== 'solo' ? RM.criticalPath(st.run, { now: nowSec() }) : null; }
803
+
804
+ /* ---------- every frame: ease toward the targets, open, light, move beads ---------- */
805
+ const BEADS = [];
806
+ const beadMat = new THREE.MeshBasicMaterial({ color: E.accent.clone(), toneMapped: false });
807
+ const beadG = new THREE.Group(); E.scene.add(beadG);
808
+ for (let i = 0; i < 24; i++) {
809
+ const m = E.noRay(new THREE.Mesh(new THREE.SphereGeometry(TS, 12, 8), beadMat)); m.visible = false; beadG.add(m);
810
+ const gl = E.glowPoints([new THREE.Vector3()], .3); gl.visible = false; beadG.add(gl); BEADS.push({ m, gl });
811
+ }
812
+ E.themeHooks.add(() => { beadMat.color.copy(E.accent); for (const el of EL.values()) { el.col = E.statusColor(el.status); halo(el); } retarget(); });
813
+ /* ---------- events: what just happened, played once on the plate. A merge sends a pulse to integration, a failed
814
+ attempt a red ring across the plate, an escalation a hop with the two models, a final review a ring in its verdict's
815
+ colour. They play for what a live poll brings and for what a replay passes; with reduced motion the block lights
816
+ instead, and a replay says each event in the live region. ---------- */
817
+ const fxG = new THREE.Group(); fxG.name = 'events'; E.scene.add(fxG);
818
+ const FX = [], RINGS = [], PULSES = [];
819
+ const UNIT_CIRCLE = Array.from({ length: 65 }, (_, i) => [Math.cos(i / 64 * Math.PI * 2), Math.sin(i / 64 * Math.PI * 2), 0]);
820
+ for (let i = 0; i < 6; i++) {
821
+ const g = new THREE.Group(), mat = E.lmat({ color: 0xffffff, transparent: true, linewidth: E.accentLineMat.linewidth, depthTest: false });
822
+ E.polyLine(UNIT_CIRCLE, mat, g); g.visible = false; g.renderOrder = 5; fxG.add(g); RINGS.push({ g, mat, busy: false });
823
+ }
824
+ for (let i = 0; i < 4; i++) {
825
+ const mat = new THREE.MeshBasicMaterial({ color: 0xffffff, toneMapped: false, transparent: true, depthTest: false });
826
+ const m = E.noRay(new THREE.Mesh(new THREE.SphereGeometry(TS, 12, 8), mat)); m.visible = false; m.renderOrder = 5;
827
+ const gl = E.glowPoints([new THREE.Vector3()], .5); gl.visible = false; fxG.add(m, gl); PULSES.push({ m, mat, gl, busy: false });
828
+ }
829
+ const FAILED = new Set(['FAIL', 'ERROR', 'CONFLICT', 'INFRA', 'TIMEOUT', 'BLOCKED']);
830
+ const evKey = ev => [ev.kind, ev.task || ev.name, ev.attempt ?? '', ev.t].join('|');
831
+ let evSeen = new Set(), evRun = null, evAt = null, sayAt = 0;
832
+ // the block an event happened to, in the view shown: the task's block, or on the time axis its attempt then
833
+ function evEl(ev) {
834
+ if (st.mode === 'plan') return EL.get(ev.kind === 'review' ? 'f:final' : 't:' + ev.task);
835
+ let best = null;
836
+ for (const el of EL.values()) {
837
+ const it = el.data && el.data.item; if (!it || it.ghost || el.g !== st.execG) continue;
838
+ const mine = ev.kind === 'review' ? it.kind === 'review' && it.review === ev.name : it.task === ev.task && !it.kind;
839
+ if (mine && it.start <= ev.t + 1 && (!best || it.start > best.data.item.start)) best = el;
840
+ }
841
+ return best;
842
+ }
843
+ const centre = el => { const p = roofTop(el); return [p.x / TS, p.z / TS, p.y / TS]; }; // world back to plan units
844
+ function ring(el, color, from, to, dur) {
845
+ const r = RINGS.find(x => !x.busy); if (!r) return;
846
+ const [x, y, z] = centre(el); r.busy = true; r.g.position.copy(W(x, y, z + 1)); r.mat.color.copy(color); r.g.visible = true;
847
+ FX.push({ t0: performance.now(), dur, step: k => { const e = 1 - Math.pow(1 - k, 3); r.g.scale.setScalar(from + (to - from) * e); E.setOp(r.mat, (1 - k) * .95); },
848
+ end: () => { r.g.visible = false; r.busy = false; } });
849
+ }
850
+ function pulse(a, b, color, dur, then) {
851
+ const p = PULSES.find(x => !x.busy); if (!p) { if (then) then(); return; }
852
+ const A = centre(a), B = centre(b), pts = E.arc(A, B, 24, .16, 140), cum = E.cumLen(pts);
853
+ p.busy = true; p.mat.color.copy(color); p.gl.material.uniforms.color.value.copy(color); p.m.visible = p.gl.visible = true; p.m.scale.setScalar(4.5);
854
+ FX.push({ t0: performance.now(), dur, step: k => { const q = E.alongPts(pts, cum, ez(k)); p.m.position.copy(W(...q)); E.moveGlow(p.gl, q, 1); },
855
+ end: () => { p.m.visible = p.gl.visible = false; p.busy = false; if (then) then(); } });
856
+ }
857
+ function hop(el, text) {
858
+ const [x, y, z] = centre(el), w = Math.ceil(text.length * 11 * E.CW) + 16;
859
+ const t = E.sprite(w, 22, 3, (c, s, C) => {
860
+ E.rr(c, s, 1, 1, w - 2, 20, 6); c.globalAlpha = .92; c.fillStyle = C.bg; c.fill(); c.globalAlpha = 1;
861
+ c.strokeStyle = C.acc; c.lineWidth = Math.max(1, s); c.stroke();
862
+ E.font(c, 11, s, 600); c.fillStyle = C.accHi; c.fillText(text, 8 * s, 15 * s);
863
+ }, { screen: true, px: 20 });
864
+ t.mesh.center.set(.5, 0); t.mat.depthTest = false; t.mesh.renderOrder = 7; fxG.add(t.mesh);
865
+ const r = el.n.w / 2;
866
+ const pts = E.arc([x - r * .8, y, z], [x + r * .8, y, z], 8, .3, 60), mat = E.lmat({ color: E.accent.clone(), transparent: true, linewidth: E.accentLineMat.linewidth, depthTest: false });
867
+ const line = E.polyLine(pts, mat, fxG);
868
+ FX.push({ t0: performance.now(), dur: 2200, step: k => { t.mesh.position.copy(W(x, y, z + 30 + 50 * ez(k))); const o = k < .7 ? 1 : 1 - (k - .7) / .3; t.mat.opacity = o; E.setOp(mat, o); },
869
+ end: () => { E.disposeGroup(t.mesh); fxG.remove(t.mesh); E.disposeGroup(line); fxG.remove(line); } });
870
+ }
871
+ function flash(el) { el.flash = performance.now() + 2600; retarget(); setTimeout(() => retarget(), 2700); }
872
+ function say(ev) {
873
+ if (ev.kind === 'merge') return `${ev.task} merged`;
874
+ if (ev.kind === 'attempt') return `${ev.task} attempt ${ev.attempt ?? ''} ${String(ev.status || '').toLowerCase()}`.replace(/\s+/g, ' ');
875
+ if (ev.kind === 'escalation') return `${ev.task} escalated ${RM.alias(ev.from) || ev.from} → ${RM.alias(ev.to) || ev.to}`;
876
+ return `final review ${ev.name || ''}: ${String(ev.verdict || '').toLowerCase()}`;
877
+ }
878
+ function playEvent(ev) {
879
+ const el = evEl(ev); if (!el || !el.root.parent) return false;
880
+ if (ev.kind === 'attempt' && !FAILED.has(ev.status)) return false;
881
+ if (reduce) { flash(el); return true; }
882
+ const r0 = Math.max(el.n.w, el.n.d) * .55;
883
+ if (ev.kind === 'merge') {
884
+ const integ = st.mode === 'plan' && st.layers.finish ? EL.get('f:integration') : null, green = E.statusColor('merged');
885
+ if (integ) pulse(el, integ, green, 900, () => ring(integ, green, Math.max(integ.n.w, integ.n.d) * .5, Math.max(integ.n.w, integ.n.d) * .5 + 60, 900));
886
+ else ring(el, green, r0, r0 + 60, 900);
887
+ } else if (ev.kind === 'attempt') ring(el, E.statusColor('fail'), r0, r0 + 220, 1400);
888
+ else if (ev.kind === 'escalation') hop(el, `${RM.alias(ev.from) || ev.from || '?'} → ${RM.alias(ev.to) || ev.to || '?'}`);
889
+ else if (ev.kind === 'review') ring(el, E.statusColor(['PASS', 'ACCEPT', 'APPROVE'].includes(String(ev.verdict || '').toUpperCase()) ? 'pass' : 'fail'), r0, r0 + 120, 1200);
890
+ E.invalidate(100);
891
+ return true;
892
+ }
893
+ // events not seen yet (a poll) or passed since the last replay time (a replay playing forward): at most six at once
894
+ function syncEvents({ replayFrom = null } = {}) {
895
+ if (st.view !== 'run' || !st.run) return;
896
+ const all = RM.events(st.run);
897
+ if (evRun !== st.run.key) { evRun = st.run.key; evSeen = new Set(all.map(evKey)); evAt = st.replay; return; }
898
+ let fresh;
899
+ if (st.replay !== null) {
900
+ fresh = replayFrom !== null && st.replay > replayFrom ? all.filter(ev => ev.t > replayFrom && ev.t <= st.replay) : [];
901
+ evAt = st.replay;
902
+ } else fresh = all.filter(ev => !evSeen.has(evKey(ev)));
903
+ for (const ev of all) evSeen.add(evKey(ev));
904
+ const shown = fresh.slice(-6).filter(playEvent);
905
+ if (st.replay !== null && shown.length && live && performance.now() - sayAt > 1500) { sayAt = performance.now(); live.textContent = shown.slice(-3).map(say).join('; '); }
906
+ }
907
+
908
+ let lastPulse = 0, lastText = 0;
909
+ function frameStep(now, dt) {
910
+ const kk = reduce ? 1 : Math.min(1, dt * 7);
911
+ let moving = false;
912
+ // the plan ⇄ exec switch: blocks travel toward where they ran, while one view fades into the other
913
+ let fk = null;
914
+ if (st.fade) {
915
+ fk = ez(clamp((now - st.fade.t0) / st.fade.dur)); moving = true;
916
+ if (fk >= 1) { st.planG.visible = st.mode === 'plan'; st.execG.visible = st.mode === 'exec'; for (const el of EL.values()) el.offset.set(0, 0, 0); st.fade = null; retarget(); fk = null; }
917
+ }
918
+ for (const [id, pr] of PROG) {
919
+ if (pr.p === pr.t) continue;
920
+ pr.p = pr.t > pr.p ? Math.min(pr.t, pr.p + dt * 1000 / 1300) : Math.max(pr.t, pr.p - dt * 1000 / 600);
921
+ if (reduce) pr.p = pr.t;
922
+ const el = EL.get(id); if (el && pr.t > 0 && !el.inner) buildInner(el);
923
+ moving = true;
924
+ }
925
+ const pulse = .5 + .5 * Math.sin(now / 420);
926
+ let pulsing = false;
927
+ for (const el of EL.values()) {
928
+ const c = el.cur, T = el.tgt;
929
+ for (const k of ['vis', 'sel', 'hov', 'lit', 'op', 'opB', 'tint', 'halo', 'hk', 'route']) {
930
+ const d = T[k] - c[k]; if (Math.abs(d) > .002) { c[k] += d * kk; moving = true; } else c[k] = T[k];
931
+ }
932
+ // which view the element belongs to, during a switch
933
+ let gf = 1;
934
+ if (fk !== null) {
935
+ const inPlan = el.g === st.planG || (el.kind === 'part');
936
+ gf = st.mode === 'exec' ? (inPlan ? 1 - fk : fk) : (inPlan ? fk : 1 - fk);
937
+ const off = st.fade.off.get(el.id);
938
+ if (off) { const k = inPlan ? (st.mode === 'exec' ? fk : 1 - fk) : (st.mode === 'exec' ? 1 - fk : fk); el.offset.copy(off).multiplyScalar(k); }
939
+ }
940
+ el.root.position.copy(el.offset);
941
+ let lift = 0, ob = 1, ol = 1;
942
+ const pr = PROG.get(el.id);
943
+ if (pr) { const p = pr.p; lift = ez(segT(p, .04, .46)); ob = 1 - segT(p, .06, .3); ol = 1 - segT(p, .14, .46); }
944
+ if (el.kind === 'part') { const par = EL.get(el.data.parent), pp = par && PROG.get(par.id); const p = pp ? pp.p : 0; const [a, b] = [.34 + el.data.idx * .05, .64 + el.data.idx * .05]; const q = back(segT(p, a, b)); el.q = q; el.root.visible = q > .001; el.cover.scale.y = Math.max(.001, q); }
945
+ el.cover.visible = ol > .003;
946
+ el.cover.position.y = (el.kind === 'task' ? 120 : 0) * lift * TS;
947
+ if (el.kind === 'task' || el.kind === 'finish') el.cover.scale.y = Math.max(.05, c.hk);
948
+ const pulseK = el.pulse && st.replay === null ? pulse : el.pulse ? .6 : 0;
949
+ if (el.pulse) pulsing = true;
950
+ const col = el.metricCol || el.col;
951
+ TMP.copy(col).lerp(E.accent, c.sel);
952
+ if (c.route > .01) TMP.lerp(E.INKHI, .55 * c.route * (1 - c.sel)); // a set route: its blocks' hairlines take the route lamp
953
+ E.paint(el.M, c.vis, Math.min(1, Math.max(c.lit + c.sel * .9, c.route * .85)), c.hov, c.op * ol * gf, c.opB * ob * gf, TMP, 0);
954
+ if (c.tint > .001) el.M.body.color.lerp(TMP2.copy(col), c.tint * c.vis);
955
+ if (el.tintMat) { el.tintMat.color.copy(col); el.tintMat.opacity = (c.tint * (E.theme === 'light' ? 1.45 : 1.1) + .08 * c.sel) * ob * gf * c.vis; }
956
+ if (el.haloMat) el.haloMat.uniforms.opacity.value = (c.halo + (el.pulse ? .12 * pulseK : 0)) * gf * c.vis;
957
+ if (el.clef) el.clef.fgM.color.copy(E.statusColor(['MERGED', 'PASS'].includes(el.status) ? 'pass' : ['FAIL', 'ERROR', 'CONFLICT'].includes(el.status) ? 'fail' : 'pending'));
958
+ if (el.inner) {
959
+ const p = pr ? pr.p : 0, vis = p > .002;
960
+ if (!vis && el.inner.visible) { E.release(el.inner); }
961
+ el.inner.visible = vis;
962
+ if (vis) {
963
+ const fo = segT(p, 0, .3) * gf;
964
+ E.setOp(el.floorMat, fo); E.setOp(el.innerLine, fo); E.lineCol(el.innerLine, E.lineMat.color, 0, 1);
965
+ for (const z of el.zoneEls) { E.lineCol(z.mat, E.majorMat.color, 0, 1); z.mat.color.lerp(E.INKHI, .18); E.setOp(z.mat, fo); z.lab.mat.opacity = fo; }
966
+ for (const f of el.innerFlows) {
967
+ const qa = EL.get(f.a), qb = EL.get(f.b), o = segT(Math.min(qa ? qa.q || 0 : 0, qb ? qb.q || 0 : 0), .7, 1) * gf;
968
+ f.line.visible = f.cone.visible = o > .01;
969
+ const hot = st.sel && (sel(f.a) || sel(f.b)) ? 1 : 0;
970
+ E.lineCol(f.mat, E.outlineMat.uniforms.color.value, f.col ? 1 : hot, 1, f.col || E.accent); f.mat.linewidth = E.lineMat.linewidth * (f.col ? 1.6 : 1.15 + .5 * hot); E.setOp(f.mat, o);
971
+ f.am.color.copy(f.mat.color); f.am.opacity = o; if (f.lab) f.lab.mat.opacity = o;
972
+ }
973
+ }
974
+ }
975
+ }
976
+ for (const f of FLOWS) {
977
+ const a = EL.get(f.from), b = EL.get(f.to);
978
+ let gf = 1;
979
+ if (fk !== null) gf = st.mode === 'exec' ? 1 - fk : fk;
980
+ const vis = Math.min(a ? a.cur.vis : 1, b ? b.cur.vis : 1);
981
+ const want = Math.max(f.hot, f.crit, f.route || 0);
982
+ f.cur += (want - f.cur) * kk; if (Math.abs(want - f.cur) > .002) moving = true;
983
+ // a set route is drawn in the route lamp's white; the critical path and hover keep the accent
984
+ E.lineCol(f.mat, E.outlineMat.uniforms.color.value, f.crit ? 1 : f.cur * .9, vis, f.route && !f.crit ? E.INKHI : E.accent);
985
+ if (!f.crit && !f.cur) f.mat.color.lerp(E.INKHI, .12);
986
+ f.mat.linewidth = E.lineMat.linewidth * (1 + .9 * Math.max(f.cur, f.crit));
987
+ E.setOp(f.mat, (.55 + .45 * Math.max(f.cur, f.crit)) * gf);
988
+ f.am.color.copy(f.mat.color); f.am.opacity = f.mat.opacity;
989
+ }
990
+ // beads: into running tasks, along the selected task's cables, inside an open running task
991
+ const want = [];
992
+ const tB = now / 1600;
993
+ for (const f of FLOWS) if ((f.live || f.hot || f.crit || f.route) && (st.mode === 'plan' || st.fade) && st.planG && st.planG.visible) want.push({ pts: f.pts, cum: f.cum, t: (tB + (f.pts[0][0] % 97) / 97) % 1, r: 3.2, size: .32, k: .8 });
994
+ for (const el of EL.values()) if (el.inner && el.inner.visible) for (const f of el.innerFlows) {
995
+ const b = EL.get(f.b); if (b && b.status === 'RUNNING' || f.kind === 'flow' && st.sel && sel(f.a)) want.push({ pts: f.pts, cum: f.cum, t: (tB * 1.4) % 1, r: 1.1, size: .12, k: .8 });
996
+ }
997
+ BEADS.forEach((bd, i) => {
998
+ const w = want[i]; bd.m.visible = bd.gl.visible = !!w && !reduce && st.layers.beads; if (!bd.m.visible) return;
999
+ const p = E.alongPts(w.pts, w.cum, w.t); bd.m.position.copy(W(...p)); bd.m.scale.setScalar(w.r);
1000
+ bd.gl.material.uniforms.size.value = w.size; E.moveGlow(bd.gl, p, w.k);
1001
+ });
1002
+ // elapsed times on running blocks tick once a second
1003
+ if (now - lastText > 1000) { lastText = now; for (const el of EL.values()) if (el.pulse && !el.roofOnly) for (const t of el.texts) if (t !== el.roof && !t.gone) t.redraw(); }
1004
+ if (st.view === 'run' && st.exec && st.exec.live && st.mode === 'exec') { placeNow(); }
1005
+ if (BEACONS.tick(now)) moving = true;
1006
+ if (tagsTick(now)) moving = true;
1007
+ for (let i = FX.length - 1; i >= 0; i--) {
1008
+ const f = FX[i], k = Math.min(1, (now - f.t0) / f.dur);
1009
+ if (k >= 1) { FX.splice(i, 1); f.end(); } else f.step(k);
1010
+ moving = true;
1011
+ }
1012
+ if (!moving && (pulsing || want.length) && !reduce) {
1013
+ // only lamps and beads move: about 30 frames a second is plenty
1014
+ if (now - lastPulse > 30) { lastPulse = now; setTimeout(() => E.invalidate(), 33); }
1015
+ return false;
1016
+ }
1017
+ return moving;
1018
+ }
1019
+ const sel = id => selIds().has(id);
1020
+ E.tickers.add(frameStep);
1021
+ E.controls.addEventListener('start', hideTip);
1022
+
1023
+ /* ---------- pointer and keys ---------- */
1024
+ const elOf = o => o && o.userData && o.userData.el ? EL.get(o.userData.el) : null;
1025
+ const pickable = o => {
1026
+ for (let x = o; x; x = x.parent) if (!x.visible) return false;
1027
+ const el = elOf(o);
1028
+ if (el && o.userData.cover) { const pr = PROG.get(el.id); if (pr && pr.p > .22) return false; if (el.kind === 'part' && (el.q || 0) < .5) return false; }
1029
+ return true;
1030
+ };
1031
+ function info(el) {
1032
+ if (!el) return null;
1033
+ if (el.kind === 'run') { const r = el.data.run; return { meta: `${RM.basename(r.repo)} · ${r.kind}${el.data.module.match ? '' : ' · filtered out'}`, title: String(r.goal || 'Untitled run').split('\n')[0], body: runInfo(r), foot: 'click to open this run' }; }
1034
+ if (el.kind === 'task') return taskCard(el);
1035
+ if (el.kind === 'finish') { const fi = finishInfo(el.id); return { meta: 'finish', title: fi.title, body: `${fi.status} · ${fi.sub}`, foot: fi.line }; }
1036
+ if (el.kind === 'part') { const p = el.data.part; return { meta: `${p.label.toLowerCase()} · attempt ${p.attempt ?? '—'}`, title: p.spec || '—', body: p.planned ? 'planned' : p.status, foot: p.exec && p.exec.id ? 'click for prompt, model and timings' : '' }; }
1037
+ const it = el.data.item; if (!it) return null;
1038
+ if (it.kind === 'verification') return { meta: 'verification', title: 'Verification', body: attemptLine(el) };
1039
+ if (it.kind === 'review') return { meta: 'final review', title: it.review, body: attemptLine(el) };
1040
+ return { meta: it.ghost ? 'projected by forge-schedule rules' : `attempt ${it.attempt ?? '—'} · lane ${it.lane + 1}`, title: it.task, body: attemptLine(el), foot: new Date(it.start * 1000).toLocaleTimeString() + ' → ' + new Date(it.end * 1000).toLocaleTimeString() };
1041
+ }
1042
+ // what a finding says after its "- [P1][CONFIRMED] path:line —" head
1043
+ function said(f) {
1044
+ const t = String(f.summary || '').replace(/^\s*-\s*\[P\d\]\s*(\[[A-Z]+\]\s*)?/, ''), i = t.indexOf(' — ');
1045
+ return i >= 0 && i < 200 ? t.slice(i + 3) : t;
1046
+ }
1047
+ function findingInfo(d) {
1048
+ const f = d.finding, r = d.review || {};
1049
+ const who = r.kind === 'final' ? `final review ${d.final}` : `${r.kind || 'review'} review${r.attempt != null ? ', attempt ' + r.attempt : ''} of ${d.task}`;
1050
+ return { meta: `${f.severity || 'no severity'} · ${String(f.status || '').toLowerCase()} · ${who}`, title: f.location || 'no location named', body: said(f), foot: 'click to open it in the diff' };
1051
+ }
1052
+ // a task's card: what it is, how it went, who did it, its worst finding and its place in the plan
1053
+ function taskCard(el) {
1054
+ const t = el.data.task, now = nowSec(), tt = RM.taskTimes(t, st.run, now), tok = RM.metricValue(t, 'tokens', st.run, now);
1055
+ const tries = list(t.attempts).length;
1056
+ const how = [tries ? `${tries} attempt${tries > 1 ? 's' : ''}` : null, tt.busy ? 'busy ' + RM.fmtDur(tt.busy) : null,
1057
+ tok ? `${tok >= 1e6 ? (tok / 1e6).toFixed(1) + 'M' : tok >= 1e3 ? Math.round(tok / 1e3) + 'k' : tok} tokens` : null].filter(Boolean).join(' · ');
1058
+ const body = [how, `${t.dwarf || '—'}${t.explored ? ' *' : ''} → ${t.qa || '—'}`];
1059
+ const tf = st.findings && list(st.findings.tasks).find(x => x.id === t.id);
1060
+ let note = null;
1061
+ if (tf) {
1062
+ const latest = Math.max(...tf.reviews.map(r => r.attempt ?? 0));
1063
+ const fs = tf.reviews.filter(r => (r.attempt ?? 0) === latest).flatMap(r => r.findings.map(f => ({ f }))).sort(byRank);
1064
+ if (fs.length) note = { sev: fs[0].f.severity, text: `${fs[0].f.location ? fs[0].f.location + ' — ' : ''}${said(fs[0].f)}${fs.length > 1 ? ` (+${fs.length - 1} more)` : ''}` };
1065
+ }
1066
+ const L = st.run ? RM.lineage(st.run, t.id) : { ancestors: new Set(), descendants: new Set() };
1067
+ const foot = [list(t.deps).length ? 'after ' + t.deps.join(', ') : 'no dependencies', L.descendants.size ? `${L.descendants.size} task${L.descendants.size > 1 ? 's' : ''} wait on it` : null].filter(Boolean).join(' · ');
1068
+ return { meta: `task · wave ${t.wave ?? '—'} · ${t.difficulty || '—'}`, title: `${t.id} · ${t.title || ''}`, status: el.status, body, note, foot };
1069
+ }
1070
+ function showTip(el, cx, cy, inf = info(el)) {
1071
+ if (!inf) return hideTip();
1072
+ tip.replaceChildren();
1073
+ for (const [cls, text] of [['meta', inf.meta], ['title', inf.title], ...[].concat(inf.body || []).map(b => ['body', b]), ['note', inf.note], ['foot', inf.foot]]) {
1074
+ if (!text) continue; const d = document.createElement('div'); d.className = 'stage-tip-' + cls;
1075
+ // the status leads the first line as the inspector shows it: a chip with its lamp
1076
+ if (cls === 'body' && inf.status && !tip.querySelector('.chip')) { const c = document.createElement('span'); c.className = 'chip st-' + String(inf.status).toLowerCase(); c.textContent = inf.status; d.append(c, ' '); }
1077
+ if (typeof text === 'string') d.append(text);
1078
+ else { const c = document.createElement('span'); c.className = 'sev-chip sev-' + (['P0', 'P1', 'P2', 'P3'].includes(text.sev) ? text.sev : 'none'); c.textContent = text.sev || '?'; d.append(c, text.text); }
1079
+ tip.append(d);
1080
+ }
1081
+ tip.hidden = false;
1082
+ // inside the part of the stage the modules and the stage's keys leave free
1083
+ const r = host.getBoundingClientRect(), tw = tip.offsetWidth || 240, th = tip.offsetHeight || 80, I = E.overlayInset();
1084
+ const bank = CTL && !CTL.bank.hidden ? CTL.bank.getBoundingClientRect() : null;
1085
+ const L = I.l + 4, R = r.width - I.r - (bank && bank.width && bank.left - r.left > r.width / 2 ? r.right - bank.left + 4 : 8), T = I.t + 4, B = r.height - I.b - 8;
1086
+ let x = cx - r.left + 16, y = cy - r.top + 16;
1087
+ if (x + tw > R) x = cx - r.left - tw - 16;
1088
+ if (y + th > B) y = cy - r.top - th - 16;
1089
+ x = Math.max(L, Math.min(x, R - tw)); y = Math.max(T, Math.min(y, B - th));
1090
+ tip.style.transform = `translate(${Math.max(4, x).toFixed(0)}px, ${Math.max(4, y).toFixed(0)}px)`;
1091
+ }
1092
+ function hideTip() { tip.hidden = true; }
1093
+ let hoverRaf = 0, hoverAt = null;
1094
+ E.handlers.hover = (cx, cy) => {
1095
+ hoverAt = cx === null ? null : [cx, cy];
1096
+ if (hoverRaf) return;
1097
+ hoverRaf = requestAnimationFrame(() => {
1098
+ hoverRaf = 0;
1099
+ // a finding's mark floats in front of its block: it is picked first
1100
+ const b = hoverAt && st.view === 'run' ? BEACONS.pick(hoverAt[0], hoverAt[1]) : null;
1101
+ BEACONS.setHot(b ? b.key : null);
1102
+ if (b) {
1103
+ E.canvas.classList.add('pointing');
1104
+ if (st.hover !== null) { st.hover = null; retarget(); }
1105
+ showTip(null, hoverAt[0], hoverAt[1], findingInfo(b.data)); E.invalidate(); return;
1106
+ }
1107
+ const o = hoverAt ? E.pick(hoverAt[0], hoverAt[1], PICK, pickable) : null;
1108
+ const el = elOf(o), id = el && !(o.userData.floor) ? el.id : null;
1109
+ E.canvas.classList.toggle('pointing', !!id);
1110
+ if (id !== st.hover) { st.hover = id; retarget(); }
1111
+ if (el && !o.userData.floor && hoverAt) showTip(el, hoverAt[0], hoverAt[1]); else hideTip();
1112
+ });
1113
+ };
1114
+ function selFor(el) {
1115
+ if (!el) return null;
1116
+ if (el.kind === 'task') return { kind: 'task', id: el.data.task.id };
1117
+ if (el.kind === 'part') { const p = el.data.part; return p.exec && p.exec.id && !p.planned ? { kind: 'execution', task: el.data.task, id: el.data.task, exec: p.exec.id, shadow: p.kind === 'shadow' } : { kind: 'task', id: el.data.task }; }
1118
+ if (el.kind === 'finish') return { kind: el.id === 'f:final' ? 'final' : el.id === 'f:verification' ? 'verification' : 'integration' };
1119
+ const it = el.data.item; if (!it) return null;
1120
+ if (it.kind === 'review') return { kind: 'final', name: it.review };
1121
+ if (it.kind === 'verification') return { kind: 'execution', task: null, exec: it.id };
1122
+ if (it.ghost || String(it.id).startsWith('span:')) return { kind: 'task', id: it.task };
1123
+ return { kind: 'execution', task: it.task, id: it.task, exec: it.id };
1124
+ }
1125
+ E.handlers.click = (cx, cy) => {
1126
+ hideTip();
1127
+ const b = st.view === 'run' ? BEACONS.pick(cx, cy) : null;
1128
+ if (b) { cb.onFinding && cb.onFinding(b.data); return; }
1129
+ const o = E.pick(cx, cy, PICK, pickable), el = elOf(o);
1130
+ if (o && o.userData.fractal) { cb.onSelect && cb.onSelect({ kind: 'fractal', task: o.userData.fractal.task, node: o.userData.fractal.node }); return; }
1131
+ if (st.view === 'atlas') {
1132
+ if (el && el.kind === 'run') {
1133
+ st.sel = { kind: 'run', key: el.data.run.key }; retarget();
1134
+ const { x, y, w, d, h } = el.n;
1135
+ E.frameBox([x - 20, y - 20, 0, x + w + 20, y + d + 20, h * 2], 600, 40);
1136
+ setTimeout(() => cb.onOpenRun && cb.onOpenRun(el.data.run.key), reduce ? 0 : 520);
1137
+ }
1138
+ return;
1139
+ }
1140
+ if (!el || o.userData.plate) { cb.onSelect && cb.onSelect(null); return; }
1141
+ if (o.userData.floor) { cb.onSelect && cb.onSelect({ kind: 'task', id: el.data.task.id }); return; }
1142
+ cb.onSelect && cb.onSelect(selFor(el));
1143
+ };
1144
+ function order() { return st.plan ? st.plan.modules.filter(m => m.kind === 'task').map(m => m.task.id) : []; }
1145
+ // the map by keyboard: ← → walk the runs, ↑ ↓ jump a repository, Enter opens; the run's card shows where it is
1146
+ const live = document.getElementById('live');
1147
+ function walkAtlas(e) {
1148
+ // in reading order: repository by repository as the plate lays them out, then row by row
1149
+ const A = st.atlas, li = new Map((A ? A.layers : []).map((l, j) => [l.id, j]));
1150
+ const mods = A ? A.modules.filter(m => A.place[m.id] && m.match !== false)
1151
+ .sort((a, b) => (li.get(a.layer) ?? 0) - (li.get(b.layer) ?? 0) || A.place[a.id][1] - A.place[b.id][1] || A.place[a.id][0] - A.place[b.id][0]) : [];
1152
+ if (!mods.length) return false;
1153
+ const cur = st.sel && st.sel.kind === 'run' ? mods.findIndex(m => m.run.key === st.sel.key) : -1;
1154
+ if (e.key === 'Enter') { if (cur < 0) return false; cb.onOpenRun && cb.onOpenRun(mods[cur].run.key); return true; }
1155
+ let i;
1156
+ if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { const d = e.key === 'ArrowRight' ? 1 : -1; i = cur < 0 ? (d > 0 ? 0 : mods.length - 1) : (cur + d + mods.length) % mods.length; }
1157
+ else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
1158
+ const d = e.key === 'ArrowDown' ? 1 : -1, layer = cur < 0 ? null : mods[cur].layer;
1159
+ const starts = mods.map((m, j) => j).filter(j => j === 0 || mods[j].layer !== mods[j - 1].layer);
1160
+ const at = layer === null ? -1 : starts.findIndex(j => mods[j].layer === layer);
1161
+ i = starts[at < 0 ? (d > 0 ? 0 : starts.length - 1) : (at + d + starts.length) % starts.length];
1162
+ } else return false;
1163
+ const m = mods[i]; st.sel = { kind: 'run', key: m.run.key }; retarget();
1164
+ const el = EL.get('r:' + m.id);
1165
+ if (el) {
1166
+ const r = E.canvas.getBoundingClientRect(), p = E.project(el.n.x + el.n.w / 2, el.n.y + el.n.d / 2, el.n.h);
1167
+ if (!p.on) E.frameBox([el.n.x - 60, el.n.y - 60, 0, el.n.x + el.n.w + 60, el.n.y + el.n.d + 60, el.n.h * 2], 500, 30);
1168
+ else showTip(el, r.left + p.x, r.top + p.y);
1169
+ }
1170
+ if (live) live.textContent = `${String(m.run.goal || m.run.run_id || m.run.key).split('\n')[0]}: ${m.state || ''}, ${RM.basename(m.run.repo || '')}, ${i + 1} of ${mods.length}`;
1171
+ return true;
1172
+ }
1173
+ E.handlers.key = e => {
1174
+ if (e.key === 'Escape') { if (st.view === 'run' && st.sel) { cb.onSelect && cb.onSelect(null); return true; } if (st.view === 'run' && cb.onBack) { cb.onBack(); return true; } if (st.view === 'atlas' && st.sel) { st.sel = null; hideTip(); retarget(); return true; } return false; }
1175
+ if (st.view === 'atlas') return walkAtlas(e);
1176
+ if (st.view !== 'run') return false;
1177
+ if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
1178
+ const ids = order(); if (!ids.length) return true;
1179
+ const cur = st.sel && (st.sel.kind === 'task' ? st.sel.id : st.sel.task), d = e.key === 'ArrowRight' ? 1 : -1;
1180
+ const i = ids.indexOf(cur), next = i < 0 ? (d > 0 ? 0 : ids.length - 1) : (i + d + ids.length) % ids.length;
1181
+ cb.onSelect && cb.onSelect({ kind: 'task', id: ids[next] }); return true;
1182
+ }
1183
+ if (e.key === 'e' || e.key === 'E') { cb.onMode && cb.onMode(st.mode === 'plan' ? 'exec' : 'plan'); return true; }
1184
+ return false;
1185
+ };
1186
+
1187
+ // the cloud has its own pointer and keys: while it is shown, the engine's handlers go to it
1188
+ let cloud = null;
1189
+ for (const k of Object.keys(E.handlers)) {
1190
+ const mine = E.handlers[k];
1191
+ E.handlers[k] = (...a) => {
1192
+ if (st.view === 'cloud') return cloud && cloud.handlers[k] ? cloud.handlers[k](...a) : k === 'grab' ? null : false;
1193
+ return mine ? mine(...a) : k === 'grab' ? null : false;
1194
+ };
1195
+ }
1196
+ const leaveCloud = () => { if (cloud) cloud.detach(); };
1197
+
1198
+ /* ---------- public API ---------- */
1199
+ function frameSelection(dur = 850, dir = null) {
1200
+ if (st.view !== 'run') return;
1201
+ const ids = [...selIds()].map(id => EL.get(id)).filter(el => el && el.root.parent && (st.mode === 'plan' ? el.g === st.planG || el.kind === 'part' : el.g === st.execG));
1202
+ if (!ids.length) { E.frameBox(boxOf(st.mode), dur, 26, dir); return; }
1203
+ // a chosen task's route is seen end to end: what it waits on and what waits on it come into the frame
1204
+ if (st.mode === 'plan' && st.routeSet) for (const id of st.routeSet) { const el = EL.get(id); if (el && el.root.parent) ids.push(el); }
1205
+ hideTip();
1206
+ const xs = ids.flatMap(el => [el.n.x, el.n.x + el.n.w]), ys = ids.flatMap(el => [el.n.y, el.n.y + el.n.d]);
1207
+ const hs = Math.max(...ids.map(el => el.n.h));
1208
+ // keep the neighbours in view: the selection fills about half the stage, not all of it
1209
+ const span = Math.max(Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys));
1210
+ const pad = Math.max(st.mode === 'exec' ? 80 : 40, span * .3);
1211
+ const opening = st.mode === 'plan' && ids.some(el => el.kind === 'task');
1212
+ const tree = opening && ids.some(el => FRACTAL.has(el.data && el.data.task && el.data.task.id)) ? hs + 130 : 0; // a Fractal tree floats above
1213
+ E.frameBox([Math.min(...xs) - pad, Math.min(...ys) - pad, 0, Math.max(...xs) + pad, Math.max(...ys) + pad, Math.max(tree, hs * (opening ? 2 : 1.4))], dur, opening ? 52 : 40, dir);
1214
+ }
1215
+
1216
+ /* ---------- the view controls (stagectl.js): fit, zoom, projection, reset, the legend ---------- */
1217
+ const ISO_DIR = new THREE.Vector3(1, 1, 1), TOP_DIR = new THREE.Vector3(0, 1, .002);
1218
+ let proj = 'persp';
1219
+ try { proj = localStorage.getItem('forge-ui-projection') || 'persp'; } catch { /* storage refused: perspective */ }
1220
+ if (!['persp', 'iso', 'top'].includes(proj)) proj = 'persp';
1221
+ if (proj !== 'persp') { E.setOrtho(true); if (proj === 'top') E.camera.position.copy(TOP_DIR).normalize().multiplyScalar(20); }
1222
+ BEACONS.setFlat(proj === 'top');
1223
+ function frameShown(dur = 650, dir = null) {
1224
+ if (st.view === 'run' && st.run) { if (st.sel) frameSelection(dur, dir); else E.frameBox(boxOf(st.mode), dur, 26, dir); }
1225
+ else if (st.view === 'atlas' && st.atlasBox) E.frameBox(st.atlasBox, dur, 30, dir);
1226
+ }
1227
+ function setProjection(p, dur = 650, dir) {
1228
+ const was = proj; proj = ['persp', 'iso', 'top'].includes(p) ? p : 'persp';
1229
+ try { localStorage.setItem('forge-ui-projection', proj); } catch { /* not remembered */ }
1230
+ E.setOrtho(proj !== 'persp'); BEACONS.setFlat(proj === 'top'); syncBeacons();
1231
+ // isometric is the true 35° view; leaving top-down goes back to it
1232
+ frameShown(dur, dir !== undefined ? dir : proj === 'top' ? TOP_DIR : proj === 'iso' || was === 'top' ? ISO_DIR : null);
1233
+ }
1234
+ const fmtMetric = (m, v) => v === null || v === undefined ? '—' : m === 'duration' ? RM.fmtDur(v) : m === 'tokens' ? (v >= 1e6 ? (v / 1e6).toFixed(1) + 'M' : v >= 1e3 ? Math.round(v / 1e3) + 'k' : String(v)) : String(Math.round(v));
1235
+ const modelHex = a => MODEL_PALETTE[[...a].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % MODEL_PALETTE.length];
1236
+ // what the legend says: only what the stage draws now
1237
+ function legendModel() {
1238
+ const L = st.layers, o = st.overlay, out = [];
1239
+ const lamps = { title: 'Lamps', rows: [
1240
+ { sw: { lamp: 'st-merged' }, label: 'merged · passed · accepted' }, { sw: { lamp: 'st-running' }, label: 'running · interrupted' },
1241
+ { sw: { lamp: 'st-fail' }, label: 'failed · conflict · infra · timeout' }, { sw: { lamp: 'st-pending' }, label: 'pending, not started' },
1242
+ { sw: { route: true }, label: st.view === 'atlas' ? 'chosen run' : 'chosen task' + (L.route ? ' and its route' : '') }] };
1243
+ if (st.view === 'atlas') {
1244
+ out.push({ title: 'Map', rows: [], note: 'Each block is a run, each district a repository. The lamps on a block are its tasks; the block stands taller the more tasks it has.' }, lamps);
1245
+ return out;
1246
+ }
1247
+ if (st.view !== 'run') return out;
1248
+ out.push(lamps);
1249
+ if (st.mode === 'exec') out.push({ title: 'Execution', rows: [], note: 'One block per attempt on wall-clock time, in the lane it ran in: the dwarf, then its review.' });
1250
+ const cables = [];
1251
+ if (L.cables) cables.push({ sw: { cable: 'dep' }, label: 'a task waits on the one this comes from' });
1252
+ if (L.cables && L.beads) cables.push({ sw: { bead: true }, label: 'work flowing into a running task' });
1253
+ if (L.cables && L.route) cables.push({ sw: { cable: 'route' }, label: 'route of the chosen task' });
1254
+ if (L.cables && o.critical) cables.push({ sw: { cable: 'critical' }, label: 'critical path: it bounded the wall time' });
1255
+ cables.push({ sw: { cable: 'retry' }, label: 'inside a task: retry, shadow review' }, { sw: { cable: 'escalation' }, label: 'inside a task: escalation to another model' });
1256
+ out.push({ title: 'Cables', rows: cables });
1257
+ const fs = L.beacons && st.findings ? BEACONS.items : [];
1258
+ if (fs.length) {
1259
+ const has = new Set(fs.map(it => it.shape));
1260
+ const rows = [['P0', 'P0 · spike'], ['P1', 'P1 · diamond'], ['P2', 'P2 · cone'], ['P3', 'P3 · pin'], ['none', 'no severity · ring']].filter(([k]) => has.has(k)).map(([k, label]) => ({ sw: { sev: k }, label }));
1261
+ if (fs.some(it => it.plausible)) rows.push({ sw: { sev: 'P2', pale: true }, label: 'plausible: smaller, paler' });
1262
+ out.push({ title: 'Findings', rows, note: 'A click opens the diff at the finding.' });
1263
+ }
1264
+ if (o.arcs && list(st.run.tasks).some(t => t.clef && t.clef.first_pass)) out.push({ title: 'Review marks', rows: [{ sw: { ring: true }, label: 'Clef ring: the chance the first try passes' }] });
1265
+ if (o.height !== 'none') {
1266
+ const mx = st.metricMax && st.metricMax[o.height];
1267
+ out.push({ title: 'Height', rows: [{ sw: { height: true }, label: `${o.height}: up to ${fmtMetric(o.height, mx)}` }] });
1268
+ } else out.push({ title: 'Height', rows: [{ sw: { height: true }, label: 'difficulty' }] });
1269
+ if (o.color === 'model') {
1270
+ const models = [...new Set(list(st.run.tasks).map(t => RM.alias(t.dwarf) || '?'))].sort();
1271
+ out.push({ title: 'Colour: model', rows: models.map(a => ({ sw: { color: modelHex(a) }, label: a })) });
1272
+ } else if (o.color !== 'status') {
1273
+ const mx = st.metricMax && st.metricMax[o.color];
1274
+ out.push({ title: `Colour: ${o.color}`, rows: [{ sw: { scale: [fmtMetric(o.color, 0), fmtMetric(o.color, mx)] }, label: '' }] });
1275
+ }
1276
+ return out;
1277
+ }
1278
+ let CTL = null, ctlQ = 0;
1279
+ const syncCtl = () => { if (CTL && !ctlQ) ctlQ = requestAnimationFrame(() => { ctlQ = 0; CTL.sync(); }); };
1280
+ const api = {
1281
+ showAtlas(runs, filter, opts = {}) {
1282
+ st.runs = list(runs); st.filter = filter || {}; st.atlasOpts = { showAll: !!opts.showAll };
1283
+ const A = RM.atlasModel(st.runs, st.filter, { now: nowSec(), ...st.atlasOpts });
1284
+ const sig = JSON.stringify(A.modules.map(m => [m.id, m.tasks.length, m.layer]));
1285
+ if (st.view !== 'atlas' || sig !== st.atlasSig) {
1286
+ leaveCloud(); st.view = 'atlas'; st.run = null; st.sel = null; clearWorld(); st.atlasSig = sig; st.rebuilds++; buildAtlas();
1287
+ } else updateAtlas();
1288
+ E.canvas.setAttribute('aria-label', `Map of ${A.modules.length} runs, ${A.matches} matching the filter${A.hidden ? `, ${A.hidden} older runs hidden` : ''}`);
1289
+ retarget();
1290
+ return { hidden: A.hidden, total: A.total, matches: A.matches, shown: A.modules.length };
1291
+ },
1292
+ showRun(run, { mode, sel } = {}) {
1293
+ if (mode) st.mode = mode;
1294
+ const key = RM.structureKey(run), first = st.view !== 'run' || !st.run || st.run.key !== run.key;
1295
+ st.run = run; st.replayState = st.replay !== null ? RM.stateAt(run, st.replay) : null;
1296
+ const selChanged = sel !== undefined && JSON.stringify(sel || null) !== JSON.stringify(st.sel || null);
1297
+ if (sel !== undefined) st.sel = sel;
1298
+ if (selChanged && !first && key === st.structure) { api.update(run); retarget(); frameSelection(); return; }
1299
+ if (first || key !== st.structure) {
1300
+ leaveCloud(); st.view = 'run'; st.structure = key; buildRun();
1301
+ if (first) {
1302
+ const b = boxOf(st.mode);
1303
+ if (!reduce) { E.frameBox(b, 0, 26); }
1304
+ if (st.sel) frameSelection(first ? 1100 : 850); else E.frameBox(b, first ? 1000 : 700, 26);
1305
+ }
1306
+ } else api.update(run);
1307
+ E.canvas.setAttribute('aria-label', `Run ${run.run_id || ''}: ${list(run.tasks).length} tasks, ${st.mode === 'plan' ? 'planned waves' : 'execution timeline'}`);
1308
+ retarget();
1309
+ syncEvents();
1310
+ },
1311
+ update(run) {
1312
+ if (st.view !== 'run' || !run || !st.run || run.key !== st.run.key) return;
1313
+ if (RM.structureKey(run) !== st.structure) { api.showRun(run); return; }
1314
+ st.run = run; st.replayState = st.replay !== null ? RM.stateAt(run, st.replay) : null;
1315
+ st.plan.finish = RM.finishState(run);
1316
+ const byId = new Map(list(run.tasks).map(t => [t.id, t]));
1317
+ for (const el of EL.values()) {
1318
+ if (el.kind === 'task') {
1319
+ const t = byId.get(el.data.task.id); if (!t) continue;
1320
+ el.data.task = t; setStatus(el, taskStatus(t)); halo(el);
1321
+ const rows = list(t.attempts).map(a => a.status);
1322
+ if (st.replay === null && t.status === 'RUNNING') rows.push('RUNNING');
1323
+ if (rows.length > el.ledCount) { /* more tries than lamps: the newest show */ }
1324
+ setLeds(el, rows.slice(-el.ledCount));
1325
+ const sig = JSON.stringify([t.status, t.findings, t.gate, list(t.shadow).map(s => s.agreement), t.dwarf, t.accepted_by, el.status]);
1326
+ if (sig !== el.sig) { el.sig = sig; redraw(el); }
1327
+ if (el.inner && RM.innerKey(t) !== el.innerKey) { closeInner(el); buildInner(el); st.rebuilds++; }
1328
+ if (el.kids) for (const k of el.kids) { const np = RM.taskParts(t).parts.find(p => p.id === k.data.part.id); if (np) { k.data.part = np; setStatus(k, np.planned ? 'PENDING' : np.status); redraw(k); } }
1329
+ } else if (el.kind === 'finish') {
1330
+ setStatus(el, finishStatus(el.id, finishInfo(el.id))); halo(el); finishLeds(el);
1331
+ const sig = JSON.stringify([finishInfo(el.id), el.status]); if (sig !== el.sig) { el.sig = sig; redraw(el); }
1332
+ }
1333
+ }
1334
+ const X = RM.execModel(run, { now: nowSec(), ghosts: st.overlay.ghosts, scale: st.exec && { t0: st.exec.t0, t1: st.exec.t1 } });
1335
+ // the hidden view is rebuilt when it is next shown, not on every poll that changes its shape
1336
+ if (execSig(X) !== st.execSig) { if (st.mode === 'exec' || st.fade) rebuildExec(); else st.execDirty = true; }
1337
+ else {
1338
+ st.exec.items = X.items; st.exec.extra = X.extra; st.exec.live = X.live;
1339
+ const byItem = new Map(X.items.concat(X.extra).map(i => [i.id, i]));
1340
+ for (const el of EL.values()) if (el.data && el.data.item) {
1341
+ const it = byItem.get(el.data.item.id); if (!it) continue;
1342
+ const grew = Math.abs((it.end - it.start) - (el.data.item.end - el.data.item.start)) * X.k > 4;
1343
+ el.data.item = it; setStatus(el, st.replay !== null ? replayItemStatus(it) : it.status); halo(el);
1344
+ const sig = JSON.stringify([it.status, it.segs.map(s => [s.status, Math.round(s.end || 0)]), it.role]);
1345
+ if (grew) { rebuildItem(el, it); } else if (sig !== el.sig) { el.sig = sig; redraw(el); }
1346
+ }
1347
+ }
1348
+ computeCritical();
1349
+ retarget();
1350
+ syncEvents();
1351
+ },
1352
+ setMode(mode) {
1353
+ if (mode === st.mode || st.view !== 'run') { st.mode = mode; return; }
1354
+ const from = st.mode; st.mode = mode;
1355
+ if (mode === 'exec' && st.execDirty) { st.execDirty = false; rebuildExec(); }
1356
+ // where each task travels: its plan block ↔ its latest attempt on the time axis
1357
+ const off = new Map();
1358
+ for (const el of EL.values()) {
1359
+ if (el.kind !== 'task') continue;
1360
+ const its = [...EL.values()].filter(x => x.data && x.data.item && x.data.item.task === el.data.task.id);
1361
+ const last = its.sort((a, b) => a.data.item.start - b.data.item.start).pop(); if (!last) continue;
1362
+ const dx = (last.n.x + last.n.w / 2) - (el.n.x + el.n.w / 2), dy = (last.n.y + last.n.d / 2) - (el.n.y + el.n.d / 2);
1363
+ off.set(el.id, W(dx, dy, 0));
1364
+ for (const x of its) off.set(x.id, W(-dx, -dy, 0));
1365
+ }
1366
+ st.planG.visible = st.execG.visible = true;
1367
+ for (const el of EL.values()) if (el.kind === 'task') { const pr = PROG.get(el.id); if (pr) pr.t = 0; }
1368
+ st.fade = reduce ? null : { t0: performance.now(), dur: 900, from, off };
1369
+ if (reduce) { st.planG.visible = mode === 'plan'; st.execG.visible = mode === 'exec'; }
1370
+ st.plan.plateG.visible = mode === 'plan'; st.exec.plateG.visible = mode === 'exec';
1371
+ retarget();
1372
+ if (st.sel) frameSelection(900); else E.frameBox(boxOf(mode), 900, 26);
1373
+ E.invalidate(950);
1374
+ },
1375
+ // RunFindings of the run shown (GET /api/runs/<key>/findings): a mark per finding
1376
+ setFindings(data) { if ((data || null) === st.findings) return; st.findings = data || null; syncBeacons(); E.invalidate(200); },
1377
+ // {cables, beads, lamps, roof, labels, beacons, finish, districts, halos}: what the stage draws
1378
+ setLayers(L) { Object.assign(st.layers, L || {}); applyLayers(); if ('beacons' in (L || {}) || 'finish' in (L || {})) syncBeacons(); retarget(); },
1379
+ get layers() { return { ...st.layers }; },
1380
+ setOverlay(o) { Object.assign(st.overlay, o || {}); if (st.view === 'run') { if ('ghosts' in (o || {})) rebuildExec(); computeCritical(); for (const el of EL.values()) redraw(el); } retarget(); },
1381
+ setReplay(t) {
1382
+ const from = st.replay;
1383
+ st.replay = t === null || t === undefined ? null : Number(t);
1384
+ if (st.view !== 'run') return;
1385
+ E.invalidate(100); // marks a replay reaches drop in
1386
+ st.replayState = st.replay !== null ? RM.stateAt(st.run, st.replay) : null;
1387
+ for (const el of EL.values()) {
1388
+ if (el.kind === 'task') setStatus(el, taskStatus(el.data.task));
1389
+ else if (el.kind === 'finish') setStatus(el, finishStatus(el.id, finishInfo(el.id)));
1390
+ else if (el.data && el.data.item) setStatus(el, st.replay !== null ? replayItemStatus(el.data.item) : el.data.item.status);
1391
+ else continue;
1392
+ halo(el); if (el.kind === 'finish') finishLeds(el);
1393
+ const sig = 'replay:' + el.status; if (sig !== el.sig) { el.sig = sig; redraw(el); }
1394
+ }
1395
+ placeNow(); retarget();
1396
+ // playing forward: what the replay just passed happens again; a jump across the run plays nothing
1397
+ const R = st.run && RM.timeRange(st.run, nowSec());
1398
+ if (st.replay === null) evAt = null;
1399
+ else syncEvents({ replayFrom: from !== null && R && st.replay - from <= .1 * Math.max(1, R.end - R.start) ? from : null });
1400
+ },
1401
+ select(s, { frame = true } = {}) {
1402
+ const same = JSON.stringify(s || null) === JSON.stringify(st.sel || null);
1403
+ st.sel = s || null; retarget();
1404
+ if (frame && !same && st.view === 'run') frameSelection();
1405
+ },
1406
+ // runs: every RunSummary; opts: {filter, layout, shape, sel, focus} (see cloud3d.js)
1407
+ showCloud(runs, opts = {}) {
1408
+ if (st.view !== 'cloud') { clearWorld(); st.view = 'cloud'; st.run = null; st.sel = null; st.atlasSig = ''; st.structure = ''; }
1409
+ if (!cloud) cloud = createCloud(E, host, cb.cloud || {});
1410
+ const c = cloud.show(list(runs), opts); syncCtl();
1411
+ E.canvas.setAttribute('aria-label', `Cloud of runs: ${c.visible} of ${c.total} runs in ${c.projects} projects, ${c.layout} layout`);
1412
+ return c;
1413
+ },
1414
+ get cloud() { return cloud; },
1415
+ // a task's Fractal tree (TaskFractal, or null), drawn over the task while it is open
1416
+ setFractal(taskId, data) {
1417
+ if (data) FRACTAL.set(taskId, data); else FRACTAL.delete(taskId);
1418
+ const el = EL.get('t:' + taskId); if (el && el.inner) { syncFractal(el); E.invalidate(300); }
1419
+ },
1420
+ focus() { E.canvas.focus({ preventScroll: true }); },
1421
+ // frame again what is shown, in the part of the stage the modules leave free (after a module opens, folds or resizes)
1422
+ reframe(dur = 500) {
1423
+ if (st.view === 'run' && st.run) { if (st.sel) frameSelection(dur); else E.frameBox(boxOf(st.mode), dur, 26); }
1424
+ else if (st.view === 'atlas' && st.atlasBox) E.frameBox(st.atlasBox, dur, 30);
1425
+ else if (st.view === 'cloud' && cloud && cloud.frameAll) cloud.frameAll();
1426
+ },
1427
+ get mode() { return st.mode; },
1428
+ get view() { return st.view; },
1429
+ dispose() { if (CTL) CTL.dispose(); E.dispose(); tip.remove(); },
1430
+ debug() {
1431
+ return {
1432
+ view: st.view, mode: st.mode, rebuilds: st.rebuilds, sel: st.sel, prog: [...PROG].map(([id, p]) => [id, p.p, p.t]), fade: !!st.fade,
1433
+ nodes: [...EL.values()].map(el => ({ id: el.id, kind: el.kind, status: el.status, sel: el.tgt.sel, vis: el.tgt.vis })),
1434
+ screenPos(id) { const el = EL.get(id); if (!el) return null; const r = E.canvas.getBoundingClientRect(); const p = E.project(el.n.x + el.n.w / 2, el.n.y + el.n.d / 2, el.n.h); return { x: r.left + p.x, y: r.top + p.y, on: p.on }; },
1435
+ settle() { for (const el of EL.values()) Object.assign(el.cur, el.tgt); for (const pr of PROG.values()) pr.p = pr.t; E.invalidate(); },
1436
+ beaconScale: () => ({ unit: BEACONS.unit, ppu: E.pxPerUnit() }),
1437
+ projection: () => ({ proj, ortho: E.isOrtho() }),
1438
+ pickBeacon: (x, y) => { const b = BEACONS.pick(x, y); return b ? b.key : null; },
1439
+ // the finding marks drawn now, each with where it is on the screen
1440
+ beacons() {
1441
+ const r = E.canvas.getBoundingClientRect();
1442
+ return BEACONS.items.map(it => { if (!it.anchor() || it.hidden || !it.head) return { key: it.key, id: it.data.finding.id, shown: false };
1443
+ const p = E.project(...it.head); return { key: it.key, id: it.data.finding.id, sev: it.sev, shown: true, x: r.left + p.x, y: r.top + p.y, on: p.on }; });
1444
+ },
1445
+ };
1446
+ },
1447
+ };
1448
+ function replayItemStatus(it) {
1449
+ const t = st.replay; if (it.ghost) return 'PENDING';
1450
+ if (t < it.start) return 'PENDING';
1451
+ if (t < it.end && (it.running || it.end > t)) return 'RUNNING';
1452
+ return it.status;
1453
+ }
1454
+ function rebuildItem(el, it) {
1455
+ const g = el.g;
1456
+ EL.delete(el.id); PICK = PICK.filter(o => o.userData.el !== el.id);
1457
+ E.disposeGroup(el.root);
1458
+ const X = st.exec, k = X.k;
1459
+ it.x = X.x(it.start); it.w = Math.max(22, (it.end - it.start) * k);
1460
+ const nel = buildAttempt(it, g);
1461
+ Object.assign(nel.cur, el.cur);
1462
+ }
1463
+ CTL = createControls(host, {
1464
+ view: () => st.view, fit: () => frameShown(650), zoom: k => E.zoomBy(k),
1465
+ get projection() { return proj; }, setProjection,
1466
+ reset: () => setProjection('persp', 700, ISO_DIR),
1467
+ legend: legendModel,
1468
+ toggleRoute: () => { api.setLayers({ route: !st.layers.route }); if (cb.onLayers) cb.onLayers({ ...st.layers }); },
1469
+ });
1470
+ return api;
1471
+ }