Skip to content
Merged
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
14 changes: 11 additions & 3 deletions src/components/realm/createRealmScene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ import {
type RealmPointerStartLane
} from './realmPointerGestureCoordinator';
import {
realmPinchZoomAmount,
createRealmPinchZoomGesture,
realmPinchZoomProfileForChromeMode
} from './realmPinchZoom';
import {
Expand Down Expand Up @@ -3790,6 +3790,7 @@ function initializeRealmScene(
captureTarget.releasePointerCapture?.(pointerId);
}
});
const pinchZoomGesture = createRealmPinchZoomGesture();
const worldControlPointerTargets = new Map<number, HTMLElement>();
const suppressedWorldControlClicks = new Map<HTMLElement, number>();
let pendingDirectGesture: PendingRealmDirectGesture | null = null;
Expand All @@ -3810,6 +3811,7 @@ function initializeRealmScene(
suppressedWorldControlClicks.forEach((timer) => window.clearTimeout(timer));
suppressedWorldControlClicks.clear();
pointerGestures.dispose();
pinchZoomGesture.reset();
pointerCaptureTargets.clear();
worldControlPointerTargets.clear();
cameraController.cancelDirectManipulation();
Expand Down Expand Up @@ -4047,12 +4049,13 @@ function initializeRealmScene(
// two-finger gesture can never inherit velocity from the preceding pan.
cameraController.beginDirectManipulation('pinch');
if (result.pinch.reset) {
pinchZoomGesture.reset();
flushDirectGesture();
return;
}
const current = localPoint(result.pinch.centroid.x, result.pinch.centroid.y);
const zoomAmount = realmPinchZoomAmount(
result.pinch.scaleRatio,
const zoomAmount = pinchZoomGesture.amount(
result.pinch,
realmPinchZoomProfileForChromeMode(
interactionRoot.dataset.realmChromeMode
)
Expand Down Expand Up @@ -4203,6 +4206,7 @@ function initializeRealmScene(
worldControlPointerTargets.clear();
pointerCaptureTargets.clear();
flushDirectGesture();
pinchZoomGesture.reset();
clearWorldControlClickSuppressions();
cancelPendingHover();
dispatchHover(null);
Expand Down Expand Up @@ -4233,6 +4237,7 @@ function initializeRealmScene(
pointerCaptureTargets.delete(event.pointerId);
queueGesture(result, event.clientX, event.clientY);
flushDirectGesture();
if (result.phase !== 'pinching') pinchZoomGesture.reset();
if (worldControlTarget && !result.tap) {
armWorldControlClickSuppression(worldControlTarget);
}
Expand Down Expand Up @@ -4269,6 +4274,7 @@ function initializeRealmScene(
worldControlPointerTargets.delete(event.pointerId);
pointerCaptureTargets.delete(event.pointerId);
flushDirectGesture();
if (result.phase !== 'pinching') pinchZoomGesture.reset();
clearWorldControlClickSuppressions();
dispatchHover(null);
syncGesturePhase(result);
Expand All @@ -4285,6 +4291,7 @@ function initializeRealmScene(
worldControlPointerTargets.delete(event.pointerId);
pointerCaptureTargets.delete(event.pointerId);
flushDirectGesture();
if (result.phase !== 'pinching') pinchZoomGesture.reset();
clearWorldControlClickSuppressions();
dispatchHover(null);
syncGesturePhase(result);
Expand Down Expand Up @@ -4480,6 +4487,7 @@ function initializeRealmScene(
cameraController.setViewport(width, height);
};
const cancelGestureForViewportChange = () => {
pinchZoomGesture.reset();
if (
directGestureFrame === 0
&& pendingDirectGesture === null
Expand Down
74 changes: 68 additions & 6 deletions src/components/realm/realmPinchZoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,23 @@ export type RealmPinchZoomProfile = 'standard' | 'miniapp';

const STANDARD_PINCH_ZOOM_SENSITIVITY = 0.78;
const MINI_APP_PINCH_ZOOM_SENSITIVITY = 0.46;
const MINI_APP_PINCH_ZOOM_SOFT_LIMIT = 0.12;
const MINI_APP_PINCH_ZOOM_SOFTNESS = 0.12;

export type RealmPinchZoomSample = Readonly<{
reset: boolean;
/** Multiplicative distance change since the previous pointer sample. */
scaleRatio: number;
/** Multiplicative distance change since the gesture began. */
scaleFromStart: number;
}>;

export type RealmPinchZoomGesture = Readonly<{
amount: (
sample: RealmPinchZoomSample,
profile: RealmPinchZoomProfile
) => number;
reset: () => void;
}>;

export function realmPinchZoomProfileForChromeMode(
chromeMode: string | undefined
Expand All @@ -11,10 +27,10 @@ export function realmPinchZoomProfileForChromeMode(
}

/**
* Converts one incremental pinch scale into the camera's normalized zoom.
* Converts a pinch scale into the camera's normalized zoom.
* Standard browser input retains the established response exactly. Mini App
* WebViews receive a gentler curve and smoothly compress unusually large
* pointer batches instead of turning them into abrupt camera jumps.
* WebViews receive a gentler curve that smoothly compresses unusually large
* changes without imposing a hard limit on a deliberate long gesture.
*/
export function realmPinchZoomAmount(
scaleRatio: number,
Expand All @@ -26,6 +42,52 @@ export function realmPinchZoomAmount(
return logarithmicDelta * STANDARD_PINCH_ZOOM_SENSITIVITY;
}
const scaledDelta = logarithmicDelta * MINI_APP_PINCH_ZOOM_SENSITIVITY;
return Math.tanh(scaledDelta / MINI_APP_PINCH_ZOOM_SOFT_LIMIT)
* MINI_APP_PINCH_ZOOM_SOFT_LIMIT;
return Math.asinh(scaledDelta / MINI_APP_PINCH_ZOOM_SOFTNESS)
* MINI_APP_PINCH_ZOOM_SOFTNESS;
}

/**
* Makes Mini App zoom independent of WebView pointer-event cadence. Mini App
* samples describe the total gesture and this adapter applies only the change
* since the previous total. Standard browsers continue to consume the exact
* established incremental ratio.
*/
export function createRealmPinchZoomGesture(): RealmPinchZoomGesture {
let previousProfile: RealmPinchZoomProfile | null = null;
let previousMiniAppTotal = 0;

const reset = () => {
previousProfile = null;
previousMiniAppTotal = 0;
};

const amount = (
sample: RealmPinchZoomSample,
profile: RealmPinchZoomProfile
) => {
if (sample.reset) {
reset();
return 0;
}
if (profile === 'standard') {
previousProfile = profile;
previousMiniAppTotal = 0;
return realmPinchZoomAmount(sample.scaleRatio, profile);
}
if (!Number.isFinite(sample.scaleFromStart) || sample.scaleFromStart <= 0) {
return 0;
}

const total = realmPinchZoomAmount(sample.scaleFromStart, profile);
const delta = previousProfile === profile
? total - previousMiniAppTotal
: previousProfile === null
? total
: realmPinchZoomAmount(sample.scaleRatio, profile);
previousProfile = profile;
previousMiniAppTotal = total;
return Number.isFinite(delta) ? delta : 0;
};

return Object.freeze({ amount, reset });
}
19 changes: 18 additions & 1 deletion src/components/realm/realmPointerGestureCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export type RealmPointerPinch = Readonly<{
centroidDelta: RealmPointerPosition;
/** Multiplicative distance change since the previous pinch sample. */
scaleRatio: number;
/** Multiplicative distance change since this two-pointer gesture began. */
scaleFromStart: number;
}>;

export type RealmPointerGestureResult = Readonly<{
Expand Down Expand Up @@ -160,6 +162,7 @@ export function createRealmPointerGestureCoordinator(
: DEFAULT_TOUCH_DRAG_THRESHOLD
);
let pinchBaseline: PinchBaseline | null = null;
let pinchOriginDistance: number | null = null;
let worldControlClickSuppressionPending = false;
let disposed = false;

Expand Down Expand Up @@ -229,6 +232,7 @@ export function createRealmPointerGestureCoordinator(
if (release) safelyRelease(pointer);
else pointer.captured = false;
pinchBaseline = pinchFor(pointers);
if (pointers.size < 2) pinchOriginDistance = null;
resetRemainingPointer();
};

Expand All @@ -240,6 +244,9 @@ export function createRealmPointerGestureCoordinator(
if (!nextPinch) return result();
const previousPinch = pinchBaseline ?? nextPinch;
pinchBaseline = nextPinch;
if (pinchOriginDistance === null && nextPinch.distance > 0) {
pinchOriginDistance = nextPinch.distance;
}
let captureStatus: RealmPointerCaptureStatus | null = null;
pointers.forEach((activePointer) => {
const status = markDragged(activePointer);
Expand All @@ -257,6 +264,11 @@ export function createRealmPointerGestureCoordinator(
}),
scaleRatio: previousPinch.distance > 0 && nextPinch.distance > 0
? nextPinch.distance / previousPinch.distance
: 1,
scaleFromStart: pinchOriginDistance !== null
&& pinchOriginDistance > 0
&& nextPinch.distance > 0
? nextPinch.distance / pinchOriginDistance
: 1
})
});
Expand Down Expand Up @@ -340,14 +352,18 @@ export function createRealmPointerGestureCoordinator(
});
pinchBaseline = pinchFor(pointers);
const baseline = pinchBaseline;
pinchOriginDistance = baseline && baseline.distance > 0
? baseline.distance
: null;
return result({
captureStatus,
pinch: baseline ? Object.freeze({
reset: true,
centroid: Object.freeze({ ...baseline.centroid }),
distance: baseline.distance,
centroidDelta: Object.freeze({ x: 0, y: 0 }),
scaleRatio: 1
scaleRatio: 1,
scaleFromStart: 1
}) : null
});
}
Expand Down Expand Up @@ -409,6 +425,7 @@ export function createRealmPointerGestureCoordinator(
const activePointers = [...pointers.values()];
pointers.clear();
pinchBaseline = null;
pinchOriginDistance = null;
activePointers.forEach(safelyRelease);
return result({ cancelled: true });
};
Expand Down
107 changes: 105 additions & 2 deletions tests/realmPinchZoom.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';

import {
createRealmPinchZoomGesture,
realmPinchZoomAmount,
realmPinchZoomProfileForChromeMode
} from '../src/components/realm/realmPinchZoom';
Expand All @@ -20,23 +21,125 @@ describe('realm pinch zoom', () => {
.toBeCloseTo(Math.log(0.9) * 0.78, 12);
});

it('slows ordinary Mini App pinches and smoothly bounds delayed WebView batches', () => {
it('slows ordinary Mini App pinches and smoothly compresses delayed WebView batches', () => {
const ordinaryStandard = realmPinchZoomAmount(1.1, 'standard');
const ordinaryMiniApp = realmPinchZoomAmount(1.1, 'miniapp');
const delayedMiniApp = realmPinchZoomAmount(2, 'miniapp');
const deliberateLongMiniApp = realmPinchZoomAmount(4, 'miniapp');

expect(ordinaryMiniApp).toBeGreaterThan(0);
expect(ordinaryMiniApp).toBeLessThan(ordinaryStandard * 0.65);
expect(delayedMiniApp).toBeGreaterThan(ordinaryMiniApp);
expect(delayedMiniApp).toBeLessThan(0.12);
expect(delayedMiniApp).toBeLessThan(realmPinchZoomAmount(2, 'standard') * 0.4);
expect(deliberateLongMiniApp).toBeGreaterThan(delayedMiniApp);
expect(realmPinchZoomAmount(0.5, 'miniapp'))
.toBeCloseTo(-delayedMiniApp, 12);
});

it('produces the same Mini App zoom for sparse and dense event cadences', () => {
const sparse = createRealmPinchZoomGesture();
sparse.amount({ reset: true, scaleRatio: 1, scaleFromStart: 1 }, 'miniapp');
const sparseAmount = sparse.amount({
reset: false,
scaleRatio: 1.5,
scaleFromStart: 1.5
}, 'miniapp');

const dense = createRealmPinchZoomGesture();
dense.amount({ reset: true, scaleRatio: 1, scaleFromStart: 1 }, 'miniapp');
let denseAmount = 0;
let previousScale = 1;
for (let step = 1; step <= 10; step += 1) {
const scaleFromStart = 1 + step * 0.05;
denseAmount += dense.amount({
reset: false,
scaleRatio: scaleFromStart / previousScale,
scaleFromStart
}, 'miniapp');
previousScale = scaleFromStart;
}

expect(denseAmount).toBeCloseTo(sparseAmount, 12);
expect(sparseAmount).toBeCloseTo(realmPinchZoomAmount(1.5, 'miniapp'), 12);
});

it('keeps the standard gesture path exactly incremental', () => {
const gesture = createRealmPinchZoomGesture();
gesture.amount({ reset: true, scaleRatio: 1, scaleFromStart: 1 }, 'standard');
const first = gesture.amount({
reset: false,
scaleRatio: 1.1,
scaleFromStart: 1.1
}, 'standard');
const second = gesture.amount({
reset: false,
scaleRatio: 1.2 / 1.1,
scaleFromStart: 1.2
}, 'standard');

expect(first + second).toBeCloseTo(Math.log(1.2) * 0.78, 12);
});

it('tracks reversals across the gesture origin without retaining drift', () => {
const gesture = createRealmPinchZoomGesture();
gesture.amount({ reset: true, scaleRatio: 1, scaleFromStart: 1 }, 'miniapp');
const outward = gesture.amount({
reset: false,
scaleRatio: 1.3,
scaleFromStart: 1.3
}, 'miniapp');
const crossedOrigin = gesture.amount({
reset: false,
scaleRatio: 0.8 / 1.3,
scaleFromStart: 0.8
}, 'miniapp');
const returnedToOrigin = gesture.amount({
reset: false,
scaleRatio: 1 / 0.8,
scaleFromStart: 1
}, 'miniapp');

expect(outward + crossedOrigin)
.toBeCloseTo(realmPinchZoomAmount(0.8, 'miniapp'), 12);
expect(outward + crossedOrigin + returnedToOrigin).toBeCloseTo(0, 12);
});

it('rebases safely across a profile change and an explicit cancellation reset', () => {
const gesture = createRealmPinchZoomGesture();
gesture.amount({ reset: true, scaleRatio: 1, scaleFromStart: 1 }, 'miniapp');
gesture.amount({ reset: false, scaleRatio: 1.2, scaleFromStart: 1.2 }, 'miniapp');

expect(gesture.amount({
reset: false,
scaleRatio: 1.1,
scaleFromStart: 1.32
}, 'standard')).toBeCloseTo(realmPinchZoomAmount(1.1, 'standard'), 12);
expect(gesture.amount({
reset: false,
scaleRatio: 1.05,
scaleFromStart: 1.386
}, 'miniapp')).toBeCloseTo(realmPinchZoomAmount(1.05, 'miniapp'), 12);

gesture.reset();
expect(gesture.amount({
reset: false,
scaleRatio: 1.25,
scaleFromStart: 1.25
}, 'miniapp')).toBeCloseTo(realmPinchZoomAmount(1.25, 'miniapp'), 12);
});

it('rejects malformed scale samples without moving the camera', () => {
expect(realmPinchZoomAmount(0, 'miniapp')).toBe(0);
expect(realmPinchZoomAmount(-1, 'miniapp')).toBe(0);
expect(realmPinchZoomAmount(Number.NaN, 'miniapp')).toBe(0);
expect(realmPinchZoomAmount(Number.POSITIVE_INFINITY, 'miniapp')).toBe(0);

const gesture = createRealmPinchZoomGesture();
gesture.amount({ reset: true, scaleRatio: 1, scaleFromStart: 1 }, 'miniapp');
expect(gesture.amount({
reset: false,
scaleRatio: Number.NaN,
scaleFromStart: Number.NaN
}, 'miniapp')).toBe(0);
});
});
Loading