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
48 changes: 34 additions & 14 deletions modules/core/src/transitions/linear-interpolator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
import TransitionInterpolator from './transition-interpolator';
import {lerp} from '@math.gl/core';

import log from '../utils/log';
import type Viewport from '../viewports/viewport';
import GlobeViewport from '../viewports/globe-viewport';

Expand All @@ -15,6 +14,7 @@ const DEFAULT_REQUIRED_PROPS = ['longitude', 'latitude', 'zoom'];
type PropsWithAnchor = {
around?: number[];
aroundPosition?: number[];
aroundLngLat?: number[];
[key: string]: any;
};

Expand Down Expand Up @@ -78,11 +78,22 @@ export default class LinearInterpolator extends TransitionInterpolator {
const {makeViewport, around} = this.opts;

if (makeViewport && around) {
const TestViewport = makeViewport(startProps);
if (TestViewport instanceof GlobeViewport) {
log.warn('around not supported in GlobeView')();
const startViewport = makeViewport(startProps);
if (startViewport instanceof GlobeViewport) {
// GlobeViewport uses spherical anchoring: unproject the screen point
// to a lng/lat on the globe and feed that to panByGlobeAnchor each
// frame. If the click is off-globe, fall through to a plain LERP.
if (startViewport.isPointOnGlobe(around)) {
const aroundLngLat = startViewport.unproject(around);
result.start.around = around;
Object.assign(result.end, {
around,
aroundLngLat,
width: endProps.width,
height: endProps.height
});
}
} else {
const startViewport = makeViewport(startProps);
const endViewport = makeViewport(endProps);
const aroundPosition = startViewport.unproject(around);
result.start.around = around;
Expand All @@ -108,17 +119,26 @@ export default class LinearInterpolator extends TransitionInterpolator {
propsInTransition[key] = lerp(startProps[key] || 0, endProps[key] || 0, t);
}

if (endProps.aroundPosition && this.opts.makeViewport) {
if (this.opts.makeViewport && (endProps.aroundPosition || endProps.aroundLngLat)) {
// Linear transition should be performed in common space
const viewport = this.opts.makeViewport({...endProps, ...propsInTransition});
Object.assign(
propsInTransition,
viewport.panByPosition(
endProps.aroundPosition,
// anchor point in current screen coordinates
lerp(startProps.around as number[], endProps.around as number[], t) as number[]
)
);
const anchorScreen = lerp(
startProps.around as number[],
endProps.around as number[],
t
) as number[];

if (viewport instanceof GlobeViewport && endProps.aroundLngLat) {
Object.assign(
propsInTransition,
viewport.panByGlobeAnchor(endProps.aroundLngLat, anchorScreen)
);
} else if (endProps.aroundPosition) {
Object.assign(
propsInTransition,
viewport.panByPosition(endProps.aroundPosition, anchorScreen)
);
}
}
return propsInTransition;
}
Expand Down
129 changes: 112 additions & 17 deletions modules/core/src/viewports/globe-viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import {Matrix4} from '@math.gl/core';
import {Matrix4, vec3, vec4} from '@math.gl/core';
import {altitudeToFovy, fovyToAltitude, MAX_LATITUDE} from '@math.gl/web-mercator';
import Viewport from './viewport';
import {PROJECTION_MODE} from '../lib/constants';
import {altitudeToFovy, fovyToAltitude} from '@math.gl/web-mercator';

import {vec3, vec4} from '@math.gl/core';

const DEGREES_TO_RADIANS = Math.PI / 180;
const RADIANS_TO_DEGREES = 180 / Math.PI;
const EARTH_RADIUS = 6370972;
export const GLOBE_RADIUS = 256;
import {MAX_LATITUDE} from '@math.gl/web-mercator';
// Where along the screen-pixel-to-globe-center distance ratio the anchored
// zoom starts losing strength. Below this ratio the anchor pins exactly; from
// here to the limb (ratio = 1) the anchor blends toward MIN_STRENGTH so a
// near-edge pixel doesn't snap the camera across the globe.
const GLOBE_ZOOM_ANCHOR_DAMPING_START_RATIO = 0.75;
const GLOBE_ZOOM_ANCHOR_MIN_STRENGTH = 0.35;

function getDistanceScales() {
const unitsPerMeter = GLOBE_RADIUS / EARTH_RADIUS;
Expand Down Expand Up @@ -191,6 +194,70 @@ export default class GlobeViewport extends Viewport {
];
}

/**
* Builds the screen-pixel → globe-center ray and the intermediate ray/sphere
* math reused by `unproject` (intersection point) and the public hit-test
* helpers (`isPointOnGlobe`, `panByGlobeAnchor`). One function so the same
* pixelUnprojectionMatrix work isn't duplicated.
*/
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 +274,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 +349,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
Loading
Loading