fix(cockpit): a place pack that will not load ends the loading batch failed; flags keep their neighbours out from under the border and out of a scaled flag's edge; a new ride asks again for a sheet that failed

This commit is contained in:
Sameh Khamis committed 2026-11-08 19:45:01 -08:00
1 parent 5a6cf22be1
commit e954734379
16 files changed
+198 -55

No files matched your search

+13 -5
View File
@@ -9,7 +9,7 @@
* 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
* 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
@@ -33,6 +33,11 @@ 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(
@@ -51,11 +56,13 @@ async function flagCell(code) {
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 * CELL.width,
height: rows * CELL.height,
width: COLUMNS * pitchX,
height: rows * pitchY,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
@@ -63,8 +70,8 @@ const sheet = await sharp({
.composite(
cells.map((input, i) => ({
input,
left: (i % COLUMNS) * CELL.width,
top: Math.floor(i / COLUMNS) * CELL.height,
left: (i % COLUMNS) * pitchX,
top: Math.floor(i / COLUMNS) * pitchY,
})),
)
.png({ compressionLevel: 9, palette: true, quality: 100, effort: 10 })
@@ -76,6 +83,7 @@ const layout = `// Generated by scripts/build-owner-flags.mjs from flag-icons ${
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)}),
+3 -1
View File
@@ -1366,5 +1366,7 @@
"server/providers/inaturalist/proxy.fixture.mjs",
"scripts/build-owner-flags.mjs",
"src/ui/ownerFlagSheet.js",
"src/data/placePacksLoading.test.mjs"
"src/data/placePacksLoading.test.mjs",
"src/ui/placePacksLoading.js",
"src/ui/placePacksLoading.test.mjs"
]
+2
View File
@@ -1069,6 +1069,7 @@
"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",
@@ -2916,6 +2917,7 @@
"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",
+2 -1
View File
@@ -3,7 +3,8 @@
`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
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)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+29 -13
View File
@@ -11,7 +11,7 @@
* 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, and the loading status names them while
* one is on its way (`placePacksLoading`); a country's division rings are decoded
* 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.
*/
@@ -47,8 +47,9 @@ const PACKS = {
),
};
/** The packs on their way now, for the loading status. */
/** The packs on their way now, and those whose last load failed. */
const loadingPacks = new Set();
const failedPacks = new Set();
const loadingListeners = new Set();
function notifyLoading() {
@@ -61,13 +62,25 @@ function notifyLoading() {
}
}
/** A pack load that the loading status counts while it runs. */
function reportedLoad(name, load) {
/**
* A pack load that the loading status follows: on its way while it runs,
* failed until a later load of it succeeds.
* @template T
* @param {string} name
* @param {() => Promise<T>} load
* @returns {() => Promise<T>}
*/
export function reportedPlacePackLoad(name, load) {
return async () => {
loadingPacks.add(name);
notifyLoading();
try {
return await load();
const result = await load();
failedPacks.delete(name);
return result;
} catch (error) {
failedPacks.add(name);
throw error;
} finally {
loadingPacks.delete(name);
notifyLoading();
@@ -76,12 +89,13 @@ function reportedLoad(name, load) {
}
/**
* 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}
* 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 placePacksLoading() {
return loadingPacks.size > 0;
export function placePacksStatus() {
return { loading: loadingPacks.size > 0, failed: failedPacks.size > 0 };
}
/**
@@ -191,7 +205,9 @@ async function readPlaces() {
}
return { land, water };
}
const loadPlaces = createRetryableLoader(reportedLoad('places', readPlaces));
const loadPlaces = createRetryableLoader(
reportedPlacePackLoad('places', readPlaces),
);
/** Divisions grouped by country code; rings decoded per country on demand. */
async function readDivisions() {
@@ -208,7 +224,7 @@ async function readDivisions() {
return { byCountry, decoded: new Map() };
}
const loadDivisions = createRetryableLoader(
reportedLoad('divisions', readDivisions),
reportedPlacePackLoad('divisions', readDivisions),
);
/** The first-level division holding a point inside country `code`, or null. */
@@ -243,7 +259,7 @@ async function readPopulatedPlaces() {
);
}
const loadPopulatedPlaces = createRetryableLoader(
reportedLoad('populated-places', readPopulatedPlaces),
reportedPlacePackLoad('populated-places', readPopulatedPlaces),
);
/**
+18 -4
View File
@@ -8,16 +8,30 @@ test('a place pack load is reported from its start to its end', async () => {
const place = await import('./placeBelow.js?loading-status');
const heard = [];
const stop = place.onPlacePacksLoadingChange(() =>
heard.push(place.placePacksLoading()),
heard.push(place.placePacksStatus().loading),
);
assert.equal(place.placePacksLoading(), false);
assert.equal(place.placePacksStatus().loading, false);
const lookup = place.nearPlace(48.86, 2.35, 50);
assert.equal(place.placePacksLoading(), true, 'on its way');
assert.equal(place.placePacksStatus().loading, true, 'on its way');
assert.equal(await lookup, 'Paris');
assert.equal(place.placePacksLoading(), false, 'loaded');
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;
assert.equal(await load(), 1);
assert.deepEqual(place.placePacksStatus(), { loading: false, failed: false });
});
+2 -18
View File
@@ -64,26 +64,10 @@ import { useLocationStore } from './stores/location.js';
import { useCockpitStore } from './stores/cockpit.js';
import { useGlobeActionsStore } from './stores/globeActions.js';
import { useRecordingStore } from './stores/recording.js';
import {
onPlacePacksLoadingChange,
placePacksLoading,
} from '../data/placeBelow.js';
import { LOADING_NAME_KEYS } from './layerNames.js';
import { onPlacePacksLoadingChange } from '../data/placeBelow.js';
import { withPlacePacks } from './placePacksLoading.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.
*
+8 -2
View File
@@ -222,14 +222,20 @@ test('an owner country shows its flag from the sheet, or its code if the sheet w
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, 2);
images[1].onload();
assert.equal(images.length, 3);
images[2].onload();
await settle();
const flag = f.section().querySelector('.cockpit-orbital-flag');
assert.equal(flag.getAttribute('role'), 'img');
+10 -4
View File
@@ -62,12 +62,18 @@ export function preloadOwnerFlags({
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);
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: `${OWNER_FLAG_SHEET.columns * widthPx}px ${OWNER_FLAG_SHEET.rows * heightPx}px`,
backgroundPosition: `${-column * widthPx}px ${-row * heightPx}px`,
backgroundSize: `${sheet.columns * pitchX}px ${sheet.rows * pitchY}px`,
backgroundPosition: `${-column * pitchX}px ${-row * pitchY}px`,
};
}
+10 -4
View File
@@ -69,28 +69,34 @@ test('the sheet holds every owner country a satellite can be marked with', () =>
);
// 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 * OWNER_FLAG_SHEET.cellWidthPx,
OWNER_FLAG_SHEET.columns * (cellWidthPx + gutterPx),
);
assert.equal(
png.readUInt32BE(20),
OWNER_FLAG_SHEET.rows * OWNER_FLAG_SHEET.cellHeightPx,
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 * 20}px ${OWNER_FLAG_SHEET.rows * 15}px`,
`${columns * 21}px ${OWNER_FLAG_SHEET.rows * 16}px`,
);
assert.equal(
style.backgroundPosition,
`${-(index % columns) * 20}px ${-Math.floor(index / columns) * 15}px`,
`${-(index % columns) * 21}px ${-Math.floor(index / columns) * 16}px`,
);
// The first flag sits at the corner.
assert.equal(
+1
View File
@@ -3,6 +3,7 @@
export const OWNER_FLAG_SHEET = Object.freeze({
cellWidthPx: 40,
cellHeightPx: 30,
gutterPx: 2,
columns: 16,
rows: 7,
codes: Object.freeze([
+30
View File
@@ -0,0 +1,30 @@
/**
* The place packs in the top-centre loading status: they load lazily, on the
* Cockpit's first place lookup (`src/data/placeBelow.js`), so while one is on
* its way the status names them beside the layers, and a pack that would not
* load makes the batch end LOAD FAILED.
*/
import { placePacksStatus } from '../data/placeBelow.js';
import { LOADING_NAME_KEYS } from './layerNames.js';
/**
* The data layers, with the place packs as one more entry while they load
* or after one would not load, so a batch they join ends LOAD FAILED rather
* than LOAD COMPLETE.
*/
export function withPlacePacks(
layers,
{ loading, failed } = placePacksStatus(),
) {
if (!loading && !failed) return layers;
return [
...layers,
{
id: 'place-names',
name: LOADING_NAME_KEYS['place-names'],
enabled: true,
stats: { loading, error: failed ? 'Place names did not load' : null },
},
];
}
+59
View File
@@ -0,0 +1,59 @@
// 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',
);
});
+3
View File
@@ -200,6 +200,9 @@
border: 1px solid var(--orbital-faint);
border-radius: 1px;
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 -3
View File
@@ -195,9 +195,14 @@ const mark = computed(() =>
const flagsReady = ref(false);
const flagsFailed = ref(false);
watch(
() => (mark.value?.kind === 'flag' ? mark.value.code : null),
async (code) => {
if (!code || flagsReady.value) return;
// 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;