gladly-plot 0.0.5 → 0.0.7

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 +253 -92
  26. package/src/core/Plot.js +644 -162
  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
package/src/core/Plot.js CHANGED
@@ -1,7 +1,7 @@
1
- import reglInit from "regl"
2
- import * as d3 from "d3-selection"
3
- import { AXES, AxisRegistry } from "../axes/AxisRegistry.js"
4
- import { Axis } from "../axes/Axis.js"
1
+ import { AXES, AXES_2D, AXIS_GEOMETRY, AxisRegistry } from "../axes/AxisRegistry.js"
2
+ import { Camera } from "../axes/Camera.js"
3
+ import { TickLabelAtlas } from "../axes/TickLabelAtlas.js"
4
+ import { mat4Identity, mat4Multiply } from "../math/mat4.js"
5
5
  import { ColorAxisRegistry } from "../axes/ColorAxisRegistry.js"
6
6
  import { FilterAxisRegistry } from "../axes/FilterAxisRegistry.js"
7
7
  import { ZoomController } from "../axes/ZoomController.js"
@@ -9,14 +9,76 @@ import { getLayerType, getRegisteredLayerTypes } from "./LayerTypeRegistry.js"
9
9
  import { getAxisQuantityKind, getScaleTypeFloat } from "../axes/AxisQuantityKindRegistry.js"
10
10
  import { getRegisteredColorscales, getRegistered2DColorscales } from "../colorscales/ColorscaleRegistry.js"
11
11
  import { Float } from "../floats/Float.js"
