mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
f3adccd01a
commit
e6a6ba0126
6 files changed
+319
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user