mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user