@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,647 @@
|
|
|
1
|
+
/* forge-ui run cloud: every run floating in 3D, grouped by project, in one of five layouts (cloudmodel.js),
|
|
2
|
+
drawn as iso-glow blocks or as orbs. Runs can be dragged anywhere; where they are dropped is kept in this
|
|
3
|
+
browser, per layout. Hosted by the stage's engine (iso.js), so the cloud shares its WebGL context with the
|
|
4
|
+
map and the run views. Blocks and labels follow archivision's figure.js (MIT, © 2026 Giamme; drawing style
|
|
5
|
+
iso-glow, MIT © 2026 April Zhu and Tolga Cohce: see the notice in iso.js). All text is drawn on canvases
|
|
6
|
+
or set with textContent. */
|
|
7
|
+
const STATE_STATUS = { live: 'RUNNING', failed: 'FAIL', done: 'MERGED', pending: 'PENDING', stale: 'INTERRUPTED' };
|
|
8
|
+
const STORE_KEY = 'forge-ui-cloud-layout-v1';
|
|
9
|
+
const STORE_CAP = 5000;
|
|
10
|
+
const LABEL_W = 236, LABEL_H = 48; // run labels, in pixels
|
|
11
|
+
|
|
12
|
+
export function createCloud(E, host, cb = {}) {
|
|
13
|
+
const { THREE, W, TS } = E;
|
|
14
|
+
const CM = window.ForgeCloudModel, RM = window.ForgeRunModel;
|
|
15
|
+
const reduce = E.reduceMotion;
|
|
16
|
+
const root = new THREE.Group(), frameG = new THREE.Group(), runG = new THREE.Group(), labelG = new THREE.Group();
|
|
17
|
+
root.add(frameG, runG, labelG);
|
|
18
|
+
const tip = document.createElement('div');
|
|
19
|
+
tip.className = 'stage-tip'; tip.hidden = true; tip.setAttribute('role', 'tooltip'); host.append(tip);
|
|
20
|
+
const TMP = new THREE.Color(), TMP2 = new THREE.Color(), V = new THREE.Vector3(), V2 = new THREE.Vector3();
|
|
21
|
+
|
|
22
|
+
const st = { runs: [], facts: new Map(), visible: [], filter: CM.emptyFilter(), layout: 'helix', shape: 'blocks', L: null, sel: null,
|
|
23
|
+
hover: null, focus: null, sig: '', framed: null, attached: false, buildMs: 0, labels: [], drag: null };
|
|
24
|
+
const EL = new Map(); // key → element (blocks) or orb slot (orbs)
|
|
25
|
+
let PICK = [], LABELPICK = [], orbs = null;
|
|
26
|
+
const empty = g => { for (const ch of [...g.children]) E.disposeGroup(ch); };
|
|
27
|
+
const nowSec = () => Date.now() / 1000;
|
|
28
|
+
const store = loadStore();
|
|
29
|
+
|
|
30
|
+
/* ---------- the dragged positions, in this browser ---------- */
|
|
31
|
+
function loadStore() {
|
|
32
|
+
try { const v = JSON.parse(localStorage.getItem(STORE_KEY) || 'null'); if (v && typeof v === 'object' && v.layouts) return v; } catch (_) { /* private mode or junk */ }
|
|
33
|
+
return { layouts: {} };
|
|
34
|
+
}
|
|
35
|
+
function saveStore() {
|
|
36
|
+
for (const name of Object.keys(store.layouts)) {
|
|
37
|
+
const e = Object.entries(store.layouts[name]);
|
|
38
|
+
if (e.length > STORE_CAP) store.layouts[name] = Object.fromEntries(e.sort((a, b) => (b[1][3] || 0) - (a[1][3] || 0)).slice(0, STORE_CAP));
|
|
39
|
+
}
|
|
40
|
+
try { localStorage.setItem(STORE_KEY, JSON.stringify(store)); } catch (_) { /* full or private: positions last this session */ }
|
|
41
|
+
}
|
|
42
|
+
const pinned = () => { const out = {}; for (const [k, v] of Object.entries(store.layouts[st.layout] || {})) out[k] = v.slice(0, 3); return out; };
|
|
43
|
+
|
|
44
|
+
/* ---------- colours ---------- */
|
|
45
|
+
const statusOf = f => STATE_STATUS[f.state] || 'PENDING';
|
|
46
|
+
const lampStatuses = f => {
|
|
47
|
+
const ts = (f.run.task_statuses || []).map(t => t.status);
|
|
48
|
+
if (ts.length <= 64) return ts;
|
|
49
|
+
const out = []; for (let i = 0; i < 64; i++) out.push(ts[Math.floor(i * ts.length / 64)]); // binned by position
|
|
50
|
+
return out;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/* ---------- blocks: one shared solid per size, per-run materials ---------- */
|
|
54
|
+
function buildBlock(f) {
|
|
55
|
+
const b = CM.blockSize(f.bucket), unit = E.unitSolid('cloud:' + f.bucket, b.s, b.s, b.h, 9);
|
|
56
|
+
const g = new THREE.Group(); runG.add(g);
|
|
57
|
+
const M = E.mats();
|
|
58
|
+
const body = E.solidFrom(unit, M, g, { cloud: f.key }); PICK.push(body);
|
|
59
|
+
// the roof: a tint in the run's colour and a lamp per task, one mesh coloured per vertex (RGBA)
|
|
60
|
+
const st0 = lampStatuses(f), n = st0.length, per = Math.max(1, Math.ceil(Math.sqrt(n))), cell = (b.s - 26) / per, sz = Math.min(5, cell * .32);
|
|
61
|
+
const quad = (cx, cy, hx, hy, z) => [[-hx, -hy], [-hx, hy], [hx, hy], [-hx, -hy], [hx, hy], [hx, -hy]].flatMap(([a, c]) => W(cx + a, cy + c, z).toArray());
|
|
62
|
+
const pos = quad(0, 0, b.s / 2 - 3, b.s / 2 - 3, b.h + .08);
|
|
63
|
+
for (let i = 0; i < n; i++) pos.push(...quad(-b.s / 2 + 13 + cell * (i % per + .5), -b.s / 2 + 13 + cell * (Math.floor(i / per) + .5), sz, sz, b.h + .6));
|
|
64
|
+
const lg = new THREE.BufferGeometry();
|
|
65
|
+
lg.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3)); lg.setAttribute('color', new THREE.Float32BufferAttribute(new Float32Array(pos.length / 3 * 4).fill(.5), 4));
|
|
66
|
+
const lampMat = new THREE.MeshBasicMaterial({ vertexColors: true, toneMapped: false, transparent: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4, polygonOffsetUnits: -12 });
|
|
67
|
+
const lamps = E.noRay(new THREE.Mesh(lg, lampMat)); lamps.renderOrder = 2; g.add(lamps); M.labs.push(lampMat);
|
|
68
|
+
const haloMat = E.softMat(0xffffff, true);
|
|
69
|
+
const halo = E.noRay(new THREE.Mesh(new THREE.PlaneGeometry((b.s + 90) * TS, (b.s + 90) * TS), haloMat)); halo.rotation.x = -Math.PI / 2; halo.position.y = .3 * TS; halo.visible = false; g.add(halo);
|
|
70
|
+
const el = { key: f.key, f, g, M, body, lamps, lampGeo: lg, lampN: n, halo, haloMat, size: b, cur: null, tgt: null, sc: 0, tgtSc: 1, roofSig: '',
|
|
71
|
+
c: { vis: 1, hot: 0, hov: 0 }, t: { vis: 1, hot: 0, hov: 0 }, sig: '' };
|
|
72
|
+
setLamps(el); return el;
|
|
73
|
+
}
|
|
74
|
+
function setLamps(el) {
|
|
75
|
+
const a = el.lampGeo.attributes.color, ss = lampStatuses(el.f);
|
|
76
|
+
for (let i = 0; i < el.lampN; i++) { const c = ss[i] ? E.statusColor(ss[i]) : E.DETAIL; for (let k = 0; k < 6; k++) a.setXYZW(6 + i * 6 + k, c.r, c.g, c.b, 1); }
|
|
77
|
+
a.needsUpdate = true; el.roofSig = '';
|
|
78
|
+
}
|
|
79
|
+
function setRoof(el, col, alpha) {
|
|
80
|
+
const sig = col.getHexString() + alpha.toFixed(3); if (sig === el.roofSig) return;
|
|
81
|
+
const a = el.lampGeo.attributes.color; el.roofSig = sig;
|
|
82
|
+
for (let k = 0; k < 6; k++) a.setXYZW(k, col.r, col.g, col.b, alpha);
|
|
83
|
+
a.needsUpdate = true;
|
|
84
|
+
}
|
|
85
|
+
function paintBlock(el, pulse) {
|
|
86
|
+
const c = el.c, col = E.statusColor(statusOf(el.f));
|
|
87
|
+
TMP.copy(col).lerp(E.accent, c.hot > .5 ? c.hot : 0);
|
|
88
|
+
const lit = statusOf(el.f) === 'PENDING' ? 0 : .3;
|
|
89
|
+
E.paint(el.M, c.vis, Math.min(1, lit + c.hot * .9), c.hov, 1, 1, TMP, 0);
|
|
90
|
+
const light = E.theme === 'light', plain = statusOf(el.f) === 'PENDING';
|
|
91
|
+
el.M.body.color.lerp(col, (light ? plain ? .06 : .34 : .12) * c.vis);
|
|
92
|
+
setRoof(el, col, Math.round((light ? plain ? .12 : .55 : .2) * c.vis * 50) / 50);
|
|
93
|
+
el.haloMat.uniforms.color.value.copy(col);
|
|
94
|
+
const glow = (el.f.state === 'live' ? .14 + .12 * pulse : 0) * c.vis + (c.hot > .5 ? .12 : 0);
|
|
95
|
+
el.haloMat.uniforms.opacity.value = glow; el.halo.visible = glow > .002; // no draw for a halo that is off
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* ---------- orbs: one instanced sphere, an instanced outline and an instanced ring of task segments ---------- */
|
|
99
|
+
const SEG_W = 64;
|
|
100
|
+
function buildOrbs(facts) {
|
|
101
|
+
const n = Math.max(1, facts.length), sphere = new THREE.SphereGeometry(1, 28, 18);
|
|
102
|
+
const body = new THREE.InstancedMesh(sphere, new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: .55, metalness: .05 }), n);
|
|
103
|
+
const out = new THREE.InstancedMesh(sphere, E.outlineInstanced(), n); E.noRay(out);
|
|
104
|
+
const ringGeo = new THREE.PlaneGeometry(2, 2);
|
|
105
|
+
const seg = new THREE.InstancedBufferAttribute(new Float32Array(n * 2), 2); ringGeo.setAttribute('aSeg', seg);
|
|
106
|
+
const data = new Uint8Array(SEG_W * n * 4), tex = new THREE.DataTexture(data, SEG_W, n, THREE.RGBAFormat);
|
|
107
|
+
tex.colorSpace = THREE.SRGBColorSpace; tex.needsUpdate = true;
|
|
108
|
+
const ringMat = new THREE.ShaderMaterial({
|
|
109
|
+
uniforms: { tSeg: { value: tex }, rows: { value: n }, opacity: { value: 1 } },
|
|
110
|
+
transparent: true, depthWrite: false, toneMapped: false,
|
|
111
|
+
vertexShader: `attribute vec2 aSeg; varying vec2 vUv; varying vec2 vSeg;
|
|
112
|
+
void main() {
|
|
113
|
+
mat4 m = modelViewMatrix;
|
|
114
|
+
#ifdef USE_INSTANCING
|
|
115
|
+
m = m * instanceMatrix;
|
|
116
|
+
#endif
|
|
117
|
+
vec4 c = m * vec4(0.0, 0.0, 0.0, 1.0);
|
|
118
|
+
float s = length(m[0].xyz);
|
|
119
|
+
c.xy += position.xy * s * 1.38;
|
|
120
|
+
gl_Position = projectionMatrix * c; vUv = uv; vSeg = aSeg;
|
|
121
|
+
}`,
|
|
122
|
+
fragmentShader: `uniform sampler2D tSeg; uniform float rows; uniform float opacity; varying vec2 vUv; varying vec2 vSeg;
|
|
123
|
+
void main() {
|
|
124
|
+
vec2 p = vUv * 2.0 - 1.0; float r = length(p);
|
|
125
|
+
if (r < 0.84 || r > 1.0) discard;
|
|
126
|
+
float n = max(vSeg.y, 1.0), a = fract(atan(p.y, p.x) / 6.2831853 + 0.25);
|
|
127
|
+
float idx = floor(a * n), fr = fract(a * n);
|
|
128
|
+
if (n > 1.0 && (fr < 0.07 || fr > 0.93)) discard;
|
|
129
|
+
vec4 col = texture2D(tSeg, vec2((idx + 0.5) / ${SEG_W}.0, (vSeg.x + 0.5) / rows));
|
|
130
|
+
if (col.a < 0.01) discard;
|
|
131
|
+
gl_FragColor = vec4(col.rgb, opacity * col.a);
|
|
132
|
+
}`,
|
|
133
|
+
});
|
|
134
|
+
const ring = E.noRay(new THREE.InstancedMesh(ringGeo, ringMat, n));
|
|
135
|
+
for (const m of [body, out, ring]) { m.frustumCulled = false; m.count = facts.length; runG.add(m); }
|
|
136
|
+
body.userData.orbs = true; PICK.push(body);
|
|
137
|
+
const glow = E.glowPoints(facts.map(() => new THREE.Vector3()), .9); runG.add(glow);
|
|
138
|
+
orbs = { body, out, ring, seg, tex, data, glow, keys: facts.map(f => f.key), index: new Map(facts.map((f, i) => [f.key, i])) };
|
|
139
|
+
facts.forEach((f, i) => {
|
|
140
|
+
body.setColorAt(i, E.bodyMat.color);
|
|
141
|
+
seg.setXY(i, i, Math.min(SEG_W, lampStatuses(f).length));
|
|
142
|
+
const el = { key: f.key, f, orb: i, size: { s: CM.orbRadius(f.bucket) * 2, h: CM.orbRadius(f.bucket) * 2, r: CM.orbRadius(f.bucket) }, cur: null, tgt: null, sc: 0, tgtSc: 1,
|
|
143
|
+
c: { vis: 1, hot: 0, hov: 0 }, t: { vis: 1, hot: 0, hov: 0 }, sig: '' };
|
|
144
|
+
EL.set(f.key, el); setOrbSegments(el);
|
|
145
|
+
});
|
|
146
|
+
seg.needsUpdate = true;
|
|
147
|
+
}
|
|
148
|
+
function setOrbSegments(el) {
|
|
149
|
+
const ss = lampStatuses(el.f), row = el.orb;
|
|
150
|
+
for (let i = 0; i < SEG_W; i++) {
|
|
151
|
+
const o = (row * SEG_W + i) * 4, s = ss[i];
|
|
152
|
+
if (!s) { orbs.data[o + 3] = 0; continue; }
|
|
153
|
+
const c = TMP2.copy(E.statusColor(s)).convertLinearToSRGB(); // the texture is sRGB: bytes are encoded
|
|
154
|
+
orbs.data[o] = c.r * 255; orbs.data[o + 1] = c.g * 255; orbs.data[o + 2] = c.b * 255; orbs.data[o + 3] = 255;
|
|
155
|
+
}
|
|
156
|
+
orbs.tex.needsUpdate = true;
|
|
157
|
+
}
|
|
158
|
+
const MAT = new THREE.Matrix4(), Q = new THREE.Quaternion(), S = new THREE.Vector3();
|
|
159
|
+
function placeOrb(el) {
|
|
160
|
+
const r = el.size.r, s = Math.max(1e-4, r * TS * el.sc);
|
|
161
|
+
V.copy(W(el.cur[0], el.cur[1], el.cur[2] + r)); S.setScalar(s);
|
|
162
|
+
MAT.compose(V, Q, S);
|
|
163
|
+
for (const m of [orbs.body, orbs.out, orbs.ring]) m.setMatrixAt(el.orb, MAT);
|
|
164
|
+
const gp = orbs.glow.geometry.attributes.position; // a beacon over live runs; others parked out of sight
|
|
165
|
+
if (el.f.state === 'live' && !el.leaving) gp.setXYZ(el.orb, V.x, V.y + s * 1.1, V.z); else gp.setXYZ(el.orb, 0, -1e7, 0);
|
|
166
|
+
gp.needsUpdate = true;
|
|
167
|
+
orbs.dirty = true;
|
|
168
|
+
}
|
|
169
|
+
function paintOrb(el, pulse) {
|
|
170
|
+
const c = el.c, col = E.statusColor(statusOf(el.f));
|
|
171
|
+
TMP2.copy(E.bodyMat.color).lerp(col, E.theme === 'light' ? .45 : .55);
|
|
172
|
+
E.mixColor(TMP, TMP2, c.vis, c.hot * .8, c.hov);
|
|
173
|
+
orbs.body.setColorAt(el.orb, TMP);
|
|
174
|
+
orbs.colorDirty = true;
|
|
175
|
+
if (el.f.state === 'live') orbs.live = true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ---------- labels: world sprites for the runs that matter right now, screen sprites per project ---------- */
|
|
179
|
+
const LABELS = new Map(); // key → sprite record (LRU by insertion)
|
|
180
|
+
function runLabel(el) {
|
|
181
|
+
let t = LABELS.get(el.key);
|
|
182
|
+
if (t) { LABELS.delete(el.key); LABELS.set(el.key, t); return t; }
|
|
183
|
+
const key = el.key, w = LABEL_W, h = LABEL_H;
|
|
184
|
+
t = E.sprite(w, h, 2, (x, s, C) => {
|
|
185
|
+
const f = (EL.get(key) || el).f, r = f.run;
|
|
186
|
+
E.rr(x, s, 1, 1, w - 2, h - 2, 10); x.fillStyle = C.light ? 'rgba(250,250,249,.92)' : 'rgba(22,23,24,.9)'; x.fill();
|
|
187
|
+
x.strokeStyle = E.statusHex(statusOf(f)); x.lineWidth = Math.max(1, 1.2 * s); x.stroke();
|
|
188
|
+
const g = E.fit(String(r.goal || 'Untitled run').split('\n')[0], w - 20, 26, 12.5, 2, 9);
|
|
189
|
+
if (g) E.lines(x, s, g, 10, 5, C.inkHi, w - 20, 600);
|
|
190
|
+
E.font(x, 9.5, s); x.fillStyle = C.ink;
|
|
191
|
+
x.fillText(E.ellipsis(`${r.run_id || ''} · ${f.n} task${f.n > 1 ? 's' : ''} · ${(STATE_STATUS[f.state] || '').toLowerCase()}`, Math.floor((w - 20) / (9.5 * E.CW))), 10 * s, (h - 8) * s);
|
|
192
|
+
}, { screen: true, px: h });
|
|
193
|
+
t.mesh.center.set(.5, -.12); t.mat.depthTest = false;
|
|
194
|
+
labelG.add(t.mesh); LABELS.set(el.key, t);
|
|
195
|
+
while (LABELS.size > 96) {
|
|
196
|
+
const [k, old] = LABELS.entries().next().value;
|
|
197
|
+
if (st.labels.includes(k)) { LABELS.delete(k); LABELS.set(k, old); if ([...LABELS.keys()].every(x => st.labels.includes(x))) break; continue; }
|
|
198
|
+
LABELS.delete(k); E.disposeGroup(old.mesh);
|
|
199
|
+
}
|
|
200
|
+
return t;
|
|
201
|
+
}
|
|
202
|
+
let projectLabels = [];
|
|
203
|
+
function buildFrames() {
|
|
204
|
+
empty(frameG);
|
|
205
|
+
for (const t of projectLabels) E.disposeGroup(t.mesh);
|
|
206
|
+
projectLabels = []; LABELPICK = [];
|
|
207
|
+
const L = st.L; if (!L) return;
|
|
208
|
+
const names = new Map(L.projects.map(p => [p.id, p]));
|
|
209
|
+
// a faint floor grid for the layouts that stand on one
|
|
210
|
+
if (st.layout !== 'constellation') {
|
|
211
|
+
const [x0, y0, , x1, y1] = L.bounds, step = 400, segs = [];
|
|
212
|
+
for (let x = Math.floor(x0 / step) * step; x <= x1; x += step) segs.push([[x, y0, -.5], [x, y1, -.5]]);
|
|
213
|
+
for (let y = Math.floor(y0 / step) * step; y <= y1; y += step) segs.push([[x0, y, -.5], [x1, y, -.5]]);
|
|
214
|
+
if (segs.length < 400) { const gm = E.lmat(); gm.color.copy(E.lineMat.color); gm.opacity = .35; gm.transparent = true; E.seg(segs, frameG, gm); }
|
|
215
|
+
}
|
|
216
|
+
for (const c of L.clusters) {
|
|
217
|
+
const p = names.get(c.pid); if (!p) continue;
|
|
218
|
+
const mat = E.lmat({ dashed: c.shape === 'ring' || c.shape === 'shell', dashSize: 14 * TS, gapSize: 10 * TS });
|
|
219
|
+
if (c.shape === 'lane') E.loopAt(c.lane[0], c.lane[1], c.lane[2], c.lane[3], 18, .4, mat, frameG, 8);
|
|
220
|
+
else if (c.shape === 'cloud') {
|
|
221
|
+
const mine = st.visible.filter(f => f.project === c.pid), hOf = new Map(mine.map(f => [f.key, shapeSize(f).h / 2]));
|
|
222
|
+
const segs = CM.constellationLinks(mine, st.L.pos).map(([a, b]) => { const A = st.L.pos[a], B = st.L.pos[b]; return [[A[0], A[1], A[2] + hOf.get(a)], [B[0], B[1], B[2] + hOf.get(b)]]; });
|
|
223
|
+
if (segs.length) E.seg(segs, frameG, mat);
|
|
224
|
+
} else {
|
|
225
|
+
const pts = Array.from({ length: 96 }, (_, i) => { const a = i / 96 * Math.PI * 2; return W(c.center[0] + c.radius * Math.cos(a), c.center[1] + c.radius * Math.sin(a), .4); });
|
|
226
|
+
const loop = E.noRay(E.loopL(pts, mat)); loop.computeLineDistances(); frameG.add(loop);
|
|
227
|
+
}
|
|
228
|
+
c.mat = mat;
|
|
229
|
+
const count = st.visible.filter(f => f.project === c.pid).length, text = `${p.name.toUpperCase()} ${count}`;
|
|
230
|
+
const w = text.length * 9 + 26, t = E.sprite(w, 26, 3, (x, s, C) => {
|
|
231
|
+
E.rr(x, s, 1, 1, w - 2, 24, 12); x.fillStyle = C.light ? 'rgba(250,250,249,.92)' : 'rgba(22,23,24,.88)'; x.fill();
|
|
232
|
+
x.strokeStyle = st.focus === c.pid ? C.acc : C.line; x.lineWidth = Math.max(1, s); x.stroke();
|
|
233
|
+
E.font(x, 12, s, 600); x.fillStyle = st.focus === c.pid ? C.accHi : C.inkHi; x.letterSpacing = `${(1.2 * s).toFixed(1)}px`;
|
|
234
|
+
x.fillText(text, 13 * s, 17 * s); x.letterSpacing = '0px';
|
|
235
|
+
}, { screen: true, px: 26 });
|
|
236
|
+
const far = Math.SQRT1_2 * c.radius;
|
|
237
|
+
const top = c.shape === 'lane' ? [c.lane[0] - 40, c.lane[1] + c.lane[3] / 2, 30] : st.layout === 'galaxy' ? [c.center[0] - far, c.center[1] - far, 40] : [c.center[0], c.center[1], (c.z1 || 0) + 120];
|
|
238
|
+
t.mesh.position.copy(W(...top)); t.mesh.userData = { project: c.pid, tp: t }; t.mat.depthTest = false;
|
|
239
|
+
if (c.shape === 'lane') t.mesh.center.set(1, .5);
|
|
240
|
+
labelG.add(t.mesh); projectLabels.push(t); LABELPICK.push(t.mesh);
|
|
241
|
+
}
|
|
242
|
+
if (L.axis) { // the time axis runs along the front of the lanes
|
|
243
|
+
const z = -.5, y = Math.max(...L.clusters.filter(c => c.lane).map(c => c.lane[1] + c.lane[3])) + 40;
|
|
244
|
+
E.seg([[[0, y, z], [L.axis.length, y, z]], ...L.axis.ticks.map(k => [[k.x, y - 20, z], [k.x, y + 20, z]])], frameG, E.majorMat);
|
|
245
|
+
for (const k of L.axis.ticks) {
|
|
246
|
+
const d = new Date(k.t * 1000), text = L.axis.step < 86400 ? d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString([], { year: 'numeric', month: 'short', day: 'numeric' });
|
|
247
|
+
const w = text.length * 8 + 14, t = E.sprite(w, 22, 3, (x, s, C) => { E.font(x, 11, s); x.fillStyle = C.ink; x.fillText(text, 7 * s, 15 * s); }, { screen: true, px: 20 });
|
|
248
|
+
t.mesh.position.copy(W(k.x, y + 70, z)); t.mat.depthTest = false; labelG.add(t.mesh); projectLabels.push(t);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/* ---------- building and laying out ---------- */
|
|
254
|
+
function clearRuns() {
|
|
255
|
+
for (const t of LABELS.values()) E.disposeGroup(t.mesh);
|
|
256
|
+
LABELS.clear(); st.labels = [];
|
|
257
|
+
empty(runG);
|
|
258
|
+
EL.clear(); PICK = []; orbs = null;
|
|
259
|
+
}
|
|
260
|
+
function rebuild() {
|
|
261
|
+
const t0 = performance.now(), was = new Map([...EL].map(([k, el]) => [k, { cur: el.cur, sc: el.sc }]));
|
|
262
|
+
clearRuns();
|
|
263
|
+
if (st.shape === 'orbs') buildOrbs(st.visible);
|
|
264
|
+
else for (const f of st.visible) EL.set(f.key, buildBlock(f));
|
|
265
|
+
for (const el of EL.values()) { // runs already on screen travel from where they were
|
|
266
|
+
const w = was.get(el.key);
|
|
267
|
+
el.tgt = (st.L.pos[el.key] || [0, 0, 0]).slice(); el.cur = w && w.cur && !reduce ? w.cur.slice() : el.tgt.slice();
|
|
268
|
+
el.sc = reduce ? 1 : w ? w.sc : 0; place(el);
|
|
269
|
+
}
|
|
270
|
+
buildFrames(); retarget();
|
|
271
|
+
st.buildMs = performance.now() - t0;
|
|
272
|
+
if (orbs) { orbs.body.computeBoundingSphere(); orbs.body.instanceColor && (orbs.body.instanceColor.needsUpdate = true); }
|
|
273
|
+
}
|
|
274
|
+
function place(el) {
|
|
275
|
+
if (el.orb !== undefined) { placeOrb(el); return; }
|
|
276
|
+
el.g.position.copy(W(...el.cur)); el.g.scale.setScalar(Math.max(1e-4, el.sc));
|
|
277
|
+
}
|
|
278
|
+
function relayout({ frame = false } = {}) {
|
|
279
|
+
const all = [...st.facts.values()];
|
|
280
|
+
st.visible = all.filter(f => CM.match(f, st.filter, nowSec()));
|
|
281
|
+
// a poll that only changes statuses keeps the layout (the constellation's simulation is not free)
|
|
282
|
+
const pin = pinned(), vkey = CM.layoutKey(st.visible), lsig = [st.layout, vkey, all.length === st.visible.length ? '' : CM.layoutKey(all), JSON.stringify(pin)].join('|');
|
|
283
|
+
const moved = lsig !== st.lsig || !st.L;
|
|
284
|
+
if (moved) { st.L = CM.layout(st.layout, st.visible, { all, pinned: pin }); st.lsig = lsig; }
|
|
285
|
+
const sig = st.shape + '|' + vkey;
|
|
286
|
+
const shapeChanged = !st.sig || st.sig.split('|')[0] !== st.shape;
|
|
287
|
+
if (shapeChanged || st.shape === 'orbs' && sig !== st.sig) { st.sig = sig; rebuild(); }
|
|
288
|
+
else {
|
|
289
|
+
st.sig = sig;
|
|
290
|
+
const want = new Set(st.visible.map(f => f.key));
|
|
291
|
+
for (const [k, el] of [...EL]) if (!want.has(k)) { el.tgtSc = 0; el.leaving = true; }
|
|
292
|
+
for (const f of st.visible) {
|
|
293
|
+
let el = EL.get(f.key);
|
|
294
|
+
if (el && el.f.bucket !== f.bucket) { removeBlock(el); el = null; }
|
|
295
|
+
if (!el) { el = buildBlock(f); el.cur = (st.L.pos[f.key] || [0, 0, 0]).slice(); el.sc = reduce ? 1 : 0; EL.set(f.key, el); }
|
|
296
|
+
el.f = f; el.leaving = false; el.tgtSc = 1; el.tgt = (st.L.pos[f.key] || el.cur).slice();
|
|
297
|
+
if (reduce) { el.cur = el.tgt.slice(); el.sc = 1; }
|
|
298
|
+
place(el);
|
|
299
|
+
}
|
|
300
|
+
if (moved) buildFrames();
|
|
301
|
+
retarget();
|
|
302
|
+
}
|
|
303
|
+
if (frame) frameAll(reduce ? 0 : 1100);
|
|
304
|
+
E.invalidate(1300);
|
|
305
|
+
}
|
|
306
|
+
function removeBlock(el) {
|
|
307
|
+
EL.delete(el.key); PICK = PICK.filter(o => o !== el.body);
|
|
308
|
+
const t = LABELS.get(el.key); if (t) { LABELS.delete(el.key); E.disposeGroup(t.mesh); }
|
|
309
|
+
E.disposeGroup(el.g);
|
|
310
|
+
}
|
|
311
|
+
function retarget() {
|
|
312
|
+
for (const el of EL.values()) {
|
|
313
|
+
el.t.vis = st.focus && el.f.project !== st.focus ? .2 : 1;
|
|
314
|
+
el.t.hot = st.sel === el.key || (st.drag && st.drag.key === el.key) ? 1 : 0;
|
|
315
|
+
el.t.hov = st.hover === el.key ? 1 : 0;
|
|
316
|
+
}
|
|
317
|
+
for (const c of st.L ? st.L.clusters : []) if (c.mat) { E.lineCol(c.mat, E.majorMat.color, st.focus === c.pid ? 1 : 0, st.focus && st.focus !== c.pid ? .3 : 1); c.mat.color.lerp(E.INKHI, .2); }
|
|
318
|
+
updateLabels(true);
|
|
319
|
+
E.invalidate(400);
|
|
320
|
+
}
|
|
321
|
+
// camera: where each layout reads best, framing what is shown (or one project)
|
|
322
|
+
function viewDir() {
|
|
323
|
+
const info = CM.LAYOUT_INFO[st.layout] || {}, e = THREE.MathUtils.degToRad(info.elevation ?? 25), a = THREE.MathUtils.degToRad(info.azimuth ?? 45);
|
|
324
|
+
return new THREE.Vector3(Math.cos(e) * Math.cos(a), Math.sin(e), Math.cos(e) * Math.sin(a));
|
|
325
|
+
}
|
|
326
|
+
// lane labels hang left of the timeline's lanes: room for them too
|
|
327
|
+
function frameBox(bx, dur, dir) { E.frameBox(bx, dur, -89, dir || null, st.layout === 'timeline' ? { ...(st.inset || {}), l: ((st.inset || {}).l || 0) + 170 } : st.inset); }
|
|
328
|
+
// the points that must be in view: each run's footprint and top, and each cluster's ring and label
|
|
329
|
+
function framePts(pid) {
|
|
330
|
+
const pts = [];
|
|
331
|
+
for (const f of st.visible) {
|
|
332
|
+
if (pid && f.project !== pid) continue;
|
|
333
|
+
const p = st.L.pos[f.key]; if (!p) continue;
|
|
334
|
+
const b = shapeSize(f), r = b.s / 2;
|
|
335
|
+
pts.push([p[0] - r, p[1], p[2]], [p[0] + r, p[1], p[2] + b.h], [p[0], p[1] - r, p[2]], [p[0], p[1] + r, p[2] + b.h]);
|
|
336
|
+
}
|
|
337
|
+
for (const c of st.L.clusters) {
|
|
338
|
+
if (pid && c.pid !== pid) continue;
|
|
339
|
+
if (c.shape === 'lane') pts.push([c.lane[0], c.lane[1], 0], [c.lane[0] + c.lane[2], c.lane[1] + c.lane[3], 0], [c.lane[0] - 160, c.lane[1] + c.lane[3] / 2, 30]);
|
|
340
|
+
else {
|
|
341
|
+
for (let i = 0; i < 8; i++) { const a = i / 8 * Math.PI * 2; pts.push([c.center[0] + c.radius * Math.cos(a), c.center[1] + c.radius * Math.sin(a), 0]); }
|
|
342
|
+
pts.push([c.center[0], c.center[1], (c.z1 || 0) + 160]);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return pts;
|
|
346
|
+
}
|
|
347
|
+
const shapeSize = f => st.shape === 'orbs' ? { s: CM.orbRadius(f.bucket) * 2, h: CM.orbRadius(f.bucket) * 2 } : CM.blockSize(f.bucket);
|
|
348
|
+
function frameAll(dur = 900) {
|
|
349
|
+
if (!st.L) return;
|
|
350
|
+
if (st.focus && st.L.clusters.some(x => x.pid === st.focus)) { frameCluster(st.focus, dur); return; }
|
|
351
|
+
const pts = framePts(null);
|
|
352
|
+
if (pts.length) frameBox({ pts }, dur, viewDir()); else { const b = st.L.bounds; frameBox([b[0], b[1], b[2], b[3], b[4], b[5]], dur, viewDir()); }
|
|
353
|
+
}
|
|
354
|
+
function frameCluster(pid, dur = 900) { const pts = framePts(pid); if (pts.length) frameBox({ pts }, dur); }
|
|
355
|
+
|
|
356
|
+
/* ---------- every frame: travel, grow, fade, light ---------- */
|
|
357
|
+
let lastPulse = 0;
|
|
358
|
+
function tick(now, dt) {
|
|
359
|
+
if (!st.attached) return false;
|
|
360
|
+
const k = reduce ? 1 : 1 - Math.exp(-6 * dt), kk = reduce ? 1 : Math.min(1, dt * 7);
|
|
361
|
+
let moving = false, live = false;
|
|
362
|
+
const pulse = .5 + .5 * Math.sin(now / 420);
|
|
363
|
+
for (const el of [...EL.values()]) {
|
|
364
|
+
let moved = false;
|
|
365
|
+
if (!st.drag || st.drag.key !== el.key) for (let i = 0; i < 3; i++) { const d = el.tgt[i] - el.cur[i]; if (Math.abs(d) > .05) { el.cur[i] += d * k; moved = true; } else el.cur[i] = el.tgt[i]; }
|
|
366
|
+
const ds = el.tgtSc - el.sc; if (Math.abs(ds) > .002) { el.sc += ds * k; moved = true; } else el.sc = el.tgtSc;
|
|
367
|
+
for (const key of ['vis', 'hot', 'hov']) { const d = el.t[key] - el.c[key]; if (Math.abs(d) > .002) { el.c[key] += d * kk; moving = true; } else el.c[key] = el.t[key]; }
|
|
368
|
+
if (moved) { place(el); moving = true; }
|
|
369
|
+
if (el.leaving && el.sc <= .002) { if (el.orb === undefined) removeBlock(el); else { el.sc = 0; place(el); } continue; }
|
|
370
|
+
if (el.f.state === 'live') live = true;
|
|
371
|
+
if (el.orb === undefined) paintBlock(el, pulse); else paintOrb(el, pulse);
|
|
372
|
+
}
|
|
373
|
+
if (orbs) {
|
|
374
|
+
if (orbs.dirty) { for (const m of [orbs.body, orbs.out, orbs.ring]) m.instanceMatrix.needsUpdate = true; orbs.body.computeBoundingSphere(); orbs.dirty = false; }
|
|
375
|
+
if (orbs.colorDirty && orbs.body.instanceColor) { orbs.body.instanceColor.needsUpdate = true; orbs.colorDirty = false; }
|
|
376
|
+
orbs.out.material.uniforms.color.value.copy(E.outlineMat.uniforms.color.value);
|
|
377
|
+
orbs.glow.material.uniforms.intensity.value = live ? .35 + .35 * pulse : 0;
|
|
378
|
+
orbs.glow.material.uniforms.color.value.copy(E.statusColor('running'));
|
|
379
|
+
}
|
|
380
|
+
if (moving) for (const k2 of st.labels) { const el = EL.get(k2), t = LABELS.get(k2); if (el && t) t.mesh.position.copy(labelAt(el)); }
|
|
381
|
+
if (!moving && live && !reduce) { if (now - lastPulse > 40) { lastPulse = now; setTimeout(() => E.invalidate(), 40); } return false; }
|
|
382
|
+
return moving;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/* ---------- level of detail: lamps, creases and labels by on-screen size ---------- */
|
|
386
|
+
let lodTimer = 0;
|
|
387
|
+
const labelAt = el => W(el.cur[0], el.cur[1], el.cur[2] + el.size.h * el.sc);
|
|
388
|
+
function pxSize(el) {
|
|
389
|
+
const cam = E.camera, [VW, VH] = E.size;
|
|
390
|
+
V.copy(W(el.cur[0], el.cur[1], el.cur[2] + el.size.h / 2));
|
|
391
|
+
const d = cam.position.distanceTo(V);
|
|
392
|
+
const unit = E.isOrtho() ? VH * cam.zoom / (cam.top - cam.bottom) : VH / (2 * d * Math.tan(THREE.MathUtils.degToRad(cam.fov / 2)));
|
|
393
|
+
return el.size.s * TS * unit * Math.max(.001, el.sc);
|
|
394
|
+
}
|
|
395
|
+
function updateLabels(force) {
|
|
396
|
+
if (!st.attached || !st.L) return;
|
|
397
|
+
const now = performance.now(); if (!force && now - lodTimer < 120) return; lodTimer = now;
|
|
398
|
+
const cands = [];
|
|
399
|
+
for (const el of EL.values()) {
|
|
400
|
+
if (el.leaving) continue;
|
|
401
|
+
const px = pxSize(el);
|
|
402
|
+
if (el.orb === undefined) { el.lamps.visible = px > 18; for (const ch of el.body.children) if (ch.userData.crease) ch.visible = px > 30; }
|
|
403
|
+
const must = el.key === st.sel || el.key === st.hover || (st.drag && st.drag.key === el.key);
|
|
404
|
+
if (!must && (px < 44 || el.c.vis < .5)) continue;
|
|
405
|
+
const p = E.project(el.cur[0], el.cur[1], el.cur[2] + el.size.h); if (!p.on) continue;
|
|
406
|
+
cands.push({ key: el.key, x: p.x, y: p.y - 6, w: LABEL_W + 8, h: LABEL_H + 4, pri: must ? 1e9 : px });
|
|
407
|
+
}
|
|
408
|
+
const keep = new Set(CM.labelPick(cands, { max: 16 }));
|
|
409
|
+
st.labels = [...keep];
|
|
410
|
+
for (const [k, t] of LABELS) t.mesh.visible = keep.has(k);
|
|
411
|
+
for (const k of keep) { const el = EL.get(k), t = runLabel(el); t.mesh.visible = true; t.mesh.position.copy(labelAt(el)); }
|
|
412
|
+
}
|
|
413
|
+
E.controls.addEventListener('change', () => { if (st.attached) updateLabels(false); });
|
|
414
|
+
E.controls.addEventListener('end', () => { if (st.attached) updateLabels(true); });
|
|
415
|
+
|
|
416
|
+
/* ---------- pointer: hover, click, double-click, drag ---------- */
|
|
417
|
+
function hitRun(cx, cy) {
|
|
418
|
+
if (!st.attached) return null;
|
|
419
|
+
const lab = LABELPICK.length ? E.pickHit(cx, cy, LABELPICK) : null;
|
|
420
|
+
const h = E.pickHit(cx, cy, PICK, o => o.visible);
|
|
421
|
+
if (lab && (!h || lab.point.distanceTo(E.camera.position) < h.point.distanceTo(E.camera.position))) return { project: lab.object.userData.project };
|
|
422
|
+
if (!h) return null;
|
|
423
|
+
const key = h.object.userData.orbs ? orbs && orbs.keys[h.instanceId] : h.object.userData.cloud;
|
|
424
|
+
const el = key && EL.get(key);
|
|
425
|
+
return el && !el.leaving ? { key, point: h.point } : null;
|
|
426
|
+
}
|
|
427
|
+
function info(el) {
|
|
428
|
+
const f = el.f, r = f.run, counts = Object.entries(r.counts || {}).map(([s, n]) => `${s.toLowerCase()} ${n}`).join(' · ');
|
|
429
|
+
const proj = st.L && st.L.projects.find(p => p.id === f.project);
|
|
430
|
+
return [['meta', `${proj ? proj.name : RM.basename(r.repo)} · ${r.run_id || ''} · ${r.kind}`], ['title', String(r.goal || 'Untitled run').split('\n')[0]],
|
|
431
|
+
['body', `${f.n} task${f.n > 1 ? 's' : ''} · ${counts}`], ['body', `${[...f.dwarf].join(', ') || '—'} → ${[...f.qa].join(', ') || '—'}`],
|
|
432
|
+
['foot', `started ${new Date(f.t * 1000).toLocaleString()}${r.final_review ? ' · review ' + r.final_review : ''}${r.verification_status ? ' · verify ' + r.verification_status : ''}`],
|
|
433
|
+
['foot', (store.layouts[st.layout] || {})[el.key] ? 'moved by you · drag to move, Shift = depth, Alt = height' : 'drag to move · double-click to open']];
|
|
434
|
+
}
|
|
435
|
+
function showTip(el, cx, cy) {
|
|
436
|
+
tip.replaceChildren();
|
|
437
|
+
for (const [cls, text] of info(el)) { const d = document.createElement('div'); d.className = 'stage-tip-' + cls; d.textContent = text; tip.append(d); }
|
|
438
|
+
tip.hidden = false;
|
|
439
|
+
const r = host.getBoundingClientRect(), tw = tip.offsetWidth || 260, th = tip.offsetHeight || 90;
|
|
440
|
+
let x = cx - r.left + 16, y = cy - r.top + 16;
|
|
441
|
+
if (x + tw > r.width - 8) x = cx - r.left - tw - 16;
|
|
442
|
+
if (y + th > r.height - 8) y = cy - r.top - th - 16;
|
|
443
|
+
tip.style.transform = `translate(${Math.max(4, x).toFixed(0)}px, ${Math.max(4, y).toFixed(0)}px)`;
|
|
444
|
+
}
|
|
445
|
+
const hideTip = () => { tip.hidden = true; };
|
|
446
|
+
let hoverRaf = 0, hoverAt = null;
|
|
447
|
+
const handlers = {
|
|
448
|
+
hover(cx, cy) {
|
|
449
|
+
hoverAt = cx === null ? null : [cx, cy];
|
|
450
|
+
if (hoverRaf) return;
|
|
451
|
+
hoverRaf = requestAnimationFrame(() => {
|
|
452
|
+
hoverRaf = 0;
|
|
453
|
+
const h = hoverAt ? hitRun(hoverAt[0], hoverAt[1]) : null, key = h && h.key || null;
|
|
454
|
+
E.canvas.classList.toggle('pointing', !!h);
|
|
455
|
+
if (key !== st.hover) { st.hover = key; retarget(); }
|
|
456
|
+
if (key && hoverAt) showTip(EL.get(key), hoverAt[0], hoverAt[1]); else hideTip();
|
|
457
|
+
});
|
|
458
|
+
},
|
|
459
|
+
click(cx, cy) {
|
|
460
|
+
hideTip();
|
|
461
|
+
const h = hitRun(cx, cy);
|
|
462
|
+
if (h && h.project) { api.focusProject(st.focus === h.project ? null : h.project); cb.onFocus && cb.onFocus(st.focus); return; }
|
|
463
|
+
api.select(h ? h.key : null); cb.onSelect && cb.onSelect(st.sel);
|
|
464
|
+
},
|
|
465
|
+
dbl(cx, cy) { const h = hitRun(cx, cy); if (h && h.key && cb.onOpenRun) cb.onOpenRun(h.key); },
|
|
466
|
+
grab(cx, cy) {
|
|
467
|
+
const h = hitRun(cx, cy); if (!h || !h.key) return null;
|
|
468
|
+
const el = EL.get(h.key), P = W(...el.cur), n = new THREE.Vector3(); E.camera.getWorldDirection(n);
|
|
469
|
+
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(n, P);
|
|
470
|
+
const H = E.rayAt(cx, cy).ray.intersectPlane(plane, new THREE.Vector3()) || P.clone();
|
|
471
|
+
st.drag = { key: h.key, plane, off: P.clone().sub(H), P: P.clone(), start: el.cur.slice(), lastY: cy };
|
|
472
|
+
hideTip(); return st.drag;
|
|
473
|
+
},
|
|
474
|
+
drag(cx, cy, e, tok) {
|
|
475
|
+
const el = EL.get(tok.key); if (!el) return;
|
|
476
|
+
const n = new THREE.Vector3(); E.camera.getWorldDirection(n);
|
|
477
|
+
const [, VH] = E.size, dist = E.camera.position.distanceTo(tok.P), dy = (cy - tok.lastY) / VH * 2 * dist;
|
|
478
|
+
if (e.shiftKey) tok.P.addScaledVector(n, -dy); // Shift: toward or away from you
|
|
479
|
+
else if (e.altKey) tok.P.y -= dy * .5; // Alt: straight up or down
|
|
480
|
+
else { const H = E.rayAt(cx, cy).ray.intersectPlane(tok.plane, V2); if (H) tok.P.copy(H).add(tok.off); }
|
|
481
|
+
if (e.shiftKey || e.altKey) { // a changed modifier carries on from where the run is now
|
|
482
|
+
tok.plane.setFromNormalAndCoplanarPoint(n, tok.P);
|
|
483
|
+
const H = E.rayAt(cx, cy).ray.intersectPlane(tok.plane, V2); if (H) tok.off.copy(tok.P).sub(H);
|
|
484
|
+
}
|
|
485
|
+
tok.lastY = cy;
|
|
486
|
+
el.cur = [tok.P.x / TS, tok.P.z / TS, Math.max(-5000, tok.P.y / TS)]; el.tgt = el.cur.slice(); place(el);
|
|
487
|
+
const t = LABELS.get(el.key); if (t) t.mesh.position.copy(labelAt(el));
|
|
488
|
+
},
|
|
489
|
+
drop(cx, cy, e, tok, how) {
|
|
490
|
+
const el = EL.get(tok.key);
|
|
491
|
+
st.drag = null;
|
|
492
|
+
if (!el) return;
|
|
493
|
+
if (how && how.cancel) { el.cur = tok.start.slice(); el.tgt = tok.start.slice(); place(el); retarget(); return; }
|
|
494
|
+
if (how && how.moved) {
|
|
495
|
+
(store.layouts[st.layout] = store.layouts[st.layout] || {})[el.key] = [...el.cur.map(v => Math.round(v * 10) / 10), Math.round(nowSec())];
|
|
496
|
+
saveStore();
|
|
497
|
+
if (st.L) { st.L.pos[el.key] = el.cur.slice(); if (st.layout === 'constellation') buildFrames(); }
|
|
498
|
+
cb.onMoved && cb.onMoved(el.key);
|
|
499
|
+
}
|
|
500
|
+
retarget();
|
|
501
|
+
},
|
|
502
|
+
key(e) {
|
|
503
|
+
const k = e.key, cam = E.camera, ctr = E.controls.target;
|
|
504
|
+
if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowUp' || k === 'ArrowDown') {
|
|
505
|
+
const sph = new THREE.Spherical().setFromVector3(V.copy(cam.position).sub(ctr));
|
|
506
|
+
if (k === 'ArrowLeft') sph.theta -= .09; if (k === 'ArrowRight') sph.theta += .09;
|
|
507
|
+
if (k === 'ArrowUp') sph.phi = Math.max(.03, sph.phi - .07); if (k === 'ArrowDown') sph.phi = Math.min(Math.PI - .03, sph.phi + .07);
|
|
508
|
+
cam.position.copy(ctr).add(V.setFromSpherical(sph)); cam.lookAt(ctr); return true;
|
|
509
|
+
}
|
|
510
|
+
if (k === '+' || k === '=' || k === '-' || k === '_') { const f = k === '+' || k === '=' ? .85 : 1 / .85; cam.position.sub(ctr).multiplyScalar(f).add(ctr); if (E.isOrtho()) { cam.zoom /= f; cam.updateProjectionMatrix(); } return true; }
|
|
511
|
+
if (k === '0' || k === 'Home') { api.resetView(); return true; }
|
|
512
|
+
if (k === 'f' || k === 'F') { if (st.sel) api.focusRun(st.sel); else frameAll(); return true; }
|
|
513
|
+
if (k === 'p' || k === 'P') {
|
|
514
|
+
const ids = st.L ? st.L.clusters.map(c => c.pid) : []; if (!ids.length) return true;
|
|
515
|
+
const i = ids.indexOf(st.focus), next = i < 0 ? ids[0] : i === ids.length - 1 ? null : ids[i + 1];
|
|
516
|
+
api.focusProject(next); cb.onFocus && cb.onFocus(next); return true;
|
|
517
|
+
}
|
|
518
|
+
if (k === '[' || k === ']') {
|
|
519
|
+
const keys = st.visible.slice().sort((a, b) => a.t - b.t).map(f => f.key); if (!keys.length) return true;
|
|
520
|
+
const i = keys.indexOf(st.sel), d = k === ']' ? 1 : -1, next = keys[i < 0 ? (d > 0 ? 0 : keys.length - 1) : (i + d + keys.length) % keys.length];
|
|
521
|
+
api.select(next); api.focusRun(next); cb.onSelect && cb.onSelect(next); return true;
|
|
522
|
+
}
|
|
523
|
+
if (k === 'Enter' && st.sel) { cb.onOpenRun && cb.onOpenRun(st.sel); return true; }
|
|
524
|
+
if (k === 'l' || k === 'L') { const i = CM.LAYOUTS.indexOf(st.layout); cb.onLayout && cb.onLayout(CM.LAYOUTS[(i + 1) % CM.LAYOUTS.length]); return true; }
|
|
525
|
+
if (k === 'b' || k === 'B') { cb.onShape && cb.onShape(st.shape === 'blocks' ? 'orbs' : 'blocks'); return true; }
|
|
526
|
+
if (k === 'Escape') {
|
|
527
|
+
if (st.sel) { api.select(null); cb.onSelect && cb.onSelect(null); return true; }
|
|
528
|
+
if (st.focus) { api.focusProject(null); cb.onFocus && cb.onFocus(null); return true; }
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
return false;
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
/* ---------- public ---------- */
|
|
536
|
+
const api = {
|
|
537
|
+
handlers,
|
|
538
|
+
// the cloud keeps its runs while another view is shown, and comes back to where its camera was
|
|
539
|
+
attach() {
|
|
540
|
+
if (st.attached) return;
|
|
541
|
+
st.attached = true; E.scene.add(root); E.tickers.add(tick); E.setProfile('free');
|
|
542
|
+
if (st.cam) { E.cancelCam(); E.controls.target.copy(st.cam.t); E.camera.position.copy(st.cam.p); }
|
|
543
|
+
E.invalidate(300);
|
|
544
|
+
},
|
|
545
|
+
detach() {
|
|
546
|
+
if (!st.attached) return;
|
|
547
|
+
st.cam = { p: E.camera.position.clone(), t: E.controls.target.clone() };
|
|
548
|
+
st.attached = false; E.scene.remove(root); E.tickers.delete(tick); hideTip(); E.setProfile('plate'); E.canvas.classList.remove('pointing', 'grabbing');
|
|
549
|
+
},
|
|
550
|
+
// runs: RunSummary list; opts: {filter, layout, shape, sel, focus}
|
|
551
|
+
show(runs, opts = {}) {
|
|
552
|
+
api.attach();
|
|
553
|
+
if (opts.layout && CM.LAYOUTS.includes(opts.layout)) st.layout = opts.layout;
|
|
554
|
+
if (opts.shape === 'blocks' || opts.shape === 'orbs') st.shape = opts.shape;
|
|
555
|
+
if (opts.filter) st.filter = CM.normFilter(opts.filter);
|
|
556
|
+
if (opts.sel !== undefined) st.sel = opts.sel;
|
|
557
|
+
if (opts.focus !== undefined) st.focus = opts.focus;
|
|
558
|
+
if (opts.inset !== undefined) st.inset = opts.inset;
|
|
559
|
+
const first = st.framed === null || st.framed !== st.layout;
|
|
560
|
+
api.update(runs, { frame: first });
|
|
561
|
+
st.framed = st.layout;
|
|
562
|
+
return api.counts();
|
|
563
|
+
},
|
|
564
|
+
update(runs, { frame = false } = {}) {
|
|
565
|
+
st.runs = Array.isArray(runs) ? runs : [];
|
|
566
|
+
const now = nowSec(), prev = st.facts;
|
|
567
|
+
st.facts = new Map(st.runs.map(r => [r.key, CM.runFacts(r, now)]));
|
|
568
|
+
relayout({ frame: frame || (!prev.size && st.facts.size > 0) }); // the first runs to arrive are framed
|
|
569
|
+
// recolour lamps and redraw labels whose text moved (a status change alone does not lay out again)
|
|
570
|
+
for (const el of EL.values()) {
|
|
571
|
+
const f = st.facts.get(el.key); if (!f) continue;
|
|
572
|
+
const sig = JSON.stringify([f.state, (f.run.task_statuses || []).map(t => t.status), f.run.goal]);
|
|
573
|
+
if (sig === el.sig) continue;
|
|
574
|
+
const had = el.sig; el.sig = sig; el.f = f;
|
|
575
|
+
if (el.orb === undefined) setLamps(el); else { setOrbSegments(el); place(el); }
|
|
576
|
+
const t = LABELS.get(el.key); if (t && had && prev.size) t.redraw();
|
|
577
|
+
}
|
|
578
|
+
E.invalidate(400);
|
|
579
|
+
return api.counts();
|
|
580
|
+
},
|
|
581
|
+
setFilter(f) { st.filter = CM.normFilter(f); relayout(); return api.counts(); },
|
|
582
|
+
setLayout(name) { if (!CM.LAYOUTS.includes(name) || name === st.layout) return; st.layout = name; relayout({ frame: true }); st.framed = name; },
|
|
583
|
+
setShape(shape) { if (shape !== 'blocks' && shape !== 'orbs' || shape === st.shape) return; st.shape = shape; st.sig = ''; relayout(); },
|
|
584
|
+
select(key) { st.sel = key && EL.has(key) ? key : null; retarget(); },
|
|
585
|
+
focusProject(pid) { st.focus = pid || null; retarget(); buildFrames(); frameAll(reduce ? 0 : 900); },
|
|
586
|
+
focusRun(key) {
|
|
587
|
+
const el = EL.get(key); if (!el) return;
|
|
588
|
+
const s = el.size.s * 2.2;
|
|
589
|
+
frameBox([el.cur[0] - s, el.cur[1] - s, el.cur[2], el.cur[0] + s, el.cur[1] + s, el.cur[2] + el.size.h * 2], reduce ? 0 : 800);
|
|
590
|
+
},
|
|
591
|
+
resetView() { frameAll(reduce ? 0 : 800); },
|
|
592
|
+
// pixels the page's overlays cover on each side ({l, r, t, b}), kept clear when framing
|
|
593
|
+
setInsets(inset) { st.inset = inset || null; },
|
|
594
|
+
pinnedCount() { return Object.keys(store.layouts[st.layout] || {}).length; },
|
|
595
|
+
isPinned(key) { return !!(store.layouts[st.layout] || {})[key]; },
|
|
596
|
+
resetPositions(scope = 'layout', key = null) {
|
|
597
|
+
const before = JSON.parse(JSON.stringify(store.layouts[st.layout] || {}));
|
|
598
|
+
if (scope === 'run' && key) delete (store.layouts[st.layout] || {})[key];
|
|
599
|
+
else store.layouts[st.layout] = {};
|
|
600
|
+
saveStore(); relayout();
|
|
601
|
+
return () => { store.layouts[st.layout] = before; saveStore(); relayout(); }; // undo
|
|
602
|
+
},
|
|
603
|
+
exportPositions() {
|
|
604
|
+
const labels = {}; for (const r of st.runs) labels[r.key] = r.run_id || '';
|
|
605
|
+
return CM.serializeLayout(store, labels, nowSec());
|
|
606
|
+
},
|
|
607
|
+
importPositions(obj) {
|
|
608
|
+
const res = CM.parseLayoutFile(obj); if (!res.ok) return res;
|
|
609
|
+
for (const [name, entries] of Object.entries(res.layouts)) store.layouts[name] = { ...(store.layouts[name] || {}), ...entries };
|
|
610
|
+
saveStore(); relayout();
|
|
611
|
+
const known = new Set(st.runs.map(r => r.key));
|
|
612
|
+
res.unknown = Object.values(res.layouts).reduce((a, e) => a + Object.keys(e).filter(k => !known.has(k)).length, 0);
|
|
613
|
+
return res;
|
|
614
|
+
},
|
|
615
|
+
counts() { return { visible: st.visible.length, total: st.facts.size, projects: st.L ? st.L.projects.length : 0, layout: st.layout, shape: st.shape }; },
|
|
616
|
+
get layout() { return st.layout; },
|
|
617
|
+
get shape() { return st.shape; },
|
|
618
|
+
get focus() { return st.focus; },
|
|
619
|
+
debug() {
|
|
620
|
+
return {
|
|
621
|
+
layout: st.layout, shape: st.shape, visible: st.visible.map(f => f.key), pinned: Object.keys(store.layouts[st.layout] || {}), sel: st.sel, focus: st.focus,
|
|
622
|
+
labels: st.labels.slice(), buildMs: st.buildMs, calls: E.renderer.info.render.calls,
|
|
623
|
+
pos(key) { const el = EL.get(key); return el ? el.cur.slice() : null; },
|
|
624
|
+
screenPos(key) {
|
|
625
|
+
const el = EL.get(key); if (!el) return null;
|
|
626
|
+
const r = E.canvas.getBoundingClientRect(), p = E.project(el.cur[0], el.cur[1], el.cur[2] + el.size.h * .5);
|
|
627
|
+
return { x: r.left + p.x, y: r.top + p.y, on: p.on };
|
|
628
|
+
},
|
|
629
|
+
settle() { for (const el of EL.values()) { el.cur = el.tgt.slice(); el.sc = el.tgtSc; Object.assign(el.c, el.t); place(el); } E.settleCam(); updateLabels(true); E.invalidate(200); },
|
|
630
|
+
// what a pointer at (cx, cy) would pick: a run key, 'project:<id>' or null
|
|
631
|
+
hit(cx, cy) { const h = hitRun(cx, cy); return h ? h.key || 'project:' + h.project : null; },
|
|
632
|
+
// a run that a click at its middle picks, inside the given client rect
|
|
633
|
+
clickable(x0 = 0, y0 = 0, x1 = 1e9, y1 = 1e9) {
|
|
634
|
+
const r = E.canvas.getBoundingClientRect();
|
|
635
|
+
for (const k of st.visible.map(f => f.key)) {
|
|
636
|
+
const el = EL.get(k); if (!el) continue;
|
|
637
|
+
const p = E.project(el.cur[0], el.cur[1], el.cur[2] + el.size.h * .5), x = r.left + p.x, y = r.top + p.y;
|
|
638
|
+
if (p.on && x > x0 && x < x1 && y > y0 && y < y1 && document.elementFromPoint(x, y) === E.canvas && hitRun(x, y)?.key === k) return { key: k, x, y };
|
|
639
|
+
}
|
|
640
|
+
return null;
|
|
641
|
+
},
|
|
642
|
+
};
|
|
643
|
+
},
|
|
644
|
+
};
|
|
645
|
+
E.themeHooks.add(() => { if (st.L) { buildFrames(); for (const el of EL.values()) if (el.orb === undefined) setLamps(el); else setOrbSegments(el); } });
|
|
646
|
+
return api;
|
|
647
|
+
}
|