fix(cockpit): the pre-load serves every lead's centre line before the frame edges, starts a fresh memory and cancels the old map's requests on a new base map, and retries only what the scheduler dropped; the sensor waits for the eased view to reach the Earth

This commit is contained in:
Sameh Khamis committed 2026-11-08 16:43:59 -08:00
1 parent b953c3f1dc
commit 4396ba4506
5 files changed
+119 -18

No files matched your search

+19
View File
@@ -596,6 +596,25 @@ export const ORBITAL_PREFETCH_ACROSS = Object.freeze([
1 / 2,
]);
/**
* The points ahead, regrouped by their place across the track: every lead's
* centre point first, then each lead's next pair out, so the tile cap trims
* the frame's edges before it trims the far leads.
* @param {Array<{lat: number, lon: number}>} points From `groundTrackAhead`,
* lead by lead.
* @param {number} [across] Points per lead.
* @returns {Array<{lat: number, lon: number}>}
*/
export function centreLinesFirst(
points,
across = ORBITAL_PREFETCH_ACROSS.length,
) {
const out = [];
for (let k = 0; k < across; k += 1)
for (let i = k; i < points.length; i += across) out.push(points[i]);
return out;
}
/** At most this many tile requests per look-ahead. */
export const ORBITAL_PREFETCH_MAX_TILES = 12;
+24
View File
@@ -15,6 +15,7 @@ import {
nextOrbitalZoom,
offNadirAngleDeg,
offNadirCentreMeetsEarth,
centreLinesFirst,
offNadirSwathScale,
orbitalMaxZoom,
sensorStripFraction,
@@ -592,3 +593,26 @@ test('a view tilted past the limb no longer meets the Earth', () => {
assert.equal(offNadirCentreMeetsEarth(420_000, { eastDeg: 80 }), false);
assert.equal(offNadirCentreMeetsEarth(NaN, {}), false);
});
test('the centre line of every lead comes before the frame edges', () => {
const points = groundTrackAhead(PASS, 20);
const across = ORBITAL_PREFETCH_ACROSS.length;
const ordered = centreLinesFirst(points);
assert.equal(ordered.length, points.length);
const leads = ORBITAL_PREFETCH_LEAD_S.length;
assert.deepEqual(
ordered.slice(0, leads),
points.filter((_, i) => i % across === 0),
);
// The 12-tile cap at a level of small tiles still reaches the far lead.
const mercator = new Cesium.WebMercatorTilingScheme();
const tiles = tilesForPoints(mercator, ordered, 17);
const far = mercator.positionToTileXY(
Cesium.Cartographic.fromDegrees(
ordered[leads - 1].lon,
ordered[leads - 1].lat,
),
17,
);
assert.ok(tiles.some((tile) => tile.x === far.x && tile.y === far.y));
});
+22 -13
View File
@@ -4,6 +4,7 @@ import { formatNumber, msg, t } from '../i18n/index.js';
import {
ORBITAL_DEFAULT_ZOOM,
ORBITAL_PREFETCH_INTERVAL_MS,
centreLinesFirst,
ORBITAL_PREFETCH_MAX_TILES,
ORBITAL_PREFETCH_MIN_ZOOM,
ORBITAL_SETTLE_BLUR_PX,
@@ -404,8 +405,12 @@ export function prefetchImageryAhead(info, nowMs) {
const provider = baseImageryProvider(this.viewer);
if (!provider) return 0;
// Another base map: its tiles have not been asked for.
// A fresh set, so a late failure from the old map's requests cannot
// drop a tile the new one has fetched.
if (provider !== state.prefetchProvider) {
state.prefetched.clear();
for (const request of state.prefetchRequests) request.cancel?.();
state.prefetchRequests = new Set();
state.prefetched = new Set();
state.prefetchProvider = provider;
}
const fov = fovForZoom(orbitalBaseFovRad(info.altitudeM), state.zoom);
@@ -440,7 +445,7 @@ export function prefetchImageryAhead(info, nowMs) {
const memory = state.prefetched;
const tiles = tilesForPoints(
provider.tilingScheme,
groundTrackAhead(info, viewKm),
centreLinesFirst(groundTrackAhead(info, viewKm)),
level,
{
maxTiles: ORBITAL_PREFETCH_MAX_TILES,
@@ -477,10 +482,12 @@ export function prefetchImageryAhead(info, nowMs) {
requests.add(request);
Promise.resolve(pending).then(
() => requests.delete(request),
() => {
(error) => {
requests.delete(request);
// Failed or dropped by the scheduler: asked again later.
memory.delete(tile.key);
// Dropped by the scheduler (rejected with no error): asked again
// later. A tile that failed to load stays remembered, so one
// missing from the map is not asked for every look-ahead.
if (error === undefined) memory.delete(tile.key);
},
);
memory.add(tile.key);
@@ -612,16 +619,18 @@ export function publishOrbitalView(info, transition = IDLE_TRANSITION) {
// republishes on its cadence, so the swath appears once the view arrives.
// Exact angles here, not the whole degrees the readout rounds to.
const target = clampOrbitalLook(state.lookTarget);
// The eased view is tested even with the target at nadir: on the way back
// from past the limb it still looks into space.
const seesEarth =
!offset ||
(offNadirCentreMeetsEarth(info.altitudeM, {
northDeg: target.pitchDeg,
eastDeg: target.yawDeg,
}) &&
(!offset ||
offNadirCentreMeetsEarth(info.altitudeM, {
northDeg: state.lookView?.pitchDeg ?? 0,
eastDeg: state.lookView?.yawDeg ?? 0,
}));
northDeg: target.pitchDeg,
eastDeg: target.yawDeg,
})) &&
offNadirCentreMeetsEarth(info.altitudeM, {
northDeg: state.lookView?.pitchDeg ?? 0,
eastDeg: state.lookView?.yawDeg ?? 0,
});
this.publishOrbital({
name: info.name,
classLabel: info.classLabel || '',
+51 -2
View File
@@ -1510,7 +1510,7 @@ test('a SAR swath reads but draws no strip under nadir', async (t) => {
assert.equal(store.orbital.sensor.stripFraction, null);
});
test('exit cancels pending pre-loads, and a failed one is asked again', async (t) => {
test('exit cancels pending pre-loads, and a dropped one is asked again', async (t) => {
const { cockpit, viewer, store, clock } = await orbitAt(t, {
satellite: WORLDVIEW_ON_TRACK,
});
@@ -1520,7 +1520,8 @@ test('exit cancels pending pre-loads, and a failed one is asked again', async (t
imageryGlobe(viewer, {
requestImage: (x, y, level, request) => {
asked.push(`${level}/${x}/${y}`);
if (fail) return Promise.reject(new Error('dropped'));
// Cesium's scheduler drops a request by rejecting with no error.
if (fail) return Promise.reject(undefined);
pending.push(request);
return new Promise(() => {});
},
@@ -1555,3 +1556,51 @@ test('the pre-load waits while the world clock is away from now', async (t) => {
cockpit.update();
assert.deepEqual(requests, []);
});
test('a tile that fails to load is not asked again', async (t) => {
const { cockpit, viewer, store, clock } = await orbitAt(t, {
satellite: WORLDVIEW_ON_TRACK,
});
const asked = [];
imageryGlobe(viewer, {
requestImage: (x, y, level) => {
asked.push(`${level}/${x}/${y}`);
return Promise.reject(new Error('404'));
},
});
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 first = asked.length;
assert.ok(first > 0);
clock.advance(ORBITAL_PREFETCH_INTERVAL_MS);
cockpit.update();
assert.equal(new Set(asked).size, asked.length, 'none asked twice');
});
test('back to nadir from past the limb, the sensor waits for the view', async (t) => {
const { cockpit, store, clock } = await orbitAt(t, {
satellite: GEO_IMAGER,
reducedMotion: false,
});
for (let step = 0; step < 3; step += 1) press(cockpit, 'ArrowRight');
for (let i = 0; i < 30; i += 1) {
clock.advance(100);
cockpit.update();
}
await settle();
assert.equal(store.orbital.sensor, null, 'looking into space');
press(cockpit, 'Home');
clock.advance(16);
cockpit.update();
await settle();
assert.equal(store.orbital.sensor, null, 'still easing back');
for (let i = 0; i < 30; i += 1) {
clock.advance(100);
cockpit.update();
}
await settle();
assert.ok(store.orbital.sensor, 'back on the Earth');
});