gladly-plot 0.0.4 → 0.0.5

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 (43) hide show
  1. package/package.json +2 -2
  2. package/src/axes/Axis.js +253 -0
  3. package/src/{AxisQuantityKindRegistry.js → axes/AxisQuantityKindRegistry.js} +7 -0
  4. package/src/{AxisRegistry.js → axes/AxisRegistry.js} +48 -0
  5. package/src/axes/ColorAxisRegistry.js +93 -0
  6. package/src/{FilterAxisRegistry.js → axes/FilterAxisRegistry.js} +63 -0
  7. package/src/axes/ZoomController.js +141 -0
  8. package/src/colorscales/BivariateColorscales.js +205 -0
  9. package/src/colorscales/ColorscaleRegistry.js +124 -0
  10. package/src/compute/ComputationRegistry.js +237 -0
  11. package/src/compute/axisFilter.js +47 -0
  12. package/src/compute/conv.js +230 -0
  13. package/src/compute/fft.js +292 -0
  14. package/src/compute/filter.js +227 -0
  15. package/src/compute/hist.js +180 -0
  16. package/src/compute/kde.js +102 -0
  17. package/src/{Layer.js → core/Layer.js} +4 -3
  18. package/src/{LayerType.js → core/LayerType.js} +72 -7
  19. package/src/core/Plot.js +735 -0
  20. package/src/{Colorbar.js → floats/Colorbar.js} +19 -5
  21. package/src/floats/Colorbar2d.js +77 -0
  22. package/src/{Filterbar.js → floats/Filterbar.js} +18 -4
  23. package/src/{FilterbarFloat.js → floats/Float.js} +17 -30
  24. package/src/{EpsgUtils.js → geo/EpsgUtils.js} +1 -1
  25. package/src/index.js +35 -22
  26. package/src/{ColorbarLayer.js → layers/ColorbarLayer.js} +2 -2
  27. package/src/layers/ColorbarLayer2d.js +97 -0
  28. package/src/{FilterbarLayer.js → layers/FilterbarLayer.js} +2 -2
  29. package/src/layers/HistogramLayer.js +212 -0
  30. package/src/layers/LinesLayer.js +199 -0
  31. package/src/layers/PointsLayer.js +114 -0
  32. package/src/layers/ScatterShared.js +142 -0
  33. package/src/{TileLayer.js → layers/TileLayer.js} +4 -4
  34. package/src/Axis.js +0 -48
  35. package/src/ColorAxisRegistry.js +0 -49
  36. package/src/ColorscaleRegistry.js +0 -52
  37. package/src/Float.js +0 -159
  38. package/src/Plot.js +0 -1073
  39. package/src/ScatterLayer.js +0 -287
  40. /package/src/{AxisLink.js → axes/AxisLink.js} +0 -0
  41. /package/src/{MatplotlibColorscales.js → colorscales/MatplotlibColorscales.js} +0 -0
  42. /package/src/{Data.js → core/Data.js} +0 -0
  43. /package/src/{LayerTypeRegistry.js → core/LayerTypeRegistry.js} +0 -0
