feat(street-level): KartaView as a third, keyless imagery provider

Coverage lines from KartaView's z13-14 JSON tiles (OSM ways with photos,
chained end to start), the photo nearest a clicked line, cones along its
sequence, and a viewer for flat photos and 360° panoramas (a small WebGL
equirectangular renderer, no new dependency) with ‹ › along the sequence.

Mapillary's camera-driven line coverage and sequence cones move to the
provider-neutral core (lineCoverage.js, sequenceCones.js) with the tile
fetch, zoom ladder, colours and pick prefixes injected; Mapillary keeps
thin wrappers and its tests pass unchanged.

KartaView quirks handled: api.kartaview.org (the documented
api.openstreetcam.org serves empty tiles), photo URLs that name storage1
for photos stored elsewhere, and a photo API that answers 429 to more than
about three requests at once (two in flight, retried with backoff).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-13 18:33:14 +01:00
1 parent 9beadbda63
commit cb01014e36
33 files changed
+4046 -1196

No files matched your search

@@ -0,0 +1,55 @@
/**
* Decode a KartaView coverage tile (`{lines: [{track: [{lat, lng}]}]}`) into
* line records for the shared coverage. The tile's lines are OSM way
* segments with photos; segments that meet end to start are chained, which
* cuts the line count about fourfold. Lines carry no sequence, date or
* projection, so nothing can filter them.
* @param {object} payload
* @param {{x: number, y: number, z: number}} address
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<never>}}
*/
export function decodeCoverageTile(payload, { x, y, z }) {
const segments = [];
for (const line of Array.isArray(payload?.lines) ? payload.lines : []) {
const points = [];
for (const point of Array.isArray(line?.track) ? line.track : []) {
const lon = Number(point?.lng);
const lat = Number(point?.lat);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
const last = points[points.length - 1];
if (last && last[0] === lon && last[1] === lat) continue;
points.push([lon, lat]);
}
if (points.length >= 2) segments.push(points);
}
const key = ([lon, lat]) => `${lon},${lat}`;
const startingAt = new Map();
segments.forEach((segment, index) => {
const at = key(segment[0]);
const list = startingAt.get(at);
if (list) list.push(index);
else startingAt.set(at, [index]);
});
const used = new Uint8Array(segments.length);
const sequences = [];
segments.forEach((segment, index) => {
if (used[index]) return;
used[index] = 1;
const chain = [...segment];
for (;;) {
const next = startingAt
.get(key(chain[chain.length - 1]))
?.find((candidate) => !used[candidate]);
if (next === undefined) break;
used[next] = 1;
chain.push(...segments[next].slice(1));
}
sequences.push({
id: `${z}/${x}/${y}/${sequences.length}`,
parts: [chain],
capturedAt: 0,
isPano: false,
});
});
return { sequences, overview: [] };
}