mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
fix(street-level): Panoramax IC8 review — line geometry budget, caches, attribution, tests
From a five-lens IC8 review (correctness, security, tests with mutation
testing, performance, UX):
- P0: over Paris at 12–60 km the unsimplified z10–z12 lines sent ~2M
points to Cesium's ground-polyline builder (gigabytes of geometry; the
tab hung). decode.js now simplifies lines (Douglas–Peucker, 4 tile
units: 6–10% of the points), and lineCoverage gains an opt-in point
budget (`maxPoints`, 10,000 a tile for Panoramax).
- Decoded coverage tiles are kept (KartaView's tile cache moves to the
shared tileCache.js); a sequence's z15 picture lists are kept and load
on their own connection; cones know their tiles from the start of a
load, so a picture opening meanwhile re-centres them and ‹ › no longer
restarts the load.
- The HD panorama loads only for the expanded viewer (a docked one on a
dense screen asked for 3 MB originals) and is aborted when another
photo opens; picture files over 48 MB or 16,384 px are refused; a
bitmap no renderer took after a context loss is closed.
- A nearest lookup survives one of its two searches failing; only a
timeout is retried; offline reads as unreachable; Retry-After is
capped at five minutes; the search box stays on the map at ±180°;
cones near both edges of a narrow (high-latitude) tile read both.
- Every producer is credited (app and photographer); with none to name,
the caption says "via <instance>" rather than crediting the host.
- Esc anywhere in the docked viewer (toolbar, caption) clears the
sequence before the panel takes it. A filter that hides every density
dot says so; wording aligned ("coverage", "photo").
- Tests for every surviving mutant (Esc consumed, keepOrder and dot-size
wiring, licence in the core, limiter aborts, radius floor, bbox corner,
first-producer-only, byte and size caps), a limiter test, and a gate
fixture that answers box searches and leaves out pictures facing away;
the gate now clicks over the other sequence's pictures, so only the
line click's own-sequence lookup can pass.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
5526666894
commit
cf502429a3
29 files changed
+1153
-216
No files matched your search
@@ -0,0 +1,80 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createLimiter } from './limiter.js';
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
/** A task that runs until `finish()`. */
|
||||
function held(log, name) {
|
||||
let finish;
|
||||
const done = new Promise((resolve) => (finish = resolve));
|
||||
return {
|
||||
task: () => {
|
||||
log.push(name);
|
||||
return done.then(() => name);
|
||||
},
|
||||
finish: () => finish(),
|
||||
};
|
||||
}
|
||||
|
||||
test('at most `limit` tasks run; queued ones start first in, first out', async () => {
|
||||
const log = [];
|
||||
const gate = createLimiter(2);
|
||||
const tasks = ['a', 'b', 'c', 'd'].map((name) => held(log, name));
|
||||
const results = tasks.map(({ task }) => gate.run(task));
|
||||
await tick();
|
||||
assert.deepEqual(log, ['a', 'b']);
|
||||
assert.equal(gate.pending, 4);
|
||||
tasks[0].finish();
|
||||
await tick();
|
||||
assert.deepEqual(log, ['a', 'b', 'c']);
|
||||
for (const { finish } of tasks) finish();
|
||||
assert.deepEqual(await Promise.all(results), ['a', 'b', 'c', 'd']);
|
||||
assert.equal(gate.pending, 0);
|
||||
});
|
||||
|
||||
test('lifo starts the newest queued task next', async () => {
|
||||
const log = [];
|
||||
const gate = createLimiter(1, { lifo: true });
|
||||
const tasks = ['a', 'b', 'c'].map((name) => held(log, name));
|
||||
const results = tasks.map(({ task }) => gate.run(task));
|
||||
await tick();
|
||||
tasks[0].finish();
|
||||
await tick();
|
||||
assert.deepEqual(log, ['a', 'c']);
|
||||
tasks[2].finish();
|
||||
tasks[1].finish();
|
||||
await Promise.all(results);
|
||||
});
|
||||
|
||||
test('a queued task whose signal aborts never runs, leaves the queue and rejects with the reason', async () => {
|
||||
const log = [];
|
||||
const gate = createLimiter(1);
|
||||
const first = held(log, 'a');
|
||||
const running = gate.run(first.task);
|
||||
const controller = new AbortController();
|
||||
const queued = gate.run(held(log, 'b').task, { signal: controller.signal });
|
||||
await tick();
|
||||
assert.equal(gate.pending, 2);
|
||||
controller.abort(new Error('overtaken'));
|
||||
await assert.rejects(queued, /overtaken/);
|
||||
assert.equal(gate.pending, 1, 'out of the queue');
|
||||
first.finish();
|
||||
await running;
|
||||
await tick();
|
||||
assert.deepEqual(log, ['a'], 'b never ran');
|
||||
});
|
||||
|
||||
test('a task already aborted when its turn comes is skipped', async () => {
|
||||
const log = [];
|
||||
const gate = createLimiter(1);
|
||||
const first = held(log, 'a');
|
||||
const running = gate.run(first.task);
|
||||
const signal = { aborted: false, reason: new Error('gone'), addEventListener() {} };
|
||||
const queued = gate.run(held(log, 'b').task, { signal });
|
||||
signal.aborted = true;
|
||||
first.finish();
|
||||
await running;
|
||||
await assert.rejects(queued, /gone/);
|
||||
assert.deepEqual(log, ['a']);
|
||||
});
|
||||
@@ -73,6 +73,7 @@ export function meshBoxInRange(box, centre) {
|
||||
* @property {number} viewRangePerHeight …or camera height times this.
|
||||
* @property {number} viewNearM Ground always covered around a tilted camera.
|
||||
* @property {boolean} [keepOrder] Cap a tile's lines in the order decoded (default: newest first).
|
||||
* @property {number} [maxPoints] Line points drawn at once, over every tile (default: no limit).
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -111,6 +112,9 @@ export function createLineCoverage({
|
||||
}) {
|
||||
const { render } = state.services;
|
||||
const perTileSequenceCap = Math.floor(tuning.maxSequences / tuning.maxTiles);
|
||||
const perTilePointCap = tuning.maxPoints
|
||||
? Math.floor(tuning.maxPoints / tuning.maxTiles)
|
||||
: Infinity;
|
||||
|
||||
/** Colour for a line: the provider's, GEV cyan while selected. */
|
||||
function sequenceColor({ selected = false } = {}) {
|
||||
@@ -151,13 +155,25 @@ export function createLineCoverage({
|
||||
function drawnSequences(entry) {
|
||||
const drawn = [];
|
||||
const current = filter();
|
||||
let points = 0;
|
||||
for (const sequence of entry.sequenceList) {
|
||||
if (drawn.length >= perTileSequenceCap) break;
|
||||
if (passes(sequence, current)) drawn.push(sequence);
|
||||
if (!passes(sequence, current)) continue;
|
||||
// A line past the point budget is skipped; a shorter one may still fit.
|
||||
const size = pointsOf(sequence);
|
||||
if (points + size > perTilePointCap) continue;
|
||||
points += size;
|
||||
drawn.push(sequence);
|
||||
}
|
||||
return drawn;
|
||||
}
|
||||
|
||||
function pointsOf(sequence) {
|
||||
let count = 0;
|
||||
for (const part of sequence.parts) count += part.length;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batched primitives for a tile's sequence parts: cast polylines where
|
||||
* terrain heights are cached, draped ground lines otherwise. Each batch
|
||||
|
||||
@@ -25,7 +25,15 @@ const TUNING = Object.freeze({
|
||||
*/
|
||||
function setup(
|
||||
t,
|
||||
{ zoomFor, messages = { rateLimited: 'busy' }, passes, fail } = {},
|
||||
{
|
||||
zoomFor,
|
||||
messages = { rateLimited: 'busy' },
|
||||
passes,
|
||||
fail,
|
||||
decoded = null,
|
||||
tuning = TUNING,
|
||||
overviewPointSize = null,
|
||||
} = {},
|
||||
) {
|
||||
const centre = tileCentre(-121.4944, 38.5816);
|
||||
const viewer = coverageViewer(
|
||||
@@ -68,6 +76,7 @@ function setup(
|
||||
fetchTile: async (tile) => {
|
||||
asked.push(tile);
|
||||
if (fail) throw fail;
|
||||
if (decoded) return decoded(centre);
|
||||
return {
|
||||
sequences: [
|
||||
{
|
||||
@@ -94,9 +103,10 @@ function setup(
|
||||
colors: { coverage: '#635bff', selected: '#00e5ff' },
|
||||
pickPrefix: 'test:line:',
|
||||
renderKey: 'test-coverage',
|
||||
tuning: TUNING,
|
||||
tuning,
|
||||
messages,
|
||||
...(passes ? { passes } : {}),
|
||||
...(overviewPointSize ? { overviewPointSize } : {}),
|
||||
});
|
||||
t.after(() => {
|
||||
coverage.clear();
|
||||
@@ -140,3 +150,88 @@ test('a refused key without its own message keeps the error it came with', async
|
||||
assert.ok(await until(() => state.keyRejected === true));
|
||||
assert.equal(state.coverage.lastError, 'the source refused the key');
|
||||
});
|
||||
|
||||
/** Three lines in one tile: an old long one, then two newer short ones. */
|
||||
function threeLines(centre) {
|
||||
const line = (id, capturedAt, span) => ({
|
||||
id,
|
||||
parts: [
|
||||
[
|
||||
[centre.lon - span, centre.lat],
|
||||
[centre.lon + span, centre.lat],
|
||||
],
|
||||
],
|
||||
capturedAt,
|
||||
isPano: false,
|
||||
});
|
||||
return {
|
||||
sequences: [
|
||||
line('old-long', 1, 0.002),
|
||||
line('new-short', 2, 0.0002),
|
||||
line('newer-short', 3, 0.0001),
|
||||
],
|
||||
overview: [],
|
||||
};
|
||||
}
|
||||
|
||||
test('the per-tile cap keeps the newest lines, or the provider’s own order with keepOrder', async (t) => {
|
||||
const capped = { ...TUNING, maxTiles: 1, maxSequences: 2 };
|
||||
const drawnIds = (state) =>
|
||||
[...state.coverage.tiles.values()].flatMap((tile) => [
|
||||
...tile.sequences.keys(),
|
||||
]);
|
||||
const newest = setup(t, { decoded: threeLines, tuning: capped });
|
||||
newest.coverage.refresh();
|
||||
assert.ok(await until(() => newest.state.coverage.tiles.size === 1));
|
||||
assert.deepEqual(drawnIds(newest.state).sort(), ['new-short', 'newer-short']);
|
||||
const kept = setup(t, {
|
||||
decoded: threeLines,
|
||||
tuning: { ...capped, keepOrder: true },
|
||||
});
|
||||
kept.coverage.refresh();
|
||||
assert.ok(await until(() => kept.state.coverage.tiles.size === 1));
|
||||
assert.deepEqual(drawnIds(kept.state), ['old-long', 'new-short']);
|
||||
});
|
||||
|
||||
test('overview points take the provider’s size, each its own', async (t) => {
|
||||
const { state, coverage, viewer } = setup(t, {
|
||||
zoomFor: () => ({ zoom: 3, kind: 'overview' }),
|
||||
tuning: { ...TUNING, overviewMaxTiles: 4 },
|
||||
decoded: (centre) => ({
|
||||
sequences: [],
|
||||
overview: [
|
||||
{ id: 'a', lon: centre.lon, lat: centre.lat, weight: 1 },
|
||||
{ id: 'b', lon: centre.lon + 0.01, lat: centre.lat, weight: 50_000 },
|
||||
],
|
||||
}),
|
||||
overviewPointSize: (point) => (point.weight > 1 ? 7 : 3),
|
||||
});
|
||||
coverage.refresh();
|
||||
assert.ok(await until(() => state.coverage.tiles.size > 0));
|
||||
const sizes = [];
|
||||
for (const primitive of viewer.scene.primitives.items)
|
||||
for (let i = 0; i < (primitive.length ?? 0); i++)
|
||||
sizes.push(primitive.get(i).pixelSize);
|
||||
assert.deepEqual(sizes.sort(), [3, 7]);
|
||||
});
|
||||
|
||||
test('a point budget skips lines past it, and a shorter one later may still fit', async (t) => {
|
||||
const { state, coverage } = setup(t, {
|
||||
tuning: { ...TUNING, maxTiles: 1, maxSequences: 10, maxPoints: 5, keepOrder: true },
|
||||
decoded: (centre) => {
|
||||
const line = (id, n) => ({
|
||||
id,
|
||||
parts: [
|
||||
Array.from({ length: n }, (_, i) => [centre.lon - 0.001 + i * 0.0001, centre.lat]),
|
||||
],
|
||||
capturedAt: 0,
|
||||
isPano: false,
|
||||
});
|
||||
return { sequences: [line('three', 3), line('four', 4), line('two', 2)], overview: [] };
|
||||
},
|
||||
});
|
||||
coverage.refresh();
|
||||
assert.ok(await until(() => state.coverage.tiles.size === 1));
|
||||
const [tile] = state.coverage.tiles.values();
|
||||
assert.deepEqual([...tile.sequences.keys()], ['three', 'two']);
|
||||
});
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
/** A photo download (tens of kilobytes to a few megabytes). */
|
||||
export const IMAGE_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** A picture file or decoded side past these is refused (a photo is a few MB). */
|
||||
export const MAX_IMAGE_BYTES = 48 * 1024 * 1024;
|
||||
export const MAX_IMAGE_SIDE = 16_384;
|
||||
|
||||
/** Panorama texture widths: enough for a docked viewer, and for an expanded one. */
|
||||
export const PANO_MID_SIZE = Object.freeze([2048, 1024]);
|
||||
export const PANO_FULL_SIZE = Object.freeze([4096, 2048]);
|
||||
@@ -27,6 +31,7 @@ export function loadImage(
|
||||
timeoutMs = IMAGE_TIMEOUT_MS,
|
||||
resize = null,
|
||||
failure = 'The photo could not be loaded',
|
||||
signal = null,
|
||||
} = {},
|
||||
) {
|
||||
if (
|
||||
@@ -35,7 +40,7 @@ export function loadImage(
|
||||
typeof globalThis.createImageBitmap === 'function' &&
|
||||
typeof globalThis.fetch === 'function'
|
||||
)
|
||||
return loadBitmap(url, timeoutMs, resize, failure);
|
||||
return loadBitmap(url, timeoutMs, resize, failure, signal);
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!url) return reject(new Error(failure));
|
||||
const image = new Image();
|
||||
@@ -57,27 +62,42 @@ export function loadImage(
|
||||
});
|
||||
}
|
||||
|
||||
async function loadBitmap(url, timeoutMs, resize, failure) {
|
||||
async function loadBitmap(url, timeoutMs, resize, failure, signal) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
// The caller's abort (the photo changed) stops a multi-megabyte download.
|
||||
const onAbort = () => controller.abort();
|
||||
if (signal?.aborted) controller.abort();
|
||||
signal?.addEventListener?.('abort', onAbort, { once: true });
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
mode: 'cors',
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(failure);
|
||||
// Picture hosts are not ours: refuse a file or an image past any real
|
||||
// photo's size before it is decoded.
|
||||
const length = Number(response.headers?.get?.('content-length'));
|
||||
if (length > MAX_IMAGE_BYTES) throw new Error(failure);
|
||||
const blob = await response.blob();
|
||||
return await (resize
|
||||
if (blob.size > MAX_IMAGE_BYTES) throw new Error(failure);
|
||||
const bitmap = await (resize
|
||||
? createImageBitmap(blob, {
|
||||
resizeWidth: resize[0],
|
||||
resizeHeight: resize[1],
|
||||
resizeQuality: 'high',
|
||||
})
|
||||
: createImageBitmap(blob));
|
||||
if (bitmap.width > MAX_IMAGE_SIDE || bitmap.height > MAX_IMAGE_SIDE) {
|
||||
bitmap.close?.();
|
||||
throw new Error(failure);
|
||||
}
|
||||
return bitmap;
|
||||
} catch {
|
||||
throw new Error(failure);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener?.('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,8 +112,8 @@ export function panoSizeFor(canvasPixels, fovDeg) {
|
||||
* @property {(photo: object) => boolean} hasPano A 360° photo with an equirectangular image.
|
||||
* @property {(photo: object) => {url: string, width: number, resize?: [number, number]|null}|null} panoFirst
|
||||
* The panorama shown first, its texture width (0 when it is only a preview), and a size to shrink it to.
|
||||
* @property {(photo: object, size: [number, number]) => {url: string, resize?: [number, number]|null, timeoutMs?: number}|null} panoSharp
|
||||
* A sharper panorama for a canvas that needs `size`, or null.
|
||||
* @property {(photo: object, size: [number, number], view: {expanded: boolean}) => {url: string, resize?: [number, number]|null, timeoutMs?: number}|null} panoSharp
|
||||
* A sharper panorama for a canvas that needs `size`, or null; `expanded` while the viewer is.
|
||||
* @property {(photo: object) => Array<string>} flatFirst Flat versions to try in turn.
|
||||
* @property {(photo: object, pixels: number) => string|null} flatSharp
|
||||
* A sharper flat version for a picture this many device pixels wide, or null.
|
||||
@@ -411,6 +431,24 @@ export function createPhotoViewer({
|
||||
renderer.setView({ fov: fov * Math.exp(event.deltaY * 0.001) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Docked, Esc anywhere in the viewer's box (the photo, its toolbar, its
|
||||
* caption) is the provider's before the panel's, which would collapse;
|
||||
* expanded, the dialog's own Esc shrinks it.
|
||||
*/
|
||||
function onEscapeKey(event) {
|
||||
if (
|
||||
event.key !== 'Escape' ||
|
||||
event.shiftKey ||
|
||||
event.defaultPrevented ||
|
||||
root?.closest?.('.sl-viewer-wrap-expanded') ||
|
||||
!onEscape()
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function onKeyDown(event) {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
@@ -419,18 +457,6 @@ export function createPhotoViewer({
|
||||
event.metaKey
|
||||
)
|
||||
return;
|
||||
// Docked, Esc is the viewer's before the panel's (which would collapse);
|
||||
// expanded, the dialog's own Esc shrinks it.
|
||||
if (
|
||||
event.key === 'Escape' &&
|
||||
!event.shiftKey &&
|
||||
!root?.closest?.('.sl-viewer-wrap-expanded') &&
|
||||
onEscape()
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
const pano = showing === 'pano' && renderer;
|
||||
const turn = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
|
||||
// Shift+← → look around a panorama; ← → step along the sequence.
|
||||
@@ -502,6 +528,7 @@ export function createPhotoViewer({
|
||||
root.append(picture, canvas, prevButton, nextButton);
|
||||
host.append(root);
|
||||
listen(root, 'keydown', onKeyDown);
|
||||
listen(host.closest?.('.sl-viewer-wrap') ?? root, 'keydown', onEscapeKey);
|
||||
applyRenderMode();
|
||||
}
|
||||
|
||||
@@ -536,13 +563,18 @@ export function createPhotoViewer({
|
||||
const size = panoSizeNow();
|
||||
if (size[0] <= panoWidth) return;
|
||||
if (panoComing?.seq === seq && size[0] <= panoComing.width) return;
|
||||
const sharp = sources.panoSharp(target, size);
|
||||
const sharp = sources.panoSharp(target, size, {
|
||||
expanded: Boolean(
|
||||
root?.closest?.('.sl-viewer-wrap-expanded'),
|
||||
),
|
||||
});
|
||||
if (!sharp?.url) return;
|
||||
const coming = { seq, width: size[0] };
|
||||
const coming = { seq, width: size[0], controller: new AbortController() };
|
||||
panoComing = coming;
|
||||
try {
|
||||
const image = await loadAs(sharp.url, {
|
||||
crossOrigin: true,
|
||||
signal: coming.controller.signal,
|
||||
resize: sharp.resize ?? null,
|
||||
...(sharp.timeoutMs ? { timeoutMs: sharp.timeoutMs } : {}),
|
||||
});
|
||||
@@ -578,6 +610,12 @@ export function createPhotoViewer({
|
||||
}
|
||||
}
|
||||
|
||||
/** Another photo, or none: a sharper panorama still on its way is not needed. */
|
||||
function dropSharpPanorama() {
|
||||
panoComing?.controller?.abort();
|
||||
panoComing = null;
|
||||
}
|
||||
|
||||
/** The sharp panorama this viewer needs now: 2,048 docked, 4,096 expanded. */
|
||||
function panoSizeNow() {
|
||||
const ratio = Math.min(globalThis.devicePixelRatio || 1, 2);
|
||||
@@ -600,7 +638,10 @@ export function createPhotoViewer({
|
||||
}
|
||||
// The context may have been lost while this loaded: build on the new
|
||||
// canvas, or let the caller show the front view.
|
||||
if (!ensureRenderer()) throw new Error(LOAD_FAILED);
|
||||
if (!ensureRenderer()) {
|
||||
image.close?.();
|
||||
throw new Error(LOAD_FAILED);
|
||||
}
|
||||
const restore = restoreView?.id === target.id ? restoreView.view : null;
|
||||
restoreView = null;
|
||||
if (restore) renderer.setView(restore);
|
||||
@@ -670,6 +711,7 @@ export function createPhotoViewer({
|
||||
if (!root) throw new Error(`${name} viewer is not mounted`);
|
||||
const id = String(imageId);
|
||||
const seq = ++openSeq;
|
||||
dropSharpPanorama();
|
||||
const keepView = keepViewFor === id;
|
||||
keepViewFor = null;
|
||||
openingId = id;
|
||||
@@ -682,6 +724,7 @@ export function createPhotoViewer({
|
||||
|
||||
close() {
|
||||
openSeq++;
|
||||
dropSharpPanorama();
|
||||
openingId = null;
|
||||
keepViewFor = null;
|
||||
photo = null;
|
||||
@@ -693,6 +736,7 @@ export function createPhotoViewer({
|
||||
|
||||
unmount() {
|
||||
openSeq++;
|
||||
dropSharpPanorama();
|
||||
openingId = null;
|
||||
keepViewFor = null;
|
||||
flatSharpUrl = null;
|
||||
|
||||
@@ -164,3 +164,144 @@ test('Esc: the docked viewer offers it to the provider first; expanded, it is th
|
||||
assert.equal(asked, 1, 'not asked while expanded');
|
||||
assert.equal(expanded.prevented, false);
|
||||
});
|
||||
|
||||
test('a sharp panorama still downloading when another photo opens is aborted', async (t) => {
|
||||
const signals = [];
|
||||
const viewer = createPhotoViewer({
|
||||
providerId: 'test',
|
||||
name: 'Test',
|
||||
sources: SOURCES,
|
||||
getPhoto: async (id) => PHOTOS[id],
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
load: (url, options) =>
|
||||
url.endsWith('-sharp')
|
||||
? new Promise((_, reject) => {
|
||||
signals.push(options.signal);
|
||||
options.signal?.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
})
|
||||
: Promise.resolve({ src: url }),
|
||||
createRenderer: () => ({
|
||||
setImage() {},
|
||||
setView() {},
|
||||
getView: () => ({ yaw: 0, pitch: 0, fov: 80 }),
|
||||
resize() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
}),
|
||||
});
|
||||
t.after(() => delete globalThis.devicePixelRatio);
|
||||
globalThis.devicePixelRatio = 1;
|
||||
const host = fakeHost();
|
||||
await viewer.mount(host);
|
||||
parts(host).canvas.clientWidth = 1800;
|
||||
await viewer.open('a');
|
||||
await tick();
|
||||
assert.equal(signals.length, 1);
|
||||
assert.equal(signals[0].aborted, false);
|
||||
await viewer.open('f');
|
||||
assert.equal(signals[0].aborted, true, 'not needed any more');
|
||||
});
|
||||
|
||||
test('a lost WebGL context while the first panorama loads releases its bitmap', async () => {
|
||||
let closed = false;
|
||||
let built = 0;
|
||||
let land = null;
|
||||
const viewer = createPhotoViewer({
|
||||
providerId: 'test',
|
||||
name: 'Test',
|
||||
sources: SOURCES,
|
||||
getPhoto: async (id) => PHOTOS[id],
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
load: (url) =>
|
||||
new Promise((resolve) => {
|
||||
land = () => resolve({ src: url, close: () => (closed = true) });
|
||||
}),
|
||||
// The first renderer is lost mid-load; a new one cannot be built.
|
||||
createRenderer: () => {
|
||||
built++;
|
||||
if (built > 1) throw new Error('context lost');
|
||||
return {
|
||||
setImage() {},
|
||||
setView() {},
|
||||
getView: () => ({ yaw: 0, pitch: 0, fov: 80 }),
|
||||
resize() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
};
|
||||
},
|
||||
});
|
||||
const host = fakeHost();
|
||||
await viewer.mount(host);
|
||||
const opening = viewer.open('a');
|
||||
assert.ok(await until(() => land !== null), 'the panorama is loading');
|
||||
parts(host).canvas.fire('webglcontextlost', {});
|
||||
const first = land;
|
||||
first();
|
||||
// The panorama failed: the front view is loaded flat instead.
|
||||
assert.ok(await until(() => land !== first), 'the flat fallback loads');
|
||||
land();
|
||||
await opening;
|
||||
assert.equal(built, 2, 'a new renderer was tried');
|
||||
assert.equal(closed, true, 'the bitmap no renderer took is closed');
|
||||
});
|
||||
|
||||
test('docked, Esc anywhere in the viewer’s box (toolbar, caption) is offered to the provider', async () => {
|
||||
let asked = 0;
|
||||
const viewer = createPhotoViewer({
|
||||
providerId: 'test',
|
||||
name: 'Test',
|
||||
sources: SOURCES,
|
||||
getPhoto: async (id) => PHOTOS[id],
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
onEscape: () => ++asked > 0,
|
||||
load: async (url) => ({ src: url }),
|
||||
});
|
||||
const host = fakeHost();
|
||||
const wrap = fakeHost();
|
||||
host.closest = (selector) => (selector === '.sl-viewer-wrap' ? wrap : null);
|
||||
await viewer.mount(host);
|
||||
const flags = { stopped: false, prevented: false };
|
||||
// Focus back on EXPAND after a shrink: the key reaches the wrap, not the photo.
|
||||
wrap.fire('keydown', {
|
||||
key: 'Escape',
|
||||
stopPropagation: () => (flags.stopped = true),
|
||||
preventDefault: () => (flags.prevented = true),
|
||||
});
|
||||
assert.equal(asked, 1);
|
||||
assert.deepEqual(flags, { stopped: true, prevented: true });
|
||||
viewer.unmount();
|
||||
wrap.fire('keydown', { key: 'Escape' });
|
||||
assert.equal(asked, 1, 'unmounted: the listener is gone');
|
||||
});
|
||||
|
||||
test('a picture file or bitmap past any real photo’s size is refused', async (t) => {
|
||||
const { loadImage, MAX_IMAGE_BYTES, MAX_IMAGE_SIDE } = await import(
|
||||
'./photoViewer.js'
|
||||
);
|
||||
const saved = {
|
||||
fetch: globalThis.fetch,
|
||||
createImageBitmap: globalThis.createImageBitmap,
|
||||
};
|
||||
t.after(() => Object.assign(globalThis, saved));
|
||||
let closed = 0;
|
||||
const bitmap = (width, height) => ({ width, height, close: () => closed++ });
|
||||
globalThis.createImageBitmap = async (blob) => blob.bitmap;
|
||||
const respond = ({ length, size, width = 100, height = 50 }) =>
|
||||
(globalThis.fetch = async () => ({
|
||||
ok: true,
|
||||
headers: new Map([['content-length', String(length ?? '')]]),
|
||||
blob: async () => ({ size: size ?? 10, bitmap: bitmap(width, height) }),
|
||||
}));
|
||||
respond({ length: MAX_IMAGE_BYTES + 1 });
|
||||
await assert.rejects(loadImage('https://x/a.jpg'), /could not be loaded/);
|
||||
respond({ size: MAX_IMAGE_BYTES + 1 });
|
||||
await assert.rejects(loadImage('https://x/a.jpg'), /could not be loaded/);
|
||||
respond({ width: MAX_IMAGE_SIDE + 1 });
|
||||
await assert.rejects(loadImage('https://x/a.jpg'), /could not be loaded/);
|
||||
assert.equal(closed, 1, 'the oversize bitmap is released');
|
||||
respond({});
|
||||
assert.equal((await loadImage('https://x/a.jpg')).width, 100);
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { createLineCoverage } from '../../lineCoverage.js';
|
||||
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';
|
||||
@@ -31,9 +34,6 @@ import {
|
||||
const PHOTO_CACHE_SIZE = 4000;
|
||||
/** Sequence pages kept in memory. */
|
||||
const PAGE_CACHE_SIZE = 12;
|
||||
/** Decoded coverage tiles kept, and for how long. */
|
||||
const TILE_CACHE_SIZE = 24;
|
||||
const TILE_CACHE_TTL_MS = 10 * 60_000;
|
||||
|
||||
/** z14 near the street, z13 a little higher, nothing above. */
|
||||
export function kartaViewZoomFor(heightM) {
|
||||
@@ -44,51 +44,6 @@ export function kartaViewZoomFor(heightM) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Least-recently-used map: reading an entry renews it. */
|
||||
function createLru(limit) {
|
||||
const map = new Map();
|
||||
return {
|
||||
get(key) {
|
||||
if (!map.has(key)) return undefined;
|
||||
const value = map.get(key);
|
||||
map.delete(key);
|
||||
map.set(key, value);
|
||||
return value;
|
||||
},
|
||||
set(key, value) {
|
||||
map.delete(key);
|
||||
map.set(key, value);
|
||||
while (map.size > limit) map.delete(map.keys().next().value);
|
||||
},
|
||||
delete: (key) => map.delete(key),
|
||||
clear: () => map.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded coverage tiles by `z/x/y`, at most `size`, each for `ttlMs`. KartaView
|
||||
* sends tiles uncompressed and uncacheable (hundreds of kB, seconds each), so
|
||||
* a pan back or a zoom across z13/z14 would fetch them all again.
|
||||
*/
|
||||
export function createTileCache({
|
||||
load,
|
||||
size = TILE_CACHE_SIZE,
|
||||
ttlMs = TILE_CACHE_TTL_MS,
|
||||
now = Date.now,
|
||||
}) {
|
||||
const kept = createLru(size);
|
||||
return {
|
||||
async get(tile, options) {
|
||||
const key = `${tile.z}/${tile.x}/${tile.y}`;
|
||||
const entry = kept.get(key);
|
||||
if (entry && now() - entry.at < ttlMs) return entry.tile;
|
||||
const loaded = await load(tile, options);
|
||||
kept.set(key, { at: now(), tile: loaded });
|
||||
return loaded;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The 1-based page of a sequence that holds `index`, if no photo is missing. */
|
||||
function pageOf(index) {
|
||||
return Math.floor(Math.max(0, index) / SEQUENCE_PAGE_SIZE) + 1;
|
||||
|
||||
@@ -746,3 +746,24 @@ test('a photo no page lists steps to the photos either side of its index', async
|
||||
await press(env.prev, env.poses);
|
||||
assert.equal(env.poses.at(-1), 's-4');
|
||||
});
|
||||
|
||||
test('Esc in the docked viewer clears the selected drive and is consumed; with none, it goes on to the panel', async (t) => {
|
||||
const env = await mounted({ sequences: { s: [0, 1, 2, 3] } });
|
||||
await withCones(t, env);
|
||||
await env.instance.viewer.open('s-1');
|
||||
await env.instance.selectSequence('s');
|
||||
assert.ok(await until(() => env.instance.sequenceStats().images === 4));
|
||||
const root = env.next.parentNode;
|
||||
const press = () => {
|
||||
const flags = { stopped: false, prevented: false };
|
||||
root.fire('keydown', {
|
||||
key: 'Escape',
|
||||
stopPropagation: () => (flags.stopped = true),
|
||||
preventDefault: () => (flags.prevented = true),
|
||||
});
|
||||
return flags;
|
||||
};
|
||||
assert.deepEqual(press(), { stopped: true, prevented: true });
|
||||
assert.equal(env.instance.sequenceStats().selectedId, null, 'drive cleared');
|
||||
assert.deepEqual(press(), { stopped: false, prevented: false });
|
||||
});
|
||||
@@ -1,7 +1,49 @@
|
||||
import { PbfReader } from 'pbf';
|
||||
import { VectorTile } from '@mapbox/vector-tile';
|
||||
import { tileLocalToLonLat } from '../../tileMath.js';
|
||||
import { OVERVIEW_BINS_PER_AXIS, panoramaxTimeMs } from './policy.js';
|
||||
import {
|
||||
OVERVIEW_BINS_PER_AXIS,
|
||||
SIMPLIFY_TOLERANCE_UNITS,
|
||||
panoramaxTimeMs,
|
||||
} from './policy.js';
|
||||
|
||||
/**
|
||||
* Douglas–Peucker in tile units: the points of `line` ({x, y}) that keep
|
||||
* it within `tolerance` of the original, first and last always kept.
|
||||
* Iterative, so a long track cannot overflow the stack.
|
||||
*/
|
||||
export function simplifyLine(line, tolerance) {
|
||||
if (line.length < 3 || !(tolerance > 0)) return line;
|
||||
const keep = new Uint8Array(line.length);
|
||||
keep[0] = 1;
|
||||
keep[line.length - 1] = 1;
|
||||
const spans = [[0, line.length - 1]];
|
||||
while (spans.length) {
|
||||
const [from, to] = spans.pop();
|
||||
const a = line[from];
|
||||
const b = line[to];
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const span = Math.hypot(dx, dy);
|
||||
let far = -1;
|
||||
let farthest = tolerance;
|
||||
for (let i = from + 1; i < to; i++) {
|
||||
const p = line[i];
|
||||
// Distance to the chord, or to `a` where the chord has no length.
|
||||
const d = span
|
||||
? Math.abs((p.x - a.x) * dy - (p.y - a.y) * dx) / span
|
||||
: Math.hypot(p.x - a.x, p.y - a.y);
|
||||
if (d > farthest) {
|
||||
farthest = d;
|
||||
far = i;
|
||||
}
|
||||
}
|
||||
if (far === -1) continue;
|
||||
keep[far] = 1;
|
||||
spans.push([from, far], [far, to]);
|
||||
}
|
||||
return line.filter((_, i) => keep[i]);
|
||||
}
|
||||
|
||||
/** Epoch ms of a sequence's day ('2025-08-11'), or 0 when undated. */
|
||||
function dayMs(value) {
|
||||
@@ -36,6 +78,8 @@ export function decodeCoverageTile(bytes, address) {
|
||||
const props = feature.properties || {};
|
||||
const parts = [];
|
||||
let length = 0;
|
||||
const tolerance =
|
||||
(SIMPLIFY_TOLERANCE_UNITS * sequences.extent) / 4096;
|
||||
for (const line of feature.loadGeometry()) {
|
||||
if (line.length < 2) continue;
|
||||
for (let k = 1; k < line.length; k++)
|
||||
@@ -43,8 +87,10 @@ export function decodeCoverageTile(bytes, address) {
|
||||
line[k].x - line[k - 1].x,
|
||||
line[k].y - line[k - 1].y,
|
||||
);
|
||||
// Simplified before anything else: GPS tracks carry a point every
|
||||
// few metres, and each becomes ~8 ground-line vertices on the GPU.
|
||||
parts.push(
|
||||
line.map((point) =>
|
||||
simplifyLine(line, tolerance).map((point) =>
|
||||
tileLocalToLonLat(point.x, point.y, sequences.extent, x, y, z),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { decodeCoverageTile, decodePictures } from './decode.js';
|
||||
import { decodeCoverageTile, decodePictures, simplifyLine } from './decode.js';
|
||||
import { OVERVIEW_BINS_PER_AXIS } from './policy.js';
|
||||
import { encodePanoramaxTile } from '../../../../testSupport/panoramaxFixtures.mjs';
|
||||
import { tileCentre } from '../../../../testSupport/streetLevelFakes.mjs';
|
||||
@@ -189,3 +189,38 @@ test('a z15 tile’s pictures of one sequence become cones; other sequences’ a
|
||||
});
|
||||
assert.deepEqual(decodePictures(lines, tile, 's1'), []);
|
||||
});
|
||||
|
||||
test('lines are simplified within a few tile units, ends and corners kept', () => {
|
||||
const zigzag = Array.from({ length: 1000 }, (_, i) => ({ x: i, y: i % 2 ? 1 : 0 }));
|
||||
assert.deepEqual(simplifyLine(zigzag, 4), [zigzag[0], zigzag.at(-1)], 'a 1-unit wobble goes');
|
||||
const corner = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 50, y: 1 },
|
||||
{ x: 100, y: 0 },
|
||||
{ x: 100, y: 100 },
|
||||
];
|
||||
assert.deepEqual(simplifyLine(corner, 4), [corner[0], corner[2], corner[3]]);
|
||||
assert.equal(simplifyLine(corner.slice(0, 2), 4).length, 2);
|
||||
// A loop back to its start (no chord) keeps its far point.
|
||||
const loop = [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 40, y: 0 },
|
||||
{ x: 0, y: 0 },
|
||||
];
|
||||
assert.equal(simplifyLine(loop, 4).length, 3);
|
||||
assert.equal(simplifyLine(zigzag, 0).length, 1000, 'no tolerance, no change');
|
||||
});
|
||||
|
||||
test('a decoded GPS track keeps a fraction of its points', () => {
|
||||
const { tile, lon, lat } = tileCentre(2.35, 48.85);
|
||||
const track = Array.from({ length: 500 }, (_, i) => [
|
||||
lon - 0.004 + i * 0.000016,
|
||||
lat + Math.sin(i / 40) * 0.0004,
|
||||
]);
|
||||
const [sequence] = decodeCoverageTile(
|
||||
encodePanoramaxTile(tile, { sequences: [{ id: 'gps', parts: [track] }] }),
|
||||
tile,
|
||||
).sequences;
|
||||
assert.ok(sequence.parts[0].length < 100, `${sequence.parts[0].length} points`);
|
||||
assert.ok(sequence.parts[0].length > 5);
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
tileBounds,
|
||||
} from '../../tileMath.js';
|
||||
import { metresBetween } from '../../view.js';
|
||||
import { createLru, createTileCache } from '../../tileCache.js';
|
||||
import { decodeCoverageTile, decodePictures } from './decode.js';
|
||||
import { createPanoramaxSource } from './source.js';
|
||||
import { createPanoramaxViewer } from './viewer.js';
|
||||
@@ -70,6 +71,10 @@ export function overviewPointSize(point) {
|
||||
const PHOTO_CACHE_SIZE = 2000;
|
||||
/** Sequences whose cone centre is remembered. */
|
||||
const ANCHOR_CACHE_SIZE = 50;
|
||||
/** Decoded coverage tiles kept (a simplified dense z10 tile is ~4 MB). */
|
||||
const COVERAGE_TILE_CACHE_SIZE = 16;
|
||||
/** One sequence's pictures in a z15 tile, kept for re-centring. */
|
||||
const PICTURE_LIST_CACHE_SIZE = 32;
|
||||
|
||||
/**
|
||||
* Sequence lines at z10–14 below 60 km; above, the density grid at z5 and
|
||||
@@ -116,26 +121,6 @@ export function passesCoverage(record, filter) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Least-recently-used map: reading an entry renews it. */
|
||||
function createLru(limit) {
|
||||
const map = new Map();
|
||||
return {
|
||||
get(key) {
|
||||
if (!map.has(key)) return undefined;
|
||||
const value = map.get(key);
|
||||
map.delete(key);
|
||||
map.set(key, value);
|
||||
return value;
|
||||
},
|
||||
set(key, value) {
|
||||
map.delete(key);
|
||||
map.set(key, value);
|
||||
while (map.size > limit) map.delete(map.keys().next().value);
|
||||
},
|
||||
delete: (key) => map.delete(key),
|
||||
};
|
||||
}
|
||||
|
||||
/** The z15 tile holding a point. */
|
||||
function pictureTileOf({ lon, lat }) {
|
||||
const z = PICTURE_TILE_ZOOM;
|
||||
@@ -143,29 +128,29 @@ function pictureTileOf({ lon, lat }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The z15 tiles whose pictures surround a point: its own, and the
|
||||
* neighbours it sits within PICTURE_TILE_EDGE_M of (at most four).
|
||||
* The z15 tiles whose pictures surround a point: its own first, and the
|
||||
* neighbours it sits within PICTURE_TILE_EDGE_M of (at most nine).
|
||||
*/
|
||||
export function pictureTilesAround(point) {
|
||||
const home = pictureTileOf(point);
|
||||
const box = tileBounds(home.x, home.y, home.z);
|
||||
const near = (lon, lat) =>
|
||||
metresBetween(point, { lon, lat }) <= PICTURE_TILE_EDGE_M;
|
||||
const dx = near(box.west, point.lat) ? -1 : near(box.east, point.lat) ? 1 : 0;
|
||||
const dy = near(point.lon, box.north)
|
||||
? -1
|
||||
: near(point.lon, box.south)
|
||||
? 1
|
||||
: 0;
|
||||
// Near both edges at once where tiles are narrow (high latitudes).
|
||||
const dxs = [0];
|
||||
if (near(box.west, point.lat)) dxs.push(-1);
|
||||
if (near(box.east, point.lat)) dxs.push(1);
|
||||
const dys = [0];
|
||||
if (near(point.lon, box.north)) dys.push(-1);
|
||||
if (near(point.lon, box.south)) dys.push(1);
|
||||
const max = 2 ** home.z;
|
||||
const tiles = [home];
|
||||
const add = (x, y) => {
|
||||
if (y < 0 || y >= max) return;
|
||||
tiles.push({ x: (x + max) % max, y, z: home.z });
|
||||
};
|
||||
if (dx) add(home.x + dx, home.y);
|
||||
if (dy) add(home.x, home.y + dy);
|
||||
if (dx && dy) add(home.x + dx, home.y + dy);
|
||||
const tiles = [];
|
||||
for (const dy of dys)
|
||||
for (const dx of dxs) {
|
||||
const y = home.y + dy;
|
||||
if (y < 0 || y >= max) continue;
|
||||
tiles.push({ x: (home.x + dx + max) % max, y, z: home.z });
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
@@ -290,12 +275,36 @@ export function createPanoramaxProvider({
|
||||
return (delta < 0 ? photo.prevId : photo.nextId) || null;
|
||||
}
|
||||
|
||||
const coverage = createLineCoverage({
|
||||
state,
|
||||
fetchTile: async (tile, { signal }) =>
|
||||
// Decoded tiles, kept a while: panning back, or across a zoom band and
|
||||
// back, neither fetches nor decodes them again.
|
||||
const coverageTiles = createTileCache({
|
||||
size: COVERAGE_TILE_CACHE_SIZE,
|
||||
load: (tile, { signal }) =>
|
||||
readable(async () =>
|
||||
decodeCoverageTile(await source.getTile(tile, { signal }), tile),
|
||||
),
|
||||
});
|
||||
/** A z15 tile's pictures of one sequence, by `z/x/y:sequence`. */
|
||||
const pictureLists = createLru(PICTURE_LIST_CACHE_SIZE);
|
||||
function picturesIn(tile, sequenceId, signal) {
|
||||
const key = `${tileKey(tile)}:${sequenceId}`;
|
||||
const kept = pictureLists.get(key);
|
||||
if (kept) return kept;
|
||||
const list = readable(async () =>
|
||||
decodePictures(
|
||||
await source.getTile(tile, { signal, lane: 'pictures' }),
|
||||
tile,
|
||||
sequenceId,
|
||||
),
|
||||
);
|
||||
pictureLists.set(key, list);
|
||||
list.catch(() => pictureLists.delete(key));
|
||||
return list;
|
||||
}
|
||||
|
||||
const coverage = createLineCoverage({
|
||||
state,
|
||||
fetchTile: (tile, options) => coverageTiles.get(tile, options),
|
||||
zoomFor: panoramaxZoomFor,
|
||||
colors: COLORS,
|
||||
pickPrefix: PICK_PREFIX.sequence,
|
||||
@@ -312,21 +321,15 @@ export function createPanoramaxProvider({
|
||||
const anchor = anchors.get(sequenceId);
|
||||
if (!anchor) return [];
|
||||
const tiles = pictureTilesAround(anchor);
|
||||
// Known from the start: a picture opening while these load is
|
||||
// judged against them (re-centred if outside, left alone inside).
|
||||
drawn = { sequenceId, tiles: new Set(tiles.map(tileKey)) };
|
||||
// The home tile must load; a neighbour that fails only thins the cones.
|
||||
const lists = await Promise.allSettled(
|
||||
tiles.map((tile) =>
|
||||
readable(async () =>
|
||||
decodePictures(
|
||||
await source.getTile(tile, { signal }),
|
||||
tile,
|
||||
sequenceId,
|
||||
),
|
||||
),
|
||||
),
|
||||
tiles.map((tile) => picturesIn(tile, sequenceId, signal)),
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (lists[0].status === 'rejected') throw lists[0].reason;
|
||||
drawn = { sequenceId, tiles: new Set(tiles.map(tileKey)) };
|
||||
const byId = new Map(
|
||||
lists
|
||||
.flatMap((list) => list.value ?? [])
|
||||
@@ -445,13 +448,18 @@ export function createPanoramaxProvider({
|
||||
const count = lines ? drawn : 0;
|
||||
const loading = state.coverage.pending.size > 0;
|
||||
const settled = !loading && !state.coverage.lastError;
|
||||
// Lines in view that the filter hid all of: say so, not "none here".
|
||||
const filteredOut =
|
||||
lines &&
|
||||
[...state.coverage.tiles.values()].some((tile) => tile.total > 0);
|
||||
let fallback = '';
|
||||
// Tiles that held something the filter hid all of.
|
||||
const filteredOut = [...state.coverage.tiles.values()].some(
|
||||
(tile) => tile.total > 0,
|
||||
);
|
||||
if (kind === 'overview')
|
||||
fallback = drawn > 0 || !settled ? OVERVIEW_HINT : NO_COVERAGE_HINT;
|
||||
fallback =
|
||||
drawn > 0 || !settled
|
||||
? OVERVIEW_HINT
|
||||
: filteredOut
|
||||
? FILTERED_HINT
|
||||
: NO_COVERAGE_HINT;
|
||||
else if (lines && settled && count === 0)
|
||||
fallback = filteredOut ? FILTERED_HINT : NO_COVERAGE_HINT;
|
||||
return {
|
||||
|
||||
@@ -49,6 +49,10 @@ export const COVERAGE_TUNING = Object.freeze({
|
||||
// looking emptier than its suburbs. (KartaView draws as many.)
|
||||
maxSequences: 14_400,
|
||||
keepOrder: true,
|
||||
// Points drawn at most, over every tile (each becomes ~8 ground-line
|
||||
// vertices, ~1.4 kB of geometry): the simplified Paris z10 tile alone
|
||||
// holds 57,000, unsimplified 750,000, which hung the tab.
|
||||
maxPoints: 90_000,
|
||||
lineWidthPx: 2.5,
|
||||
overviewPointPx: 4,
|
||||
moveDebounceMs: 320,
|
||||
@@ -56,6 +60,12 @@ export const COVERAGE_TUNING = Object.freeze({
|
||||
viewRangePerHeight: 10,
|
||||
viewNearM: 1_000,
|
||||
});
|
||||
/**
|
||||
* Lines are simplified (Douglas–Peucker) to this many tile units of a
|
||||
* 4,096 extent: under a pixel at every height lines are drawn from, and
|
||||
* 6–10% of the points GPS tracks carry.
|
||||
*/
|
||||
export const SIMPLIFY_TOLERANCE_UNITS = 4;
|
||||
/**
|
||||
* Grid cells per overview tile, at most: a z0 tile holds ~70,000 cells
|
||||
* (1.9 MB), drawn as this many binned points.
|
||||
@@ -69,9 +79,9 @@ export const NO_COVERAGE_HINT = 'no Panoramax photos in view';
|
||||
export const FILTERED_HINT = 'no Panoramax photos here match the filters';
|
||||
export const UNREADABLE_TILE_MESSAGE =
|
||||
'Panoramax sent a map tile that could not be read';
|
||||
export const GONE_MESSAGE = 'This Panoramax picture is no longer available';
|
||||
export const GONE_MESSAGE = 'This Panoramax photo is no longer available';
|
||||
/** From high up the dots are where pictures are, not sequences to click. */
|
||||
export const OVERVIEW_HINT = 'zoom in to see Panoramax sequences';
|
||||
export const OVERVIEW_HINT = 'zoom in to see Panoramax coverage';
|
||||
|
||||
/** Cones come from z15 tiles' `pictures` layer (~110 bytes a picture). */
|
||||
export const PICTURE_TILE_ZOOM = 15;
|
||||
@@ -84,11 +94,14 @@ export const API_TIMEOUT_MS = 12_000;
|
||||
export const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
||||
/**
|
||||
* The meta-catalogue speaks HTTP/1.1 only, so the browser opens six
|
||||
* connections to it: tiles take three, searches two, so a click's lookup
|
||||
* never queues behind megabyte tiles (it timed out doing so).
|
||||
* connections to it: coverage tiles take three, searches two and the
|
||||
* cones' tiles one, so a click's lookup never queues behind megabyte tiles
|
||||
* (it timed out doing so).
|
||||
*/
|
||||
export const API_CONCURRENCY = 2;
|
||||
export const TILE_CONCURRENCY = 3;
|
||||
/** The selected sequence's z15 tiles (cones) take the sixth. */
|
||||
export const PICTURE_TILE_CONCURRENCY = 1;
|
||||
|
||||
/** Pictures asked for around a point; the API returns them nearest first. */
|
||||
export const NEAREST_LIMIT = 10;
|
||||
@@ -201,10 +214,18 @@ function creatorOf(item) {
|
||||
.map((p) => p.name)
|
||||
: []),
|
||||
];
|
||||
const name = names.find((value) => typeof value === 'string' && value.trim());
|
||||
if (name && !looksLikeEmail(name)) return name.trim().slice(0, 80);
|
||||
// Licences ask for the source to be named: the instance that holds it.
|
||||
return instanceOf(item);
|
||||
// Every producer is credited (an uploading app and its photographer),
|
||||
// each name once, none that is an email address.
|
||||
const credited = [];
|
||||
for (const value of names) {
|
||||
if (typeof value !== 'string') continue;
|
||||
const name = value.trim().slice(0, 80);
|
||||
if (!name || looksLikeEmail(value)) continue;
|
||||
if (credited.some((other) => other.toLowerCase() === name.toLowerCase()))
|
||||
continue;
|
||||
credited.push(name);
|
||||
}
|
||||
return credited.length ? credited.join(', ').slice(0, 120) : null;
|
||||
}
|
||||
|
||||
/** The federated instance holding a picture (its `via` link's host), or null. */
|
||||
@@ -259,6 +280,9 @@ export function normalizeItem(item) {
|
||||
isPano: isEquirectangular(item),
|
||||
capturedAt: panoramaxTimeMs(props.datetime),
|
||||
creator: creatorOf(item),
|
||||
// Licences ask for the source to be named: the instance holding it is
|
||||
// named beside an author, or alone where the author is hidden.
|
||||
via: instanceOf(item),
|
||||
license: licenseLabel(props.license),
|
||||
width: Number.isFinite(width) && width > 0 ? width : null,
|
||||
prevId: linkedId(item, 'prev'),
|
||||
|
||||
@@ -226,17 +226,34 @@ test('hostile item data: a malformed link escape, null coordinates, a huge produ
|
||||
);
|
||||
});
|
||||
|
||||
test('a picture whose producer is hidden is credited to the instance holding it', () => {
|
||||
const item = stacItem({
|
||||
id: 'a',
|
||||
lon: 1,
|
||||
lat: 2,
|
||||
producer: '[email protected]',
|
||||
});
|
||||
test('the instance holding a picture is named (its via host), never as its author', () => {
|
||||
const item = stacItem({ id: 'a', lon: 1, lat: 2, producer: '[email protected]' });
|
||||
item.links.push({ rel: 'via', href: 'https://panoramax.ign.fr' });
|
||||
assert.equal(normalizeItem(item).creator, 'panoramax.ign.fr');
|
||||
assert.equal(normalizeItem(item).creator, null);
|
||||
assert.equal(normalizeItem(item).via, 'panoramax.ign.fr');
|
||||
item.links.at(-1).href = 'http://insecure.example';
|
||||
assert.equal(normalizeItem(item).creator, null);
|
||||
assert.equal(normalizeItem(item).via, null);
|
||||
item.links.at(-1).href = 'not a url';
|
||||
assert.equal(normalizeItem(item).creator, null);
|
||||
assert.equal(normalizeItem(item).via, null);
|
||||
});
|
||||
|
||||
test('every producer is credited once: the uploading app and its photographer', () => {
|
||||
const item = stacItem({ id: 'a', lon: 1, lat: 2, producer: 'mapcomplete' });
|
||||
item.providers = [
|
||||
{ name: 'mapcomplete', roles: ['producer'] },
|
||||
{ name: 'pyrog', roles: ['producer'] },
|
||||
{ name: 'MapComplete', roles: ['producer'] },
|
||||
{ name: '[email protected]', roles: ['producer'] },
|
||||
{ name: 'host', roles: ['host'] },
|
||||
];
|
||||
assert.equal(normalizeItem(item).creator, 'mapcomplete, pyrog');
|
||||
const hidden = stacItem({ id: 'b', lon: 1, lat: 2, producer: '[email protected]' });
|
||||
hidden.providers = [{ name: '[email protected]', roles: ['producer'] }];
|
||||
assert.equal(normalizeItem(hidden).creator, null);
|
||||
const many = stacItem({ id: 'c', lon: 1, lat: 2 });
|
||||
many.providers = Array.from({ length: 50 }, (_, i) => ({
|
||||
name: `producer-${i}`,
|
||||
roles: ['producer'],
|
||||
}));
|
||||
assert.ok(normalizeItem(many).creator.length <= 120);
|
||||
});
|
||||
@@ -535,22 +535,55 @@ test('the cones come in capture order, and a broken neighbour tile only thins th
|
||||
assert.ok(await until(() => cones.count() === 5));
|
||||
assert.deepEqual(cones.ids(), ['s-4', 's-3', 's-2', 's-1', 's-0']);
|
||||
// A neighbour answers with garbage: the home tile's cones still draw.
|
||||
const home = new Set();
|
||||
env.source.getTile = async (tile) => {
|
||||
const key = `${tile.z}/${tile.x}/${tile.y}`;
|
||||
if (!home.size) home.add(key);
|
||||
if (!home.has(key)) return new TextEncoder().encode('<html>oops</html>');
|
||||
return tiles(tile);
|
||||
};
|
||||
await env.instance.viewer.open('s-0');
|
||||
await env.instance.selectSequence('s');
|
||||
});
|
||||
|
||||
test('a broken neighbour z15 tile only thins the cones; an unreadable home tile reads as such', async (t) => {
|
||||
// At a tile corner: the home tile and three neighbours are asked.
|
||||
const home = tileCentre(ORIGIN.lon, ORIGIN.lat, 15);
|
||||
const box = tileBounds(home.tile.x, home.tile.y, 15);
|
||||
const at = { lon: box.west + 0.0003, lat: box.north - 0.0002 };
|
||||
const items = [0, 1, 2].map((i) =>
|
||||
stacItem({ id: `c-${i}`, sequenceId: 'c', lon: at.lon + i * 0.00005, lat: at.lat }),
|
||||
);
|
||||
const tiles = pictureTiles(items);
|
||||
const broken = new TextEncoder().encode('<html>oops</html>');
|
||||
const homeKey = `15/${home.tile.x}/${home.tile.y}`;
|
||||
const env = await mounted({
|
||||
items,
|
||||
tiles: (tile) =>
|
||||
`${tile.z}/${tile.x}/${tile.y}` === homeKey ? tiles(tile) : broken,
|
||||
});
|
||||
const cones = await withCones(t, env);
|
||||
await env.instance.viewer.open('c-0');
|
||||
await env.instance.selectSequence('c');
|
||||
assert.ok(await until(() => !env.instance.sequenceStats().loading));
|
||||
// The home tile itself unreadable: a readable error, not a parser's.
|
||||
env.source.getTile = async () =>
|
||||
new TextEncoder().encode('<html>oops</html>');
|
||||
await env.instance.selectSequence('s');
|
||||
assert.ok(await until(() => env.actions.errors.some(Boolean)));
|
||||
assert.match(env.actions.errors.filter(Boolean).at(-1), /could not be read/);
|
||||
assert.equal(cones.count(), 3, 'the home tile’s cones draw');
|
||||
assert.ok(env.source.calls.tiles.length > 1, 'neighbours were asked');
|
||||
// Another sequence whose home tile is unreadable too.
|
||||
const env2 = await mounted({ items, tiles: () => broken });
|
||||
await withCones(t, env2);
|
||||
await env2.instance.viewer.open('c-0');
|
||||
await env2.instance.selectSequence('c');
|
||||
assert.ok(await until(() => env2.actions.errors.some(Boolean)));
|
||||
assert.match(env2.actions.errors.filter(Boolean).at(-1), /could not be read/);
|
||||
});
|
||||
|
||||
test('cones re-centred into tiles already read do not read them again', async (t) => {
|
||||
const items = sequence('long', 400);
|
||||
const env = await mounted({ items, tiles: pictureTiles(items) });
|
||||
await withCones(t, env);
|
||||
await env.instance.viewer.open('long-0');
|
||||
await env.instance.selectSequence('long');
|
||||
assert.ok(await until(() => env.instance.sequenceStats().images > 0));
|
||||
await env.instance.viewer.open('long-399');
|
||||
assert.ok(await until(() => !env.instance.sequenceStats().loading));
|
||||
const asked = env.source.calls.tiles.length;
|
||||
// Back to the start: its tiles are kept.
|
||||
await env.instance.viewer.open('long-0');
|
||||
assert.ok(await until(() => !env.instance.sequenceStats().loading));
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.equal(env.source.calls.tiles.length, asked);
|
||||
assert.ok(env.instance.sequenceStats().images > 0);
|
||||
});
|
||||
|
||||
test('› within the tiles the cones came from reloads nothing', async (t) => {
|
||||
@@ -748,15 +781,18 @@ test('Esc in the docked viewer clears the selected sequence before the panel can
|
||||
await env.instance.selectSequence('s');
|
||||
assert.ok(await until(() => env.instance.sequenceStats().images === 3));
|
||||
const root = env.next.parentNode;
|
||||
const event = {
|
||||
const flags = { stopped: false, prevented: false };
|
||||
root.fire('keydown', {
|
||||
key: 'Escape',
|
||||
stopped: false,
|
||||
stopPropagation() {
|
||||
this.stopped = true;
|
||||
},
|
||||
};
|
||||
root.fire('keydown', event);
|
||||
stopPropagation: () => (flags.stopped = true),
|
||||
preventDefault: () => (flags.prevented = true),
|
||||
});
|
||||
assert.equal(env.instance.sequenceStats().selectedId, null);
|
||||
assert.deepEqual(
|
||||
flags,
|
||||
{ stopped: true, prevented: true },
|
||||
'consumed: the panel never sees it',
|
||||
);
|
||||
// Nothing selected: Esc goes on to the panel.
|
||||
const again = { key: 'Escape', defaultPrevented: false };
|
||||
const result = { prevented: false };
|
||||
@@ -768,3 +804,132 @@ test('Esc in the docked viewer clears the selected sequence before the panel can
|
||||
});
|
||||
assert.equal(result.prevented, false);
|
||||
});
|
||||
|
||||
test('cones near the east and south edges read those neighbours; near both edges of a narrow tile, both sides', () => {
|
||||
const home = tileCentre(ORIGIN.lon, ORIGIN.lat, 15);
|
||||
const box = tileBounds(home.tile.x, home.tile.y, 15);
|
||||
const { x, y } = home.tile;
|
||||
const keys = (point) => pictureTilesAround(point).map((t) => `${t.x}/${t.y}`);
|
||||
assert.deepEqual(keys({ lon: box.east - 0.0002, lat: box.south + 0.0002 }), [
|
||||
`${x}/${y}`,
|
||||
`${x + 1}/${y}`,
|
||||
`${x}/${y + 1}`,
|
||||
`${x + 1}/${y + 1}`,
|
||||
]);
|
||||
// Svalbard: a z15 tile is ~250 m wide, so its middle is near both sides.
|
||||
const north = tileCentre(15.6, 78.22, 15);
|
||||
const around = keys(north);
|
||||
for (const dx of [-1, 1])
|
||||
assert.ok(
|
||||
around.includes(`${north.tile.x + dx}/${north.tile.y}`),
|
||||
`${dx} in ${around}`,
|
||||
);
|
||||
assert.equal(around[0], `${north.tile.x}/${north.tile.y}`, 'its own first');
|
||||
});
|
||||
|
||||
test('a search hit outside the radius (a bounding box’s corner) is not the nearest', async () => {
|
||||
const corner = stacItem({ id: 'corner', sequenceId: 'c', ...east(65) });
|
||||
const { instance, source } = setup({ items: [corner] });
|
||||
source.nearestItems = async () => [corner];
|
||||
assert.equal(await instance.nearestImage(east(0)), null);
|
||||
});
|
||||
|
||||
test('a line clicked from low down still searches its own sequence within 50 m', async () => {
|
||||
const items = [...sequence('other', 1, { offset: 2 }), ...sequence('clicked', 1, { offset: 30 })];
|
||||
const { instance, source } = setup({ items });
|
||||
instance.init({
|
||||
camera: { positionCartographic: { height: 300 } },
|
||||
scene: {
|
||||
primitives: { add() {}, remove() {} },
|
||||
preRender: { addEventListener: () => () => {} },
|
||||
},
|
||||
});
|
||||
instance.handlePick(`${PICK_PREFIX.sequence}clicked`, {
|
||||
groundPoint: () => east(0),
|
||||
});
|
||||
assert.equal(await instance.nearestImage(east(0)), 'clicked-0');
|
||||
assert.equal(source.calls.nearest[0].radius, 50);
|
||||
});
|
||||
|
||||
test('cones re-centre on a picture that opens while they are still loading', async (t) => {
|
||||
const items = sequence('long', 400);
|
||||
const tiles = pictureTiles(items);
|
||||
let release = null;
|
||||
const env = await mounted({ items, tiles });
|
||||
await withCones(t, env);
|
||||
await env.instance.viewer.open('long-0');
|
||||
env.source.getTile = async (tile) => {
|
||||
env.source.calls.tiles.push(`${tile.z}/${tile.x}/${tile.y}`);
|
||||
if (release === null) await new Promise((go) => (release = go));
|
||||
return tiles(tile);
|
||||
};
|
||||
const first = env.instance.selectSequence('long');
|
||||
assert.ok(await until(() => release !== null), 'the first load is held');
|
||||
// The far end opens while the cones around long-0 still load.
|
||||
await env.instance.viewer.open('long-399');
|
||||
release();
|
||||
await first;
|
||||
assert.ok(await until(() => !env.instance.sequenceStats().loading));
|
||||
const [lon, lat] = items[399].geometry.coordinates;
|
||||
const [far] = pictureTilesAround({ lon, lat });
|
||||
assert.ok(
|
||||
env.source.calls.tiles.includes(`15/${far.x}/${far.y}`),
|
||||
`the far picture's tile was read: ${env.source.calls.tiles}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a filter that hides every density dot says so', async (t) => {
|
||||
const env = coverageSetup(t, {
|
||||
height: 200_000,
|
||||
filter: { pano: 'pano', sinceMs: null },
|
||||
grid: [{ lon: ORIGIN.lon, lat: ORIGIN.lat, pano: 0, flat: 5 }],
|
||||
});
|
||||
env.instance.activate(env.viewer);
|
||||
assert.ok(await until(() => env.instance.coverageStats().hint === FILTERED_HINT));
|
||||
});
|
||||
|
||||
test('dots are drawn at their own sizes, and the busiest is the biggest', async (t) => {
|
||||
const env = coverageSetup(t, {
|
||||
height: 200_000,
|
||||
grid: [
|
||||
{ lon: ORIGIN.lon, lat: ORIGIN.lat, pano: 0, flat: 1 },
|
||||
{ lon: ORIGIN.lon + 3, lat: ORIGIN.lat, pano: 30_000, flat: 20_000 },
|
||||
],
|
||||
});
|
||||
env.instance.activate(env.viewer);
|
||||
const sizes = () => {
|
||||
const out = [];
|
||||
for (const primitive of env.viewer.scene.primitives.items)
|
||||
for (let i = 0; i < (primitive.length ?? 0); i++)
|
||||
if (primitive.get(i).pixelSize) out.push(primitive.get(i).pixelSize);
|
||||
return out.sort((a, b) => a - b);
|
||||
};
|
||||
assert.ok(await until(() => sizes().length === 2));
|
||||
assert.deepEqual(sizes(), [
|
||||
overviewPointSize({ weight: 1 }),
|
||||
overviewPointSize({ weight: 50_000 }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('lines keep the decoded (longest-first) order under a budget of points', async () => {
|
||||
const { COVERAGE_TUNING } = await import('./policy.js');
|
||||
assert.equal(COVERAGE_TUNING.keepOrder, true);
|
||||
assert.ok(COVERAGE_TUNING.maxPoints > 0);
|
||||
});
|
||||
|
||||
test('a coverage tile decoded a moment ago is not fetched or decoded again', async (t) => {
|
||||
const env = coverageSetup(t, { sequences: [line('a')] });
|
||||
let asked = 0;
|
||||
const getTile = env.source.getTile;
|
||||
env.source.getTile = (tile, options) => {
|
||||
asked++;
|
||||
return getTile(tile, options);
|
||||
};
|
||||
env.instance.activate(env.viewer);
|
||||
assert.ok(await until(() => env.instance.coverageStats().count === 1));
|
||||
const first = asked;
|
||||
env.instance.deactivate();
|
||||
env.instance.activate(env.viewer);
|
||||
assert.ok(await until(() => env.instance.coverageStats().count === 1));
|
||||
assert.equal(asked, first);
|
||||
});
|
||||
@@ -1,4 +1,7 @@
|
||||
import { createLimiter } from '../../limiter.js';
|
||||
|
||||
/** Longest wait a Retry-After is honoured for. */
|
||||
const RETRY_AFTER_MAX_SEC = 300;
|
||||
import {
|
||||
API_CONCURRENCY,
|
||||
API_TIMEOUT_MS,
|
||||
@@ -8,6 +11,7 @@ import {
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_RADIUS_M,
|
||||
PANORAMAX_API,
|
||||
PICTURE_TILE_CONCURRENCY,
|
||||
TILE_CONCURRENCY,
|
||||
TILE_TIMEOUT_MS,
|
||||
} from './policy.js';
|
||||
@@ -23,12 +27,15 @@ export class PanoramaxSourceError extends Error {
|
||||
}
|
||||
|
||||
const NO_ANSWER = 'Panoramax did not answer in time — try again';
|
||||
const UNREACHABLE = 'Panoramax could not be reached — check the connection';
|
||||
|
||||
/** Seconds from a Retry-After header, or null. */
|
||||
function retryAfterSeconds(response) {
|
||||
const header = response.headers?.get?.('retry-after');
|
||||
const value = header == null || header === '' ? NaN : Number(header);
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
if (!Number.isFinite(value) || value < 0) return null;
|
||||
// A maintenance page's hour must not freeze coverage for an hour.
|
||||
return Math.min(RETRY_AFTER_MAX_SEC, Math.max(1, value));
|
||||
}
|
||||
|
||||
/** The body as bytes, refusing more than MAX_RESPONSE_BYTES whatever the headers say. */
|
||||
@@ -79,6 +86,11 @@ export function createPanoramaxSource({
|
||||
const root = api.replace(/\/+$/, '');
|
||||
const apiLimiter = createLimiter(API_CONCURRENCY, { lifo: true });
|
||||
const tileLimiter = createLimiter(TILE_CONCURRENCY);
|
||||
// The cones' z15 tiles have their own connection: a selection never
|
||||
// waits behind coverage tiles.
|
||||
const pictureLimiter = createLimiter(PICTURE_TILE_CONCURRENCY, {
|
||||
lifo: true,
|
||||
});
|
||||
|
||||
/** GET within `timeoutMs`; the caller's abort wins over the timeout. */
|
||||
async function get(url, { signal, timeoutMs, empty = [] }) {
|
||||
@@ -108,7 +120,8 @@ export function createPanoramaxSource({
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
if (controller.signal.aborted) throw controller.signal.reason;
|
||||
if (error instanceof PanoramaxSourceError) throw error;
|
||||
throw new PanoramaxSourceError(NO_ANSWER);
|
||||
// Offline, or a CORS or TLS failure: not a timeout, not worth a retry.
|
||||
throw new PanoramaxSourceError(UNREACHABLE);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
@@ -146,9 +159,10 @@ export function createPanoramaxSource({
|
||||
api: root,
|
||||
|
||||
/** Raw vector-tile bytes; an empty array where there is no coverage. */
|
||||
async getTile({ x, y, z }, { signal } = {}) {
|
||||
async getTile({ x, y, z }, { signal, lane = 'coverage' } = {}) {
|
||||
const url = `${root}/map/${z}/${x}/${y}.mvt`;
|
||||
const bytes = await tileLimiter.run(
|
||||
const gate = lane === 'pictures' ? pictureLimiter : tileLimiter;
|
||||
const bytes = await gate.run(
|
||||
() =>
|
||||
get(url, { signal, timeoutMs: TILE_TIMEOUT_MS, empty: [204, 404] }),
|
||||
{ signal },
|
||||
@@ -193,27 +207,51 @@ export function createPanoramaxSource({
|
||||
const dLat = metres / 111_320;
|
||||
const dLon =
|
||||
metres / (111_320 * Math.max(0.01, Math.cos((lat * Math.PI) / 180)));
|
||||
const box = [lon - dLon, lat - dLat, lon + dLon, lat + dLat].map((v) =>
|
||||
v.toFixed(7),
|
||||
);
|
||||
const [facing, around] = await Promise.all([
|
||||
search(
|
||||
{
|
||||
// Clamped to the map: the API finds nothing in a box past ±180°.
|
||||
const box = [
|
||||
Math.max(-180, lon - dLon),
|
||||
Math.max(-90, lat - dLat),
|
||||
Math.min(180, lon + dLon),
|
||||
Math.min(90, lat + dLat),
|
||||
].map((v) => v.toFixed(7));
|
||||
// One search failing (busy, no answer) leaves the other's pictures;
|
||||
// only both failing fails the lookup. The caller's abort stops both.
|
||||
const both = new AbortController();
|
||||
const onAbort = () => both.abort(signal.reason);
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
const run = (params) =>
|
||||
search(params, { signal: both.signal }).catch((error) => {
|
||||
if (!both.signal.aborted && !signal?.aborted) failures.push(error);
|
||||
throw error;
|
||||
});
|
||||
const failures = [];
|
||||
let settled;
|
||||
try {
|
||||
settled = await Promise.allSettled([
|
||||
run({
|
||||
place_position: `${Number(lon).toFixed(7)},${Number(lat).toFixed(7)}`,
|
||||
place_distance: `0-${metres}`,
|
||||
place_fov_tolerance: String(NEAREST_FOV_TOLERANCE_DEG),
|
||||
limit: String(Math.min(100, Math.max(1, limit))),
|
||||
...shared,
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
search(
|
||||
{ bbox: box.join(','), limit: String(NEAREST_BOX_LIMIT), ...shared },
|
||||
{ signal },
|
||||
),
|
||||
]);
|
||||
}),
|
||||
run({
|
||||
bbox: box.join(','),
|
||||
limit: String(NEAREST_BOX_LIMIT),
|
||||
...shared,
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
}
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
const found = settled
|
||||
.filter((result) => result.status === 'fulfilled')
|
||||
.map((result) => result.value);
|
||||
if (!found.length) throw failures[0] ?? settled[0].reason;
|
||||
const byId = new Map();
|
||||
for (const item of [...facing, ...around])
|
||||
for (const item of found.flat())
|
||||
if (item?.id != null && !byId.has(String(item.id)))
|
||||
byId.set(String(item.id), item);
|
||||
return [...byId.values()];
|
||||
|
||||
@@ -217,7 +217,7 @@ test('the caller’s abort wins; a silent API times out with a readable message'
|
||||
await assert.rejects(slow, /did not answer in time/);
|
||||
});
|
||||
|
||||
test('a network failure reads as no answer, not as a stack trace', async () => {
|
||||
test('a network failure reads as unreachable, not as a stack trace', async () => {
|
||||
const source = createPanoramaxSource({
|
||||
fetchImpl: async () => {
|
||||
throw new TypeError('Failed to fetch');
|
||||
@@ -225,7 +225,7 @@ test('a network failure reads as no answer, not as a stack trace', async () => {
|
||||
});
|
||||
await assert.rejects(source.getTile({ x: 0, y: 0, z: 0 }), (error) => {
|
||||
assert.ok(error instanceof PanoramaxSourceError);
|
||||
assert.match(error.message, /did not answer/);
|
||||
assert.match(error.message, /could not be reached/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -293,3 +293,68 @@ test('the HTTP/1.1 API never gets more than five requests at once: three tiles,
|
||||
}
|
||||
assert.equal(most, 5, 'never more');
|
||||
});
|
||||
|
||||
test('one of the two nearest searches failing leaves the other’s pictures; both failing fails the lookup', async () => {
|
||||
const half = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch((url) =>
|
||||
url.includes('bbox')
|
||||
? { status: 503 }
|
||||
: json({ features: [{ id: 'facing' }] }),
|
||||
).fetchImpl,
|
||||
});
|
||||
assert.deepEqual(
|
||||
(await half.nearestItems({ lat: 1, lon: 2 })).map((i) => i.id),
|
||||
['facing'],
|
||||
);
|
||||
const none = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(() => ({ status: 503 })).fetchImpl,
|
||||
});
|
||||
await assert.rejects(none.nearestItems({ lat: 1, lon: 2 }), /busy/);
|
||||
});
|
||||
|
||||
test('only a timeout is asked again: a busy or failing API is asked once, an unreachable one says so', async () => {
|
||||
for (const status of [429, 500, 503]) {
|
||||
const { urls, fetchImpl } = fakeFetch(() => ({ status }));
|
||||
const source = createPanoramaxSource({ fetchImpl });
|
||||
await assert.rejects(source.getItem('a'));
|
||||
assert.equal(urls.length, 1, `HTTP ${status}: one fetch`);
|
||||
}
|
||||
let calls = 0;
|
||||
const offline = createPanoramaxSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
throw new TypeError('Failed to fetch');
|
||||
},
|
||||
});
|
||||
await assert.rejects(offline.getItem('a'), /could not be reached/);
|
||||
assert.equal(calls, 1, 'not retried');
|
||||
});
|
||||
|
||||
test('a Retry-After is honoured up to five minutes', async () => {
|
||||
const wait = async (header) => {
|
||||
const source = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(() => ({
|
||||
status: 503,
|
||||
headers: { 'retry-after': header },
|
||||
})).fetchImpl,
|
||||
});
|
||||
return source.getTile({ x: 0, y: 0, z: 0 }).catch((error) => error.retryAfterSec);
|
||||
};
|
||||
assert.equal(await wait('3600'), 300);
|
||||
assert.equal(await wait('99999999999'), 300);
|
||||
assert.equal(await wait('0'), 1);
|
||||
assert.equal(await wait('12'), 12);
|
||||
});
|
||||
|
||||
test('a lookup near the antimeridian keeps its box on the map', async () => {
|
||||
const { urls, fetchImpl } = fakeFetch(() => json({ features: [] }));
|
||||
const source = createPanoramaxSource({ fetchImpl });
|
||||
await source.nearestItems({ lat: -16, lon: 179.9999 });
|
||||
const box = urls
|
||||
.map((u) => new URL(u).searchParams.get('bbox'))
|
||||
.find(Boolean)
|
||||
.split(',')
|
||||
.map(Number);
|
||||
assert.equal(box[2], 180);
|
||||
assert.ok(box[0] < 180);
|
||||
});
|
||||
@@ -36,9 +36,11 @@ export const PANORAMAX_SOURCES = Object.freeze({
|
||||
const width = Math.min(photo.width, PANO_FULL_SIZE[0]);
|
||||
return { url: photo.urls.hd, width, resize: [width, width / 2] };
|
||||
},
|
||||
panoSharp: (photo, size) => {
|
||||
// The original is several megabytes with no server to shrink it: only
|
||||
// for the expanded viewer (a docked one on a dense screen asks too).
|
||||
panoSharp: (photo, size, { expanded = false } = {}) => {
|
||||
const width = photo.width || 0;
|
||||
if (!photo.urls.hd || !photo.urls.sd) return null;
|
||||
if (!expanded || !photo.urls.hd || !photo.urls.sd) return null;
|
||||
// An original no wider than the SD one adds nothing; a huge one would
|
||||
// take hundreds of megabytes to decode.
|
||||
if (width <= SD_WIDTH || width > PANO_HD_MAX_WIDTH) return null;
|
||||
@@ -59,7 +61,8 @@ export const PANORAMAX_SOURCES = Object.freeze({
|
||||
flatSharp: (photo, pixels) =>
|
||||
photo.urls.sd && photo.urls.hd && pixels > SD_WIDTH ? photo.urls.hd : null,
|
||||
describe: (photo) => ({
|
||||
title: null,
|
||||
// Where no author can be named, the instance holding the photo is.
|
||||
title: !photo.creator && photo.via ? `via ${photo.via}` : null,
|
||||
creator: photo.creator,
|
||||
license: photo.license,
|
||||
externalUrl: panoramaxPictureUrl(photo.id),
|
||||
|
||||
@@ -150,7 +150,15 @@ test('a 360° picture shows its SD equirectangular image, then HD shrunk to 4,09
|
||||
assert.equal(poses.at(-1).bearing, 100);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(loads.length, 1, 'docked: the 2,048 px SD is enough');
|
||||
// Docked on a dense screen the canvas wants more, but the original is
|
||||
// megabytes: not until the viewer is expanded.
|
||||
canvas.clientWidth = 1800;
|
||||
viewer.resize();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(loads.length, 1, 'docked: still SD only');
|
||||
const root = canvas.parentNode;
|
||||
root.closest = (selector) =>
|
||||
selector.includes('sl-viewer-wrap-expanded') ? {} : null;
|
||||
// EXPAND resizes more than once while the panel settles.
|
||||
viewer.resize();
|
||||
viewer.resize();
|
||||
@@ -165,14 +173,19 @@ test('a 360° picture shows its SD equirectangular image, then HD shrunk to 4,09
|
||||
assert.equal(hd.url, PHOTOS.pano.urls.hd);
|
||||
assert.deepEqual(hd.resize, [4096, 2048]);
|
||||
assert.equal(hd.timeoutMs, 30_000);
|
||||
assert.ok(hd.signal, 'abortable');
|
||||
});
|
||||
|
||||
test('HD panoramas too big to decode, or no bigger than SD, are never fetched', () => {
|
||||
test('HD panoramas too big to decode, or no bigger than SD, are never fetched; nor while docked', () => {
|
||||
const size = [4096, 2048];
|
||||
assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.bigPano, size), null);
|
||||
assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.smallPano, size), null);
|
||||
const expanded = { expanded: true };
|
||||
assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.pano, size, { expanded: false }), null);
|
||||
assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.pano, size), null);
|
||||
assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.bigPano, size, expanded), null);
|
||||
assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.smallPano, size, expanded), null);
|
||||
assert.deepEqual(
|
||||
PANORAMAX_SOURCES.panoSharp({ ...PHOTOS.pano, width: 3000 }, size).resize,
|
||||
PANORAMAX_SOURCES.panoSharp({ ...PHOTOS.pano, width: 3000 }, size, expanded)
|
||||
.resize,
|
||||
[3000, 1500],
|
||||
'never enlarged',
|
||||
);
|
||||
@@ -190,7 +203,7 @@ test('HD panoramas too big to decode, or no bigger than SD, are never fetched',
|
||||
assert.deepEqual(PANORAMAX_SOURCES.flatFirst(hugeOnly).filter(Boolean), [
|
||||
hugeOnly.urls.thumb,
|
||||
]);
|
||||
assert.equal(PANORAMAX_SOURCES.panoSharp(hdOnly, size), null);
|
||||
assert.equal(PANORAMAX_SOURCES.panoSharp(hdOnly, size, expanded), null);
|
||||
assert.equal(PANORAMAX_SOURCES.flatSharp(PHOTOS.flat, 2048), null);
|
||||
assert.equal(
|
||||
PANORAMAX_SOURCES.flatSharp(PHOTOS.flat, 2049),
|
||||
@@ -258,3 +271,18 @@ test('a picture whose SD fails falls back to HD, then the thumbnail', async () =
|
||||
]);
|
||||
assert.equal(parts(host).picture.src, PHOTOS.flat.urls.thumb);
|
||||
});
|
||||
|
||||
test('a photo with no author to name is captioned with the instance holding it', () => {
|
||||
const photo = normalizeItem(
|
||||
stacItem({ id: 'x', lon: 1, lat: 2, producer: '[email protected]' }),
|
||||
);
|
||||
photo.via = 'panoramax.ign.fr';
|
||||
assert.deepEqual(PANORAMAX_SOURCES.describe(photo), {
|
||||
title: 'via panoramax.ign.fr',
|
||||
creator: null,
|
||||
license: 'CC BY-SA 4.0',
|
||||
externalUrl: 'https://api.panoramax.xyz/#pic=x&focus=pic',
|
||||
});
|
||||
const named = { ...photo, creator: 'Robot8A' };
|
||||
assert.equal(PANORAMAX_SOURCES.describe(named).title, null);
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/** Decoded coverage tiles kept, and for how long. */
|
||||
export const TILE_CACHE_SIZE = 24;
|
||||
export const TILE_CACHE_TTL_MS = 10 * 60_000;
|
||||
|
||||
/** Least-recently-used map: reading an entry renews it. */
|
||||
export function createLru(limit) {
|
||||
const map = new Map();
|
||||
return {
|
||||
get(key) {
|
||||
if (!map.has(key)) return undefined;
|
||||
const value = map.get(key);
|
||||
map.delete(key);
|
||||
map.set(key, value);
|
||||
return value;
|
||||
},
|
||||
set(key, value) {
|
||||
map.delete(key);
|
||||
map.set(key, value);
|
||||
while (map.size > limit) map.delete(map.keys().next().value);
|
||||
},
|
||||
delete: (key) => map.delete(key),
|
||||
clear: () => map.clear(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded (decoded) coverage tiles by `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.
|
||||
*/
|
||||
export function createTileCache({
|
||||
load,
|
||||
size = TILE_CACHE_SIZE,
|
||||
ttlMs = TILE_CACHE_TTL_MS,
|
||||
now = Date.now,
|
||||
}) {
|
||||
const kept = createLru(size);
|
||||
return {
|
||||
async get(tile, options) {
|
||||
const key = `${tile.z}/${tile.x}/${tile.y}`;
|
||||
const entry = kept.get(key);
|
||||
if (entry && now() - entry.at < ttlMs) return entry.tile;
|
||||
const loaded = await load(tile, options);
|
||||
kept.set(key, { at: now(), tile: loaded });
|
||||
return loaded;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -673,3 +673,15 @@ test('an adapter that can frame but says this image has no frame offers no FIT /
|
||||
await host.open('mapillary', 'flat-1');
|
||||
assert.equal(state.street.renderModes, true, 'a flat image can be framed');
|
||||
});
|
||||
|
||||
test('a pose’s licence reaches the caption state, and a closed viewer forgets it', async () => {
|
||||
const { state, host, google } = twoProviders();
|
||||
await host.open('google', 'g-1');
|
||||
google.emitPose('g-1', { providerId: 'google', license: 'CC BY-SA 4.0' });
|
||||
assert.equal(state.street.license, 'CC BY-SA 4.0');
|
||||
google.emitPose('g-1', { providerId: 'google' });
|
||||
assert.equal(state.street.license, null, 'none given: none shown');
|
||||
google.emitPose('g-1', { providerId: 'google', license: 'CC0' });
|
||||
host.close();
|
||||
assert.equal(state.street.license, null);
|
||||
});
|
||||
Reference in new issue
Block a user