Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
5ef53f5
feat(core): add GlobeView pointer zoom option
charlieforward9 May 16, 2026
5b988fe
feat(core): anchor LinearInterpolator transitions on GlobeViewport
charlieforward9 May 22, 2026
9495132
refactor(core): tidy GlobeViewport anchor helpers
charlieforward9 May 22, 2026
9f1d80b
fix(core): apply updated GlobeView zoom anchor option
charlieforward9 Jun 6, 2026
c9a70ad
fix(core): stabilize GlobeView pointer zoom
charlieforward9 Jun 10, 2026
10efe94
fix(core): address globe zoom review feedback
charlieforward9 Jun 11, 2026
4b98253
fix(core): recover globe pointer zoom anchors
charlieforward9 Jun 13, 2026
4179907
fix(core): always set GlobeState.zoomAround (fix HMR pointer-zoom dro…
charlieforward9 Jun 16, 2026
ce2b6ee
Merge branch 'master' into codex/globe-anchored-zoom
charlieforward9 Jul 16, 2026
8450a17
Merge remote-tracking branch 'upstream/master' into codex/pr-10385-sh…
charlieforward9 Aug 24, 2026
8f81051
refactor(core): share pointer zoom policy across controllers
charlieforward9 Aug 24, 2026
ece9795
fix(test-app): use current point radius accessor
charlieforward9 Aug 24, 2026
e8bbdf1
Merge shared zoom anchor base for stacked review
charlieforward9 Aug 24, 2026
27fb924
refactor(core): clarify globe anchor projection math
charlieforward9 Aug 24, 2026
b39f5f4
Merge latest shared zoom anchor base
charlieforward9 Aug 24, 2026
ac03464
Merge remote-tracking branch 'upstream/codex/shared-controller-zoom-a…
charlieforward9 Aug 26, 2026
8d771b0
fix(core): center globe zoom outside visible sphere
charlieforward9 Aug 26, 2026
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
39 changes: 28 additions & 11 deletions modules/core/src/controllers/globe-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ import {getMaxBoundsExtents, getMaxBoundsRect} from './utils';

import {MapState, MapStateProps} from './map-controller';
import type {MapStateInternal} from './map-controller';
import {CONSTRAINT_AROUND, type ConstraintAround} from './view-state';
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 Down Expand Up @@ -143,12 +144,6 @@ 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});
}

