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]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-19 13:51:45 +04:00
commit 6390a3346f
809 files changed
+81825 -6035

No files matched your search

@@ -0,0 +1,312 @@
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),
},
};
}