Merge upstream/main (KartaView #1121 merged) into pr/panoramax

KartaView's later viewer changes are carried into the shared
photoViewer.js: the camera bearing (`bearingOf`), redrawing the same
photo after a GPU reset through `onReopen`, no pose while a lost context
recovers, the 2,560 px flat version only once the viewer outgrows
1,280 px, `refreshPose`, and one sharper-panorama load at a time with a
second look once it lands. Its tile cache's `keyOf` and `clear` move to
the shared tileCache.js. KartaView gets `onEscape` beside `onReopen`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-14 15:52:21 +01:00
commit da0d370fa3
43 files changed
+3299 -178

No files matched your search

+391
View File
@@ -0,0 +1,391 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
loadNycSourcesFromOpenData,
nycCameraIsOnline,
nycCameraName,
nycCameraToSource,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_CCTV_MAX_SOURCES,
DEFAULT_NYC_MAX_SOURCES,
NYC_CAMERAS_URL,
NYC_GROUND_ELEVATION_M,
NYC_IMAGE_ORIGIN,
NYC_IMAGE_URL,
NYC_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import {
CAMERA_CODE_MAX_CHARS,
isLikelyNycCoordinate,
} from '../../server/providers/cctv/normalize.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
/**
* A response whose body is a live stream, plus a flag that flips when the
* stream is cancelled. A rejection path that returns without cancelling holds
* the transport open, so the flag is what the refusal tests actually assert.
*/
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('['));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
const MIDTOWN_ID = '8a6bc417-4877-4ebe-8052-88c1b261baf1';
const BROOKLYN_ID = '11111111-2222-3333-4444-555555555555';
/** One TMC camera row, shaped like the live `webcams.nyctmc.org` payload. */
const row = (overrides = {}) => ({
id: MIDTOWN_ID,
name: 'Central Park West @ 86 St',
latitude: 40.785302,
longitude: -73.969353,
area: 'Manhattan',
isOnline: 'true',
imageUrl: NYC_IMAGE_URL(MIDTOWN_ID),
...overrides,
});
test('a NYC row maps to a source on the pinned HTTPS frame host', () => {
const source = nycCameraToSource(row());
assert.equal(source.id, `nyc-${MIDTOWN_ID}`);
assert.equal(source.name, 'Central Park West @ 86 St');
assert.equal(source.city, 'Manhattan');
assert.equal(source.cityId, 'nyc');
assert.equal(source.provider, 'NYC DOT Traffic Management Center');
assert.equal(source.lat, 40.785302);
assert.equal(source.lon, -73.969353);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'nyc-dot-tmc');
assert.equal(
source.license,
'Public NYC DOT Traffic Management Center traffic camera',
);
assert.equal(source.groundElevationM, NYC_GROUND_ELEVATION_M);
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
assert.equal(source.url, source.snapshotUrl);
assert.ok(source.url.startsWith(NYC_IMAGE_ORIGIN));
assert.equal(source.headingConfidence, 'low');
assert.ok(Number.isFinite(source.headingDeg));
});
test('frame URLs are synthesized from the UUID; the payload URL is ignored', () => {
const source = nycCameraToSource(
row({ imageUrl: 'https://evil.example/cameras/x/image' }),
);
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
assert.equal(source.snapshotUrl, source.url);
});
test('offline cameras, off-city geometry and unusable ids are dropped', () => {
assert.equal(nycCameraToSource(row({ isOnline: 'false' })), null);
assert.equal(nycCameraToSource(row({ isOnline: false })), null);
assert.equal(
nycCameraToSource(row({ isOnline: 'TRUE' })).id,
`nyc-${MIDTOWN_ID}`,
);
assert.equal(
nycCameraToSource(row({ isOnline: true })).id,
`nyc-${MIDTOWN_ID}`,
);
// Austin: a plausible lat/lon, but not a New York camera.
assert.equal(
nycCameraToSource(row({ latitude: 30.2672, longitude: -97.7431 })),
null,
);
assert.equal(nycCameraToSource(row({ latitude: 0, longitude: 0 })), null);
assert.equal(nycCameraToSource(row({ id: '../etc/passwd' })), null);
assert.equal(nycCameraToSource(row({ id: 'not-a-uuid' })), null);
assert.equal(nycCameraToSource(row({ id: `${MIDTOWN_ID}/../x` })), null);
assert.equal(nycCameraToSource(null), null);
});
test('ids lower-case the UUID and unknown boroughs fall back to New York', () => {
const mixed = nycCameraToSource(
row({ id: MIDTOWN_ID.toUpperCase(), area: 'Long Island' }),
);
assert.equal(mixed.id, `nyc-${MIDTOWN_ID}`);
assert.equal(mixed.city, 'New York');
assert.equal(
mixed.url,
NYC_IMAGE_URL(MIDTOWN_ID.toUpperCase()),
'the registered frame path keeps the catalog UUID spelling',
);
});
test('a nameless row still gets a label', () => {
assert.equal(
nycCameraName({ name: ' FDR Drive @ 23 St ' }, 'nyc-x'),
'FDR Drive @ 23 St',
);
assert.equal(nycCameraName({}, `nyc-${MIDTOWN_ID}`), 'NYC Camera 8a6bc417');
assert.equal(nycCameraIsOnline('true'), true);
assert.equal(nycCameraIsOnline('false'), false);
});
test('the unselected label is the intersection, trimmed to the code width', () => {
assert.equal(nycCameraToSource(row()).code, 'CENTRAL PARK WEST @ 86 ST');
const long = nycCameraToSource(
row({
name: 'Brooklyn Queens Expressway @ Atlantic Avenue / Flatbush Avenue Extension',
}),
);
assert.ok(long.code.length <= CAMERA_CODE_MAX_CHARS);
assert.ok(long.code.endsWith('…'));
assert.ok(long.code.startsWith('BROOKLYN'));
});
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
t.mock.method(console, 'log', () => {});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push([String(url), init.redirect]);
return Response.json([
row({ latitude: 40.758, longitude: -73.9855 }),
row({
name: 'Central Park West @ 86 St (duplicate)',
latitude: 40.758,
longitude: -73.9855,
}),
row({
id: BROOKLYN_ID,
name: 'Atlantic Ave @ Flatbush Ave',
latitude: 40.65,
longitude: -73.96,
area: 'Brooklyn',
imageUrl: NYC_IMAGE_URL(BROOKLYN_ID),
}),
row({ isOnline: 'false', name: 'Offline camera' }),
row({ id: 'https://evil.example/x' }),
]);
});
const cameras = await loadNycSourcesFromOpenData();
assert.deepEqual(requested, [[NYC_CAMERAS_URL, 'manual']]);
assert.deepEqual(
cameras.map((camera) => camera.id),
// Nearest to a borough anchor first: the Times Square camera before Brooklyn.
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
);
assert.equal(cameras[0].name, 'Central Park West @ 86 St');
assert.equal(cameras[1].city, 'Brooklyn');
});
test('an upstream failure yields an empty pack and releases the response', async (t) => {
t.mock.method(console, 'warn', () => {});
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
});
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
const seen = [];
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/cameras.json' },
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return redirected.response;
});
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
assert.deepEqual(seen, [[NYC_CAMERAS_URL, 'manual']]);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const body = JSON.stringify([row()]);
return new Response(body, {
headers: {
'Content-Type': 'application/json',
'content-length': String(NYC_MAX_CATALOG_BYTES + 1),
},
});
});
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const oversized = 'x'.repeat(NYC_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/json' } },
);
});
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
});
test('a reduced pack cap keeps cameras nearest the borough anchors', async (t) => {
t.mock.method(console, 'log', () => {});
const saved = process.env.CCTV_NYC_MAX_SOURCES;
t.after(() => {
if (saved === undefined) delete process.env.CCTV_NYC_MAX_SOURCES;
else process.env.CCTV_NYC_MAX_SOURCES = saved;
});
process.env.CCTV_NYC_MAX_SOURCES = '8';
const rows = [
// Far southern Staten Island, listed first so only distance can keep them.
...[40.5, 40.505, 40.51, 40.515, 40.52, 40.525].map((lat, i) =>
row({
id: `aaaaaaaa-bbbb-cccc-dddd-${String(i).padStart(12, '0')}`,
latitude: lat,
longitude: -74.24,
area: 'Staten Island',
name: `SI ${i}`,
}),
),
...[40.758, 40.757, 40.756, 40.755, 40.754, 40.753].map((lat, i) =>
row({
id: `bbbbbbbb-cccc-dddd-eeee-${String(i).padStart(12, '0')}`,
latitude: lat,
longitude: -73.9855,
area: 'Manhattan',
name: `Midtown ${i}`,
}),
),
];
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
const cameras = await loadNycSourcesFromOpenData();
assert.equal(cameras.length, 8);
assert.deepEqual(
cameras.map((camera) => camera.city),
[
'Manhattan',
'Manhattan',
'Manhattan',
'Manhattan',
'Manhattan',
'Manhattan',
'Staten Island',
'Staten Island',
],
'Midtown fills first; the Staten Island anchor still keeps a share',
);
});
/**
* Serve the NYC catalog to the NYC endpoint and an empty payload to every
* other pack, so one catalog refresh exercises the registration without
* reaching the network.
*/
const runCatalogWithMockedUpstreams = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href === NYC_CAMERAS_URL) {
return Response.json([
row({ latitude: 40.758, longitude: -73.9855 }),
row({
id: BROOKLYN_ID,
name: 'Atlantic Ave @ Flatbush Ave',
latitude: 40.65,
longitude: -73.96,
area: 'Brooklyn',
}),
]);
}
return Response.json([]);
});
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
/** Run one catalog refresh with CCTV_NYC_ENABLED set to `flag` (undefined
* leaves it unset), restoring the environment afterwards. */
const runCatalogWithNycFlag = async (t, flag) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
if (flag === undefined) delete process.env.CCTV_NYC_ENABLED;
else process.env.CCTV_NYC_ENABLED = flag;
return await runCatalogWithMockedUpstreams(t);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
};
test('CCTV_NYC_ENABLED=1 wires the NYC lane into the catalog', async (t) => {
const { requested, sources } = await runCatalogWithNycFlag(t, '1');
assert.ok(
requested.includes(NYC_CAMERAS_URL),
'the catalog refresh invokes the NYC loader',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'nyc').map((s) => s.id),
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
'NYC cameras reach the served catalog through the registered lane',
);
});
// NYC DOT requires a data-sharing agreement for this feed, so an install that
// has not opted in must never contact it.
for (const [label, flag] of [
['an unset CCTV_NYC_ENABLED', undefined],
['an empty CCTV_NYC_ENABLED', ''],
['CCTV_NYC_ENABLED=0', '0'],
['CCTV_NYC_ENABLED=true', 'true'],
]) {
test(`${label} makes no NYC request`, async (t) => {
const { requested, sources } = await runCatalogWithNycFlag(t, flag);
assert.ok(requested.length > 0, 'the other packs still refreshed');
assert.equal(
requested.some((href) => href.includes('nyctmc.org')),
false,
'the lane never reaches its upstream',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'nyc'),
[],
);
});
}
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
});
test('isLikelyNycCoordinate spans the five boroughs and rejects the rest', () => {
assert.equal(isLikelyNycCoordinate(40.758, -73.9855), true); // Times Square
assert.equal(isLikelyNycCoordinate(40.5795, -74.1502), true); // Staten Island
assert.equal(isLikelyNycCoordinate(40.8448, -73.8648), true); // Bronx
assert.equal(isLikelyNycCoordinate(40.5077, -74.2304), true); // southern SI slack
assert.equal(isLikelyNycCoordinate(30.2672, -97.7431), false); // Austin
assert.equal(isLikelyNycCoordinate(0, 0), false);
assert.equal(isLikelyNycCoordinate(NaN, -73.98), false);
});
+14
View File
@@ -91,6 +91,13 @@ export function hideOsmCredit(viewer, owner) {
return true;
}
/** NYC DOT asks every application that uses its data to carry this
* disclaimer verbatim (nyc.gov/html/dot/html/about/datafeeds.shtml). */
export const NYC_DOT_DISCLAIMER =
'This application may contain and/or utilize information which was originally compiled by the New York City Department of Transportation (NYC DOT) for governmental purposes; the information may subsequently been modified by entity/entities other than NYC DOT. ' +
'NYC DOT and the City of New York make no representation as to the accuracy or usefulness of the information provided by this application or the information&#39;s suitability for any purpose and disclaim any liability for omissions or errors that may be contained therein. ' +
'The public is advised to observe posted signage for compliance with applicable laws and regulations.';
export const DATA_CREDITS = [
// ── Live sources ────────────────────────────────────────────────
{
@@ -247,6 +254,13 @@ export const DATA_CREDITS = [
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
'distributed by Statens vegvesen',
},
{
key: 'nyc-cctv',
html:
'CCTV cameras &amp; frames (New York City, when enabled): ' +
'<a href="https://webcams.nyctmc.org/" target="_blank" rel="noopener">NYC DOT Traffic Management Center</a>. ' +
NYC_DOT_DISCLAIMER,
},
{
key: 'gbfs',
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
+9 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DATA_CREDITS } from './dataCredits.js';
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
test('every credit carries a unique key and some markup to render', () => {
const keys = DATA_CREDITS.map((entry) => entry.key);
@@ -120,3 +120,11 @@ test('tile attribution follows source changes and viewers have independent owner
assert.equal(a.credits.size, 0);
assert.equal(b.credits.size, 1);
});
test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
const credit = DATA_CREDITS.find((entry) => entry.key === 'nyc-cctv');
assert.ok(credit, 'NYC DOT camera frames must be credited');
assert.ok(credit.html.includes(NYC_DOT_DISCLAIMER));
assert.match(credit.html, /make no representation as to the accuracy/);
assert.match(credit.html, /observe posted signage/);
});
+1
View File
@@ -438,6 +438,7 @@ export function createStreetLevelLayer({
color: entry.def.colors.coverage,
coverageLabel: entry.def.coverageLabel || null,
highlightsSequence: entry.def.highlightsSequence !== false,
sinceBasis: entry.def.sinceBasis === 'upload' ? 'upload' : 'capture',
groundClick: entry.def.groundClick === true,
filters: [...filtersOf(entry.def)],
};
+10
View File
@@ -888,6 +888,15 @@ export function createLineCoverage({
notify();
}
/**
* Load every tile again (a source that filters on its server had its
* filter changed); the lines drawn now show until the new ones are in.
*/
function reload() {
retire();
refresh();
}
/**
* Redraw tiles draped or cast for the new surface mode, then re-pick the
* zoom, since the ground height under the camera may have changed.
@@ -1018,6 +1027,7 @@ export function createLineCoverage({
clear,
resetErrors,
rebuild,
reload,
setSurface,
recolorSequence,
sequenceCount,
+39 -7
View File
@@ -146,6 +146,7 @@ export function panoSizeFor(canvasPixels, fovDeg) {
* @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
* @property {(photo: object) => number|null} [bearingOf] Where the camera looked (default `photo.heading`).
*/
/**
@@ -165,6 +166,7 @@ export function panoSizeFor(canvasPixels, fovDeg) {
* @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 {(imageId: string) => void} [options.onReopen] Draw the same photo again after a GPU reset (default: `onStep`).
* @param {() => boolean} [options.onEscape] Esc in the docked viewer: true when it was used (e.g. a selection cleared).
* @param {object} [options.render] Render governor.
* @param {(url: string, options?: object) => Promise<HTMLImageElement|ImageBitmap>} [options.load]
@@ -179,6 +181,7 @@ export function createPhotoViewer({
step,
onStep,
onShown = () => {},
onReopen = onStep,
onEscape = () => false,
render,
load = loadImage,
@@ -212,6 +215,8 @@ export function createPhotoViewer({
let panoWidth = 0;
/** The sharper panorama on its way, for which open (resizes come in bursts). */
let panoComing = null;
/** A resize came while it loaded: look again once it lands. */
let lookAgain = false;
/** 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. */
@@ -230,8 +235,13 @@ export function createPhotoViewer({
function emit() {
if (!photo || !showing) return;
// A lost GPU context, still being recovered: the open that redraws the
// panorama poses once it is on screen.
if (showing === 'pano' && !renderer) return;
const view = showing === 'pano' ? renderer.getView() : null;
const heading = photo.heading;
const heading = sources.bearingOf
? sources.bearingOf(photo)
: photo.heading;
const described = sources.describe(photo);
const pose = {
providerId,
@@ -350,7 +360,7 @@ export function createPhotoViewer({
const again = photo.id;
show(null);
openingId = again;
onStep(again);
onReopen(again);
}
}
@@ -582,11 +592,15 @@ export function createPhotoViewer({
photo = null;
}
/** Device pixels across the viewer (its picture or canvas). */
/** Device pixels across the flat photo's frame (its longer side). */
function viewerPixels() {
const ratio = Math.min(globalThis.devicePixelRatio || 1, 2);
const ratio = globalThis.devicePixelRatio || 1;
const element = showing === 'pano' ? canvas : picture;
return (element?.clientWidth || root?.clientWidth || 0) * ratio;
const side = Math.max(
element?.clientWidth || 0,
element?.clientHeight || 0,
);
return (side || root?.clientWidth || 0) * ratio;
}
/** A sharper version once the first is on screen, if nothing replaced it. */
@@ -596,13 +610,19 @@ export function createPhotoViewer({
if (kind === 'pano') {
const size = panoSizeNow();
if (size[0] <= panoWidth) return;
if (panoComing?.seq === seq && size[0] <= panoComing.width) return;
// Resizes come in bursts while the panel animates: one load at a time
// for this photo, and one more look once it lands.
if (panoComing?.seq === seq) {
lookAgain = true;
return;
}
const sharp = sources.panoSharp(target, size, {
expanded: Boolean(root?.closest?.('.sl-viewer-wrap-expanded')),
});
if (!sharp?.url) return;
const coming = { seq, width: size[0], controller: new AbortController() };
panoComing = coming;
lookAgain = false;
try {
const image = await loadAs(sharp.url, {
crossOrigin: true,
@@ -624,7 +644,13 @@ export function createPhotoViewer({
} catch {
/* the first version stays */
} finally {
if (panoComing === coming) panoComing = null;
if (panoComing === coming) {
panoComing = null;
if (lookAgain && seq === openSeq && photo === target) {
lookAgain = false;
upgrade(target, seq, 'pano');
}
}
}
return;
}
@@ -646,6 +672,7 @@ export function createPhotoViewer({
function dropSharpPanorama() {
panoComing?.controller?.abort();
panoComing = null;
lookAgain = false;
}
/** The sharp panorama this viewer needs now: 2,048 docked, 4,096 expanded. */
@@ -791,6 +818,11 @@ export function createPhotoViewer({
applyRenderMode();
},
/** The photo's direction became known (its drive loaded): pose again. */
refreshPose() {
emit();
},
onPose(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
@@ -1,19 +1,25 @@
import { MASK_KEEP_SHARE, MASK_SAMPLE_PX } from './policy.js';
/** More than any real tile holds (a dense z13 tile has ~4,700 segments). */
export const MAX_TILE_SEGMENTS = 20_000;
/** A chained line stops growing here and the next one starts. */
export const MAX_LINE_POINTS = 5_000;
/**
* Decode a KartaView coverage tile (`{lines: [{track: [{lat, lng}]}]}`) into
* line records for the shared coverage. The tile's lines are OSM way
* segments with photos; segments that meet end to start are chained, which
* cuts the line count about fourfold. Lines carry no sequence, date or
* projection, so nothing can filter them.
* The way segments of a KartaView coverage tile
* (`{lines: [{track: [{lat, lng}]}]}`), as [lon, lat] point lists. Lines
* carry no sequence, date or projection: a filter thins them through a mask
* (see `maskedSegments`).
* @param {object} payload
* @param {{x: number, y: number, z: number}} address
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<never>}}
* @returns {Array<Array<[number, number]>>}
*/
export function decodeCoverageTile(payload, { x, y, z }) {
export function tileSegments(payload) {
// Not cached as empty: an unreadable tile is asked for again.
if (!Array.isArray(payload?.lines))
throw new Error('KartaView sent an unreadable coverage tile');
const segments = [];
for (const line of payload.lines) {
if (segments.length >= MAX_TILE_SEGMENTS) break;
const points = [];
for (const point of Array.isArray(line?.track) ? line.track : []) {
const lon = Number(point?.lng);
@@ -25,6 +31,18 @@ export function decodeCoverageTile(payload, { x, y, z }) {
}
if (points.length >= 2) segments.push(points);
}
return segments;
}
/**
* Segments chained into line records for the shared coverage: segments
* that meet end to start are joined, which cuts the line count about
* fourfold.
* @param {Array<Array<[number, number]>>} segments
* @param {{x: number, y: number, z: number}} address
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<never>}}
*/
export function chainSegments(segments, { x, y, z }) {
const key = ([lon, lat]) => `${lon},${lat}`;
const startingAt = new Map();
segments.forEach((segment, index) => {
@@ -34,16 +52,28 @@ export function decodeCoverageTile(payload, { x, y, z }) {
else startingAt.set(at, [index]);
});
const used = new Uint8Array(segments.length);
// Per start point, how far its list has been used: each segment is looked
// at once, so a tile of segments sharing one point stays linear.
const cursor = new Map();
const nextFrom = (point) => {
const at = key(point);
const list = startingAt.get(at);
if (!list) return undefined;
let i = cursor.get(at) ?? 0;
while (i < list.length && used[list[i]]) i++;
cursor.set(at, i);
return list[i];
};
const sequences = [];
segments.forEach((segment, index) => {
if (used[index]) return;
used[index] = 1;
const chain = [...segment];
for (;;) {
const next = startingAt
.get(key(chain[chain.length - 1]))
?.find((candidate) => !used[candidate]);
const next = nextFrom(chain[chain.length - 1]);
if (next === undefined) break;
// Full: the segment starts the next line instead.
if (chain.length + segments[next].length - 1 > MAX_LINE_POINTS) break;
used[next] = 1;
chain.push(...segments[next].slice(1));
}
@@ -56,3 +86,91 @@ export function decodeCoverageTile(payload, { x, y, z }) {
});
return { sequences, overview: [] };
}
/** Decode a KartaView coverage tile into the shared coverage's line records. */
export function decodeCoverageTile(payload, address) {
return chainSegments(tileSegments(payload), address);
}
/**
* The segments a filtered PNG tile of the same address draws over. The PNG
* draws the same ways a few pixels wide (on a transparent ground): a
* segment is kept when at least half the points sampled along it have a
* drawn pixel within one pixel. Segments either lie on it or miss it
* almost entirely (Jakarta, 360° only: 332 of 1,041 kept, 676 missed, 33
* between). A segment with no point on the tile is not this tile's to keep.
* @param {Array<Array<[number, number]>>} segments
* @param {{width: number, height: number, alpha: ArrayLike<number>}} mask one alpha byte per pixel
* @param {{x: number, y: number, z: number}} address
*/
export function maskedSegments(segments, mask, { x, y, z }) {
const { width, height, alpha } = mask;
const n = 2 ** z;
const toPixel = ([lon, lat]) => {
const sin = Math.sin((lat * Math.PI) / 180);
const my = 0.5 - Math.log((1 + sin) / (1 - sin)) / (4 * Math.PI);
return [(((lon + 180) / 360) * n - x) * width, (my * n - y) * height];
};
const drawnNear = (px, py) => {
const cx = Math.floor(px);
const cy = Math.floor(py);
for (let dy = -1; dy <= 1; dy++)
for (let dx = -1; dx <= 1; dx++) {
const X = cx + dx;
const Y = cy + dy;
if (X < 0 || Y < 0 || X >= width || Y >= height) continue;
if (alpha[Y * width + X] > 0) return true;
}
return false;
};
return segments.filter((segment) => {
let on = 0;
let tested = 0;
let [ax, ay] = toPixel(segment[0]);
for (let i = 1; i < segment.length; i++) {
const [bx, by] = toPixel(segment[i]);
const steps = Math.max(
1,
Math.ceil(Math.hypot(bx - ax, by - ay) / MASK_SAMPLE_PX),
);
// Each point once: a segment's next stretch starts where this one ends.
for (let j = i === 1 ? 0 : 1; j <= steps; j++) {
const px = ax + ((bx - ax) * j) / steps;
const py = ay + ((by - ay) * j) / steps;
if (px < 0 || py < 0 || px >= width || py >= height) continue;
tested++;
if (drawnNear(px, py)) on++;
}
ax = bx;
ay = by;
}
return tested > 0 && on / tested >= MASK_KEEP_SHARE;
});
}
/**
* A PNG mask's alpha, one byte per pixel, decoded off the main thread
* where the browser can.
* @param {Uint8Array} bytes the PNG
* @returns {Promise<{width: number, height: number, alpha: Uint8Array}>}
*/
export async function readMaskImage(bytes) {
const bitmap = await createImageBitmap(
new Blob([bytes], { type: 'image/png' }),
);
try {
const { width, height } = bitmap;
const canvas =
typeof OffscreenCanvas === 'function'
? new OffscreenCanvas(width, height)
: Object.assign(document.createElement('canvas'), { width, height });
const context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(bitmap, 0, 0);
const rgba = context.getImageData(0, 0, width, height).data;
const alpha = new Uint8Array(width * height);
for (let i = 0; i < alpha.length; i++) alpha[i] = rgba[i * 4 + 3];
return { width, height, alpha };
} finally {
bitmap.close?.();
}
}
@@ -1,6 +1,13 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { decodeCoverageTile } from './decode.js';
import {
MAX_LINE_POINTS,
MAX_TILE_SEGMENTS,
chainSegments,
decodeCoverageTile,
maskedSegments,
tileSegments,
} from './decode.js';
const at = (lng, lat) => ({ lat: String(lat), lng: String(lng) });
const TILE = { x: 4412, y: 6061, z: 14 };
@@ -85,3 +92,96 @@ test('a tile that is not a line list fails, so it is asked for again rather than
/unreadable coverage tile/,
);
});
test('a tile whose segments all share one point decodes in linear time', () => {
// [P, Q_i, P]: every chain step looks at the same start point's list.
// 60k: the old quadratic chaining took over a second; capped and linear
// decoding takes tens of milliseconds.
const lines = Array.from({ length: 60_000 }, (_, i) => ({
track: [at(0, 0), at(1 + i * 1e-6, 1), at(0, 0)],
}));
const started = performance.now();
const { sequences } = decodeCoverageTile({ lines }, TILE);
const ms = performance.now() - started;
assert.ok(
ms < 1_000,
`${Math.round(ms)} ms (quadratic chaining took ~200 ms per 20k and grows fourfold per doubling)`,
);
const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0);
assert.ok(sequences.every((s) => s.parts[0].length <= MAX_LINE_POINTS));
assert.equal(
points,
MAX_TILE_SEGMENTS * 2 + sequences.length,
'every segment drawn once',
);
});
test('a tile is capped in segments and in points per line', () => {
const lines = Array.from({ length: MAX_TILE_SEGMENTS + 500 }, (_, i) => ({
track: [at(i, 0), at(i + 1, 0)],
}));
const { sequences } = decodeCoverageTile({ lines }, TILE);
const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0);
// One long chain, cut into lines of at most MAX_LINE_POINTS.
assert.ok(sequences.every((s) => s.parts[0].length <= MAX_LINE_POINTS));
assert.ok(points <= MAX_TILE_SEGMENTS + sequences.length);
assert.ok(
sequences.length >= Math.floor(MAX_TILE_SEGMENTS / MAX_LINE_POINTS),
);
});
/** [lon, lat] at pixel (px, py) of a 512 px mask of `tile` (Web Mercator). */
function lonLatAt(px, py, { x, y, z } = TILE, size = 512) {
const n = 2 ** z;
const lon = ((x + px / size) / n) * 360 - 180;
const my = Math.PI * (1 - (2 * (y + py / size)) / n);
const lat = (Math.atan(Math.sinh(my)) * 180) / Math.PI;
return [lon, lat];
}
/** A 512 px mask drawn on the given rows. */
function rowsMask(rows) {
const alpha = new Uint8Array(512 * 512);
for (const [from, to] of rows) alpha.fill(255, from * 512, (to + 1) * 512);
return { width: 512, height: 512, alpha };
}
test('a mask keeps the segments it draws over, chained as before', () => {
const along = (row, from = 40, to = 470) => [
lonLatAt(from, row),
lonLatAt(to, row),
];
const segments = [
along(100), // drawn at row 100
along(101.6), // a pixel off: still drawn within one pixel
along(300), // not drawn
// Two thirds along the drawn row, then off it: kept (at least half).
[lonLatAt(40, 100), lonLatAt(400, 100), lonLatAt(400, 280)],
// A third along it, then off: dropped.
[lonLatAt(300, 100), lonLatAt(400, 100), lonLatAt(400, 300)],
// Off the tile entirely: not this tile's to keep.
[lonLatAt(-200, 100), lonLatAt(-20, 100)],
];
const kept = maskedSegments(segments, rowsMask([[99, 100]]), TILE);
assert.deepEqual(kept, [segments[0], segments[1], segments[3]]);
assert.deepEqual(maskedSegments(segments, rowsMask([]), TILE), []);
// A full mask keeps every segment that is on the tile.
assert.equal(maskedSegments(segments, rowsMask([[0, 511]]), TILE).length, 5);
});
test('decoding is segments then chaining: the same lines either way', () => {
const payload = {
lines: [
{ track: [at(1, 1), at(2, 1)] },
{ track: [at(2, 1), at(3, 1)] },
{ track: [at(5, 5), at(6, 6)] },
],
};
const segments = tileSegments(payload);
assert.equal(segments.length, 3);
assert.deepEqual(
chainSegments(segments, TILE),
decodeCoverageTile(payload, TILE),
);
assert.throws(() => tileSegments({}), /unreadable coverage tile/);
});
@@ -4,7 +4,12 @@ import { createLru, createTileCache } from '../../tileCache.js';
export { createTileCache } from '../../tileCache.js';
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
import { metresBetween } from '../../view.js';
import { decodeCoverageTile } from './decode.js';
import {
chainSegments,
maskedSegments,
readMaskImage,
tileSegments,
} from './decode.js';
import { createKartaViewSource } from './source.js';
import { createKartaViewViewer } from './viewer.js';
import {
@@ -14,6 +19,7 @@ import {
COVERAGE_NOUN,
COVERAGE_STREET_ZOOM_BELOW_M,
COVERAGE_TUNING,
FILTERED_OUT_MESSAGE,
KARTAVIEW_CREDIT_HTML,
KARTAVIEW_LABEL,
KARTAVIEW_NAME,
@@ -27,7 +33,11 @@ import {
SEQUENCE_PAGE_EDGE,
SEQUENCE_PAGE_SIZE,
TOO_HIGH_HINT,
assignCourses,
cameraBearing,
normalizePhoto,
passesKartaViewFilter,
tileFilterQuery,
} from './policy.js';
/** Photos kept in memory by id (sequence pages fill it). */
@@ -53,12 +63,13 @@ function pageOf(index) {
* KartaView Street Level provider: keyless coverage lines from KartaView's
* tile API, the photos nearest a clicked line, cones along the photo's
* sequence, and a flat or 360° viewer with ‹ › along the sequence.
* @param {{source?: ReturnType<typeof createKartaViewSource>, viewerOptions?: object}} [options]
* @param {{source?: ReturnType<typeof createKartaViewSource>, viewerOptions?: object, readMask?: typeof readMaskImage}} [options]
* @returns {import('../../registry.js').StreetLevelProvider}
*/
export function createKartaViewProvider({
source = createKartaViewSource(),
viewerOptions = {},
readMask = readMaskImage,
} = {}) {
return Object.freeze({
id: KARTAVIEW_PROVIDER_ID,
@@ -67,10 +78,15 @@ export function createKartaViewProvider({
requiresKeyId: null,
pickPrefix: PICK_PREFIX.root,
colors: COLORS,
// Its own marker, as Street View has: a purple cone that turns with the view.
marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }),
credit: Object.freeze({ html: KARTAVIEW_CREDIT_HTML }),
defaultOn: false,
// Coverage lines carry no date or projection: no filter could thin them.
filters: [],
// 360° / flat and SINCE, as Mapillary: KartaView filters the lines on
// its server (see tileFilterQuery), the photos by their own record.
filters: Object.freeze(['pano', 'since']),
// SINCE is the upload date for KartaView: the panel notes it.
sinceBasis: 'upload',
// Its lines are streets, not sequences: a selected drive shows as cones.
highlightsSequence: false,
coverageLabel: Object.freeze({
@@ -117,9 +133,13 @@ export function createKartaViewProvider({
/** The sequence page the cones were drawn from. */
let drawnPage = null;
/** A record as a photo; the one already known keeps its identity (and course). */
function remember(record) {
const photo = normalizePhoto(record);
if (photo) photos.set(photo.id, photo);
const fresh = normalizePhoto(record);
if (!fresh) return null;
const known = photos.get(fresh.id);
const photo = known ? Object.assign(known, fresh) : fresh;
photos.set(photo.id, photo);
return photo;
}
@@ -141,16 +161,25 @@ export function createKartaViewProvider({
page,
itemsPerPage: SEQUENCE_PAGE_SIZE,
})
.then(({ photos: records, hasMore }) => ({
page,
hasMore,
photos: records
.map(remember)
.filter(Boolean)
.sort(
(a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0),
),
}));
.then(({ photos: records, hasMore }) => {
const list = assignCourses(
records
.map(remember)
.filter(Boolean)
.sort(
(a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0),
),
metresBetween,
);
// The open photo may have just learnt which way it looked. The
// page is good whatever the viewer is doing.
try {
viewer.refreshPose?.();
} catch {
/* the next pose carries the direction */
}
return { page, hasMore, photos: list };
});
pages.set(key, entry);
entry.catch(() => pages.delete(key));
}
@@ -251,12 +280,30 @@ export function createKartaViewProvider({
return probe;
}
// Decoded coverage tiles, kept a while: panning back asks nothing.
// A tile's way segments, kept a while: panning back, or a filter
// change, asks KartaView nothing more for them.
const tiles = createTileCache({
load: async (tile, { signal }) =>
decodeCoverageTile(await source.getTile(tile, { signal }), tile),
tileSegments(await source.getTile(tile, { signal })),
});
const fetchTile = (tile, options) => tiles.get(tile, options);
// A filtered tile's mask, by filter and tile.
const masks = createTileCache({
load: async ({ query, ...tile }, { signal }) =>
readMask(await source.getTileMask(tile, query, { signal })),
keyOf: ({ query, z, x, y }) => `${query}|${z}/${x}/${y}`,
});
/** The tile's lines under the filter now: all, or the masked ones. */
async function fetchTile(tile, options) {
const query = tileFilterQuery(context.getFilter());
if (!query) return chainSegments(await tiles.get(tile, options), tile);
const [segments, mask] = await Promise.all([
tiles.get(tile, options),
masks.get({ ...tile, query }, options),
]);
return chainSegments(maskedSegments(segments, mask, tile), tile);
}
/** The query the drawn lines were loaded under. */
let drawnQuery = tileFilterQuery(context.getFilter());
/**
* The next showable photo `delta` along the sequence, or null at its
@@ -297,24 +344,34 @@ export function createKartaViewProvider({
renderKey: 'kartaview-coverage',
tuning: COVERAGE_TUNING,
messages: { rateLimited: RATE_LIMITED_MESSAGE, tooHigh: TOO_HIGH_HINT },
// Filtered on the way in (fetchTile): a line has no date to test.
passes: () => true,
});
const cones = createSequenceCones({
state,
async loadImages(sequenceId, { signal }) {
const anchor = anchors.get(sequenceId);
let anchor = anchors.get(sequenceId);
if (!anchor) return [];
const window = await windowOf(anchor);
let window = await windowOf(anchor);
signal?.throwIfAborted();
// Another photo of the drive opened while that page loaded (its
// re-centre found nothing drawn yet): centre on the newest one.
while (anchors.get(sequenceId) !== anchor) {
anchor = anchors.get(sequenceId);
window = await windowOf(anchor);
signal?.throwIfAborted();
}
drawnPage = { sequenceId, page: window.home };
countPhotos(sequenceId);
return thinImages(
window.photos.map((photo) => ({
id: photo.id,
lon: photo.lon,
lat: photo.lat,
compassAngle: photo.heading ?? 0,
compassAngle: cameraBearing(photo) ?? 0,
capturedAt: photo.capturedAt ?? 0,
uploadedAt: photo.uploadedAt,
isPano: photo.isPano,
})),
);
@@ -323,10 +380,27 @@ export function createKartaViewProvider({
pickPrefix: PICK_PREFIX.image,
spriteId: 'street-level:kartaview-cones',
renderKey: 'kartaview-sequence',
// The filters are hidden for KartaView: one left set must not hide cones.
passes: () => true,
// By projection and upload date, as the lines.
passes: passesKartaViewFilter,
});
/** Sequence id → its photo count from KartaView (asked once). */
const counts = new Map();
function countPhotos(sequenceId) {
if (counts.has(sequenceId) || !source.getSequenceCount) return;
counts.set(sequenceId, undefined);
source.getSequenceCount(sequenceId).then(
(count) => {
if (count === null) counts.delete(sequenceId);
else {
counts.set(sequenceId, count);
context.notify();
}
},
() => counts.delete(sequenceId),
);
}
/** ‹ › open through the core (loading, errors, cones), camera still. */
function openStepped(photoId) {
context.actions.openImage(photoId, { frame: false });
@@ -361,6 +435,17 @@ export function createKartaViewProvider({
step,
onStep: openStepped,
onShown: recentreCones,
// After a GPU reset the same photo is drawn again by the viewer
// itself: going through the core would cancel a lookup the user
// started. A failure still shows.
onReopen: (photoId) =>
viewer
.open(photoId)
.catch((error) =>
context.actions.reportError(
error?.message || 'The KartaView photo could not be loaded',
),
),
// Esc in the viewer clears the selected drive, as the panel says.
onEscape: () => {
if (!state.sequence.selectedId) return false;
@@ -396,6 +481,9 @@ export function createKartaViewProvider({
cones.clearSelection();
cones.setVisible(false);
drawnPage = null;
// Tens of MB of decoded lines are not kept for a source left off.
tiles.clear();
masks.clear();
},
destroy(cesiumViewer) {
@@ -403,12 +491,24 @@ export function createKartaViewProvider({
coverage.clear();
coverage.resetErrors();
cones.destroy(cesiumViewer);
tiles.clear();
masks.clear();
},
refreshCoverage: () => coverage.refresh(),
/** No filter applies (see `filters`). */
setFilter() {},
/**
* The filter changed: the lines come from KartaView again under it
* (the old ones show meanwhile), the cones are drawn again.
*/
setFilter() {
const query = tileFilterQuery(context.getFilter());
if (query !== drawnQuery) {
drawnQuery = query;
coverage.reload();
}
cones.rerender();
},
setSurface() {
coverage.setSurface();
@@ -457,9 +557,12 @@ export function createKartaViewProvider({
cones.clearSelection();
drawnPage = null;
},
// The drive's own count, not the thinned page of it the cones show.
sequenceStats: () => ({
selectedId: state.sequence.selectedId,
images: state.sequence.images.length,
images:
counts.get(state.sequence.selectedId) ??
state.sequence.images.length,
loading: state.sequence.loading,
}),
@@ -469,13 +572,20 @@ export function createKartaViewProvider({
* file at all, so the nearest few are tried in turn.
*/
async nearestImage({ lat, lon }, { signal } = {}) {
const records = await source.nearestPhotos({ lat, lon }, { signal });
const filter = context.getFilter();
const records = await source.nearestPhotos(
{ lat, lon, filter },
{ signal },
);
const near = [];
let filteredOut = 0;
for (const record of records) {
const photo = remember(record);
if (!photo?.urls.view) continue;
const metres = metresBetween({ lat, lon }, photo);
if (metres <= NEAREST_RADIUS_M) near.push({ photo, metres });
if (metres > NEAREST_RADIUS_M) continue;
if (!passesKartaViewFilter(photo, filter)) filteredOut++;
else near.push({ photo, metres });
}
near.sort((a, b) => a.metres - b.metres);
// Probed side by side (photo storage is not the API host): the
@@ -487,6 +597,7 @@ export function createKartaViewProvider({
const hit = candidates.find((_, i) => answers[i]);
if (hit) return hit.photo.id;
if (near.length) throw new Error(ARCHIVED_MESSAGE);
if (filteredOut) throw new Error(FILTERED_OUT_MESSAGE);
return null;
},
@@ -108,6 +108,90 @@ export const FLAT_FULL_SIZE = Object.freeze([2560, 2560]);
export const KARTAVIEW_CREDIT_HTML =
'Street Level: imagery © <a href="https://kartaview.org" target="_blank" rel="noopener">KartaView</a> contributors, CC BY-SA 4.0';
/**
* The imagery filter (360° or flat, SINCE). KartaView filters coverage on
* its server only in its PNG tiles: its JSON tiles come back empty once a
* filter is asked for. So a filtered PNG tile is a mask, and a JSON line is
* kept where it lies on the mask's drawn pixels.
*
* KartaView's date filter is the upload date, and SINCE goes by it
* throughout (lines, the photo a click opens, the cones), as KartaView's own
* map does; the panel says so under the slider.
*/
export const MASK_TIMEOUT_MS = 20_000;
/**
* A filtered PNG tile is drawn on demand and a dense one can outlast
* KartaView's own 60 s (504); it keeps drawing, and has the tile a few
* seconds later. A timeout or a 5xx is asked again this many times in all.
*/
export const MASK_ATTEMPTS = 4;
export const MASK_RETRY_WAIT_MS = 4_000;
/** Real masks are tens of kilobytes. */
export const MASK_MAX_BYTES = 2 * 1024 * 1024;
/** A segment is kept when this share of the points along it lies on the mask. */
export const MASK_KEEP_SHARE = 0.5;
/** Points along a segment are tested this many mask pixels apart. */
export const MASK_SAMPLE_PX = 2;
export const MASK_SLOW_MESSAGE =
'KartaView did not filter this area in time — move the map to ask again';
export const FILTERED_OUT_MESSAGE =
'None of KartaView’s photos here pass the imagery filter';
/** Under the SINCE slider while KartaView is lit. */
export const SINCE_NOTE = 'KartaView: by upload date';
/** The UTC calendar day of `ms`, as KartaView's startDate reads it. */
function utcDay(ms) {
return new Date(ms).toISOString().slice(0, 10);
}
const filtersSince = (filter) =>
Number.isFinite(filter?.sinceMs) && filter.sinceMs > 0;
const DAY_MS = 86_400_000;
/**
* Whether a photo passes the resolved imagery filter by its projection and
* upload date, the cut-off taken as its UTC day, as KartaView's startDate.
* @param {{isPano: boolean, uploadedAt: number|null}} photo
* @param {{pano: string, sinceMs: number|null}|null} filter
*/
export function passesKartaViewFilter(photo, filter) {
if (!filter) return true;
if (filter.pano === 'pano' && !photo.isPano) return false;
if (filter.pano === 'flat' && photo.isPano) return false;
if (!filtersSince(filter)) return true;
const day = Math.floor(filter.sinceMs / DAY_MS) * DAY_MS;
return (photo.uploadedAt ?? 0) >= day;
}
/**
* The query of a filtered PNG coverage tile, or '' when nothing is
* filtered. The day, not the instant, so it is stable for a day (and keys
* the mask cache).
* @param {{pano: string, sinceMs: number|null}|null} filter resolved
*/
export function tileFilterQuery(filter) {
const params = new URLSearchParams();
if (filter?.pano === 'pano') params.set('fieldOfView', '360');
else if (filter?.pano === 'flat') params.set('fieldOfView', 'plane');
if (filtersSince(filter)) params.set('startDate', utcDay(filter.sinceMs));
return params.toString();
}
/**
* The photo search's own filter: projection and upload date (its
* `fieldOfView` is ignored).
* @param {{pano: string, sinceMs: number|null}|null} filter resolved
* @returns {Record<string, string>}
*/
export function photoFilterParams(filter) {
const params = {};
if (filter?.pano === 'pano') params.projection = 'SPHERE';
else if (filter?.pano === 'flat') params.projection = 'PLANE';
if (filtersSince(filter)) params.startDate = utcDay(filter.sinceMs);
return params;
}
/** The cone and coverage noun: a line is a street with photos, not a sequence. */
export const COVERAGE_NOUN = 'streets';
export const LINE_CLICK_HINT = 'click a line for its nearest photo';
@@ -142,6 +226,56 @@ export function kartaViewTimeMs(value) {
return Number.isFinite(ms) ? ms : null;
}
/** Initial great-circle bearing from `a` to `b`, degrees clockwise from north. */
export function bearingBetween(a, b) {
const rad = Math.PI / 180;
const dLon = (b.lon - a.lon) * rad;
const y = Math.sin(dLon) * Math.cos(b.lat * rad);
const x =
Math.cos(a.lat * rad) * Math.sin(b.lat * rad) -
Math.sin(a.lat * rad) * Math.cos(b.lat * rad) * Math.cos(dLon);
return (Math.atan2(y, x) / rad + 360) % 360;
}
/** Photos closer than this say nothing about the direction of travel. */
const COURSE_MIN_STEP_M = 2;
/** How many photos along to look for one far enough away. */
const COURSE_SEARCH = 5;
/**
* Give each photo of a drive (in index order) its `course`: the direction
* of travel from it to the next photo a few metres on (from the previous one
* at the end). KartaView is dashcam imagery, so this is where the camera
* looks; its `heading` is the device compass, which reads 0 without one and
* can sit 110° off inside a car.
* @param {Array<{lon: number, lat: number, course?: number|null}>} photos
* @param {(a: object, b: object) => number} metres
*/
export function assignCourses(photos, metres) {
photos.forEach((photo, i) => {
let course = null;
for (let j = i + 1; j <= i + COURSE_SEARCH && j < photos.length; j++)
if (metres(photo, photos[j]) >= COURSE_MIN_STEP_M) {
course = bearingBetween(photo, photos[j]);
break;
}
for (
let k = i - 1;
course === null && k >= i - COURSE_SEARCH && k >= 0;
k--
)
if (metres(photos[k], photo) >= COURSE_MIN_STEP_M)
course = bearingBetween(photos[k], photo);
photo.course = course;
});
return photos;
}
/** Where the camera looked: the direction of travel, else the compass, else unknown. */
export function cameraBearing(photo) {
return photo?.course ?? photo?.heading ?? null;
}
/** A coordinate pair as numbers; the road-matched one when KartaView has it. */
function positionOf(record) {
const matchLat = Number(record.matchLat);
@@ -211,10 +345,11 @@ export function normalizePhoto(record) {
// 0,0 is a missing fix, not a photo in the Gulf of Guinea.
if (lat === 0 && lon === 0) return null;
if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
// An exact 0 is a device without a compass (whole drives read 0), not north.
const heading =
record.heading === '' || record.heading == null
? NaN
: Number(record.heading);
: Number(record.heading) || NaN;
const isPano =
record.projection === 'SPHERE' || Number(record.fieldOfView) >= 360;
const full =
@@ -230,6 +365,8 @@ export function normalizePhoto(record) {
isPano,
capturedAt:
kartaViewTimeMs(record.shotDate) ?? kartaViewTimeMs(record.dateAdded),
// KartaView's SINCE goes by this (its server filters by upload date).
uploadedAt: kartaViewTimeMs(record.dateAdded),
urls: {
// A 1280 px rendition; for a 360° photo it is the front view only.
view:
@@ -1,10 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
assignCourses,
bearingBetween,
cameraBearing,
kartaViewPhotoUrl,
kartaViewTimeMs,
normalizePhoto,
passesKartaViewFilter,
photoFilterParams,
resizedImageUrl,
tileFilterQuery,
} from './policy.js';
/** A photo record as /2.0/photo returns it (trimmed to what is read). */
@@ -140,7 +146,9 @@ test('records without an id or a position are dropped; a missing heading is null
null,
);
assert.equal(normalizePhoto(record({ heading: '' })).heading, null);
assert.equal(normalizePhoto(record({ heading: '0' })).heading, 0);
// A device without a compass reads 0 for a whole drive: unknown, not north.
assert.equal(normalizePhoto(record({ heading: '0' })).heading, null);
assert.equal(normalizePhoto(record({ heading: '0.5' })).heading, 0.5);
assert.equal(normalizePhoto(record({ heading: 'n/a' })).heading, null);
});
@@ -237,3 +245,104 @@ test('with no processed-photo path the size prefix gives the full photo', () =>
/^https:\/\/storage3\.openstreetcam\.org\/.*\/lth\//,
);
});
test('the camera looks along the drive: each photo’s course points to the next one a few metres on', () => {
// North along a meridian, 5 m apart, then a repeat of the last position.
const step = 5 / 110_540;
const photos = [0, 1, 2, 3].map((i) => ({ lon: 10, lat: 50 + i * step }));
photos.push({ lon: 10, lat: 50 + 3 * step }); // a stop: same place
const metres = (a, b) =>
Math.hypot(
(b.lon - a.lon) * 111_320 * Math.cos((a.lat * Math.PI) / 180),
(b.lat - a.lat) * 110_540,
);
assignCourses(photos, metres);
for (const photo of photos)
assert.ok(
Math.abs(photo.course) < 0.5 || Math.abs(photo.course - 360) < 0.5,
`${photo.course}`,
);
// Eastward: 90°.
const east = [0, 1].map((i) => ({ lon: 10 + (i * 5) / 71_600, lat: 50 }));
assignCourses(east, metres);
assert.ok(
Math.abs(east[0].course - 90) < 0.5 && Math.abs(east[1].course - 90) < 0.5,
);
// A lone photo has no course; the compass is the fallback, then unknown.
const [lone] = assignCourses([{ lon: 1, lat: 1 }], metres);
assert.equal(lone.course, null);
assert.equal(cameraBearing({ ...lone, heading: 42 }), 42);
assert.equal(cameraBearing({ ...lone, heading: null }), null);
assert.equal(
cameraBearing({ course: 90, heading: 200 }),
90,
'the drive beats the compass',
);
assert.ok(
Math.abs(bearingBetween({ lon: 0, lat: 0 }, { lon: 0, lat: -1 }) - 180) <
1e-9,
);
});
test('the imagery filter becomes KartaView’s tile query and photo-search params', () => {
// 2026-03-05 23:30 UTC: the cut-off day is the UTC one.
const sinceMs = Date.UTC(2026, 2, 5, 23, 30);
assert.equal(tileFilterQuery(null), '');
assert.equal(tileFilterQuery({ pano: 'all', sinceMs: null }), '');
assert.equal(
tileFilterQuery({ pano: 'pano', sinceMs: null }),
'fieldOfView=360',
);
assert.equal(
tileFilterQuery({ pano: 'flat', sinceMs }),
'fieldOfView=plane&startDate=2026-03-05',
);
assert.equal(tileFilterQuery({ pano: 'all', sinceMs: 0 }), '');
// The search ignores fieldOfView; its projection filters instead.
assert.deepEqual(photoFilterParams({ pano: 'all', sinceMs: null }), {});
assert.deepEqual(photoFilterParams({ pano: 'pano', sinceMs }), {
projection: 'SPHERE',
startDate: '2026-03-05',
});
assert.deepEqual(photoFilterParams({ pano: 'flat', sinceMs: null }), {
projection: 'PLANE',
});
});
test('KartaView’s SINCE is the upload date, from the start of the cut-off’s UTC day', () => {
const photo = normalizePhoto(
record({
shotDate: '2016-08-23 20:17:47.000',
dateAdded: '2026-03-05 00:10:00',
}),
);
assert.equal(photo.uploadedAt, Date.UTC(2026, 2, 5, 0, 10));
const since = (iso) => ({ pano: 'all', sinceMs: Date.parse(iso) });
// Taken in 2016, uploaded on the cut-off day: it passes, as KartaView's
// startDate=2026-03-05 keeps it.
assert.equal(
passesKartaViewFilter(photo, since('2026-03-05T23:00:00Z')),
true,
);
assert.equal(
passesKartaViewFilter(photo, since('2026-03-06T00:00:00Z')),
false,
);
assert.equal(
passesKartaViewFilter(photo, { pano: 'pano', sinceMs: null }),
false,
);
assert.equal(
passesKartaViewFilter(photo, { pano: 'flat', sinceMs: null }),
true,
);
assert.equal(passesKartaViewFilter(photo, null), true);
assert.equal(
passesKartaViewFilter(
{ ...photo, uploadedAt: null },
since('2020-01-01T00:00:00Z'),
),
false,
'no upload date passes no SINCE',
);
});
@@ -6,6 +6,7 @@ import {
kartaViewZoomFor,
} from './index.js';
import {
FILTERED_OUT_MESSAGE,
NO_COVERAGE_HINT,
SEQUENCE_PAGE_SIZE,
TOO_HIGH_HINT,
@@ -36,13 +37,23 @@ function photoRecord(id, { metres = 0, seq = '14010', index = 0 } = {}) {
};
}
/** KartaView's shotDate for `ms` ago. */
const shotAgo = (ms) =>
new Date(Date.now() - ms).toISOString().replace('T', ' ').slice(0, 23);
/**
* A source over `sequences` (id → indexes present, in page order: KartaView
* pages follow photo ids, which only roughly follow the index), recording
* calls. Photos whose id is in `archived` answer 409.
*/
function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) {
const calls = { nearest: 0, photo: [], pages: [], probes: [] };
const calls = {
nearest: 0,
nearestFilters: [],
photo: [],
pages: [],
probes: [],
};
return {
calls,
probeImage: async (url) => {
@@ -50,8 +61,9 @@ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) {
return !archived.some((id) => url.includes(`/${id}.jpg`));
},
getTile: async () => ({ lines: [] }),
nearestPhotos: async () => {
nearestPhotos: async ({ filter }) => {
calls.nearest++;
calls.nearestFilters.push(filter);
return nearest;
},
getPhoto: async (id) => {
@@ -80,12 +92,13 @@ function fakeSource({ nearest = [], sequences = {}, archived = [] } = {}) {
}
function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) {
const actions = { opened: [], nearest: [], instance: null };
// `actions.filter` is the resolved filter now; a test may change it.
const actions = { opened: [], nearest: [], instance: null, filter };
return {
actions,
context: {
services: {},
getFilter: () => filter,
getFilter: () => actions.filter,
isActive: () => true,
groundCaster: null,
meshSampler: null,
@@ -105,11 +118,12 @@ function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) {
};
}
function setup(sourceOptions, contextOptions) {
function setup(sourceOptions, contextOptions, { readMask } = {}) {
const source = fakeSource(sourceOptions);
const loads = [];
const definition = createKartaViewProvider({
source,
readMask,
viewerOptions: {
load: async (url) => {
loads.push(url);
@@ -128,12 +142,17 @@ function setup(sourceOptions, contextOptions) {
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
test('KartaView registers keyless, off by default, with no filters and its own wording', () => {
test('KartaView registers keyless, off by default, with Mapillary’s filters and its own wording', () => {
const { definition } = setup();
assert.equal(definition.id, 'kartaview');
assert.equal(definition.requiresKeyId, null);
assert.equal(definition.defaultOn, false);
assert.deepEqual(definition.filters, []);
assert.deepEqual(definition.filters, ['pano', 'since']);
assert.equal(
definition.sinceBasis,
'upload',
'SINCE is KartaView’s upload date',
);
assert.equal(
definition.highlightsSequence,
false,
@@ -195,6 +214,46 @@ test('the nearest photo is the closest within 50 m, and opening it asks nothing
);
});
test('the nearest photo passes the imagery filter by its upload date, as KartaView filters; none passing says so', async () => {
const since = Date.now() - 86_400_000;
const filter = { pano: 'pano', sinceMs: since };
const pano = (id, metres, uploaded) => ({
...photoRecord(id, { metres }),
projection: 'SPHERE',
fieldOfView: '360',
shotDate: '2016-08-23 20:17:47.000',
dateAdded: uploaded,
});
// Both taken in 2016; only the second was uploaded since yesterday.
const { instance, source } = setup(
{
nearest: [
pano('stale', 5, '2016-08-24 04:01:07'),
pano('fresh', 20, shotAgo(3_600_000).slice(0, 19)),
],
},
{ filter },
);
const at = { lat: 42.3314, lon: -83.0458 };
assert.equal(await instance.nearestImage(at), 'fresh');
assert.deepEqual(source.calls.nearestFilters, [filter]);
const stale = setup(
{ nearest: [pano('stale', 5, '2016-08-24 04:01:07')] },
{ filter },
);
await assert.rejects(stale.instance.nearestImage(at), {
message: FILTERED_OUT_MESSAGE,
});
// Uploaded earlier on the cut-off's UTC day: KartaView's startDate keeps
// it, and so does the check.
const day = new Date(since).toISOString().slice(0, 10);
const sameDay = setup(
{ nearest: [pano('early', 5, `${day} 00:00:01`)] },
{ filter },
);
assert.equal(await sameDay.instance.nearestImage(at), 'early');
});
test('a cone opens its photo; a line opens the photo nearest the click', () => {
const { instance, actions } = setup();
actions.instance = null;
@@ -617,34 +676,73 @@ test('the cones re-centre when ‹ › walk off the page they were drawn from',
);
});
test('a leftover imagery filter hides no cones: flat, undated photos still draw', async (t) => {
test('the imagery filter thins the cones by each photo’s upload date and projection', async (t) => {
const env = await mounted(
{ sequences: { s: [0, 1, 2, 3] } },
{ filter: { pano: 'pano', sinceMs: Date.now() - 86_400_000 } },
);
// Undated records: no capture time to pass a "since" filter with.
const undated = (record) => ({ ...record, shotDate: '', dateAdded: '' });
// 0 and 1: 360° photos uploaded an hour ago (taken in 2016, as all);
// 2 and 3: flat ones uploaded in 2016.
const shaped = (record) =>
Number(record.sequenceIndex) < 2
? {
...record,
projection: 'SPHERE',
fieldOfView: '360',
dateAdded: shotAgo(3_600_000).slice(0, 19),
}
: { ...record, dateAdded: '2016-08-24 04:01:07' };
const getPhoto = env.source.getPhoto;
env.source.getPhoto = async (id) => undated(await getPhoto(id));
env.source.getPhoto = async (id) => shaped(await getPhoto(id));
const getPage = env.source.getSequencePhotos;
env.source.getSequencePhotos = async (...args) => {
const page = await getPage(...args);
return { ...page, photos: page.photos.map(undated) };
return { ...page, photos: page.photos.map(shaped) };
};
const cones = await withCones(t, env);
await env.instance.viewer.open('s-1');
await env.instance.selectSequence('s');
assert.ok(await until(() => !env.instance.sequenceStats().loading));
assert.equal(env.instance.sequenceStats().images, 4);
assert.equal(cones.count(), 4, 'every cone drawn');
assert.equal(env.instance.sequenceStats().images, 4, 'the drive is whole');
assert.equal(cones.count(), 2, 'the 360° photos uploaded since yesterday');
assert.deepEqual(cones(), [0, 1]);
env.actions.filter = { pano: 'flat', sinceMs: null };
env.instance.setFilter();
assert.deepEqual(cones(), [2, 3], 'the flat ones');
env.actions.filter = { pano: 'all', sinceMs: null };
env.instance.setFilter();
assert.equal(cones.count(), 4, 'every cone, unfiltered');
});
/**
* The provider drawing coverage on a stand-in viewer above one z14 tile.
* `lines` are the tile's tracks as [lon, lat] pairs; `getTile` may be held.
*/
function coverageSetup(t, { lines = [], filter, height = 900 } = {}) {
const env = setup({}, { filter });
function coverageSetup(
t,
{ lines = [], filter, height = 900, drawnRows = () => [] } = {},
) {
// A filtered tile's mask: 512 px square, drawn on the rows
// `drawnRows(query)` lists as [from, to].
const env = setup(
{},
{ filter },
{
readMask: async ({ query }) => {
const alpha = new Uint8Array(512 * 512);
for (const [from, to] of drawnRows(query))
alpha.fill(255, from * 512, (to + 1) * 512);
return { width: 512, height: 512, alpha };
},
},
);
const masks = [];
let maskHold = null;
env.source.getTileMask = async (tile, query) => {
masks.push(query);
await maskHold;
return { query };
};
const centre = tileCentre(-83.0458, 42.3314);
const viewer = coverageViewer(
{ lon: centre.lon, lat: centre.lat, height },
@@ -671,6 +769,15 @@ function coverageSetup(t, { lines = [], filter, height = 900 } = {}) {
viewer,
centre,
tiles,
masks,
holdMasks() {
let release;
maskHold = new Promise((resolve) => (release = resolve));
return () => {
maskHold = null;
release();
};
},
holdTiles() {
let release;
hold = new Promise((resolve) => (release = resolve));
@@ -682,20 +789,59 @@ function coverageSetup(t, { lines = [], filter, height = 900 } = {}) {
};
}
test('a leftover imagery filter hides no KartaView line', async (t) => {
const { instance, viewer, centre } = coverageSetup(t, {
filter: { pano: 'pano', sinceMs: Date.now() - 86_400_000 },
lines: [
[
[-83.0458 - 0.001, 42.3314],
[-83.0458 + 0.001, 42.3314],
],
],
test('a filter thins the lines to those its PNG mask draws; without one they all show', async (t) => {
const { lon, lat } = tileCentre(-83.0458, 42.3314);
// Across the tile's middle row (mask row ~256), and a quarter up (~130).
const across = (at) => [
[lon - 0.001, at],
[lon + 0.001, at],
];
const env = coverageSetup(t, {
filter: { pano: 'pano', sinceMs: null },
lines: [across(lat), across(lat + 0.004)],
// 360° photos lie on the middle line only.
drawnRows: (query) => (query === 'fieldOfView=360' ? [[254, 258]] : []),
});
assert.ok(centre);
instance.activate(viewer);
assert.ok(await until(() => instance.coverageStats().count === 1));
assert.equal(instance.coverageStats().hint, '');
env.instance.activate(env.viewer);
assert.ok(await until(() => env.instance.coverageStats().count === 1));
assert.deepEqual(env.masks, ['fieldOfView=360']);
// Unfiltered: every line, and the tile's lines are not asked for again.
env.actions.filter = { pano: 'all', sinceMs: null };
env.instance.setFilter();
assert.ok(await until(() => env.instance.coverageStats().count === 2));
assert.equal(env.tiles.length, 1, 'the segments are kept');
// SINCE a day: uploaded since yesterday (UTC) — none here. The lines
// drawn now stay on the globe while KartaView draws the filtered tile.
const since = Date.now() - 86_400_000;
const release = env.holdMasks();
env.actions.filter = { pano: 'all', sinceMs: since };
env.instance.setFilter();
assert.ok(await until(() => env.instance.coverageStats().loading));
assert.ok(
env.viewer.scene.groundPrimitives.items.size > 0,
'the unfiltered lines show meanwhile',
);
release();
assert.ok(
await until(
() =>
!env.instance.coverageStats().loading &&
env.instance.coverageStats().count === 0,
),
);
assert.equal(
env.masks.at(-1),
`startDate=${new Date(since).toISOString().slice(0, 10)}`,
);
assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT);
assert.ok(
await until(() => env.viewer.scene.groundPrimitives.items.size === 0),
'and go once it is in',
);
// The same filter again asks KartaView for nothing.
env.instance.setFilter();
for (let i = 0; i < 5; i++) await settle();
assert.equal(env.masks.length, 2);
});
test('coverage hints: zoom in from too high, “no photos” only once an empty view has loaded', async (t) => {
@@ -714,7 +860,7 @@ test('coverage hints: zoom in from too high, “no photos” only once an empty
assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT);
});
test('a coverage tile loaded a moment ago is not asked for again', async (t) => {
test('coverage tiles are kept while KartaView is on and dropped once it is switched off', async (t) => {
const env = coverageSetup(t, {
lines: [
[
@@ -725,11 +871,14 @@ test('a coverage tile loaded a moment ago is not asked for again', async (t) =>
});
env.instance.activate(env.viewer);
assert.ok(await until(() => env.instance.coverageStats().count === 1));
// Off and on again (or a pan away and back): drawn from the kept tile.
env.instance.refreshCoverage();
assert.ok(await until(() => env.instance.coverageStats().count === 1));
assert.equal(env.tiles.length, 1, 'kept while on');
// Switched off: the decoded lines are not kept, so on again asks again.
env.instance.deactivate();
env.instance.activate(env.viewer);
assert.ok(await until(() => env.instance.coverageStats().count === 1));
assert.equal(env.tiles.length, 1);
assert.equal(env.tiles.length, 2);
});
test('a photo no page lists steps to the photos either side of its index', async () => {
@@ -767,3 +916,214 @@ test('Esc in the docked viewer clears the selected drive and is consumed; with n
assert.equal(env.instance.sequenceStats().selectedId, null, 'drive cleared');
assert.deepEqual(press(), { stopped: false, prevented: false });
});
test('the panel counts the drive’s photos, not the thinned page of them the cones show', async (t) => {
const env = await mounted({
sequences: { s: Array.from({ length: 40 }, (_, i) => i) },
});
const asked = [];
env.source.getSequenceCount = async (id) => {
asked.push(id);
return 2385;
};
await withCones(t, env);
await env.instance.viewer.open('s-3');
await env.instance.selectSequence('s');
assert.ok(
await until(() => env.instance.sequenceStats().images === 2385),
`images: ${env.instance.sequenceStats().images}`,
);
// Asked once per drive.
env.instance.clearSequence();
await env.instance.viewer.open('s-30');
await env.instance.selectSequence('s');
assert.ok(await until(() => env.instance.sequenceStats().images === 2385));
assert.deepEqual(asked, ['s']);
});
test('after a GPU reset the photo is drawn again by the viewer, not through the core; a failure is reported', async () => {
const source = fakeSource();
const record = {
...photoRecord('pano', { seq: 'p', index: 0 }),
projection: 'SPHERE',
fieldOfView: '360',
filepathProc: '2022/10/6/wrapped_proc/pano.jpg',
};
source.getPhoto = async () => record;
const view = { yaw: 0, pitch: 0, fov: 80 };
let failLoads = false;
const definition = createKartaViewProvider({
source,
viewerOptions: {
load: async (url) => {
if (failLoads)
throw new Error('The KartaView photo could not be loaded');
return { src: url };
},
createRenderer: () => ({
setImage() {},
setView: (next) => Object.assign(view, next),
getView: () => ({ ...view }),
resize() {},
clear() {},
destroy() {},
}),
},
});
const { context, actions } = fakeContext();
const errors = [];
context.actions.reportError = (message) => errors.push(message);
const instance = definition.create(context);
actions.instance = instance;
const host = fakeHost();
await instance.viewer.mount(host);
const poses = [];
instance.viewer.onPose((pose) => poses.push(pose.imageId));
await instance.viewer.open('pano');
const canvas = () => host.children[0].children[1];
const kind = () => host.children[0].dataset.kind;
canvas().fire('webglcontextlost');
assert.equal(kind(), '', 'cleared while it is drawn again');
assert.ok(await until(() => kind() === 'pano'), 'drawn again');
assert.ok(poses.length >= 2);
assert.deepEqual(actions.opened, [], 'the core (and its lookup) left alone');
failLoads = true;
canvas().fire('webglcontextlost');
assert.ok(await until(() => errors.length === 1));
assert.match(errors[0], /could not be loaded/);
});
test('the marker is KartaView’s own cone, and it points along the drive, not at a wrong compass', async () => {
const env = setup({
sequences: { s: Array.from({ length: 10 }, (_, i) => i) },
});
assert.deepEqual(env.definition.marker, {
kind: 'view-cone',
color: '#635bff',
});
// Photos run east (photoRecord puts index i at 5·i m east); the compass says 200°.
const getPhoto = env.source.getPhoto;
env.source.getPhoto = async (id) => ({
...(await getPhoto(id)),
heading: '200',
});
const getPage = env.source.getSequencePhotos;
env.source.getSequencePhotos = async (...args) => {
const page = await getPage(...args);
return {
...page,
photos: page.photos.map((record) => ({ ...record, heading: '200' })),
};
};
const host = fakeHost();
await env.instance.viewer.mount(host);
const bearings = [];
env.instance.viewer.onPose((pose) => bearings.push(pose.bearing));
await env.instance.viewer.open('s-4');
assert.equal(bearings[0], 200, 'the compass until the drive is known');
assert.ok(
await until(() => Math.abs(bearings.at(-1) - 90) < 1),
`then along the drive: ${bearings}`,
);
});
/** Hold `seq`'s page `page` until the returned function is called. */
function holdPage(source, seq, page) {
const getPage = source.getSequencePhotos;
let release;
const gate = new Promise((resolve) => (release = resolve));
source.getSequencePhotos = async (id, options) => {
if (id === seq && options.page === page) await gate;
return getPage(id, options);
};
return () => release();
}
test('a photo opened while the cones first load is the one they centre on', async (t) => {
const size = SEQUENCE_PAGE_SIZE;
const env = await mounted({
sequences: { s: Array.from({ length: size * 5 }, (_, i) => i) },
});
const cones = await withCones(t, env);
const release = holdPage(env.source, 's', 1);
await env.instance.viewer.open('s-10');
const selected = env.instance.selectSequence('s');
// A line click further along the drive while page 1 is still loading:
// nothing is drawn yet for its re-centre to move.
await env.instance.viewer.open(`s-${size * 4 + 10}`);
release();
await selected;
assert.ok(
await until(
() => !env.instance.sequenceStats().loading && cones.count() > 0,
),
);
assert.ok(
cones()[0] <= size * 4 + 10 && cones()[1] >= size * 4 + 10,
`around the newer photo, not the first: ${cones()}`,
);
});
test('a sequence page that lands while a lost GPU context recovers still draws its cones', async (t) => {
const env = setup({ sequences: { p: [0, 1, 2, 3] } });
const sphere = (record) => ({
...record,
projection: 'SPHERE',
fieldOfView: '360',
filepathProc: `2022/10/6/wrapped_proc/${record.id}.jpg`,
});
const getPhoto = env.source.getPhoto;
env.source.getPhoto = async (id) => sphere(await getPhoto(id));
const getPage = env.source.getSequencePhotos;
env.source.getSequencePhotos = async (...args) => {
const page = await getPage(...args);
return { ...page, photos: page.photos.map(sphere) };
};
const release = holdPage(env.source, 'p', 1);
let holdImages = false;
let releaseImage = null;
const view = { yaw: 0, pitch: 0, fov: 80 };
const definition = createKartaViewProvider({
source: env.source,
viewerOptions: {
load: async (url) => {
if (holdImages) await new Promise((r) => (releaseImage = r));
return { src: url };
},
createRenderer: () => ({
setImage() {},
setView: (next) => Object.assign(view, next),
getView: () => ({ ...view }),
resize() {},
clear() {},
destroy() {},
}),
},
});
const { context, actions } = fakeContext();
const errors = [];
context.actions.reportError = (message) => errors.push(message);
const instance = definition.create(context);
actions.instance = instance;
const cones = await withCones(t, { instance });
const host = fakeHost();
await instance.viewer.mount(host);
await instance.viewer.open('p-0');
// › to the next panorama; the GPU resets while it loads.
holdImages = true;
const opening = instance.viewer.open('p-1');
assert.ok(await until(() => releaseImage !== null));
host.children[0].children[1].fire('webglcontextlost');
const selected = instance.selectSequence('p');
release();
await selected;
assert.ok(
await until(() => !instance.sequenceStats().loading && cones.count() > 0),
'the page is drawn, not dropped',
);
releaseImage();
await opening;
assert.equal(host.children[0].dataset.kind, 'pano', 'p-1 shows');
// reportError(null) clears; nothing was reported.
assert.deepEqual(errors.filter(Boolean), []);
});
@@ -2,6 +2,11 @@ import {
IMAGE_PROBE_TIMEOUT_MS,
MAX_RESPONSE_BYTES,
KARTAVIEW_API_HOST,
MASK_ATTEMPTS,
MASK_MAX_BYTES,
MASK_RETRY_WAIT_MS,
MASK_SLOW_MESSAGE,
MASK_TIMEOUT_MS,
NEAREST_LIMIT,
NEAREST_SEARCH_ZOOM,
PAGE_CONCURRENCY,
@@ -14,22 +19,33 @@ import {
PHOTO_API_TIMEOUT_MS,
TILE_CONCURRENCY,
TILE_TIMEOUT_MS,
photoFilterParams,
} 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. */
/**
* An HTTP or API failure; `retryAfterSec` is set while KartaView rate-limits,
* `timedOut` when it did not answer in time (the one failure worth asking
* again at once).
*/
export class KartaViewSourceError extends Error {
constructor(message, { status = 0, retryAfterSec = null } = {}) {
constructor(
message,
{ status = 0, retryAfterSec = null, timedOut = false } = {},
) {
super(message);
this.name = 'KartaViewSourceError';
this.status = status;
this.retryAfterSec = retryAfterSec;
this.timedOut = timedOut;
}
}
const NO_ANSWER = 'KartaView did not answer in time — try again';
const UNREACHABLE = 'KartaView could not be reached — check the network';
const UNREADABLE = 'KartaView sent an answer that could not be read';
/** Statuses that mean a photo file is gone, not that storage hiccupped. */
const GONE_STATUSES = Object.freeze([403, 404, 409, 410]);
@@ -64,29 +80,47 @@ function retryAfterSeconds(response) {
* waits behind slow tiles or pages. The photo API answers 429 to more than
* about three requests at once; a 429 (and a lookup that timed out) is
* retried.
* @param {{fetchImpl?: typeof fetch, apiHost?: string, retryBaseMs?: number}} [options]
* @param {{fetchImpl?: typeof fetch, apiHost?: string, retryBaseMs?: number, maskRetryMs?: number}} [options]
*/
export function createKartaViewSource({
fetchImpl = (...args) => globalThis.fetch(...args),
apiHost = KARTAVIEW_API_HOST,
retryBaseMs = PHOTO_API_RETRY_BASE_MS,
maskRetryMs = MASK_RETRY_WAIT_MS,
} = {}) {
const limiter = createLimiter(PHOTO_API_CONCURRENCY);
const tileLimiter = createLimiter(TILE_CONCURRENCY);
const pageLimiter = createLimiter(PAGE_CONCURRENCY, { lifo: true });
/** GET JSON within `timeoutMs`; the caller's abort wins over the timeout. */
async function getJson(url, { signal, timeoutMs }) {
function getJson(url, { signal, timeoutMs }) {
return fetchBody(url, { signal, timeoutMs, as: 'json' });
}
/**
* GET a body within `timeoutMs`, at most `maxBytes` of it, as parsed JSON
* or (`as: 'bytes'`) a Uint8Array; the caller's abort wins over the
* timeout.
*/
async function fetchBody(
url,
{ signal, timeoutMs, as = 'json', maxBytes = MAX_RESPONSE_BYTES },
) {
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 KartaViewSourceError(NO_ANSWER)),
() =>
controller.abort(
new KartaViewSourceError(NO_ANSWER, { timedOut: true }),
),
timeoutMs,
);
let response = null;
let read = false;
try {
const response = await fetchImpl(url, { signal: controller.signal });
response = await fetchImpl(url, { signal: controller.signal });
if (response.status === 429)
throw new KartaViewSourceError('KartaView is rate-limiting requests', {
status: 429,
@@ -102,17 +136,30 @@ export function createKartaViewSource({
new KartaViewSourceError('KartaView sent too much data', {
status: response.status,
});
if (length > MAX_RESPONSE_BYTES) throw tooMuch();
if (length > maxBytes) throw tooMuch();
// KartaView answers chunked, without a length: count while reading.
const reader = response.body?.getReader?.();
if (!reader) return await response.json();
if (!reader) {
read = true;
try {
if (as === 'bytes')
return new Uint8Array(await response.arrayBuffer());
return await response.json();
} catch (error) {
if (controller.signal.aborted) throw error;
throw new KartaViewSourceError(UNREADABLE, {
status: response.status,
});
}
}
read = true;
const chunks = [];
let bytes = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
bytes += value.byteLength;
if (bytes > MAX_RESPONSE_BYTES) {
if (bytes > maxBytes) {
reader.cancel().catch(() => {});
throw tooMuch();
}
@@ -124,15 +171,30 @@ export function createKartaViewSource({
body.set(chunk, offset);
offset += chunk.byteLength;
}
return JSON.parse(new TextDecoder().decode(body));
if (as === 'bytes') return body;
try {
return JSON.parse(new TextDecoder().decode(body));
} catch {
throw new KartaViewSourceError(UNREADABLE, {
status: response.status,
});
}
} catch (error) {
if (signal?.aborted) throw signal.reason;
if (controller.signal.aborted) throw controller.signal.reason;
if (error instanceof KartaViewSourceError) throw error;
throw new KartaViewSourceError(NO_ANSWER);
// fetch rejects on no network, a blocked request or a refused CORS check.
throw new KartaViewSourceError(UNREACHABLE);
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
// An answer turned away unread (429, an error, too long) still holds
// its connection, one of the six the browser has: let it go.
if (response && !read) {
controller.abort();
if (response.body && !response.body.locked)
response.body.cancel?.().catch?.(() => {});
}
}
}
@@ -174,7 +236,7 @@ export function createKartaViewSource({
return payload?.result ?? {};
} catch (error) {
if (signal?.aborted) throw error;
const timedOut = error?.message === NO_ANSWER && error.status === 0;
const timedOut = error?.timedOut === true;
if (timedOut && timeouts++ < 1) continue;
if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) {
if (error?.status === 429)
@@ -212,11 +274,46 @@ export function createKartaViewSource({
} catch (error) {
// A cold dense tile can outlast the timeout; KartaView has it ready
// by the second ask.
if (signal?.aborted || error?.message !== NO_ANSWER) throw error;
if (signal?.aborted || error?.timedOut !== true) throw error;
return once();
}
},
/**
* A filtered coverage tile as PNG bytes, `query` from tileFilterQuery
* (KartaView filters only its PNG tiles). It draws a filtered tile on
* demand; a dense one can outlast its own 60 s (504) and still be drawn,
* so a timeout or a 5xx is asked again a few seconds later.
*/
async getTileMask({ x, y, z }, query, { signal } = {}) {
const url = `${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.png?${query}`;
for (let attempt = 1; ; attempt++) {
try {
// Outside the gate between tries: a wait holds no connection.
return await tileLimiter.run(
() =>
fetchBody(url, {
signal,
timeoutMs: MASK_TIMEOUT_MS,
as: 'bytes',
maxBytes: MASK_MAX_BYTES,
}),
{ signal },
);
} catch (error) {
if (signal?.aborted) throw error;
const drawing =
error?.timedOut === true || [502, 503, 504].includes(error?.status);
if (!drawing) throw error;
if (attempt >= MASK_ATTEMPTS)
throw new KartaViewSourceError(MASK_SLOW_MESSAGE, {
timedOut: true,
});
await wait(maskRetryMs, signal);
}
}
},
/**
* Whether a photo file is there: true, false when it is gone (archived
* photos answer 409, and a HEAD says 200 regardless), or null when the
@@ -248,8 +345,11 @@ export function createKartaViewSource({
}
},
/** Photo records around a point, nearest first. */
async nearestPhotos({ lat, lon }, { signal } = {}) {
/**
* Photo records around a point, nearest first; with a resolved imagery
* `filter`, only those of its projection uploaded since its cut-off.
*/
async nearestPhotos({ lat, lon, filter = null }, { signal } = {}) {
const result = await photoApi(
'/2.0/photo/',
{
@@ -257,6 +357,7 @@ export function createKartaViewSource({
lng: lon.toFixed(7),
zoomLevel: String(NEAREST_SEARCH_ZOOM),
itemsPerPage: String(NEAREST_LIMIT),
...photoFilterParams(filter),
},
{ signal },
);
@@ -275,6 +376,17 @@ export function createKartaViewSource({
return result.data;
},
/** How many photos a sequence has, or null if KartaView does not say. */
async getSequenceCount(sequenceId, { signal } = {}) {
const result = await photoApi(
`/2.0/sequence/${encodeURIComponent(sequenceId)}`,
null,
{ signal },
);
const count = Number(result.data?.countActivePhotos);
return Number.isFinite(count) && count > 0 ? count : null;
},
/** A page of a sequence's photos in capture order (1-based `page`). */
async getSequencePhotos(
sequenceId,
@@ -6,6 +6,8 @@ import {
createLimiter,
} from './source.js';
import {
MASK_ATTEMPTS,
MASK_SLOW_MESSAGE,
NEAREST_LIMIT,
NEAREST_SEARCH_ZOOM,
PHOTO_API_CONCURRENCY,
@@ -151,12 +153,19 @@ test('an API refusal inside a 200 is a readable error with its code; 601 is just
test('HTTP errors, network failures and non-JSON become readable errors', async () => {
const http = createKartaViewSource({ fetchImpl: async () => status(500) });
await assert.rejects(http.getTile({ x: 0, y: 0, z: 14 }), /HTTP 500/);
let asked = 0;
const offline = createKartaViewSource({
fetchImpl: async () => {
asked++;
throw new TypeError('Failed to fetch');
},
});
await assert.rejects(offline.getPhoto('1'), /did not answer/);
// Not a timeout: no network is said as much, and not asked again at once.
await assert.rejects(
offline.getPhoto('1'),
/could not be reached — check the network/,
);
assert.equal(asked, 1);
const garbled = createKartaViewSource({
fetchImpl: async () => ({
ok: true,
@@ -169,7 +178,19 @@ test('HTTP errors, network failures and non-JSON become readable errors', async
});
await assert.rejects(
garbled.getTile({ x: 0, y: 0, z: 14 }),
/did not answer/,
/could not be read/,
);
const torn = createKartaViewSource({
fetchImpl: async () => ({
ok: true,
status: 200,
headers: new Map(),
body: streamed('{"lines": [', 4),
}),
});
await assert.rejects(
torn.getTile({ x: 0, y: 0, z: 14 }),
/could not be read/,
);
});
@@ -265,18 +286,24 @@ test('tiles, lookups and sequence pages queue separately, so a lookup never wait
assert.equal(peak.page, 1);
});
test('a lookup that times out is asked once more; a second timeout says to try again', async () => {
test('a lookup that times out is asked once more, then reported as no answer in time', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const source = createKartaViewSource({
fetchImpl: async () => {
calls++;
throw new TypeError('Failed to fetch');
},
});
await assert.rejects(
source.getPhoto('1'),
const outcome = await onMockClock(t, () =>
createKartaViewSource({
fetchImpl: (url, { signal }) => {
calls++;
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason)),
);
},
}).getPhoto('1'),
);
assert.match(
String(outcome?.error?.message),
/did not answer in time — try again/,
);
assert.equal(outcome.error.timedOut, true);
assert.equal(calls, 2);
});
@@ -323,17 +350,35 @@ test('an image probe reads only the status: an archived photo (409) is not showa
);
});
test('a cold tile that times out is asked once more', async () => {
test('a cold tile that times out is asked once more; an unreachable one is not', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let calls = 0;
const source = createKartaViewSource({
const outcome = await onMockClock(t, () =>
createKartaViewSource({
fetchImpl: (url, { signal }) => {
calls++;
if (calls === 1)
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason)),
);
return Promise.resolve(ok({ lines: [] }));
},
}).getTile({ x: 1, y: 2, z: 13 }),
);
assert.deepEqual(outcome.value, { lines: [] });
assert.equal(calls, 2);
let asked = 0;
const offline = createKartaViewSource({
fetchImpl: async () => {
calls++;
if (calls === 1) throw new TypeError('Failed to fetch');
return ok({ lines: [] });
asked++;
throw new TypeError('Failed to fetch');
},
});
assert.deepEqual(await source.getTile({ x: 1, y: 2, z: 13 }), { lines: [] });
assert.equal(calls, 2);
await assert.rejects(
offline.getTile({ x: 1, y: 2, z: 13 }),
/could not be reached/,
);
assert.equal(asked, 1);
});
test('an absurdly large answer is refused before it is parsed', async () => {
@@ -552,3 +597,132 @@ test('a sequence page gets the full 30 s on its first try, then 30 s once more',
assert.match(String(outcome?.error?.message), /did not answer in time/);
assert.equal(outcome.at, 2 * PAGE_TIMEOUT_MS);
});
test('an answer turned away unread (an error, a 429, a length past the cap) lets its connection go', async () => {
/** A response whose body is never read: records its cancel and the abort. */
const unread = (answer) => {
const seen = { cancelled: false, signal: null };
const fetchImpl = async (url, { signal }) => {
seen.signal = signal;
return {
...answer,
body: {
locked: false,
getReader: () => {
throw new Error('not read');
},
cancel: async () => {
seen.cancelled = true;
},
},
};
};
return { seen, fetchImpl };
};
const cases = [
[{ ok: false, status: 503, headers: new Map() }, /HTTP 503/],
[
{
ok: true,
status: 200,
headers: new Map([['content-length', String(64 * 1024 * 1024)]]),
},
/too much data/,
],
];
for (const [answer, message] of cases) {
const { seen, fetchImpl } = unread(answer);
const source = createKartaViewSource({ fetchImpl });
await assert.rejects(source.getTile({ x: 1, y: 2, z: 13 }), message);
assert.equal(seen.signal.aborted, true, `${message}: request aborted`);
assert.equal(seen.cancelled, true, `${message}: body cancelled`);
}
// A 429 is let go before its retry is asked.
const { seen, fetchImpl } = unread({
ok: false,
status: 429,
headers: new Map([['retry-after', '0']]),
});
const signals = [];
const source = createKartaViewSource({
fetchImpl: async (url, init) => {
signals.push(init.signal);
if (signals.length === 1) return fetchImpl(url, init);
return ok(photoPayload([]));
},
});
await source.getPhoto('1');
assert.equal(signals[0].aborted, true);
assert.equal(seen.cancelled, true);
assert.equal(signals[1].aborted, false, 'a read answer is left alone');
});
test('a filtered tile comes as PNG bytes; KartaView still drawing it (504) is asked again, then given up', async () => {
const png = new Uint8Array([137, 80, 78, 71, 1, 2, 3]);
const bytesOf = () => ({
ok: true,
status: 200,
headers: new Map(),
body: {
getReader: () => {
let given = false;
return {
read: async () =>
given
? { done: true }
: ((given = true), { done: false, value: png }),
cancel: async () => {},
};
},
},
});
const urls = [];
let answers = [status(504), status(503), bytesOf()];
const source = createKartaViewSource({
maskRetryMs: 0,
fetchImpl: async (url) => {
urls.push(url);
return answers.shift();
},
});
const tile = { x: 13054, y: 8474, z: 14 };
assert.deepEqual(await source.getTileMask(tile, 'fieldOfView=360'), png);
assert.deepEqual(urls, [
'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360',
'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360',
'https://api.kartaview.org/2.0/sequence/tiles/13054/8474/14.png?fieldOfView=360',
]);
// Still drawing after every try: given up, saying so.
urls.length = 0;
answers = Array.from({ length: 10 }, () => status(504));
await assert.rejects(source.getTileMask(tile, 'fieldOfView=360'), {
message: MASK_SLOW_MESSAGE,
timedOut: true,
});
assert.equal(urls.length, MASK_ATTEMPTS);
// A refusal is not asked again.
urls.length = 0;
answers = [status(400)];
await assert.rejects(source.getTileMask(tile, 'fieldOfView=360'), /HTTP 400/);
assert.equal(urls.length, 1);
});
test('a filtered nearest search asks KartaView for the projection and upload date', async () => {
const urls = [];
const source = createKartaViewSource({
fetchImpl: async (url) => {
urls.push(new URL(url));
return ok(photoPayload([]));
},
});
await source.nearestPhotos({
lat: 42.3314,
lon: -83.0458,
filter: { pano: 'pano', sinceMs: Date.UTC(2026, 0, 2, 12) },
});
await source.nearestPhotos({ lat: 42.3314, lon: -83.0458 });
assert.equal(urls[0].searchParams.get('projection'), 'SPHERE');
assert.equal(urls[0].searchParams.get('startDate'), '2026-01-02');
assert.equal(urls[1].searchParams.has('projection'), false);
assert.equal(urls[1].searchParams.has('startDate'), false);
});
@@ -9,6 +9,7 @@ import {
KARTAVIEW_NAME,
KARTAVIEW_PROVIDER_ID,
PANO_PREVIEW_SIZE,
cameraBearing,
kartaViewPhotoUrl,
resizedImageUrl,
} from './policy.js';
@@ -47,7 +48,14 @@ const KARTAVIEW_SOURCES = Object.freeze({
photo.isPano ? null : resizedImageUrl(photo.urls.full, FLAT_VIEW_SIZE),
photo.urls.view,
],
flatSharp: (photo) => resizedImageUrl(photo.urls.full, FLAT_FULL_SIZE),
// A docked viewer shows the 1,280 px version in full: the 2,560 px one
// (~0.6 MB a photo) waits until the viewer is bigger than that.
flatSharp: (photo, pixels) =>
pixels > FLAT_VIEW_SIZE[0]
? resizedImageUrl(photo.urls.full, FLAT_FULL_SIZE)
: null,
// The camera's direction, which the drive's track may refine.
bearingOf: (photo) => cameraBearing(photo),
describe: (photo) => ({
// KartaView's only place name is the sequence's, where the drive began.
title: null,
@@ -105,8 +105,11 @@ function setup({
rendererThrows = false,
steps = {},
failSteps = false,
hold = () => false,
} = {}) {
const loads = [];
const held = [];
const closed = [];
const renderers = [];
const stepped = [];
const asked = [];
@@ -130,7 +133,10 @@ function setup({
loads.push({ url, crossOrigin: options?.crossOrigin });
if (failLoads.some((part) => url.includes(part)))
throw new Error('The KartaView photo could not be loaded');
return { src: url };
// A decoded bitmap: close() frees it.
const image = { src: url, close: () => closed.push(url) };
if (!hold(url)) return image;
return new Promise((resolve) => held.push(() => resolve(image)));
},
createRenderer: (canvas) => {
attempts.renderer++;
@@ -152,6 +158,8 @@ function setup({
stepped,
asked,
attempts,
held,
closed,
};
}
@@ -168,6 +176,7 @@ test('a flat photo shows its 1280 px version, then the sharp one, with a pose fa
});
await viewer.mount(host);
const { picture, canvas, prev, next } = parts(host);
picture.clientWidth = 1600; // expanded: the sharp version is wanted
assert.equal(prev.disabled, true, 'nothing to step through yet');
await viewer.open('flat1');
assert.equal(picture.hidden, false);
@@ -621,6 +630,8 @@ test('quick presses while the next photo is still loading each step on from the
test('a slow sharp version of an older photo never replaces the newer photo', async () => {
const { viewer, host, shown, poses, release } = heldSetup();
await viewer.mount(host);
// An expanded viewer, so the sharp version is wanted.
parts(host).picture.clientWidth = 1600;
const first = viewer.open('a');
await release('a@1280');
await first;
@@ -904,3 +915,70 @@ test('a CORS image is decoded off the main thread into an ImageBitmap where the
/could not be loaded/,
);
});
test('a docked viewer keeps the 1,280 px photo; expanding it brings the 2,560 px one', async () => {
const { viewer, host, loads } = setup();
await viewer.mount(host);
await viewer.open('flat1');
for (let i = 0; i < 4; i++) await settle();
assert.equal(loads.length, 1, 'no 2,560 px download for a 400 px frame');
parts(host).picture.clientWidth = 1600;
viewer.resize();
assert.ok(await until(() => loads.length === 2));
assert.match(loads[1].url, /rs:fit:2560:2560/);
assert.equal(parts(host).picture.src, loads[1].url);
viewer.resize();
for (let i = 0; i < 4; i++) await settle();
assert.equal(loads.length, 2, 'once only');
});
test('resizes while a sharper panorama loads start no second load; the last size wins once it lands', async () => {
let first = true;
const { viewer, host, loads, renderers, held } = setup({
hold: (url) => {
const holdIt = first && url.includes('rs:fit:2048');
if (holdIt) first = false;
return holdIt;
},
});
await viewer.mount(host);
await viewer.open('pano1');
assert.ok(await until(() => held.length === 1));
// The panel animates open: a burst of resizes while 2,048 px loads.
parts(host).canvas.clientWidth = 1400;
for (let i = 0; i < 5; i++) viewer.resize();
for (let i = 0; i < 4; i++) await settle();
assert.equal(loads.length, 2, 'the preview and the one 2,048 px load');
held[0]();
assert.ok(await until(() => renderers[0].images.length === 3));
assert.match(renderers[0].images[1], /rs:fit:2048:1024/);
assert.match(renderers[0].images[2], /rs:fit:4096:2048/);
for (let i = 0; i < 4; i++) await settle();
assert.equal(loads.length, 3, 'one more load, for the size asked last');
});
test('a preview or sharper version nobody will draw is freed, not left decoded', async () => {
const { viewer, host, renderers, held, closed } = setup({
hold: (url) => /rs:fit:(1024|2048):/.test(url),
});
await viewer.mount(host);
// pano1's preview is still loading when flat1 opens over it.
const overtaken = viewer.open('pano1');
assert.ok(await until(() => held.length === 1));
await viewer.open('flat1');
held[0]();
await overtaken;
assert.deepEqual(closed.length, 1);
assert.match(closed[0], /rs:fit:1024/, 'the overtaken preview');
// pano1 shows; its sharp version lands after flat1 replaced it.
const shown = viewer.open('pano1');
assert.ok(await until(() => held.length === 2));
held[1]();
await shown;
assert.ok(await until(() => held.length === 3));
await viewer.open('flat1');
held[2]();
assert.ok(await until(() => closed.length === 2));
assert.match(closed[1], /rs:fit:2048/, 'the overtaken sharp version');
assert.equal(renderers[0].images.length, 1, 'only the preview was drawn');
});
+9
View File
@@ -24,6 +24,7 @@ export function filtersOf(provider) {
* @property {Array<'pano'|'since'>} [filters] Filters it honours (default both).
* @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos").
* @property {boolean} [highlightsSequence] Its lines show the selected sequence (default true; the legend's "Selected" needs one).
* @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' puts a note under the slider while the provider is lit.
* @property {(context: ProviderContext) => ProviderInstance} create
*
* @typedef {object} ProviderContext Handed to `create()` once by the core.
@@ -161,6 +162,14 @@ export function validateProviders(providers) {
throw new TypeError(
`Street Level provider ${label}: filters must list ${IMAGERY_FILTERS.join(' or ')}`,
);
if (
provider.sinceBasis !== undefined &&
provider.sinceBasis !== 'capture' &&
provider.sinceBasis !== 'upload'
)
throw new TypeError(
`Street Level provider ${label}: sinceBasis must be 'capture' or 'upload'`,
);
ids.add(provider.id);
prefixes.push(provider.pickPrefix);
}
+5
View File
@@ -117,4 +117,9 @@ test('a provider may narrow the filters it honours and pick a view-cone marker',
() => validateProviders([def({ marker: { kind: 'pegman' } })]),
/marker must be/,
);
validateProviders([def({ sinceBasis: 'upload' })]);
assert.throws(
() => validateProviders([def({ sinceBasis: 'shot' })]),
/sinceBasis must be 'capture' or 'upload'/,
);
});
+3 -2
View File
@@ -24,7 +24,7 @@ export function createLru(limit) {
}
/**
* Loaded (decoded) coverage tiles by `z/x/y`, at most `size`, each for
* Loaded (decoded) coverage tiles by `keyOf` (default `z/x/y`), at most `size`, each for
* `ttlMs`: a pan back, or a zoom across bands and back, neither fetches nor
* decodes them again. A load that fails is not kept.
*/
@@ -33,11 +33,12 @@ export function createTileCache({
size = TILE_CACHE_SIZE,
ttlMs = TILE_CACHE_TTL_MS,
now = Date.now,
keyOf = (tile) => `${tile.z}/${tile.x}/${tile.y}`,
}) {
const kept = createLru(size);
return {
async get(tile, options) {
const key = `${tile.z}/${tile.x}/${tile.y}`;
const key = keyOf(tile);
const entry = kept.get(key);
if (entry && now() - entry.at < ttlMs) return entry.tile;
const loaded = await load(tile, options);
+14
View File
@@ -19,6 +19,7 @@ import { COLORS } from './policy.js';
* @property {Array<'pano'|'since'>} [filters]
* @property {{noun: string, action: string}|null} [coverageLabel]
* @property {boolean} [highlightsSequence] Its lines turn cyan when their sequence is selected.
* @property {'capture'|'upload'} [sinceBasis] What its SINCE compares.
*/
/**
@@ -72,6 +73,18 @@ export function filterControls(providers) {
return { pano: honoured('pano'), since: honoured('since') };
}
/**
* The note under SINCE when a lit provider dates its imagery by upload,
* e.g. "KartaView: by upload date", or null.
* @param {Array<ProviderSnapshot>} providers
*/
export function sinceNote(providers) {
const names = providers
.filter((p) => p.on && p.sinceBasis === 'upload')
.map((p) => p.name);
return names.length ? `${names.join(', ')}: by upload date` : null;
}
/**
* The panel snapshot from core state and provider snapshots. The legend has
* one swatch per active source that draws coverage lines, then Selected.
@@ -112,6 +125,7 @@ export function composeUIState({
keyRejected,
filter: { ...filter },
filterControls: filterControls(providers),
sinceNote: sinceNote(providers),
providers: providers.map((p) => ({ ...p })),
coverage,
legend,
+12
View File
@@ -128,6 +128,18 @@ test('the filter controls show only for a lit provider that honours them', () =>
);
});
test('a lit provider that dates by upload puts a note under SINCE', () => {
const kartaView = provider({
id: 'kartaview',
name: 'KartaView',
sinceBasis: 'upload',
});
const note = (providers) => composeUIState({ ...base, providers }).sinceNote;
assert.equal(note([provider()]), null, 'Mapillary: capture date');
assert.equal(note([kartaView]), 'KartaView: by upload date');
assert.equal(note([{ ...kartaView, on: false }]), null, 'only while lit');
});
test('hint and error come from the first active provider that has one', () => {
const ui = composeUIState({
...base,
+22 -2
View File
@@ -50,6 +50,26 @@ export function extractStationsArray(payload) {
return [];
}
/** Read legacy text or a GBFS v3 Localized String, preferring usable English text. */
function stationName(value) {
if (typeof value === 'string') return value.trim();
if (!Array.isArray(value)) return '';
let fallback = '';
for (const translation of value) {
if (typeof translation?.text !== 'string') continue;
const text = translation.text.trim();
if (!text) continue;
if (!fallback) fallback = text;
if (
typeof translation.language === 'string' &&
/^en(?:-|$)/i.test(translation.language.trim())
)
return text;
}
return fallback;
}
/**
* Parse a GBFS station_information payload into a Map of station metadata.
* Handles field-name variations across different GBFS providers
@@ -63,7 +83,7 @@ export function parseStationInformation(payload) {
const stationMap = new Map();
for (const raw of stations) {
// Accept both GBFS 2.x (station_id) and alternate (id) field names
// Accept both GBFS (station_id) and alternate (id) field names
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
const lat = Number(raw?.lat ?? raw?.latitude);
const lon = Number(raw?.lon ?? raw?.longitude);
@@ -71,7 +91,7 @@ export function parseStationInformation(payload) {
stationMap.set(stationId, {
stationId,
name: String(raw?.name || raw?.short_name || '').trim(),
name: stationName(raw?.name) || stationName(raw?.short_name),
lat,
lon,
capacity: toNonNegativeInteger(raw?.capacity),
+199
View File
@@ -0,0 +1,199 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseStationInformation } from './gbfsStations.js';
const station = (fields = {}) => ({
station_id: 's1',
lat: 30.2681,
lon: -97.7469,
capacity: 13,
...fields,
});
const parseName = (fields, version) =>
parseStationInformation({
version,
data: { stations: [station(fields)] },
}).get('s1').name;
test('GBFS v2 station names remain trimmed strings', () => {
assert.equal(
parseName({ name: ' Republic Square ', short_name: 'Short' }, '2.3'),
'Republic Square',
);
});
test('GBFS v3 station names prefer English over the first translation', () => {
assert.equal(
parseName(
{
name: [
{ language: 'hu', text: 'Köztársaság tér' },
{ language: 'en', text: ' Republic Square ' },
],
},
'3.0',
),
'Republic Square',
);
});
test('English preference accepts case differences and regional language tags', () => {
for (const language of ['EN', 'en-GB', 'En-US']) {
assert.equal(
parseName({
name: [
{ language: 'hu', text: 'Hungarian' },
{ language, text: 'English' },
],
}),
'English',
);
}
});
test('without usable English text the first usable translation wins', () => {
assert.equal(
parseName({
name: [
null,
{ language: 'en', text: ' ' },
{ language: 'en', text: 42 },
{ language: 'hu', text: ' Első ' },
{ language: 'fr', text: 'Deuxième' },
],
}),
'Első',
);
});
test('malformed language fields cannot abort translation selection', () => {
const name = [
null,
false,
'bad',
{},
{ language: 42, text: 'Fallback' },
{ language: { unexpected: true }, text: 'Other' },
{ language: 'en', text: 'English' },
];
assert.equal(parseName({ name }), 'English');
assert.equal(
parseName({ name: [{ text: ' Usable text without a locale ' }] }),
'Usable text without a locale',
);
});
test('a usable name takes precedence over an English short name', () => {
assert.equal(
parseName({
name: [{ language: 'hu', text: 'Full name' }],
short_name: [{ language: 'en', text: 'Short name' }],
}),
'Full name',
);
});
test('missing, empty and malformed names fall back to localized short_name', () => {
for (const name of [
undefined,
null,
'',
' ',
[],
{},
0,
42,
false,
[null, {}, { language: 'en', text: [] }, { language: 'hu', text: ' ' }],
]) {
assert.equal(
parseName({
name,
short_name: [
{ language: 'hu', text: 'Rövid' },
{ language: 'en', text: ' Short ' },
],
}),
'Short',
);
}
});
test('localized names can fall back to legacy short_name strings', () => {
assert.equal(parseName({ name: [], short_name: ' Short ' }), 'Short');
});
test('no usable name leaves the station available with an empty name', () => {
for (const value of [
undefined,
null,
'',
[],
{},
42,
true,
[null, { text: false }, { language: 'en', text: ' ' }],
]) {
assert.equal(parseName({ name: value, short_name: value }), '');
}
});
test('name shape is normalized even when a feed omits or misstates its version', () => {
const name = [{ language: 'en', text: 'Republic Square' }];
for (const version of [undefined, '2.3', '3.0', '3.1', 'unknown']) {
assert.equal(parseName({ name }, version), 'Republic Square');
assert.equal(parseName({ name: 'Legacy name' }, version), 'Legacy name');
}
});
test('bad names do not lose valid stations or their metadata', () => {
const information = parseStationInformation({
version: '3.0',
data: {
stations: [
station({ station_id: 'bad', name: [null, {}] }),
station({
station_id: 'short',
name: [],
short_name: [{ language: 'en', text: 'Short' }],
}),
station({
station_id: 'good',
name: [{ language: 'en', text: 'Republic Square' }],
}),
],
},
});
assert.equal(information.size, 3);
assert.equal(information.get('bad').name, '');
assert.equal(information.get('short').name, 'Short');
assert.deepEqual(information.get('good'), {
stationId: 'good',
name: 'Republic Square',
lat: 30.2681,
lon: -97.7469,
capacity: 13,
isInstalled: true,
isRenting: true,
isReturning: true,
});
});
test('normalizing a localized name leaves the feed document unchanged', () => {
const payload = {
version: '3.0',
data: {
stations: [
station({
name: [
{ language: 'hu', text: 'Magyar' },
{ language: 'en', text: ' English ' },
],
}),
],
},
};
const original = structuredClone(payload);
parseStationInformation(payload);
assert.deepEqual(payload, original);
});
+82
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import { inflateSync } from 'node:zlib';
import { createApplicationStreetLevel } from '../app/layers/streetLevel.js';
import {
fixtureTile,
@@ -24,10 +25,15 @@ import {
answerKartaViewRequest,
describeKartaViewCall,
KV_LINE,
KV_OLD_STREET,
KV_SEQUENCE_ID,
kartaViewPhotos,
resetKartaViewFixture,
} from '../../scripts/fixtures/street-level/kartaView.mjs';
import {
maskedSegments,
tileSegments,
} from '../layers/streetLevel/providers/kartaview/decode.js';
import { lonToTileX, latToTileY } from '../layers/streetLevel/tileMath.js';
import {
normalizePhoto,
@@ -852,3 +858,79 @@ test('Panoramax fixtures: two sequences on one street, searches honour sequence
'GET api.panoramax.xyz/api/map/{z}/{x}/{y}.mvt',
);
});
/** The alpha of a fixture PNG (8-bit RGBA, unfiltered rows, as it writes them). */
function fixturePngAlpha(bytes) {
const width = bytes.readUInt32BE(16);
const height = bytes.readUInt32BE(20);
const idat = [];
for (let at = 8; at < bytes.length;) {
const length = bytes.readUInt32BE(at);
const type = bytes.toString('ascii', at + 4, at + 8);
if (type === 'IDAT') idat.push(bytes.subarray(at + 8, at + 8 + length));
at += 12 + length;
}
const rows = inflateSync(Buffer.concat(idat));
const alpha = new Uint8Array(width * height);
for (let y = 0; y < height; y++)
for (let x = 0; x < width; x++)
alpha[y * width + x] = rows[y * (width * 4 + 1) + 1 + x * 4 + 3];
return { width, height, alpha };
}
test('KartaView fixtures filter like KartaView: PNG tiles by view and upload date, the photo search by projection', () => {
const body = (answer) => JSON.parse(answer.body);
const z = 14;
const x = lonToTileX(KV_LINE.lon, z);
const y = latToTileY((KV_LINE.south + KV_LINE.north) / 2, z);
assert.equal(x, lonToTileX(KV_OLD_STREET.lon, z), 'both streets in a tile');
const base = `https://api.kartaview.org/2.0/sequence/tiles/${x}/${y}/${z}`;
const segments = tileSegments(
body(answerKartaViewRequest({ method: 'GET', url: `${base}.json` })),
);
const lons = (kept) => [...new Set(kept.map((segment) => segment[0][0]))];
const keptFor = (query) => {
const answer = answerKartaViewRequest({
method: 'GET',
url: `${base}.png?${query}`,
});
assert.equal(answer.contentType, 'image/png');
assert.equal(answer.known, true);
return lons(
maskedSegments(segments, fixturePngAlpha(answer.body), { x, y, z }),
);
};
assert.deepEqual(
lons(segments).sort(),
[KV_LINE.lon, KV_OLD_STREET.lon].sort(),
);
assert.deepEqual(keptFor('fieldOfView=360'), [KV_LINE.lon]);
assert.deepEqual(keptFor('fieldOfView=plane').sort(), lons(segments).sort());
const lastYear = new Date(Date.now() - 365 * 86_400_000)
.toISOString()
.slice(0, 10);
assert.deepEqual(keptFor(`startDate=${lastYear}`), [KV_LINE.lon]);
assert.deepEqual(keptFor('startDate=2099-01-01'), []);
assert.equal(
describeKartaViewCall('GET', new URL(`${base}.png?fieldOfView=360`)),
'GET api.kartaview.org/2.0/sequence/tiles/{x}/{y}/{z}.png?fieldOfView',
);
// The photo search: 360° only, and nothing uploaded after the drive.
resetKartaViewFixture();
const search = (extra) => {
answerKartaViewRequest({
method: 'GET',
url: 'https://api.kartaview.org/2.0/photo/?lat=0&lng=0',
});
return body(
answerKartaViewRequest({
method: 'GET',
url: `https://api.kartaview.org/2.0/photo/?lat=${KV_LINE.south + 0.002}&lng=${KV_LINE.lon}&zoomLevel=18&itemsPerPage=10${extra}`,
}),
).result.data;
};
const panos = search('&projection=SPHERE');
assert.ok(panos.length > 0);
assert.ok(panos.every((record) => record.projection === 'SPHERE'));
assert.deepEqual(search('&startDate=2099-01-01'), []);
});
+28
View File
@@ -106,6 +106,34 @@ test('bike share joins station information with live status inside the area', as
assert.equal(result.data.rows[1].capacity, 10);
});
test('bike share returns localized names without losing stations with malformed names', async () => {
const informationUrl = 'https://gbfs.example/austin/station_information.json';
const information = structuredClone(documents[informationUrl]);
information.version = '3.0';
information.data.stations[0].name = [
{ language: 'hu', text: 'Magyar név' },
{ language: 'en', text: 'Congress & 2nd' },
];
information.data.stations[2].name = [null, { language: 42, text: [] }];
const source = bikeshare([system('austin', 'Austin, TX', 30.27, -97.74)]);
const catalog = composeCatalog({
tools: coreTools,
services: {
bikeshare: {
...source,
getStations: (url) =>
url === informationUrl ? information : source.getStations(url),
},
},
});
const result = await catalog.call('get_bike_share', { area: downtown });
assert.equal(result.data.total, 2);
assert.equal(result.data.rows[0].name, 'Congress & 2nd');
assert.equal(result.data.rows[0].bikes_available, 4);
assert.equal(result.data.rows[1].name, null);
assert.doesNotMatch(result.summary, /unavailable/);
});
test('bike share reports uncovered areas and failed systems honestly', async () => {
const none = composeCatalog({
tools: coreTools,
+4
View File
@@ -73,6 +73,7 @@ export class StreetLevelControls {
sinceRow: byId('sl-since-row'),
sinceRange: byId('sl-since'),
sinceLabel: byId('sl-since-label'),
sinceNote: byId('sl-since-note'),
legend: byId('sl-legend'),
followBtn: byId('sl-follow-btn'),
viewerWrap: byId('sl-viewer-wrap'),
@@ -527,6 +528,9 @@ export class StreetLevelControls {
const shows = view.filterControls || { pano: true, since: true };
setProp(el.panoRow, 'hidden', !shows.pano);
setProp(el.sinceRow, 'hidden', !shows.since);
// A lit source that dates by upload says so under the slider.
setProp(el.sinceNote, 'textContent', view.sinceNote || '');
setProp(el.sinceNote, 'hidden', !view.sinceNote);
setProp(el.controls, 'hidden', !shows.pano && !shows.since);
setProp(el.legend, 'hidden', view.legend.length === 0);
this._renderRadios(
+22
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import { StreetLevelControls } from './streetLevelControls.js';
import { createStreetLevelLayer } from '../layers/streetLevel/index.js';
@@ -313,6 +314,7 @@ function panelDom() {
const sinceRow = add(controls, 'div', { id: 'sl-since-row' });
add(sinceRow, 'input', { id: 'sl-since' });
add(sinceRow, 'output', { id: 'sl-since-label' });
add(sinceRow, 'small', { id: 'sl-since-note' });
add(settings, 'ul', { id: 'sl-legend' });
const metaRow = add(settings, 'div', { classes: ['sl-meta-row'] });
add(metaRow, 'div', { id: 'sl-coverage-meta' });
@@ -1224,6 +1226,26 @@ test('a swap names every registered provider, also those hidden on this map', ()
]);
}));
test('a note under SINCE says what date it compares, only while one is given', () =>
withDom(async (dom) => {
const state = uiState();
const { layer } = stubPanel(dom, state);
const note = () => dom.root.querySelector('#sl-since-note');
assert.equal(note().hidden, true, 'none for Mapillary');
layer.publish({ ...state, sinceNote: 'KartaView: by upload date' });
assert.equal(note().hidden, false);
assert.equal(note().textContent, 'KartaView: by upload date');
layer.publish(state);
assert.equal(note().hidden, true);
// The panel's own markup has it, read out with the slider.
const template = fs.readFileSync(
new URL('./templates/layer-panels.html', import.meta.url),
'utf8',
);
assert.match(template, /<small id="sl-since-note"[^>]* hidden>/);
assert.match(template, /aria-describedby="sl-since-label sl-since-note"/);
}));
test('Street View lit hides the imagery filters and the legend; Mapillary lit shows them', () =>
withDom(async (dom) => {
const state = uiState();
+1
View File
@@ -225,6 +225,7 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
since: state.filterControls?.since !== false,
},
since: presentSince(filter.sinceDays, now),
sinceNote: state.sinceNote || null,
legend: state.legend || [],
viewer: presentViewer(state),
meta: presentMeta(state),
+11
View File
@@ -302,6 +302,17 @@ button.sl-status:focus-visible {
outline-offset: 3px;
}
.sl-since-note {
grid-column: 2;
font-family: var(--font-mono);
font-size: var(--sl-text-small);
color: var(--text-secondary);
}
.sl-since-note[hidden] {
display: none;
}
.sl-since-label {
grid-column: 2;
font-family: var(--font-mono);
+2 -1
View File
@@ -128,8 +128,9 @@
</div>
<div id="sl-since-row" class="sl-since">
<label class="cctv-summary-label" for="sl-since">SINCE</label>
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label" />
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label sl-since-note" />
<output id="sl-since-label" class="sl-since-label" for="sl-since">ANY DATE</output>
<small id="sl-since-note" class="sl-since-note" hidden></small>
</div>
</fieldset>
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours"></ul>