diff --git a/modules/core/src/controllers/controller.ts b/modules/core/src/controllers/controller.ts index 455c2ca3393..dd0ad070bd1 100644 --- a/modules/core/src/controllers/controller.ts +++ b/modules/core/src/controllers/controller.ts @@ -20,6 +20,12 @@ const NO_TRANSITION_PROPS = { const DEFAULT_INERTIA = 300; const INERTIA_EASING = t => 1 - (1 - t) * (1 - t); +const MAX_PINCH_ZOOM_DELTA_PER_EVENT = 0.18; +const DOUBLE_TAP_DRAG_INTERVAL = 500; +const DOUBLE_TAP_DRAG_MAX_TAP_DURATION = 350; +const DOUBLE_TAP_DRAG_MAX_TAP_DISTANCE = 28; +const DOUBLE_TAP_DRAG_START_THRESHOLD = 1; +const DOUBLE_TAP_DRAG_PIXELS_PER_ZOOM = 120; const EVENT_TYPES = { WHEEL: ['wheel'], @@ -27,6 +33,7 @@ const EVENT_TYPES = { PINCH: ['pinchstart', 'pinchmove', 'pinchend'], MULTI_PAN: ['multipanstart', 'multipanmove', 'multipanend'], DOUBLE_CLICK: ['dblclick'], + DOUBLE_TAP_DRAG: ['pointerdown', 'pointermove', 'pointerup', 'pointercancel'], KEYBOARD: ['keydown'] } as const; @@ -112,6 +119,28 @@ export type ViewStateChangeParameters = { const pinchEventWorkaround: any = {}; +type OneFingerTapState = { + pos: [number, number]; + time: number; + pointerId?: number; +}; + +type OneFingerZoomState = { + startPos: [number, number]; + pointerId?: number; + active: boolean; +}; + +function getDistance(a: [number, number], b: [number, number]): number { + const dx = a[0] - b[0]; + const dy = a[1] - b[1]; + return Math.sqrt(dx * dx + dy * dy); +} + +function clampPinchZoomDelta(delta: number): number { + return Math.max(-MAX_PINCH_ZOOM_DELTA_PER_EVENT, Math.min(MAX_PINCH_ZOOM_DELTA_PER_EVENT, delta)); +} + export default abstract class Controller> { abstract get ControllerState(): ConstructorOf; abstract get transition(): TransitionProps; @@ -135,6 +164,10 @@ export default abstract class Controller 1) { + return false; + } + const srcEvent = event.srcEvent as PointerEvent; + if (srcEvent.pointerType === 'mouse') { + return (event as any).leftButton !== false; + } + return true; + } + + private _isSamePointer(event: MjolnirEvent, pointerId?: number): boolean { + return pointerId === undefined || (event.srcEvent as PointerEvent).pointerId === pointerId; + } + // Default handler for the `keydown` event protected _onKeyDown(event: MjolnirKeyEvent): boolean { if (!this.keyboard) { diff --git a/test/modules/core/controllers/controllers.spec.ts b/test/modules/core/controllers/controllers.spec.ts index 4b50a2597c1..08e4e111245 100644 --- a/test/modules/core/controllers/controllers.spec.ts +++ b/test/modules/core/controllers/controllers.spec.ts @@ -35,6 +35,84 @@ test('MapController#inertia', async () => { }); }); +test('MapController does not apply pinch zoom inertia after quick lift', () => { + const makePinchEvent = (type: string, scale: number, deltaTime: number) => ({ + type, + offsetCenter: {x: 50, y: 50}, + scale, + rotation: 0, + deltaTime, + srcEvent: {preventDefault() {}}, + stopPropagation() {} + }); + + for (const {moveScale, endScale} of [ + {moveScale: 0.01, endScale: 0.001}, + {moveScale: 100, endScale: 1000} + ]) { + const controller = createTestController({ + view: new MapView({controller: true}), + initialViewState: { + longitude: -122.45, + latitude: 37.78, + zoom: 10, + pitch: 30, + bearing: -45, + inertia: 300 + } + }); + + controller.handleEvent(makePinchEvent('pinchstart', 1, 0) as any); + controller.handleEvent(makePinchEvent('pinchmove', moveScale, 16) as any); + const zoomAfterMove = controller.props.zoom; + + controller.handleEvent(makePinchEvent('pinchend', endScale, 17) as any); + + expect( + controller.props.zoom, + 'quick two-finger lift should end at the last live pinch zoom' + ).toBeCloseTo(zoomAfterMove); + } +}); + +test('MapController supports double-tap drag zoom when double click zoom is disabled', () => { + const controller = createTestController({ + view: new MapView({controller: {touchZoom: true, doubleClickZoom: false}}), + initialViewState: { + longitude: -122.45, + latitude: 37.78, + zoom: 10, + pitch: 30, + bearing: -45, + inertia: 300 + } + }); + + const makePointerEvent = (type: string, y: number, timeStamp: number) => ({ + type, + offsetCenter: {x: 50, y}, + timeStamp, + srcEvent: { + pointerId: 1, + pointerType: 'touch', + timeStamp, + preventDefault() {} + }, + stopPropagation() {} + }); + + controller.handleEvent(makePointerEvent('pointerdown', 50, 0) as any); + controller.handleEvent(makePointerEvent('pointerup', 50, 50) as any); + controller.handleEvent(makePointerEvent('pointerdown', 50, 120) as any); + controller.handleEvent(makePointerEvent('pointermove', 20, 150) as any); + const zoomAfterMove = controller.props.zoom; + + controller.handleEvent(makePointerEvent('pointerup', 20, 180) as any); + + expect(zoomAfterMove, 'dragging up after double tap zooms in').toBeGreaterThan(10); + expect(controller.props.zoom, 'release should not change zoom').toBeCloseTo(zoomAfterMove); +}); + test('GlobeController', async () => { await testController( GlobeView,