refactor(street-level): rename the Mapillary layer to street-level

Pure move: the layer id, panel id, CSS prefix, classes, exports and config
paths become provider-neutral (street-level, #street-level-panel, sl-);
the Mapillary-specific modules move under providers/mapillary/. Behaviour
is unchanged. The two sprite collections now register under distinct ids
instead of overwriting each other under 'mapillary'.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Fable 5.1 committed 2026-09-25 18:13:53 +01:00
1 parent 547b706f67
commit 2dbc684fb1
38 files changed
+401 -381

No files matched your search

+86
View File
@@ -0,0 +1,86 @@
/**
* Canvas glyphs for billboards: a camera cone for images, a ring for
* panoramas and a position marker for the street-level viewer. Pure canvas
* drawing so it can run without Cesium.
*/
const _cache = new Map();
function canvas(size) {
const element = document.createElement('canvas');
element.width = size;
element.height = size;
return element;
}
/** Cone glyph pointing "up"; rotate the billboard by the compass angle. */
export function imageConeGlyph({
size = 32,
color = '#e8eaed',
pano = false,
} = {}) {
const key = `cone:${size}:${color}:${pano}`;
if (_cache.has(key)) return _cache.get(key);
const element = canvas(size);
const ctx = element.getContext('2d');
const c = size / 2;
if (!pano) {
ctx.beginPath();
ctx.moveTo(c, c);
ctx.arc(c, c, size * 0.46, -Math.PI / 2 - 0.62, -Math.PI / 2 + 0.62);
ctx.closePath();
ctx.fillStyle = color;
ctx.globalAlpha = 0.42;
ctx.fill();
ctx.globalAlpha = 1;
} else {
ctx.beginPath();
ctx.arc(c, c, size * 0.4, 0, Math.PI * 2);
ctx.strokeStyle = color;
ctx.globalAlpha = 0.5;
ctx.lineWidth = Math.max(1.5, size * 0.09);
ctx.stroke();
ctx.globalAlpha = 1;
}
ctx.beginPath();
ctx.arc(c, c, size * 0.16, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.lineWidth = Math.max(1, size * 0.05);
ctx.strokeStyle = 'rgba(10,10,15,0.85)';
ctx.stroke();
_cache.set(key, element);
return element;
}
/** Pulsing-looking marker for the image the viewer currently shows. */
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
const key = `pos:${size}:${color}`;
if (_cache.has(key)) return _cache.get(key);
const element = canvas(size);
const ctx = element.getContext('2d');
const c = size / 2;
ctx.beginPath();
ctx.moveTo(c, c);
ctx.arc(c, c, size * 0.48, -Math.PI / 2 - 0.55, -Math.PI / 2 + 0.55);
ctx.closePath();
ctx.fillStyle = color;
ctx.globalAlpha = 0.55;
ctx.fill();
ctx.globalAlpha = 1;
ctx.beginPath();
ctx.arc(c, c, size * 0.2, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = '#0a0a0f';
ctx.stroke();
ctx.beginPath();
ctx.arc(c, c, size * 0.3, 0, Math.PI * 2);
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.globalAlpha = 0.8;
ctx.stroke();
_cache.set(key, element);
return element;
}
+313
View File
@@ -0,0 +1,313 @@
import { createState } from './state.js';
import { createCoverage, visibleBbox } from './providers/mapillary/coverage.js';
import { createSequences } from './providers/mapillary/sequences.js';
import { createViewerBridge } from './providers/mapillary/viewer.js';
import { createSelection } from './selection.js';
import * as Cesium from 'cesium';
import {
COLORS,
COVERAGE_RECENT_DAYS,
MAPILLARY_CREDIT_HTML,
MAPILLARY_KEY_ID,
STREET_LEVEL_LAYER_ID,
NEAREST_LIMIT,
NEAREST_RADIUS_M,
} from './providers/mapillary/policy.js';
export { STREET_LEVEL_LAYER_ID } from './providers/mapillary/policy.js';
const SOURCE_METHODS = [
'getStatus',
'getTile',
'getImage',
'getSequenceImages',
'nearestImages',
];
/**
* Construct the Mapillary street-level layer: camera-driven coverage,
* per-sequence image cones and the embedded viewer. Application services
* are injected; nothing here reads the DOM except the viewer host the UI
* hands over.
*/
export function createStreetLevelLayer({ source, services = {} }) {
if (!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, source, parts };
parts.coverage = createCoverage(context);
parts.sequences = createSequences(context);
parts.viewer = createViewerBridge(context);
parts.selection = createSelection(context);
// Street-level actions shared by clicks and the UI.
parts.street = {
async openImage(imageId) {
const host = state.street.host;
if (!host) {
state.street.error = 'Open the Street Level panel to view imagery';
notify();
return;
}
await parts.viewer.open(imageId, host);
},
};
/** Run low-priority work when the browser is idle (or soon, headless). */
function scheduleIdle(task) {
if (typeof globalThis.requestIdleCallback === 'function')
globalThis.requestIdleCallback(() => task(), { timeout: 1500 });
else setTimeout(task, 200);
}
let notifyQueued = false;
function notify() {
if (notifyQueued) return;
notifyQueued = true;
queueMicrotask(() => {
notifyQueued = false;
const snapshot = getUIState();
for (const listener of [...state.listeners]) {
try {
listener(snapshot);
} catch (error) {
console.warn('[Data:StreetLevel] listener error:', error);
}
}
});
}
state.notify = notify;
async function refreshStatus() {
try {
state.status = await source.getStatus();
state.statusError = null;
state.keyRequired = state.status?.configured !== true;
} catch (error) {
state.statusError = error?.message || 'status unavailable';
state.keyRequired = !source.hasToken();
}
notify();
}
/** Colour key for the coverage web, in the order the panel lists it. */
function coverageLegend() {
const years = Math.round(COVERAGE_RECENT_DAYS / 365);
return [
{ key: 'recent', label: `Recent (≤${years} yr)`, color: COLORS.coverage },
{ key: 'older', label: 'Older', color: COLORS.coverageOld },
{ key: 'pano', label: '360°', color: COLORS.pano },
{ key: 'selected', label: 'Selected', color: COLORS.selected },
];
}
/** CC BY-SA attribution shown on the globe while the layer is on. */
function presentCredit(viewer) {
if (state.credit || !viewer?.creditDisplay) return;
try {
state.credit = new Cesium.Credit(MAPILLARY_CREDIT_HTML, true);
viewer.creditDisplay.addStaticCredit(state.credit);
} catch {
state.credit = null;
}
}
function hideCredit(viewer) {
if (!state.credit) return;
try {
viewer?.creditDisplay?.removeStaticCredit?.(state.credit);
} catch {
/* credit display already torn down */
}
state.credit = null;
}
/** Centre of the visible ground, or the camera's own footprint. */
function viewCentre() {
const viewer = state.viewer;
if (!viewer) return null;
const bbox = visibleBbox(viewer);
if (bbox)
return { lat: (bbox[1] + bbox[3]) / 2, lon: (bbox[0] + bbox[2]) / 2 };
const carto = viewer.camera.positionCartographic;
return {
lat: Cesium.Math.toDegrees(carto.latitude),
lon: Cesium.Math.toDegrees(carto.longitude),
};
}
function getUIState() {
return {
enabled: state.enabled,
keyRequired: state.keyRequired,
coverage: {
zoom: state.coverage.zoom,
kind: state.coverage.kind,
filter: { ...state.coverage.filter },
legend: coverageLegend(),
loading: state.coverage.loading > 0,
sequences: parts.coverage.sequenceCount(),
hint: state.coverage.hint,
error: state.coverage.lastError,
},
sequence: {
selectedId: state.sequence.selectedId,
images: state.sequence.images.length,
loading: state.sequence.loading,
},
street: {
open: state.street.open,
follow: state.street.follow,
loading: state.street.loading,
error: state.street.error,
imageId: state.street.imageId,
position: state.street.position ? { ...state.street.position } : null,
bearing: state.street.bearing,
isPano: state.street.isPano,
capturedAt: state.street.capturedAt,
sequenceId: state.street.sequenceId,
creator: state.street.creator || null,
renderMode: state.street.renderMode,
},
};
}
const layer = {
id: STREET_LEVEL_LAYER_ID,
name: 'Street Level',
icon: '📷',
source: 'Mapillary',
updateInterval: 0,
statsRefreshInterval: 1000,
requiresKeyId: MAPILLARY_KEY_ID,
init(viewer) {
if (state.initialized)
throw new Error('Street Level layer is already initialized');
state.viewer = viewer;
state.initialized = true;
parts.sequences.ensureCollections(viewer);
parts.sequences.setVisible(false);
refreshStatus();
console.log('[Data:StreetLevel] Initialized');
},
enable(viewer) {
state.enabled = true;
presentCredit(viewer);
parts.sequences.setVisible(true);
parts.selection.install(viewer);
parts.coverage.attach(viewer);
if (!state.status) refreshStatus();
scheduleIdle(() => parts.viewer.prewarm());
notify();
},
disable() {
state.enabled = false;
hideCredit(state.viewer);
parts.coverage.detach();
parts.coverage.clear();
parts.sequences.clearSelection();
parts.selection.uninstall();
parts.viewer.destroy();
parts.sequences.setVisible(false);
notify();
},
async update() {
return state.enabled;
},
destroy(viewer = state.viewer) {
layer.disable();
parts.sequences.destroy(viewer);
state.listeners.clear();
state.viewer = null;
state.initialized = false;
state.destroyed = true;
},
getStats() {
let loadingLabel = '';
if (state.keyRequired) loadingLabel = 'KEY REQUIRED';
else if (state.coverage.loading > 0) loadingLabel = 'loading coverage...';
else if (state.coverage.hint && state.enabled)
loadingLabel = state.coverage.hint;
return {
count: parts.coverage.sequenceCount(),
sequences: parts.coverage.sequenceCount(),
loading: state.coverage.loading > 0,
keyRequired: state.keyRequired,
error: state.keyRequired ? 'KEY REQUIRED' : state.coverage.lastError,
loadingLabel,
};
},
// ── Public surface used by the panel ────────────────────────────────
subscribe(listener) {
if (typeof listener !== 'function') return () => {};
state.listeners.add(listener);
return () => state.listeners.delete(listener);
},
getUIState,
/** The DOM element the MapillaryJS viewer renders into. */
attachViewerHost(element) {
state.street.host = element || null;
if (element && state.street.open) parts.viewer.resize();
if (element && state.enabled)
scheduleIdle(() => parts.viewer.prewarm(element));
},
/** Imagery filter for coverage, cones and nearest-image lookups. */
setCoverageFilter(next) {
parts.coverage.setFilter(next);
parts.sequences.rerender();
},
getCoverageFilter: () => ({ ...state.coverage.filter }),
openImage: (imageId) => parts.street.openImage(imageId),
async openNearest(point) {
const view = point || viewCentre();
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
return false;
state.street.loading = true;
state.street.error = null;
notify();
try {
const images = await source.nearestImages({
lat: view.lat,
lon: view.lon,
radius: NEAREST_RADIUS_M,
limit: NEAREST_LIMIT,
});
if (!images.length)
throw new Error(
`No Mapillary imagery within ${NEAREST_RADIUS_M} m of the view centre`,
);
await parts.street.openImage(images[0].id);
return true;
} catch (error) {
state.street.error = error?.message || 'Nearest image unavailable';
state.street.loading = false;
notify();
return false;
}
},
/**
* Close the image and deselect it everywhere on the map: position
* marker, highlighted sequence and its cones.
*/
closeViewer() {
parts.viewer.close();
parts.sequences.clearSelection();
},
setViewerRenderMode: (mode) => parts.viewer.setRenderMode(mode),
setFollow: (enabled) => parts.viewer.setFollow(enabled),
lookAtImage: () => parts.viewer.lookAtPosition(),
resizeViewer: () => parts.viewer.resize(),
selectSequence: (id) => parts.sequences.select(id),
clearSequence: () => parts.sequences.clearSelection(),
refreshCoverage: () => parts.coverage.refresh(),
};
return layer;
}
@@ -0,0 +1,508 @@
import * as Cesium from 'cesium';
import { decodeCoverageTile } from './decode.js';
import {
coverageZoomForHeight,
overviewZoomForHeight,
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,
COVERAGE_RECENT_DAYS,
PICK_PREFIX,
} from './policy.js';
/** Old-zoom tiles are kept at most this long after a zoom change. */
const STALE_TILE_MAX_MS = 6000;
/**
* Sequences per ground primitive. Ground polyline geometry is built on a
* worker in proportion to the instance count, so smaller batches put the
* first lines on screen sooner instead of one big batch arriving late.
*/
const SEQUENCE_PRIMITIVE_BATCH = 120;
const PER_TILE_SEQUENCE_CAP = Math.floor(
COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES,
);
/** Colour for a sequence: brand green, dimmer with age, magenta for panoramas. */
function sequenceColor(sequence, { selected = false, now = Date.now() } = {}) {
if (selected) return Cesium.Color.fromCssColorString(COLORS.selected);
if (sequence.isPano)
return Cesium.Color.fromCssColorString(COLORS.pano).withAlpha(0.9);
const ageDays = (now - (sequence.capturedAt || 0)) / 86_400_000;
return ageDays <= COVERAGE_RECENT_DAYS
? Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92)
: Cesium.Color.fromCssColorString(COLORS.coverageOld).withAlpha(0.7);
}
/**
* Whether one capture passes the imagery filter: panorama mode
* ('all' | 'pano' | 'flat') and an optional earliest capture time.
*/
export function passesImageryFilter(record, filter) {
if (!filter) return true;
if (filter.pano === 'pano' && !record.isPano) return false;
if (filter.pano === 'flat' && record.isPano) return false;
if (
Number.isFinite(filter.sinceMs) &&
filter.sinceMs > 0 &&
(record.capturedAt || 0) < filter.sinceMs
)
return false;
return true;
}
/**
* Camera height above the surface under the camera, in metres. Falls back to
* the ellipsoidal height when the globe has no height sample yet.
*/
function cameraHeightAboveGround(viewer) {
const carto = viewer?.camera?.positionCartographic;
if (!carto) return null;
const ground = viewer.scene?.globe?.getHeight?.(carto);
return carto.height - (Number.isFinite(ground) ? ground : 0);
}
/**
* Visible bbox as [west, south, east, north] degrees, or null when the camera
* does not see the ground. A grid of screen rays is cast onto the ellipsoid
* and only the rays that hit count, so a view that includes the horizon (or a
* canvas whose frustum is stale) cannot inflate the box to the whole world;
* `computeViewRectangle` is the fallback when too few rays land.
*/
export function visibleBbox(viewer, { grid = 5 } = {}) {
const scene = viewer?.scene;
const camera = viewer?.camera;
if (!scene || !camera) return null;
const width = scene.canvas?.clientWidth || scene.canvas?.width || 0;
const height = scene.canvas?.clientHeight || scene.canvas?.height || 0;
const hits = [];
if (width > 0 && height > 0) {
const ellipsoid = scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84;
const point = new Cesium.Cartesian2();
for (let i = 0; i <= grid; i++) {
for (let j = 0; j <= grid; j++) {
point.x = (width * i) / grid;
point.y = (height * j) / grid;
let cartesian = null;
try {
cartesian = camera.pickEllipsoid(point, ellipsoid);
} catch {
cartesian = null;
}
if (!cartesian) continue;
const carto = Cesium.Cartographic.fromCartesian(cartesian, ellipsoid);
if (carto) hits.push(carto);
}
}
}
if (hits.length >= 4) {
let west = Infinity;
let south = Infinity;
let east = -Infinity;
let north = -Infinity;
for (const carto of hits) {
const lon = Cesium.Math.toDegrees(carto.longitude);
const lat = Cesium.Math.toDegrees(carto.latitude);
west = Math.min(west, lon);
east = Math.max(east, lon);
south = Math.min(south, lat);
north = Math.max(north, lat);
}
if (east - west > 0 && north - south > 0) return [west, south, east, north];
}
const rectangle = camera.computeViewRectangle?.(scene.globe?.ellipsoid);
if (!rectangle) return null;
return [
Cesium.Math.toDegrees(rectangle.west),
Cesium.Math.toDegrees(rectangle.south),
Cesium.Math.toDegrees(rectangle.east),
Cesium.Math.toDegrees(rectangle.north),
];
}
/**
* Camera-driven coverage: z0–5 `overview` points from orbit down to 60 km,
* then z11–14 sequence polylines clamped to terrain and 3D tiles. Decoded
* tiles are kept so the imagery filter can rebuild without refetching.
*/
export function createCoverage({ state, source }) {
const { render } = state.services;
function requestRender() {
render?.governorRequestRender?.('mapillary-coverage');
}
function ensureTerrainReady() {
return (state.coverage.terrainReady ||=
Cesium.GroundPolylinePrimitive.initializeTerrainHeights());
}
function tileKey({ x, y, z }) {
return `${z}/${x}/${y}`;
}
function filter() {
return state.coverage.filter;
}
/** One or more ground primitives for a tile's sequences, in draw batches. */
function buildSequencePrimitives(sequences) {
const now = Date.now();
const instances = [];
for (const sequence of sequences) {
if (!passesImageryFilter(sequence, filter())) continue;
const flat = [];
for (const [lon, lat] of sequence.coordinates) flat.push(lon, lat);
let positions;
try {
positions = Cesium.Cartesian3.fromDegreesArray(flat);
} catch {
continue;
}
if (positions.length < 2) continue;
instances.push(
new Cesium.GeometryInstance({
geometry: new Cesium.GroundPolylineGeometry({
positions,
width: COVERAGE_LINE_WIDTH_PX,
}),
id: `${PICK_PREFIX.sequence}${sequence.id}`,
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
sequenceColor(sequence, {
now,
selected: sequence.id === state.sequence.selectedId,
}),
),
},
}),
);
}
const primitives = [];
for (let i = 0; i < instances.length; i += SEQUENCE_PRIMITIVE_BATCH)
primitives.push(
new Cesium.GroundPolylinePrimitive({
geometryInstances: instances.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
appearance: new Cesium.PolylineColorAppearance(),
classificationType: Cesium.ClassificationType.BOTH,
asynchronous: true,
allowPicking: true,
}),
);
return { primitives, count: instances.length };
}
function buildOverviewCollection(points) {
const collection = new Cesium.PointPrimitiveCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
const green = Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(
0.85,
);
const pink = Cesium.Color.fromCssColorString(COLORS.pano).withAlpha(0.9);
let count = 0;
for (const point of points) {
if (!passesImageryFilter(point, filter())) continue;
collection.add({
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat),
color: point.isPano ? pink : green,
pixelSize: COVERAGE_OVERVIEW_POINT_PX,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
count++;
}
return { collection, count };
}
function attachPrimitive(entry) {
const scene = state.viewer?.scene;
if (!scene) return;
if (entry.kind === 'sequence') {
const { primitives, count } = buildSequencePrimitives(entry.sequenceList);
entry.primitives = primitives;
entry.count = count;
for (const primitive of primitives) scene.groundPrimitives.add(primitive);
} else {
const { collection, count } = buildOverviewCollection(entry.points);
entry.primitive = collection;
entry.count = count;
scene.primitives.add(collection);
}
}
function detachPrimitive(entry) {
const scene = state.viewer?.scene;
for (const primitive of entry.primitives || []) {
try {
scene?.groundPrimitives?.remove(primitive);
} catch {
/* already gone */
}
}
entry.primitives = [];
if (entry.primitive) {
try {
scene?.primitives?.remove(entry.primitive);
} catch {
/* already gone */
}
entry.primitive = null;
}
}
function removeTile(key) {
const entry = state.coverage.tiles.get(key);
if (!entry) return;
detachPrimitive(entry);
state.coverage.tiles.delete(key);
}
async function loadTile(tile, generation, kind) {
const key = tileKey(tile);
if (state.coverage.tiles.has(key) || state.coverage.pending.has(key))
return;
const controller = new AbortController();
state.coverage.pending.set(key, controller);
state.coverage.loading++;
notify();
try {
// Fetch and the one-time terrain-height table load run side by side.
const fetching = source.getTile('coverage', tile.z, tile.x, tile.y, {
signal: controller.signal,
});
if (kind === 'sequence') await ensureTerrainReady();
const bytes = await fetching;
// A refresh that still wants this tile must not throw the bytes away;
// only an abort (tile no longer wanted, or zoom changed) does.
if (
controller.signal.aborted ||
kind !== state.coverage.kind ||
tile.z !== state.coverage.zoom
)
return;
const decoded = decodeCoverageTile(bytes, tile);
const entry = { kind, primitive: null, primitives: [], count: 0 };
if (kind === 'sequence') {
// Newest first, capped per tile so a dense city stays within budget.
const sequences = decoded.sequences
.sort((a, b) => (b.capturedAt || 0) - (a.capturedAt || 0))
.slice(0, PER_TILE_SEQUENCE_CAP);
entry.sequenceList = sequences;
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
entry.total = decoded.sequences.length;
} else {
entry.points = decoded.overview;
entry.sequences = new Map();
entry.total = decoded.overview.length;
}
attachPrimitive(entry);
state.coverage.tiles.set(key, entry);
state.coverage.lastError = null;
requestRender();
} catch (error) {
if (controller.signal.aborted) return;
state.coverage.lastError = error?.message || 'Coverage tile failed';
if (error?.keyRequired) state.keyRequired = true;
} finally {
state.coverage.pending.delete(key);
state.coverage.loading = Math.max(0, state.coverage.loading - 1);
if (!state.coverage.pending.size) purgeStale();
notify();
}
}
/** Drop the previous zoom's tiles once the new ones are on screen. */
function purgeStale() {
clearTimeout(state.coverage.staleTimer);
state.coverage.staleTimer = null;
if (!state.coverage.stale.size) return;
for (const entry of state.coverage.stale.values()) detachPrimitive(entry);
state.coverage.stale.clear();
requestRender();
}
/**
* Move every loaded tile to the stale set instead of removing it, so the
* old zoom stays visible while the new zoom streams in (no blank globe).
*/
function retire() {
for (const controller of state.coverage.pending.values())
controller.abort();
state.coverage.pending.clear();
state.coverage.loading = 0;
for (const [key, entry] of state.coverage.tiles) {
const previous = state.coverage.stale.get(key);
if (previous) detachPrimitive(previous);
state.coverage.stale.set(key, entry);
}
state.coverage.tiles.clear();
clearTimeout(state.coverage.staleTimer);
state.coverage.staleTimer = setTimeout(purgeStale, STALE_TILE_MAX_MS);
}
function notify() {
state.notify?.();
}
/** Recompute the tile set for the current camera and reconcile primitives. */
function refresh() {
const viewer = state.viewer;
if (!viewer || !state.enabled || state.keyRequired) return;
const height = cameraHeightAboveGround(viewer);
const sequenceZoom = coverageZoomForHeight(height);
const overviewZoom = sequenceZoom ? null : overviewZoomForHeight(height);
const zoom = sequenceZoom ?? overviewZoom;
const kind = sequenceZoom ? 'sequence' : 'overview';
let bbox = visibleBbox(viewer);
if (kind === 'overview' && (!bbox || zoom <= 1))
bbox = [-180, -85, 180, 85];
if (!zoom || !bbox) {
state.coverage.hint =
'Point the camera at the globe for Mapillary coverage';
clear();
notify();
return;
}
state.coverage.hint = '';
if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) {
retire();
state.coverage.zoom = zoom;
state.coverage.kind = kind;
}
state.coverage.generation++;
const generation = state.coverage.generation;
const { tiles } = tilesForBbox(bbox, zoom, {
limit:
kind === 'sequence' ? COVERAGE_MAX_TILES : COVERAGE_OVERVIEW_MAX_TILES,
});
const wanted = new Set(tiles.map(tileKey));
for (const key of [...state.coverage.tiles.keys()])
if (!wanted.has(key)) removeTile(key);
for (const [key, controller] of [...state.coverage.pending])
if (!wanted.has(key)) {
controller.abort();
state.coverage.pending.delete(key);
}
for (const tile of tiles) loadTile(tile, generation, kind);
notify();
}
function scheduleRefresh() {
clearTimeout(state.coverage.debounceTimer);
state.coverage.debounceTimer = setTimeout(
refresh,
COVERAGE_MOVE_DEBOUNCE_MS,
);
}
function attach(viewer) {
detach();
const remove = viewer.camera.changed.addEventListener(scheduleRefresh);
const removeEnd = viewer.camera.moveEnd.addEventListener(scheduleRefresh);
state.coverage.removeCameraListener = () => {
remove();
removeEnd();
};
refresh();
}
function detach() {
clearTimeout(state.coverage.debounceTimer);
state.coverage.debounceTimer = null;
state.coverage.removeCameraListener?.();
state.coverage.removeCameraListener = null;
}
function clear() {
state.coverage.generation++;
for (const controller of state.coverage.pending.values())
controller.abort();
state.coverage.pending.clear();
state.coverage.loading = 0;
for (const key of [...state.coverage.tiles.keys()]) removeTile(key);
purgeStale();
state.coverage.zoom = null;
state.coverage.kind = null;
requestRender();
}
/** Apply a new imagery filter and rebuild every loaded tile from its cache. */
function setFilter(next) {
const current = state.coverage.filter;
const pano = ['all', 'pano', 'flat'].includes(next?.pano)
? next.pano
: current.pano;
const sinceMs =
next && 'sinceMs' in next
? Number.isFinite(next.sinceMs) && next.sinceMs > 0
? next.sinceMs
: null
: current.sinceMs;
if (pano === current.pano && sinceMs === current.sinceMs) return;
state.coverage.filter = { pano, sinceMs };
purgeStale();
for (const entry of state.coverage.tiles.values()) {
detachPrimitive(entry);
attachPrimitive(entry);
}
requestRender();
notify();
}
/** Look a sequence up across loaded tiles. */
function findSequence(id) {
for (const entry of state.coverage.tiles.values()) {
const hit = entry.sequences.get(id);
if (hit) return hit;
}
return null;
}
/** Recolour one sequence in place (selection highlight). */
function recolorSequence(id, selected) {
const instanceId = `${PICK_PREFIX.sequence}${id}`;
for (const entry of state.coverage.tiles.values()) {
const sequence = entry.sequences.get(id);
if (!sequence) continue;
for (const primitive of entry.primitives || []) {
if (!primitive.ready) continue;
try {
const attributes =
primitive.getGeometryInstanceAttributes(instanceId);
if (attributes)
attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue(
sequenceColor(sequence, { selected }),
attributes.color,
);
} catch {
/* instance not in this primitive */
}
}
}
requestRender();
}
function sequenceCount() {
let count = 0;
for (const entry of state.coverage.tiles.values()) count += entry.count;
return count;
}
return {
attach,
detach,
refresh,
clear,
findSequence,
recolorSequence,
sequenceCount,
setFilter,
};
}
@@ -0,0 +1,99 @@
import { PbfReader } from 'pbf';
import { VectorTile } from '@mapbox/vector-tile';
import { tileLocalToLonLat } from '../../tileMath.js';
/**
* Decode a Mapillary coverage tile (`mly1_public`) in the browser.
* Sequences are LineStrings with capture metadata; the `image` layer is only
* present at z14 and is skipped unless asked for (it can hold >150k points).
* @param {Uint8Array} bytes
* @param {{x:number,y:number,z:number}} address
* @param {{images?: boolean}} [options]
* @returns {{sequences: Array<object>, images: Array<object>}}
*/
export function decodeCoverageTile(bytes, address, { images = false } = {}) {
const result = { sequences: [], images: [], overview: [] };
if (!bytes || !bytes.length) return result;
const tile = new VectorTile(new PbfReader(bytes));
const { x, y, z } = address;
const sequenceLayer = tile.layers.sequence;
if (sequenceLayer) {
for (let i = 0; i < sequenceLayer.length; i++) {
const feature = sequenceLayer.feature(i);
const props = feature.properties || {};
const coordinates = [];
for (const ring of feature.loadGeometry()) {
for (const point of ring) {
coordinates.push(
tileLocalToLonLat(point.x, point.y, sequenceLayer.extent, x, y, z),
);
}
}
if (coordinates.length < 2) continue;
result.sequences.push({
id: String(props.id ?? feature.id ?? `${x}/${y}/${i}`),
imageId: props.image_id != null ? String(props.image_id) : null,
capturedAt: Number(props.captured_at) || 0,
isPano: props.is_pano === true,
onFoot: props.foot === true,
quality: Number.isFinite(props.quality_score)
? props.quality_score
: null,
coordinates,
});
}
}
// z0–5 tiles carry an `overview` point layer instead of sequences.
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,
});
}
}
const imageLayer = images ? tile.layers.image : null;
if (imageLayer) {
for (let i = 0; i < imageLayer.length; i++) {
const feature = imageLayer.feature(i);
const props = feature.properties || {};
const point = feature.loadGeometry()?.[0]?.[0];
if (!point) continue;
const [lon, lat] = tileLocalToLonLat(
point.x,
point.y,
imageLayer.extent,
x,
y,
z,
);
result.images.push({
id: String(props.id ?? feature.id),
lon,
lat,
compassAngle: Number(props.compass_angle) || 0,
capturedAt: Number(props.captured_at) || 0,
isPano: props.is_pano === true,
sequenceId: props.sequence_id ? String(props.sequence_id) : null,
});
}
}
return result;
}
@@ -0,0 +1,54 @@
/** Identity and tuning for the Mapillary street-level layer. */
export const STREET_LEVEL_LAYER_ID = 'street-level';
export const MAPILLARY_KEY_ID = 'mapillary';
export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com';
/** Stable id prefixes for picked primitives. */
export const PICK_PREFIX = Object.freeze({
sequence: 'mly:seq:',
image: 'mly:img:',
position: 'mly:pos',
});
/** Brand green for coverage; panoramas get a distinct hue; selection is GEV cyan. */
export const COLORS = Object.freeze({
coverage: '#05cb63',
coverageOld: '#2e7d5b',
pano: '#ff4fd8',
selected: '#00d4ff',
image: '#e8eaed',
position: '#ffb300',
});
/** 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;
export const COVERAGE_OVERVIEW_POINT_PX = 2.5;
export const COVERAGE_MAX_SEQUENCES = 6000;
export const COVERAGE_LINE_WIDTH_PX = 2.5;
/** Sequences newer than this many days draw at full brightness. */
export const COVERAGE_RECENT_DAYS = 730;
/** Per-sequence image cones after a sequence is selected. */
export const SEQUENCE_IMAGES_LIMIT = 2000;
export const IMAGE_CONE_SIZE_PX = 26;
export const IMAGE_CONE_MIN_SPACING_M = 3;
/** Nearest-image search when the user asks to look at a place. */
export const NEAREST_RADIUS_M = 50;
export const NEAREST_LIMIT = 8;
/**
* On-globe credit shown while the layer is enabled. Mapillary imagery and
* derived data are CC BY-SA 4.0 and require visible attribution.
*/
export const MAPILLARY_CREDIT_HTML =
'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, CC BY-SA 4.0';
/** Fields requested from the graph API. */
export const IMAGE_FIELDS =
'id,captured_at,compass_angle,computed_compass_angle,geometry,computed_geometry,computed_altitude,is_pano,sequence,thumb_256_url,creator,quality_score';
export const SEQUENCE_IMAGE_FIELDS =
'id,captured_at,compass_angle,geometry,is_pano,computed_altitude';
@@ -0,0 +1,265 @@
import * as Cesium from 'cesium';
import { imageConeGlyph } from '../../glyphs.js';
import { passesImageryFilter } from './coverage.js';
import {
COLORS,
IMAGE_CONE_MIN_SPACING_M,
IMAGE_CONE_SIZE_PX,
PICK_PREFIX,
} from './policy.js';
/** How many recently viewed sequences keep their image list in memory. */
const SEQUENCE_CACHE_SIZE = 40;
/** Approximate metres between two lon/lat points (small distances). */
function metresBetween(a, b) {
const lat = ((a.lat + b.lat) / 2) * (Math.PI / 180);
const dx = (b.lon - a.lon) * 111_320 * Math.cos(lat);
const dy = (b.lat - a.lat) * 110_540;
return Math.hypot(dx, dy);
}
/** Normalize a graph image record into the shape the cones use. */
function normalizeSequenceImage(record) {
const coordinates = record?.geometry?.coordinates;
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
return {
id: String(record.id),
lon: Number(coordinates[0]),
lat: Number(coordinates[1]),
compassAngle: Number(record.compass_angle) || 0,
capturedAt: Number(record.captured_at) || 0,
isPano: record.is_pano === true,
altitude: Number.isFinite(record.computed_altitude)
? record.computed_altitude
: null,
};
}
/** Drop images closer than the spacing to the previous kept one. */
function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
const kept = [];
let last = null;
for (const image of images) {
if (!last || metresBetween(last, image) >= spacingM) {
kept.push(image);
last = image;
}
}
return kept;
}
/** Image cones for one selected sequence, plus the viewer position marker. */
export function createSequences({ state, source, parts }) {
const { render, sprites } = state.services;
function requestRender() {
render?.governorRequestRender?.('mapillary-sequence');
}
function ensureCollections(viewer) {
if (!state.sequence.collection) {
state.sequence.collection = new Cesium.BillboardCollection({
scene: viewer.scene,
});
viewer.scene.primitives.add(state.sequence.collection);
sprites?.registerSpriteCollection?.(
'street-level:mapillary-cones',
state.sequence.collection,
);
}
if (!state.street.markerCollection) {
state.street.markerCollection = new Cesium.BillboardCollection({
scene: viewer.scene,
});
viewer.scene.primitives.add(state.street.markerCollection);
sprites?.registerSpriteCollection?.(
'street-level:marker',
state.street.markerCollection,
);
}
}
function clearCones() {
state.sequence.collection?.removeAll();
state.sequence.images = [];
}
function renderCones(images) {
const collection = state.sequence.collection;
if (!collection) return;
collection.removeAll();
const cone = imageConeGlyph({ size: 32, color: COLORS.image });
const ring = imageConeGlyph({ size: 32, color: COLORS.pano, pano: true });
for (const image of images) {
if (!passesImageryFilter(image, state.coverage.filter)) continue;
collection.add({
id: `${PICK_PREFIX.image}${image.id}`,
position: Cesium.Cartesian3.fromDegrees(image.lon, image.lat),
image: image.isPano ? ring : cone,
imageId: image.isPano ? 'mly-cone-pano' : 'mly-cone',
width: IMAGE_CONE_SIZE_PX,
height: IMAGE_CONE_SIZE_PX,
rotation: -Cesium.Math.toRadians(image.compassAngle),
alignedAxis: Cesium.Cartesian3.UNIT_Z,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35),
});
}
requestRender();
}
function remember(sequenceId, images) {
const { cache } = state.sequence;
cache.delete(sequenceId);
cache.set(sequenceId, images);
while (cache.size > SEQUENCE_CACHE_SIZE)
cache.delete(cache.keys().next().value);
}
/** Select a sequence: highlight its line and load its image cones. */
async function select(sequenceId) {
if (!sequenceId || !state.viewer) return;
if (
state.sequence.selectedId === sequenceId &&
state.sequence.images.length
)
return;
if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId)
parts.coverage.recolorSequence(state.sequence.selectedId, false);
state.sequence.abort?.abort();
state.sequence.selectedId = sequenceId;
parts.coverage.recolorSequence(sequenceId, true);
const cached = state.sequence.cache.get(sequenceId);
if (cached) {
state.sequence.abort = null;
state.sequence.loading = false;
state.sequence.images = cached;
renderCones(cached);
state.notify?.();
return;
}
const controller = new AbortController();
state.sequence.abort = controller;
state.sequence.loading = true;
state.notify?.();
try {
const records = await source.getSequenceImages(sequenceId, {
signal: controller.signal,
});
if (controller.signal.aborted) return;
const images = thinImages(
records
.map(normalizeSequenceImage)
.filter(Boolean)
.sort((a, b) => a.capturedAt - b.capturedAt),
);
remember(sequenceId, images);
state.sequence.images = images;
renderCones(images);
} catch (error) {
if (!controller.signal.aborted)
state.street.error = error?.message || 'Sequence images unavailable';
} finally {
if (state.sequence.abort === controller) {
state.sequence.loading = false;
state.sequence.abort = null;
}
state.notify?.();
}
}
function clearSelection() {
state.sequence.abort?.abort();
state.sequence.abort = null;
if (state.sequence.selectedId)
parts.coverage.recolorSequence(state.sequence.selectedId, false);
state.sequence.selectedId = null;
state.sequence.loading = false;
clearCones();
requestRender();
state.notify?.();
}
/** Move (or create) the viewer position marker. */
function setMarker(position, bearing) {
const collection = state.street.markerCollection;
if (!collection) return;
if (!position) {
collection.removeAll();
state.street.marker = null;
requestRender();
return;
}
const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat);
if (!state.street.marker) {
state.street.marker = collection.add({
id: PICK_PREFIX.position,
position: cartesian,
image: (id) =>
import('../../glyphs.js').then((m) =>
m.positionMarkerGlyph({ color: COLORS.position }),
),
imageId: 'mly-position',
width: 40,
height: 40,
alignedAxis: Cesium.Cartesian3.UNIT_Z,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
});
} else state.street.marker.position = cartesian;
state.street.marker.rotation = -Cesium.Math.toRadians(bearing || 0);
requestRender();
}
/** Re-draw the current sequence's cones (after an imagery filter change). */
function rerender() {
if (state.sequence.images.length) renderCones(state.sequence.images);
}
function hide(visible) {
if (state.sequence.collection) state.sequence.collection.show = visible;
if (state.street.markerCollection)
state.street.markerCollection.show = visible;
requestRender();
}
function destroy(viewer) {
state.sequence.abort?.abort();
for (const key of ['collection']) {
const collection = state.sequence[key];
if (collection) {
sprites?.unregisterSpriteCollection?.(
'street-level:mapillary-cones',
collection,
);
viewer?.scene?.primitives?.remove(collection);
state.sequence[key] = null;
}
}
if (state.street.markerCollection) {
sprites?.unregisterSpriteCollection?.(
'street-level:marker',
state.street.markerCollection,
);
viewer?.scene?.primitives?.remove(state.street.markerCollection);
state.street.markerCollection = null;
state.street.marker = null;
}
state.sequence.images = [];
state.sequence.selectedId = null;
}
return {
ensureCollections,
select,
clearSelection,
setMarker,
rerender,
setVisible: hide,
destroy,
};
}
@@ -0,0 +1,149 @@
import {
IMAGE_FIELDS,
MAPILLARY_GRAPH_HOST,
NEAREST_LIMIT,
NEAREST_RADIUS_M,
SEQUENCE_IMAGE_FIELDS,
SEQUENCE_IMAGES_LIMIT,
} from './policy.js';
/** Error carrying the HTTP status and any server-provided payload. */
export class MapillarySourceError extends Error {
constructor(message, { status = 0, payload = null } = {}) {
super(message);
this.name = 'MapillarySourceError';
this.status = status;
this.payload = payload;
this.keyRequired = payload?.keyRequired ?? payload?.error === 'no_key';
}
}
async function readJsonOrThrow(response, label) {
let payload = null;
try {
payload = await response.json();
} catch {
/* status is authoritative */
}
if (!response.ok)
throw new MapillarySourceError(
payload?.error?.message ||
payload?.error ||
`${label} HTTP ${response.status}`,
{ status: response.status, payload },
);
return payload;
}
/**
* Every network path the Mapillary layer uses. Coverage tiles go through the
* local dev-server proxy (which adds the token and caches them); image and
* sequence lookups go straight to graph.mapillary.com with the client token,
* exactly as the embedded MapillaryJS viewer does.
* @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options
*/
export function createMapillarySource({
token = '',
fetchImpl = (...args) => globalThis.fetch(...args),
endpoints = {},
} = {}) {
const urls = {
status: '/api/mapillary/status',
tiles: '/api/mapillary/tiles',
graph: MAPILLARY_GRAPH_HOST,
...endpoints,
};
async function graph(path, params = {}, { signal } = {}) {
if (!token)
throw new MapillarySourceError('Mapillary token not configured', {
status: 503,
payload: { keyRequired: true },
});
const search = new URLSearchParams({ access_token: token, ...params });
const response = await fetchImpl(`${urls.graph}/${path}?${search}`, {
signal,
});
return readJsonOrThrow(response, 'Mapillary graph');
}
return {
get token() {
return token;
},
hasToken: () => Boolean(token),
async getStatus({ signal } = {}) {
const response = await fetchImpl(urls.status, {
signal,
cache: 'no-store',
});
return readJsonOrThrow(response, 'Mapillary status');
},
/** Raw protobuf bytes for one tile; an empty array means no data. */
async getTile(layer, z, x, y, { signal } = {}) {
const response = await fetchImpl(
`${urls.tiles}/${layer}/${z}/${x}/${y}`,
{
signal,
},
);
if (response.status === 204) return new Uint8Array(0);
if (!response.ok) {
let payload = null;
try {
payload = await response.json();
} catch {
/* ignore */
}
throw new MapillarySourceError(
payload?.error || `Tile HTTP ${response.status}`,
{
status: response.status,
payload,
},
);
}
return new Uint8Array(await response.arrayBuffer());
},
getImage(id, { signal, fields = IMAGE_FIELDS } = {}) {
return graph(String(id), { fields }, { signal });
},
async getSequenceImages(
sequenceId,
{ signal, limit = SEQUENCE_IMAGES_LIMIT } = {},
) {
const payload = await graph(
'images',
{
sequence_ids: String(sequenceId),
fields: SEQUENCE_IMAGE_FIELDS,
limit: String(limit),
},
{ signal },
);
return Array.isArray(payload?.data) ? payload.data : [];
},
async nearestImages(
{ lat, lon, radius = NEAREST_RADIUS_M, limit = NEAREST_LIMIT },
{ signal } = {},
) {
const payload = await graph(
'images',
{
lat: String(lat),
lng: String(lon),
radius: String(Math.min(50, Math.max(1, radius))),
limit: String(Math.min(100, Math.max(1, limit))),
fields: IMAGE_FIELDS,
},
{ signal },
);
return Array.isArray(payload?.data) ? payload.data : [];
},
};
}
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMapillarySource, MapillarySourceError } from './source.js';
function fakeFetch(handler) {
const calls = [];
const fetchImpl = async (url, init = {}) => {
calls.push({ url: String(url), init });
return handler(String(url), init);
};
return { calls, fetchImpl };
}
const jsonResponse = (payload, status = 200) =>
new Response(JSON.stringify(payload), {
status,
headers: { 'content-type': 'application/json' },
});
test('tiles go through the local proxy and never carry the token', async () => {
const { calls, fetchImpl } = fakeFetch(
() => new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
);
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
const bytes = await source.getTile('coverage', 14, 2662, 6285);
assert.deepEqual([...bytes], [1, 2, 3]);
assert.equal(calls[0].url, '/api/mapillary/tiles/coverage/14/2662/6285');
assert.doesNotMatch(calls[0].url, /MLY/);
});
test('an empty tile (204) is an empty byte array, not an error', async () => {
const { fetchImpl } = fakeFetch(() => new Response(null, { status: 204 }));
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
assert.equal((await source.getTile('coverage', 14, 1, 1)).length, 0);
});
test('a missing key on the proxy surfaces as keyRequired', async () => {
const { fetchImpl } = fakeFetch(() =>
jsonResponse({ error: 'no_key', keyRequired: true }, 503),
);
const source = createMapillarySource({ token: '', fetchImpl });
assert.equal(source.hasToken(), false);
await assert.rejects(
() => source.getTile('coverage', 14, 1, 1),
(error) =>
error instanceof MapillarySourceError &&
error.status === 503 &&
error.keyRequired === true,
);
});
test('graph lookups call graph.mapillary.com with the client token and requested fields', async () => {
const { calls, fetchImpl } = fakeFetch(() =>
jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }),
);
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
const images = await source.getSequenceImages('seq-1');
assert.equal(images.length, 1);
const url = new URL(calls[0].url);
assert.equal(url.host, 'graph.mapillary.com');
assert.equal(url.pathname, '/images');
assert.equal(url.searchParams.get('access_token'), 'MLY|1|abc');
assert.equal(url.searchParams.get('sequence_ids'), 'seq-1');
assert.match(url.searchParams.get('fields'), /geometry/);
const nearest = await source.nearestImages({
lat: 38.5,
lon: -121.5,
radius: 500,
limit: 500,
});
assert.equal(nearest.length, 1);
const nearestUrl = new URL(calls[1].url);
assert.equal(
nearestUrl.searchParams.get('radius'),
'50',
'radius is clamped to the API maximum',
);
assert.equal(nearestUrl.searchParams.get('limit'), '100');
});
test('graph calls without a token fail fast as keyRequired without a request', async () => {
const { calls, fetchImpl } = fakeFetch(() => jsonResponse({}));
const source = createMapillarySource({ token: '', fetchImpl });
await assert.rejects(
() => source.getImage('123'),
(error) =>
error instanceof MapillarySourceError && error.keyRequired === true,
);
assert.equal(calls.length, 0);
});
test('graph API errors carry the upstream message and status', async () => {
const { fetchImpl } = fakeFetch(() =>
jsonResponse({ error: { message: 'Invalid OAuth 2.0 Access Token' } }, 400),
);
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
await assert.rejects(
() => source.getImage('999'),
(error) =>
error instanceof MapillarySourceError &&
error.status === 400 &&
/Invalid OAuth/.test(error.message),
);
});
test('the source exposes only imagery lookups', () => {
const source = createMapillarySource({ token: 'MLY|1|abc' });
for (const gone of ['queryFeatures', 'plan', 'geocode', 'spriteUrl'])
assert.equal(source[gone], undefined, gone);
});
@@ -0,0 +1,289 @@
import * as Cesium from 'cesium';
/** Eye height above the sampled ground when the globe camera follows the viewer. */
const FOLLOW_EYE_HEIGHT_M = 2.4;
/**
* Own the embedded MapillaryJS viewer: lazy-load the library, open images,
* mirror its pose onto the globe, and optionally drive the Cesium camera
* from it ("street cockpit").
*/
export function createViewerBridge({ state, source, parts }) {
const { render } = state.services;
let viewer = null;
let Library = null;
let pendingOpen = null;
let prewarming = false;
/** In-flight viewer construction, so pre-warm and open never build two. */
let creating = null;
function requestRender() {
render?.governorRequestRender?.('mapillary-viewer');
}
function libraryRenderMode(mode) {
const { RenderMode } = Library || {};
if (!RenderMode) return undefined;
return mode === 'fill' ? RenderMode.Fill : RenderMode.Letterbox;
}
async function ensureLibrary() {
if (Library) return Library;
// The viewer stylesheet is vendored into src/ui/styles/mapillary-js.css.
Library = await import('mapillary-js');
return Library;
}
function groundHeightAt(lon, lat, fallback) {
const scene = state.viewer?.scene;
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
let height = null;
try {
if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto);
} catch {
/* not sampleable yet */
}
if (!Number.isFinite(height)) height = scene?.globe?.getHeight?.(carto);
if (!Number.isFinite(height)) height = fallback;
return Number.isFinite(height) ? height : 0;
}
/** Put the Cesium camera where the street-level camera is. */
function followCamera() {
const { position, bearing, tilt } = state.street;
if (!state.street.follow || !position || !state.viewer) return;
const ground = groundHeightAt(
position.lon,
position.lat,
state.street.altitude,
);
state.viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(
position.lon,
position.lat,
ground + FOLLOW_EYE_HEIGHT_M,
),
orientation: {
heading: Cesium.Math.toRadians(bearing || 0),
pitch: Cesium.Math.toRadians(Number.isFinite(tilt) ? tilt : 0),
roll: 0,
},
});
requestRender();
}
/** Frame the current image from a short distance behind it. */
function lookAtPosition() {
const { position, bearing } = state.street;
if (!position || !state.viewer) return;
const ground = groundHeightAt(
position.lon,
position.lat,
state.street.altitude,
);
state.viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
4,
),
{
offset: new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(bearing || 0),
Cesium.Math.toRadians(-32),
140,
),
duration: 1.6,
},
);
}
async function publishPose(image) {
if (!viewer) return;
try {
const [lngLat, pov] = await Promise.all([
viewer.getPosition(),
viewer.getPointOfView(),
]);
state.street.position = { lon: lngLat.lng, lat: lngLat.lat };
state.street.bearing = pov?.bearing ?? state.street.bearing;
state.street.tilt = pov?.tilt ?? 0;
if (image) {
state.street.imageId = image.id;
state.street.isPano = image.merged
? image.cameraType === 'spherical'
: image.cameraType === 'spherical';
state.street.capturedAt = image.capturedAt ?? null;
state.street.sequenceId = image.sequenceId ?? null;
state.street.altitude = Number.isFinite(image.computedAltitude)
? image.computedAltitude
: Number.isFinite(image.originalAltitude)
? image.originalAltitude
: null;
state.street.creator = image.creatorUsername || null;
// mapillary.com highlights the selected image's sequence on the map;
// mirror that once the image itself is on screen, so the sequence
// lookup never competes with the image download.
if (!state.street.loading) selectCurrentSequence();
}
parts.sequences.setMarker(state.street.position, state.street.bearing);
followCamera();
state.notify?.();
} catch {
/* viewer torn down mid-flight */
}
}
function selectCurrentSequence() {
const { sequenceId } = state.street;
if (sequenceId && state.enabled && sequenceId !== state.sequence.selectedId)
parts.sequences.select(sequenceId);
}
/**
* Load the library and stand the viewer up ahead of the first image, so
* opening one only costs the image download. Safe to call repeatedly.
*/
async function prewarm(container = state.street.host) {
if (prewarming || !state.enabled) return;
prewarming = true;
try {
await ensureLibrary();
if (container && state.enabled && !viewer) await ensureViewer(container);
} catch {
/* the real open reports errors */
} finally {
prewarming = false;
}
}
function ensureViewer(container) {
if (viewer && state.street.container === container) return viewer;
if (creating?.container === container) return creating.promise;
const promise = createViewer(container).finally(() => {
if (creating?.promise === promise) creating = null;
});
creating = { container, promise };
return promise;
}
async function createViewer(container) {
destroyViewer();
const { Viewer } = await ensureLibrary();
viewer = new Viewer({
accessToken: source.token,
container,
component: {
cover: false,
bearing: true,
zoom: true,
attribution: true,
},
trackResize: true,
renderMode: libraryRenderMode(state.street.renderMode),
});
state.street.container = container;
viewer.on('image', (event) => publishPose(event.image));
viewer.on('pov', () => publishPose(null));
viewer.on('position', () => publishPose(null));
return viewer;
}
/**
* Open an image in the viewer inside `container`. Returns once the first
* image has loaded; the pose then streams through `state.street`.
*/
async function open(imageId, container, { frame = true } = {}) {
if (!imageId || !container) return;
const id = String(imageId);
state.street.loading = true;
state.street.error = null;
state.street.open = true;
state.notify?.();
pendingOpen = id;
try {
const instance = await ensureViewer(container);
if (pendingOpen !== id) return;
const image = await instance.moveTo(id);
if (pendingOpen !== id) return;
await publishPose(image);
if (frame && !state.street.follow) lookAtPosition();
} catch (error) {
if (pendingOpen === id)
state.street.error = error?.message || 'Image could not be opened';
} finally {
if (pendingOpen === id) state.street.loading = false;
state.notify?.();
}
if (pendingOpen === id && !state.street.error) selectCurrentSequence();
}
/** Switch between showing the whole image ('letterbox') and cropping ('fill'). */
function setRenderMode(mode) {
state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox';
try {
const value = libraryRenderMode(state.street.renderMode);
if (viewer && value !== undefined) viewer.setRenderMode(value);
} catch {
/* viewer not ready */
}
state.notify?.();
}
function setFollow(enabled) {
state.street.follow = enabled === true;
if (state.street.follow) followCamera();
state.notify?.();
}
function resize() {
try {
viewer?.resize();
} catch {
/* no-op */
}
}
function destroyViewer() {
if (viewer) {
try {
viewer.remove();
} catch {
/* already removed */
}
}
viewer = null;
state.street.container = null;
}
/** Close the image. The viewer instance is kept warm for the next open. */
function close() {
pendingOpen = null;
Object.assign(state.street, {
open: false,
follow: false,
imageId: null,
sequenceId: null,
position: null,
bearing: null,
tilt: null,
loading: false,
error: null,
});
parts.sequences.setMarker(null);
state.notify?.();
}
return {
open,
close,
setFollow,
setRenderMode,
resize,
prewarm,
lookAtPosition,
destroy() {
close();
destroyViewer();
},
};
}
+112
View File
@@ -0,0 +1,112 @@
import * as Cesium from 'cesium';
import {
STREET_LEVEL_LAYER_ID,
PICK_PREFIX,
} from './providers/mapillary/policy.js';
/** Click handling for coverage lines and image cones. */
export function createSelection({ state, parts }) {
const { picking, input } = state.services;
function ownsPick(pickedId) {
return (
typeof pickedId === 'string' &&
Object.values(PICK_PREFIX).some((prefix) => pickedId.startsWith(prefix))
);
}
function onClick(click) {
const viewer = state.viewer;
if (!viewer || !state.enabled) return;
if (input?.isPointerFree && !input.isPointerFree()) return;
const picked = viewer.scene.pick(click.position);
const id = picking?.resolvePickId
? picking.resolvePickId(picked)
: picked?.id;
if (!ownsPick(id)) return;
if (id.startsWith(PICK_PREFIX.sequence)) {
parts.sequences.select(id.slice(PICK_PREFIX.sequence.length));
} else if (id.startsWith(PICK_PREFIX.image)) {
parts.street.openImage(id.slice(PICK_PREFIX.image.length));
}
}
/**
* Esc clears the selected sequence, but only once nothing closer to the
* user wants it: an expanded viewer, an open panel or a text field.
*/
function onKeyDown(event) {
if (event.key !== 'Escape' || event.defaultPrevented) return;
if (
event.target?.closest?.(
'.panel-collapsible, [role="dialog"], input, textarea, select',
)
)
return;
if (!state.sequence.selectedId) return;
event.preventDefault();
parts.sequences.clearSelection();
}
let hoverQueued = false;
let hoverCursor = false;
/** Pointer cursor over anything this layer owns, so lines read as clickable. */
function onMove(movement) {
const viewer = state.viewer;
if (!viewer || !state.enabled || hoverQueued) return;
hoverQueued = true;
requestAnimationFrame(() => {
hoverQueued = false;
const canvas = viewer.scene?.canvas;
if (!canvas || viewer.isDestroyed?.()) return;
let hit = false;
try {
const picked = viewer.scene.pick(movement.endPosition);
const id = picking?.resolvePickId
? picking.resolvePickId(picked)
: picked?.id;
hit = ownsPick(id);
} catch {
hit = false;
}
if (hit && !hoverCursor) {
canvas.style.cursor = 'pointer';
hoverCursor = true;
} else if (!hit && hoverCursor) {
canvas.style.cursor = '';
hoverCursor = false;
}
});
}
function install(viewer) {
if (state.clickHandler) return;
state.clickHandler = new Cesium.ScreenSpaceEventHandler(
viewer.scene.canvas,
);
state.clickHandler.setInputAction(
onClick,
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
state.clickHandler.setInputAction(
onMove,
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
);
document.addEventListener('keydown', onKeyDown);
picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, ownsPick);
}
function uninstall() {
if (hoverCursor && state.viewer?.scene?.canvas) {
state.viewer.scene.canvas.style.cursor = '';
hoverCursor = false;
}
if (state.clickHandler && !state.clickHandler.isDestroyed())
state.clickHandler.destroy();
state.clickHandler = null;
document.removeEventListener('keydown', onKeyDown);
picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID);
}
return { install, uninstall, ownsPick };
}
+68
View File
@@ -0,0 +1,68 @@
/** Mutable per-layer state, created once per layer instance. */
export function createState({ services }) {
return {
services,
viewer: null,
enabled: false,
initialized: false,
destroyed: false,
status: null,
statusError: null,
keyRequired: false,
/** Cesium.Credit shown while enabled (CC BY-SA attribution). */
credit: null,
listeners: new Set(),
notifyScheduled: false,
coverage: {
zoom: null,
/** @type {Map<string, {primitive: object|null, sequences: Map<string, object>, count: number}>} */
tiles: new Map(),
/** Tiles from the previous zoom, kept on screen until replacements land. */
stale: new Map(),
staleTimer: null,
pending: new Map(),
generation: 0,
loading: 0,
lastError: null,
debounceTimer: null,
removeCameraListener: null,
terrainReady: null,
hint: '',
kind: null,
filter: { pano: 'all', sinceMs: null },
},
sequence: {
selectedId: null,
images: [],
/** @type {Map<string, Array<object>>} recent sequences' thinned images */
cache: new Map(),
collection: null,
loading: false,
abort: null,
},
street: {
bridge: null,
container: null,
open: false,
follow: false,
imageId: null,
position: null,
bearing: null,
tilt: null,
isPano: false,
capturedAt: null,
sequenceId: null,
loading: false,
error: null,
marker: null,
markerCollection: null,
/** 'letterbox' shows the whole image; 'fill' crops it to the frame. */
renderMode: 'letterbox',
},
clickHandler: null,
};
}
+163
View File
@@ -0,0 +1,163 @@
/**
* Web-Mercator tile arithmetic for Mapillary vector tiles. Pure functions,
* no Cesium dependency, shared by the browser layer and the server executor.
*/
const MAX_LAT = 85.05112878;
function clampLat(lat) {
return Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
}
/** Tile column for a longitude at zoom z. */
export function lonToTileX(lon, z) {
const n = 2 ** z;
const wrapped = ((((lon + 180) % 360) + 360) % 360) - 180;
return Math.min(n - 1, Math.max(0, Math.floor(((wrapped + 180) / 360) * n)));
}
/** Tile row for a latitude at zoom z. */
export function latToTileY(lat, z) {
const n = 2 ** z;
const rad = (clampLat(lat) * Math.PI) / 180;
const y = Math.floor(
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n,
);
return Math.min(n - 1, Math.max(0, y));
}
/** West longitude of tile column x at zoom z. */
function tileXToLon(x, z) {
return (x / 2 ** z) * 360 - 180;
}
/** North latitude of tile row y at zoom z. */
function tileYToLat(y, z) {
const n = Math.PI - (2 * Math.PI * y) / 2 ** z;
return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
}
/**
* Geographic bounds of one tile.
* @returns {{west:number,south:number,east:number,north:number}}
*/
export function tileBounds(x, y, z) {
return {
west: tileXToLon(x, z),
east: tileXToLon(x + 1, z),
north: tileYToLat(y, z),
south: tileYToLat(y + 1, z),
};
}
/**
* Normalize a bbox given as [west, south, east, north] (or an object) into
* finite, ordered numbers. Returns null when the input is not a usable box.
*/
export function normalizeBbox(input) {
const values = Array.isArray(input)
? input
: input && typeof input === 'object'
? [input.west, input.south, input.east, input.north]
: null;
if (!values || values.length !== 4) return null;
const [w, s, e, n] = values.map(Number);
if (![w, s, e, n].every(Number.isFinite)) return null;
const west = Math.max(-180, Math.min(w, e));
const east = Math.min(180, Math.max(w, e));
const south = Math.max(-MAX_LAT, Math.min(s, n));
const north = Math.min(MAX_LAT, Math.max(s, n));
if (east - west <= 0 || north - south <= 0) return null;
return { west, south, east, north };
}
/** Area of a bbox in square degrees (Mapillary's bbox limit unit). */
export function bboxAreaDeg2(bbox) {
const box = normalizeBbox(bbox);
return box ? (box.east - box.west) * (box.north - box.south) : 0;
}
/**
* Number of tiles at zoom z covering a bbox, without allocating them.
*/
export function countTilesForBbox(bbox, z) {
const box = normalizeBbox(bbox);
if (!box) return 0;
const x0 = lonToTileX(box.west, z);
const x1 = lonToTileX(box.east, z);
const y0 = latToTileY(box.north, z);
const y1 = latToTileY(box.south, z);
return (x1 - x0 + 1) * (y1 - y0 + 1);
}
/**
* Enumerate the tiles at zoom z covering a bbox, ordered from the centre of
* the box outwards so a progressive renderer fills in what the user is most
* likely looking at first. `limit` caps the list; the result reports whether
* the cap was hit.
* @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}}
*/
export function tilesForBbox(bbox, z, { limit = Infinity } = {}) {
const box = normalizeBbox(bbox);
if (!box) return { tiles: [], truncated: false, total: 0 };
const x0 = lonToTileX(box.west, z);
const x1 = lonToTileX(box.east, z);
const y0 = latToTileY(box.north, z);
const y1 = latToTileY(box.south, z);
const total = (x1 - x0 + 1) * (y1 - y0 + 1);
const cx = (x0 + x1) / 2;
const cy = (y0 + y1) / 2;
const tiles = [];
for (let y = y0; y <= y1; y++)
for (let x = x0; x <= x1; x++) tiles.push({ x, y, z });
tiles.sort(
(a, b) =>
(a.x - cx) ** 2 + (a.y - cy) ** 2 - ((b.x - cx) ** 2 + (b.y - cy) ** 2),
);
const truncated = tiles.length > limit;
return { tiles: truncated ? tiles.slice(0, limit) : tiles, truncated, total };
}
/**
* Convert a vector-tile-local coordinate (0..extent) into longitude and
* latitude for tile (x, y, z).
* @returns {[number, number]} [lon, lat]
*/
export function tileLocalToLonLat(px, py, extent, x, y, z) {
const n = 2 ** z;
const lon = ((x + px / extent) / n) * 360 - 180;
const merc = Math.PI - (2 * Math.PI * (y + py / extent)) / n;
const lat =
(180 / Math.PI) * Math.atan(0.5 * (Math.exp(merc) - Math.exp(-merc)));
return [lon, lat];
}
/**
* Pick a coverage zoom for a camera height (metres above ground). Higher
* cameras get coarser sequence tiles; below the floor Mapillary has no
* finer tiles than z14.
*/
export function coverageZoomForHeight(heightM) {
if (!Number.isFinite(heightM)) return null;
if (heightM > 60_000) return null;
if (heightM > 12_000) return 11;
if (heightM > 5_000) return 12;
if (heightM > 1_800) return 13;
return 14;
}
/**
* Pick the low-zoom `overview` tile level for a camera far above the ground.
* Mapillary publishes coverage points at z0–5; sequences start at z6 and are
* far too heavy for a continent-sized view. Returns null below the overview
* ceiling (60 km), 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;
}
+87
View File
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
bboxAreaDeg2,
countTilesForBbox,
coverageZoomForHeight,
overviewZoomForHeight,
latToTileY,
lonToTileX,
normalizeBbox,
tileBounds,
tileLocalToLonLat,
tilesForBbox,
} from './tileMath.js';
test('lon/lat to tile matches the Sacramento reference tile', () => {
assert.equal(lonToTileX(-121.4944, 14), 2662);
assert.equal(latToTileY(38.5816, 14), 6286);
const bounds = tileBounds(2662, 6286, 14);
assert.ok(bounds.west < -121.4944 && -121.4944 < bounds.east);
assert.ok(bounds.south < 38.5816 && 38.5816 < bounds.north);
});
test('tile-local coordinates round-trip into the tile bounds', () => {
const bounds = tileBounds(2662, 6286, 14);
const [lon, lat] = tileLocalToLonLat(0, 0, 4096, 2662, 6286, 14);
assert.ok(Math.abs(lon - bounds.west) < 1e-9);
assert.ok(Math.abs(lat - bounds.north) < 1e-9);
const [lon2, lat2] = tileLocalToLonLat(4096, 4096, 4096, 2662, 6286, 14);
assert.ok(Math.abs(lon2 - bounds.east) < 1e-9);
assert.ok(Math.abs(lat2 - bounds.south) < 1e-9);
});
test('normalizeBbox orders and clamps coordinates', () => {
assert.deepEqual(normalizeBbox([-121.36, 38.69, -121.56, 38.44]), {
west: -121.56,
south: 38.44,
east: -121.36,
north: 38.69,
});
assert.equal(normalizeBbox([0, 0, 0, 1]), null);
assert.equal(normalizeBbox('nope'), null);
assert.equal(normalizeBbox([1, 2, Number.NaN, 3]), null);
});
test('city-scale boxes exceed the Mapillary bbox limit and need tiles', () => {
const sacramento = [-121.56, 38.44, -121.36, 38.69];
assert.ok(bboxAreaDeg2(sacramento) > 0.01);
assert.equal(countTilesForBbox(sacramento, 14), 160);
const detroit = [-83.29, 42.25, -82.91, 42.45];
assert.equal(countTilesForBbox(detroit, 14), 234);
});
test('tilesForBbox orders from the centre outwards and honours the cap', () => {
const result = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14, {
limit: 5,
});
assert.equal(result.total, 160);
assert.equal(result.truncated, true);
assert.equal(result.tiles.length, 5);
const centreX = lonToTileX(-121.46, 14);
const centreY = latToTileY(38.565, 14);
assert.ok(Math.abs(result.tiles[0].x - centreX) <= 1);
assert.ok(Math.abs(result.tiles[0].y - centreY) <= 1);
const full = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14);
assert.equal(full.truncated, false);
assert.equal(full.tiles.length, 160);
});
test('coverageZoomForHeight steps from coarse to z14 as the camera descends', () => {
assert.equal(coverageZoomForHeight(100_000), null);
assert.equal(coverageZoomForHeight(30_000), 11);
assert.equal(coverageZoomForHeight(8_000), 12);
assert.equal(coverageZoomForHeight(3_000), 13);
assert.equal(coverageZoomForHeight(500), 14);
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);
});