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

+705
View File
@@ -0,0 +1,705 @@
import {
createPanoramaRenderer,
PANO_FOV_DEFAULT_DEG,
wrapDegrees,
} from './panorama.js';
/** A photo download (tens of kilobytes to a few megabytes). */
export const IMAGE_TIMEOUT_MS = 15_000;
/** Panorama texture widths: enough for a docked viewer, and for an expanded one. */
export const PANO_MID_SIZE = Object.freeze([2048, 1024]);
export const PANO_FULL_SIZE = Object.freeze([4096, 2048]);
/**
* Load an image within the timeout. A CORS-clean one (for WebGL) is decoded
* off the main thread into an ImageBitmap where the browser can: uploading
* a decoded bitmap costs a fraction of uploading an <img> (90 ms → 15 ms for
* 4096×2048 on a phone-class CPU). `resize` shrinks a bitmap while decoding
* it (a panorama larger than any texture the viewer uploads).
* @param {string} url
* @param {{crossOrigin?: boolean, timeoutMs?: number, resize?: [number, number]|null, failure?: string}} [options]
*/
export function loadImage(
url,
{
crossOrigin = true,
timeoutMs = IMAGE_TIMEOUT_MS,
resize = null,
failure = 'The photo could not be loaded',
} = {},
) {
if (
crossOrigin &&
url &&
typeof globalThis.createImageBitmap === 'function' &&
typeof globalThis.fetch === 'function'
)
return loadBitmap(url, timeoutMs, resize, failure);
return new Promise((resolve, reject) => {
if (!url) return reject(new Error(failure));
const image = new Image();
if (crossOrigin) image.crossOrigin = 'anonymous';
image.decoding = 'async';
const timer = setTimeout(() => {
image.src = '';
reject(new Error(failure));
}, timeoutMs);
image.onload = () => {
clearTimeout(timer);
resolve(image);
};
image.onerror = () => {
clearTimeout(timer);
reject(new Error(failure));
};
image.src = url;
});
}
async function loadBitmap(url, timeoutMs, resize, failure) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
mode: 'cors',
signal: controller.signal,
});
if (!response.ok) throw new Error(failure);
const blob = await response.blob();
return await (resize
? createImageBitmap(blob, {
resizeWidth: resize[0],
resizeHeight: resize[1],
resizeQuality: 'high',
})
: createImageBitmap(blob));
} catch {
throw new Error(failure);
} finally {
clearTimeout(timer);
}
}
/** The panorama size a canvas this wide can show sharply at this field of view. */
export function panoSizeFor(canvasPixels, fovDeg) {
const needed = (Math.max(1, canvasPixels) * 360) / Math.max(1, fovDeg);
return needed > PANO_MID_SIZE[0] ? PANO_FULL_SIZE : PANO_MID_SIZE;
}
/**
* @typedef {object} PhotoSources Where a provider's photo comes from.
* @property {(photo: object) => boolean} hasPano A 360° photo with an equirectangular image.
* @property {(photo: object) => {url: string, width: number}|null} panoFirst
* The panorama shown first, and its texture width (0 when it is only a preview).
* @property {(photo: object, size: [number, number]) => {url: string, resize?: [number, number]|null, timeoutMs?: number}|null} panoSharp
* A sharper panorama for a canvas that needs `size`, or null.
* @property {(photo: object) => Array<string>} flatFirst Flat versions to try in turn.
* @property {(photo: object, pixels: number) => string|null} flatSharp
* A sharper flat version for a picture this many device pixels wide, or null.
* @property {(photo: object) => {title?: string|null, creator?: string|null, license?: string|null, externalUrl: string, capturedAtPrecision?: 'day'|'month'}} describe
*/
/**
* Viewer adapter for providers whose photos are plain image files: a flat
* photo in an <img>, a 360° photo on a WebGL panorama (drag to look, wheel
* or pinch to zoom), and ‹ › to step along the sequence (also ← →). Poses
* follow the view direction.
*
* Photos are normalized by the provider: `{id, sequenceId, lon, lat,
* heading, isPano, capturedAt}` plus whatever its `sources` read.
*
* @param {object} options
* @param {string} options.providerId
* @param {string} options.name Provider name for labels and messages, e.g. 'KartaView'.
* @param {PhotoSources} options.sources
* @param {(photoId: string) => Promise<object>} options.getPhoto A normalized photo.
* @param {(photo: object, delta: -1|1) => Promise<string|null>} options.step The photo `delta` along the sequence, or null at its end.
* @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show).
* @param {(photo: object) => void} [options.onShown] A photo is on screen.
* @param {object} [options.render] Render governor.
* @param {(url: string, options?: object) => Promise<HTMLImageElement|ImageBitmap>} [options.load]
* @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer]
* @returns {import('./registry.js').ViewerAdapter}
*/
export function createPhotoViewer({
providerId,
name,
sources,
getPhoto,
step,
onStep,
onShown = () => {},
render,
load = loadImage,
createRenderer = createPanoramaRenderer,
}) {
const LOAD_FAILED = `The ${name} photo could not be loaded`;
const listeners = new Set();
let root = null;
let picture = null;
let canvas = null;
let prevButton = null;
let nextButton = null;
let renderer = null;
/** WebGL failed once: 360° photos show their front view instead. */
let rendererFailed = false;
let photo = null;
/** What shows: 'flat', 'pano' or null. */
let showing = null;
let openSeq = 0;
let renderMode = 'letterbox';
let drag = null;
let poseFrame = 0;
let removeListeners = [];
/** The document the viewer was built in (new canvases come from it). */
let documentOf = null;
/** A context loss's look direction, for the reopen of that photo. */
let restoreView = null;
/** Width of the panorama texture on screen (0: the preview). */
let panoWidth = 0;
/** Width of a sharper panorama on its way (resizes come in bursts). */
let panoComing = 0;
/** The sharper flat version on screen or on its way (one download each). */
let flatSharpUrl = null;
/** The photo an open is fetching: ‹ › step on from it, not the old one. */
let openingId = null;
/** ‹ › chose this photo: keep the panorama's look direction for it. */
let keepViewFor = null;
/** ‹ › presses run one after another, each from where the last one went. */
let steps = Promise.resolve();
const loadAs = (url, options) =>
load(url, { failure: LOAD_FAILED, ...options });
function requestRender() {
render?.governorRequestRender?.(`${providerId}-viewer`);
}
function emit() {
if (!photo || !showing) return;
const view = showing === 'pano' ? renderer.getView() : null;
const heading = photo.heading;
const described = sources.describe(photo);
const pose = {
providerId,
imageId: photo.id,
position: { lon: photo.lon, lat: photo.lat },
bearing:
heading === null || heading === undefined
? null
: view
? wrapDegrees(heading + view.yaw)
: heading,
tilt: view ? view.pitch : 0,
altitude: null,
isPano: showing === 'pano',
capturedAt: photo.capturedAt,
capturedAtPrecision: described.capturedAtPrecision || 'day',
title: described.title ?? null,
creator: described.creator ?? null,
license: described.license ?? null,
sequenceId: photo.sequenceId,
externalUrl: described.externalUrl,
};
for (const listener of [...listeners]) {
try {
listener(pose);
} catch {
/* listener errors are the core's to log */
}
}
requestRender();
}
/** Re-emit on the next frame: a drag moves the view many times a frame. */
function emitSoon() {
if (poseFrame) return;
poseFrame = requestAnimationFrame(() => {
poseFrame = 0;
emit();
});
}
function syncButtons() {
const enabled = Boolean(photo?.sequenceId) && showing !== null;
if (prevButton) prevButton.disabled = !enabled;
if (nextButton) nextButton.disabled = !enabled;
if (enabled) checkEnds(photo);
}
/** Disable ‹ or › at once where the sequence ends (it also warms the neighbours). */
function checkEnds(target) {
for (const [delta, button] of [
[-1, prevButton],
[1, nextButton],
])
step(target, delta).then(
(id) => {
if (photo === target && button && !id) disable(button);
},
() => {},
);
}
/** Disable a step button, keeping keyboard focus in the viewer. */
function disable(button) {
if (button.ownerDocument?.activeElement === button) root?.focus?.();
button.disabled = true;
}
function show(kind) {
showing = kind;
if (picture) picture.hidden = kind !== 'flat';
if (canvas) canvas.hidden = kind !== 'pano';
if (root) root.dataset.kind = kind || '';
syncButtons();
}
function applyRenderMode() {
if (picture)
picture.style.objectFit = renderMode === 'fill' ? 'cover' : 'contain';
}
function ensureRenderer() {
if (renderer || rendererFailed || !canvas) return renderer;
try {
renderer = createRenderer(canvas);
} catch {
rendererFailed = true;
renderer = null;
}
return renderer;
}
/**
* A lost WebGL context (GPU reset, memory pressure) takes the panorama
* with it: drop the renderer, and show the photo again on a new one.
*/
function onContextLost(event) {
event.preventDefault?.();
const lost = renderer;
renderer = null;
// The new renderer starts facing the photo's heading: keep the user's look.
if (showing === 'pano' && photo && lost)
restoreView = { id: photo.id, view: lost.getView() };
try {
lost?.destroy();
} catch {
/* already gone */
}
// A lost canvas hands back its lost context: draw on a new element.
const fresh = makeCanvas(documentOf);
fresh.hidden = canvas.hidden;
canvas.replaceWith?.(fresh);
canvas = fresh;
// Show the photo again through the core, unless a newer one is opening.
if (showing === 'pano' && photo && openingId === null) {
const again = photo.id;
show(null);
openingId = again;
onStep(again);
}
}
/** The panorama canvas, wired for drag, pinch, wheel and context loss. */
function makeCanvas(doc) {
const element = doc.createElement('canvas');
element.className = 'sl-photo-pano';
element.hidden = true;
listen(element, 'pointerdown', onPointerDown);
listen(element, 'pointermove', onPointerMove);
listen(element, 'pointerup', onPointerUp);
listen(element, 'pointercancel', onPointerUp);
listen(element, 'wheel', onWheel, { passive: false });
listen(element, 'webglcontextlost', onContextLost);
return element;
}
/**
* Step along the sequence from the photo showing, or from the one on its
* way after an earlier press, and open it through the core.
*/
function stepBy(delta) {
steps = steps.then(async () => {
const fromId = openingId ?? photo?.id;
if (!fromId || !root) return;
let from;
let nextId = null;
try {
from = await getPhoto(fromId);
if (from.sequenceId) nextId = await step(from, delta);
} catch {
// Not the end of the sequence, just no answer: the press is lost.
return;
}
// Closed, or another photo was opened meanwhile.
if ((openingId ?? photo?.id) !== fromId) return;
if (!nextId) {
const button = delta < 0 ? prevButton : nextButton;
if (button && photo?.id === fromId) disable(button);
return;
}
openingId = nextId;
keepViewFor = nextId;
onStep(nextId);
});
}
/** Pointers on the panorama: one drags the view, two pinch the zoom. */
const pointers = new Map();
let pinch = null;
function pinchSpan() {
const [a, b] = [...pointers.values()];
return Math.max(1, Math.hypot(a.x - b.x, a.y - b.y));
}
function onPointerDown(event) {
if (showing !== 'pano' || !renderer) return;
if (event.pointerType === 'mouse' && event.button !== 0) return;
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
canvas.setPointerCapture?.(event.pointerId);
if (pointers.size === 2) {
drag = null;
pinch = { span: pinchSpan(), fov: renderer.getView().fov };
return;
}
drag = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
view: renderer.getView(),
};
canvas.classList.add('sl-photo-dragging');
}
function onPointerMove(event) {
if (pointers.has(event.pointerId))
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (pinch && pointers.size === 2 && renderer) {
renderer.setView({ fov: pinch.fov * (pinch.span / pinchSpan()) });
return;
}
if (!drag || event.pointerId !== drag.id || !renderer) return;
const { fov } = drag.view;
const width = Math.max(1, canvas.clientWidth);
const height = Math.max(1, canvas.clientHeight);
const vertical = fov;
const horizontal = fov * (width / height);
renderer.setView({
yaw: drag.view.yaw - ((event.clientX - drag.x) / width) * horizontal,
pitch: drag.view.pitch + ((event.clientY - drag.y) / height) * vertical,
});
emitSoon();
}
function onPointerUp(event) {
pointers.delete(event.pointerId);
if (pointers.size < 2) pinch = null;
if (!drag || event.pointerId !== drag.id) return;
drag = null;
canvas.classList.remove('sl-photo-dragging');
}
function onWheel(event) {
if (showing !== 'pano' || !renderer) return;
event.preventDefault();
const { fov } = renderer.getView();
renderer.setView({ fov: fov * Math.exp(event.deltaY * 0.001) });
}
function onKeyDown(event) {
if (
event.defaultPrevented ||
event.altKey ||
event.ctrlKey ||
event.metaKey
)
return;
const pano = showing === 'pano' && renderer;
const turn = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
// Shift+← → look around a panorama; ← → step along the sequence.
if (turn && event.shiftKey && pano) {
event.preventDefault();
const { yaw, fov } = renderer.getView();
renderer.setView({ yaw: yaw + (turn * fov) / 4 });
emitSoon();
} else if (turn) {
event.preventDefault();
stepBy(turn);
} else if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && pano) {
event.preventDefault();
const { pitch, fov } = renderer.getView();
renderer.setView({
pitch: pitch + ((event.key === 'ArrowUp' ? 1 : -1) * fov) / 6,
});
emitSoon();
} else if (['+', '=', '-', '_'].includes(event.key) && pano) {
event.preventDefault();
const { fov } = renderer.getView();
renderer.setView({
fov: fov * (event.key === '+' || event.key === '=' ? 0.85 : 1 / 0.85),
});
}
}
function listen(target, type, handler, options) {
target.addEventListener(type, handler, options);
removeListeners.push(() =>
target.removeEventListener(type, handler, options),
);
}
function build(host) {
const doc = host.ownerDocument;
documentOf = doc;
root = doc.createElement('div');
root.className = 'sl-photo-viewer';
root.dataset.provider = providerId;
root.tabIndex = 0;
root.setAttribute('role', 'group');
root.setAttribute(
'aria-label',
`${name} photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom`,
);
picture = doc.createElement('img');
picture.className = 'sl-photo-flat';
picture.alt = `${name} street-level photo`;
picture.draggable = false;
picture.hidden = true;
canvas = makeCanvas(doc);
const nav = (label, glyph, delta) => {
const button = doc.createElement('button');
button.type = 'button';
button.className = `sl-photo-step sl-photo-step-${delta < 0 ? 'prev' : 'next'}`;
button.textContent = glyph;
button.title = label;
button.setAttribute('aria-label', label);
button.disabled = true;
listen(button, 'click', (event) => {
event.stopPropagation();
stepBy(delta);
});
return button;
};
prevButton = nav('Previous photo in this sequence (←)', '‹', -1);
nextButton = nav('Next photo in this sequence (→)', '›', 1);
root.append(picture, canvas, prevButton, nextButton);
host.append(root);
listen(root, 'keydown', onKeyDown);
applyRenderMode();
}
function teardown() {
pointers.clear();
pinch = null;
for (const remove of removeListeners) remove();
removeListeners = [];
if (poseFrame) cancelAnimationFrame(poseFrame);
poseFrame = 0;
drag = null;
renderer?.destroy();
renderer = null;
root?.remove();
root = picture = canvas = prevButton = nextButton = null;
showing = null;
photo = null;
}
/** Device pixels across the viewer (its picture or canvas). */
function viewerPixels() {
const ratio = Math.min(globalThis.devicePixelRatio || 1, 2);
const element = showing === 'pano' ? canvas : picture;
return (element?.clientWidth || root?.clientWidth || 0) * ratio;
}
/** A sharper version once the first is on screen, if nothing replaced it. */
async function upgrade(target, seq, kind) {
// A 360° photo shown flat (no WebGL) keeps its front view.
if (kind === 'flat' && target.isPano) return;
if (kind === 'pano') {
const size = panoSizeNow();
if (size[0] <= Math.max(panoWidth, panoComing)) return;
const sharp = sources.panoSharp(target, size);
if (!sharp?.url) return;
panoComing = size[0];
try {
const image = await loadAs(sharp.url, {
crossOrigin: true,
resize: sharp.resize ?? null,
...(sharp.timeoutMs ? { timeoutMs: sharp.timeoutMs } : {}),
});
if (seq !== openSeq || photo !== target || showing !== 'pano') {
image.close?.();
return;
}
if (!renderer || size[0] <= panoWidth) {
image.close?.();
return;
}
renderer.setImage(image);
panoWidth = size[0];
requestRender();
} catch {
/* the first version stays */
} finally {
if (panoComing === size[0]) panoComing = 0;
}
return;
}
const url = sources.flatSharp(target, viewerPixels());
if (!url || url === flatSharpUrl) return;
flatSharpUrl = url;
try {
const image = await loadAs(url, { crossOrigin: false });
if (seq !== openSeq || photo !== target || showing !== 'flat') return;
picture.src = image.src;
requestRender();
} catch {
// The first version stays; a later resize may try again.
if (flatSharpUrl === url && photo === target) flatSharpUrl = null;
}
}
/** The sharp panorama this viewer needs now: 2,048 docked, 4,096 expanded. */
function panoSizeNow() {
const ratio = Math.min(globalThis.devicePixelRatio || 1, 2);
const width = (canvas?.clientWidth || 0) * ratio;
return panoSizeFor(width, renderer?.getView().fov ?? PANO_FOV_DEFAULT_DEG);
}
async function showPano(target, seq, keepView) {
// If the panorama fails, the caller shows the front view rather than
// waiting on it twice.
const first = sources.panoFirst(target);
if (!first?.url) throw new Error(LOAD_FAILED);
const image = await loadAs(first.url, { crossOrigin: true });
if (seq !== openSeq) {
image.close?.();
return false;
}
// The context may have been lost while this loaded: build on the new
// canvas, or let the caller show the front view.
if (!ensureRenderer()) throw new Error(LOAD_FAILED);
const restore = restoreView?.id === target.id ? restoreView.view : null;
restoreView = null;
if (restore) renderer.setView(restore);
else if (!keepView || showing !== 'pano')
renderer.setView({ yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG });
photo = target;
// Shown first: a hidden canvas measures 0 and would be sized 1×1.
show('pano');
renderer.setImage(image);
panoWidth = first.width || 0;
panoComing = 0;
return true;
}
async function showFlat(target, seq) {
const urls = sources.flatFirst(target).filter(Boolean);
let image;
let failure = null;
for (const url of urls) {
try {
image = await loadAs(url, { crossOrigin: false });
break;
} catch (error) {
failure = error;
if (seq !== openSeq) return false;
}
}
if (!image) throw failure || new Error(LOAD_FAILED);
if (seq !== openSeq) return false;
picture.src = image.src;
flatSharpUrl = null;
photo = target;
show('flat');
return true;
}
async function openPhoto(id, seq, keepView) {
const target = await getPhoto(id);
if (seq !== openSeq) return;
let shown = false;
if (sources.hasPano(target) && ensureRenderer()) {
try {
shown = await showPano(target, seq, keepView);
} catch {
// The panorama failed: its front view is better than nothing.
if (seq !== openSeq) return;
shown = await showFlat(target, seq);
}
} else shown = await showFlat(target, seq);
if (!shown) return;
emit();
onShown(target);
upgrade(target, seq, showing);
}
const adapter = {
async mount(host) {
if (!host) throw new Error('Street Level viewer has no host element');
if (root && root.parentNode === host) return;
teardown();
rendererFailed = false;
build(host);
},
/** Resolves once the photo is on screen and its pose was emitted. */
async open(imageId) {
if (!root) throw new Error(`${name} viewer is not mounted`);
const id = String(imageId);
const seq = ++openSeq;
const keepView = keepViewFor === id;
keepViewFor = null;
openingId = id;
try {
await openPhoto(id, seq, keepView);
} finally {
if (openingId === id && seq === openSeq) openingId = null;
}
},
close() {
openSeq++;
openingId = null;
keepViewFor = null;
photo = null;
flatSharpUrl = null;
show(null);
if (picture) picture.removeAttribute('src');
renderer?.clear?.();
},
unmount() {
openSeq++;
openingId = null;
keepViewFor = null;
flatSharpUrl = null;
teardown();
},
resize() {
renderer?.resize();
// Expanded: a docked viewer's panorama or flat version is now too soft.
if (showing === 'pano' && photo && panoSizeNow()[0] > panoWidth)
upgrade(photo, openSeq, 'pano');
else if (showing === 'flat' && photo) upgrade(photo, openSeq, 'flat');
},
/** FIT / FILL frame a flat photo; a panorama has no frame to fit. */
supportsRenderModes: () => showing !== 'pano',
setRenderMode(mode) {
renderMode = mode === 'fill' ? 'fill' : 'letterbox';
applyRenderMode();
},
onPose(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
return adapter;
}