_panFromCenter(offset: [number, number]): GlobeState {
const {width, height} = this.getViewportProps();
const center: [number, number] = [width / 2, height / 2];
Expand All @@ -158,14 +153,25 @@ class GlobeState extends MapState {
}

applyConstraints(props: Required<MapStateProps>): Required<MapStateProps> {
const {longitude, latitude, maxBounds} = props;
const internalProps = props as typeof props & ConstraintAround;
const constraintAround = internalProps[CONSTRAINT_AROUND];
delete internalProps[CONSTRAINT_AROUND];
const {latitude, maxBounds} = props;

props.zoom = this._constrainZoom(props.zoom, props);

if (longitude < -180 || longitude > 180) {
props.longitude = mod(longitude + 180, 360) - 180;
if (constraintAround) {
const viewport = this.makeViewport(props);
Object.assign(
props,
viewport.panByPosition(constraintAround.position, constraintAround.screenPosition)
);
}

if (props.longitude < -180 || props.longitude > 180) {
props.longitude = mod(props.longitude + 180, 360) - 180;
}
props.latitude = clamp(latitude, -90, 90);
props.latitude = clamp(props.latitude, -90, 90);

if (props.bearing < -180 || props.bearing > 180) {
props.bearing = mod(props.bearing + 180, 360) - 180;
Expand Down Expand Up @@ -294,6 +300,17 @@ export default class GlobeController extends Controller<MapState> {

dragMode: 'pan' | 'rotate' = 'pan';

protected getZoomPosition(position: [number, number]): [number, number] {
const zoomPosition = super.getZoomPosition(position);
const viewport = this.makeViewport(this.controllerState.getViewportProps()) as GlobeViewport;

if (viewport.getZoomAnchorStrength(zoomPosition) > 0) {
return zoomPosition;
}

return viewport.project([viewport.longitude, viewport.latitude]) as [number, number];
}

// Ring buffer tracking globe position during pan for inertia velocity
private _panHistory: Array<{longitude: number; latitude: number; timestamp: number}> = [];

Expand Down
27 changes: 10 additions & 17 deletions modules/core/src/transitions/linear-interpolator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@
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';

const DEFAULT_PROPS = ['longitude', 'latitude', 'zoom', 'bearing', 'pitch'];
const DEFAULT_REQUIRED_PROPS = ['longitude', 'latitude', 'zoom'];
Expand Down Expand Up @@ -78,21 +76,16 @@ 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')();
} else {
const startViewport = makeViewport(startProps);
const endViewport = makeViewport(endProps);
const aroundPosition = startViewport.unproject(around);
result.start.around = around;
Object.assign(result.end, {
around: endViewport.project(aroundPosition),
aroundPosition,
width: endProps.width,
height: endProps.height
});
}
const startViewport = makeViewport(startProps);
const endViewport = makeViewport(endProps);
const aroundPosition = startViewport.unproject(around);
result.start.around = around;
Object.assign(result.end, {
around: endViewport.project(aroundPosition),
aroundPosition,
width: endProps.width,
height: endProps.height
});
}

return result;
Expand Down
177 changes: 145 additions & 32 deletions modules/core/src/viewports/globe-viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,23 @@
// 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';
import {mod} from '../utils/math-utils';

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 uses full correction; 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;
const GLOBE_ZOOM_ANCHOR_MAX_DISTANCE_RATIO = 1.15;

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

/**
* Builds the screen-pixel → globe-center ray and the intermediate ray/sphere
* math reused by `unproject` and anchored zoom. One function so the same
* pixelUnprojectionMatrix work isn't duplicated.
*/
Comment thread
charlieforward9 marked this conversation as resolved.
private _getRayToGlobe(
screenPosition: number[],
{topLeft = true, targetZ}: {topLeft?: boolean; targetZ?: number} = {}
): {
rayStartPosition: number[];
rayEndPosition: number[];
radius: number;
rayLengthSquared: number;
rayStartDistanceSquared: number;
distanceToCenterSquared: number;
} {
const [screenX, screenY] = screenPosition;
const adjustedScreenY = topLeft ? screenY : this.height - screenY;
const {pixelUnprojectionMatrix} = this;

const rayStartPosition = transformVector(pixelUnprojectionMatrix, [
screenX,
adjustedScreenY,
-1,
1
]);
const rayEndPosition = transformVector(pixelUnprojectionMatrix, [
screenX,
adjustedScreenY,
1,
1
]);

const radius = ((targetZ || 0) / EARTH_RADIUS + 1) * GLOBE_RADIUS;
const rayLengthSquared = vec3.sqrLen(vec3.sub([], rayStartPosition, rayEndPosition));
const rayStartDistanceSquared = vec3.sqrLen(rayStartPosition);
const rayEndDistanceSquared = vec3.sqrLen(rayEndPosition);
const triangleAreaSquared =
(4 * rayStartDistanceSquared * rayEndDistanceSquared -
(rayLengthSquared - rayStartDistanceSquared - rayEndDistanceSquared) ** 2) /
16;
const distanceToCenterSquared = (4 * triangleAreaSquared) / rayLengthSquared;

return {
rayStartPosition,
rayEndPosition,
radius,
rayLengthSquared,
rayStartDistanceSquared,
distanceToCenterSquared
};
}

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

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

/**
* Returns how strongly a screen position should anchor zoom on the visible globe.
* A value of `0` means that the controller should fall back to center zoom.
* @param screenPosition - Screen position to evaluate.
* @returns Anchor strength from `0` to `1`.
*/
getZoomAnchorStrength(screenPosition: number[]): number {
const distanceRatio = this._getRayDistanceToGlobeCenterRatio(screenPosition);
if (distanceRatio > GLOBE_ZOOM_ANCHOR_MAX_DISTANCE_RATIO) {
return 0;
}

const edgeProgress = Math.max(
0,
Math.min(
1,
(distanceRatio - GLOBE_ZOOM_ANCHOR_DAMPING_START_RATIO) /
(1 - GLOBE_ZOOM_ANCHOR_DAMPING_START_RATIO)
)
);
return 1 - edgeProgress * (1 - GLOBE_ZOOM_ANCHOR_MIN_STRENGTH);
}

unproject(
xyz: number[],
{topLeft = true, targetZ}: {topLeft?: boolean; targetZ?: number} = {}
Expand All @@ -207,20 +297,24 @@ 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);

coord = vec3.lerp([], coord0, coord1, t);
const {
rayStartPosition,
rayEndPosition,
radius,
rayLengthSquared,
rayStartDistanceSquared,
distanceToCenterSquared
} = this._getRayToGlobe(xyz, {topLeft, targetZ});
const rayStartToClosestApproach = Math.sqrt(
rayStartDistanceSquared - distanceToCenterSquared
);
const closestApproachToIntersection = Math.sqrt(
Math.max(0, radius * radius - distanceToCenterSquared)
);
const intersectionRatio =
(rayStartToClosestApproach - closestApproachToIntersection) / Math.sqrt(rayLengthSquared);

coord = vec3.lerp([], rayStartPosition, rayEndPosition, intersectionRatio);
}
const [X, Y, Z] = this.unprojectPosition(coord);

Expand Down Expand Up @@ -261,27 +355,46 @@ export default class GlobeViewport extends Viewport {
}

/**
* Pan the globe using delta-based movement
* @param coords - the geographic coordinates where the pan started
* @param pixel - the current screen position
* @param startPixel - the screen position where the pan started
* @returns updated viewport options with new longitude/latitude
* Pan the globe to place geographic coordinates at a screen pixel.
* When `dragStartPosition` is supplied, applies the delta-based movement used by globe dragging.
* @param coordinates - Geographic anchor, or the starting longitude, latitude and zoom.
* @param screenPosition - Current screen position.
* @param dragStartPosition - Screen position where a drag started.
* @returns Updated viewport options.
*/
panByPosition(
[startLng, startLat, startZoom]: number[],
pixel: number[],
startPixel: number[]
coordinates: number[],
screenPosition: number[],
dragStartPosition?: number[]
): GlobeViewportOptions {
if (!dragStartPosition) {
const anchorStrength = this.getZoomAnchorStrength(screenPosition);
if (anchorStrength === 0) {
return {longitude: this.longitude, latitude: this.latitude};
}

const currentCoordinates = this.unproject(screenPosition);
const longitudeDelta = mod(coordinates[0] - currentCoordinates[0] + 180, 360) - 180;
const longitude = this.longitude + longitudeDelta * anchorStrength;
const latitude = Math.max(
Math.min(this.latitude + (coordinates[1] - currentCoordinates[1]) * anchorStrength, 90),
-90
);

return {longitude, latitude};
}

const [startLongitude, startLatitude, startZoom] = coordinates;
// Scale rotation speed inversely with zoom, to approximate constant panning speed
const scale = Math.pow(2, this.zoom - zoomAdjust(this.latitude));
const rotationSpeed = 0.25 / scale;

const longitude = startLng + rotationSpeed * (startPixel[0] - pixel[0]);
let latitude = startLat - rotationSpeed * (startPixel[1] - pixel[1]);
const longitude = startLongitude + rotationSpeed * (dragStartPosition[0] - screenPosition[0]);
let latitude = startLatitude - rotationSpeed * (dragStartPosition[1] - screenPosition[1]);
latitude = Math.max(Math.min(latitude, 90), -90);
const out = {longitude, latitude, zoom: startZoom - zoomAdjust(startLat)};
out.zoom += zoomAdjust(out.latitude);
return out;
const nextViewState = {longitude, latitude, zoom: startZoom - zoomAdjust(startLatitude)};
nextViewState.zoom += zoomAdjust(nextViewState.latitude);
return nextViewState;
}
}

Expand Down
Loading