@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
|
@@ -153,6 +153,31 @@ flow.setNodeTasks(id, [{ text: 'done', done: true }]);
|
|
|
153
153
|
flow.setNodeImage(id, 'https://...');
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
+
## Load a graph from your own data model
|
|
157
|
+
|
|
158
|
+
When your app already has `{ nodes, edges }` with string ids — services, components, whatever — use `loadGraph`. It wipes the canvas and inserts everything atomically (one `change` event, one undo snapshot) and resolves edges by your ids:
|
|
159
|
+
|
|
160
|
+
```js
|
|
161
|
+
flow.loadGraph({
|
|
162
|
+
nodes: [
|
|
163
|
+
{ id: 'web', kind: 'process', x: -200, y: 0, title: 'Web' },
|
|
164
|
+
{ id: 'gateway', kind: 'process', x: 0, y: 0, title: 'Gateway' },
|
|
165
|
+
{ id: 'svc', kind: 'process', x: 200, y: 0, title: 'API',
|
|
166
|
+
data: { repo: 'github.com/acme/api' } },
|
|
167
|
+
],
|
|
168
|
+
edges: [
|
|
169
|
+
{ from: 'web', to: 'gateway' },
|
|
170
|
+
{ from: 'gateway', to: 'svc' },
|
|
171
|
+
],
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Find the zflow numeric id from your domain id later — no side table needed.
|
|
175
|
+
flow.findNodeByUserId('svc'); // → some number
|
|
176
|
+
flow.getNodeData(flow.findNodeByUserId('svc')).repo; // → 'github.com/acme/api'
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The `id` you provide is preserved through `toJSON()` / `loadJSON()` and remote Yjs edits. `data` is a free-form bag attached to every node for whatever metadata you need to round-trip.
|
|
180
|
+
|
|
156
181
|
## Listen to events
|
|
157
182
|
|
|
158
183
|
```js
|
package/docs/07-recipes.md
CHANGED
|
@@ -335,6 +335,108 @@ await load();
|
|
|
335
335
|
|
|
336
336
|
---
|
|
337
337
|
|
|
338
|
+
## Recipe: load a graph from your own domain model
|
|
339
|
+
|
|
340
|
+
Common case: your app has a `{ nodes, edges }` shape with **string ids** (`'svc_users'`, `'db_main'`, ...). You want to render it, let the user edit, and read it back without ever holding a `logicalId → zid` side table.
|
|
341
|
+
|
|
342
|
+
```js
|
|
343
|
+
const myGraph = {
|
|
344
|
+
nodes: [
|
|
345
|
+
{ id: 'web', kind: 'frontend', x: -200, y: -100, title: 'App web', domain: { repo: 'github.com/acme/web' } },
|
|
346
|
+
{ id: 'gateway', kind: 'gateway', x: 0, y: 0, title: 'API gateway' },
|
|
347
|
+
{ id: 'svc', kind: 'service', x: 200, y: 0, title: 'Users API' },
|
|
348
|
+
{ id: 'db', kind: 'db', x: 400, y: 0, title: 'PostgreSQL' },
|
|
349
|
+
],
|
|
350
|
+
edges: [
|
|
351
|
+
{ from: 'web', to: 'gateway', label: 'HTTPS' },
|
|
352
|
+
{ from: 'gateway', to: 'svc' },
|
|
353
|
+
{ from: 'svc', to: 'db', label: 'SELECT' },
|
|
354
|
+
],
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// Map domain fields into node.data so they survive every operation.
|
|
358
|
+
flow.loadGraph({
|
|
359
|
+
nodes: myGraph.nodes.map(n => ({
|
|
360
|
+
id: n.id, kind: n.kind, x: n.x, y: n.y, title: n.title,
|
|
361
|
+
data: { domain: n.domain ?? null },
|
|
362
|
+
})),
|
|
363
|
+
edges: myGraph.edges,
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// React to user edits — the runtime gives you back the zflow numeric id,
|
|
367
|
+
// turn it back into your domain id via flow.data.
|
|
368
|
+
flow.on('change', () => {
|
|
369
|
+
const out = { nodes: [], edges: [] };
|
|
370
|
+
for (let i = 0; i < flow.nodeCount(); i++) {
|
|
371
|
+
const d = flow.getNodeData(i) || {};
|
|
372
|
+
const p = flow.getNodePosition(i);
|
|
373
|
+
out.nodes.push({ id: d.__id, kind: flow.kinds[flow.V.kind[i]].name, x: p.x, y: p.y });
|
|
374
|
+
}
|
|
375
|
+
saveToBackend(out);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// When the user opens a context-menu action, jump back to your domain id:
|
|
379
|
+
function onContextMenuClick(zid) {
|
|
380
|
+
const myId = flow.getNodeData(zid)?.__id; // e.g. 'svc'
|
|
381
|
+
navigateToServicePage(myId);
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
After this, the typical "wipe + repopulate" boilerplate every consumer used to write disappears.
|
|
386
|
+
|
|
387
|
+
---
|
|
388
|
+
|
|
389
|
+
## Recipe: a custom DOM overlay positioned over a node
|
|
390
|
+
|
|
391
|
+
Want a React/Vue/raw-DOM popover that tracks a node as the user pans/zooms? Use the coordinate helpers and re-position from the `change` event (or on every animation frame for live drag).
|
|
392
|
+
|
|
393
|
+
```js
|
|
394
|
+
const overlay = document.createElement('div');
|
|
395
|
+
Object.assign(overlay.style, {
|
|
396
|
+
position: 'absolute', pointerEvents: 'none',
|
|
397
|
+
padding: '6px 10px', background: '#161b27', color: '#e6edf3',
|
|
398
|
+
borderRadius: '6px', font: '12px Inter, ui-sans-serif',
|
|
399
|
+
});
|
|
400
|
+
overlay.textContent = 'attached';
|
|
401
|
+
container.appendChild(overlay);
|
|
402
|
+
|
|
403
|
+
const trackedId = 3;
|
|
404
|
+
|
|
405
|
+
function positionOverlay() {
|
|
406
|
+
const p = flow.getNodePosition(trackedId);
|
|
407
|
+
if (!p) { overlay.style.display = 'none'; return; }
|
|
408
|
+
const top = flow.worldToScreen(p.x, p.y - p.h / 2 - 8);
|
|
409
|
+
const dpr = window.devicePixelRatio || 1;
|
|
410
|
+
overlay.style.left = (top.x / dpr) + 'px';
|
|
411
|
+
overlay.style.top = (top.y / dpr) + 'px';
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Re-position on commits AND every frame (cheap — just a div transform).
|
|
415
|
+
flow.on('change', positionOverlay);
|
|
416
|
+
function loop() { positionOverlay(); requestAnimationFrame(loop); }
|
|
417
|
+
loop();
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
## Recipe: atomic edits without flooding listeners
|
|
423
|
+
|
|
424
|
+
A common mistake: 1000 calls to `addNode` fire 1000 `change` events and push 1000 undo snapshots. Use `transaction`:
|
|
425
|
+
|
|
426
|
+
```js
|
|
427
|
+
flow.transaction(() => {
|
|
428
|
+
for (const row of bigPayload) {
|
|
429
|
+
flow.addNode({ kind: 'service', ...row });
|
|
430
|
+
}
|
|
431
|
+
flow.runAutoLayout();
|
|
432
|
+
});
|
|
433
|
+
// Listeners hear ONE 'change'. The whole batch is ONE undo.
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
This is what `loadGraph` and `addNodesBulk` do internally.
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
338
440
|
## Next
|
|
339
441
|
|
|
340
442
|
→ [API Reference](./08-api.md) — every method, every event
|
package/docs/08-api.md
CHANGED
|
@@ -34,12 +34,55 @@ Detach all listeners, remove canvas, release WASM views.
|
|
|
34
34
|
|
|
35
35
|
---
|
|
36
36
|
|
|
37
|
+
## Atomic loading
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
flow.loadGraph({ nodes, edges }) → Map<userId, zid>
|
|
41
|
+
flow.findNodeByUserId(userId) → number // -1 if not found
|
|
42
|
+
flow.transaction(fn) → result
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`loadGraph` wipes the current graph and inserts everything as one atomic operation — single `change` event, single undo snapshot. Edges resolve `from`/`to` against the `id` you put on each node (string, number, anything).
|
|
46
|
+
|
|
47
|
+
**`loadGraph` shape:**
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
{
|
|
51
|
+
nodes: [
|
|
52
|
+
{ id: 'a', kind: 'service', x: 0, y: 0, title: 'A', data: {...} },
|
|
53
|
+
],
|
|
54
|
+
edges: [
|
|
55
|
+
{ from: 'a', to: 'b', fp: 0, tp: 0, label: 'next' },
|
|
56
|
+
],
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Each node's `id` is also stashed in `node.data.__id`, so `toJSON()` / `loadJSON()` and Yjs sync preserve it. Recover the zflow numeric id from any of:
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
const idMap = flow.loadGraph({...});
|
|
64
|
+
idMap.get('a'); // → 0
|
|
65
|
+
flow.findNodeByUserId('a'); // → 0 (works after loadJSON or remote edits)
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**`transaction(fn)`** runs `fn` with events and snapshots suspended; emits a single `change` and pushes one snapshot at the end. Safe to nest — only the outermost call commits.
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
flow.transaction(() => {
|
|
72
|
+
for (const row of payload) flow.addNode({ kind: 'service', ...row });
|
|
73
|
+
flow.runAutoLayout();
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
37
79
|
## Mutation
|
|
38
80
|
|
|
39
81
|
```js
|
|
40
82
|
flow.addNode(spec) → number // returns node id or -1
|
|
41
83
|
flow.addEdge(spec) → number // returns edge id or -1
|
|
42
84
|
flow.deleteSelection()
|
|
85
|
+
flow.deleteNode(id) // delete one node, keep rest of selection
|
|
43
86
|
flow.moveNode(id, x, y)
|
|
44
87
|
flow.duplicateSelection(dx = 40, dy = 40)
|
|
45
88
|
flow.addNodesBulk(specs) → number[] // bulk insert (returns ids in order)
|
|
@@ -54,6 +97,7 @@ flow.addEdgesBulk(specs) → number[]
|
|
|
54
97
|
- `title`, `color`, `description`, `tags`, `status`, `progress`
|
|
55
98
|
- `image`, `checked`, `tasks`, `icon`, `links`
|
|
56
99
|
- `portIn`, `portOut` — per-node port label override
|
|
100
|
+
- `data` — free-form metadata, round-tripped through serialization
|
|
57
101
|
- `animate: false` — skip pop-in animation
|
|
58
102
|
|
|
59
103
|
**`addEdge` spec fields:**
|
|
@@ -63,11 +107,14 @@ flow.addEdgesBulk(specs) → number[]
|
|
|
63
107
|
- `tp: number` — target port index (default 0)
|
|
64
108
|
- `label: string` — edge label
|
|
65
109
|
|
|
110
|
+
> **Compaction & remapping.** When a node is deleted, zflow compacts its internal arrays so the surviving range stays contiguous starting at 0. All JS-side maps (`titles`, `colors`, `data`, `bookmarks`, `breakpoints`, `_values`, metric buffers, edge labels, etc.) are remapped automatically. You **do not** need to maintain external `logicalId → zid` tables — store your id under `data.__id` (or use `loadGraph` which does it for you) and recover it with `findNodeByUserId`.
|
|
111
|
+
|
|
66
112
|
---
|
|
67
113
|
|
|
68
114
|
## Selection
|
|
69
115
|
|
|
70
116
|
```js
|
|
117
|
+
flow.setSelection([ids]) // replace entire selection
|
|
71
118
|
flow.setSelected(id, on)
|
|
72
119
|
flow.toggleSelected(id)
|
|
73
120
|
flow.clearSelection()
|
|
@@ -93,9 +140,12 @@ flow.setNodeChecked(id, bool)
|
|
|
93
140
|
flow.setNodeTasks(id, [{text, done}, ...])
|
|
94
141
|
flow.setNodeIcon(id, glyph)
|
|
95
142
|
flow.setNodeLinks(id, [{url, label}, ...])
|
|
143
|
+
flow.setNodeData(id, anyObject) // free-form metadata bag
|
|
144
|
+
flow.getNodeData(id) → any
|
|
96
145
|
flow.setPortInLabels(id, labels)
|
|
97
146
|
flow.setPortOutLabels(id, labels)
|
|
98
147
|
flow.setEdgeLabel(eid, label)
|
|
148
|
+
flow.startEditTitle(id) // open the inline title editor
|
|
99
149
|
```
|
|
100
150
|
|
|
101
151
|
---
|
|
@@ -126,6 +176,21 @@ flow.runForceLayout(maxFrames = 220)
|
|
|
126
176
|
|
|
127
177
|
---
|
|
128
178
|
|
|
179
|
+
## Coordinate space & camera
|
|
180
|
+
|
|
181
|
+
The canvas uses **world space** (the coords you pass to `addNode`) and **screen space** (CSS pixels relative to the container). The pair of helpers converts between them.
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
flow.screenToWorld(clientX, clientY) → { x, y } // mouse / pointer event
|
|
185
|
+
flow.worldToScreen(worldX, worldY) → { x, y } // CSS pixels
|
|
186
|
+
flow.getCamera() → { x, y, zoom } // immutable snapshot
|
|
187
|
+
flow.getNodePosition(id) → { x, y, w, h } | null
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`screenToWorld` takes raw `clientX/Y` (e.g. from a `MouseEvent`); the helper handles `devicePixelRatio` and the canvas bounding rect internally. `getCamera()` returns a copy — mutating it does not move the camera (use `panTo` / `zoomTo`).
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
129
194
|
## Locks & read-only
|
|
130
195
|
|
|
131
196
|
```js
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@luispm/zflow-graph",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "WASM-powered node-edge graph editor + runtime. No framework. 100k nodes at 60fps. Built-in execution engine, Yjs multiplayer, WebGL renderer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/zflow.umd.js",
|
|
@@ -77,11 +77,11 @@
|
|
|
77
77
|
"license": "MIT",
|
|
78
78
|
"repository": {
|
|
79
79
|
"type": "git",
|
|
80
|
-
"url": "https://github.com/
|
|
80
|
+
"url": "https://github.com/LuisPadre25/zflow-graph"
|
|
81
81
|
},
|
|
82
|
-
"homepage": "https://github.com/
|
|
82
|
+
"homepage": "https://github.com/LuisPadre25/zflow-graph#readme",
|
|
83
83
|
"bugs": {
|
|
84
|
-
"url": "https://github.com/
|
|
84
|
+
"url": "https://github.com/LuisPadre25/zflow-graph/issues"
|
|
85
85
|
},
|
|
86
86
|
"engines": {
|
|
87
87
|
"node": ">=18"
|
|
@@ -95,6 +95,8 @@
|
|
|
95
95
|
"vitest": "^1.6.0"
|
|
96
96
|
},
|
|
97
97
|
"peerDependenciesMeta": {
|
|
98
|
-
"yjs": {
|
|
98
|
+
"yjs": {
|
|
99
|
+
"optional": true
|
|
100
|
+
}
|
|
99
101
|
}
|
|
100
102
|
}
|