mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Preserve satellite tracking, orbit solutions and mission replay across globes
This commit is contained in:
1 parent
133ca14119
commit
a49671a391
21 files changed
+446
-71
No files matched your search
@@ -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();
|
||||
}
|
||||
@@ -153,9 +153,9 @@ export function createApplicationCatalog({
|
||||
),
|
||||
);
|
||||
const satellites = once(() =>
|
||||
createApplicationSatellites({
|
||||
sessionModel('satellites', () => createApplicationSatellites({
|
||||
source: sources.satellites,
|
||||
}),
|
||||
})),
|
||||
);
|
||||
// Catalog order: [layer id(s), construction]. Only the layers the
|
||||
// engine can show are constructed (renderers.js).
|
||||
@@ -235,10 +235,10 @@ export function createApplicationCatalog({
|
||||
[
|
||||
'rocket-launches',
|
||||
() =>
|
||||
createApplicationLaunches({
|
||||
sessionModel('rocket-launches', () => createApplicationLaunches({
|
||||
source: sources.launches,
|
||||
satellites: satellites(),
|
||||
}),
|
||||
})),
|
||||
],
|
||||
[
|
||||
'traffic',
|
||||
|
||||
@@ -491,7 +491,7 @@ export class LayerStateCoordinator {
|
||||
const model = this.dataManager.layers.get(selected.layerId)?.module;
|
||||
if (
|
||||
model?.attachRenderer &&
|
||||
model.getParams?.()[selected.optionKey] === selected.targetId &&
|
||||
String(model.getParams?.()[selected.optionKey]) === String(selected.targetId) &&
|
||||
model.hasContact?.(selected.targetId) === true
|
||||
) return selected;
|
||||
return null;
|
||||
|
||||
@@ -179,6 +179,7 @@ export function createIngestion({
|
||||
stale: false,
|
||||
};
|
||||
const launches = parts.model.normalizeRocketLaunches(payload);
|
||||
if (layerState._waitForRenderer) await layerState._waitForRenderer;
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
token !== layerState._lifecycleToken ||
|
||||
|
||||
@@ -8,20 +8,22 @@ export function createLifecycle({
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
|
||||
const methods = {
|
||||
init(viewer, { engine = null } = {}) {
|
||||
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();
|
||||
init(viewer, options) {
|
||||
layerState._count = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._stale = false;
|
||||
layerState._lastError = null;
|
||||
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();
|
||||
const enabled = () => layerState._enabled && layerState._dataSource?.show;
|
||||
layerState._clickHandler = getRenderer().listenClick(
|
||||
@@ -53,6 +55,52 @@ export function createLifecycle({
|
||||
parts.rendering.updateMissionFrame,
|
||||
);
|
||||
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() {
|
||||
@@ -116,6 +164,8 @@ export function createLifecycle({
|
||||
},
|
||||
|
||||
async destroy(viewer) {
|
||||
layerState._resumeRenderer?.();
|
||||
layerState._waitForRenderer = layerState._resumeRenderer = null;
|
||||
layerState._sourceController.abort();
|
||||
releaseContinuousRender('rocket-launches'); // direct-destroy path (perf wave 2 fix)
|
||||
layerState._enabled = false;
|
||||
|
||||
@@ -76,18 +76,12 @@ export function createRendering({
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
// The source snapshot owns its ephemeris epoch. A renderer attached later
|
||||
// must reconstruct the same ascent and orbit, including a paused replay.
|
||||
const orbitSolutions = new WeakMap();
|
||||
function orbitForLaunch(launch, activeTleText, orbitAllowed) {
|
||||
const known = orbitSolutions.get(launch);
|
||||
if (known && known.activeTleText === activeTleText) return known;
|
||||
const coreTrack =
|
||||
orbitAllowed && launch.satelliteQuery
|
||||
? getSatelliteOrbitTrack(launch.satelliteQuery, {
|
||||
@@ -106,6 +100,24 @@ export function createRendering({
|
||||
: satelliteTrack?.orbitPath?.length > 1
|
||||
? satelliteTrack.orbitPath
|
||||
: 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) => {
|
||||
stage.endpoint = parts.paths.landingEndpoint(
|
||||
stage,
|
||||
|
||||
@@ -280,6 +280,7 @@ export function createReplay({
|
||||
return false;
|
||||
layerState._replayPausedAtMs = appClock.now();
|
||||
layerState._replayPaused = true;
|
||||
layerState._replayTracks.get(layerState._replayCameraLaunchId)?.beginReplayFrame?.();
|
||||
syncReplayButton();
|
||||
return true;
|
||||
}
|
||||
@@ -329,20 +330,22 @@ export function createReplay({
|
||||
parts.overlays.syncMissionOverlayEntries();
|
||||
}
|
||||
|
||||
function startMissionReplay(launchId) {
|
||||
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;
|
||||
if (layerState._replayCameraLaunchId === launchId) {
|
||||
if (!resuming && layerState._replayCameraLaunchId === launchId) {
|
||||
stopMissionReplay();
|
||||
return false;
|
||||
}
|
||||
|
||||
parts.selection.stopMissionZoomAnchor();
|
||||
stopMissionReplay();
|
||||
layerState._replayCameraLaunchId = launchId;
|
||||
layerState._replayPaused = false;
|
||||
layerState._replayPausedAtMs = null;
|
||||
if (!resuming) {
|
||||
parts.selection.stopMissionZoomAnchor();
|
||||
stopMissionReplay();
|
||||
layerState._replayCameraLaunchId = launchId;
|
||||
layerState._replayPaused = false;
|
||||
layerState._replayPausedAtMs = null;
|
||||
}
|
||||
const token = ++layerState._replayCameraToken;
|
||||
const ascentDurationSec = track.ascentDurationSec;
|
||||
// Start broadside to the ascent/orbit direction so the launch profile is
|
||||
@@ -354,15 +357,17 @@ export function createReplay({
|
||||
track.lastCameraHeading = initialHeading;
|
||||
syncReplayButton();
|
||||
parts.overlays.syncMissionOverlayEntries();
|
||||
parts.policyHelpers.releaseAircraftTracking(layerState._dataManager);
|
||||
layerState._viewer.trackedEntity = undefined;
|
||||
layerState._viewer.camera.cancelFlight();
|
||||
layerState._animationStarts.set(
|
||||
launchId,
|
||||
appClock.now() +
|
||||
(REPLAY_TILE_SETTLE_DELAY_SEC + REPLAY_COUNTDOWN_DURATION_SEC) * 1000,
|
||||
);
|
||||
let cameraReady = false;
|
||||
if (!resuming) {
|
||||
parts.policyHelpers.releaseAircraftTracking(layerState._dataManager);
|
||||
layerState._viewer.trackedEntity = undefined;
|
||||
layerState._viewer.camera.cancelFlight();
|
||||
layerState._animationStarts.set(
|
||||
launchId,
|
||||
appClock.now() +
|
||||
(REPLAY_TILE_SETTLE_DELAY_SEC + REPLAY_COUNTDOWN_DURATION_SEC) * 1000,
|
||||
);
|
||||
}
|
||||
let cameraReady = resuming;
|
||||
let lastCameraUpdateMs = null;
|
||||
|
||||
// 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(
|
||||
geo.boundingSphere.create(track.ascentPath[0], 0),
|
||||
{
|
||||
|
||||
@@ -75,7 +75,12 @@ export function createSelection({
|
||||
stopMissionZoomAnchor();
|
||||
layerState._viewer.selectedEntity = undefined;
|
||||
}
|
||||
showSelectedMission();
|
||||
}
|
||||
|
||||
function showSelectedMission() {
|
||||
if (!layerState._dataSource) return;
|
||||
const launchId = layerState._selectedLaunchId;
|
||||
for (const entity of layerState._dataSource.entities.values) {
|
||||
const relatedId = entityLaunchId(entity);
|
||||
entity.show = launchId
|
||||
@@ -107,6 +112,7 @@ export function createSelection({
|
||||
Number(initialRange) || MISSION_GLOBE_VIEW_RANGE_M,
|
||||
);
|
||||
layerState._missionZoomAnchorId = launch.id;
|
||||
layerState._missionZoomRange = range;
|
||||
layerState._viewer.camera.lookAt(
|
||||
target,
|
||||
getRenderer().range(0, parts.camera.missionZoomPitch(range), range),
|
||||
@@ -219,6 +225,7 @@ export function createSelection({
|
||||
focusFullGlobe,
|
||||
entityLaunchId,
|
||||
setSelectedMission,
|
||||
showSelectedMission,
|
||||
stopMissionZoomAnchor,
|
||||
startMissionZoomAnchor,
|
||||
focusMission,
|
||||
|
||||
@@ -61,6 +61,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
*/
|
||||
|
||||
async function _loadDenseCatalog({ signal = null } = {}) {
|
||||
if (layerState._waitForRenderer) await layerState._waitForRenderer;
|
||||
if (!layerState._viewer || !layerState._pointCollection)
|
||||
return { status: 'source-unavailable', reason: 'layer-unavailable' };
|
||||
layerState._denseLoadController?.abort();
|
||||
@@ -103,6 +104,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
let added = 0;
|
||||
|
||||
for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) {
|
||||
if (layerState._waitForRenderer) await layerState._waitForRenderer;
|
||||
loadSignal.throwIfAborted();
|
||||
if (
|
||||
token !== layerState._denseLoadToken ||
|
||||
|
||||
Vendored
+5
@@ -253,6 +253,10 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
* @param {string|number} noradId NORAD catalog number.
|
||||
* @returns {boolean} True if tracking started.
|
||||
*/
|
||||
hasContact(id) {
|
||||
return layerState._points.has(Number(id));
|
||||
},
|
||||
|
||||
trackById(noradId, { origin = 'programmatic' } = {}) {
|
||||
const id = Number(noradId);
|
||||
if (
|
||||
@@ -262,6 +266,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
!layerState._points.has(id)
|
||||
)
|
||||
return false;
|
||||
if (layerState._restoringViewport && layerState._trackedNorad === id) return true;
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
parts.tracking._trackSatellite(id, { origin });
|
||||
return layerState._trackedNorad === id;
|
||||
|
||||
@@ -86,6 +86,10 @@ export function createIngestion({
|
||||
? `${failed.length} CelesTrak group${failed.length === 1 ? '' : 's'} unavailable`
|
||||
: null;
|
||||
|
||||
if (layerState._waitForRenderer) {
|
||||
await layerState._waitForRenderer;
|
||||
updateSignal.throwIfAborted();
|
||||
}
|
||||
// Clear existing
|
||||
layerState._pointCollection.removeAll();
|
||||
layerState._points.clear();
|
||||
|
||||
@@ -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({
|
||||
state: layerState,
|
||||
@@ -64,6 +64,78 @@ export function createLifecycle({
|
||||
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) {
|
||||
layerState._enabled = true;
|
||||
holdContinuousRender('satellites'); // per-frame animator (perf wave 2)
|
||||
@@ -124,6 +196,8 @@ export function createLifecycle({
|
||||
},
|
||||
|
||||
destroy(viewer) {
|
||||
layerState._resumeRenderer?.();
|
||||
layerState._waitForRenderer = layerState._resumeRenderer = null;
|
||||
parts.catalog._abortActiveUpdates();
|
||||
releaseContinuousRender('satellites'); // direct-destroy path (perf wave 2 fix)
|
||||
layerState._enabled = false;
|
||||
@@ -151,7 +225,7 @@ export function createLifecycle({
|
||||
}
|
||||
// Orbit ring primitives are removed (and destroyed) here
|
||||
for (const path of layerState._orbitPaths.values()) {
|
||||
getRenderer().removeOrbit(path.primitive);
|
||||
if (path.primitive) getRenderer().removeOrbit(path.primitive);
|
||||
}
|
||||
layerState._points.clear();
|
||||
layerState._detectionObjects.clear();
|
||||
|
||||
@@ -60,6 +60,7 @@ export function createRendering({
|
||||
layerState._orbitPaths.set(noradId, {
|
||||
primitive,
|
||||
basePositions,
|
||||
color: pathColor,
|
||||
// Same Date object as computeOrbitPath's internal fixedGmst → identical
|
||||
// GMST value (gstime is pure), so delta starts at exactly 0 and the
|
||||
// initial identity modelMatrix is correct until the first ring tick.
|
||||
|
||||
@@ -412,8 +412,8 @@ export function createTracking({
|
||||
refreshTrackedReadout(layerState._trackedEntity);
|
||||
}
|
||||
|
||||
function _trackSatellite(noradId, { origin = 'programmatic' } = {}) {
|
||||
_clearTracking(false, { origin });
|
||||
function _trackSatellite(noradId, { origin = 'programmatic', resuming = false } = {}) {
|
||||
if (!resuming) _clearTracking(false, { origin });
|
||||
|
||||
const point = layerState._points.get(noradId);
|
||||
const sat = layerState._catalog.get(noradId);
|
||||
@@ -470,15 +470,17 @@ export function createTracking({
|
||||
layerState._trackedEntity.gevDisplayPosition = _trackedDisplayCached;
|
||||
_updateTrackedSatelliteLabelModel(initialPos?.altitude ?? null);
|
||||
|
||||
_emitAwarenessEvent('gev:awareness-subject-selected', {
|
||||
layerId: 'satellites',
|
||||
id: noradId,
|
||||
label: name,
|
||||
position: geo.cartesian3.clone(point.position),
|
||||
origin,
|
||||
});
|
||||
selectTrackedSubjectContext(_contextSubjectMetadata(noradId, initialPos));
|
||||
layerState._contextRefreshedAtMs = Date.now();
|
||||
if (!resuming) {
|
||||
_emitAwarenessEvent('gev:awareness-subject-selected', {
|
||||
layerId: 'satellites',
|
||||
id: noradId,
|
||||
label: name,
|
||||
position: geo.cartesian3.clone(point.position),
|
||||
origin,
|
||||
});
|
||||
selectTrackedSubjectContext(_contextSubjectMetadata(noradId, initialPos));
|
||||
layerState._contextRefreshedAtMs = Date.now();
|
||||
}
|
||||
|
||||
getRenderer().follow.start(layerState._trackedEntity);
|
||||
console.log(`[Data:Satellites] Tracking ${name} (NORAD ${noradId})`);
|
||||
|
||||
+4
-1
@@ -141,7 +141,10 @@ async function reconcile(remember = false) {
|
||||
id, { entry: entry.module.attachRenderer ? entry : null, enabled: manager.isEffectivelyEnabled(id) },
|
||||
]))
|
||||
: 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);
|
||||
await application.destroy();
|
||||
await start(pageEngineKind(), true, cockpit);
|
||||
|
||||
@@ -625,7 +625,7 @@ export class StyleManager extends ShellFacade {
|
||||
this._publishActiveStyle();
|
||||
this._updateLocationMiniStatus();
|
||||
|
||||
this._shareRestoration.start({ engineSwitch });
|
||||
this._shareRestoration.start({ engineSwitch, retainScene: Boolean(this._layerSession?.handoffLayers) });
|
||||
|
||||
// Keep the parameter panel from overlapping toggle controls.
|
||||
this._layoutRightPanels();
|
||||
@@ -976,7 +976,7 @@ export class StyleManager extends ShellFacade {
|
||||
},
|
||||
});
|
||||
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.
|
||||
contextStore.connectAwareness(militaryAwarenessLayer);
|
||||
// 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
|
||||
// rest of teardown is tearing those very layers down.
|
||||
this._contextControls.stop();
|
||||
this._navigation.destroy();
|
||||
this._navigation.destroy({ retainScene: Boolean(this._layerSession?.handoffLayers) });
|
||||
// Close camera-entry seams synchronously. Context restoration may await
|
||||
// layer work, so leaving these listeners attached until afterward lets a
|
||||
// focus event release tracking or start a flight during teardown.
|
||||
if (this._layerSession && !this._layerSession.lifetime.signal.aborted &&
|
||||
this._contextControls.canRetainContacts()) {
|
||||
this._layerSession.contacts = this._contextControls.captureScene();
|
||||
this._contextControls.canRetainScene()) {
|
||||
this._layerSession.contextScene = this._contextControls.captureScene();
|
||||
} else {
|
||||
if (this._layerSession) this._layerSession.contacts = null;
|
||||
if (this._layerSession) this._layerSession.contextScene = null;
|
||||
await this._contextControls.restoreForDisposal();
|
||||
}
|
||||
// IR boost teardown BEFORE detaching the data manager: restore fog and
|
||||
|
||||
@@ -85,8 +85,8 @@ export class ContextControls {
|
||||
this[field] = null;
|
||||
}
|
||||
}
|
||||
canRetainContacts() {
|
||||
return this._contextMode === 'flights' && !this._contextModeChanging &&
|
||||
canRetainScene() {
|
||||
return ['flights', 'space-missions'].includes(this._contextMode) && !this._contextModeChanging &&
|
||||
!this._contextRestoreState && !this._clearSelectedLayersPromise &&
|
||||
!this._contextLayerReactionPromises.size;
|
||||
}
|
||||
|
||||
@@ -280,8 +280,8 @@ export class NavigationController {
|
||||
this._cameraHandoffs?.publish();
|
||||
this._cameraHandoffs?.destroy();
|
||||
}
|
||||
destroy() {
|
||||
destroy({ retainScene = false } = {}) {
|
||||
this.stop();
|
||||
this._stampNavigation();
|
||||
this._stampNavigation({cancelPendingSelection: !retainScene});
|
||||
}
|
||||
}
|
||||
@@ -155,7 +155,7 @@ export class ShareRestoration {
|
||||
this.shareLinkManager = shareLinkManager;
|
||||
this._initialShareState = shareLinkManager.parseInitialHash();
|
||||
}
|
||||
start({ engineSwitch = false } = {}) {
|
||||
start({ engineSwitch = false, retainScene = false } = {}) {
|
||||
// Restore from URL hash if present
|
||||
const savedState = this._initialShareState;
|
||||
this._initialShareRestorePromise = savedState
|
||||
@@ -181,12 +181,13 @@ export class ShareRestoration {
|
||||
Number.isInteger(generation) &&
|
||||
this.navigation._reassertNavigationHandoff(generation, {
|
||||
trackingOrigin: engineSwitch ? 'share-restore' : 'tool',
|
||||
keepTracking: Boolean(retained),
|
||||
keepTracking: retainScene || Boolean(retained),
|
||||
});
|
||||
void this._runShareRestore(savedState, {
|
||||
applyCamera,
|
||||
instant: engineSwitch,
|
||||
retained,
|
||||
retainScene,
|
||||
navigationToken: generation,
|
||||
layersPromise: this._layerStateRestorePromise,
|
||||
settle: (result) => this._settleInitialShareRestore(result),
|
||||
@@ -245,7 +246,7 @@ export class ShareRestoration {
|
||||
*/
|
||||
async _runShareRestore(
|
||||
state,
|
||||
{ applyCamera, instant = false, retained, navigationToken, layersPromise, settle },
|
||||
{ applyCamera, instant = false, retained, retainScene = false, navigationToken, layersPromise, settle },
|
||||
) {
|
||||
const generation = this._shareRestoreGeneration;
|
||||
const current = () =>
|
||||
@@ -258,6 +259,7 @@ export class ShareRestoration {
|
||||
});
|
||||
const layers = await (layersPromise || Promise.resolve([]));
|
||||
if (!current()) return;
|
||||
if (retainScene && !retained) retained = this._layerStateCoordinator?.retainedTrackingTarget();
|
||||
const tracking =
|
||||
share.camera === 'applied'
|
||||
? (retained && this._layerStateCoordinator.resumeRetainedTracking(retained)) ||
|
||||
|
||||
@@ -236,7 +236,6 @@ export const VIEWER_ONLY_ACTIONS = Object.freeze([
|
||||
'clear_annotations',
|
||||
'fly_route',
|
||||
'move_camera',
|
||||
'set_context_mode',
|
||||
]);
|
||||
/** The refusal code of a viewer-only tool on an engine without a viewer. */
|
||||
export const UNAVAILABLE_ON_ENGINE = 'UNAVAILABLE_ON_ENGINE';
|
||||
|
||||
@@ -93,7 +93,6 @@ test('the viewer-only actions are one exported list of real tools', () => {
|
||||
'clear_annotations',
|
||||
'fly_route',
|
||||
'move_camera',
|
||||
'set_context_mode',
|
||||
]);
|
||||
assert.ok(Object.isFrozen(VIEWER_ONLY_ACTIONS));
|
||||
const tools = new Set(GEV_ACTION_SCHEMAS.map((schema) => schema.name));
|
||||
|
||||
Reference in new issue
Block a user