import * as Cesium from 'cesium'; import { isPickedWorldPosition } from '../data/scenePick.js'; import { t } from '../i18n/index.js'; function trackedTarget(viewer) { const entity = viewer.trackedEntity; // Read the visual's sample first; sampling a flight CallbackProperty twice // can advance dead reckoning ahead of the displayed aircraft. return ( entity?.gevDisplayPosition?.() || entity?.position?.getValue(viewer.clock?.currentTime) ); } 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 = viewer?.trackedEntity ? trackedTarget(viewer) : 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. */ function setCameraTargetFrame(viewer, frame) { const camera = viewer?.camera; if (!camera || !frame?.target || !Number.isFinite(frame.range)) return false; try { const trackedTransform = viewer.trackedEntity ? Cesium.Matrix4.clone(camera.transform) : null; camera.lookAt( frame.target, new Cesium.HeadingPitchRange( normalizedHeading(frame.heading), frame.pitch, frame.range, ), ); if (trackedTransform) { // Preserve EntityView's reference frame (including satellite frames). // Its next update carries this new local offset along with the target. camera.lookAtTransform(trackedTransform); viewer.scene?.requestRender?.(); return true; } 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}. */ 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)); } /** Ease an orbit without handing off the entity that owns the follow camera. */ export function createCameraOrientationAnimator( viewer, { now = () => performance.now(), duration = 650 } = {}, ) { let remove = null; let pending = null; const cancel = () => { remove?.(); remove = null; pending = null; }; function animate(frame, destination) { cancel(); if (!viewer.scene?.preUpdate?.addEventListener || duration <= 0) return setCameraTargetFrame(viewer, { ...frame, ...destination }); const entity = viewer.trackedEntity; pending = destination; const start = now(); const headingDelta = Cesium.Math.negativePiToPi( destination.heading - frame.heading, ); remove = viewer.scene.preUpdate.addEventListener(() => { if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) { cancel(); return; } const progress = Cesium.Math.clamp((now() - start) / duration, 0, 1); const eased = Cesium.EasingFunction.CUBIC_IN_OUT(progress); const target = entity ? trackedTarget(viewer) : frame.target; if ( !isPickedWorldPosition(target) || !setCameraTargetFrame(viewer, { ...frame, target, heading: frame.heading + headingDelta * eased, pitch: Cesium.Math.lerp(frame.pitch, destination.pitch, eased), }) ) { cancel(); return; } if (progress === 1) cancel(); }); viewer.scene.requestRender?.(); return true; } return { animate, cancel, get destination() { return pending; }, }; } /** * Answer the two map-orientation actions and publish the state their buttons * show: whether the map is tilted and the camera's whole-degree heading. * The state is published on every rendered frame; its store keeps only a * change, so an owner that takes the buttons back shows its state at once. */ export function bindCameraOrientationControls({ viewer, publish, runNavigation, showToast, }) { const removers = []; let destroyed = false; // 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; const animator = createCameraOrientationAnimator(viewer); /** Recompute the tilt state exactly, paying for one pick. */ const refreshTilt = () => { if (destroyed) return; tilted = frameIsTilted(readCameraTargetFrame(viewer)); sync(); }; /** * Publish the two controls' state. * * 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. * That measured the controls' own work; what a publish renders is the * view's (the globe actions compass alone renders the heading). */ function sync() { if (destroyed) return; const next = { tilted, heading: Math.round(headingDegrees(viewer?.camera)) % 360, }; publish?.(next); } function tilt() { if (destroyed) return; const pending = animator.destination; const result = runNavigation('camera', () => { const frame = readCameraTargetFrame(viewer); if (!frame) return false; const nextTilted = !frameIsTilted(pending || frame); const pitch = nextTilted ? OBLIQUE_PITCH : STRAIGHT_DOWN_PITCH; return animator.animate(frame, { heading: pending?.heading ?? frame.heading, pitch, }) ? { tilted: nextTilted, pitch } : false; }); if (result) { showToast?.(result.tilted ? t('Tilted view') : t('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(); } function northUp() { if (destroyed) return; const pending = animator.destination; const result = runNavigation('camera', () => { const frame = readCameraTargetFrame(viewer); return ( frame && animator.animate(frame, { heading: 0, pitch: pending?.pitch ?? frame.pitch, }) ); }); if (result) showToast?.(t('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); subscribe(viewer?.trackedEntityChanged, animator.cancel); for (const type of ['pointerdown', 'wheel']) { const canvas = viewer?.scene?.canvas; canvas?.addEventListener?.(type, animator.cancel, { passive: true }); removers.push(() => canvas?.removeEventListener?.(type, animator.cancel)); } refreshTilt(); return { tilt, northUp, sync, refreshTilt, cancel: animator.cancel, destroy() { if (destroyed) return; destroyed = true; animator.cancel(); for (const remove of removers.splice(0)) remove(); }, }; }