@giamme/forge 0.8.0 → 0.10.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 +50 -0
- package/README.md +106 -1766
- package/SKILL.md +22 -3
- package/VERSION +1 -1
- package/docs/README.md +26 -0
- package/docs/add-ons.md +33 -0
- package/docs/artifacts.md +84 -0
- package/docs/commands.md +365 -0
- package/docs/dataset.md +29 -0
- package/docs/decomposed-runs.md +310 -0
- package/docs/development.md +95 -0
- package/docs/forge-ui.md +128 -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 +245 -0
- package/docs/safety.md +55 -0
- package/docs/troubleshooting.md +54 -0
- package/forge +20 -0
- package/package.json +3 -2
- package/references/dataset.md +18 -1
- package/references/decompose.md +20 -2
- package/references/fractal.md +1 -1
- package/references/harnesses.md +18 -6
- package/references/solo.md +1 -1
- package/references/ui.md +27 -5
- package/scripts/forge-config.py +347 -0
- package/scripts/forge-control.py +645 -0
- package/scripts/forge-dispatch.sh +7 -1
- package/scripts/forge-issues.py +806 -0
- package/scripts/forge-lib-task.sh +234 -35
- package/scripts/forge-parallel-options.sh +3 -1
- package/scripts/forge-parallel.sh +3 -2
- package/scripts/forge-route.py +184 -27
- package/scripts/forge-runs-rm.py +840 -0
- package/scripts/forge-runs.py +139 -0
- package/scripts/forge-runtime.py +59 -6
- package/scripts/forge-schedule.py +227 -11
- package/scripts/forge-ui.py +85 -19
- package/scripts/forge-version.py +54 -12
- package/scripts/forge_dataset/__init__.py +77 -6
- package/scripts/forge_dataset/capture.py +5 -2
- package/scripts/forge_dataset/harvest.py +23 -1
- package/scripts/forge_dataset/importer.py +420 -18
- package/scripts/forge_fractal/__init__.py +2 -1
- package/scripts/forge_registry.py +168 -16
- package/ui/README.md +116 -23
- package/ui/THIRD_PARTY_NOTICES.md +108 -0
- package/ui/docs/api.md +283 -23
- package/ui/forge_ui/__main__.py +10 -2
- package/ui/forge_ui/control.py +575 -0
- package/ui/forge_ui/export.py +33 -9
- package/ui/forge_ui/fractal.py +1 -2
- package/ui/forge_ui/reader.py +121 -8
- package/ui/forge_ui/server.py +135 -5
- package/ui/forge_ui/static/D-DINCondensed-Bold.woff2 +0 -0
- package/ui/forge_ui/static/app.js +358 -131
- package/ui/forge_ui/static/cloud3d.js +3 -2
- package/ui/forge_ui/static/cloudui.js +8 -5
- package/ui/forge_ui/static/controls.js +619 -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 +29 -10
- package/ui/forge_ui/static/findings3d.js +134 -0
- package/ui/forge_ui/static/findingsview.js +412 -0
- package/ui/forge_ui/static/graph.js +3 -3
- package/ui/forge_ui/static/icons.js +94 -0
- package/ui/forge_ui/static/index.html +31 -13
- package/ui/forge_ui/static/iso.js +70 -19
- package/ui/forge_ui/static/leaderboard.js +44 -48
- package/ui/forge_ui/static/panel.js +92 -36
- package/ui/forge_ui/static/rundelete.js +388 -0
- package/ui/forge_ui/static/runmodel.js +17 -1
- package/ui/forge_ui/static/scene3d.js +518 -43
- package/ui/forge_ui/static/search.js +2 -1
- package/ui/forge_ui/static/settings.js +399 -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 +1099 -469
- package/ui/forge_ui/static/theme.js +25 -0
- package/ui/forge_ui/static/uikit.js +161 -0
- package/ui/forge_ui/transcripts.py +608 -0
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
All text from the API is drawn on canvases or set with textContent, never parsed as HTML. */
|
|
10
10
|
import { createEngine } from './iso.js';
|
|
11
11
|
import { createCloud } from './cloud3d.js';
|
|
12
|
+
import { createBeacons } from './findings3d.js';
|
|
13
|
+
import { createControls } from './stagectl.js';
|
|
12
14
|
|
|
13
15
|
const RM = window.ForgeRunModel;
|
|
14
16
|
const list = v => Array.isArray(v) ? v : [];
|
|
@@ -17,7 +19,6 @@ const clamp = v => Math.max(0, Math.min(1, v));
|
|
|
17
19
|
const segT = (v, a, b) => clamp((v - a) / (b - a));
|
|
18
20
|
const ez = t => t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
19
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);
|
|
20
|
-
const SEV_STATUS = { P0: 'fail', P1: 'conflict', P2: 'timeout', P3: 'unknown' };
|
|
21
22
|
const MODEL_PALETTE = ['#4e9af1', '#f08c3a', '#6cc070', '#c46fd8', '#e0c341', '#4fc2c0', '#e06a8c', '#9a8cff', '#a3b84a', '#d98f6b'];
|
|
22
23
|
const KIND_H = { dwarf: 24, qa: 13, shadow: 12 };
|
|
23
24
|
|
|
@@ -31,15 +32,19 @@ export async function createStage(host, cb = {}) {
|
|
|
31
32
|
|
|
32
33
|
const st = { view: null, mode: 'plan', run: null, runs: [], filter: {}, atlasOpts: {}, sel: null, hover: null, replay: null,
|
|
33
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,
|
|
34
39
|
structure: '', execSig: '', atlasSig: '', rebuilds: 0, fade: null, plan: null, exec: null, atlas: null, crit: null, now: Date.now() / 1000 };
|
|
35
40
|
let world = null; // the group of the current view
|
|
36
|
-
let EL = new Map(), PICK = [], FLOWS = [], PROG = new Map();
|
|
41
|
+
let EL = new Map(), PICK = [], FLOWS = [], PROG = new Map(), DISTRICTS = [], EXEC_BY = null;
|
|
37
42
|
const nowSec = () => st.run ? RM.liveNow(st.run, Date.now() / 1000) : Date.now() / 1000;
|
|
38
43
|
|
|
39
44
|
/* ---------- elements: a block, a part or a lane item, each with its own materials ---------- */
|
|
40
45
|
function newEl(id, kind, g, n, data) {
|
|
41
46
|
const el = { id, kind, g, n, data, status: 'PENDING', col: E.statusColor('pending'), root: new THREE.Group(), cover: new THREE.Group(),
|
|
42
|
-
M: E.mats(), cur: { vis: 1, sel: 0, hov: 0, lit: 0, op: 1, opB: 1, tint: 0, halo: 0, hk: 1 }, tgt: { vis: 1, sel: 0, hov: 0, lit: 0, op: 1, opB: 1, tint: 0, halo: 0, hk: 1 },
|
|
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 },
|
|
43
48
|
texts: [], sig: '', pulse: false, offset: new THREE.Vector3(), fadeK: 1 };
|
|
44
49
|
el.root.add(el.cover); g.add(el.root); EL.set(id, el);
|
|
45
50
|
return el;
|
|
@@ -51,7 +56,7 @@ export async function createStage(host, cb = {}) {
|
|
|
51
56
|
E.roofPlate(x + 1, y + 1, z + h + .08, w - 2, d - 2, r - 1, el.tintMat, el.cover);
|
|
52
57
|
el.haloMat = E.softMat(0xffffff, true);
|
|
53
58
|
const hm = E.noRay(new THREE.Mesh(new THREE.PlaneGeometry((w + 80) * TS, (d + 80) * TS), el.haloMat));
|
|
54
|
-
hm.rotation.x = -Math.PI / 2; hm.position.copy(W(x + w / 2, y + d / 2, z + .5)); el.root.add(hm);
|
|
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;
|
|
55
60
|
return m;
|
|
56
61
|
}
|
|
57
62
|
// a row of status lamps on a roof: one geometry, a colour per lamp
|
|
@@ -66,15 +71,15 @@ export async function createStage(host, cb = {}) {
|
|
|
66
71
|
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
|
|
67
72
|
geo.setAttribute('color', new THREE.Float32BufferAttribute(col, 3));
|
|
68
73
|
const mat = new THREE.MeshBasicMaterial({ vertexColors: true, toneMapped: false, transparent: true, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -12 });
|
|
69
|
-
|
|
70
|
-
el.leds = { geo, n };
|
|
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 };
|
|
71
76
|
}
|
|
72
77
|
function setLeds(el, statuses) {
|
|
73
78
|
if (!el.leds) return;
|
|
74
79
|
const a = el.leds.geo.attributes.color;
|
|
75
80
|
for (let i = 0; i < el.leds.n; i++) {
|
|
76
81
|
const s = statuses[i];
|
|
77
|
-
const c = s ? E.
|
|
82
|
+
const c = !s ? E.DETAIL : s.isColor ? s : E.statusColor(s); // a state, or a colour of its own (a severity)
|
|
78
83
|
for (let k = 0; k < 6; k++) a.setXYZ(i * 6 + k, c.r, c.g, c.b);
|
|
79
84
|
}
|
|
80
85
|
a.needsUpdate = true;
|
|
@@ -88,11 +93,11 @@ export async function createStage(host, cb = {}) {
|
|
|
88
93
|
function baseTargets(el) {
|
|
89
94
|
const T = el.tgt, s = el.status, light = E.theme === 'light';
|
|
90
95
|
T.lit = PLAIN.has(s) ? 0 : .32;
|
|
91
|
-
T.tint = PLAIN.has(s) ? 0 : light ? .
|
|
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
|
|
92
97
|
T.vis = 1; T.op = 1; T.opB = 1;
|
|
93
98
|
if (s === 'PENDING' || s === 'NONE') { T.opB = .55; T.op = .8; }
|
|
94
99
|
// parts are small: their status has to read from the colour of the whole piece
|
|
95
|
-
if (el.kind === 'part' && !PLAIN.has(s)) { T.tint = light ? .
|
|
100
|
+
if (el.kind === 'part' && !PLAIN.has(s)) { T.tint = light ? .5 : .32; T.lit = .55; }
|
|
96
101
|
}
|
|
97
102
|
// a label plane on the roof or the front, redrawn only when what it shows changes
|
|
98
103
|
function label(el, w, h, P, draw, where, at) {
|
|
@@ -111,7 +116,7 @@ export async function createStage(host, cb = {}) {
|
|
|
111
116
|
return w;
|
|
112
117
|
}
|
|
113
118
|
function display(el, text, { w, x, y, z }) {
|
|
114
|
-
return label(el, w, 16, 4, (cx, s, C) => {
|
|
119
|
+
return el.disp = label(el, w, 16, 4, (cx, s, C) => {
|
|
115
120
|
cx.fillStyle = C.glassOff; cx.strokeStyle = el.cur.sel > .5 ? C.acc : C.detail; cx.lineWidth = Math.max(1, .5 * s);
|
|
116
121
|
E.rr(cx, s, .5, .5, w - 1, 15, 3); cx.fill(); cx.stroke();
|
|
117
122
|
cx.fillStyle = E.statusHex(el.status); cx.beginPath(); cx.arc(7 * s, 8 * s, 2.6 * s, 0, 7); cx.fill();
|
|
@@ -135,7 +140,7 @@ export async function createStage(host, cb = {}) {
|
|
|
135
140
|
E.font(x, f, s); x.letterSpacing = `${(f * .14 * s).toFixed(1)}px`; x.fillStyle = dd.warn ? E.statusHex('fail') : C.ink;
|
|
136
141
|
x.fillText(name, 0, f * 1.05 * s, tw * s); x.letterSpacing = '0px';
|
|
137
142
|
}, plateG);
|
|
138
|
-
E.roofAt(t, dd.x + 18, dd.y + 12, 0);
|
|
143
|
+
E.roofAt(t, dd.x + 18, dd.y + 12, 0); DISTRICTS.push(t);
|
|
139
144
|
}
|
|
140
145
|
return plateG;
|
|
141
146
|
}
|
|
@@ -190,7 +195,8 @@ export async function createStage(host, cb = {}) {
|
|
|
190
195
|
display(el, e => runInfo(e.data.run), { w: w - 24, x: x + 12, y: y + d, z: h - 8 });
|
|
191
196
|
halo(el);
|
|
192
197
|
}
|
|
193
|
-
|
|
198
|
+
st.atlasBox = [-34, -34, -16, A.px + 34, A.py + 34, 80];
|
|
199
|
+
E.frameBox(st.atlasBox, st.atlasFramed ? 700 : 1400, 30);
|
|
194
200
|
st.atlasFramed = true;
|
|
195
201
|
}
|
|
196
202
|
function updateAtlas() {
|
|
@@ -240,11 +246,11 @@ export async function createStage(host, cb = {}) {
|
|
|
240
246
|
let bx = 2;
|
|
241
247
|
const f = tk.findings;
|
|
242
248
|
if (f && (f.confirmed || f.plausible)) {
|
|
243
|
-
for (const sev of ['P0', 'P1', 'P2', 'P3']) if (f[sev]) bx += chip(cx, s, C, bx, 64, `${sev}×${f[sev]}`, E.
|
|
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;
|
|
244
250
|
if (f.unlabelled) bx += chip(cx, s, C, bx, 64, `?×${f.unlabelled}`, E.statusHex('unknown')) + 3;
|
|
245
251
|
}
|
|
246
252
|
if (tk.gate && tk.gate.decision === 'accept') bx += chip(cx, s, C, bx, 64, 'gate ok', E.statusHex('pass')) + 3;
|
|
247
|
-
if (st.overlay.arcs && list(tk.shadow).some(x => x.agreement === 'disagree')) bx += chip(cx, s, C, bx, 64, '
|
|
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;
|
|
248
254
|
if (tk.accepted_by) chip(cx, s, C, bx, 64, 'accepted', E.statusHex('nochanges'));
|
|
249
255
|
}, 'cover', { x: x + 12, y: y + 8, z: h });
|
|
250
256
|
const tries = Math.max(1, list(t.attempts).length);
|
|
@@ -285,7 +291,7 @@ export async function createStage(host, cb = {}) {
|
|
|
285
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([]); }
|
|
286
292
|
E.font(cx, 15, s, 600); cx.fillStyle = sel ? C.accHi : C.inkHi; cx.fillText(fi.title || '', 4 * s, 17 * s);
|
|
287
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);
|
|
288
|
-
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.
|
|
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;
|
|
289
295
|
if (fi.findings.plausible) chip(cx, s, C, bx, 46, `~${fi.findings.plausible}`, E.statusHex('pending'), { fill: false }); }
|
|
290
296
|
}, 'cover', { x: x + 12, y: y + 8, z: h });
|
|
291
297
|
if (m.id === 'f:final') { leds(el, 4, x + 18, y + d - 13, h + .16, { step: 11, s: 3 }); }
|
|
@@ -301,7 +307,7 @@ export async function createStage(host, cb = {}) {
|
|
|
301
307
|
return info.status || 'PENDING';
|
|
302
308
|
}
|
|
303
309
|
function finishLeds(el) {
|
|
304
|
-
if (el.id === 'f:final') { const f = finishInfo(el.id).findings || {}; setLeds(el, ['P0', 'P1', 'P2', 'P3'].map(s => f[s] ?
|
|
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)); }
|
|
305
311
|
else if (el.id === 'f:integration') setLeds(el, list(st.run.tasks).map(t => taskStatus(t) === 'MERGED' ? 'MERGED' : null));
|
|
306
312
|
else setLeds(el, list(st.run.verification && st.run.verification.runs).map(r => r.exit === 0 ? 'PASS' : 'FAIL'));
|
|
307
313
|
}
|
|
@@ -361,7 +367,7 @@ export async function createStage(host, cb = {}) {
|
|
|
361
367
|
const sp = E.fit(pt.spec || '—', cw - 2 * pad, ch - f1 * 1.6 - pad * 2, 6, 2, 2.5);
|
|
362
368
|
if (sp) E.lines(cx, s, sp, pad, pad + f1 * 1.35, Cc.ink, cw - 2 * pad, 400);
|
|
363
369
|
const fs = pt.exec && pt.exec.findings || pt.shadow && pt.shadow.findings;
|
|
364
|
-
if (fs && fs.worst) chip(cx, s, Cc, pad, ch - pad - 6 * 1.6, `${fs.worst}×${fs[fs.worst]}`, E.
|
|
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) });
|
|
365
371
|
}, 'cover', { x: zx, y: n.y, z: ph });
|
|
366
372
|
el.kids.push(k);
|
|
367
373
|
});
|
|
@@ -386,6 +392,7 @@ export async function createStage(host, cb = {}) {
|
|
|
386
392
|
el.innerFlows.push(f);
|
|
387
393
|
}
|
|
388
394
|
syncFractal(el);
|
|
395
|
+
applyLayers();
|
|
389
396
|
}
|
|
390
397
|
/* ---------- Fractal: an opened task's child tasks, a small tree floating over it ---------- */
|
|
391
398
|
const FRACTAL = new Map(); // task id → TaskFractal
|
|
@@ -499,7 +506,7 @@ export async function createStage(host, cb = {}) {
|
|
|
499
506
|
E.rr(cx, s, 1 + sx, by, Math.min(sw, lw - 2 - sx), 8, 2); cx.fill(); cx.globalAlpha = 1;
|
|
500
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); }
|
|
501
508
|
}
|
|
502
|
-
if (item.findings && item.findings.worst) chip(cx, s, C, 1, by - 12, `${item.findings.worst}×${item.findings[item.findings.worst]}`, E.
|
|
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 });
|
|
503
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([]); }
|
|
504
511
|
}, 'cover', { x: it.x + 5, y: it.y + 6, z: h });
|
|
505
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 });
|
|
@@ -507,12 +514,181 @@ export async function createStage(host, cb = {}) {
|
|
|
507
514
|
return el;
|
|
508
515
|
}
|
|
509
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
|
+
|
|
510
685
|
/* ---------- building a view ---------- */
|
|
511
686
|
function clearWorld() {
|
|
512
687
|
if (world) E.disposeGroup(world);
|
|
513
688
|
world = new THREE.Group(); E.scene.add(world);
|
|
514
|
-
EL = new Map(); PICK = []; FLOWS = []; PROG = new Map();
|
|
689
|
+
EL = new Map(); PICK = []; FLOWS = []; PROG = new Map(); DISTRICTS = []; EXEC_BY = null; TAGS.clear(); tagKey = '';
|
|
515
690
|
st.plan = st.exec = st.atlas = null; st.fade = null;
|
|
691
|
+
BEACONS.set([]);
|
|
516
692
|
hideTip();
|
|
517
693
|
}
|
|
518
694
|
function buildRun() {
|
|
@@ -522,6 +698,7 @@ export async function createStage(host, cb = {}) {
|
|
|
522
698
|
buildPlan(pg); buildExec(xg);
|
|
523
699
|
pg.visible = st.mode === 'plan'; xg.visible = st.mode === 'exec';
|
|
524
700
|
computeCritical();
|
|
701
|
+
syncBeacons(); applyLayers();
|
|
525
702
|
retarget();
|
|
526
703
|
}
|
|
527
704
|
function rebuildExec() {
|
|
@@ -529,6 +706,7 @@ export async function createStage(host, cb = {}) {
|
|
|
529
706
|
PICK = PICK.filter(o => { let p = o; while (p) { if (p === st.execG) return false; p = p.parent; } return true; });
|
|
530
707
|
E.disposeGroup(st.execG); st.execG = new THREE.Group(); world.add(st.execG);
|
|
531
708
|
buildExec(st.execG); st.execG.visible = st.mode === 'exec' || !!st.fade; st.rebuilds++; st.execDirty = false;
|
|
709
|
+
EXEC_BY = null; applyLayers();
|
|
532
710
|
}
|
|
533
711
|
function boxOf(which) {
|
|
534
712
|
const P = which === 'exec' ? st.exec : st.plan;
|
|
@@ -558,7 +736,7 @@ export async function createStage(host, cb = {}) {
|
|
|
558
736
|
}
|
|
559
737
|
function metricColor(el) {
|
|
560
738
|
const o = st.overlay, t = el.data && el.data.task; if (!t || o.color === 'status') return null;
|
|
561
|
-
if (o.color === 'model')
|
|
739
|
+
if (o.color === 'model') return new THREE.Color(modelHex(RM.alias(t.dwarf) || '?'));
|
|
562
740
|
const v = RM.metricValue(t, o.color, st.run, nowSec()), mx = st.metricMax && st.metricMax[o.color];
|
|
563
741
|
if (v === null || !mx) return E.DETAIL.clone();
|
|
564
742
|
return new THREE.Color().copy(E.statusColor('pending')).lerp(E.accent, Math.sqrt(v / mx));
|
|
@@ -566,6 +744,10 @@ export async function createStage(host, cb = {}) {
|
|
|
566
744
|
function retarget() {
|
|
567
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;
|
|
568
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;
|
|
569
751
|
if (st.view === 'run' && st.overlay.height !== 'none') {
|
|
570
752
|
st.metricMax = st.metricMax || {};
|
|
571
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));
|
|
@@ -578,14 +760,16 @@ export async function createStage(host, cb = {}) {
|
|
|
578
760
|
baseTargets(el);
|
|
579
761
|
const T = el.tgt;
|
|
580
762
|
T.sel = sel.has(el.id) ? 1 : 0;
|
|
763
|
+
T.route = route.has(el.id) && st.mode === 'plan' ? 1 : 0;
|
|
581
764
|
T.hov = st.hover === el.id ? 1 : 0;
|
|
582
765
|
T.halo = el.pulse ? .22 : sel.has(el.id) ? .16 : 0;
|
|
583
766
|
if (el.kind === 'run' && el.data.module && !el.data.module.match) { T.vis = .14; T.op = .45; T.halo = 0; }
|
|
584
767
|
if (el.kind === 'ghost') { T.opB = .08; T.op = .7; T.lit = 0; T.tint = 0; }
|
|
585
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
|
|
586
770
|
if (st.overlay.critical && st.crit && el.kind === 'task' && !crit.has(el.id)) T.vis = Math.min(T.vis, .55);
|
|
587
771
|
el.metricCol = metricColor(el);
|
|
588
|
-
if (el.metricCol) { T.tint = E.theme === 'light' ? .
|
|
772
|
+
if (el.metricCol) { T.tint = E.theme === 'light' ? .5 : .45; T.lit = .5; }
|
|
589
773
|
if (el.kind === 'task' && st.overlay.height !== 'none') {
|
|
590
774
|
const v = RM.metricValue(el.data.task, st.overlay.height, st.run, nowSec()), mx = st.metricMax[st.overlay.height];
|
|
591
775
|
T.hk = v === null || !mx ? .35 : .4 + 1.4 * Math.sqrt(v / mx);
|
|
@@ -596,15 +780,24 @@ export async function createStage(host, cb = {}) {
|
|
|
596
780
|
}
|
|
597
781
|
if (el.kind === 'task') { const pr = PROG.get(el.id); if (pr) pr.t = el.id === open ? 1 : 0; }
|
|
598
782
|
// an open task has the stage: the other blocks sink and fade, as archivision does when a block opens
|
|
599
|
-
|
|
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);
|
|
600
790
|
}
|
|
601
791
|
for (const f of FLOWS) {
|
|
602
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;
|
|
603
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;
|
|
604
796
|
const tgtEl = EL.get(f.to);
|
|
605
797
|
f.live = tgtEl && tgtEl.status === 'RUNNING' && f.kind === 'dep';
|
|
606
798
|
}
|
|
607
799
|
E.invalidate(200);
|
|
800
|
+
syncCtl();
|
|
608
801
|
}
|
|
609
802
|
function computeCritical() { st.crit = st.view === 'run' && st.run && st.run.kind !== 'solo' ? RM.criticalPath(st.run, { now: nowSec() }) : null; }
|
|
610
803
|
|
|
@@ -617,6 +810,101 @@ export async function createStage(host, cb = {}) {
|
|
|
617
810
|
const gl = E.glowPoints([new THREE.Vector3()], .3); gl.visible = false; beadG.add(gl); BEADS.push({ m, gl });
|
|
618
811
|
}
|
|
619
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
|
+
|
|
620
908
|
let lastPulse = 0, lastText = 0;
|
|
621
909
|
function frameStep(now, dt) {
|
|
622
910
|
const kk = reduce ? 1 : Math.min(1, dt * 7);
|
|
@@ -638,7 +926,7 @@ export async function createStage(host, cb = {}) {
|
|
|
638
926
|
let pulsing = false;
|
|
639
927
|
for (const el of EL.values()) {
|
|
640
928
|
const c = el.cur, T = el.tgt;
|
|
641
|
-
for (const k of ['vis', 'sel', 'hov', 'lit', 'op', 'opB', 'tint', 'halo', 'hk']) {
|
|
929
|
+
for (const k of ['vis', 'sel', 'hov', 'lit', 'op', 'opB', 'tint', 'halo', 'hk', 'route']) {
|
|
642
930
|
const d = T[k] - c[k]; if (Math.abs(d) > .002) { c[k] += d * kk; moving = true; } else c[k] = T[k];
|
|
643
931
|
}
|
|
644
932
|
// which view the element belongs to, during a switch
|
|
@@ -661,9 +949,10 @@ export async function createStage(host, cb = {}) {
|
|
|
661
949
|
if (el.pulse) pulsing = true;
|
|
662
950
|
const col = el.metricCol || el.col;
|
|
663
951
|
TMP.copy(col).lerp(E.accent, c.sel);
|
|
664
|
-
|
|
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);
|
|
665
954
|
if (c.tint > .001) el.M.body.color.lerp(TMP2.copy(col), c.tint * c.vis);
|
|
666
|
-
if (el.tintMat) { el.tintMat.color.copy(col); el.tintMat.opacity = (c.tint * 1.1 + .08 * c.sel) * ob * gf * 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; }
|
|
667
956
|
if (el.haloMat) el.haloMat.uniforms.opacity.value = (c.halo + (el.pulse ? .12 * pulseK : 0)) * gf * c.vis;
|
|
668
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'));
|
|
669
958
|
if (el.inner) {
|
|
@@ -689,9 +978,10 @@ export async function createStage(host, cb = {}) {
|
|
|
689
978
|
let gf = 1;
|
|
690
979
|
if (fk !== null) gf = st.mode === 'exec' ? 1 - fk : fk;
|
|
691
980
|
const vis = Math.min(a ? a.cur.vis : 1, b ? b.cur.vis : 1);
|
|
692
|
-
const want = Math.max(f.hot, f.crit);
|
|
981
|
+
const want = Math.max(f.hot, f.crit, f.route || 0);
|
|
693
982
|
f.cur += (want - f.cur) * kk; if (Math.abs(want - f.cur) > .002) moving = true;
|
|
694
|
-
|
|
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);
|
|
695
985
|
if (!f.crit && !f.cur) f.mat.color.lerp(E.INKHI, .12);
|
|
696
986
|
f.mat.linewidth = E.lineMat.linewidth * (1 + .9 * Math.max(f.cur, f.crit));
|
|
697
987
|
E.setOp(f.mat, (.55 + .45 * Math.max(f.cur, f.crit)) * gf);
|
|
@@ -700,18 +990,25 @@ export async function createStage(host, cb = {}) {
|
|
|
700
990
|
// beads: into running tasks, along the selected task's cables, inside an open running task
|
|
701
991
|
const want = [];
|
|
702
992
|
const tB = now / 1600;
|
|
703
|
-
for (const f of FLOWS) if ((f.live || f.hot || f.crit) && (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 });
|
|
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 });
|
|
704
994
|
for (const el of EL.values()) if (el.inner && el.inner.visible) for (const f of el.innerFlows) {
|
|
705
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 });
|
|
706
996
|
}
|
|
707
997
|
BEADS.forEach((bd, i) => {
|
|
708
|
-
const w = want[i]; bd.m.visible = bd.gl.visible = !!w && !reduce; if (!
|
|
998
|
+
const w = want[i]; bd.m.visible = bd.gl.visible = !!w && !reduce && st.layers.beads; if (!bd.m.visible) return;
|
|
709
999
|
const p = E.alongPts(w.pts, w.cum, w.t); bd.m.position.copy(W(...p)); bd.m.scale.setScalar(w.r);
|
|
710
1000
|
bd.gl.material.uniforms.size.value = w.size; E.moveGlow(bd.gl, p, w.k);
|
|
711
1001
|
});
|
|
712
1002
|
// elapsed times on running blocks tick once a second
|
|
713
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(); }
|
|
714
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
|
+
}
|
|
715
1012
|
if (!moving && (pulsing || want.length) && !reduce) {
|
|
716
1013
|
// only lamps and beads move: about 30 frames a second is plenty
|
|
717
1014
|
if (now - lastPulse > 30) { lastPulse = now; setTimeout(() => E.invalidate(), 33); }
|
|
@@ -734,7 +1031,7 @@ export async function createStage(host, cb = {}) {
|
|
|
734
1031
|
function info(el) {
|
|
735
1032
|
if (!el) return null;
|
|
736
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' }; }
|
|
737
|
-
if (el.kind === 'task')
|
|
1034
|
+
if (el.kind === 'task') return taskCard(el);
|
|
738
1035
|
if (el.kind === 'finish') { const fi = finishInfo(el.id); return { meta: 'finish', title: fi.title, body: `${fi.status} · ${fi.sub}`, foot: fi.line }; }
|
|
739
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' : '' }; }
|
|
740
1037
|
const it = el.data.item; if (!it) return null;
|
|
@@ -742,17 +1039,54 @@ export async function createStage(host, cb = {}) {
|
|
|
742
1039
|
if (it.kind === 'review') return { meta: 'final review', title: it.review, body: attemptLine(el) };
|
|
743
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() };
|
|
744
1041
|
}
|
|
745
|
-
|
|
746
|
-
|
|
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();
|
|
747
1072
|
tip.replaceChildren();
|
|
748
|
-
for (const [cls, text] of [['meta', inf.meta], ['title', inf.title], ['body', inf.
|
|
749
|
-
if (!text) continue; const d = document.createElement('div'); d.className = 'stage-tip-' + cls;
|
|
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);
|
|
750
1080
|
}
|
|
751
1081
|
tip.hidden = false;
|
|
752
|
-
|
|
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;
|
|
753
1086
|
let x = cx - r.left + 16, y = cy - r.top + 16;
|
|
754
|
-
if (x + tw >
|
|
755
|
-
if (y + th >
|
|
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));
|
|
756
1090
|
tip.style.transform = `translate(${Math.max(4, x).toFixed(0)}px, ${Math.max(4, y).toFixed(0)}px)`;
|
|
757
1091
|
}
|
|
758
1092
|
function hideTip() { tip.hidden = true; }
|
|
@@ -762,6 +1096,14 @@ export async function createStage(host, cb = {}) {
|
|
|
762
1096
|
if (hoverRaf) return;
|
|
763
1097
|
hoverRaf = requestAnimationFrame(() => {
|
|
764
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
|
+
}
|
|
765
1107
|
const o = hoverAt ? E.pick(hoverAt[0], hoverAt[1], PICK, pickable) : null;
|
|
766
1108
|
const el = elOf(o), id = el && !(o.userData.floor) ? el.id : null;
|
|
767
1109
|
E.canvas.classList.toggle('pointing', !!id);
|
|
@@ -782,6 +1124,8 @@ export async function createStage(host, cb = {}) {
|
|
|
782
1124
|
}
|
|
783
1125
|
E.handlers.click = (cx, cy) => {
|
|
784
1126
|
hideTip();
|
|
1127
|
+
const b = st.view === 'run' ? BEACONS.pick(cx, cy) : null;
|
|
1128
|
+
if (b) { cb.onFinding && cb.onFinding(b.data); return; }
|
|
785
1129
|
const o = E.pick(cx, cy, PICK, pickable), el = elOf(o);
|
|
786
1130
|
if (o && o.userData.fractal) { cb.onSelect && cb.onSelect({ kind: 'fractal', task: o.userData.fractal.task, node: o.userData.fractal.node }); return; }
|
|
787
1131
|
if (st.view === 'atlas') {
|
|
@@ -798,8 +1142,37 @@ export async function createStage(host, cb = {}) {
|
|
|
798
1142
|
cb.onSelect && cb.onSelect(selFor(el));
|
|
799
1143
|
};
|
|
800
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
|
+
}
|
|
801
1173
|
E.handlers.key = e => {
|
|
802
|
-
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; } return false; }
|
|
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);
|
|
803
1176
|
if (st.view !== 'run') return false;
|
|
804
1177
|
if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') {
|
|
805
1178
|
const ids = order(); if (!ids.length) return true;
|
|
@@ -823,10 +1196,12 @@ export async function createStage(host, cb = {}) {
|
|
|
823
1196
|
const leaveCloud = () => { if (cloud) cloud.detach(); };
|
|
824
1197
|
|
|
825
1198
|
/* ---------- public API ---------- */
|
|
826
|
-
function frameSelection(dur = 850) {
|
|
1199
|
+
function frameSelection(dur = 850, dir = null) {
|
|
827
1200
|
if (st.view !== 'run') return;
|
|
828
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));
|
|
829
|
-
if (!ids.length) { E.frameBox(boxOf(st.mode), dur, 26); return; }
|
|
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); }
|
|
830
1205
|
hideTip();
|
|
831
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]);
|
|
832
1207
|
const hs = Math.max(...ids.map(el => el.n.h));
|
|
@@ -835,8 +1210,73 @@ export async function createStage(host, cb = {}) {
|
|
|
835
1210
|
const pad = Math.max(st.mode === 'exec' ? 80 : 40, span * .3);
|
|
836
1211
|
const opening = st.mode === 'plan' && ids.some(el => el.kind === 'task');
|
|
837
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
|
|
838
|
-
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);
|
|
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);
|
|
839
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(); }); };
|
|
840
1280
|
const api = {
|
|
841
1281
|
showAtlas(runs, filter, opts = {}) {
|
|
842
1282
|
st.runs = list(runs); st.filter = filter || {}; st.atlasOpts = { showAll: !!opts.showAll };
|
|
@@ -866,6 +1306,7 @@ export async function createStage(host, cb = {}) {
|
|
|
866
1306
|
} else api.update(run);
|
|
867
1307
|
E.canvas.setAttribute('aria-label', `Run ${run.run_id || ''}: ${list(run.tasks).length} tasks, ${st.mode === 'plan' ? 'planned waves' : 'execution timeline'}`);
|
|
868
1308
|
retarget();
|
|
1309
|
+
syncEvents();
|
|
869
1310
|
},
|
|
870
1311
|
update(run) {
|
|
871
1312
|
if (st.view !== 'run' || !run || !st.run || run.key !== st.run.key) return;
|
|
@@ -906,6 +1347,7 @@ export async function createStage(host, cb = {}) {
|
|
|
906
1347
|
}
|
|
907
1348
|
computeCritical();
|
|
908
1349
|
retarget();
|
|
1350
|
+
syncEvents();
|
|
909
1351
|
},
|
|
910
1352
|
setMode(mode) {
|
|
911
1353
|
if (mode === st.mode || st.view !== 'run') { st.mode = mode; return; }
|
|
@@ -930,10 +1372,17 @@ export async function createStage(host, cb = {}) {
|
|
|
930
1372
|
if (st.sel) frameSelection(900); else E.frameBox(boxOf(mode), 900, 26);
|
|
931
1373
|
E.invalidate(950);
|
|
932
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 }; },
|
|
933
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(); },
|
|
934
1381
|
setReplay(t) {
|
|
1382
|
+
const from = st.replay;
|
|
935
1383
|
st.replay = t === null || t === undefined ? null : Number(t);
|
|
936
1384
|
if (st.view !== 'run') return;
|
|
1385
|
+
E.invalidate(100); // marks a replay reaches drop in
|
|
937
1386
|
st.replayState = st.replay !== null ? RM.stateAt(st.run, st.replay) : null;
|
|
938
1387
|
for (const el of EL.values()) {
|
|
939
1388
|
if (el.kind === 'task') setStatus(el, taskStatus(el.data.task));
|
|
@@ -944,6 +1393,10 @@ export async function createStage(host, cb = {}) {
|
|
|
944
1393
|
const sig = 'replay:' + el.status; if (sig !== el.sig) { el.sig = sig; redraw(el); }
|
|
945
1394
|
}
|
|
946
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 });
|
|
947
1400
|
},
|
|
948
1401
|
select(s, { frame = true } = {}) {
|
|
949
1402
|
const same = JSON.stringify(s || null) === JSON.stringify(st.sel || null);
|
|
@@ -954,7 +1407,7 @@ export async function createStage(host, cb = {}) {
|
|
|
954
1407
|
showCloud(runs, opts = {}) {
|
|
955
1408
|
if (st.view !== 'cloud') { clearWorld(); st.view = 'cloud'; st.run = null; st.sel = null; st.atlasSig = ''; st.structure = ''; }
|
|
956
1409
|
if (!cloud) cloud = createCloud(E, host, cb.cloud || {});
|
|
957
|
-
const c = cloud.show(list(runs), opts);
|
|
1410
|
+
const c = cloud.show(list(runs), opts); syncCtl();
|
|
958
1411
|
E.canvas.setAttribute('aria-label', `Cloud of runs: ${c.visible} of ${c.total} runs in ${c.projects} projects, ${c.layout} layout`);
|
|
959
1412
|
return c;
|
|
960
1413
|
},
|
|
@@ -965,15 +1418,30 @@ export async function createStage(host, cb = {}) {
|
|
|
965
1418
|
const el = EL.get('t:' + taskId); if (el && el.inner) { syncFractal(el); E.invalidate(300); }
|
|
966
1419
|
},
|
|
967
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
|
+
},
|
|
968
1427
|
get mode() { return st.mode; },
|
|
969
1428
|
get view() { return st.view; },
|
|
970
|
-
dispose() { E.dispose(); tip.remove(); },
|
|
1429
|
+
dispose() { if (CTL) CTL.dispose(); E.dispose(); tip.remove(); },
|
|
971
1430
|
debug() {
|
|
972
1431
|
return {
|
|
973
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,
|
|
974
1433
|
nodes: [...EL.values()].map(el => ({ id: el.id, kind: el.kind, status: el.status, sel: el.tgt.sel, vis: el.tgt.vis })),
|
|
975
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 }; },
|
|
976
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
|
+
},
|
|
977
1445
|
};
|
|
978
1446
|
},
|
|
979
1447
|
};
|
|
@@ -992,5 +1460,12 @@ export async function createStage(host, cb = {}) {
|
|
|
992
1460
|
const nel = buildAttempt(it, g);
|
|
993
1461
|
Object.assign(nel.cur, el.cur);
|
|
994
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
|
+
});
|
|
995
1470
|
return api;
|
|
996
1471
|
}
|