gladly-plot 0.0.1

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.
package/src/Plot.js ADDED
@@ -0,0 +1,976 @@
1
+ import reglInit from "regl"
2
+ import * as d3 from "d3-selection"
3
+ import { scaleLinear } from "d3-scale"
4
+ import { axisBottom, axisTop, axisLeft, axisRight } from "d3-axis"
5
+ import { zoom, zoomIdentity } from "d3-zoom"
6
+ import { AXES, AxisRegistry } from "./AxisRegistry.js"
7
+ import { Axis } from "./Axis.js"
8
+ import { ColorAxisRegistry } from "./ColorAxisRegistry.js"
9
+ import { FilterAxisRegistry } from "./FilterAxisRegistry.js"
10
+ import { getLayerType, getRegisteredLayerTypes } from "./LayerTypeRegistry.js"
11
+ import { getAxisQuantityKind } from "./AxisQuantityKindRegistry.js"
12
+ import { getRegisteredColorscales } from "./ColorscaleRegistry.js"
13
+
14
+ function formatTick(v) {
15
+ if (v === 0) return "0"
16
+ const abs = Math.abs(v)
17
+ if (abs >= 10000 || abs < 0.01) {
18
+ return v.toExponential(2)
19
+ }
20
+ const s = v.toPrecision(4)
21
+ if (s.includes('.') && !s.includes('e')) {
22
+ return s.replace(/\.?0+$/, '')
23
+ }
24
+ return s
25
+ }
26
+
27
+ export class Plot {
28
+ static _FloatClass = null
29
+ static _FilterbarFloatClass = null
30
+ constructor(container, { margin } = {}) {
31
+ this.container = container
32
+ this.margin = margin ?? { top: 60, right: 60, bottom: 60, left: 60 }
33
+
34
+ // Create canvas element
35
+ this.canvas = document.createElement('canvas')
36
+ this.canvas.style.display = 'block'
37
+ this.canvas.style.position = 'absolute'
38
+ this.canvas.style.top = '0'
39
+ this.canvas.style.left = '0'
40
+ this.canvas.style.zIndex = '1'
41
+ container.appendChild(this.canvas)
42
+
43
+ // Create SVG element
44
+ this.svg = d3.select(container)
45
+ .append('svg')
46
+ .style('position', 'absolute')
47
+ .style('top', '0')
48
+ .style('left', '0')
49
+ .style('z-index', '2')
50
+ .style('user-select', 'none')
51
+
52
+ this.currentConfig = null
53
+ this.currentData = null
54
+ this.regl = null
55
+ this.layers = []
56
+ this.axisRegistry = null
57
+ this.colorAxisRegistry = null
58
+ this.filterAxisRegistry = null
59
+ this._renderCallbacks = new Set()
60
+ this._dirty = false
61
+ this._rafId = null
62
+
63
+ // Stable Axis instances keyed by axis name — persist across update() calls
64
+ this._axisCache = new Map()
65
+ this._axesProxy = null
66
+
67
+ // Auto-managed Float colorbars keyed by color axis name
68
+ this._floats = new Map()
69
+ // Auto-managed FilterbarFloat widgets keyed by filter axis name
70
+ this._filterbarFloats = new Map()
71
+
72
+ this._setupResizeObserver()
73
+ }
74
+
75
+ update({ config, data } = {}) {
76
+ const previousConfig = this.currentConfig
77
+ const previousData = this.currentData
78
+
79
+ try {
80
+ if (config !== undefined) {
81
+ this.currentConfig = config
82
+ }
83
+ if (data !== undefined) {
84
+ this.currentData = data
85
+ }
86
+
87
+ if (!this.currentConfig || !this.currentData) {
88
+ return
89
+ }
90
+
91
+ const width = this.container.clientWidth
92
+ const height = this.container.clientHeight
93
+
94
+ // Container is hidden or not yet laid out (e.g. inside display:none tab).
95
+ // Store config/data and return; ResizeObserver will call forceUpdate() once
96
+ // the container gets real dimensions.
97
+ if (width === 0 || height === 0) {
98
+ return
99
+ }
100
+
101
+ this.canvas.width = width
102
+ this.canvas.height = height
103
+ this.svg.attr('width', width).attr('height', height)
104
+
105
+ this.width = width
106
+ this.height = height
107
+ this.plotWidth = width - this.margin.left - this.margin.right
108
+ this.plotHeight = height - this.margin.top - this.margin.bottom
109
+
110
+ if (this.regl) {
111
+ this.regl.destroy()
112
+ }
113
+
114
+ this.svg.selectAll('*').remove()
115
+
116
+ this._initialize()
117
+ this._syncFloats()
118
+ } catch (error) {
119
+ this.currentConfig = previousConfig
120
+ this.currentData = previousData
121
+ throw error
122
+ }
123
+ }
124
+
125
+ forceUpdate() {
126
+ this.update({})
127
+ }
128
+
129
+ /**
130
+ * Returns a stable Axis instance for the given axis name.
131
+ * Works for spatial axes (e.g. "xaxis_bottom") and quantity-kind axes (color/filter).
132
+ * The same instance is returned across plot.update() calls, so links survive updates.
133
+ *
134
+ * Usage: plot.axes.xaxis_bottom, plot.axes["velocity_ms"], etc.
135
+ */
136
+ get axes() {
137
+ if (!this._axesProxy) {
138
+ this._axesProxy = new Proxy(this._axisCache, {
139
+ get: (cache, name) => {
140
+ if (typeof name !== 'string') return undefined
141
+ if (!cache.has(name)) cache.set(name, new Axis(this, name))
142
+ return cache.get(name)
143
+ }
144
+ })
145
+ }
146
+ return this._axesProxy
147
+ }
148
+
149
+ _getAxis(name) {
150
+ if (!this._axisCache.has(name)) this._axisCache.set(name, new Axis(this, name))
151
+ return this._axisCache.get(name)
152
+ }
153
+
154
+ getConfig() {
155
+ const axes = { ...(this.currentConfig?.axes ?? {}) }
156
+
157
+ if (this.axisRegistry) {
158
+ for (const axisId of AXES) {
159
+ const scale = this.axisRegistry.getScale(axisId)
160
+ if (scale) {
161
+ const [min, max] = scale.domain()
162
+ const qk = this.axisRegistry.axisQuantityKinds[axisId]
163
+ const qkDef = qk ? getAxisQuantityKind(qk) : {}
164
+ axes[axisId] = { ...qkDef, ...(axes[axisId] ?? {}), min, max }
165
+ }
166
+ }
167
+ }
168
+
169
+ if (this.colorAxisRegistry) {
170
+ for (const quantityKind of this.colorAxisRegistry.getQuantityKinds()) {
171
+ const range = this.colorAxisRegistry.getRange(quantityKind)
172
+ const qkDef = getAxisQuantityKind(quantityKind)
173
+ const existing = axes[quantityKind] ?? {}
174
+ axes[quantityKind] = {
175
+ colorbar: "none",
176
+ ...qkDef,
177
+ ...existing,
178
+ ...(range ? { min: range[0], max: range[1] } : {}),
179
+ }
180
+ }
181
+ }
182
+
183
+ if (this.filterAxisRegistry) {
184
+ for (const quantityKind of this.filterAxisRegistry.getQuantityKinds()) {
185
+ const range = this.filterAxisRegistry.getRange(quantityKind)
186
+ const qkDef = getAxisQuantityKind(quantityKind)
187
+ const existing = axes[quantityKind] ?? {}
188
+ axes[quantityKind] = {
189
+ filterbar: "none",
190
+ ...qkDef,
191
+ ...existing,
192
+ ...(range && range.min !== null ? { min: range.min } : {}),
193
+ ...(range && range.max !== null ? { max: range.max } : {})
194
+ }
195
+ }
196
+ }
197
+
198
+ return { ...this.currentConfig, axes }
199
+ }
200
+
201
+ _initialize() {
202
+ const { layers = [], axes = {} } = this.currentConfig
203
+
204
+ this.regl = reglInit({ canvas: this.canvas, extensions: ['ANGLE_instanced_arrays'] })
205
+
206
+ this.layers = []
207
+
208
+ AXES.forEach(a => this.svg.append("g").attr("class", a))
209
+
210
+ this.axisRegistry = new AxisRegistry(this.plotWidth, this.plotHeight)
211
+ this.colorAxisRegistry = new ColorAxisRegistry()
212
+ this.filterAxisRegistry = new FilterAxisRegistry()
213
+
214
+ this._processLayers(layers, this.currentData)
215
+ this._setDomains(axes)
216
+
217
+ this.initZoom()
218
+ this.render()
219
+ }
220
+
221
+ _setupResizeObserver() {
222
+ if (typeof ResizeObserver !== 'undefined') {
223
+ this.resizeObserver = new ResizeObserver(() => {
224
+ this.forceUpdate()
225
+ })
226
+ this.resizeObserver.observe(this.container)
227
+ } else {
228
+ this._resizeHandler = () => this.forceUpdate()
229
+ window.addEventListener('resize', this._resizeHandler)
230
+ }
231
+ }
232
+
233
+ // Returns the quantity kind for any axis ID (spatial or color axis).
234
+ // For color axes, the axis ID IS the quantity kind.
235
+ getAxisQuantityKind(axisId) {
236
+ if (AXES.includes(axisId)) {
237
+ return this.axisRegistry ? this.axisRegistry.axisQuantityKinds[axisId] : null
238
+ }
239
+ return axisId
240
+ }
241
+
242
+ // Unified domain getter for spatial, color, and filter axes.
243
+ getAxisDomain(axisId) {
244
+ if (AXES.includes(axisId)) {
245
+ const scale = this.axisRegistry?.getScale(axisId)
246
+ return scale ? scale.domain() : null
247
+ }
248
+ if (this.colorAxisRegistry?.hasAxis(axisId)) {
249
+ return this.colorAxisRegistry.getRange(axisId)
250
+ }
251
+ const filterRange = this.filterAxisRegistry?.getRange(axisId)
252
+ if (filterRange) return [filterRange.min, filterRange.max]
253
+ return null
254
+ }
255
+
256
+ // Unified domain setter for spatial, color, and filter axes.
257
+ setAxisDomain(axisId, domain) {
258
+ if (AXES.includes(axisId)) {
259
+ const scale = this.axisRegistry?.getScale(axisId)
260
+ if (scale) scale.domain(domain)
261
+ } else if (this.colorAxisRegistry?.hasAxis(axisId)) {
262
+ this.colorAxisRegistry.setRange(axisId, domain[0], domain[1])
263
+ } else if (this.filterAxisRegistry?.hasAxis(axisId)) {
264
+ this.filterAxisRegistry.setRange(axisId, domain[0], domain[1])
265
+ }
266
+ }
267
+
268
+ _syncFloats() {
269
+ const axes = this.currentConfig?.axes ?? {}
270
+
271
+ // --- Color axis floats ---
272
+ const desiredColor = new Map()
273
+ for (const [axisName, axisConfig] of Object.entries(axes)) {
274
+ if (AXES.includes(axisName)) continue // skip spatial axes
275
+ const cb = axisConfig.colorbar
276
+ if (cb === "vertical" || cb === "horizontal") {
277
+ desiredColor.set(axisName, cb)
278
+ }
279
+ }
280
+
281
+ for (const [axisName, float] of this._floats) {
282
+ const wantedOrientation = desiredColor.get(axisName)
283
+ if (wantedOrientation === undefined || wantedOrientation !== float._colorbar._orientation) {
284
+ float.destroy()
285
+ this._floats.delete(axisName)
286
+ }
287
+ }
288
+
289
+ for (const [axisName, orientation] of desiredColor) {
290
+ if (!this._floats.has(axisName)) {
291
+ this._floats.set(axisName, new Plot._FloatClass(this, axisName, { orientation }))
292
+ }
293
+ }
294
+
295
+ // --- Filter axis floats ---
296
+ const desiredFilter = new Map()
297
+ for (const [axisName, axisConfig] of Object.entries(axes)) {
298
+ if (AXES.includes(axisName)) continue
299
+ if (!this.filterAxisRegistry?.hasAxis(axisName)) continue
300
+ const fb = axisConfig.filterbar
301
+ if (fb === "vertical" || fb === "horizontal") {
302
+ desiredFilter.set(axisName, fb)
303
+ }
304
+ }
305
+
306
+ for (const [axisName, float] of this._filterbarFloats) {
307
+ const wantedOrientation = desiredFilter.get(axisName)
308
+ if (wantedOrientation === undefined || wantedOrientation !== float._filterbar._orientation) {
309
+ float.destroy()
310
+ this._filterbarFloats.delete(axisName)
311
+ }
312
+ }
313
+
314
+ for (const [axisName, orientation] of desiredFilter) {
315
+ if (!this._filterbarFloats.has(axisName)) {
316
+ this._filterbarFloats.set(axisName, new Plot._FilterbarFloatClass(this, axisName, { orientation }))
317
+ }
318
+ }
319
+ }
320
+
321
+ destroy() {
322
+ for (const float of this._floats.values()) {
323
+ float.destroy()
324
+ }
325
+ this._floats.clear()
326
+
327
+ for (const float of this._filterbarFloats.values()) {
328
+ float.destroy()
329
+ }
330
+ this._filterbarFloats.clear()
331
+
332
+ // Clear all axis listeners so linked axes stop trying to update this plot
333
+ for (const axis of this._axisCache.values()) {
334
+ axis._listeners.clear()
335
+ }
336
+
337
+ if (this.resizeObserver) {
338
+ this.resizeObserver.disconnect()
339
+ } else if (this._resizeHandler) {
340
+ window.removeEventListener('resize', this._resizeHandler)
341
+ }
342
+
343
+ if (this._rafId !== null) {
344
+ cancelAnimationFrame(this._rafId)
345
+ this._rafId = null
346
+ }
347
+
348
+ if (this.regl) {
349
+ this.regl.destroy()
350
+ }
351
+
352
+ this._renderCallbacks.clear()
353
+ this.canvas.remove()
354
+ this.svg.remove()
355
+ }
356
+
357
+ _processLayers(layersConfig, data) {
358
+ for (const layerSpec of layersConfig) {
359
+ const entries = Object.entries(layerSpec)
360
+ if (entries.length !== 1) {
361
+ throw new Error("Each layer specification must have exactly one layer type key")
362
+ }
363
+
364
+ const [layerTypeName, parameters] = entries[0]
365
+ const layerType = getLayerType(layerTypeName)
366
+
367
+ // Resolve axis config once per layer spec for registration (independent of draw call count).
368
+ const ac = layerType.resolveAxisConfig(parameters, data)
369
+ const axesConfig = this.currentConfig?.axes ?? {}
370
+
371
+ // Register spatial axes (null means no axis for that direction).
372
+ // Pass any scale override from config (e.g. "log") so the D3 scale is created correctly.
373
+ if (ac.xAxis) this.axisRegistry.ensureAxis(ac.xAxis, ac.xAxisQuantityKind, axesConfig[ac.xAxis]?.scale ?? axesConfig[ac.xAxisQuantityKind]?.scale)
374
+ if (ac.yAxis) this.axisRegistry.ensureAxis(ac.yAxis, ac.yAxisQuantityKind, axesConfig[ac.yAxis]?.scale ?? axesConfig[ac.yAxisQuantityKind]?.scale)
375
+
376
+ // Register color axes (colorscale comes from config or quantity kind registry, not from here)
377
+ for (const quantityKind of ac.colorAxisQuantityKinds) {
378
+ this.colorAxisRegistry.ensureColorAxis(quantityKind)
379
+ }
380
+
381
+ // Register filter axes
382
+ for (const quantityKind of ac.filterAxisQuantityKinds) {
383
+ this.filterAxisRegistry.ensureFilterAxis(quantityKind)
384
+ }
385
+
386
+ // Create one draw command per GPU config returned by the layer type.
387
+ for (const layer of layerType.createLayer(parameters, data)) {
388
+ layer.draw = layer.type.createDrawCommand(this.regl, layer)
389
+ this.layers.push(layer)
390
+ }
391
+ }
392
+ }
393
+
394
+ _setDomains(axesOverrides) {
395
+ // Auto-calculate spatial axis domains
396
+ const autoDomains = {}
397
+
398
+ for (const axis of AXES) {
399
+ const layersUsingAxis = this.layers.filter(l =>
400
+ l.xAxis === axis || l.yAxis === axis
401
+ )
402
+
403
+ if (layersUsingAxis.length === 0) continue
404
+
405
+ let min = Infinity
406
+ let max = -Infinity
407
+
408
+ for (const layer of layersUsingAxis) {
409
+ const isXAxis = layer.xAxis === axis
410
+ const qk = isXAxis ? layer.xAxisQuantityKind : layer.yAxisQuantityKind
411
+ if (layer.domains[qk] !== undefined) {
412
+ const [dMin, dMax] = layer.domains[qk]
413
+ if (dMin < min) min = dMin
414
+ if (dMax > max) max = dMax
415
+ } else {
416
+ const dataArray = isXAxis ? layer.attributes.x : layer.attributes.y
417
+ if (!dataArray) continue
418
+ for (let i = 0; i < dataArray.length; i++) {
419
+ const val = dataArray[i]
420
+ if (val < min) min = val
421
+ if (val > max) max = val
422
+ }
423
+ }
424
+ }
425
+
426
+ if (min !== Infinity) autoDomains[axis] = [min, max]
427
+ }
428
+
429
+ for (const axis of AXES) {
430
+ const scale = this.axisRegistry.getScale(axis)
431
+ if (scale) {
432
+ let domain
433
+ if (axesOverrides[axis]) {
434
+ const override = axesOverrides[axis]
435
+ domain = [override.min, override.max]
436
+ } else {
437
+ domain = autoDomains[axis]
438
+ }
439
+ if (domain) {
440
+ scale.domain(domain)
441
+ }
442
+ }
443
+ }
444
+
445
+ // Compute data extent for each filter axis and store it (used by Filterbar for display).
446
+ // Data comes from the attribute named by the quantity kind. If absent, auto-range is skipped.
447
+ // Also apply any range overrides from config; default is fully open bounds (no filtering).
448
+ for (const quantityKind of this.filterAxisRegistry.getQuantityKinds()) {
449
+ let extMin = Infinity, extMax = -Infinity
450
+ for (const layer of this.layers) {
451
+ for (const qk of layer.filterAxes) {
452
+ if (qk !== quantityKind) continue
453
+ if (layer.domains[qk] !== undefined) {
454
+ const [dMin, dMax] = layer.domains[qk]
455
+ if (dMin < extMin) extMin = dMin
456
+ if (dMax > extMax) extMax = dMax
457
+ } else {
458
+ const data = layer.attributes[qk]
459
+ if (!data) continue
460
+ for (let i = 0; i < data.length; i++) {
461
+ if (data[i] < extMin) extMin = data[i]
462
+ if (data[i] > extMax) extMax = data[i]
463
+ }
464
+ }
465
+ }
466
+ }
467
+ if (extMin !== Infinity) {
468
+ this.filterAxisRegistry.setDataExtent(quantityKind, extMin, extMax)
469
+ }
470
+
471
+ if (axesOverrides[quantityKind]) {
472
+ const override = axesOverrides[quantityKind]
473
+ const min = override.min !== undefined ? override.min : null
474
+ const max = override.max !== undefined ? override.max : null
475
+ this.filterAxisRegistry.setRange(quantityKind, min, max)
476
+ }
477
+ }
478
+
479
+ // Auto-calculate color axis domains.
480
+ // Data comes from the attribute named by the quantity kind. If absent, auto-range is skipped
481
+ // (e.g. ColorbarLayer, whose range is always synced externally from the target plot).
482
+ for (const quantityKind of this.colorAxisRegistry.getQuantityKinds()) {
483
+ let min = Infinity
484
+ let max = -Infinity
485
+
486
+ for (const layer of this.layers) {
487
+ for (const qk of layer.colorAxes) {
488
+ if (qk !== quantityKind) continue
489
+ // Use layer-declared domain if provided, otherwise scan the attribute array.
490
+ if (layer.domains[qk] !== undefined) {
491
+ const [dMin, dMax] = layer.domains[qk]
492
+ if (dMin < min) min = dMin
493
+ if (dMax > max) max = dMax
494
+ } else {
495
+ const data = layer.attributes[qk]
496
+ if (!data) continue
497
+ for (let i = 0; i < data.length; i++) {
498
+ if (data[i] < min) min = data[i]
499
+ if (data[i] > max) max = data[i]
500
+ }
501
+ }
502
+ }
503
+ }
504
+
505
+ if (min !== Infinity) {
506
+ const override = axesOverrides[quantityKind]
507
+ if (override?.colorscale) {
508
+ this.colorAxisRegistry.ensureColorAxis(quantityKind, override.colorscale)
509
+ }
510
+ // Config min/max override the auto-calculated values; absent means keep auto value.
511
+ this.colorAxisRegistry.setRange(quantityKind, override?.min ?? min, override?.max ?? max)
512
+ }
513
+ }
514
+
515
+ // Validate that log-scale axes have strictly positive domains/ranges.
516
+ for (const axis of AXES) {
517
+ if (!this.axisRegistry.isLogScale(axis)) continue
518
+ const [dMin, dMax] = this.axisRegistry.getScale(axis).domain()
519
+ if ((isFinite(dMin) && dMin <= 0) || (isFinite(dMax) && dMax <= 0)) {
520
+ throw new Error(
521
+ `Axis '${axis}' uses log scale but has non-positive domain [${dMin}, ${dMax}]. ` +
522
+ `All data values and min/max must be > 0 for log scale.`
523
+ )
524
+ }
525
+ }
526
+
527
+ for (const quantityKind of this.colorAxisRegistry.getQuantityKinds()) {
528
+ if (this._getScaleTypeFloat(quantityKind) <= 0.5) continue
529
+ const range = this.colorAxisRegistry.getRange(quantityKind)
530
+ if (!range) continue
531
+ if (range[0] <= 0 || range[1] <= 0) {
532
+ throw new Error(
533
+ `Color axis '${quantityKind}' uses log scale but has non-positive range [${range[0]}, ${range[1]}]. ` +
534
+ `All data values and min/max must be > 0 for log scale.`
535
+ )
536
+ }
537
+ }
538
+
539
+ for (const quantityKind of this.filterAxisRegistry.getQuantityKinds()) {
540
+ if (this._getScaleTypeFloat(quantityKind) <= 0.5) continue
541
+ const extent = this.filterAxisRegistry.getDataExtent(quantityKind)
542
+ if (extent && extent[0] <= 0) {
543
+ throw new Error(
544
+ `Filter axis '${quantityKind}' uses log scale but data minimum is ${extent[0]}. ` +
545
+ `All data values must be > 0 for log scale.`
546
+ )
547
+ }
548
+ const filterRange = this.filterAxisRegistry.getRange(quantityKind)
549
+ if (filterRange) {
550
+ if (filterRange.min !== null && filterRange.min <= 0) {
551
+ throw new Error(
552
+ `Filter axis '${quantityKind}' uses log scale but min is ${filterRange.min}. ` +
553
+ `min must be > 0 for log scale.`
554
+ )
555
+ }
556
+ if (filterRange.max !== null && filterRange.max <= 0) {
557
+ throw new Error(
558
+ `Filter axis '${quantityKind}' uses log scale but max is ${filterRange.max}. ` +
559
+ `max must be > 0 for log scale.`
560
+ )
561
+ }
562
+ }
563
+ }
564
+ }
565
+
566
+ static schema(data) {
567
+ const layerTypes = getRegisteredLayerTypes()
568
+
569
+ return {
570
+ $schema: "https://json-schema.org/draft/2020-12/schema",
571
+ type: "object",
572
+ properties: {
573
+ layers: {
574
+ type: "array",
575
+ items: {
576
+ type: "object",
577
+ oneOf: layerTypes.map(typeName => {
578
+ const layerType = getLayerType(typeName)
579
+ return {
580
+ title: typeName,
581
+ properties: {
582
+ [typeName]: layerType.schema(data)
583
+ },
584
+ required: [typeName],
585
+ additionalProperties: false
586
+ }
587
+ })
588
+ }
589
+ },
590
+ axes: {
591
+ type: "object",
592
+ properties: {
593
+ xaxis_bottom: {
594
+ type: "object",
595
+ properties: {
596
+ min: { type: "number" },
597
+ max: { type: "number" },
598
+ label: { type: "string" },
599
+ scale: { type: "string", enum: ["linear", "log"] }
600
+ }
601
+ },
602
+ xaxis_top: {
603
+ type: "object",
604
+ properties: {
605
+ min: { type: "number" },
606
+ max: { type: "number" },
607
+ label: { type: "string" },
608
+ scale: { type: "string", enum: ["linear", "log"] }
609
+ }
610
+ },
611
+ yaxis_left: {
612
+ type: "object",
613
+ properties: {
614
+ min: { type: "number" },
615
+ max: { type: "number" },
616
+ label: { type: "string" },
617
+ scale: { type: "string", enum: ["linear", "log"] }
618
+ }
619
+ },
620
+ yaxis_right: {
621
+ type: "object",
622
+ properties: {
623
+ min: { type: "number" },
624
+ max: { type: "number" },
625
+ label: { type: "string" },
626
+ scale: { type: "string", enum: ["linear", "log"] }
627
+ }
628
+ }
629
+ },
630
+ additionalProperties: {
631
+ // Color/filter/quantity-kind axes.
632
+ // All fields from the quantity kind registration are valid here and override the registration.
633
+ type: "object",
634
+ properties: {
635
+ min: { type: "number" },
636
+ max: { type: "number" },
637
+ label: { type: "string" },
638
+ scale: { type: "string", enum: ["linear", "log"] },
639
+ colorscale: {
640
+ type: "string",
641
+ enum: [...getRegisteredColorscales().keys()]
642
+ },
643
+ colorbar: {
644
+ type: "string",
645
+ enum: ["none", "vertical", "horizontal"]
646
+ },
647
+ filterbar: {
648
+ type: "string",
649
+ enum: ["none", "vertical", "horizontal"]
650
+ }
651
+ }
652
+ }
653
+ }
654
+ }
655
+ }
656
+ }
657
+
658
+ _tickCount(axisName) {
659
+ if (axisName.includes("y")) {
660
+ return Math.max(2, Math.floor(this.plotHeight / 27))
661
+ }
662
+ return Math.max(2, Math.floor(this.plotWidth / 40))
663
+ }
664
+
665
+ _makeAxis(axisConstructor, scale, axisName) {
666
+ const count = this._tickCount(axisName)
667
+ const gen = axisConstructor(scale).tickFormat(formatTick)
668
+ if (count <= 2) {
669
+ gen.tickValues(scale.domain())
670
+ } else {
671
+ gen.ticks(count)
672
+ }
673
+ return gen
674
+ }
675
+
676
+ renderAxes() {
677
+ if (this.axisRegistry.getScale("xaxis_bottom")) {
678
+ const scale = this.axisRegistry.getScale("xaxis_bottom")
679
+ const g = this.svg.select(".xaxis_bottom")
680
+ .attr("transform", `translate(${this.margin.left},${this.margin.top + this.plotHeight})`)
681
+ .call(this._makeAxis(axisBottom, scale, "xaxis_bottom"))
682
+ g.select(".domain").attr("stroke", "#000").attr("stroke-width", 2)
683
+ g.selectAll(".tick line").attr("stroke", "#000")
684
+ g.selectAll(".tick text").attr("fill", "#000").style("font-size", "12px")
685
+ this.updateAxisLabel(g, "xaxis_bottom", this.plotWidth / 2, this.margin.bottom)
686
+ }
687
+ if (this.axisRegistry.getScale("xaxis_top")) {
688
+ const scale = this.axisRegistry.getScale("xaxis_top")
689
+ const g = this.svg.select(".xaxis_top")
690
+ .attr("transform", `translate(${this.margin.left},${this.margin.top})`)
691
+ .call(this._makeAxis(axisTop, scale, "xaxis_top"))
692
+ g.select(".domain").attr("stroke", "#000").attr("stroke-width", 2)
693
+ g.selectAll(".tick line").attr("stroke", "#000")
694
+ g.selectAll(".tick text").attr("fill", "#000").style("font-size", "12px")
695
+ this.updateAxisLabel(g, "xaxis_top", this.plotWidth / 2, this.margin.top)
696
+ }
697
+ if (this.axisRegistry.getScale("yaxis_left")) {
698
+ const scale = this.axisRegistry.getScale("yaxis_left")
699
+ const g = this.svg.select(".yaxis_left")
700
+ .attr("transform", `translate(${this.margin.left},${this.margin.top})`)
701
+ .call(this._makeAxis(axisLeft, scale, "yaxis_left"))
702
+ g.select(".domain").attr("stroke", "#000").attr("stroke-width", 2)
703
+ g.selectAll(".tick line").attr("stroke", "#000")
704
+ g.selectAll(".tick text").attr("fill", "#000").style("font-size", "12px")
705
+ this.updateAxisLabel(g, "yaxis_left", -this.plotHeight / 2, this.margin.left)
706
+ }
707
+ if (this.axisRegistry.getScale("yaxis_right")) {
708
+ const scale = this.axisRegistry.getScale("yaxis_right")
709
+ const g = this.svg.select(".yaxis_right")
710
+ .attr("transform", `translate(${this.margin.left + this.plotWidth},${this.margin.top})`)
711
+ .call(this._makeAxis(axisRight, scale, "yaxis_right"))
712
+ g.select(".domain").attr("stroke", "#000").attr("stroke-width", 2)
713
+ g.selectAll(".tick line").attr("stroke", "#000")
714
+ g.selectAll(".tick text").attr("fill", "#000").style("font-size", "12px")
715
+ this.updateAxisLabel(g, "yaxis_right", -this.plotHeight / 2, this.margin.right)
716
+ }
717
+ }
718
+
719
+ updateAxisLabel(axisGroup, axisName, centerPos, availableMargin) {
720
+ const axisQuantityKind = this.axisRegistry.axisQuantityKinds[axisName]
721
+ if (!axisQuantityKind) return
722
+
723
+ const unitLabel = this.currentConfig?.axes?.[axisQuantityKind]?.label
724
+ ?? getAxisQuantityKind(axisQuantityKind).label
725
+ const isVertical = axisName.includes("y")
726
+ const padding = 5
727
+
728
+ axisGroup.select(".axis-label").remove()
729
+
730
+ const text = axisGroup.append("text")
731
+ .attr("class", "axis-label")
732
+ .attr("fill", "#000")
733
+ .style("text-anchor", "middle")
734
+ .style("font-size", "14px")
735
+ .style("font-weight", "bold")
736
+
737
+ const lines = unitLabel.split('\n')
738
+ if (lines.length > 1) {
739
+ lines.forEach((line, i) => {
740
+ text.append("tspan")
741
+ .attr("x", 0)
742
+ .attr("dy", i === 0 ? "0em" : "1.2em")
743
+ .text(line)
744
+ })
745
+ } else {
746
+ text.text(unitLabel)
747
+ }
748
+
749
+ if (isVertical) {
750
+ text.attr("transform", "rotate(-90)")
751
+ }
752
+
753
+ text.attr("x", centerPos).attr("y", 0)
754
+
755
+ const bbox = text.node().getBBox()
756
+ const tickSpace = 25
757
+
758
+ let yOffset
759
+
760
+ if (axisName === "xaxis_bottom") {
761
+ const centerY = tickSpace + (availableMargin - tickSpace) / 2
762
+ yOffset = centerY - (bbox.y + bbox.height / 2)
763
+ } else if (axisName === "xaxis_top") {
764
+ const centerY = -(tickSpace + (availableMargin - tickSpace) / 2)
765
+ yOffset = centerY - (bbox.y + bbox.height / 2)
766
+ } else if (axisName === "yaxis_left") {
767
+ const centerY = -(tickSpace + (availableMargin - tickSpace) / 2)
768
+ yOffset = centerY - (bbox.y + bbox.height / 2)
769
+ } else if (axisName === "yaxis_right") {
770
+ const centerY = tickSpace + (availableMargin - tickSpace) / 2
771
+ yOffset = centerY - (bbox.y + bbox.height / 2)
772
+ }
773
+
774
+ text.attr("y", yOffset)
775
+ }
776
+
777
+ _getScaleTypeFloat(quantityKind) {
778
+ const configScale = this.currentConfig?.axes?.[quantityKind]?.scale
779
+ const defScale = getAxisQuantityKind(quantityKind).scale
780
+ return (configScale ?? defScale) === "log" ? 1.0 : 0.0
781
+ }
782
+
783
+ scheduleRender() {
784
+ this._dirty = true
785
+ if (this._rafId === null) {
786
+ this._rafId = requestAnimationFrame(() => {
787
+ this._rafId = null
788
+ if (this._dirty) {
789
+ this._dirty = false
790
+ this.render()
791
+ }
792
+ })
793
+ }
794
+ }
795
+
796
+ render() {
797
+ this._dirty = false
798
+ this.regl.clear({ color: [1,1,1,1], depth:1 })
799
+ const viewport = {
800
+ x: this.margin.left,
801
+ y: this.margin.bottom,
802
+ width: this.plotWidth,
803
+ height: this.plotHeight
804
+ }
805
+ for (const layer of this.layers) {
806
+ const xIsLog = layer.xAxis ? this.axisRegistry.isLogScale(layer.xAxis) : false
807
+ const yIsLog = layer.yAxis ? this.axisRegistry.isLogScale(layer.yAxis) : false
808
+ const props = {
809
+ xDomain: layer.xAxis ? this.axisRegistry.getScale(layer.xAxis).domain() : [0, 1],
810
+ yDomain: layer.yAxis ? this.axisRegistry.getScale(layer.yAxis).domain() : [0, 1],
811
+ xScaleType: xIsLog ? 1.0 : 0.0,
812
+ yScaleType: yIsLog ? 1.0 : 0.0,
813
+ viewport: viewport,
814
+ count: layer.vertexCount ?? layer.attributes.x?.length ?? 0
815
+ }
816
+
817
+ if (layer.instanceCount !== null) {
818
+ props.instances = layer.instanceCount
819
+ }
820
+
821
+ // Add color axis uniforms, keyed by quantity kind
822
+ for (const qk of layer.colorAxes) {
823
+ props[`colorscale_${qk}`] = this.colorAxisRegistry.getColorscaleIndex(qk)
824
+ const range = this.colorAxisRegistry.getRange(qk)
825
+ props[`color_range_${qk}`] = range ?? [0, 1]
826
+ props[`color_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
827
+ }
828
+
829
+ // Add filter axis uniforms (vec4: [min, max, hasMin, hasMax]), keyed by quantity kind
830
+ for (const qk of layer.filterAxes) {
831
+ props[`filter_range_${qk}`] = this.filterAxisRegistry.getRangeUniform(qk)
832
+ props[`filter_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
833
+ }
834
+
835
+ layer.draw(props)
836
+ }
837
+ this.renderAxes()
838
+ for (const cb of this._renderCallbacks) cb()
839
+ }
840
+
841
+ initZoom() {
842
+ const fullOverlay = this.svg.append("rect")
843
+ .attr("class", "zoom-overlay")
844
+ .attr("x", 0)
845
+ .attr("y", 0)
846
+ .attr("width", this.width)
847
+ .attr("height", this.height)
848
+ .attr("fill", "none")
849
+ .attr("pointer-events", "all")
850
+ .style("cursor", "move")
851
+
852
+ let currentRegion = null
853
+ let gestureStartDomains = {}
854
+ let gestureStartMousePos = {}
855
+ let gestureStartDataPos = {}
856
+ let gestureStartTransform = null
857
+
858
+ const zoomBehavior = zoom()
859
+ .on("start", (event) => {
860
+ if (!event.sourceEvent) return
861
+
862
+ gestureStartTransform = { k: event.transform.k, x: event.transform.x, y: event.transform.y }
863
+ const [mouseX, mouseY] = d3.pointer(event.sourceEvent, this.svg.node())
864
+
865
+ const inPlotX = mouseX >= this.margin.left && mouseX < this.margin.left + this.plotWidth
866
+ const inPlotY = mouseY >= this.margin.top && mouseY < this.margin.top + this.plotHeight
867
+
868
+ if (inPlotX && mouseY < this.margin.top) {
869
+ currentRegion = "xaxis_top"
870
+ } else if (inPlotX && mouseY >= this.margin.top + this.plotHeight) {
871
+ currentRegion = "xaxis_bottom"
872
+ } else if (inPlotY && mouseX < this.margin.left) {
873
+ currentRegion = "yaxis_left"
874
+ } else if (inPlotY && mouseX >= this.margin.left + this.plotWidth) {
875
+ currentRegion = "yaxis_right"
876
+ } else if (inPlotX && inPlotY) {
877
+ currentRegion = "plot_area"
878
+ } else {
879
+ currentRegion = null
880
+ }
881
+
882
+ gestureStartDomains = {}
883
+ gestureStartMousePos = {}
884
+ gestureStartDataPos = {}
885
+ if (currentRegion && this.axisRegistry) {
886
+ const axesToZoom = currentRegion === "plot_area" ? AXES : [currentRegion]
887
+ axesToZoom.forEach(axis => {
888
+ const scale = this.axisRegistry.getScale(axis)
889
+ if (scale) {
890
+ const currentDomain = scale.domain()
891
+ gestureStartDomains[axis] = currentDomain.slice()
892
+
893
+ const isY = axis.includes("y")
894
+ const mousePixel = isY ? (mouseY - this.margin.top) : (mouseX - this.margin.left)
895
+ gestureStartMousePos[axis] = mousePixel
896
+
897
+ const pixelSize = isY ? this.plotHeight : this.plotWidth
898
+ const [d0, d1] = currentDomain
899
+ const isLog = this.axisRegistry.isLogScale(axis)
900
+ const t0 = isLog ? Math.log(d0) : d0
901
+ const t1 = isLog ? Math.log(d1) : d1
902
+ const tDomainWidth = t1 - t0
903
+ const fraction = mousePixel / pixelSize
904
+
905
+ if (isY) {
906
+ gestureStartDataPos[axis] = t1 - fraction * tDomainWidth
907
+ } else {
908
+ gestureStartDataPos[axis] = t0 + fraction * tDomainWidth
909
+ }
910
+ }
911
+ })
912
+ }
913
+ })
914
+ .on("zoom", (event) => {
915
+ if (!this.axisRegistry || !currentRegion || !gestureStartTransform) return
916
+
917
+ const deltaK = event.transform.k / gestureStartTransform.k
918
+ const deltaX = event.transform.x - gestureStartTransform.x
919
+ const deltaY = event.transform.y - gestureStartTransform.y
920
+
921
+ const isWheel = event.sourceEvent && event.sourceEvent.type === 'wheel'
922
+
923
+ const axesToZoom = currentRegion === "plot_area" ? AXES : [currentRegion]
924
+
925
+ axesToZoom.forEach(axis => {
926
+ const scale = this.axisRegistry.getScale(axis)
927
+ if (scale && gestureStartDomains[axis] && gestureStartDataPos[axis] !== undefined) {
928
+ const isY = axis.includes("y")
929
+ const [d0, d1] = gestureStartDomains[axis]
930
+ const isLog = this.axisRegistry.isLogScale(axis)
931
+ const t0 = isLog ? Math.log(d0) : d0
932
+ const t1 = isLog ? Math.log(d1) : d1
933
+ const tDomainWidth = t1 - t0
934
+
935
+ const pixelSize = isY ? this.plotHeight : this.plotWidth
936
+ const pixelDelta = isY ? deltaY : deltaX
937
+ const zoomScale = deltaK
938
+ const mousePixelPos = gestureStartMousePos[axis]
939
+ const targetDataPos = gestureStartDataPos[axis] // stored in transform space
940
+
941
+ const newTDomainWidth = tDomainWidth / zoomScale
942
+
943
+ const panTDomainDelta = isWheel ? 0 : (isY
944
+ ? pixelDelta * tDomainWidth / pixelSize / zoomScale
945
+ : -pixelDelta * tDomainWidth / pixelSize / zoomScale)
946
+
947
+ const fraction = mousePixelPos / pixelSize
948
+ let tCenter
949
+
950
+ if (isY) {
951
+ tCenter = (targetDataPos + panTDomainDelta) + (fraction - 0.5) * newTDomainWidth
952
+ } else {
953
+ tCenter = (targetDataPos + panTDomainDelta) + (0.5 - fraction) * newTDomainWidth
954
+ }
955
+
956
+ const newTDomain = [tCenter - newTDomainWidth / 2, tCenter + newTDomainWidth / 2]
957
+ const newDomain = isLog
958
+ ? [Math.exp(newTDomain[0]), Math.exp(newTDomain[1])]
959
+ : newTDomain
960
+ this._getAxis(axis).setDomain(newDomain)
961
+ }
962
+ })
963
+
964
+ this.scheduleRender()
965
+ })
966
+ .on("end", () => {
967
+ currentRegion = null
968
+ gestureStartDomains = {}
969
+ gestureStartMousePos = {}
970
+ gestureStartDataPos = {}
971
+ gestureStartTransform = null
972
+ })
973
+
974
+ fullOverlay.call(zoomBehavior)
975
+ }
976
+ }