Skip to content

Commit 313aebb

Browse files
author
Brian Vaughn
committed
Add rudimentary support for touch events (e.g. pinch to zoom, pan)
1 parent b13fc9f commit 313aebb

File tree

1 file changed

+159
-19
lines changed

1 file changed

+159
-19
lines changed

components/Planner/useZoom.js

+159-19
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,20 @@ const DEFAULT_STATE = {
1414
naturalHeight: 0,
1515
width: 0,
1616

17-
// Managed internally
17+
// Internal gesture state
1818
isDragging: false,
19+
lastTouches: null,
20+
touchCenterX: 0,
21+
22+
// Current pan and zoom
1923
x: 0,
2024
y: 0,
2125
z: 1,
2226
};
2327

28+
// TODO Ignore right click
29+
// https://github.com/d3/d3-zoom/blob/main/src/zoom.js#L11
30+
2431
// getDateLocation() should be kep in-sync with getDateLocation() in "drawingUtils.js"
2532
function getDateLocation(date, metadata, z, width) {
2633
const { startDate, stopDate } = metadata;
@@ -68,7 +75,11 @@ function reduce(state, action) {
6875
return {
6976
...state,
7077
metadata,
78+
79+
// Reset scroll state when metadata changes.
7180
isDragging: false,
81+
lastTouches: null,
82+
touchCenterX: 0,
7283
x: 0,
7384
y: 0,
7485
z: 1,
@@ -80,49 +91,64 @@ function reduce(state, action) {
8091

8192
if (deltaX !== 0) {
8293
// TODO Can we cache this on "zoom" (and "set-chart-size") ?
83-
const maxOffsetX =
94+
const maxX =
8495
width - getDateLocation(metadata.stopDate, metadata, z, width);
8596

86-
const x = Math.min(0, Math.max(maxOffsetX, state.x - deltaX));
97+
const x = Math.min(0, Math.max(maxX, state.x - deltaX));
8798

8899
return {
89100
...state,
90101
x: Math.round(x),
91102
};
92103
} else if (deltaY !== 0) {
93-
const maxOffsetY = height - naturalHeight;
104+
const maxY = height - naturalHeight;
94105

95106
// 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));
98109

99110
return {
100111
...state,
101112
y: Math.round(y),
102113
};
103114
}
104115
}
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+
}
105131
case "zoom": {
106132
const { metadata, x, width } = state;
107-
const { deltaX, deltaY, locationX } = payload;
133+
const { delta, locationX } = payload;
108134

109135
const z = Math.max(
110136
MIN_SCALE,
111-
Math.min(MAX_SCALE, state.z - deltaY * ZOOM_MULTIPLIER)
137+
Math.min(MAX_SCALE, state.z - delta * ZOOM_MULTIPLIER)
112138
);
113139

114-
const maxOffsetX =
140+
const maxX =
115141
width - getDateLocation(metadata.stopDate, metadata, z, width);
116142

117143
// Zoom in/out around the point we're currently hovered over.
118144
const scaleMultiplier = z / state.z;
119145
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));
122148

123149
return {
124150
...state,
125-
x: Math.round(newClampedOffsetX),
151+
x: Math.round(newClampedX),
126152
z,
127153
};
128154
}
@@ -170,8 +196,8 @@ export default function useZoom({
170196
};
171197

172198
const handleMouseMove = (event) => {
173-
const currentState = stateRef.current;
174-
if (!currentState.isDragging) {
199+
const { isDragging } = stateRef.current;
200+
if (!isDragging) {
175201
return;
176202
}
177203

@@ -209,17 +235,123 @@ export default function useZoom({
209235
dispatch({ type: "set-is-dragging", payload: { isDragging: false } });
210236
};
211237

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+
212347
const handleWheel = (event) => {
213348
const { shiftKey, x } = event;
214349
const { deltaX, deltaY } = normalizeWheelEvent(event);
215350

216351
const deltaYAbsolute = Math.abs(deltaY);
217352
const deltaXAbsolute = Math.abs(deltaX);
218353

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).
223355
if (deltaYAbsolute > deltaXAbsolute) {
224356
if (shiftKey) {
225357
stopEvent(event);
@@ -240,7 +372,7 @@ export default function useZoom({
240372

241373
dispatch({
242374
type: "zoom",
243-
payload: { deltaX, deltaY, locationX },
375+
payload: { delta: deltaY, locationX },
244376
});
245377
}
246378
}
@@ -257,17 +389,25 @@ export default function useZoom({
257389
};
258390

259391
canvas.addEventListener("mousedown", handleMouseDown);
392+
canvas.addEventListener("touchstart", handleTouchStart);
260393
canvas.addEventListener("wheel", handleWheel);
261394

262395
window.addEventListener("mousemove", handleMouseMove);
263396
window.addEventListener("mouseup", handleMouseUp);
397+
window.addEventListener("touchend", handleTouchEnd);
398+
window.addEventListener("touchmove", handleTouchMove, { passive: false });
264399

265400
return () => {
266401
canvas.removeEventListener("mousedown", handleMouseDown);
402+
canvas.removeEventListener("touchstart", handleTouchStart);
267403
canvas.removeEventListener("wheel", handleWheel);
268404

269405
window.removeEventListener("mousemove", handleMouseMove);
270406
window.removeEventListener("mouseup", handleMouseUp);
407+
window.removeEventListener("touchend", handleTouchEnd);
408+
window.removeEventListener("touchmove", handleTouchMove, {
409+
passive: false,
410+
});
271411
};
272412
}
273413
}, [canvasRef]);

0 commit comments

Comments
 (0)