gladly-plot 0.0.4 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/package.json +10 -11
- package/src/axes/Axis.js +401 -0
- package/src/{AxisLink.js → axes/AxisLink.js} +6 -2
- package/src/{AxisQuantityKindRegistry.js → axes/AxisQuantityKindRegistry.js} +7 -0
- package/src/axes/AxisRegistry.js +179 -0
- package/src/axes/Camera.js +47 -0
- package/src/axes/ColorAxisRegistry.js +101 -0
- package/src/{FilterAxisRegistry.js → axes/FilterAxisRegistry.js} +63 -0
- package/src/axes/TickLabelAtlas.js +99 -0
- package/src/axes/ZoomController.js +463 -0
- package/src/colorscales/BivariateColorscales.js +205 -0
- package/src/colorscales/ColorscaleRegistry.js +144 -0
- package/src/compute/ComputationRegistry.js +179 -0
- package/src/compute/axisFilter.js +59 -0
- package/src/compute/conv.js +286 -0
- package/src/compute/elementwise.js +72 -0
- package/src/compute/fft.js +378 -0
- package/src/compute/filter.js +229 -0
- package/src/compute/hist.js +285 -0
- package/src/compute/kde.js +120 -0
- package/src/compute/scatter2dInterpolate.js +277 -0
- package/src/compute/util.js +196 -0
- package/src/core/ComputePipeline.js +153 -0
- package/src/core/GlBase.js +141 -0
- package/src/core/Layer.js +59 -0
- package/src/core/LayerType.js +433 -0
- package/src/core/Plot.js +1213 -0
- package/src/core/PlotGroup.js +204 -0
- package/src/core/ShaderQueue.js +73 -0
- package/src/data/ColumnData.js +269 -0
- package/src/data/Computation.js +95 -0
- package/src/data/Data.js +270 -0
- package/src/{Colorbar.js → floats/Colorbar.js} +19 -5
- package/src/floats/Colorbar2d.js +77 -0
- package/src/{Filterbar.js → floats/Filterbar.js} +18 -4
- package/src/{FilterbarFloat.js → floats/Float.js} +73 -30
- package/src/{EpsgUtils.js → geo/EpsgUtils.js} +1 -1
- package/src/index.js +47 -22
- package/src/layers/BarsLayer.js +168 -0
- package/src/{ColorbarLayer.js → layers/ColorbarLayer.js} +12 -16
- package/src/layers/ColorbarLayer2d.js +86 -0
- package/src/{FilterbarLayer.js → layers/FilterbarLayer.js} +6 -5
- package/src/layers/LinesLayer.js +185 -0
- package/src/layers/PointsLayer.js +118 -0
- package/src/layers/ScatterShared.js +98 -0
- package/src/{TileLayer.js → layers/TileLayer.js} +24 -20
- package/src/math/mat4.js +100 -0
- package/src/Axis.js +0 -48
- package/src/AxisRegistry.js +0 -54
- package/src/ColorAxisRegistry.js +0 -49
- package/src/ColorscaleRegistry.js +0 -52
- package/src/Data.js +0 -67
- package/src/Float.js +0 -159
- package/src/Layer.js +0 -44
- package/src/LayerType.js +0 -209
- package/src/Plot.js +0 -1073
- package/src/ScatterLayer.js +0 -287
- /package/src/{MatplotlibColorscales.js → colorscales/MatplotlibColorscales.js} +0 -0
- /package/src/{LayerTypeRegistry.js → core/LayerTypeRegistry.js} +0 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import { AXIS_GEOMETRY, axisEndpoints } from './AxisRegistry.js'
|
|
2
|
+
import { mat4Multiply, mat4Identity, projectToScreen, sphericalToCartesian } from '../math/mat4.js'
|
|
3
|
+
|
|
4
|
+
export class ZoomController {
|
|
5
|
+
constructor(plot) {
|
|
6
|
+
this._plot = plot
|
|
7
|
+
this._init()
|
|
8
|
+
}
|
|
9
|
+
|
|
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) {
|
|
46
|
+
const plot = this._plot
|
|
47
|
+
const { width, height, axisRegistry: ar, _camera: cam } = plot
|
|
48
|
+
const axisMvp = this._computeAxisMvp()
|
|
49
|
+
|
|
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())
|
|
146
|
+
|
|
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
|
+
}
|
|
309
|
+
}
|
|
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
|
|
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
|
+
}
|
|
388
|
+
plot.scheduleRender()
|
|
389
|
+
plot._zoomEndCallbacks.forEach(cb => cb())
|
|
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
|
+
}
|
|
458
|
+
|
|
459
|
+
plot.scheduleRender()
|
|
460
|
+
plot._zoomEndCallbacks.forEach(cb => cb())
|
|
461
|
+
}, { passive: false })
|
|
462
|
+
}
|
|
463
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { register2DColorscale } from './ColorscaleRegistry.js'
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
//////////////////////////////
|
|
5
|
+
// 1. Bilinear 4-corner colormap
|
|
6
|
+
//////////////////////////////
|
|
7
|
+
register2DColorscale("bilinear4corner", `
|
|
8
|
+
vec4 colorscale_2d_bilinear4corner(vec2 t) {
|
|
9
|
+
vec3 c00 = vec3(0.0, 0.0, 1.0);
|
|
10
|
+
vec3 c10 = vec3(1.0, 0.0, 0.0);
|
|
11
|
+
vec3 c01 = vec3(0.0, 1.0, 0.0);
|
|
12
|
+
vec3 c11 = vec3(1.0, 1.0, 0.0);
|
|
13
|
+
vec3 rgb = (1.0 - t.x)*(1.0 - t.y)*c00 +
|
|
14
|
+
t.x*(1.0 - t.y)*c10 +
|
|
15
|
+
(1.0 - t.x)*t.y*c01 +
|
|
16
|
+
t.x*t.y*c11;
|
|
17
|
+
return vec4(rgb,1.0);
|
|
18
|
+
}
|
|
19
|
+
`);
|
|
20
|
+
|
|
21
|
+
register2DColorscale("Gred", `
|
|
22
|
+
vec4 colorscale_2d_Gred(vec2 t) {
|
|
23
|
+
vec3 c00 = vec3(0.0, 0.0, 0.0);
|
|
24
|
+
vec3 c10 = vec3(1.0, 0.0, 0.0);
|
|
25
|
+
vec3 c01 = vec3(0.0, 1.0, 0.0);
|
|
26
|
+
vec3 c11 = vec3(1.0, 1.0, 1.0);
|
|
27
|
+
vec3 rgb = (1.0 - t.x)*(1.0 - t.y)*c00 +
|
|
28
|
+
t.x*(1.0 - t.y)*c10 +
|
|
29
|
+
(1.0 - t.x)*t.y*c01 +
|
|
30
|
+
t.x*t.y*c11;
|
|
31
|
+
return vec4(rgb,1.0);
|
|
32
|
+
}
|
|
33
|
+
`);
|
|
34
|
+
|
|
35
|
+
register2DColorscale("Reen", `
|
|
36
|
+
vec4 colorscale_2d_Reen(vec2 t) {
|
|
37
|
+
vec3 c00 = vec3(1.0, 0.0, 0.0);
|
|
38
|
+
vec3 c10 = vec3(0.0, 0.0, 0.0);
|
|
39
|
+
vec3 c01 = vec3(1.0, 1.0, 1.0);
|
|
40
|
+
vec3 c11 = vec3(0.0, 1.0, 0.0);
|
|
41
|
+
vec3 rgb = (1.0 - t.x)*(1.0 - t.y)*c00 +
|
|
42
|
+
t.x*(1.0 - t.y)*c10 +
|
|
43
|
+
(1.0 - t.x)*t.y*c01 +
|
|
44
|
+
t.x*t.y*c11;
|
|
45
|
+
return vec4(rgb,1.0);
|
|
46
|
+
}
|
|
47
|
+
`);
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
//////////////////////////////
|
|
51
|
+
// 2. HSV Phase-Magnitude Map
|
|
52
|
+
//////////////////////////////
|
|
53
|
+
register2DColorscale("hsv_phase_magnitude", `
|
|
54
|
+
vec4 colorscale_2d_hsv_phase_magnitude(vec2 t) {
|
|
55
|
+
float angle = atan(t.y - 0.5, t.x - 0.5);
|
|
56
|
+
float r = length(t - vec2(0.5));
|
|
57
|
+
float H = (angle + 3.1415926)/(2.0*3.1415926);
|
|
58
|
+
float S = 1.0;
|
|
59
|
+
float V = clamp(r*1.4142136,0.0,1.0);
|
|
60
|
+
float c = V*S;
|
|
61
|
+
float h = H*6.0;
|
|
62
|
+
float x = c*(1.0 - abs(mod(h,2.0)-1.0));
|
|
63
|
+
vec3 rgb;
|
|
64
|
+
if(h<1.0) rgb = vec3(c,x,0.0);
|
|
65
|
+
else if(h<2.0) rgb = vec3(x,c,0.0);
|
|
66
|
+
else if(h<3.0) rgb = vec3(0.0,c,x);
|
|
67
|
+
else if(h<4.0) rgb = vec3(0.0,x,c);
|
|
68
|
+
else if(h<5.0) rgb = vec3(x,0.0,c);
|
|
69
|
+
else rgb = vec3(c,0.0,x);
|
|
70
|
+
float m = V - c;
|
|
71
|
+
rgb += vec3(m);
|
|
72
|
+
return vec4(rgb,1.0);
|
|
73
|
+
}
|
|
74
|
+
`);
|
|
75
|
+
|
|
76
|
+
//////////////////////////////
|
|
77
|
+
// 3. Diverging × Diverging Map
|
|
78
|
+
//////////////////////////////
|
|
79
|
+
register2DColorscale("diverging_diverging", `
|
|
80
|
+
vec4 colorscale_2d_diverging_diverging(vec2 t) {
|
|
81
|
+
vec3 blue = vec3(0.230,0.299,0.754);
|
|
82
|
+
vec3 white = vec3(1.0);
|
|
83
|
+
vec3 red = vec3(0.706,0.016,0.150);
|
|
84
|
+
vec3 rgbX = (t.x<0.5) ? mix(blue,white,t.x*2.0) : mix(white,red,(t.x-0.5)*2.0);
|
|
85
|
+
vec3 rgbY = (t.y<0.5) ? mix(blue,white,t.y*2.0) : mix(white,red,(t.y-0.5)*2.0);
|
|
86
|
+
vec3 rgb = 0.5*(rgbX+rgbY);
|
|
87
|
+
return vec4(rgb,1.0);
|
|
88
|
+
}
|
|
89
|
+
`);
|
|
90
|
+
|
|
91
|
+
//////////////////////////////
|
|
92
|
+
// 4. Lightness × Hue Map
|
|
93
|
+
//////////////////////////////
|
|
94
|
+
register2DColorscale("lightness_hue", `
|
|
95
|
+
vec4 colorscale_2d_lightness_hue(vec2 t) {
|
|
96
|
+
float H = t.x;
|
|
97
|
+
float L = t.y;
|
|
98
|
+
float C = 1.0 - abs(2.0*L-1.0);
|
|
99
|
+
float X = C*(1.0 - abs(mod(H*6.0,2.0)-1.0));
|
|
100
|
+
vec3 rgb;
|
|
101
|
+
if(H<1.0/6.0) rgb = vec3(C,X,0.0);
|
|
102
|
+
else if(H<2.0/6.0) rgb = vec3(X,C,0.0);
|
|
103
|
+
else if(H<3.0/6.0) rgb = vec3(0.0,C,X);
|
|
104
|
+
else if(H<4.0/6.0) rgb = vec3(0.0,X,C);
|
|
105
|
+
else if(H<5.0/6.0) rgb = vec3(X,0.0,C);
|
|
106
|
+
else rgb = vec3(C,0.0,X);
|
|
107
|
+
float m = L - 0.5*C;
|
|
108
|
+
rgb += vec3(m);
|
|
109
|
+
return vec4(rgb,1.0);
|
|
110
|
+
}
|
|
111
|
+
`);
|
|
112
|
+
|
|
113
|
+
//////////////////////////////
|
|
114
|
+
// 5. Brewer 3x3 Bivariate Grid (no vec3 c[3][3])
|
|
115
|
+
//////////////////////////////
|
|
116
|
+
register2DColorscale("brewer_3x3", `
|
|
117
|
+
vec4 colorscale_2d_brewer_3x3(vec2 t) {
|
|
118
|
+
float fx = clamp(t.x*2.0,0.0,2.0);
|
|
119
|
+
float fy = clamp(t.y*2.0,0.0,2.0);
|
|
120
|
+
int ix = int(fx);
|
|
121
|
+
int iy = int(fy);
|
|
122
|
+
vec3 rgb;
|
|
123
|
+
if(ix==0 && iy==0) rgb = vec3(215.0,25.0,28.0)/255.0;
|
|
124
|
+
else if(ix==1 && iy==0) rgb = vec3(253.0,174.0,97.0)/255.0;
|
|
125
|
+
else if(ix==2 && iy==0) rgb = vec3(255.0,255.0,191.0)/255.0;
|
|
126
|
+
else if(ix==0 && iy==1) rgb = vec3(224.0,130.0,20.0)/255.0;
|
|
127
|
+
else if(ix==1 && iy==1) rgb = vec3(255.0,255.0,179.0)/255.0;
|
|
128
|
+
else if(ix==2 && iy==1) rgb = vec3(171.0,221.0,164.0)/255.0;
|
|
129
|
+
else if(ix==0 && iy==2) rgb = vec3(26.0,150.0,65.0)/255.0;
|
|
130
|
+
else if(ix==1 && iy==2) rgb = vec3(166.0,217.0,106.0)/255.0;
|
|
131
|
+
else rgb = vec3(102.0,194.0,165.0)/255.0;
|
|
132
|
+
return vec4(rgb,1.0);
|
|
133
|
+
}
|
|
134
|
+
`);
|
|
135
|
+
|
|
136
|
+
//////////////////////////////
|
|
137
|
+
// 6. Moreland 5x5 Perceptual Grid (flattened)
|
|
138
|
+
//////////////////////////////
|
|
139
|
+
register2DColorscale("moreland_5x5", `
|
|
140
|
+
vec4 colorscale_2d_moreland_5x5(vec2 t) {
|
|
141
|
+
float fx = clamp(t.x*4.0,0.0,4.0);
|
|
142
|
+
float fy = clamp(t.y*4.0,0.0,4.0);
|
|
143
|
+
int ix = int(fx);
|
|
144
|
+
int iy = int(fy);
|
|
145
|
+
vec3 rgb;
|
|
146
|
+
if(ix==0 && iy==0) rgb = vec3(0.230,0.299,0.754);
|
|
147
|
+
else if(ix==1 && iy==0) rgb = vec3(0.375,0.544,0.837);
|
|
148
|
+
else if(ix==2 && iy==0) rgb = vec3(0.625,0.732,0.941);
|
|
149
|
+
else if(ix==3 && iy==0) rgb = vec3(0.843,0.867,0.996);
|
|
150
|
+
else if(ix==4 && iy==0) rgb = vec3(0.980,0.957,0.996);
|
|
151
|
+
else if(ix==0 && iy==1) rgb = vec3(0.266,0.353,0.819);
|
|
152
|
+
else if(ix==1 && iy==1) rgb = vec3(0.420,0.585,0.876);
|
|
153
|
+
else if(ix==2 && iy==1) rgb = vec3(0.666,0.762,0.961);
|
|
154
|
+
else if(ix==3 && iy==1) rgb = vec3(0.876,0.888,0.996);
|
|
155
|
+
else if(ix==4 && iy==1) rgb = vec3(0.992,0.969,0.996);
|
|
156
|
+
else if(ix==0 && iy==2) rgb = vec3(0.305,0.407,0.875);
|
|
157
|
+
else if(ix==1 && iy==2) rgb = vec3(0.466,0.625,0.911);
|
|
158
|
+
else if(ix==2 && iy==2) rgb = vec3(0.710,0.791,0.976);
|
|
159
|
+
else if(ix==3 && iy==2) rgb = vec3(0.905,0.908,0.996);
|
|
160
|
+
else if(ix==4 && iy==2) rgb = vec3(0.996,0.980,0.996);
|
|
161
|
+
else if(ix==0 && iy==3) rgb = vec3(0.349,0.460,0.926);
|
|
162
|
+
else if(ix==1 && iy==3) rgb = vec3(0.514,0.664,0.944);
|
|
163
|
+
else if(ix==2 && iy==3) rgb = vec3(0.753,0.817,0.988);
|
|
164
|
+
else if(ix==3 && iy==3) rgb = vec3(0.933,0.926,0.996);
|
|
165
|
+
else if(ix==4 && iy==3) rgb = vec3(0.996,0.988,0.996);
|
|
166
|
+
else if(ix==0 && iy==4) rgb = vec3(0.403,0.509,0.965);
|
|
167
|
+
else if(ix==1 && iy==4) rgb = vec3(0.563,0.700,0.972);
|
|
168
|
+
else if(ix==2 && iy==4) rgb = vec3(0.796,0.843,0.996);
|
|
169
|
+
else if(ix==3 && iy==4) rgb = vec3(0.960,0.944,0.996);
|
|
170
|
+
else rgb = vec3(1.000,1.000,1.000);
|
|
171
|
+
return vec4(rgb,1.0);
|
|
172
|
+
}
|
|
173
|
+
`);
|
|
174
|
+
|
|
175
|
+
//////////////////////////////
|
|
176
|
+
// 7. Boy's Surface / Orientation Map
|
|
177
|
+
//////////////////////////////
|
|
178
|
+
register2DColorscale("boys_surface", `
|
|
179
|
+
vec4 colorscale_2d_boys_surface(vec2 t) {
|
|
180
|
+
float u = t.x*2.0-1.0;
|
|
181
|
+
float v = t.y*2.0-1.0;
|
|
182
|
+
float x = u*(1.0-v*v/2.0);
|
|
183
|
+
float y = v*(1.0-u*u/2.0);
|
|
184
|
+
float z = (u*u-v*v)/2.0;
|
|
185
|
+
vec3 rgb = normalize(vec3(abs(x),abs(y),abs(z)));
|
|
186
|
+
return vec4(rgb,1.0);
|
|
187
|
+
}
|
|
188
|
+
`);
|
|
189
|
+
|
|
190
|
+
//////////////////////////////
|
|
191
|
+
// 8. Diverging × Sequential Map
|
|
192
|
+
//////////////////////////////
|
|
193
|
+
register2DColorscale("diverging_sequential", `
|
|
194
|
+
vec4 colorscale_2d_diverging_sequential(vec2 t) {
|
|
195
|
+
vec3 blue = vec3(0.230,0.299,0.754);
|
|
196
|
+
vec3 white = vec3(1.0);
|
|
197
|
+
vec3 red = vec3(0.706,0.016,0.150);
|
|
198
|
+
vec3 seqStart = vec3(1.0,1.0,0.8);
|
|
199
|
+
vec3 seqEnd = vec3(0.2,0.8,0.2);
|
|
200
|
+
vec3 rgbX = (t.x<0.5)? mix(blue,white,t.x*2.0) : mix(white,red,(t.x-0.5)*2.0);
|
|
201
|
+
vec3 rgbY = mix(seqStart,seqEnd,t.y);
|
|
202
|
+
vec3 rgb = 0.5*(rgbX+rgbY);
|
|
203
|
+
return vec4(rgb,1.0);
|
|
204
|
+
}
|
|
205
|
+
`);
|