From 313aebb2da667f28931f79edef005711b9f5440e Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Mon, 7 Feb 2022 10:32:39 -0500 Subject: [PATCH 1/3] Add rudimentary support for touch events (e.g. pinch to zoom, pan) --- components/Planner/useZoom.js | 178 ++++++++++++++++++++++++++++++---- 1 file changed, 159 insertions(+), 19 deletions(-) diff --git a/components/Planner/useZoom.js b/components/Planner/useZoom.js index 8a54cfe..126a038 100644 --- a/components/Planner/useZoom.js +++ b/components/Planner/useZoom.js @@ -14,13 +14,20 @@ const DEFAULT_STATE = { naturalHeight: 0, width: 0, - // Managed internally + // Internal gesture state isDragging: false, + lastTouches: null, + touchCenterX: 0, + + // Current pan and zoom x: 0, y: 0, z: 1, }; +// TODO Ignore right click +// https://github.com/d3/d3-zoom/blob/main/src/zoom.js#L11 + // getDateLocation() should be kep in-sync with getDateLocation() in "drawingUtils.js" function getDateLocation(date, metadata, z, width) { const { startDate, stopDate } = metadata; @@ -68,7 +75,11 @@ function reduce(state, action) { return { ...state, metadata, + + // Reset scroll state when metadata changes. isDragging: false, + lastTouches: null, + touchCenterX: 0, x: 0, y: 0, z: 1, @@ -80,21 +91,21 @@ function reduce(state, action) { if (deltaX !== 0) { // TODO Can we cache this on "zoom" (and "set-chart-size") ? - const maxOffsetX = + const maxX = width - getDateLocation(metadata.stopDate, metadata, z, width); - const x = Math.min(0, Math.max(maxOffsetX, state.x - deltaX)); + const x = Math.min(0, Math.max(maxX, state.x - deltaX)); return { ...state, x: Math.round(x), }; } else if (deltaY !== 0) { - const maxOffsetY = height - naturalHeight; + const maxY = height - naturalHeight; // TODO Respect natural scroll preference (if we can detect it?). - const newOffsetY = state.y - deltaY; - const y = Math.min(0, Math.max(maxOffsetY, newOffsetY)); + const newY = state.y - deltaY; + const y = Math.min(0, Math.max(maxY, newY)); return { ...state, @@ -102,27 +113,42 @@ function reduce(state, action) { }; } } + case "update-touch-state": { + const { lastTouches, touchCenterX } = payload; + + const newState = { + ...state, + lastTouches, + }; + + if (touchCenterX !== undefined) { + newState.touchCenterX = touchCenterX; + } + + return newState; + break; + } case "zoom": { const { metadata, x, width } = state; - const { deltaX, deltaY, locationX } = payload; + const { delta, locationX } = payload; const z = Math.max( MIN_SCALE, - Math.min(MAX_SCALE, state.z - deltaY * ZOOM_MULTIPLIER) + Math.min(MAX_SCALE, state.z - delta * ZOOM_MULTIPLIER) ); - const maxOffsetX = + const maxX = width - getDateLocation(metadata.stopDate, metadata, z, width); // Zoom in/out around the point we're currently hovered over. const scaleMultiplier = z / state.z; const scaledDelta = locationX - locationX * scaleMultiplier; - const newOffsetX = x * scaleMultiplier + scaledDelta; - const newClampedOffsetX = Math.min(0, Math.max(maxOffsetX, newOffsetX)); + const newX = x * scaleMultiplier + scaledDelta; + const newClampedX = Math.min(0, Math.max(maxX, newX)); return { ...state, - x: Math.round(newClampedOffsetX), + x: Math.round(newClampedX), z, }; } @@ -170,8 +196,8 @@ export default function useZoom({ }; const handleMouseMove = (event) => { - const currentState = stateRef.current; - if (!currentState.isDragging) { + const { isDragging } = stateRef.current; + if (!isDragging) { return; } @@ -209,6 +235,115 @@ export default function useZoom({ dispatch({ type: "set-is-dragging", payload: { isDragging: false } }); }; + const handleTouchEnd = (event) => { + dispatch({ + type: "update-touch-state", + payload: { touchCenterX: null, lastTouches: null }, + }); + }; + + const handleTouchMove = (event) => { + const { touchCenterX, lastTouches } = stateRef.current; + if (lastTouches === null) { + return; + } + + const { changedTouches, touches } = event; + if (changedTouches.length !== lastTouches.length) { + return; + } + + stopEvent(event); + + // Return an array of changed deltas, sorted along the x axis. + // This sorting is required for "zoom" logic since positive or negative values + // depend on the direction of the touch (which finger is pinching). + const sortedTouches = Array.from(changedTouches).sort((a, b) => { + if (a.pageX < b.pageX) { + return 1; + } else if (a.pageX > b.pageX) { + return -1; + } else { + return 0; + } + }); + + const lastTouchesMap = new Map(); + for (let touch of lastTouches) { + lastTouchesMap.set(touch.identifier, { + pageX: touch.pageX, + pageY: touch.pageY, + }); + } + + const deltas = []; + for (let changedTouch of sortedTouches) { + const touch = lastTouchesMap.get(changedTouch.identifier); + if (touch) { + deltas.push([ + changedTouch.pageX - touch.pageX, + changedTouch.pageY - touch.pageY, + ]); + } + } + + // TODO Check delta threshold(s) + + if (deltas.length === 1) { + const [deltaX, deltaY] = deltas[0]; + if (Math.abs(deltaX) > Math.abs(deltaY)) { + dispatch({ + type: "pan", + payload: { deltaX: 0 - deltaX, deltaY: 0 }, + }); + } else { + dispatch({ + type: "pan", + payload: { deltaX: 0, deltaY: 0 - deltaY }, + }); + } + } else if (deltas.length === 2) { + const [[deltaX0, deltaY0], [deltaX1, deltaY1]] = deltas; + const deltaXAbsolute = Math.abs(deltaX0) + Math.abs(deltaX1); + const deltaYAbsolute = Math.abs(deltaY0) + Math.abs(deltaY1); + + // Horizontal zooms; ignore vertical. + if (deltaXAbsolute > deltaYAbsolute) { + const delta = + Math.abs(deltaX0) > Math.abs(deltaX1) ? 0 - deltaX0 : deltaX1; + + dispatch({ + type: "zoom", + payload: { + delta, + locationX: touchCenterX, + }, + }); + } + } + + dispatch({ + type: "update-touch-state", + payload: { lastTouches: touches, touchCenterX }, + }); + }; + + const handleTouchStart = (event) => { + const { touches } = event; + + const touchCenterX = + touches.length === 1 + ? touches[0].pageX + : touches[0].pageX + (touches[1].pageX - touches[0].pageX) / 2; + + dispatch({ + type: "update-touch-state", + payload: { touchCenterX, lastTouches: touches }, + }); + + // TODO Double tab should zoom in around a point. + }; + const handleWheel = (event) => { const { shiftKey, x } = event; const { deltaX, deltaY } = normalizeWheelEvent(event); @@ -216,10 +351,7 @@ export default function useZoom({ const deltaYAbsolute = Math.abs(deltaY); const deltaXAbsolute = Math.abs(deltaX); - const currentState = stateRef.current; - - // Vertical scrolling zooms in and out (unless the SHIFT modifier is used). - // Horizontal scrolling pans. + // Horizontal wheel pans; vertical wheel zooms (unless the SHIFT modifier is used). if (deltaYAbsolute > deltaXAbsolute) { if (shiftKey) { stopEvent(event); @@ -240,7 +372,7 @@ export default function useZoom({ dispatch({ type: "zoom", - payload: { deltaX, deltaY, locationX }, + payload: { delta: deltaY, locationX }, }); } } @@ -257,17 +389,25 @@ export default function useZoom({ }; canvas.addEventListener("mousedown", handleMouseDown); + canvas.addEventListener("touchstart", handleTouchStart); canvas.addEventListener("wheel", handleWheel); window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); + window.addEventListener("touchend", handleTouchEnd); + window.addEventListener("touchmove", handleTouchMove, { passive: false }); return () => { canvas.removeEventListener("mousedown", handleMouseDown); + canvas.removeEventListener("touchstart", handleTouchStart); canvas.removeEventListener("wheel", handleWheel); window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); + window.removeEventListener("touchend", handleTouchEnd); + window.removeEventListener("touchmove", handleTouchMove, { + passive: false, + }); }; } }, [canvasRef]); From 37522307d7b2c729d8d9470f12023201ecdd1776 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 9 Feb 2022 09:24:24 -0500 Subject: [PATCH 2/3] Double-tap to zoom --- components/Planner/useZoom.js | 93 +++++++++++++++-------------------- 1 file changed, 39 insertions(+), 54 deletions(-) diff --git a/components/Planner/useZoom.js b/components/Planner/useZoom.js index 126a038..99aedd3 100644 --- a/components/Planner/useZoom.js +++ b/components/Planner/useZoom.js @@ -1,6 +1,8 @@ import { useEffect, useReducer, useRef } from "react"; import { normalizeWheelEvent } from "../utils/mouse"; +const DOUBLE_TAP_MAX_DURATION = 200; +const DOUBLE_TAP_ZOOM_IN_DELTA = -50; const MIN_SCALE = 1; const MAX_SCALE = 10; const PAN_DELTA_THRESHOLD = 1; @@ -14,11 +16,6 @@ const DEFAULT_STATE = { naturalHeight: 0, width: 0, - // Internal gesture state - isDragging: false, - lastTouches: null, - touchCenterX: 0, - // Current pan and zoom x: 0, y: 0, @@ -62,14 +59,6 @@ function reduce(state, action) { width, }; } - case "set-is-dragging": { - const { isDragging } = payload; - return { - ...state, - isDragging, - }; - break; - } case "set-metadata": { const { metadata } = payload; return { @@ -77,9 +66,6 @@ function reduce(state, action) { metadata, // Reset scroll state when metadata changes. - isDragging: false, - lastTouches: null, - touchCenterX: 0, x: 0, y: 0, z: 1, @@ -113,21 +99,6 @@ function reduce(state, action) { }; } } - case "update-touch-state": { - const { lastTouches, touchCenterX } = payload; - - const newState = { - ...state, - lastTouches, - }; - - if (touchCenterX !== undefined) { - newState.touchCenterX = touchCenterX; - } - - return newState; - break; - } case "zoom": { const { metadata, x, width } = state; const { delta, locationX } = payload; @@ -191,12 +162,17 @@ export default function useZoom({ useEffect(() => { const canvas = canvasRef.current; if (canvas) { + let currentTouchStartCenterX; + let currentTouchStartLength = 0; + let currentTouchStartTime = 0; + let isDragging; + let lastTouches; + const handleMouseDown = (event) => { - dispatch({ type: "set-is-dragging", payload: { isDragging: true } }); + isDragging = true; }; const handleMouseMove = (event) => { - const { isDragging } = stateRef.current; if (!isDragging) { return; } @@ -232,19 +208,16 @@ export default function useZoom({ }; const handleMouseUp = (event) => { - dispatch({ type: "set-is-dragging", payload: { isDragging: false } }); + isDragging = false; }; const handleTouchEnd = (event) => { - dispatch({ - type: "update-touch-state", - payload: { touchCenterX: null, lastTouches: null }, - }); + lastTouches = null; + currentTouchStartCenterX = null; }; const handleTouchMove = (event) => { - const { touchCenterX, lastTouches } = stateRef.current; - if (lastTouches === null) { + if (lastTouches == null) { return; } @@ -316,32 +289,44 @@ export default function useZoom({ type: "zoom", payload: { delta, - locationX: touchCenterX, + locationX: currentTouchStartCenterX, }, }); } } - dispatch({ - type: "update-touch-state", - payload: { lastTouches: touches, touchCenterX }, - }); + lastTouches = touches; }; const handleTouchStart = (event) => { const { touches } = event; - const touchCenterX = - touches.length === 1 - ? touches[0].pageX - : touches[0].pageX + (touches[1].pageX - touches[0].pageX) / 2; + stopEvent(event); - dispatch({ - type: "update-touch-state", - payload: { touchCenterX, lastTouches: touches }, - }); + const length = touches.length; + const now = performance.now(); + + if ( + now - currentTouchStartTime < DOUBLE_TAP_MAX_DURATION && + length === currentTouchStartLength && + length === 1 + ) { + const locationX = touches[0].pageX; + + dispatch({ + type: "zoom", + payload: { delta: DOUBLE_TAP_ZOOM_IN_DELTA, locationX }, + }); + } else { + currentTouchStartCenterX = + length === 1 + ? touches[0].pageX + : touches[0].pageX + (touches[1].pageX - touches[0].pageX) / 2; + } - // TODO Double tab should zoom in around a point. + lastTouches = touches; + currentTouchStartLength = length; + currentTouchStartTime = now; }; const handleWheel = (event) => { From f4f2e9bacc073512eed0c284711f5b4e9520eca8 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 9 Feb 2022 10:28:38 -0500 Subject: [PATCH 3/3] Add rudimentary momentum animation after zoom/pan events --- components/Planner/useZoom.js | 212 +++++++++++++++++++++++----------- components/utils/easing.js | 4 + 2 files changed, 147 insertions(+), 69 deletions(-) create mode 100644 components/utils/easing.js diff --git a/components/Planner/useZoom.js b/components/Planner/useZoom.js index 99aedd3..468a079 100644 --- a/components/Planner/useZoom.js +++ b/components/Planner/useZoom.js @@ -1,11 +1,15 @@ import { useEffect, useReducer, useRef } from "react"; +import { easeOutQuad } from "../utils/easing"; import { normalizeWheelEvent } from "../utils/mouse"; const DOUBLE_TAP_MAX_DURATION = 200; +const DOUBLE_TAP_MOMENTUM_DELTA = -5; +const DOUBLE_TAP_MOMENTUM_INTERVAL = 10; const DOUBLE_TAP_ZOOM_IN_DELTA = -50; const MIN_SCALE = 1; const MAX_SCALE = 10; const PAN_DELTA_THRESHOLD = 1; +const TOUCH_MOMENTUM_DURATION = 250; const ZOOM_DELTA_THRESHOLD = 1; const ZOOM_MULTIPLIER = 0.01; @@ -166,7 +170,87 @@ export default function useZoom({ let currentTouchStartLength = 0; let currentTouchStartTime = 0; let isDragging; + let lastTouchDeltas; let lastTouches; + let lastTouchTime = 0; + let momentumTimeoutID = null; + + // Helper methods + + const momentumHelper = ({ deltas, interval, locationX, startTime }) => { + const callback = () => { + const currentTime = performance.now(); + const ellapsedTime = currentTime - startTime; + if (ellapsedTime <= TOUCH_MOMENTUM_DURATION) { + const value = easeOutQuad( + 1 - ellapsedTime / TOUCH_MOMENTUM_DURATION + ); + if (value > 0) { + const scaledDeltas = deltas.map(([deltaX, deltaY]) => [ + deltaX * value, + deltaY * value, + ]); + + panOrZoomDeltas(scaledDeltas, locationX); + } + + momentumTimeoutID = setTimeout(callback, interval); + } + }; + + momentumTimeoutID = setTimeout(callback, interval); + }; + + const panOrZoomDeltas = (deltas, locationX) => { + // TODO Check delta threshold(s) + + switch (deltas.length) { + case 1: { + const [deltaX, deltaY] = deltas[0]; + if (Math.abs(deltaX) > Math.abs(deltaY)) { + dispatch({ + type: "pan", + payload: { deltaX: 0 - deltaX, deltaY: 0 }, + }); + } else { + dispatch({ + type: "pan", + payload: { deltaX: 0, deltaY: 0 - deltaY }, + }); + } + break; + } + case 2: { + const [[deltaX0, deltaY0], [deltaX1, deltaY1]] = deltas; + const deltaXAbsolute = Math.abs(deltaX0) + Math.abs(deltaX1); + const deltaYAbsolute = Math.abs(deltaY0) + Math.abs(deltaY1); + + // Horizontal zooms; ignore vertical. + if (deltaXAbsolute > deltaYAbsolute) { + const delta = + Math.abs(deltaX0) > Math.abs(deltaX1) ? 0 - deltaX0 : deltaX1; + + dispatch({ + type: "zoom", + payload: { + delta, + locationX, + }, + }); + } + break; + } + } + }; + + const stopActiveMomentumEffect = () => { + if (momentumTimeoutID != null) { + clearTimeout(momentumTimeoutID); + momentumTimeoutID = null; + } + }; + + // Event handlers const handleMouseDown = (event) => { isDragging = true; @@ -212,90 +296,72 @@ export default function useZoom({ }; const handleTouchEnd = (event) => { - lastTouches = null; + const deltas = lastTouchDeltas; + const locationX = currentTouchStartCenterX; + + const startTime = performance.now(); + const interval = startTime - lastTouchTime; + currentTouchStartCenterX = null; - }; + lastTouchDeltas = null; + lastTouches = null; + lastTouchTime = 0; - const handleTouchMove = (event) => { - if (lastTouches == null) { + if (deltas == null) { return; } - const { changedTouches, touches } = event; - if (changedTouches.length !== lastTouches.length) { - return; - } + momentumHelper({ deltas, interval, locationX, startTime }); + }; - stopEvent(event); + const handleTouchMove = (event) => { + const { changedTouches, touches } = event; - // Return an array of changed deltas, sorted along the x axis. - // This sorting is required for "zoom" logic since positive or negative values - // depend on the direction of the touch (which finger is pinching). - const sortedTouches = Array.from(changedTouches).sort((a, b) => { - if (a.pageX < b.pageX) { - return 1; - } else if (a.pageX > b.pageX) { - return -1; - } else { - return 0; + if (lastTouches != null) { + if (changedTouches.length !== lastTouches.length) { + return; } - }); - - const lastTouchesMap = new Map(); - for (let touch of lastTouches) { - lastTouchesMap.set(touch.identifier, { - pageX: touch.pageX, - pageY: touch.pageY, - }); - } - const deltas = []; - for (let changedTouch of sortedTouches) { - const touch = lastTouchesMap.get(changedTouch.identifier); - if (touch) { - deltas.push([ - changedTouch.pageX - touch.pageX, - changedTouch.pageY - touch.pageY, - ]); - } - } + stopEvent(event); - // TODO Check delta threshold(s) + // Return an array of changed deltas, sorted along the x axis. + // This sorting is required for "zoom" logic since positive or negative values + // depend on the direction of the touch (which finger is pinching). + const sortedTouches = Array.from(changedTouches).sort((a, b) => { + if (a.pageX < b.pageX) { + return 1; + } else if (a.pageX > b.pageX) { + return -1; + } else { + return 0; + } + }); - if (deltas.length === 1) { - const [deltaX, deltaY] = deltas[0]; - if (Math.abs(deltaX) > Math.abs(deltaY)) { - dispatch({ - type: "pan", - payload: { deltaX: 0 - deltaX, deltaY: 0 }, - }); - } else { - dispatch({ - type: "pan", - payload: { deltaX: 0, deltaY: 0 - deltaY }, + const lastTouchesMap = new Map(); + for (let touch of lastTouches) { + lastTouchesMap.set(touch.identifier, { + pageX: touch.pageX, + pageY: touch.pageY, }); } - } else if (deltas.length === 2) { - const [[deltaX0, deltaY0], [deltaX1, deltaY1]] = deltas; - const deltaXAbsolute = Math.abs(deltaX0) + Math.abs(deltaX1); - const deltaYAbsolute = Math.abs(deltaY0) + Math.abs(deltaY1); - // Horizontal zooms; ignore vertical. - if (deltaXAbsolute > deltaYAbsolute) { - const delta = - Math.abs(deltaX0) > Math.abs(deltaX1) ? 0 - deltaX0 : deltaX1; - - dispatch({ - type: "zoom", - payload: { - delta, - locationX: currentTouchStartCenterX, - }, - }); + const deltas = []; + for (let changedTouch of sortedTouches) { + const touch = lastTouchesMap.get(changedTouch.identifier); + if (touch) { + deltas.push([ + changedTouch.pageX - touch.pageX, + changedTouch.pageY - touch.pageY, + ]); + } } + + panOrZoomDeltas(deltas, currentTouchStartCenterX); } + lastTouchDeltas = deltas; lastTouches = touches; + lastTouchTime = performance.now(); }; const handleTouchStart = (event) => { @@ -303,6 +369,9 @@ export default function useZoom({ stopEvent(event); + // Interrupt any active momentum scrolling. + stopActiveMomentumEffect(); + const length = touches.length; const now = performance.now(); @@ -312,11 +381,16 @@ export default function useZoom({ length === 1 ) { const locationX = touches[0].pageX; + const fauxDeltas = [DOUBLE_TAP_MOMENTUM_DELTA, 0]; - dispatch({ - type: "zoom", - payload: { delta: DOUBLE_TAP_ZOOM_IN_DELTA, locationX }, + momentumHelper({ + deltas: [fauxDeltas, fauxDeltas], + interval: DOUBLE_TAP_MOMENTUM_INTERVAL, + locationX, + startTime: now, }); + + return; } else { currentTouchStartCenterX = length === 1 diff --git a/components/utils/easing.js b/components/utils/easing.js new file mode 100644 index 0000000..3786155 --- /dev/null +++ b/components/utils/easing.js @@ -0,0 +1,4 @@ +// https://easings.net/#easeOutQuad +export function easeOutQuad(x) { + return 1 - (1 - x) * (1 - x); +}