fix(cockpit): no sensor swath past the limb, no strip for a GEO view or a SAR; the TLE age reads from the world-clock time shown; pre-loads cancel on exit, retry when they fail, start over on a new base map, wait while the clock is away from now and reach the frame edges

This commit is contained in:
Sameh Khamis committed 2026-11-08 16:35:23 -08:00
1 parent 9a16fb8cbc
commit b953c3f1dc
8 files changed
+207 -34

No files matched your search

+9 -2
View File
@@ -585,9 +585,16 @@ export const ORBITAL_PREFETCH_LEAD_S = Object.freeze([5, 10, 15, 20]);
/**
* Where across the track it looks, as fractions of the view's width: the
* centre line and a third out each side, so a tile row spans the frame.
* centre line, a third out and the frame's edges each side, so a row of
* tiles a fifth of the view wide or more spans the frame.
*/
export const ORBITAL_PREFETCH_ACROSS = Object.freeze([0, -1 / 3, 1 / 3]);
export const ORBITAL_PREFETCH_ACROSS = Object.freeze([
0,
-1 / 3,
1 / 3,
-1 / 2,
1 / 2,
]);
/** At most this many tile requests per look-ahead. */
export const ORBITAL_PREFETCH_MAX_TILES = 12;
+11 -5
View File
@@ -461,15 +461,17 @@ test('the points ahead run along the ground track at ground speed', () => {
assert.ok(Math.abs(km - groundKmPerS * seconds) < 0.5, `${seconds} s`);
// North-east of the start, on a 45° track.
assert.ok(centre.lat > PASS.latitude && centre.lon > PASS.longitude);
// The side points sit a third of the view off the centre line.
for (const side of points.slice(i * across + 1, (i + 1) * across))
assert.ok(Math.abs(greatCircleKm(centre, side) - 20 / 3) < 0.05);
// The side points sit their fraction of the view off the centre line.
points.slice(i * across + 1, (i + 1) * across).forEach((side, k) => {
const offKm = 20 * Math.abs(ORBITAL_PREFETCH_ACROSS[k + 1]);
assert.ok(Math.abs(greatCircleKm(centre, side) - offKm) < 0.05);
});
});
assert.deepEqual(groundTrackAhead({ ...PASS, groundTrackDeg: null }, 20), []);
assert.deepEqual(groundTrackAhead(PASS, null), []);
});
test("the tile level is the one Cesium draws for the view", () => {
test('the tile level is the one Cesium draws for the view', () => {
const mercator = new Cesium.WebMercatorTilingScheme();
// Worked case: 10 m per CSS pixel at the equator. Cesium refines the
// ellipsoid terrain until a tile's error is at most 2 px, 20 m: level 12,
@@ -566,7 +568,11 @@ test('the tiles ahead are the ones the points fall in, bounded and new', () => {
assert.ok(tiles.every((tile) => tile.x >= here.x && tile.y <= here.y));
// Bounded, and a tile already asked for is skipped.
assert.equal(tilesForPoints(mercator, points, 15, { maxTiles: 3 }).length, 3);
const asked = new Set(tiles.map((tile) => tile.key));
const asked = new Set(
tilesForPoints(mercator, points, 13, { maxTiles: Infinity }).map(
(tile) => tile.key,
),
);
assert.deepEqual(
tilesForPoints(mercator, points, 13, { skip: (key) => asked.has(key) }),
[],
+5 -2
View File
@@ -97,8 +97,8 @@ function freezeOwner(owner) {
* @param {number} noradId NORAD catalog number.
* @param {{name?: string, group?: string}|null} sat Catalog entry.
* @param {{latitude: number, longitude: number, altitude: number,
* speedMps?: number|null, groundTrackDeg?: number|null}|null} fix Per-frame
* SGP4 sample (orbits.js propagatePosition).
* speedMps?: number|null, groundTrackDeg?: number|null, atMs?: number}|null}
* fix Per-frame SGP4 sample (orbits.js propagatePosition), at `atMs`.
* @returns {object|null} Subject info, or null without a usable fix.
*/
export function satelliteCockpitInfo(noradId, sat, fix) {
@@ -122,6 +122,9 @@ export function satelliteCockpitInfo(noradId, sat, fix) {
altitudeM: altitude,
// SGP4's TEME velocity: the inertial orbital speed, not ground speed.
velocityMps: Number.isFinite(fix.speedMps) ? fix.speedMps : null,
// The time the position is at (epoch ms), the satellites' world-clock
// time; null when the sample does not say.
atMs: Number.isFinite(fix.atMs) ? fix.atMs : null,
// Degrees clockwise from north; null for a geostationary satellite.
groundTrackDeg: Number.isFinite(fix.groundTrackDeg)
? fix.groundTrackDeg
+6 -6
View File
@@ -155,13 +155,13 @@ export function createTracking({ state: layerState, services, parts, source }) {
frameNumber !== layerState._trackedFrameNumber ||
layerState._trackedFrameGeo === null
) {
const pos = parts.orbits.propagatePosition(
sat.satrec,
layerState._trackedFrameNowForTest
? new Date(layerState._trackedFrameNowForTest())
: new Date(layerState.now()),
);
const atMs = layerState._trackedFrameNowForTest
? layerState._trackedFrameNowForTest()
: layerState.now();
const pos = parts.orbits.propagatePosition(sat.satrec, new Date(atMs));
if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample
// The time the sample is at: the world clock's, not the wall clock's.
pos.atMs = atMs;
layerState._trackedFrameGeo = pos;
Cesium.Cartesian3.fromDegrees(
pos.longitude,
+54 -10
View File
@@ -86,6 +86,9 @@ export function beginOrbital() {
// looked ahead, and the tiles already asked for (oldest first).
prefetchMs: -Infinity,
prefetched: new Set(),
// The provider those tiles came from, and the requests still in flight.
prefetchProvider: null,
prefetchRequests: new Set(),
// A zoom step in flight: the magnification it started from and when.
transition: null,
previousUp: null,
@@ -119,6 +122,9 @@ export function endOrbital() {
if (!state) return;
this.orbital = null;
state.placeTracker?.reset();
// Pre-loads still queued or in flight are for a view no longer shown.
for (const request of state.prefetchRequests) request.cancel?.();
state.prefetchRequests.clear();
const frustum = this.viewer.camera?.frustum;
if (frustum && state.savedFov !== null) frustum.fov = state.savedFov;
// Back to the map the operator had, unless they picked another meanwhile.
@@ -356,6 +362,9 @@ export function updateOrbital(nowMs) {
/** Tiles remembered as asked for in one orbital session. */
const PREFETCH_MEMORY = 512;
/** How far the shown time may be from now for the look-ahead to run, ms. */
const PREFETCH_LIVE_TOLERANCE_MS = 60_000;
/**
* From 16x the view is 10 to 40 km wide while the ground track moves about
* 7 km/s, so the imagery under it arrives late. Every
@@ -367,9 +376,11 @@ const PREFETCH_MEMORY = 512;
* imagery server's slots are taken by the tiles on screen or the scheduler
* is full, and a declined tile is asked again on a later look-ahead. At most
* ORBITAL_PREFETCH_MAX_TILES a time; the last PREFETCH_MEMORY tiles asked
* for are not asked again. Nothing below 16x, while the view is tilted off
* nadir (it no longer looks at the track), after the exit, or with the globe
* hidden (Google 3D tiles drawn instead).
* for are not asked again unless their request failed, and a new base map
* starts the memory over. Exit cancels the requests still pending. Nothing
* below 16x, while the view is tilted off nadir (it no longer looks at the
* track), with the world clock away from now (paused, played back), after
* the exit, or with the globe hidden (Google 3D tiles drawn instead).
* @returns {number} The tiles asked for.
*/
export function prefetchImageryAhead(info, nowMs) {
@@ -379,12 +390,24 @@ export function prefetchImageryAhead(info, nowMs) {
if (lookIsOffset(state.lookTarget, 0) || lookIsOffset(state.lookView, 0))
return 0;
if (nowMs - state.prefetchMs < ORBITAL_PREFETCH_INTERVAL_MS) return 0;
// The look-ahead runs at live speed: with the world clock paused, played
// back or moved, the view will not pass where it points.
if (
Number.isFinite(info.atMs) &&
Math.abs(info.atMs - Date.now()) > PREFETCH_LIVE_TOLERANCE_MS
)
return 0;
state.prefetchMs = nowMs;
try {
const globe = this.viewer.scene?.globe;
if (!globe || globe.show === false) return 0;
const provider = baseImageryProvider(this.viewer);
if (!provider) return 0;
// Another base map: its tiles have not been asked for.
if (provider !== state.prefetchProvider) {
state.prefetched.clear();
state.prefetchProvider = provider;
}
const fov = fovForZoom(orbitalBaseFovRad(info.altitudeM), state.zoom);
const viewKm = footprintWidthKm(
info.altitudeM,
@@ -427,6 +450,7 @@ export function prefetchImageryAhead(info, nowMs) {
let asked = 0;
for (const tile of tiles) {
let pending;
let request;
try {
// The tiles on screen keep their slots: Cesium's scheduler declines
// this request while the imagery server's slots are taken (shared
@@ -437,19 +461,28 @@ export function prefetchImageryAhead(info, nowMs) {
tile.x,
tile.y,
tile.level,
new Cesium.Request({
(request = new Cesium.Request({
throttle: true,
throttleByServer: true,
priority: Number.MAX_VALUE,
type: Cesium.RequestType.IMAGERY,
}),
})),
);
} catch {
continue;
}
// A provider that declines now (throttled) is asked again later.
if (!pending) continue;
Promise.resolve(pending).catch(() => {});
const requests = state.prefetchRequests;
requests.add(request);
Promise.resolve(pending).then(
() => requests.delete(request),
() => {
requests.delete(request);
// Failed or dropped by the scheduler: asked again later.
memory.delete(tile.key);
},
);
memory.add(tile.key);
if (memory.size > PREFETCH_MEMORY)
memory.delete(memory.values().next().value);
@@ -602,15 +635,21 @@ export function publishOrbitalView(info, transition = IDLE_TRANSITION) {
footprintKm: offset ? null : footprintKm,
// The imaging sensor's swath, drawn to scale; null without one. The
// SENSOR step's zoom is null when the swath lies outside 1x..maxZoom.
// Looking past the limb there is no ground for it to cover: no sensor.
sensor:
swathKm === null
swathKm === null || !seesEarth
? null
: {
label: info.sensor?.label ?? null,
swathKm,
// Off nadir the view and the swath widen alike: the strip
// keeps its share of the frame.
stripFraction: sensorStripFraction(swathKm, footprintKm),
// keeps its share of the frame. No strip where it would
// mislead: a geostationary view has no ground track to run
// along, and a SAR images to one side of nadir, not under it.
stripFraction:
trackUp && info.sensor?.type !== 'SAR'
? sensorStripFraction(swathKm, footprintKm)
: null,
zoom: sensorSwathZoom(info.altitudeM, swathKm, aspect, maxZoom),
},
transition: { ...transition },
@@ -634,8 +673,13 @@ export function publishOrbitalView(info, transition = IDLE_TRANSITION) {
cosparId: info.cosparId ?? null,
tle: info.tle ?? null,
// Read on the HUD's cadence, so the age stays current without a timer.
// The gap between the TLE's epoch and the time the satellite is shown
// at (the world clock's), either side: SGP4 drifts both ways.
tleAgeMs: Number.isFinite(info.tle?.epochMs)
? Date.now() - info.tle.epochMs
? Math.abs(
(Number.isFinite(info.atMs) ? info.atMs : Date.now()) -
info.tle.epochMs,
)
: null,
owner: info.owner ?? null,
operator: info.operator ?? null,
+103 -1
View File
@@ -1018,9 +1018,11 @@ const WORLDVIEW = Object.freeze({
id: '40115',
noradId: 40115,
name: 'WORLDVIEW-3 (WV-3)',
classKey: 'visual',
satelliteClass: 'imaging',
classLabel: 'IMAGING',
altitudeM: 617_000,
// A sun-synchronous pass: the strip runs along this track.
groundTrackDeg: 190,
sensor: Object.freeze({
label: 'WorldView-3',
narrowestSwathKm: 13.1,
@@ -1453,3 +1455,103 @@ test('the pre-load is throttled, at the level Cesium draws, and waits out a tilt
});
assert.equal(Number(requests[0].split('/')[0]), expected);
});
test('the TLE age is measured from the time the satellite is shown at', async (t) => {
const DAY = 86_400_000;
const { store } = await orbitAt(t, {
satellite: {
...ISS,
// The world clock thirty days back: before the element set's epoch.
atMs: START_MS - 30 * DAY,
tle: { line1: '1 25544U', line2: '2 25544', epochMs: START_MS - 2 * DAY },
},
});
assert.ok(Math.abs(store.orbital.tleAgeMs / DAY - 28) < 1e-6);
});
/** A geostationary imager with a 1000 km sensor swath. */
const GEO_IMAGER = Object.freeze({
...ISS,
id: '41866',
noradId: 41866,
name: 'GEO IMAGER',
satelliteClass: 'imaging',
classLabel: 'IMAGING',
altitudeM: 35_786_000,
sensor: Object.freeze({
label: 'GEO imager',
narrowestSwathKm: 1000,
swathConfirmed: true,
}),
});
test('looking past the limb shows no sensor swath; a GEO view draws no strip', async (t) => {
const { cockpit, store } = await orbitAt(t, { satellite: GEO_IMAGER });
assert.ok(store.orbital.sensor, 'the sensor reads at nadir');
assert.equal(
store.orbital.sensor.stripFraction,
null,
'no ground track, no strip',
);
for (let step = 0; step < 3; step += 1) press(cockpit, 'ArrowRight');
await settle();
assert.equal(store.orbital.look.swathKm, null);
assert.equal(store.orbital.sensor, null, 'nothing below to sense');
});
test('a SAR swath reads but draws no strip under nadir', async (t) => {
const { store } = await orbitAt(t, {
satellite: {
...WORLDVIEW,
sensor: { ...WORLDVIEW.sensor, type: 'SAR', label: 'SAR' },
},
});
assert.ok(store.orbital.sensor.swathKm > 0);
assert.equal(store.orbital.sensor.stripFraction, null);
});
test('exit cancels pending pre-loads, and a failed one is asked again', async (t) => {
const { cockpit, viewer, store, clock } = await orbitAt(t, {
satellite: WORLDVIEW_ON_TRACK,
});
const asked = [];
let fail = true;
const pending = [];
imageryGlobe(viewer, {
requestImage: (x, y, level, request) => {
asked.push(`${level}/${x}/${y}`);
if (fail) return Promise.reject(new Error('dropped'));
pending.push(request);
return new Promise(() => {});
},
});
for (let i = 0; i < 4; i += 1) press(cockpit, '+');
assert.equal(store.orbital.zoom, 16);
clock.advance(ORBITAL_PREFETCH_INTERVAL_MS);
cockpit.update();
await settle();
const firstAsk = asked.slice();
assert.ok(firstAsk.length > 0);
fail = false;
clock.advance(ORBITAL_PREFETCH_INTERVAL_MS);
cockpit.update();
assert.deepEqual(asked.slice(firstAsk.length), firstAsk, 'asked again');
assert.ok(pending.length > 0);
cockpit.exit();
assert.ok(
pending.every((request) => request.cancelled === true),
'cancelled on exit',
);
});
test('the pre-load waits while the world clock is away from now', async (t) => {
const { cockpit, viewer, store, clock } = await orbitAt(t, {
satellite: { ...WORLDVIEW_ON_TRACK, atMs: START_MS - 3_600_000 },
});
const { requests } = imageryGlobe(viewer);
for (let i = 0; i < 4; i += 1) press(cockpit, '+');
assert.equal(store.orbital.zoom, 16);
clock.advance(ORBITAL_PREFETCH_INTERVAL_MS);
cockpit.update();
assert.deepEqual(requests, []);
});