Merge pull request #1637 from bilawalsidhu/sameh/orbital-followups

Orbital cockpit follow-ups: sensor strip through zoom, one owner-flag sheet, place names in the loading status
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-08 20:21:29 -08:00
commit 027c9616a5
38 files changed
+943 -184

No files matched your search

@@ -0,0 +1,4 @@
- Keep the orbital cockpit's sensor strip steady through a zoom step, load the
satellite owner flags at startup from one generated 15 KB sheet, and name
"Place names" in the loading status while the place-name packs load on first
lookup (Sameh Khamis, @samehkhamis, #1637).
+11 -5
View File
@@ -1658,14 +1658,16 @@ the satellite's live altitude and the window's shape is still at least as
wide as that swath (`orbitalMaxZoom` in `src/cockpitOrbital.js`); when the
cap falls below the zoom shown, the view steps back to it. Unconfirmed
swaths add no steps, but are still drawn, read out and offered to SENSOR.
The sensor's swath is drawn to scale as a strip along the ground track with
The sensor's swath is drawn to scale as a strip along the ground track (its
width follows the view through a zoom step, not ahead of it) with
a SENSOR SWATH readout, and a SENSOR button zooms to exactly that width when
it lies between 1× and the cap. No strip is drawn for a geostationary view
(no ground track; its sensors image areas, not strips) or a SAR (it images
to one side of nadir, not under it); the readout stays. Tilted off nadir
the strip stays (view and swath widen alike), the readout scales as the swath
readout does (`SENSOR SWATH ≈ 191 KM`), and SENSOR, a nadir framing, is
hidden; tilted past the Earth's limb neither the sensor nor the swath reads. A swath is confirmed only on the operator's, an agency's (ESA's
hidden; tilted past the Earth's limb neither the sensor nor the swath
reads. A swath is confirmed only on the operator's, an agency's (ESA's
eoPortal included) or an academic page for its platform. From
16×, with flat imagery on the globe (not Google 3D tiles) and the view at
nadir, every 2 s the view asks the base imagery layer for the tiles it will
@@ -1784,15 +1786,19 @@ names it and the country in the reader's language through `Intl.DisplayNames`,
"Sichuan, China", and from 8× the most prominent populated place within half
the view's smaller side, so it is in the picture, "Sichuan, China · near
Chengdu", from a bundled pack of Natural Earth populated places loaded on
first use; else the smallest named sea, gulf, strait, channel, sound or bay;
first use (each pack loads on the first lookup that needs it, and the
top-centre loading status lists "Place names" until it arrives); else the
smallest named sea, gulf, strait, channel, sound or bay;
else the ocean; nothing in a coastline gap), at most every 2 s and only after
20 km of ground travel, or at once when a zoom step changes how much ground
the view shows. A division or populated-places pack that fails to load drops
only that part (the country still shows); a lookup that fails outright hides
the band rather than leave the last place up, and the next HUD beat looks
again. The readout card puts
the owner country's flag (an SVG from `flag-icons`, fetched only for the shown
country) or a text badge for an organisation before the name, the operator
the owner country's flag (drawn from one 15 KB sheet of every owner
country's flag, built from `flag-icons` by `scripts/build-owner-flags.mjs`
and loaded at startup, so a ride never waits for a flag; if the sheet will
not load the country code shows as a badge) or a text badge for an organisation before the name, the operator
under it, and a NORAD · COSPAR · TLE-epoch line, ending with the element
set's age (`<1 H OLD`, then whole hours, then whole days: the gap, either
side, between the epoch and the world-clock time the satellite is shown at), whose TLE button
+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",
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env node
/**
* Build the owner flag sheet the orbital Cockpit marks a satellite's owner
* country with (`src/ui/ownerFlag.js`): every flag in OWNER_COUNTRY_CODES
* (`src/layers/satellites/classes.js`), drawn from the flag-icons 4:3 SVGs
* at twice the readout's 20 × 15 px, in one PNG the app loads at startup.
*
* src/data/local_data/owner_flags/owner_flags.png the sheet
* src/ui/ownerFlagSheet.js its layout (generated)
*
* Flag i (in the sorted code list) sits at column i % COLUMNS, row
* floor(i / COLUMNS), each cell followed by a GUTTER of transparent pixels. A rerun with the same flag-icons and sharp versions
* reproduces both files.
*
* Usage: node scripts/build-owner-flags.mjs
*/
import { readFile, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as prettier from 'prettier';
import sharp from 'sharp';
import { OWNER_COUNTRY_CODES } from '../src/layers/satellites/classes.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(import.meta.url);
const FLAGS_DIR = path.join(
path.dirname(require.resolve('flag-icons/package.json')),
'flags/4x3',
);
const FLAG_ICONS_VERSION = require('flag-icons/package.json').version;
/** One flag's cell, CSS pixels × 2. */
export const CELL = Object.freeze({ width: 40, height: 30 });
/**
* Transparent sheet pixels after each flag, so a browser smoothing a scaled
* flag (a 125% display) never reaches into its neighbour.
*/
export const GUTTER = 2;
export const COLUMNS = 16;
const SHEET = path.join(
ROOT,
'src/data/local_data/owner_flags/owner_flags.png',
);
const LAYOUT = path.join(ROOT, 'src/ui/ownerFlagSheet.js');
async function flagCell(code) {
const svg = await readFile(path.join(FLAGS_DIR, `${code.toLowerCase()}.svg`));
return sharp(svg, { density: 300 })
.resize(CELL.width, CELL.height, { fit: 'fill' })
.png()
.toBuffer();
}
const codes = [...OWNER_COUNTRY_CODES];
const rows = Math.ceil(codes.length / COLUMNS);
const pitchX = CELL.width + GUTTER;
const pitchY = CELL.height + GUTTER;
const cells = await Promise.all(codes.map(flagCell));
const sheet = await sharp({
create: {
width: COLUMNS * pitchX,
height: rows * pitchY,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite(
cells.map((input, i) => ({
input,
left: (i % COLUMNS) * pitchX,
top: Math.floor(i / COLUMNS) * pitchY,
})),
)
.png({ compressionLevel: 9, palette: true, quality: 100, effort: 10 })
.toBuffer();
await writeFile(SHEET, sheet);
const layout = `// Generated by scripts/build-owner-flags.mjs from flag-icons ${FLAG_ICONS_VERSION}; do not edit.
/** The owner flag sheet's layout: flag i at column i % columns, row i / columns. */
export const OWNER_FLAG_SHEET = Object.freeze({
cellWidthPx: ${CELL.width},
cellHeightPx: ${CELL.height},
gutterPx: ${GUTTER},
columns: ${COLUMNS},
rows: ${rows},
codes: Object.freeze(${JSON.stringify(codes)}),
});
`;
await writeFile(
LAYOUT,
await prettier.format(layout, {
...(await prettier.resolveConfig(LAYOUT)),
filepath: LAYOUT,
}),
);
console.log(
`Wrote ${codes.length} flags (${COLUMNS} × ${rows}), ${(sheet.length / 1024).toFixed(1)} KB.`,
);
+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)}`,
);
+6 -1
View File
@@ -1363,5 +1363,10 @@
"src/layers/wildlifeSightings/cache.integration.test.mjs",
"src/layers/wildlifeSightings/density.test.mjs",
"src/layers/wildlifeSightings/selection.test.mjs",
"server/providers/inaturalist/proxy.fixture.mjs"
"server/providers/inaturalist/proxy.fixture.mjs",
"scripts/build-owner-flags.mjs",
"src/ui/ownerFlagSheet.js",
"src/data/placePacksLoading.test.mjs",
"src/ui/placePacksLoading.js",
"src/ui/placePacksLoading.test.mjs"
]
+8 -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,9 @@
"src/cockpitOrbitalSatellites.js",
"src/layers/installations/classLabel.js",
"src/data/placeBelow.js",
"src/ui/placePacksLoading.js",
"src/ui/ownerFlag.js",
"src/ui/ownerFlagSheet.js",
"src/layers/satellites/sensors.js",
"src/data/adminBoundaries.js",
"src/data/bundledJson.js",
@@ -2913,6 +2917,9 @@
"src/ui/cockpitOrbitalSatellites.js",
"src/cockpitOrbitalSatellites.js",
"src/data/placeBelow.js",
"src/ui/placePacksLoading.js",
"src/ui/ownerFlag.js",
"src/ui/ownerFlagSheet.js",
"src/ui/cockpitContext.js",
"src/ui/cockpitController.js",
"src/ui/cockpitDisplayPortal.js",
+18
View File
@@ -0,0 +1,18 @@
# Owner flags
`owner_flags.png` is one sheet of every owner country a satellite can be
marked with (`OWNER_COUNTRY_CODES` in `src/layers/satellites/classes.js`),
drawn at 40 × 30 px (twice the orbital readout's 20 × 15) in sorted code
order, 16 to a row, each followed by 2 transparent pixels so a scaled flag
never smooths into its neighbour. `src/ui/ownerFlag.js` loads it once at startup and draws
each flag from its cell; the layout is `src/ui/ownerFlagSheet.js`.
**Source:** the 4:3 SVGs of [flag-icons](https://github.com/lipis/flag-icons)
7.5.0 (a devDependency, used only to build the sheet).
**License:** flag-icons is MIT licensed (© Panayiotis Lipiridis). Country
flags themselves are not copyrightable designs in most jurisdictions.
**Regenerate:** `node scripts/build-owner-flags.mjs` (after adding an owner
country, or updating flag-icons). With the same flag-icons and sharp versions
a rerun reproduces both files byte for byte.
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+111 -8
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 (`placePacksStatus`); a country's division rings are decoded
* the first time a point falls inside it. A failed load is retried later
* (see `createRetryableLoader`). No network.
*/
@@ -46,6 +47,86 @@ const PACKS = {
),
};
/**
* The packs on their way now, and those that failed in the current load (or
* the last one): a load starts with no failures, so a retry that lands is
* not blamed for a pack that failed before. (A load that does not need the
* failed pack clears it too; the next lookup that needs it tries it again.)
*/
const loadingPacks = new Set();
const failedPacks = new Set();
// One lookup loads its packs one after another (countries and seas, then
// divisions, then places): the status holds them as one load until the
// lookup ends, rather than ending and starting a batch between packs.
let lookupsInFlight = 0;
let lookupLoaded = false;
const loadingListeners = new Set();
function notifyLoading() {
for (const listener of [...loadingListeners]) {
try {
listener();
} catch {
/* A status listener never breaks a lookup. */
}
}
}
/** Whether the status shows a load on its way. */
function loadingNow() {
return loadingPacks.size > 0 || (lookupLoaded && lookupsInFlight > 0);
}
/**
* A pack load that the loading status follows: on its way while it runs,
* and failed, if it fails, until the next load starts.
* @template T
* @param {string} name
* @param {() => Promise<T>} load
* @returns {() => Promise<T>}
*/
export function reportedPlacePackLoad(name, load) {
return async () => {
// A fresh load forgets earlier failures: it ends on what it loads.
if (!loadingNow()) failedPacks.clear();
if (lookupsInFlight > 0) lookupLoaded = true;
loadingPacks.add(name);
notifyLoading();
try {
return await load();
} catch (error) {
failedPacks.add(name);
throw error;
} finally {
loadingPacks.delete(name);
notifyLoading();
}
};
}
/**
* The place packs' state for the loading status. The packs load lazily, on
* the first lookup that needs them, so the status says so meanwhile, and
* says when one would not load.
* @returns {{loading: boolean, failed: boolean}}
*/
export function placePacksStatus() {
return {
loading: loadingNow(),
failed: failedPacks.size > 0,
};
}
/**
* Hear each start and end of a place pack load.
* @param {() => void} listener
* @returns {() => void} Stops listening.
*/
export function onPlacePacksLoadingChange(listener) {
loadingListeners.add(listener);
return () => loadingListeners.delete(listener);
}
/** Test seam: count pack loads (lazy-loading contract). */
export const placePackLoads = { divisions: 0, places: 0 };
@@ -106,7 +187,7 @@ function ringRank(ring) {
return (Math.abs(sum) / 2) * Math.cos((meanLat * Math.PI) / 180);
}
const loadPlaces = createRetryableLoader(async () => {
async function readPlaces() {
const [countries, marine] = await Promise.all([
loadBundledJson(PACKS.countries),
loadBundledJson(PACKS.marine),
@@ -142,10 +223,13 @@ const loadPlaces = createRetryableLoader(async () => {
});
}
return { land, water };
});
}
const loadPlaces = createRetryableLoader(
reportedPlacePackLoad('places', readPlaces),
);
/** Divisions grouped by country code; rings decoded per country on demand. */
const loadDivisions = createRetryableLoader(async () => {
async function readDivisions() {
placePackLoads.divisions += 1;
const pack = await loadBundledJson(PACKS.divisions);
const decimals = pack.meta?.decimals ?? 3;
@@ -157,7 +241,10 @@ const loadDivisions = createRetryableLoader(async () => {
byCountry.set(feature.iso2, list);
}
return { byCountry, decoded: new Map() };
});
}
const loadDivisions = createRetryableLoader(
reportedPlacePackLoad('divisions', readDivisions),
);
/** The first-level division holding a point inside country `code`, or null. */
async function divisionAt(code, lat, lon) {
@@ -179,7 +266,7 @@ async function divisionAt(code, lat, lon) {
return null;
}
const loadPopulatedPlaces = createRetryableLoader(async () => {
async function readPopulatedPlaces() {
placePackLoads.places += 1;
const pack = await loadBundledJson(PACKS.places);
return (pack.places || []).filter(
@@ -189,7 +276,10 @@ const loadPopulatedPlaces = createRetryableLoader(async () => {
Number.isFinite(row[1]) &&
Number.isFinite(row[2]),
);
});
}
const loadPopulatedPlaces = createRetryableLoader(
reportedPlacePackLoad('populated-places', readPopulatedPlaces),
);
/**
* The most prominent populated place within `radiusKm` of a point (lowest
@@ -251,7 +341,20 @@ async function orNull(lookup) {
* simplification) or it is invalid. Rejects only when the country and
* marine packs fail.
*/
export async function placeBelow(
export async function placeBelow(latitude, longitude, options) {
lookupsInFlight += 1;
try {
return await findPlaceBelow(latitude, longitude, options);
} finally {
lookupsInFlight -= 1;
if (lookupsInFlight === 0 && lookupLoaded) {
lookupLoaded = false;
notifyLoading();
}
}
}
async function findPlaceBelow(
latitude,
longitude,
{ nearKm = null, detail = DETAIL_LOOKUPS } = {},
+111
View File
@@ -0,0 +1,111 @@
// src/data/placePacksLoading.test.mjs — the loading status hears the place
// packs while they are on their way.
import test from 'node:test';
import assert from 'node:assert/strict';
test('a place pack load is reported from its start to its end', async () => {
// A module of its own, so no other test has loaded the packs yet.
const place = await import('./placeBelow.js?loading-status');
const heard = [];
const stop = place.onPlacePacksLoadingChange(() =>
heard.push(place.placePacksStatus().loading),
);
assert.equal(place.placePacksStatus().loading, false);
const lookup = place.nearPlace(48.86, 2.35, 50);
assert.equal(place.placePacksStatus().loading, true, 'on its way');
assert.equal(await lookup, 'Paris');
assert.equal(place.placePacksStatus().loading, false, 'loaded');
assert.deepEqual(heard, [true, false]);
// Loaded once: a later lookup is not reported again.
await place.nearPlace(48.86, 2.35, 50);
assert.deepEqual(heard, [true, false]);
stop();
});
test('a pack that would not load stays failed until it loads', async () => {
const place = await import('./placeBelow.js?failed-status');
let fail = true;
const load = place.reportedPlacePackLoad('test', async () => {
if (fail) throw new Error('offline');
return 1;
});
await assert.rejects(load(), /offline/);
assert.deepEqual(place.placePacksStatus(), { loading: false, failed: true });
fail = false;
const retry = load();
assert.deepEqual(
place.placePacksStatus(),
{ loading: true, failed: false },
'a retry is not a failure until it fails',
);
assert.equal(await retry, 1);
assert.deepEqual(place.placePacksStatus(), { loading: false, failed: false });
});
test('one lookup that loads several packs reads as one load', async () => {
const place = await import('./placeBelow.js?one-lookup');
const heard = [];
const stop = place.onPlacePacksLoadingChange(() => {
const { loading } = place.placePacksStatus();
if (heard.at(-1) !== loading) heard.push(loading);
});
// On land with a radius: countries and seas, then divisions, then places.
const found = await place.placeBelow(48.86, 2.35, { nearKm: 50 });
assert.equal(found.near, 'Paris');
assert.deepEqual(heard, [true, false], 'no gap between the packs');
stop();
});
/** Two stand-in packs (not real pack names) that fail while named in `fail`. */
function standInPacks(place, fail) {
const pack = (name) =>
place.reportedPlacePackLoad(name, async () => {
if (fail.has(name)) throw new Error('offline');
return name;
});
return { first: pack('stand-in-first'), second: pack('stand-in-second') };
}
/** The statuses one lookup that loads both stand-ins reports, start to end. */
async function lookupStatuses(place, { first, second }) {
const statuses = [];
const stop = place.onPlacePacksLoadingChange(() =>
statuses.push(place.placePacksStatus()),
);
await place.placeBelow(48.86, 2.35, {
detail: { division: async () => first(), near: async () => second() },
});
stop();
return statuses;
}
test('a load is not blamed for packs that failed in an earlier one', async () => {
const place = await import('./placeBelow.js?two-failed');
const fail = new Set(['stand-in-first', 'stand-in-second']);
const packs = standInPacks(place, fail);
await assert.rejects(packs.first());
await assert.rejects(packs.second());
assert.equal(place.placePacksStatus().failed, true);
fail.clear();
const statuses = await lookupStatuses(place, packs);
assert.deepEqual(statuses[0], { loading: true, failed: false });
assert.ok(
statuses.every((status) => status.failed === false),
'the earlier failures do not colour this load',
);
assert.deepEqual(place.placePacksStatus(), {
loading: false,
failed: false,
});
});
test('a pack that fails in a load keeps it failed when the next one lands', async () => {
const place = await import('./placeBelow.js?mixed');
const packs = standInPacks(place, new Set(['stand-in-first']));
const statuses = await lookupStatuses(place, packs);
assert.deepEqual(statuses.at(-1), { loading: false, failed: true });
assert.deepEqual(place.placePacksStatus(), {
loading: false,
failed: true,
});
});
+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.
+12 -1
View File
@@ -64,6 +64,9 @@ import { useLocationStore } from './stores/location.js';
import { useCockpitStore } from './stores/cockpit.js';
import { useGlobeActionsStore } from './stores/globeActions.js';
import { useRecordingStore } from './stores/recording.js';
import { onPlacePacksLoadingChange } from '../data/placeBelow.js';
import { withPlacePacks } from './placePacksLoading.js';
import { preloadOwnerFlags } from './ownerFlag.js';
/**
* Central UI orchestrator for the God's Eye View application.
@@ -158,8 +161,15 @@ export class StyleManager extends ShellFacade {
// Vue views share this runtime and its stores; dispose unmounts them.
this._ui = createUiRuntime({ pinia: applicationPinia() });
this._feedback = new ShellFeedback({
readLayers: () => this._dataManager?.getAll?.() || [],
readLayers: () => withPlacePacks(this._dataManager?.getAll?.() || []),
});
// The satellite owner flags load with the app, not on the first ride.
preloadOwnerFlags();
// The place names under a Cockpit load on its first lookup: the loading
// status names them meanwhile.
this._offPlacePacks = onPlacePacksLoadingChange(() =>
this._updateGlobalLoadingFeedback(),
);
this.viewer = viewer;
this.mapStackController = mapStackController;
this.placeSearch = placeSearch;
@@ -1825,6 +1835,7 @@ export class StyleManager extends ShellFacade {
this._wildlifeSightingCard?.destroy();
this._ui.destroy();
this._panelChrome.destroy();
this._offPlacePacks();
this._feedback.destroy();
this._displayBindings.destroy();
+61 -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,55 @@ test('the place below shows at the top once known', async (t) => {
assert.equal(document.querySelector('#cockpit-orbital-over'), null);
});
test('an owner country shows its flag from the sheet, or its code if the sheet will not load', async (t) => {
// The sheet loads through an Image; this one answers when told.
const images = [];
const RealImage = globalThis.Image;
globalThis.Image = class {
constructor() {
images.push(this);
}
};
t.after(() => {
globalThis.Image = RealImage;
});
const f = fixture(t);
f.store.publishOrbital({
...VIEW,
owner: { countryCode: 'US', label: 'USA' },
});
await nextTick();
assert.equal(f.section().querySelector('.cockpit-orbital-flag'), null);
assert.equal(images.length, 1, 'the sheet is asked for');
images[0].onerror();
await settle();
// No sheet: the code, so the owner never disappears.
assert.equal(f.text('.cockpit-orbital-owner-badge'), 'US');
// Another satellite of the same country asks again.
f.store.publishOrbital({ ...f.store.orbital, noradId: 43013 });
await nextTick();
assert.equal(images.length, 2, 'a new ride asks again');
images[1].onerror();
await settle();
// The next flag asks again; this time the sheet comes.
f.store.publishOrbital({
...f.store.orbital,
owner: { countryCode: 'FR', label: 'France' },
});
await nextTick();
assert.equal(images.length, 3);
images[2].onload();
await settle();
const flag = f.section().querySelector('.cockpit-orbital-flag');
assert.equal(flag.getAttribute('role'), 'img');
assert.equal(flag.getAttribute('aria-label'), 'France');
assert.equal(
flag.style.backgroundPosition,
ownerFlagStyle('FR').backgroundPosition,
);
assert.equal(f.section().querySelector('.cockpit-orbital-owner-badge'), null);
});
test('the readout names the owner, operator and element set', async (t) => {
const f = fixture(t);
f.store.publishOrbital({
@@ -198,16 +254,17 @@ test('the readout names the owner, operator and element set', async (t) => {
noradId: 25544,
cosparId: '1998-067A',
tle: ISS_TLE,
// Under node there is no flag glob, so a country shows as a badge.
owner: { countryCode: 'US', label: 'USA' },
// A country with no flag on the sheet shows as a badge, named in the
// reader's language.
owner: { countryCode: 'IS', label: 'Ísland' },
operator: 'NASA',
});
await nextTick();
assert.equal(f.text('.cockpit-orbital-name'), 'ISS (ZARYA)');
assert.equal(f.text('.cockpit-orbital-owner-badge'), 'USA');
assert.equal(f.text('.cockpit-orbital-owner-badge'), 'Ísland');
assert.equal(
f.section().querySelector('.cockpit-orbital-owner-badge').title,
'United States',
'Iceland',
);
assert.equal(f.text('.cockpit-orbital-operator'), 'NASA');
assert.equal(
+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?.();
+6 -1
View File
@@ -631,6 +631,11 @@ export function publishOrbitalView(info, transition = IDLE_TRANSITION) {
: canvasAspect(this.viewer);
const settledFov = fovForZoom(orbitalBaseFovRad(info.altitudeM), state.zoom);
const footprintKm = footprintWidthKm(info.altitudeM, settledFov, aspect);
// The strip is drawn over the picture, so it follows the view as a step
// zooms (republished every frame then), not the step it is heading for.
const shownKm = Number.isFinite(state.verticalFov)
? footprintWidthKm(info.altitudeM, state.verticalFov, aspect)
: footprintKm;
const maxZoom = orbitalMaxZoomFor.call(this, info);
const swathKm = sensorSwathKm(info);
const trackUp = Number.isFinite(info.groundTrackDeg);
@@ -687,7 +692,7 @@ export function publishOrbitalView(info, transition = IDLE_TRANSITION) {
// along, and a SAR images to one side of nadir, not under it.
stripFraction:
trackUp && info.sensor?.type !== 'SAR'
? sensorStripFraction(swathKm, footprintKm)
? sensorStripFraction(swathKm, shownKm)
: null,
zoom: sensorSwathZoom(info.altitudeM, swathKm, aspect, maxZoom),
},
+28 -11
View File
@@ -26,7 +26,6 @@ import {
} from '../cockpitOrbital.js';
import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js';
import { prefetchImageryAhead } from './cockpitOrbitalView.js';
import { imageryLevelForView } from '../cockpitOrbital.js';
import { claimPointer, releasePointer } from '../data/inputOwnership.js';
const ISS = Object.freeze({
@@ -1161,6 +1160,27 @@ function imageryGlobe(viewer, { requestImage } = {}) {
const WORLDVIEW_ON_TRACK = Object.freeze({ ...WORLDVIEW, groundTrackDeg: 52 });
test('the sensor strip follows the view through a zoom step, not ahead of it', async (t) => {
const { cockpit, store, clock } = await orbitAt(t, {
satellite: WORLDVIEW,
reducedMotion: false,
});
const strip = () => store.orbital.sensor.stripFraction;
const atOne = strip();
assert.ok(atOne > 0 && atOne < 1);
press(cockpit, '+');
cockpit.update();
near(strip(), atOne, 'the step starts at 1x, and so does the strip');
clock.advance(ORBITAL_ZOOM_TRANSITION_MS / 2);
cockpit.update();
const midway = strip();
clock.advance(ORBITAL_ZOOM_TRANSITION_MS + 1000);
cockpit.update();
const atTwo = strip();
assert.ok(atTwo > atOne * 1.5, 'twice the zoom, about twice the share');
assert.ok(midway > atOne && midway < atTwo, 'part-way through the step');
});
test('from 16x the imagery ahead of the ground track is pre-loaded', async (t) => {
const { cockpit, viewer, store, clock } = await orbitAt(t, {
satellite: WORLDVIEW_ON_TRACK,
@@ -1444,16 +1464,13 @@ test('the pre-load is throttled, at the level Cesium draws, and waits out a tilt
);
assert.equal(request.type, Cesium.RequestType.IMAGERY);
}
// The level Cesium's own selection takes for this view.
const fov = fovForZoom(orbitalBaseFovRad(WORLDVIEW.altitudeM), 16);
const metresPerPixel =
(WORLDVIEW.altitudeM * 2 * Math.tan(fov / 2)) /
viewer.scene.canvas.clientHeight;
const expected = imageryLevelForView(new Cesium.WebMercatorTilingScheme(), {
metresPerPixel,
latitudeDeg: WORLDVIEW.latitude,
});
assert.equal(Number(requests[0].split('/')[0]), expected);
// The level Cesium's own selection takes for this view, worked by hand:
// at 16x from 617 km the 900 px canvas is ~49.5 m a pixel. The ellipsoid
// terrain's level-zero error is 6378137 · 2π / 4 / 128 ≈ 78,272 m; it first
// meets Cesium's screen-space error of 2 px (≤ 99 m) at level 10, 76.4 m.
// A Web Mercator level-zero texel at 30° is 6378137 · 2π · cos 30° / 256
// ≈ 135,570 m, so the imagery level is round(log2(135,570 / 76.4)) = 11.
assert.equal(Number(requests[0].split('/')[0]), 11);
});
test('the TLE age is measured from the time the satellite is shown at', async (t) => {
+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]),
);
/**
+83 -52
View File
@@ -1,34 +1,81 @@
/**
* The owner mark beside a satellite's name: the owner country's flag (an SVG
* from flag-icons — Windows draws no flag emoji), or a short text badge for
* an organisation without one (ESA, EUMETSAT, "Multinational"). Each flag is
* its own lazily fetched asset; only the shown one ever loads.
* The owner mark beside a satellite's name: the owner country's flag, or a
* short text badge for an organisation without one (ESA, EUMETSAT,
* "Multinational"). Windows draws no flag emoji, so the flags are pictures:
* one sheet of every owner country's flag (`scripts/build-owner-flags.mjs`,
* from flag-icons), loaded once at startup (`preloadOwnerFlags`), so riding
* a satellite never waits for its flag.
*/
import { OWNER_FLAG_SHEET } from './ownerFlagSheet.js';
/** The flag sheet's URL. */
export const OWNER_FLAG_SHEET_URL = new URL(
'../data/local_data/owner_flags/owner_flags.png',
import.meta.url,
).href;
/** Each flag's place on the sheet, by ISO code. */
const SHEET_INDEX = new Map(
OWNER_FLAG_SHEET.codes.map((code, index) => [code, index]),
);
let sheetLoad = null;
/**
* Flag URL loaders by path; empty under node:test, which has no glob. The
* glob is eager so the bundle carries only the URL strings: a lazy glob would
* emit a JS stub chunk per flag (~270), two requests for each flag shown.
* The SVG itself is still fetched only when an <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 sheet = OWNER_FLAG_SHEET;
const column = index % sheet.columns;
const row = Math.floor(index / sheet.columns);
// A cell and its gutter, in CSS pixels at this size.
const pitchX =
(widthPx * (sheet.cellWidthPx + sheet.gutterPx)) / sheet.cellWidthPx;
const pitchY =
(heightPx * (sheet.cellHeightPx + sheet.gutterPx)) / sheet.cellHeightPx;
return {
backgroundImage: `url("${OWNER_FLAG_SHEET_URL}")`,
backgroundSize: `${sheet.columns * pitchX}px ${sheet.rows * pitchY}px`,
backgroundPosition: `${-column * pitchX}px ${-row * pitchY}px`,
};
}
/** An ISO 3166-1 alpha-2 code, upper case, or null. */
function isoCode(value) {
@@ -56,55 +103,39 @@ export function regionName(code, locale) {
/**
* How to mark a satellite's owner.
* @param {{countryCode: string|null, label: string|null}|null|undefined} owner
* @param {{locale?: string, loaders?: object}} [options]
* @param {{locale?: string, flags?: {has: (code: string) => boolean}}}
* [options] `flags`: the codes with a flag (the sheet's).
* @returns {{kind: 'flag', code: string, label: string}
* | {kind: 'badge', label: string, title: string}
* | null} A flag (with the country's localized name as its label), a text
* badge for an owner with no flag to show, or nothing when unknown.
*/
export function ownerMark(
owner,
{ locale = 'en', loaders = FLAG_LOADERS } = {},
) {
export function ownerMark(owner, { locale = 'en', flags = SHEET_INDEX } = {}) {
const code = isoCode(owner?.countryCode);
const label = String(owner?.label ?? '').trim();
if (code) {
const name = regionName(code, locale) || label || code;
if (loaders[FLAG_PATH(code)]) return { kind: 'flag', code, label: name };
if (flags.has(code)) return { kind: 'flag', code, label: name };
return { kind: 'badge', label: label || code, title: name };
}
return label ? { kind: 'badge', label, title: label } : null;
}
/**
* What the readout shows for an owner once its flag has loaded or failed: the
* flag, or a badge (the country code when the flag would not load, so the
* owner never disappears), or nothing for an unknown owner.
* What the readout shows for an owner once the flag sheet has loaded or
* failed: the flag, or a badge (the country code when the sheet would not
* load, so the owner never disappears), or nothing for an unknown owner.
* @param {ReturnType<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;
}
+99 -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,57 @@ test('a region name comes from Intl, never the bare code', () => {
assert.equal(regionName('XX', 'en'), null);
});
test('a flag that will not load falls back to its country code, never to nothing', () => {
const flag = { kind: 'flag', code: 'us', label: 'United States' };
test('the sheet holds every owner country a satellite can be marked with', () => {
assert.deepEqual([...OWNER_FLAG_SHEET.codes], [...OWNER_COUNTRY_CODES]);
assert.equal(
OWNER_FLAG_SHEET.rows,
Math.ceil(OWNER_FLAG_SHEET.codes.length / OWNER_FLAG_SHEET.columns),
);
// The PNG is as large as its layout says (width and height at bytes 16-23).
const png = readFileSync(new URL(OWNER_FLAG_SHEET_URL));
const { cellWidthPx, cellHeightPx, gutterPx } = OWNER_FLAG_SHEET;
assert.equal(
png.readUInt32BE(16),
OWNER_FLAG_SHEET.columns * (cellWidthPx + gutterPx),
);
assert.equal(
png.readUInt32BE(20),
OWNER_FLAG_SHEET.rows * (cellHeightPx + gutterPx),
);
});
test('a flag is drawn from its cell of the sheet, at the size asked', () => {
// 40 × 30 sheet cells with a 2 px gutter, drawn at 20 × 15: each cell and
// its gutter is 21 × 16 CSS pixels.
assert.equal(OWNER_FLAG_SHEET.cellWidthPx, 40);
assert.equal(OWNER_FLAG_SHEET.cellHeightPx, 30);
assert.equal(OWNER_FLAG_SHEET.gutterPx, 2);
const columns = OWNER_FLAG_SHEET.columns;
const index = OWNER_FLAG_SHEET.codes.indexOf('US');
const style = ownerFlagStyle('US');
assert.equal(style.backgroundImage, `url("${OWNER_FLAG_SHEET_URL}")`);
assert.equal(
style.backgroundSize,
`${columns * 21}px ${OWNER_FLAG_SHEET.rows * 16}px`,
);
assert.equal(
style.backgroundPosition,
`${-(index % columns) * 21}px ${-Math.floor(index / columns) * 16}px`,
);
// The first flag sits at the corner.
assert.equal(
ownerFlagStyle(OWNER_FLAG_SHEET.codes[0]).backgroundPosition,
'0px 0px',
);
assert.equal(ownerFlagStyle('XX'), null);
});
test('the flag shows once the sheet is ready; a failed sheet shows the code', () => {
const flag = { kind: 'flag', code: 'US', label: 'United States' };
assert.equal(shownOwnerMark(flag), null, 'nothing while it loads');
assert.deepEqual(shownOwnerMark(flag, { url: '/flags/us.svg' }), {
assert.deepEqual(shownOwnerMark(flag, { ready: true }), {
kind: 'flag',
url: '/flags/us.svg',
style: ownerFlagStyle('US'),
label: 'United States',
});
assert.deepEqual(shownOwnerMark(flag, { failed: true }), {
@@ -95,14 +124,23 @@ test('a flag that will not load falls back to its country code, never to nothing
assert.equal(shownOwnerMark(null), null);
});
test('flags ship as a map of URLs, not one JS chunk per flag', async () => {
const { readFileSync } = await import('node:fs');
const source = readFileSync(
new URL('./ownerFlag.js', import.meta.url),
'utf8',
);
// A lazy glob emits a stub chunk for each of ~270 flags; eager URLs ship
// only strings, and each SVG still loads when an <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);
});
+114
View File
@@ -0,0 +1,114 @@
// Generated by scripts/build-owner-flags.mjs from flag-icons 7.5.0; do not edit.
/** The owner flag sheet's layout: flag i at column i % columns, row i / columns. */
export const OWNER_FLAG_SHEET = Object.freeze({
cellWidthPx: 40,
cellHeightPx: 30,
gutterPx: 2,
columns: 16,
rows: 7,
codes: Object.freeze([
'AE',
'AM',
'AO',
'AR',
'AT',
'AU',
'AZ',
'BD',
'BE',
'BG',
'BH',
'BM',
'BO',
'BR',
'BT',
'BW',
'BY',
'CA',
'CH',
'CL',
'CN',
'CO',
'CR',
'CZ',
'DE',
'DJ',
'DK',
'DZ',
'EC',
'EE',
'EG',
'ES',
'ET',
'FI',
'FR',
'GB',
'GH',
'GR',
'GT',
'HR',
'HU',
'ID',
'IE',
'IL',
'IN',
'IQ',
'IR',
'IT',
'JO',
'JP',
'KE',
'KP',
'KR',
'KW',
'KZ',
'LA',
'LK',
'LT',
'LU',
'MA',
'MC',
'MD',
'ME',
'MM',
'MN',
'MU',
'MX',
'MY',
'NG',
'NL',
'NO',
'NP',
'NZ',
'PE',
'PH',
'PK',
'PL',
'PT',
'PY',
'QA',
'RO',
'RU',
'RW',
'SA',
'SB',
'SD',
'SE',
'SG',
'SI',
'SN',
'TH',
'TM',
'TN',
'TR',
'TW',
'UA',
'US',
'UY',
'VA',
'VE',
'VN',
'ZA',
'ZW',
]),
});
+30
View File
@@ -0,0 +1,30 @@
/**
* The place packs in the top-centre loading status: they load lazily, on the
* Cockpit's first place lookup (`src/data/placeBelow.js`), so while one is on
* its way the status names them beside the layers, and a pack that would not
* load makes the batch end LOAD FAILED.
*/
import { placePacksStatus } from '../data/placeBelow.js';
import { LOADING_NAME_KEYS } from './layerNames.js';
/**
* The data layers, with the place packs as one more entry while they load
* or after one would not load, so a batch they join ends LOAD FAILED rather
* than LOAD COMPLETE.
*/
export function withPlacePacks(
layers,
{ loading, failed } = placePacksStatus(),
) {
if (!loading && !failed) return layers;
return [
...layers,
{
id: 'place-names',
name: LOADING_NAME_KEYS['place-names'],
enabled: true,
stats: { loading, error: failed ? 'Place names did not load' : null },
},
];
}
+68
View File
@@ -0,0 +1,68 @@
// src/ui/placePacksLoading.test.mjs — the loading status names the place
// packs while they load, and a failed pack ends the batch as failed.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
aggregateLayerLoading,
createLoadingFeedbackState,
reduceLoadingFeedback,
} from '../loadingFeedback.js';
import { withPlacePacks } from './placePacksLoading.js';
const SATELLITES = Object.freeze({
id: 'satellites',
name: 'Satellites',
enabled: true,
stats: { count: 9000, lastUpdate: 1 },
});
function batch(states) {
let state = createLoadingFeedbackState();
let now = 0;
for (const status of states) {
now += 1000;
state = reduceLoadingFeedback(
state,
aggregateLayerLoading(withPlacePacks([SATELLITES], status)),
now,
);
}
return state;
}
test('nothing extra while the packs are idle', () => {
const layers = [SATELLITES];
assert.equal(
withPlacePacks(layers, { loading: false, failed: false }),
layers,
);
});
test('the packs are named while they load', () => {
const summary = aggregateLayerLoading(
withPlacePacks([SATELLITES], { loading: true, failed: false }),
);
assert.deepEqual(summary.activeIds, ['place-names']);
assert.equal(summary.active[0].label, 'Place names');
});
test('a load that lands ends complete; one that fails ends failed', () => {
const loading = { loading: true, failed: false };
assert.equal(
batch([loading, { loading: false, failed: false }]).terminal,
'complete',
);
assert.equal(
batch([loading, { loading: false, failed: true }]).terminal,
'error',
);
// A retry after a failure that lands ends complete.
assert.equal(
batch([
{ loading: false, failed: true },
loading,
{ loading: false, failed: false },
]).terminal,
'complete',
);
});
@@ -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');
});
+5 -1
View File
@@ -194,11 +194,15 @@
}
.cockpit-orbital-flag {
flex: none;
box-sizing: content-box;
width: 20px;
height: 15px;
border: 1px solid var(--orbital-faint);
border-radius: 1px;
object-fit: cover;
background-repeat: no-repeat;
/* Only the flag's own cell: the sheet's neighbours stay out from under
the border. */
background-clip: content-box;
}
.cockpit-orbital-owner-badge {
flex: none;
+20 -25
View File
@@ -8,8 +8,8 @@ import {
tleAgeParts,
} from '../../cockpitPresentation.js';
import {
loadFlagUrl,
ownerMark,
preloadOwnerFlags,
regionName,
shownOwnerMark,
} from '../../ownerFlag.js';
@@ -189,34 +189,30 @@ const placeText = computed(() => {
const mark = computed(() =>
ownerMark(orbital.value?.owner, { locale: getLocale() }),
);
const flagUrl = ref(null);
// A flag that would not load (offline, a stale chunk after a deploy, a
// missing file) falls back to the code as a badge, never to no owner at all.
const flagFailed = ref(false);
let flagRequest = 0;
// The flag sheet loads at startup; a sheet that would not load (offline, a
// stale asset after a deploy) is asked again for the next flag, and until it
// comes the code shows as a badge, never no owner at all.
const flagsReady = ref(false);
const flagsFailed = ref(false);
watch(
() => (mark.value?.kind === 'flag' ? mark.value.code : null),
async (code) => {
const request = ++flagRequest;
flagUrl.value = null;
flagFailed.value = false;
if (!code) return;
let url = null;
try {
url = await loadFlagUrl(code);
} catch {
url = null;
}
if (request !== flagRequest) return;
flagUrl.value = url;
flagFailed.value = !url;
// Keyed on the satellite too: after a failed load, the next ride asks
// again even when its owner is the same country.
() =>
mark.value?.kind === 'flag'
? `${mark.value.code}:${orbital.value?.noradId ?? ''}`
: null,
async (key) => {
if (!key || flagsReady.value) return;
const ready = await preloadOwnerFlags();
flagsReady.value = ready;
flagsFailed.value = !ready;
},
{ immediate: true },
);
const shownMark = computed(() =>
shownOwnerMark(mark.value, {
url: flagUrl.value,
failed: flagFailed.value,
ready: flagsReady.value,
failed: flagsFailed.value,
}),
);
@@ -293,8 +289,7 @@ const lensStyle = computed(() => {
<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>