mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
00d2b73b6a
commit
bce3044e0c
18 files changed
+699
-98
No files matched your search
+10
-4
@@ -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
|
||||
|
||||
@@ -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(' | ')})`,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)',
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user