@@ -14,13 +14,20 @@ const DEFAULT_STATE = {
14
14
naturalHeight : 0 ,
15
15
width : 0 ,
16
16
17
- // Managed internally
17
+ // Internal gesture state
18
18
isDragging : false ,
19
+ lastTouches : null ,
20
+ touchCenterX : 0 ,
21
+
22
+ // Current pan and zoom
19
23
x : 0 ,
20
24
y : 0 ,
21
25
z : 1 ,
22
26
} ;
23
27
28
+ // TODO Ignore right click
29
+ // https://github.com/d3/d3-zoom/blob/main/src/zoom.js#L11
30
+
24
31
// getDateLocation() should be kep in-sync with getDateLocation() in "drawingUtils.js"
25
32
function getDateLocation ( date , metadata , z , width ) {
26
33
const { startDate, stopDate } = metadata ;
@@ -68,7 +75,11 @@ function reduce(state, action) {
68
75
return {
69
76
...state ,
70
77
metadata,
78
+
79
+ // Reset scroll state when metadata changes.
71
80
isDragging : false ,
81
+ lastTouches : null ,
82
+ touchCenterX : 0 ,
72
83
x : 0 ,
73
84
y : 0 ,
74
85
z : 1 ,
@@ -80,49 +91,64 @@ function reduce(state, action) {
80
91
81
92
if ( deltaX !== 0 ) {
82
93
// TODO Can we cache this on "zoom" (and "set-chart-size") ?
83
- const maxOffsetX =
94
+ const maxX =
84
95
width - getDateLocation ( metadata . stopDate , metadata , z , width ) ;
85
96
86
- const x = Math . min ( 0 , Math . max ( maxOffsetX , state . x - deltaX ) ) ;
97
+ const x = Math . min ( 0 , Math . max ( maxX , state . x - deltaX ) ) ;
87
98
88
99
return {
89
100
...state ,
90
101
x : Math . round ( x ) ,
91
102
} ;
92
103
} else if ( deltaY !== 0 ) {
93
- const maxOffsetY = height - naturalHeight ;
104
+ const maxY = height - naturalHeight ;
94
105
95
106
// TODO Respect natural scroll preference (if we can detect it?).
96
- const newOffsetY = state . y - deltaY ;
97
- const y = Math . min ( 0 , Math . max ( maxOffsetY , newOffsetY ) ) ;
107
+ const newY = state . y - deltaY ;
108
+ const y = Math . min ( 0 , Math . max ( maxY , newY ) ) ;
98
109
99
110
return {
100
111
...state ,
101
112
y : Math . round ( y ) ,
102
113
} ;
103
114
}
104
115
}
116
+ case "update-touch-state" : {
117
+ const { lastTouches, touchCenterX } = payload ;
118
+
119
+ const newState = {
120
+ ...state ,
121
+ lastTouches,
122
+ } ;
123
+
124
+ if ( touchCenterX !== undefined ) {
125
+ newState . touchCenterX = touchCenterX ;
126
+ }
127
+
128
+ return newState ;
129
+ break ;
130
+ }
105
131
case "zoom" : {
106
132
const { metadata, x, width } = state ;
107
- const { deltaX , deltaY , locationX } = payload ;
133
+ const { delta , locationX } = payload ;
108
134
109
135
const z = Math . max (
110
136
MIN_SCALE ,
111
- Math . min ( MAX_SCALE , state . z - deltaY * ZOOM_MULTIPLIER )
137
+ Math . min ( MAX_SCALE , state . z - delta * ZOOM_MULTIPLIER )
112
138
) ;
113
139
114
- const maxOffsetX =
140
+ const maxX =
115
141
width - getDateLocation ( metadata . stopDate , metadata , z , width ) ;
116
142
117
143
// Zoom in/out around the point we're currently hovered over.
118
144
const scaleMultiplier = z / state . z ;
119
145
const scaledDelta = locationX - locationX * scaleMultiplier ;
120
- const newOffsetX = x * scaleMultiplier + scaledDelta ;
121
- const newClampedOffsetX = Math . min ( 0 , Math . max ( maxOffsetX , newOffsetX ) ) ;
146
+ const newX = x * scaleMultiplier + scaledDelta ;
147
+ const newClampedX = Math . min ( 0 , Math . max ( maxX , newX ) ) ;
122
148
123
149
return {
124
150
...state ,
125
- x : Math . round ( newClampedOffsetX ) ,
151
+ x : Math . round ( newClampedX ) ,
126
152
z,
127
153
} ;
128
154
}
@@ -170,8 +196,8 @@ export default function useZoom({
170
196
} ;
171
197
172
198
const handleMouseMove = ( event ) => {
173
- const currentState = stateRef . current ;
174
- if ( ! currentState . isDragging ) {
199
+ const { isDragging } = stateRef . current ;
200
+ if ( ! isDragging ) {
175
201
return ;
176
202
}
177
203
@@ -209,17 +235,123 @@ export default function useZoom({
209
235
dispatch ( { type : "set-is-dragging" , payload : { isDragging : false } } ) ;
210
236
} ;
211
237
238
+ const handleTouchEnd = ( event ) => {
239
+ dispatch ( {
240
+ type : "update-touch-state" ,
241
+ payload : { touchCenterX : null , lastTouches : null } ,
242
+ } ) ;
243
+ } ;
244
+
245
+ const handleTouchMove = ( event ) => {
246
+ const { touchCenterX, lastTouches } = stateRef . current ;
247
+ if ( lastTouches === null ) {
248
+ return ;
249
+ }
250
+
251
+ const { changedTouches, touches } = event ;
252
+ if ( changedTouches . length !== lastTouches . length ) {
253
+ return ;
254
+ }
255
+
256
+ stopEvent ( event ) ;
257
+
258
+ // Return an array of changed deltas, sorted along the x axis.
259
+ // This sorting is required for "zoom" logic since positive or negative values
260
+ // depend on the direction of the touch (which finger is pinching).
261
+ const sortedTouches = Array . from ( changedTouches ) . sort ( ( a , b ) => {
262
+ if ( a . pageX < b . pageX ) {
263
+ return 1 ;
264
+ } else if ( a . pageX > b . pageX ) {
265
+ return - 1 ;
266
+ } else {
267
+ return 0 ;
268
+ }
269
+ } ) ;
270
+
271
+ const lastTouchesMap = new Map ( ) ;
272
+ for ( let touch of lastTouches ) {
273
+ lastTouchesMap . set ( touch . identifier , {
274
+ pageX : touch . pageX ,
275
+ pageY : touch . pageY ,
276
+ } ) ;
277
+ }
278
+
279
+ const deltas = [ ] ;
280
+ for ( let changedTouch of sortedTouches ) {
281
+ const touch = lastTouchesMap . get ( changedTouch . identifier ) ;
282
+ if ( touch ) {
283
+ deltas . push ( [
284
+ changedTouch . pageX - touch . pageX ,
285
+ changedTouch . pageY - touch . pageY ,
286
+ ] ) ;
287
+ }
288
+ }
289
+
290
+ // TODO Check delta threshold(s)
291
+
292
+ if ( deltas . length === 1 ) {
293
+ const [ deltaX , deltaY ] = deltas [ 0 ] ;
294
+ if ( Math . abs ( deltaX ) > Math . abs ( deltaY ) ) {
295
+ dispatch ( {
296
+ type : "pan" ,
297
+ payload : { deltaX : 0 - deltaX , deltaY : 0 } ,
298
+ } ) ;
299
+ } else {
300
+ dispatch ( {
301
+ type : "pan" ,
302
+ payload : { deltaX : 0 , deltaY : 0 - deltaY } ,
303
+ } ) ;
304
+ }
305
+ } else if ( deltas . length === 2 ) {
306
+ const [ [ deltaX0 , deltaY0 ] , [ deltaX1 , deltaY1 ] ] = deltas ;
307
+ const deltaXAbsolute = Math . abs ( deltaX0 ) + Math . abs ( deltaX1 ) ;
308
+ const deltaYAbsolute = Math . abs ( deltaY0 ) + Math . abs ( deltaY1 ) ;
309
+
310
+ // Horizontal zooms; ignore vertical.
311
+ if ( deltaXAbsolute > deltaYAbsolute ) {
312
+ const delta =
313
+ Math . abs ( deltaX0 ) > Math . abs ( deltaX1 ) ? 0 - deltaX0 : deltaX1 ;
314
+
315
+ dispatch ( {
316
+ type : "zoom" ,
317
+ payload : {
318
+ delta,
319
+ locationX : touchCenterX ,
320
+ } ,
321
+ } ) ;
322
+ }
323
+ }
324
+
325
+ dispatch ( {
326
+ type : "update-touch-state" ,
327
+ payload : { lastTouches : touches , touchCenterX } ,
328
+ } ) ;
329
+ } ;
330
+
331
+ const handleTouchStart = ( event ) => {
332
+ const { touches } = event ;
333
+
334
+ const touchCenterX =
335
+ touches . length === 1
336
+ ? touches [ 0 ] . pageX
337
+ : touches [ 0 ] . pageX + ( touches [ 1 ] . pageX - touches [ 0 ] . pageX ) / 2 ;
338
+
339
+ dispatch ( {
340
+ type : "update-touch-state" ,
341
+ payload : { touchCenterX, lastTouches : touches } ,
342
+ } ) ;
343
+
344
+ // TODO Double tab should zoom in around a point.
345
+ } ;
346
+
212
347
const handleWheel = ( event ) => {
213
348
const { shiftKey, x } = event ;
214
349
const { deltaX, deltaY } = normalizeWheelEvent ( event ) ;
215
350
216
351
const deltaYAbsolute = Math . abs ( deltaY ) ;
217
352
const deltaXAbsolute = Math . abs ( deltaX ) ;
218
353
219
- const currentState = stateRef . current ;
220
-
221
- // Vertical scrolling zooms in and out (unless the SHIFT modifier is used).
222
- // Horizontal scrolling pans.
354
+ // Horizontal wheel pans; vertical wheel zooms (unless the SHIFT modifier is used).
223
355
if ( deltaYAbsolute > deltaXAbsolute ) {
224
356
if ( shiftKey ) {
225
357
stopEvent ( event ) ;
@@ -240,7 +372,7 @@ export default function useZoom({
240
372
241
373
dispatch ( {
242
374
type : "zoom" ,
243
- payload : { deltaX , deltaY, locationX } ,
375
+ payload : { delta : deltaY , locationX } ,
244
376
} ) ;
245
377
}
246
378
}
@@ -257,17 +389,25 @@ export default function useZoom({
257
389
} ;
258
390
259
391
canvas . addEventListener ( "mousedown" , handleMouseDown ) ;
392
+ canvas . addEventListener ( "touchstart" , handleTouchStart ) ;
260
393
canvas . addEventListener ( "wheel" , handleWheel ) ;
261
394
262
395
window . addEventListener ( "mousemove" , handleMouseMove ) ;
263
396
window . addEventListener ( "mouseup" , handleMouseUp ) ;
397
+ window . addEventListener ( "touchend" , handleTouchEnd ) ;
398
+ window . addEventListener ( "touchmove" , handleTouchMove , { passive : false } ) ;
264
399
265
400
return ( ) => {
266
401
canvas . removeEventListener ( "mousedown" , handleMouseDown ) ;
402
+ canvas . removeEventListener ( "touchstart" , handleTouchStart ) ;
267
403
canvas . removeEventListener ( "wheel" , handleWheel ) ;
268
404
269
405
window . removeEventListener ( "mousemove" , handleMouseMove ) ;
270
406
window . removeEventListener ( "mouseup" , handleMouseUp ) ;
407
+ window . removeEventListener ( "touchend" , handleTouchEnd ) ;
408
+ window . removeEventListener ( "touchmove" , handleTouchMove , {
409
+ passive : false ,
410
+ } ) ;
271
411
} ;
272
412
}
273
413
} , [ canvasRef ] ) ;
0 commit comments