diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b5be93f1c..85bb0badf 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1678,9 +1678,9 @@ 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 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 +scheduler is full (a declined or dropped tile is asked for again later, one that failed to load is not); it remembers the last 512 tiles asked for and does not ask +for those again, starts over on another base map (cancelling the old map's +pending requests), 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 diff --git a/src/cockpitOrbital.js b/src/cockpitOrbital.js index 3c9a8233c..7f13dc497 100644 --- a/src/cockpitOrbital.js +++ b/src/cockpitOrbital.js @@ -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; diff --git a/src/cockpitOrbital.test.mjs b/src/cockpitOrbital.test.mjs index d3c76b164..a5db3b8c4 100644 --- a/src/cockpitOrbital.test.mjs +++ b/src/cockpitOrbital.test.mjs @@ -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)); +}); diff --git a/src/ui/cockpitOrbitalView.js b/src/ui/cockpitOrbitalView.js index c54231b64..2482920f0 100644 --- a/src/ui/cockpitOrbitalView.js +++ b/src/ui/cockpitOrbitalView.js @@ -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 || '', diff --git a/src/ui/cockpitOrbitalView.test.mjs b/src/ui/cockpitOrbitalView.test.mjs index 67d1656fd..36f850240 100644 --- a/src/ui/cockpitOrbitalView.test.mjs +++ b/src/ui/cockpitOrbitalView.test.mjs @@ -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'); +});