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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api-reference/core/orthographic-controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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

Expand Down
3 changes: 2 additions & 1 deletion examples/website/orthographic/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,8 @@ export default function App({
views={new OrthographicView()}
initialViewState={initialViewState}
controller={{
maxBounds: contentBounds
maxBounds: contentBounds,
rubberBand: true
}}
layers={layers}
layerFilter={layerFilter}
Expand Down
150 changes: 116 additions & 34 deletions modules/core/src/controllers/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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'],
Expand Down Expand Up @@ -92,6 +94,8 @@ export type ControllerOptions = {
| [min: [number, number], max: [number, number]]
| [min: [number, number, number], max: [number, number, number]]
| null;
/** Enables elastic constraints during continuous interaction. Default `false`. */
rubberBand?: boolean;
};

export type ControllerProps = {
Expand Down Expand Up @@ -196,7 +200,8 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
}) {
this.transitionManager = new TransitionManager<ControllerState>({
...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)
});
Expand Down Expand Up @@ -485,6 +490,41 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
this.onStateChange(this._interactionState);
}

/** Maps a semantic input lifecycle to the constraint policy seen by controller state. */
protected _getConstraintContext(
_action: 'pan' | 'rotate' | 'zoom',
phase: 'start' | 'update' | 'end'
): ConstraintContext {
if (!this.props.rubberBand) {
return {mode: 'hard'};
}
return {mode: phase === 'update' ? 'elastic' : phase === 'end' ? 'rebound' : 'hard'};
}

