Skip to content

Commit 905323c

Browse files
feat(core): make FlyToInterpolator globe-aware
1 parent c87afb2 commit 905323c

3 files changed

Lines changed: 224 additions & 30 deletions

File tree

modules/core/src/controllers/controller.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
/* eslint-disable max-statements, complexity */
66
import TransitionManager, {TransitionProps} from './transition-manager';
77
import LinearInterpolator from '../transitions/linear-interpolator';
8+
import FlyToInterpolator from '../transitions/fly-to-interpolator';
89
import {IViewState} from './view-state';
910
import {ConstructorOf} from '../types/types';
1011
import {deepEqual} from '../utils/deep-equal';
@@ -1033,15 +1034,27 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
10331034
}
10341035

10351036
// Enables Transitions on double-click/tap and key-down events.
1036-
return opts
1037-
? {
1037+
if (opts) {
1038+
return {
10381039
...transition,
10391040
transitionInterpolator: new LinearInterpolator({
10401041
...opts,
10411042
...(transition.transitionInterpolator as LinearInterpolator).opts,
10421043
makeViewport: this.controllerState.makeViewport
10431044
})
1044-
}
1045-
: transition;
1045+
};
1046+
}
1047+
1048+
if (transition.transitionInterpolator instanceof FlyToInterpolator) {
1049+
return {
1050+
...transition,
1051+
transitionInterpolator: new FlyToInterpolator({
1052+
...transition.transitionInterpolator.opts,
1053+
makeViewport: this.controllerState.makeViewport
1054+
})
1055+
};
1056+
}
1057+
1058+
return transition;
10461059
}
10471060
}

modules/core/src/transitions/fly-to-interpolator.ts

Lines changed: 138 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
// Copyright (c) vis.gl contributors
44

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

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

