@luispm/zflow-graph 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/zflow.umd.js CHANGED
@@ -1,4 +1,4 @@
1
- /*! @luispm/zflow-graph v0.1.0 | MIT | (c) 2026 */
1
+ /*! @luispm/zflow-graph v0.2.0 | MIT | (c) 2026 */
2
2
  (function (global, factory) {
3
3
  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
4
4
  typeof define === 'function' && define.amd ? define(['exports'], factory) :
@@ -151,6 +151,10 @@
151
151
  this.titles = new Map();
152
152
  this.colors = new Map();
153
153
  this.descriptions = new Map();
154
+ // Free-form metadata bag, per-node. Consumers stash logical ids, business
155
+ // domain refs, anything they want round-tripped through toJSON/loadJSON
156
+ // without inventing their own Map<zid, ...> side table.
157
+ this.data = new Map();
154
158
  this.tags = new Map();
155
159
  this.status = new Map();
156
160
  this.progress = new Map();
@@ -319,6 +323,7 @@
319
323
  if (spec.links) this.links.set(id, spec.links.map((l) => ({ ...l })));
320
324
  if (spec.portIn) this.portIn.set(id, spec.portIn.slice());
321
325
  if (spec.portOut) this.portOut.set(id, spec.portOut.slice());
326
+ if (spec.data !== undefined) this.data.set(id, spec.data);
322
327
  if (spec.animate !== false) this._nodeAddedAt.set(id, performance.now());
323
328
  if (this._hooks) this._runHook('onNodeAdd', id, spec);
324
329
  this._emit('change');
@@ -333,13 +338,17 @@
333
338
  const wasSilent = this._suspendEvents; this._suspendEvents = true;
334
339
  for (let i = 0; i < specs.length; i++) {
335
340
  const s = specs[i];
336
- const k = this._resolveKind(s.kind);
337
- const id = this.w.addNode(k, s.x ?? 0, s.y ?? 0);
341
+ const k = this._resolveKind(s.kind ?? 'process');
342
+ const cat = this.kinds[k];
343
+ const id = this.w.addNode(
344
+ s.x ?? 0, s.y ?? 0,
345
+ s.w ?? cat.w, s.h ?? cat.h,
346
+ k, s.nin ?? cat.nin, s.nout ?? cat.nout,
347
+ );
338
348
  if (id < 0) { ids[i] = -1; continue; }
339
- if (s.w !== undefined) this.V.sizeW[id] = s.w;
340
- if (s.h !== undefined) this.V.sizeH[id] = s.h;
341
349
  if (s.title) this.titles.set(id, s.title);
342
350
  if (s.color) this.colors.set(id, s.color);
351
+ if (s.data !== undefined) this.data.set(id, s.data);
343
352
  ids[i] = id;
344
353
  }
345
354
  this._suspendEvents = wasSilent;
@@ -387,10 +396,85 @@
387
396
  this._runOrder = null;
388
397
  // Capture dying entities for fade-out before WASM compacts the arrays.
389
398
  this._captureDying();
399
+ // Build pre-compaction remaps so JS-side overlays (titles, data, etc.)
400
+ // stay aligned with the new node ids after WASM slides survivors down.
401
+ const nodeRemap = this._buildNodeRemap();
402
+ const edgeRemap = this._buildEdgeRemap();
390
403
  const n = this.w.deleteSelected();
391
- if (n > 0) { this.w.snapshot(); this._emit('change'); }
404
+ if (n > 0) {
405
+ this._applyNodeRemap(nodeRemap);
406
+ this._applyEdgeRemap(edgeRemap);
407
+ this.w.snapshot();
408
+ this._emit('change');
409
+ }
392
410
  return n;
393
411
  }
412
+ /** Compute Map<oldId, newId|null> matching WASM's deleteSelected compaction. */
413
+ _buildNodeRemap() {
414
+ const n = this.w.nodeCount_();
415
+ const m = new Map();
416
+ let newId = 0;
417
+ for (let i = 0; i < n; i++) {
418
+ if (this.V.selected[i]) m.set(i, null);
419
+ else m.set(i, newId++);
420
+ }
421
+ return m;
422
+ }
423
+ /** Same idea for edges: dropped if either endpoint is selected or edge itself. */
424
+ _buildEdgeRemap() {
425
+ const m = this.w.edgeCount_();
426
+ const out = new Map();
427
+ let newE = 0;
428
+ for (let e = 0; e < m; e++) {
429
+ const a = this.V.edgeFromN[e], b = this.V.edgeToN[e];
430
+ if (this.V.selected[a] || this.V.selected[b] || this.V.edgeSel[e]) out.set(e, null);
431
+ else out.set(e, newE++);
432
+ }
433
+ return out;
434
+ }
435
+ _applyNodeRemap(remap) {
436
+ const nodeMaps = [this.titles, this.colors, this.descriptions, this.tags, this.status,
437
+ this.progress, this.image, this.checked, this.tasks, this.icon,
438
+ this.links, this.portIn, this.portOut, this.zOrder, this.data];
439
+ for (const orig of nodeMaps) this._remapKeyedMap(orig, remap);
440
+ this._remapKeyedSet(this.locked, remap);
441
+ this._remapKeyedSet(this.breakpoints, remap);
442
+ this._remapKeyedMap(this._values, remap);
443
+ this._remapKeyedMap(this._memoKeys, remap);
444
+ this._remapKeyedMap(this.metrics, remap);
445
+ this._remapKeyedMap(this.metricMax, remap);
446
+ // Bookmarks: slot -> nodeId reverse mapping.
447
+ const newBookmarks = new Map();
448
+ for (const [slot, oldId] of this.bookmarks) {
449
+ const newId = remap.get(oldId);
450
+ if (newId != null) newBookmarks.set(slot, newId);
451
+ }
452
+ this.bookmarks = newBookmarks;
453
+ }
454
+ _applyEdgeRemap(remap) {
455
+ this._remapKeyedMap(this.edgeLabels, remap);
456
+ this._remapKeyedSet(this.animatedEdges, remap);
457
+ this._remapKeyedMap(this._edgeWaypoints, remap);
458
+ this._remapKeyedMap(this._activeEdges, remap);
459
+ }
460
+ _remapKeyedMap(map, remap) {
461
+ const next = new Map();
462
+ for (const [oldKey, v] of map) {
463
+ const newKey = remap.get(oldKey);
464
+ if (newKey != null) next.set(newKey, v);
465
+ }
466
+ map.clear();
467
+ for (const [k, v] of next) map.set(k, v);
468
+ }
469
+ _remapKeyedSet(set, remap) {
470
+ const next = new Set();
471
+ for (const oldKey of set) {
472
+ const newKey = remap.get(oldKey);
473
+ if (newKey != null) next.add(newKey);
474
+ }
475
+ set.clear();
476
+ for (const k of next) set.add(k);
477
+ }
394
478
  _captureDying() {
395
479
  const now = performance.now();
396
480
  const nodeWillDie = new Uint8Array(this.w.nodeCount_());
@@ -426,6 +510,53 @@
426
510
  toggleSelected(id) { this.w.toggleSelected(id); this._emit('select', this.getSelection()); }
427
511
  clearSelection() { this.w.clearSelection(); this._emit('select', []); }
428
512
  selectAll() { this.w.selectAll(); this._emit('select', this.getSelection()); }
513
+ /** Replace the entire selection with the given ids (no shift-add semantics). */
514
+ setSelection(ids) {
515
+ this.w.clearSelection();
516
+ if (Array.isArray(ids)) for (const id of ids) if (id >= 0 && id < this.w.nodeCount_()) this.w.setSelected(id, 1);
517
+ this._emit('select', this.getSelection());
518
+ }
519
+ /** Delete a single node by id (does not depend on prior selection). */
520
+ deleteNode(id) {
521
+ if (this.readOnly) return 0;
522
+ if (id < 0 || id >= this.w.nodeCount_()) return 0;
523
+ const prevSel = this.getSelection();
524
+ this.w.clearSelection();
525
+ this.w.setSelected(id, 1);
526
+ const removed = this.deleteSelection();
527
+ // Restore the prior selection minus the deleted node, remapped to new ids.
528
+ if (prevSel.length) {
529
+ this.w.clearSelection();
530
+ for (const old of prevSel) {
531
+ if (old === id) continue;
532
+ const remapped = old > id ? old - 1 : old;
533
+ if (remapped < this.w.nodeCount_()) this.w.setSelected(remapped, 1);
534
+ }
535
+ this._emit('select', this.getSelection());
536
+ }
537
+ return removed;
538
+ }
539
+ /**
540
+ * Run `fn` as an atomic mutation: suppresses intermediate 'change' events
541
+ * and emits a single 'change' at the end. Snapshots once on success. Safe
542
+ * to nest — only the outermost call commits.
543
+ */
544
+ transaction(fn) {
545
+ if (typeof fn !== 'function') return;
546
+ if (this._inTransaction) return fn();
547
+ this._inTransaction = true;
548
+ const prev = this._suspendEvents;
549
+ this._suspendEvents = true;
550
+ let result;
551
+ try { result = fn(); }
552
+ finally {
553
+ this._suspendEvents = prev;
554
+ this._inTransaction = false;
555
+ }
556
+ this.w.snapshot();
557
+ this._emit('change');
558
+ return result;
559
+ }
429
560
  getSelection() {
430
561
  const out = [];
431
562
  const n = this.w.nodeCount_();
@@ -452,6 +583,9 @@
452
583
  setNodeLinks(id, links) { (links && links.length) ? this.links.set(id, links.map((l) => ({ ...l }))) : this.links.delete(id); this._emit('change'); }
453
584
  setPortInLabels(id, arr) { (arr && arr.some(Boolean)) ? this.portIn.set(id, arr.slice()) : this.portIn.delete(id); this._emit('change'); }
454
585
  setPortOutLabels(id, arr) { (arr && arr.some(Boolean)) ? this.portOut.set(id, arr.slice()) : this.portOut.delete(id); this._emit('change'); }
586
+ /** Attach arbitrary data to a node. Round-trips through toJSON/loadJSON. */
587
+ setNodeData(id, data) { data === undefined || data === null ? this.data.delete(id) : this.data.set(id, data); this._emit('change'); }
588
+ getNodeData(id) { return this.data.get(id); }
455
589
 
456
590
  // ── Z-order ───────────────────────────────────────────────────────────
457
591
  _nextZ = 0;
@@ -1962,6 +2096,7 @@
1962
2096
  if (this.tags.has(i)) node.tags = this.tags.get(i);
1963
2097
  if (this.status.has(i)) node.status = this.status.get(i);
1964
2098
  if (this.progress.has(i)) node.progress = this.progress.get(i);
2099
+ if (this.data.has(i)) node.data = this.data.get(i);
1965
2100
  nodes.push(node);
1966
2101
  }
1967
2102
  const edges = [];
@@ -1977,17 +2112,72 @@
1977
2112
  edgeStyle: this.options.edgeStyle,
1978
2113
  };
1979
2114
  }
