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:
Said TurkseverandClaude Opus 5.5 committed 2026-10-06 21:59:12 +01:00
1 parent c65feda4b1
commit 4ea6631425
85 files changed
+701 -1520

No files matched your search

+7 -20
View File
@@ -1,17 +1,14 @@
import * as Cesium from 'cesium';
import { FOLLOW_EYE_HEIGHT_M } from './policy.js';
/** Ellipsoidal heights a land surface can have (Dead Sea shore to Everest, with geoid slack). */
/** Plausible ellipsoidal land heights: Dead Sea to Everest, with geoid slack. */
const SURFACE_MIN_M = -500;
const SURFACE_MAX_M = 9000;
/** Rendered mesh window around the bare earth: roofs and bridges, not whole towers. */
const MESH_BELOW_DEM_M = 15;
const MESH_ABOVE_DEM_M = 80;
/**
* Drive the globe camera from the street-level pose: either continuously
* ("camera follows view") or once, framing the image from behind.
*/
/** Drive the globe camera from the street-level pose: continuously (follow) or once (framing). */
export function createCameraFollow({ state, parts }) {
const { render } = state.services;
@@ -20,11 +17,9 @@ export function createCameraFollow({ state, parts }) {
}
/**
* Ground under a photo for framing and following. The rendered surface is
* sampled first, but `sampleHeight` can return nonsense before the tiles
* under the point have loaded (kilometres below the ellipsoid), so a sample
* counts only when it is a plausible surface height and, once the bare-earth
* height is known, lies within the mesh window around it.
* Ground under a photo. `sampleHeight` can return kilometres below the
* ellipsoid before tiles load, so a sample counts only when plausible and
* within the mesh window around bare earth (once that is known).
*/
function groundHeightAt(lon, lat, fallback) {
const scene = state.viewer?.scene;
@@ -70,12 +65,7 @@ export function createCameraFollow({ state, parts }) {
requestRender();
}
/**
* The framing flight this module started, while it is still the camera's
* current flight. Cesium cancels it (calling `cancel`) when any other
* flight starts, so a stale token never cancels someone else's flight.
* @type {object|null}
*/
/** Our framing flight while it is current; Cesium calls `cancel` when another flight starts. */
let framing = null;
/** Frame the current image from a short distance behind it. */
@@ -108,10 +98,7 @@ export function createCameraFollow({ state, parts }) {
);
}
/**
* Stop the framing flight if it is still in the air (the image closed or
* the layer went off). A flight another feature started since is left be.
*/
/** Stop our framing flight if it is still in the air; another feature's flight is left alone. */
function cancelFraming() {
if (!framing) return;
// Give up ownership first: cancelFlight delivers `cancel` synchronously.
+6 -8
View File
@@ -9,10 +9,8 @@ const heightOf = (cartesian) =>
Cesium.Cartographic.fromCartesian(cartesian).height;
/**
* A camera with Cesium's flight bookkeeping: starting a flight cancels the
* current one (calling its `cancel`), `cancelFlight` cancels whichever flight
* is current, whoever started it, and `land()` completes it. `cancelled`
* counts the flights `cancelFlight` stopped.
* A camera with Cesium's flight bookkeeping: a new flight or `cancelFlight`
* cancels the current one, `land()` completes it, `cancelled` counts stops.
*/
function flightCamera(flights, views) {
let current = null;
@@ -43,8 +41,8 @@ function flightCamera(flights, views) {
}
/**
* A viewer whose scene sample is `sampled` (the Austin case: −14,886 m before
* the tiles under the photo had loaded) and whose bare earth is `dem`.
* A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles
* under the photo load) and whose bare earth is `dem`.
*/
function setup({ sampled, dem = null, globe = null, altitude = 149 }) {
const flights = [];
@@ -118,7 +116,7 @@ test('following stands the camera at eye height above the checked ground', () =>
);
});
test('cancelFraming stops the framing flight while it is still ours (review IC8 P1)', () => {
test('cancelFraming stops the framing flight while it is still ours', () => {
const { follow, state } = setup({ sampled: 121, dem: 117 });
const { camera } = state.viewer;
follow.lookAtPosition();
@@ -134,7 +132,7 @@ test('cancelFraming stops the framing flight while it is still ours (review IC8
assert.equal(camera.cancelled, 2);
});
test('cancelFraming leaves a landed flight and a newer navigation flight alone (review IC8 P1)', () => {
test('cancelFraming leaves a landed flight and a newer navigation flight alone', () => {
const { follow, state, flights } = setup({ sampled: 121, dem: 117 });
const { camera } = state.viewer;
follow.lookAtPosition();
+2 -3
View File
@@ -1,9 +1,8 @@
import * as Cesium from 'cesium';
/**
* One on-screen Cesium credit per active provider. Street-level imagery is
* typically CC BY-SA, which needs visible attribution that goes away with
* the imagery, so these are static credits added and removed per provider.
* One static Cesium credit per active provider: CC BY-SA imagery needs
* visible attribution that goes away with the imagery.
*/
export function createCredits() {
/** @type {Map<string, object>} provider id → Cesium.Credit */
+6 -6
View File
@@ -3,8 +3,8 @@ import { FILTER_DEFAULT, MAX_SINCE_DAYS, PANO_MODES } from './policy.js';
const DAY_MS = 86_400_000;
/**
* Merge a partial filter change into the current one. Unknown panorama modes
* and negative or non-integer day counts leave the current value alone.
* Merge a partial filter change; unknown pano modes and invalid day counts
* keep the current value.
* @param {{pano?: string, sinceDays?: number}|null} next
* @param {{pano: string, sinceDays: number}} [current]
* @returns {{pano: string, sinceDays: number}}
@@ -26,8 +26,8 @@ export function sameFilter(a, b) {
}
/**
* Turn the stored filter (relative days, stable in share links) into the
* absolute form providers compare capture times against.
* Stored filter (relative days, stable in share links) to the absolute form
* providers compare capture times against.
* @returns {{pano: string, sinceMs: number|null}}
*/
export function resolveFilter(filter, now = Date.now()) {
@@ -39,8 +39,8 @@ export function resolveFilter(filter, now = Date.now()) {
}
/**
* Whether one capture passes the resolved imagery filter: panorama mode
* ('all' | 'pano' | 'flat') and an optional earliest capture time.
* Whether a capture passes the resolved filter: pano mode and an optional
* earliest capture time.
* @param {{isPano?: boolean, capturedAt?: number}} record
* @param {{pano: string, sinceMs: number|null}|null} filter
*/
+2 -8
View File
@@ -1,8 +1,4 @@
/**
* Canvas glyphs for billboards: a camera cone for images, a ring for
* panoramas and a position marker for the street-level viewer. Pure canvas
* drawing so it can run without Cesium.
*/
/** Canvas billboard glyphs (image cone, pano ring, position marker); no Cesium needed. */
const _cache = new Map();
@@ -29,7 +25,6 @@ function wedge(ctx, c, radius, halfAngle, color, alpha) {
ctx.globalAlpha = 1;
}
/** A stroked circle around the centre. */
function ring(ctx, c, radius, color, width, alpha) {
ctx.beginPath();
ctx.arc(c, c, radius, 0, Math.PI * 2);
@@ -40,7 +35,6 @@ function ring(ctx, c, radius, color, width, alpha) {
ctx.globalAlpha = 1;
}
/** A filled dot at the centre with a dark outline. */
function dot(ctx, c, radius, color, outline, width) {
ctx.beginPath();
ctx.arc(c, c, radius, 0, Math.PI * 2);
@@ -71,7 +65,7 @@ export function imageConeGlyph({
});
}
/** Pulsing-looking marker for the image the viewer currently shows. */
/** Marker for the image the viewer currently shows. */
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => {
wedge(ctx, c, size * 0.48, 0.55, color, 0.55);
+25 -46
View File
@@ -1,17 +1,9 @@
/**
* Ground casting for street-level overlays on Google Photorealistic 3D.
*
* Draped lines and clamped billboards land on the top of the photoreal mesh,
* so on a street lined with trees or under an overpass the coverage appears
* to float at roof height. Casting places the overlays at the bare-earth
* height from the terrain service (DEM) instead, plus a small lift, and lets
* the mesh hide what is behind buildings.
*
* Heights are sampled on a coarse grid and interpolated, so one z14 coverage
* tile costs at most a few hundred terrain lookups, and the grid matches the
* ground-floor service (3 decimals) so cached cells are shared. Only real DEM
* heights count; a geoid fallback (terrain proxy down) is treated as unknown
* and the caller keeps its draped placement.
* Ground casting for street-level overlays on Google 3D. Draped overlays land
* on the mesh top (trees, overpasses), so casting places them at bare-earth
* (DEM) height plus a lift and lets the mesh hide what is behind buildings.
* Heights come from a coarse grid shared with the ground-floor service; a
* geoid fallback (terrain proxy down) counts as unknown and stays draped.
*/
import { wrapLon } from './tileMath.js';
@@ -28,15 +20,12 @@ export const GROUND_CAST_MAX_CORNERS = 1024;
const GROUND_CAST_CACHE_MAX = 50_000;
/**
* Mesh refinement. The bare-earth grid cannot see a freeway in a trench, a
* steep street between grid corners, or the road under a tree, so where the
* rendered Google 3D surface has been sampled it refines the height:
* - mesh at most MESH_ROAD_ABOVE_M above bare earth (and not absurdly far
* below it) is the road itself: follow it, MESH_LIFT_M above;
* - mesh higher than that is something over the road (canopy, deck, roof):
* carry the road's offset from bare earth across from the nearest road
* samples on both sides, within MESH_BRIDGE_MAX_POINTS;
* - otherwise bare earth plus GROUND_CAST_LIFT_M, as without the mesh.
* Mesh refinement, where the Google 3D surface was sampled (the DEM grid
* misses trenches, steep streets and roads under trees):
* - mesh up to MESH_ROAD_ABOVE_M above bare earth is the road: follow it;
* - higher mesh is over the road (canopy, deck): interpolate the road's
* offset from the nearest road samples on both sides;
* - otherwise bare earth plus GROUND_CAST_LIFT_M.
*/
export const MESH_ROAD_ABOVE_M = 3;
/** A sample this far below bare earth is a bad probe, not a road. */
@@ -49,8 +38,7 @@ export const MESH_BRIDGE_MAX_POINTS = 8;
export const MESH_DENSIFY_DEG = 0.0002;
/**
* Heights for a line's points from bare earth and, where sampled, the mesh.
* Pure, so the rule above is testable without a scene.
* Heights for a line's points by the rule above.
* @param {Array<{dem: number, mesh?: number|null}>} points
* @param {{lift?: number}} [options]
* @returns {Array<number>}
@@ -93,9 +81,8 @@ export const SURFACE_TERRAIN_ENTER_M = 1400;
export const SURFACE_TERRAIN_EXIT_M = 1800;
/**
* The next surface mode for the camera. Terrain mode applies only on Google
* 3D and only at street zoom; the gap between the enter and exit heights
* stops the mode from flapping while the camera hovers at the boundary.
* Next surface mode: terrain only on Google 3D at street zoom, with
* hysteresis between the enter and exit heights.
* @param {'draped'|'terrain'} current
* @param {{photoreal: boolean, heightM: number|null, available: boolean}} view
* @returns {'draped'|'terrain'}
@@ -108,9 +95,8 @@ export function nextSurfaceMode(current, { photoreal, heightM, available }) {
}
/**
* Split long segments so a line follows the terrain between its vertices.
* A segment across ±180° is split the short way round, and the points it
* adds are wrapped back into [-180, 180].
* Split long segments so a line follows the terrain. Segments across ±180°
* go the short way round, with added points wrapped into [-180, 180].
* @param {Array<[number, number]>} coords [lon, lat] pairs
* @param {number} [maxStep] longest segment, in degrees
* @returns {Array<[number, number]>}
@@ -172,9 +158,8 @@ export function createGroundCaster({
async function resolve(points, signal) {
if (signal?.aborted) return false;
// Make room first: evicting after counting would drop corners this
// request already treated as cached. Oldest first (a Map keeps insertion
// order), so the tiles cast last, likely still on screen, keep theirs.
// Evict before counting, or we could drop corners this request counted
// as cached. Oldest first, so the tiles still on screen keep theirs.
for (const key of heights.keys()) {
if (heights.size + maxCorners <= cacheMax) break;
heights.delete(key);
@@ -182,8 +167,7 @@ export function createGroundCaster({
const missing = missingCorners(points);
if (!missing.length) return true;
if (missing.length > maxCorners) return false;
// A corner east of 180° (the far corner of a point on the meridian) is
// asked for at its longitude on the other side.
// A corner east of 180° is asked for at its wrapped longitude.
const results = await terrain.resolveEllipsoidalGround(
missing.map(([, { i, j }]) => ({
lon: Number(wrapLon(i * step).toFixed(6)),
@@ -202,9 +186,8 @@ export function createGroundCaster({
}
/**
* Fetch the grid corners around the given points. Resolves true when every
* point can now be cast; false when some corners are unknown (terrain proxy
* down, too many corners, or aborted). Never rejects.
* Fetch the grid corners around the points. True when all can be cast;
* false when some are unknown, too many, or aborted. Never rejects.
* @param {Array<[number, number]>} points [lon, lat] pairs
* @param {{signal?: AbortSignal}} [options]
*/
@@ -215,10 +198,8 @@ export function createGroundCaster({
}
/**
* `prepare` for lines, including the points densifying adds. `castLine`
* densifies finer when mesh heights refine it, and those points can fall in
* grid cells the coarse points skip, so both sets are prepared: otherwise
* the cast comes back null and the line stays draped over roofs and trees.
* `prepare` for lines at both densities: the finer mesh points can fall in
* cells the coarse ones skip, and a missing cell leaves the line draped.
*/
function prepareLines(lines, options) {
return prepare(
@@ -250,10 +231,8 @@ export function createGroundCaster({
}
/**
* A line as flat [lon, lat, height, ...] degrees and metres, densified;
* null when any corner it needs is not cached yet. With `meshAt` (sampled
* mesh heights, undefined or null where unknown) the line is densified
* finer and its heights refined by `refineHeights`.
* Densified line as flat [lon, lat, height, ...]; null until every corner is
* cached. With `meshAt` it is densified finer and refined by `refineHeights`.
* @param {Array<[number, number]>} coords
* @param {{meshAt?: (lon: number, lat: number) => number|null|undefined}} [options]
*/
+7 -8
View File
@@ -83,7 +83,7 @@ test('densifyLine splits long segments and keeps the vertices', () => {
assert.deepEqual(densifyLine([[10, 50]]), [[10, 50]]);
});
test('densifyLine crosses the date line the short way and wraps what it adds (review IC8 P2)', () => {
test('densifyLine crosses the date line the short way and wraps what it adds', () => {
// 0.0002° apart: short enough to stay one segment, not 65 points via 0°.
const short = [
[179.9999, 0],
@@ -257,8 +257,8 @@ test('a full cache is cleared before a request counts its corners, so the reques
});
// One cell's four corners are cached...
assert.equal(await caster.prepare([[10.0005, 50.0005]]), true);
// ...then a line over that cell and the next needs two more. Clearing after
// counting would have kept only the two new corners and lost the first cell.
// ...then a line over that cell and the next needs two more, and the first
// cell must survive the eviction.
const line = [
[10.0002, 50.0005],
[10.0018, 50.0005],
@@ -304,10 +304,9 @@ test('cancelling a prepare cancels its terrain request', async () => {
assert.equal(seen[0], controller.signal, 'the tile signal reaches the proxy');
});
test('prepared lines always cast with the mesh, however their segments cross the grid (review IC8 P1)', async () => {
// Seeded walks with 50–300 m segments: the finer mesh densifying used to
// land in grid cells the coarse points skipped, so most of them came back
// null and stayed draped over roofs.
test('prepared lines always cast with the mesh, however their segments cross the grid', async () => {
// Seeded walks with 50–300 m segments: the finer mesh points can land in
// grid cells the coarse points skip.
let seed = 7;
const random = () => {
seed = (seed * 16807) % 2147483647;
@@ -332,7 +331,7 @@ test('prepared lines always cast with the mesh, however their segments cross the
assert.equal(failures, 0);
});
test('a line across the date line casts along the date line (review IC8 P2)', async () => {
test('a line across the date line casts along the date line', async () => {
// Ground that rises northward only, so both sides of ±180° agree.
const calls = [];
const terrain = {
+9 -27
View File
@@ -25,11 +25,8 @@ import {
export { STREET_LEVEL_LAYER_ID } from './policy.js';
/**
* Construct the Street Level layer from a list of imagery providers (see
* registry.js for the contract). The core owns what every provider shares:
* the enable state and per-provider switches, the imagery filter, one click
* handler, one viewer host, the position marker, camera follow, credits and
* share-link parameters. Providers own their coverage, sequences and viewer.
* The Street Level layer over imagery providers (contract in registry.js).
* The core owns what they share; providers own coverage, sequences, viewer.
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
*/
export function createStreetLevelLayer({
@@ -84,23 +81,17 @@ export function createStreetLevelLayer({
function providerContext(entry) {
return Object.freeze({
services: state.services,
/** The imagery filter resolved against now; read it when filtering. */
getFilter: () => resolveFilter(state.filter),
isActive: () => state.enabled && entry.on,
/** Bare-earth heights (see groundCast.js), or null without a terrain service. */
groundCaster: parts.groundCaster,
/** Sampled Google 3D surface heights refining the cast (meshSampler.js), or null. */
meshSampler: parts.meshSampler,
/** 'terrain' when overlays should sit on the bare earth, else 'draped'. */
getSurface: () => state.surface,
notify,
actions: {
openImage: (imageId) => openImage(entry.def.id, imageId),
/**
* Show a provider error, or (null) withdraw the one it showed. A
* withdrawal never clears an error the viewer or another provider
* has shown since, and a stale one cannot mask a later coverage
* error such as KEY REJECTED.
* Show a provider error, or withdraw it (null). A withdrawal clears
* only this provider's own error, never a newer one from elsewhere.
*/
reportError: (message) => {
if (message) {
@@ -143,9 +134,8 @@ export function createStreetLevelLayer({
}
/**
* Camera height above the bare earth under it, for the surface mode. The
* ground comes from the caster's coarse grid; a cold cell near enough to
* matter is fetched once and the mode checked again when it lands.
* Camera height above bare earth, for the surface mode. A cold grid cell is
* fetched once and the mode rechecked when it lands.
*/
function cameraHeightForSurface() {
const carto = state.viewer?.camera?.positionCartographic;
@@ -163,10 +153,7 @@ export function createStreetLevelLayer({
return height;
}
/**
* On Google 3D at street zoom, overlays are cast to the bare earth; draped
* lines would land on roofs and trees. Elsewhere they stay draped.
*/
/** Cast overlays to bare earth on Google 3D at street zoom; drape them elsewhere. */
function syncSurface() {
const available = Boolean(parts.groundCaster) && state.enabled;
const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
@@ -239,12 +226,7 @@ export function createStreetLevelLayer({
parts.credits.hide(state.viewer, entry.def);
}
/**
* The nearest-image lookup in flight. Anything newer the user asks for (a
* later lookup, a picked image, closing, the layer going off) aborts it,
* so its late answer can never replace that choice.
* @type {AbortController|null}
*/
/** The nearest-image lookup in flight; any newer user action aborts it. */
let nearestLookup = null;
function abortNearest() {
@@ -502,7 +484,7 @@ export function createStreetLevelLayer({
if (!state.enabled) break;
// This provider was switched off while it looked: try the next.
if (!imageId || !entry.on) continue;
nearestLookup = null; // answered: nothing left to abort
nearestLookup = null;
return openImage(entry.def.id, imageId);
}
if (nearestLookup === lookup) nearestLookup = null;
+11 -11
View File
@@ -242,7 +242,7 @@ test('switching a provider off deactivates it and closes the image it shows', as
assert.equal(layer.getUIState().street.providerId, null);
});
test('openNearest without the panel reports it instead of loading forever (review P3)', async (t) => {
test('openNearest without the panel reports it instead of loading forever', async (t) => {
const provider = fakeProvider({ nearestImage: async () => 'img1' });
const { layer } = await enabledLayer(t, [provider]);
assert.equal(await layer.openNearest({ lat: 38.58, lon: -121.49 }), false);
@@ -252,7 +252,7 @@ test('openNearest without the panel reports it instead of loading forever (revie
assert.equal(provider.calls.mount, 0);
});
test('openNearest opens nothing once the layer went off during the lookup (review P3)', async (t) => {
test('openNearest opens nothing once the layer went off during the lookup', async (t) => {
const answer = deferred();
const provider = fakeProvider({ nearestImage: () => answer.promise });
const { layer } = await enabledLayer(t, [provider]);
@@ -268,7 +268,7 @@ test('openNearest opens nothing once the layer went off during the lookup (revie
assert.equal(street.error, null);
});
test('openNearest skips a provider switched off during its lookup (review P3)', async (t) => {
test('openNearest skips a provider switched off during its lookup', async (t) => {
const answer = deferred();
const provider = fakeProvider({ nearestImage: () => answer.promise });
const { layer } = await enabledLayer(t, [provider]);
@@ -281,7 +281,7 @@ test('openNearest skips a provider switched off during its lookup (review P3)',
assert.equal(layer.getUIState().street.loading, false);
});
test('a provider withdraws only the error it reported (review P3)', async (t) => {
test('a provider withdraws only the error it reported', async (t) => {
const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]);
const { actions } = provider.context();
@@ -298,7 +298,7 @@ test('a provider withdraws only the error it reported (review P3)', async (t) =>
assert.match(layer.getUIState().street.error, /Open the Street Level panel/);
});
test('closing the photo stops the globe flying to it (review IC8 P1)', async (t) => {
test('closing the photo stops the globe flying to it', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
layer.attachViewerHost({});
assert.equal(await layer.openImage('mapillary', 'img1'), true);
@@ -307,7 +307,7 @@ test('closing the photo stops the globe flying to it (review IC8 P1)', async (t)
assert.equal(viewer.flights.cancelled, 1);
});
test('switching the layer off or destroying it stops the framing flight (review IC8 P1)', async (t) => {
test('switching the layer off or destroying it stops the framing flight', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
layer.attachViewerHost({});
await layer.openImage('mapillary', 'img1');
@@ -320,7 +320,7 @@ test('switching the layer off or destroying it stops the framing flight (review
assert.equal(viewer.flights.cancelled, 2, 'destroyed');
});
test('closing the photo leaves a newer navigation flight alone (review IC8 P1)', async (t) => {
test('closing the photo leaves a newer navigation flight alone', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
layer.attachViewerHost({});
await layer.openImage('mapillary', 'img1');
@@ -331,7 +331,7 @@ test('closing the photo leaves a newer navigation flight alone (review IC8 P1)',
assert.equal(viewer.flights.cancelled, 0, 'the search flight keeps going');
});
test('an older nearest lookup that answers late cannot replace a newer one (review IC8 P2)', async (t) => {
test('an older nearest lookup that answers late cannot replace a newer one', async (t) => {
const { lookups, nearestImage } = slowLookups();
const provider = fakeProvider({ nearestImage });
const { layer } = await enabledLayer(t, [provider]);
@@ -347,7 +347,7 @@ test('an older nearest lookup that answers late cannot replace a newer one (revi
assert.equal(lookups[1].signal?.aborted, false);
});
test('an image picked during a nearest lookup wins over its late answer (review IC8 P2)', async (t) => {
test('an image picked during a nearest lookup wins over its late answer', async (t) => {
const { lookups, nearestImage } = slowLookups();
const provider = fakeProvider({ nearestImage });
const { layer } = await enabledLayer(t, [provider]);
@@ -361,7 +361,7 @@ test('an image picked during a nearest lookup wins over its late answer (review
assert.equal(lookups[0].signal?.aborted, true);
});
test('closing the viewer or switching the layer off retires a nearest lookup (review IC8 P2)', async (t) => {
test('closing the viewer or switching the layer off retires a nearest lookup', async (t) => {
const { lookups, nearestImage } = slowLookups();
const provider = fakeProvider({ nearestImage });
const { layer, viewer } = await enabledLayer(t, [provider]);
@@ -393,7 +393,7 @@ test('closing the viewer or switching the layer off retires a nearest lookup (re
assert.equal(lookups[1].signal?.aborted, true, 'layer off aborts it too');
});
test('the Mapillary provider hands the lookup signal to its source (review IC8 P2)', async () => {
test('the Mapillary provider hands the lookup signal to its source', async () => {
const requests = [];
const source = {
hasToken: () => true,
+3 -8
View File
@@ -6,11 +6,7 @@ import { createHorizonCull } from './view.js';
const SPRITE_ID = 'street-level:marker';
/**
* The on-globe marker for the image the viewer currently shows. In terrain
* mode (Google 3D at street zoom) it stands on the bare earth instead of
* clamping to the top of the photoreal mesh.
*/
/** On-globe marker for the open image; in terrain mode it stands on bare earth. */
export function createMarker({ state, parts }) {
const { render, sprites } = state.services;
let last = null;
@@ -36,9 +32,8 @@ export function createMarker({ state, parts }) {
}
/**
* Height for the position in terrain mode: bare earth, refined by the
* sampled Google 3D surface where it is known; null to clamp. Fetches a
* missing terrain cell and asks for a mesh sample under the marker.
* Terrain-mode height (bare earth refined by mesh samples), or null to
* clamp. Requests a missing terrain cell or mesh sample as a side effect.
*/
function castHeight(position) {
const ground = parts?.groundCaster;
+1 -1
View File
@@ -52,7 +52,7 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
return { state, view, preRender, marker, frame };
}
test('the photo marker is hidden behind the globe, and shows from its side (review IC8 P2)', () => {
test('the photo marker is hidden behind the globe, and shows from its side', () => {
const { state, view, preRender, marker, frame } = setup();
// Placed on the far side of the Earth from the camera.
marker.set({ lon: 58.51, lat: -38.58 }, 90);
+9 -29
View File
@@ -2,20 +2,11 @@ import * as Cesium from 'cesium';
import { metresBetween, whenIdle } from './view.js';
/**
* Rendered-surface heights for ground casting on Google 3D (see groundCast.js
* refineHeights). One `scene.sampleHeight` probe per ~11 m cell, cached, so
* the many sequences that share a street share their samples.
*
* `sampleHeight` renders a pick pass per probe (about 1–3 ms), so probes run
* in idle time within a small budget, nearest the camera first, and only
* within MESH_SAMPLE_RADIUS_M of the point under the camera: the streamed
* detail is fine there, and an error is visible at all. Requests are taken
* in by the same budgeted slices, cells out of range then are not queued
* (callers ask again when the camera moves), and the queue is a heap ranked
* from where the camera was, re-ranked only once it has moved MESH_RERANK_M,
* so no slice walks or sorts the whole queue. A probe that hits
* nothing (tiles not streamed yet) is retried later rather than latched.
* Overlays are excluded from the probe, so only 3D tilesets are hit.
* Google 3D surface heights for ground casting (groundCast.js refineHeights):
* one cached `scene.sampleHeight` probe per ~11 m cell. Each probe renders a
* pick pass (1–3 ms), so probes run in budgeted idle slices, nearest the
* camera first and only within MESH_SAMPLE_RADIUS_M. A miss (tiles not
* streamed yet) is retried later. Probes hit 3D tilesets only.
*/
/** Cell size, in degrees (~11 m): samples are shared within a cell. */
@@ -35,10 +26,7 @@ const MESH_NOTIFY_MS = 700;
const cellOf = (value) => Math.round(value / MESH_CELL_DEG);
/**
* The cache key of the mesh cell holding a point, one per ~11 m cell: a
* number (exact well within 2^53), as a string key would be built per point.
*/
/** Numeric key of the cell holding a point (exact within 2^53; no per-point string). */
export const meshCellKey = (lon, lat) =>
(cellOf(lon) + 1_800_001) * 2_000_000 + cellOf(lat) + 1_000_000;
@@ -70,7 +58,6 @@ function siftDown(heap, i) {
heap[i] = cell;
}
/** Take the nearest cell off a min-heap. */
function heapPop(heap) {
const top = heap[0];
const last = heap.pop();
@@ -104,11 +91,7 @@ export function createMeshSampler({
}) {
/** cell key → sampled mesh height (ellipsoidal metres). */
const heights = new Map();
/**
* cell key → retry time for probes that hit nothing. An entry goes once
* its cell is sampled or asked for after the retry time, and with the
* height cache, so the map stays bounded.
*/
/** cell key → retry time for probes that hit nothing; bounded like the height cache. */
const misses = new Map();
/** cell key → queued cell, so a cell is queued once. */
const wanted = new Map();
@@ -142,10 +125,8 @@ export function createMeshSampler({
}
/**
* Ask for the cells under these [lon, lat] points (read later, so do not
* change the list). Nothing is done here: idle slices take requests in
* within their budget, and drop cells out of range of where the camera was
* when asked; callers ask again once it moves.
* Ask for the cells under [lon, lat] points; the list is read later, so do
* not mutate it. Out-of-range cells are dropped; callers ask again on move.
*/
function request(points) {
if (!enabled || !points?.length) return;
@@ -288,7 +269,6 @@ export function createMeshSampler({
schedule();
}
/** Drop every sample and remembered miss. */
function forget() {
heights.clear();
misses.clear();
+5 -5
View File
@@ -109,7 +109,7 @@ test('a missed cell is probed again after the retry window, not before', (t) =>
sampler.destroy();
});
test('remembered misses are bounded like the samples (review P3)', (t) => {
test('remembered misses are bounded like the samples', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, probes } = fakeViewer({ holes: () => true });
const sampler = createMeshSampler({ getViewer: () => viewer, cacheMax: 2 });
@@ -191,7 +191,7 @@ function grid(lon, lat, n, step) {
return points;
}
test('cells out of range when asked are not queued, even once the camera reaches them (review IC8 P2)', (t) => {
test('cells out of range when asked are not queued, even once the camera reaches them', (t) => {
const { sampler, probes, slice, moveTo } = clocked(t);
// 400 cells ~3.6 km east, past the sampler's range.
sampler.request(grid(10.05, 50, 20, 0.0002));
@@ -206,7 +206,7 @@ test('cells out of range when asked are not queued, even once the camera reaches
sampler.destroy();
});
test('a slice keeps to its budget, counting the work before its first probe (review IC8 P2)', (t) => {
test('a slice keeps to its budget, counting the work before its first probe', (t) => {
const { sampler, probes, slice } = clocked(t, { probeMs: 2, cameraMs: 3 });
sampler.request(grid(10.001, 50, 5, 0.0002));
const lengths = [];
@@ -217,7 +217,7 @@ test('a slice keeps to its budget, counting the work before its first probe (rev
sampler.destroy();
});
test('a large queue is ranked once, not sorted again every slice (review IC8 P2)', (t) => {
test('a large queue is ranked once, not sorted again every slice', (t) => {
const { sampler, probes, slice, moveTo } = clocked(t);
// 2,500 cells ~17 m apart around the camera, all within range.
const cells = grid(10, 50, 50, 0.00015);
@@ -255,7 +255,7 @@ test('a large queue is ranked once, not sorted again every slice (review IC8 P2)
sampler.destroy();
});
test('the sampling range is measured the short way round the date line (review IC8 P2)', (t) => {
test('the sampling range is measured the short way round the date line', (t) => {
const { sampler, probes, slice, moveTo } = clocked(t);
moveTo(179.9999, 0);
sampler.request([
+4 -5
View File
@@ -1,9 +1,8 @@
import { normalizeFilter } from './filter.js';
/**
* Share-link and stored parameters: one boolean per registered provider plus
* the shared imagery filter. The codec in src/data/layerState.js names the
* same keys, so a provider added later also needs an option there.
* Share-link params: one boolean per provider plus the filter. The codec in
* src/data/layerState.js names the same keys, so a new provider needs one there.
* @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input
*/
export function encodeParams({ providers, filter }) {
@@ -15,8 +14,8 @@ export function encodeParams({ providers, filter }) {
}
/**
* Read parameters back. Unknown providers and malformed values are ignored,
* so a link written for a build with more providers still applies cleanly.
* Read params back, ignoring unknown providers and malformed values so a link
* from a build with more providers still applies.
* @param {object} params
* @param {{providerIds: Iterable<string>, filter: {pano: string, sinceDays: number}}} current
* @returns {{providers: Map<string, boolean>, filter: {pano: string, sinceDays: number}}}
+2 -3
View File
@@ -1,7 +1,6 @@
/**
* Route picked primitive ids to the provider whose prefix they carry. The
* registry guarantees prefixes never overlap, so the first match is the only
* match.
* Route picked ids to the provider whose prefix they carry; the registry
* guarantees prefixes never overlap.
* @param {() => Iterable<{def: {id: string, pickPrefix: string}, instance: object}>} getProviders
* @param {{positionId?: string}} [options] Id of the core-owned position marker.
*/
+4 -13
View File
@@ -4,12 +4,7 @@ export const STREET_LEVEL_LAYER_ID = 'street-level';
/** Pick id of the viewer position marker; the core owns it, not a provider. */
export const POSITION_PICK_ID = 'sl:pos';
/**
* One colour per imagery source, used everywhere that source appears: its
* chip, coverage lines, overview points, image cones and legend swatch.
* Registered providers take their entry from here; a new source adds its
* colour together with its provider.
*/
/** One colour per imagery source: chip, coverage lines, points, cones and legend. */
export const PROVIDER_COLORS = Object.freeze({
mapillary: '#05cb63',
});
@@ -29,16 +24,12 @@ export const MAX_SINCE_DAYS = 36_500;
/** Filter every provider starts with: all imagery, any date. */
export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
/**
* Nearest-image search when the user asks to look at a place, in metres
* (Mapillary's graph API answers within at most 50 m).
*/
/** Nearest-image search radius in metres (Mapillary's graph API caps it at 50 m). */
export const NEAREST_RADIUS_M = 50;
/**
* Map stack the globe camera may follow the street-level view on. Only Google
* Photorealistic 3D has buildings to stand among; on flat imagery or terrain
* a camera at eye height looks at a smeared texture.
* The only map stack camera follow is offered on: elsewhere a camera at eye
* height looks at a smeared texture.
*/
export const FOLLOW_MAP_STACK_ID = 'photoreal';
@@ -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 () => {
+7 -9
View File
@@ -7,17 +7,17 @@
* @property {string} label Chip text and link label, e.g. 'MAPILLARY'.
* @property {string|null} requiresKeyId Key-setup id the provider needs, or null when keyless.
* @property {string} pickPrefix Every primitive id the provider creates starts with it.
* @property {{coverage: string}} colors The source's one colour (policy.js PROVIDER_COLORS): chip, lines, points, cones and legend.
* @property {{coverage: string}} colors The source's one colour, from policy.js PROVIDER_COLORS.
* @property {{html: string}} credit On-globe attribution while the provider is active.
* @property {(context: ProviderContext) => ProviderInstance} create
*
* @typedef {object} ProviderContext Handed to `create()` once by the core.
* @property {object} services Scene services: picking, input, render, sprites, ground, terrain.
* @property {() => {pano: string, sinceMs: number|null}} getFilter The imagery filter resolved against now: read it whenever filtering, so a "since N days" window keeps up with the clock.
* @property {() => {pano: string, sinceMs: number|null}} getFilter Filter resolved against now; read it on every use so "since N days" keeps up.
* @property {() => boolean} isActive Layer enabled and this provider switched on.
* @property {() => 'draped'|'terrain'} getSurface 'terrain' on Google 3D at street zoom: draw on the bare earth, not the mesh top.
* @property {() => 'draped'|'terrain'} getSurface 'terrain' on Google 3D at street zoom: draw on bare earth.
* @property {GroundCaster|null} groundCaster Bare-earth heights for terrain mode (groundCast.js); null without a terrain service.
* @property {{meshAt: (lon: number, lat: number) => number|undefined, request: (points: Array<[number, number]>) => void, onSampled: (listener: (cells: Array<[number, number]>) => void) => () => void}|null} meshSampler Sampled Google 3D surface heights (meshSampler.js) that refine the cast; pass `meshAt` to `castLine`.
* @property {{meshAt: (lon: number, lat: number) => number|undefined, request: (points: Array<[number, number]>) => void, onSampled: (listener: (cells: Array<[number, number]>) => void) => () => void}|null} meshSampler Sampled Google 3D surface heights (meshSampler.js); pass `meshAt` to `castLine`.
* @property {() => void} notify Ask the core to publish a new UI snapshot.
* @property {{openImage: (imageId: string) => Promise<void>, reportError: (message: string|null) => void}} actions
*
@@ -81,8 +81,7 @@ const REQUIRED = Object.freeze([
const ID_GRAMMAR = /^[a-z][a-z0-9-]*$/;
/**
* Check a provider list once at construction and freeze its order, which is
* also the chip order in the panel.
* Validate providers and freeze their order, which is also the chip order.
* @param {Array<StreetLevelProvider>} providers
* @returns {ReadonlyArray<StreetLevelProvider>}
*/
@@ -130,9 +129,8 @@ export function validateProviders(providers) {
}
/**
* The layer-level key requirement: the one key id every provider shares, or
* null as soon as any provider is keyless or they differ. Per-provider needs
* are then reported chip by chip instead of gating the whole layer.
* The key id every provider shares, or null if any is keyless or they differ
* (then each chip reports its own need).
* @param {ReadonlyArray<StreetLevelProvider>} providers
* @returns {string|null}
*/
+5 -11
View File
@@ -1,10 +1,7 @@
import * as Cesium from 'cesium';
import { STREET_LEVEL_LAYER_ID } from './policy.js';
/**
* Minimum time between hover picks: ~8 `scene.pick` calls a second while the
* pointer moves, as the CCTV layer's hover does, never one per frame.
*/
/** Hover picks at most ~8 times a second (like the CCTV layer), never per frame. */
const HOVER_PICK_INTERVAL_MS = 120;
/** One click/hover handler for every provider's coverage and image cones. */
@@ -92,9 +89,8 @@ export function createSelection({ state, parts }) {
}
/**
* Pointer cursor over anything this layer owns, so lines read as clickable.
* Picks the latest position at most every HOVER_PICK_INTERVAL_MS; a move
* inside the interval is picked when it ends, so the cursor never sticks.
* Pointer cursor over anything this layer owns. Throttled; a move inside
* the interval is picked when it ends, so the cursor never sticks.
*/
function onMove(movement) {
const viewer = state.viewer;
@@ -119,10 +115,8 @@ export function createSelection({ state, parts }) {
}
/**
* Track held buttons (a drag picks nothing) and camera stops. A moving
* camera does not stop hover: an orbiting or tracking camera never stops,
* and the throttle already bounds the cost. When the camera comes to rest
* the pointer is picked once more, since what was under it has moved.
* Track held buttons (a drag picks nothing) and re-pick when the camera
* rests. Hover keeps running while it moves: a tracking camera never stops.
*/
function watchHover(viewer) {
const canvas = viewer.scene.canvas;
+4 -5
View File
@@ -155,7 +155,7 @@ test('hover does not pick while a mouse button is held', () => {
}
});
test('a camera that never stops (orbit, tracking) keeps hover throttled, not off (review P3)', () => {
test('a camera that never stops (orbit, tracking) keeps hover throttled, not off', () => {
const h = harness();
try {
// An orbiting or tracking camera raises moveStart and never moveEnd.
@@ -171,7 +171,7 @@ test('a camera that never stops (orbit, tracking) keeps hover throttled, not off
}
});
test('when the camera stops, the pointer is picked again so the cursor cannot stick (review P3)', () => {
test('when the camera stops, the pointer is picked again so the cursor cannot stick', () => {
const h = harness();
try {
h.scene.under = 'mly:seq:1';
@@ -204,9 +204,8 @@ test('a hover frame queued before the layer went off does not pick', () => {
});
/**
* An element as Esc sees it: `closest` matches the tag names a selector list
* names, and `isContentEditable` is what the browser reports, inherited from
* an editing host.
* An element as Esc sees it: `closest` matches tag names in a selector list;
* `isContentEditable` is inherited from an editing host, as in a browser.
*/
function element(tag, { isContentEditable = false } = {}) {
return {
+2 -3
View File
@@ -1,9 +1,8 @@
import { FILTER_DEFAULT } from './policy.js';
/**
* The open image's fields of `state.street`, as they are with no image open.
* Closing the viewer resets exactly these; the panel host, the render mode
* and whether following is available outlive any one image.
* The per-image fields of `state.street` with no image open; closing resets
* exactly these (host, render mode and follow availability outlive an image).
*/
export function freshStreet() {
return {
+12 -42
View File
@@ -1,7 +1,4 @@
/**
* Web-Mercator tile arithmetic for Mapillary vector tiles. Pure functions,
* no Cesium dependency, shared by the browser layer and the server executor.
*/
/** Web-Mercator tile math, without Cesium, shared by the browser layer and the server executor. */
const MAX_LAT = 85.05112878;
@@ -9,10 +6,7 @@ function clampLat(lat) {
return Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
}
/**
* A longitude, or the difference of two, wrapped into [-180, 180]: the short
* way round the date line. Values already in range come back unchanged.
*/
/** Wrap a longitude, or a difference of two, into [-180, 180]: the short way round. */
export function wrapLon(lon) {
if (lon >= -180 && lon <= 180) return lon;
return ((((lon + 180) % 360) + 360) % 360) - 180;
@@ -31,7 +25,6 @@ function tileYAt(lat, z) {
);
}
/** Tile column for a longitude at zoom z. */
export function lonToTileX(lon, z) {
const n = 2 ** z;
// 180° is the east edge of the last column, not the west edge of the first.
@@ -39,7 +32,6 @@ export function lonToTileX(lon, z) {
return Math.min(n - 1, Math.max(0, Math.floor(tileXAt(wrapLon(lon), z))));
}
/** Tile row for a latitude at zoom z. */
export function latToTileY(lat, z) {
const n = 2 ** z;
return Math.min(n - 1, Math.max(0, Math.floor(tileYAt(lat, z))));
@@ -56,10 +48,7 @@ function tileYToLat(y, z) {
return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
}
/**
* Geographic bounds of one tile.
* @returns {{west:number,south:number,east:number,north:number}}
*/
/** @returns {{west:number,south:number,east:number,north:number}} degrees */
export function tileBounds(x, y, z) {
return {
west: tileXToLon(x, z),
@@ -69,10 +58,7 @@ export function tileBounds(x, y, z) {
};
}
/**
* Normalize a bbox given as [west, south, east, north] (or an object) into
* finite, ordered numbers. Returns null when the input is not a usable box.
*/
/** A [west, south, east, north] array or object as finite, ordered bounds; null if unusable. */
export function normalizeBbox(input) {
const values = Array.isArray(input)
? input
@@ -121,16 +107,10 @@ function segmentDistance2(tile, from, to, z) {
}
/**
* 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.
* Tiles at zoom z covering a bbox (west > east crosses the date line), from
* the centre outwards, capped at `limit`. With `focus`, tiles rank by distance
* from the segment `from` (ground under the camera) to `to` (screen centre):
* a tilted view's box 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}}
*/
@@ -191,11 +171,7 @@ function tileGrid(box, z) {
return { tiles, x0, x1, y0, y1 };
}
/**
* Convert a vector-tile-local coordinate (0..extent) into longitude and
* latitude for tile (x, y, z).
* @returns {[number, number]} [lon, lat]
*/
/** A vector-tile-local coordinate (0..extent) in tile (x, y, z) as [lon, lat]. */
export function tileLocalToLonLat(px, py, extent, x, y, z) {
const n = 2 ** z;
const lon = ((x + px / extent) / n) * 360 - 180;
@@ -205,11 +181,7 @@ export function tileLocalToLonLat(px, py, extent, x, y, z) {
return [lon, lat];
}
/**
* Pick a coverage zoom for a camera height (metres above ground). Higher
* cameras get coarser sequence tiles; below the floor Mapillary has no
* finer tiles than z14.
*/
/** Sequence-tile zoom for a camera height above ground (m); Mapillary stops at z14. */
export function coverageZoomForHeight(heightM) {
if (!Number.isFinite(heightM)) return null;
if (heightM > 60_000) return null;
@@ -220,10 +192,8 @@ export function coverageZoomForHeight(heightM) {
}
/**
* Pick the low-zoom `overview` tile level for a camera far above the ground.
* Mapillary publishes coverage points at z0–5; sequences start at z6 and are
* far too heavy for a continent-sized view. Returns null below the overview
* ceiling (60 km), where `coverageZoomForHeight` takes over.
* Overview-point zoom (z0–5) above 60 km, where sequence tiles are too heavy;
* null below, where `coverageZoomForHeight` takes over.
*/
export function overviewZoomForHeight(heightM) {
if (!Number.isFinite(heightM) || heightM <= 60_000) return null;
+1 -1
View File
@@ -151,7 +151,7 @@ test('a box across the date line ranks the tiles at the line first, without and
}
});
test('a focus ranks tiles along the line of sight, not from the box centre (review IC8 P1)', () => {
test('a focus ranks tiles along the line of sight, not from the box centre', () => {
// 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 };
+4 -9
View File
@@ -18,9 +18,8 @@ import { COLORS } from './policy.js';
*/
/**
* What the switched-on providers add up to: summed counts, any loading, the
* 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.
* Totals over switched-on providers. Key-gated only when every one lacks its
* key; `keyRejected` when that is because a key was refused.
* @param {Array<ProviderSnapshot>} providers
*/
export function summarizeCoverage(providers) {
@@ -40,12 +39,8 @@ export function summarizeCoverage(providers) {
}
/**
* Compose the snapshot the panel renders from the core state and one
* snapshot per registered provider. Pure, so the merge rules are testable:
* counts add up across active providers, the layer is key-gated only when
* every switched-on provider lacks its key, and the legend lists one swatch
* per active source, in its colour and under its name, followed by the
* shared selection colour.
* The panel snapshot from core state and provider snapshots. The legend has
* one swatch per active source, then the shared selection colour.
* @param {{enabled: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
*/
export function composeUIState({
+1 -1
View File
@@ -118,7 +118,7 @@ test('summarizeCoverage is what getStats reports, without building a snapshot',
assert.equal(summarizeCoverage([]).keyRequired, false);
});
test('a rejected key gates like a missing one and is named as rejected (review IC8 P2)', () => {
test('a rejected key gates like a missing one and is named as rejected', () => {
const rejected = provider({
keyRequired: true,
keyRejected: true,
+22 -56
View File
@@ -1,11 +1,7 @@
import * as Cesium from 'cesium';
import { wrapLon } from './tileMath.js';
/**
* Approximate metres between two {lon, lat} points: equirectangular, good
* for the street-scale distances the layer measures. Measured the short way
* round, so neighbours either side of ±180° are metres apart, not a world.
*/
/** Equirectangular metres between {lon, lat} points, the short way round ±180°; street scale. */
export function metresBetween(a, b) {
const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180;
return Math.hypot(
@@ -15,11 +11,8 @@ export function metresBetween(a, b) {
}
/**
* Run low-priority work when the browser is idle, within `timeout` ms; a
* browser without idle callbacks (and Node) runs it a frame later. The task
* gets the idle deadline, or null.
* @param {(deadline: IdleDeadline|null) => void} task
* @param {number} timeout
* Run `task(deadline)` when idle, within `timeout` ms; without idle callbacks
* (Node) it runs a frame later with a null deadline.
*/
export function whenIdle(task, timeout) {
if (typeof globalThis.requestIdleCallback === 'function')
@@ -28,10 +21,8 @@ export function whenIdle(task, timeout) {
}
/**
* 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.
* Ellipsoidal height (m) of the surface under the camera: globe terrain, or
* `groundAt` where Google 3D hides the globe; null without a sample.
* @param {object} viewer
* @param {{groundAt?: (lon: number, lat: number) => number|null}} [options]
*/
@@ -48,13 +39,7 @@ export function groundUnderCamera(viewer, { groundAt } = {}) {
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]
*/
/** Camera height (m) above the ground under it (options as `groundUnderCamera`). */
export function cameraHeightAboveGround(viewer, options = {}) {
const carto = viewer?.camera?.positionCartographic;
if (!carto) return null;
@@ -90,18 +75,11 @@ function groundHit(camera, point, ellipsoid, maxRange) {
}
/**
* Visible bbox as [west, south, east, north] degrees, or null when the camera
* does not see the ground. A grid of screen rays is cast onto the ellipsoid
* 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.
* Visible [west, south, east, north] degrees, or null. Only screen rays that
* hit count, so a horizon cannot inflate the box to the world.
* `groundHeight` raises the ellipsoid to the ground (Google 3D hides the
* globe), `maxRange` (m) drops near-horizon hits, and `nearRange` (m) always
* includes the ground around the camera.
*/
export function visibleBbox(
viewer,
@@ -129,8 +107,7 @@ export function visibleBbox(
}
}
// 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.
// rows skip from the horizon to the first few metres.
const nadir = camera.positionCartographic;
if (Number.isFinite(nearRange) && nearRange > 0 && nadir) {
const dLat = nearRange / 111_320;
@@ -212,10 +189,8 @@ export function viewCentre(viewer) {
}
/**
* 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}
* Ground the camera looks at, for ranking tiles: `nadir` under the camera and
* `ahead` at the screen centre (null on a miss or beyond `maxRange`).
*/
export function viewFocus(
viewer,
@@ -251,7 +226,10 @@ export function viewFocus(
return { nadir, ahead };
}
/** How far below WGS84 the horizon cull's occluder sits, in metres. */
/**
* Horizon occluder depth below WGS84 (m): ground can lie below the ellipsoid
* (NYC −22 m), and an occluder at 0 m would hide the cones around it.
*/
export const HORIZON_CULL_DEPTH_M = 1000;
const HORIZON_CULL_ELLIPSOID = new Cesium.Ellipsoid(
Cesium.Ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M,
@@ -260,18 +238,9 @@ const HORIZON_CULL_ELLIPSOID = new Cesium.Ellipsoid(
);
/**
* Hide billboards and points behind the horizon, as the cyclones layer does.
* Street Level's skip the depth test, so Google 3D and clouds never hide the
* near side's; nothing else then stops the far hemisphere's drawing through
* the globe, and a finite skip distance cannot, as the horizon's distance
* moves with the camera. Re-culls on pre-render only once the camera moved,
* and stops listening when there is nothing left to cull.
*
* The occluder is the WGS84 ellipsoid shrunk by HORIZON_CULL_DEPTH_M, below
* any real surface: on the ellipsoid itself a camera standing on ground that
* lies below it (NYC −22 m, Colombo −95 m) would see every lower point as
* behind the horizon and hide the cones around it. A kilometre changes
* nothing for the far side of the Earth seen from orbit.
* Hide billboards and points behind the horizon: they skip the depth test, so
* nothing else stops the far side drawing through the globe. Re-culls on
* pre-render after the camera moves; stops when nothing is left to cull.
* @param {{getViewer: () => object|null, items: () => Iterable<{position: object, show: boolean}>, onChange?: () => void}} options
*/
export function createHorizonCull({ getViewer, items, onChange }) {
@@ -308,10 +277,7 @@ export function createHorizonCull({ getViewer, items, onChange }) {
if (!cullAll(position)) stop();
}
/**
* Cull `added` (new or moved items), or everything, now; then everything
* again whenever the camera moves.
*/
/** Cull `added` items (or all) now, then all again whenever the camera moves. */
function update(added) {
const viewer = getViewer();
const position = viewer?.camera?.positionWC;
+7 -7
View File
@@ -103,7 +103,7 @@ function pinhole({ lon, lat, ground, agl, pitch, w = 1600, h = 900 }) {
};
}
test('a tilted street view over high ground boxes the streets it looks at, not the horizon (review IC8 P1)', () => {
test('a tilted street view over high ground boxes the streets it looks at, not the horizon', () => {
// 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({
@@ -115,8 +115,8 @@ test('a tilted street view over high ground boxes the streets it looks at, not t
});
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.
// Without options, rays to the bare ellipsoid travel 1.6 km further down:
// the box is 20 km wide and starts past the street at the screen centre.
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 };
@@ -149,7 +149,7 @@ test('looking at the horizon from eye height still boxes the ground around the c
assert.ok(east - -121.4944 > 0.008 && -121.4944 - west > 0.008);
});
test('metresBetween measures the short way round the date line (review IC8 P2)', () => {
test('metresBetween measures the short way round the date line', () => {
const east = { lon: 179.9999, lat: 0 };
const west = { lon: -179.9999, lat: 0 };
// 0.0002° of longitude at the equator, not 40,000 km round the other way.
@@ -284,7 +284,7 @@ function cullFrom(camera, points) {
return items.map((item) => item.show);
}
test('cones and the marker stay visible on ground below the WGS84 ellipsoid (gekh P2)', () => {
test('cones and the marker stay visible on ground below the WGS84 ellipsoid', () => {
// NYC in FOLLOW: eye 2.4 m above ground at -22 m, a cone 10 m away.
assert.deepEqual(
cullFrom([-74.006, 40.7128, -19.6], [[-74.006, 40.71289, -22]]),
@@ -301,14 +301,14 @@ test('cones and the marker stay visible on ground below the WGS84 ellipsoid (gek
),
[true, true],
);
// Austin, ground well above the ellipsoid: as before.
// Austin, ground well above the ellipsoid.
assert.deepEqual(
cullFrom([-97.7431, 30.2672, 132], [[-97.7431, 30.2681, 130]]),
[true],
);
});
test('the horizon cull still hides the far side of the Earth from orbit (gekh P2)', () => {
test('the horizon cull still hides the far side of the Earth from orbit', () => {
assert.deepEqual(
cullFrom(
[0, 0, 20_000_000],
+10 -30
View File
@@ -1,9 +1,8 @@
import { freshStreet } from './state.js';
/**
* Own the one viewer element in the panel and whichever provider's viewer
* adapter is mounted in it. Poses the adapter emits become `state.street`,
* move the marker and (optionally) the globe camera.
* Owns the panel's viewer element and the provider adapter mounted in it.
* Adapter poses update `state.street`, the marker and the follow camera.
*/
export function createViewerHost({ state, parts }) {
/** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */
@@ -11,11 +10,7 @@ export function createViewerHost({ state, parts }) {
/** @type {{id: string, promise: Promise<object>}|null} */
let mounting = null;
let openSeq = 0;
/**
* Adapters stood up by `prewarm` that are not the active one: each holds a
* live viewer (a WebGL context for MapillaryJS) until `unmount` releases it.
* @type {Map<string, object>}
*/
/** Prewarmed, inactive adapters; each holds a live viewer (WebGL context) until `unmount`. */
const warmed = new Map();
function notify() {
@@ -44,9 +39,8 @@ export function createViewerHost({ state, parts }) {
});
parts.marker.set(state.street.position, state.street.bearing);
parts.follow.followCamera();
// Providers highlight the selected image's sequence on the map; mirror
// that once the image itself is on screen, so the sequence lookup never
// competes with the image download.
// Select the sequence only once the image is on screen, so its lookup
// never competes with the image download.
if (!state.street.loading && state.street.sequenceId !== previousSequence)
selectCurrentSequence();
notify();
@@ -62,10 +56,8 @@ export function createViewerHost({ state, parts }) {
}
/**
* Mount a provider's viewer adapter in the host. The adapter becomes
* `active` only once its mount succeeded, so a failed mount (a library
* that did not load) is retried on the next open; concurrent opens for the
* same provider share one mount.
* Mount a provider's adapter. It becomes `active` only on success, so a
* failed mount is retried on the next open; concurrent opens share one.
*/
function mount(entry) {
if (active?.id === entry.def.id) return Promise.resolve(active.adapter);
@@ -108,12 +100,7 @@ export function createViewerHost({ state, parts }) {
return promise;
}
/**
* Open an image from one provider. Resolves once the first pose is in
* (true) or the open failed or was overtaken (false); later poses stream
* through `state.street` as the user navigates.
* @returns {Promise<boolean>}
*/
/** Open an image: true once its first pose is in, false if it failed or was overtaken. */
async function open(providerId, imageId, { frame = true } = {}) {
const entry = providerEntry(providerId);
if (!entry || !imageId) return false;
@@ -153,10 +140,7 @@ export function createViewerHost({ state, parts }) {
return true;
}
/**
* Close the image; the mounted adapter stays warm for the next open. The
* globe stops flying toward it too (unmount closes, so layer off does).
*/
/** Close the image and stop any framing flight; the adapter stays warm. */
function close() {
openSeq++;
parts.follow.cancelFraming();
@@ -208,11 +192,7 @@ export function createViewerHost({ state, parts }) {
}
}
/**
* Tear viewers down: every one (layer disabled or destroyed), or only the
* given provider's, active or prewarmed (that provider switched off).
* @param {string} [providerId]
*/
/** Tear down every viewer, or only `providerId`'s (active or prewarmed). */
function unmount(providerId) {
if (
!providerId ||
+3 -3
View File
@@ -458,7 +458,7 @@ test('a prewarm that gave up when the layer went off and on mid-download is foll
adapter.unmount();
});
test('the Mapillary viewer leaves resizing to the panel, so a hidden one never asks for z=NaN (gekh P3)', async () => {
test('the Mapillary viewer leaves resizing to the panel, so a hidden one never asks for z=NaN', async () => {
const { gate, viewers } = fakeLibrary();
gate.resolve();
const adapter = createMapillaryViewer({ source: { token: 't' } });
@@ -467,7 +467,7 @@ test('the Mapillary viewer leaves resizing to the panel, so a hidden one never a
adapter.unmount();
});
test('closing the photo stops sequence playback in the hidden viewer (review P2-6)', async () => {
test('closing the photo stops sequence playback in the hidden viewer', async () => {
const { gate, viewers } = fakeLibrary();
gate.resolve();
const adapter = createMapillaryViewer({ source: { token: 't' } });
@@ -480,7 +480,7 @@ test('closing the photo stops sequence playback in the hidden viewer (review P2-
assert.equal(viewers.live, 1, 'the viewer itself stays warm');
});
test('closing the photo or switching the layer off stops its framing flight (review IC8 P1)', async () => {
test('closing the photo or switching the layer off stops its framing flight', async () => {
const adapter = fakeAdapter();
const { framing, host } = harness(adapter);
await host.open('mapillary', 'a');