mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +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
+1
-1
@@ -97,7 +97,7 @@
|
||||
|
||||
- Street Level: Panoramax is the fourth provider, keyless. Light its chip
|
||||
(orange; off by default, one provider at a time): from orbit dots show
|
||||
where the federated commons has pictures, and below about 30 km its
|
||||
where the federated commons has pictures, and below about 60 km its
|
||||
sequences draw as lines from `api.panoramax.xyz` vector tiles. 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 and ‹ › /
|
||||
|
||||
@@ -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 30 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 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.
|
||||
|
||||
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`.
|
||||
|
||||
|
||||
@@ -3254,9 +3254,10 @@ each picture is served by the instance holding it (OpenStreetMap France,
|
||||
IGN, MapComplete…; IGN's redirect to its OVH object storage). Its chip
|
||||
(orange, `#ff6f00`, Panoramax's own) starts off. Coverage comes from its
|
||||
vector tiles (`/api/map/{z}/{x}/{y}.mvt`, an hour's HTTP cache): sequence
|
||||
lines from z14 below 1.8 km above ground, z13 to 5 km, z12 to 12 km and z11
|
||||
to 30 km (a dense z11 tile over Paris is ~1.4 MB and 3,800 sequences; each
|
||||
tile draws its 1,600 longest), and above 30 km the `grid` layer's 0.1°
|
||||
lines from z14 below 1.8 km above ground, z13 to 5 km, z12 to 12 km, z11
|
||||
to 30 km and z10 to 60 km, as Mapillary's (a dense z10 tile over Paris is
|
||||
~2.1 MB and 8,600 sequences; each tile draws its 1,600 longest), and above
|
||||
60 km the `grid` layer's 0.1°
|
||||
density cells, binned to at most 96×96 points a tile (a z0 tile holds
|
||||
~70,000 cells) and not counted as sequences: the panel says to zoom in. Both
|
||||
filters apply: lines carry their day and camera type, and a cell stays while
|
||||
|
||||
@@ -1200,7 +1200,7 @@ async function main() {
|
||||
);
|
||||
assert.match(
|
||||
await page.$eval('#sl-coverage-meta', (node) => node.textContent),
|
||||
/sequences in view · click a line for the photo there/,
|
||||
/sequences in view · click a line for its nearest photo/,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
|
||||
@@ -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