From b953c3f1dcae7f838432abfedde8672c4797ff76 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 8 Nov 2026 16:35:23 -0800 Subject: [PATCH] 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 --- docs/CURRENT-STATE.md | 24 ++++-- scripts/format-scope.json | 3 + src/cockpitOrbital.js | 11 ++- src/cockpitOrbital.test.mjs | 16 ++-- src/layers/satellites/cockpitSubject.js | 7 +- src/layers/satellites/tracking.js | 12 +-- src/ui/cockpitOrbitalView.js | 64 ++++++++++++--- src/ui/cockpitOrbitalView.test.mjs | 104 +++++++++++++++++++++++- 8 files changed, 207 insertions(+), 34 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index edc345953..b5be93f1c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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, diff --git a/scripts/format-scope.json b/scripts/format-scope.json index f9cb045ac..0451fb3fa 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/src/cockpitOrbital.js b/src/cockpitOrbital.js index cd3db0418..3c9a8233c 100644 --- a/src/cockpitOrbital.js +++ b/src/cockpitOrbital.js @@ -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; diff --git a/src/cockpitOrbital.test.mjs b/src/cockpitOrbital.test.mjs index c40b9a342..d3c76b164 100644 --- a/src/cockpitOrbital.test.mjs +++ b/src/cockpitOrbital.test.mjs @@ -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) }), [], diff --git a/src/layers/satellites/cockpitSubject.js b/src/layers/satellites/cockpitSubject.js index aadd81aac..9e8bad9eb 100644 --- a/src/layers/satellites/cockpitSubject.js +++ b/src/layers/satellites/cockpitSubject.js @@ -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 diff --git a/src/layers/satellites/tracking.js b/src/layers/satellites/tracking.js index 0431e0c7d..8cfaf3041 100644 --- a/src/layers/satellites/tracking.js +++ b/src/layers/satellites/tracking.js @@ -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, diff --git a/src/ui/cockpitOrbitalView.js b/src/ui/cockpitOrbitalView.js index a536f8cb8..c54231b64 100644 --- a/src/ui/cockpitOrbitalView.js +++ b/src/ui/cockpitOrbitalView.js @@ -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, diff --git a/src/ui/cockpitOrbitalView.test.mjs b/src/ui/cockpitOrbitalView.test.mjs index 2b236c659..67d1656fd 100644 --- a/src/ui/cockpitOrbitalView.test.mjs +++ b/src/ui/cockpitOrbitalView.test.mjs @@ -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, []); +});