mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge upstream/main (KartaView #1121 merged) into pr/panoramax
KartaView's later viewer changes are carried into the shared photoViewer.js: the camera bearing (`bearingOf`), redrawing the same photo after a GPU reset through `onReopen`, no pose while a lost context recovers, the 2,560 px flat version only once the viewer outgrows 1,280 px, `refreshPose`, and one sharper-panorama load at a time with a second look once it lands. Its tile cache's `keyOf` and `clear` move to the shared tileCache.js. KartaView gets `onEscape` beside `onReopen`. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
da0d370fa3
43 files changed
+3299
-178
No files matched your search
@@ -438,6 +438,7 @@ export function createStreetLevelLayer({
|
||||
color: entry.def.colors.coverage,
|
||||
coverageLabel: entry.def.coverageLabel || null,
|
||||
highlightsSequence: entry.def.highlightsSequence !== false,
|
||||
sinceBasis: entry.def.sinceBasis === 'upload' ? 'upload' : 'capture',
|
||||
groundClick: entry.def.groundClick === true,
|
||||
filters: [...filtersOf(entry.def)],
|
||||
};
|
||||
|
||||
@@ -888,6 +888,15 @@ export function createLineCoverage({
|
||||
notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load every tile again (a source that filters on its server had its
|
||||
* filter changed); the lines drawn now show until the new ones are in.
|
||||
*/
|
||||
function reload() {
|
||||
retire();
|
||||
refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraw tiles draped or cast for the new surface mode, then re-pick the
|
||||
* zoom, since the ground height under the camera may have changed.
|
||||
@@ -1018,6 +1027,7 @@ export function createLineCoverage({
|
||||
clear,
|
||||
resetErrors,
|
||||
rebuild,
|
||||
reload,
|
||||
setSurface,
|
||||
recolorSequence,
|
||||
sequenceCount,
|
||||
|
||||
@@ -146,6 +146,7 @@ export function panoSizeFor(canvasPixels, fovDeg) {
|
||||
* @property {(photo: object, pixels: number) => string|null} flatSharp
|
||||
* A sharper flat version for a picture this many device pixels wide, or null.
|
||||
* @property {(photo: object) => {title?: string|null, creator?: string|null, license?: string|null, externalUrl: string, capturedAtPrecision?: 'day'|'month'}} describe
|
||||
* @property {(photo: object) => number|null} [bearingOf] Where the camera looked (default `photo.heading`).
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -165,6 +166,7 @@ export function panoSizeFor(canvasPixels, fovDeg) {
|
||||
* @param {(photo: object, delta: -1|1) => Promise<string|null>} options.step The photo `delta` along the sequence, or null at its end.
|
||||
* @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show).
|
||||
* @param {(photo: object) => void} [options.onShown] A photo is on screen.
|
||||
* @param {(imageId: string) => void} [options.onReopen] Draw the same photo again after a GPU reset (default: `onStep`).
|
||||
* @param {() => boolean} [options.onEscape] Esc in the docked viewer: true when it was used (e.g. a selection cleared).
|
||||
* @param {object} [options.render] Render governor.
|
||||
* @param {(url: string, options?: object) => Promise<HTMLImageElement|ImageBitmap>} [options.load]
|
||||
@@ -179,6 +181,7 @@ export function createPhotoViewer({
|
||||
step,
|
||||
onStep,
|
||||
onShown = () => {},
|
||||
onReopen = onStep,
|
||||
onEscape = () => false,
|
||||
render,
|
||||
load = loadImage,
|
||||
@@ -212,6 +215,8 @@ export function createPhotoViewer({
|
||||
let panoWidth = 0;
|
||||
/** The sharper panorama on its way, for which open (resizes come in bursts). */
|
||||
let panoComing = null;
|
||||
/** A resize came while it loaded: look again once it lands. */
|
||||
let lookAgain = false;
|
||||
/** The sharper flat version on screen or on its way (one download each). */
|
||||
let flatSharpUrl = null;
|
||||
/** The photo an open is fetching: ‹ › step on from it, not the old one. */
|
||||
@@ -230,8 +235,13 @@ export function createPhotoViewer({
|
||||
|
||||
function emit() {
|
||||
if (!photo || !showing) return;
|
||||
// A lost GPU context, still being recovered: the open that redraws the
|
||||
// panorama poses once it is on screen.
|
||||
if (showing === 'pano' && !renderer) return;
|
||||
const view = showing === 'pano' ? renderer.getView() : null;
|
||||
const heading = photo.heading;
|
||||
const heading = sources.bearingOf
|
||||
? sources.bearingOf(photo)
|
||||
: photo.heading;
|
||||
const described = sources.describe(photo);
|
||||
const pose = {
|
||||
providerId,
|
||||
@@ -350,7 +360,7 @@ export function createPhotoViewer({
|
||||
const again = photo.id;
|
||||
show(null);
|
||||
openingId = again;
|
||||
onStep(again);
|
||||
onReopen(again);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,11 +592,15 @@ export function createPhotoViewer({
|
||||
photo = null;
|
||||
}
|
||||
|
||||
/** Device pixels across the viewer (its picture or canvas). */
|
||||
/** Device pixels across the flat photo's frame (its longer side). */
|
||||
function viewerPixels() {
|
||||
const ratio = Math.min(globalThis.devicePixelRatio || 1, 2);
|
||||
const ratio = globalThis.devicePixelRatio || 1;
|
||||
const element = showing === 'pano' ? canvas : picture;
|
||||
return (element?.clientWidth || root?.clientWidth || 0) * ratio;
|
||||
const side = Math.max(
|
||||
element?.clientWidth || 0,
|
||||
element?.clientHeight || 0,
|
||||
);
|
||||
return (side || root?.clientWidth || 0) * ratio;
|
||||
}
|
||||
|
||||
/** A sharper version once the first is on screen, if nothing replaced it. */
|
||||
@@ -596,13 +610,19 @@ export function createPhotoViewer({
|
||||
if (kind === 'pano') {
|
||||
const size = panoSizeNow();
|
||||
if (size[0] <= panoWidth) return;
|
||||
if (panoComing?.seq === seq && size[0] <= panoComing.width) return;
|
||||
// Resizes come in bursts while the panel animates: one load at a time
|
||||
// for this photo, and one more look once it lands.
|
||||
if (panoComing?.seq === seq) {
|
||||
lookAgain = true;
|
||||
return;
|
||||
}
|
||||
const sharp = sources.panoSharp(target, size, {
|
||||
expanded: Boolean(root?.closest?.('.sl-viewer-wrap-expanded')),
|
||||
});
|
||||
if (!sharp?.url) return;
|
||||
const coming = { seq, width: size[0], controller: new AbortController() };
|
||||
panoComing = coming;
|
||||
lookAgain = false;
|
||||
try {
|
||||
const image = await loadAs(sharp.url, {
|
||||
crossOrigin: true,
|
||||
@@ -624,7 +644,13 @@ export function createPhotoViewer({
|
||||
} catch {
|
||||
/* the first version stays */
|
||||
} finally {
|
||||
if (panoComing === coming) panoComing = null;
|
||||
if (panoComing === coming) {
|
||||
panoComing = null;
|
||||
if (lookAgain && seq === openSeq && photo === target) {
|
||||
lookAgain = false;
|
||||
upgrade(target, seq, 'pano');
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -646,6 +672,7 @@ export function createPhotoViewer({
|
||||
function dropSharpPanorama() {
|
||||
panoComing?.controller?.abort();
|
||||
panoComing = null;
|
||||
lookAgain = false;
|
||||
}
|
||||
|
||||
/** The sharp panorama this viewer needs now: 2,048 docked, 4,096 expanded. */
|
||||
@@ -791,6 +818,11 @@ export function createPhotoViewer({
|
||||
applyRenderMode();
|
||||
},
|
||||
|
||||
/** The photo's direction became known (its drive loaded): pose again. */
|
||||
refreshPose() {
|
||||
emit();
|
||||
},
|
||||
|
||||
onPose(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { MASK_KEEP_SHARE, MASK_SAMPLE_PX } from './policy.js';
|
||||
|
||||
/** More than any real tile holds (a dense z13 tile has ~4,700 segments). */
|
||||
export const MAX_TILE_SEGMENTS = 20_000;
|
||||
/** A chained line stops growing here and the next one starts. */
|
||||
export const MAX_LINE_POINTS = 5_000;
|
||||
|
||||
/**
|
||||
* Decode a KartaView coverage tile (`{lines: [{track: [{lat, lng}]}]}`) into
|
||||
* line records for the shared coverage. The tile's lines are OSM way
|
||||
* segments with photos; segments that meet end to start are chained, which
|
||||
* cuts the line count about fourfold. Lines carry no sequence, date or
|
||||
* projection, so nothing can filter them.
|
||||
* The way segments of a KartaView coverage tile
|
||||
* (`{lines: [{track: [{lat, lng}]}]}`), as [lon, lat] point lists. Lines
|
||||
* carry no sequence, date or projection: a filter thins them through a mask
|
||||
* (see `maskedSegments`).
|
||||
* @param {object} payload
|
||||
* @param {{x: number, y: number, z: number}} address
|
||||
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<never>}}
|
||||
* @returns {Array<Array<[number, number]>>}
|
||||
*/
|
||||
export function decodeCoverageTile(payload, { x, y, z }) {
|
||||
export function tileSegments(payload) {
|
||||
// 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 payload.lines) {
|
||||
if (segments.length >= MAX_TILE_SEGMENTS) break;
|
||||
const points = [];
|
||||
for (const point of Array.isArray(line?.track) ? line.track : []) {
|
||||
const lon = Number(point?.lng);
|
||||
@@ -25,6 +31,18 @@ export function decodeCoverageTile(payload, { x, y, z }) {
|
||||
}
|
||||
if (points.length >= 2) segments.push(points);
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Segments chained into line records for the shared coverage: segments
|
||||
* that meet end to start are joined, which cuts the line count about
|
||||
* fourfold.
|
||||
* @param {Array<Array<[number, number]>>} segments
|
||||
* @param {{x: number, y: number, z: number}} address
|
||||
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<never>}}
|
||||
*/
|
||||
export function chainSegments(segments, { x, y, z }) {
|
||||
const key = ([lon, lat]) => `${lon},${lat}`;
|
||||
const startingAt = new Map();
|
||||
segments.forEach((segment, index) => {
|
||||
@@ -34,16 +52,28 @@ export function decodeCoverageTile(payload, { x, y, z }) {
|
||||
else startingAt.set(at, [index]);
|
||||
});
|
||||
const used = new Uint8Array(segments.length);
|
||||
// Per start point, how far its list has been used: each segment is looked
|
||||
// at once, so a tile of segments sharing one point stays linear.
|
||||
const cursor = new Map();
|
||||
const nextFrom = (point) => {
|
||||
const at = key(point);
|
||||
const list = startingAt.get(at);
|
||||
if (!list) return undefined;
|
||||
let i = cursor.get(at) ?? 0;
|
||||
while (i < list.length && used[list[i]]) i++;
|
||||
cursor.set(at, i);
|
||||
return list[i];
|
||||
};
|
||||
const sequences = [];
|
||||
segments.forEach((segment, index) => {
|
||||
if (used[index]) return;
|
||||
used[index] = 1;
|
||||
const chain = [...segment];
|
||||
for (;;) {
|
||||
const next = startingAt
|
||||
.get(key(chain[chain.length - 1]))
|
||||
?.find((candidate) => !used[candidate]);
|
||||
const next = nextFrom(chain[chain.length - 1]);
|
||||
if (next === undefined) break;
|
||||
// Full: the segment starts the next line instead.
|
||||
if (chain.length + segments[next].length - 1 > MAX_LINE_POINTS) break;
|
||||
used[next] = 1;
|
||||
chain.push(...segments[next].slice(1));
|
||||
}
|
||||
@@ -56,3 +86,91 @@ export function decodeCoverageTile(payload, { x, y, z }) {
|
||||
});
|
||||
return { sequences, overview: [] };
|
||||
}
|
||||
|
||||
/** Decode a KartaView coverage tile into the shared coverage's line records. */
|
||||
export function decodeCoverageTile(payload, address) {
|
||||
return chainSegments(tileSegments(payload), address);
|
||||
}
|
||||
|
||||
/**
|
||||
* The segments a filtered PNG tile of the same address draws over. The PNG
|
||||
* draws the same ways a few pixels wide (on a transparent ground): a
|
||||
* segment is kept when at least half the points sampled along it have a
|
||||
* drawn pixel within one pixel. Segments either lie on it or miss it
|
||||
* almost entirely (Jakarta, 360° only: 332 of 1,041 kept, 676 missed, 33
|
||||
* between). A segment with no point on the tile is not this tile's to keep.
|
||||
* @param {Array<Array<[number, number]>>} segments
|
||||
* @param {{width: number, height: number, alpha: ArrayLike<number>}} mask one alpha byte per pixel
|
||||
* @param {{x: number, y: number, z: number}} address
|
||||
*/
|
||||
export function maskedSegments(segments, mask, { x, y, z }) {
|
||||
const { width, height, alpha } = mask;
|
||||
const n = 2 ** z;
|
||||
const toPixel = ([lon, lat]) => {
|
||||
const sin = Math.sin((lat * Math.PI) / 180);
|
||||
const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI);
|
||||
return [(((lon + 180) / 360) * n - x) * width, (my * n - y) * height];
|
||||
};
|
||||
const drawnNear = (px, py) => {
|
||||
const cx = Math.floor(px);
|
||||
const cy = Math.floor(py);
|
||||
for (let dy = -1; dy <= 1; dy++)
|
||||
for (let dx = -1; dx <= 1; dx++) {
|
||||
const X = cx + dx;
|
||||
const Y = cy + dy;
|
||||
if (X < 0 || Y < 0 || X >= width || Y >= height) continue;
|
||||
if (alpha[Y * width + X] > 0) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return segments.filter((segment) => {
|
||||
let on = 0;
|
||||
let tested = 0;
|
||||
let [ax, ay] = toPixel(segment[0]);
|
||||
for (let i = 1; i < segment.length; i++) {
|
||||
const [bx, by] = toPixel(segment[i]);
|
||||
const steps = Math.max(
|
||||
1,
|
||||
Math.ceil(Math.hypot(bx - ax, by - ay) / MASK_SAMPLE_PX),
|
||||
);
|
||||
// Each point once: a segment's next stretch starts where this one ends.
|
||||
for (let j = i === 1 ? 0 : 1; j <= steps; j++) {
|
||||
const px = ax + ((bx - ax) * j) / steps;
|
||||
const py = ay + ((by - ay) * j) / steps;
|
||||
if (px < 0 || py < 0 || px >= width || py >= height) continue;
|
||||
tested++;
|
||||
if (drawnNear(px, py)) on++;
|
||||
}
|
||||
ax = bx;
|
||||
ay = by;
|
||||
}
|
||||
return tested > 0 && on / tested >= MASK_KEEP_SHARE;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A PNG mask's alpha, one byte per pixel, decoded off the main thread
|
||||
* where the browser can.
|
||||
* @param {Uint8Array} bytes the PNG
|
||||
* @returns {Promise<{width: number, height: number, alpha: Uint8Array}>}
|
||||
*/
|
||||
export async function readMaskImage(bytes) {
|
||||
const bitmap = await createImageBitmap(
|
||||
new Blob([bytes], { type: 'image/png' }),
|
||||
);
|
||||
try {
|
||||
const { width, height } = bitmap;
|
||||
const canvas =
|
||||
typeof OffscreenCanvas === 'function'
|
||||
? new OffscreenCanvas(width, height)
|
||||
: Object.assign(document.createElement('canvas'), { width, height });
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const rgba = context.getImageData(0, 0, width, height).data;
|
||||
const alpha = new Uint8Array(width * height);
|
||||
for (let i = 0; i < alpha.length; i++) alpha[i] = rgba[i * 4 + 3];
|
||||
return { width, height, alpha };
|
||||
} finally {
|
||||
bitmap.close?.();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { decodeCoverageTile } from './decode.js';
|
||||
import {
|
||||
MAX_LINE_POINTS,
|
||||
MAX_TILE_SEGMENTS,
|
||||
chainSegments,
|
||||
decodeCoverageTile,
|
||||
maskedSegments,
|
||||
tileSegments,
|
||||
} from './decode.js';
|
||||
|
||||
const at = (lng, lat) => ({ lat: String(lat), lng: String(lng) });
|
||||
const TILE = { x: 4412, y: 6061, z: 14 };
|
||||
@@ -85,3 +92,96 @@ test('a tile that is not a line list fails, so it is asked for again rather than
|
||||
/unreadable coverage tile/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a tile whose segments all share one point decodes in linear time', () => {
|
||||
// [P, Q_i, P]: every chain step looks at the same start point's list.
|
||||
// 60k: the old quadratic chaining took over a second; capped and linear
|
||||
// decoding takes tens of milliseconds.
|
||||
const lines = Array.from({ length: 60_000 }, (_, i) => ({
|
||||
track: [at(0, 0), at(1 + i * 1e-6, 1), at(0, 0)],
|
||||
}));
|
||||
const started = performance.now();
|
||||
const { sequences } = decodeCoverageTile({ lines }, TILE);
|
||||
const ms = performance.now() - started;
|
||||
assert.ok(
|
||||
ms < 1_000,
|
||||
`${Math.round(ms)} ms (quadratic chaining took ~200 ms per 20k and grows fourfold per doubling)`,
|
||||
);
|
||||
const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0);
|
||||
assert.ok(sequences.every((s) => s.parts[0].length <= MAX_LINE_POINTS));
|
||||
assert.equal(
|
||||
points,
|
||||
MAX_TILE_SEGMENTS * 2 + sequences.length,
|
||||
'every segment drawn once',
|
||||
);
|
||||
});
|
||||
|
||||
test('a tile is capped in segments and in points per line', () => {
|
||||
const lines = Array.from({ length: MAX_TILE_SEGMENTS + 500 }, (_, i) => ({
|
||||
track: [at(i, 0), at(i + 1, 0)],
|
||||
}));
|
||||
const { sequences } = decodeCoverageTile({ lines }, TILE);
|
||||
const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0);
|
||||
// One long chain, cut into lines of at most MAX_LINE_POINTS.
|
||||
assert.ok(sequences.every((s) => s.parts[0].length <= MAX_LINE_POINTS));
|
||||
assert.ok(points <= MAX_TILE_SEGMENTS + sequences.length);
|
||||
assert.ok(
|
||||
sequences.length >= Math.floor(MAX_TILE_SEGMENTS / MAX_LINE_POINTS),
|
||||
);
|
||||
});
|
||||
|
||||
/** [lon, lat] at pixel (px, py) of a 512 px mask of `tile` (Web Mercator). */
|
||||
function lonLatAt(px, py, { x, y, z } = TILE, size = 512) {
|
||||
const n = 2 ** z;
|
||||
const lon = ((x + px / size) / n) * 360 - 180;
|
||||
const my = Math.PI * (1 - (2 * (y + py / size)) / n);
|
||||
const lat = (Math.atan(Math.sinh(my)) * 180) / Math.PI;
|
||||
return [lon, lat];
|
||||
}
|
||||
|
||||
/** A 512 px mask drawn on the given rows. */
|
||||
function rowsMask(rows) {
|
||||
const alpha = new Uint8Array(512 * 512);
|
||||
for (const [from, to] of rows) alpha.fill(255, from * 512, (to + 1) * 512);
|
||||
return { width: 512, height: 512, alpha };
|
||||
}
|
||||
|
||||
test('a mask keeps the segments it draws over, chained as before', () => {
|
||||
const along = (row, from = 40, to = 470) => [
|
||||
lonLatAt(from, row),
|
||||
lonLatAt(to, row),
|
||||
];
|
||||
const segments = [
|
||||
along(100), // drawn at row 100
|
||||
along(101.6), // a pixel off: still drawn within one pixel
|
||||
along(300), // not drawn
|
||||
// Two thirds along the drawn row, then off it: kept (at least half).
|
||||
[lonLatAt(40, 100), lonLatAt(400, 100), lonLatAt(400, 280)],
|
||||
// A third along it, then off: dropped.
|
||||
[lonLatAt(300, 100), lonLatAt(400, 100), lonLatAt(400, 300)],
|
||||
// Off the tile entirely: not this tile's to keep.
|
||||
[lonLatAt(-200, 100), lonLatAt(-20, 100)],
|
||||
];
|
||||
const kept = maskedSegments(segments, rowsMask([[99, 100]]), TILE);
|
||||
assert.deepEqual(kept, [segments[0], segments[1], segments[3]]);
|
||||
assert.deepEqual(maskedSegments(segments, rowsMask([]), TILE), []);
|
||||
// A full mask keeps every segment that is on the tile.
|
||||
assert.equal(maskedSegments(segments, rowsMask([[0, 511]]), TILE).length, 5);
|
||||
});
|
||||
|
||||
test('decoding is segments then chaining: the same lines either way', () => {
|
||||
const payload = {
|
||||
lines: [
|
||||
{ track: [at(1, 1), at(2, 1)] },
|
||||
{ track: [at(2, 1), at(3, 1)] },
|
||||
{ track: [at(5, 5), at(6, 6)] },
|
||||
],
|
||||
};
|
||||
const segments = tileSegments(payload);
|
||||
assert.equal(segments.length, 3);
|
||||
assert.deepEqual(
|
||||
chainSegments(segments, TILE),
|
||||
decodeCoverageTile(payload, TILE),
|
||||
);
|
||||
assert.throws(() => tileSegments({}), /unreadable coverage tile/);
|
||||
});
|
||||
@@ -4,7 +4,12 @@ import { createLru, createTileCache } from '../../tileCache.js';
|
||||
export { createTileCache } from '../../tileCache.js';
|
||||
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
|
||||
import { metresBetween } from '../../view.js';
|
||||
import { decodeCoverageTile } from './decode.js';
|
||||
import {
|
||||
chainSegments,
|
||||
maskedSegments,
|
||||
readMaskImage,
|
||||
tileSegments,
|
||||
} from './decode.js';
|
||||
import { createKartaViewSource } from './source.js';
|
||||
import { createKartaViewViewer } from './viewer.js';
|
||||
import {
|
||||
@@ -14,6 +19,7 @@ import {
|
||||
COVERAGE_NOUN,
|
||||
COVERAGE_STREET_ZOOM_BELOW_M,
|
||||
COVERAGE_TUNING,
|
||||
FILTERED_OUT_MESSAGE,
|
||||
KARTAVIEW_CREDIT_HTML,
|
||||
KARTAVIEW_LABEL,
|
||||
KARTAVIEW_NAME,
|
||||
@@ -27,7 +33,11 @@ import {
|
||||
SEQUENCE_PAGE_EDGE,
|
||||
SEQUENCE_PAGE_SIZE,
|
||||
TOO_HIGH_HINT,
|
||||
assignCourses,
|
||||
cameraBearing,
|
||||
normalizePhoto,
|
||||
passesKartaViewFilter,
|
||||
tileFilterQuery,
|
||||
} from './policy.js';
|
||||
|
||||
/** Photos kept in memory by id (sequence pages fill it). */
|
||||
@@ -53,12 +63,13 @@ function pageOf(index) {
|
||||
* KartaView Street Level provider: keyless coverage lines from KartaView's
|
||||
* tile API, the photos nearest a clicked line, cones along the photo's
|
||||
* sequence, and a flat or 360° viewer with ‹ › along the sequence.
|
||||
* @param {{source?: ReturnType<typeof createKartaViewSource>, viewerOptions?: object}} [options]
|
||||
* @param {{source?: ReturnType<typeof createKartaViewSource>, viewerOptions?: object, readMask?: typeof readMaskImage}} [options]
|
||||
* @returns {import('../../registry.js').StreetLevelProvider}
|
||||
*/
|
||||
export function createKartaViewProvider({
|
||||
source = createKartaViewSource(),
|
||||
viewerOptions = {},
|
||||
readMask = readMaskImage,
|
||||
} = {}) {
|
||||
return Object.freeze({
|
||||
id: KARTAVIEW_PROVIDER_ID,
|
||||
@@ -67,10 +78,15 @@ export function createKartaViewProvider({
|
||||
requiresKeyId: null,
|
||||
pickPrefix: PICK_PREFIX.root,
|
||||
colors: COLORS,
|
||||
// Its own marker, as Street View has: a purple cone that turns with the view.
|
||||
marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }),
|
||||
credit: Object.freeze({ html: KARTAVIEW_CREDIT_HTML }),
|
||||
defaultOn: false,
|
||||
// Coverage lines carry no date or projection: no filter could thin them.
|
||||
filters: [],
|
||||
// 360° / flat and SINCE, as Mapillary: KartaView filters the lines on
|
||||
// its server (see tileFilterQuery), the photos by their own record.
|
||||
filters: Object.freeze(['pano', 'since']),
|
||||
// SINCE is the upload date for KartaView: the panel notes it.
|
||||
sinceBasis: 'upload',
|
||||
// Its lines are streets, not sequences: a selected drive shows as cones.
|
||||
highlightsSequence: false,
|
||||
coverageLabel: Object.freeze({
|
||||
@@ -117,9 +133,13 @@ export function createKartaViewProvider({
|
||||
/** The sequence page the cones were drawn from. */
|
||||
let drawnPage = null;
|
||||
|
||||
/** A record as a photo; the one already known keeps its identity (and course). */
|
||||
function remember(record) {
|
||||
const photo = normalizePhoto(record);
|
||||
if (photo) photos.set(photo.id, photo);
|
||||
const fresh = normalizePhoto(record);
|
||||
if (!fresh) return null;
|
||||
const known = photos.get(fresh.id);
|
||||
const photo = known ? Object.assign(known, fresh) : fresh;
|
||||
photos.set(photo.id, photo);
|
||||
return photo;
|
||||
}
|
||||
|
||||
@@ -141,16 +161,25 @@ export function createKartaViewProvider({
|
||||
page,
|
||||
itemsPerPage: SEQUENCE_PAGE_SIZE,
|
||||
})
|
||||
.then(({ photos: records, hasMore }) => ({
|
||||
page,
|
||||
hasMore,
|
||||
photos: records
|
||||
.map(remember)
|
||||
.filter(Boolean)
|
||||
.sort(
|
||||
(a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0),
|
||||
),
|
||||
}));
|
||||
.then(({ photos: records, hasMore }) => {
|
||||
const list = assignCourses(
|
||||
records
|
||||
.map(remember)
|
||||
.filter(Boolean)
|
||||
.sort(
|
||||
(a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0),
|
||||
),
|
||||
metresBetween,
|
||||
);
|
||||
// The open photo may have just learnt which way it looked. The
|
||||
// page is good whatever the viewer is doing.
|
||||
try {
|
||||
viewer.refreshPose?.();
|
||||
} catch {
|
||||
/* the next pose carries the direction */
|
||||
}
|
||||
return { page, hasMore, photos: list };
|
||||
});
|
||||
pages.set(key, entry);
|
||||
entry.catch(() => pages.delete(key));
|
||||
}
|
||||
@@ -251,12 +280,30 @@ export function createKartaViewProvider({
|
||||
return probe;
|
||||
}
|
||||
|
||||
// Decoded coverage tiles, kept a while: panning back asks nothing.
|
||||
// A tile's way segments, kept a while: panning back, or a filter
|
||||
// change, asks KartaView nothing more for them.
|
||||
const tiles = createTileCache({
|
||||
load: async (tile, { signal }) =>
|
||||
decodeCoverageTile(await source.getTile(tile, { signal }), tile),
|
||||
tileSegments(await source.getTile(tile, { signal })),
|
||||
});
|
||||
const fetchTile = (tile, options) => tiles.get(tile, options);
|
||||
// A filtered tile's mask, by filter and tile.
|
||||
const masks = createTileCache({
|
||||
load: async ({ query, ...tile }, { signal }) =>
|
||||
readMask(await source.getTileMask(tile, query, { signal })),
|
||||
keyOf: ({ query, z, x, y }) => `${query}|${z}/${x}/${y}`,
|
||||
});
|
||||
/** The tile's lines under the filter now: all, or the masked ones. */
|
||||
async function fetchTile(tile, options) {
|
||||
const query = tileFilterQuery(context.getFilter());
|
||||
if (!query) return chainSegments(await tiles.get(tile, options), tile);
|
||||
const [segments, mask] = await Promise.all([
|
||||
tiles.get(tile, options),
|
||||
masks.get({ ...tile, query }, options),
|
||||
]);
|
||||
return chainSegments(maskedSegments(segments, mask, tile), tile);
|
||||
}
|
||||
/** The query the drawn lines were loaded under. */
|
||||
let drawnQuery = tileFilterQuery(context.getFilter());
|
||||
|
||||
/**
|
||||
* The next showable photo `delta` along the sequence, or null at its
|
||||
@@ -297,24 +344,34 @@ export function createKartaViewProvider({
|
||||
renderKey: 'kartaview-coverage',
|
||||
tuning: COVERAGE_TUNING,
|
||||
messages: { rateLimited: RATE_LIMITED_MESSAGE, tooHigh: TOO_HIGH_HINT },
|
||||
// Filtered on the way in (fetchTile): a line has no date to test.
|
||||
passes: () => true,
|
||||
});
|
||||
|
||||
const cones = createSequenceCones({
|
||||
state,
|
||||
async loadImages(sequenceId, { signal }) {
|
||||
const anchor = anchors.get(sequenceId);
|
||||
let anchor = anchors.get(sequenceId);
|
||||
if (!anchor) return [];
|
||||
const window = await windowOf(anchor);
|
||||
let window = await windowOf(anchor);
|
||||
signal?.throwIfAborted();
|
||||
// Another photo of the drive opened while that page loaded (its
|
||||
// re-centre found nothing drawn yet): centre on the newest one.
|
||||
while (anchors.get(sequenceId) !== anchor) {
|
||||
anchor = anchors.get(sequenceId);
|
||||
window = await windowOf(anchor);
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
drawnPage = { sequenceId, page: window.home };
|
||||
countPhotos(sequenceId);
|
||||
return thinImages(
|
||||
window.photos.map((photo) => ({
|
||||
id: photo.id,
|
||||
lon: photo.lon,
|
||||
lat: photo.lat,
|
||||
compassAngle: photo.heading ?? 0,
|
||||
compassAngle: cameraBearing(photo) ?? 0,
|
||||
capturedAt: photo.capturedAt ?? 0,
|
||||
uploadedAt: photo.uploadedAt,
|
||||
isPano: photo.isPano,
|
||||
})),
|
||||
);
|
||||
@@ -323,10 +380,27 @@ 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,
|
||||
// By projection and upload date, as the lines.
|
||||
passes: passesKartaViewFilter,
|
||||
});
|
||||
|
||||
/** Sequence id → its photo count from KartaView (asked once). */
|
||||
const counts = new Map();
|
||||
function countPhotos(sequenceId) {
|
||||
if (counts.has(sequenceId) || !source.getSequenceCount) return;
|
||||
counts.set(sequenceId, undefined);
|
||||
source.getSequenceCount(sequenceId).then(
|
||||
(count) => {
|
||||
if (count === null) counts.delete(sequenceId);
|
||||
else {
|
||||
counts.set(sequenceId, count);
|
||||
context.notify();
|
||||
}
|
||||
},
|
||||
() => counts.delete(sequenceId),
|
||||
);
|
||||
}
|
||||
|
||||
/** ‹ › open through the core (loading, errors, cones), camera still. */
|
||||
function openStepped(photoId) {
|
||||
context.actions.openImage(photoId, { frame: false });
|
||||
@@ -361,6 +435,17 @@ export function createKartaViewProvider({
|
||||
step,
|
||||
onStep: openStepped,
|
||||
onShown: recentreCones,
|
||||
// After a GPU reset the same photo is drawn again by the viewer
|
||||
// itself: going through the core would cancel a lookup the user
|
||||
// started. A failure still shows.
|
||||
onReopen: (photoId) =>
|
||||
viewer
|
||||
.open(photoId)
|
||||
.catch((error) =>
|
||||
context.actions.reportError(
|
||||
error?.message || 'The KartaView photo could not be loaded',
|
||||
),
|
||||
),
|
||||
// Esc in the viewer clears the selected drive, as the panel says.
|
||||
onEscape: () => {
|
||||
if (!state.sequence.selectedId) return false;
|
||||
@@ -396,6 +481,9 @@ export function createKartaViewProvider({
|
||||
cones.clearSelection();
|
||||
cones.setVisible(false);
|
||||
drawnPage = null;
|
||||
// Tens of MB of decoded lines are not kept for a source left off.
|
||||
tiles.clear();
|
||||
masks.clear();
|
||||
},
|
||||
|
||||
destroy(cesiumViewer) {
|
||||
@@ -403,12 +491,24 @@ export function createKartaViewProvider({
|
||||
coverage.clear();
|
||||
coverage.resetErrors();
|
||||
cones.destroy(cesiumViewer);
|
||||
tiles.clear();
|
||||
masks.clear();
|
||||
},
|
||||
|
||||
refreshCoverage: () => coverage.refresh(),
|
||||
|
||||
/** No filter applies (see `filters`). */
|
||||
setFilter() {},
|
||||
/**
|
||||
* The filter changed: the lines come from KartaView again under it
|
||||
* (the old ones show meanwhile), the cones are drawn again.
|
||||
*/
|
||||
setFilter() {
|
||||
const query = tileFilterQuery(context.getFilter());
|
||||
if (query !== drawnQuery) {
|
||||
drawnQuery = query;
|
||||
coverage.reload();
|
||||
}
|
||||
cones.rerender();
|
||||
},
|
||||
|
||||
setSurface() {
|
||||
coverage.setSurface();
|
||||
@@ -457,9 +557,12 @@ export function createKartaViewProvider({
|
||||
cones.clearSelection();
|
||||
drawnPage = null;
|
||||
},
|
||||
// The drive's own count, not the thinned page of it the cones show.
|
||||
sequenceStats: () => ({
|
||||
selectedId: state.sequence.selectedId,
|
||||
images: state.sequence.images.length,
|
||||
images:
|
||||
counts.get(state.sequence.selectedId) ??
|
||||
state.sequence.images.length,
|
||||
loading: state.sequence.loading,
|
||||
}),
|
||||
|
||||
@@ -469,13 +572,20 @@ export function createKartaViewProvider({
|
||||
* file at all, so the nearest few are tried in turn.
|
||||
*/
|
||||
async nearestImage({ lat, lon }, { signal } = {}) {
|
||||
const records = await source.nearestPhotos({ lat, lon }, { signal });
|
||||
const filter = context.getFilter();
|
||||
const records = await source.nearestPhotos(
|
||||
{ lat, lon, filter },
|
||||
{ signal },
|
||||
);
|
||||
const near = [];
|
||||
let filteredOut = 0;
|
||||
for (const record of records) {
|
||||
const photo = remember(record);
|
||||
if (!photo?.urls.view) continue;
|
||||
const metres = metresBetween({ lat, lon }, photo);
|
||||
if (metres <= NEAREST_RADIUS_M) near.push({ photo, metres });
|
||||
if (metres > NEAREST_RADIUS_M) continue;
|
||||
if (!passesKartaViewFilter(photo, filter)) filteredOut++;
|
||||
else near.push({ photo, metres });
|
||||
}
|
||||
near.sort((a, b) => a.metres - b.metres);
|
||||
// Probed side by side (photo storage is not the API host): the
|
||||
@@ -487,6 +597,7 @@ export function createKartaViewProvider({
|
||||
const hit = candidates.find((_, i) => answers[i]);
|
||||
if (hit) return hit.photo.id;
|
||||
if (near.length) throw new Error(ARCHIVED_MESSAGE);
|
||||
if (filteredOut) throw new Error(FILTERED_OUT_MESSAGE);
|
||||
return null;
|
||||
},
|
||||
|
||||
|
||||
@@ -108,6 +108,90 @@ export const FLAT_FULL_SIZE = Object.freeze([2560, 2560]);
|
||||
export const KARTAVIEW_CREDIT_HTML =
|
||||
'Street Level: imagery © <a href="https://kartaview.org" target="_blank" rel="noopener">KartaView</a> contributors, CC BY-SA 4.0';
|
||||
|
||||
/**
|
||||
* The imagery filter (360° or flat, SINCE). KartaView filters coverage on
|
||||
* its server only in its PNG tiles: its JSON tiles come back empty once a
|
||||
* filter is asked for. So a filtered PNG tile is a mask, and a JSON line is
|
||||
* kept where it lies on the mask's drawn pixels.
|
||||
*
|
||||
* KartaView's date filter is the upload date, and SINCE goes by it
|
||||
* throughout (lines, the photo a click opens, the cones), as KartaView's own
|
||||
* map does; the panel says so under the slider.
|
||||
*/
|
||||
export const MASK_TIMEOUT_MS = 20_000;
|
||||
/**
|
||||
* A filtered PNG tile is drawn on demand and a dense one can outlast
|
||||
* KartaView's own 60 s (504); it keeps drawing, and has the tile a few
|
||||
* seconds later. A timeout or a 5xx is asked again this many times in all.
|
||||
*/
|
||||
export const MASK_ATTEMPTS = 4;
|
||||
export const MASK_RETRY_WAIT_MS = 4_000;
|
||||
/** Real masks are tens of kilobytes. */
|
||||
export const MASK_MAX_BYTES = 2 * 1024 * 1024;
|
||||
/** A segment is kept when this share of the points along it lies on the mask. */
|
||||
export const MASK_KEEP_SHARE = 0.5;
|
||||
/** Points along a segment are tested this many mask pixels apart. */
|
||||
export const MASK_SAMPLE_PX = 2;
|
||||
export const MASK_SLOW_MESSAGE =
|
||||
'KartaView did not filter this area in time — move the map to ask again';
|
||||
export const FILTERED_OUT_MESSAGE =
|
||||
'None of KartaView’s photos here pass the imagery filter';
|
||||
/** Under the SINCE slider while KartaView is lit. */
|
||||
export const SINCE_NOTE = 'KartaView: by upload date';
|
||||
|
||||
/** The UTC calendar day of `ms`, as KartaView's startDate reads it. */
|
||||
function utcDay(ms) {
|
||||
return new Date(ms).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const filtersSince = (filter) =>
|
||||
Number.isFinite(filter?.sinceMs) && filter.sinceMs > 0;
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* Whether a photo passes the resolved imagery filter by its projection and
|
||||
* upload date, the cut-off taken as its UTC day, as KartaView's startDate.
|
||||
* @param {{isPano: boolean, uploadedAt: number|null}} photo
|
||||
* @param {{pano: string, sinceMs: number|null}|null} filter
|
||||
*/
|
||||
export function passesKartaViewFilter(photo, filter) {
|
||||
if (!filter) return true;
|
||||
if (filter.pano === 'pano' && !photo.isPano) return false;
|
||||
if (filter.pano === 'flat' && photo.isPano) return false;
|
||||
if (!filtersSince(filter)) return true;
|
||||
const day = Math.floor(filter.sinceMs / DAY_MS) * DAY_MS;
|
||||
return (photo.uploadedAt ?? 0) >= day;
|
||||
}
|
||||
|
||||
/**
|
||||
* The query of a filtered PNG coverage tile, or '' when nothing is
|
||||
* filtered. The day, not the instant, so it is stable for a day (and keys
|
||||
* the mask cache).
|
||||
* @param {{pano: string, sinceMs: number|null}|null} filter resolved
|
||||
*/
|
||||
export function tileFilterQuery(filter) {
|
||||
const params = new URLSearchParams();
|
||||
if (filter?.pano === 'pano') params.set('fieldOfView', '360');
|
||||
else if (filter?.pano === 'flat') params.set('fieldOfView', 'plane');
|
||||
if (filtersSince(filter)) params.set('startDate', utcDay(filter.sinceMs));
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* The photo search's own filter: projection and upload date (its
|
||||
* `fieldOfView` is ignored).
|
||||
* @param {{pano: string, sinceMs: number|null}|null} filter resolved
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
export function photoFilterParams(filter) {
|
||||
const params = {};
|
||||
if (filter?.pano === 'pano') params.projection = 'SPHERE';
|
||||
else if (filter?.pano === 'flat') params.projection = 'PLANE';
|
||||
if (filtersSince(filter)) params.startDate = utcDay(filter.sinceMs);
|
||||
return params;
|
||||
}
|
||||
|
||||
/** The cone and coverage noun: a line is a street with photos, not a sequence. */
|
||||
export const COVERAGE_NOUN = 'streets';
|
||||
export const LINE_CLICK_HINT = 'click a line for its nearest photo';
|
||||
@@ -142,6 +226,56 @@ export function kartaViewTimeMs(value) {
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
/** Initial great-circle bearing from `a` to `b`, degrees clockwise from north. */
|
||||
export function bearingBetween(a, b) {
|
||||
const rad = Math.PI / 180;
|
||||
const dLon = (b.lon - a.lon) * rad;
|
||||
const y = Math.sin(dLon) * Math.cos(b.lat * rad);
|
||||
const x =
|
||||
Math.cos(a.lat * rad) * Math.sin(b.lat * rad) -
|
||||
Math.sin(a.lat * rad) * Math.cos(b.lat * rad) * Math.cos(dLon);
|
||||
return (Math.atan2(y, x) / rad + 360) % 360;
|
||||
}
|
||||
|
||||
/** Photos closer than this say nothing about the direction of travel. */
|
||||
const COURSE_MIN_STEP_M = 2;
|
||||
/** How many photos along to look for one far enough away. */
|
||||
const COURSE_SEARCH = 5;
|
||||
|
||||
/**
|
||||
* Give each photo of a drive (in index order) its `course`: the direction
|
||||
* of travel from it to the next photo a few metres on (from the previous one
|
||||
* at the end). KartaView is dashcam imagery, so this is where the camera
|
||||
* looks; its `heading` is the device compass, which reads 0 without one and
|
||||
* can sit 110° off inside a car.
|
||||
* @param {Array<{lon: number, lat: number, course?: number|null}>} photos
|
||||
* @param {(a: object, b: object) => number} metres
|
||||
*/
|
||||
export function assignCourses(photos, metres) {
|
||||
photos.forEach((photo, i) => {
|
||||
let course = null;
|
||||
for (let j = i + 1; j <= i + COURSE_SEARCH && j < photos.length; j++)
|
||||
if (metres(photo, photos[j]) >= COURSE_MIN_STEP_M) {
|
||||
course = bearingBetween(photo, photos[j]);
|
||||
break;
|
||||
}
|
||||
for (
|
||||
let k = i - 1;
|
||||
course === null && k >= i - COURSE_SEARCH && k >= 0;
|
||||
k--
|
||||
)
|
||||
if (metres(photos[k], photo) >= COURSE_MIN_STEP_M)
|
||||
course = bearingBetween(photos[k], photo);
|
||||
photo.course = course;
|
||||
});
|
||||
return photos;
|
||||
}
|
||||
|
||||
/** Where the camera looked: the direction of travel, else the compass, else unknown. */
|
||||
export function cameraBearing(photo) {
|
||||
return photo?.course ?? photo?.heading ?? null;
|
||||
}
|
||||
|
||||
/** A coordinate pair as numbers; the road-matched one when KartaView has it. */
|
||||
function positionOf(record) {
|
||||
const matchLat = Number(record.matchLat);
|
||||
@@ -211,10 +345,11 @@ export function normalizePhoto(record) {
|
||||
// 0,0 is a missing fix, not a photo in the Gulf of Guinea.
|
||||
if (lat === 0 && lon === 0) return null;
|
||||
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
|
||||
// An exact 0 is a device without a compass (whole drives read 0), not north.
|
||||
const heading =
|
||||
record.heading === '' || record.heading == null
|
||||
? NaN
|
||||
: Number(record.heading);
|
||||
: Number(record.heading) || NaN;
|
||||
const isPano =
|
||||
record.projection === 'SPHERE' || Number(record.fieldOfView) >= 360;
|
||||
const full =
|
||||
@@ -230,6 +365,8 @@ export function normalizePhoto(record) {
|
||||
isPano,
|
||||
capturedAt:
|
||||
kartaViewTimeMs(record.shotDate) ?? kartaViewTimeMs(record.dateAdded),
|
||||
// KartaView's SINCE goes by this (its server filters by upload date).
|
||||
uploadedAt: kartaViewTimeMs(record.dateAdded),
|
||||
urls: {
|
||||
// A 1280 px rendition; for a 360° photo it is the front view only.
|
||||
view:
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
assignCourses,
|
||||
bearingBetween,
|
||||
cameraBearing,
|
||||
kartaViewPhotoUrl,
|
||||
kartaViewTimeMs,
|
||||
normalizePhoto,
|
||||
passesKartaViewFilter,
|
||||
photoFilterParams,
|
||||
resizedImageUrl,
|
||||
tileFilterQuery,
|
||||
} from './policy.js';
|
||||
|
||||
/** A photo record as /2.0/photo returns it (trimmed to what is read). */
|
||||
@@ -140,7 +146,9 @@ test('records without an id or a position are dropped; a missing heading is null
|
||||
null,
|
||||
);
|
||||
assert.equal(normalizePhoto(record({ heading: '' })).heading, null);
|
||||
assert.equal(normalizePhoto(record({ heading: '0' })).heading, 0);
|
||||
// A device without a compass reads 0 for a whole drive: unknown, not north.
|
||||
assert.equal(normalizePhoto(record({ heading: '0' })).heading, null);
|
||||
assert.equal(normalizePhoto(record({ heading: '0.5' })).heading, 0.5);
|
||||
assert.equal(normalizePhoto(record({ heading: 'n/a' })).heading, null);
|
||||
});
|
||||
|
||||
@@ -237,3 +245,104 @@ test('with no processed-photo path the size prefix gives the full photo', () =>
|
||||
/^https:\/\/storage3\.openstreetcam\.org\/.*\/lth\//,
|
||||
);
|
||||
});
|
||||
|
||||
test('the camera looks along the drive: each photo’s course points to the next one a few metres on', () => {
|
||||
// North along a meridian, 5 m apart, then a repeat of the last position.
|
||||
const step = 5 / 110_540;
|
||||
const photos = [0, 1, 2, 3].map((i) => ({ lon: 10, lat: 50 + i * step }));
|
||||
photos.push({ lon: 10, lat: 50 + 3 * step }); // a stop: same place
|
||||
const metres = (a, b) =>
|
||||
Math.hypot(
|
||||
(b.lon - a.lon) * 111_320 * Math.cos((a.lat * Math.PI) / 180),
|
||||
(b.lat - a.lat) * 110_540,
|
||||
);
|
||||
assignCourses(photos, metres);
|
||||
for (const photo of photos)
|
||||
assert.ok(
|
||||
Math.abs(photo.course) < 0.5 || Math.abs(photo.course - 360) < 0.5,
|
||||
`${photo.course}`,
|
||||
);
|
||||
// Eastward: 90°.
|
||||
const east = [0, 1].map((i) => ({ lon: 10 + (i * 5) / 71_600, lat: 50 }));
|
||||
assignCourses(east, metres);
|
||||
assert.ok(
|
||||
Math.abs(east[0].course - 90) < 0.5 && Math.abs(east[1].course - 90) < 0.5,
|
||||
);
|
||||
// A lone photo has no course; the compass is the fallback, then unknown.
|
||||
const [lone] = assignCourses([{ lon: 1, lat: 1 }], metres);
|
||||
assert.equal(lone.course, null);
|
||||
assert.equal(cameraBearing({ ...lone, heading: 42 }), 42);
|
||||
assert.equal(cameraBearing({ ...lone, heading: null }), null);
|
||||
assert.equal(
|
||||
cameraBearing({ course: 90, heading: 200 }),
|
||||
90,
|
||||
'the drive beats the compass',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(bearingBetween({ lon: 0, lat: 0 }, { lon: 0, lat: -1 }) - 180) <
|
||||
1e-9,
|
||||
);
|
||||
});
|
||||
|
||||
test('the imagery filter becomes KartaView’s tile query and photo-search params', () => {
|
||||
// 2026-03-05 23:30 UTC: the cut-off day is the UTC one.
|
||||
const sinceMs = Date.UTC(2026, 2, 5, 23, 30);
|
||||
assert.equal(tileFilterQuery(null), '');
|
||||
assert.equal(tileFilterQuery({ pano: 'all', sinceMs: null }), '');
|
||||
assert.equal(
|
||||
tileFilterQuery({ pano: 'pano', sinceMs: null }),
|
||||
'fieldOfView=360',
|
||||
);
|
||||
assert.equal(
|
||||
tileFilterQuery({ pano: 'flat', sinceMs }),
|
||||
'fieldOfView=plane&startDate=2026-03-05',
|
||||
);
|
||||
assert.equal(tileFilterQuery({ pano: 'all', sinceMs: 0 }), '');
|
||||
// The search ignores fieldOfView; its projection filters instead.
|
||||
assert.deepEqual(photoFilterParams({ pano: 'all', sinceMs: null }), {});
|
||||
assert.deepEqual(photoFilterParams({ pano: 'pano', sinceMs }), {
|
||||
projection: 'SPHERE',
|
||||
startDate: '2026-03-05',
|
||||
});
|
||||
assert.deepEqual(photoFilterParams({ pano: 'flat', sinceMs: null }), {
|
||||
projection: 'PLANE',
|
||||
});
|
||||
});
|
||||
|
||||
test('KartaView’s SINCE is the upload date, from the start of the cut-off’s UTC day', () => {
|
||||
const photo = normalizePhoto(
|
||||
record({
|
||||
shotDate: '2016-08-23 20:17:47.000',
|
||||
dateAdded: '2026-03-05 00:10:00',
|
||||
}),
|
||||
);
|
||||
assert.equal(photo.uploadedAt, Date.UTC(2026, 2, 5, 0, 10));
|
||||
const since = (iso) => ({ pano: 'all', sinceMs: Date.parse(iso) });
|
||||
// Taken in 2016, uploaded on the cut-off day: it passes, as KartaView's
|
||||
// startDate=2026-03-05 keeps it.
|
||||
assert.equal(
|
||||
passesKartaViewFilter(photo, since('2026-03-05T23:00:00Z')),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
passesKartaViewFilter(photo, since('2026-03-06T00:00:00Z')),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
passesKartaViewFilter(photo, { pano: 'pano', sinceMs: null }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
passesKartaViewFilter(photo, { pano: 'flat', sinceMs: null }),
|
||||
true,
|
||||
);
|
||||
assert.equal(passesKartaViewFilter(photo, null), true);
|
||||
assert.equal(
|
||||
passesKartaViewFilter(
|
||||
{ ...photo, uploadedAt: null },
|
||||
since('2020-01-01T00:00:00Z'),
|
||||
),
|
||||
false,
|
||||
'no upload date passes no SINCE',
|
||||
);
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
kartaViewZoomFor,
|
||||
} from './index.js';
|
||||
import {
|
||||
FILTERED_OUT_MESSAGE,
|
||||
NO_COVERAGE_HINT,
|
||||
SEQUENCE_PAGE_SIZE,
|
||||
TOO_HIGH_HINT,
|
||||
@@ -36,13 +37,23 @@ function photoRecord(id, { metres = 0, seq = '14010', index = 0 } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/** KartaView's shotDate for `ms` ago. */
|
||||
const shotAgo = (ms) =>
|
||||
new Date(Date.now() - ms).toISOString().replace('T', ' ').slice(0, 23);
|
||||
|
||||
/**
|
||||
* 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: [] };
|
||||
const calls = {
|
||||
nearest: 0,
|
||||
nearestFilters: [],
|
||||
photo: [],
|
||||
pages: [],
|
||||
probes: [],
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
probeImage: async (url) => {
|
||||
@@ -50,8 +61,9 @@ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) {
|
||||
return !archived.some((id) => url.includes(`/${id}.jpg`));
|
||||
},
|
||||
getTile: async () => ({ lines: [] }),
|
||||
nearestPhotos: async () => {
|
||||
nearestPhotos: async ({ filter }) => {
|
||||
calls.nearest++;
|
||||
calls.nearestFilters.push(filter);
|
||||
return nearest;
|
||||
},
|
||||
getPhoto: async (id) => {
|
||||
@@ -80,12 +92,13 @@ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) {
|
||||
}
|
||||
|
||||
function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) {
|
||||
const actions = { opened: [], nearest: [], instance: null };
|
||||
// `actions.filter` is the resolved filter now; a test may change it.
|
||||
const actions = { opened: [], nearest: [], instance: null, filter };
|
||||
return {
|
||||
actions,
|
||||
context: {
|
||||
services: {},
|
||||
getFilter: () => filter,
|
||||
getFilter: () => actions.filter,
|
||||
isActive: () => true,
|
||||
groundCaster: null,
|
||||
meshSampler: null,
|
||||
@@ -105,11 +118,12 @@ function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function setup(sourceOptions, contextOptions) {
|
||||
function setup(sourceOptions, contextOptions, { readMask } = {}) {
|
||||
const source = fakeSource(sourceOptions);
|
||||
const loads = [];
|
||||
const definition = createKartaViewProvider({
|
||||
source,
|
||||
readMask,
|
||||
viewerOptions: {
|
||||
load: async (url) => {
|
||||
loads.push(url);
|
||||
@@ -128,12 +142,17 @@ function setup(sourceOptions, contextOptions) {
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('KartaView registers keyless, off by default, with no filters and its own wording', () => {
|
||||
test('KartaView registers keyless, off by default, with Mapillary’s filters and its own wording', () => {
|
||||
const { definition } = setup();
|
||||
assert.equal(definition.id, 'kartaview');
|
||||
assert.equal(definition.requiresKeyId, null);
|
||||
assert.equal(definition.defaultOn, false);
|
||||
assert.deepEqual(definition.filters, []);
|
||||
assert.deepEqual(definition.filters, ['pano', 'since']);
|
||||
assert.equal(
|
||||
definition.sinceBasis,
|
||||
'upload',
|
||||
'SINCE is KartaView’s upload date',
|
||||
);
|
||||
assert.equal(
|
||||
definition.highlightsSequence,
|
||||
false,
|
||||
@@ -195,6 +214,46 @@ test('the nearest photo is the closest within 50 m, and opening it asks nothing
|
||||
);
|
||||
});
|
||||
|
||||
test('the nearest photo passes the imagery filter by its upload date, as KartaView filters; none passing says so', async () => {
|
||||
const since = Date.now() - 86_400_000;
|
||||
const filter = { pano: 'pano', sinceMs: since };
|
||||
const pano = (id, metres, uploaded) => ({
|
||||
...photoRecord(id, { metres }),
|
||||
projection: 'SPHERE',
|
||||
fieldOfView: '360',
|
||||
shotDate: '2016-08-23 20:17:47.000',
|
||||
dateAdded: uploaded,
|
||||
});
|
||||
// Both taken in 2016; only the second was uploaded since yesterday.
|
||||
const { instance, source } = setup(
|
||||
{
|
||||
nearest: [
|
||||
pano('stale', 5, '2016-08-24 04:01:07'),
|
||||
pano('fresh', 20, shotAgo(3_600_000).slice(0, 19)),
|
||||
],
|
||||
},
|
||||
{ filter },
|
||||
);
|
||||
const at = { lat: 42.3314, lon: -83.0458 };
|
||||
assert.equal(await instance.nearestImage(at), 'fresh');
|
||||
assert.deepEqual(source.calls.nearestFilters, [filter]);
|
||||
const stale = setup(
|
||||
{ nearest: [pano('stale', 5, '2016-08-24 04:01:07')] },
|
||||
{ filter },
|
||||
);
|
||||
await assert.rejects(stale.instance.nearestImage(at), {
|
||||
message: FILTERED_OUT_MESSAGE,
|
||||
});
|
||||
// Uploaded earlier on the cut-off's UTC day: KartaView's startDate keeps
|
||||
// it, and so does the check.
|
||||
const day = new Date(since).toISOString().slice(0, 10);
|
||||
const sameDay = setup(
|
||||
{ nearest: [pano('early', 5, `${day} 00:00:01`)] },
|
||||
{ filter },
|
||||
);
|
||||
assert.equal(await sameDay.instance.nearestImage(at), 'early');
|
||||
});
|
||||
|
||||
test('a cone opens its photo; a line opens the photo nearest the click', () => {
|
||||
const { instance, actions } = setup();
|
||||
actions.instance = null;
|
||||
@@ -617,34 +676,73 @@ test('the cones re-centre when ‹ › walk off the page they were drawn from',
|
||||
);
|
||||
});
|
||||
|
||||
test('a leftover imagery filter hides no cones: flat, undated photos still draw', async (t) => {
|
||||
test('the imagery filter thins the cones by each photo’s upload date and projection', async (t) => {
|
||||
const env = await mounted(
|
||||
{ sequences: { s: [0, 1, 2, 3] } },
|
||||
{ filter: { pano: 'pano', sinceMs: Date.now() - 86_400_000 } },
|
||||
);
|
||||
// Undated records: no capture time to pass a "since" filter with.
|
||||
const undated = (record) => ({ ...record, shotDate: '', dateAdded: '' });
|
||||
// 0 and 1: 360° photos uploaded an hour ago (taken in 2016, as all);
|
||||
// 2 and 3: flat ones uploaded in 2016.
|
||||
const shaped = (record) =>
|
||||
Number(record.sequenceIndex) < 2
|
||||
? {
|
||||
...record,
|
||||
projection: 'SPHERE',
|
||||
fieldOfView: '360',
|
||||
dateAdded: shotAgo(3_600_000).slice(0, 19),
|
||||
}
|
||||
: { ...record, dateAdded: '2016-08-24 04:01:07' };
|
||||
const getPhoto = env.source.getPhoto;
|
||||
env.source.getPhoto = async (id) => undated(await getPhoto(id));
|
||||
env.source.getPhoto = async (id) => shaped(await getPhoto(id));
|
||||
const getPage = env.source.getSequencePhotos;
|
||||
env.source.getSequencePhotos = async (...args) => {
|
||||
const page = await getPage(...args);
|
||||
return { ...page, photos: page.photos.map(undated) };
|
||||
return { ...page, photos: page.photos.map(shaped) };
|
||||
};
|
||||
const cones = await withCones(t, env);
|
||||
await env.instance.viewer.open('s-1');
|
||||
await env.instance.selectSequence('s');
|
||||
assert.ok(await until(() => !env.instance.sequenceStats().loading));
|
||||
assert.equal(env.instance.sequenceStats().images, 4);
|
||||
assert.equal(cones.count(), 4, 'every cone drawn');
|
||||
assert.equal(env.instance.sequenceStats().images, 4, 'the drive is whole');
|
||||
assert.equal(cones.count(), 2, 'the 360° photos uploaded since yesterday');
|
||||
assert.deepEqual(cones(), [0, 1]);
|
||||
env.actions.filter = { pano: 'flat', sinceMs: null };
|
||||
env.instance.setFilter();
|
||||
assert.deepEqual(cones(), [2, 3], 'the flat ones');
|
||||
env.actions.filter = { pano: 'all', sinceMs: null };
|
||||
env.instance.setFilter();
|
||||
assert.equal(cones.count(), 4, 'every cone, unfiltered');
|
||||
});
|
||||
|
||||
/**
|
||||
* The provider drawing coverage on a stand-in viewer above one z14 tile.
|
||||
* `lines` are the tile's tracks as [lon, lat] pairs; `getTile` may be held.
|
||||
*/
|
||||
function coverageSetup(t, { lines = [], filter, height = 900 } = {}) {
|
||||
const env = setup({}, { filter });
|
||||
function coverageSetup(
|
||||
t,
|
||||
{ lines = [], filter, height = 900, drawnRows = () => [] } = {},
|
||||
) {
|
||||
// A filtered tile's mask: 512 px square, drawn on the rows
|
||||
// `drawnRows(query)` lists as [from, to].
|
||||
const env = setup(
|
||||
{},
|
||||
{ filter },
|
||||
{
|
||||
readMask: async ({ query }) => {
|
||||
const alpha = new Uint8Array(512 * 512);
|
||||
for (const [from, to] of drawnRows(query))
|
||||
alpha.fill(255, from * 512, (to + 1) * 512);
|
||||
return { width: 512, height: 512, alpha };
|
||||
},
|
||||
},
|
||||
);
|
||||
const masks = [];
|
||||
let maskHold = null;
|
||||
env.source.getTileMask = async (tile, query) => {
|
||||
masks.push(query);
|
||||
await maskHold;
|
||||
return { query };
|
||||
};
|
||||
const centre = tileCentre(-83.0458, 42.3314);
|
||||
const viewer = coverageViewer(
|
||||
{ lon: centre.lon, lat: centre.lat, height },
|
||||
@@ -671,6 +769,15 @@ function coverageSetup(t, { lines = [], filter, height = 900 } = {}) {
|
||||
viewer,
|
||||
centre,
|
||||
tiles,
|
||||
masks,
|
||||
holdMasks() {
|
||||
let release;
|
||||
maskHold = new Promise((resolve) => (release = resolve));
|
||||
return () => {
|
||||
maskHold = null;
|
||||
release();
|
||||
};
|
||||
},
|
||||
holdTiles() {
|
||||
let release;
|
||||
hold = new Promise((resolve) => (release = resolve));
|
||||
@@ -682,20 +789,59 @@ function coverageSetup(t, { lines = [], filter, height = 900 } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
test('a leftover imagery filter hides no KartaView line', async (t) => {
|
||||
const { instance, viewer, centre } = coverageSetup(t, {
|
||||
filter: { pano: 'pano', sinceMs: Date.now() - 86_400_000 },
|
||||
lines: [
|
||||
[
|
||||
[-83.0458 - 0.001, 42.3314],
|
||||
[-83.0458 + 0.001, 42.3314],
|
||||
],
|
||||
],
|
||||
test('a filter thins the lines to those its PNG mask draws; without one they all show', async (t) => {
|
||||
const { lon, lat } = tileCentre(-83.0458, 42.3314);
|
||||
// Across the tile's middle row (mask row ~256), and a quarter up (~130).
|
||||
const across = (at) => [
|
||||
[lon - 0.001, at],
|
||||
[lon + 0.001, at],
|
||||
];
|
||||
const env = coverageSetup(t, {
|
||||
filter: { pano: 'pano', sinceMs: null },
|
||||
lines: [across(lat), across(lat + 0.004)],
|
||||
// 360° photos lie on the middle line only.
|
||||
drawnRows: (query) => (query === 'fieldOfView=360' ? [[254, 258]] : []),
|
||||
});
|
||||
assert.ok(centre);
|
||||
instance.activate(viewer);
|
||||
assert.ok(await until(() => instance.coverageStats().count === 1));
|
||||
assert.equal(instance.coverageStats().hint, '');
|
||||
env.instance.activate(env.viewer);
|
||||
assert.ok(await until(() => env.instance.coverageStats().count === 1));
|
||||
assert.deepEqual(env.masks, ['fieldOfView=360']);
|
||||
// Unfiltered: every line, and the tile's lines are not asked for again.
|
||||
env.actions.filter = { pano: 'all', sinceMs: null };
|
||||
env.instance.setFilter();
|
||||
assert.ok(await until(() => env.instance.coverageStats().count === 2));
|
||||
assert.equal(env.tiles.length, 1, 'the segments are kept');
|
||||
// SINCE a day: uploaded since yesterday (UTC) — none here. The lines
|
||||
// drawn now stay on the globe while KartaView draws the filtered tile.
|
||||
const since = Date.now() - 86_400_000;
|
||||
const release = env.holdMasks();
|
||||
env.actions.filter = { pano: 'all', sinceMs: since };
|
||||
env.instance.setFilter();
|
||||
assert.ok(await until(() => env.instance.coverageStats().loading));
|
||||
assert.ok(
|
||||
env.viewer.scene.groundPrimitives.items.size > 0,
|
||||
'the unfiltered lines show meanwhile',
|
||||
);
|
||||
release();
|
||||
assert.ok(
|
||||
await until(
|
||||
() =>
|
||||
!env.instance.coverageStats().loading &&
|
||||
env.instance.coverageStats().count === 0,
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
env.masks.at(-1),
|
||||
`startDate=${new Date(since).toISOString().slice(0, 10)}`,
|
||||
);
|
||||
assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT);
|
||||
assert.ok(
|
||||
await until(() => env.viewer.scene.groundPrimitives.items.size === 0),
|
||||
'and go once it is in',
|
||||
);
|
||||
// The same filter again asks KartaView for nothing.
|
||||
env.instance.setFilter();
|
||||
for (let i = 0; i < 5; i++) await settle();
|
||||
assert.equal(env.masks.length, 2);
|
||||
});
|
||||
|
||||
test('coverage hints: zoom in from too high, “no photos” only once an empty view has loaded', async (t) => {
|
||||
@@ -714,7 +860,7 @@ test('coverage hints: zoom in from too high, “no photos” only once an empty
|
||||
assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT);
|
||||
});
|
||||
|
||||
test('a coverage tile loaded a moment ago is not asked for again', async (t) => {
|
||||
test('coverage tiles are kept while KartaView is on and dropped once it is switched off', async (t) => {
|
||||
const env = coverageSetup(t, {
|
||||
lines: [
|
||||
[
|
||||
@@ -725,11 +871,14 @@ test('a coverage tile loaded a moment ago is not asked for again', async (t) =>
|
||||
});
|
||||
env.instance.activate(env.viewer);
|
||||
assert.ok(await until(() => env.instance.coverageStats().count === 1));
|
||||
// Off and on again (or a pan away and back): drawn from the kept tile.
|
||||
env.instance.refreshCoverage();
|
||||
assert.ok(await until(() => env.instance.coverageStats().count === 1));
|
||||
assert.equal(env.tiles.length, 1, 'kept while on');
|
||||
// Switched off: the decoded lines are not kept, so on again asks again.
|
||||
env.instance.deactivate();
|
||||
env.instance.activate(env.viewer);
|
||||
assert.ok(await until(() => env.instance.coverageStats().count === 1));
|
||||
assert.equal(env.tiles.length, 1);
|
||||
assert.equal(env.tiles.length, 2);
|
||||
});
|
||||
|
||||
test('a photo no page lists steps to the photos either side of its index', async () => {
|
||||
@@ -767,3 +916,214 @@ test('Esc in the docked viewer clears the selected drive and is consumed; with n
|
||||
assert.equal(env.instance.sequenceStats().selectedId, null, 'drive cleared');
|
||||
assert.deepEqual(press(), { stopped: false, prevented: false });
|
||||
});
|
||||
|
||||
test('the panel counts the drive’s photos, not the thinned page of them the cones show', async (t) => {
|
||||
const env = await mounted({
|
||||
sequences: { s: Array.from({ length: 40 }, (_, i) => i) },
|
||||
});
|
||||
const asked = [];
|
||||
env.source.getSequenceCount = async (id) => {
|
||||
asked.push(id);
|
||||
return 2385;
|
||||
};
|
||||
await withCones(t, env);
|
||||
await env.instance.viewer.open('s-3');
|
||||
await env.instance.selectSequence('s');
|
||||
assert.ok(
|
||||
await until(() => env.instance.sequenceStats().images === 2385),
|
||||
`images: ${env.instance.sequenceStats().images}`,
|
||||
);
|
||||
// Asked once per drive.
|
||||
env.instance.clearSequence();
|
||||
await env.instance.viewer.open('s-30');
|
||||
await env.instance.selectSequence('s');
|
||||
assert.ok(await until(() => env.instance.sequenceStats().images === 2385));
|
||||
assert.deepEqual(asked, ['s']);
|
||||
});
|
||||
|
||||
test('after a GPU reset the photo is drawn again by the viewer, not through the core; a failure is reported', async () => {
|
||||
const source = fakeSource();
|
||||
const record = {
|
||||
...photoRecord('pano', { seq: 'p', index: 0 }),
|
||||
projection: 'SPHERE',
|
||||
fieldOfView: '360',
|
||||
filepathProc: '2022/10/6/wrapped_proc/pano.jpg',
|
||||
};
|
||||
source.getPhoto = async () => record;
|
||||
const view = { yaw: 0, pitch: 0, fov: 80 };
|
||||
let failLoads = false;
|
||||
const definition = createKartaViewProvider({
|
||||
source,
|
||||
viewerOptions: {
|
||||
load: async (url) => {
|
||||
if (failLoads)
|
||||
throw new Error('The KartaView photo could not be loaded');
|
||||
return { src: url };
|
||||
},
|
||||
createRenderer: () => ({
|
||||
setImage() {},
|
||||
setView: (next) => Object.assign(view, next),
|
||||
getView: () => ({ ...view }),
|
||||
resize() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
}),
|
||||
},
|
||||
});
|
||||
const { context, actions } = fakeContext();
|
||||
const errors = [];
|
||||
context.actions.reportError = (message) => errors.push(message);
|
||||
const instance = definition.create(context);
|
||||
actions.instance = instance;
|
||||
const host = fakeHost();
|
||||
await instance.viewer.mount(host);
|
||||
const poses = [];
|
||||
instance.viewer.onPose((pose) => poses.push(pose.imageId));
|
||||
await instance.viewer.open('pano');
|
||||
const canvas = () => host.children[0].children[1];
|
||||
const kind = () => host.children[0].dataset.kind;
|
||||
canvas().fire('webglcontextlost');
|
||||
assert.equal(kind(), '', 'cleared while it is drawn again');
|
||||
assert.ok(await until(() => kind() === 'pano'), 'drawn again');
|
||||
assert.ok(poses.length >= 2);
|
||||
assert.deepEqual(actions.opened, [], 'the core (and its lookup) left alone');
|
||||
failLoads = true;
|
||||
canvas().fire('webglcontextlost');
|
||||
assert.ok(await until(() => errors.length === 1));
|
||||
assert.match(errors[0], /could not be loaded/);
|
||||
});
|
||||
|
||||
test('the marker is KartaView’s own cone, and it points along the drive, not at a wrong compass', async () => {
|
||||
const env = setup({
|
||||
sequences: { s: Array.from({ length: 10 }, (_, i) => i) },
|
||||
});
|
||||
assert.deepEqual(env.definition.marker, {
|
||||
kind: 'view-cone',
|
||||
color: '#635bff',
|
||||
});
|
||||
// Photos run east (photoRecord puts index i at 5·i m east); the compass says 200°.
|
||||
const getPhoto = env.source.getPhoto;
|
||||
env.source.getPhoto = async (id) => ({
|
||||
...(await getPhoto(id)),
|
||||
heading: '200',
|
||||
});
|
||||
const getPage = env.source.getSequencePhotos;
|
||||
env.source.getSequencePhotos = async (...args) => {
|
||||
const page = await getPage(...args);
|
||||
return {
|
||||
...page,
|
||||
photos: page.photos.map((record) => ({ ...record, heading: '200' })),
|
||||
};
|
||||
};
|
||||
const host = fakeHost();
|
||||
await env.instance.viewer.mount(host);
|
||||
const bearings = [];
|
||||
env.instance.viewer.onPose((pose) => bearings.push(pose.bearing));
|
||||
await env.instance.viewer.open('s-4');
|
||||
assert.equal(bearings[0], 200, 'the compass until the drive is known');
|
||||
assert.ok(
|
||||
await until(() => Math.abs(bearings.at(-1) - 90) < 1),
|
||||
`then along the drive: ${bearings}`,
|
||||
);
|
||||
});
|
||||
|
||||
/** Hold `seq`'s page `page` until the returned function is called. */
|
||||
function holdPage(source, seq, page) {
|
||||
const getPage = source.getSequencePhotos;
|
||||
let release;
|
||||
const gate = new Promise((resolve) => (release = resolve));
|
||||
source.getSequencePhotos = async (id, options) => {
|
||||
if (id === seq && options.page === page) await gate;
|
||||
return getPage(id, options);
|
||||
};
|
||||
return () => release();
|
||||
}
|
||||
|
||||
test('a photo opened while the cones first load is the one they centre on', async (t) => {
|
||||
const size = SEQUENCE_PAGE_SIZE;
|
||||
const env = await mounted({
|
||||
sequences: { s: Array.from({ length: size * 5 }, (_, i) => i) },
|
||||
});
|
||||
const cones = await withCones(t, env);
|
||||
const release = holdPage(env.source, 's', 1);
|
||||
await env.instance.viewer.open('s-10');
|
||||
const selected = env.instance.selectSequence('s');
|
||||
// A line click further along the drive while page 1 is still loading:
|
||||
// nothing is drawn yet for its re-centre to move.
|
||||
await env.instance.viewer.open(`s-${size * 4 + 10}`);
|
||||
release();
|
||||
await selected;
|
||||
assert.ok(
|
||||
await until(
|
||||
() => !env.instance.sequenceStats().loading && cones.count() > 0,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
cones()[0] <= size * 4 + 10 && cones()[1] >= size * 4 + 10,
|
||||
`around the newer photo, not the first: ${cones()}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a sequence page that lands while a lost GPU context recovers still draws its cones', async (t) => {
|
||||
const env = setup({ sequences: { p: [0, 1, 2, 3] } });
|
||||
const sphere = (record) => ({
|
||||
...record,
|
||||
projection: 'SPHERE',
|
||||
fieldOfView: '360',
|
||||
filepathProc: `2022/10/6/wrapped_proc/${record.id}.jpg`,
|
||||
});
|
||||
const getPhoto = env.source.getPhoto;
|
||||
env.source.getPhoto = async (id) => sphere(await getPhoto(id));
|
||||
const getPage = env.source.getSequencePhotos;
|
||||
env.source.getSequencePhotos = async (...args) => {
|
||||
const page = await getPage(...args);
|
||||
return { ...page, photos: page.photos.map(sphere) };
|
||||
};
|
||||
const release = holdPage(env.source, 'p', 1);
|
||||
let holdImages = false;
|
||||
let releaseImage = null;
|
||||
const view = { yaw: 0, pitch: 0, fov: 80 };
|
||||
const definition = createKartaViewProvider({
|
||||
source: env.source,
|
||||
viewerOptions: {
|
||||
load: async (url) => {
|
||||
if (holdImages) await new Promise((r) => (releaseImage = r));
|
||||
return { src: url };
|
||||
},
|
||||
createRenderer: () => ({
|
||||
setImage() {},
|
||||
setView: (next) => Object.assign(view, next),
|
||||
getView: () => ({ ...view }),
|
||||
resize() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
}),
|
||||
},
|
||||
});
|
||||
const { context, actions } = fakeContext();
|
||||
const errors = [];
|
||||
context.actions.reportError = (message) => errors.push(message);
|
||||
const instance = definition.create(context);
|
||||
actions.instance = instance;
|
||||
const cones = await withCones(t, { instance });
|
||||
const host = fakeHost();
|
||||
await instance.viewer.mount(host);
|
||||
await instance.viewer.open('p-0');
|
||||
// › to the next panorama; the GPU resets while it loads.
|
||||
holdImages = true;
|
||||
const opening = instance.viewer.open('p-1');
|
||||
assert.ok(await until(() => releaseImage !== null));
|
||||
host.children[0].children[1].fire('webglcontextlost');
|
||||
const selected = instance.selectSequence('p');
|
||||
release();
|
||||
await selected;
|
||||
assert.ok(
|
||||
await until(() => !instance.sequenceStats().loading && cones.count() > 0),
|
||||
'the page is drawn, not dropped',
|
||||
);
|
||||
releaseImage();
|
||||
await opening;
|
||||
assert.equal(host.children[0].dataset.kind, 'pano', 'p-1 shows');
|
||||
// reportError(null) clears; nothing was reported.
|
||||
assert.deepEqual(errors.filter(Boolean), []);
|
||||
});
|
||||
@@ -2,6 +2,11 @@ import {
|
||||
IMAGE_PROBE_TIMEOUT_MS,
|
||||
MAX_RESPONSE_BYTES,
|
||||
KARTAVIEW_API_HOST,
|
||||
MASK_ATTEMPTS,
|
||||
MASK_MAX_BYTES,
|
||||
MASK_RETRY_WAIT_MS,
|
||||
MASK_SLOW_MESSAGE,
|
||||
MASK_TIMEOUT_MS,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_SEARCH_ZOOM,
|
||||
PAGE_CONCURRENCY,
|
||||
@@ -14,22 +19,33 @@ import {
|
||||
PHOTO_API_TIMEOUT_MS,
|
||||
TILE_CONCURRENCY,
|
||||
TILE_TIMEOUT_MS,
|
||||
photoFilterParams,
|
||||
} from './policy.js';
|
||||
import { createLimiter } from '../../limiter.js';
|
||||
|
||||
export { createLimiter } from '../../limiter.js';
|
||||
|
||||
/** An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits. */
|
||||
/**
|
||||
* An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits,
|
||||
* `timedOut` when it did not answer in time (the one failure worth asking
|
||||
* again at once).
|
||||
*/
|
||||
export class KartaViewSourceError extends Error {
|
||||
constructor(message, { status = 0, retryAfterSec = null } = {}) {
|
||||
constructor(
|
||||
message,
|
||||
{ status = 0, retryAfterSec = null, timedOut = false } = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'KartaViewSourceError';
|
||||
this.status = status;
|
||||
this.retryAfterSec = retryAfterSec;
|
||||
this.timedOut = timedOut;
|
||||
}
|
||||
}
|
||||
|
||||
const NO_ANSWER = 'KartaView did not answer in time — try again';
|
||||
const UNREACHABLE = 'KartaView could not be reached — check the network';
|
||||
const UNREADABLE = 'KartaView sent an answer that could not be read';
|
||||
/** Statuses that mean a photo file is gone, not that storage hiccupped. */
|
||||
const GONE_STATUSES = Object.freeze([403, 404, 409, 410]);
|
||||
|
||||
@@ -64,29 +80,47 @@ function retryAfterSeconds(response) {
|
||||
* 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, maskRetryMs?: number}} [options]
|
||||
*/
|
||||
export function createKartaViewSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
apiHost = KARTAVIEW_API_HOST,
|
||||
retryBaseMs = PHOTO_API_RETRY_BASE_MS,
|
||||
maskRetryMs = MASK_RETRY_WAIT_MS,
|
||||
} = {}) {
|
||||
const limiter = createLimiter(PHOTO_API_CONCURRENCY);
|
||||
const tileLimiter = createLimiter(TILE_CONCURRENCY);
|
||||
const pageLimiter = createLimiter(PAGE_CONCURRENCY, { lifo: true });
|
||||
|
||||
/** GET JSON within `timeoutMs`; the caller's abort wins over the timeout. */
|
||||
async function getJson(url, { signal, timeoutMs }) {
|
||||
function getJson(url, { signal, timeoutMs }) {
|
||||
return fetchBody(url, { signal, timeoutMs, as: 'json' });
|
||||
}
|
||||
|
||||
/**
|
||||
* GET a body within `timeoutMs`, at most `maxBytes` of it, as parsed JSON
|
||||
* or (`as: 'bytes'`) a Uint8Array; the caller's abort wins over the
|
||||
* timeout.
|
||||
*/
|
||||
async function fetchBody(
|
||||
url,
|
||||
{ signal, timeoutMs, as = 'json', maxBytes = MAX_RESPONSE_BYTES },
|
||||
) {
|
||||
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(new KartaViewSourceError(NO_ANSWER)),
|
||||
() =>
|
||||
controller.abort(
|
||||
new KartaViewSourceError(NO_ANSWER, { timedOut: true }),
|
||||
),
|
||||
timeoutMs,
|
||||
);
|
||||
let response = null;
|
||||
let read = false;
|
||||
try {
|
||||
const response = await fetchImpl(url, { signal: controller.signal });
|
||||
response = await fetchImpl(url, { signal: controller.signal });
|
||||
if (response.status === 429)
|
||||
throw new KartaViewSourceError('KartaView is rate-limiting requests', {
|
||||
status: 429,
|
||||
@@ -102,17 +136,30 @@ export function createKartaViewSource({
|
||||
new KartaViewSourceError('KartaView sent too much data', {
|
||||
status: response.status,
|
||||
});
|
||||
if (length > MAX_RESPONSE_BYTES) throw tooMuch();
|
||||
if (length > maxBytes) throw tooMuch();
|
||||
// KartaView answers chunked, without a length: count while reading.
|
||||
const reader = response.body?.getReader?.();
|
||||
if (!reader) return await response.json();
|
||||
if (!reader) {
|
||||
read = true;
|
||||
try {
|
||||
if (as === 'bytes')
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) throw error;
|
||||
throw new KartaViewSourceError(UNREADABLE, {
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
read = true;
|
||||
const chunks = [];
|
||||
let bytes = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > MAX_RESPONSE_BYTES) {
|
||||
if (bytes > maxBytes) {
|
||||
reader.cancel().catch(() => {});
|
||||
throw tooMuch();
|
||||
}
|
||||
@@ -124,15 +171,30 @@ export function createKartaViewSource({
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return JSON.parse(new TextDecoder().decode(body));
|
||||
if (as === 'bytes') return body;
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(body));
|
||||
} catch {
|
||||
throw new KartaViewSourceError(UNREADABLE, {
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
if (controller.signal.aborted) throw controller.signal.reason;
|
||||
if (error instanceof KartaViewSourceError) throw error;
|
||||
throw new KartaViewSourceError(NO_ANSWER);
|
||||
// fetch rejects on no network, a blocked request or a refused CORS check.
|
||||
throw new KartaViewSourceError(UNREACHABLE);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
// An answer turned away unread (429, an error, too long) still holds
|
||||
// its connection, one of the six the browser has: let it go.
|
||||
if (response && !read) {
|
||||
controller.abort();
|
||||
if (response.body && !response.body.locked)
|
||||
response.body.cancel?.().catch?.(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +236,7 @@ export function createKartaViewSource({
|
||||
return payload?.result ?? {};
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
const timedOut = error?.message === NO_ANSWER && error.status === 0;
|
||||
const timedOut = error?.timedOut === true;
|
||||
if (timedOut && timeouts++ < 1) continue;
|
||||
if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) {
|
||||
if (error?.status === 429)
|
||||
@@ -212,11 +274,46 @@ export function createKartaViewSource({
|
||||
} catch (error) {
|
||||
// A cold dense tile can outlast the timeout; KartaView has it ready
|
||||
// by the second ask.
|
||||
if (signal?.aborted || error?.message !== NO_ANSWER) throw error;
|
||||
if (signal?.aborted || error?.timedOut !== true) throw error;
|
||||
return once();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* A filtered coverage tile as PNG bytes, `query` from tileFilterQuery
|
||||
* (KartaView filters only its PNG tiles). It draws a filtered tile on
|
||||
* demand; a dense one can outlast its own 60 s (504) and still be drawn,
|
||||
* so a timeout or a 5xx is asked again a few seconds later.
|
||||
*/
|
||||
async getTileMask({ x, y, z }, query, { signal } = {}) {
|
||||
const url = `${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.png?${query}`;
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
// Outside the gate between tries: a wait holds no connection.
|
||||
return await tileLimiter.run(
|
||||
() =>
|
||||
fetchBody(url, {
|
||||
signal,
|
||||
timeoutMs: MASK_TIMEOUT_MS,
|
||||
as: 'bytes',
|
||||
maxBytes: MASK_MAX_BYTES,
|
||||
}),
|
||||
{ signal },
|
||||
);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
const drawing =
|
||||
error?.timedOut === true || [502, 503, 504].includes(error?.status);
|
||||
if (!drawing) throw error;
|
||||
if (attempt >= MASK_ATTEMPTS)
|
||||
throw new KartaViewSourceError(MASK_SLOW_MESSAGE, {
|
||||
timedOut: true,
|
||||
});
|
||||
await wait(maskRetryMs, signal);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether a photo file is there: true, false when it is gone (archived
|
||||
* photos answer 409, and a HEAD says 200 regardless), or null when the
|
||||
@@ -248,8 +345,11 @@ export function createKartaViewSource({
|
||||
}
|
||||
},
|
||||
|
||||
/** Photo records around a point, nearest first. */
|
||||
async nearestPhotos({ lat, lon }, { signal } = {}) {
|
||||
/**
|
||||
* Photo records around a point, nearest first; with a resolved imagery
|
||||
* `filter`, only those of its projection uploaded since its cut-off.
|
||||
*/
|
||||
async nearestPhotos({ lat, lon, filter = null }, { signal } = {}) {
|
||||
const result = await photoApi(
|
||||
'/2.0/photo/',
|
||||
{
|
||||
@@ -257,6 +357,7 @@ export function createKartaViewSource({
|
||||
lng: lon.toFixed(7),
|
||||
zoomLevel: String(NEAREST_SEARCH_ZOOM),
|
||||
itemsPerPage: String(NEAREST_LIMIT),
|
||||
...photoFilterParams(filter),
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
@@ -275,6 +376,17 @@ export function createKartaViewSource({
|
||||
return result.data;
|
||||
},
|
||||
|
||||
/** How many photos a sequence has, or null if KartaView does not say. */
|
||||
async getSequenceCount(sequenceId, { signal } = {}) {
|
||||
const result = await photoApi(
|
||||
`/2.0/sequence/${encodeURIComponent(sequenceId)}`,
|
||||
null,
|
||||
{ signal },
|
||||
);
|
||||
const count = Number(result.data?.countActivePhotos);
|
||||
return Number.isFinite(count) && count > 0 ? count : null;
|
||||
},
|
||||
|
||||
/** A page of a sequence's photos in capture order (1-based `page`). */
|
||||
async getSequencePhotos(
|
||||
sequenceId,
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
createLimiter,
|
||||
} from './source.js';
|
||||
import {
|
||||
MASK_ATTEMPTS,
|
||||
MASK_SLOW_MESSAGE,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_SEARCH_ZOOM,
|
||||
PHOTO_API_CONCURRENCY,
|
||||
@@ -151,12 +153,19 @@ test('an API refusal inside a 200 is a readable error with its code; 601 is just
|
||||
test('HTTP errors, network failures and non-JSON become readable errors', async () => {
|
||||
const http = createKartaViewSource({ fetchImpl: async () => status(500) });
|
||||
await assert.rejects(http.getTile({ x: 0, y: 0, z: 14 }), /HTTP 500/);
|
||||
let asked = 0;
|
||||
const offline = createKartaViewSource({
|
||||
fetchImpl: async () => {
|
||||
asked++;
|
||||
throw new TypeError('Failed to fetch');
|
||||
},
|
||||
});
|
||||
await assert.rejects(offline.getPhoto('1'), /did not answer/);
|
||||
// Not a timeout: no network is said as much, and not asked again at once.
|
||||
await assert.rejects(
|
||||
offline.getPhoto('1'),
|
||||
/could not be reached — check the network/,
|
||||
);
|
||||
assert.equal(asked, 1);
|
||||
const garbled = createKartaViewSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
@@ -169,7 +178,19 @@ test('HTTP errors, network failures and non-JSON become readable errors', async
|
||||
});
|
||||
await assert.rejects(
|
||||
garbled.getTile({ x: 0, y: 0, z: 14 }),
|
||||
/did not answer/,
|
||||
/could not be read/,
|
||||
);
|
||||
const torn = createKartaViewSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Map(),
|
||||
body: streamed('{"lines": [', 4),
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
torn.getTile({ x: 0, y: 0, z: 14 }),
|
||||
/could not be read/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -265,18 +286,24 @@ test('tiles, lookups and sequence pages queue separately, so a lookup never wait
|
||||
assert.equal(peak.page, 1);
|
||||
});
|
||||
|
||||
test('a lookup that times out is asked once more; a second timeout says to try again', async () => {
|
||||
test('a lookup that times out is asked once more, then reported as no answer in time', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let calls = 0;
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
throw new TypeError('Failed to fetch');
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
source.getPhoto('1'),
|
||||
const outcome = await onMockClock(t, () =>
|
||||
createKartaViewSource({
|
||||
fetchImpl: (url, { signal }) => {
|
||||
calls++;
|
||||
return new Promise((resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason)),
|
||||
);
|
||||
},
|
||||
}).getPhoto('1'),
|
||||
);
|
||||
assert.match(
|
||||
String(outcome?.error?.message),
|
||||
/did not answer in time — try again/,
|
||||
);
|
||||
assert.equal(outcome.error.timedOut, true);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
@@ -323,17 +350,35 @@ test('an image probe reads only the status: an archived photo (409) is not showa
|
||||
);
|
||||
});
|
||||
|
||||
test('a cold tile that times out is asked once more', async () => {
|
||||
test('a cold tile that times out is asked once more; an unreachable one is not', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let calls = 0;
|
||||
const source = createKartaViewSource({
|
||||
const outcome = await onMockClock(t, () =>
|
||||
createKartaViewSource({
|
||||
fetchImpl: (url, { signal }) => {
|
||||
calls++;
|
||||
if (calls === 1)
|
||||
return new Promise((resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason)),
|
||||
);
|
||||
return Promise.resolve(ok({ lines: [] }));
|
||||
},
|
||||
}).getTile({ x: 1, y: 2, z: 13 }),
|
||||
);
|
||||
assert.deepEqual(outcome.value, { lines: [] });
|
||||
assert.equal(calls, 2);
|
||||
let asked = 0;
|
||||
const offline = createKartaViewSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
if (calls === 1) throw new TypeError('Failed to fetch');
|
||||
return ok({ lines: [] });
|
||||
asked++;
|
||||
throw new TypeError('Failed to fetch');
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await source.getTile({ x: 1, y: 2, z: 13 }), { lines: [] });
|
||||
assert.equal(calls, 2);
|
||||
await assert.rejects(
|
||||
offline.getTile({ x: 1, y: 2, z: 13 }),
|
||||
/could not be reached/,
|
||||
);
|
||||
assert.equal(asked, 1);
|
||||
});
|
||||
|
||||
test('an absurdly large answer is refused before it is parsed', async () => {
|
||||
@@ -552,3 +597,132 @@ test('a sequence page gets the full 30 s on its first try, then 30 s once more',
|
||||
assert.match(String(outcome?.error?.message), /did not answer in time/);
|
||||
assert.equal(outcome.at, 2 * PAGE_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
test('an answer turned away unread (an error, a 429, a length past the cap) lets its connection go', async () => {
|
||||
/** A response whose body is never read: records its cancel and the abort. */
|
||||
const unread = (answer) => {
|
||||
const seen = { cancelled: false, signal: null };
|
||||
const fetchImpl = async (url, { signal }) => {
|
||||
seen.signal = signal;
|
||||
return {
|
||||
...answer,
|
||||
body: {
|
||||
locked: false,
|
||||
getReader: () => {
|
||||
throw new Error('not read');
|
||||
},
|
||||
cancel: async () => {
|
||||
seen.cancelled = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
return { seen, fetchImpl };
|
||||
};
|
||||
const cases = [
|
||||
[{ ok: false, status: 503, headers: new Map() }, /HTTP 503/],
|
||||
[
|
||||
{
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Map([['content-length', String(64 * 1024 * 1024)]]),
|
||||
},
|
||||
/too much data/,
|
||||
],
|
||||
];
|
||||
for (const [answer, message] of cases) {
|
||||
const { seen, fetchImpl } = unread(answer);
|
||||
const source = createKartaViewSource({ fetchImpl });
|
||||
await assert.rejects(source.getTile({ x: 1, y: 2, z: 13 }), message);
|
||||
assert.equal(seen.signal.aborted, true, `${message}: request aborted`);
|
||||
assert.equal(seen.cancelled, true, `${message}: body cancelled`);
|
||||
}
|
||||
// A 429 is let go before its retry is asked.
|
||||
const { seen, fetchImpl } = unread({
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: new Map([['retry-after', '0']]),
|
||||
});
|
||||
const signals = [];
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async (url, init) => {
|
||||
signals.push(init.signal);
|
||||
if (signals.length === 1) return fetchImpl(url, init);
|
||||
return ok(photoPayload([]));
|
||||
},
|
||||
});
|
||||
await source.getPhoto('1');
|
||||
assert.equal(signals[0].aborted, true);
|
||||
assert.equal(seen.cancelled, true);
|
||||
assert.equal(signals[1].aborted, false, 'a read answer is left alone');
|
||||
});
|
||||
|
||||
test('a filtered tile comes as PNG bytes; KartaView still drawing it (504) is asked again, then given up', async () => {
|
||||
const png = new Uint8Array([137, 80, 78, 71, 1, 2, 3]);
|
||||
const bytesOf = () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Map(),
|
||||
body: {
|
||||
getReader: () => {
|
||||
let given = false;
|
||||
return {
|
||||
read: async () =>
|
||||
given
|
||||
? { done: true }
|
||||
: ((given = true), { done: false, value: png }),
|
||||
cancel: async () => {},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
const urls = [];
|
||||
let answers = [status(504), status(503), bytesOf()];
|
||||
const source = createKartaViewSource({
|
||||
maskRetryMs: 0,
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(url);
|
||||
return answers.shift();
|
||||
},
|
||||
});
|
||||
const tile = { x: 13054, y: 8474, z: 14 };
|
||||
assert.deepEqual(await source.getTileMask(tile, 'fieldOfView=360'), png);
|
||||
assert.deepEqual(urls, [
|
||||
'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360',
|
||||
'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360',
|
||||
'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360',
|
||||
]);
|
||||
// Still drawing after every try: given up, saying so.
|
||||
urls.length = 0;
|
||||
answers = Array.from({ length: 10 }, () => status(504));
|
||||
await assert.rejects(source.getTileMask(tile, 'fieldOfView=360'), {
|
||||
message: MASK_SLOW_MESSAGE,
|
||||
timedOut: true,
|
||||
});
|
||||
assert.equal(urls.length, MASK_ATTEMPTS);
|
||||
// A refusal is not asked again.
|
||||
urls.length = 0;
|
||||
answers = [status(400)];
|
||||
await assert.rejects(source.getTileMask(tile, 'fieldOfView=360'), /HTTP 400/);
|
||||
assert.equal(urls.length, 1);
|
||||
});
|
||||
|
||||
test('a filtered nearest search asks KartaView for the projection and upload date', async () => {
|
||||
const urls = [];
|
||||
const source = createKartaViewSource({
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(new URL(url));
|
||||
return ok(photoPayload([]));
|
||||
},
|
||||
});
|
||||
await source.nearestPhotos({
|
||||
lat: 42.3314,
|
||||
lon: -83.0458,
|
||||
filter: { pano: 'pano', sinceMs: Date.UTC(2026, 0, 2, 12) },
|
||||
});
|
||||
await source.nearestPhotos({ lat: 42.3314, lon: -83.0458 });
|
||||
assert.equal(urls[0].searchParams.get('projection'), 'SPHERE');
|
||||
assert.equal(urls[0].searchParams.get('startDate'), '2026-01-02');
|
||||
assert.equal(urls[1].searchParams.has('projection'), false);
|
||||
assert.equal(urls[1].searchParams.has('startDate'), false);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
KARTAVIEW_NAME,
|
||||
KARTAVIEW_PROVIDER_ID,
|
||||
PANO_PREVIEW_SIZE,
|
||||
cameraBearing,
|
||||
kartaViewPhotoUrl,
|
||||
resizedImageUrl,
|
||||
} from './policy.js';
|
||||
@@ -47,7 +48,14 @@ const KARTAVIEW_SOURCES = Object.freeze({
|
||||
photo.isPano ? null : resizedImageUrl(photo.urls.full, FLAT_VIEW_SIZE),
|
||||
photo.urls.view,
|
||||
],
|
||||
flatSharp: (photo) => resizedImageUrl(photo.urls.full, FLAT_FULL_SIZE),
|
||||
// A docked viewer shows the 1,280 px version in full: the 2,560 px one
|
||||
// (~0.6 MB a photo) waits until the viewer is bigger than that.
|
||||
flatSharp: (photo, pixels) =>
|
||||
pixels > FLAT_VIEW_SIZE[0]
|
||||
? resizedImageUrl(photo.urls.full, FLAT_FULL_SIZE)
|
||||
: null,
|
||||
// The camera's direction, which the drive's track may refine.
|
||||
bearingOf: (photo) => cameraBearing(photo),
|
||||
describe: (photo) => ({
|
||||
// KartaView's only place name is the sequence's, where the drive began.
|
||||
title: null,
|
||||
|
||||
@@ -105,8 +105,11 @@ function setup({
|
||||
rendererThrows = false,
|
||||
steps = {},
|
||||
failSteps = false,
|
||||
hold = () => false,
|
||||
} = {}) {
|
||||
const loads = [];
|
||||
const held = [];
|
||||
const closed = [];
|
||||
const renderers = [];
|
||||
const stepped = [];
|
||||
const asked = [];
|
||||
@@ -130,7 +133,10 @@ function setup({
|
||||
loads.push({ url, crossOrigin: options?.crossOrigin });
|
||||
if (failLoads.some((part) => url.includes(part)))
|
||||
throw new Error('The KartaView photo could not be loaded');
|
||||
return { src: url };
|
||||
// A decoded bitmap: close() frees it.
|
||||
const image = { src: url, close: () => closed.push(url) };
|
||||
if (!hold(url)) return image;
|
||||
return new Promise((resolve) => held.push(() => resolve(image)));
|
||||
},
|
||||
createRenderer: (canvas) => {
|
||||
attempts.renderer++;
|
||||
@@ -152,6 +158,8 @@ function setup({
|
||||
stepped,
|
||||
asked,
|
||||
attempts,
|
||||
held,
|
||||
closed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -168,6 +176,7 @@ test('a flat photo shows its 1280 px version, then the sharp one, with a pose fa
|
||||
});
|
||||
await viewer.mount(host);
|
||||
const { picture, canvas, prev, next } = parts(host);
|
||||
picture.clientWidth = 1600; // expanded: the sharp version is wanted
|
||||
assert.equal(prev.disabled, true, 'nothing to step through yet');
|
||||
await viewer.open('flat1');
|
||||
assert.equal(picture.hidden, false);
|
||||
@@ -621,6 +630,8 @@ test('quick presses while the next photo is still loading each step on from the
|
||||
test('a slow sharp version of an older photo never replaces the newer photo', async () => {
|
||||
const { viewer, host, shown, poses, release } = heldSetup();
|
||||
await viewer.mount(host);
|
||||
// An expanded viewer, so the sharp version is wanted.
|
||||
parts(host).picture.clientWidth = 1600;
|
||||
const first = viewer.open('a');
|
||||
await release('a@1280');
|
||||
await first;
|
||||
@@ -904,3 +915,70 @@ test('a CORS image is decoded off the main thread into an ImageBitmap where the
|
||||
/could not be loaded/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a docked viewer keeps the 1,280 px photo; expanding it brings the 2,560 px one', async () => {
|
||||
const { viewer, host, loads } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('flat1');
|
||||
for (let i = 0; i < 4; i++) await settle();
|
||||
assert.equal(loads.length, 1, 'no 2,560 px download for a 400 px frame');
|
||||
parts(host).picture.clientWidth = 1600;
|
||||
viewer.resize();
|
||||
assert.ok(await until(() => loads.length === 2));
|
||||
assert.match(loads[1].url, /rs:fit:2560:2560/);
|
||||
assert.equal(parts(host).picture.src, loads[1].url);
|
||||
viewer.resize();
|
||||
for (let i = 0; i < 4; i++) await settle();
|
||||
assert.equal(loads.length, 2, 'once only');
|
||||
});
|
||||
|
||||
test('resizes while a sharper panorama loads start no second load; the last size wins once it lands', async () => {
|
||||
let first = true;
|
||||
const { viewer, host, loads, renderers, held } = setup({
|
||||
hold: (url) => {
|
||||
const holdIt = first && url.includes('rs:fit:2048');
|
||||
if (holdIt) first = false;
|
||||
return holdIt;
|
||||
},
|
||||
});
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano1');
|
||||
assert.ok(await until(() => held.length === 1));
|
||||
// The panel animates open: a burst of resizes while 2,048 px loads.
|
||||
parts(host).canvas.clientWidth = 1400;
|
||||
for (let i = 0; i < 5; i++) viewer.resize();
|
||||
for (let i = 0; i < 4; i++) await settle();
|
||||
assert.equal(loads.length, 2, 'the preview and the one 2,048 px load');
|
||||
held[0]();
|
||||
assert.ok(await until(() => renderers[0].images.length === 3));
|
||||
assert.match(renderers[0].images[1], /rs:fit:2048:1024/);
|
||||
assert.match(renderers[0].images[2], /rs:fit:4096:2048/);
|
||||
for (let i = 0; i < 4; i++) await settle();
|
||||
assert.equal(loads.length, 3, 'one more load, for the size asked last');
|
||||
});
|
||||
|
||||
test('a preview or sharper version nobody will draw is freed, not left decoded', async () => {
|
||||
const { viewer, host, renderers, held, closed } = setup({
|
||||
hold: (url) => /rs:fit:(1024|2048):/.test(url),
|
||||
});
|
||||
await viewer.mount(host);
|
||||
// pano1's preview is still loading when flat1 opens over it.
|
||||
const overtaken = viewer.open('pano1');
|
||||
assert.ok(await until(() => held.length === 1));
|
||||
await viewer.open('flat1');
|
||||
held[0]();
|
||||
await overtaken;
|
||||
assert.deepEqual(closed.length, 1);
|
||||
assert.match(closed[0], /rs:fit:1024/, 'the overtaken preview');
|
||||
// pano1 shows; its sharp version lands after flat1 replaced it.
|
||||
const shown = viewer.open('pano1');
|
||||
assert.ok(await until(() => held.length === 2));
|
||||
held[1]();
|
||||
await shown;
|
||||
assert.ok(await until(() => held.length === 3));
|
||||
await viewer.open('flat1');
|
||||
held[2]();
|
||||
assert.ok(await until(() => closed.length === 2));
|
||||
assert.match(closed[1], /rs:fit:2048/, 'the overtaken sharp version');
|
||||
assert.equal(renderers[0].images.length, 1, 'only the preview was drawn');
|
||||
});
|
||||
@@ -24,6 +24,7 @@ export function filtersOf(provider) {
|
||||
* @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 {boolean} [highlightsSequence] Its lines show the selected sequence (default true; the legend's "Selected" needs one).
|
||||
* @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' puts a note under the slider while the provider is lit.
|
||||
* @property {(context: ProviderContext) => ProviderInstance} create
|
||||
*
|
||||
* @typedef {object} ProviderContext Handed to `create()` once by the core.
|
||||
@@ -161,6 +162,14 @@ export function validateProviders(providers) {
|
||||
throw new TypeError(
|
||||
`Street Level provider ${label}: filters must list ${IMAGERY_FILTERS.join(' or ')}`,
|
||||
);
|
||||
if (
|
||||
provider.sinceBasis !== undefined &&
|
||||
provider.sinceBasis !== 'capture' &&
|
||||
provider.sinceBasis !== 'upload'
|
||||
)
|
||||
throw new TypeError(
|
||||
`Street Level provider ${label}: sinceBasis must be 'capture' or 'upload'`,
|
||||
);
|
||||
ids.add(provider.id);
|
||||
prefixes.push(provider.pickPrefix);
|
||||
}
|
||||
|
||||
@@ -117,4 +117,9 @@ test('a provider may narrow the filters it honours and pick a view-cone marker',
|
||||
() => validateProviders([def({ marker: { kind: 'pegman' } })]),
|
||||
/marker must be/,
|
||||
);
|
||||
validateProviders([def({ sinceBasis: 'upload' })]);
|
||||
assert.throws(
|
||||
() => validateProviders([def({ sinceBasis: 'shot' })]),
|
||||
/sinceBasis must be 'capture' or 'upload'/,
|
||||
);
|
||||
});
|
||||
@@ -24,7 +24,7 @@ export function createLru(limit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded (decoded) coverage tiles by `z/x/y`, at most `size`, each for
|
||||
* Loaded (decoded) coverage tiles by `keyOf` (default `z/x/y`), at most `size`, each for
|
||||
* `ttlMs`: a pan back, or a zoom across bands and back, neither fetches nor
|
||||
* decodes them again. A load that fails is not kept.
|
||||
*/
|
||||
@@ -33,11 +33,12 @@ export function createTileCache({
|
||||
size = TILE_CACHE_SIZE,
|
||||
ttlMs = TILE_CACHE_TTL_MS,
|
||||
now = Date.now,
|
||||
keyOf = (tile) => `${tile.z}/${tile.x}/${tile.y}`,
|
||||
}) {
|
||||
const kept = createLru(size);
|
||||
return {
|
||||
async get(tile, options) {
|
||||
const key = `${tile.z}/${tile.x}/${tile.y}`;
|
||||
const key = keyOf(tile);
|
||||
const entry = kept.get(key);
|
||||
if (entry && now() - entry.at < ttlMs) return entry.tile;
|
||||
const loaded = await load(tile, options);
|
||||
|
||||
@@ -19,6 +19,7 @@ import { COLORS } from './policy.js';
|
||||
* @property {Array<'pano'|'since'>} [filters]
|
||||
* @property {{noun: string, action: string}|null} [coverageLabel]
|
||||
* @property {boolean} [highlightsSequence] Its lines turn cyan when their sequence is selected.
|
||||
* @property {'capture'|'upload'} [sinceBasis] What its SINCE compares.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -72,6 +73,18 @@ export function filterControls(providers) {
|
||||
return { pano: honoured('pano'), since: honoured('since') };
|
||||
}
|
||||
|
||||
/**
|
||||
* The note under SINCE when a lit provider dates its imagery by upload,
|
||||
* e.g. "KartaView: by upload date", or null.
|
||||
* @param {Array<ProviderSnapshot>} providers
|
||||
*/
|
||||
export function sinceNote(providers) {
|
||||
const names = providers
|
||||
.filter((p) => p.on && p.sinceBasis === 'upload')
|
||||
.map((p) => p.name);
|
||||
return names.length ? `${names.join(', ')}: by upload date` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel snapshot from core state and provider snapshots. The legend has
|
||||
* one swatch per active source that draws coverage lines, then Selected.
|
||||
@@ -112,6 +125,7 @@ export function composeUIState({
|
||||
keyRejected,
|
||||
filter: { ...filter },
|
||||
filterControls: filterControls(providers),
|
||||
sinceNote: sinceNote(providers),
|
||||
providers: providers.map((p) => ({ ...p })),
|
||||
coverage,
|
||||
legend,
|
||||
|
||||
@@ -128,6 +128,18 @@ test('the filter controls show only for a lit provider that honours them', () =>
|
||||
);
|
||||
});
|
||||
|
||||
test('a lit provider that dates by upload puts a note under SINCE', () => {
|
||||
const kartaView = provider({
|
||||
id: 'kartaview',
|
||||
name: 'KartaView',
|
||||
sinceBasis: 'upload',
|
||||
});
|
||||
const note = (providers) => composeUIState({ ...base, providers }).sinceNote;
|
||||
assert.equal(note([provider()]), null, 'Mapillary: capture date');
|
||||
assert.equal(note([kartaView]), 'KartaView: by upload date');
|
||||
assert.equal(note([{ ...kartaView, on: false }]), null, 'only while lit');
|
||||
});
|
||||
|
||||
test('hint and error come from the first active provider that has one', () => {
|
||||
const ui = composeUIState({
|
||||
...base,
|
||||
|
||||
Reference in new issue
Block a user