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

This commit is contained in:
Sameh Khamis committed 2026-11-08 19:35:30 -08:00
1 parent 63aa45c520
commit 5a6cf22be1
35 files changed
+643 -216

No files matched your search

+17
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+62 -7
View File
@@ -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
+23
View File
@@ -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();
});
+1
View File
@@ -622,6 +622,7 @@
"DISABLING": "جارٍ الإيقاف",
"Military Flights": "الرحلات العسكرية",
"Satellites": "الأقمار الصناعية",
"Place names": "أسماء الأماكن",
"Space Missions (30d)": "المهام الفضائية (30 يومًا)",
"Street Traffic": "حركة مرور الشوارع",
"Radio": "الراديو",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -1787,6 +1787,7 @@
"Military Flights": "軍用機",
"Earthquakes (24h)": "地震(24時間)",
"Satellites": "衛星",
"Place names": "地名",
"Space Missions (30d)": "宇宙ミッション 30日",
"Street Traffic": "道路交通",
"CCTV": "CCTV",
+1
View File
@@ -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",
+1
View File
@@ -537,6 +537,7 @@
"Military Flights": "Военные рейсы",
"Earthquakes (24h)": "Землетрясения (24 ч)",
"Satellites": "Спутники",
"Place names": "Названия мест",
"Space Missions (30d)": "Космические миссии (30 дн.)",
"Street Traffic": "Дорожный трафик",
"CCTV": "CCTV",
+1
View File
@@ -1879,6 +1879,7 @@
"Military Flights": "Військові рейси",
"Earthquakes (24h)": "Землетруси (24 год)",
"Satellites": "Супутники",
"Place names": "Назви місць",
"Space Missions (30d)": "Космічні місії (30 дн)",
"Street Traffic": "Дорожній рух",
"CCTV": "CCTV",
+1
View File
@@ -1764,6 +1764,7 @@
"Military Flights": "军用航班",
"Earthquakes (24h)": "地震(24 小时)",
"Satellites": "卫星",
"Place names": "地名",
"Space Missions (30d)": "太空任务(30 天)",
"Street Traffic": "街道交通",
"CCTV": "CCTV",
+1
View File
@@ -1754,6 +1754,7 @@
"Military Flights": "軍用航班",
"Earthquakes (24h)": "地震(24 小時)",
"Satellites": "衛星",
"Place names": "地名",
"Space Missions (30d)": "太空任務(30 天)",
"Street Traffic": "街道交通",
"CCTV": "CCTV",
+8
View File
@@ -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.
-46
View File
@@ -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'),
'<svg xmlns="http://www.w3.org/2000/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}/`),
);
}
});
+28 -1
View File
@@ -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();
+55 -4
View File
@@ -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(
+3
View File
@@ -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?.();
+3 -3
View File
@@ -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);
});
+10 -3
View File
@@ -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]),
);
/**
+77 -52
View File
@@ -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 <img> 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<boolean>} 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<typeof ownerMark>} 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<string|null>}
*/
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;
}
+93 -61
View File
@@ -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 <img> 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);
});
+113
View File
@@ -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',
]),
});
@@ -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');
});
+2 -1
View File
@@ -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;
+13 -23
View File
@@ -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(() => {
<div class="cockpit-orbital-readout">
<p class="cockpit-orbital-kicker">{{ look.offset ? t('ORBITAL VIEW · OFF-NADIR') : t('ORBITAL VIEW · NADIR') }}</p>
<div class="cockpit-orbital-title">
<!-- i18n-ignore: @error is the image's load-failure event, not a status message. -->
<img v-if="shownMark?.kind === 'flag'" class="cockpit-orbital-flag" :src="shownMark.url" :alt="shownMark.label" :title="shownMark.label" width="20" height="15" @error="flagFailed = true" />
<span v-if="shownMark?.kind === 'flag'" class="cockpit-orbital-flag" role="img" :aria-label="shownMark.label" :title="shownMark.label" :style="shownMark.style"></span>
<span v-else-if="shownMark?.kind === 'badge'" class="cockpit-orbital-owner-badge" :title="shownMark.title">{{ shownMark.label }}</span>
<p class="cockpit-orbital-name">{{ orbital.name }}</p>
</div>