Files
gods-eye-view/src/layers/streetLevel/providers/panoramax/policy.js
T
German KhokhlovandClaude Opus 5.5 6390a3346f Merge origin/main: Google Street View, KartaView and Panoramax on the slim Street Level
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]>
2026-10-19 13:51:45 +04:00

313 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
},
};
}