mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +00:00
feat(cockpit): the owner flags load at startup from one generated 15 KB sheet; the loading status names the place packs while they load on first lookup
This commit is contained in:
1 parent
63aa45c520
commit
5a6cf22be1
35 files changed
+643
-216
No files matched your search
@@ -1786,15 +1786,19 @@ names it and the country in the reader's language through `Intl.DisplayNames`,
|
|||||||
"Sichuan, China", and from 8× the most prominent populated place within half
|
"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
|
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
|
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
|
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
|
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
|
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
|
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
|
the band rather than leave the last place up, and the next HUD beat looks
|
||||||
again. The readout card puts
|
again. The readout card puts
|
||||||
the owner country's flag (an SVG from `flag-icons`, fetched only for the shown
|
the owner country's flag (drawn from one 15 KB sheet of every owner
|
||||||
country) or a text badge for an organisation before the name, the operator
|
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
|
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
|
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
|
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",
|
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
|
||||||
"cesium": "^1.124.0",
|
"cesium": "^1.124.0",
|
||||||
"egm96-universal": "^1.1.1",
|
"egm96-universal": "^1.1.1",
|
||||||
"flag-icons": "7.5.0",
|
|
||||||
"hls.js": "^1.7.3",
|
"hls.js": "^1.7.3",
|
||||||
"mapillary-js": "^4.1.2",
|
"mapillary-js": "^4.1.2",
|
||||||
"mgrs": "^2.1.0",
|
"mgrs": "^2.1.0",
|
||||||
@@ -29,6 +28,7 @@
|
|||||||
"@happy-dom/global-registrator": "^20.14.6",
|
"@happy-dom/global-registrator": "^20.14.6",
|
||||||
"@vitejs/plugin-vue": "^5.2.4",
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
"@vue/test-utils": "^2.5.1",
|
"@vue/test-utils": "^2.5.1",
|
||||||
|
"flag-icons": "7.5.0",
|
||||||
"happy-dom": "^20.14.6",
|
"happy-dom": "^20.14.6",
|
||||||
"prettier": "3.9.6",
|
"prettier": "3.9.6",
|
||||||
"puppeteer": "^25.16.0",
|
"puppeteer": "^25.16.0",
|
||||||
@@ -2416,6 +2416,7 @@
|
|||||||
"version": "7.5.0",
|
"version": "7.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
|
||||||
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
|
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fresh": {
|
"node_modules/fresh": {
|
||||||
|
|||||||
+1
-1
@@ -63,7 +63,6 @@
|
|||||||
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
|
"@meri-imperiumi/eccodes-wasm": "^2.48.2",
|
||||||
"cesium": "^1.124.0",
|
"cesium": "^1.124.0",
|
||||||
"egm96-universal": "^1.1.1",
|
"egm96-universal": "^1.1.1",
|
||||||
"flag-icons": "7.5.0",
|
|
||||||
"hls.js": "^1.7.3",
|
"hls.js": "^1.7.3",
|
||||||
"mapillary-js": "^4.1.2",
|
"mapillary-js": "^4.1.2",
|
||||||
"mgrs": "^2.1.0",
|
"mgrs": "^2.1.0",
|
||||||
@@ -77,6 +76,7 @@
|
|||||||
"@happy-dom/global-registrator": "^20.14.6",
|
"@happy-dom/global-registrator": "^20.14.6",
|
||||||
"@vitejs/plugin-vue": "^5.2.4",
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
"@vue/test-utils": "^2.5.1",
|
"@vue/test-utils": "^2.5.1",
|
||||||
|
"flag-icons": "7.5.0",
|
||||||
"happy-dom": "^20.14.6",
|
"happy-dom": "^20.14.6",
|
||||||
"prettier": "3.9.6",
|
"prettier": "3.9.6",
|
||||||
"puppeteer": "^25.16.0",
|
"puppeteer": "^25.16.0",
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Build the owner flag sheet the orbital Cockpit marks a satellite's owner
|
||||||
|
* country with (`src/ui/ownerFlag.js`): every flag in OWNER_COUNTRY_CODES
|
||||||
|
* (`src/layers/satellites/classes.js`), drawn from the flag-icons 4:3 SVGs
|
||||||
|
* at twice the readout's 20 × 15 px, in one PNG the app loads at startup.
|
||||||
|
*
|
||||||
|
* src/data/local_data/owner_flags/owner_flags.png the sheet
|
||||||
|
* src/ui/ownerFlagSheet.js its layout (generated)
|
||||||
|
*
|
||||||
|
* Flag i (in the sorted code list) sits at column i % COLUMNS, row
|
||||||
|
* floor(i / COLUMNS). A rerun with the same flag-icons and sharp versions
|
||||||
|
* reproduces both files.
|
||||||
|
*
|
||||||
|
* Usage: node scripts/build-owner-flags.mjs
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import * as prettier from 'prettier';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { OWNER_COUNTRY_CODES } from '../src/layers/satellites/classes.js';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const FLAGS_DIR = path.join(
|
||||||
|
path.dirname(require.resolve('flag-icons/package.json')),
|
||||||
|
'flags/4x3',
|
||||||
|
);
|
||||||
|
const FLAG_ICONS_VERSION = require('flag-icons/package.json').version;
|
||||||
|
|
||||||
|
/** One flag's cell, CSS pixels × 2. */
|
||||||
|
export const CELL = Object.freeze({ width: 40, height: 30 });
|
||||||
|
export const COLUMNS = 16;
|
||||||
|
|
||||||
|
const SHEET = path.join(
|
||||||
|
ROOT,
|
||||||
|
'src/data/local_data/owner_flags/owner_flags.png',
|
||||||
|
);
|
||||||
|
const LAYOUT = path.join(ROOT, 'src/ui/ownerFlagSheet.js');
|
||||||
|
|
||||||
|
async function flagCell(code) {
|
||||||
|
const svg = await readFile(path.join(FLAGS_DIR, `${code.toLowerCase()}.svg`));
|
||||||
|
return sharp(svg, { density: 300 })
|
||||||
|
.resize(CELL.width, CELL.height, { fit: 'fill' })
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
const codes = [...OWNER_COUNTRY_CODES];
|
||||||
|
const rows = Math.ceil(codes.length / COLUMNS);
|
||||||
|
const cells = await Promise.all(codes.map(flagCell));
|
||||||
|
const sheet = await sharp({
|
||||||
|
create: {
|
||||||
|
width: COLUMNS * CELL.width,
|
||||||
|
height: rows * CELL.height,
|
||||||
|
channels: 4,
|
||||||
|
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.composite(
|
||||||
|
cells.map((input, i) => ({
|
||||||
|
input,
|
||||||
|
left: (i % COLUMNS) * CELL.width,
|
||||||
|
top: Math.floor(i / COLUMNS) * CELL.height,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.png({ compressionLevel: 9, palette: true, quality: 100, effort: 10 })
|
||||||
|
.toBuffer();
|
||||||
|
await writeFile(SHEET, sheet);
|
||||||
|
|
||||||
|
const layout = `// Generated by scripts/build-owner-flags.mjs from flag-icons ${FLAG_ICONS_VERSION}; do not edit.
|
||||||
|
/** The owner flag sheet's layout: flag i at column i % columns, row i / columns. */
|
||||||
|
export const OWNER_FLAG_SHEET = Object.freeze({
|
||||||
|
cellWidthPx: ${CELL.width},
|
||||||
|
cellHeightPx: ${CELL.height},
|
||||||
|
columns: ${COLUMNS},
|
||||||
|
rows: ${rows},
|
||||||
|
codes: Object.freeze(${JSON.stringify(codes)}),
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
await writeFile(
|
||||||
|
LAYOUT,
|
||||||
|
await prettier.format(layout, {
|
||||||
|
...(await prettier.resolveConfig(LAYOUT)),
|
||||||
|
filepath: LAYOUT,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`Wrote ${codes.length} flags (${COLUMNS} × ${rows}), ${(sheet.length / 1024).toFixed(1)} KB.`,
|
||||||
|
);
|
||||||
@@ -109,14 +109,7 @@ export async function checkPackageBoundaries(root) {
|
|||||||
// template blocks belong to the component file itself.
|
// template blocks belong to the component file itself.
|
||||||
if (info.id.startsWith('\0')) return;
|
if (info.id.startsWith('\0')) return;
|
||||||
const id = info.id.split('?')[0];
|
const id = info.id.split('?')[0];
|
||||||
// A declared dependency also owns the asset files it ships (a
|
if (!allowed.has(id)) {
|
||||||
// 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) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`,
|
`Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1363,5 +1363,8 @@
|
|||||||
"src/layers/wildlifeSightings/cache.integration.test.mjs",
|
"src/layers/wildlifeSightings/cache.integration.test.mjs",
|
||||||
"src/layers/wildlifeSightings/density.test.mjs",
|
"src/layers/wildlifeSightings/density.test.mjs",
|
||||||
"src/layers/wildlifeSightings/selection.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"
|
||||||
]
|
]
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"src/ui/views/panels/CockpitRadar.vue",
|
"src/ui/views/panels/CockpitRadar.vue",
|
||||||
"src/ui/views/panels/CockpitOrbital.vue",
|
"src/ui/views/panels/CockpitOrbital.vue",
|
||||||
"src/ui/ownerFlag.js",
|
"src/ui/ownerFlag.js",
|
||||||
|
"src/ui/ownerFlagSheet.js",
|
||||||
"src/ui/cockpitPresentation.js",
|
"src/ui/cockpitPresentation.js",
|
||||||
"src/data/headingDegrees.js",
|
"src/data/headingDegrees.js",
|
||||||
"src/data/distanceFormat.js",
|
"src/data/distanceFormat.js",
|
||||||
@@ -164,7 +165,7 @@
|
|||||||
"src/ui/views/panels/WorldTimeline.vue",
|
"src/ui/views/panels/WorldTimeline.vue",
|
||||||
"src/ui/surfaceKeyboard.js"
|
"src/ui/surfaceKeyboard.js"
|
||||||
],
|
],
|
||||||
"external": ["vue", "pinia", "vue-i18n", "flag-icons"]
|
"external": ["vue", "pinia", "vue-i18n"]
|
||||||
},
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"exports": ["./time"],
|
"exports": ["./time"],
|
||||||
@@ -1068,6 +1069,8 @@
|
|||||||
"src/cockpitOrbitalSatellites.js",
|
"src/cockpitOrbitalSatellites.js",
|
||||||
"src/layers/installations/classLabel.js",
|
"src/layers/installations/classLabel.js",
|
||||||
"src/data/placeBelow.js",
|
"src/data/placeBelow.js",
|
||||||
|
"src/ui/ownerFlag.js",
|
||||||
|
"src/ui/ownerFlagSheet.js",
|
||||||
"src/layers/satellites/sensors.js",
|
"src/layers/satellites/sensors.js",
|
||||||
"src/data/adminBoundaries.js",
|
"src/data/adminBoundaries.js",
|
||||||
"src/data/bundledJson.js",
|
"src/data/bundledJson.js",
|
||||||
@@ -2913,6 +2916,8 @@
|
|||||||
"src/ui/cockpitOrbitalSatellites.js",
|
"src/ui/cockpitOrbitalSatellites.js",
|
||||||
"src/cockpitOrbitalSatellites.js",
|
"src/cockpitOrbitalSatellites.js",
|
||||||
"src/data/placeBelow.js",
|
"src/data/placeBelow.js",
|
||||||
|
"src/ui/ownerFlag.js",
|
||||||
|
"src/ui/ownerFlagSheet.js",
|
||||||
"src/ui/cockpitContext.js",
|
"src/ui/cockpitContext.js",
|
||||||
"src/ui/cockpitController.js",
|
"src/ui/cockpitController.js",
|
||||||
"src/ui/cockpitDisplayPortal.js",
|
"src/ui/cockpitDisplayPortal.js",
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Owner flags
|
||||||
|
|
||||||
|
`owner_flags.png` is one sheet of every owner country a satellite can be
|
||||||
|
marked with (`OWNER_COUNTRY_CODES` in `src/layers/satellites/classes.js`),
|
||||||
|
drawn at 40 × 30 px (twice the orbital readout's 20 × 15) in sorted code
|
||||||
|
order, 16 to a row. `src/ui/ownerFlag.js` loads it once at startup and draws
|
||||||
|
each flag from its cell; the layout is `src/ui/ownerFlagSheet.js`.
|
||||||
|
|
||||||
|
**Source:** the 4:3 SVGs of [flag-icons](https://github.com/lipis/flag-icons)
|
||||||
|
7.5.0 (a devDependency, used only to build the sheet).
|
||||||
|
|
||||||
|
**License:** flag-icons is MIT licensed (© Panayiotis Lipiridis). Country
|
||||||
|
flags themselves are not copyrightable designs in most jurisdictions.
|
||||||
|
|
||||||
|
**Regenerate:** `node scripts/build-owner-flags.mjs` (after adding an owner
|
||||||
|
country, or updating flag-icons). With the same flag-icons and sharp versions
|
||||||
|
a rerun reproduces both files byte for byte.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
+62
-7
@@ -10,7 +10,8 @@
|
|||||||
*
|
*
|
||||||
* PURE data module: no Cesium, node-testable. Each pack loads lazily on the
|
* 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 lookup that needs it (divisions on the first land hit, places on the
|
||||||
* first radius) and is indexed once; a country's division rings are decoded
|
* first radius) and is indexed once, and the loading status names them while
|
||||||
|
* one is on its way (`placePacksLoading`); a country's division rings are decoded
|
||||||
* the first time a point falls inside it. A failed load is retried later
|
* the first time a point falls inside it. A failed load is retried later
|
||||||
* (see `createRetryableLoader`). No network.
|
* (see `createRetryableLoader`). No network.
|
||||||
*/
|
*/
|
||||||
@@ -46,6 +47,53 @@ const PACKS = {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The packs on their way now, for the loading status. */
|
||||||
|
const loadingPacks = new Set();
|
||||||
|
const loadingListeners = new Set();
|
||||||
|
|
||||||
|
function notifyLoading() {
|
||||||
|
for (const listener of [...loadingListeners]) {
|
||||||
|
try {
|
||||||
|
listener();
|
||||||
|
} catch {
|
||||||
|
/* A status listener never breaks a lookup. */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pack load that the loading status counts while it runs. */
|
||||||
|
function reportedLoad(name, load) {
|
||||||
|
return async () => {
|
||||||
|
loadingPacks.add(name);
|
||||||
|
notifyLoading();
|
||||||
|
try {
|
||||||
|
return await load();
|
||||||
|
} finally {
|
||||||
|
loadingPacks.delete(name);
|
||||||
|
notifyLoading();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a place pack is loading now. The packs load lazily, on the first
|
||||||
|
* lookup that needs them, so the loading status says so meanwhile.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function placePacksLoading() {
|
||||||
|
return loadingPacks.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hear each start and end of a place pack load.
|
||||||
|
* @param {() => void} listener
|
||||||
|
* @returns {() => void} Stops listening.
|
||||||
|
*/
|
||||||
|
export function onPlacePacksLoadingChange(listener) {
|
||||||
|
loadingListeners.add(listener);
|
||||||
|
return () => loadingListeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
/** Test seam: count pack loads (lazy-loading contract). */
|
/** Test seam: count pack loads (lazy-loading contract). */
|
||||||
export const placePackLoads = { divisions: 0, places: 0 };
|
export const placePackLoads = { divisions: 0, places: 0 };
|
||||||
|
|
||||||
@@ -106,7 +154,7 @@ function ringRank(ring) {
|
|||||||
return (Math.abs(sum) / 2) * Math.cos((meanLat * Math.PI) / 180);
|
return (Math.abs(sum) / 2) * Math.cos((meanLat * Math.PI) / 180);
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadPlaces = createRetryableLoader(async () => {
|
async function readPlaces() {
|
||||||
const [countries, marine] = await Promise.all([
|
const [countries, marine] = await Promise.all([
|
||||||
loadBundledJson(PACKS.countries),
|
loadBundledJson(PACKS.countries),
|
||||||
loadBundledJson(PACKS.marine),
|
loadBundledJson(PACKS.marine),
|
||||||
@@ -142,10 +190,11 @@ const loadPlaces = createRetryableLoader(async () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { land, water };
|
return { land, water };
|
||||||
});
|
}
|
||||||
|
const loadPlaces = createRetryableLoader(reportedLoad('places', readPlaces));
|
||||||
|
|
||||||
/** Divisions grouped by country code; rings decoded per country on demand. */
|
/** Divisions grouped by country code; rings decoded per country on demand. */
|
||||||
const loadDivisions = createRetryableLoader(async () => {
|
async function readDivisions() {
|
||||||
placePackLoads.divisions += 1;
|
placePackLoads.divisions += 1;
|
||||||
const pack = await loadBundledJson(PACKS.divisions);
|
const pack = await loadBundledJson(PACKS.divisions);
|
||||||
const decimals = pack.meta?.decimals ?? 3;
|
const decimals = pack.meta?.decimals ?? 3;
|
||||||
@@ -157,7 +206,10 @@ const loadDivisions = createRetryableLoader(async () => {
|
|||||||
byCountry.set(feature.iso2, list);
|
byCountry.set(feature.iso2, list);
|
||||||
}
|
}
|
||||||
return { byCountry, decoded: new Map() };
|
return { byCountry, decoded: new Map() };
|
||||||
});
|
}
|
||||||
|
const loadDivisions = createRetryableLoader(
|
||||||
|
reportedLoad('divisions', readDivisions),
|
||||||
|
);
|
||||||
|
|
||||||
/** The first-level division holding a point inside country `code`, or null. */
|
/** The first-level division holding a point inside country `code`, or null. */
|
||||||
async function divisionAt(code, lat, lon) {
|
async function divisionAt(code, lat, lon) {
|
||||||
@@ -179,7 +231,7 @@ async function divisionAt(code, lat, lon) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadPopulatedPlaces = createRetryableLoader(async () => {
|
async function readPopulatedPlaces() {
|
||||||
placePackLoads.places += 1;
|
placePackLoads.places += 1;
|
||||||
const pack = await loadBundledJson(PACKS.places);
|
const pack = await loadBundledJson(PACKS.places);
|
||||||
return (pack.places || []).filter(
|
return (pack.places || []).filter(
|
||||||
@@ -189,7 +241,10 @@ const loadPopulatedPlaces = createRetryableLoader(async () => {
|
|||||||
Number.isFinite(row[1]) &&
|
Number.isFinite(row[1]) &&
|
||||||
Number.isFinite(row[2]),
|
Number.isFinite(row[2]),
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
const loadPopulatedPlaces = createRetryableLoader(
|
||||||
|
reportedLoad('populated-places', readPopulatedPlaces),
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The most prominent populated place within `radiusKm` of a point (lowest
|
* The most prominent populated place within `radiusKm` of a point (lowest
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// src/data/placePacksLoading.test.mjs — the loading status hears the place
|
||||||
|
// packs while they are on their way.
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
test('a place pack load is reported from its start to its end', async () => {
|
||||||
|
// A module of its own, so no other test has loaded the packs yet.
|
||||||
|
const place = await import('./placeBelow.js?loading-status');
|
||||||
|
const heard = [];
|
||||||
|
const stop = place.onPlacePacksLoadingChange(() =>
|
||||||
|
heard.push(place.placePacksLoading()),
|
||||||
|
);
|
||||||
|
assert.equal(place.placePacksLoading(), false);
|
||||||
|
const lookup = place.nearPlace(48.86, 2.35, 50);
|
||||||
|
assert.equal(place.placePacksLoading(), true, 'on its way');
|
||||||
|
assert.equal(await lookup, 'Paris');
|
||||||
|
assert.equal(place.placePacksLoading(), false, 'loaded');
|
||||||
|
assert.deepEqual(heard, [true, false]);
|
||||||
|
// Loaded once: a later lookup is not reported again.
|
||||||
|
await place.nearPlace(48.86, 2.35, 50);
|
||||||
|
assert.deepEqual(heard, [true, false]);
|
||||||
|
stop();
|
||||||
|
});
|
||||||
@@ -622,6 +622,7 @@
|
|||||||
"DISABLING": "جارٍ الإيقاف",
|
"DISABLING": "جارٍ الإيقاف",
|
||||||
"Military Flights": "الرحلات العسكرية",
|
"Military Flights": "الرحلات العسكرية",
|
||||||
"Satellites": "الأقمار الصناعية",
|
"Satellites": "الأقمار الصناعية",
|
||||||
|
"Place names": "أسماء الأماكن",
|
||||||
"Space Missions (30d)": "المهام الفضائية (30 يومًا)",
|
"Space Missions (30d)": "المهام الفضائية (30 يومًا)",
|
||||||
"Street Traffic": "حركة مرور الشوارع",
|
"Street Traffic": "حركة مرور الشوارع",
|
||||||
"Radio": "الراديو",
|
"Radio": "الراديو",
|
||||||
|
|||||||
@@ -544,6 +544,7 @@
|
|||||||
"Military Flights": "Militärflüge",
|
"Military Flights": "Militärflüge",
|
||||||
"Earthquakes (24h)": "Erdbeben (24 h)",
|
"Earthquakes (24h)": "Erdbeben (24 h)",
|
||||||
"Satellites": "Satelliten",
|
"Satellites": "Satelliten",
|
||||||
|
"Place names": "Ortsnamen",
|
||||||
"Space Missions (30d)": "Raummissionen (30 T)",
|
"Space Missions (30d)": "Raummissionen (30 T)",
|
||||||
"Street Traffic": "Straßenverkehr",
|
"Street Traffic": "Straßenverkehr",
|
||||||
"CCTV": "CCTV",
|
"CCTV": "CCTV",
|
||||||
|
|||||||
@@ -507,6 +507,7 @@
|
|||||||
"DISABLING": "APAGANDO",
|
"DISABLING": "APAGANDO",
|
||||||
"Military Flights": "Vuelos militares",
|
"Military Flights": "Vuelos militares",
|
||||||
"Satellites": "Satélites",
|
"Satellites": "Satélites",
|
||||||
|
"Place names": "Nombres de lugares",
|
||||||
"Space Missions (30d)": "Misiones espaciales (30 d)",
|
"Space Missions (30d)": "Misiones espaciales (30 d)",
|
||||||
"Street Traffic": "Tráfico urbano",
|
"Street Traffic": "Tráfico urbano",
|
||||||
"Radio": "Radio",
|
"Radio": "Radio",
|
||||||
|
|||||||
@@ -1849,6 +1849,7 @@
|
|||||||
"Military Flights": "Vols militaires",
|
"Military Flights": "Vols militaires",
|
||||||
"Earthquakes (24h)": "Séismes (24 h)",
|
"Earthquakes (24h)": "Séismes (24 h)",
|
||||||
"Satellites": "Satellites",
|
"Satellites": "Satellites",
|
||||||
|
"Place names": "Noms de lieux",
|
||||||
"Space Missions (30d)": "Missions spatiales (30 j)",
|
"Space Missions (30d)": "Missions spatiales (30 j)",
|
||||||
"Street Traffic": "Trafic routier",
|
"Street Traffic": "Trafic routier",
|
||||||
"CCTV": "CCTV",
|
"CCTV": "CCTV",
|
||||||
|
|||||||
@@ -1849,6 +1849,7 @@
|
|||||||
"Military Flights": "Voli militari",
|
"Military Flights": "Voli militari",
|
||||||
"Earthquakes (24h)": "Terremoti (24 h)",
|
"Earthquakes (24h)": "Terremoti (24 h)",
|
||||||
"Satellites": "Satelliti",
|
"Satellites": "Satelliti",
|
||||||
|
"Place names": "Nomi dei luoghi",
|
||||||
"Space Missions (30d)": "Missioni spaziali (30 g)",
|
"Space Missions (30d)": "Missioni spaziali (30 g)",
|
||||||
"Street Traffic": "Traffico stradale",
|
"Street Traffic": "Traffico stradale",
|
||||||
"CCTV": "CCTV",
|
"CCTV": "CCTV",
|
||||||
|
|||||||
@@ -1787,6 +1787,7 @@
|
|||||||
"Military Flights": "軍用機",
|
"Military Flights": "軍用機",
|
||||||
"Earthquakes (24h)": "地震(24時間)",
|
"Earthquakes (24h)": "地震(24時間)",
|
||||||
"Satellites": "衛星",
|
"Satellites": "衛星",
|
||||||
|
"Place names": "地名",
|
||||||
"Space Missions (30d)": "宇宙ミッション 30日",
|
"Space Missions (30d)": "宇宙ミッション 30日",
|
||||||
"Street Traffic": "道路交通",
|
"Street Traffic": "道路交通",
|
||||||
"CCTV": "CCTV",
|
"CCTV": "CCTV",
|
||||||
|
|||||||
@@ -1849,6 +1849,7 @@
|
|||||||
"Military Flights": "Voos militares",
|
"Military Flights": "Voos militares",
|
||||||
"Earthquakes (24h)": "Terremotos (24 h)",
|
"Earthquakes (24h)": "Terremotos (24 h)",
|
||||||
"Satellites": "Satélites",
|
"Satellites": "Satélites",
|
||||||
|
"Place names": "Nomes de lugares",
|
||||||
"Space Missions (30d)": "Missões espaciais (30 d)",
|
"Space Missions (30d)": "Missões espaciais (30 d)",
|
||||||
"Street Traffic": "Trânsito urbano",
|
"Street Traffic": "Trânsito urbano",
|
||||||
"CCTV": "CCTV",
|
"CCTV": "CCTV",
|
||||||
|
|||||||
@@ -537,6 +537,7 @@
|
|||||||
"Military Flights": "Военные рейсы",
|
"Military Flights": "Военные рейсы",
|
||||||
"Earthquakes (24h)": "Землетрясения (24 ч)",
|
"Earthquakes (24h)": "Землетрясения (24 ч)",
|
||||||
"Satellites": "Спутники",
|
"Satellites": "Спутники",
|
||||||
|
"Place names": "Названия мест",
|
||||||
"Space Missions (30d)": "Космические миссии (30 дн.)",
|
"Space Missions (30d)": "Космические миссии (30 дн.)",
|
||||||
"Street Traffic": "Дорожный трафик",
|
"Street Traffic": "Дорожный трафик",
|
||||||
"CCTV": "CCTV",
|
"CCTV": "CCTV",
|
||||||
|
|||||||
@@ -1879,6 +1879,7 @@
|
|||||||
"Military Flights": "Військові рейси",
|
"Military Flights": "Військові рейси",
|
||||||
"Earthquakes (24h)": "Землетруси (24 год)",
|
"Earthquakes (24h)": "Землетруси (24 год)",
|
||||||
"Satellites": "Супутники",
|
"Satellites": "Супутники",
|
||||||
|
"Place names": "Назви місць",
|
||||||
"Space Missions (30d)": "Космічні місії (30 дн)",
|
"Space Missions (30d)": "Космічні місії (30 дн)",
|
||||||
"Street Traffic": "Дорожній рух",
|
"Street Traffic": "Дорожній рух",
|
||||||
"CCTV": "CCTV",
|
"CCTV": "CCTV",
|
||||||
|
|||||||
@@ -1764,6 +1764,7 @@
|
|||||||
"Military Flights": "军用航班",
|
"Military Flights": "军用航班",
|
||||||
"Earthquakes (24h)": "地震(24 小时)",
|
"Earthquakes (24h)": "地震(24 小时)",
|
||||||
"Satellites": "卫星",
|
"Satellites": "卫星",
|
||||||
|
"Place names": "地名",
|
||||||
"Space Missions (30d)": "太空任务(30 天)",
|
"Space Missions (30d)": "太空任务(30 天)",
|
||||||
"Street Traffic": "街道交通",
|
"Street Traffic": "街道交通",
|
||||||
"CCTV": "CCTV",
|
"CCTV": "CCTV",
|
||||||
|
|||||||
@@ -1754,6 +1754,7 @@
|
|||||||
"Military Flights": "軍用航班",
|
"Military Flights": "軍用航班",
|
||||||
"Earthquakes (24h)": "地震(24 小時)",
|
"Earthquakes (24h)": "地震(24 小時)",
|
||||||
"Satellites": "衛星",
|
"Satellites": "衛星",
|
||||||
|
"Place names": "地名",
|
||||||
"Space Missions (30d)": "太空任務(30 天)",
|
"Space Missions (30d)": "太空任務(30 天)",
|
||||||
"Street Traffic": "街道交通",
|
"Street Traffic": "街道交通",
|
||||||
"CCTV": "CCTV",
|
"CCTV": "CCTV",
|
||||||
|
|||||||
@@ -364,6 +364,14 @@ const COUNTRY_NAMES = Object.freeze({
|
|||||||
ZW: 'Zimbabwe',
|
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
|
// UCS "Country of Operator/Owner" → ISO code. Joint owners ("USA/Japan") and
|
||||||
// organisations are not countries and resolve through ORGANISATIONS or as a
|
// organisations are not countries and resolve through ORGANISATIONS or as a
|
||||||
// joint label.
|
// joint label.
|
||||||
|
|||||||
@@ -126,49 +126,3 @@ test('a lifecycle owner cannot acquire a layer panel even through an unused impo
|
|||||||
);
|
);
|
||||||
await assert.rejects(checkPackageBoundaries(root), /unowned module.*panel/);
|
await assert.rejects(checkPackageBoundaries(root), /unowned module.*panel/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a declared dependency owns the asset files it ships, and only those', async (t) => {
|
|
||||||
const asset = (pkg) =>
|
|
||||||
`import url from './node_modules/${pkg}/flags/aa.svg?url&no-inline'; export const value = url;`;
|
|
||||||
const root = await fixture(t, asset('flags'));
|
|
||||||
for (const pkg of ['flags', 'flags-extra', 'stray']) {
|
|
||||||
await mkdir(path.join(root, 'node_modules', pkg, 'flags'), {
|
|
||||||
recursive: true,
|
|
||||||
});
|
|
||||||
await writeFile(
|
|
||||||
path.join(root, 'node_modules', pkg, 'flags/aa.svg'),
|
|
||||||
'<svg xmlns="http://www.w3.org/2000/svg"/>',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await writeFile(
|
|
||||||
path.join(root, 'package.json'),
|
|
||||||
JSON.stringify({
|
|
||||||
name: 'boundary-fixture',
|
|
||||||
type: 'module',
|
|
||||||
exports: { './feature': './entry.js' },
|
|
||||||
dependencies: { flags: '1.0.0' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await writeFile(
|
|
||||||
path.join(root, 'scripts/package-boundaries.json'),
|
|
||||||
JSON.stringify({
|
|
||||||
feature: {
|
|
||||||
exports: ['./feature'],
|
|
||||||
modules: ['entry.js'],
|
|
||||||
external: ['flags'],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
// The entry and the flag it ships.
|
|
||||||
assert.deepEqual(await checkPackageBoundaries(root), [
|
|
||||||
{ name: 'feature', exports: 1, modules: 2 },
|
|
||||||
]);
|
|
||||||
// An undeclared package's file, and one whose name only starts the same.
|
|
||||||
for (const pkg of ['stray', 'flags-extra']) {
|
|
||||||
await writeFile(path.join(root, 'entry.js'), asset(pkg));
|
|
||||||
await assert.rejects(
|
|
||||||
checkPackageBoundaries(root),
|
|
||||||
new RegExp(`unowned module.*node_modules/${pkg}/`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -64,6 +64,25 @@ import { useLocationStore } from './stores/location.js';
|
|||||||
import { useCockpitStore } from './stores/cockpit.js';
|
import { useCockpitStore } from './stores/cockpit.js';
|
||||||
import { useGlobeActionsStore } from './stores/globeActions.js';
|
import { useGlobeActionsStore } from './stores/globeActions.js';
|
||||||
import { useRecordingStore } from './stores/recording.js';
|
import { useRecordingStore } from './stores/recording.js';
|
||||||
|
import {
|
||||||
|
onPlacePacksLoadingChange,
|
||||||
|
placePacksLoading,
|
||||||
|
} from '../data/placeBelow.js';
|
||||||
|
import { LOADING_NAME_KEYS } from './layerNames.js';
|
||||||
|
import { preloadOwnerFlags } from './ownerFlag.js';
|
||||||
|
|
||||||
|
/** The place packs, as one more loading entry while they are on their way. */
|
||||||
|
const PLACE_PACKS_LOADING = Object.freeze({
|
||||||
|
id: 'place-names',
|
||||||
|
name: LOADING_NAME_KEYS['place-names'],
|
||||||
|
enabled: true,
|
||||||
|
stats: Object.freeze({ loading: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The data layers, with the place packs while they load. */
|
||||||
|
function withPlacePacks(layers) {
|
||||||
|
return placePacksLoading() ? [...layers, PLACE_PACKS_LOADING] : layers;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Central UI orchestrator for the God's Eye View application.
|
* Central UI orchestrator for the God's Eye View application.
|
||||||
@@ -158,8 +177,15 @@ export class StyleManager extends ShellFacade {
|
|||||||
// Vue views share this runtime and its stores; dispose unmounts them.
|
// Vue views share this runtime and its stores; dispose unmounts them.
|
||||||
this._ui = createUiRuntime({ pinia: applicationPinia() });
|
this._ui = createUiRuntime({ pinia: applicationPinia() });
|
||||||
this._feedback = new ShellFeedback({
|
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.viewer = viewer;
|
||||||
this.mapStackController = mapStackController;
|
this.mapStackController = mapStackController;
|
||||||
this.placeSearch = placeSearch;
|
this.placeSearch = placeSearch;
|
||||||
@@ -1825,6 +1851,7 @@ export class StyleManager extends ShellFacade {
|
|||||||
this._wildlifeSightingCard?.destroy();
|
this._wildlifeSightingCard?.destroy();
|
||||||
this._ui.destroy();
|
this._ui.destroy();
|
||||||
this._panelChrome.destroy();
|
this._panelChrome.destroy();
|
||||||
|
this._offPlacePacks();
|
||||||
this._feedback.destroy();
|
this._feedback.destroy();
|
||||||
|
|
||||||
this._displayBindings.destroy();
|
this._displayBindings.destroy();
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ import { createPinia } from 'pinia';
|
|||||||
import { createApp, nextTick } from 'vue';
|
import { createApp, nextTick } from 'vue';
|
||||||
import { useCockpitStore } from './stores/cockpit.js';
|
import { useCockpitStore } from './stores/cockpit.js';
|
||||||
import CockpitOrbital from './views/panels/CockpitOrbital.vue';
|
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({
|
const VIEW = Object.freeze({
|
||||||
name: 'ISS (ZARYA)',
|
name: 'ISS (ZARYA)',
|
||||||
@@ -191,6 +198,49 @@ test('the place below shows at the top once known', async (t) => {
|
|||||||
assert.equal(document.querySelector('#cockpit-orbital-over'), null);
|
assert.equal(document.querySelector('#cockpit-orbital-over'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an owner country shows its flag from the sheet, or its code if the sheet will not load', async (t) => {
|
||||||
|
// The sheet loads through an Image; this one answers when told.
|
||||||
|
const images = [];
|
||||||
|
const RealImage = globalThis.Image;
|
||||||
|
globalThis.Image = class {
|
||||||
|
constructor() {
|
||||||
|
images.push(this);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.Image = RealImage;
|
||||||
|
});
|
||||||
|
const f = fixture(t);
|
||||||
|
f.store.publishOrbital({
|
||||||
|
...VIEW,
|
||||||
|
owner: { countryCode: 'US', label: 'USA' },
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(f.section().querySelector('.cockpit-orbital-flag'), null);
|
||||||
|
assert.equal(images.length, 1, 'the sheet is asked for');
|
||||||
|
images[0].onerror();
|
||||||
|
await settle();
|
||||||
|
// No sheet: the code, so the owner never disappears.
|
||||||
|
assert.equal(f.text('.cockpit-orbital-owner-badge'), 'US');
|
||||||
|
// The next flag asks again; this time the sheet comes.
|
||||||
|
f.store.publishOrbital({
|
||||||
|
...f.store.orbital,
|
||||||
|
owner: { countryCode: 'FR', label: 'France' },
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(images.length, 2);
|
||||||
|
images[1].onload();
|
||||||
|
await settle();
|
||||||
|
const flag = f.section().querySelector('.cockpit-orbital-flag');
|
||||||
|
assert.equal(flag.getAttribute('role'), 'img');
|
||||||
|
assert.equal(flag.getAttribute('aria-label'), 'France');
|
||||||
|
assert.equal(
|
||||||
|
flag.style.backgroundPosition,
|
||||||
|
ownerFlagStyle('FR').backgroundPosition,
|
||||||
|
);
|
||||||
|
assert.equal(f.section().querySelector('.cockpit-orbital-owner-badge'), null);
|
||||||
|
});
|
||||||
|
|
||||||
test('the readout names the owner, operator and element set', async (t) => {
|
test('the readout names the owner, operator and element set', async (t) => {
|
||||||
const f = fixture(t);
|
const f = fixture(t);
|
||||||
f.store.publishOrbital({
|
f.store.publishOrbital({
|
||||||
@@ -198,16 +248,17 @@ test('the readout names the owner, operator and element set', async (t) => {
|
|||||||
noradId: 25544,
|
noradId: 25544,
|
||||||
cosparId: '1998-067A',
|
cosparId: '1998-067A',
|
||||||
tle: ISS_TLE,
|
tle: ISS_TLE,
|
||||||
// Under node there is no flag glob, so a country shows as a badge.
|
// A country with no flag on the sheet shows as a badge, named in the
|
||||||
owner: { countryCode: 'US', label: 'USA' },
|
// reader's language.
|
||||||
|
owner: { countryCode: 'IS', label: 'Ísland' },
|
||||||
operator: 'NASA',
|
operator: 'NASA',
|
||||||
});
|
});
|
||||||
await nextTick();
|
await nextTick();
|
||||||
assert.equal(f.text('.cockpit-orbital-name'), 'ISS (ZARYA)');
|
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(
|
assert.equal(
|
||||||
f.section().querySelector('.cockpit-orbital-owner-badge').title,
|
f.section().querySelector('.cockpit-orbital-owner-badge').title,
|
||||||
'United States',
|
'Iceland',
|
||||||
);
|
);
|
||||||
assert.equal(f.text('.cockpit-orbital-operator'), 'NASA');
|
assert.equal(f.text('.cockpit-orbital-operator'), 'NASA');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ export function switchOrbitalSatellite(noradId) {
|
|||||||
// until the camera is on the new one, they list no sites or satellites.
|
// until the camera is on the new one, they list no sites or satellites.
|
||||||
state.awaitingFrame = true;
|
state.awaitingFrame = true;
|
||||||
state.previousUp = null;
|
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.place = undefined;
|
||||||
state.placeTracker?.reset?.();
|
state.placeTracker?.reset?.();
|
||||||
state.prefetched?.clear?.();
|
state.prefetched?.clear?.();
|
||||||
|
|||||||
@@ -1466,10 +1466,10 @@ test('the pre-load is throttled, at the level Cesium draws, and waits out a tilt
|
|||||||
}
|
}
|
||||||
// The level Cesium's own selection takes for this view, worked by hand:
|
// 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
|
// at 16x from 617 km the 900 px canvas is ~49.5 m a pixel. The ellipsoid
|
||||||
// terrain's level-zero error is 6378137 · 2π / 4 / 130 ≈ 77,067 m; it first
|
// 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, 75.3 m.
|
// 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
|
// A Web Mercator level-zero texel at 30° is 6378137 · 2π · cos 30° / 256
|
||||||
// ≈ 135,566 m, so the imagery level is round(log2(135,566 / 75.3)) = 11.
|
// ≈ 135,570 m, so the imagery level is round(log2(135,570 / 76.4)) = 11.
|
||||||
assert.equal(Number(requests[0].split('/')[0]), 11);
|
assert.equal(Number(requests[0].split('/')[0]), 11);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+10
-3
@@ -61,12 +61,19 @@ export const PANEL_LABEL_KEYS = Object.freeze({
|
|||||||
'local-firms': msg('Active Fires'),
|
'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
|
// Looked up by the English text, so a surface that holds `layer.name` keeps
|
||||||
// its English data and only the shown text translates.
|
// its English data and only the shown text translates.
|
||||||
const MODULE_NAME_KEYS = new Map(
|
const MODULE_NAME_KEYS = new Map(
|
||||||
[...Object.values(LAYER_NAME_KEYS), ...Object.values(PANEL_LABEL_KEYS)].map(
|
[
|
||||||
(key) => [key, key],
|
...Object.values(LAYER_NAME_KEYS),
|
||||||
),
|
...Object.values(PANEL_LABEL_KEYS),
|
||||||
|
...Object.values(LOADING_NAME_KEYS),
|
||||||
|
].map((key) => [key, key]),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+77
-52
@@ -1,34 +1,75 @@
|
|||||||
/**
|
/**
|
||||||
* The owner mark beside a satellite's name: the owner country's flag (an SVG
|
* The owner mark beside a satellite's name: the owner country's flag, or a
|
||||||
* from flag-icons — Windows draws no flag emoji), or a short text badge for
|
* short text badge for an organisation without one (ESA, EUMETSAT,
|
||||||
* an organisation without one (ESA, EUMETSAT, "Multinational"). Each flag is
|
* "Multinational"). Windows draws no flag emoji, so the flags are pictures:
|
||||||
* its own lazily fetched asset; only the shown one ever loads.
|
* 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
|
* Load the flag sheet, once; the app calls this at startup. A failed load
|
||||||
* glob is eager so the bundle carries only the URL strings: a lazy glob would
|
* is asked again by the next caller, so a flag shown later can still come.
|
||||||
* emit a JS stub chunk per flag (~270), two requests for each flag shown.
|
* @param {{createImage?: () => {onload: Function|null, onerror: Function|null,
|
||||||
* The SVG itself is still fetched only when an <img> shows it.
|
* src: string}}} [options] Test seam.
|
||||||
|
* @returns {Promise<boolean>} Whether the sheet is ready to draw from.
|
||||||
*/
|
*/
|
||||||
let FLAG_LOADERS = {};
|
export function preloadOwnerFlags({
|
||||||
try {
|
createImage = () => new globalThis.Image(),
|
||||||
const urls = import.meta.glob('/node_modules/flag-icons/flags/4x3/*.svg', {
|
} = {}) {
|
||||||
query: '?url&no-inline',
|
if (sheetLoad) return sheetLoad;
|
||||||
import: 'default',
|
let image;
|
||||||
eager: true,
|
try {
|
||||||
// flag-icons lives in node_modules, which globs skip by default.
|
image = createImage();
|
||||||
exhaustive: true,
|
} 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(
|
sheetLoad = load;
|
||||||
Object.entries(urls).map(([path, url]) => [path, () => url]),
|
image.src = OWNER_FLAG_SHEET_URL;
|
||||||
);
|
return load;
|
||||||
} catch {
|
|
||||||
/* Not under Vite. */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const FLAG_PATH = (code) =>
|
/**
|
||||||
`/node_modules/flag-icons/flags/4x3/${code.toLowerCase()}.svg`;
|
* How to draw a flag from the sheet at a size: the sheet as the element's
|
||||||
|
* background, scaled and shifted onto the flag's cell.
|
||||||
|
* @param {string} code ISO 3166-1 alpha-2, upper case.
|
||||||
|
* @param {{widthPx?: number, heightPx?: number}} [size] CSS pixels.
|
||||||
|
* @returns {{backgroundImage: string, backgroundSize: string,
|
||||||
|
* backgroundPosition: string}|null} Null for a code not on the sheet.
|
||||||
|
*/
|
||||||
|
export function ownerFlagStyle(code, { widthPx = 20, heightPx = 15 } = {}) {
|
||||||
|
const index = SHEET_INDEX.get(code);
|
||||||
|
if (index === undefined) return null;
|
||||||
|
const column = index % OWNER_FLAG_SHEET.columns;
|
||||||
|
const row = Math.floor(index / OWNER_FLAG_SHEET.columns);
|
||||||
|
return {
|
||||||
|
backgroundImage: `url("${OWNER_FLAG_SHEET_URL}")`,
|
||||||
|
backgroundSize: `${OWNER_FLAG_SHEET.columns * widthPx}px ${OWNER_FLAG_SHEET.rows * heightPx}px`,
|
||||||
|
backgroundPosition: `${-column * widthPx}px ${-row * heightPx}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** An ISO 3166-1 alpha-2 code, upper case, or null. */
|
/** An ISO 3166-1 alpha-2 code, upper case, or null. */
|
||||||
function isoCode(value) {
|
function isoCode(value) {
|
||||||
@@ -56,55 +97,39 @@ export function regionName(code, locale) {
|
|||||||
/**
|
/**
|
||||||
* How to mark a satellite's owner.
|
* How to mark a satellite's owner.
|
||||||
* @param {{countryCode: string|null, label: string|null}|null|undefined} 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}
|
* @returns {{kind: 'flag', code: string, label: string}
|
||||||
* | {kind: 'badge', label: string, title: string}
|
* | {kind: 'badge', label: string, title: string}
|
||||||
* | null} A flag (with the country's localized name as its label), a text
|
* | 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.
|
* badge for an owner with no flag to show, or nothing when unknown.
|
||||||
*/
|
*/
|
||||||
export function ownerMark(
|
export function ownerMark(owner, { locale = 'en', flags = SHEET_INDEX } = {}) {
|
||||||
owner,
|
|
||||||
{ locale = 'en', loaders = FLAG_LOADERS } = {},
|
|
||||||
) {
|
|
||||||
const code = isoCode(owner?.countryCode);
|
const code = isoCode(owner?.countryCode);
|
||||||
const label = String(owner?.label ?? '').trim();
|
const label = String(owner?.label ?? '').trim();
|
||||||
if (code) {
|
if (code) {
|
||||||
const name = regionName(code, locale) || label || 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 { kind: 'badge', label: label || code, title: name };
|
||||||
}
|
}
|
||||||
return label ? { kind: 'badge', label, title: label } : null;
|
return label ? { kind: 'badge', label, title: label } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the readout shows for an owner once its flag has loaded or failed: the
|
* What the readout shows for an owner once the flag sheet has loaded or
|
||||||
* flag, or a badge (the country code when the flag would not load, so the
|
* failed: the flag, or a badge (the country code when the sheet would not
|
||||||
* owner never disappears), or nothing for an unknown owner.
|
* load, so the owner never disappears), or nothing for an unknown owner.
|
||||||
* @param {ReturnType<typeof ownerMark>} mark
|
* @param {ReturnType<typeof ownerMark>} mark
|
||||||
* @param {{url?: string|null, failed?: boolean}} [flag] The flag's load state.
|
* @param {{ready?: boolean, failed?: boolean}} [sheet] The sheet's state.
|
||||||
* @returns {{kind: 'flag', url: string, label: string}
|
* @returns {{kind: 'flag', style: object, label: string}
|
||||||
* | {kind: 'badge', label: string, title: string} | null}
|
* | {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) return null;
|
||||||
if (mark.kind === 'badge') return mark;
|
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 { kind: 'badge', label: mark.code.toUpperCase(), title: mark.label };
|
||||||
}
|
}
|
||||||
return url ? { kind: 'flag', url, label: mark.label } : null;
|
return ready ? { kind: 'flag', style, 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+93
-61
@@ -1,75 +1,59 @@
|
|||||||
// src/ui/ownerFlag.test.mjs — a satellite owner's mark: a country's flag with
|
// 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 test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
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 {
|
import {
|
||||||
loadFlagUrl,
|
OWNER_FLAG_SHEET_URL,
|
||||||
|
ownerFlagStyle,
|
||||||
ownerMark,
|
ownerMark,
|
||||||
|
preloadOwnerFlags,
|
||||||
regionName,
|
regionName,
|
||||||
shownOwnerMark,
|
shownOwnerMark,
|
||||||
} from './ownerFlag.js';
|
} 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', () => {
|
test('an owner country shows its flag, named in the reader language', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(ownerMark({ countryCode: 'us', label: 'USA' }), {
|
||||||
ownerMark({ countryCode: 'us', label: 'USA' }, { loaders: LOADERS }),
|
kind: 'flag',
|
||||||
{ kind: 'flag', code: 'US', label: 'United States' },
|
code: 'US',
|
||||||
);
|
label: 'United States',
|
||||||
|
});
|
||||||
assert.equal(
|
assert.equal(
|
||||||
ownerMark(
|
ownerMark({ countryCode: 'FR', label: null }, { locale: 'de' }).label,
|
||||||
{ countryCode: 'FR', label: null },
|
|
||||||
{ locale: 'de', loaders: LOADERS },
|
|
||||||
).label,
|
|
||||||
'Frankreich',
|
'Frankreich',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an organisation without a country shows a text badge', () => {
|
test('an organisation without a country shows a text badge', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(ownerMark({ countryCode: null, label: 'ESA' }), {
|
||||||
ownerMark({ countryCode: null, label: 'ESA' }, { loaders: LOADERS }),
|
kind: 'badge',
|
||||||
{ kind: 'badge', label: 'ESA', title: 'ESA' },
|
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(
|
assert.deepEqual(
|
||||||
ownerMark({ countryCode: 'DE', label: 'Germany' }, { loaders: LOADERS }),
|
ownerMark(
|
||||||
|
{ countryCode: 'DE', label: 'Germany' },
|
||||||
|
{ flags: new Set(['US']) },
|
||||||
|
),
|
||||||
{ kind: 'badge', label: 'Germany', title: 'Germany' },
|
{ kind: 'badge', label: 'Germany', title: 'Germany' },
|
||||||
);
|
);
|
||||||
// Natural Earth and UCS write -99 or names, never a flag code.
|
// Natural Earth and UCS write -99 or names, never a flag code.
|
||||||
assert.deepEqual(
|
assert.deepEqual(ownerMark({ countryCode: '-99', label: 'Multinational' }), {
|
||||||
ownerMark(
|
kind: 'badge',
|
||||||
{ countryCode: '-99', label: 'Multinational' },
|
label: 'Multinational',
|
||||||
{ loaders: LOADERS },
|
title: 'Multinational',
|
||||||
),
|
});
|
||||||
{ kind: 'badge', label: 'Multinational', title: 'Multinational' },
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an unknown owner shows nothing', () => {
|
test('an unknown owner shows nothing', () => {
|
||||||
assert.equal(ownerMark(null, { loaders: LOADERS }), null);
|
assert.equal(ownerMark(null), null);
|
||||||
assert.equal(
|
assert.equal(ownerMark({ countryCode: null, label: ' ' }), null);
|
||||||
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,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a region name comes from Intl, never the bare code', () => {
|
test('a region name comes from Intl, never the bare code', () => {
|
||||||
@@ -77,12 +61,51 @@ test('a region name comes from Intl, never the bare code', () => {
|
|||||||
assert.equal(regionName('XX', 'en'), null);
|
assert.equal(regionName('XX', 'en'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a flag that will not load falls back to its country code, never to nothing', () => {
|
test('the sheet holds every owner country a satellite can be marked with', () => {
|
||||||
const flag = { kind: 'flag', code: 'us', label: 'United States' };
|
assert.deepEqual([...OWNER_FLAG_SHEET.codes], [...OWNER_COUNTRY_CODES]);
|
||||||
|
assert.equal(
|
||||||
|
OWNER_FLAG_SHEET.rows,
|
||||||
|
Math.ceil(OWNER_FLAG_SHEET.codes.length / OWNER_FLAG_SHEET.columns),
|
||||||
|
);
|
||||||
|
// The PNG is as large as its layout says (width and height at bytes 16-23).
|
||||||
|
const png = readFileSync(new URL(OWNER_FLAG_SHEET_URL));
|
||||||
|
assert.equal(
|
||||||
|
png.readUInt32BE(16),
|
||||||
|
OWNER_FLAG_SHEET.columns * OWNER_FLAG_SHEET.cellWidthPx,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
png.readUInt32BE(20),
|
||||||
|
OWNER_FLAG_SHEET.rows * OWNER_FLAG_SHEET.cellHeightPx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a flag is drawn from its cell of the sheet, at the size asked', () => {
|
||||||
|
const columns = OWNER_FLAG_SHEET.columns;
|
||||||
|
const index = OWNER_FLAG_SHEET.codes.indexOf('US');
|
||||||
|
const style = ownerFlagStyle('US');
|
||||||
|
assert.equal(style.backgroundImage, `url("${OWNER_FLAG_SHEET_URL}")`);
|
||||||
|
assert.equal(
|
||||||
|
style.backgroundSize,
|
||||||
|
`${columns * 20}px ${OWNER_FLAG_SHEET.rows * 15}px`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
style.backgroundPosition,
|
||||||
|
`${-(index % columns) * 20}px ${-Math.floor(index / columns) * 15}px`,
|
||||||
|
);
|
||||||
|
// The first flag sits at the corner.
|
||||||
|
assert.equal(
|
||||||
|
ownerFlagStyle(OWNER_FLAG_SHEET.codes[0]).backgroundPosition,
|
||||||
|
'0px 0px',
|
||||||
|
);
|
||||||
|
assert.equal(ownerFlagStyle('XX'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the flag shows once the sheet is ready; a failed sheet shows the code', () => {
|
||||||
|
const flag = { kind: 'flag', code: 'US', label: 'United States' };
|
||||||
assert.equal(shownOwnerMark(flag), null, 'nothing while it loads');
|
assert.equal(shownOwnerMark(flag), null, 'nothing while it loads');
|
||||||
assert.deepEqual(shownOwnerMark(flag, { url: '/flags/us.svg' }), {
|
assert.deepEqual(shownOwnerMark(flag, { ready: true }), {
|
||||||
kind: 'flag',
|
kind: 'flag',
|
||||||
url: '/flags/us.svg',
|
style: ownerFlagStyle('US'),
|
||||||
label: 'United States',
|
label: 'United States',
|
||||||
});
|
});
|
||||||
assert.deepEqual(shownOwnerMark(flag, { failed: true }), {
|
assert.deepEqual(shownOwnerMark(flag, { failed: true }), {
|
||||||
@@ -95,14 +118,23 @@ test('a flag that will not load falls back to its country code, never to nothing
|
|||||||
assert.equal(shownOwnerMark(null), null);
|
assert.equal(shownOwnerMark(null), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('flags ship as a map of URLs, not one JS chunk per flag', async () => {
|
test('the sheet loads once; a failed load is asked again', async () => {
|
||||||
const { readFileSync } = await import('node:fs');
|
const images = [];
|
||||||
const source = readFileSync(
|
const createImage = () => {
|
||||||
new URL('./ownerFlag.js', import.meta.url),
|
const image = { onload: null, onerror: null, src: '' };
|
||||||
'utf8',
|
images.push(image);
|
||||||
);
|
return image;
|
||||||
// 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.
|
const first = preloadOwnerFlags({ createImage });
|
||||||
assert.match(source, /eager:\s*true/);
|
assert.equal(images.length, 1);
|
||||||
assert.match(source, /query:\s*'\?url&no-inline'/);
|
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,113 @@
|
|||||||
|
// Generated by scripts/build-owner-flags.mjs from flag-icons 7.5.0; do not edit.
|
||||||
|
/** The owner flag sheet's layout: flag i at column i % columns, row i / columns. */
|
||||||
|
export const OWNER_FLAG_SHEET = Object.freeze({
|
||||||
|
cellWidthPx: 40,
|
||||||
|
cellHeightPx: 30,
|
||||||
|
columns: 16,
|
||||||
|
rows: 7,
|
||||||
|
codes: Object.freeze([
|
||||||
|
'AE',
|
||||||
|
'AM',
|
||||||
|
'AO',
|
||||||
|
'AR',
|
||||||
|
'AT',
|
||||||
|
'AU',
|
||||||
|
'AZ',
|
||||||
|
'BD',
|
||||||
|
'BE',
|
||||||
|
'BG',
|
||||||
|
'BH',
|
||||||
|
'BM',
|
||||||
|
'BO',
|
||||||
|
'BR',
|
||||||
|
'BT',
|
||||||
|
'BW',
|
||||||
|
'BY',
|
||||||
|
'CA',
|
||||||
|
'CH',
|
||||||
|
'CL',
|
||||||
|
'CN',
|
||||||
|
'CO',
|
||||||
|
'CR',
|
||||||
|
'CZ',
|
||||||
|
'DE',
|
||||||
|
'DJ',
|
||||||
|
'DK',
|
||||||
|
'DZ',
|
||||||
|
'EC',
|
||||||
|
'EE',
|
||||||
|
'EG',
|
||||||
|
'ES',
|
||||||
|
'ET',
|
||||||
|
'FI',
|
||||||
|
'FR',
|
||||||
|
'GB',
|
||||||
|
'GH',
|
||||||
|
'GR',
|
||||||
|
'GT',
|
||||||
|
'HR',
|
||||||
|
'HU',
|
||||||
|
'ID',
|
||||||
|
'IE',
|
||||||
|
'IL',
|
||||||
|
'IN',
|
||||||
|
'IQ',
|
||||||
|
'IR',
|
||||||
|
'IT',
|
||||||
|
'JO',
|
||||||
|
'JP',
|
||||||
|
'KE',
|
||||||
|
'KP',
|
||||||
|
'KR',
|
||||||
|
'KW',
|
||||||
|
'KZ',
|
||||||
|
'LA',
|
||||||
|
'LK',
|
||||||
|
'LT',
|
||||||
|
'LU',
|
||||||
|
'MA',
|
||||||
|
'MC',
|
||||||
|
'MD',
|
||||||
|
'ME',
|
||||||
|
'MM',
|
||||||
|
'MN',
|
||||||
|
'MU',
|
||||||
|
'MX',
|
||||||
|
'MY',
|
||||||
|
'NG',
|
||||||
|
'NL',
|
||||||
|
'NO',
|
||||||
|
'NP',
|
||||||
|
'NZ',
|
||||||
|
'PE',
|
||||||
|
'PH',
|
||||||
|
'PK',
|
||||||
|
'PL',
|
||||||
|
'PT',
|
||||||
|
'PY',
|
||||||
|
'QA',
|
||||||
|
'RO',
|
||||||
|
'RU',
|
||||||
|
'RW',
|
||||||
|
'SA',
|
||||||
|
'SB',
|
||||||
|
'SD',
|
||||||
|
'SE',
|
||||||
|
'SG',
|
||||||
|
'SI',
|
||||||
|
'SN',
|
||||||
|
'TH',
|
||||||
|
'TM',
|
||||||
|
'TN',
|
||||||
|
'TR',
|
||||||
|
'TW',
|
||||||
|
'UA',
|
||||||
|
'US',
|
||||||
|
'UY',
|
||||||
|
'VA',
|
||||||
|
'VE',
|
||||||
|
'VN',
|
||||||
|
'ZA',
|
||||||
|
'ZW',
|
||||||
|
]),
|
||||||
|
});
|
||||||
@@ -140,3 +140,14 @@ for (const locale of ['es', 'ru']) {
|
|||||||
assert.equal(shown.label, t(error).toLocaleUpperCase(locale));
|
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,12 @@
|
|||||||
}
|
}
|
||||||
.cockpit-orbital-flag {
|
.cockpit-orbital-flag {
|
||||||
flex: none;
|
flex: none;
|
||||||
|
box-sizing: content-box;
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 15px;
|
height: 15px;
|
||||||
border: 1px solid var(--orbital-faint);
|
border: 1px solid var(--orbital-faint);
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
object-fit: cover;
|
background-repeat: no-repeat;
|
||||||
}
|
}
|
||||||
.cockpit-orbital-owner-badge {
|
.cockpit-orbital-owner-badge {
|
||||||
flex: none;
|
flex: none;
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
tleAgeParts,
|
tleAgeParts,
|
||||||
} from '../../cockpitPresentation.js';
|
} from '../../cockpitPresentation.js';
|
||||||
import {
|
import {
|
||||||
loadFlagUrl,
|
|
||||||
ownerMark,
|
ownerMark,
|
||||||
|
preloadOwnerFlags,
|
||||||
regionName,
|
regionName,
|
||||||
shownOwnerMark,
|
shownOwnerMark,
|
||||||
} from '../../ownerFlag.js';
|
} from '../../ownerFlag.js';
|
||||||
@@ -189,34 +189,25 @@ const placeText = computed(() => {
|
|||||||
const mark = computed(() =>
|
const mark = computed(() =>
|
||||||
ownerMark(orbital.value?.owner, { locale: getLocale() }),
|
ownerMark(orbital.value?.owner, { locale: getLocale() }),
|
||||||
);
|
);
|
||||||
const flagUrl = ref(null);
|
// The flag sheet loads at startup; a sheet that would not load (offline, a
|
||||||
// A flag that would not load (offline, a stale chunk after a deploy, a
|
// stale asset after a deploy) is asked again for the next flag, and until it
|
||||||
// missing file) falls back to the code as a badge, never to no owner at all.
|
// comes the code shows as a badge, never no owner at all.
|
||||||
const flagFailed = ref(false);
|
const flagsReady = ref(false);
|
||||||
let flagRequest = 0;
|
const flagsFailed = ref(false);
|
||||||
watch(
|
watch(
|
||||||
() => (mark.value?.kind === 'flag' ? mark.value.code : null),
|
() => (mark.value?.kind === 'flag' ? mark.value.code : null),
|
||||||
async (code) => {
|
async (code) => {
|
||||||
const request = ++flagRequest;
|
if (!code || flagsReady.value) return;
|
||||||
flagUrl.value = null;
|
const ready = await preloadOwnerFlags();
|
||||||
flagFailed.value = false;
|
flagsReady.value = ready;
|
||||||
if (!code) return;
|
flagsFailed.value = !ready;
|
||||||
let url = null;
|
|
||||||
try {
|
|
||||||
url = await loadFlagUrl(code);
|
|
||||||
} catch {
|
|
||||||
url = null;
|
|
||||||
}
|
|
||||||
if (request !== flagRequest) return;
|
|
||||||
flagUrl.value = url;
|
|
||||||
flagFailed.value = !url;
|
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
const shownMark = computed(() =>
|
const shownMark = computed(() =>
|
||||||
shownOwnerMark(mark.value, {
|
shownOwnerMark(mark.value, {
|
||||||
url: flagUrl.value,
|
ready: flagsReady.value,
|
||||||
failed: flagFailed.value,
|
failed: flagsFailed.value,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -293,8 +284,7 @@ const lensStyle = computed(() => {
|
|||||||
<div class="cockpit-orbital-readout">
|
<div class="cockpit-orbital-readout">
|
||||||
<p class="cockpit-orbital-kicker">{{ look.offset ? t('ORBITAL VIEW · OFF-NADIR') : t('ORBITAL VIEW · NADIR') }}</p>
|
<p class="cockpit-orbital-kicker">{{ look.offset ? t('ORBITAL VIEW · OFF-NADIR') : t('ORBITAL VIEW · NADIR') }}</p>
|
||||||
<div class="cockpit-orbital-title">
|
<div class="cockpit-orbital-title">
|
||||||
<!-- i18n-ignore: @error is the image's load-failure event, not a status message. -->
|
<span v-if="shownMark?.kind === 'flag'" class="cockpit-orbital-flag" role="img" :aria-label="shownMark.label" :title="shownMark.label" :style="shownMark.style"></span>
|
||||||
<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-else-if="shownMark?.kind === 'badge'" class="cockpit-orbital-owner-badge" :title="shownMark.title">{{ shownMark.label }}</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>
|
<p class="cockpit-orbital-name">{{ orbital.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in new issue
Block a user