@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.umd.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! @luispm/zflow-graph v0.
|
|
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
|
|
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) {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2032
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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="${
|
|
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="${
|
|
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="${
|
|
2056
|
-
if (cat.shape === 'rect')
|
|
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
|
-
|
|
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
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
-
|
|
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
|
-
|
|
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 *
|
|
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
|
-
|
|
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
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
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);
|
|
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();
|