gladly-plot 0.0.5 → 0.0.6

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.
Files changed (44) hide show
  1. package/README.md +9 -2
  2. package/package.json +10 -11
  3. package/src/axes/Axis.js +320 -172
  4. package/src/axes/AxisLink.js +6 -2
  5. package/src/axes/AxisRegistry.js +116 -39
  6. package/src/axes/Camera.js +47 -0
  7. package/src/axes/ColorAxisRegistry.js +10 -2
  8. package/src/axes/FilterAxisRegistry.js +1 -1
  9. package/src/axes/TickLabelAtlas.js +99 -0
  10. package/src/axes/ZoomController.js +446 -124
  11. package/src/colorscales/ColorscaleRegistry.js +30 -10
  12. package/src/compute/ComputationRegistry.js +126 -184
  13. package/src/compute/axisFilter.js +21 -9
  14. package/src/compute/conv.js +64 -8
  15. package/src/compute/elementwise.js +72 -0
  16. package/src/compute/fft.js +106 -20
  17. package/src/compute/filter.js +105 -103
  18. package/src/compute/hist.js +247 -142
  19. package/src/compute/kde.js +64 -46
  20. package/src/compute/scatter2dInterpolate.js +277 -0
  21. package/src/compute/util.js +196 -0
  22. package/src/core/ComputePipeline.js +153 -0
  23. package/src/core/GlBase.js +141 -0
  24. package/src/core/Layer.js +22 -8
  25. package/src/core/LayerType.js +251 -92
  26. package/src/core/Plot.js +630 -152
  27. package/src/core/PlotGroup.js +204 -0
  28. package/src/core/ShaderQueue.js +73 -0
  29. package/src/data/ColumnData.js +269 -0
  30. package/src/data/Computation.js +95 -0
  31. package/src/data/Data.js +270 -0
  32. package/src/floats/Float.js +56 -0
  33. package/src/index.js +16 -4
  34. package/src/layers/BarsLayer.js +168 -0
  35. package/src/layers/ColorbarLayer.js +10 -14
  36. package/src/layers/ColorbarLayer2d.js +13 -24
  37. package/src/layers/FilterbarLayer.js +4 -3
  38. package/src/layers/LinesLayer.js +108 -122
  39. package/src/layers/PointsLayer.js +73 -69
  40. package/src/layers/ScatterShared.js +62 -106
  41. package/src/layers/TileLayer.js +20 -16
  42. package/src/math/mat4.js +100 -0
  43. package/src/core/Data.js +0 -67
  44. package/src/layers/HistogramLayer.js +0 -212
