gladly-plot 0.0.3 → 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 (44) hide show
  1. package/README.md +1 -0
  2. package/package.json +16 -8
  3. package/src/axes/Axis.js +253 -0
  4. package/src/{AxisQuantityKindRegistry.js → axes/AxisQuantityKindRegistry.js} +7 -0
  5. package/src/{AxisRegistry.js → axes/AxisRegistry.js} +48 -0
  6. package/src/axes/ColorAxisRegistry.js +93 -0
  7. package/src/{FilterAxisRegistry.js → axes/FilterAxisRegistry.js} +63 -0
  8. package/src/axes/ZoomController.js +141 -0
  9. package/src/colorscales/BivariateColorscales.js +205 -0
  10. package/src/colorscales/ColorscaleRegistry.js +124 -0
  11. package/src/compute/ComputationRegistry.js +237 -0
  12. package/src/compute/axisFilter.js +47 -0
  13. package/src/compute/conv.js +230 -0
  14. package/src/compute/fft.js +292 -0
  15. package/src/compute/filter.js +227 -0
  16. package/src/compute/hist.js +180 -0
  17. package/src/compute/kde.js +102 -0
  18. package/src/{Layer.js → core/Layer.js} +4 -3
  19. package/src/{LayerType.js → core/LayerType.js} +72 -7
  20. package/src/core/Plot.js +735 -0
  21. package/src/{Colorbar.js → floats/Colorbar.js} +19 -5
  22. package/src/floats/Colorbar2d.js +77 -0
  23. package/src/{Filterbar.js → floats/Filterbar.js} +18 -4
  24. package/src/{FilterbarFloat.js → floats/Float.js} +17 -30
  25. package/src/{EpsgUtils.js → geo/EpsgUtils.js} +1 -1
  26. package/src/index.js +35 -22
  27. package/src/{ColorbarLayer.js → layers/ColorbarLayer.js} +2 -2
  28. package/src/layers/ColorbarLayer2d.js +97 -0
  29. package/src/{FilterbarLayer.js → layers/FilterbarLayer.js} +2 -2
  30. package/src/layers/HistogramLayer.js +212 -0
  31. package/src/layers/LinesLayer.js +199 -0
  32. package/src/layers/PointsLayer.js +114 -0
  33. package/src/layers/ScatterShared.js +142 -0
  34. package/src/{TileLayer.js → layers/TileLayer.js} +4 -4
  35. package/src/Axis.js +0 -48
  36. package/src/ColorAxisRegistry.js +0 -49
  37. package/src/ColorscaleRegistry.js +0 -52
  38. package/src/Float.js +0 -159
  39. package/src/Plot.js +0 -1068
  40. package/src/ScatterLayer.js +0 -133
  41. /package/src/{AxisLink.js → axes/AxisLink.js} +0 -0
  42. /package/src/{MatplotlibColorscales.js → colorscales/MatplotlibColorscales.js} +0 -0
  43. /package/src/{Data.js → core/Data.js} +0 -0
  44. /package/src/{LayerTypeRegistry.js → core/LayerTypeRegistry.js} +0 -0
