feat(mapillary): street-level layer core — coverage, sequences, viewer bridge, query engine

Camera-driven Mapillary coverage (z0–5 overview points from orbit, z11–14
ground-clamped sequence lines below 60 km), per-sequence image cones, an
embedded MapillaryJS viewer bridge with a camera-follow mode, sprite icons
for map features, and a query engine that turns a plan into a streamed
z14 tile fan-out. Registers an on-globe CC BY-SA 4.0 credit while enabled.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Fable 5.1 committed 2026-09-23 15:44:32 +01:00
1 parent 0d41b6be54
commit fa5710bcf2
46 files changed
+9935 -32

No files matched your search

+1
View File
@@ -4,6 +4,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
militaryFlightsLayer: 'military',
satellitesLayer: 'satellites',
cctvLayer: 'cctv',
mapillaryLayer: 'mapillary',
radioLayer: 'radio',
bikeshareLayer: 'bikeshare',
transitLayer: 'transit',
+1
View File
@@ -93,6 +93,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
'military-awareness',
'military-installations',
'rocket-launches',
'mapillary',
];
const layers = ids.map((id) => ({ id }));
const catalog = createLayerCatalog(layers, metadata(layers));
+14
View File
@@ -18,6 +18,7 @@ import { createApplicationAwareness } from './layers/militaryAwareness.js';
import { createApplicationFirms } from './layers/firms.js';
import { createApplicationEarthquakes } from './layers/earthquakes.js';
import { createApplicationCables } from './layers/submarineCables.js';
import { createApplicationMapillary } from './layers/mapillary.js';
import { createInfrastructureLayers } from '../data/infrastructure.js';
import { localGeoJsonServices } from './localGeojsonServices.js';
import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
@@ -44,6 +45,18 @@ const SOURCE_METHODS = Object.freeze({
firms: ['getSnapshot'],
earthquakes: ['getSnapshot'],
cables: ['fetch'],
mapillary: [
'getStatus',
'getTile',
'queryFeatures',
'plan',
'geocode',
'getImage',
'getSequenceImages',
'nearestImages',
'getMapFeature',
'spriteUrl',
],
});
/** Construct the current catalog without choosing any source provider.
@@ -112,6 +125,7 @@ export function createApplicationCatalog({
military,
createApplicationEarthquakes({ source: sources.earthquakes }),
createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationMapillary({ surface, source: sources.mapillary }),
satellites,
createApplicationLaunches({ source: sources.launches, satellites }),
createApplicationTraffic({ source: sources.traffic }),
+1 -1
View File
@@ -39,7 +39,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal,
surface: fixtureSurface(b.signal),
});
assert.equal(first.layers.length, 21);
assert.equal(first.layers.length, 22);
assert.ok(first.get('transit'));
const order = first.layers.map(({ id }) => id);
assert.deepEqual(
+19
View File
@@ -0,0 +1,19 @@
import { createMapillaryLayer } from '../../layers/mapillary/index.js';
import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js';
import * as input from '../../data/inputOwnership.js';
import * as render from '../../renderGovernor.js';
/** Construct the Mapillary layer using the application scene owners and a supplied source. */
export function createApplicationMapillary({ surface, source }) {
return createMapillaryLayer({
source,
services: {
sprites,
picking,
input,
render,
ground: surface?.groundFloor ?? null,
},
});
}
+1
View File
@@ -344,6 +344,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
disposition: 'enabled-only',
}),
Object.freeze({ id: 'local-firms', token: 'w', disposition: 'enabled-only' }),
Object.freeze({ id: 'mapillary', token: 'y', disposition: 'enabled-only' }),
Object.freeze({
id: 'military',
token: 'm',
+19 -1
View File
@@ -25,7 +25,7 @@ export const KEY_SETUP_APPEND_HEADER =
* Provider credentials, in display order — most magic per
* minute first. `tier` mirrors the README's color legend: 'metered' (🔴) is a
* billing-enabled account, 'free' (🟡) is a register-and-paste key.
* `clientExposed` marks the two keys that are injected into the browser
* `clientExposed` marks the keys that are injected into the browser
* bundle by design (restrict them at the provider, per SECURITY.md).
* `hidden` keeps advanced configuration out of the panel and missing-key count.
*/
@@ -98,6 +98,24 @@ export const KEY_SETUP_KEYS = Object.freeze([
envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']),
tier: 'free',
}),
Object.freeze({
id: 'mapillary',
title: 'MAPILLARY',
unlocks:
'Street-level imagery, detected objects + traffic signs, and the Mapillary AI query',
getUrl: 'https://www.mapillary.com/dashboard/developers',
envVars: Object.freeze(['MAPILLARY_CLIENT_TOKEN']),
tier: 'free',
clientExposed: true,
}),
Object.freeze({
id: 'anthropic',
title: 'ANTHROPIC',
unlocks: 'Mapillary AI — type a question, get it drawn on the globe',
getUrl: 'https://console.anthropic.com/settings/keys',
envVars: Object.freeze(['ANTHROPIC_API_KEY']),
tier: 'metered',
}),
Object.freeze({
id: 'launch-library',
title: 'LAUNCH LIBRARY',
+524
View File
@@ -0,0 +1,524 @@
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) {
primitive.show = !state.coverage.resting;
scene.groundPrimitives.add(primitive);
}
} else {
const { collection, count } = buildOverviewCollection(entry.points);
entry.primitive = collection;
entry.count = count;
// Orbit-level points never rest: results are invisible at that scale.
collection.show = true;
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();
}
/** Rest (hide) the street-level sequence web while results are shown; orbit points stay. */
function setResting(resting) {
state.coverage.resting = resting === true;
for (const entry of state.coverage.tiles.values())
if (entry.kind === 'sequence')
for (const primitive of entry.primitives || [])
primitive.show = !state.coverage.resting;
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,
setResting,
setFilter,
};
}
+145
View File
@@ -0,0 +1,145 @@
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;
}
/** Decode a base64 string into bytes without Node's Buffer. */
function base64ToBytes(base64) {
if (typeof Buffer !== 'undefined' && typeof Buffer.from === 'function')
return new Uint8Array(Buffer.from(base64, 'base64'));
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
/**
* Decode a Mapillary detection `geometry` (a base64 vector tile whose single
* layer holds the segmentation polygon in a 4096 grid) into polygons of
* image-normalized [x, y] points in [0, 1], the coordinate space the
* MapillaryJS tag component expects.
* @param {string} base64
* @returns {Array<Array<[number, number]>>} One ring per polygon.
*/
export function decodeDetectionPolygons(base64) {
if (!base64) return [];
let tile;
try {
tile = new VectorTile(new PbfReader(base64ToBytes(base64)));
} catch {
return [];
}
const polygons = [];
for (const layer of Object.values(tile.layers)) {
for (let i = 0; i < layer.length; i++) {
const feature = layer.feature(i);
for (const ring of feature.loadGeometry()) {
if (ring.length < 3) continue;
const points = ring.map((point) => [
Math.min(1, Math.max(0, point.x / layer.extent)),
Math.min(1, Math.max(0, point.y / layer.extent)),
]);
const [fx, fy] = points[0];
const [lx, ly] = points[points.length - 1];
if (fx !== lx || fy !== ly) points.push([fx, fy]);
polygons.push(points);
}
}
}
return polygons;
}
+25
View File
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { decodeDetectionPolygons } from './decode.js';
// A real detection geometry from graph.mapillary.com (construction--barrier--curb).
const SAMPLE =
'Gjp4AgoGbXB5LW9yKIAgEisYAwgBIiUJzi+gKHoACAMAAwADAAMAAwADAAMAAAcEAAQABAAEAAQABAAP';
test('a detection geometry decodes into a closed, normalized polygon', () => {
const polygons = decodeDetectionPolygons(SAMPLE);
assert.equal(polygons.length, 1);
const ring = polygons[0];
assert.ok(ring.length >= 4);
for (const [x, y] of ring) {
assert.ok(x >= 0 && x <= 1, `x in range: ${x}`);
assert.ok(y >= 0 && y <= 1, `y in range: ${y}`);
}
assert.deepEqual(ring[0], ring[ring.length - 1]);
assert.ok(Math.abs(ring[0][0] - 3047 / 4096) < 1e-9);
});
test('garbage input yields no polygons instead of throwing', () => {
assert.deepEqual(decodeDetectionPolygons('not base64!!'), []);
assert.deepEqual(decodeDetectionPolygons(''), []);
});
+313
View File
@@ -0,0 +1,313 @@
import * as Cesium from 'cesium';
import {
featureDotGlyph,
rasterizeSprite,
selectionRingGlyph,
} from './glyphs.js';
import {
COLORS,
FEATURE_ICON_LIMIT,
FEATURE_ICON_MAX,
FEATURE_ICON_SIZE_PX,
FEATURE_POINT_SIZE_PX,
PICK_PREFIX,
} from './policy.js';
import { colorForValue } from './values.js';
/**
* Query-result rendering: fast points while a query streams, Mapillary
* sprite icons once it completes (clamped to the ground for modest sets,
* height-sampled for city-sized ones), plus a selection ring.
*/
export function createFeatures({ state, source }) {
const { render, sprites } = state.services;
/** @type {Map<string, Promise<HTMLCanvasElement>>} */
const spriteImages = new Map();
function requestRender() {
render?.governorRequestRender?.('mapillary-features');
}
function ensureCollections(viewer) {
const scene = viewer.scene;
if (!state.features.points) {
state.features.points = new Cesium.PointPrimitiveCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
scene.primitives.add(state.features.points);
sprites?.registerSpriteCollection?.('mapillary', state.features.points);
}
if (!state.features.collection) {
state.features.collection = new Cesium.BillboardCollection({ scene });
scene.primitives.add(state.features.collection);
sprites?.registerSpriteCollection?.(
'mapillary',
state.features.collection,
);
}
if (!state.features.highlight) {
state.features.highlight = new Cesium.BillboardCollection({ scene });
scene.primitives.add(state.features.highlight);
sprites?.registerSpriteCollection?.(
'mapillary',
state.features.highlight,
);
}
}
function groundPosition(lon, lat, lift = 1) {
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
const height = state.viewer?.scene?.globe?.getHeight?.(carto);
return Cesium.Cartesian3.fromDegrees(
lon,
lat,
Number.isFinite(height) ? height + lift : lift,
);
}
const colorCache = new Map();
function cesiumColor(value) {
let color = colorCache.get(value);
if (!color) {
color = Cesium.Color.fromCssColorString(colorForValue(value));
colorCache.set(value, color);
}
return color;
}
/** Sprite canvas for a value, cached; falls back to a coloured dot. */
function spriteImage(value) {
let pending = spriteImages.get(value);
if (!pending) {
pending = fetch(source.spriteUrl(value))
.then((response) => {
if (!response.ok) throw new Error(`sprite ${response.status}`);
return response.text();
})
.then((svg) => rasterizeSprite(svg, { size: 60 }))
.catch(() =>
featureDotGlyph({ size: 60, color: colorForValue(value) }),
);
spriteImages.set(value, pending);
}
return pending;
}
/** Raw sprite SVG text for a value (3D sign faces), or null. */
async function spriteSvg(value) {
try {
const response = await fetch(source.spriteUrl(value));
return response.ok ? await response.text() : null;
} catch {
return null;
}
}
/** Reset the result set and its primitives. */
function reset() {
state.features.points?.removeAll();
state.features.collection?.removeAll();
state.features.highlight?.removeAll();
Object.assign(state.features, {
rows: [],
byId: new Map(),
billboardById: new Map(),
counts: new Map(),
earliest: null,
latest: null,
total: 0,
truncated: false,
failed: 0,
progress: { done: 0, tiles: 0 },
iconMode: false,
selectedId: null,
iconsHidden: false,
});
if (state.features.points) state.features.points.show = true;
if (state.features.collection) state.features.collection.show = true;
requestRender();
}
/** Append streamed rows: [lon, lat, id, value, first, last]. */
function appendRows(rows) {
const points = state.features.points;
if (!points) return;
const features = state.features;
for (const row of rows) {
const [lon, lat, id, value, first, last] = row;
if (features.byId.has(id)) continue;
features.byId.set(id, row);
features.rows.push(row);
features.counts.set(value, (features.counts.get(value) || 0) + 1);
if (first)
features.earliest = features.earliest
? Math.min(features.earliest, first)
: first;
if (last)
features.latest = features.latest
? Math.max(features.latest, last)
: last;
points.add({
id: `${PICK_PREFIX.feature}${id}`,
position: groundPosition(lon, lat),
color: cesiumColor(value),
outlineColor: Cesium.Color.fromBytes(255, 255, 255, 200),
outlineWidth: 1.5,
pixelSize: FEATURE_POINT_SIZE_PX,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
scaleByDistance: new Cesium.NearFarScalar(500, 1.2, 40_000, 0.5),
});
}
features.total = features.rows.length;
requestRender();
}
/**
* Once a query completes, swap the dots for Mapillary icons. Sets up to
* FEATURE_ICON_LIMIT clamp to terrain and 3D tiles; larger sets sit on the
* sampled globe height (clamping tens of thousands of billboards stalls the
* frame). Beyond FEATURE_ICON_MAX the points stay.
*/
async function upgradeToIcons() {
const features = state.features;
if (!features.collection || features.rows.length > FEATURE_ICON_MAX) return;
const values = [...features.counts.keys()];
const images = new Map(
await Promise.all(
values.map(async (value) => [value, await spriteImage(value)]),
),
);
if (features.loading || !state.viewer) return;
const clamp = features.rows.length <= FEATURE_ICON_LIMIT;
features.collection.removeAll();
features.billboardById = new Map();
for (const [lon, lat, id, value] of features.rows) {
const billboard = features.collection.add({
id: `${PICK_PREFIX.feature}${id}`,
position: clamp
? Cesium.Cartesian3.fromDegrees(lon, lat)
: groundPosition(lon, lat, 0.5),
image: images.get(value),
imageId: `mly-sprite:${value}`,
width: FEATURE_ICON_SIZE_PX,
height: FEATURE_ICON_SIZE_PX,
...(clamp
? { heightReference: Cesium.HeightReference.CLAMP_TO_GROUND }
: {}),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
scaleByDistance: new Cesium.NearFarScalar(300, 1.1, 20_000, 0.35),
});
features.billboardById.set(id, billboard);
}
features.points.show = false;
features.collection.show = !features.iconsHidden;
features.iconMode = true;
if (features.selectedId) select(features.selectedId);
requestRender();
}
/** Highlight one result: scale its icon and draw a ring behind it. */
function select(id) {
const features = state.features;
clearSelection({ keepId: true });
const row = features.byId.get(id);
if (!row) {
features.selectedId = null;
return null;
}
features.selectedId = id;
const billboard = features.billboardById?.get(id);
if (billboard) billboard.scale = 1.45;
const [lon, lat] = row;
const clamp = features.rows.length <= FEATURE_ICON_LIMIT;
features.highlight?.add({
id: `${PICK_PREFIX.feature}${id}`,
position: clamp
? Cesium.Cartesian3.fromDegrees(lon, lat)
: groundPosition(lon, lat, 0.5),
image: selectionRingGlyph({ size: 64, color: COLORS.selected }),
imageId: 'mly-selection-ring',
width: 58,
height: 58,
...(clamp
? { heightReference: Cesium.HeightReference.CLAMP_TO_GROUND }
: {}),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
});
requestRender();
return row;
}
function clearSelection({ keepId = false } = {}) {
const features = state.features;
if (features.selectedId) {
const previous = features.billboardById?.get(features.selectedId);
if (previous) previous.scale = 1;
}
features.highlight?.removeAll();
if (!keepId) features.selectedId = null;
requestRender();
}
function setVisible(visible) {
const features = state.features;
if (features.points) features.points.show = visible && !features.iconMode;
if (features.collection)
features.collection.show =
visible && features.iconMode && !features.iconsHidden;
if (features.highlight) features.highlight.show = visible;
requestRender();
}
/** Hide the flat icons while 3D objects stand in for them. */
function setIconsHidden(hidden) {
state.features.iconsHidden = hidden === true;
if (state.features.collection)
state.features.collection.show =
state.features.iconMode && !state.features.iconsHidden;
requestRender();
}
/** Fly the camera to frame the query bbox. */
function frame(bbox = state.features.bbox, { duration = 2.2 } = {}) {
if (!bbox || !state.viewer) return;
const [west, south, east, north] = bbox;
state.viewer.camera.flyTo({
destination: Cesium.Rectangle.fromDegrees(west, south, east, north),
duration,
});
}
function findRow(id) {
return state.features.byId.get(id) || null;
}
function destroy(viewer) {
for (const key of ['points', 'collection', 'highlight']) {
const collection = state.features[key];
if (collection) {
sprites?.unregisterSpriteCollection?.('mapillary', collection);
viewer?.scene?.primitives?.remove(collection);
state.features[key] = null;
}
}
}
return {
ensureCollections,
reset,
appendRows,
upgradeToIcons,
setVisible,
setIconsHidden,
frame,
findRow,
select,
clearSelection,
spriteImage,
spriteSvg,
destroy,
};
}
+167
View File
@@ -0,0 +1,167 @@
/**
* Canvas glyphs for billboards: a camera cone for images, a ring for
* panoramas, a position marker for the street-level viewer, and a fallback
* dot for map features whose sprite is unavailable. 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;
}
/** Fallback feature dot when no sprite exists for a value. */
export function featureDotGlyph({ size = 30, color = '#00d4ff' } = {}) {
const key = `dot:${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.arc(c, c, size * 0.42, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(10,10,15,0.82)';
ctx.fill();
ctx.beginPath();
ctx.arc(c, c, size * 0.26, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
_cache.set(key, element);
return element;
}
/**
* Wrap a Mapillary sprite SVG (viewBox only, no intrinsic size) into a fixed
* square canvas with a dark backing disc so it reads over imagery.
* @param {string} svgText
* @param {{size?: number}} [options]
* @returns {Promise<HTMLCanvasElement>}
*/
export function rasterizeSprite(svgText, { size = 60 } = {}) {
const sized = svgText.replace(
/<svg\b([^>]*)>/i,
(match, attributes) =>
`<svg${attributes
.replace(/\swidth="[^"]*"/i, '')
.replace(/\sheight="[^"]*"/i, '')} width="${size}" height="${size}">`,
);
const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(sized)}`;
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => {
const element = canvas(size);
const ctx = element.getContext('2d');
const c = size / 2;
ctx.beginPath();
ctx.arc(c, c, size * 0.5, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(10,10,15,0.78)';
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
ctx.stroke();
const inset = size * 0.14;
ctx.drawImage(image, inset, inset, size - inset * 2, size - inset * 2);
resolve(element);
};
image.onerror = () => reject(new Error('sprite rasterization failed'));
image.src = url;
});
}
/** Selection ring drawn behind the picked map feature. */
export function selectionRingGlyph({ size = 64, color = '#00d4ff' } = {}) {
const key = `ring:${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.arc(c, c, size * 0.46, 0, Math.PI * 2);
ctx.strokeStyle = color;
ctx.lineWidth = Math.max(2, size * 0.06);
ctx.globalAlpha = 0.95;
ctx.stroke();
ctx.beginPath();
ctx.arc(c, c, size * 0.34, 0, Math.PI * 2);
ctx.lineWidth = Math.max(1, size * 0.03);
ctx.globalAlpha = 0.5;
ctx.stroke();
ctx.globalAlpha = 1;
_cache.set(key, element);
return element;
}
+435
View File
@@ -0,0 +1,435 @@
import { createState } from './state.js';
import { createCoverage } from './coverage.js';
import { createSequences } from './sequences.js';
import { createFeatures } from './features.js';
import { createViewerBridge } from './viewerBridge.js';
import { createQuery } from './query.js';
import { createSelection } from './selection.js';
import * as Cesium from 'cesium';
import {
COLORS,
COVERAGE_RECENT_DAYS,
MAPILLARY_CREDIT_HTML,
MAPILLARY_KEY_ID,
MAPILLARY_LAYER_ID,
} from './policy.js';
import { groupCountsByLabel, humanizeValue } from './values.js';
export { MAPILLARY_LAYER_ID } from './policy.js';
const SOURCE_METHODS = [
'getStatus',
'getTile',
'queryFeatures',
'plan',
'geocode',
'getImage',
'getSequenceImages',
'nearestImages',
'getMapFeature',
'spriteUrl',
];
/**
* Construct the Mapillary street-level layer: camera-driven coverage,
* per-sequence image cones, the embedded viewer, and the natural-language
* feature query. Application services are injected; nothing here reads the
* DOM except the viewer host the UI hands over.
*/
export function createMapillaryLayer({ 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.features = createFeatures(context);
parts.viewer = createViewerBridge(context);
parts.query = createQuery(context);
parts.selection = createSelection(context);
// Street-level actions shared by clicks, the query engine and the UI.
parts.street = {
async openImage(imageId, { keepFeature = false } = {}) {
const host = state.street.host;
if (!host) {
state.street.error = 'Open the Mapillary panel to view imagery';
notify();
return;
}
if (!keepFeature) {
parts.features.clearSelection();
state.street.feature = null;
}
await parts.viewer.open(imageId, host);
},
async openFeature(featureId) {
const row = parts.features.findRow(featureId);
parts.features.select(featureId);
state.street.loading = true;
state.street.error = null;
state.street.feature = row
? {
id: featureId,
value: row[3],
label: humanizeValue(row[3]),
firstSeen: row[4],
lastSeen: row[5],
}
: { id: featureId };
notify();
try {
const detail = await source.getMapFeature(featureId);
const images = detail?.images?.data || [];
state.street.feature = {
...state.street.feature,
value: detail.object_value,
label: humanizeValue(detail.object_value),
firstSeen:
Date.parse(detail.first_seen_at) || state.street.feature.firstSeen,
lastSeen:
Date.parse(detail.last_seen_at) || state.street.feature.lastSeen,
imageCount: images.length,
};
if (!images.length) throw new Error('No image detected this feature');
await parts.street.openImage(images[0].id, { keepFeature: true });
await parts.viewer.highlightDetections(
images[0].id,
detail.object_value,
humanizeValue(detail.object_value),
);
} catch (error) {
state.street.error = error?.message || 'Feature detail unavailable';
state.street.loading = false;
notify();
}
},
};
/** 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:Mapillary] 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;
}
function getUIState() {
return {
enabled: state.enabled,
keyRequired: state.keyRequired,
planner: state.status?.planner === true,
plannerModel: state.status?.plannerModel || null,
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,
},
features: {
total: state.features.total,
title: state.features.title,
loading: state.features.loading,
progress: { ...state.features.progress },
counts: groupCountsByLabel(state.features.counts.entries()),
truncated: state.features.truncated,
failed: state.features.failed,
iconMode: state.features.iconMode,
hasBbox: Boolean(state.features.bbox),
selectedId: state.features.selectedId,
bbox: state.features.bbox ? [...state.features.bbox] : null,
},
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,
feature: state.street.feature ? { ...state.street.feature } : null,
highlight: state.street.highlight
? { ...state.street.highlight }
: null,
},
query: {
busy: state.query.busy,
stage: state.query.stage,
prompt: state.query.prompt,
place: state.query.place,
answer: state.query.answer,
error: state.query.error,
title: state.query.title || '',
usage: state.query.usage,
history: state.query.history.map(({ prompt, total }) => ({
prompt,
total,
})),
},
};
}
const layer = {
id: MAPILLARY_LAYER_ID,
name: 'Street Level',
icon: '📷',
source: 'Mapillary',
updateInterval: 0,
statsRefreshInterval: 1000,
requiresKeyId: MAPILLARY_KEY_ID,
init(viewer) {
if (state.initialized)
throw new Error('Mapillary layer is already initialized');
state.viewer = viewer;
state.initialized = true;
parts.sequences.ensureCollections(viewer);
parts.features.ensureCollections(viewer);
parts.sequences.setVisible(false);
parts.features.setVisible(false);
refreshStatus();
console.log('[Data:Mapillary] Initialized');
},
enable(viewer) {
state.enabled = true;
presentCredit(viewer);
parts.sequences.setVisible(true);
parts.features.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.query.abort();
parts.features.clearSelection();
parts.coverage.setResting(false);
parts.coverage.detach();
parts.coverage.clear();
parts.sequences.clearSelection();
parts.selection.uninstall();
parts.viewer.destroy();
parts.sequences.setVisible(false);
parts.features.setVisible(false);
notify();
},
async update() {
return state.enabled;
},
destroy(viewer = state.viewer) {
layer.disable();
parts.features.destroy(viewer);
parts.sequences.destroy(viewer);
state.listeners.clear();
state.viewer = null;
state.initialized = false;
state.destroyed = true;
},
getStats() {
const loading =
state.coverage.loading > 0 ||
state.features.loading ||
state.query.busy;
let loadingLabel = '';
if (state.keyRequired) loadingLabel = 'KEY REQUIRED';
else if (state.query.busy) loadingLabel = `AI · ${state.query.stage}`;
else if (state.features.loading)
loadingLabel = `tiles ${state.features.progress.done}/${state.features.progress.tiles}`;
else if (state.coverage.loading > 0) loadingLabel = 'loading coverage...';
else if (state.features.total)
loadingLabel = `${state.features.total.toLocaleString()} features`;
else if (state.coverage.hint && state.enabled)
loadingLabel = state.coverage.hint;
return {
count: state.features.total || parts.coverage.sequenceCount(),
sequences: parts.coverage.sequenceCount(),
features: state.features.total,
loading,
keyRequired: state.keyRequired,
error: state.keyRequired
? 'KEY REQUIRED'
: state.coverage.lastError || state.query.error,
loadingLabel,
};
},
// ── Public surface used by the panel and the voice/analyst seams ─────
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));
},
runQuery: (prompt) => parts.query.run(prompt),
/** Stop a running query (planning, geocoding or streaming). */
abortQuery: () => parts.query.abort(),
/** Imagery filter for coverage, cones and nearest-image lookups. */
setCoverageFilter(next) {
parts.coverage.setFilter(next);
parts.sequences.rerender();
},
getCoverageFilter: () => ({ ...state.coverage.filter }),
selectFeature: (id) => parts.features.select(id),
clearFeatureSelection: () => parts.features.clearSelection(),
/** Execute a ready-made plan (voice agent, tests) without the planner. */
runPlan: (plan, options) => parts.query.runPlan(plan, options),
clearQuery: () => parts.query.clear(),
frameResults: () => parts.features.frame(),
openImage: (imageId) => parts.street.openImage(imageId),
openFeature: (featureId) => parts.street.openFeature(featureId),
async openNearest(point) {
const view = point || parts.query.viewContext();
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: 50,
limit: 5,
});
if (!images.length)
throw new Error(
'No Mapillary imagery within 50 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, result ring and outline.
*/
closeViewer() {
parts.viewer.close();
parts.features.clearSelection();
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(),
/** Result rows for the analyst query engine: plain JSON-safe objects. */
getAnalystRecords(maxCount = 2000) {
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
return state.features.rows
.slice(0, limit)
.map(([lon, lat, id, value, first, last]) => ({
id,
type: 'mapillary-feature',
value,
label: humanizeValue(value),
lat,
lon,
firstSeen: first ? new Date(first).toISOString() : null,
lastSeen: last ? new Date(last).toISOString() : null,
}));
},
};
return layer;
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Incremental NDJSON parsing for the streamed feature query. The server
* writes one JSON object per line; chunks arrive split at arbitrary byte
* boundaries, so a parser keeps the unfinished tail between pushes.
*/
export function createNdjsonParser(onRecord, onError = () => {}) {
let tail = '';
const emitLine = (line) => {
const text = line.trim();
if (!text) return;
try {
onRecord(JSON.parse(text));
} catch (error) {
onError(error, text);
}
};
return {
/** Feed one decoded text chunk. */
push(chunk) {
tail += chunk;
let index;
while ((index = tail.indexOf('\n')) >= 0) {
emitLine(tail.slice(0, index));
tail = tail.slice(index + 1);
}
},
/** Flush a final line that had no trailing newline. */
end() {
if (tail) emitLine(tail);
tail = '';
},
};
}
/**
* Read a fetch Response body as NDJSON, invoking `onRecord` per object.
* Resolves when the stream ends; rejects on network failure or abort.
*/
export async function readNdjsonResponse(response, onRecord, { signal } = {}) {
const parser = createNdjsonParser(onRecord);
const decoder = new TextDecoder();
const reader = response.body?.getReader?.();
if (!reader) {
parser.push(await response.text());
parser.end();
return;
}
const abort = () => reader.cancel().catch(() => {});
signal?.addEventListener('abort', abort, { once: true });
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
parser.push(decoder.decode(value, { stream: true }));
}
parser.push(decoder.decode());
parser.end();
} finally {
signal?.removeEventListener('abort', abort);
}
}
+30
View File
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createNdjsonParser } from './ndjson.js';
test('records split across chunks are reassembled in order', () => {
const seen = [];
const parser = createNdjsonParser((record) => seen.push(record));
parser.push('{"type":"start","tiles":2}\n{"type":"ti');
parser.push('le","i":0,"rows":[[1,2,"a","v",0,0]]}\n');
parser.push('{"type":"done"}');
parser.end();
assert.deepEqual(
seen.map((record) => record.type),
['start', 'tile', 'done'],
);
assert.deepEqual(seen[1].rows, [[1, 2, 'a', 'v', 0, 0]]);
});
test('a malformed line is reported and skipped', () => {
const seen = [];
const errors = [];
const parser = createNdjsonParser(
(record) => seen.push(record),
(error, line) => errors.push(line),
);
parser.push('not json\n{"ok":true}\n\n');
parser.end();
assert.deepEqual(seen, [{ ok: true }]);
assert.deepEqual(errors, ['not json']);
});
+65
View File
@@ -0,0 +1,65 @@
/** Identity and tuning for the Mapillary street-level layer. */
export const MAPILLARY_LAYER_ID = 'mapillary';
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:',
feature: 'mly:feat:',
object: 'mly:obj:',
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;
/** Query-result rendering: clamped icons up to this many features, unclamped
* icons up to FEATURE_ICON_MAX, coloured points beyond. */
export const FEATURE_ICON_LIMIT = 6000;
export const FEATURE_ICON_MAX = 80_000;
export const FEATURE_ICON_SIZE_PX = 30;
export const FEATURE_POINT_SIZE_PX = 9;
/**
* 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';
export const FEATURE_FIELDS =
'id,object_value,geometry,first_seen_at,last_seen_at,images,aligned_direction';
+516
View File
@@ -0,0 +1,516 @@
import * as Cesium from 'cesium';
import { passesImageryFilter, visibleBbox } from './coverage.js';
import { countTilesForBbox, normalizeBbox } from './tileMath.js';
import { groupCountsByLabel } from './values.js';
/** Fallback half-size (degrees) around a geocoded point with no bounds. */
const POINT_PLACE_HALF_DEG = 0.02;
/**
* Smallest place box worth querying (degrees, ~1.1 km). Nominatim returns a
* node-sized box for many neighbourhoods and landmarks; a query on that would
* cover one intersection and read as "nothing here".
*/
const MIN_PLACE_SPAN_DEG = 0.01;
/** Radii (metres) of the probe rings used when nothing sits within 50 m. */
const NEAREST_RING_M = [90, 180, 300];
/** Cap for a "current view" query when the server does not report one. */
const DEFAULT_MAX_TILES = 600;
const FEATURE_ZOOM = 14;
/** A current-view query wider than this is the horizon, not an area. */
const MAX_VIEW_SPAN_DEG = 4;
/** Shrink a bbox around its centre until it fits the tile budget. */
export function fitBboxToTileBudget(bbox, maxTiles, zoom = FEATURE_ZOOM) {
let box = normalizeBbox(bbox);
if (!box) return null;
let guard = 0;
while (
countTilesForBbox([box.west, box.south, box.east, box.north], zoom) >
maxTiles &&
guard++ < 40
) {
const cx = (box.west + box.east) / 2;
const cy = (box.south + box.north) / 2;
const hw = ((box.east - box.west) / 2) * 0.85;
const hh = ((box.north - box.south) / 2) * 0.85;
box = { west: cx - hw, east: cx + hw, south: cy - hh, north: cy + hh };
}
return [box.west, box.south, box.east, box.north];
}
/** 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);
}
/** Grow a bbox symmetrically until both sides reach `minSpan` degrees. */
export function enforceMinimumSpan(bbox, minSpan) {
const [west, south, east, north] = bbox;
if (east - west >= minSpan && north - south >= minSpan) return bbox;
const cx = (west + east) / 2;
const cy = (south + north) / 2;
const hw = Math.max((east - west) / 2, minSpan / 2);
const hh = Math.max((north - south) / 2, minSpan / 2);
return [cx - hw, cy - hh, cx + hw, cy + hh];
}
/** Turn a geocode result into a bbox, label and centre. */
export function bboxFromGeocode(result) {
const geometry = result?.geometry;
const location = geometry?.location;
if (
!location ||
!Number.isFinite(location.lat) ||
!Number.isFinite(location.lng)
)
return null;
const bounds = geometry.bounds || geometry.viewport;
const bbox = bounds
? [
bounds.southwest.lng,
bounds.southwest.lat,
bounds.northeast.lng,
bounds.northeast.lat,
]
: [
location.lng - POINT_PLACE_HALF_DEG,
location.lat - POINT_PLACE_HALF_DEG,
location.lng + POINT_PLACE_HALF_DEG,
location.lat + POINT_PLACE_HALF_DEG,
];
return {
bbox: enforceMinimumSpan(bbox, MIN_PLACE_SPAN_DEG),
label: result.formatted_address || '',
center: { lat: location.lat, lon: location.lng },
};
}
/** "1 tile" / "3 tiles". */
function plural(count, singular, pluralForm = `${singular}s`) {
return `${count.toLocaleString()} ${count === 1 ? singular : pluralForm}`;
}
/** Pluralise a humanised class label for a count ("fire hydrant" → "fire hydrants"). */
export function pluralLabel(label, count) {
const word = String(label || '').toLowerCase();
if (count === 1 || !word) return word;
if (/(s|x|z|ch|sh)$/.test(word)) return `${word}es`;
if (/[^aeiou]y$/.test(word)) return `${word.slice(0, -1)}ies`;
return `${word}s`;
}
/**
* Compose the one-line result summary shown under the query box. Returns an
* empty string for zero results so the caller can replace, not append to,
* the planner's optimistic answer.
*/
export function summarizeResults(features) {
const total = features.total || 0;
if (!total) return '';
const values = groupCountsByLabel(features.counts.entries());
const classes = values
.slice(0, 3)
.map(
({ label, count }) =>
`${count.toLocaleString()} ${pluralLabel(label, count)}`,
)
.join(', ');
const more =
values.length > 3
? ` and ${plural(values.length - 3, 'more class', 'more classes')}`
: '';
const years =
features.earliest && features.latest
? ` · seen ${new Date(features.earliest).getFullYear()}–${new Date(features.latest).getFullYear()}`
: '';
const truncated = features.truncated ? ' · capped' : '';
const failed = features.failed
? ` · ${plural(features.failed, 'tile')} failed`
: '';
// One class: "834 fire hydrants · seen 2017–2021". Several: lead with the total.
const head =
values.length === 1
? classes
: `${plural(total, 'feature')}: ${classes}${more}`;
return `${head}${years}${truncated}${failed}`;
}
/** What the panel says when a query found nothing where it looked. */
export function noResultsAnswer(plan, area) {
const subject = (plan?.title || 'matching features')
.replace(/\s*·.*$/, '')
.toLowerCase();
const where = area?.label ? ` in ${area.label}` : ' here';
return `No ${subject} mapped${where} yet. Try a larger area, another place, or set SINCE back to any date.`;
}
/** Natural-language query orchestration: plan → resolve area → stream → draw. */
export function createQuery({ state, source, parts }) {
function notify() {
state.notify?.();
}
function viewContext() {
const viewer = state.viewer;
if (!viewer) return {};
const carto = viewer.camera.positionCartographic;
const bbox = visibleBbox(viewer);
const center = bbox
? { lat: (bbox[1] + bbox[3]) / 2, lon: (bbox[0] + bbox[2]) / 2 }
: {
lat: Cesium.Math.toDegrees(carto.latitude),
lon: Cesium.Math.toDegrees(carto.longitude),
};
const ground = viewer.scene.globe?.getHeight?.(carto);
return {
lat: center.lat,
lon: center.lon,
heightM: carto.height - (Number.isFinite(ground) ? ground : 0),
};
}
function maxTiles() {
return state.status?.limits?.maxTiles || DEFAULT_MAX_TILES;
}
async function resolveArea(plan, signal) {
if (plan.place) {
const results = await source.geocode(plan.place, { signal });
const resolved = bboxFromGeocode(results[0]);
if (!resolved)
throw new Error(`Could not find "${plan.place}" on the map`);
return {
...resolved,
bbox: fitBboxToTileBudget(resolved.bbox, maxTiles()),
fromPlace: true,
};
}
const bbox = visibleBbox(state.viewer);
// The horizon (or a camera still in flight) yields a continent-sized
// rectangle; querying that would silently pick a box in the middle of it.
if (
!bbox ||
bbox[2] - bbox[0] > MAX_VIEW_SPAN_DEG ||
bbox[3] - bbox[1] > MAX_VIEW_SPAN_DEG
)
throw new Error('Zoom in closer to the ground, or name a place');
const view = viewContext();
return {
bbox: fitBboxToTileBudget(bbox, maxTiles()),
label: 'current view',
center: { lat: view.lat, lon: view.lon },
fromPlace: false,
};
}
async function runFeatures(plan, area, signal) {
const features = state.features;
parts.features.reset();
Object.assign(features, {
bbox: area.bbox,
title: plan.title,
layer: plan.layer,
values: plan.values,
loading: true,
});
state.query.stage = 'fetching';
notify();
// Results are the subject now: rest the coverage web until CLEAR so tens
// of thousands of markers are not read against a web of green lines.
parts.coverage.setResting(true);
// A named place is somewhere else: fly there. The current view already
// shows the area, so leave the camera where the user put it.
if (area.fromPlace) parts.features.frame(area.bbox);
let lastNotify = 0;
await source.queryFeatures(
{
bbox: area.bbox,
layer: plan.layer,
values: plan.values,
seenAfter: plan.seen_after,
seenBefore: plan.seen_before,
},
(record) => {
if (record.type === 'start')
features.progress = { done: 0, tiles: record.tiles };
else if (record.type === 'tile') {
features.progress.done++;
if (record.rows?.length) parts.features.appendRows(record.rows);
const now = Date.now();
if (now - lastNotify > 120) {
lastNotify = now;
notify();
}
} else if (record.type === 'done') {
features.truncated = record.truncated === true;
features.failed = record.failed || 0;
}
},
{ signal },
);
features.loading = false;
notify();
await parts.features.upgradeToIcons();
const summary = summarizeResults(features);
state.query.answer = summary
? `${plan.answer} ${summary}`.trim()
: noResultsAnswer(plan, area);
notify();
}
async function runNearest(plan, area, signal) {
const found = await nearestImageAround(area.center, signal, {
preferPano:
plan.prefer_pano === true || state.coverage.filter.pano === 'pano',
});
if (!found)
throw new Error(
`No Mapillary imagery within ${NEAREST_RING_M[NEAREST_RING_M.length - 1] + 50} m of ${area.label || 'that point'}`,
);
await parts.street.openImage(found.image.id);
state.query.answer = `${plan.answer} Opened the closest image, about ${Math.round(found.distanceM)} m away.`;
}
/**
* Mapillary's radius search stops at 50 m, which misses a landmark whose
* geocoded centre sits inside a large footprint. Probe the centre first,
* then rings of points around it, and keep the closest hit overall.
*/
async function nearestImageAround(
center,
signal,
{ preferPano = false } = {},
) {
const rings = [[center]];
for (const ring of NEAREST_RING_M) {
const probes = [];
for (let k = 0; k < 8; k++) {
const angle = (k / 8) * Math.PI * 2;
probes.push({
lat: center.lat + (ring * Math.cos(angle)) / 110_540,
lon:
center.lon +
(ring * Math.sin(angle)) /
(111_320 * Math.cos((center.lat * Math.PI) / 180)),
});
}
rings.push(probes);
}
for (const probes of rings) {
const results = await Promise.all(
probes.map((probe) =>
source
.nearestImages(
{ lat: probe.lat, lon: probe.lon, radius: 50, limit: 3 },
{ signal },
)
.catch(() => []),
),
);
let best = null;
let fallback = null;
for (const images of results)
for (const image of images) {
const coordinates =
image?.computed_geometry?.coordinates ||
image?.geometry?.coordinates;
if (!coordinates) continue;
const distanceM = metresBetween(center, {
lon: coordinates[0],
lat: coordinates[1],
});
const record = {
isPano: image.is_pano === true,
capturedAt: Number(image.captured_at) || 0,
};
// The dock's imagery filter is a hard constraint; a 360° preference
// from the request only breaks ties within the filtered set.
const candidate = {
image,
distanceM,
score: distanceM + (preferPano && !record.isPano ? 1000 : 0),
};
if (!fallback || distanceM < fallback.distanceM) fallback = candidate;
if (!passesImageryFilter(record, state.coverage.filter)) continue;
if (!best || candidate.score < best.score) best = candidate;
}
if (best) return best;
if (
fallback &&
state.coverage.filter.pano === 'all' &&
!state.coverage.filter.sinceMs
)
return fallback;
}
return null;
}
/**
* Execute an already-built plan (from the planner, the voice agent, or a
* test). Resolves the area, streams features or opens imagery, and records
* the turn in the history.
*/
async function execute(plan, { prompt = plan?.title || '', signal } = {}) {
if (!plan || typeof plan !== 'object')
throw new Error('A plan is required');
state.query.lastPlan = plan;
state.query.answer = plan.answer || '';
state.query.title = plan.title || '';
notify();
if (plan.intent === 'unsupported') {
state.query.stage = 'idle';
} else if (plan.intent === 'coverage') {
state.query.stage = 'resolving';
notify();
const area = await resolveArea(plan, signal);
state.query.place = area?.label || plan.place || null;
parts.features.frame(area.bbox, { duration: 2.4 });
state.query.stage = 'idle';
} else if (plan.intent === 'nearest_image') {
state.query.stage = 'resolving';
notify();
const area = await resolveArea(plan, signal);
state.query.place = area?.label || plan.place || null;
await runNearest(plan, area, signal);
state.query.stage = 'idle';
} else {
if (!plan.layer) plan = { ...plan, layer: 'points' };
state.query.stage = 'resolving';
notify();
const area = await resolveArea(plan, signal);
state.query.place = area?.label || plan.place || null;
await runFeatures(plan, area, signal);
state.query.stage = 'done';
}
state.query.history.push({ prompt, plan, total: state.features.total });
}
/** Run one natural-language request end to end. */
async function run(prompt) {
const text = String(prompt || '').trim();
if (!text) return;
abort();
const controller = new AbortController();
state.query.abort = controller;
Object.assign(state.query, {
busy: true,
stage: 'planning',
prompt: text,
answer: '',
error: null,
usage: null,
});
notify();
try {
const { plan, usage, model } = await source.plan(
{
query: text,
context: { view: viewContext(), previousPlan: state.query.lastPlan },
},
{ signal: controller.signal },
);
if (controller.signal.aborted) return;
state.query.usage = usage ? { ...usage, model } : null;
await execute(plan, { prompt: text, signal: controller.signal });
} catch (error) {
if (controller.signal.aborted) return;
state.features.loading = false;
state.query.stage = 'error';
state.query.error = describeError(error);
} finally {
if (state.query.abort === controller) {
state.query.busy = false;
state.query.abort = null;
}
notify();
}
}
/** Execute a supplied plan with the same lifecycle bookkeeping as run(). */
async function runPlan(plan, { prompt } = {}) {
abort();
const controller = new AbortController();
state.query.abort = controller;
Object.assign(state.query, {
busy: true,
stage: 'resolving',
prompt: prompt || plan?.title || '',
answer: '',
error: null,
usage: null,
});
notify();
try {
await execute(plan, { prompt, signal: controller.signal });
} catch (error) {
if (controller.signal.aborted) return;
state.features.loading = false;
state.query.stage = 'error';
state.query.error = describeError(error);
} finally {
if (state.query.abort === controller) {
state.query.busy = false;
state.query.abort = null;
}
notify();
}
}
function describeError(error) {
if (
error?.keyRequired === 'anthropic' ||
error?.payload?.keyRequired === 'anthropic'
)
return 'Mapillary AI needs an Anthropic key — add one in POWER UP.';
if (
error?.keyRequired === 'mapillary' ||
error?.payload?.keyRequired === 'mapillary' ||
error?.payload?.keyRequired === true
)
return 'Add a Mapillary client token in POWER UP first.';
if (error?.payload?.error === 'area_too_large') {
const { tiles, limit } = error.payload.detail || error.payload;
const times =
Number.isFinite(tiles) && Number.isFinite(limit) && limit > 0
? ` (about ${Math.max(2, Math.round(tiles / limit))}× what one query covers)`
: '';
return `That area is too large for one query${times}. Zoom in closer to the ground or name a smaller place.`;
}
return error?.message || 'Query failed';
}
function abort() {
state.query.abort?.abort();
state.query.abort = null;
state.query.busy = false;
state.features.loading = false;
}
/** Drop the drawn results and the conversational context. */
function clear() {
abort();
parts.features.reset();
parts.coverage.setResting(false);
Object.assign(state.query, {
stage: 'idle',
prompt: '',
answer: '',
error: null,
lastPlan: null,
usage: null,
title: '',
});
Object.assign(state.features, {
bbox: null,
title: '',
layer: null,
values: [],
});
notify();
}
return { run, runPlan, execute, clear, abort, viewContext };
}
+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
bboxFromGeocode,
enforceMinimumSpan,
fitBboxToTileBudget,
summarizeResults,
} from './query.js';
import { countTilesForBbox } from './tileMath.js';
test('bboxFromGeocode prefers bounds and falls back to a point box', () => {
const withBounds = bboxFromGeocode({
geometry: {
location: { lat: 38.58, lng: -121.49 },
bounds: {
southwest: { lat: 38.44, lng: -121.56 },
northeast: { lat: 38.69, lng: -121.36 },
},
},
formatted_address: 'Sacramento, California',
});
assert.deepEqual(withBounds.bbox, [-121.56, 38.44, -121.36, 38.69]);
assert.equal(withBounds.label, 'Sacramento, California');
const point = bboxFromGeocode({
geometry: { location: { lat: 42.33, lng: -83.05 } },
});
assert.ok(point.bbox[0] < -83.05 && point.bbox[2] > -83.05);
assert.equal(bboxFromGeocode(null), null);
});
test('fitBboxToTileBudget shrinks an oversized box around its centre', () => {
const california = [-125, 32, -114, 42];
const fitted = fitBboxToTileBudget(california, 600);
assert.ok(countTilesForBbox(fitted, 14) <= 600);
const cx = (fitted[0] + fitted[2]) / 2;
assert.ok(Math.abs(cx - -119.5) < 1e-6);
const small = [-121.5, 38.5, -121.4, 38.6];
assert.deepEqual(fitBboxToTileBudget(small, 600), small);
});
test('summarizeResults reports totals, top classes and the sighting window', () => {
const text = summarizeResults({
total: 1234,
counts: new Map([
['object--fire-hydrant', 1000],
['object--bench', 200],
['object--mailbox', 20],
['object--trash-can', 14],
]),
earliest: Date.UTC(2019, 0, 1),
latest: Date.UTC(2026, 5, 1),
truncated: true,
failed: 2,
});
assert.match(
text,
/^1,234 features: 1,000 fire hydrant, 200 bench, 20 mailbox and 1 more classes/,
);
assert.match(text, /seen 2019–2026 · capped · 2 tiles failed$/);
assert.equal(
summarizeResults({ total: 0, counts: new Map() }),
'No matching features in this area.',
);
});
test('node-sized geocode bounds are grown to a queryable neighbourhood box', () => {
const tiny = bboxFromGeocode({
geometry: {
location: { lat: 42.3292, lng: -83.0527 },
bounds: {
southwest: { lat: 42.329, lng: -83.053 },
northeast: { lat: 42.3294, lng: -83.0524 },
},
},
});
assert.ok(tiny.bbox[2] - tiny.bbox[0] >= 0.01 - 1e-9);
assert.ok(tiny.bbox[3] - tiny.bbox[1] >= 0.01 - 1e-9);
assert.deepEqual(enforceMinimumSpan([0, 0, 1, 1], 0.5), [0, 0, 1, 1]);
});
+114
View File
@@ -0,0 +1,114 @@
import * as Cesium from 'cesium';
import { MAPILLARY_LAYER_ID, PICK_PREFIX } from './policy.js';
/** Click handling for coverage lines, image cones and query results. */
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));
} else if (id.startsWith(PICK_PREFIX.feature)) {
parts.street.openFeature(id.slice(PICK_PREFIX.feature.length));
} else if (id.startsWith(PICK_PREFIX.object)) {
// 3D object entities are id'd `mly:obj:<featureId>:<part>`.
parts.street.openFeature(id.split(':')[2]);
}
}
/**
* 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?.(MAPILLARY_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?.(MAPILLARY_LAYER_ID);
}
return { install, uninstall, ownsPick };
}
+262
View File
@@ -0,0 +1,262 @@
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?.(
'mapillary',
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?.(
'mapillary',
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?.('mapillary', collection);
viewer?.scene?.primitives?.remove(collection);
state.sequence[key] = null;
}
}
if (state.street.markerCollection) {
sprites?.unregisterSpriteCollection?.(
'mapillary',
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,
};
}
+233
View File
@@ -0,0 +1,233 @@
import { readNdjsonResponse } from './ndjson.js';
import {
FEATURE_FIELDS,
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. Tiles, the feature stream,
* the planner and sprites go through the local dev-server proxy; entity
* 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',
features: '/api/mapillary/features',
plan: '/api/mapillary/plan',
sprite: '/api/mapillary/sprite',
geocode: '/api/geocode',
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());
},
/**
* Stream a feature query. `onEvent` receives each NDJSON record
* ({type:'start'|'tile'|'done'}); resolves with the done record.
*/
async queryFeatures(body, onEvent, { signal } = {}) {
const response = await fetchImpl(urls.features, {
method: 'POST',
signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) await readJsonOrThrow(response, 'Feature query');
let done = null;
await readNdjsonResponse(
response,
(record) => {
if (record?.type === 'done') done = record;
onEvent?.(record);
},
{ signal },
);
return done;
},
/** Natural language → plan, via the server-side Claude planner. */
async plan(body, { signal } = {}) {
const response = await fetchImpl(urls.plan, {
method: 'POST',
signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return readJsonOrThrow(response, 'Planner');
},
/** Place name → geocode results through the app's existing Nominatim route. */
async geocode(query, { signal } = {}) {
const response = await fetchImpl(
`${urls.geocode}?${new URLSearchParams({ q: query })}`,
{ signal },
);
const payload = await readJsonOrThrow(response, 'Geocode');
return Array.isArray(payload?.results) ? payload.results : [];
},
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 : [];
},
getMapFeature(id, { signal } = {}) {
return graph(String(id), { fields: FEATURE_FIELDS }, { signal });
},
/** Batched entity lookup (`/?ids=`), up to 50 ids per call. */
async getMapFeaturesBatch(
ids,
{ signal, fields = 'id,object_value,aligned_direction' } = {},
) {
const list = [...new Set((ids || []).map(String))].slice(0, 50);
if (!list.length) return {};
const payload = await graph(
'',
{ ids: list.join(','), fields },
{ signal },
);
return payload && typeof payload === 'object' ? payload : {};
},
/** Detections (segmentation polygons + values) inside one image. */
async getImageDetections(imageId, { signal } = {}) {
const payload = await graph(
`${String(imageId)}/detections`,
{ fields: 'id,value,geometry,created_at' },
{ signal },
);
return Array.isArray(payload?.data) ? payload.data : [];
},
spriteUrl(value) {
return `${urls.sprite}/${encodeURIComponent(value)}.svg`;
},
};
}
+107
View File
@@ -0,0 +1,107 @@
/** 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: '',
resting: false,
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,
},
features: {
collection: null,
points: null,
rows: [],
byId: new Map(),
counts: new Map(),
earliest: null,
latest: null,
bbox: null,
title: '',
layer: null,
values: [],
total: 0,
truncated: false,
failed: 0,
loading: false,
progress: { done: 0, tiles: 0 },
abort: null,
iconMode: false,
iconsHidden: false,
selectedId: null,
billboardById: new Map(),
highlight: 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',
},
query: {
busy: false,
stage: 'idle',
prompt: '',
place: null,
answer: '',
error: null,
lastPlan: null,
history: [],
abort: null,
usage: null,
},
clickHandler: null,
};
}
File diff suppressed because it is too large. Load diff
+163
View File
@@ -0,0 +1,163 @@
/**
* Web-Mercator tile arithmetic for Mapillary vector tiles. Pure functions,
* no Cesium dependency, shared by the browser layer and the server executor.
*/
const MAX_LAT = 85.05112878;
function clampLat(lat) {
return Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
}
/** Tile column for a longitude at zoom z. */
export function lonToTileX(lon, z) {
const n = 2 ** z;
const wrapped = ((((lon + 180) % 360) + 360) % 360) - 180;
return Math.min(n - 1, Math.max(0, Math.floor(((wrapped + 180) / 360) * n)));
}
/** Tile row for a latitude at zoom z. */
export function latToTileY(lat, z) {
const n = 2 ** z;
const rad = (clampLat(lat) * Math.PI) / 180;
const y = Math.floor(
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n,
);
return Math.min(n - 1, Math.max(0, y));
}
/** West longitude of tile column x at zoom z. */
function tileXToLon(x, z) {
return (x / 2 ** z) * 360 - 180;
}
/** North latitude of tile row y at zoom z. */
function tileYToLat(y, z) {
const n = Math.PI - (2 * Math.PI * y) / 2 ** z;
return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
}
/**
* Geographic bounds of one tile.
* @returns {{west:number,south:number,east:number,north:number}}
*/
export function tileBounds(x, y, z) {
return {
west: tileXToLon(x, z),
east: tileXToLon(x + 1, z),
north: tileYToLat(y, z),
south: tileYToLat(y + 1, z),
};
}
/**
* Normalize a bbox given as [west, south, east, north] (or an object) into
* finite, ordered numbers. Returns null when the input is not a usable box.
*/
export function normalizeBbox(input) {
const values = Array.isArray(input)
? input
: input && typeof input === 'object'
? [input.west, input.south, input.east, input.north]
: null;
if (!values || values.length !== 4) return null;
const [w, s, e, n] = values.map(Number);
if (![w, s, e, n].every(Number.isFinite)) return null;
const west = Math.max(-180, Math.min(w, e));
const east = Math.min(180, Math.max(w, e));
const south = Math.max(-MAX_LAT, Math.min(s, n));
const north = Math.min(MAX_LAT, Math.max(s, n));
if (east - west <= 0 || north - south <= 0) return null;
return { west, south, east, north };
}
/** Area of a bbox in square degrees (Mapillary's bbox limit unit). */
export function bboxAreaDeg2(bbox) {
const box = normalizeBbox(bbox);
return box ? (box.east - box.west) * (box.north - box.south) : 0;
}
/**
* Number of tiles at zoom z covering a bbox, without allocating them.
*/
export function countTilesForBbox(bbox, z) {
const box = normalizeBbox(bbox);
if (!box) return 0;
const x0 = lonToTileX(box.west, z);
const x1 = lonToTileX(box.east, z);
const y0 = latToTileY(box.north, z);
const y1 = latToTileY(box.south, z);
return (x1 - x0 + 1) * (y1 - y0 + 1);
}
/**
* Enumerate the tiles at zoom z covering a bbox, ordered from the centre of
* the box outwards so a progressive renderer fills in what the user is most
* likely looking at first. `limit` caps the list; the result reports whether
* the cap was hit.
* @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}}
*/
export function tilesForBbox(bbox, z, { limit = Infinity } = {}) {
const box = normalizeBbox(bbox);
if (!box) return { tiles: [], truncated: false, total: 0 };
const x0 = lonToTileX(box.west, z);
const x1 = lonToTileX(box.east, z);
const y0 = latToTileY(box.north, z);
const y1 = latToTileY(box.south, z);
const total = (x1 - x0 + 1) * (y1 - y0 + 1);
const cx = (x0 + x1) / 2;
const cy = (y0 + y1) / 2;
const tiles = [];
for (let y = y0; y <= y1; y++)
for (let x = x0; x <= x1; x++) tiles.push({ x, y, z });
tiles.sort(
(a, b) =>
(a.x - cx) ** 2 + (a.y - cy) ** 2 - ((b.x - cx) ** 2 + (b.y - cy) ** 2),
);
const truncated = tiles.length > limit;
return { tiles: truncated ? tiles.slice(0, limit) : tiles, truncated, total };
}
/**
* Convert a vector-tile-local coordinate (0..extent) into longitude and
* latitude for tile (x, y, z).
* @returns {[number, number]} [lon, lat]
*/
export function tileLocalToLonLat(px, py, extent, x, y, z) {
const n = 2 ** z;
const lon = ((x + px / extent) / n) * 360 - 180;
const merc = Math.PI - (2 * Math.PI * (y + py / extent)) / n;
const lat =
(180 / Math.PI) * Math.atan(0.5 * (Math.exp(merc) - Math.exp(-merc)));
return [lon, lat];
}
/**
* Pick a coverage zoom for a camera height (metres above ground). Higher
* cameras get coarser sequence tiles; below the floor Mapillary has no
* finer tiles than z14.
*/
export function coverageZoomForHeight(heightM) {
if (!Number.isFinite(heightM)) return null;
if (heightM > 60_000) return null;
if (heightM > 12_000) return 11;
if (heightM > 5_000) return 12;
if (heightM > 1_800) return 13;
return 14;
}
/**
* Pick the low-zoom `overview` tile level for a camera far above the ground.
* Mapillary publishes coverage points at z0–5; sequences start at z6 and are
* far too heavy for a continent-sized view. Returns null below the overview
* ceiling (60 km), where `coverageZoomForHeight` takes over.
*/
export function overviewZoomForHeight(heightM) {
if (!Number.isFinite(heightM) || heightM <= 60_000) return null;
if (heightM > 15_000_000) return 0;
if (heightM > 7_000_000) return 1;
if (heightM > 3_000_000) return 2;
if (heightM > 1_200_000) return 3;
if (heightM > 400_000) return 4;
return 5;
}
+87
View File
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
bboxAreaDeg2,
countTilesForBbox,
coverageZoomForHeight,
overviewZoomForHeight,
latToTileY,
lonToTileX,
normalizeBbox,
tileBounds,
tileLocalToLonLat,
tilesForBbox,
} from './tileMath.js';
test('lon/lat to tile matches the Sacramento reference tile', () => {
assert.equal(lonToTileX(-121.4944, 14), 2662);
assert.equal(latToTileY(38.5816, 14), 6286);
const bounds = tileBounds(2662, 6286, 14);
assert.ok(bounds.west < -121.4944 && -121.4944 < bounds.east);
assert.ok(bounds.south < 38.5816 && 38.5816 < bounds.north);
});
test('tile-local coordinates round-trip into the tile bounds', () => {
const bounds = tileBounds(2662, 6286, 14);
const [lon, lat] = tileLocalToLonLat(0, 0, 4096, 2662, 6286, 14);
assert.ok(Math.abs(lon - bounds.west) < 1e-9);
assert.ok(Math.abs(lat - bounds.north) < 1e-9);
const [lon2, lat2] = tileLocalToLonLat(4096, 4096, 4096, 2662, 6286, 14);
assert.ok(Math.abs(lon2 - bounds.east) < 1e-9);
assert.ok(Math.abs(lat2 - bounds.south) < 1e-9);
});
test('normalizeBbox orders and clamps coordinates', () => {
assert.deepEqual(normalizeBbox([-121.36, 38.69, -121.56, 38.44]), {
west: -121.56,
south: 38.44,
east: -121.36,
north: 38.69,
});
assert.equal(normalizeBbox([0, 0, 0, 1]), null);
assert.equal(normalizeBbox('nope'), null);
assert.equal(normalizeBbox([1, 2, Number.NaN, 3]), null);
});
test('city-scale boxes exceed the Mapillary bbox limit and need tiles', () => {
const sacramento = [-121.56, 38.44, -121.36, 38.69];
assert.ok(bboxAreaDeg2(sacramento) > 0.01);
assert.equal(countTilesForBbox(sacramento, 14), 160);
const detroit = [-83.29, 42.25, -82.91, 42.45];
assert.equal(countTilesForBbox(detroit, 14), 234);
});
test('tilesForBbox orders from the centre outwards and honours the cap', () => {
const result = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14, {
limit: 5,
});
assert.equal(result.total, 160);
assert.equal(result.truncated, true);
assert.equal(result.tiles.length, 5);
const centreX = lonToTileX(-121.46, 14);
const centreY = latToTileY(38.565, 14);
assert.ok(Math.abs(result.tiles[0].x - centreX) <= 1);
assert.ok(Math.abs(result.tiles[0].y - centreY) <= 1);
const full = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14);
assert.equal(full.truncated, false);
assert.equal(full.tiles.length, 160);
});
test('coverageZoomForHeight steps from coarse to z14 as the camera descends', () => {
assert.equal(coverageZoomForHeight(100_000), null);
assert.equal(coverageZoomForHeight(30_000), 11);
assert.equal(coverageZoomForHeight(8_000), 12);
assert.equal(coverageZoomForHeight(3_000), 13);
assert.equal(coverageZoomForHeight(500), 14);
assert.equal(coverageZoomForHeight(Number.NaN), null);
});
test('overviewZoomForHeight covers the globe above the sequence ceiling', () => {
assert.equal(overviewZoomForHeight(30_000), null);
assert.equal(overviewZoomForHeight(100_000), 5);
assert.equal(overviewZoomForHeight(900_000), 4);
assert.equal(overviewZoomForHeight(2_000_000), 3);
assert.equal(overviewZoomForHeight(5_000_000), 2);
assert.equal(overviewZoomForHeight(10_000_000), 1);
assert.equal(overviewZoomForHeight(25_000_000), 0);
});
+162
View File
@@ -0,0 +1,162 @@
/**
* Mapillary map-feature value helpers shared by the browser layer and the
* server query executor. Values look like `object--fire-hydrant` or
* `regulatory--stop--g1`; the segments are separated by `--`.
*/
const SIGN_PREFIXES = Object.freeze([
'regulatory',
'warning',
'information',
'complementary',
]);
/** Escape a string for use inside a RegExp source. */
function escapeRegExp(text) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Normalize one user-supplied value pattern: trimmed, lower-cased, spaces
* folded to hyphens, and only the characters a Mapillary value can contain.
* Returns '' when nothing usable remains.
* @param {unknown} pattern
* @returns {string}
*/
export function normalizeValuePattern(pattern) {
return String(pattern ?? '')
.trim()
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9*-]/g, '')
.slice(0, 120);
}
/**
* Build a matcher for a list of value patterns. `*` matches any run of
* characters, so `regulatory--stop--*` covers every regional variant and
* `object--traffic-light--*` every traffic-light subtype. An empty pattern
* list matches everything.
* @param {Iterable<string>} patterns
* @returns {(value: string) => boolean}
*/
export function createValueMatcher(patterns) {
const exact = new Set();
const wild = [];
for (const raw of patterns || []) {
const pattern = normalizeValuePattern(raw);
if (!pattern) continue;
if (pattern.includes('*')) {
wild.push(
new RegExp(`^${pattern.split('*').map(escapeRegExp).join('.*')}$`),
);
} else exact.add(pattern);
}
if (!exact.size && !wild.length) return () => true;
return (value) => {
if (typeof value !== 'string') return false;
if (exact.has(value)) return true;
for (const regex of wild) if (regex.test(value)) return true;
return false;
};
}
/**
* Whether a value belongs to the traffic-sign layer rather than the point
* object layer. Sign values start with one of four regulatory families.
* @param {string} value
* @returns {boolean}
*/
export function isSignValue(value) {
const head = String(value || '').split('--')[0];
return SIGN_PREFIXES.includes(head);
}
/**
* Human label for a value: `object--fire-hydrant` → "Fire hydrant",
* `regulatory--stop--g1` → "Stop (regulatory)",
* `object--traffic-light--general-upright` → "Traffic light · general upright".
* @param {string} value
* @returns {string}
*/
export function humanizeValue(value) {
const parts = String(value || '')
.split('--')
.filter(Boolean);
if (!parts.length) return 'Unknown';
const [head, ...rest] = parts;
const words = (segment) =>
segment.replace(/-/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
if (SIGN_PREFIXES.includes(head)) {
const body = rest.filter((segment) => !/^g\d+$/.test(segment));
return `${body.map(words).join(' · ') || words(head)} (${head})`;
}
if (head === 'object' || head === 'marking' || head === 'construction') {
const body = rest.length ? rest : [head];
return body.map(words).join(' · ');
}
return parts.map(words).join(' · ');
}
/** Coarse category used for colouring and grouping results. */
export function categoryOf(value) {
const head = String(value || '').split('--')[0];
if (SIGN_PREFIXES.includes(head)) return 'sign';
if (head === 'marking') return 'marking';
if (head === 'construction') return 'construction';
return 'object';
}
/**
* Stable CSS colour per value, so one class keeps one colour across a query.
* Signs lean warm, objects lean cool; the hue within a category is hashed
* from the value so neighbouring classes differ.
* @param {string} value
* @returns {string} CSS colour.
*/
export function colorForValue(value) {
let hash = 0;
const text = String(value || '');
for (let i = 0; i < text.length; i++)
hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
const category = categoryOf(text);
// Objects sit in the cyan-blue band so they never blend into the
// Mapillary-green coverage lines; signs lean warm, markings violet.
const base =
category === 'sign'
? 20
: category === 'marking'
? 280
: category === 'construction'
? 45
: 205;
const hue = (base + (hash % 60) - 30 + 360) % 360;
return `hsl(${hue} 90% 60%)`;
}
/**
* Merge per-value counts whose human labels coincide (regional sign variants
* such as `regulatory--stop--g1` and `--g3`), summing counts and keeping the
* first value's colour. Input and output are sorted by count, descending.
* @param {Iterable<[string, number]>} counts
* @returns {Array<{value: string, values: string[], count: number, label: string, color: string}>}
*/
export function groupCountsByLabel(counts) {
const groups = new Map();
for (const [value, count] of counts) {
const label = humanizeValue(value);
const group = groups.get(label);
if (group) {
group.count += count;
group.values.push(value);
} else
groups.set(label, {
value,
values: [value],
count,
label,
color: colorForValue(value),
});
}
return [...groups.values()].sort((a, b) => b.count - a.count);
}
+96
View File
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
categoryOf,
groupCountsByLabel,
colorForValue,
createValueMatcher,
humanizeValue,
isSignValue,
normalizeValuePattern,
} from './values.js';
test('normalizeValuePattern folds case, spaces and stray characters', () => {
assert.equal(
normalizeValuePattern(' Object--Fire Hydrant '),
'object--fire-hydrant',
);
assert.equal(
normalizeValuePattern('regulatory--stop--*'),
'regulatory--stop--*',
);
assert.equal(normalizeValuePattern('drop table;'), 'drop-table');
assert.equal(normalizeValuePattern(null), '');
});
test('createValueMatcher matches exact values and wildcards only', () => {
const matches = createValueMatcher([
'object--fire-hydrant',
'regulatory--stop--*',
]);
assert.equal(matches('object--fire-hydrant'), true);
assert.equal(matches('regulatory--stop--g1'), true);
assert.equal(matches('regulatory--stop--g3'), true);
assert.equal(matches('regulatory--stop-ahead--g1'), false);
assert.equal(matches('object--bench'), false);
assert.equal(matches(undefined), false);
});
test('an empty pattern list matches everything', () => {
const matches = createValueMatcher([]);
assert.equal(matches('anything--at-all'), true);
});
test('a wildcard only spans the segment it is placed in', () => {
const matches = createValueMatcher(['object--sign--*']);
assert.equal(matches('object--sign--store'), true);
assert.equal(matches('object--signal'), false);
// Regex metacharacters are stripped by normalization, never interpreted.
assert.equal(
createValueMatcher(['object--sign.*'])('object--sign--store'),
true,
);
});
test('isSignValue and categoryOf split the taxonomy by family', () => {
assert.equal(isSignValue('regulatory--stop--g1'), true);
assert.equal(isSignValue('warning--pedestrians-crossing--g4'), true);
assert.equal(isSignValue('object--fire-hydrant'), false);
assert.equal(categoryOf('object--fire-hydrant'), 'object');
assert.equal(categoryOf('marking--discrete--stop-line'), 'marking');
assert.equal(categoryOf('information--general-directions--g1'), 'sign');
});
test('humanizeValue produces readable labels', () => {
assert.equal(humanizeValue('object--fire-hydrant'), 'Fire hydrant');
assert.equal(humanizeValue('regulatory--stop--g1'), 'Stop (regulatory)');
assert.equal(
humanizeValue('object--traffic-light--general-upright'),
'Traffic light · General upright',
);
assert.equal(humanizeValue(''), 'Unknown');
});
test('colorForValue is stable and category-shifted', () => {
assert.equal(colorForValue('object--bench'), colorForValue('object--bench'));
assert.match(colorForValue('regulatory--stop--g1'), /^hsl\(\d+ 90% 60%\)$/);
});
test('groupCountsByLabel merges regional sign variants under one label', () => {
const groups = groupCountsByLabel([
['regulatory--stop--g1', 100],
['object--bench', 30],
['regulatory--stop--g3', 5],
]);
assert.deepEqual(
groups.map(({ label, count, values }) => ({ label, count, values })),
[
{
label: 'Stop (regulatory)',
count: 105,
values: ['regulatory--stop--g1', 'regulatory--stop--g3'],
},
{ label: 'Bench', count: 30, values: ['object--bench'] },
],
);
});
+361
View File
@@ -0,0 +1,361 @@
import * as Cesium from 'cesium';
import { decodeDetectionPolygons } from './decode.js';
/** 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) {
if (state.street.imageId && state.street.imageId !== image.id)
clearHighlight();
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,
tag: 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();
}
function clearHighlight() {
if (!state.street.highlight) return;
state.street.highlight = null;
try {
viewer?.getComponent('tag')?.removeAll();
} catch {
/* component not ready */
}
}
/**
* Outline every detection of `value` inside the current image using the
* MapillaryJS tag component. Detections are per image on the Graph API;
* their geometry is a small vector tile in image-normalized coordinates.
*/
async function highlightDetections(imageId, value, label) {
if (!viewer || !imageId) return 0;
const { OutlineTag, PolygonGeometry } = Library || {};
let component;
try {
component = viewer.getComponent('tag');
component.removeAll();
} catch {
return 0;
}
state.street.highlight = { value, count: 0, loading: true };
state.notify?.();
let detections = [];
try {
detections = await source.getImageDetections(imageId);
} catch {
detections = [];
}
if (pendingOpen !== String(imageId) || !viewer) return 0;
const tags = [];
for (const detection of detections) {
if (value && detection.value !== value) continue;
decodeDetectionPolygons(detection.geometry).forEach((polygon, index) => {
try {
tags.push(
new OutlineTag(
`mly-det-${detection.id}-${index}`,
new PolygonGeometry(polygon),
{
lineColor: 0x00d4ff,
lineWidth: 3,
fillColor: 0x00d4ff,
fillOpacity: 0.22,
text: tags.length === 0 ? label || undefined : undefined,
textColor: 0xffffff,
},
),
);
} catch {
/* degenerate polygon */
}
});
}
if (tags.length) component.add(tags);
state.street.highlight = { value, count: tags.length, loading: false };
state.notify?.();
return tags.length;
}
/** 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;
clearHighlight();
Object.assign(state.street, {
open: false,
follow: false,
imageId: null,
sequenceId: null,
position: null,
bearing: null,
tilt: null,
loading: false,
error: null,
highlight: null,
feature: null,
});
parts.sequences.setMarker(null);
state.notify?.();
}
return {
open,
close,
setFollow,
setRenderMode,
resize,
prewarm,
lookAtPosition,
highlightDetections,
destroy() {
close();
destroyViewer();
},
};
}
+4
View File
@@ -13,6 +13,7 @@ import { createSatelliteSource } from '../layers/satellites/source.js';
import { createLaunchSource } from '../layers/launches/source.js';
import { createOverpassAlprSource } from '../layers/alpr/source.js';
import { createFirmsSource } from '../layers/firms/source.js';
import { createMapillarySource } from '../layers/mapillary/source.js';
import { createReferenceSources } from '../sources/reference.js';
export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js';
@@ -35,5 +36,8 @@ export function createStandaloneLayerSources() {
launches: createLaunchSource(),
alpr: createOverpassAlprSource(),
firms: createFirmsSource(),
mapillary: createMapillarySource({
token: import.meta.env?.MAPILLARY_CLIENT_TOKEN || '',
}),
};
}
+2 -1
View File
@@ -28,7 +28,7 @@ const PANEL_GROUPS = [
},
{
label: 'Cameras',
ids: ['cctv', 'alpr-cameras'],
ids: ['cctv', 'mapillary', 'alpr-cameras'],
},
{
label: 'Infrastructure',
@@ -58,6 +58,7 @@ const PANEL_LABELS = {
'ais-live-vessels': 'Live Vessels',
bikeshare: 'Bike Share',
cctv: 'Cameras',
mapillary: 'Street Level (Mapillary)',
'alpr-cameras': 'Mapped ALPR Cameras',
'local-datacenters': 'Data Centers',
'local-firms': 'Active Fires',
+586
View File
@@ -0,0 +1,586 @@
import { makeFloating } from './floatingWindow.js';
const DOCK_STORAGE_KEY = 'gev:mapillary-dock:collapsed';
const DOCK_MINIMIZED_KEY = 'gev:mapillary-dock:minimized';
const DOCK_WINDOW_KEY = 'gev:mapillary-dock:window:v1';
const RENDER_MODE_KEY = 'gev:mapillary:render-mode';
const MAPILLARY_APP_URL = 'https://www.mapillary.com/app/';
/** Deep link to an image on mapillary.com, as the web app shares them. */
export function mapillaryImageUrl(imageId) {
const id = String(imageId || '').trim();
if (!id) return MAPILLARY_APP_URL;
return `${MAPILLARY_APP_URL}?pKey=${encodeURIComponent(id)}&focus=photo`;
}
function formatDate(ms) {
if (!Number.isFinite(ms)) return '';
try {
return new Date(ms).toISOString().slice(0, 10);
} catch {
return '';
}
}
/**
* Own the Mapillary dock: the AI query box, result chips, progress, the
* street-level viewer host and its controls. Receives the layer instance and
* application actions; never reaches into the layer's internals.
*/
export class MapillaryControls {
constructor({ root, mapillary, actions }) {
this.root = root;
this.mapillary = mapillary;
this.actions = actions;
this.destroyed = false;
this.listeners = new AbortController();
this._unsubscribe = null;
this._state = null;
this._lastStage = null;
this._elements = this._collect();
this._bind();
this._restoreCollapsed();
this._restoreMinimized();
}
_collect() {
const byId = (id) => this.root?.querySelector(`#${id}`) || null;
return {
toggle: byId('mly-dock-toggle'),
body: byId('mly-body'),
statusChip: byId('mly-status-chip'),
enableBtn: byId('mly-enable-btn'),
minimizeBtn: byId('mly-minimize-btn'),
minimized: byId('mly-minimized'),
minimizedBadge: byId('mly-minimized-badge'),
form: byId('mly-query-form'),
input: byId('mly-query-input'),
runBtn: byId('mly-query-run'),
answer: byId('mly-answer'),
progress: byId('mly-progress'),
progressFill: byId('mly-progress-fill'),
progressLabel: byId('mly-progress-label'),
results: byId('mly-results'),
chips: byId('mly-result-chips'),
frameBtn: byId('mly-frame-btn'),
threeDBtn: byId('mly-3d-btn'),
threeDNote: byId('mly-3d-note'),
photorealBtn: byId('mly-photoreal-btn'),
sinceSelect: byId('mly-since'),
aiHint: byId('mly-ai-hint'),
aiModel: byId('mly-ai-model'),
clearBtn: byId('mly-clear-btn'),
lookBtn: byId('mly-look-btn'),
followBtn: byId('mly-follow-btn'),
closeBtn: byId('mly-viewer-close'),
viewerWrap: byId('mly-viewer-wrap'),
viewerExpand: byId('mly-viewer-expand'),
viewerFit: byId('mly-viewer-fit'),
imageBy: byId('mly-image-by'),
imageWhen: byId('mly-image-when'),
imageLink: byId('mly-image-link'),
header: this.root?.querySelector('.mly-header') || null,
viewer: byId('mly-viewer'),
imageMeta: byId('mly-image-meta'),
coverageMeta: byId('mly-coverage-meta'),
};
}
listen(target, type, handler, options = {}) {
target?.addEventListener(type, handler, {
...options,
signal: this.listeners.signal,
});
}
_bind() {
const el = this._elements;
if (!this.root) return;
this.mapillary.attachViewerHost?.(el.viewer);
this.listen(el.toggle, 'click', () =>
this.setCollapsed(!this.root.classList.contains('collapsed')),
);
// Portable, resizable window: drag the header, resize from the grip.
this._floating = makeFloating(this.root, {
handle: el.header,
storageKey: DOCK_WINDOW_KEY,
dragThrough: '.mly-title-btn',
minWidth: 320,
minHeight: 220,
onChange: () => this.mapillary.resizeViewer?.(),
});
this.listen(el.viewerExpand, 'click', () =>
this.setViewerExpanded(
!this.root.classList.contains('mly-viewer-expanded'),
),
);
this.listen(document, 'keydown', (event) => {
if (
event.key === 'Escape' &&
this.root.classList.contains('mly-viewer-expanded')
)
this.setViewerExpanded(false);
});
this.listen(el.viewerFit, 'click', () => {
const next =
this._state?.street?.renderMode === 'fill' ? 'letterbox' : 'fill';
this.mapillary.setViewerRenderMode?.(next);
try {
localStorage.setItem(RENDER_MODE_KEY, next);
} catch {
/* storage unavailable */
}
});
try {
const stored = localStorage.getItem(RENDER_MODE_KEY);
if (stored === 'fill' || stored === 'letterbox')
this.mapillary.setViewerRenderMode?.(stored);
} catch {
/* storage unavailable */
}
// MapillaryJS only tracks window resizes; the dock resizes on its own
// (drag grip, expand, reflow), so watch the host element directly.
if (typeof ResizeObserver === 'function' && el.viewer) {
let queued = false;
this._resizeObserver = new ResizeObserver(() => {
if (queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
this.mapillary.resizeViewer?.();
});
});
this._resizeObserver.observe(el.viewer);
}
this.listen(el.minimizeBtn, 'click', () => this.setMinimized(true));
this.listen(el.minimized, 'click', () => this.setMinimized(false));
this.listen(el.enableBtn, 'click', () => this._toggleEnabled());
this.listen(el.form, 'submit', (event) => {
event.preventDefault();
this._run(el.input?.value);
});
for (const button of this.root.querySelectorAll('[data-mly-suggestion]')) {
this.listen(button, 'click', () => {
if (el.input) el.input.value = button.dataset.mlySuggestion || '';
this._run(button.dataset.mlySuggestion);
});
}
this.listen(el.frameBtn, 'click', () => this.mapillary.frameResults?.());
this.listen(el.clearBtn, 'click', () => this.mapillary.clearQuery?.());
this.listen(el.lookBtn, 'click', async () => {
if (!(await this._ensureEnabled())) return;
this.setCollapsed(false);
this.mapillary.openNearest?.();
});
this.listen(el.followBtn, 'click', () => {
const next = !(this._state?.street?.follow === true);
this.mapillary.setFollow?.(next);
});
this.listen(el.closeBtn, 'click', () => this.mapillary.closeViewer?.());
this.listen(el.threeDBtn, 'click', () => {
const next = !(this._state?.objects3d?.enabled === true);
this.mapillary.setObjects3d?.(next);
});
for (const button of this.root.querySelectorAll('[data-mly-pano]')) {
this.listen(button, 'click', () =>
this.mapillary.setCoverageFilter?.({ pano: button.dataset.mlyPano }),
);
}
this.listen(el.sinceSelect, 'change', () => {
const value = String(el.sinceSelect.value || '0');
let sinceMs = null;
if (value.startsWith('year:'))
sinceMs = Date.UTC(Number(value.slice(5)), 0, 1);
else if (Number(value) > 0)
sinceMs = Date.now() - Number(value) * 86_400_000;
this.mapillary.setCoverageFilter?.({ sinceMs });
});
this.listen(el.photorealBtn, 'click', async () => {
const result = await this.actions.setMapStack?.('photoreal');
if (result?.error) this.actions.showToast?.(result.error);
});
// Keep the app's keyboard shortcuts from firing while typing a query.
this.listen(el.input, 'keydown', (event) => event.stopPropagation());
this.listen(el.input, 'keyup', (event) => event.stopPropagation());
}
async _ensureEnabled() {
if (this.actions.isEnabled?.()) return true;
try {
await this.actions.setEnabled?.(true);
} catch (error) {
this.actions.showToast?.(
error?.message || 'Street Level could not start',
);
return false;
}
return this.actions.isEnabled?.() === true;
}
async _toggleEnabled() {
const enabled = this.actions.isEnabled?.() === true;
try {
await this.actions.setEnabled?.(!enabled);
} catch (error) {
this.actions.showToast?.(error?.message || 'Street Level toggle failed');
}
if (!enabled) this.setCollapsed(false);
}
async _run(prompt) {
const text = String(prompt || '').trim();
if (!text) return;
if (!(await this._ensureEnabled())) return;
this.setCollapsed(false);
this.mapillary.runQuery?.(text);
}
connect() {
this._unsubscribe?.();
this._unsubscribe = null;
if (this.destroyed || !this.root) return;
this._unsubscribe = this.mapillary.subscribe?.((state) =>
this.render(state),
);
if (this.mapillary.getUIState) this.render(this.mapillary.getUIState());
}
_restoreCollapsed() {
// Open by default: the typed Mapillary AI box is the front door.
let collapsed = false;
try {
const stored = localStorage.getItem(DOCK_STORAGE_KEY);
if (stored === '1') collapsed = true;
} catch {
/* storage unavailable */
}
this.setCollapsed(collapsed, { persist: false });
}
_restoreMinimized() {
let minimized = false;
try {
minimized = localStorage.getItem(DOCK_MINIMIZED_KEY) === '1';
} catch {
/* storage unavailable */
}
this.setMinimized(minimized, { persist: false });
}
/**
* Shrink the whole panel to a round Mapillary logo, or bring it back.
* The logo carries the layer's on/off state and a result-count badge.
*/
setMinimized(minimized, { persist = true } = {}) {
if (!this.root) return;
const on = minimized === true;
if (on) this.setViewerExpanded(false);
this.root.classList.toggle('minimized', on);
if (this._elements.minimized) this._elements.minimized.hidden = !on;
this.root.setAttribute('aria-expanded', String(!on));
if (!on) {
this.setCollapsed(false, { persist });
requestAnimationFrame(() => this.mapillary.resizeViewer?.());
}
if (persist) {
try {
localStorage.setItem(DOCK_MINIMIZED_KEY, on ? '1' : '0');
} catch {
/* storage unavailable */
}
}
this._renderMinimized(this._state);
}
isMinimized() {
return this.root?.classList.contains('minimized') === true;
}
_renderMinimized(state) {
const el = this._elements;
if (!el.minimized) return;
const enabled = state?.enabled === true;
el.minimized.classList.toggle('is-off', !enabled);
const total = state?.features?.total || 0;
if (el.minimizedBadge) {
el.minimizedBadge.hidden = !(enabled && total > 0);
el.minimizedBadge.textContent =
total >= 1000 ? `${Math.round(total / 1000)}k` : String(total);
}
el.minimized.title = enabled
? `Open Mapillary · Street Level on${total ? ` · ${total.toLocaleString()} results` : ''}`
: 'Open Mapillary';
}
/** Grow the street-level viewer to most of the screen, or shrink it back. */
setViewerExpanded(expanded) {
if (!this.root) return;
const on = expanded === true;
const wrap = this._elements.viewerWrap;
if (on === this.root.classList.contains('mly-viewer-expanded')) return;
this.root.classList.toggle('mly-viewer-expanded', on);
if (wrap) {
if (on) {
// Lift the viewer out of the dock: the dock's backdrop-filter would
// otherwise pin a fixed-position child inside the panel.
this._wrapHome = { parent: wrap.parentNode, next: wrap.nextSibling };
document.body.appendChild(wrap);
wrap.classList.add('mly-viewer-wrap-expanded');
} else if (this._wrapHome?.parent) {
wrap.classList.remove('mly-viewer-wrap-expanded');
this._wrapHome.parent.insertBefore(wrap, this._wrapHome.next);
this._wrapHome = null;
}
}
const button = this._elements.viewerExpand;
if (button) {
const icon = button.querySelector('.mly-btn-icon');
const text = button.querySelector('.mly-btn-text');
if (icon) icon.textContent = on ? '⤡' : '⤢';
if (text) text.textContent = on ? 'SHRINK' : 'EXPAND';
button.setAttribute('aria-pressed', String(on));
button.setAttribute('aria-label', on ? 'Shrink' : 'Expand');
}
requestAnimationFrame(() => this.mapillary.resizeViewer?.());
}
setCollapsed(collapsed, { persist = true } = {}) {
if (!this.root) return;
this.root.classList.toggle('collapsed', collapsed);
this._elements.toggle?.setAttribute('aria-expanded', String(!collapsed));
this._elements.toggle?.setAttribute(
'title',
collapsed ? 'Expand Mapillary' : 'Collapse Mapillary',
);
if (!collapsed) this.mapillary.resizeViewer?.();
if (persist) {
try {
localStorage.setItem(DOCK_STORAGE_KEY, collapsed ? '1' : '0');
} catch {
/* storage unavailable */
}
}
}
render(state) {
if (this.destroyed || !state || !this.root) return;
this._state = state;
const el = this._elements;
const enabled = state.enabled === true;
el.enableBtn.textContent = enabled ? 'STREET LEVEL ON' : 'STREET LEVEL OFF';
el.enableBtn.setAttribute('aria-pressed', String(enabled));
let chip = enabled ? 'ON' : 'OFF';
let chipClass = enabled ? 'is-on' : '';
if (state.keyRequired) {
chip = 'KEY REQUIRED';
chipClass = 'is-warn';
} else if (state.query.busy) {
chip = `AI · ${state.query.stage.toUpperCase()}`;
chipClass = 'is-busy';
} else if (state.coverage.loading || state.features.loading) {
chip = 'LOADING';
chipClass = 'is-busy';
}
el.statusChip.textContent = chip;
el.statusChip.className = `mly-chip ${chipClass}`.trim();
if (enabled && this._wasEnabled === false) this.setCollapsed(false);
this._wasEnabled = enabled;
this._renderMinimized(state);
// Imagery needs the panel: opening an image brings a minimized dock back.
if (state.street.open && !this._wasStreetOpen && this.isMinimized())
this.setMinimized(false);
this._wasStreetOpen = state.street.open === true;
el.runBtn.disabled = state.query.busy;
el.runBtn.textContent = state.query.busy ? '…' : 'ASK';
el.input.disabled = false;
el.input.placeholder = state.planner
? 'Type a question, e.g. show me all fire hydrants in Sacramento'
: 'Add an Anthropic key in POWER UP to enable Mapillary AI';
if (el.aiModel)
el.aiModel.textContent = state.plannerModel
? `· ${state.plannerModel}`
: '';
if (el.aiHint) {
el.aiHint.classList.toggle('is-warn', !state.planner);
el.aiHint.textContent = state.planner
? 'Type what you want to see in plain English — objects, traffic signs, coverage or a street view of any place — and press Enter. Follow-ups refine the last answer.'
: 'Mapillary AI is off until an Anthropic key is added in POWER UP. Coverage, sequences and the viewer work without it.';
}
// Imagery filters.
const filter = state.coverage.filter || { pano: 'all', sinceMs: null };
for (const button of this.root.querySelectorAll('[data-mly-pano]')) {
const active = button.dataset.mlyPano === filter.pano;
button.classList.toggle('is-active', active);
button.setAttribute('aria-checked', String(active));
}
// 3D objects.
const objects = state.objects3d || {};
el.threeDBtn.setAttribute('aria-pressed', String(objects.enabled === true));
el.threeDBtn.textContent = objects.building
? '3D OBJECTS …'
: objects.enabled
? '3D OBJECTS ON'
: '3D OBJECTS OFF';
el.threeDNote.hidden = !(
objects.enabled &&
(objects.error || objects.active)
);
el.threeDNote.textContent = objects.error
? objects.error
: objects.active
? `${objects.count.toLocaleString()} objects standing in 3D · click one to open its image`
: '';
el.photorealBtn.setAttribute(
'aria-pressed',
String(this.actions.isMapStackActive?.('photoreal') === true),
);
// Answer / error line.
const error = state.query.error || state.street.error;
if (error) {
el.answer.textContent = error;
el.answer.classList.add('is-error');
} else {
el.answer.classList.remove('is-error');
if (state.query.answer) el.answer.textContent = state.query.answer;
else if (state.query.busy) el.answer.textContent = 'Planning…';
else if (!enabled)
el.answer.textContent =
'Turn on Street Level to see Mapillary coverage, then ask for anything that appears in street imagery.';
else if (state.coverage.hint) el.answer.textContent = state.coverage.hint;
else
el.answer.textContent =
'Click a green sequence to see its images, or ask Mapillary AI for objects and signs.';
}
// Progress bar while tiles stream.
const { progress } = state.features;
const showProgress = state.features.loading && progress.tiles > 0;
el.progress.hidden = !showProgress;
if (showProgress) {
const pct = Math.round((progress.done / progress.tiles) * 100);
el.progressFill.style.width = `${pct}%`;
el.progressLabel.textContent = `${progress.done}/${progress.tiles} tiles · ${state.features.total.toLocaleString()} found`;
}
// Result chips.
const hasResults = state.features.total > 0 || state.features.hasBbox;
el.results.hidden = !hasResults;
if (hasResults) {
el.chips.replaceChildren(
...state.features.counts
.slice(0, 8)
.map(({ value, count, label, color }) => {
const chipEl = document.createElement('span');
chipEl.className = 'mly-value-chip';
chipEl.title = value;
const dot = document.createElement('i');
dot.style.background = color;
const text = document.createElement('span');
text.textContent = label;
const num = document.createElement('b');
num.textContent = count.toLocaleString();
chipEl.append(dot, text, num);
return chipEl;
}),
);
if (state.features.counts.length > 8) {
const more = document.createElement('span');
more.className = 'mly-value-chip';
more.textContent = `+${state.features.counts.length - 8} classes`;
el.chips.append(more);
}
}
// Street-level viewer.
const { street } = state;
el.viewerWrap.hidden = !street.open;
this.root.classList.toggle('mly-has-viewer', street.open === true);
if (!street.open && this.root.classList.contains('mly-viewer-expanded'))
this.setViewerExpanded(false);
el.followBtn.setAttribute('aria-pressed', String(street.follow === true));
el.followBtn.textContent = street.follow
? 'STREET COCKPIT ON'
: 'STREET COCKPIT OFF';
el.followBtn.disabled = !street.open;
if (el.viewerFit) {
const fill = street.renderMode === 'fill';
el.viewerFit.textContent = fill ? 'FILL' : 'FIT';
el.viewerFit.title = fill
? 'Filling the frame (cropped) · click to show the whole image'
: 'Showing the whole image · click to fill the frame';
}
if (street.open) {
// Caption bar as on mapillary.com: "Image by …" left, date right.
const left = [];
if (street.loading) left.push('LOADING…');
if (street.feature?.label)
left.push(
`${street.feature.label}${street.feature.imageCount ? ` · ${street.feature.imageCount} sightings` : ''}`,
);
if (street.highlight?.count)
left.push(
`${street.highlight.count} detection${street.highlight.count === 1 ? '' : 's'} outlined`,
);
if (street.creator) left.push(`Image by ${street.creator}`);
const right = [];
if (street.isPano) right.push('360°');
if (Number.isFinite(street.bearing))
right.push(`${Math.round(street.bearing)}°`);
if (street.capturedAt) right.push(formatDate(street.capturedAt));
if (el.imageBy) el.imageBy.textContent = left.join(' · ');
if (el.imageWhen) el.imageWhen.textContent = right.join(' · ');
if (el.imageLink) {
el.imageLink.href = mapillaryImageUrl(street.imageId);
el.imageLink.hidden = !street.imageId;
}
this.mapillary.resizeViewer?.();
}
// Coverage meta line.
const coverageBits = [];
if (enabled && state.coverage.zoom)
coverageBits.push(
`coverage z${state.coverage.zoom} · ${state.coverage.sequences.toLocaleString()} sequences`,
);
if (state.sequence.selectedId)
coverageBits.push(
state.sequence.loading
? 'loading sequence…'
: `${state.sequence.images} images in sequence · Esc clears`,
);
if (state.query.usage?.model)
coverageBits.push(
`${state.query.usage.model} · ${state.query.usage.input_tokens ?? '?'}→${state.query.usage.output_tokens ?? '?'} tok`,
);
el.coverageMeta.textContent = coverageBits.join(' · ');
if (state.query.stage !== this._lastStage) {
this._lastStage = state.query.stage;
if (state.query.stage === 'done' || state.query.stage === 'error') {
this.setCollapsed(false);
if (this.isMinimized()) this.setMinimized(false);
}
}
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this.setViewerExpanded(false);
this.listeners.abort();
this._resizeObserver?.disconnect();
this._resizeObserver = null;
this._floating?.destroy();
this._unsubscribe?.();
this._unsubscribe = null;
this.mapillary.attachViewerHost?.(null);
}
}
+1
View File
@@ -44,6 +44,7 @@ export function readShellElements(document = globalThis.document) {
_dataPanel: document.getElementById('data-panel'),
_scenePanel: document.getElementById('scene-panel'),
_cctvPanel: document.getElementById('cctv-panel'),
_mapillaryDock: document.getElementById('mapillary-dock'),
_radioPanel: document.getElementById('radio-panel'),
_contextRadioDock: document.getElementById('context-radio-dock'),
_contextRadioToggleBtn: document.getElementById('context-radio-toggle-btn'),
File diff suppressed because it is too large. Load diff
+959
View File
@@ -0,0 +1,959 @@
/* ── Mapillary dock ─────────────────────────── */
#mapillary-dock {
--mly-green: #05cb63;
--mly-width: clamp(320px, 30vw, 460px);
position: fixed;
/* Sit beside the right context rail (DISPLAY / CONTEXT / CCTV), which owns
the right-hand 330px column from 26vh down, so the two never overlap. */
right: calc(var(--right-rail-x, 52px) + 330px + 16px);
bottom: 3.4rem;
z-index: 118;
width: var(--mly-width);
max-width: calc(100vw - 32px);
max-height: calc(100vh - 7rem);
display: flex;
flex-direction: column;
pointer-events: auto;
font-family: var(--font-mono);
}
#mapillary-dock .panel-glow {
position: absolute;
inset: -18px;
background: radial-gradient(
ellipse at center,
rgba(5, 203, 99, 0.16) 0%,
transparent 65%
);
filter: blur(16px);
opacity: 0.25;
pointer-events: none;
}
.mly-dock-inner {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 14px;
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--panel-radius);
backdrop-filter: blur(24px) saturate(1.4);
-webkit-backdrop-filter: blur(24px) saturate(1.4);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
}
.mly-header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mly-title-btn {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: 0;
color: var(--text-primary);
cursor: pointer;
padding: 2px 0;
font: inherit;
letter-spacing: 0.12em;
font-size: 0.72rem;
}
.mly-brand-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--mly-green);
box-shadow: 0 0 10px rgba(5, 203, 99, 0.8);
}
.mly-chip {
font-size: 0.58rem;
letter-spacing: 0.1em;
padding: 2px 7px;
border-radius: 999px;
border: 1px solid var(--glass-border-hover);
color: var(--text-secondary);
}
.mly-chip.is-on {
color: var(--mly-green);
border-color: rgba(5, 203, 99, 0.55);
}
.mly-chip.is-busy {
color: var(--accent);
border-color: rgba(0, 212, 255, 0.55);
}
.mly-chip.is-warn {
color: #ffb300;
border-color: rgba(255, 179, 0, 0.55);
}
.mly-header-tools {
display: flex;
align-items: center;
gap: 6px;
}
.mly-icon-btn {
width: 26px;
height: 26px;
border-radius: var(--btn-radius);
border: 1px solid var(--glass-border-hover);
background: rgba(255, 255, 255, 0.04);
color: var(--text-secondary);
font: inherit;
font-size: 0.7rem;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
transition: all var(--transition-fast);
}
.mly-icon-btn:hover {
color: var(--text-primary);
border-color: var(--accent);
background: rgba(0, 212, 255, 0.14);
}
/* Author display rules would otherwise beat the UA's [hidden] { display: none }. */
.mly-viewer-wrap[hidden],
.mly-minimized[hidden],
.mly-minimized-badge[hidden],
.mly-image-link[hidden] {
display: none !important;
}
/* ── Minimized: only the Mapillary logo remains ─ */
.mly-minimized {
position: relative;
width: 52px;
height: 52px;
border-radius: 50%;
border: 1px solid rgba(5, 203, 99, 0.55);
background: var(--glass-bg);
backdrop-filter: blur(18px) saturate(1.4);
-webkit-backdrop-filter: blur(18px) saturate(1.4);
color: var(--mly-green);
cursor: pointer;
display: grid;
place-items: center;
box-shadow:
0 6px 24px rgba(0, 0, 0, 0.5),
0 0 18px rgba(5, 203, 99, 0.35);
transition:
transform var(--transition-fast),
box-shadow var(--transition-fast);
}
.mly-minimized:hover {
transform: scale(1.06);
box-shadow:
0 8px 28px rgba(0, 0, 0, 0.55),
0 0 26px rgba(5, 203, 99, 0.6);
}
.mly-minimized .mly-logo {
width: 28px;
height: 28px;
display: block;
}
.mly-minimized-badge {
position: absolute;
top: -4px;
right: -6px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--accent);
color: #0a0a0f;
font-size: 0.52rem;
font-weight: 600;
letter-spacing: 0.04em;
display: grid;
place-items: center;
box-shadow: 0 0 10px rgba(0, 212, 255, 0.6);
}
.mly-minimized.is-off {
color: var(--text-secondary);
border-color: var(--glass-border-hover);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5);
}
#mapillary-dock.minimized {
width: auto !important;
height: auto !important;
min-width: 0 !important;
min-height: 0 !important;
max-height: none !important;
align-items: flex-end;
}
#mapillary-dock.minimized .mly-dock-inner,
#mapillary-dock.minimized .panel-glow,
#mapillary-dock.minimized .gev-resize-grip,
#mapillary-dock.minimized .gev-resize-edge {
display: none;
}
.mly-power {
font: inherit;
font-size: 0.6rem;
letter-spacing: 0.1em;
padding: 6px 10px;
border-radius: var(--btn-radius);
border: 1px solid var(--glass-border-hover);
background: rgba(255, 255, 255, 0.04);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.mly-power[aria-pressed='true'] {
color: #0a0a0f;
background: var(--mly-green);
border-color: var(--mly-green);
box-shadow: 0 0 14px rgba(5, 203, 99, 0.45);
}
#mapillary-dock.collapsed .mly-body {
display: none;
}
.mly-body {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1 1 auto;
min-height: 0;
}
/* Everything above the viewer scrolls; the viewer itself never does. */
.mly-scroll {
display: flex;
flex-direction: column;
gap: 8px;
flex: 0 1 auto;
min-height: 0;
overflow-y: auto;
scrollbar-width: thin;
padding-right: 2px;
}
/* Children keep their natural height; the region scrolls instead. */
.mly-scroll > * {
flex-shrink: 0;
}
#mapillary-dock.mly-has-viewer .mly-scroll {
/* Let the imagery win the height fight when the window is short. */
flex-shrink: 3;
}
.mly-kicker {
font-size: 0.56rem;
letter-spacing: 0.16em;
color: var(--accent);
}
.mly-query {
display: flex;
flex-direction: column;
gap: 6px;
}
.mly-query-row {
display: flex;
gap: 6px;
}
#mly-query-input {
flex: 1;
min-width: 0;
padding: 8px 10px;
border-radius: var(--btn-radius);
border: 1px solid var(--glass-border-hover);
background: rgba(0, 0, 0, 0.35);
color: var(--text-primary);
font: inherit;
font-size: 0.72rem;
}
#mly-query-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-dim);
}
.mly-run {
min-width: 56px;
}
.mly-suggestions {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.mly-suggestion {
font: inherit;
font-size: 0.56rem;
letter-spacing: 0.06em;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid var(--glass-border);
background: rgba(255, 255, 255, 0.03);
color: var(--text-secondary);
cursor: pointer;
}
.mly-suggestion:hover {
color: var(--text-primary);
border-color: var(--glass-border-hover);
}
.mly-answer {
font-family: var(--font-sans);
font-size: 0.74rem;
line-height: 1.4;
color: var(--text-primary);
min-height: 1.2em;
}
.mly-answer.is-error {
color: #ff8a80;
}
.mly-progress {
position: relative;
height: 14px;
border-radius: 999px;
overflow: hidden;
background: rgba(255, 255, 255, 0.06);
}
.mly-progress-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, var(--mly-green), var(--accent));
transition: width 160ms linear;
}
.mly-progress-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.55rem;
letter-spacing: 0.1em;
color: var(--text-primary);
text-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
}
.mly-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.mly-value-chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.58rem;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid var(--glass-border);
color: var(--text-primary);
}
.mly-value-chip i {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.mly-value-chip b {
color: var(--text-secondary);
font-weight: 500;
}
.mly-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.mly-actions .scene-btn {
flex: 1 1 auto;
}
.scene-btn[aria-pressed='true'] {
color: #0a0a0f;
background: var(--accent);
border-color: var(--accent);
}
.mly-viewer-wrap {
position: relative;
container-type: inline-size;
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 230px;
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--glass-border-hover);
background: #000;
}
.mly-viewer {
width: 100%;
flex: 1 1 auto;
min-height: 0;
/* Intrinsic height while the dock is auto-sized; grows when floating. */
aspect-ratio: 16 / 10;
}
#mapillary-dock.gev-floating .mly-viewer,
.mly-viewer-wrap-expanded .mly-viewer {
aspect-ratio: auto;
min-height: 180px;
}
/* Caption bar, mapillary.com style: creator left, date right. */
.mly-image-meta {
flex: 0 0 auto;
padding: 6px 10px;
font-size: 0.58rem;
letter-spacing: 0.06em;
color: var(--text-secondary);
background: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
min-height: 26px;
}
.mly-image-by {
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.mly-image-right {
display: inline-flex;
align-items: center;
gap: 10px;
white-space: nowrap;
}
.mly-image-link {
color: var(--mly-green);
text-decoration: none;
letter-spacing: 0.1em;
}
.mly-image-link:hover {
text-decoration: underline;
}
.mly-viewer-tools.mly-viewer-tools-right {
left: auto;
right: 6px;
}
/* Only the buttons catch clicks; the containers let the viewer through. */
.mly-viewer-tools {
pointer-events: none;
}
.mly-viewer-tools > * {
pointer-events: auto;
}
.mly-viewer-x {
width: 30px;
height: 30px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(10, 10, 15, 0.8);
color: var(--text-primary);
font: inherit;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
transition: all var(--transition-fast);
}
.mly-viewer-x:hover {
color: #0a0a0f;
background: #fff;
border-color: #fff;
}
.mly-viewer-wrap-expanded .mly-viewer-tools.mly-viewer-tools-right {
left: auto;
right: 10px;
top: 10px;
}
.mly-viewer-wrap-expanded .mly-viewer-x {
width: 40px;
height: 40px;
font-size: 1.5rem;
}
.mly-meta {
font-size: 0.56rem;
letter-spacing: 0.06em;
color: var(--text-dim);
}
/* Match the app's chrome-hiding modes. */
body.ui-clean-view #mapillary-dock,
body.recording-mode #mapillary-dock {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
}
body.cockpit-mode #mapillary-dock {
display: none !important;
}
/* Not enough room beside the rail: fall back to the corner (still draggable). */
@media (max-width: 1100px) {
#mapillary-dock {
right: 16px;
}
}
@media (max-width: 720px) {
#mapillary-dock {
left: 16px;
right: 16px;
width: auto;
max-width: none;
bottom: 6.4rem;
max-height: calc(100vh - 8rem);
}
}
/* Short viewports: trim the chrome so the imagery keeps its room. */
@media (max-height: 640px) {
.mly-dock-inner {
padding: 8px 10px;
gap: 6px;
}
#mapillary-dock.mly-has-viewer .mly-ai-hint,
#mapillary-dock.mly-has-viewer .mly-suggestions {
display: none;
}
.mly-viewer-wrap {
min-height: 200px;
}
}
/* ── Prominent AI entry ─────────────────────── */
.mly-ai-head {
display: flex;
align-items: center;
gap: 8px;
}
.mly-ai-head .mly-kicker {
font-size: 0.66rem;
color: var(--text-primary);
}
.mly-ai-model {
margin-left: auto;
font-size: 0.52rem;
letter-spacing: 0.08em;
color: var(--text-dim);
}
.mly-ai-pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.6);
animation: mly-pulse 2.2s ease-out infinite;
}
@keyframes mly-pulse {
0% {
box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.6);
}
70% {
box-shadow: 0 0 0 9px rgba(0, 212, 255, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(0, 212, 255, 0);
}
}
#mapillary-dock #mly-query-input {
padding: 11px 12px;
font-size: 0.8rem;
border: 1px solid rgba(0, 212, 255, 0.45);
background: rgba(0, 212, 255, 0.06);
box-shadow: 0 0 18px rgba(0, 212, 255, 0.12) inset;
}
#mapillary-dock #mly-query-input::placeholder {
color: rgba(232, 234, 237, 0.55);
}
#mapillary-dock #mly-query-input:focus {
background: rgba(0, 212, 255, 0.1);
box-shadow:
0 0 0 2px var(--accent-dim),
0 0 22px rgba(0, 212, 255, 0.25);
}
.mly-ask {
min-width: 64px;
font: inherit;
font-size: 0.66rem;
letter-spacing: 0.12em;
border-radius: var(--btn-radius);
border: 1px solid var(--accent);
background: var(--accent);
color: #0a0a0f;
cursor: pointer;
font-weight: 600;
transition: all var(--transition-fast);
}
.mly-ask:hover:not(:disabled) {
box-shadow: 0 0 16px rgba(0, 212, 255, 0.5);
}
.mly-ask:disabled {
opacity: 0.55;
cursor: progress;
}
.mly-ai-hint {
font-family: var(--font-sans);
font-size: 0.66rem;
line-height: 1.35;
color: var(--text-secondary);
}
.mly-ai-hint.is-warn {
color: #ffb300;
}
/* ── Imagery filters ────────────────────────── */
.mly-filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.mly-filter-label {
font-size: 0.54rem;
letter-spacing: 0.14em;
color: var(--text-dim);
}
.mly-seg {
display: inline-flex;
border: 1px solid var(--glass-border-hover);
border-radius: 999px;
overflow: hidden;
}
.mly-seg-btn {
font: inherit;
font-size: 0.56rem;
letter-spacing: 0.08em;
padding: 4px 9px;
border: 0;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.mly-seg-btn + .mly-seg-btn {
border-left: 1px solid var(--glass-border);
}
.mly-seg-btn.is-active {
background: rgba(5, 203, 99, 0.22);
color: var(--text-primary);
}
.mly-select {
font: inherit;
font-size: 0.56rem;
letter-spacing: 0.06em;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid var(--glass-border-hover);
background: rgba(0, 0, 0, 0.35);
color: var(--text-primary);
}
.mly-select option {
background: #0a0a0f;
}
/* ── Portable, resizable windows ────────────── */
.gev-floating-handle {
cursor: grab;
}
.gev-floating-dragging,
.gev-floating-dragging * {
cursor: grabbing !important;
user-select: none;
}
.gev-floating {
position: fixed !important;
right: auto !important;
bottom: auto !important;
margin: 0 !important;
max-width: calc(100vw - 12px) !important;
z-index: 132 !important;
display: flex;
flex-direction: column;
}
.gev-resize-grip {
position: absolute;
right: 5px;
bottom: 5px;
width: 16px;
height: 16px;
cursor: nwse-resize;
z-index: 6;
opacity: 0.6;
border-radius: 3px;
background: linear-gradient(
135deg,
transparent 0 45%,
var(--accent) 45% 55%,
transparent 55% 70%,
var(--accent) 70% 80%,
transparent 80%
);
}
.gev-resize-grip:hover {
opacity: 1;
}
/* Invisible strips along the other edges and corners. */
.gev-resize-edge {
position: absolute;
z-index: 7;
}
.gev-resize-edge[data-dir='n'],
.gev-resize-edge[data-dir='s'] {
left: 10px;
right: 10px;
height: 7px;
cursor: ns-resize;
}
.gev-resize-edge[data-dir='e'],
.gev-resize-edge[data-dir='w'] {
top: 10px;
bottom: 10px;
width: 7px;
cursor: ew-resize;
}
.gev-resize-edge[data-dir='n'] {
top: -3px;
}
.gev-resize-edge[data-dir='s'] {
bottom: -3px;
}
.gev-resize-edge[data-dir='e'] {
right: -3px;
}
.gev-resize-edge[data-dir='w'] {
left: -3px;
}
.gev-resize-edge[data-dir='ne'],
.gev-resize-edge[data-dir='nw'],
.gev-resize-edge[data-dir='sw'] {
width: 14px;
height: 14px;
}
.gev-resize-edge[data-dir='ne'] {
top: -3px;
right: -3px;
cursor: nesw-resize;
}
.gev-resize-edge[data-dir='nw'] {
top: -3px;
left: -3px;
cursor: nwse-resize;
}
.gev-resize-edge[data-dir='sw'] {
bottom: -3px;
left: -3px;
cursor: nesw-resize;
}
#mapillary-dock.gev-floating {
max-height: none !important;
}
#mapillary-dock.gev-floating .mly-dock-inner {
flex: 1 1 auto;
min-height: 0;
max-height: none;
height: 100%;
}
/* The CCTV panel lives in a rail until dragged; then it floats with the
width it had (set inline when lifted), never the viewport's. */
#cctv-panel.gev-floating {
position: fixed !important;
flex: none !important;
max-height: calc(100vh - 12px) !important;
}
#cctv-panel.gev-floating:not([style*='height']) {
height: auto !important;
}
#cctv-panel.gev-floating .cctv-panel-inner {
flex: 1 1 auto;
min-height: 0;
height: 100%;
max-height: none;
}
#cctv-panel.gev-floating.collapsed .cctv-panel-inner {
height: auto;
}
/* ── Expanded street-level viewer ───────────── */
.mly-viewer-tools {
position: absolute;
top: 6px;
left: 6px;
/* Above MapillaryJS's own overlays (z 10); the corners are free — its
sequence bar is top-centre, bearing/zoom/attribution bottom-right. */
z-index: 12;
display: flex;
gap: 4px;
}
.mly-viewer-tools .scene-btn {
padding: 4px 8px;
font-size: 0.56rem;
background: rgba(10, 10, 15, 0.75);
/* Hug the label: the playback bar MapillaryJS centres at the top needs
the middle of narrow viewers. */
flex: none;
min-width: 0;
display: inline-flex;
align-items: center;
gap: 4px;
}
/* Narrow viewer: icon-only expand button so nothing meets the playback bar. */
@container (max-width: 380px) {
.mly-viewer-tools .mly-btn-text {
display: none;
}
.mly-viewer-tools .scene-btn {
padding: 4px 7px;
}
}
/* The dock's backdrop-filter makes it the containing block for fixed
descendants, so the expanded viewer is re-parented to <body>. */
.mly-viewer-wrap-expanded {
position: fixed;
left: 4vw;
right: 4vw;
top: 5vh;
bottom: 9vh;
z-index: 141;
display: flex;
flex-direction: column;
font-family: var(--font-mono);
border-color: rgba(0, 212, 255, 0.55);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7);
}
.mly-viewer-wrap-expanded .mly-viewer {
flex: 1 1 auto;
min-height: 0;
aspect-ratio: auto;
}
.mly-viewer-wrap-expanded .mly-image-meta {
padding: 10px 14px;
}
.mly-viewer-wrap-expanded .mly-image-meta {
font-size: 0.64rem;
}
.mly-viewer-wrap-expanded .mly-viewer-tools {
top: 10px;
left: 10px;
}
.mly-viewer-wrap-expanded .mly-viewer-tools .scene-btn {
padding: 8px 14px;
font-size: 0.7rem;
letter-spacing: 0.12em;
color: var(--text-primary);
border-color: rgba(0, 212, 255, 0.6);
background: rgba(10, 10, 15, 0.82);
}
.mly-viewer-wrap-expanded .mly-viewer-tools .scene-btn::after {
content: ' · ESC';
color: var(--text-secondary);
}