feat(street-level): provider contract with Mapillary as the first provider

Street Level is now a provider-neutral layer modelled on the iD editor's
photo overlay. The core owns what every imagery provider shares: the
enable state and a switch per provider, the imagery filter (360°/flat and
captured-since in relative days), one click handler routed by pick prefix,
one viewer host that mounts whichever provider's viewer adapter owns the
open image, the position marker, camera follow, one on-globe credit per
active provider, and share-link parameters (one bit per provider plus the
filter). Providers implement the contract in registry.js: coverage,
sequences, nearest-image lookup and a viewer adapter emitting a common
pose. Mapillary is the only registered provider; Google Street View,
KartaView and Panoramax slot in beside it.

Panel: a PROVIDERS chip row (one chip per registered provider, keyless
chips read as errors), relative-day SINCE options, a provider-neutral
caption whose link label follows the provider, and no vendor mark in the
header. The panel inner is a rail scroller like CCTV's.

Tests cover the registry, params codec, filter, pick router, UI-state
composition, the provider definition and coverage-tile decoding; the QA
harness switches the provider off and on and checks the filter.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Fable 5.1 committed 2026-09-25 18:34:09 +01:00
1 parent 2dbc684fb1
commit 28e6a1faa7
41 files changed
+2460 -744

No files matched your search

