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
This commit is contained in:
1chandu 2019-09-05 22:31:22 -07:00 committed by Xintong Xia
parent f0218d9009
commit eea2841052
14 changed files with 219 additions and 48 deletions

View File

@ -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

View File

@ -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)

View File

@ -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'
});
};

View File

@ -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<number>): Array<number>;
unproject(xyz: Array<number>): Array<number>;
getMapCenterByLngLatPosition({lngLat: Array<number>, pos: Array<number>}): Array<number>;
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;
}

View File

@ -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",

View File

@ -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.

View File

@ -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<PopupProps, *, HTMLDivElement> {
return style;
}
_onClick = evt => {
_onClick = (evt: MjolnirEvent) => {
if (this.props.captureClick) {
evt.stopPropagation();
}

View File

@ -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;

View File

@ -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,

View File

@ -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<string> = [];
@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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();
});

View File

@ -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();
});

View File

@ -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"