@luispm/zflow-graph 0.1.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.
@@ -0,0 +1,246 @@
1
+ /*! @luispm/zflow-graph v0.1.0 | MIT | (c) 2026 */
2
+ (function (global, factory) {
3
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
4
+ typeof define === 'function' && define.amd ? define(['exports'], factory) :
5
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.ZFlowYjs = {}));
6
+ })(this, (function (exports) { 'use strict';
7
+
8
+ // zflow ↔ Yjs adapter — real multiplayer for the graph.
9
+ //
10
+ // Usage:
11
+ // import { ZFlow } from '../zflow.js';
12
+ // import { bindYjs } from '../adapters/yjs.js';
13
+ // import * as Y from 'yjs';
14
+ // import { WebsocketProvider } from 'y-websocket';
15
+ //
16
+ // const flow = await ZFlow.create({ container, wasmUrl });
17
+ // const ydoc = new Y.Doc();
18
+ // new WebsocketProvider('wss://demos.yjs.dev', 'my-room', ydoc);
19
+ // const binding = bindYjs(flow, ydoc, { userId: 'alice', userName: 'Alice', color: '#c062e8' });
20
+ //
21
+ // What it syncs:
22
+ // • Nodes (Y.Map keyed by stable client-side uuid → { id, kind, x, y, w, h, title, color, ... })
23
+ // • Edges (Y.Map keyed by uuid → { from, to, fp, tp, label })
24
+ // • Awareness (cursor position, selection, name, color)
25
+ //
26
+ // Conflict policy: last-write-wins per field via Y.Map. Position updates are
27
+ // throttled to ~30 Hz so dragging produces ~smooth remote motion without
28
+ // flooding the wire.
29
+
30
+
31
+ function bindYjs(flow, ydoc, opts = {}) {
32
+ const userId = opts.userId || 'user-' + Math.random().toString(36).slice(2, 8);
33
+ const userName = opts.userName || userId;
34
+ const userColor = opts.color || pickColor(userId);
35
+
36
+ const ynodes = ydoc.getMap('zflow.nodes');
37
+ const yedges = ydoc.getMap('zflow.edges');
38
+ const ymeta = ydoc.getMap('zflow.meta');
39
+ const aware = opts.awareness || null; // y-protocols/awareness.Awareness, if provided
40
+
41
+ // Bidirectional mapping between local numeric ids and stable Y uuids.
42
+ const localToUuid = new Map(); // nodeId -> uuid
43
+ const uuidToLocal = new Map(); // uuid -> nodeId
44
+ const edgeLocalToUuid = new Map();
45
+ const edgeUuidToLocal = new Map();
46
+
47
+ let applyingRemote = false; // re-entrancy guard
48
+ let pendingPosFlush = null; // throttle handle
49
+
50
+ // ── Local → Remote ─────────────────────────────────────────────────
51
+ // We intercept the high-level mutators by wrapping the WASM exports so
52
+ // every change locally also writes to Yjs.
53
+ const origAddNode = flow.addNode.bind(flow);
54
+ flow.addNode = (spec = {}) => {
55
+ const id = origAddNode(spec);
56
+ if (id < 0 || applyingRemote) return id;
57
+ const uuid = newUuid();
58
+ localToUuid.set(id, uuid);
59
+ uuidToLocal.set(uuid, id);
60
+ ynodes.set(uuid, captureNode(flow, id, spec));
61
+ return id;
62
+ };
63
+
64
+ const origAddEdge = flow.addEdge.bind(flow);
65
+ flow.addEdge = (spec = {}) => {
66
+ const id = origAddEdge(spec);
67
+ if (id < 0 || applyingRemote) return id;
68
+ const uuid = newUuid();
69
+ edgeLocalToUuid.set(id, uuid);
70
+ edgeUuidToLocal.set(uuid, id);
71
+ const fromU = localToUuid.get(typeof spec.from === 'number' ? spec.from : -1);
72
+ const toU = localToUuid.get(typeof spec.to === 'number' ? spec.to : -1);
73
+ yedges.set(uuid, { from: fromU, to: toU, fp: spec.fp ?? 0, tp: spec.tp ?? 0, label: spec.label || null });
74
+ return id;
75
+ };
76
+
77
+ // Intercept deleteSelection so each removed local id pulls its uuid out of Y.
78
+ const origDelete = flow.deleteSelection.bind(flow);
79
+ flow.deleteSelection = () => {
80
+ if (applyingRemote) return origDelete();
81
+ const toRemove = [];
82
+ for (let i = 0; i < flow.w.nodeCount_(); i++) if (flow.V.selected[i]) toRemove.push(i);
83
+ origDelete();
84
+ // Local ids shift after delete; clear the affected uuid mappings by re-scan.
85
+ ydoc.transact(() => {
86
+ for (const localId of toRemove) {
87
+ const uuid = localToUuid.get(localId);
88
+ if (uuid) { ynodes.delete(uuid); localToUuid.delete(localId); uuidToLocal.delete(uuid); }
89
+ }
90
+ }, 'local-delete');
91
+ };
92
+
93
+ // Throttle dragging updates.
94
+ flow.on('change', () => {
95
+ if (applyingRemote) return;
96
+ if (pendingPosFlush) return;
97
+ pendingPosFlush = setTimeout(() => {
98
+ pendingPosFlush = null;
99
+ ydoc.transact(() => {
100
+ for (const [localId, uuid] of localToUuid) {
101
+ if (localId >= flow.w.nodeCount_()) continue;
102
+ const cur = ynodes.get(uuid);
103
+ if (!cur) continue;
104
+ const next = captureNode(flow, localId);
105
+ if (cur.x !== next.x || cur.y !== next.y || cur.w !== next.w || cur.h !== next.h ||
106
+ cur.title !== next.title || cur.color !== next.color) {
107
+ ynodes.set(uuid, { ...cur, ...next });
108
+ }
109
+ }
110
+ }, 'local-pos');
111
+ }, 33);
112
+ });
113
+
114
+ // ── Remote → Local ─────────────────────────────────────────────────
115
+ ynodes.observe((event) => {
116
+ if (event.transaction.origin === 'local-pos') return;
117
+ applyingRemote = true;
118
+ try {
119
+ event.changes.keys.forEach((change, uuid) => {
120
+ if (change.action === 'add') { addRemoteNode(uuid, ynodes.get(uuid)); }
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
+ }
134
+ });
135
+ } finally { applyingRemote = false; }
136
+ });
137
+
138
+ yedges.observe((event) => {
139
+ applyingRemote = true;
140
+ try {
141
+ event.changes.keys.forEach((change, uuid) => {
142
+ if (change.action === 'add') {
143
+ const e = yedges.get(uuid);
144
+ const from = uuidToLocal.get(e.from), to = uuidToLocal.get(e.to);
145
+ if (from !== undefined && to !== undefined) {
146
+ const localId = origAddEdge({ from, to, fp: e.fp, tp: e.tp, label: e.label });
147
+ edgeLocalToUuid.set(localId, uuid);
148
+ edgeUuidToLocal.set(uuid, localId);
149
+ }
150
+ }
151
+ if (change.action === 'delete') {
152
+ // Local-side deletion isn't wired through a single API yet; leave as a TODO.
153
+ }
154
+ });
155
+ } finally { applyingRemote = false; }
156
+ });
157
+
158
+ // ── Awareness (cursors + selection) ────────────────────────────────
159
+ if (aware) {
160
+ aware.setLocalStateField('user', { name: userName, color: userColor });
161
+ flow.canvas.addEventListener('mousemove', (e) => {
162
+ const wp = flow._s2w(e.clientX, e.clientY);
163
+ aware.setLocalStateField('cursor', { x: wp.x, y: wp.y });
164
+ });
165
+ aware.on('change', () => {
166
+ const states = aware.getStates();
167
+ flow.clearRemoteCursors();
168
+ for (const [clientId, state] of states) {
169
+ if (clientId === aware.clientID) continue;
170
+ const u = state.user || {}, c = state.cursor;
171
+ if (c) flow.setRemoteCursor(String(clientId), c.x, c.y, u.name || String(clientId), u.color || '#5be0d0');
172
+ }
173
+ });
174
+ }
175
+
176
+ // ── Initial backfill: pull whatever's already in the Y.Doc ─────────
177
+ applyingRemote = true;
178
+ try {
179
+ for (const [uuid, spec] of ynodes.entries()) addRemoteNode(uuid, spec);
180
+ for (const [uuid, spec] of yedges.entries()) {
181
+ const from = uuidToLocal.get(spec.from), to = uuidToLocal.get(spec.to);
182
+ if (from !== undefined && to !== undefined) {
183
+ const localId = origAddEdge({ from, to, fp: spec.fp, tp: spec.tp, label: spec.label });
184
+ edgeLocalToUuid.set(localId, uuid);
185
+ edgeUuidToLocal.set(uuid, localId);
186
+ }
187
+ }
188
+ } finally { applyingRemote = false; }
189
+
190
+ // ── Public adapter handle ──────────────────────────────────────────
191
+ return {
192
+ ynodes, yedges, ymeta, ydoc,
193
+ userId, userName, userColor,
194
+ destroy() {
195
+ flow.addNode = origAddNode;
196
+ flow.addEdge = origAddEdge;
197
+ },
198
+ };
199
+
200
+ // ── helpers ────────────────────────────────────────────────────────
201
+ function addRemoteNode(uuid, spec) {
202
+ if (uuidToLocal.has(uuid)) return;
203
+ const localId = origAddNode({
204
+ kind: spec.kind, x: spec.x, y: spec.y,
205
+ w: spec.w, h: spec.h, title: spec.title, color: spec.color,
206
+ });
207
+ if (localId < 0) return;
208
+ localToUuid.set(localId, uuid);
209
+ uuidToLocal.set(uuid, localId);
210
+ }
211
+ function updateRemoteNode(uuid, spec) {
212
+ const localId = uuidToLocal.get(uuid);
213
+ if (localId === undefined) return;
214
+ if (spec.x !== undefined && spec.y !== undefined) {
215
+ flow.V.posX[localId] = spec.x; flow.V.posY[localId] = spec.y;
216
+ }
217
+ if (spec.w !== undefined) flow.V.sizeW[localId] = spec.w;
218
+ if (spec.h !== undefined) flow.V.sizeH[localId] = spec.h;
219
+ if (spec.title) flow.titles.set(localId, spec.title);
220
+ if (spec.color) flow.colors.set(localId, spec.color);
221
+ }
222
+ }
223
+
224
+ function captureNode(flow, id, spec = {}) {
225
+ const cat = flow.kinds[flow.V.kind[id]];
226
+ return {
227
+ kind: cat.name,
228
+ x: flow.V.posX[id], y: flow.V.posY[id],
229
+ w: flow.V.sizeW[id], h: flow.V.sizeH[id],
230
+ title: flow.titles.get(id) || spec.title || null,
231
+ color: flow.colors.get(id) || spec.color || null,
232
+ };
233
+ }
234
+ function newUuid() {
235
+ return 'u_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
236
+ }
237
+ const PALETTE = ['#5b8def', '#c062e8', '#5bd17a', '#f0b93a', '#5be0d0', '#fb923c', '#e8462b'];
238
+ function pickColor(seed) {
239
+ let h = 0; for (const ch of seed) h = (h * 31 + ch.charCodeAt(0)) | 0;
240
+ return PALETTE[Math.abs(h) % PALETTE.length];
241
+ }
242
+
243
+ exports.bindYjs = bindYjs;
244
+
245
+ }));
246
+ //# sourceMappingURL=yjs.umd.js.map
@@ -0,0 +1,376 @@
1
+ /*! @luispm/zflow-graph v0.1.0 | MIT | (c) 2026 */
2
+ // zflow WebGL renderer — optimized path.
3
+ //
4
+ // Architecture:
5
+ // • One shared static quad geometry (6 verts, never changes).
6
+ // • Per-node attributes (center, size, color, sel) live in a persistent
7
+ // instance buffer sized at nodeCap() at init time. We never allocate
8
+ // per-frame — we update only the slots that the host marked dirty.
9
+ // • Camera (pan/zoom) is uniform-only, so panning is FREE in buffer terms.
10
+ // • ANGLE_instanced_arrays draws all nodes in a single drawCall.
11
+ // • Edges keep a persistent buffer too with dirty tracking + bezier
12
+ // tesselation regenerated only when an endpoint moves.
13
+ //
14
+ // Result: 100k nodes pan/zoom at 60 fps with zero GC. Adding/moving a
15
+ // single node touches ~28 bytes of GPU memory, not 7 MB.
16
+
17
+ const VS = `
18
+ attribute vec2 aQuad;
19
+ attribute vec2 aCenter;
20
+ attribute vec2 aSize;
21
+ attribute vec3 aColor;
22
+ attribute float aSelected;
23
+ uniform vec2 uCam;
24
+ uniform float uZoom;
25
+ uniform vec2 uScreen;
26
+ varying vec3 vColor;
27
+ varying float vSelected;
28
+ varying vec2 vUv;
29
+ void main() {
30
+ vUv = aQuad;
31
+ vSelected = aSelected;
32
+ vColor = aColor;
33
+ vec2 worldPos = aCenter + aQuad * aSize;
34
+ vec2 screen = (worldPos + uCam) * uZoom;
35
+ vec2 ndc = (screen / uScreen) * 2.0;
36
+ gl_Position = vec4(ndc.x, -ndc.y, 0.0, 1.0);
37
+ }`;
38
+
39
+ const FS = `
40
+ precision mediump float;
41
+ varying vec3 vColor;
42
+ varying float vSelected;
43
+ varying vec2 vUv;
44
+ void main() {
45
+ vec2 q = abs(vUv);
46
+ float d = max(q.x, q.y);
47
+ float alpha = smoothstep(1.0, 0.92, d);
48
+ float header = step(0.7, vUv.y) * 0.18;
49
+ vec3 col = vColor + vec3(header);
50
+ if (vSelected > 0.5) col = mix(col, vec3(0.94, 0.73, 0.23), 0.55);
51
+ gl_FragColor = vec4(col, alpha);
52
+ }`;
53
+
54
+ const EDGE_VS = `
55
+ attribute vec2 aPos;
56
+ attribute vec3 aColor;
57
+ uniform vec2 uCam;
58
+ uniform float uZoom;
59
+ uniform vec2 uScreen;
60
+ varying vec3 vColor;
61
+ void main() {
62
+ vColor = aColor;
63
+ vec2 screen = (aPos + uCam) * uZoom;
64
+ vec2 ndc = (screen / uScreen) * 2.0;
65
+ gl_Position = vec4(ndc.x, -ndc.y, 0.0, 1.0);
66
+ }`;
67
+
68
+ const EDGE_FS = `
69
+ precision mediump float;
70
+ varying vec3 vColor;
71
+ void main() { gl_FragColor = vec4(vColor, 0.85); }`;
72
+
73
+ const NODE_STRIDE_F = 8; // cx, cy, sw, sh, r, g, b, sel
74
+ const EDGE_SEGS = 24;
75
+ const EDGE_VERTS_PER = (EDGE_SEGS) * 2;
76
+ const EDGE_STRIDE_F = 5; // x, y, r, g, b per vertex
77
+
78
+ class WebGLRenderer {
79
+ constructor(flow) {
80
+ this.flow = flow;
81
+ this.glCanvas = document.createElement('canvas');
82
+ this.glCanvas.style.cssText = `position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:0;`;
83
+ flow.container.insertBefore(this.glCanvas, flow.canvas);
84
+ flow.canvas.style.background = 'transparent';
85
+ flow.canvas.style.position = 'absolute';
86
+ flow.canvas.style.zIndex = '1';
87
+ this.gl = this.glCanvas.getContext('webgl', { antialias: true, alpha: true, premultipliedAlpha: false });
88
+ if (!this.gl) { this.disabled = true; console.warn('zflow: WebGL unavailable'); return; }
89
+ this.instExt = this.gl.getExtension('ANGLE_instanced_arrays');
90
+ this.cap = flow.w.nodeCap();
91
+ this.edgeCap = flow.w.edgeCap();
92
+ this._resize();
93
+ this._setupShaders();
94
+ this._setupBuffers();
95
+ this._hookDirty();
96
+ this._resizeObs = new ResizeObserver(() => this._resize());
97
+ this._resizeObs.observe(flow.container);
98
+ this._dirty = new Set(); // node ids needing buffer upload
99
+ this._dirtyEdges = new Set();
100
+ this._fullRebuildNeeded = true;
101
+ this._lastNodeCount = 0;
102
+ this._lastEdgeCount = 0;
103
+ }
104
+
105
+ _hookDirty() {
106
+ const f = this.flow;
107
+ f.on('change', () => { this._fullRebuildNeeded = true; });
108
+ // Hijack moveSelectedBy / moveNode so position changes only mark dirty.
109
+ const origMove = f.w.moveSelectedBy;
110
+ if (origMove) {
111
+ f.w.moveSelectedBy = (dx, dy) => {
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);
114
+ };
115
+ }
116
+ }
117
+
118
+ _resize() {
119
+ const dpr = window.devicePixelRatio || 1;
120
+ const r = this.flow.container.getBoundingClientRect();
121
+ this.glCanvas.width = r.width * dpr;
122
+ this.glCanvas.height = r.height * dpr;
123
+ this.gl?.viewport(0, 0, this.glCanvas.width, this.glCanvas.height);
124
+ }
125
+
126
+ _setupShaders() {
127
+ const gl = this.gl;
128
+ this.progNode = link(gl, VS, FS);
129
+ this.progEdge = link(gl, EDGE_VS, EDGE_FS);
130
+ // Cache uniform/attrib locations.
131
+ this.locN = {
132
+ aQuad: gl.getAttribLocation(this.progNode, 'aQuad'),
133
+ aCenter: gl.getAttribLocation(this.progNode, 'aCenter'),
134
+ aSize: gl.getAttribLocation(this.progNode, 'aSize'),
135
+ aColor: gl.getAttribLocation(this.progNode, 'aColor'),
136
+ aSel: gl.getAttribLocation(this.progNode, 'aSelected'),
137
+ uCam: gl.getUniformLocation(this.progNode, 'uCam'),
138
+ uZoom: gl.getUniformLocation(this.progNode, 'uZoom'),
139
+ uScreen: gl.getUniformLocation(this.progNode, 'uScreen'),
140
+ };
141
+ this.locE = {
142
+ aPos: gl.getAttribLocation(this.progEdge, 'aPos'),
143
+ aColor: gl.getAttribLocation(this.progEdge, 'aColor'),
144
+ uCam: gl.getUniformLocation(this.progEdge, 'uCam'),
145
+ uZoom: gl.getUniformLocation(this.progEdge, 'uZoom'),
146
+ uScreen: gl.getUniformLocation(this.progEdge, 'uScreen'),
147
+ };
148
+ }
149
+
150
+ _setupBuffers() {
151
+ const gl = this.gl;
152
+ // Shared quad: 6 verts, 2 floats each, static.
153
+ this.quadBuf = gl.createBuffer();
154
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
155
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
156
+ -1, -1, 1, -1, -1, 1,
157
+ 1, -1, 1, 1, -1, 1,
158
+ ]), gl.STATIC_DRAW);
159
+
160
+ // Per-instance node buffer pre-allocated at full cap.
161
+ this.nodeData = new Float32Array(this.cap * NODE_STRIDE_F);
162
+ this.nodeBuf = gl.createBuffer();
163
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeBuf);
164
+ gl.bufferData(gl.ARRAY_BUFFER, this.nodeData.byteLength, gl.DYNAMIC_DRAW);
165
+
166
+ // Edge buffer pre-allocated.
167
+ this.edgeData = new Float32Array(this.edgeCap * EDGE_VERTS_PER * EDGE_STRIDE_F);
168
+ this.edgeBuf = gl.createBuffer();
169
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.edgeBuf);
170
+ gl.bufferData(gl.ARRAY_BUFFER, this.edgeData.byteLength, gl.DYNAMIC_DRAW);
171
+ }
172
+
173
+ _writeNode(i) {
174
+ const f = this.flow;
175
+ const cat = f.kinds[f.V.kind[i]];
176
+ const hex = f.colors.get(i) || cat.color;
177
+ const off = i * NODE_STRIDE_F;
178
+ this.nodeData[off ] = f.V.posX[i];
179
+ this.nodeData[off + 1] = f.V.posY[i];
180
+ this.nodeData[off + 2] = f.V.sizeW[i] * 0.5;
181
+ this.nodeData[off + 3] = f.V.sizeH[i] * 0.5;
182
+ const [r, g, b] = parseHex(hex);
183
+ this.nodeData[off + 4] = r;
184
+ this.nodeData[off + 5] = g;
185
+ this.nodeData[off + 6] = b;
186
+ this.nodeData[off + 7] = f.V.selected[i] !== 0 ? 1 : 0;
187
+ }
188
+
189
+ _writeEdge(i) {
190
+ const f = this.flow;
191
+ const a = f.V.edgeFromN[i], b = f.V.edgeToN[i];
192
+ const ap = f._portWorld(a, 1, f.V.edgeFromP[i]);
193
+ const bp = f._portWorld(b, 0, f.V.edgeToP[i]);
194
+ const col = parseHex(f.colors.get(a) || f.kinds[f.V.kind[a]].color);
195
+ const off = i * EDGE_VERTS_PER * EDGE_STRIDE_F;
196
+ const ortho = f.options.edgeStyle === 'orthogonal';
197
+ const offCv = Math.max(50, Math.abs(bp.x - ap.x) * 0.5 + Math.abs(bp.y - ap.y) * 0.4);
198
+ let prev = { x: ap.x, y: ap.y };
199
+ let o = off;
200
+ for (let s = 1; s <= EDGE_SEGS; s++) {
201
+ const t = s / EDGE_SEGS;
202
+ let pt;
203
+ if (ortho) {
204
+ const mx = (ap.x + bp.x) * 0.5;
205
+ pt = t < 0.33 ? lerp(ap, { x: mx, y: ap.y }, t / 0.33)
206
+ : t < 0.67 ? lerp({ x: mx, y: ap.y }, { x: mx, y: bp.y }, (t - 0.33) / 0.34)
207
+ : lerp({ x: mx, y: bp.y }, bp, (t - 0.67) / 0.33);
208
+ } else {
209
+ pt = bezPt(t, ap.x, ap.y, ap.x + offCv, ap.y, bp.x - offCv, bp.y, bp.x, bp.y);
210
+ }
211
+ this.edgeData[o++] = prev.x; this.edgeData[o++] = prev.y;
212
+ this.edgeData[o++] = col[0]; this.edgeData[o++] = col[1]; this.edgeData[o++] = col[2];
213
+ this.edgeData[o++] = pt.x; this.edgeData[o++] = pt.y;
214
+ this.edgeData[o++] = col[0]; this.edgeData[o++] = col[1]; this.edgeData[o++] = col[2];
215
+ prev = pt;
216
+ }
217
+ }
218
+
219
+ render() {
220
+ if (this.disabled) return;
221
+ const gl = this.gl;
222
+ const f = this.flow;
223
+ const n = f.w.nodeCount_(), m = f.w.edgeCount_();
224
+ const dpr = window.devicePixelRatio || 1;
225
+
226
+ gl.clearColor(0.027, 0.035, 0.06, 1.0);
227
+ gl.clear(gl.COLOR_BUFFER_BIT);
228
+ gl.enable(gl.BLEND);
229
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
230
+
231
+ const camWX = f.cam.x + (this.glCanvas.width / (2 * dpr * f.cam.zoom));
232
+ const camWY = f.cam.y + (this.glCanvas.height / (2 * dpr * f.cam.zoom));
233
+
234
+ // ── Detect what needs upload ────────────────────────────────────
235
+ if (this._fullRebuildNeeded || n !== this._lastNodeCount) {
236
+ for (let i = 0; i < n; i++) this._writeNode(i);
237
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeBuf);
238
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.nodeData.subarray(0, n * NODE_STRIDE_F));
239
+ this._dirty.clear();
240
+ } 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();
258
+ }
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
+ }
268
+ }
269
+
270
+ this._lastNodeCount = n;
271
+ this._lastEdgeCount = m;
272
+ this._fullRebuildNeeded = false;
273
+
274
+ // ── Draw edges (LINES, persistent buffer) ────────────────────────
275
+ if (m > 0) {
276
+ gl.useProgram(this.progEdge);
277
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.edgeBuf);
278
+ gl.enableVertexAttribArray(this.locE.aPos);
279
+ gl.vertexAttribPointer(this.locE.aPos, 2, gl.FLOAT, false, 5 * 4, 0);
280
+ gl.enableVertexAttribArray(this.locE.aColor);
281
+ gl.vertexAttribPointer(this.locE.aColor, 3, gl.FLOAT, false, 5 * 4, 2 * 4);
282
+ gl.uniform2f(this.locE.uCam, camWX, camWY);
283
+ gl.uniform1f(this.locE.uZoom, f.cam.zoom * dpr);
284
+ gl.uniform2f(this.locE.uScreen, this.glCanvas.width, this.glCanvas.height);
285
+ gl.lineWidth(1.6);
286
+ gl.drawArrays(gl.LINES, 0, m * EDGE_VERTS_PER);
287
+ }
288
+
289
+ // ── Draw nodes ──────────────────────────────────────────────────
290
+ if (n > 0) {
291
+ gl.useProgram(this.progNode);
292
+ // aQuad from static buffer.
293
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
294
+ gl.enableVertexAttribArray(this.locN.aQuad);
295
+ gl.vertexAttribPointer(this.locN.aQuad, 2, gl.FLOAT, false, 0, 0);
296
+ if (this.instExt) this.instExt.vertexAttribDivisorANGLE(this.locN.aQuad, 0);
297
+ // per-instance attribs from node buffer.
298
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeBuf);
299
+ const s = NODE_STRIDE_F * 4;
300
+ gl.enableVertexAttribArray(this.locN.aCenter);
301
+ gl.vertexAttribPointer(this.locN.aCenter, 2, gl.FLOAT, false, s, 0);
302
+ gl.enableVertexAttribArray(this.locN.aSize);
303
+ gl.vertexAttribPointer(this.locN.aSize, 2, gl.FLOAT, false, s, 2 * 4);
304
+ gl.enableVertexAttribArray(this.locN.aColor);
305
+ gl.vertexAttribPointer(this.locN.aColor, 3, gl.FLOAT, false, s, 4 * 4);
306
+ gl.enableVertexAttribArray(this.locN.aSel);
307
+ gl.vertexAttribPointer(this.locN.aSel, 1, gl.FLOAT, false, s, 7 * 4);
308
+ if (this.instExt) {
309
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aCenter, 1);
310
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aSize, 1);
311
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aColor, 1);
312
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aSel, 1);
313
+ }
314
+ gl.uniform2f(this.locN.uCam, camWX, camWY);
315
+ gl.uniform1f(this.locN.uZoom, f.cam.zoom * dpr);
316
+ gl.uniform2f(this.locN.uScreen, this.glCanvas.width, this.glCanvas.height);
317
+ if (this.instExt) {
318
+ this.instExt.drawArraysInstancedANGLE(gl.TRIANGLES, 0, 6, n);
319
+ // Reset divisors so other passes (edges) work correctly.
320
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aCenter, 0);
321
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aSize, 0);
322
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aColor, 0);
323
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aSel, 0);
324
+ } else {
325
+ // Slow path fallback: 6 verts per node, no extension.
326
+ // (rare; almost every browser since 2014 has the extension)
327
+ for (let i = 0; i < n; i++) {
328
+ const off = i * NODE_STRIDE_F;
329
+ gl.vertexAttrib2f(this.locN.aCenter, this.nodeData[off], this.nodeData[off + 1]);
330
+ gl.vertexAttrib2f(this.locN.aSize, this.nodeData[off + 2], this.nodeData[off + 3]);
331
+ gl.vertexAttrib3f(this.locN.aColor, this.nodeData[off + 4], this.nodeData[off + 5], this.nodeData[off + 6]);
332
+ gl.vertexAttrib1f(this.locN.aSel, this.nodeData[off + 7]);
333
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
334
+ }
335
+ }
336
+ }
337
+ }
338
+
339
+ /** Mark a node as needing buffer update. Called from host on move/recolor. */
340
+ markNodeDirty(i) { this._dirty.add(i); }
341
+ markEdgeDirty(i) { this._dirtyEdges.add(i); this._fullRebuildNeeded = true; }
342
+ markAllDirty() { this._fullRebuildNeeded = true; }
343
+
344
+ dispose() {
345
+ this._resizeObs?.disconnect();
346
+ this.glCanvas?.remove();
347
+ }
348
+ }
349
+
350
+ // ── helpers ───────────────────────────────────────────────────────────────
351
+ function compile(gl, src, kind) {
352
+ const s = gl.createShader(kind);
353
+ gl.shaderSource(s, src); gl.compileShader(s);
354
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error('GL compile: ' + gl.getShaderInfoLog(s));
355
+ return s;
356
+ }
357
+ function link(gl, vs, fs) {
358
+ const p = gl.createProgram();
359
+ gl.attachShader(p, compile(gl, vs, gl.VERTEX_SHADER));
360
+ gl.attachShader(p, compile(gl, fs, gl.FRAGMENT_SHADER));
361
+ gl.linkProgram(p);
362
+ if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error('GL link: ' + gl.getProgramInfoLog(p));
363
+ return p;
364
+ }
365
+ function parseHex(h) {
366
+ return [parseInt(h.slice(1, 3), 16) / 255, parseInt(h.slice(3, 5), 16) / 255, parseInt(h.slice(5, 7), 16) / 255];
367
+ }
368
+ function bezPt(t, x1, y1, cx1, cy1, cx2, cy2, x2, y2) {
369
+ const mt = 1 - t, mt2 = mt * mt, t2 = t * t;
370
+ const a = mt2 * mt, b = 3 * mt2 * t, c = 3 * mt * t2, d = t2 * t;
371
+ return { x: a*x1 + b*cx1 + c*cx2 + d*x2, y: a*y1 + b*cy1 + c*cy2 + d*y2 };
372
+ }
373
+ function lerp(a, b, t) { return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; }
374
+
375
+ export { WebGLRenderer };
376
+ //# sourceMappingURL=webgl-renderer.esm.js.map
@@ -0,0 +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};