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

+7 -3
View File
@@ -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
+2 -1
View File
@@ -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": {
+1 -1
View File
@@ -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",
+93
View File
@@ -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.`,
);
+1 -8
View File
@@ -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)}`,
);
+4 -1
View File
@@ -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"
]
+6 -1
View File
@@ -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",
+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>