mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
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');
|
||||
});
|
||||
Reference in new issue
Block a user