@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.esm.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
  // zflow — node-edge graph editor library (full version).
3
3
  //
4
4
  // Single ES module. Consumers do:
@@ -145,6 +145,10 @@ class ZFlow {
145
145
  this.titles = new Map();
146
146
  this.colors = new Map();
147
147
  this.descriptions = new Map();
148
+ // Free-form metadata bag, per-node. Consumers stash logical ids, business
149
+ // domain refs, anything they want round-tripped through toJSON/loadJSON
150
+ // without inventing their own Map<zid, ...> side table.
151
+ this.data = new Map();
148
152
  this.tags = new Map();
149
153
  this.status = new Map();
150
154
  this.progress = new Map();
@@ -313,6 +317,7 @@ class ZFlow {
313
317
  if (spec.links) this.links.set(id, spec.links.map((l) => ({ ...l })));
314
318
  if (spec.portIn) this.portIn.set(id, spec.portIn.slice());
315
319
  if (spec.portOut) this.portOut.set(id, spec.portOut.slice());
320
+ if (spec.data !== undefined) this.data.set(id, spec.data);
316
321
  if (spec.animate !== false) this._nodeAddedAt.set(id, performance.now());
317
322
  if (this._hooks) this._runHook('onNodeAdd', id, spec);
318
323
  this._emit('change');
@@ -327,13 +332,17 @@ class ZFlow {
327
332
  const wasSilent = this._suspendEvents; this._suspendEvents = true;
328
333
  for (let i = 0; i < specs.length; i++) {
329
334
  const s = specs[i];
330
- const k = this._resolveKind(s.kind);
331
- const id = this.w.addNode(k, s.x ?? 0, s.y ?? 0);
335
+ const k = this._resolveKind(s.kind ?? 'process');
336
+ const cat = this.kinds[k];
337
+ const id = this.w.addNode(
338
+ s.x ?? 0, s.y ?? 0,
339
+ s.w ?? cat.w, s.h ?? cat.h,
340
+ k, s.nin ?? cat.nin, s.nout ?? cat.nout,
341
+ );
332
342
  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
343
  if (s.title) this.titles.set(id, s.title);
336
344
  if (s.color) this.colors.set(id, s.color);
345
+ if (s.data !== undefined) this.data.set(id, s.data);
337
346
  ids[i] = id;
338
347
  }
339
348
  this._suspendEvents = wasSilent;
@@ -381,10 +390,85 @@ class ZFlow {
381
390
  this._runOrder = null;
382
391
  // Capture dying entities for fade-out before WASM compacts the arrays.
383
392
  this._captureDying();
393
+ // Build pre-compaction remaps so JS-side overlays (titles, data, etc.)
394
+ // stay aligned with the new node ids after WASM slides survivors down.
395
+ const nodeRemap = this._buildNodeRemap();
396
+ const edgeRemap = this._buildEdgeRemap();
384
397
  const n = this.w.deleteSelected();
385
- if (n > 0) { this.w.snapshot(); this._emit('change'); }
398
+ if (n > 0) {
399
+ this._applyNodeRemap(nodeRemap);
400
+ this._applyEdgeRemap(edgeRemap);
401
+ this.w.snapshot();
402
+ this._emit('change');
403
+ }
386
404
  return n;
387
405
  }
406
+ /** Compute Map<oldId, newId|null> matching WASM's deleteSelected compaction. */
407
+ _buildNodeRemap() {
408
+ const n = this.w.nodeCount_();
409
+ const m = new Map();
410
+ let newId = 0;
411
+ for (let i = 0; i < n; i++) {
412
+ if (this.V.selected[i]) m.set(i, null);
413
+ else m.set(i, newId++);
414
+ }
415
+ return m;
416
+ }
417
+ /** Same idea for edges: dropped if either endpoint is selected or edge itself. */
418
+ _buildEdgeRemap() {
419
+ const m = this.w.edgeCount_();
420
+ const out = new Map();
421
+ let newE = 0;
422
+ for (let e = 0; e < m; e++) {
423
+ const a = this.V.edgeFromN[e], b = this.V.edgeToN[e];
424
+ if (this.V.selected[a] || this.V.selected[b] || this.V.edgeSel[e]) out.set(e, null);
425
+ else out.set(e, newE++);
426
+ }
427
+ return out;
428
+ }
429
+ _applyNodeRemap(remap) {
430
+ const nodeMaps = [this.titles, this.colors, this.descriptions, this.tags, this.status,
431
+ this.progress, this.image, this.checked, this.tasks, this.icon,
432
+ this.links, this.portIn, this.portOut, this.zOrder, this.data];
433
+ for (const orig of nodeMaps) this._remapKeyedMap(orig, remap);
434
+ this._remapKeyedSet(this.locked, remap);
435
+ this._remapKeyedSet(this.breakpoints, remap);
436
+ this._remapKeyedMap(this._values, remap);
437
+ this._remapKeyedMap(this._memoKeys, remap);
438
+ this._remapKeyedMap(this.metrics, remap);
439
+ this._remapKeyedMap(this.metricMax, remap);
440
+ // Bookmarks: slot -> nodeId reverse mapping.
441
+ const newBookmarks = new Map();
442
+ for (const [slot, oldId] of this.bookmarks) {
443
+ const newId = remap.get(oldId);
444
+ if (newId != null) newBookmarks.set(slot, newId);
445
+ }
446
+ this.bookmarks = newBookmarks;
447
+ }
448
+ _applyEdgeRemap(remap) {
449
+ this._remapKeyedMap(this.edgeLabels, remap);
450
+ this._remapKeyedSet(this.animatedEdges, remap);
451
+ this._remapKeyedMap(this._edgeWaypoints, remap);
452
+ this._remapKeyedMap(this._activeEdges, remap);
453
+ }
454
+ _remapKeyedMap(map, remap) {
455
+ const next = new Map();
456
+ for (const [oldKey, v] of map) {
457
+ const newKey = remap.get(oldKey);
458
+ if (newKey != null) next.set(newKey, v);
459
+ }
460
+ map.clear();
461
+ for (const [k, v] of next) map.set(k, v);
462
+ }
463
+ _remapKeyedSet(set, remap) {
464
+ const next = new Set();
465
+ for (const oldKey of set) {
466
+ const newKey = remap.get(oldKey);
467
+ if (newKey != null) next.add(newKey);
468
+ }
469
+ set.clear();
470
+ for (const k of next) set.add(k);
471
+ }
388
472
  _captureDying() {
389
473
  const now = performance.now();
390
474
  const nodeWillDie = new Uint8Array(this.w.nodeCount_());
@@ -420,6 +504,53 @@ class ZFlow {
420
504
  toggleSelected(id) { this.w.toggleSelected(id); this._emit('select', this.getSelection()); }
421
505
  clearSelection() { this.w.clearSelection(); this._emit('select', []); }
422
506
  selectAll() { this.w.selectAll(); this._emit('select', this.getSelection()); }
507
+ /** Replace the entire selection with the given ids (no shift-add semantics). */
508
+ setSelection(ids) {
509
+ this.w.clearSelection();
510
+ if (Array.isArray(ids)) for (const id of ids) if (id >= 0 && id < this.w.nodeCount_()) this.w.setSelected(id, 1);
511
+ this._emit('select', this.getSelection());
512
+ }
513
+ /** Delete a single node by id (does not depend on prior selection). */
514
+ deleteNode(id) {
515
+ if (this.readOnly) return 0;
516
+ if (id < 0 || id >= this.w.nodeCount_()) return 0;
517
+ const prevSel = this.getSelection();
518
+ this.w.clearSelection();
519
+ this.w.setSelected(id, 1);
520
+ const removed = this.deleteSelection();
521
+ // Restore the prior selection minus the deleted node, remapped to new ids.
522
+ if (prevSel.length) {
523
+ this.w.clearSelection();
524
+ for (const old of prevSel) {
525
+ if (old === id) continue;
526
+ const remapped = old > id ? old - 1 : old;
527
+ if (remapped < this.w.nodeCount_()) this.w.setSelected(remapped, 1);
528
+ }
529
+ this._emit('select', this.getSelection());
530
+ }
531
+ return removed;
532
+ }
533
+ /**
534
+ * Run `fn` as an atomic mutation: suppresses intermediate 'change' events
535
+ * and emits a single 'change' at the end. Snapshots once on success. Safe
536
+ * to nest — only the outermost call commits.
537
+ */
538
+ transaction(fn) {
539
+ if (typeof fn !== 'function') return;
540
+ if (this._inTransaction) return fn();
541
+ this._inTransaction = true;
542
+ const prev = this._suspendEvents;
543
+ this._suspendEvents = true;
544
+ let result;
545
+ try { result = fn(); }
546
+ finally {
547
+ this._suspendEvents = prev;
548
+ this._inTransaction = false;
549
+ }
550
+ this.w.snapshot();
551
+ this._emit('change');
552
+ return result;
553
+ }
423
554
  getSelection() {
424
555
  const out = [];
425
556
  const n = this.w.nodeCount_();
@@ -446,6 +577,9 @@ class ZFlow {
446
577
  setNodeLinks(id, links) { (links && links.length) ? this.links.set(id, links.map((l) => ({ ...l }))) : this.links.delete(id); this._emit('change'); }
447
578
  setPortInLabels(id, arr) { (arr && arr.some(Boolean)) ? this.portIn.set(id, arr.slice()) : this.portIn.delete(id); this._emit('change'); }
448
579
  setPortOutLabels(id, arr) { (arr && arr.some(Boolean)) ? this.portOut.set(id, arr.slice()) : this.portOut.delete(id); this._emit('change'); }
580
+ /** Attach arbitrary data to a node. Round-trips through toJSON/loadJSON. */
581
+ setNodeData(id, data) { data === undefined || data === null ? this.data.delete(id) : this.data.set(id, data); this._emit('change'); }
582
+ getNodeData(id) { return this.data.get(id); }
449
583
 
450
584
  // ── Z-order ───────────────────────────────────────────────────────────
451
585
  _nextZ = 0;
@@ -1956,6 +2090,7 @@ class ZFlow {
1956
2090
  if (this.tags.has(i)) node.tags = this.tags.get(i);
1957
2091
  if (this.status.has(i)) node.status = this.status.get(i);
1958
2092
  if (this.progress.has(i)) node.progress = this.progress.get(i);
2093
+ if (this.data.has(i)) node.data = this.data.get(i);
1959
2094
  nodes.push(node);
1960
2095
  }
1961
2096
  const edges = [];
@@ -1971,17 +2106,72 @@ class ZFlow {
1971
2106
  edgeStyle: this.options.edgeStyle,
1972
2107
  };
1973
2108
  }
2109
+ /**
2110
+ * Atomic load: wipes the current graph and inserts `nodes`/`edges` whose
2111
+ * `from`/`to` reference the caller's free-form ids (strings, numbers, refs).
2112
+ *
2113
+ * loadGraph({
2114
+ * nodes: [{ id: 'a', kind: 'process', x: 0, y: 0, title: 'A' }],
2115
+ * edges: [{ from: 'a', to: 'b', label: 'next' }],
2116
+ * })
2117
+ *
2118
+ * Returns Map<userId, zflowId> for the host to keep around if needed —
2119
+ * but you can also rely on `data.id` round-tripping through toJSON, since
2120
+ * each node's `id` (if provided) is also stored under `node.data.__id`.
2121
+ * Single 'change' event and a single undo snapshot, regardless of N.
2122
+ */
2123
+ loadGraph(spec = {}) {
2124
+ const nodes = Array.isArray(spec.nodes) ? spec.nodes : [];
2125
+ const edges = Array.isArray(spec.edges) ? spec.edges : [];
2126
+ const idMap = new Map();
2127
+ this.transaction(() => {
2128
+ this.w.reset();
2129
+ this.titles.clear(); this.colors.clear(); this.descriptions.clear();
2130
+ this.tags.clear(); this.status.clear(); this.progress.clear();
2131
+ this.edgeLabels.clear(); this.data.clear();
2132
+ this.bookmarks.clear(); this.locked.clear(); this.breakpoints.clear();
2133
+ this._values.clear?.();
2134
+ for (const n of nodes) {
2135
+ const userId = n.id;
2136
+ const merged = userId !== undefined
2137
+ ? { ...n, data: { ...(n.data || {}), __id: userId } }
2138
+ : n;
2139
+ const zid = this.addNode(merged);
2140
+ if (zid < 0) continue;
2141
+ if (userId !== undefined) idMap.set(userId, zid);
2142
+ }
2143
+ for (const e of edges) {
2144
+ const a = typeof e.from === 'number' && e.from < this.w.nodeCount_() ? e.from : idMap.get(e.from);
2145
+ const b = typeof e.to === 'number' && e.to < this.w.nodeCount_() ? e.to : idMap.get(e.to);
2146
+ if (a === undefined || b === undefined) continue;
2147
+ this.addEdge({ from: a, to: b, fp: e.fp, tp: e.tp, label: e.label });
2148
+ }
2149
+ });
2150
+ if (this._gl) this._gl.markAllDirty();
2151
+ return idMap;
2152
+ }
2153
+ /** Lookup the zflow id that was assigned to a user-supplied id during loadGraph. */
2154
+ findNodeByUserId(userId) {
2155
+ const n = this.w.nodeCount_();
2156
+ for (let i = 0; i < n; i++) {
2157
+ const d = this.data.get(i);
2158
+ if (d && d.__id === userId) return i;
2159
+ }
2160
+ return -1;
2161
+ }
2162
+
1974
2163
  loadJSON(data) {
1975
2164
  this.w.reset();
1976
2165
  this.titles.clear(); this.colors.clear(); this.descriptions.clear();
1977
2166
  this.tags.clear(); this.status.clear(); this.progress.clear();
1978
- this.edgeLabels.clear();
2167
+ this.edgeLabels.clear(); this.data.clear();
1979
2168
  const idMap = new Map();
1980
2169
  for (const node of (data.nodes || [])) {
1981
2170
  const id = this.addNode({
1982
2171
  kind: node.kind, x: node.x, y: node.y, w: node.w, h: node.h,
1983
2172
  title: node.title, color: node.color, description: node.description,
1984
2173
  tags: node.tags, status: node.status, progress: node.progress,
2174
+ data: node.data,
1985
2175
  });
1986
2176
  idMap.set(node.id ?? id, id);
1987
2177
  }
@@ -2004,53 +2194,161 @@ class ZFlow {
2004
2194
  /** Build a standalone SVG document representing the current graph. */
2005
2195
  exportSVG() {
2006
2196
  const n = this.w.nodeCount_(), m = this.w.edgeCount_();
2007
- if (n === 0) return '<svg xmlns="http://www.w3.org/2000/svg"/>';
2197
+ if (n === 0 && this.notes.length === 0 && this.frames.length === 0) {
2198
+ return '<svg xmlns="http://www.w3.org/2000/svg"/>';
2199
+ }
2008
2200
  let mnx = Infinity, mxx = -Infinity, mny = Infinity, mxy = -Infinity;
2201
+ const expand = (x0, y0, x1, y1) => {
2202
+ if (x0 < mnx) mnx = x0; if (x1 > mxx) mxx = x1;
2203
+ if (y0 < mny) mny = y0; if (y1 > mxy) mxy = y1;
2204
+ };
2009
2205
  for (let i = 0; i < n; i++) {
2010
2206
  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;
2207
+ expand(this.V.posX[i] - hw, this.V.posY[i] - hh, this.V.posX[i] + hw, this.V.posY[i] + hh);
2015
2208
  }
2209
+ for (const f of this.frames) expand(f.x, f.y, f.x + f.w, f.y + f.h);
2210
+ for (const nt of this.notes) expand(nt.x, nt.y, nt.x + nt.w, nt.y + nt.h);
2016
2211
  const pad = 40;
2017
2212
  const bw = mxx - mnx + pad * 2, bh = mxy - mny + pad * 2;
2018
2213
  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}">`];
2214
+
2215
+ // Defs: gather all edge gradients up front so consumers can serialize cleanly.
2216
+ const defs = [];
2019
2217
  for (let i = 0; i < m; i++) {
2020
2218
  const a = this.V.edgeFromN[i], b = this.V.edgeToN[i];
2021
2219
  const ap = this._portWorld(a, 1, this.V.edgeFromP[i]);
2022
2220
  const bp = this._portWorld(b, 0, this.V.edgeToP[i]);
2023
2221
  const cA = this.colors.get(a) || this.kinds[this.V.kind[a]].color;
2024
2222
  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>`);
2223
+ 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>`);
2224
+ }
2225
+ if (defs.length) out.push(`<defs>${defs.join('')}</defs>`);
2226
+
2227
+ // Frames (background layer): dashed border + translucent header strip + label.
2228
+ for (const f of this.frames) {
2229
+ const fillA = alphaize(f.color, 0.05);
2230
+ const strokeA = alphaize(f.color, 0.45);
2231
+ const headA = alphaize(f.color, 0.16);
2232
+ 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"/>`);
2233
+ out.push(`<rect x="${f.x}" y="${f.y}" width="${f.w}" height="26" rx="12" fill="${headA}"/>`);
2234
+ 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>`);
2235
+ }
2236
+
2237
+ // Sticky notes.
2238
+ for (const nt of this.notes) {
2239
+ const fill = (nt.color && nt.color.fill) || '#fef9c3';
2240
+ const border = (nt.color && nt.color.border) || '#caa54a';
2241
+ const textCol = (nt.color && nt.color.text) || '#5b3d12';
2242
+ out.push(`<rect x="${nt.x}" y="${nt.y}" width="${nt.w}" height="${nt.h}" rx="4" fill="${fill}" stroke="${border}" stroke-width="1"/>`);
2243
+ if (nt.text) {
2244
+ const lines = wrapTextForSvg(nt.text, nt.w - 20, 7); // rough char-width estimate
2245
+ const startY = nt.y + 18;
2246
+ for (let li = 0; li < lines.length; li++) {
2247
+ 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>`);
2248
+ }
2249
+ }
2250
+ }
2251
+
2252
+ // Edges.
2253
+ for (let i = 0; i < m; i++) {
2254
+ const a = this.V.edgeFromN[i], b = this.V.edgeToN[i];
2255
+ const ap = this._portWorld(a, 1, this.V.edgeFromP[i]);
2256
+ const bp = this._portWorld(b, 0, this.V.edgeToP[i]);
2257
+ const selected = this.V.edgeSel[i] !== 0;
2258
+ const stroke = selected ? '#f0b93a' : `url(#zfg${i})`;
2259
+ const sw = selected ? 2.4 : 1.7;
2260
+ let d;
2027
2261
  if (this.options.edgeStyle === 'orthogonal') {
2028
2262
  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"/>`);
2263
+ d = `M ${path[0].x} ${path[0].y} ` + path.slice(1).map((p) => `L ${p.x} ${p.y}`).join(' ');
2031
2264
  } else {
2032
2265
  const dx = bp.x - ap.x, dy = bp.y - ap.y;
2033
2266
  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"/>`);
2267
+ d = `M ${ap.x} ${ap.y} C ${ap.x + off} ${ap.y} ${bp.x - off} ${bp.y} ${bp.x} ${bp.y}`;
2268
+ }
2269
+ out.push(`<path d="${d}" stroke="${stroke}" stroke-width="${sw}" fill="none" stroke-linejoin="round"/>`);
2270
+ const label = this.edgeLabels.get(i);
2271
+ if (label) {
2272
+ // Midpoint approximation: average of endpoints (close enough for export).
2273
+ const mx = (ap.x + bp.x) / 2, my = (ap.y + bp.y) / 2;
2274
+ const tw = Math.max(20, label.length * 6.5);
2275
+ 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"/>`);
2276
+ 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>`);
2035
2277
  }
2036
2278
  }
2279
+
2280
+ // Nodes.
2037
2281
  for (let i = 0; i < n; i++) {
2038
2282
  const cat = this.kinds[this.V.kind[i]];
2039
2283
  const color = this.colors.get(i) || cat.color;
2040
2284
  const x = this.V.posX[i] - this.V.sizeW[i] / 2;
2041
2285
  const y = this.V.posY[i] - this.V.sizeH[i] / 2;
2042
2286
  const w = this.V.sizeW[i], h = this.V.sizeH[i];
2287
+ const sel = this.V.selected[i] !== 0;
2288
+ const borderColor = sel ? '#f0b93a' : color;
2289
+ const borderW = sel ? 2 : 1.4;
2043
2290
  if (cat.shape === 'diamond') {
2044
2291
  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"/>`);
2292
+ out.push(`<polygon points="${cx},${y} ${x+w},${cy} ${cx},${y+h} ${x},${cy}" fill="#161b27" stroke="${borderColor}" stroke-width="${borderW}"/>`);
2046
2293
  } 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"/>`);
2294
+ 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}"/>`);
2295
+ } else if (cat.shape === 'hexagon') {
2296
+ const cx = this.V.posX[i], cy = this.V.posY[i];
2297
+ const hw = w / 2, a = hw * 0.45;
2298
+ 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}"/>`);
2048
2299
  } 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}"/>`);
