gladly-plot 0.0.4 → 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 (60) hide show
  1. package/README.md +9 -2
  2. package/package.json +10 -11
  3. package/src/axes/Axis.js +401 -0
  4. package/src/{AxisLink.js → axes/AxisLink.js} +6 -2
  5. package/src/{AxisQuantityKindRegistry.js → axes/AxisQuantityKindRegistry.js} +7 -0
  6. package/src/axes/AxisRegistry.js +179 -0
  7. package/src/axes/Camera.js +47 -0
  8. package/src/axes/ColorAxisRegistry.js +101 -0
  9. package/src/{FilterAxisRegistry.js → axes/FilterAxisRegistry.js} +63 -0
  10. package/src/axes/TickLabelAtlas.js +99 -0
  11. package/src/axes/ZoomController.js +463 -0
  12. package/src/colorscales/BivariateColorscales.js +205 -0
  13. package/src/colorscales/ColorscaleRegistry.js +144 -0
  14. package/src/compute/ComputationRegistry.js +179 -0
  15. package/src/compute/axisFilter.js +59 -0
  16. package/src/compute/conv.js +286 -0
  17. package/src/compute/elementwise.js +72 -0
  18. package/src/compute/fft.js +378 -0
  19. package/src/compute/filter.js +229 -0
  20. package/src/compute/hist.js +285 -0
  21. package/src/compute/kde.js +120 -0
  22. package/src/compute/scatter2dInterpolate.js +277 -0
  23. package/src/compute/util.js +196 -0
  24. package/src/core/ComputePipeline.js +153 -0
  25. package/src/core/GlBase.js +141 -0
  26. package/src/core/Layer.js +59 -0
  27. package/src/core/LayerType.js +433 -0
  28. package/src/core/Plot.js +1213 -0
  29. package/src/core/PlotGroup.js +204 -0
  30. package/src/core/ShaderQueue.js +73 -0
  31. package/src/data/ColumnData.js +269 -0
  32. package/src/data/Computation.js +95 -0
  33. package/src/data/Data.js +270 -0
  34. package/src/{Colorbar.js → floats/Colorbar.js} +19 -5
  35. package/src/floats/Colorbar2d.js +77 -0
  36. package/src/{Filterbar.js → floats/Filterbar.js} +18 -4
  37. package/src/{FilterbarFloat.js → floats/Float.js} +73 -30
  38. package/src/{EpsgUtils.js → geo/EpsgUtils.js} +1 -1
  39. package/src/index.js +47 -22
  40. package/src/layers/BarsLayer.js +168 -0
  41. package/src/{ColorbarLayer.js → layers/ColorbarLayer.js} +12 -16
  42. package/src/layers/ColorbarLayer2d.js +86 -0
  43. package/src/{FilterbarLayer.js → layers/FilterbarLayer.js} +6 -5
  44. package/src/layers/LinesLayer.js +185 -0
  45. package/src/layers/PointsLayer.js +118 -0
  46. package/src/layers/ScatterShared.js +98 -0
  47. package/src/{TileLayer.js → layers/TileLayer.js} +24 -20
  48. package/src/math/mat4.js +100 -0
  49. package/src/Axis.js +0 -48
  50. package/src/AxisRegistry.js +0 -54
  51. package/src/ColorAxisRegistry.js +0 -49
  52. package/src/ColorscaleRegistry.js +0 -52
  53. package/src/Data.js +0 -67
  54. package/src/Float.js +0 -159
  55. package/src/Layer.js +0 -44
  56. package/src/LayerType.js +0 -209
  57. package/src/Plot.js +0 -1073
  58. package/src/ScatterLayer.js +0 -287
  59. /package/src/{MatplotlibColorscales.js → colorscales/MatplotlibColorscales.js} +0 -0
  60. /package/src/{LayerTypeRegistry.js → core/LayerTypeRegistry.js} +0 -0
