fix(street-level): Panoramax functional QA — nearest lookup, line cap order, hostile data

From an adversarial functional QA round against the live API:
- The nearest lookup missed pictures looking away from the click
  (place_position leaves them out at any tolerance): it now runs a
  bounding-box search beside it and ranks both by distance. A line
  clicked from high up searches its sequence within height / 100
  (50–400 m).
- The per-tile line cap kept the newest sequences, not the longest
  (lineCoverage re-sorted them): providers can now keep their decoded
  order (`keepOrder`).
- A long producer name froze the tab (quadratic email regex): linear
  check. A malformed link escape no longer fails the lookup; null or
  string coordinates no longer land at 0,0.
- A line with no camera type or day stays drawn under the filters.
- A sky click on a line changes nothing (as KartaView), instead of
  selecting a sequence with no cones.
- An unreadable tile reads as such; a failed neighbour z15 tile only
  thins the cones; unknown or refused ids say the picture is gone.
- Shared viewer: the sharp-panorama guard is per open (a late download
  of the previous photo no longer lets a second one start); an HD-only
  360° picture is shrunk or skipped like any original; panorama.js read
  the bitmap size after closing it, so 2:1 power-of-two panoramas
  clamped instead of wrapping.
- Tests for each, and for the gaps mutation testing found (preferred
  point, cone order, no reload within drawn tiles, newest-first gate,
  stale bitmap release, flat upgrade retry).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-14 13:34:25 +01:00
1 parent 00d2b73b6a
commit bce3044e0c
18 files changed
+699 -98

No files matched your search

