mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
docs(street-level): shorten docs and comments before merge
Trim the doc text this PR adds (README, CHANGELOG, CURRENT-STATE, KNOWN-ISSUES, SECURITY, DATA_SOURCES, .env.example, the CI job comment), drop code comments that restate the code, cut design-note JSDoc to a line or two, and drop review tags from test names. Comments only; no code changes. Also: README key count (seven keys, two metered), CURRENT-STATE's gate paragraph (CI runs a production build with a dummy token), the stacked doc above meshBoxInRange and the orphaned inMeshRange line in coverage.js, and a duplicate ALPR row in DATA_SOURCES that a merge had brought back. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c65feda4b1
commit
4ea6631425
85 files changed
+701
-1520
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
*/
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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}}}
|
||||
|
||||
@@ -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,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,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}
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user