fix(launches): hand the camera over before stopping, and only blame a fresh index error

- releaseCameraOwnership marks the camera released before stopping, so a
  flight navigation asked to preserve survives; exit also releases it, so an
  inactive layer never cancels another owner's flight.
- The failure toast names the mission index only when its error is from the
  current Context attempt (stats.errorAt), not an earlier one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Mazeyar Moeini FeizabadiandClaude Opus 5.5 committed 2026-11-06 19:28:56 +01:00
1 parent 13219828e3
commit efa4374644
7 files changed
+41 -10

No files matched your search

+2 -1
View File
@@ -14,10 +14,10 @@ export function createControls({ state: layerState, services, parts, source }) {
releaseCameraOwnership() {
// Navigation takes the camera; a failed entry must not restore the old view.
layerState._entryCameraView = null;
layerState._cameraReleased = true;
parts.panel.clearMissionRosterHover();
parts.replay.stopMissionReplay();
parts.selection.stopMissionZoomAnchor();
layerState._cameraReleased = true;
},
/**
@@ -60,6 +60,7 @@ export function createControls({ state: layerState, services, parts, source }) {
orbitMatches: layerState._orbitMatches,
lastUpdate: layerState._lastUpdate,
error: layerState._lastError,
errorAt: layerState._lastErrorAt,
stale: layerState._stale,
};
},
+28 -5
View File
@@ -6,6 +6,7 @@ import http from 'node:http';
import * as Cesium from 'cesium';
import { DataLayerManager } from '../../data/manager.js';
import { ContextControls } from '../../ui/contextControls.js';
import { spaceMissionsIndexFailure } from '../../ui/contextActions.js';
import { createLaunchSource } from './source.js';
import { createState } from './state.js';
import { createModel } from './model.js';
@@ -454,7 +455,6 @@ test('a refresh failure after a loaded index keeps the mode and its records', as
await f.settle();
assert.deepEqual(f.view(), framed);
});
// Append to src/layers/launches/failedEntry.test.mjs (uses its fixture()).
test('exiting during a successful entry flight cancels the mission-owned flight', async (t) => {
const f = await fixture(t);
f.upstream.status = 200;
@@ -473,10 +473,7 @@ test('exiting during a successful entry flight cancels the mission-owned flight'
'leaving Space Missions stops the flight it started (main cancels it)',
);
});
// Append to src/layers/launches/failedEntry.test.mjs (uses its fixture()).
// Encodes the "surface the reason where the user acted" fix: the roster that
// carries 30-DAY MISSION INDEX UNAVAILABLE is hidden by the rollback, so the
// lasting feedback (the toast) has to name the cause.
// The rollback hides the roster, so the toast has to name the cause.
test('a failed first load tells the user the mission index is unavailable', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
@@ -491,3 +488,29 @@ test('a failed first load tells the user the mission index is unavailable', asyn
`visible feedback names the failure, got ${JSON.stringify(f.toasts)}`,
);
});
test('navigation taking the camera keeps a mission flight it asked to preserve', async (t) => {
const f = await fixture(t);
f.upstream.status = 200;
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
await f.settle();
const flight = f.camera.flight;
assert.ok(flight, 'the entry flight is in progress');
f.missions.releaseCameraOwnership();
assert.equal(f.camera.flight, flight, 'navigation decides about the flight');
// After exit an inactive layer never cancels another owner's flight.
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
await f.settle();
f.camera.flyTo({ destination: new Cesium.Cartesian3(1, 1, 1) });
const other = f.camera.flight;
f.missions.releaseCameraOwnership();
assert.equal(f.camera.flight, other);
});
test("an earlier attempt's index error is not blamed for a later failure", async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
await f.settle();
assert.match(spaceMissionsIndexFailure(f.manager), /mission index/);
assert.equal(spaceMissionsIndexFailure(f.manager, Date.now() + 1), null);
});
+1
View File
@@ -228,6 +228,7 @@ export function createIngestion({
} catch (error) {
if (layerState._enabled && token === layerState._lifecycleToken) {
layerState._lastError = error.message;
layerState._lastErrorAt = Date.now();
console.warn('[Data:RocketLaunches] Fetch error:', error);
// No index yet: fail the enable so Context rolls back.
// After a loaded index, a failed refresh keeps its records.
+2
View File
@@ -285,6 +285,7 @@ export function createLifecycle({
parts.ingestion.clearPostTleRetry();
parts.panel.clearMissionRosterHover();
parts.replay.stopMissionReplay();
layerState._cameraReleased = true;
parts.selection.stopMissionZoomAnchor();
if (layerState._dataSource) layerState._dataSource.show = false;
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
@@ -310,6 +311,7 @@ export function createLifecycle({
await parts.ingestion.restoreSatelliteDependency();
parts.panel.clearMissionRosterHover();
parts.replay.stopMissionReplay();
layerState._cameraReleased = true;
parts.selection.stopMissionZoomAnchor();
parts.ingestion.clearPostTleRetry();
if (layerState._clickHandler) layerState._clickHandler.destroy();
+2
View File
@@ -19,6 +19,8 @@ export function createState({ services }) {
state._lastError = null;
state._lastErrorAt = null;
state._entryCameraView = null;
state._entryLandedView = null;
+4 -3
View File
@@ -4,12 +4,13 @@ import {
contextModeWord,
} from '../contextModePolicy.js';
/** Why a Space Missions entry failed, when its mission index never loaded. */
export function spaceMissionsIndexFailure(dataManager) {
/** Why a Space Missions entry failed: its mission index never loaded, and
* the error is no older than `since` (an earlier attempt's is not the cause). */
export function spaceMissionsIndexFailure(dataManager, since = 0) {
const stats = dataManager?.layers
?.get('rocket-launches')
?.module?.getStats?.();
return stats?.error && stats.lastUpdate == null
return stats?.error && stats.lastUpdate == null && stats.errorAt >= since
? t(
'Space Missions unavailable: the mission index could not load. Try again later.',
)
+2 -1
View File
@@ -60,11 +60,12 @@ export function _initGlobalContextPanel() {
const nextMode =
this._contextMode === 'space-missions' ? null : 'space-missions';
this._claimContextVisualAuthority();
const startedAt = Date.now();
void this._runUserFacingContextAction(
(notificationToken) =>
this._selectContextMode(nextMode, { notificationToken }),
() =>
(nextMode && spaceMissionsIndexFailure(this._dataManager)) ||
(nextMode && spaceMissionsIndexFailure(this._dataManager, startedAt)) ||
t(
'Space Missions could not complete the requested transition; try again',
),