Skip to content
1 change: 1 addition & 0 deletions docs/api-reference/core/controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ The base Controller class supports the following options:
* `rotateSpeedX` (number) - speed of rotation using shift + left/right arrow keys, in degrees. Default `15`.
* `rotateSpeedY` (number) - speed of rotation using shift + up/down arrow keys, in degrees. Default `10`.
* `dragMode` (string) - drag behavior without pressing function keys, one of `pan` and `rotate`.
* `zoomAround` (`'center' | 'pointer'`) - zoom anchor mode when supported by the controller. Default depends on the controller.
* `inertia` (boolean | number) - 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`.
* `maxBounds` (`[min: number[], max: number[]]`) - constrain camera to the specified bounding box. Different type of views may handle this constraint differently.

Expand Down
1 change: 1 addition & 0 deletions docs/api-reference/core/globe-controller.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Supports all [Controller options](./controller.md#options) with the following de
- `keyboard`: arrow keys to pan, +/- to zoom
- `inertia`: when set to a number (milliseconds), the globe continues spinning after a fling gesture with exponential decay
- `maxBounds` - constrains the viewport to the specified bounding box `[[minLng, minLat], [maxLng, maxLat]]`
- `zoomAround`: default `'center'`. Set to `'pointer'` to keep the longitude/latitude under the cursor or touch anchor fixed during wheel, pinch, and double-click zoom. If the anchor is off the rendered globe, zoom falls back to center anchoring.

## Custom GlobeController

Expand Down
6 changes: 4 additions & 2 deletions modules/core/src/controllers/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,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 Expand Up @@ -291,8 +293,8 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
get controllerState(): ControllerState {
this._controllerState = this._controllerState || new this.ControllerState({
makeViewport: this.makeViewport,
...this.props,
...this.state
...this.state,
...this.props
});
return this._controllerState;
}
Expand Down
113 changes: 108 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,12 @@ 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 type Viewport from '../viewports/viewport';
import GlobeViewport, {
zoomAdjust,
GLOBE_RADIUS,
GLOBE_ZOOM_ANCHOR_MAX_DISTANCE_RATIO
} from '../viewports/globe-viewport';
import {
Globe,
type CameraFrame,
Expand All @@ -35,26 +40,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 +75,10 @@ class GlobeState extends MapState {
if (startPanCameraFrame !== undefined) s.startPanCameraFrame = startPanCameraFrame;
if (startPanAngularRate !== undefined) s.startPanAngularRate = startPanAngularRate;
if (startPanLockBearing !== undefined) s.startPanLockBearing = startPanLockBearing;
// Unlike the transient gesture anchors above, zoomAround is a config option that
// must always carry a value: guarding it with `!== undefined` lets a partial-props
// or HMR reconstruction drop the key, silently reverting pointer zoom to center.
s.zoomAround = zoomAround || 'center';
}

panStart({pos}: {pos: [number, number]}): GlobeState {
Expand Down Expand Up @@ -142,10 +156,59 @@ 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._unprojectZoomAnchor(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;
const initialStartZoomLngLat = state.startZoomLngLat;
const hasZoomStart = startZoom !== null && startZoom !== undefined;
const startZoomValue = hasZoomStart ? startZoom : this.getViewportProps().zoom;
const zoom = this._constrainZoom(startZoomValue + Math.log2(scale));

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

const startZoomLngLat =
initialStartZoomLngLat ??
this._unprojectZoomAnchor(startPos) ??
this._unprojectZoomAnchor(pos);

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

const zoomedViewport = this.makeViewport({...this.getViewportProps(), zoom});
return this._getUpdatedState({
zoom,
...(hasZoomStart && !initialStartZoomLngLat ? {startZoomLngLat} : {}),
...this._panByZoomAnchor(zoomedViewport, startZoomLngLat, pos)
});
}

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

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

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

const viewport = this.makeViewport(this.getViewportProps());
if (
viewport instanceof GlobeViewport &&
!viewport.isPointOnGlobe(pos, {maxDistanceRatio: GLOBE_ZOOM_ANCHOR_MAX_DISTANCE_RATIO})
) {
return undefined;
}

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

private _panByZoomAnchor(
viewport: Viewport,
anchorLngLat: [number, number],
pixel: [number, number]
): Record<string, any> {
return viewport instanceof GlobeViewport
? viewport.panByGlobeAnchor(anchorLngLat, pixel)
: viewport.panByPosition(anchorLngLat, pixel);
}

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

export default class GlobeController extends Controller<MapState> {
Expand All @@ -262,6 +356,15 @@ export default class GlobeController extends Controller<MapState> {
// Ring buffer tracking globe position during pan for inertia velocity
private _panHistory: Array<{longitude: number; latitude: number; timestamp: number}> = [];

protected _getTransitionProps(opts?: any) {
if (opts?.around && this.props.zoomAround !== 'pointer') {
const centerZoomOpts = {...opts};
delete centerZoomOpts.around;
return super._getTransitionProps(centerZoomOpts);
}
return super._getTransitionProps(opts);
}

protected _onPanStart(event: MjolnirGestureEvent): boolean {
this._panHistory = [];
return super._onPanStart(event);
Expand Down
60 changes: 45 additions & 15 deletions modules/core/src/transitions/linear-interpolator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@
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';
import GlobeViewport, {GLOBE_ZOOM_ANCHOR_MAX_DISTANCE_RATIO} from '../viewports/globe-viewport';

const DEFAULT_PROPS = ['longitude', 'latitude', 'zoom', 'bearing', 'pitch'];
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,27 @@ 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, {
maxDistanceRatio: GLOBE_ZOOM_ANCHOR_MAX_DISTANCE_RATIO
})
) {
const endViewport = makeViewport(endProps);
const aroundLngLat = startViewport.unproject(around);
result.start.around = around;
Object.assign(result.end, {
around: endViewport.project(aroundLngLat),
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 +124,31 @@ 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.aroundLngLat) {
Object.assign(
propsInTransition,
viewport.panByPosition(endProps.aroundLngLat, anchorScreen)
);
} else if (endProps.aroundPosition) {
Object.assign(
propsInTransition,
viewport.panByPosition(endProps.aroundPosition, anchorScreen)
);
}
}
return propsInTransition;
}
Expand Down
Loading
Loading