/** Returns a rebound transition when hard resolution changed the displayed viewport props. */
private _getReboundTransition(
constraintContext: ConstraintContext,
nextControllerState: ControllerState
): TransitionProps | null {
if (constraintContext.mode !== 'rebound') {
return null;
}

const nextViewportProps = nextControllerState.getViewportProps();
// At interaction end controllerState is reconstructed without the preceding elastic context.
// Compare the hard-resolved destination with the displayed props to detect visible overshoot.
const shouldRebound = Object.keys(nextViewportProps).some(
key => !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 {
Expand All @@ -498,9 +538,11 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
alternateMode = !alternateMode;
}

const newControllerState = this.controllerState[alternateMode ? 'panStart' : 'rotateStart']({
pos
});
const action = alternateMode ? 'pan' : 'rotate';
const constraintContext = this._getConstraintContext(action, 'start');
const newControllerState = alternateMode
? this.controllerState.panStart({pos}, constraintContext)
: this.controllerState.rotateStart({pos}, constraintContext);
this._panMove = alternateMode;
this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {isDragging: true});
return true;
Expand Down Expand Up @@ -528,7 +570,10 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
return false;
}
const pos = this.getCenter(event);
const newControllerState = this.controllerState.pan({pos});
const newControllerState = this.controllerState.pan(
{pos},
this._getConstraintContext('pan', 'update')
);
this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {
isDragging: true,
isPanning: true
Expand Down Expand Up @@ -558,10 +603,13 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
}
);
} else {
const newControllerState = this.controllerState.panEnd();
this.updateViewport(newControllerState, null, {
const currentControllerState = this.controllerState;
const constraintContext = this._getConstraintContext('pan', 'end');
const newControllerState = currentControllerState.panEnd(constraintContext);
const reboundTransition = this._getReboundTransition(constraintContext, newControllerState);
this.updateViewport(newControllerState, reboundTransition, {
isDragging: false,
isPanning: false
isPanning: Boolean(reboundTransition)
});
}
return true;
Expand All @@ -575,7 +623,10 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
}

const pos = this.getCenter(event);
const newControllerState = this.controllerState.rotate({pos});
const newControllerState = this.controllerState.rotate(
{pos},
this._getConstraintContext('rotate', 'update')
);
this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {
isDragging: true,
isRotating: true
Expand Down Expand Up @@ -605,10 +656,13 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
}
);
} else {
const newControllerState = this.controllerState.rotateEnd();
this.updateViewport(newControllerState, null, {
const currentControllerState = this.controllerState;
const constraintContext = this._getConstraintContext('rotate', 'end');
const newControllerState = currentControllerState.rotateEnd(constraintContext);
const reboundTransition = this._getReboundTransition(constraintContext, newControllerState);
this.updateViewport(newControllerState, reboundTransition, {
isDragging: false,
isRotating: false
isRotating: Boolean(reboundTransition)
});
}
return true;
Expand Down Expand Up @@ -676,8 +730,8 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
const pos = this.getCenter(startEvent);
const newControllerState =
multiTouchDrag === 'pan'
? this.controllerState.panStart({pos})
: this.controllerState.rotateStart({pos});
? this.controllerState.panStart({pos}, this._getConstraintContext('pan', 'start'))
: this.controllerState.rotateStart({pos}, this._getConstraintContext('rotate', 'start'));

this._multiPanMode = multiTouchDrag;
this._multiPanStartCenter = startCenter;
Expand Down Expand Up @@ -751,7 +805,9 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
return false;
}

const newControllerState = this.controllerState.zoomStart({pos}).rotateStart({pos});
const newControllerState = this.controllerState
.zoomStart({pos}, this._getConstraintContext('zoom', 'start'))
.rotateStart({pos}, this._getConstraintContext('rotate', 'start'));
// hack - hammer's `rotation` field doesn't seem to produce the correct angle
pinchEventWorkaround._startPinchRotation = event.rotation;
pinchEventWorkaround._lastPinchEvent = event;
Expand All @@ -772,13 +828,17 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
if (this.touchZoom) {
const {scale} = event;
const pos = this.getCenter(event);
newControllerState = newControllerState.zoom({pos, scale});
newControllerState = newControllerState.zoom(
{pos, scale},
this._getConstraintContext('zoom', 'update')
);
}
if (this.touchRotate) {
const {rotation} = event;
newControllerState = newControllerState.rotate({
deltaAngleX: pinchEventWorkaround._startPinchRotation - rotation
});
newControllerState = newControllerState.rotate(
{deltaAngleX: pinchEventWorkaround._startPinchRotation - rotation},
this._getConstraintContext('rotate', 'update')
);
}

this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {
Expand Down Expand Up @@ -822,12 +882,21 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
);
this.blockEvents(inertia);
} else {
const newControllerState = this.controllerState.zoomEnd().rotateEnd();
this.updateViewport(newControllerState, null, {
const currentControllerState = this.controllerState;
const zoomConstraintContext = this._getConstraintContext('zoom', 'end');
const rotateConstraintContext = this._getConstraintContext('rotate', 'end');
const newControllerState = currentControllerState
.zoomEnd(zoomConstraintContext)
.rotateEnd(rotateConstraintContext);
const reboundTransition = this._getReboundTransition(
this.touchZoom ? zoomConstraintContext : rotateConstraintContext,
newControllerState
);
this.updateViewport(newControllerState, reboundTransition, {
isDragging: false,
isPanning: false,
isZooming: false,
isRotating: false
isPanning: Boolean(reboundTransition) && this.touchZoom,
isZooming: Boolean(reboundTransition) && this.touchZoom,
isRotating: Boolean(reboundTransition) && this.touchRotate
});
}
pinchEventWorkaround._startPinchRotation = null;
Expand Down Expand Up @@ -872,9 +941,15 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
}

this._doubleClickDragAnchor = pos;
let newControllerState = this.controllerState.zoomStart({pos});
let newControllerState = this.controllerState.zoomStart(
{pos},
this._getConstraintContext('zoom', 'start')
);
if (event.scale !== 1) {
newControllerState = newControllerState.zoom({pos, scale: event.scale});
newControllerState = newControllerState.zoom(
{pos, scale: event.scale},
this._getConstraintContext('zoom', 'update')
);
}
this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {
isDragging: true,
Expand All @@ -890,7 +965,10 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
return false;
}

const newControllerState = this.controllerState.zoom({pos, scale: event.scale});
const newControllerState = this.controllerState.zoom(
{pos, scale: event.scale},
this._getConstraintContext('zoom', 'update')
);
this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {
isDragging: true,
isPanning: true,
Expand All @@ -900,16 +978,20 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
}

protected _onDoubleClickDragEnd(_event: MjolnirGestureEvent): boolean {
if (!this._doubleClickDragAnchor) {
const pos = this._doubleClickDragAnchor;
if (!pos) {
return false;
}

this._doubleClickDragAnchor = null;
const newControllerState = this.controllerState.zoomEnd();
this.updateViewport(newControllerState, null, {
const currentControllerState = this.controllerState;
const constraintContext = this._getConstraintContext('zoom', 'end');
const newControllerState = currentControllerState.zoomEnd(constraintContext);
const reboundTransition = this._getReboundTransition(constraintContext, newControllerState);
this.updateViewport(newControllerState, reboundTransition, {
isDragging: false,
isPanning: false,
isZooming: false
isPanning: Boolean(reboundTransition),
isZooming: Boolean(reboundTransition)
});
this._suppressDoubleClickUntil = Date.now() + 100;
this.blockEvents(100);
Expand Down
Loading