diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 34cbdecff..6d088b232 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1658,14 +1658,16 @@ 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, 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 +The sensor's swath is drawn to scale as a strip along the ground track (its +width follows the view through a zoom step, not ahead of it) 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; 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 +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 diff --git a/src/tooling/packageBoundaries.test.mjs b/src/tooling/packageBoundaries.test.mjs index 548cadbf4..517bad64e 100644 --- a/src/tooling/packageBoundaries.test.mjs +++ b/src/tooling/packageBoundaries.test.mjs @@ -126,3 +126,49 @@ test('a lifecycle owner cannot acquire a layer panel even through an unused impo ); await assert.rejects(checkPackageBoundaries(root), /unowned module.*panel/); }); + +test('a declared dependency owns the asset files it ships, and only those', async (t) => { + const asset = (pkg) => + `import url from './node_modules/${pkg}/flags/aa.svg?url&no-inline'; export const value = url;`; + const root = await fixture(t, asset('flags')); + for (const pkg of ['flags', 'flags-extra', 'stray']) { + await mkdir(path.join(root, 'node_modules', pkg, 'flags'), { + recursive: true, + }); + await writeFile( + path.join(root, 'node_modules', pkg, 'flags/aa.svg'), + '', + ); + } + await writeFile( + path.join(root, 'package.json'), + JSON.stringify({ + name: 'boundary-fixture', + type: 'module', + exports: { './feature': './entry.js' }, + dependencies: { flags: '1.0.0' }, + }), + ); + await writeFile( + path.join(root, 'scripts/package-boundaries.json'), + JSON.stringify({ + feature: { + exports: ['./feature'], + modules: ['entry.js'], + external: ['flags'], + }, + }), + ); + // The entry and the flag it ships. + assert.deepEqual(await checkPackageBoundaries(root), [ + { name: 'feature', exports: 1, modules: 2 }, + ]); + // An undeclared package's file, and one whose name only starts the same. + for (const pkg of ['stray', 'flags-extra']) { + await writeFile(path.join(root, 'entry.js'), asset(pkg)); + await assert.rejects( + checkPackageBoundaries(root), + new RegExp(`unowned module.*node_modules/${pkg}/`), + ); + } +}); diff --git a/src/ui/cockpitOrbitalView.js b/src/ui/cockpitOrbitalView.js index 8e6f20b06..5d4a50fd8 100644 --- a/src/ui/cockpitOrbitalView.js +++ b/src/ui/cockpitOrbitalView.js @@ -631,6 +631,11 @@ export function publishOrbitalView(info, transition = IDLE_TRANSITION) { : canvasAspect(this.viewer); const settledFov = fovForZoom(orbitalBaseFovRad(info.altitudeM), state.zoom); const footprintKm = footprintWidthKm(info.altitudeM, settledFov, aspect); + // The strip is drawn over the picture, so it follows the view as a step + // zooms (republished every frame then), not the step it is heading for. + const shownKm = Number.isFinite(state.verticalFov) + ? footprintWidthKm(info.altitudeM, state.verticalFov, aspect) + : footprintKm; const maxZoom = orbitalMaxZoomFor.call(this, info); const swathKm = sensorSwathKm(info); const trackUp = Number.isFinite(info.groundTrackDeg); @@ -687,7 +692,7 @@ export function publishOrbitalView(info, transition = IDLE_TRANSITION) { // along, and a SAR images to one side of nadir, not under it. stripFraction: trackUp && info.sensor?.type !== 'SAR' - ? sensorStripFraction(swathKm, footprintKm) + ? sensorStripFraction(swathKm, shownKm) : null, zoom: sensorSwathZoom(info.altitudeM, swathKm, aspect, maxZoom), }, diff --git a/src/ui/cockpitOrbitalView.test.mjs b/src/ui/cockpitOrbitalView.test.mjs index 36f850240..fe3b1dd90 100644 --- a/src/ui/cockpitOrbitalView.test.mjs +++ b/src/ui/cockpitOrbitalView.test.mjs @@ -26,7 +26,6 @@ import { } from '../cockpitOrbital.js'; import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js'; import { prefetchImageryAhead } from './cockpitOrbitalView.js'; -import { imageryLevelForView } from '../cockpitOrbital.js'; import { claimPointer, releasePointer } from '../data/inputOwnership.js'; const ISS = Object.freeze({ @@ -1161,6 +1160,27 @@ function imageryGlobe(viewer, { requestImage } = {}) { const WORLDVIEW_ON_TRACK = Object.freeze({ ...WORLDVIEW, groundTrackDeg: 52 }); +test('the sensor strip follows the view through a zoom step, not ahead of it', async (t) => { + const { cockpit, store, clock } = await orbitAt(t, { + satellite: WORLDVIEW, + reducedMotion: false, + }); + const strip = () => store.orbital.sensor.stripFraction; + const atOne = strip(); + assert.ok(atOne > 0 && atOne < 1); + press(cockpit, '+'); + cockpit.update(); + near(strip(), atOne, 'the step starts at 1x, and so does the strip'); + clock.advance(ORBITAL_ZOOM_TRANSITION_MS / 2); + cockpit.update(); + const midway = strip(); + clock.advance(ORBITAL_ZOOM_TRANSITION_MS + 1000); + cockpit.update(); + const atTwo = strip(); + assert.ok(atTwo > atOne * 1.5, 'twice the zoom, about twice the share'); + assert.ok(midway > atOne && midway < atTwo, 'part-way through the step'); +}); + test('from 16x the imagery ahead of the ground track is pre-loaded', async (t) => { const { cockpit, viewer, store, clock } = await orbitAt(t, { satellite: WORLDVIEW_ON_TRACK, @@ -1444,16 +1464,13 @@ test('the pre-load is throttled, at the level Cesium draws, and waits out a tilt ); assert.equal(request.type, Cesium.RequestType.IMAGERY); } - // The level Cesium's own selection takes for this view. - const fov = fovForZoom(orbitalBaseFovRad(WORLDVIEW.altitudeM), 16); - const metresPerPixel = - (WORLDVIEW.altitudeM * 2 * Math.tan(fov / 2)) / - viewer.scene.canvas.clientHeight; - const expected = imageryLevelForView(new Cesium.WebMercatorTilingScheme(), { - metresPerPixel, - latitudeDeg: WORLDVIEW.latitude, - }); - assert.equal(Number(requests[0].split('/')[0]), expected); + // The level Cesium's own selection takes for this view, worked by hand: + // at 16x from 617 km the 900 px canvas is ~49.5 m a pixel. The ellipsoid + // terrain's level-zero error is 6378137 · 2π / 4 / 130 ≈ 77,067 m; it first + // meets Cesium's screen-space error of 2 px (≤ 99 m) at level 10, 75.3 m. + // A Web Mercator level-zero texel at 30° is 6378137 · 2π · cos 30° / 256 + // ≈ 135,566 m, so the imagery level is round(log2(135,566 / 75.3)) = 11. + assert.equal(Number(requests[0].split('/')[0]), 11); }); test('the TLE age is measured from the time the satellite is shown at', async (t) => {