modulearn 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,1003 @@
1
+ /* ModuLearn — the visual ('Unreal Blueprints') front end for training runs.
2
+ *
3
+ * Flow:
4
+ * 1. Fetch the node catalog from GET /api/nodes (built from the host app's
5
+ * modulearn.Registry) and register one graph-engine node type per entry,
6
+ * translating each catalog Param into an editable node widget.
7
+ * 2. The user drops nodes from the left palette and wires them on the canvas.
8
+ * 3. On every edit we serialize the canvas to the compact graph JSON that
9
+ * modulearn/compiler.py expects and POST it to /api/graph/compile — a dry run
10
+ * that returns either the resolved config or the list of wiring errors, shown
11
+ * live in the corner panel.
12
+ * 4. "Train" POSTs the same graph to /api/graph/start, which compiles + launches.
13
+ *
14
+ * The catalog is authoritative: this file contains no hardcoded node list, so the
15
+ * palette can never drift from the Python it compiles to.
16
+ */
17
+ (function () {
18
+ "use strict";
19
+
20
+ const graph = new LGraph();
21
+ const canvas = new LGraphCanvas("#graphcanvas", graph);
22
+ let CATALOG = null; // {port_types, categories, nodes:[spec,...]}
23
+ const SPEC_BY_TYPE = {}; // node type id -> catalog spec (for serialize)
24
+ const _meas = document.createElement("canvas").getContext("2d"); // offscreen text metrics
25
+ const history = []; // array of serialize() snapshots (JSON strings)
26
+ let histIdx = -1; // index of the currently-shown snapshot
27
+ let restoring = false; // true while reuild() replays a snapshot
28
+ let histTimer = null;
29
+
30
+ function captureHistory(){
31
+ if (restoring) return;
32
+ clearTimeout(histTimer);
33
+ histTimer = setTimeout(() => { // coalesce rapid edits (slider drags)
34
+ const snap = JSON.stringify(serialize());
35
+ if (snap === history[histIdx]) return; // no structural change
36
+ history.splice(histIdx + 1); // drop the redo tail
37
+ history.push(snap);
38
+ if (history.length > 100) history.shift();
39
+ histIdx = history.length - 1;
40
+ updateUndoButtons();
41
+ }, 350);
42
+ }
43
+
44
+ function applySnapshot(snap){
45
+ restoring = true; // suppress capture from rebuild's node events
46
+ graph.clear();
47
+ rebuild(JSON.parse(snap));
48
+ restoring = false;
49
+ validate(); // revalidate WITHOUT recapturing (scheduleValidate would)
50
+ persist();
51
+ updateUndoButtons();
52
+ }
53
+ function undo() {
54
+ if (histIdx > 0){
55
+ histIdx--;
56
+ applySnapshot(history[histIdx]);
57
+ }
58
+ }
59
+ function redo(){
60
+ if (histIdx < history.length - 1){
61
+ histIdx++;
62
+ applySnapshot(history[histIdx]);
63
+ }
64
+ }
65
+ function updateUndoButtons(){
66
+ const u = document.getElementById("undoBtn"), r = document.getElementById("redoBtn");
67
+ if (u) u.disabled = histIdx <= 0;
68
+ if (r) r.disabled = histIdx >= history.length - 1;
69
+ }
70
+
71
+ // ---- canvas sizing ---------------------------------------------------------
72
+ function resize() {
73
+ const c = document.getElementById("graphcanvas");
74
+ c.width = c.parentElement.clientWidth;
75
+ c.height = c.parentElement.clientHeight;
76
+ canvas.resize();
77
+ }
78
+ window.addEventListener("resize", resize);
79
+
80
+ // ---- animation engine ------------------------------------------------------
81
+ // The engine only repaints when "dirty"; we drive a rAF loop that forces a redraw
82
+ // while there's motion to show (nodes present or a run active), and paint all the
83
+ // ambient effects in canvas.onDrawForeground (graph space, after nodes).
84
+ const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
85
+ const PORT_COLORS = { dataset: "#5aa9e6", model: "#c8663c", loss: "#c98bdb",
86
+ scalar: "#6fb98f", run: "#d8a24a" };
87
+ // Port types may be a family ("model") or a subtype ("scalar/lr"); color by base.
88
+ function linkColor(type) {
89
+ return PORT_COLORS[type] || PORT_COLORS[(type || "").split("/")[0]] || "#8b98a9";
90
+ }
91
+ const _pa = new Float32Array(2), _pb = new Float32Array(2);
92
+
93
+ function trainNode() { return graph._nodes.find((n) => n.type === "train.train"); }
94
+
95
+ // ---- helpers shared by the panel + navigation ------------------------------
96
+ function escapeHtml(s) {
97
+ return String(s).replace(/[&<>"']/g, (c) => (
98
+ { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
99
+ }
100
+
101
+ // Compiler errors name the offending node(s) by their serialized id (e.g.
102
+ // "type mismatch on wire 3.model -> 5.dataset"). Find which live node ids a
103
+ // given message references so its line can deep-link back to the canvas.
104
+ function idsInError(err) {
105
+ const ids = [];
106
+ for (const n of graph._nodes) {
107
+ const id = String(n.id);
108
+ if (new RegExp("(?:^|[^0-9])" + id + "(?:[^0-9]|$)").test(err)) ids.push(id);
109
+ }
110
+ return ids;
111
+ }
112
+
113
+ // Select a node and pan the view so it sits centre-canvas.
114
+ function focusNode(id) {
115
+ const n = graph.getNodeById(+id);
116
+ if (!n) return;
117
+ canvas.selectNode(n);
118
+ canvas.centerOnNode(n);
119
+ canvas.setDirty(true, true);
120
+ }
121
+
122
+ // Frame every node in view (or reset to 1:1 when the canvas is empty).
123
+ function zoomToFit() {
124
+ const ds = canvas.ds, nodes = graph._nodes;
125
+ if (!nodes.length) { ds.scale = 1; ds.offset[0] = 0; ds.offset[1] = 0;
126
+ canvas.setDirty(true, true); return; }
127
+ let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
128
+ for (const n of nodes) {
129
+ minx = Math.min(minx, n.pos[0]);
130
+ miny = Math.min(miny, n.pos[1] - LiteGraph.NODE_TITLE_HEIGHT);
131
+ maxx = Math.max(maxx, n.pos[0] + n.size[0]);
132
+ maxy = Math.max(maxy, n.pos[1] + n.size[1]);
133
+ }
134
+ const pad = 60, cw = canvas.canvas.width, ch = canvas.canvas.height;
135
+ const scale = Math.max(0.15, Math.min(cw / (maxx - minx + pad * 2),
136
+ ch / (maxy - miny + pad * 2), 1.6));
137
+ ds.scale = scale; // screen = (graph + offset)*scale
138
+ ds.offset[0] = cw / (2 * scale) - (minx + maxx) / 2;
139
+ ds.offset[1] = ch / (2 * scale) - (miny + maxy) / 2;
140
+ canvas.setDirty(true, true);
141
+ }
142
+
143
+ // ---- Train button state: invalid | ready | running ------------------------
144
+ let graphValid = false;
145
+ function setTrainState(s) {
146
+ const b = document.getElementById("trainBtn");
147
+ if (!b) return;
148
+ b.classList.toggle("running", s === "running");
149
+ b.disabled = s === "invalid" || s === "running";
150
+ b.lastChild.textContent = s === "running" ? " Training…" : " Train";
151
+ b.title = s === "invalid" ? "Wire a valid graph to enable launch"
152
+ : s === "running" ? "A run is in progress" : "Compile and launch this graph";
153
+ }
154
+
155
+ function bezier(p0, p1, p2, p3, t) {
156
+ const u = 1 - t, uu = u * u, tt = t * t, uuu = uu * u, ttt = tt * t;
157
+ return [uuu * p0[0] + 3 * uu * t * p1[0] + 3 * u * tt * p2[0] + ttt * p3[0],
158
+ uuu * p0[1] + 3 * uu * t * p1[1] + 3 * u * tt * p2[1] + ttt * p3[1]];
159
+ }
160
+
161
+ // Dots traveling along each wire in flow direction; faster on wires feeding a
162
+ // currently-running Train node.
163
+ function drawFlowingWires(ctx, now) {
164
+ const tn = trainNode(), running = tn && tn._running;
165
+ for (const id in graph.links) {
166
+ const l = graph.links[id];
167
+ if (!l) continue;
168
+ const src = graph.getNodeById(l.origin_id), dst = graph.getNodeById(l.target_id);
169
+ if (!src || !dst) continue;
170
+ src.getConnectionPos(false, l.origin_slot, _pa);
171
+ dst.getConnectionPos(true, l.target_slot, _pb);
172
+ const a = [_pa[0], _pa[1]], b = [_pb[0], _pb[1]];
173
+ const dist = Math.hypot(b[0] - a[0], b[1] - a[1]);
174
+ const c1 = [a[0] + dist * 0.25, a[1]], c2 = [b[0] - dist * 0.25, b[1]];
175
+ const hot = running && dst === tn;
176
+ const color = linkColor(l.type);
177
+ const speed = hot ? 0.00085 : 0.00032, gap = 0.3;
178
+ const phase = ((now * speed) % gap + gap) % gap;
179
+ for (let t = phase; t < 1; t += gap) {
180
+ const p = bezier(a, c1, c2, b, t);
181
+ ctx.beginPath();
182
+ ctx.arc(p[0], p[1], hot ? 3.4 : 2.4, 0, Math.PI * 2);
183
+ ctx.fillStyle = color;
184
+ ctx.globalAlpha = hot ? 0.95 : 0.8;
185
+ ctx.fill();
186
+ }
187
+ }
188
+ ctx.globalAlpha = 1;
189
+ }
190
+
191
+ // A brief expanding ring when a node is dropped from the palette.
192
+ function drawSpawnRings(ctx, now) {
193
+ for (const n of graph._nodes) {
194
+ if (!n._spawn) continue;
195
+ const age = now - n._spawn, life = 450;
196
+ if (age > life) { n._spawn = 0; continue; }
197
+ const k = age / life;
198
+ const cx = n.pos[0] + n.size[0] / 2, cy = n.pos[1] + n.size[1] / 2;
199
+ ctx.beginPath();
200
+ ctx.arc(cx, cy, 12 + k * Math.max(n.size[0], n.size[1]) * 0.7, 0, Math.PI * 2);
201
+ ctx.strokeStyle = "rgba(200,102,60," + (1 - k) * 0.5 + ")";
202
+ ctx.lineWidth = 2; ctx.stroke();
203
+ }
204
+ }
205
+
206
+ // Glow ports of the hovered node, and — while dragging a wire — pulse every
207
+ // input port whose type matches the output being dragged.
208
+ function drawPortCues(ctx, now) {
209
+ const pulse = 0.5 + 0.5 * Math.sin(now * 0.006);
210
+ const ring = (x, y, r, color, alpha) => {
211
+ ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2);
212
+ ctx.strokeStyle = color; ctx.globalAlpha = alpha; ctx.lineWidth = 2; ctx.stroke();
213
+ ctx.globalAlpha = 1;
214
+ };
215
+ const over = canvas.node_over;
216
+ if (over) {
217
+ (over.outputs || []).forEach((o, i) => {
218
+ over.getConnectionPos(false, i, _pa);
219
+ ring(_pa[0], _pa[1], 6 + pulse * 2, linkColor(o.type), 0.5);
220
+ });
221
+ }
222
+ const co = canvas.connecting_output;
223
+ if (co) {
224
+ for (const n of graph._nodes) {
225
+ (n.inputs || []).forEach((inp, i) => {
226
+ if (inp.type !== co.type) return;
227
+ n.getConnectionPos(true, i, _pb);
228
+ ring(_pb[0], _pb[1], 6 + pulse * 4, linkColor(inp.type), 0.35 + pulse * 0.45);
229
+ });
230
+ }
231
+ }
232
+ }
233
+
234
+ // Train node liveness: a pulsing colored border (accent while running, green on
235
+ // done, red on error) and a bottom progress bar tracking epoch completion.
236
+ function drawTrainStatus(node, ctx) {
237
+ const w = node.size[0], h = node.size[1];
238
+ const color = node._phase === "error" ? "#f85149"
239
+ : node._phase === "done" ? "#3fb950" : "#c8663c";
240
+ const running = node._phase === "running";
241
+ const pulse = 0.5 + 0.5 * Math.sin(performance.now() * 0.005);
242
+ ctx.save();
243
+ ctx.strokeStyle = color;
244
+ ctx.globalAlpha = running ? 0.35 + pulse * 0.5 : 0.85;
245
+ ctx.lineWidth = 2;
246
+ if (ctx.roundRect) { ctx.beginPath(); ctx.roundRect(1, 1, w - 2, h - 2, 7); ctx.stroke(); }
247
+ else ctx.strokeRect(1, 1, w - 2, h - 2);
248
+ const p = Math.max(0, Math.min(1, node._progress || 0));
249
+ ctx.globalAlpha = 1;
250
+ ctx.fillStyle = "rgba(255,255,255,.08)";
251
+ ctx.fillRect(8, h - 9, w - 16, 4);
252
+ ctx.fillStyle = color;
253
+ ctx.fillRect(8, h - 9, (w - 16) * p, 4);
254
+ ctx.restore();
255
+ }
256
+
257
+ function ambientDraw(ctx) {
258
+ if (reduceMotion) return;
259
+ const now = performance.now();
260
+ drawFlowingWires(ctx, now);
261
+ drawSpawnRings(ctx, now);
262
+ drawPortCues(ctx, now);
263
+ }
264
+
265
+ function animLoop() {
266
+ // Repaint only when there is something to animate — idle canvas stays still.
267
+ if (!reduceMotion && (graph._nodes.length || pollTimer)) canvas.draw(true, true);
268
+ requestAnimationFrame(animLoop);
269
+ }
270
+
271
+ // ---- widget factory: one catalog Param -> one node widget ------------------
272
+ // Keeps node.properties[name] in sync so serialization can read a flat param map.
273
+ // int_list is stored/edited as "128,64" text and parsed to an array at serialize.
274
+ function addWidget(node, p) {
275
+ node.properties[p.name] = p.default;
276
+ // Update the param and re-validate. The engine invokes this with (value, ...),
277
+ // and there is no canvas-level widget hook, so validation must originate here.
278
+ const set = (v) => { node.properties[p.name] = v; scheduleValidate(); };
279
+
280
+ if (p.kind === "float" || p.kind === "int") {
281
+ const opts = { step: p.kind === "int" ? 1 : 0.1,
282
+ precision: p.kind === "int" ? 0 : 5 };
283
+ if (p.min != null) opts.min = p.min;
284
+ if (p.max != null) opts.max = p.max;
285
+ node.addWidget("number", p.label, p.default, set, opts);
286
+ } else if (p.kind === "enum") {
287
+ node.addWidget("combo", p.label, p.default, set, { values: p.choices });
288
+ } else if (p.kind === "bool") {
289
+ node.addWidget("toggle", p.label, !!p.default, set);
290
+ } else if (p.kind === "int_list") {
291
+ const text = (p.default || []).join(",");
292
+ node.properties[p.name] = text;
293
+ node.addWidget("text", p.label, text, set);
294
+ } else {
295
+ node.addWidget("text", p.label, String(p.default ?? ""), set);
296
+ }
297
+ }
298
+
299
+ // Grow a node so its title, widget labels and descriptor lines all fit — the
300
+ // engine's computeSize() uses a fixed base width, so without this long strings
301
+ // render outside the node box. `spec` is passed explicitly because node.type is
302
+ // set by createNode before the constructor body runs but we keep it explicit.
303
+ function fitNode(node, spec) {
304
+ node.size = node.computeSize();
305
+ _meas.font = "14px Arial";
306
+ let w = _meas.measureText(node.title || "").width;
307
+ (node.widgets || []).forEach((wd) => {
308
+ w = Math.max(w, _meas.measureText(`${wd.name}: ${wd.value}`).width);
309
+ });
310
+ (node._descriptors || []).forEach((d) => {
311
+ w = Math.max(w, _meas.measureText(`${d.label}: ${d.value}`).width);
312
+ });
313
+ node.size[0] = Math.max(node.size[0], Math.ceil(w) + 46);
314
+ if (node._descriptors && node._descriptors.length) {
315
+ node.size[1] += node._descriptors.length * 15 + 4; // room for the subtitle
316
+ }
317
+ const isTrain = (spec && spec.id === "train.train") || node.type === "train.train";
318
+ if (isTrain) {
319
+ node.size[1] += 18; // a dedicated lane below the slots for the progress bar
320
+ }
321
+ }
322
+
323
+ // ---- register every catalog node as a graph-engine type --------------------
324
+ function registerNodes() {
325
+ for (const spec of CATALOG.nodes) {
326
+ SPEC_BY_TYPE[spec.id] = spec;
327
+ const Node = function () {
328
+ spec.inputs.forEach((pt) => this.addInput(pt.name, pt.type));
329
+ spec.outputs.forEach((pt) => this.addOutput(pt.name, pt.type));
330
+ this.properties = {};
331
+ this._descriptors = []; // readonly values drawn as a compact subtitle
332
+ spec.params.forEach((p) => {
333
+ if (p.readonly) {
334
+ this.properties[p.name] = p.default;
335
+ this._descriptors.push({ label: p.label, value: p.default });
336
+ } else {
337
+ addWidget(this, p);
338
+ }
339
+ });
340
+ fitNode(this, spec);
341
+ };
342
+ Node.title = spec.title_short || spec.title;
343
+ Node.desc = spec.help;
344
+ // Draw readonly descriptors as small muted lines, plus the Train node's live
345
+ // status (glowing border + epoch progress bar) while a run is attached.
346
+ Node.prototype.onDrawForeground = function (ctx) {
347
+ if (this.flags.collapsed) return;
348
+ if (this._descriptors && this._descriptors.length) {
349
+ ctx.save();
350
+ ctx.font = "11px Arial";
351
+ ctx.fillStyle = "#8b98a9";
352
+ let y = this.size[1] - this._descriptors.length * 15 + 3;
353
+ for (const d of this._descriptors) {
354
+ ctx.fillText(`${d.label}: ${d.value}`, 12, y);
355
+ y += 15;
356
+ }
357
+ ctx.restore();
358
+ }
359
+ if (this.type === "train.train" && this._phase) drawTrainStatus(this, ctx);
360
+ };
361
+ LiteGraph.registerNodeType(spec.id, Node);
362
+ }
363
+ }
364
+
365
+ // ---- left palette: searchable dropdowns, grouped by category ----------------
366
+ // Each category is a collapsible block (a dropdown). Entries carry a lowercase
367
+ // haystack (title + id + help) for the filter. Visibility is a single pass over
368
+ // both the current query and each block's collapsed state.
369
+ let paletteBlocks = []; // [{ block, items:[{el, hay}], collapsed }]
370
+ let paletteQueryStr = "";
371
+
372
+ function buildPalette() {
373
+ const list = document.getElementById("paletteList");
374
+ const empty = document.getElementById("paletteEmpty");
375
+ paletteBlocks = [];
376
+ const byCat = {};
377
+ CATALOG.nodes.forEach((n) => (byCat[n.category] ||= []).push(n));
378
+ CATALOG.categories.forEach((cat) => {
379
+ if (!byCat[cat]) return;
380
+ const block = document.createElement("div");
381
+ block.className = "catblock collapsed"; // dropdowns start closed
382
+ const h = document.createElement("div");
383
+ h.className = "cat";
384
+ h.innerHTML = `<span class="caret">▶</span><span>${cat}</span>` +
385
+ `<span class="catcount">${byCat[cat].length}</span>`;
386
+ const entry = { block, items: [], collapsed: true };
387
+ h.onclick = () => { entry.collapsed = !entry.collapsed; applyPalette(); };
388
+ block.appendChild(h);
389
+ byCat[cat].forEach((spec) => {
390
+ const item = document.createElement("div");
391
+ item.className = "item" + (spec.experimental ? " exp" : "");
392
+ item.title = spec.help;
393
+ item.innerHTML = `<span>${spec.title}</span>` +
394
+ (spec.experimental ? `<span class="tag">soon</span>` : "");
395
+ item.onclick = () => addNode(spec.id);
396
+ block.appendChild(item);
397
+ entry.items.push({ el: item, hay: `${spec.title} ${spec.id} ${spec.help || ""}`.toLowerCase() });
398
+ });
399
+ list.appendChild(block);
400
+ paletteBlocks.push(entry);
401
+ });
402
+ list.appendChild(empty); // keep the empty-state message last
403
+ applyPalette();
404
+ }
405
+
406
+ // Single visibility pass. An item shows when it matches the query AND its block is
407
+ // open; an active search force-opens any block that has matches so results appear.
408
+ function applyPalette() {
409
+ const q = paletteQueryStr;
410
+ let total = 0;
411
+ for (const b of paletteBlocks) {
412
+ const open = !b.collapsed || !!q;
413
+ let shown = 0;
414
+ for (const { el, hay } of b.items) {
415
+ const match = !q || hay.includes(q);
416
+ el.style.display = (match && open) ? "" : "none";
417
+ if (match) shown++;
418
+ }
419
+ b.block.style.display = shown ? "" : "none";
420
+ b.block.classList.toggle("collapsed", b.collapsed && !q);
421
+ total += shown;
422
+ }
423
+ document.getElementById("paletteEmpty").style.display = total ? "none" : "block";
424
+ }
425
+
426
+ function filterPalette(q) { paletteQueryStr = (q || "").trim().toLowerCase(); applyPalette(); }
427
+
428
+ function firstVisibleItem() {
429
+ for (const { items } of paletteBlocks) {
430
+ for (const { el } of items) if (el.style.display !== "none") return el;
431
+ }
432
+ return null;
433
+ }
434
+
435
+ function focusPaletteSearch() {
436
+ const q = document.getElementById("paletteQuery");
437
+ q.focus(); q.select();
438
+ }
439
+
440
+ // Wire the search box + global hotkeys (⌘/Ctrl+K, or "/" when not already typing).
441
+ function initPaletteSearch() {
442
+ const q = document.getElementById("paletteQuery");
443
+ q.addEventListener("input", () => filterPalette(q.value));
444
+ q.addEventListener("keydown", (e) => {
445
+ if (e.key === "Escape") { q.value = ""; filterPalette(""); q.blur(); }
446
+ else if (e.key === "Enter") { const it = firstVisibleItem(); if (it) it.click(); }
447
+ });
448
+ window.addEventListener("keydown", (e) => {
449
+ const typing = /^(input|textarea|select)$/i.test(e.target.tagName || "");
450
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z") {
451
+ e.preventDefault();
452
+ e.shiftKey ? redo() : undo();
453
+ } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
454
+ e.preventDefault(); focusPaletteSearch();
455
+ } else if (e.key === "/" && !typing) {
456
+ e.preventDefault(); focusPaletteSearch();
457
+ }
458
+ });
459
+ }
460
+
461
+ // ---- palette: collapse toggle + drag-to-resize -----------------------------
462
+ // Width persists across reloads; on narrow screens the palette is an overlay
463
+ // drawer (see the CSS media query) and starts hidden.
464
+ const isNarrow = () => window.matchMedia("(max-width:720px)").matches;
465
+ function initPaletteResize() {
466
+ const app = document.getElementById("app");
467
+ const rez = document.getElementById("resizer");
468
+ const toggle = document.getElementById("paletteToggle");
469
+ const root = document.documentElement;
470
+
471
+ const savedW = parseInt(localStorage.getItem("palette.w"), 10);
472
+ if (savedW >= 160 && savedW <= 480) root.style.setProperty("--palette-w", savedW + "px");
473
+ if (isNarrow() || localStorage.getItem("palette.hidden") === "1")
474
+ app.classList.add("palette-hidden");
475
+
476
+ toggle.onclick = () => {
477
+ const hidden = app.classList.toggle("palette-hidden");
478
+ if (!isNarrow()) localStorage.setItem("palette.hidden", hidden ? "1" : "0");
479
+ resize();
480
+ };
481
+
482
+ let dragging = false;
483
+ rez.addEventListener("mousedown", (e) => {
484
+ dragging = true; e.preventDefault();
485
+ rez.classList.add("dragging"); document.body.style.cursor = "col-resize";
486
+ });
487
+ window.addEventListener("mousemove", (e) => {
488
+ if (!dragging) return;
489
+ root.style.setProperty("--palette-w", Math.max(160, Math.min(480, e.clientX)) + "px");
490
+ resize();
491
+ });
492
+ window.addEventListener("mouseup", () => {
493
+ if (!dragging) return;
494
+ dragging = false; rez.classList.remove("dragging"); document.body.style.cursor = "";
495
+ const w = parseInt(getComputedStyle(root).getPropertyValue("--palette-w"), 10);
496
+ if (w) localStorage.setItem("palette.w", w);
497
+ });
498
+ }
499
+
500
+ // ---- validation/training panel: drag by its title bar, resize by the grip ---
501
+ // Position + size persist across reloads. On first interaction we switch the
502
+ // panel from its bottom-right CSS anchor to explicit top/left px so both the
503
+ // drag and the native resize grip (bottom-right corner) behave predictably.
504
+ function initOutPanel() {
505
+ const out = document.getElementById("out");
506
+ const handle = document.getElementById("outTitle");
507
+ const KEY = "out.box";
508
+
509
+ const anchorTopLeft = () => {
510
+ const r = out.getBoundingClientRect();
511
+ out.style.left = r.left + "px"; out.style.top = r.top + "px";
512
+ out.style.right = "auto"; out.style.bottom = "auto";
513
+ };
514
+ const save = () => {
515
+ const r = out.getBoundingClientRect();
516
+ try {
517
+ localStorage.setItem(KEY, JSON.stringify({
518
+ left: Math.round(r.left), top: Math.round(r.top),
519
+ w: out.style.width ? Math.round(r.width) : null,
520
+ h: out.style.height ? Math.round(r.height) : null }));
521
+ } catch (e) { /* quota/private mode */ }
522
+ };
523
+ // Keep the panel (at least its title bar) on-screen after a drag or a
524
+ // window resize, so it can never be lost off the edge.
525
+ const clampIntoView = () => {
526
+ const r = out.getBoundingClientRect();
527
+ const left = Math.max(4, Math.min(r.left, window.innerWidth - r.width - 4));
528
+ const top = Math.max(48, Math.min(r.top, window.innerHeight - 40));
529
+ out.style.left = left + "px"; out.style.top = top + "px";
530
+ };
531
+
532
+ // Anchor immediately so the resize grip works from the first drag, keeping
533
+ // the panel visually where the CSS placed it.
534
+ anchorTopLeft();
535
+ try {
536
+ const s = JSON.parse(localStorage.getItem(KEY) || "null");
537
+ if (s) {
538
+ out.style.left = s.left + "px"; out.style.top = s.top + "px";
539
+ if (s.w) out.style.width = s.w + "px";
540
+ if (s.h) out.style.height = s.h + "px";
541
+ clampIntoView();
542
+ }
543
+ } catch (e) { /* ignore malformed geometry */ }
544
+
545
+ // Drag by the title bar.
546
+ let ox = 0, oy = 0, dragging = false;
547
+ handle.addEventListener("mousedown", (e) => {
548
+ dragging = true;
549
+ const r = out.getBoundingClientRect();
550
+ ox = e.clientX - r.left; oy = e.clientY - r.top;
551
+ e.preventDefault();
552
+ });
553
+ window.addEventListener("mousemove", (e) => {
554
+ if (!dragging) return;
555
+ out.style.left = (e.clientX - ox) + "px";
556
+ out.style.top = (e.clientY - oy) + "px";
557
+ });
558
+ window.addEventListener("mouseup", () => {
559
+ if (!dragging) return;
560
+ dragging = false; clampIntoView(); save();
561
+ });
562
+
563
+ // Detect a native resize (grip drag anywhere but the title bar) and persist it.
564
+ out.addEventListener("mousedown", (e) => {
565
+ if (handle.contains(e.target)) return; // that's a move, handled above
566
+ const w0 = out.offsetWidth, h0 = out.offsetHeight;
567
+ const up = () => {
568
+ document.removeEventListener("mouseup", up);
569
+ if (out.offsetWidth !== w0 || out.offsetHeight !== h0) {
570
+ out.style.width = out.offsetWidth + "px";
571
+ out.style.height = out.offsetHeight + "px";
572
+ save();
573
+ }
574
+ };
575
+ document.addEventListener("mouseup", up);
576
+ });
577
+
578
+ window.addEventListener("resize", clampIntoView);
579
+ }
580
+
581
+ // Drop a node near the current view centre, then re-validate.
582
+ function addNode(typeId) {
583
+ const node = LiteGraph.createNode(typeId);
584
+ const [cx, cy] = canvas.convertOffsetToCanvas
585
+ ? canvas.convertOffsetToCanvas([canvas.canvas.width / 2, canvas.canvas.height / 2])
586
+ : [200, 200];
587
+ node.pos = [cx + (Math.random() * 60 - 30), cy + (Math.random() * 60 - 30)];
588
+ node._spawn = performance.now(); // triggers the drop-in ring
589
+ graph.add(node);
590
+ scheduleValidate();
591
+ }
592
+
593
+ // ---- serialize the canvas to app/graph.py's graph JSON ---------------------
594
+ function serialize() {
595
+ const nodes = graph._nodes.map((n) => ({
596
+ id: String(n.id),
597
+ type: n.type,
598
+ params: collectParams(n),
599
+ pos: [Math.round(n.pos[0]), Math.round(n.pos[1])],
600
+ }));
601
+ const links = [];
602
+ for (const id in graph.links) {
603
+ const l = graph.links[id];
604
+ if (!l) continue;
605
+ const src = graph.getNodeById(l.origin_id);
606
+ const dst = graph.getNodeById(l.target_id);
607
+ if (!src || !dst) continue;
608
+ links.push({
609
+ from: String(l.origin_id), from_port: src.outputs[l.origin_slot].name,
610
+ to: String(l.target_id), to_port: dst.inputs[l.target_slot].name,
611
+ });
612
+ }
613
+ return { nodes, links };
614
+ }
615
+
616
+ // A node's params as a flat map, parsing int_list text ("128,64") back to [128,64].
617
+ function collectParams(node) {
618
+ const spec = SPEC_BY_TYPE[node.type];
619
+ const out = {};
620
+ (spec.params || []).forEach((p) => {
621
+ let v = node.properties[p.name];
622
+ if (p.kind === "int_list") {
623
+ v = String(v).split(",").map((s) => parseInt(s.trim(), 10)).filter((x) => !isNaN(x));
624
+ }
625
+ out[p.name] = v;
626
+ });
627
+ return out;
628
+ }
629
+
630
+ // ---- canvas persistence ----------------------------------------------------
631
+ // The canvas lives only in memory, so navigating away would lose it. Mirror it
632
+ // to localStorage on every edit and restore it on boot.
633
+ const STORE_KEY = "blueprint.graph";
634
+ function persist() {
635
+ try { localStorage.setItem(STORE_KEY, JSON.stringify(serialize())); } catch (e) { /* quota/private mode */ }
636
+ }
637
+ function restore() {
638
+ try {
639
+ const raw = localStorage.getItem(STORE_KEY);
640
+ if (!raw) return false;
641
+ const g = JSON.parse(raw);
642
+ if (!g.nodes || !g.nodes.length) return false;
643
+ rebuild(g);
644
+ return true;
645
+ } catch (e) { return false; }
646
+ }
647
+
648
+ // export blueprint button
649
+ function exportBlueprint() {
650
+ const doc = {
651
+ format: "modulearn.blueprint", version: 1,
652
+ graph: serialize(),
653
+ };
654
+ const blob = new Blob([JSON.stringify(doc, null, 2)],
655
+ { type: "application/json" });
656
+ const a = document.createElement("a");
657
+ a.href = URL.createObjectURL(blob);
658
+ a.download = "modulearn-blueprint.json";
659
+ a.click();
660
+ URL.revokeObjectURL(a.href);
661
+ }
662
+
663
+ // Load a blueprint file back onto the canvas (inverse of exportBlueprint).
664
+ // Accepts either our {format, version, graph} envelope or a bare graph JSON.
665
+ // Node types this registry doesn't know are dropped and reported, never
666
+ // silently lost — the file may come from a differently-configured host.
667
+ function importBlueprint(file) {
668
+ const reader = new FileReader();
669
+ reader.onload = () => {
670
+ let doc;
671
+ try { doc = JSON.parse(reader.result); }
672
+ catch (e) { setPanel("err", "not valid JSON"); return; }
673
+ const g = doc.graph || doc;
674
+ if (!g || !Array.isArray(g.nodes)) { setPanel("err", "no nodes in file"); return; }
675
+ const unknown = [...new Set(g.nodes.filter((n) => !SPEC_BY_TYPE[n.type])
676
+ .map((n) => n.type))];
677
+ g.nodes = g.nodes.filter((n) => SPEC_BY_TYPE[n.type]);
678
+ graph.clear();
679
+ rebuild(g);
680
+ // Surface skipped nodes on the next validate() so the note isn't clobbered
681
+ // by the compile result that scheduleValidate() paints ~250ms later.
682
+ importWarning = unknown.length ? "skipped unknown nodes: " + unknown.join(", ") : "";
683
+ scheduleValidate();
684
+ };
685
+ reader.onerror = () => setPanel("err", "could not read file");
686
+ reader.readAsText(file);
687
+ }
688
+
689
+ // ---- live validation via the dry-run compile endpoint ----------------------
690
+ let validateTimer = null;
691
+ let importWarning = ""; // one-shot notice (e.g. skipped nodes) for next validate()
692
+ function scheduleValidate() {
693
+ stopPolling(); // any edit means we're no longer just watching a run
694
+ clearTrainStatus(); // and the finished-run glow no longer applies
695
+ persist();
696
+ captureHistory(); // snapshot this edit for undo/redo
697
+ clearTimeout(validateTimer);
698
+ validateTimer = setTimeout(validate, 250);
699
+ }
700
+
701
+ async function validate() {
702
+ // A pending import note rides along with this validation, then clears.
703
+ const banner = importWarning
704
+ ? `<span class="err">⚠ ${escapeHtml(importWarning)}</span><br>` : "";
705
+ importWarning = "";
706
+ const graphJson = serialize();
707
+ if (!graphJson.nodes.length) { graphValid = false; setTrainState("invalid");
708
+ setPanel("k", banner + "Empty graph."); return; }
709
+ try {
710
+ const r = await fetch("/api/graph/compile", {
711
+ method: "POST", headers: { "content-type": "application/json" },
712
+ body: JSON.stringify(graphJson),
713
+ });
714
+ const j = await r.json();
715
+ if (r.ok) {
716
+ graphValid = true; setTrainState("ready");
717
+ const cfg = j.config;
718
+ setPanel("ok", banner + "✓ valid — resolved RunConfig:<br>" +
719
+ Object.entries(cfg).map(([k, v]) =>
720
+ `<span class="k">${k}</span> ${escapeHtml(JSON.stringify(v))}`).join("<br>"));
721
+ } else {
722
+ graphValid = false; setTrainState("invalid");
723
+ const errs = (j.detail && j.detail.errors) || [j.detail || "unknown error"];
724
+ // Each line deep-links to its node when the message names one.
725
+ setPanel("err", banner + "✗ " + errs.map((e) => {
726
+ const ids = idsInError(e), safe = escapeHtml(e);
727
+ return ids.length
728
+ ? `<span class="errline" data-node="${ids[0]}">• ${safe}</span>`
729
+ : `• ${safe}`;
730
+ }).join("<br>"));
731
+ }
732
+ } catch (e) {
733
+ setPanel("err", "request failed: " + escapeHtml(e));
734
+ }
735
+ }
736
+
737
+ // Re-validate whenever the graph structure changes. These three are real LGraph
738
+ // callbacks fired by graph-engine.js; widget-value edits
739
+ // re-validate via each widget's set() callback above.
740
+ graph.onNodeConnectionChange = scheduleValidate;
741
+ graph.onNodeAdded = scheduleValidate;
742
+ graph.onNodeRemoved = scheduleValidate;
743
+
744
+ // ---- live run tracking -----------------------------------------------------
745
+ // After launching, poll the run's state into the panel so training is visible
746
+ // right here on the canvas — no trip to the classic dashboard needed.
747
+ let pollTimer = null;
748
+ function stopPolling() {
749
+ clearTimeout(pollTimer); pollTimer = null;
750
+ const dot = document.querySelector("#bar .dot");
751
+ if (dot) dot.classList.remove("live");
752
+ }
753
+
754
+ // Clear a finished run's glow from the Train node (called when the graph is edited).
755
+ function clearTrainStatus() {
756
+ const tn = trainNode();
757
+ if (tn) { tn._phase = null; tn._progress = 0; tn._running = false; }
758
+ const dot = document.querySelector("#bar .dot");
759
+ if (dot) dot.classList.remove("live");
760
+ hideChart();
761
+ document.getElementById("outTitle").textContent = "VALIDATION";
762
+ }
763
+
764
+ // Write the validation/status panel, cross-fading only when the state changes.
765
+ function setPanel(cls, html) {
766
+ const body = document.getElementById("outBody");
767
+ body.className = cls; // resets (drops any .flash)
768
+ if (!reduceMotion && body._lastClass !== cls) {
769
+ void body.offsetWidth; body.classList.add("flash");
770
+ }
771
+ body._lastClass = cls;
772
+ body.innerHTML = html;
773
+ }
774
+
775
+ function fmt(v, unit) { return v == null ? "—" : (+v).toFixed(3) + (unit || ""); }
776
+
777
+ // Live learning curve (train vs val loss) drawn from the run's per-epoch metrics.
778
+ // Redrawn each poll so you watch the model learn on the canvas itself. Keys match
779
+ // the metric rows the host writes via reporter.metric(epoch=, train=, val=).
780
+ const CHART_SERIES = [
781
+ { key: "val", color: "#c8663c", label: "val" },
782
+ { key: "train", color: "#5aa9e6", label: "train" },
783
+ ];
784
+ function drawChart(metrics) {
785
+ const cv = document.getElementById("chart");
786
+ const pts = (metrics || []).filter((m) => typeof m.epoch === "number");
787
+ if (pts.length < 2) { cv.style.display = "none"; return; }
788
+ cv.style.display = "block";
789
+ const dpr = window.devicePixelRatio || 1;
790
+ const W = cv.clientWidth || 330, H = 120;
791
+ cv.width = W * dpr; cv.height = H * dpr;
792
+ const ctx = cv.getContext("2d");
793
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
794
+ ctx.clearRect(0, 0, W, H);
795
+ const pad = { l: 34, r: 8, t: 8, b: 18 };
796
+
797
+ const ys = [];
798
+ CHART_SERIES.forEach((s) => pts.forEach((m) => {
799
+ const v = m[s.key]; if (typeof v === "number" && isFinite(v)) ys.push(v);
800
+ }));
801
+ if (!ys.length) { cv.style.display = "none"; return; }
802
+ let ymin = Math.min(...ys), ymax = Math.max(...ys);
803
+ if (ymin === ymax) ymax = ymin + 1;
804
+ const yr = ymax - ymin; ymin -= yr * 0.08; ymax += yr * 0.08;
805
+ const xmin = pts[0].epoch, xmax = pts[pts.length - 1].epoch;
806
+ const X = (e) => pad.l + (xmax === xmin ? 0 : (e - xmin) / (xmax - xmin)) * (W - pad.l - pad.r);
807
+ const Y = (v) => pad.t + (1 - (v - ymin) / (ymax - ymin)) * (H - pad.t - pad.b);
808
+
809
+ // faint horizontal grid + y-axis labels
810
+ ctx.font = "9px ui-monospace, monospace"; ctx.textBaseline = "middle";
811
+ for (let i = 0; i <= 2; i++) {
812
+ const v = ymin + (ymax - ymin) * i / 2, y = Y(v);
813
+ ctx.strokeStyle = "rgba(255,255,255,.07)"; ctx.lineWidth = 1;
814
+ ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(W - pad.r, y); ctx.stroke();
815
+ ctx.fillStyle = "#8b98a9"; ctx.fillText(v.toFixed(1), 2, y);
816
+ }
817
+ // series lines with an emphasized latest point
818
+ CHART_SERIES.forEach((s) => {
819
+ ctx.beginPath(); let started = false, last = null;
820
+ pts.forEach((m) => {
821
+ const v = m[s.key]; if (typeof v !== "number" || !isFinite(v)) return;
822
+ const x = X(m.epoch), y = Y(v);
823
+ started ? ctx.lineTo(x, y) : (ctx.moveTo(x, y), started = true);
824
+ last = m;
825
+ });
826
+ ctx.strokeStyle = s.color; ctx.lineWidth = 1.5; ctx.stroke();
827
+ if (last) {
828
+ ctx.beginPath(); ctx.arc(X(last.epoch), Y(last[s.key]), 2.6, 0, Math.PI * 2);
829
+ ctx.fillStyle = s.color; ctx.fill();
830
+ }
831
+ });
832
+ // legend + axis unit
833
+ ctx.textBaseline = "alphabetic";
834
+ let lx = pad.l;
835
+ CHART_SERIES.forEach((s) => {
836
+ ctx.fillStyle = s.color; ctx.fillRect(lx, H - 8, 9, 3);
837
+ ctx.fillStyle = "#8b98a9"; ctx.fillText(s.label, lx + 12, H - 5); lx += 46;
838
+ });
839
+ ctx.fillStyle = "#5f6b7a"; ctx.fillText("loss · epoch", W - 70, H - 5);
840
+ }
841
+
842
+ function hideChart() { document.getElementById("chart").style.display = "none"; }
843
+
844
+ async function pollRun(runId) {
845
+ let st, j;
846
+ try {
847
+ j = await (await fetch("/api/run/" + runId)).json();
848
+ st = j.state || {};
849
+ } catch (e) { stopPolling(); return; }
850
+ drawChart(j.metrics);
851
+ document.getElementById("outTitle").textContent = "TRAINING";
852
+
853
+ const phase = st.phase || "?";
854
+ const ep = st.epoch != null ? st.epoch + 1 : 0, eps = st.epochs || "?";
855
+ const prog = (typeof st.epochs === "number" && st.epochs > 0)
856
+ ? (st.epoch + 1) / st.epochs : 0;
857
+ const done = phase === "done", errored = phase === "error";
858
+
859
+ // Feed the animated Train node + header dot.
860
+ const tn = trainNode();
861
+ if (tn) { tn._phase = phase; tn._progress = prog; tn._running = phase === "running"; }
862
+ const dot = document.querySelector("#bar .dot");
863
+ if (dot) dot.classList.toggle("live", phase === "running");
864
+ setTrainState(phase === "running" ? "running" : (graphValid ? "ready" : "invalid"));
865
+
866
+ setPanel(errored ? "err" : (done ? "ok" : "k"),
867
+ `<b>${done ? "✓" : errored ? "✗" : "▶"} ${runId}</b> · ${phase}<br>` +
868
+ `<span class="k">epoch</span> ${ep}/${eps}` +
869
+ (phase === "running" ? ` <span class="k">(${Math.round(prog * 100)}%)</span>` : "") + `<br>` +
870
+ `<span class="k">best val</span> ${fmt(st.best_val)}<br>` +
871
+ `<span class="k">test score</span> ${fmt(st.test_score)}` +
872
+ (errored && st.error ? `<br><span class="err">${st.error}</span>` : ""));
873
+
874
+ if (done || errored) { stopPolling(); refreshRuns(); return; }
875
+ pollTimer = setTimeout(() => pollRun(runId), 1500);
876
+ }
877
+
878
+ // ---- toolbar actions -------------------------------------------------------
879
+ async function train() {
880
+ const r = await fetch("/api/graph/start", {
881
+ method: "POST", headers: { "content-type": "application/json" },
882
+ body: JSON.stringify(serialize()),
883
+ });
884
+ const j = await r.json();
885
+ if (r.ok) {
886
+ stopPolling();
887
+ pollRun(j.run_id); // start streaming live status into the panel
888
+ } else {
889
+ const errs = (j.detail && j.detail.errors) || [j.detail];
890
+ setPanel("err", "✗ cannot launch:<br>" + errs.map((e) => `• ${e}`).join("<br>"));
891
+ }
892
+ }
893
+
894
+ async function refreshRuns() {
895
+ const sel = document.getElementById("runs");
896
+ try {
897
+ const j = await (await fetch("/api/runs")).json();
898
+ sel.innerHTML = `<option value="">— saved blueprints —</option>` +
899
+ j.runs.map((r) => `<option value="${r.run_id}">${r.run_id} (${r.phase})</option>`).join("");
900
+ } catch { /* dashboard offline is fine */ }
901
+ }
902
+
903
+ // Load a run's saved graph.json back onto the canvas (reopen / fork a run).
904
+ async function loadRun() {
905
+ const run = document.getElementById("runs").value;
906
+ if (!run) return;
907
+ const r = await fetch("/api/graph/" + run);
908
+ const body = document.getElementById("outBody");
909
+ if (!r.ok) { body.className = "err"; body.textContent = `no saved blueprint for ${run}`; return; }
910
+ const g = await r.json();
911
+ graph.clear();
912
+ rebuild(g);
913
+ scheduleValidate();
914
+ }
915
+
916
+ // Reconstruct engine nodes + links from our graph JSON (inverse of serialize).
917
+ function rebuild(g) {
918
+ const byId = {};
919
+ (g.nodes || []).forEach((gn, i) => {
920
+ const node = LiteGraph.createNode(gn.type);
921
+ if (!node) return;
922
+ node.pos = Array.isArray(gn.pos) ? [gn.pos[0], gn.pos[1]] : [80 + (i % 4) * 240, 80 + Math.floor(i / 4) * 170];
923
+ (SPEC_BY_TYPE[gn.type].params || []).forEach((p) => {
924
+ let v = (gn.params || {})[p.name];
925
+ if (p.kind === "int_list" && Array.isArray(v)) v = v.join(",");
926
+ if (v === undefined) return;
927
+ node.properties[p.name] = v;
928
+ // Match the widget by name (index no longer aligns: readonly params have none).
929
+ const w = (node.widgets || []).find((wd) => wd.name === p.label);
930
+ if (w) w.value = v;
931
+ const d = (node._descriptors || []).find((dd) => dd.label === p.label);
932
+ if (d) d.value = v;
933
+ });
934
+ fitNode(node, SPEC_BY_TYPE[gn.type]);
935
+ graph.add(node);
936
+ byId[gn.id] = node;
937
+ });
938
+ (g.links || []).forEach((l) => {
939
+ const src = byId[l.from], dst = byId[l.to];
940
+ if (!src || !dst) return;
941
+ const so = src.outputs.findIndex((p) => p.name === l.from_port);
942
+ const di = dst.inputs.findIndex((p) => p.name === l.to_port);
943
+ if (so >= 0 && di >= 0) src.connect(so, dst, di);
944
+ });
945
+ }
946
+
947
+ // ---- boot ------------------------------------------------------------------
948
+ async function boot() {
949
+ // Header branding comes from the host app (create_app title/subtitle).
950
+ try {
951
+ const meta = await (await fetch("/api/meta")).json();
952
+ if (meta.title) document.getElementById("brand").firstChild.textContent = meta.title + " ";
953
+ if (meta.subtitle) document.getElementById("brandSub").textContent = "· " + meta.subtitle;
954
+ if (meta.title) document.title = meta.title + " — visual training editor";
955
+ } catch { /* branding is optional */ }
956
+ CATALOG = await (await fetch("/api/nodes")).json();
957
+ // Color the wires themselves by port type — including each scalar/<field>
958
+ // subtype — so they match the flowing-pulse colors.
959
+ const linkColors = {};
960
+ CATALOG.nodes.forEach((n) => [...n.inputs, ...n.outputs].forEach(
961
+ (p) => { linkColors[p.type] = linkColor(p.type); }));
962
+ LGraphCanvas.link_type_colors = Object.assign(
963
+ {}, LGraphCanvas.link_type_colors, linkColors);
964
+ registerNodes();
965
+ buildPalette();
966
+ initPaletteSearch();
967
+ initPaletteResize();
968
+ setTrainState("invalid"); // nothing wired yet
969
+ resize();
970
+ initOutPanel(); // draggable + resizable status panel
971
+ canvas.onDrawForeground = ambientDraw; // flowing wires, spawn rings, port cues
972
+ graph.start();
973
+ animLoop(); // ambient repaint loop
974
+ if (restore()) scheduleValidate(); // bring back the last canvas after a reload
975
+ captureHistory(); // seed history entry 0 (empty or restored canvas)
976
+ updateUndoButtons();
977
+ refreshRuns();
978
+ document.getElementById("trainBtn").onclick = train;
979
+ document.getElementById("loadBtn").onclick = loadRun;
980
+ document.getElementById("clearBtn").onclick = () => { graph.clear(); scheduleValidate(); };
981
+ document.getElementById("fitBtn").onclick = zoomToFit;
982
+ document.getElementById("exportBtn").onclick = exportBlueprint;
983
+ document.getElementById("importBtn").onclick = () =>
984
+ document.getElementById("importFile").click();
985
+ document.getElementById("importFile").onchange = (e) => {
986
+ if (e.target.files[0]) importBlueprint(e.target.files[0]);
987
+ e.target.value = ""; // allow re-importing the same file
988
+ };
989
+ // Delegate clicks on error lines to deep-link back to the offending node.
990
+ document.getElementById("outBody").addEventListener("click", (e) => {
991
+ const el = e.target.closest(".errline");
992
+ if (el && el.dataset.node) focusNode(el.dataset.node);
993
+ });
994
+ // Debug handle for automated/headless testing of the live editor.
995
+ window.__bp = { graph, canvas, serialize, addNode, validate, rebuild, train,
996
+ pollRun, persist, restore, SPEC_BY_TYPE,
997
+ zoomToFit, focusNode, setTrainState,
998
+ exportBlueprint, importBlueprint,
999
+ undo, redo, captureHistory, get histIdx() { return histIdx; },
1000
+ history };
1001
+ }
1002
+ boot();
1003
+ })();