@@ -0,0 +1,735 @@
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"
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
+
13
+ function buildPlotSchema(data) {
14
+ const layerTypes = getRegisteredLayerTypes()
15
+
16
+ return {
17
+ $schema: "https://json-schema.org/draft/2020-12/schema",
18
+ type: "object",
19
+ properties: {
20
+ layers: {
21
+ type: "array",
22
+ items: {
23
+ type: "object",
24
+ oneOf: layerTypes.map(typeName => {
25
+ const layerType = getLayerType(typeName)
26
+ return {
27
+ title: typeName,
28
+ properties: {
29
+ [typeName]: layerType.schema(data)
30
+ },
31
+ required: [typeName],
32
+ additionalProperties: false
33
+ }
34
+ })
35
+ }
36
+ },
37
+ axes: {
38
+ type: "object",
39
+ properties: {
40
+ xaxis_bottom: {
41
+ type: "object",
42
+ properties: {
43
+ min: { type: "number" },
44
+ max: { type: "number" },
45
+ label: { type: "string" },
46
+ scale: { type: "string", enum: ["linear", "log"] },
47
+ rotate: { type: "boolean" }
48
+ }
49
+ },
50
+ xaxis_top: {
51
+ type: "object",
52
+ properties: {
53
+ min: { type: "number" },
54
+ max: { type: "number" },
55
+ label: { type: "string" },
56
+ scale: { type: "string", enum: ["linear", "log"] },
57
+ rotate: { type: "boolean" }
58
+ }
59
+ },
60
+ yaxis_left: {
61
+ type: "object",
62
+ properties: {
63
+ min: { type: "number" },
64
+ max: { type: "number" },
65
+ label: { type: "string" },
66
+ scale: { type: "string", enum: ["linear", "log"] }
67
+ }
68
+ },
69
+ yaxis_right: {
70
+ type: "object",
71
+ properties: {
72
+ min: { type: "number" },
73
+ max: { type: "number" },
74
+ label: { type: "string" },
75
+ scale: { type: "string", enum: ["linear", "log"] }
76
+ }
77
+ }
78
+ },
79
+ additionalProperties: {
80
+ // Color/filter/quantity-kind axes.
81
+ // All fields from the quantity kind registration are valid here and override the registration.
82
+ type: "object",
83
+ properties: {
84
+ min: { type: "number" },
85
+ max: { type: "number" },
86
+ label: { type: "string" },
87
+ scale: { type: "string", enum: ["linear", "log"] },
88
+ colorscale: {
89
+ type: "string",
90
+ enum: [
91
+ ...getRegisteredColorscales().keys(),
92
+ ...getRegistered2DColorscales().keys()
93
+ ]
94
+ },
95
+ colorbar: {
96
+ type: "string",
97
+ enum: ["none", "vertical", "horizontal"]
98
+ },
99
+ filterbar: {
100
+ type: "string",
101
+ enum: ["none", "vertical", "horizontal"]
102
+ }
103
+ }
104
+ }
105
+ },
106
+ colorbars: {
107
+ type: "array",
108
+ description: "Floating colorbar widgets. Use xAxis+yAxis for 2D, one axis for 1D.",
109
+ items: {
110
+ type: "object",
111
+ properties: {
112
+ xAxis: { type: "string", description: "Quantity kind for the x axis of the colorbar" },
113
+ yAxis: { type: "string", description: "Quantity kind for the y axis of the colorbar" },
114
+ colorscale: {
115
+ type: "string",
116
+ description: "Colorscale override. A 2D colorscale name enables the true-2D path.",
117
+ enum: [
118
+ "none",
119
+ ...getRegisteredColorscales().keys(),
120
+ ...getRegistered2DColorscales().keys()
121
+ ]
122
+ }
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ export class Plot {
131
+ // Registry of float factories keyed by type name.
132
+ // Each entry: { factory(parentPlot, container, opts) → widget, defaultSize(opts) → {width,height} }
133
+ // Populated by Colorbar.js, Filterbar.js, Colorbar2d.js at module load time.
134
+ static _floatFactories = new Map()
135
+
136
+ static registerFloatFactory(type, factoryDef) {
137
+ Plot._floatFactories.set(type, factoryDef)
138
+ }
139
+
140
+ constructor(container, { margin } = {}) {
141
+ this.container = container
142
+ this.margin = margin ?? { top: 60, right: 60, bottom: 60, left: 60 }
143
+
144
+ // Create canvas element
145
+ this.canvas = document.createElement('canvas')
146
+ this.canvas.style.display = 'block'
147
+ this.canvas.style.position = 'absolute'
148
+ this.canvas.style.top = '0'
149
+ this.canvas.style.left = '0'
150
+ this.canvas.style.zIndex = '1'
151
+ container.appendChild(this.canvas)
152
+
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
+ this.currentConfig = null
163
+ this.currentData = null
164
+ this.regl = null
165
+ this.layers = []
166
+ this.axisRegistry = null
167
+ this.colorAxisRegistry = null
168
+ this.filterAxisRegistry = null
169
+ this._renderCallbacks = new Set()
170
+ this._zoomEndCallbacks = new Set()
171
+ this._dirty = false
172
+ 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
177
+
178
+ // Auto-managed Float widgets keyed by a config-derived tag string.
179
+ // Covers 1D colorbars, 2D colorbars, and filterbars in a single unified Map.
180
+ this._floats = new Map()
181
+
182
+ this._setupResizeObserver()
183
+ }
184
+
185
+ update({ config, data } = {}) {
186
+ const previousConfig = this.currentConfig
187
+ const previousData = this.currentData
188
+
189
+ try {
190
+ if (config !== undefined) {
191
+ this.currentConfig = config
192
+ }
193
+ if (data !== undefined) {
194
+ this.currentData = data
195
+ }
196
+
197
+ if (!this.currentConfig || !this.currentData) {
198
+ return
199
+ }
200
+
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
205
+
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
+ }
212
+
213
+ this.canvas.width = width
214
+ this.canvas.height = height
215
+ this.svg.attr('width', width).attr('height', height)
216
+
217
+ this.width = width
218
+ this.height = height
219
+ this.plotWidth = plotWidth
220
+ this.plotHeight = plotHeight
221
+
222
+ if (this.regl) {
223
+ this.regl.destroy()
224
+ }
225
+
226
+ this.svg.selectAll('*').remove()
227
+
228
+ this._initialize()
229
+ this._syncFloats()
230
+ } catch (error) {
231
+ this.currentConfig = previousConfig
232
+ this.currentData = previousData
233
+ throw error
234
+ }
235
+ }
236
+
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)
264
+ }
265
+
266
+ getConfig() {
267
+ const axes = { ...(this.currentConfig?.axes ?? {}) }
268
+
269
+ if (this.axisRegistry) {
270
+ for (const axisId of AXES) {
271
+ const scale = this.axisRegistry.getScale(axisId)
272
+ if (scale) {
273
+ const [min, max] = scale.domain()
274
+ const qk = this.axisRegistry.axisQuantityKinds[axisId]
275
+ const qkDef = qk ? getAxisQuantityKind(qk) : {}
276
+ axes[axisId] = { ...qkDef, ...(axes[axisId] ?? {}), min, max }
277
+ }
278
+ }
279
+ }
280
+
281
+ if (this.colorAxisRegistry) {
282
+ for (const quantityKind of this.colorAxisRegistry.getQuantityKinds()) {
283
+ const range = this.colorAxisRegistry.getRange(quantityKind)
284
+ const qkDef = getAxisQuantityKind(quantityKind)
285
+ const existing = axes[quantityKind] ?? {}
286
+ axes[quantityKind] = {
287
+ colorbar: "none",
288
+ ...qkDef,
289
+ ...existing,
290
+ ...(range ? { min: range[0], max: range[1] } : {}),
291
+ }
292
+ }
293
+ }
294
+
295
+ if (this.filterAxisRegistry) {
296
+ for (const quantityKind of this.filterAxisRegistry.getQuantityKinds()) {
297
+ const range = this.filterAxisRegistry.getRange(quantityKind)
298
+ const qkDef = getAxisQuantityKind(quantityKind)
299
+ const existing = axes[quantityKind] ?? {}
300
+ axes[quantityKind] = {
301
+ filterbar: "none",
302
+ ...qkDef,
303
+ ...existing,
304
+ ...(range && range.min !== null ? { min: range.min } : {}),
305
+ ...(range && range.max !== null ? { max: range.max } : {})
306
+ }
307
+ }
308
+ }
309
+
310
+ return { colorbars: [], ...this.currentConfig, axes}
311
+ }
312
+
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
+ })
330
+
331
+ this.layers = []
332
+
333
+ AXES.forEach(a => this.svg.append("g").attr("class", a))
334
+
335
+ this.axisRegistry = new AxisRegistry(this.plotWidth, this.plotHeight)
336
+ this.colorAxisRegistry = new ColorAxisRegistry()
337
+ this.filterAxisRegistry = new FilterAxisRegistry()
338
+
339
+ this._processLayers(layers, this.currentData)
340
+ this._setDomains(axes)
341
+
342
+ // Apply colorscale overrides from top-level colorbars entries. These override any
343
+ // per-axis colorscale from config.axes or quantity kind registry. Applying after
344
+ // _setDomains ensures they take effect last. For 2D colorbars both axes receive the
345
+ // same colorscale name, which resolves to a negative index in the shader, triggering
346
+ // the true-2D colorscale path in map_color_s_2d.
347
+ for (const entry of colorbars) {
348
+ if (!entry.colorscale || entry.colorscale == "none") continue
349
+ console.log("FROM colorbars");
350
+ if (entry.xAxis) this.colorAxisRegistry.ensureColorAxis(entry.xAxis, entry.colorscale)
351
+ if (entry.yAxis) this.colorAxisRegistry.ensureColorAxis(entry.yAxis, entry.colorscale)
352
+ }
353
+
354
+ new ZoomController(this)
355
+ this.render()
356
+ }
357
+
358
+ _setupResizeObserver() {
359
+ if (typeof ResizeObserver !== 'undefined') {
360
+ this.resizeObserver = new ResizeObserver(() => {
361
+ // Defer to next animation frame so the ResizeObserver callback exits
362
+ // before any DOM/layout changes happen, avoiding the "loop completed
363
+ // with undelivered notifications" browser error.
364
+ requestAnimationFrame(() => this.forceUpdate())
365
+ })
366
+ this.resizeObserver.observe(this.container)
367
+ } else {
368
+ this._resizeHandler = () => this.forceUpdate()
369
+ window.addEventListener('resize', this._resizeHandler)
370
+ }
371
+ }
372
+
373
+ // Returns the quantity kind for any axis ID (spatial or color axis).
374
+ // For color axes, the axis ID IS the quantity kind.
375
+ getAxisQuantityKind(axisId) {
376
+ if (AXES.includes(axisId)) {
377
+ return this.axisRegistry ? this.axisRegistry.axisQuantityKinds[axisId] : null
378
+ }
379
+ return axisId
380
+ }
381
+
382
+ // Unified domain getter for spatial, color, and filter axes.
383
+ getAxisDomain(axisId) {
384
+ if (AXES.includes(axisId)) {
385
+ const scale = this.axisRegistry?.getScale(axisId)
386
+ return scale ? scale.domain() : null
387
+ }
388
+ if (this.colorAxisRegistry?.hasAxis(axisId)) {
389
+ return this.colorAxisRegistry.getRange(axisId)
390
+ }
391
+ const filterRange = this.filterAxisRegistry?.getRange(axisId)
392
+ if (filterRange) return [filterRange.min, filterRange.max]
393
+ return null
394
+ }
395
+
396
+ // Unified domain setter for spatial, color, and filter axes.
397
+ setAxisDomain(axisId, domain) {
398
+ if (AXES.includes(axisId)) {
399
+ const scale = this.axisRegistry?.getScale(axisId)
400
+ if (scale) scale.domain(domain)
401
+ } else if (this.colorAxisRegistry?.hasAxis(axisId)) {
402
+ this.colorAxisRegistry.setRange(axisId, domain[0], domain[1])
403
+ } else if (this.filterAxisRegistry?.hasAxis(axisId)) {
404
+ this.filterAxisRegistry.setRange(axisId, domain[0], domain[1])
405
+ }
406
+ }
407
+
408
+ _syncFloats() {
409
+ const config = this.currentConfig ?? {}
410
+ const axes = config.axes ?? {}
411
+ const colorbarsConfig = config.colorbars ?? []
412
+
413
+ // Build a map from tag → { factoryDef, opts, y } for every float that should exist.
414
+ // Tags encode the full config so changing any relevant field destroys and recreates the float.
415
+ // Using tags rather than axis names means orientation changes cause clean destroy+recreate
416
+ // with no separate state to compare.
417
+ const desired = new Map()
418
+
419
+ // 1D colorbars declared inline on axes: axes[qk].colorbar = "horizontal"|"vertical"
420
+ for (const [axisName, axisConfig] of Object.entries(axes)) {
421
+ if (AXES.includes(axisName)) continue
422
+ const cb = axisConfig.colorbar
423
+ if (cb === "vertical" || cb === "horizontal") {
424
+ const tag = `colorbar:${axisName}:${cb}`
425
+ const factoryDef = Plot._floatFactories.get('colorbar')
426
+ if (factoryDef) desired.set(tag, { factoryDef, opts: { axisName, orientation: cb }, y: 10 })
427
+ }
428
+ // Filterbars declared inline on axes: axes[qk].filterbar = "horizontal"|"vertical"
429
+ if (this.filterAxisRegistry?.hasAxis(axisName)) {
430
+ const fb = axisConfig.filterbar
431
+ if (fb === "vertical" || fb === "horizontal") {
432
+ const tag = `filterbar:${axisName}:${fb}`
433
+ const factoryDef = Plot._floatFactories.get('filterbar')
434
+ if (factoryDef) desired.set(tag, { factoryDef, opts: { axisName, orientation: fb }, y: 100 })
435
+ }
436
+ }
437
+ }
438
+
439
+ // Top-level colorbars array: 1D or 2D depending on which axes are specified.
440
+ for (const entry of colorbarsConfig) {
441
+ const { xAxis, yAxis } = entry
442
+ if (xAxis && yAxis) {
443
+ // 2D colorbar
444
+ const tag = `colorbar2d:${xAxis}:${yAxis}`
445
+ const factoryDef = Plot._floatFactories.get('colorbar2d')
446
+ if (factoryDef) desired.set(tag, { factoryDef, opts: { xAxis, yAxis }, y: 10 })
447
+ } else if (xAxis) {
448
+ // 1D horizontal colorbar from colorbars array
449
+ const tag = `colorbar:${xAxis}:horizontal`
450
+ const factoryDef = Plot._floatFactories.get('colorbar')
451
+ if (factoryDef) desired.set(tag, { factoryDef, opts: { axisName: xAxis, orientation: 'horizontal' }, y: 10 })
452
+ } else if (yAxis) {
453
+ // 1D vertical colorbar from colorbars array
454
+ const tag = `colorbar:${yAxis}:vertical`
455
+ const factoryDef = Plot._floatFactories.get('colorbar')
456
+ if (factoryDef) desired.set(tag, { factoryDef, opts: { axisName: yAxis, orientation: 'vertical' }, y: 10 })
457
+ }
458
+ }
459
+
460
+ // Destroy floats whose tag is no longer in desired
461
+ for (const [tag, float] of this._floats) {
462
+ if (!desired.has(tag)) {
463
+ float.destroy()
464
+ this._floats.delete(tag)
465
+ }
466
+ }
467
+
468
+ // Create floats for new tags
469
+ for (const [tag, { factoryDef, opts, y }] of desired) {
470
+ if (!this._floats.has(tag)) {
471
+ const size = factoryDef.defaultSize(opts)
472
+ this._floats.set(tag, new Float(
473
+ this,
474
+ (container) => factoryDef.factory(this, container, opts),
475
+ { y, ...size }
476
+ ))
477
+ }
478
+ }
479
+ }
480
+
481
+ destroy() {
482
+ for (const float of this._floats.values()) {
483
+ float.destroy()
484
+ }
485
+ this._floats.clear()
486
+
487
+ // Clear all axis listeners so linked axes stop trying to update this plot
488
+ for (const axis of this._axisCache.values()) {
489
+ axis._listeners.clear()
490
+ }
491
+
492
+ if (this.resizeObserver) {
493
+ this.resizeObserver.disconnect()
494
+ } else if (this._resizeHandler) {
495
+ window.removeEventListener('resize', this._resizeHandler)
496
+ }
497
+
498
+ if (this._rafId !== null) {
499
+ cancelAnimationFrame(this._rafId)
500
+ this._rafId = null
501
+ }
502
+
503
+ if (this.regl) {
504
+ this.regl.destroy()
505
+ }
506
+
507
+ this._renderCallbacks.clear()
508
+ this.canvas.remove()
509
+ this.svg.remove()
510
+ }
511
+
512
+ _processLayers(layersConfig, data) {
513
+ for (let configLayerIndex = 0; configLayerIndex < layersConfig.length; configLayerIndex++) {
514
+ const layerSpec = layersConfig[configLayerIndex]
515
+ const entries = Object.entries(layerSpec)
516
+ if (entries.length !== 1) {
517
+ throw new Error("Each layer specification must have exactly one layer type key")
518
+ }
519
+
520
+ const [layerTypeName, parameters] = entries[0]
521
+ const layerType = getLayerType(layerTypeName)
522
+
523
+ // Resolve axis config once per layer spec for registration (independent of draw call count).
524
+ const ac = layerType.resolveAxisConfig(parameters, data)
525
+ const axesConfig = this.currentConfig?.axes ?? {}
526
+
527
+ // Register spatial axes (null means no axis for that direction).
528
+ // Pass any scale override from config (e.g. "log") so the D3 scale is created correctly.
529
+ if (ac.xAxis) this.axisRegistry.ensureAxis(ac.xAxis, ac.xAxisQuantityKind, axesConfig[ac.xAxis]?.scale ?? axesConfig[ac.xAxisQuantityKind]?.scale)
530
+ if (ac.yAxis) this.axisRegistry.ensureAxis(ac.yAxis, ac.yAxisQuantityKind, axesConfig[ac.yAxis]?.scale ?? axesConfig[ac.yAxisQuantityKind]?.scale)
531
+
532
+ // Register color axes (colorscale comes from config or quantity kind registry, not from here)
533
+ for (const quantityKind of ac.colorAxisQuantityKinds) {
534
+ this.colorAxisRegistry.ensureColorAxis(quantityKind)
535
+ }
536
+
537
+ // Register filter axes
538
+ for (const quantityKind of ac.filterAxisQuantityKinds) {
539
+ this.filterAxisRegistry.ensureFilterAxis(quantityKind)
540
+ }
541
+
542
+ // Create one draw command per GPU config returned by the layer type.
543
+ for (const layer of layerType.createLayer(parameters, data)) {
544
+ layer.configLayerIndex = configLayerIndex
545
+ layer.draw = layer.type.createDrawCommand(this.regl, layer, this)
546
+ this.layers.push(layer)
547
+ }
548
+ }
549
+ }
550
+
551
+ _setDomains(axesOverrides) {
552
+ this.axisRegistry.applyAutoDomainsFromLayers(this.layers, axesOverrides)
553
+ this.colorAxisRegistry.applyAutoDomainsFromLayers(this.layers, axesOverrides)
554
+ this.filterAxisRegistry.applyAutoDomainsFromLayers(this.layers, axesOverrides)
555
+ }
556
+
557
+ // Thin wrapper so subclasses (e.g. Colorbar) can override scale-type lookup
558
+ // for axes they proxy from another plot. Implementation delegates to the
559
+ // module-level getScaleTypeFloat which reads from axesConfig directly.
560
+ _getScaleTypeFloat(quantityKind) {
561
+ return getScaleTypeFloat(quantityKind, this.currentConfig?.axes)
562
+ }
563
+
564
+ static schema(data) {
565
+ return buildPlotSchema(data)
566
+ }
567
+
568
+ scheduleRender() {
569
+ 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
+ })
578
+ }
579
+ }
580
+
581
+ render() {
582
+ this._dirty = false
583
+ this.regl.clear({ color: [1,1,1,1], depth:1 })
584
+ const viewport = {
585
+ x: this.margin.left,
586
+ y: this.margin.bottom,
587
+ width: this.plotWidth,
588
+ height: this.plotHeight
589
+ }
590
+ const axesConfig = this.currentConfig?.axes
591
+
592
+ for (const layer of this.layers) {
593
+ if (layer._axisUpdaters) {
594
+ for (const updater of layer._axisUpdaters) updater.refreshIfNeeded(this)
595
+ }
596
+
597
+ const xIsLog = layer.xAxis ? this.axisRegistry.isLogScale(layer.xAxis) : false
598
+ const yIsLog = layer.yAxis ? this.axisRegistry.isLogScale(layer.yAxis) : false
599
+ const props = {
600
+ xDomain: layer.xAxis ? (this.axisRegistry.getScale(layer.xAxis)?.domain() ?? [0, 1]) : [0, 1],
601
+ yDomain: layer.yAxis ? (this.axisRegistry.getScale(layer.yAxis)?.domain() ?? [0, 1]) : [0, 1],
602
+ xScaleType: xIsLog ? 1.0 : 0.0,
603
+ yScaleType: yIsLog ? 1.0 : 0.0,
604
+ viewport: viewport,
605
+ count: layer.vertexCount ?? Object.values(layer.attributes).find(v => v instanceof Float32Array)?.length ?? 0,
606
+ u_pickingMode: 0.0,
607
+ u_pickLayerIndex: 0.0,
608
+ }
609
+
610
+ if (layer.instanceCount !== null) {
611
+ props.instances = layer.instanceCount
612
+ }
613
+
614
+ for (const qk of layer.colorAxes) {
615
+ props[`colorscale_${qk}`] = this.colorAxisRegistry.getColorscaleIndex(qk)
616
+ const range = this.colorAxisRegistry.getRange(qk)
617
+ props[`color_range_${qk}`] = range ?? [0, 1]
618
+ props[`color_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
619
+ }
620
+
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)
624
+ }
625
+
626
+ layer.draw(props)
627
+ }
628
+
629
+ for (const axisId of AXES) this._getAxis(axisId).render()
630
+ for (const cb of this._renderCallbacks) cb()
631
+ }
632
+
633
+ lookup(x, y) {
634
+ const result = {}
635
+ if (!this.axisRegistry) return result
636
+ const plotX = x - this.margin.left
637
+ const plotY = y - this.margin.top
638
+ for (const axisId of AXES) {
639
+ const scale = this.axisRegistry.getScale(axisId)
640
+ if (!scale) continue
641
+ const qk = this.axisRegistry.axisQuantityKinds[axisId]
642
+ const value = axisId.includes('y') ? scale.invert(plotY) : scale.invert(plotX)
643
+ result[axisId] = value
644
+ if (qk) result[qk] = value
645
+ }
646
+ return result
647
+ }
648
+
649
+ onZoomEnd(cb) {
650
+ this._zoomEndCallbacks.add(cb)
651
+ return { remove: () => this._zoomEndCallbacks.delete(cb) }
652
+ }
653
+
654
+ on(eventType, callback) {
655
+ const handler = (e) => {
656
+ if (!this.container.contains(e.target)) return
657
+ const rect = this.container.getBoundingClientRect()
658
+ const x = e.clientX - rect.left
659
+ const y = e.clientY - rect.top
660
+ callback(e, this.lookup(x, y))
661
+ }
662
+ window.addEventListener(eventType, handler, { capture: true })
663
+ return { remove: () => window.removeEventListener(eventType, handler, { capture: true }) }
664
+ }
665
+
666
+ pick(x, y) {
667
+ if (!this.regl || !this.layers.length) return null
668
+
669
+ const fbo = this.regl.framebuffer({
670
+ width: this.width, height: this.height,
671
+ colorFormat: 'rgba', colorType: 'uint8', depth: false,
672
+ })
673
+
674
+ const glX = Math.round(x)
675
+ const glY = this.height - Math.round(y) - 1
676
+ const axesConfig = this.currentConfig?.axes
677
+
678
+ let result = null
679
+ this.regl({ framebuffer: fbo })(() => {
680
+ this.regl.clear({ color: [0, 0, 0, 0] })
681
+ const viewport = {
682
+ x: this.margin.left, y: this.margin.bottom,
683
+ width: this.plotWidth, height: this.plotHeight
684
+ }
685
+ for (let i = 0; i < this.layers.length; i++) {
686
+ const layer = this.layers[i]
687
+ if (layer._axisUpdaters) {
688
+ for (const updater of layer._axisUpdaters) updater.refreshIfNeeded(this)
689
+ }
690
+
691
+ const xIsLog = layer.xAxis ? this.axisRegistry.isLogScale(layer.xAxis) : false
692
+ const yIsLog = layer.yAxis ? this.axisRegistry.isLogScale(layer.yAxis) : false
693
+ const props = {
694
+ xDomain: layer.xAxis ? (this.axisRegistry.getScale(layer.xAxis)?.domain() ?? [0, 1]) : [0, 1],
695
+ yDomain: layer.yAxis ? (this.axisRegistry.getScale(layer.yAxis)?.domain() ?? [0, 1]) : [0, 1],
696
+ xScaleType: xIsLog ? 1.0 : 0.0,
697
+ yScaleType: yIsLog ? 1.0 : 0.0,
698
+ viewport,
699
+ count: layer.vertexCount ?? Object.values(layer.attributes).find(v => v instanceof Float32Array)?.length ?? 0,
700
+ u_pickingMode: 1.0,
701
+ u_pickLayerIndex: i,
702
+ }
703
+ if (layer.instanceCount !== null) props.instances = layer.instanceCount
704
+ for (const qk of layer.colorAxes) {
705
+ props[`colorscale_${qk}`] = this.colorAxisRegistry.getColorscaleIndex(qk)
706
+ const range = this.colorAxisRegistry.getRange(qk)
707
+ props[`color_range_${qk}`] = range ?? [0, 1]
708
+ props[`color_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
709
+ }
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)
713
+ }
714
+ layer.draw(props)
715
+ }
716
+ var pixels;
717
+ try {
718
+ pixels = this.regl.read({ x: glX, y: glY, width: 1, height: 1 })
719
+ } catch (e) {
720
+ pixels = [0];
721
+ }
722
+ if (pixels[0] === 0) {
723
+ result = null
724
+ } else {
725
+ const layerIndex = pixels[0] - 1
726
+ const dataIndex = (pixels[1] << 16) | (pixels[2] << 8) | pixels[3]
727
+ const layer = this.layers[layerIndex]
728
+ result = { layerIndex, configLayerIndex: layer.configLayerIndex, dataIndex, layer }
729
+ }
730
+ })
731
+
732
+ fbo.destroy()
733
+ return result
734
+ }
735
+ }