fix(street-level): sequence loads, playback and nearest-photo edge cases

From gekh's review of #768, each bug with a test that fails before:

- P2: a failed sequence load clears the previous sequence's cones, resets
  the selection and can be retried; a second click while loading starts no
  second lookup. sequences.js had no tests: sequences.test.mjs now covers
  failure and retry, a superseded late answer, the cache and clearSelection.
- P2: closing a photo stops MapillaryJS sequence playback, which kept the
  hidden viewer stepping and downloading images.
- openNearest without the panel reports it instead of loading forever, and
  stops if the layer (or its provider) went off during the lookup; the
  mesh sampler's misses are bounded and forgotten with its samples; the
  sequence count is the union of ids across tiles; a provider withdraws
  only its own error, so a sequence error cannot mask KEY REJECTED; hover
  stays on (throttled) under an orbiting camera and re-picks when it
  stops; "since N days" follows the clock in a long-open tab.
- A prewarm waits for one in flight and then builds if it gave up.
- New coverage for the layer core (credits, KEY REJECTED vs REQUIRED,
  switching a provider off), the refresh wiring of the tilted-view fix and
  the draped-to-cast swap completion.
- Cleanup: loading is derived from pending; freshStreet(); tile ranking
  computes distances once and shares one path for the date line; shared
  metresBetween / whenIdle / redrawTile; one NEAREST_RADIUS_M and one
  source method list; unused contract fields, exports, getImage and the
  decoder's image branch removed; PROVIDER_COLORS lists only Mapillary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-05 18:35:44 +01:00
1 parent 48276450ec
commit 5f98b871e8
31 files changed
+1129 -470

No files matched your search

