mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
bce3044e0c
commit
5526666894
14 files changed
+317
-45
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user