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 (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} 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 , 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} options.getPhoto A normalized photo.
* @param {(photo: object, delta: -1|1) => Promise} 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} [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;
}