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