@luispm/zflow-graph 0.1.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.
@@ -0,0 +1,4365 @@
1
+ /*! @luispm/zflow-graph v0.1.0 | MIT | (c) 2026 */
2
+ // zflow — node-edge graph editor library (full version).
3
+ //
4
+ // Single ES module. Consumers do:
5
+ // import { ZFlow } from './dist/zflow.js';
6
+ // const flow = await ZFlow.create({ container, wasmUrl });
7
+ //
8
+ // Wraps a Zig WASM core (~200 KB) and a Canvas2D renderer. JS holds typed-
9
+ // array views over WASM linear memory for zero-copy reads. The memory
10
+ // contract: WASM allocates everything at init time and never grows again,
11
+ // so the views remain valid for the life of the instance.
12
+
13
+ // ── Default kinds shipped with the library ─────────────────────────────────
14
+ // Minimal flow primitives. Consumers extend with registerKind() for domain-
15
+ // specific kinds (service, database, queue, etc.).
16
+ const DEFAULT_KINDS = [
17
+ { name: 'input', color: '#5b8def', badge: 'I', w: 140, h: 60, nin: 0, nout: 1, shape: 'rect' },
18
+ { name: 'process', color: '#e8b04b', badge: 'P', w: 160, h: 80, nin: 1, nout: 1, shape: 'rect' },
19
+ { name: 'filter', color: '#5be0d0', badge: 'F', w: 160, h: 80, nin: 1, nout: 1, shape: 'rect' },
20
+ { name: 'decision', color: '#c062e8', badge: 'D', w: 130, h: 130, nin: 1, nout: 2, shape: 'diamond' },
21
+ { name: 'output', color: '#5bd17a', badge: 'O', w: 140, h: 60, nin: 1, nout: 0, shape: 'rect' },
22
+ { name: 'aggregator', color: '#f0b93a', badge: '∑', w: 160, h: 120, nin: 3, nout: 1, shape: 'hexagon' },
23
+ { name: 'branch', color: '#e8462b', badge: 'B', w: 130, h: 130, nin: 1, nout: 3, shape: 'ellipse' },
24
+ // Built-in control-flow kinds (execute-enabled).
25
+ { name: 'if', color: '#c062e8', badge: '?', w: 150, h: 70, nin: 1, nout: 2, shape: 'diamond',
26
+ portIn: ['value'], portOut: ['true', 'false'],
27
+ execute: (ctx, ins) => {
28
+ const v = ins.value ?? ins[0];
29
+ const cond = ctx.params?.condition;
30
+ let ok;
31
+ if (typeof cond === 'function') ok = cond(v);
32
+ else if (typeof cond === 'string') {
33
+ // Serializable: condition is a JS expression where `value` and `v` are bound.
34
+ try { ok = Function('value', 'v', `"use strict"; return (${cond});`)(v, v); }
35
+ catch { ok = false; }
36
+ } else ok = Boolean(v);
37
+ return ok ? { true: v } : { false: v };
38
+ },
39
+ },
40
+ { name: 'forEach', color: '#5be0d0', badge: '↻', w: 160, h: 70, nin: 1, nout: 1, shape: 'rect',
41
+ portIn: ['array'], portOut: ['item'],
42
+ execute: async (ctx, ins) => {
43
+ const arr = ins.array ?? ins[0] ?? [];
44
+ if (!Array.isArray(arr) || arr.length === 0) return null;
45
+ for (let i = 0; i < arr.length; i++) {
46
+ if (ctx.signal.aborted) return;
47
+ ctx.setProgress((i + 1) / arr.length);
48
+ ctx.emit({ item: arr[i] });
49
+ await new Promise((r) => setTimeout(r, 30));
50
+ }
51
+ return { item: arr[arr.length - 1] };
52
+ },
53
+ },
54
+ { name: 'const', color: '#8b95a7', badge: 'K', w: 130, h: 56, nin: 0, nout: 1, shape: 'rect',
55
+ portOut: ['value'],
56
+ execute: (ctx) => ({ value: ctx.params?.value ?? 0 }),
57
+ },
58
+ { name: 'log', color: '#5b8def', badge: '◷', w: 160, h: 60, nin: 1, nout: 0, shape: 'rect',
59
+ portIn: ['value'],
60
+ execute: (ctx, ins) => { ctx.log(ins.value ?? ins[0]); return { received: ins.value ?? ins[0] }; },
61
+ },
62
+ ];
63
+
64
+ const DARK_THEME = {
65
+ bg: '#07090f', panel: 'rgba(20,28,40,0.92)', border: 'rgba(255,255,255,0.10)',
66
+ fg: '#e6edf3', muted: '#8b95a7', accent: '#f0b93a', hi: 'rgba(240,185,58,0.10)',
67
+ grid: 'rgba(255,255,255,0.04)', gridDot: 'rgba(255,255,255,0.10)',
68
+ };
69
+ const LIGHT_THEME = {
70
+ bg: '#f6f8fb', panel: 'rgba(255,255,255,0.96)', border: 'rgba(0,0,0,0.10)',
71
+ fg: '#1d2330', muted: '#5a6577', accent: '#b8860b', hi: 'rgba(184,134,11,0.12)',
72
+ grid: 'rgba(0,0,0,0.04)', gridDot: 'rgba(0,0,0,0.16)',
73
+ };
74
+
75
+ const HANDLE_CORNERS = ['tl', 't', 'tr', 'r', 'br', 'b', 'bl', 'l'];
76
+ const HANDLE_LEFTS = new Set(['l', 'tl', 'bl']);
77
+ const HANDLE_RIGHTS = new Set(['r', 'tr', 'br']);
78
+ const HANDLE_TOPS = new Set(['t', 'tl', 'tr']);
79
+ const HANDLE_BOTS = new Set(['b', 'bl', 'br']);
80
+ const HANDLE_CURSOR = {
81
+ tl: 'nwse-resize', br: 'nwse-resize',
82
+ tr: 'nesw-resize', bl: 'nesw-resize',
83
+ t: 'ns-resize', b: 'ns-resize',
84
+ l: 'ew-resize', r: 'ew-resize',
85
+ };
86
+
87
+ class ZFlow {
88
+ /** Async constructor — loads the WASM and prepares the canvas. */
89
+ static async create(opts) {
90
+ const flow = new ZFlow();
91
+ await flow._init(opts);
92
+ return flow;
93
+ }
94
+
95
+ // ── Lifecycle ─────────────────────────────────────────────────────────
96
+ async _init(opts) {
97
+ if (!opts || !opts.container) throw new Error('zflow: container is required');
98
+ this.container = opts.container;
99
+ this.options = Object.assign({
100
+ theme: 'dark', background: '#07090f',
101
+ edgeStyle: 'bezier', snapToGrid: false, gridSize: 20,
102
+ contextMenu: true, keyboard: true,
103
+ minimap: false, animateEdges: false, edgeFlowSpeed: 60,
104
+ commandPalette: true, search: true, inlineMarkdown: true,
105
+ }, opts);
106
+ this._theme = this.options.theme === 'light' ? LIGHT_THEME : DARK_THEME;
107
+ if (this.options.theme === 'light') this.options.background = this._theme.bg;
108
+
109
+ // Load WASM bytes.
110
+ let wasmBytes;
111
+ if (opts.wasmBytes) {
112
+ wasmBytes = opts.wasmBytes instanceof Uint8Array ? opts.wasmBytes : new Uint8Array(opts.wasmBytes);
113
+ } else if (opts.wasmUrl) {
114
+ wasmBytes = new Uint8Array(await (await fetch(opts.wasmUrl)).arrayBuffer());
115
+ } else {
116
+ throw new Error('zflow: pass either { wasmUrl } or { wasmBytes }');
117
+ }
118
+ const { instance } = await WebAssembly.instantiate(wasmBytes, {});
119
+ this.w = instance.exports;
120
+ if (this.w.init() === 0) throw new Error('zflow: WASM init OOM');
121
+
122
+ const cap = this.w.nodeCap();
123
+ const ecap = this.w.edgeCap();
124
+ this.V = {
125
+ posX: new Float32Array(this.w.memory.buffer, this.w.posXPtr(), cap),
126
+ posY: new Float32Array(this.w.memory.buffer, this.w.posYPtr(), cap),
127
+ sizeW: new Float32Array(this.w.memory.buffer, this.w.sizeWPtr(), cap),
128
+ sizeH: new Float32Array(this.w.memory.buffer, this.w.sizeHPtr(), cap),
129
+ kind: new Uint8Array (this.w.memory.buffer, this.w.kindPtr(), cap),
130
+ nIn: new Uint8Array (this.w.memory.buffer, this.w.nInPtr(), cap),
131
+ nOut: new Uint8Array (this.w.memory.buffer, this.w.nOutPtr(), cap),
132
+ selected: new Uint8Array (this.w.memory.buffer, this.w.selectedPtr(), cap),
133
+ edgeFromN: new Uint32Array (this.w.memory.buffer, this.w.edgeFromNodePtr(), ecap),
134
+ edgeToN: new Uint32Array (this.w.memory.buffer, this.w.edgeToNodePtr(), ecap),
135
+ edgeFromP: new Uint8Array (this.w.memory.buffer, this.w.edgeFromPortPtr(), ecap),
136
+ edgeToP: new Uint8Array (this.w.memory.buffer, this.w.edgeToPortPtr(), ecap),
137
+ edgeSel: new Uint8Array (this.w.memory.buffer, this.w.edgeSelectedPtr(), ecap),
138
+ queryRes: new Uint32Array (this.w.memory.buffer, this.w.queryResultsPtr(), cap),
139
+ };
140
+
141
+ // Kinds + per-node JS overlays.
142
+ this.kinds = DEFAULT_KINDS.map((k) => ({ ...k }));
143
+ this.kindByName = new Map();
144
+ this.kinds.forEach((k, i) => this.kindByName.set(k.name, i));
145
+ this.titles = new Map();
146
+ this.colors = new Map();
147
+ this.descriptions = new Map();
148
+ this.tags = new Map();
149
+ this.status = new Map();
150
+ this.progress = new Map();
151
+ this.image = new Map(); // nodeId -> image url
152
+ this.checked = new Map(); // nodeId -> bool (optional checkbox)
153
+ this.tasks = new Map(); // nodeId -> [{text, done}]
154
+ this.icon = new Map(); // nodeId -> emoji/glyph
155
+ this.links = new Map(); // nodeId -> [{ url, label? }]
156
+ this.portIn = new Map(); // nodeId -> string[] (in port labels)
157
+ this.portOut = new Map(); // nodeId -> string[] (out port labels)
158
+ this.zOrder = new Map(); // nodeId -> z (default 0)
159
+ this.bookmarks = new Map(); // slot 1..9 -> nodeId
160
+ this.edgeLabels = new Map();
161
+ this._imageCache = new Map(); // url -> { img, ready }
162
+ this._nodeAddedAt = new Map(); // nodeId -> timestamp (pop animation)
163
+ this._dyingNodes = []; // [{ x,y,w,h,kind,color,t0 }] for fade-out
164
+ this._dyingEdges = [];
165
+
166
+ // Notes (sticky annotations) — JS-only entities.
167
+ this.notes = []; // [{ id, x, y, w, h, text, color }]
168
+ this._noteSeq = 0;
169
+ // Frames (groups) — JS-only entities.
170
+ this.frames = []; // [{ id, x, y, w, h, label, color }]
171
+ this._frameSeq = 0;
172
+
173
+ // Canvas + camera.
174
+ this.canvas = document.createElement('canvas');
175
+ this.canvas.style.cssText = `display:block;width:100%;height:100%;background:${this.options.background};cursor:default;outline:none;touch-action:none;user-select:none;`;
176
+ this.canvas.tabIndex = 0;
177
+ this.container.style.position = this.container.style.position || 'relative';
178
+ this.container.appendChild(this.canvas);
179
+ this.ctx = this.canvas.getContext('2d', { alpha: false });
180
+ this.cam = { x: 0, y: 0, zoom: 1 };
181
+ this._panVel = { x: 0, y: 0, lastTs: 0 };
182
+ this._clipboard = null;
183
+ this._nudgeTimer = null;
184
+
185
+ // Interaction state.
186
+ this.listeners = new Map();
187
+ this._mode = 'idle';
188
+ this._dragStart = null;
189
+ this._dragLast = null;
190
+ this._hoveredNode = -1;
191
+ this._hoveredEdge = -1;
192
+ this._hoveredNodeSince = 0;
193
+ this._previewedNode = -1;
194
+ this._resizingHandle = null;
195
+ this._marquee = null;
196
+ this._lasso = null;
197
+ this._alignGuides = null;
198
+ this._edgeStart = null;
199
+ this._edgeCursor = null;
200
+ this._draggingNote = -1;
201
+ this._noteDragLast = null;
202
+ this._draggingFrame = -1;
203
+ this._frameDragLast = null;
204
+ this._resizingFrame = null;
205
+ this._editingNote = -1;
206
+ this._editingNoteEl = null;
207
+ this._editingTitle = -1;
208
+ this._editingTitleEl = null;
209
+ this._focusFrame = -1; // subflow focus
210
+ this._htmlOverlays = new Map(); // nodeId -> DOM element
211
+ this._previewEl = null;
212
+ this._pathHighlightEnabled = false;
213
+ this._focusedSet = null;
214
+ this._lastFocusComputed = -2;
215
+
216
+ // Right-click menu element (lazy, removed on dispose).
217
+ this._menuEl = null;
218
+
219
+ // ── New: locks, read-only, snap, reachable, presence, palette ─────
220
+ this.locked = new Set(); // nodeId set — drag/resize blocked
221
+ this.readOnly = false;
222
+ this.snapToNodes = true; // edge-alignment magnet to other nodes
223
+ this._reachableSet = null; // Set<nodeId> from setReachableFrom
224
+ this.remoteCursors = new Map(); // userId -> { x, y, name, color, t }
225
+ this._edgeWaypoints = new Map(); // edgeIdx -> [{ x, y }] mid-bends
226
+ this._draggingWaypoint = null; // { edgeIdx, wpIdx }
227
+ this.frameCollapsed = new Set(); // frameIdx set
228
+ this._paletteGhost = null; // DOM element shown while dragging
229
+
230
+ // ── live metrics, animation, search, templates, validation ─────────
231
+ this.metrics = new Map(); // nodeId -> Float32Array rolling window
232
+ this.metricMax = new Map(); // nodeId -> max for normalization
233
+ this._metricCap = 32;
234
+ this.animatedEdges = new Set(); // edgeIdx set for per-edge animation
235
+ this._edgePhase = 0; // global phase for flow particles
236
+ this._connValidator = null; // fn(fromN, fromP, toN, toP) -> bool
237
+ this._templates = new Map(); // name -> { build(flow,x,y) }
238
+ this._searchEl = null; this._searchQuery = ''; this._searchHits = [];
239
+ this._cmdPaletteEl = null;
240
+ this._minimapEl = null; // canvas overlay
241
+ this._minimapCtx = null;
242
+ this._historyThumbs = []; // [{ png, t }] (best-effort)
243
+ if (this.options.minimap) this._setupMinimap();
244
+
245
+ // ── Plugin system ───────────────────────────────────────────────
246
+ this._plugins = [];
247
+ this._hooks = {
248
+ beforeRender: [], afterRender: [],
249
+ onNodeAdd: [], onNodeDelete: [], onEdgeAdd: [],
250
+ onBeforeExec: [], onAfterExec: [],
251
+ onConnect: [], onSelectionChange: [], onChange: [],
252
+ };
253
+
254
+ // ── Graph runtime (opt-in, dormant until first run) ───────────────
255
+ this._values = new Map(); // nodeId -> last output object
256
+ this._running = false;
257
+ this._runAbort = null; // AbortController
258
+ this._runSeq = 0;
259
+ this._runOrder = null; // cached topo order
260
+ this._execHooks = new Map(); // kindIdx -> execute fn
261
+ this._streamSrc = new Map(); // nodeId -> cancel fn
262
+ this._runStepDelay = 250; // ms pause between nodes (visible by default)
263
+ this._valueBubbles = []; // [{ nodeId, text, t0, dur }]
264
+ this._activeEdges = new Map(); // edgeIdx -> expiry timestamp
265
+ this._memoize = false; // skip nodes whose inputs hash unchanged
266
+ this._memoKeys = new Map(); // nodeId -> last hash
267
+ this._retryStats = new Map(); // nodeId -> attempt count for current run
268
+ this.breakpoints = new Set(); // nodeId set — pause before exec
269
+ this._paused = false; // step-through paused state
270
+ this._resumeNext = null; // resolver to continue from paused state
271
+ this._stepMode = false; // true → pause after each node
272
+ this._subflows = new Map(); // kindName -> { nodes, edges, inputs, outputs }
273
+
274
+ this._resize();
275
+ this._resizeObs = new ResizeObserver(() => this._resize());
276
+ this._resizeObs.observe(this.container);
277
+ this._attachEvents();
278
+ if (this.options.keyboard) this._attachKeyboard();
279
+ this._loop();
280
+ }
281
+
282
+ dispose() {
283
+ cancelAnimationFrame(this._raf);
284
+ this._resizeObs?.disconnect();
285
+ this.canvas?.remove();
286
+ this._menuEl?.remove();
287
+ if (this._keyHandler) window.removeEventListener('keydown', this._keyHandler);
288
+ this.listeners.clear();
289
+ }
290
+
291
+ // ── Public mutation API ───────────────────────────────────────────────
292
+ addNode(spec = {}) {
293
+ if (this.readOnly) return -1;
294
+ this._runOrder = null;
295
+ const k = this._resolveKind(spec.kind ?? 'process');
296
+ const cat = this.kinds[k];
297
+ const id = this.w.addNode(
298
+ spec.x ?? 0, spec.y ?? 0,
299
+ spec.w ?? cat.w, spec.h ?? cat.h,
300
+ k, spec.nin ?? cat.nin, spec.nout ?? cat.nout,
301
+ );
302
+ if (id < 0) return -1;
303
+ if (spec.title) this.titles.set(id, spec.title);
304
+ if (spec.color) this.colors.set(id, spec.color);
305
+ if (spec.description) this.descriptions.set(id, spec.description);
306
+ if (spec.tags) this.tags.set(id, spec.tags.slice());
307
+ if (spec.status) this.status.set(id, spec.status);
308
+ if (spec.progress !== undefined) this.progress.set(id, spec.progress);
309
+ if (spec.image) this.image.set(id, spec.image);
310
+ if (spec.checked !== undefined) this.checked.set(id, !!spec.checked);
311
+ if (spec.tasks) this.tasks.set(id, spec.tasks.map((t) => ({ ...t })));
312
+ if (spec.icon) this.icon.set(id, spec.icon);
313
+ if (spec.links) this.links.set(id, spec.links.map((l) => ({ ...l })));
314
+ if (spec.portIn) this.portIn.set(id, spec.portIn.slice());
315
+ if (spec.portOut) this.portOut.set(id, spec.portOut.slice());
316
+ if (spec.animate !== false) this._nodeAddedAt.set(id, performance.now());
317
+ if (this._hooks) this._runHook('onNodeAdd', id, spec);
318
+ this._emit('change');
319
+ return id;
320
+ }
321
+
322
+ /** Insert many nodes at once. Skips per-node emit/hook overhead; emits once at end. */
323
+ addNodesBulk(specs) {
324
+ if (this.readOnly) return [];
325
+ this._runOrder = null;
326
+ const ids = new Array(specs.length);
327
+ const wasSilent = this._suspendEvents; this._suspendEvents = true;
328
+ for (let i = 0; i < specs.length; i++) {
329
+ const s = specs[i];
330
+ const k = this._resolveKind(s.kind);
331
+ const id = this.w.addNode(k, s.x ?? 0, s.y ?? 0);
332
+ if (id < 0) { ids[i] = -1; continue; }
333
+ if (s.w !== undefined) this.V.sizeW[id] = s.w;
334
+ if (s.h !== undefined) this.V.sizeH[id] = s.h;
335
+ if (s.title) this.titles.set(id, s.title);
336
+ if (s.color) this.colors.set(id, s.color);
337
+ ids[i] = id;
338
+ }
339
+ this._suspendEvents = wasSilent;
340
+ if (this._gl) this._gl.markAllDirty();
341
+ this._emit('change');
342
+ return ids;
343
+ }
344
+
345
+ /** Insert many edges at once. */
346
+ addEdgesBulk(specs) {
347
+ if (this.readOnly) return [];
348
+ this._runOrder = null;
349
+ const ids = new Array(specs.length);
350
+ const wasSilent = this._suspendEvents; this._suspendEvents = true;
351
+ for (let i = 0; i < specs.length; i++) {
352
+ const s = specs[i];
353
+ ids[i] = this.w.addEdge(s.from, s.fp ?? 0, s.to, s.tp ?? 0);
354
+ if (s.label && ids[i] >= 0) this.edgeLabels.set(ids[i], s.label);
355
+ }
356
+ this._suspendEvents = wasSilent;
357
+ if (this._gl) this._gl.markAllDirty();
358
+ this._adjDirty = true;
359
+ this._emit('change');
360
+ return ids;
361
+ }
362
+
363
+ addEdge(spec = {}) {
364
+ if (this.readOnly) return -1;
365
+ this._runOrder = null;
366
+ this._adjDirty = true;
367
+ const eid = this.w.addEdge(spec.from, spec.fp ?? 0, spec.to, spec.tp ?? 0);
368
+ if (eid >= 0) {
369
+ if (spec.label) this.edgeLabels.set(eid, spec.label);
370
+ if (this._hooks) this._runHook('onEdgeAdd', eid, spec);
371
+ this._emit('change');
372
+ }
373
+ return eid;
374
+ }
375
+
376
+ moveNode(id, x, y) { this.w.moveNode(id, x, y); this._emit('change'); }
377
+ _guardWrite() { return !this.readOnly; }
378
+
379
+ deleteSelection() {
380
+ if (this.readOnly) return;
381
+ this._runOrder = null;
382
+ // Capture dying entities for fade-out before WASM compacts the arrays.
383
+ this._captureDying();
384
+ const n = this.w.deleteSelected();
385
+ if (n > 0) { this.w.snapshot(); this._emit('change'); }
386
+ return n;
387
+ }
388
+ _captureDying() {
389
+ const now = performance.now();
390
+ const nodeWillDie = new Uint8Array(this.w.nodeCount_());
391
+ for (let i = 0; i < this.w.nodeCount_(); i++) if (this.V.selected[i]) nodeWillDie[i] = 1;
392
+ for (let i = 0; i < this.w.nodeCount_(); i++) {
393
+ if (!nodeWillDie[i]) continue;
394
+ const cat = this.kinds[this.V.kind[i]];
395
+ this._dyingNodes.push({
396
+ x: this.V.posX[i], y: this.V.posY[i],
397
+ w: this.V.sizeW[i], h: this.V.sizeH[i],
398
+ shape: cat.shape, color: this.colors.get(i) || cat.color, t0: now,
399
+ });
400
+ }
401
+ for (let e = 0; e < this.w.edgeCount_(); e++) {
402
+ const a = this.V.edgeFromN[e], b = this.V.edgeToN[e];
403
+ if (!this.V.edgeSel[e] && !nodeWillDie[a] && !nodeWillDie[b]) continue;
404
+ const ap = this._portWorld(a, 1, this.V.edgeFromP[e]);
405
+ const bp = this._portWorld(b, 0, this.V.edgeToP[e]);
406
+ this._dyingEdges.push({
407
+ ap, bp,
408
+ colA: this.colors.get(a) || this.kinds[this.V.kind[a]].color,
409
+ colB: this.colors.get(b) || this.kinds[this.V.kind[b]].color,
410
+ t0: now,
411
+ });
412
+ }
413
+ }
414
+ duplicateSelection(dx = 40, dy = 40) {
415
+ const n = this.w.duplicateSelected(dx, dy);
416
+ if (n > 0) { this.w.snapshot(); this._emit('change'); }
417
+ return n;
418
+ }
419
+ setSelected(id, on) { this.w.setSelected(id, on ? 1 : 0); this._emit('select', this.getSelection()); }
420
+ toggleSelected(id) { this.w.toggleSelected(id); this._emit('select', this.getSelection()); }
421
+ clearSelection() { this.w.clearSelection(); this._emit('select', []); }
422
+ selectAll() { this.w.selectAll(); this._emit('select', this.getSelection()); }
423
+ getSelection() {
424
+ const out = [];
425
+ const n = this.w.nodeCount_();
426
+ for (let i = 0; i < n; i++) if (this.V.selected[i]) out.push(i);
427
+ return out;
428
+ }
429
+ nodeCount() { return this.w.nodeCount_(); }
430
+ edgeCount() { return this.w.edgeCount_(); }
431
+
432
+ // ── Per-node setters (public API) ─────────────────────────────────────
433
+ setNodeTitle(id, t) { t ? this.titles.set(id, t) : this.titles.delete(id); this._emit('change'); }
434
+ setNodeColor(id, c) { c ? this.colors.set(id, c) : this.colors.delete(id); this._emit('change'); }
435
+ setNodeDescription(id, d) { d ? this.descriptions.set(id, d) : this.descriptions.delete(id); this._emit('change'); }
436
+ setNodeTags(id, tags) { (tags && tags.length) ? this.tags.set(id, tags.slice()) : this.tags.delete(id); this._emit('change'); }
437
+ setNodeStatus(id, s) { s ? this.status.set(id, s) : this.status.delete(id); this._emit('change'); }
438
+ setNodeProgress(id, p) { (p !== undefined && p !== null) ? this.progress.set(id, p) : this.progress.delete(id); this._emit('change'); }
439
+ setEdgeLabel(eid, label) { label ? this.edgeLabels.set(eid, label) : this.edgeLabels.delete(eid); this._emit('change'); }
440
+ setEdgeStyle(style) { this.options.edgeStyle = style === 'orthogonal' ? 'orthogonal' : 'bezier'; }
441
+ setSnapToGrid(on) { this.options.snapToGrid = !!on; }
442
+ setNodeImage(id, url) { url ? this.image.set(id, url) : this.image.delete(id); this._emit('change'); }
443
+ setNodeChecked(id, on) { on === null || on === undefined ? this.checked.delete(id) : this.checked.set(id, !!on); this._emit('change'); }
444
+ setNodeTasks(id, list) { (list && list.length) ? this.tasks.set(id, list.map((t) => ({ ...t }))) : this.tasks.delete(id); this._emit('change'); }
445
+ setNodeIcon(id, glyph) { glyph ? this.icon.set(id, glyph) : this.icon.delete(id); this._emit('change'); }
446
+ setNodeLinks(id, links) { (links && links.length) ? this.links.set(id, links.map((l) => ({ ...l }))) : this.links.delete(id); this._emit('change'); }
447
+ setPortInLabels(id, arr) { (arr && arr.some(Boolean)) ? this.portIn.set(id, arr.slice()) : this.portIn.delete(id); this._emit('change'); }
448
+ setPortOutLabels(id, arr) { (arr && arr.some(Boolean)) ? this.portOut.set(id, arr.slice()) : this.portOut.delete(id); this._emit('change'); }
449
+
450
+ // ── Z-order ───────────────────────────────────────────────────────────
451
+ _nextZ = 0;
452
+ bringToFront(ids) {
453
+ const sel = ids || this.getSelection();
454
+ for (const i of sel) this.zOrder.set(i, ++this._nextZ);
455
+ }
456
+ sendToBack(ids) {
457
+ const sel = ids || this.getSelection();
458
+ for (const i of sel) this.zOrder.set(i, --this._nextZ);
459
+ }
460
+
461
+ // ── Bookmarks (slots 1..9) ───────────────────────────────────────────
462
+ setBookmark(slot, nodeId) { this.bookmarks.set(slot, nodeId ?? (this.getSelection()[0])); }
463
+ jumpBookmark(slot) {
464
+ const id = this.bookmarks.get(slot);
465
+ if (id === undefined || id >= this.w.nodeCount_()) return;
466
+ this.clearSelection(); this.w.setSelected(id, 1);
467
+ this.panTo(this.V.posX[id], this.V.posY[id]);
468
+ this._emit('select', this.getSelection());
469
+ }
470
+
471
+ // ── Hover preview popover (consumer toggles via options.hoverPreview) ──
472
+ setHoverPreview(on) { this.options.hoverPreview = !!on; if (!on) this._hidePreview(); }
473
+
474
+ // ── Plugin API ──────────────────────────────────────────────────────
475
+ /** Install a plugin object with optional lifecycle hooks. Returns dispose fn. */
476
+ use(plugin) {
477
+ if (!plugin) throw new Error('use(plugin): plugin required');
478
+ if (typeof plugin === 'function') plugin = plugin(this) || {};
479
+ this._plugins.push(plugin);
480
+ for (const name of Object.keys(this._hooks)) {
481
+ if (typeof plugin[name] === 'function') this._hooks[name].push(plugin[name]);
482
+ }
483
+ if (typeof plugin.extendAPI === 'function') plugin.extendAPI(this);
484
+ if (typeof plugin.init === 'function') plugin.init(this);
485
+ if (Array.isArray(plugin.kinds)) for (const k of plugin.kinds) this.registerKind(k);
486
+ if (Array.isArray(plugin.commands)) {
487
+ this._extraCommands = (this._extraCommands || []).concat(plugin.commands);
488
+ }
489
+ this._emit('plugin:installed', plugin.name || plugin);
490
+ return () => this._removePlugin(plugin);
491
+ }
492
+ _removePlugin(plugin) {
493
+ const i = this._plugins.indexOf(plugin); if (i === -1) return;
494
+ this._plugins.splice(i, 1);
495
+ for (const name of Object.keys(this._hooks)) {
496
+ if (typeof plugin[name] === 'function') {
497
+ const arr = this._hooks[name];
498
+ const j = arr.indexOf(plugin[name]);
499
+ if (j !== -1) arr.splice(j, 1);
500
+ }
501
+ }
502
+ if (typeof plugin.dispose === 'function') plugin.dispose(this);
503
+ }
504
+ _runHook(name, ...args) {
505
+ const arr = this._hooks[name]; if (!arr || !arr.length) return null;
506
+ let result;
507
+ for (const fn of arr) {
508
+ try { const r = fn(this, ...args); if (r === false) return false; if (r !== undefined) result = r; }
509
+ catch (e) { console.error(`plugin ${name} hook error`, e); }
510
+ }
511
+ return result;
512
+ }
513
+
514
+ // ── WebGL renderer (opt-in, hybrid: GL bodies + Canvas2D text overlay) ─
515
+ async enableWebGL(force = false) {
516
+ if (this._gl) return true;
517
+ if (!force && this.w.nodeCount_() < (this.options.webglThreshold || 2000)) return false;
518
+ try {
519
+ const mod = await Promise.resolve().then(function () { return webglRenderer; });
520
+ this._gl = new mod.WebGLRenderer(this);
521
+ if (this._gl.disabled) { this._gl = null; return false; }
522
+ this.options.renderer = 'webgl';
523
+ this._emit('renderer', 'webgl');
524
+ return true;
525
+ } catch (e) {
526
+ console.warn('zflow: WebGL renderer failed', e);
527
+ return false;
528
+ }
529
+ }
530
+ disableWebGL() {
531
+ if (!this._gl) return;
532
+ this._gl.dispose();
533
+ this._gl = null;
534
+ this.canvas.style.background = this.options.background;
535
+ this.canvas.style.zIndex = '';
536
+ this.canvas.style.position = '';
537
+ this.options.renderer = 'canvas2d';
538
+ this._emit('renderer', 'canvas2d');
539
+ }
540
+
541
+ // ── Graph execution runtime ──────────────────────────────────────────
542
+ // Each kind may register an `execute(ctx, inputs) -> outputs | Promise`.
543
+ // The scheduler walks the graph in topological order, gathering inputs
544
+ // from upstream outputs via the edge connectivity. Status / progress /
545
+ // sparkline are updated automatically so the UI shows the run live.
546
+
547
+ /** Set how long the runtime pauses between nodes so propagation is visible. */
548
+ setRunStepDelay(ms) { this._runStepDelay = Math.max(0, ms|0); }
549
+
550
+ /** Enable input memoization globally — re-runs skip nodes whose inputs match the previous tick. */
551
+ setMemoization(on) { this._memoize = !!on; if (!on) this._memoKeys?.clear(); }
552
+
553
+ /** Evaluate an expression like "{{node_3.value}} * 2" against current runtime values. */
554
+ // ── Schema / type validation ────────────────────────────────────────
555
+ /** Returns null if the connection is OK, or a string reason if not. */
556
+ validateConnection(fromN, fromP, toN, toP) {
557
+ if (fromN === toN) return 'self-loop';
558
+ const fromCat = this.kinds[this.V.kind[fromN]];
559
+ const toCat = this.kinds[this.V.kind[toN]];
560
+ const outSchema = fromCat.outputs?.[fromP];
561
+ const inSchema = toCat.inputs?.[toP];
562
+ if (outSchema && inSchema) {
563
+ if (!isCompatibleType(outSchema.type, inSchema.type)) {
564
+ return `type mismatch: ${outSchema.type} → ${inSchema.type}`;
565
+ }
566
+ }
567
+ if (this._connValidator) {
568
+ const v = this._connValidator(fromN, fromP, toN, toP);
569
+ if (v === false) return 'rejected by validator';
570
+ }
571
+ return null;
572
+ }
573
+
574
+ // ── Inline expression editor with autocomplete ────────────────────
575
+ /** Open an inline editor that accepts {{node_X.field}} expressions with live preview. */
576
+ editNodeExpression(nodeId, field = 'title') {
577
+ if (this._exprEditorEl) this._closeExprEditor();
578
+ const cur = field === 'title' ? (this.titles.get(nodeId) || '')
579
+ : field === 'desc' ? (this.descriptions.get(nodeId) || '')
580
+ : '';
581
+ const wrap = document.createElement('div');
582
+ wrap.style.cssText = `position:absolute;z-index:600;background:#161b27;border:1px solid #f0b93a;border-radius:6px;box-shadow:0 8px 24px rgba(0,0,0,0.5);font-family:Inter, ui-sans-serif;font-size:12px;color:#e6edf3;width:280px;`;
583
+ wrap.innerHTML = `
584
+ <input id="zf-expr" type="text" style="width:260px;padding:8px 10px;background:transparent;border:0;color:#e6edf3;outline:none;font-family:ui-monospace,Consolas,monospace;font-size:12px;">
585
+ <div id="zf-expr-preview" style="padding:4px 10px;border-top:1px solid rgba(255,255,255,0.08);color:#5be0d0;font-family:ui-monospace,Consolas,monospace;font-size:11px;min-height:14px;"></div>
586
+ <div id="zf-expr-list" style="max-height:160px;overflow:auto;border-top:1px solid rgba(255,255,255,0.08);display:none;"></div>`;
587
+ this.container.appendChild(wrap);
588
+ this._exprEditorEl = wrap;
589
+ const cx = this.V.posX[nodeId], cy = this.V.posY[nodeId];
590
+ const hh = this.V.sizeH[nodeId] * 0.5;
591
+ const tl = this._w2s(cx - this.V.sizeW[nodeId] * 0.5, cy - hh);
592
+ const dpr = window.devicePixelRatio || 1;
593
+ wrap.style.left = (tl.x / dpr) + 'px';
594
+ wrap.style.top = Math.max(8, tl.y / dpr - 110) + 'px';
595
+ const input = wrap.querySelector('#zf-expr');
596
+ const list = wrap.querySelector('#zf-expr-list');
597
+ const prev = wrap.querySelector('#zf-expr-preview');
598
+ input.value = cur;
599
+ const refreshPreview = () => {
600
+ try { const r = this.evalExpression(input.value); prev.style.color = '#5be0d0'; prev.textContent = '= ' + formatRuntimeValue(r); }
601
+ catch (e) { prev.style.color = '#e8462b'; prev.textContent = String(e.message || e); }
602
+ };
603
+ const refreshSuggestions = () => {
604
+ const pos = input.selectionStart;
605
+ const m = input.value.slice(0, pos).match(/\{\{\s*([\w_.]*)$/);
606
+ if (!m) { list.style.display = 'none'; return; }
607
+ const prefix = m[1].toLowerCase();
608
+ const candidates = [];
609
+ for (let i = 0; i < this.w.nodeCount_(); i++) {
610
+ if (i === nodeId) continue;
611
+ const title = this.titles.get(i) || this.kinds[this.V.kind[i]].name;
612
+ const val = this._values.get(i);
613
+ const expansions = (val && typeof val === 'object') ? Object.keys(val).map((k) => `node_${i}.${k}`) : [`node_${i}`];
614
+ for (const c of expansions) if (c.toLowerCase().startsWith(prefix)) candidates.push({ text: c, label: `${c} · ${title}` });
615
+ }
616
+ if (!candidates.length) { list.style.display = 'none'; return; }
617
+ list.style.display = 'block';
618
+ list._items = candidates.slice(0, 8);
619
+ list._cursor = 0;
620
+ list.innerHTML = list._items.map((c, i) =>
621
+ `<div data-i="${i}" data-text="${escapeHtml(c.text)}" style="padding:6px 10px;cursor:pointer;${i === 0 ? 'background:rgba(240,185,58,0.18);' : ''}">${escapeHtml(c.label)}</div>`).join('');
622
+ };
623
+ const insert = (txt) => {
624
+ const pos = input.selectionStart;
625
+ const before = input.value.slice(0, pos).replace(/\{\{\s*[\w_.]*$/, '{{') + txt + '}}';
626
+ input.value = before + input.value.slice(pos);
627
+ input.selectionStart = input.selectionEnd = before.length;
628
+ refreshPreview(); list.style.display = 'none';
629
+ };
630
+ const updateHi = () => list.querySelectorAll('[data-i]').forEach((r, i) =>
631
+ r.style.background = i === list._cursor ? 'rgba(240,185,58,0.18)' : 'transparent');
632
+ input.addEventListener('input', () => { refreshPreview(); refreshSuggestions(); });
633
+ input.addEventListener('keydown', (e) => {
634
+ const open = list.style.display !== 'none' && list._items;
635
+ if (open && e.code === 'ArrowDown') { list._cursor = (list._cursor + 1) % list._items.length; updateHi(); e.preventDefault(); return; }
636
+ if (open && e.code === 'ArrowUp') { list._cursor = (list._cursor - 1 + list._items.length) % list._items.length; updateHi(); e.preventDefault(); return; }
637
+ if (open && (e.code === 'Tab' || e.code === 'Enter')) { insert(list._items[list._cursor].text); e.preventDefault(); return; }
638
+ if (e.code === 'Enter') {
639
+ if (field === 'title') this.setNodeTitle(nodeId, input.value);
640
+ else if (field === 'desc') this.setNodeDescription(nodeId, input.value);
641
+ this._closeExprEditor();
642
+ }
643
+ if (e.code === 'Escape') this._closeExprEditor();
644
+ });
645
+ list.addEventListener('mousedown', (e) => {
646
+ const r = e.target.closest('[data-i]'); if (!r) return;
647
+ insert(r.dataset.text); input.focus(); e.preventDefault();
648
+ });
649
+ setTimeout(() => { input.focus(); input.select(); refreshPreview(); }, 10);
650
+ // Click outside closes the editor.
651
+ const closeOnOutside = (e) => {
652
+ if (this._exprEditorEl && !this._exprEditorEl.contains(e.target)) { this._closeExprEditor(); }
653
+ };
654
+ setTimeout(() => document.addEventListener('mousedown', closeOnOutside, { once: false }), 60);
655
+ this._exprEditorEl._cleanup = () => document.removeEventListener('mousedown', closeOnOutside);
656
+ }
657
+ _closeExprEditor() {
658
+ if (this._exprEditorEl) {
659
+ this._exprEditorEl._cleanup?.();
660
+ this._exprEditorEl.remove();
661
+ this._exprEditorEl = null;
662
+ }
663
+ }
664
+
665
+ evalExpression(expr, extraScope = {}) {
666
+ if (typeof expr !== 'string') return expr;
667
+ const interp = expr.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_, path) => {
668
+ const [head, ...rest] = path.split('.');
669
+ let v;
670
+ if (/^node_(\d+)$/.test(head)) v = this._values.get(parseInt(head.slice(5), 10));
671
+ else if (head in extraScope) v = extraScope[head];
672
+ else return 'null';
673
+ for (const seg of rest) v = (v == null) ? undefined : v[seg];
674
+ return JSON.stringify(v ?? null);
675
+ });
676
+ if (interp === expr) return expr;
677
+ try { return Function(`"use strict"; return (${interp})`)(); }
678
+ catch { return interp; }
679
+ }
680
+
681
+ /** Inject or replace the execute fn for a kind. Returns the previous fn. */
682
+ setKindExecutor(kindName, fn) {
683
+ const k = this.kindByName.get(kindName);
684
+ if (k === undefined) throw new Error(`unknown kind: ${kindName}`);
685
+ const prev = this.kinds[k].execute;
686
+ this.kinds[k].execute = fn;
687
+ this._runOrder = null;
688
+ return prev;
689
+ }
690
+ /** Force a specific input value for a node (useful for source nodes). */
691
+ setNodeInput(nodeId, outputs) {
692
+ this._values.set(nodeId, outputs);
693
+ this.setNodeStatus(nodeId, 'ok');
694
+ }
695
+ getNodeValue(nodeId) { return this._values.get(nodeId); }
696
+ clearRuntimeState() {
697
+ this._values.clear();
698
+ for (const sc of this._streamSrc.values()) try { sc(); } catch {}
699
+ this._streamSrc.clear();
700
+ const n = this.w.nodeCount_();
701
+ for (let i = 0; i < n; i++) { this.status.delete(i); this.progress.delete(i); }
702
+ this._emit('change');
703
+ }
704
+
705
+ /** Returns [nodeIds] in topological order, ignoring cycles. */
706
+ _topoOrder() {
707
+ if (this._runOrder) return this._runOrder;
708
+ const n = this.w.nodeCount_(), m = this.w.edgeCount_();
709
+ const indeg = new Int32Array(n);
710
+ const out = Array.from({ length: n }, () => []);
711
+ for (let i = 0; i < m; i++) {
712
+ const a = this.V.edgeFromN[i], b = this.V.edgeToN[i];
713
+ if (a !== b) { indeg[b]++; out[a].push({ to: b, fp: this.V.edgeFromP[i], tp: this.V.edgeToP[i], idx: i }); }
714
+ }
715
+ const q = [];
716
+ for (let i = 0; i < n; i++) if (indeg[i] === 0) q.push(i);
717
+ const order = [];
718
+ while (q.length) {
719
+ const u = q.shift();
720
+ order.push(u);
721
+ for (const e of out[u]) if (--indeg[e.to] === 0) q.push(e.to);
722
+ }
723
+ // Cycle nodes: append in id order so they still get a chance to run once.
724
+ if (order.length < n) {
725
+ const seen = new Set(order);
726
+ for (let i = 0; i < n; i++) if (!seen.has(i)) order.push(i);
727
+ }
728
+ this._runOrder = order;
729
+ this._runOut = out;
730
+ return order;
731
+ }
732
+
733
+ /** Gather inputs for a node by reading upstream node outputs through edges. */
734
+ _gatherInputs(nodeId) {
735
+ const inputs = {};
736
+ const cat = this.kinds[this.V.kind[nodeId]];
737
+ const portLabels = this.portIn.get(nodeId) || cat.portIn || [];
738
+ const m = this.w.edgeCount_();
739
+ for (let i = 0; i < m; i++) {
740
+ if (this.V.edgeToN[i] !== nodeId) continue;
741
+ const tp = this.V.edgeToP[i];
742
+ const src = this.V.edgeFromN[i], sp = this.V.edgeFromP[i];
743
+ const srcOut = this._values.get(src);
744
+ if (srcOut === undefined) continue;
745
+ let val;
746
+ if (srcOut && typeof srcOut === 'object' && !Array.isArray(srcOut)) {
747
+ const srcCat = this.kinds[this.V.kind[src]];
748
+ const srcPortLabels = this.portOut.get(src) || srcCat.portOut || [];
749
+ const key = srcPortLabels[sp];
750
+ // Conditional routing: if the source declared labeled outputs and THIS
751
+ // branch's labeled key isn't present, the branch didn't fire — skip.
752
+ if (key) {
753
+ if (!(key in srcOut)) continue;
754
+ val = srcOut[key];
755
+ } else if (sp in srcOut) val = srcOut[sp];
756
+ else if ('value' in srcOut) val = srcOut.value;
757
+ else if ('out' in srcOut) val = srcOut.out;
758
+ else val = srcOut;
759
+ if (val === undefined) continue;
760
+ } else val = srcOut;
761
+ const tkey = portLabels[tp] || `in${tp}`;
762
+ inputs[tkey] = val;
763
+ inputs[tp] = val;
764
+ }
765
+ return inputs;
766
+ }
767
+
768
+ /** All transitive successors of nodeId in topological order. */
769
+ _collectDownstream(nodeId) {
770
+ const m = this.w.edgeCount_();
771
+ const out = [];
772
+ const seen = new Set();
773
+ const order = this._topoOrder();
774
+ const indexOf = new Map(order.map((id, i) => [id, i]));
775
+ const stack = [nodeId];
776
+ while (stack.length) {
777
+ const u = stack.pop();
778
+ for (let i = 0; i < m; i++) {
779
+ if (this.V.edgeFromN[i] === u && !seen.has(this.V.edgeToN[i])) {
780
+ seen.add(this.V.edgeToN[i]); out.push(this.V.edgeToN[i]); stack.push(this.V.edgeToN[i]);
781
+ }
782
+ }
783
+ }
784
+ out.sort((a, b) => indexOf.get(a) - indexOf.get(b));
785
+ return out;
786
+ }
787
+
788
+ /** Topological run of the whole graph (or from a starting subgraph). */
789
+ async run({ from = null, signal = null, filter = null } = {}) {
790
+ if (this._running) return;
791
+ this._running = true;
792
+ const mySeq = ++this._runSeq;
793
+ const ac = new AbortController();
794
+ this._runAbort = ac;
795
+ if (signal) signal.addEventListener('abort', () => ac.abort());
796
+
797
+ this._topoOrder();
798
+ let order = this._runOrder;
799
+ if (from !== null) {
800
+ const reach = new Set([from]); const q = [from];
801
+ while (q.length) {
802
+ const u = q.shift();
803
+ for (const e of this._runOut[u]) if (!reach.has(e.to)) { reach.add(e.to); q.push(e.to); }
804
+ }
805
+ order = order.filter((id) => reach.has(id));
806
+ }
807
+ if (typeof filter === 'function') order = order.filter(filter);
808
+
809
+ const result = { executed: 0, errors: [], values: new Map() };
810
+ this._emit('run:start', { order });
811
+
812
+ for (const id of order) {
813
+ if (ac.signal.aborted || mySeq !== this._runSeq) break;
814
+ const cat = this.kinds[this.V.kind[id]];
815
+ const exec = cat.execute;
816
+ if (typeof exec !== 'function') {
817
+ if (!this._values.has(id)) continue;
818
+ result.values.set(id, this._values.get(id));
819
+ continue;
820
+ }
821
+ const inputs = this._gatherInputs(id);
822
+ // Conditional-routing skip: if this node has declared inputs but no
823
+ // upstream supplied any (because the parent emitted on a different branch),
824
+ // skip exec entirely — the dead branch shouldn't fire.
825
+ if (cat.nin > 0 && Object.keys(inputs).length === 0) {
826
+ // Only skip if there is at least one incoming edge in the graph (else
827
+ // it's just a disconnected node and should still run if explicitly invoked).
828
+ let hasIncoming = false;
829
+ const m2 = this.w.edgeCount_();
830
+ for (let e = 0; e < m2; e++) if (this.V.edgeToN[e] === id) { hasIncoming = true; break; }
831
+ if (hasIncoming) continue;
832
+ }
833
+ // Memoization — skip if inputs hash unchanged (FNV-1a 32-bit, no JSON cost).
834
+ if (this._memoize) {
835
+ const hash = fnvHash(inputs);
836
+ if (this._memoKeys.get(id) === hash && this._values.has(id)) {
837
+ result.executed++;
838
+ this._emit('node:cached', { id });
839
+ continue;
840
+ }
841
+ this._memoKeys.set(id, hash);
842
+ }
843
+ // Light up the incoming edges so the user sees the data path.
844
+ const m2 = this.w.edgeCount_();
845
+ for (let e = 0; e < m2; e++) {
846
+ if (this.V.edgeToN[e] === id && this._values.has(this.V.edgeFromN[e])) {
847
+ this._activeEdges.set(e, performance.now() + 800);
848
+ }
849
+ }
850
+ this.setNodeStatus(id, 'running');
851
+ this.setNodeProgress(id, 0);
852
+ this._emit('node:exec', { id, inputs });
853
+ if (this._hooks) {
854
+ const r = this._runHook('onBeforeExec', id, inputs);
855
+ if (r === false) { this.setNodeStatus(id, 'idle'); continue; }
856
+ }
857
+ // Breakpoint / step pause.
858
+ if (this.breakpoints.has(id) || this._stepMode) {
859
+ await this._awaitContinue(id);
860
+ if (ac.signal.aborted || mySeq !== this._runSeq) break;
861
+ }
862
+ if (this._runStepDelay > 0) {
863
+ await new Promise((r) => setTimeout(r, this._runStepDelay));
864
+ if (ac.signal.aborted || mySeq !== this._runSeq) break;
865
+ }
866
+ const ctx = {
867
+ nodeId: id,
868
+ signal: ac.signal,
869
+ params: this._nodeParams?.get(id) || {},
870
+ emit: (out) => { this._values.set(id, out); this._emit('node:emit', { id, outputs: out }); if (typeof out === 'number') this.pushNodeMetric(id, out); },
871
+ log: (...args) => this._emit('node:log', { id, args }),
872
+ setProgress: (p) => this.setNodeProgress(id, p),
873
+ metric: (v) => this.pushNodeMetric(id, v),
874
+ get: (otherId) => this._values.get(otherId),
875
+ };
876
+ const retryCfg = cat.retry || null;
877
+ const maxAttempts = retryCfg ? (retryCfg.n ?? 3) : 1;
878
+ const retryDelay = retryCfg ? (retryCfg.delay ?? 100) : 0;
879
+ let attempt = 0, out, lastErr = null, succeeded = false;
880
+ while (attempt < maxAttempts) {
881
+ attempt++;
882
+ this._retryStats.set(id, attempt);
883
+ try {
884
+ const outRaw = exec(ctx, inputs);
885
+ // Async generator → stream multiple emissions.
886
+ if (outRaw && typeof outRaw[Symbol.asyncIterator] === 'function') {
887
+ // For each emission, propagate through downstream chain synchronously
888
+ // before yielding the next. That way "stream → double → sink" really
889
+ // shows 5 ticks down the pipe instead of one.
890
+ let lastEmission;
891
+ const downstream = this._collectDownstream(id);
892
+ for await (const emission of outRaw) {
893
+ if (ac.signal.aborted) break;
894
+ lastEmission = emission;
895
+ this._values.set(id, emission);
896
+ this._emit('node:emit', { id, outputs: emission });
897
+ if (typeof emission === 'number') this.pushNodeMetric(id, emission);
898
+ this._valueBubbles.push({ nodeId: id, text: bubbleSummary(emission), t0: performance.now(), dur: 700 });
899
+ // Propagate through downstream nodes (skip if they have their own execute that should wait for full stream).
900
+ for (const dId of downstream) {
901
+ if (ac.signal.aborted) break;
902
+ const dCat = this.kinds[this.V.kind[dId]];
903
+ if (typeof dCat.execute !== 'function') continue;
904
+ if (dCat.execute.constructor.name === 'AsyncGeneratorFunction') continue;
905
+ const dIns = this._gatherInputs(dId);
906
+ // Light up the edge feeding this downstream node.
907
+ const m2b = this.w.edgeCount_();
908
+ for (let e = 0; e < m2b; e++) {
909
+ if (this.V.edgeToN[e] === dId && this.V.edgeFromN[e] === id) {
910
+ this._activeEdges.set(e, performance.now() + 500);
911
+ }
912
+ }
913
+ this.setNodeStatus(dId, 'running');
914
+ try {
915
+ const dRaw = dCat.execute({ ...ctx, nodeId: dId, params: this._nodeParams?.get(dId) || {} }, dIns);
916
+ const dOut = (dRaw && typeof dRaw.then === 'function') ? await dRaw : dRaw;
917
+ if (dOut !== undefined && dOut !== null) {
918
+ this._values.set(dId, dOut);
919
+ this._emit('node:emit', { id: dId, outputs: dOut });
920
+ this._valueBubbles.push({ nodeId: dId, text: bubbleSummary(dOut), t0: performance.now(), dur: 700 });
921
+ if (typeof dOut === 'number') this.pushNodeMetric(dId, dOut);
922
+ }
923
+ this.setNodeStatus(dId, 'ok');
924
+ } catch (e) { this.setNodeStatus(dId, 'error'); }
925
+ }
926
+ await new Promise((r) => setTimeout(r, 60));
927
+ }
928
+ out = lastEmission;
929
+ } else {
930
+ out = (outRaw && typeof outRaw.then === 'function') ? await outRaw : outRaw;
931
+ }
932
+ if (ac.signal.aborted || mySeq !== this._runSeq) break;
933
+ succeeded = true; lastErr = null; break;
934
+ } catch (err) {
935
+ lastErr = err;
936
+ this._emit('node:retry', { id, attempt, error: err });
937
+ if (attempt < maxAttempts) await new Promise((r) => setTimeout(r, retryDelay));
938
+ }
939
+ }
940
+ try {
941
+ if (!succeeded) throw lastErr;
942
+ if (ac.signal.aborted || mySeq !== this._runSeq) break;
943
+ if (out !== undefined && out !== null) {
944
+ this._values.set(id, out);
945
+ result.values.set(id, out);
946
+ // Show a floating value bubble above the node.
947
+ let summary;
948
+ if (typeof out === 'number') summary = formatRuntimeValue(out);
949
+ else if (out && typeof out === 'object') {
950
+ const entries = Object.entries(out).filter(([, v]) => v !== undefined && v !== null);
951
+ summary = entries.map(([k, v]) => `${k}: ${formatRuntimeValue(v)}`).join(' ');
952
+ } else summary = formatRuntimeValue(out);
953
+ this._valueBubbles.push({ nodeId: id, text: summary, t0: performance.now(), dur: 1400 });
954
+ if (typeof out === 'number') this.pushNodeMetric(id, out);
955
+ else if (out && typeof out === 'object') {
956
+ for (const v of Object.values(out)) if (typeof v === 'number') { this.pushNodeMetric(id, v); break; }
957
+ }
958
+ }
959
+ this.setNodeStatus(id, 'ok');
960
+ this.setNodeProgress(id, 1);
961
+ result.executed++;
962
+ if (this._hooks) this._runHook('onAfterExec', id, out);
963
+ this._emit('node:done', { id, outputs: out });
964
+ } catch (err) {
965
+ this.setNodeStatus(id, 'error');
966
+ result.errors.push({ id, error: err });
967
+ this._emit('node:error', { id, error: err });
968
+ if (this.options.stopOnError) break;
969
+ }
970
+ }
971
+
972
+ this._running = false;
973
+ this._runAbort = null;
974
+ this._emit('run:done', result);
975
+ return result;
976
+ }
977
+ runFrom(nodeId) { return this.run({ from: nodeId }); }
978
+
979
+ // ── Debug: breakpoints + step-through ──────────────────────────────
980
+ setBreakpoint(nodeId, on = true) {
981
+ if (on) this.breakpoints.add(nodeId); else this.breakpoints.delete(nodeId);
982
+ }
983
+ toggleBreakpoint(nodeId) {
984
+ if (this.breakpoints.has(nodeId)) this.breakpoints.delete(nodeId);
985
+ else this.breakpoints.add(nodeId);
986
+ }
987
+ clearBreakpoints() { this.breakpoints.clear(); }
988
+ setStepMode(on) { this._stepMode = !!on; }
989
+ /** When paused at a breakpoint, advance one node. */
990
+ stepOver() {
991
+ if (this._resumeNext) { const r = this._resumeNext; this._resumeNext = null; r(); }
992
+ }
993
+ /** Resume normal execution. */
994
+ resume() {
995
+ this._paused = false; this._stepMode = false;
996
+ if (this._resumeNext) { const r = this._resumeNext; this._resumeNext = null; r(); }
997
+ }
998
+ isPaused() { return this._paused; }
999
+ _awaitContinue(nodeId) {
1000
+ return new Promise((resolve) => {
1001
+ this._paused = true;
1002
+ this._emit('run:paused', { nodeId });
1003
+ this._resumeNext = resolve;
1004
+ });
1005
+ }
1006
+
1007
+ // ── Sub-flows: turn a frame into a reusable kind ───────────────────
1008
+ /** Snapshot a frame's contents as a callable kind. Returns the new kind name. */
1009
+ registerSubflowFromFrame(frameId, opts = {}) {
1010
+ const f = this.frames.find((ff) => ff.id === frameId);
1011
+ if (!f) throw new Error('frame not found');
1012
+ const inside = [];
1013
+ const n = this.w.nodeCount_();
1014
+ for (let i = 0; i < n; i++) {
1015
+ if (this.V.posX[i] >= f.x && this.V.posX[i] <= f.x + f.w &&
1016
+ this.V.posY[i] >= f.y && this.V.posY[i] <= f.y + f.h) inside.push(i);
1017
+ }
1018
+ if (!inside.length) throw new Error('frame is empty');
1019
+ const setIn = new Set(inside);
1020
+ // Capture node specs by current state.
1021
+ const localToSnap = new Map();
1022
+ const nodes = inside.map((id, i) => {
1023
+ localToSnap.set(id, i);
1024
+ return {
1025
+ kind: this.kinds[this.V.kind[id]].name,
1026
+ x: this.V.posX[id] - f.x, y: this.V.posY[id] - f.y,
1027
+ w: this.V.sizeW[id], h: this.V.sizeH[id],
1028
+ title: this.titles.get(id), color: this.colors.get(id),
1029
+ params: this._nodeParams?.get(id),
1030
+ };
1031
+ });
1032
+ const edges = [];
1033
+ const m = this.w.edgeCount_();
1034
+ for (let e = 0; e < m; e++) {
1035
+ if (setIn.has(this.V.edgeFromN[e]) && setIn.has(this.V.edgeToN[e])) {
1036
+ edges.push({
1037
+ from: localToSnap.get(this.V.edgeFromN[e]),
1038
+ to: localToSnap.get(this.V.edgeToN[e]),
1039
+ fp: this.V.edgeFromP[e], tp: this.V.edgeToP[e],
1040
+ });
1041
+ }
1042
+ }
1043
+ // Boundary detection: nodes with no inside-predecessor are inputs;
1044
+ // nodes with no inside-successor are outputs.
1045
+ const hasIncoming = new Set(), hasOutgoing = new Set();
1046
+ for (const ed of edges) { hasIncoming.add(ed.to); hasOutgoing.add(ed.from); }
1047
+ const inputs = inside.map((_, i) => i).filter((i) => !hasIncoming.has(i));
1048
+ const outputs = inside.map((_, i) => i).filter((i) => !hasOutgoing.has(i));
1049
+
1050
+ const kindName = opts.name || `subflow_${f.label || frameId}`.replace(/\s+/g, '_');
1051
+ // Surface inputs/outputs with the label from the node's title or kind.
1052
+ const inputLabels = inputs.map((idx) => {
1053
+ const local = inside[idx];
1054
+ const t = this.titles.get(local);
1055
+ return t || this.kinds[this.V.kind[local]].name;
1056
+ });
1057
+ const outputLabels = outputs.map((idx) => {
1058
+ const local = inside[idx];
1059
+ const t = this.titles.get(local);
1060
+ return t || this.kinds[this.V.kind[local]].name;
1061
+ });
1062
+ this._subflows.set(kindName, { nodes, edges, inputs, outputs, inputLabels, outputLabels });
1063
+ const self = this;
1064
+ this.registerKind({
1065
+ name: kindName, color: opts.color || '#5be0d0', badge: opts.badge || 'Σ',
1066
+ w: 180, h: 90, nin: Math.max(1, inputs.length), nout: Math.max(1, outputs.length),
1067
+ shape: 'rect',
1068
+ portIn: inputLabels,
1069
+ portOut: outputLabels,
1070
+ inputs: inputLabels.map((n) => ({ name: n, type: 'any' })),
1071
+ outputs: outputLabels.map((n) => ({ name: n, type: 'any' })),
1072
+ execute: async (ctx, ins) => {
1073
+ // Spawn ephemeral nodes for execution? Cheap path: rebuild a transient
1074
+ // value map inside this call using the snapshot's adjacency.
1075
+ const sf = self._subflows.get(kindName);
1076
+ const tmpVals = new Map();
1077
+ for (let i = 0; i < sf.inputs.length; i++) {
1078
+ const val = ins[i] ?? ins[`in${i}`] ?? ins[sf.inputLabels[i]];
1079
+ tmpVals.set(sf.inputs[i], val);
1080
+ }
1081
+ // Walk in topo order over snapshot edges.
1082
+ const indeg = sf.nodes.map(() => 0);
1083
+ const out = sf.nodes.map(() => []);
1084
+ for (const e of sf.edges) { indeg[e.to]++; out[e.from].push(e); }
1085
+ const q = [];
1086
+ for (let i = 0; i < sf.nodes.length; i++) if (indeg[i] === 0) q.push(i);
1087
+ while (q.length) {
1088
+ const u = q.shift();
1089
+ const node = sf.nodes[u];
1090
+ const cat = self.kinds[self.kindByName.get(node.kind)];
1091
+ let val;
1092
+ if (!tmpVals.has(u) && typeof cat?.execute === 'function') {
1093
+ const localIns = {};
1094
+ for (const e of sf.edges) {
1095
+ if (e.to === u && tmpVals.has(e.from)) {
1096
+ const srcVal = tmpVals.get(e.from);
1097
+ const v = (srcVal && typeof srcVal === 'object') ? (srcVal.value ?? srcVal[e.fp] ?? srcVal) : srcVal;
1098
+ localIns[`in${e.tp}`] = v;
1099
+ localIns[e.tp] = v;
1100
+ }
1101
+ }
1102
+ val = await cat.execute({ ...ctx, params: node.params || {} }, localIns);
1103
+ tmpVals.set(u, val);
1104
+ } else val = tmpVals.get(u);
1105
+ for (const e of out[u]) if (--indeg[e.to] === 0) q.push(e.to);
1106
+ }
1107
+ // Aggregated output keyed by BOTH index and label.
1108
+ const result = {};
1109
+ for (let i = 0; i < sf.outputs.length; i++) {
1110
+ const v = tmpVals.get(sf.outputs[i]);
1111
+ const unwrapped = (v && typeof v === 'object' && 'value' in v) ? v.value : v;
1112
+ result[i] = unwrapped;
1113
+ result[sf.outputLabels[i]] = unwrapped;
1114
+ }
1115
+ return result;
1116
+ },
1117
+ });
1118
+ return kindName;
1119
+ }
1120
+ runFrame(frameId) {
1121
+ const f = this.frames.find((ff) => ff.id === frameId);
1122
+ if (!f) return Promise.resolve({ executed: 0, errors: [] });
1123
+ const inside = new Set();
1124
+ const n = this.w.nodeCount_();
1125
+ for (let i = 0; i < n; i++) {
1126
+ if (this.V.posX[i] >= f.x && this.V.posX[i] <= f.x + f.w &&
1127
+ this.V.posY[i] >= f.y && this.V.posY[i] <= f.y + f.h) inside.add(i);
1128
+ }
1129
+ return this.run({ filter: (id) => inside.has(id) });
1130
+ }
1131
+ stop() { if (this._runAbort) this._runAbort.abort(); this._running = false; }
1132
+ isRunning() { return this._running; }
1133
+
1134
+ /** Set per-node runtime parameters (used by built-in kinds: const, if). */
1135
+ setNodeParams(nodeId, params) {
1136
+ if (!this._nodeParams) this._nodeParams = new Map();
1137
+ this._nodeParams.set(nodeId, params);
1138
+ }
1139
+ getNodeParams(nodeId) { return this._nodeParams?.get(nodeId); }
1140
+
1141
+ /** Long-running loop: re-run every `interval` ms until stopped. */
1142
+ startLoop(interval = 500) {
1143
+ this._loopStop = false;
1144
+ const tick = async () => {
1145
+ if (this._loopStop) return;
1146
+ await this.run();
1147
+ if (this._loopStop) return;
1148
+ setTimeout(tick, interval);
1149
+ };
1150
+ tick();
1151
+ }
1152
+ stopLoop() { this._loopStop = true; this.stop(); }
1153
+
1154
+ // ── Locks + read-only ────────────────────────────────────────────────
1155
+ lockNode(id, on = true) { if (on) this.locked.add(id); else this.locked.delete(id); }
1156
+ isLocked(id) { return this.locked.has(id); }
1157
+ setReadOnly(on) { this.readOnly = !!on; }
1158
+
1159
+ // ── Reachable highlight ──────────────────────────────────────────────
1160
+ setReachableFrom(nodeId) {
1161
+ if (nodeId === null || nodeId === undefined || nodeId < 0) { this._reachableSet = null; return; }
1162
+ const reach = new Set([nodeId]);
1163
+ const q = [nodeId];
1164
+ const m = this.w.edgeCount_();
1165
+ while (q.length) {
1166
+ const u = q.shift();
1167
+ for (let i = 0; i < m; i++) {
1168
+ if (this.V.edgeFromN[i] === u && !reach.has(this.V.edgeToN[i])) {
1169
+ reach.add(this.V.edgeToN[i]); q.push(this.V.edgeToN[i]);
1170
+ }
1171
+ }
1172
+ }
1173
+ this._reachableSet = reach;
1174
+ }
1175
+ clearReachable() { this._reachableSet = null; }
1176
+
1177
+ // ── Remote cursor presence (collaboration UI primitive) ───────────────
1178
+ setRemoteCursor(userId, x, y, name = userId, color = '#5be0d0') {
1179
+ if (x === null) { this.remoteCursors.delete(userId); return; }
1180
+ this.remoteCursors.set(userId, { x, y, name, color, t: performance.now() });
1181
+ }
1182
+ clearRemoteCursors() { this.remoteCursors.clear(); }
1183
+
1184
+ // ── Edge waypoints ───────────────────────────────────────────────────
1185
+ setEdgeWaypoints(edgeIdx, points) {
1186
+ if (!points || !points.length) this._edgeWaypoints.delete(edgeIdx);
1187
+ else this._edgeWaypoints.set(edgeIdx, points.map((p) => ({ x: p.x, y: p.y })));
1188
+ }
1189
+ clearEdgeWaypoints(edgeIdx) { this._edgeWaypoints.delete(edgeIdx); }
1190
+
1191
+ // ── Frame collapse ───────────────────────────────────────────────────
1192
+ toggleFrameCollapse(frameIdx) {
1193
+ if (this.frameCollapsed.has(frameIdx)) this.frameCollapsed.delete(frameIdx);
1194
+ else this.frameCollapsed.add(frameIdx);
1195
+ this._emit('change');
1196
+ }
1197
+ isFrameCollapsed(idx) { return this.frameCollapsed.has(idx); }
1198
+ _nodeHiddenByCollapse(nodeId) {
1199
+ if (!this.frameCollapsed.size) return false;
1200
+ for (const fidx of this.frameCollapsed) {
1201
+ const f = this.frames[fidx]; if (!f) continue;
1202
+ if (this.V.posX[nodeId] >= f.x && this.V.posX[nodeId] <= f.x + f.w &&
1203
+ this.V.posY[nodeId] >= f.y + 26 && this.V.posY[nodeId] <= f.y + f.h) return true;
1204
+ }
1205
+ return false;
1206
+ }
1207
+
1208
+ // ── Palette: any DOM element can drag-drop into the canvas ───────────
1209
+ makeDraggable(el, spec) {
1210
+ if (!el || !spec || !spec.kind) throw new Error('makeDraggable: spec.kind required');
1211
+ el.style.cursor = 'grab';
1212
+ el.addEventListener('mousedown', (ev) => {
1213
+ ev.preventDefault();
1214
+ if (this.readOnly) return;
1215
+ const ghost = el.cloneNode(true);
1216
+ Object.assign(ghost.style, { position: 'fixed', pointerEvents: 'none', opacity: '0.75', zIndex: '900', transform: 'translate(-50%,-50%) scale(1.02)' });
1217
+ document.body.appendChild(ghost);
1218
+ const move = (e) => { ghost.style.left = e.clientX + 'px'; ghost.style.top = e.clientY + 'px'; };
1219
+ move(ev);
1220
+ const up = (e) => {
1221
+ window.removeEventListener('mousemove', move);
1222
+ window.removeEventListener('mouseup', up);
1223
+ ghost.remove();
1224
+ const r = this.canvas.getBoundingClientRect();
1225
+ if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) return;
1226
+ const wp = this._s2w(e.clientX, e.clientY);
1227
+ const nodeSpec = { ...spec, x: wp.x, y: wp.y };
1228
+ delete nodeSpec.element;
1229
+ const id = this.addNode(nodeSpec);
1230
+ this._emit('palette:drop', { id, x: wp.x, y: wp.y, spec });
1231
+ };
1232
+ window.addEventListener('mousemove', move);
1233
+ window.addEventListener('mouseup', up);
1234
+ });
1235
+ }
1236
+
1237
+ // ── Theme ─────────────────────────────────────────────────────────────
1238
+ setTheme(name) {
1239
+ this._theme = name === 'light' ? LIGHT_THEME : DARK_THEME;
1240
+ this.options.theme = name;
1241
+ this.options.background = this._theme.bg;
1242
+ this.canvas.style.background = this._theme.bg;
1243
+ this._emit('theme', name);
1244
+ }
1245
+ toggleTheme() { this.setTheme(this.options.theme === 'light' ? 'dark' : 'light'); }
1246
+
1247
+ // ── Live metrics (sparkline) ──────────────────────────────────────────
1248
+ pushNodeMetric(id, value) {
1249
+ let buf = this.metrics.get(id);
1250
+ if (!buf) {
1251
+ buf = { data: new Float32Array(this._metricCap), idx: 0, count: 0 };
1252
+ this.metrics.set(id, buf);
1253
+ }
1254
+ buf.data[buf.idx] = value;
1255
+ buf.idx = (buf.idx + 1) % this._metricCap;
1256
+ if (buf.count < this._metricCap) buf.count++;
1257
+ const prev = this.metricMax.get(id) || 1;
1258
+ this.metricMax.set(id, Math.max(prev * 0.99, Math.abs(value), 1));
1259
+ }
1260
+ clearNodeMetric(id) { this.metrics.delete(id); this.metricMax.delete(id); }
1261
+
1262
+ // ── Edge animation ────────────────────────────────────────────────────
1263
+ setEdgeAnimated(edgeIdx, on) {
1264
+ if (on) this.animatedEdges.add(edgeIdx); else this.animatedEdges.delete(edgeIdx);
1265
+ }
1266
+ setAllEdgesAnimated(on) {
1267
+ if (!on) { this.animatedEdges.clear(); return; }
1268
+ const m = this.w.edgeCount_();
1269
+ for (let i = 0; i < m; i++) this.animatedEdges.add(i);
1270
+ }
1271
+
1272
+ // ── Connection validation ─────────────────────────────────────────────
1273
+ setConnectionValidator(fn) { this._connValidator = typeof fn === 'function' ? fn : null; }
1274
+
1275
+ // ── Templates ─────────────────────────────────────────────────────────
1276
+ registerTemplate(name, builder) { this._templates.set(name, builder); }
1277
+ insertTemplate(name, x = 0, y = 0) {
1278
+ const b = this._templates.get(name);
1279
+ if (!b) return -1;
1280
+ return b(this, x, y);
1281
+ }
1282
+ listTemplates() { return [...this._templates.keys()]; }
1283
+
1284
+ // ── Search ────────────────────────────────────────────────────────────
1285
+ search(query) {
1286
+ this._searchQuery = (query || '').toLowerCase();
1287
+ this._searchHits = [];
1288
+ if (!this._searchQuery) return [];
1289
+ const n = this.w.nodeCount_();
1290
+ for (let i = 0; i < n; i++) {
1291
+ const title = (this.titles.get(i) || '').toLowerCase();
1292
+ const desc = (this.descriptions.get(i) || '').toLowerCase();
1293
+ const kind = this.kinds[this.V.kind[i]].name.toLowerCase();
1294
+ const tagStr = (this.tags.get(i) || []).join(' ').toLowerCase();
1295
+ if (title.includes(this._searchQuery) || desc.includes(this._searchQuery) ||
1296
+ kind.includes(this._searchQuery) || tagStr.includes(this._searchQuery)) {
1297
+ this._searchHits.push(i);
1298
+ }
1299
+ }
1300
+ return this._searchHits.slice();
1301
+ }
1302
+ jumpToSearchHit(idx) {
1303
+ if (!this._searchHits.length) return;
1304
+ const i = this._searchHits[((idx % this._searchHits.length) + this._searchHits.length) % this._searchHits.length];
1305
+ this.clearSelection();
1306
+ this.w.setSelected(i, 1);
1307
+ this.panTo(this.V.posX[i], this.V.posY[i]);
1308
+ }
1309
+ clearSearch() { this._searchQuery = ''; this._searchHits = []; }
1310
+
1311
+ // ── Command palette ───────────────────────────────────────────────────
1312
+ openCommandPalette() {
1313
+ if (this._cmdPaletteEl) { this._cmdPaletteEl.remove(); this._cmdPaletteEl = null; return; }
1314
+ const cmds = this._builtinCommands();
1315
+ const el = document.createElement('div');
1316
+ el.style.cssText = `position:absolute;top:80px;left:50%;transform:translateX(-50%);width:480px;max-height:60vh;overflow:hidden;background:${this._theme.panel};border:1px solid ${this._theme.border};border-radius:10px;box-shadow:0 16px 48px rgba(0,0,0,0.6);z-index:500;color:${this._theme.fg};font-family:Inter, ui-sans-serif;`;
1317
+ el.innerHTML = `
1318
+ <input id="zf-cmd-q" type="text" placeholder="Type a command…" style="width:100%;padding:14px 16px;background:transparent;color:${this._theme.fg};border:0;border-bottom:1px solid ${this._theme.border};outline:none;font-size:14px;">
1319
+ <div id="zf-cmd-list" style="max-height:46vh;overflow:auto;"></div>`;
1320
+ this.container.appendChild(el);
1321
+ this._cmdPaletteEl = el;
1322
+ const input = el.querySelector('#zf-cmd-q'), list = el.querySelector('#zf-cmd-list');
1323
+ let cursor = 0, filtered = cmds;
1324
+ const render = () => {
1325
+ list.innerHTML = filtered.map((c, i) => `
1326
+ <div data-i="${i}" style="padding:9px 16px;cursor:pointer;display:flex;justify-content:space-between;align-items:center;background:${i === cursor ? this._theme.hi : 'transparent'};">
1327
+ <span>${escapeHtml(c.label)}</span>
1328
+ <span style="color:${this._theme.muted};font-family:ui-monospace,Consolas,monospace;font-size:11px;">${c.hotkey || ''}</span>
1329
+ </div>`).join('');
1330
+ };
1331
+ render();
1332
+ const run = (i) => { const cmd = filtered[i]; if (cmd) cmd.run(); this.openCommandPalette(); };
1333
+ list.addEventListener('mousedown', (e) => {
1334
+ const row = e.target.closest('[data-i]'); if (!row) return;
1335
+ run(parseInt(row.dataset.i, 10));
1336
+ });
1337
+ input.addEventListener('input', () => {
1338
+ const q = input.value.toLowerCase();
1339
+ filtered = q ? cmds.filter((c) => c.label.toLowerCase().includes(q)) : cmds;
1340
+ cursor = 0; render();
1341
+ });
1342
+ input.addEventListener('keydown', (e) => {
1343
+ if (e.code === 'ArrowDown') { cursor = Math.min(filtered.length - 1, cursor + 1); render(); e.preventDefault(); }
1344
+ if (e.code === 'ArrowUp') { cursor = Math.max(0, cursor - 1); render(); e.preventDefault(); }
1345
+ if (e.code === 'Enter') { run(cursor); }
1346
+ if (e.code === 'Escape') { this.openCommandPalette(); }
1347
+ });
1348
+ input.focus();
1349
+ }
1350
+ _builtinCommands() {
1351
+ return [
1352
+ { label: 'Auto layout (Sugiyama)', hotkey: 'L', run: () => this.runAutoLayout() },
1353
+ { label: 'Force layout', hotkey: 'F', run: () => this.runForceLayout() },
1354
+ { label: 'Fit view', hotkey: '0', run: () => this.fitView() },
1355
+ { label: 'Toggle theme (light/dark)', hotkey: 'Ctrl+T', run: () => this.toggleTheme() },
1356
+ { label: 'Toggle minimap', hotkey: 'Ctrl+M', run: () => this.setMinimap(!this.options.minimap) },
1357
+ { label: 'Toggle edge animation', hotkey: 'Ctrl+E', run: () => this.setAllEdgesAnimated(this.animatedEdges.size === 0) },
1358
+ { label: 'Toggle edge style', hotkey: '', run: () => this.setEdgeStyle(this.options.edgeStyle === 'bezier' ? 'orthogonal' : 'bezier') },
1359
+ { label: 'Toggle snap-to-grid', hotkey: 'G', run: () => this.setSnapToGrid(!this.options.snapToGrid) },
1360
+ { label: 'Toggle path highlight', hotkey: '', run: () => this.setPathHighlight(!this._pathHighlightEnabled) },
1361
+ { label: 'Toggle hover preview', hotkey: '', run: () => this.setHoverPreview(!this.options.hoverPreview) },
1362
+ { label: 'Find…', hotkey: 'Ctrl+F', run: () => this.openSearch() },
1363
+ { label: 'Highlight critical path', hotkey: '', run: () => { const e = this.criticalPath(); for (const i of e) this.w.setEdgeSelected_(i, 1); } },
1364
+ { label: 'Find SCCs (cycle groups)', hotkey: '', run: () => { const sccs = this.findSCCs(); for (const g of sccs) for (const n of g) this.w.setSelected(n, 1); } },
1365
+ { label: 'Color nodes by degree', hotkey: '', run: () => this.colorByDegree() },
1366
+ { label: 'Clear node colors', hotkey: '', run: () => this.clearNodeColors() },
1367
+ { label: 'Group selection', hotkey: 'Ctrl+G', run: () => this.groupSelection() },
1368
+ { label: 'Add sticky note', hotkey: '', run: () => this.addNote(-this.cam.x, -this.cam.y) },
1369
+ { label: 'Select all', hotkey: 'Ctrl+A', run: () => this.selectAll() },
1370
+ { label: 'Duplicate selection', hotkey: 'Ctrl+D', run: () => this.duplicateSelection() },
1371
+ { label: 'Delete selection', hotkey: 'Del', run: () => this.deleteSelection() },
1372
+ { label: 'Export PNG', hotkey: '', run: async () => { const b = await this.exportPNG(); window.open(URL.createObjectURL(b)); } },
1373
+ { label: 'Export SVG', hotkey: '', run: () => { const blob = new Blob([this.exportSVG()], { type: 'image/svg+xml' }); window.open(URL.createObjectURL(blob)); } },
1374
+ { label: 'Export JSON', hotkey: '', run: () => { const blob = new Blob([JSON.stringify(this.toJSON(), null, 2)], { type: 'application/json' }); window.open(URL.createObjectURL(blob)); } },
1375
+ { label: 'Undo', hotkey: 'Ctrl+Z', run: () => this.undo() },
1376
+ { label: 'Redo', hotkey: 'Ctrl+Y', run: () => this.redo() },
1377
+ { label: 'Run graph', hotkey: 'F5', run: () => this.run() },
1378
+ { label: 'Stop run', hotkey: 'Shift+F5', run: () => this.stop() },
1379
+ { label: 'Clear runtime state', hotkey: '', run: () => this.clearRuntimeState() },
1380
+ ...[...this._templates.keys()].map((name) => ({
1381
+ label: `Insert template: ${name}`, hotkey: '',
1382
+ run: () => this.insertTemplate(name, -this.cam.x, -this.cam.y),
1383
+ })),
1384
+ ...((this._extraCommands || []).map((c) => ({ label: c.label, hotkey: c.hotkey || '', run: c.run }))),
1385
+ ];
1386
+ }
1387
+
1388
+ // ── Search UI ─────────────────────────────────────────────────────────
1389
+ openSearch() {
1390
+ if (this._searchEl) { this._searchEl.remove(); this._searchEl = null; this.clearSearch(); return; }
1391
+ const el = document.createElement('div');
1392
+ el.style.cssText = `position:absolute;top:14px;left:50%;transform:translateX(-50%);background:${this._theme.panel};color:${this._theme.fg};border:1px solid ${this._theme.border};border-radius:8px;padding:6px 10px;display:flex;align-items:center;gap:8px;z-index:500;font-family:Inter, ui-sans-serif;box-shadow:0 8px 24px rgba(0,0,0,0.4);`;
1393
+ el.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="${this._theme.muted}" stroke-width="2"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
1394
+ <input id="zf-s" type="text" placeholder="Find nodes…" style="background:transparent;border:0;outline:none;color:${this._theme.fg};font-size:13px;width:240px;">
1395
+ <span id="zf-sn" style="color:${this._theme.muted};font-size:11px;font-family:ui-monospace,Consolas,monospace;"></span>`;
1396
+ this.container.appendChild(el);
1397
+ this._searchEl = el;
1398
+ const input = el.querySelector('#zf-s'), counter = el.querySelector('#zf-sn');
1399
+ let idx = 0;
1400
+ const onChange = () => {
1401
+ const hits = this.search(input.value);
1402
+ counter.textContent = hits.length ? `${idx + 1}/${hits.length}` : (input.value ? '0' : '');
1403
+ if (hits.length) this.jumpToSearchHit(idx);
1404
+ };
1405
+ input.addEventListener('input', () => { idx = 0; onChange(); });
1406
+ input.addEventListener('keydown', (e) => {
1407
+ if (e.code === 'Enter') { idx = (idx + (e.shiftKey ? -1 : 1) + this._searchHits.length) % Math.max(this._searchHits.length, 1); onChange(); e.preventDefault(); }
1408
+ if (e.code === 'Escape') { this.openSearch(); }
1409
+ });
1410
+ input.focus();
1411
+ }
1412
+
1413
+ // ── Minimap ───────────────────────────────────────────────────────────
1414
+ setMinimap(on) {
1415
+ this.options.minimap = !!on;
1416
+ if (on) this._setupMinimap();
1417
+ else if (this._minimapEl) { this._minimapEl.remove(); this._minimapEl = null; this._minimapCtx = null; }
1418
+ }
1419
+ _setupMinimap() {
1420
+ if (this._minimapEl) return;
1421
+ const el = document.createElement('canvas');
1422
+ el.width = 200 * (window.devicePixelRatio || 1);
1423
+ el.height = 140 * (window.devicePixelRatio || 1);
1424
+ el.style.cssText = `position:absolute;right:14px;bottom:14px;width:200px;height:140px;background:${this._theme.panel};border:1px solid ${this._theme.border};border-radius:8px;cursor:pointer;z-index:50;box-shadow:0 4px 16px rgba(0,0,0,0.4);`;
1425
+ this.container.appendChild(el);
1426
+ el.addEventListener('mousedown', (e) => {
1427
+ const r = el.getBoundingClientRect();
1428
+ const px = (e.clientX - r.left) / r.width, py = (e.clientY - r.top) / r.height;
1429
+ const bb = this._graphBounds(); if (!bb) return;
1430
+ this.cam.x = -(bb.minX + px * (bb.maxX - bb.minX));
1431
+ this.cam.y = -(bb.minY + py * (bb.maxY - bb.minY));
1432
+ });
1433
+ this._minimapEl = el;
1434
+ this._minimapCtx = el.getContext('2d', { alpha: false });
1435
+ }
1436
+ _graphBounds() {
1437
+ const n = this.w.nodeCount_(); if (n === 0) return null;
1438
+ let mnx = Infinity, mxx = -Infinity, mny = Infinity, mxy = -Infinity;
1439
+ for (let i = 0; i < n; i++) {
1440
+ const hw = this.V.sizeW[i] * 0.5, hh = this.V.sizeH[i] * 0.5;
1441
+ if (this.V.posX[i] - hw < mnx) mnx = this.V.posX[i] - hw;
1442
+ if (this.V.posX[i] + hw > mxx) mxx = this.V.posX[i] + hw;
1443
+ if (this.V.posY[i] - hh < mny) mny = this.V.posY[i] - hh;
1444
+ if (this.V.posY[i] + hh > mxy) mxy = this.V.posY[i] + hh;
1445
+ }
1446
+ const padX = (mxx - mnx) * 0.1 + 40, padY = (mxy - mny) * 0.1 + 40;
1447
+ return { minX: mnx - padX, maxX: mxx + padX, minY: mny - padY, maxY: mxy + padY };
1448
+ }
1449
+ _drawValueBubbles() {
1450
+ if (!this._valueBubbles.length) return;
1451
+ const now = performance.now();
1452
+ const ctx = this.ctx;
1453
+ for (let i = this._valueBubbles.length - 1; i >= 0; i--) {
1454
+ const b = this._valueBubbles[i];
1455
+ const t = (now - b.t0) / b.dur;
1456
+ if (t >= 1) { this._valueBubbles.splice(i, 1); continue; }
1457
+ const alpha = t < 0.15 ? t / 0.15 : t > 0.7 ? (1 - t) / 0.3 : 1;
1458
+ const rise = t * 30;
1459
+ const id = b.nodeId;
1460
+ if (id >= this.w.nodeCount_()) continue;
1461
+ const cx = this.V.posX[id], cy = this.V.posY[id];
1462
+ const hh = this.V.sizeH[id] * 0.5;
1463
+ const sp = this._w2s(cx, cy - hh);
1464
+ ctx.save();
1465
+ ctx.globalAlpha = alpha;
1466
+ ctx.font = `600 12px ui-monospace, Consolas, monospace`;
1467
+ const tw = ctx.measureText(b.text).width;
1468
+ const padX = 8;
1469
+ const bw = tw + padX * 2, bh = 22;
1470
+ const bx = sp.x - bw / 2, by = sp.y - bh - 10 - rise;
1471
+ ctx.shadowColor = 'rgba(0,0,0,0.5)';
1472
+ ctx.shadowBlur = 8;
1473
+ ctx.fillStyle = '#161b27';
1474
+ this._roundRect(bx, by, bw, bh, 5); ctx.fill();
1475
+ ctx.shadowBlur = 0;
1476
+ ctx.strokeStyle = '#5b8def'; ctx.lineWidth = 1.4;
1477
+ this._roundRect(bx, by, bw, bh, 5); ctx.stroke();
1478
+ ctx.fillStyle = '#5be0d0';
1479
+ ctx.textBaseline = 'middle'; ctx.textAlign = 'center';
1480
+ ctx.fillText(b.text, sp.x, by + bh / 2);
1481
+ // Tail.
1482
+ ctx.fillStyle = '#161b27';
1483
+ ctx.strokeStyle = '#5b8def';
1484
+ ctx.beginPath();
1485
+ ctx.moveTo(sp.x - 5, by + bh);
1486
+ ctx.lineTo(sp.x + 5, by + bh);
1487
+ ctx.lineTo(sp.x, by + bh + 6);
1488
+ ctx.closePath(); ctx.fill(); ctx.stroke();
1489
+ ctx.restore();
1490
+ }
1491
+ }
1492
+
1493
+ _drawWaypoints() {
1494
+ for (const [edgeIdx, list] of this._edgeWaypoints) {
1495
+ for (const p of list) {
1496
+ const sp = this._w2s(p.x, p.y);
1497
+ this.ctx.fillStyle = '#f0b93a';
1498
+ this.ctx.beginPath(); this.ctx.arc(sp.x, sp.y, 5, 0, Math.PI * 2); this.ctx.fill();
1499
+ this.ctx.strokeStyle = '#0b0f17'; this.ctx.lineWidth = 1.2; this.ctx.stroke();
1500
+ }
1501
+ }
1502
+ }
1503
+
1504
+ _hitWaypoint(qx, qy) {
1505
+ const tol = 8 / this.cam.zoom;
1506
+ for (const [edgeIdx, list] of this._edgeWaypoints) {
1507
+ for (let i = 0; i < list.length; i++) {
1508
+ if (Math.hypot(list[i].x - qx, list[i].y - qy) < tol) return { edgeIdx, wpIdx: i };
1509
+ }
1510
+ }
1511
+ return null;
1512
+ }
1513
+
1514
+ _drawRemoteCursors() {
1515
+ if (!this.remoteCursors.size) return;
1516
+ const now = performance.now();
1517
+ for (const [id, c] of this.remoteCursors) {
1518
+ if (now - c.t > 30000) { this.remoteCursors.delete(id); continue; }
1519
+ const sp = this._w2s(c.x, c.y);
1520
+ const ctx = this.ctx;
1521
+ ctx.save();
1522
+ // Arrow.
1523
+ ctx.fillStyle = c.color;
1524
+ ctx.beginPath();
1525
+ ctx.moveTo(sp.x, sp.y);
1526
+ ctx.lineTo(sp.x + 12, sp.y + 4);
1527
+ ctx.lineTo(sp.x + 5, sp.y + 6);
1528
+ ctx.lineTo(sp.x + 4, sp.y + 13);
1529
+ ctx.closePath(); ctx.fill();
1530
+ // Name tag.
1531
+ ctx.font = '600 11px Inter, ui-sans-serif';
1532
+ const tw = ctx.measureText(c.name).width;
1533
+ ctx.fillStyle = c.color;
1534
+ this._roundRect(sp.x + 12, sp.y + 12, tw + 12, 16, 4); ctx.fill();
1535
+ ctx.fillStyle = '#0b0f17';
1536
+ ctx.textBaseline = 'middle';
1537
+ ctx.fillText(c.name, sp.x + 18, sp.y + 20);
1538
+ ctx.restore();
1539
+ }
1540
+ }
1541
+
1542
+ _getImage(url) {
1543
+ let entry = this._imageCache.get(url);
1544
+ if (!entry) {
1545
+ entry = { img: new Image(), ready: false };
1546
+ entry.img.crossOrigin = 'anonymous';
1547
+ entry.img.onload = () => { entry.ready = true; };
1548
+ entry.img.onerror = () => { entry.ready = false; };
1549
+ entry.img.src = url;
1550
+ this._imageCache.set(url, entry);
1551
+ }
1552
+ return entry;
1553
+ }
1554
+
1555
+ _drawMinimap() {
1556
+ if (!this._minimapEl || !this._minimapCtx) return;
1557
+ const m = this._minimapCtx;
1558
+ const W = this._minimapEl.width, H = this._minimapEl.height;
1559
+ m.fillStyle = this._theme.panel;
1560
+ m.fillRect(0, 0, W, H);
1561
+ const bb = this._graphBounds();
1562
+ if (!bb) return;
1563
+ const sx = W / (bb.maxX - bb.minX), sy = H / (bb.maxY - bb.minY);
1564
+ const s = Math.min(sx, sy);
1565
+ const ox = (W - s * (bb.maxX - bb.minX)) * 0.5;
1566
+ const oy = (H - s * (bb.maxY - bb.minY)) * 0.5;
1567
+ const n = this.w.nodeCount_();
1568
+ for (let i = 0; i < n; i++) {
1569
+ const cat = this.kinds[this.V.kind[i]];
1570
+ const hw = this.V.sizeW[i] * 0.5 * s, hh = this.V.sizeH[i] * 0.5 * s;
1571
+ const x = ox + (this.V.posX[i] - bb.minX) * s, y = oy + (this.V.posY[i] - bb.minY) * s;
1572
+ m.fillStyle = this.colors.get(i) || cat.color;
1573
+ m.fillRect(x - hw, y - hh, Math.max(2, hw * 2), Math.max(2, hh * 2));
1574
+ }
1575
+ // Viewport rect.
1576
+ const dpr = window.devicePixelRatio || 1;
1577
+ const cw = this.canvas.width / dpr / this.cam.zoom;
1578
+ const ch = this.canvas.height / dpr / this.cam.zoom;
1579
+ const vx = ox + (-this.cam.x - cw * 0.5 - bb.minX) * s;
1580
+ const vy = oy + (-this.cam.y - ch * 0.5 - bb.minY) * s;
1581
+ m.strokeStyle = this._theme.accent;
1582
+ m.lineWidth = 2;
1583
+ m.strokeRect(vx, vy, cw * s, ch * s);
1584
+ }
1585
+ // Path-highlight on hover (Obsidian-style fade).
1586
+ setPathHighlight(on) { this._pathHighlightEnabled = !!on; if (!on) this._focusedSet = null; }
1587
+
1588
+ // ── Plugin API ────────────────────────────────────────────────────────
1589
+ registerKind(spec) {
1590
+ const idx = this.kinds.length;
1591
+ const cat = {
1592
+ name: spec.name ?? `custom${idx}`,
1593
+ color: spec.color ?? '#94a3b8',
1594
+ badge: spec.badge ?? 'C',
1595
+ w: spec.w ?? 140,
1596
+ h: spec.h ?? 60,
1597
+ nin: spec.nin ?? 1,
1598
+ nout: spec.nout ?? 1,
1599
+ shape: spec.shape ?? 'rect',
1600
+ html: spec.html === true,
1601
+ template: spec.template || null,
1602
+ execute: typeof spec.execute === 'function' ? spec.execute : null,
1603
+ portIn: Array.isArray(spec.portIn) ? spec.portIn.slice() : null,
1604
+ portOut: Array.isArray(spec.portOut) ? spec.portOut.slice() : null,
1605
+ // Schema declarations — each entry: { name, type, required?, default? }
1606
+ // Type is a string used by isCompatibleType(). Special: 'any' matches everything.
1607
+ inputs: Array.isArray(spec.inputs) ? spec.inputs.slice() : null,
1608
+ outputs: Array.isArray(spec.outputs) ? spec.outputs.slice() : null,
1609
+ retry: spec.retry || null,
1610
+ };
1611
+ this.kinds.push(cat);
1612
+ this.kindByName.set(cat.name, idx);
1613
+ this._runOrder = null;
1614
+ return idx;
1615
+ }
1616
+
1617
+ // ── Sticky notes ──────────────────────────────────────────────────────
1618
+ addNote(x, y, text = '', opts = {}) {
1619
+ const palette = [
1620
+ { fill: 'rgba(254,249,195,0.94)', text: '#5b3d12', border: '#caa54a' },
1621
+ { fill: 'rgba(252,231,243,0.94)', text: '#831843', border: '#db5895' },
1622
+ { fill: 'rgba(220,252,231,0.94)', text: '#14532d', border: '#5cad75' },
1623
+ { fill: 'rgba(219,234,254,0.94)', text: '#1e3a8a', border: '#5b8def' },
1624
+ ];
1625
+ const color = opts.color || palette[this.notes.length % palette.length];
1626
+ const note = { id: ++this._noteSeq, x, y, w: opts.w || 220, h: opts.h || 130, text, color };
1627
+ this.notes.push(note);
1628
+ this._emit('change');
1629
+ return note.id;
1630
+ }
1631
+ deleteNote(id) { this.notes = this.notes.filter((n) => n.id !== id); this._emit('change'); }
1632
+
1633
+ // ── Frames (groups) ───────────────────────────────────────────────────
1634
+ addFrame(x, y, w, h, label = 'Group', color = '#5b8def') {
1635
+ const f = { id: ++this._frameSeq, x, y, w, h, label, color };
1636
+ this.frames.push(f);
1637
+ this._emit('change');
1638
+ return f.id;
1639
+ }
1640
+ groupSelection(label) {
1641
+ const sel = this.getSelection();
1642
+ if (sel.length === 0) return -1;
1643
+ let mnx = Infinity, mxx = -Infinity, mny = Infinity, mxy = -Infinity;
1644
+ for (const i of sel) {
1645
+ const hw = this.V.sizeW[i] * 0.5, hh = this.V.sizeH[i] * 0.5;
1646
+ if (this.V.posX[i] - hw < mnx) mnx = this.V.posX[i] - hw;
1647
+ if (this.V.posX[i] + hw > mxx) mxx = this.V.posX[i] + hw;
1648
+ if (this.V.posY[i] - hh < mny) mny = this.V.posY[i] - hh;
1649
+ if (this.V.posY[i] + hh > mxy) mxy = this.V.posY[i] + hh;
1650
+ }
1651
+ const pad = 30;
1652
+ return this.addFrame(mnx - pad, mny - pad - 26, mxx - mnx + pad * 2, mxy - mny + pad * 2 + 26, label || `Group ${this.frames.length + 1}`);
1653
+ }
1654
+ deleteFrame(id) { this.frames = this.frames.filter((f) => f.id !== id); this._emit('change'); }
1655
+
1656
+ // Subflow drill-in: focus on a frame.
1657
+ enterSubflow(fid) {
1658
+ const idx = this.frames.findIndex((f) => f.id === fid);
1659
+ if (idx === -1) return;
1660
+ this._focusFrame = idx;
1661
+ const f = this.frames[idx];
1662
+ this.cam.x = -(f.x + f.w / 2); this.cam.y = -(f.y + f.h / 2);
1663
+ this.cam.zoom = Math.min(this.canvas.width / (f.w + 80), this.canvas.height / (f.h + 80)) * 0.9;
1664
+ this._emit('subflow', f.id);
1665
+ }
1666
+ exitSubflow() {
1667
+ if (this._focusFrame === -1) return;
1668
+ this._focusFrame = -1;
1669
+ this.fitView();
1670
+ this._emit('subflow', null);
1671
+ }
1672
+ _isInsideFocusFrame(nodeId) {
1673
+ if (this._focusFrame === -1) return true;
1674
+ const f = this.frames[this._focusFrame];
1675
+ return this.V.posX[nodeId] >= f.x && this.V.posX[nodeId] <= f.x + f.w &&
1676
+ this.V.posY[nodeId] >= f.y && this.V.posY[nodeId] <= f.y + f.h;
1677
+ }
1678
+
1679
+ _resolveKind(k) {
1680
+ if (typeof k === 'number') return k;
1681
+ const idx = this.kindByName.get(k);
1682
+ if (idx === undefined) throw new Error(`zflow: unknown kind "${k}"`);
1683
+ return idx;
1684
+ }
1685
+
1686
+ // ── Layout / view ─────────────────────────────────────────────────────
1687
+ runAutoLayout() {
1688
+ const layers = this.w.autoLayout();
1689
+ this.w.snapshot();
1690
+ this._emit('change');
1691
+ return layers;
1692
+ }
1693
+ runForceLayout(maxFrames = 220) {
1694
+ if (this._forceRaf) cancelAnimationFrame(this._forceRaf);
1695
+ this.w.forceLayoutReset();
1696
+ let i = 0;
1697
+ const tick = () => {
1698
+ this.w.forceLayoutTick(0.05);
1699
+ i++;
1700
+ if (i < maxFrames) this._forceRaf = requestAnimationFrame(tick);
1701
+ else { this._forceRaf = null; this.w.snapshot(); this._emit('change'); }
1702
+ };
1703
+ this._forceRaf = requestAnimationFrame(tick);
1704
+ }
1705
+ fitView(padding = 80) {
1706
+ const n = this.w.nodeCount_();
1707
+ if (n === 0) return;
1708
+ let mnx = Infinity, mxx = -Infinity, mny = Infinity, mxy = -Infinity;
1709
+ for (let i = 0; i < n; i++) {
1710
+ const hw = this.V.sizeW[i] * 0.5, hh = this.V.sizeH[i] * 0.5;
1711
+ if (this.V.posX[i] - hw < mnx) mnx = this.V.posX[i] - hw;
1712
+ if (this.V.posX[i] + hw > mxx) mxx = this.V.posX[i] + hw;
1713
+ if (this.V.posY[i] - hh < mny) mny = this.V.posY[i] - hh;
1714
+ if (this.V.posY[i] + hh > mxy) mxy = this.V.posY[i] + hh;
1715
+ }
1716
+ const bw = mxx - mnx + padding * 2, bh = mxy - mny + padding * 2;
1717
+ this.cam.x = -(mnx + (mxx - mnx) / 2);
1718
+ this.cam.y = -(mny + (mxy - mny) / 2);
1719
+ this.cam.zoom = Math.min(this.canvas.width / bw, this.canvas.height / bh) * 0.85;
1720
+ }
1721
+ zoomTo(zoom) { this.cam.zoom = Math.max(0.2, Math.min(3.0, zoom)); }
1722
+ panTo(x, y) { this.cam.x = -x; this.cam.y = -y; }
1723
+
1724
+ // ── History ───────────────────────────────────────────────────────────
1725
+ undo() { if (this.w.undo()) this._emit('change'); }
1726
+ redo() { if (this.w.redo()) this._emit('change'); }
1727
+ snapshot() { this.w.snapshot(); }
1728
+
1729
+ // ── Algorithms ────────────────────────────────────────────────────────
1730
+ /** Longest path in the DAG via topo-sort + DP. Returns [edgeIds] or []. */
1731
+ criticalPath() {
1732
+ const n = this.w.nodeCount_(), m = this.w.edgeCount_();
1733
+ if (n === 0) return [];
1734
+ const inDeg = new Uint32Array(n);
1735
+ for (let i = 0; i < m; i++) inDeg[this.V.edgeToN[i]]++;
1736
+ const queue = [];
1737
+ for (let i = 0; i < n; i++) if (inDeg[i] === 0) queue.push(i);
1738
+ const dist = new Int32Array(n);
1739
+ const predEdge = new Int32Array(n); predEdge.fill(-1);
1740
+ const adj = this._buildAdj();
1741
+ let head = 0;
1742
+ while (head < queue.length) {
1743
+ const u = queue[head++];
1744
+ for (const e of (adj.get(u) || [])) {
1745
+ if (dist[u] + 1 > dist[e.to]) { dist[e.to] = dist[u] + 1; predEdge[e.to] = e.edge; }
1746
+ inDeg[e.to]--;
1747
+ if (inDeg[e.to] === 0) queue.push(e.to);
1748
+ }
1749
+ }
1750
+ let best = 0;
1751
+ for (let i = 1; i < n; i++) if (dist[i] > dist[best]) best = i;
1752
+ if (dist[best] === 0) return [];
1753
+ const path = [];
1754
+ let cur = best;
1755
+ while (predEdge[cur] !== -1) { path.push(predEdge[cur]); cur = this.V.edgeFromN[predEdge[cur]]; }
1756
+ return path;
1757
+ }
1758
+
1759
+ /** Tarjan's SCC. Returns array of arrays (each non-trivial SCC's node ids). */
1760
+ findSCCs() {
1761
+ const n = this.w.nodeCount_();
1762
+ const adj = this._buildAdj();
1763
+ const index = new Int32Array(n).fill(-1);
1764
+ const lowlink = new Int32Array(n);
1765
+ const onStack = new Uint8Array(n);
1766
+ const stack = [];
1767
+ const sccs = [];
1768
+ let counter = 0;
1769
+ for (let start = 0; start < n; start++) {
1770
+ if (index[start] !== -1) continue;
1771
+ const work = [{ v: start, child: 0 }];
1772
+ index[start] = counter; lowlink[start] = counter++;
1773
+ stack.push(start); onStack[start] = 1;
1774
+ while (work.length) {
1775
+ const top = work[work.length - 1];
1776
+ const out = adj.get(top.v) || [];
1777
+ if (top.child < out.length) {
1778
+ const wto = out[top.child++].to;
1779
+ if (index[wto] === -1) {
1780
+ index[wto] = counter; lowlink[wto] = counter++;
1781
+ stack.push(wto); onStack[wto] = 1;
1782
+ work.push({ v: wto, child: 0 });
1783
+ } else if (onStack[wto]) {
1784
+ if (index[wto] < lowlink[top.v]) lowlink[top.v] = index[wto];
1785
+ }
1786
+ } else {
1787
+ if (lowlink[top.v] === index[top.v]) {
1788
+ const scc = [];
1789
+ while (stack.length) {
1790
+ const x = stack.pop(); onStack[x] = 0; scc.push(x);
1791
+ if (x === top.v) break;
1792
+ }
1793
+ if (scc.length >= 2) sccs.push(scc);
1794
+ }
1795
+ const finished = top.v;
1796
+ work.pop();
1797
+ if (work.length && lowlink[finished] < lowlink[work[work.length - 1].v]) {
1798
+ lowlink[work[work.length - 1].v] = lowlink[finished];
1799
+ }
1800
+ }
1801
+ }
1802
+ }
1803
+ return sccs;
1804
+ }
1805
+
1806
+ /** Heatmap: color every node by its in+out degree. Pass null to clear. */
1807
+ colorByDegree() {
1808
+ const n = this.w.nodeCount_(), m = this.w.edgeCount_();
1809
+ const deg = new Uint16Array(n);
1810
+ for (let i = 0; i < m; i++) { deg[this.V.edgeFromN[i]]++; deg[this.V.edgeToN[i]]++; }
1811
+ let max = 1;
1812
+ for (let i = 0; i < n; i++) if (deg[i] > max) max = deg[i];
1813
+ const ramp = ['#3b5fc4', '#5b8def', '#5be0d0', '#5bd17a', '#f0b93a', '#fb923c', '#e8462b'];
1814
+ const lerp = (t) => {
1815
+ if (t <= 0) return ramp[0]; if (t >= 1) return ramp[ramp.length - 1];
1816
+ const idx = t * (ramp.length - 1), i0 = Math.floor(idx), i1 = Math.min(ramp.length - 1, i0 + 1);
1817
+ const f = idx - i0; const a = parseHex$1(ramp[i0]), b = parseHex$1(ramp[i1]);
1818
+ return `rgb(${Math.round(a[0]*(1-f)+b[0]*f)},${Math.round(a[1]*(1-f)+b[1]*f)},${Math.round(a[2]*(1-f)+b[2]*f)})`;
1819
+ };
1820
+ for (let i = 0; i < n; i++) this.colors.set(i, lerp(deg[i] / max));
1821
+ this._emit('change');
1822
+ }
1823
+ clearNodeColors() {
1824
+ this.colors.clear();
1825
+ this._emit('change');
1826
+ }
1827
+
1828
+ // ── Imports (Mermaid + DOT) ───────────────────────────────────────────
1829
+ importMermaid(text) {
1830
+ const parsed = parseMermaid(text);
1831
+ if (!parsed || parsed.nodes.size === 0) return 0;
1832
+ const shapeMap = { rect: 'process', rhombus: 'decision', circle: 'branch', round: 'process', subroutine: 'aggregator', default: 'process' };
1833
+ const idMap = new Map();
1834
+ let drop = 0;
1835
+ for (const [mid, def] of parsed.nodes) {
1836
+ const id = this.addNode({
1837
+ kind: shapeMap[def.shape] || 'process',
1838
+ x: (drop % 8) * 200 - 700, y: Math.floor(drop / 8) * 110,
1839
+ title: def.label,
1840
+ });
1841
+ if (id < 0) break;
1842
+ idMap.set(mid, id); drop++;
1843
+ }
1844
+ for (const e of parsed.edges) {
1845
+ const a = idMap.get(e.from), b = idMap.get(e.to);
1846
+ if (a === undefined || b === undefined) continue;
1847
+ this.addEdge({ from: a, to: b, label: e.label });
1848
+ }
1849
+ this.runAutoLayout();
1850
+ this.fitView();
1851
+ return parsed.nodes.size;
1852
+ }
1853
+ importDot(text) {
1854
+ const parsed = parseDot(text);
1855
+ if (!parsed || parsed.nodes.size === 0) return 0;
1856
+ const idMap = new Map();
1857
+ let drop = 0;
1858
+ for (const [mid, def] of parsed.nodes) {
1859
+ const id = this.addNode({ kind: 'process', x: (drop % 8) * 200 - 700, y: Math.floor(drop / 8) * 110, title: def.label });
1860
+ if (id < 0) break;
1861
+ idMap.set(mid, id); drop++;
1862
+ }
1863
+ for (const e of parsed.edges) {
1864
+ const a = idMap.get(e.from), b = idMap.get(e.to);
1865
+ if (a === undefined || b === undefined) continue;
1866
+ const eid = this.addEdge({ from: a, to: b });
1867
+ if (eid >= 0 && e.label) this.setEdgeLabel(eid, e.label);
1868
+ }
1869
+ this.runAutoLayout();
1870
+ this.fitView();
1871
+ return parsed.nodes.size;
1872
+ }
1873
+
1874
+ /** Returns an array of edge indices forming the shortest path, or [] if unreachable. */
1875
+ shortestPathSafe(from, to) { return this.shortestPath(from, to) || []; }
1876
+ shortestPath(from, to) {
1877
+ const adj = this._buildAdj();
1878
+ const prev = new Map(); prev.set(from, null);
1879
+ const queue = [from];
1880
+ while (queue.length) {
1881
+ const u = queue.shift();
1882
+ if (u === to) break;
1883
+ for (const e of (adj.get(u) || [])) {
1884
+ if (!prev.has(e.to)) { prev.set(e.to, { from: u, edgeIdx: e.edge }); queue.push(e.to); }
1885
+ }
1886
+ }
1887
+ if (!prev.has(to)) return [];
1888
+ const path = [];
1889
+ let cur = to;
1890
+ while (prev.get(cur)) { path.push(prev.get(cur).edgeIdx); cur = prev.get(cur).from; }
1891
+ return path.reverse();
1892
+ }
1893
+ findCycles() {
1894
+ const n = this.w.nodeCount_();
1895
+ const color = new Uint8Array(n);
1896
+ const result = new Set();
1897
+ const adj = this._buildAdj();
1898
+ for (let start = 0; start < n; start++) {
1899
+ if (color[start] !== 0) continue;
1900
+ const stack = [{ u: start, iter: (adj.get(start) || [])[Symbol.iterator]() }];
1901
+ color[start] = 1;
1902
+ while (stack.length) {
1903
+ const top = stack[stack.length - 1];
1904
+ const next = top.iter.next();
1905
+ if (next.done) { color[top.u] = 2; stack.pop(); continue; }
1906
+ const e = next.value;
1907
+ if (color[e.to] === 1) result.add(e.edge);
1908
+ else if (color[e.to] === 0) {
1909
+ color[e.to] = 1;
1910
+ stack.push({ u: e.to, iter: (adj.get(e.to) || [])[Symbol.iterator]() });
1911
+ }
1912
+ }
1913
+ }
1914
+ return [...result];
1915
+ }
1916
+ /** Build per-node edge-id adjacency for fast dirty-marking. */
1917
+ _ensureAdj() {
1918
+ if (!this._adjDirty && this._nodeAdj) return;
1919
+ const n = this.w.nodeCount_(), m = this.w.edgeCount_();
1920
+ const adj = new Array(n);
1921
+ for (let i = 0; i < n; i++) adj[i] = [];
1922
+ for (let e = 0; e < m; e++) {
1923
+ const a = this.V.edgeFromN[e], b = this.V.edgeToN[e];
1924
+ if (a < n) adj[a].push(e);
1925
+ if (b < n && a !== b) adj[b].push(e);
1926
+ }
1927
+ this._nodeAdj = adj;
1928
+ this._adjDirty = false;
1929
+ }
1930
+
1931
+ _buildAdj() {
1932
+ const m = this.w.edgeCount_();
1933
+ const adj = new Map();
1934
+ for (let i = 0; i < m; i++) {
1935
+ const a = this.V.edgeFromN[i];
1936
+ if (!adj.has(a)) adj.set(a, []);
1937
+ adj.get(a).push({ to: this.V.edgeToN[i], edge: i });
1938
+ }
1939
+ return adj;
1940
+ }
1941
+
1942
+ // ── Persistence ───────────────────────────────────────────────────────
1943
+ toJSON() {
1944
+ const n = this.w.nodeCount_(), m = this.w.edgeCount_();
1945
+ const nodes = [];
1946
+ for (let i = 0; i < n; i++) {
1947
+ const node = {
1948
+ id: i, kind: this.kinds[this.V.kind[i]].name,
1949
+ x: this.V.posX[i], y: this.V.posY[i],
1950
+ w: this.V.sizeW[i], h: this.V.sizeH[i],
1951
+ nin: this.V.nIn[i], nout: this.V.nOut[i],
1952
+ };
1953
+ if (this.titles.has(i)) node.title = this.titles.get(i);
1954
+ if (this.colors.has(i)) node.color = this.colors.get(i);
1955
+ if (this.descriptions.has(i)) node.description = this.descriptions.get(i);
1956
+ if (this.tags.has(i)) node.tags = this.tags.get(i);
1957
+ if (this.status.has(i)) node.status = this.status.get(i);
1958
+ if (this.progress.has(i)) node.progress = this.progress.get(i);
1959
+ nodes.push(node);
1960
+ }
1961
+ const edges = [];
1962
+ for (let i = 0; i < m; i++) {
1963
+ const edge = { from: this.V.edgeFromN[i], fp: this.V.edgeFromP[i],
1964
+ to: this.V.edgeToN[i], tp: this.V.edgeToP[i] };
1965
+ if (this.edgeLabels.has(i)) edge.label = this.edgeLabels.get(i);
1966
+ edges.push(edge);
1967
+ }
1968
+ return {
1969
+ version: 1, nodes, edges,
1970
+ camera: { ...this.cam },
1971
+ edgeStyle: this.options.edgeStyle,
1972
+ };
1973
+ }
1974
+ loadJSON(data) {
1975
+ this.w.reset();
1976
+ this.titles.clear(); this.colors.clear(); this.descriptions.clear();
1977
+ this.tags.clear(); this.status.clear(); this.progress.clear();
1978
+ this.edgeLabels.clear();
1979
+ const idMap = new Map();
1980
+ for (const node of (data.nodes || [])) {
1981
+ const id = this.addNode({
1982
+ kind: node.kind, x: node.x, y: node.y, w: node.w, h: node.h,
1983
+ title: node.title, color: node.color, description: node.description,
1984
+ tags: node.tags, status: node.status, progress: node.progress,
1985
+ });
1986
+ idMap.set(node.id ?? id, id);
1987
+ }
1988
+ for (const edge of (data.edges || [])) {
1989
+ this.addEdge({
1990
+ from: idMap.get(edge.from) ?? edge.from, fp: edge.fp,
1991
+ to: idMap.get(edge.to) ?? edge.to, tp: edge.tp,
1992
+ label: edge.label,
1993
+ });
1994
+ }
1995
+ if (data.camera) Object.assign(this.cam, data.camera);
1996
+ if (data.edgeStyle) this.options.edgeStyle = data.edgeStyle;
1997
+ this.w.snapshot();
1998
+ this._emit('change');
1999
+ }
2000
+ async exportPNG() {
2001
+ return new Promise((resolve) => this.canvas.toBlob(resolve, 'image/png'));
2002
+ }
2003
+
2004
+ /** Build a standalone SVG document representing the current graph. */
2005
+ exportSVG() {
2006
+ const n = this.w.nodeCount_(), m = this.w.edgeCount_();
2007
+ if (n === 0) return '<svg xmlns="http://www.w3.org/2000/svg"/>';
2008
+ let mnx = Infinity, mxx = -Infinity, mny = Infinity, mxy = -Infinity;
2009
+ for (let i = 0; i < n; i++) {
2010
+ const hw = this.V.sizeW[i] * 0.5, hh = this.V.sizeH[i] * 0.5;
2011
+ if (this.V.posX[i] - hw < mnx) mnx = this.V.posX[i] - hw;
2012
+ if (this.V.posX[i] + hw > mxx) mxx = this.V.posX[i] + hw;
2013
+ if (this.V.posY[i] - hh < mny) mny = this.V.posY[i] - hh;
2014
+ if (this.V.posY[i] + hh > mxy) mxy = this.V.posY[i] + hh;
2015
+ }
2016
+ const pad = 40;
2017
+ const bw = mxx - mnx + pad * 2, bh = mxy - mny + pad * 2;
2018
+ const out = [`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${mnx - pad} ${mny - pad} ${bw} ${bh}" width="${bw}" height="${bh}" style="background:${this.options.background}">`];
2019
+ for (let i = 0; i < m; i++) {
2020
+ const a = this.V.edgeFromN[i], b = this.V.edgeToN[i];
2021
+ const ap = this._portWorld(a, 1, this.V.edgeFromP[i]);
2022
+ const bp = this._portWorld(b, 0, this.V.edgeToP[i]);
2023
+ const cA = this.colors.get(a) || this.kinds[this.V.kind[a]].color;
2024
+ const cB = this.colors.get(b) || this.kinds[this.V.kind[b]].color;
2025
+ const gid = `g${i}`;
2026
+ out.push(`<defs><linearGradient id="${gid}" x1="${ap.x}" y1="${ap.y}" x2="${bp.x}" y2="${bp.y}" gradientUnits="userSpaceOnUse"><stop offset="0%" stop-color="${cA}"/><stop offset="100%" stop-color="${cB}"/></linearGradient></defs>`);
2027
+ if (this.options.edgeStyle === 'orthogonal') {
2028
+ const path = this._orthoPath(ap, bp);
2029
+ const d = `M ${path[0].x} ${path[0].y} ` + path.slice(1).map((p) => `L ${p.x} ${p.y}`).join(' ');
2030
+ out.push(`<path d="${d}" stroke="url(#${gid})" stroke-width="1.7" fill="none" stroke-linejoin="round"/>`);
2031
+ } else {
2032
+ const dx = bp.x - ap.x, dy = bp.y - ap.y;
2033
+ const off = Math.max(50, Math.abs(dx) * 0.5 + Math.abs(dy) * 0.4);
2034
+ out.push(`<path d="M ${ap.x} ${ap.y} C ${ap.x + off} ${ap.y} ${bp.x - off} ${bp.y} ${bp.x} ${bp.y}" stroke="url(#${gid})" stroke-width="1.7" fill="none"/>`);
2035
+ }
2036
+ }
2037
+ for (let i = 0; i < n; i++) {
2038
+ const cat = this.kinds[this.V.kind[i]];
2039
+ const color = this.colors.get(i) || cat.color;
2040
+ const x = this.V.posX[i] - this.V.sizeW[i] / 2;
2041
+ const y = this.V.posY[i] - this.V.sizeH[i] / 2;
2042
+ const w = this.V.sizeW[i], h = this.V.sizeH[i];
2043
+ if (cat.shape === 'diamond') {
2044
+ const cx = this.V.posX[i], cy = this.V.posY[i];
2045
+ out.push(`<polygon points="${cx},${y} ${x+w},${cy} ${cx},${y+h} ${x},${cy}" fill="#161b27" stroke="${color}" stroke-width="1.4"/>`);
2046
+ } else if (cat.shape === 'ellipse') {
2047
+ out.push(`<ellipse cx="${this.V.posX[i]}" cy="${this.V.posY[i]}" rx="${w/2}" ry="${h/2}" fill="#161b27" stroke="${color}" stroke-width="1.4"/>`);
2048
+ } else {
2049
+ out.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="#161b27" stroke="${color}" stroke-width="1.4"/>`);
2050
+ if (cat.shape === 'rect') out.push(`<rect x="${x}" y="${y}" width="${w}" height="22" rx="8" fill="${color}"/>`);
2051
+ }
2052
+ const title = this.titles.get(i) || `${cat.name} #${i}`;
2053
+ out.push(`<text x="${this.V.posX[i]}" y="${y + (cat.shape === 'rect' ? 14 : h/2)}" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" text-anchor="middle" fill="${cat.shape === 'rect' ? '#0b0f17' : color}">${escapeXml(title)}</text>`);
2054
+ }
2055
+ out.push('</svg>');
2056
+ return out.join('\n');
2057
+ }
2058
+
2059
+ // ── Events ────────────────────────────────────────────────────────────
2060
+ on(event, fn) {
2061
+ if (!this.listeners.has(event)) this.listeners.set(event, []);
2062
+ this.listeners.get(event).push(fn);
2063
+ return () => { const arr = this.listeners.get(event); const i = arr.indexOf(fn); if (i >= 0) arr.splice(i, 1); };
2064
+ }
2065
+ _emit(event, ...args) {
2066
+ if (this._suspendEvents && event !== 'change') return;
2067
+ const arr = this.listeners.get(event);
2068
+ if (!arr) return;
2069
+ for (const fn of arr.slice()) try { fn(...args); } catch (e) { console.error(e); }
2070
+ }
2071
+
2072
+ // ── Coordinate helpers ────────────────────────────────────────────────
2073
+ _resize() {
2074
+ const dpr = window.devicePixelRatio || 1;
2075
+ const r = this.container.getBoundingClientRect();
2076
+ this.canvas.width = Math.floor(r.width * dpr);
2077
+ this.canvas.height = Math.floor(r.height * dpr);
2078
+ this.canvas.style.width = r.width + 'px';
2079
+ this.canvas.style.height = r.height + 'px';
2080
+ }
2081
+ _w2s(wx, wy) {
2082
+ return { x: this.canvas.width / 2 + (wx + this.cam.x) * this.cam.zoom,
2083
+ y: this.canvas.height / 2 + (wy + this.cam.y) * this.cam.zoom };
2084
+ }
2085
+ _s2w(cx, cy) {
2086
+ const dpr = window.devicePixelRatio || 1;
2087
+ const r = this.canvas.getBoundingClientRect();
2088
+ const sx = (cx - r.left) * dpr, sy = (cy - r.top) * dpr;
2089
+ return { x: (sx - this.canvas.width / 2) / this.cam.zoom - this.cam.x,
2090
+ y: (sy - this.canvas.height / 2) / this.cam.zoom - this.cam.y };
2091
+ }
2092
+
2093
+ // ── Interactions ──────────────────────────────────────────────────────
2094
+ _attachEvents() {
2095
+ const c = this.canvas;
2096
+ c.addEventListener('contextmenu', (e) => e.preventDefault());
2097
+
2098
+ c.addEventListener('mousedown', (e) => {
2099
+ this._hideMenu();
2100
+ if (this._editingNoteEl && this._editingNote !== -1) this._editingNoteEl.blur();
2101
+ if (this._editingTitleEl && this._editingTitle !== -1) this._editingTitleEl.blur();
2102
+ const wp = this._s2w(e.clientX, e.clientY);
2103
+ if (e.button === 2) { this._onRightClick(e, wp); return; }
2104
+ if (e.button === 1) { this._startPan(e); return; }
2105
+ // Port? (bidirectional)
2106
+ const ph = this.w.hitTestPort(wp.x, wp.y, 11);
2107
+ if (ph !== -1) {
2108
+ const side = (ph >>> 24) & 0xFF, idx = (ph >>> 16) & 0xFF, nid = ph & 0xFFFF;
2109
+ this._mode = 'connecting';
2110
+ this._edgeStart = { nodeId: nid, side, idx };
2111
+ this._edgeCursor = wp;
2112
+ this.canvas.style.cursor = 'crosshair';
2113
+ return;
2114
+ }
2115
+ // Resize handle?
2116
+ const handle = this._hitHandle(wp.x, wp.y);
2117
+ if (handle && !this.readOnly && !this.locked.has(handle.nodeId)) {
2118
+ this._mode = 'resize';
2119
+ this._resizingHandle = { ...handle, lastX: wp.x, lastY: wp.y };
2120
+ return;
2121
+ }
2122
+ // Edge waypoint drag?
2123
+ const wpHit = this._hitWaypoint(wp.x, wp.y);
2124
+ if (wpHit && !this.readOnly) {
2125
+ this._draggingWaypoint = wpHit;
2126
+ this._mode = 'drag-waypoint';
2127
+ return;
2128
+ }
2129
+ // Frame corner resize?
2130
+ const fc = this._hitFrameCorner(wp.x, wp.y);
2131
+ if (fc) {
2132
+ this._mode = 'resize-frame';
2133
+ this._resizingFrame = { ...fc, lastX: wp.x, lastY: wp.y };
2134
+ return;
2135
+ }
2136
+ // Frame header drag?
2137
+ const fh = this._hitFrameHeader(wp.x, wp.y);
2138
+ if (fh !== -1) {
2139
+ this._mode = 'drag-frame';
2140
+ this._draggingFrame = fh;
2141
+ this._frameDragLast = wp;
2142
+ return;
2143
+ }
2144
+ // Sticky note drag?
2145
+ const nh = this._hitNote(wp.x, wp.y);
2146
+ if (nh !== -1) {
2147
+ this._mode = 'drag-note';
2148
+ this._draggingNote = nh;
2149
+ this._noteDragLast = wp;
2150
+ return;
2151
+ }
2152
+ // Sub-task checkbox click?
2153
+ const taskHit = this._hitTaskCheckbox(wp.x, wp.y);
2154
+ if (taskHit) {
2155
+ const list = this.tasks.get(taskHit.nodeId);
2156
+ if (list && list[taskHit.taskIdx]) {
2157
+ list[taskHit.taskIdx].done = !list[taskHit.taskIdx].done;
2158
+ const done = list.filter((t) => t.done).length;
2159
+ this.progress.set(taskHit.nodeId, done / list.length);
2160
+ this._emit('change');
2161
+ }
2162
+ return;
2163
+ }
2164
+ // Node?
2165
+ const nid = this.w.hitTestNode(wp.x, wp.y);
2166
+ if (nid !== -1) {
2167
+ if (!e.shiftKey && this.V.selected[nid] === 0) { this.w.clearSelection(); this.w.setSelected(nid, 1); }
2168
+ else if (e.shiftKey) this.w.toggleSelected(nid);
2169
+ // Locked / read-only → select but do not drag.
2170
+ if (!this.readOnly && !this.locked.has(nid)) {
2171
+ this._mode = 'drag';
2172
+ this._dragLast = wp;
2173
+ }
2174
+ this._emit('select', this.getSelection());
2175
+ return;
2176
+ }
2177
+ // Edge click → select.
2178
+ const eid = this._hitTestEdge(wp.x, wp.y, 6 / this.cam.zoom);
2179
+ if (eid !== -1) {
2180
+ if (!e.shiftKey) this.w.clearSelection();
2181
+ this.w.setEdgeSelected(eid, 1);
2182
+ this._emit('select', this.getSelection());
2183
+ return;
2184
+ }
2185
+ // Alt-drag empty → lasso.
2186
+ if (e.altKey) {
2187
+ if (!e.shiftKey) this.w.clearSelection();
2188
+ this._mode = 'lasso';
2189
+ this._lasso = [{ x: wp.x, y: wp.y }];
2190
+ return;
2191
+ }
2192
+ // Empty space → marquee.
2193
+ if (!e.shiftKey) this.w.clearSelection();
2194
+ this._mode = 'marquee';
2195
+ this._marquee = { x0: wp.x, y0: wp.y, x1: wp.x, y1: wp.y };
2196
+ });
2197
+
2198
+ c.addEventListener('mousemove', (e) => {
2199
+ const wp = this._s2w(e.clientX, e.clientY);
2200
+ if (this._mode === 'pan') {
2201
+ const dpr = window.devicePixelRatio || 1;
2202
+ const dxW = (e.clientX - this._dragStart.sx) * dpr / this.cam.zoom;
2203
+ const dyW = (e.clientY - this._dragStart.sy) * dpr / this.cam.zoom;
2204
+ this.cam.x += dxW; this.cam.y += dyW;
2205
+ const now = performance.now();
2206
+ const dt = Math.max(1, now - (this._panVel.lastTs || now)) / 1000;
2207
+ this._panVel.x = dxW / dt; this._panVel.y = dyW / dt; this._panVel.lastTs = now;
2208
+ this._dragStart.sx = e.clientX; this._dragStart.sy = e.clientY;
2209
+ return;
2210
+ }
2211
+ if (this._mode === 'resize' && this._resizingHandle) {
2212
+ const dx = wp.x - this._resizingHandle.lastX, dy = wp.y - this._resizingHandle.lastY;
2213
+ this._applyResize(this._resizingHandle.corner, dx, dy);
2214
+ this._resizingHandle.lastX = wp.x; this._resizingHandle.lastY = wp.y;
2215
+ return;
2216
+ }
2217
+ if (this._mode === 'drag-waypoint' && this._draggingWaypoint) {
2218
+ const { edgeIdx, wpIdx } = this._draggingWaypoint;
2219
+ const list = this._edgeWaypoints.get(edgeIdx);
2220
+ if (list && list[wpIdx]) { list[wpIdx].x = wp.x; list[wpIdx].y = wp.y; }
2221
+ return;
2222
+ }
2223
+ if (this._mode === 'drag') {
2224
+ if (this._gl) {
2225
+ this._ensureAdj();
2226
+ for (let i = 0; i < this.w.nodeCount_(); i++) if (this.V.selected[i]) {
2227
+ this._gl.markNodeDirty(i);
2228
+ const edges = this._nodeAdj[i];
2229
+ if (edges) for (let k = 0; k < edges.length; k++) this._gl.markEdgeDirty(edges[k]);
2230
+ }
2231
+ }
2232
+ let dx = wp.x - this._dragLast.x, dy = wp.y - this._dragLast.y;
2233
+ if (this.options.snapToGrid) {
2234
+ // Snap by the first selected node.
2235
+ for (let i = 0; i < this.w.nodeCount_(); i++) {
2236
+ if (this.V.selected[i]) {
2237
+ const grid = this.options.gridSize;
2238
+ const nx = Math.round((this.V.posX[i] + dx) / grid) * grid;
2239
+ const ny = Math.round((this.V.posY[i] + dy) / grid) * grid;
2240
+ dx = nx - this.V.posX[i]; dy = ny - this.V.posY[i]; break;
2241
+ }
2242
+ }
2243
+ this._alignGuides = null;
2244
+ } else {
2245
+ const sa = this._computeAlignSnap(dx, dy);
2246
+ dx += sa.dx; dy += sa.dy;
2247
+ this._alignGuides = { v: sa.guideX !== null ? [sa.guideX] : [],
2248
+ h: sa.guideY !== null ? [sa.guideY] : [] };
2249
+ }
2250
+ this.w.moveSelectedBy(dx, dy);
2251
+ this._dragLast = { x: this._dragLast.x + dx, y: this._dragLast.y + dy };
2252
+ return;
2253
+ }
2254
+ if (this._mode === 'drag-frame') {
2255
+ const dx = wp.x - this._frameDragLast.x, dy = wp.y - this._frameDragLast.y;
2256
+ const f = this.frames[this._draggingFrame];
2257
+ f.x += dx; f.y += dy;
2258
+ for (let i = 0; i < this.w.nodeCount_(); i++) {
2259
+ if (this.V.posX[i] >= f.x && this.V.posX[i] <= f.x + f.w &&
2260
+ this.V.posY[i] >= f.y && this.V.posY[i] <= f.y + f.h) {
2261
+ this.V.posX[i] += dx; this.V.posY[i] += dy;
2262
+ }
2263
+ }
2264
+ this._frameDragLast = wp;
2265
+ return;
2266
+ }
2267
+ if (this._mode === 'resize-frame' && this._resizingFrame) {
2268
+ const dx = wp.x - this._resizingFrame.lastX, dy = wp.y - this._resizingFrame.lastY;
2269
+ this._applyFrameResize(this._resizingFrame.idx, this._resizingFrame.corner, dx, dy);
2270
+ this._resizingFrame.lastX = wp.x; this._resizingFrame.lastY = wp.y;
2271
+ return;
2272
+ }
2273
+ if (this._mode === 'drag-note') {
2274
+ const dx = wp.x - this._noteDragLast.x, dy = wp.y - this._noteDragLast.y;
2275
+ const n = this.notes[this._draggingNote];
2276
+ n.x += dx; n.y += dy;
2277
+ this._noteDragLast = wp;
2278
+ return;
2279
+ }
2280
+ if (this._mode === 'connecting') {
2281
+ this._edgeCursor = wp;
2282
+ return;
2283
+ }
2284
+ if (this._mode === 'lasso' && this._lasso) {
2285
+ const last = this._lasso[this._lasso.length - 1];
2286
+ if (Math.hypot(wp.x - last.x, wp.y - last.y) > 6 / this.cam.zoom) {
2287
+ this._lasso.push({ x: wp.x, y: wp.y });
2288
+ }
2289
+ return;
2290
+ }
2291
+ if (this._mode === 'marquee') {
2292
+ this._marquee.x1 = wp.x; this._marquee.y1 = wp.y;
2293
+ this.w.selectInRect(this._marquee.x0, this._marquee.y0, this._marquee.x1, this._marquee.y1, 1);
2294
+ return;
2295
+ }
2296
+ // Idle hover.
2297
+ const newHover = this.w.hitTestNode(wp.x, wp.y);
2298
+ if (newHover !== this._hoveredNode) {
2299
+ this._hoveredNode = newHover;
2300
+ this._hoveredNodeSince = performance.now();
2301
+ this._lastFocusComputed = -2;
2302
+ }
2303
+ this._hoveredEdge = newHover === -1 ? this._hitTestEdge(wp.x, wp.y, 6 / this.cam.zoom) : -1;
2304
+ const handle = this._hitHandle(wp.x, wp.y);
2305
+ c.style.cursor = handle ? HANDLE_CURSOR[handle.corner] : '';
2306
+ });
2307
+
2308
+ c.addEventListener('mouseup', () => {
2309
+ if (this._mode === 'connecting' && this._edgeCursor) {
2310
+ // Bidirectional: accept either output→input or input→output drop.
2311
+ const ph = this.w.hitTestPort(this._edgeCursor.x, this._edgeCursor.y, 14);
2312
+ if (ph !== -1) {
2313
+ const ts = (ph >>> 24) & 0xFF, ti = (ph >>> 16) & 0xFF, tn = ph & 0xFFFF;
2314
+ if (ts !== this._edgeStart.side && tn !== this._edgeStart.nodeId) {
2315
+ const fromN = this._edgeStart.side === 1 ? this._edgeStart.nodeId : tn;
2316
+ const fromP = this._edgeStart.side === 1 ? this._edgeStart.idx : ti;
2317
+ const toN = this._edgeStart.side === 0 ? this._edgeStart.nodeId : tn;
2318
+ const toP = this._edgeStart.side === 0 ? this._edgeStart.idx : ti;
2319
+ const reason = this.validateConnection(fromN, fromP, toN, toP);
2320
+ if (reason === null) {
2321
+ this.addEdge({ from: fromN, fp: fromP, to: toN, tp: toP });
2322
+ } else {
2323
+ this._emit('connection:rejected', { fromN, fromP, toN, toP, reason });
2324
+ this._flashReject = { x: this._edgeCursor.x, y: this._edgeCursor.y, msg: reason, t0: performance.now() };
2325
+ }
2326
+ }
2327
+ }
2328
+ } else if (this._mode === 'lasso' && this._lasso && this._lasso.length > 2) {
2329
+ for (let i = 0; i < this.w.nodeCount_(); i++) {
2330
+ if (pointInPolygon(this.V.posX[i], this.V.posY[i], this._lasso)) this.w.setSelected(i, 1);
2331
+ }
2332
+ this._emit('select', this.getSelection());
2333
+ } else if (this._mode === 'drag-waypoint') {
2334
+ this._draggingWaypoint = null;
2335
+ this._emit('change');
2336
+ } else if (this._mode === 'drag' || this._mode === 'resize' ||
2337
+ this._mode === 'drag-frame' || this._mode === 'resize-frame' || this._mode === 'drag-note' ||
2338
+ this._mode === 'marquee') {
2339
+ if (this._mode !== 'marquee') { this.w.snapshot(); this._emit('change'); }
2340
+ this._emit('select', this.getSelection());
2341
+ }
2342
+ this._mode = 'idle';
2343
+ this._edgeStart = null; this._edgeCursor = null;
2344
+ this._marquee = null; this._lasso = null; this._alignGuides = null;
2345
+ this._resizingHandle = null; this._resizingFrame = null;
2346
+ this._draggingFrame = -1; this._draggingNote = -1;
2347
+ this.canvas.classList.remove('panning');
2348
+ this.canvas.style.cursor = '';
2349
+ });
2350
+
2351
+ // ── Touch: pinch zoom + two-finger pan ────────────────────────────
2352
+ const pointers = new Map(); // pointerId -> { x, y }
2353
+ let pinchPrev = null; // { dist, mid }
2354
+ let longPressTimer = null;
2355
+ // Track which mouse events came synthesized from pointer to suppress double-firing.
2356
+ this._pointerSynthesizing = false;
2357
+ c.addEventListener('pointerdown', (e) => {
2358
+ if (e.pointerType === 'mouse') return; // mouse already handled
2359
+ c.setPointerCapture(e.pointerId);
2360
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
2361
+ if (pointers.size === 2) {
2362
+ pinchPrev = pinchInfo(pointers);
2363
+ this._mode = 'pinch';
2364
+ } else if (pointers.size === 1) {
2365
+ // Start long-press timer for context menu.
2366
+ const x = e.clientX, y = e.clientY;
2367
+ longPressTimer = setTimeout(() => {
2368
+ longPressTimer = null;
2369
+ const wp = this._s2w(x, y);
2370
+ this._onRightClick({ clientX: x, clientY: y, preventDefault() {} }, wp);
2371
+ }, 550);
2372
+ // Simulate a left mousedown for taps.
2373
+ this._pointerSynthesizing = true;
2374
+ c.dispatchEvent(new MouseEvent('mousedown', { clientX: x, clientY: y, button: 0, bubbles: true }));
2375
+ this._pointerSynthesizing = false;
2376
+ }
2377
+ });
2378
+ c.addEventListener('pointermove', (e) => {
2379
+ if (e.pointerType === 'mouse') return;
2380
+ if (!pointers.has(e.pointerId)) return;
2381
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
2382
+ if (pointers.size === 2 && pinchPrev) {
2383
+ const cur = pinchInfo(pointers);
2384
+ const zoomFactor = cur.dist / pinchPrev.dist;
2385
+ const before = this._s2w(cur.mid.x, cur.mid.y);
2386
+ this.cam.zoom = Math.max(0.2, Math.min(3.0, this.cam.zoom * zoomFactor));
2387
+ const after = this._s2w(cur.mid.x, cur.mid.y);
2388
+ this.cam.x += after.x - before.x; this.cam.y += after.y - before.y;
2389
+ const dpr = window.devicePixelRatio || 1;
2390
+ this.cam.x += (cur.mid.x - pinchPrev.mid.x) * dpr / this.cam.zoom;
2391
+ this.cam.y += (cur.mid.y - pinchPrev.mid.y) * dpr / this.cam.zoom;
2392
+ pinchPrev = cur;
2393
+ } else if (pointers.size === 1) {
2394
+ if (longPressTimer && Math.hypot(e.movementX || 0, e.movementY || 0) > 4) {
2395
+ clearTimeout(longPressTimer); longPressTimer = null;
2396
+ }
2397
+ this._pointerSynthesizing = true;
2398
+ c.dispatchEvent(new MouseEvent('mousemove', { clientX: e.clientX, clientY: e.clientY, button: 0, bubbles: true }));
2399
+ this._pointerSynthesizing = false;
2400
+ }
2401
+ });
2402
+ const endPointer = (e) => {
2403
+ if (e.pointerType === 'mouse') return;
2404
+ pointers.delete(e.pointerId);
2405
+ if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; }
2406
+ if (pointers.size < 2) { pinchPrev = null; if (this._mode === 'pinch') this._mode = 'idle'; }
2407
+ if (pointers.size === 0) {
2408
+ this._pointerSynthesizing = true;
2409
+ c.dispatchEvent(new MouseEvent('mouseup', { clientX: e.clientX, clientY: e.clientY, button: 0, bubbles: true }));
2410
+ this._pointerSynthesizing = false;
2411
+ }
2412
+ };
2413
+ c.addEventListener('pointerup', endPointer);
2414
+ c.addEventListener('pointercancel', endPointer);
2415
+
2416
+ c.addEventListener('wheel', (e) => {
2417
+ e.preventDefault();
2418
+ const isPinch = e.ctrlKey;
2419
+ if (isPinch || e.deltaMode === 1) {
2420
+ const before = this._s2w(e.clientX, e.clientY);
2421
+ this.cam.zoom = Math.max(0.2, Math.min(3.0,
2422
+ this.cam.zoom * Math.exp(-e.deltaY * (isPinch ? 0.012 : 0.05))));
2423
+ const after = this._s2w(e.clientX, e.clientY);
2424
+ this.cam.x += after.x - before.x; this.cam.y += after.y - before.y;
2425
+ return;
2426
+ }
2427
+ // Trackpad two-finger pan.
2428
+ const dpr = window.devicePixelRatio || 1;
2429
+ this.cam.x -= e.deltaX * dpr / this.cam.zoom;
2430
+ this.cam.y -= e.deltaY * dpr / this.cam.zoom;
2431
+ }, { passive: false });
2432
+
2433
+ c.addEventListener('dblclick', (e) => {
2434
+ const wp = this._s2w(e.clientX, e.clientY);
2435
+ // Frame header → drill into subflow.
2436
+ const fh = this._hitFrameHeader(wp.x, wp.y);
2437
+ if (fh !== -1) { this.enterSubflow(this.frames[fh].id); return; }
2438
+ // Note → edit text.
2439
+ const nh = this._hitNote(wp.x, wp.y);
2440
+ if (nh !== -1) { this._startEditingNote(nh); return; }
2441
+ const nid = this.w.hitTestNode(wp.x, wp.y);
2442
+ if (nid !== -1) {
2443
+ if (this.options.dblclickEditsTitle !== false) this._startEditingTitle(nid);
2444
+ this._emit('node:dblclick', nid);
2445
+ return;
2446
+ }
2447
+ const eid = this._hitTestEdge(wp.x, wp.y, 6 / this.cam.zoom);
2448
+ if (eid !== -1) { this._emit('edge:dblclick', eid); return; }
2449
+ this._emit('canvas:dblclick', wp);
2450
+ });
2451
+ }
2452
+
2453
+ _startPan(e) {
2454
+ this._mode = 'pan';
2455
+ this._dragStart = { sx: e.clientX, sy: e.clientY };
2456
+ this._panVel.x = 0; this._panVel.y = 0; this._panVel.lastTs = performance.now();
2457
+ this.canvas.style.cursor = 'grabbing';
2458
+ }
2459
+
2460
+ _attachKeyboard() {
2461
+ const handler = (e) => {
2462
+ // Only handle when our container has focus (or the canvas).
2463
+ const inOwn = this.container.contains(document.activeElement) || document.activeElement === document.body;
2464
+ if (!inOwn) return;
2465
+ // Don't steal typing in inputs/textareas.
2466
+ const t = document.activeElement;
2467
+ if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return;
2468
+ const ctrl = e.ctrlKey || e.metaKey;
2469
+
2470
+ if (e.code === 'Delete' || e.code === 'Backspace') {
2471
+ if (this.deleteSelection() > 0) e.preventDefault();
2472
+ return;
2473
+ }
2474
+ if (ctrl && e.code === 'KeyZ' && !e.shiftKey) { this.undo(); e.preventDefault(); return; }
2475
+ if (ctrl && (e.code === 'KeyY' || (e.code === 'KeyZ' && e.shiftKey))) { this.redo(); e.preventDefault(); return; }
2476
+ if (ctrl && e.code === 'KeyA') { this.selectAll(); e.preventDefault(); return; }
2477
+ if (ctrl && e.code === 'KeyD') { this.duplicateSelection(); e.preventDefault(); return; }
2478
+ if (ctrl && e.code === 'KeyC') { this._copy(); e.preventDefault(); return; }
2479
+ if (ctrl && e.code === 'KeyV') { this._paste(); e.preventDefault(); return; }
2480
+ if (ctrl && e.code === 'KeyG') { this.groupSelection(); e.preventDefault(); return; }
2481
+ if (ctrl && e.code === 'BracketRight') { this.bringToFront(); e.preventDefault(); return; }
2482
+ if (ctrl && e.code === 'BracketLeft') { this.sendToBack(); e.preventDefault(); return; }
2483
+ if (ctrl && e.code === 'KeyK') { this.openCommandPalette(); e.preventDefault(); return; }
2484
+ if (ctrl && e.code === 'KeyF' && this.options.search) { this.openSearch(); e.preventDefault(); return; }
2485
+ if (ctrl && e.code === 'KeyT') { this.toggleTheme(); e.preventDefault(); return; }
2486
+ if (ctrl && e.code === 'KeyM') { this.setMinimap(!this.options.minimap); e.preventDefault(); return; }
2487
+ if (ctrl && e.code === 'KeyE') { this.setAllEdgesAnimated(this.animatedEdges.size === 0); e.preventDefault(); return; }
2488
+ if (!ctrl && e.code === 'Digit0') { this.fitView(); e.preventDefault(); return; }
2489
+ if (!ctrl && e.code === 'KeyL' && !this._editingTitle && this._editingNote === -1) { this.runAutoLayout(); e.preventDefault(); return; }
2490
+ if (e.code === 'F5' && !e.shiftKey) { this.run(); e.preventDefault(); return; }
2491
+ if (e.code === 'F5' && e.shiftKey) { this.stop(); e.preventDefault(); return; }
2492
+ if (e.code === 'Tab' && !ctrl) {
2493
+ const n = this.w.nodeCount_();
2494
+ if (n === 0) return;
2495
+ let cur = this.getSelection()[0] ?? -1;
2496
+ let next = e.shiftKey ? cur - 1 : cur + 1;
2497
+ if (cur === -1) next = 0;
2498
+ if (next < 0) next = n - 1;
2499
+ if (next >= n) next = 0;
2500
+ this.clearSelection(); this.w.setSelected(next, 1);
2501
+ this.panTo(this.V.posX[next], this.V.posY[next]);
2502
+ e.preventDefault();
2503
+ return;
2504
+ }
2505
+ if (!ctrl && /^Digit[1-9]$/.test(e.code)) {
2506
+ const slot = parseInt(e.code.slice(5), 10);
2507
+ if (e.altKey) this.jumpBookmark(slot);
2508
+ else this.setBookmark(slot);
2509
+ e.preventDefault();
2510
+ return;
2511
+ }
2512
+ if (e.code === 'Escape') {
2513
+ // Cancel an in-progress edge or exit subflow before falling through.
2514
+ if (this._mode === 'connecting') {
2515
+ this._mode = 'idle'; this._edgeStart = null; this._edgeCursor = null;
2516
+ this.canvas.style.cursor = '';
2517
+ return;
2518
+ }
2519
+ if (this._focusFrame !== -1) { this.exitSubflow(); return; }
2520
+ this.clearSelection(); this._hideMenu(); return;
2521
+ }
2522
+ if (e.code.startsWith('Arrow')) {
2523
+ const d = e.shiftKey ? 1 : 10;
2524
+ const dx = e.code === 'ArrowLeft' ? -d : e.code === 'ArrowRight' ? d : 0;
2525
+ const dy = e.code === 'ArrowUp' ? -d : e.code === 'ArrowDown' ? d : 0;
2526
+ if (this.getSelection().length > 0) {
2527
+ this.w.moveSelectedBy(dx, dy);
2528
+ if (this._nudgeTimer) clearTimeout(this._nudgeTimer);
2529
+ this._nudgeTimer = setTimeout(() => { this.w.snapshot(); this._emit('change'); }, 400);
2530
+ e.preventDefault();
2531
+ }
2532
+ }
2533
+ };
2534
+ window.addEventListener('keydown', handler);
2535
+ this._keyHandler = handler;
2536
+ }
2537
+
2538
+ // ── Clipboard ─────────────────────────────────────────────────────────
2539
+ _copy() {
2540
+ const sel = this.getSelection();
2541
+ if (sel.length === 0) return;
2542
+ const selSet = new Set(sel);
2543
+ const nodes = sel.map((i) => ({
2544
+ origId: i,
2545
+ kind: this.kinds[this.V.kind[i]].name,
2546
+ x: this.V.posX[i], y: this.V.posY[i], w: this.V.sizeW[i], h: this.V.sizeH[i],
2547
+ title: this.titles.get(i), color: this.colors.get(i),
2548
+ description: this.descriptions.get(i), tags: this.tags.get(i),
2549
+ status: this.status.get(i), progress: this.progress.get(i),
2550
+ }));
2551
+ const edges = [];
2552
+ for (let e = 0; e < this.w.edgeCount_(); e++) {
2553
+ if (selSet.has(this.V.edgeFromN[e]) && selSet.has(this.V.edgeToN[e])) {
2554
+ edges.push({ from: this.V.edgeFromN[e], fp: this.V.edgeFromP[e],
2555
+ to: this.V.edgeToN[e], tp: this.V.edgeToP[e],
2556
+ label: this.edgeLabels.get(e) });
2557
+ }
2558
+ }
2559
+ let minX = Infinity, minY = Infinity;
2560
+ for (const n of nodes) { if (n.x < minX) minX = n.x; if (n.y < minY) minY = n.y; }
2561
+ this._clipboard = { nodes, edges, anchor: { x: minX, y: minY } };
2562
+ }
2563
+ _paste() {
2564
+ if (!this._clipboard) return;
2565
+ const c = this._clipboard;
2566
+ const px = -this.cam.x, py = -this.cam.y;
2567
+ const dx = px - c.anchor.x, dy = py - c.anchor.y;
2568
+ const idMap = new Map();
2569
+ this.clearSelection();
2570
+ for (const n of c.nodes) {
2571
+ const id = this.addNode({
2572
+ kind: n.kind, x: n.x + dx, y: n.y + dy, w: n.w, h: n.h,
2573
+ title: n.title, color: n.color, description: n.description,
2574
+ tags: n.tags, status: n.status, progress: n.progress,
2575
+ });
2576
+ idMap.set(n.origId, id);
2577
+ this.w.setSelected(id, 1);
2578
+ }
2579
+ for (const e of c.edges) {
2580
+ const a = idMap.get(e.from), b = idMap.get(e.to);
2581
+ if (a !== undefined && b !== undefined) this.addEdge({ from: a, fp: e.fp, to: b, tp: e.tp, label: e.label });
2582
+ }
2583
+ this._clipboard = { ...c, anchor: { x: c.anchor.x - 24, y: c.anchor.y - 24 } };
2584
+ this.w.snapshot();
2585
+ this._emit('change');
2586
+ }
2587
+
2588
+ // ── Resize handles ────────────────────────────────────────────────────
2589
+ // ── Frame hit-tests + resize ──────────────────────────────────────────
2590
+ _hitFrameHeader(qx, qy) {
2591
+ for (let i = this.frames.length - 1; i >= 0; i--) {
2592
+ const f = this.frames[i];
2593
+ if (qx < f.x || qx > f.x + f.w) continue;
2594
+ if (qy < f.y || qy > f.y + 26) continue;
2595
+ return i;
2596
+ }
2597
+ return -1;
2598
+ }
2599
+ _hitFrameCorner(qx, qy) {
2600
+ const tol = 14 / this.cam.zoom;
2601
+ for (let i = this.frames.length - 1; i >= 0; i--) {
2602
+ const f = this.frames[i];
2603
+ const corners = {
2604
+ tl: { x: f.x, y: f.y }, tr: { x: f.x + f.w, y: f.y },
2605
+ bl: { x: f.x, y: f.y + f.h }, br: { x: f.x + f.w, y: f.y + f.h },
2606
+ };
2607
+ for (const c of ['br', 'bl', 'tr', 'tl']) {
2608
+ const p = corners[c];
2609
+ if (Math.abs(qx - p.x) < tol && Math.abs(qy - p.y) < tol) return { idx: i, corner: c };
2610
+ }
2611
+ }
2612
+ return null;
2613
+ }
2614
+ _applyFrameResize(idx, corner, dx, dy) {
2615
+ const f = this.frames[idx], MIN_W = 120, MIN_H = 80;
2616
+ if (corner === 'br') { f.w += dx; f.h += dy; }
2617
+ if (corner === 'bl') { f.x += dx; f.w -= dx; f.h += dy; }
2618
+ if (corner === 'tr') { f.w += dx; f.y += dy; f.h -= dy; }
2619
+ if (corner === 'tl') { f.x += dx; f.w -= dx; f.y += dy; f.h -= dy; }
2620
+ if (f.w < MIN_W) { if (corner === 'bl' || corner === 'tl') f.x -= (MIN_W - f.w); f.w = MIN_W; }
2621
+ if (f.h < MIN_H) { if (corner === 'tl' || corner === 'tr') f.y -= (MIN_H - f.h); f.h = MIN_H; }
2622
+ }
2623
+ _hitNote(qx, qy) {
2624
+ for (let i = this.notes.length - 1; i >= 0; i--) {
2625
+ const n = this.notes[i];
2626
+ if (qx >= n.x && qx <= n.x + n.w && qy >= n.y && qy <= n.y + n.h) return i;
2627
+ }
2628
+ return -1;
2629
+ }
2630
+ _hitTaskCheckbox(qx, qy) {
2631
+ const n = this.w.nodeCount_();
2632
+ for (let i = n - 1; i >= 0; i--) {
2633
+ const list = this.tasks.get(i);
2634
+ if (!list || !list.length) continue;
2635
+ const cx = this.V.posX[i], cy = this.V.posY[i];
2636
+ const hw = this.V.sizeW[i] * 0.5, hh = this.V.sizeH[i] * 0.5;
2637
+ if (qx < cx - hw || qx > cx + hw || qy < cy - hh || qy > cy + hh) continue;
2638
+ // Rough layout: each row 14 world-units tall, starting ~30 from top.
2639
+ const innerLeft = cx - hw + 8;
2640
+ let curY = cy - hh + 32;
2641
+ if (this.descriptions.get(i)) curY += 30;
2642
+ const rowH = 14, boxS = 10;
2643
+ for (let t = 0; t < list.length; t++) {
2644
+ if (qx >= innerLeft && qx <= innerLeft + boxS && qy >= curY && qy <= curY + boxS) {
2645
+ return { nodeId: i, taskIdx: t };
2646
+ }
2647
+ curY += rowH;
2648
+ if (curY > cy + hh - 6) break;
2649
+ }
2650
+ }
2651
+ return null;
2652
+ }
2653
+
2654
+ _hitHandle(qx, qy) {
2655
+ // Only when exactly 1 node selected.
2656
+ const sel = this.getSelection();
2657
+ if (sel.length !== 1) return null;
2658
+ const id = sel[0];
2659
+ const cx = this.V.posX[id], cy = this.V.posY[id];
2660
+ const hw = this.V.sizeW[id] * 0.5, hh = this.V.sizeH[id] * 0.5;
2661
+ const tol = 6 / this.cam.zoom;
2662
+ const pts = {
2663
+ tl: {x: cx-hw, y: cy-hh}, t: {x: cx, y: cy-hh}, tr: {x: cx+hw, y: cy-hh},
2664
+ r: {x: cx+hw, y: cy},
2665
+ br: {x: cx+hw, y: cy+hh}, b: {x: cx, y: cy+hh}, bl: {x: cx-hw, y: cy+hh},
2666
+ l: {x: cx-hw, y: cy},
2667
+ };
2668
+ for (const c of HANDLE_CORNERS) {
2669
+ const p = pts[c];
2670
+ if (Math.abs(qx - p.x) < tol && Math.abs(qy - p.y) < tol) return { nodeId: id, corner: c };
2671
+ }
2672
+ return null;
2673
+ }
2674
+ _applyResize(corner, dx, dy) {
2675
+ const id = this._resizingHandle.nodeId;
2676
+ const MIN = 60;
2677
+ let nw = this.V.sizeW[id], nh = this.V.sizeH[id];
2678
+ let dcx = 0, dcy = 0;
2679
+ if (HANDLE_LEFTS.has(corner)) { nw -= dx; dcx = dx * 0.5; }
2680
+ if (HANDLE_RIGHTS.has(corner)) { nw += dx; dcx = dx * 0.5; }
2681
+ if (HANDLE_TOPS.has(corner)) { nh -= dy; dcy = dy * 0.5; }
2682
+ if (HANDLE_BOTS.has(corner)) { nh += dy; dcy = dy * 0.5; }
2683
+ if (nw < MIN) { nw = MIN; dcx = 0; }
2684
+ if (nh < MIN) { nh = MIN; dcy = 0; }
2685
+ this.V.sizeW[id] = nw; this.V.sizeH[id] = nh;
2686
+ this.V.posX[id] += dcx; this.V.posY[id] += dcy;
2687
+ }
2688
+
2689
+ // ── Alignment guides (during drag) ────────────────────────────────────
2690
+ _computeAlignSnap(deltaX, deltaY) {
2691
+ const ALIGN_EPS = 6;
2692
+ const n = this.w.nodeCount_();
2693
+ const xs = [], ys = [];
2694
+ for (let i = 0; i < n; i++) {
2695
+ if (this.V.selected[i]) continue;
2696
+ const cx = this.V.posX[i], cy = this.V.posY[i];
2697
+ const hw = this.V.sizeW[i] * 0.5, hh = this.V.sizeH[i] * 0.5;
2698
+ xs.push(cx, cx - hw, cx + hw);
2699
+ ys.push(cy, cy - hh, cy + hh);
2700
+ }
2701
+ let bestDX = 0, bestDY = 0, foundX = null, foundY = null;
2702
+ let bestX = ALIGN_EPS + 1, bestY = ALIGN_EPS + 1;
2703
+ for (let i = 0; i < n; i++) {
2704
+ if (!this.V.selected[i]) continue;
2705
+ const tcx = this.V.posX[i] + deltaX, tcy = this.V.posY[i] + deltaY;
2706
+ for (const x of xs) {
2707
+ const d = tcx - x;
2708
+ if (Math.abs(d) < bestX) { bestX = Math.abs(d); bestDX = -d; foundX = x; }
2709
+ }
2710
+ for (const y of ys) {
2711
+ const d = tcy - y;
2712
+ if (Math.abs(d) < bestY) { bestY = Math.abs(d); bestDY = -d; foundY = y; }
2713
+ }
2714
+ }
2715
+ return { dx: bestX <= ALIGN_EPS ? bestDX : 0, dy: bestY <= ALIGN_EPS ? bestDY : 0,
2716
+ guideX: foundX, guideY: foundY };
2717
+ }
2718
+
2719
+ // ── Edge hit-test (JS-side, samples bezier or polyline) ──────────────
2720
+ _hitTestEdge(qx, qy, tolWorld) {
2721
+ const tol2 = tolWorld * tolWorld;
2722
+ const m = this.w.edgeCount_();
2723
+ let bestIdx = -1, bestDist = tol2;
2724
+ for (let i = 0; i < m; i++) {
2725
+ const ap = this._portWorld(this.V.edgeFromN[i], 1, this.V.edgeFromP[i]);
2726
+ const bp = this._portWorld(this.V.edgeToN[i], 0, this.V.edgeToP[i]);
2727
+ const minx = Math.min(ap.x, bp.x) - tolWorld, maxx = Math.max(ap.x, bp.x) + tolWorld;
2728
+ const miny = Math.min(ap.y, bp.y) - tolWorld - 80, maxy = Math.max(ap.y, bp.y) + tolWorld + 80;
2729
+ if (qx < minx || qx > maxx || qy < miny || qy > maxy) continue;
2730
+ if (this.options.edgeStyle === 'orthogonal') {
2731
+ const path = this._orthoPath(ap, bp);
2732
+ for (let s = 0; s < path.length - 1; s++) {
2733
+ const d2 = distSeg2(qx, qy, path[s].x, path[s].y, path[s+1].x, path[s+1].y);
2734
+ if (d2 < bestDist) { bestDist = d2; bestIdx = i; }
2735
+ }
2736
+ } else {
2737
+ const dxe = bp.x - ap.x, dye = bp.y - ap.y;
2738
+ const off = Math.max(50, Math.abs(dxe) * 0.5 + Math.abs(dye) * 0.4);
2739
+ for (let s = 0; s <= 16; s++) {
2740
+ const t = s / 16;
2741
+ const pt = bezPt$1(t, ap.x, ap.y, ap.x + off, ap.y, bp.x - off, bp.y, bp.x, bp.y);
2742
+ const ddx = pt.x - qx, ddy = pt.y - qy;
2743
+ const d2 = ddx*ddx + ddy*ddy;
2744
+ if (d2 < bestDist) { bestDist = d2; bestIdx = i; }
2745
+ }
2746
+ }
2747
+ }
2748
+ return bestIdx;
2749
+ }
2750
+
2751
+ // ── Right-click menu ──────────────────────────────────────────────────
2752
+ _onRightClick(e, wp) {
2753
+ if (!this.options.contextMenu) return;
2754
+ const nid = this.w.hitTestNode(wp.x, wp.y);
2755
+ const eid = nid === -1 ? this._hitTestEdge(wp.x, wp.y, 6 / this.cam.zoom) : -1;
2756
+ let items;
2757
+ if (nid !== -1) {
2758
+ if (this.V.selected[nid] === 0) { this.w.clearSelection(); this.w.setSelected(nid, 1); }
2759
+ items = [
2760
+ { label: 'Duplicate', kbd: 'Ctrl+D', fn: () => this.duplicateSelection() },
2761
+ { label: 'Delete', kbd: 'Del', danger: true, fn: () => this.deleteSelection() },
2762
+ { sep: true },
2763
+ { label: 'Deselect', kbd: 'Esc', fn: () => this.clearSelection() },
2764
+ ];
2765
+ } else if (eid !== -1) {
2766
+ const a = this.V.edgeFromN[eid], b = this.V.edgeToN[eid];
2767
+ items = [
2768
+ { label: 'Select endpoints', fn: () => { this.clearSelection(); this.w.setSelected(a,1); this.w.setSelected(b,1); this._emit('select', this.getSelection()); } },
2769
+ { label: 'Set label…', fn: () => { const v = prompt('Edge label', this.edgeLabels.get(eid) || ''); if (v !== null) this.setEdgeLabel(eid, v); } },
2770
+ { sep: true },
2771
+ { label: 'Delete edge', danger: true, fn: () => { this.w.deleteEdge(eid); this.edgeLabels.delete(eid); this.w.snapshot(); this._emit('change'); } },
2772
+ ];
2773
+ } else {
2774
+ items = [
2775
+ { label: 'Add Process node here', fn: () => this.addNode({ kind: 'process', x: wp.x, y: wp.y }) },
2776
+ { sep: true },
2777
+ { label: 'Select all', kbd: 'Ctrl+A', fn: () => this.selectAll() },
2778
+ { label: 'Auto-layout', fn: () => this.runAutoLayout() },
2779
+ { label: 'Fit view', fn: () => this.fitView() },
2780
+ ];
2781
+ }
2782
+ this._showMenu(e.clientX, e.clientY, items);
2783
+ }
2784
+ // ── Inline editors (title + note) ─────────────────────────────────────
2785
+ _startEditingTitle(nodeId) {
2786
+ this._editingTitle = nodeId;
2787
+ if (!this._editingTitleEl) {
2788
+ const el = document.createElement('input');
2789
+ el.type = 'text';
2790
+ Object.assign(el.style, {
2791
+ position: 'absolute', background: '#161b27', color: '#e6edf3',
2792
+ border: '1px solid #f0b93a', borderRadius: '4px',
2793
+ fontFamily: 'Inter, ui-sans-serif', fontSize: '12px', fontWeight: '600',
2794
+ padding: '4px 8px', outline: 'none', zIndex: '300',
2795
+ boxShadow: '0 4px 12px rgba(0,0,0,0.35)',
2796
+ });
2797
+ this.container.appendChild(el);
2798
+ el.addEventListener('blur', () => this._stopEditingTitle());
2799
+ el.addEventListener('keydown', (e) => { if (e.code === 'Enter' || e.code === 'Escape') el.blur(); });
2800
+ this._editingTitleEl = el;
2801
+ }
2802
+ this._positionTitleEditor();
2803
+ this._editingTitleEl.value = this.titles.get(nodeId) || '';
2804
+ this._editingTitleEl.placeholder = `${this.kinds[this.V.kind[nodeId]].name} #${nodeId}`;
2805
+ this._editingTitleEl.style.display = 'block';
2806
+ setTimeout(() => { this._editingTitleEl.focus(); this._editingTitleEl.select(); }, 20);
2807
+ }
2808
+ _positionTitleEditor() {
2809
+ if (this._editingTitle === -1 || !this._editingTitleEl) return;
2810
+ const i = this._editingTitle;
2811
+ const cx = this.V.posX[i], cy = this.V.posY[i];
2812
+ const hw = this.V.sizeW[i] * 0.5, hh = this.V.sizeH[i] * 0.5;
2813
+ const tl = this._w2s(cx - hw, cy - hh);
2814
+ const dpr = window.devicePixelRatio || 1;
2815
+ this._editingTitleEl.style.left = (tl.x / dpr) + 'px';
2816
+ this._editingTitleEl.style.top = (tl.y / dpr - 32) + 'px';
2817
+ this._editingTitleEl.style.width = Math.max(120, this.V.sizeW[i] * this.cam.zoom / dpr) + 'px';
2818
+ }
2819
+ _stopEditingTitle() {
2820
+ if (this._editingTitle === -1) return;
2821
+ const v = this._editingTitleEl.value.trim();
2822
+ if (v) this.titles.set(this._editingTitle, v);
2823
+ else this.titles.delete(this._editingTitle);
2824
+ this._editingTitleEl.style.display = 'none';
2825
+ this._editingTitle = -1;
2826
+ this._emit('change');
2827
+ }
2828
+ _startEditingNote(idx) {
2829
+ this._editingNote = idx;
2830
+ if (!this._editingNoteEl) {
2831
+ const el = document.createElement('textarea');
2832
+ el.spellcheck = false;
2833
+ Object.assign(el.style, {
2834
+ position: 'absolute', resize: 'none', outline: 'none',
2835
+ border: '1px solid #f0b93a', borderRadius: '4px',
2836
+ padding: '8px 10px', fontFamily: 'Inter, ui-sans-serif',
2837
+ fontSize: '12px', lineHeight: '16px', zIndex: '300',
2838
+ });
2839
+ this.container.appendChild(el);
2840
+ el.addEventListener('blur', () => this._stopEditingNote());
2841
+ el.addEventListener('keydown', (e) => { if (e.code === 'Escape') el.blur(); });
2842
+ this._editingNoteEl = el;
2843
+ }
2844
+ this._positionNoteEditor();
2845
+ const n = this.notes[idx];
2846
+ this._editingNoteEl.style.background = n.color.fill;
2847
+ this._editingNoteEl.style.color = n.color.text;
2848
+ this._editingNoteEl.value = n.text;
2849
+ this._editingNoteEl.style.display = 'block';
2850
+ setTimeout(() => this._editingNoteEl.focus(), 20);
2851
+ }
2852
+ _positionNoteEditor() {
2853
+ if (this._editingNote === -1 || !this._editingNoteEl) return;
2854
+ const n = this.notes[this._editingNote];
2855
+ const tl = this._w2s(n.x, n.y);
2856
+ const dpr = window.devicePixelRatio || 1;
2857
+ this._editingNoteEl.style.left = (tl.x / dpr) + 'px';
2858
+ this._editingNoteEl.style.top = (tl.y / dpr) + 'px';
2859
+ this._editingNoteEl.style.width = (n.w * this.cam.zoom / dpr) + 'px';
2860
+ this._editingNoteEl.style.height = (n.h * this.cam.zoom / dpr) + 'px';
2861
+ }
2862
+ _stopEditingNote() {
2863
+ if (this._editingNote === -1) return;
2864
+ this.notes[this._editingNote].text = this._editingNoteEl.value;
2865
+ this._editingNoteEl.style.display = 'none';
2866
+ this._editingNote = -1;
2867
+ this._emit('change');
2868
+ }
2869
+
2870
+ _showMenu(x, y, items) {
2871
+ this._hideMenu();
2872
+ const m = document.createElement('div');
2873
+ m.style.cssText = 'position:fixed;min-width:200px;padding:4px;background:#161b27;border:1px solid rgba(255,255,255,0.16);border-radius:8px;box-shadow:0 12px 32px rgba(0,0,0,0.45);z-index:99999;color:#e6edf3;font:13px/1.45 ui-sans-serif, system-ui, sans-serif;';
2874
+ for (const it of items) {
2875
+ if (it.sep) { const d = document.createElement('div'); d.style.cssText = 'height:1px;background:rgba(255,255,255,0.08);margin:4px;'; m.appendChild(d); continue; }
2876
+ const d = document.createElement('div');
2877
+ d.style.cssText = `padding:6px 10px;border-radius:6px;cursor:pointer;display:flex;justify-content:space-between;gap:12px;${it.danger ? 'color:#ffb4a4;' : ''}`;
2878
+ d.innerHTML = `<span>${escapeHtml(it.label)}</span>${it.kbd ? `<span style="color:#5a6577;font-family:ui-monospace,Consolas,monospace;font-size:11px;">${escapeHtml(it.kbd)}</span>` : ''}`;
2879
+ d.onmouseenter = () => d.style.background = it.danger ? 'rgba(232,70,43,0.18)' : 'rgba(255,255,255,0.04)';
2880
+ d.onmouseleave = () => d.style.background = '';
2881
+ d.onclick = () => { it.fn(); this._hideMenu(); };
2882
+ m.appendChild(d);
2883
+ }
2884
+ m.style.left = Math.min(x, window.innerWidth - 220) + 'px';
2885
+ m.style.top = Math.min(y, window.innerHeight - 280) + 'px';
2886
+ document.body.appendChild(m);
2887
+ this._menuEl = m;
2888
+ setTimeout(() => {
2889
+ const off = (ev) => { if (!m.contains(ev.target)) { this._hideMenu(); document.removeEventListener('mousedown', off, true); } };
2890
+ document.addEventListener('mousedown', off, true);
2891
+ }, 0);
2892
+ }
2893
+ _hideMenu() { if (this._menuEl) { this._menuEl.remove(); this._menuEl = null; } }
2894
+
2895
+ // ── Render loop ───────────────────────────────────────────────────────
2896
+ _loop() { this._render(); this._raf = requestAnimationFrame(() => this._loop()); }
2897
+
2898
+ _render() {
2899
+ // Pan inertia.
2900
+ if (this._mode !== 'pan') {
2901
+ const v2 = this._panVel.x * this._panVel.x + this._panVel.y * this._panVel.y;
2902
+ if (v2 < 16) { this._panVel.x = 0; this._panVel.y = 0; }
2903
+ else {
2904
+ this.cam.x += this._panVel.x * (1/60); this.cam.y += this._panVel.y * (1/60);
2905
+ this._panVel.x *= 0.91; this._panVel.y *= 0.91;
2906
+ }
2907
+ }
2908
+
2909
+ const ctx = this.ctx;
2910
+ if (this._hooks?.beforeRender?.length) this._runHook('beforeRender', ctx);
2911
+ if (this._gl) {
2912
+ ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
2913
+ this._gl.render();
2914
+ } else {
2915
+ ctx.fillStyle = this.options.background;
2916
+ ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
2917
+ }
2918
+ this._drawGrid();
2919
+ this._drawFrames();
2920
+ this._drawNotes();
2921
+ this._refreshFocus();
2922
+ this._refreshPreview();
2923
+ this._positionTitleEditor();
2924
+ this._positionNoteEditor();
2925
+ this._drawDying();
2926
+ this._drawWaypoints();
2927
+ this._drawRemoteCursors();
2928
+ this._drawValueBubbles();
2929
+ if (this.options.minimap) this._drawMinimap();
2930
+
2931
+ const n = this.w.nodeCount_(), m = this.w.edgeCount_();
2932
+ // Edges first.
2933
+ this._edgePhase = (this._edgePhase + (this.options.edgeFlowSpeed || 60) * (1 / 60)) % 1000;
2934
+ for (let i = 0; i < m; i++) {
2935
+ const a = this.V.edgeFromN[i], b = this.V.edgeToN[i];
2936
+ const ap = this._portWorld(a, 1, this.V.edgeFromP[i]);
2937
+ const bp = this._portWorld(b, 0, this.V.edgeToP[i]);
2938
+ let dim = false;
2939
+ if (this._focusedSet && (!this._focusedSet.has(a) || !this._focusedSet.has(b))) dim = true;
2940
+ if (this._hoveredEdge !== -1 && this._hoveredEdge !== i) dim = true;
2941
+ if (this._focusFrame !== -1 && !(this._isInsideFocusFrame(a) && this._isInsideFocusFrame(b))) dim = true;
2942
+ if (dim) ctx.globalAlpha = 0.18;
2943
+ this._currentEdgeIdx = i;
2944
+ this._drawEdge(ap, bp,
2945
+ this.colors.get(a) || this.kinds[this.V.kind[a]].color,
2946
+ this.colors.get(b) || this.kinds[this.V.kind[b]].color,
2947
+ this.V.edgeSel[i] !== 0, false, this.edgeLabels.get(i));
2948
+ ctx.globalAlpha = 1;
2949
+ }
2950
+ this._currentEdgeIdx = undefined;
2951
+ // Edge in-flight preview.
2952
+ // Show rejection toast briefly (auto-clear after expiry).
2953
+ if (this._flashReject && performance.now() - this._flashReject.t0 >= 1400) {
2954
+ this._flashReject = null;
2955
+ }
2956
+ if (this._flashReject && performance.now() - this._flashReject.t0 < 1400) {
2957
+ const r = this._flashReject;
2958
+ const sp = this._w2s(r.x, r.y);
2959
+ const ctx = this.ctx;
2960
+ const alpha = 1 - (performance.now() - r.t0) / 1400;
2961
+ ctx.save();
2962
+ ctx.globalAlpha = alpha;
2963
+ ctx.fillStyle = '#e8462b'; ctx.font = '600 12px Inter, ui-sans-serif';
2964
+ const tw = ctx.measureText(r.msg).width;
2965
+ const bx = sp.x - tw / 2 - 8, by = sp.y - 36, bw = tw + 16, bh = 22;
2966
+ ctx.fillStyle = '#1a0e0e';
2967
+ this._roundRect(bx, by, bw, bh, 4); ctx.fill();
2968
+ ctx.strokeStyle = '#e8462b'; ctx.lineWidth = 1.4;
2969
+ this._roundRect(bx, by, bw, bh, 4); ctx.stroke();
2970
+ ctx.fillStyle = '#e8462b';
2971
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
2972
+ ctx.fillText(r.msg, sp.x, by + bh / 2);
2973
+ ctx.restore();
2974
+ }
2975
+ if (this._mode === 'connecting' && this._edgeStart && this._edgeCursor) {
2976
+ const ap = this._portWorld(this._edgeStart.nodeId, 1, this._edgeStart.idx);
2977
+ this._drawEdge(ap, this._edgeCursor, '#8b95a7', '#8b95a7', false, true);
2978
+ }
2979
+ const halfW = this.canvas.width / (2 * this.cam.zoom);
2980
+ const halfH = this.canvas.height / (2 * this.cam.zoom);
2981
+ const viewMinX = -this.cam.x - halfW - 80, viewMaxX = -this.cam.x + halfW + 80;
2982
+ const viewMinY = -this.cam.y - halfH - 80, viewMaxY = -this.cam.y + halfH + 80;
2983
+ let order;
2984
+ if (n > 300) {
2985
+ const c = this.w.queryRect(viewMinX, viewMinY, viewMaxX, viewMaxY);
2986
+ order = Array.from(this.V.queryRes.subarray(0, c));
2987
+ } else {
2988
+ order = [];
2989
+ for (let i = 0; i < n; i++) order.push(i);
2990
+ }
2991
+ // Sort by z-order so bringToFront'd nodes paint last.
2992
+ order.sort((a, b) => {
2993
+ const za = this.zOrder.get(a) || 0, zb = this.zOrder.get(b) || 0;
2994
+ return za === zb ? a - b : za - zb;
2995
+ });
2996
+ for (const i of order) {
2997
+ const hw = this.V.sizeW[i] * 0.5, hh = this.V.sizeH[i] * 0.5;
2998
+ if (this.V.posX[i] + hw < viewMinX || this.V.posX[i] - hw > viewMaxX) continue;
2999
+ if (this.V.posY[i] + hh < viewMinY || this.V.posY[i] - hh > viewMaxY) continue;
3000
+ // Skip canvas render for HTML-overlay kinds (DOM handled separately).
3001
+ if (this.kinds[this.V.kind[i]].html) continue;
3002
+ if (this._nodeHiddenByCollapse(i)) continue;
3003
+ // Subflow / hovered-edge / focusedSet / reachable dimming.
3004
+ let dim = false;
3005
+ if (this._focusedSet && !this._focusedSet.has(i)) dim = true;
3006
+ if (this._reachableSet && !this._reachableSet.has(i)) dim = true;
3007
+ if (this._hoveredEdge !== -1) {
3008
+ const a = this.V.edgeFromN[this._hoveredEdge], b = this.V.edgeToN[this._hoveredEdge];
3009
+ if (i !== a && i !== b) dim = true;
3010
+ }
3011
+ if (this._focusFrame !== -1 && !this._isInsideFocusFrame(i)) dim = true;
3012
+ if (dim) ctx.globalAlpha = 0.22;
3013
+ // Pop-in animation.
3014
+ const popped = this._openPopAnim(i);
3015
+ if (this._gl) this._drawNodeOverlay(i); // GL handled body+border; only paint text/badges/ports/etc
3016
+ else this._drawNode(i);
3017
+ if (popped) ctx.restore();
3018
+ if (dim) ctx.globalAlpha = 1;
3019
+ }
3020
+ this._syncHTMLOverlays();
3021
+ this._drawMultiSelectBBox();
3022
+ this._drawMarquee();
3023
+ this._drawLasso();
3024
+ this._drawAlignGuides();
3025
+ this._drawResizeHandles();
3026
+ this._drawFrameHandles();
3027
+ }
3028
+
3029
+ _openPopAnim(i) {
3030
+ const at = this._nodeAddedAt.get(i);
3031
+ if (at === undefined) return false;
3032
+ const t = (performance.now() - at) / 280;
3033
+ if (t >= 1) { this._nodeAddedAt.delete(i); return false; }
3034
+ const e = 1 - Math.pow(1 - t, 3);
3035
+ const sp = this._w2s(this.V.posX[i], this.V.posY[i]);
3036
+ this.ctx.save();
3037
+ this.ctx.globalAlpha = e;
3038
+ this.ctx.translate(sp.x, sp.y);
3039
+ this.ctx.scale(0.85 + e * 0.15, 0.85 + e * 0.15);
3040
+ this.ctx.translate(-sp.x, -sp.y);
3041
+ return true;
3042
+ }
3043
+
3044
+ _drawDying() {
3045
+ const now = performance.now();
3046
+ for (let i = this._dyingEdges.length - 1; i >= 0; i--) {
3047
+ const d = this._dyingEdges[i];
3048
+ const t = (now - d.t0) / 220;
3049
+ if (t >= 1) { this._dyingEdges.splice(i, 1); continue; }
3050
+ this.ctx.save();
3051
+ this.ctx.globalAlpha = (1 - t) * 0.7;
3052
+ this._drawEdge(d.ap, d.bp, d.colA, d.colB, false, true);
3053
+ this.ctx.restore();
3054
+ }
3055
+ for (let i = this._dyingNodes.length - 1; i >= 0; i--) {
3056
+ const d = this._dyingNodes[i];
3057
+ const t = (now - d.t0) / 220;
3058
+ if (t >= 1) { this._dyingNodes.splice(i, 1); continue; }
3059
+ const ease = 1 - Math.pow(1 - t, 3);
3060
+ const alpha = 1 - ease, scale = 1 - ease * 0.18;
3061
+ const hw = d.w * 0.5 * scale, hh = d.h * 0.5 * scale;
3062
+ const tl = this._w2s(d.x - hw, d.y - hh);
3063
+ const sw = d.w * scale * this.cam.zoom, sh = d.h * scale * this.cam.zoom;
3064
+ this.ctx.save();
3065
+ this.ctx.globalAlpha = alpha;
3066
+ this.ctx.fillStyle = '#161b27';
3067
+ this._shapePath(d.shape, tl.x, tl.y, sw, sh);
3068
+ this.ctx.fill();
3069
+ this.ctx.strokeStyle = alphaize(d.color, 0.6);
3070
+ this.ctx.lineWidth = 1.4 * this.cam.zoom;
3071
+ this._shapePath(d.shape, tl.x, tl.y, sw, sh);
3072
+ this.ctx.stroke();
3073
+ this.ctx.restore();
3074
+ }
3075
+ }
3076
+
3077
+ _drawMultiSelectBBox() {
3078
+ if (this._mode !== 'idle') return;
3079
+ const sel = this.getSelection();
3080
+ if (sel.length < 2) return;
3081
+ let mnx = Infinity, mxx = -Infinity, mny = Infinity, mxy = -Infinity;
3082
+ for (const i of sel) {
3083
+ const hw = this.V.sizeW[i] * 0.5, hh = this.V.sizeH[i] * 0.5;
3084
+ if (this.V.posX[i] - hw < mnx) mnx = this.V.posX[i] - hw;
3085
+ if (this.V.posX[i] + hw > mxx) mxx = this.V.posX[i] + hw;
3086
+ if (this.V.posY[i] - hh < mny) mny = this.V.posY[i] - hh;
3087
+ if (this.V.posY[i] + hh > mxy) mxy = this.V.posY[i] + hh;
3088
+ }
3089
+ const pad = 10;
3090
+ const a = this._w2s(mnx - pad, mny - pad), b = this._w2s(mxx + pad, mxy + pad);
3091
+ this.ctx.strokeStyle = 'rgba(240,185,58,0.55)';
3092
+ this.ctx.lineWidth = 1.2;
3093
+ this.ctx.setLineDash([5, 4]);
3094
+ this.ctx.strokeRect(a.x, a.y, b.x - a.x, b.y - a.y);
3095
+ this.ctx.setLineDash([]);
3096
+ }
3097
+
3098
+ _drawLasso() {
3099
+ if (!this._lasso || this._lasso.length < 2) return;
3100
+ this.ctx.strokeStyle = 'rgba(192,98,232,0.85)';
3101
+ this.ctx.fillStyle = 'rgba(192,98,232,0.10)';
3102
+ this.ctx.lineWidth = 1.4;
3103
+ this.ctx.setLineDash([4, 4]);
3104
+ this.ctx.beginPath();
3105
+ const first = this._w2s(this._lasso[0].x, this._lasso[0].y);
3106
+ this.ctx.moveTo(first.x, first.y);
3107
+ for (let i = 1; i < this._lasso.length; i++) {
3108
+ const p = this._w2s(this._lasso[i].x, this._lasso[i].y);
3109
+ this.ctx.lineTo(p.x, p.y);
3110
+ }
3111
+ this.ctx.closePath();
3112
+ this.ctx.fill(); this.ctx.stroke();
3113
+ this.ctx.setLineDash([]);
3114
+ }
3115
+
3116
+ _drawFrames() {
3117
+ for (let fi = 0; fi < this.frames.length; fi++) {
3118
+ const f = this.frames[fi];
3119
+ const collapsed = this.frameCollapsed.has(fi);
3120
+ const tl = this._w2s(f.x, f.y);
3121
+ const sw = f.w * this.cam.zoom;
3122
+ const sh = (collapsed ? 26 : f.h) * this.cam.zoom;
3123
+ this.ctx.fillStyle = alphaize(f.color, 0.05);
3124
+ this._roundRect(tl.x, tl.y, sw, sh, 12 * this.cam.zoom);
3125
+ this.ctx.fill();
3126
+ this.ctx.strokeStyle = alphaize(f.color, 0.45);
3127
+ this.ctx.lineWidth = 1.4 * this.cam.zoom;
3128
+ this.ctx.setLineDash([8 * this.cam.zoom, 4 * this.cam.zoom]);
3129
+ this._roundRect(tl.x, tl.y, sw, sh, 12 * this.cam.zoom);
3130
+ this.ctx.stroke();
3131
+ this.ctx.setLineDash([]);
3132
+ const hH = 26 * this.cam.zoom;
3133
+ this.ctx.save();
3134
+ this._roundRect(tl.x, tl.y, sw, sh, 12 * this.cam.zoom);
3135
+ this.ctx.clip();
3136
+ this.ctx.fillStyle = alphaize(f.color, 0.16);
3137
+ this.ctx.fillRect(tl.x, tl.y, sw, hH);
3138
+ this.ctx.restore();
3139
+ if (this.cam.zoom > 0.4) {
3140
+ this.ctx.fillStyle = f.color;
3141
+ this.ctx.font = `600 ${12 * this.cam.zoom}px Inter, ui-sans-serif`;
3142
+ this.ctx.textBaseline = 'middle';
3143
+ this.ctx.textAlign = 'left';
3144
+ const chev = collapsed ? '▸' : '▾';
3145
+ this.ctx.fillText(`${chev} ${f.label}`, tl.x + 10 * this.cam.zoom, tl.y + hH * 0.5);
3146
+ }
3147
+ }
3148
+ }
3149
+ _drawFrameHandles() {
3150
+ for (const f of this.frames) {
3151
+ const tl = this._w2s(f.x, f.y);
3152
+ const br = this._w2s(f.x + f.w, f.y + f.h);
3153
+ const s = 6 * this.cam.zoom;
3154
+ this.ctx.fillStyle = f.color;
3155
+ for (const [x, y] of [[tl.x, tl.y], [br.x, tl.y], [tl.x, br.y], [br.x, br.y]]) {
3156
+ this.ctx.fillRect(x - s / 2, y - s / 2, s, s);
3157
+ }
3158
+ }
3159
+ }
3160
+
3161
+ _drawNotes() {
3162
+ for (const n of this.notes) {
3163
+ const tl = this._w2s(n.x, n.y);
3164
+ const sw = n.w * this.cam.zoom, sh = n.h * this.cam.zoom;
3165
+ this.ctx.save();
3166
+ this.ctx.shadowColor = 'rgba(0,0,0,0.4)';
3167
+ this.ctx.shadowBlur = 12 * this.cam.zoom;
3168
+ this.ctx.shadowOffsetY = 4 * this.cam.zoom;
3169
+ this.ctx.fillStyle = n.color.fill;
3170
+ this._roundRect(tl.x, tl.y, sw, sh, 4 * this.cam.zoom);
3171
+ this.ctx.fill();
3172
+ this.ctx.restore();
3173
+ this.ctx.strokeStyle = n.color.border;
3174
+ this.ctx.lineWidth = 1 * this.cam.zoom;
3175
+ this._roundRect(tl.x, tl.y, sw, sh, 4 * this.cam.zoom);
3176
+ this.ctx.stroke();
3177
+ if (this.cam.zoom > 0.4 && n.text) {
3178
+ this.ctx.fillStyle = n.color.text;
3179
+ this.ctx.font = `500 ${12 * this.cam.zoom}px Inter, ui-sans-serif`;
3180
+ this.ctx.textBaseline = 'top';
3181
+ this.ctx.textAlign = 'left';
3182
+ const lineH = 16 * this.cam.zoom;
3183
+ const padX = 10 * this.cam.zoom, padY = 8 * this.cam.zoom;
3184
+ const maxW = sw - padX * 2;
3185
+ let ty = tl.y + padY;
3186
+ for (const para of n.text.split('\n')) {
3187
+ const words = para.split(/\s+/);
3188
+ let line = '';
3189
+ for (const word of words) {
3190
+ const test = line ? line + ' ' + word : word;
3191
+ if (this.ctx.measureText(test).width > maxW && line) {
3192
+ this.ctx.fillText(line, tl.x + padX, ty); ty += lineH; line = word;
3193
+ if (ty > tl.y + sh - lineH) break;
3194
+ } else line = test;
3195
+ }
3196
+ if (line && ty < tl.y + sh - lineH * 0.5) { this.ctx.fillText(line, tl.x + padX, ty); ty += lineH; }
3197
+ }
3198
+ }
3199
+ }
3200
+ }
3201
+
3202
+ // ── Path-highlight focus ──────────────────────────────────────────────
3203
+ _refreshFocus() {
3204
+ if (!this._pathHighlightEnabled) { this._focusedSet = null; return; }
3205
+ if (this._mode !== 'idle' || this._hoveredNode < 0) { this._focusedSet = null; this._lastFocusComputed = -2; return; }
3206
+ if (performance.now() - this._hoveredNodeSince < 200) return;
3207
+ if (this._hoveredNode === this._lastFocusComputed) return;
3208
+ const reach = new Set([this._hoveredNode]);
3209
+ const queue = [this._hoveredNode];
3210
+ const m = this.w.edgeCount_();
3211
+ while (queue.length) {
3212
+ const u = queue.shift();
3213
+ for (let i = 0; i < m; i++) {
3214
+ if (this.V.edgeFromN[i] === u && !reach.has(this.V.edgeToN[i])) { reach.add(this.V.edgeToN[i]); queue.push(this.V.edgeToN[i]); }
3215
+ if (this.V.edgeToN[i] === u && !reach.has(this.V.edgeFromN[i])) { reach.add(this.V.edgeFromN[i]); queue.push(this.V.edgeFromN[i]); }
3216
+ }
3217
+ }
3218
+ this._focusedSet = reach;
3219
+ this._lastFocusComputed = this._hoveredNode;
3220
+ }
3221
+
3222
+ // ── Hover preview popover ────────────────────────────────────────────
3223
+ _refreshPreview() {
3224
+ if (!this.options.hoverPreview) { this._hidePreview(); return; }
3225
+ if (this._mode !== 'idle' || this._hoveredNode < 0) { this._hidePreview(); return; }
3226
+ if (performance.now() - this._hoveredNodeSince < 600) return;
3227
+ if (this._previewedNode === this._hoveredNode) { this._positionPreview(this._hoveredNode); return; }
3228
+ this._showPreview(this._hoveredNode);
3229
+ this._previewedNode = this._hoveredNode;
3230
+ }
3231
+ _showPreview(id) {
3232
+ if (!this._previewEl) {
3233
+ const el = document.createElement('div');
3234
+ el.style.cssText = 'position:absolute;width:260px;padding:12px 14px;background:#161b27;border:1px solid rgba(255,255,255,0.16);border-radius:8px;box-shadow:0 12px 32px rgba(0,0,0,0.45);color:#e6edf3;font:13px/1.45 ui-sans-serif, system-ui, sans-serif;pointer-events:none;opacity:0;transition:opacity 140ms;z-index:200;';
3235
+ this.container.appendChild(el);
3236
+ this._previewEl = el;
3237
+ }
3238
+ const cat = this.kinds[this.V.kind[id]];
3239
+ const title = this.titles.get(id) || cat.name;
3240
+ const desc = this.descriptions.get(id);
3241
+ const tags = this.tags.get(id) || [];
3242
+ const status = this.status.get(id);
3243
+ const progress = this.progress.get(id);
3244
+ const parts = [
3245
+ `<div style="display:flex;align-items:center;gap:8px;padding-bottom:8px;margin-bottom:8px;border-bottom:1px solid rgba(255,255,255,0.08);">
3246
+ <span style="width:24px;height:24px;border-radius:5px;background:${alphaize(cat.color, 0.18)};color:${cat.color};display:grid;place-items:center;font:700 12px Inter">${this.icon.get(id) || cat.badge}</span>
3247
+ <div><div style="font-weight:600">${escapeHtml(title)}</div><div style="color:#8b95a7;font-family:ui-monospace,Consolas,monospace;font-size:11px;">#${id} · ${cat.name}</div></div>
3248
+ </div>`,
3249
+ ];
3250
+ if (desc) parts.push(`<div style="font-size:12px;color:#8b95a7;margin-bottom:8px;">${escapeHtml(desc)}</div>`);
3251
+ if (status || progress !== undefined) {
3252
+ const bits = [];
3253
+ if (status) bits.push(`<span style="padding:2px 7px;border-radius:10px;background:rgba(255,255,255,0.06);">${status}</span>`);
3254
+ if (progress !== undefined) bits.push(`<span style="padding:2px 7px;border-radius:10px;background:rgba(255,255,255,0.06);">${Math.round(progress * 100)}%</span>`);
3255
+ parts.push(`<div style="display:flex;gap:6px;margin-bottom:8px;font-size:11px;">${bits.join('')}</div>`);
3256
+ }
3257
+ if (tags.length) parts.push(`<div style="display:flex;flex-wrap:wrap;gap:4px;">${tags.map((t) => `<span style="font-size:10.5px;padding:2px 7px;border-radius:3px;background:${alphaize(cat.color, 0.18)};color:${cat.color};">${escapeHtml(t)}</span>`).join('')}</div>`);
3258
+ this._previewEl.innerHTML = parts.join('');
3259
+ this._positionPreview(id);
3260
+ this._previewEl.style.opacity = '1';
3261
+ }
3262
+ _positionPreview(id) {
3263
+ if (!this._previewEl) return;
3264
+ const cx = this.V.posX[id], cy = this.V.posY[id];
3265
+ const hw = this.V.sizeW[id] * 0.5, hh = this.V.sizeH[id] * 0.5;
3266
+ const tr = this._w2s(cx + hw, cy - hh), tl = this._w2s(cx - hw, cy - hh);
3267
+ const cr = this.container.getBoundingClientRect();
3268
+ const dpr = window.devicePixelRatio || 1;
3269
+ let lx = tr.x / dpr + 12, ty = tr.y / dpr;
3270
+ if (lx + 280 > cr.width) lx = tl.x / dpr - 280 - 12;
3271
+ if (lx < 8) lx = 8;
3272
+ this._previewEl.style.left = lx + 'px';
3273
+ this._previewEl.style.top = Math.max(8, ty) + 'px';
3274
+ }
3275
+ _hidePreview() {
3276
+ if (this._previewEl) this._previewEl.style.opacity = '0';
3277
+ this._previewedNode = -1;
3278
+ }
3279
+
3280
+ // ── HTML overlay nodes ────────────────────────────────────────────────
3281
+ _syncHTMLOverlays() {
3282
+ const seen = new Set();
3283
+ const n = this.w.nodeCount_();
3284
+ for (let i = 0; i < n; i++) {
3285
+ const cat = this.kinds[this.V.kind[i]];
3286
+ if (!cat.html) continue;
3287
+ seen.add(i);
3288
+ let el = this._htmlOverlays.get(i);
3289
+ if (!el) {
3290
+ el = document.createElement('div');
3291
+ el.className = 'zflow-html-node';
3292
+ el.style.cssText = 'position:absolute;border:1px solid rgba(255,255,255,0.16);border-radius:8px;background:#161b27;color:#e6edf3;overflow:hidden;font-family:Inter, ui-sans-serif;font-size:12px;transform-origin:top left;';
3293
+ // cat.template comes from the plugin author who registered the kind —
3294
+ // treat as trusted. cat.name passes through escape to be safe.
3295
+ el.innerHTML = cat.template || `<div style="padding:8px;">${escapeHtml(cat.name)} #${i}</div>`;
3296
+ this.container.appendChild(el);
3297
+ this._htmlOverlays.set(i, el);
3298
+ }
3299
+ const cx = this.V.posX[i], cy = this.V.posY[i];
3300
+ const hw = this.V.sizeW[i] * 0.5, hh = this.V.sizeH[i] * 0.5;
3301
+ const tl = this._w2s(cx - hw, cy - hh);
3302
+ const dpr = window.devicePixelRatio || 1;
3303
+ el.style.left = (tl.x / dpr) + 'px';
3304
+ el.style.top = (tl.y / dpr) + 'px';
3305
+ el.style.width = (this.V.sizeW[i] * this.cam.zoom / dpr) + 'px';
3306
+ el.style.height = (this.V.sizeH[i] * this.cam.zoom / dpr) + 'px';
3307
+ }
3308
+ for (const [id, el] of this._htmlOverlays) {
3309
+ if (!seen.has(id)) { el.remove(); this._htmlOverlays.delete(id); }
3310
+ }
3311
+ }
3312
+
3313
+ _drawGrid() {
3314
+ const ctx = this.ctx;
3315
+ const step = 40 * this.cam.zoom;
3316
+ if (step < 6) return;
3317
+ const cx = this.canvas.width / 2 + this.cam.x * this.cam.zoom;
3318
+ const cy = this.canvas.height / 2 + this.cam.y * this.cam.zoom;
3319
+ const startX = cx - Math.ceil(cx / step) * step;
3320
+ const startY = cy - Math.ceil(cy / step) * step;
3321
+ ctx.fillStyle = this.options.snapToGrid ? 'rgba(240,185,58,0.10)' : 'rgba(255,255,255,0.045)';
3322
+ const dpr = window.devicePixelRatio || 1;
3323
+ for (let x = startX; x < this.canvas.width; x += step) {
3324
+ for (let y = startY; y < this.canvas.height; y += step) {
3325
+ ctx.fillRect(x, y, 1.4 * dpr, 1.4 * dpr);
3326
+ }
3327
+ }
3328
+ }
3329
+
3330
+ _drawMarquee() {
3331
+ if (!this._marquee) return;
3332
+ const a = this._w2s(this._marquee.x0, this._marquee.y0);
3333
+ const b = this._w2s(this._marquee.x1, this._marquee.y1);
3334
+ const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y);
3335
+ const w = Math.abs(b.x - a.x), h = Math.abs(b.y - a.y);
3336
+ const ctx = this.ctx;
3337
+ ctx.fillStyle = 'rgba(240,185,58,0.10)';
3338
+ ctx.fillRect(x, y, w, h);
3339
+ ctx.strokeStyle = 'rgba(240,185,58,0.7)';
3340
+ ctx.setLineDash([4, 4]);
3341
+ ctx.lineWidth = 1.2;
3342
+ ctx.strokeRect(x, y, w, h);
3343
+ ctx.setLineDash([]);
3344
+ }
3345
+
3346
+ _drawAlignGuides() {
3347
+ if (!this._alignGuides) return;
3348
+ const ctx = this.ctx;
3349
+ ctx.strokeStyle = 'rgba(192,98,232,0.85)';
3350
+ ctx.lineWidth = 1.2;
3351
+ ctx.setLineDash([2, 4]);
3352
+ for (const g of this._alignGuides.v) {
3353
+ const x = this._w2s(g, 0).x;
3354
+ ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, this.canvas.height); ctx.stroke();
3355
+ }
3356
+ for (const g of this._alignGuides.h) {
3357
+ const y = this._w2s(0, g).y;
3358
+ ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(this.canvas.width, y); ctx.stroke();
3359
+ }
3360
+ ctx.setLineDash([]);
3361
+ }
3362
+
3363
+ _drawResizeHandles() {
3364
+ if (this._mode !== 'idle') return;
3365
+ const sel = this.getSelection();
3366
+ if (sel.length !== 1) return;
3367
+ const id = sel[0];
3368
+ const cx = this.V.posX[id], cy = this.V.posY[id];
3369
+ const hw = this.V.sizeW[id] * 0.5, hh = this.V.sizeH[id] * 0.5;
3370
+ const pts = {
3371
+ tl: {x: cx-hw, y: cy-hh}, t: {x: cx, y: cy-hh}, tr: {x: cx+hw, y: cy-hh},
3372
+ r: {x: cx+hw, y: cy},
3373
+ br: {x: cx+hw, y: cy+hh}, b: {x: cx, y: cy+hh}, bl: {x: cx-hw, y: cy+hh},
3374
+ l: {x: cx-hw, y: cy},
3375
+ };
3376
+ const ctx = this.ctx;
3377
+ const s = 4 * this.cam.zoom;
3378
+ ctx.fillStyle = '#f0b93a';
3379
+ ctx.strokeStyle = '#07090f';
3380
+ ctx.lineWidth = 1 * this.cam.zoom;
3381
+ for (const c of HANDLE_CORNERS) {
3382
+ const sp = this._w2s(pts[c].x, pts[c].y);
3383
+ ctx.beginPath();
3384
+ ctx.rect(sp.x - s, sp.y - s, s * 2, s * 2);
3385
+ ctx.fill(); ctx.stroke();
3386
+ }
3387
+ }
3388
+
3389
+ _portWorld(nodeId, side, idx) {
3390
+ const cx = this.V.posX[nodeId], cy = this.V.posY[nodeId];
3391
+ const hw = this.V.sizeW[nodeId] * 0.5, hh = this.V.sizeH[nodeId] * 0.5;
3392
+ const total = side === 0 ? this.V.nIn[nodeId] : this.V.nOut[nodeId];
3393
+ const t = (idx + 1) / (total + 1);
3394
+ const py = cy - hh + this.V.sizeH[nodeId] * t;
3395
+ return { x: side === 0 ? cx - hw : cx + hw, y: py };
3396
+ }
3397
+
3398
+ _orthoPath(p1, p2) {
3399
+ const minOff = 30, dx = p2.x - p1.x;
3400
+ if (dx > 2 * minOff) {
3401
+ const midX = (p1.x + p2.x) / 2;
3402
+ return [p1, { x: midX, y: p1.y }, { x: midX, y: p2.y }, p2];
3403
+ }
3404
+ const midY = (p1.y + p2.y) / 2;
3405
+ return [p1, { x: p1.x + minOff, y: p1.y }, { x: p1.x + minOff, y: midY },
3406
+ { x: p2.x - minOff, y: midY }, { x: p2.x - minOff, y: p2.y }, p2];
3407
+ }
3408
+
3409
+ _drawEdge(ap, bp, colA, colB, selected, preview, label) {
3410
+ const as = this._w2s(ap.x, ap.y), bs = this._w2s(bp.x, bp.y);
3411
+ const ctx = this.ctx;
3412
+ const grad = ctx.createLinearGradient(as.x, as.y, bs.x, bs.y);
3413
+ grad.addColorStop(0, colA); grad.addColorStop(1, colB);
3414
+ const isActive = this._currentEdgeIdx !== undefined && this._activeEdges.has(this._currentEdgeIdx) &&
3415
+ this._activeEdges.get(this._currentEdgeIdx) > performance.now();
3416
+ ctx.strokeStyle = selected ? '#f0b93a' : isActive ? '#5b8def' : (preview ? '#8b95a7' : grad);
3417
+ ctx.lineWidth = (selected ? 2.4 : isActive ? 3.0 : 1.6) * this.cam.zoom;
3418
+ if (isActive) { ctx.shadowColor = '#5b8def'; ctx.shadowBlur = 10 * this.cam.zoom; }
3419
+ ctx.lineJoin = 'round';
3420
+ let midPt;
3421
+ if (this.options.edgeStyle === 'orthogonal') {
3422
+ const path = this._orthoPath(as, bs);
3423
+ ctx.beginPath();
3424
+ ctx.moveTo(path[0].x, path[0].y);
3425
+ for (let i = 1; i < path.length; i++) ctx.lineTo(path[i].x, path[i].y);
3426
+ ctx.stroke();
3427
+ midPt = path[Math.floor(path.length / 2)];
3428
+ } else {
3429
+ const dxe = bs.x - as.x, dye = bs.y - as.y;
3430
+ const off = Math.max(50, Math.abs(dxe) * 0.5 + Math.abs(dye) * 0.4);
3431
+ ctx.beginPath();
3432
+ ctx.moveTo(as.x, as.y);
3433
+ ctx.bezierCurveTo(as.x + off, as.y, bs.x - off, bs.y, bs.x, bs.y);
3434
+ ctx.stroke();
3435
+ midPt = bezPt$1(0.5, as.x, as.y, as.x + off, as.y, bs.x - off, bs.y, bs.x, bs.y);
3436
+ }
3437
+ // Flow particles (set per-edge via setEdgeAnimated or globally).
3438
+ if (!preview && this._currentEdgeIdx !== undefined && this.animatedEdges.has(this._currentEdgeIdx)) {
3439
+ const N = 4;
3440
+ for (let k = 0; k < N; k++) {
3441
+ const t = ((this._edgePhase / 1000) + k / N) % 1;
3442
+ let p;
3443
+ if (this.options.edgeStyle === 'orthogonal') {
3444
+ const dxe = bs.x - as.x, dye = bs.y - as.y;
3445
+ const off = Math.max(50, Math.abs(dxe) * 0.5 + Math.abs(dye) * 0.4);
3446
+ p = bezPt$1(t, as.x, as.y, as.x + off, as.y, bs.x - off, bs.y, bs.x, bs.y);
3447
+ } else {
3448
+ const dxe = bs.x - as.x, dye = bs.y - as.y;
3449
+ const off = Math.max(50, Math.abs(dxe) * 0.5 + Math.abs(dye) * 0.4);
3450
+ p = bezPt$1(t, as.x, as.y, as.x + off, as.y, bs.x - off, bs.y, bs.x, bs.y);
3451
+ }
3452
+ ctx.fillStyle = colB;
3453
+ ctx.beginPath(); ctx.arc(p.x, p.y, 3.2 * this.cam.zoom, 0, Math.PI * 2); ctx.fill();
3454
+ }
3455
+ }
3456
+ // Edge label badge.
3457
+ if (!preview && label && this.cam.zoom > 0.45) {
3458
+ ctx.font = `600 ${10.5 * this.cam.zoom}px ui-monospace, Consolas, monospace`;
3459
+ const tw = ctx.measureText(label).width;
3460
+ const pad = 5 * this.cam.zoom;
3461
+ const bw = tw + pad * 2;
3462
+ const bh = 16 * this.cam.zoom;
3463
+ ctx.fillStyle = '#0b0f17';
3464
+ this._roundRect(midPt.x - bw / 2, midPt.y - bh / 2, bw, bh, 5 * this.cam.zoom);
3465
+ ctx.fill();
3466
+ ctx.strokeStyle = selected ? '#f0b93a' : alphaize(colA, 0.6);
3467
+ ctx.lineWidth = 1 * this.cam.zoom;
3468
+ ctx.stroke();
3469
+ ctx.fillStyle = '#e6edf3';
3470
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
3471
+ ctx.fillText(label, midPt.x, midPt.y);
3472
+ }
3473
+ }
3474
+
3475
+ /** Slim overlay: only paints what WebGL didn't, with LOD gating. */
3476
+ _drawNodeOverlay(i) {
3477
+ const z = this.cam.zoom;
3478
+ // LOD: below 0.3 zoom (or with >5k nodes far out) skip text + ports entirely.
3479
+ if (z < 0.25) return;
3480
+ if (this.w.nodeCount_() > 5000 && z < 0.55) return;
3481
+ const cx = this.V.posX[i], cy = this.V.posY[i];
3482
+ const hw = this.V.sizeW[i] * 0.5, hh = this.V.sizeH[i] * 0.5;
3483
+ const tl = this._w2s(cx - hw, cy - hh);
3484
+ const sw = this.V.sizeW[i] * z;
3485
+ const sh = this.V.sizeH[i] * z;
3486
+ const cat = this.kinds[this.V.kind[i]];
3487
+ const color = this.colors.get(i) || cat.color;
3488
+ const ctx = this.ctx;
3489
+ if (z > 0.4) {
3490
+ const title = this.titles.get(i) || cat.name;
3491
+ ctx.font = `600 ${12 * z}px Inter, ui-sans-serif`;
3492
+ ctx.fillStyle = '#0b0f17';
3493
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
3494
+ ctx.fillText(title, tl.x + sw / 2, tl.y + 11 * z);
3495
+ }
3496
+ // Ports only when zoom is decent.
3497
+ if (z > 0.35) {
3498
+ for (let s = 0; s < 2; s++) {
3499
+ const count = s === 0 ? this.V.nIn[i] : this.V.nOut[i];
3500
+ for (let p = 0; p < count; p++) {
3501
+ const wp = this._portWorld(i, s, p);
3502
+ const sp = this._w2s(wp.x, wp.y);
3503
+ ctx.fillStyle = color;
3504
+ ctx.strokeStyle = '#07090f';
3505
+ ctx.lineWidth = 1.5 * z;
3506
+ ctx.beginPath(); ctx.arc(sp.x, sp.y, 4.5 * z, 0, Math.PI * 2);
3507
+ ctx.fill(); ctx.stroke();
3508
+ }
3509
+ }
3510
+ }
3511
+ if (this.breakpoints.has(i)) {
3512
+ ctx.fillStyle = '#e8462b';
3513
+ ctx.beginPath(); ctx.arc(tl.x - 4 * z, tl.y + sh / 2, 4.5 * z, 0, Math.PI * 2); ctx.fill();
3514
+ }
3515
+ }
3516
+
3517
+ _drawNode(i) {
3518
+ const cx = this.V.posX[i], cy = this.V.posY[i];
3519
+ const hw = this.V.sizeW[i] * 0.5, hh = this.V.sizeH[i] * 0.5;
3520
+ const tl = this._w2s(cx - hw, cy - hh);
3521
+ const sw = this.V.sizeW[i] * this.cam.zoom;
3522
+ const sh = this.V.sizeH[i] * this.cam.zoom;
3523
+ const cat = this.kinds[this.V.kind[i]];
3524
+ const color = this.colors.get(i) || cat.color;
3525
+ const sel = this.V.selected[i] !== 0;
3526
+ const hov = this._hoveredNode === i && this._mode === 'idle';
3527
+ const ctx = this.ctx;
3528
+
3529
+ // Body shadow + fill.
3530
+ ctx.save();
3531
+ ctx.shadowColor = 'rgba(0,0,0,0.45)';
3532
+ ctx.shadowBlur = (hov ? 14 : 10) * this.cam.zoom;
3533
+ ctx.shadowOffsetY = 4 * this.cam.zoom;
3534
+ ctx.fillStyle = '#161b27';
3535
+ this._shapePath(cat.shape, tl.x, tl.y, sw, sh);
3536
+ ctx.fill();
3537
+ ctx.restore();
3538
+
3539
+ // Header strip (rect kinds only).
3540
+ if (cat.shape === 'rect') {
3541
+ ctx.save();
3542
+ this._shapePath(cat.shape, tl.x, tl.y, sw, sh);
3543
+ ctx.clip();
3544
+ ctx.fillStyle = color;
3545
+ ctx.fillRect(tl.x, tl.y, sw, 22 * this.cam.zoom);
3546
+ ctx.restore();
3547
+ }
3548
+
3549
+ // Running pulse — strong blue glow while exec is in flight.
3550
+ if (this.status.get(i) === 'running') {
3551
+ const pulse = 0.5 + 0.5 * Math.sin(performance.now() / 180);
3552
+ ctx.save();
3553
+ ctx.shadowColor = '#5b8def';
3554
+ ctx.shadowBlur = (18 + 14 * pulse) * this.cam.zoom;
3555
+ ctx.strokeStyle = `rgba(91,141,239,${0.6 + 0.4 * pulse})`;
3556
+ ctx.lineWidth = (2.2 + pulse) * this.cam.zoom;
3557
+ this._shapePath(cat.shape, tl.x - 2, tl.y - 2, sw + 4, sh + 4);
3558
+ ctx.stroke();
3559
+ ctx.restore();
3560
+ }
3561
+
3562
+ // Border / selection.
3563
+ if (sel) {
3564
+ ctx.save();
3565
+ ctx.shadowColor = 'rgba(240,185,58,0.55)';
3566
+ ctx.shadowBlur = 14 * this.cam.zoom;
3567
+ ctx.strokeStyle = '#f0b93a'; ctx.lineWidth = 1.6 * this.cam.zoom;
3568
+ this._shapePath(cat.shape, tl.x, tl.y, sw, sh); ctx.stroke();
3569
+ ctx.restore();
3570
+ } else {
3571
+ ctx.strokeStyle = hov ? alphaize(color, 0.55) : 'rgba(255,255,255,0.08)';
3572
+ ctx.lineWidth = (hov ? 1.3 : 1) * this.cam.zoom;
3573
+ this._shapePath(cat.shape, tl.x, tl.y, sw, sh); ctx.stroke();
3574
+ }
3575
+
3576
+ // Title.
3577
+ if (this.cam.zoom > 0.42) {
3578
+ const title = this.titles.get(i) || `${cat.name} #${i}`;
3579
+ ctx.textBaseline = 'middle';
3580
+ ctx.font = `600 ${11 * this.cam.zoom}px Inter, ui-sans-serif`;
3581
+ if (cat.shape === 'rect') {
3582
+ ctx.fillStyle = '#0b0f17'; ctx.textAlign = 'left';
3583
+ ctx.fillText(title, tl.x + 10 * this.cam.zoom, tl.y + 11 * this.cam.zoom);
3584
+ } else {
3585
+ ctx.fillStyle = color; ctx.textAlign = 'center';
3586
+ ctx.fillText(title, tl.x + sw / 2, tl.y + sh / 2);
3587
+ }
3588
+ }
3589
+
3590
+
3591
+ // Progress bar (bottom).
3592
+ const prog = this.progress.get(i);
3593
+ if (prog !== undefined && prog > 0) {
3594
+ const barH = 3 * this.cam.zoom;
3595
+ const barY = tl.y + sh - barH - 2 * this.cam.zoom;
3596
+ const barX = tl.x + 8 * this.cam.zoom;
3597
+ const barW = sw - 16 * this.cam.zoom;
3598
+ ctx.fillStyle = 'rgba(255,255,255,0.06)';
3599
+ ctx.fillRect(barX, barY, barW, barH);
3600
+ ctx.fillStyle = color;
3601
+ ctx.fillRect(barX, barY, barW * Math.min(1, Math.max(0, prog)), barH);
3602
+ }
3603
+
3604
+ // Tags.
3605
+ const tags = this.tags.get(i);
3606
+ if (tags && tags.length && this.cam.zoom > 0.5) {
3607
+ let tx = tl.x + 8 * this.cam.zoom;
3608
+ const ty = tl.y + sh - 24 * this.cam.zoom;
3609
+ ctx.font = `500 ${9 * this.cam.zoom}px Inter, ui-sans-serif`;
3610
+ ctx.textBaseline = 'middle';
3611
+ for (const tag of tags) {
3612
+ const wText = ctx.measureText(tag).width;
3613
+ const w0 = wText + 10 * this.cam.zoom;
3614
+ if (tx + w0 > tl.x + sw - 8 * this.cam.zoom) break;
3615
+ ctx.fillStyle = alphaize(color, 0.18);
3616
+ this._roundRect(tx, ty, w0, 14 * this.cam.zoom, 3 * this.cam.zoom);
3617
+ ctx.fill();
3618
+ ctx.fillStyle = color;
3619
+ ctx.textAlign = 'left';
3620
+ ctx.fillText(tag, tx + 5 * this.cam.zoom, ty + 7 * this.cam.zoom);
3621
+ tx += w0 + 4 * this.cam.zoom;
3622
+ }
3623
+ }
3624
+
3625
+ // Sparkline (live metric).
3626
+ const m = this.metrics.get(i);
3627
+ if (m && m.count > 1 && cat.shape === 'rect' && this.cam.zoom > 0.45) {
3628
+ const max = this.metricMax.get(i) || 1;
3629
+ const px = tl.x + 8 * this.cam.zoom, py = tl.y + sh - 22 * this.cam.zoom;
3630
+ const pw = sw - 16 * this.cam.zoom, ph = 16 * this.cam.zoom;
3631
+ ctx.strokeStyle = alphaize(color, 0.85);
3632
+ ctx.lineWidth = 1.5 * this.cam.zoom;
3633
+ ctx.beginPath();
3634
+ for (let k = 0; k < m.count; k++) {
3635
+ const v = m.data[(m.idx + this._metricCap - m.count + k) % this._metricCap];
3636
+ const xx = px + (k / Math.max(1, m.count - 1)) * pw;
3637
+ const yy = py + ph - (Math.min(Math.max(v / max, 0), 1)) * ph;
3638
+ if (k === 0) ctx.moveTo(xx, yy); else ctx.lineTo(xx, yy);
3639
+ }
3640
+ ctx.stroke();
3641
+ }
3642
+
3643
+ // Breakpoint indicator (red filled circle on left edge).
3644
+ if (this.breakpoints.has(i)) {
3645
+ const bx = tl.x - 4 * this.cam.zoom, by = tl.y + sh / 2;
3646
+ ctx.save();
3647
+ ctx.shadowColor = '#e8462b'; ctx.shadowBlur = 8 * this.cam.zoom;
3648
+ ctx.fillStyle = '#e8462b';
3649
+ ctx.beginPath(); ctx.arc(bx, by, 4.5 * this.cam.zoom, 0, Math.PI * 2); ctx.fill();
3650
+ ctx.restore();
3651
+ }
3652
+
3653
+ // Status dot (top-right of header).
3654
+ const st = this.status.get(i);
3655
+ if (st && cat.shape === 'rect') {
3656
+ const sCol = STATUS_COLORS[st] || '#8b95a7';
3657
+ const dotX = tl.x + sw - 12 * this.cam.zoom;
3658
+ const dotY = tl.y + 11 * this.cam.zoom;
3659
+ ctx.save();
3660
+ ctx.shadowColor = sCol; ctx.shadowBlur = 6 * this.cam.zoom;
3661
+ ctx.fillStyle = sCol;
3662
+ ctx.beginPath(); ctx.arc(dotX, dotY, 3.5 * this.cam.zoom, 0, Math.PI * 2); ctx.fill();
3663
+ ctx.restore();
3664
+ }
3665
+
3666
+ // Locked indicator (small lock glyph top-left).
3667
+ if (this.locked.has(i)) {
3668
+ const lx = tl.x + 6 * this.cam.zoom, ly = tl.y + 6 * this.cam.zoom;
3669
+ const lz = 10 * this.cam.zoom;
3670
+ ctx.fillStyle = 'rgba(0,0,0,0.55)';
3671
+ this._roundRect(lx, ly, lz, lz, 2 * this.cam.zoom); ctx.fill();
3672
+ ctx.strokeStyle = '#f0b93a'; ctx.lineWidth = 1.2 * this.cam.zoom;
3673
+ ctx.strokeRect(lx + 2.5 * this.cam.zoom, ly + 5 * this.cam.zoom, lz - 5 * this.cam.zoom, lz - 6 * this.cam.zoom);
3674
+ ctx.beginPath();
3675
+ ctx.arc(lx + lz * 0.5, ly + 5 * this.cam.zoom, 2 * this.cam.zoom, Math.PI, 0);
3676
+ ctx.stroke();
3677
+ }
3678
+
3679
+ // Image thumbnail (top of body).
3680
+ const imgUrl = this.image.get(i);
3681
+ if (imgUrl && cat.shape === 'rect' && this.cam.zoom > 0.5) {
3682
+ const img = this._getImage(imgUrl);
3683
+ if (img && img.ready) {
3684
+ const ix = tl.x + 8 * this.cam.zoom;
3685
+ const iy = tl.y + 28 * this.cam.zoom;
3686
+ const iw = sw - 16 * this.cam.zoom;
3687
+ const ih = Math.min(54 * this.cam.zoom, sh * 0.45);
3688
+ ctx.save();
3689
+ this._roundRect(ix, iy, iw, ih, 4 * this.cam.zoom);
3690
+ ctx.clip();
3691
+ ctx.drawImage(img.img, ix, iy, iw, ih);
3692
+ ctx.restore();
3693
+ }
3694
+ }
3695
+
3696
+ // Inline markdown description (one line, runs).
3697
+ const desc = this.descriptions.get(i);
3698
+ if (desc && cat.shape === 'rect' && this.cam.zoom > 0.5 && !imgUrl) {
3699
+ const runs = this.options.inlineMarkdown !== false ? parseInlineMd(desc) : [{ t: desc, style: 'p' }];
3700
+ let dx = tl.x + 8 * this.cam.zoom;
3701
+ const dyy = tl.y + 44 * this.cam.zoom;
3702
+ ctx.font = `400 ${10 * this.cam.zoom}px Inter, ui-sans-serif`;
3703
+ ctx.textBaseline = 'top';
3704
+ const maxX = tl.x + sw - 8 * this.cam.zoom;
3705
+ for (const run of runs) {
3706
+ let f = `${10 * this.cam.zoom}px `;
3707
+ if (run.style === 'b') f = `700 ${10 * this.cam.zoom}px Inter, ui-sans-serif`;
3708
+ else if (run.style === 'i') f = `italic 400 ${10 * this.cam.zoom}px Inter, ui-sans-serif`;
3709
+ else if (run.style === 'c') f = `${10 * this.cam.zoom}px ui-monospace, Consolas, monospace`;
3710
+ else if (run.style === 'a') f = `400 ${10 * this.cam.zoom}px Inter, ui-sans-serif`;
3711
+ else f = `400 ${10 * this.cam.zoom}px Inter, ui-sans-serif`;
3712
+ ctx.font = f;
3713
+ ctx.fillStyle = run.style === 'a' ? '#5be0d0' : run.style === 'c' ? '#f0b93a' : '#c8d1de';
3714
+ const tw = ctx.measureText(run.t).width;
3715
+ if (dx + tw > maxX) break;
3716
+ ctx.fillText(run.t, dx, dyy);
3717
+ dx += tw;
3718
+ }
3719
+ }
3720
+
3721
+ // Search hit glow.
3722
+ if (this._searchHits && this._searchHits.includes(i)) {
3723
+ ctx.save();
3724
+ ctx.shadowColor = '#5be0d0';
3725
+ ctx.shadowBlur = 18 * this.cam.zoom;
3726
+ ctx.strokeStyle = '#5be0d0';
3727
+ ctx.lineWidth = 1.6 * this.cam.zoom;
3728
+ this._shapePath(cat.shape, tl.x - 3, tl.y - 3, sw + 6, sh + 6);
3729
+ ctx.stroke();
3730
+ ctx.restore();
3731
+ }
3732
+
3733
+ // Ports.
3734
+ for (let s = 0; s < 2; s++) {
3735
+ const count = s === 0 ? this.V.nIn[i] : this.V.nOut[i];
3736
+ for (let p = 0; p < count; p++) {
3737
+ const wp = this._portWorld(i, s, p);
3738
+ const sp = this._w2s(wp.x, wp.y);
3739
+ ctx.fillStyle = color;
3740
+ ctx.strokeStyle = '#07090f';
3741
+ ctx.lineWidth = 1.5 * this.cam.zoom;
3742
+ ctx.beginPath(); ctx.arc(sp.x, sp.y, 4.5 * this.cam.zoom, 0, Math.PI * 2);
3743
+ ctx.fill(); ctx.stroke();
3744
+ }
3745
+ }
3746
+ }
3747
+
3748
+ _shapePath(shape, x, y, w, h) {
3749
+ const ctx = this.ctx;
3750
+ if (shape === 'diamond') {
3751
+ const cx = x + w / 2, cy = y + h / 2;
3752
+ ctx.beginPath();
3753
+ ctx.moveTo(cx, y); ctx.lineTo(x + w, cy);
3754
+ ctx.lineTo(cx, y + h); ctx.lineTo(x, cy);
3755
+ ctx.closePath(); return;
3756
+ }
3757
+ if (shape === 'ellipse') {
3758
+ ctx.beginPath();
3759
+ ctx.ellipse(x + w / 2, y + h / 2, w / 2, h / 2, 0, 0, Math.PI * 2);
3760
+ return;
3761
+ }
3762
+ if (shape === 'hexagon') {
3763
+ const cx = x + w / 2, cy = y + h / 2;
3764
+ const hw = w / 2, a = hw * 0.45;
3765
+ ctx.beginPath();
3766
+ ctx.moveTo(cx - hw + a, y); ctx.lineTo(cx + hw - a, y);
3767
+ ctx.lineTo(x + w, cy); ctx.lineTo(cx + hw - a, y + h);
3768
+ ctx.lineTo(cx - hw + a, y + h); ctx.lineTo(x, cy);
3769
+ ctx.closePath(); return;
3770
+ }
3771
+ this._roundRect(x, y, w, h, 8);
3772
+ }
3773
+ _roundRect(x, y, w, h, r) {
3774
+ const ctx = this.ctx;
3775
+ ctx.beginPath();
3776
+ ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y);
3777
+ ctx.quadraticCurveTo(x + w, y, x + w, y + r);
3778
+ ctx.lineTo(x + w, y + h - r);
3779
+ ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
3780
+ ctx.lineTo(x + r, y + h);
3781
+ ctx.quadraticCurveTo(x, y + h, x, y + h - r);
3782
+ ctx.lineTo(x, y + r);
3783
+ ctx.quadraticCurveTo(x, y, x + r, y);
3784
+ ctx.closePath();
3785
+ }
3786
+ }
3787
+
3788
+ // ── Free helpers ──────────────────────────────────────────────────────────
3789
+ function bezPt$1(t, x1, y1, cx1, cy1, cx2, cy2, x2, y2) {
3790
+ const mt = 1 - t, mt2 = mt * mt, t2 = t * t;
3791
+ const a = mt2 * mt, b = 3 * mt2 * t, c = 3 * mt * t2, d = t2 * t;
3792
+ return { x: a*x1 + b*cx1 + c*cx2 + d*x2, y: a*y1 + b*cy1 + c*cy2 + d*y2 };
3793
+ }
3794
+ function distSeg2(qx, qy, x1, y1, x2, y2) {
3795
+ const dx = x2 - x1, dy = y2 - y1;
3796
+ const len2 = dx*dx + dy*dy;
3797
+ if (len2 === 0) { const ddx = qx - x1, ddy = qy - y1; return ddx*ddx + ddy*ddy; }
3798
+ let t = ((qx - x1) * dx + (qy - y1) * dy) / len2;
3799
+ t = Math.max(0, Math.min(1, t));
3800
+ const px = x1 + t * dx, py = y1 + t * dy;
3801
+ const ddx = qx - px, ddy = qy - py;
3802
+ return ddx*ddx + ddy*ddy;
3803
+ }
3804
+ function alphaize(hex, a) {
3805
+ if (hex.startsWith('rgb')) return hex.replace(')', `, ${a})`).replace('rgb(', 'rgba(');
3806
+ const r = parseInt(hex.slice(1, 3), 16);
3807
+ const g = parseInt(hex.slice(3, 5), 16);
3808
+ const b = parseInt(hex.slice(5, 7), 16);
3809
+ return `rgba(${r},${g},${b},${a})`;
3810
+ }
3811
+ const STATUS_COLORS = {
3812
+ ok: '#5bd17a', live: '#5bd17a', running: '#5b8def', idle: '#8b95a7',
3813
+ warn: '#f0b93a', error: '#e8462b', failed: '#e8462b', stopped: '#8b95a7',
3814
+ };
3815
+
3816
+ function parseInlineMd(s) {
3817
+ if (!s) return [{ t: '', style: 'p' }];
3818
+ const runs = [];
3819
+ const re = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g;
3820
+ let last = 0, m;
3821
+ while ((m = re.exec(s)) !== null) {
3822
+ if (m.index > last) runs.push({ t: s.slice(last, m.index), style: 'p' });
3823
+ const tok = m[1];
3824
+ if (tok.startsWith('**')) runs.push({ t: tok.slice(2, -2), style: 'b' });
3825
+ else if (tok.startsWith('`')) runs.push({ t: tok.slice(1, -1), style: 'c' });
3826
+ else if (tok.startsWith('[')) {
3827
+ const close = tok.indexOf('](');
3828
+ runs.push({ t: tok.slice(1, close), style: 'a', href: tok.slice(close + 2, -1) });
3829
+ }
3830
+ else runs.push({ t: tok.slice(1, -1), style: 'i' });
3831
+ last = m.index + tok.length;
3832
+ }
3833
+ if (last < s.length) runs.push({ t: s.slice(last), style: 'p' });
3834
+ return runs;
3835
+ }
3836
+
3837
+ function pinchInfo(pointers) {
3838
+ const [a, b] = [...pointers.values()];
3839
+ const dx = b.x - a.x, dy = b.y - a.y;
3840
+ return { dist: Math.hypot(dx, dy) || 1, mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 } };
3841
+ }
3842
+
3843
+ function isCompatibleType(out, inn) {
3844
+ if (!out || !inn) return true;
3845
+ if (out === inn) return true;
3846
+ if (out === 'any' || inn === 'any') return true;
3847
+ if (inn === 'string') return true; // anything stringifies
3848
+ const numerics = new Set(['number', 'int', 'float', 'integer']);
3849
+ if (numerics.has(out) && numerics.has(inn)) return true;
3850
+ return false;
3851
+ }
3852
+
3853
+ function fnvHash(v) {
3854
+ // Fast structural hash for primitives / shallow objects / arrays. Avoids JSON.
3855
+ let h = 0x811c9dc5;
3856
+ const visit = (x) => {
3857
+ if (x === null || x === undefined) { h = (h ^ 0xff) * 0x01000193 >>> 0; return; }
3858
+ const t = typeof x;
3859
+ if (t === 'number') { h = (h ^ ((x * 1e6) | 0)) * 0x01000193 >>> 0; return; }
3860
+ if (t === 'string') { for (let i = 0; i < x.length; i++) h = (h ^ x.charCodeAt(i)) * 0x01000193 >>> 0; return; }
3861
+ if (t === 'boolean') { h = (h ^ (x ? 1 : 0)) * 0x01000193 >>> 0; return; }
3862
+ if (Array.isArray(x)) { for (const e of x) visit(e); return; }
3863
+ if (t === 'object') { for (const k of Object.keys(x).sort()) { for (let i = 0; i < k.length; i++) h = (h ^ k.charCodeAt(i)) * 0x01000193 >>> 0; visit(x[k]); } return; }
3864
+ };
3865
+ visit(v);
3866
+ return h >>> 0;
3867
+ }
3868
+
3869
+ function bubbleSummary(v) {
3870
+ if (typeof v === 'number') return formatRuntimeValue(v);
3871
+ if (v && typeof v === 'object') {
3872
+ return Object.entries(v).filter(([, x]) => x != null).map(([k, x]) => `${k}: ${formatRuntimeValue(x)}`).join(' ');
3873
+ }
3874
+ return formatRuntimeValue(v);
3875
+ }
3876
+
3877
+ function formatRuntimeValue(v) {
3878
+ if (v === null || v === undefined) return '∅';
3879
+ if (typeof v === 'number') return Number.isInteger(v) ? String(v) : v.toFixed(2);
3880
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
3881
+ if (typeof v === 'string') return v.length > 22 ? v.slice(0, 22) + '…' : v;
3882
+ try { const s = JSON.stringify(v); return s.length > 30 ? s.slice(0, 30) + '…' : s; }
3883
+ catch { return '[obj]'; }
3884
+ }
3885
+
3886
+ function parseHex$1(h) {
3887
+ return [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
3888
+ }
3889
+ function pointInPolygon(px, py, poly) {
3890
+ let inside = false;
3891
+ for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
3892
+ const xi = poly[i].x, yi = poly[i].y, xj = poly[j].x, yj = poly[j].y;
3893
+ const intersect = ((yi > py) !== (yj > py)) && (px < (xj - xi) * (py - yi) / (yj - yi) + xi);
3894
+ if (intersect) inside = !inside;
3895
+ }
3896
+ return inside;
3897
+ }
3898
+ function escapeHtml(s) {
3899
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
3900
+ }
3901
+ function escapeXml(s) { return escapeHtml(s); }
3902
+
3903
+ // ── Mermaid + DOT importers ───────────────────────────────────────────────
3904
+ function parseMermaid(text) {
3905
+ const nodes = new Map(), edges = [];
3906
+ const lines = text.split('\n').map((l) => l.replace(/\/\/.*$/, '').trim()).filter(Boolean);
3907
+ let i0 = 0;
3908
+ if (lines[0] && /^(graph|flowchart)\b/i.test(lines[0])) i0 = 1;
3909
+ const NODE_RE = /([A-Za-z_][A-Za-z0-9_]*)(?:(\[\[)([^\]]+)\]\]|\[([^\]]+)\]|\(\(([^)]+)\)\)|\(([^)]+)\)|\{([^}]+)\})?/;
3910
+ function consume(s, pos) {
3911
+ const sub = s.slice(pos);
3912
+ const m = sub.match(NODE_RE);
3913
+ if (!m || m.index !== 0) return null;
3914
+ const id = m[1];
3915
+ let shape = 'default', label = id;
3916
+ if (m[2]) { shape = 'subroutine'; label = m[3]; }
3917
+ else if (m[4]) { shape = 'rect'; label = m[4]; }
3918
+ else if (m[5]) { shape = 'circle'; label = m[5]; }
3919
+ else if (m[6]) { shape = 'round'; label = m[6]; }
3920
+ else if (m[7]) { shape = 'rhombus';label = m[7]; }
3921
+ if (!nodes.has(id) || nodes.get(id).shape === 'default') nodes.set(id, { label, shape });
3922
+ return { id, end: pos + m[0].length };
3923
+ }
3924
+ for (let li = i0; li < lines.length; li++) {
3925
+ const ln = lines[li];
3926
+ const a = consume(ln, 0); if (!a) continue;
3927
+ let rest = ln.slice(a.end).trim();
3928
+ const arrow = rest.match(/^([-=.~]+>?|---|===)\s*(?:\|([^|]+)\|\s*)?/);
3929
+ if (!arrow) continue;
3930
+ rest = rest.slice(arrow[0].length).trim();
3931
+ const offset = ln.length - rest.length;
3932
+ const b = consume(ln, offset); if (!b) continue;
3933
+ edges.push({ from: a.id, to: b.id, label: arrow[2] || null });
3934
+ }
3935
+ return { nodes, edges };
3936
+ }
3937
+ function parseDot(text) {
3938
+ const nodes = new Map(), edges = [];
3939
+ text = text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*#.*$/gm, '').replace(/\/\/.*$/gm, '');
3940
+ text = text.replace(/^\s*(?:strict\s+)?(?:di)?graph\s+\w*\s*\{/i, '').replace(/\}\s*$/, '');
3941
+ const stmts = []; let buf = '', depth = 0;
3942
+ for (const ch of text) {
3943
+ if (ch === '[') depth++;
3944
+ if (ch === ']') depth--;
3945
+ if ((ch === ';' || ch === '\n') && depth === 0) {
3946
+ if (buf.trim()) stmts.push(buf.trim());
3947
+ buf = '';
3948
+ } else buf += ch;
3949
+ }
3950
+ if (buf.trim()) stmts.push(buf.trim());
3951
+ const ID_RE = /"([^"]+)"|([A-Za-z_][A-Za-z0-9_]*)/;
3952
+ function takeId(s, p) {
3953
+ const m = s.slice(p).match(ID_RE);
3954
+ if (!m || m.index !== 0) return null;
3955
+ return { id: m[1] || m[2], end: p + m[0].length };
3956
+ }
3957
+ function takeAttrs(s, p) {
3958
+ const sub = s.slice(p).match(/^\s*\[([^\]]*)\]/);
3959
+ if (!sub) return null;
3960
+ const lm = sub[1].match(/label\s*=\s*"([^"]*)"|label\s*=\s*([A-Za-z_][A-Za-z0-9_]*)/);
3961
+ return { label: lm ? (lm[1] || lm[2]) : null, end: p + sub[0].length };
3962
+ }
3963
+ for (const stmt of stmts) {
3964
+ let p = 0;
3965
+ const a = takeId(stmt, p); if (!a) continue;
3966
+ p = a.end;
3967
+ while (stmt[p] === ' ' || stmt[p] === '\t') p++;
3968
+ const arrow = stmt.slice(p).match(/^(->|--)\s*/);
3969
+ if (arrow) {
3970
+ p += arrow[0].length;
3971
+ const b = takeId(stmt, p); if (!b) continue;
3972
+ p = b.end;
3973
+ const attrs = takeAttrs(stmt, p);
3974
+ if (!nodes.has(a.id)) nodes.set(a.id, { label: a.id });
3975
+ if (!nodes.has(b.id)) nodes.set(b.id, { label: b.id });
3976
+ edges.push({ from: a.id, to: b.id, label: attrs ? attrs.label : null });
3977
+ } else {
3978
+ const attrs = takeAttrs(stmt, p);
3979
+ const label = attrs && attrs.label ? attrs.label : a.id;
3980
+ if (!nodes.has(a.id) || nodes.get(a.id).label === a.id) nodes.set(a.id, { label });
3981
+ }
3982
+ }
3983
+ return { nodes, edges };
3984
+ }
3985
+
3986
+ // zflow WebGL renderer — optimized path.
3987
+ //
3988
+ // Architecture:
3989
+ // • One shared static quad geometry (6 verts, never changes).
3990
+ // • Per-node attributes (center, size, color, sel) live in a persistent
3991
+ // instance buffer sized at nodeCap() at init time. We never allocate
3992
+ // per-frame — we update only the slots that the host marked dirty.
3993
+ // • Camera (pan/zoom) is uniform-only, so panning is FREE in buffer terms.
3994
+ // • ANGLE_instanced_arrays draws all nodes in a single drawCall.
3995
+ // • Edges keep a persistent buffer too with dirty tracking + bezier
3996
+ // tesselation regenerated only when an endpoint moves.
3997
+ //
3998
+ // Result: 100k nodes pan/zoom at 60 fps with zero GC. Adding/moving a
3999
+ // single node touches ~28 bytes of GPU memory, not 7 MB.
4000
+
4001
+ const VS = `
4002
+ attribute vec2 aQuad;
4003
+ attribute vec2 aCenter;
4004
+ attribute vec2 aSize;
4005
+ attribute vec3 aColor;
4006
+ attribute float aSelected;
4007
+ uniform vec2 uCam;
4008
+ uniform float uZoom;
4009
+ uniform vec2 uScreen;
4010
+ varying vec3 vColor;
4011
+ varying float vSelected;
4012
+ varying vec2 vUv;
4013
+ void main() {
4014
+ vUv = aQuad;
4015
+ vSelected = aSelected;
4016
+ vColor = aColor;
4017
+ vec2 worldPos = aCenter + aQuad * aSize;
4018
+ vec2 screen = (worldPos + uCam) * uZoom;
4019
+ vec2 ndc = (screen / uScreen) * 2.0;
4020
+ gl_Position = vec4(ndc.x, -ndc.y, 0.0, 1.0);
4021
+ }`;
4022
+
4023
+ const FS = `
4024
+ precision mediump float;
4025
+ varying vec3 vColor;
4026
+ varying float vSelected;
4027
+ varying vec2 vUv;
4028
+ void main() {
4029
+ vec2 q = abs(vUv);
4030
+ float d = max(q.x, q.y);
4031
+ float alpha = smoothstep(1.0, 0.92, d);
4032
+ float header = step(0.7, vUv.y) * 0.18;
4033
+ vec3 col = vColor + vec3(header);
4034
+ if (vSelected > 0.5) col = mix(col, vec3(0.94, 0.73, 0.23), 0.55);
4035
+ gl_FragColor = vec4(col, alpha);
4036
+ }`;
4037
+
4038
+ const EDGE_VS = `
4039
+ attribute vec2 aPos;
4040
+ attribute vec3 aColor;
4041
+ uniform vec2 uCam;
4042
+ uniform float uZoom;
4043
+ uniform vec2 uScreen;
4044
+ varying vec3 vColor;
4045
+ void main() {
4046
+ vColor = aColor;
4047
+ vec2 screen = (aPos + uCam) * uZoom;
4048
+ vec2 ndc = (screen / uScreen) * 2.0;
4049
+ gl_Position = vec4(ndc.x, -ndc.y, 0.0, 1.0);
4050
+ }`;
4051
+
4052
+ const EDGE_FS = `
4053
+ precision mediump float;
4054
+ varying vec3 vColor;
4055
+ void main() { gl_FragColor = vec4(vColor, 0.85); }`;
4056
+
4057
+ const NODE_STRIDE_F = 8; // cx, cy, sw, sh, r, g, b, sel
4058
+ const EDGE_SEGS = 24;
4059
+ const EDGE_VERTS_PER = (EDGE_SEGS) * 2;
4060
+ const EDGE_STRIDE_F = 5; // x, y, r, g, b per vertex
4061
+
4062
+ class WebGLRenderer {
4063
+ constructor(flow) {
4064
+ this.flow = flow;
4065
+ this.glCanvas = document.createElement('canvas');
4066
+ this.glCanvas.style.cssText = `position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:0;`;
4067
+ flow.container.insertBefore(this.glCanvas, flow.canvas);
4068
+ flow.canvas.style.background = 'transparent';
4069
+ flow.canvas.style.position = 'absolute';
4070
+ flow.canvas.style.zIndex = '1';
4071
+ this.gl = this.glCanvas.getContext('webgl', { antialias: true, alpha: true, premultipliedAlpha: false });
4072
+ if (!this.gl) { this.disabled = true; console.warn('zflow: WebGL unavailable'); return; }
4073
+ this.instExt = this.gl.getExtension('ANGLE_instanced_arrays');
4074
+ this.cap = flow.w.nodeCap();
4075
+ this.edgeCap = flow.w.edgeCap();
4076
+ this._resize();
4077
+ this._setupShaders();
4078
+ this._setupBuffers();
4079
+ this._hookDirty();
4080
+ this._resizeObs = new ResizeObserver(() => this._resize());
4081
+ this._resizeObs.observe(flow.container);
4082
+ this._dirty = new Set(); // node ids needing buffer upload
4083
+ this._dirtyEdges = new Set();
4084
+ this._fullRebuildNeeded = true;
4085
+ this._lastNodeCount = 0;
4086
+ this._lastEdgeCount = 0;
4087
+ }
4088
+
4089
+ _hookDirty() {
4090
+ const f = this.flow;
4091
+ f.on('change', () => { this._fullRebuildNeeded = true; });
4092
+ // Hijack moveSelectedBy / moveNode so position changes only mark dirty.
4093
+ const origMove = f.w.moveSelectedBy;
4094
+ if (origMove) {
4095
+ f.w.moveSelectedBy = (dx, dy) => {
4096
+ origMove.call(f.w, dx, dy);
4097
+ for (let i = 0; i < f.w.nodeCount_(); i++) if (f.V.selected[i]) this._dirty.add(i);
4098
+ };
4099
+ }
4100
+ }
4101
+
4102
+ _resize() {
4103
+ const dpr = window.devicePixelRatio || 1;
4104
+ const r = this.flow.container.getBoundingClientRect();
4105
+ this.glCanvas.width = r.width * dpr;
4106
+ this.glCanvas.height = r.height * dpr;
4107
+ this.gl?.viewport(0, 0, this.glCanvas.width, this.glCanvas.height);
4108
+ }
4109
+
4110
+ _setupShaders() {
4111
+ const gl = this.gl;
4112
+ this.progNode = link(gl, VS, FS);
4113
+ this.progEdge = link(gl, EDGE_VS, EDGE_FS);
4114
+ // Cache uniform/attrib locations.
4115
+ this.locN = {
4116
+ aQuad: gl.getAttribLocation(this.progNode, 'aQuad'),
4117
+ aCenter: gl.getAttribLocation(this.progNode, 'aCenter'),
4118
+ aSize: gl.getAttribLocation(this.progNode, 'aSize'),
4119
+ aColor: gl.getAttribLocation(this.progNode, 'aColor'),
4120
+ aSel: gl.getAttribLocation(this.progNode, 'aSelected'),
4121
+ uCam: gl.getUniformLocation(this.progNode, 'uCam'),
4122
+ uZoom: gl.getUniformLocation(this.progNode, 'uZoom'),
4123
+ uScreen: gl.getUniformLocation(this.progNode, 'uScreen'),
4124
+ };
4125
+ this.locE = {
4126
+ aPos: gl.getAttribLocation(this.progEdge, 'aPos'),
4127
+ aColor: gl.getAttribLocation(this.progEdge, 'aColor'),
4128
+ uCam: gl.getUniformLocation(this.progEdge, 'uCam'),
4129
+ uZoom: gl.getUniformLocation(this.progEdge, 'uZoom'),
4130
+ uScreen: gl.getUniformLocation(this.progEdge, 'uScreen'),
4131
+ };
4132
+ }
4133
+
4134
+ _setupBuffers() {
4135
+ const gl = this.gl;
4136
+ // Shared quad: 6 verts, 2 floats each, static.
4137
+ this.quadBuf = gl.createBuffer();
4138
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
4139
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
4140
+ -1, -1, 1, -1, -1, 1,
4141
+ 1, -1, 1, 1, -1, 1,
4142
+ ]), gl.STATIC_DRAW);
4143
+
4144
+ // Per-instance node buffer pre-allocated at full cap.
4145
+ this.nodeData = new Float32Array(this.cap * NODE_STRIDE_F);
4146
+ this.nodeBuf = gl.createBuffer();
4147
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeBuf);
4148
+ gl.bufferData(gl.ARRAY_BUFFER, this.nodeData.byteLength, gl.DYNAMIC_DRAW);
4149
+
4150
+ // Edge buffer pre-allocated.
4151
+ this.edgeData = new Float32Array(this.edgeCap * EDGE_VERTS_PER * EDGE_STRIDE_F);
4152
+ this.edgeBuf = gl.createBuffer();
4153
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.edgeBuf);
4154
+ gl.bufferData(gl.ARRAY_BUFFER, this.edgeData.byteLength, gl.DYNAMIC_DRAW);
4155
+ }
4156
+
4157
+ _writeNode(i) {
4158
+ const f = this.flow;
4159
+ const cat = f.kinds[f.V.kind[i]];
4160
+ const hex = f.colors.get(i) || cat.color;
4161
+ const off = i * NODE_STRIDE_F;
4162
+ this.nodeData[off ] = f.V.posX[i];
4163
+ this.nodeData[off + 1] = f.V.posY[i];
4164
+ this.nodeData[off + 2] = f.V.sizeW[i] * 0.5;
4165
+ this.nodeData[off + 3] = f.V.sizeH[i] * 0.5;
4166
+ const [r, g, b] = parseHex(hex);
4167
+ this.nodeData[off + 4] = r;
4168
+ this.nodeData[off + 5] = g;
4169
+ this.nodeData[off + 6] = b;
4170
+ this.nodeData[off + 7] = f.V.selected[i] !== 0 ? 1 : 0;
4171
+ }
4172
+
4173
+ _writeEdge(i) {
4174
+ const f = this.flow;
4175
+ const a = f.V.edgeFromN[i], b = f.V.edgeToN[i];
4176
+ const ap = f._portWorld(a, 1, f.V.edgeFromP[i]);
4177
+ const bp = f._portWorld(b, 0, f.V.edgeToP[i]);
4178
+ const col = parseHex(f.colors.get(a) || f.kinds[f.V.kind[a]].color);
4179
+ const off = i * EDGE_VERTS_PER * EDGE_STRIDE_F;
4180
+ const ortho = f.options.edgeStyle === 'orthogonal';
4181
+ const offCv = Math.max(50, Math.abs(bp.x - ap.x) * 0.5 + Math.abs(bp.y - ap.y) * 0.4);
4182
+ let prev = { x: ap.x, y: ap.y };
4183
+ let o = off;
4184
+ for (let s = 1; s <= EDGE_SEGS; s++) {
4185
+ const t = s / EDGE_SEGS;
4186
+ let pt;
4187
+ if (ortho) {
4188
+ const mx = (ap.x + bp.x) * 0.5;
4189
+ pt = t < 0.33 ? lerp(ap, { x: mx, y: ap.y }, t / 0.33)
4190
+ : t < 0.67 ? lerp({ x: mx, y: ap.y }, { x: mx, y: bp.y }, (t - 0.33) / 0.34)
4191
+ : lerp({ x: mx, y: bp.y }, bp, (t - 0.67) / 0.33);
4192
+ } else {
4193
+ pt = bezPt(t, ap.x, ap.y, ap.x + offCv, ap.y, bp.x - offCv, bp.y, bp.x, bp.y);
4194
+ }
4195
+ this.edgeData[o++] = prev.x; this.edgeData[o++] = prev.y;
4196
+ this.edgeData[o++] = col[0]; this.edgeData[o++] = col[1]; this.edgeData[o++] = col[2];
4197
+ this.edgeData[o++] = pt.x; this.edgeData[o++] = pt.y;
4198
+ this.edgeData[o++] = col[0]; this.edgeData[o++] = col[1]; this.edgeData[o++] = col[2];
4199
+ prev = pt;
4200
+ }
4201
+ }
4202
+
4203
+ render() {
4204
+ if (this.disabled) return;
4205
+ const gl = this.gl;
4206
+ const f = this.flow;
4207
+ const n = f.w.nodeCount_(), m = f.w.edgeCount_();
4208
+ const dpr = window.devicePixelRatio || 1;
4209
+
4210
+ gl.clearColor(0.027, 0.035, 0.06, 1.0);
4211
+ gl.clear(gl.COLOR_BUFFER_BIT);
4212
+ gl.enable(gl.BLEND);
4213
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
4214
+
4215
+ const camWX = f.cam.x + (this.glCanvas.width / (2 * dpr * f.cam.zoom));
4216
+ const camWY = f.cam.y + (this.glCanvas.height / (2 * dpr * f.cam.zoom));
4217
+
4218
+ // ── Detect what needs upload ────────────────────────────────────
4219
+ if (this._fullRebuildNeeded || n !== this._lastNodeCount) {
4220
+ for (let i = 0; i < n; i++) this._writeNode(i);
4221
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeBuf);
4222
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.nodeData.subarray(0, n * NODE_STRIDE_F));
4223
+ this._dirty.clear();
4224
+ } else if (this._dirty.size) {
4225
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeBuf);
4226
+ // Combine contiguous dirty ranges to minimize bufferSubData calls.
4227
+ const sorted = [...this._dirty].sort((a, b) => a - b);
4228
+ let runStart = sorted[0], runEnd = sorted[0];
4229
+ for (let k = 1; k < sorted.length; k++) {
4230
+ if (sorted[k] === runEnd + 1) runEnd = sorted[k];
4231
+ else {
4232
+ for (let i = runStart; i <= runEnd; i++) this._writeNode(i);
4233
+ gl.bufferSubData(gl.ARRAY_BUFFER, runStart * NODE_STRIDE_F * 4,
4234
+ this.nodeData.subarray(runStart * NODE_STRIDE_F, (runEnd + 1) * NODE_STRIDE_F));
4235
+ runStart = sorted[k]; runEnd = sorted[k];
4236
+ }
4237
+ }
4238
+ for (let i = runStart; i <= runEnd; i++) this._writeNode(i);
4239
+ gl.bufferSubData(gl.ARRAY_BUFFER, runStart * NODE_STRIDE_F * 4,
4240
+ this.nodeData.subarray(runStart * NODE_STRIDE_F, (runEnd + 1) * NODE_STRIDE_F));
4241
+ this._dirty.clear();
4242
+ }
4243
+
4244
+ if (this._fullRebuildNeeded || m !== this._lastEdgeCount || this._dirty.size === 0) {
4245
+ // Edges depend on node positions, but only rebuild on full-rebuild path
4246
+ // since we keep no per-edge incremental delta yet.
4247
+ if (this._fullRebuildNeeded || m !== this._lastEdgeCount) {
4248
+ for (let i = 0; i < m; i++) this._writeEdge(i);
4249
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.edgeBuf);
4250
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.edgeData.subarray(0, m * EDGE_VERTS_PER * EDGE_STRIDE_F));
4251
+ }
4252
+ }
4253
+
4254
+ this._lastNodeCount = n;
4255
+ this._lastEdgeCount = m;
4256
+ this._fullRebuildNeeded = false;
4257
+
4258
+ // ── Draw edges (LINES, persistent buffer) ────────────────────────
4259
+ if (m > 0) {
4260
+ gl.useProgram(this.progEdge);
4261
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.edgeBuf);
4262
+ gl.enableVertexAttribArray(this.locE.aPos);
4263
+ gl.vertexAttribPointer(this.locE.aPos, 2, gl.FLOAT, false, 5 * 4, 0);
4264
+ gl.enableVertexAttribArray(this.locE.aColor);
4265
+ gl.vertexAttribPointer(this.locE.aColor, 3, gl.FLOAT, false, 5 * 4, 2 * 4);
4266
+ gl.uniform2f(this.locE.uCam, camWX, camWY);
4267
+ gl.uniform1f(this.locE.uZoom, f.cam.zoom * dpr);
4268
+ gl.uniform2f(this.locE.uScreen, this.glCanvas.width, this.glCanvas.height);
4269
+ gl.lineWidth(1.6);
4270
+ gl.drawArrays(gl.LINES, 0, m * EDGE_VERTS_PER);
4271
+ }
4272
+
4273
+ // ── Draw nodes ──────────────────────────────────────────────────
4274
+ if (n > 0) {
4275
+ gl.useProgram(this.progNode);
4276
+ // aQuad from static buffer.
4277
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
4278
+ gl.enableVertexAttribArray(this.locN.aQuad);
4279
+ gl.vertexAttribPointer(this.locN.aQuad, 2, gl.FLOAT, false, 0, 0);
4280
+ if (this.instExt) this.instExt.vertexAttribDivisorANGLE(this.locN.aQuad, 0);
4281
+ // per-instance attribs from node buffer.
4282
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeBuf);
4283
+ const s = NODE_STRIDE_F * 4;
4284
+ gl.enableVertexAttribArray(this.locN.aCenter);
4285
+ gl.vertexAttribPointer(this.locN.aCenter, 2, gl.FLOAT, false, s, 0);
4286
+ gl.enableVertexAttribArray(this.locN.aSize);
4287
+ gl.vertexAttribPointer(this.locN.aSize, 2, gl.FLOAT, false, s, 2 * 4);
4288
+ gl.enableVertexAttribArray(this.locN.aColor);
4289
+ gl.vertexAttribPointer(this.locN.aColor, 3, gl.FLOAT, false, s, 4 * 4);
4290
+ gl.enableVertexAttribArray(this.locN.aSel);
4291
+ gl.vertexAttribPointer(this.locN.aSel, 1, gl.FLOAT, false, s, 7 * 4);
4292
+ if (this.instExt) {
4293
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aCenter, 1);
4294
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aSize, 1);
4295
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aColor, 1);
4296
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aSel, 1);
4297
+ }
4298
+ gl.uniform2f(this.locN.uCam, camWX, camWY);
4299
+ gl.uniform1f(this.locN.uZoom, f.cam.zoom * dpr);
4300
+ gl.uniform2f(this.locN.uScreen, this.glCanvas.width, this.glCanvas.height);
4301
+ if (this.instExt) {
4302
+ this.instExt.drawArraysInstancedANGLE(gl.TRIANGLES, 0, 6, n);
4303
+ // Reset divisors so other passes (edges) work correctly.
4304
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aCenter, 0);
4305
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aSize, 0);
4306
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aColor, 0);
4307
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aSel, 0);
4308
+ } else {
4309
+ // Slow path fallback: 6 verts per node, no extension.
4310
+ // (rare; almost every browser since 2014 has the extension)
4311
+ for (let i = 0; i < n; i++) {
4312
+ const off = i * NODE_STRIDE_F;
4313
+ gl.vertexAttrib2f(this.locN.aCenter, this.nodeData[off], this.nodeData[off + 1]);
4314
+ gl.vertexAttrib2f(this.locN.aSize, this.nodeData[off + 2], this.nodeData[off + 3]);
4315
+ gl.vertexAttrib3f(this.locN.aColor, this.nodeData[off + 4], this.nodeData[off + 5], this.nodeData[off + 6]);
4316
+ gl.vertexAttrib1f(this.locN.aSel, this.nodeData[off + 7]);
4317
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
4318
+ }
4319
+ }
4320
+ }
4321
+ }
4322
+
4323
+ /** Mark a node as needing buffer update. Called from host on move/recolor. */
4324
+ markNodeDirty(i) { this._dirty.add(i); }
4325
+ markEdgeDirty(i) { this._dirtyEdges.add(i); this._fullRebuildNeeded = true; }
4326
+ markAllDirty() { this._fullRebuildNeeded = true; }
4327
+
4328
+ dispose() {
4329
+ this._resizeObs?.disconnect();
4330
+ this.glCanvas?.remove();
4331
+ }
4332
+ }
4333
+
4334
+ // ── helpers ───────────────────────────────────────────────────────────────
4335
+ function compile(gl, src, kind) {
4336
+ const s = gl.createShader(kind);
4337
+ gl.shaderSource(s, src); gl.compileShader(s);
4338
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error('GL compile: ' + gl.getShaderInfoLog(s));
4339
+ return s;
4340
+ }
4341
+ function link(gl, vs, fs) {
4342
+ const p = gl.createProgram();
4343
+ gl.attachShader(p, compile(gl, vs, gl.VERTEX_SHADER));
4344
+ gl.attachShader(p, compile(gl, fs, gl.FRAGMENT_SHADER));
4345
+ gl.linkProgram(p);
4346
+ if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error('GL link: ' + gl.getProgramInfoLog(p));
4347
+ return p;
4348
+ }
4349
+ function parseHex(h) {
4350
+ return [parseInt(h.slice(1, 3), 16) / 255, parseInt(h.slice(3, 5), 16) / 255, parseInt(h.slice(5, 7), 16) / 255];
4351
+ }
4352
+ function bezPt(t, x1, y1, cx1, cy1, cx2, cy2, x2, y2) {
4353
+ const mt = 1 - t, mt2 = mt * mt, t2 = t * t;
4354
+ const a = mt2 * mt, b = 3 * mt2 * t, c = 3 * mt * t2, d = t2 * t;
4355
+ return { x: a*x1 + b*cx1 + c*cx2 + d*x2, y: a*y1 + b*cy1 + c*cy2 + d*y2 };
4356
+ }
4357
+ function lerp(a, b, t) { return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; }
4358
+
4359
+ var webglRenderer = /*#__PURE__*/Object.freeze({
4360
+ __proto__: null,
4361
+ WebGLRenderer: WebGLRenderer
4362
+ });
4363
+
4364
+ export { ZFlow, parseDot, parseMermaid };
4365
+ //# sourceMappingURL=zflow.esm.js.map