diff --git a/examples/viewport-animation/src/app.js b/examples/viewport-animation/src/app.js index 1e188de3..39a21dbe 100644 --- a/examples/viewport-animation/src/app.js +++ b/examples/viewport-animation/src/app.js @@ -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} /> + onViewportChange={this._goToViewport} /> ); } diff --git a/package.json b/package.json index 585c1795..ddc66e74 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/interactive-map.js b/src/components/interactive-map.js index a53289f1..e816c036 100644 --- a/src/components/interactive-map.js +++ b/src/components/interactive-map.js @@ -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 + } + )) ) ); } diff --git a/src/components/navigation-control.js b/src/components/navigation-control.js index 14051c6a..976bc6d4 100644 --- a/src/components/navigation-control.js +++ b/src/components/navigation-control.js @@ -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() { diff --git a/src/index.js b/src/index.js index 6065b343..408f2e5b 100644 --- a/src/index.js +++ b/src/index.js @@ -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 }; diff --git a/src/utils/map-controls.js b/src/utils/map-controls.js index 96ec6141..9ba6b6a0 100644 --- a/src/utils/map-controls.js +++ b/src/utils/map-controls.js @@ -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 */ } diff --git a/src/utils/map-state.js b/src/utils/map-state.js index d5dc634a..ad4f6814 100644 --- a/src/utils/map-state.js +++ b/src/utils/map-state.js @@ -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) { diff --git a/src/utils/transition-manager.js b/src/utils/transition-manager.js new file mode 100644 index 00000000..dd6b4453 --- /dev/null +++ b/src/utils/transition-manager.js @@ -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; diff --git a/src/utils/viewport-transition-utils.js b/src/utils/viewport-transition-utils.js new file mode 100644 index 00000000..1e02f989 --- /dev/null +++ b/src/utils/viewport-transition-utils.js @@ -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; +}