2300
+ out.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="8" fill="#161b27" stroke="${borderColor}" stroke-width="${borderW}"/>`);
2301
+ if (cat.shape === 'rect') {
2302
+ 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}"/>`);
2303
+ }
2051
2304
  }
2052
2305
  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>`);
2306
+ const titleY = cat.shape === 'rect' ? y + 14 : y + h / 2;
2307
+ const titleFill = cat.shape === 'rect' ? '#0b0f17' : color;
2308
+ 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>`);
2309
+
2310
+ // Progress bar (bottom strip of rect bodies).
2311
+ const prog = this.progress.get(i);
2312
+ if (prog !== undefined && prog > 0 && cat.shape === 'rect') {
2313
+ const barY = y + h - 5;
2314
+ const barX = x + 8, barW = w - 16, barH = 3;
2315
+ const fillW = barW * Math.min(1, Math.max(0, prog));
2316
+ out.push(`<rect x="${barX}" y="${barY}" width="${barW}" height="${barH}" fill="rgba(255,255,255,0.06)"/>`);
2317
+ out.push(`<rect x="${barX}" y="${barY}" width="${fillW}" height="${barH}" fill="${color}"/>`);
2318
+ }
2319
+
2320
+ // Status dot (top-right of header for rect shapes).
2321
+ const st = this.status.get(i);
2322
+ if (st && cat.shape === 'rect') {
2323
+ const sCol = STATUS_COLORS[st] || '#8b95a7';
2324
+ out.push(`<circle cx="${x + w - 12}" cy="${y + 11}" r="3.5" fill="${sCol}"/>`);
2325
+ }
2326
+
2327
+ // Tags.
2328
+ const tags = this.tags.get(i);
2329
+ if (tags && tags.length) {
2330
+ let tx = x + 8;
2331
+ const ty = y + h - 24;
2332
+ const tagFill = alphaize(color, 0.18);
2333
+ for (const tag of tags) {
2334
+ const tw = tag.length * 6 + 12;
2335
+ if (tx + tw > x + w - 8) break;
2336
+ out.push(`<rect x="${tx}" y="${ty}" width="${tw}" height="14" rx="3" fill="${tagFill}"/>`);
2337
+ 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>`);
2338
+ tx += tw + 4;
2339
+ }
2340
+ }
2341
+
2342
+ // Ports (circles on left/right edges).
2343
+ const ni = this.V.nIn[i], no = this.V.nOut[i];
2344
+ for (let p = 0; p < ni; p++) {
2345
+ const py = y + h * ((p + 1) / (ni + 1));
2346
+ out.push(`<circle cx="${x}" cy="${py}" r="4.5" fill="${color}" stroke="#07090f" stroke-width="1.5"/>`);
2347
+ }
2348
+ for (let p = 0; p < no; p++) {
2349
+ const py = y + h * ((p + 1) / (no + 1));
2350
+ out.push(`<circle cx="${x + w}" cy="${py}" r="4.5" fill="${color}" stroke="#07090f" stroke-width="1.5"/>`);
2351
+ }
2054
2352
  }
2055
2353
  out.push('</svg>');
2056
2354
  return out.join('\n');
@@ -2063,7 +2361,7 @@ class ZFlow {
2063
2361
  return () => { const arr = this.listeners.get(event); const i = arr.indexOf(fn); if (i >= 0) arr.splice(i, 1); };
2064
2362
  }
2065
2363
  _emit(event, ...args) {
2066
- if (this._suspendEvents && event !== 'change') return;
2364
+ if (this._suspendEvents) return;
2067
2365
  const arr = this.listeners.get(event);
2068
2366
  if (!arr) return;
2069
2367
  for (const fn of arr.slice()) try { fn(...args); } catch (e) { console.error(e); }
@@ -2089,6 +2387,18 @@ class ZFlow {
2089
2387
  return { x: (sx - this.canvas.width / 2) / this.cam.zoom - this.cam.x,
2090
2388
  y: (sy - this.canvas.height / 2) / this.cam.zoom - this.cam.y };
2091
2389
  }
2390
+ /** Public coordinate helpers. Internal `_w2s`/`_s2w` kept as aliases. */
2391
+ worldToScreen(wx, wy) { return this._w2s(wx, wy); }
2392
+ screenToWorld(cx, cy) { return this._s2w(cx, cy); }
2393
+ /** Read-only camera snapshot — preferred over reading `flow.cam` directly. */
2394
+ getCamera() { return { x: this.cam.x, y: this.cam.y, zoom: this.cam.zoom }; }
2395
+ /** Public node geometry accessor. Returns null if id is out of range. */
2396
+ getNodePosition(id) {
2397
+ if (id < 0 || id >= this.w.nodeCount_()) return null;
2398
+ return { x: this.V.posX[id], y: this.V.posY[id], w: this.V.sizeW[id], h: this.V.sizeH[id] };
2399
+ }
2400
+ /** Open the inline title editor for a node. */
2401
+ startEditTitle(id) { if (id >= 0 && id < this.w.nodeCount_()) this._startEditingTitle(id); }
2092
2402
 
2093
2403
  // ── Interactions ──────────────────────────────────────────────────────
2094
2404
  _attachEvents() {
@@ -2547,6 +2857,7 @@ class ZFlow {
2547
2857
  title: this.titles.get(i), color: this.colors.get(i),
2548
2858
  description: this.descriptions.get(i), tags: this.tags.get(i),
2549
2859
  status: this.status.get(i), progress: this.progress.get(i),
2860
+ data: this.data.get(i),
2550
2861
  }));
2551
2862
  const edges = [];
2552
2863
  for (let e = 0; e < this.w.edgeCount_(); e++) {
@@ -2572,6 +2883,7 @@ class ZFlow {
2572
2883
  kind: n.kind, x: n.x + dx, y: n.y + dy, w: n.w, h: n.h,
2573
2884
  title: n.title, color: n.color, description: n.description,
2574
2885
  tags: n.tags, status: n.status, progress: n.progress,
2886
+ data: n.data,
2575
2887
  });
2576
2888
  idMap.set(n.origId, id);
2577
2889
  this.w.setSelected(id, 1);
@@ -3899,6 +4211,23 @@ function escapeHtml(s) {
3899
4211
  return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
3900
4212
  }
3901
4213
  function escapeXml(s) { return escapeHtml(s); }
4214
+ // Rough word-wrap for SVG export. avgCharPx is a coarse estimate (~7 px per
4215
+ // char at 12px font) since SVG export has no live metrics to measure against.
4216
+ function wrapTextForSvg(text, maxWidth, avgCharPx = 7) {
4217
+ const lines = [];
4218
+ const maxChars = Math.max(4, Math.floor(maxWidth / avgCharPx));
4219
+ for (const para of String(text).split('\n')) {
4220
+ if (para.length <= maxChars) { lines.push(para); continue; }
4221
+ let cur = '';
4222
+ for (const word of para.split(/\s+/)) {
4223
+ const test = cur ? cur + ' ' + word : word;
4224
+ if (test.length > maxChars && cur) { lines.push(cur); cur = word; }
4225
+ else cur = test;
4226
+ }
4227
+ if (cur) lines.push(cur);
4228
+ }
4229
+ return lines;
4230
+ }
3902
4231
 
3903
4232
  // ── Mermaid + DOT importers ───────────────────────────────────────────────
3904
4233
  function parseMermaid(text) {
@@ -4094,7 +4423,24 @@ class WebGLRenderer {
4094
4423
  if (origMove) {
4095
4424
  f.w.moveSelectedBy = (dx, dy) => {
4096
4425
  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);
4426
+ f._ensureAdj?.();
4427
+ const adj = f._nodeAdj;
4428
+ for (let i = 0; i < f.w.nodeCount_(); i++) {
4429
+ if (!f.V.selected[i]) continue;
4430
+ this._dirty.add(i);
4431
+ // Edges incident on a moved node need their geometry recomputed.
4432
+ if (adj && adj[i]) for (let k = 0; k < adj[i].length; k++) this._dirtyEdges.add(adj[i][k]);
4433
+ }
4434
+ };
4435
+ }
4436
+ const origMoveNode = f.w.moveNode;
4437
+ if (origMoveNode) {
4438
+ f.w.moveNode = (id, x, y) => {
4439
+ origMoveNode.call(f.w, id, x, y);
4440
+ this._dirty.add(id);
4441
+ f._ensureAdj?.();
4442
+ const adj = f._nodeAdj;
4443
+ if (adj && adj[id]) for (let k = 0; k < adj[id].length; k++) this._dirtyEdges.add(adj[id][k]);
4098
4444
  };
4099
4445
  }
4100
4446
  }
@@ -4216,39 +4562,27 @@ class WebGLRenderer {
4216
4562
  const camWY = f.cam.y + (this.glCanvas.height / (2 * dpr * f.cam.zoom));
4217
4563
 
4218
4564
  // ── Detect what needs upload ────────────────────────────────────
4219
- if (this._fullRebuildNeeded || n !== this._lastNodeCount) {
4565
+ const nodeStride = NODE_STRIDE_F;
4566
+ const edgeStride = EDGE_VERTS_PER * EDGE_STRIDE_F;
4567
+ const fullNodes = this._fullRebuildNeeded || n !== this._lastNodeCount;
4568
+ const fullEdges = this._fullRebuildNeeded || m !== this._lastEdgeCount;
4569
+ if (fullNodes) {
4220
4570
  for (let i = 0; i < n; i++) this._writeNode(i);
4221
4571
  gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeBuf);
4222
- gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.nodeData.subarray(0, n * NODE_STRIDE_F));
4572
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.nodeData.subarray(0, n * nodeStride));
4223
4573
  this._dirty.clear();
4224
4574
  } 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();
4575
+ this._uploadRuns(this._dirty, this.nodeBuf, this.nodeData, nodeStride, (i) => this._writeNode(i));
4242
4576
  }
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
- }
4577
+ if (fullEdges) {
4578
+ for (let i = 0; i < m; i++) this._writeEdge(i);
4579
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.edgeBuf);
4580
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.edgeData.subarray(0, m * edgeStride));
4581
+ this._dirtyEdges.clear();
4582
+ } else if (this._dirtyEdges.size) {
4583
+ // Filter out edges that no longer exist (deletes shift the buffer end).
4584
+ for (const e of this._dirtyEdges) if (e >= m) this._dirtyEdges.delete(e);
4585
+ this._uploadRuns(this._dirtyEdges, this.edgeBuf, this.edgeData, edgeStride, (i) => this._writeEdge(i));
4252
4586
  }
4253
4587
 
4254
4588
  this._lastNodeCount = n;
@@ -4322,9 +4656,28 @@ class WebGLRenderer {
4322
4656
 
4323
4657
  /** Mark a node as needing buffer update. Called from host on move/recolor. */
4324
4658
  markNodeDirty(i) { this._dirty.add(i); }
4325
- markEdgeDirty(i) { this._dirtyEdges.add(i); this._fullRebuildNeeded = true; }
4659
+ markEdgeDirty(i) { this._dirtyEdges.add(i); }
4326
4660
  markAllDirty() { this._fullRebuildNeeded = true; }
4327
4661
 
4662
+ /** Upload a dirty Set by collapsing it into contiguous runs of `stride` floats. */
4663
+ _uploadRuns(set, buf, dataArr, stride, writeOne) {
4664
+ const gl = this.gl;
4665
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
4666
+ const sorted = [...set].sort((a, b) => a - b);
4667
+ let runStart = sorted[0], runEnd = sorted[0];
4668
+ for (let k = 1; k < sorted.length; k++) {
4669
+ if (sorted[k] === runEnd + 1) { runEnd = sorted[k]; continue; }
4670
+ for (let i = runStart; i <= runEnd; i++) writeOne(i);
4671
+ gl.bufferSubData(gl.ARRAY_BUFFER, runStart * stride * 4,
4672
+ dataArr.subarray(runStart * stride, (runEnd + 1) * stride));
4673
+ runStart = sorted[k]; runEnd = sorted[k];
4674
+ }
4675
+ for (let i = runStart; i <= runEnd; i++) writeOne(i);
4676
+ gl.bufferSubData(gl.ARRAY_BUFFER, runStart * stride * 4,
4677
+ dataArr.subarray(runStart * stride, (runEnd + 1) * stride));
4678
+ set.clear();
4679
+ }
4680
+
4328
4681
  dispose() {
4329
4682
  this._resizeObs?.disconnect();
4330
4683
  this.glCanvas?.remove();