fix(street-level): Panoramax IC8 review — line geometry budget, caches, attribution, tests

From a five-lens IC8 review (correctness, security, tests with mutation
testing, performance, UX):
- P0: over Paris at 12–60 km the unsimplified z10–z12 lines sent ~2M
  points to Cesium's ground-polyline builder (gigabytes of geometry; the
  tab hung). decode.js now simplifies lines (Douglas–Peucker, 4 tile
  units: 6–10% of the points), and lineCoverage gains an opt-in point
  budget (`maxPoints`, 10,000 a tile for Panoramax).
- Decoded coverage tiles are kept (KartaView's tile cache moves to the
  shared tileCache.js); a sequence's z15 picture lists are kept and load
  on their own connection; cones know their tiles from the start of a
  load, so a picture opening meanwhile re-centres them and ‹ › no longer
  restarts the load.
- The HD panorama loads only for the expanded viewer (a docked one on a
  dense screen asked for 3 MB originals) and is aborted when another
  photo opens; picture files over 48 MB or 16,384 px are refused; a
  bitmap no renderer took after a context loss is closed.
- A nearest lookup survives one of its two searches failing; only a
  timeout is retried; offline reads as unreachable; Retry-After is
  capped at five minutes; the search box stays on the map at ±180°;
  cones near both edges of a narrow (high-latitude) tile read both.
- Every producer is credited (app and photographer); with none to name,
  the caption says "via <instance>" rather than crediting the host.
- Esc anywhere in the docked viewer (toolbar, caption) clears the
  sequence before the panel takes it. A filter that hides every density
  dot says so; wording aligned ("coverage", "photo").
- Tests for every surviving mutant (Esc consumed, keepOrder and dot-size
  wiring, licence in the core, limiter aborts, radius floor, bbox corner,
  first-producer-only, byte and size caps), a limiter test, and a gate
  fixture that answers box searches and leaves out pictures facing away;
  the gate now clicks over the other sequence's pictures, so only the
  line click's own-sequence lookup can pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-14 15:24:13 +01:00
1 parent 5526666894
commit cf502429a3
29 files changed
+1153 -216

No files matched your search

