gladly-plot 0.0.5 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/package.json +10 -11
- package/src/axes/Axis.js +320 -172
- package/src/axes/AxisLink.js +6 -2
- package/src/axes/AxisRegistry.js +116 -39
- package/src/axes/Camera.js +47 -0
- package/src/axes/ColorAxisRegistry.js +10 -2
- package/src/axes/FilterAxisRegistry.js +1 -1
- package/src/axes/TickLabelAtlas.js +99 -0
- package/src/axes/ZoomController.js +446 -124
- package/src/colorscales/ColorscaleRegistry.js +30 -10
- package/src/compute/ComputationRegistry.js +126 -184
- package/src/compute/axisFilter.js +21 -9
- package/src/compute/conv.js +64 -8
- package/src/compute/elementwise.js +72 -0
- package/src/compute/fft.js +106 -20
- package/src/compute/filter.js +105 -103
- package/src/compute/hist.js +247 -142
- package/src/compute/kde.js +64 -46
- package/src/compute/scatter2dInterpolate.js +277 -0
- package/src/compute/util.js +196 -0
- package/src/core/ComputePipeline.js +153 -0
- package/src/core/GlBase.js +141 -0
- package/src/core/Layer.js +22 -8
- package/src/core/LayerType.js +253 -92
- package/src/core/Plot.js +644 -162
- package/src/core/PlotGroup.js +204 -0
- package/src/core/ShaderQueue.js +73 -0
- package/src/data/ColumnData.js +269 -0
- package/src/data/Computation.js +95 -0
- package/src/data/Data.js +270 -0
- package/src/floats/Float.js +56 -0
- package/src/index.js +16 -4
- package/src/layers/BarsLayer.js +168 -0
- package/src/layers/ColorbarLayer.js +10 -14
- package/src/layers/ColorbarLayer2d.js +13 -24
- package/src/layers/FilterbarLayer.js +4 -3
- package/src/layers/LinesLayer.js +108 -122
- package/src/layers/PointsLayer.js +73 -69
- package/src/layers/ScatterShared.js +62 -106
- package/src/layers/TileLayer.js +20 -16
- package/src/math/mat4.js +100 -0
- package/src/core/Data.js +0 -67
- package/src/layers/HistogramLayer.js +0 -212
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
.
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
parts.push('
|
|
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('
|
|
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
|
}
|