mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 04:44:01 +00:00
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:
1 parent
5a6cf22be1
commit
e954734379
16 files changed
+198
-55
No files matched your search
@@ -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)}),
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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),
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
@@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
export const OWNER_FLAG_SHEET = Object.freeze({
|
||||
cellWidthPx: 40,
|
||||
cellHeightPx: 30,
|
||||
gutterPx: 2,
|
||||
columns: 16,
|
||||
rows: 7,
|
||||
codes: Object.freeze([
|
||||
|
||||
@@ -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,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',
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user