mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
fix(street-level): KartaView QA round — archived photos, connection budget, paging, ‹ ›
From two agentic QA passes (UX and failure modes): - A line click opens the nearest photo that can be shown: archived photos (409; a HEAD says 200) and records without files are skipped, a few at most, else 'KartaView's photos here are archived'. ‹ › skip them too. - api.kartaview.org is HTTP/1.1 (six connections): tiles take three, lookups two, sequence pages one, so a lookup never times out behind tiles; a timed-out lookup is asked once more; waits are capped at 10 s. - Sequence pages follow photo ids, not the index: a photo's page is found by search, a neighbour page joins near an edge, so ‹ › neither skip at page edges nor loop past gaps. Pages are 500 photos (~1.2 MB). - ‹ › open through the core without framing: loading, errors and cones behave as for any open; presses step on from the photo on its way; the ends disable up front. - Cones ignore the hidden IMAGERY / SINCE filters; no 'Selected' swatch for a source whose lines never highlight; 'no KartaView photos in view'; an unreadable tile is not cached as empty; a 0,0 fix is no photo; a failed lookup's error goes when another source is lit; the link goes through the image proxy; a stalled panorama shows its front view after 15 s instead of 60. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
310984ff0b
commit
454e261256
16 files changed
+683
-179
No files matched your search
@@ -108,7 +108,9 @@ export function createStreetLevelLayer({
|
||||
getSurface: () => state.surface,
|
||||
notify,
|
||||
actions: {
|
||||
openImage: (imageId) => openImage(entry.def.id, imageId),
|
||||
/** `{frame: false}` opens it without moving the camera. */
|
||||
openImage: (imageId, options) =>
|
||||
openImage(entry.def.id, imageId, options),
|
||||
/** This provider's image nearest a point the user picked on the map. */
|
||||
openNearest: (point) =>
|
||||
layer.openNearest(point, {
|
||||
@@ -345,6 +347,8 @@ export function createStreetLevelLayer({
|
||||
for (const other of state.providers.values())
|
||||
if (other !== entry && other.on) switchProvider(other, false);
|
||||
switchProvider(entry, next);
|
||||
// A failed lookup's message was about the source just switched away.
|
||||
if (!state.street.open) state.street.error = null;
|
||||
notify();
|
||||
return true;
|
||||
}
|
||||
@@ -414,6 +418,7 @@ export function createStreetLevelLayer({
|
||||
error: stats.error || null,
|
||||
color: entry.def.colors.coverage,
|
||||
coverageLabel: entry.def.coverageLabel || null,
|
||||
highlightsSequence: entry.def.highlightsSequence !== false,
|
||||
groundClick: entry.def.groundClick === true,
|
||||
filters: [...filtersOf(entry.def)],
|
||||
};
|
||||
|
||||
@@ -818,3 +818,23 @@ test('an empty provider composition enables and tears down without acquiring ima
|
||||
layer.enable(viewer);
|
||||
assert.deepEqual(layer.getUIState().providers, []);
|
||||
});
|
||||
|
||||
test('a failed lookup’s error goes when another source is lit; a provider can open an image without framing', async (t) => {
|
||||
const failing = fakeProvider({
|
||||
nearestImage: async () => {
|
||||
throw new Error('KartaView did not answer in time — try again');
|
||||
},
|
||||
});
|
||||
const other = fakeProvider({ id: 'other', pickPrefix: 'oth:' });
|
||||
const { layer, viewer } = await enabledLayer(t, [failing, other]);
|
||||
layer.attachViewerHost({});
|
||||
await layer.openNearest({ lat: 1, lon: 1 });
|
||||
assert.match(layer.getUIState().street.error, /did not answer/);
|
||||
layer.setProviderEnabled('other', true);
|
||||
await settle();
|
||||
assert.equal(layer.getUIState().street.error, null);
|
||||
// A provider's own open (KartaView's ‹ ›) can leave the camera alone.
|
||||
await other.context().actions.openImage('img-1', { frame: false });
|
||||
assert.deepEqual(other.calls.open, ['img-1']);
|
||||
assert.equal(viewer.flights.started, 0);
|
||||
});
|
||||
@@ -9,8 +9,11 @@
|
||||
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<never>}}
|
||||
*/
|
||||
export function decodeCoverageTile(payload, { x, y, z }) {
|
||||
// Not cached as empty: an unreadable tile is asked for again.
|
||||
if (!Array.isArray(payload?.lines))
|
||||
throw new Error('KartaView sent an unreadable coverage tile');
|
||||
const segments = [];
|
||||
for (const line of Array.isArray(payload?.lines) ? payload.lines : []) {
|
||||
for (const line of payload.lines) {
|
||||
const points = [];
|
||||
for (const point of Array.isArray(line?.track) ? line.track : []) {
|
||||
const lon = Number(point?.lng);
|
||||
|
||||
@@ -71,5 +71,17 @@ test('bad points, repeats and one-point tracks are skipped; an empty tile has no
|
||||
[2, 2],
|
||||
]);
|
||||
assert.deepEqual(decodeCoverageTile({ lines: [] }, TILE).sequences, []);
|
||||
assert.deepEqual(decodeCoverageTile(null, TILE).sequences, []);
|
||||
});
|
||||
|
||||
test('a tile that is not a line list fails, so it is asked for again rather than kept empty', () => {
|
||||
for (const payload of [
|
||||
null,
|
||||
{},
|
||||
{ lines: 'nope' },
|
||||
{ status: { apiCode: 690 } },
|
||||
])
|
||||
assert.throws(
|
||||
() => decodeCoverageTile(payload, TILE),
|
||||
/unreadable coverage tile/,
|
||||
);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { decodeCoverageTile } from './decode.js';
|
||||
import { createKartaViewSource } from './source.js';
|
||||
import { createKartaViewViewer } from './viewer.js';
|
||||
import {
|
||||
ARCHIVED_MESSAGE,
|
||||
COLORS,
|
||||
COVERAGE_MAX_HEIGHT_M,
|
||||
COVERAGE_NOUN,
|
||||
@@ -15,9 +16,12 @@ import {
|
||||
KARTAVIEW_NAME,
|
||||
KARTAVIEW_PROVIDER_ID,
|
||||
LINE_CLICK_HINT,
|
||||
NEAREST_PROBES,
|
||||
NEAREST_RADIUS_M,
|
||||
NO_COVERAGE_HINT,
|
||||
PICK_PREFIX,
|
||||
RATE_LIMITED_MESSAGE,
|
||||
SEQUENCE_PAGE_EDGE,
|
||||
SEQUENCE_PAGE_SIZE,
|
||||
TOO_HIGH_HINT,
|
||||
normalizePhoto,
|
||||
@@ -85,6 +89,8 @@ export function createKartaViewProvider({
|
||||
defaultOn: false,
|
||||
// Coverage lines carry no date or projection: no filter could thin them.
|
||||
filters: [],
|
||||
// Its lines are streets, not sequences: a selected drive shows as cones.
|
||||
highlightsSequence: false,
|
||||
coverageLabel: Object.freeze({
|
||||
noun: COVERAGE_NOUN,
|
||||
action: LINE_CLICK_HINT,
|
||||
@@ -169,42 +175,94 @@ export function createKartaViewProvider({
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** The page holding `photo` (an earlier one when photos are missing). */
|
||||
async function windowOf(photo) {
|
||||
let page = pageOf(photo.sequenceIndex ?? 0);
|
||||
for (let tries = 0; tries < 3; tries++) {
|
||||
const window = await loadPage(photo.sequenceId, page);
|
||||
const first = window.photos[0];
|
||||
if (
|
||||
window.photos.some((p) => p.id === photo.id) ||
|
||||
page === 1 ||
|
||||
!first ||
|
||||
(first.sequenceIndex ?? 0) <= (photo.sequenceIndex ?? 0)
|
||||
)
|
||||
return window;
|
||||
page--;
|
||||
/**
|
||||
* The page holding `photo`. Pages follow photo ids, which only roughly
|
||||
* follow the index, and photos go missing, so the page the index
|
||||
* suggests is a first guess: step towards the photo until a page holds
|
||||
* it, or the pages straddle where it would be.
|
||||
*/
|
||||
async function homePage(photo) {
|
||||
let number = pageOf(photo.sequenceIndex ?? 0);
|
||||
const index = photo.sequenceIndex ?? 0;
|
||||
const seen = new Set();
|
||||
let page = null;
|
||||
while (!seen.has(number) && seen.size < 6) {
|
||||
seen.add(number);
|
||||
page = await loadPage(photo.sequenceId, number);
|
||||
if (page.photos.some((p) => p.id === photo.id)) return page;
|
||||
const first = page.photos[0]?.sequenceIndex;
|
||||
const last = page.photos.at(-1)?.sequenceIndex;
|
||||
// Past the last page, or before this page's photos: go back.
|
||||
if ((first === undefined || index < first) && number > 1) number--;
|
||||
else if (last !== undefined && index > last && page.hasMore) number++;
|
||||
else break;
|
||||
}
|
||||
return loadPage(photo.sequenceId, page);
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* The photos around `photo` in index order: its page, plus a
|
||||
* neighbour page when it sits near an edge (pages overlap in index).
|
||||
* @returns {Promise<{home: number, photos: Array<object>}>}
|
||||
*/
|
||||
async function windowOf(photo) {
|
||||
const home = await homePage(photo);
|
||||
const list = home.photos;
|
||||
const at = list.findIndex((p) => p.id === photo.id);
|
||||
const parts = [list];
|
||||
if (at !== -1 && at < SEQUENCE_PAGE_EDGE && home.page > 1)
|
||||
parts.push((await loadPage(photo.sequenceId, home.page - 1)).photos);
|
||||
if (at !== -1 && at >= list.length - SEQUENCE_PAGE_EDGE && home.hasMore)
|
||||
parts.push((await loadPage(photo.sequenceId, home.page + 1)).photos);
|
||||
if (parts.length === 1) return { home: home.page, photos: list };
|
||||
const byId = new Map(parts.flat().map((p) => [p.id, p]));
|
||||
return {
|
||||
home: home.page,
|
||||
photos: [...byId.values()].sort(
|
||||
(a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Photo id → whether its file answers (archived ones do not), per session. */
|
||||
const probes = createLru(PHOTO_CACHE_SIZE);
|
||||
function showable(photo, { signal } = {}) {
|
||||
if (!photo?.urls?.view) return Promise.resolve(false);
|
||||
let probe = probes.get(photo.id);
|
||||
if (!probe) {
|
||||
probe = source.probeImage(photo.urls.view, { signal });
|
||||
probes.set(photo.id, probe);
|
||||
probe.catch(() => probes.delete(photo.id));
|
||||
}
|
||||
return probe;
|
||||
}
|
||||
|
||||
/**
|
||||
* The next showable photo `delta` along the sequence, or null at its
|
||||
* end. Archived photos are skipped, a few at most.
|
||||
*/
|
||||
async function step(photo, delta) {
|
||||
if (!photo?.sequenceId) return null;
|
||||
const window = await windowOf(photo);
|
||||
const list = window.photos;
|
||||
let at = list.findIndex((p) => p.id === photo.id);
|
||||
if (at === -1)
|
||||
at = list.findIndex(
|
||||
(p) => (p.sequenceIndex ?? 0) >= (photo.sequenceIndex ?? 0),
|
||||
);
|
||||
const next = at === -1 ? -1 : at + delta;
|
||||
if (next >= 0 && next < list.length) return list[next].id;
|
||||
if (next < 0 && window.page > 1) {
|
||||
const before = await loadPage(photo.sequenceId, window.page - 1);
|
||||
return before.photos.at(-1)?.id ?? null;
|
||||
}
|
||||
if (next >= list.length && window.hasMore) {
|
||||
const after = await loadPage(photo.sequenceId, window.page + 1);
|
||||
return after.photos[0]?.id ?? null;
|
||||
let from = photo;
|
||||
for (let skipped = 0; skipped <= NEAREST_PROBES; skipped++) {
|
||||
const { photos: list } = await windowOf(from);
|
||||
let at = list.findIndex((p) => p.id === from.id);
|
||||
if (at === -1) {
|
||||
// Not found anywhere: the nearest index on the right side.
|
||||
const index = from.sequenceIndex ?? 0;
|
||||
const later = list.findIndex((p) => (p.sequenceIndex ?? 0) > index);
|
||||
if (delta > 0 && later === -1) return null;
|
||||
at =
|
||||
delta > 0
|
||||
? later - 1
|
||||
: later === -1
|
||||
? list.length
|
||||
: list.findIndex((p) => (p.sequenceIndex ?? 0) >= index);
|
||||
}
|
||||
const next = list[at + delta];
|
||||
if (!next) return null;
|
||||
if (await showable(next)) return next.id;
|
||||
from = next;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -229,7 +287,7 @@ export function createKartaViewProvider({
|
||||
if (!anchor) return [];
|
||||
const window = await windowOf(anchor);
|
||||
signal?.throwIfAborted();
|
||||
drawnPage = { sequenceId, page: window.page };
|
||||
drawnPage = { sequenceId, page: window.home };
|
||||
return thinImages(
|
||||
window.photos.map((photo) => ({
|
||||
id: photo.id,
|
||||
@@ -245,16 +303,24 @@ export function createKartaViewProvider({
|
||||
pickPrefix: PICK_PREFIX.image,
|
||||
spriteId: 'street-level:kartaview-cones',
|
||||
renderKey: 'kartaview-sequence',
|
||||
// The filters are hidden for KartaView: one left set must not hide cones.
|
||||
passes: () => true,
|
||||
});
|
||||
|
||||
/** Re-centre the cones when ‹ › walked off the page they show. */
|
||||
async function followStep(photoId) {
|
||||
/**
|
||||
* ‹ › open through the core (loading, errors, the sequence's cones),
|
||||
* without moving the camera; the cones re-centre once ‹ › walk off the
|
||||
* page they were drawn from.
|
||||
*/
|
||||
async function openStepped(photoId) {
|
||||
const opened = await context.actions.openImage(photoId, {
|
||||
frame: false,
|
||||
});
|
||||
const photo = photos.get(photoId);
|
||||
if (!photo?.sequenceId) return;
|
||||
anchors.set(photo.sequenceId, photo);
|
||||
if (!opened || !photo?.sequenceId) return;
|
||||
if (drawnPage?.sequenceId !== photo.sequenceId) return;
|
||||
const window = await windowOf(photo).catch(() => null);
|
||||
if (window && window.page !== drawnPage.page)
|
||||
if (window && window.home !== drawnPage.page)
|
||||
cones.select(photo.sequenceId, { force: true });
|
||||
}
|
||||
|
||||
@@ -265,7 +331,7 @@ export function createKartaViewProvider({
|
||||
return photo;
|
||||
},
|
||||
step,
|
||||
onStepped: followStep,
|
||||
onStep: openStepped,
|
||||
render: context.services?.render,
|
||||
...viewerOptions,
|
||||
});
|
||||
@@ -316,12 +382,18 @@ export function createKartaViewProvider({
|
||||
coverageStats() {
|
||||
const { hint } = state.coverage;
|
||||
const count = coverage.sequenceCount();
|
||||
const loading = state.coverage.pending.size > 0;
|
||||
const empty =
|
||||
state.coverage.zoom !== null &&
|
||||
!loading &&
|
||||
count === 0 &&
|
||||
!state.coverage.lastError;
|
||||
return {
|
||||
count,
|
||||
zoom: state.coverage.zoom,
|
||||
kind: state.coverage.kind,
|
||||
loading: state.coverage.pending.size > 0,
|
||||
hint: hint || '',
|
||||
loading,
|
||||
hint: hint || (empty ? NO_COVERAGE_HINT : ''),
|
||||
error: state.coverage.lastError,
|
||||
keyRequired: false,
|
||||
keyRejected: false,
|
||||
@@ -353,18 +425,25 @@ export function createKartaViewProvider({
|
||||
loading: state.sequence.loading,
|
||||
}),
|
||||
|
||||
/** The nearest photo within the search radius, or null. */
|
||||
/**
|
||||
* The nearest photo within the search radius that can be shown, or
|
||||
* null. Many photos are archived (their files answer 409) or have no
|
||||
* file at all, so the nearest few are tried in turn.
|
||||
*/
|
||||
async nearestImage({ lat, lon }, { signal } = {}) {
|
||||
const records = await source.nearestPhotos({ lat, lon }, { signal });
|
||||
let best = null;
|
||||
const near = [];
|
||||
for (const record of records) {
|
||||
const photo = remember(record);
|
||||
if (!photo) continue;
|
||||
if (!photo?.urls.view) continue;
|
||||
const metres = metresBetween({ lat, lon }, photo);
|
||||
if (metres <= NEAREST_RADIUS_M && (!best || metres < best.metres))
|
||||
best = { photo, metres };
|
||||
if (metres <= NEAREST_RADIUS_M) near.push({ photo, metres });
|
||||
}
|
||||
return best ? best.photo.id : null;
|
||||
near.sort((a, b) => a.metres - b.metres);
|
||||
for (const { photo } of near.slice(0, NEAREST_PROBES))
|
||||
if (await showable(photo, { signal })) return photo.id;
|
||||
if (near.length) throw new Error(ARCHIVED_MESSAGE);
|
||||
return null;
|
||||
},
|
||||
|
||||
viewer,
|
||||
|
||||
@@ -47,6 +47,7 @@ export const COVERAGE_TUNING = Object.freeze({
|
||||
viewNearM: 1_000,
|
||||
});
|
||||
export const TOO_HIGH_HINT = 'zoom in to see KartaView coverage';
|
||||
export const NO_COVERAGE_HINT = 'no KartaView photos in view';
|
||||
export const RATE_LIMITED_MESSAGE =
|
||||
'KartaView is rate-limiting requests — they resume on their own';
|
||||
|
||||
@@ -57,10 +58,23 @@ export const RATE_LIMITED_MESSAGE =
|
||||
export const PHOTO_API_CONCURRENCY = 2;
|
||||
export const PHOTO_API_RETRIES = 3;
|
||||
export const PHOTO_API_RETRY_BASE_MS = 800;
|
||||
/** A Retry-After longer than this is not waited out. */
|
||||
export const PHOTO_API_RETRY_MAX_MS = 10_000;
|
||||
/** The API is HTTP/1.1 (six connections): tiles 3, lookups 2, pages 1. */
|
||||
export const TILE_CONCURRENCY = 3;
|
||||
export const PAGE_CONCURRENCY = 1;
|
||||
export const TILE_TIMEOUT_MS = 20_000;
|
||||
export const PHOTO_API_TIMEOUT_MS = 15_000;
|
||||
/** A sequence page is about a megabyte of JSON. */
|
||||
export const PAGE_TIMEOUT_MS = 30_000;
|
||||
/** A photo download (tens of kilobytes to a few megabytes). */
|
||||
export const IMAGE_TIMEOUT_MS = 30_000;
|
||||
export const IMAGE_TIMEOUT_MS = 15_000;
|
||||
/** Whether a photo file is there (not archived): headers only. */
|
||||
export const IMAGE_PROBE_TIMEOUT_MS = 8_000;
|
||||
/** Nearest photos tried before giving up on archived ones. */
|
||||
export const NEAREST_PROBES = 4;
|
||||
export const ARCHIVED_MESSAGE =
|
||||
'KartaView’s photos here are archived and cannot be shown';
|
||||
|
||||
/** Photos asked for around a point; the API returns them nearest first. */
|
||||
export const NEAREST_LIMIT = 10;
|
||||
@@ -69,9 +83,12 @@ export const NEAREST_SEARCH_ZOOM = 18;
|
||||
|
||||
/**
|
||||
* Photos of a sequence fetched at once, around the open one. Records are
|
||||
* ~2 kB each, so a page is ~2 MB; the cones and prev/next use it.
|
||||
* ~2.4 kB each, so a page is ~1.2 MB; the cones and ‹ › use it. Pages are in
|
||||
* photo-id order, which only roughly follows the sequence index.
|
||||
*/
|
||||
export const SEQUENCE_PAGE_SIZE = 1000;
|
||||
export const SEQUENCE_PAGE_SIZE = 500;
|
||||
/** Within this many photos of a page's edge, its neighbour page joins it. */
|
||||
export const SEQUENCE_PAGE_EDGE = 12;
|
||||
|
||||
/** Equirectangular panorama sizes: a quick preview, then the sharp one. */
|
||||
export const PANO_PREVIEW_SIZE = Object.freeze([1024, 512]);
|
||||
@@ -161,6 +178,9 @@ export function normalizePhoto(record) {
|
||||
if (!record || record.id == null) return null;
|
||||
const { lat, lon } = positionOf(record);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
// 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;
|
||||
const heading =
|
||||
record.heading === '' || record.heading == null
|
||||
? NaN
|
||||
@@ -195,9 +215,13 @@ export function normalizePhoto(record) {
|
||||
|
||||
/**
|
||||
* Where the image link goes. KartaView's photo pages fail for signed-out
|
||||
* visitors ("Sequence cannot be loaded"), so it is the full-size photo on
|
||||
* KartaView's own storage.
|
||||
* visitors ("Sequence cannot be loaded"), so it is the full-size photo
|
||||
* through KartaView's image proxy, which also serves photos whose storage
|
||||
* host answers 502 directly.
|
||||
*/
|
||||
export function kartaViewPhotoUrl(photo) {
|
||||
return photo?.urls?.full || photo?.urls?.view || 'https://kartaview.org';
|
||||
const url = photo?.urls?.full || photo?.urls?.view;
|
||||
return url
|
||||
? `${KARTAVIEW_CDN_HOST}/pr:sharp/${base64Url(url)}`
|
||||
: 'https://kartaview.org';
|
||||
}
|
||||
@@ -60,7 +60,14 @@ test('photo files come from the storage host the record names, not the one its U
|
||||
photo.urls.full,
|
||||
'https://storage2.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg',
|
||||
);
|
||||
assert.equal(kartaViewPhotoUrl(photo), photo.urls.full);
|
||||
// The link goes through the image proxy, which serves photos whose
|
||||
// storage host answers 502 directly.
|
||||
const link = kartaViewPhotoUrl(photo);
|
||||
assert.match(link, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\/[\w-]+$/);
|
||||
assert.equal(
|
||||
Buffer.from(link.split('/').at(-1), 'base64url').toString(),
|
||||
photo.urls.full,
|
||||
);
|
||||
// An unexpected storage value falls back to the URLs as given.
|
||||
const odd = normalizePhoto(record({ storage: 'https://evil.example' }));
|
||||
assert.match(odd.urls.view, /^https:\/\/storage1\.openstreetcam\.org\//);
|
||||
@@ -101,6 +108,14 @@ test('a 360° photo keeps its equirectangular image; the unmatched position is t
|
||||
|
||||
test('records without an id or a position are dropped; a missing heading is null', () => {
|
||||
assert.equal(normalizePhoto(null), null);
|
||||
// 0,0 is a missing fix.
|
||||
assert.equal(
|
||||
normalizePhoto(
|
||||
record({ lat: '0', lng: '0', matchLat: '0', matchLng: '0' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(normalizePhoto(record({ lat: '95', matchLat: '95' })), null);
|
||||
assert.equal(normalizePhoto(record({ id: undefined })), null);
|
||||
assert.equal(
|
||||
normalizePhoto(
|
||||
|
||||
@@ -24,11 +24,19 @@ function photoRecord(id, { metres = 0, seq = '14010', index = 0 } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/** A source over `sequences` (id → indexes present), recording calls. */
|
||||
function fakeSource({ nearest = [], sequences = {} } = {}) {
|
||||
const calls = { nearest: 0, photo: [], pages: [] };
|
||||
/**
|
||||
* A source over `sequences` (id → indexes present, in page order: KartaView
|
||||
* pages follow photo ids, which only roughly follow the index), recording
|
||||
* calls. Photos whose id is in `archived` answer 409.
|
||||
*/
|
||||
function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) {
|
||||
const calls = { nearest: 0, photo: [], pages: [], probes: [] };
|
||||
return {
|
||||
calls,
|
||||
probeImage: async (url) => {
|
||||
calls.probes.push(url);
|
||||
return !archived.some((id) => url.includes(`/${id}.jpg`));
|
||||
},
|
||||
getTile: async () => ({ lines: [] }),
|
||||
nearestPhotos: async () => {
|
||||
calls.nearest++;
|
||||
@@ -59,7 +67,7 @@ function fakeSource({ nearest = [], sequences = {} } = {}) {
|
||||
}
|
||||
|
||||
function fakeContext() {
|
||||
const actions = { opened: [], nearest: [] };
|
||||
const actions = { opened: [], nearest: [], instance: null };
|
||||
return {
|
||||
actions,
|
||||
context: {
|
||||
@@ -71,7 +79,12 @@ function fakeContext() {
|
||||
getSurface: () => 'draped',
|
||||
notify() {},
|
||||
actions: {
|
||||
openImage: (id) => actions.opened.push(id),
|
||||
// As the core does: the provider's own viewer opens it.
|
||||
openImage: async (id, options) => {
|
||||
actions.opened.push({ id, options });
|
||||
await actions.instance?.viewer.open(id);
|
||||
return true;
|
||||
},
|
||||
openNearest: (point) => actions.nearest.push(point),
|
||||
reportError() {},
|
||||
},
|
||||
@@ -96,6 +109,7 @@ function setup(sourceOptions) {
|
||||
});
|
||||
const { context, actions } = fakeContext();
|
||||
const instance = definition.create(context);
|
||||
actions.instance = instance;
|
||||
return { definition, instance, source, actions, loads };
|
||||
}
|
||||
|
||||
@@ -159,8 +173,9 @@ test('the nearest photo is the closest within 50 m, and opening it asks nothing
|
||||
|
||||
test('a cone opens its photo; a line opens the photo nearest the click', () => {
|
||||
const { instance, actions } = setup();
|
||||
actions.instance = null;
|
||||
assert.equal(instance.handlePick('kv:img:123'), true);
|
||||
assert.deepEqual(actions.opened, ['123']);
|
||||
assert.deepEqual(actions.opened, [{ id: '123', options: undefined }]);
|
||||
const point = { lon: -83.0458, lat: 42.3314 };
|
||||
assert.equal(
|
||||
instance.handlePick('kv:line:14/1/2/3', { groundPoint: () => point }),
|
||||
@@ -185,47 +200,124 @@ test('coverage stats report KartaView as configured and key-free', async () => {
|
||||
assert.equal(stats.count, 0);
|
||||
});
|
||||
|
||||
test('› walks across sequence pages; ‹ walks back; the ends stop', async () => {
|
||||
const press = async (button) => {
|
||||
button.fire('click');
|
||||
for (let i = 0; i < 12; i++) await settle();
|
||||
};
|
||||
|
||||
async function mounted(sourceOptions) {
|
||||
const env = setup(sourceOptions);
|
||||
const host = fakeHost();
|
||||
await env.instance.viewer.mount(host);
|
||||
const poses = [];
|
||||
env.instance.viewer.onPose((pose) => poses.push(pose.imageId));
|
||||
const [, , prev, next] = host.children[0].children;
|
||||
return { ...env, poses, prev, next };
|
||||
}
|
||||
|
||||
test('› walks across sequence pages without moving the camera; ‹ walks back; the ends stop', async () => {
|
||||
const size = SEQUENCE_PAGE_SIZE;
|
||||
const indexes = Array.from({ length: size + 5 }, (_, i) => i);
|
||||
const { instance, source } = setup({ sequences: { s: indexes } });
|
||||
const host = fakeHost();
|
||||
await instance.viewer.mount(host);
|
||||
const poses = [];
|
||||
instance.viewer.onPose((pose) => poses.push(pose.imageId));
|
||||
const [, , prev, next] = host.children[0].children;
|
||||
const press = async (button) => {
|
||||
button.fire('click');
|
||||
for (let i = 0; i < 6; i++) await settle();
|
||||
};
|
||||
const { instance, actions, source, poses, prev, next } = await mounted({
|
||||
sequences: { s: indexes },
|
||||
});
|
||||
await instance.viewer.open(`s-${size - 1}`);
|
||||
await press(next);
|
||||
assert.equal(poses.at(-1), `s-${size}`, 'onto the second page');
|
||||
assert.deepEqual(source.calls.pages, ['s:1', 's:2']);
|
||||
assert.deepEqual(actions.opened.at(-1), {
|
||||
id: `s-${size}`,
|
||||
options: { frame: false },
|
||||
});
|
||||
assert.ok(source.calls.pages.includes('s:2'));
|
||||
await press(prev);
|
||||
assert.equal(poses.at(-1), `s-${size - 1}`);
|
||||
await instance.viewer.open('s-0');
|
||||
for (let i = 0; i < 6; i++) await settle();
|
||||
assert.equal(prev.disabled, true, 'nothing before the first photo');
|
||||
await press(prev);
|
||||
assert.equal(poses.at(-1), 's-0', 'nothing before the first photo');
|
||||
assert.equal(prev.disabled, true);
|
||||
assert.equal(poses.at(-1), 's-0');
|
||||
});
|
||||
|
||||
test('a page with missing photos is found by stepping back a page', async () => {
|
||||
test('pages that overlap in index (id order) step one photo at a time across the edge', async () => {
|
||||
const size = SEQUENCE_PAGE_SIZE;
|
||||
// Index `size + 100` sits on page 1 when 200 earlier photos are gone.
|
||||
const indexes = Array.from({ length: size + 300 }, (_, i) => i).filter(
|
||||
(i) => i < 100 || i >= 300,
|
||||
// As KartaView pages by id: page 1 holds 0…size+2 less two, page 2 starts
|
||||
// with those two (size-2, size) before the rest.
|
||||
const first = Array.from({ length: size + 3 }, (_, i) => i).filter(
|
||||
(i) => i !== size - 2 && i !== size,
|
||||
);
|
||||
const second = [
|
||||
size - 2,
|
||||
size,
|
||||
...Array.from({ length: 50 }, (_, i) => size + 3 + i),
|
||||
];
|
||||
const { instance, poses, next } = await mounted({
|
||||
sequences: { s: [...first, ...second] },
|
||||
});
|
||||
await instance.viewer.open(`s-${size - 5}`);
|
||||
for (let k = 1; k <= 8; k++) await press(next);
|
||||
assert.deepEqual(
|
||||
poses.slice(-9).map((id) => Number(id.slice(2))),
|
||||
Array.from({ length: 9 }, (_, i) => size - 5 + i),
|
||||
'no photo skipped at the page edge',
|
||||
);
|
||||
});
|
||||
|
||||
test('a photo whose index outruns the page count is found, and its neighbours are real', async () => {
|
||||
// 1,000 photos with 500–1499 deleted: index 1500 is on page 2, not 4.
|
||||
const indexes = [
|
||||
...Array.from({ length: 500 }, (_, i) => i),
|
||||
...Array.from({ length: 500 }, (_, i) => 1500 + i),
|
||||
];
|
||||
const { instance, poses, prev, next } = await mounted({
|
||||
sequences: { s: indexes },
|
||||
});
|
||||
await instance.viewer.open('s-1500');
|
||||
await press(next);
|
||||
assert.equal(poses.at(-1), 's-1501');
|
||||
await press(prev);
|
||||
await press(prev);
|
||||
assert.equal(poses.at(-1), 's-499', 'back across the gap');
|
||||
await instance.viewer.open('s-1999');
|
||||
for (let i = 0; i < 8; i++) await settle();
|
||||
assert.equal(next.disabled, true, 'the last photo');
|
||||
});
|
||||
|
||||
test('archived photos are skipped: by the nearest lookup, and by › and ‹', async () => {
|
||||
const { instance, poses, next } = await mounted({
|
||||
nearest: [
|
||||
photoRecord('gone', { metres: 3 }),
|
||||
photoRecord('kept', { metres: 9 }),
|
||||
],
|
||||
archived: ['gone', 's-2', 's-3'],
|
||||
sequences: { s: [0, 1, 2, 3, 4] },
|
||||
});
|
||||
assert.equal(
|
||||
await instance.nearestImage({ lat: 42.3314, lon: -83.0458 }),
|
||||
'kept',
|
||||
);
|
||||
await instance.viewer.open('s-1');
|
||||
await press(next);
|
||||
assert.equal(poses.at(-1), 's-4', 'two archived photos stepped over');
|
||||
const allGone = setup({
|
||||
nearest: [photoRecord('gone', { metres: 3 })],
|
||||
archived: ['gone'],
|
||||
});
|
||||
await assert.rejects(
|
||||
allGone.instance.nearestImage({ lat: 42.3314, lon: -83.0458 }),
|
||||
/archived and cannot be shown/,
|
||||
);
|
||||
const noFiles = setup({
|
||||
nearest: [
|
||||
{
|
||||
...photoRecord('bare', { metres: 2 }),
|
||||
filepathLTh: '',
|
||||
filepathProc: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(
|
||||
await noFiles.instance.nearestImage({ lat: 42.3314, lon: -83.0458 }),
|
||||
null,
|
||||
'a record with no file is not a photo',
|
||||
);
|
||||
const { instance, source } = setup({ sequences: { s: indexes } });
|
||||
const host = fakeHost();
|
||||
await instance.viewer.mount(host);
|
||||
const poses = [];
|
||||
instance.viewer.onPose((pose) => poses.push(pose.imageId));
|
||||
const target = size + 100;
|
||||
await instance.viewer.open(`s-${target}`);
|
||||
const [, , , next] = host.children[0].children;
|
||||
next.fire('click');
|
||||
for (let i = 0; i < 8; i++) await settle();
|
||||
assert.equal(poses.at(-1), `s-${target + 1}`);
|
||||
assert.deepEqual(source.calls.pages.slice(0, 2), ['s:2', 's:1']);
|
||||
});
|
||||
@@ -1,11 +1,16 @@
|
||||
import {
|
||||
IMAGE_PROBE_TIMEOUT_MS,
|
||||
KARTAVIEW_API_HOST,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_SEARCH_ZOOM,
|
||||
PAGE_CONCURRENCY,
|
||||
PAGE_TIMEOUT_MS,
|
||||
PHOTO_API_CONCURRENCY,
|
||||
PHOTO_API_RETRIES,
|
||||
PHOTO_API_RETRY_BASE_MS,
|
||||
PHOTO_API_RETRY_MAX_MS,
|
||||
PHOTO_API_TIMEOUT_MS,
|
||||
TILE_CONCURRENCY,
|
||||
TILE_TIMEOUT_MS,
|
||||
} from './policy.js';
|
||||
|
||||
@@ -19,7 +24,7 @@ export class KartaViewSourceError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const NO_ANSWER = 'KartaView did not answer — check the network and try again';
|
||||
const NO_ANSWER = 'KartaView did not answer in time — try again';
|
||||
|
||||
/** Resolves after `ms`, or rejects when `signal` aborts first. */
|
||||
function wait(ms, signal) {
|
||||
@@ -92,8 +97,11 @@ export function createLimiter(limit) {
|
||||
|
||||
/**
|
||||
* KartaView's public API, browser-side and keyless (it allows any origin).
|
||||
* Coverage tiles are not rate-limited; photo lookups are, so they queue two
|
||||
* at a time and retry a 429.
|
||||
* It speaks HTTP/1.1, so the browser opens six connections to it: tiles take
|
||||
* at most three, photo lookups two and sequence pages one, so a lookup never
|
||||
* waits behind slow tiles or pages. The photo API answers 429 to more than
|
||||
* about three requests at once; a 429 (and a lookup that timed out) is
|
||||
* retried.
|
||||
* @param {{fetchImpl?: typeof fetch, apiHost?: string, retryBaseMs?: number}} [options]
|
||||
*/
|
||||
export function createKartaViewSource({
|
||||
@@ -102,6 +110,8 @@ export function createKartaViewSource({
|
||||
retryBaseMs = PHOTO_API_RETRY_BASE_MS,
|
||||
} = {}) {
|
||||
const limiter = createLimiter(PHOTO_API_CONCURRENCY);
|
||||
const tileLimiter = createLimiter(TILE_CONCURRENCY);
|
||||
const pageLimiter = createLimiter(PAGE_CONCURRENCY);
|
||||
|
||||
/** GET JSON within `timeoutMs`; the caller's abort wins over the timeout. */
|
||||
async function getJson(url, { signal, timeoutMs }) {
|
||||
@@ -137,32 +147,52 @@ export function createKartaViewSource({
|
||||
}
|
||||
}
|
||||
|
||||
/** A photo-API call through the limiter, retrying 429s with backoff. */
|
||||
async function photoApi(path, params, { signal } = {}) {
|
||||
/**
|
||||
* A photo-API call through `gate`, retrying 429s with backoff and, once, a
|
||||
* call that timed out.
|
||||
*/
|
||||
async function photoApi(
|
||||
path,
|
||||
params,
|
||||
{ signal } = {},
|
||||
{ gate = limiter, timeoutMs = PHOTO_API_TIMEOUT_MS } = {},
|
||||
) {
|
||||
const query = params ? `?${new URLSearchParams(params)}` : '';
|
||||
const url = `${apiHost}${path}${query}`;
|
||||
let timeouts = 0;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
const payload = await limiter.run(
|
||||
() => getJson(url, { signal, timeoutMs: PHOTO_API_TIMEOUT_MS }),
|
||||
const payload = await gate.run(
|
||||
() => getJson(url, { signal, timeoutMs }),
|
||||
{ signal },
|
||||
);
|
||||
const code = Number(payload?.status?.apiCode);
|
||||
// 600 ok, 601 empty, 602 partial: anything else is a refusal.
|
||||
if (Number.isFinite(code) && (code < 600 || code > 603))
|
||||
throw new KartaViewSourceError(
|
||||
String(
|
||||
payload?.status?.apiMessage || 'KartaView refused the request',
|
||||
).trim(),
|
||||
`KartaView refused the request (code ${code})`,
|
||||
{ status: Number(payload?.status?.httpCode) || 0 },
|
||||
);
|
||||
return payload?.result ?? {};
|
||||
} catch (error) {
|
||||
if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) throw error;
|
||||
if (signal?.aborted) throw error;
|
||||
const timedOut = error?.message === NO_ANSWER && error.status === 0;
|
||||
if (timedOut && timeouts++ < 1) continue;
|
||||
if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) {
|
||||
if (error?.status === 429)
|
||||
throw new KartaViewSourceError(
|
||||
'KartaView is rate-limiting requests — try again in a moment',
|
||||
{ status: 429, retryAfterSec: error.retryAfterSec },
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
await wait(
|
||||
Math.max(
|
||||
retryBaseMs * 2 ** attempt,
|
||||
(error.retryAfterSec ?? 0) * 1000,
|
||||
Math.min(
|
||||
PHOTO_API_RETRY_MAX_MS,
|
||||
Math.max(
|
||||
retryBaseMs * 2 ** attempt,
|
||||
(error.retryAfterSec ?? 0) * 1000,
|
||||
),
|
||||
),
|
||||
signal,
|
||||
);
|
||||
@@ -173,10 +203,42 @@ export function createKartaViewSource({
|
||||
return {
|
||||
/** A coverage tile: `{lines: [{track: [{lat, lng}]}]}`. */
|
||||
async getTile({ x, y, z }, { signal } = {}) {
|
||||
return getJson(`${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`, {
|
||||
signal,
|
||||
timeoutMs: TILE_TIMEOUT_MS,
|
||||
});
|
||||
return tileLimiter.run(
|
||||
() =>
|
||||
getJson(`${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`, {
|
||||
signal,
|
||||
timeoutMs: TILE_TIMEOUT_MS,
|
||||
}),
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether a photo file can be fetched. Archived photos answer 409 (and
|
||||
* a HEAD says 200 regardless), so this GETs and stops at the headers.
|
||||
*/
|
||||
async probeImage(url, { signal } = {}) {
|
||||
if (!url) return false;
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort(signal.reason);
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(),
|
||||
IMAGE_PROBE_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetchImpl(url, { signal: controller.signal });
|
||||
return response.ok;
|
||||
} catch {
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
// The body is not needed: drop it.
|
||||
controller.abort();
|
||||
}
|
||||
},
|
||||
|
||||
/** Photo records around a point, nearest first. */
|
||||
@@ -216,6 +278,7 @@ export function createKartaViewSource({
|
||||
`/2.0/sequence/${encodeURIComponent(sequenceId)}/photos`,
|
||||
{ page: String(page), itemsPerPage: String(itemsPerPage) },
|
||||
{ signal },
|
||||
{ gate: pageLimiter, timeoutMs: PAGE_TIMEOUT_MS },
|
||||
);
|
||||
return {
|
||||
photos: Array.isArray(result.data) ? result.data : [],
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
createKartaViewSource,
|
||||
createLimiter,
|
||||
} from './source.js';
|
||||
import { PHOTO_API_CONCURRENCY, PHOTO_API_RETRIES } from './policy.js';
|
||||
import {
|
||||
PHOTO_API_CONCURRENCY,
|
||||
PHOTO_API_RETRIES,
|
||||
TILE_CONCURRENCY,
|
||||
} from './policy.js';
|
||||
|
||||
const ok = (body) => ({
|
||||
ok: true,
|
||||
@@ -87,12 +91,13 @@ test('rate-limiting that outlasts the retries is reported with a wait', async ()
|
||||
assert.ok(error instanceof KartaViewSourceError);
|
||||
assert.equal(error.status, 429);
|
||||
assert.ok(error.retryAfterSec > 0);
|
||||
assert.match(error.message, /try again in a moment/);
|
||||
return true;
|
||||
});
|
||||
assert.equal(calls, PHOTO_API_RETRIES + 1);
|
||||
});
|
||||
|
||||
test('an API refusal inside a 200 is an error with KartaView’s message; 601 is just empty', async () => {
|
||||
test('an API refusal inside a 200 is a readable error with its code; 601 is just empty', async () => {
|
||||
const refusing = createKartaViewSource({
|
||||
fetchImpl: async () =>
|
||||
ok({
|
||||
@@ -100,7 +105,10 @@ test('an API refusal inside a 200 is an error with KartaView’s message; 601 is
|
||||
result: {},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(refusing.getPhoto('1'), /out of range/);
|
||||
await assert.rejects(
|
||||
refusing.getPhoto('1'),
|
||||
/KartaView refused the request \(code 612\)/,
|
||||
);
|
||||
const empty = createKartaViewSource({
|
||||
fetchImpl: async () =>
|
||||
ok({ status: { apiCode: 601 }, result: { data: [] } }),
|
||||
@@ -190,3 +198,92 @@ test('the limiter runs tasks in order and keeps going after a failure', async ()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(limiter.pending, 0);
|
||||
});
|
||||
|
||||
test('tiles, lookups and sequence pages queue separately, so a lookup never waits behind them', async () => {
|
||||
const running = { tile: 0, photo: 0, page: 0 };
|
||||
const peak = { tile: 0, photo: 0, page: 0 };
|
||||
const release = [];
|
||||
const kind = (url) =>
|
||||
url.includes('/tiles/')
|
||||
? 'tile'
|
||||
: url.includes('/photos')
|
||||
? 'page'
|
||||
: 'photo';
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async (url) => {
|
||||
const k = kind(url);
|
||||
running[k]++;
|
||||
peak[k] = Math.max(peak[k], running[k]);
|
||||
if (k !== 'photo') await new Promise((resolve) => release.push(resolve));
|
||||
running[k]--;
|
||||
return k === 'tile' ? ok({ lines: [] }) : ok(photoPayload([]));
|
||||
},
|
||||
});
|
||||
const tiles = Array.from({ length: 9 }, (_, i) =>
|
||||
source.getTile({ x: i, y: 0, z: 14 }),
|
||||
);
|
||||
const pages = [1, 2].map((page) =>
|
||||
source.getSequencePhotos('1', { page, itemsPerPage: 500 }),
|
||||
);
|
||||
// Every slow tile and page is in flight or queued; a lookup still answers.
|
||||
assert.deepEqual(await source.nearestPhotos({ lat: 1, lon: 1 }), []);
|
||||
assert.equal(peak.tile, TILE_CONCURRENCY);
|
||||
assert.equal(peak.page, 1);
|
||||
while (release.length) {
|
||||
release.shift()();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
await Promise.all([...tiles, ...pages]);
|
||||
assert.equal(peak.tile, TILE_CONCURRENCY);
|
||||
assert.equal(peak.page, 1);
|
||||
});
|
||||
|
||||
test('a lookup that times out is asked once more; a second timeout says to try again', async () => {
|
||||
let calls = 0;
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
throw new TypeError('Failed to fetch');
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getPhoto('1'),
|
||||
/did not answer in time — try again/,
|
||||
);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('an image probe reads only the status: an archived photo (409) is not showable', async () => {
|
||||
const seen = [];
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async (url, { signal }) => {
|
||||
seen.push(signal);
|
||||
return {
|
||||
ok: !url.includes('archived'),
|
||||
status: url.includes('archived') ? 409 : 200,
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
await source.probeImage('https://storage13.openstreetcam.org/ok.jpg'),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await source.probeImage('https://storage13.openstreetcam.org/archived.jpg'),
|
||||
false,
|
||||
);
|
||||
assert.equal(await source.probeImage(null), false);
|
||||
assert.ok(
|
||||
seen.every((signal) => signal.aborted),
|
||||
'the body is dropped',
|
||||
);
|
||||
const offline = createKartaViewSource({
|
||||
fetchImpl: async () => {
|
||||
throw new TypeError('Failed to fetch');
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
await offline.probeImage('https://x.openstreetcam.org/a.jpg'),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -49,7 +49,7 @@ export function loadImage(
|
||||
* @param {object} options
|
||||
* @param {(photoId: string) => Promise<object>} options.getPhoto A normalized photo (policy.js normalizePhoto).
|
||||
* @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.onStepped] A photo opened from ‹ ›, after its pose.
|
||||
* @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show).
|
||||
* @param {object} [options.render] Render governor.
|
||||
* @param {(url: string, options?: object) => Promise<HTMLImageElement>} [options.load]
|
||||
* @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer]
|
||||
@@ -58,7 +58,7 @@ export function loadImage(
|
||||
export function createKartaViewViewer({
|
||||
getPhoto,
|
||||
step,
|
||||
onStepped = () => {},
|
||||
onStep,
|
||||
render,
|
||||
load = loadImage,
|
||||
createRenderer = createPanoramaRenderer,
|
||||
@@ -80,6 +80,12 @@ export function createKartaViewViewer({
|
||||
let drag = null;
|
||||
let poseFrame = 0;
|
||||
let removeListeners = [];
|
||||
/** 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. */
|
||||
let keepViewFor = null;
|
||||
/** ‹ › presses run one after another, each from where the last one went. */
|
||||
let steps = Promise.resolve();
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.('kartaview-viewer');
|
||||
@@ -133,6 +139,21 @@ export function createKartaViewViewer({
|
||||
const enabled = Boolean(photo?.sequenceId) && showing !== null;
|
||||
if (prevButton) prevButton.disabled = !enabled;
|
||||
if (nextButton) nextButton.disabled = !enabled;
|
||||
if (enabled) checkEnds(photo);
|
||||
}
|
||||
|
||||
/** Disable ‹ or › at once where the sequence ends (it also warms the neighbours). */
|
||||
function checkEnds(target) {
|
||||
for (const [delta, button] of [
|
||||
[-1, prevButton],
|
||||
[1, nextButton],
|
||||
])
|
||||
step(target, delta).then(
|
||||
(id) => {
|
||||
if (photo === target && button && !id) button.disabled = true;
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
|
||||
function show(kind) {
|
||||
@@ -159,29 +180,33 @@ export function createKartaViewViewer({
|
||||
return renderer;
|
||||
}
|
||||
|
||||
/** Step along the sequence from the open photo. */
|
||||
async function stepBy(delta) {
|
||||
const from = photo;
|
||||
if (!from?.sequenceId || !showing) return;
|
||||
const seq = openSeq;
|
||||
let nextId = null;
|
||||
try {
|
||||
nextId = await step(from, delta);
|
||||
} catch {
|
||||
nextId = null;
|
||||
}
|
||||
if (seq !== openSeq || photo !== from) return;
|
||||
if (!nextId) {
|
||||
const button = delta < 0 ? prevButton : nextButton;
|
||||
if (button) button.disabled = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await adapter.open(nextId, { keepView: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (photo?.id === nextId) onStepped(nextId);
|
||||
/**
|
||||
* Step along the sequence from the photo showing, or from the one on its
|
||||
* way after an earlier press, and open it through the core.
|
||||
*/
|
||||
function stepBy(delta) {
|
||||
steps = steps.then(async () => {
|
||||
const fromId = openingId ?? photo?.id;
|
||||
if (!fromId || !root) return;
|
||||
let from;
|
||||
let nextId = null;
|
||||
try {
|
||||
from = await getPhoto(fromId);
|
||||
if (from.sequenceId) nextId = await step(from, delta);
|
||||
} catch {
|
||||
nextId = null;
|
||||
}
|
||||
// Closed, or another photo was opened meanwhile.
|
||||
if ((openingId ?? photo?.id) !== fromId) return;
|
||||
if (!nextId) {
|
||||
const button = delta < 0 ? prevButton : nextButton;
|
||||
if (button && photo?.id === fromId) button.disabled = true;
|
||||
return;
|
||||
}
|
||||
openingId = nextId;
|
||||
keepViewFor = nextId;
|
||||
onStep(nextId);
|
||||
});
|
||||
}
|
||||
|
||||
function onPointerDown(event) {
|
||||
@@ -324,15 +349,12 @@ export function createKartaViewViewer({
|
||||
}
|
||||
|
||||
async function showPano(target, seq, keepView) {
|
||||
const preview = resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE);
|
||||
let image;
|
||||
try {
|
||||
image = await load(preview, { crossOrigin: true });
|
||||
} catch {
|
||||
image = await load(resizedImageUrl(target.urls.pano, PANO_FULL_SIZE), {
|
||||
crossOrigin: true,
|
||||
});
|
||||
}
|
||||
// Only the proxy can shrink a 13,000 px panorama: if it fails, the
|
||||
// caller shows the front view rather than waiting on it twice.
|
||||
const image = await load(
|
||||
resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE),
|
||||
{ crossOrigin: true },
|
||||
);
|
||||
if (seq !== openSeq) return false;
|
||||
if (!keepView || showing !== 'pano')
|
||||
renderer.setView({ yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG });
|
||||
@@ -359,6 +381,24 @@ export function createKartaViewViewer({
|
||||
return true;
|
||||
}
|
||||
|
||||
async function openPhoto(id, seq, keepView) {
|
||||
const target = await getPhoto(id);
|
||||
if (seq !== openSeq) return;
|
||||
let shown = false;
|
||||
if (target.isPano && target.urls.pano && ensureRenderer()) {
|
||||
try {
|
||||
shown = await showPano(target, seq, keepView);
|
||||
} catch {
|
||||
// The panorama failed: its front view is better than nothing.
|
||||
if (seq !== openSeq) return;
|
||||
shown = await showFlat(target, seq);
|
||||
}
|
||||
} else shown = await showFlat(target, seq);
|
||||
if (!shown) return;
|
||||
emit();
|
||||
upgrade(target, seq, showing);
|
||||
}
|
||||
|
||||
const adapter = {
|
||||
async mount(host) {
|
||||
if (!host) throw new Error('Street Level viewer has no host element');
|
||||
@@ -368,28 +408,24 @@ export function createKartaViewViewer({
|
||||
},
|
||||
|
||||
/** Resolves once the photo is on screen and its pose was emitted. */
|
||||
async open(imageId, { keepView = false } = {}) {
|
||||
async open(imageId) {
|
||||
if (!root) throw new Error('KartaView viewer is not mounted');
|
||||
const id = String(imageId);
|
||||
const seq = ++openSeq;
|
||||
const target = await getPhoto(String(imageId));
|
||||
if (seq !== openSeq) return;
|
||||
let shown = false;
|
||||
if (target.isPano && target.urls.pano && ensureRenderer()) {
|
||||
try {
|
||||
shown = await showPano(target, seq, keepView);
|
||||
} catch {
|
||||
// The panorama failed: its front view is better than nothing.
|
||||
if (seq !== openSeq) return;
|
||||
shown = await showFlat(target, seq);
|
||||
}
|
||||
} else shown = await showFlat(target, seq);
|
||||
if (!shown) return;
|
||||
emit();
|
||||
upgrade(target, seq, showing);
|
||||
const keepView = keepViewFor === id;
|
||||
keepViewFor = null;
|
||||
openingId = id;
|
||||
try {
|
||||
await openPhoto(id, seq, keepView);
|
||||
} finally {
|
||||
if (openingId === id && seq === openSeq) openingId = null;
|
||||
}
|
||||
},
|
||||
|
||||
close() {
|
||||
openSeq++;
|
||||
openingId = null;
|
||||
keepViewFor = null;
|
||||
photo = null;
|
||||
show(null);
|
||||
if (picture) picture.removeAttribute('src');
|
||||
@@ -398,6 +434,8 @@ export function createKartaViewViewer({
|
||||
|
||||
unmount() {
|
||||
openSeq++;
|
||||
openingId = null;
|
||||
keepViewFor = null;
|
||||
teardown();
|
||||
},
|
||||
|
||||
|
||||
@@ -80,7 +80,11 @@ function setup({ failLoads = [], rendererThrows = false, steps = {} } = {}) {
|
||||
return PHOTOS[id];
|
||||
},
|
||||
step: async (photo, delta) => steps[`${photo.id}:${delta}`] ?? null,
|
||||
onStepped: (id) => stepped.push(id),
|
||||
// The core opens what ‹ › chose; here the adapter is opened directly.
|
||||
onStep: (id) => {
|
||||
stepped.push(id);
|
||||
viewer.open(id).catch(() => {});
|
||||
},
|
||||
load: async (url, options) => {
|
||||
loads.push({ url, crossOrigin: options?.crossOrigin });
|
||||
if (failLoads.some((part) => url.includes(part)))
|
||||
@@ -108,7 +112,9 @@ const parts = (host) => {
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('a flat photo shows its 1280 px version, then the sharp one, with a pose facing its heading', async () => {
|
||||
const { viewer, poses, host, loads } = setup();
|
||||
const { viewer, poses, host, loads } = setup({
|
||||
steps: { 'flat1:1': 'flat2', 'flat1:-1': 'flat0' },
|
||||
});
|
||||
await viewer.mount(host);
|
||||
const { picture, canvas, prev, next } = parts(host);
|
||||
assert.equal(prev.disabled, true, 'nothing to step through yet');
|
||||
@@ -142,7 +148,7 @@ test('a flat photo shows its 1280 px version, then the sharp one, with a pose fa
|
||||
title: null,
|
||||
},
|
||||
);
|
||||
assert.equal(pose.externalUrl, PHOTOS.flat1.urls.full);
|
||||
assert.match(pose.externalUrl, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\//);
|
||||
assert.equal(loads[0].url, PHOTOS.flat1.urls.view);
|
||||
assert.equal(loads[0].crossOrigin, false, 'an <img> needs no CORS');
|
||||
await settle();
|
||||
@@ -222,26 +228,43 @@ test('without WebGL, or when the panorama fails, a 360° photo shows its front v
|
||||
assert.equal(broken.poses.at(-1).imageId, 'pano1');
|
||||
});
|
||||
|
||||
test('‹ › and the arrow keys step along the sequence; the end disables the button', async () => {
|
||||
test('‹ › and the arrow keys step along the sequence; ends disable at once', async () => {
|
||||
const { viewer, poses, host, stepped } = setup({
|
||||
steps: { 'flat1:1': 'flat2' },
|
||||
steps: { 'flat1:1': 'flat2', 'flat2:-1': 'flat1' },
|
||||
});
|
||||
await viewer.mount(host);
|
||||
await viewer.open('flat1');
|
||||
const { root, prev, next } = parts(host);
|
||||
await settle();
|
||||
assert.equal(prev.disabled, true, 'flat1 is the first photo: known up front');
|
||||
assert.equal(next.disabled, false);
|
||||
next.fire('click');
|
||||
await settle();
|
||||
await settle();
|
||||
for (let i = 0; i < 4; i++) await settle();
|
||||
assert.equal(poses.at(-1).imageId, 'flat2');
|
||||
assert.deepEqual(stepped, ['flat2']);
|
||||
root.fire('keydown', { key: 'ArrowRight' });
|
||||
await settle();
|
||||
await settle();
|
||||
assert.deepEqual(stepped, ['flat2'], 'the core is asked to open it');
|
||||
assert.equal(next.disabled, true, 'flat2 is the last photo');
|
||||
assert.equal(prev.disabled, false);
|
||||
root.fire('keydown', { key: 'ArrowLeft', metaKey: true });
|
||||
await settle();
|
||||
assert.equal(poses.at(-1).imageId, 'flat2', 'modified arrows are not ours');
|
||||
assert.equal(prev.disabled, false);
|
||||
root.fire('keydown', { key: 'ArrowLeft' });
|
||||
for (let i = 0; i < 4; i++) await settle();
|
||||
assert.equal(poses.at(-1).imageId, 'flat1');
|
||||
});
|
||||
|
||||
test('quick presses each step on from the photo the last one chose', async () => {
|
||||
const chain = { 'flat1:1': 'flat2', 'flat2:1': 'pano1' };
|
||||
const { viewer, host, stepped } = setup({
|
||||
steps: chain,
|
||||
rendererThrows: true,
|
||||
});
|
||||
await viewer.mount(host);
|
||||
await viewer.open('flat1');
|
||||
const { next } = parts(host);
|
||||
next.fire('click');
|
||||
next.fire('click');
|
||||
for (let i = 0; i < 8; i++) await settle();
|
||||
assert.deepEqual(stepped, ['flat2', 'pano1']);
|
||||
});
|
||||
|
||||
test('a newer open wins; close hides the photo; unmount tears down the panorama', async () => {
|
||||
|
||||
@@ -22,7 +22,8 @@ export function filtersOf(provider) {
|
||||
* @property {boolean} [defaultOn] Default true.
|
||||
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
|
||||
* @property {Array<'pano'|'since'>} [filters] Filters it honours (default both).
|
||||
* @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos").
|
||||
* @property {{noun: string, action: string}} [coverageLabel]
|
||||
* @property {boolean} [highlightsSequence] Its lines show the selected sequence (default true; the legend's "Selected" needs one). How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos").
|
||||
* @property {(context: ProviderContext) => ProviderInstance} create
|
||||
*
|
||||
* @typedef {object} ProviderContext Handed to `create()` once by the core.
|
||||
@@ -33,7 +34,7 @@ export function filtersOf(provider) {
|
||||
* @property {GroundCaster|null} groundCaster Bare-earth heights for terrain mode (groundCast.js); null without a terrain service.
|
||||
* @property {{meshAt: (lon: number, lat: number) => number|undefined, request: (points: Array<[number, number]>) => void, onSampled: (listener: (cells: Array<[number, number]>) => void) => () => void}|null} meshSampler Sampled Google 3D surface heights (meshSampler.js); pass `meshAt` to `castLine`.
|
||||
* @property {() => void} notify Ask the core to publish a new UI snapshot.
|
||||
* @property {{openImage: (imageId: string) => Promise<void>, openNearest: (point: {lon: number, lat: number}) => Promise<boolean>, reportError: (message: string|null) => void}} actions
|
||||
* @property {{openImage: (imageId: string, options?: {frame?: boolean}) => Promise<boolean>, openNearest: (point: {lon: number, lat: number}) => Promise<boolean>, reportError: (message: string|null) => void}} actions
|
||||
*
|
||||
* @typedef {object} ProviderInstance
|
||||
* @property {() => Promise<{configured: boolean}>} status
|
||||
|
||||
@@ -49,6 +49,8 @@ export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
|
||||
* @param {string} options.renderKey Render-governor reason.
|
||||
* @param {(sequenceId: string, selected: boolean) => void} [options.onHighlight]
|
||||
* Highlight (or stop highlighting) the sequence's coverage line.
|
||||
* @param {(image: ConeImage, filter: object) => boolean} [options.passes]
|
||||
* Whether the imagery filter keeps a cone.
|
||||
*/
|
||||
export function createSequenceCones({
|
||||
state,
|
||||
@@ -58,6 +60,7 @@ export function createSequenceCones({
|
||||
spriteId,
|
||||
renderKey,
|
||||
onHighlight = () => {},
|
||||
passes = passesImageryFilter,
|
||||
}) {
|
||||
const { render, sprites } = state.services;
|
||||
|
||||
@@ -154,7 +157,7 @@ export function createSequenceCones({
|
||||
// Resolved now, so a "since N days" window keeps up with the clock.
|
||||
const filter = state.context.getFilter();
|
||||
images.forEach((image, index) => {
|
||||
if (!passesImageryFilter(image, filter)) return;
|
||||
if (!passes(image, filter)) return;
|
||||
const height = heights?.[index] ?? null;
|
||||
const billboard = collection.add({
|
||||
id: `${pickPrefix}${image.id}`,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { COLORS } from './policy.js';
|
||||
* @property {boolean} [groundClick]
|
||||
* @property {Array<'pano'|'since'>} [filters]
|
||||
* @property {{noun: string, action: string}|null} [coverageLabel]
|
||||
* @property {boolean} [highlightsSequence] Its lines turn cyan when their sequence is selected.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -95,7 +96,13 @@ export function composeUIState({
|
||||
label: provider.name,
|
||||
color: provider.color,
|
||||
}));
|
||||
if (legend.length)
|
||||
// "Selected" only beside a source whose lines turn cyan when selected.
|
||||
if (
|
||||
active.some(
|
||||
(provider) =>
|
||||
provider.groundClick !== true && provider.highlightsSequence !== false,
|
||||
)
|
||||
)
|
||||
legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected });
|
||||
return {
|
||||
enabled,
|
||||
|
||||
@@ -236,3 +236,25 @@ test('a key-gated layer offers the switched-off sources that would draw with a k
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('a source whose lines never show a selection lists no Selected swatch, and names its own lines', () => {
|
||||
const kartaView = provider({
|
||||
id: 'kartaview',
|
||||
name: 'KartaView',
|
||||
color: '#635bff',
|
||||
highlightsSequence: false,
|
||||
coverageLabel: {
|
||||
noun: 'streets',
|
||||
action: 'click a line for its nearest photo',
|
||||
},
|
||||
});
|
||||
const ui = composeUIState({ ...base, providers: [kartaView] });
|
||||
assert.deepEqual(
|
||||
ui.legend.map((entry) => entry.key),
|
||||
['kartaview'],
|
||||
);
|
||||
assert.deepEqual(ui.coverage.label, {
|
||||
noun: 'streets',
|
||||
action: 'click a line for its nearest photo',
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user