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