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

@@ -1,3 +1,8 @@
/** More than any real tile holds (a dense z13 tile has ~4,700 segments). */
export const MAX_TILE_SEGMENTS = 20_000;
/** A chained line stops growing here and the next one starts. */
export const MAX_LINE_POINTS = 5_000;
/**
* Decode a KartaView coverage tile (`{lines: [{track: [{lat, lng}]}]}`) into
* line records for the shared coverage. The tile's lines are OSM way
@@ -14,6 +19,7 @@ export function decodeCoverageTile(payload, { x, y, z }) {
throw new Error('KartaView sent an unreadable coverage tile');
const segments = [];
for (const line of payload.lines) {
if (segments.length >= MAX_TILE_SEGMENTS) break;
const points = [];
for (const point of Array.isArray(line?.track) ? line.track : []) {
const lon = Number(point?.lng);
@@ -34,16 +40,28 @@ export function decodeCoverageTile(payload, { x, y, z }) {
else startingAt.set(at, [index]);
});
const used = new Uint8Array(segments.length);
// Per start point, how far its list has been used: each segment is looked
// at once, so a tile of segments sharing one point stays linear.
const cursor = new Map();
const nextFrom = (point) => {
const at = key(point);
const list = startingAt.get(at);
if (!list) return undefined;
let i = cursor.get(at) ?? 0;
while (i < list.length && used[list[i]]) i++;
cursor.set(at, i);
return list[i];
};
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]);
const next = nextFrom(chain[chain.length - 1]);
if (next === undefined) break;
// Full: the segment starts the next line instead.
if (chain.length + segments[next].length - 1 > MAX_LINE_POINTS) break;
used[next] = 1;
chain.push(...segments[next].slice(1));
}
@@ -1,6 +1,10 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { decodeCoverageTile } from './decode.js';
import {
MAX_LINE_POINTS,
MAX_TILE_SEGMENTS,
decodeCoverageTile,
} from './decode.js';
const at = (lng, lat) => ({ lat: String(lat), lng: String(lng) });
const TILE = { x: 4412, y: 6061, z: 14 };
@@ -85,3 +89,40 @@ test('a tile that is not a line list fails, so it is asked for again rather than
/unreadable coverage tile/,
);
});
test('a tile whose segments all share one point decodes in linear time', () => {
// [P, Q_i, P]: every chain step looks at the same start point's list.
// 60k: the old quadratic chaining took over a second; capped and linear
// decoding takes tens of milliseconds.
const lines = Array.from({ length: 60_000 }, (_, i) => ({
track: [at(0, 0), at(1 + i * 1e-6, 1), at(0, 0)],
}));
const started = performance.now();
const { sequences } = decodeCoverageTile({ lines }, TILE);
const ms = performance.now() - started;
assert.ok(
ms < 1_000,
`${Math.round(ms)} ms (quadratic chaining took ~200 ms per 20k and grows fourfold per doubling)`,
);
const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0);
assert.ok(sequences.every((s) => s.parts[0].length <= MAX_LINE_POINTS));
assert.equal(
points,
MAX_TILE_SEGMENTS * 2 + sequences.length,
'every segment drawn once',
);
});
test('a tile is capped in segments and in points per line', () => {
const lines = Array.from({ length: MAX_TILE_SEGMENTS + 500 }, (_, i) => ({
track: [at(i, 0), at(i + 1, 0)],
}));
const { sequences } = decodeCoverageTile({ lines }, TILE);
const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0);
// One long chain, cut into lines of at most MAX_LINE_POINTS.
assert.ok(sequences.every((s) => s.parts[0].length <= MAX_LINE_POINTS));
assert.ok(points <= MAX_TILE_SEGMENTS + sequences.length);
assert.ok(
sequences.length >= Math.floor(MAX_TILE_SEGMENTS / MAX_LINE_POINTS),
);
});
@@ -24,6 +24,8 @@ import {
SEQUENCE_PAGE_EDGE,
SEQUENCE_PAGE_SIZE,
TOO_HIGH_HINT,
assignCourses,
cameraBearing,
normalizePhoto,
} from './policy.js';
@@ -86,6 +88,8 @@ export function createTileCache({
kept.set(key, { at: now(), tile: loaded });
return loaded;
},
/** Drop every tile (the provider was switched off). */
clear: () => kept.clear(),
};
}
@@ -112,6 +116,8 @@ export function createKartaViewProvider({
requiresKeyId: null,
pickPrefix: PICK_PREFIX.root,
colors: COLORS,
// Its own marker, as Street View has: a purple cone that turns with the view.
marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }),
credit: Object.freeze({ html: KARTAVIEW_CREDIT_HTML }),
defaultOn: false,
// Coverage lines carry no date or projection: no filter could thin them.
@@ -162,9 +168,13 @@ export function createKartaViewProvider({
/** The sequence page the cones were drawn from. */
let drawnPage = null;
/** A record as a photo; the one already known keeps its identity (and course). */
function remember(record) {
const photo = normalizePhoto(record);
if (photo) photos.set(photo.id, photo);
const fresh = normalizePhoto(record);
if (!fresh) return null;
const known = photos.get(fresh.id);
const photo = known ? Object.assign(known, fresh) : fresh;
photos.set(photo.id, photo);
return photo;
}
@@ -186,16 +196,20 @@ export function createKartaViewProvider({
page,
itemsPerPage: SEQUENCE_PAGE_SIZE,
})
.then(({ photos: records, hasMore }) => ({
page,
hasMore,
photos: records
.map(remember)
.filter(Boolean)
.sort(
(a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0),
),
}));
.then(({ photos: records, hasMore }) => {
const list = assignCourses(
records
.map(remember)
.filter(Boolean)
.sort(
(a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0),
),
metresBetween,
);
// The open photo may have just learnt which way it looked.
viewer.refreshPose?.();
return { page, hasMore, photos: list };
});
pages.set(key, entry);
entry.catch(() => pages.delete(key));
}
@@ -353,12 +367,13 @@ export function createKartaViewProvider({
const window = await windowOf(anchor);
signal?.throwIfAborted();
drawnPage = { sequenceId, page: window.home };
countPhotos(sequenceId);
return thinImages(
window.photos.map((photo) => ({
id: photo.id,
lon: photo.lon,
lat: photo.lat,
compassAngle: photo.heading ?? 0,
compassAngle: cameraBearing(photo) ?? 0,
capturedAt: photo.capturedAt ?? 0,
isPano: photo.isPano,
})),
@@ -372,6 +387,23 @@ export function createKartaViewProvider({
passes: () => true,
});
/** Sequence id → its photo count from KartaView (asked once). */
const counts = new Map();
function countPhotos(sequenceId) {
if (counts.has(sequenceId) || !source.getSequenceCount) return;
counts.set(sequenceId, undefined);
source.getSequenceCount(sequenceId).then(
(count) => {
if (count === null) counts.delete(sequenceId);
else {
counts.set(sequenceId, count);
context.notify();
}
},
() => counts.delete(sequenceId),
);
}
/** ‹ › open through the core (loading, errors, cones), camera still. */
function openStepped(photoId) {
context.actions.openImage(photoId, { frame: false });
@@ -406,6 +438,17 @@ export function createKartaViewProvider({
step,
onStep: openStepped,
onShown: recentreCones,
// After a GPU reset the same photo is drawn again by the viewer
// itself: going through the core would cancel a lookup the user
// started. A failure still shows.
onReopen: (photoId) =>
viewer
.open(photoId)
.catch((error) =>
context.actions.reportError(
error?.message || 'The KartaView photo could not be loaded',
),
),
render: context.services?.render,
...viewerOptions,
});
@@ -434,6 +477,8 @@ export function createKartaViewProvider({
cones.clearSelection();
cones.setVisible(false);
drawnPage = null;
// Tens of MB of decoded lines are not kept for a source left off.
tiles.clear();
},
destroy(cesiumViewer) {
@@ -441,6 +486,7 @@ export function createKartaViewProvider({
coverage.clear();
coverage.resetErrors();
cones.destroy(cesiumViewer);
tiles.clear();
},
refreshCoverage: () => coverage.refresh(),
@@ -495,9 +541,12 @@ export function createKartaViewProvider({
cones.clearSelection();
drawnPage = null;
},
// The drive's own count, not the thinned page of it the cones show.
sequenceStats: () => ({
selectedId: state.sequence.selectedId,
images: state.sequence.images.length,
images:
counts.get(state.sequence.selectedId) ??
state.sequence.images.length,
loading: state.sequence.loading,
}),
@@ -145,6 +145,56 @@ export function kartaViewTimeMs(value) {
return Number.isFinite(ms) ? ms : null;
}
/** Initial great-circle bearing from `a` to `b`, degrees clockwise from north. */
export function bearingBetween(a, b) {
const rad = Math.PI / 180;
const dLon = (b.lon - a.lon) * rad;
const y = Math.sin(dLon) * Math.cos(b.lat * rad);
const x =
Math.cos(a.lat * rad) * Math.sin(b.lat * rad) -
Math.sin(a.lat * rad) * Math.cos(b.lat * rad) * Math.cos(dLon);
return (Math.atan2(y, x) / rad + 360) % 360;
}
/** Photos closer than this say nothing about the direction of travel. */
const COURSE_MIN_STEP_M = 2;
/** How many photos along to look for one far enough away. */
const COURSE_SEARCH = 5;
/**
* Give each photo of a drive (in index order) its `course`: the direction
* of travel from it to the next photo a few metres on (from the previous one
* at the end). KartaView is dashcam imagery, so this is where the camera
* looks; its `heading` is the device compass, which reads 0 without one and
* can sit 110° off inside a car.
* @param {Array<{lon: number, lat: number, course?: number|null}>} photos
* @param {(a: object, b: object) => number} metres
*/
export function assignCourses(photos, metres) {
photos.forEach((photo, i) => {
let course = null;
for (let j = i + 1; j <= i + COURSE_SEARCH && j < photos.length; j++)
if (metres(photo, photos[j]) >= COURSE_MIN_STEP_M) {
course = bearingBetween(photo, photos[j]);
break;
}
for (
let k = i - 1;
course === null && k >= i - COURSE_SEARCH && k >= 0;
k--
)
if (metres(photos[k], photo) >= COURSE_MIN_STEP_M)
course = bearingBetween(photos[k], photo);
photo.course = course;
});
return photos;
}
/** Where the camera looked: the direction of travel, else the compass, else unknown. */
export function cameraBearing(photo) {
return photo?.course ?? photo?.heading ?? null;
}
/** A coordinate pair as numbers; the road-matched one when KartaView has it. */
function positionOf(record) {
const matchLat = Number(record.matchLat);
@@ -214,10 +264,11 @@ export function normalizePhoto(record) {
// 0,0 is a missing fix, not a photo in the Gulf of Guinea.
if (lat === 0 && lon === 0) return null;
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
// An exact 0 is a device without a compass (whole drives read 0), not north.
const heading =
record.heading === '' || record.heading == null
? NaN
: Number(record.heading);
: Number(record.heading) || NaN;
const isPano =
record.projection === 'SPHERE' || Number(record.fieldOfView) >= 360;
const full =
@@ -1,6 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
assignCourses,
bearingBetween,
cameraBearing,
kartaViewPhotoUrl,
kartaViewTimeMs,
normalizePhoto,
@@ -140,7 +143,9 @@ test('records without an id or a position are dropped; a missing heading is null
null,
);
assert.equal(normalizePhoto(record({ heading: '' })).heading, null);
assert.equal(normalizePhoto(record({ heading: '0' })).heading, 0);
// A device without a compass reads 0 for a whole drive: unknown, not north.
assert.equal(normalizePhoto(record({ heading: '0' })).heading, null);
assert.equal(normalizePhoto(record({ heading: '0.5' })).heading, 0.5);
assert.equal(normalizePhoto(record({ heading: 'n/a' })).heading, null);
});
@@ -237,3 +242,41 @@ test('with no processed-photo path the size prefix gives the full photo', () =>
/^https:\/\/storage3\.openstreetcam\.org\/.*\/lth\//,
);
});
test('the camera looks along the drive: each photo’s course points to the next one a few metres on', () => {
// North along a meridian, 5 m apart, then a repeat of the last position.
const step = 5 / 110_540;
const photos = [0, 1, 2, 3].map((i) => ({ lon: 10, lat: 50 + i * step }));
photos.push({ lon: 10, lat: 50 + 3 * step }); // a stop: same place
const metres = (a, b) =>
Math.hypot(
(b.lon - a.lon) * 111_320 * Math.cos((a.lat * Math.PI) / 180),
(b.lat - a.lat) * 110_540,
);
assignCourses(photos, metres);
for (const photo of photos)
assert.ok(
Math.abs(photo.course) < 0.5 || Math.abs(photo.course - 360) < 0.5,
`${photo.course}`,
);
// Eastward: 90°.
const east = [0, 1].map((i) => ({ lon: 10 + (i * 5) / 71_600, lat: 50 }));
assignCourses(east, metres);
assert.ok(
Math.abs(east[0].course - 90) < 0.5 && Math.abs(east[1].course - 90) < 0.5,
);
// A lone photo has no course; the compass is the fallback, then unknown.
const [lone] = assignCourses([{ lon: 1, lat: 1 }], metres);
assert.equal(lone.course, null);
assert.equal(cameraBearing({ ...lone, heading: 42 }), 42);
assert.equal(cameraBearing({ ...lone, heading: null }), null);
assert.equal(
cameraBearing({ course: 90, heading: 200 }),
90,
'the drive beats the compass',
);
assert.ok(
Math.abs(bearingBetween({ lon: 0, lat: 0 }, { lon: 0, lat: -1 }) - 180) <
1e-9,
);
});
@@ -714,7 +714,7 @@ test('coverage hints: zoom in from too high, “no photos” only once an empty
assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT);
});
test('a coverage tile loaded a moment ago is not asked for again', async (t) => {
test('coverage tiles are kept while KartaView is on and dropped once it is switched off', async (t) => {
const env = coverageSetup(t, {
lines: [
[
@@ -725,11 +725,14 @@ test('a coverage tile loaded a moment ago is not asked for again', async (t) =>
});
env.instance.activate(env.viewer);
assert.ok(await until(() => env.instance.coverageStats().count === 1));
// Off and on again (or a pan away and back): drawn from the kept tile.
env.instance.refreshCoverage();
assert.ok(await until(() => env.instance.coverageStats().count === 1));
assert.equal(env.tiles.length, 1, 'kept while on');
// Switched off: the decoded lines are not kept, so on again asks again.
env.instance.deactivate();
env.instance.activate(env.viewer);
assert.ok(await until(() => env.instance.coverageStats().count === 1));
assert.equal(env.tiles.length, 1);
assert.equal(env.tiles.length, 2);
});
test('a photo no page lists steps to the photos either side of its index', async () => {
@@ -746,3 +749,113 @@ test('a photo no page lists steps to the photos either side of its index', async
await press(env.prev, env.poses);
assert.equal(env.poses.at(-1), 's-4');
});
test('the panel counts the drive’s photos, not the thinned page of them the cones show', async (t) => {
const env = await mounted({
sequences: { s: Array.from({ length: 40 }, (_, i) => i) },
});
const asked = [];
env.source.getSequenceCount = async (id) => {
asked.push(id);
return 2385;
};
await withCones(t, env);
await env.instance.viewer.open('s-3');
await env.instance.selectSequence('s');
assert.ok(
await until(() => env.instance.sequenceStats().images === 2385),
`images: ${env.instance.sequenceStats().images}`,
);
// Asked once per drive.
env.instance.clearSequence();
await env.instance.viewer.open('s-30');
await env.instance.selectSequence('s');
assert.ok(await until(() => env.instance.sequenceStats().images === 2385));
assert.deepEqual(asked, ['s']);
});
test('after a GPU reset the photo is drawn again by the viewer, not through the core; a failure is reported', async () => {
const source = fakeSource();
const record = {
...photoRecord('pano', { seq: 'p', index: 0 }),
projection: 'SPHERE',
fieldOfView: '360',
filepathProc: '2022/10/6/wrapped_proc/pano.jpg',
};
source.getPhoto = async () => record;
const view = { yaw: 0, pitch: 0, fov: 80 };
let failLoads = false;
const definition = createKartaViewProvider({
source,
viewerOptions: {
load: async (url) => {
if (failLoads)
throw new Error('The KartaView photo could not be loaded');
return { src: url };
},
createRenderer: () => ({
setImage() {},
setView: (next) => Object.assign(view, next),
getView: () => ({ ...view }),
resize() {},
clear() {},
destroy() {},
}),
},
});
const { context, actions } = fakeContext();
const errors = [];
context.actions.reportError = (message) => errors.push(message);
const instance = definition.create(context);
actions.instance = instance;
const host = fakeHost();
await instance.viewer.mount(host);
const poses = [];
instance.viewer.onPose((pose) => poses.push(pose.imageId));
await instance.viewer.open('pano');
const canvas = () => host.children[0].children[1];
const kind = () => host.children[0].dataset.kind;
canvas().fire('webglcontextlost');
assert.equal(kind(), '', 'cleared while it is drawn again');
assert.ok(await until(() => kind() === 'pano'), 'drawn again');
assert.ok(poses.length >= 2);
assert.deepEqual(actions.opened, [], 'the core (and its lookup) left alone');
failLoads = true;
canvas().fire('webglcontextlost');
assert.ok(await until(() => errors.length === 1));
assert.match(errors[0], /could not be loaded/);
});
test('the marker is KartaView’s own cone, and it points along the drive, not at a wrong compass', async () => {
const env = setup({
sequences: { s: Array.from({ length: 10 }, (_, i) => i) },
});
assert.deepEqual(env.definition.marker, {
kind: 'view-cone',
color: '#635bff',
});
// Photos run east (photoRecord puts index i at 5·i m east); the compass says 200°.
const getPhoto = env.source.getPhoto;
env.source.getPhoto = async (id) => ({
...(await getPhoto(id)),
heading: '200',
});
const getPage = env.source.getSequencePhotos;
env.source.getSequencePhotos = async (...args) => {
const page = await getPage(...args);
return {
...page,
photos: page.photos.map((record) => ({ ...record, heading: '200' })),
};
};
const host = fakeHost();
await env.instance.viewer.mount(host);
const bearings = [];
env.instance.viewer.onPose((pose) => bearings.push(pose.bearing));
await env.instance.viewer.open('s-4');
assert.equal(bearings[0], 200, 'the compass until the drive is known');
assert.ok(
await until(() => Math.abs(bearings.at(-1) - 90) < 1),
`then along the drive: ${bearings}`,
);
});
@@ -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,
@@ -151,12 +151,19 @@ test('an API refusal inside a 200 is a readable error with its code; 601 is just
test('HTTP errors, network failures and non-JSON become readable errors', async () => {
const http = createKartaViewSource({ fetchImpl: async () => status(500) });
await assert.rejects(http.getTile({ x: 0, y: 0, z: 14 }), /HTTP 500/);
let asked = 0;
const offline = createKartaViewSource({
fetchImpl: async () => {
asked++;
throw new TypeError('Failed to fetch');
},
});
await assert.rejects(offline.getPhoto('1'), /did not answer/);
// Not a timeout: no network is said as much, and not asked again at once.
await assert.rejects(
offline.getPhoto('1'),
/could not be reached — check the network/,
);
assert.equal(asked, 1);
const garbled = createKartaViewSource({
fetchImpl: async () => ({
ok: true,
@@ -169,7 +176,19 @@ test('HTTP errors, network failures and non-JSON become readable errors', async
});
await assert.rejects(
garbled.getTile({ x: 0, y: 0, z: 14 }),
/did not answer/,
/could not be read/,
);
const torn = createKartaViewSource({
fetchImpl: async () => ({
ok: true,
status: 200,
headers: new Map(),
body: streamed('{"lines": [', 4),
}),
});
await assert.rejects(
torn.getTile({ x: 0, y: 0, z: 14 }),
/could not be read/,
);
});
@@ -265,18 +284,24 @@ test('tiles, lookups and sequence pages queue separately, so a lookup never wait
assert.equal(peak.page, 1);
});
test('a lookup that times out is asked once more; a second timeout says to try again', async () => {
test('a lookup that times out is asked once more, then reported as no answer in time', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const source = createKartaViewSource({
fetchImpl: async () => {
calls++;
throw new TypeError('Failed to fetch');
},
});
await assert.rejects(
source.getPhoto('1'),
const outcome = await onMockClock(t, () =>
createKartaViewSource({
fetchImpl: (url, { signal }) => {
calls++;
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason)),
);
},
}).getPhoto('1'),
);
assert.match(
String(outcome?.error?.message),
/did not answer in time — try again/,
);
assert.equal(outcome.error.timedOut, true);
assert.equal(calls, 2);
});
@@ -323,17 +348,35 @@ test('an image probe reads only the status: an archived photo (409) is not showa
);
});
test('a cold tile that times out is asked once more', async () => {
test('a cold tile that times out is asked once more; an unreachable one is not', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const source = createKartaViewSource({
const outcome = await onMockClock(t, () =>
createKartaViewSource({
fetchImpl: (url, { signal }) => {
calls++;
if (calls === 1)
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason)),
);
return Promise.resolve(ok({ lines: [] }));
},
}).getTile({ x: 1, y: 2, z: 13 }),
);
assert.deepEqual(outcome.value, { lines: [] });
assert.equal(calls, 2);
let asked = 0;
const offline = createKartaViewSource({
fetchImpl: async () => {
calls++;
if (calls === 1) throw new TypeError('Failed to fetch');
return ok({ lines: [] });
asked++;
throw new TypeError('Failed to fetch');
},
});
assert.deepEqual(await source.getTile({ x: 1, y: 2, z: 13 }), { lines: [] });
assert.equal(calls, 2);
await assert.rejects(
offline.getTile({ x: 1, y: 2, z: 13 }),
/could not be reached/,
);
assert.equal(asked, 1);
});
test('an absurdly large answer is refused before it is parsed', async () => {
@@ -11,6 +11,7 @@ import {
PANO_FULL_SIZE,
PANO_MID_SIZE,
PANO_PREVIEW_SIZE,
cameraBearing,
kartaViewPhotoUrl,
resizedImageUrl,
} from './policy.js';
@@ -88,6 +89,7 @@ export function panoSizeFor(canvasPixels, fovDeg) {
* @param {(photo: object, delta: -1|1) => Promise<string|null>} options.step The photo `delta` along the sequence, or null at its end.
* @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show).
* @param {(photo: object) => void} [options.onShown] A photo is on screen.
* @param {(imageId: string) => void} [options.onReopen] Draw the same photo again after a GPU reset (default: `onStep`).
* @param {object} [options.render] Render governor.
* @param {(url: string, options?: object) => Promise<HTMLImageElement>} [options.load]
* @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer]
@@ -98,6 +100,7 @@ export function createKartaViewViewer({
step,
onStep,
onShown = () => {},
onReopen = onStep,
render,
load = loadImage,
createRenderer = createPanoramaRenderer,
@@ -125,6 +128,8 @@ export function createKartaViewViewer({
let restoreView = null;
/** Width of the panorama texture on screen (0: the preview). */
let panoWidth = 0;
/** The flat photo on screen is the 2,560 px version. */
let flatSharp = false;
/** The photo an open is fetching: ‹ › step on from it, not the old one. */
let openingId = null;
/** ‹ › chose this photo: keep the panorama's look direction for it. */
@@ -139,7 +144,7 @@ export function createKartaViewViewer({
function emit() {
if (!photo || !showing) return;
const view = showing === 'pano' ? renderer.getView() : null;
const heading = photo.heading;
const heading = cameraBearing(photo);
const pose = {
providerId: KARTAVIEW_PROVIDER_ID,
imageId: photo.id,
@@ -257,7 +262,7 @@ export function createKartaViewViewer({
const again = photo.id;
show(null);
openingId = again;
onStep(again);
onReopen(again);
}
}
@@ -471,6 +476,10 @@ export function createKartaViewViewer({
if (kind === 'flat' && target.isPano) return;
const size = kind === 'pano' ? panoSizeNow() : FLAT_FULL_SIZE;
if (kind === 'pano' && size[0] <= panoWidth) return;
// A docked viewer shows the 1,280 px version in full: the 2,560 px one
// (~0.6 MB a photo) waits until the viewer is bigger than that.
if (kind === 'flat' && (flatSharp || flatPixels() <= FLAT_VIEW_SIZE[0]))
return;
const url =
kind === 'pano'
? resizedImageUrl(target.urls.pano, size)
@@ -483,13 +492,24 @@ export function createKartaViewViewer({
if (!renderer || size[0] <= panoWidth) return;
renderer.setImage(image);
panoWidth = size[0];
} else picture.src = image.src;
} else {
picture.src = image.src;
flatSharp = true;
}
requestRender();
} catch {
/* the first version stays */
}
}
/** Device pixels across the flat photo's frame. */
function flatPixels() {
const ratio = globalThis.devicePixelRatio || 1;
return (
Math.max(picture?.clientWidth || 0, picture?.clientHeight || 0) * ratio
);
}
/** The sharp panorama this viewer needs now: 2,048 docked, 4,096 expanded. */
function panoSizeNow() {
const ratio = Math.min(globalThis.devicePixelRatio || 1, 2);
@@ -543,6 +563,7 @@ export function createKartaViewViewer({
if (!image) throw failure || new Error(LOAD_FAILED);
if (seq !== openSeq) return false;
picture.src = image.src;
flatSharp = false;
photo = target;
show('flat');
return true;
@@ -610,9 +631,11 @@ export function createKartaViewViewer({
resize() {
renderer?.resize();
// Expanded: a docked viewer's 2,048 px panorama is now too soft.
// Expanded: a docked viewer's first version is now too soft.
if (showing === 'pano' && photo && panoSizeNow()[0] > panoWidth)
upgrade(photo, openSeq, 'pano');
if (showing === 'flat' && photo && !flatSharp)
upgrade(photo, openSeq, 'flat');
},
/** FIT / FILL frame a flat photo; a panorama has no frame to fit. */
@@ -623,6 +646,11 @@ export function createKartaViewViewer({
applyRenderMode();
},
/** The photo's direction became known (its drive loaded): pose again. */
refreshPose() {
emit();
},
onPose(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
@@ -168,6 +168,7 @@ test('a flat photo shows its 1280 px version, then the sharp one, with a pose fa
});
await viewer.mount(host);
const { picture, canvas, prev, next } = parts(host);
picture.clientWidth = 1600; // expanded: the sharp version is wanted
assert.equal(prev.disabled, true, 'nothing to step through yet');
await viewer.open('flat1');
assert.equal(picture.hidden, false);
@@ -621,6 +622,8 @@ test('quick presses while the next photo is still loading each step on from the
test('a slow sharp version of an older photo never replaces the newer photo', async () => {
const { viewer, host, shown, poses, release } = heldSetup();
await viewer.mount(host);
// An expanded viewer, so the sharp version is wanted.
parts(host).picture.clientWidth = 1600;
const first = viewer.open('a');
await release('a@1280');
await first;
@@ -904,3 +907,19 @@ test('a CORS image is decoded off the main thread into an ImageBitmap where the
/could not be loaded/,
);
});
test('a docked viewer keeps the 1,280 px photo; expanding it brings the 2,560 px one', async () => {
const { viewer, host, loads } = setup();
await viewer.mount(host);
await viewer.open('flat1');
for (let i = 0; i < 4; i++) await settle();
assert.equal(loads.length, 1, 'no 2,560 px download for a 400 px frame');
parts(host).picture.clientWidth = 1600;
viewer.resize();
assert.ok(await until(() => loads.length === 2));
assert.match(loads[1].url, /rs:fit:2560:2560/);
assert.equal(parts(host).picture.src, loads[1].url);
viewer.resize();
for (let i = 0; i < 4; i++) await settle();
assert.equal(loads.length, 2, 'once only');
});