mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
commit
da0d370fa3
43 files changed
+3299
-178
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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'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 & 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)',
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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)],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'/,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'), []);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user