feat(ui): tilt and north-up compass controls (#571)

Supersedes #442 by @yashveeeeeeer, ported onto the current component layout with fixes from review.

Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied.

Co-authored-by: Yashveer Singh <[email protected]>
This commit is contained in:
Bilawal SidhuandYashveer Singh authored and GitHub committed 2026-09-14 21:15:29 -05:00
1 parent fb5ee5aa36
commit d9fac6d88e
14 files changed
+895 -13

No files matched your search

+269
View File
@@ -0,0 +1,269 @@
import * as Cesium from 'cesium';
import { isPickedWorldPosition } from '../data/scenePick.js';
export const OBLIQUE_PITCH = Cesium.Math.toRadians(-35);
export const STRAIGHT_DOWN_PITCH = Cesium.Math.toRadians(-89);
const OBLIQUE_THRESHOLD = Cesium.Math.toRadians(-60);
function normalizedHeading(heading) {
const wrapped = Cesium.Math.zeroToTwoPi(
Number.isFinite(heading) ? heading : 0,
);
return Math.abs(wrapped - Cesium.Math.TWO_PI) < Cesium.Math.EPSILON10
? 0
: wrapped;
}
/** Return the world position under the center of the canvas, when available. */
export function pickViewTarget(viewer) {
const scene = viewer?.scene;
const camera = viewer?.camera;
const canvas = scene?.canvas;
if (!scene || !camera || !canvas) return null;
const width = canvas.clientWidth || canvas.width || 0;
const height = canvas.clientHeight || canvas.height || 0;
if (!width || !height) return null;
const center = new Cesium.Cartesian2(width / 2, height / 2);
let target = null;
if (scene.pickPositionSupported && typeof scene.pickPosition === 'function') {
try {
target = scene.pickPosition(center);
} catch {
target = null;
}
}
// Terrain before the ellipsoid: globe.pick intersects the rendered surface,
// so over keyless elevated terrain it returns the ground the operator is
// looking at. pickEllipsoid answers with sea level, which on a plateau sits
// far below the view and would swing the camera on every tilt.
if (
!isPickedWorldPosition(target) &&
typeof camera.getPickRay === 'function'
) {
try {
target = scene.globe?.pick(camera.getPickRay(center), scene) || null;
} catch {
target = null;
}
}
if (
!isPickedWorldPosition(target) &&
typeof camera.pickEllipsoid === 'function'
) {
try {
target = camera.pickEllipsoid(center, Cesium.Ellipsoid.WGS84);
} catch {
target = null;
}
}
return isPickedWorldPosition(target) ? target : null;
}
/** Describe the camera as an orbit around the current viewport center. */
export function readCameraTargetFrame(viewer) {
const camera = viewer?.camera;
const target = pickViewTarget(viewer);
if (!camera || !target || !isPickedWorldPosition(camera.positionWC))
return null;
const transform = Cesium.Transforms.eastNorthUpToFixedFrame(target);
const inverse = Cesium.Matrix4.inverseTransformation(
transform,
new Cesium.Matrix4(),
);
const localOffset = Cesium.Matrix4.multiplyByPoint(
inverse,
camera.positionWC,
new Cesium.Cartesian3(),
);
const range = Cesium.Cartesian3.magnitude(localOffset);
if (!Number.isFinite(range) || range < 1) return null;
const pitch = -Math.asin(Cesium.Math.clamp(localOffset.z / range, -1, 1));
// Cesium places a lookAt camera at range * (-sin h cos p, -cos h cos p,
// -sin p) in the target's east-north-up frame, so recovering h means
// negating BOTH horizontal components. Negating only the north one reads
// every heading as its mirror image (30 deg comes back as 330), which would
// make north-up rotate the wrong way from three quarters of the compass.
const targetHeading = Math.atan2(-localOffset.x, -localOffset.y);
const heading = normalizedHeading(
pitch < Cesium.Math.toRadians(-88.5) ? camera.heading : targetHeading,
);
return { target, range, heading, pitch };
}
/** Apply an orbit frame, then return the camera to Cesium's fixed-world frame. */
export function setCameraTargetFrame(viewer, frame) {
const camera = viewer?.camera;
if (!camera || !frame?.target || !Number.isFinite(frame.range)) return false;
try {
camera.lookAt(
frame.target,
new Cesium.HeadingPitchRange(
normalizedHeading(frame.heading),
frame.pitch,
frame.range,
),
);
const destination = Cesium.Cartesian3.clone(camera.positionWC);
const direction = Cesium.Cartesian3.clone(camera.directionWC);
const up = Cesium.Cartesian3.clone(camera.upWC);
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
if (destination && direction && up) {
camera.setView({ destination, orientation: { direction, up } });
}
viewer.scene?.requestRender?.();
return true;
} catch {
return false;
}
}
/**
* Whether the map reads as tilted, from the orbit frame.
*
* This is the quantity the toggle commands, so it is the only one the button
* may show. The camera's OWN pitch is a different angle: at a few kilometres
* the two agree to 0.02 degrees, but from the full-globe view they are tens of
* degrees apart, because the point under the middle of the screen is most of a
* hemisphere away. Reading the camera's pitch there made the toggle choose
* oblique twice in a row — commanded oblique, camera pitch came back near -77,
* still "not tilted", tilt again.
*
* @param {{pitch: number}|null} frame From {@link readCameraTargetFrame}.
*/
export function frameIsTilted(frame) {
const pitch = Number(frame?.pitch);
return Number.isFinite(pitch) ? pitch > OBLIQUE_THRESHOLD : false;
}
/** Toggle between a straight-down map and a useful oblique map angle. */
export function toggleCameraTilt(viewer) {
const frame = readCameraTargetFrame(viewer);
if (!frame) return false;
const tilted = frameIsTilted(frame);
const pitch = tilted ? STRAIGHT_DOWN_PITCH : OBLIQUE_PITCH;
return setCameraTargetFrame(viewer, { ...frame, pitch })
? { tilted: !tilted, pitch }
: false;
}
/** Rotate around the viewport center until north is at the top. */
export function resetCameraNorth(viewer) {
const frame = readCameraTargetFrame(viewer);
if (!frame) return false;
return setCameraTargetFrame(viewer, { ...frame, heading: 0 });
}
function headingDegrees(camera) {
return Cesium.Math.toDegrees(normalizedHeading(camera?.heading));
}
/** Bind the two map-orientation actions and keep their accessible state current. */
export function bindCameraOrientationControls({
viewer,
elements,
runNavigation,
showToast,
}) {
const tiltButton = elements?.tiltButton;
const northButton = elements?.northButton;
const removers = [];
let destroyed = false;
let applied = null;
// The tilt state is the one thing here that costs a depth read of the middle
// of the screen: 4 ms median and up to 12 ms, measured on this machine. It is
// therefore held between refreshes rather than recomputed per frame.
let tilted = false;
/** Recompute the tilt state exactly, paying for one pick. */
const refreshTilt = () => {
if (destroyed) return;
tilted = frameIsTilted(readCameraTargetFrame(viewer));
sync();
};
/**
* Write the two controls' state, but only what actually changed.
*
* The compass needle reads the camera's own bearing, which costs nothing, so
* this can run on every rendered frame: measured at 1.4 ms across 120 frames
* of a continuously dragged map (0.012 ms per frame, 0.1 ms worst case),
* against the 4 ms per frame the depth read it replaced would have cost.
*/
function sync() {
if (destroyed) return;
const next = {
tilted,
heading: Math.round(headingDegrees(viewer?.camera)) % 360,
};
if (
applied &&
applied.tilted === next.tilted &&
applied.heading === next.heading
)
return;
if (!applied || applied.tilted !== next.tilted) {
tiltButton?.setAttribute('aria-pressed', String(next.tilted));
tiltButton?.setAttribute(
'aria-label',
next.tilted
? 'Return map to straight-down view'
: 'Tilt map to oblique view',
);
}
if (!applied || applied.heading !== next.heading) {
northButton?.style?.setProperty('--camera-heading', `${next.heading}deg`);
northButton?.setAttribute(
'aria-label',
`Reset map to north up. Current heading ${next.heading} degrees`,
);
}
applied = next;
}
const listen = (element, handler) => {
if (!element) return;
element.addEventListener('click', handler);
removers.push(() => element.removeEventListener('click', handler));
};
listen(tiltButton, () => {
const result = runNavigation('camera', () => toggleCameraTilt(viewer));
if (result) {
showToast?.(result.tilted ? 'Tilted view' : 'Straight-down view');
// The action reports the state it just commanded, so the button can
// follow it without a second pick.
tilted = result.tilted;
}
sync();
});
listen(northButton, () => {
const result = runNavigation('camera', () => resetCameraNorth(viewer));
if (result) showToast?.('North up');
sync();
});
const subscribe = (event, handler) => {
const remove = event?.addEventListener?.(handler);
if (typeof remove === 'function') removers.push(remove);
};
// The needle follows every frame the scene draws. `camera.changed` is NOT
// enough: it only fires past `camera.percentageChanged`, so a rotation of a
// few degrees leaves the needle pointing at the old bearing.
subscribe(viewer?.scene?.preRender, sync);
// The exact tilt state is refreshed once per gesture, not once per frame —
// moveEnd fires when the camera stops, which is where the pick is affordable.
subscribe(viewer?.camera?.moveEnd, refreshTilt);
refreshTilt();
return {
sync,
refreshTilt,
destroy() {
if (destroyed) return;
destroyed = true;
for (const remove of removers.splice(0)) remove();
},
};
}