From eea28410529fd2ffde58e2f6d1dff6962974146c Mon Sep 17 00:00:00 2001 From: 1chandu Date: Thu, 5 Sep 2019 22:31:22 -0700 Subject: [PATCH] Viewport flyTo Interpolation: add support for auto duration and other options. (#866) * Viewport flyTo Interpoloation: add support for auto duration, curve, speed, screenSpeed and maxDuration --- docs/advanced/viewport-transition.md | 1 + docs/components/fly-to-interpolator.md | 11 ++- examples/viewport-animation/src/app.js | 4 +- .../npm/viewport-mercator-project_vx.x.x.js | 39 +++++++--- package.json | 2 +- src/components/interactive-map.js | 2 +- src/components/popup.js | 5 +- src/utils/math-utils.js | 3 +- src/utils/transition-manager.js | 11 ++- .../transition/transition-interpolator.js | 11 +++ .../viewport-fly-to-interpolator.js | 42 ++++++++++- test/src/utils/transition-manager.spec.js | 53 ++++++++++++- .../viewport-fly-to-interpolator.spec.js | 75 +++++++++++++++---- yarn.lock | 8 +- 14 files changed, 219 insertions(+), 48 deletions(-) diff --git a/docs/advanced/viewport-transition.md b/docs/advanced/viewport-transition.md index 26bf4b9b..1e405d52 100644 --- a/docs/advanced/viewport-transition.md +++ b/docs/advanced/viewport-transition.md @@ -112,6 +112,7 @@ Remarks: + `transitionEasing` + `transitionInterruption` - The default interaction/transition behavior can always be intercepted and overwritten in the handler for `onViewportChange`. However, if a transition is in progress, the properties that are being transitioned (e.g. longitude and latitude) should not be manipulated, otherwise the change will be interpreted as an interruption of the transition. +- When using `FlyToInterpolator` for `transitionInterpolator`, `transitionDuration` can be set to `'auto'` where actual duration is auto calculated based on start and end viewports and is linear to the distance between them. This duration can be further customized using `speed` parameter to `FlyToInterpolator` constructor. ## Transition Interpolators diff --git a/docs/components/fly-to-interpolator.md b/docs/components/fly-to-interpolator.md index 18b26bc9..102005e3 100644 --- a/docs/components/fly-to-interpolator.md +++ b/docs/components/fly-to-interpolator.md @@ -15,9 +15,16 @@ import ReactMapGL, {FlyToInterpolator} from 'react-map-gl'; ##### constructor -`new FlyToInterpolator()` +`new FlyToInterpolator([options])` + +Parameters: +- `options` {Object} (optional) + + `curve` (Number, optional, default: 1.414) - The zooming "curve" that will occur along the flight path. + - `speed` (Number, optional, default: 1.2) - The average speed of the animation defined in relation to `options.curve`, it linearly affects the duration, higher speed returns smaller durations and vice versa. + - `screenSpeed` (Number, optional) - The average speed of the animation measured in screenfuls per second. Similar to `opts.speed` it linearly affects the duration, when specified `opts.speed` is ignored. + - `maxDuration` (Number, optional) - Maximum duration in milliseconds, if calculated duration exceeds this value, `0` is returned. + ## Source [viewport-fly-to-interpolator.js](https://github.com/uber/react-map-gl/tree/4.1-release/src/utils/transition/viewport-fly-to-interpolator.js) - diff --git a/examples/viewport-animation/src/app.js b/examples/viewport-animation/src/app.js index 7fd0a028..89c85e2f 100644 --- a/examples/viewport-animation/src/app.js +++ b/examples/viewport-animation/src/app.js @@ -27,8 +27,8 @@ export default class App extends Component { longitude, latitude, zoom: 11, - transitionInterpolator: new FlyToInterpolator(), - transitionDuration: 3000 + transitionInterpolator: new FlyToInterpolator({speed: 2}), + transitionDuration: 'auto' }); }; diff --git a/flow-typed/npm/viewport-mercator-project_vx.x.x.js b/flow-typed/npm/viewport-mercator-project_vx.x.x.js index 66db3d8e..f4d06997 100644 --- a/flow-typed/npm/viewport-mercator-project_vx.x.x.js +++ b/flow-typed/npm/viewport-mercator-project_vx.x.x.js @@ -11,26 +11,43 @@ type Viewport = { bearing: number }; +type FlyToInterpolatorOpts = { + curve?: number, + speed?: number, + screenSpeed?: number, + maxDuraiton?: number +}; + declare module 'viewport-mercator-project' { declare export class WebMercatorViewport { - constructor(Viewport) : WebMercatorViewport; + constructor(Viewport): WebMercatorViewport; - width: number, - height: number, - longitude: number, - latitude: number, - zoom: number, - pitch: number, - bearing: number, + width: number; + height: number; + longitude: number; + latitude: number; + zoom: number; + pitch: number; + bearing: number; project(xyz: Array): Array; unproject(xyz: Array): Array; getMapCenterByLngLatPosition({lngLat: Array, pos: Array}): Array; - fitBounds(bounds: [[Number,Number],[Number,Number]], options: any): WebMercatorViewport; + fitBounds(bounds: [[Number, Number], [Number, Number]], options: any): WebMercatorViewport; } - declare export function normalizeViewportProps(props: Viewport) : Viewport; - declare export function flyToViewport(startProps: Viewport, endProps: Viewport, t: number) : Viewport; + declare export function normalizeViewportProps(props: Viewport): Viewport; + declare export function flyToViewport( + startProps: Viewport, + endProps: Viewport, + t: number, + opts?: FlyToInterpolatorOpts + ): Viewport; + declare export function getFlyToDuration( + startProps: Viewport, + endProps: Viewport, + opts?: FlyToInterpolatorOpts + ): number; declare export default typeof WebMercatorViewport; } diff --git a/package.json b/package.json index c9e420ae..939761cb 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "mjolnir.js": "^2.2.0", "prop-types": "^15.7.2", "react-virtualized-auto-sizer": "^1.0.2", - "viewport-mercator-project": "^6.1.0" + "viewport-mercator-project": "^6.2.1" }, "devDependencies": { "@babel/plugin-proposal-class-properties": "^7.4.4", diff --git a/src/components/interactive-map.js b/src/components/interactive-map.js index 60dbf701..d6259f9a 100644 --- a/src/components/interactive-map.js +++ b/src/components/interactive-map.js @@ -39,7 +39,7 @@ const propTypes = Object.assign({}, StaticMap.propTypes, { /** Viewport transition **/ // transition duration for viewport change - transitionDuration: PropTypes.number, + transitionDuration: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), // TransitionInterpolator instance, can be used to perform custom transitions. transitionInterpolator: PropTypes.object, // type of interruption of current transition on update. diff --git a/src/components/popup.js b/src/components/popup.js index c90dbc08..6fb1dcd5 100644 --- a/src/components/popup.js +++ b/src/components/popup.js @@ -20,8 +20,9 @@ // THE SOFTWARE. import {createElement, createRef} from 'react'; import PropTypes from 'prop-types'; -import BaseControl from './base-control'; +import type {MjolnirEvent} from 'mjolnir.js'; +import BaseControl from './base-control'; import {getDynamicPosition, ANCHOR_POSITION} from '../utils/dynamic-position'; import type {BaseControlProps} from './base-control'; @@ -159,7 +160,7 @@ export default class Popup extends BaseControl { return style; } - _onClick = evt => { + _onClick = (evt: MjolnirEvent) => { if (this.props.captureClick) { evt.stopPropagation(); } diff --git a/src/utils/math-utils.js b/src/utils/math-utils.js index c78c1016..4792f06b 100644 --- a/src/utils/math-utils.js +++ b/src/utils/math-utils.js @@ -1,11 +1,12 @@ // @flow -const EPSILON = 1e-9; +const EPSILON = 1e-7; // Returns true if value is either an array or a typed array function isArray(value: any): boolean { return Array.isArray(value) || ArrayBuffer.isView(value); } +// TODO: use math.gl export function equals(a: any, b: any): boolean { if (a === b) { return true; diff --git a/src/utils/transition-manager.js b/src/utils/transition-manager.js index 25ce4ba2..c94cc1f0 100644 --- a/src/utils/transition-manager.js +++ b/src/utils/transition-manager.js @@ -133,7 +133,10 @@ export default class TransitionManager { } _isTransitionEnabled(props: ViewportProps): boolean { - return props.transitionDuration > 0 && Boolean(props.transitionInterpolator); + const {transitionDuration, transitionInterpolator} = props; + return ( + (transitionDuration > 0 || transitionDuration === 'auto') && Boolean(transitionInterpolator) + ); } _isUpdateDueToCurrentTransition(props: ViewportProps): boolean { @@ -170,6 +173,10 @@ export default class TransitionManager { cancelAnimationFrame(this._animationFrame); } + // update transitionDuration for 'auto' mode + const {transitionInterpolator} = endProps; + const duration = transitionInterpolator.getDuration(startProps, endProps); + const initialProps = endProps.transitionInterpolator.initializeProps(startProps, endProps); const interactionState = { @@ -182,7 +189,7 @@ export default class TransitionManager { this.state = { // Save current transition props - duration: endProps.transitionDuration, + duration, easing: endProps.transitionEasing, interpolator: endProps.transitionInterpolator, interruption: endProps.transitionInterruption, diff --git a/src/utils/transition/transition-interpolator.js b/src/utils/transition/transition-interpolator.js index 10327573..117928e6 100644 --- a/src/utils/transition/transition-interpolator.js +++ b/src/utils/transition/transition-interpolator.js @@ -1,6 +1,7 @@ // @flow import {equals} from '../math-utils'; import assert from '../assert'; +import type {MapStateProps} from '../map-state'; export default class TransitionInterpolator { propNames: Array = []; @@ -47,4 +48,14 @@ export default class TransitionInterpolator { interpolateProps(startProps: any, endProps: any, t: number): any { assert(false, 'interpolateProps is not implemented'); } + + /** + * Returns transition duration + * @param startProps {object} - a list of starting viewport props + * @param endProps {object} - a list of target viewport props + * @returns {Number} - transition duration in milliseconds + */ + getDuration(startProps: MapStateProps, endProps: MapStateProps) { + return endProps.transitionDuration; + } } diff --git a/src/utils/transition/viewport-fly-to-interpolator.js b/src/utils/transition/viewport-fly-to-interpolator.js index 5dbecf07..a804b81d 100644 --- a/src/utils/transition/viewport-fly-to-interpolator.js +++ b/src/utils/transition/viewport-fly-to-interpolator.js @@ -2,7 +2,7 @@ import assert from '../assert'; import TransitionInterpolator from './transition-interpolator'; -import {flyToViewport} from 'viewport-mercator-project'; +import {flyToViewport, getFlyToDuration} from 'viewport-mercator-project'; import {isValid, getEndValueByShortestPath} from './transition-utils'; import {lerp} from '../math-utils'; @@ -11,6 +11,18 @@ import type {MapStateProps} from '../map-state'; const VIEWPORT_TRANSITION_PROPS = ['longitude', 'latitude', 'zoom', 'bearing', 'pitch']; const REQUIRED_PROPS = ['latitude', 'longitude', 'zoom', 'width', 'height']; const LINEARLY_INTERPOLATED_PROPS = ['bearing', 'pitch']; +const DEFAULT_OPTS = { + speed: 1.2, + curve: 1.414 + // screenSpeed and maxDuration are used only if specified +}; + +type FlyToInterpolatorProps = { + curve?: number, + speed?: number, + screenSpeed?: number, + maxDuraiton?: number +}; /** * This class adapts mapbox-gl-js Map#flyTo animation so it can be used in @@ -20,8 +32,24 @@ const LINEARLY_INTERPOLATED_PROPS = ['bearing', 'pitch']; * "Jarke J. van Wijk and Wim A.A. Nuij" */ export default class ViewportFlyToInterpolator extends TransitionInterpolator { + speed: number; propNames = VIEWPORT_TRANSITION_PROPS; + /** + * @param props {Object} + - `props.curve` (Number, optional, default: 1.414) - The zooming "curve" that will occur along the flight path. + - `props.speed` (Number, optional, default: 1.2) - The average speed of the animation defined in relation to `options.curve`, it linearly affects the duration, higher speed returns smaller durations and vice versa. + - `props.screenSpeed` (Number, optional) - The average speed of the animation measured in screenfuls per second. Similar to `opts.speed` it linearly affects the duration, when specified `opts.speed` is ignored. + - `props.maxDuration` (Number, optional) - Maximum duration in milliseconds, if calculated duration exceeds this value, `0` is returned. + */ + constructor(props: FlyToInterpolatorProps = {}) { + super(); + + this.props = Object.assign({}, DEFAULT_OPTS, props); + } + + props: FlyToInterpolatorProps; + initializeProps(startProps: MapStateProps, endProps: MapStateProps) { const startViewportProps = {}; const endViewportProps = {}; @@ -49,7 +77,7 @@ export default class ViewportFlyToInterpolator extends TransitionInterpolator { } interpolateProps(startProps: MapStateProps, endProps: MapStateProps, t: number) { - const viewport = flyToViewport(startProps, endProps, t); + const viewport = flyToViewport(startProps, endProps, t, this.props); // Linearly interpolate 'bearing' and 'pitch' if exist. for (const key of LINEARLY_INTERPOLATED_PROPS) { @@ -58,4 +86,14 @@ export default class ViewportFlyToInterpolator extends TransitionInterpolator { return viewport; } + + // computes the transition duration + getDuration(startProps: MapStateProps, endProps: MapStateProps) { + let {transitionDuration} = endProps; + if (transitionDuration === 'auto') { + // auto calculate duration based on start and end props + transitionDuration = getFlyToDuration(startProps, endProps, this.props); + } + return transitionDuration; + } } diff --git a/test/src/utils/transition-manager.spec.js b/test/src/utils/transition-manager.spec.js index 0b0054bd..848f1f3e 100644 --- a/test/src/utils/transition-manager.spec.js +++ b/test/src/utils/transition-manager.spec.js @@ -48,7 +48,7 @@ const TEST_CASES = [ zoom: 12, pitch: 0, bearing: 0, - transitionDuration: 200 + transitionDuration: 'auto' }, // transitionDuration is 0 { @@ -109,9 +109,21 @@ const TEST_CASES = [ pitch: 0, bearing: 0, transitionDuration: 200 + }, + // viewport change interrupting transition + { + width: 100, + height: 100, + longitude: -122.45, + latitude: 37.78, + zoom: 12, + pitch: 0, + bearing: 0, + transitionInterpolator: new ViewportFlyToInterpolator({speed: 50}), + transitionDuration: 'auto' } ], - expect: [true, true] + expect: [true, true, true] } ]; @@ -185,8 +197,8 @@ test('TransitionManager#callbacks', t => { }); setTimeout(() => { - t.is(startCount, 2, 'onTransitionStart() called twice'); - t.is(interruptCount, 1, 'onTransitionInterrupt() called once'); + t.is(startCount, 3, 'onTransitionStart() called twice'); + t.is(interruptCount, 2, 'onTransitionInterrupt() called once'); t.is(endCount, 1, 'onTransitionEnd() called once'); t.ok(updateCount > 2, 'onViewportChange() called'); t.end(); @@ -416,3 +428,36 @@ test('TransitionManager#TRANSITION_EVENTS', t => { }); t.end(); }); + +test('TransitionManager#auto#duration', t => { + const mergeProps = props => Object.assign({}, TransitionManager.defaultProps, props); + const initialProps = { + width: 100, + height: 100, + longitude: -122.45, + latitude: 37.78, + zoom: 12, + pitch: 0, + bearing: 0, + transitionDuration: 200 + }; + const transitionManager = new TransitionManager(mergeProps(initialProps)); + transitionManager.processViewportChange( + mergeProps({ + width: 100, + height: 100, + longitude: -100.45, // changed + latitude: 37.78, + zoom: 12, + pitch: 0, + bearing: 0, + transitionInterpolator: new ViewportFlyToInterpolator(), + transitionDuration: 'auto' + }) + ); + t.ok( + Number.isFinite(transitionManager.state.duration) && transitionManager.state.duration > 0, + 'should set duraiton when using "auto" mode' + ); + t.end(); +}); diff --git a/test/src/utils/transition/viewport-fly-to-interpolator.spec.js b/test/src/utils/transition/viewport-fly-to-interpolator.spec.js index f81b8274..8b0364b6 100644 --- a/test/src/utils/transition/viewport-fly-to-interpolator.spec.js +++ b/test/src/utils/transition/viewport-fly-to-interpolator.spec.js @@ -2,6 +2,21 @@ import test from 'tape-catch'; import {ViewportFlyToInterpolator} from 'react-map-gl/utils/transition'; import {toLowPrecision} from 'react-map-gl/test/test-utils'; +const START_PROPS = { + width: 800, + height: 600, + longitude: -122.45, + latitude: 37.78, + zoom: 12 +}; + +const END_PROPS = { + width: 800, + height: 600, + longitude: -74, + latitude: 40.7, + zoom: 11 +}; /* eslint-disable max-len */ const TEST_CASES = [ { @@ -12,20 +27,8 @@ const TEST_CASES = [ }, { title: 'optional prop fallback', - startProps: { - width: 800, - height: 600, - longitude: -122.45, - latitude: 37.78, - zoom: 12 - }, - endProps: { - width: 800, - height: 600, - longitude: -74, - latitude: 40.7, - zoom: 11 - }, + startProps: START_PROPS, + endProps: END_PROPS, expect: { start: { width: 800, @@ -136,7 +139,32 @@ const TEST_CASES = [ ]; /* eslint-enable max-len */ -test('LinearInterpolator#initializeProps', t => { +const DURATION_TEST_CASES = [ + { + title: 'fixed duration', + endProps: {transitionDuration: 100}, + expected: 100 + }, + { + title: 'auto duration', + endProps: {transitionDuration: 'auto'}, + expected: 7325.794 + }, + { + title: 'high speed', + opts: {speed: 10}, + endProps: {transitionDuration: 'auto'}, + expected: 879.0953 + }, + { + title: 'high curve', + opts: {curve: 8}, + endProps: {transitionDuration: 'auto'}, + expected: 2016.924 + } +]; + +test('ViewportFlyToInterpolator#initializeProps', t => { const interpolator = new ViewportFlyToInterpolator(); TEST_CASES.forEach(testCase => { @@ -152,7 +180,7 @@ test('LinearInterpolator#initializeProps', t => { t.end(); }); -test('LinearInterpolator#interpolateProps', t => { +test('ViewportFlyToInterpolator#interpolateProps', t => { const interpolator = new ViewportFlyToInterpolator(); TEST_CASES.filter(testCase => testCase.transition).forEach(testCase => { @@ -168,3 +196,18 @@ test('LinearInterpolator#interpolateProps', t => { t.end(); }); + +test('ViewportFlyToInterpolator#getDuration', t => { + DURATION_TEST_CASES.forEach(testCase => { + const interpolator = new ViewportFlyToInterpolator(testCase.opts); + t.equal( + toLowPrecision( + interpolator.getDuration(START_PROPS, Object.assign({}, END_PROPS, testCase.endProps)), + 7 + ), + testCase.expected, + `${testCase.title}: should receive correct duration` + ); + }); + t.end(); +}); diff --git a/yarn.lock b/yarn.lock index cb2dbadc..7cd8b1dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9392,10 +9392,10 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" -viewport-mercator-project@^6.1.0: - version "6.1.1" - resolved "https://registry.yarnpkg.com/viewport-mercator-project/-/viewport-mercator-project-6.1.1.tgz#d7b2cb3cb772b819f1daab17cf4019102a9102a6" - integrity sha512-nI0GEmXnESwZxWSJuaQkdCnvOv6yckUfqqFbNB8KWVbQY3eUExVM4ZziqCVVs5mNznLjDF1auj6HLW5D5DKcng== +viewport-mercator-project@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/viewport-mercator-project/-/viewport-mercator-project-6.2.1.tgz#4d4cf376bdcf027467d0417615a0257a6316e63c" + integrity sha512-Ns0KExngwGkX/QZCAzYbqh3TTI8LKeO8pOphZN4mZmp/+wO/HqDacbztwXMdrTLy57ToolT4XrAH2NhuK7Nyfw== dependencies: "@babel/runtime" "^7.0.0" gl-matrix "^3.0.0"