Preserve satellite tracking, orbit solutions and mission replay across globes

This commit is contained in:
German Khokhlov committed 2026-11-03 04:36:49 +04:00
1 parent 133ca14119
commit a49671a391
21 files changed
+446 -71

No files matched your search

+208
View File
@@ -0,0 +1,208 @@
import assert from 'node:assert/strict';
import puppeteer from 'puppeteer';
import { launchFixture } from '../src/bench/launchFixture.js';
import {
satelliteFixture,
SATELLITE_FIXTURE_EPOCH,
} from '../src/bench/satelliteFixture.js';
import { blockPaidQaMaps } from './qa-map-policy.mjs';
import { switchGlobe } from './qa-switch-globe.mjs';
const url = new URL(process.argv[2]),
missions = process.argv.includes('--missions');
if (
!['localhost', '127.0.0.1'].includes(url.hostname) ||
['', '4173', '4185', '4186', '4187', '4188', '4189', '4194'].includes(
url.port,
)
)
throw Error('Supply local keyless preview URL');
url.search = '?engine=navara&welcome=0';
url.hash = 'v=2&lat=5&lon=5&alt=18000000&pitch=-90&heading=0&map=sentinel2&l=';
const b = await puppeteer.launch({
headless: true,
args: ['--use-angle=metal', '--enable-gpu'],
});
try {
const p = await b.newPage();
await blockPaidQaMaps(p);
const errors = [];
p.on('pageerror', (e) => {
errors.push(e.message);
console.log('error', e.message);
});
const groups = Object.fromEntries(
[
'stations',
'visual',
'gps-ops',
'glo-ops',
'galileo',
'geo',
'starlink',
].map((g) => [g, satelliteFixture(g)]),
);
await p.evaluateOnNewDocument(
(groups, launches, epoch) => {
const fetch = window.fetch.bind(window);
window.spaceReads = {};
const mono = performance.now();
Date.now = () => epoch + performance.now() - mono;
window.fetch = (input, opt) => {
const path = new URL(input.url ?? input, location.href).pathname;
if (path.startsWith('/api/celestrak/') || path === '/api/launches') {
window.spaceReads[path] = (window.spaceReads[path] || 0) + 1;
const g = path.split('/').pop();
return Promise.resolve(
g === 'launches'
? Response.json(launches)
: new Response(groups[g] ?? groups.stations),
);
}
return fetch(input, opt);
};
},
groups,
launchFixture(),
SATELLITE_FIXTURE_EPOCH,
);
await p.goto(url.href);
await p.waitForFunction(
() =>
window.__godsEyeView?.engine.kind === 'navara' &&
document.querySelector('#loading-screen.hidden'),
);
await p.evaluate(async (missions) => {
const { dataManager, styleManager, tileset } = window.__godsEyeView;
if (tileset || window.__GOOGLE_MAPS_API_KEY__)
throw Error('Expected keyless preview');
await styleManager.initialRestorePromise;
window.sa = dataManager.layers.get('satellites').module;
window.la = dataManager.layers.get('rocket-launches').module;
if (missions) {
const result = await styleManager.setContextMode('space-missions');
if (!result.ok) throw Error('Missions mode unavailable: ' + result.error);
} else await dataManager.setEnabled('satellites', true, { origin: 'user' });
}, missions);
if (!missions) {
await p.waitForFunction(() => window.sa.getStats().count === 839, {
timeout: 40000,
});
console.log('core catalog ready');
await p.evaluate(() => window.sa.setParams({ catalog: 'dense' }));
await p.waitForFunction(() => window.sa.getStats().count === 6839, {
timeout: 60000,
});
console.log('dense catalog ready');
assert.ok(
await p.evaluate(() => window.sa.trackById(25544, { origin: 'user' })),
);
} else {
await p.waitForFunction(() => window.la.getStats().count === 3, {
timeout: 40000,
});
await p.evaluate(() => {
window.la.selectMissionAt(0);
window.la.startSelectedReplay();
window.la.setReplaySpeed(4);
});
await p.waitForFunction(() => window.la.qaDrawn()?.replay?.active, {
timeout: 20000,
});
await p.waitForFunction(
() => window.la.qaDrawn()?.replay?.phaseProgress > 0.001,
{ timeout: 35000 },
);
await p.evaluate(() => window.la.toggleReplayPause());
}
const before = await p.evaluate(() => ({
reads: window.spaceReads,
stamp: (window.la.getStats().count ? window.la : window.sa).getStats()
.lastUpdate,
mission: window.la.qaDrawn()?.selected,
replay: window.la.qaDrawn()?.replay,
}));
console.log(
'before switch',
before.reads,
before.mission,
before.replay?.active,
);
for (const kind of ['cesium', 'navara']) {
await switchGlobe(p, kind);
const state = await p.evaluate(() => {
const { dataManager, engine, styleManager } = window.__godsEyeView;
return {
ids: [
dataManager.layers.get('satellites').module === window.sa,
dataManager.layers.get('rocket-launches').module === window.la,
],
reads: window.spaceReads,
sat: window.sa.getStats(),
params: window.sa.getParams(),
follow: !!engine.camera.following(),
ring: window.sa.qaDrawn()?.rings.length,
mission: window.la.qaDrawn(),
mode: styleManager.getContextModeState().mode,
missionStats: window.la.getStats(),
};
});
console.log(
kind,
state.ids,
state.sat.count,
state.params.selectedSatTrackingId,
state.follow,
state.mode,
state.mission?.selected,
state.mission?.replay?.phaseProgress,
);
assert.ok(state.ids.every(Boolean));
assert.deepEqual(state.reads, before.reads);
if (missions) {
assert.equal(state.mode, 'space-missions');
assert.equal(state.mission.selected, before.mission);
assert.ok(state.mission.paused && state.mission.replay.active);
assert.equal(state.mission.speed, 4);
assert.equal(state.missionStats.lastUpdate, before.stamp);
assert.equal(
state.mission.replay.phaseProgress,
before.replay.phaseProgress,
);
} else {
assert.equal(state.sat.count, 6839);
assert.equal(state.params.selectedSatTrackingId, 25544);
assert.ok(state.follow && state.ring > 0);
assert.equal(state.sat.lastUpdate, before.stamp);
}
}
if (missions) {
await p.evaluate(() => window.la.toggleReplayPause());
await p.waitForFunction(() => window.la.qaDrawn()?.paused === false);
await p.waitForFunction(
(progress) => window.la.qaDrawn()?.replay?.phaseProgress > progress,
{},
before.replay.phaseProgress,
);
await switchGlobe(p, 'cesium');
const moving = await p.evaluate(() => window.la.qaDrawn());
assert.ok(
moving.replay?.active &&
!moving.paused &&
moving.replay.phaseProgress > before.replay.phaseProgress,
);
const left = await p.evaluate(() =>
window.__godsEyeView.styleManager.setContextMode(null),
);
assert.ok(left.ok);
assert.equal(
await p.evaluate(() =>
window.__godsEyeView.dataManager.isEnabled('rocket-launches'),
),
false,
);
}
assert.deepEqual(errors, []);
console.log('PASS retained space data and active camera across both planets');
} finally {
await b.close();
}
+4 -4
View File
@@ -153,9 +153,9 @@ export function createApplicationCatalog({
), ),
); );
const satellites = once(() => const satellites = once(() =>
createApplicationSatellites({ sessionModel('satellites', () => createApplicationSatellites({
source: sources.satellites, source: sources.satellites,
}), })),
); );
// Catalog order: [layer id(s), construction]. Only the layers the // Catalog order: [layer id(s), construction]. Only the layers the
// engine can show are constructed (renderers.js). // engine can show are constructed (renderers.js).
@@ -235,10 +235,10 @@ export function createApplicationCatalog({
[ [
'rocket-launches', 'rocket-launches',
() => () =>
createApplicationLaunches({ sessionModel('rocket-launches', () => createApplicationLaunches({
source: sources.launches, source: sources.launches,
satellites: satellites(), satellites: satellites(),
}), })),
], ],
[ [
'traffic', 'traffic',
+1 -1
View File
@@ -491,7 +491,7 @@ export class LayerStateCoordinator {
const model = this.dataManager.layers.get(selected.layerId)?.module; const model = this.dataManager.layers.get(selected.layerId)?.module;
if ( if (
model?.attachRenderer && model?.attachRenderer &&
model.getParams?.()[selected.optionKey] === selected.targetId && String(model.getParams?.()[selected.optionKey]) === String(selected.targetId) &&
model.hasContact?.(selected.targetId) === true model.hasContact?.(selected.targetId) === true
) return selected; ) return selected;
return null; return null;
+1
View File
@@ -179,6 +179,7 @@ export function createIngestion({
stale: false, stale: false,
}; };
const launches = parts.model.normalizeRocketLaunches(payload); const launches = parts.model.normalizeRocketLaunches(payload);
if (layerState._waitForRenderer) await layerState._waitForRenderer;
if ( if (
!layerState._enabled || !layerState._enabled ||
token !== layerState._lifecycleToken || token !== layerState._lifecycleToken ||
+59 -9
View File
@@ -8,20 +8,22 @@ export function createLifecycle({
const { holdContinuousRender, releaseContinuousRender } = services.render; const { holdContinuousRender, releaseContinuousRender } = services.render;
const methods = { const methods = {
init(viewer, { engine = null } = {}) { init(viewer, options) {
layerState._engine = engine;
layerState._renderer = services.renderer.create({ viewer, engine });
layerState._viewer = layerState._renderer.view;
layerState._declutterOccluder = getRenderer().occluder();
layerState._enabled = false;
parts.panel.createMissionPanel();
parts.overlays.createReplayVehicleOverlay();
layerState._dataSource = getRenderer().createDataSource();
layerState._count = 0; layerState._count = 0;
layerState._lastUpdate = null; layerState._lastUpdate = null;
layerState._stale = false; layerState._stale = false;
layerState._lastError = null; layerState._lastError = null;
layerState._orbitMatches = 0; layerState._orbitMatches = 0;
this.attachRenderer(viewer, options);
},
attachRenderer(viewer, { engine = null, waitForCameraRestore = false } = {}) {
layerState._engine = engine;
layerState._renderer = services.renderer.create({ viewer, engine });
layerState._viewer = layerState._renderer.view;
layerState._declutterOccluder = getRenderer().occluder();
parts.panel.createMissionPanel();
parts.overlays.createReplayVehicleOverlay();
layerState._dataSource = getRenderer().createDataSource();
parts.overlays.clearMissionOverlaySources(); parts.overlays.clearMissionOverlaySources();
const enabled = () => layerState._enabled && layerState._dataSource?.show; const enabled = () => layerState._enabled && layerState._dataSource?.show;
layerState._clickHandler = getRenderer().listenClick( layerState._clickHandler = getRenderer().listenClick(
@@ -53,6 +55,52 @@ export function createLifecycle({
parts.rendering.updateMissionFrame, parts.rendering.updateMissionFrame,
); );
parts.launchPad.initLaunchPadZonePrimitive(); parts.launchPad.initLaunchPadZonePrimitive();
if (layerState._enabled) {
holdContinuousRender('rocket-launches');
layerState._dataSource.show = true;
layerState._orbitMatches = 0;
for (const launch of layerState._launches) parts.rendering.addLaunchEntity(launch, layerState._renderedTleText);
parts.selection.showSelectedMission();
}
layerState._restoringViewport = true;
if (!waitForCameraRestore) this.resumeViewport();
},
resumeViewport() {
if (!layerState._restoringViewport) return;
layerState._restoringViewport = false;
if (layerState._enabled && layerState._replayCameraLaunchId) {
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);
}
layerState._resumeRenderer?.();
layerState._waitForRenderer = layerState._resumeRenderer = null;
},
detachRenderer() {
if (!layerState._renderer) return;
layerState._waitForRenderer ??= new Promise(resolve => { layerState._resumeRenderer = resolve; });
layerState._replayCameraToken++;
layerState._replayCameraRemover?.();
layerState._missionZoomAnchorRemover?.();
layerState._replayCameraRemover = layerState._missionZoomAnchorRemover = null;
layerState._clickHandler?.destroy();
layerState._moveHandler?.destroy();
layerState._declutterHandler?.();
layerState._clickHandler = layerState._moveHandler = layerState._declutterHandler = null;
parts.launchPad.destroyLaunchPadZonePrimitive();
parts.orbitRendering.removeMissionOrbitPrimitives();
parts.overlays.clearMissionOverlaySources();
parts.overlays.destroyReplayVehicleOverlay();
layerState._missionOverlayRecords.clear();
layerState._satelliteTelemetry.clear();
layerState._replayTracks.clear();
layerState._renderer.removeDataSource(layerState._dataSource);
layerState._renderer.destroy();
layerState._dataSource = layerState._renderer = layerState._engine = layerState._viewer = null;
if (layerState._enabled) releaseContinuousRender('rocket-launches');
}, },
async enable() { async enable() {
@@ -116,6 +164,8 @@ export function createLifecycle({
}, },
async destroy(viewer) { async destroy(viewer) {
layerState._resumeRenderer?.();
layerState._waitForRenderer = layerState._resumeRenderer = null;
layerState._sourceController.abort(); layerState._sourceController.abort();
releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix) releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix)
layerState._enabled = false; layerState._enabled = false;
+24 -12
View File
@@ -76,18 +76,12 @@ export function createRendering({
} }
} }
function addLaunchEntity(launch, activeTleText = layerState._activeTleText) { // The source snapshot owns its ephemeris epoch. A renderer attached later
const position = geo.cartesian3.fromDegrees(launch.lon, launch.lat); // must reconstruct the same ascent and orbit, including a paused replay.
const overlayRecord = { const orbitSolutions = new WeakMap();
launch, function orbitForLaunch(launch, activeTleText, orbitAllowed) {
anchorPosition: position, const known = orbitSolutions.get(launch);
elementEntryFactories: [], if (known && known.activeTleText === activeTleText) return known;
liveEventTime: null,
};
layerState._missionOverlayRecords.set(launch.id, overlayRecord);
const orbitAllowed = parts.policyHelpers.launchStatusAllowsOrbit(
launch.status,
);
const coreTrack = const coreTrack =
orbitAllowed && launch.satelliteQuery orbitAllowed && launch.satelliteQuery
? getSatelliteOrbitTrack(launch.satelliteQuery, { ? getSatelliteOrbitTrack(launch.satelliteQuery, {
@@ -106,6 +100,24 @@ export function createRendering({
: satelliteTrack?.orbitPath?.length > 1 : satelliteTrack?.orbitPath?.length > 1
? satelliteTrack.orbitPath ? satelliteTrack.orbitPath
: parts.paths.approximateOrbitPath(launch); : parts.paths.approximateOrbitPath(launch);
const solution = {activeTleText, satelliteTrack, orbitPath};
orbitSolutions.set(launch, solution);
return solution;
}
function addLaunchEntity(launch, activeTleText = layerState._activeTleText) {
const position = geo.cartesian3.fromDegrees(launch.lon, launch.lat);
const overlayRecord = {
launch,
anchorPosition: position,
elementEntryFactories: [],
liveEventTime: null,
};
layerState._missionOverlayRecords.set(launch.id, overlayRecord);
const orbitAllowed = parts.policyHelpers.launchStatusAllowsOrbit(
launch.status,
);
const { satelliteTrack, orbitPath } = orbitForLaunch(launch, activeTleText, orbitAllowed);
launch.recoveryStages.forEach((stage) => { launch.recoveryStages.forEach((stage) => {
stage.endpoint = parts.paths.landingEndpoint( stage.endpoint = parts.paths.landingEndpoint(
stage, stage,
+22 -16
View File
@@ -280,6 +280,7 @@ export function createReplay({
return false; return false;
layerState._replayPausedAtMs = appClock.now(); layerState._replayPausedAtMs = appClock.now();
layerState._replayPaused = true; layerState._replayPaused = true;
layerState._replayTracks.get(layerState._replayCameraLaunchId)?.beginReplayFrame?.();
syncReplayButton(); syncReplayButton();
return true; return true;
} }
@@ -329,20 +330,22 @@ export function createReplay({
parts.overlays.syncMissionOverlayEntries(); parts.overlays.syncMissionOverlayEntries();
} }
function startMissionReplay(launchId) { 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;
if (layerState._replayCameraLaunchId === launchId) { if (!resuming && layerState._replayCameraLaunchId === launchId) {
stopMissionReplay(); stopMissionReplay();
return false; return false;
} }
parts.selection.stopMissionZoomAnchor(); if (!resuming) {
stopMissionReplay(); parts.selection.stopMissionZoomAnchor();
layerState._replayCameraLaunchId = launchId; stopMissionReplay();
layerState._replayPaused = false; layerState._replayCameraLaunchId = launchId;
layerState._replayPausedAtMs = null; layerState._replayPaused = false;
layerState._replayPausedAtMs = null;
}
const token = ++layerState._replayCameraToken; const token = ++layerState._replayCameraToken;
const ascentDurationSec = track.ascentDurationSec; const ascentDurationSec = track.ascentDurationSec;
// Start broadside to the ascent/orbit direction so the launch profile is // Start broadside to the ascent/orbit direction so the launch profile is
@@ -354,15 +357,17 @@ export function createReplay({
track.lastCameraHeading = initialHeading; track.lastCameraHeading = initialHeading;
syncReplayButton(); syncReplayButton();
parts.overlays.syncMissionOverlayEntries(); parts.overlays.syncMissionOverlayEntries();
parts.policyHelpers.releaseAircraftTracking(layerState._dataManager); if (!resuming) {
layerState._viewer.trackedEntity = undefined; parts.policyHelpers.releaseAircraftTracking(layerState._dataManager);
layerState._viewer.camera.cancelFlight(); layerState._viewer.trackedEntity = undefined;
layerState._animationStarts.set( layerState._viewer.camera.cancelFlight();
launchId, layerState._animationStarts.set(
appClock.now() + launchId,
(REPLAY_TILE_SETTLE_DELAY_SEC + REPLAY_COUNTDOWN_DURATION_SEC) * 1000, appClock.now() +
); (REPLAY_TILE_SETTLE_DELAY_SEC + REPLAY_COUNTDOWN_DURATION_SEC) * 1000,
let cameraReady = false; );
}
let cameraReady = resuming;
let lastCameraUpdateMs = null; let lastCameraUpdateMs = null;
// Move the chase camera before scene traversal. Mutating it in preRender // Move the chase camera before scene traversal. Mutating it in preRender
@@ -486,6 +491,7 @@ export function createReplay({
} }
}); });
if (resuming) return true;
layerState._viewer.camera.flyToBoundingSphere( layerState._viewer.camera.flyToBoundingSphere(
geo.boundingSphere.create(track.ascentPath[0], 0), geo.boundingSphere.create(track.ascentPath[0], 0),
{ {
+7
View File
@@ -75,7 +75,12 @@ export function createSelection({
stopMissionZoomAnchor(); stopMissionZoomAnchor();
layerState._viewer.selectedEntity = undefined; layerState._viewer.selectedEntity = undefined;
} }
showSelectedMission();
}
function showSelectedMission() {
if (!layerState._dataSource) return; if (!layerState._dataSource) return;
const launchId = layerState._selectedLaunchId;
for (const entity of layerState._dataSource.entities.values) { for (const entity of layerState._dataSource.entities.values) {
const relatedId = entityLaunchId(entity); const relatedId = entityLaunchId(entity);
entity.show = launchId entity.show = launchId
@@ -107,6 +112,7 @@ export function createSelection({
Number(initialRange) || MISSION_GLOBE_VIEW_RANGE_M, Number(initialRange) || MISSION_GLOBE_VIEW_RANGE_M,
); );
layerState._missionZoomAnchorId = launch.id; layerState._missionZoomAnchorId = launch.id;
layerState._missionZoomRange = range;
layerState._viewer.camera.lookAt( layerState._viewer.camera.lookAt(
target, target,
getRenderer().range(0, parts.camera.missionZoomPitch(range), range), getRenderer().range(0, parts.camera.missionZoomPitch(range), range),
@@ -219,6 +225,7 @@ export function createSelection({
focusFullGlobe, focusFullGlobe,
entityLaunchId, entityLaunchId,
setSelectedMission, setSelectedMission,
showSelectedMission,
stopMissionZoomAnchor, stopMissionZoomAnchor,
startMissionZoomAnchor, startMissionZoomAnchor,
focusMission, focusMission,
+2
View File
@@ -61,6 +61,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
*/ */
async function _loadDenseCatalog({ signal = null } = {}) { async function _loadDenseCatalog({ signal = null } = {}) {
if (layerState._waitForRenderer) await layerState._waitForRenderer;
if (!layerState._viewer || !layerState._pointCollection) if (!layerState._viewer || !layerState._pointCollection)
return { status: 'source-unavailable', reason: 'layer-unavailable' }; return { status: 'source-unavailable', reason: 'layer-unavailable' };
layerState._denseLoadController?.abort(); layerState._denseLoadController?.abort();
@@ -103,6 +104,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
let added = 0; let added = 0;
for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) { for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) {
if (layerState._waitForRenderer) await layerState._waitForRenderer;
loadSignal.throwIfAborted(); loadSignal.throwIfAborted();
if ( if (
token !== layerState._denseLoadToken || token !== layerState._denseLoadToken ||
+5
View File
@@ -253,6 +253,10 @@ export function createControls({ state: layerState, services, parts, source }) {
* @param {string|number} noradId NORAD catalog number. * @param {string|number} noradId NORAD catalog number.
* @returns {boolean} True if tracking started. * @returns {boolean} True if tracking started.
*/ */
hasContact(id) {
return layerState._points.has(Number(id));
},
trackById(noradId, { origin = 'programmatic' } = {}) { trackById(noradId, { origin = 'programmatic' } = {}) {
const id = Number(noradId); const id = Number(noradId);
if ( if (
@@ -262,6 +266,7 @@ export function createControls({ state: layerState, services, parts, source }) {
!layerState._points.has(id) !layerState._points.has(id)
) )
return false; return false;
if (layerState._restoringViewport && layerState._trackedNorad === id) return true;
parts.tracking._cancelPendingTrackingRestore(); parts.tracking._cancelPendingTrackingRestore();
parts.tracking._trackSatellite(id, { origin }); parts.tracking._trackSatellite(id, { origin });
return layerState._trackedNorad === id; return layerState._trackedNorad === id;
+4
View File
@@ -86,6 +86,10 @@ export function createIngestion({
? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable` ? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable`
: null; : null;
if (layerState._waitForRenderer) {
await layerState._waitForRenderer;
updateSignal.throwIfAborted();
}
// Clear existing // Clear existing
layerState._pointCollection.removeAll(); layerState._pointCollection.removeAll();
layerState._points.clear(); layerState._points.clear();
+76 -2
View File
@@ -1,4 +1,4 @@
import { ISS_OVERLAY_SOURCE_ID } from './policy.js'; import { ISS_NORAD, ISS_OVERLAY_SOURCE_ID } from './policy.js';
export function createLifecycle({ export function createLifecycle({
state: layerState, state: layerState,
@@ -64,6 +64,78 @@ export function createLifecycle({
console.log('[Data:Satellites] Initialized'); console.log('[Data:Satellites] Initialized');
}, },
suspendCamera() {
// Outgoing tools release their cameras during teardown. That release
// must not classify a retained follow as a user deselection.
layerState._trackedEntityChangedRemove?.();
layerState._trackedEntityChangedRemove = null;
},
detachRenderer() {
const renderer = layerState._renderer;
if (!renderer) return;
layerState._waitForRenderer ??= new Promise(resolve => { layerState._resumeRenderer = resolve; });
layerState._clickHandler?.destroy();
layerState._trackedEntityChangedRemove?.();
layerState._preRenderListener?.();
layerState._clickHandler = layerState._trackedEntityChangedRemove = layerState._preRenderListener = null;
document.removeEventListener('keydown', parts.interaction._onKeyDown);
unregisterPickOwner('satellites');
if (layerState._trackedEntity) {
layerState._handoffOrigin = layerState._trackedEntity.gevSelectionOrigin;
renderer.follow.release();
renderer.removeTracked(layerState._trackedEntity);
layerState._trackedEntity = null;
}
for (const [id, point] of layerState._points) {
const {position, show, pixelSize, color, outlineColor, outlineWidth, scaleByDistance, disableDepthTestDistance} = point;
layerState._points.set(id, {id, position: {...position}, show, pixelSize, color, outlineColor, outlineWidth, scaleByDistance, disableDepthTestDistance});
}
for (const path of layerState._orbitPaths.values()) {
renderer.removeOrbit(path.primitive);
path.primitive = null;
}
layerState._detectionObjects.clear();
renderer.removePoints(layerState._pointCollection);
renderer.destroy();
layerState._engine = layerState._viewer = layerState._renderer = layerState._pointCollection = null;
layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID);
if (layerState._enabled) releaseContinuousRender('satellites');
},
attachRenderer(viewer, {engine = null, waitForCameraRestore = false} = {}) {
layerState._engine = engine;
const renderer = services.renderer.create({viewer,engine});
layerState._renderer = renderer;
layerState._viewer = renderer.view;
layerState._pointCollection = renderer.createPoints();
for (const [id, spec] of layerState._points)
layerState._points.set(id, layerState._pointCollection.add(spec));
layerState._pointCollection.show = layerState._enabled && layerState._params.showPoints;
for (const [id, path] of layerState._orbitPaths) {
path.primitive = renderer.createOrbit({ positions: path.basePositions, width: id === ISS_NORAD ? 2.5 : 2, color: path.color });
path.primitive.show = layerState._enabled && layerState._params.showOrbits;
}
// Source work can publish as soon as GPU collections exist. Restoring
// share-selected camera state may itself wait for that publication.
layerState._resumeRenderer?.();
layerState._waitForRenderer = layerState._resumeRenderer = null;
// The restored camera runs before a retained follow gets its new native target.
layerState._restoringViewport = true;
if (!waitForCameraRestore) this.resumeViewport();
},
resumeViewport() {
if (!layerState._restoringViewport || !layerState._renderer) return;
layerState._restoringViewport = false;
if (layerState._enabled) {
if (layerState._trackedNorad != null && !layerState._trackedEntity)
parts.tracking._trackSatellite(layerState._trackedNorad, {origin: layerState._handoffOrigin, resuming: true});
this.enable(layerState._viewer);
}
layerState._resumeRenderer?.();
layerState._waitForRenderer = layerState._resumeRenderer = null;
},
enable(viewer) { enable(viewer) {
layerState._enabled = true; layerState._enabled = true;
holdContinuousRender('satellites'); // per-frame animator (perf wave 2) holdContinuousRender('satellites'); // per-frame animator (perf wave 2)
@@ -124,6 +196,8 @@ export function createLifecycle({
}, },
destroy(viewer) { destroy(viewer) {
layerState._resumeRenderer?.();
layerState._waitForRenderer = layerState._resumeRenderer = null;
parts.catalog._abortActiveUpdates(); parts.catalog._abortActiveUpdates();
releaseContinuousRender('satellites'); // direct-destroy path (perf wave 2 fix) releaseContinuousRender('satellites'); // direct-destroy path (perf wave 2 fix)
layerState._enabled = false; layerState._enabled = false;
@@ -151,7 +225,7 @@ export function createLifecycle({
} }
// Orbit ring primitives are removed (and destroyed) here // Orbit ring primitives are removed (and destroyed) here
for (const path of layerState._orbitPaths.values()) { for (const path of layerState._orbitPaths.values()) {
getRenderer().removeOrbit(path.primitive); if (path.primitive) getRenderer().removeOrbit(path.primitive);
} }
layerState._points.clear(); layerState._points.clear();
layerState._detectionObjects.clear(); layerState._detectionObjects.clear();
+1
View File
@@ -60,6 +60,7 @@ export function createRendering({
layerState._orbitPaths.set(noradId, { layerState._orbitPaths.set(noradId, {
primitive, primitive,
basePositions, basePositions,
color: pathColor,
// Same Date object as computeOrbitPath's internal fixedGmst → identical // Same Date object as computeOrbitPath's internal fixedGmst → identical
// GMST value (gstime is pure), so delta starts at exactly 0 and the // GMST value (gstime is pure), so delta starts at exactly 0 and the
// initial identity modelMatrix is correct until the first ring tick. // initial identity modelMatrix is correct until the first ring tick.
+13 -11
View File
@@ -412,8 +412,8 @@ export function createTracking({
refreshTrackedReadout(layerState._trackedEntity); refreshTrackedReadout(layerState._trackedEntity);
} }
function _trackSatellite(noradId, { origin = 'programmatic' } = {}) { function _trackSatellite(noradId, { origin = 'programmatic', resuming = false } = {}) {
_clearTracking(false, { origin }); if (!resuming) _clearTracking(false, { origin });
const point = layerState._points.get(noradId); const point = layerState._points.get(noradId);
const sat = layerState._catalog.get(noradId); const sat = layerState._catalog.get(noradId);
@@ -470,15 +470,17 @@ export function createTracking({
layerState._trackedEntity.gevDisplayPosition = _trackedDisplayCached; layerState._trackedEntity.gevDisplayPosition = _trackedDisplayCached;
_updateTrackedSatelliteLabelModel(initialPos?.altitude ?? null); _updateTrackedSatelliteLabelModel(initialPos?.altitude ?? null);
_emitAwarenessEvent('gev:awareness-subject-selected', { if (!resuming) {
layerId: 'satellites', _emitAwarenessEvent('gev:awareness-subject-selected', {
id: noradId, layerId: 'satellites',
label: name, id: noradId,
position: geo.cartesian3.clone(point.position), label: name,
origin, position: geo.cartesian3.clone(point.position),
}); origin,
selectTrackedSubjectContext(_contextSubjectMetadata(noradId, initialPos)); });
layerState._contextRefreshedAtMs = Date.now(); selectTrackedSubjectContext(_contextSubjectMetadata(noradId, initialPos));
layerState._contextRefreshedAtMs = Date.now();
}
getRenderer().follow.start(layerState._trackedEntity); getRenderer().follow.start(layerState._trackedEntity);
console.log(`[Data:Satellites] Tracking ${name} (NORAD ${noradId})`); console.log(`[Data:Satellites] Tracking ${name} (NORAD ${noradId})`);
+4 -1
View File
@@ -141,7 +141,10 @@ async function reconcile(remember = false) {
id, { entry: entry.module.attachRenderer ? entry : null, enabled: manager.isEffectivelyEnabled(id) }, id, { entry: entry.module.attachRenderer ? entry : null, enabled: manager.isEffectivelyEnabled(id) },
])) ]))
: null; : null;
if (layerSession.handoffLayers) manager.layers.get('directions')?.module.suspendCamera?.(); if (layerSession.handoffLayers) {
manager.layers.get('directions')?.module.suspendCamera?.();
manager.layers.get('satellites')?.module.suspendCamera?.();
}
renderers.retain(kind); renderers.retain(kind);
await application.destroy(); await application.destroy();
await start(pageEngineKind(), true, cockpit); await start(pageEngineKind(), true, cockpit);
+6 -6
View File
@@ -625,7 +625,7 @@ export class StyleManager extends ShellFacade {
this._publishActiveStyle(); this._publishActiveStyle();
this._updateLocationMiniStatus(); this._updateLocationMiniStatus();
this._shareRestoration.start({ engineSwitch }); this._shareRestoration.start({ engineSwitch, retainScene: Boolean(this._layerSession?.handoffLayers) });
// Keep the parameter panel from overlapping toggle controls. // Keep the parameter panel from overlapping toggle controls.
this._layoutRightPanels(); this._layoutRightPanels();
@@ -976,7 +976,7 @@ export class StyleManager extends ShellFacade {
}, },
}); });
if (this._layerSession?.handoffLayers) if (this._layerSession?.handoffLayers)
this._contextControls.resumeScene(this._layerSession.contacts); this._contextControls.resumeScene(this._layerSession.contextScene);
// The Contacts layer publishes its panel; the view calls its navigation. // The Contacts layer publishes its panel; the view calls its navigation.
contextStore.connectAwareness(militaryAwarenessLayer); contextStore.connectAwareness(militaryAwarenessLayer);
// A panel left by an earlier shell must not outlive its layer. // A panel left by an earlier shell must not outlive its layer.
@@ -1856,15 +1856,15 @@ export class StyleManager extends ShellFacade {
// re-enable a mode's entry layer and republish `_contextMode` while the // re-enable a mode's entry layer and republish `_contextMode` while the
// rest of teardown is tearing those very layers down. // rest of teardown is tearing those very layers down.
this._contextControls.stop(); this._contextControls.stop();
this._navigation.destroy(); this._navigation.destroy({ retainScene: Boolean(this._layerSession?.handoffLayers) });
// Close camera-entry seams synchronously. Context restoration may await // Close camera-entry seams synchronously. Context restoration may await
// layer work, so leaving these listeners attached until afterward lets a // layer work, so leaving these listeners attached until afterward lets a
// focus event release tracking or start a flight during teardown. // focus event release tracking or start a flight during teardown.
if (this._layerSession && !this._layerSession.lifetime.signal.aborted && if (this._layerSession && !this._layerSession.lifetime.signal.aborted &&
this._contextControls.canRetainContacts()) { this._contextControls.canRetainScene()) {
this._layerSession.contacts = this._contextControls.captureScene(); this._layerSession.contextScene = this._contextControls.captureScene();
} else { } else {
if (this._layerSession) this._layerSession.contacts = null; if (this._layerSession) this._layerSession.contextScene = null;
await this._contextControls.restoreForDisposal(); await this._contextControls.restoreForDisposal();
} }
// IR boost teardown BEFORE detaching the data manager: restore fog and // IR boost teardown BEFORE detaching the data manager: restore fog and
+2 -2
View File
@@ -85,8 +85,8 @@ export class ContextControls {
this[field] = null; this[field] = null;
} }
} }
canRetainContacts() { canRetainScene() {
return this._contextMode === 'flights' && !this._contextModeChanging && return ['flights', 'space-missions'].includes(this._contextMode) && !this._contextModeChanging &&
!this._contextRestoreState && !this._clearSelectedLayersPromise && !this._contextRestoreState && !this._clearSelectedLayersPromise &&
!this._contextLayerReactionPromises.size; !this._contextLayerReactionPromises.size;
} }
+2 -2
View File
@@ -280,8 +280,8 @@ export class NavigationController {
this._cameraHandoffs?.publish(); this._cameraHandoffs?.publish();
this._cameraHandoffs?.destroy(); this._cameraHandoffs?.destroy();
} }
destroy() { destroy({ retainScene = false } = {}) {
this.stop(); this.stop();
this._stampNavigation(); this._stampNavigation({cancelPendingSelection: !retainScene});
} }
} }
+5 -3
View File
@@ -155,7 +155,7 @@ export class ShareRestoration {
this.shareLinkManager = shareLinkManager; this.shareLinkManager = shareLinkManager;
this._initialShareState = shareLinkManager.parseInitialHash(); this._initialShareState = shareLinkManager.parseInitialHash();
} }
start({ engineSwitch = false } = {}) { start({ engineSwitch = false, retainScene = false } = {}) {
// Restore from URL hash if present // Restore from URL hash if present
const savedState = this._initialShareState; const savedState = this._initialShareState;
this._initialShareRestorePromise = savedState this._initialShareRestorePromise = savedState
@@ -181,12 +181,13 @@ export class ShareRestoration {
Number.isInteger(generation) && Number.isInteger(generation) &&
this.navigation._reassertNavigationHandoff(generation, { this.navigation._reassertNavigationHandoff(generation, {
trackingOrigin: engineSwitch ? 'share-restore' : 'tool', trackingOrigin: engineSwitch ? 'share-restore' : 'tool',
keepTracking: Boolean(retained), keepTracking: retainScene || Boolean(retained),
}); });
void this._runShareRestore(savedState, { void this._runShareRestore(savedState, {
applyCamera, applyCamera,
instant: engineSwitch, instant: engineSwitch,
retained, retained,
retainScene,
navigationToken: generation, navigationToken: generation,
layersPromise: this._layerStateRestorePromise, layersPromise: this._layerStateRestorePromise,
settle: (result) => this._settleInitialShareRestore(result), settle: (result) => this._settleInitialShareRestore(result),
@@ -245,7 +246,7 @@ export class ShareRestoration {
*/ */
async _runShareRestore( async _runShareRestore(
state, state,
{ applyCamera, instant = false, retained, navigationToken, layersPromise, settle }, { applyCamera, instant = false, retained, retainScene = false, navigationToken, layersPromise, settle },
) { ) {
const generation = this._shareRestoreGeneration; const generation = this._shareRestoreGeneration;
const current = () => const current = () =>
@@ -258,6 +259,7 @@ export class ShareRestoration {
}); });
const layers = await (layersPromise || Promise.resolve([])); const layers = await (layersPromise || Promise.resolve([]));
if (!current()) return; if (!current()) return;
if (retainScene && !retained) retained = this._layerStateCoordinator?.retainedTrackingTarget();
const tracking = const tracking =
share.camera === 'applied' share.camera === 'applied'
? (retained && this._layerStateCoordinator.resumeRetainedTracking(retained)) || ? (retained && this._layerStateCoordinator.resumeRetainedTracking(retained)) ||
-1
View File
@@ -236,7 +236,6 @@ export const VIEWER_ONLY_ACTIONS = Object.freeze([
'clear_annotations', 'clear_annotations',
'fly_route', 'fly_route',
'move_camera', 'move_camera',
'set_context_mode',
]); ]);
/** The refusal code of a viewer-only tool on an engine without a viewer. */ /** The refusal code of a viewer-only tool on an engine without a viewer. */
export const UNAVAILABLE_ON_ENGINE = 'UNAVAILABLE_ON_ENGINE'; export const UNAVAILABLE_ON_ENGINE = 'UNAVAILABLE_ON_ENGINE';
-1
View File
@@ -93,7 +93,6 @@ test('the viewer-only actions are one exported list of real tools', () => {
'clear_annotations', 'clear_annotations',
'fly_route', 'fly_route',
'move_camera', 'move_camera',
'set_context_mode',
]); ]);
assert.ok(Object.isFrozen(VIEWER_ONLY_ACTIONS)); assert.ok(Object.isFrozen(VIEWER_ONLY_ACTIONS));
const tools = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name)); const tools = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name));