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

@@ -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();
},
};
}