gladly-plot 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +9 -2
  2. package/package.json +10 -11
  3. package/src/axes/Axis.js +320 -172
  4. package/src/axes/AxisLink.js +6 -2
  5. package/src/axes/AxisRegistry.js +116 -39
  6. package/src/axes/Camera.js +47 -0
  7. package/src/axes/ColorAxisRegistry.js +10 -2
  8. package/src/axes/FilterAxisRegistry.js +1 -1
  9. package/src/axes/TickLabelAtlas.js +99 -0
  10. package/src/axes/ZoomController.js +446 -124
  11. package/src/colorscales/ColorscaleRegistry.js +30 -10
  12. package/src/compute/ComputationRegistry.js +126 -184
  13. package/src/compute/axisFilter.js +21 -9
  14. package/src/compute/conv.js +64 -8
  15. package/src/compute/elementwise.js +72 -0
  16. package/src/compute/fft.js +106 -20
  17. package/src/compute/filter.js +105 -103
  18. package/src/compute/hist.js +247 -142
  19. package/src/compute/kde.js +64 -46
  20. package/src/compute/scatter2dInterpolate.js +277 -0
  21. package/src/compute/util.js +196 -0
  22. package/src/core/ComputePipeline.js +153 -0
  23. package/src/core/GlBase.js +141 -0
  24. package/src/core/Layer.js +22 -8
  25. package/src/core/LayerType.js +251 -92
  26. package/src/core/Plot.js +630 -152
  27. package/src/core/PlotGroup.js +204 -0
  28. package/src/core/ShaderQueue.js +73 -0
  29. package/src/data/ColumnData.js +269 -0
  30. package/src/data/Computation.js +95 -0
  31. package/src/data/Data.js +270 -0
  32. package/src/floats/Float.js +56 -0
  33. package/src/index.js +16 -4
  34. package/src/layers/BarsLayer.js +168 -0
  35. package/src/layers/ColorbarLayer.js +10 -14
  36. package/src/layers/ColorbarLayer2d.js +13 -24
  37. package/src/layers/FilterbarLayer.js +4 -3
  38. package/src/layers/LinesLayer.js +108 -122
  39. package/src/layers/PointsLayer.js +73 -69
  40. package/src/layers/ScatterShared.js +62 -106
  41. package/src/layers/TileLayer.js +20 -16
  42. package/src/math/mat4.js +100 -0
  43. package/src/core/Data.js +0 -67
  44. package/src/layers/HistogramLayer.js +0 -212
@@ -1,6 +1,5 @@
1
- import * as d3 from "d3-selection"
2
- import { zoom } from "d3-zoom"
3
- import { AXES } from "./AxisRegistry.js"
1
+ import { AXIS_GEOMETRY, axisEndpoints } from './AxisRegistry.js'
2
+ import { mat4Multiply, mat4Identity, projectToScreen, sphericalToCartesian } from '../math/mat4.js'
4
3
 
