refactor(street-level): one provider, no orbit overview, no prewarm, simpler tile choice

The layer now owns its Mapillary parts directly: the provider registry,
pick router, multi-provider UI summary, params codec helper and provider
context are gone. createStreetLevelLayer({ source }) builds coverage,
sequences, the nearest-image lookup and the MapillaryJS viewer itself; a
single providerOn switch keeps the share link's `mapillary` option (off
draws nothing), and getParams/setParams keep { mapillary, pano, sinceDays }.

No z0-5 overview points: above 60 km nothing is drawn and the panel says to
zoom in; the proxy now serves only coverage z11-14. Tiles come from the
visible ground within range of the camera, ranked from the ground under it
(no line-of-sight ranking or near-range padding). MapillaryJS loads on the
first photo; viewer prewarm is removed. Key status, a rejected key and a
rate limit share one `blocked` state on coverage.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-07 22:19:24 +04:00
1 parent 0f41fb04cd
commit 37a96072fa
51 files changed
+1240 -2659

No files matched your search

+2 -3
View File
@@ -1,14 +1,13 @@
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/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 render from '../../renderGovernor.js';
/** A new provider registers here; its chip, credit and share bit follow. */
/** The Street Level layer over the application's Mapillary source. */
export function createApplicationStreetLevel({ sources }) {
return createStreetLevelLayer({
providers: [createMapillaryProvider({ source: sources.mapillary })],
source: sources.mapillary,
services: { sprites, picking, input, render },
});
}
+12 -18
View File
@@ -1,38 +1,32 @@
import * as Cesium from 'cesium';
/**
* One static Cesium credit per active provider: CC BY-SA imagery needs
* A static Cesium credit shown while the layer draws: CC BY-SA imagery needs
* visible attribution that goes away with the imagery.
*/
export function createCredits() {
/** @type {Map<string, object>} provider id → Cesium.Credit */
const shown = new Map();
export function createCredit(html) {
let shown = null;
function show(viewer, def) {
if (shown.has(def.id) || !viewer?.creditDisplay) return;
function show(viewer) {
if (shown || !viewer?.creditDisplay) return;
try {
const credit = new Cesium.Credit(def.credit.html, true);
const credit = new Cesium.Credit(html, true);
viewer.creditDisplay.addStaticCredit(credit);
shown.set(def.id, credit);
shown = credit;
} catch {
/* credit display unavailable */
}
}
function hide(viewer, def) {
const credit = shown.get(def.id);
if (!credit) return;
function hide(viewer) {
if (!shown) return;
try {
viewer?.creditDisplay?.removeStaticCredit?.(credit);
viewer?.creditDisplay?.removeStaticCredit?.(shown);
} catch {
/* credit display already torn down */
}
shown.delete(def.id);
shown = null;
}
function hideAll(viewer) {
for (const id of [...shown.keys()]) hide(viewer, { id });
}
return { show, hide, hideAll };
return { show, hide };
}
+1 -1
View File
@@ -27,7 +27,7 @@ export function sameFilter(a, b) {
/**
* Stored filter (relative days, stable in share links) to the absolute form
* providers compare capture times against.
* coverage, cones and lookups compare capture times against.
* @returns {{pano: string, sinceMs: number|null}}
*/
export function resolveFilter(filter, now = Date.now()) {
+184 -228
View File
@@ -1,51 +1,70 @@
import { createState } from './state.js';
import { createState, isActive } from './state.js';
import { createMarker } from './marker.js';
import { createCameraFraming } from './cameraFraming.js';
import { createCredits } from './credits.js';
import { createPickRouter } from './pickRouter.js';
import { createCredit } from './credits.js';
import { createSelection } from './selection.js';
import { createViewerHost } from './viewerHost.js';
import { 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 { viewCentre, whenIdle } from './view.js';
import { viewCentre } from './view.js';
import { COLORS, STREET_LEVEL_LAYER_ID } from './policy.js';
import { createCoverage } from './providers/mapillary/coverage.js';
import { createSequences } from './providers/mapillary/sequences.js';
import { createMapillaryViewer } from './providers/mapillary/viewer.js';
import { nearestImageId } from './providers/mapillary/nearest.js';
import {
COLORS as MAPILLARY_COLORS,
MAPILLARY_CREDIT_HTML,
MAPILLARY_KEY_ID,
MAPILLARY_SOURCE_METHODS,
NEAREST_RADIUS_M,
POSITION_PICK_ID,
STREET_LEVEL_LAYER_ID,
} from './policy.js';
} from './providers/mapillary/policy.js';
export { STREET_LEVEL_LAYER_ID } from './policy.js';
/** Coverage as the panel sees it while the `mapillary` switch is off. */
const COVERAGE_OFF = Object.freeze({
count: 0,
loading: false,
hint: '',
error: null,
keyRequired: false,
keyRejected: false,
});
/**
* The Street Level layer over imagery providers (contract in registry.js).
* The core owns what they share; providers own coverage, sequences, viewer.
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
* The Street Level layer: Mapillary coverage lines at street zoom, a selected
* sequence's image cones, and the photo viewer in the panel.
* @param {{source: object, services?: object, photoViewer?: object}} options
* `source` is providers/mapillary/source.js or a stand-in; `photoViewer`
* stands in for the MapillaryJS adapter (providers/mapillary/viewer.js).
*/
export function createStreetLevelLayer({
providers: definitions,
source,
services = {},
photoViewer = null,
}) {
const definitionsFrozen = validateProviders(definitions);
if (
!MAPILLARY_SOURCE_METHODS.every(
(method) => typeof source?.[method] === 'function',
)
)
throw new TypeError('A Mapillary source is required');
const state = createState({ services });
const parts = {};
const context = { state, parts };
parts.credits = createCredits();
const credit = createCredit(MAPILLARY_CREDIT_HTML);
parts.marker = createMarker(context);
parts.framing = createCameraFraming(context);
parts.router = createPickRouter(() => state.providers.values(), {
positionId: POSITION_PICK_ID,
parts.coverage = createCoverage({ state, source });
parts.sequences = createSequences({ state, source, parts });
parts.viewerHost = createViewerHost({
state,
parts,
adapter:
photoViewer || createMapillaryViewer({ source, render: services.render }),
});
parts.viewerHost = createViewerHost(context);
parts.hasSelectedSequence = () =>
[...state.providers.values()].some(
(entry) => entry.instance.sequenceStats?.()?.selectedId,
);
parts.clearSequences = () => {
for (const entry of state.providers.values())
entry.instance.clearSequence?.();
};
parts.openImage = openImage;
parts.reportSequenceError = reportSequenceError;
parts.selection = createSelection(context);
let notifyQueued = false;
@@ -66,70 +85,64 @@ export function createStreetLevelLayer({
}
state.notify = notify;
function providerContext(entry) {
return Object.freeze({
services: state.services,
getFilter: () => resolveFilter(state.filter),
isActive: () => state.enabled && entry.on,
notify,
actions: {
openImage: (imageId) => openImage(entry.def.id, imageId),
/**
* Show a provider error, or withdraw it (null). A withdrawal clears
* only this provider's own error, never a newer one from elsewhere.
*/
reportError: (message) => {
if (message) {
state.street.error = entry.reportedError = message;
} else {
if (!entry.reportedError) return;
if (state.street.error === entry.reportedError)
state.street.error = null;
entry.reportedError = null;
}
notify();
},
},
});
}
/** The error a sequence lookup last showed, until it withdraws it. */
let sequenceError = null;
for (const def of definitionsFrozen) {
const entry = {
def,
instance: null,
on: true,
status: null,
/** The error this provider last reported, until it withdraws it. */
reportedError: null,
};
entry.instance = def.create(providerContext(entry));
state.providers.set(def.id, entry);
}
const activeEntries = () =>
[...state.providers.values()].filter((entry) => entry.on);
async function refreshStatus(entry) {
try {
entry.status = await entry.instance.status();
} catch {
entry.status = null;
/**
* Show a sequence error, or withdraw it (null). A withdrawal clears only
* that error, never a newer one from the viewer.
*/
function reportSequenceError(message) {
if (message) {
state.street.error = sequenceError = message;
} else {
if (!sequenceError) return;
if (state.street.error === sequenceError) state.street.error = null;
sequenceError = null;
}
notify();
}
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);
/** Coverage waits for the key status: no tile requests without a key. */
async function checkKey() {
let configured;
try {
configured = (await source.getStatus())?.configured === true;
} catch {
configured = source.hasToken?.() === true;
}
parts.coverage.setKeyStatus(configured);
}
function deactivate(entry) {
parts.viewerHost.unmount(entry.def.id);
entry.instance.deactivate();
parts.credits.hide(state.viewer, entry.def);
function startDrawing() {
const viewer = state.viewer;
if (!viewer) return;
parts.sequences.setVisible(true);
parts.coverage.attach(viewer);
credit.show(viewer);
}
/** Close the photo, clear lines and cones, forget refusals. */
function stopDrawing() {
abortNearest();
parts.viewerHost.unmount();
parts.coverage.detach();
parts.coverage.clear();
parts.coverage.unblock();
parts.sequences.clearSelection();
parts.sequences.setVisible(false);
credit.hide(state.viewer);
}
/** The share link's `mapillary` switch: off, the layer draws nothing. */
function setProviderOn(on) {
if (state.providerOn === on) return;
state.providerOn = on;
if (state.enabled) {
if (on) startDrawing();
else stopDrawing();
}
notify();
}
/** The nearest-image lookup in flight; any newer user action aborts it. */
@@ -140,97 +153,62 @@ export function createStreetLevelLayer({
nearestLookup = null;
}
function openImage(providerId, imageId) {
function openImage(imageId) {
abortNearest();
return parts.viewerHost.open(providerId, imageId);
}
function setProviderEnabled(providerId, on) {
const entry = state.providers.get(providerId);
if (!entry) return false;
const next = on !== false;
if (entry.on === next) return true;
entry.on = next;
if (state.enabled) {
if (next) activate(entry);
else deactivate(entry);
}
notify();
return true;
return parts.viewerHost.open(imageId);
}
function setCoverageFilter(next) {
const filter = normalizeFilter(next, state.filter);
if (sameFilter(filter, state.filter)) return;
state.filter = filter;
const resolved = resolveFilter(filter);
for (const entry of state.providers.values())
entry.instance.setFilter(resolved);
parts.coverage.rebuild();
parts.sequences.rerender();
notify();
}
function providerSnapshots() {
return [...state.providers.values()].map((entry) => {
const stats = entry.instance.coverageStats();
return {
id: entry.def.id,
name: entry.def.name,
label: entry.def.label,
on: entry.on,
configured: entry.status ? entry.status.configured === true : null,
// A rejected key gates the provider exactly like a missing one.
keyRequired: stats.keyRequired === true || stats.keyRejected === true,
keyRejected: stats.keyRejected === true,
requiresKeyId: entry.def.requiresKeyId || null,
loading: stats.loading === true,
count: stats.count || 0,
hint: stats.hint || '',
error: stats.error || null,
color: entry.def.colors.coverage,
};
});
}
function sequenceSnapshot() {
// The provider showing the open image answers first.
const owner = state.providers.get(state.street.providerId);
const candidates = owner
? [owner, ...[...state.providers.values()].filter((e) => e !== owner)]
: [...state.providers.values()];
for (const entry of candidates) {
const stats = entry.instance.sequenceStats?.();
if (stats?.selectedId || stats?.loading)
return {
providerId: entry.def.id,
selectedId: stats.selectedId || null,
images: stats.images || 0,
loading: stats.loading === true,
};
}
return { providerId: null, selectedId: null, images: 0, loading: false };
function coverageStats() {
return state.providerOn ? parts.coverage.stats() : COVERAGE_OFF;
}
function getUIState() {
const { host, ...street } = state.street;
return composeUIState({
const { keyRequired, keyRejected, ...coverage } = coverageStats();
return {
enabled: state.enabled,
filter: state.filter,
providers: providerSnapshots(),
providerOn: state.providerOn,
// A rejected key gates the layer exactly like a missing one.
keyRequired,
keyRejected,
filter: { ...state.filter },
coverage,
legend: state.providerOn
? [
{
key: 'mapillary',
label: 'Mapillary',
color: MAPILLARY_COLORS.coverage,
},
{ key: 'selected', label: 'Selected', color: COLORS.selected },
]
: [],
sequence: {
selectedId: state.sequence.selectedId,
images: state.sequence.images.length,
loading: state.sequence.loading,
},
street,
sequence: sequenceSnapshot(),
});
};
}
const layer = {
id: STREET_LEVEL_LAYER_ID,
name: 'Street Level',
icon: '📷',
source: definitionsFrozen.map((def) => def.name).join(' · '),
source: 'Mapillary',
updateInterval: 0,
statsRefreshInterval: 1000,
requiresKeyId: requiresKeyIdFor(definitionsFrozen),
/** Registered providers, in chip order. */
providerIds: definitionsFrozen.map((def) => def.id),
requiresKeyId: MAPILLARY_KEY_ID,
init(viewer) {
if (state.initialized)
@@ -239,10 +217,9 @@ export function createStreetLevelLayer({
state.initialized = true;
parts.marker.ensure(viewer);
parts.marker.setVisible(false);
for (const entry of state.providers.values()) {
entry.instance.init(viewer);
refreshStatus(entry);
}
parts.sequences.ensureCollections(viewer);
parts.sequences.setVisible(false);
checkKey();
console.log('[Data:StreetLevel] Initialized');
},
@@ -251,16 +228,13 @@ export function createStreetLevelLayer({
state.viewer = viewer;
parts.marker.setVisible(true);
parts.selection.install(viewer);
for (const entry of activeEntries()) activate(entry);
if (state.providerOn) startDrawing();
notify();
},
disable() {
state.enabled = false;
abortNearest();
parts.viewerHost.unmount();
for (const entry of state.providers.values()) entry.instance.deactivate();
parts.credits.hideAll(state.viewer);
stopDrawing();
parts.selection.uninstall();
parts.marker.setVisible(false);
notify();
@@ -272,8 +246,7 @@ export function createStreetLevelLayer({
destroy(viewer = state.viewer) {
layer.disable();
for (const entry of state.providers.values())
entry.instance.destroy(viewer);
parts.sequences.destroy(viewer);
parts.marker.destroy(viewer);
parts.framing.attachNavigation(null);
state.listeners.clear();
@@ -283,10 +256,9 @@ export function createStreetLevelLayer({
},
getStats() {
// The lifecycle polls this every second: summarise, don't snapshot.
const coverage = summarizeCoverage(providerSnapshots());
let loadingLabel = '';
const coverage = coverageStats();
const keyLabel = coverage.keyRejected ? 'KEY REJECTED' : 'KEY REQUIRED';
let loadingLabel = '';
if (coverage.keyRequired) loadingLabel = keyLabel;
else if (coverage.loading) loadingLabel = 'loading coverage...';
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
@@ -294,11 +266,11 @@ export function createStreetLevelLayer({
count: coverage.count,
loading: coverage.loading,
keyRequired: coverage.keyRequired,
error: coverage.keyRequired
? coverage.keyRejected
? coverage.error || keyLabel
: keyLabel
: coverage.error,
// A rejected key's message names the fix; a missing one says so.
error:
coverage.keyRequired && !coverage.keyRejected
? keyLabel
: coverage.error,
loadingLabel,
};
},
@@ -312,21 +284,26 @@ export function createStreetLevelLayer({
parts.framing.attachNavigation(navigation);
},
/** Share-link and stored state: provider switches plus the filter. */
/**
* Share-link and stored state, in the codec's keys (src/data/layerState.js):
* the `mapillary` switch plus the filter.
*/
getParams() {
return encodeParams({
providers: [...state.providers].map(([id, entry]) => [id, entry.on]),
filter: state.filter,
});
return {
mapillary: state.providerOn,
pano: state.filter.pano,
sinceDays: state.filter.sinceDays,
};
},
/** Unknown keys and malformed values are ignored, so any link applies. */
setParams(params = {}) {
const decoded = decodeParams(params, {
providerIds: state.providers.keys(),
filter: state.filter,
});
for (const [id, on] of decoded.providers) setProviderEnabled(id, on);
setCoverageFilter(decoded.filter);
const input = params && typeof params === 'object' ? params : {};
if (typeof input.mapillary === 'boolean') setProviderOn(input.mapillary);
const next = {};
if ('pano' in input) next.pano = input.pano;
if ('sinceDays' in input) next.sinceDays = input.sinceDays;
setCoverageFilter(next);
return true;
},
@@ -337,80 +314,59 @@ export function createStreetLevelLayer({
return () => state.listeners.delete(listener);
},
getUIState,
/** The DOM element provider viewers render into. */
/** The DOM element the photo viewer renders into. */
attachViewerHost(element) {
parts.viewerHost.attach(element);
if (element && state.enabled)
whenIdle(() => parts.viewerHost.prewarm(activeEntries()), 1500);
},
setProviderEnabled,
/** Imagery filter for coverage, cones and nearest-image lookups. */
setCoverageFilter,
openImage,
/** Open the nearest image any active provider has around a point. */
/** Open the nearest image around a point (default: the view centre). */
async openNearest(point) {
if (!isActive(state)) return false;
const view = point || viewCentre(state.viewer);
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
return false;
abortNearest();
const lookup = new AbortController();
nearestLookup = lookup;
const { signal } = lookup;
state.street.loading = true;
state.street.error = null;
notify();
let lastError = null;
for (const entry of activeEntries()) {
let imageId = null;
try {
imageId = await entry.instance.nearestImage(
{ lat: view.lat, lon: view.lon },
{ signal },
);
} catch (error) {
// Overtaken: whatever replaced it owns the panel, errors included.
if (signal.aborted) return false;
// One provider failing (no key, offline) must not hide the others.
lastError = error;
continue;
}
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;
nearestLookup = null;
return openImage(entry.def.id, imageId);
let imageId = null;
let failure = null;
try {
imageId = await nearestImageId(
source,
{ lat: view.lat, lon: view.lon },
resolveFilter(state.filter),
{ signal: lookup.signal },
);
} catch (error) {
failure = error;
}
if (nearestLookup === lookup) nearestLookup = null;
// Switched off while it looked: nothing to open, nothing to report.
if (state.enabled && activeEntries().length)
state.street.error =
lastError?.message ||
`No street-level imagery within ${NEAREST_RADIUS_M} m of the view centre`;
// Overtaken, closed or switched off: whatever did that owns the panel.
if (lookup.signal.aborted) return false;
nearestLookup = null;
if (imageId) return openImage(imageId);
state.street.error =
failure?.message ||
`No street-level imagery within ${NEAREST_RADIUS_M} m of the view centre`;
state.street.loading = false;
notify();
return false;
},
/** Close the image and deselect it everywhere on the map. */
/** Close the image and deselect its sequence on the map. */
closeViewer() {
abortNearest();
parts.viewerHost.close();
parts.clearSequences();
parts.sequences.clearSelection();
},
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
resizeViewer: () => parts.viewerHost.resize(),
selectSequence(
sequenceId,
providerId = state.providers.keys().next().value,
) {
return state.providers
.get(providerId)
?.instance.selectSequence?.(sequenceId);
},
clearSequence: () => parts.clearSequences(),
refreshCoverage() {
for (const entry of activeEntries()) entry.instance.refreshCoverage();
},
selectSequence: (sequenceId) => parts.sequences.select(sequenceId),
clearSequence: () => parts.sequences.clearSelection(),
refreshCoverage: () => parts.coverage.refresh(),
};
return layer;
}
+226 -221
View File
@@ -3,14 +3,19 @@ import test from 'node:test';
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 { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs';
import {
fakeMapillarySource,
fakePhotoViewer,
rayCamera,
} from '../../testSupport/streetLevelFakes.mjs';
/**
* A stand-in Cesium viewer the layer can be enabled on: a canvas for the
* click handler, camera events, a primitive list and a credit display.
* click handler, camera events, a primitive list and a credit display. With
* `view`, the camera looks straight down on Sacramento from 900 m, so
* coverage asks for tiles.
*/
function fakeViewer() {
function fakeViewer({ view = false } = {}) {
const credits = [];
/** Cesium's flight bookkeeping: a new flight cancels the current one. */
let flight = null;
@@ -26,19 +31,20 @@ function fakeViewer() {
// Keep Cesium's handler on the canvas; there is no real document here.
disableRootEvents: true,
onwheel: null,
...(view ? { clientWidth: 100, clientHeight: 100 } : {}),
});
return {
credits,
flights,
scene: {
canvas,
primitives: { add: (p) => p, remove() {} },
// Enough of a scene for the position marker to clamp to the ground.
frameState: { mode: Cesium.SceneMode.SCENE3D },
updateHeight: () => () => {},
getHeight: () => undefined,
},
camera: {
const camera = Object.assign(
view
? rayCamera({
lon: -121.4944,
lat: 38.5816,
altitude: 900,
pitch: -90,
width: 100,
height: 100,
})
: {},
{
changed: new Cesium.Event(),
moveStart: new Cesium.Event(),
moveEnd: new Cesium.Event(),
@@ -51,6 +57,20 @@ function fakeViewer() {
if (stopFlight()) flights.cancelled++;
},
},
);
return {
credits,
flights,
scene: {
canvas,
primitives: { add: (p) => p, remove() {} },
groundPrimitives: { add: (p) => p, remove() {} },
// Enough of a scene for the position marker to clamp to the ground.
frameState: { mode: Cesium.SceneMode.SCENE3D },
updateHeight: () => () => {},
getHeight: () => undefined,
},
camera,
creditDisplay: {
addStaticCredit: (credit) => credits.push(credit),
removeStaticCredit: (credit) =>
@@ -59,24 +79,41 @@ function fakeViewer() {
};
}
/** A layer over `providers`, initialised and enabled on a stand-in viewer. */
async function enabledLayer(t, providers = [fakeProvider()]) {
/**
* A layer over `source` and `photoViewer`, initialised on a stand-in viewer
* and (unless `enable` is false) enabled.
*/
async function startLayer(
t,
{
source = fakeMapillarySource(),
photoViewer = fakePhotoViewer(),
view = false,
enable = true,
} = {},
) {
const saved = globalThis.document;
// A pose moves the position marker, whose glyph is drawn on a canvas.
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const layer = createStreetLevelLayer({ providers });
// Skip Cesium's one-time terrain table download for draped lines.
t.mock.method(
Cesium.GroundPolylinePrimitive,
'initializeTerrainHeights',
async () => {},
);
const viewer = fakeViewer({ view });
const layer = createStreetLevelLayer({ source, photoViewer });
layer.init(viewer);
layer.enable(viewer);
if (enable) layer.enable(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
await settle();
return { layer, viewer };
return { layer, viewer, source, photoViewer };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
@@ -91,180 +128,194 @@ function deferred() {
return { promise, resolve, reject };
}
/** A provider whose viewer reports a pose for every image it opens. */
function posingProvider() {
const provider = fakeProvider();
const create = provider.create;
provider.create = (context) => {
const instance = create(context);
let emit = null;
instance.viewer.onPose = (listener) => {
emit = listener;
return () => {
emit = null;
};
};
instance.viewer.open = async (imageId) => {
provider.calls.open.push(imageId);
emit?.({
providerId: 'mapillary',
imageId,
position: { lon: -121.49, lat: 38.58 },
bearing: 90,
});
};
return instance;
};
return provider;
}
/** A photo viewer reporting a pose for every image it opens. */
const posingViewer = () =>
fakePhotoViewer({ pose: { position: { lon: -121.49, lat: 38.58 } } });
/** nearestImage lookups that answer when the test says, with their signals. */
/** Nearest-image lookups that answer when the test says, with their signals. */
function slowLookups() {
const lookups = [];
const nearestImage = (point, { signal } = {}) => {
const nearestImages = (query, { signal } = {}) => {
const answer = deferred();
lookups.push({ point, signal, answer });
return answer.promise;
lookups.push({ query, signal, answer });
return answer.promise.then((id) => [{ id, is_pano: false }]);
};
return { lookups, nearestImage };
return { lookups, source: fakeMapillarySource({ nearestImages }) };
}
test('the CC BY-SA credit shows while a provider is on and goes with it', async (t) => {
const { layer, viewer } = await enabledLayer(t);
const shown = () => viewer.credits.map((credit) => credit.html);
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on activate');
assert.match(shown()[0], /CC BY-SA 4\.0/);
layer.setProviderEnabled('mapillary', false);
assert.deepEqual(shown(), [], 'hidden with the provider');
layer.setProviderEnabled('mapillary', true);
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]);
layer.disable();
assert.deepEqual(shown(), [], 'every credit goes with the layer');
test('the layer is Mapillary under the codec keys it has always had', () => {
const layer = createStreetLevelLayer({ source: fakeMapillarySource() });
assert.equal(layer.id, 'street-level');
assert.equal(layer.requiresKeyId, 'mapillary');
assert.deepEqual(layer.getParams(), {
mapillary: true,
pano: 'all',
sinceDays: 0,
});
assert.throws(() => createStreetLevelLayer({ source: {} }), /source/);
});
test('getStats tells a rejected key from a missing one', async (t) => {
const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]);
provider.stats.loading = true;
assert.equal(layer.getStats().loadingLabel, 'loading coverage...');
assert.equal(layer.getStats().keyRequired, false);
test('the CC BY-SA credit shows while Mapillary is on and goes with it', async (t) => {
const { layer, viewer } = await startLayer(t);
const shown = () => viewer.credits.map((credit) => credit.html);
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on enable');
assert.match(shown()[0], /CC BY-SA 4\.0/);
layer.setParams({ mapillary: false });
assert.deepEqual(shown(), [], 'hidden with the switch');
assert.deepEqual(layer.getUIState().legend, [], 'no legend either');
layer.setParams({ mapillary: true });
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]);
layer.disable();
assert.deepEqual(shown(), [], 'the credit goes with the layer');
});
provider.stats.keyRequired = true;
assert.deepEqual(
[layer.getStats().loadingLabel, layer.getStats().error],
['KEY REQUIRED', 'KEY REQUIRED'],
);
test('coverage asks for no tiles before the key status is known, and none without a key', async (t) => {
for (const configured of [false, true]) {
const status = deferred();
const source = fakeMapillarySource({ getStatus: () => status.promise });
const { layer } = await startLayer(t, { source, view: true });
assert.equal(source.calls.tiles.length, 0, 'nothing before the status');
status.resolve({ configured });
await settle();
if (configured) assert.ok(source.calls.tiles.length > 0, 'then coverage');
else {
assert.equal(
source.calls.tiles.length,
0,
'a key-less install never asks',
);
assert.deepEqual(
[layer.getStats().loadingLabel, layer.getStats().error],
['KEY REQUIRED', 'KEY REQUIRED'],
);
assert.equal(layer.getUIState().keyRequired, true);
assert.equal(layer.getUIState().keyRejected, false);
}
layer.destroy();
}
});
// A rejected key gates the provider exactly like a missing one.
provider.stats.keyRequired = false;
provider.stats.keyRejected = true;
provider.stats.error = 'Mapillary rejected MAPILLARY_CLIENT_TOKEN';
test('a rejected key gates the layer as KEY REJECTED until it goes off', async (t) => {
const rejected = Object.assign(new Error('Mapillary rejected the token'), {
keyRejected: true,
});
const source = fakeMapillarySource({
getTile: async () => {
throw rejected;
},
});
const { layer, viewer } = await startLayer(t, { source, view: true });
await settle();
const stats = layer.getStats();
assert.equal(stats.keyRequired, true);
assert.equal(stats.loadingLabel, 'KEY REJECTED');
assert.equal(stats.error, 'Mapillary rejected MAPILLARY_CLIENT_TOKEN');
assert.match(stats.error, /rejected MAPILLARY_CLIENT_TOKEN/);
const ui = layer.getUIState();
assert.equal(ui.keyRequired, true);
assert.equal(ui.keyRejected, true);
assert.equal(ui.providers[0].keyRequired, true);
assert.deepEqual([ui.keyRequired, ui.keyRejected], [true, true]);
layer.disable();
layer.enable(viewer);
assert.equal(layer.getUIState().keyRejected, false, 'asked again once on');
});
test('switching a provider off deactivates it and closes the image it shows', async (t) => {
const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]);
test('switching Mapillary off clears coverage and closes the image it shows', async (t) => {
const { layer, photoViewer } = await startLayer(t);
layer.attachViewerHost({});
assert.equal(await layer.openImage('mapillary', 'img1'), true);
assert.deepEqual(provider.calls.open, ['img1']);
assert.equal(await layer.openImage('img1'), true);
assert.deepEqual(photoViewer.calls.open, ['img1']);
assert.equal(layer.getUIState().street.open, true);
layer.setProviderEnabled('mapillary', false);
assert.equal(provider.calls.deactivate, 1);
assert.equal(provider.calls.unmount, 1, 'its viewer is released');
layer.setParams({ mapillary: false });
assert.equal(photoViewer.calls.unmount, 1, 'its viewer is released');
assert.equal(layer.getUIState().street.open, false);
assert.equal(layer.getUIState().street.providerId, null);
assert.equal(layer.getUIState().providerOn, false);
});
test('openNearest without the panel reports it instead of loading forever', async (t) => {
const provider = fakeProvider({ nearestImage: async () => 'img1' });
const { layer } = await enabledLayer(t, [provider]);
const source = fakeMapillarySource({
nearestImages: async () => [{ id: 1 }],
});
const { layer, photoViewer } = await startLayer(t, { source });
assert.equal(await layer.openNearest({ lat: 38.58, lon: -121.49 }), false);
const { street } = layer.getUIState();
assert.equal(street.loading, false);
assert.match(street.error, /Open the Street Level panel/);
assert.equal(provider.calls.mount, 0);
assert.equal(photoViewer.calls.mount, 0);
});
test('openNearest opens nothing once the layer went off during the lookup', async (t) => {
const answer = deferred();
const provider = fakeProvider({ nearestImage: () => answer.promise });
const { layer } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
const opening = layer.openNearest({ lat: 38.58, lon: -121.49 });
layer.disable();
answer.resolve('img1');
assert.equal(await opening, false);
assert.equal(provider.calls.mount, 0, 'no viewer was stood up');
const { street } = layer.getUIState();
assert.equal(street.open, false);
assert.equal(street.loading, false);
assert.equal(street.error, null);
test('openNearest opens nothing once the layer or its switch went off during the lookup', async (t) => {
for (const off of [
(layer) => layer.disable(),
(layer) => layer.setParams({ mapillary: false }),
]) {
const { lookups, source } = slowLookups();
const { layer, photoViewer } = await startLayer(t, { source });
layer.attachViewerHost({});
const opening = layer.openNearest({ lat: 38.58, lon: -121.49 });
off(layer);
lookups[0].answer.resolve('img1');
assert.equal(await opening, false);
assert.equal(lookups[0].signal.aborted, true, 'the request was aborted');
assert.equal(photoViewer.calls.mount, 0, 'no viewer was stood up');
const { street } = layer.getUIState();
assert.deepEqual(
[street.open, street.loading, street.error],
[false, false, null],
);
layer.destroy();
}
});
test('openNearest skips a provider switched off during its lookup', async (t) => {
const answer = deferred();
const provider = fakeProvider({ nearestImage: () => answer.promise });
const { layer } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
const opening = layer.openNearest({ lat: 38.58, lon: -121.49 });
layer.setProviderEnabled('mapillary', false);
answer.resolve('img1');
assert.equal(await opening, false);
assert.deepEqual(provider.calls.open, []);
assert.equal(layer.getUIState().street.loading, false);
});
test('a provider withdraws only the error it reported', async (t) => {
const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]);
const { actions } = provider.context();
actions.reportError('Sequence images unavailable');
test('a sequence error is withdrawn with the sequence, never the viewer’s own', async (t) => {
const source = fakeMapillarySource({
getSequenceImages: async () => {
throw new Error('Sequence images unavailable');
},
});
const { layer } = await startLayer(t, { source });
await layer.selectSequence('seq-1');
assert.equal(layer.getUIState().street.error, 'Sequence images unavailable');
actions.reportError(null);
layer.clearSequence();
assert.equal(layer.getUIState().street.error, null, 'withdrawn');
// The viewer's own error is not the provider's to clear.
// The viewer's own error is not the sequence's to clear.
await layer.selectSequence('seq-2');
layer.attachViewerHost(null);
actions.reportError('Sequence images unavailable');
await layer.openImage('mapillary', 'img1');
actions.reportError(null);
await layer.openImage('img1');
layer.clearSequence();
assert.match(layer.getUIState().street.error, /Open the Street Level panel/);
});
test('closing the photo stops the globe flying to it', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
const { layer, viewer } = await startLayer(t, {
photoViewer: posingViewer(),
});
layer.attachViewerHost({});
assert.equal(await layer.openImage('mapillary', 'img1'), true);
assert.equal(await layer.openImage('img1'), true);
assert.equal(viewer.flights.started, 1, 'the photo is framed');
layer.closeViewer();
assert.equal(viewer.flights.cancelled, 1);
});
test('switching the layer off or destroying it stops the framing flight', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
const { layer, viewer } = await startLayer(t, {
photoViewer: posingViewer(),
});
layer.attachViewerHost({});
await layer.openImage('mapillary', 'img1');
await layer.openImage('img1');
layer.disable();
assert.equal(viewer.flights.cancelled, 1, 'layer off');
layer.enable(viewer);
await layer.openImage('mapillary', 'img2');
await layer.openImage('img2');
layer.destroy();
assert.equal(viewer.flights.cancelled, 2, 'destroyed');
});
test('closing the photo leaves a newer navigation flight alone', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
const { layer, viewer } = await startLayer(t, {
photoViewer: posingViewer(),
});
layer.attachViewerHost({});
await layer.openImage('mapillary', 'img1');
await layer.openImage('img1');
assert.equal(viewer.flights.started, 1, 'the photo is being framed');
// A search result flies the globe elsewhere before the framing lands.
viewer.camera.flyToBoundingSphere(null, {});
@@ -273,9 +324,8 @@ test('closing the photo leaves a newer navigation flight alone', async (t) => {
});
test('an older nearest lookup that answers late cannot replace a newer one', async (t) => {
const { lookups, nearestImage } = slowLookups();
const provider = fakeProvider({ nearestImage });
const { layer } = await enabledLayer(t, [provider]);
const { lookups, source } = slowLookups();
const { layer, photoViewer } = await startLayer(t, { source });
layer.attachViewerHost({});
const older = layer.openNearest({ lat: 38.58, lon: -121.49 });
const newer = layer.openNearest({ lat: 38.59, lon: -121.48 });
@@ -283,29 +333,27 @@ test('an older nearest lookup that answers late cannot replace a newer one', asy
assert.equal(await newer, true);
lookups[0].answer.resolve('older');
assert.equal(await older, false);
assert.deepEqual(provider.calls.open, ['newer']);
assert.deepEqual(photoViewer.calls.open, ['newer']);
assert.equal(lookups[0].signal?.aborted, true, 'its request was aborted');
assert.equal(lookups[1].signal?.aborted, false);
});
test('an image picked during a nearest lookup wins over its late answer', async (t) => {
const { lookups, nearestImage } = slowLookups();
const provider = fakeProvider({ nearestImage });
const { layer } = await enabledLayer(t, [provider]);
const { lookups, source } = slowLookups();
const { layer, photoViewer } = await startLayer(t, { source });
layer.attachViewerHost({});
const lookup = layer.openNearest({ lat: 38.58, lon: -121.49 });
// A cone click goes through the provider's openImage action.
assert.equal(await provider.context().actions.openImage('picked'), true);
// A cone click opens its image directly.
assert.equal(await layer.openImage('picked'), true);
lookups[0].answer.resolve('nearest');
assert.equal(await lookup, false);
assert.deepEqual(provider.calls.open, ['picked']);
assert.deepEqual(photoViewer.calls.open, ['picked']);
assert.equal(lookups[0].signal?.aborted, true);
});
test('closing the viewer or switching the layer off retires a nearest lookup', async (t) => {
const { lookups, nearestImage } = slowLookups();
const provider = fakeProvider({ nearestImage });
const { layer, viewer } = await enabledLayer(t, [provider]);
const { lookups, source } = slowLookups();
const { layer, viewer, photoViewer } = await startLayer(t, { source });
layer.attachViewerHost({});
const closed = layer.openNearest({ lat: 38.58, lon: -121.49 });
@@ -325,7 +373,7 @@ test('closing the viewer or switching the layer off retires a nearest lookup', a
layer.enable(viewer);
lookups[1].answer.resolve('img1');
assert.equal(await disabled, false, 'nor does it open once back on');
assert.deepEqual(provider.calls.open, []);
assert.deepEqual(photoViewer.calls.open, []);
({ street } = layer.getUIState());
assert.deepEqual(
[street.open, street.loading, street.error],
@@ -334,36 +382,8 @@ test('closing the viewer or switching the layer off retires a nearest lookup', a
assert.equal(lookups[1].signal?.aborted, true, 'layer off aborts it too');
});
test('the Mapillary provider hands the lookup signal to its source', async () => {
const requests = [];
const source = {
hasToken: () => true,
getStatus: async () => ({ configured: true }),
getTile: async () => new Uint8Array(0),
getSequenceImages: async () => [],
nearestImages: async (query, options) => {
requests.push(options);
return [{ id: 7, is_pano: false, captured_at: 10 }];
},
};
const instance = createMapillaryProvider({ source }).create({
services: {},
getFilter: () => ({ pano: 'all', sinceMs: null }),
isActive: () => true,
notify() {},
actions: { openImage() {}, reportError() {} },
});
const { signal } = new AbortController();
assert.equal(
await instance.nearestImage({ lat: 1, lon: 2 }, { signal }),
'7',
);
assert.equal(requests[0]?.signal, signal);
});
test('setParams takes "any date" (0 days) and a provider switch over the current values (share-link defaults)', async (t) => {
const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]);
test('setParams takes "any date" (0 days) and the switch over the current values (share-link defaults)', async (t) => {
const { layer } = await startLayer(t);
layer.setParams({ pano: 'flat', sinceDays: 365 });
assert.equal(layer.getParams().sinceDays, 365);
layer.setParams({ sinceDays: 0 });
@@ -372,54 +392,39 @@ test('setParams takes "any date" (0 days) and a provider switch over the current
pano: 'flat',
sinceDays: 0,
});
assert.equal(
provider.filters.at(-1).sinceMs,
null,
'providers drop the cut-off',
);
layer.setParams({ mapillary: false });
assert.equal(layer.getParams().mapillary, false);
});
/** A layer initialised on a stand-in viewer but never enabled. */
function initialisedLayer(t, providers) {
const saved = globalThis.document;
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
test('setParams ignores unknown keys and malformed values', async (t) => {
const { layer } = await startLayer(t);
layer.setParams({ pano: 'pano', sinceDays: 730 });
layer.setParams({ mapillary: '0', kartaview: true, pano: 'weird', x: 1 });
layer.setParams({ sinceDays: -3 });
layer.setParams(null);
assert.deepEqual(layer.getParams(), {
mapillary: true,
pano: 'pano',
sinceDays: 730,
});
const viewer = fakeViewer();
const layer = createStreetLevelLayer({ providers });
layer.init(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
return { layer, viewer };
}
});
test('switching a provider on while the layer is off activates nothing (M01)', async (t) => {
const provider = fakeProvider();
const prewarmed = [];
const create = provider.create;
provider.create = (context) => {
const instance = create(context);
instance.viewer.prewarm = async (host) => prewarmed.push(host);
return instance;
};
const { layer, viewer } = initialisedLayer(t, [provider]);
test('switching Mapillary on while the layer is off draws nothing (M01)', async (t) => {
const { layer, viewer, source, photoViewer } = await startLayer(t, {
view: true,
enable: false,
});
layer.attachViewerHost({});
layer.setProviderEnabled('mapillary', false);
layer.setProviderEnabled('mapillary', true);
layer.setParams({ mapillary: false });
layer.setParams({ mapillary: true });
await new Promise((resolve) => setTimeout(resolve, 40)); // past whenIdle
assert.equal(provider.calls.activate, 0, 'no coverage drawn');
await settle();
assert.equal(source.calls.tiles.length, 0, 'no coverage drawn');
assert.deepEqual(viewer.credits, [], 'no credit shown');
assert.deepEqual(prewarmed, [], 'no viewer stood up');
assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept');
// Enabling the layer is what activates it, once.
assert.equal(photoViewer.calls.mount, 0, 'no viewer stood up');
assert.equal(layer.getUIState().providerOn, true, 'the switch is kept');
// Enabling the layer is what draws it.
layer.enable(viewer);
assert.equal(provider.calls.activate, 1);
await settle();
assert.ok(source.calls.tiles.length > 0);
assert.equal(viewer.credits.length, 1);
});
-32
View File
@@ -1,32 +0,0 @@
import { normalizeFilter } from './filter.js';
/**
* Share-link params: one boolean per provider plus the filter. The codec in
* src/data/layerState.js names the same keys, so a new provider needs one there.
* @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input
*/
export function encodeParams({ providers, filter }) {
const params = {};
for (const [id, on] of providers) params[id] = on === true;
params.pano = filter.pano;
params.sinceDays = filter.sinceDays;
return params;
}
/**
* Read params back, ignoring unknown providers and malformed values so a link
* from a build with more providers still applies.
* @param {object} params
* @param {{providerIds: Iterable<string>, filter: {pano: string, sinceDays: number}}} current
* @returns {{providers: Map<string, boolean>, filter: {pano: string, sinceDays: number}}}
*/
export function decodeParams(params, { providerIds, filter }) {
const providers = new Map();
const source = params && typeof params === 'object' ? params : {};
for (const id of providerIds)
if (typeof source[id] === 'boolean') providers.set(id, source[id]);
const next = {};
if ('pano' in source) next.pano = source.pano;
if ('sinceDays' in source) next.sinceDays = source.sinceDays;
return { providers, filter: normalizeFilter(next, filter) };
}
-58
View File
@@ -1,58 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { decodeParams, encodeParams } from './params.js';
test('encodeParams writes one boolean per provider plus the filter', () => {
assert.deepEqual(
encodeParams({
providers: [
['mapillary', true],
['panoramax', false],
],
filter: { pano: 'flat', sinceDays: 365 },
}),
{ mapillary: true, panoramax: false, pano: 'flat', sinceDays: 365 },
);
});
test('decodeParams round-trips and ignores unknown or malformed keys', () => {
const current = { pano: 'all', sinceDays: 0 };
const decoded = decodeParams(
{ mapillary: false, kartaview: true, pano: 'pano', sinceDays: 730, x: 1 },
{ providerIds: ['mapillary'], filter: current },
);
assert.deepEqual([...decoded.providers], [['mapillary', false]]);
assert.deepEqual(decoded.filter, { pano: 'pano', sinceDays: 730 });
const partial = decodeParams(
{ mapillary: '1', pano: 'weird' },
{ providerIds: ['mapillary'], filter: { pano: 'flat', sinceDays: 5 } },
);
assert.equal(partial.providers.size, 0, 'a string is not a switch');
assert.deepEqual(partial.filter, { pano: 'flat', sinceDays: 5 });
const empty = decodeParams(null, {
providerIds: ['mapillary'],
filter: current,
});
assert.equal(empty.providers.size, 0);
assert.deepEqual(empty.filter, current);
});
test('decodeParams applies "any date" (0 days) over a current window (share-link defaults)', () => {
const decoded = decodeParams(
{ sinceDays: 0 },
{ providerIds: ['mapillary'], filter: { pano: 'flat', sinceDays: 365 } },
);
assert.deepEqual(decoded.filter, { pano: 'flat', sinceDays: 0 });
// The provider switch off is a value too, not an absence.
assert.deepEqual(
[
...decodeParams(
{ mapillary: false },
{ providerIds: ['mapillary'], filter: { pano: 'all', sinceDays: 0 } },
).providers,
],
[['mapillary', false]],
);
});
-21
View File
@@ -1,21 +0,0 @@
/**
* Route picked ids to the provider whose prefix they carry; the registry
* guarantees prefixes never overlap.
* @param {() => Iterable<{def: {id: string, pickPrefix: string}, instance: object}>} getProviders
* @param {{positionId?: string}} [options] Id of the core-owned position marker.
*/
export function createPickRouter(getProviders, { positionId = null } = {}) {
function resolve(id) {
if (typeof id !== 'string' || !id) return null;
if (positionId && id === positionId)
return { providerId: null, instance: null, id };
for (const entry of getProviders())
if (id.startsWith(entry.def.pickPrefix))
return { providerId: entry.def.id, instance: entry.instance, id };
return null;
}
return {
ownsPick: (id) => resolve(id) !== null,
resolve,
};
}
@@ -1,36 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPickRouter } from './pickRouter.js';
const providers = [
{ def: { id: 'mapillary', pickPrefix: 'mly:' }, instance: { name: 'm' } },
{ def: { id: 'panoramax', pickPrefix: 'pnx:' }, instance: { name: 'p' } },
];
test('ids are routed by prefix to their provider instance', () => {
const router = createPickRouter(() => providers, { positionId: 'sl:pos' });
assert.deepEqual(router.resolve('mly:seq:1'), {
providerId: 'mapillary',
instance: providers[0].instance,
id: 'mly:seq:1',
});
assert.equal(router.resolve('pnx:img:9').providerId, 'panoramax');
assert.equal(router.ownsPick('mly:img:2'), true);
});
test('the core-owned marker is owned but routes to no provider', () => {
const router = createPickRouter(() => providers, { positionId: 'sl:pos' });
assert.deepEqual(router.resolve('sl:pos'), {
providerId: null,
instance: null,
id: 'sl:pos',
});
assert.equal(router.ownsPick('sl:pos'), true);
});
test('foreign and malformed ids are not ours', () => {
const router = createPickRouter(() => providers);
for (const id of ['cctv:1', '', null, undefined, 42, {}])
assert.equal(router.ownsPick(id), false, String(id));
assert.equal(router.resolve('sl:pos'), null, 'no marker id configured');
});
+4 -12
View File
@@ -1,15 +1,10 @@
/** Identity and shared tuning for the provider-neutral Street Level layer. */
/** Identity and shared tuning for the Street Level layer. */
export const STREET_LEVEL_LAYER_ID = 'street-level';
/** Pick id of the viewer position marker; the core owns it, not a provider. */
/** Pick id of the viewer position marker. */
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',
});
/** Colours every provider shares: the selection highlight and the marker. */
/** The selection highlight and the position marker. */
export const COLORS = Object.freeze({
selected: '#00d4ff',
position: '#ffb300',
@@ -21,8 +16,5 @@ export const PANO_MODES = Object.freeze(['all', 'pano', 'flat']);
/** Longest "captured since" window the filter accepts, in days (~100 years). */
export const MAX_SINCE_DAYS = 36_500;
/** Filter every provider starts with: all imagery, any date. */
/** Filter the layer starts with: all imagery, any date. */
export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
/** Nearest-image search radius in metres (Mapillary's graph API caps it at 50 m). */
export const NEAREST_RADIUS_M = 50;
@@ -1,31 +1,18 @@
import * as Cesium from 'cesium';
import { decodeCoverageTile } from './decode.js';
import { passesImageryFilter } from '../../filter.js';
import {
createHorizonCull,
groundUnderCamera,
metresBetween,
viewFocus,
visibleBbox,
} from '../../view.js';
import {
coverageZoomForHeight,
overviewZoomForHeight,
tileBounds,
tilesForBbox,
} from '../../tileMath.js';
import { passesImageryFilter, resolveFilter } from '../../filter.js';
import { isActive } from '../../state.js';
import { cameraNadir, groundUnderCamera, visibleBbox } from '../../view.js';
import { coverageZoomForHeight, tilesForBbox } from '../../tileMath.js';
import {
COLORS,
COVERAGE_LINE_WIDTH_PX,
COVERAGE_MAX_SEQUENCES,
COVERAGE_MAX_TILES,
COVERAGE_MOVE_DEBOUNCE_MS,
COVERAGE_OVERVIEW_MAX_TILES,
COVERAGE_OVERVIEW_POINT_PX,
KEY_REJECTED_MESSAGE,
PICK_PREFIX,
RATE_LIMITED_MESSAGE,
SEQUENCE_VIEW_NEAR_M,
SEQUENCE_VIEW_RANGE_MIN_M,
SEQUENCE_VIEW_RANGE_PER_HEIGHT,
} from './policy.js';
@@ -67,9 +54,15 @@ export function sequenceIdFromPick(pickId) {
return cut === -1 ? rest : rest.slice(0, cut);
}
/** Hint above the street-zoom ceiling, where no coverage is drawn. */
export const ZOOM_IN_HINT = 'Zoom in to see street-level coverage';
/** Hint when no ground is in view within range (looking at the sky). */
export const NO_GROUND_HINT =
'Point the camera at the ground for street-level coverage';
/**
* Camera-driven coverage: overview points (z0–5) from orbit, sequence lines
* (z11–14) near the ground, draped on terrain and 3D tiles alike.
* Camera-driven coverage: Mapillary sequence lines (z11–14) near the ground,
* draped on terrain and 3D tiles alike. Nothing is drawn from orbit.
*/
export function createCoverage({ state, source }) {
const { render } = state.services;
@@ -89,7 +82,7 @@ export function createCoverage({ state, source }) {
/** The imagery filter, resolved now: "since N days" moves with the clock. */
function filter() {
return state.context.getFilter();
return resolveFilter(state.filter);
}
/**
@@ -154,65 +147,17 @@ export function createCoverage({ state, source }) {
return primitives;
}
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,
});
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]));
entry.primitives = buildSequencePrimitives(sequences);
entry.count = sequences.length;
for (const { primitive } of entry.primitives)
scene.groundPrimitives.add(primitive);
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);
}
// 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]));
entry.primitives = buildSequencePrimitives(sequences);
entry.count = sequences.length;
for (const { primitive } of entry.primitives)
scene.groundPrimitives.add(primitive);
watchSelection();
}
function detachPrimitive(entry) {
@@ -225,15 +170,6 @@ export function createCoverage({ state, source }) {
}
}
entry.primitives = [];
if (entry.primitive) {
try {
scene?.primitives?.remove(entry.primitive);
} catch {
/* already gone */
}
entry.primitive = null;
entry.overviewPoints = null;
}
}
function removeTile(key) {
@@ -243,7 +179,7 @@ export function createCoverage({ state, source }) {
state.coverage.tiles.delete(key);
}
async function loadTile(tile, kind) {
async function loadTile(tile) {
const key = tileKey(tile);
if (state.coverage.tiles.has(key) || state.coverage.pending.has(key))
return;
@@ -257,43 +193,23 @@ export function createCoverage({ state, source }) {
source.getTile('coverage', tile.z, tile.x, tile.y, {
signal: controller.signal,
}),
kind === 'sequence' ? ensureTerrainReady() : null,
ensureTerrainReady(),
]);
// 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 ||
state.coverage.pending.get(key) !== controller ||
state.coverage.tiles.has(key) ||
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;
}
const entry = { primitives: [], count: 0, sequences: new Map() };
// 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),
);
attachPrimitive(entry);
state.coverage.tiles.set(key, entry);
state.coverage.lastError = null;
@@ -304,14 +220,11 @@ export function createCoverage({ state, source }) {
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);
// Every other tile would be refused too: stop asking.
if (error?.keyRejected) block('rejected');
else if (error?.keyRequired) block('no-key');
else if (error?.retryAfterSec) block('rate-limited', error.retryAfterSec);
else state.coverage.lastError = error?.message || 'Coverage tile failed';
} 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).
@@ -324,29 +237,42 @@ export function createCoverage({ state, source }) {
}
/**
* Mapillary is rate-limiting: keep what is drawn, request nothing until the
* wait is over, then refresh once.
* Pause tile requests (see `blocked` in state.js). A rate limit keeps what
* is drawn and refreshes once its wait is over; a key problem outranks it,
* and a rejected key holds until `unblock` (the layer goes off).
*/
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);
function block(reason, retryAfterSec = 0) {
const current = state.coverage.blocked;
if (current === 'rejected') return;
if (reason === 'rate-limited' && current === 'no-key') return;
clearTimeout(state.coverage.blockTimer);
state.coverage.blockTimer = null;
state.coverage.blocked = reason;
if (reason === 'rate-limited')
state.coverage.blockTimer = setTimeout(() => {
state.coverage.blockTimer = null;
state.coverage.blocked = null;
refresh();
}, retryAfterSec * 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;
/** The layer went off: forget refusals and errors, so the next run asks again. */
function unblock() {
clearTimeout(state.coverage.blockTimer);
state.coverage.blockTimer = null;
if (['rejected', 'rate-limited'].includes(state.coverage.blocked))
state.coverage.blocked = null;
state.coverage.lastError = null;
state.keyRejected = false;
}
/** The server's key status: until it answers, nothing is requested. */
function setKeyStatus(configured) {
if (!configured) block('no-key');
else if (['status', 'no-key'].includes(state.coverage.blocked)) {
state.coverage.blocked = null;
refresh();
}
notify();
}
/** Drop the previous zoom's tiles once the new ones are on screen. */
@@ -375,74 +301,56 @@ export function createCoverage({ state, source }) {
}
function notify() {
state.context.notify();
state.notify?.();
}
/**
* Whether the screen centre meets the ground further ahead than the camera
* is high (a view tilted above 45°), or misses it.
* The tiles for the camera: what the screen shows within range of it, at
* the zoom for its height above ground, nearest the camera first.
*/
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;
function wantedTiles(viewer) {
const ground = groundUnderCamera(viewer);
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) 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,
const zoom = coverageZoomForHeight(height);
if (zoom == null) return { hint: ZOOM_IN_HINT };
// Rays meet the ground where it really is (1,600 m up in Denver) and stop
// short of the horizon, which would stretch the box to the world.
const bbox = visibleBbox(viewer, {
groundHeight: ground,
maxRange: Math.max(
SEQUENCE_VIEW_RANGE_MIN_M,
height * SEQUENCE_VIEW_RANGE_PER_HEIGHT,
),
});
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';
if (!bbox) return { hint: NO_GROUND_HINT };
// Ranked from the ground under the camera: a tilted view's box centre
// can sit kilometres ahead of anything near.
const { tiles } = tilesForBbox(bbox, zoom, {
limit: COVERAGE_MAX_TILES,
from: cameraNadir(viewer),
});
return { zoom, tiles };
}
/** Recompute the tile set for the current camera and reconcile primitives. */
function refresh() {
const viewer = state.viewer;
if (!viewer || !isActive(state) || state.coverage.blocked) return;
const { zoom, tiles, hint } = wantedTiles(viewer);
if (hint) {
state.coverage.hint = hint;
clear();
notify();
return;
}
state.coverage.hint = '';
if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) {
if (zoom !== state.coverage.zoom) {
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);
@@ -453,7 +361,7 @@ export function createCoverage({ state, source }) {
controller.abort();
state.coverage.pending.delete(key);
}
for (const tile of tiles) loadTile(tile, kind);
for (const tile of tiles) loadTile(tile);
if (!state.coverage.pending.size) purgeStale();
notify();
}
@@ -491,9 +399,7 @@ export function createCoverage({ state, source }) {
for (const key of [...state.coverage.tiles.keys()]) removeTile(key);
purgeStale();
state.coverage.zoom = null;
state.coverage.kind = null;
stopSelectionWatch();
horizon.stop();
requestRender();
}
@@ -590,17 +496,32 @@ export function createCoverage({ state, source }) {
}
/**
* Sequences (or overview points) drawn. A sequence that crosses a tile
* edge is in both tiles' lists, so sequences count once by id.
* Sequences 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;
for (const id of entry.sequences.keys()) ids.add(id);
return ids.size;
}
/** What the panel and the layer list show: count, LOADING, key gate, error. */
function stats() {
const { blocked } = state.coverage;
return {
count: sequenceCount(),
loading: state.coverage.pending.size > 0,
hint: state.coverage.hint,
keyRequired: blocked === 'no-key' || blocked === 'rejected',
keyRejected: blocked === 'rejected',
error:
blocked === 'rejected'
? KEY_REJECTED_MESSAGE
: blocked === 'rate-limited'
? RATE_LIMITED_MESSAGE
: state.coverage.lastError,
};
}
return {
@@ -608,9 +529,11 @@ export function createCoverage({ state, source }) {
detach,
refresh,
clear,
resetErrors,
unblock,
setKeyStatus,
rebuild,
recolorSequence,
sequenceCount,
stats,
};
}
@@ -1,7 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createCoverage } from './coverage.js';
import { createCoverage, ZOOM_IN_HINT } from './coverage.js';
import { freshCoverage } from '../../state.js';
import { encodeCoverageTile } from './coverageFixture.mjs';
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
import {
@@ -110,26 +111,15 @@ function setup() {
const state = {
viewer,
services: {},
keyRequired: false,
context: {
filter: { pano: 'all', sinceMs: null },
getFilter: () => state.context.filter,
isActive: () => true,
notify() {},
},
enabled: true,
providerOn: true,
filter: { pano: 'all', sinceDays: 0 },
notify() {},
coverage: {
zoom: null,
kind: null,
tiles: new Map(),
stale: new Map(),
staleTimer: null,
pending: new Map(),
lastError: null,
debounceTimer: null,
removeCameraListener: null,
// Skip Cesium's one-time terrain table download.
...freshCoverage(),
// The key status is in; skip Cesium's one-time terrain table download.
blocked: null,
terrainReady: Promise.resolve(),
hint: '',
},
sequence: { selectedId: null },
};
@@ -165,7 +155,7 @@ test('a superseded tile request cannot strand lines on the globe', async () => {
coverage.refresh(); // street zoom: request #1 for the tile
assert.equal(forTile().length, 1);
viewer.view.height = 100_000; // zoom out: retire() drops request #1
viewer.view.height = 100_000; // zoom out: clear() drops request #1
coverage.refresh();
viewer.view.height = 900; // back in before #1 settles: request #2
coverage.refresh();
@@ -231,19 +221,49 @@ test('panning off a tile that is still loading stops counting it as loading', as
coverage.clear();
});
test('zoom 0 is a zoom: the whole-earth view still shows coverage', () => {
const { viewer, source, state, coverage } = setup();
test('from orbit nothing is requested or drawn, and the hint says to zoom in', async () => {
const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
assert.ok(viewer.scene.groundPrimitives.items.size > 0, 'street lines');
viewer.view.height = 20_000_000;
coverage.refresh();
assert.equal(state.coverage.zoom, 0);
assert.equal(state.coverage.hint, '');
assert.deepEqual(
source.calls.map((call) => call.key),
['0/0/0'],
);
assert.equal(source.calls.length, 1, 'no tile is asked for');
assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'nothing drawn');
assert.equal(state.coverage.zoom, null);
assert.equal(coverage.stats().hint, ZOOM_IN_HINT);
viewer.view.height = 900;
coverage.refresh();
assert.equal(coverage.stats().hint, '', 'back at street zoom');
assert.equal(source.calls.length, 2);
coverage.clear();
});
test('the key status gates coverage: nothing before it answers, nothing without a key', () => {
for (const configured of [false, true]) {
const { source, state, coverage } = setup();
state.coverage.blocked = 'status';
coverage.refresh();
assert.equal(source.calls.length, 0, 'nothing before the status answers');
coverage.setKeyStatus(configured);
if (configured)
assert.equal(source.calls.length, 1, 'coverage loads once it is known');
else {
assert.equal(source.calls.length, 0, 'a key-less install never asks');
assert.equal(coverage.stats().keyRequired, true);
assert.equal(coverage.stats().keyRejected, false);
coverage.unblock();
assert.equal(
coverage.stats().keyRequired,
true,
'kept: it is no refusal',
);
}
coverage.clear();
}
});
test('a rejected key stops coverage requests until the layer goes off', async () => {
const { viewer, source, state, coverage } = setup();
coverage.refresh();
@@ -255,16 +275,18 @@ test('a rejected key stops coverage requests until the layer goes off', async ()
);
source.calls[0].reject(rejected);
await settle();
assert.equal(state.keyRejected, true);
assert.match(state.coverage.lastError, /rejected MAPILLARY_CLIENT_TOKEN/);
assert.equal(state.coverage.blocked, 'rejected');
assert.equal(coverage.stats().keyRequired, true, 'gated like a missing key');
assert.equal(coverage.stats().keyRejected, true);
assert.match(coverage.stats().error, /rejected MAPILLARY_CLIENT_TOKEN/);
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 1, 'panning asks for nothing more');
// Turning the provider off forgets the verdict; the next run asks again.
// Turning the layer off forgets the verdict; the next run asks again.
coverage.clear();
coverage.resetErrors();
assert.equal(state.keyRejected, false);
assert.equal(state.coverage.lastError, null);
coverage.unblock();
assert.equal(coverage.stats().keyRejected, false);
assert.equal(coverage.stats().error, null);
coverage.refresh();
assert.equal(source.calls.length, 2);
coverage.clear();
@@ -280,55 +302,16 @@ test('a rate limit keeps the drawn tiles and asks again once the wait is over',
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
assert.match(state.coverage.lastError, /rate-limited/);
assert.match(coverage.stats().error, /rate-limited/);
assert.equal(coverage.stats().keyRequired, false);
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 1, 'held: nothing is requested');
t.mock.timers.tick(30_000);
assert.equal(source.calls.length, 2, 'one refresh once the wait is over');
assert.equal(state.coverage.lastError, null);
assert.equal(coverage.stats().error, null);
coverage.clear();
coverage.resetErrors();
});
test('overview points behind the horizon are hidden, not drawn through the globe', async () => {
const { viewer, source, state, coverage } = setup();
const { view } = viewer;
view.height = 20_000_000;
Object.defineProperty(viewer.camera, 'positionWC', {
get: () => Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height),
});
coverage.refresh();
// One dot under the camera (Sacramento), one near its antipode.
source.calls[0].resolve(
encodeCoverageTile(
{ x: 0, y: 0, z: 0 },
{
overview: [
{ id: 'near', lon: -121.5, lat: 38.6 },
{ id: 'far', lon: 58.5, lat: -38.6 },
],
},
),
);
await settle();
const [entry] = state.coverage.tiles.values();
const points = [0, 1].map((i) => entry.primitive.get(i));
const west = (point) =>
Cesium.Cartographic.fromCartesian(point.position).longitude < 0;
const near = points.find(west);
const far = points.find((point) => !west(point));
frame(viewer.preRender);
assert.equal(near.show, true, 'the near side stays visible');
assert.equal(far.show, false, 'the far side does not show through');
// Fly round to the other hemisphere: the two swap.
view.lon = 58.5;
view.lat = -38.6;
frame(viewer.preRender);
assert.equal(near.show, false);
assert.equal(far.show, true);
coverage.clear();
assert.equal(viewer.preRender.size, 0, 'the cull listener is gone');
coverage.unblock();
});
test('the per-tile cap applies after the imagery filter, so a dense tile keeps its 360° lines', async () => {
@@ -354,7 +337,7 @@ test('the per-tile cap applies after the imagery filter, so a dense tile keeps i
capturedAt: 1_000_000_000_000 + i,
isPano: true,
});
state.context.filter = { pano: 'pano', sinceMs: null };
state.filter = { pano: 'pano', sinceDays: 0 };
coverage.refresh();
source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences }));
await settle();
@@ -365,7 +348,7 @@ test('the per-tile cap applies after the imagery filter, so a dense tile keeps i
// Picking and recolouring look sequences up among the drawn lines.
assert.ok(entry.sequences.has('pano-0'), 'drawn lines can be looked up');
assert.equal(entry.sequences.has('flat-699'), false);
state.context.filter = { pano: 'all', sinceMs: null };
state.filter = { pano: 'all', sinceDays: 0 };
coverage.rebuild();
assert.equal(entry.count, cap, 'all imagery is capped as before');
assert.ok(entry.sequences.has('flat-699'), 'the newest flat line is drawn');
@@ -512,36 +495,25 @@ function denverStreetView(aheadM) {
};
}
test('a tilted street view over ground 1,600 m up asks for the tiles it looks at first', () => {
test('a tilted street view over ground 1,600 m up asks for the ground it looks at, nearest the camera first', () => {
// The screen centre meets the ground at the centre of the next tile east.
const { state, coverage, keys, offsets, key } = denverStreetView(
(tileWidthM) => tileWidthM,
);
assert.equal(state.coverage.zoom, 14, 'height measured from the ground');
assert.deepEqual(
keys.slice(0, 2),
[key(0, 0), key(1, 0)],
'the tile under the camera, then the one the screen centre looks at',
);
assert.ok(keys.includes(key(-1, 0)), 'the ground behind the camera too');
assert.equal(keys[0], key(0, 0), 'the tile under the camera first');
assert.ok(keys.includes(key(1, 0)), 'the tile the screen centre looks at');
for (const [dx, dy] of offsets)
assert.ok(dx >= -1 && dx <= 2 && Math.abs(dy) <= 1, `${dx},${dy} in reach`);
assert.ok(dx >= 0 && dx <= 2 && Math.abs(dy) <= 1, `${dx},${dy} in view`);
coverage.clear();
});
test('a street view toward the horizon ranks only the ground within range', () => {
// The screen centre meets the ground 6 km out, past the 3 km range: the
// tiles are ranked around the camera, not along a line to the horizon.
test('a street view toward the horizon asks only for the ground within range', () => {
// The screen centre meets the ground 6 km out, past the 3 km range.
const { coverage, keys, offsets, key } = denverStreetView(() => 6000);
assert.equal(keys[0], key(0, 0), 'the tile under the camera first');
// Behind and ahead are equally near the camera (ties keep row order); a
// line of sight to the far ground would rank the tile ahead first.
assert.ok(
keys.indexOf(key(-1, 0)) < keys.indexOf(key(1, 0)),
'the tile ahead is not pulled forward',
);
for (const [dx, dy] of offsets)
assert.ok(Math.abs(dx) <= 1 && Math.abs(dy) <= 1, `${dx},${dy} around it`);
assert.ok(dx >= 0 && dx <= 1 && Math.abs(dy) <= 1, `${dx},${dy} near it`);
coverage.clear();
});
@@ -556,7 +528,7 @@ test('a selection cleared while the old zoom is still shown uncolours its lines'
coverage.recolorSequence('seq-1', true);
assert.deepEqual(Array.from(line.attributes.color), colourValue(true));
// Zoom out: the street tile stays on screen while the new zoom loads...
viewer.view.height = 100_000;
viewer.view.height = 3000;
coverage.refresh();
assert.equal(state.coverage.stale.size, 1, 'the old zoom is kept');
// ...and the selection is cleared meanwhile.
@@ -577,7 +549,7 @@ test('a selection made while the old zoom is still building is applied once it i
await settle();
const [entry] = state.coverage.tiles.values();
const line = controlled(entry.primitives[0], false);
viewer.view.height = 100_000;
viewer.view.height = 3000;
coverage.refresh();
assert.equal(state.coverage.stale.size, 1);
frame(viewer.postRender);
@@ -645,7 +617,7 @@ test('a rate limit holds every refresh inside its wait, not only the first', asy
t.mock.timers.tick(1000);
assert.ok(source.calls.length > 1, 'asked again once the wait is over');
coverage.clear();
coverage.resetErrors();
coverage.unblock();
});
test('a tile that fails before the terrain table loads is reported at once, never unhandled', async () => {
@@ -86,11 +86,11 @@ function lineGeometry(parts) {
* Encode a `mly1_public`-shaped tile from lon/lat features, for tests and
* the hermetic QA gate.
* @param {{x: number, y: number, z: number}} tile
* @param {{sequences?: Array<{id: string, capturedAt?: number, isPano?: boolean, parts: Array<Array<[number, number]>>}>, overview?: Array<{id: string, lon: number, lat: number, capturedAt?: number, isPano?: boolean}>, extent?: number}} [content]
* @param {{sequences?: Array<{id: string, capturedAt?: number, isPano?: boolean, parts: Array<Array<[number, number]>>}>, extent?: number}} [content]
*/
export function encodeCoverageTile(
tile,
{ sequences = [], overview = [], extent = 4096 } = {},
{ sequences = [], extent = 4096 } = {},
) {
const pbf = new PbfWriter();
if (sequences.length) {
@@ -111,17 +111,5 @@ export function encodeCoverageTile(
);
pbf.writeMessage(3, (_, out) => layer.write(out), null);
}
if (overview.length) {
const layer = layerWriter('overview', extent);
for (const point of overview) {
const [px, py] = lonLatToTileLocal([point.lon, point.lat], tile, extent);
layer.add(1, [9, zigzag(px), zigzag(py)], {
id: point.id,
captured_at: point.capturedAt ?? Date.now(),
is_pano: point.isPano === true,
});
}
pbf.writeMessage(3, (_, out) => layer.write(out), null);
}
return new Uint8Array(pbf.finish());
}
@@ -3,13 +3,13 @@ import { VectorTile } from '@mapbox/vector-tile';
import { tileLocalToLonLat } from '../../tileMath.js';
/**
* Decode a `mly1_public` coverage tile into sequences and (z0–5) overview
* points. The z14 `image` layer, which can hold >150k points, is skipped.
* Decode a `mly1_public` coverage tile (z11–14) into sequences. The z14
* `image` layer, which can hold >150k points, is skipped.
* @param {Uint8Array} bytes
* @param {{x:number,y:number,z:number}} address
*/
export function decodeCoverageTile(bytes, address) {
const result = { sequences: [], overview: [] };
const result = { sequences: [] };
if (!bytes || !bytes.length) return result;
const tile = new VectorTile(new PbfReader(bytes));
const { x, y, z } = address;
@@ -41,30 +41,5 @@ export function decodeCoverageTile(bytes, address) {
});
}
}
const overviewLayer = tile.layers.overview;
if (overviewLayer) {
for (let i = 0; i < overviewLayer.length; i++) {
const feature = overviewLayer.feature(i);
const props = feature.properties || {};
const point = feature.loadGeometry()?.[0]?.[0];
if (!point) continue;
const [lon, lat] = tileLocalToLonLat(
point.x,
point.y,
overviewLayer.extent,
x,
y,
z,
);
result.overview.push({
id: String(props.id ?? feature.id ?? i),
lon,
lat,
capturedAt: Number(props.captured_at) || 0,
isPano: props.is_pano === true,
sequenceId: props.sequence_id ? String(props.sequence_id) : null,
});
}
}
return result;
}
@@ -17,7 +17,10 @@ function linePart(points) {
return out;
}
/** A minimal mly1_public-shaped tile: one sequence line and one overview point. */
/**
* A minimal mly1_public-shaped tile: one sequence line, plus one point in the
* low-zoom `overview` layer the layer never draws.
*/
function sampleTile({ geometry = null } = {}) {
const writer = new PbfWriter();
writer.writeMessage(
@@ -94,7 +97,7 @@ function sampleTile({ geometry = null } = {}) {
return new Uint8Array(writer.finish());
}
test('sequences and overview points decode into lon/lat records', () => {
test('sequences decode into lon/lat records; overview points are ignored', () => {
const decoded = decodeCoverageTile(sampleTile(), { x: 0, y: 0, z: 1 });
assert.equal(decoded.sequences.length, 1);
const [sequence] = decoded.sequences;
@@ -109,19 +112,13 @@ test('sequences and overview points decode into lon/lat records', () => {
'west half of tile 0/0 at z1',
);
assert.ok(lat0 > 0 && lat1 < lat0, 'northern hemisphere, moving south');
assert.equal(decoded.overview.length, 1);
assert.equal(decoded.overview[0].id, '3');
assert.equal(decoded.overview[0].isPano, false);
assert.ok(Math.abs(decoded.overview[0].lon - -90) < 1e-6, 'tile centre');
assert.deepEqual(Object.keys(decoded), ['sequences']);
});
test('an empty tile decodes to empty lists', () => {
assert.deepEqual(
decodeCoverageTile(new Uint8Array(0), { x: 0, y: 0, z: 0 }),
{
sequences: [],
overview: [],
},
{ sequences: [] },
);
});
@@ -1,214 +0,0 @@
import { createCoverage, sequenceIdFromPick } from './coverage.js';
import { createSequences } from './sequences.js';
import { createMapillaryViewer } from './viewer.js';
import { passesImageryFilter } from '../../filter.js';
import { metresBetween } from '../../view.js';
import {
COLORS,
MAPILLARY_CREDIT_HTML,
MAPILLARY_KEY_ID,
MAPILLARY_LABEL,
MAPILLARY_NAME,
MAPILLARY_PROVIDER_ID,
MAPILLARY_SOURCE_METHODS,
NEAREST_LIMIT,
NEAREST_RADIUS_M,
PICK_PREFIX,
} from './policy.js';
/** Per-provider mutable state, created once per `create()`. */
function createProviderState(context) {
return {
context,
services: context.services,
viewer: null,
keyRequired: false,
/** Coverage waits for the first status: no tile requests without a key. */
statusKnown: false,
/** Mapillary refused the token: request nothing until re-enabled. */
keyRejected: false,
status: null,
coverage: {
zoom: null,
/** Current zoom's tiles by `z/x/y` key. */
tiles: new Map(),
/** Previous zoom's tiles, kept on screen until replacements land. */
stale: new Map(),
staleTimer: null,
/** Tile key → its request's controller; any entry means LOADING. */
pending: new Map(),
lastError: null,
/** While Mapillary rate-limits: no requests before this time (ms). */
holdUntil: 0,
holdTimer: null,
debounceTimer: null,
removeCameraListener: null,
terrainReady: null,
hint: '',
kind: null,
},
sequence: {
selectedId: null,
images: [],
/** Recent sequences' thinned images, by sequence id. */
cache: new Map(),
collection: null,
loading: false,
abort: null,
},
};
}
/**
* Mapillary Street Level provider: tile coverage, Graph API image cones and
* the MapillaryJS viewer. `source` is ./source.js or a stand-in.
* @returns {import('../../registry.js').StreetLevelProvider}
*/
export function createMapillaryProvider({ source }) {
if (
!MAPILLARY_SOURCE_METHODS.every(
(method) => typeof source?.[method] === 'function',
)
)
throw new TypeError('A Mapillary source is required');
return Object.freeze({
id: MAPILLARY_PROVIDER_ID,
name: MAPILLARY_NAME,
label: MAPILLARY_LABEL,
requiresKeyId: MAPILLARY_KEY_ID,
pickPrefix: PICK_PREFIX.root,
colors: COLORS,
credit: Object.freeze({ html: MAPILLARY_CREDIT_HTML }),
create(context) {
const state = createProviderState(context);
const parts = {};
parts.coverage = createCoverage({ state, source, parts });
parts.sequences = createSequences({ state, source, parts });
const viewer = createMapillaryViewer({
source,
render: context.services?.render,
});
return {
async status() {
try {
state.status = await source.getStatus();
state.keyRequired = state.status?.configured !== true;
} catch {
state.status = null;
state.keyRequired = !source.hasToken();
}
const first = !state.statusKnown;
state.statusKnown = true;
// Coverage held back until now: draw it if the layer is on.
if (first) parts.coverage.refresh();
context.notify();
return { configured: !state.keyRequired };
},
init(cesiumViewer) {
state.viewer = cesiumViewer;
parts.sequences.ensureCollections(cesiumViewer);
parts.sequences.setVisible(false);
},
activate(cesiumViewer) {
state.viewer = cesiumViewer;
parts.sequences.setVisible(true);
parts.coverage.attach(cesiumViewer);
},
deactivate() {
parts.coverage.detach();
parts.coverage.clear();
parts.coverage.resetErrors();
parts.sequences.clearSelection();
parts.sequences.setVisible(false);
},
destroy(cesiumViewer) {
parts.coverage.detach();
parts.coverage.clear();
parts.coverage.resetErrors();
parts.sequences.destroy(cesiumViewer);
},
refreshCoverage: () => parts.coverage.refresh(),
/** The filter changed: redraw. Each draw reads it from the context. */
setFilter() {
parts.coverage.rebuild();
parts.sequences.rerender();
},
coverageStats() {
return {
count: parts.coverage.sequenceCount(),
zoom: state.coverage.zoom,
kind: state.coverage.kind,
loading: state.coverage.pending.size > 0,
hint: state.coverage.hint,
error: state.coverage.lastError,
keyRequired: state.keyRequired,
keyRejected: state.keyRejected,
};
},
handlePick(pickId) {
const sequenceId = sequenceIdFromPick(pickId);
if (sequenceId) {
parts.sequences.select(sequenceId);
return true;
}
if (pickId.startsWith(PICK_PREFIX.image)) {
context.actions.openImage(pickId.slice(PICK_PREFIX.image.length));
return true;
}
return false;
},
selectSequence: (sequenceId) => parts.sequences.select(sequenceId),
clearSequence: () => parts.sequences.clearSelection(),
sequenceStats: () => ({
selectedId: state.sequence.selectedId,
images: state.sequence.images.length,
loading: state.sequence.loading,
}),
/**
* Nearest image that passes the imagery filter, or null. The core
* aborts `signal` once a newer request overtakes the lookup.
*/
async nearestImage({ lat, lon }, { signal } = {}) {
const images = await source.nearestImages(
{ lat, lon, radius: NEAREST_RADIUS_M, limit: NEAREST_LIMIT },
{ signal },
);
// The API returns the images in the radius in no particular order.
const metres = (record) => {
const [lon2, lat2] = (record.computed_geometry || record.geometry)
?.coordinates || [NaN, NaN];
const d = metresBetween({ lon, lat }, { lon: lon2, lat: lat2 });
return Number.isFinite(d) ? d : Infinity;
};
const byDistance = [...images].sort((a, b) => metres(a) - metres(b));
const filter = context.getFilter();
const hit = byDistance.find((record) =>
passesImageryFilter(
{
isPano: record.is_pano === true,
capturedAt: Number(record.captured_at) || 0,
},
filter,
),
);
return hit ? String(hit.id) : null;
},
viewer,
};
},
});
}
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { nearestImageId } from './nearest.js';
import { COLORS, mapillaryImageUrl } from './policy.js';
import { sequenceIdFromPick } from './coverage.js';
import { thinImages } from './sequences.js';
import { resolveFilter } from '../../filter.js';
const ALL = Object.freeze({ pano: 'all', sinceMs: null });
/** A source answering nearest-image lookups with `nearest`, recording options. */
function fakeSource(nearest = []) {
const requests = [];
return {
requests,
nearestImages: async (query, options) => {
requests.push({ query, options });
return nearest;
},
};
}
const at = (id, lon, lat) => ({
id,
is_pano: false,
captured_at: 10,
geometry: { type: 'Point', coordinates: [lon, lat] },
});
test('Mapillary draws in its green; image deep links match the mapillary.com share format', () => {
assert.equal(COLORS.coverage, '#05cb63');
assert.equal(
mapillaryImageUrl(1814275685699406),
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
);
assert.equal(mapillaryImageUrl(' '), 'https://www.mapillary.com/app/');
});
test('nearestImageId honours the imagery filter and hands the signal to the source', async () => {
const source = fakeSource([
{ id: 1, is_pano: false, captured_at: 10 },
{ id: 2, is_pano: true, captured_at: 20 },
]);
const { signal } = new AbortController();
assert.equal(
await nearestImageId(source, { lat: 1, lon: 2 }, ALL, { signal }),
'1',
);
assert.equal(source.requests[0].options.signal, signal);
assert.equal(source.requests[0].query.radius, 50);
assert.equal(
await nearestImageId(
source,
{ lat: 1, lon: 2 },
{
pano: 'pano',
sinceMs: null,
},
),
'2',
);
assert.equal(
await nearestImageId(
source,
{ lat: 1, lon: 2 },
{
pano: 'all',
sinceMs: 100,
},
),
null,
);
});
test('any part of a multi-part sequence picks the whole sequence', () => {
assert.equal(sequenceIdFromPick('mly:seq:abc'), 'abc');
assert.equal(sequenceIdFromPick('mly:seq:abc~2'), 'abc');
assert.equal(sequenceIdFromPick('mly:img:abc'), null);
assert.equal(sequenceIdFromPick(null), null);
});
test('nearestImageId picks the closest image, not the first the API returned', async () => {
const source = fakeSource([
at('far', 2.0004, 1), // ~45 m east
at('near', 2.00005, 1), // ~5 m east
at('mid', 2, 1.0002), // ~22 m north
]);
assert.equal(await nearestImageId(source, { lat: 1, lon: 2 }, ALL), 'near');
});
test('a "since N days" window follows the clock in a long-open tab', async (t) => {
t.mock.timers.enable({ apis: ['Date'], now: Date.UTC(2026, 9, 1) });
const day = 86_400_000;
const capturedAt = Date.now() - day / 2;
const source = fakeSource([
{ id: 'recent', is_pano: false, captured_at: capturedAt },
]);
// The layer resolves the stored filter on every lookup.
const lookup = () =>
nearestImageId(
source,
{ lat: 1, lon: 2 },
resolveFilter({ pano: 'all', sinceDays: 1 }),
);
assert.equal(await lookup(), 'recent');
t.mock.timers.tick(2 * day);
assert.equal(
await lookup(),
null,
'two days on, the image is older than the window',
);
});
test('nearestImageId measures the short way round the date line', async () => {
const east = fakeSource([
at('same-side', 179.9995, 0), // ~45 m west, on the camera's side
at('across', -179.9999, 0), // ~11 m east, across ±180°
]);
assert.equal(
await nearestImageId(east, { lat: 0, lon: 179.9999 }, ALL),
'across',
);
// And from the other side.
const west = fakeSource([
at('same-side', -179.9995, 0),
at('across', 179.9999, 0),
]);
assert.equal(
await nearestImageId(west, { lat: 0, lon: -179.9999 }, ALL),
'across',
);
});
test('cone thinning across the date line drops images metres apart', () => {
const image = (id, lon) => ({ id, lon, lat: 0 });
// A sequence driving east over ±180° with images ~1 m apart, then ~11 m on.
const kept = thinImages(
[
image('a', 179.99999),
image('b', -179.99999), // ~2 m from a, across the date line
image('c', -179.9999), // ~11 m from a
],
3,
);
assert.deepEqual(
kept.map(({ id }) => id),
['a', 'c'],
);
});
@@ -0,0 +1,38 @@
import { passesImageryFilter } from '../../filter.js';
import { metresBetween } from '../../view.js';
import { NEAREST_LIMIT, NEAREST_RADIUS_M } from './policy.js';
/**
* Id of the nearest image around a point that passes the resolved imagery
* filter, or null. An aborted `signal` gives up (the source may reject).
*/
export async function nearestImageId(
source,
{ lat, lon },
filter,
{ signal } = {},
) {
const images = await source.nearestImages(
{ lat, lon, radius: NEAREST_RADIUS_M, limit: NEAREST_LIMIT },
{ signal },
);
// The API returns the images in the radius in no particular order.
const metres = (record) => {
const [lon2, lat2] = (record.computed_geometry || record.geometry)
?.coordinates || [NaN, NaN];
const d = metresBetween({ lon, lat }, { lon: lon2, lat: lat2 });
return Number.isFinite(d) ? d : Infinity;
};
const hit = [...images]
.sort((a, b) => metres(a) - metres(b))
.find((record) =>
passesImageryFilter(
{
isPano: record.is_pano === true,
capturedAt: Number(record.captured_at) || 0,
},
filter,
),
);
return hit ? String(hit.id) : null;
}
@@ -1,15 +1,10 @@
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
import { COLORS as SHARED_COLORS } from '../../policy.js';
export { NEAREST_RADIUS_M } from '../../policy.js';
/** Identity and tuning for the Mapillary street-level provider. */
export const MAPILLARY_PROVIDER_ID = 'mapillary';
export const MAPILLARY_NAME = 'Mapillary';
export const MAPILLARY_LABEL = 'MAPILLARY';
/** Identity and tuning for Street Level's Mapillary imagery. */
export const MAPILLARY_KEY_ID = 'mapillary';
export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com';
/** Methods a source (./source.js or a stand-in) must have for the provider. */
/** Methods a source (./source.js or a stand-in) must have. */
export const MAPILLARY_SOURCE_METHODS = Object.freeze([
'getStatus',
'getTile',
@@ -24,17 +19,15 @@ export const PICK_PREFIX = Object.freeze({
image: 'mly:img:',
});
/** Mapillary green for lines, points and cones; GEV cyan for selection. */
/** Mapillary green for lines, cones and the legend; GEV cyan for selection. */
export const COLORS = Object.freeze({
coverage: PROVIDER_COLORS.mapillary,
coverage: '#05cb63',
selected: SHARED_COLORS.selected,
});
/** Camera-driven coverage refresh. */
export const COVERAGE_MOVE_DEBOUNCE_MS = 320;
export const COVERAGE_MAX_TILES = 9;
/** Overview (z0–5) coverage points seen from orbit. */
export const COVERAGE_OVERVIEW_MAX_TILES = 16;
/** Shown when Mapillary refuses the server's token (401/403). */
export const KEY_REJECTED_MESSAGE =
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
@@ -47,9 +40,6 @@ export const RATE_LIMITED_MESSAGE =
*/
export const SEQUENCE_VIEW_RANGE_MIN_M = 2_500;
export const SEQUENCE_VIEW_RANGE_PER_HEIGHT = 10;
/** Ground always covered around the camera at street zooms, in metres. */
export const SEQUENCE_VIEW_NEAR_M = 1_000;
export const COVERAGE_OVERVIEW_POINT_PX = 2.5;
export const COVERAGE_MAX_SEQUENCES = 6000;
export const COVERAGE_LINE_WIDTH_PX = 2.5;
@@ -58,7 +48,9 @@ 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). */
/** Nearest-image search radius in metres (the graph API caps it at 50 m). */
export const NEAREST_RADIUS_M = 50;
/** Images asked for in a nearest-image search. */
export const NEAREST_LIMIT = 8;
/** On-globe credit: Mapillary imagery is CC BY-SA 4.0 and needs attribution. */
@@ -1,252 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMapillaryProvider } from './index.js';
import { mapillaryImageUrl } from './policy.js';
import { sequenceIdFromPick } from './coverage.js';
import { thinImages } from './sequences.js';
import { validateProviders } from '../../registry.js';
import { PROVIDER_COLORS } from '../../policy.js';
import { resolveFilter } from '../../filter.js';
import * as Cesium from 'cesium';
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
function fakeSource({ nearest = [] } = {}) {
return {
hasToken: () => true,
getStatus: async () => ({ configured: true }),
getTile: async () => new Uint8Array(0),
getSequenceImages: async () => [],
nearestImages: async () => nearest,
};
}
function fakeContext(filter = { pano: 'all', sinceMs: null }) {
const opened = [];
return {
opened,
services: {},
getFilter: typeof filter === 'function' ? filter : () => filter,
isActive: () => true,
notify: () => {},
actions: {
openImage: (id) => {
opened.push(id);
},
reportError: () => {},
},
};
}
test('the definition satisfies the Street Level provider contract', () => {
const def = createMapillaryProvider({ source: fakeSource() });
assert.doesNotThrow(() => validateProviders([def]));
assert.equal(def.id, 'mapillary');
assert.equal(def.label, 'MAPILLARY');
assert.equal(def.requiresKeyId, 'mapillary');
assert.equal(def.pickPrefix, 'mly:');
assert.match(def.credit.html, /CC BY-SA 4\.0/);
// One colour per source: Mapillary is green everywhere it draws.
assert.deepEqual(Object.keys(def.colors).sort(), ['coverage', 'selected']);
assert.equal(def.colors.coverage, PROVIDER_COLORS.mapillary);
assert.equal(PROVIDER_COLORS.mapillary, '#05cb63');
assert.throws(() => createMapillaryProvider({ source: {} }), /source/);
});
test('image deep links match the mapillary.com share format', () => {
assert.equal(
mapillaryImageUrl(1814275685699406),
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
);
assert.equal(mapillaryImageUrl(' '), 'https://www.mapillary.com/app/');
});
test('picks route sequences to selection and images to the core opener', () => {
const context = fakeContext();
const instance = createMapillaryProvider({ source: fakeSource() }).create(
context,
);
assert.equal(instance.handlePick('mly:img:77'), true);
assert.deepEqual(context.opened, ['77']);
assert.equal(instance.handlePick('cctv:1'), false);
assert.deepEqual(instance.sequenceStats(), {
selectedId: null,
images: 0,
loading: false,
});
});
test('nearestImage honours the imagery filter and reports key status', async () => {
const nearest = [
{ id: 1, is_pano: false, captured_at: 10 },
{ id: 2, is_pano: true, captured_at: 20 },
];
const source = fakeSource({ nearest });
const flat = createMapillaryProvider({ source }).create(fakeContext());
assert.equal(await flat.nearestImage({ lat: 1, lon: 2 }), '1');
const pano = createMapillaryProvider({ source }).create(
fakeContext({ pano: 'pano', sinceMs: null }),
);
assert.equal(await pano.nearestImage({ lat: 1, lon: 2 }), '2');
const none = createMapillaryProvider({ source }).create(
fakeContext({ pano: 'all', sinceMs: 100 }),
);
assert.equal(await none.nearestImage({ lat: 1, lon: 2 }), null);
assert.deepEqual(await flat.status(), { configured: true });
assert.equal(flat.coverageStats().keyRequired, false);
});
test('any part of a multi-part sequence picks the whole sequence', () => {
assert.equal(sequenceIdFromPick('mly:seq:abc'), 'abc');
assert.equal(sequenceIdFromPick('mly:seq:abc~2'), 'abc');
assert.equal(sequenceIdFromPick('mly:img:abc'), null);
assert.equal(sequenceIdFromPick(null), null);
});
test('nearestImage picks the closest image, not the first the API returned', async () => {
const at = (id, lon, lat) => ({
id,
is_pano: false,
captured_at: 10,
geometry: { type: 'Point', coordinates: [lon, lat] },
});
const source = fakeSource({
nearest: [
at('far', 2.0004, 1), // ~45 m east
at('near', 2.00005, 1), // ~5 m east
at('mid', 2, 1.0002), // ~22 m north
],
});
const instance = createMapillaryProvider({ source }).create(fakeContext());
assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'near');
});
test('a "since N days" window follows the clock in a long-open tab', async (t) => {
t.mock.timers.enable({ apis: ['Date'], now: Date.UTC(2026, 9, 1) });
const day = 86_400_000;
const capturedAt = Date.now() - day / 2;
const source = fakeSource({
nearest: [{ id: 'recent', is_pano: false, captured_at: capturedAt }],
});
// The core's context resolves the stored filter whenever it is asked.
const instance = createMapillaryProvider({ source }).create(
fakeContext(() => resolveFilter({ pano: 'all', sinceDays: 1 })),
);
assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'recent');
t.mock.timers.tick(2 * day);
assert.equal(
await instance.nearestImage({ lat: 1, lon: 2 }),
null,
'two days on, the image is older than the window',
);
});
test('nearestImage measures the short way round the date line', async () => {
const at = (id, lon, lat) => ({
id,
is_pano: false,
captured_at: 10,
geometry: { type: 'Point', coordinates: [lon, lat] },
});
const source = fakeSource({
nearest: [
at('same-side', 179.9995, 0), // ~45 m west, on the camera's side
at('across', -179.9999, 0), // ~11 m east, across ±180°
],
});
const instance = createMapillaryProvider({ source }).create(fakeContext());
assert.equal(
await instance.nearestImage({ lat: 0, lon: 179.9999 }),
'across',
);
// And from the other side.
const west = createMapillaryProvider({
source: fakeSource({
nearest: [at('same-side', -179.9995, 0), at('across', 179.9999, 0)],
}),
}).create(fakeContext());
assert.equal(await west.nearestImage({ lat: 0, lon: -179.9999 }), 'across');
});
test('cone thinning across the date line drops images metres apart', () => {
const image = (id, lon) => ({ id, lon, lat: 0 });
// A sequence driving east over ±180° with images ~1 m apart, then ~11 m on.
const kept = thinImages(
[
image('a', 179.99999),
image('b', -179.99999), // ~2 m from a, across the date line
image('c', -179.9999), // ~11 m from a
],
3,
);
assert.deepEqual(
kept.map(({ id }) => id),
['a', 'c'],
);
});
/** A viewer looking straight down on downtown Sacramento from 900 m. */
function streetViewer() {
const collection = () => ({
add: (item) => item,
remove: () => true,
contains: () => false,
get length() {
return 0;
},
});
const event = () => ({ addEventListener: () => () => {} });
const camera = Object.assign(
rayCamera({
lon: -121.4944,
lat: 38.5816,
altitude: 900,
pitch: -90,
width: 100,
height: 100,
}),
{ changed: event(), moveEnd: event() },
);
return {
camera,
scene: {
canvas: { clientWidth: 100, clientHeight: 100 },
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
primitives: collection(),
groundPrimitives: collection(),
preRender: event(),
postRender: event(),
requestRender() {},
},
};
}
test('coverage asks for no tiles before the key status is known', async () => {
for (const configured of [false, true]) {
let answer;
const tiles = [];
const source = {
...fakeSource(),
hasToken: () => configured,
getStatus: () => new Promise((resolve) => (answer = resolve)),
getTile: (...args) => {
tiles.push(args);
return new Promise(() => {});
},
};
const instance = createMapillaryProvider({ source }).create(fakeContext());
const viewer = streetViewer();
instance.init(viewer);
const status = instance.status();
instance.activate(viewer);
assert.equal(tiles.length, 0, 'nothing before the status answers');
answer({ configured });
await status;
if (configured)
assert.ok(tiles.length > 0, 'coverage loads as soon as the key is known');
else {
assert.equal(tiles.length, 0, 'a key-less install never asks');
assert.equal(instance.coverageStats().keyRequired, true);
}
instance.destroy(viewer);
}
});
@@ -1,6 +1,6 @@
import * as Cesium from 'cesium';
import { imageConeGlyph } from '../../glyphs.js';
import { passesImageryFilter } from '../../filter.js';
import { passesImageryFilter, resolveFilter } from '../../filter.js';
import { createHorizonCull, metresBetween } from '../../view.js';
import {
COLORS,
@@ -53,7 +53,12 @@ export function createSequences({ state, source, parts }) {
}
function notify() {
state.context.notify();
state.notify?.();
}
/** Show a sequence error, or withdraw it (null); see `reportSequenceError`. */
function reportError(message) {
parts.reportSequenceError?.(message);
}
/** Drawn cone billboards. */
@@ -95,7 +100,7 @@ export function createSequences({ state, source, parts }) {
pano: true,
});
// Resolved now, so a "since N days" window keeps up with the clock.
const filter = state.context.getFilter();
const filter = resolveFilter(state.filter);
for (const image of images) {
if (!passesImageryFilter(image, filter)) continue;
const billboard = collection.add({
@@ -146,7 +151,7 @@ export function createSequences({ state, source, parts }) {
state.sequence.loading = false;
state.sequence.images = cached;
renderCones(cached);
state.context.actions.reportError(null);
reportError(null);
notify();
return;
}
@@ -172,15 +177,13 @@ export function createSequences({ state, source, parts }) {
remember(sequenceId, images);
state.sequence.images = images;
renderCones(images);
state.context.actions.reportError(null);
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',
);
reportError(error?.message || 'Sequence images unavailable');
}
} finally {
if (state.sequence.abort === controller) {
@@ -200,7 +203,7 @@ export function createSequences({ state, source, parts }) {
state.sequence.loading = false;
clearCones();
// An error about the sequence goes with it.
state.context.actions.reportError(null);
reportError(null);
requestRender();
notify();
}
@@ -62,11 +62,8 @@ function setup() {
const state = {
services: {},
viewer: {},
context: {
notify() {},
getFilter: () => ({ pano: 'all', sinceMs: null }),
actions: { reportError: (message) => errors.push(message) },
},
filter: { pano: 'all', sinceDays: 0 },
notify() {},
sequence: {
selectedId: null,
images: [],
@@ -80,6 +77,7 @@ function setup() {
coverage: {
recolorSequence: (id, selected) => colours.push([id, selected]),
},
reportSequenceError: (message) => errors.push(message),
};
const sequences = createSequences({ state, source, parts });
const drawn = () =>
@@ -1,18 +1,28 @@
import { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js';
import { mapillaryImageUrl } from './policy.js';
/**
* Viewer adapter: lazy-loads MapillaryJS into the core's host and emits a
* provider-neutral pose on every image, view or position change.
* @returns {import('../../registry.js').ViewerAdapter}
* @typedef {object} StreetPose
* @property {string} imageId
* @property {{lon: number, lat: number}} position
* @property {number|null} bearing
* @property {number|null} tilt
* @property {number|null} altitude
* @property {boolean} isPano
* @property {number|null} capturedAt Epoch milliseconds.
* @property {string|null} creator
* @property {string|null} sequenceId
* @property {string} externalUrl Deep link to the image on mapillary.com.
*/
/**
* The panel's photo viewer: loads MapillaryJS on the first `mount` and emits
* a pose on every image, view or position change.
*/
export function createMapillaryViewer({ source, render } = {}) {
let viewer = null;
let Library = null;
let container = null;
let pendingOpen = null;
let prewarming = null;
/** In-flight viewer construction, so pre-warm and open never build two. */
let creating = null;
/** Bumped by `unmount`, so a construction it overtook builds nothing. */
let generation = 0;
let renderMode = 'letterbox';
@@ -52,7 +62,7 @@ export function createMapillaryViewer({ source, render } = {}) {
}
}
/** Read MapillaryJS image metadata into the neutral pose shape. */
/** Read MapillaryJS image metadata into the pose shape. */
function describe(image) {
return {
imageId: String(image.id),
@@ -81,7 +91,6 @@ export function createMapillaryViewer({ source, render } = {}) {
if (image) current = describe(image);
if (!current || !viewer) return;
emit({
providerId: MAPILLARY_PROVIDER_ID,
...current,
position: { lon: lngLat.lng, lat: lngLat.lat },
bearing: Number.isFinite(pov?.bearing) ? pov.bearing : null,
@@ -94,17 +103,8 @@ export function createMapillaryViewer({ source, render } = {}) {
}
}
function ensureViewer(host) {
async function ensureViewer(host) {
if (viewer && container === host) return viewer;
if (creating?.container === host) return creating.promise;
const promise = createViewer(host).finally(() => {
if (creating?.promise === promise) creating = null;
});
creating = { container: host, promise };
return promise;
}
async function createViewer(host) {
const built = generation;
destroyViewer();
const { Viewer } = await ensureLibrary();
@@ -178,9 +178,8 @@ export function createMapillaryViewer({ source, render } = {}) {
unmount() {
pendingOpen = null;
// The next mount builds afresh rather than joining a stale build.
// A build still waiting for the library gives up when it arrives.
generation++;
creating = null;
destroyViewer();
},
@@ -192,26 +191,6 @@ export function createMapillaryViewer({ source, render } = {}) {
}
},
/** Build the viewer ahead of the first image. Safe to call repeatedly. */
async prewarm(host) {
if (!host) return;
// An in-flight prewarm may give up (layer toggled mid-download): wait
// for it, then build if it did not.
if (prewarming) await prewarming;
if (viewer) return;
const run = (async () => {
try {
await ensureLibrary();
if (!viewer) await ensureViewer(host);
} catch {
/* the real open reports errors */
}
})();
prewarming = run;
await run;
if (prewarming === run) prewarming = null;
},
/** Show the whole image ('letterbox') or crop it to the frame ('fill'). */
setRenderMode(mode) {
renderMode = mode === 'fill' ? 'fill' : 'letterbox';
-131
View File
@@ -1,131 +0,0 @@
/**
* The contract an imagery provider registers with the Street Level layer.
*
* @typedef {object} StreetLevelProvider
* @property {string} id Stable id, e.g. 'mapillary'; also the share-link option key.
* @property {string} name Human name for captions and legends, e.g. 'Mapillary'.
* @property {string} label Chip text and link label, e.g. 'MAPILLARY'.
* @property {string|null} requiresKeyId Key-setup id the provider needs, or null when keyless.
* @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 {(context: ProviderContext) => ProviderInstance} create
*
* @typedef {object} ProviderContext Handed to `create()` once by the core.
* @property {object} services Scene services: picking, input, render, sprites.
* @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 {() => void} notify Ask the core to publish a new UI snapshot.
* @property {{openImage: (imageId: string) => Promise<void>, reportError: (message: string|null) => void}} actions
*
* @typedef {object} ProviderInstance
* @property {() => Promise<{configured: boolean}>} status
* @property {(viewer: object) => void} init Create collections, hidden.
* @property {(viewer: object) => void} activate Start drawing coverage for the camera.
* @property {() => void} deactivate Detach, clear coverage and selection, hide.
* @property {(viewer: object) => void} destroy
* @property {() => void} refreshCoverage
* @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter The filter changed: redraw (reading `getFilter()`).
* @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 {(sequenceId: string) => Promise<void>} [selectSequence]
* @property {() => void} [clearSequence]
* @property {() => {selectedId: string|null, images: number, loading: boolean}} [sequenceStats]
* @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal}) => Promise<string|null>} nearestImage An aborted lookup gives up (it may reject with an AbortError).
* @property {ViewerAdapter} viewer
*
* @typedef {object} ViewerAdapter
* @property {(host: HTMLElement) => Promise<void>} mount Idempotent; may lazy-load a library.
* @property {(imageId: string) => Promise<void>} open Resolves once the image is on screen and a pose was emitted.
* @property {() => void} close Drop the image, keep the instance warm.
* @property {() => void} unmount Destroy the instance (host handed to another provider).
* @property {() => void} resize
* @property {(host: HTMLElement) => Promise<void>} [prewarm]
* @property {(mode: 'letterbox'|'fill') => void} [setRenderMode]
* @property {(listener: (pose: StreetPose) => void) => () => void} onPose
*
* @typedef {object} StreetPose
* @property {string} providerId
* @property {string} imageId
* @property {{lon: number, lat: number}} position
* @property {number|null} bearing
* @property {number|null} tilt
* @property {number|null} altitude
* @property {boolean} isPano
* @property {number|null} capturedAt Epoch milliseconds.
* @property {string|null} creator
* @property {string|null} sequenceId
* @property {string} externalUrl Deep link to the image on the provider's site.
*/
const REQUIRED = Object.freeze([
'id',
'name',
'label',
'pickPrefix',
'colors',
'credit',
'create',
]);
const ID_GRAMMAR = /^[a-z][a-z0-9-]*$/;
/**
* Validate providers and freeze their order, which is also the chip order.
* @param {Array<StreetLevelProvider>} providers
* @returns {ReadonlyArray<StreetLevelProvider>}
*/
export function validateProviders(providers) {
if (!Array.isArray(providers) || providers.length === 0)
throw new TypeError('Street Level needs at least one imagery provider');
const ids = new Set();
const prefixes = [];
for (const provider of providers) {
const label = provider?.id ?? '(unnamed)';
for (const key of REQUIRED)
if (provider?.[key] === undefined || provider?.[key] === null)
throw new TypeError(`Street Level provider ${label} lacks ${key}`);
if (typeof provider.id !== 'string' || !ID_GRAMMAR.test(provider.id))
throw new TypeError(`Street Level provider id ${label} is not a slug`);
if (ids.has(provider.id))
throw new TypeError(`Duplicate Street Level provider ${provider.id}`);
if (typeof provider.create !== 'function')
throw new TypeError(
`Street Level provider ${label}: create is not a function`,
);
if (typeof provider.pickPrefix !== 'string' || !provider.pickPrefix)
throw new TypeError(`Street Level provider ${label} needs a pick prefix`);
for (const other of prefixes)
if (
other.startsWith(provider.pickPrefix) ||
provider.pickPrefix.startsWith(other)
)
throw new TypeError(
`Street Level provider ${label}: pick prefix ${provider.pickPrefix} overlaps ${other}`,
);
if (!provider.credit?.html)
throw new TypeError(`Street Level provider ${label} needs a credit`);
if (
typeof provider.colors?.coverage !== 'string' ||
!provider.colors.coverage
)
throw new TypeError(
`Street Level provider ${label} needs a coverage colour`,
);
ids.add(provider.id);
prefixes.push(provider.pickPrefix);
}
return Object.freeze([...providers]);
}
/**
* The key id every provider shares, or null if any is keyless or they differ
* (then each chip reports its own need).
* @param {ReadonlyArray<StreetLevelProvider>} providers
* @returns {string|null}
*/
export function requiresKeyIdFor(providers) {
const keys = new Set(providers.map((p) => p.requiresKeyId || null));
if (keys.size !== 1) return null;
return [...keys][0];
}
-98
View File
@@ -1,98 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { requiresKeyIdFor, validateProviders } from './registry.js';
const def = (overrides = {}) => ({
id: 'mapillary',
name: 'Mapillary',
label: 'MAPILLARY',
requiresKeyId: 'mapillary',
pickPrefix: 'mly:',
colors: { coverage: '#05cb63' },
credit: { html: 'Mapillary' },
create: () => ({}),
...overrides,
});
test('a valid list is frozen in registration order', () => {
const list = validateProviders([
def(),
def({ id: 'panoramax', pickPrefix: 'pnx:', requiresKeyId: null }),
]);
assert.ok(Object.isFrozen(list));
assert.deepEqual(
list.map((p) => p.id),
['mapillary', 'panoramax'],
);
});
test('an empty list, duplicate ids and malformed ids are rejected', () => {
assert.throws(() => validateProviders([]), /at least one/);
assert.throws(() => validateProviders(null), /at least one/);
assert.throws(() => validateProviders([def(), def()]), /Duplicate/);
assert.throws(() => validateProviders([def({ id: 'Bad Id' })]), /slug/);
});
test('every required field is checked by name', () => {
for (const key of [
'name',
'label',
'pickPrefix',
'colors',
'credit',
'create',
])
assert.throws(
() => validateProviders([def({ [key]: undefined })]),
new RegExp(`lacks ${key}`),
key,
);
assert.throws(() => validateProviders([def({ create: 1 })]), /create/);
assert.throws(() => validateProviders([def({ credit: {} })]), /credit/);
assert.throws(
() => validateProviders([def({ colors: { pano: '#f0f' } })]),
/coverage colour/,
);
assert.throws(
() => validateProviders([def({ pickPrefix: '' })]),
/pick prefix/,
);
});
test('overlapping pick prefixes are rejected either way round', () => {
assert.throws(
() => validateProviders([def(), def({ id: 'other', pickPrefix: 'mly:x' })]),
/overlaps/,
);
assert.throws(
() =>
validateProviders([def({ pickPrefix: 'mly:x' }), def({ id: 'other' })]),
/overlaps/,
);
assert.doesNotThrow(() =>
validateProviders([def(), def({ id: 'other', pickPrefix: 'ml:' })]),
);
});
test('requiresKeyIdFor is the shared key, or null once providers differ', () => {
assert.equal(requiresKeyIdFor([def()]), 'mapillary');
assert.equal(
requiresKeyIdFor([def(), def({ id: 'b', pickPrefix: 'b:' })]),
'mapillary',
);
assert.equal(
requiresKeyIdFor([
def(),
def({ id: 'b', pickPrefix: 'b:', requiresKeyId: null }),
]),
null,
);
assert.equal(
requiresKeyIdFor([
def(),
def({ id: 'b', pickPrefix: 'b:', requiresKeyId: 'google-maps' }),
]),
null,
);
assert.equal(requiresKeyIdFor([def({ requiresKeyId: null })]), null);
});
+19 -12
View File
@@ -1,30 +1,37 @@
import * as Cesium from 'cesium';
import { STREET_LEVEL_LAYER_ID } from './policy.js';
import { POSITION_PICK_ID, STREET_LEVEL_LAYER_ID } from './policy.js';
import { PICK_PREFIX } from './providers/mapillary/policy.js';
import { sequenceIdFromPick } from './providers/mapillary/coverage.js';
/** Hover picks at most ~8 times a second (like the CCTV layer), never per frame. */
const HOVER_PICK_INTERVAL_MS = 120;
/** One click/hover handler for every provider's coverage and image cones. */
/** One click/hover handler for coverage lines, image cones and the marker. */
export function createSelection({ state, parts }) {
const { picking, input } = state.services;
function ownsPick(pickedId) {
return parts.router.ownsPick(pickedId);
/** Lines and cones (`mly:`) and the position marker are this layer's. */
function ownsPick(id) {
return (
typeof id === 'string' &&
(id === POSITION_PICK_ID || id.startsWith(PICK_PREFIX.root))
);
}
/** A line selects its sequence; a cone opens its image. */
function onClick(click) {
const viewer = state.viewer;
if (!viewer || !state.enabled) return;
if (!viewer || !state.enabled || !state.providerOn) return;
if (input?.isPointerFree && !input.isPointerFree()) return;
const picked = viewer.scene.pick(click.position);
const id = picking?.resolvePickId
? picking.resolvePickId(picked)
: picked?.id;
const route = parts.router.resolve(id);
if (!route?.instance) return;
const entry = state.providers.get(route.providerId);
if (!entry?.on) return;
route.instance.handlePick(route.id);
if (typeof id !== 'string') return;
const sequenceId = sequenceIdFromPick(id);
if (sequenceId) parts.sequences.select(sequenceId);
else if (id.startsWith(PICK_PREFIX.image))
parts.openImage(id.slice(PICK_PREFIX.image.length));
}
/**
@@ -42,9 +49,9 @@ export function createSelection({ state, parts }) {
)
)
return;
if (!parts.hasSelectedSequence()) return;
if (!state.sequence.selectedId) return;
event.preventDefault();
parts.clearSequences();
parts.sequences.clearSelection();
}
let hoverQueued = false;
+11 -5
View File
@@ -58,17 +58,23 @@ function harness({ selected = false } = {}) {
const state = {
viewer,
enabled: true,
providerOn: true,
clickHandler: null,
services: { picking: null, input: null },
sequence: {
get selectedId() {
return sequences.selected ? 'seq-1' : null;
},
},
};
const selection = createSelection({
state,
parts: {
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
hasSelectedSequence: () => sequences.selected,
clearSequences() {
sequences.cleared++;
sequences.selected = false;
sequences: {
clearSelection() {
sequences.cleared++;
sequences.selected = false;
},
},
},
});
+46 -7
View File
@@ -7,9 +7,6 @@ import { FILTER_DEFAULT } from './policy.js';
export function freshStreet() {
return {
open: false,
providerId: null,
providerName: null,
providerLabel: null,
imageId: null,
position: null,
bearing: null,
@@ -25,7 +22,33 @@ export function freshStreet() {
};
}
/** Mutable core state, created once per layer instance. */
/** Coverage bookkeeping, before the first refresh. */
export function freshCoverage() {
return {
zoom: null,
/** Current zoom's tiles by `z/x/y` key. */
tiles: new Map(),
/** Previous zoom's tiles, kept on screen until replacements land. */
stale: new Map(),
staleTimer: null,
/** Tile key → its request's controller; any entry means LOADING. */
pending: new Map(),
lastError: null,
/**
* Why tile requests are paused, or null: 'status' until the key status
* answers, 'no-key', 'rejected' (until the layer goes off) or
* 'rate-limited' (until `blockTimer` fires).
*/
blocked: 'status',
blockTimer: null,
debounceTimer: null,
removeCameraListener: null,
terrainReady: null,
hint: '',
};
}
/** Mutable layer state, created once per layer instance. */
export function createState({ services }) {
return {
services,
@@ -35,10 +58,10 @@ export function createState({ services }) {
destroyed: false,
listeners: new Set(),
notify: null,
/** Shared imagery filter, in the stored (relative-days) form. */
/** The share link's `mapillary` switch: off draws nothing. */
providerOn: true,
/** Imagery filter, in the stored (relative-days) form. */
filter: { ...FILTER_DEFAULT },
/** @type {Map<string, {def: object, instance: object, on: boolean}>} */
providers: new Map(),
street: {
host: null,
@@ -47,7 +70,23 @@ export function createState({ services }) {
...freshStreet(),
},
coverage: freshCoverage(),
sequence: {
selectedId: null,
images: [],
/** Recent sequences' thinned images, by sequence id. */
cache: new Map(),
collection: null,
loading: false,
abort: null,
},
marker: { collection: null, billboard: null },
clickHandler: null,
};
}
/** Coverage, cones and lookups run only while the layer and its switch are on. */
export function isActive(state) {
return state.enabled && state.providerOn;
}
+34 -84
View File
@@ -1,4 +1,4 @@
/** Web-Mercator tile math, without Cesium, shared by the browser layer and the server executor. */
/** Web-Mercator tile math for Street Level coverage, without Cesium. */
const MAX_LAT = 85.05112878;
@@ -77,78 +77,42 @@ export function normalizeBbox(input) {
}
/**
* Squared distance (in tiles) from tile (x, y)'s centre to the segment
* `from` → `to` at zoom z, measured the short way round the date line.
*/
function segmentDistance2(tile, from, to, z) {
const n = 2 ** z;
const px = tile.x + 0.5;
const py = tile.y + 0.5;
const ax = tileXAt(from.lon, z);
const ay = tileYAt(from.lat, z);
let bx = to ? tileXAt(to.lon, z) : ax;
const by = to ? tileYAt(to.lat, z) : ay;
if (bx - ax > n / 2) bx -= n;
else if (ax - bx > n / 2) bx += n;
let best = Infinity;
for (const shift of [-n, 0, n]) {
const x = px + shift;
const vx = bx - ax;
const vy = by - ay;
const len2 = vx * vx + vy * vy;
const t = len2
? Math.max(0, Math.min(1, ((x - ax) * vx + (py - ay) * vy) / len2))
: 0;
const dx = x - (ax + t * vx);
const dy = py - (ay + t * vy);
best = Math.min(best, dx * dx + dy * dy);
}
return best;
}
/**
* Tiles at zoom z covering a bbox (west > east crosses the date line), from
* the centre outwards, capped at `limit`. With `focus`, tiles rank by distance
* from the segment `from` (ground under the camera) to `to` (screen centre):
* a tilted view's box centre can sit kilometres ahead of both.
* @param {{limit?: number, focus?: {from: {lon: number, lat: number}, to?: {lon: number, lat: number}|null}|null}} [options]
* Tiles at zoom z covering a [west, south, east, north] box (west > east
* crosses the date line), nearest to `from` first (default: the box centre),
* capped at `limit`. Distances are measured the short way round ±180°.
* @param {{limit?: number, from?: {lon: number, lat: number}|null}} [options]
* @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}}
*/
export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) {
export function tilesForBbox(bbox, z, { limit = Infinity, from = null } = {}) {
const [west, south, east, north] = Array.isArray(bbox)
? bbox.map(Number)
: [];
const across = west > east;
// A box across the date line is split at ±180°.
const halves = across
? [
[west, south, 180, north],
[-180, south, east, north],
]
: [bbox];
const tiles = halves
.map(normalizeBbox)
.filter(Boolean)
.flatMap((box) => tileGrid(box, z));
const n = 2 ** z;
const tiles = [];
let centre;
if (Array.isArray(bbox) && Number(bbox[0]) > Number(bbox[2])) {
// A box across the date line comes as west > east: split it at ±180°.
const [west, south, east, north] = bbox.map(Number);
for (const half of [
[west, south, 180, north],
[-180, south, east, north],
]) {
const box = normalizeBbox(half);
if (box) tiles.push(...tileGrid(box, z).tiles);
}
centre = {
x: ((lonToTileX(west, z) + lonToTileX(east, z) + n) / 2) % n,
y: (latToTileY(north, z) + latToTileY(south, z)) / 2,
};
} else {
const box = normalizeBbox(bbox);
if (!box) return { tiles: [], truncated: false, total: 0 };
const grid = tileGrid(box, z);
tiles.push(...grid.tiles);
centre = { x: (grid.x0 + grid.x1) / 2, y: (grid.y0 + grid.y1) / 2 };
}
const distance2 = focus?.from
? (tile) => segmentDistance2(tile, focus.from, focus.to, z)
: (tile) => {
// Measured the short way round the date line.
const dx = Math.abs(tile.x - centre.x);
return Math.min(dx, n - dx) ** 2 + (tile.y - centre.y) ** 2;
};
const centre = from || {
lon: wrapLon(west + (east - west + (across ? 360 : 0)) / 2),
lat: (south + north) / 2,
};
const cx = tileXAt(wrapLon(centre.lon), z);
const cy = tileYAt(centre.lat, z);
// Each tile's distance is computed once; ties keep their row order.
const ranked = tiles
.map((tile) => ({ tile, d: distance2(tile) }))
.map((tile) => {
const dx = Math.abs(tile.x + 0.5 - cx);
const d = Math.min(dx, n - dx) ** 2 + (tile.y + 0.5 - cy) ** 2;
return { tile, d };
})
.sort((a, b) => a.d - b.d)
.map(({ tile }) => tile);
const truncated = ranked.length > limit;
@@ -159,7 +123,7 @@ export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) {
};
}
/** The tiles at zoom z in a normalized box, row by row, and their range. */
/** The tiles at zoom z in a normalized box, row by row. */
function tileGrid(box, z) {
const x0 = lonToTileX(box.west, z);
const x1 = lonToTileX(box.east, z);
@@ -168,7 +132,7 @@ function tileGrid(box, z) {
const tiles = [];
for (let y = y0; y <= y1; y++)
for (let x = x0; x <= x1; x++) tiles.push({ x, y, z });
return { tiles, x0, x1, y0, y1 };
return tiles;
}
/** A vector-tile-local coordinate (0..extent) in tile (x, y, z) as [lon, lat]. */
@@ -190,17 +154,3 @@ export function coverageZoomForHeight(heightM) {
if (heightM > 1_800) return 13;
return 14;
}
/**
* Overview-point zoom (z0–5) above 60 km, where sequence tiles are too heavy;
* null below, where `coverageZoomForHeight` takes over.
*/
export function overviewZoomForHeight(heightM) {
if (!Number.isFinite(heightM) || heightM <= 60_000) return null;
if (heightM > 15_000_000) return 0;
if (heightM > 7_000_000) return 1;
if (heightM > 3_000_000) return 2;
if (heightM > 1_200_000) return 3;
if (heightM > 400_000) return 4;
return 5;
}
+22 -47
View File
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
coverageZoomForHeight,
overviewZoomForHeight,
latToTileY,
lonToTileX,
normalizeBbox,
@@ -74,16 +73,6 @@ test('coverageZoomForHeight steps from coarse to z14 as the camera descends', ()
assert.equal(coverageZoomForHeight(Number.NaN), null);
});
test('overviewZoomForHeight covers the globe above the sequence ceiling', () => {
assert.equal(overviewZoomForHeight(30_000), null);
assert.equal(overviewZoomForHeight(100_000), 5);
assert.equal(overviewZoomForHeight(900_000), 4);
assert.equal(overviewZoomForHeight(2_000_000), 3);
assert.equal(overviewZoomForHeight(5_000_000), 2);
assert.equal(overviewZoomForHeight(10_000_000), 1);
assert.equal(overviewZoomForHeight(25_000_000), 0);
});
test('longitude 180 is the last column, so a world view keeps the eastern hemisphere', () => {
assert.equal(lonToTileX(180, 1), 1);
assert.equal(lonToTileX(179.99, 1), 1);
@@ -108,65 +97,51 @@ test('a box across the date line takes tiles on both sides of it, not the far si
assert.ok(limited.every((tile) => tile.x <= 8 || tile.x >= 247));
});
test('a box across the date line ranks the tiles at the line first, without and with a focus', () => {
test('a box across the date line ranks from the line, or from the camera across it', () => {
const z = 14;
const n = 2 ** z;
const bbox = [179.9, 0, -179.9, 0.1];
const key = (t) => `${t.x}/${t.y}`;
const keys = (list) => new Set(list.map(key));
const [north, south] = [latToTileY(0.1, z), latToTileY(0, z)];
const plain = tilesForBbox(bbox, z);
assert.equal(plain.total, 10 * (south - north + 1), '5 columns either side');
// The box centre is the date line itself, not the middle of the map: the
// middle two rows of the columns touching it come first, then the ring
// around them, all within two columns of the line.
const [mid1, mid2] = [(north + south - 1) / 2, (north + south + 1) / 2];
assert.deepEqual(
keys(plain.tiles.slice(0, 4)),
new Set([`${n - 1}/${mid1}`, `${n - 1}/${mid2}`, `0/${mid1}`, `0/${mid2}`]),
);
// columns touching it come first, then the ones beside them.
for (const tile of plain.tiles.slice(0, 4))
assert.ok([n - 1, 0].includes(tile.x), `${key(tile)} at the line`);
for (const tile of plain.tiles.slice(4, 12))
assert.ok(
[n - 2, n - 1, 0, 1].includes(tile.x),
`${key(tile)} near the line`,
);
// A line of sight across the date line just north of the equator: the two
// tiles it crosses come first, then the two in the row below them.
// From a camera metres from the line: its tile, then the one across it.
const row = latToTileY(0.05, z);
const from = { lon: 179.99, lat: 0.05 };
const to = { lon: -179.99, lat: 0.05 };
for (const focus of [
{ from, to },
{ from: to, to: from },
]) {
const ranked = tilesForBbox(bbox, z, { focus }).tiles;
assert.deepEqual(
keys(ranked.slice(0, 2)),
new Set([`${n - 1}/${row}`, `0/${row}`]),
);
assert.deepEqual(
keys(ranked.slice(2, 4)),
new Set([`${n - 1}/${row + 1}`, `0/${row + 1}`]),
);
}
const { south: rowSouth, north: rowNorth } = tileBounds(0, row, z);
const lat = (rowSouth + rowNorth) / 2;
const west = tilesForBbox(bbox, z, { from: { lon: 179.999, lat } });
assert.deepEqual(west.tiles.slice(0, 2).map(key), [
`${n - 1}/${row}`,
`0/${row}`,
]);
const east = tilesForBbox(bbox, z, { from: { lon: -179.999, lat } });
assert.deepEqual(east.tiles.slice(0, 2).map(key), [
`0/${row}`,
`${n - 1}/${row}`,
]);
});
test('a focus ranks tiles along the line of sight, not from the box centre', () => {
test('ranking from the camera keeps the ground under it, not the box centre', () => {
// A box that runs far north of the camera, as a tilted view's does: the
// box centre is kilometres from both the camera and the screen centre.
// box centre is kilometres from the camera.
const from = { lon: -121.4944, lat: 38.5816 };
const to = { lon: -121.4944, lat: 38.596 };
const bbox = [-121.6, 38.57, -121.38, 38.9];
const z = 14;
const key = (t) => `${t.x}/${t.y}`;
const under = `${lonToTileX(from.lon, z)}/${latToTileY(from.lat, z)}`;
const centre = `${lonToTileX(to.lon, z)}/${latToTileY(to.lat, z)}`;
const plain = tilesForBbox(bbox, z, { limit: 9 }).tiles.map(key);
assert.equal(plain.includes(under), false, 'the old ranking drops them');
const ranked = tilesForBbox(bbox, z, { limit: 9, focus: { from, to } });
const keys = ranked.tiles.map(key);
assert.ok(keys.includes(under), 'the tile under the camera');
assert.ok(keys.includes(centre), 'the tile at the centre of the screen');
assert.equal(plain.includes(under), false, 'the box centre drops it');
const ranked = tilesForBbox(bbox, z, { limit: 9, from });
assert.equal(key(ranked.tiles[0]), under, 'the tile under the camera first');
assert.equal(ranked.total > 9, true);
});
-74
View File
@@ -1,74 +0,0 @@
import { COLORS } from './policy.js';
/**
* @typedef {object} ProviderSnapshot
* @property {string} id
* @property {string} name
* @property {string} label
* @property {boolean} on
* @property {boolean|null} configured null until the status call answers
* @property {boolean} keyRequired no usable key: missing or rejected
* @property {boolean} [keyRejected] the provider refused the configured key
* @property {string|null} requiresKeyId
* @property {boolean} loading
* @property {number} count
* @property {string} hint
* @property {string|null} error
* @property {string} color The source's one colour.
*/
/**
* Totals over switched-on providers. Key-gated only when every one lacks its
* key; `keyRejected` when that is because a key was refused.
* @param {Array<ProviderSnapshot>} providers
*/
export function summarizeCoverage(providers) {
const active = providers.filter((p) => p.on);
return {
count: active.reduce((sum, p) => sum + (p.count || 0), 0),
loading: active.some((p) => p.loading),
hint: active.find((p) => p.hint)?.hint || '',
error: active.find((p) => p.error)?.error || null,
keyRequired:
active.length > 0 && active.every((p) => p.keyRequired === true),
keyRejected:
active.length > 0 &&
active.every((p) => p.keyRequired === true) &&
active.some((p) => p.keyRejected === true),
};
}
/**
* The panel snapshot from core state and provider snapshots. The legend has
* one swatch per active source, then the shared selection colour.
* @param {{enabled: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
*/
export function composeUIState({
enabled,
filter,
providers,
street,
sequence,
}) {
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)
legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected });
return {
enabled,
keyRequired,
keyRejected,
filter: { ...filter },
providers: providers.map((p) => ({ ...p })),
coverage,
legend,
sequence: { ...sequence },
street: { ...street },
};
}
-139
View File
@@ -1,139 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeUIState, summarizeCoverage } from './uiState.js';
import { COLORS } from './policy.js';
import { providerSnapshot } from '../../testSupport/streetLevelFakes.mjs';
const provider = (overrides = {}) =>
providerSnapshot({ count: 10, ...overrides });
const base = {
enabled: true,
filter: { pano: 'all', sinceDays: 0 },
street: { open: false },
sequence: { selectedId: null, images: 0, loading: false },
};
test('counts add up across active providers only', () => {
const ui = composeUIState({
...base,
providers: [
provider(),
provider({ id: 'panoramax', name: 'Panoramax', count: 5, on: false }),
provider({ id: 'kartaview', name: 'KartaView', count: 7, loading: true }),
],
});
assert.equal(ui.coverage.count, 17);
assert.equal(ui.coverage.loading, true);
});
test('the layer is key-gated only when every switched-on provider lacks its key', () => {
const gated = composeUIState({
...base,
providers: [provider({ keyRequired: true })],
});
assert.equal(gated.keyRequired, true);
const mixed = composeUIState({
...base,
providers: [
provider({ keyRequired: true }),
provider({ id: 'panoramax', name: 'Panoramax', requiresKeyId: null }),
],
});
assert.equal(mixed.keyRequired, false, 'a keyless provider still draws');
const offOnly = composeUIState({
...base,
providers: [provider({ keyRequired: true, on: false })],
});
assert.equal(offOnly.keyRequired, false, 'nothing switched on to gate');
});
test('the legend is one swatch per active source, in its colour, then Selected', () => {
const single = composeUIState({ ...base, providers: [provider()] });
assert.deepEqual(single.legend, [
{ key: 'mapillary', label: 'Mapillary', color: '#05cb63' },
{ key: 'selected', label: 'Selected', color: COLORS.selected },
]);
const two = composeUIState({
...base,
providers: [
provider(),
provider({ id: 'panoramax', name: 'Panoramax', color: '#a66bff' }),
],
});
assert.deepEqual(
two.legend.map((entry) => [entry.label, entry.color]),
[
['Mapillary', '#05cb63'],
['Panoramax', '#a66bff'],
['Selected', COLORS.selected],
],
);
const oneOff = composeUIState({
...base,
providers: [
provider(),
provider({ id: 'panoramax', name: 'Panoramax', on: false }),
],
});
assert.deepEqual(
oneOff.legend.map((entry) => entry.key),
['mapillary', 'selected'],
);
assert.deepEqual(
composeUIState({ ...base, providers: [provider({ on: false })] }).legend,
[],
);
});
test('hint and error come from the first active provider that has one', () => {
const ui = composeUIState({
...base,
providers: [
provider({ hint: '', error: null }),
provider({ id: 'b', name: 'B', hint: 'Look down', error: 'boom' }),
],
});
assert.equal(ui.coverage.hint, 'Look down');
assert.equal(ui.coverage.error, 'boom');
assert.deepEqual(ui.filter, base.filter);
assert.notEqual(ui.filter, base.filter, 'snapshot copies the filter');
});
test('summarizeCoverage is what getStats reports, without building a snapshot', () => {
assert.deepEqual(
summarizeCoverage([
provider({ count: 4, hint: 'Look down' }),
provider({ id: 'b', name: 'B', count: 6, loading: true, error: 'boom' }),
provider({ id: 'c', name: 'C', count: 99, on: false, keyRequired: true }),
]),
{
count: 10,
loading: true,
hint: 'Look down',
error: 'boom',
keyRequired: false,
keyRejected: false,
},
);
assert.equal(summarizeCoverage([]).keyRequired, false);
});
test('a rejected key gates like a missing one and is named as rejected', () => {
const rejected = provider({
keyRequired: true,
keyRejected: true,
error: 'Mapillary rejected MAPILLARY_CLIENT_TOKEN',
});
const summary = summarizeCoverage([rejected]);
assert.equal(summary.keyRequired, true);
assert.equal(summary.keyRejected, true);
assert.equal(summary.error, 'Mapillary rejected MAPILLARY_CLIENT_TOKEN');
const state = composeUIState({ ...base, providers: [rejected] });
assert.equal(state.keyRequired, true);
assert.equal(state.keyRejected, true);
// A provider that is merely missing its key is not "rejected".
assert.equal(
summarizeCoverage([provider({ keyRequired: true })]).keyRejected,
false,
);
});
+11 -73
View File
@@ -10,16 +10,6 @@ export function metresBetween(a, b) {
);
}
/**
* Run `task(deadline)` when idle, within `timeout` ms; without idle callbacks
* (Node) it runs a frame later with a null deadline.
*/
export function whenIdle(task, timeout) {
if (typeof globalThis.requestIdleCallback === 'function')
globalThis.requestIdleCallback(task, { timeout });
else setTimeout(() => task(null), 16);
}
/**
* Ellipsoidal height (m) of the globe terrain under the camera; null where
* the globe is hidden (Google 3D) or not yet loaded.
@@ -63,13 +53,14 @@ function groundHit(camera, point, ellipsoid, maxRange) {
/**
* Visible [west, south, east, north] degrees, or null. Only screen rays that
* hit count, so a horizon cannot inflate the box to the world.
* `groundHeight` raises the ellipsoid to the ground, `maxRange` (m) drops
* near-horizon hits, and `nearRange` (m) always includes the ground around
* the camera.
* `groundHeight` raises the ellipsoid to the ground (rays to the bare
* ellipsoid under Denver land kilometres too far out) and `maxRange` (m)
* drops near-horizon hits; with it, too few hits give null rather than
* Cesium's view rectangle, which can span the horizon.
*/
export function visibleBbox(
viewer,
{ grid = 5, groundHeight = null, maxRange = null, nearRange = null } = {},
{ grid = 5, groundHeight = null, maxRange = null } = {},
) {
const scene = viewer?.scene;
const camera = viewer?.camera;
@@ -92,27 +83,6 @@ export function visibleBbox(
}
}
}
// The ground around the camera always counts: a street-level view's screen
// rows skip from the horizon to the first few metres.
const nadir = camera.positionCartographic;
if (Number.isFinite(nearRange) && nearRange > 0 && nadir) {
const dLat = nearRange / 111_320;
const dLon = dLat / Math.max(0.05, Math.cos(nadir.latitude));
const lat = Cesium.Math.toDegrees(nadir.latitude);
const lon = Cesium.Math.toDegrees(nadir.longitude);
for (const [dx, dy] of [
[-1, -1],
[-1, 1],
[1, -1],
[1, 1],
])
hits.push(
Cesium.Cartographic.fromDegrees(
lon + dx * dLon,
Math.max(-85, Math.min(85, lat + dy * dLat)),
),
);
}
if (hits.length >= 4) {
let west = Infinity;
let south = Infinity;
@@ -145,6 +115,7 @@ export function visibleBbox(
}
if (west !== east && north - south > 0) return [west, south, east, north];
}
if (Number.isFinite(maxRange)) return null;
const rectangle = camera.computeViewRectangle?.(scene.globe?.ellipsoid);
if (!rectangle) return null;
return [
@@ -166,50 +137,17 @@ export function viewCentre(viewer) {
if (lon > 180) lon -= 360;
return { lat: (bbox[1] + bbox[3]) / 2, lon };
}
const carto = viewer.camera?.positionCartographic;
if (!carto) return null;
return {
lat: Cesium.Math.toDegrees(carto.latitude),
lon: Cesium.Math.toDegrees(carto.longitude),
};
return cameraNadir(viewer);
}
/**
* Ground the camera looks at, for ranking tiles: `nadir` under the camera and
* `ahead` at the screen centre (null on a miss or beyond `maxRange`).
*/
export function viewFocus(
viewer,
{ groundHeight = null, maxRange = null } = {},
) {
const camera = viewer?.camera;
const carto = camera?.positionCartographic;
/** The point on the ground under the camera, as {lon, lat} degrees. */
export function cameraNadir(viewer) {
const carto = viewer?.camera?.positionCartographic;
if (!carto) return null;
const nadir = {
return {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
};
const canvas = viewer.scene?.canvas;
const width = canvas?.clientWidth || canvas?.width || 0;
const height = canvas?.clientHeight || canvas?.height || 0;
let ahead = null;
if (width > 0 && height > 0) {
const hit = groundHit(
camera,
new Cesium.Cartesian2(width / 2, height / 2),
raisedEllipsoid(
viewer.scene?.globe?.ellipsoid || Cesium.Ellipsoid.WGS84,
groundHeight,
),
maxRange,
);
if (hit)
ahead = {
lon: Cesium.Math.toDegrees(hit.longitude),
lat: Cesium.Math.toDegrees(hit.latitude),
};
}
return { nadir, ahead };
}
/**
+23 -12
View File
@@ -2,11 +2,11 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
cameraNadir,
createHorizonCull,
groundUnderCamera,
metresBetween,
viewCentre,
viewFocus,
visibleBbox,
} from './view.js';
import { rayCamera } from '../../testSupport/streetLevelFakes.mjs';
@@ -113,20 +113,22 @@ test('a tilted street view over high ground boxes the streets it looks at, not t
// the box is 20 km wide and starts past the street at the screen centre.
assert.ok(unranged[2] - unranged[0] > 0.2);
assert.ok(unranged[1] > ahead, 'the old box missed the street in view');
const options = { groundHeight: 1610, maxRange: 6000, nearRange: 1000 };
const options = { groundHeight: 1610, maxRange: 6000 };
const [west, south, east, north] = visibleBbox(view, options);
assert.ok(north - south < 0.08 && east - west < 0.08, 'a street-sized box');
assert.ok(south < 39.74 && north > ahead, 'camera and screen centre inside');
const focus = viewFocus(view, options);
assert.ok(Math.abs(focus.nadir.lat - 39.74) < 1e-9);
assert.ok(south < ahead && north > ahead, 'the screen centre is inside');
assert.ok(
Math.abs(focus.ahead.lat - ahead) < 0.002,
'centre ray on the street',
south > 39.74,
'it starts ahead of the camera, where the view does',
);
assert.ok(west < -104.99 && east > -104.99);
const nadir = cameraNadir(view);
assert.ok(
Math.abs(nadir.lat - 39.74) < 1e-9 && Math.abs(nadir.lon + 104.99) < 1e-9,
);
assert.ok(Math.abs(focus.ahead.lon - -104.99) < 1e-6);
});
test('looking at the horizon from eye height still boxes the ground around the camera', () => {
test('looking at the horizon from eye height boxes the near ground, not the horizon', () => {
const view = pinhole({
lon: -121.4944,
lat: 38.5816,
@@ -137,10 +139,19 @@ test('looking at the horizon from eye height still boxes the ground around the c
const [west, south, east, north] = visibleBbox(view, {
groundHeight: 10,
maxRange: 2500,
nearRange: 1000,
});
assert.ok(north - 38.5816 > 0.008 && 38.5816 - south > 0.008);
assert.ok(east - -121.4944 > 0.008 && -121.4944 - west > 0.008);
assert.ok(south > 38.5816 && south - 38.5816 < 0.001, 'from just ahead');
assert.ok(north - 38.5816 < 2500 / 111_000, 'no further than the range');
assert.ok(west < -121.4944 && east > -121.4944);
// Looking at the sky, nothing is in range: no box, never the horizon's.
const sky = pinhole({
lon: -121.4944,
lat: 38.5816,
ground: 10,
agl: 2,
pitch: 60,
});
assert.equal(visibleBbox(sky, { groundHeight: 10, maxRange: 2500 }), null);
});
test('metresBetween measures the short way round the date line', () => {
+43 -116
View File
@@ -1,31 +1,25 @@
import { freshStreet } from './state.js';
/**
* Owns the panel's viewer element and the provider adapter mounted in it.
* Adapter poses update `state.street` and the marker.
* Owns the panel's viewer element and the photo viewer mounted in it (the
* MapillaryJS adapter, or a stand-in). Its poses update `state.street` and
* the marker; MapillaryJS loads on the first open.
*/
export function createViewerHost({ state, parts }) {
/** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */
let active = null;
/** @type {{id: string, promise: Promise<object>}|null} */
export function createViewerHost({ state, parts, adapter }) {
/** Set once mounted, until `unmount`. */
let unsubscribe = null;
/** The mount in flight, shared by concurrent opens. */
let mounting = null;
let openSeq = 0;
/** Prewarmed, inactive adapters; each holds a live viewer (WebGL context) until `unmount`. */
const warmed = new Map();
function notify() {
state.notify?.();
}
function providerEntry(providerId) {
return state.providers.get(providerId) || null;
}
function applyPose(pose) {
if (!active || !state.street.open || pose.providerId !== active.id) return;
if (!unsubscribe || !state.street.open) return;
const previousSequence = state.street.sequenceId;
Object.assign(state.street, {
providerId: pose.providerId,
imageId: pose.imageId,
position: pose.position ? { ...pose.position } : null,
bearing: Number.isFinite(pose.bearing) ? pose.bearing : null,
@@ -46,63 +40,43 @@ export function createViewerHost({ state, parts }) {
}
function selectCurrentSequence() {
const { sequenceId, providerId } = state.street;
const entry = providerEntry(providerId);
if (!sequenceId || !entry?.instance.selectSequence || !state.enabled)
return;
const current = entry.instance.sequenceStats?.()?.selectedId;
if (current !== sequenceId) entry.instance.selectSequence(sequenceId);
const { sequenceId } = state.street;
if (!sequenceId || !state.enabled) return;
if (state.sequence.selectedId !== sequenceId)
parts.sequences.select(sequenceId);
}
/**
* Mount a provider's adapter. It becomes `active` only on success, so a
* failed mount is retried on the next open; concurrent opens share one.
* Mount the viewer. It counts as mounted only on success, so a failed
* mount is retried on the next open; concurrent opens share one.
*/
function mount(entry) {
if (active?.id === entry.def.id) return Promise.resolve(active.adapter);
if (mounting?.id === entry.def.id) return mounting.promise;
if (active) {
active.adapter.close();
active.unsubscribe();
active.adapter.unmount();
active = null;
}
const adapter = entry.instance.viewer;
function mount() {
if (unsubscribe) return Promise.resolve();
if (mounting) return mounting;
const promise = (async () => {
await adapter.mount(state.street.host);
if (mounting?.promise !== promise) {
// Unmounted (layer off, provider switched) while loading. A newer
// mount of this provider shares the adapter and its viewer: tearing
// it down here would leave that mount active with no viewer.
const reused =
active?.id === entry.def.id || mounting?.id === entry.def.id;
if (!reused) adapter.unmount();
if (mounting !== promise) {
// Unmounted (layer off) while loading. A newer mount shares the
// viewer: tearing it down here would leave that mount without one.
if (!mounting && !unsubscribe) adapter.unmount();
throw new Error('Street-level viewer was closed');
}
// Listen only once current: an outdated mount releasing the same
// `applyPose` would otherwise unhook the mount that replaced it.
const unsubscribe = adapter.onPose(applyPose);
active = { id: entry.def.id, adapter, unsubscribe };
warmed.delete(entry.def.id);
unsubscribe = adapter.onPose(applyPose);
adapter.setRenderMode?.(state.street.renderMode);
return adapter;
})();
mounting = { id: entry.def.id, promise };
promise.then(
() => {
if (mounting?.promise === promise) mounting = null;
},
() => {
if (mounting?.promise === promise) mounting = null;
},
);
mounting = promise;
const settle = () => {
if (mounting === promise) mounting = null;
};
promise.then(settle, settle);
return promise;
}
/** Open an image: true once its first pose is in, false if it failed or was overtaken. */
async function open(providerId, imageId) {
const entry = providerEntry(providerId);
if (!entry || !imageId) return false;
async function open(imageId) {
if (!imageId) return false;
if (!state.street.host) {
// A nearest-image lookup may have set it; nothing is loading now.
state.street.loading = false;
@@ -114,17 +88,10 @@ export function createViewerHost({ state, parts }) {
const current = () => seq === openSeq;
// Claimed now, honoured after loading only if nothing newer took the camera.
const ticket = parts.framing.begin();
Object.assign(state.street, {
loading: true,
error: null,
open: true,
providerId,
providerName: entry.def.name,
providerLabel: entry.def.label,
});
Object.assign(state.street, { loading: true, error: null, open: true });
notify();
try {
const adapter = await mount(entry);
await mount();
if (!current()) return false;
await adapter.open(String(imageId));
if (!current()) return false;
@@ -141,11 +108,11 @@ export function createViewerHost({ state, parts }) {
return true;
}
/** Close the image and stop any framing flight; the adapter stays warm. */
/** Close the image and stop any framing flight; the viewer stays mounted. */
function close() {
openSeq++;
parts.framing.cancel();
active?.adapter.close();
if (unsubscribe) adapter.close();
Object.assign(state.street, freshStreet());
parts.marker.clear();
notify();
@@ -158,7 +125,7 @@ export function createViewerHost({ state, parts }) {
function resize() {
try {
active?.adapter.resize();
if (unsubscribe) adapter.resize();
} catch {
/* no-op */
}
@@ -167,61 +134,21 @@ export function createViewerHost({ state, parts }) {
function setRenderMode(mode) {
state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox';
try {
active?.adapter.setRenderMode?.(state.street.renderMode);
if (unsubscribe) adapter.setRenderMode?.(state.street.renderMode);
} catch {
/* adapter not ready */
}
notify();
}
/** Stand active providers' viewers up ahead of the first image. */
async function prewarm(entries) {
const host = state.street.host;
if (!host || !state.enabled) return;
for (const entry of entries) {
const adapter = entry.instance.viewer;
if (!adapter.prewarm || !entry.on) 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();
else warmed.set(entry.def.id, adapter);
}
/** Close the image and destroy the viewer (and its WebGL context). */
function unmount() {
close();
mounting = null;
unsubscribe?.();
unsubscribe = null;
adapter.unmount();
}
/** Tear down every viewer, or only `providerId`'s (active or prewarmed). */
function unmount(providerId) {
if (
!providerId ||
active?.id === providerId ||
mounting?.id === providerId
) {
close();
mounting = null;
if (active) {
active.unsubscribe();
active.adapter.unmount();
active = null;
}
}
for (const [id, adapter] of warmed) {
if (providerId && id !== providerId) continue;
warmed.delete(id);
adapter.unmount();
}
}
return {
attach,
open,
close,
resize,
setRenderMode,
prewarm,
unmount,
};
return { attach, open, close, resize, setRenderMode, unmount };
}
+35 -97
View File
@@ -33,27 +33,16 @@ const { createMapillaryViewer } =
/**
* A viewer host over `adapter`. `marker` records where the position marker
* was put (null once cleared), `framing` counts framing calls and
* `selected` every sequence the provider was asked to select.
* `selected` every sequence the layer was asked to select.
*/
function harness(adapter) {
const state = {
enabled: true,
notify() {},
providers: new Map(),
street: { host: {}, renderMode: 'letterbox' },
sequence: { selectedId: null },
};
const def = { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' };
const selected = [];
let selectedId = null;
const instance = {
viewer: adapter,
selectSequence(id) {
selected.push(id);
selectedId = id;
},
sequenceStats: () => ({ selectedId }),
};
state.providers.set('mapillary', { def, instance });
const framing = { begun: 0, started: 0, cancelled: 0 };
const marker = [];
const parts = {
@@ -67,17 +56,23 @@ function harness(adapter) {
frame: (ticket) => ticket && framing.started++,
cancel: () => framing.cancelled++,
},
sequences: {
select(id) {
selected.push(id);
state.sequence.selectedId = id;
},
},
};
return {
state,
framing,
marker,
selected,
/** Esc on the map: the provider drops its selected sequence. */
/** Esc on the map: the selected sequence is dropped. */
clearSequence() {
selectedId = null;
state.sequence.selectedId = null;
},
host: createViewerHost({ state, parts }),
host: createViewerHost({ state, parts, adapter }),
};
}
@@ -102,7 +97,6 @@ function fakeAdapter({ failMounts = 0, gate = null, openGate = null } = {}) {
/** A pose event from the library, as MapillaryJS fires them. */
emitPose(id, overrides = {}) {
emit?.({
providerId: 'mapillary',
imageId: id,
sequenceId: `seq-${id}`,
position: { lon: 1, lat: 2 },
@@ -139,10 +133,10 @@ function fakeAdapter({ failMounts = 0, gate = null, openGate = null } = {}) {
test('a failed mount is retried on the next open instead of being cached', async () => {
const adapter = fakeAdapter({ failMounts: 1 });
const { state, host } = harness(adapter);
await host.open('mapillary', 'a');
await host.open('a');
assert.equal(state.street.error, 'library failed to load');
assert.equal(adapter.calls.listeners, 0, 'the pose listener was released');
await host.open('mapillary', 'b');
await host.open('b');
assert.equal(state.street.error, null);
assert.equal(adapter.calls.mount, 2);
assert.deepEqual(adapter.calls.open, ['b']);
@@ -153,7 +147,7 @@ test('a failed mount is retried on the next open instead of being cached', async
test('concurrent opens share one mount and one pose listener', async () => {
const adapter = fakeAdapter();
const { host } = harness(adapter);
await Promise.all([host.open('mapillary', 'a'), host.open('mapillary', 'b')]);
await Promise.all([host.open('a'), host.open('b')]);
assert.equal(adapter.calls.mount, 1);
assert.equal(adapter.calls.listeners, 1);
});
@@ -161,62 +155,22 @@ test('concurrent opens share one mount and one pose listener', async () => {
test('unmounting while a mount is in flight does not leave it active', async () => {
const adapter = fakeAdapter();
const { state, host } = harness(adapter);
const opening = host.open('mapillary', 'a');
const opening = host.open('a');
host.unmount();
await opening;
assert.equal(adapter.calls.listeners, 0);
assert.equal(adapter.calls.unmount, 1);
assert.equal(adapter.calls.unmount, 2, 'layer off, then the late mount');
assert.equal(state.street.open, false);
// Not left active: the next open mounts again.
await host.open('mapillary', 'b');
await host.open('b');
assert.equal(adapter.calls.mount, 2);
assert.equal(state.street.imageId, 'b');
});
test('a prewarmed viewer is released when the layer goes off, or at once if it went off mid-load', async () => {
const adapter = fakeAdapter();
let release = null;
adapter.prewarm = () =>
new Promise((resolve) => {
release = resolve;
});
const { state, host } = harness(adapter);
const entry = state.providers.get('mapillary');
entry.on = true;
// Warmed, then the layer is switched off: unmount releases the warm viewer.
const warming = host.prewarm([entry]);
release();
await warming;
assert.equal(adapter.calls.unmount, 0);
host.unmount();
assert.equal(adapter.calls.unmount, 1, 'the WebGL viewer is destroyed');
// Switched off while the library was still loading: released on arrival.
const late = host.prewarm([entry]);
state.enabled = false;
release();
await late;
assert.equal(adapter.calls.unmount, 2);
});
test('switching one provider off releases only its viewer', async () => {
const adapter = fakeAdapter();
adapter.prewarm = async () => {};
const { state, host } = harness(adapter);
const entry = state.providers.get('mapillary');
entry.on = true;
await host.prewarm([entry]);
host.unmount('panoramax');
assert.equal(adapter.calls.unmount, 0, 'another provider leaves it alone');
host.unmount('mapillary');
assert.equal(adapter.calls.unmount, 1);
});
test('a pose that arrives after the photo was closed does not bring it back', async () => {
const adapter = fakeAdapter();
const { state, host } = harness(adapter);
await host.open('mapillary', 'a');
await host.open('a');
host.close();
const before = { ...state.street };
// The library's `image` event for a photo that was still loading.
@@ -239,16 +193,16 @@ test('switching the layer off and on during a cold first open keeps the shared v
const gate = deferred();
const adapter = fakeAdapter({ gate });
const { state, host } = harness(adapter);
const first = host.open('mapillary', 'a'); // the library is still loading
const first = host.open('a'); // the library is still loading
host.unmount(); // layer off …
const second = host.open('mapillary', 'b'); // … on again, and a new click
const second = host.open('b'); // … on again, and a new click
gate.resolve();
await Promise.all([first, second]);
assert.equal(adapter.calls.unmount, 0, 'the outdated mount left it alone');
assert.equal(adapter.calls.unmount, 1, 'only the layer going off');
assert.equal(adapter.calls.listeners, 1);
assert.equal(state.street.error, null);
assert.equal(state.street.imageId, 'b');
await host.open('mapillary', 'c');
await host.open('c');
assert.equal(state.street.error, null, 'later opens are not stuck');
assert.equal(state.street.imageId, 'c');
});
@@ -258,7 +212,7 @@ const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
test('every pose the viewer reports moves the marker; only the open frames the camera', async () => {
const adapter = fakeAdapter();
const { host, marker, framing } = harness(adapter);
await host.open('mapillary', 'a');
await host.open('a');
assert.deepEqual(marker, [{ position: { lon: 1, lat: 2 }, bearing: 90 }]);
// The user walks on: the marker goes along, the globe camera stays.
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 180 });
@@ -276,7 +230,7 @@ test('a pose on another sequence selects it, but not while the image still loads
const openGate = deferred();
const adapter = fakeAdapter({ openGate });
const { state, host, selected, clearSequence } = harness(adapter);
const opening = host.open('mapillary', 'a');
const opening = host.open('a');
await settle();
assert.equal(state.street.loading, true, 'the image is downloading');
assert.equal(state.street.sequenceId, 'seq-a', 'its pose is in');
@@ -299,7 +253,7 @@ test('a pose on another sequence selects it, but not while the image still loads
test('closing the photo clears the marker', async () => {
const adapter = fakeAdapter();
const { host, marker } = harness(adapter);
await host.open('mapillary', 'a');
await host.open('a');
assert.notEqual(marker.at(-1), null);
host.close();
assert.equal(marker.at(-1), null);
@@ -309,7 +263,7 @@ test('an open still mounting when the photo is closed does nothing when the moun
const gate = deferred();
const adapter = fakeAdapter({ gate });
const { state, host, framing, selected } = harness(adapter);
const opening = host.open('mapillary', 'a'); // MapillaryJS still loading
const opening = host.open('a'); // MapillaryJS still loading
await settle();
host.close();
gate.resolve();
@@ -322,7 +276,7 @@ test('an open still mounting when the photo is closed does nothing when the moun
[false, false, null],
);
// The mounted viewer serves the next open.
assert.equal(await host.open('mapillary', 'b'), true);
assert.equal(await host.open('b'), true);
assert.equal(state.street.imageId, 'b');
});
@@ -330,7 +284,7 @@ test('an image still opening when the photo is closed is neither framed nor sele
const openGate = deferred();
const adapter = fakeAdapter({ openGate });
const { state, host, framing, selected } = harness(adapter);
const opening = host.open('mapillary', 'a');
const opening = host.open('a');
await settle();
assert.deepEqual(adapter.calls.open, ['a'], 'the image is downloading');
host.close();
@@ -408,16 +362,16 @@ test('the Mapillary viewer survives the layer going off and on during its first
const { gate, viewers } = fakeLibrary();
const adapter = createMapillaryViewer({ source: { token: 't' } });
const { state, host } = harness(adapter);
const first = host.open('mapillary', 'img1');
const first = host.open('img1');
await Promise.resolve();
host.unmount();
const second = host.open('mapillary', 'img2');
const second = host.open('img2');
await Promise.resolve();
gate.resolve();
await Promise.all([first, second]);
assert.equal(state.street.error, null);
assert.equal(state.street.imageId, 'img2');
await host.open('mapillary', 'img3');
await host.open('img3');
assert.equal(state.street.error, null, 'later opens are not stuck');
assert.equal(state.street.imageId, 'img3');
assert.equal(viewers.live, 1);
@@ -439,20 +393,6 @@ test('a Mapillary viewer unmounted mid-download is never built; the next mount b
assert.equal(viewers.live, 0, 'nothing left holding a WebGL context');
});
test('a prewarm that gave up when the layer went off and on mid-download is followed by one that builds', async () => {
const { gate, viewers } = fakeLibrary();
const adapter = createMapillaryViewer({ source: { token: 't' } });
const element = {};
const first = adapter.prewarm(element);
adapter.unmount(); // layer off while MapillaryJS downloads
const second = adapter.prewarm(element); // layer on again
gate.resolve();
await Promise.all([first, second]);
assert.equal(viewers.created, 1, 'the second prewarm built the viewer');
assert.equal(viewers.live, 1);
adapter.unmount();
});
test('the Mapillary viewer leaves resizing to the panel, so a hidden one never asks for z=NaN', async () => {
const { gate, viewers } = fakeLibrary();
gate.resolve();
@@ -467,7 +407,7 @@ test('closing the photo stops sequence playback in the hidden viewer', async ()
gate.resolve();
const adapter = createMapillaryViewer({ source: { token: 't' } });
const { host } = harness(adapter);
await host.open('mapillary', 'img1');
await host.open('img1');
const [viewer] = viewers.instances;
assert.equal(viewer.playback.stops, 0);
host.close();
@@ -478,13 +418,11 @@ test('closing the photo stops sequence playback in the hidden viewer', async ()
test('closing the photo or switching the layer off stops its framing flight', async () => {
const adapter = fakeAdapter();
const { framing, host } = harness(adapter);
await host.open('mapillary', 'a');
await host.open('a');
assert.equal(framing.started, 1, 'the open framed the photo');
host.close();
assert.equal(framing.cancelled, 1, 'closed: the globe stops flying to it');
await host.open('mapillary', 'b');
host.unmount('panoramax');
assert.equal(framing.cancelled, 1, 'another provider leaves it alone');
await host.open('b');
host.unmount();
assert.equal(framing.cancelled, 2, 'layer off: likewise');
});
@@ -557,7 +495,7 @@ test('a photo asks for the camera when it starts opening and frames with that ti
const openGate = deferred();
const adapter = fakeAdapter({ openGate });
const { host, framing } = harness(adapter);
const opening = host.open('mapillary', 'a');
const opening = host.open('a');
assert.equal(framing.begun, 1, 'claimed before the download');
await settle();
assert.equal(framing.started, 0);
+33 -67
View File
@@ -1,83 +1,49 @@
/** Shared Street Level test stand-ins: provider snapshot, minimal provider, ray-casting camera. */
/** Shared Street Level test stand-ins: Mapillary source, photo viewer, ray-casting camera. */
import * as Cesium from 'cesium';
import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js';
const RAD = Math.PI / 180;
/** One provider as `getUIState().providers` lists it. */
export function providerSnapshot(overrides = {}) {
/**
* A Mapillary source for the layer: a configured key, empty tiles, and the
* given lookups; `calls` records tile requests.
*/
export function fakeMapillarySource(overrides = {}) {
const calls = { tiles: [] };
return {
id: 'mapillary',
name: 'Mapillary',
label: 'MAPILLARY',
on: true,
configured: true,
keyRequired: false,
requiresKeyId: 'mapillary',
loading: false,
count: 0,
hint: '',
error: null,
color: '#05cb63',
calls,
hasToken: () => true,
getStatus: async () => ({ configured: true }),
getTile: async (...args) => {
calls.tiles.push(args);
return new Uint8Array(0);
},
getSequenceImages: async () => [],
nearestImages: async () => [],
...overrides,
};
}
/**
* The smallest provider the core accepts. Mutate `stats` to change what
* coverageStats() answers; `calls`, `filters` and `context()` record use.
* A photo viewer in place of MapillaryJS: `calls` records use, and `open`
* reports a pose for the image (when `pose` is given) as the real one does.
*/
export function fakeStreetLevelProvider({
id = 'mapillary',
pickPrefix = 'mly:',
html = MAPILLARY_CREDIT_HTML,
nearestImage = async () => null,
} = {}) {
const calls = { activate: 0, deactivate: 0, mount: 0, open: [], unmount: 0 };
const filters = [];
const stats = {
count: 0,
zoom: null,
kind: null,
loading: false,
hint: '',
error: null,
keyRequired: false,
};
let context = null;
export function fakePhotoViewer({ pose = null } = {}) {
const calls = { mount: 0, open: [], unmount: 0 };
let emit = null;
return {
calls,
filters,
stats,
context: () => context,
id,
name: 'Mapillary',
label: 'MAPILLARY',
requiresKeyId: null,
pickPrefix,
colors: { coverage: '#05cb63' },
credit: { html },
create: (providerContext) => {
context = providerContext;
return {
status: async () => ({ configured: true }),
init() {},
activate: () => calls.activate++,
deactivate: () => calls.deactivate++,
destroy() {},
refreshCoverage() {},
setFilter: (filter) => filters.push(filter),
coverageStats: () => ({ ...stats }),
handlePick: () => false,
nearestImage,
viewer: {
mount: async () => calls.mount++,
open: async (imageId) => calls.open.push(imageId),
close() {},
unmount: () => calls.unmount++,
resize() {},
onPose: () => () => {},
},
mount: async () => calls.mount++,
async open(imageId) {
calls.open.push(imageId);
if (pose) emit?.({ imageId, ...pose });
},
close() {},
unmount: () => calls.unmount++,
resize() {},
onPose(listener) {
emit = listener;
return () => {
emit = null;
};
},
};
+8 -8
View File
@@ -294,8 +294,8 @@ for (const [why, retryAfter] of [
test('normalizeTileAddress enforces layer names, zoom ranges and tile bounds', () => {
assert.equal(
normalizeTileAddress({ layer: 'coverage', z: 3, x: 1, y: 2 }).key,
'coverage/3/1/2',
normalizeTileAddress({ layer: 'coverage', z: 11, x: 1, y: 2 }).key,
'coverage/11/1/2',
);
assert.deepEqual(
normalizeTileAddress({ layer: 'coverage', z: '14', x: '5', y: '6' })
@@ -307,8 +307,8 @@ test('normalizeTileAddress enforces layer names, zoom ranges and tile bounds', (
{ layer: 'points', z: 14, x: 1, y: 1 },
{ layer: 'signs', z: 14, x: 1, y: 1 },
{ layer: 'coverage', z: 15, x: 1, y: 1 },
{ layer: 'coverage', z: 2, x: 4, y: 0 },
{ layer: 'coverage', z: 2, x: 1.5, y: 0 },
{ layer: 'coverage', z: 11, x: 2048, y: 0 },
{ layer: 'coverage', z: 11, x: 1.5, y: 0 },
{ layer: 'coverage', z: -1, x: 0, y: 0 },
])
assert.throws(
@@ -327,14 +327,14 @@ test('normalizeTileAddress refuses prototype keys as layer names', () => {
);
});
test('coverage zooms the app never requests (z6–10) are refused with a 400', async () => {
for (const z of [6, 7, 8, 9, 10])
test('coverage zooms the app never requests (below z11) are refused with a 400', async () => {
for (const z of [0, 3, 5, 6, 7, 8, 9, 10])
assert.throws(
() => normalizeTileAddress({ layer: 'coverage', z, x: 0, y: 0 }),
TileRequestError,
`z${z}`,
);
for (const z of [0, 5, 11, 14])
for (const z of [11, 12, 13, 14])
assert.equal(
normalizeTileAddress({ layer: 'coverage', z, x: 0, y: 0 }).z,
z,
@@ -345,7 +345,7 @@ test('coverage zooms the app never requests (z6–10) are refused with a 400', a
const res = await call('/api/mapillary/tiles', '/coverage/9/5/5');
assert.equal(res.statusCode, 400);
assert.deepEqual(json(res), {
error: 'Tile zoom for coverage must be 0–5 or 11–14',
error: 'Tile zoom for coverage must be 11–14',
});
assert.equal(calls.length, 0, 'Mapillary is not asked');
},
+6 -11
View File
@@ -4,7 +4,7 @@ import test from 'node:test';
import { createApplicationStreetLevel } from '../app/layers/streetLevel.js';
import {
fixtureTile,
EXPECTED_PROVIDERS,
LEGEND_SWATCHES,
isCollapsed,
PARK,
STRICT_ALLOWED_SKIPS,
@@ -25,13 +25,13 @@ import { redact } from '../../scripts/qa-browserEvidence.mjs';
globalThis.requestAnimationFrame ??= (callback) =>
setTimeout(() => callback(Date.now()), 0);
test('the harness expects exactly the providers the app registers, in order', () => {
// Any method a provider asks its source for is a no-op: only ids matter.
test('the harness expects the legend the app layer draws', () => {
// Any method the layer asks its source for is a no-op: only the legend matters.
const source = new Proxy({}, { get: () => () => {} });
const layer = createApplicationStreetLevel({
sources: { mapillary: source },
});
assert.deepEqual([...EXPECTED_PROVIDERS], [...layer.providerIds]);
assert.equal(layer.getUIState().legend.length, LEGEND_SWATCHES);
});
test('a panel reads as collapsed only with the collapsed class', () => {
@@ -68,13 +68,8 @@ test('fixture tiles give the hermetic gate something real to filter', async () =
const year = 365 * 86_400_000;
const old = grid.filter((s) => now - s.capturedAt > year).length;
assert.ok(old > 0 && old < 8, 'recent and older both present');
const orbit = decodeCoverageTile(fixtureTile(3, 1, 3, now), {
x: 1,
y: 3,
z: 3,
});
assert.ok(orbit.overview.length > 0, 'overview points from orbit');
assert.equal(fixtureTile(8, 1, 1, now).length, 0, 'nothing in between');
assert.equal(fixtureTile(3, 1, 3, now).length, 0, 'nothing from orbit');
assert.equal(fixtureTile(8, 1, 1, now).length, 0, 'nor in between');
});
/* ── The real status route probe ─────────────────────────────────────── */
+4 -9
View File
@@ -180,15 +180,10 @@ export class StreetLevelControls {
async _toggleEnabled() {
const enabled = this.actions.isEnabled?.() === true;
// The pill is the only switch: switching on also brings back a provider
// a share link or a tool turned off, or the layer would draw nothing.
const dark = enabled
? []
: (this._state?.providers || []).filter((entry) => entry.on === false);
if (dark.length)
this._setParams(
Object.fromEntries(dark.map((entry) => [entry.id, true])),
);
// The pill is the only switch: switching on also brings back Mapillary
// if a share link or a tool turned it off, or the layer would draw nothing.
if (!enabled && this._state?.providerOn === false)
this._setParams({ mapillary: true });
try {
await this.actions.setEnabled?.(!enabled);
} catch (error) {
+15 -37
View File
@@ -9,7 +9,7 @@ import {
REGISTERED_LAYER_IDS,
encodeLayerStateParams,
} from '../data/layerState.js';
import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
import { fakeMapillarySource } from '../testSupport/streetLevelFakes.mjs';
/* ── A small DOM: just what the panel controls touch ───────────────────── */
@@ -215,12 +215,7 @@ async function withDom(fn, options) {
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/* ── A Street Level layer with one stand-in provider ───────────────────── */
function standInProvider() {
const def = fakeStreetLevelProvider({ html: 'Mapillary' });
return { filters: def.filters, def };
}
/* ── A Street Level layer over a stand-in Mapillary source ─────────────── */
/** Every other registered layer, as a module with no options. */
function plainLayer(id) {
@@ -254,8 +249,7 @@ const memoryStorage = () => {
/** The production path: controls → dataManager → layer → durable layer state. */
async function productionPanel(dom) {
const provider = standInProvider();
const layer = createStreetLevelLayer({ providers: [provider.def] });
const layer = createStreetLevelLayer({ source: fakeMapillarySource() });
const manager = new DataLayerManager({});
for (const id of REGISTERED_LAYER_IDS)
manager.register(id === 'street-level' ? layer : plainLayer(id));
@@ -288,7 +282,7 @@ async function productionPanel(dom) {
encodeLayerStateParams(params, coordinator.getDurableState());
return (params.get('lo') || '').split('_');
};
return { provider, layer, manager, coordinator, controls, share };
return { layer, manager, coordinator, controls, share };
}
const TOKEN = LAYER_STATE_REGISTRY.find(
@@ -297,11 +291,14 @@ const TOKEN = LAYER_STATE_REGISTRY.find(
test('a 360° click reaches the share link through the data manager', () =>
withDom(async (dom) => {
const { provider, coordinator, controls, share } =
await productionPanel(dom);
const { layer, coordinator, controls, share } = await productionPanel(dom);
dom.root.querySelectorAll('[data-sl-pano]')[1].click(); // 360°
await settle();
assert.equal(provider.filters.at(-1).pano, 'pano', 'the layer applied it');
assert.equal(
layer.getUIState().filter.pano,
'pano',
'the layer applied it',
);
assert.equal(
coordinator.getDurableState().options['street-level'].pano,
'pano',
@@ -362,35 +359,20 @@ function uiState({
} = {}) {
return {
enabled,
providerOn: on,
keyRequired,
keyRejected: false,
filter: { pano, sinceDays: 0 },
providers: [
{
id: 'mapillary',
name: 'Mapillary',
label: 'MAPILLARY',
color: '#05cb63',
on,
configured: !keyRequired,
keyRequired,
requiresKeyId: 'mapillary',
loading: false,
count: 3,
hint: '',
error: null,
},
],
coverage: { count: 3, loading: false, hint: '', error: null },
legend: legend || [
{ key: 'mapillary', label: 'Mapillary', color: '#05cb63' },
{ key: 'selected', label: 'Selected', color: '#00d4ff' },
],
sequence: { providerId: null, selectedId: null, images: 0, loading: false },
sequence: { selectedId: null, images: 0, loading: false },
street: {
open,
loading: false,
renderMode,
providerId: open ? 'mapillary' : null,
imageId: open ? 'img-1' : null,
error: null,
},
@@ -422,18 +404,14 @@ function stubPanel(dom, state, extraActions = {}) {
return { layer, calls, controls };
}
test('the header pill switches the layer, bringing back a provider switched off elsewhere', () =>
test('the header pill switches the layer, bringing back Mapillary switched off elsewhere', () =>
withDom(async (dom) => {
const status = dom.root.querySelector('#sl-status');
const on = stubPanel(dom, uiState());
status.click();
await settle();
assert.deepEqual(on.calls.setEnabled, [false]);
assert.deepEqual(
on.calls.setParams,
[],
'switching off keeps the provider',
);
assert.deepEqual(on.calls.setParams, [], 'switching off keeps the switch');
on.controls.destroy();
const dark = stubPanel(dom, uiState({ enabled: false, on: false }));
status.click();
+6 -9
View File
@@ -67,13 +67,10 @@ function presentStatus(state) {
: { text: 'OFF', tone: '', pressed, title };
}
/** How to add the missing key, for the provider that needs one. */
function keyHint(state) {
const provider = (state.providers || []).find(
(entry) => entry.keyRequired === true && entry.requiresKeyId,
);
const requirement = provider && keySetupRequirement(provider.requiresKeyId);
return requirement ? `${provider.name}: ${requirement}` : null;
/** How to add the missing Mapillary key. */
function keyHint() {
const requirement = keySetupRequirement('mapillary');
return requirement ? `Mapillary: ${requirement}` : null;
}
function presentViewer(state) {
@@ -90,7 +87,7 @@ function presentViewer(state) {
captionLeft: street.creator ? `Image by ${street.creator}` : '',
captionRight: right.join(' · '),
link: street.externalUrl || null,
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
linkLabel: street.externalUrl ? 'MAPILLARY ↗' : '',
};
}
@@ -115,7 +112,7 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
status: presentStatus(state),
controlsDisabled: state.keyRequired === true,
error: keyMissing
? keyHint(state)
? keyHint()
: state.street.error || state.coverage.error || null,
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
since: presentSince(filter.sinceDays, now),
+5 -16
View File
@@ -5,29 +5,23 @@ import {
SINCE_STOPS,
sinceStopIndex,
} from './streetLevelPresentation.js';
import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
const provider = providerSnapshot;
function snapshot(overrides = {}) {
const base = {
enabled: false,
providerOn: true,
keyRequired: false,
keyRejected: false,
filter: { pano: 'all', sinceDays: 0 },
providers: [provider()],
coverage: { loading: false, count: 0, hint: '', error: null },
legend: [
{ key: 'mapillary', label: 'Mapillary', color: '#05cb63' },
{ key: 'selected', label: 'Selected', color: '#00d4ff' },
],
sequence: { providerId: null, selectedId: null, images: 0, loading: false },
sequence: { selectedId: null, images: 0, loading: false },
street: {
open: false,
loading: false,
error: null,
providerId: null,
providerName: null,
providerLabel: null,
imageId: null,
position: null,
bearing: null,
@@ -70,7 +64,6 @@ test('KEY REQUIRED says how to add the key instead of a raw error code', () => {
enabled: true,
keyRequired: true,
coverage: { error: 'no_key' },
providers: [provider({ keyRequired: true, error: 'no_key' })],
}),
);
assert.equal(view.status.text, 'KEY REQUIRED');
@@ -86,7 +79,6 @@ test('a key Mapillary rejected reads KEY REJECTED and names the fix', () => {
keyRequired: true,
keyRejected: true,
coverage: { error },
providers: [provider({ keyRequired: true, keyRejected: true, error })],
}),
);
assert.equal(view.status.text, 'KEY REJECTED');
@@ -96,7 +88,7 @@ test('a key Mapillary rejected reads KEY REJECTED and names the fix', () => {
});
test('a key problem outranks loading on the pill: KEY REJECTED, then KEY REQUIRED (M65)', () => {
// A keyless provider can still report loading (its status check, a retry):
// A keyless layer can still report loading (its status check, a retry):
// the pill must say what is wrong, not that something is on its way.
const loading = { enabled: true, coverage: { loading: true } };
assert.equal(
@@ -221,15 +213,12 @@ test('the meta line never mixes the visible-sequence count with the selected seq
assert.equal(hinted.meta, 'Point the camera at the globe');
});
test('viewer caption reads "Image by" left, date right, and links to the provider', () => {
test('viewer caption reads "Image by" left, date right, and links to Mapillary', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
street: {
open: true,
providerId: 'mapillary',
providerName: 'Mapillary',
providerLabel: 'MAPILLARY',
imageId: '1814275685699406',
creator: 'mapfool',
capturedAt: Date.UTC(2023, 9, 8),