fix(street-level): Panoramax UX QA — connection budget, coverage to 60 km, Esc, attribution

From a UX QA round in the browser against the live API:
- A line click could time out while coverage loaded: the meta-catalogue
  speaks HTTP/1.1 only (six connections), and six tiles plus four
  searches queued the lookup behind megabyte tiles. Tiles now take
  three connections and searches two; a search that timed out is asked
  once more.
- Between 30 and ~100 km Paris read as empty: the density grid is ~10 km
  a cell. Lines now draw to 60 km (z10), as Mapillary's do; above, dots
  grow with the pictures they stand for.
- Esc in the docked viewer collapsed the whole panel instead of clearing
  the sequence the panel says it clears (shared photo viewer: KartaView
  too). The viewer now offers Esc to the provider first; expanded, the
  dialog keeps it.
- A picture whose producer is an email address is credited to the
  instance holding it (its `via` host), so Licence Ouverte pictures name
  their source.
- Empty states say why: "no Panoramax photos here match the filters"
  when the filter hid every line, and nothing to zoom into over empty
  sea. Wording follows KartaView's ("photos", "click a line for its
  nearest photo").

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-14 14:37:45 +01:00
1 parent bce3044e0c
commit 5526666894
14 files changed
+317 -45

No files matched your search

+3 -1
View File
@@ -95,6 +95,7 @@ export function meshBoxInRange(box, centre) {
* @param {LineCoverageTuning} options.tuning
* @param {{keyRejected?: string, rateLimited: string, tooHigh?: string}} options.messages
* @param {(sequence: object, filter: object) => boolean} [options.passes] Whether the imagery filter keeps a line or point.
* @param {(point: object) => number} [options.overviewPointSize] An overview point's size in pixels (default `tuning.overviewPointPx`).
*/
export function createLineCoverage({
state,
@@ -106,6 +107,7 @@ export function createLineCoverage({
tuning,
messages,
passes = passesImageryFilter,
overviewPointSize = null,
}) {
const { render } = state.services;
const perTileSequenceCap = Math.floor(tuning.maxSequences / tuning.maxTiles);
@@ -427,7 +429,7 @@ export function createLineCoverage({
collection.add({
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat),
color: tint,
pixelSize: tuning.overviewPointPx,
pixelSize: overviewPointSize?.(point) ?? tuning.overviewPointPx,
// Google 3D terrain and clouds must not hide the near side's dots;
// the horizon cull hides the far side's, which this lets through.
disableDepthTestDistance: Number.POSITIVE_INFINITY,
+14
View File
@@ -117,6 +117,7 @@ export function panoSizeFor(canvasPixels, fovDeg) {
* @param {(photo: object, delta: -1|1) => Promise<string|null>} options.step The photo `delta` along the sequence, or null at its end.
* @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show).
* @param {(photo: object) => void} [options.onShown] A photo is on screen.
* @param {() => boolean} [options.onEscape] Esc in the docked viewer: true when it was used (e.g. a selection cleared).
* @param {object} [options.render] Render governor.
* @param {(url: string, options?: object) => Promise<HTMLImageElement|ImageBitmap>} [options.load]
* @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer]
@@ -130,6 +131,7 @@ export function createPhotoViewer({
step,
onStep,
onShown = () => {},
onEscape = () => false,
render,
load = loadImage,
createRenderer = createPanoramaRenderer,
@@ -417,6 +419,18 @@ 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.
@@ -132,3 +132,35 @@ test('a sharper flat version that failed is tried again on the next resize', asy
env.release('f-sharp');
assert.ok(await until(() => picture.src === 'f-sharp'));
});
test('Esc: the docked viewer offers it to the provider first; expanded, it is the dialog’s', 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();
await viewer.mount(host);
const root = host.children[0];
const press = () => {
const event = { key: 'Escape', prevented: false, stopped: false };
event.preventDefault = () => (event.prevented = true);
event.stopPropagation = () => (event.stopped = true);
root.fire('keydown', event);
return event;
};
const docked = press();
assert.equal(asked, 1);
assert.equal(docked.prevented && docked.stopped, true);
root.closest = (selector) =>
selector.includes('sl-viewer-wrap-expanded') ? {} : null;
const expanded = press();
assert.equal(asked, 1, 'not asked while expanded');
assert.equal(expanded.prevented, false);
});
@@ -406,6 +406,13 @@ export function createKartaViewProvider({
step,
onStep: openStepped,
onShown: recentreCones,
// Esc in the viewer clears the selected drive, as the panel says.
onEscape: () => {
if (!state.sequence.selectedId) return false;
cones.clearSelection();
drawnPage = null;
return true;
},
render: context.services?.render,
...viewerOptions,
});
@@ -15,6 +15,7 @@ import {
COLORS,
COVERAGE_LINES_BELOW_M,
COVERAGE_TUNING,
COVERAGE_Z11_BELOW_M,
COVERAGE_Z12_BELOW_M,
COVERAGE_Z13_BELOW_M,
COVERAGE_Z14_BELOW_M,
@@ -24,6 +25,7 @@ import {
LINE_CLICK_RADIUS_PER_HEIGHT,
NEAREST_LIMIT,
NEAREST_RADIUS_M,
FILTERED_HINT,
NO_COVERAGE_HINT,
OVERVIEW_HINT,
PANORAMAX_CREDIT_HTML,
@@ -55,13 +57,22 @@ async function readable(decode) {
}
}
/**
* A density dot's size: busier places draw bigger (2.5 px for one picture,
* 8 px from a few hundred thousand), so a city does not read like farmland.
*/
export function overviewPointSize(point) {
const weight = Math.max(1, Number(point?.weight) || 1);
return Math.min(8, 2.5 + Math.log10(weight));
}
/** 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
* Sequence lines at z10–14 below 60 km; above, the density grid at z5 and
* coarser.
*/
export function panoramaxZoomFor(heightM) {
@@ -74,7 +85,9 @@ export function panoramaxZoomFor(heightM) {
? 13
: heightM <= COVERAGE_Z12_BELOW_M
? 12
: 11;
: heightM <= COVERAGE_Z11_BELOW_M
? 11
: 10;
return { zoom, kind: 'sequence' };
}
return {
@@ -290,6 +303,7 @@ export function createPanoramaxProvider({
tuning: COVERAGE_TUNING,
messages: { rateLimited: RATE_LIMITED_MESSAGE },
passes: passesCoverage,
overviewPointSize,
});
const cones = createSequenceCones({
@@ -352,6 +366,13 @@ export function createPanoramaxProvider({
onStep: (photoId) =>
context.actions.openImage(photoId, { frame: false }),
onShown: recentreCones,
// Esc in the viewer clears the selected sequence, as the panel says.
onEscape: () => {
if (!state.sequence.selectedId) return false;
cones.clearSelection();
drawn = null;
return true;
},
render: context.services?.render,
...viewerOptions,
});
@@ -417,17 +438,22 @@ export function createPanoramaxProvider({
},
coverageStats() {
const lines = state.coverage.kind === 'sequence';
const { kind } = state.coverage;
const lines = kind === 'sequence';
const drawn = coverage.sequenceCount();
// Density dots are not sequences: they are not counted as such.
const count = lines ? coverage.sequenceCount() : 0;
const count = lines ? drawn : 0;
const loading = state.coverage.pending.size > 0;
const empty =
lines && !loading && count === 0 && !state.coverage.lastError;
const fallback = empty
? NO_COVERAGE_HINT
: state.coverage.kind === 'overview'
? OVERVIEW_HINT
: '';
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 = '';
if (kind === 'overview')
fallback = drawn > 0 || !settled ? OVERVIEW_HINT : NO_COVERAGE_HINT;
else if (lines && settled && count === 0)
fallback = filteredOut ? FILTERED_HINT : NO_COVERAGE_HINT;
return {
count,
zoom: state.coverage.zoom,
@@ -29,15 +29,17 @@ export const COLORS = Object.freeze({
});
/**
* Sequence lines come from z7–15 tiles; z11–14 are drawn (a dense z11 tile
* over Paris is ~1.4 MB and 3,800 sequences, z12 ~800 kB). Higher up, the
* z0–5 `grid` layer's density cells (0.1°, ~10 km) draw as points: below
* 30 km they would be a few dots over a whole city.
* Sequence lines come from z7–15 tiles; z10–14 are drawn below 60 km, as
* Mapillary's are (a dense tile over Paris: z10 ~2.1 MB and 8,600
* sequences, z11 ~1.4 MB, z12 ~800 kB). Higher up, the z0–5 `grid` layer's
* density cells (0.1°, ~10 km) draw as points: lower down they would be a
* handful of dots over a whole city, which reads as no coverage.
*/
export const COVERAGE_Z14_BELOW_M = 1_800;
export const COVERAGE_Z13_BELOW_M = 5_000;
export const COVERAGE_Z12_BELOW_M = 12_000;
export const COVERAGE_LINES_BELOW_M = 30_000;
export const COVERAGE_Z11_BELOW_M = 30_000;
export const COVERAGE_LINES_BELOW_M = 60_000;
export const COVERAGE_TUNING = Object.freeze({
maxTiles: 9,
overviewMaxTiles: 6,
@@ -62,12 +64,14 @@ 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 NO_COVERAGE_HINT = 'no Panoramax photos in view';
/** Lines in view, all hidden by the imagery filter. */
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';
/** From high up the dots are where pictures are, not sequences to click. */
export const OVERVIEW_HINT = 'zoom in to click a Panoramax sequence';
export const OVERVIEW_HINT = 'zoom in to see Panoramax sequences';
/** Cones come from z15 tiles' `pictures` layer (~110 bytes a picture). */
export const PICTURE_TILE_ZOOM = 15;
@@ -78,9 +82,13 @@ export const TILE_TIMEOUT_MS = 20_000;
export const API_TIMEOUT_MS = 12_000;
/** Real tiles are under 6 MB (z6 over France); refuse anything absurd. */
export const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
/** Requests at once to the API (tiles have their own budget). */
export const API_CONCURRENCY = 4;
export const TILE_CONCURRENCY = 6;
/**
* 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).
*/
export const API_CONCURRENCY = 2;
export const TILE_CONCURRENCY = 3;
/** Pictures asked for around a point; the API returns them nearest first. */
export const NEAREST_LIMIT = 10;
@@ -110,7 +118,7 @@ export const SD_WIDTH = 2_048;
export const PANORAMAX_CREDIT_HTML =
'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture';
export const LINE_CLICK_HINT = 'click a line for the photo there';
export const LINE_CLICK_HINT = 'click a line for its nearest photo';
/** Licence ids Panoramax uses, as the caption names them. */
const LICENSE_LABELS = Object.freeze({
@@ -194,8 +202,20 @@ function creatorOf(item) {
: []),
];
const name = names.find((value) => typeof value === 'string' && value.trim());
if (!name || looksLikeEmail(name)) return null;
return name.trim().slice(0, 80);
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);
}
/** The federated instance holding a picture (its `via` link's host), or null. */
function instanceOf(item) {
const href = item?.links?.find?.((link) => link?.rel === 'via')?.href;
try {
const url = new URL(String(href));
return url.protocol === 'https:' ? url.hostname.slice(0, 80) : null;
} catch {
return null;
}
}
/** A 360° picture: a full-sphere field of view or an equirectangular projection. */
@@ -225,3 +225,18 @@ test('hostile item data: a malformed link escape, null coordinates, a huge produ
'an @ without a domain is a name',
);
});
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]',
});
item.links.push({ rel: 'via', href: 'https://panoramax.ign.fr' });
assert.equal(normalizeItem(item).creator, 'panoramax.ign.fr');
item.links.at(-1).href = 'http://insecure.example';
assert.equal(normalizeItem(item).creator, null);
item.links.at(-1).href = 'not a url';
assert.equal(normalizeItem(item).creator, null);
});
@@ -2,11 +2,17 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
createPanoramaxProvider,
overviewPointSize,
panoramaxZoomFor,
passesCoverage,
pictureTilesAround,
} from './index.js';
import { NO_COVERAGE_HINT, OVERVIEW_HINT, PICK_PREFIX } from './policy.js';
import {
FILTERED_HINT,
NO_COVERAGE_HINT,
OVERVIEW_HINT,
PICK_PREFIX,
} from './policy.js';
import { validateProviders } from '../../registry.js';
import { tileBounds } from '../../tileMath.js';
import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs';
@@ -141,19 +147,21 @@ test('Panoramax registers keyless and off by default, with both filters and its
assert.match(definition.credit.html, /Panoramax<\/a> contributors/);
assert.equal(
definition.coverageLabel.action,
'click a line for the photo there',
'click a line for its nearest photo',
);
assert.doesNotThrow(() => validateProviders([definition]));
});
test('lines at z11–14 below 30 km; density points from z5 down to z0 above', () => {
test('lines at z10–14 below 60 km, as Mapillary’s; density points from z5 down to z0 above', () => {
assert.deepEqual(panoramaxZoomFor(500), { zoom: 14, kind: 'sequence' });
assert.deepEqual(panoramaxZoomFor(1_800), { zoom: 14, kind: 'sequence' });
assert.deepEqual(panoramaxZoomFor(4_000), { zoom: 13, kind: 'sequence' });
assert.deepEqual(panoramaxZoomFor(12_000), { zoom: 12, kind: 'sequence' });
assert.deepEqual(panoramaxZoomFor(25_000), { zoom: 11, kind: 'sequence' });
assert.deepEqual(panoramaxZoomFor(30_000), { zoom: 11, kind: 'sequence' });
assert.deepEqual(panoramaxZoomFor(30_001), { zoom: 5, kind: 'overview' });
assert.deepEqual(panoramaxZoomFor(30_001), { zoom: 10, kind: 'sequence' });
assert.deepEqual(panoramaxZoomFor(60_000), { zoom: 10, kind: 'sequence' });
assert.deepEqual(panoramaxZoomFor(60_001), { zoom: 5, kind: 'overview' });
assert.deepEqual(panoramaxZoomFor(100_000), { zoom: 5, kind: 'overview' });
assert.deepEqual(panoramaxZoomFor(2_000_000), { zoom: 3, kind: 'overview' });
assert.deepEqual(panoramaxZoomFor(20_000_000), { zoom: 0, kind: 'overview' });
@@ -690,3 +698,73 @@ test('switching off clears the cones and forgets a pending line click', async (t
await env.instance.nearestImage(east(0));
assert.equal(env.source.calls.nearest.at(-1).collections, null);
});
test('lines in view that the filter hides all of say so, not that there is nothing here', async (t) => {
const env = coverageSetup(t, {
filter: { pano: 'pano', sinceMs: null },
sequences: [line('flat-only')],
});
env.instance.activate(env.viewer);
assert.ok(
await until(
() =>
!env.instance.coverageStats().loading &&
env.instance.coverageStats().zoom !== null,
),
);
assert.ok(
await until(() => env.instance.coverageStats().hint === FILTERED_HINT),
);
assert.equal(env.instance.coverageStats().count, 0);
});
test('from high up over empty sea the panel says there is nothing, not to zoom in', async (t) => {
const env = coverageSetup(t, { height: 200_000, grid: [] });
env.instance.activate(env.viewer);
assert.ok(
await until(() => env.instance.coverageStats().kind === 'overview'),
);
assert.ok(
await until(() => env.instance.coverageStats().hint === NO_COVERAGE_HINT),
);
});
test('density dots grow with the pictures they stand for', () => {
assert.equal(overviewPointSize({ weight: 1 }), 2.5);
assert.equal(overviewPointSize({ weight: 100 }), 4.5);
assert.equal(overviewPointSize({ weight: 1e9 }), 8);
assert.equal(overviewPointSize({}), 2.5);
assert.ok(
overviewPointSize({ weight: 50_000 }) >
overviewPointSize({ weight: 50 }) + 2,
);
});
test('Esc in the docked viewer clears the selected sequence before the panel can collapse', async (t) => {
const items = sequence('s', 3);
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 === 3));
const root = env.next.parentNode;
const event = {
key: 'Escape',
stopped: false,
stopPropagation() {
this.stopped = true;
},
};
root.fire('keydown', event);
assert.equal(env.instance.sequenceStats().selectedId, null);
// Nothing selected: Esc goes on to the panel.
const again = { key: 'Escape', defaultPrevented: false };
const result = { prevented: false };
root.fire('keydown', {
...again,
preventDefault() {
result.prevented = true;
},
});
assert.equal(result.prevented, false);
});
@@ -115,13 +115,24 @@ export function createPanoramaxSource({
}
}
/** A STAC search, through the API gate (newest request first). */
/**
* A STAC search, through the API gate (newest request first). One that
* timed out is asked once more: a search is quick once the connection
* is free.
*/
async function search(params, { signal } = {}) {
const url = `${root}/search?${new URLSearchParams(params)}`;
const bytes = await apiLimiter.run(
() => get(url, { signal, timeoutMs: API_TIMEOUT_MS }),
{ signal },
);
const once = () =>
apiLimiter.run(() => get(url, { signal, timeoutMs: API_TIMEOUT_MS }), {
signal,
});
let bytes;
try {
bytes = await once();
} catch (error) {
if (signal?.aborted || error?.message !== NO_ANSWER) throw error;
bytes = await once();
}
let payload;
try {
payload = JSON.parse(new TextDecoder().decode(bytes));
@@ -173,14 +173,16 @@ test('the newest search goes first when the API gate is full', async () => {
});
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');
assert.deepEqual(order, ['1', '2'], 'two 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);
let done = false;
Promise.allSettled(all).then(() => (done = true));
while (!done) {
waiting.splice(0).forEach((go) => go());
await new Promise((resolve) => setTimeout(resolve, 0));
}
});
test('an unreadable search answer, or one with no features, is handled', async () => {
@@ -227,3 +229,67 @@ test('a network failure reads as no answer, not as a stack trace', async () => {
return true;
});
});
test('a search that timed out is asked once more; a second timeout is reported', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const hang = (signal) =>
new Promise((_, reject) =>
signal.addEventListener('abort', () => reject(signal.reason)),
);
const source = createPanoramaxSource({
fetchImpl: async (url, { signal }) => {
calls++;
if (calls === 1) return hang(signal);
return new Response(JSON.stringify({ features: [{ id: 'a' }] }));
},
});
const asked = source.getItem('a');
for (let i = 0; i < 5; i++) await Promise.resolve();
t.mock.timers.tick(60_000);
assert.deepEqual(await asked, { id: 'a' });
assert.equal(calls, 2);
const stuck = createPanoramaxSource({
fetchImpl: async (url, { signal }) => hang(signal),
});
const never = stuck.getItem('b');
for (let round = 0; round < 2; round++) {
for (let i = 0; i < 10; i++) await Promise.resolve();
t.mock.timers.tick(60_000);
}
await assert.rejects(never, /did not answer in time/);
});
test('the HTTP/1.1 API never gets more than five requests at once: three tiles, two searches', async () => {
const { API_CONCURRENCY, TILE_CONCURRENCY } = await import('./policy.js');
assert.ok(API_CONCURRENCY + TILE_CONCURRENCY <= 5);
let open = 0;
let most = 0;
const waiting = [];
const source = createPanoramaxSource({
fetchImpl: async (url) => {
open++;
most = Math.max(most, open);
await new Promise((resolve) => waiting.push(resolve));
open--;
return url.includes('/map/')
? new Response(null, { status: 204 })
: new Response(JSON.stringify({ features: [] }));
},
});
const all = [
...Array.from({ length: 8 }, (_, i) =>
source.getTile({ x: i, y: 0, z: 14 }),
),
...Array.from({ length: 4 }, (_, i) => source.getItem(String(i))),
];
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(most, 5);
let done = false;
Promise.allSettled(all).then(() => (done = true));
while (!done) {
waiting.splice(0).forEach((go) => go());
await new Promise((resolve) => setTimeout(resolve, 0));
}
assert.equal(most, 5, 'never more');
});