feat(layers): Recent Imagery — open satellite imagery for a selected box (#716)

Select a box on the world and browse the last 30 days of open satellite imagery over it, keyless: NASA GIBS Harmonized Landsat Sentinel-2 (30 m) and the VIIRS daily overview (250 m), with a day strip, IMAGE / VS BASEMAP / A / B modes with a swipe divider, PNG export and share links. The before/after swipe from the Nepal scene moves into shared modules so both use it.

Co-authored-by: manjunath22466 <[email protected]>
This commit is contained in:
Bilawal Sidhuandmanjunath22466 authored and GitHub committed 2026-09-22 20:34:11 -05:00
1 parent 25491f7e33
commit 082074a006
50 files changed
+13169 -440

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
/**
* NASA CMR granule catalog for the HLS products, folded with the VIIRS
* overview days into one candidate list.
*/
import {
CATALOG_DAYS,
PRODUCTS,
coverageFor,
groupGranulesByDay,
mergeCandidates,
utcDay,
validateBox,
viirsCandidates,
} from './model.js';
const CMR_ENDPOINT = 'https://cmr.earthdata.nasa.gov/search';
const DAY_MS = 86_400_000;
const CMR_PAGE_SIZE = 200;
/**
* Most records read per collection. A 1,000 km box can touch hundreds of HLS
* tiles a day; past this cap the newest days (the sort order) are kept and
* the result is `truncated`.
*/
const CMR_MAX_RECORDS = 2000;
const CMR_SEARCH_AFTER = 'CMR-Search-After';
/**
* Read one product's granules across CMR pages, sending back each response's
* `CMR-Search-After` cursor until a short page, a missing cursor or the
* record cap ends the walk. `truncated` is measured against CMR's `hits`.
* @param {{ product: string, url: string, fetchImpl: typeof fetch, signal?: AbortSignal, maxRecords?: number }} request
* @returns {Promise<{ granules: Array<object>, truncated: boolean }>}
*/
export async function fetchCmrPages({
product,
url,
fetchImpl,
signal,
maxRecords = CMR_MAX_RECORDS,
}) {
const granules = [];
let hits = 0;
let cursor = null;
for (;;) {
const init = { signal };
if (cursor) init.headers = { [CMR_SEARCH_AFTER]: cursor };
const response = await fetchImpl(url, init);
if (!response?.ok)
throw new Error(`HTTP ${response?.status ?? 'error'} from CMR`);
const page = parseCmrUmm(await response.json(), product);
granules.push(...page.granules);
hits = Math.max(hits, page.hits);
cursor = response.headers?.get?.(CMR_SEARCH_AFTER) ?? null;
if (
!cursor ||
page.granules.length < CMR_PAGE_SIZE ||
granules.length >= maxRecords ||
granules.length >= hits
)
break;
}
return { granules, truncated: hits > granules.length };
}
function finiteOrNull(value) {
if (value === null || value === undefined || value === '') return null;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
function isoOrNull(value) {
return typeof value === 'string' && Number.isFinite(Date.parse(value))
? value
: null;
}
function ringOf(points) {
const ring = points
.map(([lon, lat]) => [finiteOrNull(lon), finiteOrNull(lat)])
.filter(([lon, lat]) => lon !== null && lat !== null);
return ring.length >= 3 ? ring : null;
}
function footprintOf(geometry) {
const polygon = geometry?.GPolygons?.[0]?.Boundary?.Points;
if (Array.isArray(polygon)) {
const ring = ringOf(polygon.map((p) => [p?.Longitude, p?.Latitude]));
if (ring) return ring;
}
const r = geometry?.BoundingRectangles?.[0];
if (!r) return null;
const [w, s, e, n] = [
r.WestBoundingCoordinate,
r.SouthBoundingCoordinate,
r.EastBoundingCoordinate,
r.NorthBoundingCoordinate,
];
const ring = ringOf([
[w, s],
[e, s],
[e, n],
[w, n],
]);
return ring?.length === 4 ? ring : null;
}
/**
* Parse a CMR `granules.umm_json` response. Every field is optional upstream;
* granules without a parseable start time are dropped.
* @param {object} json
* @param {string} product `S30` | `L30`
* @returns {{ granules: Array<object>, hits: number }}
*/
export function parseCmrUmm(json, product) {
const granules = [];
for (const item of Array.isArray(json?.items) ? json.items : []) {
const umm = item?.umm || {};
const meta = item?.meta || {};
const range = umm.TemporalExtent?.RangeDateTime || {};
const timeStart = isoOrNull(range.BeginningDateTime);
if (!timeStart) continue;
const cloud = Array.isArray(umm.AdditionalAttributes)
? umm.AdditionalAttributes.find((a) => a?.Name === 'CLOUD_COVERAGE')
?.Values?.[0]
: null;
granules.push({
id: String(
meta['concept-id'] || umm.GranuleUR || meta['native-id'] || '',
),
product,
timeStart,
timeEnd: isoOrNull(range.EndingDateTime) || timeStart,
cloud: finiteOrNull(cloud),
footprint: footprintOf(
umm.SpatialExtent?.HorizontalSpatialDomain?.Geometry,
),
});
}
return { granules, hits: finiteOrNull(json?.hits) ?? granules.length };
}
/**
* CMR granule search URL for one HLS product over a box and time window.
* @param {{ product: string, box: object, startIso: string, endIso: string }} query
* @returns {string}
*/
export function cmrSearchUrl({ product, box, startIso, endIso }) {
const spec = PRODUCTS[product];
if (!spec?.cmrCollection)
throw new TypeError(`No CMR collection for product: ${product}`);
const result = validateBox(box);
if (!result.ok) throw new TypeError(result.message);
const { west, south, east, north } = result.box;
return (
`${CMR_ENDPOINT}/granules.umm_json` +
`?collection_concept_id=${spec.cmrCollection}` +
`&bounding_box=${west},${south},${east},${north}` +
`&temporal=${startIso},${endIso}` +
`&sort_key=-start_date&page_size=${CMR_PAGE_SIZE}`
);
}
/**
* Search both HLS collections concurrently and fold the results with the
* VIIRS overview days. One product failing still yields the other's days plus
* an `errors` entry; an invalid box or clock throws a TypeError.
* @param {{ box: object, days?: number, now?: Date, fetchImpl?: typeof fetch, signal?: AbortSignal }} request
* @returns {Promise<{ candidates: Array<object>, truncated: boolean, errors: Array<{ product: string, message: string }> }>}
*/
export async function searchHls({
box,
days = CATALOG_DAYS,
now = new Date(),
fetchImpl = globalThis.fetch,
signal,
} = {}) {
const validated = validateBox(box);
if (!validated.ok) throw new TypeError(validated.message);
if (typeof fetchImpl !== 'function')
throw new TypeError('A fetch implementation is required');
const nowMs = now instanceof Date ? now.getTime() : Date.parse(now);
if (!Number.isFinite(nowMs)) throw new TypeError('A valid clock is required');
const endIso = new Date(nowMs).toISOString();
const startIso = `${utcDay(new Date(nowMs - (days - 1) * DAY_MS))}T00:00:00Z`;
const products = ['S30', 'L30'];
const settled = await Promise.allSettled(
products.map((product) =>
fetchCmrPages({
product,
url: cmrSearchUrl({ product, box: validated.box, startIso, endIso }),
fetchImpl,
signal,
}),
),
);
const granules = [];
const errors = [];
let truncated = false;
settled.forEach((result, index) => {
if (result.status === 'fulfilled') {
granules.push(...result.value.granules);
truncated ||= result.value.truncated;
} else {
errors.push({
product: products[index],
message: String(result.reason?.message || result.reason),
});
}
});
const hls = groupGranulesByDay(granules).map((candidate) => ({
...candidate,
coverage: coverageFor(candidate, validated.box),
}));
return {
candidates: mergeCandidates([hls, viirsCandidates(endIso, days)]),
truncated,
errors,
};
}
+296
View File
@@ -0,0 +1,296 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
cmrSearchUrl,
fetchCmrPages,
parseCmrUmm,
searchHls,
} from './catalog.js';
import { BOX, response } from './testDoubles.mjs';
function ummItem({
conceptId = 'G1-LPCLOUD',
begin = '2026-09-18T17:12:01.000Z',
end = '2026-09-18T17:12:31.000Z',
cloud = '12',
polygon = [
[-98, 30],
[-97, 30],
[-97, 31],
[-98, 31],
],
rectangle = null,
} = {}) {
const Geometry = {};
if (polygon)
Geometry.GPolygons = [
{
Boundary: {
Points: polygon.map(([Longitude, Latitude]) => ({
Longitude,
Latitude,
})),
},
},
];
if (rectangle)
Geometry.BoundingRectangles = [
{
WestBoundingCoordinate: rectangle[0],
SouthBoundingCoordinate: rectangle[1],
EastBoundingCoordinate: rectangle[2],
NorthBoundingCoordinate: rectangle[3],
},
];
return {
meta: { 'concept-id': conceptId },
umm: {
TemporalExtent: {
RangeDateTime: { BeginningDateTime: begin, EndingDateTime: end },
},
AdditionalAttributes: [
{ Name: 'SPATIAL_COVERAGE', Values: ['100'] },
...(cloud === undefined
? []
: [{ Name: 'CLOUD_COVERAGE', Values: [cloud] }]),
],
SpatialExtent: { HorizontalSpatialDomain: { Geometry } },
},
};
}
test('umm_json granules carry id, times, scene cloud and a footprint ring', () => {
const { granules, hits } = parseCmrUmm(
{
hits: 9,
items: [
ummItem(),
ummItem({
conceptId: 'G2',
cloud: '3.5',
polygon: null,
rectangle: [-98.5, 29.5, -97.5, 30.5],
}),
{
meta: {},
umm: {
GranuleUR: 'HLS.L30.only-ur',
TemporalExtent: {
RangeDateTime: { BeginningDateTime: '2026-09-01T16:00:00Z' },
},
},
},
ummItem({ conceptId: 'G-bad-cloud', cloud: 'n/a' }),
ummItem({
conceptId: 'G-two-points',
polygon: [
[0, 0],
[1, 1],
],
}),
{ meta: { 'concept-id': 'G-no-time' }, umm: {} },
{
meta: { 'concept-id': 'G-bad-time' },
umm: {
TemporalExtent: { RangeDateTime: { BeginningDateTime: 'soon' } },
},
},
null,
],
},
'S30',
);
assert.equal(hits, 9);
assert.deepEqual(granules[0], {
id: 'G1-LPCLOUD',
product: 'S30',
timeStart: '2026-09-18T17:12:01.000Z',
timeEnd: '2026-09-18T17:12:31.000Z',
cloud: 12,
footprint: [
[-98, 30],
[-97, 30],
[-97, 31],
[-98, 31],
],
});
assert.equal(granules[1].cloud, 3.5);
assert.deepEqual(granules[1].footprint, [
[-98.5, 29.5],
[-97.5, 29.5],
[-97.5, 30.5],
[-98.5, 30.5],
]);
assert.deepEqual(
granules.slice(2).map((g) => [g.id, g.cloud, g.footprint]),
[
['HLS.L30.only-ur', null, null],
['G-bad-cloud', null, granules[0].footprint],
['G-two-points', 12, null],
],
'missing fields stay unknown; granules without a start time are dropped',
);
assert.equal(granules[2].timeEnd, '2026-09-01T16:00:00Z');
assert.deepEqual(parseCmrUmm(null, 'S30'), { granules: [], hits: 0 });
});
test('the CMR query names the collection, box, window and page size with literal commas', () => {
assert.equal(
cmrSearchUrl({
product: 'S30',
box: BOX,
startIso: '2026-08-23T00:00:00Z',
endIso: '2026-09-21T12:00:00.000Z',
}),
'https://cmr.earthdata.nasa.gov/search/granules.umm_json?collection_concept_id=C2021957295-LPCLOUD&bounding_box=-97.8,30.2,-97.7,30.3&temporal=2026-08-23T00:00:00Z,2026-09-21T12:00:00.000Z&sort_key=-start_date&page_size=200',
);
assert.match(
cmrSearchUrl({ product: 'L30', box: BOX, startIso: 'a', endIso: 'b' }),
/collection_concept_id=C2021957657-LPCLOUD/,
);
assert.throws(
() => cmrSearchUrl({ product: 'VIIRS', box: BOX }),
/No CMR collection/,
);
});
/** Route a CMR request to the S30 or L30 handler by collection id. */
function cmrFetch(handlers, calls = []) {
return async (url, init) => {
calls.push({ url, init });
const handler = handlers[url.includes('C2021957295') ? 'S30' : 'L30'];
return typeof handler === 'function'
? handler()
: response({ json: handler });
};
}
test('both HLS collections fold with the VIIRS days; one failing product is reported, not fatal', async () => {
const calls = [];
const signal = new AbortController().signal;
const result = await searchHls({
box: BOX,
now: new Date('2026-09-21T12:00:00Z'),
signal,
fetchImpl: cmrFetch(
{
S30: {
hits: 2,
items: [
ummItem(),
ummItem({ conceptId: 'G-old', begin: '2026-09-08T17:12:01Z' }),
],
},
L30: () => response({ ok: false, status: 503 }),
},
calls,
),
});
assert.deepEqual(result.errors, [
{ product: 'L30', message: 'HTTP 503 from CMR' },
]);
assert.equal(result.truncated, false);
assert.equal(calls[0].init.signal, signal);
assert.match(
calls[0].url,
/temporal=2026-08-23T00:00:00Z,2026-09-21T12:00:00\.000Z/,
'30 UTC days ending now',
);
const keys = result.candidates.map((c) => c.key);
assert.equal(keys.length, 32);
assert.deepEqual(keys.slice(2, 5), [
'VIIRS:2026-09-19',
'S30:2026-09-18',
'VIIRS:2026-09-18',
]);
assert.ok(keys.includes('S30:2026-09-08'));
assert.equal(
result.candidates.find((c) => c.key === 'S30:2026-09-18').coverage,
'full',
'coverage is computed against the box',
);
const thrown = await searchHls({
box: BOX,
days: 2,
now: '2026-09-21T00:00:00Z',
fetchImpl: cmrFetch({
S30: () => {
throw new Error('network down');
},
L30: { hits: 500, items: [ummItem({ conceptId: 'G-L30' })] },
}),
});
assert.deepEqual(thrown.errors, [
{ product: 'S30', message: 'network down' },
]);
assert.equal(thrown.truncated, true, 'CMR has more hits than were read');
assert.deepEqual(
thrown.candidates.map((c) => c.key),
['VIIRS:2026-09-21', 'VIIRS:2026-09-20', 'L30:2026-09-18'],
);
});
test('an invalid box or clock is refused before any request', async () => {
let called = false;
const fetchImpl = async () => {
called = true;
};
await assert.rejects(
searchHls({
box: { west: 179, south: 0, east: -179, north: 1 },
fetchImpl,
}),
/dateline/,
);
await assert.rejects(
searchHls({ box: BOX, now: 'never', fetchImpl }),
/clock/,
);
assert.equal(called, false);
});
test('CMR pages follow the CMR-Search-After cursor up to 2,000 records and truncation stays honest', async () => {
const items = (count) =>
Array.from({ length: count }, (_, i) => ummItem({ conceptId: `G-${i}` }));
const cursors = [];
const paged = await fetchCmrPages({
product: 'S30',
url: 'https://cmr/x',
fetchImpl: async (url, init) => {
const page = cursors.push(init.headers?.['CMR-Search-After'] ?? null);
return response({
headers: { 'CMR-Search-After': `cursor-${page}` },
json: { hits: 450, items: items(page < 3 ? 200 : 50) },
});
},
});
assert.equal(paged.granules.length, 450);
assert.equal(paged.truncated, false);
assert.deepEqual(cursors, [null, 'cursor-1', 'cursor-2']);
let pages = 0;
const capped = await fetchCmrPages({
product: 'L30',
url: 'https://cmr/y',
fetchImpl: async () => {
pages += 1;
return response({
headers: { 'cmr-search-after': 'more' },
json: { hits: 5000, items: items(200) },
});
},
});
assert.equal(pages, 10);
assert.equal(capped.granules.length, 2000);
assert.equal(capped.truncated, true);
const noCursor = await fetchCmrPages({
product: 'S30',
url: 'u',
fetchImpl: async () => response({ json: { hits: 900, items: items(200) } }),
});
assert.equal(noCursor.granules.length, 200);
assert.equal(noCursor.truncated, true);
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+613
View File
@@ -0,0 +1,613 @@
/**
* Pure domain model for the Recent Imagery layer: products, the selection box,
* per-day candidates, ranking and the keyless URL builders. No DOM, no Cesium,
* no network.
*/
/** How far back the catalog looks, in UTC days. */
export const CATALOG_DAYS = 30;
const MAX_BOX_SIDE_KM = 1000;
/** ZOOM IN lands where the top-down view is this wide, well inside the cap. */
const FIT_VIEW_KM = 400;
const FIT_HEIGHT_KM = Object.freeze({ min: 5, max: 400 });
/** GIBS EPSG:3857 tiles do not exist beyond the Web-Mercator limit. */
const MERCATOR_LAT_LIMIT = 85.0511;
const MAX_CLOUD_FOR_CLEAR = 20;
const PIN_BOX_SIDE_KM = 10;
const WVS_ENDPOINT = 'https://wvs.earthdata.nasa.gov/api/v1/snapshot';
const KM_PER_DEG_LAT = 111.32;
const QUANTUM = 100000;
const DAY_MS = 86_400_000;
const PRODUCT_ORDER = ['S30', 'L30', 'VIIRS'];
const MONTHS = 'Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec'.split(' ');
/**
* Keyless, browser-direct imagery products. HLS products are searched via
* CMR; the VIIRS overview is a daily global mosaic with no granule catalog.
* `source` is the on/off group the row chips toggle.
*/
export const PRODUCTS = Object.freeze({
S30: Object.freeze({
label: 'HLS S30',
name: 'Sentinel-2',
source: 'hls',
sensor: 'Sentinel-2 via HLS',
resolutionM: 30,
gibsLayer: 'HLS_S30_Nadir_BRDF_Adjusted_Reflectance',
maxLevel: 12,
format: 'png',
cmrCollection: 'C2021957295-LPCLOUD',
overview: false,
}),
L30: Object.freeze({
label: 'HLS L30',
name: 'Landsat 8/9',
source: 'hls',
sensor: 'Landsat 8/9 via HLS',
resolutionM: 30,
gibsLayer: 'HLS_L30_Nadir_BRDF_Adjusted_Reflectance',
maxLevel: 12,
format: 'png',
cmrCollection: 'C2021957657-LPCLOUD',
overview: false,
}),
VIIRS: Object.freeze({
label: 'VIIRS',
name: 'Daily overview',
source: 'viirs',
sensor: 'VIIRS NOAA-21',
resolutionM: 250,
gibsLayer: 'VIIRS_NOAA21_CorrectedReflectance_TrueColor',
maxLevel: 9,
format: 'jpg',
cmrCollection: null,
overview: true,
}),
});
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
const toRadians = (degrees) => (degrees * Math.PI) / 180;
const pad2 = (value) => String(value).padStart(2, '0');
/**
* Coerce a box to `{ west, south, east, north }` with south ≤ north. West and
* east are not swapped: west > east crosses the dateline and `validateBox`
* rejects it. Null when any edge is nonfinite.
*/
function normalizeBox(box) {
if (!box || typeof box !== 'object') return null;
const [west, south, east, north] = [
box.west,
box.south,
box.east,
box.north,
].map(Number);
if (![west, south, east, north].every(Number.isFinite)) return null;
return {
west,
south: Math.min(south, north),
east,
north: Math.max(south, north),
};
}
/**
* Side lengths of a box in km: the box's own longitude span (never the
* shorter way round the globe) at its mid-latitude, and its latitude span.
* @param {object} box Degrees box.
* @returns {{ width: number, height: number }}
*/
export function boxSideKm(box) {
const normalized = normalizeBox(box);
if (!normalized) return { width: NaN, height: NaN };
const midLat = (normalized.south + normalized.north) / 2;
return {
width:
(normalized.east - normalized.west) *
KM_PER_DEG_LAT *
Math.cos(toRadians(midLat)),
height: (normalized.north - normalized.south) * KM_PER_DEG_LAT,
};
}
const refuse = (reason, message) => ({ ok: false, reason, message });
/**
* Validate a selection box for the catalog and the tile providers.
* @param {object} box Degrees box.
* @returns {{ ok: true, box: object } | { ok: false, reason: 'dateline' | 'polar' | 'degenerate' | 'too-large' | 'invalid', message: string }}
*/
export function validateBox(box) {
const normalized = normalizeBox(box);
if (!normalized) return refuse('invalid', 'Box edges must be finite');
const { west, south, east, north } = normalized;
if (Math.abs(west) > 180 || Math.abs(east) > 180)
return refuse('invalid', 'Longitudes must be within ±180°');
if (west > east) return refuse('dateline', 'Select one side of the dateline');
if (
Math.abs(south) > MERCATOR_LAT_LIMIT ||
Math.abs(north) > MERCATOR_LAT_LIMIT
)
return refuse(
'polar',
`Imagery stops at ±${MERCATOR_LAT_LIMIT.toFixed(2)}° latitude`,
);
if (east === west || north === south)
return refuse('degenerate', 'Box must enclose an area');
const { width, height } = boxSideKm(normalized);
if (width > MAX_BOX_SIDE_KM || height > MAX_BOX_SIDE_KM)
return refuse('too-large', tooLargeMessage(Math.max(width, height)));
return { ok: true, box: normalized };
}
const formatKm = (km) => Math.round(Number(km) || 0).toLocaleString('en-US');
// One short line each: the panel's hint says what to do about it.
function tooLargeMessage(sideKm) {
return `Box is ${formatKm(sideKm)} km wide · limit ${formatKm(MAX_BOX_SIDE_KM)} km`;
}
/** The refusal for USE VIEW when the camera sees more than the cap allows. */
export function viewTooLargeMessage(sideKm) {
return `View is ${formatKm(sideKm)} km wide · limit ${formatKm(MAX_BOX_SIDE_KM)} km`;
}
/**
* The camera height, in metres, at which a top-down view is `FIT_VIEW_KM`
* wide: the horizontal half-angle is atan(tan(fovy / 2) · width / height).
* Clamped to 5–400 km; without a canvas or FOV, a square 60° view.
* @param {{ width?: number, height?: number, fovy?: number }} view Canvas size in px, vertical FOV in radians.
* @returns {number}
*/
export function fitViewHeightM({ width, height, fovy } = {}) {
const aspect = Number(width) / Number(height);
const valid =
Number.isFinite(aspect) && aspect > 0 && fovy > 0 && fovy < Math.PI;
const halfWidth = valid ? Math.tan(fovy / 2) * aspect : Math.tan(Math.PI / 6);
const km = FIT_VIEW_KM / (2 * halfWidth);
return Math.min(FIT_HEIGHT_KM.max, Math.max(FIT_HEIGHT_KM.min, km)) * 1000;
}
/**
* The centre of a box that does not cross the dateline, in degrees.
* @param {object} box Degrees box.
* @returns {{ lon: number, lat: number } | null}
*/
export function boxCentre(box) {
const normalized = normalizeBox(box);
if (!normalized) return null;
return {
lon: (normalized.west + normalized.east) / 2,
lat: (normalized.south + normalized.north) / 2,
};
}
/**
* A square box centred on a pin, `sideKm` on the ground (the east–west extent
* widens by 1/cos(lat)); null when the result would not validate.
* @param {number} lon Degrees.
* @param {number} lat Degrees.
* @param {number} [sideKm]
* @returns {object | null}
*/
export function boxFromPin(lon, lat, sideKm = PIN_BOX_SIDE_KM) {
if (!finite(lon) || !finite(lat) || !finite(sideKm) || sideKm <= 0)
return null;
const cosLat = Math.cos(toRadians(lat));
if (cosLat <= 0) return null;
const halfLat = sideKm / 2 / KM_PER_DEG_LAT;
const halfLon = sideKm / 2 / (KM_PER_DEG_LAT * cosLat);
const clampLat = (value) =>
Math.max(-MERCATOR_LAT_LIMIT, Math.min(MERCATOR_LAT_LIMIT, value));
const result = validateBox({
west: lon - halfLon,
south: clampLat(lat - halfLat),
east: lon + halfLon,
north: clampLat(lat + halfLat),
});
return result.ok ? result.box : null;
}
function mapBox(box, fn) {
if (!box || typeof box !== 'object') return null;
return normalizeBox({
west: fn(box.west),
south: fn(box.south),
east: fn(box.east),
north: fn(box.north),
});
}
/** Degrees box from a radians rectangle (Cesium `Rectangle` shape). */
export function boxFromRectangle(rectangle) {
return mapBox(rectangle, (radians) => (Number(radians) * 180) / Math.PI);
}
/** Degrees box → signed integers at degrees × 100000 (the share-link form). */
export function quantizeBox(box) {
return mapBox(normalizeBox(box), (value) => Math.round(value * QUANTUM));
}
/** Inverse of `quantizeBox`. */
export function dequantizeBox(ints) {
return mapBox(ints, (value) => Number(value) / QUANTUM);
}
function isValidDay(day) {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(day));
if (!match) return false;
const time = Date.UTC(
Number(match[1]),
Number(match[2]) - 1,
Number(match[3]),
);
return (
Number.isFinite(time) && new Date(time).toISOString().slice(0, 10) === day
);
}
/**
* UTC calendar day (`YYYY-MM-DD`) of an ISO timestamp, Date or day string;
* null when unparseable or not a real day.
* @param {string | number | Date} value
* @returns {string | null}
*/
export function utcDay(value) {
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value))
return isValidDay(value) ? value : null;
const time = value instanceof Date ? value.getTime() : Date.parse(value);
return Number.isFinite(time)
? new Date(time).toISOString().slice(0, 10)
: null;
}
/** `Sep 18` for a `YYYY-MM-DD` day; '' otherwise. */
export function shortDay(day) {
const match = /^\d{4}-(\d{2})-(\d{2})$/.exec(String(day || ''));
return match ? `${MONTHS[Number(match[1]) - 1]} ${Number(match[2])}` : '';
}
/** `17:12` (UTC) for an ISO timestamp; '' when unparseable. */
export function hhmm(iso) {
const time = Date.parse(iso);
if (!Number.isFinite(time)) return '';
const date = new Date(time);
return `${pad2(date.getUTCHours())}:${pad2(date.getUTCMinutes())}`;
}
/** Candidate key: `PRODUCT:YYYY-MM-DD`. */
export function candidateKey(product, day) {
return `${product}:${day}`;
}
/** Split a candidate key; null when the product is unknown or the day invalid. */
export function parseCandidateKey(key) {
const match = /^(S30|L30|VIIRS):(\d{4}-\d{2}-\d{2})$/.exec(String(key));
if (!match || !isValidDay(match[2])) return null;
return { product: match[1], day: match[2] };
}
const cloudOf = (granule) => (finite(granule?.cloud) ? granule.cloud : null);
function buildCandidate(product, day, granules) {
const clouds = granules.map(cloudOf).filter((value) => value !== null);
const starts = granules
.map((granule) => Date.parse(granule.timeStart))
.filter(Number.isFinite);
const ends = granules
.map((granule) => Date.parse(granule.timeEnd ?? granule.timeStart))
.filter(Number.isFinite);
return {
key: candidateKey(product, day),
product,
day,
granules,
cloud: clouds.length
? { min: Math.min(...clouds), max: Math.max(...clouds) }
: null,
timeRange:
starts.length && ends.length
? {
start: new Date(Math.min(...starts)).toISOString(),
end: new Date(Math.max(...ends)).toISOString(),
}
: null,
availability: granules.length ? 'present' : 'empty',
coverage: 'unknown',
};
}
/**
* Group granules into one candidate per product and UTC day of `timeStart`,
* newest first. Granules without a known product or start time are dropped.
* @param {Array<object>} granules
* @returns {Array<object>}
*/
export function groupGranulesByDay(granules) {
const buckets = new Map();
for (const granule of Array.isArray(granules) ? granules : []) {
const day = utcDay(granule?.timeStart);
if (!PRODUCTS[granule?.product] || !day) continue;
const key = candidateKey(granule.product, day);
if (!buckets.has(key))
buckets.set(key, { product: granule.product, day, granules: [] });
buckets.get(key).granules.push(granule);
}
return mergeCandidates([
[...buckets.values()].map(({ product, day, granules: list }) =>
buildCandidate(product, day, list),
),
]);
}
/**
* One VIIRS overview candidate per UTC day for the last `days` days, today
* first. Availability stays unknown until the thumbnail probe answers;
* coverage is full because the overview is a global mosaic.
* @param {string | Date} todayIso
* @param {number} [days]
* @returns {Array<object>}
*/
export function viirsCandidates(todayIso, days = CATALOG_DAYS) {
const today = utcDay(todayIso);
if (!today) return [];
const start = Date.parse(`${today}T00:00:00Z`);
const out = [];
for (let i = 0; i < Math.max(0, Math.trunc(days)); i += 1) {
const day = new Date(start - i * DAY_MS).toISOString().slice(0, 10);
out.push({
key: candidateKey('VIIRS', day),
product: 'VIIRS',
day,
granules: [],
cloud: null,
timeRange: null,
availability: 'unknown',
coverage: 'full',
});
}
return out;
}
/**
* Merge candidate lists newest day first, then S30, L30, VIIRS; the first
* occurrence of a key wins.
* @param {Array<Array<object>>} lists
* @returns {Array<object>}
*/
export function mergeCandidates(lists) {
const byKey = new Map();
for (const list of lists) {
for (const candidate of list || []) {
if (candidate?.key && !byKey.has(candidate.key))
byKey.set(candidate.key, candidate);
}
}
return [...byKey.values()].sort((a, b) =>
a.day !== b.day
? a.day < b.day
? 1
: -1
: PRODUCT_ORDER.indexOf(a.product) - PRODUCT_ORDER.indexOf(b.product),
);
}
function pointInPolygon(lon, lat, ring) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
const crosses =
yi > lat !== yj > lat &&
lon < ((xj - xi) * (lat - yi)) / (yj - yi || Number.EPSILON) + xi;
if (crosses) inside = !inside;
}
return inside;
}
/**
* How much of the box a candidate's granule footprints cover, sampled at the
* four corners and the centre: `full`, `partial`, or `unknown` without
* footprints.
* @param {object} candidate
* @param {object} box Degrees box.
* @returns {'full' | 'partial' | 'unknown'}
*/
export function coverageFor(candidate, box) {
const normalized = normalizeBox(box);
const footprints = (candidate?.granules || [])
.map((granule) => granule?.footprint)
.filter((ring) => Array.isArray(ring) && ring.length >= 3);
if (!normalized || !footprints.length) return 'unknown';
const { west, south, east, north } = normalized;
const samples = [
[west, south],
[east, south],
[east, north],
[west, north],
[(west + east) / 2, (south + north) / 2],
];
return samples.every(([lon, lat]) =>
footprints.some((ring) => pointInPolygon(lon, lat, ring)),
)
? 'full'
: 'partial';
}
/**
* Pick the START HERE day. Coverage first: a present HLS day that covers the
* whole box (`full`, or `unknown` without footprints) beats a newer sliver at
* the box edge, which changes none of the frame when draped. Within a tier
* the newest day whose granules ALL have known cloud ≤ `maxCloud` wins
* (`clear`), else the newest (`cloudy`); only slivers left → `partial`; else
* the newest non-empty VIIRS day (`overview`). `certain` is false when the
* catalog was truncated.
* @param {Array<object>} candidates
* @param {{ maxCloud?: number, truncated?: boolean }} [options]
* @returns {{ candidate: object | null, reason: 'clear' | 'cloudy' | 'partial' | 'overview' | null, certain: boolean }}
*/
export function rankLatest(
candidates,
{ maxCloud = MAX_CLOUD_FOR_CLEAR, truncated = false } = {},
) {
const sorted = mergeCandidates([candidates]);
const certain = !truncated;
const present = (c) => c.availability !== 'empty';
const hlsDay = (c) =>
PRODUCTS[c.product] &&
!PRODUCTS[c.product].overview &&
present(c) &&
c.granules.length > 0;
const coversBox = (c) => c.coverage !== 'partial';
const clearSky = (c) =>
c.granules.every((granule) => {
const cloud = cloudOf(granule);
return cloud !== null && cloud <= maxCloud;
});
const tiers = [
[(c) => hlsDay(c) && coversBox(c) && clearSky(c), 'clear'],
[(c) => hlsDay(c) && coversBox(c), 'cloudy'],
[(c) => hlsDay(c) && clearSky(c), 'partial'],
[hlsDay, 'partial'],
[(c) => c.product === 'VIIRS' && present(c), 'overview'],
];
for (const [matches, reason] of tiers) {
const candidate = sorted.find(matches);
if (candidate) return { candidate, reason, certain };
}
return { candidate: null, reason: null, certain };
}
function daysAgoLabel(day, now) {
const today = utcDay(now);
if (!today) return null;
const delta = Math.round(
(Date.parse(`${today}T00:00:00Z`) - Date.parse(`${day}T00:00:00Z`)) /
DAY_MS,
);
if (delta <= 0) return 'today';
return delta === 1 ? 'yesterday' : `${delta} days ago`;
}
function cloudLabel(cloud) {
if (!cloud) return 'cloud unknown';
const min = Math.round(cloud.min);
const max = Math.round(cloud.max);
return min === max ? `${min}% scene cloud` : `${min}–${max}% scene cloud`;
}
/**
* One-line UTC readout, e.g.
* `Sep 18, 2026 17:12Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12% scene cloud`.
* @param {object} candidate
* @param {string | number | Date} [now]
* @returns {string}
*/
export function formatCandidateReadout(candidate, now = new Date()) {
const product = PRODUCTS[candidate?.product];
if (!product || !candidate?.day) return '';
const [y, m, d] = candidate.day.split('-').map(Number);
let when = `${MONTHS[m - 1]} ${d}, ${y}`;
if (candidate.timeRange?.start) {
const start = hhmm(candidate.timeRange.start);
const end = candidate.timeRange.end ? hhmm(candidate.timeRange.end) : start;
when +=
(candidate.granules?.length || 0) > 1 && end !== start
? ` ${start}–${end}Z`
: ` ${start}Z`;
}
const parts = [when];
const ago = daysAgoLabel(candidate.day, now);
if (ago) parts.push(ago);
parts.push(product.sensor, `${product.resolutionM} m`);
if (product.overview) parts.push('overview');
parts.push(cloudLabel(candidate.cloud));
return parts.join(' · ');
}
/**
* GIBS WMTS REST template with `{s}`, `{z}`, `{y}`, `{x}` placeholders (y
* precedes x in the GIBS path).
* @param {string} product
* @param {string} day `YYYY-MM-DD`
* @returns {string}
*/
export function gibsTemplate(product, day) {
const spec = PRODUCTS[product];
if (!spec) throw new TypeError(`Unknown imagery product: ${product}`);
return `https://gibs-{s}.earthdata.nasa.gov/wmts/epsg3857/best/${spec.gibsLayer}/default/${day}/GoogleMapsCompatible_Level${spec.maxLevel}/{z}/{y}/{x}.${spec.format}`;
}
/**
* Worldview Snapshots URL for a thumbnail or export. EPSG:4326 BBOX order is
* lat,lon: `south,west,north,east`.
* @param {{ product: string, day: string, box: object, width: number, height: number, format?: string }} request
* @returns {string}
*/
export function wvsSnapshotUrl({
product,
day,
box,
width,
height,
format = 'image/png',
}) {
const spec = PRODUCTS[product];
const normalized = normalizeBox(box);
if (!spec) throw new TypeError(`Unknown imagery product: ${product}`);
if (!normalized) throw new TypeError('A finite box is required');
const bbox = [
normalized.south,
normalized.west,
normalized.north,
normalized.east,
]
.map((value) => String(Number(value.toFixed(6))))
.join(',');
return (
`${WVS_ENDPOINT}?REQUEST=GetSnapshot&LAYERS=${spec.gibsLayer}` +
`&CRS=EPSG:4326&TIME=${day}&BBOX=${bbox}` +
`&WIDTH=${Math.round(width)}&HEIGHT=${Math.round(height)}&FORMAT=${format}`
);
}
/**
* Thumbnail load order: the focused card (clamped into the visible range),
* the visible cards outward from it, then `extra` cards beyond each edge.
* @param {number} focusIndex
* @param {number} firstVisible
* @param {number} lastVisible
* @param {number} total
* @param {number} [extra]
* @returns {number[]}
*/
export function thumbnailOrder(
focusIndex,
firstVisible,
lastVisible,
total,
extra = 2,
) {
const count = Math.max(0, Math.trunc(total));
if (!count) return [];
const clamp = (value) => Math.max(0, Math.min(count - 1, Math.trunc(value)));
const first = clamp(Math.min(firstVisible, lastVisible));
const last = clamp(Math.max(firstVisible, lastVisible));
const focus = Math.max(first, Math.min(last, clamp(focusIndex)));
const order = [];
const push = (index) => {
if (index >= 0 && index < count && !order.includes(index))
order.push(index);
};
push(focus);
for (let step = 1; step <= last - first; step += 1) {
if (focus + step <= last) push(focus + step);
if (focus - step >= first) push(focus - step);
}
for (let step = 1; step <= Math.max(0, Math.trunc(extra)); step += 1) {
push(last + step);
push(first - step);
}
return order;
}
+439
View File
@@ -0,0 +1,439 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
boxFromPin,
boxFromRectangle,
boxSideKm,
coverageFor,
boxCentre,
dequantizeBox,
fitViewHeightM,
formatCandidateReadout,
gibsTemplate,
groupGranulesByDay,
mergeCandidates,
parseCandidateKey,
quantizeBox,
rankLatest,
shortDay,
thumbnailOrder,
utcDay,
validateBox,
viewTooLargeMessage,
viirsCandidates,
wvsSnapshotUrl,
} from './model.js';
import { BOX } from './testDoubles.mjs';
function granule(overrides = {}) {
return {
id: 'g',
product: 'S30',
timeStart: '2026-09-18T17:12:00.000Z',
timeEnd: '2026-09-18T17:12:30.000Z',
cloud: 12,
footprint: null,
...overrides,
};
}
function hls(product, day, clouds, extra = {}) {
const granules = clouds.map((cloud, index) =>
granule({
id: `${product}-${day}-${index}`,
product,
timeStart: `${day}T17:1${index}:00.000Z`,
timeEnd: `${day}T17:1${index}:30.000Z`,
cloud,
}),
);
return { ...groupGranulesByDay(granules)[0], ...extra };
}
test('validateBox refuses dateline, polar, degenerate, invalid and oversized boxes', () => {
const reason = (box) => validateBox(box).reason;
assert.equal(
reason({ west: 179, south: 0, east: -179, north: 1 }),
'dateline',
);
assert.equal(
reason({ west: 0, south: 84, east: 0.5, north: 85.05111 }),
'polar',
);
assert.equal(
reason({ west: 0, south: -85.0512, east: 0.5, north: -84 }),
'polar',
);
// Refusals fit the one-line notice.
assert.equal(
validateBox({ west: 0, south: 84, east: 0.5, north: 85.05111 }).message,
'Imagery stops at ±85.05° latitude',
);
assert.equal(
validateBox({ west: 179, south: 0, east: -179, north: 1 }).message,
'Select one side of the dateline',
);
assert.equal(
validateBox({ west: 0, south: 84.9, east: 0.05, north: 85 }).ok,
true,
);
assert.equal(
reason({ west: 10, south: 10, east: 10, north: 11 }),
'degenerate',
);
assert.equal(
reason({ west: Infinity, south: 0, east: 1, north: 1 }),
'invalid',
);
assert.equal(reason({ west: -181, south: 0, east: 1, north: 1 }), 'invalid');
assert.equal(reason(undefined), 'invalid');
// The cap is 1,000 km a side, and the refusal says how big the box is.
const tall = validateBox({ west: 0, south: 0, east: 0.1, north: 10 });
assert.equal(tall.reason, 'too-large');
assert.equal(tall.message, 'Box is 1,113 km wide · limit 1,000 km');
assert.equal(
reason({ west: 0, south: 0, east: 10, north: 0.1 }),
'too-large',
);
assert.equal(validateBox({ west: 0, south: 0, east: 8, north: 8 }).ok, true);
assert.equal(
viewTooLargeMessage(2400),
'View is 2,400 km wide · limit 1,000 km',
);
// Latitudes are ordered; longitudes are never swapped.
assert.deepEqual(
validateBox({ west: -97.8, south: 30.3, east: -97.7, north: 30.2 }).box,
BOX,
);
});
test('ZOOM IN: the height whose top-down view is 400 km wide, clamped to 5–400 km; a box centre', () => {
const near = (actual, expected, label) =>
assert.ok(Math.abs(actual - expected) < 1e-6, `${label}: ${actual}`);
// A 1000 × 500 canvas with Cesium's 60° horizontal FOV: tan(fovy / 2) is
// tan(30°) / 2, so the view is 2 · h · tan(30°) wide.
const fovy = 2 * Math.atan(Math.tan(Math.PI / 6) / 2);
const height = fitViewHeightM({ width: 1000, height: 500, fovy });
near(height, 400_000 / (2 * Math.tan(Math.PI / 6)), 'wide canvas');
near(
2 * height * Math.tan(fovy / 2) * 2,
400_000,
'the view at that height is 400 km wide',
);
// A tall canvas would need more than 400 km; a very wide one less than 5.
assert.equal(
fitViewHeightM({ width: 200, height: 1000, fovy: Math.PI / 3 }),
400_000,
);
assert.equal(fitViewHeightM({ width: 1000, height: 500, fovy: 3.1 }), 5_000);
// No canvas or FOV: a square 60° view.
near(
fitViewHeightM({ width: 0, height: 0, fovy: undefined }),
400_000 / (2 * Math.tan(Math.PI / 6)),
'fallback',
);
assert.deepEqual(boxCentre({ west: 0, south: 0, east: 20, north: 10 }), {
lon: 10,
lat: 5,
});
assert.equal(boxCentre(null), null);
});
test('box width is the longitude span at the mid-latitude, never the shortest arc', () => {
const equator = boxSideKm({ west: 0, south: -0.5, east: 1, north: 0.5 });
assert.ok(Math.abs(equator.width - 111.32) < 1e-6);
assert.ok(Math.abs(equator.height - 111.32) < 1e-6);
const north = boxSideKm({ west: 0, south: 59.5, east: 1, north: 60.5 });
assert.ok(north.width > 55 && north.width < 56.5, String(north.width));
const nearlyGlobal = { west: -179.9, south: 30, east: 179.9, north: 30.1 };
assert.ok(boxSideKm(nearlyGlobal).width > 30000);
assert.equal(validateBox(nearlyGlobal).reason, 'too-large');
});
test('boxFromPin builds a 10 km square within 2% at any latitude and refuses what cannot validate', () => {
for (const [lon, lat] of [
[-97.75, 30.27],
[18.07, 59.33],
[0, 0],
[-70, -45],
]) {
const box = boxFromPin(lon, lat);
const { width, height } = boxSideKm(box);
assert.ok(Math.abs(width - 10) < 0.2, `width ${width} at ${lat}`);
assert.ok(Math.abs(height - 10) < 0.2, `height ${height} at ${lat}`);
assert.ok(Math.abs((box.west + box.east) / 2 - lon) < 1e-9);
}
assert.ok(Math.abs(boxSideKm(boxFromPin(10, 10, 50)).width - 50) < 1);
assert.equal(boxFromPin(NaN, 0), null);
assert.equal(boxFromPin(0, 0, 5000), null, 'over the cap');
assert.equal(boxFromPin(179.99, 0), null, 'would cross the dateline');
assert.equal(boxFromPin(0, 89), null, 'clamped to a degenerate polar box');
assert.equal(boxFromPin(0, 85.04).north, 85.0511, 'clamped, still valid');
});
test('rectangles convert from radians and the share-link quantization round-trips', () => {
const box = boxFromRectangle({
west: -Math.PI / 2,
south: 0,
east: -Math.PI / 4,
north: Math.PI / 6,
});
assert.ok(Math.abs(box.west + 90) < 1e-9 && Math.abs(box.north - 30) < 1e-9);
assert.equal(boxFromRectangle(null), null);
const ints = quantizeBox({
west: -97.812345678,
south: 30.2,
east: -97.7,
north: 30.3999999,
});
assert.deepEqual(ints, {
west: -9781235,
south: 3020000,
east: -9770000,
north: 3040000,
});
assert.deepEqual(quantizeBox(dequantizeBox(ints)), ints);
assert.equal(quantizeBox(null), null);
assert.equal(dequantizeBox({ west: 'x' }), null);
});
test('days and candidate keys are real UTC calendar days', () => {
assert.equal(utcDay('2026-09-18T20:00:00-05:00'), '2026-09-19');
assert.equal(utcDay(new Date(Date.UTC(2026, 0, 1))), '2026-01-01');
assert.equal(utcDay('2026-02-30'), null);
assert.equal(utcDay('nope'), null);
assert.deepEqual(parseCandidateKey('L30:2026-09-10'), {
product: 'L30',
day: '2026-09-10',
});
assert.equal(parseCandidateKey('X30:2026-09-10'), null);
assert.equal(parseCandidateKey('S30:2026-02-30'), null);
assert.equal(shortDay('2026-09-18'), 'Sep 18');
assert.equal(shortDay('nope'), '');
});
test('granules group into one candidate per product and UTC day, newest first', () => {
const candidates = groupGranulesByDay([
granule({ id: 'a', cloud: 12, timeEnd: '2026-09-18T17:12:40Z' }),
granule({
id: 'b',
cloud: 18,
timeStart: '2026-09-18T17:20:00Z',
timeEnd: '2026-09-18T17:20:40Z',
}),
granule({
id: 'c',
cloud: null,
product: 'L30',
timeStart: '2026-09-18T16:00:00Z',
timeEnd: null,
}),
granule({ id: 'd', cloud: 3, timeStart: '2026-09-10T17:00:00Z' }),
granule({ id: 'e', timeStart: 'garbage' }),
granule({ id: 'f', product: 'nope' }),
]);
assert.deepEqual(
candidates.map((c) => c.key),
['S30:2026-09-18', 'L30:2026-09-18', 'S30:2026-09-10'],
);
const [s18, l18] = candidates;
assert.deepEqual(s18.cloud, { min: 12, max: 18 });
assert.deepEqual(s18.timeRange, {
start: '2026-09-18T17:12:00.000Z',
end: '2026-09-18T17:20:40.000Z',
});
assert.equal(s18.availability, 'present');
assert.equal(l18.cloud, null, 'missing cloud stays unknown');
assert.equal(l18.timeRange.end, '2026-09-18T16:00:00.000Z');
});
test('VIIRS lists the last 30 UTC days as unprobed full-coverage days', () => {
const days = viirsCandidates('2026-03-01T13:00:00Z', 3);
assert.deepEqual(
days.map((c) => c.key),
['VIIRS:2026-03-01', 'VIIRS:2026-02-28', 'VIIRS:2026-02-27'],
);
assert.equal(days[0].availability, 'unknown');
assert.equal(days[0].coverage, 'full');
assert.equal(viirsCandidates('2026-09-21').length, 30);
assert.deepEqual(viirsCandidates('bad'), []);
});
test('merging sorts newest day first, then S30, L30, VIIRS, and the first key wins', () => {
const merged = mergeCandidates([
viirsCandidates('2026-09-18', 2),
[hls('L30', '2026-09-18', [5]), hls('S30', '2026-09-17', [5])],
[hls('S30', '2026-09-18', [7]), hls('S30', '2026-09-18', [99])],
]);
assert.deepEqual(
merged.map((c) => c.key),
[
'S30:2026-09-18',
'L30:2026-09-18',
'VIIRS:2026-09-18',
'S30:2026-09-17',
'VIIRS:2026-09-17',
],
);
assert.deepEqual(merged[0].cloud, { min: 7, max: 7 });
});
test('coverage samples the box corners and centre against the union of footprints', () => {
const ring = (west, east) => [
[west, 30],
[east, 30],
[east, 31],
[west, 31],
];
const withRings = (...rings) => ({
granules: rings.map((footprint) => granule({ footprint })),
});
assert.equal(coverageFor(withRings(ring(-98, -97)), BOX), 'full');
assert.equal(coverageFor(withRings(ring(-98, -97.75)), BOX), 'partial');
assert.equal(
coverageFor(withRings(ring(-98, -97.75), ring(-97.75, -97)), BOX),
'full',
);
assert.equal(coverageFor(withRings(null), BOX), 'unknown');
});
test('START HERE is the newest all-clear HLS day; unknown cloud is never clear', () => {
const candidates = [
hls('S30', '2026-09-20', [10, null]),
hls('S30', '2026-09-19', [5, 21]),
hls('S30', '2026-09-18', [0, 20]),
];
assert.deepEqual(rankLatest([...candidates].reverse()), {
candidate: candidates[2],
reason: 'clear',
certain: true,
});
assert.equal(rankLatest(candidates, { truncated: true }).certain, false);
// Then the newest cloudy HLS day, then the newest non-empty VIIRS day.
const cloudy = rankLatest([
hls('L30', '2026-09-20', [45]),
hls('S30', '2026-09-19', [null]),
]);
assert.equal(cloudy.candidate.key, 'L30:2026-09-20');
assert.equal(cloudy.reason, 'cloudy');
const viirs = viirsCandidates('2026-09-21', 3);
viirs[0] = { ...viirs[0], availability: 'empty' };
const empty = { ...hls('S30', '2026-09-21', [0]), availability: 'empty' };
const overview = rankLatest([empty, ...viirs]);
assert.equal(overview.candidate.key, 'VIIRS:2026-09-20');
assert.equal(overview.reason, 'overview');
assert.equal(rankLatest([]).candidate, null);
});
test('START HERE prefers a day that covers the whole box over a newer sliver', () => {
const full = (day, clouds) => hls('S30', day, clouds, { coverage: 'full' });
const sliver = (day, clouds) =>
hls('S30', day, clouds, { coverage: 'partial' });
const pick = (list) => {
const { candidate, reason } = rankLatest(list);
return `${candidate?.day} ${reason}`;
};
assert.equal(
pick([sliver('2026-09-20', [0]), full('2026-09-18', [10])]),
'2026-09-18 clear',
);
assert.equal(
pick([full('2026-09-20', [null]), sliver('2026-09-19', [0])]),
'2026-09-20 cloudy',
);
// Unknown coverage (no footprints) counts as whole-box.
assert.equal(
pick([
hls('S30', '2026-09-20', [5], { coverage: 'unknown' }),
full('2026-09-18', [5]),
]),
'2026-09-20 clear',
);
// Only slivers: the newest clear one, then the newest at all.
assert.equal(
pick([sliver('2026-09-20', [50]), sliver('2026-09-19', [0])]),
'2026-09-19 partial',
);
assert.equal(
pick([sliver('2026-09-20', [50]), sliver('2026-09-19', [null])]),
'2026-09-20 partial',
);
assert.equal(
pick([...viirsCandidates('2026-09-21', 2), sliver('2026-09-19', [70])]),
'2026-09-19 partial',
'a sliver still beats the overview',
);
});
test('the readout is one UTC line with age, sensor, resolution and scene cloud', () => {
const now = '2026-09-21T12:00:00Z';
const readout = (granules) =>
formatCandidateReadout(groupGranulesByDay(granules)[0], now);
assert.equal(
readout([granule({ timeStart: '2026-09-18T17:12:10Z' })]),
'Sep 18, 2026 17:12Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12% scene cloud',
);
assert.equal(
readout([
granule({ id: 'a' }),
granule({
id: 'b',
cloud: 18.4,
timeStart: '2026-09-18T17:20:00Z',
timeEnd: '2026-09-18T17:20:30Z',
}),
]),
'Sep 18, 2026 17:12–17:20Z · 3 days ago · Sentinel-2 via HLS · 30 m · 12–18% scene cloud',
);
assert.equal(
readout([
granule({
product: 'L30',
cloud: null,
timeStart: '2026-09-20T16:05:00Z',
timeEnd: null,
}),
]),
'Sep 20, 2026 16:05Z · yesterday · Landsat 8/9 via HLS · 30 m · cloud unknown',
);
assert.equal(
formatCandidateReadout(viirsCandidates('2026-09-21', 1)[0], now),
'Sep 21, 2026 · today · VIIRS NOAA-21 · 250 m · overview · cloud unknown',
);
assert.equal(formatCandidateReadout(null), '');
});
test('GIBS templates put y before x; Worldview snapshots take south,west,north,east', () => {
assert.equal(
gibsTemplate('S30', '2026-09-18'),
'https://gibs-{s}.earthdata.nasa.gov/wmts/epsg3857/best/HLS_S30_Nadir_BRDF_Adjusted_Reflectance/default/2026-09-18/GoogleMapsCompatible_Level12/{z}/{y}/{x}.png',
);
assert.equal(
gibsTemplate('VIIRS', '2026-09-21'),
'https://gibs-{s}.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_NOAA21_CorrectedReflectance_TrueColor/default/2026-09-21/GoogleMapsCompatible_Level9/{z}/{y}/{x}.jpg',
);
assert.match(gibsTemplate('L30', '2026-09-10'), /HLS_L30_.*Level12.*\.png$/);
assert.throws(() => gibsTemplate('MODIS', '2026-01-01'), /Unknown/);
assert.equal(
wvsSnapshotUrl({
product: 'S30',
day: '2026-09-18',
box: BOX,
width: 256,
height: 256,
}),
'https://wvs.earthdata.nasa.gov/api/v1/snapshot?REQUEST=GetSnapshot&LAYERS=HLS_S30_Nadir_BRDF_Adjusted_Reflectance&CRS=EPSG:4326&TIME=2026-09-18&BBOX=30.2,-97.8,30.3,-97.7&WIDTH=256&HEIGHT=256&FORMAT=image/png',
);
assert.throws(
() => wvsSnapshotUrl({ product: 'S30', day: 'x', box: null }),
TypeError,
);
});
test('thumbnails load the focused card, then the visible cards outward, then the margins', () => {
assert.deepEqual(thumbnailOrder(5, 3, 7, 20), [5, 6, 4, 7, 3, 8, 2, 9, 1]);
assert.deepEqual(thumbnailOrder(19, 17, 19, 20), [19, 18, 17, 16, 15]);
assert.deepEqual(thumbnailOrder(50, 7, 3, 10), [7, 6, 5, 4, 3, 8, 2, 9, 1]);
assert.deepEqual(thumbnailOrder(0, 0, 0, 0), []);
});
+265
View File
@@ -0,0 +1,265 @@
/**
* Draped-imagery renderer for the Recent Imagery layer: at most two GIBS
* imagery layers (slot `a`, left of a swipe; slot `b`, right) on whichever
* host the map stack offers. Against the basemap slot `a` splits left and
* slot `b` stays empty. Every layer is removed from the collection that owns it, so a
* host switch or a replaced image never strands one. Tile requests pass a
* small semaphore of our own; Cesium's global request limits stay untouched.
*/
import * as Cesium from 'cesium';
import { PRODUCTS, gibsTemplate } from './model.js';
const SLOT_IDS = ['a', 'b'];
const SPLIT_ENUM = { left: 'LEFT', right: 'RIGHT', none: 'NONE' };
const NO_HOST = Object.freeze({ collection: null, kind: 'none' });
/** Render reason for a deferred or settled tile request. */
export const TILE_RENDER_REASON = 'recent-imagery-tiles';
/** Delay before the one frame that retries tiles Cesium itself deferred. */
export const TILE_RETRY_DELAY_MS = 250;
function clampAlpha(value) {
const alpha = Number(value);
return Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1;
}
const boxKey = (box) =>
[box.west, box.south, box.east, box.north].map(Number).join(',');
/**
* Gate a provider's `requestImage` through the semaphore. Returning
* `undefined` tells Cesium to retry on a later frame, but under
* `requestRenderMode` there is no later frame unless something asks — and a
* deferral must never ask for one itself: the retried tile would defer again
* and ask again, keeping the scene rendering every frame. So a request that
* finds our semaphore full asks for nothing (the request holding the slot
* asks for one frame when it settles), and a request the provider itself
* deferred goes through the one coalesced delayed retry.
*/
function admitTileRequests(provider, admission, requestRender, retry) {
const original = provider.requestImage.bind(provider);
provider.requestImage = (x, y, level, request) => {
if (admission.inFlight >= admission.limit) return undefined;
admission.inFlight += 1;
const release = () => {
admission.inFlight -= 1;
};
let promise;
try {
promise = original(x, y, level, request);
} catch (error) {
release();
throw error;
}
if (!promise) {
release();
retry.schedule();
return undefined;
}
return Promise.resolve(promise).finally(() => {
release();
requestRender(TILE_RENDER_REASON);
});
};
return provider;
}
/**
* Create the renderer.
* @param {{ cesium?: object, maxTileRequests?: number, requestRender?: (reason: string) => void, setTimeoutImpl?: Function, clearTimeoutImpl?: Function }} [options]
*/
export function createRecentImageryRenderer({
cesium = Cesium,
maxTileRequests = 6,
requestRender = () => {},
setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms),
clearTimeoutImpl = (id) => globalThis.clearTimeout(id),
} = {}) {
let host = NO_HOST;
let destroyed = false;
const admission = { limit: maxTileRequests, inFlight: 0 };
/** @type {Record<'a'|'b', null | { layer: object, collection: object, candidate: object, box: object, alpha: number, split: string }>} */
const owned = { a: null, b: null };
// One timer for both slots: any number of upstream deferrals while it is
// armed share the same single retry frame.
let retryTimer = null;
const retry = {
schedule() {
if (retryTimer !== null) return;
retryTimer = setTimeoutImpl(() => {
retryTimer = null;
if (!destroyed) requestRender(TILE_RENDER_REASON);
}, TILE_RETRY_DELAY_MS);
},
cancel() {
if (retryTimer !== null) clearTimeoutImpl(retryTimer);
retryTimer = null;
},
};
function createProvider(candidate, box) {
const spec = PRODUCTS[candidate.product];
const provider = new cesium.UrlTemplateImageryProvider({
url: gibsTemplate(candidate.product, candidate.day),
subdomains: ['a', 'b', 'c'],
minimumLevel: 0,
maximumLevel: spec.maxLevel,
tilingScheme: new cesium.WebMercatorTilingScheme(),
rectangle: cesium.Rectangle.fromDegrees(
box.west,
box.south,
box.east,
box.north,
),
credit: 'NASA GIBS',
hasAlphaChannel: spec.format === 'png',
});
return admitTileRequests(provider, admission, requestRender, retry);
}
function removeOwned(slotId) {
const record = owned[slotId];
if (!record) return false;
owned[slotId] = null;
try {
record.collection.remove(record.layer, true);
} catch {
/* the collection is already gone */
}
return true;
}
function applyLook(record) {
record.layer.alpha = record.alpha;
record.layer.splitDirection =
cesium.SplitDirection?.[SPLIT_ENUM[record.split] || 'NONE'] ?? 0;
}
function mount(slotId, { candidate, box, alpha, split }) {
const layer = host.collection.addImageryProvider(
createProvider(candidate, box),
);
owned[slotId] = {
layer,
collection: host.collection,
candidate,
box,
alpha,
split,
};
applyLook(owned[slotId]);
}
return {
/**
* Drape a candidate into a slot. The same candidate on the same host only
* updates its look; anything else replaces the slot's layer.
* @param {'a'|'b'} slotId
* @param {object} candidate
* @param {object} box Degrees box.
* @param {{ alpha?: number, splitDirection?: 'left'|'right'|'none' }} [look]
* @returns {boolean} Whether a layer is now draped for the slot.
*/
showSlot(
slotId,
candidate,
box,
{ alpha = 1, splitDirection = 'none' } = {},
) {
if (destroyed || !candidate?.key || !box) return false;
if (!host.collection) {
removeOwned(slotId);
return false;
}
const current = owned[slotId];
const look = { alpha: clampAlpha(alpha), split: splitDirection };
if (
current &&
current.collection === host.collection &&
current.candidate.key === candidate.key &&
boxKey(current.box) === boxKey(box)
) {
if (current.alpha !== look.alpha || current.split !== look.split) {
Object.assign(current, look);
applyLook(current);
requestRender('recent-imagery-look');
}
return true;
}
removeOwned(slotId);
mount(slotId, { candidate, box, ...look });
requestRender('recent-imagery-show');
return true;
},
/** @param {'a'|'b'} slotId */
hideSlot(slotId) {
if (removeOwned(slotId)) requestRender('recent-imagery-hide');
},
/**
* @param {'a'|'b'} slotId
* @param {number} alpha 0–1
*/
setAlpha(slotId, alpha) {
const record = owned[slotId];
const next = clampAlpha(alpha);
if (!record || record.alpha === next) return;
record.alpha = next;
applyLook(record);
requestRender('recent-imagery-alpha');
},
/**
* Move every owned layer to a new host. Layers are rebuilt there, never
* re-parented, so the old collection is left as it was found.
* @param {{ collection: object | null, kind: string }} nextHost
*/
rebind(nextHost) {
host = nextHost || NO_HOST;
let changed = false;
for (const slotId of SLOT_IDS) {
const record = owned[slotId];
if (!record || record.collection === host.collection) continue;
removeOwned(slotId);
changed = true;
if (host.collection && !destroyed) mount(slotId, record);
}
if (changed) requestRender('recent-imagery-rebind');
},
/** What is draped right now, per slot. */
getOwned() {
const out = {};
for (const slotId of SLOT_IDS) {
const record = owned[slotId];
out[slotId] = record
? {
key: record.candidate.key,
kind: host.kind,
alpha: record.alpha,
split: record.split,
}
: null;
}
return out;
},
/** Number of layers this renderer owns (never above two). */
ownedCount() {
return SLOT_IDS.filter((slotId) => owned[slotId]).length;
},
/** Remove everything and refuse further work. */
destroy() {
if (destroyed) return;
destroyed = true;
retry.cancel();
let changed = false;
for (const slotId of SLOT_IDS) changed = removeOwned(slotId) || changed;
host = NO_HOST;
if (changed) requestRender('recent-imagery-destroy');
},
};
}
+210
View File
@@ -0,0 +1,210 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
TILE_RENDER_REASON,
TILE_RETRY_DELAY_MS,
createRecentImageryRenderer,
} from './rendering.js';
import { BOX, manualTimers } from './testDoubles.mjs';
/** A provider whose tile requests stay pending until the test settles them. */
class UrlTemplateImageryProvider {
constructor(options) {
this.options = options;
this.calls = [];
this.pending = [];
}
requestImage(x, y, level) {
this.calls.push([x, y, level]);
return new Promise((resolve) => this.pending.push(resolve));
}
}
function fakeCesium(requestImage) {
class Provider extends UrlTemplateImageryProvider {}
if (requestImage) Provider.prototype.requestImage = requestImage;
return {
UrlTemplateImageryProvider: Provider,
WebMercatorTilingScheme: class {},
Rectangle: {
fromDegrees: (west, south, east, north) => ({ west, south, east, north }),
},
SplitDirection: { LEFT: -1, NONE: 0, RIGHT: 1 },
};
}
function fakeCollection() {
const layers = [];
return {
layers,
addImageryProvider(provider) {
const layer = { provider, alpha: 1, splitDirection: 0 };
layers.push(layer);
return layer;
},
remove(layer, destroy) {
layers.splice(layers.indexOf(layer), 1);
layer.destroyed = destroy;
},
};
}
const S30 = { key: 'S30:2026-09-18', product: 'S30', day: '2026-09-18' };
const L30 = { key: 'L30:2026-09-16', product: 'L30', day: '2026-09-16' };
const VIIRS = { key: 'VIIRS:2026-09-20', product: 'VIIRS', day: '2026-09-20' };
function fixture({ requestImage, maxTileRequests } = {}) {
const renders = [];
const timers = manualTimers();
const renderer = createRecentImageryRenderer({
cesium: fakeCesium(requestImage),
maxTileRequests,
requestRender: (reason) => renders.push(reason),
setTimeoutImpl: timers.setTimeoutImpl,
clearTimeoutImpl: timers.clearTimeoutImpl,
});
const globe = fakeCollection();
renderer.rebind({ collection: globe, kind: 'globe' });
const tileFrames = () =>
renders.filter((reason) => reason === TILE_RENDER_REASON).length;
return { renderer, globe, renders, timers, tileFrames };
}
test('a slot drapes one GIBS provider bounded to the box; the same day only restyles it', () => {
const { renderer, globe, renders } = fixture();
assert.equal(renderer.showSlot('a', S30, BOX, { alpha: 0.8 }), true);
const [layer] = globe.layers;
assert.match(
layer.provider.options.url,
/^https:\/\/gibs-\{s\}\.earthdata\.nasa\.gov\/.*HLS_S30.*\/2026-09-18\/GoogleMapsCompatible_Level12\/\{z\}\/\{y\}\/\{x\}\.png$/,
);
assert.deepEqual(layer.provider.options.subdomains, ['a', 'b', 'c']);
assert.equal(layer.provider.options.credit, 'NASA GIBS');
assert.deepEqual(layer.provider.options.rectangle, BOX);
assert.equal(layer.alpha, 0.8);
assert.deepEqual(renders, ['recent-imagery-show']);
assert.deepEqual(renderer.getOwned().a, {
key: 'S30:2026-09-18',
kind: 'globe',
alpha: 0.8,
split: 'none',
});
renderer.showSlot('a', S30, BOX, { alpha: 0.5, splitDirection: 'left' });
assert.deepEqual(globe.layers, [layer], 'the layer is reused');
assert.equal(layer.splitDirection, -1);
// A different day replaces the layer in its own collection.
renderer.showSlot('a', L30, BOX);
assert.equal(layer.destroyed, true);
assert.equal(globe.layers.length, 1);
// VIIRS is a level-9 JPEG overview with no alpha channel.
renderer.showSlot('b', VIIRS, BOX, { splitDirection: 'right' });
assert.equal(globe.layers[1].provider.options.maximumLevel, 9);
assert.equal(globe.layers[1].provider.options.hasAlphaChannel, false);
assert.equal(globe.layers[1].splitDirection, 1);
renderer.setAlpha('b', 7);
assert.equal(globe.layers[1].alpha, 1, 'alpha clamps to 0–1');
});
test('rapid replacement never owns more than two layers', () => {
const { renderer, globe } = fixture();
const days = [S30, L30, VIIRS];
for (let i = 0; i < 10; i += 1) {
renderer.showSlot('a', days[i % 3], BOX);
renderer.showSlot('b', days[(i + 1) % 3], BOX);
if (i % 4 === 0) renderer.hideSlot('a');
assert.ok(globe.layers.length <= 2 && renderer.ownedCount() <= 2);
}
renderer.hideSlot('a');
renderer.hideSlot('b');
assert.equal(globe.layers.length, 0);
});
test('rebind rebuilds the owned layers on the new host and leaves the old one empty', () => {
const { renderer, globe } = fixture();
renderer.showSlot('a', S30, BOX, { alpha: 0.6, splitDirection: 'left' });
renderer.showSlot('b', L30, BOX, { splitDirection: 'right' });
const tileset = fakeCollection();
renderer.rebind({ collection: tileset, kind: 'tileset' });
assert.equal(globe.layers.length, 0);
assert.deepEqual(
tileset.layers.map((layer) => [layer.alpha, layer.splitDirection]),
[
[0.6, -1],
[1, 1],
],
);
assert.equal(renderer.getOwned().a.kind, 'tileset');
renderer.rebind({ collection: null, kind: 'none' });
assert.equal(tileset.layers.length, 0);
assert.equal(renderer.showSlot('a', S30, BOX), false);
renderer.rebind({ collection: globe, kind: 'globe' });
renderer.showSlot('a', S30, BOX);
renderer.destroy();
assert.equal(globe.layers.length, 0);
assert.equal(renderer.showSlot('a', S30, BOX), false, 'destroyed');
});
test('above the tile limit a request defers silently; its settlement asks for exactly one frame', async () => {
const { renderer, globe, timers, tileFrames } = fixture({
maxTileRequests: 1,
});
renderer.showSlot('a', S30, BOX);
const { provider } = globe.layers[0];
const first = provider.requestImage(0, 0, 1);
assert.ok(first instanceof Promise);
for (let i = 1; i <= 120; i += 1)
assert.equal(provider.requestImage(i, 0, 1), undefined);
assert.equal(provider.calls.length, 1, 'deferred requests never go out');
assert.equal(tileFrames(), 0, 'a deferral asks for no frame');
assert.equal(timers.armed(), 0);
provider.pending[0]('tile');
await first;
assert.equal(tileFrames(), 1, 'the settlement asks for the retry frame');
assert.ok(provider.requestImage(200, 0, 1) instanceof Promise);
});
test('upstream deferrals from both slots share one delayed retry frame; destroy disarms it', () => {
let throwNext = false;
const { renderer, globe, timers, tileFrames } = fixture({
maxTileRequests: 1,
requestImage() {
if (throwNext) throw new Error('boom');
return undefined;
},
});
renderer.showSlot('a', S30, BOX);
renderer.showSlot('b', L30, BOX);
for (const { provider } of globe.layers)
for (let i = 0; i < 40; i += 1)
assert.equal(provider.requestImage(i, 0, 0), undefined);
// Every deferral released its slot (the limit is one), and none asked
// for a frame of its own: one timer holds the single retry.
assert.equal(tileFrames(), 0);
assert.equal(timers.armed(), 1);
assert.equal([...timers.pending.values()][0].ms, TILE_RETRY_DELAY_MS);
timers.flush();
assert.equal(tileFrames(), 1);
// A throwing provider releases its slot too.
throwNext = true;
assert.throws(() => globe.layers[0].provider.requestImage(0, 0, 0), /boom/);
throwNext = false;
globe.layers[0].provider.requestImage(0, 0, 0);
assert.equal(timers.armed(), 1, 'the next deferral arms a fresh timer');
renderer.destroy();
assert.equal(timers.armed(), 0, 'destroy disarms the retry');
assert.equal(tileFrames(), 1);
});
test('against the basemap slot a splits left with no second layer, and leaving the swipe only restyles it', () => {
const { renderer, globe, renders } = fixture();
renderer.showSlot('a', S30, BOX, { splitDirection: 'left' });
renderer.hideSlot('b');
assert.equal(globe.layers.length, 1, 'the basemap is the right side');
assert.equal(globe.layers[0].splitDirection, -1);
assert.equal(renderer.getOwned().b, null);
const [layer] = globe.layers;
renderer.showSlot('a', S30, BOX, { splitDirection: 'none' });
assert.deepEqual(globe.layers, [layer], 'no rebuild for a new look');
assert.equal(layer.splitDirection, 0);
assert.deepEqual(renders, ['recent-imagery-show', 'recent-imagery-look']);
});
+368
View File
@@ -0,0 +1,368 @@
/**
* Test doubles shared by the Recent Imagery tests: manual timers, fetch
* responses, and the Cesium/viewer/document fakes the box tool needs.
*/
import { thumbnailOrder } from './model.js';
export const BOX = Object.freeze({
west: -97.8,
south: 30.2,
east: -97.7,
north: 30.3,
});
export const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/** Timers that fire only when the test says so. */
export function manualTimers() {
const pending = new Map();
const cleared = [];
let nextId = 1;
return {
pending,
cleared,
setTimeoutImpl(fn, ms) {
const id = nextId++;
pending.set(id, { fn, ms });
return id;
},
clearTimeoutImpl(id) {
cleared.push(id);
pending.delete(id);
},
/** Fire every armed timer, in order. */
flush() {
const entries = [...pending.values()];
pending.clear();
for (const entry of entries) entry.fn();
},
armed: () => pending.size,
};
}
/** A `fetch` response with case-insensitive headers. */
export function response({
ok = true,
status = ok ? 200 : 500,
json = {},
headers = {},
blob = { size: 1 },
} = {}) {
const lower = new Map(
Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]),
);
return {
ok,
status,
headers: { get: (name) => lower.get(name.toLowerCase()) ?? null },
json: async () => json,
blob: async () => blob,
};
}
const EVENT_TYPES = {
LEFT_DOWN: 'down',
MOUSE_MOVE: 'move',
LEFT_UP: 'up',
LEFT_CLICK: 'click',
LEFT_DOUBLE_CLICK: 'dblclick',
};
/**
* Cesium, viewer and document fakes for `initImageryBoxTool`. The canvas is
* 1000 × 500 px mapped linearly onto 0.2° near Austin; y < 40 px is sky.
*/
export function boxToolFakes() {
const handlers = [];
class ScreenSpaceEventHandler {
constructor(canvas) {
this.canvas = canvas;
this.actions = new Map();
this.destroyed = false;
handlers.push(this);
}
setInputAction(fn, type) {
this.actions.set(type, fn);
}
getInputAction(type) {
return this.actions.get(type);
}
removeInputAction(type) {
this.actions.delete(type);
}
destroy() {
this.destroyed = true;
}
}
class CustomDataSource {
constructor(name) {
this.name = name;
const values = [];
this.entities = {
values,
add: (entity) => (values.push(entity), entity),
removeAll: () => values.splice(0),
};
}
}
const color = { withAlpha: (alpha) => ({ alpha }) };
const cesium = {
ScreenSpaceEventHandler,
ScreenSpaceEventType: EVENT_TYPES,
CustomDataSource,
CallbackProperty: class {},
PolylineDashMaterialProperty: class {},
ClassificationType: { BOTH: 'both' },
Color: { fromCssColorString: () => color },
Rectangle: { fromDegrees: () => ({}) },
Cartesian3: { fromDegreesArray: (values) => values },
};
const stock = new ScreenSpaceEventHandler('stock');
const originalClick = () => 'select';
const originalDouble = () => 'track';
stock.setInputAction(originalClick, EVENT_TYPES.LEFT_CLICK);
stock.setInputAction(originalDouble, EVENT_TYPES.LEFT_DOUBLE_CLICK);
const sources = [];
const viewer = {
renders: 0,
scene: {
canvas: { clientWidth: 1000, clientHeight: 500 },
screenSpaceCameraController: { enableInputs: true },
requestRender: () => {
viewer.renders += 1;
},
},
screenSpaceEventHandler: stock,
dataSources: {
sources,
add: (source) => (sources.push(source), Promise.resolve(source)),
remove: (source) => sources.splice(sources.indexOf(source), 1),
},
};
const listeners = [];
const classes = new Set();
const documentRef = {
listeners,
classes,
addEventListener: (type, fn) => listeners.push([type, fn]),
removeEventListener: (type, fn) => {
const index = listeners.findIndex((entry) => entry[1] === fn);
if (index >= 0) listeners.splice(index, 1);
},
body: {
classList: {
add: (name) => classes.add(name),
remove: (name) => classes.delete(name),
},
},
};
const pickWorld = (_viewer, nx, ny) =>
ny * 500 < 40 ? null : { lon: -97.9 + nx * 0.2, lat: 30.4 - ny * 0.2 };
/** The live (non-stock) canvas handler, if the tool has one bound. */
const handler = () =>
handlers.find((h) => h.canvas !== 'stock' && !h.destroyed) || null;
const fire = (type, event) =>
handler()?.actions.get(EVENT_TYPES[type])?.(event);
/** Dispatch a keydown to the tool's document listener. */
const key = (name) => {
const event = {
key: name,
prevented: false,
stopped: false,
preventDefault() {
this.prevented = true;
},
stopImmediatePropagation() {
this.stopped = true;
},
};
listeners.find(([type]) => type === 'keydown')?.[1](event);
return event;
};
return {
cesium,
viewer,
documentRef,
pickWorld,
handler,
fire,
key,
types: EVENT_TYPES,
originalClick,
originalDouble,
};
}
// ---- the layer's injected parts --------------------------------------
export function candidate(product, day, cloud = 5) {
const granules =
product === 'VIIRS'
? []
: [
{
id: `${product}-${day}`,
product,
timeStart: `${day}T17:12:00Z`,
timeEnd: `${day}T17:14:00Z`,
cloud,
},
];
return {
key: `${product}:${day}`,
product,
day,
granules,
cloud: granules.length ? { min: cloud, max: cloud } : null,
timeRange: granules.length
? { start: granules[0].timeStart, end: granules[0].timeEnd }
: null,
availability: granules.length ? 'present' : 'unknown',
coverage: 'full',
};
}
// Strip order, newest first: V21 (unprobed), S18 (clear), L16 (cloudy), V15.
export const CANDIDATES = Object.freeze([
candidate('VIIRS', '2026-09-21'),
candidate('S30', '2026-09-18', 12),
candidate('L30', '2026-09-16', 60),
candidate('VIIRS', '2026-09-15'),
]);
export function fakeRenderer() {
const calls = [];
const owned = { a: null, b: null };
let host = { kind: 'none' };
let peak = 0;
return {
calls,
rebind(next) {
calls.push(['rebind', next.kind]);
host = next;
for (const slot of ['a', 'b'])
if (owned[slot]) owned[slot] = { ...owned[slot], kind: next.kind };
},
showSlot(slot, cand, box, look) {
if (owned[slot]?.key !== cand.key) calls.push(['show', slot, cand.key]);
owned[slot] = { key: cand.key, kind: host.kind, ...look };
peak = Math.max(peak, this.ownedCount());
return true;
},
hideSlot(slot) {
owned[slot] = null;
},
setAlpha(slot, alpha) {
calls.push(['alpha', slot, alpha]);
},
getOwned: () => ({ ...owned }),
ownedCount: () => ['a', 'b'].filter((slot) => owned[slot]).length,
peak: () => peak,
destroy() {
calls.push(['destroy']);
},
};
}
export function fakeThumbnails() {
const calls = [];
const listeners = new Set();
const statuses = new Map();
/** Every key the layer asked to probe, in the order it asked. */
const requested = [];
/** Direct `request` calls: `[key, priority]`. */
const requests = [];
const ask = (key) => {
if (!requested.includes(key)) requested.push(key);
};
return {
calls,
requested,
requests,
requestOrdered(candidates, box, window) {
calls.push(['ordered', candidates.map((c) => c.key), window]);
const { focusIndex, firstVisible, lastVisible, extra } = window;
thumbnailOrder(
focusIndex,
firstVisible,
lastVisible,
candidates.length,
extra,
).forEach((index) => ask(candidates[index].key));
},
request(candidate, box, priority) {
ask(candidate.key);
requests.push([candidate.key, priority]);
},
/** Settle every requested key still unknown, as the loader would. */
probeRequested(status = 'present') {
for (const key of requested)
if (!statuses.has(key)) this.probe(key, status);
},
clear() {
calls.push(['clear']);
},
get: (key) => ({ status: statuses.get(key) || 'unknown' }),
/** Record a probe result and tell the layer, as the loader does. */
probe(key, status) {
statuses.set(key, status);
for (const listener of listeners) listener(key);
},
/** Record a probe result without telling anyone yet. */
setStatus: (key, status) => statuses.set(key, status),
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
destroy() {
calls.push(['destroy']);
},
};
}
export function fakeCatalog() {
const searches = [];
return {
searches,
searchHls(request) {
return new Promise((resolve, reject) => {
request.signal?.addEventListener('abort', () =>
reject(new Error('aborted')),
);
searches.push({ request, resolve, reject });
});
},
resolveLast(candidates = CANDIDATES) {
searches.at(-1).resolve({ candidates, truncated: false, errors: [] });
},
};
}
/** A map controller whose lease settles when the test says so. */
export function fakeController({ refuse = false } = {}) {
const calls = [];
const controller = {
calls,
acquireImageryComparison(options) {
calls.push(['acquire', options.owner, options.switchPolicy]);
if (refuse) throw new Error('held by bhote');
let settle;
const ready = new Promise((resolve) => {
settle = resolve;
});
controller.lease = {
ready,
release: () => {
calls.push(['release']);
return Promise.resolve();
},
settle: (result = { status: 'ready' }) => settle(result),
};
return controller.lease;
},
};
return controller;
}
+279
View File
@@ -0,0 +1,279 @@
/**
* Thumbnail loader for the Recent Imagery strip: one Worldview snapshot per
* candidate day. The response's `Data-Present` header is the only proof that
* a day has pixels in the box (GIBS answers an empty tile with HTTP 200), and
* `Acquisition-Time` carries the sensing time. Fetches are bounded, and so
* are decoded images: residency is least-recently-USED, where a request or a
* completed decode is a use and a `get` read never is (the panel reads every
* card on every render, so a read that counted would evict the visible cards
* first). Eviction revokes the image only; what the probe proved is kept.
*/
import { thumbnailOrder, wvsSnapshotUrl } from './model.js';
const EMPTY = Object.freeze({
status: 'unknown',
objectUrl: null,
acquisitionTime: null,
loading: false,
});
/**
* @param {{ fetchImpl?: typeof fetch, maxInFlight?: number, maxDecoded?: number, createObjectUrl?: (blob: Blob) => string, revokeObjectUrl?: (url: string) => void, size?: number }} [options]
*/
export function createThumbnailLoader({
fetchImpl = globalThis.fetch?.bind(globalThis),
maxInFlight = 3,
maxDecoded = 16,
createObjectUrl = (blob) => URL.createObjectURL(blob),
revokeObjectUrl = (url) => URL.revokeObjectURL(url),
size = 256,
} = {}) {
const limit = Math.max(1, Math.trunc(maxInFlight) || 1);
const resident = Math.max(1, Math.trunc(maxDecoded) || 1);
const entries = new Map();
const listeners = new Set();
const queue = [];
let inFlight = 0;
let touchCounter = 0;
let destroyed = false;
function notify(key) {
for (const listener of [...listeners]) {
try {
listener(key);
} catch (error) {
console.warn('[Data:RecentImagery] thumbnail listener failed:', error);
}
}
}
function touch(entry) {
touchCounter += 1;
entry.touched = touchCounter;
}
function revokeImage(entry) {
if (!entry.objectUrl) return;
try {
revokeObjectUrl(entry.objectUrl);
} catch {
/* a revoked URL is already gone */
}
entry.objectUrl = null;
}
function evictDecoded(keep) {
const decoded = [...entries.values()]
.filter((entry) => entry.objectUrl && entry !== keep)
.sort((a, b) => a.touched - b.touched);
let count = decoded.length + (keep?.objectUrl ? 1 : 0);
for (const entry of decoded) {
if (count <= resident) break;
revokeImage(entry);
notify(entry.key);
count -= 1;
}
}
function enqueue(entry) {
if (!queue.includes(entry)) queue.push(entry);
pump();
}
async function run(entry) {
const controller = new AbortController();
entry.controller = controller;
entry.loading = true;
inFlight += 1;
let status = 'error';
let objectUrl = null;
let acquisitionTime = null;
let cancelled = false;
try {
const url = wvsSnapshotUrl({
product: entry.candidate.product,
day: entry.candidate.day,
box: entry.box,
width: size,
height: size,
});
const response = await fetchImpl(url, { signal: controller.signal });
if (controller.signal.aborted) {
cancelled = true;
} else if (response?.ok) {
const present = String(
response.headers?.get?.('Data-Present') ?? 'true',
).toLowerCase();
acquisitionTime = response.headers?.get?.('Acquisition-Time') || null;
if (present === 'false') {
status = 'empty';
} else {
const blob = await response.blob();
if (controller.signal.aborted) cancelled = true;
else {
objectUrl = createObjectUrl(blob);
status = 'present';
}
}
}
} catch (error) {
if (controller.signal.aborted || error?.name === 'AbortError')
cancelled = true;
else status = 'error';
} finally {
inFlight -= 1;
entry.loading = false;
entry.controller = null;
}
// Every settlement frees a slot, so a cancelled fetch pumps too.
if (cancelled || destroyed || entries.get(entry.key) !== entry) {
if (objectUrl) revokeObjectUrl(objectUrl);
pump();
return;
}
// A failed re-fetch of an evicted image keeps what the day proved.
if (status !== 'error' || entry.status !== 'present') {
entry.status = status;
entry.acquisitionTime = acquisitionTime;
}
entry.objectUrl = objectUrl;
touch(entry);
if (objectUrl) evictDecoded(entry);
notify(entry.key);
pump();
}
function pump() {
if (destroyed) return;
queue.sort((a, b) => a.priority - b.priority);
while (inFlight < limit && queue.length) {
const entry = queue.shift();
if (entries.get(entry.key) !== entry) continue;
void run(entry);
}
}
return {
/**
* Ask for one candidate's thumbnail. Lower priority loads sooner. A day
* already known is only touched (and re-prioritised while it waits); a
* known-present day whose image was evicted is fetched again.
* @param {object} candidate
* @param {object} box Degrees box.
* @param {number} [priority]
*/
request(candidate, box, priority = 0) {
if (destroyed || !candidate?.key || !box) return;
const key = candidate.key;
const existing = entries.get(key);
if (existing) {
touch(existing);
if (existing.loading) return;
if (existing.status === 'unknown') {
existing.priority = priority;
} else if (existing.status === 'present' && !existing.objectUrl) {
existing.priority = priority;
enqueue(existing);
}
return;
}
const entry = {
key,
candidate,
box,
priority,
status: 'unknown',
objectUrl: null,
acquisitionTime: null,
loading: false,
controller: null,
touched: 0,
};
touch(entry);
entries.set(key, entry);
enqueue(entry);
},
/**
* Request the visible window of a strip in `thumbnailOrder` priority.
* @param {Array<object>} candidates
* @param {object} box
* @param {{ focusIndex: number, firstVisible: number, lastVisible: number, extra?: number }} window
*/
requestOrdered(
candidates,
box,
{ focusIndex, firstVisible, lastVisible, extra },
) {
const order = thumbnailOrder(
focusIndex,
firstVisible,
lastVisible,
candidates.length,
extra,
);
order.forEach((index, priority) => {
this.request(candidates[index], box, priority);
});
},
/**
* Abort every fetch in flight and forget every unsettled request. A
* known day whose image was being fetched again keeps what it knows.
*/
cancelAll() {
queue.length = 0;
for (const entry of [...entries.values()]) {
if (entry.loading) entry.controller?.abort();
if (entry.status === 'unknown') entries.delete(entry.key);
}
},
/** Cancel everything and revoke every decoded thumbnail. */
clear() {
this.cancelAll();
for (const entry of entries.values()) revokeImage(entry);
entries.clear();
},
/**
* Read a day's state. A read is not a use: it never changes residency.
* @param {string} key Candidate key.
* @returns {{ status: 'unknown'|'present'|'empty'|'error', objectUrl: string | null, acquisitionTime: string | null, loading: boolean }}
*/
get(key) {
const entry = entries.get(key);
if (!entry) return EMPTY;
return {
status: entry.status,
objectUrl: entry.objectUrl,
acquisitionTime: entry.acquisitionTime,
loading: entry.loading,
};
},
/** @param {(key: string) => void} listener */
subscribe(listener) {
if (typeof listener !== 'function') return () => {};
listeners.add(listener);
return () => listeners.delete(listener);
},
stats() {
return {
inFlight,
queued: queue.length,
decoded: [...entries.values()].filter((entry) => entry.objectUrl)
.length,
tracked: entries.size,
};
},
destroy() {
if (destroyed) return;
this.clear();
destroyed = true;
listeners.clear();
},
};
}
@@ -0,0 +1,317 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createThumbnailLoader } from './thumbnails.js';
import { BOX, response, settle } from './testDoubles.mjs';
const candidate = (product, day) => ({
key: `${product}:${day}`,
product,
day,
});
/** A fetch whose responses the test releases, in any order. */
function fakeFetch() {
const calls = [];
const impl = (url, { signal } = {}) =>
new Promise((resolve, reject) => {
signal?.addEventListener('abort', () =>
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
);
calls.push({ url, signal, resolve });
});
const respond = (index, { ok = true, present = 'true' } = {}) =>
calls[index].resolve(
response({
ok,
headers: {
'Data-Present': present,
'Acquisition-Time': '2026-09-18T17:12:00Z',
},
}),
);
return { impl, calls, respond };
}
function fixture(options = {}) {
const fetch = fakeFetch();
const created = [];
const revoked = [];
const loader = createThumbnailLoader({
fetchImpl: fetch.impl,
createObjectUrl: () => {
created.push(`blob:${created.length + 1}`);
return created.at(-1);
},
revokeObjectUrl: (url) => revoked.push(url),
...options,
});
return { loader, fetch, created, revoked };
}
test('Data-Present decides the day: present with an object URL and sensing time, empty, or error', async () => {
const { loader, fetch, created } = fixture();
const changes = [];
loader.subscribe((key) => changes.push(key));
loader.request(candidate('S30', '2026-09-18'), BOX, 0);
loader.request(candidate('L30', '2026-09-16'), BOX, 1);
loader.request(candidate('VIIRS', '2026-09-15'), BOX, 2);
assert.equal(loader.get('S30:2026-09-18').loading, true);
assert.match(
fetch.calls[0].url,
/wvs\.earthdata\.nasa\.gov.*HLS_S30.*TIME=2026-09-18.*WIDTH=256&HEIGHT=256/,
);
fetch.respond(0);
fetch.respond(1, { present: 'false' });
fetch.respond(2, { ok: false });
await settle();
assert.deepEqual(loader.get('S30:2026-09-18'), {
status: 'present',
objectUrl: 'blob:1',
acquisitionTime: '2026-09-18T17:12:00Z',
loading: false,
});
assert.equal(loader.get('L30:2026-09-16').status, 'empty');
assert.equal(loader.get('VIIRS:2026-09-15').status, 'error');
assert.deepEqual(created, ['blob:1']);
assert.deepEqual(changes.sort(), [
'L30:2026-09-16',
'S30:2026-09-18',
'VIIRS:2026-09-15',
]);
assert.equal(loader.get('nope').status, 'unknown');
});
test('at most maxInFlight fetches run and the queue drains by priority', async () => {
const { loader, fetch } = fixture({ maxInFlight: 2 });
loader.request(candidate('S30', '2026-09-10'), BOX, 5);
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
loader.request(candidate('S30', '2026-09-12'), BOX, 0);
loader.request(candidate('S30', '2026-09-13'), BOX, 3);
// The pump is eager: the first two arrivals start at once, the rest queue.
assert.equal(fetch.calls.length, 2);
assert.match(fetch.calls[0].url, /TIME=2026-09-10/);
assert.match(fetch.calls[1].url, /TIME=2026-09-11/);
assert.deepEqual(loader.stats(), {
inFlight: 2,
queued: 2,
decoded: 0,
tracked: 4,
});
fetch.respond(0);
await settle();
assert.equal(fetch.calls.length, 3);
assert.match(fetch.calls[2].url, /TIME=2026-09-12/, 'priority 0 before 3');
// A repeat request for a queued key just re-prioritises it.
loader.request(candidate('S30', '2026-09-13'), BOX, 0);
assert.equal(loader.stats().tracked, 4);
fetch.respond(1);
await settle();
assert.equal(fetch.calls.length, 4);
assert.match(fetch.calls[3].url, /TIME=2026-09-13/);
});
test('requestOrdered loads the focused card first, then outward, then the margins', () => {
const { loader, fetch } = fixture({ maxInFlight: 10 });
const candidates = Array.from({ length: 8 }, (_, i) =>
candidate('S30', `2026-09-${String(10 + i).padStart(2, '0')}`),
);
loader.requestOrdered(candidates, BOX, {
focusIndex: 3,
firstVisible: 2,
lastVisible: 5,
extra: 1,
});
const days = fetch.calls.map(
(call) => /TIME=2026-09-(\d\d)/.exec(call.url)[1],
);
assert.deepEqual(days, ['13', '14', '12', '15', '16', '11']);
});
test('decoded thumbnails are evicted least-recently-used and revoked; a read is not a use and an evicted day keeps what it learned', async () => {
const { loader, fetch, revoked } = fixture({
maxInFlight: 10,
maxDecoded: 2,
});
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
loader.request(candidate('S30', '2026-09-12'), BOX, 2);
fetch.respond(0);
fetch.respond(1);
await settle();
assert.equal(loader.stats().decoded, 2);
loader.get('S30:2026-09-10'); // reading the oldest does not make it recent
fetch.respond(2);
await settle();
assert.equal(loader.stats().decoded, 2);
assert.deepEqual(revoked, ['blob:1']);
const evicted = loader.get('S30:2026-09-10');
assert.equal(evicted.status, 'present', 'availability survives eviction');
assert.equal(evicted.objectUrl, null);
assert.equal(evicted.acquisitionTime, '2026-09-18T17:12:00Z');
assert.equal(loader.stats().tracked, 3, 'the entry is kept');
assert.equal(loader.get('S30:2026-09-11').objectUrl, 'blob:2');
assert.equal(loader.get('S30:2026-09-12').objectUrl, 'blob:3');
// A repeat request IS a use, and re-fetches an evicted day without ever
// reporting it unknown; 11 is now the LRU and goes when 10 comes back.
loader.request(candidate('S30', '2026-09-12'), BOX, 0);
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
assert.equal(fetch.calls.length, 4, 'an evicted day is fetched again');
assert.equal(loader.get('S30:2026-09-10').status, 'present');
assert.equal(loader.get('S30:2026-09-10').loading, true);
fetch.respond(3);
await settle();
assert.equal(loader.get('S30:2026-09-10').objectUrl, 'blob:4');
assert.deepEqual(revoked, ['blob:1', 'blob:2']);
assert.equal(loader.get('S30:2026-09-11').status, 'present');
assert.equal(loader.get('S30:2026-09-12').objectUrl, 'blob:3');
// A day that already holds an image is not fetched twice.
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
assert.equal(fetch.calls.length, 4);
});
test('reading every card in strip order (a snapshot) never decides who is evicted; evicted days stay known and come back on request', async () => {
const { loader, fetch, revoked } = fixture({
maxInFlight: 20,
maxDecoded: 9,
});
const candidates = Array.from({ length: 20 }, (_, i) =>
candidate('S30', `2026-09-${String(i + 1).padStart(2, '0')}`),
);
candidates.forEach((c, i) => loader.request(c, BOX, i));
assert.equal(fetch.calls.length, 20);
// Decode the strip back to front, leaving the newest card in flight.
for (let i = 19; i >= 1; i -= 1) fetch.respond(i);
await settle();
const holders = () =>
candidates.filter((c) => loader.get(c.key).objectUrl).map((c) => c.key);
assert.equal(loader.stats().decoded, 9);
assert.equal(loader.stats().tracked, 20);
assert.deepEqual(
holders(),
candidates.slice(1, 10).map((c) => c.key),
'the nine most recently decoded',
);
// Sweep every card in strip order, several times, as getSnapshot does.
for (let pass = 0; pass < 3; pass += 1)
for (const c of candidates) loader.get(c.key);
assert.deepEqual(
holders(),
candidates.slice(1, 10).map((c) => c.key),
);
// The last decode evicts the least recently DECODED card, not the first
// card the sweep happened to read.
fetch.respond(0);
await settle();
assert.deepEqual(
holders(),
candidates.slice(0, 9).map((c) => c.key),
);
assert.equal(revoked.length, 11);
for (const c of candidates) {
const entry = loader.get(c.key);
assert.equal(entry.status, 'present', `${c.key} stays present`);
assert.equal(entry.acquisitionTime, '2026-09-18T17:12:00Z');
assert.equal(entry.loading, false);
}
// Re-requesting an evicted day fetches it again and it holds an image.
const evictedKey = candidates[15].key;
assert.equal(loader.get(evictedKey).objectUrl, null);
loader.request(candidates[15], BOX, 0);
assert.equal(fetch.calls.length, 21);
assert.equal(loader.get(evictedKey).status, 'present');
fetch.respond(20);
await settle();
assert.ok(loader.get(evictedKey).objectUrl);
assert.equal(loader.stats().decoded, 9);
assert.equal(loader.stats().tracked, 20);
});
test('cancelAll aborts in-flight fetches and a late settle never leaks an object URL', async () => {
const { loader, fetch, created, revoked } = fixture({ maxInFlight: 1 });
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
const signal = fetch.calls[0].signal;
loader.cancelAll();
assert.equal(signal.aborted, true);
assert.deepEqual(loader.stats(), {
inFlight: 1,
queued: 0,
decoded: 0,
tracked: 0,
});
await settle();
assert.equal(loader.stats().inFlight, 0);
assert.deepEqual(created, []);
assert.deepEqual(revoked, []);
assert.equal(loader.get('S30:2026-09-10').status, 'unknown');
});
test('clear and destroy revoke every resident thumbnail', async () => {
const { loader, fetch, revoked } = fixture({ maxInFlight: 10 });
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
loader.request(candidate('S30', '2026-09-11'), BOX, 1);
fetch.respond(0);
fetch.respond(1);
await settle();
loader.clear();
assert.deepEqual(revoked.sort(), ['blob:1', 'blob:2']);
assert.equal(loader.stats().tracked, 0);
loader.request(candidate('S30', '2026-09-12'), BOX, 0);
fetch.respond(2);
await settle();
loader.destroy();
assert.equal(revoked.length, 3);
loader.request(candidate('S30', '2026-09-13'), BOX, 0);
assert.equal(fetch.calls.length, 3, 'a destroyed loader fetches nothing');
});
test('a request queued behind an aborted fetch still starts once the abort settles', async () => {
// A response that arrives after the abort (the fetch ignored the signal).
const calls = [];
const fetchImpl = (url, { signal } = {}) =>
new Promise((resolve) => calls.push({ url, signal, resolve }));
const loader = createThumbnailLoader({
fetchImpl,
maxInFlight: 1,
createObjectUrl: () => 'blob:x',
revokeObjectUrl: () => {},
});
loader.request(candidate('S30', '2026-09-10'), BOX, 0);
loader.clear();
loader.request(candidate('S30', '2026-09-11'), BOX, 0);
assert.equal(calls[0].signal.aborted, true);
assert.deepEqual(loader.stats(), {
inFlight: 1,
queued: 1,
decoded: 0,
tracked: 1,
});
calls[0].resolve({
ok: true,
headers: { get: () => null },
blob: async () => ({}),
});
await settle();
assert.equal(calls.length, 2, 'the replacement request was pumped');
assert.match(calls[1].url, /TIME=2026-09-11/);
assert.deepEqual(loader.stats(), {
inFlight: 1,
queued: 0,
decoded: 0,
tracked: 1,
});
// The same sequence when the fetch rejects with AbortError instead.
const rejecting = fixture({ maxInFlight: 1 });
rejecting.loader.request(candidate('S30', '2026-09-10'), BOX, 0);
rejecting.loader.clear();
rejecting.loader.request(candidate('S30', '2026-09-11'), BOX, 0);
await settle();
assert.equal(rejecting.fetch.calls.length, 2);
assert.deepEqual(rejecting.loader.stats(), {
inFlight: 1,
queued: 0,
decoded: 0,
tracked: 1,
});
});