diff --git a/server/providers/mapillary/constants.js b/server/providers/mapillary/constants.js index 2c968004f..2b7c5ee9d 100644 --- a/server/providers/mapillary/constants.js +++ b/server/providers/mapillary/constants.js @@ -37,6 +37,16 @@ export const TILE_MAX_BYTES = 48 * 1024 * 1024; /** In-memory tile cache budget (bytes) and upstream fetch timeout. */ export const TILE_MEMORY_BUDGET_BYTES = 96 * 1024 * 1024; export const TILE_FETCH_TIMEOUT_MS = 60_000; +/** + * After Mapillary rejects the token (401/403), tile misses are answered from + * that verdict for this long instead of asking again on every camera move. + * A changed token is asked at once. + */ +export const TILE_KEY_REJECTED_HOLD_MS = 5 * 60 * 1000; +/** After a 429 without a usable Retry-After, hold tile misses this long. */ +export const TILE_RATE_LIMIT_HOLD_MS = 60_000; +/** The longest Retry-After honoured, so a bad header cannot stall coverage. */ +export const TILE_RATE_LIMIT_MAX_HOLD_MS = 10 * 60 * 1000; /** The client token lives in the browser by design; the server adds it to tile URLs too. */ export function mapillaryToken() { diff --git a/server/providers/mapillary/routes.js b/server/providers/mapillary/routes.js index 68ef672e5..7c44c6ae6 100644 --- a/server/providers/mapillary/routes.js +++ b/server/providers/mapillary/routes.js @@ -46,10 +46,25 @@ async function handleTile(req, res) { if (res.writableEnded || abandoned.signal.aborted) return; if (error instanceof TileRequestError) return sendJson(res, error.status, { error: error.message }); - if (error instanceof TileUpstreamError) + if (error instanceof TileUpstreamError) { + // A rejected token is a key problem the panel can name, not a fault. + if (error.keyRejected) + return sendJson(res, 403, { + error: 'Mapillary rejected the access token', + keyRejected: true, + }); + if (error.status === 429) { + const retryAfter = error.retryAfterSec || 60; + res.setHeader('Retry-After', String(retryAfter)); + return sendJson(res, 429, { + error: 'Mapillary is rate-limiting tile requests', + retryAfter, + }); + } return sendJson(res, error.status >= 500 ? 502 : error.status, { error: error.message, }); + } sendJson(res, 502, { error: error?.message || 'Tile fetch failed' }); } } diff --git a/server/providers/mapillary/tiles.js b/server/providers/mapillary/tiles.js index 9768880a4..80c9e113a 100644 --- a/server/providers/mapillary/tiles.js +++ b/server/providers/mapillary/tiles.js @@ -9,6 +9,9 @@ import { TILE_MAX_BYTES, TILE_MEMORY_BUDGET_BYTES, TILE_FETCH_TIMEOUT_MS, + TILE_KEY_REJECTED_HOLD_MS, + TILE_RATE_LIMIT_HOLD_MS, + TILE_RATE_LIMIT_MAX_HOLD_MS, mapillaryToken, } from './constants.js'; @@ -21,15 +24,52 @@ export class TileRequestError extends Error { } } -/** Thrown when Mapillary answered with an error status. */ +/** + * Thrown when Mapillary answered with an error status. A 401/403 means the + * token was rejected (`keyRejected`); a 429 carries how long to wait. + */ export class TileUpstreamError extends Error { - constructor(status, message) { + constructor(status, message, { retryAfterSec = null } = {}) { super(message || `Mapillary tiles HTTP ${status}`); this.name = 'TileUpstreamError'; this.status = status; + this.keyRejected = status === 401 || status === 403; + this.retryAfterSec = retryAfterSec; } } +/** + * The last refusal from Mapillary that every tile miss shares: a rejected + * token (until the token changes or the hold ends) or a rate limit (until its + * Retry-After). Cached tiles are still served meanwhile. + * @type {{status: number, until: number, token: string}|null} + */ +let _upstreamHold = null; + +/** Milliseconds to wait from a Retry-After header (seconds or an HTTP date). */ +function retryAfterMs(header, now = Date.now()) { + const text = String(header ?? '').trim(); + let ms = NaN; + if (/^\d+$/.test(text)) ms = Number(text) * 1000; + else if (text) ms = Date.parse(text) - now; + if (!Number.isFinite(ms) || ms <= 0) return TILE_RATE_LIMIT_HOLD_MS; + return Math.min(ms, TILE_RATE_LIMIT_MAX_HOLD_MS); +} + +/** The refusal to answer a miss with, while one holds for this token. */ +function heldRefusal(token) { + const hold = _upstreamHold; + if (!hold) return null; + const left = hold.until - Date.now(); + if (left <= 0 || hold.token !== token) { + _upstreamHold = null; + return null; + } + return new TileUpstreamError(hold.status, undefined, { + retryAfterSec: Math.ceil(left / 1000), + }); +} + /** * Validate and normalize a tile address. Layer names are the proxy's public * ids (coverage/points/signs); zoom must be inside the layer's published range. @@ -135,6 +175,8 @@ function writeDisk(address, bytes) { async function fetchUpstream(address, signal) { const token = mapillaryToken(); if (!token) throw new TileRequestError('Mapillary token not configured', 503); + const held = heldRefusal(token); + if (held) throw held; const url = `${MAPILLARY_TILE_HOST}/${address.upstream}/2/${address.z}/${address.x}/${address.y}?access_token=${encodeURIComponent(token)}`; const timeout = AbortSignal.timeout(TILE_FETCH_TIMEOUT_MS); const response = await fetch(url, { @@ -147,7 +189,19 @@ async function fetchUpstream(address, signal) { } if (!response.ok) { await response.body?.cancel().catch(() => {}); - throw new TileUpstreamError(response.status); + const error = new TileUpstreamError(response.status); + if (error.keyRejected) + _upstreamHold = { + status: response.status, + until: Date.now() + TILE_KEY_REJECTED_HOLD_MS, + token, + }; + else if (response.status === 429) { + const wait = retryAfterMs(response.headers.get('retry-after')); + _upstreamHold = { status: 429, until: Date.now() + wait, token }; + error.retryAfterSec = Math.ceil(wait / 1000); + } + throw error; } const declared = Number(response.headers.get('content-length')); if (Number.isFinite(declared) && declared > TILE_MAX_BYTES) { @@ -258,4 +312,5 @@ export function _resetTileMemoryForTest() { _memory.clear(); _memoryBytes = 0; _inFlight.clear(); + _upstreamHold = null; } diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 787b5ebc6..569949a38 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -263,7 +263,9 @@ export function createStreetLevelLayer({ label: entry.def.label, on: entry.on, configured: entry.status ? entry.status.configured === true : null, - keyRequired: stats.keyRequired === true, + // A rejected key gates the provider exactly like a missing one. + keyRequired: stats.keyRequired === true || stats.keyRejected === true, + keyRejected: stats.keyRejected === true, requiresKeyId: entry.def.requiresKeyId || null, loading: stats.loading === true, count: stats.count || 0, @@ -376,7 +378,8 @@ export function createStreetLevelLayer({ // The lifecycle polls this every second: summarise, don't snapshot. const coverage = summarizeCoverage(providerSnapshots()); let loadingLabel = ''; - if (coverage.keyRequired) loadingLabel = 'KEY REQUIRED'; + const keyLabel = coverage.keyRejected ? 'KEY REJECTED' : 'KEY REQUIRED'; + if (coverage.keyRequired) loadingLabel = keyLabel; else if (coverage.loading) loadingLabel = 'loading coverage...'; else if (coverage.hint && state.enabled) loadingLabel = coverage.hint; return { @@ -384,7 +387,11 @@ export function createStreetLevelLayer({ sequences: coverage.count, loading: coverage.loading, keyRequired: coverage.keyRequired, - error: coverage.keyRequired ? 'KEY REQUIRED' : coverage.error, + error: coverage.keyRequired + ? coverage.keyRejected + ? coverage.error || keyLabel + : keyLabel + : coverage.error, loadingLabel, }; }, diff --git a/src/layers/streetLevel/providers/mapillary/coverage.js b/src/layers/streetLevel/providers/mapillary/coverage.js index 7473c35a7..139fc0e73 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.js +++ b/src/layers/streetLevel/providers/mapillary/coverage.js @@ -1,7 +1,7 @@ import * as Cesium from 'cesium'; import { decodeCoverageTile } from './decode.js'; import { passesImageryFilter } from '../../filter.js'; -import { cameraHeightAboveGround, visibleBbox } from '../../view.js'; +import { groundUnderCamera, viewFocus, visibleBbox } from '../../view.js'; import { densifyLine, MESH_DENSIFY_DEG } from '../../groundCast.js'; import { MESH_CELL_DEG } from '../../meshSampler.js'; import { @@ -18,7 +18,12 @@ import { COVERAGE_MOVE_DEBOUNCE_MS, COVERAGE_OVERVIEW_MAX_TILES, COVERAGE_OVERVIEW_POINT_PX, + KEY_REJECTED_MESSAGE, PICK_PREFIX, + RATE_LIMITED_MESSAGE, + SEQUENCE_VIEW_NEAR_M, + SEQUENCE_VIEW_RANGE_MIN_M, + SEQUENCE_VIEW_RANGE_PER_HEIGHT, } from './policy.js'; /** Draped lines stay at most this long while a tile's cast lines build. */ @@ -477,6 +482,12 @@ export function createCoverage({ state, source }) { return; state.coverage.lastError = error?.message || 'Coverage tile failed'; if (error?.keyRequired) state.keyRequired = true; + if (error?.keyRejected) { + // Every other tile would be refused too: stop asking. + state.keyRejected = true; + state.coverage.lastError = KEY_REJECTED_MESSAGE; + } + 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. @@ -489,6 +500,32 @@ export function createCoverage({ state, source }) { } } + /** + * Mapillary is rate-limiting: keep what is drawn, request nothing until the + * wait is over, then refresh once. + */ + function holdFor(seconds) { + clearTimeout(state.coverage.holdTimer); + state.coverage.holdUntil = Date.now() + seconds * 1000; + state.coverage.lastError = RATE_LIMITED_MESSAGE; + state.coverage.holdTimer = setTimeout(() => { + state.coverage.holdTimer = null; + state.coverage.holdUntil = 0; + if (state.coverage.lastError === RATE_LIMITED_MESSAGE) + state.coverage.lastError = null; + refresh(); + }, seconds * 1000); + } + + /** Forget refusals when the provider goes off, so the next run asks again. */ + function resetErrors() { + clearTimeout(state.coverage.holdTimer); + state.coverage.holdTimer = null; + state.coverage.holdUntil = 0; + state.coverage.lastError = null; + state.keyRejected = false; + } + /** Drop the previous zoom's tiles once the new ones are on screen. */ function purgeStale() { clearTimeout(state.coverage.staleTimer); @@ -523,18 +560,55 @@ export function createCoverage({ state, source }) { state.context.notify(); } + /** + * Whether the screen centre meets the ground further ahead than the camera + * is high (a view tilted above 45°), or misses it. + */ + 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); + } + /** Recompute the tile set for the current camera and reconcile primitives. */ function refresh() { const viewer = state.viewer; if (!viewer || !state.context.isActive() || state.keyRequired) return; - const height = cameraHeightAboveGround(viewer, { + if (state.keyRejected || state.coverage.holdUntil > Date.now()) return; + const ground = groundUnderCamera(viewer, { groundAt: state.context.groundCaster?.groundAt, }); + const cameraHeight = viewer.camera?.positionCartographic?.height; + const height = Number.isFinite(cameraHeight) + ? cameraHeight - (ground ?? 0) + : null; const sequenceZoom = coverageZoomForHeight(height); const overviewZoom = sequenceZoom ? null : overviewZoomForHeight(height); const zoom = sequenceZoom ?? overviewZoom; const kind = sequenceZoom ? 'sequence' : 'overview'; - let bbox = visibleBbox(viewer); + // At street zooms the rays meet the ground where it really is (1,600 m up + // in Denver; Google 3D hides the globe) and stop short of the horizon. + const ranged = + kind === 'sequence' + ? { + groundHeight: ground, + maxRange: Math.max( + SEQUENCE_VIEW_RANGE_MIN_M, + height * SEQUENCE_VIEW_RANGE_PER_HEIGHT, + ), + } + : {}; + // Tilted street views rank tiles along the line of sight, from the ground + // under the camera to the ground at the centre of the screen. + const focus = kind === 'sequence' ? viewFocus(viewer, ranged) : null; + let bbox = visibleBbox(viewer, { + ...ranged, + // A tilted view's screen rows jump from the first metres to the horizon: + // keep the ground around the camera too. Looking down needs no help. + nearRange: focus && isTilted(focus, height) ? SEQUENCE_VIEW_NEAR_M : null, + }); if (kind === 'overview' && (!bbox || zoom <= 1)) bbox = [-180, -85, 180, 85]; if (zoom == null || !bbox) { @@ -553,6 +627,7 @@ export function createCoverage({ state, source }) { const { tiles } = tilesForBbox(bbox, zoom, { limit: kind === 'sequence' ? COVERAGE_MAX_TILES : COVERAGE_OVERVIEW_MAX_TILES, + focus: focus && { from: focus.nadir, to: focus.ahead }, }); const wanted = new Set(tiles.map(tileKey)); for (const key of [...state.coverage.tiles.keys()]) @@ -684,6 +759,7 @@ export function createCoverage({ state, source }) { detach, refresh, clear, + resetErrors, rebuild, setSurface, findSequence, diff --git a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs index 5c133cf6e..34335893d 100644 --- a/src/layers/streetLevel/providers/mapillary/coverage.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/coverage.test.mjs @@ -277,3 +277,50 @@ test('zoom 0 is a zoom: the whole-earth view still shows coverage', () => { ); coverage.clear(); }); + +test('a rejected key stops coverage requests until the layer goes off (review IC8 P2)', async () => { + const { viewer, source, state, coverage } = setup(); + coverage.refresh(); + const rejected = Object.assign( + new Error('Mapillary rejected the access token'), + { + keyRejected: true, + }, + ); + source.calls[0].reject(rejected); + await settle(); + assert.equal(state.keyRejected, true); + assert.match(state.coverage.lastError, /rejected MAPILLARY_CLIENT_TOKEN/); + viewer.view.lon += 0.05; + coverage.refresh(); + assert.equal(source.calls.length, 1, 'panning asks for nothing more'); + // Turning the provider off forgets the verdict; the next run asks again. + coverage.clear(); + coverage.resetErrors(); + assert.equal(state.keyRejected, false); + assert.equal(state.coverage.lastError, null); + coverage.refresh(); + assert.equal(source.calls.length, 2); + coverage.clear(); +}); + +test('a rate limit keeps the drawn tiles and asks again once the wait is over (review IC8 P2)', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); + const { viewer, source, state, coverage } = setup(); + coverage.refresh(); + source.calls[0].reject( + Object.assign(new Error('rate limited'), { retryAfterSec: 30 }), + ); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + assert.match(state.coverage.lastError, /rate-limiting/); + viewer.view.lon += 0.05; + coverage.refresh(); + assert.equal(source.calls.length, 1, 'held: nothing is requested'); + t.mock.timers.tick(30_000); + assert.equal(source.calls.length, 2, 'one refresh once the wait is over'); + assert.equal(state.coverage.lastError, null); + coverage.clear(); + coverage.resetErrors(); +}); diff --git a/src/layers/streetLevel/providers/mapillary/index.js b/src/layers/streetLevel/providers/mapillary/index.js index 436addf8e..8c02c017c 100644 --- a/src/layers/streetLevel/providers/mapillary/index.js +++ b/src/layers/streetLevel/providers/mapillary/index.js @@ -32,6 +32,8 @@ function createProviderState(context) { services: context.services, viewer: null, keyRequired: false, + /** Mapillary refused the configured token: nothing is requested until re-enabled. */ + keyRejected: false, status: null, filter: context.getFilter(), coverage: { @@ -44,6 +46,9 @@ function createProviderState(context) { pending: new Map(), loading: 0, lastError: null, + /** While Mapillary rate-limits: no requests before this time (ms). */ + holdUntil: 0, + holdTimer: null, debounceTimer: null, removeCameraListener: null, terrainReady: null, @@ -131,6 +136,7 @@ export function createMapillaryProvider({ source }) { deactivate() { parts.coverage.detach(); parts.coverage.clear(); + parts.coverage.resetErrors(); parts.sequences.clearSelection(); parts.sequences.setVisible(false); }, @@ -138,6 +144,7 @@ export function createMapillaryProvider({ source }) { destroy(cesiumViewer) { parts.coverage.detach(); parts.coverage.clear(); + parts.coverage.resetErrors(); parts.sequences.destroy(cesiumViewer); }, @@ -164,6 +171,7 @@ export function createMapillaryProvider({ source }) { hint: state.coverage.hint, error: state.coverage.lastError, keyRequired: state.keyRequired, + keyRejected: state.keyRejected, }; }, diff --git a/src/layers/streetLevel/providers/mapillary/policy.js b/src/layers/streetLevel/providers/mapillary/policy.js index 3f1fde21b..cb7f6a1cd 100644 --- a/src/layers/streetLevel/providers/mapillary/policy.js +++ b/src/layers/streetLevel/providers/mapillary/policy.js @@ -28,6 +28,21 @@ export const COVERAGE_MOVE_DEBOUNCE_MS = 320; export const COVERAGE_MAX_TILES = 9; /** Overview (z0–5) coverage points seen from orbit. */ export const COVERAGE_OVERVIEW_MAX_TILES = 16; +/** Shown when Mapillary refuses the server's token (401/403). */ +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'; +/** + * 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 + * and boxed. Scales with camera height, never below the floor. + */ +export const SEQUENCE_VIEW_RANGE_MIN_M = 2_500; +export const SEQUENCE_VIEW_RANGE_PER_HEIGHT = 10; +/** Ground always covered around the camera at street zooms, in metres. */ +export const SEQUENCE_VIEW_NEAR_M = 1_000; export const COVERAGE_OVERVIEW_POINT_PX = 2.5; export const COVERAGE_MAX_SEQUENCES = 6000; export const COVERAGE_LINE_WIDTH_PX = 2.5; diff --git a/src/layers/streetLevel/providers/mapillary/source.js b/src/layers/streetLevel/providers/mapillary/source.js index 0cc602ded..2ad2d9b20 100644 --- a/src/layers/streetLevel/providers/mapillary/source.js +++ b/src/layers/streetLevel/providers/mapillary/source.js @@ -7,14 +7,22 @@ import { SEQUENCE_IMAGES_LIMIT, } from './policy.js'; -/** Error carrying the HTTP status and any server-provided payload. */ +/** + * Error carrying the HTTP status and any server-provided payload: no token + * (`keyRequired`), a token Mapillary rejected (`keyRejected`), or a rate + * limit with the seconds to wait (`retryAfterSec`). + */ export class MapillarySourceError extends Error { - constructor(message, { status = 0, payload = null } = {}) { + constructor(message, { status = 0, payload = null, retryAfterSec } = {}) { super(message); this.name = 'MapillarySourceError'; this.status = status; this.payload = payload; this.keyRequired = payload?.keyRequired ?? payload?.error === 'no_key'; + this.keyRejected = payload?.keyRejected === true; + const wait = Number(retryAfterSec ?? payload?.retryAfter); + this.retryAfterSec = + status === 429 ? (Number.isFinite(wait) && wait > 0 ? wait : 60) : null; } } @@ -102,6 +110,7 @@ export function createMapillarySource({ { status: response.status, payload, + retryAfterSec: response.headers?.get?.('retry-after') ?? undefined, }, ); } diff --git a/src/layers/streetLevel/providers/mapillary/source.test.mjs b/src/layers/streetLevel/providers/mapillary/source.test.mjs index ec9f13923..e9e16ba19 100644 --- a/src/layers/streetLevel/providers/mapillary/source.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/source.test.mjs @@ -109,3 +109,40 @@ test('the source exposes only imagery lookups', () => { for (const gone of ['queryFeatures', 'plan', 'geocode', 'spriteUrl']) assert.equal(source[gone], undefined, gone); }); + +test('a rejected key and a rate limit are told apart from other tile errors (review IC8 P2)', async () => { + const rejectedSource = createMapillarySource({ + token: 'MLY|1|abc', + fetchImpl: fakeFetch(() => + jsonResponse( + { error: 'Mapillary rejected the access token', keyRejected: true }, + 403, + ), + ).fetchImpl, + }); + await assert.rejects( + () => rejectedSource.getTile('coverage', 14, 1, 1), + (error) => + error.keyRejected === true && + error.keyRequired === false && + error.retryAfterSec === null, + ); + const limitedSource = createMapillarySource({ + token: 'MLY|1|abc', + fetchImpl: fakeFetch( + () => + new Response( + JSON.stringify({ error: 'rate limited', retryAfter: 45 }), + { + status: 429, + headers: { 'retry-after': '30' }, + }, + ), + ).fetchImpl, + }); + await assert.rejects( + () => limitedSource.getTile('coverage', 14, 1, 1), + (error) => error.retryAfterSec === 30 && error.keyRejected === false, + 'the Retry-After header wins over the body', + ); +}); diff --git a/src/layers/streetLevel/tileMath.js b/src/layers/streetLevel/tileMath.js index d6ba315b4..02eed1aa3 100644 --- a/src/layers/streetLevel/tileMath.js +++ b/src/layers/streetLevel/tileMath.js @@ -92,15 +92,59 @@ export function countTilesForBbox(bbox, z) { return (x1 - x0 + 1) * (y1 - y0 + 1); } +/** + * Squared distance (in tiles) from tile (x, y)'s centre to the segment + * `from` → `to` at zoom z, measured the short way round the date line. + */ +function segmentDistance2(tile, from, to, z) { + const n = 2 ** z; + /** Unfloored tile coordinates of a point. */ + const tileX = (lon) => ((lon + 180) / 360) * n; + const tileY = (lat) => { + const rad = (clampLat(lat) * Math.PI) / 180; + return ( + ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n + ); + }; + const px = tile.x + 0.5; + const py = tile.y + 0.5; + const ax = tileX(from.lon); + const ay = tileY(from.lat); + let bx = to ? tileX(to.lon) : ax; + const by = to ? tileY(to.lat) : ay; + if (bx - ax > n / 2) bx -= n; + else if (ax - bx > n / 2) bx += n; + let best = Infinity; + for (const shift of [-n, 0, n]) { + const x = px + shift; + const vx = bx - ax; + const vy = by - ay; + const len2 = vx * vx + vy * vy; + const t = len2 + ? Math.max(0, Math.min(1, ((x - ax) * vx + (py - ay) * vy) / len2)) + : 0; + const dx = x - (ax + t * vx); + const dy = py - (ay + t * vy); + best = Math.min(best, dx * dx + dy * dy); + } + return best; +} + /** * Enumerate the tiles at zoom z covering a bbox (west > east means it crosses * the date line), ordered from the centre of * the box outwards so a progressive renderer fills in what the user is most * likely looking at first. `limit` caps the list; the result reports whether * the cap was hit. + * + * `focus` ranks by distance from the line between `focus.from` (the ground + * under the camera) and `focus.to` (the ground at the centre of the screen) + * instead: a tilted view's box reaches far past what the user looks at, and + * its centre can sit kilometres ahead of both. + * @param {{limit?: number, focus?: {from: {lon: number, lat: number}, to?: {lon: number, lat: number}|null}|null}} [options] * @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}} */ -export function tilesForBbox(bbox, z, { limit = Infinity } = {}) { +export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) { // A box across the date line comes as west > east: split it at ±180°. if (Array.isArray(bbox) && Number(bbox[0]) > Number(bbox[2])) { const [west, south, east, north] = bbox.map(Number); @@ -114,10 +158,17 @@ export function tilesForBbox(bbox, z, { limit = Infinity } = {}) { const cx = ((x0 + x1) / 2) % n; const cy = (latToTileY(north, z) + latToTileY(south, z)) / 2; const dx = (x) => Math.min(Math.abs(x - cx), n - Math.abs(x - cx)); - tiles.sort( - (a, b) => - dx(a.x) ** 2 + (a.y - cy) ** 2 - (dx(b.x) ** 2 + (b.y - cy) ** 2), - ); + if (focus?.from) + tiles.sort( + (a, b) => + segmentDistance2(a, focus.from, focus.to, z) - + segmentDistance2(b, focus.from, focus.to, z), + ); + else + tiles.sort( + (a, b) => + dx(a.x) ** 2 + (a.y - cy) ** 2 - (dx(b.x) ** 2 + (b.y - cy) ** 2), + ); const truncated = tiles.length > limit; return { tiles: truncated ? tiles.slice(0, limit) : tiles, @@ -137,10 +188,17 @@ export function tilesForBbox(bbox, z, { limit = Infinity } = {}) { const tiles = []; for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) tiles.push({ x, y, z }); - tiles.sort( - (a, b) => - (a.x - cx) ** 2 + (a.y - cy) ** 2 - ((b.x - cx) ** 2 + (b.y - cy) ** 2), - ); + if (focus?.from) + tiles.sort( + (a, b) => + segmentDistance2(a, focus.from, focus.to, z) - + segmentDistance2(b, focus.from, focus.to, z), + ); + else + tiles.sort( + (a, b) => + (a.x - cx) ** 2 + (a.y - cy) ** 2 - ((b.x - cx) ** 2 + (b.y - cy) ** 2), + ); const truncated = tiles.length > limit; return { tiles: truncated ? tiles.slice(0, limit) : tiles, truncated, total }; } diff --git a/src/layers/streetLevel/tileMath.test.mjs b/src/layers/streetLevel/tileMath.test.mjs index 19f290451..626c147b3 100644 --- a/src/layers/streetLevel/tileMath.test.mjs +++ b/src/layers/streetLevel/tileMath.test.mjs @@ -109,3 +109,22 @@ test('a box across the date line takes tiles on both sides of it, not the far si const limited = tilesForBbox([170, -1, -170, 1], 8, { limit: 4 }).tiles; assert.ok(limited.every((tile) => tile.x <= 8 || tile.x >= 247)); }); + +test('a focus ranks tiles along the line of sight, not from the box centre (review IC8 P1)', () => { + // A box that runs far north of the camera, as a tilted view's does: the + // box centre is kilometres from both the camera and the screen centre. + const from = { lon: -121.4944, lat: 38.5816 }; + const to = { lon: -121.4944, lat: 38.596 }; + const bbox = [-121.6, 38.57, -121.38, 38.9]; + const z = 14; + const key = (t) => `${t.x}/${t.y}`; + const under = `${lonToTileX(from.lon, z)}/${latToTileY(from.lat, z)}`; + const centre = `${lonToTileX(to.lon, z)}/${latToTileY(to.lat, z)}`; + const plain = tilesForBbox(bbox, z, { limit: 9 }).tiles.map(key); + assert.equal(plain.includes(under), false, 'the old ranking drops them'); + const ranked = tilesForBbox(bbox, z, { limit: 9, focus: { from, to } }); + const keys = ranked.tiles.map(key); + assert.ok(keys.includes(under), 'the tile under the camera'); + assert.ok(keys.includes(centre), 'the tile at the centre of the screen'); + assert.equal(ranked.total > 9, true); +}); diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index 99308669d..4a261c613 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -7,7 +7,8 @@ import { COLORS } from './policy.js'; * @property {string} label * @property {boolean} on * @property {boolean|null} configured null until the status call answers - * @property {boolean} keyRequired + * @property {boolean} keyRequired no usable key: missing or rejected + * @property {boolean} [keyRejected] the provider refused the configured key * @property {string|null} requiresKeyId * @property {boolean} loading * @property {number} count @@ -18,7 +19,8 @@ import { COLORS } from './policy.js'; /** * What the switched-on providers add up to: summed counts, any loading, the - * first hint and error, and whether every one of them lacks its key. + * first hint and error, whether every one of them lacks its key, and whether + * that is because a provider rejected the key it was given. * @param {Array} providers */ export function summarizeCoverage(providers) { @@ -30,6 +32,10 @@ export function summarizeCoverage(providers) { error: active.find((p) => p.error)?.error || null, keyRequired: active.length > 0 && active.every((p) => p.keyRequired === true), + keyRejected: + active.length > 0 && + active.every((p) => p.keyRequired === true) && + active.some((p) => p.keyRejected === true), }; } @@ -51,7 +57,8 @@ export function composeUIState({ surface = 'draped', }) { const active = providers.filter((p) => p.on); - const { keyRequired, ...coverage } = summarizeCoverage(providers); + const { keyRequired, keyRejected, ...coverage } = + summarizeCoverage(providers); const legend = active.map((provider) => ({ key: provider.id, label: provider.name, @@ -62,6 +69,7 @@ export function composeUIState({ return { enabled, keyRequired, + keyRejected, filter: { ...filter }, providers: providers.map((p) => ({ ...p })), coverage, diff --git a/src/layers/streetLevel/uiState.test.mjs b/src/layers/streetLevel/uiState.test.mjs index 4e9b59f0d..550768db8 100644 --- a/src/layers/streetLevel/uiState.test.mjs +++ b/src/layers/streetLevel/uiState.test.mjs @@ -124,11 +124,32 @@ test('summarizeCoverage is what getStats reports, without building a snapshot', hint: 'Look down', error: 'boom', keyRequired: false, + keyRejected: false, }, ); assert.equal(summarizeCoverage([]).keyRequired, false); }); +test('a rejected key gates like a missing one and is named as rejected (review IC8 P2)', () => { + const rejected = provider({ + keyRequired: true, + keyRejected: true, + error: 'Mapillary rejected MAPILLARY_CLIENT_TOKEN', + }); + const summary = summarizeCoverage([rejected]); + assert.equal(summary.keyRequired, true); + assert.equal(summary.keyRejected, true); + assert.equal(summary.error, 'Mapillary rejected MAPILLARY_CLIENT_TOKEN'); + const state = composeUIState({ ...base, providers: [rejected] }); + assert.equal(state.keyRequired, true); + assert.equal(state.keyRejected, true); + // A provider that is merely missing its key is not "rejected". + assert.equal( + summarizeCoverage([provider({ keyRequired: true })]).keyRejected, + false, + ); +}); + test('the surface mode defaults to draped and passes through', () => { assert.equal( composeUIState({ ...base, providers: [provider()] }).surface, diff --git a/src/layers/streetLevel/view.js b/src/layers/streetLevel/view.js index 2c1425b72..662644940 100644 --- a/src/layers/streetLevel/view.js +++ b/src/layers/streetLevel/view.js @@ -1,15 +1,14 @@ import * as Cesium from 'cesium'; /** - * Camera height above the surface under the camera, in metres. The globe's - * terrain answers when the globe is shown; Google 3D hides the globe, so - * there the bare-earth height from `groundAt(lon, lat)` answers instead - * (without it a camera over a city 1,600 m up reads 1,600 m too high). Falls - * back to the ellipsoidal height when neither has a sample yet. + * Height of the surface under the camera above the ellipsoid, in metres. The + * globe's terrain answers when the globe is shown; Google 3D hides the globe, + * so there the bare-earth height from `groundAt(lon, lat)` answers instead. + * Null when neither has a sample yet. * @param {object} viewer * @param {{groundAt?: (lon: number, lat: number) => number|null}} [options] */ -export function cameraHeightAboveGround(viewer, { groundAt } = {}) { +export function groundUnderCamera(viewer, { groundAt } = {}) { const carto = viewer?.camera?.positionCartographic; if (!carto) return null; const globe = viewer.scene?.globe; @@ -19,7 +18,48 @@ export function cameraHeightAboveGround(viewer, { groundAt } = {}) { Cesium.Math.toDegrees(carto.longitude), Cesium.Math.toDegrees(carto.latitude), ); - return carto.height - (Number.isFinite(ground) ? ground : 0); + return Number.isFinite(ground) ? ground : null; +} + +/** + * Camera height above the surface under the camera, in metres (without a + * ground sample a camera over a city 1,600 m up would read 1,600 m too + * high). Falls back to the ellipsoidal height when nothing has a sample yet. + * @param {object} viewer + * @param {{groundAt?: (lon: number, lat: number) => number|null}} [options] + */ +export function cameraHeightAboveGround(viewer, options = {}) { + const carto = viewer?.camera?.positionCartographic; + if (!carto) return null; + return carto.height - (groundUnderCamera(viewer, options) ?? 0); +} + +/** The ellipsoid `height` metres above `ellipsoid` (the ground at that height). */ +function raisedEllipsoid(ellipsoid, height) { + if (!Number.isFinite(height) || Math.abs(height) < 1) return ellipsoid; + const { x, y, z } = ellipsoid.radii; + return new Cesium.Ellipsoid(x + height, y + height, z + height); +} + +/** + * Where a screen point's ray meets the ground, as a Cartographic, or null. + * `maxRange` (metres from the camera) drops hits near the horizon. + */ +function groundHit(camera, point, ellipsoid, maxRange) { + let cartesian = null; + try { + cartesian = camera.pickEllipsoid(point, ellipsoid); + } catch { + cartesian = null; + } + if (!cartesian) return null; + if ( + Number.isFinite(maxRange) && + camera.positionWC && + Cesium.Cartesian3.distance(cartesian, camera.positionWC) > maxRange + ) + return null; + return Cesium.Cartographic.fromCartesian(cartesian, ellipsoid) || null; } /** @@ -28,8 +68,18 @@ export function cameraHeightAboveGround(viewer, { groundAt } = {}) { * and only the rays that hit count, so a view that includes the horizon (or a * canvas whose frustum is stale) cannot inflate the box to the whole world; * `computeViewRectangle` is the fallback when too few rays land. + * + * `groundHeight` raises the ellipsoid to the ground under the camera: Google + * 3D hides the globe, and a city 1,600 m up otherwise puts every hit + * kilometres ahead of where the rays really meet the streets. `maxRange` + * (metres) drops hits near the horizon, which a street-level view would + * otherwise stretch into a box far bigger than anything it can show. + * `nearRange` (metres) always includes the ground that far around the camera. */ -export function visibleBbox(viewer, { grid = 5 } = {}) { +export function visibleBbox( + viewer, + { grid = 5, groundHeight = null, maxRange = null, nearRange = null } = {}, +) { const scene = viewer?.scene; const camera = viewer?.camera; if (!scene || !camera) return null; @@ -37,24 +87,42 @@ export function visibleBbox(viewer, { grid = 5 } = {}) { const height = scene.canvas?.clientHeight || scene.canvas?.height || 0; const hits = []; if (width > 0 && height > 0) { - const ellipsoid = scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84; + const ellipsoid = raisedEllipsoid( + scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84, + groundHeight, + ); const point = new Cesium.Cartesian2(); for (let i = 0; i <= grid; i++) { for (let j = 0; j <= grid; j++) { point.x = (width * i) / grid; point.y = (height * j) / grid; - let cartesian = null; - try { - cartesian = camera.pickEllipsoid(point, ellipsoid); - } catch { - cartesian = null; - } - if (!cartesian) continue; - const carto = Cesium.Cartographic.fromCartesian(cartesian, ellipsoid); + const carto = groundHit(camera, point, ellipsoid, maxRange); if (carto) hits.push(carto); } } } + // The ground around the camera always counts: a street-level view's screen + // rows skip from the horizon to the first few metres, and turning the + // camera should not wait for a reload. + const nadir = camera.positionCartographic; + if (Number.isFinite(nearRange) && nearRange > 0 && nadir) { + const dLat = nearRange / 111_320; + const dLon = dLat / Math.max(0.05, Math.cos(nadir.latitude)); + const lat = Cesium.Math.toDegrees(nadir.latitude); + const lon = Cesium.Math.toDegrees(nadir.longitude); + for (const [dx, dy] of [ + [-1, -1], + [-1, 1], + [1, -1], + [1, 1], + ]) + hits.push( + Cesium.Cartographic.fromDegrees( + lon + dx * dLon, + Math.max(-85, Math.min(85, lat + dy * dLat)), + ), + ); + } if (hits.length >= 4) { let west = Infinity; let south = Infinity; @@ -115,3 +183,43 @@ export function viewCentre(viewer) { lon: Cesium.Math.toDegrees(carto.longitude), }; } + +/** + * The ground the camera is looking at, for ranking coverage tiles: the point + * under the camera (`nadir`) and the ground hit at the centre of the screen + * (`ahead`, null when the centre ray misses or is out of `maxRange`). + * @returns {{nadir: {lon: number, lat: number}, ahead: {lon: number, lat: number}|null}|null} + */ +export function viewFocus( + viewer, + { groundHeight = null, maxRange = null } = {}, +) { + const camera = viewer?.camera; + const carto = camera?.positionCartographic; + if (!carto) return null; + const nadir = { + lon: Cesium.Math.toDegrees(carto.longitude), + lat: Cesium.Math.toDegrees(carto.latitude), + }; + const canvas = viewer.scene?.canvas; + const width = canvas?.clientWidth || canvas?.width || 0; + const height = canvas?.clientHeight || canvas?.height || 0; + let ahead = null; + if (width > 0 && height > 0) { + const hit = groundHit( + camera, + new Cesium.Cartesian2(width / 2, height / 2), + raisedEllipsoid( + viewer.scene?.globe?.ellipsoid || Cesium.Ellipsoid.WGS84, + groundHeight, + ), + maxRange, + ); + if (hit) + ahead = { + lon: Cesium.Math.toDegrees(hit.longitude), + lat: Cesium.Math.toDegrees(hit.latitude), + }; + } + return { nadir, ahead }; +} diff --git a/src/layers/streetLevel/view.test.mjs b/src/layers/streetLevel/view.test.mjs index 0e9696578..d39bdc7d1 100644 --- a/src/layers/streetLevel/view.test.mjs +++ b/src/layers/streetLevel/view.test.mjs @@ -1,7 +1,12 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as Cesium from 'cesium'; -import { cameraHeightAboveGround, viewCentre, visibleBbox } from './view.js'; +import { + cameraHeightAboveGround, + viewCentre, + viewFocus, + visibleBbox, +} from './view.js'; const RAD = Math.PI / 180; /** A viewer 300 m above a street 1,600 m up (Denver-like). */ @@ -76,3 +81,96 @@ test('an ordinary view keeps west < east', () => { Math.abs(viewCentre(gridViewer([10, 11, 12], [50, 51])).lon - 11) < 1e-6, ); }); + +/** + * A pinhole camera `agl` metres above ground `ground` m up, looking north at + * `pitch` degrees, whose rays meet whatever ellipsoid they are given (real + * Cesium ray maths, as `camera.pickEllipsoid` does it). + */ +function pinhole({ lon, lat, ground, agl, pitch, w = 1600, h = 900 }) { + const position = Cesium.Cartesian3.fromDegrees(lon, lat, ground + agl); + const frame = Cesium.Transforms.eastNorthUpToFixedFrame(position); + const p = pitch * RAD; + const forward = [0, Math.cos(p), Math.sin(p)]; + const up = [0, -Math.sin(p), Math.cos(p)]; + const half = Math.tan(30 * RAD); + return { + scene: { + canvas: { clientWidth: w, clientHeight: h }, + globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 }, + }, + camera: { + positionWC: position, + positionCartographic: Cesium.Cartographic.fromDegrees( + lon, + lat, + ground + agl, + ), + pickEllipsoid(point, ellipsoid = Cesium.Ellipsoid.WGS84) { + const sx = ((2 * point.x) / w - 1) * half * (w / h); + const sy = (1 - (2 * point.y) / h) * half; + const local = [0, 1, 2].map( + (i) => forward[i] + sx * (i === 0 ? 1 : 0) + sy * up[i], + ); + const direction = Cesium.Cartesian3.normalize( + Cesium.Matrix4.multiplyByPointAsVector( + frame, + new Cesium.Cartesian3(...local), + new Cesium.Cartesian3(), + ), + new Cesium.Cartesian3(), + ); + const ray = new Cesium.Ray(position, direction); + const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid); + return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined; + }, + computeViewRectangle: () => undefined, + }, + }; +} + +test('a tilted street view over high ground boxes the streets it looks at, not the horizon (review IC8 P1)', () => { + // Denver, 600 m above a street 1,610 m up, looking 20° down: the centre of + // the screen meets the street about 1.65 km north of the camera. + const view = pinhole({ + lon: -104.99, + lat: 39.74, + ground: 1610, + agl: 600, + pitch: -20, + }); + const ahead = 39.74 + 1648 / 111_000; + const unranged = visibleBbox(view); + // Rays to the bare ellipsoid travel 1.6 km further down: the old box was + // 20 km wide and started past the street at the centre of the screen. + assert.ok(unranged[2] - unranged[0] > 0.2); + assert.ok(unranged[1] > ahead, 'the old box missed the street in view'); + const options = { groundHeight: 1610, maxRange: 6000, nearRange: 1000 }; + const [west, south, east, north] = visibleBbox(view, options); + assert.ok(north - south < 0.08 && east - west < 0.08, 'a street-sized box'); + assert.ok(south < 39.74 && north > ahead, 'camera and screen centre inside'); + const focus = viewFocus(view, options); + assert.ok(Math.abs(focus.nadir.lat - 39.74) < 1e-9); + assert.ok( + Math.abs(focus.ahead.lat - ahead) < 0.002, + 'centre ray on the street', + ); + assert.ok(Math.abs(focus.ahead.lon - -104.99) < 1e-6); +}); + +test('looking at the horizon from eye height still boxes the ground around the camera', () => { + const view = pinhole({ + lon: -121.4944, + lat: 38.5816, + ground: 10, + agl: 2, + pitch: -5, + }); + const [west, south, east, north] = visibleBbox(view, { + groundHeight: 10, + maxRange: 2500, + nearRange: 1000, + }); + assert.ok(north - 38.5816 > 0.008 && 38.5816 - south > 0.008); + assert.ok(east - -121.4944 > 0.008 && -121.4944 - west > 0.008); +}); diff --git a/src/tooling/mapillaryProvider.test.mjs b/src/tooling/mapillaryProvider.test.mjs index 2c51386b3..3abcc38cb 100644 --- a/src/tooling/mapillaryProvider.test.mjs +++ b/src/tooling/mapillaryProvider.test.mjs @@ -117,6 +117,76 @@ test('tile route validates the path and refuses to proxy without a token', async } }); +/** + * Drive the tile route against a scripted upstream: `answer(call)` returns the + * Response for each upstream fetch. Coordinates use z9 (never requested by + * the app) and refusals are never cached, so no disk tile is written. + */ +async function withUpstream(answer, run) { + const savedFetch = globalThis.fetch; + const savedToken = process.env.MAPILLARY_CLIENT_TOKEN; + process.env.MAPILLARY_CLIENT_TOKEN = 'MLY|test|token'; + _resetTileMemoryForTest(); + const calls = []; + globalThis.fetch = async (url) => { + calls.push(String(url)); + return answer(calls.length); + }; + try { + await run({ calls, call: install(mapillaryProxy()).call }); + } finally { + globalThis.fetch = savedFetch; + if (savedToken === undefined) delete process.env.MAPILLARY_CLIENT_TOKEN; + else process.env.MAPILLARY_CLIENT_TOKEN = savedToken; + _resetTileMemoryForTest(); + } +} + +for (const status of [401, 403]) + test(`an upstream ${status} is a rejected key, and misses stop asking Mapillary (review IC8 P2)`, async () => { + await withUpstream( + () => new Response('{}', { status }), + async ({ calls, call }) => { + for (const tile of ['/coverage/9/5/5', '/coverage/9/6/6']) { + const res = await call('/api/mapillary/tiles', tile); + assert.equal(res.statusCode, 403); + assert.deepEqual(json(res), { + error: 'Mapillary rejected the access token', + keyRejected: true, + }); + assert.doesNotMatch(String(res.body), /MLY\|/); + } + assert.equal(calls.length, 1, 'the second miss is answered locally'); + // A new token is asked at once. + process.env.MAPILLARY_CLIENT_TOKEN = 'MLY|new|token'; + await call('/api/mapillary/tiles', '/coverage/9/7/7'); + assert.equal(calls.length, 2); + }, + ); + }); + +test('a 429 passes its Retry-After on and holds misses until it is over (review IC8 P2)', async (t) => { + await withUpstream( + () => new Response('{}', { status: 429, headers: { 'Retry-After': '30' } }), + async ({ calls, call }) => { + const first = await call('/api/mapillary/tiles', '/coverage/9/5/5'); + assert.equal(first.statusCode, 429); + assert.equal(first.headers['retry-after'], '30'); + assert.deepEqual(json(first), { + error: 'Mapillary is rate-limiting tile requests', + retryAfter: 30, + }); + const held = await call('/api/mapillary/tiles', '/coverage/9/6/6'); + assert.equal(held.statusCode, 429); + assert.equal(calls.length, 1, 'held: Mapillary is not asked again'); + const now = Date.now(); + t.mock.method(Date, 'now', () => now + 31_000); + await call('/api/mapillary/tiles', '/coverage/9/6/6'); + assert.equal(calls.length, 2, 'asked again once the wait is over'); + }, + ); +}); + test('normalizeTileAddress enforces layer names, zoom ranges and tile bounds', () => { assert.equal( normalizeTileAddress({ layer: 'coverage', z: 3, x: 1, y: 2 }).key, diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index 68602d4b0..8879b5784 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -65,6 +65,8 @@ function formatDate(ms) { function presentStatus(state) { const pressed = state.enabled === true; const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on'; + if (state.keyRejected) + return { text: 'KEY REJECTED', tone: 'warn', pressed, title }; if (state.keyRequired) return { text: 'KEY REQUIRED', tone: 'warn', pressed, title }; if (state.coverage.loading) @@ -85,7 +87,9 @@ function presentProviders(state) { const keyRequired = provider.keyRequired === true; const on = enabled && provider.on === true; let title = `${provider.name} imagery ${on ? 'on' : 'off'}`; - if (keyRequired && provider.requiresKeyId) + if (provider.keyRejected && provider.error) + title = `${provider.name}: ${provider.error}`; + else if (keyRequired && provider.requiresKeyId) title = `${provider.name}: ${keySetupRequirement(provider.requiresKeyId)}`; else if (provider.error) title = `${provider.name}: ${provider.error}`; return { diff --git a/src/ui/streetLevelPresentation.test.mjs b/src/ui/streetLevelPresentation.test.mjs index 5ecfe3884..2d66d86dc 100644 --- a/src/ui/streetLevelPresentation.test.mjs +++ b/src/ui/streetLevelPresentation.test.mjs @@ -78,6 +78,26 @@ test('a key-gated layer disables every control and flags KEY REQUIRED', () => { assert.equal(view.status.tone, 'warn'); }); +test('a key Mapillary rejected reads KEY REJECTED and names the fix (review IC8 P2)', () => { + const error = + 'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings'; + const view = presentStreetLevelPanel( + snapshot({ + enabled: true, + keyRequired: true, + keyRejected: true, + coverage: { error }, + providers: [provider({ keyRequired: true, keyRejected: true, error })], + }), + ); + assert.equal(view.status.text, 'KEY REJECTED'); + assert.equal(view.status.tone, 'warn'); + assert.equal(view.controlsDisabled, true); + assert.equal(view.error, error); + assert.equal(view.providers[0].state, 'error'); + assert.equal(view.providers[0].title, `Mapillary: ${error}`); +}); + test('the header pill is the on/off switch and reads OFF, LOADING or ON', () => { assert.deepEqual(presentStreetLevelPanel(snapshot()).status, { text: 'OFF',