Skip to content

Commit 0dd35c6

Browse files
feat(core): tame pinch zoom inertia on touch lift
The post-pinch inertia projection is intact but no longer kicks the camera past the user's gesture when the final frame is noisy: - Cap the velocity that drives the projection (0.005 log2(scale)/ms). A spike on the lift frame can't translate into a runaway zoom. - Halve both the forward-projected distance and the inertia transition duration so the post-lift glide is soft instead of abrupt. This is most noticeable on touch where the last frame is usually the noisiest. Behavior unchanged for slow/steady pinches whose velocity stays below the cap; faster pinches still get inertia, just gentler.
1 parent 48760d5 commit 0dd35c6

2 files changed

Lines changed: 58 additions & 5 deletions

File tree

modules/core/src/controllers/controller.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ const NO_TRANSITION_PROPS = {
2020

2121
const DEFAULT_INERTIA = 300;
2222
const INERTIA_EASING = t => 1 - (1 - t) * (1 - t);
23+
// Cap the velocity that drives pinch-zoom inertia (in log2(scale) per ms).
24+
// A noisy last frame can produce a runaway projection — without a cap, lift
25+
// "kicks" the camera further than the gesture intended.
26+
const PINCH_INERTIA_VELOCITY_LOG2_CAP = 0.005;
27+
// Scale both the forward-projected distance and the inertia transition
28+
// duration. 0.5 keeps a sense of momentum without overshoot, especially on
29+
// touch where the pinch end frame is often the noisiest one.
30+
const PINCH_INERTIA_DAMPING = 0.5;
2331

2432
const EVENT_TYPES = {
2533
WHEEL: ['wheel'],
@@ -696,16 +704,24 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
696704
const pos = this.getCenter(event);
697705
let newControllerState = this.controllerState.rotateEnd();
698706
const z = Math.log2(event.scale);
699-
const velocityZ =
700-
(z - Math.log2(_lastPinchEvent.scale)) / (event.deltaTime - _lastPinchEvent.deltaTime);
701-
const endScale = Math.pow(2, z + (velocityZ * inertia) / 2);
707+
const dt = Math.max(1, event.deltaTime - _lastPinchEvent.deltaTime);
708+
const rawVelocityZ = (z - Math.log2(_lastPinchEvent.scale)) / dt;
709+
// Cap the velocity so a noisy last frame can't kick the camera, and
710+
// halve the forward projection so inertia feels like a soft glide
711+
// instead of an abrupt extra zoom past the user's gesture.
712+
const velocityZ = Math.max(
713+
-PINCH_INERTIA_VELOCITY_LOG2_CAP,
714+
Math.min(PINCH_INERTIA_VELOCITY_LOG2_CAP, rawVelocityZ)
715+
);
716+
const dampedInertia = inertia * PINCH_INERTIA_DAMPING;
717+
const endScale = Math.pow(2, z + (velocityZ * dampedInertia) / 2);
702718
newControllerState = newControllerState.zoom({pos, scale: endScale}).zoomEnd();
703719

704720
this.updateViewport(
705721
newControllerState,
706722
{
707723
...this._getTransitionProps({around: pos}),
708-
transitionDuration: inertia,
724+
transitionDuration: dampedInertia,
709725
transitionEasing: INERTIA_EASING
710726
},
711727
{
@@ -715,7 +731,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
715731
isRotating: false
716732
}
717733
);
718-
this.blockEvents(inertia);
734+
this.blockEvents(dampedInertia);
719735
} else {
720736
const newControllerState = this.controllerState.zoomEnd().rotateEnd();
721737
this.updateViewport(newControllerState, null, {

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,43 @@ test('MapController#inertia', async () => {
3535
});
3636
});
3737

38+
test('MapController caps pinch zoom inertia from a spike on touch lift', () => {
39+
// A noisy final pinch frame produces a much higher velocity than the live
40+
// gesture. The post-end inertia projection should be bounded to a soft
41+
// glide, not a hard kick.
42+
const makePinchEvent = (type: string, scale: number, deltaTime: number) => ({
43+
type,
44+
offsetCenter: {x: 50, y: 50},
45+
scale,
46+
rotation: 0,
47+
deltaTime,
48+
srcEvent: {preventDefault() {}},
49+
stopPropagation() {}
50+
});
51+
52+
const controller = createTestController({
53+
view: new MapView({controller: true}),
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(makePinchEvent('pinchstart', 1, 0) as any);
65+
controller.handleEvent(makePinchEvent('pinchmove', 1.1, 16) as any);
66+
const zoomAfterMove = controller.props.zoom as number;
67+
// Simulate a 100x spike on the very last frame.
68+
controller.handleEvent(makePinchEvent('pinchend', 100, 17) as any);
69+
70+
const overshoot = (controller.props.zoom as number) - zoomAfterMove;
71+
// velocity cap (0.005 log2/ms) * inertia (300) * damping (0.5) / 2 = 0.375
72+
expect(overshoot, 'inertia overshoot is bounded by the velocity cap and damping').toBeLessThan(1);
73+
});
74+
3875
test('GlobeController', async () => {
3976
await testController(
4077
GlobeView,

0 commit comments

Comments
 (0)