From 2affeee0d8c83e8753b3cf80be4d65b08806e404 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Sat, 25 Jul 2026 09:28:47 -0400 Subject: [PATCH 1/6] Add orthographic maxBounds rubber-band inertia --- .../core/orthographic-controller.md | 1 + examples/website/orthographic/app.tsx | 3 +- modules/core/src/controllers/controller.ts | 2 + .../controllers/orthographic-controller.ts | 119 +++++++++++- .../core/controllers/controllers.spec.ts | 182 ++++++++++++++++++ 5 files changed, 302 insertions(+), 5 deletions(-) diff --git a/docs/api-reference/core/orthographic-controller.md b/docs/api-reference/core/orthographic-controller.md index febe2914ab7..5fdc7fd81b8 100644 --- a/docs/api-reference/core/orthographic-controller.md +++ b/docs/api-reference/core/orthographic-controller.md @@ -47,6 +47,7 @@ Also accepts additional options: - `zoomAxis` (string) - which axes to apply zoom to. Affects scroll, keyboard +/- and double tap. One of `X` (zoom along the X axis only), `Y` (zoom along the Y axis only), `all`. Default `all`. If this option is set to `X` or `Y`, `viewState.zoom` must be an array to enable independent zoom for each axis. - `maxBounds` - constrains the target position within the specified bounding box `[[minX, minY], [maxX, maxY]]` +- `rubberBand` (boolean) - allows panning to temporarily overshoot `maxBounds` with increasing resistance, then animates the viewport back within bounds on release. Requires `maxBounds`. Default `false`. ## Custom OrthographicController diff --git a/examples/website/orthographic/app.tsx b/examples/website/orthographic/app.tsx index 16c0907b7a0..54dabd70a28 100644 --- a/examples/website/orthographic/app.tsx +++ b/examples/website/orthographic/app.tsx @@ -227,7 +227,8 @@ export default function App({ views={new OrthographicView()} initialViewState={initialViewState} controller={{ - maxBounds: contentBounds + maxBounds: contentBounds, + rubberBand: true }} layers={layers} layerFilter={layerFilter} diff --git a/modules/core/src/controllers/controller.ts b/modules/core/src/controllers/controller.ts index 55eea2eaef8..23fae5e2875 100644 --- a/modules/core/src/controllers/controller.ts +++ b/modules/core/src/controllers/controller.ts @@ -92,6 +92,8 @@ export type ControllerOptions = { | [min: [number, number], max: [number, number]] | [min: [number, number, number], max: [number, number, number]] | null; + /** Allow orthographic panning to temporarily overshoot `maxBounds`. Default `false`. */ + rubberBand?: boolean; }; export type ControllerProps = { diff --git a/modules/core/src/controllers/orthographic-controller.ts b/modules/core/src/controllers/orthographic-controller.ts index 4c028e93ad6..5d684ab668e 100644 --- a/modules/core/src/controllers/orthographic-controller.ts +++ b/modules/core/src/controllers/orthographic-controller.ts @@ -10,6 +10,36 @@ import type Viewport from '../viewports/viewport'; import LinearInterpolator from '../transitions/linear-interpolator'; import type {MjolnirGestureEvent} from 'mjolnir.js'; +// Only gesture and transition targets may temporarily exceed maxBounds. +const RUBBER_BAND_TARGETS = new WeakSet(); + +class RubberBandInterpolator extends LinearInterpolator { + private target: number[]; + + constructor(target: number[]) { + super(['target', 'zoomX', 'zoomY']); + this.target = target; + } + + override interpolateProps( + startProps: Record, + endProps: Record, + t: number + ): Record { + const props = super.interpolateProps(startProps, endProps, t); + if (t < 1) { + props.target = this.target.map( + (value: number, index: number) => + (1 - t) * (1 - t) * (startProps.target[index] ?? value) + + 2 * (1 - t) * t * value + + t * t * (endProps.target[index] ?? value) + ); + RUBBER_BAND_TARGETS.add(props.target); + } + return props; + } +} + export type OrthographicStateProps = { width: number; height: number; @@ -26,6 +56,7 @@ export type OrthographicStateProps = { minZoomY?: number; maxBounds?: ControllerProps['maxBounds']; + rubberBand?: boolean; }; type OrthographicStateInternal = { @@ -81,6 +112,7 @@ export class OrthographicState extends ViewState< maxZoomY = maxZoom, maxBounds = null, + rubberBand = false, /** Interaction states, required to calculate change during transform */ // Model state when the pan operation first started @@ -105,7 +137,8 @@ export class OrthographicState extends ViewState< maxZoomX, minZoomY, maxZoomY, - maxBounds + maxBounds, + rubberBand }, { startPanPosition, @@ -139,6 +172,26 @@ export class OrthographicState extends ViewState< const viewport = this.makeViewport(this.getViewportProps()); const newProps = viewport.panByPosition(startPanPosition, pos); + const {maxBounds, rubberBand, width, height, zoomX, zoomY} = this.getViewportProps(); + + if (rubberBand && maxBounds && this.getState().startPanPosition) { + const halfWidth = width / 2 / 2 ** zoomX; + const halfHeight = height / 2 / 2 ** zoomY; + newProps.target = newProps.target.slice(); + + for (const [index, halfSize] of [halfWidth, halfHeight].entries()) { + const minimum = maxBounds[0][index] + halfSize; + const maximum = maxBounds[1][index] - halfSize; + const constrained = clamp(newProps.target[index], minimum, maximum); + const overshoot = newProps.target[index] - constrained; + if (overshoot) { + newProps.target[index] = + constrained + (overshoot * halfSize) / (halfSize + Math.abs(overshoot)); + } + } + + RUBBER_BAND_TARGETS.add(newProps.target); + } return this._getUpdatedState(newProps); } @@ -148,8 +201,10 @@ export class OrthographicState extends ViewState< * Must call if `panStart()` was called */ panEnd(): OrthographicState { + const {target} = this.getViewportProps(); return this._getUpdatedState({ - startPanPosition: null + startPanPosition: null, + ...(RUBBER_BAND_TARGETS.has(target) && {target: target.slice()}) }); } @@ -360,7 +415,7 @@ export class OrthographicState extends ViewState< : props.zoomX; const {maxBounds, target} = props; - if (maxBounds) { + if (maxBounds && !(props.rubberBand && RUBBER_BAND_TARGETS.has(target))) { // only calculate center and zoom ranges at rotation=0 // to maintain visual stability when rotating const halfWidth = props.width / 2 / 2 ** zoomX; @@ -440,8 +495,64 @@ export default class OrthographicController extends Controller { ); }); +const ORTHOGRAPHIC_MAX_BOUNDS: [[number, number], [number, number]] = [ + [0, 0], + [200, 200] +]; + +test('OrthographicController keeps maxBounds hard by default', () => { + for (const rubberBand of [undefined, false]) { + const controller = createTestController({ + view: new OrthographicView({ + controller: { + maxBounds: ORTHOGRAPHIC_MAX_BOUNDS, + rubberBand + } + }), + initialViewState: {target: [100, 100, 0], zoom: 0} + }); + + controller.handleEvent(makeGestureEvent('panstart') as any); + controller.handleEvent(makeGestureEvent('panmove', {x: 200}) as any); + + expect(controller.props.target, 'panning stops at the visible bounds').toEqual([50, 100]); + controller.finalize(); + } +}); + +test('OrthographicController applies resistance beyond every edge', () => { + for (const {x, y, axes} of [ + {x: 200, y: 50, axes: [0]}, + {x: -100, y: 50, axes: [0]}, + {x: 50, y: 200, axes: [1]}, + {x: 50, y: -100, axes: [1]}, + {x: 200, y: 200, axes: [0, 1]} + ]) { + const controller = createTestController({ + view: new OrthographicView({ + controller: { + maxBounds: ORTHOGRAPHIC_MAX_BOUNDS, + rubberBand: true + } + }), + initialViewState: {target: [100, 100, 0], zoom: 0} + }); + + controller.handleEvent(makeGestureEvent('panstart') as any); + controller.handleEvent(makeGestureEvent('panmove', {x, y}) as any); + + for (const axis of axes) { + const displacement = Math.abs(controller.props.target[axis] - 100); + expect(Number.isFinite(displacement), 'overscroll stays finite').toBe(true); + expect(displacement, 'panning temporarily exceeds the visible bounds').toBeGreaterThan(50); + expect(displacement, 'overscroll is smaller than the unresisted displacement').toBeLessThan( + 150 + ); + } + controller.finalize(); + } +}); + +test('OrthographicController springs overscroll back within maxBounds', () => { + for (const {inertia, velocity} of [ + {inertia: undefined, velocity: 0}, + {inertia: 450, velocity: 1} + ]) { + const interactionStates: any[] = []; + const controller = createTestController({ + view: new OrthographicView({ + controller: { + maxBounds: ORTHOGRAPHIC_MAX_BOUNDS, + rubberBand: true, + inertia + } + }), + initialViewState: {target: [100, 100, 0], zoom: 0}, + onStateChange: state => interactionStates.push({...state}) + }); + + controller.handleEvent(makeGestureEvent('panstart') as any); + controller.handleEvent(makeGestureEvent('panmove', {x: 200}) as any); + const draggedTarget = controller.props.target[0]; + controller.handleEvent({ + ...makeGestureEvent('panend', {x: 200}), + velocity, + velocityX: velocity, + velocityY: 0 + } as any); + + const transition = controller.transitionManager.transition; + expect(transition.inProgress, 'release starts a spring-back transition').toBe(true); + expect(transition.settings.duration, 'spring-back uses the configured inertia').toBe( + inertia ?? 300 + ); + + const timeline: Timeline = transition._timeline; + timeline.setTime(timeline.getTime() + transition.settings.duration / 4); + controller.updateTransition(); + if (velocity) { + expect(controller.props.target[0], 'a fling increases the initial overshoot').toBeLessThan( + draggedTarget + ); + } + + timeline.setTime(timeline.getTime() + (transition.settings.duration * 3) / 4); + controller.updateTransition(); + + expect(controller.props.target, 'spring-back finishes exactly at the edge').toEqual([50, 100]); + expect(transition.inProgress, 'the transition finishes').toBe(false); + expect( + interactionStates[interactionStates.length - 1], + 'interaction state is cleared' + ).toMatchObject({ + inTransition: false, + isDragging: false, + isPanning: false + }); + controller.finalize(); + } +}); + +test('OrthographicController interrupts rubber-band spring-back with a new pan', () => { + const interactionStates: any[] = []; + const controller = createTestController({ + view: new OrthographicView({ + controller: { + maxBounds: ORTHOGRAPHIC_MAX_BOUNDS, + rubberBand: true + } + }), + initialViewState: {target: [100, 100, 0], zoom: 0}, + onStateChange: state => interactionStates.push({...state}) + }); + + controller.handleEvent(makeGestureEvent('panstart') as any); + controller.handleEvent(makeGestureEvent('panmove', {x: 200}) as any); + controller.handleEvent(makeGestureEvent('panend', {x: 200}) as any); + expect(controller.transitionManager.transition.inProgress).toBe(true); + + controller.handleEvent(makeGestureEvent('panstart') as any); + + expect(controller.transitionManager.transition.inProgress).toBe(false); + expect(interactionStates[interactionStates.length - 1]).toMatchObject({ + inTransition: false, + isDragging: true, + isPanning: false + }); + controller.finalize(); +}); + +test('OrthographicController applies rubber-band resistance to multi-touch panning', () => { + const controller = createTestController({ + view: new OrthographicView({ + controller: { + maxBounds: ORTHOGRAPHIC_MAX_BOUNDS, + rubberBand: true, + multiTouchDrag: 'pan' + } + }), + initialViewState: {target: [100, 100, 0], zoom: 0} + }); + + controller.handleEvent(makeGestureEvent('multipanstart') as any); + controller.handleEvent(makeGestureEvent('multipanmove', {x: 200, deltaX: 150}) as any); + + expect(controller.props.target[0], 'multi-touch panning overshoots the edge').toBeLessThan(50); + expect(controller.props.target[0], 'multi-touch overscroll is resisted').toBeGreaterThan(-50); + controller.finalize(); +}); + +test('OrthographicController ignores rubberBand without maxBounds', () => { + for (const rubberBand of [false, true]) { + const controller = createTestController({ + view: new OrthographicView({controller: {rubberBand}}), + initialViewState: {target: [100, 100, 0], zoom: 0} + }); + + controller.handleEvent(makeGestureEvent('panstart') as any); + controller.handleEvent(makeGestureEvent('panmove', {x: 200}) as any); + + expect(controller.props.target, 'unbounded panning is unchanged').toEqual([-50, 100]); + controller.finalize(); + } +}); + test('OrthographicController supports multipan only in pan mode', () => { const panController = createTestController({ view: new OrthographicView({controller: {multiTouchDrag: 'pan'}}), From 836738ee1dd08a822ce811445914a52dd5db168e Mon Sep 17 00:00:00 2001 From: Ib Green Date: Sat, 25 Jul 2026 17:14:45 -0400 Subject: [PATCH 2/6] fix(core): address orthographic rubber-band edge cases --- .../core/orthographic-controller.md | 2 +- .../controllers/orthographic-controller.ts | 35 +++- .../core/controllers/controllers.spec.ts | 185 ++++++++++++++++++ 3 files changed, 217 insertions(+), 5 deletions(-) diff --git a/docs/api-reference/core/orthographic-controller.md b/docs/api-reference/core/orthographic-controller.md index 5fdc7fd81b8..d62cbe7429d 100644 --- a/docs/api-reference/core/orthographic-controller.md +++ b/docs/api-reference/core/orthographic-controller.md @@ -47,7 +47,7 @@ Also accepts additional options: - `zoomAxis` (string) - which axes to apply zoom to. Affects scroll, keyboard +/- and double tap. One of `X` (zoom along the X axis only), `Y` (zoom along the Y axis only), `all`. Default `all`. If this option is set to `X` or `Y`, `viewState.zoom` must be an array to enable independent zoom for each axis. - `maxBounds` - constrains the target position within the specified bounding box `[[minX, minY], [maxX, maxY]]` -- `rubberBand` (boolean) - allows panning to temporarily overshoot `maxBounds` with increasing resistance, then animates the viewport back within bounds on release. Requires `maxBounds`. Default `false`. +- `rubberBand` (boolean) - allows panning to temporarily overshoot `maxBounds` with increasing resistance, then animates the viewport back within bounds on release. Requires `maxBounds`. Uses the configured `inertia` duration, or a 300 ms default, for the return transition. Default `false`. ## Custom OrthographicController diff --git a/modules/core/src/controllers/orthographic-controller.ts b/modules/core/src/controllers/orthographic-controller.ts index 5d684ab668e..21d347f92d9 100644 --- a/modules/core/src/controllers/orthographic-controller.ts +++ b/modules/core/src/controllers/orthographic-controller.ts @@ -56,6 +56,7 @@ export type OrthographicStateProps = { minZoomY?: number; maxBounds?: ControllerProps['maxBounds']; + /** Enables resisted, spring-backed panning when `maxBounds` is set. */ rubberBand?: boolean; }; @@ -182,6 +183,10 @@ export class OrthographicState extends ViewState< for (const [index, halfSize] of [halfWidth, halfHeight].entries()) { const minimum = maxBounds[0][index] + halfSize; const maximum = maxBounds[1][index] - halfSize; + if (!Number.isFinite(halfSize) || minimum > maximum) { + newProps.target[index] = (maxBounds[0][index] + maxBounds[1][index]) / 2; + continue; + } const constrained = clamp(newProps.target[index], minimum, maximum); const overshoot = newProps.target[index] - constrained; if (overshoot) { @@ -415,7 +420,7 @@ export class OrthographicState extends ViewState< : props.zoomX; const {maxBounds, target} = props; - if (maxBounds && !(props.rubberBand && RUBBER_BAND_TARGETS.has(target))) { + if (maxBounds) { // only calculate center and zoom ranges at rotation=0 // to maintain visual stability when rotating const halfWidth = props.width / 2 / 2 ** zoomX; @@ -424,12 +429,26 @@ export class OrthographicState extends ViewState< const maxX = maxBounds[1][0] - halfWidth; const minY = maxBounds[0][1] + halfHeight; const maxY = maxBounds[1][1] - halfHeight; - const x = clamp(target[0], minX, maxX); - const y = clamp(target[1], minY, maxY); + const preserveRubberBandTarget = Boolean(props.rubberBand && RUBBER_BAND_TARGETS.has(target)); + const x = + minX > maxX && props.rubberBand + ? (maxBounds[0][0] + maxBounds[1][0]) / 2 + : preserveRubberBandTarget + ? target[0] + : clamp(target[0], minX, maxX); + const y = + minY > maxY && props.rubberBand + ? (maxBounds[0][1] + maxBounds[1][1]) / 2 + : preserveRubberBandTarget + ? target[1] + : clamp(target[1], minY, maxY); if (x !== target[0] || y !== target[1]) { props.target = target.slice(); props.target[0] = x; props.target[1] = y; + if (preserveRubberBandTarget && (minX <= maxX || minY <= maxY)) { + RUBBER_BAND_TARGETS.add(props.target); + } } } return props; @@ -526,7 +545,15 @@ export default class OrthographicController extends Controller { } }); +test.each([ + { + description: 'the vertical bounds cannot fill the viewport', + maxBounds: [ + [0, 10], + [200, 11] + ] as [[number, number], [number, number]], + target: [100, 10.5, 0], + maxZoomX: 6, + maxZoomY: 2, + position: {x: 200, y: 50}, + elasticAxis: 0, + centeredAxis: 1 + }, + { + description: 'the horizontal bounds cannot fill the viewport', + maxBounds: [ + [10, 0], + [11, 200] + ] as [[number, number], [number, number]], + target: [10.5, 100, 0], + maxZoomX: 2, + maxZoomY: 6, + position: {x: 50, y: 200}, + elasticAxis: 1, + centeredAxis: 0 + } +])('OrthographicController preserves rubber-band panning when $description', testCase => { + const interactionStates: any[] = []; + const controller = createTestController({ + view: new OrthographicView({ + controller: { + maxBounds: testCase.maxBounds, + rubberBand: true + } + }), + initialViewState: { + target: testCase.target, + zoom: 0, + zoomAxis: 'X', + maxZoomX: testCase.maxZoomX, + maxZoomY: testCase.maxZoomY + }, + onStateChange: state => interactionStates.push({...state}) + }); + + expect(controller.props.target[testCase.centeredAxis], 'the non-fitting axis is centered').toBe( + 10.5 + ); + + controller.handleEvent(makeGestureEvent('panstart') as any); + controller.handleEvent(makeGestureEvent('panmove', testCase.position) as any); + + expect( + controller.props.target[testCase.centeredAxis], + 'panning keeps the other axis centered' + ).toBe(10.5); + expect(controller.props.target[testCase.elasticAxis], 'the fitting axis overshoots').toBeLessThan( + 50 + ); + expect( + controller.props.target[testCase.elasticAxis], + 'the overshoot is resisted' + ).toBeGreaterThan(-50); + + controller.handleEvent(makeGestureEvent('panend', testCase.position) as any); + + const transition = controller.transitionManager.transition; + expect(transition.inProgress, 'the fitting axis starts a spring-back transition').toBe(true); + + const timeline: Timeline = transition._timeline; + timeline.setTime(timeline.getTime() + transition.settings.duration); + controller.updateTransition(); + + expect( + controller.props.target[testCase.elasticAxis], + 'the fitting axis settles at its edge' + ).toBe(50); + expect( + controller.props.target[testCase.centeredAxis], + 'the non-fitting axis stays centered' + ).toBe(10.5); + expect( + interactionStates[interactionStates.length - 1], + 'interaction state is cleared' + ).toMatchObject({ + inTransition: false, + isDragging: false, + isPanning: false + }); + controller.finalize(); +}); + test('OrthographicController springs overscroll back within maxBounds', () => { for (const {inertia, velocity} of [ {inertia: undefined, velocity: 0}, @@ -441,6 +534,98 @@ test('OrthographicController springs overscroll back within maxBounds', () => { } }); +test('OrthographicController preserves overscroll during an inward fling', () => { + const interactionStates: any[] = []; + const controller = createTestController({ + view: new OrthographicView({ + controller: { + maxBounds: ORTHOGRAPHIC_MAX_BOUNDS, + rubberBand: true, + inertia: 300 + } + }), + initialViewState: {target: [100, 100, 0], zoom: 0}, + onStateChange: state => interactionStates.push({...state}) + }); + + controller.handleEvent(makeGestureEvent('panstart') as any); + controller.handleEvent(makeGestureEvent('panmove', {x: 200}) as any); + + const draggedTarget = controller.props.target[0]; + expect(draggedTarget, 'the drag temporarily exceeds the visible edge').toBeLessThan(50); + + controller.handleEvent({ + ...makeGestureEvent('panend', {x: 200}), + velocity: 1, + velocityX: -1, + velocityY: 0 + } as any); + + const transition = controller.transitionManager.transition; + expect(transition.inProgress, 'the inward release starts a rubber-band transition').toBe(true); + expect( + controller.props.target[0], + 'the first transition frame does not snap to the edge' + ).toBeCloseTo(draggedTarget); + + const timeline: Timeline = transition._timeline; + timeline.setTime(timeline.getTime() + 20); + controller.updateTransition(); + + expect(controller.props.target[0], 'the spring moves inward').toBeGreaterThan(draggedTarget); + expect( + controller.props.target[0], + 'the early transition frame remains overscrolled' + ).toBeLessThan(50); + + timeline.setTime(timeline.getTime() + 280); + controller.updateTransition(); + + expect(controller.props.target, 'the fling settles at the projected in-bounds target').toEqual([ + 100, 100 + ]); + expect(transition.inProgress, 'the transition finishes').toBe(false); + expect( + interactionStates[interactionStates.length - 1], + 'interaction state is cleared' + ).toMatchObject({ + inTransition: false, + isDragging: false, + isPanning: false + }); + controller.finalize(); +}); + +test('OrthographicController preserves native inertia for in-bounds flings', () => { + const controller = createTestController({ + view: new OrthographicView({ + controller: { + maxBounds: ORTHOGRAPHIC_MAX_BOUNDS, + rubberBand: true, + inertia: 300 + } + }), + initialViewState: {target: [100, 100, 0], zoom: 0} + }); + + controller.handleEvent(makeGestureEvent('panstart') as any); + controller.handleEvent(makeGestureEvent('panmove', {x: 60}) as any); + controller.handleEvent({ + ...makeGestureEvent('panend', {x: 60}), + velocity: 1, + velocityX: 0.1, + velocityY: 0 + } as any); + + const transition = controller.transitionManager.transition; + expect(transition.inProgress, 'the in-bounds fling starts native inertia').toBe(true); + expect( + transition.settings.interpolator.constructor.name, + 'in-bounds flings retain the native linear interpolator' + ).toBe('LinearInterpolator'); + controller.finalize(); +}); + test('OrthographicController interrupts rubber-band spring-back with a new pan', () => { const interactionStates: any[] = []; const controller = createTestController({ From 65a8d31816026f957c2249ecf88fb897cfa7cb9b Mon Sep 17 00:00:00 2001 From: Ib Green Date: Sat, 25 Jul 2026 20:51:59 -0400 Subject: [PATCH 3/6] refactor(core): simplify maxBounds rubber-banding --- .../core/orthographic-controller.md | 2 +- examples/website/orthographic/app.tsx | 2 +- modules/core/src/controllers/controller.ts | 7 +- .../controllers/orthographic-controller.ts | 173 ++++---- .../core/controllers/controllers.spec.ts | 383 ++++++++---------- 5 files changed, 269 insertions(+), 298 deletions(-) diff --git a/docs/api-reference/core/orthographic-controller.md b/docs/api-reference/core/orthographic-controller.md index d62cbe7429d..37843e6453a 100644 --- a/docs/api-reference/core/orthographic-controller.md +++ b/docs/api-reference/core/orthographic-controller.md @@ -47,7 +47,7 @@ Also accepts additional options: - `zoomAxis` (string) - which axes to apply zoom to. Affects scroll, keyboard +/- and double tap. One of `X` (zoom along the X axis only), `Y` (zoom along the Y axis only), `all`. Default `all`. If this option is set to `X` or `Y`, `viewState.zoom` must be an array to enable independent zoom for each axis. - `maxBounds` - constrains the target position within the specified bounding box `[[minX, minY], [maxX, maxY]]` -- `rubberBand` (boolean) - allows panning to temporarily overshoot `maxBounds` with increasing resistance, then animates the viewport back within bounds on release. Requires `maxBounds`. Uses the configured `inertia` duration, or a 300 ms default, for the return transition. Default `false`. +- `maxBoundsRubberBand` (boolean) - allows panning to temporarily overshoot `maxBounds` with increasing resistance, then animates the viewport back within bounds on release. Requires `maxBounds` and does not relax zoom constraints. Uses the configured `inertia` duration, or a 300 ms default, for the return transition. Default `false`. ## Custom OrthographicController diff --git a/examples/website/orthographic/app.tsx b/examples/website/orthographic/app.tsx index 54dabd70a28..6f80178aa9b 100644 --- a/examples/website/orthographic/app.tsx +++ b/examples/website/orthographic/app.tsx @@ -228,7 +228,7 @@ export default function App({ initialViewState={initialViewState} controller={{ maxBounds: contentBounds, - rubberBand: true + maxBoundsRubberBand: true }} layers={layers} layerFilter={layerFilter} diff --git a/modules/core/src/controllers/controller.ts b/modules/core/src/controllers/controller.ts index 23fae5e2875..dc858fc9d72 100644 --- a/modules/core/src/controllers/controller.ts +++ b/modules/core/src/controllers/controller.ts @@ -92,8 +92,11 @@ export type ControllerOptions = { | [min: [number, number], max: [number, number]] | [min: [number, number, number], max: [number, number, number]] | null; - /** Allow orthographic panning to temporarily overshoot `maxBounds`. Default `false`. */ - rubberBand?: boolean; + /** + * Allows orthographic panning to temporarily exceed `maxBounds` and spring back. + * @default false + */ + maxBoundsRubberBand?: boolean; }; export type ControllerProps = { diff --git a/modules/core/src/controllers/orthographic-controller.ts b/modules/core/src/controllers/orthographic-controller.ts index 21d347f92d9..cba4f308314 100644 --- a/modules/core/src/controllers/orthographic-controller.ts +++ b/modules/core/src/controllers/orthographic-controller.ts @@ -10,9 +10,14 @@ import type Viewport from '../viewports/viewport'; import LinearInterpolator from '../transitions/linear-interpolator'; import type {MjolnirGestureEvent} from 'mjolnir.js'; -// Only gesture and transition targets may temporarily exceed maxBounds. -const RUBBER_BAND_TARGETS = new WeakSet(); +/** Marks temporary gesture and transition props without exposing them in view state. */ +const MAX_BOUNDS_RUBBER_BAND_PHASE = Symbol('maxBoundsRubberBandPhase'); +type MaxBoundsRubberBandPhase = { + [MAX_BOUNDS_RUBBER_BAND_PHASE]?: 'drag' | 'transition'; +}; + +/** Returns an overscrolled target through a quadratic Bézier curve. */ class RubberBandInterpolator extends LinearInterpolator { private target: number[]; @@ -21,12 +26,22 @@ class RubberBandInterpolator extends LinearInterpolator { this.target = target; } + /** Allows a zero-duration gesture to interrupt an in-progress return. */ + override arePropsEqual( + currentProps: Record, + nextProps: Record + ): boolean { + return currentProps.transitionDuration !== 0 && super.arePropsEqual(currentProps, nextProps); + } + + /** Preserves temporary overshoot until the final, bounded transition frame. */ override interpolateProps( startProps: Record, endProps: Record, t: number ): Record { - const props = super.interpolateProps(startProps, endProps, t); + const props = super.interpolateProps(startProps, endProps, t) as Record & + MaxBoundsRubberBandPhase; if (t < 1) { props.target = this.target.map( (value: number, index: number) => @@ -34,7 +49,7 @@ class RubberBandInterpolator extends LinearInterpolator { 2 * (1 - t) * t * value + t * t * (endProps.target[index] ?? value) ); - RUBBER_BAND_TARGETS.add(props.target); + props[MAX_BOUNDS_RUBBER_BAND_PHASE] = 'transition'; } return props; } @@ -56,8 +71,8 @@ export type OrthographicStateProps = { minZoomY?: number; maxBounds?: ControllerProps['maxBounds']; - /** Enables resisted, spring-backed panning when `maxBounds` is set. */ - rubberBand?: boolean; + /** Enables spring-backed panning only with `maxBounds`. Defaults to `false`. */ + maxBoundsRubberBand?: boolean; }; type OrthographicStateInternal = { @@ -113,7 +128,7 @@ export class OrthographicState extends ViewState< maxZoomY = maxZoom, maxBounds = null, - rubberBand = false, + maxBoundsRubberBand = false, /** Interaction states, required to calculate change during transform */ // Model state when the pan operation first started @@ -123,6 +138,8 @@ export class OrthographicState extends ViewState< startZoom } = options; + const {[MAX_BOUNDS_RUBBER_BAND_PHASE]: maxBoundsRubberBandPhase} = + options as OrthographicStateProps & MaxBoundsRubberBandPhase; const {zoomX, zoomY} = normalizeZoom(options); super( @@ -139,7 +156,11 @@ export class OrthographicState extends ViewState< minZoomY, maxZoomY, maxBounds, - rubberBand + maxBoundsRubberBand, + ...{ + [MAX_BOUNDS_RUBBER_BAND_PHASE]: + maxBoundsRubberBandPhase ?? (startPanPosition ? 'transition' : undefined) + } }, { startPanPosition, @@ -173,30 +194,6 @@ export class OrthographicState extends ViewState< const viewport = this.makeViewport(this.getViewportProps()); const newProps = viewport.panByPosition(startPanPosition, pos); - const {maxBounds, rubberBand, width, height, zoomX, zoomY} = this.getViewportProps(); - - if (rubberBand && maxBounds && this.getState().startPanPosition) { - const halfWidth = width / 2 / 2 ** zoomX; - const halfHeight = height / 2 / 2 ** zoomY; - newProps.target = newProps.target.slice(); - - for (const [index, halfSize] of [halfWidth, halfHeight].entries()) { - const minimum = maxBounds[0][index] + halfSize; - const maximum = maxBounds[1][index] - halfSize; - if (!Number.isFinite(halfSize) || minimum > maximum) { - newProps.target[index] = (maxBounds[0][index] + maxBounds[1][index]) / 2; - continue; - } - const constrained = clamp(newProps.target[index], minimum, maximum); - const overshoot = newProps.target[index] - constrained; - if (overshoot) { - newProps.target[index] = - constrained + (overshoot * halfSize) / (halfSize + Math.abs(overshoot)); - } - } - - RUBBER_BAND_TARGETS.add(newProps.target); - } return this._getUpdatedState(newProps); } @@ -206,10 +203,8 @@ export class OrthographicState extends ViewState< * Must call if `panStart()` was called */ panEnd(): OrthographicState { - const {target} = this.getViewportProps(); return this._getUpdatedState({ - startPanPosition: null, - ...(RUBBER_BAND_TARGETS.has(target) && {target: target.slice()}) + startPanPosition: null }); } @@ -402,12 +397,18 @@ export class OrthographicState extends ViewState< makeViewport: this.makeViewport, ...this.getViewportProps(), ...this.getState(), - ...newProps + ...newProps, + [MAX_BOUNDS_RUBBER_BAND_PHASE]: + this.getState().startPanPosition && newProps.target ? 'drag' : undefined }); } // Apply any constraints (mathematical or defined by _viewportProps) to map state applyConstraints(props: Required): Required { + const internalProps = props as typeof props & MaxBoundsRubberBandPhase; + const maxBoundsRubberBandPhase = internalProps[MAX_BOUNDS_RUBBER_BAND_PHASE]; + delete internalProps[MAX_BOUNDS_RUBBER_BAND_PHASE]; + // Ensure zoom is within specified range const {zoomX, zoomY} = this._constrainZoom(props, props); props.zoomX = zoomX; @@ -419,36 +420,35 @@ export class OrthographicState extends ViewState< ? [props.zoomX, props.zoomY] : props.zoomX; - const {maxBounds, target} = props; + const {maxBounds, maxBoundsRubberBand, target} = props; if (maxBounds) { // only calculate center and zoom ranges at rotation=0 // to maintain visual stability when rotating const halfWidth = props.width / 2 / 2 ** zoomX; const halfHeight = props.height / 2 / 2 ** zoomY; - const minX = maxBounds[0][0] + halfWidth; - const maxX = maxBounds[1][0] - halfWidth; - const minY = maxBounds[0][1] + halfHeight; - const maxY = maxBounds[1][1] - halfHeight; - const preserveRubberBandTarget = Boolean(props.rubberBand && RUBBER_BAND_TARGETS.has(target)); - const x = - minX > maxX && props.rubberBand - ? (maxBounds[0][0] + maxBounds[1][0]) / 2 - : preserveRubberBandTarget - ? target[0] - : clamp(target[0], minX, maxX); - const y = - minY > maxY && props.rubberBand - ? (maxBounds[0][1] + maxBounds[1][1]) / 2 - : preserveRubberBandTarget - ? target[1] - : clamp(target[1], minY, maxY); - if (x !== target[0] || y !== target[1]) { - props.target = target.slice(); - props.target[0] = x; - props.target[1] = y; - if (preserveRubberBandTarget && (minX <= maxX || minY <= maxY)) { - RUBBER_BAND_TARGETS.add(props.target); + const constrainedTarget = target.slice(); + + for (const [index, halfSize] of [halfWidth, halfHeight].entries()) { + const minimum = maxBounds[0][index] + halfSize; + const maximum = maxBounds[1][index] - halfSize; + + if (maxBoundsRubberBand && (!Number.isFinite(halfSize) || minimum > maximum)) { + constrainedTarget[index] = (maxBounds[0][index] + maxBounds[1][index]) / 2; + continue; } + + const constrained = clamp(target[index], minimum, maximum); + const overshoot = target[index] - constrained; + constrainedTarget[index] = + maxBoundsRubberBand && maxBoundsRubberBandPhase === 'transition' + ? target[index] + : maxBoundsRubberBand && maxBoundsRubberBandPhase === 'drag' && overshoot + ? constrained + (overshoot * halfSize) / (halfSize + Math.abs(overshoot)) + : constrained; + } + + if (constrainedTarget[0] !== target[0] || constrainedTarget[1] !== target[1]) { + props.target = constrainedTarget; } } return props; @@ -514,21 +514,29 @@ export default class OrthographicController extends Controller value === constrainedTarget[index]) && + currentTarget.every((value, index) => value === currentConstrainedTarget[index]) + ) { + return super._onPanMoveEnd(event); } this.updateViewport( @@ -569,17 +574,7 @@ export default class OrthographicController extends Controller { - for (const rubberBand of [undefined, false]) { - const controller = createTestController({ - view: new OrthographicView({ - controller: { - maxBounds: ORTHOGRAPHIC_MAX_BOUNDS, - rubberBand - } - }), - initialViewState: {target: [100, 100, 0], zoom: 0} - }); +type RubberBandControllerOptions = Omit[0], 'view'> & { + controller?: Partial; +}; + +/** Creates a bounded orthographic controller with elasticity enabled by default. */ +function createRubberBandController({ + controller: controllerOptions, + initialViewState, + ...options +}: RubberBandControllerOptions = {}) { + return createTestController({ + ...options, + view: new OrthographicView({ + controller: { + maxBounds: ORTHOGRAPHIC_MAX_BOUNDS, + maxBoundsRubberBand: true, + ...controllerOptions + } + }), + initialViewState: {target: [100, 100, 0], zoom: 0, ...initialViewState} + }); +} + +/** Replays a pointer or two-finger pan through deck.gl's normal gesture handlers. */ +function panRubberBand( + controller: ReturnType, + position: Parameters[1] = {x: 200}, + gesture: 'pan' | 'multipan' = 'pan' +) { + controller.handleEvent(makeGestureEvent(`${gesture}start`) as any); + return controller.handleEvent(makeGestureEvent(`${gesture}move`, position) as any); +} + +/** Advances a spring-back deterministically on the controller's own timeline. */ +function advanceRubberBandTransition( + controller: ReturnType, + duration: number +) { + const timeline: Timeline = controller.transitionManager.transition._timeline; + timeline.setTime(timeline.getTime() + duration); + controller.updateTransition(); +} - controller.handleEvent(makeGestureEvent('panstart') as any); - controller.handleEvent(makeGestureEvent('panmove', {x: 200}) as any); +/** Checks that both gesture and transition interaction flags are cleared. */ +function expectRubberBandInteractionEnded(interactionStates: any[]) { + expect( + interactionStates[interactionStates.length - 1], + 'interaction state is cleared' + ).toMatchObject({ + inTransition: false, + isDragging: false, + isPanning: false + }); +} +test('OrthographicController keeps maxBounds hard by default', () => { + for (const maxBoundsRubberBand of [undefined, false]) { + const controller = createRubberBandController({controller: {maxBoundsRubberBand}}); + panRubberBand(controller); expect(controller.props.target, 'panning stops at the visible bounds').toEqual([50, 100]); controller.finalize(); } }); +test('OrthographicController keeps non-gesture bounds hard with rubber-banding enabled', () => { + const controller = createRubberBandController({ + controller: {keyboard: {moveSpeed: 150}}, + initialViewState: {target: [-100, 100, 0]}, + onViewStateChange: ({viewState}) => ({...viewState, transitionDuration: 0}) + }); + expect(controller.props.target, 'programmatic view state is hard-clamped').toEqual([50, 100, 0]); + controller.handleEvent({ + type: 'keydown', + srcEvent: {code: 'ArrowRight', preventDefault() {}}, + stopPropagation() {} + } as any); + expect(controller.props.target[0], 'keyboard navigation is hard-clamped').toBe(50); + controller.finalize(); + + const disabledController = createRubberBandController({controller: {dragPan: false}}); + const handled = panRubberBand(disabledController); + expect(handled, 'disabled panning ignores movement').toBe(false); + expect(disabledController.props.target, 'disabled panning does not overscroll').toEqual([ + 100, 100, 0 + ]); + expect( + disabledController.transitionManager.transition.inProgress, + 'disabled panning does not start a spring-back' + ).toBe(false); + disabledController.finalize(); +}); + test('OrthographicController applies resistance beyond every edge', () => { for (const {x, y, axes} of [ {x: 200, y: 50, axes: [0]}, @@ -356,19 +429,8 @@ test('OrthographicController applies resistance beyond every edge', () => { {x: 50, y: -100, axes: [1]}, {x: 200, y: 200, axes: [0, 1]} ]) { - const controller = createTestController({ - view: new OrthographicView({ - controller: { - maxBounds: ORTHOGRAPHIC_MAX_BOUNDS, - rubberBand: true - } - }), - initialViewState: {target: [100, 100, 0], zoom: 0} - }); - - controller.handleEvent(makeGestureEvent('panstart') as any); - controller.handleEvent(makeGestureEvent('panmove', {x, y}) as any); - + const controller = createRubberBandController(); + panRubberBand(controller, {x, y}); for (const axis of axes) { const displacement = Math.abs(controller.props.target[axis] - 100); expect(Number.isFinite(displacement), 'overscroll stays finite').toBe(true); @@ -388,9 +450,7 @@ test.each([ [0, 10], [200, 11] ] as [[number, number], [number, number]], - target: [100, 10.5, 0], - maxZoomX: 6, - maxZoomY: 2, + initialViewState: {target: [100, 10.5, 0], zoomAxis: 'X', maxZoomX: 6, maxZoomY: 2}, position: {x: 200, y: 50}, elasticAxis: 0, centeredAxis: 1 @@ -401,76 +461,31 @@ test.each([ [10, 0], [11, 200] ] as [[number, number], [number, number]], - target: [10.5, 100, 0], - maxZoomX: 2, - maxZoomY: 6, + initialViewState: {target: [10.5, 100, 0], zoomAxis: 'X', maxZoomX: 2, maxZoomY: 6}, position: {x: 50, y: 200}, elasticAxis: 1, centeredAxis: 0 } ])('OrthographicController preserves rubber-band panning when $description', testCase => { + const {maxBounds, initialViewState, position, elasticAxis, centeredAxis} = testCase; const interactionStates: any[] = []; - const controller = createTestController({ - view: new OrthographicView({ - controller: { - maxBounds: testCase.maxBounds, - rubberBand: true - } - }), - initialViewState: { - target: testCase.target, - zoom: 0, - zoomAxis: 'X', - maxZoomX: testCase.maxZoomX, - maxZoomY: testCase.maxZoomY - }, + const controller = createRubberBandController({ + controller: {maxBounds}, + initialViewState, onStateChange: state => interactionStates.push({...state}) }); - - expect(controller.props.target[testCase.centeredAxis], 'the non-fitting axis is centered').toBe( - 10.5 - ); - - controller.handleEvent(makeGestureEvent('panstart') as any); - controller.handleEvent(makeGestureEvent('panmove', testCase.position) as any); - - expect( - controller.props.target[testCase.centeredAxis], - 'panning keeps the other axis centered' - ).toBe(10.5); - expect(controller.props.target[testCase.elasticAxis], 'the fitting axis overshoots').toBeLessThan( - 50 - ); - expect( - controller.props.target[testCase.elasticAxis], - 'the overshoot is resisted' - ).toBeGreaterThan(-50); - - controller.handleEvent(makeGestureEvent('panend', testCase.position) as any); - + expect(controller.props.target[centeredAxis], 'the non-fitting axis is centered').toBe(10.5); + panRubberBand(controller, position); + expect(controller.props.target[centeredAxis], 'panning keeps the other axis centered').toBe(10.5); + expect(controller.props.target[elasticAxis], 'the fitting axis overshoots').toBeLessThan(50); + expect(controller.props.target[elasticAxis], 'the overshoot is resisted').toBeGreaterThan(-50); + controller.handleEvent(makeGestureEvent('panend', position) as any); const transition = controller.transitionManager.transition; expect(transition.inProgress, 'the fitting axis starts a spring-back transition').toBe(true); - - const timeline: Timeline = transition._timeline; - timeline.setTime(timeline.getTime() + transition.settings.duration); - controller.updateTransition(); - - expect( - controller.props.target[testCase.elasticAxis], - 'the fitting axis settles at its edge' - ).toBe(50); - expect( - controller.props.target[testCase.centeredAxis], - 'the non-fitting axis stays centered' - ).toBe(10.5); - expect( - interactionStates[interactionStates.length - 1], - 'interaction state is cleared' - ).toMatchObject({ - inTransition: false, - isDragging: false, - isPanning: false - }); + advanceRubberBandTransition(controller, transition.settings.duration); + expect(controller.props.target[elasticAxis], 'the fitting axis settles at its edge').toBe(50); + expect(controller.props.target[centeredAxis], 'the non-fitting axis stays centered').toBe(10.5); + expectRubberBandInteractionEnded(interactionStates); controller.finalize(); }); @@ -480,20 +495,11 @@ test('OrthographicController springs overscroll back within maxBounds', () => { {inertia: 450, velocity: 1} ]) { const interactionStates: any[] = []; - const controller = createTestController({ - view: new OrthographicView({ - controller: { - maxBounds: ORTHOGRAPHIC_MAX_BOUNDS, - rubberBand: true, - inertia - } - }), - initialViewState: {target: [100, 100, 0], zoom: 0}, + const controller = createRubberBandController({ + controller: {inertia}, onStateChange: state => interactionStates.push({...state}) }); - - controller.handleEvent(makeGestureEvent('panstart') as any); - controller.handleEvent(makeGestureEvent('panmove', {x: 200}) as any); + panRubberBand(controller); const draggedTarget = controller.props.target[0]; controller.handleEvent({ ...makeGestureEvent('panend', {x: 200}), @@ -501,122 +507,70 @@ test('OrthographicController springs overscroll back within maxBounds', () => { velocityX: velocity, velocityY: 0 } as any); - const transition = controller.transitionManager.transition; expect(transition.inProgress, 'release starts a spring-back transition').toBe(true); expect(transition.settings.duration, 'spring-back uses the configured inertia').toBe( inertia ?? 300 ); - - const timeline: Timeline = transition._timeline; - timeline.setTime(timeline.getTime() + transition.settings.duration / 4); - controller.updateTransition(); + advanceRubberBandTransition(controller, transition.settings.duration / 4); if (velocity) { expect(controller.props.target[0], 'a fling increases the initial overshoot').toBeLessThan( draggedTarget ); } - - timeline.setTime(timeline.getTime() + (transition.settings.duration * 3) / 4); - controller.updateTransition(); - + advanceRubberBandTransition(controller, (transition.settings.duration * 3) / 4); expect(controller.props.target, 'spring-back finishes exactly at the edge').toEqual([50, 100]); expect(transition.inProgress, 'the transition finishes').toBe(false); - expect( - interactionStates[interactionStates.length - 1], - 'interaction state is cleared' - ).toMatchObject({ - inTransition: false, - isDragging: false, - isPanning: false - }); + expectRubberBandInteractionEnded(interactionStates); controller.finalize(); } }); test('OrthographicController preserves overscroll during an inward fling', () => { const interactionStates: any[] = []; - const controller = createTestController({ - view: new OrthographicView({ - controller: { - maxBounds: ORTHOGRAPHIC_MAX_BOUNDS, - rubberBand: true, - inertia: 300 - } - }), - initialViewState: {target: [100, 100, 0], zoom: 0}, + const controller = createRubberBandController({ + controller: {inertia: 300}, onStateChange: state => interactionStates.push({...state}) }); - - controller.handleEvent(makeGestureEvent('panstart') as any); - controller.handleEvent(makeGestureEvent('panmove', {x: 200}) as any); - + panRubberBand(controller); const draggedTarget = controller.props.target[0]; expect(draggedTarget, 'the drag temporarily exceeds the visible edge').toBeLessThan(50); - controller.handleEvent({ ...makeGestureEvent('panend', {x: 200}), velocity: 1, velocityX: -1, velocityY: 0 } as any); - const transition = controller.transitionManager.transition; expect(transition.inProgress, 'the inward release starts a rubber-band transition').toBe(true); expect( controller.props.target[0], 'the first transition frame does not snap to the edge' ).toBeCloseTo(draggedTarget); - - const timeline: Timeline = transition._timeline; - timeline.setTime(timeline.getTime() + 20); - controller.updateTransition(); - + advanceRubberBandTransition(controller, 20); expect(controller.props.target[0], 'the spring moves inward').toBeGreaterThan(draggedTarget); expect( controller.props.target[0], 'the early transition frame remains overscrolled' ).toBeLessThan(50); - - timeline.setTime(timeline.getTime() + 280); - controller.updateTransition(); - + advanceRubberBandTransition(controller, 280); expect(controller.props.target, 'the fling settles at the projected in-bounds target').toEqual([ 100, 100 ]); expect(transition.inProgress, 'the transition finishes').toBe(false); - expect( - interactionStates[interactionStates.length - 1], - 'interaction state is cleared' - ).toMatchObject({ - inTransition: false, - isDragging: false, - isPanning: false - }); + expectRubberBandInteractionEnded(interactionStates); controller.finalize(); }); test('OrthographicController preserves native inertia for in-bounds flings', () => { - const controller = createTestController({ - view: new OrthographicView({ - controller: { - maxBounds: ORTHOGRAPHIC_MAX_BOUNDS, - rubberBand: true, - inertia: 300 - } - }), - initialViewState: {target: [100, 100, 0], zoom: 0} - }); - - controller.handleEvent(makeGestureEvent('panstart') as any); - controller.handleEvent(makeGestureEvent('panmove', {x: 60}) as any); + const controller = createRubberBandController({controller: {inertia: 300}}); + panRubberBand(controller, {x: 60}); controller.handleEvent({ ...makeGestureEvent('panend', {x: 60}), velocity: 1, velocityX: 0.1, velocityY: 0 } as any); - const transition = controller.transitionManager.transition; expect(transition.inProgress, 'the in-bounds fling starts native inertia').toBe(true); expect( @@ -626,65 +580,84 @@ test('OrthographicController preserves native inertia for in-bounds flings', () controller.finalize(); }); -test('OrthographicController interrupts rubber-band spring-back with a new pan', () => { +test.each([ + ['pointer pan', 'pan'], + ['multi-touch pan', 'multipan'] +])('OrthographicController interrupts spring-back with a new %s', (_description, gesture) => { const interactionStates: any[] = []; - const controller = createTestController({ - view: new OrthographicView({ - controller: { - maxBounds: ORTHOGRAPHIC_MAX_BOUNDS, - rubberBand: true - } - }), - initialViewState: {target: [100, 100, 0], zoom: 0}, + const controller = createRubberBandController({ + controller: {multiTouchDrag: 'pan'}, onStateChange: state => interactionStates.push({...state}) }); - - controller.handleEvent(makeGestureEvent('panstart') as any); - controller.handleEvent(makeGestureEvent('panmove', {x: 200}) as any); + panRubberBand(controller); controller.handleEvent(makeGestureEvent('panend', {x: 200}) as any); - expect(controller.transitionManager.transition.inProgress).toBe(true); - - controller.handleEvent(makeGestureEvent('panstart') as any); - - expect(controller.transitionManager.transition.inProgress).toBe(false); - expect(interactionStates[interactionStates.length - 1]).toMatchObject({ + const transition = controller.transitionManager.transition; + expect(transition.inProgress, 'release starts a spring-back transition').toBe(true); + advanceRubberBandTransition(controller, transition.settings.duration / 4); + const targetBeforeInterruption = controller.props.target.slice(); + expect(targetBeforeInterruption[0], 'the return remains overscrolled').toBeLessThan(50); + controller.handleEvent(makeGestureEvent(`${gesture}start`) as any); + expect(transition.inProgress, 'the new gesture interrupts the return').toBe(false); + expect(controller.props.target, 'the new gesture does not jump to the boundary').toEqual( + targetBeforeInterruption + ); + expect(interactionStates[interactionStates.length - 1], 'the new drag is active').toMatchObject({ inTransition: false, isDragging: true, isPanning: false }); + const position = {x: 60, deltaX: 10}; + controller.handleEvent(makeGestureEvent(`${gesture}move`, position) as any); + controller.handleEvent(makeGestureEvent(`${gesture}end`, position) as any); + if (transition.inProgress) { + advanceRubberBandTransition(controller, transition.settings.duration); + } + expectRubberBandInteractionEnded(interactionStates); controller.finalize(); }); test('OrthographicController applies rubber-band resistance to multi-touch panning', () => { - const controller = createTestController({ - view: new OrthographicView({ - controller: { - maxBounds: ORTHOGRAPHIC_MAX_BOUNDS, - rubberBand: true, - multiTouchDrag: 'pan' - } - }), - initialViewState: {target: [100, 100, 0], zoom: 0} - }); - - controller.handleEvent(makeGestureEvent('multipanstart') as any); - controller.handleEvent(makeGestureEvent('multipanmove', {x: 200, deltaX: 150}) as any); - + const controller = createRubberBandController({controller: {multiTouchDrag: 'pan'}}); + panRubberBand(controller, {x: 200, deltaX: 150}, 'multipan'); expect(controller.props.target[0], 'multi-touch panning overshoots the edge').toBeLessThan(50); expect(controller.props.target[0], 'multi-touch overscroll is resisted').toBeGreaterThan(-50); controller.finalize(); }); -test('OrthographicController ignores rubberBand without maxBounds', () => { - for (const rubberBand of [false, true]) { - const controller = createTestController({ - view: new OrthographicView({controller: {rubberBand}}), - initialViewState: {target: [100, 100, 0], zoom: 0} - }); - - controller.handleEvent(makeGestureEvent('panstart') as any); - controller.handleEvent(makeGestureEvent('panmove', {x: 200}) as any); +test('OrthographicController does not expose rubber-band interaction metadata', () => { + const viewStates: Record[] = []; + const controller = createRubberBandController({ + onViewStateChange: ({viewState}) => { + viewStates.push(viewState); + } + }); + panRubberBand(controller); + controller.handleEvent(makeGestureEvent('panend', {x: 200}) as any); + const transition = controller.transitionManager.transition; + expect(transition.inProgress, 'release starts a spring-back transition').toBe(true); + advanceRubberBandTransition(controller, transition.settings.duration / 2); + advanceRubberBandTransition(controller, transition.settings.duration / 2); + expect(viewStates.length, 'drag and transition updates are emitted').toBeGreaterThan(3); + for (const viewState of viewStates) { + expect(Object.getOwnPropertySymbols(viewState), 'view state has no private symbols').toEqual( + [] + ); + expect(Object.getOwnPropertySymbols(viewState.target), 'target has no private symbols').toEqual( + [] + ); + expect(JSON.parse(JSON.stringify(viewState)).target, 'view state remains serializable').toEqual( + viewState.target + ); + } + controller.finalize(); +}); +test('OrthographicController ignores maxBoundsRubberBand without maxBounds', () => { + for (const maxBoundsRubberBand of [false, true]) { + const controller = createRubberBandController({ + controller: {maxBounds: null, maxBoundsRubberBand} + }); + panRubberBand(controller); expect(controller.props.target, 'unbounded panning is unchanged').toEqual([-50, 100]); controller.finalize(); } From ed0ca0398c3cc110dae34df810d04beaeceaeef8 Mon Sep 17 00:00:00 2001 From: Ib Green Date: Mon, 27 Jul 2026 12:06:05 -0400 Subject: [PATCH 4/6] fix(core): improve orthographic bounds rubber-band rebound --- .../core/orthographic-controller.md | 2 +- .../controllers/orthographic-controller.ts | 86 ++++---- .../core/controllers/controllers.spec.ts | 194 +++++++++++++++--- 3 files changed, 211 insertions(+), 71 deletions(-) diff --git a/docs/api-reference/core/orthographic-controller.md b/docs/api-reference/core/orthographic-controller.md index 37843e6453a..fbbc49a939d 100644 --- a/docs/api-reference/core/orthographic-controller.md +++ b/docs/api-reference/core/orthographic-controller.md @@ -47,7 +47,7 @@ Also accepts additional options: - `zoomAxis` (string) - which axes to apply zoom to. Affects scroll, keyboard +/- and double tap. One of `X` (zoom along the X axis only), `Y` (zoom along the Y axis only), `all`. Default `all`. If this option is set to `X` or `Y`, `viewState.zoom` must be an array to enable independent zoom for each axis. - `maxBounds` - constrains the target position within the specified bounding box `[[minX, minY], [maxX, maxY]]` -- `maxBoundsRubberBand` (boolean) - allows panning to temporarily overshoot `maxBounds` with increasing resistance, then animates the viewport back within bounds on release. Requires `maxBounds` and does not relax zoom constraints. Uses the configured `inertia` duration, or a 300 ms default, for the return transition. Default `false`. +- `maxBoundsRubberBand` (boolean) - allows panning to temporarily overshoot `maxBounds` with increasing resistance, including when the complete content already fits in the viewport, then returns directly within bounds on release. The return uses a 300 ms ease-out independently of `inertia`, so genuine in-bounds flings retain their configured duration. Requires `maxBounds` and does not relax zoom constraints. Default `false`. ## Custom OrthographicController diff --git a/modules/core/src/controllers/orthographic-controller.ts b/modules/core/src/controllers/orthographic-controller.ts index cba4f308314..b1ad412547a 100644 --- a/modules/core/src/controllers/orthographic-controller.ts +++ b/modules/core/src/controllers/orthographic-controller.ts @@ -12,18 +12,17 @@ import type {MjolnirGestureEvent} from 'mjolnir.js'; /** Marks temporary gesture and transition props without exposing them in view state. */ const MAX_BOUNDS_RUBBER_BAND_PHASE = Symbol('maxBoundsRubberBandPhase'); +/** Minimum dominant-axis release speed required to classify a pan as a flick, in pixels/ms. */ +const MAX_BOUNDS_RUBBER_BAND_MIN_FLING_VELOCITY = 0.3; type MaxBoundsRubberBandPhase = { [MAX_BOUNDS_RUBBER_BAND_PHASE]?: 'drag' | 'transition'; }; -/** Returns an overscrolled target through a quadratic Bézier curve. */ +/** Preserves temporary overscroll during a direct, monotonic return to the content bounds. */ class RubberBandInterpolator extends LinearInterpolator { - private target: number[]; - - constructor(target: number[]) { + constructor() { super(['target', 'zoomX', 'zoomY']); - this.target = target; } /** Allows a zero-duration gesture to interrupt an in-progress return. */ @@ -43,12 +42,6 @@ class RubberBandInterpolator extends LinearInterpolator { const props = super.interpolateProps(startProps, endProps, t) as Record & MaxBoundsRubberBandPhase; if (t < 1) { - props.target = this.target.map( - (value: number, index: number) => - (1 - t) * (1 - t) * (startProps.target[index] ?? value) + - 2 * (1 - t) * t * value + - t * t * (endProps.target[index] ?? value) - ); props[MAX_BOUNDS_RUBBER_BAND_PHASE] = 'transition'; } return props; @@ -431,13 +424,17 @@ export class OrthographicState extends ViewState< for (const [index, halfSize] of [halfWidth, halfHeight].entries()) { const minimum = maxBounds[0][index] + halfSize; const maximum = maxBounds[1][index] - halfSize; + const midpoint = (maxBounds[0][index] + maxBounds[1][index]) / 2; - if (maxBoundsRubberBand && (!Number.isFinite(halfSize) || minimum > maximum)) { - constrainedTarget[index] = (maxBounds[0][index] + maxBounds[1][index]) / 2; + if (maxBoundsRubberBand && !Number.isFinite(halfSize)) { + constrainedTarget[index] = midpoint; continue; } - const constrained = clamp(target[index], minimum, maximum); + const constrained = + maxBoundsRubberBand && minimum > maximum + ? midpoint + : clamp(target[index], minimum, maximum); const overshoot = target[index] - constrained; constrainedTarget[index] = maxBoundsRubberBand && maxBoundsRubberBandPhase === 'transition' @@ -533,44 +530,41 @@ export default class OrthographicController extends Controller value !== constrainedTarget[index]); + + if (isOverscrolled) { + this.updateViewport( + constrainedState, + { + ...this._getTransitionProps(), + transitionDuration: this.transition.transitionDuration, + transitionEasing: time => 1 - (1 - time) * (1 - time), + transitionInterpolator: new RubberBandInterpolator() + }, + {isDragging: false, isPanning: true} + ); + return true; } - const constrainedState = overshotState.panEnd(); - const overshotTarget = overshotState.getViewportProps().target; - const constrainedTarget = constrainedState.getViewportProps().target; - const currentTarget = currentState.getViewportProps().target; - const currentConstrainedTarget = currentState.panEnd().getViewportProps().target; + const velocityX = Number.isFinite(event.velocityX) ? event.velocityX : 0; + const velocityY = Number.isFinite(event.velocityY) ? event.velocityY : 0; + const isIntentionalFling = + Math.max(Math.abs(velocityX), Math.abs(velocityY)) > + MAX_BOUNDS_RUBBER_BAND_MIN_FLING_VELOCITY; - if ( - overshotTarget.every((value, index) => value === constrainedTarget[index]) && - currentTarget.every((value, index) => value === currentConstrainedTarget[index]) - ) { - return super._onPanMoveEnd(event); + if (!isIntentionalFling && event.velocity) { + this.updateViewport(constrainedState, null, { + isDragging: false, + isPanning: false + }); + return true; } - this.updateViewport( - constrainedState, - { - ...this._getTransitionProps(), - transitionDuration: duration, - transitionInterpolator: new RubberBandInterpolator(overshotTarget) - }, - {isDragging: false, isPanning: true} - ); - return true; + return super._onPanMoveEnd(event); } protected _onMultiPanStart(event: MjolnirGestureEvent): boolean { diff --git a/test/modules/core/controllers/controllers.spec.ts b/test/modules/core/controllers/controllers.spec.ts index 10fa5677cfd..9a99faceed5 100644 --- a/test/modules/core/controllers/controllers.spec.ts +++ b/test/modules/core/controllers/controllers.spec.ts @@ -443,6 +443,80 @@ test('OrthographicController applies resistance beyond every edge', () => { } }); +test.each( + [ + {description: 'exact fit', width: 100, span: 25, zoom: 2, maxZoom: 6}, + { + description: 'floating-point-inverted fit', + width: 1024, + span: 5, + zoom: Math.log2(1024 / 5), + maxZoom: 12 + }, + {description: 'content smaller than the viewport', width: 100, span: 25, zoom: 1, maxZoom: 1} + ].flatMap(viewport => + [ + {direction: 'left', offsetX: viewport.width, offsetY: 0, axes: [0]}, + {direction: 'right', offsetX: -viewport.width, offsetY: 0, axes: [0]}, + {direction: 'top', offsetX: 0, offsetY: viewport.width, axes: [1]}, + {direction: 'bottom', offsetX: 0, offsetY: -viewport.width, axes: [1]}, + {direction: 'diagonal', offsetX: viewport.width, offsetY: viewport.width, axes: [0, 1]} + ].map(direction => ({...viewport, ...direction})) + ) +)( + 'OrthographicController rubber-bands $description toward the $direction', + ({width, span, zoom, maxZoom, offsetX, offsetY, axes}) => { + const center = span / 2; + const maxBounds: [[number, number], [number, number]] = [ + [0, 0], + [span, span] + ]; + const interactionStates: any[] = []; + const controller = createRubberBandController({ + controller: {maxBounds, inertia: 900}, + initialViewState: { + width, + height: width, + target: [center, center, 0], + zoom, + maxZoomX: maxZoom, + maxZoomY: maxZoom + }, + onStateChange: state => interactionStates.push({...state}) + }); + const startPosition = {x: width / 2, y: width / 2}; + const endPosition = { + x: startPosition.x + offsetX, + y: startPosition.y + offsetY + }; + + controller.handleEvent(makeGestureEvent('panstart', startPosition) as any); + controller.handleEvent(makeGestureEvent('panmove', endPosition) as any); + + for (const axis of axes) { + const displacement = Math.abs(controller.props.target[axis] - center); + expect(displacement, 'a centered axis visibly overscrolls').toBeGreaterThan(0); + expect(displacement, 'the overscroll is resisted').toBeLessThan(span); + } + + controller.handleEvent(makeGestureEvent('panend', endPosition) as any); + const transition = controller.transitionManager.transition; + expect(transition.inProgress, 'release starts one spring-back transition').toBe(true); + expect(transition.settings.duration, 'the spring is independent of fling inertia').toBe(300); + + advanceRubberBandTransition(controller, 300); + expect(controller.props.target[0], 'the horizontal axis settles at its center').toBeCloseTo( + center + ); + expect(controller.props.target[1], 'the vertical axis settles at its center').toBeCloseTo( + center + ); + expect(transition.inProgress, 'the spring finishes').toBe(false); + expectRubberBandInteractionEnded(interactionStates); + controller.finalize(); + } +); + test.each([ { description: 'the vertical bounds cannot fill the viewport', @@ -490,10 +564,7 @@ test.each([ }); test('OrthographicController springs overscroll back within maxBounds', () => { - for (const {inertia, velocity} of [ - {inertia: undefined, velocity: 0}, - {inertia: 450, velocity: 1} - ]) { + for (const inertia of [undefined, false, true, 450, 900]) { const interactionStates: any[] = []; const controller = createRubberBandController({ controller: {inertia}, @@ -501,24 +572,25 @@ test('OrthographicController springs overscroll back within maxBounds', () => { }); panRubberBand(controller); const draggedTarget = controller.props.target[0]; - controller.handleEvent({ - ...makeGestureEvent('panend', {x: 200}), - velocity, - velocityX: velocity, - velocityY: 0 - } as any); + controller.handleEvent(makeGestureEvent('panend', {x: 200}) as any); const transition = controller.transitionManager.transition; expect(transition.inProgress, 'release starts a spring-back transition').toBe(true); - expect(transition.settings.duration, 'spring-back uses the configured inertia').toBe( - inertia ?? 300 - ); - advanceRubberBandTransition(controller, transition.settings.duration / 4); - if (velocity) { - expect(controller.props.target[0], 'a fling increases the initial overshoot').toBeLessThan( - draggedTarget + expect(transition.settings.duration, 'spring-back is independent of fling inertia').toBe(300); + + const initialDistance = Math.abs(draggedTarget - 50); + let previousDistance = initialDistance; + for (const elapsed of [75, 150, 225, 300]) { + advanceRubberBandTransition(controller, 75); + const distance = Math.abs(controller.props.target[0] - 50); + expect(distance, 'the spring moves directly toward the edge').toBeLessThanOrEqual( + previousDistance + ); + expect(distance, 'the spring follows the native quadratic ease-out').toBeCloseTo( + initialDistance * (1 - elapsed / 300) ** 2 ); + previousDistance = distance; } - advanceRubberBandTransition(controller, (transition.settings.duration * 3) / 4); + expect(controller.props.target, 'spring-back finishes exactly at the edge').toEqual([50, 100]); expect(transition.inProgress, 'the transition finishes').toBe(false); expectRubberBandInteractionEnded(interactionStates); @@ -526,7 +598,49 @@ test('OrthographicController springs overscroll back within maxBounds', () => { } }); -test('OrthographicController preserves overscroll during an inward fling', () => { +test.each([ + {description: 'a stationary release', velocity: 0, velocityX: 0, velocityY: 0}, + {description: 'slow horizontal velocity', velocity: 0.05, velocityX: 0.05, velocityY: 0}, + {description: 'slow diagonal velocity', velocity: 0.22, velocityX: 0.22, velocityY: 0.22}, + {description: 'the flick threshold', velocity: 0.3, velocityX: 0.3, velocityY: 0}, + {description: 'a fast outward release', velocity: 1, velocityX: 1, velocityY: 0}, + {description: 'a fast inward release', velocity: 1, velocityX: -1, velocityY: 0} +])( + 'OrthographicController returns overscroll directly after $description', + ({velocity, velocityX, velocityY}) => { + const controller = createRubberBandController({controller: {inertia: 900}}); + panRubberBand(controller); + const draggedTarget = controller.props.target[0]; + const initialDistance = Math.abs(draggedTarget - 50); + + controller.handleEvent({ + ...makeGestureEvent('panend', {x: 200}), + velocity, + velocityX, + velocityY + } as any); + + const transition = controller.transitionManager.transition; + expect(transition.settings.duration, 'an edge release always uses the short spring').toBe(300); + let previousDistance = initialDistance; + + for (const elapsed of [75, 150, 225, 300]) { + advanceRubberBandTransition(controller, 75); + const distance = Math.abs(controller.props.target[0] - 50); + expect(distance, 'release never increases overscroll').toBeLessThanOrEqual(previousDistance); + expect(distance, 'release follows one quadratic ease-out').toBeCloseTo( + initialDistance * (1 - elapsed / 300) ** 2 + ); + previousDistance = distance; + } + + expect(controller.props.target, 'release settles at the bounded edge').toEqual([50, 100]); + expect(transition.inProgress, 'the spring finishes without restarting').toBe(false); + controller.finalize(); + } +); + +test('OrthographicController preserves overscroll during an inward release', () => { const interactionStates: any[] = []; const controller = createRubberBandController({ controller: {inertia: 300}, @@ -554,8 +668,8 @@ test('OrthographicController preserves overscroll during an inward fling', () => 'the early transition frame remains overscrolled' ).toBeLessThan(50); advanceRubberBandTransition(controller, 280); - expect(controller.props.target, 'the fling settles at the projected in-bounds target').toEqual([ - 100, 100 + expect(controller.props.target, 'the release settles at the nearest bounded edge').toEqual([ + 50, 100 ]); expect(transition.inProgress, 'the transition finishes').toBe(false); expectRubberBandInteractionEnded(interactionStates); @@ -563,23 +677,55 @@ test('OrthographicController preserves overscroll during an inward fling', () => }); test('OrthographicController preserves native inertia for in-bounds flings', () => { - const controller = createRubberBandController({controller: {inertia: 300}}); + const controller = createRubberBandController({controller: {inertia: 900}}); panRubberBand(controller, {x: 60}); controller.handleEvent({ ...makeGestureEvent('panend', {x: 60}), - velocity: 1, - velocityX: 0.1, + velocity: 0.4, + velocityX: 0.4, velocityY: 0 } as any); const transition = controller.transitionManager.transition; expect(transition.inProgress, 'the in-bounds fling starts native inertia').toBe(true); + expect(transition.settings.duration, 'the fling keeps its configured inertia').toBe(900); expect( transition.settings.interpolator.constructor.name, 'in-bounds flings retain the native linear interpolator' ).toBe('LinearInterpolator'); + advanceRubberBandTransition(controller, 900); + expect(transition.inProgress, 'the native fling finishes').toBe(false); controller.finalize(); }); +test.each([ + {description: 'slow horizontal velocity', velocity: 0.05, velocityX: 0.05, velocityY: 0}, + {description: 'slow diagonal velocity', velocity: 0.22, velocityX: 0.22, velocityY: 0.22}, + {description: 'velocity at the flick threshold', velocity: 0.3, velocityX: 0.3, velocityY: 0} +])( + 'OrthographicController does not turn $description into an in-bounds fling', + ({velocity, velocityX, velocityY}) => { + const controller = createRubberBandController({controller: {inertia: 900}}); + panRubberBand(controller, {x: 60}); + const draggedTarget = controller.props.target.slice(); + + controller.handleEvent({ + ...makeGestureEvent('panend', {x: 60}), + velocity, + velocityX, + velocityY + } as any); + + expect(controller.props.target, 'the release remains at its dragged position').toEqual( + draggedTarget + ); + expect( + controller.transitionManager.transition.inProgress, + 'a slow release does not start inertia' + ).toBe(false); + controller.finalize(); + } +); + test.each([ ['pointer pan', 'pan'], ['multi-touch pan', 'multipan'] From 47dcf90ad44fc22fa46b519278d509019dfcbf7b Mon Sep 17 00:00:00 2001 From: Ib Green Date: Mon, 27 Jul 2026 14:27:00 -0400 Subject: [PATCH 5/6] fix(core): move rubber-band rebound into controller state --- .../core/orthographic-controller.md | 2 +- modules/core/src/controllers/controller.ts | 17 +- .../controllers/orthographic-controller.ts | 214 +++++++++++------- .../core/controllers/controllers.spec.ts | 181 ++++++++++++--- 4 files changed, 300 insertions(+), 114 deletions(-) diff --git a/docs/api-reference/core/orthographic-controller.md b/docs/api-reference/core/orthographic-controller.md index fbbc49a939d..8df1c520cd5 100644 --- a/docs/api-reference/core/orthographic-controller.md +++ b/docs/api-reference/core/orthographic-controller.md @@ -47,7 +47,7 @@ Also accepts additional options: - `zoomAxis` (string) - which axes to apply zoom to. Affects scroll, keyboard +/- and double tap. One of `X` (zoom along the X axis only), `Y` (zoom along the Y axis only), `all`. Default `all`. If this option is set to `X` or `Y`, `viewState.zoom` must be an array to enable independent zoom for each axis. - `maxBounds` - constrains the target position within the specified bounding box `[[minX, minY], [maxX, maxY]]` -- `maxBoundsRubberBand` (boolean) - allows panning to temporarily overshoot `maxBounds` with increasing resistance, including when the complete content already fits in the viewport, then returns directly within bounds on release. The return uses a 300 ms ease-out independently of `inertia`, so genuine in-bounds flings retain their configured duration. Requires `maxBounds` and does not relax zoom constraints. Default `false`. +- `maxBoundsRubberBand` (boolean) - allows panning to temporarily overshoot `maxBounds` with increasing resistance, including when the complete content already fits in the viewport, then returns directly within bounds on release. The return uses a 300 ms ease-out independently of `inertia`, so genuine in-bounds flings retain their configured duration. Rubber-banding follows the semantic pan action, including remapped pointer and multi-touch gestures. Requires `maxBounds` and does not relax zoom constraints. Default `false`. ## Custom OrthographicController diff --git a/modules/core/src/controllers/controller.ts b/modules/core/src/controllers/controller.ts index dc858fc9d72..36fab9aef9d 100644 --- a/modules/core/src/controllers/controller.ts +++ b/modules/core/src/controllers/controller.ts @@ -452,14 +452,17 @@ export default abstract class Controller oldViewState[key] !== viewState[key]); - this.state = newControllerState.getState(); - this._setInteractionState(interactionState); + // Semantic state actions may keep an interaction active for a transition after input ends. + // Consume this one-shot signal instead of persisting it into the next gesture. + this.state = controllerState; + this._setInteractionState({...interactionState, ...transitionInteractionState}); if (changed) { const oldViewState = this.controllerState && this.controllerState.getViewportProps(); @@ -550,12 +553,18 @@ export default abstract class Controller, endProps: Record): number { + return isTargetOverscrolled(startProps) + ? MAX_BOUNDS_RUBBER_BAND_DURATION + : super.getDuration(startProps, endProps); + } + /** Allows a zero-duration gesture to interrupt an in-progress return. */ override arePropsEqual( currentProps: Record, nextProps: Record ): boolean { - return currentProps.transitionDuration !== 0 && super.arePropsEqual(currentProps, nextProps); + return ( + !(currentProps.transitionDuration === 0 && isTargetOverscrolled(currentProps)) && + super.arePropsEqual(currentProps, nextProps) + ); + } + + /** Carries rebound identity only inside the transition manager. */ + override initializeProps(startProps: Record, endProps: Record) { + const props = super.initializeProps(startProps, endProps) as { + start: Record & MaxBoundsRubberBandPhase; + end: Record; + }; + if (isTargetOverscrolled(startProps)) { + props.start[MAX_BOUNDS_RUBBER_BAND_PHASE] = 'transition'; + } + return props; } /** Preserves temporary overshoot until the final, bounded transition frame. */ @@ -41,7 +64,10 @@ class RubberBandInterpolator extends LinearInterpolator { ): Record { const props = super.interpolateProps(startProps, endProps, t) as Record & MaxBoundsRubberBandPhase; - if (t < 1) { + const phase = (startProps as Record & MaxBoundsRubberBandPhase)[ + MAX_BOUNDS_RUBBER_BAND_PHASE + ]; + if (phase === 'transition' && t < 1) { props[MAX_BOUNDS_RUBBER_BAND_PHASE] = 'transition'; } return props; @@ -72,6 +98,12 @@ type OrthographicStateInternal = { startPanPosition?: number[]; startZoomPosition?: number[]; startZoom?: number[]; + /** Preserves the release target before native inertia projects another pan. */ + previousPanTarget?: number[]; + /** Identifies a state-owned rebound while a controlled view reconstructs it. */ + isMaxBoundsRubberBandTransition?: boolean; + /** Keeps panning active while the state-defined edge return is running. */ + transitionInteractionState?: InteractionState; }; function normalizeZoom({ @@ -91,6 +123,37 @@ function normalizeZoom({ return {zoomX, zoomY}; } +function getAxisBounds( + maxBounds: NonNullable, + index: number, + halfSize: number, + target: number +) { + const minimum = maxBounds[0][index] + halfSize; + const maximum = maxBounds[1][index] - halfSize; + const midpoint = (maxBounds[0][index] + maxBounds[1][index]) / 2; + return { + minimum, + maximum, + midpoint, + settledTarget: + Number.isFinite(halfSize) && minimum <= maximum ? clamp(target, minimum, maximum) : midpoint + }; +} + +/** Tests each axis against the position where it would settle after release. */ +function isTargetOverscrolled(props: Record): boolean { + const {maxBounds, maxBoundsRubberBand, target, width, height} = props; + if (!maxBoundsRubberBand || !maxBounds || !target) { + return false; + } + const {zoomX, zoomY} = normalizeZoom(props); + return [width / 2 / 2 ** zoomX, height / 2 / 2 ** zoomY].some((halfSize, index) => { + const {settledTarget} = getAxisBounds(maxBounds, index, halfSize, target[index]); + return target[index] !== settledTarget; + }); +} + export class OrthographicState extends ViewState< OrthographicState, OrthographicStateProps, @@ -128,11 +191,15 @@ export class OrthographicState extends ViewState< startPanPosition, // Model state when the zoom operation first started startZoomPosition, - startZoom + startZoom, + previousPanTarget, + isMaxBoundsRubberBandTransition, + transitionInteractionState } = options; const {[MAX_BOUNDS_RUBBER_BAND_PHASE]: maxBoundsRubberBandPhase} = options as OrthographicStateProps & MaxBoundsRubberBandPhase; + // Let inherited, remappable actions interrupt a spring without snapping to its edge. const {zoomX, zoomY} = normalizeZoom(options); super( @@ -152,13 +219,17 @@ export class OrthographicState extends ViewState< maxBoundsRubberBand, ...{ [MAX_BOUNDS_RUBBER_BAND_PHASE]: - maxBoundsRubberBandPhase ?? (startPanPosition ? 'transition' : undefined) + maxBoundsRubberBandPhase ?? + (startPanPosition || isMaxBoundsRubberBandTransition ? 'transition' : undefined) } }, { startPanPosition, startZoomPosition, - startZoom + startZoom, + previousPanTarget, + isMaxBoundsRubberBandTransition, + transitionInteractionState }, options.makeViewport ); @@ -188,17 +259,56 @@ export class OrthographicState extends ViewState< const viewport = this.makeViewport(this.getViewportProps()); const newProps = viewport.panByPosition(startPanPosition, pos); - return this._getUpdatedState(newProps); + return this._getUpdatedState({ + ...newProps, + previousPanTarget: this.getViewportProps().target + }); } /** - * End panning - * Must call if `panStart()` was called + * Ends a semantic pan and supplies a spring-back when it exceeds `maxBounds`. + * Must be called if `panStart()` was called. */ panEnd(): OrthographicState { - return this._getUpdatedState({ - startPanPosition: null + const {maxBounds, maxBoundsRubberBand, target} = this.getViewportProps(); + const previousPanTarget = this.getState().previousPanTarget; + let endedState = this._getUpdatedState({ + startPanPosition: null, + previousPanTarget: null }); + let isOverscrolled = target.some( + (value, index) => value !== endedState.getViewportProps().target[index] + ); + + // Settle from the real release target, not a later inertia-projected position. + if (maxBoundsRubberBand && maxBounds && previousPanTarget) { + const previousEndedState = this._getUpdatedState({ + target: previousPanTarget, + startPanPosition: null, + previousPanTarget: null + }); + const wasOverscrolled = previousPanTarget.some( + (value, index) => value !== previousEndedState.getViewportProps().target[index] + ); + if (wasOverscrolled) { + isOverscrolled = true; + endedState = previousEndedState; + } + } + + if (maxBoundsRubberBand && maxBounds && isOverscrolled) { + endedState = endedState._getUpdatedState({ + isMaxBoundsRubberBandTransition: true, + transitionInteractionState: {isPanning: true} + }); + Object.assign(endedState.getViewportProps(), { + transitionDuration: MAX_BOUNDS_RUBBER_BAND_DURATION, + transitionInterpolator: new RubberBandInterpolator(), + transitionEasing: (time: number) => 1 - (1 - time) * (1 - time) + } satisfies TransitionProps); + } + + return endedState; } /** @@ -391,8 +501,12 @@ export class OrthographicState extends ViewState< ...this.getViewportProps(), ...this.getState(), ...newProps, + // A semantic action consumes the active rebound identity. panEnd sets it again when needed. + isMaxBoundsRubberBandTransition: newProps.isMaxBoundsRubberBandTransition, [MAX_BOUNDS_RUBBER_BAND_PHASE]: - this.getState().startPanPosition && newProps.target ? 'drag' : undefined + this.getState().startPanPosition && newProps.startPanPosition !== null && newProps.target + ? 'drag' + : undefined }); } @@ -422,19 +536,21 @@ export class OrthographicState extends ViewState< const constrainedTarget = target.slice(); for (const [index, halfSize] of [halfWidth, halfHeight].entries()) { - const minimum = maxBounds[0][index] + halfSize; - const maximum = maxBounds[1][index] - halfSize; - const midpoint = (maxBounds[0][index] + maxBounds[1][index]) / 2; + const {minimum, maximum, midpoint, settledTarget} = getAxisBounds( + maxBounds, + index, + halfSize, + target[index] + ); if (maxBoundsRubberBand && !Number.isFinite(halfSize)) { constrainedTarget[index] = midpoint; continue; } - const constrained = - maxBoundsRubberBand && minimum > maximum - ? midpoint - : clamp(target[index], minimum, maximum); + const constrained = maxBoundsRubberBand + ? settledTarget + : clamp(target[index], minimum, maximum); const overshoot = target[index] - constrained; constrainedTarget[index] = maxBoundsRubberBand && maxBoundsRubberBandPhase === 'transition' @@ -511,62 +627,6 @@ export default class OrthographicController extends Controller value !== constrainedTarget[index]); - - if (isOverscrolled) { - this.updateViewport( - constrainedState, - { - ...this._getTransitionProps(), - transitionDuration: this.transition.transitionDuration, - transitionEasing: time => 1 - (1 - time) * (1 - time), - transitionInterpolator: new RubberBandInterpolator() - }, - {isDragging: false, isPanning: true} - ); - return true; - } - - const velocityX = Number.isFinite(event.velocityX) ? event.velocityX : 0; - const velocityY = Number.isFinite(event.velocityY) ? event.velocityY : 0; - const isIntentionalFling = - Math.max(Math.abs(velocityX), Math.abs(velocityY)) > - MAX_BOUNDS_RUBBER_BAND_MIN_FLING_VELOCITY; - - if (!isIntentionalFling && event.velocity) { - this.updateViewport(constrainedState, null, { - isDragging: false, - isPanning: false - }); - return true; - } - - return super._onPanMoveEnd(event); - } - protected _onMultiPanStart(event: MjolnirGestureEvent): boolean { return this.multiTouchDrag === 'pan' && super._onMultiPanStart(event); } diff --git a/test/modules/core/controllers/controllers.spec.ts b/test/modules/core/controllers/controllers.spec.ts index 9a99faceed5..8953ea0502b 100644 --- a/test/modules/core/controllers/controllers.spec.ts +++ b/test/modules/core/controllers/controllers.spec.ts @@ -5,6 +5,7 @@ import {test, expect} from 'vitest'; import { type ControllerProps, + LinearInterpolator, MapView, OrbitView, OrthographicView, @@ -384,6 +385,35 @@ function expectRubberBandInteractionEnded(interactionStates: any[]) { }); } +test('OrthographicState constrains and releases panning without gesture handlers', () => { + for (const maxBoundsRubberBand of [false, true]) { + const controller = createRubberBandController({controller: {maxBoundsRubberBand}}); + const panningState = controller.controllerState.panStart({pos: [50, 50]}).pan({pos: [200, 50]}); + const draggedTarget = panningState.getViewportProps().target; + + if (maxBoundsRubberBand) { + expect(draggedTarget[0], 'the state permits resisted overscroll').toBeLessThan(50); + expect(draggedTarget[0], 'the state resists raw pan displacement').toBeGreaterThan(-50); + } else { + expect(draggedTarget, 'the state keeps default bounds hard').toEqual([50, 100]); + } + + const releasedProps = panningState.panEnd().getViewportProps() as Record; + expect(releasedProps.target, 'panEnd returns to the nearest valid edge').toEqual([50, 100]); + if (maxBoundsRubberBand) { + expect(releasedProps.transitionDuration, 'panEnd owns the short spring').toBe(300); + expect( + releasedProps.transitionInterpolator, + 'panEnd preserves native linear interpolation' + ).toBeInstanceOf(LinearInterpolator); + } else { + expect(releasedProps.transitionDuration ?? 0, 'hard bounds do not start a spring').toBe(0); + } + expect(Object.getOwnPropertySymbols(releasedProps), 'state metadata stays private').toEqual([]); + controller.finalize(); + } +}); + test('OrthographicController keeps maxBounds hard by default', () => { for (const maxBoundsRubberBand of [undefined, false]) { const controller = createRubberBandController({controller: {maxBoundsRubberBand}}); @@ -408,6 +438,25 @@ test('OrthographicController keeps non-gesture bounds hard with rubber-banding e expect(controller.props.target[0], 'keyboard navigation is hard-clamped').toBe(50); controller.finalize(); + const animatedController = createRubberBandController(); + panRubberBand(animatedController); + animatedController.handleEvent(makeGestureEvent('panend', {x: 200}) as any); + advanceRubberBandTransition(animatedController, 300); + animatedController.setProps({ + ...animatedController.props, + target: [300, 100, 0], + transitionDuration: 500 + }); + const animatedTransition = animatedController.transitionManager.transition; + expect(animatedTransition.settings.duration, 'programmatic animation keeps its duration').toBe( + 500 + ); + advanceRubberBandTransition(animatedController, 500); + expect(animatedController.props.target, 'programmatic animation ends at the hard edge').toEqual([ + 150, 100 + ]); + animatedController.finalize(); + const disabledController = createRubberBandController({controller: {dragPan: false}}); const handled = panRubberBand(disabledController); expect(handled, 'disabled panning ignores movement').toBe(false); @@ -421,6 +470,36 @@ test('OrthographicController keeps non-gesture bounds hard with rubber-banding e disabledController.finalize(); }); +test('OrthographicController rubber-bands a modifier-remapped pan action', () => { + const interactionStates: any[] = []; + const controller = createRubberBandController({ + controller: {dragMode: 'rotate'}, + onStateChange: state => interactionStates.push({...state}) + }); + const makeRemappedGesture = (type: string, x: number = 50) => ({ + ...makeGestureEvent(type, {x}), + srcEvent: {shiftKey: true} + }); + + expect(controller.handleEvent(makeRemappedGesture('panstart') as any)).toBe(true); + expect(controller.handleEvent(makeRemappedGesture('panmove', 200) as any)).toBe(true); + expect(controller.props.target[0], 'the remapped action exceeds the edge').toBeLessThan(50); + expect(controller.props.target[0], 'the remapped action is resisted').toBeGreaterThan(-50); + expect(controller.handleEvent(makeRemappedGesture('panend', 200) as any)).toBe(true); + + const transition = controller.transitionManager.transition; + expect(transition.inProgress, 'the remapped action starts the state-defined return').toBe(true); + expect(transition.settings.duration, 'the remapped action uses the short spring').toBe(300); + expect( + interactionStates[interactionStates.length - 1], + 'the remapped spring keeps semantic panning active' + ).toMatchObject({inTransition: true, isDragging: false, isPanning: true}); + advanceRubberBandTransition(controller, 300); + expect(controller.props.target, 'the remapped action settles at the edge').toEqual([50, 100]); + expectRubberBandInteractionEnded(interactionStates); + controller.finalize(); +}); + test('OrthographicController applies resistance beyond every edge', () => { for (const {x, y, axes} of [ {x: 200, y: 50, axes: [0]}, @@ -563,6 +642,37 @@ test.each([ controller.finalize(); }); +test('OrthographicController preserves inertia when only the horizontal axis fits', () => { + const controller = createRubberBandController({ + controller: { + inertia: 900, + maxBounds: [ + [0, 10], + [200, 11] + ] + }, + initialViewState: { + target: [100, 10.5, 0], + zoomAxis: 'X', + maxZoomX: 6, + maxZoomY: 2 + } + }); + panRubberBand(controller, {x: 60}); + controller.handleEvent({ + ...makeGestureEvent('panend', {x: 60}), + velocity: 0.05, + velocityX: 0.05 + } as any); + + const transition = controller.transitionManager.transition; + expect(transition.inProgress, 'the in-bounds horizontal fling starts native inertia').toBe(true); + expect(transition.settings.duration, 'the fling keeps its configured inertia').toBe(900); + advanceRubberBandTransition(controller, 900); + expect(controller.props.target[1], 'the non-fitting vertical axis remains centered').toBe(10.5); + controller.finalize(); +}); + test('OrthographicController springs overscroll back within maxBounds', () => { for (const inertia of [undefined, false, true, 450, 900]) { const interactionStates: any[] = []; @@ -576,6 +686,10 @@ test('OrthographicController springs overscroll back within maxBounds', () => { const transition = controller.transitionManager.transition; expect(transition.inProgress, 'release starts a spring-back transition').toBe(true); expect(transition.settings.duration, 'spring-back is independent of fling inertia').toBe(300); + expect( + interactionStates[interactionStates.length - 1], + 'the spring keeps semantic panning active after input ends' + ).toMatchObject({inTransition: true, isDragging: false, isPanning: true}); const initialDistance = Math.abs(draggedTarget - 50); let previousDistance = initialDistance; @@ -676,38 +790,16 @@ test('OrthographicController preserves overscroll during an inward release', () controller.finalize(); }); -test('OrthographicController preserves native inertia for in-bounds flings', () => { - const controller = createRubberBandController({controller: {inertia: 900}}); - panRubberBand(controller, {x: 60}); - controller.handleEvent({ - ...makeGestureEvent('panend', {x: 60}), - velocity: 0.4, - velocityX: 0.4, - velocityY: 0 - } as any); - const transition = controller.transitionManager.transition; - expect(transition.inProgress, 'the in-bounds fling starts native inertia').toBe(true); - expect(transition.settings.duration, 'the fling keeps its configured inertia').toBe(900); - expect( - transition.settings.interpolator.constructor.name, - 'in-bounds flings retain the native linear interpolator' - ).toBe('LinearInterpolator'); - advanceRubberBandTransition(controller, 900); - expect(transition.inProgress, 'the native fling finishes').toBe(false); - controller.finalize(); -}); - test.each([ {description: 'slow horizontal velocity', velocity: 0.05, velocityX: 0.05, velocityY: 0}, {description: 'slow diagonal velocity', velocity: 0.22, velocityX: 0.22, velocityY: 0.22}, - {description: 'velocity at the flick threshold', velocity: 0.3, velocityX: 0.3, velocityY: 0} + {description: 'moderate horizontal velocity', velocity: 0.3, velocityX: 0.3, velocityY: 0}, + {description: 'fast horizontal velocity', velocity: 0.4, velocityX: 0.4, velocityY: 0} ])( - 'OrthographicController does not turn $description into an in-bounds fling', + 'OrthographicController preserves native inertia for $description', ({velocity, velocityX, velocityY}) => { const controller = createRubberBandController({controller: {inertia: 900}}); panRubberBand(controller, {x: 60}); - const draggedTarget = controller.props.target.slice(); - controller.handleEvent({ ...makeGestureEvent('panend', {x: 60}), velocity, @@ -715,13 +807,15 @@ test.each([ velocityY } as any); - expect(controller.props.target, 'the release remains at its dragged position').toEqual( - draggedTarget - ); + const transition = controller.transitionManager.transition; + expect(transition.inProgress, 'the in-bounds fling starts native inertia').toBe(true); + expect(transition.settings.duration, 'the fling keeps its configured inertia').toBe(900); expect( - controller.transitionManager.transition.inProgress, - 'a slow release does not start inertia' - ).toBe(false); + transition.settings.interpolator, + 'in-bounds flings retain native linear interpolation' + ).toBeInstanceOf(LinearInterpolator); + advanceRubberBandTransition(controller, 900); + expect(transition.inProgress, 'the native fling finishes').toBe(false); controller.finalize(); } ); @@ -764,9 +858,17 @@ test.each([ test('OrthographicController applies rubber-band resistance to multi-touch panning', () => { const controller = createRubberBandController({controller: {multiTouchDrag: 'pan'}}); - panRubberBand(controller, {x: 200, deltaX: 150}, 'multipan'); + const position = {x: 200, deltaX: 150}; + panRubberBand(controller, position, 'multipan'); expect(controller.props.target[0], 'multi-touch panning overshoots the edge').toBeLessThan(50); expect(controller.props.target[0], 'multi-touch overscroll is resisted').toBeGreaterThan(-50); + expect(controller.handleEvent(makeGestureEvent('multipanend', position) as any)).toBe(true); + + const transition = controller.transitionManager.transition; + expect(transition.inProgress, 'multi-touch panEnd starts the state-defined return').toBe(true); + expect(transition.settings.duration, 'multi-touch panEnd uses the short spring').toBe(300); + advanceRubberBandTransition(controller, 300); + expect(controller.props.target, 'multi-touch panEnd settles at the edge').toEqual([50, 100]); controller.finalize(); }); @@ -791,6 +893,13 @@ test('OrthographicController does not expose rubber-band interaction metadata', expect(Object.getOwnPropertySymbols(viewState.target), 'target has no private symbols').toEqual( [] ); + expect( + viewState, + 'one-shot transition interaction state is not public view state' + ).not.toHaveProperty('transitionInteractionState'); + expect(viewState, 'active rebound state is not public view state').not.toHaveProperty( + 'isMaxBoundsRubberBandTransition' + ); expect(JSON.parse(JSON.stringify(viewState)).target, 'view state remains serializable').toEqual( viewState.target ); @@ -805,6 +914,14 @@ test('OrthographicController ignores maxBoundsRubberBand without maxBounds', () }); panRubberBand(controller); expect(controller.props.target, 'unbounded panning is unchanged').toEqual([-50, 100]); + controller.handleEvent(makeGestureEvent('panend', {x: 200}) as any); + controller.setProps({...controller.props, target: [0, 100, 0], transitionDuration: 500}); + advanceRubberBandTransition(controller, 100); + controller.handleEvent(makeGestureEvent('panstart') as any); + expect( + controller.transitionManager.transition.inProgress, + 'a stationary gesture does not change native transition interruption' + ).toBe(true); controller.finalize(); } }); From 62a2df64afef2b19fec229229d77bf64df4564d1 Mon Sep 17 00:00:00 2001 From: Pessimistress Date: Fri, 7 Aug 2026 17:23:38 -0700 Subject: [PATCH 6/6] generic constraint mechanism --- .../core/orthographic-controller.md | 2 +- examples/website/orthographic/app.tsx | 2 +- modules/core/src/controllers/controller.ts | 172 +++++--- .../controllers/orthographic-controller.ts | 368 +++++++----------- .../src/controllers/transition-manager.ts | 26 +- modules/core/src/controllers/view-state.ts | 170 ++++---- .../core/controllers/controllers.spec.ts | 221 +++++------ 7 files changed, 492 insertions(+), 469 deletions(-) diff --git a/docs/api-reference/core/orthographic-controller.md b/docs/api-reference/core/orthographic-controller.md index 8df1c520cd5..5ba2b703871 100644 --- a/docs/api-reference/core/orthographic-controller.md +++ b/docs/api-reference/core/orthographic-controller.md @@ -47,7 +47,7 @@ Also accepts additional options: - `zoomAxis` (string) - which axes to apply zoom to. Affects scroll, keyboard +/- and double tap. One of `X` (zoom along the X axis only), `Y` (zoom along the Y axis only), `all`. Default `all`. If this option is set to `X` or `Y`, `viewState.zoom` must be an array to enable independent zoom for each axis. - `maxBounds` - constrains the target position within the specified bounding box `[[minX, minY], [maxX, maxY]]` -- `maxBoundsRubberBand` (boolean) - allows panning to temporarily overshoot `maxBounds` with increasing resistance, including when the complete content already fits in the viewport, then returns directly within bounds on release. The return uses a 300 ms ease-out independently of `inertia`, so genuine in-bounds flings retain their configured duration. Rubber-banding follows the semantic pan action, including remapped pointer and multi-touch gestures. Requires `maxBounds` and does not relax zoom constraints. Default `false`. +- `rubberBand` (boolean) - allows continuous pan and zoom interactions to temporarily overshoot `maxBounds`, `minZoom`, and `maxZoom` with increasing resistance. On release, the view returns within constraints using a 300 ms exponential ease-out independently of `inertia`. Default `false`. ## Custom OrthographicController diff --git a/examples/website/orthographic/app.tsx b/examples/website/orthographic/app.tsx index 6f80178aa9b..54dabd70a28 100644 --- a/examples/website/orthographic/app.tsx +++ b/examples/website/orthographic/app.tsx @@ -228,7 +228,7 @@ export default function App({ initialViewState={initialViewState} controller={{ maxBounds: contentBounds, - maxBoundsRubberBand: true + rubberBand: true }} layers={layers} layerFilter={layerFilter} diff --git a/modules/core/src/controllers/controller.ts b/modules/core/src/controllers/controller.ts index 36fab9aef9d..4a804e19407 100644 --- a/modules/core/src/controllers/controller.ts +++ b/modules/core/src/controllers/controller.ts @@ -5,7 +5,7 @@ /* eslint-disable max-statements, complexity */ import TransitionManager, {TransitionProps} from './transition-manager'; import LinearInterpolator from '../transitions/linear-interpolator'; -import {IViewState} from './view-state'; +import {IViewState, type ConstraintContext} from './view-state'; import {ConstructorOf} from '../types/types'; import {deepEqual} from '../utils/deep-equal'; @@ -25,7 +25,9 @@ const NO_TRANSITION_PROPS = { } as const; const DEFAULT_INERTIA = 300; -const INERTIA_EASING = t => 1 - (1 - t) * (1 - t); +const REBOUND_DURATION = 300; +const INERTIA_EASING = (t: number): number => 1 - (1 - t) * (1 - t); +const EASE_OUT_EXPONENTIAL = (t: number): number => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t)); const EVENT_TYPES = { WHEEL: ['wheel'], PAN: ['panstart', 'panmove', 'panend'], @@ -92,11 +94,8 @@ export type ControllerOptions = { | [min: [number, number], max: [number, number]] | [min: [number, number, number], max: [number, number, number]] | null; - /** - * Allows orthographic panning to temporarily exceed `maxBounds` and spring back. - * @default false - */ - maxBoundsRubberBand?: boolean; + /** Enables elastic constraints during continuous interaction. Default `false`. */ + rubberBand?: boolean; }; export type ControllerProps = { @@ -201,7 +200,8 @@ export default abstract class Controller({ ...opts, - getControllerState: props => new this.ControllerState(props), + getControllerState: (props, constraintContext) => + new this.ControllerState({...props, constraintContext}), onViewStateChange: this._onTransition.bind(this), onStateChange: this._setInteractionState.bind(this) }); @@ -452,17 +452,14 @@ export default abstract class Controller oldViewState[key] !== viewState[key]); - // Semantic state actions may keep an interaction active for a transition after input ends. - // Consume this one-shot signal instead of persisting it into the next gesture. - this.state = controllerState; - this._setInteractionState({...interactionState, ...transitionInteractionState}); + this.state = newControllerState.getState(); + this._setInteractionState(interactionState); if (changed) { const oldViewState = this.controllerState && this.controllerState.getViewportProps(); @@ -493,6 +490,41 @@ export default abstract class Controller !deepEqual(this.props[key], nextViewportProps[key], 1) + ); + return shouldRebound + ? { + ...this._getTransitionProps(), + transitionDuration: REBOUND_DURATION, + transitionEasing: EASE_OUT_EXPONENTIAL + } + : null; + } + /* Event handlers */ // Default handler for the `panstart` event. protected _onPanStart(event: MjolnirGestureEvent): boolean { @@ -506,9 +538,11 @@ export default abstract class Controller, endProps: Record): number { - return isTargetOverscrolled(startProps) - ? MAX_BOUNDS_RUBBER_BAND_DURATION - : super.getDuration(startProps, endProps); - } - - /** Allows a zero-duration gesture to interrupt an in-progress return. */ - override arePropsEqual( - currentProps: Record, - nextProps: Record - ): boolean { - return ( - !(currentProps.transitionDuration === 0 && isTargetOverscrolled(currentProps)) && - super.arePropsEqual(currentProps, nextProps) - ); - } - - /** Carries rebound identity only inside the transition manager. */ - override initializeProps(startProps: Record, endProps: Record) { - const props = super.initializeProps(startProps, endProps) as { - start: Record & MaxBoundsRubberBandPhase; - end: Record; - }; - if (isTargetOverscrolled(startProps)) { - props.start[MAX_BOUNDS_RUBBER_BAND_PHASE] = 'transition'; - } - return props; - } - - /** Preserves temporary overshoot until the final, bounded transition frame. */ - override interpolateProps( - startProps: Record, - endProps: Record, - t: number - ): Record { - const props = super.interpolateProps(startProps, endProps, t) as Record & - MaxBoundsRubberBandPhase; - const phase = (startProps as Record & MaxBoundsRubberBandPhase)[ - MAX_BOUNDS_RUBBER_BAND_PHASE - ]; - if (phase === 'transition' && t < 1) { - props[MAX_BOUNDS_RUBBER_BAND_PHASE] = 'transition'; - } - return props; - } -} - export type OrthographicStateProps = { width: number; height: number; @@ -90,20 +36,16 @@ export type OrthographicStateProps = { minZoomY?: number; maxBounds?: ControllerProps['maxBounds']; - /** Enables spring-backed panning only with `maxBounds`. Defaults to `false`. */ - maxBoundsRubberBand?: boolean; + /** Enables elastic bounds and zoom constraints during interaction. Defaults to `false`. */ + rubberBand?: boolean; }; +const ZOOM_RUBBER_BAND_RANGE = 1; + type OrthographicStateInternal = { startPanPosition?: number[]; startZoomPosition?: number[]; startZoom?: number[]; - /** Preserves the release target before native inertia projects another pan. */ - previousPanTarget?: number[]; - /** Identifies a state-owned rebound while a controlled view reconstructs it. */ - isMaxBoundsRubberBandTransition?: boolean; - /** Keeps panning active while the state-defined edge return is running. */ - transitionInteractionState?: InteractionState; }; function normalizeZoom({ @@ -141,17 +83,11 @@ function getAxisBounds( }; } -/** Tests each axis against the position where it would settle after release. */ -function isTargetOverscrolled(props: Record): boolean { - const {maxBounds, maxBoundsRubberBand, target, width, height} = props; - if (!maxBoundsRubberBand || !maxBounds || !target) { - return false; - } - const {zoomX, zoomY} = normalizeZoom(props); - return [width / 2 / 2 ** zoomX, height / 2 / 2 ** zoomY].some((halfSize, index) => { - const {settledTarget} = getAxisBounds(maxBounds, index, halfSize, target[index]); - return target[index] !== settledTarget; - }); +function applyRubberBand(value: number, constrainedValue: number, range: number): number { + const overshoot = value - constrainedValue; + return overshoot && Number.isFinite(overshoot) + ? constrainedValue + (overshoot * range) / (range + Math.abs(overshoot)) + : constrainedValue; } export class OrthographicState extends ViewState< @@ -165,6 +101,7 @@ export class OrthographicState extends ViewState< maxZoom?: number; minZoom?: number; makeViewport: (props: Record) => Viewport; + constraintContext?: ConstraintContext; } ) { const { @@ -184,22 +121,16 @@ export class OrthographicState extends ViewState< maxZoomY = maxZoom, maxBounds = null, - maxBoundsRubberBand = false, + rubberBand = false, /** Interaction states, required to calculate change during transform */ // Model state when the pan operation first started startPanPosition, // Model state when the zoom operation first started startZoomPosition, - startZoom, - previousPanTarget, - isMaxBoundsRubberBandTransition, - transitionInteractionState + startZoom } = options; - - const {[MAX_BOUNDS_RUBBER_BAND_PHASE]: maxBoundsRubberBandPhase} = - options as OrthographicStateProps & MaxBoundsRubberBandPhase; - // Let inherited, remappable actions interrupt a spring without snapping to its edge. + const {[CONSTRAINT_AROUND]: constraintAround} = options as typeof options & ConstraintAround; const {zoomX, zoomY} = normalizeZoom(options); super( @@ -216,22 +147,16 @@ export class OrthographicState extends ViewState< minZoomY, maxZoomY, maxBounds, - maxBoundsRubberBand, - ...{ - [MAX_BOUNDS_RUBBER_BAND_PHASE]: - maxBoundsRubberBandPhase ?? - (startPanPosition || isMaxBoundsRubberBandTransition ? 'transition' : undefined) - } + rubberBand, + ...{[CONSTRAINT_AROUND]: constraintAround} }, { startPanPosition, startZoomPosition, - startZoom, - previousPanTarget, - isMaxBoundsRubberBandTransition, - transitionInteractionState + startZoom }, - options.makeViewport + options.makeViewport, + options.constraintContext ); } @@ -239,17 +164,21 @@ export class OrthographicState extends ViewState< * Start panning * @param {[Number, Number]} pos - position on screen where the pointer grabs */ - panStart({pos}: {pos: [number, number]}): OrthographicState { - return this._getUpdatedState({ - startPanPosition: this._unproject(pos) - }); + panStart( + {pos}: {pos: [number, number]}, + constraintContext?: ConstraintContext + ): OrthographicState { + return this._getUpdatedState({startPanPosition: this._unproject(pos)}, constraintContext); } /** * Pan * @param {[Number, Number]} pos - position on screen where the pointer is */ - pan({pos, startPosition}: {pos: [number, number]; startPosition?: number[]}): OrthographicState { + pan( + {pos, startPosition}: {pos: [number, number]; startPosition?: number[]}, + constraintContext?: ConstraintContext + ): OrthographicState { const startPanPosition = this.getState().startPanPosition || startPosition; if (!startPanPosition) { @@ -259,56 +188,15 @@ export class OrthographicState extends ViewState< const viewport = this.makeViewport(this.getViewportProps()); const newProps = viewport.panByPosition(startPanPosition, pos); - return this._getUpdatedState({ - ...newProps, - previousPanTarget: this.getViewportProps().target - }); + return this._getUpdatedState(newProps, constraintContext); } /** - * Ends a semantic pan and supplies a spring-back when it exceeds `maxBounds`. - * Must be called if `panStart()` was called. + * End panning + * Must call if `panStart()` was called */ - panEnd(): OrthographicState { - const {maxBounds, maxBoundsRubberBand, target} = this.getViewportProps(); - const previousPanTarget = this.getState().previousPanTarget; - let endedState = this._getUpdatedState({ - startPanPosition: null, - previousPanTarget: null - }); - let isOverscrolled = target.some( - (value, index) => value !== endedState.getViewportProps().target[index] - ); - - // Settle from the real release target, not a later inertia-projected position. - if (maxBoundsRubberBand && maxBounds && previousPanTarget) { - const previousEndedState = this._getUpdatedState({ - target: previousPanTarget, - startPanPosition: null, - previousPanTarget: null - }); - const wasOverscrolled = previousPanTarget.some( - (value, index) => value !== previousEndedState.getViewportProps().target[index] - ); - if (wasOverscrolled) { - isOverscrolled = true; - endedState = previousEndedState; - } - } - - if (maxBoundsRubberBand && maxBounds && isOverscrolled) { - endedState = endedState._getUpdatedState({ - isMaxBoundsRubberBandTransition: true, - transitionInteractionState: {isPanning: true} - }); - Object.assign(endedState.getViewportProps(), { - transitionDuration: MAX_BOUNDS_RUBBER_BAND_DURATION, - transitionInterpolator: new RubberBandInterpolator(), - transitionEasing: (time: number) => 1 - (1 - time) * (1 - time) - } satisfies TransitionProps); - } - - return endedState; + panEnd(constraintContext?: ConstraintContext): OrthographicState { + return this._getUpdatedState({startPanPosition: null}, constraintContext); } /** @@ -343,12 +231,18 @@ export class OrthographicState extends ViewState< * Start zooming * @param {[Number, Number]} pos - position on screen where the pointer grabs */ - zoomStart({pos}: {pos: [number, number]}): OrthographicState { + zoomStart( + {pos}: {pos: [number, number]}, + constraintContext?: ConstraintContext + ): OrthographicState { const {zoomX, zoomY} = this.getViewportProps(); - return this._getUpdatedState({ - startZoomPosition: this._unproject(pos), - startZoom: [zoomX, zoomY] - }); + return this._getUpdatedState( + { + startZoomPosition: this._unproject(pos), + startZoom: [zoomX, zoomY] + }, + constraintContext + ); } /** @@ -359,15 +253,18 @@ export class OrthographicState extends ViewState< * @param {Number} scale - a number between [0, 1] specifying the accumulated * relative scale. */ - zoom({ - pos, - startPos, - scale - }: { - pos: [number, number]; - startPos?: [number, number]; - scale: number; - }): OrthographicState { + zoom( + { + pos, + startPos, + scale + }: { + pos: [number, number]; + startPos?: [number, number]; + scale: number; + }, + constraintContext?: ConstraintContext + ): OrthographicState { let {startZoom, startZoomPosition} = this.getState(); if (!startZoomPosition) { // We have two modes of zoom: @@ -383,48 +280,52 @@ export class OrthographicState extends ViewState< if (!startZoomPosition) { return this; } - const newZoomProps = this._constrainZoom(this._calculateNewZoom({scale, startZoom})); - const zoomedViewport = this.makeViewport({...this.getViewportProps(), ...newZoomProps}); - - return this._getUpdatedState({ - ...newZoomProps, - ...zoomedViewport.panByPosition(startZoomPosition, pos) - }); + const newZoomProps = this._calculateNewZoom({scale, startZoom}); + return this._getUpdatedState( + { + ...newZoomProps, + [CONSTRAINT_AROUND]: {position: startZoomPosition, screenPosition: pos} + }, + constraintContext + ); } /** * End zooming * Must call if `zoomStart()` was called */ - zoomEnd(): OrthographicState { - return this._getUpdatedState({ - startZoomPosition: null, - startZoom: null - }); + zoomEnd(constraintContext?: ConstraintContext): OrthographicState { + return this._getUpdatedState( + { + startZoomPosition: null, + startZoom: null + }, + constraintContext + ); } - zoomIn(speed: number = 2): OrthographicState { - return this._getUpdatedState(this._calculateNewZoom({scale: speed})); + zoomIn(speed: number = 2, constraintContext?: ConstraintContext): OrthographicState { + return this._getUpdatedState(this._calculateNewZoom({scale: speed}), constraintContext); } - zoomOut(speed: number = 2): OrthographicState { - return this._getUpdatedState(this._calculateNewZoom({scale: 1 / speed})); + zoomOut(speed: number = 2, constraintContext?: ConstraintContext): OrthographicState { + return this._getUpdatedState(this._calculateNewZoom({scale: 1 / speed}), constraintContext); } - moveLeft(speed: number = 50): OrthographicState { - return this._panFromCenter([-speed, 0]); + moveLeft(speed: number = 50, constraintContext?: ConstraintContext): OrthographicState { + return this._panFromCenter([-speed, 0], constraintContext); } - moveRight(speed: number = 50): OrthographicState { - return this._panFromCenter([speed, 0]); + moveRight(speed: number = 50, constraintContext?: ConstraintContext): OrthographicState { + return this._panFromCenter([speed, 0], constraintContext); } - moveUp(speed: number = 50): OrthographicState { - return this._panFromCenter([0, -speed]); + moveUp(speed: number = 50, constraintContext?: ConstraintContext): OrthographicState { + return this._panFromCenter([0, -speed], constraintContext); } - moveDown(speed: number = 50): OrthographicState { - return this._panFromCenter([0, speed]); + moveDown(speed: number = 50, constraintContext?: ConstraintContext): OrthographicState { + return this._panFromCenter([0, speed], constraintContext); } rotateLeft(speed: number = 15): OrthographicState { @@ -485,41 +386,73 @@ export class OrthographicState extends ViewState< }; } - _panFromCenter(offset) { + _panFromCenter(offset, constraintContext?: ConstraintContext) { const {target} = this.getViewportProps(); const center = this._project(target); - return this.pan({ - startPosition: target, - pos: [center[0] + offset[0], center[1] + offset[1]] - }); + return this.pan( + { + startPosition: target, + pos: [center[0] + offset[0], center[1] + offset[1]] + }, + constraintContext + ); } - _getUpdatedState(newProps): OrthographicState { + _getUpdatedState(newProps, constraintContext?: ConstraintContext): OrthographicState { // @ts-ignore return new this.constructor({ makeViewport: this.makeViewport, ...this.getViewportProps(), ...this.getState(), ...newProps, - // A semantic action consumes the active rebound identity. panEnd sets it again when needed. - isMaxBoundsRubberBandTransition: newProps.isMaxBoundsRubberBandTransition, - [MAX_BOUNDS_RUBBER_BAND_PHASE]: - this.getState().startPanPosition && newProps.startPanPosition !== null && newProps.target - ? 'drag' - : undefined + constraintContext }); } // Apply any constraints (mathematical or defined by _viewportProps) to map state - applyConstraints(props: Required): Required { - const internalProps = props as typeof props & MaxBoundsRubberBandPhase; - const maxBoundsRubberBandPhase = internalProps[MAX_BOUNDS_RUBBER_BAND_PHASE]; - delete internalProps[MAX_BOUNDS_RUBBER_BAND_PHASE]; - - // Ensure zoom is within specified range - const {zoomX, zoomY} = this._constrainZoom(props, props); + applyConstraints( + props: Required, + constraintContext?: ConstraintContext + ): Required { + const internalProps = props as typeof props & ConstraintAround; + const constraintAround = internalProps[CONSTRAINT_AROUND]; + delete internalProps[CONSTRAINT_AROUND]; + + // Reconciliation frames already describe the intended visual path. Applying + // hard limits here would collapse that path to its settled endpoint. Rebound + // intentionally follows the hard path; the controller animates to that result. + const normalizedZoom = normalizeZoom(props); + const constrainedZoom = this._constrainZoom(normalizedZoom, props); + const shouldRubberBand = props.rubberBand && constraintContext?.mode === 'elastic'; + const {zoomX, zoomY} = + constraintContext?.mode === 'preserve' + ? normalizedZoom + : shouldRubberBand + ? { + zoomX: applyRubberBand( + normalizedZoom.zoomX, + constrainedZoom.zoomX, + ZOOM_RUBBER_BAND_RANGE + ), + zoomY: applyRubberBand( + normalizedZoom.zoomY, + constrainedZoom.zoomY, + ZOOM_RUBBER_BAND_RANGE + ) + } + : constrainedZoom; props.zoomX = zoomX; props.zoomY = zoomY; + + // Resolve the semantic zoom anchor only after zoom constraints have selected + // the displayed scale, otherwise the anchor would be calculated from raw intent. + if (constraintAround) { + const viewport = this.makeViewport({...props, zoomX, zoomY}); + Object.assign( + props, + viewport.panByPosition(constraintAround.position, constraintAround.screenPosition) + ); + } // Backward compatibility: update zoom to reflect new view state // zoom will always be ignored when zoomX and zoomY are specified, but legacy apps may still read zoom in `onViewStateChange` props.zoom = @@ -527,7 +460,7 @@ export class OrthographicState extends ViewState< ? [props.zoomX, props.zoomY] : props.zoomX; - const {maxBounds, maxBoundsRubberBand, target} = props; + const {maxBounds, rubberBand, target} = props; if (maxBounds) { // only calculate center and zoom ranges at rotation=0 // to maintain visual stability when rotating @@ -543,20 +476,17 @@ export class OrthographicState extends ViewState< target[index] ); - if (maxBoundsRubberBand && !Number.isFinite(halfSize)) { + if (constraintContext?.mode !== 'preserve' && rubberBand && !Number.isFinite(halfSize)) { constrainedTarget[index] = midpoint; continue; } - const constrained = maxBoundsRubberBand - ? settledTarget - : clamp(target[index], minimum, maximum); - const overshoot = target[index] - constrained; + const constrained = rubberBand ? settledTarget : clamp(target[index], minimum, maximum); constrainedTarget[index] = - maxBoundsRubberBand && maxBoundsRubberBandPhase === 'transition' + constraintContext?.mode === 'preserve' ? target[index] - : maxBoundsRubberBand && maxBoundsRubberBandPhase === 'drag' && overshoot - ? constrained + (overshoot * halfSize) / (halfSize + Math.abs(overshoot)) + : shouldRubberBand + ? applyRubberBand(target[index], constrained, halfSize) : constrained; } diff --git a/modules/core/src/controllers/transition-manager.ts b/modules/core/src/controllers/transition-manager.ts index dc39679e508..848d3e53f70 100644 --- a/modules/core/src/controllers/transition-manager.ts +++ b/modules/core/src/controllers/transition-manager.ts @@ -4,12 +4,14 @@ import Transition, {TransitionSettings as BaseTransitionSettings} from '../transitions/transition'; import TransitionInterpolator from '../transitions/transition-interpolator'; -import type {IViewState} from './view-state'; +import type {ConstraintContext, IViewState} from './view-state'; import type {Timeline} from '@luma.gl/engine'; import type {InteractionState} from './controller'; const noop = () => {}; +const PRESERVE_CONSTRAINT_CONTEXT: ConstraintContext = {mode: 'preserve'}; +const HARD_CONSTRAINT_CONTEXT: ConstraintContext = {mode: 'hard'}; // Enums cannot be directly exported as they are not transpiled correctly into ES5, see https://github.com/visgl/deck.gl/issues/7130 export const TRANSITION_EVENTS = { @@ -49,7 +51,7 @@ type TransitionSettings = BaseTransitionSettings & { }; export default class TransitionManager> { - getControllerState: (props: any) => ControllerState; + getControllerState: (props: any, constraintContext?: ConstraintContext) => ControllerState; props?: TransitionProps; propsInTransition: Record | null; transition: Transition; @@ -61,7 +63,7 @@ export default class TransitionManager ControllerState; + getControllerState: (props: any, constraintContext?: ConstraintContext) => ControllerState; onViewStateChange?: (params: { viewState: Record; oldViewState: Record; @@ -168,8 +170,11 @@ export default class TransitionManager, @@ -18,10 +26,11 @@ export default abstract class ViewState< constructor( props: Required, state: State, - makeViewport: (props: Record) => Viewport + makeViewport: (props: Record) => Viewport, + constraintContext?: ConstraintContext ) { this.makeViewport = makeViewport; - this._viewportProps = this.applyConstraints(props); + this._viewportProps = this.applyConstraints(props, constraintContext); this._state = state; } @@ -33,42 +42,57 @@ export default abstract class ViewState< return this._state; } - abstract applyConstraints(props: Required): Required; + abstract applyConstraints( + props: Required, + constraintContext?: ConstraintContext + ): Required; abstract shortestPathFrom(viewState: T): Props; - abstract panStart(params: {pos: [number, number]}): T; - abstract pan({pos, startPos}: {pos: [number, number]; startPos?: [number, number]}): T; - abstract panEnd(): T; - - abstract rotateStart(params: {pos: [number, number]; altitude?: number}): T; - abstract rotate(params: {pos?: [number, number]; deltaAngleX?: number; deltaAngleY: number}): T; - abstract rotateEnd(): T; - - abstract zoomStart({pos}: {pos: [number, number]}): T; - abstract zoom({ - pos, - startPos, - scale - }: { - pos: [number, number]; - startPos?: [number, number]; - scale: number; - }): T; - abstract zoomEnd(): T; - - abstract zoomIn(speed?: number): T; - abstract zoomOut(speed?: number): T; - - abstract moveLeft(speed?: number): T; - abstract moveRight(speed?: number): T; - abstract moveUp(speed?: number): T; - abstract moveDown(speed?: number): T; - - abstract rotateLeft(speed?: number): T; - abstract rotateRight(speed?: number): T; - abstract rotateUp(speed?: number): T; - abstract rotateDown(speed?: number): T; + abstract panStart(params: {pos: [number, number]}, constraintContext?: ConstraintContext): T; + abstract pan( + {pos, startPos}: {pos: [number, number]; startPos?: [number, number]}, + constraintContext?: ConstraintContext + ): T; + abstract panEnd(constraintContext?: ConstraintContext): T; + + abstract rotateStart( + params: {pos: [number, number]; altitude?: number}, + constraintContext?: ConstraintContext + ): T; + abstract rotate( + params: {pos?: [number, number]; deltaAngleX?: number; deltaAngleY: number}, + constraintContext?: ConstraintContext + ): T; + abstract rotateEnd(constraintContext?: ConstraintContext): T; + + abstract zoomStart({pos}: {pos: [number, number]}, constraintContext?: ConstraintContext): T; + abstract zoom( + { + pos, + startPos, + scale + }: { + pos: [number, number]; + startPos?: [number, number]; + scale: number; + }, + constraintContext?: ConstraintContext + ): T; + abstract zoomEnd(constraintContext?: ConstraintContext): T; + + abstract zoomIn(speed?: number, constraintContext?: ConstraintContext): T; + abstract zoomOut(speed?: number, constraintContext?: ConstraintContext): T; + + abstract moveLeft(speed?: number, constraintContext?: ConstraintContext): T; + abstract moveRight(speed?: number, constraintContext?: ConstraintContext): T; + abstract moveUp(speed?: number, constraintContext?: ConstraintContext): T; + abstract moveDown(speed?: number, constraintContext?: ConstraintContext): T; + + abstract rotateLeft(speed?: number, constraintContext?: ConstraintContext): T; + abstract rotateRight(speed?: number, constraintContext?: ConstraintContext): T; + abstract rotateUp(speed?: number, constraintContext?: ConstraintContext): T; + abstract rotateDown(speed?: number, constraintContext?: ConstraintContext): T; } export interface IViewState { @@ -80,36 +104,48 @@ export interface IViewState { shortestPathFrom(viewState: T): Record; - panStart(params: {pos: [number, number]}): T; - pan({pos, startPos}: {pos: [number, number]; startPos?: [number, number]}): T; - panEnd(): T; - - rotateStart(params: {pos: [number, number]; altitude?: number}): T; - rotate(params: {pos?: [number, number]; deltaAngleX?: number; deltaAngleY?: number}): T; - rotateEnd(): T; - - zoomStart({pos}: {pos: [number, number]}): T; - zoom({ - pos, - startPos, - scale - }: { - pos: [number, number]; - startPos?: [number, number]; - scale: number; - }): T; - zoomEnd(): T; - - zoomIn(speed?: number): T; - zoomOut(speed?: number): T; - - moveLeft(speed?: number): T; - moveRight(speed?: number): T; - moveUp(speed?: number): T; - moveDown(speed?: number): T; - - rotateLeft(speed?: number): T; - rotateRight(speed?: number): T; - rotateUp(speed?: number): T; - rotateDown(speed?: number): T; + panStart(params: {pos: [number, number]}, constraintContext?: ConstraintContext): T; + pan( + {pos, startPos}: {pos: [number, number]; startPos?: [number, number]}, + constraintContext?: ConstraintContext + ): T; + panEnd(constraintContext?: ConstraintContext): T; + + rotateStart( + params: {pos: [number, number]; altitude?: number}, + constraintContext?: ConstraintContext + ): T; + rotate( + params: {pos?: [number, number]; deltaAngleX?: number; deltaAngleY?: number}, + constraintContext?: ConstraintContext + ): T; + rotateEnd(constraintContext?: ConstraintContext): T; + + zoomStart({pos}: {pos: [number, number]}, constraintContext?: ConstraintContext): T; + zoom( + { + pos, + startPos, + scale + }: { + pos: [number, number]; + startPos?: [number, number]; + scale: number; + }, + constraintContext?: ConstraintContext + ): T; + zoomEnd(constraintContext?: ConstraintContext): T; + + zoomIn(speed?: number, constraintContext?: ConstraintContext): T; + zoomOut(speed?: number, constraintContext?: ConstraintContext): T; + + moveLeft(speed?: number, constraintContext?: ConstraintContext): T; + moveRight(speed?: number, constraintContext?: ConstraintContext): T; + moveUp(speed?: number, constraintContext?: ConstraintContext): T; + moveDown(speed?: number, constraintContext?: ConstraintContext): T; + + rotateLeft(speed?: number, constraintContext?: ConstraintContext): T; + rotateRight(speed?: number, constraintContext?: ConstraintContext): T; + rotateUp(speed?: number, constraintContext?: ConstraintContext): T; + rotateDown(speed?: number, constraintContext?: ConstraintContext): T; } diff --git a/test/modules/core/controllers/controllers.spec.ts b/test/modules/core/controllers/controllers.spec.ts index 8953ea0502b..4344f1acaa6 100644 --- a/test/modules/core/controllers/controllers.spec.ts +++ b/test/modules/core/controllers/controllers.spec.ts @@ -345,7 +345,7 @@ function createRubberBandController({ view: new OrthographicView({ controller: { maxBounds: ORTHOGRAPHIC_MAX_BOUNDS, - maxBoundsRubberBand: true, + rubberBand: true, ...controllerOptions } }), @@ -363,6 +363,19 @@ function panRubberBand( return controller.handleEvent(makeGestureEvent(`${gesture}move`, position) as any); } +/** Replays a pinch zoom through deck.gl's normal gesture handlers. */ +function pinchRubberBand(controller: ReturnType, scale: number) { + const makePinchEvent = (type: string, eventScale: number) => ({ + ...makeGestureEvent(type), + scale: eventScale, + rotation: 0, + deltaTime: type === 'pinchstart' ? 0 : 16 + }); + controller.handleEvent(makePinchEvent('pinchstart', 1) as any); + controller.handleEvent(makePinchEvent('pinchmove', scale) as any); + return makePinchEvent('pinchend', scale); +} + /** Advances a spring-back deterministically on the controller's own timeline. */ function advanceRubberBandTransition( controller: ReturnType, @@ -386,43 +399,95 @@ function expectRubberBandInteractionEnded(interactionStates: any[]) { } test('OrthographicState constrains and releases panning without gesture handlers', () => { - for (const maxBoundsRubberBand of [false, true]) { - const controller = createRubberBandController({controller: {maxBoundsRubberBand}}); - const panningState = controller.controllerState.panStart({pos: [50, 50]}).pan({pos: [200, 50]}); + for (const rubberBand of [false, true]) { + const controller = createRubberBandController({controller: {rubberBand}}); + const panningState = controller.controllerState + .panStart({pos: [50, 50]}, {mode: 'hard'}) + .pan({pos: [200, 50]}, {mode: rubberBand ? 'elastic' : 'hard'}); const draggedTarget = panningState.getViewportProps().target; - if (maxBoundsRubberBand) { + if (rubberBand) { expect(draggedTarget[0], 'the state permits resisted overscroll').toBeLessThan(50); expect(draggedTarget[0], 'the state resists raw pan displacement').toBeGreaterThan(-50); } else { expect(draggedTarget, 'the state keeps default bounds hard').toEqual([50, 100]); } - const releasedProps = panningState.panEnd().getViewportProps() as Record; + const releasedProps = panningState.panEnd({mode: 'rebound'}).getViewportProps(); expect(releasedProps.target, 'panEnd returns to the nearest valid edge').toEqual([50, 100]); - if (maxBoundsRubberBand) { - expect(releasedProps.transitionDuration, 'panEnd owns the short spring').toBe(300); - expect( - releasedProps.transitionInterpolator, - 'panEnd preserves native linear interpolation' - ).toBeInstanceOf(LinearInterpolator); - } else { - expect(releasedProps.transitionDuration ?? 0, 'hard bounds do not start a spring').toBe(0); - } expect(Object.getOwnPropertySymbols(releasedProps), 'state metadata stays private').toEqual([]); controller.finalize(); } }); test('OrthographicController keeps maxBounds hard by default', () => { - for (const maxBoundsRubberBand of [undefined, false]) { - const controller = createRubberBandController({controller: {maxBoundsRubberBand}}); + for (const rubberBand of [undefined, false]) { + const controller = createRubberBandController({controller: {rubberBand}}); panRubberBand(controller); expect(controller.props.target, 'panning stops at the visible bounds').toEqual([50, 100]); controller.finalize(); } }); +test.each([ + {description: 'maxZoom', scale: 4, expectedElasticZoom: 1.5, expectedSettledZoom: 1}, + {description: 'minZoom', scale: 0.25, expectedElasticZoom: -1.5, expectedSettledZoom: -1} +])( + 'OrthographicController rubber-bands $description during continuous zoom', + ({scale, expectedElasticZoom, expectedSettledZoom}) => { + const interactionStates: any[] = []; + const controller = createRubberBandController({ + controller: {maxBounds: null}, + initialViewState: {zoom: 0, minZoomX: -1, minZoomY: -1, maxZoomX: 1, maxZoomY: 1}, + onStateChange: state => interactionStates.push({...state}) + }); + const endEvent = pinchRubberBand(controller, scale); + + expect(controller.props.zoomX, 'pinch zoom temporarily exceeds the limit').toBeCloseTo( + expectedElasticZoom + ); + controller.handleEvent(endEvent as any); + + const transition = controller.transitionManager.transition; + expect(transition.inProgress, 'release starts a rebound transition').toBe(true); + expect(transition.settings.duration, 'zoom rebound uses the short duration').toBe(300); + advanceRubberBandTransition(controller, 300); + expect(controller.props.zoomX, 'zoom settles at the configured limit').toBeCloseTo( + expectedSettledZoom + ); + expectRubberBandInteractionEnded(interactionStates); + controller.finalize(); + } +); + +test('OrthographicState keeps one-shot zoom hard with rubberBand enabled', () => { + const controller = createRubberBandController({ + controller: {maxBounds: null}, + initialViewState: {zoom: 0, minZoomX: -1, minZoomY: -1, maxZoomX: 1, maxZoomY: 1} + }); + const zoomedState = controller.controllerState.zoom({pos: [50, 50], scale: 4}); + expect(zoomedState.getViewportProps().zoomX, 'zoom without elastic context is clamped').toBe(1); + controller.finalize(); +}); + +test('OrthographicController hard-constrains programmatic transition endpoints', () => { + const controller = createRubberBandController(); + controller.setProps({ + ...controller.props, + target: [300, 100, 0], + transitionDuration: 500 + }); + + const transition = controller.transitionManager.transition; + expect(transition.inProgress, 'the programmatic transition starts').toBe(true); + expect(transition.settings.duration, 'the programmatic duration is preserved').toBe(500); + advanceRubberBandTransition(controller, 500); + expect(controller.props.target, 'the transition ends at the hard-constrained edge').toEqual([ + 150, 100, 0 + ]); + controller.finalize(); +}); + test('OrthographicController keeps non-gesture bounds hard with rubber-banding enabled', () => { const controller = createRubberBandController({ controller: {keyboard: {moveSpeed: 150}}, @@ -438,25 +503,6 @@ test('OrthographicController keeps non-gesture bounds hard with rubber-banding e expect(controller.props.target[0], 'keyboard navigation is hard-clamped').toBe(50); controller.finalize(); - const animatedController = createRubberBandController(); - panRubberBand(animatedController); - animatedController.handleEvent(makeGestureEvent('panend', {x: 200}) as any); - advanceRubberBandTransition(animatedController, 300); - animatedController.setProps({ - ...animatedController.props, - target: [300, 100, 0], - transitionDuration: 500 - }); - const animatedTransition = animatedController.transitionManager.transition; - expect(animatedTransition.settings.duration, 'programmatic animation keeps its duration').toBe( - 500 - ); - advanceRubberBandTransition(animatedController, 500); - expect(animatedController.props.target, 'programmatic animation ends at the hard edge').toEqual([ - 150, 100 - ]); - animatedController.finalize(); - const disabledController = createRubberBandController({controller: {dragPan: false}}); const handled = panRubberBand(disabledController); expect(handled, 'disabled panning ignores movement').toBe(false); @@ -488,7 +534,7 @@ test('OrthographicController rubber-bands a modifier-remapped pan action', () => expect(controller.handleEvent(makeRemappedGesture('panend', 200) as any)).toBe(true); const transition = controller.transitionManager.transition; - expect(transition.inProgress, 'the remapped action starts the state-defined return').toBe(true); + expect(transition.inProgress, 'the remapped action starts the controller rebound').toBe(true); expect(transition.settings.duration, 'the remapped action uses the short spring').toBe(300); expect( interactionStates[interactionStates.length - 1], @@ -699,8 +745,8 @@ test('OrthographicController springs overscroll back within maxBounds', () => { expect(distance, 'the spring moves directly toward the edge').toBeLessThanOrEqual( previousDistance ); - expect(distance, 'the spring follows the native quadratic ease-out').toBeCloseTo( - initialDistance * (1 - elapsed / 300) ** 2 + expect(distance, 'the spring follows exponential ease-out').toBeCloseTo( + elapsed === 300 ? 0 : initialDistance * 2 ** (-10 * (elapsed / 300)) ); previousDistance = distance; } @@ -712,81 +758,19 @@ test('OrthographicController springs overscroll back within maxBounds', () => { } }); -test.each([ - {description: 'a stationary release', velocity: 0, velocityX: 0, velocityY: 0}, - {description: 'slow horizontal velocity', velocity: 0.05, velocityX: 0.05, velocityY: 0}, - {description: 'slow diagonal velocity', velocity: 0.22, velocityX: 0.22, velocityY: 0.22}, - {description: 'the flick threshold', velocity: 0.3, velocityX: 0.3, velocityY: 0}, - {description: 'a fast outward release', velocity: 1, velocityX: 1, velocityY: 0}, - {description: 'a fast inward release', velocity: 1, velocityX: -1, velocityY: 0} -])( - 'OrthographicController returns overscroll directly after $description', - ({velocity, velocityX, velocityY}) => { - const controller = createRubberBandController({controller: {inertia: 900}}); - panRubberBand(controller); - const draggedTarget = controller.props.target[0]; - const initialDistance = Math.abs(draggedTarget - 50); - - controller.handleEvent({ - ...makeGestureEvent('panend', {x: 200}), - velocity, - velocityX, - velocityY - } as any); - - const transition = controller.transitionManager.transition; - expect(transition.settings.duration, 'an edge release always uses the short spring').toBe(300); - let previousDistance = initialDistance; - - for (const elapsed of [75, 150, 225, 300]) { - advanceRubberBandTransition(controller, 75); - const distance = Math.abs(controller.props.target[0] - 50); - expect(distance, 'release never increases overscroll').toBeLessThanOrEqual(previousDistance); - expect(distance, 'release follows one quadratic ease-out').toBeCloseTo( - initialDistance * (1 - elapsed / 300) ** 2 - ); - previousDistance = distance; - } - - expect(controller.props.target, 'release settles at the bounded edge').toEqual([50, 100]); - expect(transition.inProgress, 'the spring finishes without restarting').toBe(false); - controller.finalize(); - } -); - -test('OrthographicController preserves overscroll during an inward release', () => { - const interactionStates: any[] = []; - const controller = createRubberBandController({ - controller: {inertia: 300}, - onStateChange: state => interactionStates.push({...state}) - }); +test('OrthographicController lets inertia take precedence over rebound', () => { + const controller = createRubberBandController({controller: {inertia: 900}}); panRubberBand(controller); - const draggedTarget = controller.props.target[0]; - expect(draggedTarget, 'the drag temporarily exceeds the visible edge').toBeLessThan(50); controller.handleEvent({ ...makeGestureEvent('panend', {x: 200}), velocity: 1, - velocityX: -1, + velocityX: 1, velocityY: 0 } as any); + const transition = controller.transitionManager.transition; - expect(transition.inProgress, 'the inward release starts a rubber-band transition').toBe(true); - expect( - controller.props.target[0], - 'the first transition frame does not snap to the edge' - ).toBeCloseTo(draggedTarget); - advanceRubberBandTransition(controller, 20); - expect(controller.props.target[0], 'the spring moves inward').toBeGreaterThan(draggedTarget); - expect( - controller.props.target[0], - 'the early transition frame remains overscrolled' - ).toBeLessThan(50); - advanceRubberBandTransition(controller, 280); - expect(controller.props.target, 'the release settles at the nearest bounded edge').toEqual([ - 50, 100 - ]); - expect(transition.inProgress, 'the transition finishes').toBe(false); - expectRubberBandInteractionEnded(interactionStates); + expect(transition.inProgress, 'the release starts native inertia').toBe(true); + expect(transition.settings.duration, 'inertia keeps its configured duration').toBe(900); controller.finalize(); }); @@ -838,9 +822,10 @@ test.each([ expect(targetBeforeInterruption[0], 'the return remains overscrolled').toBeLessThan(50); controller.handleEvent(makeGestureEvent(`${gesture}start`) as any); expect(transition.inProgress, 'the new gesture interrupts the return').toBe(false); - expect(controller.props.target, 'the new gesture does not jump to the boundary').toEqual( - targetBeforeInterruption - ); + expect( + controller.props.target, + 'the hard start context resolves the remaining overshoot' + ).toEqual([50, 100]); expect(interactionStates[interactionStates.length - 1], 'the new drag is active').toMatchObject({ inTransition: false, isDragging: true, @@ -865,7 +850,7 @@ test('OrthographicController applies rubber-band resistance to multi-touch panni expect(controller.handleEvent(makeGestureEvent('multipanend', position) as any)).toBe(true); const transition = controller.transitionManager.transition; - expect(transition.inProgress, 'multi-touch panEnd starts the state-defined return').toBe(true); + expect(transition.inProgress, 'multi-touch panEnd starts the controller rebound').toBe(true); expect(transition.settings.duration, 'multi-touch panEnd uses the short spring').toBe(300); advanceRubberBandTransition(controller, 300); expect(controller.props.target, 'multi-touch panEnd settles at the edge').toEqual([50, 100]); @@ -893,12 +878,8 @@ test('OrthographicController does not expose rubber-band interaction metadata', expect(Object.getOwnPropertySymbols(viewState.target), 'target has no private symbols').toEqual( [] ); - expect( - viewState, - 'one-shot transition interaction state is not public view state' - ).not.toHaveProperty('transitionInteractionState'); - expect(viewState, 'active rebound state is not public view state').not.toHaveProperty( - 'isMaxBoundsRubberBandTransition' + expect(viewState, 'constraint policy is not public view state').not.toHaveProperty( + 'constraintContext' ); expect(JSON.parse(JSON.stringify(viewState)).target, 'view state remains serializable').toEqual( viewState.target @@ -907,10 +888,10 @@ test('OrthographicController does not expose rubber-band interaction metadata', controller.finalize(); }); -test('OrthographicController ignores maxBoundsRubberBand without maxBounds', () => { - for (const maxBoundsRubberBand of [false, true]) { +test('OrthographicController ignores rubberBand panning without maxBounds', () => { + for (const rubberBand of [false, true]) { const controller = createRubberBandController({ - controller: {maxBounds: null, maxBoundsRubberBand} + controller: {maxBounds: null, rubberBand} }); panRubberBand(controller); expect(controller.props.target, 'unbounded panning is unchanged').toEqual([-50, 100]);