Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion modules/core/src/controllers/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const NO_TRANSITION_PROPS = {

const DEFAULT_INERTIA = 300;
const INERTIA_EASING = t => 1 - (1 - t) * (1 - t);
// Cap how much the smoothed log-scale can change per pinch event. 0.18 log2
// units ≈ a 1.13× zoom per frame, well above any realistic pinch rate but
// low enough to swallow the spike from a single noisy final-lift frame.
const MAX_PINCH_ZOOM_DELTA_PER_EVENT = 0.18;

const EVENT_TYPES = {
WHEEL: ['wheel'],
Expand Down Expand Up @@ -112,6 +116,10 @@ export type ViewStateChangeParameters<ViewStateT = any> = {

const pinchEventWorkaround: any = {};

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<ControllerState extends IViewState<ControllerState>> {
abstract get ControllerState(): ConstructorOf<ControllerState>;
abstract get transition(): TransitionProps;
Expand Down Expand Up @@ -650,6 +658,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
// hack - hammer's `rotation` field doesn't seem to produce the correct angle
pinchEventWorkaround._startPinchRotation = event.rotation;
pinchEventWorkaround._lastPinchEvent = event;
pinchEventWorkaround._smoothedPinchScaleLog = 0;
this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {isDragging: true});
return true;
}
Expand All @@ -667,7 +676,14 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
if (this.touchZoom) {
const {scale} = event;
const pos = this.getCenter(event);
newControllerState = newControllerState.zoom({pos, scale});
// Apply the raw pinch scale in log space, clamped per event so a single
// noisy frame (especially on touch lift) can't introduce a jump.
const rawScaleLog = Math.log2(scale);
const previousScaleLog = pinchEventWorkaround._smoothedPinchScaleLog ?? 0;
const smoothedScaleLog =
previousScaleLog + clampPinchZoomDelta(rawScaleLog - previousScaleLog);
pinchEventWorkaround._smoothedPinchScaleLog = smoothedScaleLog;
newControllerState = newControllerState.zoom({pos, scale: Math.pow(2, smoothedScaleLog)});
}
if (this.touchRotate) {
const {rotation} = event;
Expand Down Expand Up @@ -727,6 +743,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
}
pinchEventWorkaround._startPinchRotation = null;
pinchEventWorkaround._lastPinchEvent = null;
pinchEventWorkaround._smoothedPinchScaleLog = null;
return true;
}

Expand Down
39 changes: 39 additions & 0 deletions test/modules/core/controllers/controllers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,45 @@ test('MapController#inertia', async () => {
});
});

test('MapController clamps a noisy final pinch frame instead of jumping', () => {
// Simulate a normal pinch ending with one sensor-noise spike. Without the
// per-event log-scale clamp the spike would propagate straight into the zoom.
const makePinchEvent = (type: string, scale: number, deltaTime: number) => ({
type,
offsetCenter: {x: 50, y: 50},
scale,
rotation: 0,
deltaTime,
srcEvent: {preventDefault() {}},
stopPropagation() {}
});

const controller = createTestController({
view: new MapView({controller: true}),
initialViewState: {
longitude: -122.45,
latitude: 37.78,
zoom: 10,
pitch: 30,
bearing: -45
}
});

controller.handleEvent(makePinchEvent('pinchstart', 1, 0) as any);
controller.handleEvent(makePinchEvent('pinchmove', 1.05, 16) as any);
controller.handleEvent(makePinchEvent('pinchmove', 1.1, 32) as any);
const zoomBeforeSpike = controller.props.zoom as number;
// 100x scale on a single frame is the kind of spike a noisy lift produces.
controller.handleEvent(makePinchEvent('pinchmove', 100, 48) as any);

const delta = (controller.props.zoom as number) - zoomBeforeSpike;
// The per-event log2 delta cap is 0.18.
expect(
delta,
'noisy final pinch frame is clamped to the per-event log-scale cap'
).toBeLessThanOrEqual(0.18 + 1e-6);
});

test('GlobeController', async () => {
await testController(
GlobeView,
Expand Down
Loading