mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
commit
76db4114ff
80 files changed
+3845
-201
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user