@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.
- package/CHANGELOG.md +205 -0
- package/LICENSE +21 -0
- package/README.md +194 -2
- package/SKILL.md +123 -0
- package/VERSION +1 -0
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +69 -0
- package/docs/commands.md +315 -0
- package/docs/dataset.md +20 -0
- package/docs/decomposed-runs.md +268 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +92 -0
- package/docs/fractal.md +181 -0
- package/docs/install.md +160 -0
- package/docs/memory.md +156 -0
- package/docs/models.md +118 -0
- package/docs/reviews.md +231 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +79 -0
- package/package.json +27 -4
- package/references/dataset.md +329 -0
- package/references/decompose.md +667 -0
- package/references/fractal-dashboard.md +21 -0
- package/references/fractal.md +307 -0
- package/references/harnesses.md +365 -0
- package/references/jev.md +865 -0
- package/references/memory.md +228 -0
- package/references/planning.md +42 -0
- package/references/ripwire.md +47 -0
- package/references/runtime.md +64 -0
- package/references/solo.md +113 -0
- package/references/ui.md +112 -0
- package/registry.tsv +81 -0
- package/scripts/forge-artifact.sh +78 -0
- package/scripts/forge-contract.py +430 -0
- package/scripts/forge-dataset.py +6 -0
- package/scripts/forge-dispatch.sh +671 -0
- package/scripts/forge-fail-first.py +195 -0
- package/scripts/forge-fractal-dispatch.sh +4 -0
- package/scripts/forge-fractal-options.sh +54 -0
- package/scripts/forge-fractal.py +6 -0
- package/scripts/forge-guard.py +266 -0
- package/scripts/forge-install-fractal.sh +4 -0
- package/scripts/forge-install-ripwire.sh +52 -0
- package/scripts/forge-install.sh +135 -0
- package/scripts/forge-jev-options.sh +259 -0
- package/scripts/forge-jev.py +6 -0
- package/scripts/forge-lib-plans.sh +785 -0
- package/scripts/forge-lib-review.sh +494 -0
- package/scripts/forge-lib-task.sh +302 -0
- package/scripts/forge-lib-verify.sh +30 -0
- package/scripts/forge-memory.sh +575 -0
- package/scripts/forge-metrics.sh +39 -0
- package/scripts/forge-parallel-options.sh +76 -0
- package/scripts/forge-parallel.sh +1717 -0
- package/scripts/forge-plan-check.py +118 -0
- package/scripts/forge-prompt.py +45 -0
- package/scripts/forge-ripwire.py +136 -0
- package/scripts/forge-route.py +344 -0
- package/scripts/forge-runs.py +121 -0
- package/scripts/forge-runtime.py +586 -0
- package/scripts/forge-schedule.py +312 -0
- package/scripts/forge-shadow.py +305 -0
- package/scripts/forge-solo.sh +347 -0
- package/scripts/forge-ui-options.sh +42 -0
- package/scripts/forge-ui.py +435 -0
- package/scripts/forge-update.sh +71 -0
- package/scripts/forge-verify-coverage.py +956 -0
- package/scripts/forge-version.py +240 -0
- package/scripts/forge_dataset/__init__.py +439 -0
- package/scripts/forge_dataset/capture.py +330 -0
- package/scripts/forge_dataset/checks.py +109 -0
- package/scripts/forge_dataset/cli.py +233 -0
- package/scripts/forge_dataset/export.py +1009 -0
- package/scripts/forge_dataset/harvest.py +716 -0
- package/scripts/forge_dataset/importer.py +412 -0
- package/scripts/forge_dataset/outcomes.py +275 -0
- package/scripts/forge_dataset/routing.py +278 -0
- package/scripts/forge_fractal/__init__.py +84 -0
- package/scripts/forge_fractal/bridge.py +46 -0
- package/scripts/forge_fractal/cli.py +202 -0
- package/scripts/forge_fractal/dashboard.css +138 -0
- package/scripts/forge_fractal/dashboard.html +41 -0
- package/scripts/forge_fractal/dashboard.js +687 -0
- package/scripts/forge_fractal/decomposition.py +262 -0
- package/scripts/forge_fractal/execution.py +659 -0
- package/scripts/forge_fractal/inspection.py +261 -0
- package/scripts/forge_fractal/install.py +158 -0
- package/scripts/forge_fractal/invocation.py +94 -0
- package/scripts/forge_fractal/pipeline.py +124 -0
- package/scripts/forge_fractal/selection.py +179 -0
- package/scripts/forge_jev/__init__.py +376 -0
- package/scripts/forge_jev/backtest.py +322 -0
- package/scripts/forge_jev/cli.py +980 -0
- package/scripts/forge_jev/client.py +418 -0
- package/scripts/forge_jev/corpus.py +156 -0
- package/scripts/forge_jev/coupling.py +244 -0
- package/scripts/forge_jev/features.py +24 -0
- package/scripts/forge_jev/gates.py +100 -0
- package/scripts/forge_jev/memory.py +163 -0
- package/scripts/forge_jev/outcome.py +49 -0
- package/scripts/forge_jev/predict.py +204 -0
- package/scripts/forge_jev/questions.py +661 -0
- package/scripts/forge_jev/review.py +73 -0
- package/scripts/forge_jev/routing.py +271 -0
- package/scripts/forge_jev/subset.py +123 -0
- package/scripts/forge_jev/verify.py +453 -0
- package/scripts/forge_registry.py +140 -0
- package/ui/README.md +347 -0
- package/ui/THIRD_PARTY_NOTICES.md +177 -0
- package/ui/docs/api.md +970 -0
- package/ui/forge_ui/__init__.py +20 -0
- package/ui/forge_ui/__main__.py +135 -0
- package/ui/forge_ui/diffs.py +454 -0
- package/ui/forge_ui/executions.py +436 -0
- package/ui/forge_ui/export.py +214 -0
- package/ui/forge_ui/findings.py +132 -0
- package/ui/forge_ui/fractal.py +582 -0
- package/ui/forge_ui/models.py +216 -0
- package/ui/forge_ui/reader.py +1424 -0
- package/ui/forge_ui/server.py +312 -0
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +1079 -0
- package/ui/forge_ui/static/cloud3d.js +647 -0
- package/ui/forge_ui/static/cloudmodel.js +375 -0
- package/ui/forge_ui/static/cloudui.js +397 -0
- package/ui/forge_ui/static/convo.js +295 -0
- package/ui/forge_ui/static/datatable.js +232 -0
- package/ui/forge_ui/static/diffview.js +235 -0
- package/ui/forge_ui/static/favicon.svg +1 -0
- package/ui/forge_ui/static/files.js +776 -0
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/gantt.js +97 -0
- package/ui/forge_ui/static/graph.js +266 -0
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +67 -0
- package/ui/forge_ui/static/iso.js +816 -0
- package/ui/forge_ui/static/leaderboard.js +311 -0
- package/ui/forge_ui/static/map3d.js +45 -0
- package/ui/forge_ui/static/modelstats.js +249 -0
- package/ui/forge_ui/static/notify.js +179 -0
- package/ui/forge_ui/static/panel.js +562 -0
- package/ui/forge_ui/static/runmodel.js +667 -0
- package/ui/forge_ui/static/scene3d.js +1471 -0
- package/ui/forge_ui/static/search.js +120 -0
- package/ui/forge_ui/static/shell.js +137 -0
- package/ui/forge_ui/static/stagectl.js +150 -0
- package/ui/forge_ui/static/style.css +1073 -0
- package/ui/forge_ui/static/theme.js +25 -0
- 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
|
+
}
|