mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
1 parent
9ba6f96d87
commit
547b706f67
44 files changed
+143
-5318
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(''), []);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,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]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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'] },
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user