react-map-gl/src/utils/transition-manager.js

257 lines
7.6 KiB
JavaScript

// @flow
/* global requestAnimationFrame, cancelAnimationFrame */
import assert from './assert';
import {TransitionInterpolator, LinearInterpolator} from './transition';
import MapState from './map-state';
import type {MapStateProps} from './map-state';
const noop = () => {};
// crops the old easing function from x0 to 1 where x0 is the interruption point
// returns a new easing function with domain [0, 1] and range [0, 1]
export function cropEasingFunction(easing: number => number, x0: number): number => number {
const y0 = easing(x0);
return (t: number) => (1 / (1 - y0)) * (easing(t * (1 - x0) + x0) - y0);
}
export const TRANSITION_EVENTS = {
BREAK: 1,
SNAP_TO_END: 2,
IGNORE: 3,
UPDATE: 4
};
const DEFAULT_PROPS = {
transitionDuration: 0,
transitionEasing: (t: number) => t,
transitionInterpolator: new LinearInterpolator(),
transitionInterruption: TRANSITION_EVENTS.BREAK,
onTransitionStart: noop,
onTransitionInterrupt: noop,
onTransitionEnd: noop,
onViewportChange: noop,
onStateChange: noop
};
export type ViewportProps = MapStateProps & {
onTransitionStart: Function,
onTransitionInterrupt: Function,
onTransitionEnd: Function,
onViewportChange: Function,
onStateChange: Function
};
type TransitionState = {
propsInTransition: any,
interactionState: any,
startProps: MapStateProps,
endProps: MapStateProps,
duration: number,
easing: number => number,
interpolator: TransitionInterpolator,
interruption: number,
startTime: number
};
export default class TransitionManager {
static defaultProps = DEFAULT_PROPS;
constructor(props?: ViewportProps, getTime: Function) {
if (props) {
this.props = props;
}
this.time = getTime || Date.now;
}
props: ViewportProps;
state: TransitionState;
time: () => number;
_animationFrame: ?AnimationFrameID = null;
// Returns current transitioned viewport.
getViewportInTransition() {
return this._animationFrame ? this.state.propsInTransition : null;
}
// Process the viewport change, either ignore or trigger a new transiton.
// Return true if a new transition is triggered, false otherwise.
processViewportChange(nextProps: ViewportProps) {
const currentProps = this.props;
// Set this.props here as '_triggerTransition' calls '_updateViewport' that uses this.props.
this.props = nextProps;
// NOTE: Be cautious re-ordering statements in this function.
if (this._shouldIgnoreViewportChange(currentProps, nextProps)) {
return false;
}
if (this._isTransitionEnabled(nextProps)) {
const startProps = Object.assign({}, currentProps);
const endProps = Object.assign({}, nextProps);
if (this._isTransitionInProgress()) {
currentProps.onTransitionInterrupt();
if (this.state.interruption === TRANSITION_EVENTS.SNAP_TO_END) {
Object.assign(startProps, this.state.endProps);
} else {
Object.assign(startProps, this.state.propsInTransition);
}
if (this.state.interruption === TRANSITION_EVENTS.UPDATE) {
const currentTime = this.time();
const x0 = (currentTime - this.state.startTime) / this.state.duration;
endProps.transitionDuration = this.state.duration - (currentTime - this.state.startTime);
endProps.transitionEasing = cropEasingFunction(this.state.easing, x0);
endProps.transitionInterpolator = startProps.transitionInterpolator;
}
}
endProps.onTransitionStart();
this._triggerTransition(startProps, endProps);
return true;
}
if (this._isTransitionInProgress()) {
currentProps.onTransitionInterrupt();
this._endTransition();
}
return false;
}
// Helper methods
_isTransitionInProgress(): boolean {
return Boolean(this._animationFrame);
}
_isTransitionEnabled(props: ViewportProps): boolean {
const {transitionDuration, transitionInterpolator} = props;
return (
(transitionDuration > 0 || transitionDuration === 'auto') && Boolean(transitionInterpolator)
);
}
_isUpdateDueToCurrentTransition(props: ViewportProps): boolean {
if (this.state.propsInTransition) {
return this.state.interpolator.arePropsEqual(props, this.state.propsInTransition);
}
return false;
}
_shouldIgnoreViewportChange(currentProps: ViewportProps, nextProps: ViewportProps): boolean {
if (!currentProps) {
return true;
}
if (this._isTransitionInProgress()) {
// Ignore update if it is requested to be ignored
return (
this.state.interruption === TRANSITION_EVENTS.IGNORE ||
// Ignore update if it is due to current active transition.
this._isUpdateDueToCurrentTransition(nextProps)
);
}
if (this._isTransitionEnabled(nextProps)) {
// Ignore if none of the viewport props changed.
return nextProps.transitionInterpolator.arePropsEqual(currentProps, nextProps);
}
return true;
}
_triggerTransition(startProps: ViewportProps, endProps: ViewportProps) {
assert(this._isTransitionEnabled(endProps), 'Transition is not enabled');
if (this._animationFrame) {
cancelAnimationFrame(this._animationFrame);
}
// update transitionDuration for 'auto' mode
const {transitionInterpolator} = endProps;
const duration = transitionInterpolator.getDuration
? transitionInterpolator.getDuration(startProps, endProps)
: endProps.transitionDuration;
const initialProps = endProps.transitionInterpolator.initializeProps(startProps, endProps);
const interactionState = {
inTransition: true,
isZooming: startProps.zoom !== endProps.zoom,
isPanning:
startProps.longitude !== endProps.longitude || startProps.latitude !== endProps.latitude,
isRotating: startProps.bearing !== endProps.bearing || startProps.pitch !== endProps.pitch
};
this.state = {
// Save current transition props
duration,
easing: endProps.transitionEasing,
interpolator: endProps.transitionInterpolator,
interruption: endProps.transitionInterruption,
startTime: this.time(),
startProps: initialProps.start,
endProps: initialProps.end,
animation: null,
propsInTransition: {},
interactionState
};
this._onTransitionFrame();
this.props.onStateChange(interactionState);
}
_onTransitionFrame = () => {
// _updateViewport() may cancel the animation
this._animationFrame = requestAnimationFrame(this._onTransitionFrame);
this._updateViewport();
};
_endTransition() {
if (this._animationFrame) {
cancelAnimationFrame(this._animationFrame);
this._animationFrame = null;
}
this.props.onStateChange({
inTransition: false,
isZooming: false,
isPanning: false,
isRotating: false
});
}
_updateViewport() {
// NOTE: Be cautious re-ordering statements in this function.
const currentTime = this.time();
const {startTime, duration, easing, interpolator, startProps, endProps} = this.state;
let shouldEnd = false;
let t = (currentTime - startTime) / duration;
if (t >= 1) {
t = 1;
shouldEnd = true;
}
t = easing(t);
const viewport = interpolator.interpolateProps(startProps, endProps, t);
// Normalize viewport props
const mapState = new MapState(Object.assign({}, this.props, viewport));
this.state.propsInTransition = mapState.getViewportProps();
this.props.onViewportChange(
this.state.propsInTransition,
this.state.interactionState,
this.props
);
if (shouldEnd) {
this._endTransition();
this.props.onTransitionEnd();
}
}
}