diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index c899cd684..e6ba0f8c0 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -171,6 +171,7 @@ export class StyleManager { cancelShareSelection: () => this._shareRestoration.cancelSelection(), getDataManager: () => this._dataManager, stopOrbit: () => this._stopOrbit(), + cancelOrientation: () => this._cameraOrientationControls?.cancel(), showToast: (text) => this._showToast(text), }); this._shareRestoration = new ShareRestoration({ @@ -3281,7 +3282,7 @@ export class StyleManager { northButton: this._northUpBtn, }, runNavigation: (noun, navigate) => - this._runExplicitNavigation(noun, navigate), + this._navigation.runOrientation(noun, navigate), showToast: (message) => this._showToast(message), }); } diff --git a/src/ui/cameraOrientationControls.js b/src/ui/cameraOrientationControls.js index ba3ca46e3..a76d8cf7e 100644 --- a/src/ui/cameraOrientationControls.js +++ b/src/ui/cameraOrientationControls.js @@ -1,6 +1,16 @@ import * as Cesium from 'cesium'; import { isPickedWorldPosition } from '../data/scenePick.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); @@ -63,7 +73,9 @@ export function pickViewTarget(viewer) { /** Describe the camera as an orbit around the current viewport center. */ export function readCameraTargetFrame(viewer) { const camera = viewer?.camera; - const target = pickViewTarget(viewer); + const target = viewer?.trackedEntity + ? trackedTarget(viewer) + : pickViewTarget(viewer); if (!camera || !target || !isPickedWorldPosition(camera.positionWC)) return null; @@ -98,6 +110,9 @@ export 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( @@ -106,6 +121,13 @@ export function setCameraTargetFrame(viewer, frame) { 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); @@ -160,6 +182,62 @@ 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; + }, + }; +} + /** Bind the two map-orientation actions and keep their accessible state current. */ export function bindCameraOrientationControls({ viewer, @@ -176,6 +254,7 @@ export function bindCameraOrientationControls({ // 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 = () => { @@ -229,7 +308,19 @@ export function bindCameraOrientationControls({ removers.push(() => element.removeEventListener('click', handler)); }; listen(tiltButton, () => { - const result = runNavigation('camera', () => toggleCameraTilt(viewer)); + 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 ? 'Tilted view' : 'Straight-down view'); // The action reports the state it just commanded, so the button can @@ -239,7 +330,17 @@ export function bindCameraOrientationControls({ sync(); }); listen(northButton, () => { - const result = runNavigation('camera', () => resetCameraNorth(viewer)); + 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?.('North up'); sync(); }); @@ -255,14 +356,22 @@ export function bindCameraOrientationControls({ // 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 { sync, refreshTilt, + cancel: animator.cancel, destroy() { if (destroyed) return; destroyed = true; + animator.cancel(); for (const remove of removers.splice(0)) remove(); }, }; diff --git a/src/ui/cameraOrientationControls.test.mjs b/src/ui/cameraOrientationControls.test.mjs index b706e5b7a..264c68deb 100644 --- a/src/ui/cameraOrientationControls.test.mjs +++ b/src/ui/cameraOrientationControls.test.mjs @@ -5,11 +5,13 @@ import { OBLIQUE_PITCH, STRAIGHT_DOWN_PITCH, bindCameraOrientationControls, + createCameraOrientationAnimator, pickViewTarget, readCameraTargetFrame, resetCameraNorth, toggleCameraTilt, } from './cameraOrientationControls.js'; +import { NavigationController } from './navigationController.js'; function createViewer({ cameraPosition, target, pickPosition = null } = {}) { const calls = []; @@ -489,3 +491,173 @@ test('bindings route both controls and release every listener on destroy', () => assert.deepEqual(navigations, ['camera', 'camera']); assert.equal(STRAIGHT_DOWN_PITCH, Cesium.Math.toRadians(-89)); }); + +for (const [label, altitude, range] of [ + ['aircraft', 10000, 1500], + ['satellite', 400000, 1500000], + ['high satellite', 35786000, 3000000], +]) { + test(`${label}: animated tilt and north retain a moving EntityView`, (t) => { + // Use Cesium's built-in TEME fallback without fetching Earth-orientation data. + t.mock.method( + Cesium.Transforms, + 'computeFixedToIcrfMatrix', + () => undefined, + ); + let target = Cesium.Cartesian3.fromDegrees(-97, 30, altitude); + const { viewer } = createRealViewer(target); + viewer.scene.preUpdate = new Cesium.Event(); + viewer.clock = { currentTime: Cesium.JulianDate.now() }; + const entity = new Cesium.Entity({ + position: new Cesium.CallbackPositionProperty(() => target, false), + trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU, + }); + entity.gevDisplayPosition = () => target; + viewer.trackedEntity = entity; + const follow = new Cesium.EntityView(entity, viewer.scene); + follow.update(viewer.clock.currentTime); + viewer.camera.lookAt( + target, + new Cesium.HeadingPitchRange( + Cesium.Math.toRadians(350), + OBLIQUE_PITCH, + range, + ), + ); + let time = 0; + const animator = createCameraOrientationAnimator(viewer, { + now: () => time, + duration: 1000, + }); + for (const pitch of [STRAIGHT_DOWN_PITCH, OBLIQUE_PITCH]) { + const frame = readCameraTargetFrame(viewer); + animator.animate(frame, { heading: 0, pitch }); + for (const step of [0, 100, 500, 900, 1000]) { + time = (pitch === STRAIGHT_DOWN_PITCH ? 0 : 1000) + step; + target = Cesium.Cartesian3.fromDegrees( + -97 + time / 100000, + 30, + altitude, + ); + follow.update(viewer.clock.currentTime); + const transform = Cesium.Matrix4.clone(viewer.camera.transform); + viewer.scene.preUpdate.raiseEvent(); + assert.equal(viewer.trackedEntity, entity); + assert.ok( + Cesium.Matrix4.equalsEpsilon( + viewer.camera.transform, + transform, + 1e-8, + ), + ); + const actual = readCameraTargetFrame(viewer); + const eased = Cesium.EasingFunction.CUBIC_IN_OUT(step / 1000); + assert.ok( + Math.abs(actual.pitch - Cesium.Math.lerp(frame.pitch, pitch, eased)) < + 1e-6, + ); + assert.ok(Math.abs(actual.range - range) < 1e-5); + } + assert.equal(viewer.scene.preUpdate.numberOfListeners, 0); + } + target = Cesium.Cartesian3.fromDegrees(-96, 31, altitude); + follow.update(viewer.clock.currentTime); + assert.equal(viewer.trackedEntity, entity); + assert.ok(Math.abs(readCameraTargetFrame(viewer).range - range) < 1e-5); + }); +} + +test('orientation animation cancels on replacement and explicit cancellation', () => { + const { viewer } = createRealViewer(Cesium.Cartesian3.fromDegrees(0, 0)); + orbit(viewer, Cesium.Cartesian3.fromDegrees(0, 0), 90, OBLIQUE_PITCH, 1000); + viewer.scene.preUpdate = new Cesium.Event(); + const animator = createCameraOrientationAnimator(viewer); + const frame = readCameraTargetFrame(viewer); + animator.animate(frame, { heading: 0, pitch: STRAIGHT_DOWN_PITCH }); + animator.animate(frame, { heading: 0, pitch: OBLIQUE_PITCH }); + assert.equal(viewer.scene.preUpdate.numberOfListeners, 1); + viewer.trackedEntity = {}; + viewer.scene.preUpdate.raiseEvent(); + assert.equal(viewer.scene.preUpdate.numberOfListeners, 0); + viewer.trackedEntity = undefined; + animator.animate(frame, { heading: 0, pitch: STRAIGHT_DOWN_PITCH }); + animator.cancel(); + assert.equal(viewer.scene.preUpdate.numberOfListeners, 0); +}); + +test('orientation navigation preserves follow and selection; new navigation cancels animation', () => { + const calls = []; + const entity = {}; + const viewer = { + trackedEntity: entity, + camera: { cancelFlight: () => calls.push('cancel-flight') }, + }; + let cockpit = false; + const navigation = new NavigationController({ + viewer, + tracking: {}, + isCockpitActive: () => cockpit, + cancelOrientation: () => calls.push('cancel-orientation'), + clearLocation: () => calls.push('clear-location'), + cancelShareSelection: () => calls.push('clear-selection'), + interruptCameraMotion: () => calls.push('interrupt-motion'), + stopOrbit: () => calls.push('stop-orbit'), + showToast: () => calls.push('toast'), + }); + assert.equal( + navigation.runOrientation('camera', () => 'animated'), + 'animated', + ); + assert.equal(viewer.trackedEntity, entity); + assert.deepEqual(calls, [ + 'cancel-orientation', + 'interrupt-motion', + 'stop-orbit', + 'cancel-flight', + ]); + calls.length = 0; + navigation._stampNavigation({ cancelPendingSelection: false }); + assert.deepEqual(calls, ['cancel-orientation', 'clear-location']); + calls.length = 0; + cockpit = true; + assert.equal( + navigation.runOrientation('camera', () => + assert.fail('cockpit should refuse'), + ), + false, + ); + assert.deepEqual(calls, ['toast']); +}); + +test('rapid tilt presses reverse direction and north-up keeps the requested tilt', (t) => { + let time = 0; + t.mock.method(performance, 'now', () => time); + const target = Cesium.Cartesian3.fromDegrees(0, 0); + const { viewer } = createRealViewer(target); + orbit(viewer, target, 90, OBLIQUE_PITCH, 1000); + viewer.scene.preUpdate = new Cesium.Event(); + const tiltButton = new FakeButton(); + const northButton = new FakeButton(); + let controls; + controls = bindCameraOrientationControls({ + viewer, + elements: { tiltButton, northButton }, + runNavigation: (_noun, navigate) => { + controls.cancel(); + return navigate(); + }, + }); + tiltButton.click(); + tiltButton.click(); + assert.equal(tiltButton.getAttribute('aria-pressed'), 'true'); + tiltButton.click(); + northButton.click(); + assert.equal(viewer.scene.preUpdate.numberOfListeners, 1); + time = 650; + viewer.scene.preUpdate.raiseEvent(); + const frame = readCameraTargetFrame(viewer); + assert.ok(Math.abs(frame.pitch - STRAIGHT_DOWN_PITCH) < 1e-6); + assert.ok(Math.abs(frame.heading) < 1e-6); + controls.destroy(); + assert.equal(viewer.scene.preUpdate.numberOfListeners, 0); +}); diff --git a/src/ui/layerPanel.js b/src/ui/layerPanel.js index 73cf5ecdd..d213cdc87 100644 --- a/src/ui/layerPanel.js +++ b/src/ui/layerPanel.js @@ -11,6 +11,60 @@ const FEED_STATE_LABELS = Object.freeze({ unavailable: 'UNAVAILABLE', }); +// Presentation order is independent of catalog registration and startup order. +const PANEL_GROUPS = [ + { + label: 'Movement', + ids: [ + 'satellites', + 'flights', + 'military', + 'ais-live-vessels', + 'traffic', + 'bikeshare', + ], + }, + { + label: 'Cameras', + ids: ['cctv', 'alpr-cameras'], + }, + { + label: 'Infrastructure', + ids: [ + 'military-installations', + 'local-datacenters', + 'telegeography-submarine-cables', + 'local-dams', + ], + }, + { + label: 'Events', + ids: ['rocket-launches', 'earthquakes', 'local-firms'], + }, + { + label: 'Utilities', + ids: ['directions', 'radio'], + }, +]; +const PANEL_ORDER = PANEL_GROUPS.flatMap(({ label, ids }) => + ids.map((id) => ({ id, label })), +); +const PANEL_POSITIONS = new Map( + PANEL_ORDER.map(({ id }, index) => [id, index]), +); +const PANEL_LABELS = { + 'ais-live-vessels': 'Live Vessels', + bikeshare: 'Bike Share', + cctv: 'Cameras', + 'alpr-cameras': 'Mapped ALPR Cameras', + 'local-datacenters': 'Data Centers', + 'local-firms': 'Active Fires', +}; + +function panelLabel(layer) { + return PANEL_LABELS[layer.id] || layer.name; +} + /** * Guidance for a control a missing provider key is holding back. * @@ -81,8 +135,25 @@ export class LayerPanel { this._toggleContainer.innerHTML = ''; const generation = this._generation; - for (const layer of this.getAll()) { + const layers = this.getAll() + .slice() + .sort( + (a, b) => + (PANEL_POSITIONS.get(a.id) ?? PANEL_ORDER.length) - + (PANEL_POSITIONS.get(b.id) ?? PANEL_ORDER.length), + ); + let previousGroup = ''; + for (const layer of layers) { if (!layer.showInTogglePanel) continue; + const group = + PANEL_ORDER[PANEL_POSITIONS.get(layer.id)]?.label ?? 'Other layers'; + if (group && group !== previousGroup) { + const heading = document.createElement('h3'); + heading.className = 'data-layer-group-heading'; + heading.textContent = group; + this._toggleContainer.appendChild(heading); + } + previousGroup = group; const row = document.createElement('div'); row.className = 'data-toggle-row'; row.dataset.layerId = layer.id; @@ -97,7 +168,7 @@ export class LayerPanel { icon.textContent = layer.icon; const name = document.createElement('span'); name.className = 'data-name'; - name.textContent = layer.name; + name.textContent = panelLabel(layer); left.appendChild(icon); left.appendChild(name); @@ -489,8 +560,8 @@ export class LayerPanel { button.setAttribute( 'aria-label', keyGuidance - ? `${layer.name}: ${button.textContent}. ${keyGuidance}` - : `${layer.name}: ${button.textContent}`, + ? `${panelLabel(layer)}: ${button.textContent}. ${keyGuidance}` + : `${panelLabel(layer)}: ${button.textContent}`, ); } diff --git a/src/ui/navigationController.js b/src/ui/navigationController.js index 4fd3b02db..0cf05135b 100644 --- a/src/ui/navigationController.js +++ b/src/ui/navigationController.js @@ -18,6 +18,7 @@ export class NavigationController { getDataManager, stopOrbit, showToast, + cancelOrientation = () => {}, }) { Object.assign(this, { viewer, @@ -30,6 +31,7 @@ export class NavigationController { getDataManager, stopOrbit, showToast, + cancelOrientation, }); this._navigationGeneration = 0; this._activeLocationSearchGeneration = null; @@ -39,6 +41,7 @@ export class NavigationController { cancelPendingSelection = true, clearSearchedLocation = true, } = {}) { + this.cancelOrientation(); const { flightsLayer, militaryFlightsLayer, satellitesLayer } = this.tracking; this._navigationGeneration += 1; @@ -188,6 +191,27 @@ export class NavigationController { }); } + /** Change the viewing angle without clearing selection or follow ownership. */ + runOrientation(noun, navigate) { + return runExplicitNavigation({ + disposed: this._disposed, + cockpitActive: this.isCockpitActive(), + noun, + showToast: (text) => this.showToast(text), + stamp: () => + this._stampNavigation({ + cancelPendingSelection: false, + clearSearchedLocation: false, + }), + release: () => { + this.interruptCameraMotion('camera-orientation'); + this.stopOrbit(); + this.viewer.camera.cancelFlight(); + }, + navigate, + }); + } + _beginDeferredNavigation( noun = 'location', { cancelPendingSelection = true } = {}, diff --git a/src/ui/styles/layers.css b/src/ui/styles/layers.css index ae3c8dcfa..7a85745cf 100644 --- a/src/ui/styles/layers.css +++ b/src/ui/styles/layers.css @@ -107,6 +107,23 @@ border-radius: 999px; } +.data-layer-group-heading { + margin: 8px 10px 2px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + color: var(--text-secondary); + font-size: 10px; + font-weight: 600; + letter-spacing: 1.4px; + text-transform: uppercase; +} + +.data-layer-group-heading:first-child { + margin-top: 0; + padding-top: 4px; + border-top: 0; +} + .data-toggle-row { padding: 8px 10px 6px; border-radius: 8px;