diff --git a/CHANGELOG.md b/CHANGELOG.md index 20b6fccdb..d658b4212 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 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. Missing catalog feeds leave their layers unavailable without blocking startup or making fallback requests; malformed supplied feeds remain configuration errors. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 77e0cf0fc..6f4e3e546 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -7,8 +7,14 @@ visibility lifecycle before initialization. Invalid supplied methods still fail construction. `createStandaloneApplication` and `createStandaloneCatalog` accept `sources` overrides; unspecified entries retain standalone defaults, and null entries explicitly remove a source. Street Level -continues to accept an explicit provider list. Directions and Recent Imagery -acquisition are still selected by their application constructors. +continues to accept an explicit provider list. Directions (`getRoute`) and +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. An explicit list replaces the default Mapillary provider; an empty list removes diff --git a/package.json b/package.json index 9222cd6c4..d9bf6cf8a 100644 --- a/package.json +++ b/package.json @@ -163,6 +163,7 @@ "./layers/vessels": "./src/layers/vessels/index.js", "./layers/earthquakes": "./src/layers/earthquakes/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", "./sources/firms-csv": "./src/data/firmsCsv.js", "./layers/firms": "./src/layers/firms/index.js", @@ -173,6 +174,7 @@ "./layers/traffic": "./src/layers/traffic/index.js", "./layers/bikeshare": "./src/layers/bikeshare/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/alpr": "./src/layers/alpr/index.js", "./layers/radio": "./src/layers/radio/index.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c5a5a3b89..5b10693ab 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -934,6 +934,7 @@ "exports": ["./layers/recent-imagery"], "modules": [ "src/layers/recentImagery/index.js", + "src/layers/recentImagery/source.js", "src/layers/recentImagery/model.js", "src/layers/recentImagery/catalog.js", "src/layers/recentImagery/rendering.js", @@ -1994,6 +1995,7 @@ "src/annotations/drawTool.js", "src/data/routeSteps.js", "src/layers/directions/index.js", + "src/layers/directions/source.js", "src/data/directions.js", "src/app/layers/directions.js", "src/search/coordinateGeocoder.js", @@ -2128,6 +2130,7 @@ "src/ui/localSdrPresentation.js", "src/app/layers/recentImagery.js", "src/layers/recentImagery/index.js", + "src/layers/recentImagery/source.js", "src/layers/recentImagery/model.js", "src/layers/recentImagery/catalog.js", "src/layers/recentImagery/rendering.js", @@ -2573,6 +2576,7 @@ "src/services/groundFloor.js", "src/data/routeSteps.js", "src/layers/directions/index.js", + "src/layers/directions/source.js", "src/services/terrainHeights.js", "src/data/terrainHeights.js", "src/data/groundFloor.js", @@ -2661,6 +2665,7 @@ "src/data/modelEligibility.js", "src/app/layers/recentImagery.js", "src/layers/recentImagery/index.js", + "src/layers/recentImagery/source.js", "src/layers/recentImagery/model.js", "src/layers/recentImagery/catalog.js", "src/layers/recentImagery/rendering.js", @@ -2915,9 +2920,27 @@ ], "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": { "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"] }, "radio-browser-source": { diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 74cbcf47f..54d2bae3b 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -133,8 +133,8 @@ export function createApplicationCatalog({ createApplicationRadio({ surface, source: sources.radio }), createApplicationTransit({ surface, source: sources.transit }), createApplicationBikeshare({ source: sources.bikeshare }), - createApplicationDirections(), - createApplicationRecentImagery(), + createApplicationDirections({ source: sources.directions }), + createApplicationRecentImagery({ source: sources['recent-imagery'] }), vessels, installations, createApplicationAwareness({ diff --git a/src/app/layers/directions.js b/src/app/layers/directions.js index 1e535fc88..8b16c5cff 100644 --- a/src/app/layers/directions.js +++ b/src/app/layers/directions.js @@ -1,12 +1,6 @@ import { createApplicationDirectionsLayer } from '../../data/directions.js'; -/** - * Construct one Directions layer using the application scene owners. - * - * 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(); +/** Construct Directions with application scene owners and an explicit route source. */ +export function createApplicationDirections({ source }) { + return createApplicationDirectionsLayer({ source }); } diff --git a/src/app/layers/recentImagery.js b/src/app/layers/recentImagery.js index 1de4b22d4..cc4659727 100644 --- a/src/app/layers/recentImagery.js +++ b/src/app/layers/recentImagery.js @@ -3,17 +3,17 @@ import { createRecentImageryRenderer } from '../../layers/recentImagery/renderin import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js'; import { governorRequestRender } from '../../renderGovernor.js'; -/** - * Construct the Recent Imagery layer with the real GIBS renderer and the - * 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() { +/** Construct imagery presentation over one explicitly supplied acquisition source. */ +export function createApplicationRecentImagery({ source }) { return createRecentImageryLayer({ + catalog: { searchHls: (...args) => source.searchHls(...args) }, renderer: createRecentImageryRenderer({ requestRender: governorRequestRender, + tileTemplate: (...args) => source.getTileTemplate(...args), + credit: source.credit || source.label || 'Imagery', + }), + thumbnails: createThumbnailLoader({ + requestThumbnail: (...args) => source.getThumbnail(...args), }), - thumbnails: createThumbnailLoader(), }); } diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 77b3ebe10..e40c67dd4 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -6,6 +6,11 @@ const contract = (layers, methods) => /** Acquisition contracts and their consumers; records remain family-specific. */ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({ + directions: contract(['directions'], ['getRoute']), + 'recent-imagery': contract( + ['recent-imagery'], + ['searchHls', 'getThumbnail', 'getTileTemplate'], + ), flights: contract(['flights'], ['getSnapshot']), military: contract(['military'], ['getSnapshot']), vessels: contract(['ais-live-vessels'], ['getSnapshot']), diff --git a/src/data/directions.js b/src/data/directions.js index ddb1109f7..ef5565488 100644 --- a/src/data/directions.js +++ b/src/data/directions.js @@ -1,3 +1,4 @@ +import { createDirectionsSource } from '../layers/directions/source.js'; /** * @module directions * @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. */ -export function createApplicationDirectionsLayer() { - return createDirectionsLayer({ services: directionsServices }); +export function createApplicationDirectionsLayer({ + source = createDirectionsSource(), +} = {}) { + return createDirectionsLayer({ services: directionsServices, source }); } export { diff --git a/src/data/sharedOsmDisplays.test.mjs b/src/data/sharedOsmDisplays.test.mjs index d4f322a57..273a9dc56 100644 --- a/src/data/sharedOsmDisplays.test.mjs +++ b/src/data/sharedOsmDisplays.test.mjs @@ -7,6 +7,7 @@ import { createBhoteKoshiLocatorLayer } from './bhoteKoshiLocator.js'; import { createWorldAnnotationRenderer } from '../annotations/worldAnnotationRenderer.js'; import * as annotations from '../annotations/worldAnnotationRenderer.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 * as input from './inputOwnership.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) => { const v = viewer(t); 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: {}, overlays: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop }, render: { governorRequestRender: noop, holdContinuousRender: noop, releaseContinuousRender: noop }, diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index bc1ec7296..0a06d5219 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -452,10 +452,7 @@ export function createDirectionsStepOverlayEntry(index, position, copy) { * @param {{lat:number, lon:number}} b * @returns {string} */ -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`; -} +export { directionsRequestUrl } from './source.js'; /** * 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 * shared services are injected, which is what keeps this module's own imports * 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. */ -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; * tests swap it. Resolved on use, not at import, because the services arrive @@ -827,18 +826,12 @@ export function createDirectionsLayer({ services }) { _error = null; notifyRow(); try { - const response = await fetch(directionsRequestUrl(mode, _a, _b), { - 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', - ); - } + const payload = await source.getRoute( + { mode, a: _a, b: _b }, + { + signal: controller.signal, + }, + ); if (seq !== _routeSeq || !_enabled) return; const route = normalizeRoutePayload(payload, mode); if (!route) { diff --git a/src/layers/directions/source.js b/src/layers/directions/source.js new file mode 100644 index 000000000..f4f353653 --- /dev/null +++ b/src/layers/directions/source.js @@ -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; + }, + }; +} diff --git a/src/layers/directions/source.test.mjs b/src/layers/directions/source.test.mjs new file mode 100644 index 000000000..824b1221b --- /dev/null +++ b/src/layers/directions/source.test.mjs @@ -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); + } +}); diff --git a/src/layers/recentImagery/rendering.js b/src/layers/recentImagery/rendering.js index 1d0a7893f..b241d0f52 100644 --- a/src/layers/recentImagery/rendering.js +++ b/src/layers/recentImagery/rendering.js @@ -70,6 +70,8 @@ function admitTileRequests(provider, admission, requestRender, retry) { */ export function createRecentImageryRenderer({ cesium = Cesium, + tileTemplate = gibsTemplate, + credit = 'NASA GIBS', maxTileRequests = 6, requestRender = () => {}, setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms), @@ -101,7 +103,7 @@ export function createRecentImageryRenderer({ function createProvider(candidate, box) { const spec = PRODUCTS[candidate.product]; const provider = new cesium.UrlTemplateImageryProvider({ - url: gibsTemplate(candidate.product, candidate.day), + url: tileTemplate(candidate.product, candidate.day), subdomains: ['a', 'b', 'c'], minimumLevel: 0, maximumLevel: spec.maxLevel, @@ -112,7 +114,7 @@ export function createRecentImageryRenderer({ box.east, box.north, ), - credit: 'NASA GIBS', + credit, hasAlphaChannel: spec.format === 'png', }); return admitTileRequests(provider, admission, requestRender, retry); diff --git a/src/layers/recentImagery/rendering.test.mjs b/src/layers/recentImagery/rendering.test.mjs index ad7e166a8..c1d38cacc 100644 --- a/src/layers/recentImagery/rendering.test.mjs +++ b/src/layers/recentImagery/rendering.test.mjs @@ -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 VIIRS = { key: 'VIIRS:2026-09-20', product: 'VIIRS', day: '2026-09-20' }; -function fixture({ requestImage, maxTileRequests } = {}) { +function fixture({ requestImage, maxTileRequests, ...options } = {}) { const renders = []; const timers = manualTimers(); const renderer = createRecentImageryRenderer({ + ...options, cesium: fakeCesium(requestImage), maxTileRequests, 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.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); +}); diff --git a/src/layers/recentImagery/source.js b/src/layers/recentImagery/source.js new file mode 100644 index 000000000..258e8e2e1 --- /dev/null +++ b/src/layers/recentImagery/source.js @@ -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), + }; +} diff --git a/src/layers/recentImagery/source.test.mjs b/src/layers/recentImagery/source.test.mjs new file mode 100644 index 000000000..d3cafc5b0 --- /dev/null +++ b/src/layers/recentImagery/source.test.mjs @@ -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); +}); diff --git a/src/layers/recentImagery/thumbnails.js b/src/layers/recentImagery/thumbnails.js index 47c383f13..23fe4dd49 100644 --- a/src/layers/recentImagery/thumbnails.js +++ b/src/layers/recentImagery/thumbnails.js @@ -8,7 +8,8 @@ * 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'; +import { thumbnailOrder } from './model.js'; +import { createRecentImagerySource } from './source.js'; const EMPTY = Object.freeze({ status: 'unknown', @@ -22,6 +23,7 @@ const EMPTY = Object.freeze({ */ export function createThumbnailLoader({ fetchImpl = globalThis.fetch?.bind(globalThis), + requestThumbnail = createRecentImagerySource({ fetchImpl }).getThumbnail, maxInFlight = 3, maxDecoded = 16, createObjectUrl = (blob) => URL.createObjectURL(blob), @@ -90,14 +92,10 @@ export function createThumbnailLoader({ 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 requestThumbnail(entry.candidate, entry.box, { + size, + signal: controller.signal, }); - const response = await fetchImpl(url, { signal: controller.signal }); if (controller.signal.aborted) { cancelled = true; } else if (response?.ok) { diff --git a/src/layers/recentImagery/thumbnails.test.mjs b/src/layers/recentImagery/thumbnails.test.mjs index 891b2e8dd..ada857f9a 100644 --- a/src/layers/recentImagery/thumbnails.test.mjs +++ b/src/layers/recentImagery/thumbnails.test.mjs @@ -315,3 +315,24 @@ test('a request queued behind an aborted fetch still starts once the abort settl 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']); +}); diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index 4e461f331..935351685 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -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 { createFlightSource, @@ -28,6 +30,8 @@ export function createStandaloneLayerSources(overrides = {}) { const mapTiles = createOpenFreeMapSource(); const defaults = { ...createReferenceSources(), + directions: createDirectionsSource(), + 'recent-imagery': createRecentImagerySource(), flights: createFlightSource(), military: createMilitarySource(), vessels: createVesselSource({