mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-10 21:32:23 +00:00
Merge origin/main: Panoramax, bikeshare Norway/Detroit, cloud filter strength, Overpass cache
Conflicts:
- KartaView viewer moved into the shared photoViewer.js on main; its
labels now take the provider name ("{name} street-level photo") and
are translated there.
- Weather: main's cloud filter strength slider, with its labels in t().
- Street Level caption: translated creator line plus main's licence.
Panoramax status and error texts are marked like KartaView's, and
"Panoramax answered HTTP {status}" gets a template in the panel and the
layer row. Catalogs: es/ru 2553 messages each, complete.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
7fd1dd867f
77 files changed
+9201
-919
No files matched your search
@@ -0,0 +1,283 @@
|
||||
import { tileBounds } from '../../../src/layers/streetLevel/tileMath.js';
|
||||
import { encodePanoramaxTile } from '../../../src/testSupport/panoramaxFixtures.mjs';
|
||||
import { metresApart, photoJpeg } from './mapillaryGraph.mjs';
|
||||
|
||||
/**
|
||||
* Panoramax's meta-catalogue and one instance's pictures answered from
|
||||
* fixtures, so the gate's Panoramax steps need no network. Two sequences
|
||||
* drive east along the same Paris street (PNX_LINE), their pictures
|
||||
* interleaved, so a click on one line must open that line's picture, not
|
||||
* the other's that is as near. Every seventh picture of the first is 360°.
|
||||
* The meta-catalogue's pictures live on panoramax.openstreetmap.fr; the
|
||||
* first sequence is CC BY-SA 4.0 by "Fixture Mapper", the second Licence
|
||||
* Ouverte by an account named by an email address (never shown).
|
||||
*/
|
||||
|
||||
export const PNX_SEQUENCES = Object.freeze([
|
||||
'a1a1a1a1-0000-4000-8000-000000000001',
|
||||
'b2b2b2b2-0000-4000-8000-000000000002',
|
||||
]);
|
||||
export const PNX_LINE = Object.freeze({
|
||||
lat: 48.8874,
|
||||
west: 2.3445,
|
||||
east: 2.3545,
|
||||
});
|
||||
export const PNX_PARK = Object.freeze({
|
||||
lon: 2.3495,
|
||||
lat: 48.8874,
|
||||
height: 700,
|
||||
pitch: -1.4,
|
||||
});
|
||||
export const PNX_CREATOR = 'Fixture Mapper';
|
||||
const PNX_SPACING_M = 10;
|
||||
const METRES_PER_DEG_LON = 111_320 * Math.cos((PNX_LINE.lat * Math.PI) / 180);
|
||||
const API = 'https://api.panoramax.xyz/api';
|
||||
const HOST = 'https://panoramax.openstreetmap.fr';
|
||||
|
||||
const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' });
|
||||
|
||||
/** Calls answered since the last reset, by kind. */
|
||||
let asked = { search: 0, hd: 0 };
|
||||
|
||||
export function resetPanoramaxFixture() {
|
||||
asked = { search: 0, hd: 0 };
|
||||
}
|
||||
|
||||
/** How many HD originals were downloaded since the last reset. */
|
||||
export function panoramaxHdDownloads() {
|
||||
return asked.hd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Both sequences' pictures, west to east. The second's sit 5 m along from
|
||||
* the first's, on the same street.
|
||||
*/
|
||||
export function panoramaxPictures() {
|
||||
const step = PNX_SPACING_M / METRES_PER_DEG_LON;
|
||||
const count = Math.floor((PNX_LINE.east - PNX_LINE.west) / step);
|
||||
return PNX_SEQUENCES.flatMap((sequenceId, s) =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `${sequenceId.slice(0, 8)}-${String(i).padStart(4, '0')}`,
|
||||
sequenceId,
|
||||
index: i,
|
||||
count,
|
||||
lon: Number((PNX_LINE.west + (i + s / 2) * step).toFixed(7)),
|
||||
lat: PNX_LINE.lat,
|
||||
isPano: s === 0 && i % 7 === 3,
|
||||
datetime:
|
||||
s === 0 ? '2025-06-01T10:00:00+00:00' : '2023-03-01T09:00:00+00:00',
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
function item(picture, pictures) {
|
||||
const sibling = (delta) =>
|
||||
pictures.find(
|
||||
(p) =>
|
||||
p.sequenceId === picture.sequenceId &&
|
||||
p.index === picture.index + delta,
|
||||
);
|
||||
const link = (rel, other) =>
|
||||
other
|
||||
? [
|
||||
{
|
||||
rel,
|
||||
href: `${API}/collections/${other.sequenceId}/items/${other.id}`,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const first = picture.sequenceId === PNX_SEQUENCES[0];
|
||||
const files = `${HOST}/derivates/${picture.id}`;
|
||||
return {
|
||||
type: 'Feature',
|
||||
id: picture.id,
|
||||
collection: picture.sequenceId,
|
||||
geometry: { type: 'Point', coordinates: [picture.lon, picture.lat] },
|
||||
providers: [
|
||||
{
|
||||
name: first ? PNX_CREATOR : '[email protected]',
|
||||
roles: ['producer'],
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
datetime: picture.datetime,
|
||||
license: first ? 'CC-BY-SA-4.0' : 'etalab-2.0',
|
||||
'view:azimuth': 90,
|
||||
'pers:interior_orientation': picture.isPano
|
||||
? { field_of_view: 360, sensor_array_dimensions: [5760, 2880] }
|
||||
: { field_of_view: 70, sensor_array_dimensions: [4032, 3024] },
|
||||
},
|
||||
assets: {
|
||||
hd: { href: `${files}/hd.jpg` },
|
||||
sd: { href: `${files}/sd.jpg` },
|
||||
thumb: { href: `${files}/thumb.jpg` },
|
||||
},
|
||||
links: [
|
||||
...link('prev', sibling(-1)),
|
||||
...link('next', sibling(1)),
|
||||
{ rel: 'via', href: HOST },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** The tile `z/x/y` in the meta-catalogue's shape. */
|
||||
export function panoramaxTile(z, x, y) {
|
||||
const tile = { x, y, z };
|
||||
const { west, east, south, north } = tileBounds(x, y, z);
|
||||
if (PNX_LINE.lat < south || PNX_LINE.lat > north) return new Uint8Array(0);
|
||||
const from = Math.max(PNX_LINE.west, west);
|
||||
const to = Math.min(PNX_LINE.east, east);
|
||||
if (from >= to) return new Uint8Array(0);
|
||||
if (z <= 5)
|
||||
return encodePanoramaxTile(tile, {
|
||||
grid: [{ lon: (from + to) / 2, lat: PNX_LINE.lat, pano: 30, flat: 170 }],
|
||||
});
|
||||
if (z < 7) return new Uint8Array(0);
|
||||
const sequences = PNX_SEQUENCES.map((id, s) => ({
|
||||
id,
|
||||
type: s === 0 ? 'equirectangular' : 'flat',
|
||||
date: s === 0 ? '2025-06-01' : '2023-03-01',
|
||||
parts: [
|
||||
[
|
||||
[from, PNX_LINE.lat],
|
||||
[to, PNX_LINE.lat],
|
||||
],
|
||||
],
|
||||
}));
|
||||
if (z < 15) return encodePanoramaxTile(tile, { sequences });
|
||||
const pictures = panoramaxPictures()
|
||||
.filter((p) => p.lon >= west && p.lon < east)
|
||||
.map((p) => ({
|
||||
id: p.id,
|
||||
lon: p.lon,
|
||||
lat: p.lat,
|
||||
sequenceId: p.sequenceId,
|
||||
heading: 90,
|
||||
ts: p.datetime.replace('T', ' ').replace('+00:00', '+00'),
|
||||
type: p.isPano ? 'equirectangular' : 'flat',
|
||||
}));
|
||||
return encodePanoramaxTile(tile, { sequences, pictures });
|
||||
}
|
||||
|
||||
/** A call named by host, path shape and parameter names. */
|
||||
export function describePanoramaxCall(method, url) {
|
||||
const keys = [...url.searchParams.keys()].sort();
|
||||
const path = url.pathname
|
||||
.replace(/\/map\/\d+\/\d+\/\d+\.mvt$/, '/map/{z}/{x}/{y}.mvt')
|
||||
.replace(
|
||||
/^\/derivates\/[\w-]+\/(sd|hd|thumb)\.jpg$/,
|
||||
'/derivates/{id}/$1.jpg',
|
||||
);
|
||||
return `${method} ${url.hostname}${path}${keys.length ? `?${keys.join('&')}` : ''}`;
|
||||
}
|
||||
|
||||
const json = (body) => ({
|
||||
status: 200,
|
||||
contentType: 'application/geo+json',
|
||||
headers: CORS,
|
||||
body: JSON.stringify(body),
|
||||
known: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Answer one Panoramax request; `known` is false for a call no fixture
|
||||
* covers, so the gate fails on a new one instead of missing it.
|
||||
*/
|
||||
export function answerPanoramaxRequest({ method, url }) {
|
||||
const address = new URL(url);
|
||||
if (method === 'OPTIONS')
|
||||
return { status: 204, headers: CORS, body: '', known: true };
|
||||
const pictures = panoramaxPictures();
|
||||
if (address.hostname === new URL(HOST).hostname) {
|
||||
const file = address.pathname.match(
|
||||
/^\/derivates\/([\w-]+)\/(sd|hd|thumb)\.jpg$/,
|
||||
);
|
||||
if (!file || !pictures.some((p) => p.id === file[1]))
|
||||
return { status: 404, headers: CORS, body: '', known: false };
|
||||
if (file[2] === 'hd') asked.hd++;
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'image/jpeg',
|
||||
headers: CORS,
|
||||
body: photoJpeg(),
|
||||
known: true,
|
||||
};
|
||||
}
|
||||
if (address.hostname !== 'api.panoramax.xyz')
|
||||
return { status: 404, headers: CORS, body: '', known: false };
|
||||
const tile = address.pathname.match(/^\/api\/map\/(\d+)\/(\d+)\/(\d+)\.mvt$/);
|
||||
if (tile) {
|
||||
const bytes = panoramaxTile(
|
||||
Number(tile[1]),
|
||||
Number(tile[2]),
|
||||
Number(tile[3]),
|
||||
);
|
||||
return bytes.length
|
||||
? {
|
||||
status: 200,
|
||||
contentType: 'application/vnd.mapbox-vector-tile',
|
||||
headers: CORS,
|
||||
body: Buffer.from(bytes),
|
||||
known: true,
|
||||
}
|
||||
: { status: 204, headers: CORS, body: '', known: true };
|
||||
}
|
||||
if (address.pathname !== '/api/search')
|
||||
return { status: 404, headers: CORS, body: '', known: false };
|
||||
asked.search++;
|
||||
const q = address.searchParams;
|
||||
if (q.has('ids')) {
|
||||
const wanted = q.get('ids').split(',');
|
||||
return json({
|
||||
type: 'FeatureCollection',
|
||||
features: pictures
|
||||
.filter((p) => wanted.includes(p.id))
|
||||
.map((p) => item(p, pictures)),
|
||||
});
|
||||
}
|
||||
const filter = q.get('filter') || '';
|
||||
const since = q.get('datetime')?.split('/')[0];
|
||||
const kept = pictures
|
||||
.filter(
|
||||
(p) => !q.get('collections') || p.sequenceId === q.get('collections'),
|
||||
)
|
||||
.filter((p) =>
|
||||
filter === 'field_of_view=360'
|
||||
? p.isPano
|
||||
: filter === 'field_of_view<360'
|
||||
? !p.isPano
|
||||
: true,
|
||||
)
|
||||
.filter((p) => !since || Date.parse(p.datetime) >= Date.parse(since));
|
||||
// A box: every picture in it, nearest its centre first, as the live API
|
||||
// returns them (roughly: the browser ranks them again).
|
||||
if (q.has('bbox')) {
|
||||
const [west, south, east, north] = q.get('bbox').split(',').map(Number);
|
||||
const centre = { lon: (west + east) / 2, lat: (south + north) / 2 };
|
||||
return json({
|
||||
type: 'FeatureCollection',
|
||||
features: kept
|
||||
.filter(
|
||||
(p) =>
|
||||
p.lon >= west && p.lon <= east && p.lat >= south && p.lat <= north,
|
||||
)
|
||||
.map((p) => ({ p, metres: metresApart(centre, p) }))
|
||||
.sort((a, b) => a.metres - b.metres || a.p.id.localeCompare(b.p.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)
|
||||
.slice(0, Number(q.get('limit')) || 10)
|
||||
.map(({ p }) => item(p, pictures));
|
||||
return json({ type: 'FeatureCollection', features: near });
|
||||
}
|
||||
@@ -932,15 +932,33 @@
|
||||
"src/layers/streetLevel/providers/kartaview/source.js",
|
||||
"src/layers/streetLevel/providers/kartaview/decode.js",
|
||||
"src/layers/streetLevel/providers/kartaview/viewer.js",
|
||||
"src/layers/streetLevel/providers/kartaview/panorama.js",
|
||||
"src/layers/streetLevel/panorama.js",
|
||||
"src/layers/streetLevel/lineCoverage.js",
|
||||
"src/layers/streetLevel/sequenceCones.js",
|
||||
"src/layers/streetLevel/providers/kartaview/policy.test.mjs",
|
||||
"src/layers/streetLevel/providers/kartaview/decode.test.mjs",
|
||||
"src/layers/streetLevel/providers/kartaview/source.test.mjs",
|
||||
"src/layers/streetLevel/providers/kartaview/panorama.test.mjs",
|
||||
"src/layers/streetLevel/panorama.test.mjs",
|
||||
"src/layers/streetLevel/providers/kartaview/viewer.test.mjs",
|
||||
"src/layers/streetLevel/providers/kartaview/provider.test.mjs",
|
||||
"src/layers/streetLevel/providers/panoramax/index.js",
|
||||
"src/layers/streetLevel/providers/panoramax/policy.js",
|
||||
"src/layers/streetLevel/providers/panoramax/source.js",
|
||||
"src/layers/streetLevel/providers/panoramax/decode.js",
|
||||
"src/layers/streetLevel/providers/panoramax/viewer.js",
|
||||
"src/layers/streetLevel/photoViewer.js",
|
||||
"src/layers/streetLevel/limiter.js",
|
||||
"src/layers/streetLevel/imageSize.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/imageSize.test.mjs",
|
||||
"src/layers/streetLevel/lineCoverage.test.mjs",
|
||||
"src/testSupport/kartaViewFakes.mjs",
|
||||
"src/testSupport/googleStreetViewFakes.mjs",
|
||||
@@ -957,6 +975,7 @@
|
||||
"src/qaChrome.test.mjs",
|
||||
"scripts/fixtures/street-level/mapillaryGraph.mjs",
|
||||
"scripts/fixtures/street-level/kartaView.mjs",
|
||||
"scripts/fixtures/street-level/panoramax.mjs",
|
||||
"src/layers/streetLevel/filter.test.mjs",
|
||||
"src/layers/streetLevel/registry.test.mjs",
|
||||
"src/layers/streetLevel/params.test.mjs",
|
||||
|
||||
@@ -1357,7 +1357,16 @@
|
||||
"src/layers/streetLevel/providers/kartaview/source.js",
|
||||
"src/layers/streetLevel/providers/kartaview/decode.js",
|
||||
"src/layers/streetLevel/providers/kartaview/viewer.js",
|
||||
"src/layers/streetLevel/providers/kartaview/panorama.js",
|
||||
"src/layers/streetLevel/providers/panoramax/index.js",
|
||||
"src/layers/streetLevel/providers/panoramax/policy.js",
|
||||
"src/layers/streetLevel/providers/panoramax/source.js",
|
||||
"src/layers/streetLevel/providers/panoramax/decode.js",
|
||||
"src/layers/streetLevel/providers/panoramax/viewer.js",
|
||||
"src/layers/streetLevel/photoViewer.js",
|
||||
"src/layers/streetLevel/limiter.js",
|
||||
"src/layers/streetLevel/imageSize.js",
|
||||
"src/layers/streetLevel/tileCache.js",
|
||||
"src/layers/streetLevel/panorama.js",
|
||||
"src/layers/streetLevel/lineCoverage.js",
|
||||
"src/layers/streetLevel/sequenceCones.js",
|
||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||
@@ -2365,7 +2374,16 @@
|
||||
"src/layers/streetLevel/providers/kartaview/source.js",
|
||||
"src/layers/streetLevel/providers/kartaview/decode.js",
|
||||
"src/layers/streetLevel/providers/kartaview/viewer.js",
|
||||
"src/layers/streetLevel/providers/kartaview/panorama.js",
|
||||
"src/layers/streetLevel/providers/panoramax/index.js",
|
||||
"src/layers/streetLevel/providers/panoramax/policy.js",
|
||||
"src/layers/streetLevel/providers/panoramax/source.js",
|
||||
"src/layers/streetLevel/providers/panoramax/decode.js",
|
||||
"src/layers/streetLevel/providers/panoramax/viewer.js",
|
||||
"src/layers/streetLevel/photoViewer.js",
|
||||
"src/layers/streetLevel/limiter.js",
|
||||
"src/layers/streetLevel/imageSize.js",
|
||||
"src/layers/streetLevel/tileCache.js",
|
||||
"src/layers/streetLevel/panorama.js",
|
||||
"src/layers/streetLevel/lineCoverage.js",
|
||||
"src/layers/streetLevel/sequenceCones.js",
|
||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||
@@ -2922,7 +2940,16 @@
|
||||
"src/layers/streetLevel/providers/kartaview/source.js",
|
||||
"src/layers/streetLevel/providers/kartaview/decode.js",
|
||||
"src/layers/streetLevel/providers/kartaview/viewer.js",
|
||||
"src/layers/streetLevel/providers/kartaview/panorama.js",
|
||||
"src/layers/streetLevel/providers/panoramax/index.js",
|
||||
"src/layers/streetLevel/providers/panoramax/policy.js",
|
||||
"src/layers/streetLevel/providers/panoramax/source.js",
|
||||
"src/layers/streetLevel/providers/panoramax/decode.js",
|
||||
"src/layers/streetLevel/providers/panoramax/viewer.js",
|
||||
"src/layers/streetLevel/photoViewer.js",
|
||||
"src/layers/streetLevel/limiter.js",
|
||||
"src/layers/streetLevel/imageSize.js",
|
||||
"src/layers/streetLevel/tileCache.js",
|
||||
"src/layers/streetLevel/panorama.js",
|
||||
"src/layers/streetLevel/lineCoverage.js",
|
||||
"src/layers/streetLevel/sequenceCones.js",
|
||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||
|
||||
+376
-9
@@ -3,8 +3,9 @@
|
||||
* Browser QA for the Street Level layer against a running server. Run with
|
||||
* `npm run qa:street-level -- --url http://localhost:4173`.
|
||||
*
|
||||
* `--fixtures` (the CI mode) answers every *.mapillary.com and KartaView
|
||||
* request from fixtures, so nothing reaches Mapillary or KartaView. The server still needs a token
|
||||
* `--fixtures` (the CI mode) answers every *.mapillary.com, KartaView and
|
||||
* Panoramax request from fixtures, so nothing reaches those providers. The
|
||||
* server still needs a token
|
||||
* because the bundle carries it; CI uses a dummy one. Fixture runs also stage
|
||||
* a rejected key and a keyless second server.
|
||||
*
|
||||
@@ -46,6 +47,17 @@ import {
|
||||
kartaViewPhotos,
|
||||
resetKartaViewFixture,
|
||||
} from './fixtures/street-level/kartaView.mjs';
|
||||
import {
|
||||
answerPanoramaxRequest,
|
||||
describePanoramaxCall,
|
||||
PNX_CREATOR,
|
||||
PNX_LINE,
|
||||
PNX_PARK,
|
||||
PNX_SEQUENCES,
|
||||
panoramaxHdDownloads,
|
||||
panoramaxPictures,
|
||||
resetPanoramaxFixture,
|
||||
} from './fixtures/street-level/panoramax.mjs';
|
||||
|
||||
const PANEL_ID = 'street-level-panel';
|
||||
|
||||
@@ -62,6 +74,7 @@ export const EXPECTED_PROVIDERS = Object.freeze([
|
||||
'mapillary',
|
||||
'google',
|
||||
'kartaview',
|
||||
'panoramax',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -245,6 +258,8 @@ export async function serveFixtures(page, fixture) {
|
||||
{ urlPattern: '*://api.kartaview.org/*' },
|
||||
{ urlPattern: '*://cdn.kartaview.org/*' },
|
||||
{ urlPattern: '*://*.openstreetcam.org/*' },
|
||||
{ urlPattern: '*://api.panoramax.xyz/*' },
|
||||
{ urlPattern: '*://panoramax.openstreetmap.fr/*' },
|
||||
],
|
||||
});
|
||||
const fulfil = (requestId, { status, headers = {}, contentType, body }) =>
|
||||
@@ -280,6 +295,14 @@ export async function serveFixtures(page, fixture) {
|
||||
answer = answerKartaViewRequest({ method, url: request.url });
|
||||
fixture.calls.add(call);
|
||||
if (!answer.known) fixture.unknown.push(call);
|
||||
} else if (
|
||||
address.hostname === 'api.panoramax.xyz' ||
|
||||
address.hostname === 'panoramax.openstreetmap.fr'
|
||||
) {
|
||||
const call = describePanoramaxCall(method, address);
|
||||
answer = answerPanoramaxRequest({ method, url: request.url });
|
||||
fixture.calls.add(call);
|
||||
if (!answer.known) fixture.unknown.push(call);
|
||||
} else if (
|
||||
address.pathname === '/api/mapillary/status' &&
|
||||
fixture.configured !== undefined
|
||||
@@ -1054,7 +1077,7 @@ async function main() {
|
||||
assert.equal(street.renderModes, true, 'FIT / FILL for a flat photo');
|
||||
assert.ok(
|
||||
await settle(page, () => {
|
||||
const img = document.querySelector('.sl-kv-photo');
|
||||
const img = document.querySelector('.sl-photo-flat');
|
||||
return img && !img.hidden && img.naturalWidth > 0;
|
||||
}),
|
||||
'the photo is on screen',
|
||||
@@ -1098,7 +1121,7 @@ async function main() {
|
||||
);
|
||||
}
|
||||
const before = street.imageId;
|
||||
await clickPanelControl(page, '.sl-kv-step-next');
|
||||
await clickPanelControl(page, '.sl-photo-step-next');
|
||||
assert.ok(
|
||||
await uiUntil(
|
||||
(u, was) =>
|
||||
@@ -1150,10 +1173,10 @@ async function main() {
|
||||
assert.equal(u.street.error, null);
|
||||
assert.equal(u.street.renderModes, false, 'no frame to fit');
|
||||
assert.equal(
|
||||
await page.$eval('.sl-kv-pano', (canvas) => canvas.hidden),
|
||||
await page.$eval('.sl-photo-pano', (canvas) => canvas.hidden),
|
||||
false,
|
||||
);
|
||||
const box = await page.$eval('.sl-kv-pano', (canvas) => {
|
||||
const box = await page.$eval('.sl-photo-pano', (canvas) => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||
});
|
||||
@@ -1170,7 +1193,7 @@ async function main() {
|
||||
// A GPU reset: the panorama comes back on a new canvas, and drags.
|
||||
assert.ok(
|
||||
await page.evaluate(() => {
|
||||
const canvas = document.querySelector('.sl-kv-pano');
|
||||
const canvas = document.querySelector('.sl-photo-pano');
|
||||
window.__kvLostCanvas = canvas;
|
||||
const lose = canvas
|
||||
.getContext('webgl')
|
||||
@@ -1184,7 +1207,7 @@ async function main() {
|
||||
await settle(
|
||||
page,
|
||||
() => {
|
||||
const canvas = document.querySelector('.sl-kv-pano');
|
||||
const canvas = document.querySelector('.sl-photo-pano');
|
||||
return (
|
||||
canvas &&
|
||||
canvas !== window.__kvLostCanvas &&
|
||||
@@ -1231,6 +1254,349 @@ async function main() {
|
||||
await setStack(original);
|
||||
}
|
||||
|
||||
/**
|
||||
* Panoramax: keyless orange coverage with both filters, a line click
|
||||
* opening that sequence's picture nearest the click (author, licence,
|
||||
* cones, ›), a 360° picture whose original is fetched once on EXPAND.
|
||||
* Live runs park over Paris, where Panoramax has pictures. Runs on a
|
||||
* draped map; leaves Mapillary lit.
|
||||
*/
|
||||
async function panoramaxSteps() {
|
||||
resetPanoramaxFixture();
|
||||
const ui = () =>
|
||||
page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getUIState(),
|
||||
);
|
||||
const uiUntil = (test, arg = null, options) =>
|
||||
settle(
|
||||
page,
|
||||
`(${test})(window.__godsEyeView.dataManager.layers.get('street-level').module.getUIState(), ${JSON.stringify(arg)})`,
|
||||
undefined,
|
||||
options,
|
||||
);
|
||||
const lit = (id) =>
|
||||
uiUntil(
|
||||
(u, want) =>
|
||||
u.providers.find((p) => p.id === want)?.on === true &&
|
||||
u.providers.filter((p) => p.on).length === 1,
|
||||
id,
|
||||
);
|
||||
const module = (call, ...args) =>
|
||||
page.evaluate(
|
||||
(name, list) =>
|
||||
window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module[name](...list),
|
||||
call,
|
||||
args,
|
||||
);
|
||||
const stack = () =>
|
||||
page.evaluate(() =>
|
||||
window.__godsEyeView.mapStackController.getActiveId(),
|
||||
);
|
||||
const setStack = async (id) => {
|
||||
await page.evaluate(
|
||||
(stackId) =>
|
||||
window.__godsEyeView.mapStackController.setStack(stackId),
|
||||
id,
|
||||
);
|
||||
await settle(
|
||||
page,
|
||||
(stackId) =>
|
||||
window.__godsEyeView.mapStackController.getActiveId() === stackId,
|
||||
id,
|
||||
);
|
||||
};
|
||||
const camera = () =>
|
||||
page.evaluate(() => {
|
||||
const c = window.__godsEyeView.viewer.camera.positionCartographic;
|
||||
return {
|
||||
lon: (c.longitude * 180) / Math.PI,
|
||||
lat: (c.latitude * 180) / Math.PI,
|
||||
height: c.height,
|
||||
};
|
||||
});
|
||||
const pnxChip = '#sl-provider-chips [data-chip-id="panoramax"]';
|
||||
const original = await stack();
|
||||
await module('closeViewer');
|
||||
await setStack('esri-imagery');
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
await page.evaluate((at) => {
|
||||
const v = window.__godsEyeView.viewer;
|
||||
v.camera.cancelFlight?.();
|
||||
const C = v.camera.positionCartographic.constructor;
|
||||
v.camera.setView({
|
||||
destination: v.scene.globe.ellipsoid.cartographicToCartesian(
|
||||
C.fromDegrees(at.lon, at.lat, at.height),
|
||||
),
|
||||
orientation: { heading: 0, pitch: at.pitch, roll: 0 },
|
||||
});
|
||||
}, PNX_PARK);
|
||||
await sleep(1000);
|
||||
}
|
||||
try {
|
||||
await step(
|
||||
'Panoramax: its chip draws keyless orange coverage, keeps both filters, credits Panoramax and lights in the share link',
|
||||
async () => {
|
||||
await clickPanelControl(page, pnxChip);
|
||||
assert.ok(await lit('panoramax'), 'Panoramax alone is lit');
|
||||
await waitForCoverage(page);
|
||||
const u = await ui();
|
||||
assert.equal(u.keyRequired, false);
|
||||
assert.deepEqual(u.filterControls, { pano: true, since: true });
|
||||
assert.equal(
|
||||
await page.$eval(pnxChip, (chip) =>
|
||||
chip.style.getPropertyValue('--chip-color'),
|
||||
),
|
||||
'#ff6f00',
|
||||
);
|
||||
assert.match(
|
||||
await page.$eval('#sl-coverage-meta', (node) => node.textContent),
|
||||
/sequences in view · click a line for its nearest photo/,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
document.body.innerHTML.includes('Panoramax</a> contributors'),
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
assert.ok(
|
||||
await settle(page, () =>
|
||||
(new URLSearchParams(location.hash.slice(1)).get('lo') || '')
|
||||
.split('_')
|
||||
.some(
|
||||
(entry) =>
|
||||
entry.startsWith('0.') && entry.includes('panoramax-1'),
|
||||
),
|
||||
),
|
||||
`the share link lights Panoramax: ${await page.evaluate(() => location.hash)}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
'Panoramax: a click on a line opens that sequence’s picture nearest the click, camera still, with author and licence, cones and ›',
|
||||
async ({ skip }) => {
|
||||
// Points on the street: the fixture's, or live pictures there.
|
||||
let points = null;
|
||||
if (fixtures)
|
||||
points = Array.from({ length: 9 }, (_, i) => [
|
||||
PNX_LINE.west +
|
||||
((i + 2) / 13) * (PNX_LINE.east - PNX_LINE.west),
|
||||
PNX_LINE.lat,
|
||||
]);
|
||||
else {
|
||||
const near = await fetch(
|
||||
`https://api.panoramax.xyz/api/search?place_position=${PNX_PARK.lon},${PNX_PARK.lat}&place_distance=0-150&place_fov_tolerance=180&limit=12`,
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.catch(() => null);
|
||||
points = near?.features?.map((f) => f.geometry.coordinates);
|
||||
if (!points?.length)
|
||||
return skip('no Panoramax picture near the park');
|
||||
}
|
||||
const handle = await page
|
||||
.waitForFunction(
|
||||
findPickPoint,
|
||||
{ timeout: 60_000, polling: 500 },
|
||||
{
|
||||
prefix: 'pnx:seq:',
|
||||
id: null,
|
||||
points,
|
||||
billboardIds: null,
|
||||
},
|
||||
)
|
||||
.catch(() => null);
|
||||
if (!handle && !fixtures) return skip('no Panoramax line in view');
|
||||
assert.ok(handle, 'a Panoramax line is pickable');
|
||||
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(
|
||||
() => {
|
||||
const s = window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getUIState().street;
|
||||
return (s.imageId && !s.loading) || s.error;
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
);
|
||||
const street = (await ui()).street;
|
||||
assert.equal(street.error, null, `viewer error: ${street.error}`);
|
||||
assert.equal(street.providerId, 'panoramax');
|
||||
assert.equal(
|
||||
street.sequenceId,
|
||||
at.id,
|
||||
'the picture is from the line clicked',
|
||||
);
|
||||
assert.ok(street.license, 'the caption names the licence');
|
||||
assert.match(
|
||||
street.externalUrl,
|
||||
/^https:\/\/api\.panoramax\.xyz\/#pic=/,
|
||||
);
|
||||
await sleep(1500);
|
||||
const now = await camera();
|
||||
assert.ok(
|
||||
Math.abs(now.lon - parked.lon) < 1e-6 &&
|
||||
Math.abs(now.lat - parked.lat) < 1e-6 &&
|
||||
Math.abs(now.height - parked.height) < 0.5,
|
||||
`the camera did not move: ${JSON.stringify({ parked, now })}`,
|
||||
);
|
||||
if (fixtures) {
|
||||
const caption = await page.$eval(
|
||||
'#sl-image-by',
|
||||
(node) => node.textContent,
|
||||
);
|
||||
if (street.sequenceId === PNX_SEQUENCES[0])
|
||||
assert.equal(caption, `Image by ${PNX_CREATOR} · CC BY-SA 4.0`);
|
||||
else
|
||||
assert.equal(
|
||||
caption,
|
||||
'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,
|
||||
);
|
||||
assert.ok(
|
||||
await uiUntil(
|
||||
(u, want) =>
|
||||
u.sequence.selectedId === want &&
|
||||
!u.sequence.loading &&
|
||||
u.sequence.images > 0,
|
||||
street.sequenceId,
|
||||
{ timeout: 30_000 },
|
||||
),
|
||||
`cones along the sequence: ${JSON.stringify((await ui()).sequence)}`,
|
||||
);
|
||||
assert.ok((await ui()).sequence.images <= pictures.length);
|
||||
}
|
||||
const before = street.imageId;
|
||||
await clickPanelControl(page, '.sl-photo-step-next');
|
||||
assert.ok(
|
||||
await uiUntil(
|
||||
(u, was) =>
|
||||
u.street.imageId &&
|
||||
u.street.imageId !== was &&
|
||||
!u.street.loading &&
|
||||
!u.street.error,
|
||||
before,
|
||||
{ timeout: 30_000 },
|
||||
),
|
||||
'› opened the next picture',
|
||||
);
|
||||
const after = (await ui()).street;
|
||||
assert.equal(after.sequenceId, street.sequenceId);
|
||||
if (fixtures) {
|
||||
const all = panoramaxPictures();
|
||||
const from = all.find((p) => p.id === before);
|
||||
assert.equal(
|
||||
after.imageId,
|
||||
all.find(
|
||||
(p) =>
|
||||
p.sequenceId === from.sequenceId &&
|
||||
p.index === from.index + 1,
|
||||
).id,
|
||||
'› followed the picture’s next link',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (fixtures)
|
||||
await step(
|
||||
'Panoramax: a 360° picture opens on the panorama without FIT / FILL, drags, and EXPAND fetches its original once',
|
||||
async () => {
|
||||
const pano = panoramaxPictures().find((p) => p.isPano);
|
||||
await module('openImage', 'panoramax', pano.id);
|
||||
assert.ok(
|
||||
await uiUntil(
|
||||
(u, want) =>
|
||||
u.street.imageId === want &&
|
||||
!u.street.loading &&
|
||||
u.street.isPano,
|
||||
pano.id,
|
||||
{ timeout: 60_000 },
|
||||
),
|
||||
`the panorama opened: ${JSON.stringify((await ui()).street)}`,
|
||||
);
|
||||
const u = await ui();
|
||||
assert.equal(u.street.renderModes, false, 'no frame to fit');
|
||||
const box = await page.$eval('.sl-photo-pano', (canvas) => {
|
||||
const r = canvas.getBoundingClientRect();
|
||||
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
||||
});
|
||||
const bearing = u.street.bearing;
|
||||
await page.mouse.move(box.x, box.y);
|
||||
await page.mouse.down();
|
||||
for (let i = 1; i <= 8; i++)
|
||||
await page.mouse.move(box.x - i * 15, box.y);
|
||||
await page.mouse.up();
|
||||
assert.ok(
|
||||
await uiUntil((v, was) => v.street.bearing !== was, bearing),
|
||||
'dragging the panorama turned its bearing',
|
||||
);
|
||||
assert.equal(panoramaxHdDownloads(), 0, 'docked: SD only');
|
||||
await clickPanelControl(page, '#sl-viewer-expand');
|
||||
assert.ok(
|
||||
await settle(page, () =>
|
||||
Boolean(document.querySelector('.sl-viewer-wrap-expanded')),
|
||||
),
|
||||
'the viewer expanded',
|
||||
);
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (panoramaxHdDownloads() === 0 && Date.now() < deadline)
|
||||
await sleep(250);
|
||||
await sleep(2000);
|
||||
assert.equal(
|
||||
panoramaxHdDownloads(),
|
||||
1,
|
||||
'the original, once, for the expanded viewer',
|
||||
);
|
||||
await page.keyboard.press('Escape');
|
||||
},
|
||||
);
|
||||
else
|
||||
await skipStep(
|
||||
'fixtures only',
|
||||
'Panoramax: a 360° picture opens on the panorama without FIT / FILL, drags, and EXPAND fetches its original once',
|
||||
);
|
||||
} finally {
|
||||
await module('closeViewer');
|
||||
await clickPanelControl(
|
||||
page,
|
||||
'#sl-provider-chips [data-chip-id="mapillary"]',
|
||||
);
|
||||
assert.ok(await lit('mapillary'), 'Mapillary is lit again');
|
||||
await setStack(original);
|
||||
}
|
||||
}
|
||||
|
||||
async function keyedSteps() {
|
||||
// A late startup flight only shows itself by moving the camera, so park
|
||||
// and check it stays put rather than reading a state.
|
||||
@@ -2549,6 +2915,7 @@ async function main() {
|
||||
},
|
||||
);
|
||||
await kartaViewSteps();
|
||||
await panoramaxSteps();
|
||||
await step(
|
||||
'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token',
|
||||
async () => {
|
||||
@@ -2632,7 +2999,7 @@ async function main() {
|
||||
);
|
||||
if (fixtures)
|
||||
await step(
|
||||
'every Mapillary and KartaView request was answered from a fixture',
|
||||
'every Mapillary, KartaView and Panoramax request was answered from a fixture',
|
||||
() => {
|
||||
console.log(
|
||||
` (answered: ${[...fixture.calls].sort().join(' | ')})`,
|
||||
|
||||
Reference in new issue
Block a user