Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions modules/core/src/controllers/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
/* eslint-disable max-statements, complexity */
import TransitionManager, {TransitionProps} from './transition-manager';
import LinearInterpolator from '../transitions/linear-interpolator';
import FlyToInterpolator from '../transitions/fly-to-interpolator';
import {IViewState} from './view-state';
import {ConstructorOf} from '../types/types';
import {deepEqual} from '../utils/deep-equal';
Expand Down Expand Up @@ -1033,15 +1034,27 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
}

// Enables Transitions on double-click/tap and key-down events.
return opts
? {
if (opts) {
return {
...transition,
transitionInterpolator: new LinearInterpolator({
...opts,
...(transition.transitionInterpolator as LinearInterpolator).opts,
makeViewport: this.controllerState.makeViewport
})
}
: transition;
};
}

if (transition.transitionInterpolator instanceof FlyToInterpolator) {
return {
...transition,
transitionInterpolator: new FlyToInterpolator({
...transition.transitionInterpolator.opts,
makeViewport: this.controllerState.makeViewport
})
};
}

return transition;
}
}
164 changes: 138 additions & 26 deletions modules/core/src/transitions/fly-to-interpolator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@
// Copyright (c) vis.gl contributors

import TransitionInterpolator from './transition-interpolator';
import {lerp} from '@math.gl/core';
import {clamp, lerp, vec3} from '@math.gl/core';

import type Viewport from '../viewports/viewport';
import GlobeViewport, {zoomAdjust} from '../viewports/globe-viewport';
import {Globe} from '../viewports/globe-utils';
import {flyToViewport, getFlyToDuration} from '@math.gl/web-mercator';

