diff --git a/src/layers/launches/controls.js b/src/layers/launches/controls.js index b0dc035cb..95a212a73 100644 --- a/src/layers/launches/controls.js +++ b/src/layers/launches/controls.js @@ -12,6 +12,8 @@ export function createControls({ state: layerState, services, parts, source }) { /** Release only Space Mission camera ownership, preserving layer and selection state. */ releaseCameraOwnership() { + // A failed entry no longer gives back the view it replaced. + layerState._entryCameraView = null; parts.panel.clearMissionRosterHover(); parts.replay.stopMissionReplay(); parts.selection.stopMissionZoomAnchor(); diff --git a/src/layers/launches/failedEntry.test.mjs b/src/layers/launches/failedEntry.test.mjs index f8b760dde..b82663597 100644 --- a/src/layers/launches/failedEntry.test.mjs +++ b/src/layers/launches/failedEntry.test.mjs @@ -57,17 +57,29 @@ function fakeCamera() { roll: 0, flights: 0, cancelled: 0, + flight: null, get positionWC() { return camera.position; }, - flyTo({ destination }) { + // Like Cesium, a new flight cancels the one in progress. + flyTo({ destination, complete, cancel }) { + camera.flight?.cancel?.(); + camera.flight = { complete, cancel }; camera.flights += 1; camera.position = Cesium.Cartesian3.clone(destination); camera.heading = 0; camera.pitch = -Math.PI / 2; }, + land() { + const flight = camera.flight; + camera.flight = null; + flight?.complete?.(); + }, cancelFlight() { camera.cancelled += 1; + const flight = camera.flight; + camera.flight = null; + flight?.cancel?.(); }, setView({ destination, orientation }) { camera.position = Cesium.Cartesian3.clone(destination); @@ -81,11 +93,12 @@ function fakeCamera() { async function fixture(t) { const upstream = { status: 429, results: [launchRecord] }; - const server = http.createServer((req, res) => { + const server = http.createServer(async (req, res) => { if (req.url === '/api/celestrak/active') { res.end(''); return; } + if (upstream.hold) await upstream.hold; if (upstream.status !== 200) { res.writeHead(upstream.status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Launch Library 2 unavailable' })); @@ -134,8 +147,13 @@ async function fixture(t) { }, replay: { stopMissionReplay: noop, presentReplay: () => null }, selection: { - focusFullGlobe: (target) => - target.camera.flyTo({ destination: new Cesium.Cartesian3(9, 9, 9) }), + focusFullGlobe(target, flight) { + target.camera.flyTo({ + destination: new Cesium.Cartesian3(9, 9, 9), + ...flight, + }); + return true; + }, setSelectedMission: noop, focusMission: noop, stopMissionZoomAnchor: noop, @@ -284,6 +302,87 @@ test('a Context-panel Space Missions entry that gets HTTP 429 restores the previ assert.deepEqual(f.view(), before); }); +// Holds the /api/launches reply until the returned release is called. +function holdReply(f) { + let release; + f.upstream.hold = new Promise((resolve) => { + release = resolve; + }); + return () => { + f.upstream.hold = null; + release(); + }; +} + +async function untilFlown(f) { + for (let i = 0; i < 200 && f.camera.flights === 0; i++) + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.ok(f.camera.flights > 0, 'the entry started its full-globe flight'); +} + +test('a landed entry flight still gives the view back after a failed first load', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + const before = f.view(); + const release = holdReply(f); + + const entering = f.manager.setEnabled('rocket-launches', true, { + origin: 'user', + }); + await untilFlown(f); + f.camera.land(); + + release(); + assert.equal(await entering, false); + await f.settle(); + assert.deepEqual(f.view(), before); +}); + +test('a camera move after the entry flight survives a failed first load', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + const release = holdReply(f); + + const entering = f.manager.setEnabled('rocket-launches', true, { + origin: 'user', + }); + await untilFlown(f); + f.camera.land(); + // The user drags the globe while the mission index is still loading. + f.camera.setView({ + destination: new Cesium.Cartesian3(7, 7, 7), + orientation: { heading: 0.3, pitch: -0.2, roll: 0 }, + }); + const moved = f.view(); + + release(); + assert.equal(await entering, false); + await f.settle(); + assert.deepEqual(f.view(), moved); +}); + +test('a navigation during a failed first load is not cancelled by the rollback', async (t) => { + const f = await fixture(t); + await f.manager.setEnabled('earthquakes', true, { origin: 'user' }); + const release = holdReply(f); + + const entering = f.manager.setEnabled('rocket-launches', true, { + origin: 'user', + }); + await untilFlown(f); + // Search or Home: navigation releases mission camera ownership, then flies. + f.missions.releaseCameraOwnership(); + f.camera.flyTo({ destination: new Cesium.Cartesian3(5, 5, 5) }); + const cancelled = f.camera.cancelled; + const navigated = f.view(); + + release(); + assert.equal(await entering, false); + await f.settle(); + assert.equal(f.camera.cancelled, cancelled); + assert.deepEqual(f.view(), navigated); +}); + test('the roster reports a failed mission index instead of loading forever', async (t) => { const f = await fixture(t); await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }); diff --git a/src/layers/launches/lifecycle.js b/src/layers/launches/lifecycle.js index cf0332823..6ef0ce4df 100644 --- a/src/layers/launches/lifecycle.js +++ b/src/layers/launches/lifecycle.js @@ -125,11 +125,29 @@ function captureCameraView(viewer) { }; } -/** Stop the full-globe flight and return to a captured pose. */ -function restoreCameraView(viewer, view) { +function sameCameraView(camera, view) { + return ( + Cesium.Cartesian3.equalsEpsilon( + camera.positionWC, + view.destination, + 0, + 1, + ) && + ['heading', 'pitch', 'roll'].every((key) => + Cesium.Math.equalsEpsilon(camera[key], view.orientation[key], 0, 1e-6), + ) + ); +} + +/** + * Return to the pose before entry while the entry's full-globe flight still + * holds the camera: in flight, or landed with nothing moved since. + */ +function restoreCameraView(viewer, view, landedView) { const camera = viewer?.camera; if (!camera) return; - camera.cancelFlight?.(); + if (!landedView) camera.cancelFlight?.(); + else if (!sameCameraView(camera, landedView)) return; camera.setView(view); } @@ -228,9 +246,21 @@ export function createLifecycle({ parts.panel.renderMissionRoster(); } // Kept until the mission index loads, so an entry that never does - // gives the view back. + // gives the view back. A newer flight takes the camera and drops it. + const token = layerState._lifecycleToken; layerState._entryCameraView = captureCameraView(layerState._viewer); - parts.selection.focusFullGlobe(layerState._viewer); + layerState._entryLandedView = null; + const flying = parts.selection.focusFullGlobe(layerState._viewer, { + complete: () => { + if (token === layerState._lifecycleToken) + layerState._entryLandedView = captureCameraView(layerState._viewer); + }, + cancel: () => { + if (token === layerState._lifecycleToken) + layerState._entryCameraView = null; + }, + }); + if (!flying) layerState._entryCameraView = null; if (layerState._selectedLaunchId) parts.selection.setSelectedMission( layerState._selectedLaunchId, @@ -246,7 +276,12 @@ export function createLifecycle({ layerState._enabled = false; const entryView = layerState._entryCameraView; layerState._entryCameraView = null; - if (entryView) restoreCameraView(layerState._viewer, entryView); + if (entryView) + restoreCameraView( + layerState._viewer, + entryView, + layerState._entryLandedView, + ); releaseContinuousRender('rocket-launches'); layerState._lifecycleToken++; layerState._updateDirty = false; diff --git a/src/layers/launches/selection.js b/src/layers/launches/selection.js index 813b7875f..0f7955551 100644 --- a/src/layers/launches/selection.js +++ b/src/layers/launches/selection.js @@ -9,7 +9,8 @@ export function createSelection({ }) { const { getKeyholeGeometry } = services.geometry; - function focusFullGlobe(viewer, duration = 2.4) { + /** @returns {boolean} Whether the flight started. */ + function focusFullGlobe(viewer, { duration = 2.4, complete, cancel } = {}) { const canvas = viewer?.scene?.canvas; const height = canvas?.clientHeight || canvas?.height; const width = canvas?.clientWidth || canvas?.width; @@ -23,7 +24,7 @@ export function createSelection({ fovy <= 0 || fovy >= Math.PI ) - return; + return false; const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius; const keyholeRadius = getKeyholeGeometry(width, height).radius; const targetScreenRadius = keyholeRadius * 0.61; @@ -45,7 +46,10 @@ export function createSelection({ }, duration, easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + complete, + cancel, }); + return true; } function entityLaunchId(entity) { diff --git a/src/layers/launches/state.js b/src/layers/launches/state.js index c4c032b10..9ef571428 100644 --- a/src/layers/launches/state.js +++ b/src/layers/launches/state.js @@ -21,6 +21,8 @@ export function createState({ services }) { state._entryCameraView = null; + state._entryLandedView = null; + state._stale = false; state._orbitMatches = 0;