refactor(street-level): drop hover, legend and byte-budget cache; flatten the provider directory

- No pointer cursor over coverage lines (no other layer has one); a click
  test replaces the hover tests.
- No legend: one source, one colour.
- The on-globe credit lives in index.js; credits.js is gone.
- Billboard glyphs are SVG data URLs with the same geometry, so tests need
  no canvas stub.
- The tile cache keeps the 120 most recently used tiles, same 24 h TTL,
  instead of a byte budget; listTileLayers was test-only.
- providers/mapillary/ is flattened into the layer and its policy merged
  into one policy.js; the source's method list is a literal in the catalog.
- One fakeCesiumViewer for the layer tests.
- The fixture gate keeps the steps the unit tests do not cover: coverage and
  credit, a rejected key, line -> cone -> photo, close, a keyless page.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Fable 5.1 committed 2026-10-08 19:17:45 +04:00
1 parent 34d43c7e3f
commit e214129e45
41 files changed
+335 -909

No files matched your search

@@ -1,127 +0,0 @@
import {
MAPILLARY_GRAPH_HOST,
SEQUENCE_IMAGE_FIELDS,
SEQUENCE_IMAGES_LIMIT,
} from './policy.js';
/** HTTP error flagged `keyRequired`, `keyRejected` or `retryAfterSec` (429). */
export class MapillarySourceError extends Error {
constructor(message, { status = 0, payload = null, retryAfterSec } = {}) {
super(message);
this.name = 'MapillarySourceError';
this.status = status;
this.payload = payload;
this.keyRequired = payload?.keyRequired ?? payload?.error === 'no_key';
this.keyRejected = payload?.keyRejected === true;
const wait = Number(retryAfterSec ?? payload?.retryAfter);
this.retryAfterSec =
status === 429 ? (Number.isFinite(wait) && wait > 0 ? wait : 60) : null;
}
}
async function readJsonOrThrow(response, label) {
let payload = null;
try {
payload = await response.json();
} catch {
/* status is authoritative */
}
if (!response.ok)
throw new MapillarySourceError(
payload?.error?.message ||
payload?.error ||
`${label} HTTP ${response.status}`,
{ status: response.status, payload },
);
return payload;
}
/**
* Mapillary network access: tiles via the local proxy, which adds the token
* and caches; Graph API lookups direct with the client token, as MapillaryJS.
* @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options
*/
export function createMapillarySource({
token = '',
fetchImpl = (...args) => globalThis.fetch(...args),
endpoints = {},
} = {}) {
const urls = {
status: '/api/mapillary/status',
tiles: '/api/mapillary/tiles',
graph: MAPILLARY_GRAPH_HOST,
...endpoints,
};
async function graph(path, params = {}, { signal } = {}) {
if (!token)
throw new MapillarySourceError('Mapillary token not configured', {
status: 503,
payload: { keyRequired: true },
});
const search = new URLSearchParams({ access_token: token, ...params });
const response = await fetchImpl(`${urls.graph}/${path}?${search}`, {
signal,
});
return readJsonOrThrow(response, 'Mapillary graph');
}
return {
get token() {
return token;
},
hasToken: () => Boolean(token),
async getStatus({ signal } = {}) {
const response = await fetchImpl(urls.status, {
signal,
cache: 'no-store',
});
return readJsonOrThrow(response, 'Mapillary status');
},
/** Raw protobuf bytes for one tile; an empty array means no data. */
async getTile(layer, z, x, y, { signal } = {}) {
const response = await fetchImpl(
`${urls.tiles}/${layer}/${z}/${x}/${y}`,
{
signal,
},
);
if (response.status === 204) return new Uint8Array(0);
if (!response.ok) {
let payload = null;
try {
payload = await response.json();
} catch {
/* ignore */
}
throw new MapillarySourceError(
payload?.error || `Tile HTTP ${response.status}`,
{
status: response.status,
payload,
retryAfterSec: response.headers?.get?.('retry-after') ?? undefined,
},
);
}
return new Uint8Array(await response.arrayBuffer());
},
async getSequenceImages(
sequenceId,
{ signal, limit = SEQUENCE_IMAGES_LIMIT } = {},
) {
const payload = await graph(
'images',
{
sequence_ids: String(sequenceId),
fields: SEQUENCE_IMAGE_FIELDS,
limit: String(limit),
},
{ signal },
);
return Array.isArray(payload?.data) ? payload.data : [];
},
};
}