@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,384 @@
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.ZFlowWebGL = {}));
6
+ })(this, (function (exports) { 'use strict';
7
+
8
+ // zflow WebGL renderer — optimized path.
9
+ //
10
+ // Architecture:
11
+ // • One shared static quad geometry (6 verts, never changes).
12
+ // • Per-node attributes (center, size, color, sel) live in a persistent
13
+ // instance buffer sized at nodeCap() at init time. We never allocate
14
+ // per-frame — we update only the slots that the host marked dirty.
15
+ // • Camera (pan/zoom) is uniform-only, so panning is FREE in buffer terms.
16
+ // • ANGLE_instanced_arrays draws all nodes in a single drawCall.
17
+ // • Edges keep a persistent buffer too with dirty tracking + bezier
18
+ // tesselation regenerated only when an endpoint moves.
19
+ //
20
+ // Result: 100k nodes pan/zoom at 60 fps with zero GC. Adding/moving a
21
+ // single node touches ~28 bytes of GPU memory, not 7 MB.
22
+
23
+ const VS = `
24
+ attribute vec2 aQuad;
25
+ attribute vec2 aCenter;
26
+ attribute vec2 aSize;
27
+ attribute vec3 aColor;
28
+ attribute float aSelected;
29
+ uniform vec2 uCam;
30
+ uniform float uZoom;
31
+ uniform vec2 uScreen;
32
+ varying vec3 vColor;
33
+ varying float vSelected;
34
+ varying vec2 vUv;
35
+ void main() {
36
+ vUv = aQuad;
37
+ vSelected = aSelected;
38
+ vColor = aColor;
39
+ vec2 worldPos = aCenter + aQuad * aSize;
40
+ vec2 screen = (worldPos + uCam) * uZoom;
41
+ vec2 ndc = (screen / uScreen) * 2.0;
42
+ gl_Position = vec4(ndc.x, -ndc.y, 0.0, 1.0);
43
+ }`;
44
+
45
+ const FS = `
46
+ precision mediump float;
47
+ varying vec3 vColor;
48
+ varying float vSelected;
49
+ varying vec2 vUv;
50
+ void main() {
51
+ vec2 q = abs(vUv);
52
+ float d = max(q.x, q.y);
53
+ float alpha = smoothstep(1.0, 0.92, d);
54
+ float header = step(0.7, vUv.y) * 0.18;
55
+ vec3 col = vColor + vec3(header);
56
+ if (vSelected > 0.5) col = mix(col, vec3(0.94, 0.73, 0.23), 0.55);
57
+ gl_FragColor = vec4(col, alpha);
58
+ }`;
59
+
60
+ const EDGE_VS = `
61
+ attribute vec2 aPos;
62
+ attribute vec3 aColor;
63
+ uniform vec2 uCam;
64
+ uniform float uZoom;
65
+ uniform vec2 uScreen;
66
+ varying vec3 vColor;
67
+ void main() {
68
+ vColor = aColor;
69
+ vec2 screen = (aPos + uCam) * uZoom;
70
+ vec2 ndc = (screen / uScreen) * 2.0;
71
+ gl_Position = vec4(ndc.x, -ndc.y, 0.0, 1.0);
72
+ }`;
73
+
74
+ const EDGE_FS = `
75
+ precision mediump float;
76
+ varying vec3 vColor;
77
+ void main() { gl_FragColor = vec4(vColor, 0.85); }`;
78
+
79
+ const NODE_STRIDE_F = 8; // cx, cy, sw, sh, r, g, b, sel
80
+ const EDGE_SEGS = 24;
81
+ const EDGE_VERTS_PER = (EDGE_SEGS) * 2;
82
+ const EDGE_STRIDE_F = 5; // x, y, r, g, b per vertex
83
+
84
+ class WebGLRenderer {
85
+ constructor(flow) {
86
+ this.flow = flow;
87
+ this.glCanvas = document.createElement('canvas');
88
+ this.glCanvas.style.cssText = `position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:0;`;
89
+ flow.container.insertBefore(this.glCanvas, flow.canvas);
90
+ flow.canvas.style.background = 'transparent';
91
+ flow.canvas.style.position = 'absolute';
92
+ flow.canvas.style.zIndex = '1';
93
+ this.gl = this.glCanvas.getContext('webgl', { antialias: true, alpha: true, premultipliedAlpha: false });
94
+ if (!this.gl) { this.disabled = true; console.warn('zflow: WebGL unavailable'); return; }
95
+ this.instExt = this.gl.getExtension('ANGLE_instanced_arrays');
96
+ this.cap = flow.w.nodeCap();
97
+ this.edgeCap = flow.w.edgeCap();
98
+ this._resize();
99
+ this._setupShaders();
100
+ this._setupBuffers();
101
+ this._hookDirty();
102
+ this._resizeObs = new ResizeObserver(() => this._resize());
103
+ this._resizeObs.observe(flow.container);
104
+ this._dirty = new Set(); // node ids needing buffer upload
105
+ this._dirtyEdges = new Set();
106
+ this._fullRebuildNeeded = true;
107
+ this._lastNodeCount = 0;
108
+ this._lastEdgeCount = 0;
109
+ }
110
+
111
+ _hookDirty() {
112
+ const f = this.flow;
113
+ f.on('change', () => { this._fullRebuildNeeded = true; });
114
+ // Hijack moveSelectedBy / moveNode so position changes only mark dirty.
115
+ const origMove = f.w.moveSelectedBy;
116
+ if (origMove) {
117
+ f.w.moveSelectedBy = (dx, dy) => {
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);
120
+ };
121
+ }
122
+ }
123
+
124
+ _resize() {
125
+ const dpr = window.devicePixelRatio || 1;
126
+ const r = this.flow.container.getBoundingClientRect();
127
+ this.glCanvas.width = r.width * dpr;
128
+ this.glCanvas.height = r.height * dpr;
129
+ this.gl?.viewport(0, 0, this.glCanvas.width, this.glCanvas.height);
130
+ }
131
+
132
+ _setupShaders() {
133
+ const gl = this.gl;
134
+ this.progNode = link(gl, VS, FS);
135
+ this.progEdge = link(gl, EDGE_VS, EDGE_FS);
136
+ // Cache uniform/attrib locations.
137
+ this.locN = {
138
+ aQuad: gl.getAttribLocation(this.progNode, 'aQuad'),
139
+ aCenter: gl.getAttribLocation(this.progNode, 'aCenter'),
140
+ aSize: gl.getAttribLocation(this.progNode, 'aSize'),
141
+ aColor: gl.getAttribLocation(this.progNode, 'aColor'),
142
+ aSel: gl.getAttribLocation(this.progNode, 'aSelected'),
143
+ uCam: gl.getUniformLocation(this.progNode, 'uCam'),
144
+ uZoom: gl.getUniformLocation(this.progNode, 'uZoom'),
145
+ uScreen: gl.getUniformLocation(this.progNode, 'uScreen'),
146
+ };
147
+ this.locE = {
148
+ aPos: gl.getAttribLocation(this.progEdge, 'aPos'),
149
+ aColor: gl.getAttribLocation(this.progEdge, 'aColor'),
150
+ uCam: gl.getUniformLocation(this.progEdge, 'uCam'),
151
+ uZoom: gl.getUniformLocation(this.progEdge, 'uZoom'),
152
+ uScreen: gl.getUniformLocation(this.progEdge, 'uScreen'),
153
+ };
154
+ }
155
+
156
+ _setupBuffers() {
157
+ const gl = this.gl;
158
+ // Shared quad: 6 verts, 2 floats each, static.
159
+ this.quadBuf = gl.createBuffer();
160
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
161
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
162
+ -1, -1, 1, -1, -1, 1,
163
+ 1, -1, 1, 1, -1, 1,
164
+ ]), gl.STATIC_DRAW);
165
+
166
+ // Per-instance node buffer pre-allocated at full cap.
167
+ this.nodeData = new Float32Array(this.cap * NODE_STRIDE_F);
168
+ this.nodeBuf = gl.createBuffer();
169
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeBuf);
170
+ gl.bufferData(gl.ARRAY_BUFFER, this.nodeData.byteLength, gl.DYNAMIC_DRAW);
171
+
172
+ // Edge buffer pre-allocated.
173
+ this.edgeData = new Float32Array(this.edgeCap * EDGE_VERTS_PER * EDGE_STRIDE_F);
174
+ this.edgeBuf = gl.createBuffer();
175
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.edgeBuf);
176
+ gl.bufferData(gl.ARRAY_BUFFER, this.edgeData.byteLength, gl.DYNAMIC_DRAW);
177
+ }
178
+
179
+ _writeNode(i) {
180
+ const f = this.flow;
181
+ const cat = f.kinds[f.V.kind[i]];
182
+ const hex = f.colors.get(i) || cat.color;
183
+ const off = i * NODE_STRIDE_F;
184
+ this.nodeData[off ] = f.V.posX[i];
185
+ this.nodeData[off + 1] = f.V.posY[i];
186
+ this.nodeData[off + 2] = f.V.sizeW[i] * 0.5;
187
+ this.nodeData[off + 3] = f.V.sizeH[i] * 0.5;
188
+ const [r, g, b] = parseHex(hex);
189
+ this.nodeData[off + 4] = r;
190
+ this.nodeData[off + 5] = g;
191
+ this.nodeData[off + 6] = b;
192
+ this.nodeData[off + 7] = f.V.selected[i] !== 0 ? 1 : 0;
193
+ }
194
+
195
+ _writeEdge(i) {
196
+ const f = this.flow;
197
+ const a = f.V.edgeFromN[i], b = f.V.edgeToN[i];
198
+ const ap = f._portWorld(a, 1, f.V.edgeFromP[i]);
199
+ const bp = f._portWorld(b, 0, f.V.edgeToP[i]);
200
+ const col = parseHex(f.colors.get(a) || f.kinds[f.V.kind[a]].color);
201
+ const off = i * EDGE_VERTS_PER * EDGE_STRIDE_F;
202
+ const ortho = f.options.edgeStyle === 'orthogonal';
203
+ const offCv = Math.max(50, Math.abs(bp.x - ap.x) * 0.5 + Math.abs(bp.y - ap.y) * 0.4);
204
+ let prev = { x: ap.x, y: ap.y };
205
+ let o = off;
206
+ for (let s = 1; s <= EDGE_SEGS; s++) {
207
+ const t = s / EDGE_SEGS;
208
+ let pt;
209
+ if (ortho) {
210
+ const mx = (ap.x + bp.x) * 0.5;
211
+ pt = t < 0.33 ? lerp(ap, { x: mx, y: ap.y }, t / 0.33)
212
+ : t < 0.67 ? lerp({ x: mx, y: ap.y }, { x: mx, y: bp.y }, (t - 0.33) / 0.34)
213
+ : lerp({ x: mx, y: bp.y }, bp, (t - 0.67) / 0.33);
214
+ } else {
215
+ pt = bezPt(t, ap.x, ap.y, ap.x + offCv, ap.y, bp.x - offCv, bp.y, bp.x, bp.y);
216
+ }
217
+ this.edgeData[o++] = prev.x; this.edgeData[o++] = prev.y;
218
+ this.edgeData[o++] = col[0]; this.edgeData[o++] = col[1]; this.edgeData[o++] = col[2];
219
+ this.edgeData[o++] = pt.x; this.edgeData[o++] = pt.y;
220
+ this.edgeData[o++] = col[0]; this.edgeData[o++] = col[1]; this.edgeData[o++] = col[2];
221
+ prev = pt;
222
+ }
223
+ }
224
+
225
+ render() {
226
+ if (this.disabled) return;
227
+ const gl = this.gl;
228
+ const f = this.flow;
229
+ const n = f.w.nodeCount_(), m = f.w.edgeCount_();
230
+ const dpr = window.devicePixelRatio || 1;
231
+
232
+ gl.clearColor(0.027, 0.035, 0.06, 1.0);
233
+ gl.clear(gl.COLOR_BUFFER_BIT);
234
+ gl.enable(gl.BLEND);
235
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
236
+
237
+ const camWX = f.cam.x + (this.glCanvas.width / (2 * dpr * f.cam.zoom));
238
+ const camWY = f.cam.y + (this.glCanvas.height / (2 * dpr * f.cam.zoom));
239
+
240
+ // ── Detect what needs upload ────────────────────────────────────
241
+ if (this._fullRebuildNeeded || n !== this._lastNodeCount) {
242
+ for (let i = 0; i < n; i++) this._writeNode(i);
243
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeBuf);
244
+ gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.nodeData.subarray(0, n * NODE_STRIDE_F));
245
+ this._dirty.clear();
246
+ } 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();
264
+ }
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
+ }
274
+ }
275
+
276
+ this._lastNodeCount = n;
277
+ this._lastEdgeCount = m;
278
+ this._fullRebuildNeeded = false;
279
+
280
+ // ── Draw edges (LINES, persistent buffer) ────────────────────────
281
+ if (m > 0) {
282
+ gl.useProgram(this.progEdge);
283
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.edgeBuf);
284
+ gl.enableVertexAttribArray(this.locE.aPos);
285
+ gl.vertexAttribPointer(this.locE.aPos, 2, gl.FLOAT, false, 5 * 4, 0);
286
+ gl.enableVertexAttribArray(this.locE.aColor);
287
+ gl.vertexAttribPointer(this.locE.aColor, 3, gl.FLOAT, false, 5 * 4, 2 * 4);
288
+ gl.uniform2f(this.locE.uCam, camWX, camWY);
289
+ gl.uniform1f(this.locE.uZoom, f.cam.zoom * dpr);
290
+ gl.uniform2f(this.locE.uScreen, this.glCanvas.width, this.glCanvas.height);
291
+ gl.lineWidth(1.6);
292
+ gl.drawArrays(gl.LINES, 0, m * EDGE_VERTS_PER);
293
+ }
294
+
295
+ // ── Draw nodes ──────────────────────────────────────────────────
296
+ if (n > 0) {
297
+ gl.useProgram(this.progNode);
298
+ // aQuad from static buffer.
299
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.quadBuf);
300
+ gl.enableVertexAttribArray(this.locN.aQuad);
301
+ gl.vertexAttribPointer(this.locN.aQuad, 2, gl.FLOAT, false, 0, 0);
302
+ if (this.instExt) this.instExt.vertexAttribDivisorANGLE(this.locN.aQuad, 0);
303
+ // per-instance attribs from node buffer.
304
+ gl.bindBuffer(gl.ARRAY_BUFFER, this.nodeBuf);
305
+ const s = NODE_STRIDE_F * 4;
306
+ gl.enableVertexAttribArray(this.locN.aCenter);
307
+ gl.vertexAttribPointer(this.locN.aCenter, 2, gl.FLOAT, false, s, 0);
308
+ gl.enableVertexAttribArray(this.locN.aSize);
309
+ gl.vertexAttribPointer(this.locN.aSize, 2, gl.FLOAT, false, s, 2 * 4);
310
+ gl.enableVertexAttribArray(this.locN.aColor);
311
+ gl.vertexAttribPointer(this.locN.aColor, 3, gl.FLOAT, false, s, 4 * 4);
312
+ gl.enableVertexAttribArray(this.locN.aSel);
313
+ gl.vertexAttribPointer(this.locN.aSel, 1, gl.FLOAT, false, s, 7 * 4);
314
+ if (this.instExt) {
315
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aCenter, 1);
316
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aSize, 1);
317
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aColor, 1);
318
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aSel, 1);
319
+ }
320
+ gl.uniform2f(this.locN.uCam, camWX, camWY);
321
+ gl.uniform1f(this.locN.uZoom, f.cam.zoom * dpr);
322
+ gl.uniform2f(this.locN.uScreen, this.glCanvas.width, this.glCanvas.height);
323
+ if (this.instExt) {
324
+ this.instExt.drawArraysInstancedANGLE(gl.TRIANGLES, 0, 6, n);
325
+ // Reset divisors so other passes (edges) work correctly.
326
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aCenter, 0);
327
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aSize, 0);
328
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aColor, 0);
329
+ this.instExt.vertexAttribDivisorANGLE(this.locN.aSel, 0);
330
+ } else {
331
+ // Slow path fallback: 6 verts per node, no extension.
332
+ // (rare; almost every browser since 2014 has the extension)
333
+ for (let i = 0; i < n; i++) {
334
+ const off = i * NODE_STRIDE_F;
335
+ gl.vertexAttrib2f(this.locN.aCenter, this.nodeData[off], this.nodeData[off + 1]);
336
+ gl.vertexAttrib2f(this.locN.aSize, this.nodeData[off + 2], this.nodeData[off + 3]);
337
+ gl.vertexAttrib3f(this.locN.aColor, this.nodeData[off + 4], this.nodeData[off + 5], this.nodeData[off + 6]);
338
+ gl.vertexAttrib1f(this.locN.aSel, this.nodeData[off + 7]);
339
+ gl.drawArrays(gl.TRIANGLES, 0, 6);
340
+ }
341
+ }
342
+ }
343
+ }
344
+
345
+ /** Mark a node as needing buffer update. Called from host on move/recolor. */
346
+ markNodeDirty(i) { this._dirty.add(i); }
347
+ markEdgeDirty(i) { this._dirtyEdges.add(i); this._fullRebuildNeeded = true; }
348
+ markAllDirty() { this._fullRebuildNeeded = true; }
349
+
350
+ dispose() {
351
+ this._resizeObs?.disconnect();
352
+ this.glCanvas?.remove();
353
+ }
354
+ }
355
+
356
+ // ── helpers ───────────────────────────────────────────────────────────────
357
+ function compile(gl, src, kind) {
358
+ const s = gl.createShader(kind);
359
+ gl.shaderSource(s, src); gl.compileShader(s);
360
+ if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error('GL compile: ' + gl.getShaderInfoLog(s));
361
+ return s;
362
+ }
363
+ function link(gl, vs, fs) {
364
+ const p = gl.createProgram();
365
+ gl.attachShader(p, compile(gl, vs, gl.VERTEX_SHADER));
366
+ gl.attachShader(p, compile(gl, fs, gl.FRAGMENT_SHADER));
367
+ gl.linkProgram(p);
368
+ if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error('GL link: ' + gl.getProgramInfoLog(p));
369
+ return p;
370
+ }
371
+ function parseHex(h) {
372
+ return [parseInt(h.slice(1, 3), 16) / 255, parseInt(h.slice(3, 5), 16) / 255, parseInt(h.slice(5, 7), 16) / 255];
373
+ }
374
+ function bezPt(t, x1, y1, cx1, cy1, cx2, cy2, x2, y2) {
375
+ const mt = 1 - t, mt2 = mt * mt, t2 = t * t;
376
+ const a = mt2 * mt, b = 3 * mt2 * t, c = 3 * mt * t2, d = t2 * t;
377
+ return { x: a*x1 + b*cx1 + c*cx2 + d*x2, y: a*y1 + b*cy1 + c*cy2 + d*y2 };
378
+ }
379
+ function lerp(a, b, t) { return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }; }
380
+
381
+ exports.WebGLRenderer = WebGLRenderer;
382
+
383
+ }));
384
+ //# sourceMappingURL=webgl-renderer.umd.js.map