@@ -0,0 +1,1213 @@
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
+ import { ColorAxisRegistry } from "../axes/ColorAxisRegistry.js"
6
+ import { FilterAxisRegistry } from "../axes/FilterAxisRegistry.js"
7
+ import { ZoomController } from "../axes/ZoomController.js"
8
+ import { getLayerType, getRegisteredLayerTypes } from "./LayerTypeRegistry.js"
9
+ import { getAxisQuantityKind, getScaleTypeFloat } from "../axes/AxisQuantityKindRegistry.js"
10
+ import { getRegisteredColorscales, getRegistered2DColorscales } from "../colorscales/ColorscaleRegistry.js"
11
+ import { Float } from "../floats/Float.js"
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) {
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)
63
+
64
+ return {
65
+ $schema: "https://json-schema.org/draft/2020-12/schema",
66
+ $defs: { ...compDefs, ...transformDefs },
67
+ type: "object",
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
+ },
82
+ layers: {
83
+ type: "array",
84
+ items: {
85
+ type: "object",
86
+ oneOf: layerTypes.map(typeName => {
87
+ const layerType = getLayerType(typeName)
88
+ return {
89
+ title: typeName,
90
+ properties: {
91
+ [typeName]: layerType.schema(fullSchemaData)
92
+ },
93
+ required: [typeName],
94
+ additionalProperties: false
95
+ }
96
+ })
97
+ }
98
+ },
99
+ axes: {
100
+ type: "object",
101
+ properties: {
102
+ xaxis_bottom: {
103
+ type: "object",
104
+ properties: {
105
+ min: { type: "number" },
106
+ max: { type: "number" },
107
+ label: { type: "string" },
108
+ scale: { type: "string", enum: ["linear", "log"] },
109
+ rotate: { type: "boolean" }
110
+ }
111
+ },
112
+ xaxis_top: {
113
+ type: "object",
114
+ properties: {
115
+ min: { type: "number" },
116
+ max: { type: "number" },
117
+ label: { type: "string" },
118
+ scale: { type: "string", enum: ["linear", "log"] },
119
+ rotate: { type: "boolean" }
120
+ }
121
+ },
122
+ yaxis_left: {
123
+ type: "object",
124
+ properties: {
125
+ min: { type: "number" },
126
+ max: { type: "number" },
127
+ label: { type: "string" },
128
+ scale: { type: "string", enum: ["linear", "log"] }
129
+ }
130
+ },
131
+ yaxis_right: {
132
+ type: "object",
133
+ properties: {
134
+ min: { type: "number" },
135
+ max: { type: "number" },
136
+ label: { type: "string" },
137
+ scale: { type: "string", enum: ["linear", "log"] }
138
+ }
139
+ }
140
+ },
141
+ additionalProperties: {
142
+ // Color/filter/quantity-kind axes.
143
+ // All fields from the quantity kind registration are valid here and override the registration.
144
+ type: "object",
145
+ properties: {
146
+ min: { type: "number" },
147
+ max: { type: "number" },
148
+ label: { type: "string" },
149
+ scale: { type: "string", enum: ["linear", "log"] },
150
+ colorscale: {
151
+ type: "string",
152
+ enum: [
153
+ ...getRegisteredColorscales().keys(),
154
+ ...getRegistered2DColorscales().keys()
155
+ ]
156
+ },
157
+ colorbar: {
158
+ type: "string",
159
+ enum: ["none", "vertical", "horizontal"]
160
+ },
161
+ filterbar: {
162
+ type: "string",
163
+ enum: ["none", "vertical", "horizontal"]
164
+ }
165
+ }
166
+ }
167
+ },
168
+ colorbars: {
169
+ type: "array",
170
+ description: "Floating colorbar widgets. Use xAxis+yAxis for 2D, one axis for 1D.",
171
+ items: {
172
+ type: "object",
173
+ properties: {
174
+ xAxis: { type: "string", description: "Quantity kind for the x axis of the colorbar" },
175
+ yAxis: { type: "string", description: "Quantity kind for the y axis of the colorbar" },
176
+ colorscale: {
177
+ type: "string",
178
+ description: "Colorscale override. A 2D colorscale name enables the true-2D path.",
179
+ enum: [
180
+ "none",
181
+ ...getRegisteredColorscales().keys(),
182
+ ...getRegistered2DColorscales().keys()
183
+ ]
184
+ }
185
+ }
186
+ }
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ export class Plot extends GlBase {
193
+ // Registry of float factories keyed by type name.
194
+ // Each entry: { factory(parentPlot, container, opts) → widget, defaultSize(opts) → {width,height} }
195
+ // Populated by Colorbar.js, Filterbar.js, Colorbar2d.js at module load time.
196
+ static _floatFactories = new Map()
197
+
198
+ static registerFloatFactory(type, factoryDef) {
199
+ Plot._floatFactories.set(type, factoryDef)
200
+ }
201
+
202
+ constructor(container, { margin } = {}) {
203
+ super()
204
+ this.container = container
205
+ this.margin = margin ?? { top: 60, right: 60, bottom: 60, left: 60 }
206
+
207
+ // Create canvas element
208
+ this.canvas = document.createElement('canvas')
209
+ this.canvas.style.display = 'block'
210
+ this.canvas.style.position = 'absolute'
211
+ this.canvas.style.top = '0'
212
+ this.canvas.style.left = '0'
213
+ container.appendChild(this.canvas)
214
+
215
+ this.currentConfig = null
216
+ this._lastRawDataArg = undefined
217
+ this.layers = []
218
+ this.axisRegistry = null
219
+ this.colorAxisRegistry = null
220
+ this._renderCallbacks = new Set()
221
+ this._zoomEndCallbacks = new Set()
222
+ this._dirty = false
223
+ this._rafId = 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()
238
+
239
+ // Auto-managed Float widgets keyed by a config-derived tag string.
240
+ // Covers 1D colorbars, 2D colorbars, and filterbars in a single unified Map.
241
+ this._floats = new Map()
242
+
243
+ this._setupResizeObserver()
244
+ }
245
+
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
+ }
254
+
255
+ if (!this.currentConfig || !this._rawData) return
256
+
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
261
+
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
266
+
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
274
+
275
+ this._warnedMissingDomains = false
276
+ await this._initialize()
277
+ this._syncFloats()
278
+ }
279
+
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
+ }
298
+
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
307
+ }
308
+ }
309
+
310
+ if (data !== undefined) this._lastRawDataArg = data
311
+
312
+ const previousConfig = this.currentConfig
313
+ const previousRawData = this._rawData
314
+ try {
315
+ await this._applyUpdate({ config, data })
316
+ this._validateLinks()
317
+ } catch (error) {
318
+ this.currentConfig = previousConfig
319
+ this._rawData = previousRawData
320
+ throw error
321
+ }
322
+ }
323
+
324
+ async forceUpdate() {
325
+ await this.update({})
326
+ }
327
+
328
+ getConfig() {
329
+ const axes = { ...(this.currentConfig?.axes ?? {}) }
330
+
331
+ if (this.axisRegistry) {
332
+ for (const axisId of AXES) {
333
+ const scale = this.axisRegistry.getScale(axisId)
334
+ if (scale) {
335
+ const [min, max] = scale.domain()
336
+ const qk = this.axisRegistry.axisQuantityKinds[axisId]
337
+ const qkDef = qk ? getAxisQuantityKind(qk) : {}
338
+ axes[axisId] = { ...qkDef, ...(axes[axisId] ?? {}), min, max }
339
+ }
340
+ }
341
+ }
342
+
343
+ if (this.colorAxisRegistry) {
344
+ for (const quantityKind of this.colorAxisRegistry.getQuantityKinds()) {
345
+ const range = this.colorAxisRegistry.getRange(quantityKind)
346
+ const qkDef = getAxisQuantityKind(quantityKind)
347
+ const existing = axes[quantityKind] ?? {}
348
+ axes[quantityKind] = {
349
+ colorbar: "none",
350
+ ...qkDef,
351
+ ...existing,
352
+ ...(range ? { min: range[0], max: range[1] } : {}),
353
+ }
354
+ }
355
+ }
356
+
357
+ if (this.filterAxisRegistry) {
358
+ for (const quantityKind of this.filterAxisRegistry.getQuantityKinds()) {
359
+ const range = this.filterAxisRegistry.getRange(quantityKind)
360
+ const qkDef = getAxisQuantityKind(quantityKind)
361
+ const existing = axes[quantityKind] ?? {}
362
+ axes[quantityKind] = {
363
+ filterbar: "none",
364
+ ...qkDef,
365
+ ...existing,
366
+ ...(range && range.min !== null ? { min: range.min } : {}),
367
+ ...(range && range.max !== null ? { max: range.max } : {})
368
+ }
369
+ }
370
+ }
371
+
372
+ return { transforms: [], colorbars: [], ...this.currentConfig, axes}
373
+ }
374
+
375
+ async _initialize() {
376
+ const epoch = ++this._initEpoch
377
+ const { layers = [], axes = {}, colorbars = [], transforms = [] } = this.currentConfig
378
+
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
+ }
386
+
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
+ }
405
+
406
+ this.axisRegistry = new AxisRegistry(this.plotWidth, this.plotHeight)
407
+ this.colorAxisRegistry = new ColorAxisRegistry()
408
+ this.filterAxisRegistry = new FilterAxisRegistry()
409
+
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
414
+ this._setDomains(axes)
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
+
430
+ // Apply colorscale overrides from top-level colorbars entries. These override any
431
+ // per-axis colorscale from config.axes or quantity kind registry. Applying after
432
+ // _setDomains ensures they take effect last. For 2D colorbars both axes receive the
433
+ // same colorscale name, which resolves to a negative index in the shader, triggering
434
+ // the true-2D colorscale path in map_color_s_2d.
435
+ for (const entry of colorbars) {
436
+ if (!entry.colorscale || entry.colorscale == "none") continue
437
+ console.log("FROM colorbars");
438
+ if (entry.xAxis) this.colorAxisRegistry.ensureColorAxis(entry.xAxis, entry.colorscale)
439
+ if (entry.yAxis) this.colorAxisRegistry.ensureColorAxis(entry.yAxis, entry.colorscale)
440
+ }
441
+
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
+ })
544
+ }
545
+
546
+ _setupResizeObserver() {
547
+ if (typeof ResizeObserver !== 'undefined') {
548
+ this.resizeObserver = new ResizeObserver(() => {
549
+ // Defer to next animation frame so the ResizeObserver callback exits
550
+ // before any DOM/layout changes happen, avoiding the "loop completed
551
+ // with undelivered notifications" browser error.
552
+ requestAnimationFrame(async () => {
553
+ try {
554
+ await this.forceUpdate()
555
+ } catch (e) {
556
+ console.error('[gladly] Error during resize-triggered update():', e)
557
+ }
558
+ })
559
+ })
560
+ this.resizeObserver.observe(this.container)
561
+ } else {
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
+ }
569
+ window.addEventListener('resize', this._resizeHandler)
570
+ }
571
+ }
572
+
573
+ // Returns the quantity kind for any axis ID (spatial or color axis).
574
+ // For color axes, the axis ID IS the quantity kind.
575
+ getAxisQuantityKind(axisId) {
576
+ if (Object.prototype.hasOwnProperty.call(AXIS_GEOMETRY, axisId)) {
577
+ return this.axisRegistry ? this.axisRegistry.axisQuantityKinds[axisId] : null
578
+ }
579
+ return axisId
580
+ }
581
+
582
+ // Unified domain getter for spatial, color, and filter axes.
583
+ getAxisDomain(axisId) {
584
+ if (Object.prototype.hasOwnProperty.call(AXIS_GEOMETRY, axisId)) {
585
+ const scale = this.axisRegistry?.getScale(axisId)
586
+ return scale ? scale.domain() : null
587
+ }
588
+ if (this.colorAxisRegistry?.hasAxis(axisId)) {
589
+ return this.colorAxisRegistry.getRange(axisId)
590
+ }
591
+ const filterRange = this.filterAxisRegistry?.getRange(axisId)
592
+ if (filterRange) return [filterRange.min, filterRange.max]
593
+ return null
594
+ }
595
+
596
+ // Unified domain setter for spatial, color, and filter axes.
597
+ setAxisDomain(axisId, domain) {
598
+ if (Object.prototype.hasOwnProperty.call(AXIS_GEOMETRY, axisId)) {
599
+ const scale = this.axisRegistry?.getScale(axisId)
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
+ }
608
+ } else if (this.colorAxisRegistry?.hasAxis(axisId)) {
609
+ this.colorAxisRegistry.setRange(axisId, domain[0], domain[1])
610
+ } else if (this.filterAxisRegistry?.hasAxis(axisId)) {
611
+ this.filterAxisRegistry.setRange(axisId, domain[0], domain[1])
612
+ }
613
+ }
614
+
615
+ _syncFloats() {
616
+ const config = this.currentConfig ?? {}
617
+ const axes = config.axes ?? {}
618
+ const colorbarsConfig = config.colorbars ?? []
619
+
620
+ // Build a map from tag → { factoryDef, opts, y } for every float that should exist.
621
+ // Tags encode the full config so changing any relevant field destroys and recreates the float.
622
+ // Using tags rather than axis names means orientation changes cause clean destroy+recreate
623
+ // with no separate state to compare.
624
+ const desired = new Map()
625
+
626
+ // 1D colorbars declared inline on axes: axes[qk].colorbar = "horizontal"|"vertical"
627
+ for (const [axisName, axisConfig] of Object.entries(axes)) {
628
+ if (AXES.includes(axisName)) continue
629
+ const cb = axisConfig.colorbar
630
+ if (cb === "vertical" || cb === "horizontal") {
631
+ const tag = `colorbar:${axisName}:${cb}`
632
+ const factoryDef = Plot._floatFactories.get('colorbar')
633
+ if (factoryDef) desired.set(tag, { factoryDef, opts: { axisName, orientation: cb }, y: 10 })
634
+ }
635
+ // Filterbars declared inline on axes: axes[qk].filterbar = "horizontal"|"vertical"
636
+ if (this.filterAxisRegistry?.hasAxis(axisName)) {
637
+ const fb = axisConfig.filterbar
638
+ if (fb === "vertical" || fb === "horizontal") {
639
+ const tag = `filterbar:${axisName}:${fb}`
640
+ const factoryDef = Plot._floatFactories.get('filterbar')
641
+ if (factoryDef) desired.set(tag, { factoryDef, opts: { axisName, orientation: fb }, y: 100 })
642
+ }
643
+ }
644
+ }
645
+
646
+ // Top-level colorbars array: 1D or 2D depending on which axes are specified.
647
+ for (const entry of colorbarsConfig) {
648
+ const { xAxis, yAxis } = entry
649
+ if (xAxis && yAxis) {
650
+ // 2D colorbar
651
+ const tag = `colorbar2d:${xAxis}:${yAxis}`
652
+ const factoryDef = Plot._floatFactories.get('colorbar2d')
653
+ if (factoryDef) desired.set(tag, { factoryDef, opts: { xAxis, yAxis }, y: 10 })
654
+ } else if (xAxis) {
655
+ // 1D horizontal colorbar from colorbars array
656
+ const tag = `colorbar:${xAxis}:horizontal`
657
+ const factoryDef = Plot._floatFactories.get('colorbar')
658
+ if (factoryDef) desired.set(tag, { factoryDef, opts: { axisName: xAxis, orientation: 'horizontal' }, y: 10 })
659
+ } else if (yAxis) {
660
+ // 1D vertical colorbar from colorbars array
661
+ const tag = `colorbar:${yAxis}:vertical`
662
+ const factoryDef = Plot._floatFactories.get('colorbar')
663
+ if (factoryDef) desired.set(tag, { factoryDef, opts: { axisName: yAxis, orientation: 'vertical' }, y: 10 })
664
+ }
665
+ }
666
+
667
+ // Destroy floats whose tag is no longer in desired
668
+ for (const [tag, float] of this._floats) {
669
+ if (!desired.has(tag)) {
670
+ float.destroy()
671
+ this._floats.delete(tag)
672
+ }
673
+ }
674
+
675
+ // Create floats for new tags
676
+ for (const [tag, { factoryDef, opts, y }] of desired) {
677
+ if (!this._floats.has(tag)) {
678
+ const size = factoryDef.defaultSize(opts)
679
+ this._floats.set(tag, new Float(
680
+ this,
681
+ (container) => factoryDef.factory(this, container, opts),
682
+ { y, ...size }
683
+ ))
684
+ }
685
+ }
686
+ }
687
+
688
+ destroy() {
689
+ for (const float of this._floats.values()) {
690
+ float.destroy()
691
+ }
692
+ this._floats.clear()
693
+
694
+ // Clear all axis listeners so linked axes stop trying to update this plot
695
+ for (const axis of this._axisCache.values()) {
696
+ axis._listeners.clear()
697
+ }
698
+
699
+ if (this.resizeObserver) {
700
+ this.resizeObserver.disconnect()
701
+ } else if (this._resizeHandler) {
702
+ window.removeEventListener('resize', this._resizeHandler)
703
+ }
704
+
705
+ if (this._rafId !== null) {
706
+ cancelAnimationFrame(this._rafId)
707
+ this._rafId = null
708
+ }
709
+
710
+ if (this._tickLabelAtlas) {
711
+ this._tickLabelAtlas.destroy()
712
+ this._tickLabelAtlas = null
713
+ }
714
+
715
+ this._shaderCache.clear()
716
+
717
+ if (this.regl) {
718
+ this.regl.destroy()
719
+ this.regl = null
720
+ }
721
+
722
+ this._renderCallbacks.clear()
723
+ this.canvas.remove()
724
+ }
725
+
726
+ async _processLayers(layersConfig, data, epoch) {
727
+ const TDR_STEP_MS = 500
728
+ for (let configLayerIndex = 0; configLayerIndex < layersConfig.length; configLayerIndex++) {
729
+ const layerSpec = layersConfig[configLayerIndex]
730
+ const entries = Object.entries(layerSpec)
731
+ if (entries.length !== 1) {
732
+ throw new Error("Each layer specification must have exactly one layer type key")
733
+ }
734
+
735
+ const [layerTypeName, parameters] = entries[0]
736
+ const layerType = getLayerType(layerTypeName)
737
+ if (!layerType) throw new Error(`Unknown layer type '${layerTypeName}'`)
738
+
739
+ // Resolve axis config once per layer spec for registration (independent of draw call count).
740
+ const ac = layerType.resolveAxisConfig(parameters, data)
741
+ const axesConfig = this.currentConfig?.axes ?? {}
742
+
743
+ // Register spatial axes (null means no axis for that direction).
744
+ // Pass any scale override from config (e.g. "log") so the D3 scale is created correctly.
745
+ if (ac.xAxis) this.axisRegistry.ensureAxis(ac.xAxis, ac.xAxisQuantityKind, axesConfig[ac.xAxis]?.scale ?? axesConfig[ac.xAxisQuantityKind]?.scale)
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)
748
+
749
+ // Register color axes (colorscale comes from config or quantity kind registry, not from here)
750
+ for (const quantityKind of Object.values(ac.colorAxisQuantityKinds)) {
751
+ this.colorAxisRegistry.ensureColorAxis(quantityKind)
752
+ }
753
+
754
+ // Register filter axes
755
+ for (const quantityKind of Object.values(ac.filterAxisQuantityKinds)) {
756
+ this.filterAxisRegistry.ensureFilterAxis(quantityKind)
757
+ }
758
+
759
+ // Create one draw command per GPU config returned by the layer type.
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) {
768
+ layer.configLayerIndex = configLayerIndex
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
779
+ this.layers.push(layer)
780
+ }
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
+ }
853
+ }
854
+
855
+ _setDomains(axesOverrides) {
856
+ this.axisRegistry.applyAutoDomainsFromLayers(this.layers, axesOverrides)
857
+ this.colorAxisRegistry.applyAutoDomainsFromLayers(this.layers, axesOverrides)
858
+ this.filterAxisRegistry.applyAutoDomainsFromLayers(this.layers, axesOverrides)
859
+ }
860
+
861
+ // Thin wrapper so subclasses (e.g. Colorbar) can override scale-type lookup
862
+ // for axes they proxy from another plot. Implementation delegates to the
863
+ // module-level getScaleTypeFloat which reads from axesConfig directly.
864
+ _getScaleTypeFloat(quantityKind) {
865
+ return getScaleTypeFloat(quantityKind, this.currentConfig?.axes)
866
+ }
867
+
868
+ static schema(data, config) {
869
+ return buildPlotSchema(data, config)
870
+ }
871
+
872
+ scheduleRender(sourcePlot = null) {
873
+ if (!this.regl) return
874
+ this._dirty = true
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
+ }
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
+ })
934
+ }
935
+
936
+ async render() {
937
+ this._dirty = false
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
+ }
961
+ const viewport = {
962
+ x: this.margin.left,
963
+ y: this.margin.bottom,
964
+ width: this.plotWidth,
965
+ height: this.plotHeight
966
+ }
967
+ const axesConfig = this.currentConfig?.axes
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
+
1004
+ for (const layer of this.layers) {
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))
1010
+ }
1011
+ }
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) {
1018
+ const xIsLog = layer.xAxis ? this.axisRegistry.isLogScale(layer.xAxis) : false
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]
1023
+ const props = {
1024
+ xDomain: layer.xAxis ? (this.axisRegistry.getScale(layer.xAxis)?.domain() ?? [0, 1]) : [0, 1],
1025
+ yDomain: layer.yAxis ? (this.axisRegistry.getScale(layer.yAxis)?.domain() ?? [0, 1]) : [0, 1],
1026
+ zDomain,
1027
+ xScaleType: xIsLog ? 1.0 : 0.0,
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,
1032
+ viewport: viewport,
1033
+ count: layer.vertexCount ?? Object.values(layer.attributes).find(v => v instanceof Float32Array)?.length ?? 0,
1034
+ u_pickingMode: 0.0,
1035
+ u_pickLayerIndex: 0.0,
1036
+ }
1037
+
1038
+ if (layer.instanceCount !== null) {
1039
+ props.instances = layer.instanceCount
1040
+ }
1041
+
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
+ props[`colorscale_${qk}`] = this.colorAxisRegistry.getColorscaleIndex(qk)
1056
+ const range = this.colorAxisRegistry.getRange(qk)
1057
+ props[`color_range_${qk}`] = range ?? [0, 1]
1058
+ props[`color_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
1059
+ props[`alpha_blend_${qk}`] = this.colorAxisRegistry.getAlphaBlend(qk)
1060
+ }
1061
+
1062
+ for (const qk of Object.values(layer.filterAxes)) {
1063
+ props[`filter_range_${qk}`] = this.filterAxisRegistry.getRangeUniform(qk)
1064
+ props[`filter_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
1065
+ }
1066
+
1067
+ layer.draw(props)
1068
+ }
1069
+
1070
+ // Render all registered spatial axes via WebGL (axis lines + tick marks + labels).
1071
+ if (this._axisLineCmd && this._axisBillboardCmd && this._tickLabelAtlas) {
1072
+ // Pre-pass: mark all labels needed this frame, then flush the atlas once.
1073
+ for (const axisId of AXES) {
1074
+ if (!this.axisRegistry.getScale(axisId)) continue
1075
+ this._getAxis(axisId).prepareAtlas(this._tickLabelAtlas, axisMvp, this.width, this.height)
1076
+ }
1077
+ this._tickLabelAtlas.flush()
1078
+
1079
+ for (const axisId of AXES) {
1080
+ if (!this.axisRegistry.getScale(axisId)) continue
1081
+ this._getAxis(axisId).render(
1082
+ this.regl, axisMvp, this.width, this.height,
1083
+ this._is3D, this._tickLabelAtlas,
1084
+ this._axisLineCmd, this._axisBillboardCmd,
1085
+ )
1086
+ }
1087
+ }
1088
+ for (const cb of this._renderCallbacks) cb()
1089
+ }
1090
+
1091
+ lookup(x, y) {
1092
+ const result = {}
1093
+ if (!this.axisRegistry) return result
1094
+ const plotX = x - this.margin.left
1095
+ const plotY = y - this.margin.top
1096
+ for (const axisId of AXES) {
1097
+ const scale = this.axisRegistry.getScale(axisId)
1098
+ if (!scale) continue
1099
+ const qk = this.axisRegistry.axisQuantityKinds[axisId]
1100
+ const value = axisId.includes('y') ? scale.invert(plotY) : scale.invert(plotX)
1101
+ result[axisId] = value
1102
+ if (qk) result[qk] = value
1103
+ }
1104
+ return result
1105
+ }
1106
+
1107
+ onZoomEnd(cb) {
1108
+ this._zoomEndCallbacks.add(cb)
1109
+ return { remove: () => this._zoomEndCallbacks.delete(cb) }
1110
+ }
1111
+
1112
+ on(eventType, callback) {
1113
+ const handler = (e) => {
1114
+ if (!this.container.contains(e.target)) return
1115
+ const rect = this.container.getBoundingClientRect()
1116
+ const x = e.clientX - rect.left
1117
+ const y = e.clientY - rect.top
1118
+ callback(e, this.lookup(x, y))
1119
+ }
1120
+ window.addEventListener(eventType, handler, { capture: true })
1121
+ return { remove: () => window.removeEventListener(eventType, handler, { capture: true }) }
1122
+ }
1123
+
1124
+ async pick(x, y) {
1125
+ if (!this.regl || !this.layers.length) return null
1126
+
1127
+ const glX = Math.round(x)
1128
+ const glY = this.height - Math.round(y) - 1
1129
+
1130
+ if (glX < 0 || glX >= this.width || glY < 0 || glY >= this.height) return null
1131
+
1132
+ const fbo = this.regl.framebuffer({
1133
+ width: this.width, height: this.height,
1134
+ colorFormat: 'rgba', colorType: 'uint8', depth: false,
1135
+ })
1136
+
1137
+ const axesConfig = this.currentConfig?.axes
1138
+
1139
+ // Refresh transform nodes before picking (same as render)
1140
+ for (const node of this._dataTransformNodes) {
1141
+ await node.refreshIfNeeded(this)
1142
+ }
1143
+
1144
+ // Refresh data columns outside the synchronous regl() callback
1145
+ for (const layer of this.layers) {
1146
+ for (const col of layer._dataColumns ?? []) await col.refresh(this)
1147
+ }
1148
+
1149
+ let result = null
1150
+ try {
1151
+ this.regl({ framebuffer: fbo })(() => {
1152
+ this.regl.clear({ color: [0, 0, 0, 0] })
1153
+ const viewport = {
1154
+ x: this.margin.left, y: this.margin.bottom,
1155
+ width: this.plotWidth, height: this.plotHeight
1156
+ }
1157
+ for (let i = 0; i < this.layers.length; i++) {
1158
+ const layer = this.layers[i]
1159
+
1160
+ const xIsLog = layer.xAxis ? this.axisRegistry.isLogScale(layer.xAxis) : false
1161
+ const yIsLog = layer.yAxis ? this.axisRegistry.isLogScale(layer.yAxis) : false
1162
+ const zIsLog = layer.zAxis ? this.axisRegistry.isLogScale(layer.zAxis) : false
1163
+ const zScale = layer.zAxis ? this.axisRegistry.getScale(layer.zAxis) : null
1164
+ const camMvp = this._camera ? this._camera.getMVP() : mat4Identity()
1165
+ const props = {
1166
+ xDomain: layer.xAxis ? (this.axisRegistry.getScale(layer.xAxis)?.domain() ?? [0, 1]) : [0, 1],
1167
+ yDomain: layer.yAxis ? (this.axisRegistry.getScale(layer.yAxis)?.domain() ?? [0, 1]) : [0, 1],
1168
+ zDomain: zScale ? zScale.domain() : [0, 1],
1169
+ xScaleType: xIsLog ? 1.0 : 0.0,
1170
+ yScaleType: yIsLog ? 1.0 : 0.0,
1171
+ zScaleType: zIsLog ? 1.0 : 0.0,
1172
+ u_is3D: this._is3D ? 1.0 : 0.0,
1173
+ u_mvp: camMvp,
1174
+ viewport,
1175
+ count: layer.vertexCount ?? Object.values(layer.attributes).find(v => v instanceof Float32Array)?.length ?? 0,
1176
+ u_pickingMode: 1.0,
1177
+ u_pickLayerIndex: i,
1178
+ }
1179
+ if (layer.instanceCount !== null) props.instances = layer.instanceCount
1180
+ for (const qk of Object.values(layer.colorAxes)) {
1181
+ props[`colorscale_${qk}`] = this.colorAxisRegistry.getColorscaleIndex(qk)
1182
+ const range = this.colorAxisRegistry.getRange(qk)
1183
+ props[`color_range_${qk}`] = range ?? [0, 1]
1184
+ props[`color_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
1185
+ props[`alpha_blend_${qk}`] = this.colorAxisRegistry.getAlphaBlend(qk)
1186
+ }
1187
+ for (const qk of Object.values(layer.filterAxes)) {
1188
+ props[`filter_range_${qk}`] = this.filterAxisRegistry.getRangeUniform(qk)
1189
+ props[`filter_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
1190
+ }
1191
+ layer.draw(props)
1192
+ }
1193
+ var pixels;
1194
+ try {
1195
+ pixels = this.regl.read({ x: glX, y: glY, width: 1, height: 1 })
1196
+ } catch (e) {
1197
+ pixels = [0];
1198
+ }
1199
+ if (pixels[0] === 0) {
1200
+ result = null
1201
+ } else {
1202
+ const layerIndex = pixels[0] - 1
1203
+ const dataIndex = (pixels[1] << 16) | (pixels[2] << 8) | pixels[3]
1204
+ const layer = this.layers[layerIndex]
1205
+ result = { layerIndex, configLayerIndex: layer.configLayerIndex, dataIndex, layer }
1206
+ }
1207
+ })
1208
+ } finally {
1209
+ fbo.destroy()
1210
+ }
1211
+ return result
1212
+ }
1213
+ }