fix(street-level): KartaView marker and camera direction; remaining review P4s

Marker and direction (reported on the PR):
- KartaView has its own marker, a purple view cone (Street View's is
  blue), not GEV's default pin that Mapillary uses.
- The camera direction (marker, cones, FOLLOW, a panorama's centre) is
  the direction of travel to the next photo a few metres on. KartaView's
  heading is the device compass: whole drives read 0 (no compass) and
  others sit a steady ~110° off. Until the drive loads the compass stands
  in (0 = unknown), and the pose is sent again once the course is known.

Remaining P4s from the review of #1121:
- Segment chaining is linear (a per-point cursor) and capped at 20,000
  segments a tile and 5,000 points a line: a hostile tile of 60k segments
  sharing one point took 1.5 s, now tens of ms.
- Decoded tiles are dropped when KartaView is switched off.
- No network / a refused request says so and is not retried as a timeout;
  an unreadable answer says so.
- A docked flat photo skips the 2,560 px download until the viewer grows.
- The panel counts the drive's photos (countActivePhotos), not the thinned
  page of cones (2,250, not 217).
- After a GPU reset the viewer redraws the photo itself, so a lookup the
  user started is not cancelled; a failure is still reported.
- The gate's keyless page lights KartaView and checks it draws with no key;
  the fixture answers the sequence count; the line-click step checks the
  camera looks along the drive when the compass says 200°.
- README/CHANGELOG show the share-link form the app writes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-14 12:17:33 +01:00
1 parent c99eb93174
commit e9a4dbb062
15 files changed
+588 -63

No files matched your search

@@ -16,17 +16,27 @@ import {
TILE_TIMEOUT_MS,
} from './policy.js';
/** An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits. */
/**
* An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits,
* `timedOut` when it did not answer in time (the one failure worth asking
* again at once).
*/
export class KartaViewSourceError extends Error {
constructor(message, { status = 0, retryAfterSec = null } = {}) {
constructor(
message,
{ status = 0, retryAfterSec = null, timedOut = false } = {},
) {
super(message);
this.name = 'KartaViewSourceError';
this.status = status;
this.retryAfterSec = retryAfterSec;
this.timedOut = timedOut;
}
}
const NO_ANSWER = 'KartaView did not answer in time — try again';
const UNREACHABLE = 'KartaView could not be reached — check the network';
const UNREADABLE = 'KartaView sent an answer that could not be read';
/** Statuses that mean a photo file is gone, not that storage hiccupped. */
const GONE_STATUSES = Object.freeze([403, 404, 409, 410]);
@@ -129,7 +139,10 @@ export function createKartaViewSource({
if (signal?.aborted) throw signal.reason;
signal?.addEventListener('abort', onAbort, { once: true });
const timer = setTimeout(
() => controller.abort(new KartaViewSourceError(NO_ANSWER)),
() =>
controller.abort(
new KartaViewSourceError(NO_ANSWER, { timedOut: true }),
),
timeoutMs,
);
try {
@@ -152,7 +165,16 @@ export function createKartaViewSource({
if (length > MAX_RESPONSE_BYTES) throw tooMuch();
// KartaView answers chunked, without a length: count while reading.
const reader = response.body?.getReader?.();
if (!reader) return await response.json();
if (!reader) {
try {
return await response.json();
} catch (error) {
if (controller.signal.aborted) throw error;
throw new KartaViewSourceError(UNREADABLE, {
status: response.status,
});
}
}
const chunks = [];
let bytes = 0;
for (;;) {
@@ -171,12 +193,19 @@ export function createKartaViewSource({
body.set(chunk, offset);
offset += chunk.byteLength;
}
return JSON.parse(new TextDecoder().decode(body));
try {
return JSON.parse(new TextDecoder().decode(body));
} catch {
throw new KartaViewSourceError(UNREADABLE, {
status: response.status,
});
}
} catch (error) {
if (signal?.aborted) throw signal.reason;
if (controller.signal.aborted) throw controller.signal.reason;
if (error instanceof KartaViewSourceError) throw error;
throw new KartaViewSourceError(NO_ANSWER);
// fetch rejects on no network, a blocked request or a refused CORS check.
throw new KartaViewSourceError(UNREACHABLE);
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
@@ -221,7 +250,7 @@ export function createKartaViewSource({
return payload?.result ?? {};
} catch (error) {
if (signal?.aborted) throw error;
const timedOut = error?.message === NO_ANSWER && error.status === 0;
const timedOut = error?.timedOut === true;
if (timedOut && timeouts++ < 1) continue;
if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) {
if (error?.status === 429)
@@ -259,7 +288,7 @@ export function createKartaViewSource({
} catch (error) {
// A cold dense tile can outlast the timeout; KartaView has it ready
// by the second ask.
if (signal?.aborted || error?.message !== NO_ANSWER) throw error;
if (signal?.aborted || error?.timedOut !== true) throw error;
return once();
}
},
@@ -322,6 +351,17 @@ export function createKartaViewSource({
return result.data;
},
/** How many photos a sequence has, or null if KartaView does not say. */
async getSequenceCount(sequenceId, { signal } = {}) {
const result = await photoApi(
`/2.0/sequence/${encodeURIComponent(sequenceId)}`,
null,
{ signal },
);
const count = Number(result.data?.countActivePhotos);
return Number.isFinite(count) && count > 0 ? count : null;
},
/** A page of a sequence's photos in capture order (1-based `page`). */
async getSequencePhotos(
sequenceId,