diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7b28ca40b..7cf173b2c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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=`, 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=/..`). 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=/..`). 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 diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 7437b2be8..52251ba2c 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -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(' | ')})`, diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index 85edd97c1..9c363a07e 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -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 { diff --git a/src/layers/streetLevel/panorama.js b/src/layers/streetLevel/panorama.js index 50f348a9b..72f5718a0 100644 --- a/src/layers/streetLevel/panorama.js +++ b/src/layers/streetLevel/panorama.js @@ -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); diff --git a/src/layers/streetLevel/panorama.test.mjs b/src/layers/streetLevel/panorama.test.mjs index 5bb6380d1..6a0c7c8b8 100644 --- a/src/layers/streetLevel/panorama.test.mjs +++ b/src/layers/streetLevel/panorama.test.mjs @@ -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)', + ); +}); diff --git a/src/layers/streetLevel/photoViewer.js b/src/layers/streetLevel/photoViewer.js index 49dac9949..705519446 100644 --- a/src/layers/streetLevel/photoViewer.js +++ b/src/layers/streetLevel/photoViewer.js @@ -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} 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; } diff --git a/src/layers/streetLevel/photoViewer.test.mjs b/src/layers/streetLevel/photoViewer.test.mjs new file mode 100644 index 000000000..5276ea375 --- /dev/null +++ b/src/layers/streetLevel/photoViewer.test.mjs @@ -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')); +}); diff --git a/src/layers/streetLevel/providers/panoramax/decode.js b/src/layers/streetLevel/providers/panoramax/decode.js index 02ff0579f..19b470846 100644 --- a/src/layers/streetLevel/providers/panoramax/decode.js +++ b/src/layers/streetLevel/providers/panoramax/decode.js @@ -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); diff --git a/src/layers/streetLevel/providers/panoramax/decode.test.mjs b/src/layers/streetLevel/providers/panoramax/decode.test.mjs index 397380dad..4347463b0 100644 --- a/src/layers/streetLevel/providers/panoramax/decode.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/decode.test.mjs @@ -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', () => { diff --git a/src/layers/streetLevel/providers/panoramax/index.js b/src/layers/streetLevel/providers/panoramax/index.js index cda9015c0..1fa65fd51 100644 --- a/src/layers/streetLevel/providers/panoramax/index.js +++ b/src/layers/streetLevel/providers/panoramax/index.js @@ -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; diff --git a/src/layers/streetLevel/providers/panoramax/policy.js b/src/layers/streetLevel/providers/panoramax/policy.js index b163be640..f7629b5aa 100644 --- a/src/layers/streetLevel/providers/panoramax/policy.js +++ b/src/layers/streetLevel/providers/panoramax/policy.js @@ -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; diff --git a/src/layers/streetLevel/providers/panoramax/policy.test.mjs b/src/layers/streetLevel/providers/panoramax/policy.test.mjs index 5d93d7956..605bcf7ad 100644 --- a/src/layers/streetLevel/providers/panoramax/policy.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/policy.test.mjs @@ -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', + ); +}); diff --git a/src/layers/streetLevel/providers/panoramax/provider.test.mjs b/src/layers/streetLevel/providers/panoramax/provider.test.mjs index e0d5eae61..2ae2c8091 100644 --- a/src/layers/streetLevel/providers/panoramax/provider.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/provider.test.mjs @@ -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('oops'); + 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('oops'); + 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) => { diff --git a/src/layers/streetLevel/providers/panoramax/source.js b/src/layers/streetLevel/providers/panoramax/source.js index 3ddb6e1e9..7d3c86e6c 100644 --- a/src/layers/streetLevel/providers/panoramax/source.js +++ b/src/layers/streetLevel/providers/panoramax/source.js @@ -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()]; }, }; } diff --git a/src/layers/streetLevel/providers/panoramax/source.test.mjs b/src/layers/streetLevel/providers/panoramax/source.test.mjs index 7f2e87237..a068a3eff 100644 --- a/src/layers/streetLevel/providers/panoramax/source.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/source.test.mjs @@ -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 () => { diff --git a/src/layers/streetLevel/providers/panoramax/viewer.js b/src/layers/streetLevel/providers/panoramax/viewer.js index df666237b..90ef88c97 100644 --- a/src/layers/streetLevel/providers/panoramax/viewer.js +++ b/src/layers/streetLevel/providers/panoramax/viewer.js @@ -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) => ({ diff --git a/src/layers/streetLevel/providers/panoramax/viewer.test.mjs b/src/layers/streetLevel/providers/panoramax/viewer.test.mjs index d1ec2732e..3dd5e7a1b 100644 --- a/src/layers/streetLevel/providers/panoramax/viewer.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/viewer.test.mjs @@ -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( diff --git a/src/tooling/streetLevelQa.test.mjs b/src/tooling/streetLevelQa.test.mjs index 354ca3edf..c4eb22dd3 100644 --- a/src/tooling/streetLevelQa.test.mjs +++ b/src/tooling/streetLevelQa.test.mjs @@ -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(