mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +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
+664
-160
No files matched your search
@@ -108,7 +108,9 @@ export function createStreetLevelLayer({
|
|||||||
getSurface: () => state.surface,
|
getSurface: () => state.surface,
|
||||||
notify,
|
notify,
|
||||||
actions: {
|
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. */
|
/** This provider's image nearest a point the user picked on the map. */
|
||||||
openNearest: (point) =>
|
openNearest: (point) =>
|
||||||
layer.openNearest(point, {
|
layer.openNearest(point, {
|
||||||
@@ -345,6 +347,8 @@ export function createStreetLevelLayer({
|
|||||||
for (const other of state.providers.values())
|
for (const other of state.providers.values())
|
||||||
if (other !== entry && other.on) switchProvider(other, false);
|
if (other !== entry && other.on) switchProvider(other, false);
|
||||||
switchProvider(entry, next);
|
switchProvider(entry, next);
|
||||||
|
// A failed lookup's message was about the source just switched away.
|
||||||
|
if (!state.street.open) state.street.error = null;
|
||||||
notify();
|
notify();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -414,6 +418,7 @@ export function createStreetLevelLayer({
|
|||||||
error: stats.error || null,
|
error: stats.error || null,
|
||||||
color: entry.def.colors.coverage,
|
color: entry.def.colors.coverage,
|
||||||
coverageLabel: entry.def.coverageLabel || null,
|
coverageLabel: entry.def.coverageLabel || null,
|
||||||
|
highlightsSequence: entry.def.highlightsSequence !== false,
|
||||||
groundClick: entry.def.groundClick === true,
|
groundClick: entry.def.groundClick === true,
|
||||||
filters: [...filtersOf(entry.def)],
|
filters: [...filtersOf(entry.def)],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -818,3 +818,23 @@ test('an empty provider composition enables and tears down without acquiring ima
|
|||||||
layer.enable(viewer);
|
layer.enable(viewer);
|
||||||
assert.deepEqual(layer.getUIState().providers, []);
|
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>}}
|
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<never>}}
|
||||||
*/
|
*/
|
||||||
export function decodeCoverageTile(payload, { x, y, z }) {
|
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 = [];
|
const segments = [];
|
||||||
for (const line of Array.isArray(payload?.lines) ? payload.lines : []) {
|
for (const line of payload.lines) {
|
||||||
const points = [];
|
const points = [];
|
||||||
for (const point of Array.isArray(line?.track) ? line.track : []) {
|
for (const point of Array.isArray(line?.track) ? line.track : []) {
|
||||||
const lon = Number(point?.lng);
|
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],
|
[2, 2],
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(decodeCoverageTile({ lines: [] }, TILE).sequences, []);
|
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 { createKartaViewSource } from './source.js';
|
||||||
import { createKartaViewViewer } from './viewer.js';
|
import { createKartaViewViewer } from './viewer.js';
|
||||||
import {
|
import {
|
||||||
|
ARCHIVED_MESSAGE,
|
||||||
COLORS,
|
COLORS,
|
||||||
COVERAGE_MAX_HEIGHT_M,
|
COVERAGE_MAX_HEIGHT_M,
|
||||||
COVERAGE_NOUN,
|
COVERAGE_NOUN,
|
||||||
@@ -15,9 +16,12 @@ import {
|
|||||||
KARTAVIEW_NAME,
|
KARTAVIEW_NAME,
|
||||||
KARTAVIEW_PROVIDER_ID,
|
KARTAVIEW_PROVIDER_ID,
|
||||||
LINE_CLICK_HINT,
|
LINE_CLICK_HINT,
|
||||||
|
NEAREST_PROBES,
|
||||||
NEAREST_RADIUS_M,
|
NEAREST_RADIUS_M,
|
||||||
|
NO_COVERAGE_HINT,
|
||||||
PICK_PREFIX,
|
PICK_PREFIX,
|
||||||
RATE_LIMITED_MESSAGE,
|
RATE_LIMITED_MESSAGE,
|
||||||
|
SEQUENCE_PAGE_EDGE,
|
||||||
SEQUENCE_PAGE_SIZE,
|
SEQUENCE_PAGE_SIZE,
|
||||||
TOO_HIGH_HINT,
|
TOO_HIGH_HINT,
|
||||||
normalizePhoto,
|
normalizePhoto,
|
||||||
@@ -85,6 +89,8 @@ export function createKartaViewProvider({
|
|||||||
defaultOn: false,
|
defaultOn: false,
|
||||||
// Coverage lines carry no date or projection: no filter could thin them.
|
// Coverage lines carry no date or projection: no filter could thin them.
|
||||||
filters: [],
|
filters: [],
|
||||||
|
// Its lines are streets, not sequences: a selected drive shows as cones.
|
||||||
|
highlightsSequence: false,
|
||||||
coverageLabel: Object.freeze({
|
coverageLabel: Object.freeze({
|
||||||
noun: COVERAGE_NOUN,
|
noun: COVERAGE_NOUN,
|
||||||
action: LINE_CLICK_HINT,
|
action: LINE_CLICK_HINT,
|
||||||
@@ -169,42 +175,94 @@ export function createKartaViewProvider({
|
|||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The page holding `photo` (an earlier one when photos are missing). */
|
/**
|
||||||
async function windowOf(photo) {
|
* The page holding `photo`. Pages follow photo ids, which only roughly
|
||||||
let page = pageOf(photo.sequenceIndex ?? 0);
|
* follow the index, and photos go missing, so the page the index
|
||||||
for (let tries = 0; tries < 3; tries++) {
|
* suggests is a first guess: step towards the photo until a page holds
|
||||||
const window = await loadPage(photo.sequenceId, page);
|
* it, or the pages straddle where it would be.
|
||||||
const first = window.photos[0];
|
*/
|
||||||
if (
|
async function homePage(photo) {
|
||||||
window.photos.some((p) => p.id === photo.id) ||
|
let number = pageOf(photo.sequenceIndex ?? 0);
|
||||||
page === 1 ||
|
const index = photo.sequenceIndex ?? 0;
|
||||||
!first ||
|
const seen = new Set();
|
||||||
(first.sequenceIndex ?? 0) <= (photo.sequenceIndex ?? 0)
|
let page = null;
|
||||||
)
|
while (!seen.has(number) && seen.size < 6) {
|
||||||
return window;
|
seen.add(number);
|
||||||
page--;
|
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) {
|
async function step(photo, delta) {
|
||||||
if (!photo?.sequenceId) return null;
|
if (!photo?.sequenceId) return null;
|
||||||
const window = await windowOf(photo);
|
let from = photo;
|
||||||
const list = window.photos;
|
for (let skipped = 0; skipped <= NEAREST_PROBES; skipped++) {
|
||||||
let at = list.findIndex((p) => p.id === photo.id);
|
const { photos: list } = await windowOf(from);
|
||||||
if (at === -1)
|
let at = list.findIndex((p) => p.id === from.id);
|
||||||
at = list.findIndex(
|
if (at === -1) {
|
||||||
(p) => (p.sequenceIndex ?? 0) >= (photo.sequenceIndex ?? 0),
|
// Not found anywhere: the nearest index on the right side.
|
||||||
);
|
const index = from.sequenceIndex ?? 0;
|
||||||
const next = at === -1 ? -1 : at + delta;
|
const later = list.findIndex((p) => (p.sequenceIndex ?? 0) > index);
|
||||||
if (next >= 0 && next < list.length) return list[next].id;
|
if (delta > 0 && later === -1) return null;
|
||||||
if (next < 0 && window.page > 1) {
|
at =
|
||||||
const before = await loadPage(photo.sequenceId, window.page - 1);
|
delta > 0
|
||||||
return before.photos.at(-1)?.id ?? null;
|
? later - 1
|
||||||
|
: later === -1
|
||||||
|
? list.length
|
||||||
|
: list.findIndex((p) => (p.sequenceIndex ?? 0) >= index);
|
||||||
}
|
}
|
||||||
if (next >= list.length && window.hasMore) {
|
const next = list[at + delta];
|
||||||
const after = await loadPage(photo.sequenceId, window.page + 1);
|
if (!next) return null;
|
||||||
return after.photos[0]?.id ?? null;
|
if (await showable(next)) return next.id;
|
||||||
|
from = next;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -229,7 +287,7 @@ export function createKartaViewProvider({
|
|||||||
if (!anchor) return [];
|
if (!anchor) return [];
|
||||||
const window = await windowOf(anchor);
|
const window = await windowOf(anchor);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
drawnPage = { sequenceId, page: window.page };
|
drawnPage = { sequenceId, page: window.home };
|
||||||
return thinImages(
|
return thinImages(
|
||||||
window.photos.map((photo) => ({
|
window.photos.map((photo) => ({
|
||||||
id: photo.id,
|
id: photo.id,
|
||||||
@@ -245,16 +303,24 @@ export function createKartaViewProvider({
|
|||||||
pickPrefix: PICK_PREFIX.image,
|
pickPrefix: PICK_PREFIX.image,
|
||||||
spriteId: 'street-level:kartaview-cones',
|
spriteId: 'street-level:kartaview-cones',
|
||||||
renderKey: 'kartaview-sequence',
|
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);
|
const photo = photos.get(photoId);
|
||||||
if (!photo?.sequenceId) return;
|
if (!opened || !photo?.sequenceId) return;
|
||||||
anchors.set(photo.sequenceId, photo);
|
|
||||||
if (drawnPage?.sequenceId !== photo.sequenceId) return;
|
if (drawnPage?.sequenceId !== photo.sequenceId) return;
|
||||||
const window = await windowOf(photo).catch(() => null);
|
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 });
|
cones.select(photo.sequenceId, { force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,7 +331,7 @@ export function createKartaViewProvider({
|
|||||||
return photo;
|
return photo;
|
||||||
},
|
},
|
||||||
step,
|
step,
|
||||||
onStepped: followStep,
|
onStep: openStepped,
|
||||||
render: context.services?.render,
|
render: context.services?.render,
|
||||||
...viewerOptions,
|
...viewerOptions,
|
||||||
});
|
});
|
||||||
@@ -316,12 +382,18 @@ export function createKartaViewProvider({
|
|||||||
coverageStats() {
|
coverageStats() {
|
||||||
const { hint } = state.coverage;
|
const { hint } = state.coverage;
|
||||||
const count = coverage.sequenceCount();
|
const count = coverage.sequenceCount();
|
||||||
|
const loading = state.coverage.pending.size > 0;
|
||||||
|
const empty =
|
||||||
|
state.coverage.zoom !== null &&
|
||||||
|
!loading &&
|
||||||
|
count === 0 &&
|
||||||
|
!state.coverage.lastError;
|
||||||
return {
|
return {
|
||||||
count,
|
count,
|
||||||
zoom: state.coverage.zoom,
|
zoom: state.coverage.zoom,
|
||||||
kind: state.coverage.kind,
|
kind: state.coverage.kind,
|
||||||
loading: state.coverage.pending.size > 0,
|
loading,
|
||||||
hint: hint || '',
|
hint: hint || (empty ? NO_COVERAGE_HINT : ''),
|
||||||
error: state.coverage.lastError,
|
error: state.coverage.lastError,
|
||||||
keyRequired: false,
|
keyRequired: false,
|
||||||
keyRejected: false,
|
keyRejected: false,
|
||||||
@@ -353,18 +425,25 @@ export function createKartaViewProvider({
|
|||||||
loading: state.sequence.loading,
|
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 } = {}) {
|
async nearestImage({ lat, lon }, { signal } = {}) {
|
||||||
const records = await source.nearestPhotos({ lat, lon }, { signal });
|
const records = await source.nearestPhotos({ lat, lon }, { signal });
|
||||||
let best = null;
|
const near = [];
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
const photo = remember(record);
|
const photo = remember(record);
|
||||||
if (!photo) continue;
|
if (!photo?.urls.view) continue;
|
||||||
const metres = metresBetween({ lat, lon }, photo);
|
const metres = metresBetween({ lat, lon }, photo);
|
||||||
if (metres <= NEAREST_RADIUS_M && (!best || metres < best.metres))
|
if (metres <= NEAREST_RADIUS_M) near.push({ photo, metres });
|
||||||
best = { 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,
|
viewer,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const COVERAGE_TUNING = Object.freeze({
|
|||||||
viewNearM: 1_000,
|
viewNearM: 1_000,
|
||||||
});
|
});
|
||||||
export const TOO_HIGH_HINT = 'zoom in to see KartaView coverage';
|
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 =
|
export const RATE_LIMITED_MESSAGE =
|
||||||
'KartaView is rate-limiting requests — they resume on their own';
|
'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_CONCURRENCY = 2;
|
||||||
export const PHOTO_API_RETRIES = 3;
|
export const PHOTO_API_RETRIES = 3;
|
||||||
export const PHOTO_API_RETRY_BASE_MS = 800;
|
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 TILE_TIMEOUT_MS = 20_000;
|
||||||
export const PHOTO_API_TIMEOUT_MS = 15_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). */
|
/** 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. */
|
/** Photos asked for around a point; the API returns them nearest first. */
|
||||||
export const NEAREST_LIMIT = 10;
|
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
|
* 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. */
|
/** Equirectangular panorama sizes: a quick preview, then the sharp one. */
|
||||||
export const PANO_PREVIEW_SIZE = Object.freeze([1024, 512]);
|
export const PANO_PREVIEW_SIZE = Object.freeze([1024, 512]);
|
||||||
@@ -161,6 +178,9 @@ export function normalizePhoto(record) {
|
|||||||
if (!record || record.id == null) return null;
|
if (!record || record.id == null) return null;
|
||||||
const { lat, lon } = positionOf(record);
|
const { lat, lon } = positionOf(record);
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
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 =
|
const heading =
|
||||||
record.heading === '' || record.heading == null
|
record.heading === '' || record.heading == null
|
||||||
? NaN
|
? NaN
|
||||||
@@ -195,9 +215,13 @@ export function normalizePhoto(record) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Where the image link goes. KartaView's photo pages fail for signed-out
|
* 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
|
* visitors ("Sequence cannot be loaded"), so it is the full-size photo
|
||||||
* KartaView's own storage.
|
* through KartaView's image proxy, which also serves photos whose storage
|
||||||
|
* host answers 502 directly.
|
||||||
*/
|
*/
|
||||||
export function kartaViewPhotoUrl(photo) {
|
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,
|
photo.urls.full,
|
||||||
'https://storage2.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg',
|
'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.
|
// An unexpected storage value falls back to the URLs as given.
|
||||||
const odd = normalizePhoto(record({ storage: 'https://evil.example' }));
|
const odd = normalizePhoto(record({ storage: 'https://evil.example' }));
|
||||||
assert.match(odd.urls.view, /^https:\/\/storage1\.openstreetcam\.org\//);
|
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', () => {
|
test('records without an id or a position are dropped; a missing heading is null', () => {
|
||||||
assert.equal(normalizePhoto(null), 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(record({ id: undefined })), null);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
normalizePhoto(
|
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 = {} } = {}) {
|
* A source over `sequences` (id → indexes present, in page order: KartaView
|
||||||
const calls = { nearest: 0, photo: [], pages: [] };
|
* 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 {
|
return {
|
||||||
calls,
|
calls,
|
||||||
|
probeImage: async (url) => {
|
||||||
|
calls.probes.push(url);
|
||||||
|
return !archived.some((id) => url.includes(`/${id}.jpg`));
|
||||||
|
},
|
||||||
getTile: async () => ({ lines: [] }),
|
getTile: async () => ({ lines: [] }),
|
||||||
nearestPhotos: async () => {
|
nearestPhotos: async () => {
|
||||||
calls.nearest++;
|
calls.nearest++;
|
||||||
@@ -59,7 +67,7 @@ function fakeSource({ nearest = [], sequences = {} } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fakeContext() {
|
function fakeContext() {
|
||||||
const actions = { opened: [], nearest: [] };
|
const actions = { opened: [], nearest: [], instance: null };
|
||||||
return {
|
return {
|
||||||
actions,
|
actions,
|
||||||
context: {
|
context: {
|
||||||
@@ -71,7 +79,12 @@ function fakeContext() {
|
|||||||
getSurface: () => 'draped',
|
getSurface: () => 'draped',
|
||||||
notify() {},
|
notify() {},
|
||||||
actions: {
|
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),
|
openNearest: (point) => actions.nearest.push(point),
|
||||||
reportError() {},
|
reportError() {},
|
||||||
},
|
},
|
||||||
@@ -96,6 +109,7 @@ function setup(sourceOptions) {
|
|||||||
});
|
});
|
||||||
const { context, actions } = fakeContext();
|
const { context, actions } = fakeContext();
|
||||||
const instance = definition.create(context);
|
const instance = definition.create(context);
|
||||||
|
actions.instance = instance;
|
||||||
return { definition, instance, source, actions, loads };
|
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', () => {
|
test('a cone opens its photo; a line opens the photo nearest the click', () => {
|
||||||
const { instance, actions } = setup();
|
const { instance, actions } = setup();
|
||||||
|
actions.instance = null;
|
||||||
assert.equal(instance.handlePick('kv:img:123'), true);
|
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 };
|
const point = { lon: -83.0458, lat: 42.3314 };
|
||||||
assert.equal(
|
assert.equal(
|
||||||
instance.handlePick('kv:line:14/1/2/3', { groundPoint: () => point }),
|
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);
|
assert.equal(stats.count, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('› walks across sequence pages; ‹ 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) => {
|
const press = async (button) => {
|
||||||
button.fire('click');
|
button.fire('click');
|
||||||
for (let i = 0; i < 6; i++) await settle();
|
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, actions, source, poses, prev, next } = await mounted({
|
||||||
|
sequences: { s: indexes },
|
||||||
|
});
|
||||||
await instance.viewer.open(`s-${size - 1}`);
|
await instance.viewer.open(`s-${size - 1}`);
|
||||||
await press(next);
|
await press(next);
|
||||||
assert.equal(poses.at(-1), `s-${size}`, 'onto the second page');
|
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);
|
await press(prev);
|
||||||
assert.equal(poses.at(-1), `s-${size - 1}`);
|
assert.equal(poses.at(-1), `s-${size - 1}`);
|
||||||
await instance.viewer.open('s-0');
|
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);
|
await press(prev);
|
||||||
assert.equal(poses.at(-1), 's-0', 'nothing before the first photo');
|
assert.equal(poses.at(-1), 's-0');
|
||||||
assert.equal(prev.disabled, true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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;
|
const size = SEQUENCE_PAGE_SIZE;
|
||||||
// Index `size + 100` sits on page 1 when 200 earlier photos are gone.
|
// As KartaView pages by id: page 1 holds 0…size+2 less two, page 2 starts
|
||||||
const indexes = Array.from({ length: size + 300 }, (_, i) => i).filter(
|
// with those two (size-2, size) before the rest.
|
||||||
(i) => i < 100 || i >= 300,
|
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 {
|
import {
|
||||||
|
IMAGE_PROBE_TIMEOUT_MS,
|
||||||
KARTAVIEW_API_HOST,
|
KARTAVIEW_API_HOST,
|
||||||
NEAREST_LIMIT,
|
NEAREST_LIMIT,
|
||||||
NEAREST_SEARCH_ZOOM,
|
NEAREST_SEARCH_ZOOM,
|
||||||
|
PAGE_CONCURRENCY,
|
||||||
|
PAGE_TIMEOUT_MS,
|
||||||
PHOTO_API_CONCURRENCY,
|
PHOTO_API_CONCURRENCY,
|
||||||
PHOTO_API_RETRIES,
|
PHOTO_API_RETRIES,
|
||||||
PHOTO_API_RETRY_BASE_MS,
|
PHOTO_API_RETRY_BASE_MS,
|
||||||
|
PHOTO_API_RETRY_MAX_MS,
|
||||||
PHOTO_API_TIMEOUT_MS,
|
PHOTO_API_TIMEOUT_MS,
|
||||||
|
TILE_CONCURRENCY,
|
||||||
TILE_TIMEOUT_MS,
|
TILE_TIMEOUT_MS,
|
||||||
} from './policy.js';
|
} 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. */
|
/** Resolves after `ms`, or rejects when `signal` aborts first. */
|
||||||
function wait(ms, signal) {
|
function wait(ms, signal) {
|
||||||
@@ -92,8 +97,11 @@ export function createLimiter(limit) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* KartaView's public API, browser-side and keyless (it allows any origin).
|
* 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
|
* It speaks HTTP/1.1, so the browser opens six connections to it: tiles take
|
||||||
* at a time and retry a 429.
|
* 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]
|
* @param {{fetchImpl?: typeof fetch, apiHost?: string, retryBaseMs?: number}} [options]
|
||||||
*/
|
*/
|
||||||
export function createKartaViewSource({
|
export function createKartaViewSource({
|
||||||
@@ -102,6 +110,8 @@ export function createKartaViewSource({
|
|||||||
retryBaseMs = PHOTO_API_RETRY_BASE_MS,
|
retryBaseMs = PHOTO_API_RETRY_BASE_MS,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const limiter = createLimiter(PHOTO_API_CONCURRENCY);
|
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. */
|
/** GET JSON within `timeoutMs`; the caller's abort wins over the timeout. */
|
||||||
async function getJson(url, { signal, timeoutMs }) {
|
async function getJson(url, { signal, timeoutMs }) {
|
||||||
@@ -137,33 +147,53 @@ 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 query = params ? `?${new URLSearchParams(params)}` : '';
|
||||||
const url = `${apiHost}${path}${query}`;
|
const url = `${apiHost}${path}${query}`;
|
||||||
|
let timeouts = 0;
|
||||||
for (let attempt = 0; ; attempt++) {
|
for (let attempt = 0; ; attempt++) {
|
||||||
try {
|
try {
|
||||||
const payload = await limiter.run(
|
const payload = await gate.run(
|
||||||
() => getJson(url, { signal, timeoutMs: PHOTO_API_TIMEOUT_MS }),
|
() => getJson(url, { signal, timeoutMs }),
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
const code = Number(payload?.status?.apiCode);
|
const code = Number(payload?.status?.apiCode);
|
||||||
// 600 ok, 601 empty, 602 partial: anything else is a refusal.
|
// 600 ok, 601 empty, 602 partial: anything else is a refusal.
|
||||||
if (Number.isFinite(code) && (code < 600 || code > 603))
|
if (Number.isFinite(code) && (code < 600 || code > 603))
|
||||||
throw new KartaViewSourceError(
|
throw new KartaViewSourceError(
|
||||||
String(
|
`KartaView refused the request (code ${code})`,
|
||||||
payload?.status?.apiMessage || 'KartaView refused the request',
|
|
||||||
).trim(),
|
|
||||||
{ status: Number(payload?.status?.httpCode) || 0 },
|
{ status: Number(payload?.status?.httpCode) || 0 },
|
||||||
);
|
);
|
||||||
return payload?.result ?? {};
|
return payload?.result ?? {};
|
||||||
} catch (error) {
|
} 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(
|
await wait(
|
||||||
|
Math.min(
|
||||||
|
PHOTO_API_RETRY_MAX_MS,
|
||||||
Math.max(
|
Math.max(
|
||||||
retryBaseMs * 2 ** attempt,
|
retryBaseMs * 2 ** attempt,
|
||||||
(error.retryAfterSec ?? 0) * 1000,
|
(error.retryAfterSec ?? 0) * 1000,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
signal,
|
signal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -173,10 +203,42 @@ export function createKartaViewSource({
|
|||||||
return {
|
return {
|
||||||
/** A coverage tile: `{lines: [{track: [{lat, lng}]}]}`. */
|
/** A coverage tile: `{lines: [{track: [{lat, lng}]}]}`. */
|
||||||
async getTile({ x, y, z }, { signal } = {}) {
|
async getTile({ x, y, z }, { signal } = {}) {
|
||||||
return getJson(`${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`, {
|
return tileLimiter.run(
|
||||||
|
() =>
|
||||||
|
getJson(`${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`, {
|
||||||
signal,
|
signal,
|
||||||
timeoutMs: TILE_TIMEOUT_MS,
|
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. */
|
/** Photo records around a point, nearest first. */
|
||||||
@@ -216,6 +278,7 @@ export function createKartaViewSource({
|
|||||||
`/2.0/sequence/${encodeURIComponent(sequenceId)}/photos`,
|
`/2.0/sequence/${encodeURIComponent(sequenceId)}/photos`,
|
||||||
{ page: String(page), itemsPerPage: String(itemsPerPage) },
|
{ page: String(page), itemsPerPage: String(itemsPerPage) },
|
||||||
{ signal },
|
{ signal },
|
||||||
|
{ gate: pageLimiter, timeoutMs: PAGE_TIMEOUT_MS },
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
photos: Array.isArray(result.data) ? result.data : [],
|
photos: Array.isArray(result.data) ? result.data : [],
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import {
|
|||||||
createKartaViewSource,
|
createKartaViewSource,
|
||||||
createLimiter,
|
createLimiter,
|
||||||
} from './source.js';
|
} 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) => ({
|
const ok = (body) => ({
|
||||||
ok: true,
|
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.ok(error instanceof KartaViewSourceError);
|
||||||
assert.equal(error.status, 429);
|
assert.equal(error.status, 429);
|
||||||
assert.ok(error.retryAfterSec > 0);
|
assert.ok(error.retryAfterSec > 0);
|
||||||
|
assert.match(error.message, /try again in a moment/);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
assert.equal(calls, PHOTO_API_RETRIES + 1);
|
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({
|
const refusing = createKartaViewSource({
|
||||||
fetchImpl: async () =>
|
fetchImpl: async () =>
|
||||||
ok({
|
ok({
|
||||||
@@ -100,7 +105,10 @@ test('an API refusal inside a 200 is an error with KartaView’s message; 601 is
|
|||||||
result: {},
|
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({
|
const empty = createKartaViewSource({
|
||||||
fetchImpl: async () =>
|
fetchImpl: async () =>
|
||||||
ok({ status: { apiCode: 601 }, result: { data: [] } }),
|
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));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
assert.equal(limiter.pending, 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 {object} options
|
||||||
* @param {(photoId: string) => Promise<object>} options.getPhoto A normalized photo (policy.js normalizePhoto).
|
* @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 {(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 {object} [options.render] Render governor.
|
||||||
* @param {(url: string, options?: object) => Promise<HTMLImageElement>} [options.load]
|
* @param {(url: string, options?: object) => Promise<HTMLImageElement>} [options.load]
|
||||||
* @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer]
|
* @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer]
|
||||||
@@ -58,7 +58,7 @@ export function loadImage(
|
|||||||
export function createKartaViewViewer({
|
export function createKartaViewViewer({
|
||||||
getPhoto,
|
getPhoto,
|
||||||
step,
|
step,
|
||||||
onStepped = () => {},
|
onStep,
|
||||||
render,
|
render,
|
||||||
load = loadImage,
|
load = loadImage,
|
||||||
createRenderer = createPanoramaRenderer,
|
createRenderer = createPanoramaRenderer,
|
||||||
@@ -80,6 +80,12 @@ export function createKartaViewViewer({
|
|||||||
let drag = null;
|
let drag = null;
|
||||||
let poseFrame = 0;
|
let poseFrame = 0;
|
||||||
let removeListeners = [];
|
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() {
|
function requestRender() {
|
||||||
render?.governorRequestRender?.('kartaview-viewer');
|
render?.governorRequestRender?.('kartaview-viewer');
|
||||||
@@ -133,6 +139,21 @@ export function createKartaViewViewer({
|
|||||||
const enabled = Boolean(photo?.sequenceId) && showing !== null;
|
const enabled = Boolean(photo?.sequenceId) && showing !== null;
|
||||||
if (prevButton) prevButton.disabled = !enabled;
|
if (prevButton) prevButton.disabled = !enabled;
|
||||||
if (nextButton) nextButton.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) {
|
function show(kind) {
|
||||||
@@ -159,29 +180,33 @@ export function createKartaViewViewer({
|
|||||||
return renderer;
|
return renderer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Step along the sequence from the open photo. */
|
/**
|
||||||
async function stepBy(delta) {
|
* Step along the sequence from the photo showing, or from the one on its
|
||||||
const from = photo;
|
* way after an earlier press, and open it through the core.
|
||||||
if (!from?.sequenceId || !showing) return;
|
*/
|
||||||
const seq = openSeq;
|
function stepBy(delta) {
|
||||||
|
steps = steps.then(async () => {
|
||||||
|
const fromId = openingId ?? photo?.id;
|
||||||
|
if (!fromId || !root) return;
|
||||||
|
let from;
|
||||||
let nextId = null;
|
let nextId = null;
|
||||||
try {
|
try {
|
||||||
nextId = await step(from, delta);
|
from = await getPhoto(fromId);
|
||||||
|
if (from.sequenceId) nextId = await step(from, delta);
|
||||||
} catch {
|
} catch {
|
||||||
nextId = null;
|
nextId = null;
|
||||||
}
|
}
|
||||||
if (seq !== openSeq || photo !== from) return;
|
// Closed, or another photo was opened meanwhile.
|
||||||
|
if ((openingId ?? photo?.id) !== fromId) return;
|
||||||
if (!nextId) {
|
if (!nextId) {
|
||||||
const button = delta < 0 ? prevButton : nextButton;
|
const button = delta < 0 ? prevButton : nextButton;
|
||||||
if (button) button.disabled = true;
|
if (button && photo?.id === fromId) button.disabled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
openingId = nextId;
|
||||||
await adapter.open(nextId, { keepView: true });
|
keepViewFor = nextId;
|
||||||
} catch {
|
onStep(nextId);
|
||||||
return;
|
});
|
||||||
}
|
|
||||||
if (photo?.id === nextId) onStepped(nextId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPointerDown(event) {
|
function onPointerDown(event) {
|
||||||
@@ -324,15 +349,12 @@ export function createKartaViewViewer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function showPano(target, seq, keepView) {
|
async function showPano(target, seq, keepView) {
|
||||||
const preview = resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE);
|
// Only the proxy can shrink a 13,000 px panorama: if it fails, the
|
||||||
let image;
|
// caller shows the front view rather than waiting on it twice.
|
||||||
try {
|
const image = await load(
|
||||||
image = await load(preview, { crossOrigin: true });
|
resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE),
|
||||||
} catch {
|
{ crossOrigin: true },
|
||||||
image = await load(resizedImageUrl(target.urls.pano, PANO_FULL_SIZE), {
|
);
|
||||||
crossOrigin: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (seq !== openSeq) return false;
|
if (seq !== openSeq) return false;
|
||||||
if (!keepView || showing !== 'pano')
|
if (!keepView || showing !== 'pano')
|
||||||
renderer.setView({ yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG });
|
renderer.setView({ yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG });
|
||||||
@@ -359,19 +381,8 @@ export function createKartaViewViewer({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const adapter = {
|
async function openPhoto(id, seq, keepView) {
|
||||||
async mount(host) {
|
const target = await getPhoto(id);
|
||||||
if (!host) throw new Error('Street Level viewer has no host element');
|
|
||||||
if (root && root.parentNode === host) return;
|
|
||||||
teardown();
|
|
||||||
build(host);
|
|
||||||
},
|
|
||||||
|
|
||||||
/** Resolves once the photo is on screen and its pose was emitted. */
|
|
||||||
async open(imageId, { keepView = false } = {}) {
|
|
||||||
if (!root) throw new Error('KartaView viewer is not mounted');
|
|
||||||
const seq = ++openSeq;
|
|
||||||
const target = await getPhoto(String(imageId));
|
|
||||||
if (seq !== openSeq) return;
|
if (seq !== openSeq) return;
|
||||||
let shown = false;
|
let shown = false;
|
||||||
if (target.isPano && target.urls.pano && ensureRenderer()) {
|
if (target.isPano && target.urls.pano && ensureRenderer()) {
|
||||||
@@ -386,10 +397,35 @@ export function createKartaViewViewer({
|
|||||||
if (!shown) return;
|
if (!shown) return;
|
||||||
emit();
|
emit();
|
||||||
upgrade(target, seq, showing);
|
upgrade(target, seq, showing);
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = {
|
||||||
|
async mount(host) {
|
||||||
|
if (!host) throw new Error('Street Level viewer has no host element');
|
||||||
|
if (root && root.parentNode === host) return;
|
||||||
|
teardown();
|
||||||
|
build(host);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Resolves once the photo is on screen and its pose was emitted. */
|
||||||
|
async open(imageId) {
|
||||||
|
if (!root) throw new Error('KartaView viewer is not mounted');
|
||||||
|
const id = String(imageId);
|
||||||
|
const seq = ++openSeq;
|
||||||
|
const keepView = keepViewFor === id;
|
||||||
|
keepViewFor = null;
|
||||||
|
openingId = id;
|
||||||
|
try {
|
||||||
|
await openPhoto(id, seq, keepView);
|
||||||
|
} finally {
|
||||||
|
if (openingId === id && seq === openSeq) openingId = null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
openSeq++;
|
openSeq++;
|
||||||
|
openingId = null;
|
||||||
|
keepViewFor = null;
|
||||||
photo = null;
|
photo = null;
|
||||||
show(null);
|
show(null);
|
||||||
if (picture) picture.removeAttribute('src');
|
if (picture) picture.removeAttribute('src');
|
||||||
@@ -398,6 +434,8 @@ export function createKartaViewViewer({
|
|||||||
|
|
||||||
unmount() {
|
unmount() {
|
||||||
openSeq++;
|
openSeq++;
|
||||||
|
openingId = null;
|
||||||
|
keepViewFor = null;
|
||||||
teardown();
|
teardown();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,11 @@ function setup({ failLoads = [], rendererThrows = false, steps = {} } = {}) {
|
|||||||
return PHOTOS[id];
|
return PHOTOS[id];
|
||||||
},
|
},
|
||||||
step: async (photo, delta) => steps[`${photo.id}:${delta}`] ?? null,
|
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) => {
|
load: async (url, options) => {
|
||||||
loads.push({ url, crossOrigin: options?.crossOrigin });
|
loads.push({ url, crossOrigin: options?.crossOrigin });
|
||||||
if (failLoads.some((part) => url.includes(part)))
|
if (failLoads.some((part) => url.includes(part)))
|
||||||
@@ -108,7 +112,9 @@ const parts = (host) => {
|
|||||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
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 () => {
|
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);
|
await viewer.mount(host);
|
||||||
const { picture, canvas, prev, next } = parts(host);
|
const { picture, canvas, prev, next } = parts(host);
|
||||||
assert.equal(prev.disabled, true, 'nothing to step through yet');
|
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,
|
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].url, PHOTOS.flat1.urls.view);
|
||||||
assert.equal(loads[0].crossOrigin, false, 'an <img> needs no CORS');
|
assert.equal(loads[0].crossOrigin, false, 'an <img> needs no CORS');
|
||||||
await settle();
|
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');
|
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({
|
const { viewer, poses, host, stepped } = setup({
|
||||||
steps: { 'flat1:1': 'flat2' },
|
steps: { 'flat1:1': 'flat2', 'flat2:-1': 'flat1' },
|
||||||
});
|
});
|
||||||
await viewer.mount(host);
|
await viewer.mount(host);
|
||||||
await viewer.open('flat1');
|
await viewer.open('flat1');
|
||||||
const { root, prev, next } = parts(host);
|
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');
|
next.fire('click');
|
||||||
await settle();
|
for (let i = 0; i < 4; i++) await settle();
|
||||||
await settle();
|
|
||||||
assert.equal(poses.at(-1).imageId, 'flat2');
|
assert.equal(poses.at(-1).imageId, 'flat2');
|
||||||
assert.deepEqual(stepped, ['flat2']);
|
assert.deepEqual(stepped, ['flat2'], 'the core is asked to open it');
|
||||||
root.fire('keydown', { key: 'ArrowRight' });
|
|
||||||
await settle();
|
|
||||||
await settle();
|
|
||||||
assert.equal(next.disabled, true, 'flat2 is the last photo');
|
assert.equal(next.disabled, true, 'flat2 is the last photo');
|
||||||
|
assert.equal(prev.disabled, false);
|
||||||
root.fire('keydown', { key: 'ArrowLeft', metaKey: true });
|
root.fire('keydown', { key: 'ArrowLeft', metaKey: true });
|
||||||
await settle();
|
await settle();
|
||||||
assert.equal(poses.at(-1).imageId, 'flat2', 'modified arrows are not ours');
|
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 () => {
|
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 {boolean} [defaultOn] Default true.
|
||||||
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
|
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
|
||||||
* @property {Array<'pano'|'since'>} [filters] Filters it honours (default both).
|
* @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
|
* @property {(context: ProviderContext) => ProviderInstance} create
|
||||||
*
|
*
|
||||||
* @typedef {object} ProviderContext Handed to `create()` once by the core.
|
* @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 {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 {{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 {() => 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
|
* @typedef {object} ProviderInstance
|
||||||
* @property {() => Promise<{configured: boolean}>} status
|
* @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 {string} options.renderKey Render-governor reason.
|
||||||
* @param {(sequenceId: string, selected: boolean) => void} [options.onHighlight]
|
* @param {(sequenceId: string, selected: boolean) => void} [options.onHighlight]
|
||||||
* Highlight (or stop highlighting) the sequence's coverage line.
|
* 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({
|
export function createSequenceCones({
|
||||||
state,
|
state,
|
||||||
@@ -58,6 +60,7 @@ export function createSequenceCones({
|
|||||||
spriteId,
|
spriteId,
|
||||||
renderKey,
|
renderKey,
|
||||||
onHighlight = () => {},
|
onHighlight = () => {},
|
||||||
|
passes = passesImageryFilter,
|
||||||
}) {
|
}) {
|
||||||
const { render, sprites } = state.services;
|
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.
|
// Resolved now, so a "since N days" window keeps up with the clock.
|
||||||
const filter = state.context.getFilter();
|
const filter = state.context.getFilter();
|
||||||
images.forEach((image, index) => {
|
images.forEach((image, index) => {
|
||||||
if (!passesImageryFilter(image, filter)) return;
|
if (!passes(image, filter)) return;
|
||||||
const height = heights?.[index] ?? null;
|
const height = heights?.[index] ?? null;
|
||||||
const billboard = collection.add({
|
const billboard = collection.add({
|
||||||
id: `${pickPrefix}${image.id}`,
|
id: `${pickPrefix}${image.id}`,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { COLORS } from './policy.js';
|
|||||||
* @property {boolean} [groundClick]
|
* @property {boolean} [groundClick]
|
||||||
* @property {Array<'pano'|'since'>} [filters]
|
* @property {Array<'pano'|'since'>} [filters]
|
||||||
* @property {{noun: string, action: string}|null} [coverageLabel]
|
* @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,
|
label: provider.name,
|
||||||
color: provider.color,
|
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 });
|
legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected });
|
||||||
return {
|
return {
|
||||||
enabled,
|
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