mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' into fix/street-level-gate-pill-race
This commit is contained in:
203 files changed
+20390
-1205
No files matched your search
@@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Docker launcher — the container's entry point (see Dockerfile).
|
||||
*
|
||||
* `compose.yaml` publishes the port on the host's loopback, but Docker still
|
||||
* delivers the host's own browser to the container FROM the container
|
||||
* network's gateway address, never from 127.0.0.1. Provider Settings admits
|
||||
* loopback only, so without this the POWER UP panel refuses the very machine
|
||||
* running the container. This launcher reads the gateway out of the kernel
|
||||
* routing table and declares exactly that one address trusted, then starts
|
||||
* Vite the way scripts/pinokio-start.mjs does.
|
||||
*/
|
||||
import { accessSync, constants, readFileSync, realpathSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isDirectInvocation } from './pinokio-install.mjs';
|
||||
import { loadViteFromCanonicalRoot } from './pinokio-start.mjs';
|
||||
|
||||
const MODULE_PATH = fileURLToPath(import.meta.url);
|
||||
const ROOT = realpathSync(path.resolve(path.dirname(MODULE_PATH), '..'));
|
||||
const ROUTE_TABLE = '/proc/net/route';
|
||||
|
||||
/**
|
||||
* Extract the IPv4 default gateway from Linux `/proc/net/route` text. The
|
||||
* addresses there are little-endian hex and only the default route (a
|
||||
* destination of 00000000 on an interface flagged up) names a gateway.
|
||||
* Returns null when there is none — the `--network none` case, which must
|
||||
* never fabricate an address.
|
||||
* @param {string} text Raw routing-table contents.
|
||||
* @returns {string|null} Dotted-quad gateway, or null.
|
||||
*/
|
||||
export function defaultGatewayFromRouteTable(text) {
|
||||
const lines = String(text || '').split(/\r?\n/).slice(1);
|
||||
for (const line of lines) {
|
||||
const fields = line.trim().split(/\s+/);
|
||||
if (fields.length < 4) continue;
|
||||
const [, destination, gateway, flags] = fields;
|
||||
if (destination !== '00000000' || !/^[0-9a-f]{8}$/i.test(gateway)) continue;
|
||||
if ((Number.parseInt(flags, 16) & 0x1) === 0) continue; // RTF_UP
|
||||
const octets = [6, 4, 2, 0].map((offset) =>
|
||||
Number.parseInt(gateway.slice(offset, offset + 2), 16),
|
||||
);
|
||||
if (octets.every((octet) => octet === 0)) continue;
|
||||
return octets.join('.');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide this launch's trusted-peer list. An operator value always wins, so a
|
||||
* compose override stays authoritative; otherwise the gateway alone is
|
||||
* trusted. No gateway trusts nothing and Provider Settings stays loopback-only,
|
||||
* exactly as under `npm run dev`.
|
||||
* @param {{env?: NodeJS.ProcessEnv, routeTable?: string}} input
|
||||
* @returns {string} Value for GEV_KEY_SETUP_TRUSTED_PEERS ('' trusts nothing).
|
||||
*/
|
||||
export function resolveTrustedPeers({ env = process.env, routeTable = '' } = {}) {
|
||||
const explicit = String(env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim();
|
||||
if (explicit) return explicit;
|
||||
return defaultGatewayFromRouteTable(routeTable) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* A checkout the container cannot write fails quietly: Provider Settings
|
||||
* answers 500 and `.gev-cache` is never created, with nothing in the log. The
|
||||
* usual cause is a clone made as root on a NAS or Proxmox host while the
|
||||
* container runs as 1000:1000. Say so at start, naming both sets of ids.
|
||||
* @param {{root: string, writable: boolean, owner: {uid: number, gid: number}, runAs: {uid: number, gid: number}}} input
|
||||
* @returns {string|null} A warning, or null when the checkout is writable.
|
||||
*/
|
||||
export function unwritableRootWarning({ root, writable, owner, runAs }) {
|
||||
if (writable) return null;
|
||||
return (
|
||||
`[Docker] ${root} is owned by ${owner.uid}:${owner.gid} but the container runs as ` +
|
||||
`${runAs.uid}:${runAs.gid}, so saving keys and caching will fail. Either give ` +
|
||||
`the checkout to that user (chown -R ${runAs.uid}:${runAs.gid} on the host) or ` +
|
||||
`run as its owner (env UID=${owner.uid} GID=${owner.gid} docker compose up)`
|
||||
);
|
||||
}
|
||||
|
||||
function readRouteTable() {
|
||||
try {
|
||||
return readFileSync(ROUTE_TABLE, 'utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function start() {
|
||||
let writable = true;
|
||||
try {
|
||||
accessSync(ROOT, constants.W_OK);
|
||||
} catch {
|
||||
writable = false;
|
||||
}
|
||||
if (!writable) {
|
||||
const { uid, gid } = statSync(ROOT);
|
||||
console.warn(
|
||||
unwritableRootWarning({
|
||||
root: ROOT,
|
||||
writable,
|
||||
owner: { uid, gid },
|
||||
runAs: { uid: process.getuid?.() ?? -1, gid: process.getgid?.() ?? -1 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const fromOperator = Boolean(
|
||||
String(process.env.GEV_KEY_SETUP_TRUSTED_PEERS ?? '').trim(),
|
||||
);
|
||||
const trustedPeers = resolveTrustedPeers({ routeTable: readRouteTable() });
|
||||
if (trustedPeers) {
|
||||
// Set before Vite is imported: the dev server snapshots process.env during
|
||||
// configuration, and the admission gate reads it per request.
|
||||
process.env.GEV_KEY_SETUP_TRUSTED_PEERS = trustedPeers;
|
||||
console.log(
|
||||
fromOperator
|
||||
? `[Docker] Provider Settings trusted peers: ${trustedPeers} (GEV_KEY_SETUP_TRUSTED_PEERS).`
|
||||
: `[Docker] Provider Settings trusts the container gateway (${trustedPeers}).`,
|
||||
);
|
||||
} else {
|
||||
console.log('[Docker] No default gateway; Provider Settings stays loopback-only.');
|
||||
}
|
||||
|
||||
const port = Number.parseInt(process.env.PORT, 10) || 4173;
|
||||
const { createServer } = await loadViteFromCanonicalRoot(ROOT);
|
||||
const server = await createServer({
|
||||
root: ROOT,
|
||||
server: {
|
||||
// The container's own namespace. compose publishes this to the host's
|
||||
// 127.0.0.1 only; widening that mapping is the LAN opt-in.
|
||||
host: '0.0.0.0',
|
||||
port,
|
||||
strictPort: true,
|
||||
},
|
||||
});
|
||||
await server.listen();
|
||||
server.printUrls();
|
||||
console.log(`[Docker] Ready at http://localhost:${port}/`);
|
||||
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) {
|
||||
process.once(signal, async () => {
|
||||
await server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isDirectInvocation(process.argv[1], MODULE_PATH)) {
|
||||
start().catch((error) => {
|
||||
console.error(`[Docker] Start refused: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -112,6 +112,8 @@
|
||||
"server/providers/cctv/cap.js",
|
||||
"server/providers/cctv/groundHeights.js",
|
||||
"server/providers/cctv/catalog.js",
|
||||
"server/providers/cctv/lithuania.js",
|
||||
"src/data/cctvLithuania.test.mjs",
|
||||
"server/providers/cctv/constants.js",
|
||||
"server/providers/cctv/media.js",
|
||||
"server/providers/cctv/normalize.js",
|
||||
@@ -366,6 +368,12 @@
|
||||
"src/layers/vessels/tracking.js",
|
||||
"src/layers/vessels/ownership.test.mjs",
|
||||
"src/data/earthquakes.js",
|
||||
"src/app/layers/airQuality.js",
|
||||
"src/layers/airQuality/index.js",
|
||||
"src/layers/airQuality/model.js",
|
||||
"src/layers/airQuality/model.test.mjs",
|
||||
"src/layers/airQuality/ownership.test.mjs",
|
||||
"src/layers/airQuality/source.js",
|
||||
"src/layers/earthquakes/index.js",
|
||||
"src/layers/earthquakes/model.js",
|
||||
"src/layers/earthquakes/source.js",
|
||||
@@ -540,6 +548,7 @@
|
||||
"src/data/cctvEstonia.test.mjs",
|
||||
"src/data/cctvWarendorf.test.mjs",
|
||||
"src/data/cctvCalgary.test.mjs",
|
||||
"src/data/cctvCataloniaSource.test.mjs",
|
||||
"src/data/cctvVegvesen.test.mjs",
|
||||
"src/data/cctvNyc.test.mjs",
|
||||
"src/data/cctvNswSource.test.mjs",
|
||||
@@ -734,6 +743,7 @@
|
||||
"src/search/nominatim.js",
|
||||
"src/search/nominatim.test.mjs",
|
||||
"src/sources/httpBody.js",
|
||||
"src/sources/httpBody.test.mjs",
|
||||
"src/sources/overpass.js",
|
||||
"src/sources/nominatim.js",
|
||||
"server/providers/transit.js",
|
||||
@@ -932,15 +942,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 +985,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",
|
||||
@@ -1021,6 +1050,9 @@
|
||||
"src/costEndpointThrottle.test.mjs",
|
||||
"src/sources/gbfsStations.test.mjs",
|
||||
"scripts/qa-gbfs-names.mjs",
|
||||
"scripts/generate-icons.mjs",
|
||||
"src/tooling/webAppIcons.test.mjs",
|
||||
"src/ui/flyToDataSource.js",
|
||||
"src/ui/flyToDataSource.test.mjs"
|
||||
"src/ui/flyToDataSource.test.mjs",
|
||||
"src/data/gbfsProxyRateLimit.test.mjs"
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
// Renders the home-screen and favicon rasters from `public/logo.svg`.
|
||||
//
|
||||
// Re-run after changing the logo: `node scripts/generate-icons.mjs`. The output
|
||||
// is committed, because the install icons have to exist in a plain `vite build`
|
||||
// and contributors should not need an image toolchain to ship the app.
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import sharp from 'sharp';
|
||||
|
||||
const REPO_ROOT = path.resolve(
|
||||
fileURLToPath(new URL('.', import.meta.url)),
|
||||
'..',
|
||||
);
|
||||
const LOGO = path.join(REPO_ROOT, 'public', 'logo.svg');
|
||||
const OUT_DIR = path.join(REPO_ROOT, 'public', 'icons');
|
||||
|
||||
/** `--bg-dark` from src/ui/styles/foundation.css, so the tile matches the app. */
|
||||
const BACKGROUND = '#0a0a0f';
|
||||
|
||||
/**
|
||||
* Fraction of the canvas the mark is allowed to occupy.
|
||||
*
|
||||
* A maskable icon may be cropped to a circle inscribed in the middle 80% of the
|
||||
* square, so its mark stays well inside that circle; the other icons are shown
|
||||
* as drawn and only need optical breathing room.
|
||||
*/
|
||||
const INSET = { normal: 0.78, maskable: 0.54 };
|
||||
|
||||
const TARGETS = [
|
||||
{ file: 'icon-32.png', size: 32, inset: INSET.normal },
|
||||
{ file: 'icon-180.png', size: 180, inset: INSET.normal },
|
||||
{ file: 'icon-192.png', size: 192, inset: INSET.normal },
|
||||
{ file: 'icon-512.png', size: 512, inset: INSET.normal },
|
||||
{ file: 'icon-512-maskable.png', size: 512, inset: INSET.maskable },
|
||||
];
|
||||
|
||||
/** Fit the logo inside `size * inset` and centre it on an opaque square. */
|
||||
async function render(svg, { size, inset }) {
|
||||
const box = Math.round(size * inset);
|
||||
// The logo's viewBox is wider than it is tall, so `contain` bounds it by
|
||||
// width and leaves the vertical padding uneven — centring is done by the
|
||||
// composite below rather than by the resize.
|
||||
const mark = await sharp(svg, { density: 384 })
|
||||
.resize(box, box, {
|
||||
fit: 'contain',
|
||||
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
return sharp({
|
||||
create: {
|
||||
width: size,
|
||||
height: size,
|
||||
channels: 4,
|
||||
background: BACKGROUND,
|
||||
},
|
||||
})
|
||||
.composite([{ input: mark, gravity: 'centre' }])
|
||||
.png({ compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
const svg = await readFile(LOGO);
|
||||
await mkdir(OUT_DIR, { recursive: true });
|
||||
for (const target of TARGETS) {
|
||||
await writeFile(path.join(OUT_DIR, target.file), await render(svg, target));
|
||||
console.log(`icons: wrote ${target.file} (${target.size}px)`);
|
||||
}
|
||||
@@ -123,7 +123,12 @@
|
||||
"traffic-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/traffic"],
|
||||
"modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"],
|
||||
"modules": [
|
||||
"server/providers/traffic.js",
|
||||
"server/providers/common/http.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/data/tomtomTiles.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"traffic-source": {
|
||||
@@ -139,7 +144,12 @@
|
||||
"firms-provider": {
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/firms"],
|
||||
"modules": ["server/providers/firms.js", "src/data/firmsCsv.js"],
|
||||
"modules": [
|
||||
"server/providers/firms.js",
|
||||
"server/providers/common/http.js",
|
||||
"src/sources/httpBody.js",
|
||||
"src/data/firmsCsv.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"gbfs-provider": {
|
||||
@@ -148,6 +158,8 @@
|
||||
"modules": [
|
||||
"server/providers/gbfs.js",
|
||||
"server/providers/common/http.js",
|
||||
"server/providers/common/rate-limit.js",
|
||||
"src/sources/rateLimit.js",
|
||||
"src/data/gbfsSource.js",
|
||||
"src/sources/httpBody.js"
|
||||
],
|
||||
@@ -166,6 +178,7 @@
|
||||
"server/providers/cctv/cap.js",
|
||||
"server/providers/cctv/groundHeights.js",
|
||||
"server/providers/cctv/catalog.js",
|
||||
"server/providers/cctv/lithuania.js",
|
||||
"server/providers/cctv/constants.js",
|
||||
"server/providers/cctv/media.js",
|
||||
"server/providers/cctv/normalize.js",
|
||||
@@ -1176,6 +1189,7 @@
|
||||
"src/layers/cctv/ingestion.js",
|
||||
"src/layers/cctv/lifecycle.js",
|
||||
"src/layers/cctv/model.js",
|
||||
"src/layers/cctv/modelSpecs.js",
|
||||
"src/layers/cctv/navigation.js",
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
@@ -1237,7 +1251,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",
|
||||
@@ -1682,6 +1705,7 @@
|
||||
"src/layers/cctv/ingestion.js",
|
||||
"src/layers/cctv/lifecycle.js",
|
||||
"src/layers/cctv/model.js",
|
||||
"src/layers/cctv/modelSpecs.js",
|
||||
"src/layers/cctv/navigation.js",
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
@@ -2213,7 +2237,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",
|
||||
@@ -2239,7 +2272,11 @@
|
||||
"src/app/embed.js",
|
||||
"src/data/cameraFraming.js",
|
||||
"src/data/geoImportPins.js",
|
||||
"src/data/geoImportImages.js"
|
||||
"src/data/geoImportImages.js",
|
||||
"src/app/layers/airQuality.js",
|
||||
"src/layers/airQuality/index.js",
|
||||
"src/layers/airQuality/model.js",
|
||||
"src/layers/airQuality/source.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -2430,6 +2467,7 @@
|
||||
"src/layers/cctv/ingestion.js",
|
||||
"src/layers/cctv/lifecycle.js",
|
||||
"src/layers/cctv/model.js",
|
||||
"src/layers/cctv/modelSpecs.js",
|
||||
"src/layers/cctv/navigation.js",
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
@@ -2760,7 +2798,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",
|
||||
@@ -2780,7 +2827,11 @@
|
||||
"src/layers/installations/namedMarkers.js",
|
||||
"src/data/infrastructureOverlayEntry.js",
|
||||
"src/sources/infrastructureData.js",
|
||||
"src/data/cameraFraming.js"
|
||||
"src/data/cameraFraming.js",
|
||||
"src/app/layers/airQuality.js",
|
||||
"src/layers/airQuality/index.js",
|
||||
"src/layers/airQuality/model.js",
|
||||
"src/layers/airQuality/source.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -3161,7 +3212,9 @@
|
||||
"src/layers/perimeters/source.js",
|
||||
"src/layers/perimeters/records.js",
|
||||
"src/layers/submarineCables/bundledSource.js",
|
||||
"src/layers/gnss/source.js"
|
||||
"src/layers/gnss/source.js",
|
||||
"src/layers/airQuality/model.js",
|
||||
"src/layers/airQuality/source.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
@@ -3544,5 +3597,24 @@
|
||||
"src/data/layerStateTokenReservations.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"air-quality": {
|
||||
"exports": ["./layers/air-quality"],
|
||||
"modules": [
|
||||
"src/layers/airQuality/index.js",
|
||||
"src/layers/airQuality/model.js",
|
||||
"src/layers/airQuality/source.js",
|
||||
"src/sources/httpBody.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"air-quality-source": {
|
||||
"exports": ["./layers/air-quality/source"],
|
||||
"modules": [
|
||||
"src/layers/airQuality/model.js",
|
||||
"src/layers/airQuality/source.js",
|
||||
"src/sources/httpBody.js"
|
||||
],
|
||||
"external": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* qa-air-quality.mjs — headless proof for the Air quality layer.
|
||||
*
|
||||
* Checks what unit tests cannot: that the live ECCC feed answers, that readings
|
||||
* reach the app in the provider-shaped form other networks will share, and that
|
||||
* the Weather readout descriptor carries a legend and an in-view list.
|
||||
*
|
||||
* AQHI is reported by ECCC as an integer from 1 with "10+" above ten. The API
|
||||
* returns the underlying decimal, so a reading arriving as 1.08 — or a station
|
||||
* with no reading surfacing as a confident 1 — is a real defect this catches.
|
||||
*
|
||||
* Run: node scripts/qa-air-quality.mjs --url http://localhost:4173
|
||||
*/
|
||||
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const getOpt = (name, dflt) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
|
||||
};
|
||||
const APP_URL = getOpt('--url', 'http://localhost:4173');
|
||||
|
||||
const results = [];
|
||||
function record(name, ok, detail) {
|
||||
results.push({ name, ok, detail });
|
||||
const tag = ok ? '\x1b[32mPASS\x1b[0m' : '\x1b[31mFAIL\x1b[0m';
|
||||
console.log(` [${tag}] ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
}
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function main() {
|
||||
let exitCode = 0;
|
||||
const consoleErrors = [];
|
||||
const browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: [
|
||||
'--use-gl=swiftshader',
|
||||
'--enable-unsafe-swiftshader',
|
||||
'--no-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
],
|
||||
defaultViewport: { width: 1400, height: 900 },
|
||||
});
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
page.on('pageerror', (e) => consoleErrors.push(String(e).slice(0, 160)));
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 160));
|
||||
});
|
||||
|
||||
await page.goto(APP_URL, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__godsEyeView?.viewer && window.__godsEyeView?.dataManager,
|
||||
{ timeout: 90000 },
|
||||
);
|
||||
await sleep(1500);
|
||||
|
||||
const stats = await page.evaluate(async () => {
|
||||
const dm = window.__godsEyeView.dataManager;
|
||||
await dm.setEnabled('air-quality', true);
|
||||
const mod = dm.layers.get('air-quality')?.module;
|
||||
if (!mod) return { missing: true };
|
||||
let s = null;
|
||||
for (let i = 0; i < 45; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
s = mod.getStats();
|
||||
if (s.count > 0 || s.error) break;
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
const loaded = !stats?.missing && !stats?.error && stats?.count > 0;
|
||||
record('stations report a current index', loaded,
|
||||
`count=${stats?.count} error=${stats?.error}`);
|
||||
if (!loaded) exitCode = 1;
|
||||
|
||||
const records = await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager
|
||||
.layers.get('air-quality')
|
||||
?.module.getAnalystRecords(500) || [],
|
||||
);
|
||||
|
||||
// The provider-neutral contract other networks will share.
|
||||
const shaped = records.every(
|
||||
(r) => r.provider && r.scale && Number.isFinite(r.value) && r.band,
|
||||
);
|
||||
record('every reading carries provider, scale, value and band', shaped,
|
||||
records.length
|
||||
? `sample={provider:${records[0].provider}, scale:${records[0].scale}, value:${records[0].value}, band:${records[0].band}}`
|
||||
: 'no records');
|
||||
if (!shaped) exitCode = 1;
|
||||
|
||||
const published = records.every((r) => Number.isInteger(r.value) && r.value >= 1);
|
||||
record('every AQHI value is in the published integer form, floored at 1', published,
|
||||
`values=${[...new Set(records.map((r) => r.value))].sort((a, b) => a - b).join(',')}`);
|
||||
if (!published) exitCode = 1;
|
||||
|
||||
const zones = [...new Set(records.map((r) => r.zone).filter(Boolean))].sort();
|
||||
record('coverage spans multiple ECCC administrative zones', zones.length >= 3,
|
||||
`zones=${zones.join(',') || 'none'} stations=${records.length}`);
|
||||
if (zones.length < 3) exitCode = 1;
|
||||
|
||||
// The card's whole point is "highest readings in view", so put the camera
|
||||
// over Canada before asserting on it — the default view is over Austin,
|
||||
// where an empty in-view list is correct and proves nothing.
|
||||
await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const ellipsoid = gev.viewer.scene.globe.ellipsoid;
|
||||
const d2r = Math.PI / 180;
|
||||
try {
|
||||
gev.viewer.camera.cancelFlight();
|
||||
} catch {
|
||||
/* no flight in progress */
|
||||
}
|
||||
gev.viewer.camera.setView({
|
||||
destination: ellipsoid.cartographicToCartesian({
|
||||
longitude: -100 * d2r,
|
||||
latitude: 55 * d2r,
|
||||
height: 4_000_000,
|
||||
}),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
gev.viewer.scene.requestRender?.();
|
||||
});
|
||||
await sleep(2500);
|
||||
|
||||
const controls = await page.evaluate(
|
||||
() =>
|
||||
window.__godsEyeView.dataManager
|
||||
.layers.get('air-quality')
|
||||
?.module.getRowControls?.() || null,
|
||||
);
|
||||
const readoutOk =
|
||||
controls?.readout === true &&
|
||||
Array.isArray(controls?.legend?.colors) &&
|
||||
controls.legend.colors.length === controls.legend.labels.length &&
|
||||
Array.isArray(controls?.list?.items) &&
|
||||
controls.list.items.length > 0 &&
|
||||
controls.list.items.every((item) => item.id && item.label && item.value) &&
|
||||
/Canada only/.test(controls?.summary?.coverage || '');
|
||||
record('the Weather readout lists the worst readings in view, with a legend', readoutOk,
|
||||
controls
|
||||
? `bands=${controls.legend?.labels?.join('/')} inView=${controls.list?.items?.length} coverage="${controls.summary?.coverage}"`
|
||||
: 'no descriptor');
|
||||
if (!readoutOk) exitCode = 1;
|
||||
|
||||
record('no console errors', consoleErrors.length === 0,
|
||||
consoleErrors.length ? consoleErrors.slice(0, 2).join(' | ') : 'clean');
|
||||
if (consoleErrors.length) exitCode = 1;
|
||||
} catch (e) {
|
||||
console.error('\x1b[31mHarness error:\x1b[0m', e);
|
||||
exitCode = 3;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
const pass = results.filter((r) => r.ok === true).length;
|
||||
const fail = results.filter((r) => r.ok === false).length;
|
||||
console.log('\n' + '─'.repeat(60));
|
||||
console.log(` RESULT: ${pass} passed, ${fail} failed`);
|
||||
console.log('─'.repeat(60) + '\n');
|
||||
process.exit(exitCode || (fail > 0 ? 1 : 0));
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(3);
|
||||
});
|
||||
+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.
|
||||
@@ -2556,6 +2922,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 () => {
|
||||
@@ -2639,7 +3006,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(' | ')})`,
|
||||
|
||||
@@ -443,7 +443,10 @@ async function main() {
|
||||
// for nearby names. Answer both with empty results: the entity-context
|
||||
// assertions are about the SELECTED contact, and a live lookup would
|
||||
// make the run non-hermetic (and bill the owner's Google quota).
|
||||
if (url.hostname === 'maps.googleapis.com' && url.pathname.startsWith('/maps/api/geocode')) {
|
||||
if (
|
||||
isAppRequest &&
|
||||
(url.pathname === '/api/google/reverse-geocode' || url.pathname === '/api/google/geocode')
|
||||
) {
|
||||
return Promise.resolve(jsonResponse({ status: 'ZERO_RESULTS', results: [] }));
|
||||
}
|
||||
if (isAppRequest && url.pathname === '/api/google/nearby-places') {
|
||||
|
||||
Reference in new issue
Block a user