From ea04b7bfebb9768d92efe8975de770eb4dfeabba Mon Sep 17 00:00:00 2001 From: ael-dev3 Date: Mon, 3 Aug 2026 23:40:22 +0200 Subject: [PATCH] fix: stabilize mini app pinch cadence --- src/components/realm/createRealmScene.ts | 14 ++- src/components/realm/realmPinchZoom.ts | 74 +++++++++++- .../realm/realmPointerGestureCoordinator.ts | 19 ++- tests/realmPinchZoom.test.ts | 107 ++++++++++++++++- tests/realmPointerGestureCoordinator.test.ts | 52 +++++++- tests/realmSceneCleanup.test.ts | 111 ++++++++++++++++++ 6 files changed, 363 insertions(+), 14 deletions(-) diff --git a/src/components/realm/createRealmScene.ts b/src/components/realm/createRealmScene.ts index dac2505c..f3e11870 100644 --- a/src/components/realm/createRealmScene.ts +++ b/src/components/realm/createRealmScene.ts @@ -186,7 +186,7 @@ import { type RealmPointerStartLane } from './realmPointerGestureCoordinator'; import { - realmPinchZoomAmount, + createRealmPinchZoomGesture, realmPinchZoomProfileForChromeMode } from './realmPinchZoom'; import { @@ -3790,6 +3790,7 @@ function initializeRealmScene( captureTarget.releasePointerCapture?.(pointerId); } }); + const pinchZoomGesture = createRealmPinchZoomGesture(); const worldControlPointerTargets = new Map(); const suppressedWorldControlClicks = new Map(); let pendingDirectGesture: PendingRealmDirectGesture | null = null; @@ -3810,6 +3811,7 @@ function initializeRealmScene( suppressedWorldControlClicks.forEach((timer) => window.clearTimeout(timer)); suppressedWorldControlClicks.clear(); pointerGestures.dispose(); + pinchZoomGesture.reset(); pointerCaptureTargets.clear(); worldControlPointerTargets.clear(); cameraController.cancelDirectManipulation(); @@ -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 ) @@ -4203,6 +4206,7 @@ function initializeRealmScene( worldControlPointerTargets.clear(); pointerCaptureTargets.clear(); flushDirectGesture(); + pinchZoomGesture.reset(); clearWorldControlClickSuppressions(); cancelPendingHover(); dispatchHover(null); @@ -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); } @@ -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); @@ -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); @@ -4480,6 +4487,7 @@ function initializeRealmScene( cameraController.setViewport(width, height); }; const cancelGestureForViewportChange = () => { + pinchZoomGesture.reset(); if ( directGestureFrame === 0 && pendingDirectGesture === null diff --git a/src/components/realm/realmPinchZoom.ts b/src/components/realm/realmPinchZoom.ts index f0384ad7..75d2f770 100644 --- a/src/components/realm/realmPinchZoom.ts +++ b/src/components/realm/realmPinchZoom.ts @@ -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 @@ -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, @@ -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 }); } diff --git a/src/components/realm/realmPointerGestureCoordinator.ts b/src/components/realm/realmPointerGestureCoordinator.ts index 21d27e34..441031ae 100644 --- a/src/components/realm/realmPointerGestureCoordinator.ts +++ b/src/components/realm/realmPointerGestureCoordinator.ts @@ -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<{ @@ -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; @@ -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(); }; @@ -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); @@ -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 }) }); @@ -340,6 +352,9 @@ export function createRealmPointerGestureCoordinator( }); pinchBaseline = pinchFor(pointers); const baseline = pinchBaseline; + pinchOriginDistance = baseline && baseline.distance > 0 + ? baseline.distance + : null; return result({ captureStatus, pinch: baseline ? Object.freeze({ @@ -347,7 +362,8 @@ export function createRealmPointerGestureCoordinator( centroid: Object.freeze({ ...baseline.centroid }), distance: baseline.distance, centroidDelta: Object.freeze({ x: 0, y: 0 }), - scaleRatio: 1 + scaleRatio: 1, + scaleFromStart: 1 }) : null }); } @@ -409,6 +425,7 @@ export function createRealmPointerGestureCoordinator( const activePointers = [...pointers.values()]; pointers.clear(); pinchBaseline = null; + pinchOriginDistance = null; activePointers.forEach(safelyRelease); return result({ cancelled: true }); }; diff --git a/tests/realmPinchZoom.test.ts b/tests/realmPinchZoom.test.ts index abb53b24..41051397 100644 --- a/tests/realmPinchZoom.test.ts +++ b/tests/realmPinchZoom.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + createRealmPinchZoomGesture, realmPinchZoomAmount, realmPinchZoomProfileForChromeMode } from '../src/components/realm/realmPinchZoom'; @@ -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); }); }); diff --git a/tests/realmPointerGestureCoordinator.test.ts b/tests/realmPointerGestureCoordinator.test.ts index a292213b..02dd45f2 100644 --- a/tests/realmPointerGestureCoordinator.test.ts +++ b/tests/realmPointerGestureCoordinator.test.ts @@ -184,7 +184,8 @@ describe('Realm pointer gesture coordinator', () => { centroid: { x: 150, y: 100 }, distance: 100, centroidDelta: { x: 0, y: 0 }, - scaleRatio: 1 + scaleRatio: 1, + scaleFromStart: 1 } }); expect(coordinator.move({ @@ -196,7 +197,8 @@ describe('Realm pointer gesture coordinator', () => { }).pinch).toMatchObject({ reset: false, centroid: { x: 160, y: 105 }, - centroidDelta: { x: 10, y: 5 } + centroidDelta: { x: 10, y: 5 }, + scaleFromStart: Math.hypot(120, 10) / 100 }); expect(coordinator.end({ pointerId: 2, x: 220, y: 110 })).toMatchObject({ @@ -215,6 +217,52 @@ describe('Realm pointer gesture coordinator', () => { expect(coordinator.consumeWorldControlClickSuppression()).toBe(true); }); + it('establishes a safe cumulative origin after coincident fingers separate', () => { + const coordinator = createRealmPointerGestureCoordinator(); + coordinator.start({ + pointerId: 1, + pointerType: 'touch', + lane: 'canvas', + x: 100, + y: 100 + }); + expect(coordinator.start({ + pointerId: 2, + pointerType: 'touch', + lane: 'canvas', + x: 100, + y: 100 + }).pinch).toMatchObject({ + reset: true, + distance: 0, + scaleRatio: 1, + scaleFromStart: 1 + }); + + expect(coordinator.move({ + pointerId: 2, + pointerType: 'touch', + buttons: 1, + x: 110, + y: 100 + }).pinch).toMatchObject({ + distance: 10, + scaleRatio: 1, + scaleFromStart: 1 + }); + expect(coordinator.move({ + pointerId: 2, + pointerType: 'touch', + buttons: 1, + x: 120, + y: 100 + }).pinch).toMatchObject({ + distance: 20, + scaleRatio: 2, + scaleFromStart: 2 + }); + }); + it('keeps a session valid when capture throws and guards release failures', () => { const capturePointer = vi.fn(() => { throw new DOMException('capture unavailable'); diff --git a/tests/realmSceneCleanup.test.ts b/tests/realmSceneCleanup.test.ts index 046272f8..a7078627 100644 --- a/tests/realmSceneCleanup.test.ts +++ b/tests/realmSceneCleanup.test.ts @@ -3159,6 +3159,117 @@ describe('realm scene setup cleanup', () => { root.remove(); }); + it('keeps Mini App pinch zoom cadence-independent and gentler than standard web', () => { + let nextFrameId = 1; + let frameTime = 0; + const scheduled = new Map(); + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + const frameId = nextFrameId; + nextFrameId += 1; + scheduled.set(frameId, callback); + return frameId; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation((frameId) => { + scheduled.delete(frameId); + }); + const flushQueuedFrames = () => { + const callbacks = [...scheduled.values()]; + scheduled.clear(); + frameTime += 16; + callbacks.forEach((callback) => callback(frameTime)); + }; + const runPinch = ( + stepCount: number, + chromeMode: 'miniapp' | 'desktop-web', + flushBetweenMoves = false + ) => { + const root = document.createElement('main'); + root.className = 'realm-map-screen'; + root.dataset.realmChromeMode = chromeMode; + const canvas = document.createElement('canvas'); + canvas.className = 'realm-map-screen__canvas'; + root.append(canvas); + document.body.append(root); + vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + top: 0, + right: 800, + bottom: 600, + left: 0, + width: 800, + height: 600, + toJSON: () => ({}) + }); + Object.defineProperties(canvas, { + clientWidth: { configurable: true, value: 800 }, + clientHeight: { configurable: true, value: 600 }, + setPointerCapture: { configurable: true, value: vi.fn() }, + releasePointerCapture: { configurable: true, value: vi.fn() }, + hasPointerCapture: { configurable: true, value: vi.fn(() => true) } + }); + const scene = createRealmScene(createOptions(canvas, { + surface: createRealmTerrainSurface( + `realm-pinch-cadence-${chromeMode}-${stepCount}`, + 4, + 5 + ), + reducedMotion: true + })); + scene.frameFoundingDistrict(); + scheduled.clear(); + const initialZoom = scene.getCameraAttestation().zoom; + + dispatchPointer(canvas, 'pointerdown', { + pointerId: 1, + clientX: 300, + clientY: 300, + pointerType: 'touch' + }); + dispatchPointer(canvas, 'pointerdown', { + pointerId: 2, + clientX: 500, + clientY: 300, + pointerType: 'touch' + }); + for (let step = 1; step <= stepCount; step += 1) { + dispatchPointer(canvas, 'pointermove', { + pointerId: 2, + clientX: 500 + (100 * step) / stepCount, + clientY: 300, + pointerType: 'touch' + }); + if (flushBetweenMoves) flushQueuedFrames(); + } + dispatchPointer(window, 'pointerup', { + pointerId: 2, + clientX: 600, + clientY: 300, + pointerType: 'touch' + }); + dispatchPointer(window, 'pointerup', { + pointerId: 1, + clientX: 300, + clientY: 300, + pointerType: 'touch' + }); + + const zoomDelta = scene.getCameraAttestation().zoom - initialZoom; + scene.dispose(); + root.remove(); + scheduled.clear(); + return zoomDelta; + }; + + const sparseMiniApp = runPinch(1, 'miniapp'); + const denseMiniApp = runPinch(10, 'miniapp', true); + const standardWeb = runPinch(1, 'desktop-web'); + + expect(sparseMiniApp).not.toBe(0); + expect(denseMiniApp).toBeCloseTo(sparseMiniApp, 10); + expect(Math.abs(sparseMiniApp)).toBeLessThan(Math.abs(standardWeb)); + }); + it('does not inherit pan-release inertia when a second finger only starts a pinch', () => { let nextFrameId = 1; let frameTime = 0;