@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 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) / deleteSelection() / moveNode(id, x, y)
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
- // Rich content
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
- // Serialization
199
- flow.toJSON() / loadJSON(data) / exportSVG() / exportPNG()
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/luisg/@luispm/zflow-graph
265
- cd @luispm/zflow-graph
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 # 47 tests across 6 files
370
+ npm test # 61 tests across 7 files
270
371
  ```
271
372
 
272
373
  ## Security
@@ -1,4 +1,4 @@
1
- /*! @luispm/zflow-graph v0.1.0 | MIT | (c) 2026 */
1
+ /*! @luispm/zflow-graph v0.2.0 | MIT | (c) 2026 */
2
2
  // zflow ↔ 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.V.posX[localId] = spec.x; flow.V.posY[localId] = spec.y;
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.1.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,y=new Map,m=new Map;let b=!1,h=null;const w=e.addNode.bind(e);e.addNode=(n={})=>{const s=w(n);if(s<0||b)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||b)return t;const n=o();y.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(b)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",()=>{b||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){b=!0;try{t.changes.keys.forEach((t,o)=>{if("add"===t.action&&x(o,d.get(o)),"update"===t.action&&function(t,o){const n=p.get(t);if(void 0===n)return;void 0!==o.x&&void 0!==o.y&&(e.V.posX[n]=o.x,e.V.posY[n]=o.y);void 0!==o.w&&(e.V.sizeW[n]=o.w);void 0!==o.h&&(e.V.sizeH[n]=o.h);o.title&&e.titles.set(n,o.title);o.color&&e.colors.set(n,o.color)}(o,d.get(o)),"delete"===t.action){const t=p.get(o);if(void 0!==t){e.w.setSelected(t,1);const n=e.deleteSelection;e.deleteSelection=S;try{e.deleteSelection()}finally{e.deleteSelection=n}g.delete(t),p.delete(o)}}})}finally{b=!1}}}),a.observe(e=>{b=!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});y.set(s,t),m.set(t,s)}}e.action})}finally{b=!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")}})),b=!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});y.set(s,e),m.set(e,s)}}}finally{b=!1}return{ynodes:d,yedges:a,ymeta:f,ydoc:s,userId:r,userName:c,userColor:i,destroy(){e.addNode=w,e.addEdge=v}};function x(e,t){if(p.has(e))return;const o=w({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};
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};
@@ -1,4 +1,4 @@
1
- /*! @luispm/zflow-graph v0.1.0 | MIT | (c) 2026 */
1
+ /*! @luispm/zflow-graph v0.2.0 | MIT | (c) 2026 */
2
2
  (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.V.posX[localId] = spec.x; flow.V.posY[localId] = spec.y;
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.0 | MIT | (c) 2026 */
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
- for (let i = 0; i < f.w.nodeCount_(); i++) if (f.V.selected[i]) this._dirty.add(i);
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
- if (this._fullRebuildNeeded || n !== this._lastNodeCount) {
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 * NODE_STRIDE_F));
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
- gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeBuf);
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
- if (this._fullRebuildNeeded || m !== this._lastEdgeCount || this._dirty.size === 0) {
261
- // Edges depend on node positions, but only rebuild on full-rebuild path
262
- // since we keep no per-edge incremental delta yet.
263
- if (this._fullRebuildNeeded || m !== this._lastEdgeCount) {
264
- for (let i = 0; i < m; i++) this._writeEdge(i);
265
- gl.bindBuffer(gl.ARRAY_BUFFER, this.edgeBuf);
266
- gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.edgeData.subarray(0, m * EDGE_VERTS_PER * EDGE_STRIDE_F));
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); this._fullRebuildNeeded = true; }
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.1.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);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.0 | MIT | (c) 2026 */
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
- for (let i = 0; i < f.w.nodeCount_(); i++) if (f.V.selected[i]) this._dirty.add(i);
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
- if (this._fullRebuildNeeded || n !== this._lastNodeCount) {
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 * NODE_STRIDE_F));
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
- gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeBuf);
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
- if (this._fullRebuildNeeded || m !== this._lastEdgeCount || this._dirty.size === 0) {
267
- // Edges depend on node positions, but only rebuild on full-rebuild path
268
- // since we keep no per-edge incremental delta yet.
269
- if (this._fullRebuildNeeded || m !== this._lastEdgeCount) {
270
- for (let i = 0; i < m; i++) this._writeEdge(i);
271
- gl.bindBuffer(gl.ARRAY_BUFFER, this.edgeBuf);
272
- gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.edgeData.subarray(0, m * EDGE_VERTS_PER * EDGE_STRIDE_F));
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); this._fullRebuildNeeded = true; }
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();