mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
docs(street-level): shorten docs and comments before merge
Trim the doc text this PR adds (README, CHANGELOG, CURRENT-STATE, KNOWN-ISSUES, SECURITY, DATA_SOURCES, .env.example, the CI job comment), drop code comments that restate the code, cut design-note JSDoc to a line or two, and drop review tags from test names. Comments only; no code changes. Also: README key count (seven keys, two metered), CURRENT-STATE's gate paragraph (CI runs a production build with a dummy token), the stacked doc above meshBoxInRange and the orphaned inMeshRange line in coverage.js, and a duplicate ALPR row in DATA_SOURCES that a merge had brought back. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c65feda4b1
commit
4ea6631425
85 files changed
+701
-1520
No files matched your search
@@ -41,11 +41,7 @@ const REMESH_INTERVAL_MS = 1500;
|
||||
const REMESH_STAGGER_MS = 120;
|
||||
/** Old-zoom tiles are kept at most this long after a zoom change. */
|
||||
const STALE_TILE_MAX_MS = 6000;
|
||||
/**
|
||||
* Sequences per ground primitive. Ground polyline geometry is built on a
|
||||
* worker in proportion to the instance count, so smaller batches put the
|
||||
* first lines on screen sooner instead of one big batch arriving late.
|
||||
*/
|
||||
/** Sequences per primitive: smaller batches build, and show, sooner. */
|
||||
const SEQUENCE_PRIMITIVE_BATCH = 120;
|
||||
|
||||
const PER_TILE_SEQUENCE_CAP = Math.floor(
|
||||
@@ -71,12 +67,7 @@ function partIds(sequence) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequence id for a picked line, whichever part was hit: `mly:seq:<id>` and
|
||||
* `mly:seq:<id>~<part>` both give `<id>`; null for any other pick.
|
||||
* @param {string} pickId
|
||||
* @returns {string|null}
|
||||
*/
|
||||
/** Sequence id for any part's pick id (`mly:seq:<id>[~<part>]`), else null. */
|
||||
export function sequenceIdFromPick(pickId) {
|
||||
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX.sequence))
|
||||
return null;
|
||||
@@ -86,22 +77,11 @@ export function sequenceIdFromPick(pickId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera-driven coverage: z0–5 `overview` points from orbit down to 60 km,
|
||||
* then z11–14 sequence polylines clamped to terrain and 3D tiles. Decoded
|
||||
* tiles are kept so the imagery filter can rebuild without refetching.
|
||||
*
|
||||
* In the core's terrain surface mode (Google 3D at street zoom) a tile's
|
||||
* lines are cast to the bare earth instead: draped lines would land on roofs
|
||||
* and tree tops. A tile is drawn draped first and swapped for its cast lines
|
||||
* once the terrain heights are in, so coverage never waits on the terrain.
|
||||
*/
|
||||
/**
|
||||
* Whether any point of a line's bounding `box` is within the mesh sampler's
|
||||
* range of the camera ground point `centre`, measured the short way round
|
||||
* the date line.
|
||||
* Whether any point of `box` ({west, south, east, north}) is within the mesh
|
||||
* sampler's range of `centre`, the ground point under the camera.
|
||||
*/
|
||||
export function meshBoxInRange(box, centre) {
|
||||
// Take the camera's longitude to the box's side of the date line first:
|
||||
// Move the camera's longitude to the box's side of the date line first:
|
||||
// clamping 179.999 into a box at -179.99 would pick its far edge.
|
||||
const lon =
|
||||
centre.lon +
|
||||
@@ -113,6 +93,12 @@ export function meshBoxInRange(box, centre) {
|
||||
return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera-driven coverage: overview points (z0–5) from orbit, sequence lines
|
||||
* (z11–14) near the ground. In terrain surface mode a tile is drawn draped
|
||||
* first, then swapped for lines cast to the bare earth (draped lines would
|
||||
* land on roofs), so coverage never waits on terrain heights.
|
||||
*/
|
||||
export function createCoverage({ state, source }) {
|
||||
const { render } = state.services;
|
||||
|
||||
@@ -142,9 +128,8 @@ export function createCoverage({ state, source }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The sequences a tile draws: newest first, past the imagery filter, then
|
||||
* capped. Capping before the filter could leave a dense tile of newer flat
|
||||
* captures with no 360° lines at all.
|
||||
* A tile's sequences, filtered then capped: capping first could leave a
|
||||
* dense tile of newer flat captures with no 360° lines at all.
|
||||
*/
|
||||
function drawnSequences(entry) {
|
||||
const drawn = [];
|
||||
@@ -157,11 +142,9 @@ export function createCoverage({ state, source }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Primitives for a tile's sequences, in draw batches: ground primitives
|
||||
* draped on the globe, plus (in terrain mode) plain polylines at the cast
|
||||
* heights for every part whose heights are cached. Each part of a sequence
|
||||
* with a capture gap is its own line. Each batch remembers the selection
|
||||
* its colours were built with.
|
||||
* Batched primitives for a tile's sequence parts: cast polylines where
|
||||
* terrain heights are cached, draped ground lines otherwise. Each batch
|
||||
* records the selection its colours were built with.
|
||||
*/
|
||||
function buildSequencePrimitives(sequences) {
|
||||
const ground = terrainMode() ? state.context.groundCaster : null;
|
||||
@@ -233,13 +216,11 @@ export function createCoverage({ state, source }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the terrain heights a tile's lines need, then redraw the tile cast.
|
||||
* `castRequested` holds while the tile's castable lines are cast; a cast
|
||||
* that resolves nothing (terrain proxy down, tile too big) clears it, so
|
||||
* the next refresh tries again rather than this retrying in a loop.
|
||||
* Fetch the terrain heights a tile's lines need, then redraw it cast. A
|
||||
* cast that resolves nothing clears `castRequested`, so the next refresh
|
||||
* retries instead of this looping.
|
||||
*/
|
||||
function castTile(entry) {
|
||||
// `castAbort` is set while a cast is in flight.
|
||||
if (entry.castRequested || entry.castAbort || !terrainMode()) return;
|
||||
entry.castRequested = true;
|
||||
entry.castAbort = new AbortController();
|
||||
@@ -264,10 +245,7 @@ export function createCoverage({ state, source }) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a tile's lines (cast, or with new mesh heights), keeping the old
|
||||
* ones on screen until the new ones are built, so nothing blinks.
|
||||
*/
|
||||
/** Rebuild a tile's lines; the old ones stay up until the new are ready. */
|
||||
function redrawTile(entry) {
|
||||
const previous = entry.primitives;
|
||||
entry.primitives = [];
|
||||
@@ -275,7 +253,7 @@ export function createCoverage({ state, source }) {
|
||||
removeWhenReady(entry, previous);
|
||||
}
|
||||
|
||||
/** The point under the camera: where the mesh sampler measures its range from. */
|
||||
/** Lon/lat under the camera, where mesh range is measured from. */
|
||||
function meshCentre() {
|
||||
const carto = state.viewer?.camera?.positionCartographic;
|
||||
if (!carto) return null;
|
||||
@@ -285,11 +263,9 @@ export function createCoverage({ state, source }) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether any of a {west, south, east, north} box is within the sampler's range. */
|
||||
|
||||
/**
|
||||
* A tile's drawn lines with their bounding boxes, built once per filter;
|
||||
* each line's mesh points are densified the first time it is in range.
|
||||
* A tile's drawn lines with bounding boxes, built once per filter; mesh
|
||||
* points are densified the first time a line is in range.
|
||||
*/
|
||||
function meshParts(entry) {
|
||||
if (entry.meshParts) return entry.meshParts;
|
||||
@@ -332,10 +308,7 @@ export function createCoverage({ state, source }) {
|
||||
if (points.length) sampler.request(points);
|
||||
}
|
||||
|
||||
/**
|
||||
* New mesh samples landed: redraw the tiles they fall in, throttled, with
|
||||
* the same no-blink swap as the bare-earth cast.
|
||||
*/
|
||||
/** Redraw the tiles new mesh samples fall in, throttled. */
|
||||
function onMeshSampled(batch) {
|
||||
if (!terrainMode() || !state.context.isActive()) return;
|
||||
for (const entry of state.coverage.tiles.values()) {
|
||||
@@ -543,9 +516,8 @@ export function createCoverage({ state, source }) {
|
||||
});
|
||||
if (kind === 'sequence') await ensureTerrainReady();
|
||||
const bytes = await fetching;
|
||||
// A refresh that still wants this tile must not throw the bytes away;
|
||||
// only an abort (tile no longer wanted, or zoom changed) does.
|
||||
// A retire, clear or newer request for this tile owns it now.
|
||||
// Only an abort, a retire, a clear or a newer request for this tile
|
||||
// discards the bytes; a refresh that still wants the tile keeps them.
|
||||
const current = () =>
|
||||
state.coverage.pending.get(key) === controller &&
|
||||
!state.coverage.tiles.has(key);
|
||||
@@ -644,10 +616,7 @@ export function createCoverage({ state, source }) {
|
||||
requestRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Move every loaded tile to the stale set instead of removing it, so the
|
||||
* old zoom stays visible while the new zoom streams in (no blank globe).
|
||||
*/
|
||||
/** Move tiles to the stale set: the old zoom shows until the new one loads. */
|
||||
function retire() {
|
||||
for (const controller of state.coverage.pending.values())
|
||||
controller.abort();
|
||||
@@ -812,9 +781,8 @@ export function createCoverage({ state, source }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Redraw loaded tiles draped or cast after the core's surface mode changes,
|
||||
* then re-pick the zoom: the bare-earth height under the camera that the
|
||||
* change brings in can move it (a high city reads much closer to the street).
|
||||
* Redraw tiles draped or cast for the new surface mode, then re-pick the
|
||||
* zoom, since the ground height under the camera may have changed.
|
||||
*/
|
||||
function setSurface() {
|
||||
if (!state.context.isActive()) return;
|
||||
@@ -839,10 +807,7 @@ export function createCoverage({ state, source }) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every tile drawn on the globe: the current zoom's, and the old zoom's
|
||||
* kept visible until the new one is in (see retire).
|
||||
*/
|
||||
/** Every tile on the globe: the current zoom's and the stale zoom's. */
|
||||
function drawnEntries() {
|
||||
return [...state.coverage.tiles.values(), ...state.coverage.stale.values()];
|
||||
}
|
||||
@@ -886,10 +851,8 @@ export function createCoverage({ state, source }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Colours are baked in when a tile's lines are built, and a primitive
|
||||
* cannot be recoloured until it is ready. Cast swaps and remeshes rebuild
|
||||
* tiles all the time, so while any primitive is building, catch each one
|
||||
* up with the selection made or cleared meanwhile as it becomes ready.
|
||||
* A primitive cannot be recoloured until it is ready, so while any is
|
||||
* building, catch each one up with the selection as it becomes ready.
|
||||
*/
|
||||
function watchSelection() {
|
||||
const scene = state.viewer?.scene;
|
||||
|
||||
@@ -171,7 +171,7 @@ function frame(listeners) {
|
||||
for (const listener of [...listeners]) listener();
|
||||
}
|
||||
|
||||
test('a superseded tile request cannot strand lines on the globe (review P0 #2)', async () => {
|
||||
test('a superseded tile request cannot strand lines on the globe', async () => {
|
||||
const { viewer, source, state, coverage, bytes, tileKey, onGlobe } = setup();
|
||||
const forTile = () => source.calls.filter((call) => call.key === tileKey);
|
||||
|
||||
@@ -304,7 +304,7 @@ 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 () => {
|
||||
test('a rejected key stops coverage requests until the layer goes off', async () => {
|
||||
const { viewer, source, state, coverage } = setup();
|
||||
coverage.refresh();
|
||||
const rejected = Object.assign(
|
||||
@@ -330,7 +330,7 @@ test('a rejected key stops coverage requests until the layer goes off (review IC
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a rate limit keeps the drawn tiles and asks again once the wait is over (review IC8 P2)', async (t) => {
|
||||
test('a rate limit keeps the drawn tiles and asks again once the wait is over', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const { viewer, source, state, coverage } = setup();
|
||||
coverage.refresh();
|
||||
@@ -566,7 +566,7 @@ test('a selection made or cleared while a tile builds is applied once it is read
|
||||
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
|
||||
});
|
||||
|
||||
test('a sequence that crosses a tile edge is counted once (review P3)', async () => {
|
||||
test('a sequence that crosses a tile edge is counted once', async () => {
|
||||
const { viewer, source, coverage, centre } = setup();
|
||||
// Look straight down on the edge between this tile and its east neighbour.
|
||||
const { east } = tileBounds(centre.tile.x, centre.tile.y, 14);
|
||||
@@ -598,10 +598,7 @@ test('a sequence that crosses a tile edge is counted once (review P3)', async ()
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
/**
|
||||
* In terrain mode with a cast tile mid-swap: the draped lines and the cast
|
||||
* primitive whose readiness the test controls.
|
||||
*/
|
||||
/** A terrain-mode tile mid-swap, with a cast primitive the test readies. */
|
||||
async function midSwap() {
|
||||
// Drawn draped first; castLine only answers once the heights are in.
|
||||
let heightsReady = false;
|
||||
@@ -665,9 +662,8 @@ function tiltedCamera({ lon, lat, height, heading, pitch }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Denver, 300 m above a street 1,600 m above the ellipsoid, at the centre of
|
||||
* a z14 tile and looking east with the screen centre `aheadM` metres out.
|
||||
* Google 3D hides the globe, so only the bare-earth caster knows the ground.
|
||||
* Denver: 300 m above a street 1,600 m up, at a z14 tile centre, looking east
|
||||
* at ground `aheadM` metres out. Only the bare-earth caster knows the ground.
|
||||
*/
|
||||
function denverStreetView(aheadM) {
|
||||
const z = 14;
|
||||
@@ -735,7 +731,7 @@ test('a street view toward the horizon ranks only the ground within range', () =
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a selection cleared while the old zoom is still shown uncolours its lines (review IC8 P2)', async () => {
|
||||
test('a selection cleared while the old zoom is still shown uncolours its lines', async () => {
|
||||
const { viewer, source, state, coverage, bytes } = setup();
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
@@ -760,7 +756,7 @@ test('a selection cleared while the old zoom is still shown uncolours its lines
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a selection made while the old zoom is still building is applied once it is ready (review IC8 P2)', async () => {
|
||||
test('a selection made while the old zoom is still building is applied once it is ready', async () => {
|
||||
const { viewer, source, state, coverage, bytes } = setup();
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(bytes);
|
||||
@@ -784,7 +780,7 @@ test('a selection made while the old zoom is still building is applied once it i
|
||||
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
|
||||
});
|
||||
|
||||
test('only lines within the mesh sampler range of the camera are asked for (review IC8 P2)', async () => {
|
||||
test('only lines within the mesh sampler range of the camera are asked for', async () => {
|
||||
let heightsReady = false;
|
||||
const groundCaster = {
|
||||
prepareLines: async () => (heightsReady = true),
|
||||
@@ -874,7 +870,7 @@ test('the old zoom goes as soon as the new zoom has loaded, not after the stale
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('a rate limit holds every refresh inside its wait, not only the first (review IC8 P2)', async (t) => {
|
||||
test('a rate limit holds every refresh inside its wait, not only the first', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
const { viewer, source, coverage } = setup();
|
||||
coverage.refresh();
|
||||
@@ -898,7 +894,7 @@ test('a rate limit holds every refresh inside its wait, not only the first (revi
|
||||
coverage.resetErrors();
|
||||
});
|
||||
|
||||
test('a line across the date line from the camera is in mesh range (gekh P3)', () => {
|
||||
test('a line across the date line from the camera is in mesh range', () => {
|
||||
// A z14 tile's width of lines just past -180, the camera 111 m short of it.
|
||||
const camera = { lon: 179.999, lat: 0 };
|
||||
const east = { west: -180, east: -179.978, south: -0.001, north: 0.001 };
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { PbfWriter } from 'pbf';
|
||||
|
||||
/**
|
||||
* Encode a Mapillary-shaped coverage tile (mly1_public: `sequence` lines and
|
||||
* `overview` points) from lon/lat features, for tests and the hermetic QA
|
||||
* gate. The decoder in ./decode.js reads it like a real tile.
|
||||
*/
|
||||
|
||||
const zigzag = (value) => (value << 1) ^ (value >> 31);
|
||||
|
||||
/** Tile-local [x, y] (0..extent) of a lon/lat inside tile (x, y, z). */
|
||||
@@ -67,7 +61,7 @@ function layerWriter(name, extent) {
|
||||
};
|
||||
}
|
||||
|
||||
/** MoveTo/LineTo commands for line parts, with the cursor carried across parts. */
|
||||
/** MVT MoveTo/LineTo commands; the cursor carries over between parts. */
|
||||
function lineGeometry(parts) {
|
||||
const out = [];
|
||||
let cx = 0;
|
||||
@@ -89,9 +83,10 @@ function lineGeometry(parts) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a `mly1_public`-shaped tile from lon/lat features, for tests and
|
||||
* the hermetic QA gate.
|
||||
* @param {{x: number, y: number, z: number}} tile
|
||||
* @param {{sequences?: Array<{id: string, capturedAt?: number, isPano?: boolean, parts: Array<Array<[number, number]>>}>, overview?: Array<{id: string, lon: number, lat: number, capturedAt?: number, isPano?: boolean}>, extent?: number}} [content]
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
export function encodeCoverageTile(
|
||||
tile,
|
||||
|
||||
@@ -3,13 +3,10 @@ import { VectorTile } from '@mapbox/vector-tile';
|
||||
import { tileLocalToLonLat } from '../../tileMath.js';
|
||||
|
||||
/**
|
||||
* Decode a Mapillary coverage tile (`mly1_public`) in the browser.
|
||||
* Sequences are LineStrings with capture metadata; z0–5 tiles carry
|
||||
* `overview` points instead. The z14 `image` layer (it can hold >150k
|
||||
* points) is never read.
|
||||
* Decode a `mly1_public` coverage tile into sequences and (z0–5) overview
|
||||
* points. The z14 `image` layer, which can hold >150k points, is skipped.
|
||||
* @param {Uint8Array} bytes
|
||||
* @param {{x:number,y:number,z:number}} address
|
||||
* @returns {{sequences: Array<object>, overview: Array<object>}}
|
||||
*/
|
||||
export function decodeCoverageTile(bytes, address) {
|
||||
const result = { sequences: [], overview: [] };
|
||||
@@ -44,7 +41,6 @@ export function decodeCoverageTile(bytes, address) {
|
||||
});
|
||||
}
|
||||
}
|
||||
// z0–5 tiles carry an `overview` point layer instead of sequences.
|
||||
const overviewLayer = tile.layers.overview;
|
||||
if (overviewLayer) {
|
||||
for (let i = 0; i < overviewLayer.length; i++) {
|
||||
|
||||
@@ -23,19 +23,16 @@ function createProviderState(context) {
|
||||
services: context.services,
|
||||
viewer: null,
|
||||
keyRequired: false,
|
||||
/**
|
||||
* False until the first status answer: coverage asks for nothing before
|
||||
* it, so a key-less install never sends tile requests that get 503.
|
||||
*/
|
||||
/** Coverage waits for the first status: no tile requests without a key. */
|
||||
statusKnown: false,
|
||||
/** Mapillary refused the configured token: nothing is requested until re-enabled. */
|
||||
/** Mapillary refused the token: request nothing until re-enabled. */
|
||||
keyRejected: false,
|
||||
status: null,
|
||||
coverage: {
|
||||
zoom: null,
|
||||
/** @type {Map<string, {primitive: object|null, sequences: Map<string, object>, count: number}>} */
|
||||
/** Current zoom's tiles by `z/x/y` key. */
|
||||
tiles: new Map(),
|
||||
/** Tiles from the previous zoom, kept on screen until replacements land. */
|
||||
/** Previous zoom's tiles, kept on screen until replacements land. */
|
||||
stale: new Map(),
|
||||
staleTimer: null,
|
||||
/** Tile key → its request's controller; any entry means LOADING. */
|
||||
@@ -53,7 +50,7 @@ function createProviderState(context) {
|
||||
sequence: {
|
||||
selectedId: null,
|
||||
images: [],
|
||||
/** @type {Map<string, Array<object>>} recent sequences' thinned images */
|
||||
/** Recent sequences' thinned images, by sequence id. */
|
||||
cache: new Map(),
|
||||
collection: null,
|
||||
loading: false,
|
||||
@@ -63,9 +60,8 @@ function createProviderState(context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapillary as a Street Level provider: coverage from cached vector tiles,
|
||||
* per-sequence image cones from the Graph API and the MapillaryJS viewer.
|
||||
* @param {{source: object}} options A source from ./source.js (or a stand-in with the same methods).
|
||||
* Mapillary Street Level provider: tile coverage, Graph API image cones and
|
||||
* the MapillaryJS viewer. `source` is ./source.js or a stand-in.
|
||||
* @returns {import('../../registry.js').StreetLevelProvider}
|
||||
*/
|
||||
export function createMapillaryProvider({ source }) {
|
||||
|
||||
@@ -24,10 +24,7 @@ export const PICK_PREFIX = Object.freeze({
|
||||
image: 'mly:img:',
|
||||
});
|
||||
|
||||
/**
|
||||
* Mapillary draws in one colour, its brand green, for lines, overview points
|
||||
* and cones alike (360° cones keep their ring shape); selection is GEV cyan.
|
||||
*/
|
||||
/** Mapillary green for lines, points and cones; GEV cyan for selection. */
|
||||
export const COLORS = Object.freeze({
|
||||
coverage: PROVIDER_COLORS.mapillary,
|
||||
selected: SHARED_COLORS.selected,
|
||||
@@ -45,9 +42,8 @@ export const KEY_REJECTED_MESSAGE =
|
||||
export const RATE_LIMITED_MESSAGE =
|
||||
'Coverage requests are being rate-limited — they resume on their own';
|
||||
/**
|
||||
* How far from the camera a street-zoom view may reach for coverage: a tilted
|
||||
* view sees to the horizon, but only the ground within this range is ranked
|
||||
* and boxed. Scales with camera height, never below the floor.
|
||||
* Street-zoom coverage reaches this far from the camera: camera height times
|
||||
* the factor, never below the floor, even when a tilted view sees further.
|
||||
*/
|
||||
export const SEQUENCE_VIEW_RANGE_MIN_M = 2_500;
|
||||
export const SEQUENCE_VIEW_RANGE_PER_HEIGHT = 10;
|
||||
@@ -65,10 +61,7 @@ export const IMAGE_CONE_MIN_SPACING_M = 3;
|
||||
/** Images asked for in a nearest-image search (radius: NEAREST_RADIUS_M). */
|
||||
export const NEAREST_LIMIT = 8;
|
||||
|
||||
/**
|
||||
* On-globe credit shown while the provider is active. Mapillary imagery and
|
||||
* derived data are CC BY-SA 4.0 and require visible attribution.
|
||||
*/
|
||||
/** On-globe credit: Mapillary imagery is CC BY-SA 4.0 and needs attribution. */
|
||||
export const MAPILLARY_CREDIT_HTML =
|
||||
'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, CC BY-SA 4.0';
|
||||
|
||||
|
||||
@@ -120,7 +120,7 @@ test('nearestImage picks the closest image, not the first the API returned', asy
|
||||
assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'near');
|
||||
});
|
||||
|
||||
test('a "since N days" window follows the clock in a long-open tab (review P3)', async (t) => {
|
||||
test('a "since N days" window follows the clock in a long-open tab', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: Date.UTC(2026, 9, 1) });
|
||||
const day = 86_400_000;
|
||||
const capturedAt = Date.now() - day / 2;
|
||||
@@ -140,7 +140,7 @@ test('a "since N days" window follows the clock in a long-open tab (review P3)',
|
||||
);
|
||||
});
|
||||
|
||||
test('nearestImage measures the short way round the date line (review IC8 P2)', async () => {
|
||||
test('nearestImage measures the short way round the date line', async () => {
|
||||
const at = (id, lon, lat) => ({
|
||||
id,
|
||||
is_pano: false,
|
||||
@@ -167,7 +167,7 @@ test('nearestImage measures the short way round the date line (review IC8 P2)',
|
||||
assert.equal(await west.nearestImage({ lat: 0, lon: -179.9999 }), 'across');
|
||||
});
|
||||
|
||||
test('cone thinning across the date line drops images metres apart (review IC8 P2)', () => {
|
||||
test('cone thinning across the date line drops images metres apart', () => {
|
||||
const image = (id, lon) => ({ id, lon, lat: 0 });
|
||||
// A sequence driving east over ±180° with images ~1 m apart, then ~11 m on.
|
||||
const kept = thinImages(
|
||||
@@ -220,7 +220,7 @@ function streetViewer() {
|
||||
};
|
||||
}
|
||||
|
||||
test('coverage asks for no tiles before the key status is known (gekh P3)', async () => {
|
||||
test('coverage asks for no tiles before the key status is known', async () => {
|
||||
for (const configured of [false, true]) {
|
||||
let answer;
|
||||
const tiles = [];
|
||||
|
||||
@@ -58,12 +58,8 @@ export function createSequences({ state, source, parts }) {
|
||||
state.context.notify();
|
||||
}
|
||||
|
||||
/**
|
||||
* The cones on the globe: each drawn image's index, billboard and height
|
||||
* (null while clamped), and the mesh cells under them, so a mesh sample
|
||||
* moves only the cones it can affect.
|
||||
*/
|
||||
const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() });
|
||||
/** Drawn cones, plus the mesh cells under them, so a sample moves only those. */
|
||||
let drawn = nothingDrawn();
|
||||
|
||||
// The cones skip the depth test: hide the ones behind the globe.
|
||||
@@ -105,11 +101,8 @@ export function createSequences({ state, source, parts }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Heights for the cones in terrain mode (Google 3D at street zoom), so they
|
||||
* stand on the street rather than on roofs and tree tops: bare earth,
|
||||
* refined along the sequence by the sampled Google 3D surface. Null when
|
||||
* draped, or when a terrain height is not cached yet; that fetches the
|
||||
* heights and draws the cones again.
|
||||
* Terrain-mode cone heights on the street, not on roofs: bare earth refined
|
||||
* by mesh samples. Null when draped or while terrain heights are fetched.
|
||||
*/
|
||||
function coneHeights(images, { request = true } = {}) {
|
||||
const ground = state.context.groundCaster;
|
||||
@@ -173,10 +166,8 @@ export function createSequences({ state, source, parts }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* New mesh samples landed: when any is under a cone, re-place the cones
|
||||
* whose cast height changed, in place. A sample can move a neighbour too
|
||||
* (refineHeights bridges across covered points), so every height is
|
||||
* recomputed (cheap arithmetic) but only moved cones are touched.
|
||||
* Re-place cones whose height new mesh samples changed. A sample can move
|
||||
* a neighbour too, so every height is recomputed but only moved cones touched.
|
||||
*/
|
||||
function onMeshSampled(batch) {
|
||||
if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length)
|
||||
|
||||
@@ -91,7 +91,7 @@ function setup() {
|
||||
return { state, source, sequences, colours, errors, drawn };
|
||||
}
|
||||
|
||||
test('a failed sequence load leaves no stale cones and can be retried (review P2-4)', async () => {
|
||||
test('a failed sequence load leaves no stale cones and can be retried', async () => {
|
||||
const { state, source, sequences, colours, errors, drawn } = setup();
|
||||
sequences.select('A');
|
||||
source.last('A').resolve(records('A'));
|
||||
@@ -174,9 +174,8 @@ test('clearSelection cancels the lookup and uncolours the sequence', async () =>
|
||||
});
|
||||
|
||||
/**
|
||||
* Terrain mode (Google 3D at street zoom): bare earth 10 m everywhere, a
|
||||
* mesh sampler whose samples the test sets and announces, and cones that
|
||||
* log every change made to them or their collection.
|
||||
* Terrain mode with bare earth at 10 m, a mesh sampler the test drives, and
|
||||
* cones that log every change made to them.
|
||||
*/
|
||||
function terrainSetup() {
|
||||
const context = setup();
|
||||
@@ -240,7 +239,7 @@ function terrainSetup() {
|
||||
return { ...context, sequences, changes, items, sample, heightOf };
|
||||
}
|
||||
|
||||
test('a mesh sample away from the selected sequence changes no cone (review IC8 P1)', async () => {
|
||||
test('a mesh sample away from the selected sequence changes no cone', async () => {
|
||||
const { source, sequences, changes, items, sample } = terrainSetup();
|
||||
sequences.select('A');
|
||||
source.last('A').resolve(records('A', 3));
|
||||
@@ -251,7 +250,7 @@ test('a mesh sample away from the selected sequence changes no cone (review IC8
|
||||
assert.deepEqual(changes, [], 'nothing rebuilt or moved');
|
||||
});
|
||||
|
||||
test('a mesh sample under a cone moves that cone in place, and no other (review IC8 P1)', async () => {
|
||||
test('a mesh sample under a cone moves that cone in place, and no other', async () => {
|
||||
const { source, sequences, changes, items, sample, heightOf } =
|
||||
terrainSetup();
|
||||
sequences.select('A');
|
||||
@@ -282,7 +281,7 @@ test('a mesh sample under a cone moves that cone in place, and no other (review
|
||||
assert.deepEqual([after[0], after[2]], [before[0], before[2]]);
|
||||
});
|
||||
|
||||
test('cones behind the globe are hidden, and show again from the other side (review IC8 P2)', async () => {
|
||||
test('cones behind the globe are hidden, and show again from the other side', async () => {
|
||||
const { state, source, sequences } = setup();
|
||||
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
|
||||
const preRender = new Set();
|
||||
|
||||
@@ -7,11 +7,7 @@ import {
|
||||
SEQUENCE_IMAGES_LIMIT,
|
||||
} from './policy.js';
|
||||
|
||||
/**
|
||||
* 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`).
|
||||
*/
|
||||
/** HTTP error flagged `keyRequired`, `keyRejected` or `retryAfterSec` (429). */
|
||||
export class MapillarySourceError extends Error {
|
||||
constructor(message, { status = 0, payload = null, retryAfterSec } = {}) {
|
||||
super(message);
|
||||
@@ -44,10 +40,8 @@ async function readJsonOrThrow(response, label) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every network path the Mapillary layer uses. Coverage tiles go through the
|
||||
* local dev-server proxy (which adds the token and caches them); image and
|
||||
* sequence lookups go straight to graph.mapillary.com with the client token,
|
||||
* exactly as the embedded MapillaryJS viewer does.
|
||||
* Mapillary network access: tiles via the local proxy, which adds the token
|
||||
* and caches; Graph API lookups direct with the client token, as MapillaryJS.
|
||||
* @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options
|
||||
*/
|
||||
export function createMapillarySource({
|
||||
|
||||
@@ -110,7 +110,7 @@ test('the source exposes only imagery lookups', () => {
|
||||
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 () => {
|
||||
test('a rejected key and a rate limit are told apart from other tile errors', async () => {
|
||||
const rejectedSource = createMapillarySource({
|
||||
token: 'MLY|1|abc',
|
||||
fetchImpl: fakeFetch(() =>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js';
|
||||
|
||||
/**
|
||||
* The Mapillary viewer adapter: lazy-load MapillaryJS, mount it in the host
|
||||
* the core hands over, open images, and emit a provider-neutral pose for
|
||||
* every image, point-of-view or position change.
|
||||
* Viewer adapter: lazy-loads MapillaryJS into the core's host and emits a
|
||||
* provider-neutral pose on every image, view or position change.
|
||||
* @returns {import('../../registry.js').ViewerAdapter}
|
||||
*/
|
||||
export function createMapillaryViewer({ source, render } = {}) {
|
||||
@@ -11,7 +10,6 @@ export function createMapillaryViewer({ source, render } = {}) {
|
||||
let Library = null;
|
||||
let container = null;
|
||||
let pendingOpen = null;
|
||||
/** The prewarm in flight, if any. */
|
||||
let prewarming = null;
|
||||
/** In-flight viewer construction, so pre-warm and open never build two. */
|
||||
let creating = null;
|
||||
@@ -34,10 +32,8 @@ export function createMapillaryViewer({ source, render } = {}) {
|
||||
|
||||
async function ensureLibrary() {
|
||||
if (Library) return Library;
|
||||
// The stylesheet comes with the lazy chunk: Vite applies a dynamically
|
||||
// imported stylesheet before its import resolves, so the viewer's chrome
|
||||
// is styled from the first frame without costing every page load ~90 KB
|
||||
// of render-blocking CSS.
|
||||
// Lazy CSS: Vite applies it before the import resolves, so the viewer is
|
||||
// styled from its first frame without ~90 KB of render-blocking CSS.
|
||||
const [library] = await Promise.all([
|
||||
import('mapillary-js'),
|
||||
import('mapillary-js/dist/mapillary.css'),
|
||||
@@ -196,14 +192,11 @@ export function createMapillaryViewer({ source, render } = {}) {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load the library and stand the viewer up ahead of the first image, so
|
||||
* opening one only costs the image download. Safe to call repeatedly.
|
||||
*/
|
||||
/** Build the viewer ahead of the first image. Safe to call repeatedly. */
|
||||
async prewarm(host) {
|
||||
if (!host) return;
|
||||
// A prewarm already in flight may give up (the layer went off and on
|
||||
// while the library downloaded): wait for it, then build if it did not.
|
||||
// An in-flight prewarm may give up (layer toggled mid-download): wait
|
||||
// for it, then build if it did not.
|
||||
if (prewarming) await prewarming;
|
||||
if (viewer) return;
|
||||
const run = (async () => {
|
||||
|
||||
Reference in new issue
Block a user