From 5a6cf22be150165e34459ead56a954cdf3a054e9 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 8 Nov 2026 19:35:30 -0800 Subject: [PATCH] feat(cockpit): the owner flags load at startup from one generated 15 KB sheet; the loading status names the place packs while they load on first lookup --- docs/CURRENT-STATE.md | 10 +- package-lock.json | 3 +- package.json | 2 +- scripts/build-owner-flags.mjs | 93 +++++++++++ scripts/check-package-boundaries.mjs | 9 +- scripts/format-scope.json | 5 +- scripts/package-boundaries.json | 7 +- src/data/local_data/owner_flags/README.md | 17 ++ .../local_data/owner_flags/owner_flags.png | Bin 0 -> 15348 bytes src/data/placeBelow.js | 69 +++++++- src/data/placePacksLoading.test.mjs | 23 +++ src/i18n/locales/ar.json | 1 + src/i18n/locales/de.json | 1 + src/i18n/locales/es.json | 1 + src/i18n/locales/fr.json | 1 + src/i18n/locales/it.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/pt.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/uk.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/i18n/locales/zh-Hant.json | 1 + src/layers/satellites/classes.js | 8 + src/tooling/packageBoundaries.test.mjs | 46 ------ src/ui/applicationShell.js | 29 +++- src/ui/cockpitOrbital.test.mjs | 59 ++++++- src/ui/cockpitOrbitalSatellites.js | 3 + src/ui/cockpitOrbitalView.test.mjs | 6 +- src/ui/layerNames.js | 13 +- src/ui/ownerFlag.js | 129 +++++++++------ src/ui/ownerFlag.test.mjs | 154 +++++++++++------- src/ui/ownerFlagSheet.js | 113 +++++++++++++ .../shellFeedbackStatusLocalization.test.mjs | 11 ++ src/ui/styles/cockpitOrbital.css | 3 +- src/ui/views/panels/CockpitOrbital.vue | 36 ++-- 35 files changed, 643 insertions(+), 216 deletions(-) create mode 100644 scripts/build-owner-flags.mjs create mode 100644 src/data/local_data/owner_flags/README.md create mode 100644 src/data/local_data/owner_flags/owner_flags.png create mode 100644 src/data/placePacksLoading.test.mjs create mode 100644 src/ui/ownerFlagSheet.js 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 0000000000000000000000000000000000000000..fd1826a0e44129fa907d5c0a2eec8ca97253e485 GIT binary patch literal 15348 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 literal 0 HcmV?d00001 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 }}