gladly-plot 0.0.5 → 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.
- package/README.md +9 -2
- package/package.json +10 -11
- package/src/axes/Axis.js +320 -172
- package/src/axes/AxisLink.js +6 -2
- package/src/axes/AxisRegistry.js +116 -39
- package/src/axes/Camera.js +47 -0
- package/src/axes/ColorAxisRegistry.js +10 -2
- package/src/axes/FilterAxisRegistry.js +1 -1
- package/src/axes/TickLabelAtlas.js +99 -0
- package/src/axes/ZoomController.js +446 -124
- package/src/colorscales/ColorscaleRegistry.js +30 -10
- package/src/compute/ComputationRegistry.js +126 -184
- package/src/compute/axisFilter.js +21 -9
- package/src/compute/conv.js +64 -8
- package/src/compute/elementwise.js +72 -0
- package/src/compute/fft.js +106 -20
- package/src/compute/filter.js +105 -103
- package/src/compute/hist.js +247 -142
- package/src/compute/kde.js +64 -46
- package/src/compute/scatter2dInterpolate.js +277 -0
- package/src/compute/util.js +196 -0
- package/src/core/ComputePipeline.js +153 -0
- package/src/core/GlBase.js +141 -0
- package/src/core/Layer.js +22 -8
- package/src/core/LayerType.js +251 -92
- package/src/core/Plot.js +630 -152
- package/src/core/PlotGroup.js +204 -0
- package/src/core/ShaderQueue.js +73 -0
- package/src/data/ColumnData.js +269 -0
- package/src/data/Computation.js +95 -0
- package/src/data/Data.js +270 -0
- package/src/floats/Float.js +56 -0
- package/src/index.js +16 -4
- package/src/layers/BarsLayer.js +168 -0
- package/src/layers/ColorbarLayer.js +10 -14
- package/src/layers/ColorbarLayer2d.js +13 -24
- package/src/layers/FilterbarLayer.js +4 -3
- package/src/layers/LinesLayer.js +108 -122
- package/src/layers/PointsLayer.js +73 -69
- package/src/layers/ScatterShared.js +62 -106
- package/src/layers/TileLayer.js +20 -16
- package/src/math/mat4.js +100 -0
- package/src/core/Data.js +0 -67
- package/src/layers/HistogramLayer.js +0 -212
package/src/core/Plot.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { AXES, AXES_2D, AXIS_GEOMETRY, AxisRegistry } from "../axes/AxisRegistry.js"
|
|
2
|
+
import { Camera } from "../axes/Camera.js"
|
|
3
|
+
import { TickLabelAtlas } from "../axes/TickLabelAtlas.js"
|
|
4
|
+
import { mat4Identity, mat4Multiply } from "../math/mat4.js"
|
|
5
5
|
import { ColorAxisRegistry } from "../axes/ColorAxisRegistry.js"
|
|
6
6
|
import { FilterAxisRegistry } from "../axes/FilterAxisRegistry.js"
|
|
7
7
|
import { ZoomController } from "../axes/ZoomController.js"
|
|
@@ -9,14 +9,76 @@ import { getLayerType, getRegisteredLayerTypes } from "./LayerTypeRegistry.js"
|
|
|
9
9
|
import { getAxisQuantityKind, getScaleTypeFloat } from "../axes/AxisQuantityKindRegistry.js"
|
|
10
10
|
import { getRegisteredColorscales, getRegistered2DColorscales } from "../colorscales/ColorscaleRegistry.js"
|
|
11
11
|
import { Float } from "../floats/Float.js"
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
import { computationSchema, buildTransformSchema, getComputedData } from "../compute/ComputationRegistry.js"
|
|
13
|
+
import { DataGroup, normalizeData } from "../data/Data.js"
|
|
14
|
+
import { enqueueRegl, compileEnqueuedShaders } from "./ShaderQueue.js"
|
|
15
|
+
import { GlBase } from "./GlBase.js"
|
|
16
|
+
|
|
17
|
+
// If a single compute step (ComputedData refresh or TextureColumn refresh) takes
|
|
18
|
+
// longer than this on the CPU, yield to the browser before the next step.
|
|
19
|
+
// This prevents submitting an unbounded burst of GPU commands in one synchronous
|
|
20
|
+
// block, which can trigger the Windows TDR watchdog (~2 s GPU timeout).
|
|
21
|
+
const TDR_STEP_MS = 500
|
|
22
|
+
|
|
23
|
+
// Throttle linked-plot renders when the source plot's "blocked lag" is high.
|
|
24
|
+
// Blocked lag = max(0, RAF_wait - own_render_time): high when other plots' renders
|
|
25
|
+
// are delaying the source's frames, but near zero for fast plots (colorbars, filterbars).
|
|
26
|
+
const LINK_THROTTLE_MS = 150 // ms between renders of a throttled linked plot
|
|
27
|
+
const BLOCKED_LAG_THRESHOLD = 30 // ms: throttle kicks in above this blocked lag
|
|
28
|
+
const BLOCKED_LAG_ALPHA = 0.5 // EMA weight — reacts within ~2 samples
|
|
29
|
+
|
|
30
|
+
function buildPlotSchema(data, config) {
|
|
14
31
|
const layerTypes = getRegisteredLayerTypes()
|
|
32
|
+
// Normalise once — always a DataGroup (or null). Columns are e.g. "input.x1".
|
|
33
|
+
const wrappedData = normalizeData(data)
|
|
34
|
+
|
|
35
|
+
// Build fullSchemaData: the normalised DataGroup plus lightweight stubs for each
|
|
36
|
+
// declared transform so that layer schemas enumerate transform output columns.
|
|
37
|
+
// Stubs only need columns() — getData/getQuantityKind/getDomain return null (schema only).
|
|
38
|
+
const transforms = config?.transforms ?? []
|
|
39
|
+
let fullSchemaData = wrappedData
|
|
40
|
+
if (wrappedData && transforms.length > 0) {
|
|
41
|
+
const group = new DataGroup({})
|
|
42
|
+
group._children = { ...wrappedData._children }
|
|
43
|
+
for (const { name, transform: spec } of transforms) {
|
|
44
|
+
const entries = Object.entries(spec)
|
|
45
|
+
if (entries.length !== 1) continue
|
|
46
|
+
const [className] = entries[0]
|
|
47
|
+
const cd = getComputedData(className)
|
|
48
|
+
if (!cd) continue
|
|
49
|
+
group._children[name] = {
|
|
50
|
+
columns: () => cd.columns(),
|
|
51
|
+
getData: () => null,
|
|
52
|
+
getQuantityKind: () => null,
|
|
53
|
+
getDomain: () => null,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
fullSchemaData = group
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { '$defs': compDefs } = computationSchema(fullSchemaData)
|
|
60
|
+
|
|
61
|
+
// wrappedData is already the correctly-shaped DataGroup (columns "input.x1" etc.)
|
|
62
|
+
const { '$defs': transformDefs } = buildTransformSchema(wrappedData)
|
|
15
63
|
|
|
16
64
|
return {
|
|
17
65
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
66
|
+
$defs: { ...compDefs, ...transformDefs },
|
|
18
67
|
type: "object",
|
|
19
68
|
properties: {
|
|
69
|
+
transforms: {
|
|
70
|
+
type: "array",
|
|
71
|
+
description: "Named data transforms applied before layers. Each item is a { name, transform: { ClassName: params } } object.",
|
|
72
|
+
items: {
|
|
73
|
+
type: "object",
|
|
74
|
+
properties: {
|
|
75
|
+
name: { type: "string" },
|
|
76
|
+
transform: { '$ref': '#/$defs/transform_expression' }
|
|
77
|
+
},
|
|
78
|
+
required: ["name", "transform"],
|
|
79
|
+
additionalProperties: false
|
|
80
|
+
}
|
|
81
|
+
},
|
|
20
82
|
layers: {
|
|
21
83
|
type: "array",
|
|
22
84
|
items: {
|
|
@@ -26,7 +88,7 @@ function buildPlotSchema(data) {
|
|
|
26
88
|
return {
|
|
27
89
|
title: typeName,
|
|
28
90
|
properties: {
|
|
29
|
-
[typeName]: layerType.schema(
|
|
91
|
+
[typeName]: layerType.schema(fullSchemaData)
|
|
30
92
|
},
|
|
31
93
|
required: [typeName],
|
|
32
94
|
additionalProperties: false
|
|
@@ -127,7 +189,7 @@ function buildPlotSchema(data) {
|
|
|
127
189
|
}
|
|
128
190
|
}
|
|
129
191
|
|
|
130
|
-
export class Plot {
|
|
192
|
+
export class Plot extends GlBase {
|
|
131
193
|
// Registry of float factories keyed by type name.
|
|
132
194
|
// Each entry: { factory(parentPlot, container, opts) → widget, defaultSize(opts) → {width,height} }
|
|
133
195
|
// Populated by Colorbar.js, Filterbar.js, Colorbar2d.js at module load time.
|
|
@@ -138,6 +200,7 @@ export class Plot {
|
|
|
138
200
|
}
|
|
139
201
|
|
|
140
202
|
constructor(container, { margin } = {}) {
|
|
203
|
+
super()
|
|
141
204
|
this.container = container
|
|
142
205
|
this.margin = margin ?? { top: 60, right: 60, bottom: 60, left: 60 }
|
|
143
206
|
|
|
@@ -147,33 +210,31 @@ export class Plot {
|
|
|
147
210
|
this.canvas.style.position = 'absolute'
|
|
148
211
|
this.canvas.style.top = '0'
|
|
149
212
|
this.canvas.style.left = '0'
|
|
150
|
-
this.canvas.style.zIndex = '1'
|
|
151
213
|
container.appendChild(this.canvas)
|
|
152
214
|
|
|
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
215
|
this.currentConfig = null
|
|
163
|
-
this.
|
|
164
|
-
this.regl = null
|
|
216
|
+
this._lastRawDataArg = undefined
|
|
165
217
|
this.layers = []
|
|
166
218
|
this.axisRegistry = null
|
|
167
219
|
this.colorAxisRegistry = null
|
|
168
|
-
this.filterAxisRegistry = null
|
|
169
220
|
this._renderCallbacks = new Set()
|
|
170
221
|
this._zoomEndCallbacks = new Set()
|
|
171
222
|
this._dirty = false
|
|
172
223
|
this._rafId = null
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
this.
|
|
176
|
-
this.
|
|
224
|
+
this._rendering = false
|
|
225
|
+
this._lastRenderEnd = performance.now()
|
|
226
|
+
this._throttleTimerId = null
|
|
227
|
+
this._pendingSourcePlot = null // source plot of pending linked render (for throttle check)
|
|
228
|
+
this._blockedLag = 0 // EMA of (RAF wait - own render time); high when blocked by others
|
|
229
|
+
this._is3D = false
|
|
230
|
+
this._camera = null
|
|
231
|
+
this._tickLabelAtlas = null
|
|
232
|
+
this._axisLineCmd = null
|
|
233
|
+
this._axisBillboardCmd = null
|
|
234
|
+
|
|
235
|
+
// Compiled regl draw commands keyed by vert+frag shader source.
|
|
236
|
+
// Persists across update() calls so shader recompilation is avoided.
|
|
237
|
+
this._shaderCache = new Map()
|
|
177
238
|
|
|
178
239
|
// Auto-managed Float widgets keyed by a config-derived tag string.
|
|
179
240
|
// Covers 1D colorbars, 2D colorbars, and filterbars in a single unified Map.
|
|
@@ -182,85 +243,86 @@ export class Plot {
|
|
|
182
243
|
this._setupResizeObserver()
|
|
183
244
|
}
|
|
184
245
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
246
|
+
// Stores new config/data and re-initialises the plot. No link validation.
|
|
247
|
+
// Called directly by PlotGroup so it can validate all plots together after
|
|
248
|
+
// all have been updated.
|
|
249
|
+
async _applyUpdate({ config, data } = {}) {
|
|
250
|
+
if (config !== undefined) this.currentConfig = config
|
|
251
|
+
if (data !== undefined) {
|
|
252
|
+
this._rawData = normalizeData(data) // normalise once; kept immutable
|
|
253
|
+
}
|
|
188
254
|
|
|
189
|
-
|
|
190
|
-
if (config !== undefined) {
|
|
191
|
-
this.currentConfig = config
|
|
192
|
-
}
|
|
193
|
-
if (data !== undefined) {
|
|
194
|
-
this.currentData = data
|
|
195
|
-
}
|
|
255
|
+
if (!this.currentConfig || !this._rawData) return
|
|
196
256
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
257
|
+
const width = this.container.clientWidth
|
|
258
|
+
const height = this.container.clientHeight
|
|
259
|
+
const plotWidth = width - this.margin.left - this.margin.right
|
|
260
|
+
const plotHeight = height - this.margin.top - this.margin.bottom
|
|
200
261
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
262
|
+
// Container is hidden, not yet laid out, or too small to fit the margins.
|
|
263
|
+
// Store config/data and return; ResizeObserver will call forceUpdate() once
|
|
264
|
+
// the container gets real dimensions.
|
|
265
|
+
if (width === 0 || height === 0 || plotWidth <= 0 || plotHeight <= 0) return
|
|
205
266
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
267
|
+
this.canvas.width = width
|
|
268
|
+
this.canvas.height = height
|
|
269
|
+
|
|
270
|
+
this.width = width
|
|
271
|
+
this.height = height
|
|
272
|
+
this.plotWidth = plotWidth
|
|
273
|
+
this.plotHeight = plotHeight
|
|
212
274
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
275
|
+
this._warnedMissingDomains = false
|
|
276
|
+
await this._initialize()
|
|
277
|
+
this._syncFloats()
|
|
278
|
+
}
|
|
216
279
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
280
|
+
// Validates that all axes on this plot that are linked to axes on other plots
|
|
281
|
+
// still share the same quantity kind. Throws if any mismatch is found.
|
|
282
|
+
_validateLinks() {
|
|
283
|
+
for (const [name, axis] of this._axisCache) {
|
|
284
|
+
const qk = axis.quantityKind
|
|
285
|
+
if (!qk) continue
|
|
286
|
+
for (const other of axis._linkedAxes) {
|
|
287
|
+
const otherQk = other.quantityKind
|
|
288
|
+
if (otherQk && otherQk !== qk) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`Axis '${name}' (quantity kind '${qk}') is linked to axis '${other._name}' ` +
|
|
291
|
+
`with incompatible quantity kind '${otherQk}'. ` +
|
|
292
|
+
`Unlink the axes before changing their quantity kinds, or update both plots atomically via PlotGroup.`
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
221
298
|
|
|
222
|
-
|
|
223
|
-
|
|
299
|
+
async update({ config, data } = {}) {
|
|
300
|
+
// Skip expensive _initialize() if nothing actually changed.
|
|
301
|
+
if (config !== undefined || data !== undefined) {
|
|
302
|
+
const configSame = config === undefined || JSON.stringify(config) === JSON.stringify(this.currentConfig)
|
|
303
|
+
const dataSame = data === undefined || data === this._lastRawDataArg
|
|
304
|
+
if (configSame && dataSame) {
|
|
305
|
+
this.scheduleRender()
|
|
306
|
+
return
|
|
224
307
|
}
|
|
308
|
+
}
|
|
225
309
|
|
|
226
|
-
|
|
310
|
+
if (data !== undefined) this._lastRawDataArg = data
|
|
227
311
|
|
|
228
|
-
|
|
229
|
-
|
|
312
|
+
const previousConfig = this.currentConfig
|
|
313
|
+
const previousRawData = this._rawData
|
|
314
|
+
try {
|
|
315
|
+
await this._applyUpdate({ config, data })
|
|
316
|
+
this._validateLinks()
|
|
230
317
|
} catch (error) {
|
|
231
318
|
this.currentConfig = previousConfig
|
|
232
|
-
this.
|
|
319
|
+
this._rawData = previousRawData
|
|
233
320
|
throw error
|
|
234
321
|
}
|
|
235
322
|
}
|
|
236
323
|
|
|
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)
|
|
324
|
+
async forceUpdate() {
|
|
325
|
+
await this.update({})
|
|
264
326
|
}
|
|
265
327
|
|
|
266
328
|
getConfig() {
|
|
@@ -271,7 +333,7 @@ export class Plot {
|
|
|
271
333
|
const scale = this.axisRegistry.getScale(axisId)
|
|
272
334
|
if (scale) {
|
|
273
335
|
const [min, max] = scale.domain()
|
|
274
|
-
const qk
|
|
336
|
+
const qk = this.axisRegistry.axisQuantityKinds[axisId]
|
|
275
337
|
const qkDef = qk ? getAxisQuantityKind(qk) : {}
|
|
276
338
|
axes[axisId] = { ...qkDef, ...(axes[axisId] ?? {}), min, max }
|
|
277
339
|
}
|
|
@@ -307,38 +369,64 @@ export class Plot {
|
|
|
307
369
|
}
|
|
308
370
|
}
|
|
309
371
|
|
|
310
|
-
return { colorbars: [], ...this.currentConfig, axes}
|
|
372
|
+
return { transforms: [], colorbars: [], ...this.currentConfig, axes}
|
|
311
373
|
}
|
|
312
374
|
|
|
313
|
-
_initialize() {
|
|
314
|
-
const
|
|
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
|
-
})
|
|
375
|
+
async _initialize() {
|
|
376
|
+
const epoch = ++this._initEpoch
|
|
377
|
+
const { layers = [], axes = {}, colorbars = [], transforms = [] } = this.currentConfig
|
|
330
378
|
|
|
331
|
-
this.
|
|
379
|
+
if (!this.regl) {
|
|
380
|
+
this._initRegl(this.canvas)
|
|
381
|
+
} else {
|
|
382
|
+
// Notify regl of any canvas dimension change so its internal state stays
|
|
383
|
+
// consistent (e.g. default framebuffer size used by regl.clear).
|
|
384
|
+
this.regl.poll()
|
|
385
|
+
}
|
|
332
386
|
|
|
333
|
-
|
|
387
|
+
// Destroy GPU buffers owned by the previous layer set before rebuilding.
|
|
388
|
+
for (const layer of this.layers) {
|
|
389
|
+
for (const buf of Object.values(layer._bufferProps ?? {})) {
|
|
390
|
+
if (buf && typeof buf.destroy === 'function') buf.destroy()
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
this.layers = []
|
|
395
|
+
this._dataTransformNodes = []
|
|
396
|
+
|
|
397
|
+
// Restore original user data before applying transforms (handles re-initialization).
|
|
398
|
+
// Create a fresh shallow copy of _rawData's children so _rawData is never mutated
|
|
399
|
+
// and transform nodes from previous runs don't carry over.
|
|
400
|
+
if (this._rawData != null) {
|
|
401
|
+
const fresh = new DataGroup({})
|
|
402
|
+
fresh._children = { ...this._rawData._children }
|
|
403
|
+
this.currentData = fresh
|
|
404
|
+
}
|
|
334
405
|
|
|
335
406
|
this.axisRegistry = new AxisRegistry(this.plotWidth, this.plotHeight)
|
|
336
407
|
this.colorAxisRegistry = new ColorAxisRegistry()
|
|
337
408
|
this.filterAxisRegistry = new FilterAxisRegistry()
|
|
338
409
|
|
|
339
|
-
this.
|
|
410
|
+
await this._processTransforms(transforms, epoch)
|
|
411
|
+
if (this._initEpoch !== epoch) return
|
|
412
|
+
await this._processLayers(layers, this.currentData, epoch)
|
|
413
|
+
if (this._initEpoch !== epoch) return
|
|
340
414
|
this._setDomains(axes)
|
|
341
415
|
|
|
416
|
+
// Detect 3D mode: any axis outside the 4 standard 2D positions has a scale.
|
|
417
|
+
this._is3D = AXES.some(a => !AXES_2D.includes(a) && this.axisRegistry.getScale(a) !== null)
|
|
418
|
+
|
|
419
|
+
// Camera (recreated each _initialize so aspect ratio and 3D flag stay in sync).
|
|
420
|
+
this._camera = new Camera(this._is3D)
|
|
421
|
+
this._camera.resize(this.plotWidth, this.plotHeight)
|
|
422
|
+
|
|
423
|
+
// Shared atlas for tick and title labels.
|
|
424
|
+
if (this._tickLabelAtlas) this._tickLabelAtlas.destroy()
|
|
425
|
+
this._tickLabelAtlas = new TickLabelAtlas(this.regl)
|
|
426
|
+
|
|
427
|
+
// Compile shared axis draw commands (once per regl context; cached on Plot).
|
|
428
|
+
if (!this._axisLineCmd) this._initAxisCommands()
|
|
429
|
+
|
|
342
430
|
// Apply colorscale overrides from top-level colorbars entries. These override any
|
|
343
431
|
// per-axis colorscale from config.axes or quantity kind registry. Applying after
|
|
344
432
|
// _setDomains ensures they take effect last. For 2D colorbars both axes receive the
|
|
@@ -351,8 +439,108 @@ export class Plot {
|
|
|
351
439
|
if (entry.yAxis) this.colorAxisRegistry.ensureColorAxis(entry.yAxis, entry.colorscale)
|
|
352
440
|
}
|
|
353
441
|
|
|
354
|
-
new ZoomController(this)
|
|
355
|
-
this.
|
|
442
|
+
if (!this._zoomController) this._zoomController = new ZoomController(this)
|
|
443
|
+
this.scheduleRender()
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Compile the two regl commands shared across all axis rendering.
|
|
447
|
+
// Called once after the first regl context is created.
|
|
448
|
+
_initAxisCommands() {
|
|
449
|
+
const regl = this.regl
|
|
450
|
+
|
|
451
|
+
// Axis lines and tick marks (simple 3D line segments).
|
|
452
|
+
this._axisLineCmd = regl({
|
|
453
|
+
vert: `#version 300 es
|
|
454
|
+
precision highp float;
|
|
455
|
+
in vec3 a_position;
|
|
456
|
+
uniform mat4 u_mvp;
|
|
457
|
+
void main() { gl_Position = u_mvp * vec4(a_position, 1.0); }`,
|
|
458
|
+
frag: `#version 300 es
|
|
459
|
+
precision highp float;
|
|
460
|
+
uniform vec4 u_color;
|
|
461
|
+
out vec4 fragColor;
|
|
462
|
+
void main() { fragColor = u_color; }`,
|
|
463
|
+
attributes: { a_position: regl.prop('positions') },
|
|
464
|
+
uniforms: {
|
|
465
|
+
u_mvp: regl.prop('mvp'),
|
|
466
|
+
u_color: regl.prop('color'),
|
|
467
|
+
},
|
|
468
|
+
primitive: 'lines',
|
|
469
|
+
count: regl.prop('count'),
|
|
470
|
+
viewport: regl.prop('viewport'),
|
|
471
|
+
depth: {
|
|
472
|
+
enable: regl.prop('depthEnable'),
|
|
473
|
+
mask: true,
|
|
474
|
+
},
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
// Billboard quads for tick labels and axis titles.
|
|
478
|
+
// a_anchor: vec3 — label centre in model space
|
|
479
|
+
// a_offset_px: vec2 — corner offset in HTML pixels (x right, y down)
|
|
480
|
+
// a_uv: vec2 — atlas UV (v=0 = canvas top)
|
|
481
|
+
this._axisBillboardCmd = regl({
|
|
482
|
+
vert: `#version 300 es
|
|
483
|
+
precision highp float;
|
|
484
|
+
in vec3 a_anchor;
|
|
485
|
+
in vec2 a_offset_px;
|
|
486
|
+
in vec2 a_uv;
|
|
487
|
+
uniform mat4 u_mvp;
|
|
488
|
+
uniform vec2 u_canvas_size;
|
|
489
|
+
out vec2 v_uv;
|
|
490
|
+
void main() {
|
|
491
|
+
vec4 clip = u_mvp * vec4(a_anchor, 1.0);
|
|
492
|
+
// Project anchor from NDC to HTML-pixel space (x right, y down).
|
|
493
|
+
vec2 ndc_anchor = clip.xy / clip.w;
|
|
494
|
+
vec2 anchor_px = vec2(
|
|
495
|
+
ndc_anchor.x * 0.5 + 0.5,
|
|
496
|
+
-ndc_anchor.y * 0.5 + 0.5) * u_canvas_size;
|
|
497
|
+
// a_offset_px is in HTML pixels (x right, y down).
|
|
498
|
+
// abs(a_offset_px) = (hw, hh) for every corner; top-left = anchor - (hw, hh).
|
|
499
|
+
// Snap the top-left corner to an integer pixel with floor() so that
|
|
500
|
+
// each pixel maps to exactly one atlas texel and the label is never
|
|
501
|
+
// shifted right by the round-half-up behaviour of round().
|
|
502
|
+
vec2 hw_vec = abs(a_offset_px);
|
|
503
|
+
vec2 tl_px = floor(anchor_px - hw_vec); // snap top-left (always left)
|
|
504
|
+
vec2 vert_px = tl_px + hw_vec + a_offset_px; // reconstruct this corner
|
|
505
|
+
// Convert HTML pixels back to NDC.
|
|
506
|
+
vec2 ndc = vec2(
|
|
507
|
+
vert_px.x / u_canvas_size.x * 2.0 - 1.0,
|
|
508
|
+
-(vert_px.y / u_canvas_size.y * 2.0 - 1.0));
|
|
509
|
+
gl_Position = vec4(ndc, clip.z / clip.w, 1.0);
|
|
510
|
+
v_uv = a_uv;
|
|
511
|
+
}`,
|
|
512
|
+
frag: `#version 300 es
|
|
513
|
+
precision highp float;
|
|
514
|
+
uniform sampler2D u_atlas;
|
|
515
|
+
in vec2 v_uv;
|
|
516
|
+
out vec4 fragColor;
|
|
517
|
+
void main() {
|
|
518
|
+
ivec2 tc = ivec2(v_uv * vec2(textureSize(u_atlas, 0)));
|
|
519
|
+
fragColor = texelFetch(u_atlas, tc, 0);
|
|
520
|
+
if (fragColor.a < 0.05) discard;
|
|
521
|
+
}`,
|
|
522
|
+
attributes: {
|
|
523
|
+
a_anchor: regl.prop('anchors'),
|
|
524
|
+
a_offset_px: regl.prop('offsetsPx'),
|
|
525
|
+
a_uv: regl.prop('uvs'),
|
|
526
|
+
},
|
|
527
|
+
uniforms: {
|
|
528
|
+
u_mvp: regl.prop('mvp'),
|
|
529
|
+
u_canvas_size: regl.prop('canvasSize'),
|
|
530
|
+
u_atlas: regl.prop('atlas'),
|
|
531
|
+
},
|
|
532
|
+
primitive: 'triangles',
|
|
533
|
+
count: regl.prop('count'),
|
|
534
|
+
viewport: regl.prop('viewport'),
|
|
535
|
+
depth: {
|
|
536
|
+
enable: regl.prop('depthEnable'),
|
|
537
|
+
mask: false, // depth test but don't write — labels don't occlude each other
|
|
538
|
+
},
|
|
539
|
+
blend: {
|
|
540
|
+
enable: true,
|
|
541
|
+
func: { srcRGB: 'src alpha', dstRGB: 'one minus src alpha', srcAlpha: 0, dstAlpha: 1 },
|
|
542
|
+
},
|
|
543
|
+
})
|
|
356
544
|
}
|
|
357
545
|
|
|
358
546
|
_setupResizeObserver() {
|
|
@@ -361,11 +549,23 @@ export class Plot {
|
|
|
361
549
|
// Defer to next animation frame so the ResizeObserver callback exits
|
|
362
550
|
// before any DOM/layout changes happen, avoiding the "loop completed
|
|
363
551
|
// with undelivered notifications" browser error.
|
|
364
|
-
requestAnimationFrame(() =>
|
|
552
|
+
requestAnimationFrame(async () => {
|
|
553
|
+
try {
|
|
554
|
+
await this.forceUpdate()
|
|
555
|
+
} catch (e) {
|
|
556
|
+
console.error('[gladly] Error during resize-triggered update():', e)
|
|
557
|
+
}
|
|
558
|
+
})
|
|
365
559
|
})
|
|
366
560
|
this.resizeObserver.observe(this.container)
|
|
367
561
|
} else {
|
|
368
|
-
this._resizeHandler = () =>
|
|
562
|
+
this._resizeHandler = async () => {
|
|
563
|
+
try {
|
|
564
|
+
await this.forceUpdate()
|
|
565
|
+
} catch (e) {
|
|
566
|
+
console.error('[gladly] Error during resize-triggered update():', e)
|
|
567
|
+
}
|
|
568
|
+
}
|
|
369
569
|
window.addEventListener('resize', this._resizeHandler)
|
|
370
570
|
}
|
|
371
571
|
}
|
|
@@ -373,7 +573,7 @@ export class Plot {
|
|
|
373
573
|
// Returns the quantity kind for any axis ID (spatial or color axis).
|
|
374
574
|
// For color axes, the axis ID IS the quantity kind.
|
|
375
575
|
getAxisQuantityKind(axisId) {
|
|
376
|
-
if (
|
|
576
|
+
if (Object.prototype.hasOwnProperty.call(AXIS_GEOMETRY, axisId)) {
|
|
377
577
|
return this.axisRegistry ? this.axisRegistry.axisQuantityKinds[axisId] : null
|
|
378
578
|
}
|
|
379
579
|
return axisId
|
|
@@ -381,7 +581,7 @@ export class Plot {
|
|
|
381
581
|
|
|
382
582
|
// Unified domain getter for spatial, color, and filter axes.
|
|
383
583
|
getAxisDomain(axisId) {
|
|
384
|
-
if (
|
|
584
|
+
if (Object.prototype.hasOwnProperty.call(AXIS_GEOMETRY, axisId)) {
|
|
385
585
|
const scale = this.axisRegistry?.getScale(axisId)
|
|
386
586
|
return scale ? scale.domain() : null
|
|
387
587
|
}
|
|
@@ -395,9 +595,16 @@ export class Plot {
|
|
|
395
595
|
|
|
396
596
|
// Unified domain setter for spatial, color, and filter axes.
|
|
397
597
|
setAxisDomain(axisId, domain) {
|
|
398
|
-
if (
|
|
598
|
+
if (Object.prototype.hasOwnProperty.call(AXIS_GEOMETRY, axisId)) {
|
|
399
599
|
const scale = this.axisRegistry?.getScale(axisId)
|
|
400
|
-
if (scale)
|
|
600
|
+
if (scale) {
|
|
601
|
+
scale.domain(domain)
|
|
602
|
+
// Keep currentConfig in sync so update() skips _initialize() when only domains changed.
|
|
603
|
+
if (this.currentConfig) {
|
|
604
|
+
const axes = this.currentConfig.axes ?? {}
|
|
605
|
+
this.currentConfig = { ...this.currentConfig, axes: { ...axes, [axisId]: { ...(axes[axisId] ?? {}), min: domain[0], max: domain[1] } } }
|
|
606
|
+
}
|
|
607
|
+
}
|
|
401
608
|
} else if (this.colorAxisRegistry?.hasAxis(axisId)) {
|
|
402
609
|
this.colorAxisRegistry.setRange(axisId, domain[0], domain[1])
|
|
403
610
|
} else if (this.filterAxisRegistry?.hasAxis(axisId)) {
|
|
@@ -500,16 +707,24 @@ export class Plot {
|
|
|
500
707
|
this._rafId = null
|
|
501
708
|
}
|
|
502
709
|
|
|
710
|
+
if (this._tickLabelAtlas) {
|
|
711
|
+
this._tickLabelAtlas.destroy()
|
|
712
|
+
this._tickLabelAtlas = null
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
this._shaderCache.clear()
|
|
716
|
+
|
|
503
717
|
if (this.regl) {
|
|
504
718
|
this.regl.destroy()
|
|
719
|
+
this.regl = null
|
|
505
720
|
}
|
|
506
721
|
|
|
507
722
|
this._renderCallbacks.clear()
|
|
508
723
|
this.canvas.remove()
|
|
509
|
-
this.svg.remove()
|
|
510
724
|
}
|
|
511
725
|
|
|
512
|
-
_processLayers(layersConfig, data) {
|
|
726
|
+
async _processLayers(layersConfig, data, epoch) {
|
|
727
|
+
const TDR_STEP_MS = 500
|
|
513
728
|
for (let configLayerIndex = 0; configLayerIndex < layersConfig.length; configLayerIndex++) {
|
|
514
729
|
const layerSpec = layersConfig[configLayerIndex]
|
|
515
730
|
const entries = Object.entries(layerSpec)
|
|
@@ -519,6 +734,7 @@ export class Plot {
|
|
|
519
734
|
|
|
520
735
|
const [layerTypeName, parameters] = entries[0]
|
|
521
736
|
const layerType = getLayerType(layerTypeName)
|
|
737
|
+
if (!layerType) throw new Error(`Unknown layer type '${layerTypeName}'`)
|
|
522
738
|
|
|
523
739
|
// Resolve axis config once per layer spec for registration (independent of draw call count).
|
|
524
740
|
const ac = layerType.resolveAxisConfig(parameters, data)
|
|
@@ -528,24 +744,112 @@ export class Plot {
|
|
|
528
744
|
// Pass any scale override from config (e.g. "log") so the D3 scale is created correctly.
|
|
529
745
|
if (ac.xAxis) this.axisRegistry.ensureAxis(ac.xAxis, ac.xAxisQuantityKind, axesConfig[ac.xAxis]?.scale ?? axesConfig[ac.xAxisQuantityKind]?.scale)
|
|
530
746
|
if (ac.yAxis) this.axisRegistry.ensureAxis(ac.yAxis, ac.yAxisQuantityKind, axesConfig[ac.yAxis]?.scale ?? axesConfig[ac.yAxisQuantityKind]?.scale)
|
|
747
|
+
if (ac.zAxis) this.axisRegistry.ensureAxis(ac.zAxis, ac.zAxisQuantityKind, axesConfig[ac.zAxis]?.scale ?? axesConfig[ac.zAxisQuantityKind]?.scale)
|
|
531
748
|
|
|
532
749
|
// Register color axes (colorscale comes from config or quantity kind registry, not from here)
|
|
533
|
-
for (const quantityKind of ac.colorAxisQuantityKinds) {
|
|
750
|
+
for (const quantityKind of Object.values(ac.colorAxisQuantityKinds)) {
|
|
534
751
|
this.colorAxisRegistry.ensureColorAxis(quantityKind)
|
|
535
752
|
}
|
|
536
753
|
|
|
537
754
|
// Register filter axes
|
|
538
|
-
for (const quantityKind of ac.filterAxisQuantityKinds) {
|
|
755
|
+
for (const quantityKind of Object.values(ac.filterAxisQuantityKinds)) {
|
|
539
756
|
this.filterAxisRegistry.ensureFilterAxis(quantityKind)
|
|
540
757
|
}
|
|
541
758
|
|
|
542
759
|
// Create one draw command per GPU config returned by the layer type.
|
|
543
|
-
|
|
760
|
+
let gpuLayers
|
|
761
|
+
try {
|
|
762
|
+
gpuLayers = await layerType.createLayer(this.regl, parameters, data, this)
|
|
763
|
+
} catch (e) {
|
|
764
|
+
throw new Error(`Layer '${layerTypeName}' (index ${configLayerIndex}) failed to create: ${e.message}`, { cause: e })
|
|
765
|
+
}
|
|
766
|
+
if (this._initEpoch !== epoch) return
|
|
767
|
+
for (const layer of gpuLayers) {
|
|
544
768
|
layer.configLayerIndex = configLayerIndex
|
|
545
|
-
|
|
769
|
+
const stepStart = performance.now()
|
|
770
|
+
try {
|
|
771
|
+
layer.draw = await this._compileLayerDraw(layer)
|
|
772
|
+
} catch (e) {
|
|
773
|
+
throw new Error(`Layer '${layerTypeName}' (index ${configLayerIndex}) failed to build draw command: ${e.message}`, { cause: e })
|
|
774
|
+
}
|
|
775
|
+
if (this._initEpoch !== epoch) return
|
|
776
|
+
if (performance.now() - stepStart > TDR_STEP_MS)
|
|
777
|
+
await new Promise(r => requestAnimationFrame(r))
|
|
778
|
+
if (this._initEpoch !== epoch) return
|
|
546
779
|
this.layers.push(layer)
|
|
547
780
|
}
|
|
548
781
|
}
|
|
782
|
+
if (this._initEpoch !== epoch) return
|
|
783
|
+
compileEnqueuedShaders(this.regl)
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async _compileLayerDraw(layer) {
|
|
787
|
+
const drawConfig = await layer.type.createDrawCommand(this.regl, layer, this)
|
|
788
|
+
|
|
789
|
+
// Layer types that fully override createDrawCommand (e.g. TileLayer) return
|
|
790
|
+
// a ready-to-call function instead of a plain drawConfig object. Pass through.
|
|
791
|
+
if (typeof drawConfig === 'function') return drawConfig
|
|
792
|
+
|
|
793
|
+
const shaderKey = drawConfig.vert + '\0' + drawConfig.frag
|
|
794
|
+
|
|
795
|
+
if (!this._shaderCache.has(shaderKey)) {
|
|
796
|
+
// Build a version of the draw config where all layer-specific data
|
|
797
|
+
// (attribute buffers and texture closures) is replaced with regl.prop()
|
|
798
|
+
// references. This compiled command can then be reused for any layer
|
|
799
|
+
// that produces the same shader source, regardless of its data.
|
|
800
|
+
const propAttrs = {}
|
|
801
|
+
for (const [key, val] of Object.entries(drawConfig.attributes)) {
|
|
802
|
+
const rawBuf = val?.buffer instanceof Float32Array ? val.buffer
|
|
803
|
+
: val instanceof Float32Array ? val : null
|
|
804
|
+
if (rawBuf !== null) {
|
|
805
|
+
const propKey = `attr_${key}`
|
|
806
|
+
const divisor = val?.divisor
|
|
807
|
+
propAttrs[key] = divisor !== undefined
|
|
808
|
+
? { buffer: this.regl.prop(propKey), divisor }
|
|
809
|
+
: this.regl.prop(propKey)
|
|
810
|
+
} else {
|
|
811
|
+
propAttrs[key] = val
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
const propUniforms = {}
|
|
816
|
+
for (const [key, val] of Object.entries(drawConfig.uniforms)) {
|
|
817
|
+
propUniforms[key] = typeof val === 'function' ? this.regl.prop(key) : val
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const propConfig = { ...drawConfig, attributes: propAttrs, uniforms: propUniforms }
|
|
821
|
+
this._shaderCache.set(shaderKey, enqueueRegl(this.regl, propConfig))
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const cmd = this._shaderCache.get(shaderKey)
|
|
825
|
+
|
|
826
|
+
// Extract per-layer data: GPU buffers for Float32Array attributes,
|
|
827
|
+
// and texture closures for sampler uniforms.
|
|
828
|
+
const bufferProps = {}
|
|
829
|
+
for (const [key, val] of Object.entries(drawConfig.attributes)) {
|
|
830
|
+
const rawBuf = val?.buffer instanceof Float32Array ? val.buffer
|
|
831
|
+
: val instanceof Float32Array ? val : null
|
|
832
|
+
if (rawBuf !== null) {
|
|
833
|
+
bufferProps[`attr_${key}`] = this.regl.buffer(rawBuf)
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const textureClosures = {}
|
|
838
|
+
for (const [key, val] of Object.entries(drawConfig.uniforms)) {
|
|
839
|
+
if (typeof val === 'function') textureClosures[key] = val
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
layer._bufferProps = bufferProps
|
|
843
|
+
layer._textureClosures = textureClosures
|
|
844
|
+
|
|
845
|
+
return (runtimeProps) => {
|
|
846
|
+
// Resolve texture closures at draw time so live texture swaps are picked up.
|
|
847
|
+
const textureProps = {}
|
|
848
|
+
for (const [key, fn] of Object.entries(textureClosures)) {
|
|
849
|
+
textureProps[key] = fn()
|
|
850
|
+
}
|
|
851
|
+
cmd({ ...bufferProps, ...textureProps, ...runtimeProps })
|
|
852
|
+
}
|
|
549
853
|
}
|
|
550
854
|
|
|
551
855
|
_setDomains(axesOverrides) {
|
|
@@ -561,26 +865,99 @@ export class Plot {
|
|
|
561
865
|
return getScaleTypeFloat(quantityKind, this.currentConfig?.axes)
|
|
562
866
|
}
|
|
563
867
|
|
|
564
|
-
static schema(data) {
|
|
565
|
-
return buildPlotSchema(data)
|
|
868
|
+
static schema(data, config) {
|
|
869
|
+
return buildPlotSchema(data, config)
|
|
566
870
|
}
|
|
567
871
|
|
|
568
|
-
scheduleRender() {
|
|
872
|
+
scheduleRender(sourcePlot = null) {
|
|
873
|
+
if (!this.regl) return
|
|
569
874
|
this._dirty = true
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
875
|
+
// Track source plot — sticky until RAF is committed so timer/vsync re-entries
|
|
876
|
+
// can still check whether throttling is warranted.
|
|
877
|
+
if (sourcePlot) this._pendingSourcePlot = sourcePlot
|
|
878
|
+
if (this._rafId !== null || this._rendering || this._throttleTimerId !== null) return
|
|
879
|
+
|
|
880
|
+
// Throttle only when the source plot is being blocked by slow linked renders.
|
|
881
|
+
// blocked lag = RAF wait − own render time; near zero for fast plots (colorbars,
|
|
882
|
+
// filterbars) so those never throttle this plot's renders.
|
|
883
|
+
const source = this._pendingSourcePlot
|
|
884
|
+
if (source && source._blockedLag > BLOCKED_LAG_THRESHOLD) {
|
|
885
|
+
const delay = LINK_THROTTLE_MS - (performance.now() - this._lastRenderEnd)
|
|
886
|
+
if (delay > 0) {
|
|
887
|
+
this._throttleTimerId = setTimeout(() => {
|
|
888
|
+
this._throttleTimerId = null
|
|
889
|
+
if (this._dirty) this.scheduleRender()
|
|
890
|
+
}, delay)
|
|
891
|
+
return
|
|
892
|
+
}
|
|
578
893
|
}
|
|
894
|
+
this._pendingSourcePlot = null // commit: reset now that we're queuing the RAF
|
|
895
|
+
|
|
896
|
+
const schedTime = performance.now()
|
|
897
|
+
const _st0 = schedTime
|
|
898
|
+
setTimeout(() => {
|
|
899
|
+
const _stLag = performance.now() - _st0
|
|
900
|
+
if (_stLag > 20) console.warn(`[gladly] setTimeout(0) lag ${_stLag.toFixed(0)}ms`)
|
|
901
|
+
}, 0)
|
|
902
|
+
this._rafId = requestAnimationFrame(async (rafTime) => {
|
|
903
|
+
this._rafId = null
|
|
904
|
+
const now = performance.now()
|
|
905
|
+
const lag = now - schedTime
|
|
906
|
+
const postVsync = now - rafTime // time from vsync to our callback executing
|
|
907
|
+
if (lag > 50) console.warn(`[gladly] RAF lag ${lag.toFixed(0)}ms (vsync-to-callback: ${postVsync.toFixed(1)}ms)`)
|
|
908
|
+
if (this._dirty) {
|
|
909
|
+
this._dirty = false
|
|
910
|
+
const t0 = performance.now()
|
|
911
|
+
this._rendering = true
|
|
912
|
+
try {
|
|
913
|
+
await this.render()
|
|
914
|
+
} catch (e) {
|
|
915
|
+
console.error('[gladly] Error during render():', e)
|
|
916
|
+
} finally {
|
|
917
|
+
this._rendering = false
|
|
918
|
+
}
|
|
919
|
+
const dt = performance.now() - t0
|
|
920
|
+
if (dt > 10) console.warn(`[gladly] render ${dt.toFixed(0)}ms`)
|
|
921
|
+
// Update blocked-lag EMA: how long were we waiting for others vs rendering ourselves?
|
|
922
|
+
const blockedLag = Math.max(0, lag - dt)
|
|
923
|
+
this._blockedLag = this._blockedLag * (1 - BLOCKED_LAG_ALPHA) + blockedLag * BLOCKED_LAG_ALPHA
|
|
924
|
+
this._lastRenderEnd = performance.now()
|
|
925
|
+
// After submitting GPU work, hold _rafId so no new render can be queued
|
|
926
|
+
// until the compositor is ready for the next frame (browser waits for GPU).
|
|
927
|
+
// Any state changes that arrive during GPU execution are captured then.
|
|
928
|
+
this._rafId = requestAnimationFrame(() => {
|
|
929
|
+
this._rafId = null
|
|
930
|
+
if (this._dirty) this.scheduleRender()
|
|
931
|
+
})
|
|
932
|
+
}
|
|
933
|
+
})
|
|
579
934
|
}
|
|
580
935
|
|
|
581
|
-
render() {
|
|
936
|
+
async render() {
|
|
582
937
|
this._dirty = false
|
|
583
|
-
|
|
938
|
+
|
|
939
|
+
// Validate axis domains once per render (warn only when domain is still
|
|
940
|
+
// the D3 default, i.e. was never set — indicates a missing ensureAxis call)
|
|
941
|
+
if (!this._warnedMissingDomains && this.axisRegistry) {
|
|
942
|
+
for (const axisId of AXES) {
|
|
943
|
+
const scale = this.axisRegistry.getScale(axisId)
|
|
944
|
+
if (!scale) continue
|
|
945
|
+
const [lo, hi] = scale.domain()
|
|
946
|
+
if (!isFinite(lo) || !isFinite(hi)) {
|
|
947
|
+
console.warn(
|
|
948
|
+
`[gladly] Axis '${axisId}': domain [${lo}, ${hi}] is non-finite at render time. ` +
|
|
949
|
+
`All data on this axis will be invisible.`
|
|
950
|
+
)
|
|
951
|
+
this._warnedMissingDomains = true
|
|
952
|
+
} else if (lo === hi) {
|
|
953
|
+
console.warn(
|
|
954
|
+
`[gladly] Axis '${axisId}': domain is degenerate [${lo}] at render time. ` +
|
|
955
|
+
`Data on this axis will collapse to a single line.`
|
|
956
|
+
)
|
|
957
|
+
this._warnedMissingDomains = true
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
584
961
|
const viewport = {
|
|
585
962
|
x: this.margin.left,
|
|
586
963
|
y: this.margin.bottom,
|
|
@@ -589,18 +966,69 @@ export class Plot {
|
|
|
589
966
|
}
|
|
590
967
|
const axesConfig = this.currentConfig?.axes
|
|
591
968
|
|
|
969
|
+
// Camera MVP for data layers (maps unit cube to NDC within the plot-area viewport).
|
|
970
|
+
const cameraMvp = this._camera ? this._camera.getMVP() : mat4Identity()
|
|
971
|
+
|
|
972
|
+
// Axis MVP maps the unit cube to full-canvas NDC so axis lines and labels
|
|
973
|
+
// can extend into the margin area outside the plot viewport.
|
|
974
|
+
// sx = plotWidth/width, sy = plotHeight/height
|
|
975
|
+
// cx = (marginLeft - marginRight) / width (NDC centre offset x)
|
|
976
|
+
// cy = (marginBottom - marginTop) / height
|
|
977
|
+
const sx = this.plotWidth / this.width
|
|
978
|
+
const sy = this.plotHeight / this.height
|
|
979
|
+
const cx = (this.margin.left - this.margin.right) / this.width
|
|
980
|
+
const cy = (this.margin.bottom - this.margin.top) / this.height
|
|
981
|
+
// Column-major viewport scale+translate matrix
|
|
982
|
+
const Mvp = new Float32Array([
|
|
983
|
+
sx, 0, 0, 0,
|
|
984
|
+
0, sy, 0, 0,
|
|
985
|
+
0, 0, 1, 0,
|
|
986
|
+
cx, cy, 0, 1,
|
|
987
|
+
])
|
|
988
|
+
const axisMvp = mat4Multiply(Mvp, cameraMvp)
|
|
989
|
+
|
|
990
|
+
// Phase 1 — async compute: refresh all transforms and data columns before touching the canvas.
|
|
991
|
+
// Any TDR-yield RAF pauses happen here while the previous frame is still visible.
|
|
992
|
+
// Yield between steps when a step is expensive to avoid triggering the Windows TDR watchdog.
|
|
993
|
+
for (const node of this._dataTransformNodes) {
|
|
994
|
+
const stepStart = performance.now()
|
|
995
|
+
try {
|
|
996
|
+
await node.refreshIfNeeded(this)
|
|
997
|
+
} catch (e) {
|
|
998
|
+
throw new Error(`Transform refresh failed: ${e.message}`, { cause: e })
|
|
999
|
+
}
|
|
1000
|
+
if (performance.now() - stepStart > TDR_STEP_MS)
|
|
1001
|
+
await new Promise(r => requestAnimationFrame(r))
|
|
1002
|
+
}
|
|
1003
|
+
|
|
592
1004
|
for (const layer of this.layers) {
|
|
593
|
-
|
|
594
|
-
|
|
1005
|
+
for (const col of layer._dataColumns ?? []) {
|
|
1006
|
+
const stepStart = performance.now()
|
|
1007
|
+
await col.refresh(this)
|
|
1008
|
+
if (performance.now() - stepStart > TDR_STEP_MS)
|
|
1009
|
+
await new Promise(r => requestAnimationFrame(r))
|
|
595
1010
|
}
|
|
1011
|
+
}
|
|
596
1012
|
|
|
1013
|
+
// Phase 2 — synchronous draw: all data is ready; clear once then draw every layer.
|
|
1014
|
+
// No async yields from here to the end of render() so the canvas is never blank mid-frame.
|
|
1015
|
+
this.regl.clear({ color: [1,1,1,1], depth:1 })
|
|
1016
|
+
|
|
1017
|
+
for (const layer of this.layers) {
|
|
597
1018
|
const xIsLog = layer.xAxis ? this.axisRegistry.isLogScale(layer.xAxis) : false
|
|
598
1019
|
const yIsLog = layer.yAxis ? this.axisRegistry.isLogScale(layer.yAxis) : false
|
|
1020
|
+
const zIsLog = layer.zAxis ? this.axisRegistry.isLogScale(layer.zAxis) : false
|
|
1021
|
+
const zScale = layer.zAxis ? this.axisRegistry.getScale(layer.zAxis) : null
|
|
1022
|
+
const zDomain = zScale ? zScale.domain() : [0, 1]
|
|
599
1023
|
const props = {
|
|
600
1024
|
xDomain: layer.xAxis ? (this.axisRegistry.getScale(layer.xAxis)?.domain() ?? [0, 1]) : [0, 1],
|
|
601
1025
|
yDomain: layer.yAxis ? (this.axisRegistry.getScale(layer.yAxis)?.domain() ?? [0, 1]) : [0, 1],
|
|
1026
|
+
zDomain,
|
|
602
1027
|
xScaleType: xIsLog ? 1.0 : 0.0,
|
|
603
1028
|
yScaleType: yIsLog ? 1.0 : 0.0,
|
|
1029
|
+
zScaleType: zIsLog ? 1.0 : 0.0,
|
|
1030
|
+
u_is3D: this._is3D ? 1.0 : 0.0,
|
|
1031
|
+
u_mvp: cameraMvp,
|
|
604
1032
|
viewport: viewport,
|
|
605
1033
|
count: layer.vertexCount ?? Object.values(layer.attributes).find(v => v instanceof Float32Array)?.length ?? 0,
|
|
606
1034
|
u_pickingMode: 0.0,
|
|
@@ -611,14 +1039,27 @@ export class Plot {
|
|
|
611
1039
|
props.instances = layer.instanceCount
|
|
612
1040
|
}
|
|
613
1041
|
|
|
614
|
-
|
|
1042
|
+
// Warn once if this draw call will produce no geometry
|
|
1043
|
+
if (!layer._warnedZeroCount && !layer.type?.suppressWarnings) {
|
|
1044
|
+
const drawCount = props.instances ?? props.count
|
|
1045
|
+
if (drawCount === 0) {
|
|
1046
|
+
console.warn(
|
|
1047
|
+
`[gladly] Layer '${layer.type?.name ?? 'unknown'}' (config index ${layer.configLayerIndex}): ` +
|
|
1048
|
+
`draw count is 0 — nothing will be rendered`
|
|
1049
|
+
)
|
|
1050
|
+
layer._warnedZeroCount = true
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
for (const qk of Object.values(layer.colorAxes)) {
|
|
615
1055
|
props[`colorscale_${qk}`] = this.colorAxisRegistry.getColorscaleIndex(qk)
|
|
616
1056
|
const range = this.colorAxisRegistry.getRange(qk)
|
|
617
1057
|
props[`color_range_${qk}`] = range ?? [0, 1]
|
|
618
1058
|
props[`color_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
|
|
1059
|
+
props[`alpha_blend_${qk}`] = this.colorAxisRegistry.getAlphaBlend(qk)
|
|
619
1060
|
}
|
|
620
1061
|
|
|
621
|
-
for (const qk of layer.filterAxes) {
|
|
1062
|
+
for (const qk of Object.values(layer.filterAxes)) {
|
|
622
1063
|
props[`filter_range_${qk}`] = this.filterAxisRegistry.getRangeUniform(qk)
|
|
623
1064
|
props[`filter_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
|
|
624
1065
|
}
|
|
@@ -626,7 +1067,24 @@ export class Plot {
|
|
|
626
1067
|
layer.draw(props)
|
|
627
1068
|
}
|
|
628
1069
|
|
|
629
|
-
|
|
1070
|
+
// Render all registered spatial axes via WebGL (axis lines + tick marks + labels).
|
|
1071
|
+
if (this._axisLineCmd && this._axisBillboardCmd && this._tickLabelAtlas) {
|
|
1072
|
+
// Pre-pass: mark all labels needed this frame, then flush the atlas once.
|
|
1073
|
+
for (const axisId of AXES) {
|
|
1074
|
+
if (!this.axisRegistry.getScale(axisId)) continue
|
|
1075
|
+
this._getAxis(axisId).prepareAtlas(this._tickLabelAtlas, axisMvp, this.width, this.height)
|
|
1076
|
+
}
|
|
1077
|
+
this._tickLabelAtlas.flush()
|
|
1078
|
+
|
|
1079
|
+
for (const axisId of AXES) {
|
|
1080
|
+
if (!this.axisRegistry.getScale(axisId)) continue
|
|
1081
|
+
this._getAxis(axisId).render(
|
|
1082
|
+
this.regl, axisMvp, this.width, this.height,
|
|
1083
|
+
this._is3D, this._tickLabelAtlas,
|
|
1084
|
+
this._axisLineCmd, this._axisBillboardCmd,
|
|
1085
|
+
)
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
630
1088
|
for (const cb of this._renderCallbacks) cb()
|
|
631
1089
|
}
|
|
632
1090
|
|
|
@@ -663,19 +1121,33 @@ export class Plot {
|
|
|
663
1121
|
return { remove: () => window.removeEventListener(eventType, handler, { capture: true }) }
|
|
664
1122
|
}
|
|
665
1123
|
|
|
666
|
-
pick(x, y) {
|
|
1124
|
+
async pick(x, y) {
|
|
667
1125
|
if (!this.regl || !this.layers.length) return null
|
|
668
1126
|
|
|
1127
|
+
const glX = Math.round(x)
|
|
1128
|
+
const glY = this.height - Math.round(y) - 1
|
|
1129
|
+
|
|
1130
|
+
if (glX < 0 || glX >= this.width || glY < 0 || glY >= this.height) return null
|
|
1131
|
+
|
|
669
1132
|
const fbo = this.regl.framebuffer({
|
|
670
1133
|
width: this.width, height: this.height,
|
|
671
1134
|
colorFormat: 'rgba', colorType: 'uint8', depth: false,
|
|
672
1135
|
})
|
|
673
1136
|
|
|
674
|
-
const glX = Math.round(x)
|
|
675
|
-
const glY = this.height - Math.round(y) - 1
|
|
676
1137
|
const axesConfig = this.currentConfig?.axes
|
|
677
1138
|
|
|
1139
|
+
// Refresh transform nodes before picking (same as render)
|
|
1140
|
+
for (const node of this._dataTransformNodes) {
|
|
1141
|
+
await node.refreshIfNeeded(this)
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
// Refresh data columns outside the synchronous regl() callback
|
|
1145
|
+
for (const layer of this.layers) {
|
|
1146
|
+
for (const col of layer._dataColumns ?? []) await col.refresh(this)
|
|
1147
|
+
}
|
|
1148
|
+
|
|
678
1149
|
let result = null
|
|
1150
|
+
try {
|
|
679
1151
|
this.regl({ framebuffer: fbo })(() => {
|
|
680
1152
|
this.regl.clear({ color: [0, 0, 0, 0] })
|
|
681
1153
|
const viewport = {
|
|
@@ -684,30 +1156,35 @@ export class Plot {
|
|
|
684
1156
|
}
|
|
685
1157
|
for (let i = 0; i < this.layers.length; i++) {
|
|
686
1158
|
const layer = this.layers[i]
|
|
687
|
-
if (layer._axisUpdaters) {
|
|
688
|
-
for (const updater of layer._axisUpdaters) updater.refreshIfNeeded(this)
|
|
689
|
-
}
|
|
690
1159
|
|
|
691
1160
|
const xIsLog = layer.xAxis ? this.axisRegistry.isLogScale(layer.xAxis) : false
|
|
692
1161
|
const yIsLog = layer.yAxis ? this.axisRegistry.isLogScale(layer.yAxis) : false
|
|
1162
|
+
const zIsLog = layer.zAxis ? this.axisRegistry.isLogScale(layer.zAxis) : false
|
|
1163
|
+
const zScale = layer.zAxis ? this.axisRegistry.getScale(layer.zAxis) : null
|
|
1164
|
+
const camMvp = this._camera ? this._camera.getMVP() : mat4Identity()
|
|
693
1165
|
const props = {
|
|
694
1166
|
xDomain: layer.xAxis ? (this.axisRegistry.getScale(layer.xAxis)?.domain() ?? [0, 1]) : [0, 1],
|
|
695
1167
|
yDomain: layer.yAxis ? (this.axisRegistry.getScale(layer.yAxis)?.domain() ?? [0, 1]) : [0, 1],
|
|
1168
|
+
zDomain: zScale ? zScale.domain() : [0, 1],
|
|
696
1169
|
xScaleType: xIsLog ? 1.0 : 0.0,
|
|
697
1170
|
yScaleType: yIsLog ? 1.0 : 0.0,
|
|
1171
|
+
zScaleType: zIsLog ? 1.0 : 0.0,
|
|
1172
|
+
u_is3D: this._is3D ? 1.0 : 0.0,
|
|
1173
|
+
u_mvp: camMvp,
|
|
698
1174
|
viewport,
|
|
699
1175
|
count: layer.vertexCount ?? Object.values(layer.attributes).find(v => v instanceof Float32Array)?.length ?? 0,
|
|
700
1176
|
u_pickingMode: 1.0,
|
|
701
1177
|
u_pickLayerIndex: i,
|
|
702
1178
|
}
|
|
703
1179
|
if (layer.instanceCount !== null) props.instances = layer.instanceCount
|
|
704
|
-
for (const qk of layer.colorAxes) {
|
|
1180
|
+
for (const qk of Object.values(layer.colorAxes)) {
|
|
705
1181
|
props[`colorscale_${qk}`] = this.colorAxisRegistry.getColorscaleIndex(qk)
|
|
706
1182
|
const range = this.colorAxisRegistry.getRange(qk)
|
|
707
1183
|
props[`color_range_${qk}`] = range ?? [0, 1]
|
|
708
1184
|
props[`color_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
|
|
1185
|
+
props[`alpha_blend_${qk}`] = this.colorAxisRegistry.getAlphaBlend(qk)
|
|
709
1186
|
}
|
|
710
|
-
for (const qk of layer.filterAxes) {
|
|
1187
|
+
for (const qk of Object.values(layer.filterAxes)) {
|
|
711
1188
|
props[`filter_range_${qk}`] = this.filterAxisRegistry.getRangeUniform(qk)
|
|
712
1189
|
props[`filter_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
|
|
713
1190
|
}
|
|
@@ -728,8 +1205,9 @@ export class Plot {
|
|
|
728
1205
|
result = { layerIndex, configLayerIndex: layer.configLayerIndex, dataIndex, layer }
|
|
729
1206
|
}
|
|
730
1207
|
})
|
|
731
|
-
|
|
732
|
-
|
|
1208
|
+
} finally {
|
|
1209
|
+
fbo.destroy()
|
|
1210
|
+
}
|
|
733
1211
|
return result
|
|
734
1212
|
}
|
|
735
1213
|
}
|