mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Image loading (photoViewer.js) - One AbortController per open: a new open, close or unmount stops the previous photo's first and sharper downloads (an <img> by src = ''). A collapsed viewer never falls back to the original; Panoramax's flat fallback is the thumbnail. A superseded open resolves quietly. - Flat HD tracked as "loading" and "shown" apart: a discarded response no longer blocks later upgrades, and resizes while it loads ask once. - No upgrade of the photo still shown while another opens. - Size limits before decoding: imageSize.js reads JPEG/PNG/WebP/GIF headers; more than 64 MP or 16,384 px a side is refused undecoded, an <img> that decodes larger is refused and let go, flat HD needs a known width of at most 8,192 px, and panoramas always decode with a resize. Panoramax provider - Retry-After parsed as seconds or an HTTP date; the browser cannot read it here, so busy answers back off 5 s doubling to 300 s, reset once answered. - Headers timed, then the body by an idle timeout (15 s of silence), so a slow link still gets its megabyte tiles. - Cones centre only on the picture shown (onShowing), never on a late lookup; lookups nobody waits for are aborted (shared, refcounted). - onReopen: a GPU reset redraws in the viewer, not through the core. - Cones draw the home z15 tile at once and add neighbours as they arrive; a neighbour that failed is not counted as drawn. - Coverage tiles decode in ~8 ms slices (a 2 MB z10 tile over Paris was one ~85-200 ms task); sorted by measured length without a Map. - Box search asks for 20, not 100 (the API returns them nearest first). - looksLikeEmail checks every '@'; creatorOf de-duplicates with a Set. lineCoverage.js - A redraw while a swap still builds keeps what is on screen (the swap's old lines and its ready new ones) and drops only the unready ones. Tests for each (mutation-checked), two vacuous tests made real, the cones' pictures lane and readable()'s busy/timeout paths covered; the fixture's box search is nearest first. Docs: README filters and share links, CHANGELOG HD wording, CURRENT-STATE. Co-Authored-By: Claude Opus 5.5 <[email protected]>
284 lines
9.2 KiB
JavaScript
284 lines
9.2 KiB
JavaScript
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 });
|
|
}
|