mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 04:44:01 +00:00
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:
1 parent
9a16fb8cbc
commit
b953c3f1dc
8 files changed
+207
-34
No files matched your search
+16
-8
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) }),
|
||||
[],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
Reference in new issue
Block a user