+5 -1
View File
@@ -106,7 +106,11 @@
and licence (CC BY-SA 4.0, Licence Ouverte 2.0…) and links to it in the
Panoramax viewer; HD originals load only for an expanded viewer, once.
Share links carry it as `0.m.0_0.r.panoramax-1`. KartaView's photo viewer
is now shared (`photoViewer.js`, `panorama.js`).
is now shared (`photoViewer.js`, `panorama.js`); in it, for KartaView too,
Esc clears the selected drive before it collapses the panel. Lines are
simplified and drawn within a point budget (a tile's unsimplified lines
over Paris hung the tab), decoded tiles are kept, and the cones' tiles
have their own connection.
- Street Level: KartaView is the third provider, keyless. Light its chip
(purple; off by default, one provider at a time) and zoom in below about
+1 -1
View File
@@ -344,7 +344,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. KartaView photos are undated on its map, so the filters do not apply to it. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's author and licence (mostly CC BY-SA 4.0; IGN's are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. KartaView photos are undated on its map, so the filters do not apply to it. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.kartaview-1` KartaView on, `0.m.0_0.r.panoramax-1` Panoramax on (a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
+13 -7
View File
@@ -3275,16 +3275,22 @@ 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
picture opens outside them. ‹ › follow the picture's own `prev`/`next`
item), plus the neighbours within 150 m of an edge, on their own connection
(coverage tiles take three, searches two); each tile's list for the sequence
is kept, and the cones re-centre when a picture opens outside the tiles
they were drawn from, judged from the moment their load starts. ‹ › follow the picture's own `prev`/`next`
links, so stepping costs one small search per picture. The viewer shows the
2,048 px SD derivative (equirectangular for a 360° picture) and swaps in the
HD original only when the viewer is wider than SD (flat) or needs a 4,096 px
panorama (expanded), once, shrunk while it decodes; originals wider than
8,192 px are never fetched. The caption names the author (not an account
named by an email address) and the picture's licence (CC BY-SA 4.0, Licence
HD original only when the viewer is wider than SD (flat) or, for a
panorama, expanded; once, shrunk while it decodes, and aborted if another
photo opens; originals wider than 8,192 px are never fetched, and any
picture file over 48 MB or 16,384 px is refused. The caption names every
producer (an uploading app and its photographer; never an account named by
an email address), or else the instance holding the picture ("via
panoramax.ign.fr"), and the picture's licence (CC BY-SA 4.0, Licence
Ouverte 2.0…; StreetPose `license`), and links to the picture in the
Panoramax viewer. The MCP globe panel allows the meta-catalogue and the
Panoramax viewer. Esc anywhere in the docked viewer clears the selected
sequence before the panel can take it (KartaView too). The MCP globe panel allows the meta-catalogue and the
three biggest instances; a picture on another instance shows only in the
app.
+29 -5
View File
@@ -112,7 +112,11 @@ function item(picture, pictures) {
sd: { href: `${files}/sd.jpg` },
thumb: { href: `${files}/thumb.jpg` },
},
links: [...link('prev', sibling(-1)), ...link('next', sibling(1))],
links: [
...link('prev', sibling(-1)),
...link('next', sibling(1)),
{ rel: 'via', href: HOST },
],
};
}
@@ -231,11 +235,9 @@ export function answerPanoramaxRequest({ method, url }) {
.map((p) => item(p, pictures)),
});
}
const [lon, lat] = (q.get('place_position') || '').split(',').map(Number);
const radius = Number((q.get('place_distance') || '0-50').split('-')[1]);
const filter = q.get('filter') || '';
const since = q.get('datetime')?.split('/')[0];
const near = pictures
const kept = pictures
.filter(
(p) => !q.get('collections') || p.sequenceId === q.get('collections'),
)
@@ -246,7 +248,29 @@ export function answerPanoramaxRequest({ method, url }) {
? !p.isPano
: true,
)
.filter((p) => !since || Date.parse(p.datetime) >= Date.parse(since))
.filter((p) => !since || Date.parse(p.datetime) >= Date.parse(since));
// A box: every picture in it, in no particular order (id order here).
if (q.has('bbox')) {
const [west, south, east, north] = q.get('bbox').split(',').map(Number);
return json({
type: 'FeatureCollection',
features: kept
.filter(
(p) =>
p.lon >= west && p.lon <= east && p.lat >= south && p.lat <= north,
)
.sort((a, b) => a.id.localeCompare(b.id))
.slice(0, Number(q.get('limit')) || 10)
.map((p) => item(p, pictures)),
});
}
const [lon, lat] = (q.get('place_position') || '').split(',').map(Number);
const radius = Number((q.get('place_distance') || '0-50').split('-')[1]);
// As the API does, even at the widest tolerance: a place behind a
// picture (every one looks east) is not in it.
const facing = (p) => lon >= p.lon - 1e-7;
const near = kept
.filter(facing)
.map((p) => ({ p, metres: metresApart({ lon, lat }, p) }))
.filter(({ metres }) => metres <= radius)
.sort((a, b) => a.metres - b.metres)
+4
View File
@@ -928,12 +928,16 @@
"src/layers/streetLevel/providers/panoramax/viewer.js",
"src/layers/streetLevel/photoViewer.js",
"src/layers/streetLevel/limiter.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/providers/panoramax/policy.test.mjs",
"src/layers/streetLevel/providers/panoramax/decode.test.mjs",
"src/layers/streetLevel/providers/panoramax/source.test.mjs",
"src/layers/streetLevel/providers/panoramax/viewer.test.mjs",
"src/layers/streetLevel/providers/panoramax/provider.test.mjs",
"src/testSupport/panoramaxFixtures.mjs",
"src/layers/streetLevel/photoViewer.test.mjs",
"src/layers/streetLevel/limiter.test.mjs",
"src/layers/streetLevel/panorama.test.mjs",
"src/layers/streetLevel/lineCoverage.test.mjs",
"src/testSupport/kartaViewFakes.mjs",
"src/testSupport/googleStreetViewFakes.mjs",
+3
View File
@@ -1241,6 +1241,7 @@
"src/layers/streetLevel/providers/panoramax/viewer.js",
"src/layers/streetLevel/photoViewer.js",
"src/layers/streetLevel/limiter.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
@@ -2216,6 +2217,7 @@
"src/layers/streetLevel/providers/panoramax/viewer.js",
"src/layers/streetLevel/photoViewer.js",
"src/layers/streetLevel/limiter.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
@@ -2762,6 +2764,7 @@
"src/layers/streetLevel/providers/panoramax/viewer.js",
"src/layers/streetLevel/photoViewer.js",
"src/layers/streetLevel/limiter.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
+26 -3
View File
@@ -1255,7 +1255,30 @@ async function main() {
.catch(() => null);
if (!handle && !fixtures) return skip('no Panoramax line in view');
assert.ok(handle, 'a Panoramax line is pickable');
const at = await handle.jsonValue();
let at = await handle.jsonValue();
if (fixtures) {
// Click the top line where the other sequence's pictures are:
// the nearest picture is then the other's, and only the line
// click's own-sequence lookup opens the clicked line's.
const others = panoramaxPictures()
.filter((p) => p.sequenceId !== at.id)
.slice(40, 100)
.map((p) => [p.lon, p.lat]);
const aimed = await page
.waitForFunction(
findPickPoint,
{ timeout: 60_000, polling: 500 },
{
prefix: 'pnx:seq:',
id: at.id,
points: others,
billboardIds: null,
},
)
.catch(() => null);
assert.ok(aimed, 'the line is pickable over the other’s pictures');
at = await aimed.jsonValue();
}
const parked = await camera();
await page.mouse.click(at.x, at.y);
await page.waitForFunction(
@@ -1298,8 +1321,8 @@ async function main() {
else
assert.equal(
caption,
'Licence Ouverte 2.0',
'an account named by an email address is not shown',
'via panoramax.openstreetmap.fr · Licence Ouverte 2.0',
'an account named by an email address is not shown; the instance is',
);
const pictures = panoramaxPictures().filter(
(p) => p.sequenceId === street.sequenceId,
+80
View File
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createLimiter } from './limiter.js';
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
/** A task that runs until `finish()`. */
function held(log, name) {
let finish;
const done = new Promise((resolve) => (finish = resolve));
return {
task: () => {
log.push(name);
return done.then(() => name);
},
finish: () => finish(),
};
}
test('at most `limit` tasks run; queued ones start first in, first out', async () => {
const log = [];
const gate = createLimiter(2);
const tasks = ['a', 'b', 'c', 'd'].map((name) => held(log, name));
const results = tasks.map(({ task }) => gate.run(task));
await tick();
assert.deepEqual(log, ['a', 'b']);
assert.equal(gate.pending, 4);
tasks[0].finish();
await tick();
assert.deepEqual(log, ['a', 'b', 'c']);
for (const { finish } of tasks) finish();
assert.deepEqual(await Promise.all(results), ['a', 'b', 'c', 'd']);
assert.equal(gate.pending, 0);
});
test('lifo starts the newest queued task next', async () => {
const log = [];
const gate = createLimiter(1, { lifo: true });
const tasks = ['a', 'b', 'c'].map((name) => held(log, name));
const results = tasks.map(({ task }) => gate.run(task));
await tick();
tasks[0].finish();
await tick();
assert.deepEqual(log, ['a', 'c']);
tasks[2].finish();
tasks[1].finish();
await Promise.all(results);
});
test('a queued task whose signal aborts never runs, leaves the queue and rejects with the reason', async () => {
const log = [];
const gate = createLimiter(1);
const first = held(log, 'a');
const running = gate.run(first.task);
const controller = new AbortController();
const queued = gate.run(held(log, 'b').task, { signal: controller.signal });
await tick();
assert.equal(gate.pending, 2);
controller.abort(new Error('overtaken'));
await assert.rejects(queued, /overtaken/);
assert.equal(gate.pending, 1, 'out of the queue');
first.finish();
await running;
await tick();
assert.deepEqual(log, ['a'], 'b never ran');
});
test('a task already aborted when its turn comes is skipped', async () => {
const log = [];
const gate = createLimiter(1);
const first = held(log, 'a');
const running = gate.run(first.task);
const signal = { aborted: false, reason: new Error('gone'), addEventListener() {} };
const queued = gate.run(held(log, 'b').task, { signal });
signal.aborted = true;
first.finish();
await running;
await assert.rejects(queued, /gone/);
assert.deepEqual(log, ['a']);
});
+17 -1
View File
@@ -73,6 +73,7 @@ export function meshBoxInRange(box, centre) {
* @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).
* @property {number} [maxPoints] Line points drawn at once, over every tile (default: no limit).
*/
/**
@@ -111,6 +112,9 @@ export function createLineCoverage({
}) {
const { render } = state.services;
const perTileSequenceCap = Math.floor(tuning.maxSequences / tuning.maxTiles);
const perTilePointCap = tuning.maxPoints
? Math.floor(tuning.maxPoints / tuning.maxTiles)
: Infinity;
/** Colour for a line: the provider's, GEV cyan while selected. */
function sequenceColor({ selected = false } = {}) {
@@ -151,13 +155,25 @@ export function createLineCoverage({
function drawnSequences(entry) {
const drawn = [];
const current = filter();
let points = 0;
for (const sequence of entry.sequenceList) {
if (drawn.length >= perTileSequenceCap) break;
if (passes(sequence, current)) drawn.push(sequence);
if (!passes(sequence, current)) continue;
// A line past the point budget is skipped; a shorter one may still fit.
const size = pointsOf(sequence);
if (points + size > perTilePointCap) continue;
points += size;
drawn.push(sequence);
}
return drawn;
}
function pointsOf(sequence) {
let count = 0;
for (const part of sequence.parts) count += part.length;
return count;
}
/**
* Batched primitives for a tile's sequence parts: cast polylines where
* terrain heights are cached, draped ground lines otherwise. Each batch
+97 -2
View File
@@ -25,7 +25,15 @@ const TUNING = Object.freeze({
*/
function setup(
t,
{ zoomFor, messages = { rateLimited: 'busy' }, passes, fail } = {},
{
zoomFor,
messages = { rateLimited: 'busy' },
passes,
fail,
decoded = null,
tuning = TUNING,
overviewPointSize = null,
} = {},
) {
const centre = tileCentre(-121.4944, 38.5816);
const viewer = coverageViewer(
@@ -68,6 +76,7 @@ function setup(
fetchTile: async (tile) => {
asked.push(tile);
if (fail) throw fail;
if (decoded) return decoded(centre);
return {
sequences: [
{
@@ -94,9 +103,10 @@ function setup(
colors: { coverage: '#635bff', selected: '#00e5ff' },
pickPrefix: 'test:line:',
renderKey: 'test-coverage',
tuning: TUNING,
tuning,
messages,
...(passes ? { passes } : {}),
...(overviewPointSize ? { overviewPointSize } : {}),
});
t.after(() => {
coverage.clear();
@@ -140,3 +150,88 @@ test('a refused key without its own message keeps the error it came with', async
assert.ok(await until(() => state.keyRejected === true));
assert.equal(state.coverage.lastError, 'the source refused the key');
});
/** Three lines in one tile: an old long one, then two newer short ones. */
function threeLines(centre) {
const line = (id, capturedAt, span) => ({
id,
parts: [
[
[centre.lon - span, centre.lat],
[centre.lon + span, centre.lat],
],
],
capturedAt,
isPano: false,
});
return {
sequences: [
line('old-long', 1, 0.002),
line('new-short', 2, 0.0002),
line('newer-short', 3, 0.0001),
],
overview: [],
};
}
test('the per-tile cap keeps the newest lines, or the provider’s own order with keepOrder', async (t) => {
const capped = { ...TUNING, maxTiles: 1, maxSequences: 2 };
const drawnIds = (state) =>
[...state.coverage.tiles.values()].flatMap((tile) => [
...tile.sequences.keys(),
]);
const newest = setup(t, { decoded: threeLines, tuning: capped });
newest.coverage.refresh();
assert.ok(await until(() => newest.state.coverage.tiles.size === 1));
assert.deepEqual(drawnIds(newest.state).sort(), ['new-short', 'newer-short']);
const kept = setup(t, {
decoded: threeLines,
tuning: { ...capped, keepOrder: true },
});
kept.coverage.refresh();
assert.ok(await until(() => kept.state.coverage.tiles.size === 1));
assert.deepEqual(drawnIds(kept.state), ['old-long', 'new-short']);
});
test('overview points take the provider’s size, each its own', async (t) => {
const { state, coverage, viewer } = setup(t, {
zoomFor: () => ({ zoom: 3, kind: 'overview' }),
tuning: { ...TUNING, overviewMaxTiles: 4 },
decoded: (centre) => ({
sequences: [],
overview: [
{ id: 'a', lon: centre.lon, lat: centre.lat, weight: 1 },
{ id: 'b', lon: centre.lon + 0.01, lat: centre.lat, weight: 50_000 },
],
}),
overviewPointSize: (point) => (point.weight > 1 ? 7 : 3),
});
coverage.refresh();
assert.ok(await until(() => state.coverage.tiles.size > 0));
const sizes = [];
for (const primitive of viewer.scene.primitives.items)
for (let i = 0; i < (primitive.length ?? 0); i++)
sizes.push(primitive.get(i).pixelSize);
assert.deepEqual(sizes.sort(), [3, 7]);
});
test('a point budget skips lines past it, and a shorter one later may still fit', async (t) => {
const { state, coverage } = setup(t, {
tuning: { ...TUNING, maxTiles: 1, maxSequences: 10, maxPoints: 5, keepOrder: true },
decoded: (centre) => {
const line = (id, n) => ({
id,
parts: [
Array.from({ length: n }, (_, i) => [centre.lon - 0.001 + i * 0.0001, centre.lat]),
],
capturedAt: 0,
isPano: false,
});
return { sequences: [line('three', 3), line('four', 4), line('two', 2)], overview: [] };
},
});
coverage.refresh();
assert.ok(await until(() => state.coverage.tiles.size === 1));
const [tile] = state.coverage.tiles.values();
assert.deepEqual([...tile.sequences.keys()], ['three', 'two']);
});
+64 -20
View File
@@ -7,6 +7,10 @@ import {
/** A photo download (tens of kilobytes to a few megabytes). */
export const IMAGE_TIMEOUT_MS = 15_000;
/** A picture file or decoded side past these is refused (a photo is a few MB). */
export const MAX_IMAGE_BYTES = 48 * 1024 * 1024;
export const MAX_IMAGE_SIDE = 16_384;
/** Panorama texture widths: enough for a docked viewer, and for an expanded one. */
export const PANO_MID_SIZE = Object.freeze([2048, 1024]);
export const PANO_FULL_SIZE = Object.freeze([4096, 2048]);
@@ -27,6 +31,7 @@ export function loadImage(
timeoutMs = IMAGE_TIMEOUT_MS,
resize = null,
failure = 'The photo could not be loaded',
signal = null,
} = {},
) {
if (
@@ -35,7 +40,7 @@ export function loadImage(
typeof globalThis.createImageBitmap === 'function' &&
typeof globalThis.fetch === 'function'
)
return loadBitmap(url, timeoutMs, resize, failure);
return loadBitmap(url, timeoutMs, resize, failure, signal);
return new Promise((resolve, reject) => {
if (!url) return reject(new Error(failure));
const image = new Image();
@@ -57,27 +62,42 @@ export function loadImage(
});
}
async function loadBitmap(url, timeoutMs, resize, failure) {
async function loadBitmap(url, timeoutMs, resize, failure, signal) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
// The caller's abort (the photo changed) stops a multi-megabyte download.
const onAbort = () => controller.abort();
if (signal?.aborted) controller.abort();
signal?.addEventListener?.('abort', onAbort, { once: true });
try {
const response = await fetch(url, {
mode: 'cors',
signal: controller.signal,
});
if (!response.ok) throw new Error(failure);
// Picture hosts are not ours: refuse a file or an image past any real
// photo's size before it is decoded.
const length = Number(response.headers?.get?.('content-length'));
if (length > MAX_IMAGE_BYTES) throw new Error(failure);
const blob = await response.blob();
return await (resize
if (blob.size > MAX_IMAGE_BYTES) throw new Error(failure);
const bitmap = await (resize
? createImageBitmap(blob, {
resizeWidth: resize[0],
resizeHeight: resize[1],
resizeQuality: 'high',
})
: createImageBitmap(blob));
if (bitmap.width > MAX_IMAGE_SIDE || bitmap.height > MAX_IMAGE_SIDE) {
bitmap.close?.();
throw new Error(failure);
}
return bitmap;
} catch {
throw new Error(failure);
} finally {
clearTimeout(timer);
signal?.removeEventListener?.('abort', onAbort);
}
}
@@ -92,8 +112,8 @@ export function panoSizeFor(canvasPixels, fovDeg) {
* @property {(photo: object) => boolean} hasPano A 360° photo with an equirectangular image.
* @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, size: [number, number], view: {expanded: boolean}) => {url: string, resize?: [number, number]|null, timeoutMs?: number}|null} panoSharp
* A sharper panorama for a canvas that needs `size`, or null; `expanded` while the viewer is.
* @property {(photo: object) => Array<string>} flatFirst Flat versions to try in turn.
* @property {(photo: object, pixels: number) => string|null} flatSharp
* A sharper flat version for a picture this many device pixels wide, or null.
@@ -411,6 +431,24 @@ export function createPhotoViewer({
renderer.setView({ fov: fov * Math.exp(event.deltaY * 0.001) });
}
/**
* Docked, Esc anywhere in the viewer's box (the photo, its toolbar, its
* caption) is the provider's before the panel's, which would collapse;
* expanded, the dialog's own Esc shrinks it.
*/
function onEscapeKey(event) {
if (
event.key !== 'Escape' ||
event.shiftKey ||
event.defaultPrevented ||
root?.closest?.('.sl-viewer-wrap-expanded') ||
!onEscape()
)
return;
event.preventDefault();
event.stopPropagation();
}
function onKeyDown(event) {
if (
event.defaultPrevented ||
@@ -419,18 +457,6 @@ export function createPhotoViewer({
event.metaKey
)
return;
// Docked, Esc is the viewer's before the panel's (which would collapse);
// expanded, the dialog's own Esc shrinks it.
if (
event.key === 'Escape' &&
!event.shiftKey &&
!root?.closest?.('.sl-viewer-wrap-expanded') &&
onEscape()
) {
event.preventDefault();
event.stopPropagation();
return;
}
const pano = showing === 'pano' && renderer;
const turn = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
// Shift+← → look around a panorama; ← → step along the sequence.
@@ -502,6 +528,7 @@ export function createPhotoViewer({
root.append(picture, canvas, prevButton, nextButton);
host.append(root);
listen(root, 'keydown', onKeyDown);
listen(host.closest?.('.sl-viewer-wrap') ?? root, 'keydown', onEscapeKey);
applyRenderMode();
}
@@ -536,13 +563,18 @@ export function createPhotoViewer({
const size = panoSizeNow();
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, {
expanded: Boolean(
root?.closest?.('.sl-viewer-wrap-expanded'),
),
});
if (!sharp?.url) return;
const coming = { seq, width: size[0] };
const coming = { seq, width: size[0], controller: new AbortController() };
panoComing = coming;
try {
const image = await loadAs(sharp.url, {
crossOrigin: true,
signal: coming.controller.signal,
resize: sharp.resize ?? null,
...(sharp.timeoutMs ? { timeoutMs: sharp.timeoutMs } : {}),
});
@@ -578,6 +610,12 @@ export function createPhotoViewer({
}
}
/** Another photo, or none: a sharper panorama still on its way is not needed. */
function dropSharpPanorama() {
panoComing?.controller?.abort();
panoComing = null;
}
/** The sharp panorama this viewer needs now: 2,048 docked, 4,096 expanded. */
function panoSizeNow() {
const ratio = Math.min(globalThis.devicePixelRatio || 1, 2);
@@ -600,7 +638,10 @@ export function createPhotoViewer({
}
// The context may have been lost while this loaded: build on the new
// canvas, or let the caller show the front view.
if (!ensureRenderer()) throw new Error(LOAD_FAILED);
if (!ensureRenderer()) {
image.close?.();
throw new Error(LOAD_FAILED);
}
const restore = restoreView?.id === target.id ? restoreView.view : null;
restoreView = null;
if (restore) renderer.setView(restore);
@@ -670,6 +711,7 @@ export function createPhotoViewer({
if (!root) throw new Error(`${name} viewer is not mounted`);
const id = String(imageId);
const seq = ++openSeq;
dropSharpPanorama();
const keepView = keepViewFor === id;
keepViewFor = null;
openingId = id;
@@ -682,6 +724,7 @@ export function createPhotoViewer({
close() {
openSeq++;
dropSharpPanorama();
openingId = null;
keepViewFor = null;
photo = null;
@@ -693,6 +736,7 @@ export function createPhotoViewer({
unmount() {
openSeq++;
dropSharpPanorama();
openingId = null;
keepViewFor = null;
flatSharpUrl = null;
+141
View File
@@ -164,3 +164,144 @@ test('Esc: the docked viewer offers it to the provider first; expanded, it is th
assert.equal(asked, 1, 'not asked while expanded');
assert.equal(expanded.prevented, false);
});
test('a sharp panorama still downloading when another photo opens is aborted', async (t) => {
const signals = [];
const viewer = createPhotoViewer({
providerId: 'test',
name: 'Test',
sources: SOURCES,
getPhoto: async (id) => PHOTOS[id],
step: async () => null,
onStep() {},
load: (url, options) =>
url.endsWith('-sharp')
? new Promise((_, reject) => {
signals.push(options.signal);
options.signal?.addEventListener('abort', () => reject(new Error('aborted')));
})
: Promise.resolve({ src: url }),
createRenderer: () => ({
setImage() {},
setView() {},
getView: () => ({ yaw: 0, pitch: 0, fov: 80 }),
resize() {},
clear() {},
destroy() {},
}),
});
t.after(() => delete globalThis.devicePixelRatio);
globalThis.devicePixelRatio = 1;
const host = fakeHost();
await viewer.mount(host);
parts(host).canvas.clientWidth = 1800;
await viewer.open('a');
await tick();
assert.equal(signals.length, 1);
assert.equal(signals[0].aborted, false);
await viewer.open('f');
assert.equal(signals[0].aborted, true, 'not needed any more');
});
test('a lost WebGL context while the first panorama loads releases its bitmap', async () => {
let closed = false;
let built = 0;
let land = null;
const viewer = createPhotoViewer({
providerId: 'test',
name: 'Test',
sources: SOURCES,
getPhoto: async (id) => PHOTOS[id],
step: async () => null,
onStep() {},
load: (url) =>
new Promise((resolve) => {
land = () => resolve({ src: url, close: () => (closed = true) });
}),
// The first renderer is lost mid-load; a new one cannot be built.
createRenderer: () => {
built++;
if (built > 1) throw new Error('context lost');
return {
setImage() {},
setView() {},
getView: () => ({ yaw: 0, pitch: 0, fov: 80 }),
resize() {},
clear() {},
destroy() {},
};
},
});
const host = fakeHost();
await viewer.mount(host);
const opening = viewer.open('a');
assert.ok(await until(() => land !== null), 'the panorama is loading');
parts(host).canvas.fire('webglcontextlost', {});
const first = land;
first();
// The panorama failed: the front view is loaded flat instead.
assert.ok(await until(() => land !== first), 'the flat fallback loads');
land();
await opening;
assert.equal(built, 2, 'a new renderer was tried');
assert.equal(closed, true, 'the bitmap no renderer took is closed');
});
test('docked, Esc anywhere in the viewer’s box (toolbar, caption) is offered to the provider', async () => {
let asked = 0;
const viewer = createPhotoViewer({
providerId: 'test',
name: 'Test',
sources: SOURCES,
getPhoto: async (id) => PHOTOS[id],
step: async () => null,
onStep() {},
onEscape: () => ++asked > 0,
load: async (url) => ({ src: url }),
});
const host = fakeHost();
const wrap = fakeHost();
host.closest = (selector) => (selector === '.sl-viewer-wrap' ? wrap : null);
await viewer.mount(host);
const flags = { stopped: false, prevented: false };
// Focus back on EXPAND after a shrink: the key reaches the wrap, not the photo.
wrap.fire('keydown', {
key: 'Escape',
stopPropagation: () => (flags.stopped = true),
preventDefault: () => (flags.prevented = true),
});
assert.equal(asked, 1);
assert.deepEqual(flags, { stopped: true, prevented: true });
viewer.unmount();
wrap.fire('keydown', { key: 'Escape' });
assert.equal(asked, 1, 'unmounted: the listener is gone');
});
test('a picture file or bitmap past any real photo’s size is refused', async (t) => {
const { loadImage, MAX_IMAGE_BYTES, MAX_IMAGE_SIDE } = await import(
'./photoViewer.js'
);
const saved = {
fetch: globalThis.fetch,
createImageBitmap: globalThis.createImageBitmap,
};
t.after(() => Object.assign(globalThis, saved));
let closed = 0;
const bitmap = (width, height) => ({ width, height, close: () => closed++ });
globalThis.createImageBitmap = async (blob) => blob.bitmap;
const respond = ({ length, size, width = 100, height = 50 }) =>
(globalThis.fetch = async () => ({
ok: true,
headers: new Map([['content-length', String(length ?? '')]]),
blob: async () => ({ size: size ?? 10, bitmap: bitmap(width, height) }),
}));
respond({ length: MAX_IMAGE_BYTES + 1 });
await assert.rejects(loadImage('https://x/a.jpg'), /could not be loaded/);
respond({ size: MAX_IMAGE_BYTES + 1 });
await assert.rejects(loadImage('https://x/a.jpg'), /could not be loaded/);
respond({ width: MAX_IMAGE_SIDE + 1 });
await assert.rejects(loadImage('https://x/a.jpg'), /could not be loaded/);
assert.equal(closed, 1, 'the oversize bitmap is released');
respond({});
assert.equal((await loadImage('https://x/a.jpg')).width, 100);
});
@@ -1,4 +1,7 @@
import { createLineCoverage } from '../../lineCoverage.js';
import { createLru, createTileCache } from '../../tileCache.js';
export { createTileCache } from '../../tileCache.js';
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
import { metresBetween } from '../../view.js';
import { decodeCoverageTile } from './decode.js';
@@ -31,9 +34,6 @@ import {
const PHOTO_CACHE_SIZE = 4000;
/** Sequence pages kept in memory. */
const PAGE_CACHE_SIZE = 12;
/** Decoded coverage tiles kept, and for how long. */
const TILE_CACHE_SIZE = 24;
const TILE_CACHE_TTL_MS = 10 * 60_000;
/** z14 near the street, z13 a little higher, nothing above. */
export function kartaViewZoomFor(heightM) {
@@ -44,51 +44,6 @@ export function kartaViewZoomFor(heightM) {
};
}
/** Least-recently-used map: reading an entry renews it. */
function createLru(limit) {
const map = new Map();
return {
get(key) {
if (!map.has(key)) return undefined;
const value = map.get(key);
map.delete(key);
map.set(key, value);
return value;
},
set(key, value) {
map.delete(key);
map.set(key, value);
while (map.size > limit) map.delete(map.keys().next().value);
},
delete: (key) => map.delete(key),
clear: () => map.clear(),
};
}
/**
* Loaded coverage tiles by `z/x/y`, at most `size`, each for `ttlMs`. KartaView
* sends tiles uncompressed and uncacheable (hundreds of kB, seconds each), so
* a pan back or a zoom across z13/z14 would fetch them all again.
*/
export function createTileCache({
load,
size = TILE_CACHE_SIZE,
ttlMs = TILE_CACHE_TTL_MS,
now = Date.now,
}) {
const kept = createLru(size);
return {
async get(tile, options) {
const key = `${tile.z}/${tile.x}/${tile.y}`;
const entry = kept.get(key);
if (entry && now() - entry.at < ttlMs) return entry.tile;
const loaded = await load(tile, options);
kept.set(key, { at: now(), tile: loaded });
return loaded;
},
};
}
/** The 1-based page of a sequence that holds `index`, if no photo is missing. */
function pageOf(index) {
return Math.floor(Math.max(0, index) / SEQUENCE_PAGE_SIZE) + 1;
@@ -746,3 +746,24 @@ test('a photo no page lists steps to the photos either side of its index', async
await press(env.prev, env.poses);
assert.equal(env.poses.at(-1), 's-4');
});
test('Esc in the docked viewer clears the selected drive and is consumed; with none, it goes on to the panel', async (t) => {
const env = await mounted({ sequences: { s: [0, 1, 2, 3] } });
await withCones(t, env);
await env.instance.viewer.open('s-1');
await env.instance.selectSequence('s');
assert.ok(await until(() => env.instance.sequenceStats().images === 4));
const root = env.next.parentNode;
const press = () => {
const flags = { stopped: false, prevented: false };
root.fire('keydown', {
key: 'Escape',
stopPropagation: () => (flags.stopped = true),
preventDefault: () => (flags.prevented = true),
});
return flags;
};
assert.deepEqual(press(), { stopped: true, prevented: true });
assert.equal(env.instance.sequenceStats().selectedId, null, 'drive cleared');
assert.deepEqual(press(), { stopped: false, prevented: false });
});
@@ -1,7 +1,49 @@
import { PbfReader } from 'pbf';
import { VectorTile } from '@mapbox/vector-tile';
import { tileLocalToLonLat } from '../../tileMath.js';
import { OVERVIEW_BINS_PER_AXIS, panoramaxTimeMs } from './policy.js';
import {
OVERVIEW_BINS_PER_AXIS,
SIMPLIFY_TOLERANCE_UNITS,
panoramaxTimeMs,
} from './policy.js';
/**
* Douglas–Peucker in tile units: the points of `line` ({x, y}) that keep
* it within `tolerance` of the original, first and last always kept.
* Iterative, so a long track cannot overflow the stack.
*/
export function simplifyLine(line, tolerance) {
if (line.length < 3 || !(tolerance > 0)) return line;
const keep = new Uint8Array(line.length);
keep[0] = 1;
keep[line.length - 1] = 1;
const spans = [[0, line.length - 1]];
while (spans.length) {
const [from, to] = spans.pop();
const a = line[from];
const b = line[to];
const dx = b.x - a.x;
const dy = b.y - a.y;
const span = Math.hypot(dx, dy);
let far = -1;
let farthest = tolerance;
for (let i = from + 1; i < to; i++) {
const p = line[i];
// Distance to the chord, or to `a` where the chord has no length.
const d = span
? Math.abs((p.x - a.x) * dy - (p.y - a.y) * dx) / span
: Math.hypot(p.x - a.x, p.y - a.y);
if (d > farthest) {
farthest = d;
far = i;
}
}
if (far === -1) continue;
keep[far] = 1;
spans.push([from, far], [far, to]);
}
return line.filter((_, i) => keep[i]);
}
/** Epoch ms of a sequence's day ('2025-08-11'), or 0 when undated. */
function dayMs(value) {
@@ -36,6 +78,8 @@ export function decodeCoverageTile(bytes, address) {
const props = feature.properties || {};
const parts = [];
let length = 0;
const tolerance =
(SIMPLIFY_TOLERANCE_UNITS * sequences.extent) / 4096;
for (const line of feature.loadGeometry()) {
if (line.length < 2) continue;
for (let k = 1; k < line.length; k++)
@@ -43,8 +87,10 @@ export function decodeCoverageTile(bytes, address) {
line[k].x - line[k - 1].x,
line[k].y - line[k - 1].y,
);
// Simplified before anything else: GPS tracks carry a point every
// few metres, and each becomes ~8 ground-line vertices on the GPU.
parts.push(
line.map((point) =>
simplifyLine(line, tolerance).map((point) =>
tileLocalToLonLat(point.x, point.y, sequences.extent, x, y, z),
),
);
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { decodeCoverageTile, decodePictures } from './decode.js';
import { decodeCoverageTile, decodePictures, simplifyLine } from './decode.js';
import { OVERVIEW_BINS_PER_AXIS } from './policy.js';
import { encodePanoramaxTile } from '../../../../testSupport/panoramaxFixtures.mjs';
import { tileCentre } from '../../../../testSupport/streetLevelFakes.mjs';
@@ -189,3 +189,38 @@ test('a z15 tile’s pictures of one sequence become cones; other sequences’ a
});
assert.deepEqual(decodePictures(lines, tile, 's1'), []);
});
test('lines are simplified within a few tile units, ends and corners kept', () => {
const zigzag = Array.from({ length: 1000 }, (_, i) => ({ x: i, y: i % 2 ? 1 : 0 }));
assert.deepEqual(simplifyLine(zigzag, 4), [zigzag[0], zigzag.at(-1)], 'a 1-unit wobble goes');
const corner = [
{ x: 0, y: 0 },
{ x: 50, y: 1 },
{ x: 100, y: 0 },
{ x: 100, y: 100 },
];
assert.deepEqual(simplifyLine(corner, 4), [corner[0], corner[2], corner[3]]);
assert.equal(simplifyLine(corner.slice(0, 2), 4).length, 2);
// A loop back to its start (no chord) keeps its far point.
const loop = [
{ x: 0, y: 0 },
{ x: 40, y: 0 },
{ x: 0, y: 0 },
];
assert.equal(simplifyLine(loop, 4).length, 3);
assert.equal(simplifyLine(zigzag, 0).length, 1000, 'no tolerance, no change');
});
test('a decoded GPS track keeps a fraction of its points', () => {
const { tile, lon, lat } = tileCentre(2.35, 48.85);
const track = Array.from({ length: 500 }, (_, i) => [
lon - 0.004 + i * 0.000016,
lat + Math.sin(i / 40) * 0.0004,
]);
const [sequence] = decodeCoverageTile(
encodePanoramaxTile(tile, { sequences: [{ id: 'gps', parts: [track] }] }),
tile,
).sequences;
assert.ok(sequence.parts[0].length < 100, `${sequence.parts[0].length} points`);
assert.ok(sequence.parts[0].length > 5);
});
@@ -8,6 +8,7 @@ import {
tileBounds,
} from '../../tileMath.js';
import { metresBetween } from '../../view.js';
import { createLru, createTileCache } from '../../tileCache.js';
import { decodeCoverageTile, decodePictures } from './decode.js';
import { createPanoramaxSource } from './source.js';
import { createPanoramaxViewer } from './viewer.js';
@@ -70,6 +71,10 @@ export function overviewPointSize(point) {
const PHOTO_CACHE_SIZE = 2000;
/** Sequences whose cone centre is remembered. */
const ANCHOR_CACHE_SIZE = 50;
/** Decoded coverage tiles kept (a simplified dense z10 tile is ~4 MB). */
const COVERAGE_TILE_CACHE_SIZE = 16;
/** One sequence's pictures in a z15 tile, kept for re-centring. */
const PICTURE_LIST_CACHE_SIZE = 32;
/**
* Sequence lines at z10–14 below 60 km; above, the density grid at z5 and
@@ -116,26 +121,6 @@ export function passesCoverage(record, filter) {
});
}
/** Least-recently-used map: reading an entry renews it. */
function createLru(limit) {
const map = new Map();
return {
get(key) {
if (!map.has(key)) return undefined;
const value = map.get(key);
map.delete(key);
map.set(key, value);
return value;
},
set(key, value) {
map.delete(key);
map.set(key, value);
while (map.size > limit) map.delete(map.keys().next().value);
},
delete: (key) => map.delete(key),
};
}
/** The z15 tile holding a point. */
function pictureTileOf({ lon, lat }) {
const z = PICTURE_TILE_ZOOM;
@@ -143,29 +128,29 @@ function pictureTileOf({ lon, lat }) {
}
/**
* The z15 tiles whose pictures surround a point: its own, and the
* neighbours it sits within PICTURE_TILE_EDGE_M of (at most four).
* The z15 tiles whose pictures surround a point: its own first, and the
* neighbours it sits within PICTURE_TILE_EDGE_M of (at most nine).
*/
export function pictureTilesAround(point) {
const home = pictureTileOf(point);
const box = tileBounds(home.x, home.y, home.z);
const near = (lon, lat) =>
metresBetween(point, { lon, lat }) <= PICTURE_TILE_EDGE_M;
const dx = near(box.west, point.lat) ? -1 : near(box.east, point.lat) ? 1 : 0;
const dy = near(point.lon, box.north)
? -1
: near(point.lon, box.south)
? 1
: 0;
// Near both edges at once where tiles are narrow (high latitudes).
const dxs = [0];
if (near(box.west, point.lat)) dxs.push(-1);
if (near(box.east, point.lat)) dxs.push(1);
const dys = [0];
if (near(point.lon, box.north)) dys.push(-1);
if (near(point.lon, box.south)) dys.push(1);
const max = 2 ** home.z;
const tiles = [home];
const add = (x, y) => {
if (y < 0 || y >= max) return;
tiles.push({ x: (x + max) % max, y, z: home.z });
};
if (dx) add(home.x + dx, home.y);
if (dy) add(home.x, home.y + dy);
if (dx && dy) add(home.x + dx, home.y + dy);
const tiles = [];
for (const dy of dys)
for (const dx of dxs) {
const y = home.y + dy;
if (y < 0 || y >= max) continue;
tiles.push({ x: (home.x + dx + max) % max, y, z: home.z });
}
return tiles;
}
@@ -290,12 +275,36 @@ export function createPanoramaxProvider({
return (delta < 0 ? photo.prevId : photo.nextId) || null;
}
const coverage = createLineCoverage({
state,
fetchTile: async (tile, { signal }) =>
// Decoded tiles, kept a while: panning back, or across a zoom band and
// back, neither fetches nor decodes them again.
const coverageTiles = createTileCache({
size: COVERAGE_TILE_CACHE_SIZE,
load: (tile, { signal }) =>
readable(async () =>
decodeCoverageTile(await source.getTile(tile, { signal }), tile),
),
});
/** A z15 tile's pictures of one sequence, by `z/x/y:sequence`. */
const pictureLists = createLru(PICTURE_LIST_CACHE_SIZE);
function picturesIn(tile, sequenceId, signal) {
const key = `${tileKey(tile)}:${sequenceId}`;
const kept = pictureLists.get(key);
if (kept) return kept;
const list = readable(async () =>
decodePictures(
await source.getTile(tile, { signal, lane: 'pictures' }),
tile,
sequenceId,
),
);
pictureLists.set(key, list);
list.catch(() => pictureLists.delete(key));
return list;
}
const coverage = createLineCoverage({
state,
fetchTile: (tile, options) => coverageTiles.get(tile, options),
zoomFor: panoramaxZoomFor,
colors: COLORS,
pickPrefix: PICK_PREFIX.sequence,
@@ -312,21 +321,15 @@ export function createPanoramaxProvider({
const anchor = anchors.get(sequenceId);
if (!anchor) return [];
const tiles = pictureTilesAround(anchor);
// Known from the start: a picture opening while these load is
// judged against them (re-centred if outside, left alone inside).
drawn = { sequenceId, tiles: new Set(tiles.map(tileKey)) };
// 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,
),
),
),
tiles.map((tile) => picturesIn(tile, sequenceId, signal)),
);
signal?.throwIfAborted();
if (lists[0].status === 'rejected') throw lists[0].reason;
drawn = { sequenceId, tiles: new Set(tiles.map(tileKey)) };
const byId = new Map(
lists
.flatMap((list) => list.value ?? [])
@@ -445,13 +448,18 @@ export function createPanoramaxProvider({
const count = lines ? drawn : 0;
const loading = state.coverage.pending.size > 0;
const settled = !loading && !state.coverage.lastError;
// Lines in view that the filter hid all of: say so, not "none here".
const filteredOut =
lines &&
[...state.coverage.tiles.values()].some((tile) => tile.total > 0);
let fallback = '';
// Tiles that held something the filter hid all of.
const filteredOut = [...state.coverage.tiles.values()].some(
(tile) => tile.total > 0,
);
if (kind === 'overview')
fallback = drawn > 0 || !settled ? OVERVIEW_HINT : NO_COVERAGE_HINT;
fallback =
drawn > 0 || !settled
? OVERVIEW_HINT
: filteredOut
? FILTERED_HINT
: NO_COVERAGE_HINT;
else if (lines && settled && count === 0)
fallback = filteredOut ? FILTERED_HINT : NO_COVERAGE_HINT;
return {
@@ -49,6 +49,10 @@ export const COVERAGE_TUNING = Object.freeze({
// looking emptier than its suburbs. (KartaView draws as many.)
maxSequences: 14_400,
keepOrder: true,
// Points drawn at most, over every tile (each becomes ~8 ground-line
// vertices, ~1.4 kB of geometry): the simplified Paris z10 tile alone
// holds 57,000, unsimplified 750,000, which hung the tab.
maxPoints: 90_000,
lineWidthPx: 2.5,
overviewPointPx: 4,
moveDebounceMs: 320,
@@ -56,6 +60,12 @@ export const COVERAGE_TUNING = Object.freeze({
viewRangePerHeight: 10,
viewNearM: 1_000,
});
/**
* Lines are simplified (Douglas–Peucker) to this many tile units of a
* 4,096 extent: under a pixel at every height lines are drawn from, and
* 6–10% of the points GPS tracks carry.
*/
export const SIMPLIFY_TOLERANCE_UNITS = 4;
/**
* Grid cells per overview tile, at most: a z0 tile holds ~70,000 cells
* (1.9 MB), drawn as this many binned points.
@@ -69,9 +79,9 @@ export const NO_COVERAGE_HINT = 'no Panoramax photos in view';
export const FILTERED_HINT = 'no Panoramax photos here match the filters';
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';
export const GONE_MESSAGE = 'This Panoramax photo is no longer available';
/** From high up the dots are where pictures are, not sequences to click. */
export const OVERVIEW_HINT = 'zoom in to see Panoramax sequences';
export const OVERVIEW_HINT = 'zoom in to see Panoramax coverage';
/** Cones come from z15 tiles' `pictures` layer (~110 bytes a picture). */
export const PICTURE_TILE_ZOOM = 15;
@@ -84,11 +94,14 @@ export const API_TIMEOUT_MS = 12_000;
export const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
/**
* The meta-catalogue speaks HTTP/1.1 only, so the browser opens six
* connections to it: tiles take three, searches two, so a click's lookup
* never queues behind megabyte tiles (it timed out doing so).
* connections to it: coverage tiles take three, searches two and the
* cones' tiles one, so a click's lookup never queues behind megabyte tiles
* (it timed out doing so).
*/
export const API_CONCURRENCY = 2;
export const TILE_CONCURRENCY = 3;
/** The selected sequence's z15 tiles (cones) take the sixth. */
export const PICTURE_TILE_CONCURRENCY = 1;
/** Pictures asked for around a point; the API returns them nearest first. */
export const NEAREST_LIMIT = 10;
@@ -201,10 +214,18 @@ function creatorOf(item) {
.map((p) => p.name)
: []),
];
const name = names.find((value) => typeof value === 'string' && value.trim());
if (name && !looksLikeEmail(name)) return name.trim().slice(0, 80);
// Licences ask for the source to be named: the instance that holds it.
return instanceOf(item);
// Every producer is credited (an uploading app and its photographer),
// each name once, none that is an email address.
const credited = [];
for (const value of names) {
if (typeof value !== 'string') continue;
const name = value.trim().slice(0, 80);
if (!name || looksLikeEmail(value)) continue;
if (credited.some((other) => other.toLowerCase() === name.toLowerCase()))
continue;
credited.push(name);
}
return credited.length ? credited.join(', ').slice(0, 120) : null;
}
/** The federated instance holding a picture (its `via` link's host), or null. */
@@ -259,6 +280,9 @@ export function normalizeItem(item) {
isPano: isEquirectangular(item),
capturedAt: panoramaxTimeMs(props.datetime),
creator: creatorOf(item),
// Licences ask for the source to be named: the instance holding it is
// named beside an author, or alone where the author is hidden.
via: instanceOf(item),
license: licenseLabel(props.license),
width: Number.isFinite(width) && width > 0 ? width : null,
prevId: linkedId(item, 'prev'),
@@ -226,17 +226,34 @@ test('hostile item data: a malformed link escape, null coordinates, a huge produ
);
});
test('a picture whose producer is hidden is credited to the instance holding it', () => {
const item = stacItem({
id: 'a',
lon: 1,
lat: 2,
producer: '[email protected]',
});
test('the instance holding a picture is named (its via host), never as its author', () => {
const item = stacItem({ id: 'a', lon: 1, lat: 2, producer: '[email protected]' });
item.links.push({ rel: 'via', href: 'https://panoramax.ign.fr' });
assert.equal(normalizeItem(item).creator, 'panoramax.ign.fr');
assert.equal(normalizeItem(item).creator, null);
assert.equal(normalizeItem(item).via, 'panoramax.ign.fr');
item.links.at(-1).href = 'http://insecure.example';
assert.equal(normalizeItem(item).creator, null);
assert.equal(normalizeItem(item).via, null);
item.links.at(-1).href = 'not a url';
assert.equal(normalizeItem(item).creator, null);
assert.equal(normalizeItem(item).via, null);
});
test('every producer is credited once: the uploading app and its photographer', () => {
const item = stacItem({ id: 'a', lon: 1, lat: 2, producer: 'mapcomplete' });
item.providers = [
{ name: 'mapcomplete', roles: ['producer'] },
{ name: 'pyrog', roles: ['producer'] },
{ name: 'MapComplete', roles: ['producer'] },
{ name: '[email protected]', roles: ['producer'] },
{ name: 'host', roles: ['host'] },
];
assert.equal(normalizeItem(item).creator, 'mapcomplete, pyrog');
const hidden = stacItem({ id: 'b', lon: 1, lat: 2, producer: '[email protected]' });
hidden.providers = [{ name: '[email protected]', roles: ['producer'] }];
assert.equal(normalizeItem(hidden).creator, null);
const many = stacItem({ id: 'c', lon: 1, lat: 2 });
many.providers = Array.from({ length: 50 }, (_, i) => ({
name: `producer-${i}`,
roles: ['producer'],
}));
assert.ok(normalizeItem(many).creator.length <= 120);
});
@@ -535,22 +535,55 @@ test('the cones come in capture order, and a broken neighbour tile only thins th
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');
});
test('a broken neighbour z15 tile only thins the cones; an unreadable home tile reads as such', async (t) => {
// At a tile corner: the home tile and three neighbours are asked.
const home = tileCentre(ORIGIN.lon, ORIGIN.lat, 15);
const box = tileBounds(home.tile.x, home.tile.y, 15);
const at = { lon: box.west + 0.0003, lat: box.north - 0.0002 };
const items = [0, 1, 2].map((i) =>
stacItem({ id: `c-${i}`, sequenceId: 'c', lon: at.lon + i * 0.00005, lat: at.lat }),
);
const tiles = pictureTiles(items);
const broken = new TextEncoder().encode('<html>oops</html>');
const homeKey = `15/${home.tile.x}/${home.tile.y}`;
const env = await mounted({
items,
tiles: (tile) =>
`${tile.z}/${tile.x}/${tile.y}` === homeKey ? tiles(tile) : broken,
});
const cones = await withCones(t, env);
await env.instance.viewer.open('c-0');
await env.instance.selectSequence('c');
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/);
assert.equal(cones.count(), 3, 'the home tile’s cones draw');
assert.ok(env.source.calls.tiles.length > 1, 'neighbours were asked');
// Another sequence whose home tile is unreadable too.
const env2 = await mounted({ items, tiles: () => broken });
await withCones(t, env2);
await env2.instance.viewer.open('c-0');
await env2.instance.selectSequence('c');
assert.ok(await until(() => env2.actions.errors.some(Boolean)));
assert.match(env2.actions.errors.filter(Boolean).at(-1), /could not be read/);
});
test('cones re-centred into tiles already read do not read them again', async (t) => {
const items = sequence('long', 400);
const env = await mounted({ items, tiles: pictureTiles(items) });
await withCones(t, env);
await env.instance.viewer.open('long-0');
await env.instance.selectSequence('long');
assert.ok(await until(() => env.instance.sequenceStats().images > 0));
await env.instance.viewer.open('long-399');
assert.ok(await until(() => !env.instance.sequenceStats().loading));
const asked = env.source.calls.tiles.length;
// Back to the start: its tiles are kept.
await env.instance.viewer.open('long-0');
assert.ok(await until(() => !env.instance.sequenceStats().loading));
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(env.source.calls.tiles.length, asked);
assert.ok(env.instance.sequenceStats().images > 0);
});
test('› within the tiles the cones came from reloads nothing', async (t) => {
@@ -748,15 +781,18 @@ test('Esc in the docked viewer clears the selected sequence before the panel can
await env.instance.selectSequence('s');
assert.ok(await until(() => env.instance.sequenceStats().images === 3));
const root = env.next.parentNode;
const event = {
const flags = { stopped: false, prevented: false };
root.fire('keydown', {
key: 'Escape',
stopped: false,
stopPropagation() {
this.stopped = true;
},
};
root.fire('keydown', event);
stopPropagation: () => (flags.stopped = true),
preventDefault: () => (flags.prevented = true),
});
assert.equal(env.instance.sequenceStats().selectedId, null);
assert.deepEqual(
flags,
{ stopped: true, prevented: true },
'consumed: the panel never sees it',
);
// Nothing selected: Esc goes on to the panel.
const again = { key: 'Escape', defaultPrevented: false };
const result = { prevented: false };
@@ -768,3 +804,132 @@ test('Esc in the docked viewer clears the selected sequence before the panel can
});
assert.equal(result.prevented, false);
});
test('cones near the east and south edges read those neighbours; near both edges of a narrow tile, both sides', () => {
const home = tileCentre(ORIGIN.lon, ORIGIN.lat, 15);
const box = tileBounds(home.tile.x, home.tile.y, 15);
const { x, y } = home.tile;
const keys = (point) => pictureTilesAround(point).map((t) => `${t.x}/${t.y}`);
assert.deepEqual(keys({ lon: box.east - 0.0002, lat: box.south + 0.0002 }), [
`${x}/${y}`,
`${x + 1}/${y}`,
`${x}/${y + 1}`,
`${x + 1}/${y + 1}`,
]);
// Svalbard: a z15 tile is ~250 m wide, so its middle is near both sides.
const north = tileCentre(15.6, 78.22, 15);
const around = keys(north);
for (const dx of [-1, 1])
assert.ok(
around.includes(`${north.tile.x + dx}/${north.tile.y}`),
`${dx} in ${around}`,
);
assert.equal(around[0], `${north.tile.x}/${north.tile.y}`, 'its own first');
});
test('a search hit outside the radius (a bounding box’s corner) is not the nearest', async () => {
const corner = stacItem({ id: 'corner', sequenceId: 'c', ...east(65) });
const { instance, source } = setup({ items: [corner] });
source.nearestItems = async () => [corner];
assert.equal(await instance.nearestImage(east(0)), null);
});
test('a line clicked from low down still searches its own sequence within 50 m', async () => {
const items = [...sequence('other', 1, { offset: 2 }), ...sequence('clicked', 1, { offset: 30 })];
const { instance, source } = setup({ items });
instance.init({
camera: { positionCartographic: { height: 300 } },
scene: {
primitives: { add() {}, remove() {} },
preRender: { addEventListener: () => () => {} },
},
});
instance.handlePick(`${PICK_PREFIX.sequence}clicked`, {
groundPoint: () => east(0),
});
assert.equal(await instance.nearestImage(east(0)), 'clicked-0');
assert.equal(source.calls.nearest[0].radius, 50);
});
test('cones re-centre on a picture that opens while they are still loading', async (t) => {
const items = sequence('long', 400);
const tiles = pictureTiles(items);
let release = null;
const env = await mounted({ items, tiles });
await withCones(t, env);
await env.instance.viewer.open('long-0');
env.source.getTile = async (tile) => {
env.source.calls.tiles.push(`${tile.z}/${tile.x}/${tile.y}`);
if (release === null) await new Promise((go) => (release = go));
return tiles(tile);
};
const first = env.instance.selectSequence('long');
assert.ok(await until(() => release !== null), 'the first load is held');
// The far end opens while the cones around long-0 still load.
await env.instance.viewer.open('long-399');
release();
await first;
assert.ok(await until(() => !env.instance.sequenceStats().loading));
const [lon, lat] = items[399].geometry.coordinates;
const [far] = pictureTilesAround({ lon, lat });
assert.ok(
env.source.calls.tiles.includes(`15/${far.x}/${far.y}`),
`the far picture's tile was read: ${env.source.calls.tiles}`,
);
});
test('a filter that hides every density dot says so', async (t) => {
const env = coverageSetup(t, {
height: 200_000,
filter: { pano: 'pano', sinceMs: null },
grid: [{ lon: ORIGIN.lon, lat: ORIGIN.lat, pano: 0, flat: 5 }],
});
env.instance.activate(env.viewer);
assert.ok(await until(() => env.instance.coverageStats().hint === FILTERED_HINT));
});
test('dots are drawn at their own sizes, and the busiest is the biggest', async (t) => {
const env = coverageSetup(t, {
height: 200_000,
grid: [
{ lon: ORIGIN.lon, lat: ORIGIN.lat, pano: 0, flat: 1 },
{ lon: ORIGIN.lon + 3, lat: ORIGIN.lat, pano: 30_000, flat: 20_000 },
],
});
env.instance.activate(env.viewer);
const sizes = () => {
const out = [];
for (const primitive of env.viewer.scene.primitives.items)
for (let i = 0; i < (primitive.length ?? 0); i++)
if (primitive.get(i).pixelSize) out.push(primitive.get(i).pixelSize);
return out.sort((a, b) => a - b);
};
assert.ok(await until(() => sizes().length === 2));
assert.deepEqual(sizes(), [
overviewPointSize({ weight: 1 }),
overviewPointSize({ weight: 50_000 }),
]);
});
test('lines keep the decoded (longest-first) order under a budget of points', async () => {
const { COVERAGE_TUNING } = await import('./policy.js');
assert.equal(COVERAGE_TUNING.keepOrder, true);
assert.ok(COVERAGE_TUNING.maxPoints > 0);
});
test('a coverage tile decoded a moment ago is not fetched or decoded again', async (t) => {
const env = coverageSetup(t, { sequences: [line('a')] });
let asked = 0;
const getTile = env.source.getTile;
env.source.getTile = (tile, options) => {
asked++;
return getTile(tile, options);
};
env.instance.activate(env.viewer);
assert.ok(await until(() => env.instance.coverageStats().count === 1));
const first = asked;
env.instance.deactivate();
env.instance.activate(env.viewer);
assert.ok(await until(() => env.instance.coverageStats().count === 1));
assert.equal(asked, first);
});
@@ -1,4 +1,7 @@
import { createLimiter } from '../../limiter.js';
/** Longest wait a Retry-After is honoured for. */
const RETRY_AFTER_MAX_SEC = 300;
import {
API_CONCURRENCY,
API_TIMEOUT_MS,
@@ -8,6 +11,7 @@ import {
NEAREST_LIMIT,
NEAREST_RADIUS_M,
PANORAMAX_API,
PICTURE_TILE_CONCURRENCY,
TILE_CONCURRENCY,
TILE_TIMEOUT_MS,
} from './policy.js';
@@ -23,12 +27,15 @@ export class PanoramaxSourceError extends Error {
}
const NO_ANSWER = 'Panoramax did not answer in time — try again';
const UNREACHABLE = 'Panoramax could not be reached — check the connection';
/** Seconds from a Retry-After header, or null. */
function retryAfterSeconds(response) {
const header = response.headers?.get?.('retry-after');
const value = header == null || header === '' ? NaN : Number(header);
return Number.isFinite(value) && value >= 0 ? value : null;
if (!Number.isFinite(value) || value < 0) return null;
// A maintenance page's hour must not freeze coverage for an hour.
return Math.min(RETRY_AFTER_MAX_SEC, Math.max(1, value));
}
/** The body as bytes, refusing more than MAX_RESPONSE_BYTES whatever the headers say. */
@@ -79,6 +86,11 @@ export function createPanoramaxSource({
const root = api.replace(/\/+$/, '');
const apiLimiter = createLimiter(API_CONCURRENCY, { lifo: true });
const tileLimiter = createLimiter(TILE_CONCURRENCY);
// The cones' z15 tiles have their own connection: a selection never
// waits behind coverage tiles.
const pictureLimiter = createLimiter(PICTURE_TILE_CONCURRENCY, {
lifo: true,
});
/** GET within `timeoutMs`; the caller's abort wins over the timeout. */
async function get(url, { signal, timeoutMs, empty = [] }) {
@@ -108,7 +120,8 @@ export function createPanoramaxSource({
if (signal?.aborted) throw signal.reason;
if (controller.signal.aborted) throw controller.signal.reason;
if (error instanceof PanoramaxSourceError) throw error;
throw new PanoramaxSourceError(NO_ANSWER);
// Offline, or a CORS or TLS failure: not a timeout, not worth a retry.
throw new PanoramaxSourceError(UNREACHABLE);
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
@@ -146,9 +159,10 @@ export function createPanoramaxSource({
api: root,
/** Raw vector-tile bytes; an empty array where there is no coverage. */
async getTile({ x, y, z }, { signal } = {}) {
async getTile({ x, y, z }, { signal, lane = 'coverage' } = {}) {
const url = `${root}/map/${z}/${x}/${y}.mvt`;
const bytes = await tileLimiter.run(
const gate = lane === 'pictures' ? pictureLimiter : tileLimiter;
const bytes = await gate.run(
() =>
get(url, { signal, timeoutMs: TILE_TIMEOUT_MS, empty: [204, 404] }),
{ signal },
@@ -193,27 +207,51 @@ export function createPanoramaxSource({
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(
{
// Clamped to the map: the API finds nothing in a box past ±180°.
const box = [
Math.max(-180, lon - dLon),
Math.max(-90, lat - dLat),
Math.min(180, lon + dLon),
Math.min(90, lat + dLat),
].map((v) => v.toFixed(7));
// One search failing (busy, no answer) leaves the other's pictures;
// only both failing fails the lookup. The caller's abort stops both.
const both = new AbortController();
const onAbort = () => both.abort(signal.reason);
if (signal?.aborted) throw signal.reason;
signal?.addEventListener('abort', onAbort, { once: true });
const run = (params) =>
search(params, { signal: both.signal }).catch((error) => {
if (!both.signal.aborted && !signal?.aborted) failures.push(error);
throw error;
});
const failures = [];
let settled;
try {
settled = await Promise.allSettled([
run({
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 },
),
]);
}),
run({
bbox: box.join(','),
limit: String(NEAREST_BOX_LIMIT),
...shared,
}),
]);
} finally {
signal?.removeEventListener('abort', onAbort);
}
if (signal?.aborted) throw signal.reason;
const found = settled
.filter((result) => result.status === 'fulfilled')
.map((result) => result.value);
if (!found.length) throw failures[0] ?? settled[0].reason;
const byId = new Map();
for (const item of [...facing, ...around])
for (const item of found.flat())
if (item?.id != null && !byId.has(String(item.id)))
byId.set(String(item.id), item);
return [...byId.values()];
@@ -217,7 +217,7 @@ test('the caller’s abort wins; a silent API times out with a readable message'
await assert.rejects(slow, /did not answer in time/);
});
test('a network failure reads as no answer, not as a stack trace', async () => {
test('a network failure reads as unreachable, not as a stack trace', async () => {
const source = createPanoramaxSource({
fetchImpl: async () => {
throw new TypeError('Failed to fetch');
@@ -225,7 +225,7 @@ test('a network failure reads as no answer, not as a stack trace', async () => {
});
await assert.rejects(source.getTile({ x: 0, y: 0, z: 0 }), (error) => {
assert.ok(error instanceof PanoramaxSourceError);
assert.match(error.message, /did not answer/);
assert.match(error.message, /could not be reached/);
return true;
});
});
@@ -293,3 +293,68 @@ test('the HTTP/1.1 API never gets more than five requests at once: three tiles,
}
assert.equal(most, 5, 'never more');
});
test('one of the two nearest searches failing leaves the other’s pictures; both failing fails the lookup', async () => {
const half = createPanoramaxSource({
fetchImpl: fakeFetch((url) =>
url.includes('bbox')
? { status: 503 }
: json({ features: [{ id: 'facing' }] }),
).fetchImpl,
});
assert.deepEqual(
(await half.nearestItems({ lat: 1, lon: 2 })).map((i) => i.id),
['facing'],
);
const none = createPanoramaxSource({
fetchImpl: fakeFetch(() => ({ status: 503 })).fetchImpl,
});
await assert.rejects(none.nearestItems({ lat: 1, lon: 2 }), /busy/);
});
test('only a timeout is asked again: a busy or failing API is asked once, an unreachable one says so', async () => {
for (const status of [429, 500, 503]) {
const { urls, fetchImpl } = fakeFetch(() => ({ status }));
const source = createPanoramaxSource({ fetchImpl });
await assert.rejects(source.getItem('a'));
assert.equal(urls.length, 1, `HTTP ${status}: one fetch`);
}
let calls = 0;
const offline = createPanoramaxSource({
fetchImpl: async () => {
calls++;
throw new TypeError('Failed to fetch');
},
});
await assert.rejects(offline.getItem('a'), /could not be reached/);
assert.equal(calls, 1, 'not retried');
});
test('a Retry-After is honoured up to five minutes', async () => {
const wait = async (header) => {
const source = createPanoramaxSource({
fetchImpl: fakeFetch(() => ({
status: 503,
headers: { 'retry-after': header },
})).fetchImpl,
});
return source.getTile({ x: 0, y: 0, z: 0 }).catch((error) => error.retryAfterSec);
};
assert.equal(await wait('3600'), 300);
assert.equal(await wait('99999999999'), 300);
assert.equal(await wait('0'), 1);
assert.equal(await wait('12'), 12);
});
test('a lookup near the antimeridian keeps its box on the map', async () => {
const { urls, fetchImpl } = fakeFetch(() => json({ features: [] }));
const source = createPanoramaxSource({ fetchImpl });
await source.nearestItems({ lat: -16, lon: 179.9999 });
const box = urls
.map((u) => new URL(u).searchParams.get('bbox'))
.find(Boolean)
.split(',')
.map(Number);
assert.equal(box[2], 180);
assert.ok(box[0] < 180);
});
@@ -36,9 +36,11 @@ export const PANORAMAX_SOURCES = Object.freeze({
const width = Math.min(photo.width, PANO_FULL_SIZE[0]);
return { url: photo.urls.hd, width, resize: [width, width / 2] };
},
panoSharp: (photo, size) => {
// The original is several megabytes with no server to shrink it: only
// for the expanded viewer (a docked one on a dense screen asks too).
panoSharp: (photo, size, { expanded = false } = {}) => {
const width = photo.width || 0;
if (!photo.urls.hd || !photo.urls.sd) return null;
if (!expanded || !photo.urls.hd || !photo.urls.sd) return null;
// An original no wider than the SD one adds nothing; a huge one would
// take hundreds of megabytes to decode.
if (width <= SD_WIDTH || width > PANO_HD_MAX_WIDTH) return null;
@@ -59,7 +61,8 @@ export const PANORAMAX_SOURCES = Object.freeze({
flatSharp: (photo, pixels) =>
photo.urls.sd && photo.urls.hd && pixels > SD_WIDTH ? photo.urls.hd : null,
describe: (photo) => ({
title: null,
// Where no author can be named, the instance holding the photo is.
title: !photo.creator && photo.via ? `via ${photo.via}` : null,
creator: photo.creator,
license: photo.license,
externalUrl: panoramaxPictureUrl(photo.id),
@@ -150,7 +150,15 @@ test('a 360° picture shows its SD equirectangular image, then HD shrunk to 4,09
assert.equal(poses.at(-1).bearing, 100);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(loads.length, 1, 'docked: the 2,048 px SD is enough');
// Docked on a dense screen the canvas wants more, but the original is
// megabytes: not until the viewer is expanded.
canvas.clientWidth = 1800;
viewer.resize();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(loads.length, 1, 'docked: still SD only');
const root = canvas.parentNode;
root.closest = (selector) =>
selector.includes('sl-viewer-wrap-expanded') ? {} : null;
// EXPAND resizes more than once while the panel settles.
viewer.resize();
viewer.resize();
@@ -165,14 +173,19 @@ test('a 360° picture shows its SD equirectangular image, then HD shrunk to 4,09
assert.equal(hd.url, PHOTOS.pano.urls.hd);
assert.deepEqual(hd.resize, [4096, 2048]);
assert.equal(hd.timeoutMs, 30_000);
assert.ok(hd.signal, 'abortable');
});
test('HD panoramas too big to decode, or no bigger than SD, are never fetched', () => {
test('HD panoramas too big to decode, or no bigger than SD, are never fetched; nor while docked', () => {
const size = [4096, 2048];
assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.bigPano, size), null);
assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.smallPano, size), null);
const expanded = { expanded: true };
assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.pano, size, { expanded: false }), null);
assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.pano, size), null);
assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.bigPano, size, expanded), null);
assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.smallPano, size, expanded), null);
assert.deepEqual(
PANORAMAX_SOURCES.panoSharp({ ...PHOTOS.pano, width: 3000 }, size).resize,
PANORAMAX_SOURCES.panoSharp({ ...PHOTOS.pano, width: 3000 }, size, expanded)
.resize,
[3000, 1500],
'never enlarged',
);
@@ -190,7 +203,7 @@ test('HD panoramas too big to decode, or no bigger than SD, are never fetched',
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, expanded), null);
assert.equal(PANORAMAX_SOURCES.flatSharp(PHOTOS.flat, 2048), null);
assert.equal(
PANORAMAX_SOURCES.flatSharp(PHOTOS.flat, 2049),
@@ -258,3 +271,18 @@ test('a picture whose SD fails falls back to HD, then the thumbnail', async () =
]);
assert.equal(parts(host).picture.src, PHOTOS.flat.urls.thumb);
});
test('a photo with no author to name is captioned with the instance holding it', () => {
const photo = normalizeItem(
stacItem({ id: 'x', lon: 1, lat: 2, producer: '[email protected]' }),
);
photo.via = 'panoramax.ign.fr';
assert.deepEqual(PANORAMAX_SOURCES.describe(photo), {
title: 'via panoramax.ign.fr',
creator: null,
license: 'CC BY-SA 4.0',
externalUrl: 'https://api.panoramax.xyz/#pic=x&focus=pic',
});
const named = { ...photo, creator: 'Robot8A' };
assert.equal(PANORAMAX_SOURCES.describe(named).title, null);
});
+48
View File
@@ -0,0 +1,48 @@
/** Decoded coverage tiles kept, and for how long. */
export const TILE_CACHE_SIZE = 24;
export const TILE_CACHE_TTL_MS = 10 * 60_000;
/** Least-recently-used map: reading an entry renews it. */
export function createLru(limit) {
const map = new Map();
return {
get(key) {
if (!map.has(key)) return undefined;
const value = map.get(key);
map.delete(key);
map.set(key, value);
return value;
},
set(key, value) {
map.delete(key);
map.set(key, value);
while (map.size > limit) map.delete(map.keys().next().value);
},
delete: (key) => map.delete(key),
clear: () => map.clear(),
};
}
/**
* Loaded (decoded) coverage tiles by `z/x/y`, at most `size`, each for
* `ttlMs`: a pan back, or a zoom across bands and back, neither fetches nor
* decodes them again. A load that fails is not kept.
*/
export function createTileCache({
load,
size = TILE_CACHE_SIZE,
ttlMs = TILE_CACHE_TTL_MS,
now = Date.now,
}) {
const kept = createLru(size);
return {
async get(tile, options) {
const key = `${tile.z}/${tile.x}/${tile.y}`;
const entry = kept.get(key);
if (entry && now() - entry.at < ttlMs) return entry.tile;
const loaded = await load(tile, options);
kept.set(key, { at: now(), tile: loaded });
return loaded;
},
};
}
@@ -673,3 +673,15 @@ test('an adapter that can frame but says this image has no frame offers no FIT /
await host.open('mapillary', 'flat-1');
assert.equal(state.street.renderModes, true, 'a flat image can be framed');
});
test('a pose’s licence reaches the caption state, and a closed viewer forgets it', async () => {
const { state, host, google } = twoProviders();
await host.open('google', 'g-1');
google.emitPose('g-1', { providerId: 'google', license: 'CC BY-SA 4.0' });
assert.equal(state.street.license, 'CC BY-SA 4.0');
google.emitPose('g-1', { providerId: 'google' });
assert.equal(state.street.license, null, 'none given: none shown');
google.emitPose('g-1', { providerId: 'google', license: 'CC0' });
host.close();
assert.equal(state.street.license, null);
});
+12
View File
@@ -810,6 +810,18 @@ test('Panoramax fixtures: two sequences on one street, searches honour sequence
true,
'the second sequence is older',
);
// A box has every picture in it, those looking away included; a place
// search leaves out pictures the point is behind (all look east).
const d = 0.0002;
const boxed = ask(
`bbox=${at.lon - d},${at.lat - d},${at.lon + d},${at.lat + d}&limit=100`,
);
assert.ok(boxed.some((p) => p.lon < at.lon) && boxed.some((p) => p.lon > at.lon));
const placed = ask(
`place_position=${at.lon},${at.lat}&place_distance=0-30&limit=50`,
);
assert.ok(placed.length > 0);
assert.ok(placed.every((p) => p.lon <= at.lon + 1e-7), 'none it is behind');
const one = ask(`ids=${at.id}&limit=1`);
assert.equal(one[0].id, at.id);
// Its pictures, and only those, are served; HD downloads are counted.
+19
View File
@@ -539,3 +539,22 @@ test('KEY REQUIRED points at Street View when it would draw with the Google key'
assert.equal(plain.status.text, 'KEY REQUIRED');
assert.notEqual(plain.meta, guidance);
});
test('the caption names an image’s own licence after its author, and leaves it out when there is none', () => {
const caption = (street) =>
presentStreetLevelPanel({
enabled: true,
providers: [],
coverage: {},
legend: [],
filter: {},
sequence: {},
street: { open: true, imageId: '1', ...street },
}).viewer.captionLeft;
assert.equal(
caption({ creator: 'Fixture Mapper', license: 'CC BY-SA 4.0' }),
'Image by Fixture Mapper · CC BY-SA 4.0',
);
assert.equal(caption({ license: 'Licence Ouverte 2.0' }), 'Licence Ouverte 2.0');
assert.equal(caption({ creator: 'Someone' }), 'Image by Someone');
});
+3 -2
View File
@@ -651,7 +651,7 @@ button.sl-status:focus-visible {
pointer-events: none;
}
/* White on #ff6f00 is 2.9:1; the darker orange behind ‹ › is 4.6:1. */
/* White on #ff6f00 is 2.8:1; on the darker orange behind ‹ › it is 5.1:1. */
.sl-photo-viewer[data-provider='panoramax'] {
--sl-photo-ring: var(--sl-panoramax);
--sl-photo-fill: var(--sl-panoramax-fill);
@@ -720,7 +720,8 @@ button.sl-status:focus-visible {
}
/* Nothing to step through until a photo shows. */
.sl-photo-viewer:not([data-kind='flat']):not([data-kind='pano']) .sl-photo-step {
.sl-photo-viewer:not([data-kind='flat']):not([data-kind='pano'])
.sl-photo-step {
display: none;
}