@@ -0,0 +1,204 @@
1
+ import { AXES } from "../axes/AxisRegistry.js"
2
+ import { linkAxes } from "../axes/AxisLink.js"
3
+ import { normalizeData } from "../data/Data.js"
4
+
5
+ /**
6
+ * Coordinates a set of named Plot instances.
7
+ *
8
+ * - update({ data, plots }) normalizes data once so all plots share the same
9
+ * DataGroup, and updates all plots before re-establishing any auto-links,
10
+ * so intermediate QK mismatches never reach linkAxes().
11
+ *
12
+ * - When autoLink is true, all axes across all plots that share the same
13
+ * quantity kind are automatically linked. Axes that no longer share a QK
14
+ * after an update are simply unlinked rather than throwing.
15
+ *
16
+ * - When autoLink is false, manual links created via linkAxes() on axes
17
+ * belonging to plots in the group survive PlotGroup.update() calls
18
+ * unchanged (Axis instances are stable across plot updates).
19
+ */
20
+ export class PlotGroup {
21
+ constructor(plots = {}, { autoLink = false } = {}) {
22
+ this._plots = new Map()
23
+ this._autoLink = autoLink
24
+ // key → { unlink, plotA, plotB }
25
+ this._links = new Map()
26
+
27
+ for (const [name, plot] of Object.entries(plots)) {
28
+ this._plots.set(name, plot)
29
+ }
30
+
31
+ if (autoLink) this._updateAutoLinks()
32
+ }
33
+
34
+ /** Add a named plot to the group. Re-runs auto-linking if enabled. */
35
+ add(name, plot) {
36
+ this._plots.set(name, plot)
37
+ if (this._autoLink) this._updateAutoLinks()
38
+ }
39
+
40
+ /** Remove a named plot from the group, tearing down any links involving it. */
41
+ remove(name) {
42
+ if (!this._plots.has(name)) return
43
+ this._removeLinksForPlot(name)
44
+ this._plots.delete(name)
45
+ }
46
+
47
+ /**
48
+ * Update plots in the group.
49
+ *
50
+ * @param {object} options
51
+ * @param {*} [options.data] - Raw data passed to all plots (normalized once).
52
+ * @param {object} [options.plots] - Map of { plotName: plotConfig } to update individually.
53
+ */
54
+ async update({ data, plots } = {}) {
55
+ // Normalize data once so every plot receives the same DataGroup instance.
56
+ const normalizedData = data !== undefined ? normalizeData(data) : undefined
57
+
58
+ // Drop auto-links before updating any plot so intermediate states (one plot
59
+ // updated, the other not yet) don't trigger false QK mismatch errors.
60
+ if (this._autoLink) {
61
+ for (const entry of this._links.values()) entry.unlink()
62
+ this._links.clear()
63
+ }
64
+
65
+ // Collect which plots will actually be updated, and snapshot their state
66
+ // so we can roll all of them back atomically if validation fails.
67
+ const toUpdate = []
68
+ for (const [name, plot] of this._plots) {
69
+ const plotConfig = plots?.[name]
70
+ if (normalizedData === undefined && plotConfig === undefined) continue
71
+ toUpdate.push({ name, plot, prevConfig: plot.currentConfig, prevRawData: plot._rawData })
72
+ }
73
+
74
+ try {
75
+ // Phase 1: apply all updates (no link validation yet).
76
+ for (const { name, plot } of toUpdate) {
77
+ const arg = {}
78
+ if (normalizedData !== undefined) arg.data = normalizedData
79
+ if (plots?.[name] !== undefined) arg.config = plots[name]
80
+ await plot._applyUpdate(arg)
81
+ }
82
+
83
+ // Phase 2: validate links across every plot now that all QKs are final.
84
+ for (const [, plot] of this._plots) plot._validateLinks()
85
+
86
+ // Phase 3: reconcile auto-links with the new QKs in place.
87
+ if (this._autoLink) this._updateAutoLinks()
88
+
89
+ } catch (error) {
90
+ // Roll back every plot that was updated.
91
+ for (const { plot, prevConfig, prevRawData } of toUpdate) {
92
+ plot.currentConfig = prevConfig
93
+ plot._rawData = prevRawData
94
+ try { await plot._applyUpdate({}) } catch (e) {
95
+ console.error('[gladly] PlotGroup: error during rollback re-render:', e)
96
+ }
97
+ }
98
+ // Restore auto-links to match the rolled-back state.
99
+ if (this._autoLink) this._updateAutoLinks()
100
+ throw error
101
+ }
102
+ }
103
+
104
+ /** Tear down all auto-managed links. Does not destroy the plots themselves. */
105
+ destroy() {
106
+ for (const entry of this._links.values()) entry.unlink()
107
+ this._links.clear()
108
+ }
109
+
110
+ // ─── Internal ─────────────────────────────────────────────────────────────
111
+
112
+ _updateAutoLinks() {
113
+ // Collect all axes grouped by quantity kind across all plots.
114
+ // Spatial, color, and filter axes are all handled uniformly:
115
+ // plot._getAxis(id) returns a stable Axis instance for any id.
116
+ const qkAxes = new Map() // QK → [{ plotName, axisId }]
117
+
118
+ for (const [plotName, plot] of this._plots) {
119
+ // Spatial axes
120
+ if (plot.axisRegistry) {
121
+ for (const axisId of AXES) {
122
+ const qk = plot.getAxisQuantityKind(axisId)
123
+ if (!qk) continue
124
+ _push(qkAxes, qk, { plotName, axisId })
125
+ }
126
+ }
127
+
128
+ // Color axes (axisId === quantityKind for non-spatial axes)
129
+ if (plot.colorAxisRegistry) {
130
+ for (const qk of plot.colorAxisRegistry.getQuantityKinds()) {
131
+ _push(qkAxes, qk, { plotName, axisId: qk })
132
+ }
133
+ }
134
+
135
+ // Filter axes
136
+ if (plot.filterAxisRegistry) {
137
+ for (const qk of plot.filterAxisRegistry.getQuantityKinds()) {
138
+ _push(qkAxes, qk, { plotName, axisId: qk })
139
+ }
140
+ }
141
+ }
142
+
143
+ // Determine which links should exist.
144
+ const desiredKeys = new Set()
145
+ for (const entries of qkAxes.values()) {
146
+ if (entries.length < 2) continue
147
+ for (let i = 0; i < entries.length; i++) {
148
+ for (let j = i + 1; j < entries.length; j++) {
149
+ desiredKeys.add(_linkKey(entries[i], entries[j]))
150
+ }
151
+ }
152
+ }
153
+
154
+ // Remove stale links.
155
+ for (const [key, entry] of this._links) {
156
+ if (!desiredKeys.has(key)) {
157
+ entry.unlink()
158
+ this._links.delete(key)
159
+ }
160
+ }
161
+
162
+ // Create missing links.
163
+ for (const [, entries] of qkAxes) {
164
+ if (entries.length < 2) continue
165
+ for (let i = 0; i < entries.length; i++) {
166
+ for (let j = i + 1; j < entries.length; j++) {
167
+ const a = entries[i], b = entries[j]
168
+ const key = _linkKey(a, b)
169
+ if (this._links.has(key)) continue
170
+
171
+ const axisA = this._plots.get(a.plotName)._getAxis(a.axisId)
172
+ const axisB = this._plots.get(b.plotName)._getAxis(b.axisId)
173
+ // Both axes share the same QK (guaranteed by qkAxes grouping),
174
+ // so linkAxes() will not throw.
175
+ const handle = linkAxes(axisA, axisB)
176
+ this._links.set(key, { unlink: handle.unlink, plotA: a.plotName, plotB: b.plotName })
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ _removeLinksForPlot(name) {
183
+ for (const [key, entry] of this._links) {
184
+ if (entry.plotA === name || entry.plotB === name) {
185
+ entry.unlink()
186
+ this._links.delete(key)
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
193
+
194
+ function _push(map, key, value) {
195
+ if (!map.has(key)) map.set(key, [])
196
+ map.get(key).push(value)
197
+ }
198
+
199
+ /** Canonical link key — lexicographically sorted so (A,B) === (B,A). */
200
+ function _linkKey(a, b) {
201
+ const ka = `${a.plotName}\0${a.axisId}`
202
+ const kb = `${b.plotName}\0${b.axisId}`
203
+ return ka < kb ? `${ka}--${kb}` : `${kb}--${ka}`
204
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Parallel shader compilation helpers.
3
+ *
4
+ * enqueueRegl(regl, config) — drop-in for regl(config) that defers compilation.
5
+ * Returns a callable handle backed by a null command until compileEnqueuedShaders()
6
+ * is called. State is stored on the regl object itself so no separate instance
7
+ * is needed.
8
+ *
9
+ * compileEnqueuedShaders(regl) — compiles all enqueued programs in parallel:
10
+ * 1. Kicks off raw GL compilation for every queued program without checking
11
+ * status, so the GPU driver can pipeline them concurrently.
12
+ * 2. Forces completion by checking LINK_STATUS on each (blocks only on
13
+ * stragglers; the rest are already done).
14
+ * 3. Discards the raw programs — they exist only to warm the driver's shader
15
+ * binary cache (e.g. ANGLE on Chrome/Edge, Mesa on Linux).
16
+ * 4. Creates real regl commands (driver returns cached binaries immediately)
17
+ * and resolves all handles.
18
+ */
19
+
20
+ export function enqueueRegl(regl, config) {
21
+ if (!regl._shaderQueue) regl._shaderQueue = []
22
+
23
+ let realCmd = null
24
+ const handle = (props) => realCmd(props)
25
+ handle._config = config
26
+ handle._resolve = (cmd) => { realCmd = cmd }
27
+ regl._shaderQueue.push(handle)
28
+ return handle
29
+ }
30
+
31
+ export function compileEnqueuedShaders(regl) {
32
+ const queue = regl._shaderQueue ?? []
33
+ regl._shaderQueue = null
34
+
35
+ if (queue.length === 0) return
36
+
37
+ const gl = regl._gl
38
+
39
+ // Phase 1: start all compilations without checking status
40
+ const precompiled = queue.map(({ _config: { vert, frag } }) => {
41
+ const vs = gl.createShader(gl.VERTEX_SHADER)
42
+ gl.shaderSource(vs, vert)
43
+ gl.compileShader(vs)
44
+
45
+ const fs = gl.createShader(gl.FRAGMENT_SHADER)
46
+ gl.shaderSource(fs, frag)
47
+ gl.compileShader(fs)
48
+
49
+ const prog = gl.createProgram()
50
+ gl.attachShader(prog, vs)
51
+ gl.attachShader(prog, fs)
52
+ gl.linkProgram(prog)
53
+
54
+ return { prog, vs, fs }
55
+ })
56
+
57
+ // Phase 2: wait for all (they've been compiling in parallel)
58
+ for (const { prog, vs, fs } of precompiled) {
59
+ if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
60
+ console.warn('[gladly] Shader pre-compilation failed; regl will report the detailed error')
61
+ }
62
+ gl.detachShader(prog, vs)
63
+ gl.detachShader(prog, fs)
64
+ gl.deleteShader(vs)
65
+ gl.deleteShader(fs)
66
+ gl.deleteProgram(prog)
67
+ }
68
+
69
+ // Phase 3: create real regl commands (driver binary cache hit)
70
+ for (const handle of queue) {
71
+ handle._resolve(regl(handle._config))
72
+ }
73
+ }
@@ -0,0 +1,269 @@
1
+ // ─── GLSL helper injected into any shader that samples column data ─────────────
2
+ // Values are packed 4 per texel (RGBA). Element i → texel i/4, channel i%4.
3
+ export const SAMPLE_COLUMN_GLSL = `float sampleColumn(sampler2D tex, float idx) {
4
+ ivec2 sz = textureSize(tex, 0);
5
+ int i = int(idx);
6
+ int texelI = i / 4;
7
+ int chan = i % 4;
8
+ ivec2 coord = ivec2(texelI % sz.x, texelI / sz.x);
9
+ vec4 texel = texelFetch(tex, coord, 0);
10
+ if (chan == 0) return texel.r;
11
+ if (chan == 1) return texel.g;
12
+ if (chan == 2) return texel.b;
13
+ return texel.a;
14
+ }`
15
+
16
+ // ─── GLSL helper for multi-dimensional column sampling ───────────────────────
17
+ // shape.xyzw holds the size of each logical dimension (unused dims = 1).
18
+ // idx is the multi-dimensional index, row-major (first dim varies fastest).
19
+ export const SAMPLE_COLUMN_ND_GLSL = `float sampleColumnND(sampler2D tex, ivec4 shape, ivec4 idx) {
20
+ int i = idx.x + shape.x * (idx.y + shape.y * (idx.z + shape.z * idx.w));
21
+ ivec2 sz = textureSize(tex, 0);
22
+ int texelI = i / 4;
23
+ int chan = i % 4;
24
+ ivec2 coord = ivec2(texelI % sz.x, texelI / sz.x);
25
+ vec4 texel = texelFetch(tex, coord, 0);
26
+ if (chan == 0) return texel.r;
27
+ if (chan == 1) return texel.g;
28
+ if (chan == 2) return texel.b;
29
+ return texel.a;
30
+ }`
31
+
32
+ // Upload a Float32Array as a 2D RGBA texture with 4 values packed per texel.
33
+ export function uploadToTexture(regl, array) {
34
+ const nTexels = Math.ceil(array.length / 4)
35
+ const w = Math.min(nTexels, regl.limits.maxTextureSize)
36
+ const h = Math.ceil(nTexels / w)
37
+ const texData = new Float32Array(w * h * 4)
38
+ for (let i = 0; i < array.length; i++) texData[i] = array[i]
39
+ const tex = regl.texture({ data: texData, shape: [w, h], type: 'float', format: 'rgba' })
40
+ tex._dataLength = array.length
41
+ return tex
42
+ }
43
+
44
+ // ─── ColumnData base class ────────────────────────────────────────────────────
45
+ export class ColumnData {
46
+ get length() { return null }
47
+ get domain() { return null }
48
+ get quantityKind() { return null }
49
+ get shape() { return [this.length] }
50
+ get ndim() { return this.shape.length }
51
+ get totalLength() { return this.shape.reduce((a, b) => a * b, 1) }
52
+
53
+ // Returns { glslExpr: string, textures: { uniformName: () => reglTexture } }
54
+ // path must be a valid GLSL identifier fragment (no dots or special chars)
55
+ resolve(path, regl) { throw new Error('Not implemented') }
56
+
57
+ // Returns a regl texture (4 values per texel, RGBA, 2D layout).
58
+ // May run a GPU render pass for GlslColumn.
59
+ toTexture(regl) { throw new Error('Not implemented') }
60
+
61
+ // Called before each render to refresh axis-dependent textures.
62
+ // Returns true if the texture was updated.
63
+ refresh(plot) { return false }
64
+
65
+ // Returns a new ColumnData that samples at a_pickId + (offsetExpr) instead of a_pickId.
66
+ // offsetExpr is a GLSL expression string, e.g. 'a_endPoint' or '1.0'.
67
+ withOffset(offsetExpr) { return new OffsetColumn(this, offsetExpr) }
68
+ }
69
+
70
+ // ─── ArrayColumn ──────────────────────────────────────────────────────────────
71
+ export class ArrayColumn extends ColumnData {
72
+ constructor(array, { domain = null, quantityKind = null, shape = null } = {}) {
73
+ super()
74
+ this._array = array
75
+ this._domain = domain
76
+ this._quantityKind = quantityKind
77
+ this._shape = shape
78
+ this._ref = null // { texture } lazy
79
+ }
80
+
81
+ get length() { return this._array.length }
82
+ get domain() { return this._domain }
83
+ get quantityKind() { return this._quantityKind }
84
+ get array() { return this._array }
85
+ get shape() { return this._shape ?? [this._array.length] }
86
+
87
+ _upload(regl) {
88
+ if (this._array.length === 0) {
89
+ throw new Error(`[gladly] ArrayColumn: cannot upload empty array as texture — the data source has 0 elements`)
90
+ }
91
+ if (!this._ref) this._ref = { texture: uploadToTexture(regl, this._array) }
92
+ return this._ref
93
+ }
94
+
95
+ resolve(path, regl) {
96
+ const ref = this._upload(regl)
97
+ const uName = `u_col_${path}`
98
+ const shape = this.shape
99
+ if (shape.length === 1) {
100
+ return { glslExpr: `sampleColumn(${uName}, a_pickId)`, textures: { [uName]: () => ref.texture }, shape }
101
+ }
102
+ return { glslExpr: null, textures: { [uName]: () => ref.texture }, shape }
103
+ }
104
+
105
+ toTexture(regl) { return this._upload(regl).texture }
106
+ }
107
+
108
+ // ─── TextureColumn ────────────────────────────────────────────────────────────
109
+ export class TextureColumn extends ColumnData {
110
+ constructor(ref, { domain = null, quantityKind = null, length = null, refreshFn = null, shape = null } = {}) {
111
+ super()
112
+ this._ref = ref // { texture } mutable so hot-swaps propagate
113
+ this._domain = domain
114
+ this._quantityKind = quantityKind
115
+ this._length = length
116
+ this._refreshFn = refreshFn
117
+ this._shape = shape
118
+ }
119
+
120
+ get length() { return this._length }
121
+ get domain() { return this._domain }
122
+ get quantityKind() { return this._quantityKind }
123
+ get shape() { return this._shape ?? (this._length != null ? [this._length] : [0]) }
124
+
125
+ resolve(path, regl) {
126
+ if (!this._ref.texture) {
127
+ throw new Error(`[gladly] TextureColumn '${path}': texture is null — the column was not properly initialized or its computation failed`)
128
+ }
129
+ const uName = `u_col_${path}`
130
+ const texFn = () => {
131
+ if (!this._ref.texture) throw new Error(`[gladly] TextureColumn '${path}': texture became null after initialization`)
132
+ return this._ref.texture
133
+ }
134
+ const shape = this.shape
135
+ if (shape.length === 1) {
136
+ return { glslExpr: `sampleColumn(${uName}, a_pickId)`, textures: { [uName]: texFn }, shape }
137
+ }
138
+ return { glslExpr: null, textures: { [uName]: texFn }, shape }
139
+ }
140
+
141
+ toTexture(regl) {
142
+ if (!this._ref.texture) {
143
+ throw new Error(`[gladly] TextureColumn.toTexture(): texture is null — the column was not properly initialized`)
144
+ }
145
+ return this._ref.texture
146
+ }
147
+
148
+ async refresh(plot) {
149
+ if (this._refreshFn) return await this._refreshFn(plot, this._ref) ?? false
150
+ return false
151
+ }
152
+ }
153
+
154
+ // ─── GlslColumn ───────────────────────────────────────────────────────────────
155
+ export class GlslColumn extends ColumnData {
156
+ constructor(inputs, glslFn, { domain = null, quantityKind = null, shape = null } = {}) {
157
+ super()
158
+ this._inputs = inputs // { name: ColumnData }
159
+ this._glslFn = glslFn // (resolvedExprs: { name: string }) => string
160
+ this._domain = domain
161
+ this._quantityKind = quantityKind
162
+ this._targetShape = shape // logical output shape (null = 1D, infer from inputs)
163
+ }
164
+
165
+ get length() {
166
+ if (this._targetShape) return this._targetShape.reduce((a, b) => a * b, 1)
167
+ return Object.values(this._inputs)[0]?.length ?? null
168
+ }
169
+ get domain() { return this._domain }
170
+ get quantityKind() { return this._quantityKind }
171
+ get shape() {
172
+ if (this._targetShape) return this._targetShape
173
+ const l = Object.values(this._inputs)[0]?.length ?? null
174
+ return l != null ? [l] : [0]
175
+ }
176
+
177
+ resolve(path, regl) {
178
+ const resolvedExprs = {}
179
+ const textures = {}
180
+ for (const [name, col] of Object.entries(this._inputs)) {
181
+ const { glslExpr, textures: colTextures } = col.resolve(`${path}_${name}`, regl)
182
+ resolvedExprs[name] = glslExpr
183
+ Object.assign(textures, colTextures)
184
+ }
185
+ return { glslExpr: this._glslFn(resolvedExprs), textures }
186
+ }
187
+
188
+ toTexture(regl) {
189
+ const N = this.length
190
+ if (N === null) throw new Error('GlslColumn: cannot determine length for toTexture()')
191
+ const nTexels = Math.ceil(N / 4)
192
+ const w = Math.min(nTexels, regl.limits.maxTextureSize)
193
+ const h = Math.ceil(nTexels / w)
194
+ const { glslExpr, textures } = this.resolve('glsl_mat', regl)
195
+ const samplerDecls = Object.keys(textures).map(n => `uniform sampler2D ${n};`).join('\n')
196
+ const vert = `#version 300 es
197
+ precision highp float;
198
+ in vec2 a_position;
199
+ void main() {
200
+ gl_Position = vec4(a_position, 0.0, 1.0);
201
+ }`
202
+ const frag = `#version 300 es
203
+ precision highp float;
204
+ precision highp sampler2D;
205
+ ${samplerDecls}
206
+ ${SAMPLE_COLUMN_GLSL}
207
+ out vec4 fragColor;
208
+ float gladly_eval(float a_pickId) {
209
+ return ${glslExpr};
210
+ }
211
+ void main() {
212
+ int texelI = int(gl_FragCoord.y) * ${w} + int(gl_FragCoord.x);
213
+ int base = texelI * 4;
214
+ float v0 = base + 0 < ${N} ? gladly_eval(float(base + 0)) : 0.0;
215
+ float v1 = base + 1 < ${N} ? gladly_eval(float(base + 1)) : 0.0;
216
+ float v2 = base + 2 < ${N} ? gladly_eval(float(base + 2)) : 0.0;
217
+ float v3 = base + 3 < ${N} ? gladly_eval(float(base + 3)) : 0.0;
218
+ fragColor = vec4(v0, v1, v2, v3);
219
+ }`
220
+ const outputTex = regl.texture({ width: w, height: h, type: 'float', format: 'rgba' })
221
+ const outputFBO = regl.framebuffer({ color: outputTex, depth: false, stencil: false })
222
+ const uniforms = {}
223
+ for (const [k, fn] of Object.entries(textures)) uniforms[k] = fn
224
+ regl({
225
+ framebuffer: outputFBO, vert, frag,
226
+ attributes: { a_position: [[-1, -1], [1, -1], [-1, 1], [1, 1]] },
227
+ uniforms,
228
+ count: 4,
229
+ primitive: 'triangle strip'
230
+ })()
231
+ outputTex._dataLength = N
232
+ return outputTex
233
+ }
234
+
235
+ refresh(plot) {
236
+ let changed = false
237
+ for (const col of Object.values(this._inputs)) {
238
+ if (col.refresh(plot)) changed = true
239
+ }
240
+ return changed
241
+ }
242
+ }
243
+
244
+ // ─── OffsetColumn ─────────────────────────────────────────────────────────────
245
+ // Wraps a ColumnData and shifts the GLSL sampling index by a GLSL expression.
246
+ // Produced by col.withOffset(offsetExpr).
247
+ export class OffsetColumn extends ColumnData {
248
+ constructor(base, offsetExpr) {
249
+ super()
250
+ this._base = base
251
+ this._offsetExpr = offsetExpr
252
+ }
253
+
254
+ get length() { return this._base.length }
255
+ get domain() { return this._base.domain }
256
+ get quantityKind() { return this._base.quantityKind }
257
+ get shape() { return this._base.shape }
258
+
259
+ resolve(path, regl) {
260
+ const { glslExpr, textures } = this._base.resolve(path, regl)
261
+ return {
262
+ glslExpr: glslExpr.replace('a_pickId', `(a_pickId + (${this._offsetExpr}))`),
263
+ textures
264
+ }
265
+ }
266
+
267
+ toTexture(regl) { return this._base.toTexture(regl) }
268
+ refresh(plot) { return this._base.refresh(plot) }
269
+ }
@@ -0,0 +1,95 @@
1
+ import { ColumnData, TextureColumn, GlslColumn } from './ColumnData.js'
2
+
3
+ function domainsEqual(a, b) {
4
+ if (a === b) return true
5
+ if (a == null || b == null) return a === b
6
+ return a[0] === b[0] && a[1] === b[1]
7
+ }
8
+
9
+ // ─── Base classes ─────────────────────────────────────────────────────────────
10
+ export class Computation {
11
+ schema(data) { throw new Error('Not implemented') }
12
+ getQuantityKind(params, data) { return null }
13
+ }
14
+
15
+ export class ComputedData {
16
+ columns() { throw new Error('Not implemented') }
17
+ compute(regl, params, data, getAxisDomain) { throw new Error('Not implemented') }
18
+ schema(data) { throw new Error('Not implemented') }
19
+ filterAxes(params, data) { return {} }
20
+ }
21
+
22
+ export class TextureComputation extends Computation {
23
+ // Override: inputs is { name: ColumnData | scalar }, returns raw regl texture.
24
+ compute(regl, inputs, getAxisDomain) { throw new Error('Not implemented') }
25
+
26
+ // Override to declare output shape in JS without running GPU work.
27
+ // Return int[] (e.g. [W, H] for a 2D output), or null to fall back to 1D (_dataLength).
28
+ // inputs values are ColumnData (shapes accessible) or scalars.
29
+ outputShape(inputs) { return null }
30
+
31
+ async createColumn(regl, inputs, plot) {
32
+ const accessedAxes = new Set()
33
+ const cachedDomains = {}
34
+
35
+ const getAxisDomain = (axisId) => {
36
+ accessedAxes.add(axisId)
37
+ return plot ? plot.getAxisDomain(axisId) : null
38
+ }
39
+
40
+ const rawTex = await this.compute(regl, inputs, getAxisDomain)
41
+ const ref = { texture: rawTex }
42
+
43
+ const hasColumnInputs = Object.values(inputs).some(v => v instanceof ColumnData)
44
+ let refreshFn = null
45
+ if (accessedAxes.size > 0 || hasColumnInputs) {
46
+ for (const axisId of accessedAxes) {
47
+ cachedDomains[axisId] = plot ? plot.getAxisDomain(axisId) : null
48
+ }
49
+
50
+ const comp = this
51
+ refreshFn = async (currentPlot, texRef) => {
52
+ // Refresh inputs first; track if any updated
53
+ let inputsRefreshed = false
54
+ for (const val of Object.values(inputs)) {
55
+ if (val instanceof ColumnData && await val.refresh(currentPlot)) inputsRefreshed = true
56
+ }
57
+
58
+ let ownAxisChanged = false
59
+ for (const axisId of accessedAxes) {
60
+ if (!domainsEqual(currentPlot.getAxisDomain(axisId), cachedDomains[axisId])) {
61
+ ownAxisChanged = true
62
+ break
63
+ }
64
+ }
65
+
66
+ if (!inputsRefreshed && !ownAxisChanged) return false
67
+
68
+ const newAxes = new Set()
69
+ const newGetter = (axisId) => { newAxes.add(axisId); return currentPlot.getAxisDomain(axisId) }
70
+ texRef.texture = await comp.compute(regl, inputs, newGetter)
71
+
72
+ accessedAxes.clear()
73
+ for (const axisId of newAxes) {
74
+ accessedAxes.add(axisId)
75
+ cachedDomains[axisId] = currentPlot.getAxisDomain(axisId)
76
+ }
77
+ return true
78
+ }
79
+ }
80
+
81
+ return new TextureColumn(ref, {
82
+ length: rawTex._dataLength ?? rawTex.width,
83
+ shape: rawTex._dataShape ?? this.outputShape(inputs) ?? null,
84
+ refreshFn
85
+ })
86
+ }
87
+ }
88
+
89
+ export class GlslComputation extends Computation {
90
+ glsl(resolvedExprs) { throw new Error('Not implemented') }
91
+
92
+ createColumn(inputs, meta = {}) {
93
+ return new GlslColumn(inputs, resolvedExprs => this.glsl(resolvedExprs), meta)
94
+ }
95
+ }