@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,375 @@
|
|
|
1
|
+
/* forge-ui cloud model: pure data for the run cloud (no DOM, no I/O). Groups runs by project, filters
|
|
2
|
+
them, and places them in one of five layouts. A plain script in the page (window.ForgeCloudModel) and a
|
|
3
|
+
CommonJS module under node:test. Coordinates are plan units (centimetres; x right, y toward the viewer,
|
|
4
|
+
z up), the same as iso.js's W(), and a position is the centre of a run's footprint at its base. */
|
|
5
|
+
(function (root, factory) {
|
|
6
|
+
'use strict';
|
|
7
|
+
const RM = typeof module === 'object' && module.exports ? require('./runmodel.js') : root.ForgeRunModel;
|
|
8
|
+
const api = factory(RM);
|
|
9
|
+
if (typeof module === 'object' && module.exports) module.exports = api;
|
|
10
|
+
else root.ForgeCloudModel = api;
|
|
11
|
+
})(typeof self !== 'undefined' ? self : this, function (RM) {
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const list = v => Array.isArray(v) ? v : [];
|
|
15
|
+
const num = v => typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
16
|
+
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
|
17
|
+
const GOLDEN = Math.PI * (3 - Math.sqrt(5));
|
|
18
|
+
const LAYOUTS = ['helix', 'sphere', 'galaxy', 'timeline', 'constellation'];
|
|
19
|
+
const LAYOUT_INFO = {
|
|
20
|
+
helix: { name: 'Helix', elevation: 18, azimuth: 45, blurb: 'One spiral per project: oldest runs at the bottom, newest at the top.' },
|
|
21
|
+
sphere: { name: 'Sphere', elevation: 24, azimuth: 45, blurb: 'Runs spread evenly over a shell per project, oldest at the bottom pole.' },
|
|
22
|
+
galaxy: { name: 'Galaxy', elevation: 55, azimuth: 45, blurb: 'A flat spiral disc per project, newest at the centre.' },
|
|
23
|
+
timeline: { name: 'Timeline', elevation: 52, azimuth: 90, blurb: 'Left to right is when each run started; one lane per project.' },
|
|
24
|
+
constellation: { name: 'Constellation', elevation: 24, azimuth: 45, blurb: 'Runs pulled together by project and by the models they share; lines join each project\'s nearest runs.' },
|
|
25
|
+
};
|
|
26
|
+
const DIFFS = ['low', 'medium', 'high'];
|
|
27
|
+
|
|
28
|
+
// FNV-1a: a short stable id for a project (its repo path)
|
|
29
|
+
function hashId(s) {
|
|
30
|
+
let h = 0x811c9dc5;
|
|
31
|
+
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; }
|
|
32
|
+
return h.toString(16).padStart(8, '0');
|
|
33
|
+
}
|
|
34
|
+
const unit = s => hashId(String(s)).split('').reduce((a, c) => (a * 16 + parseInt(c, 16)) % 1e9, 7) / 1e9; // [0, 1)
|
|
35
|
+
const startOf = r => num(r.created) ?? num(r.updated) ?? 0;
|
|
36
|
+
|
|
37
|
+
/* ---------- projects and per-run facts ---------- */
|
|
38
|
+
function projects(runs) {
|
|
39
|
+
const by = new Map();
|
|
40
|
+
for (const r of list(runs)) {
|
|
41
|
+
const repo = r.repo || '?', id = hashId(repo);
|
|
42
|
+
if (!by.has(id)) by.set(id, { id, repo: r.repo || null, name: RM.basename(r.repo), keys: [], first: Infinity, last: -Infinity, count: 0 });
|
|
43
|
+
const p = by.get(id), t = startOf(r);
|
|
44
|
+
p.keys.push(r.key); p.count++; p.first = Math.min(p.first, t); p.last = Math.max(p.last, num(r.updated) ?? t);
|
|
45
|
+
}
|
|
46
|
+
const out = [...by.values()].sort((a, b) => a.first - b.first || a.id.localeCompare(b.id));
|
|
47
|
+
// two repos with the same folder name: add the parent folder
|
|
48
|
+
const names = new Map(); for (const p of out) names.set(p.name, (names.get(p.name) || 0) + 1);
|
|
49
|
+
for (const p of out) if (names.get(p.name) > 1 && p.repo) { const parts = String(p.repo).split(/[\\/]/).filter(Boolean); p.name = `${p.name} (${parts[parts.length - 2] || '/'})`; }
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
const bucket = n => clamp(Math.round(Math.sqrt(Math.max(1, n))), 1, 9);
|
|
53
|
+
const blockSize = b => ({ s: 70 + 24 * b, h: 40 + 6 * b });
|
|
54
|
+
const orbRadius = b => 26 + 9 * b;
|
|
55
|
+
function runFacts(run, now) {
|
|
56
|
+
const tasks = list(run.task_statuses);
|
|
57
|
+
const diff = { low: 0, medium: 0, high: 0, none: 0 };
|
|
58
|
+
for (const t of tasks) { const d = String(t.difficulty || '').toLowerCase(); diff[DIFFS.includes(d) ? d : 'none']++; }
|
|
59
|
+
const dominant = [...DIFFS, 'none'].reduce((a, k) => diff[k] > diff[a] ? k : a, 'none');
|
|
60
|
+
let state = RM.runState(run);
|
|
61
|
+
if (state === 'live' && num(now) !== null && num(run.updated) !== null && now - run.updated > 7200) state = 'stale';
|
|
62
|
+
const n = Math.max(1, num(run.task_count) || tasks.length || 1);
|
|
63
|
+
const aliases = specs => new Set(list(specs).map(RM.alias).filter(Boolean));
|
|
64
|
+
return { key: run.key, run, project: hashId(run.repo || '?'), n, bucket: bucket(n), diff, dominant, state, t: startOf(run),
|
|
65
|
+
dwarf: aliases(run.dwarf_models || run.models), qa: aliases(run.qa_models || []), all: aliases(run.models),
|
|
66
|
+
review: run.final_review || null, verification: run.verification_status || null, decompose: run.decompose_level || null, kind: run.kind };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/* ---------- filters: the cloud shows exactly the runs that match ---------- */
|
|
70
|
+
function emptyFilter() {
|
|
71
|
+
return { text: '', projects: [], states: [], kinds: [], difficulty: { levels: [], mode: 'contains' }, tasks: { min: null, max: null },
|
|
72
|
+
models: { aliases: [], role: 'any' }, activeDays: 0, review: '', verification: '', decompose: [] };
|
|
73
|
+
}
|
|
74
|
+
function normFilter(f) {
|
|
75
|
+
const e = emptyFilter(); f = f || {};
|
|
76
|
+
return { ...e, ...f, difficulty: { ...e.difficulty, ...(f.difficulty || {}) }, tasks: { ...e.tasks, ...(f.tasks || {}) }, models: { ...e.models, ...(f.models || {}) } };
|
|
77
|
+
}
|
|
78
|
+
function activeCount(f) {
|
|
79
|
+
f = normFilter(f);
|
|
80
|
+
return [f.text.trim(), f.projects.length, f.states.length, f.kinds.length, f.difficulty.levels.length, num(f.tasks.min) !== null || num(f.tasks.max) !== null,
|
|
81
|
+
f.models.aliases.length, f.activeDays, f.review, f.verification, f.decompose.length].filter(Boolean).length;
|
|
82
|
+
}
|
|
83
|
+
function verdictMatch(want, have) { return !want || (want === 'none' ? !have : have === want); }
|
|
84
|
+
function match(fact, f, now) {
|
|
85
|
+
f = normFilter(f);
|
|
86
|
+
const r = fact.run;
|
|
87
|
+
const text = f.text.trim().toLowerCase();
|
|
88
|
+
if (text) {
|
|
89
|
+
const hay = [r.goal, r.repo, r.run_id, r.path, ...list(r.models), ...list(r.task_statuses).flatMap(t => [t.id, t.title])];
|
|
90
|
+
if (!hay.some(v => String(v || '').toLowerCase().includes(text))) return false;
|
|
91
|
+
}
|
|
92
|
+
if (f.projects.length && !f.projects.includes(fact.project)) return false;
|
|
93
|
+
if (f.states.length && !f.states.includes(fact.state)) return false;
|
|
94
|
+
if (f.kinds.length && !f.kinds.includes(fact.kind)) return false;
|
|
95
|
+
if (f.difficulty.levels.length) {
|
|
96
|
+
const ok = f.difficulty.mode === 'dominant' ? f.difficulty.levels.includes(fact.dominant) : f.difficulty.levels.some(l => fact.diff[l] > 0);
|
|
97
|
+
if (!ok) return false;
|
|
98
|
+
}
|
|
99
|
+
if (num(f.tasks.min) !== null && fact.n < f.tasks.min) return false;
|
|
100
|
+
if (num(f.tasks.max) !== null && fact.n > f.tasks.max) return false;
|
|
101
|
+
if (f.models.aliases.length) {
|
|
102
|
+
const pool = f.models.role === 'dwarf' ? fact.dwarf : f.models.role === 'qa' ? fact.qa : fact.all;
|
|
103
|
+
if (!f.models.aliases.some(a => pool.has(a))) return false;
|
|
104
|
+
}
|
|
105
|
+
if (f.activeDays && num(now) !== null && !(num(r.updated) !== null && r.updated >= now - f.activeDays * 86400)) return false;
|
|
106
|
+
if (!verdictMatch(f.review, fact.review)) return false;
|
|
107
|
+
if (!verdictMatch(f.verification, fact.verification)) return false;
|
|
108
|
+
if (f.decompose.length && !f.decompose.includes(fact.decompose || 'none')) return false;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
function options(facts) {
|
|
112
|
+
const states = {}, kinds = {}, diff = { low: 0, medium: 0, high: 0, none: 0 }, aliases = new Map(), decompose = {};
|
|
113
|
+
let maxTasks = 1;
|
|
114
|
+
for (const f of facts) {
|
|
115
|
+
states[f.state] = (states[f.state] || 0) + 1; kinds[f.kind] = (kinds[f.kind] || 0) + 1;
|
|
116
|
+
for (const k of Object.keys(diff)) if (f.diff[k]) diff[k]++;
|
|
117
|
+
for (const a of f.all) aliases.set(a, (aliases.get(a) || 0) + 1);
|
|
118
|
+
const d = f.decompose || 'none'; decompose[d] = (decompose[d] || 0) + 1;
|
|
119
|
+
maxTasks = Math.max(maxTasks, f.n);
|
|
120
|
+
}
|
|
121
|
+
return { states, kinds, difficulty: diff, aliases: [...aliases].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([alias, count]) => ({ alias, count })), decompose, maxTasks };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ---------- cluster anchors: computed from every run, so a filter never moves a project ---------- */
|
|
125
|
+
function clusterRadius(layout, m, smax) {
|
|
126
|
+
if (layout === 'sphere') return Math.max(smax, Math.sqrt(m * (1.3 * smax) ** 2 / (4 * Math.PI))) + smax;
|
|
127
|
+
if (layout === 'galaxy') return 1.05 * smax * Math.sqrt(m + 1) + smax;
|
|
128
|
+
const k = clamp(Math.round(4 + 2 * Math.sqrt(m)), 6, 24);
|
|
129
|
+
return Math.max(.9 * smax, k * 1.35 * smax / (2 * Math.PI)) + smax;
|
|
130
|
+
}
|
|
131
|
+
function anchors(layout, projs, countOf, smax) {
|
|
132
|
+
const placed = [], out = new Map(), gap = 140, step = 220;
|
|
133
|
+
for (const p of projs) {
|
|
134
|
+
const R = Math.ceil(clusterRadius(layout, countOf(p), smax) / 50) * 50; // quantised: a few more runs do not move it
|
|
135
|
+
for (let j = 0; j < 100000; j++) {
|
|
136
|
+
const r = step * Math.sqrt(j), th = j * GOLDEN, x = r * Math.cos(th), y = r * Math.sin(th);
|
|
137
|
+
if (placed.every(q => Math.hypot(q.x - x, q.y - y) >= q.R + R + gap)) { placed.push({ x, y, R }); out.set(p.id, { x, y, R }); break; }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* ---------- the layouts ---------- */
|
|
144
|
+
function sizes(facts) {
|
|
145
|
+
let smax = 0, hmax = 0;
|
|
146
|
+
for (const f of facts) { const b = blockSize(f.bucket); smax = Math.max(smax, b.s); hmax = Math.max(hmax, b.h); }
|
|
147
|
+
return { smax: smax || blockSize(1).s, hmax: hmax || blockSize(1).h };
|
|
148
|
+
}
|
|
149
|
+
function helix(group, c, smax, hmax, pid) {
|
|
150
|
+
const m = group.length, k = clamp(Math.round(4 + 2 * Math.sqrt(m)), 6, 24);
|
|
151
|
+
const a = 1.35 * smax, R = Math.max(.9 * smax, k * a / (2 * Math.PI)), th0 = unit(pid) * Math.PI * 2;
|
|
152
|
+
const pos = {};
|
|
153
|
+
group.forEach((f, i) => { const th = th0 + i * 2 * Math.PI / k; pos[f.key] = [c.x + R * Math.cos(th), c.y + R * Math.sin(th), i * (hmax + 40) / k]; });
|
|
154
|
+
return { pos, ring: R, top: m ? (m - 1) * (hmax + 40) / k + hmax : 0 };
|
|
155
|
+
}
|
|
156
|
+
function sphere(group, c, smax) {
|
|
157
|
+
const m = group.length, R = Math.max(smax, Math.sqrt(m * (1.3 * smax) ** 2 / (4 * Math.PI))), pos = {};
|
|
158
|
+
group.forEach((f, i) => {
|
|
159
|
+
const yy = -1 + (2 * i + 1) / Math.max(1, m), rr = Math.sqrt(Math.max(0, 1 - yy * yy)), ph = i * GOLDEN;
|
|
160
|
+
pos[f.key] = [c.x + R * rr * Math.cos(ph), c.y + R * rr * Math.sin(ph), R * (1 + yy)];
|
|
161
|
+
});
|
|
162
|
+
return { pos, ring: R, top: 2 * R + smax };
|
|
163
|
+
}
|
|
164
|
+
function galaxy(group, c, smax) {
|
|
165
|
+
const m = group.length, cc = 1.05 * smax, pos = {}; // blocks are axis-aligned squares: wide enough apart on any diagonal
|
|
166
|
+
group.slice().reverse().forEach((f, i) => { // newest first, at the centre
|
|
167
|
+
const r = cc * Math.sqrt(i + .5), th = i * GOLDEN + unit(f.key) * .2;
|
|
168
|
+
pos[f.key] = [c.x + r * Math.cos(th), c.y + r * Math.sin(th), (unit(f.key + '*') - .5) * 30 + 15];
|
|
169
|
+
});
|
|
170
|
+
return { pos, ring: cc * Math.sqrt(m + .5) + smax / 2, top: smax };
|
|
171
|
+
}
|
|
172
|
+
function niceAxis(t0, t1, L) {
|
|
173
|
+
const span = Math.max(1, t1 - t0), units = [[3600, 'hour'], [6 * 3600, '6h'], [86400, 'day'], [7 * 86400, 'week'], [30 * 86400, 'month'], [365 * 86400, 'year']];
|
|
174
|
+
const [step] = units.find(([u]) => span / u <= 12) || units[units.length - 1];
|
|
175
|
+
const ticks = [];
|
|
176
|
+
for (let t = Math.ceil(t0 / step) * step; t <= t1; t += step) ticks.push({ t, x: (t - t0) / span * L });
|
|
177
|
+
return { t0, t1, step, length: L, ticks };
|
|
178
|
+
}
|
|
179
|
+
function timeline(facts, projs, smax, hmax) {
|
|
180
|
+
const N = facts.length, L = clamp(400 * Math.sqrt(Math.max(1, N)), 2000, 30000);
|
|
181
|
+
const t0 = Math.min(...facts.map(f => f.t)), t1 = Math.max(...facts.map(f => f.t), t0 + 1);
|
|
182
|
+
const pos = {}, clusters = [], laneD = 1.5 * smax;
|
|
183
|
+
let lane = 0;
|
|
184
|
+
for (const p of projs) {
|
|
185
|
+
const group = facts.filter(f => f.project === p.id).sort((a, b) => a.t - b.t);
|
|
186
|
+
if (!group.length) continue;
|
|
187
|
+
const levels = [];
|
|
188
|
+
for (const f of group) {
|
|
189
|
+
const s = blockSize(f.bucket).s, x = (f.t - t0) / (t1 - t0) * L;
|
|
190
|
+
let lv = levels.findIndex(end => end <= x - s / 2 - 10);
|
|
191
|
+
if (lv < 0) { lv = levels.length; levels.push(0); }
|
|
192
|
+
levels[lv] = x + s / 2;
|
|
193
|
+
pos[f.key] = [x, lane * laneD, lv * (hmax + 20)];
|
|
194
|
+
}
|
|
195
|
+
clusters.push({ pid: p.id, center: [L / 2, lane * laneD], radius: L / 2 + smax, lane: [-smax, lane * laneD - laneD / 2 + 8, L + 2 * smax, laneD - 16], z0: 0, z1: levels.length * (hmax + 20), shape: 'lane' });
|
|
196
|
+
lane++;
|
|
197
|
+
}
|
|
198
|
+
return { pos, clusters, axis: niceAxis(t0, t1, L) };
|
|
199
|
+
}
|
|
200
|
+
// a seeded PRNG, so the same runs always settle the same way
|
|
201
|
+
function mulberry32(a) { return () => { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
|
|
202
|
+
function jaccard(a, b) { if (!a.size || !b.size) return 0; let n = 0; for (const x of a) if (b.has(x)) n++; return n / (a.size + b.size - n); }
|
|
203
|
+
// 3D force layout: springs between runs of one project and runs that share models, repulsion within a cutoff
|
|
204
|
+
function forceLayout(facts, { iterations = 300, seed = 7 } = {}) {
|
|
205
|
+
const N = facts.length, rnd = mulberry32(seed), s = sizes(facts).smax, R0 = 100 * Math.sqrt(Math.max(1, N));
|
|
206
|
+
const P = facts.map(() => [(rnd() - .5) * 2 * R0, (rnd() - .5) * 2 * R0, rnd() * R0]);
|
|
207
|
+
const links = [];
|
|
208
|
+
const byNode = facts.map(() => []);
|
|
209
|
+
for (let i = 0; i < N; i++) for (let j = i + 1; j < N; j++) {
|
|
210
|
+
const same = facts[i].project === facts[j].project, J = jaccard(facts[i].all, facts[j].all);
|
|
211
|
+
if (same) byNode[i].push([j, 1, 3.2 * s]), byNode[j].push([i, 1, 3.2 * s]);
|
|
212
|
+
else if (J >= .34) byNode[i].push([j, .3 * J, 6 * s]), byNode[j].push([i, .3 * J, 6 * s]);
|
|
213
|
+
}
|
|
214
|
+
const seen = new Set();
|
|
215
|
+
byNode.forEach((ls, i) => ls.sort((a, b) => b[1] - a[1]).slice(0, 12).forEach(([j, w, rest]) => {
|
|
216
|
+
const k = i < j ? i + ',' + j : j + ',' + i; if (!seen.has(k)) { seen.add(k); links.push([Math.min(i, j), Math.max(i, j), w, rest]); }
|
|
217
|
+
}));
|
|
218
|
+
const cell = 8 * s;
|
|
219
|
+
for (let it = 0; it < iterations; it++) {
|
|
220
|
+
const cool = 1 - it / iterations, F = P.map(() => [0, 0, 0]);
|
|
221
|
+
for (const [i, j, w, rest] of links) {
|
|
222
|
+
const d = [P[j][0] - P[i][0], P[j][1] - P[i][1], P[j][2] - P[i][2]], L = Math.hypot(...d) || 1, f = w * (L - rest) / L * .1;
|
|
223
|
+
for (let k = 0; k < 3; k++) { F[i][k] += d[k] * f; F[j][k] -= d[k] * f; }
|
|
224
|
+
}
|
|
225
|
+
const grid = new Map(), keyOf = p => p.map(v => Math.floor(v / cell)).join(',');
|
|
226
|
+
P.forEach((p, i) => { const k = keyOf(p); if (!grid.has(k)) grid.set(k, []); grid.get(k).push(i); });
|
|
227
|
+
for (let i = 0; i < N; i++) {
|
|
228
|
+
const [cx, cy, cz] = P[i].map(v => Math.floor(v / cell));
|
|
229
|
+
for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) for (let dz = -1; dz <= 1; dz++) {
|
|
230
|
+
for (const j of grid.get(`${cx + dx},${cy + dy},${cz + dz}`) || []) {
|
|
231
|
+
if (j <= i) continue;
|
|
232
|
+
const d = [P[i][0] - P[j][0], P[i][1] - P[j][1], P[i][2] - P[j][2]];
|
|
233
|
+
let L = Math.hypot(...d); if (L > cell) continue;
|
|
234
|
+
if (L < 1e-3) { d[0] = rnd() - .5; d[1] = rnd() - .5; L = Math.hypot(...d) || 1; }
|
|
235
|
+
const f = (2.6 * s) * (2.6 * s) / (L * L) * .5;
|
|
236
|
+
for (let k = 0; k < 3; k++) { F[i][k] += d[k] / L * f; F[j][k] -= d[k] / L * f; }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (let i = 0; i < N; i++) {
|
|
241
|
+
for (let k = 0; k < 3; k++) {
|
|
242
|
+
F[i][k] -= P[i][k] * .002; // weak gravity toward the middle
|
|
243
|
+
const step = clamp(F[i][k], -s, s) * cool;
|
|
244
|
+
P[i][k] += step;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const zmin = Math.min(0, ...P.map(p => p[2]));
|
|
249
|
+
const pos = {};
|
|
250
|
+
facts.forEach((f, i) => { pos[f.key] = [P[i][0], P[i][1], P[i][2] - zmin]; });
|
|
251
|
+
return pos;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// push apart any two runs whose blocks intersect, along the axis where they overlap least; pinned runs stay put
|
|
255
|
+
function resolve(pos, facts, fixed, iterations = 60) {
|
|
256
|
+
const items = facts.filter(f => pos[f.key]).map(f => ({ p: pos[f.key], b: blockSize(f.bucket), fixed: !!fixed[f.key] }));
|
|
257
|
+
const cell = sizes(facts).smax + 8, key = (x, y, z) => x + ',' + y + ',' + z;
|
|
258
|
+
for (let it = 0; it < iterations; it++) {
|
|
259
|
+
const grid = new Map();
|
|
260
|
+
items.forEach((a, i) => { const k = key(...a.p.map(v => Math.floor(v / cell))); if (!grid.has(k)) grid.set(k, []); grid.get(k).push(i); });
|
|
261
|
+
let moved = false;
|
|
262
|
+
items.forEach((a, i) => {
|
|
263
|
+
const [cx, cy, cz] = a.p.map(v => Math.floor(v / cell));
|
|
264
|
+
for (let dx = -1; dx <= 1; dx++) for (let dy = -1; dy <= 1; dy++) for (let dz = -1; dz <= 1; dz++) {
|
|
265
|
+
for (const j of grid.get(key(cx + dx, cy + dy, cz + dz)) || []) {
|
|
266
|
+
if (j <= i) continue;
|
|
267
|
+
const b = items[j]; if (a.fixed && b.fixed) continue;
|
|
268
|
+
const ox = (a.b.s + b.b.s) / 2 + 4 - Math.abs(a.p[0] - b.p[0]), oy = (a.b.s + b.b.s) / 2 + 4 - Math.abs(a.p[1] - b.p[1]);
|
|
269
|
+
const oz = Math.min(a.p[2] + a.b.h, b.p[2] + b.b.h) - Math.max(a.p[2], b.p[2]) + 4;
|
|
270
|
+
if (ox <= 0 || oy <= 0 || oz <= 0) continue;
|
|
271
|
+
const m = Math.min(ox, oy, oz), ax = m === ox ? 0 : m === oy ? 1 : 2, dir = Math.sign(a.p[ax] - b.p[ax]) || (i % 2 ? 1 : -1);
|
|
272
|
+
const wa = a.fixed ? 0 : b.fixed ? 1 : .5;
|
|
273
|
+
a.p[ax] += dir * m * wa; b.p[ax] -= dir * m * (1 - wa); moved = true;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
if (!moved) break;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// each project's runs joined by their minimum spanning tree (the constellation's lines): pairs of keys
|
|
281
|
+
function constellationLinks(facts, pos) {
|
|
282
|
+
const out = [], by = new Map();
|
|
283
|
+
for (const f of list(facts)) if (pos[f.key]) { if (!by.has(f.project)) by.set(f.project, []); by.get(f.project).push(f.key); }
|
|
284
|
+
for (const keys of by.values()) {
|
|
285
|
+
if (keys.length < 2) continue;
|
|
286
|
+
const n = keys.length, inTree = new Uint8Array(n), best = new Array(n).fill(Infinity), from = new Array(n).fill(-1);
|
|
287
|
+
const d = (i, j) => { const a = pos[keys[i]], b = pos[keys[j]]; return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); };
|
|
288
|
+
best[0] = 0;
|
|
289
|
+
for (let k = 0; k < n; k++) {
|
|
290
|
+
let u = -1; for (let i = 0; i < n; i++) if (!inTree[i] && (u < 0 || best[i] < best[u])) u = i;
|
|
291
|
+
inTree[u] = 1; if (from[u] >= 0) out.push([keys[from[u]], keys[u]]);
|
|
292
|
+
for (let v = 0; v < n; v++) if (!inTree[v]) { const w = d(u, v); if (w < best[v]) { best[v] = w; from[v] = u; } }
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return out;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// facts: the runs to place (filtered); all: every run (for cluster anchors); pinned: {key: [x,y,z]} of dragged runs
|
|
299
|
+
function layout(name, facts, { all = null, pinned = {}, seed = 7 } = {}) {
|
|
300
|
+
name = LAYOUTS.includes(name) ? name : 'helix';
|
|
301
|
+
facts = list(facts); const everyone = list(all || facts);
|
|
302
|
+
const projs = projects(everyone.map(f => f.run));
|
|
303
|
+
const { smax, hmax } = sizes(everyone);
|
|
304
|
+
let pos = {}, clusters = [], axis = null;
|
|
305
|
+
if (name === 'timeline') ({ pos, clusters, axis } = facts.length ? timeline(facts, projs, smax, hmax) : { pos: {}, clusters: [], axis: null });
|
|
306
|
+
else if (name === 'constellation') pos = forceLayout(facts, { seed }); // pins are applied after: a drag never moves the others
|
|
307
|
+
else {
|
|
308
|
+
const total = new Map(projs.map(p => [p.id, everyone.filter(f => f.project === p.id).length]));
|
|
309
|
+
const A = anchors(name, projs, p => total.get(p.id), smax);
|
|
310
|
+
for (const p of projs) {
|
|
311
|
+
const group = facts.filter(f => f.project === p.id).sort((a, b) => a.t - b.t || a.key.localeCompare(b.key));
|
|
312
|
+
if (!group.length) continue;
|
|
313
|
+
const c = A.get(p.id);
|
|
314
|
+
const r = name === 'sphere' ? sphere(group, c, smax) : name === 'galaxy' ? galaxy(group, c, smax) : helix(group, c, smax, hmax, p.id);
|
|
315
|
+
Object.assign(pos, r.pos);
|
|
316
|
+
clusters.push({ pid: p.id, center: [c.x, c.y], radius: r.ring + smax / 2, z0: 0, z1: r.top, shape: name === 'sphere' ? 'shell' : 'ring' });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (name === 'constellation') {
|
|
320
|
+
for (const p of projs) {
|
|
321
|
+
const ps = facts.filter(f => f.project === p.id).map(f => pos[f.key]); if (!ps.length) continue;
|
|
322
|
+
const cx = ps.reduce((a, q) => a + q[0], 0) / ps.length, cy = ps.reduce((a, q) => a + q[1], 0) / ps.length;
|
|
323
|
+
const R = Math.max(smax, ...ps.map(q => Math.hypot(q[0] - cx, q[1] - cy))) + smax / 2;
|
|
324
|
+
clusters.push({ pid: p.id, center: [cx, cy], radius: R, z0: 0, z1: Math.max(...ps.map(q => q[2])) + hmax, shape: 'cloud' });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const fixed = {};
|
|
328
|
+
for (const [k, v] of Object.entries(pinned || {})) if (pos[k] && Array.isArray(v) && v.length >= 3 && v.slice(0, 3).every(x => num(x) !== null)) { pos[k] = v.slice(0, 3); fixed[k] = true; }
|
|
329
|
+
if (name === 'constellation' || name === 'sphere') resolve(pos, facts, fixed);
|
|
330
|
+
const xs = [], ys = [], zs = [];
|
|
331
|
+
for (const k of Object.keys(pos)) { const [x, y, z] = pos[k]; xs.push(x - smax, x + smax); ys.push(y - smax, y + smax); zs.push(z, z + hmax); }
|
|
332
|
+
for (const c of clusters) { xs.push(c.center[0] - c.radius, c.center[0] + c.radius); ys.push(c.center[1] - c.radius, c.center[1] + c.radius); }
|
|
333
|
+
const bounds = xs.length ? [Math.min(...xs), Math.min(...ys), Math.min(0, ...zs), Math.max(...xs), Math.max(...ys), Math.max(...zs, hmax)] : [-500, -500, 0, 500, 500, 100];
|
|
334
|
+
return { name, pos, clusters, bounds, axis, smax, hmax, projects: projs };
|
|
335
|
+
}
|
|
336
|
+
// what changes the layout (not a status or a colour)
|
|
337
|
+
function layoutKey(facts) { return JSON.stringify(list(facts).map(f => [f.key, f.bucket, f.project, Math.round(f.t)])); }
|
|
338
|
+
|
|
339
|
+
/* ---------- labels: keep the biggest that do not overlap on screen ---------- */
|
|
340
|
+
function labelPick(cands, { max = 32 } = {}) {
|
|
341
|
+
const out = [], rects = [];
|
|
342
|
+
for (const c of list(cands).slice().sort((a, b) => b.pri - a.pri)) {
|
|
343
|
+
if (out.length >= max) break;
|
|
344
|
+
const r = [c.x - c.w / 2, c.y - c.h, c.x + c.w / 2, c.y];
|
|
345
|
+
if (rects.some(q => r[0] < q[2] && r[2] > q[0] && r[1] < q[3] && r[3] > q[1])) continue;
|
|
346
|
+
rects.push(r); out.push(c.key);
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/* ---------- the dragged positions, as a file to export and import ---------- */
|
|
352
|
+
const KEY = /^[0-9a-f]{12}$/;
|
|
353
|
+
function serializeLayout(store, labels, now) {
|
|
354
|
+
return { format: 'forge-ui-cloud-layout', version: 1, exported_at: num(now) ?? null, layouts: store && store.layouts || {}, labels: labels || {} };
|
|
355
|
+
}
|
|
356
|
+
function parseLayoutFile(obj) {
|
|
357
|
+
const errors = [], layouts = {};
|
|
358
|
+
let count = 0;
|
|
359
|
+
if (!obj || typeof obj !== 'object' || obj.format !== 'forge-ui-cloud-layout' || obj.version !== 1) return { ok: false, errors: ['not a forge-ui cloud layout file (version 1)'], layouts, count };
|
|
360
|
+
for (const [name, entries] of Object.entries(obj.layouts || {})) {
|
|
361
|
+
if (!LAYOUTS.includes(name)) { errors.push(`unknown layout ${name}`); continue; }
|
|
362
|
+
if (!entries || typeof entries !== 'object') continue;
|
|
363
|
+
layouts[name] = {};
|
|
364
|
+
for (const [k, v] of Object.entries(entries)) {
|
|
365
|
+
if (count >= 10000) { errors.push('more than 10000 positions: the rest were skipped'); break; }
|
|
366
|
+
if (!KEY.test(k) || !Array.isArray(v) || v.length < 3 || !v.slice(0, 3).every(x => num(x) !== null && Math.abs(x) <= 1e6)) { errors.push(`bad entry ${String(k).slice(0, 20)}`); continue; }
|
|
367
|
+
layouts[name][k] = v.slice(0, 4).map(Number); count++;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return { ok: true, errors, layouts, count };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return { LAYOUTS, LAYOUT_INFO, hashId, projects, runFacts, blockSize, orbRadius, emptyFilter, normFilter, activeCount, match, options,
|
|
374
|
+
layout, layoutKey, resolve, constellationLinks, niceAxis, labelPick, serializeLayout, parseLayoutFile };
|
|
375
|
+
});
|