gladly-plot 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/package.json +10 -11
- package/src/axes/Axis.js +401 -0
- package/src/{AxisLink.js → axes/AxisLink.js} +6 -2
- package/src/{AxisQuantityKindRegistry.js → axes/AxisQuantityKindRegistry.js} +7 -0
- package/src/axes/AxisRegistry.js +179 -0
- package/src/axes/Camera.js +47 -0
- package/src/axes/ColorAxisRegistry.js +101 -0
- package/src/{FilterAxisRegistry.js → axes/FilterAxisRegistry.js} +63 -0
- package/src/axes/TickLabelAtlas.js +99 -0
- package/src/axes/ZoomController.js +463 -0
- package/src/colorscales/BivariateColorscales.js +205 -0
- package/src/colorscales/ColorscaleRegistry.js +144 -0
- package/src/compute/ComputationRegistry.js +179 -0
- package/src/compute/axisFilter.js +59 -0
- package/src/compute/conv.js +286 -0
- package/src/compute/elementwise.js +72 -0
- package/src/compute/fft.js +378 -0
- package/src/compute/filter.js +229 -0
- package/src/compute/hist.js +285 -0
- package/src/compute/kde.js +120 -0
- 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 +59 -0
- package/src/core/LayerType.js +433 -0
- package/src/core/Plot.js +1213 -0
- 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/{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} +73 -30
- package/src/{EpsgUtils.js → geo/EpsgUtils.js} +1 -1
- package/src/index.js +47 -22
- package/src/layers/BarsLayer.js +168 -0
- package/src/{ColorbarLayer.js → layers/ColorbarLayer.js} +12 -16
- package/src/layers/ColorbarLayer2d.js +86 -0
- package/src/{FilterbarLayer.js → layers/FilterbarLayer.js} +6 -5
- package/src/layers/LinesLayer.js +185 -0
- package/src/layers/PointsLayer.js +118 -0
- package/src/layers/ScatterShared.js +98 -0
- package/src/{TileLayer.js → layers/TileLayer.js} +24 -20
- package/src/math/mat4.js +100 -0
- package/src/Axis.js +0 -48
- package/src/AxisRegistry.js +0 -54
- package/src/ColorAxisRegistry.js +0 -49
- package/src/ColorscaleRegistry.js +0 -52
- package/src/Data.js +0 -67
- package/src/Float.js +0 -159
- package/src/Layer.js +0 -44
- package/src/LayerType.js +0 -209
- package/src/Plot.js +0 -1073
- package/src/ScatterLayer.js +0 -287
- /package/src/{MatplotlibColorscales.js → colorscales/MatplotlibColorscales.js} +0 -0
- /package/src/{LayerTypeRegistry.js → core/LayerTypeRegistry.js} +0 -0
package/src/core/Plot.js
ADDED
|
@@ -0,0 +1,1213 @@
|
|
|
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
|
+
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
|
+
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) {
|
|
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)
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
66
|
+
$defs: { ...compDefs, ...transformDefs },
|
|
67
|
+
type: "object",
|
|
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
|
+
},
|
|
82
|
+
layers: {
|
|
83
|
+
type: "array",
|
|
84
|
+
items: {
|
|
85
|
+
type: "object",
|
|
86
|
+
oneOf: layerTypes.map(typeName => {
|
|
87
|
+
const layerType = getLayerType(typeName)
|
|
88
|
+
return {
|
|
89
|
+
title: typeName,
|
|
90
|
+
properties: {
|
|
91
|
+
[typeName]: layerType.schema(fullSchemaData)
|
|
92
|
+
},
|
|
93
|
+
required: [typeName],
|
|
94
|
+
additionalProperties: false
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
axes: {
|
|
100
|
+
type: "object",
|
|
101
|
+
properties: {
|
|
102
|
+
xaxis_bottom: {
|
|
103
|
+
type: "object",
|
|
104
|
+
properties: {
|
|
105
|
+
min: { type: "number" },
|
|
106
|
+
max: { type: "number" },
|
|
107
|
+
label: { type: "string" },
|
|
108
|
+
scale: { type: "string", enum: ["linear", "log"] },
|
|
109
|
+
rotate: { type: "boolean" }
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
xaxis_top: {
|
|
113
|
+
type: "object",
|
|
114
|
+
properties: {
|
|
115
|
+
min: { type: "number" },
|
|
116
|
+
max: { type: "number" },
|
|
117
|
+
label: { type: "string" },
|
|
118
|
+
scale: { type: "string", enum: ["linear", "log"] },
|
|
119
|
+
rotate: { type: "boolean" }
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
yaxis_left: {
|
|
123
|
+
type: "object",
|
|
124
|
+
properties: {
|
|
125
|
+
min: { type: "number" },
|
|
126
|
+
max: { type: "number" },
|
|
127
|
+
label: { type: "string" },
|
|
128
|
+
scale: { type: "string", enum: ["linear", "log"] }
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
yaxis_right: {
|
|
132
|
+
type: "object",
|
|
133
|
+
properties: {
|
|
134
|
+
min: { type: "number" },
|
|
135
|
+
max: { type: "number" },
|
|
136
|
+
label: { type: "string" },
|
|
137
|
+
scale: { type: "string", enum: ["linear", "log"] }
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
additionalProperties: {
|
|
142
|
+
// Color/filter/quantity-kind axes.
|
|
143
|
+
// All fields from the quantity kind registration are valid here and override the registration.
|
|
144
|
+
type: "object",
|
|
145
|
+
properties: {
|
|
146
|
+
min: { type: "number" },
|
|
147
|
+
max: { type: "number" },
|
|
148
|
+
label: { type: "string" },
|
|
149
|
+
scale: { type: "string", enum: ["linear", "log"] },
|
|
150
|
+
colorscale: {
|
|
151
|
+
type: "string",
|
|
152
|
+
enum: [
|
|
153
|
+
...getRegisteredColorscales().keys(),
|
|
154
|
+
...getRegistered2DColorscales().keys()
|
|
155
|
+
]
|
|
156
|
+
},
|
|
157
|
+
colorbar: {
|
|
158
|
+
type: "string",
|
|
159
|
+
enum: ["none", "vertical", "horizontal"]
|
|
160
|
+
},
|
|
161
|
+
filterbar: {
|
|
162
|
+
type: "string",
|
|
163
|
+
enum: ["none", "vertical", "horizontal"]
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
colorbars: {
|
|
169
|
+
type: "array",
|
|
170
|
+
description: "Floating colorbar widgets. Use xAxis+yAxis for 2D, one axis for 1D.",
|
|
171
|
+
items: {
|
|
172
|
+
type: "object",
|
|
173
|
+
properties: {
|
|
174
|
+
xAxis: { type: "string", description: "Quantity kind for the x axis of the colorbar" },
|
|
175
|
+
yAxis: { type: "string", description: "Quantity kind for the y axis of the colorbar" },
|
|
176
|
+
colorscale: {
|
|
177
|
+
type: "string",
|
|
178
|
+
description: "Colorscale override. A 2D colorscale name enables the true-2D path.",
|
|
179
|
+
enum: [
|
|
180
|
+
"none",
|
|
181
|
+
...getRegisteredColorscales().keys(),
|
|
182
|
+
...getRegistered2DColorscales().keys()
|
|
183
|
+
]
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export class Plot extends GlBase {
|
|
193
|
+
// Registry of float factories keyed by type name.
|
|
194
|
+
// Each entry: { factory(parentPlot, container, opts) → widget, defaultSize(opts) → {width,height} }
|
|
195
|
+
// Populated by Colorbar.js, Filterbar.js, Colorbar2d.js at module load time.
|
|
196
|
+
static _floatFactories = new Map()
|
|
197
|
+
|
|
198
|
+
static registerFloatFactory(type, factoryDef) {
|
|
199
|
+
Plot._floatFactories.set(type, factoryDef)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
constructor(container, { margin } = {}) {
|
|
203
|
+
super()
|
|
204
|
+
this.container = container
|
|
205
|
+
this.margin = margin ?? { top: 60, right: 60, bottom: 60, left: 60 }
|
|
206
|
+
|
|
207
|
+
// Create canvas element
|
|
208
|
+
this.canvas = document.createElement('canvas')
|
|
209
|
+
this.canvas.style.display = 'block'
|
|
210
|
+
this.canvas.style.position = 'absolute'
|
|
211
|
+
this.canvas.style.top = '0'
|
|
212
|
+
this.canvas.style.left = '0'
|
|
213
|
+
container.appendChild(this.canvas)
|
|
214
|
+
|
|
215
|
+
this.currentConfig = null
|
|
216
|
+
this._lastRawDataArg = undefined
|
|
217
|
+
this.layers = []
|
|
218
|
+
this.axisRegistry = null
|
|
219
|
+
this.colorAxisRegistry = null
|
|
220
|
+
this._renderCallbacks = new Set()
|
|
221
|
+
this._zoomEndCallbacks = new Set()
|
|
222
|
+
this._dirty = false
|
|
223
|
+
this._rafId = null
|
|
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()
|
|
238
|
+
|
|
239
|
+
// Auto-managed Float widgets keyed by a config-derived tag string.
|
|
240
|
+
// Covers 1D colorbars, 2D colorbars, and filterbars in a single unified Map.
|
|
241
|
+
this._floats = new Map()
|
|
242
|
+
|
|
243
|
+
this._setupResizeObserver()
|
|
244
|
+
}
|
|
245
|
+
|
|
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
|
+
}
|
|
254
|
+
|
|
255
|
+
if (!this.currentConfig || !this._rawData) return
|
|
256
|
+
|
|
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
|
|
261
|
+
|
|
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
|
|
266
|
+
|
|
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
|
|
274
|
+
|
|
275
|
+
this._warnedMissingDomains = false
|
|
276
|
+
await this._initialize()
|
|
277
|
+
this._syncFloats()
|
|
278
|
+
}
|
|
279
|
+
|
|
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
|
+
}
|
|
298
|
+
|
|
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
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (data !== undefined) this._lastRawDataArg = data
|
|
311
|
+
|
|
312
|
+
const previousConfig = this.currentConfig
|
|
313
|
+
const previousRawData = this._rawData
|
|
314
|
+
try {
|
|
315
|
+
await this._applyUpdate({ config, data })
|
|
316
|
+
this._validateLinks()
|
|
317
|
+
} catch (error) {
|
|
318
|
+
this.currentConfig = previousConfig
|
|
319
|
+
this._rawData = previousRawData
|
|
320
|
+
throw error
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async forceUpdate() {
|
|
325
|
+
await this.update({})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
getConfig() {
|
|
329
|
+
const axes = { ...(this.currentConfig?.axes ?? {}) }
|
|
330
|
+
|
|
331
|
+
if (this.axisRegistry) {
|
|
332
|
+
for (const axisId of AXES) {
|
|
333
|
+
const scale = this.axisRegistry.getScale(axisId)
|
|
334
|
+
if (scale) {
|
|
335
|
+
const [min, max] = scale.domain()
|
|
336
|
+
const qk = this.axisRegistry.axisQuantityKinds[axisId]
|
|
337
|
+
const qkDef = qk ? getAxisQuantityKind(qk) : {}
|
|
338
|
+
axes[axisId] = { ...qkDef, ...(axes[axisId] ?? {}), min, max }
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (this.colorAxisRegistry) {
|
|
344
|
+
for (const quantityKind of this.colorAxisRegistry.getQuantityKinds()) {
|
|
345
|
+
const range = this.colorAxisRegistry.getRange(quantityKind)
|
|
346
|
+
const qkDef = getAxisQuantityKind(quantityKind)
|
|
347
|
+
const existing = axes[quantityKind] ?? {}
|
|
348
|
+
axes[quantityKind] = {
|
|
349
|
+
colorbar: "none",
|
|
350
|
+
...qkDef,
|
|
351
|
+
...existing,
|
|
352
|
+
...(range ? { min: range[0], max: range[1] } : {}),
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (this.filterAxisRegistry) {
|
|
358
|
+
for (const quantityKind of this.filterAxisRegistry.getQuantityKinds()) {
|
|
359
|
+
const range = this.filterAxisRegistry.getRange(quantityKind)
|
|
360
|
+
const qkDef = getAxisQuantityKind(quantityKind)
|
|
361
|
+
const existing = axes[quantityKind] ?? {}
|
|
362
|
+
axes[quantityKind] = {
|
|
363
|
+
filterbar: "none",
|
|
364
|
+
...qkDef,
|
|
365
|
+
...existing,
|
|
366
|
+
...(range && range.min !== null ? { min: range.min } : {}),
|
|
367
|
+
...(range && range.max !== null ? { max: range.max } : {})
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return { transforms: [], colorbars: [], ...this.currentConfig, axes}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async _initialize() {
|
|
376
|
+
const epoch = ++this._initEpoch
|
|
377
|
+
const { layers = [], axes = {}, colorbars = [], transforms = [] } = this.currentConfig
|
|
378
|
+
|
|
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
|
+
}
|
|
386
|
+
|
|
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
|
+
}
|
|
405
|
+
|
|
406
|
+
this.axisRegistry = new AxisRegistry(this.plotWidth, this.plotHeight)
|
|
407
|
+
this.colorAxisRegistry = new ColorAxisRegistry()
|
|
408
|
+
this.filterAxisRegistry = new FilterAxisRegistry()
|
|
409
|
+
|
|
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
|
|
414
|
+
this._setDomains(axes)
|
|
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
|
+
|
|
430
|
+
// Apply colorscale overrides from top-level colorbars entries. These override any
|
|
431
|
+
// per-axis colorscale from config.axes or quantity kind registry. Applying after
|
|
432
|
+
// _setDomains ensures they take effect last. For 2D colorbars both axes receive the
|
|
433
|
+
// same colorscale name, which resolves to a negative index in the shader, triggering
|
|
434
|
+
// the true-2D colorscale path in map_color_s_2d.
|
|
435
|
+
for (const entry of colorbars) {
|
|
436
|
+
if (!entry.colorscale || entry.colorscale == "none") continue
|
|
437
|
+
console.log("FROM colorbars");
|
|
438
|
+
if (entry.xAxis) this.colorAxisRegistry.ensureColorAxis(entry.xAxis, entry.colorscale)
|
|
439
|
+
if (entry.yAxis) this.colorAxisRegistry.ensureColorAxis(entry.yAxis, entry.colorscale)
|
|
440
|
+
}
|
|
441
|
+
|
|
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
|
+
})
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
_setupResizeObserver() {
|
|
547
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
548
|
+
this.resizeObserver = new ResizeObserver(() => {
|
|
549
|
+
// Defer to next animation frame so the ResizeObserver callback exits
|
|
550
|
+
// before any DOM/layout changes happen, avoiding the "loop completed
|
|
551
|
+
// with undelivered notifications" browser error.
|
|
552
|
+
requestAnimationFrame(async () => {
|
|
553
|
+
try {
|
|
554
|
+
await this.forceUpdate()
|
|
555
|
+
} catch (e) {
|
|
556
|
+
console.error('[gladly] Error during resize-triggered update():', e)
|
|
557
|
+
}
|
|
558
|
+
})
|
|
559
|
+
})
|
|
560
|
+
this.resizeObserver.observe(this.container)
|
|
561
|
+
} else {
|
|
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
|
+
}
|
|
569
|
+
window.addEventListener('resize', this._resizeHandler)
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Returns the quantity kind for any axis ID (spatial or color axis).
|
|
574
|
+
// For color axes, the axis ID IS the quantity kind.
|
|
575
|
+
getAxisQuantityKind(axisId) {
|
|
576
|
+
if (Object.prototype.hasOwnProperty.call(AXIS_GEOMETRY, axisId)) {
|
|
577
|
+
return this.axisRegistry ? this.axisRegistry.axisQuantityKinds[axisId] : null
|
|
578
|
+
}
|
|
579
|
+
return axisId
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Unified domain getter for spatial, color, and filter axes.
|
|
583
|
+
getAxisDomain(axisId) {
|
|
584
|
+
if (Object.prototype.hasOwnProperty.call(AXIS_GEOMETRY, axisId)) {
|
|
585
|
+
const scale = this.axisRegistry?.getScale(axisId)
|
|
586
|
+
return scale ? scale.domain() : null
|
|
587
|
+
}
|
|
588
|
+
if (this.colorAxisRegistry?.hasAxis(axisId)) {
|
|
589
|
+
return this.colorAxisRegistry.getRange(axisId)
|
|
590
|
+
}
|
|
591
|
+
const filterRange = this.filterAxisRegistry?.getRange(axisId)
|
|
592
|
+
if (filterRange) return [filterRange.min, filterRange.max]
|
|
593
|
+
return null
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Unified domain setter for spatial, color, and filter axes.
|
|
597
|
+
setAxisDomain(axisId, domain) {
|
|
598
|
+
if (Object.prototype.hasOwnProperty.call(AXIS_GEOMETRY, axisId)) {
|
|
599
|
+
const scale = this.axisRegistry?.getScale(axisId)
|
|
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
|
+
}
|
|
608
|
+
} else if (this.colorAxisRegistry?.hasAxis(axisId)) {
|
|
609
|
+
this.colorAxisRegistry.setRange(axisId, domain[0], domain[1])
|
|
610
|
+
} else if (this.filterAxisRegistry?.hasAxis(axisId)) {
|
|
611
|
+
this.filterAxisRegistry.setRange(axisId, domain[0], domain[1])
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
_syncFloats() {
|
|
616
|
+
const config = this.currentConfig ?? {}
|
|
617
|
+
const axes = config.axes ?? {}
|
|
618
|
+
const colorbarsConfig = config.colorbars ?? []
|
|
619
|
+
|
|
620
|
+
// Build a map from tag → { factoryDef, opts, y } for every float that should exist.
|
|
621
|
+
// Tags encode the full config so changing any relevant field destroys and recreates the float.
|
|
622
|
+
// Using tags rather than axis names means orientation changes cause clean destroy+recreate
|
|
623
|
+
// with no separate state to compare.
|
|
624
|
+
const desired = new Map()
|
|
625
|
+
|
|
626
|
+
// 1D colorbars declared inline on axes: axes[qk].colorbar = "horizontal"|"vertical"
|
|
627
|
+
for (const [axisName, axisConfig] of Object.entries(axes)) {
|
|
628
|
+
if (AXES.includes(axisName)) continue
|
|
629
|
+
const cb = axisConfig.colorbar
|
|
630
|
+
if (cb === "vertical" || cb === "horizontal") {
|
|
631
|
+
const tag = `colorbar:${axisName}:${cb}`
|
|
632
|
+
const factoryDef = Plot._floatFactories.get('colorbar')
|
|
633
|
+
if (factoryDef) desired.set(tag, { factoryDef, opts: { axisName, orientation: cb }, y: 10 })
|
|
634
|
+
}
|
|
635
|
+
// Filterbars declared inline on axes: axes[qk].filterbar = "horizontal"|"vertical"
|
|
636
|
+
if (this.filterAxisRegistry?.hasAxis(axisName)) {
|
|
637
|
+
const fb = axisConfig.filterbar
|
|
638
|
+
if (fb === "vertical" || fb === "horizontal") {
|
|
639
|
+
const tag = `filterbar:${axisName}:${fb}`
|
|
640
|
+
const factoryDef = Plot._floatFactories.get('filterbar')
|
|
641
|
+
if (factoryDef) desired.set(tag, { factoryDef, opts: { axisName, orientation: fb }, y: 100 })
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Top-level colorbars array: 1D or 2D depending on which axes are specified.
|
|
647
|
+
for (const entry of colorbarsConfig) {
|
|
648
|
+
const { xAxis, yAxis } = entry
|
|
649
|
+
if (xAxis && yAxis) {
|
|
650
|
+
// 2D colorbar
|
|
651
|
+
const tag = `colorbar2d:${xAxis}:${yAxis}`
|
|
652
|
+
const factoryDef = Plot._floatFactories.get('colorbar2d')
|
|
653
|
+
if (factoryDef) desired.set(tag, { factoryDef, opts: { xAxis, yAxis }, y: 10 })
|
|
654
|
+
} else if (xAxis) {
|
|
655
|
+
// 1D horizontal colorbar from colorbars array
|
|
656
|
+
const tag = `colorbar:${xAxis}:horizontal`
|
|
657
|
+
const factoryDef = Plot._floatFactories.get('colorbar')
|
|
658
|
+
if (factoryDef) desired.set(tag, { factoryDef, opts: { axisName: xAxis, orientation: 'horizontal' }, y: 10 })
|
|
659
|
+
} else if (yAxis) {
|
|
660
|
+
// 1D vertical colorbar from colorbars array
|
|
661
|
+
const tag = `colorbar:${yAxis}:vertical`
|
|
662
|
+
const factoryDef = Plot._floatFactories.get('colorbar')
|
|
663
|
+
if (factoryDef) desired.set(tag, { factoryDef, opts: { axisName: yAxis, orientation: 'vertical' }, y: 10 })
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Destroy floats whose tag is no longer in desired
|
|
668
|
+
for (const [tag, float] of this._floats) {
|
|
669
|
+
if (!desired.has(tag)) {
|
|
670
|
+
float.destroy()
|
|
671
|
+
this._floats.delete(tag)
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Create floats for new tags
|
|
676
|
+
for (const [tag, { factoryDef, opts, y }] of desired) {
|
|
677
|
+
if (!this._floats.has(tag)) {
|
|
678
|
+
const size = factoryDef.defaultSize(opts)
|
|
679
|
+
this._floats.set(tag, new Float(
|
|
680
|
+
this,
|
|
681
|
+
(container) => factoryDef.factory(this, container, opts),
|
|
682
|
+
{ y, ...size }
|
|
683
|
+
))
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
destroy() {
|
|
689
|
+
for (const float of this._floats.values()) {
|
|
690
|
+
float.destroy()
|
|
691
|
+
}
|
|
692
|
+
this._floats.clear()
|
|
693
|
+
|
|
694
|
+
// Clear all axis listeners so linked axes stop trying to update this plot
|
|
695
|
+
for (const axis of this._axisCache.values()) {
|
|
696
|
+
axis._listeners.clear()
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
if (this.resizeObserver) {
|
|
700
|
+
this.resizeObserver.disconnect()
|
|
701
|
+
} else if (this._resizeHandler) {
|
|
702
|
+
window.removeEventListener('resize', this._resizeHandler)
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (this._rafId !== null) {
|
|
706
|
+
cancelAnimationFrame(this._rafId)
|
|
707
|
+
this._rafId = null
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (this._tickLabelAtlas) {
|
|
711
|
+
this._tickLabelAtlas.destroy()
|
|
712
|
+
this._tickLabelAtlas = null
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
this._shaderCache.clear()
|
|
716
|
+
|
|
717
|
+
if (this.regl) {
|
|
718
|
+
this.regl.destroy()
|
|
719
|
+
this.regl = null
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
this._renderCallbacks.clear()
|
|
723
|
+
this.canvas.remove()
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
async _processLayers(layersConfig, data, epoch) {
|
|
727
|
+
const TDR_STEP_MS = 500
|
|
728
|
+
for (let configLayerIndex = 0; configLayerIndex < layersConfig.length; configLayerIndex++) {
|
|
729
|
+
const layerSpec = layersConfig[configLayerIndex]
|
|
730
|
+
const entries = Object.entries(layerSpec)
|
|
731
|
+
if (entries.length !== 1) {
|
|
732
|
+
throw new Error("Each layer specification must have exactly one layer type key")
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const [layerTypeName, parameters] = entries[0]
|
|
736
|
+
const layerType = getLayerType(layerTypeName)
|
|
737
|
+
if (!layerType) throw new Error(`Unknown layer type '${layerTypeName}'`)
|
|
738
|
+
|
|
739
|
+
// Resolve axis config once per layer spec for registration (independent of draw call count).
|
|
740
|
+
const ac = layerType.resolveAxisConfig(parameters, data)
|
|
741
|
+
const axesConfig = this.currentConfig?.axes ?? {}
|
|
742
|
+
|
|
743
|
+
// Register spatial axes (null means no axis for that direction).
|
|
744
|
+
// Pass any scale override from config (e.g. "log") so the D3 scale is created correctly.
|
|
745
|
+
if (ac.xAxis) this.axisRegistry.ensureAxis(ac.xAxis, ac.xAxisQuantityKind, axesConfig[ac.xAxis]?.scale ?? axesConfig[ac.xAxisQuantityKind]?.scale)
|
|
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)
|
|
748
|
+
|
|
749
|
+
// Register color axes (colorscale comes from config or quantity kind registry, not from here)
|
|
750
|
+
for (const quantityKind of Object.values(ac.colorAxisQuantityKinds)) {
|
|
751
|
+
this.colorAxisRegistry.ensureColorAxis(quantityKind)
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Register filter axes
|
|
755
|
+
for (const quantityKind of Object.values(ac.filterAxisQuantityKinds)) {
|
|
756
|
+
this.filterAxisRegistry.ensureFilterAxis(quantityKind)
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Create one draw command per GPU config returned by the layer type.
|
|
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) {
|
|
768
|
+
layer.configLayerIndex = configLayerIndex
|
|
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
|
|
779
|
+
this.layers.push(layer)
|
|
780
|
+
}
|
|
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
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
_setDomains(axesOverrides) {
|
|
856
|
+
this.axisRegistry.applyAutoDomainsFromLayers(this.layers, axesOverrides)
|
|
857
|
+
this.colorAxisRegistry.applyAutoDomainsFromLayers(this.layers, axesOverrides)
|
|
858
|
+
this.filterAxisRegistry.applyAutoDomainsFromLayers(this.layers, axesOverrides)
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// Thin wrapper so subclasses (e.g. Colorbar) can override scale-type lookup
|
|
862
|
+
// for axes they proxy from another plot. Implementation delegates to the
|
|
863
|
+
// module-level getScaleTypeFloat which reads from axesConfig directly.
|
|
864
|
+
_getScaleTypeFloat(quantityKind) {
|
|
865
|
+
return getScaleTypeFloat(quantityKind, this.currentConfig?.axes)
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
static schema(data, config) {
|
|
869
|
+
return buildPlotSchema(data, config)
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
scheduleRender(sourcePlot = null) {
|
|
873
|
+
if (!this.regl) return
|
|
874
|
+
this._dirty = true
|
|
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
|
+
}
|
|
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
|
+
})
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
async render() {
|
|
937
|
+
this._dirty = false
|
|
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
|
+
}
|
|
961
|
+
const viewport = {
|
|
962
|
+
x: this.margin.left,
|
|
963
|
+
y: this.margin.bottom,
|
|
964
|
+
width: this.plotWidth,
|
|
965
|
+
height: this.plotHeight
|
|
966
|
+
}
|
|
967
|
+
const axesConfig = this.currentConfig?.axes
|
|
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
|
+
|
|
1004
|
+
for (const layer of this.layers) {
|
|
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))
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
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) {
|
|
1018
|
+
const xIsLog = layer.xAxis ? this.axisRegistry.isLogScale(layer.xAxis) : false
|
|
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]
|
|
1023
|
+
const props = {
|
|
1024
|
+
xDomain: layer.xAxis ? (this.axisRegistry.getScale(layer.xAxis)?.domain() ?? [0, 1]) : [0, 1],
|
|
1025
|
+
yDomain: layer.yAxis ? (this.axisRegistry.getScale(layer.yAxis)?.domain() ?? [0, 1]) : [0, 1],
|
|
1026
|
+
zDomain,
|
|
1027
|
+
xScaleType: xIsLog ? 1.0 : 0.0,
|
|
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,
|
|
1032
|
+
viewport: viewport,
|
|
1033
|
+
count: layer.vertexCount ?? Object.values(layer.attributes).find(v => v instanceof Float32Array)?.length ?? 0,
|
|
1034
|
+
u_pickingMode: 0.0,
|
|
1035
|
+
u_pickLayerIndex: 0.0,
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
if (layer.instanceCount !== null) {
|
|
1039
|
+
props.instances = layer.instanceCount
|
|
1040
|
+
}
|
|
1041
|
+
|
|
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)) {
|
|
1055
|
+
props[`colorscale_${qk}`] = this.colorAxisRegistry.getColorscaleIndex(qk)
|
|
1056
|
+
const range = this.colorAxisRegistry.getRange(qk)
|
|
1057
|
+
props[`color_range_${qk}`] = range ?? [0, 1]
|
|
1058
|
+
props[`color_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
|
|
1059
|
+
props[`alpha_blend_${qk}`] = this.colorAxisRegistry.getAlphaBlend(qk)
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
for (const qk of Object.values(layer.filterAxes)) {
|
|
1063
|
+
props[`filter_range_${qk}`] = this.filterAxisRegistry.getRangeUniform(qk)
|
|
1064
|
+
props[`filter_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
layer.draw(props)
|
|
1068
|
+
}
|
|
1069
|
+
|
|
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
|
+
}
|
|
1088
|
+
for (const cb of this._renderCallbacks) cb()
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
lookup(x, y) {
|
|
1092
|
+
const result = {}
|
|
1093
|
+
if (!this.axisRegistry) return result
|
|
1094
|
+
const plotX = x - this.margin.left
|
|
1095
|
+
const plotY = y - this.margin.top
|
|
1096
|
+
for (const axisId of AXES) {
|
|
1097
|
+
const scale = this.axisRegistry.getScale(axisId)
|
|
1098
|
+
if (!scale) continue
|
|
1099
|
+
const qk = this.axisRegistry.axisQuantityKinds[axisId]
|
|
1100
|
+
const value = axisId.includes('y') ? scale.invert(plotY) : scale.invert(plotX)
|
|
1101
|
+
result[axisId] = value
|
|
1102
|
+
if (qk) result[qk] = value
|
|
1103
|
+
}
|
|
1104
|
+
return result
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
onZoomEnd(cb) {
|
|
1108
|
+
this._zoomEndCallbacks.add(cb)
|
|
1109
|
+
return { remove: () => this._zoomEndCallbacks.delete(cb) }
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
on(eventType, callback) {
|
|
1113
|
+
const handler = (e) => {
|
|
1114
|
+
if (!this.container.contains(e.target)) return
|
|
1115
|
+
const rect = this.container.getBoundingClientRect()
|
|
1116
|
+
const x = e.clientX - rect.left
|
|
1117
|
+
const y = e.clientY - rect.top
|
|
1118
|
+
callback(e, this.lookup(x, y))
|
|
1119
|
+
}
|
|
1120
|
+
window.addEventListener(eventType, handler, { capture: true })
|
|
1121
|
+
return { remove: () => window.removeEventListener(eventType, handler, { capture: true }) }
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
async pick(x, y) {
|
|
1125
|
+
if (!this.regl || !this.layers.length) return null
|
|
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
|
+
|
|
1132
|
+
const fbo = this.regl.framebuffer({
|
|
1133
|
+
width: this.width, height: this.height,
|
|
1134
|
+
colorFormat: 'rgba', colorType: 'uint8', depth: false,
|
|
1135
|
+
})
|
|
1136
|
+
|
|
1137
|
+
const axesConfig = this.currentConfig?.axes
|
|
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
|
+
|
|
1149
|
+
let result = null
|
|
1150
|
+
try {
|
|
1151
|
+
this.regl({ framebuffer: fbo })(() => {
|
|
1152
|
+
this.regl.clear({ color: [0, 0, 0, 0] })
|
|
1153
|
+
const viewport = {
|
|
1154
|
+
x: this.margin.left, y: this.margin.bottom,
|
|
1155
|
+
width: this.plotWidth, height: this.plotHeight
|
|
1156
|
+
}
|
|
1157
|
+
for (let i = 0; i < this.layers.length; i++) {
|
|
1158
|
+
const layer = this.layers[i]
|
|
1159
|
+
|
|
1160
|
+
const xIsLog = layer.xAxis ? this.axisRegistry.isLogScale(layer.xAxis) : false
|
|
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()
|
|
1165
|
+
const props = {
|
|
1166
|
+
xDomain: layer.xAxis ? (this.axisRegistry.getScale(layer.xAxis)?.domain() ?? [0, 1]) : [0, 1],
|
|
1167
|
+
yDomain: layer.yAxis ? (this.axisRegistry.getScale(layer.yAxis)?.domain() ?? [0, 1]) : [0, 1],
|
|
1168
|
+
zDomain: zScale ? zScale.domain() : [0, 1],
|
|
1169
|
+
xScaleType: xIsLog ? 1.0 : 0.0,
|
|
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,
|
|
1174
|
+
viewport,
|
|
1175
|
+
count: layer.vertexCount ?? Object.values(layer.attributes).find(v => v instanceof Float32Array)?.length ?? 0,
|
|
1176
|
+
u_pickingMode: 1.0,
|
|
1177
|
+
u_pickLayerIndex: i,
|
|
1178
|
+
}
|
|
1179
|
+
if (layer.instanceCount !== null) props.instances = layer.instanceCount
|
|
1180
|
+
for (const qk of Object.values(layer.colorAxes)) {
|
|
1181
|
+
props[`colorscale_${qk}`] = this.colorAxisRegistry.getColorscaleIndex(qk)
|
|
1182
|
+
const range = this.colorAxisRegistry.getRange(qk)
|
|
1183
|
+
props[`color_range_${qk}`] = range ?? [0, 1]
|
|
1184
|
+
props[`color_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
|
|
1185
|
+
props[`alpha_blend_${qk}`] = this.colorAxisRegistry.getAlphaBlend(qk)
|
|
1186
|
+
}
|
|
1187
|
+
for (const qk of Object.values(layer.filterAxes)) {
|
|
1188
|
+
props[`filter_range_${qk}`] = this.filterAxisRegistry.getRangeUniform(qk)
|
|
1189
|
+
props[`filter_scale_type_${qk}`] = this._getScaleTypeFloat(qk)
|
|
1190
|
+
}
|
|
1191
|
+
layer.draw(props)
|
|
1192
|
+
}
|
|
1193
|
+
var pixels;
|
|
1194
|
+
try {
|
|
1195
|
+
pixels = this.regl.read({ x: glX, y: glY, width: 1, height: 1 })
|
|
1196
|
+
} catch (e) {
|
|
1197
|
+
pixels = [0];
|
|
1198
|
+
}
|
|
1199
|
+
if (pixels[0] === 0) {
|
|
1200
|
+
result = null
|
|
1201
|
+
} else {
|
|
1202
|
+
const layerIndex = pixels[0] - 1
|
|
1203
|
+
const dataIndex = (pixels[1] << 16) | (pixels[2] << 8) | pixels[3]
|
|
1204
|
+
const layer = this.layers[layerIndex]
|
|
1205
|
+
result = { layerIndex, configLayerIndex: layer.configLayerIndex, dataIndex, layer }
|
|
1206
|
+
}
|
|
1207
|
+
})
|
|
1208
|
+
} finally {
|
|
1209
|
+
fbo.destroy()
|
|
1210
|
+
}
|
|
1211
|
+
return result
|
|
1212
|
+
}
|
|
1213
|
+
}
|