Files
gods-eye-view/src/layers/streetLevel/providers/mapillary/source.js
T
German KhokhlovandClaude Opus 5.5 6390a3346f Merge origin/main: Google Street View, KartaView and Panoramax on the slim Street Level
Main gained three imagery providers (Google Street View, KartaView,
Panoramax), shared line-coverage, cone and photo-viewer modules, one source
lit at a time and the translated interface. Their shared modules still
carried what this branch removes, so the merge ports them onto it:

- lineCoverage.js is this branch's coverage.js made provider-neutral
  (draped lines near the ground only, the selection drawn over them, key
  and rate-limit blocking); sequenceCones.js is sequences.js the same way.
  Mapillary, KartaView and Panoramax all build on them; Mapillary moves
  back to providers/mapillary/ beside the others.
- No ground casting, mesh sampling, surface modes, FOLLOW, legend, prewarm,
  orbit overview points (Panoramax's density dots included), stale-tile
  swaps or water-filled point budget come back.
- Kept from main because the new providers need them: requiresMapStack and
  attachMapStackController (Street View on Google 3D only), ground clicks,
  openNearest with an optional provider nearestImage, openImage {frame},
  reportError, the view-cone marker (now an SVG glyph), filters /
  coverageLabel / sinceBasis, OPEN STREET VIEW, keyAlternatives and i18n.
- photoViewer.js treats the viewer as expanded when its wrap is full screen
  or the iPhone popover, as EXPAND now is.
- The fixture browser gate keeps its 8 steps and adds one each for
  KartaView and Panoramax; catalogs, docs, boundaries and format scope
  follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-19 13:51:45 +04:00

128 lines
3.6 KiB
JavaScript

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 : [];
},
};
}