diff --git a/scripts/build-owner-flags.mjs b/scripts/build-owner-flags.mjs index 99b7a7545..9ca7ed083 100644 --- a/scripts/build-owner-flags.mjs +++ b/scripts/build-owner-flags.mjs @@ -9,7 +9,7 @@ * src/ui/ownerFlagSheet.js its layout (generated) * * Flag i (in the sorted code list) sits at column i % COLUMNS, row - * floor(i / COLUMNS). A rerun with the same flag-icons and sharp versions + * floor(i / COLUMNS), each cell followed by a GUTTER of transparent pixels. A rerun with the same flag-icons and sharp versions * reproduces both files. * * Usage: node scripts/build-owner-flags.mjs @@ -33,6 +33,11 @@ const FLAG_ICONS_VERSION = require('flag-icons/package.json').version; /** One flag's cell, CSS pixels × 2. */ export const CELL = Object.freeze({ width: 40, height: 30 }); +/** + * Transparent sheet pixels after each flag, so a browser smoothing a scaled + * flag (a 125% display) never reaches into its neighbour. + */ +export const GUTTER = 2; export const COLUMNS = 16; const SHEET = path.join( @@ -51,11 +56,13 @@ async function flagCell(code) { const codes = [...OWNER_COUNTRY_CODES]; const rows = Math.ceil(codes.length / COLUMNS); +const pitchX = CELL.width + GUTTER; +const pitchY = CELL.height + GUTTER; const cells = await Promise.all(codes.map(flagCell)); const sheet = await sharp({ create: { - width: COLUMNS * CELL.width, - height: rows * CELL.height, + width: COLUMNS * pitchX, + height: rows * pitchY, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 }, }, @@ -63,8 +70,8 @@ const sheet = await sharp({ .composite( cells.map((input, i) => ({ input, - left: (i % COLUMNS) * CELL.width, - top: Math.floor(i / COLUMNS) * CELL.height, + left: (i % COLUMNS) * pitchX, + top: Math.floor(i / COLUMNS) * pitchY, })), ) .png({ compressionLevel: 9, palette: true, quality: 100, effort: 10 }) @@ -76,6 +83,7 @@ const layout = `// Generated by scripts/build-owner-flags.mjs from flag-icons ${ export const OWNER_FLAG_SHEET = Object.freeze({ cellWidthPx: ${CELL.width}, cellHeightPx: ${CELL.height}, + gutterPx: ${GUTTER}, columns: ${COLUMNS}, rows: ${rows}, codes: Object.freeze(${JSON.stringify(codes)}), diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 887c72eb7..7b09e8077 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1366,5 +1366,7 @@ "server/providers/inaturalist/proxy.fixture.mjs", "scripts/build-owner-flags.mjs", "src/ui/ownerFlagSheet.js", - "src/data/placePacksLoading.test.mjs" + "src/data/placePacksLoading.test.mjs", + "src/ui/placePacksLoading.js", + "src/ui/placePacksLoading.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 997191dbb..f7fd906a7 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1069,6 +1069,7 @@ "src/cockpitOrbitalSatellites.js", "src/layers/installations/classLabel.js", "src/data/placeBelow.js", + "src/ui/placePacksLoading.js", "src/ui/ownerFlag.js", "src/ui/ownerFlagSheet.js", "src/layers/satellites/sensors.js", @@ -2916,6 +2917,7 @@ "src/ui/cockpitOrbitalSatellites.js", "src/cockpitOrbitalSatellites.js", "src/data/placeBelow.js", + "src/ui/placePacksLoading.js", "src/ui/ownerFlag.js", "src/ui/ownerFlagSheet.js", "src/ui/cockpitContext.js", diff --git a/src/data/local_data/owner_flags/README.md b/src/data/local_data/owner_flags/README.md index 60a02a919..ed0dc071a 100644 --- a/src/data/local_data/owner_flags/README.md +++ b/src/data/local_data/owner_flags/README.md @@ -3,7 +3,8 @@ `owner_flags.png` is one sheet of every owner country a satellite can be marked with (`OWNER_COUNTRY_CODES` in `src/layers/satellites/classes.js`), drawn at 40 × 30 px (twice the orbital readout's 20 × 15) in sorted code -order, 16 to a row. `src/ui/ownerFlag.js` loads it once at startup and draws +order, 16 to a row, each followed by 2 transparent pixels so a scaled flag +never smooths into its neighbour. `src/ui/ownerFlag.js` loads it once at startup and draws each flag from its cell; the layout is `src/ui/ownerFlagSheet.js`. **Source:** the 4:3 SVGs of [flag-icons](https://github.com/lipis/flag-icons) diff --git a/src/data/local_data/owner_flags/owner_flags.png b/src/data/local_data/owner_flags/owner_flags.png index fd1826a0e..99e3d07b0 100644 Binary files a/src/data/local_data/owner_flags/owner_flags.png and b/src/data/local_data/owner_flags/owner_flags.png differ diff --git a/src/data/placeBelow.js b/src/data/placeBelow.js index 67e120fa9..b5bdff4f2 100644 --- a/src/data/placeBelow.js +++ b/src/data/placeBelow.js @@ -11,7 +11,7 @@ * PURE data module: no Cesium, node-testable. Each pack loads lazily on the * first lookup that needs it (divisions on the first land hit, places on the * first radius) and is indexed once, and the loading status names them while - * one is on its way (`placePacksLoading`); a country's division rings are decoded + * one is on its way (`placePacksStatus`); a country's division rings are decoded * the first time a point falls inside it. A failed load is retried later * (see `createRetryableLoader`). No network. */ @@ -47,8 +47,9 @@ const PACKS = { ), }; -/** The packs on their way now, for the loading status. */ +/** The packs on their way now, and those whose last load failed. */ const loadingPacks = new Set(); +const failedPacks = new Set(); const loadingListeners = new Set(); function notifyLoading() { @@ -61,13 +62,25 @@ function notifyLoading() { } } -/** A pack load that the loading status counts while it runs. */ -function reportedLoad(name, load) { +/** + * A pack load that the loading status follows: on its way while it runs, + * failed until a later load of it succeeds. + * @template T + * @param {string} name + * @param {() => Promise} load + * @returns {() => Promise} + */ +export function reportedPlacePackLoad(name, load) { return async () => { loadingPacks.add(name); notifyLoading(); try { - return await load(); + const result = await load(); + failedPacks.delete(name); + return result; + } catch (error) { + failedPacks.add(name); + throw error; } finally { loadingPacks.delete(name); notifyLoading(); @@ -76,12 +89,13 @@ function reportedLoad(name, load) { } /** - * Whether a place pack is loading now. The packs load lazily, on the first - * lookup that needs them, so the loading status says so meanwhile. - * @returns {boolean} + * The place packs' state for the loading status. The packs load lazily, on + * the first lookup that needs them, so the status says so meanwhile, and + * says when one would not load. + * @returns {{loading: boolean, failed: boolean}} */ -export function placePacksLoading() { - return loadingPacks.size > 0; +export function placePacksStatus() { + return { loading: loadingPacks.size > 0, failed: failedPacks.size > 0 }; } /** @@ -191,7 +205,9 @@ async function readPlaces() { } return { land, water }; } -const loadPlaces = createRetryableLoader(reportedLoad('places', readPlaces)); +const loadPlaces = createRetryableLoader( + reportedPlacePackLoad('places', readPlaces), +); /** Divisions grouped by country code; rings decoded per country on demand. */ async function readDivisions() { @@ -208,7 +224,7 @@ async function readDivisions() { return { byCountry, decoded: new Map() }; } const loadDivisions = createRetryableLoader( - reportedLoad('divisions', readDivisions), + reportedPlacePackLoad('divisions', readDivisions), ); /** The first-level division holding a point inside country `code`, or null. */ @@ -243,7 +259,7 @@ async function readPopulatedPlaces() { ); } const loadPopulatedPlaces = createRetryableLoader( - reportedLoad('populated-places', readPopulatedPlaces), + reportedPlacePackLoad('populated-places', readPopulatedPlaces), ); /** diff --git a/src/data/placePacksLoading.test.mjs b/src/data/placePacksLoading.test.mjs index ff1837a3d..2d95afd49 100644 --- a/src/data/placePacksLoading.test.mjs +++ b/src/data/placePacksLoading.test.mjs @@ -8,16 +8,30 @@ test('a place pack load is reported from its start to its end', async () => { const place = await import('./placeBelow.js?loading-status'); const heard = []; const stop = place.onPlacePacksLoadingChange(() => - heard.push(place.placePacksLoading()), + heard.push(place.placePacksStatus().loading), ); - assert.equal(place.placePacksLoading(), false); + assert.equal(place.placePacksStatus().loading, false); const lookup = place.nearPlace(48.86, 2.35, 50); - assert.equal(place.placePacksLoading(), true, 'on its way'); + assert.equal(place.placePacksStatus().loading, true, 'on its way'); assert.equal(await lookup, 'Paris'); - assert.equal(place.placePacksLoading(), false, 'loaded'); + assert.equal(place.placePacksStatus().loading, false, 'loaded'); assert.deepEqual(heard, [true, false]); // Loaded once: a later lookup is not reported again. await place.nearPlace(48.86, 2.35, 50); assert.deepEqual(heard, [true, false]); stop(); }); + +test('a pack that would not load stays failed until it loads', async () => { + const place = await import('./placeBelow.js?failed-status'); + let fail = true; + const load = place.reportedPlacePackLoad('test', async () => { + if (fail) throw new Error('offline'); + return 1; + }); + await assert.rejects(load(), /offline/); + assert.deepEqual(place.placePacksStatus(), { loading: false, failed: true }); + fail = false; + assert.equal(await load(), 1); + assert.deepEqual(place.placePacksStatus(), { loading: false, failed: false }); +}); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 4cf9643d7..7bffa4d4d 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -64,26 +64,10 @@ import { useLocationStore } from './stores/location.js'; import { useCockpitStore } from './stores/cockpit.js'; import { useGlobeActionsStore } from './stores/globeActions.js'; import { useRecordingStore } from './stores/recording.js'; -import { - onPlacePacksLoadingChange, - placePacksLoading, -} from '../data/placeBelow.js'; -import { LOADING_NAME_KEYS } from './layerNames.js'; +import { onPlacePacksLoadingChange } from '../data/placeBelow.js'; +import { withPlacePacks } from './placePacksLoading.js'; import { preloadOwnerFlags } from './ownerFlag.js'; -/** The place packs, as one more loading entry while they are on their way. */ -const PLACE_PACKS_LOADING = Object.freeze({ - id: 'place-names', - name: LOADING_NAME_KEYS['place-names'], - enabled: true, - stats: Object.freeze({ loading: true }), -}); - -/** The data layers, with the place packs while they load. */ -function withPlacePacks(layers) { - return placePacksLoading() ? [...layers, PLACE_PACKS_LOADING] : layers; -} - /** * Central UI orchestrator for the God's Eye View application. * diff --git a/src/ui/cockpitOrbital.test.mjs b/src/ui/cockpitOrbital.test.mjs index 5de131693..987527c28 100644 --- a/src/ui/cockpitOrbital.test.mjs +++ b/src/ui/cockpitOrbital.test.mjs @@ -222,14 +222,20 @@ test('an owner country shows its flag from the sheet, or its code if the sheet w await settle(); // No sheet: the code, so the owner never disappears. assert.equal(f.text('.cockpit-orbital-owner-badge'), 'US'); + // Another satellite of the same country asks again. + f.store.publishOrbital({ ...f.store.orbital, noradId: 43013 }); + await nextTick(); + assert.equal(images.length, 2, 'a new ride asks again'); + images[1].onerror(); + await settle(); // The next flag asks again; this time the sheet comes. f.store.publishOrbital({ ...f.store.orbital, owner: { countryCode: 'FR', label: 'France' }, }); await nextTick(); - assert.equal(images.length, 2); - images[1].onload(); + assert.equal(images.length, 3); + images[2].onload(); await settle(); const flag = f.section().querySelector('.cockpit-orbital-flag'); assert.equal(flag.getAttribute('role'), 'img'); diff --git a/src/ui/ownerFlag.js b/src/ui/ownerFlag.js index 750b36e43..5020143c6 100644 --- a/src/ui/ownerFlag.js +++ b/src/ui/ownerFlag.js @@ -62,12 +62,18 @@ export function preloadOwnerFlags({ export function ownerFlagStyle(code, { widthPx = 20, heightPx = 15 } = {}) { const index = SHEET_INDEX.get(code); if (index === undefined) return null; - const column = index % OWNER_FLAG_SHEET.columns; - const row = Math.floor(index / OWNER_FLAG_SHEET.columns); + const sheet = OWNER_FLAG_SHEET; + const column = index % sheet.columns; + const row = Math.floor(index / sheet.columns); + // A cell and its gutter, in CSS pixels at this size. + const pitchX = + (widthPx * (sheet.cellWidthPx + sheet.gutterPx)) / sheet.cellWidthPx; + const pitchY = + (heightPx * (sheet.cellHeightPx + sheet.gutterPx)) / sheet.cellHeightPx; return { backgroundImage: `url("${OWNER_FLAG_SHEET_URL}")`, - backgroundSize: `${OWNER_FLAG_SHEET.columns * widthPx}px ${OWNER_FLAG_SHEET.rows * heightPx}px`, - backgroundPosition: `${-column * widthPx}px ${-row * heightPx}px`, + backgroundSize: `${sheet.columns * pitchX}px ${sheet.rows * pitchY}px`, + backgroundPosition: `${-column * pitchX}px ${-row * pitchY}px`, }; } diff --git a/src/ui/ownerFlag.test.mjs b/src/ui/ownerFlag.test.mjs index 737ab018d..d1d6bf455 100644 --- a/src/ui/ownerFlag.test.mjs +++ b/src/ui/ownerFlag.test.mjs @@ -69,28 +69,34 @@ test('the sheet holds every owner country a satellite can be marked with', () => ); // The PNG is as large as its layout says (width and height at bytes 16-23). const png = readFileSync(new URL(OWNER_FLAG_SHEET_URL)); + const { cellWidthPx, cellHeightPx, gutterPx } = OWNER_FLAG_SHEET; assert.equal( png.readUInt32BE(16), - OWNER_FLAG_SHEET.columns * OWNER_FLAG_SHEET.cellWidthPx, + OWNER_FLAG_SHEET.columns * (cellWidthPx + gutterPx), ); assert.equal( png.readUInt32BE(20), - OWNER_FLAG_SHEET.rows * OWNER_FLAG_SHEET.cellHeightPx, + OWNER_FLAG_SHEET.rows * (cellHeightPx + gutterPx), ); }); test('a flag is drawn from its cell of the sheet, at the size asked', () => { + // 40 × 30 sheet cells with a 2 px gutter, drawn at 20 × 15: each cell and + // its gutter is 21 × 16 CSS pixels. + assert.equal(OWNER_FLAG_SHEET.cellWidthPx, 40); + assert.equal(OWNER_FLAG_SHEET.cellHeightPx, 30); + assert.equal(OWNER_FLAG_SHEET.gutterPx, 2); const columns = OWNER_FLAG_SHEET.columns; const index = OWNER_FLAG_SHEET.codes.indexOf('US'); const style = ownerFlagStyle('US'); assert.equal(style.backgroundImage, `url("${OWNER_FLAG_SHEET_URL}")`); assert.equal( style.backgroundSize, - `${columns * 20}px ${OWNER_FLAG_SHEET.rows * 15}px`, + `${columns * 21}px ${OWNER_FLAG_SHEET.rows * 16}px`, ); assert.equal( style.backgroundPosition, - `${-(index % columns) * 20}px ${-Math.floor(index / columns) * 15}px`, + `${-(index % columns) * 21}px ${-Math.floor(index / columns) * 16}px`, ); // The first flag sits at the corner. assert.equal( diff --git a/src/ui/ownerFlagSheet.js b/src/ui/ownerFlagSheet.js index dec6ef8e6..723bf00de 100644 --- a/src/ui/ownerFlagSheet.js +++ b/src/ui/ownerFlagSheet.js @@ -3,6 +3,7 @@ export const OWNER_FLAG_SHEET = Object.freeze({ cellWidthPx: 40, cellHeightPx: 30, + gutterPx: 2, columns: 16, rows: 7, codes: Object.freeze([ diff --git a/src/ui/placePacksLoading.js b/src/ui/placePacksLoading.js new file mode 100644 index 000000000..f18d79769 --- /dev/null +++ b/src/ui/placePacksLoading.js @@ -0,0 +1,30 @@ +/** + * The place packs in the top-centre loading status: they load lazily, on the + * Cockpit's first place lookup (`src/data/placeBelow.js`), so while one is on + * its way the status names them beside the layers, and a pack that would not + * load makes the batch end LOAD FAILED. + */ + +import { placePacksStatus } from '../data/placeBelow.js'; +import { LOADING_NAME_KEYS } from './layerNames.js'; + +/** + * The data layers, with the place packs as one more entry while they load + * or after one would not load, so a batch they join ends LOAD FAILED rather + * than LOAD COMPLETE. + */ +export function withPlacePacks( + layers, + { loading, failed } = placePacksStatus(), +) { + if (!loading && !failed) return layers; + return [ + ...layers, + { + id: 'place-names', + name: LOADING_NAME_KEYS['place-names'], + enabled: true, + stats: { loading, error: failed ? 'Place names did not load' : null }, + }, + ]; +} diff --git a/src/ui/placePacksLoading.test.mjs b/src/ui/placePacksLoading.test.mjs new file mode 100644 index 000000000..09db21fc3 --- /dev/null +++ b/src/ui/placePacksLoading.test.mjs @@ -0,0 +1,59 @@ +// src/ui/placePacksLoading.test.mjs — the loading status names the place +// packs while they load, and a failed pack ends the batch as failed. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + aggregateLayerLoading, + createLoadingFeedbackState, + reduceLoadingFeedback, +} from '../loadingFeedback.js'; +import { withPlacePacks } from './placePacksLoading.js'; + +const SATELLITES = Object.freeze({ + id: 'satellites', + name: 'Satellites', + enabled: true, + stats: { count: 9000, lastUpdate: 1 }, +}); + +function batch(states) { + let state = createLoadingFeedbackState(); + let now = 0; + for (const status of states) { + now += 1000; + state = reduceLoadingFeedback( + state, + aggregateLayerLoading(withPlacePacks([SATELLITES], status)), + now, + ); + } + return state; +} + +test('nothing extra while the packs are idle', () => { + const layers = [SATELLITES]; + assert.equal( + withPlacePacks(layers, { loading: false, failed: false }), + layers, + ); +}); + +test('the packs are named while they load', () => { + const summary = aggregateLayerLoading( + withPlacePacks([SATELLITES], { loading: true, failed: false }), + ); + assert.deepEqual(summary.activeIds, ['place-names']); + assert.equal(summary.active[0].label, 'Place names'); +}); + +test('a load that lands ends complete; one that fails ends failed', () => { + const loading = { loading: true, failed: false }; + assert.equal( + batch([loading, { loading: false, failed: false }]).terminal, + 'complete', + ); + assert.equal( + batch([loading, { loading: false, failed: true }]).terminal, + 'error', + ); +}); diff --git a/src/ui/styles/cockpitOrbital.css b/src/ui/styles/cockpitOrbital.css index 6f0bd76f9..9e799f2be 100644 --- a/src/ui/styles/cockpitOrbital.css +++ b/src/ui/styles/cockpitOrbital.css @@ -200,6 +200,9 @@ border: 1px solid var(--orbital-faint); border-radius: 1px; background-repeat: no-repeat; + /* Only the flag's own cell: the sheet's neighbours stay out from under + the border. */ + background-clip: content-box; } .cockpit-orbital-owner-badge { flex: none; diff --git a/src/ui/views/panels/CockpitOrbital.vue b/src/ui/views/panels/CockpitOrbital.vue index 3e8083755..e43848d7e 100644 --- a/src/ui/views/panels/CockpitOrbital.vue +++ b/src/ui/views/panels/CockpitOrbital.vue @@ -195,9 +195,14 @@ const mark = computed(() => const flagsReady = ref(false); const flagsFailed = ref(false); watch( - () => (mark.value?.kind === 'flag' ? mark.value.code : null), - async (code) => { - if (!code || flagsReady.value) return; + // Keyed on the satellite too: after a failed load, the next ride asks + // again even when its owner is the same country. + () => + mark.value?.kind === 'flag' + ? `${mark.value.code}:${orbital.value?.noradId ?? ''}` + : null, + async (key) => { + if (!key || flagsReady.value) return; const ready = await preloadOwnerFlags(); flagsReady.value = ready; flagsFailed.value = !ready;