refactor(mapillary): remove the plain-English query feature

Street Level is imagery only from here on: coverage, sequences and the
embedded viewer. The Claude planner, map-feature and traffic-sign tiles,
the NDJSON feature stream, sprites, detection outlines and analyst records
go, along with @anthropic-ai/sdk, zod and the Anthropic key entry.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Fable 5.1 committed 2026-09-25 18:08:43 +01:00
1 parent 9ba6f96d87
commit 547b706f67
44 files changed
+143 -5318

No files matched your search

+1 -17
View File
@@ -228,16 +228,11 @@ export function createCoverage({ state, source }) {
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);
}
for (const primitive of primitives) 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);
}
}
@@ -494,16 +489,6 @@ export function createCoverage({ state, source }) {
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;
@@ -518,7 +503,6 @@ export function createCoverage({ state, source }) {
findSequence,
recolorSequence,
sequenceCount,
setResting,
setFilter,
};
}
-46
View File
@@ -97,49 +97,3 @@ export function decodeCoverageTile(bytes, address, { images = false } = {}) {
}
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
@@ -1,25 +0,0 @@
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
@@ -1,313 +0,0 @@
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,
};
}
+2 -83
View File
@@ -1,8 +1,7 @@
/**
* 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.
* panoramas and a position marker for the street-level viewer. Pure canvas
* drawing so it can run without Cesium.
*/
const _cache = new Map();
@@ -85,83 +84,3 @@ export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
_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;
}
+32 -155
View File
@@ -1,9 +1,7 @@
import { createState } from './state.js';
import { createCoverage } from './coverage.js';
import { createCoverage, visibleBbox } 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 {
@@ -12,29 +10,25 @@ import {
MAPILLARY_CREDIT_HTML,
MAPILLARY_KEY_ID,
MAPILLARY_LAYER_ID,
NEAREST_LIMIT,
NEAREST_RADIUS_M,
} 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.
* per-sequence image cones and the embedded viewer. Application services
* are injected; nothing here reads the DOM except the viewer host the UI
* hands over.
*/
export function createMapillaryLayer({ source, services = {} }) {
if (!SOURCE_METHODS.every((method) => typeof source?.[method] === 'function'))
@@ -44,67 +38,20 @@ export function createMapillaryLayer({ source, services = {} }) {
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.
// Street-level actions shared by clicks and the UI.
parts.street = {
async openImage(imageId, { keepFeature = false } = {}) {
async openImage(imageId) {
const host = state.street.host;
if (!host) {
state.street.error = 'Open the Mapillary panel to view imagery';
state.street.error = 'Open the Street Level 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). */
@@ -176,13 +123,24 @@ export function createMapillaryLayer({ source, services = {} }) {
state.credit = null;
}
/** Centre of the visible ground, or the camera's own footprint. */
function viewCentre() {
const viewer = state.viewer;
if (!viewer) return null;
const bbox = visibleBbox(viewer);
if (bbox)
return { lat: (bbox[1] + bbox[3]) / 2, lon: (bbox[0] + bbox[2]) / 2 };
const carto = viewer.camera.positionCartographic;
return {
lat: Cesium.Math.toDegrees(carto.latitude),
lon: Cesium.Math.toDegrees(carto.longitude),
};
}
function getUIState() {
return {
enabled: state.enabled,
keyRequired: state.keyRequired,
// null until the status call answers (the layer initialises lazily).
planner: state.status ? state.status.planner === true : null,
plannerModel: state.status?.plannerModel || null,
coverage: {
zoom: state.coverage.zoom,
kind: state.coverage.kind,
@@ -198,19 +156,6 @@ export function createMapillaryLayer({ source, services = {} }) {
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,
@@ -224,24 +169,6 @@ export function createMapillaryLayer({ source, services = {} }) {
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,
})),
},
};
}
@@ -261,9 +188,7 @@ export function createMapillaryLayer({ source, services = {} }) {
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');
},
@@ -272,7 +197,6 @@ export function createMapillaryLayer({ source, services = {} }) {
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();
@@ -283,16 +207,12 @@ export function createMapillaryLayer({ source, services = {} }) {
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();
},
@@ -302,7 +222,6 @@ export function createMapillaryLayer({ source, services = {} }) {
destroy(viewer = state.viewer) {
layer.disable();
parts.features.destroy(viewer);
parts.sequences.destroy(viewer);
state.listeners.clear();
state.viewer = null;
@@ -311,34 +230,22 @@ export function createMapillaryLayer({ source, services = {} }) {
},
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(),
count: parts.coverage.sequenceCount(),
sequences: parts.coverage.sequenceCount(),
features: state.features.total,
loading,
loading: state.coverage.loading > 0,
keyRequired: state.keyRequired,
error: state.keyRequired
? 'KEY REQUIRED'
: state.coverage.lastError || state.query.error,
error: state.keyRequired ? 'KEY REQUIRED' : state.coverage.lastError,
loadingLabel,
};
},
// ── Public surface used by the panel and the voice/analyst seams ─────
// ── Public surface used by the panel ────────────────────────────────
subscribe(listener) {
if (typeof listener !== 'function') return () => {};
state.listeners.add(listener);
@@ -352,25 +259,15 @@ export function createMapillaryLayer({ source, services = {} }) {
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();
const view = point || viewCentre();
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
return false;
state.street.loading = true;
@@ -380,12 +277,12 @@ export function createMapillaryLayer({ source, services = {} }) {
const images = await source.nearestImages({
lat: view.lat,
lon: view.lon,
radius: 50,
limit: 5,
radius: NEAREST_RADIUS_M,
limit: NEAREST_LIMIT,
});
if (!images.length)
throw new Error(
'No Mapillary imagery within 50 m of the view centre',
`No Mapillary imagery within ${NEAREST_RADIUS_M} m of the view centre`,
);
await parts.street.openImage(images[0].id);
return true;
@@ -398,11 +295,10 @@ export function createMapillaryLayer({ source, services = {} }) {
},
/**
* Close the image and deselect it everywhere on the map: position
* marker, highlighted sequence and its cones, result ring and outline.
* marker, highlighted sequence and its cones.
*/
closeViewer() {
parts.viewer.close();
parts.features.clearSelection();
parts.sequences.clearSelection();
},
setViewerRenderMode: (mode) => parts.viewer.setRenderMode(mode),
@@ -412,25 +308,6 @@ export function createMapillaryLayer({ source, services = {} }) {
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
@@ -1,61 +0,0 @@
/**
* 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
@@ -1,30 +0,0 @@
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']);
});
-11
View File
@@ -7,8 +7,6 @@ export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com';
export const PICK_PREFIX = Object.freeze({
sequence: 'mly:seq:',
image: 'mly:img:',
feature: 'mly:feat:',
object: 'mly:obj:',
position: 'mly:pos',
});
@@ -42,13 +40,6 @@ export const IMAGE_CONE_MIN_SPACING_M = 3;
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.
@@ -61,5 +52,3 @@ 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
@@ -1,516 +0,0 @@
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 };
}
-114
View File
@@ -1,114 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
bboxFromGeocode,
enforceMinimumSpan,
fitBboxToTileBudget,
noResultsAnswer,
pluralLabel,
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 hydrants, 200 benches, 20 mailboxes and 1 more class/,
);
assert.match(text, /seen 2019–2026 · capped · 2 tiles failed$/);
});
test('summarizeResults leads with the class when there is only one', () => {
const text = summarizeResults({
total: 834,
counts: new Map([['object--fire-hydrant', 834]]),
earliest: Date.UTC(2017, 0, 1),
latest: Date.UTC(2021, 0, 1),
failed: 1,
});
assert.equal(text, '834 fire hydrants · seen 2017–2021 · 1 tile failed');
assert.equal(
summarizeResults({ total: 1, counts: new Map([['object--bench', 1]]) }),
'1 bench',
);
});
test('zero results produce no summary so the answer can be replaced', () => {
assert.equal(summarizeResults({ total: 0, counts: new Map() }), '');
assert.equal(
noResultsAnswer(
{ title: 'Fire hydrants · Springfield' },
{ label: 'Springfield, IL' },
),
'No fire hydrants mapped in Springfield, IL yet. Try a larger area, another place, or set SINCE back to any date.',
);
assert.match(
noResultsAnswer({}, null),
/^No matching features mapped here yet/,
);
});
test("pluralLabel handles the taxonomy's English labels", () => {
assert.equal(pluralLabel('Fire hydrant', 2), 'fire hydrants');
assert.equal(pluralLabel('Bench', 3), 'benches');
assert.equal(pluralLabel('Mailbox', 4), 'mailboxes');
assert.equal(pluralLabel('Bench', 1), 'bench');
});
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]);
});
+1 -6
View File
@@ -1,7 +1,7 @@
import * as Cesium from 'cesium';
import { MAPILLARY_LAYER_ID, PICK_PREFIX } from './policy.js';
/** Click handling for coverage lines, image cones and query results. */
/** Click handling for coverage lines and image cones. */
export function createSelection({ state, parts }) {
const { picking, input } = state.services;
@@ -25,11 +25,6 @@ export function createSelection({ state, parts }) {
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]);
}
}
+4 -88
View File
@@ -1,6 +1,4 @@
import { readNdjsonResponse } from './ndjson.js';
import {
FEATURE_FIELDS,
IMAGE_FIELDS,
MAPILLARY_GRAPH_HOST,
NEAREST_LIMIT,
@@ -38,10 +36,10 @@ async function readJsonOrThrow(response, label) {
}
/**
* 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.
* Every network path the Mapillary layer uses. Coverage tiles go through the
* local dev-server proxy (which adds the token and caches them); image and
* sequence lookups go straight to graph.mapillary.com with the client token,
* exactly as the embedded MapillaryJS viewer does.
* @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options
*/
export function createMapillarySource({
@@ -52,10 +50,6 @@ export function createMapillarySource({
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,
};
@@ -114,51 +108,6 @@ export function createMapillarySource({
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 });
},
@@ -196,38 +145,5 @@ export function createMapillarySource({
);
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`;
},
};
}
+4 -38
View File
@@ -96,7 +96,7 @@ test('graph API errors carry the upstream message and status', async () => {
);
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
await assert.rejects(
() => source.getMapFeature('999'),
() => source.getImage('999'),
(error) =>
error instanceof MapillarySourceError &&
error.status === 400 &&
@@ -104,42 +104,8 @@ test('graph API errors carry the upstream message and status', async () => {
);
});
test('feature queries stream NDJSON records and resolve with the done record', async () => {
const lines = [
JSON.stringify({ type: 'start', tiles: 2 }),
JSON.stringify({
type: 'tile',
rows: [[-121.5, 38.5, 'a', 'object--bench', 0, 0]],
}),
JSON.stringify({ type: 'done', total: 1 }),
].join('\n');
const { calls, fetchImpl } = fakeFetch(
() =>
new Response(lines, {
status: 200,
headers: { 'content-type': 'application/x-ndjson' },
}),
);
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
const seen = [];
const done = await source.queryFeatures(
{
bbox: [-121.5, 38.5, -121.4, 38.6],
layer: 'points',
values: ['object--bench'],
},
(record) => seen.push(record.type),
);
assert.deepEqual(seen, ['start', 'tile', 'done']);
assert.equal(done.total, 1);
assert.equal(calls[0].url, '/api/mapillary/features');
assert.equal(calls[0].init.method, 'POST');
});
test('sprite URLs are proxied per taxonomy value', () => {
test('the source exposes only imagery lookups', () => {
const source = createMapillarySource({ token: 'MLY|1|abc' });
assert.equal(
source.spriteUrl('object--fire-hydrant'),
'/api/mapillary/sprite/object--fire-hydrant.svg',
);
for (const gone of ['queryFeatures', 'plan', 'geocode', 'spriteUrl'])
assert.equal(source[gone], undefined, gone);
});
-39
View File
@@ -29,7 +29,6 @@ export function createState({ services }) {
removeCameraListener: null,
terrainReady: null,
hint: '',
resting: false,
kind: null,
filter: { pano: 'all', sinceMs: null },
},
@@ -44,31 +43,6 @@ export function createState({ services }) {
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,
@@ -89,19 +63,6 @@ export function createState({ services }) {
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
-162
View File
@@ -1,162 +0,0 @@
/**
* 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
@@ -1,96 +0,0 @@
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'] },
],
);
});
-72
View File
@@ -1,5 +1,4 @@
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;
@@ -109,8 +108,6 @@ export function createViewerBridge({ state, source, parts }) {
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'
@@ -180,7 +177,6 @@ export function createViewerBridge({ state, source, parts }) {
bearing: true,
zoom: true,
attribution: true,
tag: true,
},
trackResize: true,
renderMode: libraryRenderMode(state.street.renderMode),
@@ -221,70 +217,6 @@ export function createViewerBridge({ state, source, parts }) {
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';
@@ -326,7 +258,6 @@ export function createViewerBridge({ state, source, parts }) {
/** 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,
@@ -337,8 +268,6 @@ export function createViewerBridge({ state, source, parts }) {
tilt: null,
loading: false,
error: null,
highlight: null,
feature: null,
});
parts.sequences.setMarker(null);
state.notify?.();
@@ -352,7 +281,6 @@ export function createViewerBridge({ state, source, parts }) {
resize,
prewarm,
lookAtPosition,
highlightDetections,
destroy() {
close();
destroyViewer();