mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 15:22:21 +00:00
Merge main and switch Cesium and Navara in place through Vue
Preserve the camera, map source and layer selection across engine sessions. Restore the previous engine if startup fails and retain local history navigation.
This commit is contained in:
commit
b8f4d097c4
1691 files changed
+225876
-41594
No files matched your search
@@ -0,0 +1,366 @@
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import { tileBounds } from '../../../src/layers/streetLevel/tileMath.js';
|
||||
import { metresApart, photoJpeg } from './mapillaryGraph.mjs';
|
||||
|
||||
/**
|
||||
* KartaView's API, image proxy and photo storage answered from fixtures, so
|
||||
* the gate's KartaView steps need no network. One drive runs north along
|
||||
* KV_LINE; every tenth photo is a 360° one. Photos live on storage2 while
|
||||
* their `fileurl*` name storage1, as KartaView's API does, so the gate also
|
||||
* proves the provider builds addresses from `storage`. Every odd-indexed
|
||||
* photo is archived (storage answers 409, the image proxy still serves it),
|
||||
* so the nearest photo opened is even and › must skip the one after it. The
|
||||
* first nearest-photo lookup is rate-limited (429, Retry-After: 0), as
|
||||
* KartaView does under load.
|
||||
*
|
||||
* Beside the drive runs KV_OLD_STREET, photographed flat in 2019 only: the
|
||||
* imagery filter drops it. KartaView filters coverage only in its PNG
|
||||
* tiles, so filtered PNG tiles are drawn here for the streets that pass.
|
||||
*/
|
||||
|
||||
export const KV_SEQUENCE_ID = '900001';
|
||||
export const KV_LINE = Object.freeze({
|
||||
lon: -121.4931,
|
||||
south: 38.5795,
|
||||
north: 38.5875,
|
||||
});
|
||||
/** A street ~130 m east of the drive with only flat 2019 photos. */
|
||||
export const KV_OLD_STREET = Object.freeze({
|
||||
lon: -121.4916,
|
||||
south: 38.58,
|
||||
north: 38.587,
|
||||
});
|
||||
const OLD_STREET_UPLOADED = '2019-06-01';
|
||||
const KV_SPACING_M = 25;
|
||||
const METRES_PER_DEG_LAT = 110_540;
|
||||
/** KartaView's time format for `ms`. */
|
||||
const kartaViewTime = (ms) =>
|
||||
new Date(ms).toISOString().replace('T', ' ').slice(0, 23);
|
||||
/** The drive is a month old, whenever the gate runs: LAST YEAR keeps it. */
|
||||
const SHOT = kartaViewTime(Date.now() - 30 * 86_400_000);
|
||||
|
||||
const CORS = Object.freeze({ 'Access-Control-Allow-Origin': '*' });
|
||||
|
||||
/** Lookups answered so far: the first one is rate-limited. */
|
||||
let nearestLookups = 0;
|
||||
|
||||
/** Forget the lookups answered, so the next one is rate-limited again. */
|
||||
export function resetKartaViewFixture() {
|
||||
nearestLookups = 0;
|
||||
}
|
||||
|
||||
/** The drive's photos, south to north; every tenth is 360°, every odd one archived. */
|
||||
export function kartaViewPhotos() {
|
||||
const step = KV_SPACING_M / METRES_PER_DEG_LAT;
|
||||
const count = Math.floor((KV_LINE.north - KV_LINE.south) / step) + 1;
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: String(77_000_000 + i),
|
||||
index: i,
|
||||
lon: KV_LINE.lon,
|
||||
lat: Number((KV_LINE.south + i * step).toFixed(7)),
|
||||
isPano: i % 10 === 5,
|
||||
archived: i % 2 === 1,
|
||||
}));
|
||||
}
|
||||
|
||||
function record(photo) {
|
||||
const name = `${KV_SEQUENCE_ID}_${photo.id}.jpg`;
|
||||
const folder = photo.isPano ? 'wrapped_proc' : 'proc';
|
||||
const path = (prefix) => `2024/5/2/${prefix}/${name}`;
|
||||
const storage1 = (prefix) =>
|
||||
`https://storage1.openstreetcam.org/files/photo/${path(prefix)}`;
|
||||
return {
|
||||
id: photo.id,
|
||||
sequenceId: KV_SEQUENCE_ID,
|
||||
sequenceIndex: String(photo.index),
|
||||
lat: String(photo.lat),
|
||||
lng: String(photo.lon),
|
||||
matchLat: String(photo.lat),
|
||||
matchLng: String(photo.lon),
|
||||
// A dashcam phone's compass, disturbed by the car: the drive runs north.
|
||||
heading: '200',
|
||||
fieldOfView: photo.isPano ? '360' : '0',
|
||||
projection: photo.isPano ? 'SPHERE' : 'PLANE',
|
||||
shotDate: SHOT,
|
||||
dateAdded: SHOT,
|
||||
storage: 'storage2',
|
||||
filepath: path('{{sizeprefix}}'),
|
||||
filepathLTh: path('lth'),
|
||||
filepathProc: path(folder),
|
||||
fileurl: storage1('{{sizeprefix}}'),
|
||||
fileurlLTh: storage1('lth'),
|
||||
fileurlProc: storage1(folder),
|
||||
};
|
||||
}
|
||||
|
||||
const ok = (result) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
headers: CORS,
|
||||
body: JSON.stringify({ status: { apiCode: 600, httpCode: 200 }, result }),
|
||||
});
|
||||
const raw = (status, body) => ({
|
||||
status,
|
||||
contentType: 'application/json',
|
||||
headers: CORS,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
/** The streets with photos: the drive, and the old street beside it. */
|
||||
function streets() {
|
||||
return [
|
||||
{ line: KV_LINE, uploaded: SHOT.slice(0, 10), views: ['360', 'plane'] },
|
||||
{ line: KV_OLD_STREET, uploaded: OLD_STREET_UPLOADED, views: ['plane'] },
|
||||
];
|
||||
}
|
||||
|
||||
/** A street's stretch inside tile `z/x/y`, as [from, to] latitudes, or null. */
|
||||
function stretch(line, z, x, y) {
|
||||
const { west, east, south, north } = tileBounds(x, y, z);
|
||||
if (line.lon < west || line.lon > east) return null;
|
||||
const from = Math.max(line.south, south);
|
||||
const to = Math.min(line.north, north);
|
||||
return from < to ? [from, to] : null;
|
||||
}
|
||||
|
||||
/** The streets' tracks inside tile `z/x/y`, as KartaView's JSON tile. */
|
||||
function tileLines(z, x, y) {
|
||||
const lines = [];
|
||||
for (const { line } of streets()) {
|
||||
const inside = stretch(line, z, x, y);
|
||||
if (!inside) continue;
|
||||
const [from, to] = inside;
|
||||
// Two segments meeting end to start, as KartaView splits ways.
|
||||
const mid = (from + to) / 2;
|
||||
const at = (lat) => ({ lat: String(lat), lng: String(line.lon) });
|
||||
lines.push(
|
||||
{ track: [at(from), at(mid)], matched: '1' },
|
||||
{ track: [at(mid), at(to)], matched: '1' },
|
||||
);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Whether a street has photos passing a filtered tile's query. */
|
||||
function passesTileQuery(street, query) {
|
||||
const view = query.get('fieldOfView');
|
||||
if (view && !street.views.includes(view)) return false;
|
||||
const since = query.get('startDate');
|
||||
return !since || street.uploaded >= since;
|
||||
}
|
||||
|
||||
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
return c >>> 0;
|
||||
});
|
||||
function crc32(bytes) {
|
||||
let c = 0xffffffff;
|
||||
for (const byte of bytes) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
function pngChunk(type, data) {
|
||||
const head = Buffer.alloc(8);
|
||||
head.writeUInt32BE(data.length, 0);
|
||||
head.write(type, 4, 'ascii');
|
||||
const crc = Buffer.alloc(4);
|
||||
crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
|
||||
return Buffer.concat([head, data, crc]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A filtered coverage tile as KartaView draws it: 512 px, transparent, the
|
||||
* passing streets three pixels wide.
|
||||
*/
|
||||
export function maskTilePng(z, x, y, query) {
|
||||
const size = 512;
|
||||
const rgba = Buffer.alloc(size * size * 4);
|
||||
const { west, east } = tileBounds(x, y, z);
|
||||
const n = 2 ** z;
|
||||
const row = (lat) => {
|
||||
const sin = Math.sin((lat * Math.PI) / 180);
|
||||
const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI);
|
||||
return Math.round((my * n - y) * size);
|
||||
};
|
||||
for (const street of streets()) {
|
||||
if (!passesTileQuery(street, query)) continue;
|
||||
const inside = stretch(street.line, z, x, y);
|
||||
if (!inside) continue;
|
||||
const column = Math.round(
|
||||
((street.line.lon - west) / (east - west)) * size,
|
||||
);
|
||||
const top = Math.max(0, row(inside[1]));
|
||||
const bottom = Math.min(size - 1, row(inside[0]));
|
||||
for (let py = top; py <= bottom; py++)
|
||||
for (let px = column - 1; px <= column + 1; px++) {
|
||||
if (px < 0 || px >= size) continue;
|
||||
rgba.set([99, 91, 255, 255], (py * size + px) * 4);
|
||||
}
|
||||
}
|
||||
const rows = Buffer.alloc(size * (size * 4 + 1));
|
||||
for (let py = 0; py < size; py++)
|
||||
rgba.copy(
|
||||
rows,
|
||||
py * (size * 4 + 1) + 1,
|
||||
py * size * 4,
|
||||
(py + 1) * size * 4,
|
||||
);
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(size, 0);
|
||||
header.writeUInt32BE(size, 4);
|
||||
header.set([8, 6, 0, 0, 0], 8);
|
||||
return Buffer.concat([
|
||||
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
pngChunk('IHDR', header),
|
||||
pngChunk('IDAT', deflateSync(rows)),
|
||||
pngChunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
/** A call named by host, path shape and parameter names. */
|
||||
export function describeKartaViewCall(method, url) {
|
||||
const keys = [...url.searchParams.keys()].sort();
|
||||
const path = url.pathname
|
||||
.replace(/\/tiles\/\d+\/\d+\/\d+\.json$/, '/tiles/{x}/{y}/{z}.json')
|
||||
.replace(/\/tiles\/\d+\/\d+\/\d+\.png$/, '/tiles/{x}/{y}/{z}.png')
|
||||
.replace(/\/photo\/\d+$/, '/photo/{id}')
|
||||
.replace(/\/sequence\/\d+\/photos$/, '/sequence/{id}/photos')
|
||||
.replace(/\/sequence\/\d+$/, '/sequence/{id}')
|
||||
.replace(/^\/files\/photo\/.*$/, '/files/photo/{path}')
|
||||
.replace(
|
||||
/^\/pr:sharp\/rs:fit:\d+:\d+\/.*$/,
|
||||
'/pr:sharp/rs:fit:{w}:{h}/{address}',
|
||||
);
|
||||
return `${method} ${url.hostname}${path}${keys.length ? `?${keys.join('&')}` : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Answer one KartaView request; `known` is false for a call no fixture
|
||||
* covers, so the gate fails on a new one instead of missing it.
|
||||
*/
|
||||
export function answerKartaViewRequest({ method, url }) {
|
||||
const address = new URL(url);
|
||||
const photos = kartaViewPhotos();
|
||||
if (method === 'OPTIONS')
|
||||
return { status: 204, headers: CORS, body: '', known: true };
|
||||
const image = {
|
||||
status: 200,
|
||||
contentType: 'image/jpeg',
|
||||
headers: CORS,
|
||||
body: photoJpeg(),
|
||||
known: true,
|
||||
};
|
||||
if (address.hostname === 'cdn.kartaview.org')
|
||||
return /^\/pr:sharp\/rs:fit:\d+:\d+\/[\w-]+$/.test(address.pathname)
|
||||
? image
|
||||
: { ...raw(404, {}), known: false };
|
||||
if (address.hostname === 'storage2.openstreetcam.org') {
|
||||
if (!address.pathname.includes(KV_SEQUENCE_ID))
|
||||
return { ...raw(404, {}), known: false };
|
||||
const archived = photos.some(
|
||||
(photo) =>
|
||||
photo.archived && address.pathname.endsWith(`_${photo.id}.jpg`),
|
||||
);
|
||||
// An archived photo's file is gone from storage: KartaView answers 409.
|
||||
return archived ? { ...raw(409, {}), known: true } : image;
|
||||
}
|
||||
if (/^storage\d+\.openstreetcam\.org$/.test(address.hostname))
|
||||
// The wrong host the API names: KartaView answers 404 there.
|
||||
return { ...raw(404, {}), known: false };
|
||||
if (address.hostname !== 'api.kartaview.org')
|
||||
return { ...raw(404, {}), known: false };
|
||||
const path = address.pathname;
|
||||
const tile = path.match(
|
||||
/^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.json$/,
|
||||
);
|
||||
if (tile)
|
||||
return {
|
||||
...raw(200, {
|
||||
lines: tileLines(Number(tile[3]), Number(tile[1]), Number(tile[2])),
|
||||
}),
|
||||
known: true,
|
||||
};
|
||||
// Filtered coverage: KartaView filters only its PNG tiles.
|
||||
const mask = path.match(
|
||||
/^\/2\.0\/sequence\/tiles\/(\d+)\/(\d+)\/(\d+)\.png$/,
|
||||
);
|
||||
if (mask)
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
headers: CORS,
|
||||
body: maskTilePng(
|
||||
Number(mask[3]),
|
||||
Number(mask[1]),
|
||||
Number(mask[2]),
|
||||
address.searchParams,
|
||||
),
|
||||
known: true,
|
||||
};
|
||||
if (path === '/2.0/photo/') {
|
||||
if (nearestLookups++ === 0)
|
||||
return {
|
||||
...raw(429, {}),
|
||||
headers: {
|
||||
...CORS,
|
||||
'Retry-After': '0',
|
||||
'Access-Control-Expose-Headers': 'Retry-After',
|
||||
},
|
||||
known: true,
|
||||
};
|
||||
const q = address.searchParams;
|
||||
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
|
||||
// Its own filter, as KartaView's: projection, and upload date.
|
||||
const projection = q.get('projection');
|
||||
const since = q.get('startDate');
|
||||
const near = photos
|
||||
.filter(
|
||||
(photo) =>
|
||||
(!projection || projection === (photo.isPano ? 'SPHERE' : 'PLANE')) &&
|
||||
(!since || SHOT.slice(0, 10) >= since),
|
||||
)
|
||||
.map((photo) => ({ photo, metres: metresApart(at, photo) }))
|
||||
.filter(({ metres }) => metres < 200)
|
||||
.sort((a, b) => a.metres - b.metres)
|
||||
.slice(0, Number(q.get('itemsPerPage')) || 10)
|
||||
.map(({ photo, metres }) => ({
|
||||
...record(photo),
|
||||
distance: metres.toFixed(2),
|
||||
}));
|
||||
return { ...ok({ data: near, hasMoreData: false }), known: true };
|
||||
}
|
||||
const one = path.match(/^\/2\.0\/photo\/(\d+)$/);
|
||||
if (one) {
|
||||
const photo = photos.find((p) => p.id === one[1]);
|
||||
return photo
|
||||
? { ...ok({ data: record(photo) }), known: true }
|
||||
: { ...raw(200, { status: { apiCode: 601 }, result: {} }), known: true };
|
||||
}
|
||||
const sequence = path.match(/^\/2\.0\/sequence\/(\d+)$/);
|
||||
if (sequence)
|
||||
return {
|
||||
...ok({
|
||||
data: {
|
||||
id: sequence[1],
|
||||
countActivePhotos:
|
||||
sequence[1] === KV_SEQUENCE_ID ? String(photos.length) : '0',
|
||||
},
|
||||
}),
|
||||
known: true,
|
||||
};
|
||||
const page = path.match(/^\/2\.0\/sequence\/(\d+)\/photos$/);
|
||||
if (page) {
|
||||
const q = address.searchParams;
|
||||
const size = Number(q.get('itemsPerPage')) || 100;
|
||||
const number = Number(q.get('page')) || 1;
|
||||
const rows = page[1] === KV_SEQUENCE_ID ? photos : [];
|
||||
return {
|
||||
...ok({
|
||||
data: rows.slice((number - 1) * size, number * size).map(record),
|
||||
hasMoreData: number * size < rows.length,
|
||||
}),
|
||||
known: true,
|
||||
};
|
||||
}
|
||||
return { ...raw(404, {}), known: false };
|
||||
}
|
||||
@@ -2,8 +2,8 @@ import { readFileSync } from 'node:fs';
|
||||
|
||||
/**
|
||||
* Mapillary Graph API and image CDN answered from fixtures, covering every
|
||||
* request the provider and MapillaryJS make while opening, stepping and
|
||||
* closing a photo, so the gate needs no network and no real token.
|
||||
* request the provider and MapillaryJS make while opening and closing a
|
||||
* photo, so the gate needs no network and no real token.
|
||||
*
|
||||
* No `merge_cc` or `sfm_cluster` means MapillaryJS never asks for a mesh or
|
||||
* cluster, and empty S2 cells mean no spatial edges, which the gate does not
|
||||
@@ -21,7 +21,7 @@ export const PHOTO_LINE = Object.freeze({
|
||||
north: 38.591,
|
||||
});
|
||||
/** Metres between consecutive photos on the line (≥ the 3 m cone thinning). */
|
||||
export const PHOTO_SPACING_M = 30;
|
||||
const PHOTO_SPACING_M = 30;
|
||||
/** Fixture thumbnail host (answered, never reached). */
|
||||
export const THUMB_HOST = 'qa-fixture.mapillary.com';
|
||||
|
||||
@@ -181,18 +181,6 @@ export function answerMapillaryRequest({ method, url, images }) {
|
||||
: [];
|
||||
return { ...json(200, { data }), known: true };
|
||||
}
|
||||
if (q.has('lat') && q.has('lng')) {
|
||||
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
|
||||
const radius = Math.min(50, Number(q.get('radius')) || 50);
|
||||
const limit = Number(q.get('limit')) || 10;
|
||||
// In no particular order, as the real API answers.
|
||||
const data = photos
|
||||
.filter((image) => metresApart(at, image) <= radius)
|
||||
.reverse()
|
||||
.slice(0, limit)
|
||||
.map((image) => withFields(imageRecord(image), fields));
|
||||
return { ...json(200, { data }), known: true };
|
||||
}
|
||||
if (q.has('s2')) return { ...json(200, { data: [] }), known: true };
|
||||
return {
|
||||
...json(400, { error: 'unsupported images query' }),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
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 });
|
||||
}
|
||||
Reference in new issue
Block a user