Skip to content

Commit b1e6a61

Browse files
feat(core): one-finger double-tap-drag zoom gesture
Adds the Google Maps-style touch zoom: a quick tap followed by a press-and-drag with the same finger zooms in/out continuously without ever using the second finger. Useful for one-handed touch interaction. - New DOUBLE_TAP_DRAG event channel routed through pointerdown/move/up. - Tap timing constants tuned for snap response without false triggers. - _onPointerDown promotes a stored single tap to a one-finger zoom session when the second tap lands close enough in time and space. - _onPointerMove drives controllerState.zoom relative to vertical drag; _onPointerUp finalizes and suppresses the trailing dblclick. - pan handlers short-circuit while a one-finger zoom is active.
1 parent 48760d5 commit b1e6a61

2 files changed

Lines changed: 230 additions & 0 deletions

File tree

modules/core/src/controllers/controller.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,21 @@ const NO_TRANSITION_PROPS = {
2020

2121
const DEFAULT_INERTIA = 300;
2222
const INERTIA_EASING = t => 1 - (1 - t) * (1 - t);
23+
// One-finger double-tap-drag-to-zoom gesture (matches Google Maps).
24+
// Empirical values chosen to feel snappy but reject accidental triggers.
25+
const DOUBLE_TAP_DRAG_INTERVAL = 500;
26+
const DOUBLE_TAP_DRAG_MAX_TAP_DURATION = 350;
27+
const DOUBLE_TAP_DRAG_MAX_TAP_DISTANCE = 28;
28+
const DOUBLE_TAP_DRAG_START_THRESHOLD = 1;
29+
const DOUBLE_TAP_DRAG_PIXELS_PER_ZOOM = 120;
2330

2431
const EVENT_TYPES = {
2532
WHEEL: ['wheel'],
2633
PAN: ['panstart', 'panmove', 'panend'],
2734
PINCH: ['pinchstart', 'pinchmove', 'pinchend'],
2835
MULTI_PAN: ['multipanstart', 'multipanmove', 'multipanend'],
2936
DOUBLE_CLICK: ['dblclick'],
37+
DOUBLE_TAP_DRAG: ['pointerdown', 'pointermove', 'pointerup', 'pointercancel'],
3038
KEYBOARD: ['keydown']
3139
} as const;
3240

@@ -112,6 +120,24 @@ export type ViewStateChangeParameters<ViewStateT = any> = {
112120

113121
const pinchEventWorkaround: any = {};
114122

123+
type OneFingerTapState = {
124+
pos: [number, number];
125+
time: number;
126+
pointerId?: number;
127+
};
128+
129+
type OneFingerZoomState = {
130+
startPos: [number, number];
131+
pointerId?: number;
132+
active: boolean;
133+
};
134+
135+
function getDistance(a: [number, number], b: [number, number]): number {
136+
const dx = a[0] - b[0];
137+
const dy = a[1] - b[1];
138+
return Math.sqrt(dx * dx + dy * dy);
139+
}
140+
115141
export default abstract class Controller<ControllerState extends IViewState<ControllerState>> {
116142
abstract get ControllerState(): ConstructorOf<ControllerState>;
117143
abstract get transition(): TransitionProps;
@@ -135,6 +161,10 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
135161
private _customEvents: string[] = [];
136162
private _eventStartBlocked: any = null;
137163
private _panMove: boolean = false;
164+
private _tapStart: OneFingerTapState | null = null;
165+
private _lastTap: OneFingerTapState | null = null;
166+
private _oneFingerZoom: OneFingerZoomState | null = null;
167+
private _suppressDoubleClickUntil: number = 0;
138168

139169
protected invertPan: boolean = false;
140170
protected dragMode: 'pan' | 'rotate' = 'rotate';
@@ -209,10 +239,19 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
209239

210240
switch (event.type) {
211241
case 'panstart':
242+
if (this._oneFingerZoom) {
243+
return false;
244+
}
212245
return eventStartBlocked ? false : this._onPanStart(event);
213246
case 'panmove':
247+
if (this._oneFingerZoom) {
248+
return false;
249+
}
214250
return this._onPan(event);
215251
case 'panend':
252+
if (this._oneFingerZoom) {
253+
return false;
254+
}
216255
return this._onPanEnd(event);
217256
case 'pinchstart':
218257
return eventStartBlocked ? false : this._onPinchStart(event);
@@ -228,6 +267,13 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
228267
return this._onMultiPanEnd(event);
229268
case 'dblclick':
230269
return this._onDoubleClick(event);
270+
case 'pointerdown':
271+
return this._onPointerDown(event);
272+
case 'pointermove':
273+
return this._onPointerMove(event);
274+
case 'pointerup':
275+
case 'pointercancel':
276+
return this._onPointerUp(event);
231277
case 'wheel':
232278
return this._onWheel(event as MjolnirWheelEvent);
233279
case 'keydown':
@@ -328,6 +374,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
328374
this.toggleEvents(EVENT_TYPES.PINCH, isInteractive && (touchZoom || touchRotate));
329375
this.toggleEvents(EVENT_TYPES.MULTI_PAN, isInteractive && touchRotate);
330376
this.toggleEvents(EVENT_TYPES.DOUBLE_CLICK, isInteractive && doubleClickZoom);
377+
this.toggleEvents(EVENT_TYPES.DOUBLE_TAP_DRAG, isInteractive && touchZoom);
331378
this.toggleEvents(EVENT_TYPES.KEYBOARD, isInteractive && keyboard);
332379

333380
// Interaction toggles
@@ -641,6 +688,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
641688

642689
// Default handler for the `pinchstart` event.
643690
protected _onPinchStart(event: MjolnirGestureEvent): boolean {
691+
this._resetOneFingerZoom();
644692
const pos = this.getCenter(event);
645693
if (!this.isPointInBounds(pos, event)) {
646694
return false;
@@ -735,6 +783,9 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
735783
if (!this.doubleClickZoom) {
736784
return false;
737785
}
786+
if (Date.now() < this._suppressDoubleClickUntil) {
787+
return false;
788+
}
738789
const pos = this.getCenter(event);
739790
if (!this.isPointInBounds(pos, event)) {
740791
return false;
@@ -751,6 +802,147 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
751802
return true;
752803
}
753804

805+
protected _onPointerDown(event: MjolnirEvent): boolean {
806+
if (!this.touchZoom || !this._isPrimaryPointer(event)) {
807+
this._resetOneFingerZoom();
808+
return false;
809+
}
810+
811+
const pos = this.getCenter(event as MjolnirGestureEvent);
812+
if (!this.isPointInBounds(pos, event)) {
813+
this._resetOneFingerZoom();
814+
return false;
815+
}
816+
817+
const time = this._getEventTime(event);
818+
const pointerId = (event.srcEvent as PointerEvent).pointerId;
819+
if (
820+
this._lastTap &&
821+
time - this._lastTap.time <= DOUBLE_TAP_DRAG_INTERVAL &&
822+
getDistance(pos, this._lastTap.pos) <= DOUBLE_TAP_DRAG_MAX_TAP_DISTANCE
823+
) {
824+
this._tapStart = null;
825+
this._lastTap = null;
826+
this._oneFingerZoom = {startPos: pos, pointerId, active: false};
827+
event.srcEvent.preventDefault();
828+
event.stopPropagation();
829+
return true;
830+
}
831+
832+
this._tapStart = {pos, time, pointerId};
833+
this._lastTap = null;
834+
if ((event.srcEvent as PointerEvent).pointerType === 'touch') {
835+
event.srcEvent.preventDefault();
836+
}
837+
return false;
838+
}
839+
840+
protected _onPointerMove(event: MjolnirEvent): boolean {
841+
const oneFingerZoom = this._oneFingerZoom;
842+
if (!oneFingerZoom || !this._isSamePointer(event, oneFingerZoom.pointerId)) {
843+
return false;
844+
}
845+
846+
const pos = this.getCenter(event as MjolnirGestureEvent);
847+
const dy = oneFingerZoom.startPos[1] - pos[1];
848+
if (!oneFingerZoom.active && Math.abs(dy) < DOUBLE_TAP_DRAG_START_THRESHOLD) {
849+
event.srcEvent.preventDefault();
850+
event.stopPropagation();
851+
return true;
852+
}
853+
854+
const scale = Math.pow(2, dy / DOUBLE_TAP_DRAG_PIXELS_PER_ZOOM);
855+
const startPos = oneFingerZoom.startPos;
856+
let newControllerState = this.controllerState;
857+
if (!oneFingerZoom.active) {
858+
oneFingerZoom.active = true;
859+
newControllerState = newControllerState.zoomStart({pos: startPos});
860+
}
861+
newControllerState = newControllerState.zoom({pos: startPos, scale});
862+
this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {
863+
isDragging: true,
864+
isPanning: true,
865+
isZooming: true
866+
});
867+
868+
event.srcEvent.preventDefault();
869+
event.stopPropagation();
870+
return true;
871+
}
872+
873+
protected _onPointerUp(event: MjolnirEvent): boolean {
874+
const oneFingerZoom = this._oneFingerZoom;
875+
if (oneFingerZoom && this._isSamePointer(event, oneFingerZoom.pointerId)) {
876+
this._oneFingerZoom = null;
877+
if (oneFingerZoom.active) {
878+
const newControllerState = this.controllerState.zoomEnd();
879+
this.updateViewport(newControllerState, null, {
880+
isDragging: false,
881+
isPanning: false,
882+
isZooming: false
883+
});
884+
this._suppressDoubleClickUntil = Date.now() + 100;
885+
this.blockEvents(100);
886+
event.srcEvent.preventDefault();
887+
event.stopPropagation();
888+
return true;
889+
}
890+
return false;
891+
}
892+
893+
if (event.type === 'pointercancel') {
894+
this._resetOneFingerZoom();
895+
return false;
896+
}
897+
898+
const tapStart = this._tapStart;
899+
if (!tapStart || !this._isSamePointer(event, tapStart.pointerId)) {
900+
return false;
901+
}
902+
903+
const pos = this.getCenter(event as MjolnirGestureEvent);
904+
const time = this._getEventTime(event);
905+
if (
906+
time - tapStart.time <= DOUBLE_TAP_DRAG_MAX_TAP_DURATION &&
907+
getDistance(pos, tapStart.pos) <= DOUBLE_TAP_DRAG_MAX_TAP_DISTANCE
908+
) {
909+
this._lastTap = {pos, time, pointerId: tapStart.pointerId};
910+
} else {
911+
this._lastTap = null;
912+
}
913+
this._tapStart = null;
914+
if ((event.srcEvent as PointerEvent).pointerType === 'touch') {
915+
event.srcEvent.preventDefault();
916+
}
917+
return false;
918+
}
919+
920+
private _resetOneFingerZoom(): void {
921+
this._tapStart = null;
922+
this._lastTap = null;
923+
this._oneFingerZoom = null;
924+
}
925+
926+
private _getEventTime(event: MjolnirEvent): number {
927+
return (event as any).timeStamp || event.srcEvent.timeStamp || Date.now();
928+
}
929+
930+
private _isPrimaryPointer(event: MjolnirEvent): boolean {
931+
const pointers = (event as any).pointers;
932+
if (pointers && pointers.length > 1) {
933+
return false;
934+
}
935+
const srcEvent = event.srcEvent as PointerEvent;
936+
if (srcEvent.pointerType === 'mouse') {
937+
return (event as any).leftButton !== false;
938+
}
939+
return true;
940+
}
941+
942+
private _isSamePointer(event: MjolnirEvent, pointerId?: number): boolean {
943+
return pointerId === undefined || (event.srcEvent as PointerEvent).pointerId === pointerId;
944+
}
945+
754946
// Default handler for the `keydown` event
755947
protected _onKeyDown(event: MjolnirKeyEvent): boolean {
756948
if (!this.keyboard) {

test/modules/core/controllers/controllers.spec.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,19 @@ import {Timeline} from '@luma.gl/engine';
1414

1515
import testController, {createTestController} from './test-controller';
1616

17+
const makePointerEvent = (type: string, y: number, timeStamp: number, pointerId: number = 1) => ({
18+
type,
19+
offsetCenter: {x: 50, y},
20+
timeStamp,
21+
srcEvent: {
22+
pointerId,
23+
pointerType: 'touch',
24+
timeStamp,
25+
preventDefault() {}
26+
},
27+
stopPropagation() {}
28+
});
29+
1730
test('MapController', async () => {
1831
await testController(MapView, {
1932
longitude: -122.45,
@@ -35,6 +48,31 @@ test('MapController#inertia', async () => {
3548
});
3649
});
3750

51+
test('MapController supports double-tap drag zoom when double click zoom is disabled', () => {
52+
const controller = createTestController({
53+
view: new MapView({controller: {touchZoom: true, doubleClickZoom: false}}),
54+
initialViewState: {
55+
longitude: -122.45,
56+
latitude: 37.78,
57+
zoom: 10,
58+
pitch: 30,
59+
bearing: -45,
60+
inertia: 300
61+
}
62+
});
63+
64+
controller.handleEvent(makePointerEvent('pointerdown', 50, 0) as any);
65+
controller.handleEvent(makePointerEvent('pointerup', 50, 50) as any);
66+
controller.handleEvent(makePointerEvent('pointerdown', 50, 120) as any);
67+
controller.handleEvent(makePointerEvent('pointermove', 20, 150) as any);
68+
const zoomAfterMove = controller.props.zoom;
69+
70+
controller.handleEvent(makePointerEvent('pointerup', 20, 180) as any);
71+
72+
expect(zoomAfterMove, 'dragging up after double tap zooms in').toBeGreaterThan(10);
73+
expect(controller.props.zoom, 'release should not change zoom').toBeCloseTo(zoomAfterMove);
74+
});
75+
3876
test('GlobeController', async () => {
3977
await testController(
4078
GlobeView,

0 commit comments

Comments
 (0)