@@ -1,5 +1,7 @@
import * as Cesium from 'cesium';
import { decodeCoverageTile } from './decode.js';
import { passesImageryFilter } from '../../filter.js';
import { cameraHeightAboveGround, visibleBbox } from '../../view.js';
import {
coverageZoomForHeight,
overviewZoomForHeight,
@@ -41,92 +43,6 @@ function sequenceColor(sequence, { selected = false, now = Date.now() } = {}) {
: 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
@@ -149,7 +65,7 @@ export function createCoverage({ state, source }) {
}
function filter() {
return state.coverage.filter;
return state.filter;
}
/** One or more ground primitives for a tile's sequences, in draw batches. */
@@ -264,7 +180,7 @@ export function createCoverage({ state, source }) {
state.coverage.tiles.delete(key);
}
async function loadTile(tile, generation, kind) {
async function loadTile(tile, kind) {
const key = tileKey(tile);
if (state.coverage.tiles.has(key) || state.coverage.pending.has(key))
return;
@@ -348,13 +264,13 @@ export function createCoverage({ state, source }) {
}
function notify() {
state.notify?.();
state.context.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;
if (!viewer || !state.context.isActive() || state.keyRequired) return;
const height = cameraHeightAboveGround(viewer);
const sequenceZoom = coverageZoomForHeight(height);
const overviewZoom = sequenceZoom ? null : overviewZoomForHeight(height);
@@ -365,7 +281,7 @@ export function createCoverage({ state, source }) {
bbox = [-180, -85, 180, 85];
if (!zoom || !bbox) {
state.coverage.hint =
'Point the camera at the globe for Mapillary coverage';
'Point the camera at the globe for street-level coverage';
clear();
notify();
return;
@@ -376,8 +292,6 @@ export function createCoverage({ state, source }) {
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,
@@ -390,7 +304,7 @@ export function createCoverage({ state, source }) {
controller.abort();
state.coverage.pending.delete(key);
}
for (const tile of tiles) loadTile(tile, generation, kind);
for (const tile of tiles) loadTile(tile, kind);
notify();
}
@@ -421,7 +335,6 @@ export function createCoverage({ state, source }) {
}
function clear() {
state.coverage.generation++;
for (const controller of state.coverage.pending.values())
controller.abort();
state.coverage.pending.clear();
@@ -433,20 +346,8 @@ export function createCoverage({ state, source }) {
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 };
/** Rebuild every loaded tile from its decoded cache (after a filter change). */
function rebuild() {
purgeStale();
for (const entry of state.coverage.tiles.values()) {
detachPrimitive(entry);
@@ -500,9 +401,9 @@ export function createCoverage({ state, source }) {
detach,
refresh,
clear,
rebuild,
findSequence,
recolorSequence,
sequenceCount,
setFilter,
};
}
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PbfWriter } from 'pbf';
import { decodeCoverageTile } from './decode.js';
const zigzag = (value) => (value << 1) ^ (value >> 31);
/** A minimal mly1_public-shaped tile: one sequence line and one overview point. */
function sampleTile() {
const writer = new PbfWriter();
writer.writeMessage(
3,
(_, layer) => {
layer.writeVarintField(15, 2);
layer.writeStringField(1, 'sequence');
layer.writeVarintField(5, 4096);
for (const key of ['id', 'captured_at', 'is_pano'])
layer.writeStringField(3, key);
layer.writeMessage(
4,
(__, value) => value.writeStringField(1, 'seq-42'),
null,
);
layer.writeMessage(
4,
(__, value) => value.writeVarintField(5, 1_700_000_000),
null,
);
layer.writeMessage(
4,
(__, value) => value.writeBooleanField(7, true),
null,
);
layer.writeMessage(
2,
(__, feature) => {
feature.writeVarintField(1, 7);
feature.writePackedVarint(2, [0, 0, 1, 1, 2, 2]);
feature.writeVarintField(3, 2); // LineString
feature.writePackedVarint(4, [
9,
zigzag(100),
zigzag(100), // MoveTo
10,
zigzag(200),
zigzag(200), // LineTo ×1
]);
},
null,
);
},
null,
);
writer.writeMessage(
3,
(_, layer) => {
layer.writeVarintField(15, 2);
layer.writeStringField(1, 'overview');
layer.writeVarintField(5, 4096);
layer.writeStringField(3, 'is_pano');
layer.writeMessage(
4,
(__, value) => value.writeBooleanField(7, false),
null,
);
layer.writeMessage(
2,
(__, feature) => {
feature.writeVarintField(1, 3);
feature.writePackedVarint(2, [0, 0]);
feature.writeVarintField(3, 1); // Point
feature.writePackedVarint(4, [9, zigzag(2048), zigzag(2048)]);
},
null,
);
},
null,
);
return new Uint8Array(writer.finish());
}
test('sequences and overview points decode into lon/lat records', () => {
const decoded = decodeCoverageTile(sampleTile(), { x: 0, y: 0, z: 1 });
assert.equal(decoded.sequences.length, 1);
const [sequence] = decoded.sequences;
assert.equal(sequence.id, 'seq-42');
assert.equal(sequence.capturedAt, 1_700_000_000);
assert.equal(sequence.isPano, true);
assert.equal(sequence.coordinates.length, 2);
const [[lon0, lat0], [lon1, lat1]] = sequence.coordinates;
assert.ok(
lon0 > -180 && lon0 < 0 && lon1 > lon0,
'west half of tile 0/0 at z1',
);
assert.ok(lat0 > 0 && lat1 < lat0, 'northern hemisphere, moving south');
assert.equal(decoded.overview.length, 1);
assert.equal(decoded.overview[0].id, '3');
assert.equal(decoded.overview[0].isPano, false);
assert.ok(Math.abs(decoded.overview[0].lon - -90) < 1e-6, 'tile centre');
assert.equal(decoded.images.length, 0, 'image layer skipped by default');
});
test('an empty tile decodes to empty lists', () => {
assert.deepEqual(
decodeCoverageTile(new Uint8Array(0), { x: 0, y: 0, z: 0 }),
{
sequences: [],
images: [],
overview: [],
},
);
});
@@ -0,0 +1,210 @@
import { createCoverage } from './coverage.js';
import { createSequences } from './sequences.js';
import { createMapillaryViewer } from './viewer.js';
import { passesImageryFilter } from '../../filter.js';
import {
COLORS,
MAPILLARY_CREDIT_HTML,
MAPILLARY_KEY_ID,
MAPILLARY_LABEL,
MAPILLARY_LEGEND,
MAPILLARY_NAME,
MAPILLARY_PROVIDER_ID,
NEAREST_LIMIT,
NEAREST_RADIUS_M,
PICK_PREFIX,
mapillaryImageUrl,
} from './policy.js';
export { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js';
const SOURCE_METHODS = [
'getStatus',
'getTile',
'getImage',
'getSequenceImages',
'nearestImages',
];
/** Per-provider mutable state, created once per `create()`. */
function createProviderState(context) {
return {
context,
services: context.services,
viewer: null,
keyRequired: false,
status: null,
filter: context.getFilter(),
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(),
loading: 0,
lastError: null,
debounceTimer: null,
removeCameraListener: null,
terrainReady: null,
hint: '',
kind: null,
},
sequence: {
selectedId: null,
images: [],
/** @type {Map<string, Array<object>>} recent sequences' thinned images */
cache: new Map(),
collection: null,
loading: false,
abort: null,
},
};
}
/**
* Mapillary as a Street Level provider: coverage from cached vector tiles,
* per-sequence image cones from the Graph API and the MapillaryJS viewer.
* @param {{source: object}} options A source from ./source.js (or a stand-in with the same methods).
* @returns {import('../../registry.js').StreetLevelProvider}
*/
export function createMapillaryProvider({ source }) {
if (!SOURCE_METHODS.every((method) => typeof source?.[method] === 'function'))
throw new TypeError('A Mapillary source is required');
return Object.freeze({
id: MAPILLARY_PROVIDER_ID,
name: MAPILLARY_NAME,
label: MAPILLARY_LABEL,
requiresKeyId: MAPILLARY_KEY_ID,
pickPrefix: PICK_PREFIX.root,
colors: COLORS,
credit: Object.freeze({
key: MAPILLARY_PROVIDER_ID,
html: MAPILLARY_CREDIT_HTML,
}),
capabilities: Object.freeze({
coverage: 'tiles',
sequences: true,
pano: true,
capturedAt: true,
creator: true,
follow: true,
}),
legend: MAPILLARY_LEGEND,
externalUrl: mapillaryImageUrl,
create(context) {
const state = createProviderState(context);
const parts = {};
parts.coverage = createCoverage({ state, source, parts });
parts.sequences = createSequences({ state, source, parts });
const viewer = createMapillaryViewer({
source,
render: context.services?.render,
});
return {
async status() {
try {
state.status = await source.getStatus();
state.keyRequired = state.status?.configured !== true;
} catch {
state.status = null;
state.keyRequired = !source.hasToken();
}
context.notify();
return { configured: !state.keyRequired };
},
init(cesiumViewer) {
state.viewer = cesiumViewer;
parts.sequences.ensureCollections(cesiumViewer);
parts.sequences.setVisible(false);
},
activate(cesiumViewer) {
state.viewer = cesiumViewer;
parts.sequences.setVisible(true);
parts.coverage.attach(cesiumViewer);
},
deactivate() {
parts.coverage.detach();
parts.coverage.clear();
parts.sequences.clearSelection();
parts.sequences.setVisible(false);
},
destroy(cesiumViewer) {
parts.coverage.detach();
parts.coverage.clear();
parts.sequences.destroy(cesiumViewer);
},
refreshCoverage: () => parts.coverage.refresh(),
setFilter(filter) {
state.filter = filter;
parts.coverage.rebuild();
parts.sequences.rerender();
},
coverageStats() {
return {
count: parts.coverage.sequenceCount(),
zoom: state.coverage.zoom,
kind: state.coverage.kind,
loading: state.coverage.loading > 0,
hint: state.coverage.hint,
error: state.coverage.lastError,
keyRequired: state.keyRequired,
};
},
handlePick(pickId) {
if (pickId.startsWith(PICK_PREFIX.sequence)) {
parts.sequences.select(pickId.slice(PICK_PREFIX.sequence.length));
return true;
}
if (pickId.startsWith(PICK_PREFIX.image)) {
context.actions.openImage(pickId.slice(PICK_PREFIX.image.length));
return true;
}
return false;
},
selectSequence: (sequenceId) => parts.sequences.select(sequenceId),
clearSequence: () => parts.sequences.clearSelection(),
sequenceStats: () => ({
selectedId: state.sequence.selectedId,
images: state.sequence.images.length,
loading: state.sequence.loading,
}),
/** Nearest image that passes the imagery filter, or null. */
async nearestImage({ lat, lon }) {
const images = await source.nearestImages({
lat,
lon,
radius: NEAREST_RADIUS_M,
limit: NEAREST_LIMIT,
});
const hit = images.find((record) =>
passesImageryFilter(
{
isPano: record.is_pano === true,
capturedAt: Number(record.captured_at) || 0,
},
state.filter,
),
);
return hit ? String(hit.id) : null;
},
viewer,
};
},
});
}
@@ -1,13 +1,17 @@
/** Identity and tuning for the Mapillary street-level layer. */
export const STREET_LEVEL_LAYER_ID = 'street-level';
import { COLORS as SHARED_COLORS } from '../../policy.js';
/** Identity and tuning for the Mapillary street-level provider. */
export const MAPILLARY_PROVIDER_ID = 'mapillary';
export const MAPILLARY_NAME = 'Mapillary';
export const MAPILLARY_LABEL = 'MAPILLARY';
export const MAPILLARY_KEY_ID = 'mapillary';
export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com';
/** Stable id prefixes for picked primitives. */
/** Stable id prefixes for picked primitives; every one starts with `mly:`. */
export const PICK_PREFIX = Object.freeze({
root: 'mly:',
sequence: 'mly:seq:',
image: 'mly:img:',
position: 'mly:pos',
});
/** Brand green for coverage; panoramas get a distinct hue; selection is GEV cyan. */
@@ -15,9 +19,8 @@ export const COLORS = Object.freeze({
coverage: '#05cb63',
coverageOld: '#2e7d5b',
pano: '#ff4fd8',
selected: '#00d4ff',
selected: SHARED_COLORS.selected,
image: '#e8eaed',
position: '#ffb300',
});
/** Camera-driven coverage refresh. */
@@ -41,12 +44,30 @@ export const NEAREST_RADIUS_M = 50;
export const NEAREST_LIMIT = 8;
/**
* On-globe credit shown while the layer is enabled. Mapillary imagery and
* On-globe credit shown while the provider is active. 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';
/** Coverage colour key, in the order the panel lists it. */
export const MAPILLARY_LEGEND = Object.freeze([
Object.freeze({
key: 'recent',
label: `Recent (≤${Math.round(COVERAGE_RECENT_DAYS / 365)} yr)`,
color: COLORS.coverage,
}),
Object.freeze({ key: 'older', label: 'Older', color: COLORS.coverageOld }),
Object.freeze({ key: 'pano', label: '360°', color: COLORS.pano }),
]);
/** Deep link to an image on mapillary.com, as the web app shares them. */
export function mapillaryImageUrl(imageId) {
const id = String(imageId || '').trim();
if (!id) return 'https://www.mapillary.com/app/';
return `https://www.mapillary.com/app/?pKey=${encodeURIComponent(id)}&focus=photo`;
}
/** 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';
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMapillaryProvider, mapillaryImageUrl } from './index.js';
import { validateProviders } from '../../registry.js';
function fakeSource({ nearest = [] } = {}) {
return {
hasToken: () => true,
getStatus: async () => ({ configured: true }),
getTile: async () => new Uint8Array(0),
getImage: async () => ({}),
getSequenceImages: async () => [],
nearestImages: async () => nearest,
};
}
function fakeContext(filter = { pano: 'all', sinceMs: null }) {
const opened = [];
return {
opened,
services: {},
getViewer: () => null,
getFilter: () => filter,
isActive: () => true,
notify: () => {},
actions: {
openImage: (id) => {
opened.push(id);
},
reportError: () => {},
},
};
}
test('the definition satisfies the Street Level provider contract', () => {
const def = createMapillaryProvider({ source: fakeSource() });
assert.doesNotThrow(() => validateProviders([def]));
assert.equal(def.id, 'mapillary');
assert.equal(def.label, 'MAPILLARY');
assert.equal(def.requiresKeyId, 'mapillary');
assert.equal(def.pickPrefix, 'mly:');
assert.equal(def.capabilities.coverage, 'tiles');
assert.match(def.credit.html, /CC BY-SA 4\.0/);
assert.deepEqual(
def.legend.map((entry) => entry.key),
['recent', 'older', 'pano'],
);
assert.throws(() => createMapillaryProvider({ source: {} }), /source/);
});
test('image deep links match the mapillary.com share format', () => {
const def = createMapillaryProvider({ source: fakeSource() });
assert.equal(
def.externalUrl(1814275685699406),
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
);
assert.equal(mapillaryImageUrl(' '), 'https://www.mapillary.com/app/');
});
test('picks route sequences to selection and images to the core opener', () => {
const context = fakeContext();
const instance = createMapillaryProvider({ source: fakeSource() }).create(
context,
);
assert.equal(instance.handlePick('mly:img:77'), true);
assert.deepEqual(context.opened, ['77']);
assert.equal(instance.handlePick('cctv:1'), false);
assert.deepEqual(instance.sequenceStats(), {
selectedId: null,
images: 0,
loading: false,
});
});
test('nearestImage honours the imagery filter and reports key status', async () => {
const nearest = [
{ id: 1, is_pano: false, captured_at: 10 },
{ id: 2, is_pano: true, captured_at: 20 },
];
const source = fakeSource({ nearest });
const flat = createMapillaryProvider({ source }).create(fakeContext());
assert.equal(await flat.nearestImage({ lat: 1, lon: 2 }), '1');
const pano = createMapillaryProvider({ source }).create(
fakeContext({ pano: 'pano', sinceMs: null }),
);
assert.equal(await pano.nearestImage({ lat: 1, lon: 2 }), '2');
const none = createMapillaryProvider({ source }).create(
fakeContext({ pano: 'all', sinceMs: 100 }),
);
assert.equal(await none.nearestImage({ lat: 1, lon: 2 }), null);
assert.deepEqual(await flat.status(), { configured: true });
assert.equal(flat.coverageStats().keyRequired, false);
});
@@ -1,6 +1,6 @@
import * as Cesium from 'cesium';
import { imageConeGlyph } from '../../glyphs.js';
import { passesImageryFilter } from './coverage.js';
import { passesImageryFilter } from '../../filter.js';
import {
COLORS,
IMAGE_CONE_MIN_SPACING_M,
@@ -8,6 +8,8 @@ import {
PICK_PREFIX,
} from './policy.js';
const SPRITE_ID = 'street-level:mapillary-cones';
/** How many recently viewed sequences keep their image list in memory. */
const SEQUENCE_CACHE_SIZE = 40;
@@ -20,7 +22,7 @@ function metresBetween(a, b) {
}
/** Normalize a graph image record into the shape the cones use. */
function normalizeSequenceImage(record) {
export function normalizeSequenceImage(record) {
const coordinates = record?.geometry?.coordinates;
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
return {
@@ -37,7 +39,7 @@ function normalizeSequenceImage(record) {
}
/** Drop images closer than the spacing to the previous kept one. */
function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
const kept = [];
let last = null;
for (const image of images) {
@@ -49,7 +51,7 @@ function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
return kept;
}
/** Image cones for one selected sequence, plus the viewer position marker. */
/** Image cones for one selected sequence. */
export function createSequences({ state, source, parts }) {
const { render, sprites } = state.services;
@@ -57,27 +59,17 @@ export function createSequences({ state, source, parts }) {
render?.governorRequestRender?.('mapillary-sequence');
}
function notify() {
state.context.notify();
}
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,
);
}
if (state.sequence.collection) return;
state.sequence.collection = new Cesium.BillboardCollection({
scene: viewer.scene,
});
viewer.scene.primitives.add(state.sequence.collection);
sprites?.registerSpriteCollection?.(SPRITE_ID, state.sequence.collection);
}
function clearCones() {
@@ -92,7 +84,7 @@ export function createSequences({ state, source, parts }) {
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;
if (!passesImageryFilter(image, state.filter)) continue;
collection.add({
id: `${PICK_PREFIX.image}${image.id}`,
position: Cesium.Cartesian3.fromDegrees(image.lon, image.lat),
@@ -138,13 +130,13 @@ export function createSequences({ state, source, parts }) {
state.sequence.loading = false;
state.sequence.images = cached;
renderCones(cached);
state.notify?.();
notify();
return;
}
const controller = new AbortController();
state.sequence.abort = controller;
state.sequence.loading = true;
state.notify?.();
notify();
try {
const records = await source.getSequenceImages(sequenceId, {
signal: controller.signal,
@@ -161,13 +153,15 @@ export function createSequences({ state, source, parts }) {
renderCones(images);
} catch (error) {
if (!controller.signal.aborted)
state.street.error = error?.message || 'Sequence images unavailable';
state.context.actions.reportError(
error?.message || 'Sequence images unavailable',
);
} finally {
if (state.sequence.abort === controller) {
state.sequence.loading = false;
state.sequence.abort = null;
}
state.notify?.();
notify();
}
}
@@ -180,39 +174,7 @@ export function createSequences({ state, source, parts }) {
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();
notify();
}
/** Re-draw the current sequence's cones (after an imagery filter change). */
@@ -220,34 +182,18 @@ export function createSequences({ state, source, parts }) {
if (state.sequence.images.length) renderCones(state.sequence.images);
}
function hide(visible) {
function setVisible(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;
const collection = state.sequence.collection;
if (collection) {
sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection);
viewer?.scene?.primitives?.remove(collection);
state.sequence.collection = null;
}
state.sequence.images = [];
state.sequence.selectedId = null;
@@ -257,9 +203,8 @@ export function createSequences({ state, source, parts }) {
ensureCollections,
select,
clearSelection,
setMarker,
rerender,
setVisible: hide,
setVisible,
destroy,
};
}
@@ -1,21 +1,23 @@
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;
import { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js';
/**
* 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").
* The Mapillary viewer adapter: lazy-load MapillaryJS, mount it in the host
* the core hands over, open images, and emit a provider-neutral pose for
* every image, point-of-view or position change.
* @returns {import('../../registry.js').ViewerAdapter}
*/
export function createViewerBridge({ state, source, parts }) {
const { render } = state.services;
export function createMapillaryViewer({ source, render } = {}) {
let viewer = null;
let Library = null;
let container = null;
let pendingOpen = null;
let prewarming = false;
/** In-flight viewer construction, so pre-warm and open never build two. */
let creating = null;
let renderMode = 'letterbox';
/** Metadata of the image on screen; pov/position events reuse it. */
let current = null;
const listeners = new Set();
function requestRender() {
render?.governorRequestRender?.('mapillary-viewer');
@@ -34,67 +36,30 @@ export function createViewerBridge({ state, source, parts }) {
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 */
function emit(pose) {
for (const listener of [...listeners]) {
try {
listener(pose);
} catch {
/* listener errors are the core's to log */
}
}
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,
},
);
/** Read MapillaryJS image metadata into the neutral pose shape. */
function describe(image) {
return {
imageId: String(image.id),
isPano: image.cameraType === 'spherical',
capturedAt: image.capturedAt ?? null,
sequenceId: image.sequenceId ?? null,
altitude: Number.isFinite(image.computedAltitude)
? image.computedAltitude
: Number.isFinite(image.originalAltitude)
? image.originalAltitude
: null,
creator: image.creatorUsername || null,
};
}
async function publishPose(image) {
@@ -104,74 +69,38 @@ export function createViewerBridge({ state, source, parts }) {
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?.();
if (image) current = describe(image);
if (!current || !viewer) return;
emit({
providerId: MAPILLARY_PROVIDER_ID,
...current,
position: { lon: lngLat.lng, lat: lngLat.lat },
bearing: Number.isFinite(pov?.bearing) ? pov.bearing : null,
tilt: Number.isFinite(pov?.tilt) ? pov.tilt : 0,
externalUrl: mapillaryImageUrl(current.imageId),
});
requestRender();
} 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(() => {
function ensureViewer(host) {
if (viewer && container === host) return viewer;
if (creating?.container === host) return creating.promise;
const promise = createViewer(host).finally(() => {
if (creating?.promise === promise) creating = null;
});
creating = { container, promise };
creating = { container: host, promise };
return promise;
}
async function createViewer(container) {
async function createViewer(host) {
destroyViewer();
const { Viewer } = await ensureLibrary();
viewer = new Viewer({
accessToken: source.token,
container,
container: host,
component: {
cover: false,
bearing: true,
@@ -179,70 +108,15 @@ export function createViewerBridge({ state, source, parts }) {
attribution: true,
},
trackResize: true,
renderMode: libraryRenderMode(state.street.renderMode),
renderMode: libraryRenderMode(renderMode),
});
state.street.container = container;
container = host;
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 {
@@ -252,38 +126,77 @@ export function createViewerBridge({ state, source, parts }) {
}
}
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?.();
container = null;
current = null;
}
return {
open,
close,
setFollow,
setRenderMode,
resize,
prewarm,
lookAtPosition,
destroy() {
close();
async mount(host) {
if (!host) throw new Error('Street Level viewer has no host element');
await ensureViewer(host);
},
/** Resolves once the image is on screen and its first pose was emitted. */
async open(imageId) {
const id = String(imageId);
if (!container) throw new Error('Mapillary viewer is not mounted');
pendingOpen = id;
const instance = await ensureViewer(container);
if (pendingOpen !== id) return;
const image = await instance.moveTo(id);
if (pendingOpen !== id) return;
await publishPose(image);
},
close() {
pendingOpen = null;
current = null;
},
unmount() {
pendingOpen = null;
destroyViewer();
},
resize() {
try {
viewer?.resize();
} catch {
/* no-op */
}
},
/**
* 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 prewarm(host) {
if (prewarming || !host) return;
prewarming = true;
try {
await ensureLibrary();
if (!viewer) await ensureViewer(host);
} catch {
/* the real open reports errors */
} finally {
prewarming = false;
}
},
/** Show the whole image ('letterbox') or crop it to the frame ('fill'). */
setRenderMode(mode) {
renderMode = mode === 'fill' ? 'fill' : 'letterbox';
try {
const value = libraryRenderMode(renderMode);
if (viewer && value !== undefined) viewer.setRenderMode(value);
} catch {
/* viewer not ready */
}
},
onPose(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}