+10 -4
View File
@@ -3262,10 +3262,16 @@ density cells, binned to at most 96×96 points a tile (a z0 tile holds
filters apply: lines carry their day and camera type, and a cell stays while filters apply: lines carry their day and camera type, and a cell stays while
it holds pictures of the kind asked for (cells are undated). A click on a it holds pictures of the kind asked for (cells are undated). A click on a
line asks the core (`actions.openNearest`) for that sequence's picture line asks the core (`actions.openNearest`) for that sequence's picture
nearest the click (`/api/search?place_position…&collections=<id>`, any nearest the click, within the camera height / 100 (50–400 m: from high up
direction: `place_fov_tolerance=180`), without framing, then any sequence's a click lands well off a line), without framing, then any sequence's within
if it has none within 50 m; the filters go to the API (`filter= 50 m. Each lookup is two searches side by side, ranked by distance in the
field_of_view=360`, `datetime=<since>/..`). Once the picture is on screen browser: `place_position` (nearest first, but even at
`place_fov_tolerance=180` it leaves out pictures looking away from the
point) and a bounding box (those too, but in no order); the filters go to
both (`filter=field_of_view=360`, `datetime=<since>/..`). A line is dated
by its sequence's first day, so a sequence still growing can hide under
SINCE; a line with no camera type or day stays drawn. Lines keep the
decoded longest-first order under the per-tile cap (`keepOrder`). Once the picture is on screen
its sequence is highlighted and drawn as cones from the z15 tiles around it its sequence is highlighted and drawn as cones from the z15 tiles around it
(their `pictures` layer, ~110 bytes a picture, against ~7.5 kB for a STAC (their `pictures` layer, ~110 bytes a picture, against ~7.5 kB for a STAC
item), plus a neighbour within 150 m of an edge; the cones re-centre when a item), plus a neighbour within 150 m of an edge; the cones re-centre when a
+1 -1
View File
@@ -2809,7 +2809,7 @@ async function main() {
); );
if (fixtures) if (fixtures)
await step( await step(
'every Mapillary and KartaView request was answered from a fixture', 'every Mapillary, KartaView and Panoramax request was answered from a fixture',
() => { () => {
console.log( console.log(
` (answered: ${[...fixture.calls].sort().join(' | ')})`, ` (answered: ${[...fixture.calls].sort().join(' | ')})`,
+8 -4
View File
@@ -72,6 +72,7 @@ export function meshBoxInRange(box, centre) {
* @property {number} viewRangeMinM Street-zoom coverage reaches at least this far… * @property {number} viewRangeMinM Street-zoom coverage reaches at least this far…
* @property {number} viewRangePerHeight …or camera height times this. * @property {number} viewRangePerHeight …or camera height times this.
* @property {number} viewNearM Ground always covered around a tilted camera. * @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).
*/ */
/** /**
@@ -559,10 +560,13 @@ export function createLineCoverage({
north: bounds.north + pad, north: bounds.north + pad,
}; };
if (kind === 'sequence') { if (kind === 'sequence') {
// Newest first; the per-tile cap applies to what passes the filter. // Newest first, unless the provider ordered them itself; the
entry.sequenceList = decoded.sequences.sort( // per-tile cap applies to what passes the filter.
(a, b) => (b.capturedAt || 0) - (a.capturedAt || 0), entry.sequenceList = tuning.keepOrder
); ? decoded.sequences
: decoded.sequences.sort(
(a, b) => (b.capturedAt || 0) - (a.capturedAt || 0),
);
entry.sequences = new Map(); entry.sequences = new Map();
entry.total = decoded.sequences.length; entry.total = decoded.sequences.length;
} else { } else {
+3 -1
View File
@@ -188,13 +188,15 @@ export function createPanoramaRenderer(canvas) {
gl.UNSIGNED_BYTE, gl.UNSIGNED_BYTE,
image, image,
); );
// Read before the close: a closed bitmap measures 0×0.
const { width, height } = image;
// The pixels are on the GPU now; a bitmap's copy can go. // The pixels are on the GPU now; a bitmap's copy can go.
image.close?.(); image.close?.();
// Linear only: a mipmapped lookup draws a seam where longitude wraps. // Linear only: a mipmapped lookup draws a seam where longitude wraps.
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
const repeat = const repeat =
isPowerOfTwo(image.width) && isPowerOfTwo(image.height) isPowerOfTwo(width) && isPowerOfTwo(height)
? gl.REPEAT ? gl.REPEAT
: gl.CLAMP_TO_EDGE; : gl.CLAMP_TO_EDGE;
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, repeat); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, repeat);
+30
View File
@@ -167,3 +167,33 @@ test('an image uploads as RGBA and its bitmap is released; closing hands the tex
); );
assert.equal(renderer.hasImage(), false); assert.equal(renderer.hasImage(), false);
}); });
test('a power-of-two bitmap wraps in longitude even though it is closed once uploaded', (t) => {
const { gl, renderer } = stubPanorama(t);
const wraps = [];
gl.texParameteri = (target, name, value) => wraps.push([name, value]);
gl.TEXTURE_WRAP_S = 'WRAP_S';
gl.REPEAT = 'REPEAT';
gl.CLAMP_TO_EDGE = 'CLAMP';
const bitmap = {
width: 2048,
height: 1024,
// A closed ImageBitmap measures 0×0.
close() {
this.width = 0;
this.height = 0;
},
};
renderer.setImage(bitmap);
assert.deepEqual(
wraps.find(([name]) => name === 'WRAP_S'),
['WRAP_S', 'REPEAT'],
);
wraps.length = 0;
renderer.setImage({ width: 2000, height: 1000 });
assert.deepEqual(
wraps.find(([name]) => name === 'WRAP_S'),
['WRAP_S', 'CLAMP'],
'other sizes clamp (WebGL 1 repeats powers of two only)',
);
});
+14 -9
View File
@@ -90,8 +90,8 @@ export function panoSizeFor(canvasPixels, fovDeg) {
/** /**
* @typedef {object} PhotoSources Where a provider's photo comes from. * @typedef {object} PhotoSources Where a provider's photo comes from.
* @property {(photo: object) => boolean} hasPano A 360° photo with an equirectangular image. * @property {(photo: object) => boolean} hasPano A 360° photo with an equirectangular image.
* @property {(photo: object) => {url: string, width: number}|null} panoFirst * @property {(photo: object) => {url: string, width: number, resize?: [number, number]|null}|null} panoFirst
* The panorama shown first, and its texture width (0 when it is only a preview). * 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 * @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. * A sharper panorama for a canvas that needs `size`, or null.
* @property {(photo: object) => Array<string>} flatFirst Flat versions to try in turn. * @property {(photo: object) => Array<string>} flatFirst Flat versions to try in turn.
@@ -158,8 +158,8 @@ export function createPhotoViewer({
let restoreView = null; let restoreView = null;
/** Width of the panorama texture on screen (0: the preview). */ /** Width of the panorama texture on screen (0: the preview). */
let panoWidth = 0; let panoWidth = 0;
/** Width of a sharper panorama on its way (resizes come in bursts). */ /** The sharper panorama on its way, for which open (resizes come in bursts). */
let panoComing = 0; let panoComing = null;
/** The sharper flat version on screen or on its way (one download each). */ /** The sharper flat version on screen or on its way (one download each). */
let flatSharpUrl = null; let flatSharpUrl = null;
/** The photo an open is fetching: ‹ › step on from it, not the old one. */ /** The photo an open is fetching: ‹ › step on from it, not the old one. */
@@ -520,10 +520,12 @@ export function createPhotoViewer({
if (kind === 'flat' && target.isPano) return; if (kind === 'flat' && target.isPano) return;
if (kind === 'pano') { if (kind === 'pano') {
const size = panoSizeNow(); const size = panoSizeNow();
if (size[0] <= Math.max(panoWidth, panoComing)) return; 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);
if (!sharp?.url) return; if (!sharp?.url) return;
panoComing = size[0]; const coming = { seq, width: size[0] };
panoComing = coming;
try { try {
const image = await loadAs(sharp.url, { const image = await loadAs(sharp.url, {
crossOrigin: true, crossOrigin: true,
@@ -544,7 +546,7 @@ export function createPhotoViewer({
} catch { } catch {
/* the first version stays */ /* the first version stays */
} finally { } finally {
if (panoComing === size[0]) panoComing = 0; if (panoComing === coming) panoComing = null;
} }
return; return;
} }
@@ -574,7 +576,10 @@ export function createPhotoViewer({
// waiting on it twice. // waiting on it twice.
const first = sources.panoFirst(target); const first = sources.panoFirst(target);
if (!first?.url) throw new Error(LOAD_FAILED); if (!first?.url) throw new Error(LOAD_FAILED);
const image = await loadAs(first.url, { crossOrigin: true }); const image = await loadAs(first.url, {
crossOrigin: true,
resize: first.resize ?? null,
});
if (seq !== openSeq) { if (seq !== openSeq) {
image.close?.(); image.close?.();
return false; return false;
@@ -592,7 +597,7 @@ export function createPhotoViewer({
show('pano'); show('pano');
renderer.setImage(image); renderer.setImage(image);
panoWidth = first.width || 0; panoWidth = first.width || 0;
panoComing = 0; panoComing = null;
return true; return true;
} }
+134
View File
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPhotoViewer } from './photoViewer.js';
import { fakeHost, until } from '../../testSupport/kartaViewFakes.mjs';
const PHOTOS = {
a: { id: 'a', sequenceId: 's', lon: 1, lat: 2, heading: 0, isPano: true },
b: { id: 'b', sequenceId: 's', lon: 1, lat: 2, heading: 0, isPano: true },
f: { id: 'f', sequenceId: 's', lon: 1, lat: 2, heading: 0, isPano: false },
};
/** Sources whose panoramas and sharper flat versions are named after the photo. */
const SOURCES = {
hasPano: (photo) => photo.isPano,
panoFirst: (photo) => ({ url: `${photo.id}-first`, width: 2048 }),
panoSharp: (photo) => ({ url: `${photo.id}-sharp` }),
flatFirst: (photo) => [`${photo.id}-first`],
flatSharp: (photo, pixels) => (pixels > 1000 ? `${photo.id}-sharp` : null),
describe: () => ({ externalUrl: 'https://example.org' }),
};
/** A viewer whose loads wait until `release(url)` (or fail with `fail(url)`). */
function setup() {
const pending = new Map();
const loads = [];
const images = [];
const viewer = createPhotoViewer({
providerId: 'test',
name: 'Test',
sources: SOURCES,
getPhoto: async (id) => PHOTOS[id],
step: async () => null,
onStep() {},
load: (url) =>
new Promise((resolve, reject) => {
loads.push(url);
pending.set(url, { resolve, reject });
}),
createRenderer: () => ({
setImage: (image) => images.push(image.src),
setView() {},
getView: () => ({ yaw: 0, pitch: 0, fov: 80 }),
resize() {},
clear() {},
destroy() {},
}),
});
const release = (url) => {
const closed = { value: false };
pending.get(url).resolve({ src: url, close: () => (closed.value = true) });
pending.delete(url);
return closed;
};
const fail = (url) => {
pending.get(url).reject(new Error('gone'));
pending.delete(url);
};
return { viewer, loads, images, release, fail, host: fakeHost() };
}
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
const parts = (host) => {
const [picture, canvas] = host.children[0].children;
return { picture, canvas };
};
test('a panorama that arrives after another photo opened is released, not shown', async () => {
const env = setup();
await env.viewer.mount(env.host);
const first = env.viewer.open('a');
await tick();
const second = env.viewer.open('b');
await tick();
const stale = env.release('a-first');
await first;
assert.equal(stale.value, true, 'its bitmap is closed');
env.release('b-first');
await second;
assert.deepEqual(env.images, ['b-first']);
});
test('one sharp panorama download per open, even across a quick switch and a burst of resizes', async (t) => {
const env = setup();
t.after(() => delete globalThis.devicePixelRatio);
globalThis.devicePixelRatio = 1;
await env.viewer.mount(env.host);
const { canvas } = parts(env.host);
canvas.clientWidth = 1800;
const opening = env.viewer.open('a');
await tick();
env.release('a-first');
await opening;
await tick();
assert.ok(env.loads.includes('a-sharp'), 'expanded: the sharp one is asked');
// Photo b opens while a's sharp version is still on its way.
const next = env.viewer.open('b');
await tick();
env.release('b-first');
await next;
await tick();
assert.ok(env.loads.includes('b-sharp'));
// a's download finishes late; resizes must not ask for b's again.
env.release('a-sharp');
await tick();
env.viewer.resize();
env.viewer.resize();
await tick();
assert.equal(env.loads.filter((url) => url === 'b-sharp').length, 1);
env.release('b-sharp');
await tick();
assert.deepEqual(env.images, ['a-first', 'b-first', 'b-sharp']);
});
test('a sharper flat version that failed is tried again on the next resize', async (t) => {
const env = setup();
t.after(() => delete globalThis.devicePixelRatio);
globalThis.devicePixelRatio = 1;
await env.viewer.mount(env.host);
const { picture } = parts(env.host);
picture.clientWidth = 1200;
const opening = env.viewer.open('f');
await tick();
env.release('f-first');
await opening;
await tick();
env.fail('f-sharp');
await tick();
assert.equal(picture.src, 'f-first');
env.viewer.resize();
await tick();
assert.equal(env.loads.filter((url) => url === 'f-sharp').length, 2);
env.release('f-sharp');
assert.ok(await until(() => picture.src === 'f-sharp'));
});
@@ -54,7 +54,13 @@ export function decodeCoverageTile(bytes, address) {
id: String(props.id), id: String(props.id),
parts, parts,
capturedAt: dayMs(props.date), capturedAt: dayMs(props.date),
isPano: props.type === 'equirectangular', // A sequence with no camera type is neither (the filters keep it).
isPano:
props.type === 'equirectangular'
? true
: props.type === 'flat'
? false
: null,
}; };
lengths.set(sequence, length); lengths.set(sequence, length);
result.sequences.push(sequence); result.sequences.push(sequence);
@@ -47,6 +47,24 @@ test('sequence lines keep their parts apart, with their day and camera type', ()
assert.equal(pano.parts.length, 2, 'the gap is not bridged'); assert.equal(pano.parts.length, 2, 'the gap is not bridged');
assert.ok(near(pano.parts[1][1][0], lon + 0.001)); assert.ok(near(pano.parts[1][1][0], lon + 0.001));
assert.equal(flat.isPano, false); assert.equal(flat.isPano, false);
const untyped = decodeCoverageTile(
encodePanoramaxTile(tile, {
sequences: [
{
id: 'u',
type: '',
parts: [
[
[lon, lat],
[lon + 0.001, lat],
],
],
},
],
}),
tile,
).sequences[0];
assert.equal(untyped.isPano, null, 'no camera type: neither');
}); });
test('density cells bin to a bounded number of points that keep the counts and kinds', () => { test('density cells bin to a bounded number of points that keep the counts and kinds', () => {
@@ -18,7 +18,10 @@ import {
COVERAGE_Z12_BELOW_M, COVERAGE_Z12_BELOW_M,
COVERAGE_Z13_BELOW_M, COVERAGE_Z13_BELOW_M,
COVERAGE_Z14_BELOW_M, COVERAGE_Z14_BELOW_M,
GONE_MESSAGE,
LINE_CLICK_HINT, LINE_CLICK_HINT,
LINE_CLICK_RADIUS_MAX_M,
LINE_CLICK_RADIUS_PER_HEIGHT,
NEAREST_LIMIT, NEAREST_LIMIT,
NEAREST_RADIUS_M, NEAREST_RADIUS_M,
NO_COVERAGE_HINT, NO_COVERAGE_HINT,
@@ -31,13 +34,31 @@ import {
PICTURE_TILE_EDGE_M, PICTURE_TILE_EDGE_M,
PICTURE_TILE_ZOOM, PICTURE_TILE_ZOOM,
RATE_LIMITED_MESSAGE, RATE_LIMITED_MESSAGE,
UNREADABLE_TILE_MESSAGE,
normalizeItem, normalizeItem,
} from './policy.js'; } from './policy.js';
export { PANORAMAX_PROVIDER_ID } from './policy.js'; export { PANORAMAX_PROVIDER_ID } from './policy.js';
/**
* A tile's bytes decoded by `decode`; a body that is not a vector tile
* (an HTML error page, a cut-off answer) reads as such, not as a parser's
* "Unimplemented type: 4".
*/
async function readable(decode) {
try {
return await decode();
} catch (error) {
if (error?.name === 'AbortError' || error?.status !== undefined)
throw error;
throw new Error(UNREADABLE_TILE_MESSAGE);
}
}
/** Pictures kept in memory by id (lookups and ‹ › fill it). */ /** Pictures kept in memory by id (lookups and ‹ › fill it). */
const PHOTO_CACHE_SIZE = 2000; const PHOTO_CACHE_SIZE = 2000;
/** Sequences whose cone centre is remembered. */
const ANCHOR_CACHE_SIZE = 50;
/** /**
* Sequence lines at z11–14 below 30 km; above, the density grid at z5 and * Sequence lines at z11–14 below 30 km; above, the density grid at z5 and
@@ -63,17 +84,23 @@ export function panoramaxZoomFor(heightM) {
} }
/** /**
* The imagery filter for lines (dated, typed) and grid cells (undated, with * The imagery filter for lines and grid cells. A cell (undated, with 360°
* 360° and flat counts): a cell stays while it holds pictures of the kind * and flat counts) stays while it holds pictures of the kind asked for. A
* asked for. * line is dated by its sequence's first day, so a sequence still growing
* may hide under "since" while its newer pictures can be opened; a line
* with no camera type or no day is kept (a click finds out).
*/ */
export function passesCoverage(record, filter) { export function passesCoverage(record, filter) {
if (!filter) return true;
if ('hasPano' in record) { if ('hasPano' in record) {
if (filter?.pano === 'pano') return record.hasPano; if (filter.pano === 'pano') return record.hasPano;
if (filter?.pano === 'flat') return record.hasFlat; if (filter.pano === 'flat') return record.hasFlat;
return true; return true;
} }
return passesImageryFilter(record, filter); return passesImageryFilter(record, {
pano: typeof record.isPano === 'boolean' ? filter.pano : 'all',
sinceMs: record.capturedAt ? filter.sinceMs : null,
});
} }
/** Least-recently-used map: reading an entry renews it. */ /** Least-recently-used map: reading an entry renews it. */
@@ -193,7 +220,7 @@ export function createPanoramaxProvider({
}; };
const photos = createLru(PHOTO_CACHE_SIZE); const photos = createLru(PHOTO_CACHE_SIZE);
/** Where each sequence's cones centre: the picture last opened in it, or a click. */ /** Where each sequence's cones centre: the picture last opened in it, or a click. */
const anchors = new Map(); const anchors = createLru(ANCHOR_CACHE_SIZE);
/** The z15 tiles the cones were drawn from. */ /** The z15 tiles the cones were drawn from. */
let drawn = null; let drawn = null;
/** A line click: its sequence's pictures win the next nearest lookup there. */ /** A line click: its sequence's pictures win the next nearest lookup there. */
@@ -215,10 +242,14 @@ export function createPanoramaxProvider({
if (!lookup) { if (!lookup) {
lookup = source lookup = source
.getItem(id) .getItem(id)
.catch((error) => {
// Ids are UUIDs: the API answers 400 to anything else.
if (error?.status === 400) return null;
throw error;
})
.then((item) => { .then((item) => {
const photo = item && remember(item); const photo = item && remember(item);
if (!photo) if (!photo) throw new Error(GONE_MESSAGE);
throw new Error('Panoramax has no position for this picture');
return photo; return photo;
}) })
.finally(() => inflight.delete(id)); .finally(() => inflight.delete(id));
@@ -227,6 +258,19 @@ export function createPanoramaxProvider({
return lookup; return lookup;
} }
/**
* How far from a line click its sequence's pictures are looked for:
* from high up the click may land tens of metres off the line.
*/
function lineClickRadius() {
const height = state.viewer?.camera?.positionCartographic?.height;
if (!Number.isFinite(height)) return NEAREST_RADIUS_M;
return Math.min(
LINE_CLICK_RADIUS_MAX_M,
Math.max(NEAREST_RADIUS_M, height * LINE_CLICK_RADIUS_PER_HEIGHT),
);
}
/** The picture `delta` along its sequence, from its own links (null at an end). */ /** The picture `delta` along its sequence, from its own links (null at an end). */
async function step(photo, delta) { async function step(photo, delta) {
if (!photo?.sequenceId) return null; if (!photo?.sequenceId) return null;
@@ -236,7 +280,9 @@ export function createPanoramaxProvider({
const coverage = createLineCoverage({ const coverage = createLineCoverage({
state, state,
fetchTile: async (tile, { signal }) => fetchTile: async (tile, { signal }) =>
decodeCoverageTile(await source.getTile(tile, { signal }), tile), readable(async () =>
decodeCoverageTile(await source.getTile(tile, { signal }), tile),
),
zoomFor: panoramaxZoomFor, zoomFor: panoramaxZoomFor,
colors: COLORS, colors: COLORS,
pickPrefix: PICK_PREFIX.sequence, pickPrefix: PICK_PREFIX.sequence,
@@ -252,18 +298,26 @@ export function createPanoramaxProvider({
const anchor = anchors.get(sequenceId); const anchor = anchors.get(sequenceId);
if (!anchor) return []; if (!anchor) return [];
const tiles = pictureTilesAround(anchor); const tiles = pictureTilesAround(anchor);
const lists = await Promise.all( // The home tile must load; a neighbour that fails only thins the cones.
tiles.map(async (tile) => const lists = await Promise.allSettled(
decodePictures( tiles.map((tile) =>
await source.getTile(tile, { signal }), readable(async () =>
tile, decodePictures(
sequenceId, await source.getTile(tile, { signal }),
tile,
sequenceId,
),
), ),
), ),
); );
signal?.throwIfAborted(); signal?.throwIfAborted();
if (lists[0].status === 'rejected') throw lists[0].reason;
drawn = { sequenceId, tiles: new Set(tiles.map(tileKey)) }; drawn = { sequenceId, tiles: new Set(tiles.map(tileKey)) };
const byId = new Map(lists.flat().map((image) => [image.id, image])); const byId = new Map(
lists
.flatMap((list) => list.value ?? [])
.map((image) => [image.id, image]),
);
return thinImages( return thinImages(
[...byId.values()].sort((a, b) => a.capturedAt - b.capturedAt), [...byId.values()].sort((a, b) => a.capturedAt - b.capturedAt),
); );
@@ -302,14 +356,14 @@ export function createPanoramaxProvider({
...viewerOptions, ...viewerOptions,
}); });
/** Nearest of `items` within the search radius of a point, or null. */ /** Nearest of `items` within `radius` metres of a point, or null. */
function nearestOf(items, point) { function nearestOf(items, point, radius = NEAREST_RADIUS_M) {
let best = null; let best = null;
for (const item of items) { for (const item of items) {
const photo = remember(item); const photo = remember(item);
if (!photo) continue; if (!photo) continue;
const metres = metresBetween(point, photo); const metres = metresBetween(point, photo);
if (metres > NEAREST_RADIUS_M) continue; if (metres > radius) continue;
if (!best || metres < best.metres) best = { photo, metres }; if (!best || metres < best.metres) best = { photo, metres };
} }
return best?.photo ?? null; return best?.photo ?? null;
@@ -398,13 +452,15 @@ export function createPanoramaxProvider({
const sequenceId = sequenceIdFromPick(pickId, PICK_PREFIX.sequence); const sequenceId = sequenceIdFromPick(pickId, PICK_PREFIX.sequence);
if (!sequenceId) return false; if (!sequenceId) return false;
const point = groundPoint?.(); const point = groundPoint?.();
if (!point) { // No ground under the click (the sky beyond a line): nothing to open.
// No ground under the click: show the line selected, nothing more. if (!point) return true;
cones.select(sequenceId);
return true;
}
anchors.set(sequenceId, { ...point, sequenceId }); anchors.set(sequenceId, { ...point, sequenceId });
preferred = { sequenceId, lon: point.lon, lat: point.lat }; preferred = {
sequenceId,
lon: point.lon,
lat: point.lat,
radius: lineClickRadius(),
};
context.actions.openNearest(point); context.actions.openNearest(point);
return true; return true;
}, },
@@ -429,17 +485,16 @@ export function createPanoramaxProvider({
async nearestImage({ lat, lon }, { signal } = {}) { async nearestImage({ lat, lon }, { signal } = {}) {
const point = { lat, lon }; const point = { lat, lon };
const filter = context.getFilter(); const filter = context.getFilter();
// Only the lookup the click itself asked for (the same point).
const wanted = const wanted =
preferred && metresBetween(preferred, point) < 1 preferred && metresBetween(preferred, point) < 1 ? preferred : null;
? preferred.sequenceId
: null;
preferred = null; preferred = null;
const ask = (collections) => const ask = (collections, radius = NEAREST_RADIUS_M) =>
source.nearestItems( source.nearestItems(
{ {
lat, lat,
lon, lon,
radius: NEAREST_RADIUS_M, radius,
limit: NEAREST_LIMIT, limit: NEAREST_LIMIT,
collections, collections,
pano: filter.pano, pano: filter.pano,
@@ -448,7 +503,11 @@ export function createPanoramaxProvider({
{ signal }, { signal },
); );
if (wanted) { if (wanted) {
const hit = nearestOf(await ask(wanted), point); const hit = nearestOf(
await ask(wanted.sequenceId, wanted.radius),
point,
wanted.radius,
);
if (hit) return hit.id; if (hit) return hit.id;
} }
return nearestOf(await ask(null), point)?.id ?? null; return nearestOf(await ask(null), point)?.id ?? null;
@@ -41,10 +41,12 @@ export const COVERAGE_LINES_BELOW_M = 30_000;
export const COVERAGE_TUNING = Object.freeze({ export const COVERAGE_TUNING = Object.freeze({
maxTiles: 9, maxTiles: 9,
overviewMaxTiles: 6, overviewMaxTiles: 6,
// Each tile draws at most 1,600 lines, its longest first: a dense z11 // Each tile draws at most 1,600 lines, its longest first (as decoded,
// `keepOrder`): a dense z11
// tile over Paris holds 3,800, and a lower cap left the city centre // tile over Paris holds 3,800, and a lower cap left the city centre
// looking emptier than its suburbs. (KartaView draws as many.) // looking emptier than its suburbs. (KartaView draws as many.)
maxSequences: 14_400, maxSequences: 14_400,
keepOrder: true,
lineWidthPx: 2.5, lineWidthPx: 2.5,
overviewPointPx: 4, overviewPointPx: 4,
moveDebounceMs: 320, moveDebounceMs: 320,
@@ -61,6 +63,9 @@ export const OVERVIEW_BINS_PER_AXIS = 96;
export const RATE_LIMITED_MESSAGE = export const RATE_LIMITED_MESSAGE =
'Panoramax is busy — coverage resumes on its own'; 'Panoramax is busy — coverage resumes on its own';
export const NO_COVERAGE_HINT = 'no Panoramax pictures in view'; export const NO_COVERAGE_HINT = 'no Panoramax pictures in view';
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';
/** From high up the dots are where pictures are, not sequences to click. */ /** From high up the dots are where pictures are, not sequences to click. */
export const OVERVIEW_HINT = 'zoom in to click a Panoramax sequence'; export const OVERVIEW_HINT = 'zoom in to click a Panoramax sequence';
@@ -79,8 +84,20 @@ export const TILE_CONCURRENCY = 6;
/** Pictures asked for around a point; the API returns them nearest first. */ /** Pictures asked for around a point; the API returns them nearest first. */
export const NEAREST_LIMIT = 10; export const NEAREST_LIMIT = 10;
/** Any picture within the radius, whichever way it looks (2–180°). */ /**
* The widest the API allows: the point may be beside a picture, but a
* picture looking away from it is still left out (the bounding-box search
* beside it has those).
*/
export const NEAREST_FOV_TOLERANCE_DEG = 180; export const NEAREST_FOV_TOLERANCE_DEG = 180;
/** Pictures in the bounding box around a click, at most. */
export const NEAREST_BOX_LIMIT = 100;
/**
* A line clicked from high up may be tens of metres off the click: its
* sequence is searched within the camera height / 100 (50–400 m).
*/
export const LINE_CLICK_RADIUS_PER_HEIGHT = 0.01;
export const LINE_CLICK_RADIUS_MAX_M = 400;
/** A full-resolution panorama is only fetched up to this width (memory). */ /** A full-resolution panorama is only fetched up to this width (memory). */
export const PANO_HD_MAX_WIDTH = 8_192; export const PANO_HD_MAX_WIDTH = 8_192;
@@ -147,7 +164,19 @@ function linkedId(item, rel) {
const href = item?.links?.find?.((link) => link?.rel === rel)?.href; const href = item?.links?.find?.((link) => link?.rel === rel)?.href;
if (typeof href !== 'string') return null; if (typeof href !== 'string') return null;
const id = href.split(/[?#]/)[0].split('/').filter(Boolean).pop(); const id = href.split(/[?#]/)[0].split('/').filter(Boolean).pop();
return id ? decodeURIComponent(id) : null; if (!id) return null;
try {
return decodeURIComponent(id);
} catch {
// A malformed escape is no link, not a failed lookup.
return null;
}
}
/** An email address anywhere in a name (linear: names come from any instance). */
function looksLikeEmail(name) {
const at = name.indexOf('@');
return at > 0 && name.indexOf('.', at + 2) !== -1;
} }
/** /**
@@ -165,7 +194,7 @@ function creatorOf(item) {
: []), : []),
]; ];
const name = names.find((value) => typeof value === 'string' && value.trim()); const name = names.find((value) => typeof value === 'string' && value.trim());
if (!name || /\S+@\S+\.\S+/.test(name)) return null; if (!name || looksLikeEmail(name)) return null;
return name.trim().slice(0, 80); return name.trim().slice(0, 80);
} }
@@ -184,8 +213,11 @@ export function isEquirectangular(item) {
*/ */
export function normalizeItem(item) { export function normalizeItem(item) {
if (!item || item.id == null) return null; if (!item || item.id == null) return null;
// Only numbers: Number(null) and Number('') would put a picture at 0,0.
const [lon, lat] = Array.isArray(item.geometry?.coordinates) const [lon, lat] = Array.isArray(item.geometry?.coordinates)
? item.geometry.coordinates.map(Number) ? item.geometry.coordinates.map((value) =>
typeof value === 'number' ? value : NaN,
)
: [NaN, NaN]; : [NaN, NaN];
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null; if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
@@ -193,3 +193,35 @@ test('the caption link opens the picture in the Panoramax viewer', () => {
assert.equal(panoramaxPictureUrl(null), 'https://api.panoramax.xyz/'); assert.equal(panoramaxPictureUrl(null), 'https://api.panoramax.xyz/');
assert.ok(panoramaxPictureUrl('a&b=c').includes('pic=a%26b%3Dc')); assert.ok(panoramaxPictureUrl('a&b=c').includes('pic=a%26b%3Dc'));
}); });
test('hostile item data: a malformed link escape, null coordinates, a huge producer name', () => {
const item = stacItem({ id: 'a', lon: 1, lat: 2, nextId: 'n' });
item.links.push({
rel: 'prev',
href: 'https://api.panoramax.xyz/api/collections/s/items/%E0%A4%A',
});
const photo = normalizeItem(item);
assert.equal(photo.prevId, null, 'no link, no throw');
assert.equal(photo.nextId, 'n');
for (const coordinates of [[null, null], ['', ''], ['2.35', '48.85'], [2.35]])
assert.equal(
normalizeItem({ id: 'x', geometry: { coordinates } }),
null,
JSON.stringify(coordinates),
);
// A million characters without spaces is checked in linear time.
const started = Date.now();
const huge = normalizeItem(
stacItem({ id: 'a', lon: 1, lat: 2, producer: 'a'.repeat(1_000_000) }),
);
assert.ok(Date.now() - started < 500, `${Date.now() - started} ms`);
assert.equal(huge.creator.length, 80);
const named = (producer) =>
normalizeItem(stacItem({ id: 'a', lon: 1, lat: 2, producer })).creator;
assert.equal(named(`${'x'.repeat(200_000)}@example.org`), null);
assert.equal(
named('Carto@Cité'),
'Carto@Cité',
'an @ without a domain is a name',
);
});
@@ -160,6 +160,20 @@ test('lines at z11–14 below 30 km; density points from z5 down to z0 above', (
assert.equal(panoramaxZoomFor(NaN), null); assert.equal(panoramaxZoomFor(NaN), null);
}); });
test('a line with no camera type or no day passes the filters: a click finds out', () => {
const filter = { pano: 'pano', sinceMs: Date.UTC(2024, 0, 1) };
assert.equal(
passesCoverage({ isPano: null, capturedAt: Date.UTC(2025, 0, 1) }, filter),
true,
);
assert.equal(passesCoverage({ isPano: true, capturedAt: 0 }, filter), true);
assert.equal(passesCoverage({ isPano: false, capturedAt: 0 }, filter), false);
assert.equal(
passesCoverage({ isPano: null, capturedAt: Date.UTC(2020, 0, 1) }, filter),
false,
);
});
test('the imagery filter: lines by type and day, density cells by what they hold', () => { test('the imagery filter: lines by type and day, density cells by what they hold', () => {
const old = Date.UTC(2020, 0, 1); const old = Date.UTC(2020, 0, 1);
const since = { pano: 'all', sinceMs: Date.UTC(2024, 0, 1) }; const since = { pano: 'all', sinceMs: Date.UTC(2024, 0, 1) };
@@ -314,9 +328,25 @@ test('one lookup for a picture asked twice at once', async () => {
assert.deepEqual(source.calls.items, ['s-0']); assert.deepEqual(source.calls.items, ['s-0']);
}); });
test('a picture Panoramax does not know fails the open with a reason', async () => { test('a picture Panoramax does not know, or an id it refuses, fails the open with a reason', async () => {
const env = await mounted({ items: [] }); const env = await mounted({ items: [] });
await assert.rejects(env.instance.viewer.open('ghost'), /no position/); await assert.rejects(
env.instance.viewer.open('ghost'),
/no longer available/,
);
env.source.getItem = async () => {
throw Object.assign(new Error('Panoramax answered HTTP 400'), {
status: 400,
});
};
await assert.rejects(
env.instance.viewer.open('not-a-uuid'),
/no longer available/,
);
env.source.getItem = async () => {
throw Object.assign(new Error('Panoramax is busy'), { status: 503 });
};
await assert.rejects(env.instance.viewer.open('busy'), /busy/);
}); });
/** Cones on a stand-in Cesium viewer; `count()` reads how many are drawn. */ /** Cones on a stand-in Cesium viewer; `count()` reads how many are drawn. */
@@ -340,7 +370,13 @@ async function withCones(t, env) {
}, },
camera: { positionWC: new Cesium.Cartesian3(0, 0, 1e8) }, camera: { positionWC: new Cesium.Cartesian3(0, 0, 1e8) },
}); });
return { count: () => collection?.length ?? 0 }; return {
count: () => collection?.length ?? 0,
ids: () =>
Array.from({ length: collection?.length ?? 0 }, (_, i) =>
String(collection.get(i).id).replace(PICK_PREFIX.image, ''),
),
};
} }
/** z15 tiles holding every item of `items` as a picture. */ /** z15 tiles holding every item of `items` as a picture. */
@@ -379,18 +415,148 @@ test('once a picture is on screen its sequence’s cones load from the z15 tile
assert.ok(env.source.calls.tiles.every((key) => key.startsWith('15/'))); assert.ok(env.source.calls.tiles.every((key) => key.startsWith('15/')));
}); });
test('a sequence selected with no picture or click to centre on draws no cones', async (t) => { test('a click on a line with no ground under it changes nothing: the open sequence keeps its cones', async (t) => {
const items = [...sequence('x', 6), ...sequence('y', 3, { offset: 3 })];
const env = await mounted({ items, tiles: pictureTiles(items) });
const cones = await withCones(t, env);
await env.instance.viewer.open('x-1');
await env.instance.selectSequence('x');
assert.ok(await until(() => cones.count() === 6));
const asked = env.source.calls.tiles.length;
assert.equal(
env.instance.handlePick(`${PICK_PREFIX.sequence}y`, {
groundPoint: () => null,
}),
true,
'the click is the provider’s, even with nothing to open',
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(env.instance.sequenceStats().selectedId, 'x');
assert.equal(cones.count(), 6);
assert.equal(env.source.calls.tiles.length, asked);
assert.deepEqual(env.actions.nearest, []);
});
test('a sequence selected with nothing to centre on draws no cones, and a picture opening in it then does not throw', async (t) => {
const items = sequence('s', 3); const items = sequence('s', 3);
const env = await mounted({ items, tiles: pictureTiles(items) }); const env = await mounted({ items, tiles: pictureTiles(items) });
await withCones(t, env); await withCones(t, env);
env.instance.handlePick(`${PICK_PREFIX.sequence}s`, { await env.instance.selectSequence('s');
groundPoint: () => null,
});
assert.ok(await until(() => !env.instance.sequenceStats().loading));
assert.equal(env.instance.sequenceStats().selectedId, 's', 'highlighted');
assert.equal(env.instance.sequenceStats().images, 0); assert.equal(env.instance.sequenceStats().images, 0);
assert.deepEqual(env.source.calls.tiles, []); assert.deepEqual(env.source.calls.tiles, []);
assert.deepEqual(env.actions.nearest, []); // Its first picture opens: the cones load around it.
await env.instance.viewer.open('s-0');
await env.instance.selectSequence('s');
assert.ok(await until(() => env.instance.sequenceStats().images === 3));
});
test('a line clicked from high up looks for its sequence further off; the radius grows with height, up to 400 m', async () => {
const items = [
...sequence('near', 1),
...sequence('line', 1, { offset: 220 }),
];
const { instance, source } = setup({ items });
const height = (h) =>
instance.init({
camera: { positionCartographic: { height: h } },
scene: {
primitives: { add() {}, remove() {} },
preRender: { addEventListener: () => () => {} },
},
});
const click = () =>
instance.handlePick(`${PICK_PREFIX.sequence}line`, {
groundPoint: () => east(0),
});
height(25_000);
click();
assert.equal(
await instance.nearestImage(east(0)),
'line-0',
'220 m off, from 25 km',
);
assert.equal(source.calls.nearest.at(-1).radius, 250);
height(1_000);
click();
assert.equal(
await instance.nearestImage(east(0)),
'near-0',
'from 1 km only 50 m: any sequence’s nearer picture',
);
height(900_000);
click();
await instance.nearestImage(east(0));
assert.equal(
source.calls.nearest.filter((q) => q.collections === 'line').at(-1).radius,
400,
'capped',
);
});
test('a lookup at another point does not take a line click’s sequence', async () => {
const items = [
...sequence('other', 1),
...sequence('clicked', 1, { offset: 8 }),
];
const { instance, source } = setup({ items });
instance.handlePick(`${PICK_PREFIX.sequence}clicked`, {
groundPoint: () => east(1),
});
// OPEN at the view centre, a few metres away, overtakes the click.
assert.equal(await instance.nearestImage(east(3)), 'other-0');
assert.deepEqual(
source.calls.nearest.map((q) => q.collections),
[null],
);
});
test('the cones come in capture order, and a broken neighbour tile only thins them', async (t) => {
const items = sequence('s', 5).map((item, i) => ({
...item,
properties: {
...item.properties,
// Captured in reverse of their position along the street.
datetime: `2025-06-01T10:00:0${9 - i}+00:00`,
},
}));
const tiles = pictureTiles(items);
const env = await mounted({ items, tiles });
const cones = await withCones(t, env);
await env.instance.viewer.open('s-0');
await env.instance.selectSequence('s');
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');
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/);
});
test('› within the tiles the cones came from reloads nothing', async (t) => {
const items = sequence('s', 4);
const env = await mounted({ items, tiles: pictureTiles(items) });
await withCones(t, env);
await env.instance.viewer.open('s-0');
await env.instance.selectSequence('s');
assert.ok(await until(() => env.instance.sequenceStats().images === 4));
const asked = env.source.calls.tiles.length;
env.next.fire('click');
assert.ok(await until(() => env.poses.at(-1) === 's-1'));
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(env.source.calls.tiles.length, asked);
}); });
test('the cones re-centre when › walks into another z15 tile', async (t) => { test('the cones re-centre when › walks into another z15 tile', async (t) => {
@@ -3,6 +3,7 @@ import {
API_CONCURRENCY, API_CONCURRENCY,
API_TIMEOUT_MS, API_TIMEOUT_MS,
MAX_RESPONSE_BYTES, MAX_RESPONSE_BYTES,
NEAREST_BOX_LIMIT,
NEAREST_FOV_TOLERANCE_DEG, NEAREST_FOV_TOLERANCE_DEG,
NEAREST_LIMIT, NEAREST_LIMIT,
NEAREST_RADIUS_M, NEAREST_RADIUS_M,
@@ -151,9 +152,13 @@ export function createPanoramaxSource({
}, },
/** /**
* Pictures within `radius` metres of a point, nearest first, whichever * Pictures within `radius` metres of a point, unranked: the caller ranks
* way they look; `collections` keeps one sequence's, `pano` and * them by distance. Two searches side by side, since neither finds them
* `sinceMs` filter as the imagery filter does. * all: `place_position` (nearest first) leaves out pictures looking
* away from the point, whatever the tolerance; a bounding box has them
* but in no order, so in a dense spot its page may miss the nearest.
* `collections` keeps one sequence's, `pano` and `sinceMs` filter as
* the imagery filter does.
*/ */
async nearestItems( async nearestItems(
{ {
@@ -167,18 +172,40 @@ export function createPanoramaxSource({
}, },
{ signal } = {}, { signal } = {},
) { ) {
const params = { const metres = Math.min(1000, Math.max(1, Math.round(radius)));
place_position: `${Number(lon).toFixed(7)},${Number(lat).toFixed(7)}`, const shared = {};
place_distance: `0-${Math.min(1000, Math.max(1, Math.round(radius)))}`, if (collections) shared.collections = String(collections);
place_fov_tolerance: String(NEAREST_FOV_TOLERANCE_DEG), if (pano === 'pano') shared.filter = 'field_of_view=360';
limit: String(Math.min(100, Math.max(1, limit))), else if (pano === 'flat') shared.filter = 'field_of_view<360';
};
if (collections) params.collections = String(collections);
if (pano === 'pano') params.filter = 'field_of_view=360';
else if (pano === 'flat') params.filter = 'field_of_view<360';
if (Number.isFinite(sinceMs) && sinceMs > 0) if (Number.isFinite(sinceMs) && sinceMs > 0)
params.datetime = `${new Date(sinceMs).toISOString()}/..`; shared.datetime = `${new Date(sinceMs).toISOString()}/..`;
return search(params, { signal }); 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(
{
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 },
),
]);
const byId = new Map();
for (const item of [...facing, ...around])
if (item?.id != null && !byId.has(String(item.id)))
byId.set(String(item.id), item);
return [...byId.values()];
}, },
}; };
} }
@@ -101,7 +101,7 @@ test('a picture by id is a STAC search; another id in the answer is not taken fo
assert.equal(asked.searchParams.get('limit'), '1'); assert.equal(asked.searchParams.get('limit'), '1');
}); });
test('nearest pictures: any direction within the radius, filtered as the imagery filter asks', async () => { test('nearest pictures: a place search and a box search side by side, both filtered as the imagery filter asks', async () => {
const { urls, fetchImpl } = fakeFetch(() => json({ features: [] })); const { urls, fetchImpl } = fakeFetch(() => json({ features: [] }));
const source = createPanoramaxSource({ fetchImpl }); const source = createPanoramaxSource({ fetchImpl });
await source.nearestItems({ lat: 48.85, lon: 2.35 }); await source.nearestItems({ lat: 48.85, lon: 2.35 });
@@ -115,7 +115,13 @@ test('nearest pictures: any direction within the radius, filtered as the imagery
limit: 500, limit: 500,
}); });
await source.nearestItems({ lat: 48.85, lon: 2.35, pano: 'flat' }); await source.nearestItems({ lat: 48.85, lon: 2.35, pano: 'flat' });
const [plain, filtered, flat] = urls.map((u) => new URL(u).searchParams); const asked = urls.map((u) => new URL(u).searchParams);
assert.equal(asked.length, 6, 'two searches a lookup');
const place = asked.filter((q) => q.has('place_position'));
const box = asked.filter((q) => q.has('bbox'));
assert.equal(place.length, 3);
assert.equal(box.length, 3);
const [plain, filtered, flat] = place;
assert.equal(plain.get('place_position'), '2.3500000,48.8500000'); assert.equal(plain.get('place_position'), '2.3500000,48.8500000');
assert.equal(plain.get('place_distance'), '0-50'); assert.equal(plain.get('place_distance'), '0-50');
assert.equal(plain.get('place_fov_tolerance'), '180'); assert.equal(plain.get('place_fov_tolerance'), '180');
@@ -128,6 +134,53 @@ test('nearest pictures: any direction within the radius, filtered as the imagery
assert.equal(filtered.get('place_distance'), '0-1000', 'capped'); assert.equal(filtered.get('place_distance'), '0-1000', 'capped');
assert.equal(filtered.get('limit'), '100', 'capped'); assert.equal(filtered.get('limit'), '100', 'capped');
assert.equal(flat.get('filter'), 'field_of_view<360'); assert.equal(flat.get('filter'), 'field_of_view<360');
// The box spans the radius each way, and carries the same filters.
const [w, s, e, n] = box[0].get('bbox').split(',').map(Number);
assert.ok(Math.abs((n - s) * 111_320 - 100) < 1, `${n - s}°`);
assert.ok(
Math.abs((e - w) * 111_320 * Math.cos((48.85 * Math.PI) / 180) - 100) < 1,
);
assert.equal(box[1].get('collections'), 'seq');
assert.equal(box[1].get('filter'), 'field_of_view=360');
assert.equal(box[1].get('datetime'), '2025-01-01T00:00:00.000Z/..');
assert.equal(box[2].get('filter'), 'field_of_view<360');
});
test('a picture both searches find is listed once', async () => {
const source = createPanoramaxSource({
fetchImpl: fakeFetch((url) =>
url.includes('bbox')
? json({ features: [{ id: 'b' }, { id: 'a' }, { id: null }] })
: json({ features: [{ id: 'a' }] }),
).fetchImpl,
});
const items = await source.nearestItems({ lat: 1, lon: 2 });
assert.deepEqual(
items.map((i) => i.id),
['a', 'b'],
);
});
test('the newest search goes first when the API gate is full', async () => {
const waiting = [];
const order = [];
const source = createPanoramaxSource({
fetchImpl: async (url) => {
order.push(new URL(url).searchParams.get('ids'));
await new Promise((resolve) => waiting.push(resolve));
return new Response(JSON.stringify({ features: [] }));
},
});
const all = ['1', '2', '3', '4', '5', '6'].map((id) => source.getItem(id));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(order, ['1', '2', '3', '4'], 'four at once');
waiting.shift()();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(order.at(-1), '6', 'the latest waiting one next');
while (waiting.length) waiting.shift()();
await new Promise((resolve) => setTimeout(resolve, 0));
while (waiting.length) waiting.shift()();
await Promise.all(all);
}); });
test('an unreadable search answer, or one with no features, is handled', async () => { test('an unreadable search answer, or one with no features, is handled', async () => {
@@ -1,4 +1,8 @@
import { createPhotoViewer, loadImage } from '../../photoViewer.js'; import {
PANO_FULL_SIZE,
createPhotoViewer,
loadImage,
} from '../../photoViewer.js';
import { import {
HD_TIMEOUT_MS, HD_TIMEOUT_MS,
PANORAMAX_NAME, PANORAMAX_NAME,
@@ -8,6 +12,13 @@ import {
panoramaxPictureUrl, panoramaxPictureUrl,
} from './policy.js'; } from './policy.js';
/** An HD original small enough to decode (its width is known and under the cap). */
function hdDecodable(photo) {
return Boolean(
photo.urls.hd && photo.width && photo.width <= PANO_HD_MAX_WIDTH,
);
}
/** /**
* Where Panoramax pictures come from: the instance holding each one serves * Where Panoramax pictures come from: the instance holding each one serves
* a 2,048 px SD derivative (equirectangular for a 360° picture) and the HD * a 2,048 px SD derivative (equirectangular for a 360° picture) and the HD
@@ -16,11 +27,15 @@ import {
* @type {import('../../photoViewer.js').PhotoSources} * @type {import('../../photoViewer.js').PhotoSources}
*/ */
export const PANORAMAX_SOURCES = Object.freeze({ export const PANORAMAX_SOURCES = Object.freeze({
hasPano: (photo) => Boolean(photo.isPano && (photo.urls.sd || photo.urls.hd)), hasPano: (photo) =>
panoFirst: (photo) => Boolean(photo.isPano && (photo.urls.sd || hdDecodable(photo))),
photo.urls.sd // Without an SD derivative the original goes straight to the texture size.
? { url: photo.urls.sd, width: SD_WIDTH } panoFirst: (photo) => {
: { url: photo.urls.hd, width: photo.width || SD_WIDTH }, if (photo.urls.sd) return { url: photo.urls.sd, width: SD_WIDTH };
if (!hdDecodable(photo)) return null;
const width = Math.min(photo.width, PANO_FULL_SIZE[0]);
return { url: photo.urls.hd, width, resize: [width, width / 2] };
},
panoSharp: (photo, size) => { panoSharp: (photo, size) => {
const width = photo.width || 0; const width = photo.width || 0;
if (!photo.urls.hd || !photo.urls.sd) return null; if (!photo.urls.hd || !photo.urls.sd) return null;
@@ -34,8 +49,13 @@ export const PANORAMAX_SOURCES = Object.freeze({
timeoutMs: HD_TIMEOUT_MS, timeoutMs: HD_TIMEOUT_MS,
}; };
}, },
// A 360° picture shown flat (no WebGL) is its whole equirectangular image. // A 360° picture shown flat (no WebGL) is its whole equirectangular image;
flatFirst: (photo) => [photo.urls.sd, photo.urls.hd, photo.urls.thumb], // an original too big to decode is skipped for the thumbnail.
flatFirst: (photo) => [
photo.urls.sd,
!photo.isPano || hdDecodable(photo) ? photo.urls.hd : null,
photo.urls.thumb,
],
flatSharp: (photo, pixels) => flatSharp: (photo, pixels) =>
photo.urls.sd && photo.urls.hd && pixels > SD_WIDTH ? photo.urls.hd : null, photo.urls.sd && photo.urls.hd && pixels > SD_WIDTH ? photo.urls.hd : null,
describe: (photo) => ({ describe: (photo) => ({
@@ -143,6 +143,7 @@ test('a 360° picture shows its SD equirectangular image, then HD shrunk to 4,09
assert.deepEqual(loads[0], { assert.deepEqual(loads[0], {
url: PHOTOS.pano.urls.sd, url: PHOTOS.pano.urls.sd,
crossOrigin: true, crossOrigin: true,
resize: null,
failure: 'The Panoramax photo could not be loaded', failure: 'The Panoramax photo could not be loaded',
}); });
assert.equal(poses.at(-1).isPano, true); assert.equal(poses.at(-1).isPano, true);
@@ -179,8 +180,16 @@ test('HD panoramas too big to decode, or no bigger than SD, are never fetched',
const hdOnly = { ...PHOTOS.pano, urls: { ...PHOTOS.pano.urls, sd: null } }; const hdOnly = { ...PHOTOS.pano, urls: { ...PHOTOS.pano.urls, sd: null } };
assert.deepEqual(PANORAMAX_SOURCES.panoFirst(hdOnly), { assert.deepEqual(PANORAMAX_SOURCES.panoFirst(hdOnly), {
url: hdOnly.urls.hd, url: hdOnly.urls.hd,
width: 5760, width: 4096,
resize: [4096, 2048],
}); });
// …unless it is too big to decode: then no panorama, and the thumbnail.
const hugeOnly = { ...hdOnly, width: 11_904 };
assert.equal(PANORAMAX_SOURCES.panoFirst(hugeOnly), null);
assert.equal(PANORAMAX_SOURCES.hasPano(hugeOnly), false);
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), null);
assert.equal(PANORAMAX_SOURCES.flatSharp(PHOTOS.flat, 2048), null); assert.equal(PANORAMAX_SOURCES.flatSharp(PHOTOS.flat, 2048), null);
assert.equal( assert.equal(
+4 -6
View File
@@ -755,12 +755,10 @@ test('Panoramax fixtures: two sequences on one street, searches honour sequence
panoramaxTile, panoramaxTile,
resetPanoramaxFixture, resetPanoramaxFixture,
} = await import('../../scripts/fixtures/street-level/panoramax.mjs'); } = await import('../../scripts/fixtures/street-level/panoramax.mjs');
const { decodeCoverageTile, decodePictures } = await import( const { decodeCoverageTile, decodePictures } =
'../layers/streetLevel/providers/panoramax/decode.js' await import('../layers/streetLevel/providers/panoramax/decode.js');
); const { normalizeItem } =
const { normalizeItem } = await import( await import('../layers/streetLevel/providers/panoramax/policy.js');
'../layers/streetLevel/providers/panoramax/policy.js'
);
resetPanoramaxFixture(); resetPanoramaxFixture();
const pictures = panoramaxPictures(); const pictures = panoramaxPictures();
assert.deepEqual( assert.deepEqual(