5
4
  export class ZoomController {
6
5
  constructor(plot) {
@@ -8,134 +7,457 @@ export class ZoomController {
8
7
  this._init()
9
8
  }
10
9
 
11
- _init() {
10
+ // Recompute the axis MVP — same matrix Plot.render() uses for axis lines/labels.
11
+ _computeAxisMvp() {
12
+ const { width, height, plotWidth, plotHeight, margin, _camera } = this._plot
13
+ const sx = plotWidth / width
14
+ const sy = plotHeight / height
15
+ const cx = (margin.left - margin.right) / width
16
+ const cy = (margin.bottom - margin.top) / height
17
+ const Mvp = new Float32Array([
18
+ sx, 0, 0, 0,
19
+ 0, sy, 0, 0,
20
+ 0, 0, 1, 0,
21
+ cx, cy, 0, 1,
22
+ ])
23
+ return mat4Multiply(Mvp, _camera ? _camera.getMVP() : mat4Identity())
24
+ }
25
+
26
+ // Return { axes: [axisId, ...], type: 'plot_area'|'axis' } or null.
27
+ _getRegion(mx, my) {
28
+ return this._plot._is3D ? this._getRegion3D(mx, my) : this._getRegion2D(mx, my)
29
+ }
30
+
31
+ _getRegion2D(mx, my) {
32
+ const { margin, plotWidth, plotHeight, axisRegistry: ar } = this._plot
33
+ const inX = mx >= margin.left && mx < margin.left + plotWidth
34
+ const inY = my >= margin.top && my < margin.top + plotHeight
35
+
36
+ if (inX && inY)
37
+ return { axes: ['xaxis_bottom','xaxis_top','yaxis_left','yaxis_right'].filter(a => ar.getScale(a)), type: 'plot_area' }
38
+ if (inX && my < margin.top && ar.getScale('xaxis_top')) return { axes: ['xaxis_top'], type: 'axis' }
39
+ if (inX && my >= margin.top + plotHeight && ar.getScale('xaxis_bottom')) return { axes: ['xaxis_bottom'], type: 'axis' }
40
+ if (inY && mx < margin.left && ar.getScale('yaxis_left')) return { axes: ['yaxis_left'], type: 'axis' }
41
+ if (inY && mx >= margin.left + plotWidth && ar.getScale('yaxis_right')) return { axes: ['yaxis_right'], type: 'axis' }
42
+ return null
43
+ }
44
+
45
+ _getRegion3D(mx, my) {
12
46
  const plot = this._plot
47
+ const { width, height, axisRegistry: ar, _camera: cam } = plot
48
+ const axisMvp = this._computeAxisMvp()
13
49
 
14
- const fullOverlay = plot.svg.append("rect")
15
- .attr("class", "zoom-overlay")
16
- .attr("x", 0)
17
- .attr("y", 0)
18
- .attr("width", plot.width)
19
- .attr("height", plot.height)
20
- .attr("fill", "none")
21
- .attr("pointer-events", "all")
22
- .style("cursor", "move")
23
-
24
- let currentRegion = null
25
- let gestureStartDomains = {}
26
- let gestureStartMousePos = {}
27
- let gestureStartDataPos = {}
28
- let gestureStartTransform = null
29
-
30
- const zoomBehavior = zoom()
31
- .on("start", (event) => {
32
- if (!event.sourceEvent) return
33
-
34
- gestureStartTransform = { k: event.transform.k, x: event.transform.x, y: event.transform.y }
35
- const [mouseX, mouseY] = d3.pointer(event.sourceEvent, plot.svg.node())
36
- const { margin, plotWidth, plotHeight } = plot
37
-
38
- const inPlotX = mouseX >= margin.left && mouseX < margin.left + plotWidth
39
- const inPlotY = mouseY >= margin.top && mouseY < margin.top + plotHeight
40
-
41
- if (inPlotX && mouseY < margin.top) {
42
- currentRegion = "xaxis_top"
43
- } else if (inPlotX && mouseY >= margin.top + plotHeight) {
44
- currentRegion = "xaxis_bottom"
45
- } else if (inPlotY && mouseX < margin.left) {
46
- currentRegion = "yaxis_left"
47
- } else if (inPlotY && mouseX >= margin.left + plotWidth) {
48
- currentRegion = "yaxis_right"
49
- } else if (inPlotX && inPlotY) {
50
- currentRegion = "plot_area"
51
- } else {
52
- currentRegion = null
53
- }
50
+ // Camera eye for front-face culling
51
+ const eye = sphericalToCartesian(cam._theta, cam._phi, cam._radius)
52
+ const eyeLen = Math.sqrt(eye[0]**2 + eye[1]**2 + eye[2]**2)
53
+
54
+ let bestAxis = null
55
+ let bestDist = Infinity
56
+
57
+ for (const axisId of Object.keys(AXIS_GEOMETRY)) {
58
+ if (!ar.getScale(axisId)) continue
59
+
60
+ const { outward } = AXIS_GEOMETRY[axisId]
61
+
62
+ // Skip back-facing axes: outward normal points away from camera
63
+ const facingCam = outward[0]*eye[0]/eyeLen + outward[1]*eye[1]/eyeLen + outward[2]*eye[2]/eyeLen
64
+ if (facingCam <= 0) continue
65
+
66
+ const { start, end } = axisEndpoints(axisId)
67
+ const startS = projectToScreen(start, axisMvp, width, height)
68
+ const endS = projectToScreen(end, axisMvp, width, height)
69
+ if (!startS || !endS) continue
70
+
71
+ // Project outward direction to screen space via the axis midpoint
72
+ const mid = [(start[0]+end[0])/2, (start[1]+end[1])/2, (start[2]+end[2])/2]
73
+ const midO = [mid[0]+outward[0]*0.2, mid[1]+outward[1]*0.2, mid[2]+outward[2]*0.2]
74
+ const midS = projectToScreen(mid, axisMvp, width, height)
75
+ const midOS = projectToScreen(midO, axisMvp, width, height)
76
+ if (!midS || !midOS) continue
77
+
78
+ // Perpendicular to projected axis segment, aligned with screen-space outward direction
79
+ const segDx = endS[0] - startS[0]
80
+ const segDy = endS[1] - startS[1]
81
+ const segLen = Math.sqrt(segDx**2 + segDy**2)
82
+ if (segLen < 1e-6) continue
83
+
84
+ let perpX = -segDy / segLen
85
+ let perpY = segDx / segLen
86
+ const odx = midOS[0] - midS[0]
87
+ const ody = midOS[1] - midS[1]
88
+ if (perpX*odx + perpY*ody < 0) { perpX = -perpX; perpY = -perpY }
89
+
90
+ // Signed distance from click to axis line (positive = outward / margin side)
91
+ const signedDist = (mx - startS[0]) * perpX + (my - startS[1]) * perpY
92
+ if (signedDist <= 0) continue
93
+
94
+ // Projection along segment — reject clicks far off the ends
95
+ const segT = ((mx-startS[0])*segDx + (my-startS[1])*segDy) / segLen**2
96
+ if (segT < -0.5 || segT > 1.5) continue
97
+
98
+ if (signedDist < bestDist) {
99
+ bestDist = signedDist
100
+ bestAxis = axisId
101
+ }
102
+ }
103
+
104
+ if (bestAxis) return { axes: [bestAxis], type: 'axis' }
105
+
106
+ // Inside plot: all active spatial axes
107
+ return { axes: Object.keys(AXIS_GEOMETRY).filter(a => ar.getScale(a)), type: 'plot_area' }
108
+ }
109
+
110
+ // Unproject a canvas pixel to a point in normalised [-1,+1]³ world space.
111
+ //
112
+ // 2D: direct pixel → NDC (identity camera MVP).
113
+ // 3D: find the intersection of the camera ray through the pixel with the
114
+ // screen plane that passes through the world origin (the orbit target).
115
+ // In eye space the origin sits at z_eye = -radius, so the ray parameter
116
+ // at that depth is exactly radius, giving:
117
+ // P_eye = [nx * aspect * radius / f, ny * radius / f, -radius]
118
+ // Converting back to world space via the camera right/up vectors yields
119
+ // P_world = right * P_eye.x + up * P_eye.y (the z component cancels).
120
+ _unproject(mx, my) {
121
+ const { margin, plotWidth, plotHeight, _is3D, _camera } = this._plot
122
+
123
+ const nx = (mx - margin.left) * 2 / plotWidth - 1
124
+ const ny = 1 - (my - margin.top) * 2 / plotHeight
125
+
126
+ if (!_is3D) return [nx, ny, 0]
127
+
128
+ const { right, up } = _camera.getCameraVectors()
129
+ const f = 1 / Math.tan(_camera._fov / 2)
130
+ const radius = _camera._radius
131
+ const aspect = plotWidth / plotHeight
132
+ const sx = nx * aspect * radius / f
133
+ const sy = ny * radius / f
134
+ return [
135
+ right[0]*sx + up[0]*sy,
136
+ right[1]*sx + up[1]*sy,
137
+ right[2]*sx + up[2]*sy,
138
+ ]
139
+ }
140
+
141
+ _init() {
142
+ const plot = this._plot
143
+ const canvas = plot.canvas
144
+
145
+ canvas.addEventListener('contextmenu', e => e.preventDefault())
54
146
 
55
- gestureStartDomains = {}
56
- gestureStartMousePos = {}
57
- gestureStartDataPos = {}
58
-
59
- if (currentRegion && plot.axisRegistry) {
60
- const axesToZoom = currentRegion === "plot_area" ? AXES : [currentRegion]
61
- axesToZoom.forEach(axis => {
62
- const scale = plot.axisRegistry.getScale(axis)
63
- if (!scale) return
64
-
65
- const { margin, plotWidth, plotHeight } = plot
66
- const isY = axis.includes("y")
67
- const mousePixel = isY ? (mouseY - margin.top) : (mouseX - margin.left)
68
- const pixelSize = isY ? plotHeight : plotWidth
69
- const currentDomain = scale.domain()
70
- gestureStartDomains[axis] = currentDomain.slice()
71
- gestureStartMousePos[axis] = mousePixel
72
-
73
- const isLog = plot.axisRegistry.isLogScale(axis)
74
- const [d0, d1] = currentDomain
75
- const t0 = isLog ? Math.log(d0) : d0
76
- const t1 = isLog ? Math.log(d1) : d1
77
- const fraction = mousePixel / pixelSize
78
- gestureStartDataPos[axis] = isY
79
- ? t1 - fraction * (t1 - t0)
80
- : t0 + fraction * (t1 - t0)
81
- })
147
+ let isDragging = false
148
+ let isRotating = false
149
+ let dragRegion = null
150
+ let startWorld = null // world point [x,y,z] pinned at drag start
151
+ let startDomains = {} // { axisId: [d0,d1] } snapshot at drag start
152
+ let lastMouse = null // [x,y] for rotation delta
153
+ let dragRect = null // cached at mousedown; canvas position doesn't change during a drag
154
+ const onMouseMove = (e) => {
155
+ const _t0 = performance.now()
156
+ const mx = e.clientX - dragRect.left
157
+ const my = e.clientY - dragRect.top
158
+
159
+ if (isRotating) {
160
+ const [lx, ly] = lastMouse
161
+ const dx = mx - lx
162
+ const dy = my - ly
163
+ plot._camera._theta -= dx * 0.008
164
+ plot._camera._phi = Math.max(
165
+ -Math.PI / 2 + 0.02,
166
+ Math.min(Math.PI / 2 - 0.02, plot._camera._phi + dy * 0.008)
167
+ )
168
+ lastMouse = [mx, my]
169
+ plot.scheduleRender()
170
+ const _dt = performance.now() - _t0
171
+ if (_dt > 2) console.warn(`[gladly] onMouseMove(rotate) ${_dt.toFixed(1)}ms`)
172
+ return
173
+ }
174
+
175
+ if (!isDragging) return
176
+
177
+ const currentWorld = this._unproject(mx, my)
178
+ const dw = [
179
+ startWorld[0] - currentWorld[0],
180
+ startWorld[1] - currentWorld[1],
181
+ startWorld[2] - currentWorld[2],
182
+ ]
183
+
184
+ for (const axisId of dragRegion.axes) {
185
+ const startDomain = startDomains[axisId]
186
+ if (!startDomain) continue
187
+ if (!plot.axisRegistry.getScale(axisId)) continue
188
+
189
+ const { dir } = AXIS_GEOMETRY[axisId]
190
+ const dirIdx = dir === 'x' ? 0 : dir === 'y' ? 1 : 2
191
+ const isLog = plot.axisRegistry.isLogScale(axisId)
192
+ const t0 = isLog ? Math.log(startDomain[0]) : startDomain[0]
193
+ const t1 = isLog ? Math.log(startDomain[1]) : startDomain[1]
194
+ // Normalised world space: delta_normalised = delta_t * 2 / (t1-t0)
195
+ // → delta_t = dw[dirIdx] * (t1-t0) / 2
196
+ const deltaT = dw[dirIdx] * (t1 - t0) / 2
197
+ const newT0 = t0 + deltaT
198
+ const newT1 = t1 + deltaT
199
+ plot._getAxis(axisId).setDomain(isLog ? [Math.exp(newT0), Math.exp(newT1)] : [newT0, newT1])
200
+ }
201
+
202
+ plot.scheduleRender()
203
+ const _dt = performance.now() - _t0
204
+ if (_dt > 2) console.warn(`[gladly] onMouseMove(pan) ${_dt.toFixed(1)}ms`)
205
+ }
206
+
207
+ const onMouseUp = (e) => {
208
+ if (isDragging || isRotating) plot._zoomEndCallbacks.forEach(cb => cb())
209
+ isDragging = false
210
+ isRotating = false
211
+ dragRegion = null
212
+ startWorld = null
213
+ startDomains = {}
214
+ lastMouse = null
215
+ dragRect = null
216
+ window.removeEventListener('mousemove', onMouseMove)
217
+ window.removeEventListener('mouseup', onMouseUp)
218
+ }
219
+
220
+ canvas.addEventListener('mousedown', (e) => {
221
+ e.preventDefault()
222
+ dragRect = canvas.getBoundingClientRect()
223
+ const mx = e.clientX - dragRect.left
224
+ const my = e.clientY - dragRect.top
225
+
226
+ // Right-click or Ctrl+left in 3D → rotate
227
+ if (plot._is3D && (e.button === 2 || (e.button === 0 && e.ctrlKey))) {
228
+ isRotating = true
229
+ lastMouse = [mx, my]
230
+ window.addEventListener('mousemove', onMouseMove)
231
+ window.addEventListener('mouseup', onMouseUp)
232
+ return
233
+ }
234
+
235
+ if (e.button !== 0) return
236
+
237
+ // Left-click → pan
238
+ const region = this._getRegion(mx, my)
239
+ if (!region) return
240
+
241
+ isDragging = true
242
+ dragRegion = region
243
+ startWorld = this._unproject(mx, my)
244
+ startDomains = {}
245
+ for (const axisId of region.axes) {
246
+ const scale = plot.axisRegistry.getScale(axisId)
247
+ if (scale) startDomains[axisId] = scale.domain().slice()
248
+ }
249
+ window.addEventListener('mousemove', onMouseMove)
250
+ window.addEventListener('mouseup', onMouseUp)
251
+ })
252
+
253
+ // Touch support
254
+ let touchDragging = false
255
+ let touchZooming = false
256
+ let touchRotating = false
257
+ let touchDragRegion = null
258
+ let touchStartWorld = null
259
+ let touchStartDomains = {}
260
+ let touchLastDist = null
261
+ let touchZoomRegion = null
262
+ let touchLastPos = null
263
+ let touchRect = null
264
+
265
+ const getTouchMid = (touches) => [
266
+ (touches[0].clientX + touches[1].clientX) / 2,
267
+ (touches[0].clientY + touches[1].clientY) / 2,
268
+ ]
269
+ const getTouchDist = (touches) => {
270
+ const dx = touches[1].clientX - touches[0].clientX
271
+ const dy = touches[1].clientY - touches[0].clientY
272
+ return Math.sqrt(dx*dx + dy*dy)
273
+ }
274
+
275
+ const startTouchPan = (mx, my) => {
276
+ const region = this._getRegion(mx, my)
277
+ if (!region) return
278
+ touchDragging = true
279
+ touchDragRegion = region
280
+ touchStartWorld = this._unproject(mx, my)
281
+ touchStartDomains = {}
282
+ for (const axisId of region.axes) {
283
+ const scale = plot.axisRegistry.getScale(axisId)
284
+ if (scale) touchStartDomains[axisId] = scale.domain().slice()
285
+ }
286
+ }
287
+
288
+ canvas.addEventListener('touchstart', (e) => {
289
+ e.preventDefault()
290
+ touchRect = canvas.getBoundingClientRect()
291
+
292
+ if (e.touches.length === 1) {
293
+ touchZooming = false
294
+ touchRotating = false
295
+ touchZoomRegion = null
296
+ touchLastDist = null
297
+ const t = e.touches[0]
298
+ const mx = t.clientX - touchRect.left
299
+ const my = t.clientY - touchRect.top
300
+ // In 3D mode, touches within 20px of any canvas edge rotate the plot.
301
+ if (plot._is3D) {
302
+ const EDGE = 20
303
+ const { width, height } = canvas.getBoundingClientRect()
304
+ if (mx < EDGE || my < EDGE || mx > width - EDGE || my > height - EDGE) {
305
+ touchRotating = true
306
+ touchLastPos = [mx, my]
307
+ return
308
+ }
82
309
  }
83
- })
84
- .on("zoom", (event) => {
85
- if (!plot.axisRegistry || !currentRegion || !gestureStartTransform) return
86
-
87
- const deltaK = event.transform.k / gestureStartTransform.k
88
- const deltaX = event.transform.x - gestureStartTransform.x
89
- const deltaY = event.transform.y - gestureStartTransform.y
90
- const isWheel = event.sourceEvent && event.sourceEvent.type === 'wheel'
91
- const axesToZoom = currentRegion === "plot_area" ? AXES : [currentRegion]
92
-
93
- axesToZoom.forEach(axis => {
94
- const scale = plot.axisRegistry.getScale(axis)
95
- if (!scale || !gestureStartDomains[axis] || gestureStartDataPos[axis] === undefined) return
96
-
97
- const { plotWidth, plotHeight } = plot
98
- const isY = axis.includes("y")
99
- const [d0, d1] = gestureStartDomains[axis]
100
- const isLog = plot.axisRegistry.isLogScale(axis)
101
- const t0 = isLog ? Math.log(d0) : d0
102
- const t1 = isLog ? Math.log(d1) : d1
103
- const tDomainWidth = t1 - t0
104
-
105
- const pixelSize = isY ? plotHeight : plotWidth
106
- const pixelDelta = isY ? deltaY : deltaX
107
- const newTDomainWidth = tDomainWidth / deltaK
108
- const targetDataPos = gestureStartDataPos[axis]
109
- const mousePixelPos = gestureStartMousePos[axis]
110
- const fraction = mousePixelPos / pixelSize
111
-
112
- const panTDomainDelta = isWheel ? 0 : (isY
113
- ? pixelDelta * tDomainWidth / pixelSize / deltaK
114
- : -pixelDelta * tDomainWidth / pixelSize / deltaK)
115
-
116
- const tCenter = isY
117
- ? (targetDataPos + panTDomainDelta) + (fraction - 0.5) * newTDomainWidth
118
- : (targetDataPos + panTDomainDelta) + (0.5 - fraction) * newTDomainWidth
119
-
120
- const newTDomain = [tCenter - newTDomainWidth / 2, tCenter + newTDomainWidth / 2]
121
- const newDomain = isLog
122
- ? [Math.exp(newTDomain[0]), Math.exp(newTDomain[1])]
123
- : newTDomain
124
-
125
- plot._getAxis(axis).setDomain(newDomain)
126
- })
310
+ startTouchPan(mx, my)
311
+ } else if (e.touches.length === 2) {
312
+ touchDragging = false
313
+ touchZooming = true
314
+ touchLastDist = getTouchDist(e.touches)
315
+ const [midX, midY] = getTouchMid(e.touches)
316
+ const mx = midX - touchRect.left
317
+ const my = midY - touchRect.top
318
+ touchZoomRegion = this._getRegion(mx, my)
319
+ }
320
+ }, { passive: false })
321
+
322
+ canvas.addEventListener('touchmove', (e) => {
323
+ e.preventDefault()
324
+ if (!touchRect) return
127
325
 
326
+ if (touchRotating && e.touches.length === 1) {
327
+ const t = e.touches[0]
328
+ const mx = t.clientX - touchRect.left
329
+ const my = t.clientY - touchRect.top
330
+ const [lx, ly] = touchLastPos
331
+ const dx = mx - lx
332
+ const dy = my - ly
333
+ plot._camera._theta -= dx * 0.008
334
+ plot._camera._phi = Math.max(
335
+ -Math.PI / 2 + 0.02,
336
+ Math.min(Math.PI / 2 - 0.02, plot._camera._phi + dy * 0.008)
337
+ )
338
+ touchLastPos = [mx, my]
339
+ plot.scheduleRender()
340
+ } else if (touchDragging && e.touches.length === 1) {
341
+ const t = e.touches[0]
342
+ const mx = t.clientX - touchRect.left
343
+ const my = t.clientY - touchRect.top
344
+ const currentWorld = this._unproject(mx, my)
345
+ const dw = [
346
+ touchStartWorld[0] - currentWorld[0],
347
+ touchStartWorld[1] - currentWorld[1],
348
+ touchStartWorld[2] - currentWorld[2],
349
+ ]
350
+ for (const axisId of touchDragRegion.axes) {
351
+ const startDomain = touchStartDomains[axisId]
352
+ if (!startDomain) continue
353
+ if (!plot.axisRegistry.getScale(axisId)) continue
354
+ const { dir } = AXIS_GEOMETRY[axisId]
355
+ const dirIdx = dir === 'x' ? 0 : dir === 'y' ? 1 : 2
356
+ const isLog = plot.axisRegistry.isLogScale(axisId)
357
+ const t0 = isLog ? Math.log(startDomain[0]) : startDomain[0]
358
+ const t1 = isLog ? Math.log(startDomain[1]) : startDomain[1]
359
+ const deltaT = dw[dirIdx] * (t1 - t0) / 2
360
+ plot._getAxis(axisId).setDomain(isLog
361
+ ? [Math.exp(t0 + deltaT), Math.exp(t1 + deltaT)]
362
+ : [t0 + deltaT, t1 + deltaT])
363
+ }
364
+ plot.scheduleRender()
365
+ } else if (touchZooming && e.touches.length === 2) {
366
+ const newDist = getTouchDist(e.touches)
367
+ const factor = touchLastDist / newDist // pinch out → zoom in (factor < 1)
368
+ touchLastDist = newDist
369
+ if (!touchZoomRegion) return
370
+ const [midX, midY] = getTouchMid(e.touches)
371
+ const mx = midX - touchRect.left
372
+ const my = midY - touchRect.top
373
+ const worldCursor = this._unproject(mx, my)
374
+ for (const axisId of touchZoomRegion.axes) {
375
+ const scale = plot.axisRegistry.getScale(axisId)
376
+ if (!scale) continue
377
+ const { dir } = AXIS_GEOMETRY[axisId]
378
+ const dirIdx = dir === 'x' ? 0 : dir === 'y' ? 1 : 2
379
+ const [d0, d1] = scale.domain()
380
+ const isLog = plot.axisRegistry.isLogScale(axisId)
381
+ const t0 = isLog ? Math.log(d0) : d0
382
+ const t1 = isLog ? Math.log(d1) : d1
383
+ const tCursor = (worldCursor[dirIdx] + 1) / 2 * (t1 - t0) + t0
384
+ const newT0 = tCursor + (t0 - tCursor) * factor
385
+ const newT1 = tCursor + (t1 - tCursor) * factor
386
+ plot._getAxis(axisId).setDomain(isLog ? [Math.exp(newT0), Math.exp(newT1)] : [newT0, newT1])
387
+ }
128
388
  plot.scheduleRender()
129
- })
130
- .on("end", () => {
131
- currentRegion = null
132
- gestureStartDomains = {}
133
- gestureStartMousePos = {}
134
- gestureStartDataPos = {}
135
- gestureStartTransform = null
136
389
  plot._zoomEndCallbacks.forEach(cb => cb())
137
- })
390
+ }
391
+ }, { passive: false })
392
+
393
+ const onTouchEnd = (e) => {
394
+ e.preventDefault()
395
+ if (touchDragging || touchZooming || touchRotating) plot._zoomEndCallbacks.forEach(cb => cb())
396
+ touchDragging = false
397
+ touchZooming = false
398
+ touchRotating = false
399
+ touchDragRegion = null
400
+ touchStartWorld = null
401
+ touchStartDomains = {}
402
+ touchLastDist = null
403
+ touchZoomRegion = null
404
+ touchLastPos = null
405
+ // If one finger remains after a pinch, restart pan/rotate from current position
406
+ if (e.touches.length === 1 && touchRect) {
407
+ const t = e.touches[0]
408
+ const mx = t.clientX - touchRect.left
409
+ const my = t.clientY - touchRect.top
410
+ if (plot._is3D) {
411
+ const EDGE = 20
412
+ const { width, height } = canvas.getBoundingClientRect()
413
+ if (mx < EDGE || my < EDGE || mx > width - EDGE || my > height - EDGE) {
414
+ touchRotating = true
415
+ touchLastPos = [mx, my]
416
+ return
417
+ }
418
+ }
419
+ startTouchPan(mx, my)
420
+ } else {
421
+ touchRect = null
422
+ }
423
+ }
424
+ canvas.addEventListener('touchend', onTouchEnd, { passive: false })
425
+ canvas.addEventListener('touchcancel', onTouchEnd, { passive: false })
426
+
427
+ // Scroll wheel: zoom toward cursor position
428
+ canvas.addEventListener('wheel', (e) => {
429
+ e.preventDefault()
430
+ if (!plot.axisRegistry) return
431
+
432
+ const rect = canvas.getBoundingClientRect()
433
+ const mx = e.clientX - rect.left
434
+ const my = e.clientY - rect.top
435
+ const factor = e.deltaY > 0 ? 1.15 : 1 / 1.15
436
+ const region = this._getRegion(mx, my)
437
+ if (!region) return
438
+
439
+ const worldCursor = this._unproject(mx, my)
440
+
441
+ for (const axisId of region.axes) {
442
+ const scale = plot.axisRegistry.getScale(axisId)
443
+ if (!scale) continue
444
+
445
+ const { dir } = AXIS_GEOMETRY[axisId]
446
+ const dirIdx = dir === 'x' ? 0 : dir === 'y' ? 1 : 2
447
+ const [d0, d1] = scale.domain()
448
+ const isLog = plot.axisRegistry.isLogScale(axisId)
449
+ const t0 = isLog ? Math.log(d0) : d0
450
+ const t1 = isLog ? Math.log(d1) : d1
451
+ // Cursor t-position: worldCursor[dirIdx] ∈ [-1,+1] → t-space
452
+ const tCursor = (worldCursor[dirIdx] + 1) / 2 * (t1 - t0) + t0
453
+ // Zoom around cursor: keep tCursor fixed, scale the domain
454
+ const newT0 = tCursor + (t0 - tCursor) * factor
455
+ const newT1 = tCursor + (t1 - tCursor) * factor
456
+ plot._getAxis(axisId).setDomain(isLog ? [Math.exp(newT0), Math.exp(newT1)] : [newT0, newT1])
457
+ }
138
458
 
139
- fullOverlay.call(zoomBehavior)
459
+ plot.scheduleRender()
460
+ plot._zoomEndCallbacks.forEach(cb => cb())
461
+ }, { passive: false })
140
462
  }
141
463
  }
@@ -107,18 +107,38 @@ export function buildColorGlsl() {
107
107
  // map_color_s_2d — blend two 1D colorscales, or dispatch to a true 2D colorscale.
108
108
  // A true 2D colorscale is selected when cs_a < 0 && cs_a == cs_b (both axes share the
109
109
  // same 2D colorscale, identified by the negative index -(idx+1)).
110
- parts.push('vec4 map_color_s_2d(int cs_a, vec2 range_a, float v_a, float type_a,')
111
- parts.push(' int cs_b, vec2 range_b, float v_b, float type_b) {')
110
+ // useAlpha_a / useAlpha_b: if > 0.5, the normalised value for that axis modulates alpha.
111
+ parts.push('vec4 map_color_s_2d(int cs_a, vec2 range_a, float v_a, float type_a, float useAlpha_a,')
112
+ parts.push(' int cs_b, vec2 range_b, float v_b, float type_b, float useAlpha_b) {')
113
+
114
+ parts.push(' bool a_nan = isnan(v_a);')
115
+ parts.push(' bool b_nan = isnan(v_b);')
116
+ parts.push(' vec4 c;')
117
+
112
118
  parts.push(' if (cs_a < 0 && cs_a == cs_b) {')
113
- parts.push(' float ta = gladly_normalize_color(range_a, v_a, type_a);')
114
- parts.push(' float tb = gladly_normalize_color(range_b, v_b, type_b);')
115
- parts.push(' return gladly_apply_color(map_color_2d(-(cs_a + 1), vec2(ta, tb)));')
119
+ parts.push(' float ta = a_nan ? 0.0 : gladly_normalize_color(range_a, v_a, type_a);')
120
+ parts.push(' float tb = b_nan ? 0.0 : gladly_normalize_color(range_b, v_b, type_b);')
121
+ parts.push(' c = map_color_2d(-(cs_a + 1), vec2(ta, tb));')
122
+ parts.push(' } else if (cs_a >= 0) {')
123
+ parts.push(' if (!a_nan) c = gladly_map_color_raw(cs_a, range_a, v_a, type_a);')
124
+ parts.push(' else if (!b_nan) c = gladly_map_color_raw(cs_b, range_b, v_b, type_b);')
125
+ parts.push(' else c = vec4(0.0);')
126
+ parts.push(' } else {')
127
+ parts.push(' // fallback (cs_a < 0 but not equal to cs_b)')
128
+ parts.push(' if (!a_nan && !b_nan) {')
129
+ parts.push(' vec4 ca = gladly_map_color_raw(cs_a, range_a, v_a, type_a);')
130
+ parts.push(' vec4 cb = gladly_map_color_raw(cs_b, range_b, v_b, type_b);')
131
+ parts.push(' c = (ca + cb) / 2.0;')
132
+ parts.push(' } else if (!a_nan) c = gladly_map_color_raw(cs_a, range_a, v_a, type_a);')
133
+ parts.push(' else if (!b_nan) c = gladly_map_color_raw(cs_b, range_b, v_b, type_b);')
134
+ parts.push(' else c = vec4(0.0);')
116
135
  parts.push(' }')
117
- parts.push(' return gladly_apply_color(')
118
- parts.push(' (gladly_map_color_raw(cs_a, range_a, v_a, type_a) +')
119
- parts.push(' gladly_map_color_raw(cs_b, range_b, v_b, type_b)) / 2.0')
120
- parts.push(' );')
121
- parts.push('}')
122
136
 
137
+ parts.push(' if (!a_nan && useAlpha_a > 0.5) c.a = gladly_normalize_color(range_a, v_a, type_a);')
138
+ parts.push(' if (!b_nan && useAlpha_b > 0.5) c.a *= gladly_normalize_color(range_b, v_b, type_b);')
139
+
140
+ parts.push(' return gladly_apply_color(c);')
141
+ parts.push('}')
142
+
123
143
  return parts.join('\n')
124
144
  }