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

+16 -8
View File
@@ -1656,12 +1656,15 @@ is confirmed also gets 16×, 32×, 64× and 128×, each only while the view at
the satellite's live altitude and the window's shape is still at least as
wide as that swath (`orbitalMaxZoom` in `src/cockpitOrbital.js`); when the
cap falls below the zoom shown, the view steps back to it. Unconfirmed
swaths add no steps. The sensor's swath is drawn to scale as a strip along
the ground track with a SENSOR SWATH readout, and a SENSOR button zooms
to exactly that width when it lies between 1× and the cap. Tilted off nadir
swaths add no steps, but are still drawn, read out and offered to SENSOR.
The sensor's swath is drawn to scale as a strip along the ground track with
a SENSOR SWATH readout, and a SENSOR button zooms to exactly that width when
it lies between 1× and the cap. No strip is drawn for a geostationary view
(no ground track; its sensors image areas, not strips) or a SAR (it images
to one side of nadir, not under it); the readout stays. Tilted off nadir
the strip stays (view and swath widen alike), the readout scales as the swath
readout does (`SENSOR SWATH ≈ 191 KM`), and SENSOR, a nadir framing, is
hidden. A swath is confirmed only on the operator's, an agency's (ESA's
hidden; tilted past the Earth's limb neither the sensor nor the swath reads. A swath is confirmed only on the operator's, an agency's (ESA's
eoPortal included) or an academic page for its platform. From
16×, with flat imagery on the globe (not Google 3D tiles) and the view at
nadir, every 2 s the view asks the base imagery layer for the tiles it will
@@ -1675,9 +1678,13 @@ error, about one to two screen pixels per texel). It asks for at most 12
tiles at a time through Cesium's request scheduler, throttled per server and
at the lowest priority, so a request is declined while the imagery server's
slots are taken by the tiles on screen and is the first dropped when the
scheduler is full (a declined tile is asked for again later); it remembers the last 512 tiles asked for and does not ask
for those again. While tilted off nadir the view looks away from the track,
so nothing is pre-loaded. The arrows tilt the view off nadir in 5° steps
scheduler is full (a declined or failed tile is asked for again later); it remembers the last 512 tiles asked for and does not ask
for those again, starts over on another base map, and cancels the requests
still pending on exit. Across the track it samples the centre line, a third
out and the frame's edges. While tilted off nadir the view looks away from
the track, and with the world clock more than a minute from now (paused or
played back) it will not pass where the look-ahead points, so nothing is
pre-loaded. The arrows tilt the view off nadir in 5° steps
along the frame's axes (Up/Down tilt forward/aft along the ground track,
Left/Right across it, each axis to ±45°; Shift takes no larger step); Home,
`0` or NADIR return to nadir. While tilted the panel shows the total angle
@@ -1714,7 +1721,8 @@ again. The readout card puts
the owner country's flag (an SVG from `flag-icons`, fetched only for the shown
country) or a text badge for an organisation before the name, the operator
under it, and a NORAD · COSPAR · TLE-epoch line, ending with the element
set's age (`<1 H OLD`, then whole hours, then whole days), whose TLE button
set's age (`<1 H OLD`, then whole hours, then whole days: the gap, either
side, between the epoch and the world-clock time the satellite is shown at), whose TLE button
unfolds the two element lines; each row is left out when the satellites layer does not
know it. The
aircraft and bridge instruments, tapes, compass, radar, look controls,
+3
View File
@@ -331,6 +331,9 @@
"src/ui/styles/cockpitBridge.css",
"src/ui/styles/cockpitRadar.css",
"src/ui/styles/cockpitOrbital.css",
"src/layers/satellites/sensors.js",
"src/layers/satellites/sensors.test.mjs",
"src/data/imagingSensors.js",
"src/ui/styles/command-dock-compact.css",
"src/app/stateChannel.js",
"src/app/stateChannel.test.mjs",
+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, []);
});