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