@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/README.md +108 -7
- package/dist/adapters/yjs.esm.js +39 -16
- package/dist/adapters/yjs.esm.min.js +2 -2
- package/dist/adapters/yjs.umd.js +39 -16
- package/dist/webgl-renderer.esm.js +55 -31
- package/dist/webgl-renderer.esm.min.js +2 -2
- package/dist/webgl-renderer.umd.js +55 -31
- package/dist/zflow.esm.js +406 -53
- package/dist/zflow.esm.js.map +1 -1
- package/dist/zflow.esm.min.js +2 -2
- package/dist/zflow.umd.js +406 -53
- package/dist/zflow.umd.js.map +1 -1
- package/dist/zflow.umd.min.js +2 -2
- package/docs/01-getting-started.md +25 -0
- package/docs/07-recipes.md +102 -0
- package/docs/08-api.md +65 -0
- package/package.json +7 -5
package/dist/zflow.esm.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! @luispm/zflow-graph v0.
|
|
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
|
|
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) {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2026
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="${
|
|
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="${
|
|
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="${
|
|
2050
|
-
if (cat.shape === 'rect')
|
|
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
|
-
|
|
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
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
-
|
|
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
|
-
|
|
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 *
|
|
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
|
-
|
|
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
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
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);
|
|
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();
|