2115
+ /**
2116
+ * Atomic load: wipes the current graph and inserts `nodes`/`edges` whose
2117
+ * `from`/`to` reference the caller's free-form ids (strings, numbers, refs).
2118
+ *
2119
+ * loadGraph({
2120
+ * nodes: [{ id: 'a', kind: 'process', x: 0, y: 0, title: 'A' }],
2121
+ * edges: [{ from: 'a', to: 'b', label: 'next' }],
2122
+ * })
2123
+ *
2124
+ * Returns Map<userId, zflowId> for the host to keep around if needed —
2125
+ * but you can also rely on `data.id` round-tripping through toJSON, since
2126
+ * each node's `id` (if provided) is also stored under `node.data.__id`.
2127
+ * Single 'change' event and a single undo snapshot, regardless of N.
2128
+ */
2129
+ loadGraph(spec = {}) {
2130
+ const nodes = Array.isArray(spec.nodes) ? spec.nodes : [];
2131
+ const edges = Array.isArray(spec.edges) ? spec.edges : [];
2132
+ const idMap = new Map();
2133
+ this.transaction(() => {
2134
+ this.w.reset();
2135
+ this.titles.clear(); this.colors.clear(); this.descriptions.clear();
2136
+ this.tags.clear(); this.status.clear(); this.progress.clear();
2137
+ this.edgeLabels.clear(); this.data.clear();
2138
+ this.bookmarks.clear(); this.locked.clear(); this.breakpoints.clear();
2139
+ this._values.clear?.();
2140
+ for (const n of nodes) {
2141
+ const userId = n.id;
2142
+ const merged = userId !== undefined
2143
+ ? { ...n, data: { ...(n.data || {}), __id: userId } }
2144
+ : n;
2145
+ const zid = this.addNode(merged);
2146
+ if (zid < 0) continue;
2147
+ if (userId !== undefined) idMap.set(userId, zid);
2148
+ }
2149
+ for (const e of edges) {
2150
+ const a = typeof e.from === 'number' && e.from < this.w.nodeCount_() ? e.from : idMap.get(e.from);
2151
+ const b = typeof e.to === 'number' && e.to < this.w.nodeCount_() ? e.to : idMap.get(e.to);
2152
+ if (a === undefined || b === undefined) continue;
2153
+ this.addEdge({ from: a, to: b, fp: e.fp, tp: e.tp, label: e.label });
2154
+ }
2155
+ });
2156
+ if (this._gl) this._gl.markAllDirty();
2157
+ return idMap;
2158
+ }
2159
+ /** Lookup the zflow id that was assigned to a user-supplied id during loadGraph. */
2160
+ findNodeByUserId(userId) {
2161
+ const n = this.w.nodeCount_();
2162
+ for (let i = 0; i < n; i++) {
2163
+ const d = this.data.get(i);
2164
+ if (d && d.__id === userId) return i;
2165
+ }
2166
+ return -1;
2167
+ }
2168
+
1980
2169
  loadJSON(data) {
1981
2170
  this.w.reset();
1982
2171
  this.titles.clear(); this.colors.clear(); this.descriptions.clear();
1983
2172
  this.tags.clear(); this.status.clear(); this.progress.clear();
1984
- this.edgeLabels.clear();
2173
+ this.edgeLabels.clear(); this.data.clear();
1985
2174
  const idMap = new Map();
1986
2175
  for (const node of (data.nodes || [])) {
1987
2176
  const id = this.addNode({
1988
2177
  kind: node.kind, x: node.x, y: node.y, w: node.w, h: node.h,
1989
2178
  title: node.title, color: node.color, description: node.description,
1990
2179
  tags: node.tags, status: node.status, progress: node.progress,
2180
+ data: node.data,
1991
2181
  });
1992
2182
  idMap.set(node.id ?? id, id);
1993
2183
  }
@@ -2010,53 +2200,161 @@
2010
2200
  /** Build a standalone SVG document representing the current graph. */
2011
2201
  exportSVG() {
2012
2202
  const n = this.w.nodeCount_(), m = this.w.edgeCount_();
2013
- if (n === 0) return '<svg xmlns="http://www.w3.org/2000/svg"/>';
2203
+ if (n === 0 && this.notes.length === 0 && this.frames.length === 0) {
2204
+ return '<svg xmlns="http://www.w3.org/2000/svg"/>';
2205
+ }
2014
2206
  let mnx = Infinity, mxx = -Infinity, mny = Infinity, mxy = -Infinity;
2207
+ const expand = (x0, y0, x1, y1) => {
2208
+ if (x0 < mnx) mnx = x0; if (x1 > mxx) mxx = x1;
2209
+ if (y0 < mny) mny = y0; if (y1 > mxy) mxy = y1;
2210
+ };
2015
2211
  for (let i = 0; i < n; i++) {
2016
2212
  const hw = this.V.sizeW[i] * 0.5, hh = this.V.sizeH[i] * 0.5;
2017
- if (this.V.posX[i] - hw < mnx) mnx = this.V.posX[i] - hw;
2018
- if (this.V.posX[i] + hw > mxx) mxx = this.V.posX[i] + hw;
2019
- if (this.V.posY[i] - hh < mny) mny = this.V.posY[i] - hh;
2020
- if (this.V.posY[i] + hh > mxy) mxy = this.V.posY[i] + hh;
2213
+ expand(this.V.posX[i] - hw, this.V.posY[i] - hh, this.V.posX[i] + hw, this.V.posY[i] + hh);
2021
2214
  }
2215
+ for (const f of this.frames) expand(f.x, f.y, f.x + f.w, f.y + f.h);
2216
+ for (const nt of this.notes) expand(nt.x, nt.y, nt.x + nt.w, nt.y + nt.h);
2022
2217
  const pad = 40;
2023
2218
  const bw = mxx - mnx + pad * 2, bh = mxy - mny + pad * 2;
2024
2219
  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}">`];
2220
+
2221
+ // Defs: gather all edge gradients up front so consumers can serialize cleanly.
2222
+ const defs = [];
2025
2223
  for (let i = 0; i < m; i++) {
2026
2224
  const a = this.V.edgeFromN[i], b = this.V.edgeToN[i];
2027
2225
  const ap = this._portWorld(a, 1, this.V.edgeFromP[i]);
2028
2226
  const bp = this._portWorld(b, 0, this.V.edgeToP[i]);
2029
2227
  const cA = this.colors.get(a) || this.kinds[this.V.kind[a]].color;
2030
2228
  const cB = this.colors.get(b) || this.kinds[this.V.kind[b]].color;
2031
- const gid = `g${i}`;
2032
- 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>`);
2229
+ defs.push(`<linearGradient id="zfg${i}" 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>`);
2230
+ }
2231
+ if (defs.length) out.push(`<defs>${defs.join('')}</defs>`);
2232
+
2233
+ // Frames (background layer): dashed border + translucent header strip + label.
2234
+ for (const f of this.frames) {
2235
+ const fillA = alphaize(f.color, 0.05);
2236
+ const strokeA = alphaize(f.color, 0.45);
2237
+ const headA = alphaize(f.color, 0.16);
2238
+ out.push(`<rect x="${f.x}" y="${f.y}" width="${f.w}" height="${f.h}" rx="12" fill="${fillA}" stroke="${strokeA}" stroke-width="1.4" stroke-dasharray="8 4"/>`);
2239
+ out.push(`<rect x="${f.x}" y="${f.y}" width="${f.w}" height="26" rx="12" fill="${headA}"/>`);
2240
+ out.push(`<text x="${f.x + 10}" y="${f.y + 17}" font-family="Inter, system-ui, sans-serif" font-size="12" font-weight="600" fill="${f.color}">${escapeXml(f.label || '')}</text>`);
2241
+ }
2242
+
2243
+ // Sticky notes.
2244
+ for (const nt of this.notes) {
2245
+ const fill = (nt.color && nt.color.fill) || '#fef9c3';
2246
+ const border = (nt.color && nt.color.border) || '#caa54a';
2247
+ const textCol = (nt.color && nt.color.text) || '#5b3d12';
2248
+ out.push(`<rect x="${nt.x}" y="${nt.y}" width="${nt.w}" height="${nt.h}" rx="4" fill="${fill}" stroke="${border}" stroke-width="1"/>`);
2249
+ if (nt.text) {
2250
+ const lines = wrapTextForSvg(nt.text, nt.w - 20, 7); // rough char-width estimate
2251
+ const startY = nt.y + 18;
2252
+ for (let li = 0; li < lines.length; li++) {
2253
+ out.push(`<text x="${nt.x + 10}" y="${startY + li * 16}" font-family="Inter, system-ui, sans-serif" font-size="12" fill="${textCol}">${escapeXml(lines[li])}</text>`);
2254
+ }
2255
+ }
2256
+ }
2257
+
2258
+ // Edges.
2259
+ for (let i = 0; i < m; i++) {
2260
+ const a = this.V.edgeFromN[i], b = this.V.edgeToN[i];
2261
+ const ap = this._portWorld(a, 1, this.V.edgeFromP[i]);
2262
+ const bp = this._portWorld(b, 0, this.V.edgeToP[i]);
2263
+ const selected = this.V.edgeSel[i] !== 0;
2264
+ const stroke = selected ? '#f0b93a' : `url(#zfg${i})`;
2265
+ const sw = selected ? 2.4 : 1.7;
2266
+ let d;
2033
2267
  if (this.options.edgeStyle === 'orthogonal') {
2034
2268
  const path = this._orthoPath(ap, bp);
2035
- const d = `M ${path[0].x} ${path[0].y} ` + path.slice(1).map((p) => `L ${p.x} ${p.y}`).join(' ');
2036
- out.push(`<path d="${d}" stroke="url(#${gid})" stroke-width="1.7" fill="none" stroke-linejoin="round"/>`);
2269
+ d = `M ${path[0].x} ${path[0].y} ` + path.slice(1).map((p) => `L ${p.x} ${p.y}`).join(' ');
2037
2270
  } else {
2038
2271
  const dx = bp.x - ap.x, dy = bp.y - ap.y;
2039
2272
  const off = Math.max(50, Math.abs(dx) * 0.5 + Math.abs(dy) * 0.4);
2040
- 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"/>`);
2273
+ d = `M ${ap.x} ${ap.y} C ${ap.x + off} ${ap.y} ${bp.x - off} ${bp.y} ${bp.x} ${bp.y}`;
2274
+ }
2275
+ out.push(`<path d="${d}" stroke="${stroke}" stroke-width="${sw}" fill="none" stroke-linejoin="round"/>`);
2276
+ const label = this.edgeLabels.get(i);
2277
+ if (label) {
2278
+ // Midpoint approximation: average of endpoints (close enough for export).
2279
+ const mx = (ap.x + bp.x) / 2, my = (ap.y + bp.y) / 2;
2280
+ const tw = Math.max(20, label.length * 6.5);
2281
+ out.push(`<rect x="${mx - tw / 2 - 5}" y="${my - 9}" width="${tw + 10}" height="16" rx="5" fill="#0b0f17" stroke="${selected ? '#f0b93a' : alphaize(this.colors.get(a) || this.kinds[this.V.kind[a]].color, 0.6)}" stroke-width="1"/>`);
2282
+ out.push(`<text x="${mx}" y="${my + 3}" font-family="ui-monospace, Consolas, monospace" font-size="10.5" font-weight="600" fill="#e6edf3" text-anchor="middle">${escapeXml(label)}</text>`);
2041
2283
  }
2042
2284
  }
2285
+
2286
+ // Nodes.
2043
2287
  for (let i = 0; i < n; i++) {
2044
2288
  const cat = this.kinds[this.V.kind[i]];
2045
2289
  const color = this.colors.get(i) || cat.color;
2046
2290
  const x = this.V.posX[i] - this.V.sizeW[i] / 2;
2047
2291
  const y = this.V.posY[i] - this.V.sizeH[i] / 2;
2048
2292
  const w = this.V.sizeW[i], h = this.V.sizeH[i];
2293
+ const sel = this.V.selected[i] !== 0;
2294
+ const borderColor = sel ? '#f0b93a' : color;
2295
+ const borderW = sel ? 2 : 1.4;
2049
2296
  if (cat.shape === 'diamond') {
2050
2297
  const cx = this.V.posX[i], cy = this.V.posY[i];
2051
- out.push(`<polygon points="${cx},${y} ${x+w},${cy} ${cx},${y+h} ${x},${cy}" fill="#161b27" stroke="${color}" stroke-width="1.4"/>`);
2298
+ out.push(`<polygon points="${cx},${y} ${x+w},${cy} ${cx},${y+h} ${x},${cy}" fill="#161b27" stroke="${borderColor}" stroke-width="${borderW}"/>`);
2052
2299
  } else if (cat.shape === 'ellipse') {
2053
- 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"/>`);
2300
+ out.push(`<ellipse cx="${this.V.posX[i]}" cy="${this.V.posY[i]}" rx="${w/2}" ry="${h/2}" fill="#161b27" stroke="${borderColor}" stroke-width="${borderW}"/>`);
2301
+ } else if (cat.shape === 'hexagon') {
2302
+ const cx = this.V.posX[i], cy = this.V.posY[i];
2303
+ const hw = w / 2, a = hw * 0.45;
2304
+ out.push(`<polygon points="${cx - hw + a},${y} ${cx + hw - a},${y} ${x + w},${cy} ${cx + hw - a},${y + h} ${cx - hw + a},${y + h} ${x},${cy}" fill="#161b27" stroke="${borderColor}" stroke-width="${borderW}"/>`);
2054
2305
  } else {
2055
- out.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="#161b27" stroke="${color}" stroke-width="1.4"/>`);
2056
- if (cat.shape === 'rect') out.push(`<rect x="${x}" y="${y}" width="${w}" height="22" rx="8" fill="${color}"/>`);
2306
+ out.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="#161b27" stroke="${borderColor}" stroke-width="${borderW}"/>`);
2307
+ if (cat.shape === 'rect') {
2308
+ out.push(`<path d="M ${x + 8} ${y} L ${x + w - 8} ${y} Q ${x + w} ${y} ${x + w} ${y + 8} L ${x + w} ${y + 22} L ${x} ${y + 22} L ${x} ${y + 8} Q ${x} ${y} ${x + 8} ${y} Z" fill="${color}"/>`);
2309
+ }
2057
2310
  }
2058
2311
  const title = this.titles.get(i) || `${cat.name} #${i}`;
2059
- 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>`);
2312
+ const titleY = cat.shape === 'rect' ? y + 14 : y + h / 2;
2313
+ const titleFill = cat.shape === 'rect' ? '#0b0f17' : color;
2314
+ out.push(`<text x="${this.V.posX[i]}" y="${titleY}" font-family="Inter, system-ui, sans-serif" font-size="11" font-weight="600" text-anchor="middle" fill="${titleFill}" dominant-baseline="middle">${escapeXml(title)}</text>`);
2315
+
2316
+ // Progress bar (bottom strip of rect bodies).
2317
+ const prog = this.progress.get(i);
2318
+ if (prog !== undefined && prog > 0 && cat.shape === 'rect') {
2319
+ const barY = y + h - 5;
2320
+ const barX = x + 8, barW = w - 16, barH = 3;
2321
+ const fillW = barW * Math.min(1, Math.max(0, prog));
2322
+ out.push(`<rect x="${barX}" y="${barY}" width="${barW}" height="${barH}" fill="rgba(255,255,255,0.06)"/>`);
2323
+ out.push(`<rect x="${barX}" y="${barY}" width="${fillW}" height="${barH}" fill="${color}"/>`);
2324
+ }
2325
+
2326
+ // Status dot (top-right of header for rect shapes).
2327
+ const st = this.status.get(i);
2328
+ if (st && cat.shape === 'rect') {
2329
+ const sCol = STATUS_COLORS[st] || '#8b95a7';
2330
+ out.push(`<circle cx="${x + w - 12}" cy="${y + 11}" r="3.5" fill="${sCol}"/>`);
2331
+ }
2332
+
2333
+ // Tags.
2334
+ const tags = this.tags.get(i);
2335
+ if (tags && tags.length) {
2336
+ let tx = x + 8;
2337
+ const ty = y + h - 24;
2338
+ const tagFill = alphaize(color, 0.18);
2339
+ for (const tag of tags) {
2340
+ const tw = tag.length * 6 + 12;
2341
+ if (tx + tw > x + w - 8) break;
2342
+ out.push(`<rect x="${tx}" y="${ty}" width="${tw}" height="14" rx="3" fill="${tagFill}"/>`);
2343
+ out.push(`<text x="${tx + tw / 2}" y="${ty + 7}" font-family="Inter, system-ui, sans-serif" font-size="9" text-anchor="middle" fill="${color}" dominant-baseline="middle">${escapeXml(tag)}</text>`);
2344
+ tx += tw + 4;
2345
+ }
2346
+ }
2347
+
2348
+ // Ports (circles on left/right edges).
2349
+ const ni = this.V.nIn[i], no = this.V.nOut[i];
2350
+ for (let p = 0; p < ni; p++) {
2351
+ const py = y + h * ((p + 1) / (ni + 1));
2352
+ out.push(`<circle cx="${x}" cy="${py}" r="4.5" fill="${color}" stroke="#07090f" stroke-width="1.5"/>`);
2353
+ }
2354
+ for (let p = 0; p < no; p++) {
2355
+ const py = y + h * ((p + 1) / (no + 1));
2356
+ out.push(`<circle cx="${x + w}" cy="${py}" r="4.5" fill="${color}" stroke="#07090f" stroke-width="1.5"/>`);
2357
+ }
2060
2358
  }
2061
2359
  out.push('</svg>');
2062
2360
  return out.join('\n');
@@ -2069,7 +2367,7 @@
2069
2367
  return () => { const arr = this.listeners.get(event); const i = arr.indexOf(fn); if (i >= 0) arr.splice(i, 1); };
2070
2368
  }
2071
2369
  _emit(event, ...args) {
2072
- if (this._suspendEvents && event !== 'change') return;
2370
+ if (this._suspendEvents) return;
2073
2371
  const arr = this.listeners.get(event);
2074
2372
  if (!arr) return;
2075
2373
  for (const fn of arr.slice()) try { fn(...args); } catch (e) { console.error(e); }
@@ -2095,6 +2393,18 @@
2095
2393
  return { x: (sx - this.canvas.width / 2) / this.cam.zoom - this.cam.x,
2096
2394
  y: (sy - this.canvas.height / 2) / this.cam.zoom - this.cam.y };
2097
2395
  }
2396
+ /** Public coordinate helpers. Internal `_w2s`/`_s2w` kept as aliases. */
2397
+ worldToScreen(wx, wy) { return this._w2s(wx, wy); }
2398
+ screenToWorld(cx, cy) { return this._s2w(cx, cy); }
2399
+ /** Read-only camera snapshot — preferred over reading `flow.cam` directly. */
2400
+ getCamera() { return { x: this.cam.x, y: this.cam.y, zoom: this.cam.zoom }; }
2401
+ /** Public node geometry accessor. Returns null if id is out of range. */
2402
+ getNodePosition(id) {
2403
+ if (id < 0 || id >= this.w.nodeCount_()) return null;
2404
+ return { x: this.V.posX[id], y: this.V.posY[id], w: this.V.sizeW[id], h: this.V.sizeH[id] };
2405
+ }
2406
+ /** Open the inline title editor for a node. */
2407
+ startEditTitle(id) { if (id >= 0 && id < this.w.nodeCount_()) this._startEditingTitle(id); }
2098
2408
 
2099
2409
  // ── Interactions ──────────────────────────────────────────────────────
2100
2410
  _attachEvents() {
@@ -2553,6 +2863,7 @@
2553
2863
  title: this.titles.get(i), color: this.colors.get(i),
2554
2864
  description: this.descriptions.get(i), tags: this.tags.get(i),
2555
2865
  status: this.status.get(i), progress: this.progress.get(i),
2866
+ data: this.data.get(i),
2556
2867
  }));
2557
2868
  const edges = [];
2558
2869
  for (let e = 0; e < this.w.edgeCount_(); e++) {
@@ -2578,6 +2889,7 @@
2578
2889
  kind: n.kind, x: n.x + dx, y: n.y + dy, w: n.w, h: n.h,
2579
2890
  title: n.title, color: n.color, description: n.description,
2580
2891
  tags: n.tags, status: n.status, progress: n.progress,
2892
+ data: n.data,
2581
2893
  });
2582
2894
  idMap.set(n.origId, id);
2583
2895
  this.w.setSelected(id, 1);
@@ -3905,6 +4217,23 @@
3905
4217
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
3906
4218
  }
3907
4219
  function escapeXml(s) { return escapeHtml(s); }
4220
+ // Rough word-wrap for SVG export. avgCharPx is a coarse estimate (~7 px per
4221
+ // char at 12px font) since SVG export has no live metrics to measure against.
4222
+ function wrapTextForSvg(text, maxWidth, avgCharPx = 7) {
4223
+ const lines = [];
4224
+ const maxChars = Math.max(4, Math.floor(maxWidth / avgCharPx));
4225
+ for (const para of String(text).split('\n')) {
4226
+ if (para.length <= maxChars) { lines.push(para); continue; }
4227
+ let cur = '';
4228
+ for (const word of para.split(/\s+/)) {
4229
+ const test = cur ? cur + ' ' + word : word;
4230
+ if (test.length > maxChars && cur) { lines.push(cur); cur = word; }
4231
+ else cur = test;
4232
+ }
4233
+ if (cur) lines.push(cur);
4234
+ }
4235
+ return lines;
4236
+ }
3908
4237
 
3909
4238
  // ── Mermaid + DOT importers ───────────────────────────────────────────────
3910
4239
  function parseMermaid(text) {
@@ -4100,7 +4429,24 @@ void main() { gl_FragColor = vec4(vColor, 0.85); }`;
4100
4429
  if (origMove) {
4101
4430
  f.w.moveSelectedBy = (dx, dy) => {
4102
4431
  origMove.call(f.w, dx, dy);
4103
- for (let i = 0; i < f.w.nodeCount_(); i++) if (f.V.selected[i]) this._dirty.add(i);
4432
+ f._ensureAdj?.();
4433
+ const adj = f._nodeAdj;
4434
+ for (let i = 0; i < f.w.nodeCount_(); i++) {
4435
+ if (!f.V.selected[i]) continue;
4436
+ this._dirty.add(i);
4437
+ // Edges incident on a moved node need their geometry recomputed.
4438
+ if (adj && adj[i]) for (let k = 0; k < adj[i].length; k++) this._dirtyEdges.add(adj[i][k]);
4439
+ }
4440
+ };
4441
+ }
4442
+ const origMoveNode = f.w.moveNode;
4443
+ if (origMoveNode) {
4444
+ f.w.moveNode = (id, x, y) => {
4445
+ origMoveNode.call(f.w, id, x, y);
4446
+ this._dirty.add(id);
4447
+ f._ensureAdj?.();
4448
+ const adj = f._nodeAdj;
4449
+ if (adj && adj[id]) for (let k = 0; k < adj[id].length; k++) this._dirtyEdges.add(adj[id][k]);
4104
4450
  };
4105
4451
  }
4106
4452
  }
@@ -4222,39 +4568,27 @@ void main() { gl_FragColor = vec4(vColor, 0.85); }`;
4222
4568
  const camWY = f.cam.y + (this.glCanvas.height / (2 * dpr * f.cam.zoom));
4223
4569
 
4224
4570
  // ── Detect what needs upload ────────────────────────────────────
4225
- if (this._fullRebuildNeeded || n !== this._lastNodeCount) {
4571
+ const nodeStride = NODE_STRIDE_F;
4572
+ const edgeStride = EDGE_VERTS_PER * EDGE_STRIDE_F;
4573
+ const fullNodes = this._fullRebuildNeeded || n !== this._lastNodeCount;
4574
+ const fullEdges = this._fullRebuildNeeded || m !== this._lastEdgeCount;
4575
+ if (fullNodes) {
4226
4576
  for (let i = 0; i < n; i++) this._writeNode(i);
4227
4577
  gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeBuf);
4228
- gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.nodeData.subarray(0, n * NODE_STRIDE_F));
4578
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.nodeData.subarray(0, n * nodeStride));
4229
4579
  this._dirty.clear();
4230
4580
  } else if (this._dirty.size) {
4231
- gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeBuf);
4232
- // Combine contiguous dirty ranges to minimize bufferSubData calls.
4233
- const sorted = [...this._dirty].sort((a, b) => a - b);
4234
- let runStart = sorted[0], runEnd = sorted[0];
4235
- for (let k = 1; k < sorted.length; k++) {
4236
- if (sorted[k] === runEnd + 1) runEnd = sorted[k];
4237
- else {
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
- runStart = sorted[k]; runEnd = sorted[k];
4242
- }
4243
- }
4244
- for (let i = runStart; i <= runEnd; i++) this._writeNode(i);
4245
- gl.bufferSubData(gl.ARRAY_BUFFER, runStart * NODE_STRIDE_F * 4,
4246
- this.nodeData.subarray(runStart * NODE_STRIDE_F, (runEnd + 1) * NODE_STRIDE_F));
4247
- this._dirty.clear();
4581
+ this._uploadRuns(this._dirty, this.nodeBuf, this.nodeData, nodeStride, (i) => this._writeNode(i));
4248
4582
  }
4249
-
4250
- if (this._fullRebuildNeeded || m !== this._lastEdgeCount || this._dirty.size === 0) {
4251
- // Edges depend on node positions, but only rebuild on full-rebuild path
4252
- // since we keep no per-edge incremental delta yet.
4253
- if (this._fullRebuildNeeded || m !== this._lastEdgeCount) {
4254
- for (let i = 0; i < m; i++) this._writeEdge(i);
4255
- gl.bindBuffer(gl.ARRAY_BUFFER, this.edgeBuf);
4256
- gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.edgeData.subarray(0, m * EDGE_VERTS_PER * EDGE_STRIDE_F));
4257
- }
4583
+ if (fullEdges) {
4584
+ for (let i = 0; i < m; i++) this._writeEdge(i);
4585
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.edgeBuf);
4586
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.edgeData.subarray(0, m * edgeStride));
4587
+ this._dirtyEdges.clear();
4588
+ } else if (this._dirtyEdges.size) {
4589
+ // Filter out edges that no longer exist (deletes shift the buffer end).
4590
+ for (const e of this._dirtyEdges) if (e >= m) this._dirtyEdges.delete(e);
4591
+ this._uploadRuns(this._dirtyEdges, this.edgeBuf, this.edgeData, edgeStride, (i) => this._writeEdge(i));
4258
4592
  }
4259
4593
 
4260
4594
  this._lastNodeCount = n;
@@ -4328,9 +4662,28 @@ void main() { gl_FragColor = vec4(vColor, 0.85); }`;
4328
4662
 
4329
4663
  /** Mark a node as needing buffer update. Called from host on move/recolor. */
4330
4664
  markNodeDirty(i) { this._dirty.add(i); }
4331
- markEdgeDirty(i) { this._dirtyEdges.add(i); this._fullRebuildNeeded = true; }
4665
+ markEdgeDirty(i) { this._dirtyEdges.add(i); }
4332
4666
  markAllDirty() { this._fullRebuildNeeded = true; }
4333
4667
 
4668
+ /** Upload a dirty Set by collapsing it into contiguous runs of `stride` floats. */
4669
+ _uploadRuns(set, buf, dataArr, stride, writeOne) {
4670
+ const gl = this.gl;
4671
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
4672
+ const sorted = [...set].sort((a, b) => a - b);
4673
+ let runStart = sorted[0], runEnd = sorted[0];
4674
+ for (let k = 1; k < sorted.length; k++) {
4675
+ if (sorted[k] === runEnd + 1) { runEnd = sorted[k]; continue; }
4676
+ for (let i = runStart; i <= runEnd; i++) writeOne(i);
4677
+ gl.bufferSubData(gl.ARRAY_BUFFER, runStart * stride * 4,
4678
+ dataArr.subarray(runStart * stride, (runEnd + 1) * stride));
4679
+ runStart = sorted[k]; runEnd = sorted[k];
4680
+ }
4681
+ for (let i = runStart; i <= runEnd; i++) writeOne(i);
4682
+ gl.bufferSubData(gl.ARRAY_BUFFER, runStart * stride * 4,
4683
+ dataArr.subarray(runStart * stride, (runEnd + 1) * stride));
4684
+ set.clear();
4685
+ }
4686
+
4334
4687
  dispose() {
4335
4688
  this._resizeObs?.disconnect();
4336
4689
  this.glCanvas?.remove();