diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6d088b232..08dc69d1f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1786,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..99b7a7545 --- /dev/null +++ b/scripts/build-owner-flags.mjs @@ -0,0 +1,93 @@ +#!/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). 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 }); +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 cells = await Promise.all(codes.map(flagCell)); +const sheet = await sharp({ + create: { + width: COLUMNS * CELL.width, + height: rows * CELL.height, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, +}) + .composite( + cells.map((input, i) => ({ + input, + left: (i % COLUMNS) * CELL.width, + top: Math.floor(i / COLUMNS) * CELL.height, + })), + ) + .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}, + 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..887c72eb7 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1363,5 +1363,8 @@ "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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 829d0713c..997191dbb 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,8 @@ "src/cockpitOrbitalSatellites.js", "src/layers/installations/classLabel.js", "src/data/placeBelow.js", + "src/ui/ownerFlag.js", + "src/ui/ownerFlagSheet.js", "src/layers/satellites/sensors.js", "src/data/adminBoundaries.js", "src/data/bundledJson.js", @@ -2913,6 +2916,8 @@ "src/ui/cockpitOrbitalSatellites.js", "src/cockpitOrbitalSatellites.js", "src/data/placeBelow.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..60a02a919 --- /dev/null +++ b/src/data/local_data/owner_flags/README.md @@ -0,0 +1,17 @@ +# 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. `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..fd1826a0e 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..67e120fa9 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 (`placePacksLoading`); 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,53 @@ const PACKS = { ), }; +/** The packs on their way now, for the loading status. */ +const loadingPacks = new Set(); +const loadingListeners = new Set(); + +function notifyLoading() { + for (const listener of [...loadingListeners]) { + try { + listener(); + } catch { + /* A status listener never breaks a lookup. */ + } + } +} + +/** A pack load that the loading status counts while it runs. */ +function reportedLoad(name, load) { + return async () => { + loadingPacks.add(name); + notifyLoading(); + try { + return await load(); + } finally { + loadingPacks.delete(name); + notifyLoading(); + } + }; +} + +/** + * 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} + */ +export function placePacksLoading() { + return loadingPacks.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 +154,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 +190,11 @@ const loadPlaces = createRetryableLoader(async () => { }); } return { land, water }; -}); +} +const loadPlaces = createRetryableLoader(reportedLoad('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 +206,10 @@ const loadDivisions = createRetryableLoader(async () => { byCountry.set(feature.iso2, list); } return { byCountry, decoded: new Map() }; -}); +} +const loadDivisions = createRetryableLoader( + reportedLoad('divisions', readDivisions), +); /** The first-level division holding a point inside country `code`, or null. */ async function divisionAt(code, lat, lon) { @@ -179,7 +231,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 +241,10 @@ const loadPopulatedPlaces = createRetryableLoader(async () => { Number.isFinite(row[1]) && Number.isFinite(row[2]), ); -}); +} +const loadPopulatedPlaces = createRetryableLoader( + reportedLoad('populated-places', readPopulatedPlaces), +); /** * The most prominent populated place within `radiusKm` of a point (lowest diff --git a/src/data/placePacksLoading.test.mjs b/src/data/placePacksLoading.test.mjs new file mode 100644 index 000000000..ff1837a3d --- /dev/null +++ b/src/data/placePacksLoading.test.mjs @@ -0,0 +1,23 @@ +// 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.placePacksLoading()), + ); + assert.equal(place.placePacksLoading(), false); + const lookup = place.nearPlace(48.86, 2.35, 50); + assert.equal(place.placePacksLoading(), true, 'on its way'); + assert.equal(await lookup, 'Paris'); + assert.equal(place.placePacksLoading(), 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(); +}); 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/tooling/packageBoundaries.test.mjs b/src/tooling/packageBoundaries.test.mjs index 517bad64e..548cadbf4 100644 --- a/src/tooling/packageBoundaries.test.mjs +++ b/src/tooling/packageBoundaries.test.mjs @@ -126,49 +126,3 @@ 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/applicationShell.js b/src/ui/applicationShell.js index a11228dfa..4cf9643d7 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -64,6 +64,25 @@ 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 { 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. @@ -158,8 +177,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 +1851,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..5de131693 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,49 @@ 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'); + // 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(); + 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 +248,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.test.mjs b/src/ui/cockpitOrbitalView.test.mjs index fe3b1dd90..72130a9e8 100644 --- a/src/ui/cockpitOrbitalView.test.mjs +++ b/src/ui/cockpitOrbitalView.test.mjs @@ -1466,10 +1466,10 @@ test('the pre-load is throttled, at the level Cesium draws, and waits out a tilt } // 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. + // 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,566 m, so the imagery level is round(log2(135,566 / 75.3)) = 11. + // ≈ 135,570 m, so the imagery level is round(log2(135,570 / 76.4)) = 11. assert.equal(Number(requests[0].split('/')[0]), 11); }); 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..750b36e43 100644 --- a/src/ui/ownerFlag.js +++ b/src/ui/ownerFlag.js @@ -1,34 +1,75 @@ /** - * 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 column = index % OWNER_FLAG_SHEET.columns; + const row = Math.floor(index / OWNER_FLAG_SHEET.columns); + 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`, + }; +} /** An ISO 3166-1 alpha-2 code, upper case, or null. */ function isoCode(value) { @@ -56,55 +97,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..737ab018d 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,51 @@ 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)); + assert.equal( + png.readUInt32BE(16), + OWNER_FLAG_SHEET.columns * OWNER_FLAG_SHEET.cellWidthPx, + ); + assert.equal( + png.readUInt32BE(20), + OWNER_FLAG_SHEET.rows * OWNER_FLAG_SHEET.cellHeightPx, + ); +}); + +test('a flag is drawn from its cell of the sheet, at the size asked', () => { + 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`, + ); + assert.equal( + style.backgroundPosition, + `${-(index % columns) * 20}px ${-Math.floor(index / columns) * 15}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 +118,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..dec6ef8e6 --- /dev/null +++ b/src/ui/ownerFlagSheet.js @@ -0,0 +1,113 @@ +// 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, + 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/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..6f0bd76f9 100644 --- a/src/ui/styles/cockpitOrbital.css +++ b/src/ui/styles/cockpitOrbital.css @@ -194,11 +194,12 @@ } .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; } .cockpit-orbital-owner-badge { flex: none; diff --git a/src/ui/views/panels/CockpitOrbital.vue b/src/ui/views/panels/CockpitOrbital.vue index 47f19cbef..3e8083755 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,25 @@ 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; + if (!code || 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 +284,7 @@ const lensStyle = computed(() => {

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

- - + {{ shownMark.label }}

{{ orbital.name }}