mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 04:44:01 +00:00
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:
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
@@ -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
|
||||
|
||||
Generated
+2
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
@@ -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)}`,
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 } = {},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -622,6 +622,7 @@
|
||||
"DISABLING": "جارٍ الإيقاف",
|
||||
"Military Flights": "الرحلات العسكرية",
|
||||
"Satellites": "الأقمار الصناعية",
|
||||
"Place names": "أسماء الأماكن",
|
||||
"Space Missions (30d)": "المهام الفضائية (30 يومًا)",
|
||||
"Street Traffic": "حركة مرور الشوارع",
|
||||
"Radio": "الراديو",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1787,6 +1787,7 @@
|
||||
"Military Flights": "軍用機",
|
||||
"Earthquakes (24h)": "地震(24時間)",
|
||||
"Satellites": "衛星",
|
||||
"Place names": "地名",
|
||||
"Space Missions (30d)": "宇宙ミッション 30日",
|
||||
"Street Traffic": "道路交通",
|
||||
"CCTV": "CCTV",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -537,6 +537,7 @@
|
||||
"Military Flights": "Военные рейсы",
|
||||
"Earthquakes (24h)": "Землетрясения (24 ч)",
|
||||
"Satellites": "Спутники",
|
||||
"Place names": "Названия мест",
|
||||
"Space Missions (30d)": "Космические миссии (30 дн.)",
|
||||
"Street Traffic": "Дорожный трафик",
|
||||
"CCTV": "CCTV",
|
||||
|
||||
@@ -1879,6 +1879,7 @@
|
||||
"Military Flights": "Військові рейси",
|
||||
"Earthquakes (24h)": "Землетруси (24 год)",
|
||||
"Satellites": "Супутники",
|
||||
"Place names": "Назви місць",
|
||||
"Space Missions (30d)": "Космічні місії (30 дн)",
|
||||
"Street Traffic": "Дорожній рух",
|
||||
"CCTV": "CCTV",
|
||||
|
||||
@@ -1764,6 +1764,7 @@
|
||||
"Military Flights": "军用航班",
|
||||
"Earthquakes (24h)": "地震(24 小时)",
|
||||
"Satellites": "卫星",
|
||||
"Place names": "地名",
|
||||
"Space Missions (30d)": "太空任务(30 天)",
|
||||
"Street Traffic": "街道交通",
|
||||
"CCTV": "CCTV",
|
||||
|
||||
@@ -1754,6 +1754,7 @@
|
||||
"Military Flights": "軍用航班",
|
||||
"Earthquakes (24h)": "地震(24 小時)",
|
||||
"Satellites": "衛星",
|
||||
"Place names": "地名",
|
||||
"Space Missions (30d)": "太空任務(30 天)",
|
||||
"Street Traffic": "街道交通",
|
||||
"CCTV": "CCTV",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
]),
|
||||
});
|
||||
@@ -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 },
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user