const LINEARLY_INTERPOLATED_PROPS = {
Expand All @@ -17,6 +20,105 @@ const DEFAULT_OPTS = {
curve: 1.414
};

type FlyToInterpolatorOptions = {
/** The zooming "curve" that will occur along the flight path. Default 1.414 */
curve?: number;
/** 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. Default 1.2 */
speed?: number;
/** 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. */
screenSpeed?: number;
/** Maximum duration in milliseconds, if calculated duration exceeds this value, `0` is returned. */
maxDuration?: number;
/** Construct the active viewport for the current view state. Injected by controllers when available. */
makeViewport?: (props: Record<string, any>) => Viewport | null | undefined;
};

type FlyToViewportProps = {
width: number;
height: number;
longitude: number;
latitude: number;
zoom: number;
bearing?: number;
pitch?: number;
};

function getFlyToViewportProps(props: Record<string, any>): FlyToViewportProps {
return {
width: props.width,
height: props.height,
longitude: props.longitude,
latitude: props.latitude,
zoom: props.zoom,
bearing: props.bearing,
pitch: props.pitch
};
}

function getFlyToOptions(
opts: FlyToInterpolator['opts']
): Omit<FlyToInterpolatorOptions, 'makeViewport'> {
const {makeViewport, ...flyToOptions} = opts;
return flyToOptions;
}

function isGlobeViewportTransition(
startProps: Record<string, any>,
endProps: Record<string, any>,
makeViewport?: (props: Record<string, any>) => Viewport | null | undefined
): boolean {
if (!makeViewport) {
return false;
}

return (
makeViewport(startProps) instanceof GlobeViewport ||
makeViewport(endProps) instanceof GlobeViewport
);
}

function normalizeAngle(value: number): number {
return ((((value + 180) % 360) + 360) % 360) - 180;
}

function lerpAngle(start: number, end: number, t: number): number {
return normalizeAngle(start + normalizeAngle(end - start) * t);
}

function slerpPosition(start: number[], end: number[], t: number): number[] {
const dot = clamp(vec3.dot(start, end), -1, 1);
const omega = Math.acos(dot);

if (omega < 1e-6) {
return vec3.normalize(
[],
[
lerp(start[0], end[0], t),
lerp(start[1], end[1], t),
lerp(start[2], end[2], t)
]
) as number[];
}

const sinOmega = Math.sin(omega);
if (Math.abs(sinOmega) < 1e-6) {
let axis = vec3.cross([], start, [0, 0, 1]);
if (vec3.len(axis) < 1e-6) {
axis = vec3.cross([], start, [0, 1, 0]);
}
vec3.normalize(axis, axis);
return Globe.rotate(start, axis, Math.PI * t);
}

const startScale = Math.sin((1 - t) * omega) / sinOmega;
const endScale = Math.sin(t * omega) / sinOmega;
return [
start[0] * startScale + end[0] * endScale,
start[1] * startScale + end[1] * endScale,
start[2] * startScale + end[2] * endScale
];
}

/**
* This class adapts mapbox-gl-js Map#flyTo animation so it can be used in
* react/redux architecture.
Expand All @@ -25,25 +127,10 @@ const DEFAULT_OPTS = {
* "Jarke J. van Wijk and Wim A.A. Nuij"
*/
export default class FlyToInterpolator extends TransitionInterpolator {
opts: {
curve: number;
speed: number;
screenSpeed?: number;
maxDuration?: number;
};
opts: Required<Pick<FlyToInterpolatorOptions, 'curve' | 'speed'>> &
Omit<FlyToInterpolatorOptions, 'curve' | 'speed'>;

constructor(
opts: {
/** The zooming "curve" that will occur along the flight path. Default 1.414 */
curve?: number;
/** 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. Default 1.2 */
speed?: number;
/** 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. */
screenSpeed?: number;
/** Maximum duration in milliseconds, if calculated duration exceeds this value, `0` is returned. */
maxDuration?: number;
} = {}
) {
constructor(opts: FlyToInterpolatorOptions = {}) {
super({
compare: ['longitude', 'latitude', 'zoom', 'bearing', 'pitch', 'position'],
extract: ['width', 'height', 'longitude', 'latitude', 'zoom', 'bearing', 'pitch', 'position'],
Expand All @@ -53,30 +140,55 @@ export default class FlyToInterpolator extends TransitionInterpolator {
}

interpolateProps(startProps, endProps, t) {
const viewport = flyToViewport(startProps, endProps, t, this.opts);
const flyToOptions = getFlyToOptions(this.opts);
const useGlobeViewport = isGlobeViewportTransition(startProps, endProps, this.opts.makeViewport);
const viewport = useGlobeViewport
? this._interpolateGlobeProps(startProps, endProps, t, flyToOptions)
: flyToViewport(startProps, endProps, t, flyToOptions);

// Linearly interpolate 'bearing', 'pitch' and 'position'.
// If they are not supplied, they are interpreted as zeros in viewport calculation
// (fallback defined in WebMercatorViewport)
// Because there is no guarantee that the current controller's ViewState normalizes
// these props, safe guard is needed to avoid generating NaNs
for (const key in LINEARLY_INTERPOLATED_PROPS) {
viewport[key] = lerp(
startProps[key] || LINEARLY_INTERPOLATED_PROPS[key],
endProps[key] || LINEARLY_INTERPOLATED_PROPS[key],
t
);
const startValue = startProps[key] || LINEARLY_INTERPOLATED_PROPS[key];
const endValue = endProps[key] || LINEARLY_INTERPOLATED_PROPS[key];
viewport[key] =
useGlobeViewport && key === 'bearing'
? lerpAngle(startValue, endValue, t)
: lerp(startValue, endValue, t);
}

return viewport;
}

private _interpolateGlobeProps(startProps, endProps, t, flyToOptions) {
const startPosition = Globe.toPosition(startProps.longitude, startProps.latitude);
const endPosition = Globe.toPosition(endProps.longitude, endProps.latitude);
const [longitude, latitude] = Globe.toLngLat(slerpPosition(startPosition, endPosition, t));
const flyTo = flyToViewport(
getFlyToViewportProps(startProps),
getFlyToViewportProps(endProps),
t,
flyToOptions
);
const flyToScaleZoom = flyTo.zoom - zoomAdjust(flyTo.latitude, true);
const zoom = flyToScaleZoom + zoomAdjust(latitude, true);

return {
longitude,
latitude,
zoom
};
}

// computes the transition duration
getDuration(startProps, endProps) {
let {transitionDuration} = endProps;
if (transitionDuration === 'auto') {
// auto calculate duration based on start and end props
transitionDuration = getFlyToDuration(startProps, endProps, this.opts);
transitionDuration = getFlyToDuration(startProps, endProps, getFlyToOptions(this.opts));
}
return transitionDuration;
}
Expand Down
69 changes: 69 additions & 0 deletions test/modules/core/transitions/fly-to-interpolator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

import {test, expect} from 'vitest';
import FlyToInterpolator from '@deck.gl/core/transitions/fly-to-interpolator';
import GlobeViewport, {zoomAdjust} from '@deck.gl/core/viewports/globe-viewport';
import {toLowPrecision} from '@deck.gl/test-utils/vitest';
import {flyToViewport} from '@math.gl/web-mercator';

const START_PROPS = {
width: 800,
Expand Down Expand Up @@ -149,6 +151,73 @@ test('ViewportFlyToInterpolator#interpolateProps', () => {
});
});

test('ViewportFlyToInterpolator follows great-circle path for GlobeViewport', () => {
const interpolator = new FlyToInterpolator({
makeViewport: props => new GlobeViewport(props)
});
const midpoint = interpolator.interpolateProps(
{
width: 1000,
height: 800,
longitude: 179,
latitude: 0,
zoom: 4,
bearing: 0,
pitch: 0
},
{
width: 1000,
height: 800,
longitude: -179,
latitude: 0,
zoom: 4,
bearing: 0,
pitch: 0
},
0.5
);

expect(
Math.abs(Math.abs(midpoint.longitude) - 180),
'antimeridian flight should not travel back through Greenwich'
).toBeLessThan(1e-6);
expect(midpoint.latitude).toBeCloseTo(0);
});

test('ViewportFlyToInterpolator preserves GlobeViewport scale', () => {
const interpolator = new FlyToInterpolator({
makeViewport: props => new GlobeViewport(props)
});
const start = {
width: 1000,
height: 800,
longitude: 0,
latitude: 0,
zoom: 4,
bearing: 0,
pitch: 0
};
const end = {
width: 1000,
height: 800,
longitude: 25,
latitude: 60,
zoom: 6,
bearing: 30,
pitch: 50
};
const midpoint = interpolator.interpolateProps(start, end, 0.5);
const flyTo = flyToViewport(start, end, 0.5, {speed: 1.2, curve: 1.414});
const expectedScaleZoom = flyTo.zoom - zoomAdjust(flyTo.latitude, true);

expect(
midpoint.zoom - zoomAdjust(midpoint.latitude, true),
'zoom follows the FlyTo curve while staying continuous in globe scale space'
).toBeCloseTo(expectedScaleZoom);
expect(midpoint.bearing).toBeCloseTo(15);
expect(midpoint.pitch).toBeCloseTo(25);
});

test('ViewportFlyToInterpolator#getDuration', () => {
DURATION_TEST_CASES.forEach(testCase => {
const interpolator = new FlyToInterpolator(testCase.opts);
Expand Down
Loading