fix: keep the globe on screen when zooming out from a tilted view

Cesium zooms a shallow view out by backing along the cursor ray with
heading and pitch held fixed, and GEV set no zoom ceiling. Past ~700 km
the limb drops below the fixed pitch, the steps grow with height, and
the camera flies off into space.

Cap user zoom at 40,000 km (whole-Earth view; flights are not limited)
and, above 300 km during a user wheel, pinch or right-drag zoom-out,
ease the pitch down to at least 8 degrees below the Earth's limb. The
clamp yields to disabled inputs, tracked transforms and camera flights.
This commit is contained in:
Said Turksever committed 2026-11-03 13:50:16 +00:00
1 parent f3adccd01a
commit e6a6ba0126
6 files changed
+319

No files matched your search

+8
View File
@@ -44,6 +44,14 @@ pinching traverses globe and street scales with fewer gestures. Cesium retains
its distance-aware zoom, collision handling and minimum/maximum distance limits.
Fine-pointer desktop mouse and trackpad sensitivity is unchanged.
User zoom stops at 40,000 km above the surface, a whole-Earth view; camera
flights, share links and story views are not limited. Above 300 km, a
wheel, pinch or right-drag zoom-out from a shallow, tilted view pitches the
camera down as it climbs, keeping the view at least 8° below the Earth's limb,
so the globe stays on screen instead of sliding off into space. Oblique views
below 300 km, zooming in, tilting by drag, tracked entities, cockpit tracking
and camera flights are left alone.
Analyst region queries resolve unique Natural Earth names offline without
geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
Duplicate names require a geocoded containment anchor; when several entries for
+2
View File
@@ -57,6 +57,8 @@
"src/app/mapLifecycle.test.mjs",
"src/app/startupCamera.test.mjs",
"src/app/viewer.js",
"src/app/zoomOutGuard.js",
"src/app/zoomOutGuard.test.mjs",
"src/app/atmosphereCompat.test.mjs",
"src/standalone/application.js",
"src/standalone/controls.js",
+1
View File
@@ -2274,6 +2274,7 @@
"src/app/layers/traffic.js",
"src/app/operations.js",
"src/app/scene.js",
"src/app/zoomOutGuard.js",
"src/sources/sourceSlot.js",
"src/app/sources.js",
"src/app/startupChrome.js",
+2
View File
@@ -4,6 +4,7 @@ import {
createApplicationViewer,
installTrackpadPinchZoom,
} from '../app/viewer.js';
import { installZoomOutGuard } from './zoomOutGuard.js';
import { registerDataCredits } from '../data/dataCredits.js';
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
import { MapStackController } from '../mapStackController.js';
@@ -58,6 +59,7 @@ export async function createApplicationScene({
if (!viewer.isDestroyed()) viewer.destroy();
});
defer(installTrackpadPinchZoom(viewer));
defer(installZoomOutGuard(viewer));
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
+106
View File
@@ -0,0 +1,106 @@
import * as Cesium from 'cesium';
import { governorRequestRender } from '../renderGovernor.js';
/** Whole-Earth view. Programmatic flights ignore it; only user input stops here. */
export const MAXIMUM_ZOOM_DISTANCE_M = 40_000_000;
/** Below this, oblique browsing is left alone. */
export const PITCH_CLAMP_MIN_HEIGHT_M = 300_000;
/** Look this far below the Earth's limb, so the globe stays on screen. */
export const LIMB_MARGIN_RAD = Cesium.Math.toRadians(8);
/** How long after the last zoom input the camera still counts as user-zoomed. */
export const USER_ZOOM_WINDOW_MS = 500;
/** Fraction of the remaining pitch error closed per frame. */
const PITCH_EASE = 0.35;
const PITCH_SNAP_RAD = Cesium.Math.toRadians(0.1);
const EARTH_RADIUS_M = 6_371_000;
/**
* The shallowest pitch (radians, negative is down) that still keeps the Earth
* framed from height `h`, or null where tilted views are left alone.
* The limb sits acos(R / (R + h)) below the local horizontal.
*/
export function maxPitchForHeight(h) {
if (!(h >= PITCH_CLAMP_MIN_HEIGHT_M)) return null;
const limb = Math.acos(EARTH_RADIUS_M / (EARTH_RADIUS_M + h));
return -Math.min(limb + LIMB_MARGIN_RAD, Cesium.Math.PI_OVER_TWO);
}
function isUserZoomInput(event) {
if (event.type === 'wheel') return true;
if (event.type === 'touchmove') return (event.touches?.length ?? 0) >= 2;
// Cesium's default zoom bindings also include a right-button drag.
return event.type === 'pointermove' && (event.buttons & 2) === 2;
}
/**
* Stop a tilted wheel/pinch zoom-out from flying the camera off into space.
*
* Cesium zooms a shallow view out by backing along the cursor ray with the
* pitch held fixed, and with no ceiling. The limb drops below a fixed pitch
* by ~700 km, then the steps grow with height and never stop. This caps the
* zoom distance and, only while the user is zooming out, pitches the view
* down to keep the globe framed. Returns its disposer.
*/
export function installZoomOutGuard(
viewer,
{ now = () => performance.now(), requestRender = governorRequestRender } = {},
) {
const scene = viewer?.scene;
const controller = scene?.screenSpaceCameraController;
const camera = viewer?.camera;
const container = viewer?.container;
if (!controller || !camera || !container || !scene.preRender)
throw new TypeError('A complete Cesium viewer is required');
const originalMaximumZoomDistance = controller.maximumZoomDistance;
controller.maximumZoomDistance = MAXIMUM_ZOOM_DISTANCE_M;
let lastUserZoomAt = -Infinity;
const markInput = (event) => {
if (isUserZoomInput(event)) lastUserZoomAt = now();
};
const inputTypes = ['wheel', 'touchmove', 'pointermove'];
for (const type of inputTypes)
container.addEventListener(type, markInput, {
capture: true,
passive: true,
});
let lastHeight = NaN;
const guard = () => {
const height = camera.positionCartographic?.height;
const previousHeight = lastHeight;
lastHeight = height;
if (!(height > previousHeight)) return; // Zoom-out only.
if (now() - lastUserZoomAt > USER_ZOOM_WINDOW_MS) return;
if (controller.enableInputs === false) return;
if (!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY))
return;
// Cesium keeps no public flag for a running flyTo; never fight one.
if (camera._currentFlight) return;
const maxPitch = maxPitchForHeight(height);
if (maxPitch === null || !(camera.pitch > maxPitch)) return;
const error = maxPitch - camera.pitch;
const pitch =
Math.abs(error) <= PITCH_SNAP_RAD
? maxPitch
: camera.pitch + error * PITCH_EASE;
camera.setView({
orientation: { heading: camera.heading, pitch, roll: camera.roll },
});
lastHeight = camera.positionCartographic?.height;
requestRender('zoom-out-guard');
};
const removePreRender = scene.preRender.addEventListener(guard);
let active = true;
return () => {
if (!active) return;
active = false;
removePreRender?.();
for (const type of inputTypes)
container.removeEventListener(type, markInput, true);
if (controller.maximumZoomDistance === MAXIMUM_ZOOM_DISTANCE_M)
controller.maximumZoomDistance = originalMaximumZoomDistance;
};
}
+200
View File
@@ -0,0 +1,200 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
installZoomOutGuard,
maxPitchForHeight,
MAXIMUM_ZOOM_DISTANCE_M,
USER_ZOOM_WINDOW_MS,
} from './zoomOutGuard.js';
const deg = Cesium.Math.toDegrees;
test('maxPitchForHeight leaves low-altitude tilt alone', () => {
assert.equal(maxPitchForHeight(0), null);
assert.equal(maxPitchForHeight(299_999), null);
assert.equal(maxPitchForHeight(NaN), null);
assert.equal(maxPitchForHeight(undefined), null);
});
test('maxPitchForHeight looks 8 degrees below the limb', () => {
// Limb at 1,000 km is 30.2 degrees below the horizontal.
assert.ok(Math.abs(deg(maxPitchForHeight(1_000_000)) + 38.19) < 0.01);
assert.equal(deg(maxPitchForHeight(1e12)), -90);
});
test('maxPitchForHeight steepens monotonically with height', () => {
let previous = 0;
for (let h = 300_000; h <= 100_000_000; h *= 1.5) {
const pitch = maxPitchForHeight(h);
assert.ok(pitch <= previous, `pitch at ${h} m`);
previous = pitch;
}
});
function guardFixture({ maximumZoomDistance = Infinity } = {}) {
let clock = 0;
const listeners = new Map();
const preRender = new Set();
const container = {
addEventListener(type, handler, options) {
assert.equal(options.capture, true);
listeners.set(type, handler);
},
removeEventListener(type, handler, capture) {
assert.equal(capture, true);
if (listeners.get(type) === handler) listeners.delete(type);
},
emit(event) {
listeners.get(event.type)?.(event);
},
};
const camera = {
height: 170_000,
heading: 0.4,
pitch: Cesium.Math.toRadians(-25),
roll: 0,
transform: Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY),
views: [],
get positionCartographic() {
return { height: this.height };
},
setView({ orientation }) {
this.views.push(orientation);
this.heading = orientation.heading;
this.pitch = orientation.pitch;
this.roll = orientation.roll;
},
};
const controller = { maximumZoomDistance, enableInputs: true };
const viewer = {
camera,
container,
scene: {
screenSpaceCameraController: controller,
preRender: {
addEventListener(handler) {
preRender.add(handler);
return () => preRender.delete(handler);
},
},
},
};
const renders = [];
const dispose = installZoomOutGuard(viewer, {
now: () => clock,
requestRender: (reason) => renders.push(reason),
});
const frame = (height) => {
camera.height = height;
for (const handler of preRender) handler();
};
return {
camera,
controller,
container,
listeners,
preRender,
renders,
dispose,
frame,
advance: (ms) => (clock += ms),
};
}
test('a tilted wheel zoom-out pitches down to keep the globe framed', () => {
const f = guardFixture();
f.frame(170_000);
f.container.emit({ type: 'wheel' });
f.frame(1_000_000);
const target = maxPitchForHeight(1_000_000);
assert.ok(f.camera.pitch < Cesium.Math.toRadians(-25));
assert.ok(f.camera.pitch > target, 'eases rather than snapping');
assert.equal(f.camera.heading, 0.4);
for (let i = 1; i <= 40; i++) f.frame(1_000_000 + i);
assert.ok(
Math.abs(f.camera.pitch - maxPitchForHeight(f.camera.height)) < 1e-3,
);
assert.ok(f.renders.length > 0);
});
test('two-finger touch and right-drag count as zoom input, one finger does not', () => {
const f = guardFixture();
f.frame(500_000);
f.container.emit({ type: 'touchmove', touches: [1] });
f.container.emit({ type: 'pointermove', buttons: 1 });
f.frame(600_000);
assert.equal(f.camera.views.length, 0);
f.container.emit({ type: 'touchmove', touches: [1, 2] });
f.frame(700_000);
assert.equal(f.camera.views.length, 1);
f.container.emit({ type: 'pointermove', buttons: 2 });
f.frame(800_000);
assert.equal(f.camera.views.length, 2);
});
test('guard leaves the camera alone without recent user zoom input', () => {
const f = guardFixture();
f.frame(170_000);
f.frame(2_000_000); // programmatic flight, no input
f.container.emit({ type: 'wheel' });
f.advance(USER_ZOOM_WINDOW_MS + 1);
f.frame(3_000_000);
assert.equal(f.camera.views.length, 0);
});
test('guard only acts while zooming out', () => {
const f = guardFixture();
f.frame(2_000_000);
f.container.emit({ type: 'wheel' });
f.frame(1_500_000);
f.frame(1_500_000);
assert.equal(f.camera.views.length, 0);
});
test('guard yields to disabled inputs, tracked transforms and flights', () => {
const f = guardFixture();
f.frame(500_000);
f.container.emit({ type: 'wheel' });
f.controller.enableInputs = false;
f.frame(600_000);
f.controller.enableInputs = true;
f.camera.transform = Cesium.Matrix4.fromTranslation(
new Cesium.Cartesian3(1, 2, 3),
);
f.frame(700_000);
f.camera.transform = Cesium.Matrix4.clone(Cesium.Matrix4.IDENTITY);
f.camera._currentFlight = {};
f.frame(800_000);
delete f.camera._currentFlight;
assert.equal(f.camera.views.length, 0);
f.frame(900_000);
assert.equal(f.camera.views.length, 1);
});
test('installer sets the zoom ceiling and the disposer restores it', () => {
const f = guardFixture({ maximumZoomDistance: 123 });
assert.equal(f.controller.maximumZoomDistance, MAXIMUM_ZOOM_DISTANCE_M);
assert.equal(f.listeners.size, 3);
assert.equal(f.preRender.size, 1);
f.dispose();
f.dispose();
assert.equal(f.controller.maximumZoomDistance, 123);
assert.equal(f.listeners.size, 0);
assert.equal(f.preRender.size, 0);
});
test('disposer keeps a ceiling someone else changed later', () => {
const f = guardFixture();
f.controller.maximumZoomDistance = 5;
f.dispose();
assert.equal(f.controller.maximumZoomDistance, 5);
});
test('installer rejects an incomplete viewer', () => {
assert.throws(() => installZoomOutGuard({}), TypeError);
});