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
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
nearest the click (`/api/search?place_position…&collections=<id>`, any
direction: `place_fov_tolerance=180`), without framing, then any sequence's
if it has none within 50 m; the filters go to the API (`filter=
field_of_view=360`, `datetime=<since>/..`). Once the picture is on screen
nearest the click, within the camera height / 100 (50–400 m: from high up
a click lands well off a line), without framing, then any sequence's within
50 m. Each lookup is two searches side by side, ranked by distance in the
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
(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
+1 -1
View File
@@ -2809,7 +2809,7 @@ async function main() {
);
if (fixtures)
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(
` (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} 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).
*/
/**
@@ -559,10 +560,13 @@ export function createLineCoverage({
north: bounds.north + pad,
};
if (kind === 'sequence') {
// Newest first; the per-tile cap applies to what passes the filter.
entry.sequenceList = decoded.sequences.sort(
(a, b) => (b.capturedAt || 0) - (a.capturedAt || 0),
);
// Newest first, unless the provider ordered them itself; the
// per-tile cap applies to what passes the filter.
entry.sequenceList = tuning.keepOrder
? decoded.sequences
: decoded.sequences.sort(
(a, b) => (b.capturedAt || 0) - (a.capturedAt || 0),
);
entry.sequences = new Map();
entry.total = decoded.sequences.length;
} else {
+3 -1
View File
@@ -188,13 +188,15 @@ export function createPanoramaRenderer(canvas) {
gl.UNSIGNED_BYTE,
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.
image.close?.();
// 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_MAG_FILTER, gl.LINEAR);
const repeat =
isPowerOfTwo(image.width) && isPowerOfTwo(image.height)
isPowerOfTwo(width) && isPowerOfTwo(height)
? gl.REPEAT
: gl.CLAMP_TO_EDGE;
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);
});
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.
* @property {(photo: object) => boolean} hasPano A 360° photo with an equirectangular image.
* @property {(photo: object) => {url: string, width: number}|null} panoFirst
* The panorama shown first, and its texture width (0 when it is only a preview).
* @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) => Array<string>} flatFirst Flat versions to try in turn.
@@ -158,8 +158,8 @@ export function createPhotoViewer({
let restoreView = null;
/** Width of the panorama texture on screen (0: the preview). */
let panoWidth = 0;
/** Width of a sharper panorama on its way (resizes come in bursts). */
let panoComing = 0;
/** The sharper panorama on its way, for which open (resizes come in bursts). */
let panoComing = null;
/** The sharper flat version on screen or on its way (one download each). */
let flatSharpUrl = null;
/** The photo an open is fetching: ‹ › step on from it, not the old one. */
@@ -520,10 +520,12 @@ export function createPhotoViewer({
if (kind === 'flat' && target.isPano) return;
if (kind === 'pano') {
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);
if (!sharp?.url) return;
panoComing = size[0];
const coming = { seq, width: size[0] };
panoComing = coming;
try {
const image = await loadAs(sharp.url, {
crossOrigin: true,
@@ -544,7 +546,7 @@ export function createPhotoViewer({
} catch {
/* the first version stays */
} finally {
if (panoComing === size[0]) panoComing = 0;
if (panoComing === coming) panoComing = null;
}
return;
}
@@ -574,7 +576,10 @@ export function createPhotoViewer({
// waiting on it twice.
const first = sources.panoFirst(target);
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) {
image.close?.();
return false;
@@ -592,7 +597,7 @@ export function createPhotoViewer({
show('pano');
renderer.setImage(image);
panoWidth = first.width || 0;
panoComing = 0;
panoComing = null;
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),
parts,
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);
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.ok(near(pano.parts[1][1][0], lon + 0.001));
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', () => {
@@ -18,7 +18,10 @@ import {
COVERAGE_Z12_BELOW_M,
COVERAGE_Z13_BELOW_M,
COVERAGE_Z14_BELOW_M,
GONE_MESSAGE,
LINE_CLICK_HINT,
LINE_CLICK_RADIUS_MAX_M,
LINE_CLICK_RADIUS_PER_HEIGHT,
NEAREST_LIMIT,
NEAREST_RADIUS_M,
NO_COVERAGE_HINT,
@@ -31,13 +34,31 @@ import {
PICTURE_TILE_EDGE_M,
PICTURE_TILE_ZOOM,
RATE_LIMITED_MESSAGE,
UNREADABLE_TILE_MESSAGE,
normalizeItem,
} 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). */
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
@@ -63,17 +84,23 @@ export function panoramaxZoomFor(heightM) {
}
/**
* The imagery filter for lines (dated, typed) and grid cells (undated, with
* 360° and flat counts): a cell stays while it holds pictures of the kind
* asked for.
* The imagery filter for lines and grid cells. A cell (undated, with 360°
* and flat counts) stays while it holds pictures of the kind asked for. A
* 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) {
if (!filter) return true;
if ('hasPano' in record) {
if (filter?.pano === 'pano') return record.hasPano;
if (filter?.pano === 'flat') return record.hasFlat;
if (filter.pano === 'pano') return record.hasPano;
if (filter.pano === 'flat') return record.hasFlat;
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. */
@@ -193,7 +220,7 @@ export function createPanoramaxProvider({
};
const photos = createLru(PHOTO_CACHE_SIZE);
/** 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. */
let drawn = null;
/** A line click: its sequence's pictures win the next nearest lookup there. */
@@ -215,10 +242,14 @@ export function createPanoramaxProvider({
if (!lookup) {
lookup = source
.getItem(id)
.catch((error) => {
// Ids are UUIDs: the API answers 400 to anything else.
if (error?.status === 400) return null;
throw error;
})
.then((item) => {
const photo = item && remember(item);
if (!photo)
throw new Error('Panoramax has no position for this picture');
if (!photo) throw new Error(GONE_MESSAGE);
return photo;
})
.finally(() => inflight.delete(id));
@@ -227,6 +258,19 @@ export function createPanoramaxProvider({
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). */
async function step(photo, delta) {
if (!photo?.sequenceId) return null;
@@ -236,7 +280,9 @@ export function createPanoramaxProvider({
const coverage = createLineCoverage({
state,
fetchTile: async (tile, { signal }) =>
decodeCoverageTile(await source.getTile(tile, { signal }), tile),
readable(async () =>
decodeCoverageTile(await source.getTile(tile, { signal }), tile),
),
zoomFor: panoramaxZoomFor,
colors: COLORS,
pickPrefix: PICK_PREFIX.sequence,
@@ -252,18 +298,26 @@ export function createPanoramaxProvider({
const anchor = anchors.get(sequenceId);
if (!anchor) return [];
const tiles = pictureTilesAround(anchor);
const lists = await Promise.all(
tiles.map(async (tile) =>
decodePictures(
await source.getTile(tile, { signal }),
tile,
sequenceId,
// 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,
),
),
),
);
signal?.throwIfAborted();
if (lists[0].status === 'rejected') throw lists[0].reason;
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(
[...byId.values()].sort((a, b) => a.capturedAt - b.capturedAt),
);
@@ -302,14 +356,14 @@ export function createPanoramaxProvider({
...viewerOptions,
});
/** Nearest of `items` within the search radius of a point, or null. */
function nearestOf(items, point) {
/** Nearest of `items` within `radius` metres of a point, or null. */
function nearestOf(items, point, radius = NEAREST_RADIUS_M) {
let best = null;
for (const item of items) {
const photo = remember(item);
if (!photo) continue;
const metres = metresBetween(point, photo);
if (metres > NEAREST_RADIUS_M) continue;
if (metres > radius) continue;
if (!best || metres < best.metres) best = { photo, metres };
}
return best?.photo ?? null;
@@ -398,13 +452,15 @@ export function createPanoramaxProvider({
const sequenceId = sequenceIdFromPick(pickId, PICK_PREFIX.sequence);
if (!sequenceId) return false;
const point = groundPoint?.();
if (!point) {
// No ground under the click: show the line selected, nothing more.
cones.select(sequenceId);
return true;
}
// No ground under the click (the sky beyond a line): nothing to open.
if (!point) return true;
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);
return true;
},
@@ -429,17 +485,16 @@ export function createPanoramaxProvider({
async nearestImage({ lat, lon }, { signal } = {}) {
const point = { lat, lon };
const filter = context.getFilter();
// Only the lookup the click itself asked for (the same point).
const wanted =
preferred && metresBetween(preferred, point) < 1
? preferred.sequenceId
: null;
preferred && metresBetween(preferred, point) < 1 ? preferred : null;
preferred = null;
const ask = (collections) =>
const ask = (collections, radius = NEAREST_RADIUS_M) =>
source.nearestItems(
{
lat,
lon,
radius: NEAREST_RADIUS_M,
radius,
limit: NEAREST_LIMIT,
collections,
pano: filter.pano,
@@ -448,7 +503,11 @@ export function createPanoramaxProvider({
{ signal },
);
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;
}
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({
maxTiles: 9,
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
// looking emptier than its suburbs. (KartaView draws as many.)
maxSequences: 14_400,
keepOrder: true,
lineWidthPx: 2.5,
overviewPointPx: 4,
moveDebounceMs: 320,
@@ -61,6 +63,9 @@ export const OVERVIEW_BINS_PER_AXIS = 96;
export const RATE_LIMITED_MESSAGE =
'Panoramax is busy — coverage resumes on its own';
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. */
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. */
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;
/** 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). */
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;
if (typeof href !== 'string') return null;
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());
if (!name || /\S+@\S+\.\S+/.test(name)) return null;
if (!name || looksLikeEmail(name)) return null;
return name.trim().slice(0, 80);
}
@@ -184,8 +213,11 @@ export function isEquirectangular(item) {
*/
export function normalizeItem(item) {
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)
? item.geometry.coordinates.map(Number)
? item.geometry.coordinates.map((value) =>
typeof value === 'number' ? value : NaN,
)
: [NaN, NaN];
if (!Number.isFinite(lat) || !Number.isFinite(lon)) 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.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);
});
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', () => {
const old = Date.UTC(2020, 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']);
});
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: [] });
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. */
@@ -340,7 +370,13 @@ async function withCones(t, env) {
},
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. */
@@ -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/')));
});
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 env = await mounted({ items, tiles: pictureTiles(items) });
await withCones(t, env);
env.instance.handlePick(`${PICK_PREFIX.sequence}s`, {
groundPoint: () => null,
});
assert.ok(await until(() => !env.instance.sequenceStats().loading));
assert.equal(env.instance.sequenceStats().selectedId, 's', 'highlighted');
await env.instance.selectSequence('s');
assert.equal(env.instance.sequenceStats().images, 0);
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) => {
@@ -3,6 +3,7 @@ import {
API_CONCURRENCY,
API_TIMEOUT_MS,
MAX_RESPONSE_BYTES,
NEAREST_BOX_LIMIT,
NEAREST_FOV_TOLERANCE_DEG,
NEAREST_LIMIT,
NEAREST_RADIUS_M,
@@ -151,9 +152,13 @@ export function createPanoramaxSource({
},
/**
* Pictures within `radius` metres of a point, nearest first, whichever
* way they look; `collections` keeps one sequence's, `pano` and
* `sinceMs` filter as the imagery filter does.
* Pictures within `radius` metres of a point, unranked: the caller ranks
* them by distance. Two searches side by side, since neither finds them
* 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(
{
@@ -167,18 +172,40 @@ export function createPanoramaxSource({
},
{ signal } = {},
) {
const params = {
place_position: `${Number(lon).toFixed(7)},${Number(lat).toFixed(7)}`,
place_distance: `0-${Math.min(1000, Math.max(1, Math.round(radius)))}`,
place_fov_tolerance: String(NEAREST_FOV_TOLERANCE_DEG),
limit: String(Math.min(100, Math.max(1, limit))),
};
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';
const metres = Math.min(1000, Math.max(1, Math.round(radius)));
const shared = {};
if (collections) shared.collections = String(collections);
if (pano === 'pano') shared.filter = 'field_of_view=360';
else if (pano === 'flat') shared.filter = 'field_of_view<360';
if (Number.isFinite(sinceMs) && sinceMs > 0)
params.datetime = `${new Date(sinceMs).toISOString()}/..`;
return search(params, { signal });
shared.datetime = `${new Date(sinceMs).toISOString()}/..`;
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');
});
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 source = createPanoramaxSource({ fetchImpl });
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,
});
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_distance'), '0-50');
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('limit'), '100', 'capped');
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 () => {
@@ -1,4 +1,8 @@
import { createPhotoViewer, loadImage } from '../../photoViewer.js';
import {
PANO_FULL_SIZE,
createPhotoViewer,
loadImage,
} from '../../photoViewer.js';
import {
HD_TIMEOUT_MS,
PANORAMAX_NAME,
@@ -8,6 +12,13 @@ import {
panoramaxPictureUrl,
} 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
* a 2,048 px SD derivative (equirectangular for a 360° picture) and the HD
@@ -16,11 +27,15 @@ import {
* @type {import('../../photoViewer.js').PhotoSources}
*/
export const PANORAMAX_SOURCES = Object.freeze({
hasPano: (photo) => Boolean(photo.isPano && (photo.urls.sd || photo.urls.hd)),
panoFirst: (photo) =>
photo.urls.sd
? { url: photo.urls.sd, width: SD_WIDTH }
: { url: photo.urls.hd, width: photo.width || SD_WIDTH },
hasPano: (photo) =>
Boolean(photo.isPano && (photo.urls.sd || hdDecodable(photo))),
// Without an SD derivative the original goes straight to the texture size.
panoFirst: (photo) => {
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) => {
const width = photo.width || 0;
if (!photo.urls.hd || !photo.urls.sd) return null;
@@ -34,8 +49,13 @@ export const PANORAMAX_SOURCES = Object.freeze({
timeoutMs: HD_TIMEOUT_MS,
};
},
// A 360° picture shown flat (no WebGL) is its whole equirectangular image.
flatFirst: (photo) => [photo.urls.sd, photo.urls.hd, photo.urls.thumb],
// A 360° picture shown flat (no WebGL) is its whole equirectangular image;
// 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) =>
photo.urls.sd && photo.urls.hd && pixels > SD_WIDTH ? photo.urls.hd : null,
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], {
url: PHOTOS.pano.urls.sd,
crossOrigin: true,
resize: null,
failure: 'The Panoramax photo could not be loaded',
});
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 } };
assert.deepEqual(PANORAMAX_SOURCES.panoFirst(hdOnly), {
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.flatSharp(PHOTOS.flat, 2048), null);
assert.equal(
+4 -6
View File
@@ -755,12 +755,10 @@ test('Panoramax fixtures: two sequences on one street, searches honour sequence
panoramaxTile,
resetPanoramaxFixture,
} = await import('../../scripts/fixtures/street-level/panoramax.mjs');
const { decodeCoverageTile, decodePictures } = await import(
'../layers/streetLevel/providers/panoramax/decode.js'
);
const { normalizeItem } = await import(
'../layers/streetLevel/providers/panoramax/policy.js'
);
const { decodeCoverageTile, decodePictures } =
await import('../layers/streetLevel/providers/panoramax/decode.js');
const { normalizeItem } =
await import('../layers/streetLevel/providers/panoramax/policy.js');
resetPanoramaxFixture();
const pictures = panoramaxPictures();
assert.deepEqual(