diff --git a/src/app/viewer.js b/src/app/viewer.js index c8b841352..96c9851d9 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -2,6 +2,48 @@ import * as Cesium from 'cesium'; import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js'; import { StarrySky } from '../sky/starrySky.js'; +/** + * At the farthest a hand can zoom out, the globe is this share of the + * screen's height across. Cesium sets no limit, so zooming on left the globe + * a speck among the stars. + */ +const MIN_GLOBE_SHARE = 0.3; + +/** + * The farthest a hand can zoom out, in metres above the ellipsoid, for a + * vertical field of view. It follows the screen's shape, as the app's own + * whole-globe view does, so that view always lies within it. + * @param {number} fovy Vertical field of view, radians. + */ +export function zoomOutLimit(fovy) { + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const angular = Math.atan(MIN_GLOBE_SHARE * Math.tan(fovy / 2)); + return radius / Math.sin(angular) - radius; +} + +/** + * Hold a hand's zoom out to `zoomOutLimit` on the globe, each frame, since the + * field of view follows the screen's shape. The limit never sits below the + * camera: one already past it (a flight, a screen turned) stops there rather + * than jumping in at the next zoom. Flat maps keep Cesium's own, unlimited. + * @returns {() => void} Stop limiting. + */ +export function limitZoomOut(viewer) { + const { scene, camera } = viewer; + return scene.preRender.addEventListener(() => { + const controller = scene.screenSpaceCameraController; + const fovy = camera.frustum.fovy; + if (scene.mode !== Cesium.SceneMode.SCENE3D || !Number.isFinite(fovy)) { + controller.maximumZoomDistance = Number.POSITIVE_INFINITY; + return; + } + controller.maximumZoomDistance = Math.max( + zoomOutLimit(fovy), + camera.positionCartographic?.height ?? 0, + ); + }); +} + const PINCH_ZOOM_MULTIPLIER = 8; const MAX_PINCH_PIXEL_DELTA = 120; @@ -142,6 +184,7 @@ export function createApplicationViewer({ container, creditContainer }) { // required on touch-first screens. Mouse/trackpad sensitivity stays native. if (globalThis.matchMedia?.('(pointer: coarse)').matches) viewer.scene.screenSpaceCameraController.zoomFactor *= 3; + limitZoomOut(viewer); // Before any tile builds a draw command: Cesium's per-vertex model // atmosphere fails to LINK on Apple's Metal backend and kills the // render loop. See app/atmosphereCompat.js. diff --git a/src/app/viewer.test.mjs b/src/app/viewer.test.mjs new file mode 100644 index 000000000..94d80e20a --- /dev/null +++ b/src/app/viewer.test.mjs @@ -0,0 +1,73 @@ +// A hand zooming out stops while the globe is still a fair share of the +// screen, on any screen's shape, rather than leaving it a speck. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { limitZoomOut, zoomOutLimit } from './viewer.js'; + +// The share of the screen's height the globe spans from `altitude`. +function globeShare(altitude, fovy) { + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const angular = Math.asin(radius / (radius + altitude)); + return Math.tan(angular) / Math.tan(fovy / 2); +} + +test("zooming out stops with the globe 30% of the screen's height across", () => { + for (const fovy of [Math.PI / 3, 0.4, 0.2]) { + const limit = zoomOutLimit(fovy); + assert.ok(Math.abs(globeShare(limit, fovy) - 0.3) < 1e-9, `fovy ${fovy}`); + } +}); + +test('a wide screen, whose view is narrower, may zoom out farther', () => { + assert.ok(zoomOutLimit(0.4) > zoomOutLimit(Math.PI / 3)); + assert.ok(zoomOutLimit(Math.PI / 3) > 20_000_000, 'past the views a link opens'); +}); + +function fakeViewer({ fovy = Math.PI / 3, height = 10_000_000 } = {}) { + const scene = { + mode: Cesium.SceneMode.SCENE3D, + preRender: new Cesium.Event(), + screenSpaceCameraController: { maximumZoomDistance: Infinity }, + }; + const camera = { + frustum: { fovy }, + positionCartographic: { height }, + }; + return { scene, camera }; +} + +test('the limit follows the screen, and holds a camera already past it', () => { + const viewer = fakeViewer(); + const controller = viewer.scene.screenSpaceCameraController; + const stop = limitZoomOut(viewer); + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, zoomOutLimit(Math.PI / 3)); + + // A wider screen narrows the view, and the globe may go farther. + viewer.camera.frustum.fovy = 0.4; + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4)); + + // Flown past it, the next zoom out stops there instead of jumping in. + viewer.camera.positionCartographic.height = zoomOutLimit(0.4) * 2; + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4) * 2); + + stop(); + viewer.camera.positionCartographic.height = 0; + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4) * 2); +}); + +test('flat maps keep zooming out unlimited', () => { + const viewer = fakeViewer(); + const controller = viewer.scene.screenSpaceCameraController; + limitZoomOut(viewer); + viewer.scene.preRender.raiseEvent(); + assert.ok(Number.isFinite(controller.maximumZoomDistance)); + viewer.scene.mode = Cesium.SceneMode.SCENE2D; + viewer.camera.frustum = {}; + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, Infinity); +});