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