@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/README.md
CHANGED
|
@@ -163,6 +163,93 @@ flow.addNode({ kind: 'authPipe', x: 600, y: 100 });
|
|
|
163
163
|
|
|
164
164
|
The library auto-detects inputs and outputs of the sub-flow based on which inner nodes lack inside-graph predecessors / successors.
|
|
165
165
|
|
|
166
|
+
## Common patterns
|
|
167
|
+
|
|
168
|
+
These are the patterns most apps actually need. Skip if you only want the toy example above.
|
|
169
|
+
|
|
170
|
+
### Loading a graph from your own data model
|
|
171
|
+
|
|
172
|
+
If you already have a `{ nodes, edges }` shape with **your own string ids**, use `loadGraph`. It wipes the canvas and inserts everything in one atomic transaction — single `change` event, single undo snapshot — and resolves the `from`/`to` refs by your ids automatically.
|
|
173
|
+
|
|
174
|
+
```js
|
|
175
|
+
const idMap = flow.loadGraph({
|
|
176
|
+
nodes: [
|
|
177
|
+
{ id: 'svc_users', kind: 'service', x: 0, y: 0, title: 'Users API' },
|
|
178
|
+
{ id: 'db_main', kind: 'db', x: 200, y: 0, title: 'PostgreSQL' },
|
|
179
|
+
],
|
|
180
|
+
edges: [
|
|
181
|
+
{ from: 'svc_users', to: 'db_main', label: 'SELECT' },
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
idMap.get('svc_users') // → 0 (zflow numeric id)
|
|
186
|
+
flow.findNodeByUserId('db_main') // → 1
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
The user id you passed is **also persisted in `data.__id`**, so it survives `toJSON()` → `loadJSON()` round-trips and remote edits over Yjs.
|
|
190
|
+
|
|
191
|
+
### Free-form metadata per node (`data`)
|
|
192
|
+
|
|
193
|
+
Need to attach a domain object, a database row id, a logical ref — anything? Use `data`. It is a `Map<zid, any>` round-tripped through `toJSON`/`loadJSON` and remapped automatically after deletes.
|
|
194
|
+
|
|
195
|
+
```js
|
|
196
|
+
const id = flow.addNode({
|
|
197
|
+
kind: 'service',
|
|
198
|
+
x: 0, y: 0,
|
|
199
|
+
data: { serviceId: 'svc_users', tenant: 'acme', uptime: 0.998 },
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
flow.getNodeData(id).serviceId // → 'svc_users'
|
|
203
|
+
flow.setNodeData(id, { ...flow.getNodeData(id), uptime: 0.999 });
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
When the user deletes a node, zflow compacts its internal arrays. The `data` map (and every other JS-side map — titles, colors, bookmarks, breakpoints, etc.) is remapped to match. **You do not need to maintain a side table** of `logicalId → zid`.
|
|
207
|
+
|
|
208
|
+
### Atomic mutations (`transaction`)
|
|
209
|
+
|
|
210
|
+
By default every `addNode`/`addEdge`/`setNode*` call fires a `change` event and is undoable individually. For bulk programmatic edits, wrap them so listeners see one consolidated update and the undo stack gets one entry:
|
|
211
|
+
|
|
212
|
+
```js
|
|
213
|
+
flow.transaction(() => {
|
|
214
|
+
for (const row of bigPayload) {
|
|
215
|
+
flow.addNode({ kind: 'service', x: row.x, y: row.y, data: row });
|
|
216
|
+
}
|
|
217
|
+
flow.runAutoLayout();
|
|
218
|
+
});
|
|
219
|
+
// Listeners hear ONE 'change'. Undo rolls back the whole batch.
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Nesting is safe — only the outermost call commits. The same effect is built into `addNodesBulk`, `addEdgesBulk`, and `loadGraph`.
|
|
223
|
+
|
|
224
|
+
### Coordinate spaces (overlays, tooltips, custom DOM)
|
|
225
|
+
|
|
226
|
+
The canvas uses a world space (your node coords) and a screen space (DOM pixels). The pair of helpers converts between them so you can position popovers, custom HUDs, or hit-test against your own logic:
|
|
227
|
+
|
|
228
|
+
```js
|
|
229
|
+
// User clicked somewhere on the canvas — where in world coords?
|
|
230
|
+
canvas.addEventListener('click', (ev) => {
|
|
231
|
+
const wp = flow.screenToWorld(ev.clientX, ev.clientY);
|
|
232
|
+
console.log('clicked at world', wp); // { x, y }
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Position a custom React/DOM tooltip above node 7.
|
|
236
|
+
const p = flow.getNodePosition(7); // { x, y, w, h } in world
|
|
237
|
+
const top = flow.worldToScreen(p.x, p.y - p.h/2); // → { x, y } in CSS pixels
|
|
238
|
+
tooltip.style.left = top.x + 'px';
|
|
239
|
+
tooltip.style.top = top.y + 'px';
|
|
240
|
+
|
|
241
|
+
// Camera state for minimaps and view sync.
|
|
242
|
+
const cam = flow.getCamera(); // { x, y, zoom } (snapshot)
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Programmatic selection and single-node delete
|
|
246
|
+
|
|
247
|
+
```js
|
|
248
|
+
flow.setSelection([3, 7, 12]); // replace the entire selection
|
|
249
|
+
flow.deleteNode(5); // delete just one — keeps the rest of selection
|
|
250
|
+
flow.startEditTitle(5); // open the inline title editor
|
|
251
|
+
```
|
|
252
|
+
|
|
166
253
|
## API at a glance
|
|
167
254
|
|
|
168
255
|
```js
|
|
@@ -170,17 +257,31 @@ The library auto-detects inputs and outputs of the sub-flow based on which inner
|
|
|
170
257
|
const flow = await ZFlow.create({ container, wasmUrl });
|
|
171
258
|
flow.dispose();
|
|
172
259
|
|
|
260
|
+
// Loading & atomic edits
|
|
261
|
+
flow.loadGraph({ nodes, edges }) // accepts your own ids, returns Map<userId, zid>
|
|
262
|
+
flow.transaction(fn) // one 'change' event + one undo snapshot
|
|
263
|
+
flow.findNodeByUserId(userId) // look up zid by the id you passed to loadGraph
|
|
264
|
+
flow.toJSON() / loadJSON(data)
|
|
265
|
+
|
|
173
266
|
// Mutation
|
|
174
|
-
flow.addNode(spec) / addEdge(spec) /
|
|
267
|
+
flow.addNode(spec) / addEdge(spec) / moveNode(id, x, y)
|
|
268
|
+
flow.deleteSelection() / deleteNode(id)
|
|
175
269
|
flow.addNodesBulk(specs) / addEdgesBulk(specs) // batch (50k nodes in ~50ms)
|
|
176
270
|
|
|
177
271
|
// Selection
|
|
272
|
+
flow.setSelection([ids]) // replace selection
|
|
178
273
|
flow.setSelected(id, on) / toggleSelected(id) / clearSelection() / selectAll()
|
|
179
274
|
flow.getSelection()
|
|
180
275
|
|
|
181
|
-
//
|
|
276
|
+
// Coordinate helpers (overlays / tooltips)
|
|
277
|
+
flow.screenToWorld(cx, cy) / worldToScreen(wx, wy)
|
|
278
|
+
flow.getCamera() / getNodePosition(id)
|
|
279
|
+
flow.startEditTitle(id)
|
|
280
|
+
|
|
281
|
+
// Rich content per node
|
|
182
282
|
flow.setNodeTitle / Description / Color / Tags / Status / Progress
|
|
183
283
|
flow.setNodeImage / Checked / Tasks / Icon / Links
|
|
284
|
+
flow.setNodeData(id, anyObject) / getNodeData(id) // free-form metadata bag
|
|
184
285
|
|
|
185
286
|
// Runtime
|
|
186
287
|
flow.registerKind({ name, execute, retry, inputs, outputs, ... })
|
|
@@ -195,8 +296,8 @@ flow.evalExpression('{{node_3.value}} * 2')
|
|
|
195
296
|
// Algorithms
|
|
196
297
|
flow.shortestPath(from, to) / criticalPath() / findSCCs() / findCycles()
|
|
197
298
|
|
|
198
|
-
//
|
|
199
|
-
flow.
|
|
299
|
+
// Export
|
|
300
|
+
flow.exportSVG() / exportPNG()
|
|
200
301
|
|
|
201
302
|
// Imports
|
|
202
303
|
flow.importMermaid(text) / importDot(text)
|
|
@@ -261,12 +362,12 @@ If you bundle, copy `node_modules/@luispm/zflow-graph/dist/zflow.wasm` to your `
|
|
|
261
362
|
You need Zig 0.16+ and Node 18+.
|
|
262
363
|
|
|
263
364
|
```bash
|
|
264
|
-
git clone https://github.com/
|
|
265
|
-
cd
|
|
365
|
+
git clone https://github.com/LuisPadre25/zflow-graph
|
|
366
|
+
cd zflow-graph
|
|
266
367
|
npm install
|
|
267
368
|
zig build # produces dist/zflow.wasm
|
|
268
369
|
npm run build:js # produces dist/zflow.{esm,umd}{,.min}.js
|
|
269
|
-
npm test #
|
|
370
|
+
npm test # 61 tests across 7 files
|
|
270
371
|
```
|
|
271
372
|
|
|
272
373
|
## Security
|
package/dist/adapters/yjs.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 ↔ Yjs adapter — real multiplayer for the graph.
|
|
3
3
|
//
|
|
4
4
|
// Usage:
|
|
@@ -113,18 +113,7 @@ function bindYjs(flow, ydoc, opts = {}) {
|
|
|
113
113
|
event.changes.keys.forEach((change, uuid) => {
|
|
114
114
|
if (change.action === 'add') { addRemoteNode(uuid, ynodes.get(uuid)); }
|
|
115
115
|
if (change.action === 'update') { updateRemoteNode(uuid, ynodes.get(uuid)); }
|
|
116
|
-
if (change.action === 'delete') {
|
|
117
|
-
const localId = uuidToLocal.get(uuid);
|
|
118
|
-
if (localId !== undefined) {
|
|
119
|
-
flow.w.setSelected(localId, 1);
|
|
120
|
-
const orig = flow.deleteSelection;
|
|
121
|
-
flow.deleteSelection = origDelete; // bypass intercept
|
|
122
|
-
try { flow.deleteSelection(); }
|
|
123
|
-
finally { flow.deleteSelection = orig; }
|
|
124
|
-
localToUuid.delete(localId);
|
|
125
|
-
uuidToLocal.delete(uuid);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
116
|
+
if (change.action === 'delete') { deleteRemoteNode(uuid); }
|
|
128
117
|
});
|
|
129
118
|
} finally { applyingRemote = false; }
|
|
130
119
|
});
|
|
@@ -205,14 +194,48 @@ function bindYjs(flow, ydoc, opts = {}) {
|
|
|
205
194
|
function updateRemoteNode(uuid, spec) {
|
|
206
195
|
const localId = uuidToLocal.get(uuid);
|
|
207
196
|
if (localId === undefined) return;
|
|
197
|
+
let sizeChanged = false;
|
|
198
|
+
if (spec.w !== undefined) { flow.V.sizeW[localId] = spec.w; sizeChanged = true; }
|
|
199
|
+
if (spec.h !== undefined) { flow.V.sizeH[localId] = spec.h; sizeChanged = true; }
|
|
200
|
+
// Route position through moveNode so the WASM spatial grid is invalidated.
|
|
201
|
+
// If only size changed, re-set the current position to force the same flush.
|
|
208
202
|
if (spec.x !== undefined && spec.y !== undefined) {
|
|
209
|
-
flow.
|
|
203
|
+
flow.w.moveNode(localId, spec.x, spec.y);
|
|
204
|
+
} else if (sizeChanged) {
|
|
205
|
+
flow.w.moveNode(localId, flow.V.posX[localId], flow.V.posY[localId]);
|
|
210
206
|
}
|
|
211
|
-
if (spec.w !== undefined) flow.V.sizeW[localId] = spec.w;
|
|
212
|
-
if (spec.h !== undefined) flow.V.sizeH[localId] = spec.h;
|
|
213
207
|
if (spec.title) flow.titles.set(localId, spec.title);
|
|
214
208
|
if (spec.color) flow.colors.set(localId, spec.color);
|
|
215
209
|
}
|
|
210
|
+
function deleteRemoteNode(uuid) {
|
|
211
|
+
const localId = uuidToLocal.get(uuid);
|
|
212
|
+
if (localId === undefined) return;
|
|
213
|
+
// No single-node delete in core: use selection-delete with just this node.
|
|
214
|
+
const prevSel = [];
|
|
215
|
+
for (let i = 0; i < flow.w.nodeCount_(); i++) {
|
|
216
|
+
if (flow.V.selected[i]) prevSel.push(i);
|
|
217
|
+
}
|
|
218
|
+
flow.w.clearSelection();
|
|
219
|
+
flow.w.setSelected(localId, 1);
|
|
220
|
+
flow.w.deleteSelected();
|
|
221
|
+
// Local ids may have shifted: rebuild localToUuid/uuidToLocal from scratch
|
|
222
|
+
// using the previous mapping minus the removed id.
|
|
223
|
+
const survivors = [];
|
|
224
|
+
for (const [lid, u] of localToUuid) if (lid !== localId) survivors.push({ oldLid: lid, u });
|
|
225
|
+
survivors.sort((a, b) => a.oldLid - b.oldLid);
|
|
226
|
+
localToUuid.clear(); uuidToLocal.clear();
|
|
227
|
+
for (let newLid = 0; newLid < survivors.length; newLid++) {
|
|
228
|
+
localToUuid.set(newLid, survivors[newLid].u);
|
|
229
|
+
uuidToLocal.set(survivors[newLid].u, newLid);
|
|
230
|
+
}
|
|
231
|
+
// Restore prior selection (intersected with survivors).
|
|
232
|
+
flow.w.clearSelection();
|
|
233
|
+
for (const oldLid of prevSel) {
|
|
234
|
+
if (oldLid === localId) continue;
|
|
235
|
+
const newLid = oldLid > localId ? oldLid - 1 : oldLid;
|
|
236
|
+
if (newLid < flow.w.nodeCount_()) flow.w.setSelected(newLid, 1);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
216
239
|
}
|
|
217
240
|
|
|
218
241
|
function captureNode(flow, id, spec = {}) {
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
/*! @luispm/zflow-graph v0.
|
|
2
|
-
function e(e,s,l={}){const r=l.userId||"user-"+Math.random().toString(36).slice(2,8),c=l.userName||r,i=l.color||function(e){let t=0;for(const o of e)t=31*t+o.charCodeAt(0)|0;return n[Math.abs(t)%n.length]}(r),d=s.getMap("zflow.nodes"),a=s.getMap("zflow.edges"),f=s.getMap("zflow.meta"),u=l.awareness||null,g=new Map,p=new Map,
|
|
1
|
+
/*! @luispm/zflow-graph v0.2.0 | MIT | (c) 2026 */
|
|
2
|
+
function e(e,s,l={}){const r=l.userId||"user-"+Math.random().toString(36).slice(2,8),c=l.userName||r,i=l.color||function(e){let t=0;for(const o of e)t=31*t+o.charCodeAt(0)|0;return n[Math.abs(t)%n.length]}(r),d=s.getMap("zflow.nodes"),a=s.getMap("zflow.edges"),f=s.getMap("zflow.meta"),u=l.awareness||null,g=new Map,p=new Map,w=new Map,m=new Map;let y=!1,h=null;const b=e.addNode.bind(e);e.addNode=(n={})=>{const s=b(n);if(s<0||y)return s;const l=o();return g.set(s,l),p.set(l,s),d.set(l,t(e,s,n)),s};const v=e.addEdge.bind(e);e.addEdge=(e={})=>{const t=v(e);if(t<0||y)return t;const n=o();w.set(t,n),m.set(n,t);const s=g.get("number"==typeof e.from?e.from:-1),l=g.get("number"==typeof e.to?e.to:-1);return a.set(n,{from:s,to:l,fp:e.fp??0,tp:e.tp??0,label:e.label||null}),t};const S=e.deleteSelection.bind(e);e.deleteSelection=()=>{if(y)return S();const t=[];for(let o=0;o<e.w.nodeCount_();o++)e.V.selected[o]&&t.push(o);S(),s.transact(()=>{for(const e of t){const t=g.get(e);t&&(d.delete(t),g.delete(e),p.delete(t))}},"local-delete")},e.on("change",()=>{y||h||(h=setTimeout(()=>{h=null,s.transact(()=>{for(const[o,n]of g){if(o>=e.w.nodeCount_())continue;const s=d.get(n);if(!s)continue;const l=t(e,o);s.x===l.x&&s.y===l.y&&s.w===l.w&&s.h===l.h&&s.title===l.title&&s.color===l.color||d.set(n,{...s,...l})}},"local-pos")},33))}),d.observe(t=>{if("local-pos"!==t.transaction.origin){y=!0;try{t.changes.keys.forEach((t,o)=>{"add"===t.action&&x(o,d.get(o)),"update"===t.action&&function(t,o){const n=p.get(t);if(void 0===n)return;let s=!1;void 0!==o.w&&(e.V.sizeW[n]=o.w,s=!0);void 0!==o.h&&(e.V.sizeH[n]=o.h,s=!0);void 0!==o.x&&void 0!==o.y?e.w.moveNode(n,o.x,o.y):s&&e.w.moveNode(n,e.V.posX[n],e.V.posY[n]);o.title&&e.titles.set(n,o.title);o.color&&e.colors.set(n,o.color)}(o,d.get(o)),"delete"===t.action&&function(t){const o=p.get(t);if(void 0===o)return;const n=[];for(let t=0;t<e.w.nodeCount_();t++)e.V.selected[t]&&n.push(t);e.w.clearSelection(),e.w.setSelected(o,1),e.w.deleteSelected();const s=[];for(const[e,t]of g)e!==o&&s.push({oldLid:e,u:t});s.sort((e,t)=>e.oldLid-t.oldLid),g.clear(),p.clear();for(let e=0;e<s.length;e++)g.set(e,s[e].u),p.set(s[e].u,e);e.w.clearSelection();for(const t of n){if(t===o)continue;const n=t>o?t-1:t;n<e.w.nodeCount_()&&e.w.setSelected(n,1)}}(o)})}finally{y=!1}}}),a.observe(e=>{y=!0;try{e.changes.keys.forEach((e,t)=>{if("add"===e.action){const e=a.get(t),o=p.get(e.from),n=p.get(e.to);if(void 0!==o&&void 0!==n){const s=v({from:o,to:n,fp:e.fp,tp:e.tp,label:e.label});w.set(s,t),m.set(t,s)}}e.action})}finally{y=!1}}),u&&(u.setLocalStateField("user",{name:c,color:i}),e.canvas.addEventListener("mousemove",t=>{const o=e._s2w(t.clientX,t.clientY);u.setLocalStateField("cursor",{x:o.x,y:o.y})}),u.on("change",()=>{const t=u.getStates();e.clearRemoteCursors();for(const[o,n]of t){if(o===u.clientID)continue;const t=n.user||{},s=n.cursor;s&&e.setRemoteCursor(String(o),s.x,s.y,t.name||String(o),t.color||"#5be0d0")}})),y=!0;try{for(const[e,t]of d.entries())x(e,t);for(const[e,t]of a.entries()){const o=p.get(t.from),n=p.get(t.to);if(void 0!==o&&void 0!==n){const s=v({from:o,to:n,fp:t.fp,tp:t.tp,label:t.label});w.set(s,e),m.set(e,s)}}}finally{y=!1}return{ynodes:d,yedges:a,ymeta:f,ydoc:s,userId:r,userName:c,userColor:i,destroy(){e.addNode=b,e.addEdge=v}};function x(e,t){if(p.has(e))return;const o=b({kind:t.kind,x:t.x,y:t.y,w:t.w,h:t.h,title:t.title,color:t.color});o<0||(g.set(o,e),p.set(e,o))}}function t(e,t,o={}){return{kind:e.kinds[e.V.kind[t]].name,x:e.V.posX[t],y:e.V.posY[t],w:e.V.sizeW[t],h:e.V.sizeH[t],title:e.titles.get(t)||o.title||null,color:e.colors.get(t)||o.color||null}}function o(){return"u_"+Math.random().toString(36).slice(2,10)+Date.now().toString(36)}const n=["#5b8def","#c062e8","#5bd17a","#f0b93a","#5be0d0","#fb923c","#e8462b"];export{e as bindYjs};
|
package/dist/adapters/yjs.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) :
|
|
@@ -119,18 +119,7 @@
|
|
|
119
119
|
event.changes.keys.forEach((change, uuid) => {
|
|
120
120
|
if (change.action === 'add') { addRemoteNode(uuid, ynodes.get(uuid)); }
|
|
121
121
|
if (change.action === 'update') { updateRemoteNode(uuid, ynodes.get(uuid)); }
|
|
122
|
-
if (change.action === 'delete') {
|
|
123
|
-
const localId = uuidToLocal.get(uuid);
|
|
124
|
-
if (localId !== undefined) {
|
|
125
|
-
flow.w.setSelected(localId, 1);
|
|
126
|
-
const orig = flow.deleteSelection;
|
|
127
|
-
flow.deleteSelection = origDelete; // bypass intercept
|
|
128
|
-
try { flow.deleteSelection(); }
|
|
129
|
-
finally { flow.deleteSelection = orig; }
|
|
130
|
-
localToUuid.delete(localId);
|
|
131
|
-
uuidToLocal.delete(uuid);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
122
|
+
if (change.action === 'delete') { deleteRemoteNode(uuid); }
|
|
134
123
|
});
|
|
135
124
|
} finally { applyingRemote = false; }
|
|
136
125
|
});
|
|
@@ -211,14 +200,48 @@
|
|
|
211
200
|
function updateRemoteNode(uuid, spec) {
|
|
212
201
|
const localId = uuidToLocal.get(uuid);
|
|
213
202
|
if (localId === undefined) return;
|
|
203
|
+
let sizeChanged = false;
|
|
204
|
+
if (spec.w !== undefined) { flow.V.sizeW[localId] = spec.w; sizeChanged = true; }
|
|
205
|
+
if (spec.h !== undefined) { flow.V.sizeH[localId] = spec.h; sizeChanged = true; }
|
|
206
|
+
// Route position through moveNode so the WASM spatial grid is invalidated.
|
|
207
|
+
// If only size changed, re-set the current position to force the same flush.
|
|
214
208
|
if (spec.x !== undefined && spec.y !== undefined) {
|
|
215
|
-
flow.
|
|
209
|
+
flow.w.moveNode(localId, spec.x, spec.y);
|
|
210
|
+
} else if (sizeChanged) {
|
|
211
|
+
flow.w.moveNode(localId, flow.V.posX[localId], flow.V.posY[localId]);
|
|
216
212
|
}
|
|
217
|
-
if (spec.w !== undefined) flow.V.sizeW[localId] = spec.w;
|
|
218
|
-
if (spec.h !== undefined) flow.V.sizeH[localId] = spec.h;
|
|
219
213
|
if (spec.title) flow.titles.set(localId, spec.title);
|
|
220
214
|
if (spec.color) flow.colors.set(localId, spec.color);
|
|
221
215
|
}
|
|
216
|
+
function deleteRemoteNode(uuid) {
|
|
217
|
+
const localId = uuidToLocal.get(uuid);
|
|
218
|
+
if (localId === undefined) return;
|
|
219
|
+
// No single-node delete in core: use selection-delete with just this node.
|
|
220
|
+
const prevSel = [];
|
|
221
|
+
for (let i = 0; i < flow.w.nodeCount_(); i++) {
|
|
222
|
+
if (flow.V.selected[i]) prevSel.push(i);
|
|
223
|
+
}
|
|
224
|
+
flow.w.clearSelection();
|
|
225
|
+
flow.w.setSelected(localId, 1);
|
|
226
|
+
flow.w.deleteSelected();
|
|
227
|
+
// Local ids may have shifted: rebuild localToUuid/uuidToLocal from scratch
|
|
228
|
+
// using the previous mapping minus the removed id.
|
|
229
|
+
const survivors = [];
|
|
230
|
+
for (const [lid, u] of localToUuid) if (lid !== localId) survivors.push({ oldLid: lid, u });
|
|
231
|
+
survivors.sort((a, b) => a.oldLid - b.oldLid);
|
|
232
|
+
localToUuid.clear(); uuidToLocal.clear();
|
|
233
|
+
for (let newLid = 0; newLid < survivors.length; newLid++) {
|
|
234
|
+
localToUuid.set(newLid, survivors[newLid].u);
|
|
235
|
+
uuidToLocal.set(survivors[newLid].u, newLid);
|
|
236
|
+
}
|
|
237
|
+
// Restore prior selection (intersected with survivors).
|
|
238
|
+
flow.w.clearSelection();
|
|
239
|
+
for (const oldLid of prevSel) {
|
|
240
|
+
if (oldLid === localId) continue;
|
|
241
|
+
const newLid = oldLid > localId ? oldLid - 1 : oldLid;
|
|
242
|
+
if (newLid < flow.w.nodeCount_()) flow.w.setSelected(newLid, 1);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
222
245
|
}
|
|
223
246
|
|
|
224
247
|
function captureNode(flow, id, spec = {}) {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! @luispm/zflow-graph v0.
|
|
1
|
+
/*! @luispm/zflow-graph v0.2.0 | MIT | (c) 2026 */
|
|
2
2
|
// zflow WebGL renderer — optimized path.
|
|
3
3
|
//
|
|
4
4
|
// Architecture:
|
|
@@ -110,7 +110,24 @@ class WebGLRenderer {
|
|
|
110
110
|
if (origMove) {
|
|
111
111
|
f.w.moveSelectedBy = (dx, dy) => {
|
|
112
112
|
origMove.call(f.w, dx, dy);
|
|
113
|
-
|
|
113
|
+
f._ensureAdj?.();
|
|
114
|
+
const adj = f._nodeAdj;
|
|
115
|
+
for (let i = 0; i < f.w.nodeCount_(); i++) {
|
|
116
|
+
if (!f.V.selected[i]) continue;
|
|
117
|
+
this._dirty.add(i);
|
|
118
|
+
// Edges incident on a moved node need their geometry recomputed.
|
|
119
|
+
if (adj && adj[i]) for (let k = 0; k < adj[i].length; k++) this._dirtyEdges.add(adj[i][k]);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const origMoveNode = f.w.moveNode;
|
|
124
|
+
if (origMoveNode) {
|
|
125
|
+
f.w.moveNode = (id, x, y) => {
|
|
126
|
+
origMoveNode.call(f.w, id, x, y);
|
|
127
|
+
this._dirty.add(id);
|
|
128
|
+
f._ensureAdj?.();
|
|
129
|
+
const adj = f._nodeAdj;
|
|
130
|
+
if (adj && adj[id]) for (let k = 0; k < adj[id].length; k++) this._dirtyEdges.add(adj[id][k]);
|
|
114
131
|
};
|
|
115
132
|
}
|
|
116
133
|
}
|
|
@@ -232,39 +249,27 @@ class WebGLRenderer {
|
|
|
232
249
|
const camWY = f.cam.y + (this.glCanvas.height / (2 * dpr * f.cam.zoom));
|
|
233
250
|
|
|
234
251
|
// ── Detect what needs upload ────────────────────────────────────
|
|
235
|
-
|
|
252
|
+
const nodeStride = NODE_STRIDE_F;
|
|
253
|
+
const edgeStride = EDGE_VERTS_PER * EDGE_STRIDE_F;
|
|
254
|
+
const fullNodes = this._fullRebuildNeeded || n !== this._lastNodeCount;
|
|
255
|
+
const fullEdges = this._fullRebuildNeeded || m !== this._lastEdgeCount;
|
|
256
|
+
if (fullNodes) {
|
|
236
257
|
for (let i = 0; i < n; i++) this._writeNode(i);
|
|
237
258
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeBuf);
|
|
238
|
-
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.nodeData.subarray(0, n *
|
|
259
|
+
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.nodeData.subarray(0, n * nodeStride));
|
|
239
260
|
this._dirty.clear();
|
|
240
261
|
} else if (this._dirty.size) {
|
|
241
|
-
|
|
242
|
-
// Combine contiguous dirty ranges to minimize bufferSubData calls.
|
|
243
|
-
const sorted = [...this._dirty].sort((a, b) => a - b);
|
|
244
|
-
let runStart = sorted[0], runEnd = sorted[0];
|
|
245
|
-
for (let k = 1; k < sorted.length; k++) {
|
|
246
|
-
if (sorted[k] === runEnd + 1) runEnd = sorted[k];
|
|
247
|
-
else {
|
|
248
|
-
for (let i = runStart; i <= runEnd; i++) this._writeNode(i);
|
|
249
|
-
gl.bufferSubData(gl.ARRAY_BUFFER, runStart * NODE_STRIDE_F * 4,
|
|
250
|
-
this.nodeData.subarray(runStart * NODE_STRIDE_F, (runEnd + 1) * NODE_STRIDE_F));
|
|
251
|
-
runStart = sorted[k]; runEnd = sorted[k];
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
for (let i = runStart; i <= runEnd; i++) this._writeNode(i);
|
|
255
|
-
gl.bufferSubData(gl.ARRAY_BUFFER, runStart * NODE_STRIDE_F * 4,
|
|
256
|
-
this.nodeData.subarray(runStart * NODE_STRIDE_F, (runEnd + 1) * NODE_STRIDE_F));
|
|
257
|
-
this._dirty.clear();
|
|
262
|
+
this._uploadRuns(this._dirty, this.nodeBuf, this.nodeData, nodeStride, (i) => this._writeNode(i));
|
|
258
263
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
264
|
+
if (fullEdges) {
|
|
265
|
+
for (let i = 0; i < m; i++) this._writeEdge(i);
|
|
266
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.edgeBuf);
|
|
267
|
+
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.edgeData.subarray(0, m * edgeStride));
|
|
268
|
+
this._dirtyEdges.clear();
|
|
269
|
+
} else if (this._dirtyEdges.size) {
|
|
270
|
+
// Filter out edges that no longer exist (deletes shift the buffer end).
|
|
271
|
+
for (const e of this._dirtyEdges) if (e >= m) this._dirtyEdges.delete(e);
|
|
272
|
+
this._uploadRuns(this._dirtyEdges, this.edgeBuf, this.edgeData, edgeStride, (i) => this._writeEdge(i));
|
|
268
273
|
}
|
|
269
274
|
|
|
270
275
|
this._lastNodeCount = n;
|
|
@@ -338,9 +343,28 @@ class WebGLRenderer {
|
|
|
338
343
|
|
|
339
344
|
/** Mark a node as needing buffer update. Called from host on move/recolor. */
|
|
340
345
|
markNodeDirty(i) { this._dirty.add(i); }
|
|
341
|
-
markEdgeDirty(i) { this._dirtyEdges.add(i);
|
|
346
|
+
markEdgeDirty(i) { this._dirtyEdges.add(i); }
|
|
342
347
|
markAllDirty() { this._fullRebuildNeeded = true; }
|
|
343
348
|
|
|
349
|
+
/** Upload a dirty Set by collapsing it into contiguous runs of `stride` floats. */
|
|
350
|
+
_uploadRuns(set, buf, dataArr, stride, writeOne) {
|
|
351
|
+
const gl = this.gl;
|
|
352
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
|
353
|
+
const sorted = [...set].sort((a, b) => a - b);
|
|
354
|
+
let runStart = sorted[0], runEnd = sorted[0];
|
|
355
|
+
for (let k = 1; k < sorted.length; k++) {
|
|
356
|
+
if (sorted[k] === runEnd + 1) { runEnd = sorted[k]; continue; }
|
|
357
|
+
for (let i = runStart; i <= runEnd; i++) writeOne(i);
|
|
358
|
+
gl.bufferSubData(gl.ARRAY_BUFFER, runStart * stride * 4,
|
|
359
|
+
dataArr.subarray(runStart * stride, (runEnd + 1) * stride));
|
|
360
|
+
runStart = sorted[k]; runEnd = sorted[k];
|
|
361
|
+
}
|
|
362
|
+
for (let i = runStart; i <= runEnd; i++) writeOne(i);
|
|
363
|
+
gl.bufferSubData(gl.ARRAY_BUFFER, runStart * stride * 4,
|
|
364
|
+
dataArr.subarray(runStart * stride, (runEnd + 1) * stride));
|
|
365
|
+
set.clear();
|
|
366
|
+
}
|
|
367
|
+
|
|
344
368
|
dispose() {
|
|
345
369
|
this._resizeObs?.disconnect();
|
|
346
370
|
this.glCanvas?.remove();
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
/*! @luispm/zflow-graph v0.
|
|
2
|
-
class t{constructor(t){if(this.flow=t,this.glCanvas=document.createElement("canvas"),this.glCanvas.style.cssText="position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:0;",t.container.insertBefore(this.glCanvas,t.canvas),t.canvas.style.background="transparent",t.canvas.style.position="absolute",t.canvas.style.zIndex="1",this.gl=this.glCanvas.getContext("webgl",{antialias:!0,alpha:!0,premultipliedAlpha:!1}),!this.gl)return this.disabled=!0,void console.warn("zflow: WebGL unavailable");this.instExt=this.gl.getExtension("ANGLE_instanced_arrays"),this.cap=t.w.nodeCap(),this.edgeCap=t.w.edgeCap(),this._resize(),this._setupShaders(),this._setupBuffers(),this._hookDirty(),this._resizeObs=new ResizeObserver(()=>this._resize()),this._resizeObs.observe(t.container),this._dirty=new Set,this._dirtyEdges=new Set,this._fullRebuildNeeded=!0,this._lastNodeCount=0,this._lastEdgeCount=0}_hookDirty(){const t=this.flow;t.on("change",()=>{this._fullRebuildNeeded=!0});const e=t.w.moveSelectedBy;e&&(t.w.moveSelectedBy=(i,o)=>{e.call(t.w,i,o);for(let e=0;e<t.w.nodeCount_();e++)t.V.selected[e]&&this._dirty.add(e)})}_resize(){const t=window.devicePixelRatio||1,e=this.flow.container.getBoundingClientRect();this.glCanvas.width=e.width*t,this.glCanvas.height=e.height*t,this.gl?.viewport(0,0,this.glCanvas.width,this.glCanvas.height)}_setupShaders(){const t=this.gl;this.progNode=i(t,"\nattribute vec2 aQuad;\nattribute vec2 aCenter;\nattribute vec2 aSize;\nattribute vec3 aColor;\nattribute float aSelected;\nuniform vec2 uCam;\nuniform float uZoom;\nuniform vec2 uScreen;\nvarying vec3 vColor;\nvarying float vSelected;\nvarying vec2 vUv;\nvoid main() {\n vUv = aQuad;\n vSelected = aSelected;\n vColor = aColor;\n vec2 worldPos = aCenter + aQuad * aSize;\n vec2 screen = (worldPos + uCam) * uZoom;\n vec2 ndc = (screen / uScreen) * 2.0;\n gl_Position = vec4(ndc.x, -ndc.y, 0.0, 1.0);\n}","\nprecision mediump float;\nvarying vec3 vColor;\nvarying float vSelected;\nvarying vec2 vUv;\nvoid main() {\n vec2 q = abs(vUv);\n float d = max(q.x, q.y);\n float alpha = smoothstep(1.0, 0.92, d);\n float header = step(0.7, vUv.y) * 0.18;\n vec3 col = vColor + vec3(header);\n if (vSelected > 0.5) col = mix(col, vec3(0.94, 0.73, 0.23), 0.55);\n gl_FragColor = vec4(col, alpha);\n}"),this.progEdge=i(t,"\nattribute vec2 aPos;\nattribute vec3 aColor;\nuniform vec2 uCam;\nuniform float uZoom;\nuniform vec2 uScreen;\nvarying vec3 vColor;\nvoid main() {\n vColor = aColor;\n vec2 screen = (aPos + uCam) * uZoom;\n vec2 ndc = (screen / uScreen) * 2.0;\n gl_Position = vec4(ndc.x, -ndc.y, 0.0, 1.0);\n}","\nprecision mediump float;\nvarying vec3 vColor;\nvoid main() { gl_FragColor = vec4(vColor, 0.85); }"),this.locN={aQuad:t.getAttribLocation(this.progNode,"aQuad"),aCenter:t.getAttribLocation(this.progNode,"aCenter"),aSize:t.getAttribLocation(this.progNode,"aSize"),aColor:t.getAttribLocation(this.progNode,"aColor"),aSel:t.getAttribLocation(this.progNode,"aSelected"),uCam:t.getUniformLocation(this.progNode,"uCam"),uZoom:t.getUniformLocation(this.progNode,"uZoom"),uScreen:t.getUniformLocation(this.progNode,"uScreen")},this.locE={aPos:t.getAttribLocation(this.progEdge,"aPos"),aColor:t.getAttribLocation(this.progEdge,"aColor"),uCam:t.getUniformLocation(this.progEdge,"uCam"),uZoom:t.getUniformLocation(this.progEdge,"uZoom"),uScreen:t.getUniformLocation(this.progEdge,"uScreen")}}_setupBuffers(){const t=this.gl;this.quadBuf=t.createBuffer(),t.bindBuffer(t.ARRAY_BUFFER,this.quadBuf),t.bufferData(t.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,-1,1,1,-1,1]),t.STATIC_DRAW),this.nodeData=new Float32Array(8*this.cap),this.nodeBuf=t.createBuffer(),t.bindBuffer(t.ARRAY_BUFFER,this.nodeBuf),t.bufferData(t.ARRAY_BUFFER,this.nodeData.byteLength,t.DYNAMIC_DRAW),this.edgeData=new Float32Array(48*this.edgeCap*5),this.edgeBuf=t.createBuffer(),t.bindBuffer(t.ARRAY_BUFFER,this.edgeBuf),t.bufferData(t.ARRAY_BUFFER,this.edgeData.byteLength,t.DYNAMIC_DRAW)}_writeNode(t){const e=this.flow,i=e.kinds[e.V.kind[t]],a=e.colors.get(t)||i.color,r=8*t;this.nodeData[r]=e.V.posX[t],this.nodeData[r+1]=e.V.posY[t],this.nodeData[r+2]=.5*e.V.sizeW[t],this.nodeData[r+3]=.5*e.V.sizeH[t];const[s,n,h]=o(a);this.nodeData[r+4]=s,this.nodeData[r+5]=n,this.nodeData[r+6]=h,this.nodeData[r+7]=0!==e.V.selected[t]?1:0}_writeEdge(t){const e=this.flow,i=e.V.edgeFromN[t],s=e.V.edgeToN[t],n=e._portWorld(i,1,e.V.edgeFromP[t]),h=e._portWorld(s,0,e.V.edgeToP[t]),l=o(e.colors.get(i)||e.kinds[e.V.kind[i]].color),d=48*t*5,c="orthogonal"===e.options.edgeStyle,u=Math.max(50,.5*Math.abs(h.x-n.x)+.4*Math.abs(h.y-n.y));let g={x:n.x,y:n.y},f=d;for(let t=1;t<=24;t++){const e=t/24;let i;if(c){const t=.5*(n.x+h.x);i=e<.33?r(n,{x:t,y:n.y},e/.33):e<.67?r({x:t,y:n.y},{x:t,y:h.y},(e-.33)/.34):r({x:t,y:h.y},h,(e-.67)/.33)}else i=a(e,n.x,n.y,n.x+u,n.y,h.x-u,h.y,h.x,h.y);this.edgeData[f++]=g.x,this.edgeData[f++]=g.y,this.edgeData[f++]=l[0],this.edgeData[f++]=l[1],this.edgeData[f++]=l[2],this.edgeData[f++]=i.x,this.edgeData[f++]=i.y,this.edgeData[f++]=l[0],this.edgeData[f++]=l[1],this.edgeData[f++]=l[2],g=i}}render(){if(this.disabled)return;const t=this.gl,e=this.flow,i=e.w.nodeCount_(),o=e.w.edgeCount_(),a=window.devicePixelRatio||1;t.clearColor(.027,.035,.06,1),t.clear(t.COLOR_BUFFER_BIT),t.enable(t.BLEND),t.blendFunc(t.SRC_ALPHA,t.ONE_MINUS_SRC_ALPHA);const r=e.cam.x+this.glCanvas.width/(2*a*e.cam.zoom),s=e.cam.y+this.glCanvas.height/(2*a*e.cam.zoom);if(this._fullRebuildNeeded||i!==this._lastNodeCount){for(let t=0;t<i;t++)this._writeNode(t);t.bindBuffer(t.ARRAY_BUFFER,this.nodeBuf),t.bufferSubData(t.ARRAY_BUFFER,0,this.nodeData.subarray(0,8*i)),this._dirty.clear()}else if(this._dirty.size){t.bindBuffer(t.ARRAY_BUFFER,this.nodeBuf);const e=[...this._dirty].sort((t,e)=>t-e);let i=e[0],o=e[0];for(let a=1;a<e.length;a++)if(e[a]===o+1)o=e[a];else{for(let t=i;t<=o;t++)this._writeNode(t);t.bufferSubData(t.ARRAY_BUFFER,8*i*4,this.nodeData.subarray(8*i,8*(o+1))),i=e[a],o=e[a]}for(let t=i;t<=o;t++)this._writeNode(t);t.bufferSubData(t.ARRAY_BUFFER,8*i*4,this.nodeData.subarray(8*i,8*(o+1))),this._dirty.clear()}if((this._fullRebuildNeeded||o!==this._lastEdgeCount||0===this._dirty.size)&&(this._fullRebuildNeeded||o!==this._lastEdgeCount)){for(let t=0;t<o;t++)this._writeEdge(t);t.bindBuffer(t.ARRAY_BUFFER,this.edgeBuf),t.bufferSubData(t.ARRAY_BUFFER,0,this.edgeData.subarray(0,48*o*5))}if(this._lastNodeCount=i,this._lastEdgeCount=o,this._fullRebuildNeeded=!1,o>0&&(t.useProgram(this.progEdge),t.bindBuffer(t.ARRAY_BUFFER,this.edgeBuf),t.enableVertexAttribArray(this.locE.aPos),t.vertexAttribPointer(this.locE.aPos,2,t.FLOAT,!1,20,0),t.enableVertexAttribArray(this.locE.aColor),t.vertexAttribPointer(this.locE.aColor,3,t.FLOAT,!1,20,8),t.uniform2f(this.locE.uCam,r,s),t.uniform1f(this.locE.uZoom,e.cam.zoom*a),t.uniform2f(this.locE.uScreen,this.glCanvas.width,this.glCanvas.height),t.lineWidth(1.6),t.drawArrays(t.LINES,0,48*o)),i>0){t.useProgram(this.progNode),t.bindBuffer(t.ARRAY_BUFFER,this.quadBuf),t.enableVertexAttribArray(this.locN.aQuad),t.vertexAttribPointer(this.locN.aQuad,2,t.FLOAT,!1,0,0),this.instExt&&this.instExt.vertexAttribDivisorANGLE(this.locN.aQuad,0),t.bindBuffer(t.ARRAY_BUFFER,this.nodeBuf);const o=32;if(t.enableVertexAttribArray(this.locN.aCenter),t.vertexAttribPointer(this.locN.aCenter,2,t.FLOAT,!1,o,0),t.enableVertexAttribArray(this.locN.aSize),t.vertexAttribPointer(this.locN.aSize,2,t.FLOAT,!1,o,8),t.enableVertexAttribArray(this.locN.aColor),t.vertexAttribPointer(this.locN.aColor,3,t.FLOAT,!1,o,16),t.enableVertexAttribArray(this.locN.aSel),t.vertexAttribPointer(this.locN.aSel,1,t.FLOAT,!1,o,28),this.instExt&&(this.instExt.vertexAttribDivisorANGLE(this.locN.aCenter,1),this.instExt.vertexAttribDivisorANGLE(this.locN.aSize,1),this.instExt.vertexAttribDivisorANGLE(this.locN.aColor,1),this.instExt.vertexAttribDivisorANGLE(this.locN.aSel,1)),t.uniform2f(this.locN.uCam,r,s),t.uniform1f(this.locN.uZoom,e.cam.zoom*a),t.uniform2f(this.locN.uScreen,this.glCanvas.width,this.glCanvas.height),this.instExt)this.instExt.drawArraysInstancedANGLE(t.TRIANGLES,0,6,i),this.instExt.vertexAttribDivisorANGLE(this.locN.aCenter,0),this.instExt.vertexAttribDivisorANGLE(this.locN.aSize,0),this.instExt.vertexAttribDivisorANGLE(this.locN.aColor,0),this.instExt.vertexAttribDivisorANGLE(this.locN.aSel,0);else for(let e=0;e<i;e++){const i=8*e;t.vertexAttrib2f(this.locN.aCenter,this.nodeData[i],this.nodeData[i+1]),t.vertexAttrib2f(this.locN.aSize,this.nodeData[i+2],this.nodeData[i+3]),t.vertexAttrib3f(this.locN.aColor,this.nodeData[i+4],this.nodeData[i+5],this.nodeData[i+6]),t.vertexAttrib1f(this.locN.aSel,this.nodeData[i+7]),t.drawArrays(t.TRIANGLES,0,6)}}}markNodeDirty(t){this._dirty.add(t)}markEdgeDirty(t){this._dirtyEdges.add(t),this._fullRebuildNeeded=!0}markAllDirty(){this._fullRebuildNeeded=!0}dispose(){this._resizeObs?.disconnect(),this.glCanvas?.remove()}}function e(t,e,i){const o=t.createShader(i);if(t.shaderSource(o,e),t.compileShader(o),!t.getShaderParameter(o,t.COMPILE_STATUS))throw new Error("GL compile: "+t.getShaderInfoLog(o));return o}function i(t,i,o){const a=t.createProgram();if(t.attachShader(a,e(t,i,t.VERTEX_SHADER)),t.attachShader(a,e(t,o,t.FRAGMENT_SHADER)),t.linkProgram(a),!t.getProgramParameter(a,t.LINK_STATUS))throw new Error("GL link: "+t.getProgramInfoLog(a));return a}function o(t){return[parseInt(t.slice(1,3),16)/255,parseInt(t.slice(3,5),16)/255,parseInt(t.slice(5,7),16)/255]}function a(t,e,i,o,a,r,s,n,h){const l=1-t,d=l*l,c=t*t,u=d*l,g=3*d*t,f=3*l*c,v=c*t;return{x:u*e+g*o+f*r+v*n,y:u*i+g*a+f*s+v*h}}function r(t,e,i){return{x:t.x+(e.x-t.x)*i,y:t.y+(e.y-t.y)*i}}export{t as WebGLRenderer};
|
|
1
|
+
/*! @luispm/zflow-graph v0.2.0 | MIT | (c) 2026 */
|
|
2
|
+
class t{constructor(t){if(this.flow=t,this.glCanvas=document.createElement("canvas"),this.glCanvas.style.cssText="position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:0;",t.container.insertBefore(this.glCanvas,t.canvas),t.canvas.style.background="transparent",t.canvas.style.position="absolute",t.canvas.style.zIndex="1",this.gl=this.glCanvas.getContext("webgl",{antialias:!0,alpha:!0,premultipliedAlpha:!1}),!this.gl)return this.disabled=!0,void console.warn("zflow: WebGL unavailable");this.instExt=this.gl.getExtension("ANGLE_instanced_arrays"),this.cap=t.w.nodeCap(),this.edgeCap=t.w.edgeCap(),this._resize(),this._setupShaders(),this._setupBuffers(),this._hookDirty(),this._resizeObs=new ResizeObserver(()=>this._resize()),this._resizeObs.observe(t.container),this._dirty=new Set,this._dirtyEdges=new Set,this._fullRebuildNeeded=!0,this._lastNodeCount=0,this._lastEdgeCount=0}_hookDirty(){const t=this.flow;t.on("change",()=>{this._fullRebuildNeeded=!0});const e=t.w.moveSelectedBy;e&&(t.w.moveSelectedBy=(i,o)=>{e.call(t.w,i,o),t._ensureAdj?.();const a=t._nodeAdj;for(let e=0;e<t.w.nodeCount_();e++)if(t.V.selected[e]&&(this._dirty.add(e),a&&a[e]))for(let t=0;t<a[e].length;t++)this._dirtyEdges.add(a[e][t])});const i=t.w.moveNode;i&&(t.w.moveNode=(e,o,a)=>{i.call(t.w,e,o,a),this._dirty.add(e),t._ensureAdj?.();const r=t._nodeAdj;if(r&&r[e])for(let t=0;t<r[e].length;t++)this._dirtyEdges.add(r[e][t])})}_resize(){const t=window.devicePixelRatio||1,e=this.flow.container.getBoundingClientRect();this.glCanvas.width=e.width*t,this.glCanvas.height=e.height*t,this.gl?.viewport(0,0,this.glCanvas.width,this.glCanvas.height)}_setupShaders(){const t=this.gl;this.progNode=i(t,"\nattribute vec2 aQuad;\nattribute vec2 aCenter;\nattribute vec2 aSize;\nattribute vec3 aColor;\nattribute float aSelected;\nuniform vec2 uCam;\nuniform float uZoom;\nuniform vec2 uScreen;\nvarying vec3 vColor;\nvarying float vSelected;\nvarying vec2 vUv;\nvoid main() {\n vUv = aQuad;\n vSelected = aSelected;\n vColor = aColor;\n vec2 worldPos = aCenter + aQuad * aSize;\n vec2 screen = (worldPos + uCam) * uZoom;\n vec2 ndc = (screen / uScreen) * 2.0;\n gl_Position = vec4(ndc.x, -ndc.y, 0.0, 1.0);\n}","\nprecision mediump float;\nvarying vec3 vColor;\nvarying float vSelected;\nvarying vec2 vUv;\nvoid main() {\n vec2 q = abs(vUv);\n float d = max(q.x, q.y);\n float alpha = smoothstep(1.0, 0.92, d);\n float header = step(0.7, vUv.y) * 0.18;\n vec3 col = vColor + vec3(header);\n if (vSelected > 0.5) col = mix(col, vec3(0.94, 0.73, 0.23), 0.55);\n gl_FragColor = vec4(col, alpha);\n}"),this.progEdge=i(t,"\nattribute vec2 aPos;\nattribute vec3 aColor;\nuniform vec2 uCam;\nuniform float uZoom;\nuniform vec2 uScreen;\nvarying vec3 vColor;\nvoid main() {\n vColor = aColor;\n vec2 screen = (aPos + uCam) * uZoom;\n vec2 ndc = (screen / uScreen) * 2.0;\n gl_Position = vec4(ndc.x, -ndc.y, 0.0, 1.0);\n}","\nprecision mediump float;\nvarying vec3 vColor;\nvoid main() { gl_FragColor = vec4(vColor, 0.85); }"),this.locN={aQuad:t.getAttribLocation(this.progNode,"aQuad"),aCenter:t.getAttribLocation(this.progNode,"aCenter"),aSize:t.getAttribLocation(this.progNode,"aSize"),aColor:t.getAttribLocation(this.progNode,"aColor"),aSel:t.getAttribLocation(this.progNode,"aSelected"),uCam:t.getUniformLocation(this.progNode,"uCam"),uZoom:t.getUniformLocation(this.progNode,"uZoom"),uScreen:t.getUniformLocation(this.progNode,"uScreen")},this.locE={aPos:t.getAttribLocation(this.progEdge,"aPos"),aColor:t.getAttribLocation(this.progEdge,"aColor"),uCam:t.getUniformLocation(this.progEdge,"uCam"),uZoom:t.getUniformLocation(this.progEdge,"uZoom"),uScreen:t.getUniformLocation(this.progEdge,"uScreen")}}_setupBuffers(){const t=this.gl;this.quadBuf=t.createBuffer(),t.bindBuffer(t.ARRAY_BUFFER,this.quadBuf),t.bufferData(t.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,-1,1,1,-1,1]),t.STATIC_DRAW),this.nodeData=new Float32Array(8*this.cap),this.nodeBuf=t.createBuffer(),t.bindBuffer(t.ARRAY_BUFFER,this.nodeBuf),t.bufferData(t.ARRAY_BUFFER,this.nodeData.byteLength,t.DYNAMIC_DRAW),this.edgeData=new Float32Array(48*this.edgeCap*5),this.edgeBuf=t.createBuffer(),t.bindBuffer(t.ARRAY_BUFFER,this.edgeBuf),t.bufferData(t.ARRAY_BUFFER,this.edgeData.byteLength,t.DYNAMIC_DRAW)}_writeNode(t){const e=this.flow,i=e.kinds[e.V.kind[t]],a=e.colors.get(t)||i.color,r=8*t;this.nodeData[r]=e.V.posX[t],this.nodeData[r+1]=e.V.posY[t],this.nodeData[r+2]=.5*e.V.sizeW[t],this.nodeData[r+3]=.5*e.V.sizeH[t];const[s,n,d]=o(a);this.nodeData[r+4]=s,this.nodeData[r+5]=n,this.nodeData[r+6]=d,this.nodeData[r+7]=0!==e.V.selected[t]?1:0}_writeEdge(t){const e=this.flow,i=e.V.edgeFromN[t],s=e.V.edgeToN[t],n=e._portWorld(i,1,e.V.edgeFromP[t]),d=e._portWorld(s,0,e.V.edgeToP[t]),h=o(e.colors.get(i)||e.kinds[e.V.kind[i]].color),l=48*t*5,c="orthogonal"===e.options.edgeStyle,u=Math.max(50,.5*Math.abs(d.x-n.x)+.4*Math.abs(d.y-n.y));let g={x:n.x,y:n.y},f=l;for(let t=1;t<=24;t++){const e=t/24;let i;if(c){const t=.5*(n.x+d.x);i=e<.33?r(n,{x:t,y:n.y},e/.33):e<.67?r({x:t,y:n.y},{x:t,y:d.y},(e-.33)/.34):r({x:t,y:d.y},d,(e-.67)/.33)}else i=a(e,n.x,n.y,n.x+u,n.y,d.x-u,d.y,d.x,d.y);this.edgeData[f++]=g.x,this.edgeData[f++]=g.y,this.edgeData[f++]=h[0],this.edgeData[f++]=h[1],this.edgeData[f++]=h[2],this.edgeData[f++]=i.x,this.edgeData[f++]=i.y,this.edgeData[f++]=h[0],this.edgeData[f++]=h[1],this.edgeData[f++]=h[2],g=i}}render(){if(this.disabled)return;const t=this.gl,e=this.flow,i=e.w.nodeCount_(),o=e.w.edgeCount_(),a=window.devicePixelRatio||1;t.clearColor(.027,.035,.06,1),t.clear(t.COLOR_BUFFER_BIT),t.enable(t.BLEND),t.blendFunc(t.SRC_ALPHA,t.ONE_MINUS_SRC_ALPHA);const r=e.cam.x+this.glCanvas.width/(2*a*e.cam.zoom),s=e.cam.y+this.glCanvas.height/(2*a*e.cam.zoom),n=this._fullRebuildNeeded||i!==this._lastNodeCount,d=this._fullRebuildNeeded||o!==this._lastEdgeCount;if(n){for(let t=0;t<i;t++)this._writeNode(t);t.bindBuffer(t.ARRAY_BUFFER,this.nodeBuf),t.bufferSubData(t.ARRAY_BUFFER,0,this.nodeData.subarray(0,8*i)),this._dirty.clear()}else this._dirty.size&&this._uploadRuns(this._dirty,this.nodeBuf,this.nodeData,8,t=>this._writeNode(t));if(d){for(let t=0;t<o;t++)this._writeEdge(t);t.bindBuffer(t.ARRAY_BUFFER,this.edgeBuf),t.bufferSubData(t.ARRAY_BUFFER,0,this.edgeData.subarray(0,240*o)),this._dirtyEdges.clear()}else if(this._dirtyEdges.size){for(const t of this._dirtyEdges)t>=o&&this._dirtyEdges.delete(t);this._uploadRuns(this._dirtyEdges,this.edgeBuf,this.edgeData,240,t=>this._writeEdge(t))}if(this._lastNodeCount=i,this._lastEdgeCount=o,this._fullRebuildNeeded=!1,o>0&&(t.useProgram(this.progEdge),t.bindBuffer(t.ARRAY_BUFFER,this.edgeBuf),t.enableVertexAttribArray(this.locE.aPos),t.vertexAttribPointer(this.locE.aPos,2,t.FLOAT,!1,20,0),t.enableVertexAttribArray(this.locE.aColor),t.vertexAttribPointer(this.locE.aColor,3,t.FLOAT,!1,20,8),t.uniform2f(this.locE.uCam,r,s),t.uniform1f(this.locE.uZoom,e.cam.zoom*a),t.uniform2f(this.locE.uScreen,this.glCanvas.width,this.glCanvas.height),t.lineWidth(1.6),t.drawArrays(t.LINES,0,48*o)),i>0){t.useProgram(this.progNode),t.bindBuffer(t.ARRAY_BUFFER,this.quadBuf),t.enableVertexAttribArray(this.locN.aQuad),t.vertexAttribPointer(this.locN.aQuad,2,t.FLOAT,!1,0,0),this.instExt&&this.instExt.vertexAttribDivisorANGLE(this.locN.aQuad,0),t.bindBuffer(t.ARRAY_BUFFER,this.nodeBuf);const o=32;if(t.enableVertexAttribArray(this.locN.aCenter),t.vertexAttribPointer(this.locN.aCenter,2,t.FLOAT,!1,o,0),t.enableVertexAttribArray(this.locN.aSize),t.vertexAttribPointer(this.locN.aSize,2,t.FLOAT,!1,o,8),t.enableVertexAttribArray(this.locN.aColor),t.vertexAttribPointer(this.locN.aColor,3,t.FLOAT,!1,o,16),t.enableVertexAttribArray(this.locN.aSel),t.vertexAttribPointer(this.locN.aSel,1,t.FLOAT,!1,o,28),this.instExt&&(this.instExt.vertexAttribDivisorANGLE(this.locN.aCenter,1),this.instExt.vertexAttribDivisorANGLE(this.locN.aSize,1),this.instExt.vertexAttribDivisorANGLE(this.locN.aColor,1),this.instExt.vertexAttribDivisorANGLE(this.locN.aSel,1)),t.uniform2f(this.locN.uCam,r,s),t.uniform1f(this.locN.uZoom,e.cam.zoom*a),t.uniform2f(this.locN.uScreen,this.glCanvas.width,this.glCanvas.height),this.instExt)this.instExt.drawArraysInstancedANGLE(t.TRIANGLES,0,6,i),this.instExt.vertexAttribDivisorANGLE(this.locN.aCenter,0),this.instExt.vertexAttribDivisorANGLE(this.locN.aSize,0),this.instExt.vertexAttribDivisorANGLE(this.locN.aColor,0),this.instExt.vertexAttribDivisorANGLE(this.locN.aSel,0);else for(let e=0;e<i;e++){const i=8*e;t.vertexAttrib2f(this.locN.aCenter,this.nodeData[i],this.nodeData[i+1]),t.vertexAttrib2f(this.locN.aSize,this.nodeData[i+2],this.nodeData[i+3]),t.vertexAttrib3f(this.locN.aColor,this.nodeData[i+4],this.nodeData[i+5],this.nodeData[i+6]),t.vertexAttrib1f(this.locN.aSel,this.nodeData[i+7]),t.drawArrays(t.TRIANGLES,0,6)}}}markNodeDirty(t){this._dirty.add(t)}markEdgeDirty(t){this._dirtyEdges.add(t)}markAllDirty(){this._fullRebuildNeeded=!0}_uploadRuns(t,e,i,o,a){const r=this.gl;r.bindBuffer(r.ARRAY_BUFFER,e);const s=[...t].sort((t,e)=>t-e);let n=s[0],d=s[0];for(let t=1;t<s.length;t++)if(s[t]!==d+1){for(let t=n;t<=d;t++)a(t);r.bufferSubData(r.ARRAY_BUFFER,n*o*4,i.subarray(n*o,(d+1)*o)),n=s[t],d=s[t]}else d=s[t];for(let t=n;t<=d;t++)a(t);r.bufferSubData(r.ARRAY_BUFFER,n*o*4,i.subarray(n*o,(d+1)*o)),t.clear()}dispose(){this._resizeObs?.disconnect(),this.glCanvas?.remove()}}function e(t,e,i){const o=t.createShader(i);if(t.shaderSource(o,e),t.compileShader(o),!t.getShaderParameter(o,t.COMPILE_STATUS))throw new Error("GL compile: "+t.getShaderInfoLog(o));return o}function i(t,i,o){const a=t.createProgram();if(t.attachShader(a,e(t,i,t.VERTEX_SHADER)),t.attachShader(a,e(t,o,t.FRAGMENT_SHADER)),t.linkProgram(a),!t.getProgramParameter(a,t.LINK_STATUS))throw new Error("GL link: "+t.getProgramInfoLog(a));return a}function o(t){return[parseInt(t.slice(1,3),16)/255,parseInt(t.slice(3,5),16)/255,parseInt(t.slice(5,7),16)/255]}function a(t,e,i,o,a,r,s,n,d){const h=1-t,l=h*h,c=t*t,u=l*h,g=3*l*t,f=3*h*c,v=c*t;return{x:u*e+g*o+f*r+v*n,y:u*i+g*a+f*s+v*d}}function r(t,e,i){return{x:t.x+(e.x-t.x)*i,y:t.y+(e.y-t.y)*i}}export{t as WebGLRenderer};
|
|
@@ -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) :
|
|
@@ -116,7 +116,24 @@ void main() { gl_FragColor = vec4(vColor, 0.85); }`;
|
|
|
116
116
|
if (origMove) {
|
|
117
117
|
f.w.moveSelectedBy = (dx, dy) => {
|
|
118
118
|
origMove.call(f.w, dx, dy);
|
|
119
|
-
|
|
119
|
+
f._ensureAdj?.();
|
|
120
|
+
const adj = f._nodeAdj;
|
|
121
|
+
for (let i = 0; i < f.w.nodeCount_(); i++) {
|
|
122
|
+
if (!f.V.selected[i]) continue;
|
|
123
|
+
this._dirty.add(i);
|
|
124
|
+
// Edges incident on a moved node need their geometry recomputed.
|
|
125
|
+
if (adj && adj[i]) for (let k = 0; k < adj[i].length; k++) this._dirtyEdges.add(adj[i][k]);
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const origMoveNode = f.w.moveNode;
|
|
130
|
+
if (origMoveNode) {
|
|
131
|
+
f.w.moveNode = (id, x, y) => {
|
|
132
|
+
origMoveNode.call(f.w, id, x, y);
|
|
133
|
+
this._dirty.add(id);
|
|
134
|
+
f._ensureAdj?.();
|
|
135
|
+
const adj = f._nodeAdj;
|
|
136
|
+
if (adj && adj[id]) for (let k = 0; k < adj[id].length; k++) this._dirtyEdges.add(adj[id][k]);
|
|
120
137
|
};
|
|
121
138
|
}
|
|
122
139
|
}
|
|
@@ -238,39 +255,27 @@ void main() { gl_FragColor = vec4(vColor, 0.85); }`;
|
|
|
238
255
|
const camWY = f.cam.y + (this.glCanvas.height / (2 * dpr * f.cam.zoom));
|
|
239
256
|
|
|
240
257
|
// ── Detect what needs upload ────────────────────────────────────
|
|
241
|
-
|
|
258
|
+
const nodeStride = NODE_STRIDE_F;
|
|
259
|
+
const edgeStride = EDGE_VERTS_PER * EDGE_STRIDE_F;
|
|
260
|
+
const fullNodes = this._fullRebuildNeeded || n !== this._lastNodeCount;
|
|
261
|
+
const fullEdges = this._fullRebuildNeeded || m !== this._lastEdgeCount;
|
|
262
|
+
if (fullNodes) {
|
|
242
263
|
for (let i = 0; i < n; i++) this._writeNode(i);
|
|
243
264
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeBuf);
|
|
244
|
-
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.nodeData.subarray(0, n *
|
|
265
|
+
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.nodeData.subarray(0, n * nodeStride));
|
|
245
266
|
this._dirty.clear();
|
|
246
267
|
} else if (this._dirty.size) {
|
|
247
|
-
|
|
248
|
-
// Combine contiguous dirty ranges to minimize bufferSubData calls.
|
|
249
|
-
const sorted = [...this._dirty].sort((a, b) => a - b);
|
|
250
|
-
let runStart = sorted[0], runEnd = sorted[0];
|
|
251
|
-
for (let k = 1; k < sorted.length; k++) {
|
|
252
|
-
if (sorted[k] === runEnd + 1) runEnd = sorted[k];
|
|
253
|
-
else {
|
|
254
|
-
for (let i = runStart; i <= runEnd; i++) this._writeNode(i);
|
|
255
|
-
gl.bufferSubData(gl.ARRAY_BUFFER, runStart * NODE_STRIDE_F * 4,
|
|
256
|
-
this.nodeData.subarray(runStart * NODE_STRIDE_F, (runEnd + 1) * NODE_STRIDE_F));
|
|
257
|
-
runStart = sorted[k]; runEnd = sorted[k];
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
for (let i = runStart; i <= runEnd; i++) this._writeNode(i);
|
|
261
|
-
gl.bufferSubData(gl.ARRAY_BUFFER, runStart * NODE_STRIDE_F * 4,
|
|
262
|
-
this.nodeData.subarray(runStart * NODE_STRIDE_F, (runEnd + 1) * NODE_STRIDE_F));
|
|
263
|
-
this._dirty.clear();
|
|
268
|
+
this._uploadRuns(this._dirty, this.nodeBuf, this.nodeData, nodeStride, (i) => this._writeNode(i));
|
|
264
269
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
270
|
+
if (fullEdges) {
|
|
271
|
+
for (let i = 0; i < m; i++) this._writeEdge(i);
|
|
272
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.edgeBuf);
|
|
273
|
+
gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.edgeData.subarray(0, m * edgeStride));
|
|
274
|
+
this._dirtyEdges.clear();
|
|
275
|
+
} else if (this._dirtyEdges.size) {
|
|
276
|
+
// Filter out edges that no longer exist (deletes shift the buffer end).
|
|
277
|
+
for (const e of this._dirtyEdges) if (e >= m) this._dirtyEdges.delete(e);
|
|
278
|
+
this._uploadRuns(this._dirtyEdges, this.edgeBuf, this.edgeData, edgeStride, (i) => this._writeEdge(i));
|
|
274
279
|
}
|
|
275
280
|
|
|
276
281
|
this._lastNodeCount = n;
|
|
@@ -344,9 +349,28 @@ void main() { gl_FragColor = vec4(vColor, 0.85); }`;
|
|
|
344
349
|
|
|
345
350
|
/** Mark a node as needing buffer update. Called from host on move/recolor. */
|
|
346
351
|
markNodeDirty(i) { this._dirty.add(i); }
|
|
347
|
-
markEdgeDirty(i) { this._dirtyEdges.add(i);
|
|
352
|
+
markEdgeDirty(i) { this._dirtyEdges.add(i); }
|
|
348
353
|
markAllDirty() { this._fullRebuildNeeded = true; }
|
|
349
354
|
|
|
355
|
+
/** Upload a dirty Set by collapsing it into contiguous runs of `stride` floats. */
|
|
356
|
+
_uploadRuns(set, buf, dataArr, stride, writeOne) {
|
|
357
|
+
const gl = this.gl;
|
|
358
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
|
|
359
|
+
const sorted = [...set].sort((a, b) => a - b);
|
|
360
|
+
let runStart = sorted[0], runEnd = sorted[0];
|
|
361
|
+
for (let k = 1; k < sorted.length; k++) {
|
|
362
|
+
if (sorted[k] === runEnd + 1) { runEnd = sorted[k]; continue; }
|
|
363
|
+
for (let i = runStart; i <= runEnd; i++) writeOne(i);
|
|
364
|
+
gl.bufferSubData(gl.ARRAY_BUFFER, runStart * stride * 4,
|
|
365
|
+
dataArr.subarray(runStart * stride, (runEnd + 1) * stride));
|
|
366
|
+
runStart = sorted[k]; runEnd = sorted[k];
|
|
367
|
+
}
|
|
368
|
+
for (let i = runStart; i <= runEnd; i++) writeOne(i);
|
|
369
|
+
gl.bufferSubData(gl.ARRAY_BUFFER, runStart * stride * 4,
|
|
370
|
+
dataArr.subarray(runStart * stride, (runEnd + 1) * stride));
|
|
371
|
+
set.clear();
|
|
372
|
+
}
|
|
373
|
+
|
|
350
374
|
dispose() {
|
|
351
375
|
this._resizeObs?.disconnect();
|
|
352
376
|
this.glCanvas?.remove();
|