Keep a newer camera view when a failed Space Missions entry rolls back

This commit is contained in:
German Khokhlov committed 2026-11-05 21:21:02 +04:00
1 parent c64ed40c62
commit 655d93cc92
5 files changed
+154 -12

No files matched your search

+2
View File
@@ -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();
+103 -4
View File
@@ -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' });
+41 -6
View File
@@ -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;
+6 -2
View File
@@ -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) {
+2
View File
@@ -21,6 +21,8 @@ export function createState({ services }) {
state._entryCameraView = null;
state._entryLandedView = null;
state._stale = false;
state._orbitMatches = 0;