Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
09a2210
PoC 3D picking
felixpalmer Jan 13, 2026
5e3749b
Augment pickable prop
felixpalmer Jan 13, 2026
f59fb30
Tidy
felixpalmer Jan 13, 2026
a2e343b
hover support
felixpalmer Jan 13, 2026
97e0ce4
Add elevation label to terrain app
felixpalmer Jan 14, 2026
d1c1c76
Rotation pivot point fixed
felixpalmer Jan 13, 2026
85b1fcf
3d enabled rotation pivot point
felixpalmer Jan 13, 2026
068c4cc
prototype rotation pivot
felixpalmer Jan 13, 2026
50743f2
Hook up picking with rotation pivot
felixpalmer Jan 13, 2026
771c3f3
rotation pivot helper
felixpalmer Jan 13, 2026
e614ac3
Map3dcontroller
felixpalmer Jan 14, 2026
e367799
rotatePivot API
felixpalmer Jan 14, 2026
67914ba
Use planes in demo
felixpalmer Jan 14, 2026
79d2d7f
Shorthand
felixpalmer Jan 15, 2026
9544d5d
Revert vite config
felixpalmer Jan 15, 2026
9eebac1
remove extra override
felixpalmer Jan 15, 2026
90352d0
Unify naming
felixpalmer Jan 15, 2026
11d9dde
Lint
felixpalmer Jan 15, 2026
3b3da3d
Merge branch 'master' into felix/rotate-pivot-3d
felixpalmer Jan 15, 2026
447bdcb
Merge branch 'master' into felix/rotate-pivot-3d
felixpalmer Feb 23, 2026
207e042
tidy
felixpalmer Feb 25, 2026
91b45cb
docs
felixpalmer Feb 25, 2026
549bbdb
Move rotationPivot into MapController
felixpalmer Feb 25, 2026
e4d3713
Move pickPosition
felixpalmer Feb 25, 2026
c400256
typing
felixpalmer Feb 25, 2026
830e939
Revert test app
felixpalmer Feb 25, 2026
ba3ea71
Succinct panByPosition3D
felixpalmer Feb 25, 2026
c8b5d6f
Merge branch 'master' into felix/rotate-pivot-3d
felixpalmer Feb 25, 2026
e929374
Merge branch 'master' into felix/rotate-pivot-3d
felixpalmer Feb 25, 2026
69ce703
Docs
felixpalmer Feb 25, 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
2 changes: 2 additions & 0 deletions docs/api-reference/core/deck.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ Receives arguments:
+ `isPanning` (boolean)
+ `isRotating` (boolean)
+ `isZooming` (boolean)
+ `rotationPivotPosition` ([number, number, number]) - World coordinate `[lng, lat, altitude]` of the rotation pivot point when rotating. Only present when the `rotationPivot` controller option is set to `'2d'` or `'3d'`.
* `oldViewState` - The previous [view state](../../developer-guide/views.md) object.

Returns:
Expand All @@ -483,6 +484,7 @@ Receives arguments:
+ `isPanning` (boolean)
+ `isRotating` (boolean)
+ `isZooming` (boolean)
+ `rotationPivotPosition` ([number, number, number]) - World coordinate `[lng, lat, altitude]` of the rotation pivot point when rotating. Only present when the `rotationPivot` controller option is set to `'2d'` or `'3d'`.

Note:
* `onInteractionStateChange` may be fired without `onViewStateChange`. For example, when the pointer is released at the end of a drag-pan, `isDragging` is reset to `false`, without the viewport's `longitude` and `latitude` changing.
Expand Down
17 changes: 13 additions & 4 deletions modules/core/src/controllers/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ export type InteractionState = {
isRotating?: boolean;
/** If the view is being zoomed, either from user input or transition */
isZooming?: boolean;
/** World coordinate [lng, lat, altitude] of rotation pivot point when rotating */
rotationPivotPosition?: [number, number, number];
}

/** Parameters passed to the onViewStateChange callback */
Expand Down Expand Up @@ -119,7 +121,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
protected eventManager: EventManager;
protected onViewStateChange: (params: ViewStateChangeParameters) => void;
protected onStateChange: (state: InteractionState) => void;
protected makeViewport: (opts: Record<string, any>) => Viewport
protected makeViewport: (opts: Record<string, any>) => Viewport;

