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

-5
View File
@@ -59,14 +59,9 @@ const SOURCE_METHODS = Object.freeze({
mapillary: [
'getStatus',
'getTile',
'queryFeatures',
'plan',
'geocode',
'getImage',
'getSequenceImages',
'nearestImages',
'getMapFeature',
'spriteUrl',
],
});
+1 -10
View File
@@ -102,21 +102,12 @@ export const KEY_SETUP_KEYS = Object.freeze([
id: 'mapillary',
title: 'MAPILLARY',
unlocks:
'Street-level imagery, coverage, detected objects and traffic signs. Free: register an app in the Mapillary developer dashboard and paste its Client Token',
'Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token',
getUrl: 'https://www.mapillary.com/dashboard/developers',
envVars: Object.freeze(['MAPILLARY_CLIENT_TOKEN']),
tier: 'free',
clientExposed: true,
}),
Object.freeze({
id: 'anthropic',
title: 'ANTHROPIC',
unlocks:
'Street Level plain-English questions — type a request, 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',
+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();
+10 -12
View File
@@ -63,9 +63,12 @@ test('panel styles stay inside GEV conventions: no !important, no fixed panel',
assert.deepEqual(fixed, ['.mly-viewer-wrap-expanded']);
});
test('keyless and no-planner states gate the controls rather than leave dead buttons', () => {
test('the keyless state gates the controls rather than leaving dead buttons', () => {
// The key requirement is documented (README, .env.example), not repeated in the panel.
assert.doesNotMatch(html, /mly-keyless/);
assert.doesNotMatch(
html,
/mly-keyless|mly-query|mly-results|data-mly-suggestion/,
);
assert.match(html, /<fieldset id="mly-controls"/);
assert.match(html, /<ul id="mly-legend"/);
assert.match(html, /id="mly-error"[^>]*role="alert"/);
@@ -76,18 +79,13 @@ test('the expanded viewer is a modal dialog that restores focus', () => {
assert.match(controls, /setAttribute\('role', 'dialog'\)/);
assert.match(controls, /setAttribute\('aria-modal', 'true'\)/);
assert.match(controls, /_expandReturnFocus/);
assert.match(
controls,
/if \(event\.key !== 'Escape'\) event\.stopPropagation\(\);/,
);
});
test('labels say what the buttons do', () => {
for (const label of [
'OPEN NEAREST PHOTO',
'CAMERA FOLLOWS VIEW',
'ZOOM TO RESULTS',
])
for (const label of ['OPEN NEAREST PHOTO', 'CAMERA FOLLOWS VIEW'])
assert.ok(html.includes(label), label);
assert.doesNotMatch(html, /LOOK HERE|STREET COCKPIT|>FRAME</);
assert.doesNotMatch(
html,
/LOOK HERE|STREET COCKPIT|>FRAME<|ZOOM TO RESULTS|ASK IN PLAIN ENGLISH/,
);
});
-3
View File
@@ -266,7 +266,6 @@ test('doctor describes the credential ladder without exposing values', () => {
OPENSKY_CLIENT_SECRET: { configured: false },
LL2_API_TOKEN: { configured: true, source: 'environment' },
MAPILLARY_CLIENT_TOKEN: { configured: false },
ANTHROPIC_API_KEY: { configured: false },
};
const capabilities = buildCapabilitySummary(credentials);
assert.match(
@@ -322,7 +321,6 @@ test('doctor sends Keychain-backed reports to dev-fresh and describes OpenSky as
'OPENSKY_CLIENT_SECRET',
'LL2_API_TOKEN',
'MAPILLARY_CLIENT_TOKEN',
'ANTHROPIC_API_KEY',
].map((name) => [name, { configured: false }]),
);
credentials.GOOGLE_MAPS_API_KEY = {
@@ -364,7 +362,6 @@ test('doctor never calls a dependency-missing setup ready', () => {
'OPENSKY_CLIENT_SECRET',
'LL2_API_TOKEN',
'MAPILLARY_CLIENT_TOKEN',
'ANTHROPIC_API_KEY',
].map((name) => [name, { configured: false }]),
);
const output = formatSetupReport({
+9 -262
View File
@@ -4,14 +4,6 @@ import path from 'node:path';
import test from 'node:test';
import { PbfWriter } from 'pbf';
import { mapillaryProxy } from 'gods-eye-view/server/providers/mapillary';
import {
buildPlannerUserMessage,
finalizePlan,
planQuery,
PLANNER_SYSTEM_PROMPT,
reconcilePlanValues,
} from '../../server/providers/mapillary/planner.js';
import { normalizeFeatureQuery } from '../../server/providers/mapillary/features.js';
import {
listTileLayers,
stripTileLayers,
@@ -22,7 +14,6 @@ import {
TileRequestError,
_resetTileMemoryForTest,
} from '../../server/providers/mapillary/tiles.js';
import { handleSprite } from '../../server/providers/mapillary/sprites.js';
/** Mount the plugin and return a caller keyed by route. */
function install(plugin, mode = 'configureServer') {
@@ -78,36 +69,25 @@ function install(plugin, mode = 'configureServer') {
const json = (res) => JSON.parse(String(res.body));
test('the plugin mounts the five Mapillary routes for dev and preview servers', () => {
test('the plugin mounts the status and tile routes for dev and preview servers', () => {
for (const mode of ['configureServer', 'configurePreviewServer']) {
const { routes } = install(mapillaryProxy(), mode);
assert.deepEqual([...routes.keys()].sort(), [
'/api/mapillary/features',
'/api/mapillary/plan',
'/api/mapillary/sprite',
'/api/mapillary/status',
'/api/mapillary/tiles',
]);
}
});
test('status reports capabilities, never values, and rejects non-GET', async () => {
const saved = {
MAPILLARY_CLIENT_TOKEN: process.env.MAPILLARY_CLIENT_TOKEN,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
};
test('status reports whether a token exists, never its value, and rejects non-GET', async () => {
const saved = { MAPILLARY_CLIENT_TOKEN: process.env.MAPILLARY_CLIENT_TOKEN };
delete process.env.MAPILLARY_CLIENT_TOKEN;
delete process.env.ANTHROPIC_API_KEY;
try {
const { call } = install(mapillaryProxy());
const res = await call('/api/mapillary/status');
assert.equal(res.statusCode, 200);
const payload = json(res);
assert.equal(payload.configured, false);
assert.equal(payload.planner, false);
assert.equal(payload.plannerModel, null);
assert.equal(payload.limits.featureZoom, 14);
assert.doesNotMatch(String(res.body), /MLY\||sk-ant/);
assert.deepEqual(json(res), { configured: false });
assert.doesNotMatch(String(res.body), /MLY\||planner/);
const post = await call('/api/mapillary/status', '/', 'POST');
assert.equal(post.statusCode, 405);
} finally {
@@ -124,6 +104,8 @@ test('tile route validates the path and refuses to proxy without a token', async
const { call } = install(mapillaryProxy());
const bad = await call('/api/mapillary/tiles', '/coverage/14/1/x');
assert.equal(bad.statusCode, 400);
const signs = await call('/api/mapillary/tiles', '/signs/14/1/2');
assert.equal(signs.statusCode, 400, 'only coverage tiles are proxied');
const noKey = await call('/api/mapillary/tiles', '/coverage/14/1/2');
assert.equal(noKey.statusCode, 503);
assert.deepEqual(json(noKey), { error: 'no_key', keyRequired: true });
@@ -145,13 +127,10 @@ test('normalizeTileAddress enforces layer names, zoom ranges and tile bounds', (
.dropLayers,
['image'],
);
assert.deepEqual(
normalizeTileAddress({ layer: 'points', z: 14, x: 5, y: 6 }).dropLayers,
[],
);
for (const bad of [
{ layer: 'image', z: 14, x: 1, y: 1 },
{ layer: 'points', z: 13, x: 1, y: 1 },
{ layer: 'points', z: 14, x: 1, y: 1 },
{ layer: 'signs', z: 14, x: 1, y: 1 },
{ layer: 'coverage', z: 15, x: 1, y: 1 },
{ layer: 'coverage', z: 2, x: 4, y: 0 },
{ layer: 'coverage', z: 2, x: 1.5, y: 0 },
@@ -223,238 +202,6 @@ test('fetchTile serves from memory after one upstream fetch and strips the image
}
});
test('sprite route only accepts taxonomy values, never paths', async () => {
const respond = async (url) => {
const headers = {};
const res = {
statusCode: 200,
setHeader: (k, v) => {
headers[k.toLowerCase()] = v;
},
end(body) {
this.body = body;
},
};
await handleSprite({ url }, res);
return { status: res.statusCode, headers, body: res.body };
};
for (const bad of [
'/..%2Fetc%2Fpasswd.svg',
'/../x.svg',
'/Object--Fire.svg',
'/a..b.svg',
'/a--.svg',
'/.svg',
'/x.png',
])
assert.equal((await respond(bad)).status, 400, bad);
const savedFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
assert.match(
String(url),
/package_objects\/object--fire-hydrant--zz-test\.svg$/,
);
return new Response('<svg xmlns="http://www.w3.org/2000/svg"></svg>', {
status: 200,
});
};
try {
const ok = await respond('/object--fire-hydrant--zz-test.svg');
assert.equal(ok.status, 200);
assert.match(ok.headers['content-type'], /image\/svg\+xml/);
} finally {
globalThis.fetch = savedFetch;
}
});
test('normalizeFeatureQuery caps values, normalises the bbox and rejects oversized areas', () => {
const missing = normalizeFeatureQuery({});
assert.equal(missing.ok, false);
assert.equal(missing.status, 400);
const ok = normalizeFeatureQuery({
bbox: [-121.5, 38.55, -121.45, 38.6],
layer: 'points',
values: ['object--fire-hydrant', '', 'object--bench'],
seenAfter: '2020-01-01',
});
assert.equal(ok.ok, true);
assert.deepEqual(ok.request.values.slice(0, 2), [
'object--fire-hydrant',
'object--bench',
]);
assert.equal(ok.request.since, Date.parse('2020-01-01'));
assert.ok(ok.request.tiles.length > 0);
const huge = normalizeFeatureQuery({
bbox: [-125, 32, -114, 42],
layer: 'points',
values: ['object--bench'],
});
assert.equal(huge.ok, false);
assert.equal(huge.error, 'area_too_large');
assert.ok(huge.detail.tiles > huge.detail.limit);
});
// ── Planner (relocated from src/layers/mapillary/planner.test.mjs) ──────────
const basePlan = {
intent: 'map_features',
place: 'Sacramento, California',
use_current_view: false,
values: ['object--fire-hydrant'],
seen_after: null,
seen_before: null,
prefer_pano: false,
visualise: 'icons',
title: 'Fire hydrants · Sacramento',
answer: 'Showing fire hydrants in Sacramento.',
};
test('the system prompt is frozen text carrying the taxonomy', () => {
assert.match(PLANNER_SYSTEM_PROMPT, /object--fire-hydrant/);
assert.match(PLANNER_SYSTEM_PROMPT, /regulatory--stop\b/);
assert.doesNotMatch(PLANNER_SYSTEM_PROMPT, /Today is/);
});
test('reconcilePlanValues keeps taxonomy values, expands families, drops junk', () => {
const result = reconcilePlanValues([
'object--fire-hydrant',
'regulatory--stop',
'regulatory--stop--*',
'object--unicorn',
'warning--nothing-like-this--*',
]);
assert.deepEqual(result.values, [
'object--fire-hydrant',
'regulatory--stop--*',
]);
assert.deepEqual(result.dropped, [
'object--unicorn',
'warning--nothing-like-this--*',
]);
});
test('finalizePlan derives the tile layer from the values', () => {
assert.equal(finalizePlan(basePlan).layer, 'points');
const signs = finalizePlan({
...basePlan,
intent: 'map_features',
values: ['regulatory--stop--*'],
});
assert.equal(signs.layer, 'signs');
assert.equal(signs.intent, 'traffic_signs');
});
test('finalizePlan downgrades to unsupported when nothing usable survives', () => {
const plan = finalizePlan({ ...basePlan, values: ['object--unicorn'] });
assert.equal(plan.intent, 'unsupported');
assert.match(plan.answer, /object--unicorn/);
});
test('finalizePlan keeps objects and reports signs on a mixed request', () => {
const plan = finalizePlan({
...basePlan,
values: ['object--fire-hydrant', 'regulatory--stop--*'],
});
assert.equal(plan.layer, 'points');
assert.deepEqual(plan.values, ['object--fire-hydrant']);
assert.deepEqual(plan.dropped, ['regulatory--stop--*']);
assert.match(plan.answer, /different layers/);
});
test('buildPlannerUserMessage carries the date, view and previous plan', () => {
const text = buildPlannerUserMessage({
query: 'only after 2024',
today: '2026-09-15',
context: {
view: { lat: 38.58, lon: -121.49, heightM: 1234.6, label: 'Sacramento' },
previousPlan: basePlan,
},
});
assert.match(text, /Today is 2026-09-15/);
assert.match(
text,
/38\.5800, -121\.4900, camera 1235 m above ground, near Sacramento/,
);
assert.match(text, /Previous plan: \{"intent":"map_features"/);
assert.match(text, /Request: only after 2024$/);
});
test('planQuery sends a cached system block and structured output config', async () => {
const calls = [];
const anthropic = {
messages: {
async create(params) {
calls.push(params);
return {
stop_reason: 'end_turn',
content: [
{
type: 'text',
text: JSON.stringify({
...basePlan,
values: ['object--fire-hydrant', 'bogus'],
}),
},
],
usage: { input_tokens: 10, output_tokens: 5 },
};
},
},
};
const result = await planQuery(
{ query: 'show me all fire hydrants in Sacramento', context: {} },
{
anthropic,
model: 'claude-opus-5',
now: new Date('2026-09-15T12:00:00Z'),
},
);
assert.equal(calls.length, 1);
assert.equal(calls[0].model, 'claude-opus-5');
assert.deepEqual(calls[0].system[0].cache_control, { type: 'ephemeral' });
assert.equal(calls[0].output_config.effort, 'low');
assert.equal(calls[0].output_config.format.type, 'json_schema');
assert.equal(calls[0].output_config.format.schema.type, 'object');
assert.ok(!('$schema' in calls[0].output_config.format.schema));
assert.ok(calls[0].output_config.format.schema.properties.intent);
assert.deepEqual(result.plan.values, ['object--fire-hydrant']);
assert.deepEqual(result.plan.dropped, ['bogus']);
assert.equal(result.plan.layer, 'points');
});
test('planQuery turns a refusal into an unsupported plan instead of throwing', async () => {
const anthropic = {
messages: {
async create() {
return { stop_reason: 'refusal', usage: null };
},
},
};
const result = await planQuery(
{ query: 'x', context: {} },
{ anthropic, model: 'm' },
);
assert.equal(result.plan.intent, 'unsupported');
});
test('normalizeFeatureQuery validates bbox, layer, values and the tile cap', () => {
const ok = normalizeFeatureQuery({
bbox: [-121.56, 38.44, -121.36, 38.69],
layer: 'points',
values: ['Object--Fire-Hydrant', ''],
seenAfter: '2024-01-01',
});
assert.equal(ok.ok, true);
assert.equal(ok.request.tiles.length, 160);
assert.deepEqual(ok.request.values, ['object--fire-hydrant']);
assert.equal(ok.request.since, Date.parse('2024-01-01'));
assert.equal(ok.request.until, null);
assert.equal(normalizeFeatureQuery({ bbox: 'nope' }).status, 400);
const huge = normalizeFeatureQuery({ bbox: [-125, 32, -114, 42] });
assert.equal(huge.ok, false);
assert.equal(huge.status, 413);
assert.equal(huge.error, 'area_too_large');
});
// ── Tile trimming (relocated from server/providers/mapillary/trim.test.mjs) ──
/** Build a minimal MVT: layers with a name, a version and one opaque feature. */
function tile(layers) {
-9
View File
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import {
hydrantPlan,
isCollapsed,
RAIL_ORDER,
VIEWPORTS,
@@ -15,14 +14,6 @@ test('the QA harness exercises both review viewports', () => {
);
});
test('the ready-made plan is executable without the planner', () => {
const plan = hydrantPlan();
assert.equal(plan.intent, 'map_features');
assert.equal(plan.layer, 'points');
assert.equal(plan.use_current_view, true);
assert.deepEqual(plan.values, ['object--fire-hydrant']);
});
test('rail order puts Street Level between CCTV and Context', () => {
assert.deepEqual(RAIL_ORDER, [
'pp-toggles',
+7 -133
View File
@@ -7,8 +7,8 @@ const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
/**
* Own the Street Level panel: the query box, result chips, progress, the
* imagery filters and the embedded MapillaryJS viewer. The panel itself is
* Own the Street Level panel: the imagery filters, the legend and the
* embedded MapillaryJS viewer. The panel itself is
* ordinary GEV chrome (collapse button, rail layout, persistence) driven by
* the application shell; this class only fills the body and asks the shell
* to open the panel when something worth seeing arrives.
@@ -25,7 +25,6 @@ export class MapillaryControls {
this._view = null;
this._wasEnabled = null;
this._wasStreetOpen = false;
this._lastStage = null;
this._wrapHome = null;
this._expandReturnFocus = null;
this._elements = this._collect();
@@ -39,20 +38,8 @@ export class MapillaryControls {
controls: byId('mly-controls'),
enableBtn: byId('mly-enable-btn'),
lookBtn: byId('mly-look-btn'),
form: byId('mly-query-form'),
input: byId('mly-query-input'),
runBtn: byId('mly-query-run'),
hint: byId('mly-ai-hint'),
answer: byId('mly-answer'),
error: byId('mly-error'),
errorText: byId('mly-error-text'),
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'),
clearBtn: byId('mly-clear-btn'),
sinceSelect: byId('mly-since'),
legend: byId('mly-legend'),
followBtn: byId('mly-follow-btn'),
@@ -85,28 +72,6 @@ export class MapillaryControls {
if (!(await this._ensureEnabled())) return;
this.mapillary.openNearest?.();
});
this.listen(el.form, 'submit', (event) => {
event.preventDefault();
if (this._view?.query.submitIsStop) this.mapillary.abortQuery?.();
else 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.chips, 'click', (event) => {
const more = event.target.closest?.('[data-mly-more]');
if (!more) return;
const expanded = el.chips.classList.toggle('is-expanded');
more.setAttribute('aria-expanded', String(expanded));
more.textContent = expanded
? 'FEWER'
: `+${more.dataset.mlyMore} CLASSES`;
});
for (const button of this.root.querySelectorAll('[data-mly-pano]')) {
this.listen(button, 'click', () =>
this.mapillary.setCoverageFilter?.({ pano: button.dataset.mlyPano }),
@@ -148,13 +113,6 @@ export class MapillaryControls {
}
// The expanded viewer is a dialog: Esc closes it, Tab stays inside.
this.listen(el.viewerWrap, 'keydown', (event) => this._onDialogKey(event));
// Keep the app's keyboard shortcuts from firing while typing a query,
// but let Escape through so GEV's panel disclosure can collapse the panel.
const shield = (event) => {
if (event.key !== 'Escape') event.stopPropagation();
};
this.listen(el.input, 'keydown', shield);
this.listen(el.input, 'keyup', shield);
// MapillaryJS only tracks window resizes; the panel resizes on its own.
if (typeof ResizeObserver === 'function' && el.viewer) {
let queued = false;
@@ -192,13 +150,6 @@ export class MapillaryControls {
}
}
async _run(prompt) {
const text = String(prompt || '').trim();
if (!text) return;
if (!(await this._ensureEnabled())) return;
this.mapillary.runQuery?.(text);
}
connect() {
this._unsubscribe?.();
this._unsubscribe = null;
@@ -304,8 +255,7 @@ export class MapillaryControls {
this._view = view;
this._renderHeader(view);
this._renderGate(view);
this._renderQuery(view);
this._renderResults(view);
this._renderError(view);
this._renderFilters(view);
this._renderViewer(view, state);
this._renderMeta(view);
@@ -333,82 +283,11 @@ export class MapillaryControls {
if (el.controls) el.controls.disabled = view.controlsDisabled;
}
_renderQuery(view) {
_renderError(view) {
const el = this._elements;
const { query } = view;
if (el.input) {
el.input.placeholder = query.placeholder;
el.input.disabled = query.inputDisabled;
}
if (el.runBtn) {
el.runBtn.textContent = query.submitLabel;
el.runBtn.classList.toggle('is-stop', query.submitIsStop);
el.runBtn.disabled = query.inputDisabled && !query.submitIsStop;
el.runBtn.setAttribute(
'aria-label',
query.submitIsStop ? 'Stop the query' : 'Ask',
);
}
for (const button of this.root.querySelectorAll('[data-mly-suggestion]'))
button.disabled = query.suggestionsDisabled;
if (el.hint) {
el.hint.textContent = query.hint;
el.hint.classList.toggle('is-warn', query.hintWarn);
}
if (el.answer) el.answer.textContent = view.answer;
if (el.error) {
el.error.hidden = !view.error;
if (el.errorText) el.errorText.textContent = view.error || '';
}
if (el.progress) {
el.progress.hidden = !view.progress.visible;
el.progress.classList.toggle(
'is-indeterminate',
view.progress.indeterminate,
);
if (el.progressFill && !view.progress.indeterminate)
el.progressFill.style.width = `${view.progress.percent}%`;
if (el.progressFill && view.progress.indeterminate)
el.progressFill.style.removeProperty('width');
if (el.progressLabel) el.progressLabel.textContent = view.progress.label;
}
}
_renderResults(view) {
const el = this._elements;
if (!el.results) return;
el.results.hidden = !view.results.visible;
if (!view.results.visible || !el.chips) return;
const expanded = el.chips.classList.contains('is-expanded');
const chip = (entry, overflow) => {
const node = document.createElement('span');
node.className = `mly-value-chip${overflow ? ' is-overflow' : ''}`;
node.title = entry.value;
const dot = document.createElement('i');
dot.style.background = entry.color;
const text = document.createElement('span');
text.textContent = entry.label;
const num = document.createElement('b');
num.textContent = entry.count;
node.append(dot, text, num);
return node;
};
const nodes = view.results.all.map((entry, index) =>
chip(entry, index >= view.results.chips.length),
);
if (view.results.more) {
const more = document.createElement('button');
more.type = 'button';
more.className = 'mly-value-chip';
more.dataset.mlyMore = String(view.results.more.count);
more.title = view.results.more.title;
more.setAttribute('aria-expanded', String(expanded));
more.textContent = expanded
? 'FEWER'
: `+${view.results.more.count} CLASSES`;
nodes.push(more);
} else el.chips.classList.remove('is-expanded');
el.chips.replaceChildren(...nodes);
if (!el.error) return;
el.error.hidden = !view.error;
if (el.errorText) el.errorText.textContent = view.error || '';
}
_renderFilters(view) {
@@ -471,11 +350,6 @@ export class MapillaryControls {
this._wasEnabled = enabled;
if (state.street.open && !this._wasStreetOpen) this.setCollapsed(false);
this._wasStreetOpen = state.street.open === true;
if (state.query.stage !== this._lastStage) {
this._lastStage = state.query.stage;
if (state.query.stage === 'done' || state.query.stage === 'error')
this.setCollapsed(false);
}
}
destroy() {
+7 -126
View File
@@ -3,15 +3,6 @@
* renders. Pure: no DOM, no layer calls, so every wording decision is testable.
*/
const HINT_READY =
'Objects, traffic signs, coverage or a street view of any place. Follow-ups refine the last answer.';
const HINT_NO_PLANNER =
'Plain-English questions need an Anthropic key in POWER UP. Coverage, photos and filters work without it.';
const IDLE_ANSWER_OFF =
'Turn on Street Level to see Mapillary coverage on the globe.';
const IDLE_ANSWER_ON =
'Click a green line to see its photos, or ask for objects and signs.';
function formatDate(ms) {
if (!Number.isFinite(ms)) return '';
try {
@@ -30,84 +21,12 @@ export function mapillaryImageUrl(imageId) {
function presentStatus(state) {
if (state.keyRequired) return { text: 'KEY REQUIRED', tone: 'warn' };
if (state.query.busy)
return { text: `AI · ${state.query.stage.toUpperCase()}`, tone: 'busy' };
if (state.coverage.loading || state.features.loading)
return { text: 'LOADING', tone: 'busy' };
if (state.coverage.loading) return { text: 'LOADING', tone: 'busy' };
return state.enabled ? { text: 'ON', tone: 'on' } : { text: 'OFF', tone: '' };
}
function presentProgress(state) {
const { stage, busy } = state.query;
const { progress, loading, total } = state.features;
if (loading && progress.tiles > 0) {
const pct = Math.round((progress.done / progress.tiles) * 100);
return {
visible: true,
indeterminate: false,
percent: pct,
label: `${progress.done}/${progress.tiles} tiles · ${total.toLocaleString()} found`,
};
}
if (busy && stage === 'planning')
return {
visible: true,
indeterminate: true,
percent: 0,
label: 'Planning…',
};
if (busy && stage === 'resolving')
return {
visible: true,
indeterminate: true,
percent: 0,
label: `Finding ${state.query.place || 'the area'}…`,
};
if (busy)
return {
visible: true,
indeterminate: true,
percent: 0,
label: 'Working…',
};
return { visible: false, indeterminate: false, percent: 0, label: '' };
}
function presentResults(state, { maxChips = 8 } = {}) {
const counts = state.features.counts || [];
const visible = state.features.total > 0 || state.features.hasBbox === true;
const chips = counts.map(({ value, count, label, color }) => ({
value,
label,
color,
count: count.toLocaleString(),
}));
const hidden = chips.slice(maxChips);
return {
visible,
chips: chips.slice(0, maxChips),
more: hidden.length
? {
count: hidden.length,
title: hidden.map((chip) => `${chip.label} ${chip.count}`).join(', '),
}
: null,
all: chips,
};
}
function presentViewer(state) {
const { street } = state;
const left = [];
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))
@@ -117,7 +36,7 @@ function presentViewer(state) {
open: street.open === true,
loading: street.loading === true && !street.imageId,
renderMode: street.renderMode === 'fill' ? 'fill' : 'letterbox',
captionLeft: left.join(' · '),
captionLeft: street.creator ? `Image by ${street.creator}` : '',
captionRight: right.join(' · '),
link: street.imageId ? mapillaryImageUrl(street.imageId) : null,
follow: {
@@ -133,17 +52,9 @@ function presentMeta(state) {
return state.sequence.loading
? 'Loading this sequence…'
: `${state.sequence.images.toLocaleString()} images in this sequence · Esc clears`;
const bits = [];
if (state.coverage.zoom && state.coverage.sequences > 0)
bits.push(
`${state.coverage.sequences.toLocaleString()} sequences in view · click a line for its photos`,
);
else if (state.coverage.hint) bits.push(state.coverage.hint);
if (state.query.usage?.model)
bits.push(
`${state.query.usage.model} · ${state.query.usage.input_tokens ?? '?'}→${state.query.usage.output_tokens ?? '?'} tok`,
);
return bits.join(' · ');
return `${state.coverage.sequences.toLocaleString()} sequences in view · click a line for its photos`;
return state.coverage.hint || '';
}
/**
@@ -153,18 +64,6 @@ function presentMeta(state) {
export function presentMapillaryPanel(state) {
const enabled = state.enabled === true;
const keyRequired = state.keyRequired === true;
// Unknown (status not fetched yet) reads as available: the server answers
// a missing key with its own error, and the panel must not claim one is missing.
const planner = state.planner !== false;
const busy = state.query.busy === true;
const error = state.query.error || state.street.error || null;
let answer = state.query.answer || '';
if (!answer && !error) {
if (busy) answer = '';
else if (!enabled) answer = IDLE_ANSWER_OFF;
else if (state.coverage.hint) answer = state.coverage.hint;
else answer = IDLE_ANSWER_ON;
}
return {
enabled,
keyRequired,
@@ -174,30 +73,12 @@ export function presentMapillaryPanel(state) {
text: enabled ? 'STREET LEVEL ON' : 'STREET LEVEL OFF',
pressed: enabled,
},
query: {
placeholder: planner
? 'Ask about objects, signs or a place'
: 'Anthropic key needed — see POWER UP',
inputDisabled: !planner,
suggestionsDisabled: !planner,
hint: planner ? HINT_READY : HINT_NO_PLANNER,
hintWarn: !planner,
model: state.plannerModel ? `· ${state.plannerModel}` : '',
submitLabel: busy ? 'STOP' : 'ASK',
submitIsStop: busy,
},
progress: presentProgress(state),
answer,
error,
results: presentResults(state),
error: state.street.error || state.coverage.error || null,
filter: state.coverage.filter || { pano: 'all', sinceMs: null },
legend: state.coverage.legend || [],
viewer: presentViewer(state),
meta: presentMeta(state),
/** The panel opens itself at these moments (a native panel stays put otherwise). */
wantsOpen:
state.street.open === true ||
state.query.stage === 'done' ||
state.query.stage === 'error',
/** The panel opens itself when an image opens (a native panel stays put otherwise). */
wantsOpen: state.street.open === true,
};
}
+43 -112
View File
@@ -9,8 +9,6 @@ function snapshot(overrides = {}) {
const base = {
enabled: false,
keyRequired: false,
planner: true,
plannerModel: 'claude-opus-5',
coverage: {
zoom: null,
kind: null,
@@ -27,19 +25,6 @@ function snapshot(overrides = {}) {
error: null,
},
sequence: { selectedId: null, images: 0, loading: false },
features: {
total: 0,
title: '',
loading: false,
progress: { done: 0, tiles: 0 },
counts: [],
truncated: false,
failed: 0,
iconMode: false,
hasBbox: false,
selectedId: null,
bbox: null,
},
street: {
open: false,
follow: false,
@@ -53,19 +38,6 @@ function snapshot(overrides = {}) {
sequenceId: null,
creator: null,
renderMode: 'letterbox',
feature: null,
highlight: null,
},
query: {
busy: false,
stage: 'idle',
prompt: '',
place: null,
answer: '',
error: null,
title: '',
usage: null,
history: [],
},
};
return deepMerge(base, overrides);
@@ -92,90 +64,36 @@ test('a missing Mapillary token gates every control and flags KEY REQUIRED', ()
assert.deepEqual(view.status, { text: 'KEY REQUIRED', tone: 'warn' });
});
test('without an Anthropic key the query box explains itself and stays disabled', () => {
const view = presentMapillaryPanel(
snapshot({ planner: false, enabled: true }),
);
assert.equal(view.query.inputDisabled, true);
assert.equal(view.query.suggestionsDisabled, true);
assert.equal(view.query.hintWarn, true);
assert.match(view.query.hint, /Anthropic key/);
assert.ok(view.query.placeholder.length < 40, 'placeholder fits the field');
assert.equal(view.controlsDisabled, false);
});
test('before the status call answers the query box does not claim a missing key', () => {
const view = presentMapillaryPanel(snapshot({ planner: null }));
assert.equal(view.query.inputDisabled, false);
assert.equal(view.query.hintWarn, false);
assert.doesNotMatch(view.query.placeholder, /Anthropic/);
});
test('a running query turns ASK into STOP and shows indeterminate progress per stage', () => {
const planning = presentMapillaryPanel(
snapshot({ enabled: true, query: { busy: true, stage: 'planning' } }),
);
assert.equal(planning.query.submitLabel, 'STOP');
assert.equal(planning.query.submitIsStop, true);
assert.deepEqual(planning.progress, {
visible: true,
indeterminate: true,
percent: 0,
label: 'Planning…',
test('status reads LOADING while coverage streams, then ON or OFF', () => {
assert.deepEqual(presentMapillaryPanel(snapshot()).status, {
text: 'OFF',
tone: '',
});
assert.equal(planning.status.text, 'AI · PLANNING');
const resolving = presentMapillaryPanel(
snapshot({
enabled: true,
query: { busy: true, stage: 'resolving', place: 'Sacramento, CA' },
}),
assert.deepEqual(
presentMapillaryPanel(
snapshot({ enabled: true, coverage: { loading: true } }),
).status,
{ text: 'LOADING', tone: 'busy' },
);
assert.equal(resolving.progress.label, 'Finding Sacramento, CA…');
const fetching = presentMapillaryPanel(
snapshot({
enabled: true,
query: { busy: true, stage: 'fetching' },
features: { loading: true, progress: { done: 3, tiles: 12 }, total: 410 },
}),
);
assert.equal(fetching.progress.indeterminate, false);
assert.equal(fetching.progress.percent, 25);
assert.equal(fetching.progress.label, '3/12 tiles · 410 found');
assert.deepEqual(presentMapillaryPanel(snapshot({ enabled: true })).status, {
text: 'ON',
tone: 'on',
});
});
test('idle copy depends on whether the layer is on', () => {
assert.match(
presentMapillaryPanel(snapshot()).answer,
/Turn on Street Level/,
test('errors from the viewer or the coverage web surface in one alert', () => {
assert.equal(presentMapillaryPanel(snapshot()).error, null);
assert.equal(
presentMapillaryPanel(
snapshot({ street: { error: 'Image could not be opened' } }),
).error,
'Image could not be opened',
);
assert.match(
presentMapillaryPanel(snapshot({ enabled: true })).answer,
/Click a green line/,
assert.equal(
presentMapillaryPanel(snapshot({ coverage: { error: 'Tile HTTP 502' } }))
.error,
'Tile HTTP 502',
);
const view = presentMapillaryPanel(
snapshot({ enabled: true, query: { error: 'Planner unavailable' } }),
);
assert.equal(view.error, 'Planner unavailable');
assert.equal(view.answer, '');
});
test('result chips cap at eight and describe the overflow', () => {
const counts = Array.from({ length: 11 }, (_, i) => ({
value: `object--v${i}`,
label: `Class ${i}`,
color: '#fff',
count: 100 - i,
}));
const view = presentMapillaryPanel(
snapshot({ enabled: true, features: { total: 900, counts } }),
);
assert.equal(view.results.visible, true);
assert.equal(view.results.chips.length, 8);
assert.equal(view.results.more.count, 3);
assert.match(view.results.more.title, /Class 8 92, Class 9 91, Class 10 90/);
assert.equal(view.results.all.length, 11);
});
test('legend passes through in the layer’s order', () => {
@@ -187,6 +105,7 @@ test('legend passes through in the layer’s order', () => {
});
test('the meta line never mixes the visible-sequence count with the selected sequence', () => {
assert.equal(presentMapillaryPanel(snapshot()).meta, '');
const browsing = presentMapillaryPanel(
snapshot({ enabled: true, coverage: { zoom: 14, sequences: 812 } }),
);
@@ -200,6 +119,13 @@ test('the meta line never mixes the visible-sequence count with the selected seq
);
assert.equal(selected.meta, '33 images in this sequence · Esc clears');
assert.doesNotMatch(selected.meta, /sequences in view/);
const hinted = presentMapillaryPanel(
snapshot({
enabled: true,
coverage: { hint: 'Point the camera at the globe' },
}),
);
assert.equal(hinted.meta, 'Point the camera at the globe');
});
test('viewer caption reads "Image by" left and date right, with a deep link', () => {
@@ -213,15 +139,10 @@ test('viewer caption reads "Image by" left and date right, with a deep link', ()
capturedAt: Date.UTC(2023, 9, 8),
bearing: 93.4,
isPano: true,
feature: { label: 'Fire hydrant', imageCount: 5 },
highlight: { count: 2 },
},
}),
);
assert.equal(
view.viewer.captionLeft,
'Fire hydrant · 5 sightings · 2 detections outlined · Image by mapfool',
);
assert.equal(view.viewer.captionLeft, 'Image by mapfool');
assert.equal(view.viewer.captionRight, '360° · 93° · 2023-10-08');
assert.equal(
view.viewer.link,
@@ -231,6 +152,16 @@ test('viewer caption reads "Image by" left and date right, with a deep link', ()
assert.equal(view.wantsOpen, true);
});
test('an image without a creator name leaves the left caption empty', () => {
const view = presentMapillaryPanel(
snapshot({
street: { open: true, imageId: '1', capturedAt: Date.UTC(2024, 0, 2) },
}),
);
assert.equal(view.viewer.captionLeft, '');
assert.equal(view.viewer.captionRight, '2024-01-02');
});
test('image deep links match the mapillary.com share format', () => {
assert.equal(
mapillaryImageUrl(1814275685699406),
+1 -221
View File
@@ -110,126 +110,7 @@
pointer-events: none;
}
/* ── Query ───────────────────────────────────────────────────────────────── */
.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: 9px 11px;
border-radius: var(--btn-radius);
border: 1px solid rgba(0, 212, 255, 0.45);
background: rgba(0, 212, 255, 0.06);
color: var(--text-primary);
font: inherit;
font-family: var(--font-mono);
font-size: 0.74rem;
}
#mly-query-input::placeholder {
color: rgba(232, 234, 237, 0.55);
}
#mly-query-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-dim);
}
#mly-query-input:disabled {
border-color: var(--glass-border);
background: rgba(255, 255, 255, 0.03);
}
.mly-ask {
flex: 0 0 auto;
min-width: 64px;
color: #0a0a0f;
background: var(--accent);
border-color: var(--accent);
font-weight: 600;
}
.mly-ask:hover:not(:disabled) {
color: #0a0a0f;
background: var(--accent);
box-shadow: 0 0 16px rgba(0, 212, 255, 0.5);
}
.mly-ask.is-stop {
color: #ff8a80;
background: rgba(255, 88, 88, 0.15);
border-color: #ff7272;
}
.mly-ask.is-stop:hover:not(:disabled) {
color: #ff8a80;
background: rgba(255, 88, 88, 0.25);
border-color: #ff7272;
}
.mly-hint {
margin: 0;
font-family: var(--font-sans);
font-size: 0.68rem;
line-height: 1.35;
color: var(--text-secondary);
}
.mly-hint.is-warn {
color: #ffb300;
}
.mly-suggestions {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.mly-suggestion {
font-family: var(--font-mono);
font-size: var(--mly-text-small);
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:not(:disabled) {
color: var(--text-primary);
border-color: var(--glass-border-hover);
}
.mly-suggestion:disabled {
opacity: 0.5;
cursor: default;
}
/* ── Answer, error, progress ─────────────────────────────────────────────── */
.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:empty {
display: none;
}
/* ── Errors ──────────────────────────────────────────────────────────────── */
.mly-error {
font-family: var(--font-sans);
font-size: 0.72rem;
@@ -237,102 +118,6 @@
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.is-indeterminate .mly-progress-fill {
width: 40%;
animation: mly-indeterminate 1.4s ease-in-out infinite;
}
@keyframes mly-indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(250%);
}
}
.mly-progress-label {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-size: var(--mly-text-small);
letter-spacing: 0.1em;
color: var(--text-primary);
text-shadow: 0 0 4px rgba(0, 0, 0, 0.8);
}
/* ── Results ─────────────────────────────────────────────────────────────── */
.mly-results {
display: flex;
flex-direction: column;
gap: 8px;
}
.mly-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.mly-chips.is-expanded .mly-value-chip.is-overflow {
display: inline-flex;
}
.mly-value-chip {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: var(--mly-text-small);
padding: 3px 8px;
border-radius: 999px;
border: 1px solid var(--glass-border);
background: transparent;
color: var(--text-primary);
}
.mly-value-chip.is-overflow {
display: none;
}
.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;
}
button.mly-value-chip {
cursor: pointer;
}
button.mly-value-chip:hover {
border-color: var(--glass-border-hover);
}
/* ── Filters and legend ──────────────────────────────────────────────────── */
.mly-filters {
display: flex;
@@ -648,11 +433,6 @@ button.mly-value-chip:hover {
gap: 6px;
}
#mapillary-panel .mly-hint,
#mapillary-panel .mly-suggestions {
display: none;
}
.mly-viewer {
min-height: 150px;
}
-26
View File
@@ -92,33 +92,7 @@
<button id="mly-enable-btn" class="scene-btn" type="button" aria-pressed="false" title="Turn the Street Level layer on or off">STREET LEVEL OFF</button>
<button id="mly-look-btn" class="scene-btn" type="button" title="Open the nearest Mapillary photo to the view centre">OPEN NEAREST PHOTO</button>
</div>
<form id="mly-query-form" class="mly-query" autocomplete="off">
<label for="mly-query-input" class="cctv-summary-label">ASK IN PLAIN ENGLISH</label>
<div class="mly-query-row">
<input id="mly-query-input" type="text" maxlength="600" spellcheck="false" placeholder="Ask about objects, signs or a place" aria-label="Ask about objects, signs or a place" />
<button id="mly-query-run" class="scene-btn mly-ask" type="submit">ASK</button>
</div>
<p id="mly-ai-hint" class="mly-hint">Objects, traffic signs, coverage or a street view of any place. Follow-ups refine the last answer.</p>
<div class="mly-suggestions" role="group" aria-label="Example queries">
<button type="button" class="mly-suggestion" data-mly-suggestion="show me all fire hydrants in Sacramento">Hydrants · Sacramento</button>
<button type="button" class="mly-suggestion" data-mly-suggestion="stop signs in downtown Detroit">Stop signs · Detroit</button>
<button type="button" class="mly-suggestion" data-mly-suggestion="cctv cameras in London">CCTV cameras · London</button>
<button type="button" class="mly-suggestion" data-mly-suggestion="show me a 360 street view here">360° view · here</button>
</div>
</form>
<div id="mly-answer" class="mly-answer" role="status" aria-live="polite"></div>
<div id="mly-error" class="mly-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="mly-error-text"></span></div>
<div id="mly-progress" class="mly-progress" hidden>
<div id="mly-progress-fill" class="mly-progress-fill"></div>
<span id="mly-progress-label" class="mly-progress-label"></span>
</div>
<div id="mly-results" class="mly-results" hidden>
<div id="mly-result-chips" class="mly-chips"></div>
<div class="cctv-controls">
<button id="mly-frame-btn" class="scene-btn" type="button" title="Fly the camera to the result area">ZOOM TO RESULTS</button>
<button id="mly-clear-btn" class="scene-btn" type="button" title="Remove the results from the globe">CLEAR</button>
</div>
</div>
<div class="mly-filters" role="group" aria-label="Imagery filters">
<span class="cctv-summary-label">IMAGERY</span>
<div class="mly-seg" role="radiogroup" aria-label="Panorama filter">