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

@@ -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;