diff --git a/changelog.d/1637-orbital-cockpit-followups.md b/changelog.d/1637-orbital-cockpit-followups.md new file mode 100644 index 000000000..984e25d6a --- /dev/null +++ b/changelog.d/1637-orbital-cockpit-followups.md @@ -0,0 +1,4 @@ +- Keep the orbital cockpit's sensor strip steady through a zoom step, load the + satellite owner flags at startup from one generated 15 KB sheet, and name + "Place names" in the loading status while the place-name packs load on first + lookup (Sameh Khamis, @samehkhamis, #1637). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 34cbdecff..08dc69d1f 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 @@ -1784,15 +1786,19 @@ names it and the country in the reader's language through `Intl.DisplayNames`, "Sichuan, China", and from 8× the most prominent populated place within half the view's smaller side, so it is in the picture, "Sichuan, China · near Chengdu", from a bundled pack of Natural Earth populated places loaded on -first use; else the smallest named sea, gulf, strait, channel, sound or bay; +first use (each pack loads on the first lookup that needs it, and the +top-centre loading status lists "Place names" until it arrives); else the +smallest named sea, gulf, strait, channel, sound or bay; else the ocean; nothing in a coastline gap), at most every 2 s and only after 20 km of ground travel, or at once when a zoom step changes how much ground the view shows. A division or populated-places pack that fails to load drops only that part (the country still shows); a lookup that fails outright hides the band rather than leave the last place up, and the next HUD beat looks 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 +the owner country's flag (drawn from one 15 KB sheet of every owner +country's flag, built from `flag-icons` by `scripts/build-owner-flags.mjs` +and loaded at startup, so a ride never waits for a flag; if the sheet will +not load the country code shows as a badge) 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: the gap, either side, between the epoch and the world-clock time the satellite is shown at), whose TLE button diff --git a/package-lock.json b/package-lock.json index 87c2cb80b..9ab7b43f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,6 @@ "@meri-imperiumi/eccodes-wasm": "^2.48.2", "cesium": "^1.124.0", "egm96-universal": "^1.1.1", - "flag-icons": "7.5.0", "hls.js": "^1.7.3", "mapillary-js": "^4.1.2", "mgrs": "^2.1.0", @@ -29,6 +28,7 @@ "@happy-dom/global-registrator": "^20.14.6", "@vitejs/plugin-vue": "^5.2.4", "@vue/test-utils": "^2.5.1", + "flag-icons": "7.5.0", "happy-dom": "^20.14.6", "prettier": "3.9.6", "puppeteer": "^25.16.0", @@ -2416,6 +2416,7 @@ "version": "7.5.0", "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz", "integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==", + "dev": true, "license": "MIT" }, "node_modules/fresh": { diff --git a/package.json b/package.json index 3d107667a..76808c084 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,6 @@ "@meri-imperiumi/eccodes-wasm": "^2.48.2", "cesium": "^1.124.0", "egm96-universal": "^1.1.1", - "flag-icons": "7.5.0", "hls.js": "^1.7.3", "mapillary-js": "^4.1.2", "mgrs": "^2.1.0", @@ -77,6 +76,7 @@ "@happy-dom/global-registrator": "^20.14.6", "@vitejs/plugin-vue": "^5.2.4", "@vue/test-utils": "^2.5.1", + "flag-icons": "7.5.0", "happy-dom": "^20.14.6", "prettier": "3.9.6", "puppeteer": "^25.16.0", diff --git a/scripts/build-owner-flags.mjs b/scripts/build-owner-flags.mjs new file mode 100644 index 000000000..9ca7ed083 --- /dev/null +++ b/scripts/build-owner-flags.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/** + * Build the owner flag sheet the orbital Cockpit marks a satellite's owner + * country with (`src/ui/ownerFlag.js`): every flag in OWNER_COUNTRY_CODES + * (`src/layers/satellites/classes.js`), drawn from the flag-icons 4:3 SVGs + * at twice the readout's 20 × 15 px, in one PNG the app loads at startup. + * + * src/data/local_data/owner_flags/owner_flags.png the sheet + * src/ui/ownerFlagSheet.js its layout (generated) + * + * Flag i (in the sorted code list) sits at column i % COLUMNS, row + * 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 + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as prettier from 'prettier'; +import sharp from 'sharp'; +import { OWNER_COUNTRY_CODES } from '../src/layers/satellites/classes.js'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const require = createRequire(import.meta.url); +const FLAGS_DIR = path.join( + path.dirname(require.resolve('flag-icons/package.json')), + 'flags/4x3', +); +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( + ROOT, + 'src/data/local_data/owner_flags/owner_flags.png', +); +const LAYOUT = path.join(ROOT, 'src/ui/ownerFlagSheet.js'); + +async function flagCell(code) { + const svg = await readFile(path.join(FLAGS_DIR, `${code.toLowerCase()}.svg`)); + return sharp(svg, { density: 300 }) + .resize(CELL.width, CELL.height, { fit: 'fill' }) + .png() + .toBuffer(); +} + +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 * pitchX, + height: rows * pitchY, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, +}) + .composite( + cells.map((input, i) => ({ + input, + left: (i % COLUMNS) * pitchX, + top: Math.floor(i / COLUMNS) * pitchY, + })), + ) + .png({ compressionLevel: 9, palette: true, quality: 100, effort: 10 }) + .toBuffer(); +await writeFile(SHEET, sheet); + +const layout = `// Generated by scripts/build-owner-flags.mjs from flag-icons ${FLAG_ICONS_VERSION}; do not edit. +/** The owner flag sheet's layout: flag i at column i % columns, row i / columns. */ +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)}), +}); +`; +await writeFile( + LAYOUT, + await prettier.format(layout, { + ...(await prettier.resolveConfig(LAYOUT)), + filepath: LAYOUT, + }), +); +console.log( + `Wrote ${codes.length} flags (${COLUMNS} × ${rows}), ${(sheet.length / 1024).toFixed(1)} KB.`, +); diff --git a/scripts/check-package-boundaries.mjs b/scripts/check-package-boundaries.mjs index ad5d2bdb2..700dc9a1a 100644 --- a/scripts/check-package-boundaries.mjs +++ b/scripts/check-package-boundaries.mjs @@ -109,14 +109,7 @@ export async function checkPackageBoundaries(root) { // template blocks belong to the component file itself. if (info.id.startsWith('\0')) return; const id = info.id.split('?')[0]; - // A declared dependency also owns the asset files it ships (a - // flag-icons SVG imported as a URL) that a glob resolves in place. - const dependencyAsset = group.external.some((external) => - id.startsWith( - `${normalizePath(path.join(root, 'node_modules', external))}/`, - ), - ); - if (!allowed.has(id) && !dependencyAsset) { + if (!allowed.has(id)) { throw new Error( `Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`, ); diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 0451fb3fa..7b09e8077 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1363,5 +1363,10 @@ "src/layers/wildlifeSightings/cache.integration.test.mjs", "src/layers/wildlifeSightings/density.test.mjs", "src/layers/wildlifeSightings/selection.test.mjs", - "server/providers/inaturalist/proxy.fixture.mjs" + "server/providers/inaturalist/proxy.fixture.mjs", + "scripts/build-owner-flags.mjs", + "src/ui/ownerFlagSheet.js", + "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 829d0713c..f7fd906a7 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -42,6 +42,7 @@ "src/ui/views/panels/CockpitRadar.vue", "src/ui/views/panels/CockpitOrbital.vue", "src/ui/ownerFlag.js", + "src/ui/ownerFlagSheet.js", "src/ui/cockpitPresentation.js", "src/data/headingDegrees.js", "src/data/distanceFormat.js", @@ -164,7 +165,7 @@ "src/ui/views/panels/WorldTimeline.vue", "src/ui/surfaceKeyboard.js" ], - "external": ["vue", "pinia", "vue-i18n", "flag-icons"] + "external": ["vue", "pinia", "vue-i18n"] }, "time": { "exports": ["./time"], @@ -1068,6 +1069,9 @@ "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", "src/data/adminBoundaries.js", "src/data/bundledJson.js", @@ -2913,6 +2917,9 @@ "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", "src/ui/cockpitController.js", "src/ui/cockpitDisplayPortal.js", diff --git a/src/data/local_data/owner_flags/README.md b/src/data/local_data/owner_flags/README.md new file mode 100644 index 000000000..ed0dc071a --- /dev/null +++ b/src/data/local_data/owner_flags/README.md @@ -0,0 +1,18 @@ +# Owner flags + +`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, 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) +7.5.0 (a devDependency, used only to build the sheet). + +**License:** flag-icons is MIT licensed (© Panayiotis Lipiridis). Country +flags themselves are not copyrightable designs in most jurisdictions. + +**Regenerate:** `node scripts/build-owner-flags.mjs` (after adding an owner +country, or updating flag-icons). With the same flag-icons and sharp versions +a rerun reproduces both files byte for byte. diff --git a/src/data/local_data/owner_flags/owner_flags.png b/src/data/local_data/owner_flags/owner_flags.png new file mode 100644 index 000000000..99e3d07b0 Binary files /dev/null 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 5c39d731e..ce644044c 100644 --- a/src/data/placeBelow.js +++ b/src/data/placeBelow.js @@ -10,7 +10,8 @@ * * 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; a country's division rings are decoded + * first radius) and is indexed once, and the loading status names them while + * 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. */ @@ -46,6 +47,86 @@ const PACKS = { ), }; +/** + * The packs on their way now, and those that failed in the current load (or + * the last one): a load starts with no failures, so a retry that lands is + * not blamed for a pack that failed before. (A load that does not need the + * failed pack clears it too; the next lookup that needs it tries it again.) + */ +const loadingPacks = new Set(); +const failedPacks = new Set(); +// One lookup loads its packs one after another (countries and seas, then +// divisions, then places): the status holds them as one load until the +// lookup ends, rather than ending and starting a batch between packs. +let lookupsInFlight = 0; +let lookupLoaded = false; +const loadingListeners = new Set(); + +function notifyLoading() { + for (const listener of [...loadingListeners]) { + try { + listener(); + } catch { + /* A status listener never breaks a lookup. */ + } + } +} + +/** Whether the status shows a load on its way. */ +function loadingNow() { + return loadingPacks.size > 0 || (lookupLoaded && lookupsInFlight > 0); +} + +/** + * A pack load that the loading status follows: on its way while it runs, + * and failed, if it fails, until the next load starts. + * @template T + * @param {string} name + * @param {() => Promise} load + * @returns {() => Promise} + */ +export function reportedPlacePackLoad(name, load) { + return async () => { + // A fresh load forgets earlier failures: it ends on what it loads. + if (!loadingNow()) failedPacks.clear(); + if (lookupsInFlight > 0) lookupLoaded = true; + loadingPacks.add(name); + notifyLoading(); + try { + return await load(); + } catch (error) { + failedPacks.add(name); + throw error; + } finally { + loadingPacks.delete(name); + notifyLoading(); + } + }; +} + +/** + * 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 placePacksStatus() { + return { + loading: loadingNow(), + failed: failedPacks.size > 0, + }; +} + +/** + * Hear each start and end of a place pack load. + * @param {() => void} listener + * @returns {() => void} Stops listening. + */ +export function onPlacePacksLoadingChange(listener) { + loadingListeners.add(listener); + return () => loadingListeners.delete(listener); +} + /** Test seam: count pack loads (lazy-loading contract). */ export const placePackLoads = { divisions: 0, places: 0 }; @@ -106,7 +187,7 @@ function ringRank(ring) { return (Math.abs(sum) / 2) * Math.cos((meanLat * Math.PI) / 180); } -const loadPlaces = createRetryableLoader(async () => { +async function readPlaces() { const [countries, marine] = await Promise.all([ loadBundledJson(PACKS.countries), loadBundledJson(PACKS.marine), @@ -142,10 +223,13 @@ const loadPlaces = createRetryableLoader(async () => { }); } return { land, water }; -}); +} +const loadPlaces = createRetryableLoader( + reportedPlacePackLoad('places', readPlaces), +); /** Divisions grouped by country code; rings decoded per country on demand. */ -const loadDivisions = createRetryableLoader(async () => { +async function readDivisions() { placePackLoads.divisions += 1; const pack = await loadBundledJson(PACKS.divisions); const decimals = pack.meta?.decimals ?? 3; @@ -157,7 +241,10 @@ const loadDivisions = createRetryableLoader(async () => { byCountry.set(feature.iso2, list); } return { byCountry, decoded: new Map() }; -}); +} +const loadDivisions = createRetryableLoader( + reportedPlacePackLoad('divisions', readDivisions), +); /** The first-level division holding a point inside country `code`, or null. */ async function divisionAt(code, lat, lon) { @@ -179,7 +266,7 @@ async function divisionAt(code, lat, lon) { return null; } -const loadPopulatedPlaces = createRetryableLoader(async () => { +async function readPopulatedPlaces() { placePackLoads.places += 1; const pack = await loadBundledJson(PACKS.places); return (pack.places || []).filter( @@ -189,7 +276,10 @@ const loadPopulatedPlaces = createRetryableLoader(async () => { Number.isFinite(row[1]) && Number.isFinite(row[2]), ); -}); +} +const loadPopulatedPlaces = createRetryableLoader( + reportedPlacePackLoad('populated-places', readPopulatedPlaces), +); /** * The most prominent populated place within `radiusKm` of a point (lowest @@ -251,7 +341,20 @@ async function orNull(lookup) { * simplification) or it is invalid. Rejects only when the country and * marine packs fail. */ -export async function placeBelow( +export async function placeBelow(latitude, longitude, options) { + lookupsInFlight += 1; + try { + return await findPlaceBelow(latitude, longitude, options); + } finally { + lookupsInFlight -= 1; + if (lookupsInFlight === 0 && lookupLoaded) { + lookupLoaded = false; + notifyLoading(); + } + } +} + +async function findPlaceBelow( latitude, longitude, { nearKm = null, detail = DETAIL_LOOKUPS } = {}, diff --git a/src/data/placePacksLoading.test.mjs b/src/data/placePacksLoading.test.mjs new file mode 100644 index 000000000..e23857a93 --- /dev/null +++ b/src/data/placePacksLoading.test.mjs @@ -0,0 +1,111 @@ +// src/data/placePacksLoading.test.mjs — the loading status hears the place +// packs while they are on their way. +import test from 'node:test'; +import assert from 'node:assert/strict'; + +test('a place pack load is reported from its start to its end', async () => { + // A module of its own, so no other test has loaded the packs yet. + const place = await import('./placeBelow.js?loading-status'); + const heard = []; + const stop = place.onPlacePacksLoadingChange(() => + heard.push(place.placePacksStatus().loading), + ); + assert.equal(place.placePacksStatus().loading, false); + const lookup = place.nearPlace(48.86, 2.35, 50); + assert.equal(place.placePacksStatus().loading, true, 'on its way'); + assert.equal(await lookup, 'Paris'); + 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; + const retry = load(); + assert.deepEqual( + place.placePacksStatus(), + { loading: true, failed: false }, + 'a retry is not a failure until it fails', + ); + assert.equal(await retry, 1); + assert.deepEqual(place.placePacksStatus(), { loading: false, failed: false }); +}); + +test('one lookup that loads several packs reads as one load', async () => { + const place = await import('./placeBelow.js?one-lookup'); + const heard = []; + const stop = place.onPlacePacksLoadingChange(() => { + const { loading } = place.placePacksStatus(); + if (heard.at(-1) !== loading) heard.push(loading); + }); + // On land with a radius: countries and seas, then divisions, then places. + const found = await place.placeBelow(48.86, 2.35, { nearKm: 50 }); + assert.equal(found.near, 'Paris'); + assert.deepEqual(heard, [true, false], 'no gap between the packs'); + stop(); +}); + +/** Two stand-in packs (not real pack names) that fail while named in `fail`. */ +function standInPacks(place, fail) { + const pack = (name) => + place.reportedPlacePackLoad(name, async () => { + if (fail.has(name)) throw new Error('offline'); + return name; + }); + return { first: pack('stand-in-first'), second: pack('stand-in-second') }; +} + +/** The statuses one lookup that loads both stand-ins reports, start to end. */ +async function lookupStatuses(place, { first, second }) { + const statuses = []; + const stop = place.onPlacePacksLoadingChange(() => + statuses.push(place.placePacksStatus()), + ); + await place.placeBelow(48.86, 2.35, { + detail: { division: async () => first(), near: async () => second() }, + }); + stop(); + return statuses; +} + +test('a load is not blamed for packs that failed in an earlier one', async () => { + const place = await import('./placeBelow.js?two-failed'); + const fail = new Set(['stand-in-first', 'stand-in-second']); + const packs = standInPacks(place, fail); + await assert.rejects(packs.first()); + await assert.rejects(packs.second()); + assert.equal(place.placePacksStatus().failed, true); + fail.clear(); + const statuses = await lookupStatuses(place, packs); + assert.deepEqual(statuses[0], { loading: true, failed: false }); + assert.ok( + statuses.every((status) => status.failed === false), + 'the earlier failures do not colour this load', + ); + assert.deepEqual(place.placePacksStatus(), { + loading: false, + failed: false, + }); +}); + +test('a pack that fails in a load keeps it failed when the next one lands', async () => { + const place = await import('./placeBelow.js?mixed'); + const packs = standInPacks(place, new Set(['stand-in-first'])); + const statuses = await lookupStatuses(place, packs); + assert.deepEqual(statuses.at(-1), { loading: false, failed: true }); + assert.deepEqual(place.placePacksStatus(), { + loading: false, + failed: true, + }); +}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 880eed4e0..de9dd13dd 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -622,6 +622,7 @@ "DISABLING": "جارٍ الإيقاف", "Military Flights": "الرحلات العسكرية", "Satellites": "الأقمار الصناعية", + "Place names": "أسماء الأماكن", "Space Missions (30d)": "المهام الفضائية (30 يومًا)", "Street Traffic": "حركة مرور الشوارع", "Radio": "الراديو", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 09f9c98a7..792122d41 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -544,6 +544,7 @@ "Military Flights": "Militärflüge", "Earthquakes (24h)": "Erdbeben (24 h)", "Satellites": "Satelliten", + "Place names": "Ortsnamen", "Space Missions (30d)": "Raummissionen (30 T)", "Street Traffic": "Straßenverkehr", "CCTV": "CCTV", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index fa59166fa..28a65d68c 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -507,6 +507,7 @@ "DISABLING": "APAGANDO", "Military Flights": "Vuelos militares", "Satellites": "Satélites", + "Place names": "Nombres de lugares", "Space Missions (30d)": "Misiones espaciales (30 d)", "Street Traffic": "Tráfico urbano", "Radio": "Radio", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index e597c118f..44edfae1c 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1849,6 +1849,7 @@ "Military Flights": "Vols militaires", "Earthquakes (24h)": "Séismes (24 h)", "Satellites": "Satellites", + "Place names": "Noms de lieux", "Space Missions (30d)": "Missions spatiales (30 j)", "Street Traffic": "Trafic routier", "CCTV": "CCTV", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 687ac971c..a23589731 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1849,6 +1849,7 @@ "Military Flights": "Voli militari", "Earthquakes (24h)": "Terremoti (24 h)", "Satellites": "Satelliti", + "Place names": "Nomi dei luoghi", "Space Missions (30d)": "Missioni spaziali (30 g)", "Street Traffic": "Traffico stradale", "CCTV": "CCTV", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index f3f86ea2d..866c5ecae 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1787,6 +1787,7 @@ "Military Flights": "軍用機", "Earthquakes (24h)": "地震(24時間)", "Satellites": "衛星", + "Place names": "地名", "Space Missions (30d)": "宇宙ミッション 30日", "Street Traffic": "道路交通", "CCTV": "CCTV", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 054bfc32e..06ed341b3 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1849,6 +1849,7 @@ "Military Flights": "Voos militares", "Earthquakes (24h)": "Terremotos (24 h)", "Satellites": "Satélites", + "Place names": "Nomes de lugares", "Space Missions (30d)": "Missões espaciais (30 d)", "Street Traffic": "Trânsito urbano", "CCTV": "CCTV", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 993edb912..02e3ec44c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -537,6 +537,7 @@ "Military Flights": "Военные рейсы", "Earthquakes (24h)": "Землетрясения (24 ч)", "Satellites": "Спутники", + "Place names": "Названия мест", "Space Missions (30d)": "Космические миссии (30 дн.)", "Street Traffic": "Дорожный трафик", "CCTV": "CCTV", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 1fe699b69..fb131cd83 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1879,6 +1879,7 @@ "Military Flights": "Військові рейси", "Earthquakes (24h)": "Землетруси (24 год)", "Satellites": "Супутники", + "Place names": "Назви місць", "Space Missions (30d)": "Космічні місії (30 дн)", "Street Traffic": "Дорожній рух", "CCTV": "CCTV", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 537325aef..4bb8d040b 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1764,6 +1764,7 @@ "Military Flights": "军用航班", "Earthquakes (24h)": "地震(24 小时)", "Satellites": "卫星", + "Place names": "地名", "Space Missions (30d)": "太空任务(30 天)", "Street Traffic": "街道交通", "CCTV": "CCTV", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index f358a5128..57f0f0d22 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1754,6 +1754,7 @@ "Military Flights": "軍用航班", "Earthquakes (24h)": "地震(24 小時)", "Satellites": "衛星", + "Place names": "地名", "Space Missions (30d)": "太空任務(30 天)", "Street Traffic": "街道交通", "CCTV": "CCTV", diff --git a/src/layers/satellites/classes.js b/src/layers/satellites/classes.js index f4d47a0ea..9dd2e3700 100644 --- a/src/layers/satellites/classes.js +++ b/src/layers/satellites/classes.js @@ -364,6 +364,14 @@ const COUNTRY_NAMES = Object.freeze({ ZW: 'Zimbabwe', }); +/** + * Every owner country a satellite can be marked with, sorted: the flags + * `scripts/build-owner-flags.mjs` draws into the owner flag sheet. + */ +export const OWNER_COUNTRY_CODES = Object.freeze( + Object.keys(COUNTRY_NAMES).sort(), +); + // UCS "Country of Operator/Owner" → ISO code. Joint owners ("USA/Japan") and // organisations are not countries and resolve through ORGANISATIONS or as a // joint label. diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index a11228dfa..7bffa4d4d 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -64,6 +64,9 @@ 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 } from '../data/placeBelow.js'; +import { withPlacePacks } from './placePacksLoading.js'; +import { preloadOwnerFlags } from './ownerFlag.js'; /** * Central UI orchestrator for the God's Eye View application. @@ -158,8 +161,15 @@ export class StyleManager extends ShellFacade { // Vue views share this runtime and its stores; dispose unmounts them. this._ui = createUiRuntime({ pinia: applicationPinia() }); this._feedback = new ShellFeedback({ - readLayers: () => this._dataManager?.getAll?.() || [], + readLayers: () => withPlacePacks(this._dataManager?.getAll?.() || []), }); + // The satellite owner flags load with the app, not on the first ride. + preloadOwnerFlags(); + // The place names under a Cockpit load on its first lookup: the loading + // status names them meanwhile. + this._offPlacePacks = onPlacePacksLoadingChange(() => + this._updateGlobalLoadingFeedback(), + ); this.viewer = viewer; this.mapStackController = mapStackController; this.placeSearch = placeSearch; @@ -1825,6 +1835,7 @@ export class StyleManager extends ShellFacade { this._wildlifeSightingCard?.destroy(); this._ui.destroy(); this._panelChrome.destroy(); + this._offPlacePacks(); this._feedback.destroy(); this._displayBindings.destroy(); diff --git a/src/ui/cockpitOrbital.test.mjs b/src/ui/cockpitOrbital.test.mjs index 049f4c50f..987527c28 100644 --- a/src/ui/cockpitOrbital.test.mjs +++ b/src/ui/cockpitOrbital.test.mjs @@ -7,6 +7,13 @@ import { createPinia } from 'pinia'; import { createApp, nextTick } from 'vue'; import { useCockpitStore } from './stores/cockpit.js'; import CockpitOrbital from './views/panels/CockpitOrbital.vue'; +import { ownerFlagStyle } from './ownerFlag.js'; + +/** Let a settled promise reach the view. */ +async function settle() { + await new Promise((resolve) => setTimeout(resolve, 0)); + await nextTick(); +} const VIEW = Object.freeze({ name: 'ISS (ZARYA)', @@ -191,6 +198,55 @@ test('the place below shows at the top once known', async (t) => { assert.equal(document.querySelector('#cockpit-orbital-over'), null); }); +test('an owner country shows its flag from the sheet, or its code if the sheet will not load', async (t) => { + // The sheet loads through an Image; this one answers when told. + const images = []; + const RealImage = globalThis.Image; + globalThis.Image = class { + constructor() { + images.push(this); + } + }; + t.after(() => { + globalThis.Image = RealImage; + }); + const f = fixture(t); + f.store.publishOrbital({ + ...VIEW, + owner: { countryCode: 'US', label: 'USA' }, + }); + await nextTick(); + assert.equal(f.section().querySelector('.cockpit-orbital-flag'), null); + assert.equal(images.length, 1, 'the sheet is asked for'); + images[0].onerror(); + 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, 3); + images[2].onload(); + await settle(); + const flag = f.section().querySelector('.cockpit-orbital-flag'); + assert.equal(flag.getAttribute('role'), 'img'); + assert.equal(flag.getAttribute('aria-label'), 'France'); + assert.equal( + flag.style.backgroundPosition, + ownerFlagStyle('FR').backgroundPosition, + ); + assert.equal(f.section().querySelector('.cockpit-orbital-owner-badge'), null); +}); + test('the readout names the owner, operator and element set', async (t) => { const f = fixture(t); f.store.publishOrbital({ @@ -198,16 +254,17 @@ test('the readout names the owner, operator and element set', async (t) => { noradId: 25544, cosparId: '1998-067A', tle: ISS_TLE, - // Under node there is no flag glob, so a country shows as a badge. - owner: { countryCode: 'US', label: 'USA' }, + // A country with no flag on the sheet shows as a badge, named in the + // reader's language. + owner: { countryCode: 'IS', label: 'Ísland' }, operator: 'NASA', }); await nextTick(); assert.equal(f.text('.cockpit-orbital-name'), 'ISS (ZARYA)'); - assert.equal(f.text('.cockpit-orbital-owner-badge'), 'USA'); + assert.equal(f.text('.cockpit-orbital-owner-badge'), 'Ísland'); assert.equal( f.section().querySelector('.cockpit-orbital-owner-badge').title, - 'United States', + 'Iceland', ); assert.equal(f.text('.cockpit-orbital-operator'), 'NASA'); assert.equal( diff --git a/src/ui/cockpitOrbitalSatellites.js b/src/ui/cockpitOrbitalSatellites.js index d8735dd65..43bd54d12 100644 --- a/src/ui/cockpitOrbitalSatellites.js +++ b/src/ui/cockpitOrbitalSatellites.js @@ -144,6 +144,9 @@ export function switchOrbitalSatellite(noradId) { // until the camera is on the new one, they list no sites or satellites. state.awaitingFrame = true; state.previousUp = null; + // The field of view is still the old satellite's until the next frame; the + // HUD published below sizes the strip from the settled zoom meanwhile. + state.verticalFov = null; state.place = undefined; state.placeTracker?.reset?.(); state.prefetched?.clear?.(); 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..72130a9e8 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 / 128 ≈ 78,272 m; it first + // meets Cesium's screen-space error of 2 px (≤ 99 m) at level 10, 76.4 m. + // A Web Mercator level-zero texel at 30° is 6378137 · 2π · cos 30° / 256 + // ≈ 135,570 m, so the imagery level is round(log2(135,570 / 76.4)) = 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) => { diff --git a/src/ui/layerNames.js b/src/ui/layerNames.js index bba7f96f7..e742ed3ee 100644 --- a/src/ui/layerNames.js +++ b/src/ui/layerNames.js @@ -61,12 +61,19 @@ export const PANEL_LABEL_KEYS = Object.freeze({ 'local-firms': msg('Active Fires'), }); +/** What the loading status names for a load that is not a layer's. */ +export const LOADING_NAME_KEYS = Object.freeze({ + 'place-names': msg('Place names'), +}); + // Looked up by the English text, so a surface that holds `layer.name` keeps // its English data and only the shown text translates. const MODULE_NAME_KEYS = new Map( - [...Object.values(LAYER_NAME_KEYS), ...Object.values(PANEL_LABEL_KEYS)].map( - (key) => [key, key], - ), + [ + ...Object.values(LAYER_NAME_KEYS), + ...Object.values(PANEL_LABEL_KEYS), + ...Object.values(LOADING_NAME_KEYS), + ].map((key) => [key, key]), ); /** diff --git a/src/ui/ownerFlag.js b/src/ui/ownerFlag.js index 35d1f2f58..5020143c6 100644 --- a/src/ui/ownerFlag.js +++ b/src/ui/ownerFlag.js @@ -1,34 +1,81 @@ /** - * The owner mark beside a satellite's name: the owner country's flag (an SVG - * from flag-icons — Windows draws no flag emoji), or a short text badge for - * an organisation without one (ESA, EUMETSAT, "Multinational"). Each flag is - * its own lazily fetched asset; only the shown one ever loads. + * The owner mark beside a satellite's name: the owner country's flag, or a + * short text badge for an organisation without one (ESA, EUMETSAT, + * "Multinational"). Windows draws no flag emoji, so the flags are pictures: + * one sheet of every owner country's flag (`scripts/build-owner-flags.mjs`, + * from flag-icons), loaded once at startup (`preloadOwnerFlags`), so riding + * a satellite never waits for its flag. */ +import { OWNER_FLAG_SHEET } from './ownerFlagSheet.js'; + +/** The flag sheet's URL. */ +export const OWNER_FLAG_SHEET_URL = new URL( + '../data/local_data/owner_flags/owner_flags.png', + import.meta.url, +).href; + +/** Each flag's place on the sheet, by ISO code. */ +const SHEET_INDEX = new Map( + OWNER_FLAG_SHEET.codes.map((code, index) => [code, index]), +); + +let sheetLoad = null; + /** - * Flag URL loaders by path; empty under node:test, which has no glob. The - * glob is eager so the bundle carries only the URL strings: a lazy glob would - * emit a JS stub chunk per flag (~270), two requests for each flag shown. - * The SVG itself is still fetched only when an shows it. + * Load the flag sheet, once; the app calls this at startup. A failed load + * is asked again by the next caller, so a flag shown later can still come. + * @param {{createImage?: () => {onload: Function|null, onerror: Function|null, + * src: string}}} [options] Test seam. + * @returns {Promise} Whether the sheet is ready to draw from. */ -let FLAG_LOADERS = {}; -try { - const urls = import.meta.glob('/node_modules/flag-icons/flags/4x3/*.svg', { - query: '?url&no-inline', - import: 'default', - eager: true, - // flag-icons lives in node_modules, which globs skip by default. - exhaustive: true, +export function preloadOwnerFlags({ + createImage = () => new globalThis.Image(), +} = {}) { + if (sheetLoad) return sheetLoad; + let image; + try { + image = createImage(); + } catch { + return Promise.resolve(false); + } + const load = new Promise((resolve) => { + image.onload = () => resolve(true); + image.onerror = () => { + if (sheetLoad === load) sheetLoad = null; + resolve(false); + }; }); - FLAG_LOADERS = Object.fromEntries( - Object.entries(urls).map(([path, url]) => [path, () => url]), - ); -} catch { - /* Not under Vite. */ + sheetLoad = load; + image.src = OWNER_FLAG_SHEET_URL; + return load; } -const FLAG_PATH = (code) => - `/node_modules/flag-icons/flags/4x3/${code.toLowerCase()}.svg`; +/** + * How to draw a flag from the sheet at a size: the sheet as the element's + * background, scaled and shifted onto the flag's cell. + * @param {string} code ISO 3166-1 alpha-2, upper case. + * @param {{widthPx?: number, heightPx?: number}} [size] CSS pixels. + * @returns {{backgroundImage: string, backgroundSize: string, + * backgroundPosition: string}|null} Null for a code not on the sheet. + */ +export function ownerFlagStyle(code, { widthPx = 20, heightPx = 15 } = {}) { + const index = SHEET_INDEX.get(code); + if (index === undefined) return null; + 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: `${sheet.columns * pitchX}px ${sheet.rows * pitchY}px`, + backgroundPosition: `${-column * pitchX}px ${-row * pitchY}px`, + }; +} /** An ISO 3166-1 alpha-2 code, upper case, or null. */ function isoCode(value) { @@ -56,55 +103,39 @@ export function regionName(code, locale) { /** * How to mark a satellite's owner. * @param {{countryCode: string|null, label: string|null}|null|undefined} owner - * @param {{locale?: string, loaders?: object}} [options] + * @param {{locale?: string, flags?: {has: (code: string) => boolean}}} + * [options] `flags`: the codes with a flag (the sheet's). * @returns {{kind: 'flag', code: string, label: string} * | {kind: 'badge', label: string, title: string} * | null} A flag (with the country's localized name as its label), a text * badge for an owner with no flag to show, or nothing when unknown. */ -export function ownerMark( - owner, - { locale = 'en', loaders = FLAG_LOADERS } = {}, -) { +export function ownerMark(owner, { locale = 'en', flags = SHEET_INDEX } = {}) { const code = isoCode(owner?.countryCode); const label = String(owner?.label ?? '').trim(); if (code) { const name = regionName(code, locale) || label || code; - if (loaders[FLAG_PATH(code)]) return { kind: 'flag', code, label: name }; + if (flags.has(code)) return { kind: 'flag', code, label: name }; return { kind: 'badge', label: label || code, title: name }; } return label ? { kind: 'badge', label, title: label } : null; } /** - * What the readout shows for an owner once its flag has loaded or failed: the - * flag, or a badge (the country code when the flag would not load, so the - * owner never disappears), or nothing for an unknown owner. + * What the readout shows for an owner once the flag sheet has loaded or + * failed: the flag, or a badge (the country code when the sheet would not + * load, so the owner never disappears), or nothing for an unknown owner. * @param {ReturnType} mark - * @param {{url?: string|null, failed?: boolean}} [flag] The flag's load state. - * @returns {{kind: 'flag', url: string, label: string} + * @param {{ready?: boolean, failed?: boolean}} [sheet] The sheet's state. + * @returns {{kind: 'flag', style: object, label: string} * | {kind: 'badge', label: string, title: string} | null} */ -export function shownOwnerMark(mark, { url = null, failed = false } = {}) { +export function shownOwnerMark(mark, { ready = false, failed = false } = {}) { if (!mark) return null; if (mark.kind === 'badge') return mark; - if (failed) { + const style = ownerFlagStyle(mark.code); + if (failed || !style) { return { kind: 'badge', label: mark.code.toUpperCase(), title: mark.label }; } - return url ? { kind: 'flag', url, label: mark.label } : null; -} - -/** - * Fetch a flag's URL. - * @param {string} code ISO 3166-1 alpha-2. - * @returns {Promise} - */ -export async function loadFlagUrl(code, loaders = FLAG_LOADERS) { - const load = loaders[FLAG_PATH(String(code))]; - if (!load) return null; - try { - return (await load()) || null; - } catch { - return null; - } + return ready ? { kind: 'flag', style, label: mark.label } : null; } diff --git a/src/ui/ownerFlag.test.mjs b/src/ui/ownerFlag.test.mjs index a1447bc86..d1d6bf455 100644 --- a/src/ui/ownerFlag.test.mjs +++ b/src/ui/ownerFlag.test.mjs @@ -1,75 +1,59 @@ // src/ui/ownerFlag.test.mjs — a satellite owner's mark: a country's flag with -// its localized name, a text badge for an organisation, nothing when unknown. +// its localized name, drawn from the owner flag sheet; a text badge for an +// organisation; nothing when unknown. import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { OWNER_COUNTRY_CODES } from '../layers/satellites/classes.js'; +import { OWNER_FLAG_SHEET } from './ownerFlagSheet.js'; import { - loadFlagUrl, + OWNER_FLAG_SHEET_URL, + ownerFlagStyle, ownerMark, + preloadOwnerFlags, regionName, shownOwnerMark, } from './ownerFlag.js'; -const FLAG = (code) => `/node_modules/flag-icons/flags/4x3/${code}.svg`; -const LOADERS = Object.freeze({ - [FLAG('us')]: async () => '/assets/us-abc.svg', - [FLAG('fr')]: async () => '/assets/fr-def.svg', -}); - test('an owner country shows its flag, named in the reader language', () => { - assert.deepEqual( - ownerMark({ countryCode: 'us', label: 'USA' }, { loaders: LOADERS }), - { kind: 'flag', code: 'US', label: 'United States' }, - ); + assert.deepEqual(ownerMark({ countryCode: 'us', label: 'USA' }), { + kind: 'flag', + code: 'US', + label: 'United States', + }); assert.equal( - ownerMark( - { countryCode: 'FR', label: null }, - { locale: 'de', loaders: LOADERS }, - ).label, + ownerMark({ countryCode: 'FR', label: null }, { locale: 'de' }).label, 'Frankreich', ); }); test('an organisation without a country shows a text badge', () => { - assert.deepEqual( - ownerMark({ countryCode: null, label: 'ESA' }, { loaders: LOADERS }), - { kind: 'badge', label: 'ESA', title: 'ESA' }, - ); + assert.deepEqual(ownerMark({ countryCode: null, label: 'ESA' }), { + kind: 'badge', + label: 'ESA', + title: 'ESA', + }); }); -test('a country with no flag to show falls back to a badge', () => { +test('a country with no flag on the sheet falls back to a badge', () => { assert.deepEqual( - ownerMark({ countryCode: 'DE', label: 'Germany' }, { loaders: LOADERS }), + ownerMark( + { countryCode: 'DE', label: 'Germany' }, + { flags: new Set(['US']) }, + ), { kind: 'badge', label: 'Germany', title: 'Germany' }, ); // Natural Earth and UCS write -99 or names, never a flag code. - assert.deepEqual( - ownerMark( - { countryCode: '-99', label: 'Multinational' }, - { loaders: LOADERS }, - ), - { kind: 'badge', label: 'Multinational', title: 'Multinational' }, - ); + assert.deepEqual(ownerMark({ countryCode: '-99', label: 'Multinational' }), { + kind: 'badge', + label: 'Multinational', + title: 'Multinational', + }); }); test('an unknown owner shows nothing', () => { - assert.equal(ownerMark(null, { loaders: LOADERS }), null); - assert.equal( - ownerMark({ countryCode: null, label: ' ' }, { loaders: LOADERS }), - null, - ); -}); - -test('only the flag asked for loads', async () => { - assert.equal(await loadFlagUrl('US', LOADERS), '/assets/us-abc.svg'); - assert.equal(await loadFlagUrl('DE', LOADERS), null); - assert.equal( - await loadFlagUrl('FR', { - [FLAG('fr')]: async () => { - throw new Error('offline'); - }, - }), - null, - ); + assert.equal(ownerMark(null), null); + assert.equal(ownerMark({ countryCode: null, label: ' ' }), null); }); test('a region name comes from Intl, never the bare code', () => { @@ -77,12 +61,57 @@ test('a region name comes from Intl, never the bare code', () => { assert.equal(regionName('XX', 'en'), null); }); -test('a flag that will not load falls back to its country code, never to nothing', () => { - const flag = { kind: 'flag', code: 'us', label: 'United States' }; +test('the sheet holds every owner country a satellite can be marked with', () => { + assert.deepEqual([...OWNER_FLAG_SHEET.codes], [...OWNER_COUNTRY_CODES]); + assert.equal( + OWNER_FLAG_SHEET.rows, + Math.ceil(OWNER_FLAG_SHEET.codes.length / OWNER_FLAG_SHEET.columns), + ); + // 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 * (cellWidthPx + gutterPx), + ); + assert.equal( + png.readUInt32BE(20), + 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 * 21}px ${OWNER_FLAG_SHEET.rows * 16}px`, + ); + assert.equal( + style.backgroundPosition, + `${-(index % columns) * 21}px ${-Math.floor(index / columns) * 16}px`, + ); + // The first flag sits at the corner. + assert.equal( + ownerFlagStyle(OWNER_FLAG_SHEET.codes[0]).backgroundPosition, + '0px 0px', + ); + assert.equal(ownerFlagStyle('XX'), null); +}); + +test('the flag shows once the sheet is ready; a failed sheet shows the code', () => { + const flag = { kind: 'flag', code: 'US', label: 'United States' }; assert.equal(shownOwnerMark(flag), null, 'nothing while it loads'); - assert.deepEqual(shownOwnerMark(flag, { url: '/flags/us.svg' }), { + assert.deepEqual(shownOwnerMark(flag, { ready: true }), { kind: 'flag', - url: '/flags/us.svg', + style: ownerFlagStyle('US'), label: 'United States', }); assert.deepEqual(shownOwnerMark(flag, { failed: true }), { @@ -95,14 +124,23 @@ test('a flag that will not load falls back to its country code, never to nothing assert.equal(shownOwnerMark(null), null); }); -test('flags ship as a map of URLs, not one JS chunk per flag', async () => { - const { readFileSync } = await import('node:fs'); - const source = readFileSync( - new URL('./ownerFlag.js', import.meta.url), - 'utf8', - ); - // A lazy glob emits a stub chunk for each of ~270 flags; eager URLs ship - // only strings, and each SVG still loads when an shows it. - assert.match(source, /eager:\s*true/); - assert.match(source, /query:\s*'\?url&no-inline'/); +test('the sheet loads once; a failed load is asked again', async () => { + const images = []; + const createImage = () => { + const image = { onload: null, onerror: null, src: '' }; + images.push(image); + return image; + }; + const first = preloadOwnerFlags({ createImage }); + assert.equal(images.length, 1); + assert.equal(images[0].src, OWNER_FLAG_SHEET_URL); + assert.equal(preloadOwnerFlags({ createImage }), first, 'one load at a time'); + images[0].onerror(); + assert.equal(await first, false); + const second = preloadOwnerFlags({ createImage }); + assert.equal(images.length, 2, 'asked again after a failure'); + images[1].onload(); + assert.equal(await second, true); + assert.equal(preloadOwnerFlags({ createImage }), second, 'kept once loaded'); + assert.equal(images.length, 2); }); diff --git a/src/ui/ownerFlagSheet.js b/src/ui/ownerFlagSheet.js new file mode 100644 index 000000000..723bf00de --- /dev/null +++ b/src/ui/ownerFlagSheet.js @@ -0,0 +1,114 @@ +// Generated by scripts/build-owner-flags.mjs from flag-icons 7.5.0; do not edit. +/** The owner flag sheet's layout: flag i at column i % columns, row i / columns. */ +export const OWNER_FLAG_SHEET = Object.freeze({ + cellWidthPx: 40, + cellHeightPx: 30, + gutterPx: 2, + columns: 16, + rows: 7, + codes: Object.freeze([ + 'AE', + 'AM', + 'AO', + 'AR', + 'AT', + 'AU', + 'AZ', + 'BD', + 'BE', + 'BG', + 'BH', + 'BM', + 'BO', + 'BR', + 'BT', + 'BW', + 'BY', + 'CA', + 'CH', + 'CL', + 'CN', + 'CO', + 'CR', + 'CZ', + 'DE', + 'DJ', + 'DK', + 'DZ', + 'EC', + 'EE', + 'EG', + 'ES', + 'ET', + 'FI', + 'FR', + 'GB', + 'GH', + 'GR', + 'GT', + 'HR', + 'HU', + 'ID', + 'IE', + 'IL', + 'IN', + 'IQ', + 'IR', + 'IT', + 'JO', + 'JP', + 'KE', + 'KP', + 'KR', + 'KW', + 'KZ', + 'LA', + 'LK', + 'LT', + 'LU', + 'MA', + 'MC', + 'MD', + 'ME', + 'MM', + 'MN', + 'MU', + 'MX', + 'MY', + 'NG', + 'NL', + 'NO', + 'NP', + 'NZ', + 'PE', + 'PH', + 'PK', + 'PL', + 'PT', + 'PY', + 'QA', + 'RO', + 'RU', + 'RW', + 'SA', + 'SB', + 'SD', + 'SE', + 'SG', + 'SI', + 'SN', + 'TH', + 'TM', + 'TN', + 'TR', + 'TW', + 'UA', + 'US', + 'UY', + 'VA', + 'VE', + 'VN', + 'ZA', + 'ZW', + ]), +}); 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..44aab8836 --- /dev/null +++ b/src/ui/placePacksLoading.test.mjs @@ -0,0 +1,68 @@ +// 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', + ); + // A retry after a failure that lands ends complete. + assert.equal( + batch([ + { loading: false, failed: true }, + loading, + { loading: false, failed: false }, + ]).terminal, + 'complete', + ); +}); diff --git a/src/ui/shellFeedbackStatusLocalization.test.mjs b/src/ui/shellFeedbackStatusLocalization.test.mjs index b05ceb54f..aa282784d 100644 --- a/src/ui/shellFeedbackStatusLocalization.test.mjs +++ b/src/ui/shellFeedbackStatusLocalization.test.mjs @@ -140,3 +140,14 @@ for (const locale of ['es', 'ru']) { assert.equal(shown.label, t(error).toLocaleUpperCase(locale)); }); } + +test('the place names loading beside the layers are named in the language', () => { + setLocale('de'); + const localized = localizeLoadingPresentation({ + state: 'loading', + label: 'LOADING LIVE DATA', + detail: 'Satellites · Place names', + }); + assert.equal(localized.detail, 'Satelliten · Ortsnamen'); + setLocale('en'); +}); diff --git a/src/ui/styles/cockpitOrbital.css b/src/ui/styles/cockpitOrbital.css index 2fc84fd0e..9e799f2be 100644 --- a/src/ui/styles/cockpitOrbital.css +++ b/src/ui/styles/cockpitOrbital.css @@ -194,11 +194,15 @@ } .cockpit-orbital-flag { flex: none; + box-sizing: content-box; width: 20px; height: 15px; border: 1px solid var(--orbital-faint); border-radius: 1px; - object-fit: cover; + 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 47f19cbef..e43848d7e 100644 --- a/src/ui/views/panels/CockpitOrbital.vue +++ b/src/ui/views/panels/CockpitOrbital.vue @@ -8,8 +8,8 @@ import { tleAgeParts, } from '../../cockpitPresentation.js'; import { - loadFlagUrl, ownerMark, + preloadOwnerFlags, regionName, shownOwnerMark, } from '../../ownerFlag.js'; @@ -189,34 +189,30 @@ const placeText = computed(() => { const mark = computed(() => ownerMark(orbital.value?.owner, { locale: getLocale() }), ); -const flagUrl = ref(null); -// A flag that would not load (offline, a stale chunk after a deploy, a -// missing file) falls back to the code as a badge, never to no owner at all. -const flagFailed = ref(false); -let flagRequest = 0; +// The flag sheet loads at startup; a sheet that would not load (offline, a +// stale asset after a deploy) is asked again for the next flag, and until it +// comes the code shows as a badge, never no owner at all. +const flagsReady = ref(false); +const flagsFailed = ref(false); watch( - () => (mark.value?.kind === 'flag' ? mark.value.code : null), - async (code) => { - const request = ++flagRequest; - flagUrl.value = null; - flagFailed.value = false; - if (!code) return; - let url = null; - try { - url = await loadFlagUrl(code); - } catch { - url = null; - } - if (request !== flagRequest) return; - flagUrl.value = url; - flagFailed.value = !url; + // 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; }, { immediate: true }, ); const shownMark = computed(() => shownOwnerMark(mark.value, { - url: flagUrl.value, - failed: flagFailed.value, + ready: flagsReady.value, + failed: flagsFailed.value, }), ); @@ -293,8 +289,7 @@ const lensStyle = computed(() => {

{{ look.offset ? t('ORBITAL VIEW · OFF-NADIR') : t('ORBITAL VIEW · NADIR') }}

- - + {{ shownMark.label }}

{{ orbital.name }}