Separate route and recent imagery acquisition from application assembly

This commit is contained in:
Sameh Khamis committed 2026-10-09 17:42:00 -07:00
1 parent 5756ae92a8
commit 99988cb348
20 files changed
+279 -52

No files matched your search

+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
- Directions and Recent Imagery accept replaceable acquisition sources, including
route transport, imagery catalog, thumbnails, tile templates and attribution.
Explicitly removing either source leaves its layer unavailable; standalone
defaults preserve the existing providers.
- Application source configuration accepts replacements and explicit removal. - Application source configuration accepts replacements and explicit removal.
Missing catalog feeds leave their layers unavailable without blocking startup Missing catalog feeds leave their layers unavailable without blocking startup
or making fallback requests; malformed supplied feeds remain configuration errors. or making fallback requests; malformed supplied feeds remain configuration errors.
+8 -2
View File
@@ -7,8 +7,14 @@ visibility lifecycle before initialization. Invalid supplied methods still fail
construction. `createStandaloneApplication` and `createStandaloneCatalog` accept construction. `createStandaloneApplication` and `createStandaloneCatalog` accept
`sources` overrides; unspecified entries retain `sources` overrides; unspecified entries retain
standalone defaults, and null entries explicitly remove a source. Street Level standalone defaults, and null entries explicitly remove a source. Street Level
continues to accept an explicit provider list. Directions and Recent Imagery continues to accept an explicit provider list. Directions (`getRoute`) and
acquisition are still selected by their application constructors. Recent Imagery (`searchHls`, `getThumbnail`, `getTileTemplate`) use the same source
configuration boundary. Standalone selects their default adapters; layers retain
route normalization, imagery selection and rendering. Recent Imagery adapters
consume the existing HLS/VIIRS product schema and provide their own tile attribution.
The source factories are exported at `layers/directions/source` and
`layers/recent-imagery/source`. Configuration is applied at construction time;
this does not introduce live source replacement.
Application catalog composition accepts an optional `streetLevelProviders` list. Application catalog composition accepts an optional `streetLevelProviders` list.
An explicit list replaces the default Mapillary provider; an empty list removes An explicit list replaces the default Mapillary provider; an empty list removes
+2
View File
@@ -163,6 +163,7 @@
"./layers/vessels": "./src/layers/vessels/index.js", "./layers/vessels": "./src/layers/vessels/index.js",
"./layers/earthquakes": "./src/layers/earthquakes/index.js", "./layers/earthquakes": "./src/layers/earthquakes/index.js",
"./layers/recent-imagery": "./src/layers/recentImagery/index.js", "./layers/recent-imagery": "./src/layers/recentImagery/index.js",
"./layers/recent-imagery/source": "./src/layers/recentImagery/source.js",
"./layers/perimeters": "./src/layers/perimeters/index.js", "./layers/perimeters": "./src/layers/perimeters/index.js",
"./sources/firms-csv": "./src/data/firmsCsv.js", "./sources/firms-csv": "./src/data/firmsCsv.js",
"./layers/firms": "./src/layers/firms/index.js", "./layers/firms": "./src/layers/firms/index.js",
@@ -173,6 +174,7 @@
"./layers/traffic": "./src/layers/traffic/index.js", "./layers/traffic": "./src/layers/traffic/index.js",
"./layers/bikeshare": "./src/layers/bikeshare/index.js", "./layers/bikeshare": "./src/layers/bikeshare/index.js",
"./layers/directions": "./src/layers/directions/index.js", "./layers/directions": "./src/layers/directions/index.js",
"./layers/directions/source": "./src/layers/directions/source.js",
"./layers/cctv": "./src/layers/cctv/index.js", "./layers/cctv": "./src/layers/cctv/index.js",
"./layers/alpr": "./src/layers/alpr/index.js", "./layers/alpr": "./src/layers/alpr/index.js",
"./layers/radio": "./src/layers/radio/index.js", "./layers/radio": "./src/layers/radio/index.js",
+24 -1
View File
@@ -934,6 +934,7 @@
"exports": ["./layers/recent-imagery"], "exports": ["./layers/recent-imagery"],
"modules": [ "modules": [
"src/layers/recentImagery/index.js", "src/layers/recentImagery/index.js",
"src/layers/recentImagery/source.js",
"src/layers/recentImagery/model.js", "src/layers/recentImagery/model.js",
"src/layers/recentImagery/catalog.js", "src/layers/recentImagery/catalog.js",
"src/layers/recentImagery/rendering.js", "src/layers/recentImagery/rendering.js",
@@ -1994,6 +1995,7 @@
"src/annotations/drawTool.js", "src/annotations/drawTool.js",
"src/data/routeSteps.js", "src/data/routeSteps.js",
"src/layers/directions/index.js", "src/layers/directions/index.js",
"src/layers/directions/source.js",
"src/data/directions.js", "src/data/directions.js",
"src/app/layers/directions.js", "src/app/layers/directions.js",
"src/search/coordinateGeocoder.js", "src/search/coordinateGeocoder.js",
@@ -2128,6 +2130,7 @@
"src/ui/localSdrPresentation.js", "src/ui/localSdrPresentation.js",
"src/app/layers/recentImagery.js", "src/app/layers/recentImagery.js",
"src/layers/recentImagery/index.js", "src/layers/recentImagery/index.js",
"src/layers/recentImagery/source.js",
"src/layers/recentImagery/model.js", "src/layers/recentImagery/model.js",
"src/layers/recentImagery/catalog.js", "src/layers/recentImagery/catalog.js",
"src/layers/recentImagery/rendering.js", "src/layers/recentImagery/rendering.js",
@@ -2573,6 +2576,7 @@
"src/services/groundFloor.js", "src/services/groundFloor.js",
"src/data/routeSteps.js", "src/data/routeSteps.js",
"src/layers/directions/index.js", "src/layers/directions/index.js",
"src/layers/directions/source.js",
"src/services/terrainHeights.js", "src/services/terrainHeights.js",
"src/data/terrainHeights.js", "src/data/terrainHeights.js",
"src/data/groundFloor.js", "src/data/groundFloor.js",
@@ -2661,6 +2665,7 @@
"src/data/modelEligibility.js", "src/data/modelEligibility.js",
"src/app/layers/recentImagery.js", "src/app/layers/recentImagery.js",
"src/layers/recentImagery/index.js", "src/layers/recentImagery/index.js",
"src/layers/recentImagery/source.js",
"src/layers/recentImagery/model.js", "src/layers/recentImagery/model.js",
"src/layers/recentImagery/catalog.js", "src/layers/recentImagery/catalog.js",
"src/layers/recentImagery/rendering.js", "src/layers/recentImagery/rendering.js",
@@ -2915,9 +2920,27 @@
], ],
"external": [] "external": []
}, },
"directions-source": {
"exports": ["./layers/directions/source"],
"modules": ["src/layers/directions/source.js"],
"external": []
},
"recent-imagery-source": {
"exports": ["./layers/recent-imagery/source"],
"modules": [
"src/layers/recentImagery/source.js",
"src/layers/recentImagery/catalog.js",
"src/layers/recentImagery/model.js"
],
"external": []
},
"directions-layer": { "directions-layer": {
"exports": ["./layers/directions"], "exports": ["./layers/directions"],
"modules": ["src/layers/directions/index.js", "src/data/routeSteps.js"], "modules": [
"src/layers/directions/index.js",
"src/layers/directions/source.js",
"src/data/routeSteps.js"
],
"external": ["cesium"] "external": ["cesium"]
}, },
"radio-browser-source": { "radio-browser-source": {
+2 -2
View File
@@ -133,8 +133,8 @@ export function createApplicationCatalog({
createApplicationRadio({ surface, source: sources.radio }), createApplicationRadio({ surface, source: sources.radio }),
createApplicationTransit({ surface, source: sources.transit }), createApplicationTransit({ surface, source: sources.transit }),
createApplicationBikeshare({ source: sources.bikeshare }), createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationDirections(), createApplicationDirections({ source: sources.directions }),
createApplicationRecentImagery(), createApplicationRecentImagery({ source: sources['recent-imagery'] }),
vessels, vessels,
installations, installations,
createApplicationAwareness({ createApplicationAwareness({
+3 -9
View File
@@ -1,12 +1,6 @@
import { createApplicationDirectionsLayer } from '../../data/directions.js'; import { createApplicationDirectionsLayer } from '../../data/directions.js';
/** /** Construct Directions with application scene owners and an explicit route source. */
* Construct one Directions layer using the application scene owners. export function createApplicationDirections({ source }) {
* return createApplicationDirectionsLayer({ source });
* It takes no data source: a route is fetched from `/api/route` when the
* operator places both endpoints, so there is nothing to supply up front.
* @returns {object} A fresh layer instance for this catalog.
*/
export function createApplicationDirections() {
return createApplicationDirectionsLayer();
} }
+8 -8
View File
@@ -3,17 +3,17 @@ import { createRecentImageryRenderer } from '../../layers/recentImagery/renderin
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js'; import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
import { governorRequestRender } from '../../renderGovernor.js'; import { governorRequestRender } from '../../renderGovernor.js';
/** /** Construct imagery presentation over one explicitly supplied acquisition source. */
* Construct the Recent Imagery layer with the real GIBS renderer and the export function createApplicationRecentImagery({ source }) {
* browser thumbnail loader. It takes no data source: the catalog is NASA
* CMR and the tiles are GIBS, both browser-direct and keyless.
* @returns {object} A fresh layer instance for this catalog.
*/
export function createApplicationRecentImagery() {
return createRecentImageryLayer({ return createRecentImageryLayer({
catalog: { searchHls: (...args) => source.searchHls(...args) },
renderer: createRecentImageryRenderer({ renderer: createRecentImageryRenderer({
requestRender: governorRequestRender, requestRender: governorRequestRender,
tileTemplate: (...args) => source.getTileTemplate(...args),
credit: source.credit || source.label || 'Imagery',
}),
thumbnails: createThumbnailLoader({
requestThumbnail: (...args) => source.getThumbnail(...args),
}), }),
thumbnails: createThumbnailLoader(),
}); });
} }
+5
View File
@@ -6,6 +6,11 @@ const contract = (layers, methods) =>
/** Acquisition contracts and their consumers; records remain family-specific. */ /** Acquisition contracts and their consumers; records remain family-specific. */
export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
directions: contract(['directions'], ['getRoute']),
'recent-imagery': contract(
['recent-imagery'],
['searchHls', 'getThumbnail', 'getTileTemplate'],
),
flights: contract(['flights'], ['getSnapshot']), flights: contract(['flights'], ['getSnapshot']),
military: contract(['military'], ['getSnapshot']), military: contract(['military'], ['getSnapshot']),
vessels: contract(['ais-live-vessels'], ['getSnapshot']), vessels: contract(['ais-live-vessels'], ['getSnapshot']),
+5 -2
View File
@@ -1,3 +1,4 @@
import { createDirectionsSource } from '../layers/directions/source.js';
/** /**
* @module directions * @module directions
* @description Service bundle for the Directions layer. * @description Service bundle for the Directions layer.
@@ -37,8 +38,10 @@ export const directionsServices = Object.freeze({
}); });
/** Construct one Directions layer over the application scene owners. */ /** Construct one Directions layer over the application scene owners. */
export function createApplicationDirectionsLayer() { export function createApplicationDirectionsLayer({
return createDirectionsLayer({ services: directionsServices }); source = createDirectionsSource(),
} = {}) {
return createDirectionsLayer({ services: directionsServices, source });
} }
export { export {
+2 -1
View File
@@ -7,6 +7,7 @@ import { createBhoteKoshiLocatorLayer } from './bhoteKoshiLocator.js';
import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js'; import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js';
import * as annotations from '../annotations/worldAnnotationRenderer.js'; import * as annotations from '../annotations/worldAnnotationRenderer.js';
import { createDirectionsLayer } from '../layers/directions/index.js'; import { createDirectionsLayer } from '../layers/directions/index.js';
import { createDirectionsSource } from '../layers/directions/source.js';
import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js'; import { createLifecycle as createCctvLifecycle } from '../layers/cctv/lifecycle.js';
import * as input from './inputOwnership.js'; import * as input from './inputOwnership.js';
import { createOverpassAlprSource } from '../layers/alpr/source.js'; import { createOverpassAlprSource } from '../layers/alpr/source.js';
@@ -64,7 +65,7 @@ test('voice route geometry introduces the shared OSM credit', (t) => {
test('Directions introduces the shared OSM credit when the requested route draws', async (t) => { test('Directions introduces the shared OSM credit when the requested route draws', async (t) => {
const v = viewer(t); const v = viewer(t);
t.mock.method(globalThis, 'fetch', async () => Response.json({ ok: true, geometry: [[0, 0], [0.01, 0.01]], steps: [] })); t.mock.method(globalThis, 'fetch', async () => Response.json({ ok: true, geometry: [[0, 0], [0.01, 0.01]], steps: [] }));
const layer = createDirectionsLayer({ services: { const layer = createDirectionsLayer({ source: createDirectionsSource(), services: {
credits: { showOsmCredit, hideOsmCredit }, annotations, input, scenePick: {}, camera: {}, credits: { showOsmCredit, hideOsmCredit }, annotations, input, scenePick: {}, camera: {},
overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop }, overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop },
render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop }, render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop },
+9 -16
View File
@@ -452,10 +452,7 @@ export function createDirectionsStepOverlayEntry(index, position, copy) {
* @param {{lat:number, lon:number}} b * @param {{lat:number, lon:number}} b
* @returns {string} * @returns {string}
*/ */
export function directionsRequestUrl(mode, a, b) { export { directionsRequestUrl } from './source.js';
const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`;
return `/api/route?profile=${encodeURIComponent(mode)}&coords=${encodeURIComponent(coords)}&steps=1`;
}
/** /**
* Validate a proxy payload into the route record the layer keeps, or null. * Validate a proxy payload into the route record the layer keeps, or null.
@@ -509,10 +506,12 @@ export function normalizeRoutePayload(payload, mode) {
* route, the markers, the pointer claim or the flight this layer owns. The * route, the markers, the pointer claim or the flight this layer owns. The
* shared services are injected, which is what keeps this module's own imports * shared services are injected, which is what keeps this module's own imports
* down to Cesium and the pure step formatter. * down to Cesium and the pure step formatter.
* @param {{services: object}} options * @param {{services: object, source: {getRoute: Function}}} options
* @returns {object} The layer module the data manager registers. * @returns {object} The layer module the data manager registers.
*/ */
export function createDirectionsLayer({ services }) { export function createDirectionsLayer({ services, source }) {
if (typeof source?.getRoute !== 'function')
throw new TypeError('Directions require a route source');
/** /**
* Where the maneuver card is drawn. Defaults to the shared world-overlay host; * Where the maneuver card is drawn. Defaults to the shared world-overlay host;
* tests swap it. Resolved on use, not at import, because the services arrive * tests swap it. Resolved on use, not at import, because the services arrive
@@ -827,18 +826,12 @@ export function createDirectionsLayer({ services }) {
_error = null; _error = null;
notifyRow(); notifyRow();
try { try {
const response = await fetch(directionsRequestUrl(mode, _a, _b), { const payload = await source.getRoute(
{ mode, a: _a, b: _b },
{
signal: controller.signal, signal: controller.signal,
headers: { Accept: 'application/json' }, },
});
const payload = await response.json();
if (response.status === 429) {
throw new Error(
typeof payload?.error === 'string' && payload.error
? `${payload.error} — try again in a moment`
: 'Routing is rate limited — try again in a moment',
); );
}
if (seq !== _routeSeq || !_enabled) return; if (seq !== _routeSeq || !_enabled) return;
const route = normalizeRoutePayload(payload, mode); const route = normalizeRoutePayload(payload, mode);
if (!route) { if (!route) {
+34
View File
@@ -0,0 +1,34 @@
/** Build the existing route-proxy query from geographic endpoints. */
export function directionsRequestUrl(mode, a, b) {
const coords = `${a.lon.toFixed(6)},${a.lat.toFixed(6)};${b.lon.toFixed(6)},${b.lat.toFixed(6)}`;
return `/api/route?profile=${encodeURIComponent(mode)}&coords=${encodeURIComponent(coords)}&steps=1`;
}
/** Acquire route records using caller-owned transport and an optional endpoint. */
export function createDirectionsSource({
fetchImpl = (...args) => globalThis.fetch(...args),
endpoint = '/api/route',
} = {}) {
return {
async getRoute({ mode, a, b }, { signal } = {}) {
signal?.throwIfAborted();
const query = directionsRequestUrl(mode, a, b).split('?')[1];
const response = await fetchImpl(`${endpoint}?${query}`, {
signal,
headers: { Accept: 'application/json' },
});
const payload = await response.json();
signal?.throwIfAborted();
if (response.status === 429) {
throw new Error(
typeof payload?.error === 'string' && payload.error
? `${payload.error} — try again in a moment`
: 'Routing is rate limited — try again in a moment',
);
}
if (!response.ok && !payload?.error)
throw new Error(`Routing unavailable (HTTP ${response.status})`);
return payload;
},
};
}
+43
View File
@@ -0,0 +1,43 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createDirectionsSource } from './source.js';
const query = { mode: 'foot', a: { lon: 1, lat: 2 }, b: { lon: 3, lat: 4 } };
test('route acquisition uses only the supplied transport and endpoint', async () => {
const controller = new AbortController();
const payload = { ok: true, geometry: [[1, 2], [3, 4]] };
const calls = [];
const source = createDirectionsSource({
endpoint: 'https://routes.example/route',
fetchImpl: async (...args) => { calls.push(args); return Response.json(payload); },
});
assert.deepEqual(await source.getRoute(query, { signal: controller.signal }), payload);
assert.equal(calls.length, 1);
const [url, init] = calls[0];
assert.equal(new URL(url).origin, 'https://routes.example');
assert.equal(new URL(url).searchParams.get('coords'), '1.000000,2.000000;3.000000,4.000000');
assert.equal(new URL(url).searchParams.get('profile'), 'foot');
assert.equal(init.signal, controller.signal);
assert.equal(init.headers.Accept, 'application/json');
});
test('aborting during route parsing discards the result', async () => {
const controller = new AbortController();
const source = createDirectionsSource({ fetchImpl: async () => ({
ok: true, json: async () => { controller.abort(); return { ok: true }; },
}) });
await assert.rejects(source.getRoute(query, { signal: controller.signal }), { name: 'AbortError' });
});
test('route failures are surfaced without fallback requests', async () => {
for (const status of [429, 503]) {
let calls = 0;
const source = createDirectionsSource({ fetchImpl: async () => {
calls++;
return Response.json({}, { status });
} });
await assert.rejects(source.getRoute(query), status === 429 ? /rate limited/ : /HTTP 503/);
assert.equal(calls, 1);
}
});
+4 -2
View File
@@ -70,6 +70,8 @@ function admitTileRequests(provider, admission, requestRender, retry) {
*/ */
export function createRecentImageryRenderer({ export function createRecentImageryRenderer({
cesium = Cesium, cesium = Cesium,
tileTemplate = gibsTemplate,
credit = 'NASA GIBS',
maxTileRequests = 6, maxTileRequests = 6,
requestRender = () => {}, requestRender = () => {},
setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms), setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms),
@@ -101,7 +103,7 @@ export function createRecentImageryRenderer({
function createProvider(candidate, box) { function createProvider(candidate, box) {
const spec = PRODUCTS[candidate.product]; const spec = PRODUCTS[candidate.product];
const provider = new cesium.UrlTemplateImageryProvider({ const provider = new cesium.UrlTemplateImageryProvider({
url: gibsTemplate(candidate.product, candidate.day), url: tileTemplate(candidate.product, candidate.day),
subdomains: ['a', 'b', 'c'], subdomains: ['a', 'b', 'c'],
minimumLevel: 0, minimumLevel: 0,
maximumLevel: spec.maxLevel, maximumLevel: spec.maxLevel,
@@ -112,7 +114,7 @@ export function createRecentImageryRenderer({
box.east, box.east,
box.north, box.north,
), ),
credit: 'NASA GIBS', credit,
hasAlphaChannel: spec.format === 'png', hasAlphaChannel: spec.format === 'png',
}); });
return admitTileRequests(provider, admission, requestRender, retry); return admitTileRequests(provider, admission, requestRender, retry);
+20 -1
View File
@@ -53,10 +53,11 @@ 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 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' }; const VIIRS = { key: 'VIIRS:2026-09-20', product: 'VIIRS', day: '2026-09-20' };
function fixture({ requestImage, maxTileRequests } = {}) { function fixture({ requestImage, maxTileRequests, ...options } = {}) {
const renders = []; const renders = [];
const timers = manualTimers(); const timers = manualTimers();
const renderer = createRecentImageryRenderer({ const renderer = createRecentImageryRenderer({
...options,
cesium: fakeCesium(requestImage), cesium: fakeCesium(requestImage),
maxTileRequests, maxTileRequests,
requestRender: (reason) => renders.push(reason), requestRender: (reason) => renders.push(reason),
@@ -208,3 +209,21 @@ test('against the basemap slot a splits left with no second layer, and leaving t
assert.equal(layer.splitDirection, 0); assert.equal(layer.splitDirection, 0);
assert.deepEqual(renders, ['recent-imagery-show', 'recent-imagery-look']); assert.deepEqual(renders, ['recent-imagery-show', 'recent-imagery-look']);
}); });
test('replacement imagery uses source-owned tile URLs and credit and tears down normally', () => {
const { renderer, globe } = fixture({
tileTemplate: (product, day) =>
`https://tiles.example/${product}/${day}/{z}/{x}/{y}`,
credit: 'Example imagery',
});
assert.equal(renderer.showSlot('a', S30, BOX), true);
const layer = globe.layers[0];
assert.equal(
layer.provider.options.url,
'https://tiles.example/S30/2026-09-18/{z}/{x}/{y}',
);
assert.equal(layer.provider.options.credit, 'Example imagery');
renderer.destroy();
assert.equal(globe.layers.length, 0);
assert.equal(layer.destroyed, true);
});
+32
View File
@@ -0,0 +1,32 @@
import { searchHls } from './catalog.js';
import { gibsTemplate, wvsSnapshotUrl } from './model.js';
/** NASA-compatible imagery acquisition; transport and URL builders are replaceable. */
export function createRecentImagerySource({
fetchImpl = (...args) => globalThis.fetch(...args),
search = searchHls,
thumbnailUrl = wvsSnapshotUrl,
tileTemplate = gibsTemplate,
credit = 'NASA GIBS',
} = {}) {
return {
credit,
searchHls: (query) => search({ ...query, fetchImpl }),
async getThumbnail(candidate, box, { size = 256, signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl(
thumbnailUrl({
product: candidate.product,
day: candidate.day,
box,
width: size,
height: size,
}),
{ signal },
);
signal?.throwIfAborted();
return response;
},
getTileTemplate: (product, day) => tileTemplate(product, day),
};
}
+42
View File
@@ -0,0 +1,42 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRecentImagerySource } from './source.js';
test('imagery source routes catalog, thumbnails and tiles through supplied adapters', async () => {
const controller = new AbortController();
const query = { box: { west: 0, east: 1, south: 0, north: 1 }, signal: controller.signal };
const candidate = { product: 'S30', day: '2026-10-01' };
const calls = [];
const fetchImpl = async (...args) => { calls.push(args); return new Response('image'); };
const source = createRecentImagerySource({
fetchImpl,
search: async (request) => {
assert.equal(request.fetchImpl, fetchImpl);
assert.equal(request.signal, controller.signal);
assert.equal(request.box, query.box);
return [candidate];
},
thumbnailUrl: ({ product, day, width }) => `https://imagery.example/${product}/${day}/${width}`,
tileTemplate: (product, day) => `https://tiles.example/${product}/${day}/{z}/{x}/{y}`,
credit: 'Example imagery',
});
assert.deepEqual(await source.searchHls(query), [candidate]);
assert.equal(await (await source.getThumbnail(candidate, query.box, { signal: controller.signal })).text(), 'image');
assert.equal(calls.length, 1);
assert.equal(calls[0][0], 'https://imagery.example/S30/2026-10-01/256');
assert.equal(calls[0][1].signal, controller.signal);
assert.equal(source.getTileTemplate(candidate.product, candidate.day), 'https://tiles.example/S30/2026-10-01/{z}/{x}/{y}');
assert.equal(source.credit, 'Example imagery');
});
test('cancelled thumbnail acquisition does not return stale responses or retry', async () => {
const controller = new AbortController();
let calls = 0;
const source = createRecentImagerySource({
thumbnailUrl: () => 'https://imagery.example/thumbnail',
fetchImpl: async () => { calls++; controller.abort(); return new Response('image'); },
});
await assert.rejects(source.getThumbnail({}, {}, { signal: controller.signal }), { name: 'AbortError' });
await assert.rejects(source.getThumbnail({}, {}, { signal: controller.signal }), { name: 'AbortError' });
assert.equal(calls, 1);
});
+6 -8
View File
@@ -8,7 +8,8 @@
* card on every render, so a read that counted would evict the visible cards * 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. * first). Eviction revokes the image only; what the probe proved is kept.
*/ */
import { thumbnailOrder, wvsSnapshotUrl } from './model.js'; import { thumbnailOrder } from './model.js';
import { createRecentImagerySource } from './source.js';
const EMPTY = Object.freeze({ const EMPTY = Object.freeze({
status: 'unknown', status: 'unknown',
@@ -22,6 +23,7 @@ const EMPTY = Object.freeze({
*/ */
export function createThumbnailLoader({ export function createThumbnailLoader({
fetchImpl = globalThis.fetch?.bind(globalThis), fetchImpl = globalThis.fetch?.bind(globalThis),
requestThumbnail = createRecentImagerySource({ fetchImpl }).getThumbnail,
maxInFlight = 3, maxInFlight = 3,
maxDecoded = 16, maxDecoded = 16,
createObjectUrl = (blob) => URL.createObjectURL(blob), createObjectUrl = (blob) => URL.createObjectURL(blob),
@@ -90,14 +92,10 @@ export function createThumbnailLoader({
let acquisitionTime = null; let acquisitionTime = null;
let cancelled = false; let cancelled = false;
try { try {
const url = wvsSnapshotUrl({ const response = await requestThumbnail(entry.candidate, entry.box, {
product: entry.candidate.product, size,
day: entry.candidate.day, signal: controller.signal,
box: entry.box,
width: size,
height: size,
}); });
const response = await fetchImpl(url, { signal: controller.signal });
if (controller.signal.aborted) { if (controller.signal.aborted) {
cancelled = true; cancelled = true;
} else if (response?.ok) { } else if (response?.ok) {
@@ -315,3 +315,24 @@ test('a request queued behind an aborted fetch still starts once the abort settl
tracked: 1, tracked: 1,
}); });
}); });
test('injected thumbnail acquisition owns requests while the loader releases object URLs', async () => {
const calls = [];
const { loader, fetch, revoked } = fixture({
requestThumbnail: async (...args) => {
calls.push(args);
return response({ headers: { 'Data-Present': 'true' } });
},
});
const entry = candidate('S30', '2026-09-18');
loader.request(entry, BOX, 0);
await settle();
assert.equal(fetch.calls.length, 0);
assert.equal(calls.length, 1);
assert.equal(calls[0][0], entry);
assert.deepEqual(calls[0][1], BOX);
assert.ok(calls[0][2].signal instanceof AbortSignal);
assert.equal(loader.get(entry.key).status, 'present');
loader.destroy();
assert.deepEqual(revoked, ['blob:1']);
});
+4
View File
@@ -1,3 +1,5 @@
import { createDirectionsSource } from '../layers/directions/source.js';
import { createRecentImagerySource } from '../layers/recentImagery/source.js';
import { createOpenFreeMapSource } from '../sources/openFreeMap.js'; import { createOpenFreeMapSource } from '../sources/openFreeMap.js';
import { import {
createFlightSource, createFlightSource,
@@ -28,6 +30,8 @@ export function createStandaloneLayerSources(overrides = {}) {
const mapTiles = createOpenFreeMapSource(); const mapTiles = createOpenFreeMapSource();
const defaults = { const defaults = {
...createReferenceSources(), ...createReferenceSources(),
directions: createDirectionsSource(),
'recent-imagery': createRecentImagerySource(),
flights: createFlightSource(), flights: createFlightSource(),
military: createMilitarySource(), military: createMilitarySource(),
vessels: createVesselSource({ vessels: createVesselSource({