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

+6 -1
View File
@@ -103,7 +103,12 @@
look, wheel to zoom), with cones along its drive and ‹ › / ← → to step
through it. The caption links to the full-size photo (KartaView's photo
pages fail for signed-out visitors), and the globe credits "© KartaView
contributors, CC BY-SA 4.0". Share links carry it as `0.m.0_0.r.kartaview-1`. Archived photos (KartaView answers 409) are skipped by the click and by ‹ ›; a keyboard and pinch look around a 360° photo.
contributors, CC BY-SA 4.0". Share links carry it in the provider
switches (`0.m.0_0.r.google-0*kartaview-1`). Archived photos (KartaView
answers 409) are skipped by the click and by ‹ ›. Shift+← →, ↑ ↓, + − and
pinch look around a 360° photo. The open photo's marker is a purple view
cone pointing along the drive (KartaView's own compass headings are often
0 or far off).
Mapillary's line coverage and sequence cones are now shared by both
(`lineCoverage.js`, `sequenceCones.js`).
+1 -1
View File
@@ -346,7 +346,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. KartaView photos are undated on its map, so the filters do not apply to it. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.kartaview-1` KartaView on (a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1` KartaView on, as the app writes it (`0.m.0_0.r.kartaview-1` works too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
## 🎖️ Field Missions
+18 -6
View File
@@ -3195,14 +3195,16 @@ ground and z13 up to 6 km (lower zooms take seconds per tile; z10 does not
answer), so higher up the hint says to zoom in, and an empty view says there
are no KartaView photos in it. A tile's lines are OSM way segments with
photos and carry no sequence, date or projection, so `decode.js` chains
segments that meet end to start (about four times fewer lines), they are
segments that meet end to start (about four times fewer lines; linear time,
at most 20,000 segments a tile and 5,000 points a line), they are
drawn as `kv:line:<z/x/y/n>`, up to 1,600 per tile, and never highlighted
(`highlightsSequence: false`, so the legend has no "Selected"), and the
provider declares `filters: []` and `coverageLabel` ("N streets in view ·
click a line for its nearest photo"). Tiles come uncompressed and
uncacheable, so the last 24 decoded tiles are kept for 10 minutes
(`createTileCache`); one that does not answer in 20 s is asked once more,
and one that is not a line list is not cached. A click on a line reaches
uncacheable, so the last 24 decoded tiles are kept for 10 minutes while
KartaView is lit (`createTileCache`, dropped when it is switched off); one
that does not answer in 20 s is asked once more, and one that is not a line
list is not cached. A click on a line reaches
`handlePick(id, {groundPoint})`, and the provider asks the core
(`actions.openNearest`) for its nearest showable photo within 50 m, without
framing: many photos are archived (their file answers 409, while a HEAD says
@@ -3211,7 +3213,15 @@ off at the headers, and where all are gone the panel says KartaView's photos
there are archived. Only a definite answer (403/404/409/410, or 200) is
remembered; a storage timeout or 5xx is asked again. The open photo's
sequence draws as cones from the 250-photo page holding it
(`/2.0/sequence/{id}/photos`). Pages follow photo ids, which only roughly
(`/2.0/sequence/{id}/photos`); the panel counts the drive's own photos
(`countActivePhotos`, asked once per sequence). The camera direction (the
marker, cones, FOLLOW, and a panorama's centre) is the direction of travel
to the next photo a few metres on (`assignCourses`): KartaView is dashcam
imagery, while its `heading` is the device compass, which reads 0 for whole
drives without one and can sit a steady 110° off inside a car. Until the
drive's page arrives the compass stands in (0 counts as unknown), and the
pose is sent again once the course is known. The open photo's marker is a
purple view cone (`marker: {kind: 'view-cone'}`), as Street View's is blue. Pages follow photo ids, which only roughly
follow the index, so the page is found by search (both ways, halving past the
end when many photos were deleted), a neighbour page joins within 12 photos
of an edge, and the cones re-centre whenever a photo of the selected sequence
@@ -3222,7 +3232,9 @@ browser's six connections to it tiles take three, lookups two and pages one
(newest first), and a lookup never waits behind tiles; the photo API answers
429 to more than about three requests at once, so a 429 is retried with
backoff (at most 10 s), and a lookup gets 8 s, then 15 s once more (KartaView
sometimes sits on a lookup for 11 s and answers the same one in 3 s). Answers
sometimes sits on a lookup for 11 s and answers the same one in 3 s); a
request that could not be made at all (no network, CORS) says so and is not
repeated. Answers
are read with a running byte count and cut off past 16 MB (KartaView sends
them chunked, without a length). Photo URLs are built from
`storage` and `filepath*`: the API's `fileurl*` always name storage1, where
+15 -1
View File
@@ -66,7 +66,8 @@ function record(photo) {
lng: String(photo.lon),
matchLat: String(photo.lat),
matchLng: String(photo.lon),
heading: '0',
// A dashcam phone's compass, disturbed by the car: the drive runs north.
heading: '200',
fieldOfView: photo.isPano ? '360' : '0',
projection: photo.isPano ? 'SPHERE' : 'PLANE',
shotDate: SHOT,
@@ -117,6 +118,7 @@ export function describeKartaViewCall(method, url) {
.replace(/\/tiles\/\d+\/\d+\/\d+\.json$/, '/tiles/{x}/{y}/{z}.json')
.replace(/\/photo\/\d+$/, '/photo/{id}')
.replace(/\/sequence\/\d+\/photos$/, '/sequence/{id}/photos')
.replace(/\/sequence\/\d+$/, '/sequence/{id}')
.replace(/^\/files\/photo\/.*$/, '/files/photo/{path}')
.replace(
/^\/pr:sharp\/rs:fit:\d+:\d+\/.*$/,
@@ -202,6 +204,18 @@ export function answerKartaViewRequest({ method, url }) {
? { ...ok({ data: record(photo) }), known: true }
: { ...raw(200, { status: { apiCode: 601 }, result: {} }), known: true };
}
const sequence = path.match(/^\/2\.0\/sequence\/(\d+)$/);
if (sequence)
return {
...ok({
data: {
id: sequence[1],
countActivePhotos:
sequence[1] === KV_SEQUENCE_ID ? String(photos.length) : '0',
},
}),
known: true,
};
const page = path.match(/^\/2\.0\/sequence\/(\d+)\/photos$/);
if (page) {
const q = address.searchParams;
+50 -1
View File
@@ -464,6 +464,45 @@ async function assertKeylessGate(page) {
assert.match(chip.title, /MAPILLARY_CLIENT_TOKEN/);
}
/**
* With no Mapillary key the panel points at KartaView, which needs none:
* lit, it draws coverage and the layer is not key-gated.
*/
async function assertKeylessKartaView(page) {
assert.match(
await page.$eval('#sl-coverage-meta', (node) => node.textContent),
/light KARTAVIEW/,
'KEY REQUIRED points at KartaView',
);
for (let attempt = 0; attempt < 4; attempt++) {
await page.evaluate((at) => {
const v = window.__godsEyeView.viewer;
v.camera.cancelFlight?.();
const C = v.camera.positionCartographic.constructor;
v.camera.setView({
destination: v.scene.globe.ellipsoid.cartographicToCartesian(
C.fromDegrees(at.lon, at.lat, at.height),
),
orientation: { heading: 0, pitch: at.pitch, roll: 0 },
});
}, PARK);
await sleep(1000);
}
await clickPanelControl(
page,
'#sl-provider-chips [data-chip-id="kartaview"]',
);
await waitForCoverage(page);
const state = await page.evaluate(() => ({
status: document.getElementById('sl-status').textContent,
keyRequired: window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState().keyRequired,
}));
assert.equal(state.keyRequired, false);
assert.doesNotMatch(state.status, /KEY/);
}
/**
* Runs in the page. Find a point where a real click picks `prefix` + `id` (any
* id when null), searched around `points` ([lon, lat]), the aimed billboards,
@@ -942,6 +981,15 @@ async function main() {
),
`cones for the whole drive: ${JSON.stringify((await ui()).sequence)}`,
);
// The drive runs north; the photos' compass says 200°.
assert.ok(
await uiUntil(
(u) =>
Number.isFinite(u.street.bearing) &&
Math.min(u.street.bearing, 360 - u.street.bearing) < 2,
),
`the camera looks along the drive, not where the compass points: ${(await ui()).street.bearing}`,
);
}
const before = street.imageId;
await clickPanelControl(page, '.sl-kv-step-next');
@@ -2444,7 +2492,7 @@ async function main() {
);
if (fixtures)
await step(
'keyless install (a second page, no key on the server) gates the controls and reports KEY REQUIRED',
'keyless install (a second page, no key on the server) gates the controls and reports KEY REQUIRED, and KartaView draws there',
async () => {
const keyless = await browser.newPage();
await keyless.setViewport(VIEWPORTS[0]);
@@ -2453,6 +2501,7 @@ async function main() {
await boot(keyless, url);
await expandStrip(keyless);
await assertKeylessGate(keyless);
await assertKeylessKartaView(keyless);
renderErrorsSeen.push(
...(await readRenderErrors(keyless)).map(
(text) => `[keyless] scene.renderError: ${text}`,
@@ -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');
});