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