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:
Said TurkseverandClaude Opus 5.5 committed 2026-10-14 15:24:13 +01:00
1 parent 5526666894
commit cf502429a3
29 files changed
+1153 -216

No files matched your search

+80
View File
@@ -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']);
});
+17 -1
View File
@@ -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
+97 -2
View File
@@ -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']);
});
+64 -20
View File
@@ -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;
+141
View File
@@ -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);
});
+48
View File
@@ -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);
});