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) =>
geo.cartesian3.fromDegrees(launch.lon, launch.lat, height);
function rig() {
function rig({ tle = async () => '1 25544U' } = {}) {
const engine = createFakeEngine({ kind: 'navara' });
engine.fake.setPose({
longitude: geo.math.toRadians(-100),
@@ -52,7 +52,7 @@ function rig() {
let feed = [CAPE, VANDENBERG];
const source = {
getLaunches: async () => feed,
getActiveTle: async () => '1 25544U',
getActiveTle: () => tle(),
};
const parts = {
panel: {
@@ -119,6 +119,7 @@ function rig() {
].includes(method),
),
cleanup() {
parts.ingestion.clearPostTleRetry();
state._sourceController.abort();
parts.replay.stopMissionReplay();
renderer.destroy();
@@ -183,3 +184,97 @@ test('edge:rocket-launches: a launch cancelled mid-flyover stops the flyover and
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;
layerState._activeTleText = text;
layerState._focusAfterActiveLookup = Boolean(
layerState._selectedLaunchId,
layerState._selectedLaunchId &&
layerState._missionFocusIntentId === layerState._selectedLaunchId,
);
schedulePostTleRetry(token);
return text;
@@ -210,7 +211,12 @@ export function createIngestion({
parts.selection.setSelectedMission(null, false);
} else {
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(
launches.find(
(launch) => launch.id === layerState._selectedLaunchId,
+33 -9
View File
@@ -16,7 +16,10 @@ export function createLifecycle({
layerState._orbitMatches = 0;
this.attachRenderer(viewer, options);
},
attachRenderer(viewer, { engine = null, waitForCameraRestore = false } = {}) {
attachRenderer(
viewer,
{ engine = null, waitForCameraRestore = false } = {},
) {
layerState._engine = engine;
layerState._renderer = services.renderer.create({ viewer, engine });
layerState._viewer = layerState._renderer.view;
@@ -59,7 +62,8 @@ export function createLifecycle({
holdContinuousRender('rocket-launches');
layerState._dataSource.show = true;
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();
}
layerState._restoringViewport = true;
@@ -70,10 +74,18 @@ export function createLifecycle({
if (!layerState._restoringViewport) return;
layerState._restoringViewport = false;
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) {
const launch = layerState._launches.find(item => item.id === layerState._missionZoomAnchorId);
if (launch) parts.selection.startMissionZoomAnchor(launch, layerState._missionZoomRange);
const launch = layerState._launches.find(
(item) => item.id === layerState._missionZoomAnchorId,
);
if (launch)
parts.selection.startMissionZoomAnchor(
launch,
layerState._missionZoomRange,
);
}
layerState._resumeRenderer?.();
layerState._waitForRenderer = layerState._resumeRenderer = null;
@@ -81,15 +93,21 @@ export function createLifecycle({
detachRenderer() {
if (!layerState._renderer) return;
layerState._waitForRenderer ??= new Promise(resolve => { layerState._resumeRenderer = resolve; });
layerState._waitForRenderer ??= new Promise((resolve) => {
layerState._resumeRenderer = resolve;
});
layerState._replayCameraToken++;
layerState._replayCameraRemover?.();
layerState._missionZoomAnchorRemover?.();
layerState._replayCameraRemover = layerState._missionZoomAnchorRemover = null;
layerState._replayCameraRemover = layerState._missionZoomAnchorRemover =
null;
layerState._clickHandler?.destroy();
layerState._moveHandler?.destroy();
layerState._declutterHandler?.();
layerState._clickHandler = layerState._moveHandler = layerState._declutterHandler = null;
layerState._clickHandler =
layerState._moveHandler =
layerState._declutterHandler =
null;
parts.launchPad.destroyLaunchPadZonePrimitive();
parts.orbitRendering.removeMissionOrbitPrimitives();
parts.overlays.clearMissionOverlaySources();
@@ -99,7 +117,11 @@ export function createLifecycle({
layerState._replayTracks.clear();
layerState._renderer.removeDataSource(layerState._dataSource);
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');
},
@@ -158,6 +180,8 @@ export function createLifecycle({
parts.launchPad.hideLaunchPadZone();
parts.overlays.hideReplayVehicleOverlay();
layerState._selectedLaunchId = null;
layerState._missionFocusIntentId = null;
layerState._focusAfterActiveLookup = false;
parts.overlays.clearMissionOverlaySources();
await parts.ingestion.restoreSatelliteDependency();
parts.panel.renderMissionPanel();
+6 -2
View File
@@ -326,13 +326,16 @@ export function createReplay({
layerState._replayCameraLaunchId = null;
layerState._replayPaused = false;
layerState._replayPausedAtMs = null;
if (stoppedLaunchId)
if (stoppedLaunchId) {
layerState._animationStarts.set(stoppedLaunchId, appClock.now());
// The flyover moved the camera; the orbit view asked for before it is gone.
layerState._missionFocusIntentId = null;
}
syncReplayButton();
parts.overlays.syncMissionOverlayEntries();
}
function startMissionReplay(launchId, {resuming = false} = {}) {
function startMissionReplay(launchId, { resuming = false } = {}) {
const launch = layerState._launches.find((item) => item.id === launchId);
const track = layerState._replayTracks.get(launchId);
if (!layerState._viewer || !launch || !track) return false;
@@ -345,6 +348,7 @@ export function createReplay({
parts.selection.stopMissionZoomAnchor();
stopMissionReplay();
layerState._replayCameraLaunchId = launchId;
layerState._missionFocusIntentId = null;
layerState._replayPaused = false;
layerState._replayPausedAtMs = null;
}
+4
View File
@@ -68,6 +68,8 @@ export function createSelection({
layerState._missionZoomAnchorId !== launchId
)
stopMissionZoomAnchor();
if (layerState._missionFocusIntentId !== launchId)
layerState._missionFocusIntentId = null;
layerState._selectedLaunchId = launchId;
layerState._explicitSelection = Boolean(launchId && isolate);
if (launchId) layerState._animationStarts.set(launchId, appClock.now());
@@ -122,6 +124,7 @@ export function createSelection({
function focusMission(launch) {
if (!layerState._viewer || !launch) return;
stopMissionZoomAnchor();
layerState._missionFocusIntentId = launch.id;
layerState._viewer.selectedEntity = layerState._dataSource.entities.getById(
`rocket-launch:${launch.id}`,
);
@@ -198,6 +201,7 @@ export function createSelection({
function focusLaunchSite(launch) {
if (!layerState._viewer || !launch) return;
parts.replay.stopMissionReplay();
layerState._missionFocusIntentId = null;
stopMissionZoomAnchor();
const launchPosition = geo.cartesian3.fromDegrees(launch.lon, launch.lat);
layerState._viewer.selectedEntity = layerState._dataSource.entities.getById(
+5
View File
@@ -100,6 +100,11 @@ export function createState({ services }) {
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._satelliteActivationPromise = null;