private _controllerState?: ControllerState;
private _events: Record<string, boolean> = {};
Expand Down Expand Up @@ -154,6 +156,7 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
makeViewport: (opts: Record<string, any>) => Viewport;
onViewStateChange: (params: ViewStateChangeParameters) => void;
onStateChange: (state: InteractionState) => void;
pickPosition?: (x: number, y: number) => {coordinate?: number[]} | null;
}) {
this.transitionManager = new TransitionManager<ControllerState>({
...opts,
Expand Down Expand Up @@ -396,14 +399,20 @@ export default abstract class Controller<ControllerState extends IViewState<Cont
// invertPan is replaced by props.dragMode, keeping for backward compatibility
alternateMode = !alternateMode;
}
const newControllerState = this.controllerState[alternateMode ? 'panStart' : 'rotateStart']({
pos
});

const newControllerState = alternateMode
? this.controllerState.panStart({pos})
: this.controllerState.rotateStart(this._getRotateStartParams(pos));
this._panMove = alternateMode;
this.updateViewport(newControllerState, NO_TRANSITION_PROPS, {isDragging: true});
return true;
}

/** Returns parameters for rotateStart. Override to add extra params (e.g. altitude). */
protected _getRotateStartParams(pos: [number, number]): {pos: [number, number]} {
return {pos};
}

// Default handler for the `panmove` and `panend` event.
protected _onPan(event: MjolnirGestureEvent): boolean {
if (!this.isDragging()) {
Expand Down
101 changes: 97 additions & 4 deletions modules/core/src/controllers/map-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// Copyright (c) vis.gl contributors

import {clamp} from '@math.gl/core';
import Controller, {ControllerProps} from './controller';
import Controller, {ControllerProps, InteractionState} from './controller';
import ViewState from './view-state';
import {normalizeViewportProps} from '@math.gl/web-mercator';
import assert from '../utils/assert';
Expand Down Expand Up @@ -57,6 +57,8 @@ export type MapStateInternal = {
startZoomLngLat?: [number, number];
/* Pointer position when rotation started */
startRotatePos?: [number, number];
/* The lng/lat/altitude point at the rotation pivot (where rotation started) */
startRotateLngLat?: [number, number, number];
/** Bearing when current perspective rotate operation started */
startBearing?: number;
/** Pitch when current perspective rotate operation started */
Expand Down Expand Up @@ -114,6 +116,8 @@ export class MapState extends ViewState<MapState, MapStateProps, MapStateInterna
startZoomLngLat,
/* Pointer position when rotation started */
startRotatePos,
/* The lng/lat point at the rotation pivot (where rotation started) */
startRotateLngLat,
/** Bearing when current perspective rotate operation started */
startBearing,
/** Pitch when current perspective rotate operation started */
Expand Down Expand Up @@ -150,6 +154,7 @@ export class MapState extends ViewState<MapState, MapStateProps, MapStateInterna
startPanLngLat,
startZoomLngLat,
startRotatePos,
startRotateLngLat,
startBearing,
startPitch,
startZoom
Expand Down Expand Up @@ -201,10 +206,15 @@ export class MapState extends ViewState<MapState, MapStateProps, MapStateInterna
/**
* Start rotating
* @param {[Number, Number]} pos - position on screen where the center is
* @param {Number} altitude - optional altitude for rotation pivot
* - undefined: rotate around viewport center (no pivot point)
* - 0: rotate around pointer position at ground level
* - other value: rotate around pointer position at specified altitude
*/
rotateStart({pos}: {pos: [number, number]}): MapState {
rotateStart({pos, altitude}: {pos: [number, number]; altitude?: number}): MapState {
return this._getUpdatedState({
startRotatePos: pos,
startRotateLngLat: altitude !== undefined ? this._unproject3D(pos, altitude) : undefined,
startBearing: this.getViewportProps().bearing,
startPitch: this.getViewportProps().pitch
});
Expand All @@ -223,7 +233,7 @@ export class MapState extends ViewState<MapState, MapStateProps, MapStateInterna
deltaAngleX?: number;
deltaAngleY?: number;
}): MapState {
const {startRotatePos, startBearing, startPitch} = this.getState();
const {startRotatePos, startRotateLngLat, startBearing, startPitch} = this.getState();

if (!startRotatePos || startBearing === undefined || startPitch === undefined) {
return this;
Expand All @@ -237,6 +247,21 @@ export class MapState extends ViewState<MapState, MapStateProps, MapStateInterna
pitch: startPitch + deltaAngleY
};
}

// If we have a pivot point, adjust the camera position to keep the pivot point fixed
if (startRotateLngLat) {
const rotatedViewport = this.makeViewport({
...this.getViewportProps(),
...newRotation
});
// Use panByPosition3D if available (WebMercatorViewport), otherwise fall back to panByPosition
const panMethod = 'panByPosition3D' in rotatedViewport ? 'panByPosition3D' : 'panByPosition';
return this._getUpdatedState({
...newRotation,
...rotatedViewport[panMethod](startRotateLngLat, startRotatePos)
});
}

return this._getUpdatedState(newRotation);
}

Expand All @@ -246,6 +271,8 @@ export class MapState extends ViewState<MapState, MapStateProps, MapStateInterna
*/
rotateEnd(): MapState {
return this._getUpdatedState({
startRotatePos: null,
startRotateLngLat: null,
startBearing: null,
startPitch: null
});
Expand Down Expand Up @@ -435,6 +462,11 @@ export class MapState extends ViewState<MapState, MapStateProps, MapStateInterna
return pos && viewport.unproject(pos);
}

_unproject3D(pos: [number, number], altitude: number): [number, number, number] {
const viewport = this.makeViewport(this.getViewportProps());
return viewport.unproject(pos, {targetZ: altitude}) as [number, number, number];
}

_getNewRotation(
pos: [number, number],
startPos: [number, number],
Expand Down Expand Up @@ -502,7 +534,28 @@ export default class MapController extends Controller<MapState> {

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

setProps(props: ControllerProps & MapStateProps) {
/**
* Rotation pivot behavior:
* - 'center': Rotate around viewport center (default)
* - '2d': Rotate around pointer position at ground level (z=0)
* - '3d': Rotate around 3D picked point (requires pickPosition callback)
*/
protected rotationPivot: 'center' | '2d' | '3d' = 'center';

/**
* Internal callback to access deck picking engine. Populated by ViewManager
*/
protected pickPosition?: (x: number, y: number) => {coordinate?: number[]} | null;

constructor(opts: ConstructorParameters<typeof Controller>[0]) {
super(opts);
this.pickPosition = opts.pickPosition;
}

setProps(props: ControllerProps & MapStateProps & {rotationPivot?: 'center' | '2d' | '3d'}) {
if ('rotationPivot' in props) {
this.rotationPivot = props.rotationPivot || 'center';
}
props.position = props.position || [0, 0, 0];
const oldProps = this.props;

Expand All @@ -520,4 +573,44 @@ export default class MapController extends Controller<MapState> {
);
}
}

protected updateViewport(
newControllerState: MapState,
extraProps: Record<string, any> | null = null,
interactionState: InteractionState = {}
): void {
// Inject rotation pivot position during rotation for visual feedback
const state = newControllerState.getState();
if (interactionState.isDragging && state.startRotateLngLat) {
interactionState = {
...interactionState,
rotationPivotPosition: state.startRotateLngLat
};
} else if (interactionState.isDragging === false) {
// Clear pivot when drag ends
interactionState = {...interactionState, rotationPivotPosition: undefined};
}

super.updateViewport(newControllerState, extraProps, interactionState);
}

/** Add altitude to rotateStart params based on rotationPivot mode */
protected _getRotateStartParams(pos: [number, number]): {
pos: [number, number];
altitude?: number;
} {
let altitude: number | undefined;
if (this.rotationPivot === '2d') {
altitude = 0;
} else if (this.rotationPivot === '3d') {
if (this.pickPosition) {
const {x, y} = this.props;
const pickResult = this.pickPosition(x + pos[0], y + pos[1]);
if (pickResult && pickResult.coordinate && pickResult.coordinate.length >= 3) {
altitude = pickResult.coordinate[2];
}
}
}
return {pos, altitude};
}
}
4 changes: 2 additions & 2 deletions modules/core/src/controllers/view-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export default abstract class ViewState<
abstract pan({pos, startPos}: {pos: [number, number]; startPos?: [number, number]}): T;
abstract panEnd(): T;

abstract rotateStart(params: {pos: [number, number]}): T;
abstract rotateStart(params: {pos: [number, number]; altitude?: number}): T;
abstract rotate(params: {pos?: [number, number]; deltaAngleX?: number; deltaAngleY: number}): T;
abstract rotateEnd(): T;

Expand Down Expand Up @@ -77,7 +77,7 @@ export interface IViewState<T> {
pan({pos, startPos}: {pos: [number, number]; startPos?: [number, number]}): T;
panEnd(): T;

rotateStart(params: {pos: [number, number]}): T;
rotateStart(params: {pos: [number, number]; altitude?: number}): T;
rotate(params: {pos?: [number, number]; deltaAngleX?: number; deltaAngleY?: number}): T;
rotateEnd(): T;

Expand Down
10 changes: 10 additions & 0 deletions modules/core/src/lib/deck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,15 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
return this._pick('pickObjects', 'pickObjects Time', opts);
}

/**
* Internal method used by controllers to pick 3D position at a screen coordinate
* @private
*/
private _pickPositionForController(x: number, y: number): {coordinate?: number[]} | null {
const pickResult = this.pickObject({x, y, radius: 0, unproject3D: true});
return pickResult;
}

/** Experimental
* Add a global resource for sharing among layers
*/
Expand Down Expand Up @@ -1103,6 +1112,7 @@ export default class Deck<ViewsT extends ViewOrViews = null> {
eventManager: this.eventManager,
onViewStateChange: this._onViewStateChange.bind(this),
onInteractionStateChange: this._onInteractionStateChange.bind(this),
pickPosition: this._pickPositionForController.bind(this),
views: this._getViews(),
viewState: this._getViewState(),
width: this.width,
Expand Down
10 changes: 9 additions & 1 deletion modules/core/src/lib/view-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type ViewManagerProps<ViewsT extends ViewOrViews> = {
viewState: ViewStateObject<ViewsT> | null;
onViewStateChange?: (params: ViewStateChangeParameters<AnyViewStateOf<ViewsT>>) => void;
onInteractionStateChange?: (state: InteractionState) => void;
pickPosition?: (x: number, y: number) => {coordinate?: number[]} | null;
width?: number;
height?: number;
};
Expand All @@ -65,6 +66,7 @@ export default class ViewManager<ViewsT extends View[]> {
onViewStateChange?: (params: ViewStateChangeParameters) => void;
onInteractionStateChange?: (state: InteractionState) => void;
};
private _pickPosition?: (x: number, y: number) => {coordinate?: number[]} | null;

constructor(
props: ViewManagerProps<ViewsT> & {
Expand Down Expand Up @@ -92,6 +94,7 @@ export default class ViewManager<ViewsT extends View[]> {
onViewStateChange: props.onViewStateChange,
onInteractionStateChange: props.onInteractionStateChange
};
this._pickPosition = props.pickPosition;

Object.seal(this);

Expand Down Expand Up @@ -224,6 +227,10 @@ export default class ViewManager<ViewsT extends View[]> {
this._setSize(props.width as number, props.height as number);
}

if ('pickPosition' in props) {
this._pickPosition = props.pickPosition;
}

// Important: avoid invoking _update() inside itself
// Nested updates result in unexpected side effects inside _rebuildViewports()
// when using auto control in pure-js
Expand Down Expand Up @@ -308,7 +315,8 @@ export default class ViewManager<ViewsT extends View[]> {
viewState,
width: this.width,
height: this.height
})
}),
pickPosition: this._pickPosition
});

return controller;
Expand Down
10 changes: 10 additions & 0 deletions modules/core/src/viewports/web-mercator-viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,16 @@ export default class WebMercatorViewport extends Viewport {
return {longitude, latitude};
}

/**
* Returns a new longitude and latitude that keeps a 3D world coordinate at a given screen pixel
* This version handles the z-component (altitude) properly for cameras positioned above ground
*/
panByPosition3D(coords: number[], pixel: number[]): WebMercatorViewportOptions {
const targetZ = coords[2] || 0;
const deltaLngLat = vec2.sub([], coords, this.unproject(pixel, {targetZ}));
return {longitude: this.longitude + deltaLngLat[0], latitude: this.latitude + deltaLngLat[1]};
}

getBounds(options: {z?: number} = {}): [number, number, number, number] {
// @ts-ignore
const corners = getBounds(this, options.z || 0);
Expand Down
Loading