diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 35d81dd05..f88d6e39d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/scripts/format-scope.json b/scripts/format-scope.json index bcf815de1..9052475ac 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 6d55213e7..0a3a636bf 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/app/scene.js b/src/app/scene.js index 1455b8268..3ca680b0a 100644 --- a/src/app/scene.js +++ b/src/app/scene.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))); diff --git a/src/app/zoomOutGuard.js b/src/app/zoomOutGuard.js new file mode 100644 index 000000000..14d09556d --- /dev/null +++ b/src/app/zoomOutGuard.js @@ -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; + }; +} diff --git a/src/app/zoomOutGuard.test.mjs b/src/app/zoomOutGuard.test.mjs new file mode 100644 index 000000000..568490b00 --- /dev/null +++ b/src/app/zoomOutGuard.test.mjs @@ -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); +});