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
+281 -54

No files matched your search

+5
View File
@@ -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.
+8 -2
View File
@@ -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
+2
View File
@@ -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",
+24 -1
View File
@@ -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": {
+2 -2
View File
@@ -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({
+3 -9
View File
@@ -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 });
}
+8 -8
View File
@@ -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(),
});
}
+5
View File
@@ -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']),
+5 -2
View File
@@ -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 {
+2 -1
View File
@@ -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 },
+11 -18
View File
@@ -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) {
+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({
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);
+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 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);
});
+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
* 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) {
@@ -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']);
});
+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 {
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({