mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
c99eb93174
commit
00d2b73b6a
37 files changed
+4235
-711
No files matched your search
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* A gate letting at most `limit` tasks run at once, first in first out, or
|
||||
* newest first with `lifo` (the last request is the one the user waits on).
|
||||
*/
|
||||
export function createLimiter(limit, { lifo = false } = {}) {
|
||||
let running = 0;
|
||||
const queue = [];
|
||||
function next() {
|
||||
while (running < limit && queue.length) {
|
||||
const { task, resolve, reject, signal } = lifo
|
||||
? queue.pop()
|
||||
: queue.shift();
|
||||
if (signal?.aborted) {
|
||||
reject(signal.reason);
|
||||
continue;
|
||||
}
|
||||
running++;
|
||||
Promise.resolve()
|
||||
.then(task)
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
running--;
|
||||
next();
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
run(task, { signal } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const item = { task, resolve, reject, signal };
|
||||
queue.push(item);
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
const at = queue.indexOf(item);
|
||||
if (at === -1) return;
|
||||
queue.splice(at, 1);
|
||||
reject(signal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
next();
|
||||
});
|
||||
},
|
||||
get pending() {
|
||||
return running + queue.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -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;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export const PROVIDER_COLORS = Object.freeze({
|
||||
mapillary: '#05cb63',
|
||||
google: '#4285f4',
|
||||
kartaview: '#635bff',
|
||||
panoramax: '#ff6f00',
|
||||
});
|
||||
|
||||
/** Colours every provider shares: the selection highlight and the marker. */
|
||||
|
||||
@@ -100,9 +100,6 @@ export const SEQUENCE_PAGE_EDGE = 12;
|
||||
|
||||
/** Equirectangular panorama sizes: a quick preview, then the sharp one. */
|
||||
export const PANO_PREVIEW_SIZE = Object.freeze([1024, 512]);
|
||||
/** Enough for a docked viewer; an expanded one gets the full size. */
|
||||
export const PANO_MID_SIZE = Object.freeze([2048, 1024]);
|
||||
export const PANO_FULL_SIZE = Object.freeze([4096, 2048]);
|
||||
/** A flat photo's first version, then its sharp one. */
|
||||
export const FLAT_VIEW_SIZE = Object.freeze([1280, 1280]);
|
||||
export const FLAT_FULL_SIZE = Object.freeze([2560, 2560]);
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
TILE_CONCURRENCY,
|
||||
TILE_TIMEOUT_MS,
|
||||
} from './policy.js';
|
||||
import { createLimiter } from '../../limiter.js';
|
||||
|
||||
export { createLimiter } from '../../limiter.js';
|
||||
|
||||
/** An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits. */
|
||||
export class KartaViewSourceError extends Error {
|
||||
@@ -54,56 +57,6 @@ function retryAfterSeconds(response) {
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A gate letting at most `limit` tasks run at once, first in first out, or
|
||||
* newest first with `lifo` (the last request is the one the user waits on).
|
||||
*/
|
||||
export function createLimiter(limit, { lifo = false } = {}) {
|
||||
let running = 0;
|
||||
const queue = [];
|
||||
function next() {
|
||||
while (running < limit && queue.length) {
|
||||
const { task, resolve, reject, signal } = lifo
|
||||
? queue.pop()
|
||||
: queue.shift();
|
||||
if (signal?.aborted) {
|
||||
reject(signal.reason);
|
||||
continue;
|
||||
}
|
||||
running++;
|
||||
Promise.resolve()
|
||||
.then(task)
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
running--;
|
||||
next();
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
run(task, { signal } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const item = { task, resolve, reject, signal };
|
||||
queue.push(item);
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
const at = queue.indexOf(item);
|
||||
if (at === -1) return;
|
||||
queue.splice(at, 1);
|
||||
reject(signal.reason);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
next();
|
||||
});
|
||||
},
|
||||
get pending() {
|
||||
return running + queue.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* KartaView's public API, browser-side and keyless (it allows any origin).
|
||||
* It speaks HTTP/1.1, so the browser opens six connections to it: tiles take
|
||||
|
||||
@@ -1,632 +1,81 @@
|
||||
import {
|
||||
createPanoramaRenderer,
|
||||
PANO_FOV_DEFAULT_DEG,
|
||||
wrapDegrees,
|
||||
} from './panorama.js';
|
||||
createPhotoViewer,
|
||||
loadImage as loadAnyImage,
|
||||
} from '../../photoViewer.js';
|
||||
import {
|
||||
FLAT_FULL_SIZE,
|
||||
FLAT_VIEW_SIZE,
|
||||
IMAGE_TIMEOUT_MS,
|
||||
KARTAVIEW_NAME,
|
||||
KARTAVIEW_PROVIDER_ID,
|
||||
PANO_FULL_SIZE,
|
||||
PANO_MID_SIZE,
|
||||
PANO_PREVIEW_SIZE,
|
||||
kartaViewPhotoUrl,
|
||||
resizedImageUrl,
|
||||
} from './policy.js';
|
||||
|
||||
export { panoSizeFor } from '../../photoViewer.js';
|
||||
|
||||
const LOAD_FAILED = 'The KartaView photo could not be loaded';
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export function loadImage(
|
||||
url,
|
||||
{ crossOrigin = true, timeoutMs = IMAGE_TIMEOUT_MS } = {},
|
||||
) {
|
||||
if (
|
||||
crossOrigin &&
|
||||
url &&
|
||||
typeof globalThis.createImageBitmap === 'function' &&
|
||||
typeof globalThis.fetch === 'function'
|
||||
)
|
||||
return loadBitmap(url, timeoutMs);
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!url) return reject(new Error(LOAD_FAILED));
|
||||
const image = new Image();
|
||||
if (crossOrigin) image.crossOrigin = 'anonymous';
|
||||
image.decoding = 'async';
|
||||
const timer = setTimeout(() => {
|
||||
image.src = '';
|
||||
reject(new Error(LOAD_FAILED));
|
||||
}, timeoutMs);
|
||||
image.onload = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(LOAD_FAILED));
|
||||
};
|
||||
image.src = url;
|
||||
/** Load a KartaView photo (see photoViewer.js `loadImage`). */
|
||||
export function loadImage(url, options = {}) {
|
||||
return loadAnyImage(url, {
|
||||
timeoutMs: IMAGE_TIMEOUT_MS,
|
||||
failure: LOAD_FAILED,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadBitmap(url, timeoutMs) {
|
||||
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(LOAD_FAILED);
|
||||
return await createImageBitmap(await response.blob());
|
||||
} catch {
|
||||
throw new Error(LOAD_FAILED);
|
||||
} 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;
|
||||
}
|
||||
/**
|
||||
* Where KartaView photos come from. Only KartaView's image proxy can shrink
|
||||
* a 13,000 px panorama, so panoramas and flat upgrades go through it.
|
||||
* @type {import('../../photoViewer.js').PhotoSources}
|
||||
*/
|
||||
const KARTAVIEW_SOURCES = Object.freeze({
|
||||
hasPano: (photo) => Boolean(photo.isPano && photo.urls.pano),
|
||||
panoFirst: (photo) => ({
|
||||
url: resizedImageUrl(photo.urls.pano, PANO_PREVIEW_SIZE),
|
||||
width: 0,
|
||||
}),
|
||||
panoSharp: (photo, size) => ({
|
||||
url: resizedImageUrl(photo.urls.pano, size),
|
||||
}),
|
||||
// The proxy's 1,280 px version is a few hundred kB; KartaView's own
|
||||
// "large thumbnail" can be 1.7 MB. Either serves when the other fails.
|
||||
// A 360° photo's front view is only on storage.
|
||||
flatFirst: (photo) => [
|
||||
photo.isPano ? null : resizedImageUrl(photo.urls.full, FLAT_VIEW_SIZE),
|
||||
photo.urls.view,
|
||||
],
|
||||
flatSharp: (photo) => resizedImageUrl(photo.urls.full, FLAT_FULL_SIZE),
|
||||
describe: (photo) => ({
|
||||
// KartaView's only place name is the sequence's, where the drive began.
|
||||
title: null,
|
||||
creator: null,
|
||||
externalUrl: kartaViewPhotoUrl(photo),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Viewer adapter for KartaView photos: a flat photo in an <img>, a 360°
|
||||
* photo on a WebGL panorama (drag to look, wheel to zoom), and ‹ › to step
|
||||
* along the sequence (also ← →). Poses follow the view direction.
|
||||
* photo on a WebGL panorama, and ‹ › along the sequence (photoViewer.js).
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {(photoId: string) => Promise<object>} options.getPhoto A normalized photo (policy.js normalizePhoto).
|
||||
* @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 {(photo: object, delta: -1|1) => Promise<string|null>} options.step
|
||||
* @param {(imageId: string) => void} options.onStep
|
||||
* @param {(photo: object) => void} [options.onShown]
|
||||
* @param {object} [options.render]
|
||||
* @param {(url: string, options?: object) => Promise<HTMLImageElement>} [options.load]
|
||||
* @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer]
|
||||
* @returns {import('../../registry.js').ViewerAdapter}
|
||||
*/
|
||||
export function createKartaViewViewer({
|
||||
getPhoto,
|
||||
step,
|
||||
onStep,
|
||||
onShown = () => {},
|
||||
render,
|
||||
load = loadImage,
|
||||
createRenderer = createPanoramaRenderer,
|
||||
}) {
|
||||
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;
|
||||
/** 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();
|
||||
|
||||
function requestRender() {
|
||||
render?.governorRequestRender?.('kartaview-viewer');
|
||||
}
|
||||
|
||||
function emit() {
|
||||
if (!photo || !showing) return;
|
||||
const view = showing === 'pano' ? renderer.getView() : null;
|
||||
const heading = photo.heading;
|
||||
const pose = {
|
||||
providerId: KARTAVIEW_PROVIDER_ID,
|
||||
imageId: photo.id,
|
||||
position: { lon: photo.lon, lat: photo.lat },
|
||||
bearing:
|
||||
heading === null
|
||||
? null
|
||||
: view
|
||||
? wrapDegrees(heading + view.yaw)
|
||||
: heading,
|
||||
tilt: view ? view.pitch : 0,
|
||||
altitude: null,
|
||||
isPano: showing === 'pano',
|
||||
capturedAt: photo.capturedAt,
|
||||
capturedAtPrecision: 'day',
|
||||
// KartaView's only place name is the sequence's, where the drive began.
|
||||
title: null,
|
||||
creator: null,
|
||||
sequenceId: photo.sequenceId,
|
||||
externalUrl: kartaViewPhotoUrl(photo),
|
||||
};
|
||||
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-kv-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-kv-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-kv-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-kv-viewer';
|
||||
root.tabIndex = 0;
|
||||
root.setAttribute('role', 'group');
|
||||
root.setAttribute(
|
||||
'aria-label',
|
||||
'KartaView 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-kv-photo';
|
||||
picture.alt = 'KartaView 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-kv-step sl-kv-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;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
const size = kind === 'pano' ? panoSizeNow() : FLAT_FULL_SIZE;
|
||||
if (kind === 'pano' && size[0] <= panoWidth) return;
|
||||
const url =
|
||||
kind === 'pano'
|
||||
? resizedImageUrl(target.urls.pano, size)
|
||||
: resizedImageUrl(target.urls.full, size);
|
||||
if (!url) return;
|
||||
try {
|
||||
const image = await load(url, { crossOrigin: kind === 'pano' });
|
||||
if (seq !== openSeq || photo !== target || showing !== kind) return;
|
||||
if (kind === 'pano') {
|
||||
if (!renderer || size[0] <= panoWidth) return;
|
||||
renderer.setImage(image);
|
||||
panoWidth = size[0];
|
||||
} else picture.src = image.src;
|
||||
requestRender();
|
||||
} catch {
|
||||
/* the first version stays */
|
||||
}
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
// Only the proxy can shrink a 13,000 px panorama: if it fails, the
|
||||
// caller shows the front view rather than waiting on it twice.
|
||||
const image = await load(
|
||||
resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE),
|
||||
{ crossOrigin: true },
|
||||
);
|
||||
if (seq !== openSeq) 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 = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function showFlat(target, seq) {
|
||||
// The proxy's 1,280 px version is a few hundred kB; KartaView's own
|
||||
// "large thumbnail" can be 1.7 MB. Either serves when the other fails.
|
||||
// A 360° photo's front view is only on storage.
|
||||
const proxied = target.isPano
|
||||
? null
|
||||
: resizedImageUrl(target.urls.full, FLAT_VIEW_SIZE);
|
||||
const sources = [proxied, target.urls.view].filter(Boolean);
|
||||
let image;
|
||||
let failure = null;
|
||||
for (const url of sources) {
|
||||
try {
|
||||
image = await load(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;
|
||||
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 (target.isPano && target.urls.pano && 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('KartaView 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;
|
||||
show(null);
|
||||
if (picture) picture.removeAttribute('src');
|
||||
renderer?.clear?.();
|
||||
},
|
||||
|
||||
unmount() {
|
||||
openSeq++;
|
||||
openingId = null;
|
||||
keepViewFor = null;
|
||||
teardown();
|
||||
},
|
||||
|
||||
resize() {
|
||||
renderer?.resize();
|
||||
// Expanded: a docked viewer's 2,048 px panorama is now too soft.
|
||||
if (showing === 'pano' && photo && panoSizeNow()[0] > panoWidth)
|
||||
upgrade(photo, openSeq, 'pano');
|
||||
},
|
||||
|
||||
/** 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;
|
||||
export function createKartaViewViewer({ load = loadImage, ...options }) {
|
||||
return createPhotoViewer({
|
||||
providerId: KARTAVIEW_PROVIDER_ID,
|
||||
name: KARTAVIEW_NAME,
|
||||
sources: KARTAVIEW_SOURCES,
|
||||
load,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PbfWriter } from 'pbf';
|
||||
|
||||
const zigzag = (value) => (value << 1) ^ (value >> 31);
|
||||
export const zigzag = (value) => (value << 1) ^ (value >> 31);
|
||||
|
||||
/** Tile-local [x, y] (0..extent) of a lon/lat inside tile (x, y, z). */
|
||||
export function lonLatToTileLocal([lon, lat], { x, y, z }, extent = 4096) {
|
||||
@@ -13,7 +13,7 @@ export function lonLatToTileLocal([lon, lat], { x, y, z }, extent = 4096) {
|
||||
}
|
||||
|
||||
/** One MVT layer writer with deduplicated keys and appended values. */
|
||||
function layerWriter(name, extent) {
|
||||
export function layerWriter(name, extent) {
|
||||
const keys = new Map();
|
||||
const values = [];
|
||||
const features = [];
|
||||
@@ -62,7 +62,7 @@ function layerWriter(name, extent) {
|
||||
}
|
||||
|
||||
/** MVT MoveTo/LineTo commands; the cursor carries over between parts. */
|
||||
function lineGeometry(parts) {
|
||||
export function lineGeometry(parts) {
|
||||
const out = [];
|
||||
let cx = 0;
|
||||
let cy = 0;
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { PbfReader } from 'pbf';
|
||||
import { VectorTile } from '@mapbox/vector-tile';
|
||||
import { tileLocalToLonLat } from '../../tileMath.js';
|
||||
import { OVERVIEW_BINS_PER_AXIS, panoramaxTimeMs } from './policy.js';
|
||||
|
||||
/** Epoch ms of a sequence's day ('2025-08-11'), or 0 when undated. */
|
||||
function dayMs(value) {
|
||||
return panoramaxTimeMs(typeof value === 'string' ? value : '') ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a Panoramax coverage tile (`/api/map/{z}/{x}/{y}.mvt`).
|
||||
*
|
||||
* - `sequences` (z7–15): one line per sequence, its parts kept apart so no
|
||||
* segment is drawn across a gap, with its day and camera type; longest
|
||||
* first, so a cap on lines drawn drops the shortest.
|
||||
* - `grid` (z0–6): picture-density cells, as overview points binned to at
|
||||
* most OVERVIEW_BINS_PER_AXIS² a tile (a z0 tile holds ~70,000 cells). A
|
||||
* cell has 360° and flat counts but no date.
|
||||
* - `pictures` (z15) is left for `decodePictures`.
|
||||
* @param {Uint8Array} bytes
|
||||
* @param {{x: number, y: number, z: number}} address
|
||||
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<{id: string, lon: number, lat: number, capturedAt: null, isPano: boolean, hasPano: boolean, hasFlat: boolean, weight: number}>}}
|
||||
*/
|
||||
export function decodeCoverageTile(bytes, address) {
|
||||
const result = { sequences: [], overview: [] };
|
||||
if (!bytes || !bytes.length) return result;
|
||||
const tile = new VectorTile(new PbfReader(bytes));
|
||||
const { x, y, z } = address;
|
||||
|
||||
const sequences = tile.layers.sequences;
|
||||
if (sequences) {
|
||||
const lengths = new Map();
|
||||
for (let i = 0; i < sequences.length; i++) {
|
||||
const feature = sequences.feature(i);
|
||||
const props = feature.properties || {};
|
||||
const parts = [];
|
||||
let length = 0;
|
||||
for (const line of feature.loadGeometry()) {
|
||||
if (line.length < 2) continue;
|
||||
for (let k = 1; k < line.length; k++)
|
||||
length += Math.hypot(
|
||||
line[k].x - line[k - 1].x,
|
||||
line[k].y - line[k - 1].y,
|
||||
);
|
||||
parts.push(
|
||||
line.map((point) =>
|
||||
tileLocalToLonLat(point.x, point.y, sequences.extent, x, y, z),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!parts.length || props.id == null) continue;
|
||||
const sequence = {
|
||||
id: String(props.id),
|
||||
parts,
|
||||
capturedAt: dayMs(props.date),
|
||||
isPano: props.type === 'equirectangular',
|
||||
};
|
||||
lengths.set(sequence, length);
|
||||
result.sequences.push(sequence);
|
||||
}
|
||||
result.sequences.sort((a, b) => lengths.get(b) - lengths.get(a));
|
||||
}
|
||||
|
||||
const grid = tile.layers.grid;
|
||||
if (grid) {
|
||||
const bins = new Map();
|
||||
const size = grid.extent / OVERVIEW_BINS_PER_AXIS;
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
const feature = grid.feature(i);
|
||||
const props = feature.properties || {};
|
||||
const point = feature.loadGeometry()?.[0]?.[0];
|
||||
if (!point) continue;
|
||||
const pano = Number(props.nb_360_pictures) || 0;
|
||||
const flat = Number(props.nb_flat_pictures) || 0;
|
||||
const weight = Number(props.nb_pictures) || pano + flat;
|
||||
if (weight <= 0) continue;
|
||||
const key = `${Math.floor(point.x / size)}:${Math.floor(point.y / size)}`;
|
||||
const bin = bins.get(key);
|
||||
if (!bin) {
|
||||
bins.set(key, { point, weight, pano, flat, top: weight });
|
||||
continue;
|
||||
}
|
||||
// The bin sits on its busiest cell and counts them all.
|
||||
bin.weight += weight;
|
||||
bin.pano += pano;
|
||||
bin.flat += flat;
|
||||
if (weight > bin.top) {
|
||||
bin.top = weight;
|
||||
bin.point = point;
|
||||
}
|
||||
}
|
||||
for (const [key, bin] of bins) {
|
||||
const [lon, lat] = tileLocalToLonLat(
|
||||
bin.point.x,
|
||||
bin.point.y,
|
||||
grid.extent,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
);
|
||||
result.overview.push({
|
||||
id: `${z}/${x}/${y}/${key}`,
|
||||
lon,
|
||||
lat,
|
||||
capturedAt: null,
|
||||
isPano: bin.pano > bin.flat,
|
||||
hasPano: bin.pano > 0,
|
||||
hasFlat: bin.flat > 0,
|
||||
weight: bin.weight,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** A picture's sequence ids: the tile stores them as a JSON array in a string. */
|
||||
function sequenceIdsOf(props) {
|
||||
const raw = props.sequences;
|
||||
if (typeof raw === 'string' && raw.startsWith('[')) {
|
||||
try {
|
||||
const ids = JSON.parse(raw);
|
||||
if (Array.isArray(ids)) return ids.map(String);
|
||||
} catch {
|
||||
/* fall through to the first sequence */
|
||||
}
|
||||
}
|
||||
return props.first_sequence != null ? [String(props.first_sequence)] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The pictures of a z15 tile that belong to `sequenceId`, as cones draw
|
||||
* them (unsorted).
|
||||
* @param {Uint8Array} bytes
|
||||
* @param {{x: number, y: number, z: number}} address
|
||||
* @param {string} sequenceId
|
||||
* @returns {Array<{id: string, lon: number, lat: number, compassAngle: number, capturedAt: number, isPano: boolean}>}
|
||||
*/
|
||||
export function decodePictures(bytes, address, sequenceId) {
|
||||
if (!bytes || !bytes.length) return [];
|
||||
const tile = new VectorTile(new PbfReader(bytes));
|
||||
const layer = tile.layers.pictures;
|
||||
if (!layer) return [];
|
||||
const { x, y, z } = address;
|
||||
const wanted = String(sequenceId);
|
||||
const out = [];
|
||||
for (let i = 0; i < layer.length; i++) {
|
||||
const feature = layer.feature(i);
|
||||
const props = feature.properties || {};
|
||||
// A cheap test first: most pictures in a tile are other sequences'.
|
||||
if (
|
||||
typeof props.sequences === 'string' &&
|
||||
!props.sequences.includes(wanted)
|
||||
)
|
||||
continue;
|
||||
if (!sequenceIdsOf(props).includes(wanted) || props.id == null) continue;
|
||||
const point = feature.loadGeometry()?.[0]?.[0];
|
||||
if (!point) continue;
|
||||
const [lon, lat] = tileLocalToLonLat(
|
||||
point.x,
|
||||
point.y,
|
||||
layer.extent,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
);
|
||||
const heading = Number(props.heading);
|
||||
out.push({
|
||||
id: String(props.id),
|
||||
lon,
|
||||
lat,
|
||||
compassAngle: Number.isFinite(heading) ? heading : 0,
|
||||
capturedAt: panoramaxTimeMs(String(props.ts ?? '')) ?? 0,
|
||||
isPano: props.type === 'equirectangular',
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { decodeCoverageTile, decodePictures } from './decode.js';
|
||||
import { OVERVIEW_BINS_PER_AXIS } from './policy.js';
|
||||
import { encodePanoramaxTile } from '../../../../testSupport/panoramaxFixtures.mjs';
|
||||
import { tileCentre } from '../../../../testSupport/streetLevelFakes.mjs';
|
||||
|
||||
const near = (a, b, eps = 1e-4) => Math.abs(a - b) < eps;
|
||||
|
||||
test('sequence lines keep their parts apart, with their day and camera type', () => {
|
||||
const { tile, lon, lat } = tileCentre(2.35, 48.85);
|
||||
const bytes = encodePanoramaxTile(tile, {
|
||||
sequences: [
|
||||
{
|
||||
id: 'pano-seq',
|
||||
type: 'equirectangular',
|
||||
date: '2024-03-05',
|
||||
parts: [
|
||||
[
|
||||
[lon - 0.001, lat],
|
||||
[lon, lat],
|
||||
],
|
||||
[
|
||||
[lon + 0.0005, lat],
|
||||
[lon + 0.001, lat],
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'flat-seq',
|
||||
parts: [
|
||||
[
|
||||
[lon, lat - 0.001],
|
||||
[lon, lat],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const { sequences, overview } = decodeCoverageTile(bytes, tile);
|
||||
assert.equal(overview.length, 0);
|
||||
assert.equal(sequences.length, 2);
|
||||
const pano = sequences.find((s) => s.id === 'pano-seq');
|
||||
const flat = sequences.find((s) => s.id === 'flat-seq');
|
||||
assert.equal(pano.isPano, true);
|
||||
assert.equal(pano.capturedAt, Date.UTC(2024, 2, 5));
|
||||
assert.equal(pano.parts.length, 2, 'the gap is not bridged');
|
||||
assert.ok(near(pano.parts[1][1][0], lon + 0.001));
|
||||
assert.equal(flat.isPano, false);
|
||||
});
|
||||
|
||||
test('density cells bin to a bounded number of points that keep the counts and kinds', () => {
|
||||
const tile = { x: 0, y: 0, z: 0 };
|
||||
// Two cells in one bin, one far away, one empty.
|
||||
const grid = [
|
||||
{ lon: 2.35, lat: 48.85, pano: 0, flat: 5 },
|
||||
{ lon: 2.36, lat: 48.85, pano: 30, flat: 0 },
|
||||
{ lon: -73.9, lat: 40.7, pano: 1, flat: 0 },
|
||||
{ lon: 10, lat: 10, pano: 0, flat: 0 },
|
||||
];
|
||||
const { overview } = decodeCoverageTile(
|
||||
encodePanoramaxTile(tile, { grid }),
|
||||
tile,
|
||||
);
|
||||
assert.equal(overview.length, 2, 'Paris cells share a bin; empty cells go');
|
||||
const paris = overview.find((p) => p.lon > 0);
|
||||
assert.equal(paris.weight, 35);
|
||||
assert.equal(paris.hasPano, true);
|
||||
assert.equal(paris.hasFlat, true);
|
||||
assert.equal(paris.isPano, true, 'mostly 360°');
|
||||
assert.equal(paris.capturedAt, null, 'cells are undated');
|
||||
assert.ok(near(paris.lon, 2.36, 0.2), 'sits on the busiest cell');
|
||||
const nyc = overview.find((p) => p.lon < 0);
|
||||
assert.deepEqual([nyc.hasPano, nyc.hasFlat, nyc.weight], [true, false, 1]);
|
||||
});
|
||||
|
||||
test('lines come longest first, so a cap on lines drawn drops the shortest', () => {
|
||||
const { tile, lon, lat } = tileCentre(2.35, 48.85);
|
||||
const span = (id, metres) => ({
|
||||
id,
|
||||
parts: [
|
||||
[
|
||||
[lon, lat],
|
||||
[lon + metres / 73_000, lat],
|
||||
],
|
||||
],
|
||||
});
|
||||
const { sequences } = decodeCoverageTile(
|
||||
encodePanoramaxTile(tile, {
|
||||
sequences: [span('short', 20), span('long', 900), span('mid', 300)],
|
||||
}),
|
||||
tile,
|
||||
);
|
||||
assert.deepEqual(
|
||||
sequences.map((s) => s.id),
|
||||
['long', 'mid', 'short'],
|
||||
);
|
||||
});
|
||||
|
||||
test('a dense grid tile never draws more than the bins allow', () => {
|
||||
const tile = { x: 0, y: 0, z: 0 };
|
||||
const grid = [];
|
||||
for (let i = 0; i < 300; i++)
|
||||
for (let j = 0; j < 60; j++)
|
||||
grid.push({ lon: -170 + i * 1.1, lat: -60 + j * 2 });
|
||||
const { overview } = decodeCoverageTile(
|
||||
encodePanoramaxTile(tile, { grid }),
|
||||
tile,
|
||||
);
|
||||
assert.ok(overview.length <= OVERVIEW_BINS_PER_AXIS ** 2);
|
||||
assert.ok(overview.length > 1000);
|
||||
assert.equal(
|
||||
overview.reduce((sum, p) => sum + p.weight, 0),
|
||||
grid.length,
|
||||
'every picture is still counted',
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty tile decodes to nothing', () => {
|
||||
for (const bytes of [new Uint8Array(0), null, undefined])
|
||||
assert.deepEqual(decodeCoverageTile(bytes, { x: 0, y: 0, z: 0 }), {
|
||||
sequences: [],
|
||||
overview: [],
|
||||
});
|
||||
assert.deepEqual(
|
||||
decodePictures(new Uint8Array(0), { x: 0, y: 0, z: 15 }, 's'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('a z15 tile’s pictures of one sequence become cones; other sequences’ are skipped', () => {
|
||||
const { tile, lon, lat } = tileCentre(2.35, 48.85, 15);
|
||||
const bytes = encodePanoramaxTile(tile, {
|
||||
pictures: [
|
||||
{
|
||||
id: 'a',
|
||||
lon,
|
||||
lat,
|
||||
sequenceId: 's1',
|
||||
heading: 45,
|
||||
type: 'equirectangular',
|
||||
ts: '2023-12-12 07:56:37+00',
|
||||
},
|
||||
{ id: 'b', lon: lon + 0.0001, lat, sequenceId: 's2' },
|
||||
{ id: 'c', lon: lon + 0.0002, lat, sequenceId: 's1' },
|
||||
],
|
||||
});
|
||||
const images = decodePictures(bytes, tile, 's1');
|
||||
assert.deepEqual(
|
||||
images.map((i) => i.id),
|
||||
['a', 'c'],
|
||||
);
|
||||
assert.equal(images[0].compassAngle, 45);
|
||||
assert.equal(images[0].isPano, true);
|
||||
assert.equal(images[0].capturedAt, Date.UTC(2023, 11, 12, 7, 56, 37));
|
||||
assert.ok(near(images[1].lon, lon + 0.0002));
|
||||
assert.deepEqual(decodePictures(bytes, tile, 'nope'), []);
|
||||
// A tile without pictures (below z15) has none to give.
|
||||
const lines = encodePanoramaxTile(tile, {
|
||||
sequences: [
|
||||
{
|
||||
id: 's1',
|
||||
parts: [
|
||||
[
|
||||
[lon, lat],
|
||||
[lon + 0.001, lat],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.deepEqual(decodePictures(lines, tile, 's1'), []);
|
||||
});
|
||||
@@ -0,0 +1,461 @@
|
||||
import { createLineCoverage, sequenceIdFromPick } from '../../lineCoverage.js';
|
||||
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
|
||||
import { passesImageryFilter } from '../../filter.js';
|
||||
import {
|
||||
lonToTileX,
|
||||
latToTileY,
|
||||
overviewZoomForHeight,
|
||||
tileBounds,
|
||||
} from '../../tileMath.js';
|
||||
import { metresBetween } from '../../view.js';
|
||||
import { decodeCoverageTile, decodePictures } from './decode.js';
|
||||
import { createPanoramaxSource } from './source.js';
|
||||
import { createPanoramaxViewer } from './viewer.js';
|
||||
import {
|
||||
COLORS,
|
||||
COVERAGE_LINES_BELOW_M,
|
||||
COVERAGE_TUNING,
|
||||
COVERAGE_Z12_BELOW_M,
|
||||
COVERAGE_Z13_BELOW_M,
|
||||
COVERAGE_Z14_BELOW_M,
|
||||
LINE_CLICK_HINT,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_RADIUS_M,
|
||||
NO_COVERAGE_HINT,
|
||||
OVERVIEW_HINT,
|
||||
PANORAMAX_CREDIT_HTML,
|
||||
PANORAMAX_LABEL,
|
||||
PANORAMAX_NAME,
|
||||
PANORAMAX_PROVIDER_ID,
|
||||
PICK_PREFIX,
|
||||
PICTURE_TILE_EDGE_M,
|
||||
PICTURE_TILE_ZOOM,
|
||||
RATE_LIMITED_MESSAGE,
|
||||
normalizeItem,
|
||||
} from './policy.js';
|
||||
|
||||
export { PANORAMAX_PROVIDER_ID } from './policy.js';
|
||||
|
||||
/** Pictures kept in memory by id (lookups and ‹ › fill it). */
|
||||
const PHOTO_CACHE_SIZE = 2000;
|
||||
|
||||
/**
|
||||
* Sequence lines at z11–14 below 30 km; above, the density grid at z5 and
|
||||
* coarser.
|
||||
*/
|
||||
export function panoramaxZoomFor(heightM) {
|
||||
if (!Number.isFinite(heightM)) return null;
|
||||
if (heightM <= COVERAGE_LINES_BELOW_M) {
|
||||
const zoom =
|
||||
heightM <= COVERAGE_Z14_BELOW_M
|
||||
? 14
|
||||
: heightM <= COVERAGE_Z13_BELOW_M
|
||||
? 13
|
||||
: heightM <= COVERAGE_Z12_BELOW_M
|
||||
? 12
|
||||
: 11;
|
||||
return { zoom, kind: 'sequence' };
|
||||
}
|
||||
return {
|
||||
zoom: Math.min(5, overviewZoomForHeight(heightM) ?? 5),
|
||||
kind: 'overview',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The imagery filter for lines (dated, typed) and grid cells (undated, with
|
||||
* 360° and flat counts): a cell stays while it holds pictures of the kind
|
||||
* asked for.
|
||||
*/
|
||||
export function passesCoverage(record, filter) {
|
||||
if ('hasPano' in record) {
|
||||
if (filter?.pano === 'pano') return record.hasPano;
|
||||
if (filter?.pano === 'flat') return record.hasFlat;
|
||||
return true;
|
||||
}
|
||||
return passesImageryFilter(record, filter);
|
||||
}
|
||||
|
||||
/** Least-recently-used map: reading an entry renews it. */
|
||||
function createLru(limit) {
|
||||
const map = new Map();
|
||||
return {
|
||||
get(key) {
|
||||
if (!map.has(key)) return undefined;
|
||||
const value = map.get(key);
|
||||
map.delete(key);
|
||||
map.set(key, value);
|
||||
return value;
|
||||
},
|
||||
set(key, value) {
|
||||
map.delete(key);
|
||||
map.set(key, value);
|
||||
while (map.size > limit) map.delete(map.keys().next().value);
|
||||
},
|
||||
delete: (key) => map.delete(key),
|
||||
};
|
||||
}
|
||||
|
||||
/** The z15 tile holding a point. */
|
||||
function pictureTileOf({ lon, lat }) {
|
||||
const z = PICTURE_TILE_ZOOM;
|
||||
return { x: lonToTileX(lon, z), y: latToTileY(lat, z), z };
|
||||
}
|
||||
|
||||
/**
|
||||
* The z15 tiles whose pictures surround a point: its own, and the
|
||||
* neighbours it sits within PICTURE_TILE_EDGE_M of (at most four).
|
||||
*/
|
||||
export function pictureTilesAround(point) {
|
||||
const home = pictureTileOf(point);
|
||||
const box = tileBounds(home.x, home.y, home.z);
|
||||
const near = (lon, lat) =>
|
||||
metresBetween(point, { lon, lat }) <= PICTURE_TILE_EDGE_M;
|
||||
const dx = near(box.west, point.lat) ? -1 : near(box.east, point.lat) ? 1 : 0;
|
||||
const dy = near(point.lon, box.north)
|
||||
? -1
|
||||
: near(point.lon, box.south)
|
||||
? 1
|
||||
: 0;
|
||||
const max = 2 ** home.z;
|
||||
const tiles = [home];
|
||||
const add = (x, y) => {
|
||||
if (y < 0 || y >= max) return;
|
||||
tiles.push({ x: (x + max) % max, y, z: home.z });
|
||||
};
|
||||
if (dx) add(home.x + dx, home.y);
|
||||
if (dy) add(home.x, home.y + dy);
|
||||
if (dx && dy) add(home.x + dx, home.y + dy);
|
||||
return tiles;
|
||||
}
|
||||
|
||||
const tileKey = ({ x, y, z }) => `${z}/${x}/${y}`;
|
||||
|
||||
/**
|
||||
* Panoramax Street Level provider: the federated, keyless, open
|
||||
* street-level commons. Coverage from its vector tiles (density points
|
||||
* from orbit, sequence lines near the ground); a click on a line opens that
|
||||
* sequence's picture nearest the click, with cones along the sequence
|
||||
* around it and ‹ › to step through it. Flat pictures show as photos, 360°
|
||||
* ones on the shared WebGL panorama.
|
||||
* @param {{source?: ReturnType<typeof createPanoramaxSource>, viewerOptions?: object}} [options]
|
||||
* @returns {import('../../registry.js').StreetLevelProvider}
|
||||
*/
|
||||
export function createPanoramaxProvider({
|
||||
source = createPanoramaxSource(),
|
||||
viewerOptions = {},
|
||||
} = {}) {
|
||||
return Object.freeze({
|
||||
id: PANORAMAX_PROVIDER_ID,
|
||||
name: PANORAMAX_NAME,
|
||||
label: PANORAMAX_LABEL,
|
||||
requiresKeyId: null,
|
||||
pickPrefix: PICK_PREFIX.root,
|
||||
colors: COLORS,
|
||||
credit: Object.freeze({ html: PANORAMAX_CREDIT_HTML }),
|
||||
defaultOn: false,
|
||||
coverageLabel: Object.freeze({
|
||||
noun: 'sequences',
|
||||
action: LINE_CLICK_HINT,
|
||||
}),
|
||||
|
||||
create(context) {
|
||||
const state = {
|
||||
context,
|
||||
services: context.services,
|
||||
viewer: null,
|
||||
keyRequired: false,
|
||||
keyRejected: false,
|
||||
statusKnown: true,
|
||||
coverage: {
|
||||
zoom: null,
|
||||
tiles: new Map(),
|
||||
stale: new Map(),
|
||||
staleTimer: null,
|
||||
pending: new Map(),
|
||||
lastError: null,
|
||||
holdUntil: 0,
|
||||
holdTimer: null,
|
||||
debounceTimer: null,
|
||||
removeCameraListener: null,
|
||||
terrainReady: null,
|
||||
hint: '',
|
||||
kind: null,
|
||||
},
|
||||
sequence: {
|
||||
selectedId: null,
|
||||
images: [],
|
||||
cache: new Map(),
|
||||
collection: null,
|
||||
loading: false,
|
||||
abort: null,
|
||||
},
|
||||
};
|
||||
const photos = createLru(PHOTO_CACHE_SIZE);
|
||||
/** Where each sequence's cones centre: the picture last opened in it, or a click. */
|
||||
const anchors = new Map();
|
||||
/** The z15 tiles the cones were drawn from. */
|
||||
let drawn = null;
|
||||
/** A line click: its sequence's pictures win the next nearest lookup there. */
|
||||
let preferred = null;
|
||||
|
||||
function remember(item) {
|
||||
const photo = normalizeItem(item);
|
||||
if (photo) photos.set(photo.id, photo);
|
||||
return photo;
|
||||
}
|
||||
|
||||
/** Lookups in flight, shared: ‹ › and the viewer ask for the same picture. */
|
||||
const inflight = new Map();
|
||||
function getPhoto(photoId) {
|
||||
const id = String(photoId);
|
||||
const cached = photos.get(id);
|
||||
if (cached) return Promise.resolve(cached);
|
||||
let lookup = inflight.get(id);
|
||||
if (!lookup) {
|
||||
lookup = source
|
||||
.getItem(id)
|
||||
.then((item) => {
|
||||
const photo = item && remember(item);
|
||||
if (!photo)
|
||||
throw new Error('Panoramax has no position for this picture');
|
||||
return photo;
|
||||
})
|
||||
.finally(() => inflight.delete(id));
|
||||
inflight.set(id, lookup);
|
||||
}
|
||||
return lookup;
|
||||
}
|
||||
|
||||
/** The picture `delta` along its sequence, from its own links (null at an end). */
|
||||
async function step(photo, delta) {
|
||||
if (!photo?.sequenceId) return null;
|
||||
return (delta < 0 ? photo.prevId : photo.nextId) || null;
|
||||
}
|
||||
|
||||
const coverage = createLineCoverage({
|
||||
state,
|
||||
fetchTile: async (tile, { signal }) =>
|
||||
decodeCoverageTile(await source.getTile(tile, { signal }), tile),
|
||||
zoomFor: panoramaxZoomFor,
|
||||
colors: COLORS,
|
||||
pickPrefix: PICK_PREFIX.sequence,
|
||||
renderKey: 'panoramax-coverage',
|
||||
tuning: COVERAGE_TUNING,
|
||||
messages: { rateLimited: RATE_LIMITED_MESSAGE },
|
||||
passes: passesCoverage,
|
||||
});
|
||||
|
||||
const cones = createSequenceCones({
|
||||
state,
|
||||
async loadImages(sequenceId, { signal }) {
|
||||
const anchor = anchors.get(sequenceId);
|
||||
if (!anchor) return [];
|
||||
const tiles = pictureTilesAround(anchor);
|
||||
const lists = await Promise.all(
|
||||
tiles.map(async (tile) =>
|
||||
decodePictures(
|
||||
await source.getTile(tile, { signal }),
|
||||
tile,
|
||||
sequenceId,
|
||||
),
|
||||
),
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
drawn = { sequenceId, tiles: new Set(tiles.map(tileKey)) };
|
||||
const byId = new Map(lists.flat().map((image) => [image.id, image]));
|
||||
return thinImages(
|
||||
[...byId.values()].sort((a, b) => a.capturedAt - b.capturedAt),
|
||||
);
|
||||
},
|
||||
colors: COLORS,
|
||||
pickPrefix: PICK_PREFIX.image,
|
||||
spriteId: 'street-level:panoramax-cones',
|
||||
renderKey: 'panoramax-sequence',
|
||||
onHighlight: (sequenceId, selected) =>
|
||||
coverage.recolorSequence(sequenceId, selected),
|
||||
});
|
||||
|
||||
/**
|
||||
* The cones show the z15 tiles around one picture: once a picture of
|
||||
* the selected sequence opens outside them (‹ ›, a cone), re-centre.
|
||||
*/
|
||||
function recentreCones(photo) {
|
||||
if (!photo?.sequenceId) return;
|
||||
if (state.sequence.selectedId !== photo.sequenceId) return;
|
||||
if (drawn?.sequenceId !== photo.sequenceId) return;
|
||||
if (drawn.tiles.has(tileKey(pictureTileOf(photo)))) return;
|
||||
cones.select(photo.sequenceId, { force: true });
|
||||
}
|
||||
|
||||
const viewer = createPanoramaxViewer({
|
||||
getPhoto: async (photoId) => {
|
||||
const photo = await getPhoto(photoId);
|
||||
if (photo.sequenceId) anchors.set(photo.sequenceId, photo);
|
||||
return photo;
|
||||
},
|
||||
step,
|
||||
onStep: (photoId) =>
|
||||
context.actions.openImage(photoId, { frame: false }),
|
||||
onShown: recentreCones,
|
||||
render: context.services?.render,
|
||||
...viewerOptions,
|
||||
});
|
||||
|
||||
/** Nearest of `items` within the search radius of a point, or null. */
|
||||
function nearestOf(items, point) {
|
||||
let best = null;
|
||||
for (const item of items) {
|
||||
const photo = remember(item);
|
||||
if (!photo) continue;
|
||||
const metres = metresBetween(point, photo);
|
||||
if (metres > NEAREST_RADIUS_M) continue;
|
||||
if (!best || metres < best.metres) best = { photo, metres };
|
||||
}
|
||||
return best?.photo ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
async status() {
|
||||
return { configured: true };
|
||||
},
|
||||
|
||||
init(cesiumViewer) {
|
||||
state.viewer = cesiumViewer;
|
||||
cones.ensureCollections(cesiumViewer);
|
||||
cones.setVisible(false);
|
||||
},
|
||||
|
||||
activate(cesiumViewer) {
|
||||
state.viewer = cesiumViewer;
|
||||
cones.setVisible(true);
|
||||
coverage.attach(cesiumViewer);
|
||||
},
|
||||
|
||||
deactivate() {
|
||||
coverage.detach();
|
||||
coverage.clear();
|
||||
coverage.resetErrors();
|
||||
cones.clearSelection();
|
||||
cones.setVisible(false);
|
||||
drawn = null;
|
||||
preferred = null;
|
||||
},
|
||||
|
||||
destroy(cesiumViewer) {
|
||||
coverage.detach();
|
||||
coverage.clear();
|
||||
coverage.resetErrors();
|
||||
cones.destroy(cesiumViewer);
|
||||
},
|
||||
|
||||
refreshCoverage: () => coverage.refresh(),
|
||||
|
||||
/** The filter changed: redraw. Each draw reads it from the context. */
|
||||
setFilter() {
|
||||
coverage.rebuild();
|
||||
cones.rerender();
|
||||
},
|
||||
|
||||
setSurface() {
|
||||
coverage.setSurface();
|
||||
cones.rerender();
|
||||
},
|
||||
|
||||
coverageStats() {
|
||||
const lines = state.coverage.kind === 'sequence';
|
||||
// Density dots are not sequences: they are not counted as such.
|
||||
const count = lines ? coverage.sequenceCount() : 0;
|
||||
const loading = state.coverage.pending.size > 0;
|
||||
const empty =
|
||||
lines && !loading && count === 0 && !state.coverage.lastError;
|
||||
const fallback = empty
|
||||
? NO_COVERAGE_HINT
|
||||
: state.coverage.kind === 'overview'
|
||||
? OVERVIEW_HINT
|
||||
: '';
|
||||
return {
|
||||
count,
|
||||
zoom: state.coverage.zoom,
|
||||
kind: state.coverage.kind,
|
||||
loading,
|
||||
hint: state.coverage.hint || fallback,
|
||||
error: state.coverage.lastError,
|
||||
keyRequired: false,
|
||||
keyRejected: false,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* A line opens its sequence's picture nearest the click (the cones
|
||||
* follow once it is on screen); a cone opens its picture.
|
||||
*/
|
||||
handlePick(pickId, { groundPoint } = {}) {
|
||||
if (pickId.startsWith(PICK_PREFIX.image)) {
|
||||
context.actions.openImage(pickId.slice(PICK_PREFIX.image.length));
|
||||
return true;
|
||||
}
|
||||
const sequenceId = sequenceIdFromPick(pickId, PICK_PREFIX.sequence);
|
||||
if (!sequenceId) return false;
|
||||
const point = groundPoint?.();
|
||||
if (!point) {
|
||||
// No ground under the click: show the line selected, nothing more.
|
||||
cones.select(sequenceId);
|
||||
return true;
|
||||
}
|
||||
anchors.set(sequenceId, { ...point, sequenceId });
|
||||
preferred = { sequenceId, lon: point.lon, lat: point.lat };
|
||||
context.actions.openNearest(point);
|
||||
return true;
|
||||
},
|
||||
|
||||
selectSequence: (sequenceId) =>
|
||||
cones.select(sequenceId, { force: true }),
|
||||
clearSequence: () => {
|
||||
cones.clearSelection();
|
||||
drawn = null;
|
||||
},
|
||||
sequenceStats: () => ({
|
||||
selectedId: state.sequence.selectedId,
|
||||
images: state.sequence.images.length,
|
||||
loading: state.sequence.loading,
|
||||
}),
|
||||
|
||||
/**
|
||||
* The nearest picture within the search radius that passes the
|
||||
* imagery filter, or null. After a click on a line, that line's
|
||||
* sequence is asked first.
|
||||
*/
|
||||
async nearestImage({ lat, lon }, { signal } = {}) {
|
||||
const point = { lat, lon };
|
||||
const filter = context.getFilter();
|
||||
const wanted =
|
||||
preferred && metresBetween(preferred, point) < 1
|
||||
? preferred.sequenceId
|
||||
: null;
|
||||
preferred = null;
|
||||
const ask = (collections) =>
|
||||
source.nearestItems(
|
||||
{
|
||||
lat,
|
||||
lon,
|
||||
radius: NEAREST_RADIUS_M,
|
||||
limit: NEAREST_LIMIT,
|
||||
collections,
|
||||
pano: filter.pano,
|
||||
sinceMs: filter.sinceMs,
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
if (wanted) {
|
||||
const hit = nearestOf(await ask(wanted), point);
|
||||
if (hit) return hit.id;
|
||||
}
|
||||
return nearestOf(await ask(null), point)?.id ?? null;
|
||||
},
|
||||
|
||||
viewer,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.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; z11–14 are drawn (a dense z11 tile
|
||||
* over Paris is ~1.4 MB and 3,800 sequences, z12 ~800 kB). Higher up, the
|
||||
* z0–5 `grid` layer's density cells (0.1°, ~10 km) draw as points: below
|
||||
* 30 km they would be a few dots over a whole city.
|
||||
*/
|
||||
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_LINES_BELOW_M = 30_000;
|
||||
export const COVERAGE_TUNING = Object.freeze({
|
||||
maxTiles: 9,
|
||||
overviewMaxTiles: 6,
|
||||
// Each tile draws at most 1,600 lines, its longest first: 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,
|
||||
lineWidthPx: 2.5,
|
||||
overviewPointPx: 4,
|
||||
moveDebounceMs: 320,
|
||||
viewRangeMinM: 2_500,
|
||||
viewRangePerHeight: 10,
|
||||
viewNearM: 1_000,
|
||||
});
|
||||
/**
|
||||
* Grid cells per overview tile, at most: a z0 tile holds ~70,000 cells
|
||||
* (1.9 MB), drawn as this many binned points.
|
||||
*/
|
||||
export const OVERVIEW_BINS_PER_AXIS = 96;
|
||||
|
||||
export const RATE_LIMITED_MESSAGE =
|
||||
'Panoramax is busy — coverage resumes on its own';
|
||||
export const NO_COVERAGE_HINT = 'no Panoramax pictures in view';
|
||||
/** From high up the dots are where pictures are, not sequences to click. */
|
||||
export const OVERVIEW_HINT = 'zoom in to click a Panoramax sequence';
|
||||
|
||||
/** 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;
|
||||
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;
|
||||
/** Requests at once to the API (tiles have their own budget). */
|
||||
export const API_CONCURRENCY = 4;
|
||||
export const TILE_CONCURRENCY = 6;
|
||||
|
||||
/** Pictures asked for around a point; the API returns them nearest first. */
|
||||
export const NEAREST_LIMIT = 10;
|
||||
/** Any picture within the radius, whichever way it looks (2–180°). */
|
||||
export const NEAREST_FOV_TOLERANCE_DEG = 180;
|
||||
|
||||
/** 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 the photo there';
|
||||
|
||||
/** 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();
|
||||
return id ? decodeURIComponent(id) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
: []),
|
||||
];
|
||||
const name = names.find((value) => typeof value === 'string' && value.trim());
|
||||
if (!name || /\S+@\S+\.\S+/.test(name)) return null;
|
||||
return name.trim().slice(0, 80);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
const [lon, lat] = Array.isArray(item.geometry?.coordinates)
|
||||
? item.geometry.coordinates.map(Number)
|
||||
: [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),
|
||||
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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
COLORS,
|
||||
PICK_PREFIX,
|
||||
isEquirectangular,
|
||||
licenseLabel,
|
||||
normalizeItem,
|
||||
panoramaxPictureUrl,
|
||||
panoramaxTimeMs,
|
||||
} from './policy.js';
|
||||
import { PROVIDER_COLORS } from '../../policy.js';
|
||||
import { stacItem } from '../../../../testSupport/panoramaxFixtures.mjs';
|
||||
|
||||
test('Panoramax draws in its own orange, and every pick id starts pnx:', () => {
|
||||
assert.equal(COLORS.coverage, '#ff6f00');
|
||||
assert.equal(PROVIDER_COLORS.panoramax, '#ff6f00');
|
||||
for (const prefix of Object.values(PICK_PREFIX))
|
||||
assert.ok(prefix.startsWith('pnx:'));
|
||||
// One colour per source.
|
||||
assert.equal(
|
||||
new Set(Object.values(PROVIDER_COLORS)).size,
|
||||
Object.keys(PROVIDER_COLORS).length,
|
||||
);
|
||||
});
|
||||
|
||||
test('capture times parse in the API’s ISO form, the tiles’ Postgres form, and as bare days', () => {
|
||||
assert.equal(
|
||||
panoramaxTimeMs('2023-12-12T07:54:47+00:00'),
|
||||
Date.UTC(2023, 11, 12, 7, 54, 47),
|
||||
);
|
||||
assert.equal(
|
||||
panoramaxTimeMs('2023-12-12 07:56:37+00'),
|
||||
Date.UTC(2023, 11, 12, 7, 56, 37),
|
||||
);
|
||||
assert.equal(
|
||||
panoramaxTimeMs('2025-08-13 07:45:51.98+00'),
|
||||
Date.UTC(2025, 7, 13, 7, 45, 51, 980),
|
||||
);
|
||||
// Microseconds are cut to what Date.parse reads.
|
||||
assert.equal(
|
||||
panoramaxTimeMs('2025-08-03T14:38:51.838000+00:00'),
|
||||
Date.UTC(2025, 7, 3, 14, 38, 51, 838),
|
||||
);
|
||||
// A sequence's day: its trailing "-11" is the day, not a UTC offset.
|
||||
assert.equal(panoramaxTimeMs('2025-08-11'), Date.UTC(2025, 7, 11));
|
||||
assert.equal(
|
||||
panoramaxTimeMs('2025-08-13 09:00:00+02'),
|
||||
Date.UTC(2025, 7, 13, 7),
|
||||
);
|
||||
for (const bad of ['', 'junk', '1970-01-01', null, 42])
|
||||
assert.equal(panoramaxTimeMs(bad), null, String(bad));
|
||||
});
|
||||
|
||||
test('a STAC item becomes a picture: position, heading, 360°, licence, links and assets', () => {
|
||||
const photo = normalizeItem(
|
||||
stacItem({
|
||||
id: 'a',
|
||||
lon: 2.35,
|
||||
lat: 48.85,
|
||||
sequenceId: 's',
|
||||
heading: 370,
|
||||
pano: true,
|
||||
license: 'etalab-2.0',
|
||||
prevId: 'p',
|
||||
nextId: 'n',
|
||||
}),
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
id: photo.id,
|
||||
sequenceId: photo.sequenceId,
|
||||
lon: photo.lon,
|
||||
lat: photo.lat,
|
||||
heading: photo.heading,
|
||||
isPano: photo.isPano,
|
||||
license: photo.license,
|
||||
width: photo.width,
|
||||
prevId: photo.prevId,
|
||||
nextId: photo.nextId,
|
||||
creator: photo.creator,
|
||||
},
|
||||
{
|
||||
id: 'a',
|
||||
sequenceId: 's',
|
||||
lon: 2.35,
|
||||
lat: 48.85,
|
||||
heading: 10,
|
||||
isPano: true,
|
||||
license: 'Licence Ouverte 2.0',
|
||||
width: 5760,
|
||||
prevId: 'p',
|
||||
nextId: 'n',
|
||||
creator: 'tester',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
photo.urls.sd,
|
||||
'https://panoramax.example.org/pictures/a/sd.jpg',
|
||||
);
|
||||
assert.equal(photo.capturedAt, Date.UTC(2025, 5, 1, 10));
|
||||
});
|
||||
|
||||
test('a picture without links, heading or a usable position', () => {
|
||||
const bare = normalizeItem({
|
||||
id: 7,
|
||||
geometry: { coordinates: [1, 2] },
|
||||
properties: {},
|
||||
});
|
||||
assert.equal(bare.id, '7');
|
||||
assert.equal(bare.heading, null);
|
||||
assert.equal(bare.prevId, null);
|
||||
assert.equal(bare.nextId, null);
|
||||
assert.equal(bare.sequenceId, null);
|
||||
assert.equal(bare.isPano, false);
|
||||
assert.equal(bare.capturedAt, null);
|
||||
assert.deepEqual(bare.urls, { thumb: null, sd: null, hd: null });
|
||||
for (const item of [
|
||||
null,
|
||||
{ geometry: { coordinates: [1, 2] } },
|
||||
{ id: 'x', geometry: { coordinates: ['a', 2] } },
|
||||
{ id: 'x', geometry: { coordinates: [181, 2] } },
|
||||
{ id: 'x', geometry: { coordinates: [1, 91] } },
|
||||
{ id: 'x' },
|
||||
])
|
||||
assert.equal(normalizeItem(item), null, JSON.stringify(item));
|
||||
});
|
||||
|
||||
test('asset links that are not https are dropped', () => {
|
||||
const item = stacItem({ id: 'a', lon: 1, lat: 2 });
|
||||
item.assets.sd.href = 'http://insecure.example/sd.jpg';
|
||||
item.assets.hd.href = 'javascript:alert(1)';
|
||||
const photo = normalizeItem(item);
|
||||
assert.equal(photo.urls.sd, null);
|
||||
assert.equal(photo.urls.hd, null);
|
||||
assert.ok(photo.urls.thumb.startsWith('https://'));
|
||||
});
|
||||
|
||||
test('an account named by an email address is not shown as the creator', () => {
|
||||
const named = (producer) =>
|
||||
normalizeItem(stacItem({ id: 'a', lon: 1, lat: 2, producer })).creator;
|
||||
assert.equal(named('[email protected]'), null);
|
||||
assert.equal(named('Robot8A'), 'Robot8A');
|
||||
assert.equal(named(' padded '), 'padded');
|
||||
assert.equal(named('x'.repeat(200)).length, 80);
|
||||
// The producer role among the providers serves when the property is missing.
|
||||
const item = stacItem({ id: 'a', lon: 1, lat: 2 });
|
||||
delete item.properties['geovisio:producer'];
|
||||
item.providers = [
|
||||
{ name: 'host', roles: ['host'] },
|
||||
{ name: 'mapper', roles: ['producer'] },
|
||||
];
|
||||
assert.equal(normalizeItem(item).creator, 'mapper');
|
||||
});
|
||||
|
||||
test('360°: a full field of view, or the equirectangular projection', () => {
|
||||
assert.equal(
|
||||
isEquirectangular({
|
||||
properties: { 'pers:interior_orientation': { field_of_view: 360 } },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isEquirectangular({ properties: { 'pers:projection': 'equirectangular' } }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isEquirectangular({
|
||||
properties: { 'pers:interior_orientation': { field_of_view: 92 } },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(isEquirectangular(null), false);
|
||||
});
|
||||
|
||||
test('licences read as the caption names them', () => {
|
||||
assert.equal(licenseLabel('CC-BY-SA-4.0'), 'CC BY-SA 4.0');
|
||||
assert.equal(licenseLabel('etalab-2.0'), 'Licence Ouverte 2.0');
|
||||
assert.equal(licenseLabel('CC0-1.0'), 'CC0');
|
||||
assert.equal(licenseLabel('ODbL-1.0'), 'ODbL-1.0');
|
||||
assert.equal(licenseLabel('proprietary'), null);
|
||||
assert.equal(licenseLabel(''), null);
|
||||
assert.equal(licenseLabel(undefined), null);
|
||||
assert.equal(licenseLabel('x'.repeat(100)).length, 40);
|
||||
});
|
||||
|
||||
test('the caption link opens the picture in the Panoramax viewer', () => {
|
||||
assert.equal(
|
||||
panoramaxPictureUrl('d5467bbb-4357'),
|
||||
'https://api.panoramax.xyz/#pic=d5467bbb-4357&focus=pic',
|
||||
);
|
||||
assert.equal(panoramaxPictureUrl(''), 'https://api.panoramax.xyz/');
|
||||
assert.equal(panoramaxPictureUrl(null), 'https://api.panoramax.xyz/');
|
||||
assert.ok(panoramaxPictureUrl('a&b=c').includes('pic=a%26b%3Dc'));
|
||||
});
|
||||
@@ -0,0 +1,526 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
createPanoramaxProvider,
|
||||
panoramaxZoomFor,
|
||||
passesCoverage,
|
||||
pictureTilesAround,
|
||||
} from './index.js';
|
||||
import { NO_COVERAGE_HINT, OVERVIEW_HINT, PICK_PREFIX } from './policy.js';
|
||||
import { validateProviders } from '../../registry.js';
|
||||
import { tileBounds } from '../../tileMath.js';
|
||||
import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs';
|
||||
import {
|
||||
coverageViewer,
|
||||
tileCentre,
|
||||
} from '../../../../testSupport/streetLevelFakes.mjs';
|
||||
import {
|
||||
encodePanoramaxTile,
|
||||
stacItem,
|
||||
} from '../../../../testSupport/panoramaxFixtures.mjs';
|
||||
|
||||
const ORIGIN = { lon: 2.3495, lat: 48.8874 };
|
||||
/** A point `metres` east of ORIGIN. */
|
||||
const east = (metres) => ({
|
||||
lon: ORIGIN.lon + metres / 73_300,
|
||||
lat: ORIGIN.lat,
|
||||
});
|
||||
|
||||
/**
|
||||
* A sequence `seq` of `count` pictures 5 m apart heading east from ORIGIN
|
||||
* (`offset` m in), each linked to its neighbours.
|
||||
*/
|
||||
function sequence(seq, count, { offset = 0, pano = false } = {}) {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const at = east(offset + i * 5);
|
||||
return stacItem({
|
||||
id: `${seq}-${i}`,
|
||||
sequenceId: seq,
|
||||
...at,
|
||||
pano,
|
||||
prevId: i ? `${seq}-${i - 1}` : null,
|
||||
nextId: i < count - 1 ? `${seq}-${i + 1}` : null,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** A source over `items`, answering nearest lookups by distance, recording calls. */
|
||||
function fakeSource({ items = [], tiles = () => new Uint8Array(0) } = {}) {
|
||||
const calls = { nearest: [], items: [], tiles: [] };
|
||||
const metres = (a, b) =>
|
||||
Math.hypot((a.lon - b.lon) * 73_300, (a.lat - b.lat) * 111_200);
|
||||
return {
|
||||
calls,
|
||||
getTile: async (tile) => {
|
||||
calls.tiles.push(`${tile.z}/${tile.x}/${tile.y}`);
|
||||
return tiles(tile);
|
||||
},
|
||||
getItem: async (id) => {
|
||||
calls.items.push(id);
|
||||
return items.find((item) => item.id === id) ?? null;
|
||||
},
|
||||
nearestItems: async (query) => {
|
||||
calls.nearest.push(query);
|
||||
const at = { lon: query.lon, lat: query.lat };
|
||||
return items
|
||||
.filter(
|
||||
(item) => !query.collections || item.collection === query.collections,
|
||||
)
|
||||
.map((item) => ({
|
||||
item,
|
||||
d: metres(at, {
|
||||
lon: item.geometry.coordinates[0],
|
||||
lat: item.geometry.coordinates[1],
|
||||
}),
|
||||
}))
|
||||
.filter(({ d }) => d <= query.radius)
|
||||
.sort((a, b) => a.d - b.d)
|
||||
.slice(0, query.limit)
|
||||
.map(({ item }) => item);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) {
|
||||
const actions = { opened: [], nearest: [], errors: [], instance: null };
|
||||
return {
|
||||
actions,
|
||||
context: {
|
||||
services: {},
|
||||
getFilter: () => filter,
|
||||
isActive: () => true,
|
||||
groundCaster: null,
|
||||
meshSampler: null,
|
||||
getSurface: () => 'draped',
|
||||
notify() {},
|
||||
actions: {
|
||||
openImage: async (id, options) => {
|
||||
actions.opened.push({ id, options });
|
||||
// As the core does, errors included (a test may not mount a viewer).
|
||||
await actions.instance?.viewer.open(id).catch((error) => {
|
||||
actions.errors.push(error.message);
|
||||
});
|
||||
return true;
|
||||
},
|
||||
openNearest: (point) => actions.nearest.push(point),
|
||||
reportError: (message) => actions.errors.push(message),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setup(sourceOptions, contextOptions) {
|
||||
const source = fakeSource(sourceOptions);
|
||||
const loads = [];
|
||||
const definition = createPanoramaxProvider({
|
||||
source,
|
||||
viewerOptions: {
|
||||
load: async (url) => {
|
||||
loads.push(url);
|
||||
return { src: url };
|
||||
},
|
||||
createRenderer: () => {
|
||||
throw new Error('no WebGL in Node');
|
||||
},
|
||||
},
|
||||
});
|
||||
const { context, actions } = fakeContext(contextOptions);
|
||||
const instance = definition.create(context);
|
||||
actions.instance = instance;
|
||||
return { definition, instance, source, actions, loads };
|
||||
}
|
||||
|
||||
test('Panoramax registers keyless and off by default, with both filters and its own wording', () => {
|
||||
const { definition } = setup();
|
||||
assert.equal(definition.id, 'panoramax');
|
||||
assert.equal(definition.label, 'PANORAMAX');
|
||||
assert.equal(definition.requiresKeyId, null);
|
||||
assert.equal(definition.defaultOn, false);
|
||||
assert.equal(definition.filters, undefined, 'pano and since both apply');
|
||||
assert.notEqual(definition.highlightsSequence, false, 'lines are sequences');
|
||||
assert.match(definition.credit.html, /Panoramax<\/a> contributors/);
|
||||
assert.equal(
|
||||
definition.coverageLabel.action,
|
||||
'click a line for the photo there',
|
||||
);
|
||||
assert.doesNotThrow(() => validateProviders([definition]));
|
||||
});
|
||||
|
||||
test('lines at z11–14 below 30 km; density points from z5 down to z0 above', () => {
|
||||
assert.deepEqual(panoramaxZoomFor(500), { zoom: 14, kind: 'sequence' });
|
||||
assert.deepEqual(panoramaxZoomFor(1_800), { zoom: 14, kind: 'sequence' });
|
||||
assert.deepEqual(panoramaxZoomFor(4_000), { zoom: 13, kind: 'sequence' });
|
||||
assert.deepEqual(panoramaxZoomFor(12_000), { zoom: 12, kind: 'sequence' });
|
||||
assert.deepEqual(panoramaxZoomFor(25_000), { zoom: 11, kind: 'sequence' });
|
||||
assert.deepEqual(panoramaxZoomFor(30_000), { zoom: 11, kind: 'sequence' });
|
||||
assert.deepEqual(panoramaxZoomFor(30_001), { zoom: 5, kind: 'overview' });
|
||||
assert.deepEqual(panoramaxZoomFor(100_000), { zoom: 5, kind: 'overview' });
|
||||
assert.deepEqual(panoramaxZoomFor(2_000_000), { zoom: 3, kind: 'overview' });
|
||||
assert.deepEqual(panoramaxZoomFor(20_000_000), { zoom: 0, kind: 'overview' });
|
||||
assert.equal(panoramaxZoomFor(NaN), null);
|
||||
});
|
||||
|
||||
test('the imagery filter: lines by type and day, density cells by what they hold', () => {
|
||||
const old = Date.UTC(2020, 0, 1);
|
||||
const since = { pano: 'all', sinceMs: Date.UTC(2024, 0, 1) };
|
||||
assert.equal(
|
||||
passesCoverage({ isPano: false, capturedAt: old }, since),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
passesCoverage(
|
||||
{ isPano: true, capturedAt: Date.UTC(2025, 0, 1) },
|
||||
{
|
||||
pano: 'pano',
|
||||
sinceMs: null,
|
||||
},
|
||||
),
|
||||
true,
|
||||
);
|
||||
const cell = { hasPano: false, hasFlat: true, capturedAt: null };
|
||||
assert.equal(passesCoverage(cell, { pano: 'pano', sinceMs: null }), false);
|
||||
assert.equal(passesCoverage(cell, { pano: 'flat', sinceMs: null }), true);
|
||||
assert.equal(passesCoverage(cell, since), true, 'cells are undated: kept');
|
||||
assert.equal(passesCoverage(cell, null), true);
|
||||
});
|
||||
|
||||
test('cones read the z15 tile around a picture, and its neighbours near an edge', () => {
|
||||
const home = tileCentre(ORIGIN.lon, ORIGIN.lat, 15);
|
||||
assert.equal(pictureTilesAround(home).length, 1, 'mid-tile: one tile');
|
||||
const box = tileBounds(home.tile.x, home.tile.y, 15);
|
||||
const corner = { lon: box.west + 0.0002, lat: box.north - 0.0002 };
|
||||
const tiles = pictureTilesAround(corner).map((t) => `${t.x}/${t.y}`);
|
||||
const { x, y } = home.tile;
|
||||
assert.deepEqual(tiles, [
|
||||
`${x}/${y}`,
|
||||
`${x - 1}/${y}`,
|
||||
`${x}/${y - 1}`,
|
||||
`${x - 1}/${y - 1}`,
|
||||
]);
|
||||
// Across the antimeridian the column wraps.
|
||||
const dateLine = pictureTilesAround({ lon: -179.9999, lat: 0.5 });
|
||||
assert.equal(dateLine[1].x, 2 ** 15 - 1);
|
||||
});
|
||||
|
||||
test('the nearest picture within 50 m that passes the filter; filters go to the API', async () => {
|
||||
const items = [
|
||||
...sequence('far', 1, { offset: 80 }),
|
||||
...sequence('a', 3, { offset: 10 }),
|
||||
];
|
||||
const { instance, source } = setup(
|
||||
{ items },
|
||||
{ filter: { pano: 'pano', sinceMs: 123 } },
|
||||
);
|
||||
const id = await instance.nearestImage(east(0));
|
||||
assert.equal(id, 'a-0');
|
||||
assert.equal(source.calls.nearest.length, 1);
|
||||
assert.equal(source.calls.nearest[0].pano, 'pano');
|
||||
assert.equal(source.calls.nearest[0].sinceMs, 123);
|
||||
assert.equal(source.calls.nearest[0].collections, null);
|
||||
assert.equal(await instance.nearestImage(east(300)), null, 'none near');
|
||||
});
|
||||
|
||||
test('a click on a line opens that sequence’s picture nearest the click, not another’s', async () => {
|
||||
const items = [
|
||||
...sequence('other', 2, { offset: 0 }),
|
||||
...sequence('clicked', 4, { offset: 8 }),
|
||||
];
|
||||
const { instance, actions, source } = setup({ items });
|
||||
assert.equal(
|
||||
instance.handlePick(`${PICK_PREFIX.sequence}clicked~1`, {
|
||||
groundPoint: () => east(1),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(actions.nearest, [east(1)]);
|
||||
// The core asks the provider for the nearest picture to that point.
|
||||
assert.equal(await instance.nearestImage(east(1)), 'clicked-0');
|
||||
assert.equal(source.calls.nearest[0].collections, 'clicked');
|
||||
// Only once: the next lookup (OPEN, another click) is any sequence's.
|
||||
assert.equal(await instance.nearestImage(east(1)), 'other-0');
|
||||
});
|
||||
|
||||
test('a line click whose sequence has nothing near falls back to any picture there', async () => {
|
||||
const items = [
|
||||
...sequence('near', 1),
|
||||
...sequence('clicked', 1, { offset: 400 }),
|
||||
];
|
||||
const { instance, source } = setup({ items });
|
||||
instance.handlePick(`${PICK_PREFIX.sequence}clicked`, {
|
||||
groundPoint: () => east(0),
|
||||
});
|
||||
assert.equal(await instance.nearestImage(east(0)), 'near-0');
|
||||
assert.deepEqual(
|
||||
source.calls.nearest.map((q) => q.collections),
|
||||
['clicked', null],
|
||||
);
|
||||
});
|
||||
|
||||
test('a cone opens its picture; foreign ids are not picked', () => {
|
||||
const { instance, actions } = setup();
|
||||
assert.equal(instance.handlePick(`${PICK_PREFIX.image}abc`), true);
|
||||
assert.equal(actions.opened[0].id, 'abc');
|
||||
assert.equal(instance.handlePick('kv:line:1'), false);
|
||||
assert.equal(instance.handlePick('pnx:other'), false);
|
||||
});
|
||||
|
||||
/** A provider and its viewer mounted, opening pictures through the fake core. */
|
||||
async function mounted(sourceOptions, contextOptions) {
|
||||
const env = setup(sourceOptions, contextOptions);
|
||||
const poses = [];
|
||||
env.instance.viewer.onPose((pose) => poses.push(pose.imageId));
|
||||
const host = fakeHost();
|
||||
await env.instance.viewer.mount(host);
|
||||
const [, , prev, next] = host.children[0].children;
|
||||
return { ...env, poses, prev, next };
|
||||
}
|
||||
|
||||
test('› and ‹ step along the picture’s links without moving the camera, asking only for the next picture', async () => {
|
||||
const items = sequence('s', 3);
|
||||
const env = await mounted({ items });
|
||||
await env.instance.viewer.open('s-0');
|
||||
assert.ok(await until(() => env.prev.disabled), 'the first picture: no ‹');
|
||||
env.next.fire('click');
|
||||
assert.ok(await until(() => env.poses.at(-1) === 's-1'));
|
||||
assert.deepEqual(env.actions.opened.at(-1), {
|
||||
id: 's-1',
|
||||
options: { frame: false },
|
||||
});
|
||||
env.next.fire('click');
|
||||
assert.ok(await until(() => env.poses.at(-1) === 's-2'));
|
||||
assert.ok(await until(() => env.next.disabled), 'the last picture: no ›');
|
||||
assert.deepEqual(env.source.calls.items, ['s-0', 's-1', 's-2']);
|
||||
env.prev.fire('click');
|
||||
assert.ok(await until(() => env.poses.at(-1) === 's-1'));
|
||||
assert.equal(env.source.calls.items.length, 3, 's-1 came from the cache');
|
||||
});
|
||||
|
||||
test('one lookup for a picture asked twice at once', async () => {
|
||||
const items = sequence('s', 1);
|
||||
const { instance, source } = setup({ items });
|
||||
let release;
|
||||
const gate = new Promise((resolve) => (release = resolve));
|
||||
const getItem = source.getItem;
|
||||
source.getItem = async (id) => {
|
||||
await gate;
|
||||
return getItem(id);
|
||||
};
|
||||
const host = fakeHost();
|
||||
await instance.viewer.mount(host);
|
||||
const first = instance.viewer.open('s-0');
|
||||
const second = instance.viewer.open('s-0');
|
||||
release();
|
||||
await Promise.all([first, second]);
|
||||
assert.deepEqual(source.calls.items, ['s-0']);
|
||||
});
|
||||
|
||||
test('a picture Panoramax does not know fails the open with a reason', async () => {
|
||||
const env = await mounted({ items: [] });
|
||||
await assert.rejects(env.instance.viewer.open('ghost'), /no position/);
|
||||
});
|
||||
|
||||
/** Cones on a stand-in Cesium viewer; `count()` reads how many are drawn. */
|
||||
async function withCones(t, env) {
|
||||
const Cesium = await import('cesium');
|
||||
// The cone glyph draws on a canvas: any drawing call is fine.
|
||||
const drawing = new Proxy({}, { get: () => () => {}, set: () => true });
|
||||
const saved = globalThis.document;
|
||||
globalThis.document = {
|
||||
createElement: () => ({ getContext: () => drawing }),
|
||||
};
|
||||
t.after(() => (globalThis.document = saved));
|
||||
let collection = null;
|
||||
env.instance.init({
|
||||
scene: {
|
||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||
updateHeight: () => () => {},
|
||||
getHeight: () => undefined,
|
||||
primitives: { add: (c) => (collection = c), remove() {} },
|
||||
preRender: { addEventListener: () => () => {} },
|
||||
},
|
||||
camera: { positionWC: new Cesium.Cartesian3(0, 0, 1e8) },
|
||||
});
|
||||
return { count: () => collection?.length ?? 0 };
|
||||
}
|
||||
|
||||
/** z15 tiles holding every item of `items` as a picture. */
|
||||
const pictureTiles = (items) => (tile) => {
|
||||
if (tile.z !== 15) return new Uint8Array(0);
|
||||
const box = tileBounds(tile.x, tile.y, tile.z);
|
||||
const inside = items.filter(
|
||||
({
|
||||
geometry: {
|
||||
coordinates: [lon, lat],
|
||||
},
|
||||
}) =>
|
||||
lon >= box.west && lon < box.east && lat <= box.north && lat > box.south,
|
||||
);
|
||||
return encodePanoramaxTile(tile, {
|
||||
pictures: inside.map((item) => ({
|
||||
id: item.id,
|
||||
lon: item.geometry.coordinates[0],
|
||||
lat: item.geometry.coordinates[1],
|
||||
sequenceId: item.collection,
|
||||
ts: item.properties.datetime.replace('T', ' ').replace('+00:00', '+00'),
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
test('once a picture is on screen its sequence’s cones load from the z15 tile around it', async (t) => {
|
||||
const items = [...sequence('s', 6), ...sequence('x', 3, { offset: 2 })];
|
||||
const env = await mounted({ items, tiles: pictureTiles(items) });
|
||||
const cones = await withCones(t, env);
|
||||
await env.instance.viewer.open('s-2');
|
||||
await env.instance.selectSequence('s');
|
||||
assert.ok(await until(() => !env.instance.sequenceStats().loading));
|
||||
assert.equal(env.instance.sequenceStats().selectedId, 's');
|
||||
assert.equal(env.instance.sequenceStats().images, 6, 'only sequence s');
|
||||
assert.equal(cones.count(), 6);
|
||||
assert.ok(env.source.calls.tiles.every((key) => key.startsWith('15/')));
|
||||
});
|
||||
|
||||
test('a sequence selected with no picture or click to centre on draws no cones', async (t) => {
|
||||
const items = sequence('s', 3);
|
||||
const env = await mounted({ items, tiles: pictureTiles(items) });
|
||||
await withCones(t, env);
|
||||
env.instance.handlePick(`${PICK_PREFIX.sequence}s`, {
|
||||
groundPoint: () => null,
|
||||
});
|
||||
assert.ok(await until(() => !env.instance.sequenceStats().loading));
|
||||
assert.equal(env.instance.sequenceStats().selectedId, 's', 'highlighted');
|
||||
assert.equal(env.instance.sequenceStats().images, 0);
|
||||
assert.deepEqual(env.source.calls.tiles, []);
|
||||
assert.deepEqual(env.actions.nearest, []);
|
||||
});
|
||||
|
||||
test('the cones re-centre when › walks into another z15 tile', async (t) => {
|
||||
// 400 pictures 5 m apart span 2 km: more than two z15 tiles (~800 m).
|
||||
const items = sequence('long', 400);
|
||||
const env = await mounted({ items, tiles: pictureTiles(items) });
|
||||
await withCones(t, env);
|
||||
await env.instance.viewer.open('long-0');
|
||||
await env.instance.selectSequence('long');
|
||||
assert.ok(await until(() => !env.instance.sequenceStats().loading));
|
||||
const firstTiles = new Set(env.source.calls.tiles);
|
||||
const first = env.instance.sequenceStats().images;
|
||||
assert.ok(first > 0 && first < 400, `part of the sequence: ${first}`);
|
||||
// Jump to the far end: the cones follow.
|
||||
await env.instance.viewer.open('long-399');
|
||||
assert.ok(
|
||||
await until(() =>
|
||||
env.source.calls.tiles.some((key) => !firstTiles.has(key)),
|
||||
),
|
||||
'a new tile was read',
|
||||
);
|
||||
assert.ok(await until(() => !env.instance.sequenceStats().loading));
|
||||
assert.ok(env.instance.sequenceStats().images > 0);
|
||||
});
|
||||
|
||||
/** The provider drawing coverage on a stand-in viewer above one z14 tile. */
|
||||
function coverageSetup(
|
||||
t,
|
||||
{ sequences = [], grid = [], filter, height = 900 } = {},
|
||||
) {
|
||||
const centre = tileCentre(ORIGIN.lon, ORIGIN.lat);
|
||||
let hold = null;
|
||||
const env = setup(
|
||||
{
|
||||
tiles: (tile) => {
|
||||
const bytes = encodePanoramaxTile(tile, { sequences, grid });
|
||||
return hold ? hold.then(() => bytes) : bytes;
|
||||
},
|
||||
},
|
||||
{ filter },
|
||||
);
|
||||
const viewer = coverageViewer({ ...centre, height }, t);
|
||||
env.instance.init(viewer);
|
||||
t.after(() => env.instance.destroy(viewer));
|
||||
return {
|
||||
...env,
|
||||
viewer,
|
||||
centre,
|
||||
holdTiles() {
|
||||
let release;
|
||||
hold = new Promise((resolve) => (release = resolve));
|
||||
return () => {
|
||||
hold = null;
|
||||
release();
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const line = (id, options = {}) => ({
|
||||
id,
|
||||
parts: [
|
||||
[
|
||||
[ORIGIN.lon - 0.001, ORIGIN.lat],
|
||||
[ORIGIN.lon + 0.001, ORIGIN.lat],
|
||||
],
|
||||
],
|
||||
...options,
|
||||
});
|
||||
|
||||
test('sequence lines draw near the ground, and the imagery filter thins them', async (t) => {
|
||||
const env = coverageSetup(t, {
|
||||
sequences: [
|
||||
line('new-pano', { type: 'equirectangular', date: '2025-06-01' }),
|
||||
line('old-flat', { date: '2019-06-01' }),
|
||||
],
|
||||
});
|
||||
env.instance.activate(env.viewer);
|
||||
assert.ok(await until(() => env.instance.coverageStats().count === 2));
|
||||
assert.equal(env.instance.coverageStats().kind, 'sequence');
|
||||
assert.equal(env.instance.coverageStats().keyRequired, false);
|
||||
env.instance.deactivate();
|
||||
|
||||
const filtered = coverageSetup(t, {
|
||||
filter: { pano: 'pano', sinceMs: null },
|
||||
sequences: [
|
||||
line('new-pano', { type: 'equirectangular' }),
|
||||
line('old-flat'),
|
||||
],
|
||||
});
|
||||
filtered.instance.activate(filtered.viewer);
|
||||
assert.ok(await until(() => filtered.instance.coverageStats().count === 1));
|
||||
});
|
||||
|
||||
test('coverage hints: nothing while loading, “no pictures” once an empty view has loaded', async (t) => {
|
||||
const env = coverageSetup(t);
|
||||
const release = env.holdTiles();
|
||||
env.instance.activate(env.viewer);
|
||||
assert.ok(await until(() => env.instance.coverageStats().loading));
|
||||
assert.equal(env.instance.coverageStats().hint, '');
|
||||
release();
|
||||
assert.ok(await until(() => !env.instance.coverageStats().loading));
|
||||
assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT);
|
||||
});
|
||||
|
||||
test('from high up the density grid draws points, not counted as sequences, and the panel says to zoom in', async (t) => {
|
||||
const env = coverageSetup(t, {
|
||||
height: 200_000,
|
||||
grid: [{ lon: ORIGIN.lon, lat: ORIGIN.lat, pano: 2, flat: 3 }],
|
||||
});
|
||||
env.instance.activate(env.viewer);
|
||||
assert.ok(
|
||||
await until(() => env.instance.coverageStats().kind === 'overview'),
|
||||
);
|
||||
assert.ok(await until(() => !env.instance.coverageStats().loading));
|
||||
assert.equal(env.instance.coverageStats().zoom, 5);
|
||||
assert.equal(env.instance.coverageStats().count, 0, 'dots are not sequences');
|
||||
assert.equal(env.instance.coverageStats().hint, OVERVIEW_HINT);
|
||||
});
|
||||
|
||||
test('switching off clears the cones and forgets a pending line click', async (t) => {
|
||||
const items = sequence('s', 3);
|
||||
const env = await mounted({ items, tiles: pictureTiles(items) });
|
||||
await withCones(t, env);
|
||||
env.instance.handlePick(`${PICK_PREFIX.sequence}s`, {
|
||||
groundPoint: () => east(0),
|
||||
});
|
||||
env.instance.deactivate();
|
||||
assert.equal(env.instance.sequenceStats().selectedId, null);
|
||||
// The click's preference is gone: a later lookup asks any sequence.
|
||||
await env.instance.nearestImage(east(0));
|
||||
assert.equal(env.source.calls.nearest.at(-1).collections, null);
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { createLimiter } from '../../limiter.js';
|
||||
import {
|
||||
API_CONCURRENCY,
|
||||
API_TIMEOUT_MS,
|
||||
MAX_RESPONSE_BYTES,
|
||||
NEAREST_FOV_TOLERANCE_DEG,
|
||||
NEAREST_LIMIT,
|
||||
NEAREST_RADIUS_M,
|
||||
PANORAMAX_API,
|
||||
TILE_CONCURRENCY,
|
||||
TILE_TIMEOUT_MS,
|
||||
} from './policy.js';
|
||||
|
||||
/** An HTTP or API failure; `retryAfterSec` is set while Panoramax is busy. */
|
||||
export class PanoramaxSourceError extends Error {
|
||||
constructor(message, { status = 0, retryAfterSec = null } = {}) {
|
||||
super(message);
|
||||
this.name = 'PanoramaxSourceError';
|
||||
this.status = status;
|
||||
this.retryAfterSec = retryAfterSec;
|
||||
}
|
||||
}
|
||||
|
||||
const NO_ANSWER = 'Panoramax did not answer in time — try again';
|
||||
|
||||
/** Seconds from a Retry-After header, or null. */
|
||||
function retryAfterSeconds(response) {
|
||||
const header = response.headers?.get?.('retry-after');
|
||||
const value = header == null || header === '' ? NaN : Number(header);
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
/** The body as bytes, refusing more than MAX_RESPONSE_BYTES whatever the headers say. */
|
||||
async function readBounded(response) {
|
||||
const tooMuch = () =>
|
||||
new PanoramaxSourceError('Panoramax sent too much data', {
|
||||
status: response.status,
|
||||
});
|
||||
const length = Number(response.headers?.get?.('content-length'));
|
||||
if (length > MAX_RESPONSE_BYTES) throw tooMuch();
|
||||
const reader = response.body?.getReader?.();
|
||||
if (!reader) {
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength > MAX_RESPONSE_BYTES) throw tooMuch();
|
||||
return bytes;
|
||||
}
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > MAX_RESPONSE_BYTES) {
|
||||
reader.cancel().catch(() => {});
|
||||
throw tooMuch();
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
const body = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every network path the Panoramax provider uses, browser-side and keyless:
|
||||
* the meta-catalogue allows any origin. Vector tiles for coverage and
|
||||
* cones, STAC search for single pictures and nearest ones.
|
||||
* @param {{fetchImpl?: typeof fetch, api?: string}} [options]
|
||||
*/
|
||||
export function createPanoramaxSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
api = PANORAMAX_API,
|
||||
} = {}) {
|
||||
const root = api.replace(/\/+$/, '');
|
||||
const apiLimiter = createLimiter(API_CONCURRENCY, { lifo: true });
|
||||
const tileLimiter = createLimiter(TILE_CONCURRENCY);
|
||||
|
||||
/** GET within `timeoutMs`; the caller's abort wins over the timeout. */
|
||||
async function get(url, { signal, timeoutMs, empty = [] }) {
|
||||
const controller = new AbortController();
|
||||
const onAbort = () => controller.abort(signal.reason);
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new PanoramaxSourceError(NO_ANSWER)),
|
||||
timeoutMs,
|
||||
);
|
||||
try {
|
||||
const response = await fetchImpl(url, { signal: controller.signal });
|
||||
if (empty.includes(response.status)) return null;
|
||||
if (response.status === 429 || response.status === 503)
|
||||
throw new PanoramaxSourceError('Panoramax is busy', {
|
||||
status: response.status,
|
||||
retryAfterSec: retryAfterSeconds(response) ?? 5,
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new PanoramaxSourceError(
|
||||
`Panoramax answered HTTP ${response.status}`,
|
||||
{ status: response.status },
|
||||
);
|
||||
return await readBounded(response);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw signal.reason;
|
||||
if (controller.signal.aborted) throw controller.signal.reason;
|
||||
if (error instanceof PanoramaxSourceError) throw error;
|
||||
throw new PanoramaxSourceError(NO_ANSWER);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
}
|
||||
}
|
||||
|
||||
/** A STAC search, through the API gate (newest request first). */
|
||||
async function search(params, { signal } = {}) {
|
||||
const url = `${root}/search?${new URLSearchParams(params)}`;
|
||||
const bytes = await apiLimiter.run(
|
||||
() => get(url, { signal, timeoutMs: API_TIMEOUT_MS }),
|
||||
{ signal },
|
||||
);
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(new TextDecoder().decode(bytes));
|
||||
} catch {
|
||||
throw new PanoramaxSourceError('Panoramax sent an unreadable answer');
|
||||
}
|
||||
return Array.isArray(payload?.features) ? payload.features : [];
|
||||
}
|
||||
|
||||
return {
|
||||
api: root,
|
||||
|
||||
/** Raw vector-tile bytes; an empty array where there is no coverage. */
|
||||
async getTile({ x, y, z }, { signal } = {}) {
|
||||
const url = `${root}/map/${z}/${x}/${y}.mvt`;
|
||||
const bytes = await tileLimiter.run(
|
||||
() =>
|
||||
get(url, { signal, timeoutMs: TILE_TIMEOUT_MS, empty: [204, 404] }),
|
||||
{ signal },
|
||||
);
|
||||
return bytes ?? new Uint8Array(0);
|
||||
},
|
||||
|
||||
/** One picture (a STAC item) by id, or null. */
|
||||
async getItem(id, { signal } = {}) {
|
||||
const [item] = await search({ ids: String(id), limit: '1' }, { signal });
|
||||
return item && String(item.id) === String(id) ? item : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Pictures within `radius` metres of a point, nearest first, whichever
|
||||
* way they look; `collections` keeps one sequence's, `pano` and
|
||||
* `sinceMs` filter as the imagery filter does.
|
||||
*/
|
||||
async nearestItems(
|
||||
{
|
||||
lat,
|
||||
lon,
|
||||
radius = NEAREST_RADIUS_M,
|
||||
limit = NEAREST_LIMIT,
|
||||
collections = null,
|
||||
pano = 'all',
|
||||
sinceMs = null,
|
||||
},
|
||||
{ signal } = {},
|
||||
) {
|
||||
const params = {
|
||||
place_position: `${Number(lon).toFixed(7)},${Number(lat).toFixed(7)}`,
|
||||
place_distance: `0-${Math.min(1000, Math.max(1, Math.round(radius)))}`,
|
||||
place_fov_tolerance: String(NEAREST_FOV_TOLERANCE_DEG),
|
||||
limit: String(Math.min(100, Math.max(1, limit))),
|
||||
};
|
||||
if (collections) params.collections = String(collections);
|
||||
if (pano === 'pano') params.filter = 'field_of_view=360';
|
||||
else if (pano === 'flat') params.filter = 'field_of_view<360';
|
||||
if (Number.isFinite(sinceMs) && sinceMs > 0)
|
||||
params.datetime = `${new Date(sinceMs).toISOString()}/..`;
|
||||
return search(params, { signal });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { PanoramaxSourceError, createPanoramaxSource } from './source.js';
|
||||
import { MAX_RESPONSE_BYTES } from './policy.js';
|
||||
|
||||
/** A fetch answering from `respond(url)`, recording every URL asked. */
|
||||
function fakeFetch(respond) {
|
||||
const urls = [];
|
||||
const fetchImpl = async (url, { signal } = {}) => {
|
||||
urls.push(url);
|
||||
const answer = await respond(url, signal);
|
||||
if (answer instanceof Response) return answer;
|
||||
const { status = 200, body = '', headers = {} } = answer;
|
||||
return new Response(status === 204 ? null : body, { status, headers });
|
||||
};
|
||||
return { urls, fetchImpl };
|
||||
}
|
||||
|
||||
const json = (value) => ({ body: JSON.stringify(value) });
|
||||
|
||||
test('a tile is the raw vector-tile bytes; 204 and 404 mean no coverage', async () => {
|
||||
const { urls, fetchImpl } = fakeFetch((url) =>
|
||||
url.includes('/14/')
|
||||
? { body: new Uint8Array([1, 2, 3]) }
|
||||
: { status: 204 },
|
||||
);
|
||||
const source = createPanoramaxSource({ fetchImpl });
|
||||
assert.deepEqual(
|
||||
[...(await source.getTile({ x: 8299, y: 5634, z: 14 }))],
|
||||
[1, 2, 3],
|
||||
);
|
||||
assert.equal(urls[0], 'https://api.panoramax.xyz/api/map/14/8299/5634.mvt');
|
||||
assert.equal((await source.getTile({ x: 1, y: 1, z: 13 })).length, 0);
|
||||
const missing = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(() => ({ status: 404 })).fetchImpl,
|
||||
});
|
||||
assert.equal((await missing.getTile({ x: 1, y: 1, z: 13 })).length, 0);
|
||||
});
|
||||
|
||||
test('a busy API says so and when to come back; other failures name the status', async () => {
|
||||
const busy = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(() => ({
|
||||
status: 429,
|
||||
headers: { 'retry-after': '12' },
|
||||
})).fetchImpl,
|
||||
});
|
||||
await assert.rejects(busy.getTile({ x: 0, y: 0, z: 0 }), (error) => {
|
||||
assert.ok(error instanceof PanoramaxSourceError);
|
||||
assert.equal(error.status, 429);
|
||||
assert.equal(error.retryAfterSec, 12);
|
||||
return true;
|
||||
});
|
||||
const down = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(() => ({ status: 503 })).fetchImpl,
|
||||
});
|
||||
await assert.rejects(down.getTile({ x: 0, y: 0, z: 0 }), (error) => {
|
||||
assert.equal(error.retryAfterSec, 5, 'a default wait without the header');
|
||||
return true;
|
||||
});
|
||||
const broken = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(() => ({ status: 500 })).fetchImpl,
|
||||
});
|
||||
await assert.rejects(broken.getItem('x'), /HTTP 500/);
|
||||
});
|
||||
|
||||
test('an oversized answer is refused while it streams', async () => {
|
||||
let pulled = 0;
|
||||
const huge = new ReadableStream({
|
||||
pull(controller) {
|
||||
pulled++;
|
||||
controller.enqueue(new Uint8Array(4 * 1024 * 1024));
|
||||
},
|
||||
});
|
||||
const source = createPanoramaxSource({
|
||||
fetchImpl: async () => new Response(huge, { status: 200 }),
|
||||
});
|
||||
await assert.rejects(source.getTile({ x: 0, y: 0, z: 0 }), /too much data/);
|
||||
assert.ok(pulled * 4 * 1024 * 1024 <= MAX_RESPONSE_BYTES + 8 * 1024 * 1024);
|
||||
// A declared length over the cap is refused before reading.
|
||||
const declared = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(() => ({
|
||||
body: 'x',
|
||||
headers: { 'content-length': String(MAX_RESPONSE_BYTES + 1) },
|
||||
})).fetchImpl,
|
||||
});
|
||||
await assert.rejects(declared.getItem('a'), /too much data/);
|
||||
});
|
||||
|
||||
test('a picture by id is a STAC search; another id in the answer is not taken for it', async () => {
|
||||
const { urls, fetchImpl } = fakeFetch((url) =>
|
||||
url.includes('ids=good')
|
||||
? json({ features: [{ id: 'good' }] })
|
||||
: json({ features: [{ id: 'other' }] }),
|
||||
);
|
||||
const source = createPanoramaxSource({ fetchImpl });
|
||||
assert.deepEqual(await source.getItem('good'), { id: 'good' });
|
||||
assert.equal(await source.getItem('bad'), null);
|
||||
const asked = new URL(urls[0]);
|
||||
assert.equal(asked.pathname, '/api/search');
|
||||
assert.equal(asked.searchParams.get('ids'), 'good');
|
||||
assert.equal(asked.searchParams.get('limit'), '1');
|
||||
});
|
||||
|
||||
test('nearest pictures: any direction within the radius, filtered as the imagery filter asks', async () => {
|
||||
const { urls, fetchImpl } = fakeFetch(() => json({ features: [] }));
|
||||
const source = createPanoramaxSource({ fetchImpl });
|
||||
await source.nearestItems({ lat: 48.85, lon: 2.35 });
|
||||
await source.nearestItems({
|
||||
lat: 48.85,
|
||||
lon: 2.35,
|
||||
collections: 'seq',
|
||||
pano: 'pano',
|
||||
sinceMs: Date.UTC(2025, 0, 1),
|
||||
radius: 5000,
|
||||
limit: 500,
|
||||
});
|
||||
await source.nearestItems({ lat: 48.85, lon: 2.35, pano: 'flat' });
|
||||
const [plain, filtered, flat] = urls.map((u) => new URL(u).searchParams);
|
||||
assert.equal(plain.get('place_position'), '2.3500000,48.8500000');
|
||||
assert.equal(plain.get('place_distance'), '0-50');
|
||||
assert.equal(plain.get('place_fov_tolerance'), '180');
|
||||
assert.equal(plain.get('collections'), null);
|
||||
assert.equal(plain.get('filter'), null);
|
||||
assert.equal(plain.get('datetime'), null);
|
||||
assert.equal(filtered.get('collections'), 'seq');
|
||||
assert.equal(filtered.get('filter'), 'field_of_view=360');
|
||||
assert.equal(filtered.get('datetime'), '2025-01-01T00:00:00.000Z/..');
|
||||
assert.equal(filtered.get('place_distance'), '0-1000', 'capped');
|
||||
assert.equal(filtered.get('limit'), '100', 'capped');
|
||||
assert.equal(flat.get('filter'), 'field_of_view<360');
|
||||
});
|
||||
|
||||
test('an unreadable search answer, or one with no features, is handled', async () => {
|
||||
const garbled = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(() => ({ body: '<html>' })).fetchImpl,
|
||||
});
|
||||
await assert.rejects(garbled.getItem('a'), /unreadable/);
|
||||
const empty = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(() => json({ type: 'FeatureCollection' })).fetchImpl,
|
||||
});
|
||||
assert.deepEqual(await empty.nearestItems({ lat: 0, lon: 0 }), []);
|
||||
});
|
||||
|
||||
test('the caller’s abort wins; a silent API times out with a readable message', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const hang = (url, signal) =>
|
||||
new Promise((_, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason)),
|
||||
);
|
||||
const source = createPanoramaxSource({
|
||||
fetchImpl: fakeFetch(hang).fetchImpl,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const asked = source.getItem('a', { signal: controller.signal });
|
||||
await Promise.resolve();
|
||||
controller.abort(new Error('overtaken'));
|
||||
await assert.rejects(asked, /overtaken/);
|
||||
const slow = source.getTile({ x: 0, y: 0, z: 0 });
|
||||
// Let the request start before the clock runs out.
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
t.mock.timers.tick(60_000);
|
||||
await assert.rejects(slow, /did not answer in time/);
|
||||
});
|
||||
|
||||
test('a network failure reads as no answer, not as a stack trace', async () => {
|
||||
const source = createPanoramaxSource({
|
||||
fetchImpl: async () => {
|
||||
throw new TypeError('Failed to fetch');
|
||||
},
|
||||
});
|
||||
await assert.rejects(source.getTile({ x: 0, y: 0, z: 0 }), (error) => {
|
||||
assert.ok(error instanceof PanoramaxSourceError);
|
||||
assert.match(error.message, /did not answer/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createPhotoViewer, loadImage } from '../../photoViewer.js';
|
||||
import {
|
||||
HD_TIMEOUT_MS,
|
||||
PANORAMAX_NAME,
|
||||
PANORAMAX_PROVIDER_ID,
|
||||
PANO_HD_MAX_WIDTH,
|
||||
SD_WIDTH,
|
||||
panoramaxPictureUrl,
|
||||
} from './policy.js';
|
||||
|
||||
/**
|
||||
* Where Panoramax pictures come from: the instance holding each one serves
|
||||
* a 2,048 px SD derivative (equirectangular for a 360° picture) and the HD
|
||||
* original. SD shows first; HD only once the viewer is wide enough to use
|
||||
* it. A 360° original is shrunk to the texture size while it decodes.
|
||||
* @type {import('../../photoViewer.js').PhotoSources}
|
||||
*/
|
||||
export const PANORAMAX_SOURCES = Object.freeze({
|
||||
hasPano: (photo) => Boolean(photo.isPano && (photo.urls.sd || photo.urls.hd)),
|
||||
panoFirst: (photo) =>
|
||||
photo.urls.sd
|
||||
? { url: photo.urls.sd, width: SD_WIDTH }
|
||||
: { url: photo.urls.hd, width: photo.width || SD_WIDTH },
|
||||
panoSharp: (photo, size) => {
|
||||
const width = photo.width || 0;
|
||||
if (!photo.urls.hd || !photo.urls.sd) return null;
|
||||
// An original no wider than the SD one adds nothing; a huge one would
|
||||
// take hundreds of megabytes to decode.
|
||||
if (width <= SD_WIDTH || width > PANO_HD_MAX_WIDTH) return null;
|
||||
const target = Math.min(size[0], width);
|
||||
return {
|
||||
url: photo.urls.hd,
|
||||
resize: [target, target / 2],
|
||||
timeoutMs: HD_TIMEOUT_MS,
|
||||
};
|
||||
},
|
||||
// A 360° picture shown flat (no WebGL) is its whole equirectangular image.
|
||||
flatFirst: (photo) => [photo.urls.sd, photo.urls.hd, photo.urls.thumb],
|
||||
flatSharp: (photo, pixels) =>
|
||||
photo.urls.sd && photo.urls.hd && pixels > SD_WIDTH ? photo.urls.hd : null,
|
||||
describe: (photo) => ({
|
||||
title: null,
|
||||
creator: photo.creator,
|
||||
license: photo.license,
|
||||
externalUrl: panoramaxPictureUrl(photo.id),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Viewer adapter for Panoramax pictures: flat in an <img>, 360° on the
|
||||
* shared WebGL panorama, ‹ › along the sequence (photoViewer.js).
|
||||
* @param {object} options See photoViewer.js `createPhotoViewer`.
|
||||
* @returns {import('../../registry.js').ViewerAdapter}
|
||||
*/
|
||||
export function createPanoramaxViewer({ load = loadImage, ...options }) {
|
||||
return createPhotoViewer({
|
||||
providerId: PANORAMAX_PROVIDER_ID,
|
||||
name: PANORAMAX_NAME,
|
||||
sources: PANORAMAX_SOURCES,
|
||||
load,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { PANORAMAX_SOURCES, createPanoramaxViewer } from './viewer.js';
|
||||
import { normalizeItem } from './policy.js';
|
||||
import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs';
|
||||
import { stacItem } from '../../../../testSupport/panoramaxFixtures.mjs';
|
||||
|
||||
const photo = (options) =>
|
||||
normalizeItem(stacItem({ lon: 2.35, lat: 48.85, ...options }));
|
||||
|
||||
const PHOTOS = {
|
||||
flat: photo({ id: 'flat', prevId: 'p0', nextId: 'flat2', heading: 30 }),
|
||||
flat2: photo({ id: 'flat2', prevId: 'flat', heading: 40 }),
|
||||
pano: photo({ id: 'pano', pano: true, heading: 100, width: 5760 }),
|
||||
bigPano: photo({ id: 'bigPano', pano: true, width: 11_000 }),
|
||||
smallPano: photo({ id: 'smallPano', pano: true, width: 2048 }),
|
||||
email: photo({ id: 'email', producer: '[email protected]' }),
|
||||
};
|
||||
|
||||
function fakeRenderer() {
|
||||
const view = { yaw: 0, pitch: 0, fov: 80 };
|
||||
const images = [];
|
||||
return {
|
||||
images,
|
||||
setImage: (image) => images.push(image),
|
||||
setView(next) {
|
||||
Object.assign(view, next);
|
||||
},
|
||||
getView: () => ({ ...view }),
|
||||
resize() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
};
|
||||
}
|
||||
|
||||
function setup() {
|
||||
const loads = [];
|
||||
const renderers = [];
|
||||
const stepped = [];
|
||||
const viewer = createPanoramaxViewer({
|
||||
getPhoto: async (id) => {
|
||||
if (!PHOTOS[id]) throw new Error('no such picture');
|
||||
return PHOTOS[id];
|
||||
},
|
||||
step: async (from, delta) => (delta < 0 ? from.prevId : from.nextId),
|
||||
onStep: (id) => {
|
||||
stepped.push(id);
|
||||
viewer.open(id).catch(() => {});
|
||||
},
|
||||
load: async (url, options) => {
|
||||
loads.push({ url, ...options });
|
||||
return { src: url, width: 1, height: 1 };
|
||||
},
|
||||
createRenderer: () => {
|
||||
const renderer = fakeRenderer();
|
||||
renderers.push(renderer);
|
||||
return renderer;
|
||||
},
|
||||
});
|
||||
const poses = [];
|
||||
viewer.onPose((pose) => poses.push(pose));
|
||||
return { viewer, poses, loads, renderers, stepped, host: fakeHost() };
|
||||
}
|
||||
|
||||
const parts = (host) => {
|
||||
const root = host.children[0];
|
||||
const [picture, canvas, prev, next] = root.children;
|
||||
return { root, picture, canvas, prev, next };
|
||||
};
|
||||
|
||||
test('a flat picture shows its SD version with author, licence and a Panoramax link', async () => {
|
||||
const { viewer, poses, loads, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const { root, picture, canvas } = parts(host);
|
||||
assert.equal(root.dataset.provider, 'panoramax', 'its colour in the CSS');
|
||||
assert.match(root.attributes['aria-label'], /^Panoramax photo/);
|
||||
await viewer.open('flat');
|
||||
assert.equal(picture.hidden, false);
|
||||
assert.equal(canvas.hidden, true);
|
||||
assert.equal(
|
||||
picture.src,
|
||||
'https://panoramax.example.org/pictures/flat/sd.jpg',
|
||||
);
|
||||
assert.deepEqual(
|
||||
loads.map((l) => l.url),
|
||||
[PHOTOS.flat.urls.sd],
|
||||
);
|
||||
const pose = poses.at(-1);
|
||||
assert.equal(pose.providerId, 'panoramax');
|
||||
assert.equal(pose.bearing, 30);
|
||||
assert.equal(pose.isPano, false);
|
||||
assert.equal(pose.creator, 'tester');
|
||||
assert.equal(pose.license, 'CC BY-SA 4.0');
|
||||
assert.equal(pose.sequenceId, 'seq-1');
|
||||
assert.equal(
|
||||
pose.externalUrl,
|
||||
'https://api.panoramax.xyz/#pic=flat&focus=pic',
|
||||
);
|
||||
assert.equal(viewer.supportsRenderModes(), true);
|
||||
});
|
||||
|
||||
test('no creator in the caption for an account named by an email address', async () => {
|
||||
const { viewer, poses, host } = setup();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('email');
|
||||
assert.equal(poses.at(-1).creator, null);
|
||||
});
|
||||
|
||||
test('HD replaces SD only once the viewer is wide enough to use it', async (t) => {
|
||||
const { viewer, loads, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const { picture, root } = parts(host);
|
||||
t.after(() => delete globalThis.devicePixelRatio);
|
||||
globalThis.devicePixelRatio = 1;
|
||||
picture.clientWidth = 400;
|
||||
await viewer.open('flat');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(loads.length, 1, 'docked: SD is sharp enough');
|
||||
// EXPAND on a wide screen.
|
||||
picture.clientWidth = root.clientWidth = 2600;
|
||||
viewer.resize();
|
||||
assert.ok(await until(() => picture.src === PHOTOS.flat.urls.hd));
|
||||
viewer.resize();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(
|
||||
loads.filter((l) => l.url === PHOTOS.flat.urls.hd).length,
|
||||
1,
|
||||
'asked once',
|
||||
);
|
||||
});
|
||||
|
||||
test('a 360° picture shows its SD equirectangular image, then HD shrunk to 4,096 when expanded', async (t) => {
|
||||
const { viewer, poses, loads, renderers, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const { canvas, picture } = parts(host);
|
||||
t.after(() => delete globalThis.devicePixelRatio);
|
||||
globalThis.devicePixelRatio = 1;
|
||||
canvas.clientWidth = 400;
|
||||
await viewer.open('pano');
|
||||
assert.equal(canvas.hidden, false);
|
||||
assert.equal(picture.hidden, true);
|
||||
assert.equal(viewer.supportsRenderModes(), false, 'no frame to fit');
|
||||
assert.deepEqual(loads[0], {
|
||||
url: PHOTOS.pano.urls.sd,
|
||||
crossOrigin: true,
|
||||
failure: 'The Panoramax photo could not be loaded',
|
||||
});
|
||||
assert.equal(poses.at(-1).isPano, true);
|
||||
assert.equal(poses.at(-1).bearing, 100);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(loads.length, 1, 'docked: the 2,048 px SD is enough');
|
||||
canvas.clientWidth = 1800;
|
||||
// EXPAND resizes more than once while the panel settles.
|
||||
viewer.resize();
|
||||
viewer.resize();
|
||||
viewer.resize();
|
||||
assert.ok(await until(() => renderers[0].images.length === 2));
|
||||
assert.equal(
|
||||
loads.filter((l) => l.url === PHOTOS.pano.urls.hd).length,
|
||||
1,
|
||||
'the original is fetched once',
|
||||
);
|
||||
const hd = loads.at(-1);
|
||||
assert.equal(hd.url, PHOTOS.pano.urls.hd);
|
||||
assert.deepEqual(hd.resize, [4096, 2048]);
|
||||
assert.equal(hd.timeoutMs, 30_000);
|
||||
});
|
||||
|
||||
test('HD panoramas too big to decode, or no bigger than SD, are never fetched', () => {
|
||||
const size = [4096, 2048];
|
||||
assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.bigPano, size), null);
|
||||
assert.equal(PANORAMAX_SOURCES.panoSharp(PHOTOS.smallPano, size), null);
|
||||
assert.deepEqual(
|
||||
PANORAMAX_SOURCES.panoSharp({ ...PHOTOS.pano, width: 3000 }, size).resize,
|
||||
[3000, 1500],
|
||||
'never enlarged',
|
||||
);
|
||||
// Without an SD derivative the HD one is the first and only.
|
||||
const hdOnly = { ...PHOTOS.pano, urls: { ...PHOTOS.pano.urls, sd: null } };
|
||||
assert.deepEqual(PANORAMAX_SOURCES.panoFirst(hdOnly), {
|
||||
url: hdOnly.urls.hd,
|
||||
width: 5760,
|
||||
});
|
||||
assert.equal(PANORAMAX_SOURCES.panoSharp(hdOnly, size), null);
|
||||
assert.equal(PANORAMAX_SOURCES.flatSharp(PHOTOS.flat, 2048), null);
|
||||
assert.equal(
|
||||
PANORAMAX_SOURCES.flatSharp(PHOTOS.flat, 2049),
|
||||
PHOTOS.flat.urls.hd,
|
||||
);
|
||||
assert.equal(
|
||||
PANORAMAX_SOURCES.hasPano({ isPano: true, urls: { sd: null, hd: null } }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('› and ‹ follow the picture’s own links; the ends disable the buttons', async () => {
|
||||
const { viewer, poses, stepped, host } = setup();
|
||||
await viewer.mount(host);
|
||||
const { next, prev } = parts(host);
|
||||
await viewer.open('flat');
|
||||
next.fire('click');
|
||||
assert.ok(await until(() => poses.at(-1)?.imageId === 'flat2'));
|
||||
assert.deepEqual(stepped, ['flat2']);
|
||||
assert.ok(await until(() => next.disabled), 'flat2 is the last');
|
||||
assert.equal(prev.disabled, false);
|
||||
});
|
||||
|
||||
test('without WebGL a 360° picture shows its whole equirectangular image flat', async () => {
|
||||
const loads = [];
|
||||
const viewer = createPanoramaxViewer({
|
||||
getPhoto: async () => PHOTOS.pano,
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
load: async (url) => {
|
||||
loads.push(url);
|
||||
return { src: url };
|
||||
},
|
||||
createRenderer: () => {
|
||||
throw new Error('no WebGL');
|
||||
},
|
||||
});
|
||||
const host = fakeHost();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('pano');
|
||||
const { picture } = parts(host);
|
||||
assert.equal(picture.hidden, false);
|
||||
assert.equal(picture.src, PHOTOS.pano.urls.sd);
|
||||
});
|
||||
|
||||
test('a picture whose SD fails falls back to HD, then the thumbnail', async () => {
|
||||
const tried = [];
|
||||
const viewer = createPanoramaxViewer({
|
||||
getPhoto: async () => PHOTOS.flat,
|
||||
step: async () => null,
|
||||
onStep() {},
|
||||
load: async (url) => {
|
||||
tried.push(url);
|
||||
if (!url.endsWith('thumb.jpg')) throw new Error('gone');
|
||||
return { src: url };
|
||||
},
|
||||
});
|
||||
const host = fakeHost();
|
||||
await viewer.mount(host);
|
||||
await viewer.open('flat');
|
||||
assert.deepEqual(tried, [
|
||||
PHOTOS.flat.urls.sd,
|
||||
PHOTOS.flat.urls.hd,
|
||||
PHOTOS.flat.urls.thumb,
|
||||
]);
|
||||
assert.equal(parts(host).picture.src, PHOTOS.flat.urls.thumb);
|
||||
});
|
||||
@@ -82,6 +82,7 @@ export function filtersOf(provider) {
|
||||
* @property {'day'|'month'} [capturedAtPrecision] How exactly the provider dates images (default 'day').
|
||||
* @property {string|null} [title] A place name for the caption, e.g. the address.
|
||||
* @property {string|null} creator
|
||||
* @property {string|null} [license] The image's licence when it is per image, e.g. 'CC BY-SA 4.0'.
|
||||
* @property {string|null} sequenceId
|
||||
* @property {string} externalUrl Deep link to the image on the provider's site.
|
||||
*/
|
||||
|
||||
@@ -26,6 +26,7 @@ export function freshStreet() {
|
||||
renderModes: true,
|
||||
sequenceId: null,
|
||||
creator: null,
|
||||
license: null,
|
||||
externalUrl: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
@@ -38,6 +38,7 @@ export function createViewerHost({ state, parts }) {
|
||||
title: pose.title || null,
|
||||
sequenceId: pose.sequenceId ?? null,
|
||||
creator: pose.creator || null,
|
||||
license: pose.license || null,
|
||||
externalUrl: pose.externalUrl || null,
|
||||
// FIT / FILL, unless this image has no frame to fit (a panorama).
|
||||
renderModes:
|
||||
|
||||
Reference in new issue
Block a user