mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Main gained three imagery providers (Google Street View, KartaView,
Panoramax), shared line-coverage, cone and photo-viewer modules, one source
lit at a time and the translated interface. Their shared modules still
carried what this branch removes, so the merge ports them onto it:
- lineCoverage.js is this branch's coverage.js made provider-neutral
(draped lines near the ground only, the selection drawn over them, key
and rate-limit blocking); sequenceCones.js is sequences.js the same way.
Mapillary, KartaView and Panoramax all build on them; Mapillary moves
back to providers/mapillary/ beside the others.
- No ground casting, mesh sampling, surface modes, FOLLOW, legend, prewarm,
orbit overview points (Panoramax's density dots included), stale-tile
swaps or water-filled point budget come back.
- Kept from main because the new providers need them: requiresMapStack and
attachMapStackController (Street View on Google 3D only), ground clicks,
openNearest with an optional provider nearestImage, openImage {frame},
reportError, the view-cone marker (now an SVG glyph), filters /
coverageLabel / sinceBasis, OPEN STREET VIEW, keyAlternatives and i18n.
- photoViewer.js treats the viewer as expanded when its wrap is full screen
or the iPhone popover, as EXPAND now is.
- The fixture browser gate keeps its 8 steps and adds one each for
KartaView and Panoramax; catalogs, docs, boundaries and format scope
follow.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
313 lines
12 KiB
JavaScript
313 lines
12 KiB
JavaScript
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
|
||
import { msg } from '../../../../i18n/index.js';
|
||
|
||
export { NEAREST_RADIUS_M } from '../../policy.js';
|
||
|
||
/** Identity and tuning for the Panoramax street-level provider (keyless). */
|
||
export const PANORAMAX_PROVIDER_ID = 'panoramax';
|
||
export const PANORAMAX_NAME = 'Panoramax';
|
||
export const PANORAMAX_LABEL = 'PANORAMAX';
|
||
|
||
/**
|
||
* The Panoramax meta-catalogue: one keyless, CORS-enabled STAC API over
|
||
* every federated instance (OpenStreetMap France, IGN, MapComplete…). Its
|
||
* pictures are served by the instance that holds them.
|
||
*/
|
||
export const PANORAMAX_API = 'https://api.panoramax.xyz/api';
|
||
/** The meta-catalogue's web viewer, where the caption link goes. */
|
||
export const PANORAMAX_VIEWER = 'https://api.panoramax.xyz/';
|
||
|
||
/** Stable id prefixes for picked primitives; every one starts with `pnx:`. */
|
||
export const PICK_PREFIX = Object.freeze({
|
||
root: 'pnx:',
|
||
sequence: 'pnx:seq:',
|
||
image: 'pnx:img:',
|
||
});
|
||
|
||
export const COLORS = Object.freeze({
|
||
coverage: PROVIDER_COLORS.panoramax,
|
||
selected: SHARED_COLORS.selected,
|
||
});
|
||
|
||
/**
|
||
* Sequence lines come from z7–15 tiles; z10–14 are drawn below 60 km, as
|
||
* Mapillary's are (a dense tile over Paris: z10 ~2.1 MB and 8,600
|
||
* sequences, z11 ~1.4 MB, z12 ~800 kB). Higher up nothing is drawn.
|
||
*/
|
||
export const COVERAGE_Z14_BELOW_M = 1_800;
|
||
export const COVERAGE_Z13_BELOW_M = 5_000;
|
||
export const COVERAGE_Z12_BELOW_M = 12_000;
|
||
export const COVERAGE_Z11_BELOW_M = 30_000;
|
||
export const COVERAGE_LINES_BELOW_M = 60_000;
|
||
export const COVERAGE_TUNING = Object.freeze({
|
||
maxTiles: 9,
|
||
// Each tile draws at most 1,600 lines, its longest first (as decoded,
|
||
// `keepOrder`): a dense z11
|
||
// tile over Paris holds 3,800, and a lower cap left the city centre
|
||
// looking emptier than its suburbs. (KartaView draws as many.)
|
||
maxSequences: 14_400,
|
||
keepOrder: true,
|
||
// Points drawn at most, over every tile, a ninth of it a tile (each
|
||
// becomes ~8 ground-line vertices, ~1.4 kB of geometry): the simplified
|
||
// Paris z10 tile alone holds 57,000, unsimplified 750,000, which hung
|
||
// the tab.
|
||
maxPoints: 90_000,
|
||
lineWidthPx: 2.5,
|
||
moveDebounceMs: 320,
|
||
viewRangeMinM: 2_500,
|
||
viewRangePerHeight: 10,
|
||
});
|
||
/**
|
||
* Lines are simplified (Douglas–Peucker) to this many tile units of a
|
||
* 4,096 extent: about a pixel where each zoom is drawn (1.6 m at z14,
|
||
* within GPS noise), and 6–10% of the points GPS tracks carry.
|
||
*/
|
||
export const SIMPLIFY_TOLERANCE_UNITS = 4;
|
||
|
||
/**
|
||
* Coverage tiles decode in slices of this many ms, the page getting a turn
|
||
* between them (decode.js `decodeCoverageTileSliced`): under a frame.
|
||
*/
|
||
export const DECODE_SLICE_MS = 8;
|
||
|
||
export const RATE_LIMITED_MESSAGE = msg(
|
||
'Panoramax is busy — coverage resumes on its own',
|
||
);
|
||
export const NO_COVERAGE_HINT = msg('no Panoramax photos in view');
|
||
/** Lines in view, all hidden by the imagery filter. */
|
||
export const FILTERED_HINT = msg('no Panoramax photos here match the filters');
|
||
export const UNREADABLE_TILE_MESSAGE = msg(
|
||
'Panoramax sent a map tile that could not be read',
|
||
);
|
||
export const GONE_MESSAGE = msg('This Panoramax photo is no longer available');
|
||
/** Above the line ceiling nothing is drawn. */
|
||
export const ZOOM_IN_HINT = msg('zoom in to see Panoramax coverage');
|
||
|
||
/** Cones come from z15 tiles' `pictures` layer (~110 bytes a picture). */
|
||
export const PICTURE_TILE_ZOOM = 15;
|
||
/** Within this of a z15 tile's edge, the neighbouring tile's pictures join. */
|
||
export const PICTURE_TILE_EDGE_M = 150;
|
||
|
||
export const TILE_TIMEOUT_MS = 20_000;
|
||
/**
|
||
* Once an answer has started, it may take as long as it keeps coming: a
|
||
* 2 MB tile on a slow link outlasts any whole-request timeout, so only a
|
||
* stream silent this long is given up.
|
||
*/
|
||
export const STREAM_IDLE_TIMEOUT_MS = 15_000;
|
||
/** Busy (429/503) without a Retry-After the browser can read: back off from this. */
|
||
export const BUSY_BACKOFF_BASE_SEC = 5;
|
||
export const API_TIMEOUT_MS = 12_000;
|
||
/** Real tiles are under 6 MB (z6 over France); refuse anything absurd. */
|
||
export const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
||
/**
|
||
* The meta-catalogue speaks HTTP/1.1 only, so the browser opens six
|
||
* connections to it: coverage tiles take three, searches two and the
|
||
* cones' tiles one, so a click's lookup never queues behind megabyte tiles
|
||
* (it timed out doing so).
|
||
*/
|
||
export const API_CONCURRENCY = 2;
|
||
export const TILE_CONCURRENCY = 3;
|
||
/** The selected sequence's z15 tiles (cones) take the sixth. */
|
||
export const PICTURE_TILE_CONCURRENCY = 1;
|
||
|
||
/** Pictures asked for around a point; the API returns them nearest first. */
|
||
export const NEAREST_LIMIT = 10;
|
||
/**
|
||
* The widest the API allows: the point may be beside a picture, but a
|
||
* picture looking away from it is still left out (the bounding-box search
|
||
* beside it has those).
|
||
*/
|
||
export const NEAREST_FOV_TOLERANCE_DEG = 180;
|
||
/**
|
||
* Pictures in the bounding box around a click, at most. The API returns
|
||
* them roughly nearest first (the first ten of 100 are the first ten of
|
||
* 10), and the browser ranks them exactly: 20 is ~60 kB, 100 ~600 kB.
|
||
*/
|
||
export const NEAREST_BOX_LIMIT = 20;
|
||
/**
|
||
* A line clicked from high up may be tens of metres off the click: its
|
||
* sequence is searched within the camera height / 100 (50–400 m).
|
||
*/
|
||
export const LINE_CLICK_RADIUS_PER_HEIGHT = 0.01;
|
||
export const LINE_CLICK_RADIUS_MAX_M = 400;
|
||
|
||
/** A full-resolution panorama is only fetched up to this width (memory). */
|
||
export const PANO_HD_MAX_WIDTH = 8_192;
|
||
/** An HD panorama can be several megabytes. */
|
||
export const HD_TIMEOUT_MS = 30_000;
|
||
/** Panoramax's SD derivative is 2,048 px wide, its thumbnail 500 px. */
|
||
export const SD_WIDTH = 2_048;
|
||
|
||
/** On-globe credit: licences are per picture, named in the caption. */
|
||
export const PANORAMAX_CREDIT_HTML =
|
||
'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture';
|
||
|
||
export const LINE_CLICK_HINT = 'click a line for its nearest photo';
|
||
|
||
/** Licence ids Panoramax uses, as the caption names them. */
|
||
const LICENSE_LABELS = Object.freeze({
|
||
'CC-BY-SA-4.0': 'CC BY-SA 4.0',
|
||
'CC-BY-4.0': 'CC BY 4.0',
|
||
'CC0-1.0': 'CC0',
|
||
'etalab-2.0': 'Licence Ouverte 2.0',
|
||
});
|
||
|
||
/** The caption's licence name for a STAC licence id, or null. */
|
||
export function licenseLabel(license) {
|
||
if (typeof license !== 'string' || !license.trim()) return null;
|
||
const id = license.trim();
|
||
if (id === 'proprietary' || id === 'other') return null;
|
||
return LICENSE_LABELS[id] || id.slice(0, 40);
|
||
}
|
||
|
||
/** Deep link to a picture in the Panoramax web viewer. */
|
||
export function panoramaxPictureUrl(pictureId) {
|
||
const id = String(pictureId ?? '').trim();
|
||
if (!id) return PANORAMAX_VIEWER;
|
||
return `${PANORAMAX_VIEWER}#${new URLSearchParams({ pic: id, focus: 'pic' })}`;
|
||
}
|
||
|
||
/** Epoch ms for a capture time ('2023-12-12T07:54:47+00:00', '2023-12-12 07:56:37+00'), or null. */
|
||
export function panoramaxTimeMs(value) {
|
||
if (typeof value !== 'string' || !value) return null;
|
||
// Tile timestamps are Postgres text: a space and an hour-only offset.
|
||
const iso = /\d[T ]\d/.test(value)
|
||
? value
|
||
.replace(' ', 'T')
|
||
.replace(/([+-]\d{2})$/, '$1:00')
|
||
.replace(/\.(\d{3})\d+/, '.$1')
|
||
: value;
|
||
const ms = Date.parse(iso);
|
||
return Number.isFinite(ms) && ms > Date.UTC(1990, 0, 1) ? ms : null;
|
||
}
|
||
|
||
/** An https address, or null (asset links come from federated instances). */
|
||
function httpsUrl(value) {
|
||
try {
|
||
const url = new URL(String(value));
|
||
return url.protocol === 'https:' ? url.href : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Last path segment of an item's `rel` link: the linked picture's id. */
|
||
function linkedId(item, rel) {
|
||
const href = item?.links?.find?.((link) => link?.rel === rel)?.href;
|
||
if (typeof href !== 'string') return null;
|
||
const id = href.split(/[?#]/)[0].split('/').filter(Boolean).pop();
|
||
if (!id) return null;
|
||
try {
|
||
return decodeURIComponent(id);
|
||
} catch {
|
||
// A malformed escape is no link, not a failed lookup.
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* An email address anywhere in a name, at any of its '@'s. Linear: names
|
||
* come from any instance, and each run between '@'s is scanned once.
|
||
*/
|
||
function looksLikeEmail(name) {
|
||
return /[^\s@]@[^\s@]+\.[^\s@]/.test(name);
|
||
}
|
||
|
||
/**
|
||
* Who took the picture. Producers are account names, and some accounts
|
||
* are named by an email address: those are not repeated in the caption.
|
||
*/
|
||
function creatorOf(item) {
|
||
const props = item.properties || {};
|
||
const names = [
|
||
props['geovisio:producer'],
|
||
...(Array.isArray(item.providers)
|
||
? item.providers
|
||
.filter((p) => p?.roles?.includes?.('producer'))
|
||
.map((p) => p.name)
|
||
: []),
|
||
];
|
||
// Every producer is credited (an uploading app and its photographer),
|
||
// each name once, none that is an email address.
|
||
const credited = [];
|
||
const seen = new Set();
|
||
for (const value of names) {
|
||
if (typeof value !== 'string') continue;
|
||
const name = value.trim().slice(0, 80);
|
||
if (!name || looksLikeEmail(value)) continue;
|
||
const key = name.toLowerCase();
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
credited.push(name);
|
||
}
|
||
return credited.length ? credited.join(', ').slice(0, 120) : null;
|
||
}
|
||
|
||
/** The federated instance holding a picture (its `via` link's host), or null. */
|
||
function instanceOf(item) {
|
||
const href = item?.links?.find?.((link) => link?.rel === 'via')?.href;
|
||
try {
|
||
const url = new URL(String(href));
|
||
return url.protocol === 'https:' ? url.hostname.slice(0, 80) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** A 360° picture: a full-sphere field of view or an equirectangular projection. */
|
||
export function isEquirectangular(item) {
|
||
const props = item?.properties || {};
|
||
const orientation = props['pers:interior_orientation'];
|
||
if (Number(orientation?.field_of_view) >= 360) return true;
|
||
return props['pers:projection'] === 'equirectangular';
|
||
}
|
||
|
||
/**
|
||
* A STAC item (from search or a collection) in the shape the provider and
|
||
* viewer use, or null without an id or a position.
|
||
* @returns {{id: string, sequenceId: string|null, lon: number, lat: number, heading: number|null, isPano: boolean, capturedAt: number|null, creator: string|null, license: string|null, width: number|null, prevId: string|null, nextId: string|null, urls: {thumb: string|null, sd: string|null, hd: string|null}}|null}
|
||
*/
|
||
export function normalizeItem(item) {
|
||
if (!item || item.id == null) return null;
|
||
// Only numbers: Number(null) and Number('') would put a picture at 0,0.
|
||
const [lon, lat] = Array.isArray(item.geometry?.coordinates)
|
||
? item.geometry.coordinates.map((value) =>
|
||
typeof value === 'number' ? value : NaN,
|
||
)
|
||
: [NaN, NaN];
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
|
||
const props = item.properties || {};
|
||
const assets = item.assets || {};
|
||
const azimuth =
|
||
props['view:azimuth'] === '' || props['view:azimuth'] == null
|
||
? NaN
|
||
: Number(props['view:azimuth']);
|
||
const width = Number(
|
||
props['pers:interior_orientation']?.sensor_array_dimensions?.[0],
|
||
);
|
||
return {
|
||
id: String(item.id),
|
||
sequenceId: item.collection != null ? String(item.collection) : null,
|
||
lon,
|
||
lat,
|
||
heading: Number.isFinite(azimuth) ? ((azimuth % 360) + 360) % 360 : null,
|
||
isPano: isEquirectangular(item),
|
||
capturedAt: panoramaxTimeMs(props.datetime),
|
||
creator: creatorOf(item),
|
||
// Licences ask for the source to be named: where no author can be
|
||
// named, the caption names the instance holding the picture.
|
||
via: instanceOf(item),
|
||
license: licenseLabel(props.license),
|
||
width: Number.isFinite(width) && width > 0 ? width : null,
|
||
prevId: linkedId(item, 'prev'),
|
||
nextId: linkedId(item, 'next'),
|
||
urls: {
|
||
thumb: httpsUrl(assets.thumb?.href),
|
||
sd: httpsUrl(assets.sd?.href),
|
||
hd: httpsUrl(assets.hd?.href),
|
||
},
|
||
};
|
||
}
|