mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +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
@@ -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',
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user