package/src/Plot.js DELETED
@@ -1,1068 +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
-
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 (let configLayerIndex = 0; configLayerIndex < layersConfig.length; configLayerIndex++) {
359
- const layerSpec = layersConfig[configLayerIndex]
360
- const entries = Object.entries(layerSpec)
361
- if (entries.length !== 1) {
362
- throw new Error("Each layer specification must have exactly one layer type key")
363
- }
364
-
365
- const [layerTypeName, parameters] = entries[0]
366
- const layerType = getLayerType(layerTypeName)
367
-
368
- // Resolve axis config once per layer spec for registration (independent of draw call count).
369
- const ac = layerType.resolveAxisConfig(parameters, data)
370
- const axesConfig = this.currentConfig?.axes ?? {}
371
-
372
- // Register spatial axes (null means no axis for that direction).
373
- // Pass any scale override from config (e.g. "log") so the D3 scale is created correctly.
374
- if (ac.xAxis) this.axisRegistry.ensureAxis(ac.xAxis, ac.xAxisQuantityKind, axesConfig[ac.xAxis]?.scale ?? axesConfig[ac.xAxisQuantityKind]?.scale)
375
- if (ac.yAxis) this.axisRegistry.ensureAxis(ac.yAxis, ac.yAxisQuantityKind, axesConfig[ac.yAxis]?.scale ?? axesConfig[ac.yAxisQuantityKind]?.scale)
376
-
377
- // Register color axes (colorscale comes from config or quantity kind registry, not from here)
378
- for (const quantityKind of ac.colorAxisQuantityKinds) {
379
- this.colorAxisRegistry.ensureColorAxis(quantityKind)
380
- }
381
-
382
- // Register filter axes
383
- for (const quantityKind of ac.filterAxisQuantityKinds) {
384
- this.filterAxisRegistry.ensureFilterAxis(quantityKind)
385
- }
386
-
387
- // Create one draw command per GPU config returned by the layer type.
388
- for (const layer of layerType.createLayer(parameters, data)) {
389
- layer.configLayerIndex = configLayerIndex
390
- layer.draw = layer.type.createDrawCommand(this.regl, layer, this)
391
- this.layers.push(layer)
392
- }
393
- }
394
- }
395
-
396
- _setDomains(axesOverrides) {
397
- // Auto-calculate spatial axis domains
398
- const autoDomains = {}
399
-
400
- for (const axis of AXES) {
401
- const layersUsingAxis = this.layers.filter(l =>
402
- l.xAxis === axis || l.yAxis === axis
403
- )
404
-
405
- if (layersUsingAxis.length === 0) continue
406
-
407
- let min = Infinity
408
- let max = -Infinity
409
-
410
- for (const layer of layersUsingAxis) {
411
- const isXAxis = layer.xAxis === axis
412
- const qk = isXAxis ? layer.xAxisQuantityKind : layer.yAxisQuantityKind
413
- if (layer.domains[qk] !== undefined) {
414
- const [dMin, dMax] = layer.domains[qk]
415
- if (dMin < min) min = dMin
416
- if (dMax > max) max = dMax
417
- } else {
418
- const dataArray = isXAxis ? layer.attributes.x : layer.attributes.y
419
- if (!dataArray) continue
420
- for (let i = 0; i < dataArray.length; i++) {
421
- const val = dataArray[i]
422
- if (val < min) min = val
423
- if (val > max) max = val
424
- }
425
- }
426
- }
427
-
428
- if (min !== Infinity) autoDomains[axis] = [min, max]
429
- }
430
-
431
- for (const axis of AXES) {
432
- const scale = this.axisRegistry.getScale(axis)
433
- if (scale) {
434
- let domain
435
- if (axesOverrides[axis]) {
436
- const override = axesOverrides[axis]
437
- domain = [override.min, override.max]
438
- } else {
439
- domain = autoDomains[axis]
440
- }
441
- if (domain) {
442
- scale.domain(domain)
443
- }
444
- }
445
- }
446
-
447
- // Compute data extent for each filter axis and store it (used by Filterbar for display).
448
- // Data comes from the attribute named by the quantity kind. If absent, auto-range is skipped.
449
- // Also apply any range overrides from config; default is fully open bounds (no filtering).
450
- for (const quantityKind of this.filterAxisRegistry.getQuantityKinds()) {
451
- let extMin = Infinity, extMax = -Infinity
452
- for (const layer of this.layers) {
453
- for (const qk of layer.filterAxes) {
454
- if (qk !== quantityKind) continue
455
- if (layer.domains[qk] !== undefined) {
456
- const [dMin, dMax] = layer.domains[qk]
457
- if (dMin < extMin) extMin = dMin
458
- if (dMax > extMax) extMax = dMax
459
- } else {
460
- const data = layer.attributes[qk]
461
- if (!data) continue
462
- for (let i = 0; i < data.length; i++) {
463
- if (data[i] < extMin) extMin = data[i]
464
- if (data[i] > extMax) extMax = data[i]
465
- }
466
- }
467
- }
468
- }
469
- if (extMin !== Infinity) {
470
- this.filterAxisRegistry.setDataExtent(quantityKind, extMin, extMax)
471
- }
472
-
473
- if (axesOverrides[quantityKind]) {
474
- const override = axesOverrides[quantityKind]
475
- const min = override.min !== undefined ? override.min : null
476
- const max = override.max !== undefined ? override.max : null
477
- this.filterAxisRegistry.setRange(quantityKind, min, max)
478
- }
479
- }
480
-
481
- // Auto-calculate color axis domains.
482
- // Data comes from the attribute named by the quantity kind. If absent, auto-range is skipped
483
- // (e.g. ColorbarLayer, whose range is always synced externally from the target plot).
484
- for (const quantityKind of this.colorAxisRegistry.getQuantityKinds()) {
485
- let min = Infinity
486
- let max = -Infinity
487
-
488
- for (const layer of this.layers) {
489
- for (const qk of layer.colorAxes) {
490
- if (qk !== quantityKind) continue
491
- // Use layer-declared domain if provided, otherwise scan the attribute array.
492
- if (layer.domains[qk] !== undefined) {
493
- const [dMin, dMax] = layer.domains[qk]
494
- if (dMin < min) min = dMin
495
- if (dMax > max) max = dMax
496
- } else {
497
- const data = layer.attributes[qk]
498
- if (!data) continue
499
- for (let i = 0; i < data.length; i++) {
500
- if (data[i] < min) min = data[i]
501
- if (data[i] > max) max = data[i]
502
- }
503
- }
504
- }
505
- }
506
-
507
- if (min !== Infinity) {
508
- const override = axesOverrides[quantityKind]
509
- if (override?.colorscale) {
510
- this.colorAxisRegistry.ensureColorAxis(quantityKind, override.colorscale)
511
- }
512
- // Config min/max override the auto-calculated values; absent means keep auto value.
513
- this.colorAxisRegistry.setRange(quantityKind, override?.min ?? min, override?.max ?? max)
514
- }
515
- }
516
-
517
- // Validate that log-scale axes have strictly positive domains/ranges.
518
- for (const axis of AXES) {
519
- if (!this.axisRegistry.isLogScale(axis)) continue
520
- const [dMin, dMax] = this.axisRegistry.getScale(axis).domain()
521
- if ((isFinite(dMin) && dMin <= 0) || (isFinite(dMax) && dMax <= 0)) {
522
- throw new Error(
523
- `Axis '${axis}' uses log scale but has non-positive domain [${dMin}, ${dMax}]. ` +
524
- `All data values and min/max must be > 0 for log scale.`
525
- )
526
- }
527
- }
528
-
529
- for (const quantityKind of this.colorAxisRegistry.getQuantityKinds()) {
530
- if (this._getScaleTypeFloat(quantityKind) <= 0.5) continue
531
- const range = this.colorAxisRegistry.getRange(quantityKind)
532
- if (!range) continue
533
- if (range[0] <= 0 || range[1] <= 0) {
534
- throw new Error(
535
- `Color axis '${quantityKind}' uses log scale but has non-positive range [${range[0]}, ${range[1]}]. ` +
536
- `All data values and min/max must be > 0 for log scale.`
537
- )
538
- }
539
- }
540
-
541
- for (const quantityKind of this.filterAxisRegistry.getQuantityKinds()) {
542
- if (this._getScaleTypeFloat(quantityKind) <= 0.5) continue
543
- const extent = this.filterAxisRegistry.getDataExtent(quantityKind)
544
- if (extent && extent[0] <= 0) {
545
- throw new Error(
546
- `Filter axis '${quantityKind}' uses log scale but data minimum is ${extent[0]}. ` +
547
- `All data values must be > 0 for log scale.`
548
- )
549
- }
550
- const filterRange = this.filterAxisRegistry.getRange(quantityKind)
551
- if (filterRange) {
552
- if (filterRange.min !== null && filterRange.min <= 0) {
553
- throw new Error(
554
- `Filter axis '${quantityKind}' uses log scale but min is ${filterRange.min}. ` +
555
- `min must be > 0 for log scale.`
556
- )
557
- }
558
- if (filterRange.max !== null && filterRange.max <= 0) {
559
- throw new Error(
560
- `Filter axis '${quantityKind}' uses log scale but max is ${filterRange.max}. ` +
561
- `max must be > 0 for log scale.`
562
- )
563
- }
564
- }
565
- }
566
- }
567
-
568
- static schema(data) {
569
- const layerTypes = getRegisteredLayerTypes()
570
-
571
- return {
572
- $schema: "https://json-schema.org/draft/2020-12/schema",
573
- type: "object",
574
- properties: {
575
- layers: {
576
- type: "array",
577
- items: {
578
- type: "object",
579
- oneOf: layerTypes.map(typeName => {
580
- const layerType = getLayerType(typeName)
581
- return {
582
- title: typeName,
583
- properties: {
584
- [typeName]: layerType.schema(data)
585
- },
586
- required: [typeName],
587
- additionalProperties: false
588
- }
589
- })
590
- }
591
- },
592
- axes: {
593
- type: "object",
594
- properties: {
595
- xaxis_bottom: {
596
- type: "object",
597
- properties: {
598
- min: { type: "number" },
599
- max: { type: "number" },
600
- label: { type: "string" },
601
- scale: { type: "string", enum: ["linear", "log"] }
602
- }
603
- },
604
- xaxis_top: {
605
- type: "object",
606
- properties: {
607
- min: { type: "number" },
608
- max: { type: "number" },
609
- label: { type: "string" },
610
- scale: { type: "string", enum: ["linear", "log"] }
611
- }
612
- },
613
- yaxis_left: {
614
- type: "object",
615
- properties: {
616
- min: { type: "number" },
617
- max: { type: "number" },
618
- label: { type: "string" },
619
- scale: { type: "string", enum: ["linear", "log"] }
620
- }
621
- },
622
- yaxis_right: {
623
- type: "object",
624
- properties: {
625
- min: { type: "number" },
626
- max: { type: "number" },
627
- label: { type: "string" },
628
- scale: { type: "string", enum: ["linear", "log"] }
629
- }
630
- }
631
- },
632
- additionalProperties: {
633
- // Color/filter/quantity-kind axes.
634
- // All fields from the quantity kind registration are valid here and override the registration.
635
- type: "object",
636
- properties: {
637
- min: { type: "number" },
638
- max: { type: "number" },
639
- label: { type: "string" },
640
- scale: { type: "string", enum: ["linear", "log"] },
641
- colorscale: {
642
- type: "string",
643
- enum: [...getRegisteredColorscales().keys()]
644
- },
645
- colorbar: {
646
- type: "string",
647
- enum: ["none", "vertical", "horizontal"]
648
- },
649
- filterbar: {
650
- type: "string",
651
- enum: ["none", "vertical", "horizontal"]
652
- }
653
- }
654
- }
655
- }
656
- }
657
- }
658
- }
659
-
660
- _tickCount(axisName) {
661
- if (axisName.includes("y")) {
662
- return Math.max(2, Math.floor(this.plotHeight / 27))
663
- }
664
- return Math.max(2, Math.floor(this.plotWidth / 40))
665
- }
666
-
667
- _makeAxis(axisConstructor, scale, axisName) {
668
- const count = this._tickCount(axisName)
669
- const gen = axisConstructor(scale).tickFormat(formatTick)
670
- if (count <= 2) {
671
- gen.tickValues(scale.domain())
672
- } else {
673
- gen.ticks(count)
674
- }
675
- return gen
676
- }
677
-
678
- renderAxes() {
679
- if (this.axisRegistry.getScale("xaxis_bottom")) {
680
- const scale = this.axisRegistry.getScale("xaxis_bottom")
681
- const g = this.svg.select(".xaxis_bottom")
682
- .attr("transform", `translate(${this.margin.left},${this.margin.top + this.plotHeight})`)
683
- .call(this._makeAxis(axisBottom, scale, "xaxis_bottom"))
684
- g.select(".domain").attr("stroke", "#000").attr("stroke-width", 2)
685
- g.selectAll(".tick line").attr("stroke", "#000")
686
- g.selectAll(".tick text").attr("fill", "#000").style("font-size", "12px")
687
- this.updateAxisLabel(g, "xaxis_bottom", this.plotWidth / 2, this.margin.bottom)
688
- }
689
- if (this.axisRegistry.getScale("xaxis_top")) {
690
- const scale = this.axisRegistry.getScale("xaxis_top")
691
- const g = this.svg.select(".xaxis_top")
692
- .attr("transform", `translate(${this.margin.left},${this.margin.top})`)
693
- .call(this._makeAxis(axisTop, scale, "xaxis_top"))
694
- g.select(".domain").attr("stroke", "#000").attr("stroke-width", 2)
695
- g.selectAll(".tick line").attr("stroke", "#000")
696
- g.selectAll(".tick text").attr("fill", "#000").style("font-size", "12px")
697
- this.updateAxisLabel(g, "xaxis_top", this.plotWidth / 2, this.margin.top)
698
- }
699
- if (this.axisRegistry.getScale("yaxis_left")) {
700
- const scale = this.axisRegistry.getScale("yaxis_left")
701
- const g = this.svg.select(".yaxis_left")
702
- .attr("transform", `translate(${this.margin.left},${this.margin.top})`)
703
- .call(this._makeAxis(axisLeft, scale, "yaxis_left"))
704
- g.select(".domain").attr("stroke", "#000").attr("stroke-width", 2)
705
- g.selectAll(".tick line").attr("stroke", "#000")
706
- g.selectAll(".tick text").attr("fill", "#000").style("font-size", "12px")
707
- this.updateAxisLabel(g, "yaxis_left", -this.plotHeight / 2, this.margin.left)
708
- }
709
- if (this.axisRegistry.getScale("yaxis_right")) {
710
- const scale = this.axisRegistry.getScale("yaxis_right")
711
- const g = this.svg.select(".yaxis_right")
712
- .attr("transform", `translate(${this.margin.left + this.plotWidth},${this.margin.top})`)
713
- .call(this._makeAxis(axisRight, scale, "yaxis_right"))
714
- g.select(".domain").attr("stroke", "#000").attr("stroke-width", 2)
715
- g.selectAll(".tick line").attr("stroke", "#000")
716
- g.selectAll(".tick text").attr("fill", "#000").style("font-size", "12px")
717
- this.updateAxisLabel(g, "yaxis_right", -this.plotHeight / 2, this.margin.right)
718
- }
719
- }
720
-
721
- updateAxisLabel(axisGroup, axisName, centerPos, availableMargin) {
722
- const axisQuantityKind = this.axisRegistry.axisQuantityKinds[axisName]
723
- if (!axisQuantityKind) return
724
-
725
- const unitLabel = this.currentConfig?.axes?.[axisQuantityKind]?.label
726
- ?? getAxisQuantityKind(axisQuantityKind).label
727
- const isVertical = axisName.includes("y")
728
- const padding = 5
729
-
730
- axisGroup.select(".axis-label").remove()
731
-
732
- const text = axisGroup.append("text")
733
- .attr("class", "axis-label")
734
- .attr("fill", "#000")
735
- .style("text-anchor", "middle")
736
- .style("font-size", "14px")
737
- .style("font-weight", "bold")
738
-
739
- const lines = unitLabel.split('\n')
740
- if (lines.length > 1) {
741
- lines.forEach((line, i) => {
742
- text.append("tspan")
743
- .attr("x", 0)
744
- .attr("dy", i === 0 ? "0em" : "1.2em")
745
- .text(line)
746
- })
747
- } else {
748
- text.text(unitLabel)
749
- }
750
-
751
- if (isVertical) {
752
- text.attr("transform", "rotate(-90)")
753
- }
754
-
755
- text.attr("x", centerPos).attr("y", 0)
756
-
757
- const bbox = text.node().getBBox()
758
- const tickSpace = 25
759
-
760
- let yOffset
761
-
762
- if (axisName === "xaxis_bottom") {
763
- const centerY = tickSpace + (availableMargin - tickSpace) / 2
764
- yOffset = centerY - (bbox.y + bbox.height / 2)
765
- } else if (axisName === "xaxis_top") {
766
- const centerY = -(tickSpace + (availableMargin - tickSpace) / 2)
767
- yOffset = centerY - (bbox.y + bbox.height / 2)
768
- } else if (axisName === "yaxis_left") {
769
- const centerY = -(tickSpace + (availableMargin - tickSpace) / 2)
770
- yOffset = centerY - (bbox.y + bbox.height / 2)
771
- } else if (axisName === "yaxis_right") {
772
- const centerY = tickSpace + (availableMargin - tickSpace) / 2
773
- yOffset = centerY - (bbox.y + bbox.height / 2)
774
- }
775
-
776
- text.attr("y", yOffset)
777
- }
778
-
779
- _getScaleTypeFloat(quantityKind) {
780
- const configScale = this.currentConfig?.axes?.[quantityKind]?.scale
781
- const defScale = getAxisQuantityKind(quantityKind).scale
782
- return (configScale ?? defScale) === "log" ? 1.0 : 0.0
783
- }
784
-
785
- scheduleRender() {
786
- this._dirty = true
787
- if (this._rafId === null) {
788
- this._rafId = requestAnimationFrame(() => {
789
- this._rafId = null
790
- if (this._dirty) {
791
- this._dirty = false
792
- this.render()
793
- }
794
- })
795
- }
796
- }
797
-
798
- render() {
799
- this._dirty = false
800
- this.regl.clear({ color: [1,1,1,1], depth:1 })
801
- const viewport = {
802
- x: this.margin.left,
803
- y: this.margin.bottom,
804
- width: this.plotWidth,
805
- height: this.plotHeight
806
- }
807
- for (const layer of this.layers) {
808
- const xIsLog = layer.xAxis ? this.axisRegistry.isLogScale(layer.xAxis) : false
809
- const yIsLog = layer.yAxis ? this.axisRegistry.isLogScale(layer.yAxis) : false
810
- const props = {
811
- xDomain: layer.xAxis ? this.axisRegistry.getScale(layer.xAxis).domain() : [0, 1],
812
- yDomain: layer.yAxis ? this.axisRegistry.getScale(layer.yAxis).domain() : [0, 1],
813
- xScaleType: xIsLog ? 1.0 : 0.0,
814
- yScaleType: yIsLog ? 1.0 : 0.0,
815
- viewport: viewport,
816
- count: layer.vertexCount ?? layer.attributes.x?.length ?? 0,
817
- u_pickingMode: 0.0,
818
- u_pickLayerIndex: 0.0,
819
- }
820
-
821
- if (layer.instanceCount !== null) {
822
- props.instances = layer.instanceCount
823
- }
824
-
825
- // Add color axis uniforms, keyed by quantity kind
826
- for (const qk of layer.colorAxes) {
827
- props[`colorscale_${qk}`] = this.colorAxisRegistry.getColorscaleIndex(qk)
828
- const range = this.colorAxisRegistry.getRange(qk)
829
- props[`color_range_${qk}`] = range ?? [0, 1]
830
- props[`color_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
831
- }
832
-
833
- // Add filter axis uniforms (vec4: [min, max, hasMin, hasMax]), keyed by quantity kind
834
- for (const qk of layer.filterAxes) {
835
- props[`filter_range_${qk}`] = this.filterAxisRegistry.getRangeUniform(qk)
836
- props[`filter_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
837
- }
838
-
839
- layer.draw(props)
840
- }
841
- this.renderAxes()
842
- for (const cb of this._renderCallbacks) cb()
843
- }
844
-
845
- initZoom() {
846
- const fullOverlay = this.svg.append("rect")
847
- .attr("class", "zoom-overlay")
848
- .attr("x", 0)
849
- .attr("y", 0)
850
- .attr("width", this.width)
851
- .attr("height", this.height)
852
- .attr("fill", "none")
853
- .attr("pointer-events", "all")
854
- .style("cursor", "move")
855
-
856
- let currentRegion = null
857
- let gestureStartDomains = {}
858
- let gestureStartMousePos = {}
859
- let gestureStartDataPos = {}
860
- let gestureStartTransform = null
861
-
862
- const zoomBehavior = zoom()
863
- .on("start", (event) => {
864
- if (!event.sourceEvent) return
865
-
866
- gestureStartTransform = { k: event.transform.k, x: event.transform.x, y: event.transform.y }
867
- const [mouseX, mouseY] = d3.pointer(event.sourceEvent, this.svg.node())
868
-
869
- const inPlotX = mouseX >= this.margin.left && mouseX < this.margin.left + this.plotWidth
870
- const inPlotY = mouseY >= this.margin.top && mouseY < this.margin.top + this.plotHeight
871
-
872
- if (inPlotX && mouseY < this.margin.top) {
873
- currentRegion = "xaxis_top"
874
- } else if (inPlotX && mouseY >= this.margin.top + this.plotHeight) {
875
- currentRegion = "xaxis_bottom"
876
- } else if (inPlotY && mouseX < this.margin.left) {
877
- currentRegion = "yaxis_left"
878
- } else if (inPlotY && mouseX >= this.margin.left + this.plotWidth) {
879
- currentRegion = "yaxis_right"
880
- } else if (inPlotX && inPlotY) {
881
- currentRegion = "plot_area"
882
- } else {
883
- currentRegion = null
884
- }
885
-
886
- gestureStartDomains = {}
887
- gestureStartMousePos = {}
888
- gestureStartDataPos = {}
889
- if (currentRegion && this.axisRegistry) {
890
- const axesToZoom = currentRegion === "plot_area" ? AXES : [currentRegion]
891
- axesToZoom.forEach(axis => {
892
- const scale = this.axisRegistry.getScale(axis)
893
- if (scale) {
894
- const currentDomain = scale.domain()
895
- gestureStartDomains[axis] = currentDomain.slice()
896
-
897
- const isY = axis.includes("y")
898
- const mousePixel = isY ? (mouseY - this.margin.top) : (mouseX - this.margin.left)
899
- gestureStartMousePos[axis] = mousePixel
900
-
901
- const pixelSize = isY ? this.plotHeight : this.plotWidth
902
- const [d0, d1] = currentDomain
903
- const isLog = this.axisRegistry.isLogScale(axis)
904
- const t0 = isLog ? Math.log(d0) : d0
905
- const t1 = isLog ? Math.log(d1) : d1
906
- const tDomainWidth = t1 - t0
907
- const fraction = mousePixel / pixelSize
908
-
909
- if (isY) {
910
- gestureStartDataPos[axis] = t1 - fraction * tDomainWidth
911
- } else {
912
- gestureStartDataPos[axis] = t0 + fraction * tDomainWidth
913
- }
914
- }
915
- })
916
- }
917
- })
918
- .on("zoom", (event) => {
919
- if (!this.axisRegistry || !currentRegion || !gestureStartTransform) return
920
-
921
- const deltaK = event.transform.k / gestureStartTransform.k
922
- const deltaX = event.transform.x - gestureStartTransform.x
923
- const deltaY = event.transform.y - gestureStartTransform.y
924
-
925
- const isWheel = event.sourceEvent && event.sourceEvent.type === 'wheel'
926
-
927
- const axesToZoom = currentRegion === "plot_area" ? AXES : [currentRegion]
928
-
929
- axesToZoom.forEach(axis => {
930
- const scale = this.axisRegistry.getScale(axis)
931
- if (scale && gestureStartDomains[axis] && gestureStartDataPos[axis] !== undefined) {
932
- const isY = axis.includes("y")
933
- const [d0, d1] = gestureStartDomains[axis]
934
- const isLog = this.axisRegistry.isLogScale(axis)
935
- const t0 = isLog ? Math.log(d0) : d0
936
- const t1 = isLog ? Math.log(d1) : d1
937
- const tDomainWidth = t1 - t0
938
-
939
- const pixelSize = isY ? this.plotHeight : this.plotWidth
940
- const pixelDelta = isY ? deltaY : deltaX
941
- const zoomScale = deltaK
942
- const mousePixelPos = gestureStartMousePos[axis]
943
- const targetDataPos = gestureStartDataPos[axis] // stored in transform space
944
-
945
- const newTDomainWidth = tDomainWidth / zoomScale
946
-
947
- const panTDomainDelta = isWheel ? 0 : (isY
948
- ? pixelDelta * tDomainWidth / pixelSize / zoomScale
949
- : -pixelDelta * tDomainWidth / pixelSize / zoomScale)
950
-
951
- const fraction = mousePixelPos / pixelSize
952
- let tCenter
953
-
954
- if (isY) {
955
- tCenter = (targetDataPos + panTDomainDelta) + (fraction - 0.5) * newTDomainWidth
956
- } else {
957
- tCenter = (targetDataPos + panTDomainDelta) + (0.5 - fraction) * newTDomainWidth
958
- }
959
-
960
- const newTDomain = [tCenter - newTDomainWidth / 2, tCenter + newTDomainWidth / 2]
961
- const newDomain = isLog
962
- ? [Math.exp(newTDomain[0]), Math.exp(newTDomain[1])]
963
- : newTDomain
964
- this._getAxis(axis).setDomain(newDomain)
965
- }
966
- })
967
-
968
- this.scheduleRender()
969
- })
970
- .on("end", () => {
971
- currentRegion = null
972
- gestureStartDomains = {}
973
- gestureStartMousePos = {}
974
- gestureStartDataPos = {}
975
- gestureStartTransform = null
976
- })
977
-
978
- fullOverlay.call(zoomBehavior)
979
- }
980
-
981
- lookup(x, y) {
982
- const result = {}
983
- if (!this.axisRegistry) return result
984
- const plotX = x - this.margin.left
985
- const plotY = y - this.margin.top
986
- for (const axisId of AXES) {
987
- const scale = this.axisRegistry.getScale(axisId)
988
- if (!scale) continue
989
- const qk = this.axisRegistry.axisQuantityKinds[axisId]
990
- const value = axisId.includes('y') ? scale.invert(plotY) : scale.invert(plotX)
991
- result[axisId] = value
992
- if (qk) result[qk] = value
993
- }
994
- return result
995
- }
996
-
997
- on(eventType, callback) {
998
- const handler = (e) => {
999
- if (!this.container.contains(e.target)) return
1000
- const rect = this.container.getBoundingClientRect()
1001
- const x = e.clientX - rect.left
1002
- const y = e.clientY - rect.top
1003
- callback(e, this.lookup(x, y))
1004
- }
1005
- window.addEventListener(eventType, handler, { capture: true })
1006
- return { remove: () => window.removeEventListener(eventType, handler, { capture: true }) }
1007
- }
1008
-
1009
- pick(x, y) {
1010
- if (!this.regl || !this.layers.length) return null
1011
-
1012
- const fbo = this.regl.framebuffer({
1013
- width: this.width, height: this.height,
1014
- colorFormat: 'rgba', colorType: 'uint8', depth: false,
1015
- })
1016
-
1017
- const glX = Math.round(x)
1018
- const glY = this.height - Math.round(y) - 1
1019
-
1020
- let result = null
1021
- this.regl({ framebuffer: fbo })(() => {
1022
- this.regl.clear({ color: [0, 0, 0, 0] })
1023
- const viewport = {
1024
- x: this.margin.left, y: this.margin.bottom,
1025
- width: this.plotWidth, height: this.plotHeight
1026
- }
1027
- for (let i = 0; i < this.layers.length; i++) {
1028
- const layer = this.layers[i]
1029
- const xIsLog = layer.xAxis ? this.axisRegistry.isLogScale(layer.xAxis) : false
1030
- const yIsLog = layer.yAxis ? this.axisRegistry.isLogScale(layer.yAxis) : false
1031
- const props = {
1032
- xDomain: layer.xAxis ? this.axisRegistry.getScale(layer.xAxis).domain() : [0, 1],
1033
- yDomain: layer.yAxis ? this.axisRegistry.getScale(layer.yAxis).domain() : [0, 1],
1034
- xScaleType: xIsLog ? 1.0 : 0.0,
1035
- yScaleType: yIsLog ? 1.0 : 0.0,
1036
- viewport,
1037
- count: layer.vertexCount ?? layer.attributes.x?.length ?? 0,
1038
- u_pickingMode: 1.0,
1039
- u_pickLayerIndex: i,
1040
- }
1041
- if (layer.instanceCount !== null) props.instances = layer.instanceCount
1042
- for (const qk of layer.colorAxes) {
1043
- props[`colorscale_${qk}`] = this.colorAxisRegistry.getColorscaleIndex(qk)
1044
- const range = this.colorAxisRegistry.getRange(qk)
1045
- props[`color_range_${qk}`] = range ?? [0, 1]
1046
- props[`color_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
1047
- }
1048
- for (const qk of layer.filterAxes) {
1049
- props[`filter_range_${qk}`] = this.filterAxisRegistry.getRangeUniform(qk)
1050
- props[`filter_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
1051
- }
1052
- layer.draw(props)
1053
- }
1054
- const pixels = this.regl.read({ x: glX, y: glY, width: 1, height: 1 })
1055
- if (pixels[0] === 0) {
1056
- result = null
1057
- } else {
1058
- const layerIndex = pixels[0] - 1
1059
- const dataIndex = (pixels[1] << 16) | (pixels[2] << 8) | pixels[3]
1060
- const layer = this.layers[layerIndex]
1061
- result = { layerIndex, configLayerIndex: layer.configLayerIndex, dataIndex, layer }
1062
- }
1063
- })
1064
-
1065
- fbo.destroy()
1066
- return result
1067
- }
1068
- }