Skip to content
Open
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
2 changes: 2 additions & 0 deletions modules/core/src/controllers/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ export type ControllerOptions = {
};
/** Drag behavior without pressing function keys, one of `pan` and `rotate`. */
dragMode?: 'pan' | 'rotate';
/** Zoom anchor, one of `center` and `pointer`. Default depends on the controller. */
zoomAround?: 'center' | 'pointer';
/** Enable inertia after panning/pinching. If a number is provided, indicates the duration of time over which the velocity reduces to zero, in milliseconds. Default `false`. */
inertia?: boolean | number;
/** Bounding box of content that the controller is constrained in */
Expand Down
81 changes: 76 additions & 5 deletions modules/core/src/controllers/globe-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {MapState, MapStateProps} from './map-controller';
import type {MapStateInternal} from './map-controller';
import {mod} from '../utils/math-utils';
import LinearInterpolator from '../transitions/linear-interpolator';
import {zoomAdjust, GLOBE_RADIUS} from '../viewports/globe-viewport';
import GlobeViewport, {zoomAdjust, GLOBE_RADIUS} from '../viewports/globe-viewport';
import {
Globe,
type CameraFrame,
Expand All @@ -35,26 +35,31 @@ function pixelsToDegrees(pixels: number, zoom: number = 0): number {
return radians * RADIANS_TO_DEGREES;
}

type GlobeZoomAround = 'center' | 'pointer';

type GlobeStateInternal = MapStateInternal & {
startPanPos?: [number, number];
startPanCameraFrame?: CameraFrame;
startPanAngularRate?: number;
/** When true, bearing is held fixed during pan (north stays up) */
startPanLockBearing?: boolean;
zoomAround?: GlobeZoomAround;
};

class GlobeState extends MapState {
constructor(
options: MapStateProps &
GlobeStateInternal & {
makeViewport: (props: Record<string, any>) => any;
zoomAround?: GlobeZoomAround;
}
) {
const {
startPanPos,
startPanCameraFrame,
startPanAngularRate,
startPanLockBearing,
zoomAround,
...mapStateOptions
} = options;
mapStateOptions.normalize = false;
Expand All @@ -65,6 +70,7 @@ class GlobeState extends MapState {
if (startPanCameraFrame !== undefined) s.startPanCameraFrame = startPanCameraFrame;
if (startPanAngularRate !== undefined) s.startPanAngularRate = startPanAngularRate;
if (startPanLockBearing !== undefined) s.startPanLockBearing = startPanLockBearing;
if (zoomAround !== undefined) s.zoomAround = zoomAround;
}

panStart({pos}: {pos: [number, number]}): GlobeState {
Expand Down Expand Up @@ -142,10 +148,57 @@ class GlobeState extends MapState {
}) as GlobeState;
}

zoom({scale}: {scale: number}): MapState {
const startZoom = this.getState().startZoom || this.getViewportProps().zoom;
const zoom = startZoom + Math.log2(scale);
return this._getUpdatedState({zoom});
zoomStart({pos}: {pos: [number, number]}): GlobeState {
const startZoomLngLat = this._shouldZoomAroundPointer()
? this._unprojectOnGlobe(pos)
: undefined;

return this._getUpdatedState({
startZoomLngLat,
startZoom: this.getViewportProps().zoom
}) as GlobeState;
}

zoom({
pos,
startPos,
scale
}: {
pos: [number, number];
startPos?: [number, number];
scale: number;
}): MapState {
const state = this.getState();
const {startZoom} = state;
let {startZoomLngLat} = state;
const hasZoomStart = startZoom !== undefined;
const startZoomValue = (startZoom as number) ?? this.getViewportProps().zoom;
const zoom = this._constrainZoom(startZoomValue + Math.log2(scale));

if (!this._shouldZoomAroundPointer()) {
return this._getUpdatedState({zoom});
}

if (!startZoomLngLat && !hasZoomStart) {
startZoomLngLat = this._unprojectOnGlobe(startPos) || this._unprojectOnGlobe(pos);
}

if (!startZoomLngLat) {
return this._getUpdatedState({zoom});
}

const zoomedViewport = this.makeViewport({...this.getViewportProps(), zoom}) as GlobeViewport;
return this._getUpdatedState({
zoom,
...zoomedViewport.panByGlobeAnchor(startZoomLngLat, pos)
});
}

zoomEnd(): GlobeState {
return this._getUpdatedState({
startZoomLngLat: null,
startZoom: null
}) as GlobeState;
}

_panFromCenter(offset: [number, number]): GlobeState {
Expand Down Expand Up @@ -242,6 +295,24 @@ class GlobeState extends MapState {
const zoomAdjustment = zoomAdjust(props.latitude, true) - zoomAdjust(0, true);
return clamp(zoom, minZoom + zoomAdjustment, maxZoom + zoomAdjustment);
}

private _unprojectOnGlobe(pos?: [number, number]): [number, number] | undefined {
if (!pos) {
return undefined;
}

const viewport = this.makeViewport(this.getViewportProps()) as GlobeViewport;
if (!viewport.isPointOnGlobe(pos)) {
return undefined;
}

const lngLat = viewport.unproject(pos);
return [lngLat[0], lngLat[1]];
}

private _shouldZoomAroundPointer(): boolean {
return (this.getState() as GlobeStateInternal).zoomAround === 'pointer';
}
}

export default class GlobeController extends Controller<MapState> {
Expand Down
112 changes: 100 additions & 12 deletions modules/core/src/viewports/globe-viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const DEGREES_TO_RADIANS = Math.PI / 180;
const RADIANS_TO_DEGREES = 180 / Math.PI;
const EARTH_RADIUS = 6370972;
export const GLOBE_RADIUS = 256;
const GLOBE_ZOOM_ANCHOR_DAMPING_START_RATIO = 0.75;
const GLOBE_ZOOM_ANCHOR_MIN_STRENGTH = 0.35;
import {MAX_LATITUDE} from '@math.gl/web-mercator';

function getDistanceScales() {
Expand Down Expand Up @@ -191,6 +193,64 @@ export default class GlobeViewport extends Viewport {
];
}

private _getRayToGlobe(
xy: number[],
{topLeft = true, targetZ}: {topLeft?: boolean; targetZ?: number} = {}
): {
coord0: number[];
coord1: number[];
radius: number;
rayLengthSquared: number;
coord0LengthSquared: number;
distanceToCenterSquared: number;
} {
const [x, y] = xy;
const y2 = topLeft ? y : this.height - y;
const {pixelUnprojectionMatrix} = this;

const coord0 = transformVector(pixelUnprojectionMatrix, [x, y2, -1, 1]);
const coord1 = transformVector(pixelUnprojectionMatrix, [x, y2, 1, 1]);

const radius = ((targetZ || 0) / EARTH_RADIUS + 1) * GLOBE_RADIUS;
const rayLengthSquared = vec3.sqrLen(vec3.sub([], coord0, coord1));
const coord0LengthSquared = vec3.sqrLen(coord0);
const coord1LengthSquared = vec3.sqrLen(coord1);
const triangleAreaSquared =
(4 * coord0LengthSquared * coord1LengthSquared -
(rayLengthSquared - coord0LengthSquared - coord1LengthSquared) ** 2) /
16;
const distanceToCenterSquared = (4 * triangleAreaSquared) / rayLengthSquared;

return {
coord0,
coord1,
radius,
rayLengthSquared,
coord0LengthSquared,
distanceToCenterSquared
};
}

private _getRayDistanceToGlobeCenterRatio(
xy: number[],
options?: {topLeft?: boolean; targetZ?: number}
): number {
const {distanceToCenterSquared, radius} = this._getRayToGlobe(xy, options);

return Math.sqrt(Math.max(0, distanceToCenterSquared)) / radius;
}

isPointOnGlobe(
xy: number[],
{
topLeft = true,
targetZ,
maxDistanceRatio = 1
}: {topLeft?: boolean; targetZ?: number; maxDistanceRatio?: number} = {}
): boolean {
return this._getRayDistanceToGlobeCenterRatio(xy, {topLeft, targetZ}) <= maxDistanceRatio;
}

unproject(
xyz: number[],
{topLeft = true, targetZ}: {topLeft?: boolean; targetZ?: number} = {}
Expand All @@ -207,18 +267,17 @@ export default class GlobeViewport extends Viewport {
} else {
// since we don't know the correct projected z value for the point,
// unproject two points to get a line and then find the point on that line that intersects with the sphere
const coord0 = transformVector(pixelUnprojectionMatrix, [x, y2, -1, 1]);
const coord1 = transformVector(pixelUnprojectionMatrix, [x, y2, 1, 1]);

const lt = ((targetZ || 0) / EARTH_RADIUS + 1) * GLOBE_RADIUS;
const lSqr = vec3.sqrLen(vec3.sub([], coord0, coord1));
const l0Sqr = vec3.sqrLen(coord0);
const l1Sqr = vec3.sqrLen(coord1);
const sSqr = (4 * l0Sqr * l1Sqr - (lSqr - l0Sqr - l1Sqr) ** 2) / 16;
const dSqr = (4 * sSqr) / lSqr;
const r0 = Math.sqrt(l0Sqr - dSqr);
const dr = Math.sqrt(Math.max(0, lt * lt - dSqr));
const t = (r0 - dr) / Math.sqrt(lSqr);
const {
coord0,
coord1,
radius,
rayLengthSquared,
coord0LengthSquared,
distanceToCenterSquared
} = this._getRayToGlobe(xyz, {topLeft, targetZ});
const r0 = Math.sqrt(coord0LengthSquared - distanceToCenterSquared);
const dr = Math.sqrt(Math.max(0, radius * radius - distanceToCenterSquared));
const t = (r0 - dr) / Math.sqrt(rayLengthSquared);

coord = vec3.lerp([], coord0, coord1, t);
}
Expand Down Expand Up @@ -283,6 +342,35 @@ export default class GlobeViewport extends Viewport {
out.zoom += zoomAdjust(out.latitude);
return out;
}

/**
* Pan the globe so that a known geographic point remains under a screen pixel.
* Used for cursor/touch-anchored zoom when the pointer is on the globe surface.
*/
panByGlobeAnchor(anchorLngLat: number[], pixel: number[]): GlobeViewportOptions {
const distanceRatio = this._getRayDistanceToGlobeCenterRatio(pixel);
if (distanceRatio > 1) {
return {longitude: this.longitude, latitude: this.latitude};
}

const currentAtPixel = this.unproject(pixel);
const edgeProgress = Math.max(
0,
Math.min(
1,
(distanceRatio - GLOBE_ZOOM_ANCHOR_DAMPING_START_RATIO) /
(1 - GLOBE_ZOOM_ANCHOR_DAMPING_START_RATIO)
)
);
const anchorStrength = 1 - edgeProgress * (1 - GLOBE_ZOOM_ANCHOR_MIN_STRENGTH);
const longitude = this.longitude + (anchorLngLat[0] - currentAtPixel[0]) * anchorStrength;
const latitude = Math.max(
Math.min(this.latitude + (anchorLngLat[1] - currentAtPixel[1]) * anchorStrength, 90),
-90
);

return {longitude, latitude};
}
}

export function zoomAdjust(latitude: number, clampToPoles?: boolean): number {
Expand Down
31 changes: 31 additions & 0 deletions test/modules/core/controllers/controllers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,37 @@ test('GlobeController', async () => {
);
});

test('GlobeController supports pointer anchored zoom option', () => {
const makeController = (controller: true | {zoomAround: 'pointer'}) =>
createTestController({
view: new GlobeView({controller}),
initialViewState: {
longitude: 0,
latitude: 0,
zoom: 1
}
});

const makeWheelEvent = () => ({
type: 'wheel',
offsetCenter: {x: 75, y: 50},
delta: -10,
srcEvent: {preventDefault() {}},
stopPropagation() {}
});

const centerZoomController = makeController(true);
const pointerZoomController = makeController({zoomAround: 'pointer'});

centerZoomController.handleEvent(makeWheelEvent() as any);
pointerZoomController.handleEvent(makeWheelEvent() as any);

expect(centerZoomController.props.longitude, 'center zoom preserves longitude').toBeCloseTo(0);
expect(pointerZoomController.props.longitude, 'pointer zoom adjusts longitude').not.toBeCloseTo(
0
);
});

test('OrbitController', async () => {
await testController(OrbitView, {
orbitAxis: 'Y',
Expand Down
16 changes: 16 additions & 0 deletions test/modules/core/viewports/globe-viewport.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,22 @@ test('GlobeViewport#project, unproject', () => {
config.EPSILON = oldEpsilon;
});

test('GlobeViewport#isPointOnGlobe', () => {
const viewport = new GlobeViewport({
width: 800,
height: 600,
latitude: 0,
longitude: 0,
zoom: 1
});

expect(
viewport.isPointOnGlobe([viewport.width / 2, viewport.height / 2]),
'screen center intersects the globe'
).toBe(true);
expect(viewport.isPointOnGlobe([0, 0]), 'corner misses the globe').toBe(false);
});

test('GlobeViewport#getBounds', () => {
for (const testCase of TEST_VIEWPORTS) {
const bounds = new GlobeViewport(testCase).getBounds();
Expand Down
Loading