Merge upstream/main into pr/google-street-view

Conflicts with upstream's Street Level provider composition and share-state
codec, resolved:

- Street View's switch moves from its own share option (`g`) to upstream's
  provider-switch field (`r`, e.g. `0.r.google-1`); a link without it keeps
  Street View off. Docs and the browser gate follow.
- createApplicationStreetLevel keeps upstream's injectable provider list;
  its default, once the application supplies its Street Level source, is
  Mapillary plus Google Street View. Without that source there are none, as
  upstream intends.
- The coverage hint keeps upstream's empty-composition message and joins
  every switched-on provider's hint otherwise.
- CHANGELOG, README and SECURITY keep both sides' entries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-12 11:32:05 +01:00
commit 76db4114ff
80 files changed
+3845 -201

No files matched your search

+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
@@ -806,18 +805,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']);
});
+10
View File
@@ -444,6 +444,16 @@ export function createStreetLevelLayer({
},
/** Registered providers, in chip order. */
providerIds: definitionsFrozen.map((def) => def.id),
getSourceAvailability() {
const available = definitionsFrozen.length > 0;
return {
available,
source: STREET_LEVEL_LAYER_ID,
reason: available
? null
: 'No street-level imagery providers configured',
};
},
init(viewer) {
if (state.initialized)
+14
View File
@@ -665,3 +665,17 @@ test('with no Mapillary token, the layer points at Street View, on Google 3D onl
layer.setProviderEnabled('google', true);
assert.equal(layer.getStats().keyRequired, false);
});
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
const { layer, viewer } = await enabledLayer(t, []);
assert.deepEqual(layer.providerIds, []);
assert.deepEqual(layer.getUIState().providers, []);
assert.equal(layer.getUIState().keyRequired, false);
assert.match(
layer.getUIState().coverage.hint,
/No street-level imagery providers/,
);
layer.disable();
layer.enable(viewer);
assert.deepEqual(layer.getUIState().providers, []);
});
+16 -5
View File
@@ -1,13 +1,19 @@
import { normalizeFilter } from './filter.js';
import { encodeIdSwitches, decodeIdSwitches } from '../../data/idSwitches.js';
/**
* Share-link params: one boolean per provider plus the filter. The codec in
* src/data/layerState.js names the same keys, so a new provider needs one there.
* Provider params plus the filter. Custom switches share a stable ID-based
* field; Mapillary retains its existing boolean and share token.
* @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input
*/
export function encodeParams({ providers, filter }) {
const params = {};
for (const [id, on] of providers) params[id] = on === true;
const entries = [...providers];
for (const [id, on] of entries) params[id] = on === true;
const switches = encodeIdSwitches(
entries.filter(([id]) => id !== 'mapillary'),
);
if (switches) params.providerSwitches = switches;
params.pano = filter.pano;
params.sinceDays = filter.sinceDays;
return params;
@@ -23,8 +29,13 @@ export function encodeParams({ providers, filter }) {
export function decodeParams(params, { providerIds, filter }) {
const providers = new Map();
const source = params && typeof params === 'object' ? params : {};
for (const id of providerIds)
if (typeof source[id] === 'boolean') providers.set(id, source[id]);
const switches = decodeIdSwitches(source.providerSwitches);
switches.delete('mapillary');
for (const id of providerIds) {
if (switches.has(id)) providers.set(id, switches.get(id));
else if (Object.hasOwn(source, id) && typeof source[id] === 'boolean')
providers.set(id, source[id]);
}
const next = {};
if ('pano' in source) next.pano = source.pano;
if ('sinceDays' in source) next.sinceDays = source.sinceDays;
+7 -1
View File
@@ -11,7 +11,13 @@ test('encodeParams writes one boolean per provider plus the filter', () => {
],
filter: { pano: 'flat', sinceDays: 365 },
}),
{ mapillary: true, panoramax: false, pano: 'flat', sinceDays: 365 },
{
mapillary: true,
panoramax: false,
providerSwitches: 'panoramax-0',
pano: 'flat',
sinceDays: 365,
},
);
});
@@ -0,0 +1,153 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { encodeParams, decodeParams } from './params.js';
import {
normalizeIdSwitches,
patchRequestedIdSwitches,
decodeIdSwitches,
encodeIdSwitches,
} from '../../data/idSwitches.js';
import {
createDefaultLayerState,
encodeLayerStateParams,
decodeLayerStateParams,
serializeStoredLayerState,
parseStoredLayerState,
} from '../../data/layerState.js';
import { validateProviders } from './registry.js';
import { fakeStreetLevelProvider } from '../../testSupport/streetLevelFakes.mjs';
const filter = { pano: 'flat', sinceDays: 365 };
test('custom switches survive sharing and storage without depending on provider order', () => {
const providers = [
['panoramax', false],
['example', true],
['mapillary', false],
];
const options = encodeParams({ providers, filter });
assert.equal(options.providerSwitches, 'example-1*panoramax-0');
assert.equal(
encodeParams({ providers: [...providers].reverse(), filter })
.providerSwitches,
options.providerSwitches,
);
const state = createDefaultLayerState();
state.enabledLayerIds = ['street-level'];
state.options['street-level'] = options;
const params = new URLSearchParams({ v: '2' });
encodeLayerStateParams(params, state);
assert.match(params.get('lo'), /0\.m\.0/);
assert.match(params.get('lo'), /0\.r\.example-1\*panoramax-0/);
assert.ok(
params.toString().includes('0.r.example-1*panoramax-0'),
'custom switches need no percent escaping',
);
for (const restored of [
decodeLayerStateParams(params),
parseStoredLayerState(serializeStoredLayerState(state)),
]) {
const decoded = decodeParams(restored.options['street-level'], {
providerIds: ['example', 'mapillary', 'panoramax'],
filter: { pano: 'all', sinceDays: 0 },
});
assert.deepEqual(
[...decoded.providers],
[
['example', true],
['mapillary', false],
['panoramax', false],
],
);
assert.deepEqual(decoded.filter, filter);
assert.deepEqual(
[
...decodeParams(restored.options['street-level'], {
providerIds: ['panoramax'],
filter,
}).providers,
],
[['panoramax', false]],
);
assert.equal(
decodeParams(restored.options['street-level'], {
providerIds: [],
filter,
}).providers.size,
0,
);
}
});
test('legacy links retain Mapillary semantics and leave custom defaults alone', () => {
for (const [lo, on] of [
['', true],
['0.m.0', false],
]) {
const state = decodeLayerStateParams(
new URLSearchParams({ v: '2', l: '0', lo }),
);
const restored = decodeParams(state.options['street-level'], {
providerIds: ['mapillary', 'example'],
filter,
});
assert.deepEqual([...restored.providers], [['mapillary', on]]);
}
});
test('switches reject ambiguous or oversized fields and cannot override the legacy token', () => {
for (const value of [
'example-0*example-1',
'mapillary-0',
'__proto__-1',
'a-2',
'a-1*',
'a.b-1',
'a_b-0',
`${'a'.repeat(255)}-1`,
null,
{},
]) {
assert.equal(normalizeIdSwitches(value, ['mapillary']), null);
}
assert.equal(normalizeIdSwitches('constructor-0'), 'constructor-0');
assert.throws(
() => encodeIdSwitches([['a'.repeat(255), true]]),
/share-link/,
);
assert.throws(
() => validateProviders([fakeStreetLevelProvider({ id: 'a'.repeat(255) })]),
/share-link/,
);
const decoded = decodeParams(
{ providerSwitches: 'mapillary-0', mapillary: true },
{ providerIds: ['mapillary'], filter },
);
assert.equal(decoded.providers.get('mapillary'), true);
});
test('neutral switch codec preserves hyphens and numeric suffixes in IDs', () => {
const switches = [
['provider-0', false],
['provider-1', true],
['a-b-c', true],
];
const encoded = encodeIdSwitches(switches);
assert.equal(encoded, 'a-b-c-1*provider-0-0*provider-1-1');
const decoded = decodeIdSwitches(encoded);
for (const [id, on] of switches) assert.equal(decoded.get(id), on);
assert.equal(decodeIdSwitches('valid-1*broken').size, 0);
});
test('editing a receiving composition drops unknown IDs before enforcing the share bound', () => {
const previous = `${'x'.repeat(254)}-1`;
assert.equal(normalizeIdSwitches(previous), previous);
assert.equal(
patchRequestedIdSwitches(previous, 'alpha-0*beta-0', { alpha: false }),
'alpha-0',
);
assert.equal(
patchRequestedIdSwitches(previous, 'alpha-0', { pano: 'flat' }),
undefined,
);
});
+18 -3
View File
@@ -1,3 +1,5 @@
import { encodeIdSwitches } from '../../data/idSwitches.js';
/**
* The contract an imagery provider registers with the Street Level layer.
*
@@ -11,7 +13,7 @@
* @property {{html: string}} credit On-globe attribution while the provider is active.
* @property {boolean} [groundClick] No coverage to click: a click on the ground at street zoom opens its nearest image.
* @property {string} [requiresMapStack] Usable only on this map stack (Google Street View: 'photoreal', per Google's terms).
* @property {boolean} [defaultOn] Switched on until the user says otherwise (default true); match the share-link option default.
* @property {boolean} [defaultOn] Switched on until the user says otherwise (default true); a share link without its switch keeps it.
* @property {(context: ProviderContext) => ProviderInstance} create
*
* @typedef {object} ProviderContext Handed to `create()` once by the core.
@@ -91,8 +93,8 @@ const ID_GRAMMAR = /^[a-z][a-z0-9-]*$/;
* @returns {ReadonlyArray<StreetLevelProvider>}
*/
export function validateProviders(providers) {
if (!Array.isArray(providers) || providers.length === 0)
throw new TypeError('Street Level needs at least one imagery provider');
if (!Array.isArray(providers))
throw new TypeError('Street Level providers must be an array');
const ids = new Set();
const prefixes = [];
for (const provider of providers) {
@@ -130,6 +132,19 @@ export function validateProviders(providers) {
ids.add(provider.id);
prefixes.push(provider.pickPrefix);
}
// Validate the complete bounded share field once, before any instance exists.
try {
encodeIdSwitches(
providers
.filter(({ id }) => id !== 'mapillary')
.map(({ id }) => [id, true]),
);
} catch (error) {
if (!(error instanceof RangeError)) throw error;
throw new RangeError(
'Street Level provider IDs cannot fit in the 256-character share-link field; use shorter IDs or fewer providers',
);
}
return Object.freeze([...providers]);
}
+8 -3
View File
@@ -26,9 +26,14 @@ test('a valid list is frozen in registration order', () => {
);
});
test('an empty list, duplicate ids and malformed ids are rejected', () => {
assert.throws(() => validateProviders([]), /at least one/);
assert.throws(() => validateProviders(null), /at least one/);
test('an empty provider list is valid and has no key requirement', () => {
assert.deepEqual(validateProviders([]), []);
assert.ok(Object.isFrozen(validateProviders([])));
assert.equal(requiresKeyIdFor([]), null);
});
test('non-arrays, duplicate ids and malformed ids are rejected', () => {
assert.throws(() => validateProviders(null), /array/);
assert.throws(() => validateProviders([def(), def()]), /Duplicate/);
assert.throws(() => validateProviders([def({ id: 'Bad Id' })]), /slug/);
});
+4 -1
View File
@@ -27,7 +27,10 @@ export function summarizeCoverage(providers) {
return {
count: active.reduce((sum, p) => sum + (p.count || 0), 0),
loading: active.some((p) => p.loading),
hint: [...new Set(active.map((p) => p.hint).filter(Boolean))].join(' · '),
hint:
providers.length === 0
? 'No street-level imagery providers configured'
: [...new Set(active.map((p) => p.hint).filter(Boolean))].join(' · '),
error: active.find((p) => p.error)?.error || null,
keyRequired:
active.length > 0 && active.every((p) => p.keyRequired === true),
+1 -1
View File
@@ -109,7 +109,7 @@ export function createIngestion({
feed.acceptedRowCount = snapshot.acceptedRowCount;
feed.partial = payload?.complete === false;
if (snapshot.acceptedRowCount === 0) {
if (snapshot.acceptedRowCount === 0 && !snapshot.emptyCoverage) {
feed.count = readCount();
feed.stale = feed.count > 0 || Boolean(payload?.refreshing);
if (isDefinitiveTransportFailure(snapshot.transportStatus)) {
+44
View File
@@ -361,3 +361,47 @@ test('partial vessel snapshots preserve freshness and recover after a complete u
layer.destroy();
assert.equal(layer.getStats().partial, false);
});
test('authoritative empty coverage clears old contacts without declaring an outage', async () => {
let current = snapshot([observation('111')]);
const { layer } = setup({
async getSnapshot() {
return current;
},
});
await layer.update();
current = snapshot([], { observedAtMs: null, lastMessageAt: 500 });
await layer.update();
assert.ok(!layer.hasContact('111'));
assert.equal(layer.getStats().count, 0);
assert.equal(layer.getStats().error, null);
assert.equal(layer.getStats().stale, false);
assert.equal(layer.getStats().lastUpdate, null);
});
test('empty coverage cannot hide stale, incomplete or rejected source data', async () => {
for (const patch of [
{ freshness: 'unknown' },
{ freshness: 'stale' },
{ complete: false },
{ rawRowCount: 1 },
{ transportStatus: 'stale' },
{ lastMessageAt: null },
]) {
let current = snapshot([observation('111')]);
const { layer } = setup({
async getSnapshot() {
return current;
},
});
await layer.update();
current = snapshot([], {
observedAtMs: null,
lastMessageAt: 500,
...patch,
});
await layer.update();
assert.equal(layer.hasContact('111'), true, JSON.stringify(patch));
assert.ok(layer.getStats().error, JSON.stringify(patch));
}
});
+13 -3
View File
@@ -119,7 +119,16 @@ export function createQueries({
typeof payload?.status === 'string' ? payload.status : null;
const lastMessageAt = payload?.lastMessageAt ?? null;
const acceptedRowCount = acceptedRows.length;
const emptyCoverage =
rawRows.length === 0 &&
payload?.rawRowCount === 0 &&
payload?.complete === true &&
payload?.freshness === 'current' &&
AIS_HEALTHY_STATUSES.has(transportStatus) &&
Number.isFinite(Number(lastMessageAt)) &&
Number(lastMessageAt) > 0;
return {
emptyCoverage,
transportStatus,
lastMessageAt,
rawRows,
@@ -130,9 +139,10 @@ export function createQueries({
? payload.rawRowCount
: rawRows.length,
acceptedRowCount,
error:
deriveAisFeedError(payload, acceptedRowCount) ||
(acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null),
error: emptyCoverage
? null
: deriveAisFeedError(payload, acceptedRowCount) ||
(acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null),
};
}