1013
const LINEARLY_INTERPOLATED_PROPS = {
@@ -17,6 +20,105 @@ const DEFAULT_OPTS = {
1720
curve: 1.414
1821
};
1922

23+
type FlyToInterpolatorOptions = {
24+
/** The zooming "curve" that will occur along the flight path. Default 1.414 */
25+
curve?: number;
26+
/** 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 */
27+
speed?: number;
28+
/** 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. */
29+
screenSpeed?: number;
30+
/** Maximum duration in milliseconds, if calculated duration exceeds this value, `0` is returned. */
31+
maxDuration?: number;
32+
/** Construct the active viewport for the current view state. Injected by controllers when available. */
33+
makeViewport?: (props: Record<string, any>) => Viewport | null | undefined;
34+
};
35+
36+
type FlyToViewportProps = {
37+
width: number;
38+
height: number;
39+
longitude: number;
40+
latitude: number;
41+
zoom: number;
42+
bearing?: number;
43+
pitch?: number;
44+
};
45+
46+
function getFlyToViewportProps(props: Record<string, any>): FlyToViewportProps {
47+
return {
48+
width: props.width,
49+
height: props.height,
50+
longitude: props.longitude,
51+
latitude: props.latitude,
52+
zoom: props.zoom,
53+
bearing: props.bearing,
54+
pitch: props.pitch
55+
};
56+
}
57+
58+
function getFlyToOptions(
59+
opts: FlyToInterpolator['opts']
60+
): Omit<FlyToInterpolatorOptions, 'makeViewport'> {
61+
const {makeViewport, ...flyToOptions} = opts;
62+
return flyToOptions;
63+
}
64+
65+
function isGlobeViewportTransition(
66+
startProps: Record<string, any>,
67+
endProps: Record<string, any>,
68+
makeViewport?: (props: Record<string, any>) => Viewport | null | undefined
69+
): boolean {
70+
if (!makeViewport) {
71+
return false;
72+
}
73+
74+
return (
75+
makeViewport(startProps) instanceof GlobeViewport ||
76+
makeViewport(endProps) instanceof GlobeViewport
77+
);
78+
}
79+
80+
function normalizeAngle(value: number): number {
81+
return ((((value + 180) % 360) + 360) % 360) - 180;
82+
}
83+
84+
function lerpAngle(start: number, end: number, t: number): number {
85+
return normalizeAngle(start + normalizeAngle(end - start) * t);
86+
}
87+
88+
function slerpPosition(start: number[], end: number[], t: number): number[] {
89+
const dot = clamp(vec3.dot(start, end), -1, 1);
90+
const omega = Math.acos(dot);
91+
92+
if (omega < 1e-6) {
93+
return vec3.normalize(
94+
[],
95+
[
96+
lerp(start[0], end[0], t),
97+
lerp(start[1], end[1], t),
98+
lerp(start[2], end[2], t)
99+
]
100+
) as number[];
101+
}
102+
103+
const sinOmega = Math.sin(omega);
104+
if (Math.abs(sinOmega) < 1e-6) {
105+
let axis = vec3.cross([], start, [0, 0, 1]);
106+
if (vec3.len(axis) < 1e-6) {
107+
axis = vec3.cross([], start, [0, 1, 0]);
108+
}
109+
vec3.normalize(axis, axis);
110+
return Globe.rotate(start, axis, Math.PI * t);
111+
}
112+
113+
const startScale = Math.sin((1 - t) * omega) / sinOmega;
114+
const endScale = Math.sin(t * omega) / sinOmega;
115+
return [
116+
start[0] * startScale + end[0] * endScale,
117+
start[1] * startScale + end[1] * endScale,
118+
start[2] * startScale + end[2] * endScale
119+
];
120+
}
121+
20122
/**
21123
* This class adapts mapbox-gl-js Map#flyTo animation so it can be used in
22124
* react/redux architecture.
@@ -25,25 +127,10 @@ const DEFAULT_OPTS = {
25127
* "Jarke J. van Wijk and Wim A.A. Nuij"
26128
*/
27129
export default class FlyToInterpolator extends TransitionInterpolator {
28-
opts: {
29-
curve: number;
30-
speed: number;
31-
screenSpeed?: number;
32-
maxDuration?: number;
33-
};
130+
opts: Required<Pick<FlyToInterpolatorOptions, 'curve' | 'speed'>> &
131+
Omit<FlyToInterpolatorOptions, 'curve' | 'speed'>;
34132

35-
constructor(
36-
opts: {
37-
/** The zooming "curve" that will occur along the flight path. Default 1.414 */
38-
curve?: number;
39-
/** 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 */
40-
speed?: number;
41-
/** 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. */
42-
screenSpeed?: number;
43-
/** Maximum duration in milliseconds, if calculated duration exceeds this value, `0` is returned. */
44-
maxDuration?: number;
45-
} = {}
46-
) {
133+
constructor(opts: FlyToInterpolatorOptions = {}) {
47134
super({
48135
compare: ['longitude', 'latitude', 'zoom', 'bearing', 'pitch', 'position'],
49136
extract: ['width', 'height', 'longitude', 'latitude', 'zoom', 'bearing', 'pitch', 'position'],
@@ -53,30 +140,55 @@ export default class FlyToInterpolator extends TransitionInterpolator {
53140
}
54141

55142
interpolateProps(startProps, endProps, t) {
56-
const viewport = flyToViewport(startProps, endProps, t, this.opts);
143+
const flyToOptions = getFlyToOptions(this.opts);
144+
const useGlobeViewport = isGlobeViewportTransition(startProps, endProps, this.opts.makeViewport);
145+
const viewport = useGlobeViewport
146+
? this._interpolateGlobeProps(startProps, endProps, t, flyToOptions)
147+
: flyToViewport(startProps, endProps, t, flyToOptions);
57148

58149
// Linearly interpolate 'bearing', 'pitch' and 'position'.
59150
// If they are not supplied, they are interpreted as zeros in viewport calculation
60151
// (fallback defined in WebMercatorViewport)
61152
// Because there is no guarantee that the current controller's ViewState normalizes
62153
// these props, safe guard is needed to avoid generating NaNs
63154
for (const key in LINEARLY_INTERPOLATED_PROPS) {
64-
viewport[key] = lerp(
65-
startProps[key] || LINEARLY_INTERPOLATED_PROPS[key],
66-
endProps[key] || LINEARLY_INTERPOLATED_PROPS[key],
67-
t
68-
);
155+
const startValue = startProps[key] || LINEARLY_INTERPOLATED_PROPS[key];
156+
const endValue = endProps[key] || LINEARLY_INTERPOLATED_PROPS[key];
157+
viewport[key] =
158+
useGlobeViewport && key === 'bearing'
159+
? lerpAngle(startValue, endValue, t)
160+
: lerp(startValue, endValue, t);
69161
}
70162

71163
return viewport;
72164
}
73165

166+
private _interpolateGlobeProps(startProps, endProps, t, flyToOptions) {
167+
const startPosition = Globe.toPosition(startProps.longitude, startProps.latitude);
168+
const endPosition = Globe.toPosition(endProps.longitude, endProps.latitude);
169+
const [longitude, latitude] = Globe.toLngLat(slerpPosition(startPosition, endPosition, t));
170+
const flyTo = flyToViewport(
171+
getFlyToViewportProps(startProps),
172+
getFlyToViewportProps(endProps),
173+
t,
174+
flyToOptions
175+
);
176+
const flyToScaleZoom = flyTo.zoom - zoomAdjust(flyTo.latitude, true);
177+
const zoom = flyToScaleZoom + zoomAdjust(latitude, true);
178+
179+
return {
180+
longitude,
181+
latitude,
182+
zoom
183+
};
184+
}
185+
74186
// computes the transition duration
75187
getDuration(startProps, endProps) {
76188
let {transitionDuration} = endProps;
77189
if (transitionDuration === 'auto') {
78190
// auto calculate duration based on start and end props
79-
transitionDuration = getFlyToDuration(startProps, endProps, this.opts);
191+
transitionDuration = getFlyToDuration(startProps, endProps, getFlyToOptions(this.opts));
80192
}
81193
return transitionDuration;
82194
}

test/modules/core/transitions/fly-to-interpolator.spec.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44

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

911
const START_PROPS = {
1012
width: 800,
@@ -149,6 +151,73 @@ test('ViewportFlyToInterpolator#interpolateProps', () => {
149151
});
150152
});
151153

154+
test('ViewportFlyToInterpolator follows great-circle path for GlobeViewport', () => {
155+
const interpolator = new FlyToInterpolator({
156+
makeViewport: props => new GlobeViewport(props)
157+
});
158+
const midpoint = interpolator.interpolateProps(
159+
{
160+
width: 1000,
161+
height: 800,
162+
longitude: 179,
163+
latitude: 0,
164+
zoom: 4,
165+
bearing: 0,
166+
pitch: 0
167+
},
168+
{
169+
width: 1000,
170+
height: 800,
171+
longitude: -179,
172+
latitude: 0,
173+
zoom: 4,
174+
bearing: 0,
175+
pitch: 0
176+
},
177+
0.5
178+
);
179+
180+
expect(
181+
Math.abs(Math.abs(midpoint.longitude) - 180),
182+
'antimeridian flight should not travel back through Greenwich'
183+
).toBeLessThan(1e-6);
184+
expect(midpoint.latitude).toBeCloseTo(0);
185+
});
186+
187+
test('ViewportFlyToInterpolator preserves GlobeViewport scale', () => {
188+
const interpolator = new FlyToInterpolator({
189+
makeViewport: props => new GlobeViewport(props)
190+
});
191+
const start = {
192+
width: 1000,
193+
height: 800,
194+
longitude: 0,
195+
latitude: 0,
196+
zoom: 4,
197+
bearing: 0,
198+
pitch: 0
199+
};
200+
const end = {
201+
width: 1000,
202+
height: 800,
203+
longitude: 25,
204+
latitude: 60,
205+
zoom: 6,
206+
bearing: 30,
207+
pitch: 50
208+
};
209+
const midpoint = interpolator.interpolateProps(start, end, 0.5);
210+
const flyTo = flyToViewport(start, end, 0.5, {speed: 1.2, curve: 1.414});
211+
const expectedScaleZoom = flyTo.zoom - zoomAdjust(flyTo.latitude, true);
212+
213+
expect(
214+
midpoint.zoom - zoomAdjust(midpoint.latitude, true),
215+
'zoom follows the FlyTo curve while staying continuous in globe scale space'
216+
).toBeCloseTo(expectedScaleZoom);
217+
expect(midpoint.bearing).toBeCloseTo(15);
218+
expect(midpoint.pitch).toBeCloseTo(25);
219+
});
220+
152221
test('ViewportFlyToInterpolator#getDuration', () => {
153222
DURATION_TEST_CASES.forEach(testCase => {
154223
const interpolator = new FlyToInterpolator(testCase.opts);

0 commit comments

Comments
 (0)