feat(street-level): Panoramax as a fourth, keyless imagery provider

Panoramax, the open federated street-level commons, registers beside
Mapillary, Google Street View and KartaView. Its meta-catalogue
(api.panoramax.xyz) is keyless and CORS-open, so it runs in the browser:

- coverage from its vector tiles: sequence lines at z11–14 below 30 km
  (longest first under the per-tile cap), and above that the grid layer's
  density cells as binned points, not counted as sequences;
- a click on a line opens that sequence's picture nearest the click
  (STAC search, any direction), without moving the camera, then any
  sequence's if it has none within 50 m; the 360° and since filters go to
  the lines and to the API;
- cones from the z15 tiles' pictures layer around the open picture
  (~110 bytes a picture, against ~7.5 kB for a STAC item), re-centred when
  a picture opens outside them; ‹ › follow the picture's prev/next links;
- the caption names the author (not an account named by an email
  address) and the picture's licence (StreetPose `license`), and links to
  the picture in the Panoramax viewer; the globe credits Panoramax.

KartaView's viewer becomes the shared photoViewer.js (flat <img>, 360° on
panorama.js, ‹ ›, keyboard, pinch, context loss), driven by a per-provider
PhotoSources table; the request limiter moves to limiter.js. Panoramax shows
the 2,048 px SD derivative and fetches the HD original only when the viewer
is wide enough, once, shrunk while it decodes (never above 8,192 px).

Orange #ff6f00 (Panoramax's own), off by default, share option
0.m.0_0.r.panoramax-1. The MCP panel allows the meta-catalogue and the
biggest instances. Tests: policy, decode, source, viewer, provider, and a
Panoramax fixture in the hermetic browser gate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-14 13:10:40 +01:00
1 parent c99eb93174
commit 00d2b73b6a
37 files changed
+4235 -711

No files matched your search

+256
View File
@@ -0,0 +1,256 @@
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))],
};
}
/** 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 [lon, lat] = (q.get('place_position') || '').split(',').map(Number);
const radius = Number((q.get('place_distance') || '0-50').split('-')[1]);
const filter = q.get('filter') || '';
const since = q.get('datetime')?.split('/')[0];
const near = 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))
.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 });
}