Merge branch 'main' into contrib/gods-001-coordinate-empty-components-20261011-reviewed

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Sameh Khamis committed 2026-10-14 11:06:17 -07:00
commit db8e1ad7cb
93 files changed
+14946 -1408

No files matched your search

+24 -6
View File
@@ -1,21 +1,38 @@
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js';
import { createKartaViewProvider } from '../../layers/streetLevel/providers/kartaview/index.js';
import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js';
import * as input from '../../data/inputOwnership.js';
import * as scenePick from '../../data/scenePick.js';
import * as render from '../../renderGovernor.js';
/** The browser Maps key the scene was started with (src/app/scene.js). */
const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null;
/**
* Inject providers, or use Mapillary only when its source is supplied.
* An explicit provider list replaces the default; unused sources.mapillary is
* neither constructed nor validated, including when the list is empty.
* Mapillary, Google Street View and KartaView, once the Mapillary source is
* supplied; else none.
*/
function defaultProviders(sources) {
if (sources.mapillary == null) return [];
return [
createMapillaryProvider({ source: sources.mapillary }),
createGoogleProvider({ getApiKey: googleApiKey }),
createKartaViewProvider(),
];
}
/**
* Inject providers, or use the defaults. An explicit provider list replaces
* them; an unused sources.mapillary is neither constructed nor validated,
* including when the list is empty.
*/
export function createApplicationStreetLevel({
surface,
sources = {},
providers = sources.mapillary == null
? []
: [createMapillaryProvider({ source: sources.mapillary })],
providers = defaultProviders(sources),
}) {
return createStreetLevelLayer({
providers,
@@ -23,6 +40,7 @@ export function createApplicationStreetLevel({
sprites,
picking,
input,
scenePick,
render,
ground: surface?.groundFloor ?? null,
meshFloor: surface?.meshFloor ?? null,
+27
View File
@@ -27,3 +27,30 @@ test('explicit providers replace the default source and an empty list removes it
/array/,
);
});
test('the defaults are Mapillary (given its source), Street View and KartaView, which start off and travel as provider switches', async () => {
const { createDefaultLayerState } = await import('../../data/layerState.js');
const mapillary = {
getStatus() {},
getTile() {},
getSequenceImages() {},
nearestImages() {},
};
const layer = createApplicationStreetLevel({ sources: { mapillary } });
assert.deepEqual(layer.providerIds, ['mapillary', 'google', 'kartaview']);
// A fresh page and a restored default agree: Street View is billed, so off.
layer.setParams(createDefaultLayerState().options['street-level']);
assert.equal(layer.getParams().google, false);
assert.equal(layer.getParams().mapillary, true);
// A link that lit Street View carries it in the shared switch field.
layer.setParams({ providerSwitches: 'google-1' });
assert.equal(layer.getParams().google, true);
assert.equal(layer.getParams().providerSwitches, 'google-1*kartaview-0');
// KartaView is keyless and free, but one source shows at a time.
layer.setParams({ providerSwitches: 'kartaview-1' });
assert.equal(layer.getParams().kartaview, true);
assert.equal(layer.getParams().google, false);
assert.equal(layer.getParams().mapillary, false);
// Without the application's Street Level source there are none.
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
});
+391
View File
@@ -0,0 +1,391 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
loadNycSourcesFromOpenData,
nycCameraIsOnline,
nycCameraName,
nycCameraToSource,
} from '../../server/providers/cctv/sources.js';
import {
DEFAULT_CCTV_MAX_SOURCES,
DEFAULT_NYC_MAX_SOURCES,
NYC_CAMERAS_URL,
NYC_GROUND_ELEVATION_M,
NYC_IMAGE_ORIGIN,
NYC_IMAGE_URL,
NYC_MAX_CATALOG_BYTES,
} from '../../server/providers/cctv/constants.js';
import {
CAMERA_CODE_MAX_CHARS,
isLikelyNycCoordinate,
} from '../../server/providers/cctv/normalize.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
/**
* A response whose body is a live stream, plus a flag that flips when the
* stream is cancelled. A rejection path that returns without cancelling holds
* the transport open, so the flag is what the refusal tests actually assert.
*/
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('['));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
const MIDTOWN_ID = '8a6bc417-4877-4ebe-8052-88c1b261baf1';
const BROOKLYN_ID = '11111111-2222-3333-4444-555555555555';
/** One TMC camera row, shaped like the live `webcams.nyctmc.org` payload. */
const row = (overrides = {}) => ({
id: MIDTOWN_ID,
name: 'Central Park West @ 86 St',
latitude: 40.785302,
longitude: -73.969353,
area: 'Manhattan',
isOnline: 'true',
imageUrl: NYC_IMAGE_URL(MIDTOWN_ID),
...overrides,
});
test('a NYC row maps to a source on the pinned HTTPS frame host', () => {
const source = nycCameraToSource(row());
assert.equal(source.id, `nyc-${MIDTOWN_ID}`);
assert.equal(source.name, 'Central Park West @ 86 St');
assert.equal(source.city, 'Manhattan');
assert.equal(source.cityId, 'nyc');
assert.equal(source.provider, 'NYC DOT Traffic Management Center');
assert.equal(source.lat, 40.785302);
assert.equal(source.lon, -73.969353);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'nyc-dot-tmc');
assert.equal(
source.license,
'Public NYC DOT Traffic Management Center traffic camera',
);
assert.equal(source.groundElevationM, NYC_GROUND_ELEVATION_M);
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
assert.equal(source.url, source.snapshotUrl);
assert.ok(source.url.startsWith(NYC_IMAGE_ORIGIN));
assert.equal(source.headingConfidence, 'low');
assert.ok(Number.isFinite(source.headingDeg));
});
test('frame URLs are synthesized from the UUID; the payload URL is ignored', () => {
const source = nycCameraToSource(
row({ imageUrl: 'https://evil.example/cameras/x/image' }),
);
assert.equal(source.url, NYC_IMAGE_URL(MIDTOWN_ID));
assert.equal(source.snapshotUrl, source.url);
});
test('offline cameras, off-city geometry and unusable ids are dropped', () => {
assert.equal(nycCameraToSource(row({ isOnline: 'false' })), null);
assert.equal(nycCameraToSource(row({ isOnline: false })), null);
assert.equal(
nycCameraToSource(row({ isOnline: 'TRUE' })).id,
`nyc-${MIDTOWN_ID}`,
);
assert.equal(
nycCameraToSource(row({ isOnline: true })).id,
`nyc-${MIDTOWN_ID}`,
);
// Austin: a plausible lat/lon, but not a New York camera.
assert.equal(
nycCameraToSource(row({ latitude: 30.2672, longitude: -97.7431 })),
null,
);
assert.equal(nycCameraToSource(row({ latitude: 0, longitude: 0 })), null);
assert.equal(nycCameraToSource(row({ id: '../etc/passwd' })), null);
assert.equal(nycCameraToSource(row({ id: 'not-a-uuid' })), null);
assert.equal(nycCameraToSource(row({ id: `${MIDTOWN_ID}/../x` })), null);
assert.equal(nycCameraToSource(null), null);
});
test('ids lower-case the UUID and unknown boroughs fall back to New York', () => {
const mixed = nycCameraToSource(
row({ id: MIDTOWN_ID.toUpperCase(), area: 'Long Island' }),
);
assert.equal(mixed.id, `nyc-${MIDTOWN_ID}`);
assert.equal(mixed.city, 'New York');
assert.equal(
mixed.url,
NYC_IMAGE_URL(MIDTOWN_ID.toUpperCase()),
'the registered frame path keeps the catalog UUID spelling',
);
});
test('a nameless row still gets a label', () => {
assert.equal(
nycCameraName({ name: ' FDR Drive @ 23 St ' }, 'nyc-x'),
'FDR Drive @ 23 St',
);
assert.equal(nycCameraName({}, `nyc-${MIDTOWN_ID}`), 'NYC Camera 8a6bc417');
assert.equal(nycCameraIsOnline('true'), true);
assert.equal(nycCameraIsOnline('false'), false);
});
test('the unselected label is the intersection, trimmed to the code width', () => {
assert.equal(nycCameraToSource(row()).code, 'CENTRAL PARK WEST @ 86 ST');
const long = nycCameraToSource(
row({
name: 'Brooklyn Queens Expressway @ Atlantic Avenue / Flatbush Avenue Extension',
}),
);
assert.ok(long.code.length <= CAMERA_CODE_MAX_CHARS);
assert.ok(long.code.endsWith('…'));
assert.ok(long.code.startsWith('BROOKLYN'));
});
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
t.mock.method(console, 'log', () => {});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url, init) => {
requested.push([String(url), init.redirect]);
return Response.json([
row({ latitude: 40.758, longitude: -73.9855 }),
row({
name: 'Central Park West @ 86 St (duplicate)',
latitude: 40.758,
longitude: -73.9855,
}),
row({
id: BROOKLYN_ID,
name: 'Atlantic Ave @ Flatbush Ave',
latitude: 40.65,
longitude: -73.96,
area: 'Brooklyn',
imageUrl: NYC_IMAGE_URL(BROOKLYN_ID),
}),
row({ isOnline: 'false', name: 'Offline camera' }),
row({ id: 'https://evil.example/x' }),
]);
});
const cameras = await loadNycSourcesFromOpenData();
assert.deepEqual(requested, [[NYC_CAMERAS_URL, 'manual']]);
assert.deepEqual(
cameras.map((camera) => camera.id),
// Nearest to a borough anchor first: the Times Square camera before Brooklyn.
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
);
assert.equal(cameras[0].name, 'Central Park West @ 86 St');
assert.equal(cameras[1].city, 'Brooklyn');
});
test('an upstream failure yields an empty pack and releases the response', async (t) => {
t.mock.method(console, 'warn', () => {});
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
});
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
const seen = [];
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/cameras.json' },
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return redirected.response;
});
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
assert.deepEqual(seen, [[NYC_CAMERAS_URL, 'manual']]);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const body = JSON.stringify([row()]);
return new Response(body, {
headers: {
'Content-Type': 'application/json',
'content-length': String(NYC_MAX_CATALOG_BYTES + 1),
},
});
});
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const oversized = 'x'.repeat(NYC_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/json' } },
);
});
assert.deepEqual(await loadNycSourcesFromOpenData(), []);
});
test('a reduced pack cap keeps cameras nearest the borough anchors', async (t) => {
t.mock.method(console, 'log', () => {});
const saved = process.env.CCTV_NYC_MAX_SOURCES;
t.after(() => {
if (saved === undefined) delete process.env.CCTV_NYC_MAX_SOURCES;
else process.env.CCTV_NYC_MAX_SOURCES = saved;
});
process.env.CCTV_NYC_MAX_SOURCES = '8';
const rows = [
// Far southern Staten Island, listed first so only distance can keep them.
...[40.5, 40.505, 40.51, 40.515, 40.52, 40.525].map((lat, i) =>
row({
id: `aaaaaaaa-bbbb-cccc-dddd-${String(i).padStart(12, '0')}`,
latitude: lat,
longitude: -74.24,
area: 'Staten Island',
name: `SI ${i}`,
}),
),
...[40.758, 40.757, 40.756, 40.755, 40.754, 40.753].map((lat, i) =>
row({
id: `bbbbbbbb-cccc-dddd-eeee-${String(i).padStart(12, '0')}`,
latitude: lat,
longitude: -73.9855,
area: 'Manhattan',
name: `Midtown ${i}`,
}),
),
];
t.mock.method(globalThis, 'fetch', async () => Response.json(rows));
const cameras = await loadNycSourcesFromOpenData();
assert.equal(cameras.length, 8);
assert.deepEqual(
cameras.map((camera) => camera.city),
[
'Manhattan',
'Manhattan',
'Manhattan',
'Manhattan',
'Manhattan',
'Manhattan',
'Staten Island',
'Staten Island',
],
'Midtown fills first; the Staten Island anchor still keeps a share',
);
});
/**
* Serve the NYC catalog to the NYC endpoint and an empty payload to every
* other pack, so one catalog refresh exercises the registration without
* reaching the network.
*/
const runCatalogWithMockedUpstreams = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href === NYC_CAMERAS_URL) {
return Response.json([
row({ latitude: 40.758, longitude: -73.9855 }),
row({
id: BROOKLYN_ID,
name: 'Atlantic Ave @ Flatbush Ave',
latitude: 40.65,
longitude: -73.96,
area: 'Brooklyn',
}),
]);
}
return Response.json([]);
});
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
/** Run one catalog refresh with CCTV_NYC_ENABLED set to `flag` (undefined
* leaves it unset), restoring the environment afterwards. */
const runCatalogWithNycFlag = async (t, flag) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
if (flag === undefined) delete process.env.CCTV_NYC_ENABLED;
else process.env.CCTV_NYC_ENABLED = flag;
return await runCatalogWithMockedUpstreams(t);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
};
test('CCTV_NYC_ENABLED=1 wires the NYC lane into the catalog', async (t) => {
const { requested, sources } = await runCatalogWithNycFlag(t, '1');
assert.ok(
requested.includes(NYC_CAMERAS_URL),
'the catalog refresh invokes the NYC loader',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'nyc').map((s) => s.id),
[`nyc-${MIDTOWN_ID}`, `nyc-${BROOKLYN_ID}`],
'NYC cameras reach the served catalog through the registered lane',
);
});
// NYC DOT requires a data-sharing agreement for this feed, so an install that
// has not opted in must never contact it.
for (const [label, flag] of [
['an unset CCTV_NYC_ENABLED', undefined],
['an empty CCTV_NYC_ENABLED', ''],
['CCTV_NYC_ENABLED=0', '0'],
['CCTV_NYC_ENABLED=true', 'true'],
]) {
test(`${label} makes no NYC request`, async (t) => {
const { requested, sources } = await runCatalogWithNycFlag(t, flag);
assert.ok(requested.length > 0, 'the other packs still refreshed');
assert.equal(
requested.some((href) => href.includes('nyctmc.org')),
false,
'the lane never reaches its upstream',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'nyc'),
[],
);
});
}
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
assert.equal(DEFAULT_NYC_MAX_SOURCES, 250);
});
test('isLikelyNycCoordinate spans the five boroughs and rejects the rest', () => {
assert.equal(isLikelyNycCoordinate(40.758, -73.9855), true); // Times Square
assert.equal(isLikelyNycCoordinate(40.5795, -74.1502), true); // Staten Island
assert.equal(isLikelyNycCoordinate(40.8448, -73.8648), true); // Bronx
assert.equal(isLikelyNycCoordinate(40.5077, -74.2304), true); // southern SI slack
assert.equal(isLikelyNycCoordinate(30.2672, -97.7431), false); // Austin
assert.equal(isLikelyNycCoordinate(0, 0), false);
assert.equal(isLikelyNycCoordinate(NaN, -73.98), false);
});
+14
View File
@@ -91,6 +91,13 @@ export function hideOsmCredit(viewer, owner) {
return true;
}
/** NYC DOT asks every application that uses its data to carry this
* disclaimer verbatim (nyc.gov/html/dot/html/about/datafeeds.shtml). */
export const NYC_DOT_DISCLAIMER =
'This application may contain and/or utilize information which was originally compiled by the New York City Department of Transportation (NYC DOT) for governmental purposes; the information may subsequently been modified by entity/entities other than NYC DOT. ' +
'NYC DOT and the City of New York make no representation as to the accuracy or usefulness of the information provided by this application or the information&#39;s suitability for any purpose and disclaim any liability for omissions or errors that may be contained therein. ' +
'The public is advised to observe posted signage for compliance with applicable laws and regulations.';
export const DATA_CREDITS = [
// ── Live sources ────────────────────────────────────────────────
{
@@ -247,6 +254,13 @@ export const DATA_CREDITS = [
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
'distributed by Statens vegvesen',
},
{
key: 'nyc-cctv',
html:
'CCTV cameras &amp; frames (New York City, when enabled): ' +
'<a href="https://webcams.nyctmc.org/" target="_blank" rel="noopener">NYC DOT Traffic Management Center</a>. ' +
NYC_DOT_DISCLAIMER,
},
{
key: 'gbfs',
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
+9 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DATA_CREDITS } from './dataCredits.js';
import { DATA_CREDITS, NYC_DOT_DISCLAIMER } from './dataCredits.js';
test('every credit carries a unique key and some markup to render', () => {
const keys = DATA_CREDITS.map((entry) => entry.key);
@@ -120,3 +120,11 @@ test('tile attribution follows source changes and viewers have independent owner
assert.equal(a.credits.size, 0);
assert.equal(b.credits.size, 1);
});
test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
const credit = DATA_CREDITS.find((entry) => entry.key === 'nyc-cctv');
assert.ok(credit, 'NYC DOT camera frames must be credited');
assert.ok(credit.html.includes(NYC_DOT_DISCLAIMER));
assert.match(credit.html, /make no representation as to the accuracy/);
assert.match(credit.html, /observe posted signage/);
});
+7 -1
View File
@@ -2703,13 +2703,19 @@ test('Street Level: the link codec accepts exactly the windows the filter keeps'
assert.equal(streetLevelOptions('0.s.3652').sinceDays, 3652);
});
test('Street Level: a link without the provider switch keeps Mapillary on', () => {
test('Street Level: a link without the provider switches keeps Mapillary on and Street View off', () => {
assert.deepEqual(streetLevelOptions(''), {
mapillary: true,
providerSwitches: '',
pano: 'all',
sinceDays: 0,
});
// Street View rides the shared provider-switch field, off unless lit.
assert.equal(streetLevelOptions('0.r.google-1').providerSwitches, 'google-1');
assert.equal(
createDefaultLayerState().options['street-level'].providerSwitches,
'',
);
assert.equal(streetLevelOptions('0.p.f').mapillary, true);
assert.equal(streetLevelOptions('0.m.0').mapillary, false);
assert.equal(
+49
View File
@@ -47,3 +47,52 @@ export function isPickedWorldPosition(position) {
const magnitude = Math.hypot(x, y, z);
return magnitude >= MIN_PICK_MAGNITUDE_M && magnitude <= MAX_PICK_MAGNITUDE_M;
}
/**
* The world position under a screen point: the rendered surface where the
* depth buffer has it, else the ellipsoid; null over the sky.
*
* @param {object} viewer A Cesium viewer.
* @param {{x: number, y: number}} screenPosition
* @returns {{x: number, y: number, z: number}|null} A Cartesian3.
*/
export function pickGroundPosition(viewer, screenPosition) {
const scene = viewer?.scene;
let position = null;
if (
scene?.pickPositionSupported &&
typeof scene.pickPosition === 'function'
) {
try {
position = scene.pickPosition(screenPosition);
} catch {
position = null;
}
}
if (
!isPickedWorldPosition(position) &&
typeof viewer?.camera?.pickEllipsoid === 'function'
) {
try {
position = viewer.camera.pickEllipsoid(screenPosition);
} catch {
position = null;
}
}
return isPickedWorldPosition(position) ? position : null;
}
/**
* Whether a `scene.pick()` result is the map itself: nothing, or 3D tile
* content (which has no id).
*
* @param {object|null|undefined} picked
* @returns {boolean}
*/
export function isSurfacePick(picked) {
if (!picked) return true;
if (picked.id !== undefined) return false;
return (
picked.content !== undefined || picked.primitive?.isCesium3DTileset === true
);
}
+68 -13
View File
@@ -3,9 +3,8 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { isPickedWorldPosition } from './scenePick.js';
const describe = (position) => (position
? `(${position.x}, ${position.y}, ${position.z})`
: String(position));
const describe = (position) =>
position ? `(${position.x}, ${position.y}, ${position.z})` : String(position);
test('a degenerate depth pick is rejected before it reaches Cesium', () => {
// These are the shapes `scene.pickPosition()` can produce when the depth read
@@ -21,7 +20,11 @@ test('a degenerate depth pick is rejected before it reaches Cesium', () => {
new Cesium.Cartesian3(0.2, -0.3, 0.1),
];
for (const position of degenerate) {
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
assert.equal(
isPickedWorldPosition(position),
false,
`must reject ${describe(position)}`,
);
}
});
@@ -38,11 +41,18 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
new Cesium.Cartesian3(0, 0, 5_999_999),
];
for (const position of belowTheFloor) {
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
assert.equal(
isPickedWorldPosition(position),
false,
`must reject ${describe(position)}`,
);
// Each one converts WITHOUT throwing — that is exactly what makes it dangerous.
const carto = Cesium.Cartographic.fromCartesian(position);
assert.ok(carto, `Cesium converts ${describe(position)} without complaint`);
assert.ok(carto.height < -300_000, 'and puts the "target" hundreds of km underground');
assert.ok(
carto.height < -300_000,
'and puts the "target" hundreds of km underground',
);
}
const aboveTheCeiling = [
@@ -52,7 +62,11 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
new Cesium.Cartesian3(1e155, 0, 0),
];
for (const position of aboveTheCeiling) {
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
assert.equal(
isPickedWorldPosition(position),
false,
`must reject ${describe(position)}`,
);
}
assert.throws(
() => Cesium.Cartographic.fromCartesian(new Cesium.Cartesian3(1e155, 0, 0)),
@@ -61,8 +75,14 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
);
// Both edges of the band are inclusive, and both are real magnitudes.
assert.equal(isPickedWorldPosition(new Cesium.Cartesian3(6_000_000, 0, 0)), true);
assert.equal(isPickedWorldPosition(new Cesium.Cartesian3(1_000_000_000, 0, 0)), true);
assert.equal(
isPickedWorldPosition(new Cesium.Cartesian3(6_000_000, 0, 0)),
true,
);
assert.equal(
isPickedWorldPosition(new Cesium.Cartesian3(1_000_000_000, 0, 0)),
true,
);
});
test('a real picked position is accepted', () => {
@@ -81,13 +101,22 @@ test('a real picked position is accepted', () => {
Cesium.Cartesian3.fromDegrees(0, 0, 35_786_000),
];
for (const position of real) {
assert.equal(isPickedWorldPosition(position), true, `must accept ${describe(position)}`);
assert.equal(
isPickedWorldPosition(position),
true,
`must accept ${describe(position)}`,
);
}
// The floor has real margin under the smallest legitimate magnitude, so no
// plausible pick sits anywhere near the boundary.
const polarSurface = Cesium.Cartesian3.magnitude(Cesium.Cartesian3.fromDegrees(0, 90, -10_935));
assert.ok(polarSurface > 6_300_000, `smallest real magnitude was ${polarSurface}`);
const polarSurface = Cesium.Cartesian3.magnitude(
Cesium.Cartesian3.fromDegrees(0, 90, -10_935),
);
assert.ok(
polarSurface > 6_300_000,
`smallest real magnitude was ${polarSurface}`,
);
});
test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
@@ -118,7 +147,10 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
// Earth's core. That is the quietest failure of the three and the reason
// the guard needs a magnitude band, not just a finiteness check.
const nearCenter = new Cesium.Cartesian3(0.2, -0.3, 0.1);
assert.ok(Cesium.Cartographic.fromCartesian(nearCenter), 'Cesium converts it without complaint');
assert.ok(
Cesium.Cartographic.fromCartesian(nearCenter),
'Cesium converts it without complaint',
);
assert.equal(isPickedWorldPosition(nearCenter), false);
// …and every position the guard accepts must convert to the right place.
@@ -129,3 +161,26 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.longitude) + 97.7431) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.latitude) - 30.2672) < 1e-6);
});
test('the ground under a click: the rendered surface, else the ellipsoid, never the sky', async () => {
const { pickGroundPosition } = await import('./scenePick.js');
const ground = { x: 6378137, y: 0, z: 0 };
const sky = { x: 500, y: 0, z: 0 };
const viewer = (picked, ellipsoid) => ({
scene: { pickPositionSupported: true, pickPosition: () => picked },
camera: { pickEllipsoid: () => ellipsoid },
});
assert.equal(pickGroundPosition(viewer(ground, null), {}), ground);
assert.equal(pickGroundPosition(viewer(sky, ground), {}), ground);
assert.equal(pickGroundPosition(viewer(undefined, undefined), {}), null);
assert.equal(pickGroundPosition(null, {}), null);
});
test('a surface pick is nothing, or Google 3D tile content without an id', async () => {
const { isSurfacePick } = await import('./scenePick.js');
assert.equal(isSurfacePick(undefined), true);
assert.equal(isSurfacePick({ content: {} }), true);
assert.equal(isSurfacePick({ primitive: { isCesium3DTileset: true } }), true);
assert.equal(isSurfacePick({ id: 'flight:1', content: {} }), false);
assert.equal(isSurfacePick({ primitive: {} }), false, 'an unnamed primitive');
});
+23 -1
View File
@@ -36,7 +36,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({
id: 'google-maps',
title: 'GOOGLE MAPS',
unlocks: 'The photorealistic 3D planet + place search',
unlocks: 'The photorealistic 3D planet, place search + Street View',
getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key',
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
tier: 'metered',
@@ -352,6 +352,28 @@ export function keySetupRequirement(id) {
return `Needs ${entry.envVars.join(' + ')} — add it in Provider Settings`;
}
/**
* Key guidance that also names switched-off sources whose key is already set.
* @param {string} requirement What `keySetupRequirement` said ('' allowed).
* @param {Array<{label: string, requiresKeyId?: string|null}>} [alternatives]
*/
export function keySetupWithAlternatives(requirement, alternatives = []) {
const options = alternatives.map(({ label, requiresKeyId }) => {
const entry = KEY_SETUP_KEYS.find(
(candidate) => candidate.id === requiresKeyId,
);
const name = entry
? entry.title
.toLowerCase()
.replace(/\b\w/g, (letter) => letter.toUpperCase())
: null;
return name ? `light ${label} to use your ${name} key` : `light ${label}`;
});
if (!options.length) return requirement;
const offer = `or ${options.join(', or ')}`;
return requirement ? `${requirement}, ${offer}` : offer.replace(/^or l/, 'L');
}
/**
* Decide whether a live provider value belongs to a source outside the store
* Provider Settings is allowed to edit. `wasExternalAtBoot` carries source
+14
View File
@@ -9,6 +9,7 @@ import {
isKeySetupExternallyManaged,
keySetupStatus,
keySetupRequirement,
keySetupWithAlternatives,
knownKeySetupEnvVars,
parseWindowsUserSid,
upsertDotenvValues,
@@ -362,3 +363,16 @@ test('server Google key remains supported without appearing in setup or its miss
assert.ok(!JSON.stringify(status).includes('GOOGLE_MAPS_SERVER_API_KEY'));
assert.ok(!JSON.stringify(status).includes(secret));
});
test('key guidance can offer another source that works with a key already set', () => {
const streetView = { label: 'STREET VIEW', requiresKeyId: 'google-maps' };
assert.equal(
keySetupWithAlternatives(keySetupRequirement('mapillary'), [streetView]),
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key',
);
assert.equal(
keySetupWithAlternatives('', [streetView]),
'Light STREET VIEW to use your Google Maps key',
);
assert.equal(keySetupWithAlternatives('Needs X', []), 'Needs X');
});
+5 -26
View File
@@ -617,32 +617,11 @@ export function createDirectionsLayer({ services, source }) {
}
function pickGround(screenPosition) {
const scene = _viewer?.scene;
let cartesian = null;
if (
scene?.pickPositionSupported &&
typeof scene.pickPosition === 'function'
) {
try {
cartesian = scene.pickPosition(screenPosition);
} catch {
cartesian = null;
}
}
if (
!services.scenePick.isPickedWorldPosition(cartesian) &&
typeof _viewer?.camera?.pickEllipsoid === 'function'
) {
try {
cartesian = _viewer.camera.pickEllipsoid(
screenPosition,
Cesium.Ellipsoid.WGS84,
);
} catch {
cartesian = null;
}
}
if (!services.scenePick.isPickedWorldPosition(cartesian)) return null;
const cartesian = services.scenePick.pickGroundPosition(
_viewer,
screenPosition,
);
if (!cartesian) return null;
const carto = Cesium.Cartographic.fromCartesian(cartesian);
if (!carto) return null;
return {
+9
View File
@@ -65,6 +65,15 @@ export function imageConeGlyph({
});
}
/** Street View-style marker: a field-of-view cone from a white-ringed dot. */
export function viewConeMarkerGlyph({ size = 56, color = '#4285f4' } = {}) {
return cachedGlyph(`view-cone:${size}:${color}`, size, (ctx, c) => {
wedge(ctx, c, size * 0.48, 0.62, color, 0.38);
wedge(ctx, c, size * 0.3, 0.62, color, 0.3);
dot(ctx, c, size * 0.13, color, '#ffffff', Math.max(2, size * 0.05));
});
}
/** Marker for the image the viewer currently shows. */
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => {
+56
View File
@@ -0,0 +1,56 @@
import * as Cesium from 'cesium';
import { GROUND_CLICK_MAX_HEIGHT_M } from './policy.js';
import { cameraHeightAboveGround } from './view.js';
/** Whether the camera is low enough for a click on the ground to mean a street. */
export function groundClickHeightOk(viewer, groundAt) {
if (!viewer?.camera?.positionCartographic) return false;
return (
cameraHeightAboveGround(viewer, { groundAt }) <= GROUND_CLICK_MAX_HEIGHT_M
);
}
/**
* A click on the map itself at street zoom opens the nearest image of the
* active `groundClick` providers, without moving the camera. The height is
* measured at the click, not read from the cached flag.
* @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean, groundAt?: Function}} options
* @returns {(screenPosition: {x: number, y: number}, picked?: object) => Promise<boolean>|false}
*/
export function createGroundClick({
state,
openNearest,
isAvailable,
groundAt = null,
}) {
return function openAtGround(screenPosition, picked) {
const scenePick = state.services.scenePick;
if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition)
return false;
if (
!scenePick.isSurfacePick(picked) ||
!groundClickHeightOk(state.viewer, groundAt)
)
return false;
const providerIds = [...state.providers.values()]
.filter(
(entry) =>
entry.on &&
entry.def.groundClick &&
entry.status?.configured &&
isAvailable(entry),
)
.map((entry) => entry.def.id);
if (!providerIds.length) return false;
const position = scenePick.pickGroundPosition(state.viewer, screenPosition);
const carto = position && Cesium.Cartographic.fromCartesian(position);
if (!carto) return false;
return openNearest(
{
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
},
{ providerIds, frame: false },
);
};
}
+133
View File
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import * as scenePick from '../../data/scenePick.js';
const GROUND = Cesium.Cartesian3.fromDegrees(-121.4944, 38.5816, 10);
const TILESET = { isCesium3DTileset: true };
/**
* A layer state with providers `{id, on, groundClick, configured, available}`;
* `picked` is where the depth buffer lands, `ellipsoid` the fallback hit.
*/
function harness({
providers = [
{ id: 'mapillary', groundClick: false },
{ id: 'google', groundClick: true },
],
height = 400,
enabled = true,
picked = GROUND,
ellipsoid = undefined,
} = {}) {
const opened = [];
const state = {
enabled,
services: { scenePick },
providers: new Map(
providers.map(
({
id,
on = true,
groundClick,
configured = true,
available = true,
}) => [
id,
{ on, def: { id, groundClick }, status: { configured }, available },
],
),
),
viewer: {
scene: { pickPositionSupported: true, pickPosition: () => picked },
camera: {
pickEllipsoid: () => ellipsoid,
positionCartographic: Cesium.Cartographic.fromDegrees(
-121.4944,
38.5816,
height,
),
},
},
};
const openAtGround = createGroundClick({
state,
openNearest: (point, options) => {
opened.push({ point, options });
return Promise.resolve(true);
},
isAvailable: (entry) => entry.available,
});
return { opened, openAtGround };
}
const near = (point) =>
Math.abs(point.lon - -121.4944) < 1e-9 &&
Math.abs(point.lat - 38.5816) < 1e-9;
test('a click on the map opens the click-to-open providers there, without moving the camera', () => {
for (const picked of [undefined, { primitive: TILESET }, { content: {} }]) {
const { opened, openAtGround } = harness();
openAtGround({ x: 10, y: 20 }, picked);
assert.equal(opened.length, 1, JSON.stringify(picked));
assert.ok(near(opened[0].point));
assert.deepEqual(opened[0].options, {
providerIds: ['google'],
frame: false,
});
}
});
test('where the depth buffer names no place, the ellipsoid answers', () => {
const { opened, openAtGround } = harness({
picked: new Cesium.Cartesian3(500, 0, 0),
ellipsoid: GROUND,
});
openAtGround({ x: 1, y: 1 });
assert.ok(near(opened[0].point));
});
test('nothing opens over another layer’s object, the sky, from high up, off its map stack, or with the provider off', () => {
const cases = [
{ picked: { id: { id: 'flight:1' } }, label: 'an entity' },
{ picked: { id: 'cctv:7', primitive: TILESET }, label: 'an id on a tile' },
{ options: { picked: null, ellipsoid: undefined }, label: 'the sky' },
{ options: { height: 5000 }, label: 'high up' },
{
options: {
providers: [{ id: 'google', groundClick: true, available: false }],
},
label: 'not Google 3D',
},
{
options: { providers: [{ id: 'google', groundClick: true, on: false }] },
label: 'off',
},
{
options: {
providers: [{ id: 'google', groundClick: true, configured: false }],
},
label: 'no key',
},
{ options: { enabled: false }, label: 'layer off' },
];
for (const { picked, options = {}, label } of cases) {
const { opened, openAtGround } = harness(options);
assert.equal(openAtGround({ x: 1, y: 1 }, picked), false, label);
assert.equal(opened.length, 0, label);
}
});
test('street zoom is measured above the ground under the camera', () => {
const viewer = (height) => ({
camera: {
positionCartographic: Cesium.Cartographic.fromDegrees(0, 0, height),
},
scene: { globe: { getHeight: () => 1500 } },
});
// 1,500 m up a plateau, 2,000 m above it: still a street.
assert.equal(groundClickHeightOk(viewer(3500)), true);
assert.equal(groundClickHeightOk(viewer(5000)), false);
assert.equal(groundClickHeightOk(null), false);
});
+246 -32
View File
@@ -1,3 +1,4 @@
import * as Cesium from 'cesium';
import { createState } from './state.js';
import { createMarker } from './marker.js';
import { createCameraFollow } from './cameraFollow.js';
@@ -5,17 +6,23 @@ import { createCredits } from './credits.js';
import { createPickRouter } from './pickRouter.js';
import { createSelection } from './selection.js';
import { createViewerHost } from './viewerHost.js';
import { requiresKeyIdFor, validateProviders } from './registry.js';
import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js';
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
import { decodeParams, encodeParams } from './params.js';
import { composeUIState, summarizeCoverage } from './uiState.js';
import {
composeUIState,
keyAlternatives,
summarizeCoverage,
} from './uiState.js';
import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
import { createMeshSampler } from './meshSampler.js';
/** Above this camera height the ground under the camera is not worth fetching. */
const SURFACE_WARM_BELOW_M = 6000;
import {
AIM_AT_GROUND_MESSAGE,
FOLLOW_MAP_STACK_ID,
NEAREST_RADIUS_M,
POSITION_PICK_ID,
@@ -66,6 +73,12 @@ export function createStreetLevelLayer({
entry.instance.clearSequence?.();
};
parts.selection = createSelection(context);
parts.openAtGround = createGroundClick({
state,
openNearest: (point, options) => layer.openNearest(point, options),
isAvailable: (entry) => providerAvailable(entry),
groundAt: parts.groundCaster?.groundAt ?? null,
});
let notifyQueued = false;
function notify() {
@@ -95,7 +108,15 @@ export function createStreetLevelLayer({
getSurface: () => state.surface,
notify,
actions: {
openImage: (imageId) => openImage(entry.def.id, imageId),
/** `{frame: false}` opens it without moving the camera. */
openImage: (imageId, options) =>
openImage(entry.def.id, imageId, options),
/** This provider's image nearest a point the user picked on the map. */
openNearest: (point) =>
layer.openNearest(point, {
providerIds: [entry.def.id],
frame: false,
}),
/**
* Show a provider error, or withdraw it (null). A withdrawal clears
* only this provider's own error, never a newer one from elsewhere.
@@ -115,11 +136,13 @@ export function createStreetLevelLayer({
});
}
// One source at a time: the first provider that defaults on.
const startsOn = definitionsFrozen.find((def) => def.defaultOn !== false);
for (const def of definitionsFrozen) {
const entry = {
def,
instance: null,
on: true,
on: def === startsOn,
status: null,
/** The error this provider last reported, until it withdraws it. */
reportedError: null,
@@ -140,6 +163,39 @@ export function createStreetLevelLayer({
notify();
}
/** Usable only on its map stack (Google's terms: Street View on Google 3D only). */
function providerAvailable(entry) {
const required = entry.def.requiresMapStack;
return !required || mapStack?.getActiveId?.() === required;
}
/** Leaving a provider's map stack closes its image, viewer and credit. */
function syncProviderAvailability() {
const owner = state.providers.get(state.street.providerId);
if (state.street.open && owner && !providerAvailable(owner))
layer.closeViewer();
for (const entry of state.providers.values()) {
if (!entry.def.requiresMapStack) continue;
if (state.enabled && entry.on && providerAvailable(entry))
parts.credits.show(state.viewer, entry.def);
else {
parts.credits.hide(state.viewer, entry.def);
parts.viewerHost.unmount(entry.def.id);
}
}
notify();
}
/** Whether a ground click can open an image: the camera is at street zoom. */
function syncGroundClickReady() {
const ready =
state.enabled &&
groundClickHeightOk(state.viewer, parts.groundCaster?.groundAt);
if (ready === state.groundClickReady) return;
state.groundClickReady = ready;
notify();
}
/**
* Camera height above bare earth, for the surface mode. A cold grid cell is
* fetched once and the mode rechecked when it lands.
@@ -162,6 +218,7 @@ export function createStreetLevelLayer({
/** Cast overlays to bare earth on Google 3D at street zoom; drape them elsewhere. */
function syncSurface() {
syncGroundClickReady();
const available = Boolean(parts.groundCaster) && state.enabled;
const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
const mode = nextSurfaceMode(state.surface, {
@@ -222,9 +279,13 @@ export function createStreetLevelLayer({
function activate(entry) {
if (!state.viewer) return;
entry.instance.activate(state.viewer);
parts.credits.show(state.viewer, entry.def);
if (!entry.status) refreshStatus(entry);
whenIdle(() => parts.viewerHost.prewarm([entry]), 1500);
if (!providerAvailable(entry)) return;
parts.credits.show(state.viewer, entry.def);
// The map may have changed by then.
whenIdle(() => {
if (providerAvailable(entry)) parts.viewerHost.prewarm([entry]);
}, 1500);
}
function deactivate(entry) {
@@ -241,25 +302,101 @@ export function createStreetLevelLayer({
nearestLookup = null;
}
function openImage(providerId, imageId) {
abortNearest();
return parts.viewerHost.open(providerId, imageId);
/** Ground under the screen centre; null for sky, undefined when it cannot aim. */
function screenCentreGround() {
const canvas = state.viewer?.scene?.canvas;
const pick = state.services.scenePick?.pickGroundPosition;
if (!pick || !canvas?.clientWidth || !canvas?.clientHeight)
return undefined;
const position = pick(state.viewer, {
x: canvas.clientWidth / 2,
y: canvas.clientHeight / 2,
});
const carto = position && Cesium.Cartographic.fromCartesian(position);
return carto
? {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
}
: null;
}
/** `frame: false` keeps the camera where it is (the user picked the spot). */
function openImage(providerId, imageId, { frame = true } = {}) {
abortNearest();
const entry = state.providers.get(providerId);
if (entry && !providerAvailable(entry)) {
state.street.loading = false;
state.street.error = unavailableReason(entry);
notify();
return Promise.resolve(false);
}
return parts.viewerHost.open(providerId, imageId, { frame });
}
function unavailableReason(entry) {
return `${entry.def.name} needs the Google 3D map: choose it under MAP SOURCE`;
}
/** One imagery source at a time: lighting a provider darkens the others. */
function setProviderEnabled(providerId, on) {
const entry = state.providers.get(providerId);
if (!entry) return false;
const before = litIds();
const next = on !== false;
if (entry.on === next) return true;
entry.on = next;
if (state.enabled) {
if (next) activate(entry);
else deactivate(entry);
}
if (next)
for (const other of state.providers.values())
if (other !== entry && other.on) switchProvider(other, false);
switchProvider(entry, next);
sourceChanged(before);
notify();
return true;
}
const litIds = () =>
[...state.providers]
.filter(([, entry]) => entry.on)
.map(([id]) => id)
.join();
/**
* After a chip, voice or a link changed the lit source: a lookup still
* running and its spinner or error were about the source switched away
* (an open photo keeps its own state).
*/
function sourceChanged(before) {
if (litIds() === before) return;
abortNearest();
if (state.street.open) return;
state.street.error = null;
state.street.loading = false;
}
function switchProvider(entry, on) {
if (entry.on === on) return;
entry.on = on;
if (!state.enabled) return;
if (on) activate(entry);
else deactivate(entry);
}
/**
* The one provider params leave lit: of those they switch on, the one
* already on (reapplying never flips), else the first; with none, the lit
* one unless they switch it off.
*/
function litByParams(switches) {
const asked = [...state.providers.keys()].filter(
(id) => switches.get(id) === true,
);
const current = [...state.providers]
.filter(([, entry]) => entry.on)
.map(([id]) => id);
if (asked.length)
return asked.find((id) => current.includes(id)) ?? asked[0];
return current.find((id) => switches.get(id) !== false) ?? null;
}
function setCoverageFilter(next) {
const filter = normalizeFilter(next, state.filter);
if (sameFilter(filter, state.filter)) return;
@@ -270,8 +407,19 @@ export function createStreetLevelLayer({
notify();
}
/** The hint a ground-click provider shows, or the provider's own. */
function hintFor(entry, stats) {
if (entry.def.groundClick && stats.hint && !state.groundClickReady)
return `zoom in to a street to open ${entry.def.name}`;
return stats.hint || '';
}
/** Providers usable on this map stack; the others are not shown at all. */
const listedEntries = () =>
[...state.providers.values()].filter((entry) => providerAvailable(entry));
function providerSnapshots() {
return [...state.providers.values()].map((entry) => {
return listedEntries().map((entry) => {
const stats = entry.instance.coverageStats();
return {
id: entry.def.id,
@@ -285,9 +433,14 @@ export function createStreetLevelLayer({
requiresKeyId: entry.def.requiresKeyId || null,
loading: stats.loading === true,
count: stats.count || 0,
hint: stats.hint || '',
hint: hintFor(entry, stats),
error: stats.error || null,
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)],
};
});
}
@@ -313,24 +466,43 @@ export function createStreetLevelLayer({
function getUIState() {
const { host, ...street } = state.street;
return composeUIState({
const ui = composeUIState({
enabled: state.enabled,
groundClickReady: state.groundClickReady,
filter: state.filter,
providers: providerSnapshots(),
street,
sequence: sequenceSnapshot(),
surface: state.surface,
});
// The lit source cannot run on this map: say so, not a blank panel.
const stranded = [...state.providers.values()].find(
(entry) => entry.on && !providerAvailable(entry),
);
if (stranded && !ui.providers.some((p) => p.on))
ui.coverage.hint = unavailableReason(stranded);
return ui;
}
const layer = {
id: STREET_LEVEL_LAYER_ID,
name: 'Street Level',
icon: '📷',
source: definitionsFrozen.map((def) => def.name).join(' · '),
/** The sources usable on this map (Street View only on Google 3D). */
get source() {
return listedEntries()
.map((entry) => entry.def.name)
.join(' · ');
},
updateInterval: 0,
statsRefreshInterval: 1000,
requiresKeyId: requiresKeyIdFor(definitionsFrozen),
/** The key the switched-on providers share (or null), for the layer row. */
get requiresKeyId() {
const on = [...state.providers.values()].filter((entry) => entry.on);
return requiresKeyIdFor(
on.length ? on.map((entry) => entry.def) : definitionsFrozen,
);
},
/** Registered providers, in chip order. */
providerIds: definitionsFrozen.map((def) => def.id),
getSourceAvailability() {
@@ -404,16 +576,19 @@ export function createStreetLevelLayer({
getStats() {
// The lifecycle polls this every second: summarise, don't snapshot.
const coverage = summarizeCoverage(providerSnapshots());
const snapshots = providerSnapshots();
const coverage = summarizeCoverage(snapshots);
let loadingLabel = '';
const keyLabel = coverage.keyRejected ? 'KEY REJECTED' : 'KEY REQUIRED';
if (coverage.keyRequired) loadingLabel = keyLabel;
else if (coverage.loading) loadingLabel = 'loading coverage...';
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
return {
source: layer.source,
count: coverage.count,
loading: coverage.loading,
keyRequired: coverage.keyRequired,
keyAlternatives: keyAlternatives(snapshots),
error: coverage.keyRequired
? coverage.keyRejected
? coverage.error || keyLabel
@@ -441,9 +616,11 @@ export function createStreetLevelLayer({
unsubscribeMapStack =
mapStack?.subscribe?.(() => {
syncFollowAvailability();
syncProviderAvailability();
syncSurface();
}) || null;
syncFollowAvailability();
syncProviderAvailability();
syncSurface();
},
@@ -460,7 +637,13 @@ export function createStreetLevelLayer({
providerIds: state.providers.keys(),
filter: state.filter,
});
for (const [id, on] of decoded.providers) setProviderEnabled(id, on);
const lit = litByParams(decoded.providers);
const before = litIds();
for (const [id, entry] of state.providers)
if (id !== lit) switchProvider(entry, false);
if (lit) switchProvider(state.providers.get(lit), true);
sourceChanged(before);
notify();
setCoverageFilter(decoded.filter);
return true;
},
@@ -476,15 +659,39 @@ export function createStreetLevelLayer({
attachViewerHost(element) {
parts.viewerHost.attach(element);
if (element && state.enabled)
whenIdle(() => parts.viewerHost.prewarm(activeEntries()), 1500);
whenIdle(
() =>
parts.viewerHost.prewarm(
activeEntries().filter((entry) => providerAvailable(entry)),
),
1500,
);
},
setProviderEnabled,
/** Imagery filter for coverage, cones and nearest-image lookups. */
setCoverageFilter,
openImage,
/** Open the nearest image any active provider has around a point. */
async openNearest(point) {
const view = point || viewCentre(state.viewer);
/**
* Open the nearest image of the active providers (or `providerIds`) near
* a point, else the view centre, or with `aim: 'screen-centre'` the ground
* under the screen centre (sky: nothing).
*/
async openNearest(
point,
{ providerIds = null, frame = true, aim = 'view' } = {},
) {
let view = point;
if (!view && aim === 'screen-centre') {
view = screenCentreGround();
if (view === null) {
abortNearest();
state.street.loading = false;
state.street.error = AIM_AT_GROUND_MESSAGE;
notify();
return false;
}
}
view ||= viewCentre(state.viewer);
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
return false;
abortNearest();
@@ -495,7 +702,11 @@ export function createStreetLevelLayer({
state.street.error = null;
notify();
let lastError = null;
for (const entry of activeEntries()) {
const asked = (entry) =>
(!providerIds || providerIds.includes(entry.def.id)) &&
providerAvailable(entry);
const entries = activeEntries().filter(asked);
for (const entry of entries) {
let imageId = null;
try {
imageId = await entry.instance.nearestImage(
@@ -511,17 +722,20 @@ export function createStreetLevelLayer({
}
if (signal.aborted) return false;
if (!state.enabled) break;
// This provider was switched off while it looked: try the next.
if (!imageId || !entry.on) continue;
// Switched off, or its map left, while it looked: try the next.
if (!imageId || !entry.on || !providerAvailable(entry)) continue;
nearestLookup = null;
return openImage(entry.def.id, imageId);
return openImage(entry.def.id, imageId, { frame });
}
if (nearestLookup === lookup) nearestLookup = null;
// Switched off while it looked: nothing to open, nothing to report.
if (state.enabled && activeEntries().length)
const stillOn = entries.some(
(entry) => entry.on && providerAvailable(entry),
);
if (state.enabled && stillOn)
state.street.error =
lastError?.message ||
`No street-level imagery within ${NEAREST_RADIUS_M} m of the view centre`;
`No street-level imagery within ${NEAREST_RADIUS_M} m of ${point ? 'that point' : 'the view centre'}`;
state.street.loading = false;
notify();
return false;
+449 -2
View File
@@ -4,6 +4,7 @@ import * as Cesium from 'cesium';
import { createStreetLevelLayer } from './index.js';
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
import { createMapillaryProvider } from './providers/mapillary/index.js';
import { createKartaViewProvider } from './providers/kartaview/index.js';
import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs';
/**
@@ -92,8 +93,8 @@ function deferred() {
}
/** A provider whose viewer reports a pose for every image it opens. */
function posingProvider() {
const provider = fakeProvider();
function posingProvider(options) {
const provider = fakeProvider(options);
const create = provider.create;
provider.create = (context) => {
const instance = create(context);
@@ -483,6 +484,328 @@ test('switching a provider on while the layer is off activates nothing (M01)', a
assert.equal(viewer.credits.length, 1);
});
/** The real Google provider over the Street View fakes, beside a Mapillary stand-in. */
async function withStreetView(
t,
{ mapStack = 'photoreal', nearest, mapillaryKeyless = false } = {},
) {
const { createGoogleProvider } = await import('./providers/google/index.js');
const { fakeHost, fakeMapsLoader, fakeStreetViewLibrary } =
await import('../../testSupport/googleStreetViewFakes.mjs');
const fake = fakeStreetViewLibrary({
panoramas: {
'pano-a': { lat: 38.5816, lng: -121.4944, imageDate: '2024-05' },
},
nearest: nearest ?? (() => 'pano-a'),
});
const mapillaryLookups = [];
const mapillary = fakeProvider({
nearestImage: async (point) => {
mapillaryLookups.push(point);
return null;
},
});
mapillary.requiresKeyId = 'mapillary';
if (mapillaryKeyless) {
mapillary.stats.keyRequired = true;
const create = mapillary.create;
mapillary.create = (context) => ({
...create(context),
status: async () => ({ configured: false }),
});
}
const loader = fakeMapsLoader(fake.library);
const google = createGoogleProvider({
getApiKey: () => 'browser-key',
loader,
});
const { layer, viewer } = await enabledLayer(t, [mapillary, google]);
const stack = fakeMapStack(mapStack);
layer.attachMapStackController(stack);
layer.attachViewerHost(fakeHost());
return { layer, viewer, stack, fake, loader, mapillaryLookups };
}
test('Street View starts off, and the layer row names the key of the providers that are on', async (t) => {
const { layer } = await withStreetView(t);
const google = () =>
layer.getUIState().providers.find((p) => p.id === 'google');
assert.equal(google().on, false);
assert.equal(layer.requiresKeyId, 'mapillary');
layer.setProviderEnabled('google', true);
assert.equal(layer.requiresKeyId, 'google-maps', 'Street View alone');
});
test('the layer starts with one source lit: the first that defaults on', async (t) => {
const { layer } = await enabledLayer(t, [
fakeProvider(),
fakeProvider({ id: 'panoramax', pickPrefix: 'pmx:' }),
]);
assert.deepEqual(
layer
.getUIState()
.providers.filter((p) => p.on)
.map((p) => p.id),
['mapillary'],
);
});
test('leaving Google 3D during a Street View lookup ends it without an error or a stuck spinner', async (t) => {
const { layer, stack, fake } = await withStreetView(t);
layer.setProviderEnabled('google', true);
let answer;
fake.library.StreetViewService.prototype.getPanorama = () =>
new Promise((resolve) => {
answer = resolve;
});
const opening = layer.openNearest({ lat: 38.5816, lon: -121.4944 });
await settle();
assert.equal(layer.getUIState().street.loading, true);
stack.switchTo('bing');
answer({ data: { location: { pano: 'pano-a' }, imageDate: '2024-05' } });
assert.equal(await opening, false);
const { street } = layer.getUIState();
assert.equal(street.loading, false, 'OPEN STREET VIEW is usable again');
assert.equal(street.error, null);
assert.equal(street.open, false);
});
test('a source swap during a lookup reports no false "no imagery"', async (t) => {
let answer;
const mapillary = fakeProvider({
nearestImage: () =>
new Promise((resolve) => {
answer = resolve;
}),
});
const other = fakeProvider({ id: 'panoramax', pickPrefix: 'pmx:' });
const { layer } = await enabledLayer(t, [mapillary, other]);
const opening = layer.openNearest({ lat: 1, lon: 2 });
layer.setProviderEnabled('panoramax', true);
answer('img-1');
assert.equal(await opening, false);
assert.equal(layer.getUIState().street.error, null);
assert.equal(layer.getUIState().street.loading, false);
});
test('Street View lit off Google 3D says why the panel is empty', async (t) => {
const { layer, stack } = await withStreetView(t);
layer.setProviderEnabled('google', true);
stack.switchTo('esri-imagery');
assert.match(
layer.getUIState().coverage.hint,
/Google Street View needs the Google 3D map/,
);
layer.setProviderEnabled('mapillary', true);
assert.doesNotMatch(layer.getUIState().coverage.hint, /Google 3D/);
});
test('one imagery source at a time: lighting one darkens the other, either way', async (t) => {
const { layer } = await withStreetView(t);
const lit = () =>
layer
.getUIState()
.providers.filter((p) => p.on)
.map((p) => p.id);
assert.deepEqual(lit(), ['mapillary'], 'Mapillary by default: it is free');
layer.setProviderEnabled('google', true);
assert.deepEqual(lit(), ['google']);
assert.equal(layer.getParams().mapillary, false);
layer.setProviderEnabled('mapillary', true);
assert.deepEqual(lit(), ['mapillary']);
assert.equal(layer.getParams().google, false);
// Params that light one switch to it; reapplying them changes nothing.
layer.setParams({ google: true });
assert.deepEqual(lit(), ['google']);
layer.setParams({ google: true });
assert.deepEqual(lit(), ['google']);
// Params lighting both keep the one already on, however often applied.
for (let i = 0; i < 2; i++) {
layer.setParams({ mapillary: true, providerSwitches: 'google-1' });
assert.deepEqual(lit(), ['google']);
}
layer.setParams({ mapillary: true, google: false });
assert.deepEqual(lit(), ['mapillary']);
// Filter-only params leave the source alone.
layer.setParams({ pano: 'pano' });
assert.deepEqual(lit(), ['mapillary']);
// Darkening the only lit one leaves none: the panel turns the layer off.
layer.setProviderEnabled('mapillary', false);
assert.deepEqual(lit(), []);
});
test('Street View appears only on the Google 3D map: chip, legend, credit, source and images', async (t) => {
const { layer, viewer, stack } = await withStreetView(t, {
mapStack: 'esri-imagery',
});
layer.setProviderEnabled('google', true);
await settle();
const ui = () => layer.getUIState();
const listed = () => ui().providers.map((p) => p.id);
const credited = () =>
viewer.credits.some((credit) => /Street View imagery/.test(credit.html));
assert.deepEqual(listed(), ['mapillary'], 'no Street View chip');
assert.ok(!ui().legend.some((item) => item.key === 'google'));
assert.equal(credited(), false, 'no Google credit on another map');
assert.equal(layer.source, 'Mapillary');
assert.equal(layer.getStats().source, 'Mapillary');
assert.equal(await layer.openImage('google', 'pano-a'), false);
assert.match(ui().street.error, /needs the Google 3D map/);
assert.equal(layer.getParams().google, true, 'the choice is kept');
stack.switchTo('photoreal');
assert.deepEqual(listed(), ['mapillary', 'google']);
assert.equal(credited(), true);
assert.equal(layer.source, 'Mapillary · Google Street View');
assert.equal(await layer.openImage('google', 'pano-a'), true);
assert.equal(ui().street.providerId, 'google');
stack.switchTo('bing');
await settle();
assert.equal(ui().street.open, false, 'closed with the map');
assert.equal(credited(), false);
assert.deepEqual(listed(), ['mapillary']);
});
test('the UI state says when a click (or the button) can open Street View here', async (t) => {
const { layer } = await withStreetView(t);
assert.equal(typeof layer.getUIState().groundClickReady, 'boolean');
});
test('a lookup for the click-to-open providers asks only them and reports "that point"', async (t) => {
const { layer, mapillaryLookups } = await withStreetView(t, {
nearest: () => null,
});
layer.setProviderEnabled('google', true);
await settle();
const opened = await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false },
);
assert.equal(opened, false);
assert.deepEqual(mapillaryLookups, [], 'Mapillary was not asked');
assert.equal(
layer.getUIState().street.error,
'No street-level imagery within 50 m of that point',
);
});
test('an image opened from a ground click leaves the camera where it is', async (t) => {
const { layer, viewer } = await withStreetView(t);
layer.setProviderEnabled('google', true);
await settle();
await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false },
);
assert.equal(layer.getUIState().street.imageId, 'pano-a');
assert.equal(viewer.flights.started, 0, 'no framing flight');
});
test('off Google 3D not even Google\u2019s script is loaded for Street View', async (t) => {
const { layer, loader } = await withStreetView(t, {
mapStack: 'esri-imagery',
});
layer.setProviderEnabled('google', true);
layer.attachViewerHost({ ownerDocument: { createElement: () => ({}) } });
await new Promise((resolve) => setTimeout(resolve, 100));
assert.deepEqual(loader.imports, [], 'no prewarm, no lookup');
});
test('the button’s “centre of the view” is the ground under the middle of the screen', async (t) => {
const { createStreetLevelLayer: create } = await import('./index.js');
const scenePick = await import('../../data/scenePick.js');
const lookups = [];
const provider = fakeProvider({
nearestImage: async (point) => {
lookups.push(point);
return null;
},
});
const saved = globalThis.document;
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0);
Object.assign(viewer.scene, {
pickPositionSupported: true,
pickPosition: (at) => (at.x === 500 && at.y === 300 ? centre : undefined),
});
viewer.scene.canvas.clientWidth = 1000;
viewer.scene.canvas.clientHeight = 600;
const layer = create({ providers: [provider], services: { scenePick } });
layer.init(viewer);
layer.enable(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
await layer.openNearest(undefined, { aim: 'screen-centre' });
assert.equal(lookups.length, 1);
assert.ok(Math.abs(lookups[0].lat - 38.5799) < 1e-9);
assert.ok(Math.abs(lookups[0].lon - -121.4937) < 1e-9);
});
test('the button aimed at the sky opens nothing, and says to aim at a street', async (t) => {
const { createStreetLevelLayer: create } = await import('./index.js');
const scenePick = await import('../../data/scenePick.js');
const lookups = [];
const provider = fakeProvider({
nearestImage: async (point) => {
lookups.push(point);
return null;
},
});
const saved = globalThis.document;
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
// Every ground pick misses: the middle of the screen is sky.
Object.assign(viewer.scene, {
pickPositionSupported: true,
pickPosition: () => undefined,
});
viewer.camera.pickEllipsoid = () => undefined;
viewer.scene.canvas.clientWidth = 1000;
viewer.scene.canvas.clientHeight = 600;
const layer = create({ providers: [provider], services: { scenePick } });
layer.init(viewer);
layer.enable(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
assert.equal(
await layer.openNearest(undefined, { aim: 'screen-centre' }),
false,
);
assert.deepEqual(lookups, [], 'no lookup at the view centre instead');
const { street } = layer.getUIState();
assert.match(street.error, /Aim the middle of the view at a street/);
assert.equal(street.loading, false);
});
test('with no Mapillary token, the layer points at Street View, on Google 3D only', async (t) => {
const { layer, stack } = await withStreetView(t, { mapillaryKeyless: true });
await settle();
const stats = layer.getStats();
assert.equal(stats.keyRequired, true);
assert.deepEqual(stats.keyAlternatives, [
{ id: 'google', label: 'GOOGLE STREET VIEW', requiresKeyId: 'google-maps' },
]);
assert.deepEqual(layer.getUIState().keyAlternatives, stats.keyAlternatives);
stack.switchTo('esri-imagery');
assert.deepEqual(layer.getStats().keyAlternatives, [], 'not off Google 3D');
// Lit, Street View draws: the layer is no longer key-gated.
stack.switchTo('photoreal');
layer.setProviderEnabled('google', true);
assert.equal(layer.getStats().keyRequired, false);
});
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
const { layer, viewer } = await enabledLayer(t, []);
assert.deepEqual(layer.providerIds, []);
@@ -496,3 +819,127 @@ test('an empty provider composition enables and tears down without acquiring ima
layer.enable(viewer);
assert.deepEqual(layer.getUIState().providers, []);
});
test('a failed lookup’s error goes when another source is lit; a provider can open an image without framing', async (t) => {
const failing = fakeProvider({
nearestImage: async () => {
throw new Error('KartaView did not answer in time — try again');
},
});
const other = fakeProvider({ id: 'other', pickPrefix: 'oth:' });
const { layer, viewer } = await enabledLayer(t, [failing, other]);
layer.attachViewerHost({});
await layer.openNearest({ lat: 1, lon: 1 });
assert.match(layer.getUIState().street.error, /did not answer/);
layer.setProviderEnabled('other', true);
await settle();
assert.equal(layer.getUIState().street.error, null);
// A provider's own open (KartaView's ‹ ›) can leave the camera alone.
await other.context().actions.openImage('img-1', { frame: false });
assert.deepEqual(other.calls.open, ['img-1']);
assert.equal(viewer.flights.started, 0);
});
test('a provider’s own open can leave the camera where it is; a default open frames', async (t) => {
const provider = posingProvider();
const { layer, viewer } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
await provider.context().actions.openImage('img-1', { frame: false });
assert.deepEqual(provider.calls.open, ['img-1']);
assert.equal(viewer.flights.started, 0, 'frame: false keeps the camera');
await provider.context().actions.openImage('img-2');
assert.equal(viewer.flights.started, 1, 'a default open frames the image');
});
test('a provider’s openNearest (a line click) asks only that provider and leaves the camera', async (t) => {
const lit = posingProvider({ nearestImage: async () => 'lit-near' });
const other = fakeProvider({
id: 'other',
pickPrefix: 'oth:',
nearestImage: async () => 'other-near',
});
const { layer, viewer } = await enabledLayer(t, [lit, other]);
layer.attachViewerHost({});
const point = { lat: 38.58, lon: -121.49 };
// The other source is off: its click asks nobody else.
await other.context().actions.openNearest(point);
await settle();
assert.deepEqual(lit.calls.open, [], 'the lit source was not asked');
assert.deepEqual(other.calls.open, []);
await lit.context().actions.openNearest(point);
assert.deepEqual(lit.calls.open, ['lit-near']);
assert.equal(viewer.flights.started, 0, 'the user picked the spot');
});
test('KartaView through the core: its own swatch without Selected, its lines counted as streets', async (t) => {
const kartaView = createKartaViewProvider({
source: {
getTile: async () => ({ lines: [] }),
nearestPhotos: async () => [],
getPhoto: async () => {
throw new Error('KartaView has no such photo');
},
getSequencePhotos: async () => ({ photos: [], hasMore: false }),
probeImage: async () => true,
},
});
const { layer } = await enabledLayer(t, [kartaView]);
layer.setProviderEnabled('kartaview', true);
await settle();
const ui = layer.getUIState();
assert.deepEqual(
ui.legend.map((entry) => entry.key),
['kartaview'],
);
assert.equal(ui.coverage.label?.noun, 'streets');
assert.equal(
ui.providers.find((p) => p.id === 'kartaview').highlightsSequence,
false,
);
});
test('switching source by params (the chip path) stops a running lookup and clears its spinner and error', async (t) => {
const slow = slowLookups();
const looking = fakeProvider({ nearestImage: slow.nearestImage });
const other = fakeProvider({ id: 'other', pickPrefix: 'oth:' });
const { layer } = await enabledLayer(t, [looking, other]);
layer.attachViewerHost({});
const opening = layer.openNearest({ lat: 1, lon: 1 });
await settle();
assert.equal(layer.getUIState().street.loading, true);
layer.setParams({ mapillary: false, other: true });
assert.equal(slow.lookups[0].signal.aborted, true, 'the lookup is stopped');
assert.equal(
layer.getUIState().street.loading,
false,
'no spinner left behind',
);
// The stopped lookup answers late: nothing opens, nothing is reported.
slow.lookups[0].answer.resolve('img1');
assert.equal(await opening, false);
assert.deepEqual(looking.calls.open, []);
assert.equal(layer.getUIState().street.error, null);
});
test('switching source by params clears a failed lookup’s error; reapplying the same switches does not', async (t) => {
const failing = fakeProvider({
nearestImage: async () => {
throw new Error('KartaView did not answer in time — try again');
},
});
const { layer: second } = await enabledLayer(t, [
failing,
fakeProvider({ id: 'other', pickPrefix: 'oth:' }),
]);
second.attachViewerHost({});
await second.openNearest({ lat: 1, lon: 1 });
assert.match(second.getUIState().street.error, /did not answer/);
second.setParams({ mapillary: false, other: true });
assert.equal(second.getUIState().street.error, null);
// Reapplying the same switches (a restore) leaves state alone.
await second.openNearest({ lat: 1, lon: 1 });
const kept = second.getUIState().street.error;
assert.match(kept, /No street-level imagery/);
second.setParams({ mapillary: false, other: true });
assert.equal(second.getUIState().street.error, kept);
});
+936
View File
@@ -0,0 +1,936 @@
import * as Cesium from 'cesium';
import { passesImageryFilter } from './filter.js';
import {
createHorizonCull,
groundUnderCamera,
metresBetween,
viewFocus,
visibleBbox,
whenIdle,
} from './view.js';
import { densifyLine, MESH_DENSIFY_DEG } from './groundCast.js';
import { MESH_SAMPLE_RADIUS_M } from './meshSampler.js';
import { tileBounds, tilesForBbox } from './tileMath.js';
/** Draped lines stay at most this long while a tile's cast lines build. */
export const SWAP_MAX_WAIT_MS = 4000;
/** Tiles touched by new mesh samples are redrawn at most this often. */
const REMESH_INTERVAL_MS = 1500;
/** Gap between redrawing one dirty tile and the next. */
const REMESH_STAGGER_MS = 120;
/** Old-zoom tiles are kept at most this long after a zoom change. */
const STALE_TILE_MAX_MS = 6000;
/** Sequences per primitive: smaller batches build, and show, sooner. */
const SEQUENCE_PRIMITIVE_BATCH = 120;
/** Separates a multi-part sequence's part index from its id in pick ids. */
const PART_SEPARATOR = '~';
/** Pick ids for every part of a sequence; the first part is unsuffixed. */
function partIds(sequence, prefix) {
return sequence.parts.map((_, index) =>
index
? `${prefix}${sequence.id}${PART_SEPARATOR}${index}`
: `${prefix}${sequence.id}`,
);
}
/** Sequence id for any part's pick id (`<prefix><id>[~<part>]`), else null. */
export function sequenceIdFromPick(pickId, prefix) {
if (typeof pickId !== 'string' || !prefix || !pickId.startsWith(prefix))
return null;
const rest = pickId.slice(prefix.length);
const cut = rest.indexOf(PART_SEPARATOR);
return cut === -1 ? rest : rest.slice(0, cut);
}
/**
* Whether any point of `box` ({west, south, east, north}) is within the mesh
* sampler's range of `centre`, the ground point under the camera.
*/
export function meshBoxInRange(box, centre) {
// Move the camera's longitude to the box's side of the date line first:
// clamping 179.999 into a box at -179.99 would pick its far edge.
const lon =
centre.lon +
360 * Math.round(((box.west + box.east) / 2 - centre.lon) / 360);
const nearest = {
lon: Math.min(box.east, Math.max(box.west, lon)),
lat: Math.min(box.north, Math.max(box.south, centre.lat)),
};
return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M;
}
/**
* @typedef {object} LineCoverageTuning
* @property {number} maxTiles Line tiles drawn at once.
* @property {number} overviewMaxTiles Overview tiles drawn at once.
* @property {number} maxSequences Lines drawn at once, over every tile.
* @property {number} lineWidthPx
* @property {number} overviewPointPx
* @property {number} moveDebounceMs Camera rest before the tile set is recomputed.
* @property {number} viewRangeMinM Street-zoom coverage reaches at least this far…
* @property {number} viewRangePerHeight …or camera height times this.
* @property {number} viewNearM Ground always covered around a tilted camera.
*/
/**
* Camera-driven coverage from any XYZ line-tile source: overview points from
* orbit, sequence lines near the ground. In terrain surface mode a tile is
* drawn draped first, then swapped for lines cast to the bare earth (draped
* lines would land on roofs), so coverage never waits on terrain heights.
*
* @param {object} options
* @param {object} options.state Provider state: `context`, `services`, `viewer`, `coverage`, `sequence`, key flags.
* @param {(tile: {x: number, y: number, z: number}, init: {signal: AbortSignal}) => Promise<{sequences: Array<object>, overview: Array<object>}>} options.fetchTile
* A decoded tile. Sequences are `{id, parts, capturedAt, isPano}`; overview
* points `{lon, lat, capturedAt, isPano}`. A rejection may carry
* `keyRequired`, `keyRejected` or `retryAfterSec`.
* @param {(heightM: number|null) => {zoom: number, kind: 'sequence'|'overview'}|null} options.zoomFor
* Tile zoom for a camera height above ground; null draws nothing.
* @param {{coverage: string, selected: string}} options.colors
* @param {string} options.pickPrefix Pick-id prefix of the lines.
* @param {string} options.renderKey Render-governor reason.
* @param {LineCoverageTuning} options.tuning
* @param {{keyRejected?: string, rateLimited: string, tooHigh?: string}} options.messages
* @param {(sequence: object, filter: object) => boolean} [options.passes] Whether the imagery filter keeps a line or point.
*/
export function createLineCoverage({
state,
fetchTile,
zoomFor,
colors,
pickPrefix,
renderKey,
tuning,
messages,
passes = passesImageryFilter,
}) {
const { render } = state.services;
const perTileSequenceCap = Math.floor(tuning.maxSequences / tuning.maxTiles);
/** Colour for a line: the provider's, GEV cyan while selected. */
function sequenceColor({ selected = false } = {}) {
return selected
? Cesium.Color.fromCssColorString(colors.selected)
: Cesium.Color.fromCssColorString(colors.coverage).withAlpha(0.92);
}
function requestRender() {
render?.governorRequestRender?.(renderKey);
}
function ensureTerrainReady() {
return (state.coverage.terrainReady ||=
Cesium.GroundPolylinePrimitive.initializeTerrainHeights());
}
function tileKey({ x, y, z }) {
return `${z}/${x}/${y}`;
}
/** The imagery filter, resolved now: "since N days" moves with the clock. */
function filter() {
return state.context.getFilter();
}
function terrainMode() {
return (
state.context.getSurface?.() === 'terrain' &&
Boolean(state.context.groundCaster)
);
}
/**
* A tile's sequences, filtered then capped: capping first could leave a
* dense tile of newer flat captures with no 360° lines at all.
*/
function drawnSequences(entry) {
const drawn = [];
const current = filter();
for (const sequence of entry.sequenceList) {
if (drawn.length >= perTileSequenceCap) break;
if (passes(sequence, current)) drawn.push(sequence);
}
return drawn;
}
/**
* Batched primitives for a tile's sequence parts: cast polylines where
* terrain heights are cached, draped ground lines otherwise. Each batch
* records the selection its colours were built with.
*/
function buildSequencePrimitives(sequences) {
const ground = terrainMode() ? state.context.groundCaster : null;
const meshAt = ground ? state.context.meshSampler?.meshAt : undefined;
const selectedId = state.sequence.selectedId ?? null;
const draped = [];
const cast = [];
for (const sequence of sequences) {
const color = Cesium.ColorGeometryInstanceAttribute.fromColor(
sequenceColor({ selected: sequence.id === selectedId }),
);
const ids = partIds(sequence, pickPrefix);
sequence.parts.forEach((coordinates, index) => {
const flat = ground?.castLine(coordinates, { meshAt });
let positions;
try {
positions = flat
? Cesium.Cartesian3.fromDegreesArrayHeights(flat)
: Cesium.Cartesian3.fromDegreesArray(coordinates.flat());
} catch {
return;
}
if (positions.length < 2) return;
const geometry = flat
? new Cesium.PolylineGeometry({
positions,
width: tuning.lineWidthPx,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
arcType: Cesium.ArcType.NONE,
})
: new Cesium.GroundPolylineGeometry({
positions,
width: tuning.lineWidthPx,
});
(flat ? cast : draped).push(
new Cesium.GeometryInstance({
geometry,
id: ids[index],
attributes: { color },
}),
);
});
}
const primitives = [];
for (let i = 0; i < draped.length; i += SEQUENCE_PRIMITIVE_BATCH)
primitives.push({
onGround: true,
selectedId,
primitive: new Cesium.GroundPolylinePrimitive({
geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
appearance: new Cesium.PolylineColorAppearance(),
classificationType: Cesium.ClassificationType.BOTH,
asynchronous: true,
allowPicking: true,
}),
});
for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH)
primitives.push({
onGround: false,
selectedId,
primitive: new Cesium.Primitive({
geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
asynchronous: true,
allowPicking: true,
}),
});
return { primitives, draped, cast: cast.length };
}
/**
* Fetch the terrain heights a tile's lines need, then redraw it cast.
* `castRequested` stops the redraw from casting again at once; a refresh
* `retry`s a tile whose lines are still draped (a failed or partial cast),
* and it is redrawn only when more of its lines can be cast.
*/
function castTile(entry, { retry = false } = {}) {
if ((entry.castRequested && !retry) || entry.castAbort || !terrainMode())
return;
entry.castRequested = true;
entry.castAbort = new AbortController();
const { signal } = entry.castAbort;
const lines = [...entry.sequences.values()].flatMap(
(sequence) => sequence.parts,
);
const caster = state.context.groundCaster;
caster.prepareLines(lines, { signal }).then(() => {
if (entry.castAbort?.signal === signal) entry.castAbort = null;
const attached = [...state.coverage.tiles.values()].includes(entry);
if (signal.aborted || !attached || !terrainMode()) return;
// Nothing new to cast: keep what is drawn until the next refresh.
// Counted like buildSequencePrimitives: a cast line needs two points.
const castable = lines.filter(
(coords) => caster.castLine(coords)?.length >= 6,
).length;
if (castable <= (entry.castLines || 0)) {
entry.castRequested = (entry.castLines || 0) > 0;
return;
}
// A remesh may have cleared the flag while this was in flight.
entry.castRequested = true;
redrawTile(entry);
requestMesh(entry);
});
}
/** Rebuild a tile's lines; the old ones stay up until the new are ready. */
function redrawTile(entry) {
const previous = entry.primitives;
entry.primitives = [];
attachPrimitive(entry);
removeWhenReady(entry, previous);
}
/** Lon/lat under the camera, where mesh range is measured from. */
function meshCentre() {
const carto = state.viewer?.camera?.positionCartographic;
if (!carto) return null;
return {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
};
}
/**
* A tile's drawn lines with bounding boxes, built once per filter; mesh
* points are densified the first time a line is in range.
*/
function meshParts(entry) {
if (entry.meshParts) return entry.meshParts;
entry.meshParts = [];
for (const sequence of entry.sequences.values())
for (const part of sequence.parts) {
const box = {
west: Infinity,
south: Infinity,
east: -Infinity,
north: -Infinity,
};
for (const [lon, lat] of part) {
if (lon < box.west) box.west = lon;
if (lon > box.east) box.east = lon;
if (lat < box.south) box.south = lat;
if (lat > box.north) box.north = lat;
}
entry.meshParts.push({ part, box, points: null });
}
return entry.meshParts;
}
/**
* Ask for mesh samples under a cast tile's lines within the sampler's range
* of the camera; the rest are asked for when a camera move brings them in.
*/
function requestMesh(entry) {
const sampler = state.context.meshSampler;
if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return;
const centre = meshCentre();
if (!centre || !entry.bounds || !meshBoxInRange(entry.bounds, centre))
return;
const points = [];
for (const line of meshParts(entry)) {
if (!meshBoxInRange(line.box, centre)) continue;
line.points ||= densifyLine(line.part, MESH_DENSIFY_DEG);
for (const point of line.points) points.push(point);
}
if (points.length) sampler.request(points);
}
/** Redraw the tiles new mesh samples fall in, throttled. */
function onMeshSampled(batch) {
if (!terrainMode() || !state.context.isActive()) return;
for (const entry of state.coverage.tiles.values()) {
if (entry.kind !== 'sequence' || !entry.bounds) continue;
const { west, south, east, north } = entry.bounds;
if (
batch.some(
([lon, lat]) =>
lon >= west && lon <= east && lat >= south && lat <= north,
)
)
state.coverage.remeshDirty.add(entry);
}
if (!state.coverage.remeshDirty.size || state.coverage.remeshTimer) return;
const wait = Math.max(
0,
REMESH_INTERVAL_MS - (Date.now() - (state.coverage.remeshAt || 0)),
);
state.coverage.remeshTimer = setTimeout(remesh, wait);
}
/** Redraw one dirty tile per idle slice, so a burst of samples never stalls a frame. */
function remesh() {
state.coverage.remeshTimer = null;
state.coverage.remeshAt = Date.now();
if (!terrainMode() || !state.context.isActive()) {
state.coverage.remeshDirty.clear();
return;
}
const attached = new Set(state.coverage.tiles.values());
const [entry] = state.coverage.remeshDirty;
if (!entry) return;
state.coverage.remeshDirty.delete(entry);
if (attached.has(entry)) {
redrawTile(entry);
requestRender();
}
if (state.coverage.remeshDirty.size)
state.coverage.remeshTimer = setTimeout(
() => whenIdle(remesh, 500),
REMESH_STAGGER_MS,
);
}
state.coverage.remeshDirty = new Set();
state.context.meshSampler?.onSampled(onMeshSampled);
/**
* Remove a tile's previous primitives once all its current ones are ready
* (or after a few seconds); detaching the tile removes them at once.
*/
function removeWhenReady(entry, old) {
const scene = state.viewer?.scene;
finishSwap(entry);
if (!scene?.postRender) {
removePrimitives(old);
return;
}
const fresh = entry.primitives;
const started = Date.now();
const stop = scene.postRender.addEventListener(() => {
const ready = fresh.every(
({ primitive }) => primitive.ready || primitive.isDestroyed?.(),
);
if (!ready && Date.now() - started < SWAP_MAX_WAIT_MS) {
requestRender();
return;
}
finishSwap(entry);
requestRender();
});
entry.swap = { old, stop };
requestRender();
}
function finishSwap(entry) {
if (!entry.swap) return;
entry.swap.stop();
removePrimitives(entry.swap.old);
entry.swap = null;
}
function buildOverviewCollection(points) {
const collection = new Cesium.PointPrimitiveCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
const tint = Cesium.Color.fromCssColorString(colors.coverage).withAlpha(
0.85,
);
const drawn = [];
const current = filter();
for (const point of points) {
if (!passes(point, current)) continue;
drawn.push(
collection.add({
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat),
color: tint,
pixelSize: tuning.overviewPointPx,
// Google 3D terrain and clouds must not hide the near side's dots;
// the horizon cull hides the far side's, which this lets through.
disableDepthTestDistance: Number.POSITIVE_INFINITY,
}),
);
}
return { collection, points: drawn };
}
/** Every overview point on the globe, current zoom and old alike. */
function* overviewPoints() {
for (const entry of drawnEntries())
if (entry.overviewPoints) yield* entry.overviewPoints;
}
// Overview points skip the depth test: hide the far hemisphere's.
const horizon = createHorizonCull({
getViewer: () => state.viewer,
items: overviewPoints,
onChange: requestRender,
});
function attachPrimitive(entry) {
const scene = state.viewer?.scene;
if (!scene) return;
if (entry.kind === 'sequence') {
// Lookup, picking and counts follow what is drawn, not the whole tile.
const sequences = drawnSequences(entry);
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
const { primitives, draped, cast } = buildSequencePrimitives(sequences);
entry.primitives = primitives;
entry.count = sequences.length;
for (const { primitive, onGround } of primitives)
(onGround ? scene.groundPrimitives : scene.primitives).add(primitive);
// Fewer lines cast than last time: the caster has dropped heights this
// tile used (a full cache), so it must be cast again.
if (cast < (entry.castLines || 0)) entry.castRequested = false;
entry.castLines = cast;
entry.drapedLines = draped.length;
if (draped.length) castTile(entry);
watchSelection();
} else {
const { collection, points } = buildOverviewCollection(entry.points);
entry.primitive = collection;
entry.overviewPoints = points;
entry.count = points.length;
scene.primitives.add(collection);
// Cull the new points now, then all of them whenever the camera moves.
horizon.update(points);
}
}
function removePrimitives(list) {
const scene = state.viewer?.scene;
for (const { primitive, onGround } of list || []) {
try {
(onGround ? scene?.groundPrimitives : scene?.primitives)?.remove(
primitive,
);
} catch {
/* already gone */
}
}
}
function detachPrimitive(entry) {
const scene = state.viewer?.scene;
finishSwap(entry);
removePrimitives(entry.primitives);
entry.primitives = [];
if (entry.primitive) {
try {
scene?.primitives?.remove(entry.primitive);
} catch {
/* already gone */
}
entry.primitive = null;
entry.overviewPoints = null;
}
}
/** Stop a tile's pending terrain lookup (tile dropped or retired). */
function cancelCast(entry) {
entry.castAbort?.abort();
entry.castAbort = null;
}
function removeTile(key) {
const entry = state.coverage.tiles.get(key);
if (!entry) return;
cancelCast(entry);
detachPrimitive(entry);
state.coverage.tiles.delete(key);
}
async function loadTile(tile, kind) {
const key = tileKey(tile);
if (state.coverage.tiles.has(key) || state.coverage.pending.has(key))
return;
const controller = new AbortController();
state.coverage.pending.set(key, controller);
notify();
try {
// Fetch and the one-time terrain-height table load run side by side,
// both handled from the start so an early tile failure is not unhandled.
const [decoded] = await Promise.all([
fetchTile(tile, { signal: controller.signal }),
kind === 'sequence' ? ensureTerrainReady() : null,
]);
// Only an abort, a retire, a clear or a newer request for this tile
// discards the bytes; a refresh that still wants the tile keeps them.
const current = () =>
state.coverage.pending.get(key) === controller &&
!state.coverage.tiles.has(key);
if (
controller.signal.aborted ||
!current() ||
kind !== state.coverage.kind ||
tile.z !== state.coverage.zoom
)
return;
const entry = { kind, primitive: null, primitives: [], count: 0 };
// Padded: vector tiles carry a small buffer past their edge.
const bounds = tileBounds(tile.x, tile.y, tile.z);
const pad = (bounds.east - bounds.west) * 0.05;
entry.bounds = {
west: bounds.west - pad,
east: bounds.east + pad,
south: bounds.south - pad,
north: bounds.north + pad,
};
if (kind === 'sequence') {
// Newest first; the per-tile cap applies to what passes the filter.
entry.sequenceList = decoded.sequences.sort(
(a, b) => (b.capturedAt || 0) - (a.capturedAt || 0),
);
entry.sequences = new Map();
entry.total = decoded.sequences.length;
} else {
entry.points = decoded.overview;
entry.sequences = new Map();
entry.total = decoded.overview.length;
}
attachPrimitive(entry);
state.coverage.tiles.set(key, entry);
state.coverage.lastError = null;
requestRender();
} catch (error) {
if (
controller.signal.aborted ||
state.coverage.pending.get(key) !== controller
)
return;
state.coverage.lastError = error?.message || 'Coverage tile failed';
if (error?.keyRequired) state.keyRequired = true;
if (error?.keyRejected) {
// Every other tile would be refused too: stop asking.
state.keyRejected = true;
state.coverage.lastError =
messages.keyRejected || state.coverage.lastError;
}
if (error?.retryAfterSec) holdFor(error.retryAfterSec);
} finally {
// Only the request that still owns the key settles it: a superseded one
// must not drop a newer request's entry (which is what counts as loading).
if (state.coverage.pending.get(key) === controller) {
state.coverage.pending.delete(key);
if (!state.coverage.pending.size) purgeStale();
}
notify();
}
}
/**
* The source is rate-limiting: keep what is drawn, request nothing until the
* wait is over, then refresh once.
*/
function holdFor(seconds) {
clearTimeout(state.coverage.holdTimer);
state.coverage.holdUntil = Date.now() + seconds * 1000;
state.coverage.lastError = messages.rateLimited;
state.coverage.holdTimer = setTimeout(() => {
state.coverage.holdTimer = null;
state.coverage.holdUntil = 0;
if (state.coverage.lastError === messages.rateLimited)
state.coverage.lastError = null;
refresh();
}, seconds * 1000);
}
/** Forget refusals when the provider goes off, so the next run asks again. */
function resetErrors() {
clearTimeout(state.coverage.holdTimer);
state.coverage.holdTimer = null;
state.coverage.holdUntil = 0;
state.coverage.lastError = null;
state.keyRejected = false;
}
/** Drop the previous zoom's tiles once the new ones are on screen. */
function purgeStale() {
clearTimeout(state.coverage.staleTimer);
state.coverage.staleTimer = null;
if (!state.coverage.stale.size) return;
for (const entry of state.coverage.stale.values()) detachPrimitive(entry);
state.coverage.stale.clear();
requestRender();
}
/** Move tiles to the stale set: the old zoom shows until the new one loads. */
function retire() {
for (const controller of state.coverage.pending.values())
controller.abort();
state.coverage.pending.clear();
for (const [key, entry] of state.coverage.tiles) {
cancelCast(entry);
const previous = state.coverage.stale.get(key);
if (previous) detachPrimitive(previous);
state.coverage.stale.set(key, entry);
}
state.coverage.tiles.clear();
clearTimeout(state.coverage.staleTimer);
state.coverage.staleTimer = setTimeout(purgeStale, STALE_TILE_MAX_MS);
}
function notify() {
state.context.notify();
}
/**
* Whether the screen centre meets the ground further ahead than the camera
* is high (a view tilted above 45°), or misses it.
*/
function isTilted({ nadir, ahead }, height) {
if (!ahead) return true;
return metresBetween(nadir, ahead) > Math.max(1, height);
}
/** Recompute the tile set for the current camera and reconcile primitives. */
function refresh() {
const viewer = state.viewer;
if (!viewer || !state.context.isActive() || state.keyRequired) return;
if (state.statusKnown === false) return;
if (state.keyRejected || state.coverage.holdUntil > Date.now()) return;
const ground = groundUnderCamera(viewer, {
groundAt: state.context.groundCaster?.groundAt,
});
const cameraHeight = viewer.camera?.positionCartographic?.height;
const height = Number.isFinite(cameraHeight)
? cameraHeight - (ground ?? 0)
: null;
const level = zoomFor(height);
const zoom = level?.zoom ?? null;
const kind = level?.kind ?? null;
// At street zooms the rays meet the ground where it really is (1,600 m up
// in Denver; Google 3D hides the globe) and stop short of the horizon.
const ranged =
kind === 'sequence'
? {
groundHeight: ground,
maxRange: Math.max(
tuning.viewRangeMinM,
height * tuning.viewRangePerHeight,
),
}
: {};
// Tilted street views rank tiles along the line of sight, from the ground
// under the camera to the ground at the centre of the screen.
const focus = kind === 'sequence' ? viewFocus(viewer, ranged) : null;
let bbox = visibleBbox(viewer, {
...ranged,
// A tilted view's screen rows jump from the first metres to the horizon:
// keep the ground around the camera too. Looking down needs no help.
nearRange: focus && isTilted(focus, height) ? tuning.viewNearM : null,
});
if (kind === 'overview' && (!bbox || zoom <= 1))
bbox = [-180, -85, 180, 85];
if (zoom == null || !bbox) {
state.coverage.hint =
zoom == null && Number.isFinite(height) && messages.tooHigh
? messages.tooHigh
: 'Point the camera at the globe for street-level coverage';
clear();
notify();
return;
}
state.coverage.hint = '';
if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) {
retire();
state.coverage.zoom = zoom;
state.coverage.kind = kind;
}
const { tiles } = tilesForBbox(bbox, zoom, {
limit: kind === 'sequence' ? tuning.maxTiles : tuning.overviewMaxTiles,
focus: focus && { from: focus.nadir, to: focus.ahead },
});
const wanted = new Set(tiles.map(tileKey));
for (const key of [...state.coverage.tiles.keys()])
if (!wanted.has(key)) removeTile(key);
for (const [key, controller] of [...state.coverage.pending])
if (!wanted.has(key)) {
// The aborted request no longer owns its key, so its `finally` will
// not settle it: settle it here.
controller.abort();
state.coverage.pending.delete(key);
}
for (const tile of tiles) loadTile(tile, kind);
if (!state.coverage.pending.size) purgeStale();
// The camera moved: cells that were out of the sampler's range may not be,
// and lines left draped by a failed, partial or dropped cast get another try.
if (terrainMode())
for (const entry of state.coverage.tiles.values()) {
if (entry.castRequested) requestMesh(entry);
if (entry.drapedLines) castTile(entry, { retry: true });
}
notify();
}
function scheduleRefresh() {
clearTimeout(state.coverage.debounceTimer);
state.coverage.debounceTimer = setTimeout(refresh, tuning.moveDebounceMs);
}
function attach(viewer) {
detach();
const remove = viewer.camera.changed.addEventListener(scheduleRefresh);
const removeEnd = viewer.camera.moveEnd.addEventListener(scheduleRefresh);
state.coverage.removeCameraListener = () => {
remove();
removeEnd();
};
refresh();
}
function detach() {
clearTimeout(state.coverage.debounceTimer);
state.coverage.debounceTimer = null;
state.coverage.removeCameraListener?.();
state.coverage.removeCameraListener = null;
}
function clear() {
for (const controller of state.coverage.pending.values())
controller.abort();
state.coverage.pending.clear();
for (const key of [...state.coverage.tiles.keys()]) removeTile(key);
purgeStale();
state.coverage.zoom = null;
state.coverage.kind = null;
clearTimeout(state.coverage.remeshTimer);
state.coverage.remeshTimer = null;
state.coverage.remeshDirty.clear();
stopSelectionWatch();
horizon.stop();
requestRender();
}
/** Rebuild every loaded tile from its decoded cache (after a filter change). */
function rebuild() {
purgeStale();
for (const entry of state.coverage.tiles.values()) {
cancelCast(entry);
entry.castRequested = false;
entry.meshParts = null;
detachPrimitive(entry);
attachPrimitive(entry);
}
requestRender();
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.
*/
function setSurface() {
if (!state.context.isActive()) return;
rebuild();
refresh();
}
/** Recolour every part of a sequence in one ready primitive. */
function recolorInstances(primitive, sequence, selected) {
const value = sequenceColor({ selected });
for (const instanceId of partIds(sequence, pickPrefix)) {
try {
const attributes = primitive.getGeometryInstanceAttributes(instanceId);
if (attributes)
attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue(
value,
attributes.color,
);
} catch {
/* instance not in this primitive */
}
}
}
/** Every tile on the globe: the current zoom's and the stale zoom's. */
function drawnEntries() {
return [...state.coverage.tiles.values(), ...state.coverage.stale.values()];
}
/** A tile's primitives on the globe: its own, and the ones a swap still shows. */
function drawnRecords(entry) {
const records = entry.primitives || [];
return entry.swap ? [...records, ...entry.swap.old] : records;
}
/** Recolour one sequence, every part of it, in place (selection highlight). */
function recolorSequence(id, selected) {
for (const entry of drawnEntries()) {
const sequence = entry.sequences.get(id);
if (!sequence) continue;
for (const record of drawnRecords(entry)) {
// Still building: `syncSelection` catches it up once it is ready.
if (!record.primitive.ready) continue;
recolorInstances(record.primitive, sequence, selected);
if (selected) record.selectedId = id;
else if (record.selectedId === id) record.selectedId = null;
}
}
requestRender();
}
/**
* Bring a ready primitive's highlight up to the current selection, when
* it was built with another one (or none). True when it changed.
*/
function syncSelection(entry, record) {
const current = state.sequence.selectedId ?? null;
if (record.selectedId === current || !record.primitive.ready) return false;
const previous =
record.selectedId && entry.sequences.get(record.selectedId);
if (previous) recolorInstances(record.primitive, previous, false);
const next = current && entry.sequences.get(current);
if (next) recolorInstances(record.primitive, next, true);
record.selectedId = current;
return true;
}
/**
* A primitive cannot be recoloured until it is ready, so while any is
* building, catch each one up with the selection as it becomes ready.
*/
function watchSelection() {
const scene = state.viewer?.scene;
if (state.coverage.stopSelectionWatch || !scene?.postRender) return;
const stop = scene.postRender.addEventListener(() => {
let building = false;
let changed = false;
for (const entry of drawnEntries()) {
for (const record of entry.primitives || []) {
if (!record.primitive.ready) building = true;
else if (syncSelection(entry, record)) changed = true;
}
// Lines a swap still shows were ready (or never show): catch up only.
for (const record of entry.swap?.old || [])
if (syncSelection(entry, record)) changed = true;
}
if (changed) requestRender();
if (!building) stopSelectionWatch();
});
state.coverage.stopSelectionWatch = stop;
}
function stopSelectionWatch() {
state.coverage.stopSelectionWatch?.();
state.coverage.stopSelectionWatch = null;
}
/**
* Sequences (or overview points) drawn. A sequence that crosses a tile
* edge is in both tiles' lists, so sequences count once by id.
*/
function sequenceCount() {
const ids = new Set();
let points = 0;
for (const entry of state.coverage.tiles.values())
if (entry.kind === 'sequence')
for (const id of entry.sequences.keys()) ids.add(id);
else points += entry.count;
return ids.size + points;
}
return {
attach,
detach,
refresh,
clear,
resetErrors,
rebuild,
reload,
setSurface,
recolorSequence,
sequenceCount,
};
}
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createLineCoverage } from './lineCoverage.js';
import {
coverageViewer,
tileCentre,
} from '../../testSupport/streetLevelFakes.mjs';
import { until } from '../../testSupport/kartaViewFakes.mjs';
const TUNING = Object.freeze({
maxTiles: 9,
overviewMaxTiles: 0,
maxSequences: 900,
lineWidthPx: 2,
overviewPointPx: 2,
moveDebounceMs: 10,
viewRangeMinM: 2_500,
viewRangePerHeight: 10,
viewNearM: 1_000,
});
/**
* Line coverage over one tile holding one line, on a viewer looking down from
* `height`. `fetchTile` answers with that line unless `fail` says otherwise.
*/
function setup(
t,
{ zoomFor, messages = { rateLimited: 'busy' }, passes, fail } = {},
) {
const centre = tileCentre(-121.4944, 38.5816);
const viewer = coverageViewer(
{ lon: centre.lon, lat: centre.lat, height: 900 },
t,
);
const state = {
viewer,
services: {},
keyRequired: false,
keyRejected: false,
context: {
getFilter: () => ({ pano: 'all', sinceMs: null }),
isActive: () => true,
notify() {},
getSurface: () => 'draped',
groundCaster: null,
meshSampler: null,
},
coverage: {
zoom: null,
kind: null,
tiles: new Map(),
stale: new Map(),
staleTimer: null,
pending: new Map(),
lastError: null,
holdUntil: 0,
holdTimer: null,
debounceTimer: null,
removeCameraListener: null,
terrainReady: Promise.resolve(),
hint: '',
},
sequence: { selectedId: null },
};
const asked = [];
const coverage = createLineCoverage({
state,
fetchTile: async (tile) => {
asked.push(tile);
if (fail) throw fail;
return {
sequences: [
{
id: 'line-1',
parts: [
[
[centre.lon - 0.001, centre.lat],
[centre.lon + 0.001, centre.lat],
],
],
capturedAt: 0,
isPano: false,
},
],
overview: [],
};
},
zoomFor:
zoomFor ??
((height) =>
Number.isFinite(height) && height <= 6_000
? { zoom: 14, kind: 'sequence' }
: null),
colors: { coverage: '#635bff', selected: '#00e5ff' },
pickPrefix: 'test:line:',
renderKey: 'test-coverage',
tuning: TUNING,
messages,
...(passes ? { passes } : {}),
});
t.after(() => {
coverage.clear();
coverage.resetErrors();
});
return { viewer, state, coverage, asked };
}
test('above the zoom range the provider’s own “zoom in” hint shows; without one, the generic hint', (t) => {
const tooHigh = 'zoom in to see test coverage';
const high = setup(t, { messages: { rateLimited: 'busy', tooHigh } });
high.viewer.view.height = 7_000;
high.coverage.refresh();
assert.equal(high.state.coverage.hint, tooHigh);
assert.equal(high.state.coverage.zoom, null);
assert.equal(high.asked.length, 0, 'nothing is asked from up there');
const generic = setup(t);
generic.viewer.view.height = 7_000;
generic.coverage.refresh();
assert.notEqual(generic.state.coverage.hint, tooHigh);
assert.match(generic.state.coverage.hint, /Point the camera/);
});
test('a provider’s passes() decides which lines are drawn and counted', async (t) => {
const all = setup(t, { passes: () => true });
all.coverage.refresh();
assert.ok(await until(() => all.state.coverage.tiles.size === 1));
assert.equal(all.coverage.sequenceCount(), 1, 'control: the line is drawn');
const none = setup(t, { passes: () => false });
none.coverage.refresh();
assert.ok(await until(() => none.state.coverage.tiles.size === 1));
assert.equal(none.coverage.sequenceCount(), 0, 'filtered out');
});
test('a refused key without its own message keeps the error it came with', async (t) => {
const refused = Object.assign(new Error('the source refused the key'), {
keyRejected: true,
});
const { state, coverage } = setup(t, { fail: refused });
coverage.refresh();
assert.ok(await until(() => state.keyRejected === true));
assert.equal(state.coverage.lastError, 'the source refused the key');
});
+35 -11
View File
@@ -1,11 +1,27 @@
import * as Cesium from 'cesium';
import { positionMarkerGlyph } from './glyphs.js';
import { positionMarkerGlyph, viewConeMarkerGlyph } from './glyphs.js';
import { COLORS, POSITION_PICK_ID } from './policy.js';
import { refineHeights } from './groundCast.js';
import { createHorizonCull } from './view.js';
const SPRITE_ID = 'street-level:marker';
function markerLook(style) {
if (style?.kind === 'view-cone') {
const color = style.color || COLORS.position;
return {
key: `view-cone:${color}`,
image: viewConeMarkerGlyph({ color }),
size: 48,
};
}
return {
key: 'pin',
image: positionMarkerGlyph({ color: COLORS.position }),
size: 40,
};
}
/** On-globe marker for the open image; in terrain mode it stands on bare earth. */
export function createMarker({ state, parts }) {
const { render, sprites } = state.services;
@@ -42,7 +58,8 @@ export function createMarker({ state, parts }) {
const dem = ground.groundAt(lon, lat);
if (dem === null) {
ground.prepare([[lon, lat]]).then((ready) => {
if (ready && last?.position === position) set(position, last.bearing);
if (ready && last?.position === position)
set(position, last.bearing, last.style);
});
return null;
}
@@ -53,14 +70,15 @@ export function createMarker({ state, parts }) {
// A new mesh sample may move the marker onto (or off) the road surface.
parts?.meshSampler?.onSampled(() => {
if (last && state.surface === 'terrain') set(last.position, last.bearing);
if (last && state.surface === 'terrain')
set(last.position, last.bearing, last.style);
});
/** Move (or create) the marker; a null position removes it. */
function set(position, bearing) {
/** Move (or create) the marker; null removes it. `style`: the provider's `marker`. */
function set(position, bearing, style = null) {
const collection = state.marker.collection;
if (!collection) return;
last = position ? { position, bearing } : null;
last = position ? { position, bearing, style } : null;
if (!position) {
collection.removeAll();
state.marker.billboard = null;
@@ -78,14 +96,20 @@ export function createMarker({ state, parts }) {
height === null
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE;
const look = markerLook(style);
if (state.marker.billboard && state.marker.look !== look.key) {
collection.remove?.(state.marker.billboard);
state.marker.billboard = null;
}
if (!state.marker.billboard) {
state.marker.look = look.key;
state.marker.billboard = collection.add({
id: POSITION_PICK_ID,
position: cartesian,
image: positionMarkerGlyph({ color: COLORS.position }),
imageId: 'sl-position',
width: 40,
height: 40,
image: look.image,
imageId: `sl-position:${look.key}`,
width: look.size,
height: look.size,
alignedAxis: Cesium.Cartesian3.UNIT_Z,
heightReference,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
@@ -117,7 +141,7 @@ export function createMarker({ state, parts }) {
/** Re-place the marker after the surface mode changes. */
function setSurface() {
if (last) set(last.position, last.bearing);
if (last) set(last.position, last.bearing, last.style);
}
return {
+26
View File
@@ -40,6 +40,7 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
collection: {
show: true,
add: (options) => ({ ...options }),
remove() {},
removeAll() {},
},
billboard: null,
@@ -187,3 +188,28 @@ test('draped mode clamps the marker to the globe whatever the ground says', () =
);
assert.equal(ground.prepares.length, 0);
});
test('a Street View photo gets a blue view cone; a Mapillary photo GEV\u2019s pin', () => {
const { state, marker } = setup();
const removed = [];
state.marker.collection.remove = (billboard) => removed.push(billboard);
const streetView = { kind: 'view-cone', color: '#4285f4' };
marker.set({ lon: -121.49, lat: 38.58 }, 45, streetView);
const cone = state.marker.billboard;
assert.equal(cone.imageId, 'sl-position:view-cone:#4285f4');
assert.equal(
cone.rotation,
-Cesium.Math.toRadians(45),
'it turns with the view',
);
// Dragging the panorama keeps the look and the billboard.
marker.set({ lon: -121.49, lat: 38.58 }, 90, streetView);
assert.equal(state.marker.billboard, cone);
// Re-placed for a surface change: still the cone.
marker.setSurface();
assert.equal(state.marker.billboard, cone);
// A Mapillary photo next (its provider has no marker): the pin.
marker.set({ lon: -121.49, lat: 38.58 }, 90, undefined);
assert.equal(state.marker.billboard.imageId, 'sl-position:pin');
assert.deepEqual(removed, [cone]);
});
+8
View File
@@ -7,6 +7,8 @@ export const POSITION_PICK_ID = 'sl:pos';
/** One colour per imagery source: chip, coverage lines, points, cones and legend. */
export const PROVIDER_COLORS = Object.freeze({
mapillary: '#05cb63',
google: '#4285f4',
kartaview: '#635bff',
});
/** Colours every provider shares: the selection highlight and the marker. */
@@ -35,3 +37,9 @@ export const FOLLOW_MAP_STACK_ID = 'photoreal';
/** Eye height above the sampled ground when the globe camera follows the viewer. */
export const FOLLOW_EYE_HEIGHT_M = 2.4;
/** Above this, a ground click is a misclick, not a street. */
export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
export const AIM_AT_GROUND_MESSAGE =
'Aim the middle of the view at a street to look for imagery there';
@@ -0,0 +1,184 @@
import { FOLLOW_MAP_STACK_ID } from '../../policy.js';
import { createMapsLoader } from './mapsLoader.js';
import { createGoogleViewer } from './viewer.js';
import {
COLORS,
GOOGLE_CREDIT_HTML,
GOOGLE_KEY_ID,
GOOGLE_LABEL,
GOOGLE_NAME,
GOOGLE_PROVIDER_ID,
GROUND_CLICK_HINT,
KEY_REJECTED_MESSAGE,
NEAREST_RADIUS_M,
NEAREST_TIMEOUT_MS,
NO_ANSWER_MESSAGE,
PICK_PREFIX,
} from './policy.js';
/** `promise`, unless it times out, `signal` aborts or Google refuses the key. */
function settleWithin(promise, { ms, signal, loader }) {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => finish(reject, new Error(NO_ANSWER_MESSAGE)),
ms,
);
const onAbort = () => finish(reject, signal.reason);
signal?.addEventListener('abort', onAbort, { once: true });
const stopAuthWatch = loader.onAuthFailure(() =>
finish(reject, new Error(KEY_REJECTED_MESSAGE)),
);
promise.then(
(value) => finish(resolve, value),
(error) => finish(reject, error),
);
function finish(settle, value) {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
stopAuthWatch();
settle(value);
}
});
}
/**
* Google Street View provider: Google's panorama viewer and nearest-panorama
* lookups. It draws no coverage; a click on a street opens the panorama.
* @returns {import('../../registry.js').StreetLevelProvider}
*/
export function createGoogleProvider({
getApiKey,
loader = createMapsLoader({ getApiKey }),
}) {
if (typeof getApiKey !== 'function')
throw new TypeError('Google Street View needs getApiKey');
return Object.freeze({
id: GOOGLE_PROVIDER_ID,
name: GOOGLE_NAME,
label: GOOGLE_LABEL,
requiresKeyId: GOOGLE_KEY_ID,
pickPrefix: PICK_PREFIX.root,
colors: COLORS,
credit: Object.freeze({ html: GOOGLE_CREDIT_HTML }),
groundClick: true,
// Google's terms forbid Street View beside non-Google maps.
requiresMapStack: FOLLOW_MAP_STACK_ID,
// Every panorama is billed to the Maps key.
defaultOn: false,
// All 360°, and no coverage for a date filter to thin.
filters: [],
marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }),
create(context) {
let keyRequired = !getApiKey();
let active = false;
let service = null;
async function getService() {
const lib = await loader.importLibrary('streetView');
service ||= new lib.StreetViewService();
return service;
}
const viewer = createGoogleViewer({
loader,
getService,
render: context.services?.render,
});
const stopAuthWatch = loader.onAuthFailure(() => {
// Switched off: its chip says so.
if (context.isActive?.())
context.actions.reportError(KEY_REJECTED_MESSAGE);
context.notify();
});
return {
async status() {
keyRequired = !getApiKey();
context.notify();
return { configured: !keyRequired };
},
init() {},
activate() {
active = true;
},
deactivate() {
active = false;
},
destroy() {
active = false;
stopAuthWatch();
},
refreshCoverage() {},
/** No filter applies to Street View (see `filters`). */
setFilter() {},
coverageStats() {
const keyRejected = loader.authFailed();
return {
count: 0,
zoom: null,
kind: null,
loading: false,
hint:
!active || keyRequired || keyRejected ? '' : GROUND_CLICK_HINT,
error: keyRejected ? KEY_REJECTED_MESSAGE : null,
keyRequired,
keyRejected,
};
},
handlePick: () => false,
/**
* Nearest Google-collected outdoor panorama, or null. The imagery
* filters do not apply (see `filters`).
*/
async nearestImage({ lat, lon }, { signal } = {}) {
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
signal?.throwIfAborted();
// Set once the caller has its answer (aborted, timed out, refused):
// a lookup still waiting on Google's script then asks nothing.
let settled = false;
const lookup = (async () => {
const lib = await loader.importLibrary('streetView');
await getService();
if (settled) return null;
const { GOOGLE, OUTDOOR } = lib.StreetViewSource;
try {
const { data } = await service.getPanorama({
location: { lat, lng: lon },
radius: NEAREST_RADIUS_M,
// Both: Google's own imagery, outdoors (sources intersect).
sources: [GOOGLE, OUTDOOR],
preference: lib.StreetViewPreference.NEAREST,
});
return data;
} catch (error) {
if (error?.code === 'ZERO_RESULTS') return null;
throw error;
}
})();
const data = await settleWithin(lookup, {
ms: NEAREST_TIMEOUT_MS,
signal,
loader,
}).finally(() => {
settled = true;
});
const panoId = data?.location?.pano;
if (!panoId) return null;
viewer.remember(panoId, data);
return panoId;
},
viewer,
};
},
});
}
@@ -0,0 +1,85 @@
/**
* Loads the Google Maps JavaScript API once per page, on first use. Google's
* `gm_authFailure` (key refused) reaches every `onAuthFailure` listener.
*/
const SCRIPT_ORIGIN = 'https://maps.googleapis.com';
const READY_CALLBACK = '__gevGoogleMapsReady';
/** The page's one script load, shared by every loader on it. */
const PAGE_LOAD = Symbol.for('gev.googleMapsLoad');
/**
* @param {{getApiKey: () => string|null|undefined, document?: Document, global?: object}} options
*/
export function createMapsLoader({
getApiKey,
document = globalThis.document,
global = globalThis,
}) {
let authFailed = false;
let authHandlerInstalled = false;
const authListeners = new Set();
/** Once per loader: a retry must not wrap its own handler. */
function installAuthHandler() {
if (authHandlerInstalled) return;
authHandlerInstalled = true;
const previous = global.gm_authFailure;
global.gm_authFailure = () => {
authFailed = true;
for (const listener of [...authListeners]) {
try {
listener();
} catch {
/* a listener's error is its own */
}
}
if (typeof previous === 'function') previous();
};
}
function load() {
const key = getApiKey?.();
if (!key)
return Promise.reject(new Error('GOOGLE_MAPS_API_KEY is not set'));
installAuthHandler();
if (global[PAGE_LOAD]) return global[PAGE_LOAD];
if (global.google?.maps?.importLibrary)
return Promise.resolve(global.google.maps);
const loading = new Promise((resolve, reject) => {
global[READY_CALLBACK] = () => {
delete global[READY_CALLBACK];
resolve(global.google.maps);
};
const script = document.createElement('script');
script.async = true;
script.src = `${SCRIPT_ORIGIN}/maps/api/js?${new URLSearchParams({
key,
v: 'weekly',
loading: 'async',
callback: READY_CALLBACK,
})}`;
script.onerror = () => {
if (global[PAGE_LOAD] === loading) delete global[PAGE_LOAD];
script.remove();
delete global[READY_CALLBACK];
reject(new Error('The Google Maps JavaScript API could not load'));
};
document.head.append(script);
});
global[PAGE_LOAD] = loading;
return loading;
}
return {
async importLibrary(name) {
const maps = await load();
return maps.importLibrary(name);
},
authFailed: () => authFailed,
onAuthFailure(listener) {
authListeners.add(listener);
return () => authListeners.delete(listener);
},
};
}
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMapsLoader } from './mapsLoader.js';
/** A document whose appended scripts the test loads or fails. */
function fakePage() {
const scripts = [];
const document = {
createElement: () => {
const script = {
remove() {
scripts.splice(scripts.indexOf(script), 1);
},
};
return script;
},
head: { append: (script) => scripts.push(script) },
};
const global = {};
const library = { name: 'streetView' };
/** Google's script arrived: it defines google.maps and calls back. */
const loadScript = () => {
global.google = {
maps: { importLibrary: async (name) => ({ ...library, name }) },
};
const callback = new URL(scripts.at(-1).src).searchParams.get('callback');
global[callback]();
};
return { document, global, scripts, loadScript };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
test('the API loads once, with the key, asynchronously, and hands out libraries', async () => {
const page = fakePage();
const loader = createMapsLoader({ getApiKey: () => 'k-1', ...page });
const first = loader.importLibrary('streetView');
const second = loader.importLibrary('streetView');
await settle();
assert.equal(page.scripts.length, 1, 'one script for concurrent imports');
const url = new URL(page.scripts[0].src);
assert.equal(
url.origin + url.pathname,
'https://maps.googleapis.com/maps/api/js',
);
assert.equal(url.searchParams.get('key'), 'k-1');
assert.equal(url.searchParams.get('loading'), 'async');
assert.equal(page.scripts[0].async, true);
page.loadScript();
assert.equal((await first).name, 'streetView');
assert.equal((await second).name, 'streetView');
const callback = url.searchParams.get('callback');
assert.equal(page.global[callback], undefined, 'the callback is cleaned up');
});
test('two loaders on one page share one script and both get the API', async () => {
const page = fakePage();
const first = createMapsLoader({ getApiKey: () => 'k', ...page });
const second = createMapsLoader({ getApiKey: () => 'k', ...page });
const one = first.importLibrary('streetView');
const two = second.importLibrary('streetView');
await settle();
assert.equal(page.scripts.length, 1, 'one script for the page');
page.loadScript();
assert.equal((await one).name, 'streetView');
assert.equal((await two).name, 'streetView');
assert.equal((await first.importLibrary('streetView')).name, 'streetView');
});
test('after a failed script, either loader on the page can retry it', async () => {
const page = fakePage();
const first = createMapsLoader({ getApiKey: () => 'k', ...page });
const second = createMapsLoader({ getApiKey: () => 'k', ...page });
const failed = first.importLibrary('streetView');
await settle();
page.scripts[0].onerror();
await assert.rejects(failed, /could not load/);
const retry = second.importLibrary('streetView');
const again = first.importLibrary('streetView');
await settle();
assert.equal(page.scripts.length, 1);
page.loadScript();
assert.equal((await retry).name, 'streetView');
assert.equal((await again).name, 'streetView');
});
test('without a key nothing loads', async () => {
const page = fakePage();
const loader = createMapsLoader({ getApiKey: () => null, ...page });
await assert.rejects(loader.importLibrary('streetView'), /not set/);
assert.equal(page.scripts.length, 0);
});
test('a script that fails to load can be retried', async () => {
const page = fakePage();
const loader = createMapsLoader({ getApiKey: () => 'k', ...page });
const failed = loader.importLibrary('streetView');
await settle();
page.scripts[0].onerror();
await assert.rejects(failed, /could not load/);
assert.equal(page.scripts.length, 0, 'the failed script is removed');
const retry = loader.importLibrary('streetView');
await settle();
assert.equal(page.scripts.length, 1);
page.loadScript();
assert.equal((await retry).name, 'streetView');
});
test("Google's refusal of the key reaches every listener and the page's own handler", async () => {
const page = fakePage();
let before = 0;
page.global.gm_authFailure = () => before++;
const loader = createMapsLoader({ getApiKey: () => 'k', ...page });
const heard = [];
const stop = loader.onAuthFailure(() => heard.push('a'));
loader.importLibrary('streetView');
await settle();
assert.equal(loader.authFailed(), false);
page.global.gm_authFailure();
assert.equal(loader.authFailed(), true);
assert.deepEqual(heard, ['a']);
assert.equal(before, 1, 'a handler installed earlier still runs');
stop();
page.global.gm_authFailure();
assert.deepEqual(heard, ['a']);
});
test('a retried load still reports a refusal once, and the page handler once', async () => {
const page = fakePage();
let before = 0;
page.global.gm_authFailure = () => before++;
const loader = createMapsLoader({ getApiKey: () => 'k', ...page });
const heard = [];
loader.onAuthFailure(() => heard.push('a'));
const failed = loader.importLibrary('streetView');
await settle();
page.scripts[0].onerror();
await assert.rejects(failed);
loader.importLibrary('streetView');
await settle();
page.global.gm_authFailure();
assert.deepEqual(heard, ['a']);
assert.equal(before, 1);
});
@@ -0,0 +1,65 @@
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
export { NEAREST_RADIUS_M } from '../../policy.js';
export const GOOGLE_PROVIDER_ID = 'google';
export const GOOGLE_NAME = 'Google Street View';
export const GOOGLE_LABEL = 'GOOGLE STREET VIEW';
/** The browser Maps key; Street View also needs the Maps JavaScript API on it. */
export const GOOGLE_KEY_ID = 'google-maps';
export const PICK_PREFIX = Object.freeze({ root: 'gsv:' });
export const COLORS = Object.freeze({
coverage: PROVIDER_COLORS.google,
selected: SHARED_COLORS.selected,
});
export const GOOGLE_CREDIT_HTML =
'Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>';
/** Shown when Google refuses the key (gm_authFailure). */
export const KEY_REJECTED_MESSAGE =
'Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer';
/** Past these, a nearest lookup, an open or a details lookup has failed. */
export const NEAREST_TIMEOUT_MS = 10_000;
export const OPEN_TIMEOUT_MS = 20_000;
export const DETAILS_TIMEOUT_MS = 10_000;
/** Pause after a failed details lookup before a view event can retry. */
export const DETAILS_RETRY_COOLDOWN_MS = 5_000;
export const NO_ANSWER_MESSAGE =
'Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY';
export const GROUND_CLICK_HINT = 'click a street to open Street View';
/** Deep link to a panorama on Google Maps (Maps URLs). */
export function streetViewUrl(panoId, { heading = 0, pitch = 0 } = {}) {
const params = new URLSearchParams({
api: '1',
map_action: 'pano',
pano: String(panoId),
heading: String(Math.round(heading)),
pitch: String(Math.round(pitch)),
});
return `https://www.google.com/maps/@?${params}`;
}
/** Epoch ms for a Street View image date ('YYYY-MM'), or null. */
export function imageDateMs(imageDate) {
const match = /^(\d{4})-(\d{2})/.exec(String(imageDate ?? ''));
if (!match) return null;
const month = Number(match[2]);
if (month < 1 || month > 12) return null;
return Date.UTC(Number(match[1]), month - 1, 1);
}
/** The photographer from a copyright line ('© 2024 Google' → 'Google'). */
export function creatorFrom(copyright) {
const text = String(copyright ?? '')
.replace(/^©\s*(\d{4}\s+)?/, '')
.replace(/^From the Owner,\s*Photo by:\s*/i, '')
.trim();
return text || null;
}
@@ -0,0 +1,293 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGoogleProvider } from './index.js';
import {
GROUND_CLICK_HINT,
KEY_REJECTED_MESSAGE,
creatorFrom,
imageDateMs,
streetViewUrl,
} from './policy.js';
import { PROVIDER_COLORS } from '../../policy.js';
import { requiresKeyIdFor, validateProviders } from '../../registry.js';
import { createMapillaryProvider } from '../mapillary/index.js';
import {
fakeHost,
fakeMapsLoader,
fakeStreetViewLibrary,
} from '../../../../testSupport/googleStreetViewFakes.mjs';
const PANORAMAS = {
fresh: { lat: 38.5816, lng: -121.4944, imageDate: '2025-06' },
old: { lat: 38.6, lng: -121.5, imageDate: '2009-03' },
};
function setup({ key = 'browser-key', nearest = () => 'fresh' } = {}) {
const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS, nearest });
const loader = fakeMapsLoader(fake.library);
let apiKey = key;
const def = createGoogleProvider({ getApiKey: () => apiKey, loader });
const reported = [];
let filter = { pano: 'all', sinceMs: null };
let active = false;
const context = {
services: {},
getFilter: () => filter,
isActive: () => active,
notify() {},
actions: { reportError: (message) => reported.push(message) },
};
const created = def.create(context);
// The core switches it on and off; mirror that for isActive().
const instance = {
...created,
activate() {
active = true;
created.activate();
},
deactivate() {
active = false;
created.deactivate();
},
};
return {
def,
instance,
fake,
loader,
reported,
setFilter: (next) => (filter = next),
setKey: (next) => (apiKey = next),
};
}
test('the provider registers beside Mapillary, off by default, gated on the browser Maps key', () => {
const { def } = setup();
assert.equal(def.id, 'google');
assert.equal(def.label, 'GOOGLE STREET VIEW');
assert.deepEqual(def.filters, [], 'no imagery filter applies');
assert.deepEqual(def.marker, { kind: 'view-cone', color: '#4285f4' });
assert.equal(def.requiresKeyId, 'google-maps');
assert.equal(def.defaultOn, false, 'every panorama is billed');
assert.equal(def.groundClick, true);
assert.equal(def.colors.coverage, PROVIDER_COLORS.google);
assert.match(def.credit.html, /Street View imagery © .*Google/);
const mapillary = createMapillaryProvider({
source: {
getStatus() {},
getTile() {},
getSequenceImages() {},
nearestImages() {},
},
});
const both = validateProviders([mapillary, def]);
assert.equal(requiresKeyIdFor(both), null, 'each chip gates on its own key');
assert.throws(() => createGoogleProvider({}), /getApiKey/);
});
test('status follows the browser Maps key', async () => {
const { instance, setKey } = setup({ key: '' });
assert.deepEqual(await instance.status(), { configured: false });
assert.equal(instance.coverageStats().keyRequired, true);
setKey('browser-key');
assert.deepEqual(await instance.status(), { configured: true });
assert.equal(instance.coverageStats().keyRequired, false);
});
test('the nearest lookup asks Google for its own outdoor imagery within 50 m', async () => {
const { instance, fake } = setup();
assert.equal(
await instance.nearestImage({ lat: 38.58, lon: -121.49 }),
'fresh',
);
const [request] = fake.lookups;
assert.deepEqual(request.location, { lat: 38.58, lng: -121.49 });
assert.equal(request.radius, 50);
assert.deepEqual(request.sources, ['google', 'outdoor']);
assert.equal(request.preference, 'nearest');
});
test('no panorama nearby is null; the imagery filters (Mapillary\u2019s) do not apply', async () => {
const none = setup({ nearest: () => null });
assert.equal(await none.instance.nearestImage({ lat: 0, lon: 0 }), null);
const { instance, setFilter } = setup({ nearest: () => 'old' }); // 2009
for (const filter of [
{ pano: 'all', sinceMs: Date.UTC(2020, 0, 1) },
{ pano: 'flat', sinceMs: null },
{ pano: 'pano', sinceMs: Date.UTC(2025, 0, 1) },
]) {
setFilter(filter);
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), 'old');
}
assert.equal(instance.coverageStats().hint, '', 'off: no hint');
instance.activate();
setFilter({ pano: 'flat', sinceMs: null });
assert.equal(instance.coverageStats().hint, GROUND_CLICK_HINT);
});
test('an aborted lookup gives up before asking Google', async () => {
const { instance } = setup();
const controller = new AbortController();
controller.abort();
await assert.rejects(
instance.nearestImage({ lat: 0, lon: 0 }, { signal: controller.signal }),
{ name: 'AbortError' },
);
});
test('a key Google refuses reads KEY REJECTED, shows why, and stops lookups', async () => {
const { instance, loader, reported } = setup();
instance.activate();
assert.equal(instance.coverageStats().hint, GROUND_CLICK_HINT);
loader.refuseKey();
const stats = instance.coverageStats();
assert.equal(stats.keyRejected, true);
assert.equal(stats.error, KEY_REJECTED_MESSAGE);
assert.equal(stats.hint, '', 'no "click a street" hint over a dead key');
assert.deepEqual(reported, [KEY_REJECTED_MESSAGE]);
await assert.rejects(instance.nearestImage({ lat: 0, lon: 0 }), {
message: KEY_REJECTED_MESSAGE,
});
instance.destroy();
assert.equal(loader.listenerCount(), 0, 'destroy stops listening');
});
test('the hint shows only while the provider is on', () => {
const { instance } = setup();
assert.equal(instance.coverageStats().hint, '');
instance.activate();
assert.equal(instance.coverageStats().hint, GROUND_CLICK_HINT);
instance.deactivate();
assert.equal(instance.coverageStats().hint, '');
});
test('image dates, photographers and deep links read the way Google writes them', () => {
assert.equal(imageDateMs('2024-05'), Date.UTC(2024, 4, 1));
assert.equal(imageDateMs('2024-13'), null);
assert.equal(imageDateMs(undefined), null);
assert.equal(creatorFrom('© 2024 Google'), 'Google');
assert.equal(creatorFrom('From the Owner, Photo by: Ada'), 'Ada');
assert.equal(creatorFrom(''), null);
assert.equal(
streetViewUrl('a b', { heading: 90.4, pitch: -9.6 }),
'https://www.google.com/maps/@?api=1&map_action=pano&pano=a+b&heading=90&pitch=-10',
);
});
test('a lookup Google never answers fails after the timeout, and a refused key fails it at once', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { NEAREST_TIMEOUT_MS, NO_ANSWER_MESSAGE } = await import('./policy.js');
const { instance, fake, loader } = setup();
fake.library.StreetViewService.prototype.getPanorama = () =>
new Promise(() => {});
const hung = instance.nearestImage({ lat: 0, lon: 0 });
await Promise.resolve();
await Promise.resolve();
t.mock.timers.tick(NEAREST_TIMEOUT_MS);
await assert.rejects(hung, { message: NO_ANSWER_MESSAGE });
const refused = instance.nearestImage({ lat: 0, lon: 0 });
await Promise.resolve();
loader.refuseKey();
await assert.rejects(refused, { message: KEY_REJECTED_MESSAGE });
assert.equal(
loader.listenerCount(),
1,
'only the provider’s own watch is left',
);
});
test('aborting a lookup in flight gives up at once', async () => {
const { instance, fake } = setup();
fake.library.StreetViewService.prototype.getPanorama = () =>
new Promise(() => {});
const controller = new AbortController();
const lookup = instance.nearestImage(
{ lat: 0, lon: 0 },
{ signal: controller.signal },
);
controller.abort();
await assert.rejects(lookup, { name: 'AbortError' });
});
test('a lookup cancelled while Google\u2019s script loads asks Google nothing', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { NEAREST_TIMEOUT_MS } = await import('./policy.js');
const fake = fakeStreetViewLibrary({
panoramas: PANORAMAS,
nearest: () => 'fresh',
});
const loader = fakeMapsLoader(fake.library, { deferred: true });
const instance = createGoogleProvider({
getApiKey: () => 'browser-key',
loader,
}).create({
services: {},
getFilter: () => ({ pano: 'all', sinceMs: null }),
isActive: () => true,
notify() {},
actions: { reportError() {} },
});
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
// Aborted, then timed out, while the script still loads.
const controller = new AbortController();
const aborted = instance.nearestImage(
{ lat: 0, lon: 0 },
{ signal: controller.signal },
);
controller.abort();
await assert.rejects(aborted, { name: 'AbortError' });
const expired = instance.nearestImage({ lat: 0, lon: 0 });
t.mock.timers.tick(NEAREST_TIMEOUT_MS);
await assert.rejects(expired, /did not answer/);
// The script arrives: a current lookup still gets its answer.
const current = instance.nearestImage({ lat: 0, lon: 0 });
loader.release();
await flush();
assert.equal(await current, 'fresh');
assert.equal(fake.lookups.length, 1, 'only the current lookup asked Google');
});
test('a Google outage is an error, not "no imagery here"', async () => {
const { instance, fake } = setup();
fake.library.StreetViewService.prototype.getPanorama = () =>
Promise.reject(
Object.assign(new Error('UNKNOWN_ERROR'), {
code: 'UNKNOWN_ERROR',
endpoint: 'STREETVIEW_GET_PANORAMA',
}),
);
await assert.rejects(instance.nearestImage({ lat: 0, lon: 0 }), {
code: 'UNKNOWN_ERROR',
});
});
test('a refusal while the provider is off reports no error over another photo', () => {
const { instance, loader, reported } = setup();
loader.refuseKey();
assert.deepEqual(reported, []);
assert.equal(
instance.coverageStats().keyRejected,
true,
'its chip still says so',
);
});
test('the panorama a lookup found is dated without a second lookup', async () => {
const { instance, fake } = setup();
await instance.nearestImage({ lat: 38.58, lon: -121.49 });
const host = fakeHost();
const poses = [];
instance.viewer.onPose((pose) => poses.push(pose));
await instance.viewer.mount(host);
await instance.viewer.open('fresh');
assert.equal(poses.at(-1).capturedAt, Date.UTC(2025, 5, 1));
assert.deepEqual(
fake.lookups.filter((request) => request.pano),
[],
'no getPanorama({pano}) for a panorama the lookup already described',
);
});
@@ -0,0 +1,286 @@
import {
DETAILS_RETRY_COOLDOWN_MS,
DETAILS_TIMEOUT_MS,
GOOGLE_PROVIDER_ID,
KEY_REJECTED_MESSAGE,
NO_ANSWER_MESSAGE,
OPEN_TIMEOUT_MS,
creatorFrom,
imageDateMs,
streetViewUrl,
} from './policy.js';
const PANORAMA_OPTIONS = Object.freeze({
visible: false,
// The caption has the address; Google's card covers a docked panorama.
addressControl: false,
linksControl: true,
panControl: true,
zoomControl: true,
clickToGo: true,
showRoadLabels: true,
// The panel has EXPAND and ×.
fullscreenControl: false,
enableCloseButton: false,
motionTracking: false,
motionTrackingControl: false,
});
/**
* Viewer adapter over Google's StreetViewPanorama: emits a pose for every
* panorama and view change, including steps along Google's arrows.
* @param {{loader: ReturnType<import('./mapsLoader.js').createMapsLoader>, getService?: () => Promise<object>, render?: object}} options
* @returns {import('../../registry.js').ViewerAdapter & {remember: Function}}
*/
export function createGoogleViewer({ loader, getService, render } = {}) {
let library = null;
let panorama = null;
let element = null;
let host = null;
let service = null;
let pendingOpen = null;
let cancelShown = null;
/** Bumped by `unmount`: a construction it overtook builds nothing. */
let generation = 0;
let subscriptions = [];
const described = new Map();
/** Panorama id → its lookup in flight; a late answer to a dropped one is ignored. */
const lookingUp = new Map();
const retryAfter = new Map();
const listeners = new Set();
function requestRender() {
render?.governorRequestRender?.('google-street-view');
}
async function ensureLibrary() {
library ||= await loader.importLibrary('streetView');
return library;
}
function emit(pose) {
for (const listener of [...listeners]) {
try {
listener(pose);
} catch {
/* listener errors are the core's to log */
}
}
}
function metaFrom(data) {
return {
capturedAt: imageDateMs(data?.imageDate),
creator: creatorFrom(data?.copyright),
title: data?.location?.description || null,
};
}
/**
* Look up a panorama's date, photographer and address once, then re-emit
* the pose. A failed or timed-out lookup can retry after the cooldown.
*/
async function describe(panoId) {
if (described.has(panoId) || lookingUp.has(panoId)) return;
if (Date.now() < (retryAfter.get(panoId) ?? 0)) return;
const lookup = { timer: null };
lookingUp.set(panoId, lookup);
const current = () => lookingUp.get(panoId) === lookup;
try {
const { data } = await new Promise((resolve, reject) => {
lookup.timer = setTimeout(
() => reject(new Error(NO_ANSWER_MESSAGE)),
DETAILS_TIMEOUT_MS,
);
(async () => {
service ||= getService
? await getService()
: new (await ensureLibrary()).StreetViewService();
return service.getPanorama({ pano: panoId });
})().then(resolve, reject);
});
if (!current()) return;
described.set(panoId, metaFrom(data));
retryAfter.delete(panoId);
} catch {
if (current())
retryAfter.set(panoId, Date.now() + DETAILS_RETRY_COOLDOWN_MS);
return;
} finally {
clearTimeout(lookup.timer);
if (current()) lookingUp.delete(panoId);
}
publishPose();
}
function releaseLookups() {
for (const lookup of lookingUp.values()) clearTimeout(lookup.timer);
lookingUp.clear();
}
function publishPose() {
const panoId = panorama?.getPano?.();
const latLng = panorama?.getPosition?.();
if (!panoId || !latLng || pendingOpen === null) return;
const meta = described.get(panoId);
if (!meta) describe(panoId);
const pov = panorama.getPov() || {};
emit({
providerId: GOOGLE_PROVIDER_ID,
imageId: panoId,
position: { lon: latLng.lng(), lat: latLng.lat() },
bearing: Number.isFinite(pov.heading) ? pov.heading : null,
tilt: Number.isFinite(pov.pitch) ? pov.pitch : 0,
altitude: null,
isPano: true,
capturedAt: meta?.capturedAt ?? null,
capturedAtPrecision: 'month',
creator: meta?.creator ?? null,
title: meta?.title ?? null,
sequenceId: null,
externalUrl: streetViewUrl(panoId, {
heading: pov.heading || 0,
pitch: pov.pitch || 0,
}),
});
requestRender();
}
async function ensurePanorama(target) {
if (panorama && host === target) return panorama;
const built = generation;
const { StreetViewPanorama } = await ensureLibrary();
if (built !== generation) throw new Error('Street View was unmounted');
if (panorama && host === target) return panorama;
destroyPanorama();
element = target.ownerDocument.createElement('div');
element.className = 'sl-google-panorama';
element.style.cssText = 'position:absolute;inset:0;';
target.append(element);
host = target;
panorama = new StreetViewPanorama(element, PANORAMA_OPTIONS);
// Not pano_changed: it can fire with the old panorama's position.
subscriptions = ['position_changed', 'pov_changed'].map((name) =>
panorama.addListener(name, () => publishPose()),
);
return panorama;
}
function destroyPanorama() {
for (const subscription of subscriptions) subscription.remove();
subscriptions = [];
try {
panorama?.setVisible(false);
} catch {
/* already gone */
}
element?.remove();
panorama = null;
element = null;
host = null;
}
/**
* Resolves once `panoId` shows (OK status, or a new position when the
* status stays OK); rejects on no imagery, a refused key or no answer.
*/
function shown(instance, panoId) {
if (instance.getPano() === panoId && instance.getStatus() === 'OK')
return Promise.resolve();
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => finish(reject, new Error(NO_ANSWER_MESSAGE)),
OPEN_TIMEOUT_MS,
);
const stopAuthWatch = loader.onAuthFailure(() =>
finish(reject, new Error(KEY_REJECTED_MESSAGE)),
);
cancelShown = () => finish(resolve);
// At a position event the status may still be the last panorama's failure.
const onStatus = () => {
if (pendingOpen !== panoId) return finish(resolve);
const status = instance.getStatus();
if (status === 'OK') finish(resolve);
else if (status)
finish(
reject,
new Error('Street View has no imagery for this place'),
);
};
const onPosition = () => {
if (pendingOpen !== panoId || instance.getStatus() === 'OK')
finish(resolve);
};
const subscriptions = [
instance.addListener('status_changed', onStatus),
instance.addListener('position_changed', onPosition),
];
function finish(settle, value) {
cancelShown = null;
clearTimeout(timer);
for (const subscription of subscriptions) subscription.remove();
stopAuthWatch();
settle(value);
}
});
}
return {
async mount(target) {
if (!target) throw new Error('Street Level viewer has no host element');
await ensurePanorama(target);
},
async open(imageId) {
const panoId = String(imageId);
if (!host) throw new Error('Street View is not mounted');
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
pendingOpen = panoId;
const instance = await ensurePanorama(host);
if (pendingOpen !== panoId) return;
const ready = shown(instance, panoId);
instance.setPano(panoId);
instance.setVisible(true);
await ready;
if (pendingOpen !== panoId) return;
publishPose();
},
close() {
pendingOpen = null;
cancelShown?.();
try {
panorama?.setVisible(false);
} catch {
/* torn down */
}
},
unmount() {
pendingOpen = null;
cancelShown?.();
releaseLookups();
generation++;
destroyPanorama();
},
resize() {
if (!panorama) return;
try {
globalThis.google?.maps?.event?.trigger(panorama, 'resize');
} catch {
/* no-op */
}
},
onPose(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
/** Seed the details cache from a nearest lookup's answer. */
remember(panoId, data) {
if (panoId && data) described.set(String(panoId), metaFrom(data));
},
};
}
@@ -0,0 +1,405 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGoogleViewer } from './viewer.js';
import { DETAILS_RETRY_COOLDOWN_MS, DETAILS_TIMEOUT_MS } from './policy.js';
import {
fakeHost,
fakeMapsLoader,
fakeStreetViewLibrary,
} from '../../../../testSupport/googleStreetViewFakes.mjs';
const PANORAMAS = {
'pano-a': {
lat: 38.5816,
lng: -121.4944,
imageDate: '2024-05',
copyright: '© 2024 Google',
description: '10th St',
},
'pano-b': {
lat: 38.5817,
lng: -121.4944,
imageDate: '2019-08',
copyright: 'From the Owner, Photo by: Ada',
},
};
function setup() {
const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS });
const loader = fakeMapsLoader(fake.library);
const viewer = createGoogleViewer({ loader });
const poses = [];
viewer.onPose((pose) => poses.push(pose));
const host = fakeHost();
return { fake, loader, viewer, poses, host };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
test('opening a panorama shows it and emits a provider-neutral pose', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
assert.equal(host.children.length, 1, 'renders in its own element');
const [panorama] = fake.built;
assert.equal(panorama.options.fullscreenControl, false);
assert.equal(panorama.options.addressControl, false, 'the caption has it');
assert.equal(panorama.options.enableCloseButton, false);
assert.equal(panorama.visible, false, 'nothing shows before an open');
await viewer.open('pano-a');
assert.equal(panorama.visible, true);
await settle(); // the pose is dated once its lookup answers
const pose = poses.at(-1);
assert.deepEqual(
{
providerId: pose.providerId,
imageId: pose.imageId,
position: pose.position,
isPano: pose.isPano,
capturedAt: pose.capturedAt,
capturedAtPrecision: pose.capturedAtPrecision,
creator: pose.creator,
title: pose.title,
},
{
providerId: 'google',
imageId: 'pano-a',
position: { lon: -121.4944, lat: 38.5816 },
isPano: true,
capturedAt: Date.UTC(2024, 4, 1),
capturedAtPrecision: 'month',
creator: 'Google',
title: '10th St',
},
);
assert.match(pose.externalUrl, /map_action=pano&pano=pano-a/);
});
test('dragging the view and stepping along an arrow both emit poses; the date is looked up once per panorama', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
await viewer.open('pano-a');
const [panorama] = fake.built;
panorama.setPov({ heading: 90, pitch: -10 });
await settle();
assert.deepEqual(
[poses.at(-1).bearing, poses.at(-1).tilt],
[90, -10],
'the view follows the drag',
);
// Google's own arrow: the panorama changes without an open.
panorama.setPano('pano-b');
await settle();
await settle();
assert.equal(poses.at(-1).imageId, 'pano-b');
assert.equal(poses.at(-1).creator, 'Ada');
const dateLookups = fake.lookups.filter((request) => request.pano);
assert.deepEqual(
dateLookups.map((request) => request.pano),
['pano-a', 'pano-b'],
);
});
test('a panorama Google has no imagery for fails the open', async () => {
const { viewer, host } = setup();
await viewer.mount(host);
await assert.rejects(viewer.open('gone'), /no imagery/);
});
test('a closed panorama is hidden and its late view changes emit nothing', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
await viewer.open('pano-a');
const [panorama] = fake.built;
viewer.close();
assert.equal(panorama.visible, false);
const count = poses.length;
panorama.setPov({ heading: 180, pitch: 0 });
await settle();
assert.equal(poses.length, count);
});
test('an open overtaken by a newer one only shows the newer panorama', async () => {
const { viewer, poses, host } = setup();
await viewer.mount(host);
const first = viewer.open('pano-a');
const second = viewer.open('pano-b');
await Promise.all([first, second]);
assert.deepEqual(
[...new Set(poses.map((pose) => pose.imageId))].at(-1),
'pano-b',
);
assert.ok(!poses.some((pose) => pose.imageId === 'pano-a'));
});
test('unmounting removes the element and every listener', async () => {
const { fake, viewer, host } = setup();
await viewer.mount(host);
const [panorama] = fake.built;
assert.ok(panorama.listenerCount() > 0);
viewer.unmount();
assert.equal(host.children.length, 0);
assert.equal(panorama.listenerCount(), 0);
await assert.rejects(viewer.open('pano-a'), /not mounted/);
});
test('there is no prewarm: Google\u2019s script loads on first use only, never on another map', () => {
const { viewer, loader } = setup();
assert.equal(viewer.prewarm, undefined);
assert.deepEqual(loader.imports, []);
});
test('a key Google refuses fails the open at once, not after the timeout', async () => {
const { fake, loader, viewer, host } = setup();
await viewer.mount(host);
const [panorama] = fake.built;
// Google refuses the key while the panorama loads; it never reports OK.
panorama.setPano = (id) => {
panorama.pano = id;
queueMicrotask(() => loader.refuseKey());
};
await assert.rejects(viewer.open('pano-a'), /Maps JavaScript API is enabled/);
await assert.rejects(viewer.open('pano-b'), /Maps JavaScript API is enabled/);
assert.equal(loader.listenerCount(), 0, 'no auth listener is left behind');
});
test('a viewer unmounted while the library loads builds no panorama (none is billed)', async () => {
const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS });
const loader = fakeMapsLoader(fake.library, { deferred: true });
const viewer = createGoogleViewer({ loader });
const host = fakeHost();
const mounting = viewer.mount(host);
viewer.unmount();
loader.release();
await assert.rejects(mounting, /unmounted/);
assert.equal(fake.built.length, 0);
assert.equal(host.children.length, 0);
});
test('a date that arrives after the photo was closed emits nothing', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
await viewer.open('pano-a');
const [panorama] = fake.built;
let answer;
fake.library.StreetViewService.prototype.getPanorama = () =>
new Promise((resolve) => {
answer = resolve;
});
panorama.setPano('pano-b'); // Google's arrow; its date is still unknown
await settle();
assert.equal(poses.at(-1).imageId, 'pano-b', 'the step shows at once');
assert.equal(
poses.at(-1).capturedAt,
null,
'undated until the lookup answers',
);
viewer.close();
const count = poses.length;
answer({ data: { imageDate: '2019-08' } });
await settle();
assert.equal(poses.length, count);
});
test('an open never waits on the date lookup', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
fake.library.StreetViewService.prototype.getPanorama = () =>
new Promise(() => {}); // the metadata never answers
await viewer.open('pano-a');
assert.equal(poses.at(-1).imageId, 'pano-a');
viewer.unmount();
});
test('closing while a panorama loads settles the open and drops its listeners', async () => {
const { fake, loader, viewer, host } = setup();
await viewer.mount(host);
const [panorama] = fake.built;
const before = panorama.listenerCount();
panorama.setPano = (id) => {
panorama.pano = id; // still loading
};
const opening = viewer.open('pano-a');
await settle();
viewer.close();
await opening;
assert.equal(panorama.listenerCount(), before);
assert.equal(loader.listenerCount(), 0);
});
test('a second open whose status stays OK still resolves, on the new position', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
await viewer.open('pano-a');
assert.equal(fake.built[0].getStatus(), 'OK');
await viewer.open('pano-b'); // OK → OK: no status_changed
assert.equal(poses.at(-1).imageId, 'pano-b');
});
test('Google answering with a newer id for the place shows that id', async () => {
const fake = fakeStreetViewLibrary({
panoramas: PANORAMAS,
substitutes: { 'old-id': 'pano-a' },
});
const viewer = createGoogleViewer({ loader: fakeMapsLoader(fake.library) });
const poses = [];
viewer.onPose((pose) => poses.push(pose));
await viewer.mount(fakeHost());
await viewer.open('old-id');
assert.equal(poses.at(-1).imageId, 'pano-a');
});
test('a panorama that never shows fails after the timeout and leaves no listener', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { OPEN_TIMEOUT_MS, NO_ANSWER_MESSAGE } = await import('./policy.js');
const { fake, loader, viewer, host } = setup();
await viewer.mount(host);
const [panorama] = fake.built;
const before = panorama.listenerCount();
panorama.setPano = (id) => {
panorama.pano = id; // Google never answers
};
const opening = viewer.open('pano-a');
await Promise.resolve();
await Promise.resolve();
t.mock.timers.tick(OPEN_TIMEOUT_MS);
await assert.rejects(opening, { message: NO_ANSWER_MESSAGE });
assert.equal(panorama.listenerCount(), before);
assert.equal(loader.listenerCount(), 0);
});
test('failed details lookups cool down through repeated drags and retry after the pause', async (t) => {
let now = 1_000;
t.mock.method(Date, 'now', () => now);
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
let failures = 2;
let calls = 0;
fake.library.StreetViewService.prototype.getPanorama = function (request) {
calls++;
if (failures-- > 0) return Promise.reject(new Error('offline'));
return getPanorama.call(this, request);
};
await viewer.open('pano-a');
assert.equal(poses.at(-1).capturedAt, null, 'undated while it failed');
assert.equal(calls, 1);
for (let attempt = 0; attempt < 2; attempt++) {
for (let heading = 0; heading < 20; heading++) {
fake.built[0].setPov({ heading, pitch: 0 });
await settle();
}
assert.equal(calls, attempt + 1, 'drags do not retry during the pause');
assert.equal(poses.at(-1).bearing, 19, 'pose updates still follow drags');
now += DETAILS_RETRY_COOLDOWN_MS - 1;
fake.built[0].setPov({ heading: 20, pitch: 0 });
await settle();
assert.equal(calls, attempt + 1, 'still paused just before the deadline');
now++;
fake.built[0].setPov({ heading: 21, pitch: 0 });
await settle();
assert.equal(calls, attempt + 2, 'one retry at the deadline');
}
assert.equal(poses.at(-1).capturedAt, Date.UTC(2024, 4, 1));
now += DETAILS_RETRY_COOLDOWN_MS;
fake.built[0].setPov({ heading: 10, pitch: 0 });
await settle();
assert.equal(calls, 3, 'successful details stay cached');
});
test('a failed panorama does not delay details for another panorama', async (t) => {
t.mock.method(Date, 'now', () => 1_000);
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
const requests = [];
fake.library.StreetViewService.prototype.getPanorama = function (request) {
requests.push(request.pano);
if (request.pano === 'pano-a') return Promise.reject(new Error('offline'));
return getPanorama.call(this, request);
};
await viewer.open('pano-a');
fake.built[0].setPano('pano-b');
await settle();
assert.deepEqual(requests, ['pano-a', 'pano-b']);
assert.equal(poses.at(-1).creator, 'Ada');
fake.built[0].setPano('pano-a');
await settle();
assert.deepEqual(
requests,
['pano-a', 'pano-b'],
'returning respects the pause',
);
});
test('a retry after a failed open is not failed by the old panorama\u2019s status', async () => {
const { viewer, poses, host } = setup();
await viewer.mount(host);
await assert.rejects(viewer.open('gone'), /no imagery/);
// The new panorama moves first; its OK status follows.
await viewer.open('pano-a');
assert.equal(poses.at(-1).imageId, 'pano-a');
});
test('a details lookup that never answers times out, cools down and retries', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 1_000 });
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
const requests = [];
let hang = true;
fake.library.StreetViewService.prototype.getPanorama = function (request) {
requests.push(request.pano);
return hang ? new Promise(() => {}) : getPanorama.call(this, request);
};
const opening = viewer.open('pano-a');
await flush();
await opening;
assert.deepEqual(requests, ['pano-a']);
fake.built[0].setPov({ heading: 5, pitch: 0 });
await flush();
assert.deepEqual(requests, ['pano-a'], 'one lookup in flight at a time');
t.mock.timers.tick(DETAILS_TIMEOUT_MS);
await flush();
hang = false;
fake.built[0].setPov({ heading: 6, pitch: 0 });
await flush();
assert.deepEqual(requests, ['pano-a'], 'the cooldown follows a timeout');
t.mock.timers.tick(DETAILS_RETRY_COOLDOWN_MS);
fake.built[0].setPov({ heading: 7, pitch: 0 });
await flush();
assert.deepEqual(requests, ['pano-a', 'pano-a']);
assert.equal(poses.at(-1).capturedAt, Date.UTC(2024, 4, 1));
assert.equal(poses.at(-1).bearing, 7);
});
test('a remount looks up again a panorama whose lookup hung', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
const requests = [];
let answerHung;
fake.library.StreetViewService.prototype.getPanorama = function (request) {
requests.push(request.pano);
if (requests.length === 1)
return new Promise((resolve) => {
answerHung = () => resolve(getPanorama.call(this, request));
});
return getPanorama.call(this, request);
};
await viewer.open('pano-b');
viewer.unmount();
await viewer.mount(host);
await viewer.open('pano-b');
await settle();
assert.deepEqual(requests, ['pano-b', 'pano-b']);
assert.equal(poses.at(-1).creator, 'Ada');
// The hung answer arriving now is for a viewer that is gone: dropped.
const count = poses.length;
answerHung();
await settle();
assert.equal(poses.length, count);
});
@@ -0,0 +1,176 @@
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;
/**
* 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
* @returns {Array<Array<[number, number]>>}
*/
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);
const lat = Number(point?.lat);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
const last = points[points.length - 1];
if (last && last[0] === lon && last[1] === lat) continue;
points.push([lon, lat]);
}
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) => {
const at = key(segment[0]);
const list = startingAt.get(at);
if (list) list.push(index);
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 = 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));
}
sequences.push({
id: `${z}/${x}/${y}/${sequences.length}`,
parts: [chain],
capturedAt: 0,
isPano: false,
});
});
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?.();
}
}
@@ -0,0 +1,187 @@
import assert from 'node:assert/strict';
import test from 'node:test';
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 };
test('segments that meet end to start chain into one line', () => {
const { sequences, overview } = decodeCoverageTile(
{
lines: [
{ track: [at(1, 1), at(2, 1)], matched: '1' },
{ track: [at(5, 5), at(6, 6)], matched: '1' },
{ track: [at(2, 1), at(3, 1), at(4, 2)], matched: '1' },
],
},
TILE,
);
assert.deepEqual(overview, []);
assert.equal(sequences.length, 2);
assert.deepEqual(sequences[0].parts, [
[
[1, 1],
[2, 1],
[3, 1],
[4, 2],
],
]);
assert.equal(sequences[0].id, '14/4412/6061/0');
assert.equal(sequences[1].id, '14/4412/6061/1');
// Nothing to date or project: every line passes as flat and undated.
assert.equal(sequences[0].capturedAt, 0);
assert.equal(sequences[0].isPano, false);
});
test('a junction continues one way only, and every segment is drawn exactly once', () => {
const { sequences } = decodeCoverageTile(
{
lines: [
{ track: [at(0, 0), at(1, 0)] },
{ track: [at(1, 0), at(2, 0)] },
{ track: [at(1, 0), at(1, 1)] },
// A loop back to its own start must end, not spin.
{ track: [at(9, 9), at(9, 8)] },
{ track: [at(9, 8), at(9, 9)] },
],
},
TILE,
);
const points = sequences.reduce((sum, s) => sum + s.parts[0].length, 0);
assert.equal(sequences.length, 3);
// 5 segments of 2 points, 2 joins in chains: 10 - 2 = 8 points.
assert.equal(points, 8);
});
test('bad points, repeats and one-point tracks are skipped; an empty tile has no lines', () => {
const { sequences } = decodeCoverageTile(
{
lines: [
{ track: [at(1, 1), at(1, 1), { lat: 'x', lng: '2' }, at(2, 2)] },
{ track: [at(3, 3)] },
{ track: null },
null,
],
},
TILE,
);
assert.equal(sequences.length, 1);
assert.deepEqual(sequences[0].parts[0], [
[1, 1],
[2, 2],
]);
assert.deepEqual(decodeCoverageTile({ lines: [] }, TILE).sequences, []);
});
test('a tile that is not a line list fails, so it is asked for again rather than kept empty', () => {
for (const payload of [
null,
{},
{ lines: 'nope' },
{ status: { apiCode: 690 } },
])
assert.throws(
() => decodeCoverageTile(payload, TILE),
/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/);
});
@@ -0,0 +1,649 @@
import { createLineCoverage } from '../../lineCoverage.js';
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
import { metresBetween } from '../../view.js';
import {
chainSegments,
maskedSegments,
readMaskImage,
tileSegments,
} from './decode.js';
import { createKartaViewSource } from './source.js';
import { createKartaViewViewer } from './viewer.js';
import {
ARCHIVED_MESSAGE,
COLORS,
COVERAGE_MAX_HEIGHT_M,
COVERAGE_NOUN,
COVERAGE_STREET_ZOOM_BELOW_M,
COVERAGE_TUNING,
FILTERED_OUT_MESSAGE,
KARTAVIEW_CREDIT_HTML,
KARTAVIEW_LABEL,
KARTAVIEW_NAME,
KARTAVIEW_PROVIDER_ID,
LINE_CLICK_HINT,
NEAREST_PROBES,
NEAREST_RADIUS_M,
NO_COVERAGE_HINT,
PICK_PREFIX,
RATE_LIMITED_MESSAGE,
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). */
const PHOTO_CACHE_SIZE = 4000;
/** Sequence pages kept in memory. */
const PAGE_CACHE_SIZE = 12;
/** Decoded coverage tiles kept, and for how long (masks too). */
const TILE_CACHE_SIZE = 24;
const TILE_CACHE_TTL_MS = 10 * 60_000;
/** z14 near the street, z13 a little higher, nothing above. */
export function kartaViewZoomFor(heightM) {
if (!Number.isFinite(heightM) || heightM > COVERAGE_MAX_HEIGHT_M) return null;
return {
zoom: heightM > COVERAGE_STREET_ZOOM_BELOW_M ? 13 : 14,
kind: 'sequence',
};
}
/** Least-recently-used map: reading an entry renews it. */
function createLru(limit) {
const map = new Map();
return {
get(key) {
if (!map.has(key)) return undefined;
const value = map.get(key);
map.delete(key);
map.set(key, value);
return value;
},
set(key, value) {
map.delete(key);
map.set(key, value);
while (map.size > limit) map.delete(map.keys().next().value);
},
delete: (key) => map.delete(key),
clear: () => map.clear(),
};
}
/**
* Loaded coverage tiles by `z/x/y`, at most `size`, each for `ttlMs`. KartaView
* sends tiles uncompressed and uncacheable (hundreds of kB, seconds each), so
* a pan back or a zoom across z13/z14 would fetch them all again.
*/
export function createTileCache({
load,
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 = keyOf(tile);
const entry = kept.get(key);
if (entry && now() - entry.at < ttlMs) return entry.tile;
const loaded = await load(tile, options);
kept.set(key, { at: now(), tile: loaded });
return loaded;
},
/** Drop every tile (the provider was switched off). */
clear: () => kept.clear(),
};
}
/** The 1-based page of a sequence that holds `index`, if no photo is missing. */
function pageOf(index) {
return Math.floor(Math.max(0, index) / SEQUENCE_PAGE_SIZE) + 1;
}
/**
* 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, readMask?: typeof readMaskImage}} [options]
* @returns {import('../../registry.js').StreetLevelProvider}
*/
export function createKartaViewProvider({
source = createKartaViewSource(),
viewerOptions = {},
readMask = readMaskImage,
} = {}) {
return Object.freeze({
id: KARTAVIEW_PROVIDER_ID,
name: KARTAVIEW_NAME,
label: KARTAVIEW_LABEL,
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,
// 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({
noun: COVERAGE_NOUN,
action: LINE_CLICK_HINT,
}),
create(context) {
const state = {
context,
services: context.services,
viewer: null,
keyRequired: false,
keyRejected: false,
statusKnown: true,
coverage: {
zoom: null,
tiles: new Map(),
stale: new Map(),
staleTimer: null,
pending: new Map(),
lastError: null,
holdUntil: 0,
holdTimer: null,
debounceTimer: null,
removeCameraListener: null,
terrainReady: null,
hint: '',
kind: null,
},
sequence: {
selectedId: null,
images: [],
cache: new Map(),
collection: null,
loading: false,
abort: null,
},
};
const photos = createLru(PHOTO_CACHE_SIZE);
const pages = createLru(PAGE_CACHE_SIZE);
/** The photo last opened in each sequence: its cones centre on it. */
const anchors = new Map();
/** 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 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;
}
async function getPhoto(photoId, { signal } = {}) {
const cached = photos.get(photoId);
if (cached) return cached;
const photo = remember(await source.getPhoto(photoId, { signal }));
if (!photo) throw new Error('KartaView has no position for this photo');
return photo;
}
/** A page of a sequence's photos, in index order (shared while in flight). */
function loadPage(sequenceId, page) {
const key = `${sequenceId}:${page}`;
let entry = pages.get(key);
if (!entry) {
entry = source
.getSequencePhotos(sequenceId, {
page,
itemsPerPage: SEQUENCE_PAGE_SIZE,
})
.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));
}
return entry;
}
/**
* The page holding `photo`. Pages follow photo ids, which only roughly
* follow the index, and photos go missing, so the page the index
* suggests is a first guess: step towards the photo until a page holds
* it, or the pages straddle where it would be.
*/
async function homePage(photo) {
let number = pageOf(photo.sequenceIndex ?? 0);
const index = photo.sequenceIndex ?? 0;
let page = await loadPage(photo.sequenceId, number);
if (!page.photos.length && number > 1) {
// Past the end (many photos deleted): find the last page by halving.
let low = 1;
let high = number - 1;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
if ((await loadPage(photo.sequenceId, middle)).photos.length)
low = middle;
else high = middle - 1;
}
number = low;
}
const seen = new Set();
while (!seen.has(number) && seen.size < 8) {
seen.add(number);
page = await loadPage(photo.sequenceId, number);
if (page.photos.some((p) => p.id === photo.id)) return page;
const first = page.photos[0]?.sequenceIndex;
const last = page.photos.at(-1)?.sequenceIndex;
// Past the last page, or before this page's photos: go back.
if ((first === undefined || index < first) && number > 1) number--;
else if (last !== undefined && index > last && page.hasMore) number++;
else break;
}
return page;
}
/**
* The photos around `photo` in index order: its page, plus a
* neighbour page when it sits near an edge (pages overlap in index).
* @returns {Promise<{home: number, photos: Array<object>}>}
*/
async function windowOf(photo) {
const home = await homePage(photo);
const list = home.photos;
const found = list.findIndex((p) => p.id === photo.id);
// A photo no page lists sits where its index would.
const at =
found !== -1
? found
: list.filter(
(p) => (p.sequenceIndex ?? 0) < (photo.sequenceIndex ?? 0),
).length;
const parts = [list];
if (at < SEQUENCE_PAGE_EDGE && home.page > 1)
parts.push((await loadPage(photo.sequenceId, home.page - 1)).photos);
if (at >= list.length - SEQUENCE_PAGE_EDGE && home.hasMore)
parts.push((await loadPage(photo.sequenceId, home.page + 1)).photos);
if (parts.length === 1) return { home: home.page, photos: list };
const byId = new Map(parts.flat().map((p) => [p.id, p]));
return {
home: home.page,
photos: [...byId.values()].sort(
(a, b) => (a.sequenceIndex ?? 0) - (b.sequenceIndex ?? 0),
),
};
}
/**
* Photo id → whether its file is there (archived ones are not). Only
* a definite answer is kept: a storage hiccup is asked again next
* time, and meanwhile the photo is tried. A probe is shared, so it
* runs to its own timeout rather than any one caller's abort.
*/
const probes = createLru(PHOTO_CACHE_SIZE);
function showable(photo) {
if (!photo?.urls?.view) return Promise.resolve(false);
let probe = probes.get(photo.id);
if (!probe) {
probe = source.probeImage(photo.urls.view).then(
(answer) => {
if (answer === null) probes.delete(photo.id);
return answer !== false;
},
() => {
probes.delete(photo.id);
return true;
},
);
probes.set(photo.id, probe);
}
return probe;
}
// 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 }) =>
tileSegments(await source.getTile(tile, { signal })),
});
// 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
* end. Archived photos are skipped, a few at most.
*/
async function step(photo, delta) {
if (!photo?.sequenceId) return null;
let from = photo;
for (let skipped = 0; skipped <= NEAREST_PROBES; skipped++) {
const { photos: list } = await windowOf(from);
let at = list.findIndex((p) => p.id === from.id);
if (at === -1) {
// Not found anywhere: the nearest index on the right side.
const index = from.sequenceIndex ?? 0;
const later = list.findIndex((p) => (p.sequenceIndex ?? 0) > index);
if (delta > 0 && later === -1) return null;
at =
delta > 0
? later - 1
: later === -1
? list.length
: list.findIndex((p) => (p.sequenceIndex ?? 0) >= index);
}
const next = list[at + delta];
if (!next) return null;
if (await showable(next)) return next.id;
from = next;
}
return null;
}
const coverage = createLineCoverage({
state,
fetchTile,
zoomFor: kartaViewZoomFor,
colors: COLORS,
pickPrefix: PICK_PREFIX.line,
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 }) {
let anchor = anchors.get(sequenceId);
if (!anchor) return [];
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: cameraBearing(photo) ?? 0,
capturedAt: photo.capturedAt ?? 0,
uploadedAt: photo.uploadedAt,
isPano: photo.isPano,
})),
);
},
colors: COLORS,
pickPrefix: PICK_PREFIX.image,
spriteId: 'street-level:kartaview-cones',
renderKey: 'kartaview-sequence',
// 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 });
}
/**
* The cones show one page of a sequence: once a photo of the selected
* sequence opens on another page (‹ ›, a cone, a line), re-centre them.
*/
async function recentreCones(photo) {
if (!photo?.sequenceId) return;
if (state.sequence.selectedId !== photo.sequenceId) return;
const drawn = drawnPage;
if (drawn?.sequenceId !== photo.sequenceId) return;
const window = await windowOf(photo).catch(() => null);
// Still the drawn page and the latest photo once the page is in.
if (
window &&
drawnPage === drawn &&
window.home !== drawn.page &&
anchors.get(photo.sequenceId) === photo
)
cones.select(photo.sequenceId, { force: true });
}
const viewer = createKartaViewViewer({
getPhoto: async (photoId) => {
const photo = await getPhoto(photoId);
if (photo.sequenceId) anchors.set(photo.sequenceId, photo);
return photo;
},
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',
),
),
render: context.services?.render,
...viewerOptions,
});
return {
async status() {
return { configured: true };
},
init(cesiumViewer) {
state.viewer = cesiumViewer;
cones.ensureCollections(cesiumViewer);
cones.setVisible(false);
},
activate(cesiumViewer) {
state.viewer = cesiumViewer;
cones.setVisible(true);
coverage.attach(cesiumViewer);
},
deactivate() {
coverage.detach();
coverage.clear();
coverage.resetErrors();
cones.clearSelection();
cones.setVisible(false);
drawnPage = null;
// Tens of MB of decoded lines are not kept for a source left off.
tiles.clear();
masks.clear();
},
destroy(cesiumViewer) {
coverage.detach();
coverage.clear();
coverage.resetErrors();
cones.destroy(cesiumViewer);
tiles.clear();
masks.clear();
},
refreshCoverage: () => coverage.refresh(),
/**
* 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();
cones.rerender();
},
coverageStats() {
const { hint } = state.coverage;
const count = coverage.sequenceCount();
const loading = state.coverage.pending.size > 0;
const empty =
state.coverage.zoom !== null &&
!loading &&
count === 0 &&
!state.coverage.lastError;
return {
count,
zoom: state.coverage.zoom,
kind: state.coverage.kind,
loading,
hint: hint || (empty ? NO_COVERAGE_HINT : ''),
error: state.coverage.lastError,
keyRequired: false,
keyRejected: false,
};
},
/** A line opens the photo nearest the click; a cone opens its photo. */
handlePick(pickId, { groundPoint } = {}) {
if (pickId.startsWith(PICK_PREFIX.image)) {
context.actions.openImage(pickId.slice(PICK_PREFIX.image.length));
return true;
}
if (pickId.startsWith(PICK_PREFIX.line)) {
const point = groundPoint?.();
if (point) context.actions.openNearest(point);
return true;
}
return false;
},
// Never from the cache: it is keyed by sequence, the cones by page.
selectSequence: (sequenceId) =>
cones.select(sequenceId, { force: true }),
clearSequence: () => {
cones.clearSelection();
drawnPage = null;
},
// The drive's own count, not the thinned page of it the cones show.
sequenceStats: () => ({
selectedId: state.sequence.selectedId,
images:
counts.get(state.sequence.selectedId) ??
state.sequence.images.length,
loading: state.sequence.loading,
}),
/**
* The nearest photo within the search radius that can be shown, or
* null. Many photos are archived (their files answer 409) or have no
* file at all, so the nearest few are tried in turn.
*/
async nearestImage({ 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) 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
// nearest one that answers wins.
const candidates = near.slice(0, NEAREST_PROBES);
const answers = await Promise.all(
candidates.map(({ photo }) => showable(photo, { signal })),
);
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;
},
viewer,
};
},
});
}
@@ -0,0 +1,243 @@
/**
* A minimal equirectangular panorama renderer on WebGL: one full-screen
* triangle whose fragment shader turns each pixel's view ray into texture
* coordinates. The image centre is the camera's forward direction; yaw turns
* right from it, pitch looks up.
*/
export const PANO_FOV_DEFAULT_DEG = 80;
export const PANO_FOV_MIN_DEG = 30;
export const PANO_FOV_MAX_DEG = 100;
export const PANO_PITCH_LIMIT_DEG = 85;
const VERTEX = `
attribute vec2 a_position;
varying vec2 v_ndc;
void main() {
v_ndc = a_position;
gl_Position = vec4(a_position, 0.0, 1.0);
}`;
const FRAGMENT = `
precision highp float;
uniform sampler2D u_image;
uniform float u_yaw;
uniform float u_pitch;
uniform float u_tanHalfFov;
uniform float u_aspect;
varying vec2 v_ndc;
const float PI = 3.141592653589793;
void main() {
vec3 ray = normalize(vec3(v_ndc.x * u_tanHalfFov * u_aspect, v_ndc.y * u_tanHalfFov, -1.0));
float cp = cos(u_pitch), sp = sin(u_pitch);
ray = vec3(ray.x, ray.y * cp - ray.z * sp, ray.y * sp + ray.z * cp);
float cy = cos(u_yaw), sy = sin(u_yaw);
ray = vec3(ray.x * cy - ray.z * sy, ray.y, ray.x * sy + ray.z * cy);
float lon = atan(ray.x, -ray.z);
float lat = asin(clamp(ray.y, -1.0, 1.0));
gl_FragColor = texture2D(u_image, vec2(0.5 + lon / (2.0 * PI), 0.5 - lat / PI));
}`;
/** Wrap degrees into [0, 360). */
export function wrapDegrees(value) {
return ((value % 360) + 360) % 360;
}
export function clampPitch(degrees) {
return Math.max(
-PANO_PITCH_LIMIT_DEG,
Math.min(PANO_PITCH_LIMIT_DEG, degrees),
);
}
export function clampFov(degrees) {
return Math.max(PANO_FOV_MIN_DEG, Math.min(PANO_FOV_MAX_DEG, degrees));
}
/**
* Texture coordinates the shader samples for a view ray, for tests and
* picking: `yaw`/`pitch` in degrees, the ray in camera space (x right, y up,
* z backward).
*/
export function rayToUv(ray, { yaw = 0, pitch = 0 } = {}) {
const p = (pitch * Math.PI) / 180;
const w = (yaw * Math.PI) / 180;
const length = Math.hypot(ray[0], ray[1], ray[2]) || 1;
let [x, y, z] = ray.map((v) => v / length);
[y, z] = [
y * Math.cos(p) - z * Math.sin(p),
y * Math.sin(p) + z * Math.cos(p),
];
[x, z] = [
x * Math.cos(w) - z * Math.sin(w),
x * Math.sin(w) + z * Math.cos(w),
];
const lon = Math.atan2(x, -z);
const lat = Math.asin(Math.max(-1, Math.min(1, y)));
return [0.5 + lon / (2 * Math.PI), 0.5 - lat / Math.PI];
}
function compile(gl, type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const log = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error(`Panorama shader failed: ${log}`);
}
return shader;
}
const isPowerOfTwo = (n) => n > 0 && (n & (n - 1)) === 0;
/**
* A panorama view on `canvas`. Throws when WebGL is unavailable, so the
* caller can fall back to a flat picture.
* @param {HTMLCanvasElement} canvas
* @returns {{setImage: (image: TexImageSource) => void, setView: (view: {yaw?: number, pitch?: number, fov?: number}) => void, getView: () => {yaw: number, pitch: number, fov: number}, resize: () => void, render: () => void, hasImage: () => boolean, destroy: () => void}}
*/
export function createPanoramaRenderer(canvas) {
const gl =
canvas.getContext('webgl', {
antialias: false,
depth: false,
preserveDrawingBuffer: false,
}) || canvas.getContext('experimental-webgl');
if (!gl) throw new Error('WebGL is unavailable');
const program = gl.createProgram();
gl.attachShader(program, compile(gl, gl.VERTEX_SHADER, VERTEX));
gl.attachShader(program, compile(gl, gl.FRAGMENT_SHADER, FRAGMENT));
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS))
throw new Error('Panorama program failed to link');
gl.useProgram(program);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
// One triangle covering the screen.
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([-1, -1, 3, -1, -1, 3]),
gl.STATIC_DRAW,
);
const position = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(position);
gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 0, 0);
const uniforms = Object.fromEntries(
['u_image', 'u_yaw', 'u_pitch', 'u_tanHalfFov', 'u_aspect'].map((name) => [
name,
gl.getUniformLocation(program, name),
]),
);
const texture = gl.createTexture();
let loaded = false;
let frame = 0;
const view = { yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG };
/** Match the drawing buffer to the canvas's laid-out size. */
function syncSize() {
const ratio = Math.min(globalThis.devicePixelRatio || 1, 2);
const width = Math.max(1, Math.round(canvas.clientWidth * ratio));
const height = Math.max(1, Math.round(canvas.clientHeight * ratio));
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
}
function resize() {
syncSize();
scheduleRender();
}
function draw() {
frame = 0;
if (gl.isContextLost()) return;
// Every frame: the canvas may have been laid out since the last one.
syncSize();
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0, 0, 0, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
if (!loaded) return;
gl.uniform1i(uniforms.u_image, 0);
gl.uniform1f(uniforms.u_yaw, (view.yaw * Math.PI) / 180);
gl.uniform1f(uniforms.u_pitch, (view.pitch * Math.PI) / 180);
gl.uniform1f(uniforms.u_tanHalfFov, Math.tan((view.fov * Math.PI) / 360));
gl.uniform1f(uniforms.u_aspect, canvas.width / Math.max(1, canvas.height));
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
function scheduleRender() {
if (frame) return;
frame = requestAnimationFrame(draw);
}
return {
setImage(image) {
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
// RGBA: the format a decoded ImageBitmap uploads without conversion.
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
image,
);
// The pixels are on the GPU now; a bitmap's copy can go.
image.close?.();
// Linear only: a mipmapped lookup draws a seam where longitude wraps.
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
const repeat =
isPowerOfTwo(image.width) && isPowerOfTwo(image.height)
? gl.REPEAT
: gl.CLAMP_TO_EDGE;
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, repeat);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
loaded = true;
resize();
scheduleRender();
},
setView(next = {}) {
if (Number.isFinite(next.yaw)) view.yaw = wrapDegrees(next.yaw);
if (Number.isFinite(next.pitch)) view.pitch = clampPitch(next.pitch);
if (Number.isFinite(next.fov)) view.fov = clampFov(next.fov);
scheduleRender();
},
getView: () => ({ ...view }),
resize,
render: scheduleRender,
hasImage: () => loaded,
clear() {
loaded = false;
// Hand the 32 MB texture back: a closed viewer keeps only 1×1.
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
1,
1,
0,
gl.RGBA,
gl.UNSIGNED_BYTE,
new Uint8Array(4),
);
scheduleRender();
},
destroy() {
if (frame) cancelAnimationFrame(frame);
frame = 0;
loaded = false;
gl.deleteTexture(texture);
gl.deleteBuffer(buffer);
gl.deleteProgram(program);
gl.getExtension('WEBGL_lose_context')?.loseContext();
},
};
}
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
PANO_FOV_MAX_DEG,
PANO_FOV_MIN_DEG,
PANO_PITCH_LIMIT_DEG,
clampFov,
clampPitch,
createPanoramaRenderer,
rayToUv,
wrapDegrees,
} from './panorama.js';
const close = (actual, expected) =>
actual.forEach((value, i) =>
assert.ok(Math.abs(value - expected[i]) < 1e-9, `${actual} ≉ ${expected}`),
);
test('straight ahead samples the image centre; yaw turns right, pitch looks up', () => {
close(rayToUv([0, 0, -1]), [0.5, 0.5]);
close(rayToUv([0, 0, -1], { yaw: 90 }), [0.75, 0.5]);
close(rayToUv([0, 0, -1], { yaw: -90 }), [0.25, 0.5]);
close(rayToUv([0, 0, -1], { pitch: 45 }), [0.5, 0.25]);
close(rayToUv([0, 0, -1], { pitch: -90 }), [0.5, 1]);
// Pitch first, then yaw: looking up-right stays up.
close(rayToUv([0, 0, -1], { yaw: 90, pitch: 45 }), [0.75, 0.25]);
// A ray to the right of the screen is the same as turning right.
close(rayToUv([1, 0, 0]), [0.75, 0.5]);
});
test('view limits: pitch stops short of the poles, field of view stays usable', () => {
assert.equal(clampPitch(120), PANO_PITCH_LIMIT_DEG);
assert.equal(clampPitch(-120), -PANO_PITCH_LIMIT_DEG);
assert.equal(clampFov(5), PANO_FOV_MIN_DEG);
assert.equal(clampFov(170), PANO_FOV_MAX_DEG);
assert.equal(wrapDegrees(-30), 330);
assert.equal(wrapDegrees(725), 5);
});
/**
* A WebGL stand-in that records draw calls and uniform values; `lost` makes
* it report a lost context.
*/
function stubGl() {
const gl = {
lost: false,
calls: [],
uniforms: {},
isContextLost: () => gl.lost,
getShaderParameter: () => true,
getProgramParameter: () => true,
getUniformLocation: (program, name) => name,
getExtension: () => null,
uniform1f: (name, value) => (gl.uniforms[name] = value),
};
return new Proxy(gl, {
get(target, key) {
if (key in target) return target[key];
if (typeof key === 'string' && /^[A-Z_0-9]+$/.test(key)) return key;
return (...args) => {
target.calls.push(key);
return { key, args };
};
},
});
}
/** A panorama on a stub canvas, with frames run when the test says. */
function stubPanorama(t, { width = 400, height = 250 } = {}) {
const gl = stubGl();
const canvas = {
clientWidth: width,
clientHeight: height,
width: 300,
height: 150,
getContext: () => gl,
};
const frames = [];
const saved = {
request: globalThis.requestAnimationFrame,
cancel: globalThis.cancelAnimationFrame,
};
globalThis.requestAnimationFrame = (callback) => frames.push(callback);
globalThis.cancelAnimationFrame = () => {};
t.after(() => {
globalThis.requestAnimationFrame = saved.request;
globalThis.cancelAnimationFrame = saved.cancel;
});
const renderer = createPanoramaRenderer(canvas);
const runFrames = () => {
while (frames.length) frames.shift()();
};
return { gl, canvas, renderer, runFrames };
}
const IMAGE = { width: 1024, height: 512 };
test('a panorama draws once it has an image; looking up and right are positive angles', (t) => {
const { gl, renderer, runFrames } = stubPanorama(t);
renderer.render();
runFrames();
assert.equal(gl.calls.includes('drawArrays'), false, 'nothing to draw yet');
renderer.setImage(IMAGE);
renderer.setView({ yaw: 30, pitch: 30 });
runFrames();
assert.ok(gl.calls.includes('drawArrays'));
assert.ok(gl.uniforms.u_pitch > 0, `u_pitch ${gl.uniforms.u_pitch}`);
assert.ok(Math.abs(gl.uniforms.u_pitch - Math.PI / 6) < 1e-9);
assert.ok(Math.abs(gl.uniforms.u_yaw - Math.PI / 6) < 1e-9);
renderer.setView({ pitch: -20 });
runFrames();
assert.ok(gl.uniforms.u_pitch < 0, 'looking down is negative');
});
test('a lost WebGL context draws nothing', (t) => {
const { gl, renderer, runFrames } = stubPanorama(t);
renderer.setImage(IMAGE);
gl.lost = true;
gl.calls.length = 0;
runFrames();
assert.deepEqual(
gl.calls.filter((call) =>
['viewport', 'clear', 'drawArrays'].includes(call),
),
[],
);
});
test('the canvas is measured again when it draws, not only when its image arrived', (t) => {
// The image arrives while the canvas is still hidden (0×0)…
const { canvas, renderer, runFrames } = stubPanorama(t, {
width: 0,
height: 0,
});
renderer.setImage(IMAGE);
assert.deepEqual([canvas.width, canvas.height], [1, 1]);
// …and is laid out before the frame draws.
canvas.clientWidth = 400;
canvas.clientHeight = 250;
runFrames();
assert.deepEqual([canvas.width, canvas.height], [400, 250]);
});
test('an image uploads as RGBA and its bitmap is released; closing hands the texture back as 1×1', (t) => {
const { gl, renderer } = stubPanorama(t);
const uploads = [];
gl.texImage2D = (...args) => uploads.push(args);
let closed = false;
renderer.setImage({
width: 2048,
height: 1024,
close: () => (closed = true),
});
assert.equal(uploads.length, 1);
assert.equal(
uploads[0][2],
'RGBA',
'the format a decoded bitmap uploads without conversion',
);
assert.equal(closed, true, 'the decoded copy is freed once on the GPU');
renderer.clear();
const last = uploads.at(-1);
assert.deepEqual(
[last[3], last[4]],
[1, 1],
'a closed viewer keeps a 1×1 texture',
);
assert.equal(renderer.hasImage(), false);
});
@@ -0,0 +1,397 @@
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
export { NEAREST_RADIUS_M } from '../../policy.js';
/** Identity and tuning for the KartaView street-level provider (keyless). */
export const KARTAVIEW_PROVIDER_ID = 'kartaview';
export const KARTAVIEW_NAME = 'KartaView';
export const KARTAVIEW_LABEL = 'KARTAVIEW';
/**
* The public API. Its documented twin, api.openstreetcam.org, still answers
* but serves empty coverage tiles.
*/
export const KARTAVIEW_API_HOST = 'https://api.kartaview.org';
/** KartaView's image proxy: resizes a photo given its base64url address. */
export const KARTAVIEW_CDN_HOST = 'https://cdn.kartaview.org';
/** Stable id prefixes for picked primitives; every one starts with `kv:`. */
export const PICK_PREFIX = Object.freeze({
root: 'kv:',
line: 'kv:line:',
image: 'kv:img:',
});
export const COLORS = Object.freeze({
coverage: PROVIDER_COLORS.kartaview,
selected: SHARED_COLORS.selected,
});
/**
* Coverage lines are OSM ways with photos, from z13–14 JSON tiles. Lower
* zooms take seconds per tile (z10 does not answer), so higher up the panel
* asks the user to zoom in.
*/
export const COVERAGE_STREET_ZOOM_BELOW_M = 2_500;
export const COVERAGE_MAX_HEIGHT_M = 6_000;
export const COVERAGE_TUNING = Object.freeze({
maxTiles: 9,
overviewMaxTiles: 0,
// Chained ways are short: a dense downtown z13 tile holds ~1,500, so each
// tile may draw 1,600 (a frame stays at 16.7 ms with 4,400 drawn).
maxSequences: 14_400,
lineWidthPx: 2.5,
overviewPointPx: 2.5,
moveDebounceMs: 320,
viewRangeMinM: 2_500,
viewRangePerHeight: 10,
viewNearM: 1_000,
});
export const TOO_HIGH_HINT = 'zoom in to see KartaView coverage';
export const NO_COVERAGE_HINT = 'no KartaView photos in view';
export const RATE_LIMITED_MESSAGE =
'KartaView is rate-limiting requests — they resume on their own';
/**
* The photo API answers 429 to more than about three requests at once, so
* at most two are in flight; a 429 is retried after a short wait.
*/
export const PHOTO_API_CONCURRENCY = 2;
export const PHOTO_API_RETRIES = 3;
export const PHOTO_API_RETRY_BASE_MS = 800;
/** A Retry-After longer than this is not waited out. */
export const PHOTO_API_RETRY_MAX_MS = 10_000;
/** The API is HTTP/1.1 (six connections): tiles 3, lookups 2, pages 1. */
export const TILE_CONCURRENCY = 3;
export const PAGE_CONCURRENCY = 1;
export const TILE_TIMEOUT_MS = 20_000;
export const PHOTO_API_TIMEOUT_MS = 15_000;
/**
* The first try of a lookup: KartaView sometimes sits on a lookup for 11 s
* and more, then answers the same one again in 3 s.
*/
export const PHOTO_API_FIRST_TIMEOUT_MS = 8_000;
/** A sequence page is about 650 kB of JSON. */
export const PAGE_TIMEOUT_MS = 30_000;
/** Real tiles are under 1 MB; refuse anything absurd before parsing it. */
export const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
/** A photo download (tens of kilobytes to a few megabytes). */
export const IMAGE_TIMEOUT_MS = 15_000;
/** Whether a photo file is there (not archived): headers only. */
export const IMAGE_PROBE_TIMEOUT_MS = 8_000;
/** Nearest photos probed (at once) before giving up on archived ones; ‹ › skip this many. */
export const NEAREST_PROBES = 6;
export const ARCHIVED_MESSAGE =
'KartaView’s photos here are archived and cannot be shown';
/** Photos asked for around a point; the API returns them nearest first. */
export const NEAREST_LIMIT = 10;
/** The photo API's search zoom: 18 searches the few tens of metres around the point. */
export const NEAREST_SEARCH_ZOOM = 18;
/**
* Photos of a sequence fetched at once, around the open one. Records are
* ~2.6 kB each, so a page is ~650 kB; the cones and ‹ › use it. Pages are in
* photo-id order, which only roughly follows the sequence index.
*/
export const SEQUENCE_PAGE_SIZE = 250;
/** Within this many photos of a page's edge, its neighbour page joins it. */
export const SEQUENCE_PAGE_EDGE = 12;
/** Equirectangular panorama sizes: a quick preview, then the sharp one. */
export const PANO_PREVIEW_SIZE = Object.freeze([1024, 512]);
/** Enough for a docked viewer; an expanded one gets the full size. */
export const PANO_MID_SIZE = Object.freeze([2048, 1024]);
export const PANO_FULL_SIZE = Object.freeze([4096, 2048]);
/** A flat photo's first version, then its sharp one. */
export const FLAT_VIEW_SIZE = Object.freeze([1280, 1280]);
export const FLAT_FULL_SIZE = Object.freeze([2560, 2560]);
/** On-globe credit: KartaView imagery is CC BY-SA 4.0 and needs attribution. */
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';
/** Base64url of a string, as the image proxy reads addresses. */
function base64Url(text) {
const bytes = new TextEncoder().encode(text);
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
/** A photo resized by KartaView's image proxy to fit `width`×`height`. */
export function resizedImageUrl(url, [width, height]) {
if (!url) return null;
return `${KARTAVIEW_CDN_HOST}/pr:sharp/rs:fit:${width}:${height}/${base64Url(url)}`;
}
/** Epoch ms for a KartaView timestamp ('2016-02-19 15:28:18.000', UTC), or null. */
export function kartaViewTimeMs(value) {
const match =
/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/.exec(
String(value ?? ''),
);
if (!match) return null;
const [, y, mo, d, h = '0', mi = '0', s = '0'] = match;
if (Number(y) < 1990) return null;
const ms = Date.UTC(+y, +mo - 1, +d, +h, +mi, +s);
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);
const matchLon = Number(record.matchLng);
if (
Number.isFinite(matchLat) &&
Number.isFinite(matchLon) &&
(matchLat !== 0 || matchLon !== 0)
)
return { lat: matchLat, lon: matchLon };
return { lat: Number(record.lat), lon: Number(record.lng) };
}
const STORAGE_HOST = /^storage\d+$/;
/** A KartaView photo-storage address, or null for anything else. */
function storageUrl(value) {
try {
const url = new URL(String(value));
return url.protocol === 'https:' &&
/^storage\d+\.openstreetcam\.org$/.test(url.hostname)
? url.href
: null;
} catch {
return null;
}
}
/**
* A photo file's address. The API's `fileurl*` fields always name storage1,
* but a photo whose `storage` is another host (storage2…) is only there, so
* the address is built from `storage` and the relative `filepath*`. A
* `fileurl*` fallback must itself be on KartaView's storage.
*/
function fileUrl(record, pathKey, urlKey) {
const path = record[pathKey];
if (STORAGE_HOST.test(record.storage || '') && path)
return storageUrl(
`https://${record.storage}.openstreetcam.org/files/photo/${path}`,
);
return storageUrl(record[urlKey]);
}
/** A storage URL with its `{{sizeprefix}}` placeholder filled. */
function sized(record, prefix) {
const fill = (value) =>
typeof value === 'string' && value.includes('{{sizeprefix}}')
? value.replace('{{sizeprefix}}', prefix)
: null;
const path = fill(record.filepath);
return fileUrl(
{ ...record, filepath: path, fileurl: fill(record.fileurl) },
'filepath',
'fileurl',
);
}
/**
* A photo API record in the shape the provider uses, or null without an id
* or a position.
* @returns {{id: string, sequenceId: string|null, sequenceIndex: number|null, lon: number, lat: number, heading: number|null, isPano: boolean, capturedAt: number|null, urls: {view: string|null, full: string|null, pano: string|null}}|null}
*/
export function normalizePhoto(record) {
if (!record || record.id == null) return null;
const { lat, lon } = positionOf(record);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
// 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) || NaN;
const isPano =
record.projection === 'SPHERE' || Number(record.fieldOfView) >= 360;
const full =
fileUrl(record, 'filepathProc', 'fileurlProc') || sized(record, 'proc');
const index = Number(record.sequenceIndex);
return {
id: String(record.id),
sequenceId: record.sequenceId != null ? String(record.sequenceId) : null,
sequenceIndex: Number.isFinite(index) ? index : null,
lon,
lat,
heading: Number.isFinite(heading) ? heading : null,
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:
fileUrl(record, 'filepathLTh', 'fileurlLTh') ||
sized(record, 'lth') ||
full,
full,
// A 360° photo's equirectangular image ('wrapped_proc').
pano: isPano && /wrapped/.test(full || '') ? full : null,
},
};
}
/**
* Where the image link goes. KartaView's photo pages fail for signed-out
* visitors ("Sequence cannot be loaded"), so it is the full-size photo
* through KartaView's image proxy, which also serves photos whose storage
* host answers 502 directly.
*/
export function kartaViewPhotoUrl(photo) {
const url = photo?.urls?.full || photo?.urls?.view;
return url
? `${KARTAVIEW_CDN_HOST}/pr:sharp/${base64Url(url)}`
: 'https://kartaview.org';
}
@@ -0,0 +1,348 @@
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). */
function record(overrides = {}) {
return {
id: '7102491',
sequenceId: '14010',
sequenceIndex: '950',
lat: '42.335712',
lng: '-83.048301',
matchLat: '42.335731506347656',
matchLng: '-83.04828643798828',
heading: '299.58',
fieldOfView: '0',
projection: 'PLANE',
shotDate: '2016-08-23 20:17:47.000',
dateAdded: '2016-08-24 04:01:07',
storage: 'storage1',
filepath: '2016/8/24/{{sizeprefix}}/14010_ba53c_57bd1d23d50b7.jpg',
filepathLTh: '2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg',
filepathProc: '2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg',
fileurl:
'https://storage1.openstreetcam.org/files/photo/2016/8/24/{{sizeprefix}}/14010_ba53c_57bd1d23d50b7.jpg',
fileurlLTh:
'https://storage1.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg',
fileurlProc:
'https://storage1.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg',
...overrides,
};
}
test('a photo record normalizes to numbers, the road-matched position and UTC capture time', () => {
const photo = normalizePhoto(record());
assert.equal(photo.id, '7102491');
assert.equal(photo.sequenceId, '14010');
assert.equal(photo.sequenceIndex, 950);
assert.equal(photo.lat, 42.335731506347656);
assert.equal(photo.lon, -83.04828643798828);
assert.equal(photo.heading, 299.58);
assert.equal(photo.isPano, false);
assert.equal(photo.capturedAt, Date.UTC(2016, 7, 23, 20, 17, 47));
assert.equal(photo.urls.pano, null);
});
test('photo files come from the storage host the record names, not the one its URLs say', () => {
// The API writes storage1 into every URL; this photo lives on storage2.
const photo = normalizePhoto(record({ storage: 'storage2' }));
assert.equal(
photo.urls.view,
'https://storage2.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg',
);
assert.equal(
photo.urls.full,
'https://storage2.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg',
);
// The link goes through the image proxy, which serves photos whose
// storage host answers 502 directly.
const link = kartaViewPhotoUrl(photo);
assert.match(link, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\/[\w-]+$/);
assert.equal(
Buffer.from(link.split('/').at(-1), 'base64url').toString(),
photo.urls.full,
);
// A fallback address must be on KartaView's storage, over https.
for (const fileurlLTh of [
'https://tracker.example/x.jpg',
'http://storage1.openstreetcam.org/a.jpg',
'https://storage1.openstreetcam.org.evil.example/a.jpg',
'javascript:alert(1)',
]) {
const hostile = normalizePhoto(
record({ storage: '', filepathLTh: '', fileurlLTh, fileurl: fileurlLTh }),
);
assert.notEqual(hostile.urls.view, fileurlLTh);
assert.doesNotMatch(
String(hostile.urls.view),
/tracker|javascript|evil|^http:/,
);
}
// An unexpected storage value falls back to the URLs as given.
const odd = normalizePhoto(record({ storage: 'https://evil.example' }));
assert.match(odd.urls.view, /^https:\/\/storage1\.openstreetcam\.org\//);
// No large thumbnail path: the size prefix fills in.
const bare = normalizePhoto(
record({ filepathLTh: '', fileurlLTh: '', storage: 'storage3' }),
);
assert.equal(
bare.urls.view,
'https://storage3.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg',
);
});
test('a 360° photo keeps its equirectangular image; the unmatched position is the fallback', () => {
const photo = normalizePhoto(
record({
projection: 'SPHERE',
fieldOfView: '360',
matchLat: '0',
matchLng: '0',
storage: 'storage13',
filepathProc: '2022/10/6/wrapped_proc/6187609_626b8.jpg',
}),
);
assert.equal(photo.isPano, true);
assert.equal(photo.lat, 42.335712);
assert.equal(
photo.urls.pano,
'https://storage13.openstreetcam.org/files/photo/2022/10/6/wrapped_proc/6187609_626b8.jpg',
);
// A sphere without a wrapped image has nothing to project.
assert.equal(
normalizePhoto(record({ projection: 'SPHERE', fieldOfView: '360' })).urls
.pano,
null,
);
});
test('records without an id or a position are dropped; a missing heading is null', () => {
assert.equal(normalizePhoto(null), null);
// 0,0 is a missing fix.
assert.equal(
normalizePhoto(
record({ lat: '0', lng: '0', matchLat: '0', matchLng: '0' }),
),
null,
);
assert.equal(normalizePhoto(record({ lat: '95', matchLat: '95' })), null);
assert.equal(normalizePhoto(record({ id: undefined })), null);
assert.equal(
normalizePhoto(
record({ lat: 'x', lng: 'y', matchLat: undefined, matchLng: undefined }),
),
null,
);
assert.equal(normalizePhoto(record({ heading: '' })).heading, null);
// 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);
});
test('timestamps parse as UTC; years before 1990 and garbage are unknown', () => {
assert.equal(kartaViewTimeMs('2022-03-21'), Date.UTC(2022, 2, 21));
assert.equal(
kartaViewTimeMs('2022-03-21T06:42:59'),
Date.UTC(2022, 2, 21, 6, 42, 59),
);
assert.equal(kartaViewTimeMs('0000-00-00 00:00:00'), null);
assert.equal(kartaViewTimeMs(''), null);
assert.equal(kartaViewTimeMs(null), null);
const fallback = normalizePhoto(record({ shotDate: null }));
assert.equal(fallback.capturedAt, Date.UTC(2016, 7, 24, 4, 1, 7));
});
test('resized images go through the proxy with a base64url address', () => {
const url = resizedImageUrl(
'https://storage13.openstreetcam.org/files/photo/a?b=c+d',
[1024, 512],
);
const [, encoded] = /\/rs:fit:1024:512\/(.+)$/.exec(url);
assert.match(url, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\/rs:fit:/);
assert.doesNotMatch(encoded, /[+/=]/);
const decoded = Buffer.from(
encoded.replace(/-/g, '+').replace(/_/g, '/'),
'base64',
).toString();
assert.equal(
decoded,
'https://storage13.openstreetcam.org/files/photo/a?b=c+d',
);
assert.equal(resizedImageUrl(null, [1, 1]), null);
});
test('a storage value that only contains a storage host name builds no address from it', () => {
for (const storage of ['evil.example/#storage1', 'storage1.evil.example']) {
const photo = normalizePhoto(record({ storage }));
// Not a storage host: the API's own storage URLs serve.
assert.equal(
photo.urls.view,
'https://storage1.openstreetcam.org/files/photo/2016/8/24/lth/14010_ba53c_57bd1d23d50b7.jpg',
storage,
);
assert.equal(
photo.urls.full,
'https://storage1.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg',
storage,
);
}
});
test('a 360° field of view makes a panorama whatever the projection says', () => {
assert.equal(
normalizePhoto(record({ projection: 'PLANE', fieldOfView: '360' })).isPano,
true,
);
assert.equal(
normalizePhoto(record({ projection: 'PLANE', fieldOfView: '359' })).isPano,
false,
);
});
test('a longitude past ±180 is not a position', () => {
assert.equal(normalizePhoto(record({ lng: '181', matchLng: '181' })), null);
assert.equal(normalizePhoto(record({ lng: '-181', matchLng: '-181' })), null);
assert.equal(
normalizePhoto(record({ lng: '180', matchLng: '180' })).lon,
180,
);
});
test('with no processed-photo path the size prefix gives the full photo', () => {
const photo = normalizePhoto(
record({ filepathProc: '', fileurlProc: '', storage: 'storage3' }),
);
assert.equal(
photo.urls.full,
'https://storage3.openstreetcam.org/files/photo/2016/8/24/proc/14010_ba53c_57bd1d23d50b7.jpg',
);
// Only the large thumbnail, no path at all: the thumbnail still shows.
const thumbOnly = normalizePhoto(
record({
filepathProc: '',
fileurlProc: '',
filepath: '',
fileurl: '',
storage: 'storage3',
}),
);
assert.equal(thumbOnly.urls.full, null);
assert.match(
thumbOnly.urls.view,
/^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',
);
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,459 @@
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,
PAGE_TIMEOUT_MS,
PHOTO_API_CONCURRENCY,
PHOTO_API_RETRIES,
PHOTO_API_RETRY_BASE_MS,
PHOTO_API_RETRY_MAX_MS,
PHOTO_API_FIRST_TIMEOUT_MS,
PHOTO_API_TIMEOUT_MS,
TILE_CONCURRENCY,
TILE_TIMEOUT_MS,
photoFilterParams,
} from './policy.js';
/**
* 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, 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]);
/** Resolves after `ms`, or rejects when `signal` aborts first. */
function wait(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) return reject(signal.reason);
const timer = setTimeout(done, ms);
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason);
};
signal?.addEventListener('abort', onAbort, { once: true });
function done() {
signal?.removeEventListener('abort', onAbort);
resolve();
}
});
}
/** Seconds from a Retry-After header, or null. */
function retryAfterSeconds(response) {
const header = response.headers?.get?.('retry-after');
const value = header == null || header === '' ? NaN : Number(header);
return Number.isFinite(value) && value >= 0 ? value : null;
}
/**
* A gate letting at most `limit` tasks run at once, first in first out, or
* newest first with `lifo` (the last request is the one the user waits on).
*/
export function createLimiter(limit, { lifo = false } = {}) {
let running = 0;
const queue = [];
function next() {
while (running < limit && queue.length) {
const { task, resolve, reject, signal } = lifo
? queue.pop()
: queue.shift();
if (signal?.aborted) {
reject(signal.reason);
continue;
}
running++;
Promise.resolve()
.then(task)
.then(resolve, reject)
.finally(() => {
running--;
next();
});
}
}
return {
run(task, { signal } = {}) {
return new Promise((resolve, reject) => {
const item = { task, resolve, reject, signal };
queue.push(item);
signal?.addEventListener(
'abort',
() => {
const at = queue.indexOf(item);
if (at === -1) return;
queue.splice(at, 1);
reject(signal.reason);
},
{ once: true },
);
next();
});
},
get pending() {
return running + queue.length;
},
};
}
/**
* KartaView's public API, browser-side and keyless (it allows any origin).
* It speaks HTTP/1.1, so the browser opens six connections to it: tiles take
* at most three, photo lookups two and sequence pages one, so a lookup never
* 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, 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. */
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, { timedOut: true }),
),
timeoutMs,
);
let response = null;
let read = false;
try {
response = await fetchImpl(url, { signal: controller.signal });
if (response.status === 429)
throw new KartaViewSourceError('KartaView is rate-limiting requests', {
status: 429,
retryAfterSec: retryAfterSeconds(response) ?? 2,
});
if (!response.ok)
throw new KartaViewSourceError(
`KartaView answered HTTP ${response.status}`,
{ status: response.status },
);
const length = Number(response.headers?.get?.('content-length'));
const tooMuch = () =>
new KartaViewSourceError('KartaView sent too much data', {
status: response.status,
});
if (length > maxBytes) throw tooMuch();
// KartaView answers chunked, without a length: count while reading.
const reader = response.body?.getReader?.();
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 > maxBytes) {
reader.cancel().catch(() => {});
throw tooMuch();
}
chunks.push(value);
}
const body = new Uint8Array(bytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
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;
// 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?.(() => {});
}
}
}
/**
* A photo-API call through `gate`, retrying 429s with backoff and, once, a
* call that timed out.
*/
async function photoApi(
path,
params,
{ signal } = {},
{
gate = limiter,
timeoutMs = PHOTO_API_TIMEOUT_MS,
firstTimeoutMs = PHOTO_API_FIRST_TIMEOUT_MS,
} = {},
) {
const query = params ? `?${new URLSearchParams(params)}` : '';
const url = `${apiHost}${path}${query}`;
let timeouts = 0;
for (let attempt = 0; ; attempt++) {
try {
const payload = await gate.run(
// A slow first answer is usually quick when asked again.
() =>
getJson(url, {
signal,
timeoutMs: timeouts === 0 ? firstTimeoutMs : timeoutMs,
}),
{ signal },
);
const code = Number(payload?.status?.apiCode);
// 600 ok, 601 empty, 602 partial: anything else is a refusal.
if (Number.isFinite(code) && (code < 600 || code > 603))
throw new KartaViewSourceError(
`KartaView refused the request (code ${code})`,
{ status: Number(payload?.status?.httpCode) || 0 },
);
return payload?.result ?? {};
} catch (error) {
if (signal?.aborted) throw error;
const timedOut = error?.timedOut === true;
if (timedOut && timeouts++ < 1) continue;
if (error?.status !== 429 || attempt >= PHOTO_API_RETRIES) {
if (error?.status === 429)
throw new KartaViewSourceError(
'KartaView is rate-limiting requests — try again in a moment',
{ status: 429, retryAfterSec: error.retryAfterSec },
);
throw error;
}
await wait(
Math.min(
PHOTO_API_RETRY_MAX_MS,
Math.max(
retryBaseMs * 2 ** attempt,
(error.retryAfterSec ?? 0) * 1000,
),
),
signal,
);
}
}
}
return {
/** A coverage tile: `{lines: [{track: [{lat, lng}]}]}`. */
async getTile({ x, y, z }, { signal } = {}) {
const url = `${apiHost}/2.0/sequence/tiles/${x}/${y}/${z}.json`;
const once = () =>
tileLimiter.run(
() => getJson(url, { signal, timeoutMs: TILE_TIMEOUT_MS }),
{ signal },
);
try {
return await once();
} catch (error) {
// A cold dense tile can outlast the timeout; KartaView has it ready
// by the second ask.
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
* storage did not say (timeout, network, 5xx). It GETs and stops at the
* headers.
*/
async probeImage(url, { signal } = {}) {
if (!url) return false;
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(),
IMAGE_PROBE_TIMEOUT_MS,
);
try {
const response = await fetchImpl(url, { signal: controller.signal });
if (response.ok) return true;
return GONE_STATUSES.includes(response.status) ? false : null;
} catch {
if (signal?.aborted) throw signal.reason;
return null;
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
// The body is not needed: drop it.
controller.abort();
}
},
/**
* 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/',
{
lat: lat.toFixed(7),
lng: lon.toFixed(7),
zoomLevel: String(NEAREST_SEARCH_ZOOM),
itemsPerPage: String(NEAREST_LIMIT),
...photoFilterParams(filter),
},
{ signal },
);
return Array.isArray(result.data) ? result.data : [];
},
/** One photo record. */
async getPhoto(photoId, { signal } = {}) {
const result = await photoApi(
`/2.0/photo/${encodeURIComponent(photoId)}`,
null,
{ signal },
);
if (!result.data || typeof result.data !== 'object')
throw new KartaViewSourceError('KartaView has no such photo');
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,
{ page, itemsPerPage },
{ signal } = {},
) {
const result = await photoApi(
`/2.0/sequence/${encodeURIComponent(sequenceId)}/photos`,
{ page: String(page), itemsPerPage: String(itemsPerPage) },
{ signal },
{
gate: pageLimiter,
timeoutMs: PAGE_TIMEOUT_MS,
firstTimeoutMs: PAGE_TIMEOUT_MS,
},
);
return {
photos: Array.isArray(result.data) ? result.data : [],
hasMore: result.hasMoreData === true,
};
},
};
}
@@ -0,0 +1,728 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
KartaViewSourceError,
createKartaViewSource,
createLimiter,
} from './source.js';
import {
MASK_ATTEMPTS,
MASK_SLOW_MESSAGE,
NEAREST_LIMIT,
NEAREST_SEARCH_ZOOM,
PHOTO_API_CONCURRENCY,
PHOTO_API_RETRIES,
PHOTO_API_FIRST_TIMEOUT_MS,
PHOTO_API_TIMEOUT_MS,
PAGE_TIMEOUT_MS,
TILE_CONCURRENCY,
} from './policy.js';
/** A body streamed as browsers do, in `size`-byte chunks. */
function streamed(text, size = 16_384) {
const bytes = new TextEncoder().encode(text);
let at = 0;
return {
getReader: () => ({
read: async () => {
if (at >= bytes.length) return { done: true };
const value = bytes.slice(at, at + size);
at += size;
return { done: false, value };
},
cancel: async () => {},
}),
};
}
// A 200 as browsers deliver it: streamed, so the code under test reads it
// the way it does in production (`json` is there only to fail loudly).
const ok = (body) => ({
ok: true,
status: 200,
headers: new Map(),
body: streamed(JSON.stringify(body)),
json: async () => {
throw new Error('a browser answer is read from its stream');
},
});
const status = (code, headers = new Map()) => ({
ok: false,
status: code,
headers,
json: async () => ({}),
});
const photoPayload = (data) => ({
status: { apiCode: 600, httpCode: 200 },
result: { data, hasMoreData: false },
});
test('coverage tiles come from api.kartaview.org as JSON', async () => {
const urls = [];
const source = createKartaViewSource({
fetchImpl: async (url) => {
urls.push(url);
return ok({ lines: [] });
},
});
assert.deepEqual(await source.getTile({ x: 4412, y: 6061, z: 14 }), {
lines: [],
});
assert.deepEqual(urls, [
'https://api.kartaview.org/2.0/sequence/tiles/4412/6061/14.json',
]);
});
test('photo lookups run at most two at a time', async () => {
let running = 0;
let peak = 0;
const source = createKartaViewSource({
fetchImpl: async () => {
running++;
peak = Math.max(peak, running);
await new Promise((resolve) => setTimeout(resolve, 5));
running--;
return ok(photoPayload({ id: '1' }));
},
});
await Promise.all(
Array.from({ length: 7 }, (_, i) => source.getPhoto(String(i))),
);
assert.equal(peak, PHOTO_API_CONCURRENCY);
});
test('a 429 is retried after a wait, then answered', async () => {
let calls = 0;
const source = createKartaViewSource({
retryBaseMs: 1,
fetchImpl: async () => {
calls++;
return calls < 3
? status(429, new Map([['retry-after', '0']]))
: ok(photoPayload([{ id: '9' }]));
},
});
const records = await source.nearestPhotos({ lat: 42.3, lon: -83.04 });
assert.deepEqual(records, [{ id: '9' }]);
assert.equal(calls, 3);
});
test('rate-limiting that outlasts the retries is reported with a wait', async () => {
let calls = 0;
const source = createKartaViewSource({
retryBaseMs: 1,
fetchImpl: async () => {
calls++;
return status(429, new Map([['retry-after', '0.001']]));
},
});
await assert.rejects(source.getPhoto('1'), (error) => {
assert.ok(error instanceof KartaViewSourceError);
assert.equal(error.status, 429);
assert.ok(error.retryAfterSec > 0);
assert.match(error.message, /try again in a moment/);
return true;
});
assert.equal(calls, PHOTO_API_RETRIES + 1);
});
test('an API refusal inside a 200 is a readable error with its code; 601 is just empty', async () => {
const refusing = createKartaViewSource({
fetchImpl: async () =>
ok({
status: { apiCode: 612, apiMessage: ' out of range ', httpCode: 400 },
result: {},
}),
});
await assert.rejects(
refusing.getPhoto('1'),
/KartaView refused the request \(code 612\)/,
);
const empty = createKartaViewSource({
fetchImpl: async () =>
ok({ status: { apiCode: 601 }, result: { data: [] } }),
});
assert.deepEqual(await empty.nearestPhotos({ lat: 0, lon: 0 }), []);
const page = await empty.getSequencePhotos('2008', {
page: 2,
itemsPerPage: 1000,
});
assert.deepEqual(page, { photos: [], hasMore: false });
});
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');
},
});
// 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,
status: 200,
headers: new Map(),
json: async () => {
throw new SyntaxError('Unexpected token <');
},
}),
});
await assert.rejects(
garbled.getTile({ x: 0, y: 0, z: 14 }),
/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/,
);
});
test('an abort stops a queued lookup without fetching, and wins over a retry wait', async () => {
let calls = 0;
let release;
const gate = new Promise((resolve) => (release = resolve));
const source = createKartaViewSource({
retryBaseMs: 10_000,
fetchImpl: async (url) => {
calls++;
if (url.includes('/slow')) await gate;
return status(429);
},
});
// Two slow lookups fill the queue; the third waits for a slot.
const first = new AbortController();
const slow = [
source.getPhoto('slow', { signal: first.signal }),
source.getPhoto('slow', { signal: first.signal }),
];
const controller = new AbortController();
const queued = source.getPhoto('queued', { signal: controller.signal });
controller.abort(new Error('overtaken'));
await assert.rejects(queued, /overtaken/);
assert.equal(calls, 2, 'the queued lookup never fetched');
release();
// Each slow one is now waiting 10 s to retry; nothing else is asked.
const retrying = new AbortController();
const waiting = source.getPhoto('slow', { signal: retrying.signal });
await new Promise((resolve) => setTimeout(resolve, 20));
retrying.abort(new Error('closed'));
await assert.rejects(waiting, /closed/);
// The two first lookups are still waiting out their backoff: drop them.
first.abort(new Error('done'));
for (const promise of slow) await assert.rejects(promise, /done/);
});
test('the limiter runs tasks in order and keeps going after a failure', async () => {
const limiter = createLimiter(1);
const order = [];
const failing = limiter.run(async () => {
order.push('a');
throw new Error('a failed');
});
const second = limiter.run(async () => {
order.push('b');
return 'b';
});
await assert.rejects(failing, /a failed/);
assert.equal(await second, 'b');
assert.deepEqual(order, ['a', 'b']);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(limiter.pending, 0);
});
test('tiles, lookups and sequence pages queue separately, so a lookup never waits behind them', async () => {
const running = { tile: 0, photo: 0, page: 0 };
const peak = { tile: 0, photo: 0, page: 0 };
const release = [];
const kind = (url) =>
url.includes('/tiles/')
? 'tile'
: url.includes('/photos')
? 'page'
: 'photo';
const source = createKartaViewSource({
fetchImpl: async (url) => {
const k = kind(url);
running[k]++;
peak[k] = Math.max(peak[k], running[k]);
if (k !== 'photo') await new Promise((resolve) => release.push(resolve));
running[k]--;
return k === 'tile' ? ok({ lines: [] }) : ok(photoPayload([]));
},
});
const tiles = Array.from({ length: 9 }, (_, i) =>
source.getTile({ x: i, y: 0, z: 14 }),
);
const pages = [1, 2].map((page) =>
source.getSequencePhotos('1', { page, itemsPerPage: 500 }),
);
// Every slow tile and page is in flight or queued; a lookup still answers.
assert.deepEqual(await source.nearestPhotos({ lat: 1, lon: 1 }), []);
assert.equal(peak.tile, TILE_CONCURRENCY);
assert.equal(peak.page, 1);
while (release.length) {
release.shift()();
await new Promise((resolve) => setTimeout(resolve, 0));
}
await Promise.all([...tiles, ...pages]);
assert.equal(peak.tile, TILE_CONCURRENCY);
assert.equal(peak.page, 1);
});
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 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);
});
test('an image probe reads only the status: an archived photo (409) is not showable', async () => {
const seen = [];
const source = createKartaViewSource({
fetchImpl: async (url, { signal }) => {
seen.push(signal);
return {
ok: !url.includes('archived'),
status: url.includes('archived') ? 409 : 200,
};
},
});
assert.equal(
await source.probeImage('https://storage13.openstreetcam.org/ok.jpg'),
true,
);
assert.equal(
await source.probeImage('https://storage13.openstreetcam.org/archived.jpg'),
false,
);
assert.equal(await source.probeImage(null), false);
assert.ok(
seen.every((signal) => signal.aborted),
'the body is dropped',
);
const offline = createKartaViewSource({
fetchImpl: async () => {
throw new TypeError('Failed to fetch');
},
});
// No answer is not an answer: the caller may try again later.
assert.equal(
await offline.probeImage('https://x.openstreetcam.org/a.jpg'),
null,
);
const busy = createKartaViewSource({
fetchImpl: async () => ({ ok: false, status: 502 }),
});
assert.equal(
await busy.probeImage('https://x.openstreetcam.org/a.jpg'),
null,
);
});
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 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 () => {
asked++;
throw new TypeError('Failed to fetch');
},
});
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 () => {
let parsed = false;
const source = createKartaViewSource({
fetchImpl: async () => ({
ok: true,
status: 200,
headers: new Map([['content-length', String(64 * 1024 * 1024)]]),
json: async () => {
parsed = true;
return { lines: [] };
},
}),
});
await assert.rejects(source.getTile({ x: 1, y: 2, z: 13 }), /too much data/);
assert.equal(parsed, false);
});
test('sequence pages run one at a time, the newest first', async () => {
const order = [];
let release;
const gate = new Promise((resolve) => (release = resolve));
const source = createKartaViewSource({
fetchImpl: async (url) => {
const page = new URL(url).searchParams.get('page');
order.push(page);
if (page === '1') await gate;
return ok(photoPayload([]));
},
});
const pages = ['1', '2', '3'].map((page) =>
source.getSequencePhotos('9', { page, itemsPerPage: 250 }),
);
await new Promise((resolve) => setTimeout(resolve, 0));
release();
await Promise.all(pages);
assert.deepEqual(order, ['1', '3', '2']);
});
/** Every pending promise callback, without moving the mocked clock. */
const flush = () => new Promise((resolve) => setImmediate(resolve));
/**
* Run `call` on the mocked clock in 100 ms steps until it settles (60 s at
* most). `stamp()` reads the clock, for a fetch to record when it was asked.
*/
async function onMockClock(t, call, { until = 60_000 } = {}) {
let now = 0;
const stamp = () => now;
let outcome = null;
const promise = call(stamp);
promise.then(
(value) => (outcome = { value, at: now }),
(error) => (outcome = { error, at: now }),
);
while (!outcome && now < until) {
await flush();
if (outcome) break;
t.mock.timers.tick(100);
now += 100;
}
await flush();
return outcome;
}
test('429 waits double from 0.8 s, wait out a longer Retry-After up to 10 s, and 2 s without one', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
async function waits(retryAfter) {
const asked = [];
const headers = new Map(
retryAfter === undefined ? [] : [['retry-after', retryAfter]],
);
const outcome = await onMockClock(t, (stamp) =>
createKartaViewSource({
fetchImpl: async () => {
asked.push(stamp());
return asked.length <= PHOTO_API_RETRIES
? status(429, headers)
: ok(photoPayload({ id: '1' }));
},
}).getPhoto('1'),
);
assert.deepEqual(outcome?.value, { id: '1' }, `answered (${retryAfter})`);
return asked.slice(1).map((at, i) => at - asked[i]);
}
assert.deepEqual(await waits('0'), [800, 1600, 3200], 'doubling backoff');
assert.deepEqual(await waits('5'), [5000, 5000, 5000], 'Retry-After wins');
assert.deepEqual(await waits('30'), [10_000, 10_000, 10_000], 'capped');
assert.deepEqual(await waits(undefined), [2000, 2000, 3200], 'assumed 2 s');
});
test('a lookup that hangs is given up after a short first try, then the full timeout, then reported', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const asked = [];
const outcome = await onMockClock(t, (stamp) =>
createKartaViewSource({
fetchImpl: (url, { signal }) => {
asked.push(stamp());
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason)),
);
},
}).getPhoto('1'),
);
assert.deepEqual(asked, [0, PHOTO_API_FIRST_TIMEOUT_MS]);
assert.match(String(outcome?.error?.message), /did not answer in time/);
assert.equal(outcome.at, PHOTO_API_FIRST_TIMEOUT_MS + PHOTO_API_TIMEOUT_MS);
});
test('the nearest lookup asks for the few photos around the point at the search zoom', async () => {
const urls = [];
const source = createKartaViewSource({
fetchImpl: async (url) => {
urls.push(new URL(url));
return ok(photoPayload([]));
},
});
await source.nearestPhotos({ lat: 42.33, lon: -83.04 });
assert.equal(urls[0].pathname, '/2.0/photo/');
assert.deepEqual(Object.fromEntries(urls[0].searchParams), {
lat: '42.3300000',
lng: '-83.0400000',
zoomLevel: String(NEAREST_SEARCH_ZOOM),
itemsPerPage: String(NEAREST_LIMIT),
});
assert.equal(NEAREST_SEARCH_ZOOM, 18);
assert.equal(NEAREST_LIMIT, 10);
});
test('a chunked answer with no length is cut off once it grows past the cap', async () => {
let reads = 0;
let cancelled = false;
const chunk = new Uint8Array(6 * 1024 * 1024);
const source = createKartaViewSource({
fetchImpl: async () => ({
ok: true,
status: 200,
headers: new Map(),
body: {
getReader: () => ({
read: async () =>
++reads > 10 ? { done: true } : { done: false, value: chunk },
cancel: async () => {
cancelled = true;
},
}),
},
}),
});
await assert.rejects(source.getTile({ x: 1, y: 2, z: 13 }), /too much data/);
assert.equal(reads, 3, 'stopped at the third 6 MB chunk');
assert.equal(cancelled, true);
const small = createKartaViewSource({
fetchImpl: async () => {
const bytes = new TextEncoder().encode('{"lines":[]}');
let given = false;
return {
ok: true,
status: 200,
headers: new Map(),
body: {
getReader: () => ({
read: async () =>
given
? { done: true }
: ((given = true), { done: false, value: bytes }),
cancel: async () => {},
}),
},
};
},
});
assert.deepEqual(await small.getTile({ x: 1, y: 2, z: 13 }), { lines: [] });
});
test('a chunked answer split mid-character is reassembled in order', async () => {
const payload = {
lines: [
{
track: [{ lat: '1', lng: '2' }],
name: 'Strada Memorandumului — Cluj-Napoca 😀',
},
],
note: 'ăîşţ'.repeat(200),
};
const source = createKartaViewSource({
fetchImpl: async () => ({
ok: true,
status: 200,
headers: new Map(),
// 3-byte chunks cut multi-byte characters in two.
body: streamed(JSON.stringify(payload), 3),
}),
});
assert.deepEqual(await source.getTile({ x: 1, y: 2, z: 13 }), payload);
});
test('a sequence page gets the full 30 s on its first try, then 30 s once more', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const asked = [];
const outcome = await onMockClock(
t,
(stamp) =>
createKartaViewSource({
fetchImpl: (url, { signal }) => {
asked.push(stamp());
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason)),
);
},
}).getSequencePhotos('9', { page: 1, itemsPerPage: 250 }),
{ until: 120_000 },
);
assert.deepEqual(asked, [0, PAGE_TIMEOUT_MS]);
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);
});
@@ -0,0 +1,698 @@
import {
createPanoramaRenderer,
PANO_FOV_DEFAULT_DEG,
wrapDegrees,
} from './panorama.js';
import {
FLAT_FULL_SIZE,
FLAT_VIEW_SIZE,
IMAGE_TIMEOUT_MS,
KARTAVIEW_PROVIDER_ID,
PANO_FULL_SIZE,
PANO_MID_SIZE,
PANO_PREVIEW_SIZE,
cameraBearing,
kartaViewPhotoUrl,
resizedImageUrl,
} from './policy.js';
const LOAD_FAILED = 'The KartaView photo could not be loaded';
/**
* Load an image within the timeout. A CORS-clean one (for WebGL) is decoded
* off the main thread into an ImageBitmap where the browser can: uploading
* a decoded bitmap costs a fraction of uploading an <img> (90 ms → 15 ms for
* 4096×2048 on a phone-class CPU).
*/
export function loadImage(
url,
{ crossOrigin = true, timeoutMs = IMAGE_TIMEOUT_MS } = {},
) {
if (
crossOrigin &&
url &&
typeof globalThis.createImageBitmap === 'function' &&
typeof globalThis.fetch === 'function'
)
return loadBitmap(url, timeoutMs);
return new Promise((resolve, reject) => {
if (!url) return reject(new Error(LOAD_FAILED));
const image = new Image();
if (crossOrigin) image.crossOrigin = 'anonymous';
image.decoding = 'async';
const timer = setTimeout(() => {
image.src = '';
reject(new Error(LOAD_FAILED));
}, timeoutMs);
image.onload = () => {
clearTimeout(timer);
resolve(image);
};
image.onerror = () => {
clearTimeout(timer);
reject(new Error(LOAD_FAILED));
};
image.src = url;
});
}
async function loadBitmap(url, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
mode: 'cors',
signal: controller.signal,
});
if (!response.ok) throw new Error(LOAD_FAILED);
return await createImageBitmap(await response.blob());
} catch {
throw new Error(LOAD_FAILED);
} finally {
clearTimeout(timer);
}
}
/** Free a decoded bitmap nobody will draw (an <img> has nothing to free). */
function release(image) {
image?.close?.();
}
/** The panorama size a canvas this wide can show sharply at this field of view. */
export function panoSizeFor(canvasPixels, fovDeg) {
const needed = (Math.max(1, canvasPixels) * 360) / Math.max(1, fovDeg);
return needed > PANO_MID_SIZE[0] ? PANO_FULL_SIZE : PANO_MID_SIZE;
}
/**
* Viewer adapter for KartaView photos: a flat photo in an <img>, a 360°
* photo on a WebGL panorama (drag to look, wheel to zoom), and ‹ › to step
* along the sequence (also ← →). Poses follow the view direction.
*
* @param {object} options
* @param {(photoId: string) => Promise<object>} options.getPhoto A normalized photo (policy.js normalizePhoto).
* @param {(photo: object, delta: -1|1) => Promise<string|null>} options.step The photo `delta` along the sequence, or null at its end.
* @param {(imageId: string) => void} options.onStep Open the photo ‹ › chose (through the core, so loading and errors show).
* @param {(photo: object) => void} [options.onShown] A photo is on screen.
* @param {(imageId: string) => void} [options.onReopen] Draw the same photo again after a GPU reset (default: `onStep`).
* @param {object} [options.render] Render governor.
* @param {(url: string, options?: object) => Promise<HTMLImageElement>} [options.load]
* @param {(canvas: HTMLCanvasElement) => object} [options.createRenderer]
* @returns {import('../../registry.js').ViewerAdapter}
*/
export function createKartaViewViewer({
getPhoto,
step,
onStep,
onShown = () => {},
onReopen = onStep,
render,
load = loadImage,
createRenderer = createPanoramaRenderer,
}) {
const listeners = new Set();
let root = null;
let picture = null;
let canvas = null;
let prevButton = null;
let nextButton = null;
let renderer = null;
/** WebGL failed once: 360° photos show their front view instead. */
let rendererFailed = false;
let photo = null;
/** What shows: 'flat', 'pano' or null. */
let showing = null;
let openSeq = 0;
let renderMode = 'letterbox';
let drag = null;
let poseFrame = 0;
let removeListeners = [];
/** The document the viewer was built in (new canvases come from it). */
let documentOf = null;
/** A context loss's look direction, for the reopen of that photo. */
let restoreView = null;
/** Width of the panorama texture on screen (0: the preview). */
let panoWidth = 0;
/** The flat photo on screen is the 2,560 px version. */
let flatSharp = false;
/** The sharper version loading ({seq, kind}): a resize starts no second one. */
let upgrading = null;
/** A resize came while it loaded: look again once it lands. */
let upgradeAgain = false;
/** The photo an open is fetching: ‹ › step on from it, not the old one. */
let openingId = null;
/** ‹ › chose this photo: keep the panorama's look direction for it. */
let keepViewFor = null;
/** ‹ › presses run one after another, each from where the last one went. */
let steps = Promise.resolve();
function requestRender() {
render?.governorRequestRender?.('kartaview-viewer');
}
function emit() {
if (!photo || !showing) return;
// 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 = cameraBearing(photo);
const pose = {
providerId: KARTAVIEW_PROVIDER_ID,
imageId: photo.id,
position: { lon: photo.lon, lat: photo.lat },
bearing:
heading === null
? null
: view
? wrapDegrees(heading + view.yaw)
: heading,
tilt: view ? view.pitch : 0,
altitude: null,
isPano: showing === 'pano',
capturedAt: photo.capturedAt,
capturedAtPrecision: 'day',
// KartaView's only place name is the sequence's, where the drive began.
title: null,
creator: null,
sequenceId: photo.sequenceId,
externalUrl: kartaViewPhotoUrl(photo),
};
for (const listener of [...listeners]) {
try {
listener(pose);
} catch {
/* listener errors are the core's to log */
}
}
requestRender();
}
/** Re-emit on the next frame: a drag moves the view many times a frame. */
function emitSoon() {
if (poseFrame) return;
poseFrame = requestAnimationFrame(() => {
poseFrame = 0;
emit();
});
}
function syncButtons() {
const enabled = Boolean(photo?.sequenceId) && showing !== null;
if (prevButton) prevButton.disabled = !enabled;
if (nextButton) nextButton.disabled = !enabled;
if (enabled) checkEnds(photo);
}
/** Disable ‹ or › at once where the sequence ends (it also warms the neighbours). */
function checkEnds(target) {
for (const [delta, button] of [
[-1, prevButton],
[1, nextButton],
])
step(target, delta).then(
(id) => {
if (photo === target && button && !id) disable(button);
},
() => {},
);
}
/** Disable a step button, keeping keyboard focus in the viewer. */
function disable(button) {
if (button.ownerDocument?.activeElement === button) root?.focus?.();
button.disabled = true;
}
function show(kind) {
showing = kind;
if (picture) picture.hidden = kind !== 'flat';
if (canvas) canvas.hidden = kind !== 'pano';
if (root) root.dataset.kind = kind || '';
syncButtons();
}
function applyRenderMode() {
if (picture)
picture.style.objectFit = renderMode === 'fill' ? 'cover' : 'contain';
}
function ensureRenderer() {
if (renderer || rendererFailed || !canvas) return renderer;
try {
renderer = createRenderer(canvas);
} catch {
rendererFailed = true;
renderer = null;
}
return renderer;
}
/**
* A lost WebGL context (GPU reset, memory pressure) takes the panorama
* with it: drop the renderer, and show the photo again on a new one.
*/
function onContextLost(event) {
event.preventDefault?.();
const lost = renderer;
renderer = null;
// The new renderer starts facing the photo's heading: keep the user's look.
if (showing === 'pano' && photo && lost)
restoreView = { id: photo.id, view: lost.getView() };
try {
lost?.destroy();
} catch {
/* already gone */
}
// A lost canvas hands back its lost context: draw on a new element.
const fresh = makeCanvas(documentOf);
fresh.hidden = canvas.hidden;
canvas.replaceWith?.(fresh);
canvas = fresh;
// Show the photo again through the core, unless a newer one is opening.
if (showing === 'pano' && photo && openingId === null) {
const again = photo.id;
show(null);
openingId = again;
onReopen(again);
}
}
/** The panorama canvas, wired for drag, pinch, wheel and context loss. */
function makeCanvas(doc) {
const element = doc.createElement('canvas');
element.className = 'sl-kv-pano';
element.hidden = true;
listen(element, 'pointerdown', onPointerDown);
listen(element, 'pointermove', onPointerMove);
listen(element, 'pointerup', onPointerUp);
listen(element, 'pointercancel', onPointerUp);
listen(element, 'wheel', onWheel, { passive: false });
listen(element, 'webglcontextlost', onContextLost);
return element;
}
/**
* Step along the sequence from the photo showing, or from the one on its
* way after an earlier press, and open it through the core.
*/
function stepBy(delta) {
steps = steps.then(async () => {
const fromId = openingId ?? photo?.id;
if (!fromId || !root) return;
let from;
let nextId = null;
try {
from = await getPhoto(fromId);
if (from.sequenceId) nextId = await step(from, delta);
} catch {
// Not the end of the sequence, just no answer: the press is lost.
return;
}
// Closed, or another photo was opened meanwhile.
if ((openingId ?? photo?.id) !== fromId) return;
if (!nextId) {
const button = delta < 0 ? prevButton : nextButton;
if (button && photo?.id === fromId) disable(button);
return;
}
openingId = nextId;
keepViewFor = nextId;
onStep(nextId);
});
}
/** Pointers on the panorama: one drags the view, two pinch the zoom. */
const pointers = new Map();
let pinch = null;
function pinchSpan() {
const [a, b] = [...pointers.values()];
return Math.max(1, Math.hypot(a.x - b.x, a.y - b.y));
}
function onPointerDown(event) {
if (showing !== 'pano' || !renderer) return;
if (event.pointerType === 'mouse' && event.button !== 0) return;
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
canvas.setPointerCapture?.(event.pointerId);
if (pointers.size === 2) {
drag = null;
pinch = { span: pinchSpan(), fov: renderer.getView().fov };
return;
}
drag = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
view: renderer.getView(),
};
canvas.classList.add('sl-kv-dragging');
}
function onPointerMove(event) {
if (pointers.has(event.pointerId))
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (pinch && pointers.size === 2 && renderer) {
renderer.setView({ fov: pinch.fov * (pinch.span / pinchSpan()) });
return;
}
if (!drag || event.pointerId !== drag.id || !renderer) return;
const { fov } = drag.view;
const width = Math.max(1, canvas.clientWidth);
const height = Math.max(1, canvas.clientHeight);
const vertical = fov;
const horizontal = fov * (width / height);
renderer.setView({
yaw: drag.view.yaw - ((event.clientX - drag.x) / width) * horizontal,
pitch: drag.view.pitch + ((event.clientY - drag.y) / height) * vertical,
});
emitSoon();
}
function onPointerUp(event) {
pointers.delete(event.pointerId);
if (pointers.size < 2) pinch = null;
if (!drag || event.pointerId !== drag.id) return;
drag = null;
canvas.classList.remove('sl-kv-dragging');
}
function onWheel(event) {
if (showing !== 'pano' || !renderer) return;
event.preventDefault();
const { fov } = renderer.getView();
renderer.setView({ fov: fov * Math.exp(event.deltaY * 0.001) });
}
function onKeyDown(event) {
if (
event.defaultPrevented ||
event.altKey ||
event.ctrlKey ||
event.metaKey
)
return;
const pano = showing === 'pano' && renderer;
const turn = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
// Shift+← → look around a panorama; ← → step along the sequence.
if (turn && event.shiftKey && pano) {
event.preventDefault();
const { yaw, fov } = renderer.getView();
renderer.setView({ yaw: yaw + (turn * fov) / 4 });
emitSoon();
} else if (turn) {
event.preventDefault();
stepBy(turn);
} else if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && pano) {
event.preventDefault();
const { pitch, fov } = renderer.getView();
renderer.setView({
pitch: pitch + ((event.key === 'ArrowUp' ? 1 : -1) * fov) / 6,
});
emitSoon();
} else if (['+', '=', '-', '_'].includes(event.key) && pano) {
event.preventDefault();
const { fov } = renderer.getView();
renderer.setView({
fov: fov * (event.key === '+' || event.key === '=' ? 0.85 : 1 / 0.85),
});
}
}
function listen(target, type, handler, options) {
target.addEventListener(type, handler, options);
removeListeners.push(() =>
target.removeEventListener(type, handler, options),
);
}
function build(host) {
const doc = host.ownerDocument;
documentOf = doc;
root = doc.createElement('div');
root.className = 'sl-kv-viewer';
root.tabIndex = 0;
root.setAttribute('role', 'group');
root.setAttribute(
'aria-label',
'KartaView photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom',
);
picture = doc.createElement('img');
picture.className = 'sl-kv-photo';
picture.alt = 'KartaView street-level photo';
picture.draggable = false;
picture.hidden = true;
canvas = makeCanvas(doc);
const nav = (label, glyph, delta) => {
const button = doc.createElement('button');
button.type = 'button';
button.className = `sl-kv-step sl-kv-step-${delta < 0 ? 'prev' : 'next'}`;
button.textContent = glyph;
button.title = label;
button.setAttribute('aria-label', label);
button.disabled = true;
listen(button, 'click', (event) => {
event.stopPropagation();
stepBy(delta);
});
return button;
};
prevButton = nav('Previous photo in this sequence (←)', '‹', -1);
nextButton = nav('Next photo in this sequence (→)', '›', 1);
root.append(picture, canvas, prevButton, nextButton);
host.append(root);
listen(root, 'keydown', onKeyDown);
applyRenderMode();
}
function teardown() {
pointers.clear();
pinch = null;
for (const remove of removeListeners) remove();
removeListeners = [];
if (poseFrame) cancelAnimationFrame(poseFrame);
poseFrame = 0;
drag = null;
renderer?.destroy();
renderer = null;
root?.remove();
root = picture = canvas = prevButton = nextButton = null;
showing = null;
photo = null;
}
/** A sharper version once the first is on screen, if nothing replaced it. */
async function upgrade(target, seq, kind) {
// A 360° photo shown flat (no WebGL) keeps its front view.
if (kind === 'flat' && target.isPano) return;
const size = kind === 'pano' ? panoSizeNow() : FLAT_FULL_SIZE;
if (kind === 'pano' && size[0] <= panoWidth) return;
// 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.
if (kind === 'flat' && (flatSharp || flatPixels() <= FLAT_VIEW_SIZE[0]))
return;
const url =
kind === 'pano'
? resizedImageUrl(target.urls.pano, size)
: resizedImageUrl(target.urls.full, size);
if (!url) 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 (upgrading?.seq === seq && upgrading.kind === kind) {
upgradeAgain = true;
return;
}
const pending = (upgrading = { seq, kind });
upgradeAgain = false;
let image = null;
try {
image = await load(url, { crossOrigin: kind === 'pano' });
if (seq !== openSeq || photo !== target || showing !== kind) return;
if (kind === 'pano') {
if (!renderer || size[0] <= panoWidth) return;
renderer.setImage(image);
image = null;
panoWidth = size[0];
} else {
picture.src = image.src;
image = null;
flatSharp = true;
}
requestRender();
} catch {
/* the first version stays */
} finally {
release(image);
if (upgrading === pending) {
upgrading = null;
if (upgradeAgain && seq === openSeq && photo === target) {
upgradeAgain = false;
upgrade(target, seq, showing);
}
}
}
}
/** Device pixels across the flat photo's frame. */
function flatPixels() {
const ratio = globalThis.devicePixelRatio || 1;
return (
Math.max(picture?.clientWidth || 0, picture?.clientHeight || 0) * ratio
);
}
/** The sharp panorama this viewer needs now: 2,048 docked, 4,096 expanded. */
function panoSizeNow() {
const ratio = Math.min(globalThis.devicePixelRatio || 1, 2);
const width = (canvas?.clientWidth || 0) * ratio;
return panoSizeFor(width, renderer?.getView().fov ?? PANO_FOV_DEFAULT_DEG);
}
async function showPano(target, seq, keepView) {
// Only the proxy can shrink a 13,000 px panorama: if it fails, the
// caller shows the front view rather than waiting on it twice.
const image = await load(
resizedImageUrl(target.urls.pano, PANO_PREVIEW_SIZE),
{ crossOrigin: true },
);
if (seq !== openSeq) {
release(image);
return false;
}
// The context may have been lost while this loaded: build on the new
// canvas, or let the caller show the front view.
if (!ensureRenderer()) {
release(image);
throw new Error(LOAD_FAILED);
}
const restore = restoreView?.id === target.id ? restoreView.view : null;
restoreView = null;
if (restore) renderer.setView(restore);
else if (!keepView || showing !== 'pano')
renderer.setView({ yaw: 0, pitch: 0, fov: PANO_FOV_DEFAULT_DEG });
photo = target;
// Shown first: a hidden canvas measures 0 and would be sized 1×1.
show('pano');
renderer.setImage(image);
panoWidth = 0;
return true;
}
async function showFlat(target, seq) {
// The proxy's 1,280 px version is a few hundred kB; KartaView's own
// "large thumbnail" can be 1.7 MB. Either serves when the other fails.
// A 360° photo's front view is only on storage.
const proxied = target.isPano
? null
: resizedImageUrl(target.urls.full, FLAT_VIEW_SIZE);
const sources = [proxied, target.urls.view].filter(Boolean);
let image;
let failure = null;
for (const url of sources) {
try {
image = await load(url, { crossOrigin: false });
break;
} catch (error) {
failure = error;
if (seq !== openSeq) return false;
}
}
if (!image) throw failure || new Error(LOAD_FAILED);
if (seq !== openSeq) return false;
picture.src = image.src;
flatSharp = false;
photo = target;
show('flat');
return true;
}
async function openPhoto(id, seq, keepView) {
const target = await getPhoto(id);
if (seq !== openSeq) return;
let shown = false;
if (target.isPano && target.urls.pano && ensureRenderer()) {
try {
shown = await showPano(target, seq, keepView);
} catch {
// The panorama failed: its front view is better than nothing.
if (seq !== openSeq) return;
shown = await showFlat(target, seq);
}
} else shown = await showFlat(target, seq);
if (!shown) return;
emit();
onShown(target);
upgrade(target, seq, showing);
}
const adapter = {
async mount(host) {
if (!host) throw new Error('Street Level viewer has no host element');
if (root && root.parentNode === host) return;
teardown();
rendererFailed = false;
build(host);
},
/** Resolves once the photo is on screen and its pose was emitted. */
async open(imageId) {
if (!root) throw new Error('KartaView viewer is not mounted');
const id = String(imageId);
const seq = ++openSeq;
const keepView = keepViewFor === id;
keepViewFor = null;
openingId = id;
try {
await openPhoto(id, seq, keepView);
} finally {
if (openingId === id && seq === openSeq) openingId = null;
}
},
close() {
openSeq++;
openingId = null;
keepViewFor = null;
photo = null;
show(null);
if (picture) picture.removeAttribute('src');
renderer?.clear?.();
},
unmount() {
openSeq++;
openingId = null;
keepViewFor = null;
teardown();
},
resize() {
renderer?.resize();
// Expanded: a docked viewer's first version is now too soft.
if (showing === 'pano' && photo && panoSizeNow()[0] > panoWidth)
upgrade(photo, openSeq, 'pano');
if (showing === 'flat' && photo && !flatSharp)
upgrade(photo, openSeq, 'flat');
},
/** FIT / FILL frame a flat photo; a panorama has no frame to fit. */
supportsRenderModes: () => showing !== 'pano',
setRenderMode(mode) {
renderMode = mode === 'fill' ? 'fill' : 'letterbox';
applyRenderMode();
},
/** The photo's direction became known (its drive loaded): pose again. */
refreshPose() {
emit();
},
onPose(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
return adapter;
}
@@ -0,0 +1,984 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createKartaViewViewer, loadImage, panoSizeFor } from './viewer.js';
import { fakeHost, until } from '../../../../testSupport/kartaViewFakes.mjs';
const PHOTOS = {
flat1: {
id: 'flat1',
sequenceId: 's1',
sequenceIndex: 1,
lon: -83.04,
lat: 42.33,
heading: 90,
isPano: false,
capturedAt: Date.UTC(2016, 7, 23),
urls: {
view: 'https://storage2.openstreetcam.org/lth/a.jpg',
full: 'https://storage2.openstreetcam.org/proc/a.jpg',
pano: null,
},
},
flat2: {
id: 'flat2',
sequenceId: 's1',
sequenceIndex: 2,
lon: -83.0401,
lat: 42.33,
heading: 92,
isPano: false,
capturedAt: Date.UTC(2016, 7, 23),
urls: {
view: 'https://storage2.openstreetcam.org/lth/b.jpg',
full: 'https://storage2.openstreetcam.org/proc/b.jpg',
pano: null,
},
},
pano1: {
id: 'pano1',
sequenceId: 's2',
sequenceIndex: 7,
lon: 106.84,
lat: -6.19,
heading: 150,
isPano: true,
capturedAt: Date.UTC(2022, 2, 21),
urls: {
view: 'https://storage13.openstreetcam.org/lth/p.jpg',
full: 'https://storage13.openstreetcam.org/wrapped_proc/p.jpg',
pano: 'https://storage13.openstreetcam.org/wrapped_proc/p.jpg',
},
},
pano2: {
id: 'pano2',
sequenceId: 's2',
sequenceIndex: 8,
lon: 106.8401,
lat: -6.19,
heading: 152,
isPano: true,
capturedAt: Date.UTC(2022, 2, 21),
urls: {
view: 'https://storage13.openstreetcam.org/lth/q.jpg',
full: 'https://storage13.openstreetcam.org/wrapped_proc/q.jpg',
pano: 'https://storage13.openstreetcam.org/wrapped_proc/q.jpg',
},
},
};
function fakeRenderer(canvas) {
const view = { yaw: 0, pitch: 0, fov: 80 };
return {
canvas,
images: [],
hiddenAtSetImage: [],
setImage(image) {
this.images.push(image.src);
// A hidden canvas measures 0×0: sizing it then gives a 1×1 buffer.
this.hiddenAtSetImage.push(canvas?.hidden);
},
setView(next) {
Object.assign(view, next);
},
getView: () => ({ ...view }),
resize() {},
clear() {},
destroy() {
this.destroyed = true;
},
};
}
PHOTOS.pano2 = {
...PHOTOS.pano1,
id: 'pano2',
sequenceIndex: 8,
urls: {
view: 'https://storage13.openstreetcam.org/lth/p2.jpg',
full: 'https://storage13.openstreetcam.org/wrapped_proc/p2.jpg',
pano: 'https://storage13.openstreetcam.org/wrapped_proc/p2.jpg',
},
};
function setup({
failLoads = [],
rendererThrows = false,
steps = {},
failSteps = false,
hold = () => false,
} = {}) {
const loads = [];
const held = [];
const closed = [];
const renderers = [];
const stepped = [];
const asked = [];
const attempts = { renderer: 0 };
const viewer = createKartaViewViewer({
getPhoto: async (id) => {
if (!PHOTOS[id]) throw new Error('no such photo');
return PHOTOS[id];
},
step: async (photo, delta) => {
asked.push(`${photo.id}:${delta}`);
if (failSteps) throw new Error('KartaView did not answer in time');
return steps[`${photo.id}:${delta}`] ?? null;
},
// The core opens what ‹ › chose; here the adapter is opened directly.
onStep: (id) => {
stepped.push(id);
viewer.open(id).catch(() => {});
},
load: async (url, options) => {
loads.push({ url, crossOrigin: options?.crossOrigin });
if (failLoads.some((part) => url.includes(part)))
throw new Error('The KartaView photo could not be loaded');
// 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++;
if (rendererThrows) throw new Error('WebGL is unavailable');
const renderer = fakeRenderer(canvas);
renderers.push(renderer);
return renderer;
},
});
const poses = [];
viewer.onPose((pose) => poses.push(pose));
const host = fakeHost();
return {
viewer,
poses,
host,
loads,
renderers,
stepped,
asked,
attempts,
held,
closed,
};
}
const parts = (host) => {
const root = host.children[0];
const [picture, canvas, prev, next] = root.children;
return { root, picture, canvas, prev, next };
};
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
test('a flat photo shows its 1280 px version, then the sharp one, with a pose facing its heading', async () => {
const { viewer, poses, host, loads } = setup({
steps: { 'flat1:1': 'flat2', 'flat1:-1': 'flat0' },
});
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);
assert.equal(canvas.hidden, true);
assert.equal(prev.disabled, false);
assert.equal(next.disabled, false);
const pose = poses.at(-1);
assert.deepEqual(
{
providerId: pose.providerId,
imageId: pose.imageId,
position: pose.position,
bearing: pose.bearing,
tilt: pose.tilt,
isPano: pose.isPano,
sequenceId: pose.sequenceId,
capturedAt: pose.capturedAt,
title: pose.title,
},
{
providerId: 'kartaview',
imageId: 'flat1',
position: { lon: -83.04, lat: 42.33 },
bearing: 90,
tilt: 0,
isPano: false,
sequenceId: 's1',
capturedAt: Date.UTC(2016, 7, 23),
title: null,
},
);
assert.match(pose.externalUrl, /^https:\/\/cdn\.kartaview\.org\/pr:sharp\//);
// The proxy's 1,280 px version first: KartaView's own can be 1.7 MB.
assert.match(
loads[0].url,
/^https:\/\/cdn\.kartaview\.org\/.*rs:fit:1280:1280/,
);
assert.equal(loads[0].crossOrigin, false, 'an <img> needs no CORS');
await settle();
assert.match(
loads[1].url,
/^https:\/\/cdn\.kartaview\.org\/.*rs:fit:2560:2560/,
);
assert.equal(picture.src, loads[1].url, 'upgraded to the sharp version');
assert.equal(viewer.supportsRenderModes(), true);
viewer.setRenderMode('fill');
assert.equal(picture.style.objectFit, 'cover');
});
test('a failing image proxy falls back to the photo on KartaView storage', async () => {
const { viewer, host, loads } = setup({ failLoads: ['cdn.kartaview.org'] });
await viewer.mount(host);
await viewer.open('flat1');
assert.equal(loads[1].url, PHOTOS.flat1.urls.view);
assert.equal(parts(host).picture.hidden, false);
assert.equal(parts(host).picture.src, PHOTOS.flat1.urls.view);
});
test('a 360° photo shows on the panorama, preview first; dragging turns the bearing', async () => {
const { viewer, poses, host, loads, renderers } = setup();
await viewer.mount(host);
await viewer.open('pano1');
const { picture, canvas } = parts(host);
assert.equal(canvas.hidden, false);
assert.equal(picture.hidden, true);
assert.match(loads[0].url, /rs:fit:1024:512/);
assert.equal(loads[0].crossOrigin, true, 'WebGL textures need CORS');
assert.equal(poses.at(-1).isPano, true);
assert.equal(poses.at(-1).bearing, 150);
assert.equal(viewer.supportsRenderModes(), false, 'no frame to fit');
await settle();
// Docked (400 px at 80°), 2,048 px is all it can show.
assert.match(renderers[0].images.at(-1), /rs:fit:2048:1024/);
// Drag left by a quarter of the width: the view turns right.
const frames = [];
globalThis.requestAnimationFrame = (callback) => {
frames.push(callback);
return frames.length;
};
try {
canvas.fire('pointerdown', {
button: 0,
pointerId: 1,
clientX: 200,
clientY: 100,
});
canvas.fire('pointermove', { pointerId: 1, clientX: 100, clientY: 100 });
canvas.fire('pointerup', { pointerId: 1 });
frames.forEach((callback) => callback());
} finally {
delete globalThis.requestAnimationFrame;
}
const turned = poses.at(-1).bearing;
assert.ok(turned > 150 && turned < 360, `bearing ${turned} turned right`);
});
test('without WebGL, or when the panorama fails, a 360° photo shows its front view', async () => {
const noGl = setup({ rendererThrows: true });
await noGl.viewer.mount(noGl.host);
await noGl.viewer.open('pano1');
assert.equal(parts(noGl.host).picture.hidden, false);
assert.equal(noGl.loads[0].url, PHOTOS.pano1.urls.view);
assert.equal(noGl.poses.at(-1).isPano, false);
await settle();
assert.equal(
noGl.loads.length,
1,
'the equirectangular image never replaces it',
);
const broken = setup({ failLoads: ['cdn.kartaview.org'] });
await broken.viewer.mount(broken.host);
await broken.viewer.open('pano1');
assert.equal(parts(broken.host).picture.hidden, false);
assert.equal(broken.poses.at(-1).imageId, 'pano1');
});
test('‹ › and the arrow keys step along the sequence; ends disable at once', async () => {
const { viewer, poses, host, stepped, asked } = setup({
steps: { 'flat1:1': 'flat2', 'flat2:-1': 'flat1' },
});
await viewer.mount(host);
await viewer.open('flat1');
const { root, prev, next } = parts(host);
assert.ok(
await until(() => prev.disabled),
'flat1 is the first photo: known up front',
);
assert.equal(next.disabled, false);
next.fire('click');
assert.ok(await until(() => poses.at(-1).imageId === 'flat2'));
assert.deepEqual(stepped, ['flat2'], 'the core is asked to open it');
assert.ok(await until(() => next.disabled), 'flat2 is the last photo');
assert.equal(prev.disabled, false);
// A modified arrow, then a plain → (at the end: it only asks). Presses run
// in turn, so once → has asked, a stepping ⌘← would have asked first.
const before = asked.length;
root.fire('keydown', { key: 'ArrowLeft', metaKey: true });
root.fire('keydown', { key: 'ArrowRight' });
assert.ok(await until(() => asked.length > before));
await settle();
assert.deepEqual(
asked.slice(before),
['flat2:1'],
'modified arrows are not ours',
);
assert.equal(poses.at(-1).imageId, 'flat2');
root.fire('keydown', { key: 'ArrowLeft' });
assert.ok(await until(() => poses.at(-1).imageId === 'flat1'));
});
test('quick presses each step on from the photo the last one chose', async () => {
const chain = { 'flat1:1': 'flat2', 'flat2:1': 'pano1' };
const { viewer, host, stepped } = setup({
steps: chain,
rendererThrows: true,
});
await viewer.mount(host);
await viewer.open('flat1');
const { next } = parts(host);
next.fire('click');
next.fire('click');
assert.ok(await until(() => stepped.length === 2), `stepped ${stepped}`);
assert.deepEqual(stepped, ['flat2', 'pano1']);
});
test('a newer open wins; close hides the photo; unmount tears down the panorama', async () => {
const { viewer, poses, host, renderers } = setup();
await viewer.mount(host);
const first = viewer.open('pano1');
const second = viewer.open('flat1');
await Promise.all([first, second]);
assert.deepEqual(
poses.map((pose) => pose.imageId),
['flat1'],
'the overtaken panorama never posed',
);
assert.equal(renderers.length, 0, 'nor built a WebGL context');
await viewer.open('pano1');
viewer.close();
const { picture, canvas, root } = parts(host);
assert.equal(canvas.hidden, true);
assert.equal(picture.hidden, true);
assert.equal(root.dataset.kind, '');
await assert.rejects(viewer.open('nope'), /no such photo/);
viewer.unmount();
assert.equal(host.children.length, 0);
assert.equal(renderers[0].destroyed, true);
await assert.rejects(viewer.open('flat1'), /not mounted/);
});
test('a panorama is put on screen before its image is sized into the canvas', async () => {
const { viewer, host, renderers } = setup();
await viewer.mount(host);
await viewer.open('flat1');
await viewer.open('pano1');
await settle();
assert.deepEqual(renderers[0].hiddenAtSetImage, [false, false]);
});
test('a step that gets no answer is not the end of the sequence', async () => {
const { viewer, host, poses, asked } = setup({ failSteps: true });
await viewer.mount(host);
await viewer.open('flat1');
const { next } = parts(host);
next.disabled = false;
// The ends check asked once already; the press asks again and fails.
const asks = () => asked.filter((ask) => ask === 'flat1:1').length;
next.fire('click');
assert.ok(await until(() => asks() === 2), 'the press asked');
await settle();
assert.equal(next.disabled, false, 'still enabled: try again');
assert.equal(poses.at(-1).imageId, 'flat1');
});
test('the keyboard looks around a panorama: Shift+arrows turn, ↑ ↓ tilt, + − zoom', async (t) => {
globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0);
t.after(() => delete globalThis.requestAnimationFrame);
const { viewer, host, renderers, stepped, asked } = setup({
steps: { 'pano1:1': 'flat1' },
});
await viewer.mount(host);
await viewer.open('pano1');
const { root } = parts(host);
const view = () => renderers[0].getView();
const before = asked.length;
root.fire('keydown', { key: 'ArrowRight', shiftKey: true });
assert.ok(view().yaw > 0, 'turned right');
root.fire('keydown', { key: 'ArrowUp' });
assert.ok(view().pitch > 0, 'looked up');
const fov = view().fov;
root.fire('keydown', { key: '+' });
assert.ok(view().fov < fov, 'zoomed in');
root.fire('keydown', { key: '-' });
root.fire('keydown', { key: '-' });
assert.ok(view().fov > fov, 'zoomed out');
root.fire('keydown', { key: 'ArrowRight' });
assert.ok(await until(() => stepped.length === 1));
assert.deepEqual(stepped, ['flat1'], 'a plain arrow still steps');
// Only that press asked, then flat1's ends check: a Shift+→ that stepped
// would have sent the plain → on from flat1.
assert.ok(await until(() => asked.slice(before).length >= 3));
await settle();
assert.deepEqual(
asked.slice(before).sort(),
['flat1:-1', 'flat1:1', 'pano1:1'],
'none of the others stepped',
);
});
test('two fingers pinch the panorama’s zoom', async () => {
const { viewer, host, renderers } = setup();
await viewer.mount(host);
await viewer.open('pano1');
const { canvas } = parts(host);
const before = renderers[0].getView().fov;
canvas.fire('pointerdown', {
pointerId: 1,
pointerType: 'touch',
clientX: 100,
clientY: 100,
});
canvas.fire('pointerdown', {
pointerId: 2,
pointerType: 'touch',
clientX: 140,
clientY: 100,
});
canvas.fire('pointermove', {
pointerId: 2,
pointerType: 'touch',
clientX: 180,
clientY: 100,
});
assert.ok(renderers[0].getView().fov < before, 'spread zooms in');
canvas.fire('pointerup', { pointerId: 1 });
canvas.fire('pointerup', { pointerId: 2 });
});
test('a lost WebGL context drops the panorama and shows the photo again on a new one', async () => {
const { viewer, host, renderers, poses, stepped } = setup();
await viewer.mount(host);
await viewer.open('pano1');
const lost = parts(host).canvas;
lost.fire('webglcontextlost');
assert.equal(renderers[0].destroyed, true);
// A lost canvas only ever gives back its lost context: a new element.
assert.notEqual(parts(host).canvas, lost);
assert.deepEqual(stepped, ['pano1'], 'reopened through the core');
assert.ok(await until(() => renderers.length === 2), 'a new renderer');
assert.equal(renderers[1].canvas, parts(host).canvas);
assert.equal(poses.at(-1).imageId, 'pano1');
assert.equal(parts(host).canvas.hidden, false);
});
test('a context lost while a newer photo opens leaves that open alone', async () => {
const { viewer, host, stepped } = setup();
await viewer.mount(host);
await viewer.open('pano1');
const opening = viewer.open('flat1');
parts(host).canvas.fire('webglcontextlost');
await opening;
assert.deepEqual(stepped, [], 'no reopen of the old panorama');
assert.equal(parts(host).picture.hidden, false);
});
test('a WebGL failure only lasts until the viewer is mounted again', async () => {
let fail = true;
const built = [];
const viewer = createKartaViewViewer({
getPhoto: async (id) => PHOTOS[id],
step: async () => null,
onStep() {},
load: async (url) => ({ src: url }),
createRenderer: (canvas) => {
if (fail) throw new Error('WebGL is unavailable');
const renderer = fakeRenderer(canvas);
built.push(renderer);
return renderer;
},
});
const host = fakeHost();
await viewer.mount(host);
await viewer.open('pano1');
assert.equal(parts(host).picture.hidden, false, 'front view');
viewer.unmount();
fail = false;
const again = fakeHost();
await viewer.mount(again);
await viewer.open('pano1');
assert.equal(built.length, 1);
assert.equal(parts(again).canvas.hidden, false);
});
/** Flat photos a → d of one drive, for the tests that hold loads back. */
const DRIVE = Object.fromEntries(
['a', 'b', 'c', 'd'].map((id, index) => [
id,
{
id,
sequenceId: 'drive',
sequenceIndex: index,
lon: 0.1 + index * 0.0001,
lat: 0.1,
heading: 0,
isPano: false,
capturedAt: 0,
urls: {
view: `https://storage2.openstreetcam.org/lth/${id}.jpg`,
full: `https://storage2.openstreetcam.org/proc/${id}.jpg`,
pano: null,
},
},
]),
);
const DRIVE_ORDER = Object.keys(DRIVE);
/** Which drive photo, at which size, an image address shows. */
function shows(src) {
const proxied =
/^https:\/\/cdn\.kartaview\.org\/pr:sharp\/rs:fit:(\d+):\d+\/([\w-]+)$/.exec(
src || '',
);
const url = proxied ? Buffer.from(proxied[2], 'base64url').toString() : src;
const id = /\/(\w+)\.jpg$/.exec(url || '')?.[1] ?? null;
return proxied ? `${id}@${proxied[1]}` : id;
}
/**
* A viewer whose photo loads (and, with `slowSteps`, step answers) wait for
* the test. ‹ › open through a stand-in core that, like the real one, awaits
* a tick (the viewer mount) before it opens the photo.
*/
function heldSetup({ slowSteps = false } = {}) {
const loads = [];
const asks = [];
const stepped = [];
const shown = [];
const viewer = createKartaViewViewer({
getPhoto: async (id) => {
if (!DRIVE[id]) throw new Error('no such photo');
return DRIVE[id];
},
step: (photo, delta) => {
const answer = DRIVE_ORDER[DRIVE_ORDER.indexOf(photo.id) + delta] ?? null;
if (!slowSteps) return Promise.resolve(answer);
return new Promise((resolve) =>
asks.push({ key: `${photo.id}:${delta}`, resolve }),
);
},
onStep: async (id) => {
stepped.push(id);
await settle();
viewer.open(id).catch(() => {});
},
onShown: (photo) => shown.push(photo.id),
load: (url) =>
new Promise((resolve, reject) =>
loads.push({
url,
shows: shows(url),
resolve: () => resolve({ src: url }),
reject: () =>
reject(new Error('The KartaView photo could not be loaded')),
}),
),
createRenderer: () => {
throw new Error('no WebGL in Node');
},
});
const poses = [];
viewer.onPose((pose) => poses.push(pose.imageId));
const host = fakeHost();
/** Let the load showing `what` (e.g. 'a@1280') finish. */
const release = async (what) => {
assert.ok(
await until(() => loads.some((load) => load.shows === what)),
`${what} is loading: ${loads.map((load) => load.shows)}`,
);
const at = loads.findIndex((load) => load.shows === what);
loads.splice(at, 1)[0].resolve();
await settle();
};
/** Answer the oldest pending step ask for `key` (e.g. 'a:1'). */
const answer = async (key, value) => {
assert.ok(await until(() => asks.some((ask) => ask.key === key)));
const at = asks.findIndex((ask) => ask.key === key);
asks.splice(at, 1)[0].resolve(value);
await settle();
};
return { viewer, host, loads, asks, stepped, shown, poses, release, answer };
}
test('quick presses while the next photo is still loading each step on from the last one chosen', async () => {
const { viewer, host, stepped, release } = heldSetup();
await viewer.mount(host);
const opening = viewer.open('a');
await release('a@1280');
await opening;
const { next } = parts(host);
assert.ok(await until(() => !next.disabled));
// b, c and d never load: each press steps on from the photo on its way.
next.fire('click');
next.fire('click');
next.fire('click');
assert.ok(await until(() => stepped.length >= 3), `stepped ${stepped}`);
await settle();
assert.deepEqual(stepped, ['b', 'c', 'd']);
});
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;
const second = viewer.open('b');
await release('b@1280');
await second;
const { picture } = parts(host);
assert.equal(shows(picture.src), 'b@1280');
await release('a@2560'); // a's upgrade, overtaken
assert.equal(shows(picture.src), 'b@1280', 'a never comes back');
await release('b@2560');
assert.equal(shows(picture.src), 'b@2560', 'b’s own upgrade lands');
assert.deepEqual(shown, ['a', 'b']);
assert.deepEqual(poses, ['a', 'b']);
});
test('an overtaken flat open never shows, nor tells the provider it did', async () => {
const { viewer, host, shown, poses, loads, release } = heldSetup();
await viewer.mount(host);
const first = viewer.open('a');
assert.ok(await until(() => loads.length === 1), 'a is loading');
const second = viewer.open('b');
await release('b@1280');
await second;
await release('a@1280');
await first;
const { picture } = parts(host);
assert.equal(shows(picture.src), 'b@1280');
assert.deepEqual(poses, ['b']);
assert.deepEqual(shown, ['b'], 'onShown only for the photo on screen');
});
test('a photo still loading when the viewer closes stays closed', async () => {
const { viewer, host, shown, poses, loads, release } = heldSetup();
await viewer.mount(host);
const opening = viewer.open('a');
assert.ok(await until(() => loads.length === 1), 'a is loading');
viewer.close();
await release('a@1280');
await opening;
const { picture, root } = parts(host);
assert.equal(picture.hidden, true);
assert.equal(root.dataset.kind, '');
assert.deepEqual(poses, []);
assert.deepEqual(shown, []);
});
test('an ends check for an earlier photo does not disable › on the photo now showing', async () => {
const { viewer, host, release, answer } = heldSetup({ slowSteps: true });
await viewer.mount(host);
const first = viewer.open('a');
await release('a@1280');
await first;
const second = viewer.open('c');
await release('c@1280');
await second;
const { prev, next } = parts(host);
await answer('c:-1', 'b');
await answer('c:1', 'd');
// a's check answers last, saying nothing lies before or after it.
await answer('a:-1', null);
await answer('a:1', null);
assert.equal(prev.disabled, false);
assert.equal(next.disabled, false, 'c still has a next photo');
});
test('a press whose answer comes after another photo was opened is dropped', async () => {
const { viewer, host, stepped, release, answer, asks } = heldSetup({
slowSteps: true,
});
await viewer.mount(host);
const first = viewer.open('a');
await release('a@1280');
await first;
await answer('a:-1', null);
await answer('a:1', 'b');
const { next } = parts(host);
next.disabled = false;
next.fire('click');
assert.ok(await until(() => asks.some((ask) => ask.key === 'a:1')));
// The user picks another photo meanwhile.
const other = viewer.open('d');
await release('d@1280');
await other;
await answer('a:1', 'b');
await settle();
assert.deepEqual(stepped, [], 'the old press does not open b over d');
});
test('a failed open does not become the photo ‹ › step from', async () => {
const { viewer, host, stepped } = setup({ steps: { 'flat1:1': 'flat2' } });
await viewer.mount(host);
await viewer.open('flat1');
await assert.rejects(viewer.open('nope'), /no such photo/);
parts(host).next.disabled = false;
parts(host).next.fire('click');
assert.ok(await until(() => stepped.length === 1));
assert.deepEqual(stepped, ['flat2'], 'from the photo still showing');
});
test('WebGL is tried once per mount, not again for every 360° photo', async () => {
const { viewer, host, attempts } = setup({ rendererThrows: true });
await viewer.mount(host);
await viewer.open('pano1');
await viewer.open('pano2');
assert.equal(attempts.renderer, 1);
assert.equal(parts(host).picture.hidden, false, 'front views instead');
});
test('a step button that disables under the keyboard hands focus to the viewer', async () => {
const { viewer, host } = setup();
await viewer.mount(host);
const { root, prev, next } = parts(host);
let focused = 0;
root.focus = () => focused++;
next.ownerDocument = { activeElement: next };
prev.ownerDocument = { activeElement: root };
await viewer.open('flat1');
assert.ok(await until(() => next.disabled && prev.disabled));
assert.equal(focused, 1, 'only for the focused button');
});
test('› to another 360° photo keeps the look direction; opening one afresh faces its heading', async (t) => {
globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0);
t.after(() => delete globalThis.requestAnimationFrame);
const { viewer, host, renderers, poses } = setup({
steps: { 'pano1:1': 'pano2' },
});
await viewer.mount(host);
await viewer.open('pano1');
const { root, next } = parts(host);
root.fire('keydown', { key: 'ArrowRight', shiftKey: true });
const yaw = renderers[0].getView().yaw;
assert.ok(yaw > 0);
next.fire('click');
assert.ok(await until(() => poses.at(-1).imageId === 'pano2'));
assert.equal(renderers[0].getView().yaw, yaw, 'still looking the same way');
await viewer.open('pano1');
assert.equal(renderers[0].getView().yaw, 0, 'a fresh open faces ahead');
});
/** A viewer whose loads for `held` URLs wait until `release()`. */
function heldPanoSetup(held) {
const renderers = [];
const stepped = [];
let release;
const gate = new Promise((resolve) => (release = resolve));
const viewer = createKartaViewViewer({
getPhoto: async (id) => PHOTOS[id],
step: async () => null,
onStep: (id) => {
stepped.push(id);
viewer.open(id).catch(() => {});
},
load: async (url) => {
if (held.some((part) => url.includes(part))) await gate;
return { src: url };
},
createRenderer: (canvas) => {
const renderer = fakeRenderer(canvas);
renderers.push(renderer);
return renderer;
},
});
const poses = [];
viewer.onPose((pose) => poses.push(pose));
return { viewer, renderers, stepped, poses, host: fakeHost(), release };
}
const pano2Preview = Buffer.from(
'https://storage13.openstreetcam.org/wrapped_proc/p2.jpg',
).toString('base64url');
test('a context lost while a newer panorama loads shows that one on a new canvas, not a black viewer', async () => {
const { viewer, renderers, poses, host, release, stepped } = heldPanoSetup([
pano2Preview,
]);
await viewer.mount(host);
await viewer.open('pano1');
const opening = viewer.open('pano2');
await new Promise((resolve) => setTimeout(resolve, 0));
parts(host).canvas.fire('webglcontextlost');
release();
await opening;
assert.equal(
poses.at(-1).imageId,
'pano2',
'the newer panorama is on screen',
);
assert.equal(poses.at(-1).isPano, true);
assert.deepEqual(stepped, [], 'its own open, not a reopen of the old one');
assert.equal(renderers.length, 2, 'drawn by a new renderer');
assert.equal(renderers[1].canvas, parts(host).canvas);
assert.equal(parts(host).canvas.hidden, false);
});
test('a second context loss, on the new canvas, reopens again; the new canvas still drags', async (t) => {
globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0);
t.after(() => delete globalThis.requestAnimationFrame);
const { viewer, host, renderers, stepped, poses } = setup();
await viewer.mount(host);
await viewer.open('pano1');
parts(host).canvas.fire('webglcontextlost');
assert.ok(await until(() => renderers.length === 2));
parts(host).canvas.fire('webglcontextlost');
assert.ok(await until(() => renderers.length === 3), 'reopened twice');
assert.deepEqual(stepped, ['pano1', 'pano1']);
assert.equal(renderers[2].canvas, parts(host).canvas);
const canvas = parts(host).canvas;
const before = poses.at(-1).bearing;
canvas.fire('pointerdown', {
button: 0,
pointerId: 7,
pointerType: 'mouse',
clientX: 200,
clientY: 100,
});
canvas.fire('pointermove', { pointerId: 7, clientX: 100, clientY: 100 });
canvas.fire('pointerup', { pointerId: 7 });
assert.ok(await until(() => poses.at(-1).bearing !== before), 'dragged');
});
test('a context loss keeps where the user was looking', async () => {
const { viewer, host, renderers } = setup();
await viewer.mount(host);
await viewer.open('pano1');
renderers[0].setView({ yaw: 70, pitch: 12, fov: 60 });
parts(host).canvas.fire('webglcontextlost');
assert.ok(await until(() => renderers.length === 2));
assert.deepEqual(renderers[1].getView(), { yaw: 70, pitch: 12, fov: 60 });
});
test('the panorama is as sharp as the viewer needs: 2,048 docked, 4,096 once expanded', async () => {
assert.deepEqual(panoSizeFor(400, 80), [2048, 1024]);
assert.deepEqual(panoSizeFor(1400, 80), [4096, 2048]);
const { viewer, host, renderers } = setup();
await viewer.mount(host);
await viewer.open('pano1');
assert.ok(await until(() => renderers[0].images.length === 2));
assert.match(renderers[0].images[1], /rs:fit:2048:1024/);
// Expanded: the panel tells the viewer it resized.
parts(host).canvas.clientWidth = 1400;
viewer.resize();
assert.ok(await until(() => renderers[0].images.length === 3));
assert.match(renderers[0].images[2], /rs:fit:4096:2048/);
viewer.resize();
for (let i = 0; i < 4; i++) await new Promise((r) => setTimeout(r, 0));
assert.equal(renderers[0].images.length, 3, 'not again once sharp');
});
test('a CORS image is decoded off the main thread into an ImageBitmap where the browser can', async (t) => {
const saved = {
fetch: globalThis.fetch,
bitmap: globalThis.createImageBitmap,
};
t.after(() => {
globalThis.fetch = saved.fetch;
globalThis.createImageBitmap = saved.bitmap;
});
const fetched = [];
globalThis.fetch = async (url, init) => {
fetched.push({ url, mode: init.mode });
return url.includes('gone')
? { ok: false, status: 409 }
: { ok: true, blob: async () => 'blob' };
};
globalThis.createImageBitmap = async (blob) => ({
blob,
width: 4096,
height: 2048,
});
const bitmap = await loadImage('https://cdn.kartaview.org/a', {
crossOrigin: true,
});
assert.deepEqual(bitmap, { blob: 'blob', width: 4096, height: 2048 });
assert.deepEqual(fetched, [
{ url: 'https://cdn.kartaview.org/a', mode: 'cors' },
]);
await assert.rejects(
loadImage('https://cdn.kartaview.org/gone', { crossOrigin: true }),
/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');
});
@@ -1,21 +1,11 @@
import * as Cesium from 'cesium';
import { decodeCoverageTile } from './decode.js';
import { passesImageryFilter } from '../../filter.js';
import {
createHorizonCull,
groundUnderCamera,
metresBetween,
viewFocus,
visibleBbox,
whenIdle,
} from '../../view.js';
import { densifyLine, MESH_DENSIFY_DEG } from '../../groundCast.js';
import { MESH_SAMPLE_RADIUS_M } from '../../meshSampler.js';
createLineCoverage,
sequenceIdFromPick as lineSequenceIdFromPick,
} from '../../lineCoverage.js';
import { decodeCoverageTile } from './decode.js';
import {
coverageZoomForHeight,
overviewZoomForHeight,
tileBounds,
tilesForBbox,
} from '../../tileMath.js';
import {
COLORS,
@@ -33,885 +23,48 @@ import {
SEQUENCE_VIEW_RANGE_PER_HEIGHT,
} from './policy.js';
/** Draped lines stay at most this long while a tile's cast lines build. */
export const SWAP_MAX_WAIT_MS = 4000;
/** Tiles touched by new mesh samples are redrawn at most this often. */
const REMESH_INTERVAL_MS = 1500;
/** Gap between redrawing one dirty tile and the next. */
const REMESH_STAGGER_MS = 120;
/** Old-zoom tiles are kept at most this long after a zoom change. */
const STALE_TILE_MAX_MS = 6000;
/** Sequences per primitive: smaller batches build, and show, sooner. */
const SEQUENCE_PRIMITIVE_BATCH = 120;
export { SWAP_MAX_WAIT_MS, meshBoxInRange } from '../../lineCoverage.js';
const PER_TILE_SEQUENCE_CAP = Math.floor(
COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES,
);
/** Colour for a sequence: Mapillary green, GEV cyan while selected. */
function sequenceColor({ selected = false } = {}) {
return selected
? Cesium.Color.fromCssColorString(COLORS.selected)
: Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92);
}
/** Separates a multi-part sequence's part index from its id in pick ids. */
const PART_SEPARATOR = '~';
/** Pick ids for every part of a sequence; the first part is unsuffixed. */
function partIds(sequence) {
return sequence.parts.map((_, index) =>
index
? `${PICK_PREFIX.sequence}${sequence.id}${PART_SEPARATOR}${index}`
: `${PICK_PREFIX.sequence}${sequence.id}`,
);
/** Overview points (z0–5) from orbit, sequence lines (z11–14) below 60 km. */
export function mapillaryZoomFor(heightM) {
const sequence = coverageZoomForHeight(heightM);
if (sequence != null) return { zoom: sequence, kind: 'sequence' };
const overview = overviewZoomForHeight(heightM);
return overview != null ? { zoom: overview, kind: 'overview' } : null;
}
/** Sequence id for any part's pick id (`mly:seq:<id>[~<part>]`), else null. */
export function sequenceIdFromPick(pickId) {
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX.sequence))
return null;
const rest = pickId.slice(PICK_PREFIX.sequence.length);
const cut = rest.indexOf(PART_SEPARATOR);
return cut === -1 ? rest : rest.slice(0, cut);
return lineSequenceIdFromPick(pickId, PICK_PREFIX.sequence);
}
/**
* Whether any point of `box` ({west, south, east, north}) is within the mesh
* sampler's range of `centre`, the ground point under the camera.
*/
export function meshBoxInRange(box, centre) {
// Move the camera's longitude to the box's side of the date line first:
// clamping 179.999 into a box at -179.99 would pick its far edge.
const lon =
centre.lon +
360 * Math.round(((box.west + box.east) / 2 - centre.lon) / 360);
const nearest = {
lon: Math.min(box.east, Math.max(box.west, lon)),
lat: Math.min(box.north, Math.max(box.south, centre.lat)),
};
return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M;
}
/**
* Camera-driven coverage: overview points (z0–5) from orbit, sequence lines
* (z11–14) near the ground. In terrain surface mode a tile is drawn draped
* first, then swapped for lines cast to the bare earth (draped lines would
* land on roofs), so coverage never waits on terrain heights.
*/
/** Mapillary's vector-tile coverage through the server proxy. */
export function createCoverage({ state, source }) {
const { render } = state.services;
function requestRender() {
render?.governorRequestRender?.('mapillary-coverage');
}
function ensureTerrainReady() {
return (state.coverage.terrainReady ||=
Cesium.GroundPolylinePrimitive.initializeTerrainHeights());
}
function tileKey({ x, y, z }) {
return `${z}/${x}/${y}`;
}
/** The imagery filter, resolved now: "since N days" moves with the clock. */
function filter() {
return state.context.getFilter();
}
function terrainMode() {
return (
state.context.getSurface?.() === 'terrain' &&
Boolean(state.context.groundCaster)
);
}
/**
* A tile's sequences, filtered then capped: capping first could leave a
* dense tile of newer flat captures with no 360° lines at all.
*/
function drawnSequences(entry) {
const drawn = [];
const current = filter();
for (const sequence of entry.sequenceList) {
if (drawn.length >= PER_TILE_SEQUENCE_CAP) break;
if (passesImageryFilter(sequence, current)) drawn.push(sequence);
}
return drawn;
}
/**
* Batched primitives for a tile's sequence parts: cast polylines where
* terrain heights are cached, draped ground lines otherwise. Each batch
* records the selection its colours were built with.
*/
function buildSequencePrimitives(sequences) {
const ground = terrainMode() ? state.context.groundCaster : null;
const meshAt = ground ? state.context.meshSampler?.meshAt : undefined;
const selectedId = state.sequence.selectedId ?? null;
const draped = [];
const cast = [];
for (const sequence of sequences) {
const color = Cesium.ColorGeometryInstanceAttribute.fromColor(
sequenceColor({ selected: sequence.id === selectedId }),
);
const ids = partIds(sequence);
sequence.parts.forEach((coordinates, index) => {
const flat = ground?.castLine(coordinates, { meshAt });
let positions;
try {
positions = flat
? Cesium.Cartesian3.fromDegreesArrayHeights(flat)
: Cesium.Cartesian3.fromDegreesArray(coordinates.flat());
} catch {
return;
}
if (positions.length < 2) return;
const geometry = flat
? new Cesium.PolylineGeometry({
positions,
width: COVERAGE_LINE_WIDTH_PX,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
arcType: Cesium.ArcType.NONE,
})
: new Cesium.GroundPolylineGeometry({
positions,
width: COVERAGE_LINE_WIDTH_PX,
});
(flat ? cast : draped).push(
new Cesium.GeometryInstance({
geometry,
id: ids[index],
attributes: { color },
}),
);
});
}
const primitives = [];
for (let i = 0; i < draped.length; i += SEQUENCE_PRIMITIVE_BATCH)
primitives.push({
onGround: true,
selectedId,
primitive: new Cesium.GroundPolylinePrimitive({
geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
appearance: new Cesium.PolylineColorAppearance(),
classificationType: Cesium.ClassificationType.BOTH,
asynchronous: true,
allowPicking: true,
}),
});
for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH)
primitives.push({
onGround: false,
selectedId,
primitive: new Cesium.Primitive({
geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
asynchronous: true,
allowPicking: true,
}),
});
return { primitives, draped, cast: cast.length };
}
/**
* Fetch the terrain heights a tile's lines need, then redraw it cast.
* `castRequested` stops the redraw from casting again at once; a refresh
* `retry`s a tile whose lines are still draped (a failed or partial cast),
* and it is redrawn only when more of its lines can be cast.
*/
function castTile(entry, { retry = false } = {}) {
if ((entry.castRequested && !retry) || entry.castAbort || !terrainMode())
return;
entry.castRequested = true;
entry.castAbort = new AbortController();
const { signal } = entry.castAbort;
const lines = [...entry.sequences.values()].flatMap(
(sequence) => sequence.parts,
);
const caster = state.context.groundCaster;
caster.prepareLines(lines, { signal }).then(() => {
if (entry.castAbort?.signal === signal) entry.castAbort = null;
const attached = [...state.coverage.tiles.values()].includes(entry);
if (signal.aborted || !attached || !terrainMode()) return;
// Nothing new to cast: keep what is drawn until the next refresh.
// Counted like buildSequencePrimitives: a cast line needs two points.
const castable = lines.filter(
(coords) => caster.castLine(coords)?.length >= 6,
).length;
if (castable <= (entry.castLines || 0)) {
entry.castRequested = (entry.castLines || 0) > 0;
return;
}
// A remesh may have cleared the flag while this was in flight.
entry.castRequested = true;
redrawTile(entry);
requestMesh(entry);
});
}
/** Rebuild a tile's lines; the old ones stay up until the new are ready. */
function redrawTile(entry) {
const previous = entry.primitives;
entry.primitives = [];
attachPrimitive(entry);
removeWhenReady(entry, previous);
}
/** Lon/lat under the camera, where mesh range is measured from. */
function meshCentre() {
const carto = state.viewer?.camera?.positionCartographic;
if (!carto) return null;
return {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
};
}
/**
* A tile's drawn lines with bounding boxes, built once per filter; mesh
* points are densified the first time a line is in range.
*/
function meshParts(entry) {
if (entry.meshParts) return entry.meshParts;
entry.meshParts = [];
for (const sequence of entry.sequences.values())
for (const part of sequence.parts) {
const box = {
west: Infinity,
south: Infinity,
east: -Infinity,
north: -Infinity,
};
for (const [lon, lat] of part) {
if (lon < box.west) box.west = lon;
if (lon > box.east) box.east = lon;
if (lat < box.south) box.south = lat;
if (lat > box.north) box.north = lat;
}
entry.meshParts.push({ part, box, points: null });
}
return entry.meshParts;
}
/**
* Ask for mesh samples under a cast tile's lines within the sampler's range
* of the camera; the rest are asked for when a camera move brings them in.
*/
function requestMesh(entry) {
const sampler = state.context.meshSampler;
if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return;
const centre = meshCentre();
if (!centre || !entry.bounds || !meshBoxInRange(entry.bounds, centre))
return;
const points = [];
for (const line of meshParts(entry)) {
if (!meshBoxInRange(line.box, centre)) continue;
line.points ||= densifyLine(line.part, MESH_DENSIFY_DEG);
for (const point of line.points) points.push(point);
}
if (points.length) sampler.request(points);
}
/** Redraw the tiles new mesh samples fall in, throttled. */
function onMeshSampled(batch) {
if (!terrainMode() || !state.context.isActive()) return;
for (const entry of state.coverage.tiles.values()) {
if (entry.kind !== 'sequence' || !entry.bounds) continue;
const { west, south, east, north } = entry.bounds;
if (
batch.some(
([lon, lat]) =>
lon >= west && lon <= east && lat >= south && lat <= north,
)
)
state.coverage.remeshDirty.add(entry);
}
if (!state.coverage.remeshDirty.size || state.coverage.remeshTimer) return;
const wait = Math.max(
0,
REMESH_INTERVAL_MS - (Date.now() - (state.coverage.remeshAt || 0)),
);
state.coverage.remeshTimer = setTimeout(remesh, wait);
}
/** Redraw one dirty tile per idle slice, so a burst of samples never stalls a frame. */
function remesh() {
state.coverage.remeshTimer = null;
state.coverage.remeshAt = Date.now();
if (!terrainMode() || !state.context.isActive()) {
state.coverage.remeshDirty.clear();
return;
}
const attached = new Set(state.coverage.tiles.values());
const [entry] = state.coverage.remeshDirty;
if (!entry) return;
state.coverage.remeshDirty.delete(entry);
if (attached.has(entry)) {
redrawTile(entry);
requestRender();
}
if (state.coverage.remeshDirty.size)
state.coverage.remeshTimer = setTimeout(
() => whenIdle(remesh, 500),
REMESH_STAGGER_MS,
);
}
state.coverage.remeshDirty = new Set();
state.context.meshSampler?.onSampled(onMeshSampled);
/**
* Remove a tile's previous primitives once all its current ones are ready
* (or after a few seconds); detaching the tile removes them at once.
*/
function removeWhenReady(entry, old) {
const scene = state.viewer?.scene;
finishSwap(entry);
if (!scene?.postRender) {
removePrimitives(old);
return;
}
const fresh = entry.primitives;
const started = Date.now();
const stop = scene.postRender.addEventListener(() => {
const ready = fresh.every(
({ primitive }) => primitive.ready || primitive.isDestroyed?.(),
);
if (!ready && Date.now() - started < SWAP_MAX_WAIT_MS) {
requestRender();
return;
}
finishSwap(entry);
requestRender();
});
entry.swap = { old, stop };
requestRender();
}
function finishSwap(entry) {
if (!entry.swap) return;
entry.swap.stop();
removePrimitives(entry.swap.old);
entry.swap = null;
}
function buildOverviewCollection(points) {
const collection = new Cesium.PointPrimitiveCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
const green = Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(
0.85,
);
const drawn = [];
const current = filter();
for (const point of points) {
if (!passesImageryFilter(point, current)) continue;
drawn.push(
collection.add({
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat),
color: green,
pixelSize: COVERAGE_OVERVIEW_POINT_PX,
// Google 3D terrain and clouds must not hide the near side's dots;
// the horizon cull hides the far side's, which this lets through.
disableDepthTestDistance: Number.POSITIVE_INFINITY,
}),
);
}
return { collection, points: drawn };
}
/** Every overview point on the globe, current zoom and old alike. */
function* overviewPoints() {
for (const entry of drawnEntries())
if (entry.overviewPoints) yield* entry.overviewPoints;
}
// Overview points skip the depth test: hide the far hemisphere's.
const horizon = createHorizonCull({
getViewer: () => state.viewer,
items: overviewPoints,
onChange: requestRender,
return createLineCoverage({
state,
fetchTile: async (tile, { signal }) =>
decodeCoverageTile(
await source.getTile('coverage', tile.z, tile.x, tile.y, { signal }),
tile,
),
zoomFor: mapillaryZoomFor,
colors: COLORS,
pickPrefix: PICK_PREFIX.sequence,
renderKey: 'mapillary-coverage',
tuning: {
maxTiles: COVERAGE_MAX_TILES,
overviewMaxTiles: COVERAGE_OVERVIEW_MAX_TILES,
maxSequences: COVERAGE_MAX_SEQUENCES,
lineWidthPx: COVERAGE_LINE_WIDTH_PX,
overviewPointPx: COVERAGE_OVERVIEW_POINT_PX,
moveDebounceMs: COVERAGE_MOVE_DEBOUNCE_MS,
viewRangeMinM: SEQUENCE_VIEW_RANGE_MIN_M,
viewRangePerHeight: SEQUENCE_VIEW_RANGE_PER_HEIGHT,
viewNearM: SEQUENCE_VIEW_NEAR_M,
},
messages: {
keyRejected: KEY_REJECTED_MESSAGE,
rateLimited: RATE_LIMITED_MESSAGE,
},
});
function attachPrimitive(entry) {
const scene = state.viewer?.scene;
if (!scene) return;
if (entry.kind === 'sequence') {
// Lookup, picking and counts follow what is drawn, not the whole tile.
const sequences = drawnSequences(entry);
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
const { primitives, draped, cast } = buildSequencePrimitives(sequences);
entry.primitives = primitives;
entry.count = sequences.length;
for (const { primitive, onGround } of primitives)
(onGround ? scene.groundPrimitives : scene.primitives).add(primitive);
// Fewer lines cast than last time: the caster has dropped heights this
// tile used (a full cache), so it must be cast again.
if (cast < (entry.castLines || 0)) entry.castRequested = false;
entry.castLines = cast;
entry.drapedLines = draped.length;
if (draped.length) castTile(entry);
watchSelection();
} else {
const { collection, points } = buildOverviewCollection(entry.points);
entry.primitive = collection;
entry.overviewPoints = points;
entry.count = points.length;
scene.primitives.add(collection);
// Cull the new points now, then all of them whenever the camera moves.
horizon.update(points);
}
}
function removePrimitives(list) {
const scene = state.viewer?.scene;
for (const { primitive, onGround } of list || []) {
try {
(onGround ? scene?.groundPrimitives : scene?.primitives)?.remove(
primitive,
);
} catch {
/* already gone */
}
}
}
function detachPrimitive(entry) {
const scene = state.viewer?.scene;
finishSwap(entry);
removePrimitives(entry.primitives);
entry.primitives = [];
if (entry.primitive) {
try {
scene?.primitives?.remove(entry.primitive);
} catch {
/* already gone */
}
entry.primitive = null;
entry.overviewPoints = null;
}
}
/** Stop a tile's pending terrain lookup (tile dropped or retired). */
function cancelCast(entry) {
entry.castAbort?.abort();
entry.castAbort = null;
}
function removeTile(key) {
const entry = state.coverage.tiles.get(key);
if (!entry) return;
cancelCast(entry);
detachPrimitive(entry);
state.coverage.tiles.delete(key);
}
async function loadTile(tile, kind) {
const key = tileKey(tile);
if (state.coverage.tiles.has(key) || state.coverage.pending.has(key))
return;
const controller = new AbortController();
state.coverage.pending.set(key, controller);
notify();
try {
// Fetch and the one-time terrain-height table load run side by side,
// both handled from the start so an early tile failure is not unhandled.
const [bytes] = await Promise.all([
source.getTile('coverage', tile.z, tile.x, tile.y, {
signal: controller.signal,
}),
kind === 'sequence' ? ensureTerrainReady() : null,
]);
// Only an abort, a retire, a clear or a newer request for this tile
// discards the bytes; a refresh that still wants the tile keeps them.
const current = () =>
state.coverage.pending.get(key) === controller &&
!state.coverage.tiles.has(key);
if (
controller.signal.aborted ||
!current() ||
kind !== state.coverage.kind ||
tile.z !== state.coverage.zoom
)
return;
const decoded = decodeCoverageTile(bytes, tile);
const entry = { kind, primitive: null, primitives: [], count: 0 };
// Padded: vector tiles carry a small buffer past their edge.
const bounds = tileBounds(tile.x, tile.y, tile.z);
const pad = (bounds.east - bounds.west) * 0.05;
entry.bounds = {
west: bounds.west - pad,
east: bounds.east + pad,
south: bounds.south - pad,
north: bounds.north + pad,
};
if (kind === 'sequence') {
// Newest first; the per-tile cap applies to what passes the filter.
entry.sequenceList = decoded.sequences.sort(
(a, b) => (b.capturedAt || 0) - (a.capturedAt || 0),
);
entry.sequences = new Map();
entry.total = decoded.sequences.length;
} else {
entry.points = decoded.overview;
entry.sequences = new Map();
entry.total = decoded.overview.length;
}
attachPrimitive(entry);
state.coverage.tiles.set(key, entry);
state.coverage.lastError = null;
requestRender();
} catch (error) {
if (
controller.signal.aborted ||
state.coverage.pending.get(key) !== controller
)
return;
state.coverage.lastError = error?.message || 'Coverage tile failed';
if (error?.keyRequired) state.keyRequired = true;
if (error?.keyRejected) {
// Every other tile would be refused too: stop asking.
state.keyRejected = true;
state.coverage.lastError = KEY_REJECTED_MESSAGE;
}
if (error?.retryAfterSec) holdFor(error.retryAfterSec);
} finally {
// Only the request that still owns the key settles it: a superseded one
// must not drop a newer request's entry (which is what counts as loading).
if (state.coverage.pending.get(key) === controller) {
state.coverage.pending.delete(key);
if (!state.coverage.pending.size) purgeStale();
}
notify();
}
}
/**
* Mapillary is rate-limiting: keep what is drawn, request nothing until the
* wait is over, then refresh once.
*/
function holdFor(seconds) {
clearTimeout(state.coverage.holdTimer);
state.coverage.holdUntil = Date.now() + seconds * 1000;
state.coverage.lastError = RATE_LIMITED_MESSAGE;
state.coverage.holdTimer = setTimeout(() => {
state.coverage.holdTimer = null;
state.coverage.holdUntil = 0;
if (state.coverage.lastError === RATE_LIMITED_MESSAGE)
state.coverage.lastError = null;
refresh();
}, seconds * 1000);
}
/** Forget refusals when the provider goes off, so the next run asks again. */
function resetErrors() {
clearTimeout(state.coverage.holdTimer);
state.coverage.holdTimer = null;
state.coverage.holdUntil = 0;
state.coverage.lastError = null;
state.keyRejected = false;
}
/** Drop the previous zoom's tiles once the new ones are on screen. */
function purgeStale() {
clearTimeout(state.coverage.staleTimer);
state.coverage.staleTimer = null;
if (!state.coverage.stale.size) return;
for (const entry of state.coverage.stale.values()) detachPrimitive(entry);
state.coverage.stale.clear();
requestRender();
}
/** Move tiles to the stale set: the old zoom shows until the new one loads. */
function retire() {
for (const controller of state.coverage.pending.values())
controller.abort();
state.coverage.pending.clear();
for (const [key, entry] of state.coverage.tiles) {
cancelCast(entry);
const previous = state.coverage.stale.get(key);
if (previous) detachPrimitive(previous);
state.coverage.stale.set(key, entry);
}
state.coverage.tiles.clear();
clearTimeout(state.coverage.staleTimer);
state.coverage.staleTimer = setTimeout(purgeStale, STALE_TILE_MAX_MS);
}
function notify() {
state.context.notify();
}
/**
* Whether the screen centre meets the ground further ahead than the camera
* is high (a view tilted above 45°), or misses it.
*/
function isTilted({ nadir, ahead }, height) {
if (!ahead) return true;
return metresBetween(nadir, ahead) > Math.max(1, height);
}
/** Recompute the tile set for the current camera and reconcile primitives. */
function refresh() {
const viewer = state.viewer;
if (!viewer || !state.context.isActive() || state.keyRequired) return;
if (state.statusKnown === false) return;
if (state.keyRejected || state.coverage.holdUntil > Date.now()) return;
const ground = groundUnderCamera(viewer, {
groundAt: state.context.groundCaster?.groundAt,
});
const cameraHeight = viewer.camera?.positionCartographic?.height;
const height = Number.isFinite(cameraHeight)
? cameraHeight - (ground ?? 0)
: null;
const sequenceZoom = coverageZoomForHeight(height);
const overviewZoom = sequenceZoom ? null : overviewZoomForHeight(height);
const zoom = sequenceZoom ?? overviewZoom;
const kind = sequenceZoom ? 'sequence' : 'overview';
// At street zooms the rays meet the ground where it really is (1,600 m up
// in Denver; Google 3D hides the globe) and stop short of the horizon.
const ranged =
kind === 'sequence'
? {
groundHeight: ground,
maxRange: Math.max(
SEQUENCE_VIEW_RANGE_MIN_M,
height * SEQUENCE_VIEW_RANGE_PER_HEIGHT,
),
}
: {};
// Tilted street views rank tiles along the line of sight, from the ground
// under the camera to the ground at the centre of the screen.
const focus = kind === 'sequence' ? viewFocus(viewer, ranged) : null;
let bbox = visibleBbox(viewer, {
...ranged,
// A tilted view's screen rows jump from the first metres to the horizon:
// keep the ground around the camera too. Looking down needs no help.
nearRange: focus && isTilted(focus, height) ? SEQUENCE_VIEW_NEAR_M : null,
});
if (kind === 'overview' && (!bbox || zoom <= 1))
bbox = [-180, -85, 180, 85];
if (zoom == null || !bbox) {
state.coverage.hint =
'Point the camera at the globe for street-level coverage';
clear();
notify();
return;
}
state.coverage.hint = '';
if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) {
retire();
state.coverage.zoom = zoom;
state.coverage.kind = kind;
}
const { tiles } = tilesForBbox(bbox, zoom, {
limit:
kind === 'sequence' ? COVERAGE_MAX_TILES : COVERAGE_OVERVIEW_MAX_TILES,
focus: focus && { from: focus.nadir, to: focus.ahead },
});
const wanted = new Set(tiles.map(tileKey));
for (const key of [...state.coverage.tiles.keys()])
if (!wanted.has(key)) removeTile(key);
for (const [key, controller] of [...state.coverage.pending])
if (!wanted.has(key)) {
// The aborted request no longer owns its key, so its `finally` will
// not settle it: settle it here.
controller.abort();
state.coverage.pending.delete(key);
}
for (const tile of tiles) loadTile(tile, kind);
if (!state.coverage.pending.size) purgeStale();
// The camera moved: cells that were out of the sampler's range may not be,
// and lines left draped by a failed, partial or dropped cast get another try.
if (terrainMode())
for (const entry of state.coverage.tiles.values()) {
if (entry.castRequested) requestMesh(entry);
if (entry.drapedLines) castTile(entry, { retry: true });
}
notify();
}
function scheduleRefresh() {
clearTimeout(state.coverage.debounceTimer);
state.coverage.debounceTimer = setTimeout(
refresh,
COVERAGE_MOVE_DEBOUNCE_MS,
);
}
function attach(viewer) {
detach();
const remove = viewer.camera.changed.addEventListener(scheduleRefresh);
const removeEnd = viewer.camera.moveEnd.addEventListener(scheduleRefresh);
state.coverage.removeCameraListener = () => {
remove();
removeEnd();
};
refresh();
}
function detach() {
clearTimeout(state.coverage.debounceTimer);
state.coverage.debounceTimer = null;
state.coverage.removeCameraListener?.();
state.coverage.removeCameraListener = null;
}
function clear() {
for (const controller of state.coverage.pending.values())
controller.abort();
state.coverage.pending.clear();
for (const key of [...state.coverage.tiles.keys()]) removeTile(key);
purgeStale();
state.coverage.zoom = null;
state.coverage.kind = null;
clearTimeout(state.coverage.remeshTimer);
state.coverage.remeshTimer = null;
state.coverage.remeshDirty.clear();
stopSelectionWatch();
horizon.stop();
requestRender();
}
/** Rebuild every loaded tile from its decoded cache (after a filter change). */
function rebuild() {
purgeStale();
for (const entry of state.coverage.tiles.values()) {
cancelCast(entry);
entry.castRequested = false;
entry.meshParts = null;
detachPrimitive(entry);
attachPrimitive(entry);
}
requestRender();
notify();
}
/**
* 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.
*/
function setSurface() {
if (!state.context.isActive()) return;
rebuild();
refresh();
}
/** Recolour every part of a sequence in one ready primitive. */
function recolorInstances(primitive, sequence, selected) {
const value = sequenceColor({ selected });
for (const instanceId of partIds(sequence)) {
try {
const attributes = primitive.getGeometryInstanceAttributes(instanceId);
if (attributes)
attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue(
value,
attributes.color,
);
} catch {
/* instance not in this primitive */
}
}
}
/** Every tile on the globe: the current zoom's and the stale zoom's. */
function drawnEntries() {
return [...state.coverage.tiles.values(), ...state.coverage.stale.values()];
}
/** A tile's primitives on the globe: its own, and the ones a swap still shows. */
function drawnRecords(entry) {
const records = entry.primitives || [];
return entry.swap ? [...records, ...entry.swap.old] : records;
}
/** Recolour one sequence, every part of it, in place (selection highlight). */
function recolorSequence(id, selected) {
for (const entry of drawnEntries()) {
const sequence = entry.sequences.get(id);
if (!sequence) continue;
for (const record of drawnRecords(entry)) {
// Still building: `syncSelection` catches it up once it is ready.
if (!record.primitive.ready) continue;
recolorInstances(record.primitive, sequence, selected);
if (selected) record.selectedId = id;
else if (record.selectedId === id) record.selectedId = null;
}
}
requestRender();
}
/**
* Bring a ready primitive's highlight up to the current selection, when
* it was built with another one (or none). True when it changed.
*/
function syncSelection(entry, record) {
const current = state.sequence.selectedId ?? null;
if (record.selectedId === current || !record.primitive.ready) return false;
const previous =
record.selectedId && entry.sequences.get(record.selectedId);
if (previous) recolorInstances(record.primitive, previous, false);
const next = current && entry.sequences.get(current);
if (next) recolorInstances(record.primitive, next, true);
record.selectedId = current;
return true;
}
/**
* A primitive cannot be recoloured until it is ready, so while any is
* building, catch each one up with the selection as it becomes ready.
*/
function watchSelection() {
const scene = state.viewer?.scene;
if (state.coverage.stopSelectionWatch || !scene?.postRender) return;
const stop = scene.postRender.addEventListener(() => {
let building = false;
let changed = false;
for (const entry of drawnEntries()) {
for (const record of entry.primitives || []) {
if (!record.primitive.ready) building = true;
else if (syncSelection(entry, record)) changed = true;
}
// Lines a swap still shows were ready (or never show): catch up only.
for (const record of entry.swap?.old || [])
if (syncSelection(entry, record)) changed = true;
}
if (changed) requestRender();
if (!building) stopSelectionWatch();
});
state.coverage.stopSelectionWatch = stop;
}
function stopSelectionWatch() {
state.coverage.stopSelectionWatch?.();
state.coverage.stopSelectionWatch = null;
}
/**
* Sequences (or overview points) drawn. A sequence that crosses a tile
* edge is in both tiles' lists, so sequences count once by id.
*/
function sequenceCount() {
const ids = new Set();
let points = 0;
for (const entry of state.coverage.tiles.values())
if (entry.kind === 'sequence')
for (const id of entry.sequences.keys()) ids.add(id);
else points += entry.count;
return ids.size + points;
}
return {
attach,
detach,
refresh,
clear,
resetErrors,
rebuild,
setSurface,
recolorSequence,
sequenceCount,
};
}
@@ -55,8 +55,6 @@ export const COVERAGE_LINE_WIDTH_PX = 2.5;
/** Per-sequence image cones after a sequence is selected. */
export const SEQUENCE_IMAGES_LIMIT = 2000;
export const IMAGE_CONE_SIZE_PX = 26;
export const IMAGE_CONE_MIN_SPACING_M = 3;
/** Images asked for in a nearest-image search (radius: NEAREST_RADIUS_M). */
export const NEAREST_LIMIT = 8;
@@ -1,20 +1,7 @@
import * as Cesium from 'cesium';
import { imageConeGlyph } from '../../glyphs.js';
import { passesImageryFilter } from '../../filter.js';
import { refineHeights } from '../../groundCast.js';
import { createHorizonCull, metresBetween } from '../../view.js';
import { meshCellKey } from '../../meshSampler.js';
import {
COLORS,
IMAGE_CONE_MIN_SPACING_M,
IMAGE_CONE_SIZE_PX,
PICK_PREFIX,
} from './policy.js';
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
import { COLORS, PICK_PREFIX } from './policy.js';
const SPRITE_ID = 'street-level:mapillary-cones';
/** How many recently viewed sequences keep their image list in memory. */
const SEQUENCE_CACHE_SIZE = 40;
export { thinImages } from '../../sequenceCones.js';
/** Normalize a graph image record into the shape the cones use. */
export function normalizeSequenceImage(record) {
@@ -33,290 +20,24 @@ export function normalizeSequenceImage(record) {
};
}
/** Drop images closer than the spacing to the previous kept one. */
export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
const kept = [];
let last = null;
for (const image of images) {
if (!last || metresBetween(last, image) >= spacingM) {
kept.push(image);
last = image;
}
}
return kept;
}
/** Image cones for one selected sequence. */
/** Image cones for the selected Mapillary sequence, its line highlighted. */
export function createSequences({ state, source, parts }) {
const { render, sprites } = state.services;
function requestRender() {
render?.governorRequestRender?.('mapillary-sequence');
}
function notify() {
state.context.notify();
}
const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() });
/** Drawn cones, plus the mesh cells under them, so a sample moves only those. */
let drawn = nothingDrawn();
// The cones skip the depth test: hide the ones behind the globe.
const horizon = createHorizonCull({
getViewer: () => state.viewer,
items: () => drawn.cones.map((cone) => cone.billboard),
onChange: requestRender,
});
function ensureCollections(viewer) {
if (state.sequence.collection) return;
state.sequence.collection = new Cesium.BillboardCollection({
scene: viewer.scene,
});
viewer.scene.primitives.add(state.sequence.collection);
sprites?.registerSpriteCollection?.(SPRITE_ID, state.sequence.collection);
}
function clearCones() {
state.sequence.collection?.removeAll();
state.sequence.images = [];
drawn = nothingDrawn();
horizon.stop();
}
/** Where a cone stands: at `height`, or clamped to the ground when null. */
function placement(image, height) {
return {
position: Cesium.Cartesian3.fromDegrees(
image.lon,
image.lat,
height ?? 0,
),
heightReference:
height === null
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE,
};
}
/**
* Terrain-mode cone heights on the street, not on roofs: bare earth refined
* by mesh samples. Null when draped or while terrain heights are fetched.
*/
function coneHeights(images, { request = true } = {}) {
const ground = state.context.groundCaster;
if (state.context.getSurface?.() !== 'terrain' || !ground) return null;
const dems = images.map((image) => ground.groundAt(image.lon, image.lat));
if (dems.includes(null)) {
ground
.prepare(images.map((image) => [image.lon, image.lat]))
.then((ready) => {
if (ready && state.sequence.images === images) renderCones(images);
});
return null;
}
const sampler = state.context.meshSampler;
if (request)
sampler?.request(images.map((image) => [image.lon, image.lat]));
return refineHeights(
images.map((image, i) => ({
dem: dems[i],
mesh: sampler?.meshAt(image.lon, image.lat),
})),
);
}
function renderCones(images) {
const collection = state.sequence.collection;
if (!collection) return;
collection.removeAll();
drawn = { ...nothingDrawn(), images };
const heights = coneHeights(images);
// One colour per source; 360° images keep the ring shape.
const cone = imageConeGlyph({ size: 32, color: COLORS.coverage });
const ring = imageConeGlyph({
size: 32,
color: COLORS.coverage,
pano: true,
});
// Resolved now, so a "since N days" window keeps up with the clock.
const filter = state.context.getFilter();
images.forEach((image, index) => {
if (!passesImageryFilter(image, filter)) return;
const height = heights?.[index] ?? null;
const billboard = collection.add({
id: `${PICK_PREFIX.image}${image.id}`,
...placement(image, height),
image: image.isPano ? ring : cone,
imageId: image.isPano ? 'mly-cone-pano' : 'mly-cone',
width: IMAGE_CONE_SIZE_PX,
height: IMAGE_CONE_SIZE_PX,
rotation: -Cesium.Math.toRadians(image.compassAngle),
alignedAxis: Cesium.Cartesian3.UNIT_Z,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35),
});
drawn.cones.push({ index, billboard, height });
drawn.cells.add(meshCellKey(image.lon, image.lat));
});
horizon.update();
requestRender();
}
/**
* Re-place cones whose height new mesh samples changed. A sample can move
* a neighbour too, so every height is recomputed but only moved cones touched.
*/
function onMeshSampled(batch) {
if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length)
return;
if (!batch.some(([lon, lat]) => drawn.cells.has(meshCellKey(lon, lat))))
return;
const { images, cones } = drawn;
// Already asked for when drawn; a missing terrain cell redraws them all.
const heights = coneHeights(images, { request: false });
if (!heights) return;
const moved = [];
for (const cone of cones) {
const height = heights[cone.index] ?? null;
if (height === cone.height) continue;
cone.height = height;
const { position, heightReference } = placement(
images[cone.index],
height,
);
cone.billboard.heightReference = heightReference;
cone.billboard.position = position;
moved.push(cone.billboard);
}
if (!moved.length) return;
horizon.update(moved);
requestRender();
}
function remember(sequenceId, images) {
const { cache } = state.sequence;
cache.delete(sequenceId);
cache.set(sequenceId, images);
while (cache.size > SEQUENCE_CACHE_SIZE)
cache.delete(cache.keys().next().value);
}
/** Select a sequence: highlight its line and load its image cones. */
async function select(sequenceId) {
if (!sequenceId || !state.viewer) return;
// Already on screen, or already on its way.
if (
state.sequence.selectedId === sequenceId &&
(state.sequence.images.length || state.sequence.abort)
)
return;
if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId)
parts.coverage.recolorSequence(state.sequence.selectedId, false);
state.sequence.abort?.abort();
state.sequence.selectedId = sequenceId;
parts.coverage.recolorSequence(sequenceId, true);
const cached = state.sequence.cache.get(sequenceId);
if (cached) {
state.sequence.abort = null;
state.sequence.loading = false;
state.sequence.images = cached;
renderCones(cached);
state.context.actions.reportError(null);
notify();
return;
}
// The previous sequence's cones must not stay clickable under the new
// highlight while this one loads, nor after its load fails.
clearCones();
requestRender();
const controller = new AbortController();
state.sequence.abort = controller;
state.sequence.loading = true;
notify();
try {
const records = await source.getSequenceImages(sequenceId, {
signal: controller.signal,
});
if (controller.signal.aborted) return;
const images = thinImages(
return createSequenceCones({
state,
async loadImages(sequenceId, { signal }) {
const records = await source.getSequenceImages(sequenceId, { signal });
return thinImages(
records
.map(normalizeSequenceImage)
.filter(Boolean)
.sort((a, b) => a.capturedAt - b.capturedAt),
);
remember(sequenceId, images);
state.sequence.images = images;
renderCones(images);
state.context.actions.reportError(null);
} catch (error) {
if (!controller.signal.aborted) {
// Nothing to show: drop the highlight, so a click asks again.
parts.coverage.recolorSequence(sequenceId, false);
state.sequence.selectedId = null;
state.context.actions.reportError(
error?.message || 'Sequence images unavailable',
);
}
} finally {
if (state.sequence.abort === controller) {
state.sequence.loading = false;
state.sequence.abort = null;
}
notify();
}
}
function clearSelection() {
state.sequence.abort?.abort();
state.sequence.abort = null;
if (state.sequence.selectedId)
parts.coverage.recolorSequence(state.sequence.selectedId, false);
state.sequence.selectedId = null;
state.sequence.loading = false;
clearCones();
// An error about the sequence goes with it.
state.context.actions.reportError(null);
requestRender();
notify();
}
/** Re-draw the current sequence's cones (after an imagery filter change). */
function rerender() {
if (state.sequence.images.length) renderCones(state.sequence.images);
}
// New mesh samples may move the cones onto (or off) the road surface.
const stopMeshListener = state.context.meshSampler?.onSampled(onMeshSampled);
function setVisible(visible) {
if (state.sequence.collection) state.sequence.collection.show = visible;
requestRender();
}
function destroy(viewer) {
state.sequence.abort?.abort();
horizon.stop();
drawn = nothingDrawn();
stopMeshListener?.();
const collection = state.sequence.collection;
if (collection) {
sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection);
viewer?.scene?.primitives?.remove(collection);
state.sequence.collection = null;
}
state.sequence.images = [];
state.sequence.selectedId = null;
}
return {
ensureCollections,
select,
clearSelection,
rerender,
setVisible,
destroy,
};
},
colors: COLORS,
pickPrefix: PICK_PREFIX.image,
spriteId: 'street-level:mapillary-cones',
renderKey: 'mapillary-sequence',
onHighlight: (sequenceId, selected) =>
parts.coverage.recolorSequence(sequenceId, selected),
});
}
+48 -3
View File
@@ -1,5 +1,11 @@
import { encodeIdSwitches } from '../../data/idSwitches.js';
export const IMAGERY_FILTERS = Object.freeze(['pano', 'since']);
export function filtersOf(provider) {
return provider?.filters ?? IMAGERY_FILTERS;
}
/**
* The contract an imagery provider registers with the Street Level layer.
*
@@ -11,17 +17,25 @@ import { encodeIdSwitches } from '../../data/idSwitches.js';
* @property {string} pickPrefix Every primitive id the provider creates starts with it.
* @property {{coverage: string}} colors The source's one colour, from policy.js PROVIDER_COLORS.
* @property {{html: string}} credit On-globe attribution while the provider is active.
* @property {boolean} [groundClick] No coverage: a ground click at street zoom opens its nearest image.
* @property {string} [requiresMapStack] Usable only on this map stack.
* @property {boolean} [defaultOn] Default true.
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
* @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.
* @property {object} services Scene services: picking, input, render, sprites, ground, terrain.
* @property {object} services Scene services: picking, input, render, sprites, ground, terrain, scenePick (ground clicks need it).
* @property {() => {pano: string, sinceMs: number|null}} getFilter Filter resolved against now; read it on every use so "since N days" keeps up.
* @property {() => boolean} isActive Layer enabled and this provider switched on.
* @property {() => 'draped'|'terrain'} getSurface 'terrain' on Google 3D at street zoom: draw on bare earth.
* @property {GroundCaster|null} groundCaster Bare-earth heights for terrain mode (groundCast.js); null without a terrain service.
* @property {{meshAt: (lon: number, lat: number) => number|undefined, request: (points: Array<[number, number]>) => void, onSampled: (listener: (cells: Array<[number, number]>) => void) => () => void}|null} meshSampler Sampled Google 3D surface heights (meshSampler.js); pass `meshAt` to `castLine`.
* @property {() => void} notify Ask the core to publish a new UI snapshot.
* @property {{openImage: (imageId: string) => Promise<void>, reportError: (message: string|null) => void}} actions
* @property {{openImage: (imageId: string, options?: {frame?: boolean}) => Promise<boolean>, openNearest: (point: {lon: number, lat: number}) => Promise<boolean>, reportError: (message: string|null) => void}} actions
*
* @typedef {object} ProviderInstance
* @property {() => Promise<{configured: boolean}>} status
@@ -33,7 +47,7 @@ import { encodeIdSwitches } from '../../data/idSwitches.js';
* @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter The filter changed: redraw (reading `getFilter()`).
* @property {(mode: 'draped'|'terrain') => void} [setSurface] Redraw coverage and cones for the new surface mode.
* @property {() => {count: number, zoom: number|null, kind: string|null, loading: boolean, hint: string, error: string|null, keyRequired: boolean}} coverageStats
* @property {(pickId: string) => boolean} handlePick The id carries the provider's own prefix.
* @property {(pickId: string, click?: {groundPoint: () => {lon: number, lat: number}|null}) => boolean} handlePick The id carries the provider's own prefix; `groundPoint` is where the click met the ground.
* @property {(sequenceId: string) => Promise<void>} [selectSequence]
* @property {() => void} [clearSequence]
* @property {() => {selectedId: string|null, images: number, loading: boolean}} [sequenceStats]
@@ -54,6 +68,7 @@ import { encodeIdSwitches } from '../../data/idSwitches.js';
* @property {() => void} resize
* @property {(host: HTMLElement) => Promise<void>} [prewarm]
* @property {(mode: 'letterbox'|'fill') => void} [setRenderMode]
* @property {() => boolean} [supportsRenderModes] False while the image has no frame to fit (a panorama): FIT / FILL hide.
* @property {(listener: (pose: StreetPose) => void) => () => void} onPose
*
* @typedef {object} StreetPose
@@ -65,6 +80,8 @@ import { encodeIdSwitches } from '../../data/idSwitches.js';
* @property {number|null} altitude
* @property {boolean} isPano
* @property {number|null} capturedAt Epoch milliseconds.
* @property {'day'|'month'} [capturedAtPrecision] How exactly the provider dates images (default 'day').
* @property {string|null} [title] A place name for the caption, e.g. the address.
* @property {string|null} creator
* @property {string|null} sequenceId
* @property {string} externalUrl Deep link to the image on the provider's site.
@@ -124,6 +141,34 @@ export function validateProviders(providers) {
throw new TypeError(
`Street Level provider ${label} needs a coverage colour`,
);
if (
provider.marker !== undefined &&
!(
provider.marker?.kind === 'view-cone' &&
typeof provider.marker.color === 'string'
)
)
throw new TypeError(
`Street Level provider ${label}: marker must be {kind: 'view-cone', color}`,
);
if (
provider.filters !== undefined &&
!(
Array.isArray(provider.filters) &&
provider.filters.every((key) => IMAGERY_FILTERS.includes(key))
)
)
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);
}
+23 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { requiresKeyIdFor, validateProviders } from './registry.js';
import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js';
const def = (overrides = {}) => ({
id: 'mapillary',
@@ -101,3 +101,25 @@ test('requiresKeyIdFor is the shared key, or null once providers differ', () =>
);
assert.equal(requiresKeyIdFor([def({ requiresKeyId: null })]), null);
});
test('a provider may narrow the filters it honours and pick a view-cone marker', () => {
assert.deepEqual(filtersOf(def()), ['pano', 'since'], 'both by default');
const narrowed = def({
filters: [],
marker: { kind: 'view-cone', color: '#4285f4' },
});
assert.deepEqual(filtersOf(validateProviders([narrowed])[0]), []);
assert.throws(
() => validateProviders([def({ filters: ['colour'] })]),
/filters must list pano or since/,
);
assert.throws(
() => validateProviders([def({ marker: { kind: 'pegman' } })]),
/marker must be/,
);
validateProviders([def({ sinceBasis: 'upload' })]);
assert.throws(
() => validateProviders([def({ sinceBasis: 'shot' })]),
/sinceBasis must be 'capture' or 'upload'/,
);
});
+23 -2
View File
@@ -21,10 +21,31 @@ export function createSelection({ state, parts }) {
? picking.resolvePickId(picked)
: picked?.id;
const route = parts.router.resolve(id);
if (!route?.instance) return;
// Not ours: the ground handler decides whether it is the map itself.
if (!route?.instance) {
parts.openAtGround?.(click.position, picked);
return;
}
const entry = state.providers.get(route.providerId);
if (!entry?.on) return;
route.instance.handlePick(route.id);
route.instance.handlePick(route.id, {
groundPoint: () => groundPointAt(viewer, click.position),
});
}
/** Lon/lat of the ground under a screen point, or null (sky, no picker). */
function groundPointAt(viewer, screenPosition) {
const position = state.services.scenePick?.pickGroundPosition?.(
viewer,
screenPosition,
);
const carto = position && Cesium.Cartographic.fromCartesian(position);
return carto
? {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
}
: null;
}
/**
+78 -3
View File
@@ -8,7 +8,12 @@ import { createSelection } from './selection.js';
* `frame()` runs them; timers and Date.now() are mocked so a burst of moves
* has a known timeline.
*/
function harness({ selected = false } = {}) {
function harness({
selected = false,
router = null,
providers = new Map(),
services = {},
} = {}) {
const saved = {
document: globalThis.document,
requestAnimationFrame: globalThis.requestAnimationFrame,
@@ -34,6 +39,7 @@ function harness({ selected = false } = {}) {
removeListener(type, listener, options);
};
const sequences = { selected, cleared: 0 };
const groundClicks = [];
const picks = [];
/** What the pointer is over: a line this layer owns, or nothing. */
const scene = { under: null };
@@ -59,13 +65,19 @@ function harness({ selected = false } = {}) {
viewer,
enabled: true,
clickHandler: null,
services: { picking: null, input: null },
providers,
services: { picking: null, input: null, ...services },
};
const selection = createSelection({
state,
parts: {
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
router: router || {
ownsPick: (id) => id === 'mly:seq:1',
resolve: () => null,
},
hasSelectedSequence: () => sequences.selected,
openAtGround: (position, picked) =>
groundClicks.push({ position, picked }),
clearSequences() {
sequences.cleared++;
sequences.selected = false;
@@ -76,6 +88,9 @@ function harness({ selected = false } = {}) {
const onMove = state.clickHandler.getInputAction(
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
);
const onClick = state.clickHandler.getInputAction(
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
return {
state,
sequences,
@@ -97,6 +112,8 @@ function harness({ selected = false } = {}) {
camera,
picks,
move: (x) => onMove({ endPosition: { x, y: 10 } }),
click: (x) => onClick({ position: { x, y: 10 } }),
groundClicks,
frame() {
for (const task of frames.splice(0)) task();
},
@@ -256,3 +273,61 @@ test('Esc in a text field or rich-text editor keeps the selection (M61)', () =>
h.restore();
}
});
test('a click on nothing of ours goes to the ground handler with the pick, which decides', () => {
const h = harness();
try {
h.click(5);
h.scene.under = 'flight:abc';
h.click(6);
assert.deepEqual(
h.groundClicks.map(({ position, picked }) => [position.x, picked?.id]),
[
[5, undefined],
[6, 'flight:abc'],
],
);
} finally {
h.restore();
}
});
test('a click on a provider line hands it the ground under the click, measured only if asked', () => {
const handled = [];
const instance = {
handlePick: (id, click) => handled.push({ id, click }),
};
const groundPicks = [];
const h = harness({
router: {
ownsPick: () => true,
resolve: (id) => ({ providerId: 'kartaview', instance, id }),
},
providers: new Map([['kartaview', { on: true }]]),
services: {
scenePick: {
pickGroundPosition: (viewer, position) => {
groundPicks.push(position);
return Cesium.Cartesian3.fromDegrees(-83.0458, 42.3314, 190);
},
},
},
});
try {
h.scene.under = 'kv:line:14/1/2/3';
h.click(40);
assert.equal(handled.length, 1);
assert.equal(handled[0].id, 'kv:line:14/1/2/3');
assert.equal(
groundPicks.length,
0,
'no depth read until the provider asks',
);
const point = handled[0].click.groundPoint();
assert.ok(Math.abs(point.lon + 83.0458) < 1e-9);
assert.ok(Math.abs(point.lat - 42.3314) < 1e-9);
assert.deepEqual(groundPicks, [{ x: 40, y: 10 }]);
} finally {
h.restore();
}
});
+334
View File
@@ -0,0 +1,334 @@
import * as Cesium from 'cesium';
import { imageConeGlyph } from './glyphs.js';
import { passesImageryFilter } from './filter.js';
import { refineHeights } from './groundCast.js';
import { createHorizonCull, metresBetween } from './view.js';
import { meshCellKey } from './meshSampler.js';
/** How many recently viewed sequences keep their image list in memory. */
const SEQUENCE_CACHE_SIZE = 40;
/** Cones closer than this to the previous kept one are dropped. */
export const IMAGE_CONE_MIN_SPACING_M = 3;
export const IMAGE_CONE_SIZE_PX = 26;
/**
* @typedef {object} ConeImage One image of a sequence, as the cones draw it.
* @property {string} id
* @property {number} lon
* @property {number} lat
* @property {number} compassAngle Degrees clockwise from north.
* @property {number} capturedAt Epoch ms (0 when unknown).
* @property {boolean} isPano
*/
/** Drop images closer than the spacing to the previous kept one. */
export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
const kept = [];
let last = null;
for (const image of images) {
if (!last || metresBetween(last, image) >= spacingM) {
kept.push(image);
last = image;
}
}
return kept;
}
/**
* Image cones for one selected sequence, any provider's. A cone's pick id is
* `pickPrefix` + the image id; 360° images draw as rings.
*
* @param {object} options
* @param {object} options.state Provider state: `context`, `services`, `viewer`, `sequence`.
* @param {(sequenceId: string, init: {signal: AbortSignal}) => Promise<Array<ConeImage>>} options.loadImages
* The sequence's images in capture order, thinned (see `thinImages`).
* @param {{coverage: string}} options.colors
* @param {string} options.pickPrefix Pick-id prefix of the cones.
* @param {string} options.spriteId Sprite-order id of the cone collection.
* @param {string} options.renderKey Render-governor reason.
* @param {(sequenceId: string, selected: boolean) => void} [options.onHighlight]
* Highlight (or stop highlighting) the sequence's coverage line.
* @param {(image: ConeImage, filter: object) => boolean} [options.passes]
* Whether the imagery filter keeps a cone.
*/
export function createSequenceCones({
state,
loadImages,
colors,
pickPrefix,
spriteId,
renderKey,
onHighlight = () => {},
passes = passesImageryFilter,
}) {
const { render, sprites } = state.services;
function requestRender() {
render?.governorRequestRender?.(renderKey);
}
function notify() {
state.context.notify();
}
const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() });
/** Drawn cones, plus the mesh cells under them, so a sample moves only those. */
let drawn = nothingDrawn();
// The cones skip the depth test: hide the ones behind the globe.
const horizon = createHorizonCull({
getViewer: () => state.viewer,
items: () => drawn.cones.map((cone) => cone.billboard),
onChange: requestRender,
});
function ensureCollections(viewer) {
if (state.sequence.collection) return;
state.sequence.collection = new Cesium.BillboardCollection({
scene: viewer.scene,
});
viewer.scene.primitives.add(state.sequence.collection);
sprites?.registerSpriteCollection?.(spriteId, state.sequence.collection);
}
function clearCones() {
state.sequence.collection?.removeAll();
state.sequence.images = [];
drawn = nothingDrawn();
horizon.stop();
}
/** Where a cone stands: at `height`, or clamped to the ground when null. */
function placement(image, height) {
return {
position: Cesium.Cartesian3.fromDegrees(
image.lon,
image.lat,
height ?? 0,
),
heightReference:
height === null
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE,
};
}
/**
* Terrain-mode cone heights on the street, not on roofs: bare earth refined
* by mesh samples. Null when draped or while terrain heights are fetched.
*/
function coneHeights(images, { request = true } = {}) {
const ground = state.context.groundCaster;
if (state.context.getSurface?.() !== 'terrain' || !ground) return null;
const dems = images.map((image) => ground.groundAt(image.lon, image.lat));
if (dems.includes(null)) {
ground
.prepare(images.map((image) => [image.lon, image.lat]))
.then((ready) => {
if (ready && state.sequence.images === images) renderCones(images);
});
return null;
}
const sampler = state.context.meshSampler;
if (request)
sampler?.request(images.map((image) => [image.lon, image.lat]));
return refineHeights(
images.map((image, i) => ({
dem: dems[i],
mesh: sampler?.meshAt(image.lon, image.lat),
})),
);
}
function renderCones(images) {
const collection = state.sequence.collection;
if (!collection) return;
collection.removeAll();
drawn = { ...nothingDrawn(), images };
const heights = coneHeights(images);
// One colour per source; 360° images keep the ring shape.
const cone = imageConeGlyph({ size: 32, color: colors.coverage });
const ring = imageConeGlyph({
size: 32,
color: colors.coverage,
pano: true,
});
// Resolved now, so a "since N days" window keeps up with the clock.
const filter = state.context.getFilter();
images.forEach((image, index) => {
if (!passes(image, filter)) return;
const height = heights?.[index] ?? null;
const billboard = collection.add({
id: `${pickPrefix}${image.id}`,
...placement(image, height),
image: image.isPano ? ring : cone,
imageId: image.isPano ? `${spriteId}:pano` : `${spriteId}:flat`,
width: IMAGE_CONE_SIZE_PX,
height: IMAGE_CONE_SIZE_PX,
rotation: -Cesium.Math.toRadians(image.compassAngle),
alignedAxis: Cesium.Cartesian3.UNIT_Z,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35),
});
drawn.cones.push({ index, billboard, height });
drawn.cells.add(meshCellKey(image.lon, image.lat));
});
horizon.update();
requestRender();
}
/**
* Re-place cones whose height new mesh samples changed. A sample can move
* a neighbour too, so every height is recomputed but only moved cones touched.
*/
function onMeshSampled(batch) {
if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length)
return;
if (!batch.some(([lon, lat]) => drawn.cells.has(meshCellKey(lon, lat))))
return;
const { images, cones } = drawn;
// Already asked for when drawn; a missing terrain cell redraws them all.
const heights = coneHeights(images, { request: false });
if (!heights) return;
const moved = [];
for (const cone of cones) {
const height = heights[cone.index] ?? null;
if (height === cone.height) continue;
cone.height = height;
const { position, heightReference } = placement(
images[cone.index],
height,
);
cone.billboard.heightReference = heightReference;
cone.billboard.position = position;
moved.push(cone.billboard);
}
if (!moved.length) return;
horizon.update(moved);
requestRender();
}
function remember(sequenceId, images) {
const { cache } = state.sequence;
cache.delete(sequenceId);
cache.set(sequenceId, images);
while (cache.size > SEQUENCE_CACHE_SIZE)
cache.delete(cache.keys().next().value);
}
/**
* Select a sequence: highlight its line and load its image cones. `force`
* reloads a selected one (a provider that loads part of a sequence moved on).
*/
async function select(sequenceId, { force = false } = {}) {
if (!sequenceId || !state.viewer) return;
// Already on screen, or already on its way.
if (
!force &&
state.sequence.selectedId === sequenceId &&
(state.sequence.images.length || state.sequence.abort)
)
return;
if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId)
onHighlight(state.sequence.selectedId, false);
state.sequence.abort?.abort();
state.sequence.selectedId = sequenceId;
onHighlight(sequenceId, true);
const cached = force ? null : state.sequence.cache.get(sequenceId);
if (cached) {
state.sequence.abort = null;
state.sequence.loading = false;
state.sequence.images = cached;
renderCones(cached);
state.context.actions.reportError(null);
notify();
return;
}
// The previous sequence's cones must not stay clickable under the new
// highlight while this one loads, nor after its load fails.
clearCones();
requestRender();
const controller = new AbortController();
state.sequence.abort = controller;
state.sequence.loading = true;
notify();
try {
const images = await loadImages(sequenceId, {
signal: controller.signal,
});
if (controller.signal.aborted) return;
remember(sequenceId, images);
state.sequence.images = images;
renderCones(images);
state.context.actions.reportError(null);
} catch (error) {
if (!controller.signal.aborted) {
// Nothing to show: drop the highlight, so a click asks again.
onHighlight(sequenceId, false);
state.sequence.selectedId = null;
state.context.actions.reportError(
error?.message || 'Sequence images unavailable',
);
}
} finally {
if (state.sequence.abort === controller) {
state.sequence.loading = false;
state.sequence.abort = null;
}
notify();
}
}
function clearSelection() {
state.sequence.abort?.abort();
state.sequence.abort = null;
if (state.sequence.selectedId)
onHighlight(state.sequence.selectedId, false);
state.sequence.selectedId = null;
state.sequence.loading = false;
clearCones();
// An error about the sequence goes with it.
state.context.actions.reportError(null);
requestRender();
notify();
}
/** Re-draw the current sequence's cones (after an imagery filter change). */
function rerender() {
if (state.sequence.images.length) renderCones(state.sequence.images);
}
// New mesh samples may move the cones onto (or off) the road surface.
const stopMeshListener = state.context.meshSampler?.onSampled(onMeshSampled);
function setVisible(visible) {
if (state.sequence.collection) state.sequence.collection.show = visible;
requestRender();
}
function destroy(viewer) {
state.sequence.abort?.abort();
horizon.stop();
drawn = nothingDrawn();
stopMeshListener?.();
const collection = state.sequence.collection;
if (collection) {
sprites?.unregisterSpriteCollection?.(spriteId, collection);
viewer?.scene?.primitives?.remove(collection);
state.sequence.collection = null;
}
state.sequence.images = [];
state.sequence.selectedId = null;
}
return {
ensureCollections,
select,
clearSelection,
rerender,
setVisible,
destroy,
};
}
+10 -1
View File
@@ -18,6 +18,12 @@ export function freshStreet() {
altitude: null,
isPano: false,
capturedAt: null,
/** 'day', or 'month' for providers that date images by month (Google). */
capturedAtPrecision: 'day',
/** A place name for the caption (Google's address), or null. */
title: null,
/** Whether the open viewer has FIT/FILL render modes. */
renderModes: true,
sequenceId: null,
creator: null,
externalUrl: null,
@@ -50,10 +56,13 @@ export function createState({ services }) {
...freshStreet(),
},
/** Whether the camera is low enough for a ground click to open an image. */
groundClickReady: false,
/** 'terrain' on Google 3D at street zoom (overlays on the bare earth), else 'draped'. */
surface: 'draped',
marker: { collection: null, billboard: null },
marker: { collection: null, billboard: null, look: null },
clickHandler: null,
};
}
+65 -8
View File
@@ -15,6 +15,11 @@ import { COLORS } from './policy.js';
* @property {string} hint
* @property {string|null} error
* @property {string} color The source's one colour.
* @property {boolean} [groundClick]
* @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.
*/
/**
@@ -30,8 +35,10 @@ export function summarizeCoverage(providers) {
hint:
providers.length === 0
? 'No street-level imagery providers configured'
: active.find((p) => p.hint)?.hint || '',
: [...new Set(active.map((p) => p.hint).filter(Boolean))].join(' · '),
error: active.find((p) => p.error)?.error || null,
// One source is lit at a time; its wording counts the lines.
label: active.find((p) => p.coverageLabel)?.coverageLabel || null,
keyRequired:
active.length > 0 && active.every((p) => p.keyRequired === true),
keyRejected:
@@ -41,13 +48,51 @@ export function summarizeCoverage(providers) {
};
}
/**
* Switched-off providers that would draw with a key already set, offered
* while the switched-on ones lack theirs.
* @param {Array<ProviderSnapshot>} providers
* @returns {Array<{id: string, label: string, requiresKeyId: string|null}>}
*/
export function keyAlternatives(providers) {
if (!summarizeCoverage(providers).keyRequired) return [];
return providers
.filter((p) => !p.on && p.configured === true && p.keyRequired !== true)
.map(({ id, label, requiresKeyId }) => ({ id, label, requiresKeyId }));
}
/**
* The filter controls a switched-on provider honours (both when none is on).
* @param {Array<ProviderSnapshot>} providers
*/
export function filterControls(providers) {
const active = providers.filter((p) => p.on);
const honoured = (key) =>
active.length === 0 ||
active.some((p) => (p.filters ?? ['pano', 'since']).includes(key));
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, then the shared selection colour.
* one swatch per active source that draws coverage lines, then Selected.
* @param {{enabled: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
*/
export function composeUIState({
enabled,
groundClickReady = false,
filter,
providers,
street,
@@ -57,18 +102,30 @@ export function composeUIState({
const active = providers.filter((p) => p.on);
const { keyRequired, keyRejected, ...coverage } =
summarizeCoverage(providers);
const legend = active.map((provider) => ({
key: provider.id,
label: provider.name,
color: provider.color,
}));
if (active.length)
const legend = active
.filter((provider) => provider.groundClick !== true)
.map((provider) => ({
key: provider.id,
label: provider.name,
color: provider.color,
}));
// "Selected" only beside a source whose lines turn cyan when selected.
if (
active.some(
(provider) =>
provider.groundClick !== true && provider.highlightsSequence !== false,
)
)
legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected });
return {
enabled,
groundClickReady: groundClickReady === true,
keyRequired,
keyAlternatives: keyAlternatives(providers),
keyRejected,
filter: { ...filter },
filterControls: filterControls(providers),
sinceNote: sinceNote(providers),
providers: providers.map((p) => ({ ...p })),
coverage,
legend,
+121
View File
@@ -85,6 +85,61 @@ test('the legend is one swatch per active source, in its colour, then Selected',
);
});
test('Street View draws no lines: no legend swatch, and no Selected on its own', () => {
const streetView = provider({
id: 'google',
name: 'Google Street View',
color: '#4285f4',
count: 0,
groundClick: true,
filters: [],
});
assert.deepEqual(
composeUIState({ ...base, providers: [streetView] }).legend,
[],
);
assert.deepEqual(
composeUIState({
...base,
providers: [provider(), streetView],
}).legend.map((entry) => entry.key),
['mapillary', 'selected'],
);
});
test('the filter controls show only for a lit provider that honours them', () => {
const streetView = provider({ id: 'google', groundClick: true, filters: [] });
const controls = (providers) =>
composeUIState({ ...base, providers }).filterControls;
assert.deepEqual(controls([streetView]), { pano: false, since: false });
assert.deepEqual(controls([provider(), streetView]), {
pano: true,
since: true,
});
assert.deepEqual(
controls([provider({ on: false }), streetView]),
{ pano: false, since: false },
'Mapillary switched off',
);
assert.deepEqual(
controls([provider({ on: false }), { ...streetView, on: false }]),
{ pano: true, since: true },
'nothing lit: both, ready for the first chip',
);
});
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,
@@ -111,6 +166,7 @@ test('summarizeCoverage is what getStats reports, without building a snapshot',
loading: true,
hint: 'Look down',
error: 'boom',
label: null,
keyRequired: false,
keyRejected: false,
},
@@ -149,3 +205,68 @@ test('the surface mode defaults to draped and passes through', () => {
'terrain',
);
});
test('every switched-on provider’s hint is kept, once each', async () => {
const { summarizeCoverage } = await import('./uiState.js');
const { providerSnapshot } =
await import('../../testSupport/streetLevelFakes.mjs');
const summary = summarizeCoverage([
providerSnapshot({ hint: 'zoom in for sequences' }),
providerSnapshot({
id: 'google',
hint: 'click a street to open Street View',
}),
providerSnapshot({ id: 'off', on: false, hint: 'not shown' }),
providerSnapshot({ id: 'dup', hint: 'zoom in for sequences' }),
]);
assert.equal(
summary.hint,
'zoom in for sequences · click a street to open Street View',
);
});
test('a key-gated layer offers the switched-off sources that would draw with a key already set', async () => {
const { keyAlternatives } = await import('./uiState.js');
const mapillary = providerSnapshot({ keyRequired: true, configured: false });
const streetView = providerSnapshot({
id: 'google',
label: 'STREET VIEW',
on: false,
configured: true,
requiresKeyId: 'google-maps',
});
assert.deepEqual(keyAlternatives([mapillary, streetView]), [
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
]);
// Nothing to offer: Mapillary has its key, or Street View has none either.
assert.deepEqual(keyAlternatives([providerSnapshot(), streetView]), []);
assert.deepEqual(
keyAlternatives([
mapillary,
{ ...streetView, configured: false, keyRequired: true },
]),
[],
);
});
test('a source whose lines never show a selection lists no Selected swatch, and names its own lines', () => {
const kartaView = provider({
id: 'kartaview',
name: 'KartaView',
color: '#635bff',
highlightsSequence: false,
coverageLabel: {
noun: 'streets',
action: 'click a line for its nearest photo',
},
});
const ui = composeUIState({ ...base, providers: [kartaView] });
assert.deepEqual(
ui.legend.map((entry) => entry.key),
['kartaview'],
);
assert.deepEqual(ui.coverage.label, {
noun: 'streets',
action: 'click a line for its nearest photo',
});
});
+26 -4
View File
@@ -33,11 +33,22 @@ export function createViewerHost({ state, parts }) {
altitude: Number.isFinite(pose.altitude) ? pose.altitude : null,
isPano: pose.isPano === true,
capturedAt: pose.capturedAt ?? null,
capturedAtPrecision:
pose.capturedAtPrecision === 'month' ? 'month' : 'day',
title: pose.title || null,
sequenceId: pose.sequenceId ?? null,
creator: pose.creator || null,
externalUrl: pose.externalUrl || null,
// FIT / FILL, unless this image has no frame to fit (a panorama).
renderModes:
typeof active.adapter.setRenderMode === 'function' &&
active.adapter.supportsRenderModes?.() !== false,
});
parts.marker.set(state.street.position, state.street.bearing);
parts.marker.set(
state.street.position,
state.street.bearing,
providerEntry(pose.providerId)?.def.marker,
);
parts.follow.followCamera();
// Select the sequence only once the image is on screen, so its lookup
// never competes with the image download.
@@ -68,6 +79,12 @@ export function createViewerHost({ state, parts }) {
active.adapter.unmount();
active = null;
}
// A prewarmed viewer would sit under this one, holding its WebGL context.
for (const [id, warm] of warmed) {
if (id === entry.def.id) continue;
warmed.delete(id);
warm.unmount();
}
const adapter = entry.instance.viewer;
const promise = (async () => {
await adapter.mount(state.street.host);
@@ -128,6 +145,8 @@ export function createViewerHost({ state, parts }) {
try {
const adapter = await mount(entry);
if (!current()) return false;
// Per open: close() resets it, and a reopen skips mount().
state.street.renderModes = typeof adapter.setRenderMode === 'function';
await adapter.open(String(imageId));
if (!current()) return false;
if (ticket && !state.street.follow) parts.follow.lookAtPosition(ticket);
@@ -183,14 +202,17 @@ export function createViewerHost({ state, parts }) {
for (const entry of entries) {
const adapter = entry.instance.viewer;
if (!adapter.prewarm || !entry.on) continue;
// Another provider's viewer is showing in the host: do not build under it.
if (active && active.id !== entry.def.id) continue;
try {
await adapter.prewarm(host);
} catch {
/* the real open reports errors */
}
if (active?.id === entry.def.id) continue;
// Switched off while it loaded: release it now rather than keep it.
if (!state.enabled || !entry.on) adapter.unmount();
if (active?.id === entry.def.id || mounting?.id === entry.def.id)
continue;
// Switched off, or another viewer took the host meanwhile.
if (!state.enabled || !entry.on || active || mounting) adapter.unmount();
else warmed.set(entry.def.id, adapter);
}
}
@@ -576,3 +576,100 @@ test('a photo asks for the camera when it starts opening and frames with that ti
assert.equal(await host.open('mapillary', 'b'), true);
assert.deepEqual([framing.begun, framing.started], [1, 1]);
});
/** The harness with a second provider, `google`, whose adapter has no render modes. */
function twoProviders() {
const mapillary = fakeAdapter();
mapillary.prewarm = async () => {};
mapillary.setRenderMode = () => {};
const google = fakeAdapter();
google.prewarm = async () => {};
const h = harness(mapillary);
h.state.providers.set('google', {
def: { id: 'google', name: 'Google Street View', label: 'STREET VIEW' },
instance: { viewer: google },
on: true,
});
h.state.providers.get('mapillary').on = true;
return { ...h, mapillary, google };
}
test("opening one provider's image releases another's prewarmed viewer in the shared host", async () => {
const { state, host, mapillary, google } = twoProviders();
await host.prewarm([state.providers.get('mapillary')]);
assert.equal(mapillary.calls.unmount, 0, 'warm');
await host.open('google', 'g-1');
assert.equal(mapillary.calls.unmount, 1, 'not left under the panorama');
assert.equal(google.calls.unmount, 0);
// While Google shows, Mapillary is not built again under it.
let built = 0;
mapillary.prewarm = async () => built++;
await host.prewarm([...state.providers.values()]);
assert.equal(built, 0);
});
test('the render modes follow the viewer that is open', async () => {
const { state, host } = twoProviders();
await host.open('mapillary', 'm-1');
assert.equal(state.street.renderModes, true);
await host.open('google', 'g-1');
assert.equal(state.street.renderModes, false, 'Google always fills');
});
test('a pose can date its image by month and name its place', async () => {
const { state, host, google } = twoProviders();
await host.open('google', 'g-1');
google.emitPose('g-1', {
providerId: 'google',
capturedAtPrecision: 'month',
title: '1014 10th St',
capturedAt: Date.UTC(2023, 0, 1),
});
assert.equal(state.street.capturedAtPrecision, 'month');
assert.equal(state.street.title, '1014 10th St');
host.close();
assert.deepEqual(
[state.street.capturedAtPrecision, state.street.title],
['day', null],
);
});
test('a prewarm that lands after another provider took the host is released, not built under it', async () => {
const { state, host, mapillary } = twoProviders();
let release;
mapillary.prewarm = () =>
new Promise((resolve) => {
release = resolve;
});
const warming = host.prewarm([state.providers.get('mapillary')]);
await host.open('google', 'g-1'); // the user opens Street View meanwhile
release();
await warming;
assert.equal(mapillary.calls.unmount, 1, 'released at once');
// Not kept warm: the next Mapillary open mounts afresh.
await host.open('mapillary', 'm-1');
assert.equal(state.street.providerId, 'mapillary');
});
test('FIT/FILL stays hidden for a second Street View panorama after a close', async () => {
const { state, host } = twoProviders();
await host.open('google', 'g-1');
assert.equal(state.street.renderModes, false);
host.close();
await host.open('google', 'g-2');
assert.equal(state.street.renderModes, false);
});
test('an adapter that can frame but says this image has no frame offers no FIT / FILL', async () => {
const adapter = fakeAdapter();
let frameable = false;
adapter.setRenderMode = () => {};
adapter.supportsRenderModes = () => frameable;
const { state, host } = harness(adapter);
await host.open('mapillary', 'pano-1');
assert.equal(state.street.imageId, 'pano-1', 'the pose arrived');
assert.equal(state.street.renderModes, false);
frameable = true;
await host.open('mapillary', 'flat-1');
assert.equal(state.street.renderModes, true, 'a flat image can be framed');
});
+37
View File
@@ -25,5 +25,42 @@ export function createEsriImagery() {
export function createIonImagery(style, accessToken) {
accessToken = String(accessToken || '').trim();
if (!accessToken) throw new Error('Ion imagery requires an explicit token');
if (
style === Cesium.IonWorldImageryStyle.AERIAL ||
style === Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS
) {
return createBingIonImagery(style, accessToken);
}
return Cesium.IonImageryProvider.fromAssetId(style, { accessToken });
}
async function createBingIonImagery(style, accessToken) {
const endpoint = await new Cesium.Resource({
url: `https://api.cesium.com/v1/assets/${style}/endpoint`,
queryParameters: { access_token: accessToken },
}).fetchJson();
if (
endpoint?.type !== 'IMAGERY' ||
endpoint.externalType !== 'BING' ||
typeof endpoint.options?.url !== 'string'
) {
throw new Error('Ion world imagery did not return a Bing imagery endpoint');
}
// Bing otherwise follows the page's HTTP scheme on localhost. Resolve its
// ion-issued credentials through public APIs so tiles use HTTPS without
// weakening CSP, changing SDK defaults or touching provider internals.
const { url, ...options } = endpoint.options;
const provider = await Cesium.BingMapsImageryProvider.fromUrl(url, {
...options,
tileProtocol: 'https',
});
const ionCredits = (endpoint.attributions ?? []).map(
({ html, collapsible }) => new Cesium.Credit(html, collapsible === false),
);
const getTileCredits = provider.getTileCredits.bind(provider);
provider.getTileCredits = (x, y, level) => [
...ionCredits,
...(getTileCredits(x, y, level) ?? []),
];
return provider;
}
+157 -1
View File
@@ -89,7 +89,7 @@ test('imagery and terrain pass their own ion token without relying on SDK defaul
calls.push({ kind: 'terrain', resource, options });
return { id: 'terrain' };
};
await createIonImagery(Cesium.IonWorldImageryStyle.AERIAL, 'imagery-token');
await createIonImagery(12345, 'imagery-token');
const result = await createWorldTerrain('terrain-token');
assert.equal(calls[0].options.accessToken, 'imagery-token');
assert.equal(calls[1].options.accessToken, 'terrain-token');
@@ -104,6 +104,162 @@ test('imagery and terrain pass their own ion token without relying on SDK defaul
}
});
test('both Bing ion stacks use HTTPS and retain ion and per-tile attribution', async (t) => {
const defaultToken = Cesium.Ion.defaultAccessToken;
const metadataCalls = [];
const bingCalls = [];
const perTileCalls = [];
const bingCredit = new Cesium.Credit('Bing tile credit', true);
const endpoint = {
type: 'IMAGERY',
externalType: 'BING',
options: Object.freeze({
url: 'https://dev.virtualearth.net',
key: 'ion-issued-bing-fixture',
mapStyle: 'Aerial',
tileProtocol: 'http',
}),
attributions: [
{ html: 'Ion required credit', collapsible: false },
{ html: 'Ion optional credit', collapsible: true },
],
};
t.mock.method(Cesium.Resource.prototype, 'fetchJson', async function () {
metadataCalls.push({
url: this.url,
token: this.queryParameters.access_token,
});
const mapStyle = this.url.includes(
`/assets/${Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS}/`,
)
? 'AerialWithLabels'
: 'Aerial';
return {
...endpoint,
options: Object.freeze({ ...endpoint.options, mapStyle }),
};
});
t.mock.method(Cesium.IonImageryProvider, 'fromAssetId', () =>
assert.fail('Bing must not inherit the local HTTP page protocol'),
);
t.mock.method(
Cesium.BingMapsImageryProvider,
'fromUrl',
async (url, options) => {
bingCalls.push({ url, options });
const provider = {
errorEvent: new Cesium.Event(),
getTileCredits(x, y, level) {
assert.equal(this, provider);
perTileCalls.push([x, y, level]);
return level === 0 ? undefined : [bingCredit];
},
};
return provider;
},
);
for (const style of [
Cesium.IonWorldImageryStyle.AERIAL,
Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS,
]) {
const provider = await createIonImagery(style, ' own-ion-token ');
const credits = provider.getTileCredits(3, 4, 5);
assert.deepEqual(
credits.map(({ html, showOnScreen }) => ({ html, showOnScreen })),
[
{ html: 'Ion required credit', showOnScreen: true },
{ html: 'Ion optional credit', showOnScreen: false },
{ html: 'Bing tile credit', showOnScreen: true },
],
);
assert.equal(credits[2], bingCredit);
assert.equal(provider.getTileCredits(0, 0, 0).length, 2);
assert.ok(provider.errorEvent instanceof Cesium.Event);
assert.equal(metadataCalls.at(-1).token, 'own-ion-token');
const requested = new URL(metadataCalls.at(-1).url);
assert.equal(requested.origin, 'https://api.cesium.com');
assert.equal(requested.pathname, `/v1/assets/${style}/endpoint`);
}
assert.deepEqual(perTileCalls, [
[3, 4, 5],
[0, 0, 0],
[3, 4, 5],
[0, 0, 0],
]);
assert.equal(bingCalls.length, 2);
for (const [index, { url, options }] of bingCalls.entries()) {
assert.equal(url, 'https://dev.virtualearth.net');
assert.deepEqual(options, {
key: 'ion-issued-bing-fixture',
mapStyle: index === 0 ? 'Aerial' : 'AerialWithLabels',
tileProtocol: 'https',
});
}
assert.equal(endpoint.options.tileProtocol, 'http');
assert.equal(Cesium.Ion.defaultAccessToken, defaultToken);
});
test('Bing ion metadata and construction failures propagate without another source', async (t) => {
const style = Cesium.IonWorldImageryStyle.AERIAL;
const metadataError = new Error('ion metadata fixture failure');
const providerError = new Error('Bing construction fixture failure');
let metadata = () => {
throw metadataError;
};
let bingCalls = 0;
t.mock.method(Cesium.Resource.prototype, 'fetchJson', async () => metadata());
t.mock.method(Cesium.BingMapsImageryProvider, 'fromUrl', async () => {
bingCalls += 1;
throw providerError;
});
t.mock.method(Cesium.IonImageryProvider, 'fromAssetId', () =>
assert.fail('Bing failures must not silently use an alternate provider'),
);
await assert.rejects(createIonImagery(style, 'test-token'), metadataError);
for (const endpoint of [
undefined,
{
type: 'TERRAIN',
externalType: 'BING',
options: { url: 'https://example.test' },
},
{
type: 'IMAGERY',
externalType: 'OTHER',
options: { url: 'https://example.test' },
},
{ type: 'IMAGERY', externalType: 'BING', options: {} },
]) {
metadata = () => endpoint;
await assert.rejects(
createIonImagery(style, 'test-token'),
/Bing imagery endpoint/,
);
}
assert.equal(bingCalls, 0);
metadata = () => ({
type: 'IMAGERY',
externalType: 'BING',
options: { url: 'https://dev.virtualearth.net', key: 'bing-fixture' },
});
await assert.rejects(createIonImagery(style, 'test-token'), providerError);
assert.equal(bingCalls, 1);
});
test('other ion imagery assets keep the standard SDK factory', async (t) => {
const expected = {};
const calls = [];
t.mock.method(Cesium.Resource.prototype, 'fetchJson', () =>
assert.fail('generic assets must use the SDK ion factory'),
);
t.mock.method(Cesium.IonImageryProvider, 'fromAssetId', async (...args) => {
calls.push(args);
return expected;
});
assert.equal(await createIonImagery(12345, ' custom-token '), expected);
assert.deepEqual(calls, [[12345, { accessToken: 'custom-token' }]]);
});
test('cancellation after ion metadata prevents terrain construction', async () => {
const originalResource = Cesium.IonResource.fromAssetId;
const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl;
+22 -2
View File
@@ -50,6 +50,26 @@ export function extractStationsArray(payload) {
return [];
}
/** Read legacy text or a GBFS v3 Localized String, preferring usable English text. */
function stationName(value) {
if (typeof value === 'string') return value.trim();
if (!Array.isArray(value)) return '';
let fallback = '';
for (const translation of value) {
if (typeof translation?.text !== 'string') continue;
const text = translation.text.trim();
if (!text) continue;
if (!fallback) fallback = text;
if (
typeof translation.language === 'string' &&
/^en(?:-|$)/i.test(translation.language.trim())
)
return text;
}
return fallback;
}
/**
* Parse a GBFS station_information payload into a Map of station metadata.
* Handles field-name variations across different GBFS providers
@@ -63,7 +83,7 @@ export function parseStationInformation(payload) {
const stationMap = new Map();
for (const raw of stations) {
// Accept both GBFS 2.x (station_id) and alternate (id) field names
// Accept both GBFS (station_id) and alternate (id) field names
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
const lat = Number(raw?.lat ?? raw?.latitude);
const lon = Number(raw?.lon ?? raw?.longitude);
@@ -71,7 +91,7 @@ export function parseStationInformation(payload) {
stationMap.set(stationId, {
stationId,
name: String(raw?.name || raw?.short_name || '').trim(),
name: stationName(raw?.name) || stationName(raw?.short_name),
lat,
lon,
capacity: toNonNegativeInteger(raw?.capacity),
+199
View File
@@ -0,0 +1,199 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseStationInformation } from './gbfsStations.js';
const station = (fields = {}) => ({
station_id: 's1',
lat: 30.2681,
lon: -97.7469,
capacity: 13,
...fields,
});
const parseName = (fields, version) =>
parseStationInformation({
version,
data: { stations: [station(fields)] },
}).get('s1').name;
test('GBFS v2 station names remain trimmed strings', () => {
assert.equal(
parseName({ name: ' Republic Square ', short_name: 'Short' }, '2.3'),
'Republic Square',
);
});
test('GBFS v3 station names prefer English over the first translation', () => {
assert.equal(
parseName(
{
name: [
{ language: 'hu', text: 'Köztársaság tér' },
{ language: 'en', text: ' Republic Square ' },
],
},
'3.0',
),
'Republic Square',
);
});
test('English preference accepts case differences and regional language tags', () => {
for (const language of ['EN', 'en-GB', 'En-US']) {
assert.equal(
parseName({
name: [
{ language: 'hu', text: 'Hungarian' },
{ language, text: 'English' },
],
}),
'English',
);
}
});
test('without usable English text the first usable translation wins', () => {
assert.equal(
parseName({
name: [
null,
{ language: 'en', text: ' ' },
{ language: 'en', text: 42 },
{ language: 'hu', text: ' Első ' },
{ language: 'fr', text: 'Deuxième' },
],
}),
'Első',
);
});
test('malformed language fields cannot abort translation selection', () => {
const name = [
null,
false,
'bad',
{},
{ language: 42, text: 'Fallback' },
{ language: { unexpected: true }, text: 'Other' },
{ language: 'en', text: 'English' },
];
assert.equal(parseName({ name }), 'English');
assert.equal(
parseName({ name: [{ text: ' Usable text without a locale ' }] }),
'Usable text without a locale',
);
});
test('a usable name takes precedence over an English short name', () => {
assert.equal(
parseName({
name: [{ language: 'hu', text: 'Full name' }],
short_name: [{ language: 'en', text: 'Short name' }],
}),
'Full name',
);
});
test('missing, empty and malformed names fall back to localized short_name', () => {
for (const name of [
undefined,
null,
'',
' ',
[],
{},
0,
42,
false,
[null, {}, { language: 'en', text: [] }, { language: 'hu', text: ' ' }],
]) {
assert.equal(
parseName({
name,
short_name: [
{ language: 'hu', text: 'Rövid' },
{ language: 'en', text: ' Short ' },
],
}),
'Short',
);
}
});
test('localized names can fall back to legacy short_name strings', () => {
assert.equal(parseName({ name: [], short_name: ' Short ' }), 'Short');
});
test('no usable name leaves the station available with an empty name', () => {
for (const value of [
undefined,
null,
'',
[],
{},
42,
true,
[null, { text: false }, { language: 'en', text: ' ' }],
]) {
assert.equal(parseName({ name: value, short_name: value }), '');
}
});
test('name shape is normalized even when a feed omits or misstates its version', () => {
const name = [{ language: 'en', text: 'Republic Square' }];
for (const version of [undefined, '2.3', '3.0', '3.1', 'unknown']) {
assert.equal(parseName({ name }, version), 'Republic Square');
assert.equal(parseName({ name: 'Legacy name' }, version), 'Legacy name');
}
});
test('bad names do not lose valid stations or their metadata', () => {
const information = parseStationInformation({
version: '3.0',
data: {
stations: [
station({ station_id: 'bad', name: [null, {}] }),
station({
station_id: 'short',
name: [],
short_name: [{ language: 'en', text: 'Short' }],
}),
station({
station_id: 'good',
name: [{ language: 'en', text: 'Republic Square' }],
}),
],
},
});
assert.equal(information.size, 3);
assert.equal(information.get('bad').name, '');
assert.equal(information.get('short').name, 'Short');
assert.deepEqual(information.get('good'), {
stationId: 'good',
name: 'Republic Square',
lat: 30.2681,
lon: -97.7469,
capacity: 13,
isInstalled: true,
isRenting: true,
isReturning: true,
});
});
test('normalizing a localized name leaves the feed document unchanged', () => {
const payload = {
version: '3.0',
data: {
stations: [
station({
name: [
{ language: 'hu', text: 'Magyar' },
{ language: 'en', text: ' English ' },
],
}),
],
},
};
const original = structuredClone(payload);
parseStationInformation(payload);
assert.deepEqual(payload, original);
});
+11
View File
@@ -253,3 +253,14 @@ test('the Cyber theme frames the Street Level panel like its rail peers (P3)', (
// A theme header rule must be able to outrank the panel's own.
assert.doesNotMatch(panelCss, /#street-level-panel \.panel-header/);
});
test('OPEN STREET VIEW keeps its width beside a long coverage line', () => {
// .scene-btn grows and shrinks (flex: 1) and loads after this stylesheet:
// only a more specific rule keeps the button from being squeezed.
assert.match(css, /\.scene-btn \{[^}]*flex: 1;/);
assert.match(
panelCss,
/\.sl-meta-row \.sl-nearest-btn \{\s*flex: 0 0 auto;\s*\}/,
);
assert.match(panelCss, /\.sl-meta-row \.sl-meta \{[^}]*min-width: 0;/);
});
+194
View File
@@ -0,0 +1,194 @@
/**
* Stand-ins for the Google Maps JavaScript API's Street View library and the
* loader that hands it out. Panoramas are {id: {lat, lng, imageDate,
* copyright, description}}; `nearest(location, radius)` answers location
* lookups with an id or null; `substitutes` maps an id Google answers with a
* newer one. Like Google, `setPano` sets the id (pano_changed) at once, and
* the position and status follow when the data arrives; status_changed fires
* only when the status changes.
*/
function latLng(lat, lng) {
return { lat: () => lat, lng: () => lng };
}
export function fakeStreetViewLibrary({
panoramas = {},
nearest = () => null,
substitutes = {},
} = {}) {
const built = [];
const lookups = [];
class StreetViewPanorama {
constructor(element, options) {
this.element = element;
this.options = options;
this.visible = options?.visible ?? true;
this.pano = null;
this.status = null;
this.position = null;
this.pov = { heading: 0, pitch: 0 };
this.listeners = new Map();
built.push(this);
}
addListener(name, listener) {
if (!this.listeners.has(name)) this.listeners.set(name, new Set());
this.listeners.get(name).add(listener);
return { remove: () => this.listeners.get(name)?.delete(listener) };
}
fire(name) {
for (const listener of [...(this.listeners.get(name) || [])]) listener();
}
listenerCount() {
let count = 0;
for (const set of this.listeners.values()) count += set.size;
return count;
}
/** Like Google: the id is set at once; the data loads asynchronously. */
setPano(id) {
this.pano = id;
this.fire('pano_changed');
queueMicrotask(() => {
if (this.pano !== id) return;
const shownId = substitutes[id] ?? id;
const known = panoramas[shownId];
const status = known ? 'OK' : 'ZERO_RESULTS';
if (shownId !== id) {
this.pano = shownId;
this.fire('pano_changed');
}
if (known) {
this.position = latLng(known.lat, known.lng);
this.fire('position_changed');
}
if (status !== this.status) {
this.status = status;
this.fire('status_changed');
}
});
}
getPano() {
return this.pano;
}
getStatus() {
return this.status;
}
getPosition() {
return this.position;
}
getPov() {
return { ...this.pov };
}
/** The user drags the view. */
setPov(pov) {
this.pov = { ...pov };
this.fire('pov_changed');
}
setVisible(visible) {
this.visible = visible;
}
}
class StreetViewService {
async getPanorama(request) {
lookups.push(request);
const id = request.pano ?? nearest(request.location, request.radius);
const known = id && panoramas[id];
if (!known)
throw Object.assign(new Error('ZERO_RESULTS'), {
code: 'ZERO_RESULTS',
});
return {
data: {
location: {
pano: id,
latLng: latLng(known.lat, known.lng),
description: known.description,
},
imageDate: known.imageDate,
copyright: known.copyright,
},
};
}
}
return {
library: {
StreetViewPanorama,
StreetViewService,
StreetViewSource: { GOOGLE: 'google', OUTDOOR: 'outdoor' },
StreetViewPreference: { NEAREST: 'nearest', BEST: 'best' },
},
built,
lookups,
};
}
/**
* A loader over `library`; `refuseKey()` plays Google's gm_authFailure. With
* `deferred`, imports wait for `release()`.
*/
export function fakeMapsLoader(library, { deferred = false } = {}) {
let failed = false;
const listeners = new Set();
const imports = [];
let release = null;
const gate = deferred
? new Promise((resolve) => {
release = resolve;
})
: null;
return {
imports,
async importLibrary(name) {
imports.push(name);
await gate;
return library;
},
release: () => release?.(),
authFailed: () => failed,
onAuthFailure(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
refuseKey() {
failed = true;
for (const listener of [...listeners]) listener();
},
listenerCount: () => listeners.size,
};
}
/** A host element with just what the viewer adapter touches. */
export function fakeHost() {
const children = [];
const host = {
children,
ownerDocument: {
createElement: () => {
const element = {
style: {},
className: '',
remove() {
const index = children.indexOf(element);
if (index !== -1) children.splice(index, 1);
},
};
return element;
},
},
append: (element) => children.push(element),
};
return host;
}
+81
View File
@@ -0,0 +1,81 @@
// DOM stand-ins for the KartaView viewer's tests (no browser in Node).
/** A DOM element with what the viewer touches: children, events, attributes. */
export function fakeElement(tag) {
const listeners = new Map();
const element = {
tagName: tag.toUpperCase(),
children: [],
parentNode: null,
style: {},
dataset: {},
attributes: {},
hidden: false,
disabled: false,
clientWidth: 400,
clientHeight: 250,
classList: {
values: new Set(),
add: (name) => element.classList.values.add(name),
remove: (name) => element.classList.values.delete(name),
},
append(...nodes) {
for (const node of nodes) {
node.parentNode = element;
element.children.push(node);
}
},
remove() {
const list = element.parentNode?.children;
if (list) list.splice(list.indexOf(element), 1);
element.parentNode = null;
},
replaceWith(next) {
const parent = element.parentNode;
if (!parent) return;
parent.children[parent.children.indexOf(element)] = next;
next.parentNode = parent;
next.ownerDocument = element.ownerDocument;
element.parentNode = null;
},
setAttribute: (name, value) => (element.attributes[name] = value),
removeAttribute: (name) => {
delete element.attributes[name];
if (name === 'src') element.src = '';
},
addEventListener: (type, handler) => listeners.set(type, handler),
removeEventListener: (type) => listeners.delete(type),
setPointerCapture() {},
/** Fire an event at the element's listener. */
fire(type, event = {}) {
return listeners.get(type)?.({
preventDefault() {
this.defaultPrevented = true;
},
stopPropagation() {},
...event,
});
},
};
return element;
}
export function fakeHost() {
const doc = { createElement: (tag) => fakeElement(tag) };
const host = fakeElement('div');
host.ownerDocument = doc;
return host;
}
/**
* Wait, a macrotask at a time, until `condition` holds; false if it never
* does within `ticks`. A test asserting that something did not happen waits
* on a sign that the work is done instead of a fixed count of ticks.
*/
export async function until(condition, { ticks = 200 } = {}) {
for (let i = 0; i < ticks; i++) {
if (await condition()) return true;
await new Promise((resolve) => setTimeout(resolve, 0));
}
return Boolean(await condition());
}
+94
View File
@@ -1,6 +1,11 @@
/** Shared Street Level test stand-ins: provider snapshot, minimal provider, ray-casting camera. */
import * as Cesium from 'cesium';
import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js';
import {
latToTileY,
lonToTileX,
tileBounds,
} from '../layers/streetLevel/tileMath.js';
const RAD = Math.PI / 180;
@@ -141,3 +146,92 @@ export function rayCamera({
computeViewRectangle: () => undefined,
};
}
/** The centre of the tile holding a point at zoom `z`, so a small view is one tile. */
export function tileCentre(lon, lat, z = 14) {
const x = lonToTileX(lon, z);
const y = latToTileY(lat, z);
const { west, east, south, north } = tileBounds(x, y, z);
return {
tile: { x, y, z },
lon: (west + east) / 2,
lat: (south + north) / 2,
};
}
/** A scene collection that remembers what is on the globe. */
function sceneCollection() {
const items = new Set();
return {
items,
add(primitive) {
items.add(primitive);
return primitive;
},
remove(primitive) {
return items.delete(primitive);
},
};
}
/**
* A viewer looking straight down on a 0.004° square around `view`
* (`{lon, lat, height}`, mutable), enough for line coverage to attach,
* refresh and draw. Cesium's one-time terrain-height download is skipped.
*/
export function coverageViewer(view, t) {
const initialize = Cesium.GroundPolylinePrimitive.initializeTerrainHeights;
Cesium.GroundPolylinePrimitive.initializeTerrainHeights = () =>
Promise.resolve();
t?.after(() => {
Cesium.GroundPolylinePrimitive.initializeTerrainHeights = initialize;
});
// Node has no WebGL context to report line-width limits.
Cesium.ContextLimits._minimumAliasedLineWidth = 1;
Cesium.ContextLimits._maximumAliasedLineWidth = 10;
const listeners = () => {
const set = new Set();
return {
set,
addEventListener(listener) {
set.add(listener);
return () => set.delete(listener);
},
};
};
return {
view,
scene: {
canvas: { clientWidth: 100, clientHeight: 100 },
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
frameState: { mode: Cesium.SceneMode.SCENE3D },
updateHeight: () => () => {},
getHeight: () => undefined,
groundPrimitives: sceneCollection(),
primitives: sceneCollection(),
postRender: listeners(),
preRender: listeners(),
},
camera: {
changed: new Cesium.Event(),
moveEnd: new Cesium.Event(),
get positionWC() {
return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height);
},
get positionCartographic() {
return {
longitude: view.lon * RAD,
latitude: view.lat * RAD,
height: view.height,
};
},
pickEllipsoid(point) {
return Cesium.Cartesian3.fromDegrees(
view.lon - 0.002 + (point.x / 100) * 0.004,
view.lat + 0.002 - (point.y / 100) * 0.004,
);
},
computeViewRectangle: () => undefined,
},
};
}
+193 -2
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import { inflateSync } from 'node:zlib';
import { createApplicationStreetLevel } from '../app/layers/streetLevel.js';
import {
fixtureTile,
@@ -20,6 +21,24 @@ import {
THUMB_HOST,
} from '../../scripts/fixtures/street-level/mapillaryGraph.mjs';
import { redact } from '../../scripts/qa-browserEvidence.mjs';
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,
resizedImageUrl,
} from '../layers/streetLevel/providers/kartaview/policy.js';
// The press helpers wait for paint; Node has no frames, so a tick stands in.
globalThis.requestAnimationFrame ??= (callback) =>
@@ -515,15 +534,20 @@ test('evidence never carries a Mapillary token', () => {
test('--strict accepts only the documented skips', () => {
assert.deepEqual(
[...STRICT_ALLOWED_SKIPS],
['no Google 3D', 'fixtures only'],
['no Google 3D', 'no Google key', 'fixtures only'],
);
assert.deepEqual(
strictViolations([
{ reason: 'no Google 3D', label: 'terrain' },
{ reason: 'no Google key', label: 'street view' },
{ reason: 'fixtures only', label: 'keyless page' },
{ reason: 'no Mapillary key', label: 'the keyed steps' },
{ reason: 'Google key refused', label: 'street view' },
]),
[{ reason: 'no Mapillary key', label: 'the keyed steps' }],
[
{ reason: 'no Mapillary key', label: 'the keyed steps' },
{ reason: 'Google key refused', label: 'street view' },
],
);
});
@@ -634,3 +658,170 @@ test('CI runs the gates hermetically against a production build, strict, with ev
'a missed header press fails CI now that the rail settles',
);
});
test('KartaView fixtures draw the drive, answer nearest first, and refuse the storage host the API names', () => {
const body = (answer) => JSON.parse(answer.body);
const lat = (KV_LINE.south + KV_LINE.north) / 2;
const x = lonToTileX(KV_LINE.lon, 14);
const y = latToTileY(lat, 14);
const tile = answerKartaViewRequest({
method: 'GET',
url: `https://api.kartaview.org/2.0/sequence/tiles/${x}/${y}/14.json`,
});
assert.equal(tile.known, true);
assert.ok(body(tile).lines.length >= 1);
const empty = answerKartaViewRequest({
method: 'GET',
url: `https://api.kartaview.org/2.0/sequence/tiles/${x + 3}/${y}/14.json`,
});
assert.deepEqual(body(empty).lines, []);
const nearUrl = `https://api.kartaview.org/2.0/photo/?lat=${lat}&lng=${KV_LINE.lon + 0.0001}&zoomLevel=18&itemsPerPage=10`;
resetKartaViewFixture();
// The first lookup is rate-limited, readable cross-origin; the retry answers.
const limited = answerKartaViewRequest({ method: 'GET', url: nearUrl });
assert.equal(limited.status, 429);
assert.equal(limited.known, true);
assert.equal(limited.headers['Retry-After'], '0');
assert.match(limited.headers['Access-Control-Expose-Headers'], /Retry-After/);
const near = answerKartaViewRequest({ method: 'GET', url: nearUrl });
assert.equal(near.status, 200);
const records = body(near).result.data;
assert.ok(records.length > 1);
assert.ok(Number(records[0].distance) <= Number(records[1].distance));
const photo = normalizePhoto(records[0]);
assert.match(photo.urls.view, /^https:\/\/storage2\.openstreetcam\.org\//);
assert.equal(
answerKartaViewRequest({ method: 'GET', url: photo.urls.view }).known,
true,
);
// Every odd photo is archived: its file answers 409, an even one the image.
const photos = kartaViewPhotos();
const storageAnswer = (index) =>
answerKartaViewRequest({
method: 'GET',
url: normalizePhoto(
body(
answerKartaViewRequest({
method: 'GET',
url: `https://api.kartaview.org/2.0/photo/${photos[index].id}`,
}),
).result.data,
).urls.view,
});
assert.equal(photos[3].archived, true);
assert.equal(storageAnswer(3).status, 409);
assert.equal(storageAnswer(3).known, true);
assert.equal(photos[4].archived, false);
assert.equal(storageAnswer(4).status, 200);
assert.equal(storageAnswer(4).contentType, 'image/jpeg');
// The image proxy still serves an archived photo: the 360° step opens one.
const pano = photos.find((p) => p.isPano);
assert.equal(pano.archived, true);
const panoRecord = body(
answerKartaViewRequest({
method: 'GET',
url: `https://api.kartaview.org/2.0/photo/${pano.id}`,
}),
).result.data;
assert.equal(
answerKartaViewRequest({
method: 'GET',
url: resizedImageUrl(normalizePhoto(panoRecord).urls.pano, [1024, 512]),
}).status,
200,
);
assert.equal(
answerKartaViewRequest({ method: 'GET', url: records[0].fileurlLTh }).known,
false,
'the storage1 address the API gives is not where the photo is',
);
const page = answerKartaViewRequest({
method: 'GET',
url: `https://api.kartaview.org/2.0/sequence/${KV_SEQUENCE_ID}/photos?page=1&itemsPerPage=1000`,
});
assert.equal(body(page).result.data.length, kartaViewPhotos().length);
assert.ok(kartaViewPhotos().some((p) => p.isPano));
assert.equal(
describeKartaViewCall(
'GET',
new URL('https://api.kartaview.org/2.0/photo/77000001'),
),
'GET api.kartaview.org/2.0/photo/{id}',
);
});
/** 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'), []);
});
+1 -1
View File
@@ -675,7 +675,7 @@ test('public transit documentation uses courtesy attribution and the approved gr
);
assert.ok(
readme.includes(
'Twenty layers and map sources. **Seventeen have a keyless path.**',
'Twenty layers and map sources. **Eighteen have a keyless path.**',
),
);
assert.ok(
+4 -2
View File
@@ -183,7 +183,7 @@ test('only embed-mode documents may be framed, and only by the allowed ancestors
}
});
test('the CSP admits the event media embeds, and no other script origin', async () => {
test('the CSP admits the event media embeds and the Maps JavaScript API, and no other script origin', async () => {
const { embeddedMediaFrameUrl, resolveEmbeddedMediaSource } =
await import('../../src/data/bhoteKoshiEmbeddedMedia.js');
const directive = (name) =>
@@ -201,7 +201,8 @@ test('the CSP admits the event media embeds, and no other script origin', async
'https://platform.twitter.com',
])
assert.ok(scripts.includes(origin), origin);
// Only those three, and never a wildcard or a scheme.
// Only those three and Google Street View's Maps JavaScript API, never a
// wildcard or a scheme.
assert.deepEqual(
scripts.filter(
(source) => source.startsWith('http') || source === 'https:',
@@ -210,6 +211,7 @@ test('the CSP admits the event media embeds, and no other script origin', async
'https://www.youtube.com',
'https://connect.facebook.net',
'https://platform.twitter.com',
'https://maps.googleapis.com',
],
);
assert.ok(!scripts.some((source) => source.includes('*')));
+11 -1
View File
@@ -15,7 +15,8 @@ export const PANEL_WORKER_PRELUDE_PATH = 'cesium/worker-prelude.js';
/**
* Sites the app's browser code loads from directly: map imagery, 3D tiles,
* terrain, fonts, and Street Level's Mapillary Graph API and image CDN (its
* coverage tiles come through the app's server). Everything from the app's
* coverage tiles come through the app's server), Google Street View and
* KartaView. Everything from the app's
* own server comes through the MCP server.
*/
const PROVIDER_ORIGINS = Object.freeze([
@@ -37,8 +38,17 @@ const PROVIDER_ORIGINS = Object.freeze([
'https://fonts.googleapis.com',
'https://fonts.gstatic.com',
'https://graph.mapillary.com',
// Google Street View's tiles, controls and contributed photos (the Maps
// JavaScript API itself comes from maps.googleapis.com above).
'https://streetviewpixels-pa.googleapis.com',
'https://maps.gstatic.com',
'https://*.ggpht.com',
// Mapillary photos come from regional hosts, e.g. scontent-man2-1.xx.fbcdn.net.
'https://*.xx.fbcdn.net',
// KartaView's API, its image proxy and its photo storage (storage1…storage13).
'https://api.kartaview.org',
'https://cdn.kartaview.org',
'https://*.openstreetcam.org',
]);
export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
+17
View File
@@ -169,3 +169,20 @@ test('the package exports the panel resource and its runtime together', async ()
assert.equal(panel.PANEL_BASE, '/panel/');
assert.match(resource.text, /panelRuntime|function/);
});
test('the panel lets Street Level reach Google Street View from inside the embed', () => {
const { csp } = createGlobePanelResource({
runtime: panelRuntime,
panelKey: 'test-key',
})._meta.ui;
// The API script and lookups, panorama tiles, control images, contributed photos.
for (const url of [
'https://maps.googleapis.com/maps/api/js?key=k&v=weekly',
'https://streetviewpixels-pa.googleapis.com/v1/tile?panoid=p&x=0&y=0&zoom=1',
'https://maps.gstatic.com/mapfiles/api-3/images/cb_scout5_hdpi.png',
'https://lh3.ggpht.com/p/photo.jpg',
]) {
assert.ok(allows(csp.connectDomains, url), `connect-src allows ${url}`);
assert.ok(allows(csp.resourceDomains, url), `img-src allows ${url}`);
}
});
+28
View File
@@ -106,6 +106,34 @@ test('bike share joins station information with live status inside the area', as
assert.equal(result.data.rows[1].capacity, 10);
});
test('bike share returns localized names without losing stations with malformed names', async () => {
const informationUrl = 'https://gbfs.example/austin/station_information.json';
const information = structuredClone(documents[informationUrl]);
information.version = '3.0';
information.data.stations[0].name = [
{ language: 'hu', text: 'Magyar név' },
{ language: 'en', text: 'Congress & 2nd' },
];
information.data.stations[2].name = [null, { language: 42, text: [] }];
const source = bikeshare([system('austin', 'Austin, TX', 30.27, -97.74)]);
const catalog = composeCatalog({
tools: coreTools,
services: {
bikeshare: {
...source,
getStations: (url) =>
url === informationUrl ? information : source.getStations(url),
},
},
});
const result = await catalog.call('get_bike_share', { area: downtown });
assert.equal(result.data.total, 2);
assert.equal(result.data.rows[0].name, 'Congress & 2nd');
assert.equal(result.data.rows[0].bikes_available, 4);
assert.equal(result.data.rows[1].name, null);
assert.doesNotMatch(result.summary, /unavailable/);
});
test('bike share reports uncovered areas and failed systems honestly', async () => {
const none = composeCatalog({
tools: coreTools,
+16
View File
@@ -141,3 +141,19 @@ test('the manager publishes the declared key id on the row', async () => {
);
assert.equal(layerKeyRequirementTooltip(ungated), '');
});
test('a key-gated layer with another source ready says how to use it', () => {
const text = layerKeyRequirementTooltip({
requiresKeyId: 'mapillary',
stats: {
keyRequired: true,
keyAlternatives: [
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
],
},
});
assert.equal(
text,
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key',
);
});
+9 -2
View File
@@ -3,7 +3,10 @@ import { syncRowList } from './rowList.js';
import { layerFeedState } from '../data/feedState.js';
export { layerFeedState } from '../data/feedState.js';
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
import { keySetupRequirement } from '../keySetupCore.mjs';
import {
keySetupRequirement,
keySetupWithAlternatives,
} from '../keySetupCore.mjs';
import { createWeatherPanel } from './weatherPanel.js';
const FEED_STATE_LABELS = Object.freeze({
nominal: 'ON',
@@ -100,7 +103,11 @@ function panelLabel(layer) {
export function layerKeyRequirementTooltip(layer = {}) {
if (layer?.stats?.keyRequired !== true) return '';
const requiresKeyId = String(layer.requiresKeyId || '').trim();
return requiresKeyId ? keySetupRequirement(requiresKeyId) : '';
// A layer with several sources also names the ones it could use instead.
return keySetupWithAlternatives(
requiresKeyId ? keySetupRequirement(requiresKeyId) : '',
layer.stats.keyAlternatives || [],
);
}
/** Layer row presentation over supplied state and actions; no layer imports. */
+50 -13
View File
@@ -66,10 +66,14 @@ export class StreetLevelControls {
status: byId('sl-status'),
controls: byId('sl-controls'),
providerChips: byId('sl-provider-chips'),
nearestBtn: byId('sl-nearest-btn'),
error: byId('sl-error'),
errorText: byId('sl-error-text'),
panoRow: byId('sl-pano-row'),
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'),
@@ -103,9 +107,11 @@ export class StreetLevelControls {
this.listen(el.providerChips, 'click', (event) => {
const button = event.target?.closest?.('.data-toggle-chip');
if (!button || button.disabled) return;
// Without a key the chips stay focusable for their tooltip; a click
// says the same thing rather than switching on a layer that cannot draw.
if (this._view?.controlsDisabled) {
// A keyless chip shows its tooltip instead of switching; others switch.
const chip = this._view?.providers.find(
(entry) => entry.id === button.dataset.chipId,
);
if (chip?.state === 'error') {
if (button.title) this.actions.showToast?.(button.title);
return;
}
@@ -129,6 +135,15 @@ export class StreetLevelControls {
this.listen(el.sinceRange, 'change', () =>
this._setParams({ sinceDays: sinceDays() }),
);
this.listen(el.nearestBtn, 'click', () => {
const nearest = this._view?.nearest;
if (!nearest?.visible || nearest.disabled) return;
this.layer.openNearest?.(undefined, {
providerIds: nearest.providerIds,
frame: false,
aim: 'screen-centre',
});
});
this.listen(el.followBtn, 'click', () => {
this.layer.setFollow?.(!(this._state?.street?.follow === true));
});
@@ -238,24 +253,23 @@ export class StreetLevelControls {
}
/**
* Lighting a chip turns the layer on; darkening the last lit one turns the
* layer off but keeps the provider on, so the layer comes back with it.
* Lighting a chip turns the layer on and darkens the others (one source at
* a time); darkening the lit one turns the layer off, keeping the provider.
*/
async _toggleProvider(providerId) {
const chip = this._view?.providers.find((entry) => entry.id === providerId);
if (!chip) return;
if (!chip.active) {
this._setParams({ [providerId]: true });
// Every registered provider in one update (those hidden on this map
// too), so the share link records the swap.
const ids =
this.layer.providerIds || this._view.providers.map((entry) => entry.id);
this._setParams(
Object.fromEntries(ids.map((id) => [id, id === providerId])),
);
await this._ensureEnabled();
return;
}
const othersLit = this._view.providers.some(
(entry) => entry.id !== providerId && entry.active,
);
if (othersLit) {
this._setParams({ [providerId]: false });
return;
}
try {
await this.actions.setEnabled?.(false);
} catch (error) {
@@ -466,6 +480,7 @@ export class StreetLevelControls {
this._renderFilters(view);
this._renderViewer(view);
this._renderMeta(view);
this._renderNearest(view);
this._reactToTransitions(view, state);
}
@@ -510,6 +525,14 @@ export class StreetLevelControls {
_renderFilters(view) {
const el = this._elements;
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(
el.panoButtons,
(button) => button.dataset.slPano === view.filter.pano,
@@ -577,6 +600,9 @@ export class StreetLevelControls {
el.renderButtons,
(button) => button.dataset.slRender === viewer.renderMode,
);
// Google's viewer always fills its frame: no FIT/FILL to offer.
const renderGroup = el.renderButtons[0]?.parentElement;
if (renderGroup) setProp(renderGroup, 'hidden', !viewer.renderModes);
if (!viewer.open) return;
setProp(el.imageBy, 'textContent', viewer.captionLeft);
setProp(el.imageWhen, 'textContent', viewer.captionRight);
@@ -592,6 +618,17 @@ export class StreetLevelControls {
setProp(this._elements.coverageMeta, 'textContent', view.meta);
}
_renderNearest(view) {
const button = this._elements.nearestBtn;
if (!button) return;
const { nearest } = view;
setProp(button, 'hidden', !nearest.visible);
if (!nearest.visible) return;
setProp(button, 'textContent', nearest.label);
setProp(button, 'title', nearest.title);
setProp(button, 'disabled', nearest.disabled);
}
/**
* Open the panel on a switch-on or when an image opens. Never persisted:
* the stored collapse state stays the user's own choice.
+149 -4
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import { StreetLevelControls } from './streetLevelControls.js';
import { createStreetLevelLayer } from '../layers/streetLevel/index.js';
@@ -307,12 +308,17 @@ function panelDom() {
add(settings, 'div', { id });
// A missing key gates the filters only; the chips stay outside the gate.
const controls = add(settings, 'fieldset', { id: 'sl-controls' });
const panoRow = add(controls, 'div', { id: 'sl-pano-row' });
for (const pano of ['all', 'pano', 'flat'])
add(controls, 'button', { dataset: { slPano: pano } });
add(controls, 'input', { id: 'sl-since' });
add(controls, 'output', { id: 'sl-since-label' });
add(panoRow, 'button', { dataset: { slPano: pano } });
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' });
add(settings, 'div', { id: 'sl-coverage-meta' });
const metaRow = add(settings, 'div', { classes: ['sl-meta-row'] });
add(metaRow, 'div', { id: 'sl-coverage-meta' });
add(metaRow, 'button', { id: 'sl-nearest-btn' });
// The globe's canvas is focusable too (tabindex=0).
const globe = add(
add(document.body, 'div', { id: 'cesiumContainer' }),
@@ -1136,3 +1142,142 @@ test('a <body> child added while the viewer is expanded goes inert with the rest
assert.equal(observer.watching(), false);
}),
));
test('OPEN STREET VIEW opens the nearest panorama at the view centre without moving the camera', () =>
withDom(async (dom) => {
const state = uiState();
state.providers.push({
...state.providers[0],
id: 'google',
name: 'Google Street View',
label: 'STREET VIEW',
groundClick: true,
count: 0,
});
state.groundClickReady = true;
const { layer } = stubPanel(dom, state);
const asked = [];
layer.openNearest = (point, options) => asked.push([point, options]);
const button = dom.root.querySelector('#sl-nearest-btn');
assert.equal(button.hidden, false);
assert.equal(button.textContent, 'OPEN STREET VIEW');
button.click();
assert.deepEqual(asked, [
[
undefined,
{ providerIds: ['google'], frame: false, aim: 'screen-centre' },
],
]);
// Too high to mean a street: disabled, and a click does nothing.
layer.publish({ ...state, groundClickReady: false });
assert.equal(button.disabled, true);
button.click();
assert.equal(asked.length, 1);
// Off Google 3D the provider is not listed: no button.
layer.publish({ ...state, providers: [state.providers[0]] });
assert.equal(button.hidden, true);
}));
test('a refused Street View key cannot lock Mapillary out; its chip only explains itself', () =>
withDom(async (dom) => {
const state = uiState({ on: false, keyRequired: true });
state.providers.push({
...state.providers[0],
id: 'google',
name: 'Google Street View',
label: 'STREET VIEW',
on: true,
keyRequired: true,
keyRejected: true,
error: 'Google refused GOOGLE_MAPS_API_KEY for Street View',
});
state.providers[0].keyRequired = false;
const { calls, layer } = stubPanel(dom, state);
const chips = () => dom.root.querySelector('#sl-provider-chips').children;
chips()[0].click(); // Mapillary: configured, dark
await settle();
// One source at a time: lighting Mapillary darkens Street View too.
assert.deepEqual(calls.setParams, [
[{ mapillary: true, google: false }, { origin: 'user' }],
]);
// Now dark, the refused chip says why instead of lighting.
state.providers[0].on = true;
state.providers[1].on = false;
state.keyRequired = false;
layer.publish({ ...state });
chips()[1].click();
await settle();
assert.equal(calls.setParams.length, 1);
assert.deepEqual(calls.toasts, [
dom.root.querySelector('#sl-provider-chips').children[1].title,
]);
}));
test('a swap names every registered provider, also those hidden on this map', () =>
withDom(async (dom) => {
const state = uiState({ on: false });
const { calls, layer, chip } = stubPanel(dom, state);
// Off Google 3D the Street View chip is not listed, but stays registered.
layer.providerIds = ['mapillary', 'google'];
chip().click();
await settle();
assert.deepEqual(calls.setParams, [
[{ mapillary: true, google: false }, { origin: 'user' }],
]);
}));
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();
const { layer } = stubPanel(dom, state);
const shown = () => ({
pano: !dom.root.querySelector('#sl-pano-row').hidden,
since: !dom.root.querySelector('#sl-since-row').hidden,
controls: !dom.root.querySelector('#sl-controls').hidden,
legend: !dom.root.querySelector('#sl-legend').hidden,
});
assert.deepEqual(shown(), {
pano: true,
since: true,
controls: true,
legend: true,
});
layer.publish({
...state,
filterControls: { pano: false, since: false },
legend: [],
});
assert.deepEqual(shown(), {
pano: false,
since: false,
controls: false,
legend: false,
});
layer.publish(state);
assert.deepEqual(shown(), {
pano: true,
since: true,
controls: true,
legend: true,
});
}));
+79 -8
View File
@@ -1,4 +1,7 @@
import { keySetupRequirement } from '../keySetupCore.mjs';
import {
keySetupRequirement,
keySetupWithAlternatives,
} from '../keySetupCore.mjs';
// Street Level UI state to panel strings and flags. Pure, so it is testable.
@@ -44,22 +47,43 @@ function presentSince(days, now) {
};
}
function formatDate(ms) {
/** YYYY-MM-DD, or YYYY-MM for a provider that dates images by month. */
function formatDate(ms, precision = 'day') {
if (!Number.isFinite(ms)) return '';
try {
return new Date(ms).toISOString().slice(0, 10);
return new Date(ms).toISOString().slice(0, precision === 'month' ? 7 : 10);
} catch {
return '';
}
}
/**
* What the key-gated layer needs: each switched-on provider's key, and the
* switched-off ones that would draw with a key already set.
*/
function keyGuidance(state) {
const needs = (state.providers || [])
.filter((p) => p.on && p.keyRequired && p.requiresKeyId)
.map((p) => keySetupRequirement(p.requiresKeyId))
.filter(Boolean);
return keySetupWithAlternatives(
[...new Set(needs)].join('; '),
state.keyAlternatives || [],
);
}
function presentStatus(state) {
const pressed = state.enabled === true;
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
if (state.keyRejected)
return { text: 'KEY REJECTED', tone: 'warn', pressed, title };
if (state.keyRequired)
return { text: 'KEY REQUIRED', tone: 'warn', pressed, title };
return {
text: 'KEY REQUIRED',
tone: 'warn',
pressed,
title: keyGuidance(state) || title,
};
if (state.coverage.loading)
return { text: 'LOADING', tone: 'busy', pressed, title };
return pressed
@@ -104,12 +128,17 @@ function presentViewer(state) {
if (street.isPano) right.push('360°');
if (Number.isFinite(street.bearing))
right.push(`${Math.round(street.bearing)}°`);
if (street.capturedAt) right.push(formatDate(street.capturedAt));
if (street.capturedAt)
right.push(formatDate(street.capturedAt, street.capturedAtPrecision));
const left = [];
if (street.title) left.push(street.title);
if (street.creator) left.push(`Image by ${street.creator}`);
return {
open: street.open === true,
loading: street.loading === true && !street.imageId,
renderMode: street.renderMode === 'fill' ? 'fill' : 'letterbox',
captionLeft: street.creator ? `Image by ${street.creator}` : '',
renderModes: street.renderModes !== false,
captionLeft: left.join(' · '),
captionRight: right.join(' · '),
link: street.externalUrl || null,
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
@@ -124,16 +153,52 @@ function presentViewer(state) {
};
}
/** OPEN STREET VIEW: the keyboard path to providers with no coverage to click. */
function presentNearest(state) {
const targets = (state.providers || []).filter(
(provider) =>
provider.on && provider.groundClick && provider.keyRequired !== true,
);
if (state.enabled !== true || !targets.length)
return {
visible: false,
label: '',
title: '',
disabled: true,
providerIds: [],
};
const names = targets.map((provider) => provider.name).join(' or ');
const ready = state.groundClickReady === true;
return {
visible: true,
label: `OPEN ${targets.map((provider) => provider.label).join(' / ')}`,
title: ready
? `Open ${names} at the centre of the view`
: `Zoom in to a street to open ${names} here`,
disabled: !ready || state.street.loading === true,
providerIds: targets.map((provider) => provider.id),
};
}
function presentMeta(state) {
if (state.providers.length === 0) return state.coverage.hint || '';
if (!state.enabled) return 'Switch a provider on to draw its coverage.';
// Key-gated with another source ready: say how to use it.
if (state.keyRequired && state.keyAlternatives?.length)
return keyGuidance(state);
if (state.sequence.selectedId)
return state.sequence.loading
? 'Loading this sequence…'
: `${state.sequence.images.toLocaleString()} images in this sequence · Esc clears`;
const hint = state.coverage.hint || '';
if (state.coverage.count > 0)
return `${state.coverage.count.toLocaleString()} sequences in view · click a line for its photos`;
return state.coverage.hint || '';
return [
`${state.coverage.count.toLocaleString()} ${state.coverage.label?.noun || 'sequences'} in view · ${state.coverage.label?.action || 'click a line for its photos'}`,
hint,
]
.filter(Boolean)
.join(' · ');
return hint;
}
/** @param {{now?: number}} [options] Clock for the SINCE readout. */
@@ -154,10 +219,16 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
? null
: state.street.error || state.coverage.error || null,
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
filterControls: {
pano: state.filterControls?.pano !== false,
since: state.filterControls?.since !== false,
},
since: presentSince(filter.sinceDays, now),
sinceNote: state.sinceNote || null,
legend: state.legend || [],
viewer: presentViewer(state),
meta: presentMeta(state),
nearest: presentNearest(state),
wantsOpen: state.street.open === true,
};
}
+134
View File
@@ -405,3 +405,137 @@ test('FOLLOW is disabled off Google 3D and says where to switch', () => {
);
assert.equal(closed.viewer.follow.disabled, true, 'nothing to follow');
});
test('a Street View caption names the place, dates it by month, and offers no FIT/FILL', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
street: {
open: true,
providerId: 'google',
providerName: 'Google Street View',
providerLabel: 'STREET VIEW',
imageId: 'pano-1',
title: '1014 10th St',
creator: 'Google',
capturedAt: Date.UTC(2023, 0, 1),
capturedAtPrecision: 'month',
bearing: 0,
isPano: true,
renderModes: false,
externalUrl:
'https://www.google.com/maps/@?api=1&map_action=pano&pano=pano-1',
},
}),
);
assert.equal(view.viewer.captionLeft, '1014 10th St · Image by Google');
assert.equal(view.viewer.captionRight, '360° · 0° · 2023-01');
assert.equal(view.viewer.linkLabel, 'STREET VIEW ↗');
assert.equal(view.viewer.renderModes, false);
});
test('the meta line keeps the sequence count and adds the other providers’ hints', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
coverage: {
loading: false,
count: 2514,
hint: 'click a street to open Street View',
error: null,
},
}),
);
assert.equal(
view.meta,
`${(2514).toLocaleString()} sequences in view · click a line for its photos · click a street to open Street View`,
);
});
test('OPEN STREET VIEW shows while Street View is on, and waits for street zoom', () => {
const streetView = provider({
id: 'google',
name: 'Google Street View',
label: 'STREET VIEW',
groundClick: true,
requiresKeyId: 'google-maps',
});
const view = (overrides) =>
presentStreetLevelPanel(
snapshot({
enabled: true,
providers: [provider(), streetView],
...overrides,
}),
).nearest;
const ready = view({ groundClickReady: true });
assert.deepEqual(ready, {
visible: true,
label: 'OPEN STREET VIEW',
title: 'Open Google Street View at the centre of the view',
disabled: false,
providerIds: ['google'],
});
const high = view({ groundClickReady: false });
assert.equal(high.disabled, true);
assert.equal(
high.title,
'Zoom in to a street to open Google Street View here',
);
// Hidden with Street View off, without its key, or with the layer off
// (off Google 3D the provider is not listed at all).
assert.equal(
view({ providers: [provider(), { ...streetView, on: false }] }).visible,
false,
);
assert.equal(
view({ providers: [provider(), { ...streetView, keyRequired: true }] })
.visible,
false,
);
assert.equal(view({ providers: [provider()] }).visible, false);
assert.equal(
presentStreetLevelPanel(snapshot({ providers: [provider(), streetView] }))
.nearest.visible,
false,
);
});
test('KEY REQUIRED points at Street View when it would draw with the Google key', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
keyRequired: true,
providers: [
provider({ keyRequired: true, configured: false }),
provider({
id: 'google',
name: 'Google Street View',
label: 'STREET VIEW',
on: false,
configured: true,
requiresKeyId: 'google-maps',
}),
],
keyAlternatives: [
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
],
}),
);
const guidance =
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key';
assert.equal(view.status.text, 'KEY REQUIRED');
assert.equal(view.status.title, guidance);
assert.equal(view.meta, guidance);
// Off Google 3D Street View is not listed: plain KEY REQUIRED.
const plain = presentStreetLevelPanel(
snapshot({
enabled: true,
keyRequired: true,
providers: [provider({ keyRequired: true, configured: false })],
keyAlternatives: [],
}),
);
assert.equal(plain.status.text, 'KEY REQUIRED');
assert.notEqual(plain.meta, guidance);
});
+143 -1
View File
@@ -4,6 +4,9 @@
#street-level-panel,
.sl-viewer-wrap {
--sl-green: #05cb63;
/* KartaView's purple for lines; a lighter one where it is text (WCAG AA). */
--sl-kartaview: #635bff;
--sl-kartaview-text: #8f8aff;
--sl-text: 0.62rem;
--sl-text-small: 0.6rem;
}
@@ -174,6 +177,11 @@ button.sl-status:focus-visible {
);
}
/* #635bff text on its own tint is 3.7:1 at this size; the lighter one is ~6:1. */
.sl-chips .data-toggle-chip.active[data-chip-id='kartaview'] {
color: var(--sl-kartaview-text);
}
.sl-chips .data-toggle-chip.active::before {
opacity: 1;
}
@@ -183,6 +191,28 @@ button.sl-status:focus-visible {
border-color: rgba(255, 179, 0, 0.55);
}
/* The coverage hint and OPEN STREET VIEW share a line; the hint wraps. */
.sl-meta-row {
display: flex;
align-items: center;
gap: 8px;
}
.sl-meta-row .sl-meta {
flex: 1 1 auto;
min-width: 0;
}
/* Scoped to outrank .scene-btn's flex: 1, which squeezed the button to its
min-width beside a long coverage line and clipped its one-line label. */
.sl-meta-row .sl-nearest-btn {
flex: 0 0 auto;
}
.sl-nearest-btn[hidden] {
display: none;
}
/* ── Errors ──────────────────────────────────────────────────────────────── */
.sl-error {
font-family: var(--font-sans);
@@ -207,6 +237,14 @@ button.sl-status:focus-visible {
border-radius: 999px;
}
.sl-seg[hidden],
.sl-controls[hidden],
.sl-filters[hidden],
.sl-since[hidden],
.sl-legend[hidden] {
display: none;
}
.sl-seg-btn {
font-family: var(--font-mono);
font-size: var(--sl-text-small);
@@ -261,6 +299,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);
@@ -480,7 +529,9 @@ button.sl-status:focus-visible {
/* A dragged window keeps its natural height, so bound it to the viewport:
the rail's max-height: 100% does not bind an auto-height parent.
Id-qualified to outrank that rule. */
#right-context-rail > #street-level-panel.panel-floating > .street-level-panel-inner {
#right-context-rail
> #street-level-panel.panel-floating
> .street-level-panel-inner {
max-height: calc(100vh - 12px);
overflow-y: auto;
}
@@ -581,6 +632,97 @@ button.sl-status:focus-visible {
font-size: 1.3rem;
}
/* ── KartaView photo viewer ──────────────────────────────────────────────── */
.sl-kv-viewer {
position: absolute;
inset: 0;
background: #000;
outline: none;
overflow: hidden;
}
.sl-kv-viewer:focus-visible {
outline: none;
}
/* The photo and canvas fill the viewer and would cover a ring drawn on it:
draw the ring on a layer above them (clicks pass through). */
.sl-kv-viewer:focus-visible::after {
content: '';
position: absolute;
inset: 0;
z-index: 3;
border: 2px solid var(--sl-kartaview-text);
pointer-events: none;
}
.sl-kv-photo,
.sl-kv-pano {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
user-select: none;
}
.sl-kv-photo {
object-fit: contain;
}
.sl-kv-pano {
cursor: grab;
touch-action: none;
}
.sl-kv-pano.sl-kv-dragging {
cursor: grabbing;
}
.sl-kv-photo[hidden],
.sl-kv-pano[hidden] {
display: none;
}
.sl-kv-step {
position: absolute;
top: 50%;
z-index: 2;
width: 34px;
height: 44px;
margin-top: -22px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
background: rgba(10, 10, 15, 0.62);
color: #fff;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
}
.sl-kv-step-prev {
left: 6px;
}
.sl-kv-step-next {
right: 6px;
}
.sl-kv-step:hover:not(:disabled),
.sl-kv-step:focus-visible {
background: color-mix(in srgb, var(--sl-kartaview) 85%, transparent);
outline: none;
}
.sl-kv-step:disabled {
opacity: 0.3;
cursor: default;
}
/* Nothing to step through until a photo shows. */
.sl-kv-viewer:not([data-kind='flat']):not([data-kind='pano']) .sl-kv-step {
display: none;
}
/* ── Short viewports ─────────────────────────────────────────────────────── */
@media (max-height: 640px) {
.street-level-panel-inner {
+9 -4
View File
@@ -118,7 +118,7 @@
<!-- A missing key gates the filters only: the chips above stay
focusable so their tooltip can say how to add the key. -->
<fieldset id="sl-controls" class="sl-controls">
<div class="sl-filters" role="group" aria-label="Imagery filters">
<div id="sl-pano-row" class="sl-filters" role="group" aria-label="Imagery filters">
<span class="cctv-summary-label">IMAGERY</span>
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter">
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true">ALL</button>
@@ -126,14 +126,19 @@
<button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1">FLAT</button>
</div>
</div>
<div class="sl-since">
<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>
<div id="sl-coverage-meta" class="sl-meta"></div>
<!-- The hint says how to open imagery; the button does it without a pointer. -->
<div class="sl-meta-row">
<div id="sl-coverage-meta" class="sl-meta"></div>
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" hidden>OPEN STREET VIEW</button>
</div>
</div>
</div>
</div>