@@ -1,7 +1,13 @@
import * as Cesium from 'cesium';
import { decodeCoverageTile } from './decode.js';
import { passesImageryFilter } from '../../filter.js';
import { groundUnderCamera, viewFocus, visibleBbox } from '../../view.js';
import {
groundUnderCamera,
metresBetween,
viewFocus,
visibleBbox,
whenIdle,
} from '../../view.js';
import { densifyLine, MESH_DENSIFY_DEG } from '../../groundCast.js';
import { MESH_CELL_DEG } from '../../meshSampler.js';
import {
@@ -27,7 +33,7 @@ import {
} from './policy.js';
/** Draped lines stay at most this long while a tile's cast lines build. */
const SWAP_MAX_WAIT_MS = 4000;
export const SWAP_MAX_WAIT_MS = 4000;
/** Tiles touched by new mesh samples are redrawn at most this often. */
const REMESH_INTERVAL_MS = 1500;
/** Gap between redrawing one dirty tile and the next. */
@@ -104,8 +110,9 @@ export function createCoverage({ state, source }) {
return `${z}/${x}/${y}`;
}
/** The imagery filter, resolved now: "since N days" moves with the clock. */
function filter() {
return state.filter;
return state.context.getFilter();
}
function terrainMode() {
@@ -122,9 +129,10 @@ export function createCoverage({ state, source }) {
*/
function drawnSequences(entry) {
const drawn = [];
const current = filter();
for (const sequence of entry.sequenceList) {
if (drawn.length >= PER_TILE_SEQUENCE_CAP) break;
if (passesImageryFilter(sequence, filter())) drawn.push(sequence);
if (passesImageryFilter(sequence, current)) drawn.push(sequence);
}
return drawn;
}
@@ -232,15 +240,22 @@ export function createCoverage({ state, source }) {
}
// A remesh may have cleared the flag while this was in flight.
entry.castRequested = true;
// Keep the draped lines until the cast ones are built, so nothing blinks.
const previous = entry.primitives;
entry.primitives = [];
attachPrimitive(entry);
removeWhenReady(entry, previous);
redrawTile(entry);
requestMesh(entry);
});
}
/**
* Rebuild a tile's lines (cast, or with new mesh heights), keeping the old
* ones on screen until the new ones are built, so nothing blinks.
*/
function redrawTile(entry) {
const previous = entry.primitives;
entry.primitives = [];
attachPrimitive(entry);
removeWhenReady(entry, previous);
}
/** The mesh cells under a tile's drawn lines, built once per filter. */
function meshCells(entry) {
if (entry.meshCells) return entry.meshCells;
@@ -302,25 +317,16 @@ export function createCoverage({ state, source }) {
if (!entry) return;
state.coverage.remeshDirty.delete(entry);
if (attached.has(entry)) {
const previous = entry.primitives;
entry.primitives = [];
attachPrimitive(entry);
removeWhenReady(entry, previous);
redrawTile(entry);
requestRender();
}
if (state.coverage.remeshDirty.size)
state.coverage.remeshTimer = setTimeout(
() => idleTask(remesh),
() => whenIdle(remesh, 500),
REMESH_STAGGER_MS,
);
}
function idleTask(task) {
if (typeof globalThis.requestIdleCallback === 'function')
globalThis.requestIdleCallback(() => task(), { timeout: 500 });
else task();
}
state.coverage.remeshDirty = new Set();
state.context.meshSampler?.onSampled(onMeshSampled);
@@ -367,8 +373,9 @@ export function createCoverage({ state, source }) {
0.85,
);
const drawn = [];
const current = filter();
for (const point of points) {
if (!passesImageryFilter(point, filter())) continue;
if (!passesImageryFilter(point, current)) continue;
drawn.push(
collection.add({
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat),
@@ -513,7 +520,6 @@ export function createCoverage({ state, source }) {
return;
const controller = new AbortController();
state.coverage.pending.set(key, controller);
state.coverage.loading++;
notify();
try {
// Fetch and the one-time terrain-height table load run side by side.
@@ -578,10 +584,9 @@ export function createCoverage({ state, source }) {
if (error?.retryAfterSec) holdFor(error.retryAfterSec);
} finally {
// Only the request that still owns the key settles it: a superseded one
// must not drop a newer request's entry or its loading count.
// must not drop a newer request's entry (which is what counts as loading).
if (state.coverage.pending.get(key) === controller) {
state.coverage.pending.delete(key);
state.coverage.loading = Math.max(0, state.coverage.loading - 1);
if (!state.coverage.pending.size) purgeStale();
}
notify();
@@ -632,7 +637,6 @@ export function createCoverage({ state, source }) {
for (const controller of state.coverage.pending.values())
controller.abort();
state.coverage.pending.clear();
state.coverage.loading = 0;
for (const [key, entry] of state.coverage.tiles) {
cancelCast(entry);
const previous = state.coverage.stale.get(key);
@@ -654,10 +658,7 @@ export function createCoverage({ state, source }) {
*/
function isTilted({ nadir, ahead }, height) {
if (!ahead) return true;
const dLat = (ahead.lat - nadir.lat) * 111_320;
const dLon =
(ahead.lon - nadir.lon) * 111_320 * Math.cos((nadir.lat * Math.PI) / 180);
return Math.hypot(dLat, dLon) > Math.max(1, height);
return metresBetween(nadir, ahead) > Math.max(1, height);
}
/** Recompute the tile set for the current camera and reconcile primitives. */
@@ -723,10 +724,9 @@ export function createCoverage({ state, source }) {
for (const [key, controller] of [...state.coverage.pending])
if (!wanted.has(key)) {
// The aborted request no longer owns its key, so its `finally` will
// not settle the count: settle it here.
// not settle it: settle it here.
controller.abort();
state.coverage.pending.delete(key);
state.coverage.loading = Math.max(0, state.coverage.loading - 1);
}
for (const tile of tiles) loadTile(tile, kind);
if (!state.coverage.pending.size) purgeStale();
@@ -769,7 +769,6 @@ export function createCoverage({ state, source }) {
for (const controller of state.coverage.pending.values())
controller.abort();
state.coverage.pending.clear();
state.coverage.loading = 0;
for (const key of [...state.coverage.tiles.keys()]) removeTile(key);
purgeStale();
state.coverage.zoom = null;
@@ -807,15 +806,6 @@ export function createCoverage({ state, source }) {
refresh();
}
/** Look a sequence up across loaded tiles. */
function findSequence(id) {
for (const entry of state.coverage.tiles.values()) {
const hit = entry.sequences.get(id);
if (hit) return hit;
}
return null;
}
/** Recolour every part of a sequence in one ready primitive. */
function recolorInstances(primitive, sequence, selected) {
const value = sequenceColor({ selected });
@@ -893,10 +883,18 @@ export function createCoverage({ state, source }) {
state.coverage.stopSelectionWatch = null;
}
/**
* Sequences (or overview points) drawn. A sequence that crosses a tile
* edge is in both tiles' lists, so sequences count once by id.
*/
function sequenceCount() {
let count = 0;
for (const entry of state.coverage.tiles.values()) count += entry.count;
return count;
const ids = new Set();
let points = 0;
for (const entry of state.coverage.tiles.values())
if (entry.kind === 'sequence')
for (const id of entry.sequences.keys()) ids.add(id);
else points += entry.count;
return ids.size + points;
}
return {
@@ -907,7 +905,6 @@ export function createCoverage({ state, source }) {
resetErrors,
rebuild,
setSurface,
findSequence,
recolorSequence,
sequenceCount,
};
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createCoverage } from './coverage.js';
import { SWAP_MAX_WAIT_MS, createCoverage } from './coverage.js';
import { encodeCoverageTile } from './coverageFixture.mjs';
import {
COLORS,
@@ -114,9 +114,10 @@ function setup({
const state = {
viewer,
services: {},
filter: { pano: 'all', sinceMs: null },
keyRequired: false,
context: {
filter: { pano: 'all', sinceMs: null },
getFilter: () => state.context.filter,
isActive: () => true,
notify() {},
getSurface: () => surface,
@@ -130,7 +131,6 @@ function setup({
stale: new Map(),
staleTimer: null,
pending: new Map(),
loading: 0,
lastError: null,
debounceTimer: null,
removeCameraListener: null,
@@ -188,7 +188,7 @@ test('a superseded tile request cannot strand lines on the globe (review P0 #2)'
true,
'#2 still owns the key',
);
assert.equal(state.coverage.loading, 1);
assert.equal(state.coverage.pending.size, 1, 'still loading');
coverage.refresh();
assert.equal(forTile().length, 2, 'no duplicate request');
@@ -265,26 +265,24 @@ test('a tile whose lines cannot be cast is not rebuilt for nothing', async () =>
coverage.clear();
});
test('panning off a tile that is still loading settles the loading count', async () => {
test('panning off a tile that is still loading stops counting it as loading', async () => {
const { viewer, source, state, coverage } = setup();
coverage.refresh();
assert.equal(state.coverage.loading, 1);
assert.equal(state.coverage.pending.size, 1);
// Same zoom, two tiles east: the first request is dropped, a new one starts.
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 2);
assert.equal(source.calls[0].signal.aborted, true);
assert.equal(state.coverage.pending.size, 1);
assert.equal(
state.coverage.loading,
state.coverage.pending.size,
1,
'only the live request counts as loading',
);
source.calls[0].reject(new DOMException('aborted', 'AbortError'));
source.calls[1].resolve(new Uint8Array());
await settle();
assert.equal(state.coverage.pending.size, 0);
assert.equal(state.coverage.loading, 0, 'LOADING clears');
assert.equal(state.coverage.pending.size, 0, 'LOADING clears');
coverage.clear();
});
@@ -337,7 +335,7 @@ test('a rate limit keeps the drawn tiles and asks again once the wait is over (r
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
assert.match(state.coverage.lastError, /rate-limiting/);
assert.match(state.coverage.lastError, /rate-limited/);
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 1, 'held: nothing is requested');
@@ -494,7 +492,7 @@ test('the per-tile cap applies after the imagery filter, so a dense tile keeps i
capturedAt: 1_000_000_000_000 + i,
isPano: true,
});
state.filter = { pano: 'pano', sinceMs: null };
state.context.filter = { pano: 'pano', sinceMs: null };
coverage.refresh();
source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences }));
await settle();
@@ -502,13 +500,14 @@ test('the per-tile cap applies after the imagery filter, so a dense tile keeps i
const cap = Math.floor(COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES);
assert.equal(entry.count, Math.min(300, cap), 'every 360° line is drawn');
assert.equal(coverage.sequenceCount(), Math.min(300, cap));
assert.ok(coverage.findSequence('pano-0'), 'drawn lines can be looked up');
assert.equal(coverage.findSequence('flat-699'), null);
state.filter = { pano: 'all', sinceMs: null };
// Picking and recolouring look sequences up among the drawn lines.
assert.ok(entry.sequences.has('pano-0'), 'drawn lines can be looked up');
assert.equal(entry.sequences.has('flat-699'), false);
state.context.filter = { pano: 'all', sinceMs: null };
coverage.rebuild();
assert.equal(entry.count, cap, 'all imagery is capped as before');
assert.ok(coverage.findSequence('flat-699'), 'the newest flat line is drawn');
assert.equal(coverage.findSequence('pano-299'), null, 'over the cap');
assert.ok(entry.sequences.has('flat-699'), 'the newest flat line is drawn');
assert.equal(entry.sequences.has('pano-299'), false, 'over the cap');
coverage.clear();
});
@@ -558,3 +557,199 @@ test('a selection made or cleared while a tile builds is applied once it is read
coverage.clear();
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
});
test('a sequence that crosses a tile edge is counted once (review P3)', async () => {
const { viewer, source, coverage, centre } = setup();
// Look straight down on the edge between this tile and its east neighbour.
const { east } = tileBounds(centre.tile.x, centre.tile.y, 14);
viewer.view.lon = east;
coverage.refresh();
assert.equal(source.calls.length, 2, 'two tiles in view');
const across = [
[
[east - 0.001, centre.lat],
[east + 0.001, centre.lat],
],
];
for (const call of source.calls) {
const [, x, y] = call.key.split('/').map(Number);
call.resolve(
encodeCoverageTile(
{ x, y, z: 14 },
{
sequences: [
{ id: 'across', parts: across },
{ id: `only-${x}`, parts: across },
],
},
),
);
}
await settle();
assert.equal(coverage.sequenceCount(), 3, 'across, plus one per tile');
coverage.clear();
});
/**
* In terrain mode with a cast tile mid-swap: the draped lines and the cast
* primitive whose readiness the test controls.
*/
async function midSwap() {
// Drawn draped first; castLine only answers once the heights are in.
let heightsReady = false;
const groundCaster = {
prepareLines: async () => (heightsReady = true),
castLine: (coords) =>
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
};
const context = setup({ surface: 'terrain', groundCaster });
context.coverage.refresh();
context.source.calls[0].resolve(context.bytes);
await settle();
await settle();
const { groundPrimitives, primitives } = context.viewer.scene;
assert.equal(groundPrimitives.items.size, 1, 'draped lines kept');
assert.equal(primitives.items.size, 1, 'cast lines building');
const [cast] = primitives.items;
const control = { ready: false };
Object.defineProperty(cast, 'ready', { get: () => control.ready });
return { ...context, control };
}
test('the draped lines go once the cast lines are ready', async () => {
const { viewer, coverage, control } = await midSwap();
frame(viewer.postRender);
assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'still building');
control.ready = true;
frame(viewer.postRender);
assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'draped removed');
assert.equal(viewer.scene.primitives.items.size, 1, 'cast lines stay');
assert.equal(viewer.postRender.size, 0, 'nothing left watching');
coverage.clear();
});
test('the draped lines go after the wait even if the cast lines never get ready', async (t) => {
t.mock.timers.enable({ apis: ['Date'] });
const { viewer, coverage } = await midSwap();
t.mock.timers.tick(SWAP_MAX_WAIT_MS - 1);
frame(viewer.postRender);
assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'still waiting');
t.mock.timers.tick(2);
frame(viewer.postRender);
assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'draped removed');
assert.equal(viewer.scene.primitives.items.size, 1);
coverage.clear();
});
/**
* A camera `height` m above WGS84 looking along `heading` with `pitch` (deg),
* a 60°×40° view on a 100×100 canvas, whose screen rays really meet the
* ellipsoid they are given (as Cesium's `pickEllipsoid` does).
*/
function tiltedCamera({ lon, lat, height, heading, pitch }) {
const position = Cesium.Cartesian3.fromDegrees(lon, lat, height);
const frameAt = Cesium.Transforms.eastNorthUpToFixedFrame(position);
function ray(point) {
const h = (heading + (point.x / 100 - 0.5) * 60) * RAD;
const p = (pitch + (0.5 - point.y / 100) * 40) * RAD;
const local = new Cesium.Cartesian3(
Math.sin(h) * Math.cos(p),
Math.cos(h) * Math.cos(p),
Math.sin(p),
);
const direction = Cesium.Matrix4.multiplyByPointAsVector(
frameAt,
local,
new Cesium.Cartesian3(),
);
return new Cesium.Ray(
position,
Cesium.Cartesian3.normalize(direction, direction),
);
}
return {
positionWC: position,
positionCartographic: {
longitude: lon * RAD,
latitude: lat * RAD,
height,
},
pickEllipsoid(point, ellipsoid) {
const r = ray(point);
const hit = Cesium.IntersectionTests.rayEllipsoid(r, ellipsoid);
return hit ? Cesium.Ray.getPoint(r, hit.start) : undefined;
},
computeViewRectangle: () => undefined,
};
}
/**
* Denver, 300 m above a street 1,600 m above the ellipsoid, at the centre of
* a z14 tile and looking east with the screen centre `aheadM` metres out.
* Google 3D hides the globe, so only the bare-earth caster knows the ground.
*/
function denverStreetView(aheadM) {
const z = 14;
const x = lonToTileX(-104.99, z);
const y = latToTileY(39.74, z);
const tile = tileBounds(x, y, z);
const lon = (tile.west + tile.east) / 2;
const lat = (tile.south + tile.north) / 2;
const ground = 1600;
const above = 300;
const context = setup({ groundCaster: { groundAt: () => ground } });
const tileWidthM = (tile.east - tile.west) * 111_320 * Math.cos(lat * RAD);
context.viewer.camera = tiltedCamera({
lon,
lat,
height: ground + above,
heading: 90,
pitch: -Math.atan(above / aheadM(tileWidthM)) / RAD,
});
context.coverage.refresh();
const keys = context.source.calls.map((call) => call.key);
/** Requested tiles as [dx, dy] from the tile under the camera. */
const offsets = keys.map((k) => {
const [, tx, ty] = k.split('/').map(Number);
return [tx - x, ty - y];
});
return {
...context,
keys,
offsets,
key: (dx, dy) => `${z}/${x + dx}/${y + dy}`,
};
}
test('a tilted street view over ground 1,600 m up asks for the tiles it looks at first', () => {
// The screen centre meets the ground at the centre of the next tile east.
const { state, coverage, keys, offsets, key } = denverStreetView(
(tileWidthM) => tileWidthM,
);
assert.equal(state.coverage.zoom, 14, 'height measured from the ground');
assert.deepEqual(
keys.slice(0, 2),
[key(0, 0), key(1, 0)],
'the tile under the camera, then the one the screen centre looks at',
);
assert.ok(keys.includes(key(-1, 0)), 'the ground behind the camera too');
for (const [dx, dy] of offsets)
assert.ok(dx >= -1 && dx <= 2 && Math.abs(dy) <= 1, `${dx},${dy} in reach`);
coverage.clear();
});
test('a street view toward the horizon ranks only the ground within range', () => {
// The screen centre meets the ground 6 km out, past the 3 km range: the
// tiles are ranked around the camera, not along a line to the horizon.
const { coverage, keys, offsets, key } = denverStreetView(() => 6000);
assert.equal(keys[0], key(0, 0), 'the tile under the camera first');
// Behind and ahead are equally near the camera (ties keep row order); a
// line of sight to the far ground would rank the tile ahead first.
assert.ok(
keys.indexOf(key(-1, 0)) < keys.indexOf(key(1, 0)),
'the tile ahead is not pulled forward',
);
for (const [dx, dy] of offsets)
assert.ok(Math.abs(dx) <= 1 && Math.abs(dy) <= 1, `${dx},${dy} around it`);
coverage.clear();
});
@@ -4,15 +4,15 @@ import { tileLocalToLonLat } from '../../tileMath.js';
/**
* Decode a Mapillary coverage tile (`mly1_public`) in the browser.
* Sequences are LineStrings with capture metadata; the `image` layer is only
* present at z14 and is skipped unless asked for (it can hold >150k points).
* Sequences are LineStrings with capture metadata; z0–5 tiles carry
* `overview` points instead. The z14 `image` layer (it can hold >150k
* points) is never read.
* @param {Uint8Array} bytes
* @param {{x:number,y:number,z:number}} address
* @param {{images?: boolean}} [options]
* @returns {{sequences: Array<object>, images: Array<object>}}
* @returns {{sequences: Array<object>, overview: Array<object>}}
*/
export function decodeCoverageTile(bytes, address, { images = false } = {}) {
const result = { sequences: [], images: [], overview: [] };
export function decodeCoverageTile(bytes, address) {
const result = { sequences: [], overview: [] };
if (!bytes || !bytes.length) return result;
const tile = new VectorTile(new PbfReader(bytes));
const { x, y, z } = address;
@@ -70,31 +70,5 @@ export function decodeCoverageTile(bytes, address, { images = false } = {}) {
});
}
}
const imageLayer = images ? tile.layers.image : null;
if (imageLayer) {
for (let i = 0; i < imageLayer.length; i++) {
const feature = imageLayer.feature(i);
const props = feature.properties || {};
const point = feature.loadGeometry()?.[0]?.[0];
if (!point) continue;
const [lon, lat] = tileLocalToLonLat(
point.x,
point.y,
imageLayer.extent,
x,
y,
z,
);
result.images.push({
id: String(props.id ?? feature.id),
lon,
lat,
compassAngle: Number(props.compass_angle) || 0,
capturedAt: Number(props.captured_at) || 0,
isPano: props.is_pano === true,
sequenceId: props.sequence_id ? String(props.sequence_id) : null,
});
}
}
return result;
}
@@ -113,7 +113,6 @@ test('sequences and overview points decode into lon/lat records', () => {
assert.equal(decoded.overview[0].id, '3');
assert.equal(decoded.overview[0].isPano, false);
assert.ok(Math.abs(decoded.overview[0].lon - -90) < 1e-6, 'tile centre');
assert.equal(decoded.images.length, 0, 'image layer skipped by default');
});
test('an empty tile decodes to empty lists', () => {
@@ -121,7 +120,6 @@ test('an empty tile decodes to empty lists', () => {
decodeCoverageTile(new Uint8Array(0), { x: 0, y: 0, z: 0 }),
{
sequences: [],
images: [],
overview: [],
},
);
@@ -2,6 +2,7 @@ import { createCoverage, sequenceIdFromPick } from './coverage.js';
import { createSequences } from './sequences.js';
import { createMapillaryViewer } from './viewer.js';
import { passesImageryFilter } from '../../filter.js';
import { metresBetween } from '../../view.js';
import {
COLORS,
MAPILLARY_CREDIT_HTML,
@@ -9,22 +10,14 @@ import {
MAPILLARY_LABEL,
MAPILLARY_NAME,
MAPILLARY_PROVIDER_ID,
MAPILLARY_SOURCE_METHODS,
NEAREST_LIMIT,
NEAREST_RADIUS_M,
PICK_PREFIX,
mapillaryImageUrl,
} from './policy.js';
export { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js';
const SOURCE_METHODS = [
'getStatus',
'getTile',
'getImage',
'getSequenceImages',
'nearestImages',
];
/** Per-provider mutable state, created once per `create()`. */
function createProviderState(context) {
return {
@@ -35,7 +28,6 @@ function createProviderState(context) {
/** Mapillary refused the configured token: nothing is requested until re-enabled. */
keyRejected: false,
status: null,
filter: context.getFilter(),
coverage: {
zoom: null,
/** @type {Map<string, {primitive: object|null, sequences: Map<string, object>, count: number}>} */
@@ -43,8 +35,8 @@ function createProviderState(context) {
/** Tiles from the previous zoom, kept on screen until replacements land. */
stale: new Map(),
staleTimer: null,
/** Tile key → its request's controller; any entry means LOADING. */
pending: new Map(),
loading: 0,
lastError: null,
/** While Mapillary rate-limits: no requests before this time (ms). */
holdUntil: 0,
@@ -74,7 +66,11 @@ function createProviderState(context) {
* @returns {import('../../registry.js').StreetLevelProvider}
*/
export function createMapillaryProvider({ source }) {
if (!SOURCE_METHODS.every((method) => typeof source?.[method] === 'function'))
if (
!MAPILLARY_SOURCE_METHODS.every(
(method) => typeof source?.[method] === 'function',
)
)
throw new TypeError('A Mapillary source is required');
return Object.freeze({
@@ -84,19 +80,7 @@ export function createMapillaryProvider({ source }) {
requiresKeyId: MAPILLARY_KEY_ID,
pickPrefix: PICK_PREFIX.root,
colors: COLORS,
credit: Object.freeze({
key: MAPILLARY_PROVIDER_ID,
html: MAPILLARY_CREDIT_HTML,
}),
capabilities: Object.freeze({
coverage: 'tiles',
sequences: true,
pano: true,
capturedAt: true,
creator: true,
follow: true,
}),
externalUrl: mapillaryImageUrl,
credit: Object.freeze({ html: MAPILLARY_CREDIT_HTML }),
create(context) {
const state = createProviderState(context);
@@ -150,8 +134,8 @@ export function createMapillaryProvider({ source }) {
refreshCoverage: () => parts.coverage.refresh(),
setFilter(filter) {
state.filter = filter;
/** The filter changed: redraw. Each draw reads it from the context. */
setFilter() {
parts.coverage.rebuild();
parts.sequences.rerender();
},
@@ -167,7 +151,7 @@ export function createMapillaryProvider({ source }) {
count: parts.coverage.sequenceCount(),
zoom: state.coverage.zoom,
kind: state.coverage.kind,
loading: state.coverage.loading > 0,
loading: state.coverage.pending.size > 0,
hint: state.coverage.hint,
error: state.coverage.lastError,
keyRequired: state.keyRequired,
@@ -208,19 +192,18 @@ export function createMapillaryProvider({ source }) {
const metres = (record) => {
const [lon2, lat2] = (record.computed_geometry || record.geometry)
?.coordinates || [NaN, NaN];
const dx = (lon2 - lon) * 111_320 * Math.cos((lat * Math.PI) / 180);
const dy = (lat2 - lat) * 110_540;
const d = Math.hypot(dx, dy);
const d = metresBetween({ lon, lat }, { lon: lon2, lat: lat2 });
return Number.isFinite(d) ? d : Infinity;
};
const byDistance = [...images].sort((a, b) => metres(a) - metres(b));
const filter = context.getFilter();
const hit = byDistance.find((record) =>
passesImageryFilter(
{
isPano: record.is_pano === true,
capturedAt: Number(record.captured_at) || 0,
},
state.filter,
filter,
),
);
return hit ? String(hit.id) : null;
@@ -1,5 +1,7 @@
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
export { NEAREST_RADIUS_M } from '../../policy.js';
/** Identity and tuning for the Mapillary street-level provider. */
export const MAPILLARY_PROVIDER_ID = 'mapillary';
export const MAPILLARY_NAME = 'Mapillary';
@@ -7,6 +9,14 @@ export const MAPILLARY_LABEL = 'MAPILLARY';
export const MAPILLARY_KEY_ID = 'mapillary';
export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com';
/** Methods a source (./source.js or a stand-in) must have for the provider. */
export const MAPILLARY_SOURCE_METHODS = Object.freeze([
'getStatus',
'getTile',
'getSequenceImages',
'nearestImages',
]);
/** Stable id prefixes for picked primitives; every one starts with `mly:`. */
export const PICK_PREFIX = Object.freeze({
root: 'mly:',
@@ -33,7 +43,7 @@ export const KEY_REJECTED_MESSAGE =
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
/** Shown while Mapillary rate-limits tile requests (429). */
export const RATE_LIMITED_MESSAGE =
'Mapillary is rate-limiting coverage requests — it resumes on its own';
'Coverage requests are being rate-limited — they resume on their own';
/**
* How far from the camera a street-zoom view may reach for coverage: a tilted
* view sees to the horizon, but only the ground within this range is ranked
@@ -52,8 +62,7 @@ export const SEQUENCE_IMAGES_LIMIT = 2000;
export const IMAGE_CONE_SIZE_PX = 26;
export const IMAGE_CONE_MIN_SPACING_M = 3;
/** Nearest-image search when the user asks to look at a place. */
export const NEAREST_RADIUS_M = 50;
/** Images asked for in a nearest-image search (radius: NEAREST_RADIUS_M). */
export const NEAREST_LIMIT = 8;
/**
@@ -4,13 +4,13 @@ import { createMapillaryProvider, mapillaryImageUrl } from './index.js';
import { sequenceIdFromPick } from './coverage.js';
import { validateProviders } from '../../registry.js';
import { PROVIDER_COLORS } from '../../policy.js';
import { resolveFilter } from '../../filter.js';
function fakeSource({ nearest = [] } = {}) {
return {
hasToken: () => true,
getStatus: async () => ({ configured: true }),
getTile: async () => new Uint8Array(0),
getImage: async () => ({}),
getSequenceImages: async () => [],
nearestImages: async () => nearest,
};
@@ -21,8 +21,7 @@ function fakeContext(filter = { pano: 'all', sinceMs: null }) {
return {
opened,
services: {},
getViewer: () => null,
getFilter: () => filter,
getFilter: typeof filter === 'function' ? filter : () => filter,
isActive: () => true,
notify: () => {},
actions: {
@@ -41,7 +40,6 @@ test('the definition satisfies the Street Level provider contract', () => {
assert.equal(def.label, 'MAPILLARY');
assert.equal(def.requiresKeyId, 'mapillary');
assert.equal(def.pickPrefix, 'mly:');
assert.equal(def.capabilities.coverage, 'tiles');
assert.match(def.credit.html, /CC BY-SA 4\.0/);
// One colour per source: Mapillary is green everywhere it draws.
assert.deepEqual(Object.keys(def.colors).sort(), ['coverage', 'selected']);
@@ -51,9 +49,8 @@ test('the definition satisfies the Street Level provider contract', () => {
});
test('image deep links match the mapillary.com share format', () => {
const def = createMapillaryProvider({ source: fakeSource() });
assert.equal(
def.externalUrl(1814275685699406),
mapillaryImageUrl(1814275685699406),
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
);
assert.equal(mapillaryImageUrl(' '), 'https://www.mapillary.com/app/');
@@ -118,3 +115,23 @@ test('nearestImage picks the closest image, not the first the API returned', asy
const instance = createMapillaryProvider({ source }).create(fakeContext());
assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'near');
});
test('a "since N days" window follows the clock in a long-open tab (review P3)', async (t) => {
t.mock.timers.enable({ apis: ['Date'], now: Date.UTC(2026, 9, 1) });
const day = 86_400_000;
const capturedAt = Date.now() - day / 2;
const source = fakeSource({
nearest: [{ id: 'recent', is_pano: false, captured_at: capturedAt }],
});
// The core's context resolves the stored filter whenever it is asked.
const instance = createMapillaryProvider({ source }).create(
fakeContext(() => resolveFilter({ pano: 'all', sinceDays: 1 })),
);
assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'recent');
t.mock.timers.tick(2 * day);
assert.equal(
await instance.nearestImage({ lat: 1, lon: 2 }),
null,
'two days on, the image is older than the window',
);
});
@@ -2,6 +2,7 @@ import * as Cesium from 'cesium';
import { imageConeGlyph } from '../../glyphs.js';
import { passesImageryFilter } from '../../filter.js';
import { refineHeights } from '../../groundCast.js';
import { metresBetween } from '../../view.js';
import {
COLORS,
IMAGE_CONE_MIN_SPACING_M,
@@ -14,14 +15,6 @@ const SPRITE_ID = 'street-level:mapillary-cones';
/** How many recently viewed sequences keep their image list in memory. */
const SEQUENCE_CACHE_SIZE = 40;
/** Approximate metres between two lon/lat points (small distances). */
function metresBetween(a, b) {
const lat = ((a.lat + b.lat) / 2) * (Math.PI / 180);
const dx = (b.lon - a.lon) * 111_320 * Math.cos(lat);
const dy = (b.lat - a.lat) * 110_540;
return Math.hypot(dx, dy);
}
/** Normalize a graph image record into the shape the cones use. */
export function normalizeSequenceImage(record) {
const coordinates = record?.geometry?.coordinates;
@@ -119,8 +112,10 @@ export function createSequences({ state, source, parts }) {
color: COLORS.coverage,
pano: true,
});
// Resolved now, so a "since N days" window keeps up with the clock.
const filter = state.context.getFilter();
images.forEach((image, index) => {
if (!passesImageryFilter(image, state.filter)) return;
if (!passesImageryFilter(image, filter)) return;
const height = heights?.[index] ?? null;
collection.add({
id: `${PICK_PREFIX.image}${image.id}`,
@@ -158,9 +153,10 @@ export function createSequences({ state, source, parts }) {
/** Select a sequence: highlight its line and load its image cones. */
async function select(sequenceId) {
if (!sequenceId || !state.viewer) return;
// Already on screen, or already on its way.
if (
state.sequence.selectedId === sequenceId &&
state.sequence.images.length
(state.sequence.images.length || state.sequence.abort)
)
return;
if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId)
@@ -174,9 +170,14 @@ export function createSequences({ state, source, parts }) {
state.sequence.loading = false;
state.sequence.images = cached;
renderCones(cached);
state.context.actions.reportError(null);
notify();
return;
}
// The previous sequence's cones must not stay clickable under the new
// highlight while this one loads, nor after its load fails.
clearCones();
requestRender();
const controller = new AbortController();
state.sequence.abort = controller;
state.sequence.loading = true;
@@ -195,11 +196,16 @@ export function createSequences({ state, source, parts }) {
remember(sequenceId, images);
state.sequence.images = images;
renderCones(images);
state.context.actions.reportError(null);
} catch (error) {
if (!controller.signal.aborted)
if (!controller.signal.aborted) {
// Nothing to show: drop the highlight, so a click asks again.
parts.coverage.recolorSequence(sequenceId, false);
state.sequence.selectedId = null;
state.context.actions.reportError(
error?.message || 'Sequence images unavailable',
);
}
} finally {
if (state.sequence.abort === controller) {
state.sequence.loading = false;
@@ -217,6 +223,8 @@ export function createSequences({ state, source, parts }) {
state.sequence.selectedId = null;
state.sequence.loading = false;
clearCones();
// An error about the sequence goes with it.
state.context.actions.reportError(null);
requestRender();
notify();
}
@@ -0,0 +1,172 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createSequences } from './sequences.js';
// The cone glyphs draw on a canvas; Node has none, so any drawing is a no-op.
const noop = () => {};
const context2d = new Proxy({}, { get: () => noop, set: () => true });
globalThis.document ??= {
createElement: () => ({ getContext: () => context2d }),
};
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/** A billboard collection that remembers the cones on the globe. */
function cones() {
const items = [];
return {
items,
show: true,
add(options) {
items.push(options);
return options;
},
removeAll() {
items.length = 0;
},
};
}
/** A source whose sequence lookups the test answers, one call at a time. */
function deferredSource() {
const calls = [];
return {
calls,
getSequenceImages(sequenceId, { signal } = {}) {
return new Promise((resolve, reject) => {
calls.push({ sequenceId, signal, resolve, reject });
});
},
/** The latest lookup for a sequence. */
last(sequenceId) {
return calls.filter((call) => call.sequenceId === sequenceId).at(-1);
},
};
}
/** Graph image records for a sequence, a few metres apart. */
function records(sequenceId, count = 2) {
return Array.from({ length: count }, (_, i) => ({
id: `${sequenceId}-${i}`,
geometry: { coordinates: [-121.49 + i * 0.001, 38.58] },
compass_angle: 90,
captured_at: 1_700_000_000_000 + i,
}));
}
function setup() {
const source = deferredSource();
const colours = [];
const errors = [];
const state = {
services: {},
viewer: {},
context: {
notify() {},
getFilter: () => ({ pano: 'all', sinceMs: null }),
getSurface: () => 'draped',
groundCaster: null,
meshSampler: null,
actions: { reportError: (message) => errors.push(message) },
},
sequence: {
selectedId: null,
images: [],
cache: new Map(),
collection: cones(),
loading: false,
abort: null,
},
};
const parts = {
coverage: {
recolorSequence: (id, selected) => colours.push([id, selected]),
},
};
const sequences = createSequences({ state, source, parts });
const drawn = () =>
state.sequence.collection.items.map((cone) => cone.id.split(':').at(-1));
return { state, source, sequences, colours, errors, drawn };
}
test('a failed sequence load leaves no stale cones and can be retried (review P2-4)', async () => {
const { state, source, sequences, colours, errors, drawn } = setup();
sequences.select('A');
source.last('A').resolve(records('A'));
await settle();
assert.deepEqual(drawn(), ['A-0', 'A-1']);
sequences.select('B');
assert.deepEqual(drawn(), [], "A's cones go while B loads");
source.last('B').reject(new Error('Sequence images unavailable'));
await settle();
assert.deepEqual(drawn(), [], 'nothing of A is left to click');
assert.equal(state.sequence.loading, false);
assert.equal(state.sequence.selectedId, null, 'B is not left highlighted');
assert.deepEqual(colours.at(-1), ['B', false]);
assert.deepEqual(errors, [null, 'Sequence images unavailable']);
// Clicking B again asks again.
sequences.select('B');
assert.equal(source.calls.filter((c) => c.sequenceId === 'B').length, 2);
source.last('B').resolve(records('B'));
await settle();
assert.deepEqual(drawn(), ['B-0', 'B-1']);
assert.equal(state.sequence.selectedId, 'B');
assert.equal(errors.at(-1), null, 'the error is cleared once B loads');
});
test('a late answer for a superseded sequence neither draws nor is cached', async () => {
const { state, source, sequences, drawn } = setup();
sequences.select('A');
sequences.select('B');
assert.equal(source.last('A').signal.aborted, true, 'A was cancelled');
source.last('B').resolve(records('B'));
await settle();
source.last('A').resolve(records('A'));
await settle();
assert.deepEqual(drawn(), ['B-0', 'B-1']);
assert.equal(state.sequence.selectedId, 'B');
assert.equal(state.sequence.cache.has('A'), false);
assert.equal(state.sequence.loading, false);
});
test('a second click on a loading sequence does not start another lookup', async () => {
const { source, sequences } = setup();
sequences.select('A');
sequences.select('A');
assert.equal(source.calls.length, 1);
assert.equal(source.calls[0].signal.aborted, false);
});
test('a cached sequence is re-selected without a lookup', async () => {
const { state, source, sequences, drawn } = setup();
sequences.select('A');
source.last('A').resolve(records('A'));
await settle();
sequences.select('B');
source.last('B').resolve(records('B'));
await settle();
sequences.select('A');
assert.equal(source.calls.length, 2, 'A came from the cache');
assert.deepEqual(drawn(), ['A-0', 'A-1'], 'drawn at once');
assert.equal(state.sequence.loading, false);
assert.equal(state.sequence.selectedId, 'A');
});
test('clearSelection cancels the lookup and uncolours the sequence', async () => {
const { state, source, sequences, colours, errors, drawn } = setup();
sequences.select('A');
sequences.clearSelection();
assert.equal(source.last('A').signal.aborted, true);
assert.deepEqual(colours, [
['A', true],
['A', false],
]);
assert.equal(state.sequence.selectedId, null);
assert.equal(state.sequence.loading, false);
assert.equal(errors.at(-1), null, 'an error about it goes too');
source.last('A').resolve(records('A'));
await settle();
assert.deepEqual(drawn(), [], 'the late answer draws nothing');
});
@@ -117,10 +117,6 @@ export function createMapillarySource({
return new Uint8Array(await response.arrayBuffer());
},
getImage(id, { signal, fields = IMAGE_FIELDS } = {}) {
return graph(String(id), { fields }, { signal });
},
async getSequenceImages(
sequenceId,
{ signal, limit = SEQUENCE_IMAGES_LIMIT } = {},
@@ -83,7 +83,7 @@ test('graph calls without a token fail fast as keyRequired without a request', a
const { calls, fetchImpl } = fakeFetch(() => jsonResponse({}));
const source = createMapillarySource({ token: '', fetchImpl });
await assert.rejects(
() => source.getImage('123'),
() => source.getSequenceImages('123'),
(error) =>
error instanceof MapillarySourceError && error.keyRequired === true,
);
@@ -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.getImage('999'),
() => source.getSequenceImages('999'),
(error) =>
error instanceof MapillarySourceError &&
error.status === 400 &&
@@ -11,7 +11,8 @@ export function createMapillaryViewer({ source, render } = {}) {
let Library = null;
let container = null;
let pendingOpen = null;
let prewarming = false;
/** The prewarm in flight, if any. */
let prewarming = null;
/** In-flight viewer construction, so pre-warm and open never build two. */
let creating = null;
/** Bumped by `unmount`, so a construction it overtook builds nothing. */
@@ -167,6 +168,13 @@ export function createMapillaryViewer({ source, render } = {}) {
close() {
pendingOpen = null;
current = null;
// A playing sequence would keep stepping through (and downloading)
// images in the hidden viewer.
try {
viewer?.getComponent('sequence')?.stop();
} catch {
/* sequence component not available */
}
},
unmount() {
@@ -190,16 +198,22 @@ export function createMapillaryViewer({ source, render } = {}) {
* opening one only costs the image download. Safe to call repeatedly.
*/
async prewarm(host) {
if (prewarming || !host) return;
prewarming = true;
try {
await ensureLibrary();
if (!viewer) await ensureViewer(host);
} catch {
/* the real open reports errors */
} finally {
prewarming = false;
}
if (!host) return;
// A prewarm already in flight may give up (the layer went off and on
// while the library downloaded): wait for it, then build if it did not.
if (prewarming) await prewarming;
if (viewer) return;
const run = (async () => {
try {
await ensureLibrary();
if (!viewer) await ensureViewer(host);
} catch {
/* the real open reports errors */
}
})();
prewarming = run;
await run;
if (prewarming === run) prewarming = null;
},
/** Show the whole image ('letterbox') or crop it to the frame ('fill'). */