mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
fix(street-level): load the coverage in view, and name a rejected token
Tilted views: the visible box came from screen rays against the bare
ellipsoid and the 9 tiles kept were the ones nearest its centre, so a view
over high ground (Denver, 1,600 m up) or near the horizon dropped the
streets in front of the camera. At street zooms the rays now meet an
ellipsoid raised to the ground under the camera, hits beyond a range tied
to camera height are ignored, tilted views also keep the ground around the
camera, and tiles are ranked along the line of sight (nadir to screen
centre). Simulated with real Cesium ray maths over Sacramento and Denver
from 2 m to 1,000 m: the nearest visible ground and the screen centre are
always loaded.
Rejected token: Mapillary's 401/403 reached the panel as a raw "Mapillary
tiles HTTP 403" while the pill said ON, and every pan asked Mapillary again.
The proxy now answers a refusal as {keyRejected: true} and holds it for that
token (5 min), and a 429 with its Retry-After (held until then). The panel
reads KEY REJECTED, names MAPILLARY_CLIENT_TOKEN and Provider Settings,
gates its controls like KEY REQUIRED, requests nothing more, and clears the
message when the layer goes off. A rate limit keeps the drawn tiles and
refreshes once the wait is over.
Found in the IC8 review of #768 (P1 tile selection, P2 rejected token).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
57c58a862a
commit
63280e22c0
19 files changed
+728
-43
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<ProviderSnapshot>} 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+125
-17
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user