Merge origin/main: keep the provider registry, slim it

main grew the Street Level provider registry while this branch removed it
(#982–#985: injected providers, an empty composition, the `r` switch field).
The registry, params, uiState and pick router come back as main has them and
the slimmed layer sits behind the provider contract:

- The core (index.js) drives providers as on main, without surface modes,
  ground casting, FOLLOW, prewarm and nearest-image lookups. A provider's key
  status is asked with a 10 s timeout and asked again on a switch-on while no
  key is known.
- Mapillary is mapillaryProvider.js over the slim coverage, sequences and
  viewer modules, which read the filter and the active flag from the provider
  context.
- The viewer host mounts one provider adapter at a time, no prewarm.
- The panel has a chip per provider again (the pill switches the layer);
  no legend, EXPAND stays the Fullscreen API.
- The contract drops what no provider implements: groundCaster, meshSampler,
  getSurface/setSurface, nearestImage, prewarm, reportError, the legend and
  surface fields of the UI snapshot.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Fable 5.1 committed 2026-10-13 13:24:25 +04:00
commit c29ccd55d6
103 files changed
+5469 -522

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
@@ -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 -5
View File
@@ -1,7 +1,6 @@
import * as Cesium from 'cesium';
import { decodeCoverageTile } from './decode.js';
import { passesImageryFilter, resolveFilter } from './filter.js';
import { isActive } from './state.js';
import { passesImageryFilter } from './filter.js';
import { groundUnderCamera, viewFocus, visibleBbox } from './view.js';
import { coverageZoomForHeight, tilesForBbox } from './tileMath.js';
import {
@@ -96,7 +95,7 @@ export function createCoverage({ state, source }) {
/** The imagery filter, resolved now: "since N days" moves with the clock. */
function filter() {
return resolveFilter(state.filter);
return state.getFilter();
}
/**
@@ -320,7 +319,7 @@ export function createCoverage({ state, source }) {
/** Recompute the tile set for the current camera and reconcile primitives. */
function refresh() {
const viewer = state.viewer;
if (!viewer || !isActive(state) || state.coverage.blocked) return;
if (!viewer || !state.isActive() || state.coverage.blocked) return;
const { zoom, tiles, hint } = wantedTiles(viewer);
if (hint) {
state.coverage.hint = hint;
@@ -410,7 +409,7 @@ export function createCoverage({ state, source }) {
// Waiting for the key status is loading too, not an empty view.
loading:
state.coverage.pending.size > 0 ||
(blocked === 'status' && isActive(state)),
(blocked === 'status' && state.isActive()),
hint: state.coverage.hint,
keyRequired: blocked === 'no-key' || blocked === 'rejected',
keyRejected: blocked === 'rejected',
+3
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import * as Cesium from 'cesium';
import { createCoverage, ZOOM_IN_HINT } from './coverage.js';
import { freshCoverage } from './state.js';
import { resolveFilter } from './filter.js';
import { encodeCoverageTile } from './coverageFixture.mjs';
import { rayCamera } from '../../testSupport/streetLevelFakes.mjs';
import { COVERAGE_MAX_SEQUENCES, COVERAGE_MAX_TILES } from './policy.js';
@@ -93,6 +94,8 @@ function setup() {
enabled: true,
providerOn: true,
filter: { pano: 'all', sinceDays: 0 },
getFilter: () => resolveFilter(state.filter),
isActive: () => state.enabled && state.providerOn,
notify() {},
coverage: {
...freshCoverage(),
+38
View File
@@ -0,0 +1,38 @@
import * as Cesium from 'cesium';
/**
* One static Cesium credit per active provider: CC BY-SA imagery needs
* visible attribution that goes away with the imagery.
*/
export function createCredits() {
/** @type {Map<string, object>} provider id → Cesium.Credit */
const shown = new Map();
function show(viewer, def) {
if (shown.has(def.id) || !viewer?.creditDisplay) return;
try {
const credit = new Cesium.Credit(def.credit.html, true);
viewer.creditDisplay.addStaticCredit(credit);
shown.set(def.id, credit);
} catch {
/* credit display unavailable */
}
}
function hide(viewer, def) {
const credit = shown.get(def.id);
if (!credit) return;
try {
viewer?.creditDisplay?.removeStaticCredit?.(credit);
} catch {
/* credit display already torn down */
}
shown.delete(def.id);
}
function hideAll(viewer) {
for (const id of [...shown.keys()]) hide(viewer, { id });
}
return { show, hide, hideAll };
}
+186 -134
View File
@@ -1,61 +1,55 @@
import * as Cesium from 'cesium';
import { createState } from './state.js';
import { createMarker } from './marker.js';
import { createCameraFraming } from './cameraFraming.js';
import { createCredits } from './credits.js';
import { createPickRouter } from './pickRouter.js';
import { createSelection } from './selection.js';
import { createViewerHost } from './viewerHost.js';
import { normalizeFilter, sameFilter } from './filter.js';
import { requiresKeyIdFor, validateProviders } from './registry.js';
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
import { decodeParams, encodeParams } from './params.js';
import { composeUIState, summarizeCoverage } from './uiState.js';
import {
keyStatusLabel,
MAPILLARY_CREDIT_HTML,
MAPILLARY_KEY_ID,
POSITION_PICK_ID,
STREET_LEVEL_LAYER_ID,
} from './policy.js';
import { createCoverage } from './coverage.js';
import { createSequences } from './sequences.js';
import { createMapillaryViewer } from './viewer.js';
export { STREET_LEVEL_LAYER_ID } from './policy.js';
/** Longest wait for the server's key status; past it, the bundle's token decides. */
/** Longest wait for a provider's key status; past it, the provider decides. */
const KEY_STATUS_TIMEOUT_MS = 10_000;
/** Coverage as the panel sees it while the `mapillary` switch is off. */
const COVERAGE_OFF = Object.freeze({
count: 0,
loading: false,
hint: '',
error: null,
keyRequired: false,
keyRejected: false,
});
/**
* The Street Level layer: Mapillary coverage lines at street zoom, a selected
* sequence's image cones, and the photo viewer in the panel.
* @param {{source: object, services?: object, photoViewer?: object}} options
* `source` is source.js or a stand-in; `photoViewer` stands in for the
* MapillaryJS adapter (viewer.js).
* The Street Level layer over imagery providers (contract in registry.js).
* The core owns what they share: the filter and switches, the photo viewer
* host, the position marker and the click handler. Providers own coverage,
* sequences and their viewer.
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
*/
export function createStreetLevelLayer({
source,
providers: definitions,
services = {},
photoViewer = null,
}) {
const defs = validateProviders(definitions);
const state = createState({ services });
const parts = {};
const context = { state, parts };
parts.credits = createCredits();
parts.marker = createMarker(context);
parts.framing = createCameraFraming(context);
parts.coverage = createCoverage({ state, source });
parts.sequences = createSequences({ state, source, parts });
parts.viewerHost = createViewerHost({
state,
parts,
adapter:
photoViewer || createMapillaryViewer({ source, render: services.render }),
parts.router = createPickRouter(() => state.providers.values(), {
positionId: POSITION_PICK_ID,
});
parts.openImage = openImage;
parts.viewerHost = createViewerHost(context);
parts.hasSelectedSequence = () =>
[...state.providers.values()].some(
(entry) => entry.instance.sequenceStats?.()?.selectedId,
);
parts.clearSequences = () => {
for (const entry of state.providers.values())
entry.instance.clearSequence?.();
};
parts.selection = createSelection(context);
let notifyQueued = false;
@@ -76,127 +70,184 @@ export function createStreetLevelLayer({
}
state.notify = notify;
/** The key-status request in flight; overlapping checks share it. */
let keyCheck = null;
/** Coverage waits for the key status: no tile requests without a key. */
function checkKey() {
keyCheck ||= (async () => {
let configured;
try {
// A hung server must not hold LOADING, or the next check, forever.
const signal = AbortSignal.timeout(KEY_STATUS_TIMEOUT_MS);
configured = (await source.getStatus({ signal }))?.configured === true;
} catch {
configured = source.hasToken?.() === true;
}
parts.coverage.setKeyStatus(configured);
})().finally(() => {
keyCheck = null;
function providerContext(entry) {
return Object.freeze({
services: state.services,
/** Resolved against now, so "since N days" keeps up with the clock. */
getFilter: () => resolveFilter(state.filter),
/** The layer and this provider's switch are both on. */
isActive: () => state.enabled && entry.on,
notify,
actions: {
openImage: (imageId) => openImage(entry.def.id, imageId),
},
});
return keyCheck;
}
/** The on-globe credit while the layer draws: CC BY-SA imagery needs it. */
let credit = null;
function showCredit(viewer) {
if (credit || !viewer?.creditDisplay) return;
credit = new Cesium.Credit(MAPILLARY_CREDIT_HTML, true);
viewer.creditDisplay.addStaticCredit(credit);
}
function hideCredit(viewer) {
if (!credit) return;
viewer?.creditDisplay?.removeStaticCredit?.(credit);
credit = null;
for (const def of defs) {
const entry = {
def,
instance: null,
on: true,
status: null,
/** The status request in flight; overlapping checks share it. */
statusCheck: null,
};
entry.instance = def.create(providerContext(entry));
state.providers.set(def.id, entry);
}
function startDrawing() {
const viewer = state.viewer;
if (!viewer) return;
parts.sequences.setVisible(true);
parts.coverage.attach(viewer);
showCredit(viewer);
const activeEntries = () =>
[...state.providers.values()].filter((entry) => entry.on);
/** A provider's key status. A hung server must not hold LOADING, or the next check, forever. */
function checkStatus(entry) {
entry.statusCheck ||= (async () => {
try {
entry.status = await entry.instance.status({
signal: AbortSignal.timeout(KEY_STATUS_TIMEOUT_MS),
});
} catch {
entry.status = null;
}
notify();
})().finally(() => {
entry.statusCheck = null;
});
return entry.statusCheck;
}
function activate(entry) {
if (!state.viewer) return;
entry.instance.activate(state.viewer);
parts.credits.show(state.viewer, entry.def);
// Without a known key, a switch-on asks again: the server may be back,
// or have a key now.
if (['status', 'no-key'].includes(state.coverage.blocked)) checkKey();
if (entry.status?.configured !== true) checkStatus(entry);
}
/** Close the photo, clear lines and cones, forget refusals. */
function stopDrawing() {
parts.viewerHost.unmount();
parts.coverage.detach();
parts.coverage.clear();
parts.coverage.unblock();
parts.sequences.clearSelection();
parts.sequences.setVisible(false);
hideCredit(state.viewer);
function deactivate(entry) {
parts.viewerHost.unmount(entry.def.id);
entry.instance.deactivate();
parts.credits.hide(state.viewer, entry.def);
}
/** The share link's `mapillary` switch: off, the layer draws nothing. */
function setProviderOn(on) {
if (state.providerOn === on) return;
state.providerOn = on;
function openImage(providerId, imageId) {
return parts.viewerHost.open(providerId, imageId);
}
function setProviderEnabled(providerId, on) {
const entry = state.providers.get(providerId);
if (!entry) return false;
const next = on !== false;
if (entry.on === next) return true;
entry.on = next;
if (state.enabled) {
if (on) startDrawing();
else stopDrawing();
if (next) activate(entry);
else deactivate(entry);
}
notify();
}
function openImage(imageId) {
return parts.viewerHost.open(imageId);
return true;
}
function setCoverageFilter(next) {
const filter = normalizeFilter(next, state.filter);
if (sameFilter(filter, state.filter)) return;
state.filter = filter;
parts.coverage.rebuild();
parts.sequences.rerender();
const resolved = resolveFilter(filter);
for (const entry of state.providers.values())
entry.instance.setFilter(resolved);
notify();
}
function coverageStats() {
return state.providerOn ? parts.coverage.stats() : COVERAGE_OFF;
function providerSnapshots() {
return [...state.providers.values()].map((entry) => {
const stats = entry.instance.coverageStats();
return {
id: entry.def.id,
name: entry.def.name,
label: entry.def.label,
on: entry.on,
configured: entry.status ? entry.status.configured === true : null,
// A rejected key gates the provider exactly like a missing one.
keyRequired: stats.keyRequired === true || stats.keyRejected === true,
keyRejected: stats.keyRejected === true,
requiresKeyId: entry.def.requiresKeyId || null,
loading: stats.loading === true,
count: stats.count || 0,
hint: stats.hint || '',
error: stats.error || null,
color: entry.def.colors.coverage,
};
});
}
/** The selected sequence; the provider showing the open image answers first. */
function sequenceSnapshot() {
const owner = state.providers.get(state.street.providerId);
const candidates = owner
? [owner, ...[...state.providers.values()].filter((e) => e !== owner)]
: [...state.providers.values()];
for (const entry of candidates) {
const stats = entry.instance.sequenceStats?.();
if (stats?.selectedId || stats?.loading || stats?.error)
return {
providerId: entry.def.id,
selectedId: stats.selectedId || null,
images: stats.images || 0,
loading: stats.loading === true,
error: stats.error || null,
};
}
return {
providerId: null,
selectedId: null,
images: 0,
loading: false,
error: null,
};
}
function getUIState() {
const { host, ...street } = state.street;
const { keyRequired, keyRejected, ...coverage } = coverageStats();
return {
return composeUIState({
enabled: state.enabled,
providerOn: state.providerOn,
// A rejected key gates the layer exactly like a missing one.
keyRequired,
keyRejected,
filter: { ...state.filter },
coverage,
sequence: {
selectedId: state.sequence.selectedId,
images: state.sequence.images.length,
loading: state.sequence.loading,
error: state.sequence.error,
},
filter: state.filter,
providers: providerSnapshots(),
street,
};
sequence: sequenceSnapshot(),
});
}
const layer = {
id: STREET_LEVEL_LAYER_ID,
name: 'Street Level',
icon: '📷',
source: 'Mapillary',
source: defs.map((def) => def.name).join(' · '),
updateInterval: 0,
statsRefreshInterval: 1000,
requiresKeyId: MAPILLARY_KEY_ID,
requiresKeyId: requiresKeyIdFor(defs),
/** Registered providers, in chip order. */
providerIds: defs.map((def) => def.id),
getSourceAvailability() {
const available = defs.length > 0;
return {
available,
source: STREET_LEVEL_LAYER_ID,
reason: available
? null
: 'No street-level imagery providers configured',
};
},
init(viewer) {
state.viewer = viewer;
parts.marker.ensure(viewer);
parts.marker.setVisible(false);
parts.sequences.ensureCollections(viewer);
parts.sequences.setVisible(false);
checkKey();
for (const entry of state.providers.values()) {
entry.instance.init(viewer);
checkStatus(entry);
}
console.log('[Data:StreetLevel] Initialized');
},
@@ -205,13 +256,15 @@ export function createStreetLevelLayer({
state.viewer = viewer;
parts.marker.setVisible(true);
parts.selection.install(viewer);
if (state.providerOn) startDrawing();
for (const entry of activeEntries()) activate(entry);
notify();
},
disable() {
state.enabled = false;
stopDrawing();
parts.viewerHost.unmount();
for (const entry of state.providers.values()) entry.instance.deactivate();
parts.credits.hideAll(state.viewer);
parts.selection.uninstall();
parts.marker.setVisible(false);
notify();
@@ -223,7 +276,8 @@ export function createStreetLevelLayer({
destroy(viewer = state.viewer) {
layer.disable();
parts.sequences.destroy(viewer);
for (const entry of state.providers.values())
entry.instance.destroy(viewer);
parts.marker.destroy(viewer);
parts.framing.attachNavigation(null);
state.listeners.clear();
@@ -231,7 +285,8 @@ export function createStreetLevelLayer({
},
getStats() {
const coverage = coverageStats();
// The lifecycle polls this every second: summarise, don't snapshot.
const coverage = summarizeCoverage(providerSnapshots());
const keyLabel = keyStatusLabel(coverage);
let loadingLabel = '';
if (keyLabel) loadingLabel = keyLabel;
@@ -252,26 +307,22 @@ export function createStreetLevelLayer({
parts.framing.attachNavigation(navigation);
},
/**
* Share-link and stored state, in the codec's keys (src/data/layerState.js):
* the `mapillary` switch plus the filter.
*/
/** Share-link and stored state: provider switches plus the filter. */
getParams() {
return {
mapillary: state.providerOn,
pano: state.filter.pano,
sinceDays: state.filter.sinceDays,
};
return encodeParams({
providers: [...state.providers].map(([id, entry]) => [id, entry.on]),
filter: state.filter,
});
},
/** Unknown keys and malformed values are ignored, so any link applies. */
/** Unknown providers and malformed values are ignored, so any link applies. */
setParams(params = {}) {
const input = params && typeof params === 'object' ? params : {};
if (typeof input.mapillary === 'boolean') setProviderOn(input.mapillary);
const next = {};
if ('pano' in input) next.pano = input.pano;
if ('sinceDays' in input) next.sinceDays = input.sinceDays;
setCoverageFilter(next);
const decoded = decodeParams(params, {
providerIds: state.providers.keys(),
filter: state.filter,
});
for (const [id, on] of decoded.providers) setProviderEnabled(id, on);
setCoverageFilter(decoded.filter);
return true;
},
@@ -282,15 +333,16 @@ export function createStreetLevelLayer({
return () => state.listeners.delete(listener);
},
getUIState,
/** The DOM element the photo viewer renders into. */
/** The DOM element provider viewers render into. */
attachViewerHost(element) {
parts.viewerHost.attach(element);
},
setProviderEnabled,
openImage,
/** Close the image and deselect its sequence on the map. */
closeViewer() {
parts.viewerHost.close();
parts.sequences.clearSelection();
parts.clearSequences();
},
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
resizeViewer: () => parts.viewerHost.resize(),
+25 -9
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createStreetLevelLayer } from './index.js';
import { createMapillaryProvider } from './mapillaryProvider.js';
import { MAPILLARY_CREDIT_HTML } from './policy.js';
import {
fakeCesiumViewer,
@@ -18,6 +19,7 @@ async function startLayer(
{
source = fakeMapillarySource(),
photoViewer = fakePhotoViewer(),
providers = [createMapillaryProvider({ source, photoViewer })],
view = false,
enable = true,
} = {},
@@ -32,7 +34,7 @@ async function startLayer(
async () => {},
);
const viewer = fakeCesiumViewer({ view });
const layer = createStreetLevelLayer({ source, photoViewer });
const layer = createStreetLevelLayer({ providers });
layer.init(viewer);
if (enable) layer.enable(viewer);
t.after(() => {
@@ -181,13 +183,13 @@ test('a rejected key gates the layer as KEY REJECTED until it goes off', async (
test('switching Mapillary off clears coverage and closes the image it shows', async (t) => {
const { layer, photoViewer } = await startLayer(t);
layer.attachViewerHost({});
assert.equal(await layer.openImage('img1'), true);
assert.equal(await layer.openImage('mapillary', 'img1'), true);
assert.deepEqual(photoViewer.calls.open, ['img1']);
assert.equal(layer.getUIState().street.open, true);
layer.setParams({ mapillary: false });
assert.equal(photoViewer.calls.unmount, 1, 'its viewer is released');
assert.equal(layer.getUIState().street.open, false);
assert.equal(layer.getUIState().providerOn, false);
assert.equal(layer.getUIState().providers[0].on, false);
});
test('a sequence that fails to load reports it apart from the photo, and the error goes with it', async (t) => {
@@ -202,7 +204,7 @@ test('a sequence that fails to load reports it apart from the photo, and the err
});
const { layer } = await startLayer(t, { source, photoViewer });
layer.attachViewerHost({});
assert.equal(await layer.openImage('img1'), true);
assert.equal(await layer.openImage('mapillary', 'img1'), true);
await settle();
let ui = layer.getUIState();
assert.equal(ui.sequence.error, 'Sequence images unavailable');
@@ -217,17 +219,17 @@ test('closing the photo, switching the layer off or destroying it stops the fram
photoViewer: posingViewer(),
});
layer.attachViewerHost({});
assert.equal(await layer.openImage('img1'), true);
assert.equal(await layer.openImage('mapillary', 'img1'), true);
assert.equal(viewer.flights.started, 1, 'the photo is framed');
layer.closeViewer();
assert.equal(viewer.flights.cancelled, 1, 'closed');
await layer.openImage('img2');
await layer.openImage('mapillary', 'img2');
layer.disable();
assert.equal(viewer.flights.cancelled, 2, 'layer off');
layer.enable(viewer);
await layer.openImage('img3');
await layer.openImage('mapillary', 'img3');
layer.destroy();
assert.equal(viewer.flights.cancelled, 3, 'destroyed');
});
@@ -264,7 +266,7 @@ test('setParams ignores unknown keys and malformed values', async (t) => {
});
});
test('switching Mapillary on while the layer is off draws nothing (M01)', async (t) => {
test('switching Mapillary on while the layer is off draws nothing', async (t) => {
const { layer, viewer, source, photoViewer } = await startLayer(t, {
view: true,
enable: false,
@@ -276,10 +278,24 @@ test('switching Mapillary on while the layer is off draws nothing (M01)', async
assert.equal(source.calls.tiles.length, 0, 'no coverage drawn');
assert.deepEqual(viewer.credits, [], 'no credit shown');
assert.equal(photoViewer.calls.mount, 0, 'no viewer stood up');
assert.equal(layer.getUIState().providerOn, true, 'the switch is kept');
assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept');
// Enabling the layer is what draws it.
layer.enable(viewer);
await settle();
assert.ok(source.calls.tiles.length > 0);
assert.equal(viewer.credits.length, 1);
});
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
const { layer, viewer } = await startLayer(t, { providers: [] });
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, []);
});
+145
View File
@@ -0,0 +1,145 @@
import { freshCoverage } from './state.js';
import { createCoverage, sequenceIdFromPick } from './coverage.js';
import { createSequences } from './sequences.js';
import { createMapillaryViewer } from './viewer.js';
import {
COLORS,
MAPILLARY_CREDIT_HTML,
MAPILLARY_KEY_ID,
MAPILLARY_LABEL,
MAPILLARY_NAME,
MAPILLARY_PROVIDER_ID,
MAPILLARY_SOURCE_METHODS,
PICK_PREFIX,
} from './policy.js';
/** Per-provider mutable state; coverage and sequences share it. */
function createProviderState(context) {
return {
services: context.services,
getFilter: context.getFilter,
isActive: context.isActive,
notify: context.notify,
viewer: null,
coverage: freshCoverage(),
sequence: {
selectedId: null,
images: [],
collection: null,
loading: false,
/** Why the selected sequence's images could not load, until it goes. */
error: null,
abort: null,
},
};
}
/**
* The Mapillary provider: coverage lines from vector tiles, image cones from
* the Graph API and the MapillaryJS viewer.
* @param {{source: object, photoViewer?: object}} options `source` is
* source.js or a stand-in; `photoViewer` stands in for the viewer adapter.
* @returns {import('./registry.js').StreetLevelProvider}
*/
export function createMapillaryProvider({ source, photoViewer = null }) {
if (
!MAPILLARY_SOURCE_METHODS.every(
(method) => typeof source?.[method] === 'function',
)
)
throw new TypeError('A Mapillary source is required');
return Object.freeze({
id: MAPILLARY_PROVIDER_ID,
name: MAPILLARY_NAME,
label: MAPILLARY_LABEL,
requiresKeyId: MAPILLARY_KEY_ID,
pickPrefix: PICK_PREFIX.root,
colors: COLORS,
credit: Object.freeze({ html: MAPILLARY_CREDIT_HTML }),
create(context) {
const state = createProviderState(context);
const parts = {};
parts.coverage = createCoverage({ state, source });
parts.sequences = createSequences({ state, source, parts });
return {
/** Coverage waits for the answer: no tile requests without a key. */
async status({ signal } = {}) {
let configured;
try {
configured =
(await source.getStatus({ signal }))?.configured === true;
} catch {
configured = source.hasToken?.() === true;
}
parts.coverage.setKeyStatus(configured);
return { configured };
},
init(viewer) {
state.viewer = viewer;
parts.sequences.ensureCollections(viewer);
parts.sequences.setVisible(false);
},
activate(viewer) {
state.viewer = viewer;
parts.sequences.setVisible(true);
parts.coverage.attach(viewer);
},
/** Clear lines and cones, forget refusals. */
deactivate() {
parts.coverage.detach();
parts.coverage.clear();
parts.coverage.unblock();
parts.sequences.clearSelection();
parts.sequences.setVisible(false);
},
destroy(viewer) {
parts.coverage.detach();
parts.coverage.clear();
parts.sequences.destroy(viewer);
},
/** The filter changed: redraw. Each draw reads it from the context. */
setFilter() {
parts.coverage.rebuild();
parts.sequences.rerender();
},
coverageStats: () => parts.coverage.stats(),
/** A line selects its sequence; a cone opens its image. */
handlePick(pickId) {
const sequenceId = sequenceIdFromPick(pickId);
if (sequenceId) {
parts.sequences.select(sequenceId);
return true;
}
if (pickId.startsWith(PICK_PREFIX.image)) {
context.actions.openImage(pickId.slice(PICK_PREFIX.image.length));
return true;
}
return false;
},
selectSequence: (sequenceId) => parts.sequences.select(sequenceId),
clearSequence: () => parts.sequences.clearSelection(),
sequenceStats: () => ({
selectedId: state.sequence.selectedId,
images: state.sequence.images.length,
loading: state.sequence.loading,
error: state.sequence.error,
}),
viewer:
photoViewer ||
createMapillaryViewer({ source, render: context.services?.render }),
};
},
});
}
@@ -0,0 +1,172 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createMapillaryProvider } from './mapillaryProvider.js';
import { mapillaryImageUrl } from './policy.js';
import { sequenceIdFromPick } from './coverage.js';
import { thinImages } from './sequences.js';
import { validateProviders } from './registry.js';
import {
fakeMapillarySource,
rayCamera,
} from '../../testSupport/streetLevelFakes.mjs';
function fakeContext(filter = { pano: 'all', sinceMs: null }) {
const opened = [];
return {
opened,
services: {},
getFilter: () => filter,
isActive: () => true,
notify: () => {},
actions: { openImage: (id) => opened.push(id) },
};
}
test('the definition satisfies the Street Level provider contract', () => {
const def = createMapillaryProvider({ source: fakeMapillarySource() });
assert.doesNotThrow(() => validateProviders([def]));
assert.equal(def.id, 'mapillary');
assert.equal(def.label, 'MAPILLARY');
assert.equal(def.requiresKeyId, 'mapillary');
assert.equal(def.pickPrefix, 'mly:');
assert.match(def.credit.html, /CC BY-SA 4\.0/);
// One colour per source: Mapillary is green everywhere it draws.
assert.equal(def.colors.coverage, '#05cb63');
assert.throws(() => createMapillaryProvider({ source: {} }), /source/);
});
test('image deep links match the mapillary.com share format', () => {
assert.equal(
mapillaryImageUrl(1814275685699406),
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
);
assert.equal(mapillaryImageUrl(' '), 'https://www.mapillary.com/app/');
});
test('picks route sequences to selection and images to the core opener', () => {
const context = fakeContext();
const instance = createMapillaryProvider({
source: fakeMapillarySource(),
}).create(context);
assert.equal(instance.handlePick('mly:img:77'), true);
assert.deepEqual(context.opened, ['77']);
assert.equal(instance.handlePick('cctv:1'), false);
assert.deepEqual(instance.sequenceStats(), {
selectedId: null,
images: 0,
loading: false,
error: null,
});
});
test('a line pick names its sequence; anything else is not a sequence', () => {
assert.equal(sequenceIdFromPick('mly:seq:abc'), 'abc');
assert.equal(sequenceIdFromPick('mly:img:abc'), null);
assert.equal(sequenceIdFromPick(null), null);
});
test('cone thinning across the date line drops images metres apart', () => {
const image = (id, lon) => ({ id, lon, lat: 0 });
// A sequence driving east over ±180° with images ~1 m apart, then ~11 m on.
const kept = thinImages(
[
image('a', 179.99999),
image('b', -179.99999), // ~2 m from a, across the date line
image('c', -179.9999), // ~11 m from a
],
3,
);
assert.deepEqual(
kept.map(({ id }) => id),
['a', 'c'],
);
});
/** A viewer looking straight down on downtown Sacramento from 900 m. */
function streetViewer() {
const collection = () => ({
add: (item) => item,
remove: () => true,
contains: () => false,
get length() {
return 0;
},
});
const event = () => ({ addEventListener: () => () => {} });
const camera = Object.assign(
rayCamera({
lon: -121.4944,
lat: 38.5816,
altitude: 900,
pitch: -90,
width: 100,
height: 100,
}),
{ changed: event(), moveEnd: event() },
);
return {
camera,
scene: {
canvas: { clientWidth: 100, clientHeight: 100 },
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
primitives: collection(),
groundPrimitives: collection(),
preRender: event(),
postRender: event(),
requestRender() {},
},
};
}
test('coverage asks for no tiles before the key status is known, and none without a key', async (t) => {
// Skip Cesium's one-time terrain table download for draped lines.
t.mock.method(
Cesium.GroundPolylinePrimitive,
'initializeTerrainHeights',
async () => {},
);
for (const configured of [false, true]) {
let answer;
const tiles = [];
const source = fakeMapillarySource({
hasToken: () => configured,
getStatus: () => new Promise((resolve) => (answer = resolve)),
getTile: (...args) => {
tiles.push(args);
return new Promise(() => {});
},
});
const instance = createMapillaryProvider({ source }).create(fakeContext());
const viewer = streetViewer();
instance.init(viewer);
const status = instance.status();
instance.activate(viewer);
assert.equal(tiles.length, 0, 'nothing before the status answers');
answer({ configured });
assert.deepEqual(await status, { configured });
if (configured)
assert.ok(tiles.length > 0, 'coverage loads as soon as the key is known');
else {
assert.equal(tiles.length, 0, 'a key-less install never asks');
assert.equal(instance.coverageStats().keyRequired, true);
}
instance.destroy(viewer);
}
});
test('a status the server never answers falls back to the bundled token once the core gives up', async () => {
const source = fakeMapillarySource({
hasToken: () => false,
getStatus: ({ signal } = {}) =>
new Promise((_, reject) =>
signal?.addEventListener('abort', () => reject(signal.reason)),
),
});
const instance = createMapillaryProvider({ source }).create(fakeContext());
const controller = new AbortController();
const status = instance.status({ signal: controller.signal });
controller.abort(new Error('timed out'));
assert.deepEqual(await status, { configured: false });
assert.equal(instance.coverageStats().keyRequired, true);
});
+43
View File
@@ -0,0 +1,43 @@
import { normalizeFilter } from './filter.js';
import { encodeIdSwitches, decodeIdSwitches } from '../../data/idSwitches.js';
/**
* 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 = {};
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;
}
/**
* Read params back, ignoring unknown providers and malformed values so a link
* from a build with more providers still applies.
* @param {object} params
* @param {{providerIds: Iterable<string>, filter: {pano: string, sinceDays: number}}} current
* @returns {{providers: Map<string, boolean>, filter: {pano: string, sinceDays: number}}}
*/
export function decodeParams(params, { providerIds, filter }) {
const providers = new Map();
const source = params && typeof params === 'object' ? params : {};
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;
return { providers, filter: normalizeFilter(next, filter) };
}
+64
View File
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { decodeParams, encodeParams } from './params.js';
test('encodeParams writes one boolean per provider plus the filter', () => {
assert.deepEqual(
encodeParams({
providers: [
['mapillary', true],
['panoramax', false],
],
filter: { pano: 'flat', sinceDays: 365 },
}),
{
mapillary: true,
panoramax: false,
providerSwitches: 'panoramax-0',
pano: 'flat',
sinceDays: 365,
},
);
});
test('decodeParams round-trips and ignores unknown or malformed keys', () => {
const current = { pano: 'all', sinceDays: 0 };
const decoded = decodeParams(
{ mapillary: false, kartaview: true, pano: 'pano', sinceDays: 730, x: 1 },
{ providerIds: ['mapillary'], filter: current },
);
assert.deepEqual([...decoded.providers], [['mapillary', false]]);
assert.deepEqual(decoded.filter, { pano: 'pano', sinceDays: 730 });
const partial = decodeParams(
{ mapillary: '1', pano: 'weird' },
{ providerIds: ['mapillary'], filter: { pano: 'flat', sinceDays: 5 } },
);
assert.equal(partial.providers.size, 0, 'a string is not a switch');
assert.deepEqual(partial.filter, { pano: 'flat', sinceDays: 5 });
const empty = decodeParams(null, {
providerIds: ['mapillary'],
filter: current,
});
assert.equal(empty.providers.size, 0);
assert.deepEqual(empty.filter, current);
});
test('decodeParams applies "any date" (0 days) over a current window (share-link defaults)', () => {
const decoded = decodeParams(
{ sinceDays: 0 },
{ providerIds: ['mapillary'], filter: { pano: 'flat', sinceDays: 365 } },
);
assert.deepEqual(decoded.filter, { pano: 'flat', sinceDays: 0 });
// The provider switch off is a value too, not an absence.
assert.deepEqual(
[
...decodeParams(
{ mapillary: false },
{ providerIds: ['mapillary'], filter: { pano: 'all', sinceDays: 0 } },
).providers,
],
[['mapillary', false]],
);
});
+21
View File
@@ -0,0 +1,21 @@
/**
* Route picked ids to the provider whose prefix they carry; the registry
* guarantees prefixes never overlap.
* @param {() => Iterable<{def: {id: string, pickPrefix: string}, instance: object}>} getProviders
* @param {{positionId?: string}} [options] Id of the core-owned position marker.
*/
export function createPickRouter(getProviders, { positionId = null } = {}) {
function resolve(id) {
if (typeof id !== 'string' || !id) return null;
if (positionId && id === positionId)
return { providerId: null, instance: null, id };
for (const entry of getProviders())
if (id.startsWith(entry.def.pickPrefix))
return { providerId: entry.def.id, instance: entry.instance, id };
return null;
}
return {
ownsPick: (id) => resolve(id) !== null,
resolve,
};
}
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPickRouter } from './pickRouter.js';
const providers = [
{ def: { id: 'mapillary', pickPrefix: 'mly:' }, instance: { name: 'm' } },
{ def: { id: 'panoramax', pickPrefix: 'pnx:' }, instance: { name: 'p' } },
];
test('ids are routed by prefix to their provider instance', () => {
const router = createPickRouter(() => providers, { positionId: 'sl:pos' });
assert.deepEqual(router.resolve('mly:seq:1'), {
providerId: 'mapillary',
instance: providers[0].instance,
id: 'mly:seq:1',
});
assert.equal(router.resolve('pnx:img:9').providerId, 'panoramax');
assert.equal(router.ownsPick('mly:img:2'), true);
});
test('the core-owned marker is owned but routes to no provider', () => {
const router = createPickRouter(() => providers, { positionId: 'sl:pos' });
assert.deepEqual(router.resolve('sl:pos'), {
providerId: null,
instance: null,
id: 'sl:pos',
});
assert.equal(router.ownsPick('sl:pos'), true);
});
test('foreign and malformed ids are not ours', () => {
const router = createPickRouter(() => providers);
for (const id of ['cctv:1', '', null, undefined, 42, {}])
assert.equal(router.ownsPick(id), false, String(id));
assert.equal(router.resolve('sl:pos'), null, 'no marker id configured');
});
+11 -1
View File
@@ -1,8 +1,18 @@
/** Identity and tuning for the Street Level layer and its Mapillary imagery. */
/** Identity and tuning for the Street Level layer and its Mapillary provider. */
export const STREET_LEVEL_LAYER_ID = 'street-level';
export const MAPILLARY_PROVIDER_ID = 'mapillary';
export const MAPILLARY_NAME = 'Mapillary';
export const MAPILLARY_LABEL = 'MAPILLARY';
export const MAPILLARY_KEY_ID = 'mapillary';
export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com';
/** What the provider needs from its source (source.js or a stand-in). */
export const MAPILLARY_SOURCE_METHODS = Object.freeze([
'getStatus',
'getTile',
'getSequenceImages',
]);
/** Pick id of the viewer position marker. */
export const POSITION_PICK_ID = 'sl:pos';
@@ -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,
);
});
+141
View File
@@ -0,0 +1,141 @@
import { encodeIdSwitches } from '../../data/idSwitches.js';
/**
* The contract an imagery provider registers with the Street Level layer.
*
* @typedef {object} StreetLevelProvider
* @property {string} id Stable id, e.g. 'mapillary'; also the share-link option key.
* @property {string} name Human name for captions, e.g. 'Mapillary'.
* @property {string} label Chip text and link label, e.g. 'MAPILLARY'.
* @property {string|null} requiresKeyId Key-setup id the provider needs, or null when keyless.
* @property {string} pickPrefix Every primitive id the provider creates starts with it.
* @property {{coverage: string}} colors The source's one colour.
* @property {{html: string}} credit On-globe attribution while the provider is active.
* @property {(context: ProviderContext) => ProviderInstance} create
*
* @typedef {object} ProviderContext Handed to `create()` once by the core.
* @property {object} services Scene services: picking, input, render, sprites.
* @property {() => {pano: string, sinceMs: number|null}} getFilter Filter resolved against now; read it on every use so "since N days" keeps up.
* @property {() => boolean} isActive Layer enabled and this provider switched on.
* @property {() => void} notify Ask the core to publish a new UI snapshot.
* @property {{openImage: (imageId: string) => Promise<boolean>}} actions
*
* @typedef {object} ProviderInstance
* @property {(options?: {signal?: AbortSignal}) => Promise<{configured: boolean}>} status Whether a usable key is set; the core gives up on a hung answer through `signal`.
* @property {(viewer: object) => void} init Create collections, hidden.
* @property {(viewer: object) => void} activate Start drawing coverage for the camera.
* @property {() => void} deactivate Detach, clear coverage and selection, hide.
* @property {(viewer: object) => void} destroy
* @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter The filter changed: redraw (reading `getFilter()`).
* @property {() => {count: number, loading: boolean, hint: string, error: string|null, keyRequired: boolean, keyRejected?: boolean}} coverageStats
* @property {(pickId: string) => boolean} handlePick The id carries the provider's own prefix.
* @property {(sequenceId: string) => Promise<void>} [selectSequence]
* @property {() => void} [clearSequence]
* @property {() => {selectedId: string|null, images: number, loading: boolean, error?: string|null}} [sequenceStats]
* @property {ViewerAdapter} viewer
*
* @typedef {object} ViewerAdapter
* @property {(host: HTMLElement) => Promise<void>} mount Idempotent; may lazy-load a library.
* @property {(imageId: string) => Promise<void>} open Resolves once the image is on screen and a pose was emitted.
* @property {() => void} close Drop the image, keep the instance warm.
* @property {() => void} unmount Destroy the instance (host handed to another provider).
* @property {() => void} resize
* @property {(mode: 'letterbox'|'fill') => void} [setRenderMode]
* @property {(listener: (pose: StreetPose) => void) => () => void} onPose
*
* @typedef {object} StreetPose
* @property {string} imageId
* @property {{lon: number, lat: number}} position
* @property {number|null} bearing
* @property {number|null} altitude
* @property {boolean} isPano
* @property {number|null} capturedAt Epoch milliseconds.
* @property {string|null} creator
* @property {string|null} sequenceId
* @property {string} externalUrl Deep link to the image on the provider's site.
*/
const REQUIRED = Object.freeze([
'id',
'name',
'label',
'pickPrefix',
'colors',
'credit',
'create',
]);
const ID_GRAMMAR = /^[a-z][a-z0-9-]*$/;
/**
* Validate providers and freeze their order, which is also the chip order.
* @param {Array<StreetLevelProvider>} providers
* @returns {ReadonlyArray<StreetLevelProvider>}
*/
export function validateProviders(providers) {
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) {
const label = provider?.id ?? '(unnamed)';
for (const key of REQUIRED)
if (provider?.[key] === undefined || provider?.[key] === null)
throw new TypeError(`Street Level provider ${label} lacks ${key}`);
if (typeof provider.id !== 'string' || !ID_GRAMMAR.test(provider.id))
throw new TypeError(`Street Level provider id ${label} is not a slug`);
if (ids.has(provider.id))
throw new TypeError(`Duplicate Street Level provider ${provider.id}`);
if (typeof provider.create !== 'function')
throw new TypeError(
`Street Level provider ${label}: create is not a function`,
);
if (typeof provider.pickPrefix !== 'string' || !provider.pickPrefix)
throw new TypeError(`Street Level provider ${label} needs a pick prefix`);
for (const other of prefixes)
if (
other.startsWith(provider.pickPrefix) ||
provider.pickPrefix.startsWith(other)
)
throw new TypeError(
`Street Level provider ${label}: pick prefix ${provider.pickPrefix} overlaps ${other}`,
);
if (!provider.credit?.html)
throw new TypeError(`Street Level provider ${label} needs a credit`);
if (
typeof provider.colors?.coverage !== 'string' ||
!provider.colors.coverage
)
throw new TypeError(
`Street Level provider ${label} needs a coverage colour`,
);
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]);
}
/**
* The key id every provider shares, or null if any is keyless or they differ
* (then each chip reports its own need).
* @param {ReadonlyArray<StreetLevelProvider>} providers
* @returns {string|null}
*/
export function requiresKeyIdFor(providers) {
const keys = new Set(providers.map((p) => p.requiresKeyId || null));
if (keys.size !== 1) return null;
return [...keys][0];
}
+103
View File
@@ -0,0 +1,103 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { requiresKeyIdFor, validateProviders } from './registry.js';
const def = (overrides = {}) => ({
id: 'mapillary',
name: 'Mapillary',
label: 'MAPILLARY',
requiresKeyId: 'mapillary',
pickPrefix: 'mly:',
colors: { coverage: '#05cb63' },
credit: { html: 'Mapillary' },
create: () => ({}),
...overrides,
});
test('a valid list is frozen in registration order', () => {
const list = validateProviders([
def(),
def({ id: 'panoramax', pickPrefix: 'pnx:', requiresKeyId: null }),
]);
assert.ok(Object.isFrozen(list));
assert.deepEqual(
list.map((p) => p.id),
['mapillary', 'panoramax'],
);
});
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/);
});
test('every required field is checked by name', () => {
for (const key of [
'name',
'label',
'pickPrefix',
'colors',
'credit',
'create',
])
assert.throws(
() => validateProviders([def({ [key]: undefined })]),
new RegExp(`lacks ${key}`),
key,
);
assert.throws(() => validateProviders([def({ create: 1 })]), /create/);
assert.throws(() => validateProviders([def({ credit: {} })]), /credit/);
assert.throws(
() => validateProviders([def({ colors: { pano: '#f0f' } })]),
/coverage colour/,
);
assert.throws(
() => validateProviders([def({ pickPrefix: '' })]),
/pick prefix/,
);
});
test('overlapping pick prefixes are rejected either way round', () => {
assert.throws(
() => validateProviders([def(), def({ id: 'other', pickPrefix: 'mly:x' })]),
/overlaps/,
);
assert.throws(
() =>
validateProviders([def({ pickPrefix: 'mly:x' }), def({ id: 'other' })]),
/overlaps/,
);
assert.doesNotThrow(() =>
validateProviders([def(), def({ id: 'other', pickPrefix: 'ml:' })]),
);
});
test('requiresKeyIdFor is the shared key, or null once providers differ', () => {
assert.equal(requiresKeyIdFor([def()]), 'mapillary');
assert.equal(
requiresKeyIdFor([def(), def({ id: 'b', pickPrefix: 'b:' })]),
'mapillary',
);
assert.equal(
requiresKeyIdFor([
def(),
def({ id: 'b', pickPrefix: 'b:', requiresKeyId: null }),
]),
null,
);
assert.equal(
requiresKeyIdFor([
def(),
def({ id: 'b', pickPrefix: 'b:', requiresKeyId: 'google-maps' }),
]),
null,
);
assert.equal(requiresKeyIdFor([def({ requiresKeyId: null })]), null);
});
+11 -26
View File
@@ -1,37 +1,22 @@
import * as Cesium from 'cesium';
import {
PICK_PREFIX,
POSITION_PICK_ID,
STREET_LEVEL_LAYER_ID,
} from './policy.js';
import { sequenceIdFromPick } from './coverage.js';
import { STREET_LEVEL_LAYER_ID } from './policy.js';
/** One click handler for coverage lines, image cones and the marker. */
/** One click handler for every provider's lines and cones, and the marker. */
export function createSelection({ state, parts }) {
const { picking, input } = state.services;
/** Lines and cones (`mly:`) and the position marker are this layer's. */
function ownsPick(id) {
return (
typeof id === 'string' &&
(id === POSITION_PICK_ID || id.startsWith(PICK_PREFIX.root))
);
}
/** A line selects its sequence; a cone opens its image. */
/** The id carries a provider's prefix: that provider handles the pick. */
function onClick(click) {
const viewer = state.viewer;
if (!viewer || !state.enabled || !state.providerOn) return;
if (!viewer || !state.enabled) return;
if (input?.isPointerFree && !input.isPointerFree()) return;
const picked = viewer.scene.pick(click.position);
const id = picking?.resolvePickId
? picking.resolvePickId(picked)
: picked?.id;
if (typeof id !== 'string') return;
const sequenceId = sequenceIdFromPick(id);
if (sequenceId) parts.sequences.select(sequenceId);
else if (id.startsWith(PICK_PREFIX.image))
parts.openImage(id.slice(PICK_PREFIX.image.length));
const hit = parts.router.resolve(id);
// The position marker is the core's own: nothing to select.
if (hit?.instance) hit.instance.handlePick(hit.id);
}
/**
@@ -49,9 +34,9 @@ export function createSelection({ state, parts }) {
)
)
return;
if (!state.sequence.selectedId) return;
if (!parts.hasSelectedSequence()) return;
event.preventDefault();
parts.sequences.clearSelection();
parts.clearSequences();
}
function install(viewer) {
@@ -64,7 +49,7 @@ export function createSelection({ state, parts }) {
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
document.addEventListener('keydown', onKeyDown);
picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, ownsPick);
picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, parts.router.ownsPick);
}
function uninstall() {
@@ -76,5 +61,5 @@ export function createSelection({ state, parts }) {
picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID);
}
return { install, uninstall, ownsPick };
return { install, uninstall, ownsPick: parts.router.ownsPick };
}
+27 -23
View File
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createSelection } from './selection.js';
import { createPickRouter } from './pickRouter.js';
/** The selection installed on a stand-in viewer, with what its clicks reach. */
function harness({ selected = false } = {}) {
@@ -38,28 +39,36 @@ function harness({ selected = false } = {}) {
const state = {
viewer,
enabled: true,
providerOn: true,
clickHandler: null,
services: { picking: null, input: null },
sequence: {
get selectedId() {
return sequences.selected ? 'seq-1' : null;
},
providers: new Map([
[
'mapillary',
{
def: { id: 'mapillary', pickPrefix: 'mly:' },
instance: {
handlePick(id) {
if (id.startsWith('mly:seq:'))
sequences.selects.push(id.slice(8));
else if (id.startsWith('mly:img:')) opened.push(id.slice(8));
return true;
},
},
},
],
]),
};
const parts = {
router: createPickRouter(() => state.providers.values(), {
positionId: 'sl:pos',
}),
hasSelectedSequence: () => sequences.selected,
clearSequences() {
sequences.cleared++;
sequences.selected = false;
},
};
const selection = createSelection({
state,
parts: {
sequences: {
select: (id) => sequences.selects.push(id),
clearSelection() {
sequences.cleared++;
sequences.selected = false;
},
},
openImage: (id) => opened.push(id),
},
});
const selection = createSelection({ state, parts });
selection.install(viewer);
const action = (type) =>
state.clickHandler.getInputAction(Cesium.ScreenSpaceEventType[type]);
@@ -103,11 +112,6 @@ test('a click on a line selects its sequence, on a cone opens its image, elsewhe
h.click();
}
assert.deepEqual([h.sequences.selects, h.opened], [['abc'], ['42']]);
// Mapillary switched off under a layer that is on: lines are not drawn.
h.state.providerOn = false;
h.scene.under = 'mly:seq:abc';
h.click();
assert.deepEqual(h.sequences.selects, ['abc']);
} finally {
h.restore();
}
+2 -2
View File
@@ -1,6 +1,6 @@
import * as Cesium from 'cesium';
import { imageConeGlyph } from './glyphs.js';
import { passesImageryFilter, resolveFilter } from './filter.js';
import { passesImageryFilter } from './filter.js';
import { createHorizonCull, metresBetween } from './view.js';
import {
COLORS,
@@ -89,7 +89,7 @@ export function createSequences({ state, source, parts }) {
pano: true,
});
// Resolved now, so a "since N days" window keeps up with the clock.
const filter = resolveFilter(state.filter);
const filter = state.getFilter();
for (const image of images) {
if (!passesImageryFilter(image, filter)) continue;
const billboard = collection.add({
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { resolveFilter } from './filter.js';
import { createSequences } from './sequences.js';
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
@@ -56,6 +57,7 @@ function setup() {
services: {},
viewer: {},
filter: { pano: 'all', sinceDays: 0 },
getFilter: () => resolveFilter(state.filter),
notify() {},
sequence: {
selectedId: null,
+9 -22
View File
@@ -7,6 +7,9 @@ import { FILTER_DEFAULT } from './policy.js';
export function freshStreet() {
return {
open: false,
providerId: null,
providerName: null,
providerLabel: null,
imageId: null,
position: null,
bearing: null,
@@ -21,7 +24,7 @@ export function freshStreet() {
};
}
/** Coverage bookkeeping, before the first refresh. */
/** A provider's coverage bookkeeping, before the first refresh. */
export function freshCoverage() {
return {
zoom: null,
@@ -34,7 +37,7 @@ export function freshCoverage() {
lastError: null,
/**
* Why tile requests are paused, or null: 'status' until the key status
* answers, 'no-key', 'rejected' (until the layer goes off) or
* answers, 'no-key', 'rejected' (until the provider goes off) or
* 'rate-limited' (until `blockTimer` fires).
*/
blocked: 'status',
@@ -46,7 +49,7 @@ export function freshCoverage() {
};
}
/** Mutable layer state, created once per layer instance. */
/** Mutable core state, created once per layer instance. */
export function createState({ services }) {
return {
services,
@@ -54,10 +57,10 @@ export function createState({ services }) {
enabled: false,
listeners: new Set(),
notify: null,
/** The share link's `mapillary` switch: off draws nothing. */
providerOn: true,
/** Imagery filter, in the stored (relative-days) form. */
/** Shared imagery filter, in the stored (relative-days) form. */
filter: { ...FILTER_DEFAULT },
/** @type {Map<string, {def: object, instance: object, on: boolean, status: object|null}>} */
providers: new Map(),
street: {
host: null,
@@ -66,23 +69,7 @@ export function createState({ services }) {
...freshStreet(),
},
coverage: freshCoverage(),
sequence: {
selectedId: null,
images: [],
collection: null,
loading: false,
/** Why the selected sequence's images could not load, until it goes. */
error: null,
abort: null,
},
marker: { collection: null, billboard: null },
clickHandler: null,
};
}
/** Coverage, cones and lookups run only while the layer and its switch are on. */
export function isActive(state) {
return state.enabled && state.providerOn;
}
+65
View File
@@ -0,0 +1,65 @@
/**
* @typedef {object} ProviderSnapshot
* @property {string} id
* @property {string} name
* @property {string} label
* @property {boolean} on
* @property {boolean|null} configured null until the status call answers
* @property {boolean} keyRequired no usable key: missing or rejected
* @property {boolean} [keyRejected] the provider refused the configured key
* @property {string|null} requiresKeyId
* @property {boolean} loading
* @property {number} count
* @property {string} hint
* @property {string|null} error
* @property {string} color The source's one colour.
*/
/**
* Totals over switched-on providers. Key-gated only when every one lacks its
* key; `keyRejected` when that is because a key was refused.
* @param {Array<ProviderSnapshot>} providers
*/
export function summarizeCoverage(providers) {
const active = providers.filter((p) => p.on);
return {
count: active.reduce((sum, p) => sum + (p.count || 0), 0),
loading: active.some((p) => p.loading),
hint:
providers.length === 0
? 'No street-level imagery providers configured'
: active.find((p) => p.hint)?.hint || '',
error: active.find((p) => p.error)?.error || null,
keyRequired:
active.length > 0 && active.every((p) => p.keyRequired === true),
keyRejected:
active.length > 0 &&
active.every((p) => p.keyRequired === true) &&
active.some((p) => p.keyRejected === true),
};
}
/**
* The panel snapshot from core state and provider snapshots.
* @param {{enabled: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
*/
export function composeUIState({
enabled,
filter,
providers,
street,
sequence,
}) {
const { keyRequired, keyRejected, ...coverage } =
summarizeCoverage(providers);
return {
enabled,
keyRequired,
keyRejected,
filter: { ...filter },
providers: providers.map((p) => ({ ...p })),
coverage,
sequence: { ...sequence },
street: { ...street },
};
}
+100
View File
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeUIState, summarizeCoverage } from './uiState.js';
import { providerSnapshot } from '../../testSupport/streetLevelFakes.mjs';
const provider = (overrides = {}) =>
providerSnapshot({ count: 10, ...overrides });
const base = {
enabled: true,
filter: { pano: 'all', sinceDays: 0 },
street: { open: false },
sequence: { selectedId: null, images: 0, loading: false },
};
test('counts add up across active providers only', () => {
const ui = composeUIState({
...base,
providers: [
provider(),
provider({ id: 'panoramax', name: 'Panoramax', count: 5, on: false }),
provider({ id: 'kartaview', name: 'KartaView', count: 7, loading: true }),
],
});
assert.equal(ui.coverage.count, 17);
assert.equal(ui.coverage.loading, true);
});
test('the layer is key-gated only when every switched-on provider lacks its key', () => {
const gated = composeUIState({
...base,
providers: [provider({ keyRequired: true })],
});
assert.equal(gated.keyRequired, true);
const mixed = composeUIState({
...base,
providers: [
provider({ keyRequired: true }),
provider({ id: 'panoramax', name: 'Panoramax', requiresKeyId: null }),
],
});
assert.equal(mixed.keyRequired, false, 'a keyless provider still draws');
const offOnly = composeUIState({
...base,
providers: [provider({ keyRequired: true, on: false })],
});
assert.equal(offOnly.keyRequired, false, 'nothing switched on to gate');
});
test('hint and error come from the first active provider that has one', () => {
const ui = composeUIState({
...base,
providers: [
provider({ hint: '', error: null }),
provider({ id: 'b', name: 'B', hint: 'Look down', error: 'boom' }),
],
});
assert.equal(ui.coverage.hint, 'Look down');
assert.equal(ui.coverage.error, 'boom');
assert.deepEqual(ui.filter, base.filter);
assert.notEqual(ui.filter, base.filter, 'snapshot copies the filter');
});
test('summarizeCoverage is what getStats reports, without building a snapshot', () => {
assert.deepEqual(
summarizeCoverage([
provider({ count: 4, hint: 'Look down' }),
provider({ id: 'b', name: 'B', count: 6, loading: true, error: 'boom' }),
provider({ id: 'c', name: 'C', count: 99, on: false, keyRequired: true }),
]),
{
count: 10,
loading: true,
hint: 'Look down',
error: 'boom',
keyRequired: false,
keyRejected: false,
},
);
assert.equal(summarizeCoverage([]).keyRequired, false);
});
test('a rejected key gates like a missing one and is named as rejected', () => {
const rejected = provider({
keyRequired: true,
keyRejected: true,
error: 'Mapillary rejected MAPILLARY_CLIENT_TOKEN',
});
const summary = summarizeCoverage([rejected]);
assert.equal(summary.keyRequired, true);
assert.equal(summary.keyRejected, true);
assert.equal(summary.error, 'Mapillary rejected MAPILLARY_CLIENT_TOKEN');
const state = composeUIState({ ...base, providers: [rejected] });
assert.equal(state.keyRequired, true);
assert.equal(state.keyRejected, true);
// A provider that is merely missing its key is not "rejected".
assert.equal(
summarizeCoverage([provider({ keyRequired: true })]).keyRejected,
false,
);
});
+66 -35
View File
@@ -1,10 +1,13 @@
import { freshStreet } from './state.js';
/** The panel's photo viewer: its poses update `state.street` and the marker. */
export function createViewerHost({ state, parts, adapter }) {
/** Set once mounted, until `unmount`. */
let unsubscribe = null;
/** The mount in flight, shared by concurrent opens. */
/**
* The panel's photo viewer element and the provider adapter mounted in it.
* Adapter poses update `state.street` and the marker.
*/
export function createViewerHost({ state, parts }) {
/** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */
let active = null;
/** The mount in flight, shared by concurrent opens. @type {{id: string, promise: Promise<object>}|null} */
let mounting = null;
let openSeq = 0;
@@ -13,7 +16,7 @@ export function createViewerHost({ state, parts, adapter }) {
}
function applyPose(pose) {
if (!unsubscribe || !state.street.open) return;
if (!active || !state.street.open) return;
const previousSequence = state.street.sequenceId;
Object.assign(state.street, {
imageId: pose.imageId,
@@ -35,43 +38,57 @@ export function createViewerHost({ state, parts, adapter }) {
}
function selectCurrentSequence() {
const { sequenceId } = state.street;
if (!sequenceId || !state.enabled) return;
if (state.sequence.selectedId !== sequenceId)
parts.sequences.select(sequenceId);
const { sequenceId, providerId } = state.street;
const entry = state.providers.get(providerId);
if (!sequenceId || !entry?.instance.selectSequence || !state.enabled)
return;
if (entry.instance.sequenceStats?.()?.selectedId !== sequenceId)
entry.instance.selectSequence(sequenceId);
}
/**
* Mount the viewer. It counts as mounted only on success, so a failed
* mount is retried on the next open; concurrent opens share one.
* Mount a provider's adapter, releasing another provider's first. It
* counts as mounted only on success, so a failed mount is retried on the
* next open; concurrent opens share one.
*/
function mount() {
if (unsubscribe) return Promise.resolve();
if (mounting) return mounting;
function mount(entry) {
const id = entry.def.id;
if (active?.id === id) return Promise.resolve(active.adapter);
if (mounting?.id === id) return mounting.promise;
if (active) {
active.adapter.close();
active.unsubscribe();
active.adapter.unmount();
active = null;
}
const adapter = entry.instance.viewer;
const promise = (async () => {
await adapter.mount(state.street.host);
if (mounting !== promise) {
// Unmounted (layer off) while loading. A newer mount shares the
// viewer: tearing it down here would leave that mount without one.
if (!mounting && !unsubscribe) adapter.unmount();
if (mounting?.promise !== promise) {
// Unmounted (layer off, provider switched) while loading. A newer
// mount of this provider shares the adapter: tearing it down here
// would leave that mount without a viewer.
if (active?.id !== id && mounting?.id !== id) adapter.unmount();
throw new Error('Street-level viewer was closed');
}
// Listen only once current: an outdated mount releasing the same
// `applyPose` would otherwise unhook the mount that replaced it.
unsubscribe = adapter.onPose(applyPose);
active = { id, adapter, unsubscribe: adapter.onPose(applyPose) };
adapter.setRenderMode?.(state.street.renderMode);
return adapter;
})();
mounting = promise;
mounting = { id, adapter, promise };
const settle = () => {
if (mounting === promise) mounting = null;
if (mounting?.promise === promise) mounting = null;
};
promise.then(settle, settle);
return promise;
}
/** Open an image: true once its first pose is in, false if it failed or was overtaken. */
async function open(imageId) {
if (!imageId) return false;
async function open(providerId, imageId) {
const entry = state.providers.get(providerId);
if (!entry || !imageId) return false;
if (!state.street.host) {
state.street.error = 'Open the Street Level panel to view imagery';
notify();
@@ -81,10 +98,17 @@ export function createViewerHost({ state, parts, adapter }) {
const current = () => seq === openSeq;
// Claimed now, honoured after loading only if nothing newer took the camera.
const ticket = parts.framing.begin();
Object.assign(state.street, { loading: true, error: null, open: true });
Object.assign(state.street, {
loading: true,
error: null,
open: true,
providerId,
providerName: entry.def.name,
providerLabel: entry.def.label,
});
notify();
try {
await mount();
const adapter = await mount(entry);
if (!current()) return false;
await adapter.open(String(imageId));
if (!current()) return false;
@@ -101,11 +125,11 @@ export function createViewerHost({ state, parts, adapter }) {
return true;
}
/** Close the image and stop any framing flight; the viewer stays mounted. */
/** Close the image and stop any framing flight; the adapter stays mounted. */
function close() {
openSeq++;
parts.framing.cancel();
if (unsubscribe) adapter.close();
active?.adapter.close();
Object.assign(state.street, freshStreet());
parts.marker.clear();
notify();
@@ -117,22 +141,29 @@ export function createViewerHost({ state, parts, adapter }) {
}
function resize() {
if (unsubscribe) adapter.resize();
active?.adapter.resize();
}
function setRenderMode(mode) {
state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox';
if (unsubscribe) adapter.setRenderMode?.(state.street.renderMode);
active?.adapter.setRenderMode?.(state.street.renderMode);
notify();
}
/** Close the image and destroy the viewer (and its WebGL context). */
function unmount() {
/**
* Close the image and destroy the viewer (and its WebGL context): every
* provider's, or only `providerId`'s. A mount still in flight is told too,
* so it builds nothing when its library lands.
*/
function unmount(providerId) {
if (providerId && active?.id !== providerId && mounting?.id !== providerId)
return;
close();
const pending = mounting;
mounting = null;
unsubscribe?.();
unsubscribe = null;
adapter.unmount();
active?.unsubscribe();
(active?.adapter ?? pending?.adapter)?.unmount();
active = null;
}
return { attach, open, close, resize, setRenderMode, unmount };
+35 -25
View File
@@ -35,13 +35,29 @@ const { createMapillaryViewer } = await import('./viewer.js');
* `selected` every sequence the layer was asked to select.
*/
function harness(adapter) {
const selected = [];
const state = {
enabled: true,
notify() {},
street: { host: {}, renderMode: 'letterbox' },
sequence: { selectedId: null },
providers: new Map([
[
'mapillary',
{
def: { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' },
instance: {
viewer: adapter,
selectSequence(id) {
selected.push(id);
state.sequence.selectedId = id;
},
sequenceStats: () => ({ selectedId: state.sequence.selectedId }),
},
},
],
]),
};
const selected = [];
const framing = { begun: 0, started: 0, cancelled: 0 };
const marker = [];
const parts = {
@@ -55,12 +71,6 @@ function harness(adapter) {
frame: (ticket) => ticket && framing.started++,
cancel: () => framing.cancelled++,
},
sequences: {
select(id) {
selected.push(id);
state.sequence.selectedId = id;
},
},
};
return {
state,
@@ -71,7 +81,7 @@ function harness(adapter) {
clearSequence() {
state.sequence.selectedId = null;
},
host: createViewerHost({ state, parts, adapter }),
host: createViewerHost({ state, parts }),
};
}
@@ -132,10 +142,10 @@ function fakeAdapter({ failMounts = 0, gate = null, openGate = null } = {}) {
test('a failed mount is retried on the next open instead of being cached', async () => {
const adapter = fakeAdapter({ failMounts: 1 });
const { state, host } = harness(adapter);
await host.open('a');
await host.open('mapillary', 'a');
assert.equal(state.street.error, 'library failed to load');
assert.equal(adapter.calls.listeners, 0, 'the pose listener was released');
await host.open('b');
await host.open('mapillary', 'b');
assert.equal(state.street.error, null);
assert.equal(adapter.calls.mount, 2);
assert.deepEqual(adapter.calls.open, ['b']);
@@ -146,7 +156,7 @@ test('a failed mount is retried on the next open instead of being cached', async
test('concurrent opens share one mount and one pose listener', async () => {
const adapter = fakeAdapter();
const { host } = harness(adapter);
await Promise.all([host.open('a'), host.open('b')]);
await Promise.all([host.open('mapillary', 'a'), host.open('mapillary', 'b')]);
assert.equal(adapter.calls.mount, 1);
assert.equal(adapter.calls.listeners, 1);
});
@@ -154,14 +164,14 @@ test('concurrent opens share one mount and one pose listener', async () => {
test('unmounting while a mount is in flight does not leave it active', async () => {
const adapter = fakeAdapter();
const { state, host } = harness(adapter);
const opening = host.open('a');
const opening = host.open('mapillary', 'a');
host.unmount();
await opening;
assert.equal(adapter.calls.listeners, 0);
assert.equal(adapter.calls.unmount, 2, 'layer off, then the late mount');
assert.equal(state.street.open, false);
// Not left active: the next open mounts again.
await host.open('b');
await host.open('mapillary', 'b');
assert.equal(adapter.calls.mount, 2);
assert.equal(state.street.imageId, 'b');
});
@@ -169,7 +179,7 @@ test('unmounting while a mount is in flight does not leave it active', async ()
test('a pose that arrives after the photo was closed does not bring it back', async () => {
const adapter = fakeAdapter();
const { state, host } = harness(adapter);
await host.open('a');
await host.open('mapillary', 'a');
host.close();
const before = { ...state.street };
// The library's `image` event for a photo that was still loading.
@@ -193,7 +203,7 @@ const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
test('every pose the viewer reports moves the marker; only the open frames the camera', async () => {
const adapter = fakeAdapter();
const { host, marker, framing } = harness(adapter);
await host.open('a');
await host.open('mapillary', 'a');
assert.deepEqual(marker, [{ position: { lon: 1, lat: 2 }, bearing: 90 }]);
// The user walks on: the marker goes along, the globe camera stays.
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 180 });
@@ -211,7 +221,7 @@ test('a pose on another sequence selects it, but not while the image still loads
const openGate = deferred();
const adapter = fakeAdapter({ openGate });
const { state, host, selected, clearSequence } = harness(adapter);
const opening = host.open('a');
const opening = host.open('mapillary', 'a');
await settle();
assert.equal(state.street.loading, true, 'the image is downloading');
assert.equal(state.street.sequenceId, 'seq-a', 'its pose is in');
@@ -234,7 +244,7 @@ test('a pose on another sequence selects it, but not while the image still loads
test('closing the photo clears the marker', async () => {
const adapter = fakeAdapter();
const { host, marker } = harness(adapter);
await host.open('a');
await host.open('mapillary', 'a');
assert.notEqual(marker.at(-1), null);
host.close();
assert.equal(marker.at(-1), null);
@@ -244,7 +254,7 @@ test('an open still mounting when the photo is closed does nothing when the moun
const gate = deferred();
const adapter = fakeAdapter({ gate });
const { state, host, framing, selected } = harness(adapter);
const opening = host.open('a'); // MapillaryJS still loading
const opening = host.open('mapillary', 'a'); // MapillaryJS still loading
await settle();
host.close();
gate.resolve();
@@ -257,7 +267,7 @@ test('an open still mounting when the photo is closed does nothing when the moun
[false, false, null],
);
// The mounted viewer serves the next open.
assert.equal(await host.open('b'), true);
assert.equal(await host.open('mapillary', 'b'), true);
assert.equal(state.street.imageId, 'b');
});
@@ -265,7 +275,7 @@ test('an image still opening when the photo is closed is neither framed nor sele
const openGate = deferred();
const adapter = fakeAdapter({ openGate });
const { state, host, framing, selected } = harness(adapter);
const opening = host.open('a');
const opening = host.open('mapillary', 'a');
await settle();
assert.deepEqual(adapter.calls.open, ['a'], 'the image is downloading');
host.close();
@@ -343,16 +353,16 @@ test('the Mapillary viewer survives the layer going off and on during its first
const { gate, viewers } = fakeLibrary();
const adapter = createMapillaryViewer({ source: { token: 't' } });
const { state, host } = harness(adapter);
const first = host.open('img1');
const first = host.open('mapillary', 'img1');
await Promise.resolve();
host.unmount();
const second = host.open('img2');
const second = host.open('mapillary', 'img2');
await Promise.resolve();
gate.resolve();
await Promise.all([first, second]);
assert.equal(state.street.error, null);
assert.equal(state.street.imageId, 'img2');
await host.open('img3');
await host.open('mapillary', 'img3');
assert.equal(state.street.error, null, 'later opens are not stuck');
assert.equal(state.street.imageId, 'img3');
assert.equal(viewers.live, 1);
@@ -379,7 +389,7 @@ test('closing the photo stops sequence playback in the hidden viewer', async ()
gate.resolve();
const adapter = createMapillaryViewer({ source: { token: 't' } });
const { host } = harness(adapter);
await host.open('img1');
await host.open('mapillary', 'img1');
const [viewer] = viewers.instances;
assert.equal(viewer.playback.stops, 0);
host.close();
@@ -455,7 +465,7 @@ test('a photo asks for the camera when it starts opening and frames with that ti
const openGate = deferred();
const adapter = fakeAdapter({ openGate });
const { host, framing } = harness(adapter);
const opening = host.open('a');
const opening = host.open('mapillary', 'a');
assert.equal(framing.begun, 1, 'claimed before the download');
await settle();
assert.equal(framing.started, 0);
+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),
};
}