12
-
13
- function buildPlotSchema(data) {
12
+ import { computationSchema, buildTransformSchema, getComputedData } from "../compute/ComputationRegistry.js"
13
+ import { DataGroup, normalizeData } from "../data/Data.js"
14
+ import { enqueueRegl, compileEnqueuedShaders } from "./ShaderQueue.js"
15
+ import { GlBase } from "./GlBase.js"
16
+
17
+ // If a single compute step (ComputedData refresh or TextureColumn refresh) takes
18
+ // longer than this on the CPU, yield to the browser before the next step.
19
+ // This prevents submitting an unbounded burst of GPU commands in one synchronous
20
+ // block, which can trigger the Windows TDR watchdog (~2 s GPU timeout).
21
+ const TDR_STEP_MS = 500
22
+
23
+ // Throttle linked-plot renders when the source plot's "blocked lag" is high.
24
+ // Blocked lag = max(0, RAF_wait - own_render_time): high when other plots' renders
25
+ // are delaying the source's frames, but near zero for fast plots (colorbars, filterbars).
26
+ const LINK_THROTTLE_MS = 150 // ms between renders of a throttled linked plot
27
+ const BLOCKED_LAG_THRESHOLD = 30 // ms: throttle kicks in above this blocked lag
28
+ const BLOCKED_LAG_ALPHA = 0.5 // EMA weight — reacts within ~2 samples
29
+
30
+ function buildPlotSchema(data, config) {
14
31
  const layerTypes = getRegisteredLayerTypes()
32
+ // Normalise once — always a DataGroup (or null). Columns are e.g. "input.x1".
33
+ const wrappedData = normalizeData(data)
34
+
35
+ // Build fullSchemaData: the normalised DataGroup plus lightweight stubs for each
36
+ // declared transform so that layer schemas enumerate transform output columns.
37
+ // Stubs only need columns() — getData/getQuantityKind/getDomain return null (schema only).
38
+ const transforms = config?.transforms ?? []
39
+ let fullSchemaData = wrappedData
40
+ if (wrappedData && transforms.length > 0) {
41
+ const group = new DataGroup({})
42
+ group._children = { ...wrappedData._children }
43
+ for (const { name, transform: spec } of transforms) {
44
+ const entries = Object.entries(spec)
45
+ if (entries.length !== 1) continue
46
+ const [className] = entries[0]
47
+ const cd = getComputedData(className)
48
+ if (!cd) continue
49
+ group._children[name] = {
50
+ columns: () => cd.columns(),
51
+ getData: () => null,
52
+ getQuantityKind: () => null,
53
+ getDomain: () => null,
54
+ }
55
+ }
56
+ fullSchemaData = group
57
+ }
58
+
59
+ const { '$defs': compDefs } = computationSchema(fullSchemaData)
60
+
61
+ // wrappedData is already the correctly-shaped DataGroup (columns "input.x1" etc.)
62
+ const { '$defs': transformDefs } = buildTransformSchema(wrappedData)
15
63
 
16
64
  return {
17
65
  $schema: "https://json-schema.org/draft/2020-12/schema",
66
+ $defs: { ...compDefs, ...transformDefs },
18
67
  type: "object",
19
68
  properties: {
69
+ transforms: {
70
+ type: "array",
71
+ description: "Named data transforms applied before layers. Each item is a { name, transform: { ClassName: params } } object.",
72
+ items: {
73
+ type: "object",
74
+ properties: {
75
+ name: { type: "string" },
76
+ transform: { '$ref': '#/$defs/transform_expression' }
77
+ },
78
+ required: ["name", "transform"],
79
+ additionalProperties: false
80
+ }
81
+ },
20
82
  layers: {
21
83
  type: "array",
22
84
  items: {
@@ -26,7 +88,7 @@ function buildPlotSchema(data) {
26
88
  return {
27
89
  title: typeName,
28
90
  properties: {
29
- [typeName]: layerType.schema(data)
91
+ [typeName]: layerType.schema(fullSchemaData)
30
92
  },
31
93
  required: [typeName],
32
94
  additionalProperties: false
@@ -127,7 +189,7 @@ function buildPlotSchema(data) {
127
189
  }
128
190
  }
129
191
 
130
- export class Plot {
192
+ export class Plot extends GlBase {
131
193
  // Registry of float factories keyed by type name.
132
194
  // Each entry: { factory(parentPlot, container, opts) → widget, defaultSize(opts) → {width,height} }
133
195
  // Populated by Colorbar.js, Filterbar.js, Colorbar2d.js at module load time.
@@ -138,6 +200,7 @@ export class Plot {
138
200
  }
139
201
 
140
202
  constructor(container, { margin } = {}) {
203
+ super()
141
204
  this.container = container
142
205
  this.margin = margin ?? { top: 60, right: 60, bottom: 60, left: 60 }
143
206
 
@@ -147,33 +210,31 @@ export class Plot {
147
210
  this.canvas.style.position = 'absolute'
148
211
  this.canvas.style.top = '0'
149
212
  this.canvas.style.left = '0'
150
- this.canvas.style.zIndex = '1'
151
213
  container.appendChild(this.canvas)
152
214
 
153
- // Create SVG element
154
- this.svg = d3.select(container)
155
- .append('svg')
156
- .style('position', 'absolute')
157
- .style('top', '0')
158
- .style('left', '0')
159
- .style('z-index', '2')
160
- .style('user-select', 'none')
161
-
162
215
  this.currentConfig = null
163
- this.currentData = null
164
- this.regl = null
216
+ this._lastRawDataArg = undefined
165
217
  this.layers = []
166
218
  this.axisRegistry = null
167
219
  this.colorAxisRegistry = null
168
- this.filterAxisRegistry = null
169
220
  this._renderCallbacks = new Set()
170
221
  this._zoomEndCallbacks = new Set()
171
222
  this._dirty = false
172
223
  this._rafId = null
173
-
174
- // Stable Axis instances keyed by axis name — persist across update() calls
175
- this._axisCache = new Map()
176
- this._axesProxy = null
224
+ this._rendering = false
225
+ this._lastRenderEnd = performance.now()
226
+ this._throttleTimerId = null
227
+ this._pendingSourcePlot = null // source plot of pending linked render (for throttle check)
228
+ this._blockedLag = 0 // EMA of (RAF wait - own render time); high when blocked by others
229
+ this._is3D = false
230
+ this._camera = null
231
+ this._tickLabelAtlas = null
232
+ this._axisLineCmd = null
233
+ this._axisBillboardCmd = null
234
+
235
+ // Compiled regl draw commands keyed by vert+frag shader source.
236
+ // Persists across update() calls so shader recompilation is avoided.
237
+ this._shaderCache = new Map()
177
238
 
178
239
  // Auto-managed Float widgets keyed by a config-derived tag string.
179
240
  // Covers 1D colorbars, 2D colorbars, and filterbars in a single unified Map.
@@ -182,85 +243,86 @@ export class Plot {
182
243
  this._setupResizeObserver()
183
244
  }
184
245
 
185
- update({ config, data } = {}) {
186
- const previousConfig = this.currentConfig
187
- const previousData = this.currentData
246
+ // Stores new config/data and re-initialises the plot. No link validation.
247
+ // Called directly by PlotGroup so it can validate all plots together after
248
+ // all have been updated.
249
+ async _applyUpdate({ config, data } = {}) {
250
+ if (config !== undefined) this.currentConfig = config
251
+ if (data !== undefined) {
252
+ this._rawData = normalizeData(data) // normalise once; kept immutable
253
+ }
188
254
 
189
- try {
190
- if (config !== undefined) {
191
- this.currentConfig = config
192
- }
193
- if (data !== undefined) {
194
- this.currentData = data
195
- }
255
+ if (!this.currentConfig || !this._rawData) return
196
256
 
197
- if (!this.currentConfig || !this.currentData) {
198
- return
199
- }
257
+ const width = this.container.clientWidth
258
+ const height = this.container.clientHeight
259
+ const plotWidth = width - this.margin.left - this.margin.right
260
+ const plotHeight = height - this.margin.top - this.margin.bottom
200
261
 
201
- const width = this.container.clientWidth
202
- const height = this.container.clientHeight
203
- const plotWidth = width - this.margin.left - this.margin.right
204
- const plotHeight = height - this.margin.top - this.margin.bottom
262
+ // Container is hidden, not yet laid out, or too small to fit the margins.
263
+ // Store config/data and return; ResizeObserver will call forceUpdate() once
264
+ // the container gets real dimensions.
265
+ if (width === 0 || height === 0 || plotWidth <= 0 || plotHeight <= 0) return
205
266
 
206
- // Container is hidden, not yet laid out, or too small to fit the margins.
207
- // Store config/data and return; ResizeObserver will call forceUpdate() once
208
- // the container gets real dimensions.
209
- if (width === 0 || height === 0 || plotWidth <= 0 || plotHeight <= 0) {
210
- return
211
- }
267
+ this.canvas.width = width
268
+ this.canvas.height = height
269
+
270
+ this.width = width
271
+ this.height = height
272
+ this.plotWidth = plotWidth
273
+ this.plotHeight = plotHeight
212
274
 
213
- this.canvas.width = width
214
- this.canvas.height = height
215
- this.svg.attr('width', width).attr('height', height)
275
+ this._warnedMissingDomains = false
276
+ await this._initialize()
277
+ this._syncFloats()
278
+ }
216
279
 
217
- this.width = width
218
- this.height = height
219
- this.plotWidth = plotWidth
220
- this.plotHeight = plotHeight
280
+ // Validates that all axes on this plot that are linked to axes on other plots
281
+ // still share the same quantity kind. Throws if any mismatch is found.
282
+ _validateLinks() {
283
+ for (const [name, axis] of this._axisCache) {
284
+ const qk = axis.quantityKind
285
+ if (!qk) continue
286
+ for (const other of axis._linkedAxes) {
287
+ const otherQk = other.quantityKind
288
+ if (otherQk && otherQk !== qk) {
289
+ throw new Error(
290
+ `Axis '${name}' (quantity kind '${qk}') is linked to axis '${other._name}' ` +
291
+ `with incompatible quantity kind '${otherQk}'. ` +
292
+ `Unlink the axes before changing their quantity kinds, or update both plots atomically via PlotGroup.`
293
+ )
294
+ }
295
+ }
296
+ }
297
+ }
221
298
 
222
- if (this.regl) {
223
- this.regl.destroy()
299
+ async update({ config, data } = {}) {
300
+ // Skip expensive _initialize() if nothing actually changed.
301
+ if (config !== undefined || data !== undefined) {
302
+ const configSame = config === undefined || JSON.stringify(config) === JSON.stringify(this.currentConfig)
303
+ const dataSame = data === undefined || data === this._lastRawDataArg
304
+ if (configSame && dataSame) {
305
+ this.scheduleRender()
306
+ return
224
307
  }
308
+ }
225
309
 
226
- this.svg.selectAll('*').remove()
310
+ if (data !== undefined) this._lastRawDataArg = data
227
311
 
228
- this._initialize()
229
- this._syncFloats()
312
+ const previousConfig = this.currentConfig
313
+ const previousRawData = this._rawData
314
+ try {
315
+ await this._applyUpdate({ config, data })
316
+ this._validateLinks()
230
317
  } catch (error) {
231
318
  this.currentConfig = previousConfig
232
- this.currentData = previousData
319
+ this._rawData = previousRawData
233
320
  throw error
234
321
  }
235
322
  }
236
323
 
237
- forceUpdate() {
238
- this.update({})
239
- }
240
-
241
- /**
242
- * Returns a stable Axis instance for the given axis name.
243
- * Works for spatial axes (e.g. "xaxis_bottom") and quantity-kind axes (color/filter).
244
- * The same instance is returned across plot.update() calls, so links survive updates.
245
- *
246
- * Usage: plot.axes.xaxis_bottom, plot.axes["velocity_ms"], etc.
247
- */
248
- get axes() {
249
- if (!this._axesProxy) {
250
- this._axesProxy = new Proxy(this._axisCache, {
251
- get: (cache, name) => {
252
- if (typeof name !== 'string') return undefined
253
- if (!cache.has(name)) cache.set(name, new Axis(this, name))
254
- return cache.get(name)
255
- }
256
- })
257
- }
258
- return this._axesProxy
259
- }
260
-
261
- _getAxis(name) {
262
- if (!this._axisCache.has(name)) this._axisCache.set(name, new Axis(this, name))
263
- return this._axisCache.get(name)
324
+ async forceUpdate() {
325
+ await this.update({})
264
326
  }
265
327
 
266
328
  getConfig() {
@@ -271,7 +333,7 @@ export class Plot {
271
333
  const scale = this.axisRegistry.getScale(axisId)
272
334
  if (scale) {
273
335
  const [min, max] = scale.domain()
274
- const qk = this.axisRegistry.axisQuantityKinds[axisId]
336
+ const qk = this.axisRegistry.axisQuantityKinds[axisId]
275
337
  const qkDef = qk ? getAxisQuantityKind(qk) : {}
276
338
  axes[axisId] = { ...qkDef, ...(axes[axisId] ?? {}), min, max }
277
339
  }
@@ -307,38 +369,64 @@ export class Plot {
307
369
  }
308
370
  }
309
371
 
310
- return { colorbars: [], ...this.currentConfig, axes}
372
+ return { transforms: [], colorbars: [], ...this.currentConfig, axes}
311
373
  }
312
374
 
313
- _initialize() {
314
- const { layers = [], axes = {}, colorbars = [] } = this.currentConfig
315
-
316
- this.regl = reglInit({
317
- canvas: this.canvas,
318
- extensions: [
319
- 'ANGLE_instanced_arrays',
320
- 'OES_texture_float',
321
- 'OES_texture_float_linear',
322
- ],
323
- optionalExtensions: [
324
- // WebGL1: render to float framebuffers (needed by compute passes)
325
- 'WEBGL_color_buffer_float',
326
- // WebGL2: render to float framebuffers (standard but must be opted in)
327
- 'EXT_color_buffer_float',
328
- ]
329
- })
375
+ async _initialize() {
376
+ const epoch = ++this._initEpoch
377
+ const { layers = [], axes = {}, colorbars = [], transforms = [] } = this.currentConfig
330
378
 
331
- this.layers = []
379
+ if (!this.regl) {
380
+ this._initRegl(this.canvas)
381
+ } else {
382
+ // Notify regl of any canvas dimension change so its internal state stays
383
+ // consistent (e.g. default framebuffer size used by regl.clear).
384
+ this.regl.poll()
385
+ }
332
386
 
333
- AXES.forEach(a => this.svg.append("g").attr("class", a))
387
+ // Destroy GPU buffers owned by the previous layer set before rebuilding.
388
+ for (const layer of this.layers) {
389
+ for (const buf of Object.values(layer._bufferProps ?? {})) {
390
+ if (buf && typeof buf.destroy === 'function') buf.destroy()
391
+ }
392
+ }
393
+
394
+ this.layers = []
395
+ this._dataTransformNodes = []
396
+
397
+ // Restore original user data before applying transforms (handles re-initialization).
398
+ // Create a fresh shallow copy of _rawData's children so _rawData is never mutated
399
+ // and transform nodes from previous runs don't carry over.
400
+ if (this._rawData != null) {
401
+ const fresh = new DataGroup({})
402
+ fresh._children = { ...this._rawData._children }
403
+ this.currentData = fresh
404
+ }
334
405
 
335
406
  this.axisRegistry = new AxisRegistry(this.plotWidth, this.plotHeight)
336
407
  this.colorAxisRegistry = new ColorAxisRegistry()
337
408
  this.filterAxisRegistry = new FilterAxisRegistry()
338
409
 
339
- this._processLayers(layers, this.currentData)
410
+ await this._processTransforms(transforms, epoch)
411
+ if (this._initEpoch !== epoch) return
412
+ await this._processLayers(layers, this.currentData, epoch)
413
+ if (this._initEpoch !== epoch) return
340
414
  this._setDomains(axes)
341
415
 
416
+ // Detect 3D mode: any axis outside the 4 standard 2D positions has a scale.
417
+ this._is3D = AXES.some(a => !AXES_2D.includes(a) && this.axisRegistry.getScale(a) !== null)
418
+
419
+ // Camera (recreated each _initialize so aspect ratio and 3D flag stay in sync).
420
+ this._camera = new Camera(this._is3D)
421
+ this._camera.resize(this.plotWidth, this.plotHeight)
422
+
423
+ // Shared atlas for tick and title labels.
424
+ if (this._tickLabelAtlas) this._tickLabelAtlas.destroy()
425
+ this._tickLabelAtlas = new TickLabelAtlas(this.regl)
426
+
427
+ // Compile shared axis draw commands (once per regl context; cached on Plot).
428
+ if (!this._axisLineCmd) this._initAxisCommands()
429
+
342
430
  // Apply colorscale overrides from top-level colorbars entries. These override any
343
431
  // per-axis colorscale from config.axes or quantity kind registry. Applying after
344
432
  // _setDomains ensures they take effect last. For 2D colorbars both axes receive the
@@ -351,8 +439,108 @@ export class Plot {
351
439
  if (entry.yAxis) this.colorAxisRegistry.ensureColorAxis(entry.yAxis, entry.colorscale)
352
440
  }
353
441
 
354
- new ZoomController(this)
355
- this.render()
442
+ if (!this._zoomController) this._zoomController = new ZoomController(this)
443
+ this.scheduleRender()
444
+ }
445
+
446
+ // Compile the two regl commands shared across all axis rendering.
447
+ // Called once after the first regl context is created.
448
+ _initAxisCommands() {
449
+ const regl = this.regl
450
+
451
+ // Axis lines and tick marks (simple 3D line segments).
452
+ this._axisLineCmd = regl({
453
+ vert: `#version 300 es
454
+ precision highp float;
455
+ in vec3 a_position;
456
+ uniform mat4 u_mvp;
457
+ void main() { gl_Position = u_mvp * vec4(a_position, 1.0); }`,
458
+ frag: `#version 300 es
459
+ precision highp float;
460
+ uniform vec4 u_color;
461
+ out vec4 fragColor;
462
+ void main() { fragColor = u_color; }`,
463
+ attributes: { a_position: regl.prop('positions') },
464
+ uniforms: {
465
+ u_mvp: regl.prop('mvp'),
466
+ u_color: regl.prop('color'),
467
+ },
468
+ primitive: 'lines',
469
+ count: regl.prop('count'),
470
+ viewport: regl.prop('viewport'),
471
+ depth: {
472
+ enable: regl.prop('depthEnable'),
473
+ mask: true,
474
+ },
475
+ })
476
+
477
+ // Billboard quads for tick labels and axis titles.
478
+ // a_anchor: vec3 — label centre in model space
479
+ // a_offset_px: vec2 — corner offset in HTML pixels (x right, y down)
480
+ // a_uv: vec2 — atlas UV (v=0 = canvas top)
481
+ this._axisBillboardCmd = regl({
482
+ vert: `#version 300 es
483
+ precision highp float;
484
+ in vec3 a_anchor;
485
+ in vec2 a_offset_px;
486
+ in vec2 a_uv;
487
+ uniform mat4 u_mvp;
488
+ uniform vec2 u_canvas_size;
489
+ out vec2 v_uv;
490
+ void main() {
491
+ vec4 clip = u_mvp * vec4(a_anchor, 1.0);
492
+ // Project anchor from NDC to HTML-pixel space (x right, y down).
493
+ vec2 ndc_anchor = clip.xy / clip.w;
494
+ vec2 anchor_px = vec2(
495
+ ndc_anchor.x * 0.5 + 0.5,
496
+ -ndc_anchor.y * 0.5 + 0.5) * u_canvas_size;
497
+ // a_offset_px is in HTML pixels (x right, y down).
498
+ // abs(a_offset_px) = (hw, hh) for every corner; top-left = anchor - (hw, hh).
499
+ // Snap the top-left corner to an integer pixel with floor() so that
500
+ // each pixel maps to exactly one atlas texel and the label is never
501
+ // shifted right by the round-half-up behaviour of round().
502
+ vec2 hw_vec = abs(a_offset_px);
503
+ vec2 tl_px = floor(anchor_px - hw_vec); // snap top-left (always left)
504
+ vec2 vert_px = tl_px + hw_vec + a_offset_px; // reconstruct this corner
505
+ // Convert HTML pixels back to NDC.
506
+ vec2 ndc = vec2(
507
+ vert_px.x / u_canvas_size.x * 2.0 - 1.0,
508
+ -(vert_px.y / u_canvas_size.y * 2.0 - 1.0));
509
+ gl_Position = vec4(ndc, clip.z / clip.w, 1.0);
510
+ v_uv = a_uv;
511
+ }`,
512
+ frag: `#version 300 es
513
+ precision highp float;
514
+ uniform sampler2D u_atlas;
515
+ in vec2 v_uv;
516
+ out vec4 fragColor;
517
+ void main() {
518
+ ivec2 tc = ivec2(v_uv * vec2(textureSize(u_atlas, 0)));
519
+ fragColor = texelFetch(u_atlas, tc, 0);
520
+ if (fragColor.a < 0.05) discard;
521
+ }`,
522
+ attributes: {
523
+ a_anchor: regl.prop('anchors'),
524
+ a_offset_px: regl.prop('offsetsPx'),
525
+ a_uv: regl.prop('uvs'),
526
+ },
527
+ uniforms: {
528
+ u_mvp: regl.prop('mvp'),
529
+ u_canvas_size: regl.prop('canvasSize'),
530
+ u_atlas: regl.prop('atlas'),
531
+ },
532
+ primitive: 'triangles',
533
+ count: regl.prop('count'),
534
+ viewport: regl.prop('viewport'),
535
+ depth: {
536
+ enable: regl.prop('depthEnable'),
537
+ mask: false, // depth test but don't write — labels don't occlude each other
538
+ },
539
+ blend: {
540
+ enable: true,
541
+ func: { srcRGB: 'src alpha', dstRGB: 'one minus src alpha', srcAlpha: 0, dstAlpha: 1 },
542
+ },
543
+ })
356
544
  }
357
545
 
358
546
  _setupResizeObserver() {
@@ -361,11 +549,23 @@ export class Plot {
361
549
  // Defer to next animation frame so the ResizeObserver callback exits
362
550
  // before any DOM/layout changes happen, avoiding the "loop completed
363
551
  // with undelivered notifications" browser error.
364
- requestAnimationFrame(() => this.forceUpdate())
552
+ requestAnimationFrame(async () => {
553
+ try {
554
+ await this.forceUpdate()
555
+ } catch (e) {
556
+ console.error('[gladly] Error during resize-triggered update():', e)
557
+ }
558
+ })
365
559
  })
366
560
  this.resizeObserver.observe(this.container)
367
561
  } else {
368
- this._resizeHandler = () => this.forceUpdate()
562
+ this._resizeHandler = async () => {
563
+ try {
564
+ await this.forceUpdate()
565
+ } catch (e) {
566
+ console.error('[gladly] Error during resize-triggered update():', e)
567
+ }
568
+ }
369
569
  window.addEventListener('resize', this._resizeHandler)
370
570
  }
371
571
  }
@@ -373,7 +573,7 @@ export class Plot {
373
573
  // Returns the quantity kind for any axis ID (spatial or color axis).
374
574
  // For color axes, the axis ID IS the quantity kind.
375
575
  getAxisQuantityKind(axisId) {
376
- if (AXES.includes(axisId)) {
576
+ if (Object.prototype.hasOwnProperty.call(AXIS_GEOMETRY, axisId)) {
377
577
  return this.axisRegistry ? this.axisRegistry.axisQuantityKinds[axisId] : null
378
578
  }
379
579
  return axisId
@@ -381,7 +581,7 @@ export class Plot {
381
581
 
382
582
  // Unified domain getter for spatial, color, and filter axes.
383
583
  getAxisDomain(axisId) {
384
- if (AXES.includes(axisId)) {
584
+ if (Object.prototype.hasOwnProperty.call(AXIS_GEOMETRY, axisId)) {
385
585
  const scale = this.axisRegistry?.getScale(axisId)
386
586
  return scale ? scale.domain() : null
387
587
  }
@@ -395,9 +595,16 @@ export class Plot {
395
595
 
396
596
  // Unified domain setter for spatial, color, and filter axes.
397
597
  setAxisDomain(axisId, domain) {
398
- if (AXES.includes(axisId)) {
598
+ if (Object.prototype.hasOwnProperty.call(AXIS_GEOMETRY, axisId)) {
399
599
  const scale = this.axisRegistry?.getScale(axisId)
400
- if (scale) scale.domain(domain)
600
+ if (scale) {
601
+ scale.domain(domain)
602
+ // Keep currentConfig in sync so update() skips _initialize() when only domains changed.
603
+ if (this.currentConfig) {
604
+ const axes = this.currentConfig.axes ?? {}
605
+ this.currentConfig = { ...this.currentConfig, axes: { ...axes, [axisId]: { ...(axes[axisId] ?? {}), min: domain[0], max: domain[1] } } }
606
+ }
607
+ }
401
608
  } else if (this.colorAxisRegistry?.hasAxis(axisId)) {
402
609
  this.colorAxisRegistry.setRange(axisId, domain[0], domain[1])
403
610
  } else if (this.filterAxisRegistry?.hasAxis(axisId)) {
@@ -500,16 +707,24 @@ export class Plot {
500
707
  this._rafId = null
501
708
  }
502
709
 
710
+ if (this._tickLabelAtlas) {
711
+ this._tickLabelAtlas.destroy()
712
+ this._tickLabelAtlas = null
713
+ }
714
+
715
+ this._shaderCache.clear()
716
+
503
717
  if (this.regl) {
504
718
  this.regl.destroy()
719
+ this.regl = null
505
720
  }
506
721
 
507
722
  this._renderCallbacks.clear()
508
723
  this.canvas.remove()
509
- this.svg.remove()
510
724
  }
511
725
 
512
- _processLayers(layersConfig, data) {
726
+ async _processLayers(layersConfig, data, epoch) {
727
+ const TDR_STEP_MS = 500
513
728
  for (let configLayerIndex = 0; configLayerIndex < layersConfig.length; configLayerIndex++) {
514
729
  const layerSpec = layersConfig[configLayerIndex]
515
730
  const entries = Object.entries(layerSpec)
@@ -519,6 +734,7 @@ export class Plot {
519
734
 
520
735
  const [layerTypeName, parameters] = entries[0]
521
736
  const layerType = getLayerType(layerTypeName)
737
+ if (!layerType) throw new Error(`Unknown layer type '${layerTypeName}'`)
522
738
 
523
739
  // Resolve axis config once per layer spec for registration (independent of draw call count).
524
740
  const ac = layerType.resolveAxisConfig(parameters, data)
@@ -528,24 +744,112 @@ export class Plot {
528
744
  // Pass any scale override from config (e.g. "log") so the D3 scale is created correctly.
529
745
  if (ac.xAxis) this.axisRegistry.ensureAxis(ac.xAxis, ac.xAxisQuantityKind, axesConfig[ac.xAxis]?.scale ?? axesConfig[ac.xAxisQuantityKind]?.scale)
530
746
  if (ac.yAxis) this.axisRegistry.ensureAxis(ac.yAxis, ac.yAxisQuantityKind, axesConfig[ac.yAxis]?.scale ?? axesConfig[ac.yAxisQuantityKind]?.scale)
747
+ if (ac.zAxis) this.axisRegistry.ensureAxis(ac.zAxis, ac.zAxisQuantityKind, axesConfig[ac.zAxis]?.scale ?? axesConfig[ac.zAxisQuantityKind]?.scale)
531
748
 
532
749
  // Register color axes (colorscale comes from config or quantity kind registry, not from here)
533
- for (const quantityKind of ac.colorAxisQuantityKinds) {
750
+ for (const quantityKind of Object.values(ac.colorAxisQuantityKinds)) {
534
751
  this.colorAxisRegistry.ensureColorAxis(quantityKind)
535
752
  }
536
753
 
537
754
  // Register filter axes
538
- for (const quantityKind of ac.filterAxisQuantityKinds) {
755
+ for (const quantityKind of Object.values(ac.filterAxisQuantityKinds)) {
539
756
  this.filterAxisRegistry.ensureFilterAxis(quantityKind)
540
757
  }
541
758
 
542
759
  // Create one draw command per GPU config returned by the layer type.
543
- for (const layer of layerType.createLayer(parameters, data)) {
760
+ let gpuLayers
761
+ try {
762
+ gpuLayers = await layerType.createLayer(this.regl, parameters, data, this)
763
+ } catch (e) {
764
+ throw new Error(`Layer '${layerTypeName}' (index ${configLayerIndex}) failed to create: ${e.message}`, { cause: e })
765
+ }
766
+ if (this._initEpoch !== epoch) return
767
+ for (const layer of gpuLayers) {
544
768
  layer.configLayerIndex = configLayerIndex
545
- layer.draw = layer.type.createDrawCommand(this.regl, layer, this)
769
+ const stepStart = performance.now()
770
+ try {
771
+ layer.draw = await this._compileLayerDraw(layer)
772
+ } catch (e) {
773
+ throw new Error(`Layer '${layerTypeName}' (index ${configLayerIndex}) failed to build draw command: ${e.message}`, { cause: e })
774
+ }
775
+ if (this._initEpoch !== epoch) return
776
+ if (performance.now() - stepStart > TDR_STEP_MS)
777
+ await new Promise(r => requestAnimationFrame(r))
778
+ if (this._initEpoch !== epoch) return
546
779
  this.layers.push(layer)
547
780
  }
548
781
  }
782
+ if (this._initEpoch !== epoch) return
783
+ compileEnqueuedShaders(this.regl)
784
+ }
785
+
786
+ async _compileLayerDraw(layer) {
787
+ const drawConfig = await layer.type.createDrawCommand(this.regl, layer, this)
788
+
789
+ // Layer types that fully override createDrawCommand (e.g. TileLayer) return
790
+ // a ready-to-call function instead of a plain drawConfig object. Pass through.
791
+ if (typeof drawConfig === 'function') return drawConfig
792
+
793
+ const shaderKey = drawConfig.vert + '\0' + drawConfig.frag
794
+
795
+ if (!this._shaderCache.has(shaderKey)) {
796
+ // Build a version of the draw config where all layer-specific data
797
+ // (attribute buffers and texture closures) is replaced with regl.prop()
798
+ // references. This compiled command can then be reused for any layer
799
+ // that produces the same shader source, regardless of its data.
800
+ const propAttrs = {}
801
+ for (const [key, val] of Object.entries(drawConfig.attributes)) {
802
+ const rawBuf = val?.buffer instanceof Float32Array ? val.buffer
803
+ : val instanceof Float32Array ? val : null
804
+ if (rawBuf !== null) {
805
+ const propKey = `attr_${key}`
806
+ const divisor = val?.divisor
807
+ propAttrs[key] = divisor !== undefined
808
+ ? { buffer: this.regl.prop(propKey), divisor }
809
+ : this.regl.prop(propKey)
810
+ } else {
811
+ propAttrs[key] = val
812
+ }
813
+ }
814
+
815
+ const propUniforms = {}
816
+ for (const [key, val] of Object.entries(drawConfig.uniforms)) {
817
+ propUniforms[key] = typeof val === 'function' ? this.regl.prop(key) : val
818
+ }
819
+
820
+ const propConfig = { ...drawConfig, attributes: propAttrs, uniforms: propUniforms }
821
+ this._shaderCache.set(shaderKey, enqueueRegl(this.regl, propConfig))
822
+ }
823
+
824
+ const cmd = this._shaderCache.get(shaderKey)
825
+
826
+ // Extract per-layer data: GPU buffers for Float32Array attributes,
827
+ // and texture closures for sampler uniforms.
828
+ const bufferProps = {}
829
+ for (const [key, val] of Object.entries(drawConfig.attributes)) {
830
+ const rawBuf = val?.buffer instanceof Float32Array ? val.buffer
831
+ : val instanceof Float32Array ? val : null
832
+ if (rawBuf !== null) {
833
+ bufferProps[`attr_${key}`] = this.regl.buffer(rawBuf)
834
+ }
835
+ }
836
+
837
+ const textureClosures = {}
838
+ for (const [key, val] of Object.entries(drawConfig.uniforms)) {
839
+ if (typeof val === 'function') textureClosures[key] = val
840
+ }
841
+
842
+ layer._bufferProps = bufferProps
843
+ layer._textureClosures = textureClosures
844
+
845
+ return (runtimeProps) => {
846
+ // Resolve texture closures at draw time so live texture swaps are picked up.
847
+ const textureProps = {}
848
+ for (const [key, fn] of Object.entries(textureClosures)) {
849
+ textureProps[key] = fn()
850
+ }
851
+ cmd({ ...bufferProps, ...textureProps, ...runtimeProps })
852
+ }
549
853
  }
550
854
 
551
855
  _setDomains(axesOverrides) {
@@ -561,26 +865,99 @@ export class Plot {
561
865
  return getScaleTypeFloat(quantityKind, this.currentConfig?.axes)
562
866
  }
563
867
 
564
- static schema(data) {
565
- return buildPlotSchema(data)
868
+ static schema(data, config) {
869
+ return buildPlotSchema(data, config)
566
870
  }
567
871
 
568
- scheduleRender() {
872
+ scheduleRender(sourcePlot = null) {
873
+ if (!this.regl) return
569
874
  this._dirty = true
570
- if (this._rafId === null) {
571
- this._rafId = requestAnimationFrame(() => {
572
- this._rafId = null
573
- if (this._dirty) {
574
- this._dirty = false
575
- this.render()
576
- }
577
- })
875
+ // Track source plot — sticky until RAF is committed so timer/vsync re-entries
876
+ // can still check whether throttling is warranted.
877
+ if (sourcePlot) this._pendingSourcePlot = sourcePlot
878
+ if (this._rafId !== null || this._rendering || this._throttleTimerId !== null) return
879
+
880
+ // Throttle only when the source plot is being blocked by slow linked renders.
881
+ // blocked lag = RAF wait − own render time; near zero for fast plots (colorbars,
882
+ // filterbars) so those never throttle this plot's renders.
883
+ const source = this._pendingSourcePlot
884
+ if (source && source._blockedLag > BLOCKED_LAG_THRESHOLD) {
885
+ const delay = LINK_THROTTLE_MS - (performance.now() - this._lastRenderEnd)
886
+ if (delay > 0) {
887
+ this._throttleTimerId = setTimeout(() => {
888
+ this._throttleTimerId = null
889
+ if (this._dirty) this.scheduleRender()
890
+ }, delay)
891
+ return
892
+ }
578
893
  }
894
+ this._pendingSourcePlot = null // commit: reset now that we're queuing the RAF
895
+
896
+ const schedTime = performance.now()
897
+ const _st0 = schedTime
898
+ setTimeout(() => {
899
+ const _stLag = performance.now() - _st0
900
+ if (_stLag > 20) console.warn(`[gladly] setTimeout(0) lag ${_stLag.toFixed(0)}ms`)
901
+ }, 0)
902
+ this._rafId = requestAnimationFrame(async (rafTime) => {
903
+ this._rafId = null
904
+ const now = performance.now()
905
+ const lag = now - schedTime
906
+ const postVsync = now - rafTime // time from vsync to our callback executing
907
+ if (lag > 50) console.warn(`[gladly] RAF lag ${lag.toFixed(0)}ms (vsync-to-callback: ${postVsync.toFixed(1)}ms)`)
908
+ if (this._dirty) {
909
+ this._dirty = false
910
+ const t0 = performance.now()
911
+ this._rendering = true
912
+ try {
913
+ await this.render()
914
+ } catch (e) {
915
+ console.error('[gladly] Error during render():', e)
916
+ } finally {
917
+ this._rendering = false
918
+ }
919
+ const dt = performance.now() - t0
920
+ if (dt > 10) console.warn(`[gladly] render ${dt.toFixed(0)}ms`)
921
+ // Update blocked-lag EMA: how long were we waiting for others vs rendering ourselves?
922
+ const blockedLag = Math.max(0, lag - dt)
923
+ this._blockedLag = this._blockedLag * (1 - BLOCKED_LAG_ALPHA) + blockedLag * BLOCKED_LAG_ALPHA
924
+ this._lastRenderEnd = performance.now()
925
+ // After submitting GPU work, hold _rafId so no new render can be queued
926
+ // until the compositor is ready for the next frame (browser waits for GPU).
927
+ // Any state changes that arrive during GPU execution are captured then.
928
+ this._rafId = requestAnimationFrame(() => {
929
+ this._rafId = null
930
+ if (this._dirty) this.scheduleRender()
931
+ })
932
+ }
933
+ })
579
934
  }
580
935
 
581
- render() {
936
+ async render() {
582
937
  this._dirty = false
583
- this.regl.clear({ color: [1,1,1,1], depth:1 })
938
+
939
+ // Validate axis domains once per render (warn only when domain is still
940
+ // the D3 default, i.e. was never set — indicates a missing ensureAxis call)
941
+ if (!this._warnedMissingDomains && this.axisRegistry) {
942
+ for (const axisId of AXES) {
943
+ const scale = this.axisRegistry.getScale(axisId)
944
+ if (!scale) continue
945
+ const [lo, hi] = scale.domain()
946
+ if (!isFinite(lo) || !isFinite(hi)) {
947
+ console.warn(
948
+ `[gladly] Axis '${axisId}': domain [${lo}, ${hi}] is non-finite at render time. ` +
949
+ `All data on this axis will be invisible.`
950
+ )
951
+ this._warnedMissingDomains = true
952
+ } else if (lo === hi) {
953
+ console.warn(
954
+ `[gladly] Axis '${axisId}': domain is degenerate [${lo}] at render time. ` +
955
+ `Data on this axis will collapse to a single line.`
956
+ )
957
+ this._warnedMissingDomains = true
958
+ }
959
+ }
960
+ }
584
961
  const viewport = {
585
962
  x: this.margin.left,
586
963
  y: this.margin.bottom,
@@ -589,18 +966,69 @@ export class Plot {
589
966
  }
590
967
  const axesConfig = this.currentConfig?.axes
591
968
 
969
+ // Camera MVP for data layers (maps unit cube to NDC within the plot-area viewport).
970
+ const cameraMvp = this._camera ? this._camera.getMVP() : mat4Identity()
971
+
972
+ // Axis MVP maps the unit cube to full-canvas NDC so axis lines and labels
973
+ // can extend into the margin area outside the plot viewport.
974
+ // sx = plotWidth/width, sy = plotHeight/height
975
+ // cx = (marginLeft - marginRight) / width (NDC centre offset x)
976
+ // cy = (marginBottom - marginTop) / height
977
+ const sx = this.plotWidth / this.width
978
+ const sy = this.plotHeight / this.height
979
+ const cx = (this.margin.left - this.margin.right) / this.width
980
+ const cy = (this.margin.bottom - this.margin.top) / this.height
981
+ // Column-major viewport scale+translate matrix
982
+ const Mvp = new Float32Array([
983
+ sx, 0, 0, 0,
984
+ 0, sy, 0, 0,
985
+ 0, 0, 1, 0,
986
+ cx, cy, 0, 1,
987
+ ])
988
+ const axisMvp = mat4Multiply(Mvp, cameraMvp)
989
+
990
+ // Phase 1 — async compute: refresh all transforms and data columns before touching the canvas.
991
+ // Any TDR-yield RAF pauses happen here while the previous frame is still visible.
992
+ // Yield between steps when a step is expensive to avoid triggering the Windows TDR watchdog.
993
+ for (const node of this._dataTransformNodes) {
994
+ const stepStart = performance.now()
995
+ try {
996
+ await node.refreshIfNeeded(this)
997
+ } catch (e) {
998
+ throw new Error(`Transform refresh failed: ${e.message}`, { cause: e })
999
+ }
1000
+ if (performance.now() - stepStart > TDR_STEP_MS)
1001
+ await new Promise(r => requestAnimationFrame(r))
1002
+ }
1003
+
592
1004
  for (const layer of this.layers) {
593
- if (layer._axisUpdaters) {
594
- for (const updater of layer._axisUpdaters) updater.refreshIfNeeded(this)
1005
+ for (const col of layer._dataColumns ?? []) {
1006
+ const stepStart = performance.now()
1007
+ await col.refresh(this)
1008
+ if (performance.now() - stepStart > TDR_STEP_MS)
1009
+ await new Promise(r => requestAnimationFrame(r))
595
1010
  }
1011
+ }
596
1012
 
1013
+ // Phase 2 — synchronous draw: all data is ready; clear once then draw every layer.
1014
+ // No async yields from here to the end of render() so the canvas is never blank mid-frame.
1015
+ this.regl.clear({ color: [1,1,1,1], depth:1 })
1016
+
1017
+ for (const layer of this.layers) {
597
1018
  const xIsLog = layer.xAxis ? this.axisRegistry.isLogScale(layer.xAxis) : false
598
1019
  const yIsLog = layer.yAxis ? this.axisRegistry.isLogScale(layer.yAxis) : false
1020
+ const zIsLog = layer.zAxis ? this.axisRegistry.isLogScale(layer.zAxis) : false
1021
+ const zScale = layer.zAxis ? this.axisRegistry.getScale(layer.zAxis) : null
1022
+ const zDomain = zScale ? zScale.domain() : [0, 1]
599
1023
  const props = {
600
1024
  xDomain: layer.xAxis ? (this.axisRegistry.getScale(layer.xAxis)?.domain() ?? [0, 1]) : [0, 1],
601
1025
  yDomain: layer.yAxis ? (this.axisRegistry.getScale(layer.yAxis)?.domain() ?? [0, 1]) : [0, 1],
1026
+ zDomain,
602
1027
  xScaleType: xIsLog ? 1.0 : 0.0,
603
1028
  yScaleType: yIsLog ? 1.0 : 0.0,
1029
+ zScaleType: zIsLog ? 1.0 : 0.0,
1030
+ u_is3D: this._is3D ? 1.0 : 0.0,
1031
+ u_mvp: cameraMvp,
604
1032
  viewport: viewport,
605
1033
  count: layer.vertexCount ?? Object.values(layer.attributes).find(v => v instanceof Float32Array)?.length ?? 0,
606
1034
  u_pickingMode: 0.0,
@@ -611,22 +1039,54 @@ export class Plot {
611
1039
  props.instances = layer.instanceCount
612
1040
  }
613
1041
 
614
- for (const qk of layer.colorAxes) {
615
- props[`colorscale_${qk}`] = this.colorAxisRegistry.getColorscaleIndex(qk)
1042
+ // Warn once if this draw call will produce no geometry
1043
+ if (!layer._warnedZeroCount && !layer.type?.suppressWarnings) {
1044
+ const drawCount = props.instances ?? props.count
1045
+ if (drawCount === 0) {
1046
+ console.warn(
1047
+ `[gladly] Layer '${layer.type?.name ?? 'unknown'}' (config index ${layer.configLayerIndex}): ` +
1048
+ `draw count is 0 — nothing will be rendered`
1049
+ )
1050
+ layer._warnedZeroCount = true
1051
+ }
1052
+ }
1053
+
1054
+ for (const qk of Object.values(layer.colorAxes)) {
1055
+ const pk = qk.replace(/\./g, '_')
1056
+ props[`colorscale_${pk}`] = this.colorAxisRegistry.getColorscaleIndex(qk)
616
1057
  const range = this.colorAxisRegistry.getRange(qk)
617
- props[`color_range_${qk}`] = range ?? [0, 1]
618
- props[`color_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
1058
+ props[`color_range_${pk}`] = range ?? [0, 1]
1059
+ props[`color_scale_type_${pk}`] = this._getScaleTypeFloat(qk)
1060
+ props[`alpha_blend_${pk}`] = this.colorAxisRegistry.getAlphaBlend(qk)
619
1061
  }
620
1062
 
621
- for (const qk of layer.filterAxes) {
622
- props[`filter_range_${qk}`] = this.filterAxisRegistry.getRangeUniform(qk)
623
- props[`filter_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
1063
+ for (const qk of Object.values(layer.filterAxes)) {
1064
+ const pk = qk.replace(/\./g, '_')
1065
+ props[`filter_range_${pk}`] = this.filterAxisRegistry.getRangeUniform(qk)
1066
+ props[`filter_scale_type_${pk}`] = this._getScaleTypeFloat(qk)
624
1067
  }
625
1068
 
626
1069
  layer.draw(props)
627
1070
  }
628
1071
 
629
- for (const axisId of AXES) this._getAxis(axisId).render()
1072
+ // Render all registered spatial axes via WebGL (axis lines + tick marks + labels).
1073
+ if (this._axisLineCmd && this._axisBillboardCmd && this._tickLabelAtlas) {
1074
+ // Pre-pass: mark all labels needed this frame, then flush the atlas once.
1075
+ for (const axisId of AXES) {
1076
+ if (!this.axisRegistry.getScale(axisId)) continue
1077
+ this._getAxis(axisId).prepareAtlas(this._tickLabelAtlas, axisMvp, this.width, this.height)
1078
+ }
1079
+ this._tickLabelAtlas.flush()
1080
+
1081
+ for (const axisId of AXES) {
1082
+ if (!this.axisRegistry.getScale(axisId)) continue
1083
+ this._getAxis(axisId).render(
1084
+ this.regl, axisMvp, this.width, this.height,
1085
+ this._is3D, this._tickLabelAtlas,
1086
+ this._axisLineCmd, this._axisBillboardCmd,
1087
+ )
1088
+ }
1089
+ }
630
1090
  for (const cb of this._renderCallbacks) cb()
631
1091
  }
632
1092
 
@@ -663,19 +1123,33 @@ export class Plot {
663
1123
  return { remove: () => window.removeEventListener(eventType, handler, { capture: true }) }
664
1124
  }
665
1125
 
666
- pick(x, y) {
1126
+ async pick(x, y) {
667
1127
  if (!this.regl || !this.layers.length) return null
668
1128
 
1129
+ const glX = Math.round(x)
1130
+ const glY = this.height - Math.round(y) - 1
1131
+
1132
+ if (glX < 0 || glX >= this.width || glY < 0 || glY >= this.height) return null
1133
+
669
1134
  const fbo = this.regl.framebuffer({
670
1135
  width: this.width, height: this.height,
671
1136
  colorFormat: 'rgba', colorType: 'uint8', depth: false,
672
1137
  })
673
1138
 
674
- const glX = Math.round(x)
675
- const glY = this.height - Math.round(y) - 1
676
1139
  const axesConfig = this.currentConfig?.axes
677
1140
 
1141
+ // Refresh transform nodes before picking (same as render)
1142
+ for (const node of this._dataTransformNodes) {
1143
+ await node.refreshIfNeeded(this)
1144
+ }
1145
+
1146
+ // Refresh data columns outside the synchronous regl() callback
1147
+ for (const layer of this.layers) {
1148
+ for (const col of layer._dataColumns ?? []) await col.refresh(this)
1149
+ }
1150
+
678
1151
  let result = null
1152
+ try {
679
1153
  this.regl({ framebuffer: fbo })(() => {
680
1154
  this.regl.clear({ color: [0, 0, 0, 0] })
681
1155
  const viewport = {
@@ -684,32 +1158,39 @@ export class Plot {
684
1158
  }
685
1159
  for (let i = 0; i < this.layers.length; i++) {
686
1160
  const layer = this.layers[i]
687
- if (layer._axisUpdaters) {
688
- for (const updater of layer._axisUpdaters) updater.refreshIfNeeded(this)
689
- }
690
1161
 
691
1162
  const xIsLog = layer.xAxis ? this.axisRegistry.isLogScale(layer.xAxis) : false
692
1163
  const yIsLog = layer.yAxis ? this.axisRegistry.isLogScale(layer.yAxis) : false
1164
+ const zIsLog = layer.zAxis ? this.axisRegistry.isLogScale(layer.zAxis) : false
1165
+ const zScale = layer.zAxis ? this.axisRegistry.getScale(layer.zAxis) : null
1166
+ const camMvp = this._camera ? this._camera.getMVP() : mat4Identity()
693
1167
  const props = {
694
1168
  xDomain: layer.xAxis ? (this.axisRegistry.getScale(layer.xAxis)?.domain() ?? [0, 1]) : [0, 1],
695
1169
  yDomain: layer.yAxis ? (this.axisRegistry.getScale(layer.yAxis)?.domain() ?? [0, 1]) : [0, 1],
1170
+ zDomain: zScale ? zScale.domain() : [0, 1],
696
1171
  xScaleType: xIsLog ? 1.0 : 0.0,
697
1172
  yScaleType: yIsLog ? 1.0 : 0.0,
1173
+ zScaleType: zIsLog ? 1.0 : 0.0,
1174
+ u_is3D: this._is3D ? 1.0 : 0.0,
1175
+ u_mvp: camMvp,
698
1176
  viewport,
699
1177
  count: layer.vertexCount ?? Object.values(layer.attributes).find(v => v instanceof Float32Array)?.length ?? 0,
700
1178
  u_pickingMode: 1.0,
701
1179
  u_pickLayerIndex: i,
702
1180
  }
703
1181
  if (layer.instanceCount !== null) props.instances = layer.instanceCount
704
- for (const qk of layer.colorAxes) {
705
- props[`colorscale_${qk}`] = this.colorAxisRegistry.getColorscaleIndex(qk)
1182
+ for (const qk of Object.values(layer.colorAxes)) {
1183
+ const pk = qk.replace(/\./g, '_')
1184
+ props[`colorscale_${pk}`] = this.colorAxisRegistry.getColorscaleIndex(qk)
706
1185
  const range = this.colorAxisRegistry.getRange(qk)
707
- props[`color_range_${qk}`] = range ?? [0, 1]
708
- props[`color_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
1186
+ props[`color_range_${pk}`] = range ?? [0, 1]
1187
+ props[`color_scale_type_${pk}`] = this._getScaleTypeFloat(qk)
1188
+ props[`alpha_blend_${pk}`] = this.colorAxisRegistry.getAlphaBlend(qk)
709
1189
  }
710
- for (const qk of layer.filterAxes) {
711
- props[`filter_range_${qk}`] = this.filterAxisRegistry.getRangeUniform(qk)
712
- props[`filter_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
1190
+ for (const qk of Object.values(layer.filterAxes)) {
1191
+ const pk = qk.replace(/\./g, '_')
1192
+ props[`filter_range_${pk}`] = this.filterAxisRegistry.getRangeUniform(qk)
1193
+ props[`filter_scale_type_${pk}`] = this._getScaleTypeFloat(qk)
713
1194
  }
714
1195
  layer.draw(props)
715
1196
  }
@@ -728,8 +1209,9 @@ export class Plot {
728
1209
  result = { layerIndex, configLayerIndex: layer.configLayerIndex, dataIndex, layer }
729
1210
  }
730
1211
  })
731
-
732
- fbo.destroy()
1212
+ } finally {
1213
+ fbo.destroy()
1214
+ }
733
1215
  return result
734
1216
  }
735
1217
  }