Reframe a mission after a late active-satellite answer only while nobody has left its orbit view

A flyover, a launch-site view, a cancelled flyover or another mission now clears the pending orbit framing, so a late TLE lookup followed by a data refresh never takes the camera back.
This commit is contained in:
German Khokhlov committed 2026-11-03 20:31:49 +04:00
1 parent f0d7def9c7
commit 057dbe24af
6 files changed
+153 -15

No files matched your search

+97 -2
View File
@@ -26,7 +26,7 @@ const VANDENBERG = {
const pad = (launch, height = 0) => const pad = (launch, height = 0) =>
geo.cartesian3.fromDegrees(launch.lon, launch.lat, height); geo.cartesian3.fromDegrees(launch.lon, launch.lat, height);
function rig() { function rig({ tle = async () => '1 25544U' } = {}) {
const engine = createFakeEngine({ kind: 'navara' }); const engine = createFakeEngine({ kind: 'navara' });
engine.fake.setPose({ engine.fake.setPose({
longitude: geo.math.toRadians(-100), longitude: geo.math.toRadians(-100),
@@ -52,7 +52,7 @@ function rig() {
let feed = [CAPE, VANDENBERG]; let feed = [CAPE, VANDENBERG];
const source = { const source = {
getLaunches: async () => feed, getLaunches: async () => feed,
getActiveTle: async () => '1 25544U', getActiveTle: () => tle(),
}; };
const parts = { const parts = {
panel: { panel: {
@@ -119,6 +119,7 @@ function rig() {
].includes(method), ].includes(method),
), ),
cleanup() { cleanup() {
parts.ingestion.clearPostTleRetry();
state._sourceController.abort(); state._sourceController.abort();
parts.replay.stopMissionReplay(); parts.replay.stopMissionReplay();
renderer.destroy(); renderer.destroy();
@@ -183,3 +184,97 @@ test('edge:rocket-launches: a launch cancelled mid-flyover stops the flyover and
r.cleanup(); r.cleanup();
} }
}); });
/** An active-satellite lookup that answers only when the test says so. */
function lateTle() {
let answer;
const pending = new Promise((resolve) => (answer = resolve));
return { tle: () => pending, answer: () => answer('1 25544U') };
}
const flights = (r) =>
r.cameraCommands().filter(({ method }) => method !== 'camera.lookAt');
test('edge:rocket-launches: a late TLE answer and a refresh never take the camera from a superseded flyover', async () => {
const lookup = lateTle();
const r = rig({ tle: lookup.tle });
try {
await r.parts.ingestion.requestMissionUpdate();
r.parts.selection.setSelectedMission(CAPE.id);
r.parts.selection.focusMission(CAPE);
r.engine.fake.frames(200);
r.parts.replay.startMissionReplay(CAPE.id);
r.engine.fake.frames(20);
// The user moves on to the second mission's launch site.
r.parts.selection.setSelectedMission(VANDENBERG.id);
r.parts.selection.focusLaunchSite(VANDENBERG);
r.engine.fake.frames(400);
const parked = r.engine.camera.pose().position;
r.engine.fake.resetCalls();
lookup.answer();
await settle();
await r.parts.ingestion.requestMissionUpdate();
await settle();
r.engine.fake.frames(400);
assert.deepEqual(flights(r), [], 'no flight after the refresh');
assert.ok(near(r.engine.camera.pose().position, parked, 1));
assert.equal(r.state._selectedLaunchId, VANDENBERG.id);
} finally {
r.cleanup();
}
});
test('edge:rocket-launches: a late TLE answer and a refresh never take the camera after a running or cancelled flyover', async () => {
for (const cancel of [false, true]) {
const lookup = lateTle();
const r = rig({ tle: lookup.tle });
try {
await r.parts.ingestion.requestMissionUpdate();
r.parts.selection.setSelectedMission(CAPE.id);
r.parts.selection.focusMission(CAPE);
r.engine.fake.frames(200);
r.parts.replay.startMissionReplay(CAPE.id);
r.engine.fake.frames(20);
if (cancel) r.parts.replay.stopMissionReplay();
lookup.answer();
await settle();
// The refresh ends a running flyover (its tracks are rebuilt) and must
// leave the camera where the flyover put it.
const parked = r.engine.camera.pose().position;
r.engine.fake.resetCalls();
await r.parts.ingestion.requestMissionUpdate();
await settle();
r.engine.fake.frames(400);
assert.deepEqual(flights(r), [], `no flight (cancelled: ${cancel})`);
assert.ok(near(r.engine.camera.pose().position, parked, 1));
assert.equal(r.state._replayCameraLaunchId, null);
assert.equal(r.state._selectedLaunchId, CAPE.id, 'the card stays');
} finally {
r.cleanup();
}
}
});
test('edge:rocket-launches: a mission still framed when its late TLE arrives is framed again with the orbit', async () => {
const lookup = lateTle();
const r = rig({ tle: lookup.tle });
try {
await r.parts.ingestion.requestMissionUpdate();
r.parts.selection.setSelectedMission(CAPE.id);
r.parts.selection.focusMission(CAPE);
r.engine.fake.frames(200);
r.engine.fake.resetCalls();
lookup.answer();
await settle();
await r.parts.ingestion.requestMissionUpdate();
await settle();
assert.deepEqual(
flights(r).map(({ method }) => method),
['camera.flyTo'],
'the orbit view is framed once',
);
} finally {
r.cleanup();
}
});
+8 -2
View File
@@ -70,7 +70,8 @@ export function createIngestion({
return null; return null;
layerState._activeTleText = text; layerState._activeTleText = text;
layerState._focusAfterActiveLookup = Boolean( layerState._focusAfterActiveLookup = Boolean(
layerState._selectedLaunchId, layerState._selectedLaunchId &&
layerState._missionFocusIntentId === layerState._selectedLaunchId,
); );
schedulePostTleRetry(token); schedulePostTleRetry(token);
return text; return text;
@@ -210,7 +211,12 @@ export function createIngestion({
parts.selection.setSelectedMission(null, false); parts.selection.setSelectedMission(null, false);
} else { } else {
parts.selection.setSelectedMission(layerState._selectedLaunchId, true); parts.selection.setSelectedMission(layerState._selectedLaunchId, true);
if (layerState._focusAfterActiveLookup) { // Only a mission view nobody has left since the lookup answered: a
// flyover, a launch-site view or another mission supersedes it.
if (
layerState._focusAfterActiveLookup &&
layerState._missionFocusIntentId === layerState._selectedLaunchId
) {
parts.selection.focusMission( parts.selection.focusMission(
launches.find( launches.find(
(launch) => launch.id === layerState._selectedLaunchId, (launch) => launch.id === layerState._selectedLaunchId,
+33 -9
View File
@@ -16,7 +16,10 @@ export function createLifecycle({
layerState._orbitMatches = 0; layerState._orbitMatches = 0;
this.attachRenderer(viewer, options); this.attachRenderer(viewer, options);
}, },
attachRenderer(viewer, { engine = null, waitForCameraRestore = false } = {}) { attachRenderer(
viewer,
{ engine = null, waitForCameraRestore = false } = {},
) {
layerState._engine = engine; layerState._engine = engine;
layerState._renderer = services.renderer.create({ viewer, engine }); layerState._renderer = services.renderer.create({ viewer, engine });
layerState._viewer = layerState._renderer.view; layerState._viewer = layerState._renderer.view;
@@ -59,7 +62,8 @@ export function createLifecycle({
holdContinuousRender('rocket-launches'); holdContinuousRender('rocket-launches');
layerState._dataSource.show = true; layerState._dataSource.show = true;
layerState._orbitMatches = 0; layerState._orbitMatches = 0;
for (const launch of layerState._launches) parts.rendering.addLaunchEntity(launch, layerState._renderedTleText); for (const launch of layerState._launches)
parts.rendering.addLaunchEntity(launch, layerState._renderedTleText);
parts.selection.showSelectedMission(); parts.selection.showSelectedMission();
} }
layerState._restoringViewport = true; layerState._restoringViewport = true;
@@ -70,10 +74,18 @@ export function createLifecycle({
if (!layerState._restoringViewport) return; if (!layerState._restoringViewport) return;
layerState._restoringViewport = false; layerState._restoringViewport = false;
if (layerState._enabled && layerState._replayCameraLaunchId) { if (layerState._enabled && layerState._replayCameraLaunchId) {
parts.replay.startMissionReplay(layerState._replayCameraLaunchId, { resuming: true }); parts.replay.startMissionReplay(layerState._replayCameraLaunchId, {
resuming: true,
});
} else if (layerState._enabled && layerState._missionZoomAnchorId) { } else if (layerState._enabled && layerState._missionZoomAnchorId) {
const launch = layerState._launches.find(item => item.id === layerState._missionZoomAnchorId); const launch = layerState._launches.find(
if (launch) parts.selection.startMissionZoomAnchor(launch, layerState._missionZoomRange); (item) => item.id === layerState._missionZoomAnchorId,
);
if (launch)
parts.selection.startMissionZoomAnchor(
launch,
layerState._missionZoomRange,
);
} }
layerState._resumeRenderer?.(); layerState._resumeRenderer?.();
layerState._waitForRenderer = layerState._resumeRenderer = null; layerState._waitForRenderer = layerState._resumeRenderer = null;
@@ -81,15 +93,21 @@ export function createLifecycle({
detachRenderer() { detachRenderer() {
if (!layerState._renderer) return; if (!layerState._renderer) return;
layerState._waitForRenderer ??= new Promise(resolve => { layerState._resumeRenderer = resolve; }); layerState._waitForRenderer ??= new Promise((resolve) => {
layerState._resumeRenderer = resolve;
});
layerState._replayCameraToken++; layerState._replayCameraToken++;
layerState._replayCameraRemover?.(); layerState._replayCameraRemover?.();
layerState._missionZoomAnchorRemover?.(); layerState._missionZoomAnchorRemover?.();
layerState._replayCameraRemover = layerState._missionZoomAnchorRemover = null; layerState._replayCameraRemover = layerState._missionZoomAnchorRemover =
null;
layerState._clickHandler?.destroy(); layerState._clickHandler?.destroy();
layerState._moveHandler?.destroy(); layerState._moveHandler?.destroy();
layerState._declutterHandler?.(); layerState._declutterHandler?.();
layerState._clickHandler = layerState._moveHandler = layerState._declutterHandler = null; layerState._clickHandler =
layerState._moveHandler =
layerState._declutterHandler =
null;
parts.launchPad.destroyLaunchPadZonePrimitive(); parts.launchPad.destroyLaunchPadZonePrimitive();
parts.orbitRendering.removeMissionOrbitPrimitives(); parts.orbitRendering.removeMissionOrbitPrimitives();
parts.overlays.clearMissionOverlaySources(); parts.overlays.clearMissionOverlaySources();
@@ -99,7 +117,11 @@ export function createLifecycle({
layerState._replayTracks.clear(); layerState._replayTracks.clear();
layerState._renderer.removeDataSource(layerState._dataSource); layerState._renderer.removeDataSource(layerState._dataSource);
layerState._renderer.destroy(); layerState._renderer.destroy();
layerState._dataSource = layerState._renderer = layerState._engine = layerState._viewer = null; layerState._dataSource =
layerState._renderer =
layerState._engine =
layerState._viewer =
null;
if (layerState._enabled) releaseContinuousRender('rocket-launches'); if (layerState._enabled) releaseContinuousRender('rocket-launches');
}, },
@@ -158,6 +180,8 @@ export function createLifecycle({
parts.launchPad.hideLaunchPadZone(); parts.launchPad.hideLaunchPadZone();
parts.overlays.hideReplayVehicleOverlay(); parts.overlays.hideReplayVehicleOverlay();
layerState._selectedLaunchId = null; layerState._selectedLaunchId = null;
layerState._missionFocusIntentId = null;
layerState._focusAfterActiveLookup = false;
parts.overlays.clearMissionOverlaySources(); parts.overlays.clearMissionOverlaySources();
await parts.ingestion.restoreSatelliteDependency(); await parts.ingestion.restoreSatelliteDependency();
parts.panel.renderMissionPanel(); parts.panel.renderMissionPanel();
+6 -2
View File
@@ -326,13 +326,16 @@ export function createReplay({
layerState._replayCameraLaunchId = null; layerState._replayCameraLaunchId = null;
layerState._replayPaused = false; layerState._replayPaused = false;
layerState._replayPausedAtMs = null; layerState._replayPausedAtMs = null;
if (stoppedLaunchId) if (stoppedLaunchId) {
layerState._animationStarts.set(stoppedLaunchId, appClock.now()); layerState._animationStarts.set(stoppedLaunchId, appClock.now());
// The flyover moved the camera; the orbit view asked for before it is gone.
layerState._missionFocusIntentId = null;
}
syncReplayButton(); syncReplayButton();
parts.overlays.syncMissionOverlayEntries(); parts.overlays.syncMissionOverlayEntries();
} }
function startMissionReplay(launchId, {resuming = false} = {}) { function startMissionReplay(launchId, { resuming = false } = {}) {
const launch = layerState._launches.find((item) => item.id === launchId); const launch = layerState._launches.find((item) => item.id === launchId);
const track = layerState._replayTracks.get(launchId); const track = layerState._replayTracks.get(launchId);
if (!layerState._viewer || !launch || !track) return false; if (!layerState._viewer || !launch || !track) return false;
@@ -345,6 +348,7 @@ export function createReplay({
parts.selection.stopMissionZoomAnchor(); parts.selection.stopMissionZoomAnchor();
stopMissionReplay(); stopMissionReplay();
layerState._replayCameraLaunchId = launchId; layerState._replayCameraLaunchId = launchId;
layerState._missionFocusIntentId = null;
layerState._replayPaused = false; layerState._replayPaused = false;
layerState._replayPausedAtMs = null; layerState._replayPausedAtMs = null;
} }
+4
View File
@@ -68,6 +68,8 @@ export function createSelection({
layerState._missionZoomAnchorId !== launchId layerState._missionZoomAnchorId !== launchId
) )
stopMissionZoomAnchor(); stopMissionZoomAnchor();
if (layerState._missionFocusIntentId !== launchId)
layerState._missionFocusIntentId = null;
layerState._selectedLaunchId = launchId; layerState._selectedLaunchId = launchId;
layerState._explicitSelection = Boolean(launchId && isolate); layerState._explicitSelection = Boolean(launchId && isolate);
if (launchId) layerState._animationStarts.set(launchId, appClock.now()); if (launchId) layerState._animationStarts.set(launchId, appClock.now());
@@ -122,6 +124,7 @@ export function createSelection({
function focusMission(launch) { function focusMission(launch) {
if (!layerState._viewer || !launch) return; if (!layerState._viewer || !launch) return;
stopMissionZoomAnchor(); stopMissionZoomAnchor();
layerState._missionFocusIntentId = launch.id;
layerState._viewer.selectedEntity = layerState._dataSource.entities.getById( layerState._viewer.selectedEntity = layerState._dataSource.entities.getById(
`rocket-launch:${launch.id}`, `rocket-launch:${launch.id}`,
); );
@@ -198,6 +201,7 @@ export function createSelection({
function focusLaunchSite(launch) { function focusLaunchSite(launch) {
if (!layerState._viewer || !launch) return; if (!layerState._viewer || !launch) return;
parts.replay.stopMissionReplay(); parts.replay.stopMissionReplay();
layerState._missionFocusIntentId = null;
stopMissionZoomAnchor(); stopMissionZoomAnchor();
const launchPosition = geo.cartesian3.fromDegrees(launch.lon, launch.lat); const launchPosition = geo.cartesian3.fromDegrees(launch.lon, launch.lat);
layerState._viewer.selectedEntity = layerState._dataSource.entities.getById( layerState._viewer.selectedEntity = layerState._dataSource.entities.getById(
+5
View File
@@ -100,6 +100,11 @@ export function createState({ services }) {
state._focusAfterActiveLookup = false; state._focusAfterActiveLookup = false;
// The mission whose orbit view the user asked for last; any later camera
// command of this layer (a flyover, a launch-site view) clears it, so a late
// active-satellite answer can reframe only a view nobody has left.
state._missionFocusIntentId = null;
state._satelliteStateBeforeMission = null; state._satelliteStateBeforeMission = null;
state._satelliteActivationPromise = null; state._satelliteActivationPromise = null;