Port TransitionManager from deck.gl (#383)

This commit is contained in:
Xiaoji Chen 2017-10-21 17:48:06 -07:00 committed by GitHub
parent e7ff75e1e6
commit 25c3b795ba
9 changed files with 445 additions and 73 deletions

View File

@ -1,21 +1,13 @@
/* global window */
import React, {Component} from 'react';
import {render} from 'react-dom';
import MapGL from 'react-map-gl';
import MapGL, {experimental} from 'react-map-gl';
import {PerspectiveMercatorViewport} from 'viewport-mercator-project';
import TWEEN from 'tween.js';
import ControlPanel from './control-panel';
const MAPBOX_TOKEN = ''; // Set your mapbox token here
// Required by tween.js
function animate() {
TWEEN.update();
window.requestAnimationFrame(animate);
}
animate();
export default class App extends Component {
state = {
@ -39,34 +31,25 @@ export default class App extends Component {
window.removeEventListener('resize', this._resize);
}
_resize = () => {
this.setState({
viewport: {
...this.state.viewport,
width: this.props.width || window.innerWidth,
height: this.props.height || window.innerHeight
}
_onViewportChange = viewport => this.setState({
viewport: {...this.state.viewport, ...viewport}
});
_resize = () => this._onViewportChange({
width: this.props.width || window.innerWidth,
height: this.props.height || window.innerHeight
});
_goToViewport = ({longitude, latitude}) => {
this._onViewportChange({
longitude,
latitude,
zoom: 11,
transitionInterpolator: experimental.viewportFlyToInterpolator,
transitionDuration: 3000
});
};
_easeTo = ({longitude, latitude}) => {
// Remove existing animations
TWEEN.removeAll();
const {viewport} = this.state;
new TWEEN.Tween(viewport)
.to({
longitude, latitude,
zoom: 11
}, 3000)
.easing(TWEEN.Easing.Cubic.InOut)
.onUpdate(() => this._onViewportChange(viewport))
.start();
};
_onViewportChange = viewport => this.setState({viewport});
render() {
const {viewport, settings} = this.state;
@ -81,7 +64,7 @@ export default class App extends Component {
dragToRotate={false}
mapboxApiAccessToken={MAPBOX_TOKEN} />
<ControlPanel containerComponent={this.props.containerComponent}
onViewportChange={this._easeTo} />
onViewportChange={this._goToViewport} />
</div>
);
}

View File

@ -38,9 +38,10 @@
"bowser": "^1.2.0",
"immutable": "*",
"mapbox-gl": "0.40.1",
"math.gl": ">= 1.0.0-alpha.8",
"mjolnir.js": ">=0.4.0",
"prop-types": "^15.5.7",
"viewport-mercator-project": "^4.0.1"
"viewport-mercator-project": ">= 4.2.0-alpha.2"
},
"devDependencies": {
"babel-cli": "^6.22.2",

View File

@ -6,6 +6,8 @@ import StaticMap from './static-map';
import {MAPBOX_LIMITS} from '../utils/map-state';
import {PerspectiveMercatorViewport} from 'viewport-mercator-project';
import TransitionManager from '../utils/transition-manager';
import {EventManager} from 'mjolnir.js';
import MapControls from '../utils/map-controls';
import config from '../config';
@ -31,6 +33,20 @@ const propTypes = Object.assign({}, StaticMap.propTypes, {
*/
onViewportChange: PropTypes.func,
/** Viewport transition **/
// transition duration for viewport change
transitionDuration: PropTypes.number,
// function called for each transition step, can be used to perform custom transitions.
transitionInterpolator: PropTypes.func,
// type of interruption of current transition on update.
transitionInterruption: PropTypes.number,
// easing function
transitionEasing: PropTypes.func,
// transition status update functions
onTransitionStart: PropTypes.func,
onTransitionInterrupt: PropTypes.func,
onTransitionEnd: PropTypes.func,
/** Enables control event handling */
// Scroll to zoom
scrollZoom: PropTypes.bool,
@ -100,22 +116,25 @@ const getDefaultCursor = ({isDragging, isHovering}) => isDragging ?
config.CURSOR.GRABBING :
(isHovering ? config.CURSOR.POINTER : config.CURSOR.GRAB);
const defaultProps = Object.assign({}, StaticMap.defaultProps, MAPBOX_LIMITS, {
onViewportChange: null,
onClick: null,
onHover: null,
const defaultProps = Object.assign({},
StaticMap.defaultProps, MAPBOX_LIMITS, TransitionManager.defaultProps,
{
onViewportChange: null,
onClick: null,
onHover: null,
scrollZoom: true,
dragPan: true,
dragRotate: true,
doubleClickZoom: true,
touchZoomRotate: true,
scrollZoom: true,
dragPan: true,
dragRotate: true,
doubleClickZoom: true,
touchZoomRotate: true,
clickRadius: 0,
getCursor: getDefaultCursor,
clickRadius: 0,
getCursor: getDefaultCursor,
visibilityConstraints: MAPBOX_LIMITS
});
visibilityConstraints: MAPBOX_LIMITS
}
);
const childContextTypes = {
viewport: PropTypes.instanceOf(PerspectiveMercatorViewport),
@ -167,10 +186,13 @@ export default class InteractiveMap extends PureComponent {
onStateChange: this._onInteractiveStateChange,
eventManager
}));
this._transitionManager = new TransitionManager(this.props);
}
componentWillUpdate(nextProps) {
this._mapControls.setOptions(nextProps);
this._transitionManager.processViewportChange(nextProps);
}
componentWillUnmount() {
@ -189,7 +211,7 @@ export default class InteractiveMap extends PureComponent {
}
// Checks a visibilityConstraints object to see if the map should be displayed
checkVisibilityConstraints(props) {
_checkVisibilityConstraints(props) {
const capitalize = s => s[0].toUpperCase() + s.slice(1);
const {visibilityConstraints} = props;
@ -290,11 +312,14 @@ export default class InteractiveMap extends PureComponent {
ref: this._eventCanvasLoaded,
style: eventCanvasStyle
},
createElement(StaticMap, Object.assign({}, this.props, {
visible: this.checkVisibilityConstraints(this.props),
ref: this._staticMapLoaded,
children: this._eventManager ? this.props.children : null
}))
createElement(StaticMap, Object.assign({}, this.props,
this._transitionManager && this._transitionManager.getViewportInTransition(),
{
visible: this._checkVisibilityConstraints(this.props),
ref: this._staticMapLoaded,
children: this._eventManager ? this.props.children : null
}
))
)
);
}

View File

@ -4,9 +4,14 @@ import BaseControl from './base-control';
import autobind from '../utils/autobind';
import MapState from '../utils/map-state';
import TransitionManager from '../utils/transition-manager';
import deprecateWarn from '../utils/deprecate-warn';
const LINEAR_TRANSITION_PROPS = Object.assign({}, TransitionManager.defaultProps, {
transitionDuration: 300
});
const propTypes = Object.assign({}, BaseControl.propTypes, {
// Custom className
className: PropTypes.string,
@ -45,7 +50,9 @@ export default class NavigationControl extends BaseControl {
const mapState = new MapState(Object.assign({}, viewport, opts));
// TODO(deprecate): remove this check when `onChangeViewport` gets deprecated
const onViewportChange = this.props.onChangeViewport || this.props.onViewportChange;
onViewportChange(mapState.getViewportProps());
const newViewport = Object.assign({}, mapState.getViewportProps(), LINEAR_TRANSITION_PROPS);
onViewportChange(newViewport);
}
_onZoomIn() {
@ -57,7 +64,7 @@ export default class NavigationControl extends BaseControl {
}
_onResetNorth() {
this._updateViewport({bearing: 0});
this._updateViewport({bearing: 0, pitch: 0});
}
_renderCompass() {

View File

@ -34,6 +34,10 @@ export {default as CanvasOverlay} from './overlays/canvas-overlay';
export {default as HTMLOverlay} from './overlays/html-overlay';
export {default as SVGOverlay} from './overlays/svg-overlay';
import {TRANSITION_EVENTS} from './utils/transition-manager';
import {viewportLinearInterpolator, viewportFlyToInterpolator}
from './utils/viewport-transition-utils';
// Utilities
// Experimental Features (May change in minor version bumps, use at your own risk)
@ -42,5 +46,8 @@ import autobind from './utils/autobind';
export const experimental = {
MapControls,
autobind
autobind,
TRANSITION_EVENTS,
viewportLinearInterpolator,
viewportFlyToInterpolator
};

View File

@ -18,7 +18,15 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
import MapState from '../utils/map-state';
import MapState from './map-state';
import TransitionManager from './transition-manager';
const NO_TRANSITION_PROPS = {
transitionDuration: 0
};
const LINEAR_TRANSITION_PROPS = Object.assign({}, TransitionManager.defaultProps, {
transitionDuration: 300
});
// EVENT HANDLING PARAMETERS
const PITCH_MOUSE_THRESHOLD = 5;
@ -99,9 +107,9 @@ export default class MapControls {
/* Callback util */
// formats map state and invokes callback function
updateViewport(newMapState, extraState = {}) {
updateViewport(newMapState, extraProps = {}, extraState = {}) {
const oldViewport = this.mapState.getViewportProps();
const newViewport = newMapState.getViewportProps();
const newViewport = Object.assign({}, newMapState.getViewportProps(), extraProps);
if (this.onViewportChange &&
Object.keys(newViewport).some(key => oldViewport[key] !== newViewport[key])) {
@ -182,7 +190,7 @@ export default class MapControls {
_onPanStart(event) {
const pos = this.getCenter(event);
const newMapState = this.mapState.panStart({pos}).rotateStart({pos});
return this.updateViewport(newMapState, {isDragging: true});
return this.updateViewport(newMapState, NO_TRANSITION_PROPS, {isDragging: true});
}
// Default handler for the `panmove` event.
@ -194,7 +202,7 @@ export default class MapControls {
// Default handler for the `panend` event.
_onPanEnd(event) {
const newMapState = this.mapState.panEnd().rotateEnd();
return this.updateViewport(newMapState, {isDragging: false});
return this.updateViewport(newMapState, null, {isDragging: false});
}
// Default handler for panning to move.
@ -205,7 +213,7 @@ export default class MapControls {
}
const pos = this.getCenter(event);
const newMapState = this.mapState.pan({pos});
return this.updateViewport(newMapState);
return this.updateViewport(newMapState, NO_TRANSITION_PROPS, {isDragging: true});
}
// Default handler for panning to rotate.
@ -237,7 +245,7 @@ export default class MapControls {
deltaScaleY = Math.min(1, Math.max(-1, deltaScaleY));
const newMapState = this.mapState.rotate({deltaScaleX, deltaScaleY});
return this.updateViewport(newMapState);
return this.updateViewport(newMapState, NO_TRANSITION_PROPS, {isDragging: true});
}
// Default handler for the `wheel` event.
@ -256,14 +264,14 @@ export default class MapControls {
}
const newMapState = this.mapState.zoom({pos, scale});
return this.updateViewport(newMapState);
return this.updateViewport(newMapState, NO_TRANSITION_PROPS);
}
// Default handler for the `pinchstart` event.
_onPinchStart(event) {
const pos = this.getCenter(event);
const newMapState = this.mapState.zoomStart({pos});
return this.updateViewport(newMapState, {isDragging: true});
return this.updateViewport(newMapState, NO_TRANSITION_PROPS, {isDragging: true});
}
// Default handler for the `pinch` event.
@ -274,13 +282,13 @@ export default class MapControls {
const pos = this.getCenter(event);
const {scale} = event;
const newMapState = this.mapState.zoom({pos, scale});
return this.updateViewport(newMapState);
return this.updateViewport(newMapState, NO_TRANSITION_PROPS, {isDragging: true});
}
// Default handler for the `pinchend` event.
_onPinchEnd(event) {
const newMapState = this.mapState.zoomEnd();
return this.updateViewport(newMapState, {isDragging: false});
return this.updateViewport(newMapState, null, {isDragging: false});
}
// Default handler for the `doubletap` event.
@ -292,7 +300,7 @@ export default class MapControls {
const isZoomOut = this.isFunctionKeyPressed(event);
const newMapState = this.mapState.zoom({pos, scale: isZoomOut ? 0.5 : 2});
return this.updateViewport(newMapState);
return this.updateViewport(newMapState, LINEAR_TRANSITION_PROPS);
}
/* eslint-disable complexity */
@ -351,7 +359,7 @@ export default class MapControls {
default:
return false;
}
return this.updateViewport(newMapState);
return this.updateViewport(newMapState, LINEAR_TRANSITION_PROPS);
}
/* eslint-enable complexity */
}

View File

@ -278,10 +278,16 @@ export default class MapState {
}
// Apply any constraints (mathematical or defined by _viewportProps) to map state
/* eslint-disable complexity */
_applyConstraints(props) {
// Normalize degrees
props.longitude = mod(props.longitude + 180, 360) - 180;
props.bearing = mod(props.bearing + 180, 360) - 180;
const {longitude, bearing} = props;
if (longitude < -180 || longitude > 180) {
props.longitude = mod(longitude + 180, 360) - 180;
}
if (bearing < -180 || bearing > 180) {
props.bearing = mod(bearing + 180, 360) - 180;
}
// Ensure zoom is within specified range
const {maxZoom, minZoom, zoom} = props;
@ -319,6 +325,7 @@ export default class MapState {
return props;
}
/* eslint-enable complexity */
// Returns {viewport, latitudeRange: [topY, bottomY]} in non-perspective mode
_getLatitudeRange(props) {

View File

@ -0,0 +1,181 @@
/* global requestAnimationFrame, cancelAnimationFrame */
import assert from 'assert';
import {
viewportLinearInterpolator,
extractViewportFrom,
areViewportsEqual
} from './viewport-transition-utils';
import MapState from './map-state';
const noop = () => {};
export const TRANSITION_EVENTS = {
BREAK: 1,
SNAP_TO_END: 2,
IGNORE: 3
};
const DEFAULT_PROPS = {
transitionDuration: 0,
transitionInterpolator: viewportLinearInterpolator,
transitionEasing: t => t,
transitionInterruption: TRANSITION_EVENTS.BREAK,
onTransitionStart: noop,
onTransitionInterrupt: noop,
onTransitionEnd: noop
};
const DEFAULT_STATE = {
animation: null,
viewport: null,
startViewport: null,
endViewport: null
};
export default class TransitionManager {
constructor(props) {
this.props = props;
this.state = DEFAULT_STATE;
this._onTransitionFrame = this._onTransitionFrame.bind(this);
}
// Returns current transitioned viewport.
getViewportInTransition() {
return this.state.viewport;
}
// Process the vewiport change, either ignore or trigger a new transiton.
processViewportChange(nextProps) {
// NOTE: Be cautious re-ordering statements in this function.
if (this._shouldIgnoreViewportChange(nextProps)) {
this.props = nextProps;
return;
}
const isTransitionInProgress = this._isTransitionInProgress();
if (this._isTransitionEnabled(nextProps)) {
const currentViewport = this.state.viewport || extractViewportFrom(this.props);
const endViewport = this.state.endViewport;
const startViewport = this.state.interruption === TRANSITION_EVENTS.SNAP_TO_END ?
(endViewport || currentViewport) :
currentViewport;
this._triggerTransition(startViewport, nextProps);
if (isTransitionInProgress) {
this.props.onTransitionInterrupt();
}
nextProps.onTransitionStart();
} else if (isTransitionInProgress) {
this.props.onTransitionInterrupt();
this._endTransition();
}
this.props = nextProps;
}
// Helper methods
_isTransitionInProgress() {
return this.state.viewport;
}
_isTransitionEnabled(props) {
return props.transitionDuration > 0;
}
_isUpdateDueToCurrentTransition(props) {
if (this.state.viewport) {
return areViewportsEqual(props, this.state.viewport);
}
return false;
}
_shouldIgnoreViewportChange(nextProps) {
// Ignore update if it is due to current active transition.
// Ignore update if it is requested to be ignored
if (this._isTransitionInProgress()) {
if (this.state.interruption === TRANSITION_EVENTS.IGNORE ||
this._isUpdateDueToCurrentTransition(nextProps)) {
return true;
}
} else if (!this._isTransitionEnabled(nextProps)) {
return true;
}
// Ignore if none of the viewport props changed.
if (areViewportsEqual(this.props, nextProps)) {
return true;
}
return false;
}
_triggerTransition(startViewport, nextProps) {
assert(nextProps.transitionDuration !== 0);
const endViewport = extractViewportFrom(nextProps);
cancelAnimationFrame(this.state.animation);
this.state = {
// Save current transition props
duration: nextProps.transitionDuration,
easing: nextProps.transitionEasing,
interpolator: nextProps.transitionInterpolator,
interruption: nextProps.transitionInterruption,
startTime: Date.now(),
startViewport,
endViewport,
animation: null,
viewport: startViewport
};
this._onTransitionFrame();
}
_onTransitionFrame() {
// _updateViewport() may cancel the animation
this.state.animation = requestAnimationFrame(this._onTransitionFrame);
this._updateViewport();
}
_endTransition() {
cancelAnimationFrame(this.state.animation);
this.state = DEFAULT_STATE;
}
_updateViewport() {
// NOTE: Be cautious re-ordering statements in this function.
const currentTime = Date.now();
const {startTime, duration, easing, interpolator, startViewport, endViewport} = this.state;
let shouldEnd = false;
let t = (currentTime - startTime) / duration;
if (t >= 1) {
t = 1;
shouldEnd = true;
}
t = easing(t);
const viewport = interpolator(startViewport, endViewport, t);
// Normalize viewport props
const mapState = new MapState(Object.assign({}, this.props, viewport));
this.state.viewport = mapState.getViewportProps();
if (this.props.onViewportChange) {
this.props.onViewportChange(this.state.viewport);
}
if (shouldEnd) {
this._endTransition();
this.props.onTransitionEnd();
}
}
}
TransitionManager.defaultProps = DEFAULT_PROPS;

View File

@ -0,0 +1,153 @@
/* eslint max-statements: ["error", 50] */
import {projectFlat, unprojectFlat} from 'viewport-mercator-project';
import {Vector2} from 'math.gl';
const EPSILON = 0.01;
const VIEWPORT_PROPS = ['longitude', 'latitude', 'zoom', 'bearing', 'pitch',
'position', 'width', 'height'];
const VIEWPORT_INTERPOLATION_PROPS =
['longitude', 'latitude', 'zoom', 'bearing', 'pitch', 'position'];
/** Util functions */
function lerp(start, end, step) {
if (Array.isArray(start)) {
return start.map((element, index) => {
return lerp(element, end[index], step);
});
}
return step * end + (1 - step) * start;
}
function zoomToScale(zoom) {
return Math.pow(2, zoom);
}
function scaleToZoom(scale) {
return Math.log2(scale);
}
export function extractViewportFrom(props) {
const viewport = {};
VIEWPORT_PROPS.forEach((key) => {
if (typeof props[key] !== 'undefined') {
viewport[key] = props[key];
}
});
return viewport;
}
/* eslint-disable max-depth */
export function areViewportsEqual(startViewport, endViewport) {
for (const p of VIEWPORT_INTERPOLATION_PROPS) {
if (Array.isArray(startViewport[p])) {
for (let i = 0; i < startViewport[p].length; ++i) {
if (startViewport[p][i] !== endViewport[p][i]) {
return false;
}
}
} else if (startViewport[p] !== endViewport[p]) {
return false;
}
}
return true;
}
/* eslint-enable max-depth */
/**
* Performs linear interpolation of two viewports.
* @param {Object} startViewport - object containing starting viewport parameters.
* @param {Object} endViewport - object containing ending viewport parameters.
* @param {Number} t - interpolation step.
* @return {Object} - interpolated viewport for given step.
*/
export function viewportLinearInterpolator(startViewport, endViewport, t) {
const viewport = {};
for (const p of VIEWPORT_INTERPOLATION_PROPS) {
const startValue = startViewport[p];
const endValue = endViewport[p];
// TODO: 'position' is not always specified
if (typeof startValue !== 'undefined' && typeof endValue !== 'undefined') {
viewport[p] = lerp(startValue, endValue, t);
}
}
return viewport;
}
/**
* This method adapts mapbox-gl-js Map#flyTo animation so it can be used in
* react/redux architecture.
* mapbox-gl-js flyTo : https://www.mapbox.com/mapbox-gl-js/api/#map#flyto.
* It implements Smooth and efficient zooming and panning. algorithm by
* "Jarke J. van Wijk and Wim A.A. Nuij"
*
* @param {Object} startViewport - object containing starting viewport parameters.
* @param {Object} endViewport - object containing ending viewport parameters.
* @param {Number} t - interpolation step.
* @return {Object} - interpolated viewport for given step.
*/
export function viewportFlyToInterpolator(startViewport, endViewport, t) {
// Equations from above paper are referred where needed.
const viewport = {};
// TODO: add this as an option for applications.
const rho = 1.414;
const startZoom = startViewport.zoom;
const startCenter = [startViewport.longitude, startViewport.latitude];
const startScale = zoomToScale(startZoom);
const endZoom = endViewport.zoom;
const endCenter = [endViewport.longitude, endViewport.latitude];
const scale = zoomToScale(endZoom - startZoom);
const startCenterXY = new Vector2(projectFlat(startCenter, startScale));
const endCenterXY = new Vector2(projectFlat(endCenter, startScale));
const uDelta = endCenterXY.subtract(startCenterXY);
const w0 = Math.max(startViewport.width, startViewport.height);
const w1 = w0 / scale;
const u1 = Math.sqrt((uDelta.x * uDelta.x) + (uDelta.y * uDelta.y));
// u0 is treated as '0' in Eq (9).
// Linearly interpolate 'bearing' and 'pitch'
for (const p of ['bearing', 'pitch']) {
const startValue = startViewport[p];
const endValue = endViewport[p];
viewport[p] = lerp(startValue, endValue, t);
}
// If change in center is too small, do linear interpolaiton.
if (Math.abs(u1) < EPSILON) {
for (const p of ['latitude', 'longitude', 'zoom']) {
const startValue = startViewport[p];
const endValue = endViewport[p];
viewport[p] = lerp(startValue, endValue, t);
}
return viewport;
}
// Implement Equation (9) from above algorithm.
const rho2 = rho * rho;
const b0 = (w1 * w1 - w0 * w0 + rho2 * rho2 * u1 * u1) / (2 * w0 * rho2 * u1);
const b1 = (w1 * w1 - w0 * w0 - rho2 * rho2 * u1 * u1) / (2 * w1 * rho2 * u1);
const r0 = Math.log(Math.sqrt(b0 * b0 + 1) - b0);
const r1 = Math.log(Math.sqrt(b1 * b1 + 1) - b1);
const S = (r1 - r0) / rho;
const s = t * S;
const w = (Math.cosh(r0) / Math.cosh(r0 + rho * s));
const u = w0 * ((Math.cosh(r0) * Math.tanh(r0 + rho * s) - Math.sinh(r0)) / rho2) / u1;
const scaleIncrement = 1 / w; // Using w method for scaling.
const newZoom = startZoom + scaleToZoom(scaleIncrement);
const newCenter = unprojectFlat(
(startCenterXY.add(uDelta.scale(u))).scale(scaleIncrement),
zoomToScale(newZoom));
viewport.longitude = newCenter[0];
viewport.latitude = newCenter[1];
viewport.zoom = newZoom;
return viewport;
}