Skip to content

Commit 9d31831

Browse files
fix(core): preserve GlobeView off-globe pan
1 parent ad2cdb9 commit 9d31831

4 files changed

Lines changed: 199 additions & 28 deletions

File tree

modules/core/src/controllers/globe-controller.ts

Lines changed: 84 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ import {clamp} from '@math.gl/core';
66
import Controller from './controller';
77

88
import {MapState, MapStateProps} from './map-controller';
9+
import type {MapStateInternal} from './map-controller';
910
import {mod} from '../utils/math-utils';
1011
import LinearInterpolator from '../transitions/linear-interpolator';
1112
import {zoomAdjust, GLOBE_RADIUS} from '../viewports/globe-viewport';
13+
import type GlobeViewport from '../viewports/globe-viewport';
1214

1315
import {MAX_LATITUDE} from '@math.gl/web-mercator';
1416

@@ -26,18 +28,88 @@ function pixelsToDegrees(pixels: number, zoom: number = 0): number {
2628
return radians * RADIANS_TO_DEGREES;
2729
}
2830

31+
type GlobeStateInternal = MapStateInternal & {
32+
startPanPos?: [number, number];
33+
startPanOnGlobe?: boolean;
34+
};
35+
36+
function unprojectOnGlobe(
37+
viewport: GlobeViewport,
38+
pos?: [number, number]
39+
): [number, number] | undefined {
40+
const lngLat = pos && viewport.unproject(pos, {fallback: false});
41+
return lngLat ? [lngLat[0], lngLat[1]] : undefined;
42+
}
43+
2944
class GlobeState extends MapState {
3045
constructor(
31-
options: MapStateProps & {
32-
makeViewport: (props: Record<string, any>) => any;
33-
}
46+
options: MapStateProps &
47+
GlobeStateInternal & {
48+
makeViewport: (props: Record<string, any>) => any;
49+
}
3450
) {
51+
const {startPanPos, startPanOnGlobe, ...mapStateOptions} = options;
3552
// Disable MapState's default web-mercator bounds; globe covers the whole earth.
36-
// Pan (panStart/pan/panEnd) is inherited from MapState so the grabbed lng/lat
37-
// stays under the cursor — matching MapView. The old globe-specific delta-pan
38-
// produced the wrong on-screen speed and yanked the center at zoom > 12 (where
39-
// WebMercatorViewport takes over rendering but ignored the delta third-arg).
40-
super({...options, normalize: false});
53+
super({...mapStateOptions, normalize: false});
54+
55+
if (startPanPos !== undefined) {
56+
(this as any)._state.startPanPos = startPanPos;
57+
}
58+
if (startPanOnGlobe !== undefined) {
59+
(this as any)._state.startPanOnGlobe = startPanOnGlobe;
60+
}
61+
}
62+
63+
panStart({pos}: {pos: [number, number]}): GlobeState {
64+
const {longitude, latitude, zoom} = this.getViewportProps();
65+
const viewport = this.makeViewport(this.getViewportProps()) as GlobeViewport;
66+
const startPanLngLat = unprojectOnGlobe(viewport, pos);
67+
68+
return this._getUpdatedState({
69+
startPanLngLat: startPanLngLat || [longitude, latitude],
70+
startPanPos: pos,
71+
startPanOnGlobe: Boolean(startPanLngLat),
72+
startZoom: zoom
73+
}) as GlobeState;
74+
}
75+
76+
pan({pos, startPos}: {pos: [number, number]; startPos?: [number, number]}): GlobeState {
77+
const state = this.getState() as GlobeStateInternal;
78+
const viewport = this.makeViewport(this.getViewportProps()) as GlobeViewport;
79+
const startPanOnGlobe =
80+
state.startPanOnGlobe ?? (startPos ? viewport.isPointOnGlobe(startPos) : true);
81+
82+
if (startPanOnGlobe) {
83+
const startPanLngLat = state.startPanLngLat || unprojectOnGlobe(viewport, startPos);
84+
if (!startPanLngLat) {
85+
return this;
86+
}
87+
return this._getUpdatedState(viewport.panByLngLat(startPanLngLat, pos)) as GlobeState;
88+
}
89+
90+
const startPanPos = state.startPanPos || startPos;
91+
if (!startPanPos) {
92+
return this;
93+
}
94+
95+
const {longitude, latitude, zoom} = this.getViewportProps();
96+
const startPanLngLat = state.startPanLngLat || [longitude, latitude];
97+
const startZoom = state.startZoom ?? zoom;
98+
const newProps = viewport.panByPosition(
99+
[startPanLngLat[0], startPanLngLat[1], startZoom],
100+
pos,
101+
startPanPos
102+
);
103+
return this._getUpdatedState(newProps) as GlobeState;
104+
}
105+
106+
panEnd(): GlobeState {
107+
return this._getUpdatedState({
108+
startPanLngLat: null,
109+
startPanPos: null,
110+
startPanOnGlobe: null,
111+
startZoom: null
112+
}) as GlobeState;
41113
}
42114

43115
zoom({
@@ -50,10 +122,11 @@ class GlobeState extends MapState {
50122
scale: number;
51123
}): MapState {
52124
let {startZoom, startZoomLngLat} = this.getState();
125+
const viewport = this.makeViewport(this.getViewportProps()) as GlobeViewport;
53126

54127
if (!startZoomLngLat) {
55128
startZoom = this.getViewportProps().zoom;
56-
startZoomLngLat = this._unproject(startPos) || this._unproject(pos);
129+
startZoomLngLat = unprojectOnGlobe(viewport, startPos) || unprojectOnGlobe(viewport, pos);
57130
}
58131

59132
const zoom = this._constrainZoom((startZoom as number) + Math.log2(scale));
@@ -63,10 +136,10 @@ class GlobeState extends MapState {
63136
return this._getUpdatedState({zoom});
64137
}
65138

66-
const zoomedViewport = this.makeViewport({...this.getViewportProps(), zoom});
139+
const zoomedViewport = this.makeViewport({...this.getViewportProps(), zoom}) as GlobeViewport;
67140
return this._getUpdatedState({
68141
zoom,
69-
...zoomedViewport.panByPosition(startZoomLngLat, pos)
142+
...zoomedViewport.panByLngLat(startZoomLngLat, pos)
70143
});
71144
}
72145

modules/core/src/viewports/globe-viewport.ts

Lines changed: 69 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,19 @@ export default class GlobeViewport extends Viewport {
191191
];
192192
}
193193

194+
unproject(xyz: number[], options?: {topLeft?: boolean; targetZ?: number}): number[];
194195
unproject(
195196
xyz: number[],
196-
{topLeft = true, targetZ}: {topLeft?: boolean; targetZ?: number} = {}
197-
): number[] {
197+
options: {topLeft?: boolean; targetZ?: number; fallback: false}
198+
): number[] | null;
199+
unproject(
200+
xyz: number[],
201+
{
202+
topLeft = true,
203+
targetZ,
204+
fallback = true
205+
}: {topLeft?: boolean; targetZ?: number; fallback?: boolean} = {}
206+
): number[] | null {
198207
const [x, y, z] = xyz;
199208

200209
const y2 = topLeft ? y : this.height - y;
@@ -207,23 +216,21 @@ export default class GlobeViewport extends Viewport {
207216
} else {
208217
// since we don't know the correct projected z value for the point,
209218
// unproject two points to get a line and then find the point on that line that intersects with the sphere
210-
const coord0 = transformVector(pixelUnprojectionMatrix, [x, y2, -1, 1]);
211-
const coord1 = transformVector(pixelUnprojectionMatrix, [x, y2, 1, 1]);
212-
213-
const lt = ((targetZ || 0) / EARTH_RADIUS + 1) * GLOBE_RADIUS;
214-
const lSqr = vec3.sqrLen(vec3.sub([], coord0, coord1));
215-
const l0Sqr = vec3.sqrLen(coord0);
216-
const l1Sqr = vec3.sqrLen(coord1);
217-
const sSqr = (4 * l0Sqr * l1Sqr - (lSqr - l0Sqr - l1Sqr) ** 2) / 16;
218-
const dSqr = (4 * sSqr) / lSqr;
219-
const r0 = Math.sqrt(l0Sqr - dSqr);
220-
const discriminant = lt * lt - dSqr;
219+
const {coord0, coord1, lSqr, r0, discriminant} = this._getRaySphereIntersection(
220+
x,
221+
y2,
222+
targetZ
223+
);
221224

222225
if (discriminant < 0) {
226+
if (!fallback) {
227+
return null;
228+
}
223229
// Ray misses the sphere — project the closest-approach point onto the sphere surface
224230
const tClosest = r0 / Math.sqrt(lSqr);
225231
const closest = vec3.lerp([], coord0, coord1, tClosest);
226232
const len = vec3.len(closest);
233+
const lt = ((targetZ || 0) / EARTH_RADIUS + 1) * GLOBE_RADIUS;
227234
coord = len > 0 ? vec3.scale([], closest, lt / len) : [0, 0, lt];
228235
} else {
229236
const dr = Math.sqrt(discriminant);
@@ -239,6 +246,15 @@ export default class GlobeViewport extends Viewport {
239246
return Number.isFinite(targetZ) ? [X, Y, targetZ as number] : [X, Y];
240247
}
241248

249+
isPointOnGlobe(
250+
pixel: number[],
251+
{topLeft = true, targetZ}: {topLeft?: boolean; targetZ?: number} = {}
252+
): boolean {
253+
const [x, y] = pixel;
254+
const y2 = topLeft ? y : this.height - y;
255+
return this._getRaySphereIntersection(x, y2, targetZ).discriminant >= 0;
256+
}
257+
242258
projectPosition(xyz: number[]): [number, number, number] {
243259
const [lng, lat, Z = 0] = xyz;
244260
const lambda = lng * DEGREES_TO_RADIANS;
@@ -269,13 +285,35 @@ export default class GlobeViewport extends Viewport {
269285
return xyz as [number, number];
270286
}
271287

288+
/**
289+
* Pan the globe using delta-based movement.
290+
* Used when the pointer starts outside the globe so dragging spins the globe.
291+
*/
292+
panByPosition(coords: number[], pixel: number[], startPixel?: number[]): GlobeViewportOptions {
293+
if (!startPixel) {
294+
return this.panByLngLat(coords, pixel);
295+
}
296+
297+
const [startLng, startLat, startZoom] = coords;
298+
// Scale rotation speed inversely with zoom to keep off-globe drags predictable.
299+
const scale = Math.pow(2, this.zoom - zoomAdjust(this.latitude));
300+
const rotationSpeed = 0.25 / scale;
301+
302+
const longitude = startLng + rotationSpeed * (startPixel[0] - pixel[0]);
303+
const latitude = Math.max(
304+
Math.min(startLat - rotationSpeed * (startPixel[1] - pixel[1]), MAX_LATITUDE),
305+
-MAX_LATITUDE
306+
);
307+
const zoom = startZoom + zoomAdjust(latitude) - zoomAdjust(startLat);
308+
return {longitude, latitude, zoom};
309+
}
310+
272311
/**
273312
* Pan the globe so that a geographic position appears at a given screen pixel.
274-
* Shifts center by (coords - unproject(pixel)) — i.e. keeps the grabbed lng/lat
275-
* under the cursor. Used for drag-pan and zoom-toward-cursor.
313+
* Used for on-globe drag-pan and zoom-toward-cursor.
276314
*/
277-
panByPosition(coords: number[], pixel: number[]): GlobeViewportOptions {
278-
const currentAtPixel = this.unproject(pixel);
315+
panByLngLat(coords: number[], pixel: number[]): GlobeViewportOptions {
316+
const currentAtPixel = this.unproject(pixel, {fallback: false});
279317
if (!currentAtPixel) {
280318
return {longitude: this.longitude, latitude: this.latitude};
281319
}
@@ -288,6 +326,20 @@ export default class GlobeViewport extends Viewport {
288326
const zoom = this.zoom + zoomAdjust(latitude) - zoomAdjust(this.latitude);
289327
return {longitude, latitude, zoom};
290328
}
329+
330+
private _getRaySphereIntersection(x: number, y: number, targetZ?: number) {
331+
const coord0 = transformVector(this.pixelUnprojectionMatrix, [x, y, -1, 1]);
332+
const coord1 = transformVector(this.pixelUnprojectionMatrix, [x, y, 1, 1]);
333+
const lt = ((targetZ || 0) / EARTH_RADIUS + 1) * GLOBE_RADIUS;
334+
const lSqr = vec3.sqrLen(vec3.sub([], coord0, coord1));
335+
const l0Sqr = vec3.sqrLen(coord0);
336+
const l1Sqr = vec3.sqrLen(coord1);
337+
const sSqr = (4 * l0Sqr * l1Sqr - (lSqr - l0Sqr - l1Sqr) ** 2) / 16;
338+
const dSqr = (4 * sSqr) / lSqr;
339+
const r0 = Math.sqrt(l0Sqr - dSqr);
340+
341+
return {coord0, coord1, lSqr, r0, discriminant: lt * lt - dSqr};
342+
}
291343
}
292344

293345
export function zoomAdjust(latitude: number): number {

test/modules/core/controllers/view-states.spec.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
OrbitController,
99
FirstPersonController,
1010
_GlobeController as GlobeController,
11+
_GlobeViewport as GlobeViewport,
1112
OrbitViewport,
1213
OrthographicController,
1314
Viewport
@@ -251,6 +252,36 @@ test('GlobeViewState#pitch and bearing constraints', () => {
251252
).toBeTruthy();
252253
});
253254

255+
test('GlobeViewState#pan starts outside globe with delta spin', () => {
256+
const GlobeViewState = new GlobeController({} as any).ControllerState;
257+
const makeViewport = (props: any) => new GlobeViewport(props);
258+
const startPos: [number, number] = [0, 0];
259+
const pos: [number, number] = [100, 0];
260+
const startProps = {
261+
width: 800,
262+
height: 600,
263+
longitude: 0,
264+
latitude: 0,
265+
zoom: 0,
266+
makeViewport
267+
};
268+
const viewport = makeViewport(startProps);
269+
270+
expect(viewport.isPointOnGlobe(startPos), 'test starts off globe').toBe(false);
271+
expect(viewport.isPointOnGlobe([startProps.width / 2, startProps.height / 2])).toBe(true);
272+
273+
const viewState = new GlobeViewState(startProps);
274+
const pannedState = viewState.panStart({pos: startPos}).pan({pos});
275+
const viewportProps = pannedState.getViewportProps();
276+
const rotationSpeed = 0.25 / Math.pow(2, startProps.zoom - Math.log2(Math.PI));
277+
278+
expect(viewportProps.longitude, 'off-globe pan uses delta longitude').toBeCloseTo(
279+
rotationSpeed * (startPos[0] - pos[0])
280+
);
281+
expect(viewportProps.latitude, 'horizontal off-globe pan keeps latitude').toBe(0);
282+
expect(viewportProps.zoom, 'off-globe pan preserves zoom at the equator').toBe(0);
283+
});
284+
254285
test('OrbitViewState', () => {
255286
const OrbitViewState = new OrbitController({} as any).ControllerState;
256287
const makeViewport = (props: any) => new OrbitViewport(props);

test/modules/core/viewports/globe-viewport.spec.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,21 @@ test('GlobeViewport#project, unproject', () => {
168168
config.EPSILON = oldEpsilon;
169169
});
170170

171+
test('GlobeViewport#isPointOnGlobe', () => {
172+
const viewport = new GlobeViewport({
173+
width: 800,
174+
height: 600,
175+
latitude: 0,
176+
longitude: 0,
177+
zoom: 0
178+
});
179+
180+
expect(viewport.isPointOnGlobe([viewport.width / 2, viewport.height / 2])).toBe(true);
181+
expect(viewport.isPointOnGlobe([0, 0])).toBe(false);
182+
expect(viewport.unproject([0, 0]), 'unproject falls back to a surface point').toBeTruthy();
183+
expect(viewport.unproject([0, 0], {fallback: false}), 'fallback can be disabled').toBeNull();
184+
});
185+
171186
test('GlobeViewport#getBounds', () => {
172187
for (const testCase of TEST_VIEWPORTS) {
173188
const bounds = new GlobeViewport(testCase).getBounds();

0 commit comments

Comments
 (0)