refactor(street-level): one provider, no orbit overview, no prewarm, simpler tile choice

The layer now owns its Mapillary parts directly: the provider registry,
pick router, multi-provider UI summary, params codec helper and provider
context are gone. createStreetLevelLayer({ source }) builds coverage,
sequences, the nearest-image lookup and the MapillaryJS viewer itself; a
single providerOn switch keeps the share link's `mapillary` option (off
draws nothing), and getParams/setParams keep { mapillary, pano, sinceDays }.

No z0-5 overview points: above 60 km nothing is drawn and the panel says to
zoom in; the proxy now serves only coverage z11-14. Tiles come from the
visible ground within range of the camera, ranked from the ground under it
(no line-of-sight ranking or near-range padding). MapillaryJS loads on the
first photo; viewer prewarm is removed. Key status, a rejected key and a
rate limit share one `blocked` state on coverage.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-07 22:19:24 +04:00
1 parent 0f41fb04cd
commit 37a96072fa
51 files changed
+1240 -2659

No files matched your search

+8 -8
View File
@@ -2,14 +2,14 @@
## [Unreleased]
- Street Level: a street-level imagery layer modelled on the iD editor's photo
overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0
imagery). One right-rail panel holds an ON/OFF pill, shared 360°/flat and
captured-since filters, a legend, and an embedded viewer with full-screen
EXPAND and FIT/FILL. Coverage lines and image cones are drawn from orbit
down to a single street, draped on the terrain and on Google 3D tiles;
opening a photo frames it with one camera flight.
Share links carry the provider switches, the filters and the panel's collapsed
- Street Level: Mapillary street-level imagery modelled on the iD editor's
photo overlay (free client token, CC BY-SA 4.0 imagery). One right-rail panel
holds an ON/OFF pill, 360°/flat and captured-since filters, a legend, and an
embedded viewer with full-screen EXPAND and FIT/FILL. Coverage lines (below
60 km; from orbit the panel says to zoom in) and image cones are draped on
the terrain and on Google 3D tiles; MapillaryJS loads on the first photo,
and opening one frames it with one camera flight.
Share links carry the Mapillary switch, the filters and the panel's collapsed
state. A server proxy caches Mapillary coverage tiles (24 h, 1 GiB on disk),
refuses cross-site requests, rate-limits per IP and backs off when Mapillary
rejects the token or rate-limits.
+3 -3
View File
@@ -334,13 +334,13 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
## 📷 Street Level
> Street-level photos from any provider in one panel. This build ships **Mapillary**, which needs a free client token.
> Street-level photos from **Mapillary** in one panel. It needs a free client token.
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider draws in its own colour (Mapillary green; see the legend). The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to every provider. Click a coverage line for its image cones and a cone to open the photo. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL**. The caption links to the image on the provider's site, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while Mapillary is on.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Zoom in to street level (below 60 km) for the green coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters narrow the lines, cones and nearest-photo lookups. Click a coverage line for its image cones and a cone to open the photo. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL**. The caption links to the image on mapillary.com, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while the layer is on.
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
Share links carry the Mapillary switch and the filters (`0.m.0` Mapillary off, `0.p.p` panoramas only, `0.s.365` the last year).
## 🎖️ Field Missions
+24 -26
View File
@@ -2928,7 +2928,7 @@ its criteria cannot be silently ignored.
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Street-level imagery; Mapillary is the first provider (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, cached 24 h) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, cached 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
@@ -2938,19 +2938,21 @@ Street Level is a collapsible right-rail panel (`#street-level-panel`, layer
token `0`, option owner `street-level`, panel `ui` token `t`) that starts
collapsed. It opens itself only when the user, voice or a tool switches the
layer on or a photo opens, never on a restore, and those opens are not stored.
Like the iD editor's photo overlay, it has one switch per registered provider
(share options, no panel chips),
shared 360°/flat and captured-since filters (relative days, so a link keeps its
meaning), one viewer host and one on-globe credit per active provider. Each
provider draws in one colour (`PROVIDER_COLORS` in
`src/layers/streetLevel/policy.js`); 360° cones are rings and the selected
sequence is GEV cyan. Share options: `m` (Mapillary), `p` (`a`/`p`/`f`
panoramas), `s` (since, days). Only Mapillary is registered. Without
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED, its error line says
which key to add, and the filters are disabled. With it, coverage draws as z0–5 overview points from orbit and z11–14
sequence lines below 60 km; the proxy strips the unused `image` layer from z14
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
requests for a tile.
Like the iD editor's photo overlay, it shows Mapillary with 360°/flat and
captured-since filters (relative days, so a link keeps its meaning), one
viewer host and an on-globe credit while the layer is on. Lines and cones are
Mapillary green; 360° cones are rings and the selected sequence is GEV cyan.
Share options: `m` (Mapillary on/off; off draws nothing), `p` (`a`/`p`/`f`
panoramas), `s` (since, days). Without `MAPILLARY_CLIENT_TOKEN` the panel
reads KEY REQUIRED, its error line says which key to add, and the filters are
disabled. Coverage is z11–14 sequence lines below 60 km; above that nothing is
drawn and the panel says to zoom in. Tiles come from the visible ground within
range of the camera (rays meet the terrain height, not the bare ellipsoid),
nearest the camera first, at most 9. The proxy serves only z11–14, strips the
unused `image` layer from z14 tiles (12 MB → ~80 KB) and shares one upstream
fetch between concurrent requests for a tile. A 429 pauses tile requests for
its Retry-After and then refreshes once; a rejected key stops them and reads
KEY REJECTED until the layer goes off.
Sequence lines are `GroundPolylinePrimitive`s with
`ClassificationType.BOTH`, so they drape on the terrain globe and on Google 3D
@@ -2959,12 +2961,13 @@ photo marker are billboards clamped to the ground; they skip the depth test, so
a horizon cull hides the ones behind the globe. Roads on elevated decks are
drawn at ground level.
The header pill is the only layer switch; switching on also turns back on a
provider a share link or tool switched off. The viewer sits under the header
The header pill is the only layer switch; switching on also turns Mapillary
back on if a share link or tool switched it off. The viewer sits under the header
with EXPAND, FIT/FILL and close above the image. EXPAND is the browser's
Fullscreen API on the viewer element, which stays in the panel (Esc or SHRINK
leaves; the button is hidden where the API is missing). Opening a photo flies
the globe camera once to frame it; there is no camera follow. The flight goes
leaves; the button is hidden where the API is missing). MapillaryJS loads on the
first photo (no prewarm). Opening a photo flies the globe camera once to frame
it; there is no camera follow. The flight goes
through the application's deferred navigation (`attachNavigation`: `begin` /
`reassert`), which releases aircraft and satellite tracking and is refused in
the cockpit; a photo takes its ticket when it starts opening and frames only
@@ -2972,14 +2975,9 @@ if nothing newer took the camera while it loaded. SINCE is a stepped slider whos
cut-off date. At phone width (≤720 px) the viewer height is derived from the
rail band so the whole photo fits.
Providers implement the contract in `src/layers/streetLevel/registry.js`. The
core routes clicks by pick prefix, swaps viewer adapters in the one host,
manages each provider's credit and fans the filter out to every provider. To
add a provider: implement the definition, register it in
`src/app/layers/streetLevel.js`, add its boolean option to the `street-level`
group in `src/data/layerState.js` and its modules to
`scripts/package-boundaries.json`. Once a keyless provider registers, the
layer's `requiresKeyId` becomes null and the key gate must move per provider.
The layer (`src/layers/streetLevel/index.js`) owns the Mapillary parts
directly: `providers/mapillary/` holds the source, tile decoding, coverage,
sequence cones, nearest-image lookup and the MapillaryJS viewer.
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
-1
View File
@@ -260,7 +260,6 @@
"node": "./server/providers/mapillary.js"
},
"./layers/street-level": "./src/layers/streetLevel/index.js",
"./layers/street-level/mapillary": "./src/layers/streetLevel/providers/mapillary/index.js",
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js",
"./tools": "./src/tools/index.js",
"./tools/mcp": "./src/tools/mcp/index.js",
+2 -10
View File
@@ -867,16 +867,11 @@
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/registry.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
@@ -887,6 +882,7 @@
"src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/layers/streetLevel/providers/mapillary/nearest.js",
"server/providers/mapillary.js",
"server/providers/mapillary/routes.js",
"server/providers/mapillary/constants.js",
@@ -898,12 +894,8 @@
"scripts/qa-browserEvidence.mjs",
"scripts/fixtures/street-level/mapillaryGraph.mjs",
"src/layers/streetLevel/filter.test.mjs",
"src/layers/streetLevel/registry.test.mjs",
"src/layers/streetLevel/params.test.mjs",
"src/layers/streetLevel/pickRouter.test.mjs",
"src/layers/streetLevel/uiState.test.mjs",
"src/layers/streetLevel/view.test.mjs",
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
"src/layers/streetLevel/providers/mapillary/mapillary.test.mjs",
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
"src/layers/streetLevel/providers/mapillary/coverage.test.mjs",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
+3 -16
View File
@@ -1178,21 +1178,15 @@
"street-level-layer": {
"exports": [
"./layers/street-level",
"./layers/street-level/mapillary",
"./layers/street-level/mapillary/source"
],
"modules": [
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/registry.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/state.js",
@@ -1206,6 +1200,7 @@
"src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/layers/streetLevel/providers/mapillary/nearest.js",
"scripts/qa-street-level.mjs"
],
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
@@ -2100,16 +2095,11 @@
"src/ui/streetLevelControls.js",
"src/ui/streetLevelPresentation.js",
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/registry.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/state.js",
@@ -2123,6 +2113,7 @@
"src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/layers/streetLevel/providers/mapillary/nearest.js",
"src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js",
"src/sources/capability.js",
@@ -2619,16 +2610,11 @@
"src/layers/recentImagery/thumbnails.js",
"src/maps/imageryHost.js",
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/registry.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/state.js",
@@ -2642,6 +2628,7 @@
"src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/layers/streetLevel/providers/mapillary/nearest.js",
"src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js",
"src/sources/capability.js",
+9 -20
View File
@@ -41,8 +41,8 @@ export const VIEWPORTS = Object.freeze([
{ width: 1280, height: 800 },
]);
/** Providers the layer registers: one legend swatch each, plus "Selected". */
export const EXPECTED_PROVIDERS = Object.freeze(['mapillary']);
/** Legend swatches: Mapillary, then "Selected". */
export const LEGEND_SWATCHES = 2;
/**
* Skips no setup can avoid: live runs cannot stage fixture-only cases.
@@ -89,24 +89,14 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const DAY_MS = 86_400_000;
/**
* Deterministic coverage for any tile: a grid of mixed 360°/flat, new/old
* sequences plus the photo sequence (PHOTO_LINE), or a few overview points.
* @returns {Uint8Array} empty for zooms that carry nothing
* Deterministic coverage for any street-zoom tile: a grid of mixed 360°/flat,
* new/old sequences plus the photo sequence (PHOTO_LINE).
* @returns {Uint8Array} empty below z11, which the layer never asks for
*/
export function fixtureTile(z, x, y, now = Date.now()) {
const tile = { x, y, z };
const { west, east, south, north } = tileBounds(x, y, z);
const at = (t, lo, hi) => lo + (hi - lo) * t;
if (z <= 5)
return encodeCoverageTile(tile, {
overview: [0.25, 0.5, 0.75].map((t, i) => ({
id: `fx-${z}-${x}-${y}-o${i}`,
lon: at(t, west, east),
lat: at(t, south, north),
isPano: i === 1,
capturedAt: now - 30 * DAY_MS,
})),
});
if (z < 11) return new Uint8Array(0);
const margin = (east - west) * 0.05;
const sequences = [];
@@ -654,12 +644,12 @@ async function main() {
const info = await panel();
assert.ok(!isCollapsed(info.classes));
assert.equal(info.bodyDisplay, 'flex');
// One swatch per source plus "Selected".
// Mapillary plus "Selected".
assert.equal(
await page.evaluate(
() => document.querySelectorAll('#sl-legend li').length,
),
EXPECTED_PROVIDERS.length + 1,
LEGEND_SWATCHES,
);
},
);
@@ -1016,7 +1006,7 @@ async function main() {
await page.evaluate((imageId) => {
void window.__godsEyeView.dataManager.layers
.get('street-level')
.module.openImage('mapillary', imageId);
.module.openImage(imageId);
}, id);
await waitForImage(id);
const street = (await ui()).street;
@@ -1149,7 +1139,6 @@ async function main() {
assert.equal(view.hidden, false);
assert.ok(view.width > 200);
assert.ok(view.when.length > 0, 'caption shows the capture date');
assert.equal(street.providerId, 'mapillary');
firstImageId = street.imageId;
assert.equal(view.link, 'MAPILLARY ↗');
if (fixtures) {
@@ -1246,7 +1235,7 @@ async function main() {
window.__godsEyeView.dataManager.layers.get(
'street-level',
).module;
const opened = await module.openImage('mapillary', id);
const opened = await module.openImage(id);
const flying = Boolean(v.camera._currentFlight);
const from = v.camera.positionCartographic.clone();
module.closeViewer();
+2 -2
View File
@@ -5,12 +5,12 @@ export const MAPILLARY_TILE_HOST = 'https://tiles.mapillary.com/maps/vtp';
/** Public tile layer names this proxy exposes, mapped to Mapillary's ids. */
export const TILE_LAYERS = Object.freeze({
// Region-sized z6–10 tiles are refused; the app never asks for them. The
// Only the street zooms the app draws (z11–14); the rest are refused. The
// z14 `image` layer is ~98% of a tile and unused (positions come from the
// graph API), so it is dropped in transit.
coverage: Object.freeze({
upstream: 'mly1_public',
zoomRanges: Object.freeze([Object.freeze([0, 5]), Object.freeze([11, 14])]),
zoomRanges: Object.freeze([Object.freeze([11, 14])]),
dropLayers: Object.freeze(['image']),
}),
});
+2 -3
View File
@@ -1,14 +1,13 @@
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js';
import * as input from '../../data/inputOwnership.js';
import * as render from '../../renderGovernor.js';
/** A new provider registers here; its chip, credit and share bit follow. */
/** The Street Level layer over the application's Mapillary source. */
export function createApplicationStreetLevel({ sources }) {
return createStreetLevelLayer({
providers: [createMapillaryProvider({ source: sources.mapillary })],
source: sources.mapillary,
services: { sprites, picking, input, render },
});
}
+12 -18
View File
@@ -1,38 +1,32 @@
import * as Cesium from 'cesium';
/**
* One static Cesium credit per active provider: CC BY-SA imagery needs
* A static Cesium credit shown while the layer draws: CC BY-SA imagery needs
* visible attribution that goes away with the imagery.
*/
export function createCredits() {
/** @type {Map<string, object>} provider id → Cesium.Credit */
const shown = new Map();
export function createCredit(html) {
let shown = null;
function show(viewer, def) {
if (shown.has(def.id) || !viewer?.creditDisplay) return;
function show(viewer) {
if (shown || !viewer?.creditDisplay) return;
try {
const credit = new Cesium.Credit(def.credit.html, true);
const credit = new Cesium.Credit(html, true);
viewer.creditDisplay.addStaticCredit(credit);
shown.set(def.id, credit);
shown = credit;
} catch {
/* credit display unavailable */
}
}
function hide(viewer, def) {
const credit = shown.get(def.id);
if (!credit) return;
function hide(viewer) {
if (!shown) return;
try {
viewer?.creditDisplay?.removeStaticCredit?.(credit);
viewer?.creditDisplay?.removeStaticCredit?.(shown);
} catch {
/* credit display already torn down */
}
shown.delete(def.id);
shown = null;
}
function hideAll(viewer) {
for (const id of [...shown.keys()]) hide(viewer, { id });
}
return { show, hide, hideAll };
return { show, hide };
}
+1 -1
View File
@@ -27,7 +27,7 @@ export function sameFilter(a, b) {
/**
* Stored filter (relative days, stable in share links) to the absolute form
* providers compare capture times against.
* coverage, cones and lookups compare capture times against.
* @returns {{pano: string, sinceMs: number|null}}
*/
export function resolveFilter(filter, now = Date.now()) {
+184 -228
View File
@@ -1,51 +1,70 @@
import { createState } from './state.js';
import { createState, isActive } from './state.js';
import { createMarker } from './marker.js';
import { createCameraFraming } from './cameraFraming.js';
import { createCredits } from './credits.js';
import { createPickRouter } from './pickRouter.js';
import { createCredit } from './credits.js';
import { createSelection } from './selection.js';
import { createViewerHost } from './viewerHost.js';
import { requiresKeyIdFor, validateProviders } from './registry.js';
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
import { decodeParams, encodeParams } from './params.js';
import { composeUIState, summarizeCoverage } from './uiState.js';
import { viewCentre, whenIdle } from './view.js';
import { viewCentre } from './view.js';
import { COLORS, STREET_LEVEL_LAYER_ID } from './policy.js';
import { createCoverage } from './providers/mapillary/coverage.js';
import { createSequences } from './providers/mapillary/sequences.js';
import { createMapillaryViewer } from './providers/mapillary/viewer.js';
import { nearestImageId } from './providers/mapillary/nearest.js';
import {
COLORS as MAPILLARY_COLORS,
MAPILLARY_CREDIT_HTML,
MAPILLARY_KEY_ID,
MAPILLARY_SOURCE_METHODS,
NEAREST_RADIUS_M,
POSITION_PICK_ID,
STREET_LEVEL_LAYER_ID,
} from './policy.js';
} from './providers/mapillary/policy.js';
export { STREET_LEVEL_LAYER_ID } from './policy.js';
/** Coverage as the panel sees it while the `mapillary` switch is off. */
const COVERAGE_OFF = Object.freeze({
count: 0,
loading: false,
hint: '',
error: null,
keyRequired: false,
keyRejected: false,
});
/**
* 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
* The Street Level layer: Mapillary coverage lines at street zoom, a selected
* sequence's image cones, and the photo viewer in the panel.
* @param {{source: object, services?: object, photoViewer?: object}} options
* `source` is providers/mapillary/source.js or a stand-in; `photoViewer`
* stands in for the MapillaryJS adapter (providers/mapillary/viewer.js).
*/
export function createStreetLevelLayer({
providers: definitions,
source,
services = {},
photoViewer = null,
}) {
const definitionsFrozen = validateProviders(definitions);
if (
!MAPILLARY_SOURCE_METHODS.every(
(method) => typeof source?.[method] === 'function',
)
)
throw new TypeError('A Mapillary source is required');
const state = createState({ services });
const parts = {};
const context = { state, parts };
parts.credits = createCredits();
const credit = createCredit(MAPILLARY_CREDIT_HTML);
parts.marker = createMarker(context);
parts.framing = createCameraFraming(context);
parts.router = createPickRouter(() => state.providers.values(), {
positionId: POSITION_PICK_ID,
parts.coverage = createCoverage({ state, source });
parts.sequences = createSequences({ state, source, parts });
parts.viewerHost = createViewerHost({
state,
parts,
adapter:
photoViewer || createMapillaryViewer({ source, render: services.render }),
});
parts.viewerHost = createViewerHost(context);
parts.hasSelectedSequence = () =>
[...state.providers.values()].some(
(entry) => entry.instance.sequenceStats?.()?.selectedId,
);
parts.clearSequences = () => {
for (const entry of state.providers.values())
entry.instance.clearSequence?.();
};
parts.openImage = openImage;
parts.reportSequenceError = reportSequenceError;
parts.selection = createSelection(context);
let notifyQueued = false;
@@ -66,70 +85,64 @@ export function createStreetLevelLayer({
}
state.notify = notify;
function providerContext(entry) {
return Object.freeze({
services: state.services,
getFilter: () => resolveFilter(state.filter),
isActive: () => state.enabled && entry.on,
notify,
actions: {
openImage: (imageId) => openImage(entry.def.id, imageId),
/**
* 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) {
state.street.error = entry.reportedError = message;
} else {
if (!entry.reportedError) return;
if (state.street.error === entry.reportedError)
state.street.error = null;
entry.reportedError = null;
}
notify();
},
},
});
}
/** The error a sequence lookup last showed, until it withdraws it. */
let sequenceError = null;
for (const def of definitionsFrozen) {
const entry = {
def,
instance: null,
on: true,
status: null,
/** The error this provider last reported, until it withdraws it. */
reportedError: null,
};
entry.instance = def.create(providerContext(entry));
state.providers.set(def.id, entry);
}
const activeEntries = () =>
[...state.providers.values()].filter((entry) => entry.on);
async function refreshStatus(entry) {
try {
entry.status = await entry.instance.status();
} catch {
entry.status = null;
/**
* Show a sequence error, or withdraw it (null). A withdrawal clears only
* that error, never a newer one from the viewer.
*/
function reportSequenceError(message) {
if (message) {
state.street.error = sequenceError = message;
} else {
if (!sequenceError) return;
if (state.street.error === sequenceError) state.street.error = null;
sequenceError = null;
}
notify();
}
function activate(entry) {
if (!state.viewer) return;
entry.instance.activate(state.viewer);
parts.credits.show(state.viewer, entry.def);
if (!entry.status) refreshStatus(entry);
whenIdle(() => parts.viewerHost.prewarm([entry]), 1500);
/** Coverage waits for the key status: no tile requests without a key. */
async function checkKey() {
let configured;
try {
configured = (await source.getStatus())?.configured === true;
} catch {
configured = source.hasToken?.() === true;
}
parts.coverage.setKeyStatus(configured);
}
function deactivate(entry) {
parts.viewerHost.unmount(entry.def.id);
entry.instance.deactivate();
parts.credits.hide(state.viewer, entry.def);
function startDrawing() {
const viewer = state.viewer;
if (!viewer) return;
parts.sequences.setVisible(true);
parts.coverage.attach(viewer);
credit.show(viewer);
}
/** Close the photo, clear lines and cones, forget refusals. */
function stopDrawing() {
abortNearest();
parts.viewerHost.unmount();
parts.coverage.detach();
parts.coverage.clear();
parts.coverage.unblock();
parts.sequences.clearSelection();
parts.sequences.setVisible(false);
credit.hide(state.viewer);
}
/** The share link's `mapillary` switch: off, the layer draws nothing. */
function setProviderOn(on) {
if (state.providerOn === on) return;
state.providerOn = on;
if (state.enabled) {
if (on) startDrawing();
else stopDrawing();
}
notify();
}
/** The nearest-image lookup in flight; any newer user action aborts it. */
@@ -140,97 +153,62 @@ export function createStreetLevelLayer({
nearestLookup = null;
}
function openImage(providerId, imageId) {
function openImage(imageId) {
abortNearest();
return parts.viewerHost.open(providerId, imageId);
}
function setProviderEnabled(providerId, on) {
const entry = state.providers.get(providerId);
if (!entry) return false;
const next = on !== false;
if (entry.on === next) return true;
entry.on = next;
if (state.enabled) {
if (next) activate(entry);
else deactivate(entry);
}
notify();
return true;
return parts.viewerHost.open(imageId);
}
function setCoverageFilter(next) {
const filter = normalizeFilter(next, state.filter);
if (sameFilter(filter, state.filter)) return;
state.filter = filter;
const resolved = resolveFilter(filter);
for (const entry of state.providers.values())
entry.instance.setFilter(resolved);
parts.coverage.rebuild();
parts.sequences.rerender();
notify();
}
function providerSnapshots() {
return [...state.providers.values()].map((entry) => {
const stats = entry.instance.coverageStats();
return {
id: entry.def.id,
name: entry.def.name,
label: entry.def.label,
on: entry.on,
configured: entry.status ? entry.status.configured === true : null,
// A rejected key gates the provider exactly like a missing one.
keyRequired: stats.keyRequired === true || stats.keyRejected === true,
keyRejected: stats.keyRejected === true,
requiresKeyId: entry.def.requiresKeyId || null,
loading: stats.loading === true,
count: stats.count || 0,
hint: stats.hint || '',
error: stats.error || null,
color: entry.def.colors.coverage,
};
});
}
function sequenceSnapshot() {
// The provider showing the open image answers first.
const owner = state.providers.get(state.street.providerId);
const candidates = owner
? [owner, ...[...state.providers.values()].filter((e) => e !== owner)]
: [...state.providers.values()];
for (const entry of candidates) {
const stats = entry.instance.sequenceStats?.();
if (stats?.selectedId || stats?.loading)
return {
providerId: entry.def.id,
selectedId: stats.selectedId || null,
images: stats.images || 0,
loading: stats.loading === true,
};
}
return { providerId: null, selectedId: null, images: 0, loading: false };
function coverageStats() {
return state.providerOn ? parts.coverage.stats() : COVERAGE_OFF;
}
function getUIState() {
const { host, ...street } = state.street;
return composeUIState({
const { keyRequired, keyRejected, ...coverage } = coverageStats();
return {
enabled: state.enabled,
filter: state.filter,
providers: providerSnapshots(),
providerOn: state.providerOn,
// A rejected key gates the layer exactly like a missing one.
keyRequired,
keyRejected,
filter: { ...state.filter },
coverage,
legend: state.providerOn
? [
{
key: 'mapillary',
label: 'Mapillary',
color: MAPILLARY_COLORS.coverage,
},
{ key: 'selected', label: 'Selected', color: COLORS.selected },
]
: [],
sequence: {
selectedId: state.sequence.selectedId,
images: state.sequence.images.length,
loading: state.sequence.loading,
},
street,
sequence: sequenceSnapshot(),
});
};
}
const layer = {
id: STREET_LEVEL_LAYER_ID,
name: 'Street Level',
icon: '📷',
source: definitionsFrozen.map((def) => def.name).join(' · '),
source: 'Mapillary',
updateInterval: 0,
statsRefreshInterval: 1000,
requiresKeyId: requiresKeyIdFor(definitionsFrozen),
/** Registered providers, in chip order. */
providerIds: definitionsFrozen.map((def) => def.id),
requiresKeyId: MAPILLARY_KEY_ID,
init(viewer) {
if (state.initialized)
@@ -239,10 +217,9 @@ export function createStreetLevelLayer({
state.initialized = true;
parts.marker.ensure(viewer);
parts.marker.setVisible(false);
for (const entry of state.providers.values()) {
entry.instance.init(viewer);
refreshStatus(entry);
}
parts.sequences.ensureCollections(viewer);
parts.sequences.setVisible(false);
checkKey();
console.log('[Data:StreetLevel] Initialized');
},
@@ -251,16 +228,13 @@ export function createStreetLevelLayer({
state.viewer = viewer;
parts.marker.setVisible(true);
parts.selection.install(viewer);
for (const entry of activeEntries()) activate(entry);
if (state.providerOn) startDrawing();
notify();
},
disable() {
state.enabled = false;
abortNearest();
parts.viewerHost.unmount();
for (const entry of state.providers.values()) entry.instance.deactivate();
parts.credits.hideAll(state.viewer);
stopDrawing();
parts.selection.uninstall();
parts.marker.setVisible(false);
notify();
@@ -272,8 +246,7 @@ export function createStreetLevelLayer({
destroy(viewer = state.viewer) {
layer.disable();
for (const entry of state.providers.values())
entry.instance.destroy(viewer);
parts.sequences.destroy(viewer);
parts.marker.destroy(viewer);
parts.framing.attachNavigation(null);
state.listeners.clear();
@@ -283,10 +256,9 @@ export function createStreetLevelLayer({
},
getStats() {
// The lifecycle polls this every second: summarise, don't snapshot.
const coverage = summarizeCoverage(providerSnapshots());
let loadingLabel = '';
const coverage = coverageStats();
const keyLabel = coverage.keyRejected ? 'KEY REJECTED' : 'KEY REQUIRED';
let loadingLabel = '';
if (coverage.keyRequired) loadingLabel = keyLabel;
else if (coverage.loading) loadingLabel = 'loading coverage...';
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
@@ -294,11 +266,11 @@ export function createStreetLevelLayer({
count: coverage.count,
loading: coverage.loading,
keyRequired: coverage.keyRequired,
error: coverage.keyRequired
? coverage.keyRejected
? coverage.error || keyLabel
: keyLabel
: coverage.error,
// A rejected key's message names the fix; a missing one says so.
error:
coverage.keyRequired && !coverage.keyRejected
? keyLabel
: coverage.error,
loadingLabel,
};
},
@@ -312,21 +284,26 @@ export function createStreetLevelLayer({
parts.framing.attachNavigation(navigation);
},
/** Share-link and stored state: provider switches plus the filter. */
/**
* Share-link and stored state, in the codec's keys (src/data/layerState.js):
* the `mapillary` switch plus the filter.
*/
getParams() {
return encodeParams({
providers: [...state.providers].map(([id, entry]) => [id, entry.on]),
filter: state.filter,
});
return {
mapillary: state.providerOn,
pano: state.filter.pano,
sinceDays: state.filter.sinceDays,
};
},
/** Unknown keys and malformed values are ignored, so any link applies. */
setParams(params = {}) {
const decoded = decodeParams(params, {
providerIds: state.providers.keys(),
filter: state.filter,
});
for (const [id, on] of decoded.providers) setProviderEnabled(id, on);
setCoverageFilter(decoded.filter);
const input = params && typeof params === 'object' ? params : {};
if (typeof input.mapillary === 'boolean') setProviderOn(input.mapillary);
const next = {};
if ('pano' in input) next.pano = input.pano;
if ('sinceDays' in input) next.sinceDays = input.sinceDays;
setCoverageFilter(next);
return true;
},
@@ -337,80 +314,59 @@ export function createStreetLevelLayer({
return () => state.listeners.delete(listener);
},
getUIState,
/** The DOM element provider viewers render into. */
/** The DOM element the photo viewer renders into. */
attachViewerHost(element) {
parts.viewerHost.attach(element);
if (element && state.enabled)
whenIdle(() => parts.viewerHost.prewarm(activeEntries()), 1500);
},
setProviderEnabled,
/** Imagery filter for coverage, cones and nearest-image lookups. */
setCoverageFilter,
openImage,
/** Open the nearest image any active provider has around a point. */
/** Open the nearest image around a point (default: the view centre). */
async openNearest(point) {
if (!isActive(state)) return false;
const view = point || viewCentre(state.viewer);
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
return false;
abortNearest();
const lookup = new AbortController();
nearestLookup = lookup;
const { signal } = lookup;
state.street.loading = true;
state.street.error = null;
notify();
let lastError = null;
for (const entry of activeEntries()) {
let imageId = null;
try {
imageId = await entry.instance.nearestImage(
{ lat: view.lat, lon: view.lon },
{ signal },
);
} catch (error) {
// Overtaken: whatever replaced it owns the panel, errors included.
if (signal.aborted) return false;
// One provider failing (no key, offline) must not hide the others.
lastError = error;
continue;
}
if (signal.aborted) return false;
if (!state.enabled) break;
// This provider was switched off while it looked: try the next.
if (!imageId || !entry.on) continue;
nearestLookup = null;
return openImage(entry.def.id, imageId);
let imageId = null;
let failure = null;
try {
imageId = await nearestImageId(
source,
{ lat: view.lat, lon: view.lon },
resolveFilter(state.filter),
{ signal: lookup.signal },
);
} catch (error) {
failure = error;
}
if (nearestLookup === lookup) nearestLookup = null;
// Switched off while it looked: nothing to open, nothing to report.
if (state.enabled && activeEntries().length)
state.street.error =
lastError?.message ||
`No street-level imagery within ${NEAREST_RADIUS_M} m of the view centre`;
// Overtaken, closed or switched off: whatever did that owns the panel.
if (lookup.signal.aborted) return false;
nearestLookup = null;
if (imageId) return openImage(imageId);
state.street.error =
failure?.message ||
`No street-level imagery within ${NEAREST_RADIUS_M} m of the view centre`;
state.street.loading = false;
notify();
return false;
},
/** Close the image and deselect it everywhere on the map. */
/** Close the image and deselect its sequence on the map. */
closeViewer() {
abortNearest();
parts.viewerHost.close();
parts.clearSequences();
parts.sequences.clearSelection();
},
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
resizeViewer: () => parts.viewerHost.resize(),
selectSequence(
sequenceId,
providerId = state.providers.keys().next().value,
) {
return state.providers
.get(providerId)
?.instance.selectSequence?.(sequenceId);
},
clearSequence: () => parts.clearSequences(),
refreshCoverage() {
for (const entry of activeEntries()) entry.instance.refreshCoverage();
},
selectSequence: (sequenceId) => parts.sequences.select(sequenceId),
clearSequence: () => parts.sequences.clearSelection(),
refreshCoverage: () => parts.coverage.refresh(),
};
return layer;
}
+226 -221
View File
@@ -3,14 +3,19 @@ import test from 'node:test';
import * as Cesium from 'cesium';
import { createStreetLevelLayer } from './index.js';
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
import { createMapillaryProvider } from './providers/mapillary/index.js';
import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs';
import {
fakeMapillarySource,
fakePhotoViewer,
rayCamera,
} from '../../testSupport/streetLevelFakes.mjs';
/**
* A stand-in Cesium viewer the layer can be enabled on: a canvas for the
* click handler, camera events, a primitive list and a credit display.
* click handler, camera events, a primitive list and a credit display. With
* `view`, the camera looks straight down on Sacramento from 900 m, so
* coverage asks for tiles.
*/
function fakeViewer() {
function fakeViewer({ view = false } = {}) {
const credits = [];
/** Cesium's flight bookkeeping: a new flight cancels the current one. */
let flight = null;
@@ -26,19 +31,20 @@ function fakeViewer() {
// Keep Cesium's handler on the canvas; there is no real document here.
disableRootEvents: true,
onwheel: null,
...(view ? { clientWidth: 100, clientHeight: 100 } : {}),
});
return {
credits,
flights,
scene: {
canvas,
primitives: { add: (p) => p, remove() {} },
// Enough of a scene for the position marker to clamp to the ground.
frameState: { mode: Cesium.SceneMode.SCENE3D },
updateHeight: () => () => {},
getHeight: () => undefined,
},
camera: {
const camera = Object.assign(
view
? rayCamera({
lon: -121.4944,
lat: 38.5816,
altitude: 900,
pitch: -90,
width: 100,
height: 100,
})
: {},
{
changed: new Cesium.Event(),
moveStart: new Cesium.Event(),
moveEnd: new Cesium.Event(),
@@ -51,6 +57,20 @@ function fakeViewer() {
if (stopFlight()) flights.cancelled++;
},
},
);
return {
credits,
flights,
scene: {
canvas,
primitives: { add: (p) => p, remove() {} },
groundPrimitives: { add: (p) => p, remove() {} },
// Enough of a scene for the position marker to clamp to the ground.
frameState: { mode: Cesium.SceneMode.SCENE3D },
updateHeight: () => () => {},
getHeight: () => undefined,
},
camera,
creditDisplay: {
addStaticCredit: (credit) => credits.push(credit),
removeStaticCredit: (credit) =>
@@ -59,24 +79,41 @@ function fakeViewer() {
};
}
/** A layer over `providers`, initialised and enabled on a stand-in viewer. */
async function enabledLayer(t, providers = [fakeProvider()]) {
/**
* A layer over `source` and `photoViewer`, initialised on a stand-in viewer
* and (unless `enable` is false) enabled.
*/
async function startLayer(
t,
{
source = fakeMapillarySource(),
photoViewer = fakePhotoViewer(),
view = false,
enable = true,
} = {},
) {
const saved = globalThis.document;
// A pose moves the position marker, whose glyph is drawn on a canvas.
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const layer = createStreetLevelLayer({ providers });
// Skip Cesium's one-time terrain table download for draped lines.
t.mock.method(
Cesium.GroundPolylinePrimitive,
'initializeTerrainHeights',
async () => {},
);
const viewer = fakeViewer({ view });
const layer = createStreetLevelLayer({ source, photoViewer });
layer.init(viewer);
layer.enable(viewer);
if (enable) layer.enable(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
await settle();
return { layer, viewer };
return { layer, viewer, source, photoViewer };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
@@ -91,180 +128,194 @@ function deferred() {
return { promise, resolve, reject };
}
/** A provider whose viewer reports a pose for every image it opens. */
function posingProvider() {
const provider = fakeProvider();
const create = provider.create;
provider.create = (context) => {
const instance = create(context);
let emit = null;
instance.viewer.onPose = (listener) => {
emit = listener;
return () => {
emit = null;
};
};
instance.viewer.open = async (imageId) => {
provider.calls.open.push(imageId);
emit?.({
providerId: 'mapillary',
imageId,
position: { lon: -121.49, lat: 38.58 },
bearing: 90,
});
};
return instance;
};
return provider;
}
/** A photo viewer reporting a pose for every image it opens. */
const posingViewer = () =>
fakePhotoViewer({ pose: { position: { lon: -121.49, lat: 38.58 } } });
/** nearestImage lookups that answer when the test says, with their signals. */
/** Nearest-image lookups that answer when the test says, with their signals. */
function slowLookups() {
const lookups = [];
const nearestImage = (point, { signal } = {}) => {
const nearestImages = (query, { signal } = {}) => {
const answer = deferred();
lookups.push({ point, signal, answer });
return answer.promise;
lookups.push({ query, signal, answer });
return answer.promise.then((id) => [{ id, is_pano: false }]);
};
return { lookups, nearestImage };
return { lookups, source: fakeMapillarySource({ nearestImages }) };
}
test('the CC BY-SA credit shows while a provider is on and goes with it', async (t) => {
const { layer, viewer } = await enabledLayer(t);
const shown = () => viewer.credits.map((credit) => credit.html);
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on activate');
assert.match(shown()[0], /CC BY-SA 4\.0/);
layer.setProviderEnabled('mapillary', false);
assert.deepEqual(shown(), [], 'hidden with the provider');
layer.setProviderEnabled('mapillary', true);
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]);
layer.disable();
assert.deepEqual(shown(), [], 'every credit goes with the layer');
test('the layer is Mapillary under the codec keys it has always had', () => {
const layer = createStreetLevelLayer({ source: fakeMapillarySource() });
assert.equal(layer.id, 'street-level');
assert.equal(layer.requiresKeyId, 'mapillary');
assert.deepEqual(layer.getParams(), {
mapillary: true,
pano: 'all',
sinceDays: 0,
});
assert.throws(() => createStreetLevelLayer({ source: {} }), /source/);
});
test('getStats tells a rejected key from a missing one', async (t) => {
const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]);
provider.stats.loading = true;
assert.equal(layer.getStats().loadingLabel, 'loading coverage...');
assert.equal(layer.getStats().keyRequired, false);
test('the CC BY-SA credit shows while Mapillary is on and goes with it', async (t) => {
const { layer, viewer } = await startLayer(t);
const shown = () => viewer.credits.map((credit) => credit.html);
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on enable');
assert.match(shown()[0], /CC BY-SA 4\.0/);
layer.setParams({ mapillary: false });
assert.deepEqual(shown(), [], 'hidden with the switch');
assert.deepEqual(layer.getUIState().legend, [], 'no legend either');
layer.setParams({ mapillary: true });
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]);
layer.disable();
assert.deepEqual(shown(), [], 'the credit goes with the layer');
});
provider.stats.keyRequired = true;
assert.deepEqual(
[layer.getStats().loadingLabel, layer.getStats().error],
['KEY REQUIRED', 'KEY REQUIRED'],
);
test('coverage asks for no tiles before the key status is known, and none without a key', async (t) => {
for (const configured of [false, true]) {
const status = deferred();
const source = fakeMapillarySource({ getStatus: () => status.promise });
const { layer } = await startLayer(t, { source, view: true });
assert.equal(source.calls.tiles.length, 0, 'nothing before the status');
status.resolve({ configured });
await settle();
if (configured) assert.ok(source.calls.tiles.length > 0, 'then coverage');
else {
assert.equal(
source.calls.tiles.length,
0,
'a key-less install never asks',
);
assert.deepEqual(
[layer.getStats().loadingLabel, layer.getStats().error],
['KEY REQUIRED', 'KEY REQUIRED'],
);
assert.equal(layer.getUIState().keyRequired, true);
assert.equal(layer.getUIState().keyRejected, false);
}
layer.destroy();
}
});
// A rejected key gates the provider exactly like a missing one.
provider.stats.keyRequired = false;
provider.stats.keyRejected = true;
provider.stats.error = 'Mapillary rejected MAPILLARY_CLIENT_TOKEN';
test('a rejected key gates the layer as KEY REJECTED until it goes off', async (t) => {
const rejected = Object.assign(new Error('Mapillary rejected the token'), {
keyRejected: true,
});
const source = fakeMapillarySource({
getTile: async () => {
throw rejected;
},
});
const { layer, viewer } = await startLayer(t, { source, view: true });
await settle();
const stats = layer.getStats();
assert.equal(stats.keyRequired, true);
assert.equal(stats.loadingLabel, 'KEY REJECTED');
assert.equal(stats.error, 'Mapillary rejected MAPILLARY_CLIENT_TOKEN');
assert.match(stats.error, /rejected MAPILLARY_CLIENT_TOKEN/);
const ui = layer.getUIState();
assert.equal(ui.keyRequired, true);
assert.equal(ui.keyRejected, true);
assert.equal(ui.providers[0].keyRequired, true);
assert.deepEqual([ui.keyRequired, ui.keyRejected], [true, true]);
layer.disable();
layer.enable(viewer);
assert.equal(layer.getUIState().keyRejected, false, 'asked again once on');
});
test('switching a provider off deactivates it and closes the image it shows', async (t) => {
const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]);
test('switching Mapillary off clears coverage and closes the image it shows', async (t) => {
const { layer, photoViewer } = await startLayer(t);
layer.attachViewerHost({});
assert.equal(await layer.openImage('mapillary', 'img1'), true);
assert.deepEqual(provider.calls.open, ['img1']);
assert.equal(await layer.openImage('img1'), true);
assert.deepEqual(photoViewer.calls.open, ['img1']);
assert.equal(layer.getUIState().street.open, true);
layer.setProviderEnabled('mapillary', false);
assert.equal(provider.calls.deactivate, 1);
assert.equal(provider.calls.unmount, 1, 'its viewer is released');
layer.setParams({ mapillary: false });
assert.equal(photoViewer.calls.unmount, 1, 'its viewer is released');
assert.equal(layer.getUIState().street.open, false);
assert.equal(layer.getUIState().street.providerId, null);
assert.equal(layer.getUIState().providerOn, false);
});
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]);
const source = fakeMapillarySource({
nearestImages: async () => [{ id: 1 }],
});
const { layer, photoViewer } = await startLayer(t, { source });
assert.equal(await layer.openNearest({ lat: 38.58, lon: -121.49 }), false);
const { street } = layer.getUIState();
assert.equal(street.loading, false);
assert.match(street.error, /Open the Street Level panel/);
assert.equal(provider.calls.mount, 0);
assert.equal(photoViewer.calls.mount, 0);
});
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]);
layer.attachViewerHost({});
const opening = layer.openNearest({ lat: 38.58, lon: -121.49 });
layer.disable();
answer.resolve('img1');
assert.equal(await opening, false);
assert.equal(provider.calls.mount, 0, 'no viewer was stood up');
const { street } = layer.getUIState();
assert.equal(street.open, false);
assert.equal(street.loading, false);
assert.equal(street.error, null);
test('openNearest opens nothing once the layer or its switch went off during the lookup', async (t) => {
for (const off of [
(layer) => layer.disable(),
(layer) => layer.setParams({ mapillary: false }),
]) {
const { lookups, source } = slowLookups();
const { layer, photoViewer } = await startLayer(t, { source });
layer.attachViewerHost({});
const opening = layer.openNearest({ lat: 38.58, lon: -121.49 });
off(layer);
lookups[0].answer.resolve('img1');
assert.equal(await opening, false);
assert.equal(lookups[0].signal.aborted, true, 'the request was aborted');
assert.equal(photoViewer.calls.mount, 0, 'no viewer was stood up');
const { street } = layer.getUIState();
assert.deepEqual(
[street.open, street.loading, street.error],
[false, false, null],
);
layer.destroy();
}
});
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]);
layer.attachViewerHost({});
const opening = layer.openNearest({ lat: 38.58, lon: -121.49 });
layer.setProviderEnabled('mapillary', false);
answer.resolve('img1');
assert.equal(await opening, false);
assert.deepEqual(provider.calls.open, []);
assert.equal(layer.getUIState().street.loading, false);
});
test('a provider withdraws only the error it reported', async (t) => {
const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]);
const { actions } = provider.context();
actions.reportError('Sequence images unavailable');
test('a sequence error is withdrawn with the sequence, never the viewer’s own', async (t) => {
const source = fakeMapillarySource({
getSequenceImages: async () => {
throw new Error('Sequence images unavailable');
},
});
const { layer } = await startLayer(t, { source });
await layer.selectSequence('seq-1');
assert.equal(layer.getUIState().street.error, 'Sequence images unavailable');
actions.reportError(null);
layer.clearSequence();
assert.equal(layer.getUIState().street.error, null, 'withdrawn');
// The viewer's own error is not the provider's to clear.
// The viewer's own error is not the sequence's to clear.
await layer.selectSequence('seq-2');
layer.attachViewerHost(null);
actions.reportError('Sequence images unavailable');
await layer.openImage('mapillary', 'img1');
actions.reportError(null);
await layer.openImage('img1');
layer.clearSequence();
assert.match(layer.getUIState().street.error, /Open the Street Level panel/);
});
test('closing the photo stops the globe flying to it', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
const { layer, viewer } = await startLayer(t, {
photoViewer: posingViewer(),
});
layer.attachViewerHost({});
assert.equal(await layer.openImage('mapillary', 'img1'), true);
assert.equal(await layer.openImage('img1'), true);
assert.equal(viewer.flights.started, 1, 'the photo is framed');
layer.closeViewer();
assert.equal(viewer.flights.cancelled, 1);
});
test('switching the layer off or destroying it stops the framing flight', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
const { layer, viewer } = await startLayer(t, {
photoViewer: posingViewer(),
});
layer.attachViewerHost({});
await layer.openImage('mapillary', 'img1');
await layer.openImage('img1');
layer.disable();
assert.equal(viewer.flights.cancelled, 1, 'layer off');
layer.enable(viewer);
await layer.openImage('mapillary', 'img2');
await layer.openImage('img2');
layer.destroy();
assert.equal(viewer.flights.cancelled, 2, 'destroyed');
});
test('closing the photo leaves a newer navigation flight alone', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
const { layer, viewer } = await startLayer(t, {
photoViewer: posingViewer(),
});
layer.attachViewerHost({});
await layer.openImage('mapillary', 'img1');
await layer.openImage('img1');
assert.equal(viewer.flights.started, 1, 'the photo is being framed');
// A search result flies the globe elsewhere before the framing lands.
viewer.camera.flyToBoundingSphere(null, {});
@@ -273,9 +324,8 @@ test('closing the photo leaves a newer navigation flight alone', 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]);
const { lookups, source } = slowLookups();
const { layer, photoViewer } = await startLayer(t, { source });
layer.attachViewerHost({});
const older = layer.openNearest({ lat: 38.58, lon: -121.49 });
const newer = layer.openNearest({ lat: 38.59, lon: -121.48 });
@@ -283,29 +333,27 @@ test('an older nearest lookup that answers late cannot replace a newer one', asy
assert.equal(await newer, true);
lookups[0].answer.resolve('older');
assert.equal(await older, false);
assert.deepEqual(provider.calls.open, ['newer']);
assert.deepEqual(photoViewer.calls.open, ['newer']);
assert.equal(lookups[0].signal?.aborted, true, 'its request was aborted');
assert.equal(lookups[1].signal?.aborted, false);
});
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]);
const { lookups, source } = slowLookups();
const { layer, photoViewer } = await startLayer(t, { source });
layer.attachViewerHost({});
const lookup = layer.openNearest({ lat: 38.58, lon: -121.49 });
// A cone click goes through the provider's openImage action.
assert.equal(await provider.context().actions.openImage('picked'), true);
// A cone click opens its image directly.
assert.equal(await layer.openImage('picked'), true);
lookups[0].answer.resolve('nearest');
assert.equal(await lookup, false);
assert.deepEqual(provider.calls.open, ['picked']);
assert.deepEqual(photoViewer.calls.open, ['picked']);
assert.equal(lookups[0].signal?.aborted, true);
});
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]);
const { lookups, source } = slowLookups();
const { layer, viewer, photoViewer } = await startLayer(t, { source });
layer.attachViewerHost({});
const closed = layer.openNearest({ lat: 38.58, lon: -121.49 });
@@ -325,7 +373,7 @@ test('closing the viewer or switching the layer off retires a nearest lookup', a
layer.enable(viewer);
lookups[1].answer.resolve('img1');
assert.equal(await disabled, false, 'nor does it open once back on');
assert.deepEqual(provider.calls.open, []);
assert.deepEqual(photoViewer.calls.open, []);
({ street } = layer.getUIState());
assert.deepEqual(
[street.open, street.loading, street.error],
@@ -334,36 +382,8 @@ test('closing the viewer or switching the layer off retires a nearest lookup', a
assert.equal(lookups[1].signal?.aborted, true, 'layer off aborts it too');
});
test('the Mapillary provider hands the lookup signal to its source', async () => {
const requests = [];
const source = {
hasToken: () => true,
getStatus: async () => ({ configured: true }),
getTile: async () => new Uint8Array(0),
getSequenceImages: async () => [],
nearestImages: async (query, options) => {
requests.push(options);
return [{ id: 7, is_pano: false, captured_at: 10 }];
},
};
const instance = createMapillaryProvider({ source }).create({
services: {},
getFilter: () => ({ pano: 'all', sinceMs: null }),
isActive: () => true,
notify() {},
actions: { openImage() {}, reportError() {} },
});
const { signal } = new AbortController();
assert.equal(
await instance.nearestImage({ lat: 1, lon: 2 }, { signal }),
'7',
);
assert.equal(requests[0]?.signal, signal);
});
test('setParams takes "any date" (0 days) and a provider switch over the current values (share-link defaults)', async (t) => {
const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]);
test('setParams takes "any date" (0 days) and the switch over the current values (share-link defaults)', async (t) => {
const { layer } = await startLayer(t);
layer.setParams({ pano: 'flat', sinceDays: 365 });
assert.equal(layer.getParams().sinceDays, 365);
layer.setParams({ sinceDays: 0 });
@@ -372,54 +392,39 @@ test('setParams takes "any date" (0 days) and a provider switch over the current
pano: 'flat',
sinceDays: 0,
});
assert.equal(
provider.filters.at(-1).sinceMs,
null,
'providers drop the cut-off',
);
layer.setParams({ mapillary: false });
assert.equal(layer.getParams().mapillary, false);
});
/** A layer initialised on a stand-in viewer but never enabled. */
function initialisedLayer(t, providers) {
const saved = globalThis.document;
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
test('setParams ignores unknown keys and malformed values', async (t) => {
const { layer } = await startLayer(t);
layer.setParams({ pano: 'pano', sinceDays: 730 });
layer.setParams({ mapillary: '0', kartaview: true, pano: 'weird', x: 1 });
layer.setParams({ sinceDays: -3 });
layer.setParams(null);
assert.deepEqual(layer.getParams(), {
mapillary: true,
pano: 'pano',
sinceDays: 730,
});
const viewer = fakeViewer();
const layer = createStreetLevelLayer({ providers });
layer.init(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
return { layer, viewer };
}
});
test('switching a provider on while the layer is off activates nothing (M01)', async (t) => {
const provider = fakeProvider();
const prewarmed = [];
const create = provider.create;
provider.create = (context) => {
const instance = create(context);
instance.viewer.prewarm = async (host) => prewarmed.push(host);
return instance;
};
const { layer, viewer } = initialisedLayer(t, [provider]);
test('switching Mapillary on while the layer is off draws nothing (M01)', async (t) => {
const { layer, viewer, source, photoViewer } = await startLayer(t, {
view: true,
enable: false,
});
layer.attachViewerHost({});
layer.setProviderEnabled('mapillary', false);
layer.setProviderEnabled('mapillary', true);
layer.setParams({ mapillary: false });
layer.setParams({ mapillary: true });
await new Promise((resolve) => setTimeout(resolve, 40)); // past whenIdle
assert.equal(provider.calls.activate, 0, 'no coverage drawn');
await settle();
assert.equal(source.calls.tiles.length, 0, 'no coverage drawn');
assert.deepEqual(viewer.credits, [], 'no credit shown');
assert.deepEqual(prewarmed, [], 'no viewer stood up');
assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept');
// Enabling the layer is what activates it, once.
assert.equal(photoViewer.calls.mount, 0, 'no viewer stood up');
assert.equal(layer.getUIState().providerOn, true, 'the switch is kept');
// Enabling the layer is what draws it.
layer.enable(viewer);
assert.equal(provider.calls.activate, 1);
await settle();
assert.ok(source.calls.tiles.length > 0);
assert.equal(viewer.credits.length, 1);
});
-32
View File
@@ -1,32 +0,0 @@
import { normalizeFilter } from './filter.js';
/**
* 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 }) {
const params = {};
for (const [id, on] of providers) params[id] = on === true;
params.pano = filter.pano;
params.sinceDays = filter.sinceDays;
return params;
}
/**
* 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}}}
*/
export function decodeParams(params, { providerIds, filter }) {
const providers = new Map();
const source = params && typeof params === 'object' ? params : {};
for (const id of providerIds)
if (typeof source[id] === 'boolean') providers.set(id, source[id]);
const next = {};
if ('pano' in source) next.pano = source.pano;
if ('sinceDays' in source) next.sinceDays = source.sinceDays;
return { providers, filter: normalizeFilter(next, filter) };
}
-58
View File
@@ -1,58 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { decodeParams, encodeParams } from './params.js';
test('encodeParams writes one boolean per provider plus the filter', () => {
assert.deepEqual(
encodeParams({
providers: [
['mapillary', true],
['panoramax', false],
],
filter: { pano: 'flat', sinceDays: 365 },
}),
{ mapillary: true, panoramax: false, pano: 'flat', sinceDays: 365 },
);
});
test('decodeParams round-trips and ignores unknown or malformed keys', () => {
const current = { pano: 'all', sinceDays: 0 };
const decoded = decodeParams(
{ mapillary: false, kartaview: true, pano: 'pano', sinceDays: 730, x: 1 },
{ providerIds: ['mapillary'], filter: current },
);
assert.deepEqual([...decoded.providers], [['mapillary', false]]);
assert.deepEqual(decoded.filter, { pano: 'pano', sinceDays: 730 });
const partial = decodeParams(
{ mapillary: '1', pano: 'weird' },
{ providerIds: ['mapillary'], filter: { pano: 'flat', sinceDays: 5 } },
);
assert.equal(partial.providers.size, 0, 'a string is not a switch');
assert.deepEqual(partial.filter, { pano: 'flat', sinceDays: 5 });
const empty = decodeParams(null, {
providerIds: ['mapillary'],
filter: current,
});
assert.equal(empty.providers.size, 0);
assert.deepEqual(empty.filter, current);
});
test('decodeParams applies "any date" (0 days) over a current window (share-link defaults)', () => {
const decoded = decodeParams(
{ sinceDays: 0 },
{ providerIds: ['mapillary'], filter: { pano: 'flat', sinceDays: 365 } },
);
assert.deepEqual(decoded.filter, { pano: 'flat', sinceDays: 0 });
// The provider switch off is a value too, not an absence.
assert.deepEqual(
[
...decodeParams(
{ mapillary: false },
{ providerIds: ['mapillary'], filter: { pano: 'all', sinceDays: 0 } },
).providers,
],
[['mapillary', false]],
);
});
-21
View File
@@ -1,21 +0,0 @@
/**
* 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.
*/
export function createPickRouter(getProviders, { positionId = null } = {}) {
function resolve(id) {
if (typeof id !== 'string' || !id) return null;
if (positionId && id === positionId)
return { providerId: null, instance: null, id };
for (const entry of getProviders())
if (id.startsWith(entry.def.pickPrefix))
return { providerId: entry.def.id, instance: entry.instance, id };
return null;
}
return {
ownsPick: (id) => resolve(id) !== null,
resolve,
};
}
@@ -1,36 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPickRouter } from './pickRouter.js';
const providers = [
{ def: { id: 'mapillary', pickPrefix: 'mly:' }, instance: { name: 'm' } },
{ def: { id: 'panoramax', pickPrefix: 'pnx:' }, instance: { name: 'p' } },
];
test('ids are routed by prefix to their provider instance', () => {
const router = createPickRouter(() => providers, { positionId: 'sl:pos' });
assert.deepEqual(router.resolve('mly:seq:1'), {
providerId: 'mapillary',
instance: providers[0].instance,
id: 'mly:seq:1',
});
assert.equal(router.resolve('pnx:img:9').providerId, 'panoramax');
assert.equal(router.ownsPick('mly:img:2'), true);
});
test('the core-owned marker is owned but routes to no provider', () => {
const router = createPickRouter(() => providers, { positionId: 'sl:pos' });
assert.deepEqual(router.resolve('sl:pos'), {
providerId: null,
instance: null,
id: 'sl:pos',
});
assert.equal(router.ownsPick('sl:pos'), true);
});
test('foreign and malformed ids are not ours', () => {
const router = createPickRouter(() => providers);
for (const id of ['cctv:1', '', null, undefined, 42, {}])
assert.equal(router.ownsPick(id), false, String(id));
assert.equal(router.resolve('sl:pos'), null, 'no marker id configured');
});
+4 -12
View File
@@ -1,15 +1,10 @@
/** Identity and shared tuning for the provider-neutral Street Level layer. */
/** Identity and shared tuning for the Street Level layer. */
export const STREET_LEVEL_LAYER_ID = 'street-level';
/** Pick id of the viewer position marker; the core owns it, not a provider. */
/** Pick id of the viewer position marker. */
export const POSITION_PICK_ID = 'sl:pos';
/** One colour per imagery source: chip, coverage lines, points, cones and legend. */
export const PROVIDER_COLORS = Object.freeze({
mapillary: '#05cb63',
});
/** Colours every provider shares: the selection highlight and the marker. */
/** The selection highlight and the position marker. */
export const COLORS = Object.freeze({
selected: '#00d4ff',
position: '#ffb300',
@@ -21,8 +16,5 @@ export const PANO_MODES = Object.freeze(['all', 'pano', 'flat']);
/** Longest "captured since" window the filter accepts, in days (~100 years). */
export const MAX_SINCE_DAYS = 36_500;
/** Filter every provider starts with: all imagery, any date. */
/** Filter the layer starts with: all imagery, any date. */
export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
/** Nearest-image search radius in metres (Mapillary's graph API caps it at 50 m). */
export const NEAREST_RADIUS_M = 50;
@@ -1,31 +1,18 @@
import * as Cesium from 'cesium';
import { decodeCoverageTile } from './decode.js';
import { passesImageryFilter } from '../../filter.js';
import {
createHorizonCull,
groundUnderCamera,
metresBetween,
viewFocus,
visibleBbox,
} from '../../view.js';
import {
coverageZoomForHeight,
overviewZoomForHeight,
tileBounds,
tilesForBbox,
} from '../../tileMath.js';
import { passesImageryFilter, resolveFilter } from '../../filter.js';
import { isActive } from '../../state.js';
import { cameraNadir, groundUnderCamera, visibleBbox } from '../../view.js';
import { coverageZoomForHeight, tilesForBbox } from '../../tileMath.js';
import {
COLORS,
COVERAGE_LINE_WIDTH_PX,
COVERAGE_MAX_SEQUENCES,
COVERAGE_MAX_TILES,
COVERAGE_MOVE_DEBOUNCE_MS,
COVERAGE_OVERVIEW_MAX_TILES,
COVERAGE_OVERVIEW_POINT_PX,
KEY_REJECTED_MESSAGE,
PICK_PREFIX,
RATE_LIMITED_MESSAGE,
SEQUENCE_VIEW_NEAR_M,
SEQUENCE_VIEW_RANGE_MIN_M,
SEQUENCE_VIEW_RANGE_PER_HEIGHT,
} from './policy.js';
@@ -67,9 +54,15 @@ export function sequenceIdFromPick(pickId) {
return cut === -1 ? rest : rest.slice(0, cut);
}
/** Hint above the street-zoom ceiling, where no coverage is drawn. */
export const ZOOM_IN_HINT = 'Zoom in to see street-level coverage';
/** Hint when no ground is in view within range (looking at the sky). */
export const NO_GROUND_HINT =
'Point the camera at the ground for street-level coverage';
/**
* Camera-driven coverage: overview points (z0–5) from orbit, sequence lines
* (z11–14) near the ground, draped on terrain and 3D tiles alike.
* Camera-driven coverage: Mapillary sequence lines (z11–14) near the ground,
* draped on terrain and 3D tiles alike. Nothing is drawn from orbit.
*/
export function createCoverage({ state, source }) {
const { render } = state.services;
@@ -89,7 +82,7 @@ export function createCoverage({ state, source }) {
/** The imagery filter, resolved now: "since N days" moves with the clock. */
function filter() {
return state.context.getFilter();
return resolveFilter(state.filter);
}
/**
@@ -154,65 +147,17 @@ export function createCoverage({ state, source }) {
return primitives;
}
function buildOverviewCollection(points) {
const collection = new Cesium.PointPrimitiveCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
const green = Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(
0.85,
);
const drawn = [];
const current = filter();
for (const point of points) {
if (!passesImageryFilter(point, current)) continue;
drawn.push(
collection.add({
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat),
color: green,
pixelSize: COVERAGE_OVERVIEW_POINT_PX,
// Google 3D terrain and clouds must not hide the near side's dots;
// the horizon cull hides the far side's, which this lets through.
disableDepthTestDistance: Number.POSITIVE_INFINITY,
}),
);
}
return { collection, points: drawn };
}
/** Every overview point on the globe, current zoom and old alike. */
function* overviewPoints() {
for (const entry of drawnEntries())
if (entry.overviewPoints) yield* entry.overviewPoints;
}
// Overview points skip the depth test: hide the far hemisphere's.
const horizon = createHorizonCull({
getViewer: () => state.viewer,
items: overviewPoints,
onChange: requestRender,
});
function attachPrimitive(entry) {
const scene = state.viewer?.scene;
if (!scene) return;
if (entry.kind === 'sequence') {
// Lookup, picking and counts follow what is drawn, not the whole tile.
const sequences = drawnSequences(entry);
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
entry.primitives = buildSequencePrimitives(sequences);
entry.count = sequences.length;
for (const { primitive } of entry.primitives)
scene.groundPrimitives.add(primitive);
watchSelection();
} else {
const { collection, points } = buildOverviewCollection(entry.points);
entry.primitive = collection;
entry.overviewPoints = points;
entry.count = points.length;
scene.primitives.add(collection);
// Cull the new points now, then all of them whenever the camera moves.
horizon.update(points);
}
// Lookup, picking and counts follow what is drawn, not the whole tile.
const sequences = drawnSequences(entry);
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
entry.primitives = buildSequencePrimitives(sequences);
entry.count = sequences.length;
for (const { primitive } of entry.primitives)
scene.groundPrimitives.add(primitive);
watchSelection();
}
function detachPrimitive(entry) {
@@ -225,15 +170,6 @@ export function createCoverage({ state, source }) {
}
}
entry.primitives = [];
if (entry.primitive) {
try {
scene?.primitives?.remove(entry.primitive);
} catch {
/* already gone */
}
entry.primitive = null;
entry.overviewPoints = null;
}
}
function removeTile(key) {
@@ -243,7 +179,7 @@ export function createCoverage({ state, source }) {
state.coverage.tiles.delete(key);
}
async function loadTile(tile, kind) {
async function loadTile(tile) {
const key = tileKey(tile);
if (state.coverage.tiles.has(key) || state.coverage.pending.has(key))
return;
@@ -257,43 +193,23 @@ export function createCoverage({ state, source }) {
source.getTile('coverage', tile.z, tile.x, tile.y, {
signal: controller.signal,
}),
kind === 'sequence' ? ensureTerrainReady() : null,
ensureTerrainReady(),
]);
// 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);
if (
controller.signal.aborted ||
!current() ||
kind !== state.coverage.kind ||
state.coverage.pending.get(key) !== controller ||
state.coverage.tiles.has(key) ||
tile.z !== state.coverage.zoom
)
return;
const decoded = decodeCoverageTile(bytes, tile);
const entry = { kind, primitive: null, primitives: [], count: 0 };
// Padded: vector tiles carry a small buffer past their edge.
const bounds = tileBounds(tile.x, tile.y, tile.z);
const pad = (bounds.east - bounds.west) * 0.05;
entry.bounds = {
west: bounds.west - pad,
east: bounds.east + pad,
south: bounds.south - pad,
north: bounds.north + pad,
};
if (kind === 'sequence') {
// Newest first; the per-tile cap applies to what passes the filter.
entry.sequenceList = decoded.sequences.sort(
(a, b) => (b.capturedAt || 0) - (a.capturedAt || 0),
);
entry.sequences = new Map();
entry.total = decoded.sequences.length;
} else {
entry.points = decoded.overview;
entry.sequences = new Map();
entry.total = decoded.overview.length;
}
const entry = { primitives: [], count: 0, sequences: new Map() };
// Newest first; the per-tile cap applies to what passes the filter.
entry.sequenceList = decoded.sequences.sort(
(a, b) => (b.capturedAt || 0) - (a.capturedAt || 0),
);
attachPrimitive(entry);
state.coverage.tiles.set(key, entry);
state.coverage.lastError = null;
@@ -304,14 +220,11 @@ export function createCoverage({ state, source }) {
state.coverage.pending.get(key) !== controller
)
return;
state.coverage.lastError = error?.message || 'Coverage tile failed';
if (error?.keyRequired) state.keyRequired = true;
if (error?.keyRejected) {
// Every other tile would be refused too: stop asking.
state.keyRejected = true;
state.coverage.lastError = KEY_REJECTED_MESSAGE;
}
if (error?.retryAfterSec) holdFor(error.retryAfterSec);
// Every other tile would be refused too: stop asking.
if (error?.keyRejected) block('rejected');
else if (error?.keyRequired) block('no-key');
else if (error?.retryAfterSec) block('rate-limited', error.retryAfterSec);
else state.coverage.lastError = error?.message || 'Coverage tile failed';
} finally {
// Only the request that still owns the key settles it: a superseded one
// must not drop a newer request's entry (which is what counts as loading).
@@ -324,29 +237,42 @@ export function createCoverage({ state, source }) {
}
/**
* Mapillary is rate-limiting: keep what is drawn, request nothing until the
* wait is over, then refresh once.
* Pause tile requests (see `blocked` in state.js). A rate limit keeps what
* is drawn and refreshes once its wait is over; a key problem outranks it,
* and a rejected key holds until `unblock` (the layer goes off).
*/
function holdFor(seconds) {
clearTimeout(state.coverage.holdTimer);
state.coverage.holdUntil = Date.now() + seconds * 1000;
state.coverage.lastError = RATE_LIMITED_MESSAGE;
state.coverage.holdTimer = setTimeout(() => {
state.coverage.holdTimer = null;
state.coverage.holdUntil = 0;
if (state.coverage.lastError === RATE_LIMITED_MESSAGE)
state.coverage.lastError = null;
refresh();
}, seconds * 1000);
function block(reason, retryAfterSec = 0) {
const current = state.coverage.blocked;
if (current === 'rejected') return;
if (reason === 'rate-limited' && current === 'no-key') return;
clearTimeout(state.coverage.blockTimer);
state.coverage.blockTimer = null;
state.coverage.blocked = reason;
if (reason === 'rate-limited')
state.coverage.blockTimer = setTimeout(() => {
state.coverage.blockTimer = null;
state.coverage.blocked = null;
refresh();
}, retryAfterSec * 1000);
}
/** Forget refusals when the provider goes off, so the next run asks again. */
function resetErrors() {
clearTimeout(state.coverage.holdTimer);
state.coverage.holdTimer = null;
state.coverage.holdUntil = 0;
/** The layer went off: forget refusals and errors, so the next run asks again. */
function unblock() {
clearTimeout(state.coverage.blockTimer);
state.coverage.blockTimer = null;
if (['rejected', 'rate-limited'].includes(state.coverage.blocked))
state.coverage.blocked = null;
state.coverage.lastError = null;
state.keyRejected = false;
}
/** The server's key status: until it answers, nothing is requested. */
function setKeyStatus(configured) {
if (!configured) block('no-key');
else if (['status', 'no-key'].includes(state.coverage.blocked)) {
state.coverage.blocked = null;
refresh();
}
notify();
}
/** Drop the previous zoom's tiles once the new ones are on screen. */
@@ -375,74 +301,56 @@ export function createCoverage({ state, source }) {
}
function notify() {
state.context.notify();
state.notify?.();
}
/**
* Whether the screen centre meets the ground further ahead than the camera
* is high (a view tilted above 45°), or misses it.
* The tiles for the camera: what the screen shows within range of it, at
* the zoom for its height above ground, nearest the camera first.
*/
function isTilted({ nadir, ahead }, height) {
if (!ahead) return true;
return metresBetween(nadir, ahead) > Math.max(1, height);
}
/** Recompute the tile set for the current camera and reconcile primitives. */
function refresh() {
const viewer = state.viewer;
if (!viewer || !state.context.isActive() || state.keyRequired) return;
if (state.statusKnown === false) return;
if (state.keyRejected || state.coverage.holdUntil > Date.now()) return;
function wantedTiles(viewer) {
const ground = groundUnderCamera(viewer);
const cameraHeight = viewer.camera?.positionCartographic?.height;
const height = Number.isFinite(cameraHeight)
? cameraHeight - (ground ?? 0)
: null;
const sequenceZoom = coverageZoomForHeight(height);
const overviewZoom = sequenceZoom ? null : overviewZoomForHeight(height);
const zoom = sequenceZoom ?? overviewZoom;
const kind = sequenceZoom ? 'sequence' : 'overview';
// At street zooms the rays meet the ground where it really is (1,600 m up
// in Denver) and stop short of the horizon.
const ranged =
kind === 'sequence'
? {
groundHeight: ground,
maxRange: Math.max(
SEQUENCE_VIEW_RANGE_MIN_M,
height * SEQUENCE_VIEW_RANGE_PER_HEIGHT,
),
}
: {};
// Tilted street views rank tiles along the line of sight, from the ground
// under the camera to the ground at the centre of the screen.
const focus = kind === 'sequence' ? viewFocus(viewer, ranged) : null;
let bbox = visibleBbox(viewer, {
...ranged,
// A tilted view's screen rows jump from the first metres to the horizon:
// keep the ground around the camera too. Looking down needs no help.
nearRange: focus && isTilted(focus, height) ? SEQUENCE_VIEW_NEAR_M : null,
const zoom = coverageZoomForHeight(height);
if (zoom == null) return { hint: ZOOM_IN_HINT };
// Rays meet the ground where it really is (1,600 m up in Denver) and stop
// short of the horizon, which would stretch the box to the world.
const bbox = visibleBbox(viewer, {
groundHeight: ground,
maxRange: Math.max(
SEQUENCE_VIEW_RANGE_MIN_M,
height * SEQUENCE_VIEW_RANGE_PER_HEIGHT,
),
});
if (kind === 'overview' && (!bbox || zoom <= 1))
bbox = [-180, -85, 180, 85];
if (zoom == null || !bbox) {
state.coverage.hint =
'Point the camera at the globe for street-level coverage';
if (!bbox) return { hint: NO_GROUND_HINT };
// Ranked from the ground under the camera: a tilted view's box centre
// can sit kilometres ahead of anything near.
const { tiles } = tilesForBbox(bbox, zoom, {
limit: COVERAGE_MAX_TILES,
from: cameraNadir(viewer),
});
return { zoom, tiles };
}
/** Recompute the tile set for the current camera and reconcile primitives. */
function refresh() {
const viewer = state.viewer;
if (!viewer || !isActive(state) || state.coverage.blocked) return;
const { zoom, tiles, hint } = wantedTiles(viewer);
if (hint) {
state.coverage.hint = hint;
clear();
notify();
return;
}
state.coverage.hint = '';
if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) {
if (zoom !== state.coverage.zoom) {
retire();
state.coverage.zoom = zoom;
state.coverage.kind = kind;
}
const { tiles } = tilesForBbox(bbox, zoom, {
limit:
kind === 'sequence' ? COVERAGE_MAX_TILES : COVERAGE_OVERVIEW_MAX_TILES,
focus: focus && { from: focus.nadir, to: focus.ahead },
});
const wanted = new Set(tiles.map(tileKey));
for (const key of [...state.coverage.tiles.keys()])
if (!wanted.has(key)) removeTile(key);
@@ -453,7 +361,7 @@ export function createCoverage({ state, source }) {
controller.abort();
state.coverage.pending.delete(key);
}
for (const tile of tiles) loadTile(tile, kind);
for (const tile of tiles) loadTile(tile);
if (!state.coverage.pending.size) purgeStale();
notify();
}
@@ -491,9 +399,7 @@ export function createCoverage({ state, source }) {
for (const key of [...state.coverage.tiles.keys()]) removeTile(key);
purgeStale();
state.coverage.zoom = null;
state.coverage.kind = null;
stopSelectionWatch();
horizon.stop();
requestRender();
}
@@ -590,17 +496,32 @@ export function createCoverage({ state, source }) {
}
/**
* Sequences (or overview points) drawn. A sequence that crosses a tile
* edge is in both tiles' lists, so sequences count once by id.
* Sequences drawn. A sequence that crosses a tile edge is in both tiles'
* lists, so sequences count once by id.
*/
function sequenceCount() {
const ids = new Set();
let points = 0;
for (const entry of state.coverage.tiles.values())
if (entry.kind === 'sequence')
for (const id of entry.sequences.keys()) ids.add(id);
else points += entry.count;
return ids.size + points;
for (const id of entry.sequences.keys()) ids.add(id);
return ids.size;
}
/** What the panel and the layer list show: count, LOADING, key gate, error. */
function stats() {
const { blocked } = state.coverage;
return {
count: sequenceCount(),
loading: state.coverage.pending.size > 0,
hint: state.coverage.hint,
keyRequired: blocked === 'no-key' || blocked === 'rejected',
keyRejected: blocked === 'rejected',
error:
blocked === 'rejected'
? KEY_REJECTED_MESSAGE
: blocked === 'rate-limited'
? RATE_LIMITED_MESSAGE
: state.coverage.lastError,
};
}
return {
@@ -608,9 +529,11 @@ export function createCoverage({ state, source }) {
detach,
refresh,
clear,
resetErrors,
unblock,
setKeyStatus,
rebuild,
recolorSequence,
sequenceCount,
stats,
};
}
@@ -1,7 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createCoverage } from './coverage.js';
import { createCoverage, ZOOM_IN_HINT } from './coverage.js';
import { freshCoverage } from '../../state.js';
import { encodeCoverageTile } from './coverageFixture.mjs';
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
import {
@@ -110,26 +111,15 @@ function setup() {
const state = {
viewer,
services: {},
keyRequired: false,
context: {
filter: { pano: 'all', sinceMs: null },
getFilter: () => state.context.filter,
isActive: () => true,
notify() {},
},
enabled: true,
providerOn: true,
filter: { pano: 'all', sinceDays: 0 },
notify() {},
coverage: {
zoom: null,
kind: null,
tiles: new Map(),
stale: new Map(),
staleTimer: null,
pending: new Map(),
lastError: null,
debounceTimer: null,
removeCameraListener: null,
// Skip Cesium's one-time terrain table download.
...freshCoverage(),
// The key status is in; skip Cesium's one-time terrain table download.
blocked: null,
terrainReady: Promise.resolve(),
hint: '',
},
sequence: { selectedId: null },
};
@@ -165,7 +155,7 @@ test('a superseded tile request cannot strand lines on the globe', async () => {
coverage.refresh(); // street zoom: request #1 for the tile
assert.equal(forTile().length, 1);
viewer.view.height = 100_000; // zoom out: retire() drops request #1
viewer.view.height = 100_000; // zoom out: clear() drops request #1
coverage.refresh();
viewer.view.height = 900; // back in before #1 settles: request #2
coverage.refresh();
@@ -231,19 +221,49 @@ test('panning off a tile that is still loading stops counting it as loading', as
coverage.clear();
});
test('zoom 0 is a zoom: the whole-earth view still shows coverage', () => {
const { viewer, source, state, coverage } = setup();
test('from orbit nothing is requested or drawn, and the hint says to zoom in', async () => {
const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
assert.ok(viewer.scene.groundPrimitives.items.size > 0, 'street lines');
viewer.view.height = 20_000_000;
coverage.refresh();
assert.equal(state.coverage.zoom, 0);
assert.equal(state.coverage.hint, '');
assert.deepEqual(
source.calls.map((call) => call.key),
['0/0/0'],
);
assert.equal(source.calls.length, 1, 'no tile is asked for');
assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'nothing drawn');
assert.equal(state.coverage.zoom, null);
assert.equal(coverage.stats().hint, ZOOM_IN_HINT);
viewer.view.height = 900;
coverage.refresh();
assert.equal(coverage.stats().hint, '', 'back at street zoom');
assert.equal(source.calls.length, 2);
coverage.clear();
});
test('the key status gates coverage: nothing before it answers, nothing without a key', () => {
for (const configured of [false, true]) {
const { source, state, coverage } = setup();
state.coverage.blocked = 'status';
coverage.refresh();
assert.equal(source.calls.length, 0, 'nothing before the status answers');
coverage.setKeyStatus(configured);
if (configured)
assert.equal(source.calls.length, 1, 'coverage loads once it is known');
else {
assert.equal(source.calls.length, 0, 'a key-less install never asks');
assert.equal(coverage.stats().keyRequired, true);
assert.equal(coverage.stats().keyRejected, false);
coverage.unblock();
assert.equal(
coverage.stats().keyRequired,
true,
'kept: it is no refusal',
);
}
coverage.clear();
}
});
test('a rejected key stops coverage requests until the layer goes off', async () => {
const { viewer, source, state, coverage } = setup();
coverage.refresh();
@@ -255,16 +275,18 @@ test('a rejected key stops coverage requests until the layer goes off', async ()
);
source.calls[0].reject(rejected);
await settle();
assert.equal(state.keyRejected, true);
assert.match(state.coverage.lastError, /rejected MAPILLARY_CLIENT_TOKEN/);
assert.equal(state.coverage.blocked, 'rejected');
assert.equal(coverage.stats().keyRequired, true, 'gated like a missing key');
assert.equal(coverage.stats().keyRejected, true);
assert.match(coverage.stats().error, /rejected MAPILLARY_CLIENT_TOKEN/);
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 1, 'panning asks for nothing more');
// Turning the provider off forgets the verdict; the next run asks again.
// Turning the layer off forgets the verdict; the next run asks again.
coverage.clear();
coverage.resetErrors();
assert.equal(state.keyRejected, false);
assert.equal(state.coverage.lastError, null);
coverage.unblock();
assert.equal(coverage.stats().keyRejected, false);
assert.equal(coverage.stats().error, null);
coverage.refresh();
assert.equal(source.calls.length, 2);
coverage.clear();
@@ -280,55 +302,16 @@ test('a rate limit keeps the drawn tiles and asks again once the wait is over',
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
assert.match(state.coverage.lastError, /rate-limited/);
assert.match(coverage.stats().error, /rate-limited/);
assert.equal(coverage.stats().keyRequired, false);
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 1, 'held: nothing is requested');
t.mock.timers.tick(30_000);
assert.equal(source.calls.length, 2, 'one refresh once the wait is over');
assert.equal(state.coverage.lastError, null);
assert.equal(coverage.stats().error, null);
coverage.clear();
coverage.resetErrors();
});
test('overview points behind the horizon are hidden, not drawn through the globe', async () => {
const { viewer, source, state, coverage } = setup();
const { view } = viewer;
view.height = 20_000_000;
Object.defineProperty(viewer.camera, 'positionWC', {
get: () => Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height),
});
coverage.refresh();
// One dot under the camera (Sacramento), one near its antipode.
source.calls[0].resolve(
encodeCoverageTile(
{ x: 0, y: 0, z: 0 },
{
overview: [
{ id: 'near', lon: -121.5, lat: 38.6 },
{ id: 'far', lon: 58.5, lat: -38.6 },
],
},
),
);
await settle();
const [entry] = state.coverage.tiles.values();
const points = [0, 1].map((i) => entry.primitive.get(i));
const west = (point) =>
Cesium.Cartographic.fromCartesian(point.position).longitude < 0;
const near = points.find(west);
const far = points.find((point) => !west(point));
frame(viewer.preRender);
assert.equal(near.show, true, 'the near side stays visible');
assert.equal(far.show, false, 'the far side does not show through');
// Fly round to the other hemisphere: the two swap.
view.lon = 58.5;
view.lat = -38.6;
frame(viewer.preRender);
assert.equal(near.show, false);
assert.equal(far.show, true);
coverage.clear();
assert.equal(viewer.preRender.size, 0, 'the cull listener is gone');
coverage.unblock();
});
test('the per-tile cap applies after the imagery filter, so a dense tile keeps its 360° lines', async () => {
@@ -354,7 +337,7 @@ test('the per-tile cap applies after the imagery filter, so a dense tile keeps i
capturedAt: 1_000_000_000_000 + i,
isPano: true,
});
state.context.filter = { pano: 'pano', sinceMs: null };
state.filter = { pano: 'pano', sinceDays: 0 };
coverage.refresh();
source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences }));
await settle();
@@ -365,7 +348,7 @@ test('the per-tile cap applies after the imagery filter, so a dense tile keeps i
// Picking and recolouring look sequences up among the drawn lines.
assert.ok(entry.sequences.has('pano-0'), 'drawn lines can be looked up');
assert.equal(entry.sequences.has('flat-699'), false);
state.context.filter = { pano: 'all', sinceMs: null };
state.filter = { pano: 'all', sinceDays: 0 };
coverage.rebuild();
assert.equal(entry.count, cap, 'all imagery is capped as before');
assert.ok(entry.sequences.has('flat-699'), 'the newest flat line is drawn');
@@ -512,36 +495,25 @@ function denverStreetView(aheadM) {
};
}
test('a tilted street view over ground 1,600 m up asks for the tiles it looks at first', () => {
test('a tilted street view over ground 1,600 m up asks for the ground it looks at, nearest the camera first', () => {
// The screen centre meets the ground at the centre of the next tile east.
const { state, coverage, keys, offsets, key } = denverStreetView(
(tileWidthM) => tileWidthM,
);
assert.equal(state.coverage.zoom, 14, 'height measured from the ground');
assert.deepEqual(
keys.slice(0, 2),
[key(0, 0), key(1, 0)],
'the tile under the camera, then the one the screen centre looks at',
);
assert.ok(keys.includes(key(-1, 0)), 'the ground behind the camera too');
assert.equal(keys[0], key(0, 0), 'the tile under the camera first');
assert.ok(keys.includes(key(1, 0)), 'the tile the screen centre looks at');
for (const [dx, dy] of offsets)
assert.ok(dx >= -1 && dx <= 2 && Math.abs(dy) <= 1, `${dx},${dy} in reach`);
assert.ok(dx >= 0 && dx <= 2 && Math.abs(dy) <= 1, `${dx},${dy} in view`);
coverage.clear();
});
test('a street view toward the horizon ranks only the ground within range', () => {
// The screen centre meets the ground 6 km out, past the 3 km range: the
// tiles are ranked around the camera, not along a line to the horizon.
test('a street view toward the horizon asks only for the ground within range', () => {
// The screen centre meets the ground 6 km out, past the 3 km range.
const { coverage, keys, offsets, key } = denverStreetView(() => 6000);
assert.equal(keys[0], key(0, 0), 'the tile under the camera first');
// Behind and ahead are equally near the camera (ties keep row order); a
// line of sight to the far ground would rank the tile ahead first.
assert.ok(
keys.indexOf(key(-1, 0)) < keys.indexOf(key(1, 0)),
'the tile ahead is not pulled forward',
);
for (const [dx, dy] of offsets)
assert.ok(Math.abs(dx) <= 1 && Math.abs(dy) <= 1, `${dx},${dy} around it`);
assert.ok(dx >= 0 && dx <= 1 && Math.abs(dy) <= 1, `${dx},${dy} near it`);
coverage.clear();
});
@@ -556,7 +528,7 @@ test('a selection cleared while the old zoom is still shown uncolours its lines'
coverage.recolorSequence('seq-1', true);
assert.deepEqual(Array.from(line.attributes.color), colourValue(true));
// Zoom out: the street tile stays on screen while the new zoom loads...
viewer.view.height = 100_000;
viewer.view.height = 3000;
coverage.refresh();
assert.equal(state.coverage.stale.size, 1, 'the old zoom is kept');
// ...and the selection is cleared meanwhile.
@@ -577,7 +549,7 @@ test('a selection made while the old zoom is still building is applied once it i
await settle();
const [entry] = state.coverage.tiles.values();
const line = controlled(entry.primitives[0], false);
viewer.view.height = 100_000;
viewer.view.height = 3000;
coverage.refresh();
assert.equal(state.coverage.stale.size, 1);
frame(viewer.postRender);
@@ -645,7 +617,7 @@ test('a rate limit holds every refresh inside its wait, not only the first', asy
t.mock.timers.tick(1000);
assert.ok(source.calls.length > 1, 'asked again once the wait is over');
coverage.clear();
coverage.resetErrors();
coverage.unblock();
});
test('a tile that fails before the terrain table loads is reported at once, never unhandled', async () => {
@@ -86,11 +86,11 @@ 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]
* @param {{sequences?: Array<{id: string, capturedAt?: number, isPano?: boolean, parts: Array<Array<[number, number]>>}>, extent?: number}} [content]
*/
export function encodeCoverageTile(
tile,
{ sequences = [], overview = [], extent = 4096 } = {},
{ sequences = [], extent = 4096 } = {},
) {
const pbf = new PbfWriter();
if (sequences.length) {
@@ -111,17 +111,5 @@ export function encodeCoverageTile(
);
pbf.writeMessage(3, (_, out) => layer.write(out), null);
}
if (overview.length) {
const layer = layerWriter('overview', extent);
for (const point of overview) {
const [px, py] = lonLatToTileLocal([point.lon, point.lat], tile, extent);
layer.add(1, [9, zigzag(px), zigzag(py)], {
id: point.id,
captured_at: point.capturedAt ?? Date.now(),
is_pano: point.isPano === true,
});
}
pbf.writeMessage(3, (_, out) => layer.write(out), null);
}
return new Uint8Array(pbf.finish());
}
@@ -3,13 +3,13 @@ import { VectorTile } from '@mapbox/vector-tile';
import { tileLocalToLonLat } from '../../tileMath.js';
/**
* Decode a `mly1_public` coverage tile into sequences and (z0–5) overview
* points. The z14 `image` layer, which can hold >150k points, is skipped.
* Decode a `mly1_public` coverage tile (z11–14) into sequences. The z14
* `image` layer, which can hold >150k points, is skipped.
* @param {Uint8Array} bytes
* @param {{x:number,y:number,z:number}} address
*/
export function decodeCoverageTile(bytes, address) {
const result = { sequences: [], overview: [] };
const result = { sequences: [] };
if (!bytes || !bytes.length) return result;
const tile = new VectorTile(new PbfReader(bytes));
const { x, y, z } = address;
@@ -41,30 +41,5 @@ export function decodeCoverageTile(bytes, address) {
});
}
}
const overviewLayer = tile.layers.overview;
if (overviewLayer) {
for (let i = 0; i < overviewLayer.length; i++) {
const feature = overviewLayer.feature(i);
const props = feature.properties || {};
const point = feature.loadGeometry()?.[0]?.[0];
if (!point) continue;
const [lon, lat] = tileLocalToLonLat(
point.x,
point.y,
overviewLayer.extent,
x,
y,
z,
);
result.overview.push({
id: String(props.id ?? feature.id ?? i),
lon,
lat,
capturedAt: Number(props.captured_at) || 0,
isPano: props.is_pano === true,
sequenceId: props.sequence_id ? String(props.sequence_id) : null,
});
}
}
return result;
}
@@ -17,7 +17,10 @@ function linePart(points) {
return out;
}
/** A minimal mly1_public-shaped tile: one sequence line and one overview point. */
/**
* A minimal mly1_public-shaped tile: one sequence line, plus one point in the
* low-zoom `overview` layer the layer never draws.
*/
function sampleTile({ geometry = null } = {}) {
const writer = new PbfWriter();
writer.writeMessage(
@@ -94,7 +97,7 @@ function sampleTile({ geometry = null } = {}) {
return new Uint8Array(writer.finish());
}
test('sequences and overview points decode into lon/lat records', () => {
test('sequences decode into lon/lat records; overview points are ignored', () => {
const decoded = decodeCoverageTile(sampleTile(), { x: 0, y: 0, z: 1 });
assert.equal(decoded.sequences.length, 1);
const [sequence] = decoded.sequences;
@@ -109,19 +112,13 @@ test('sequences and overview points decode into lon/lat records', () => {
'west half of tile 0/0 at z1',
);
assert.ok(lat0 > 0 && lat1 < lat0, 'northern hemisphere, moving south');
assert.equal(decoded.overview.length, 1);
assert.equal(decoded.overview[0].id, '3');
assert.equal(decoded.overview[0].isPano, false);
assert.ok(Math.abs(decoded.overview[0].lon - -90) < 1e-6, 'tile centre');
assert.deepEqual(Object.keys(decoded), ['sequences']);
});
test('an empty tile decodes to empty lists', () => {
assert.deepEqual(
decodeCoverageTile(new Uint8Array(0), { x: 0, y: 0, z: 0 }),
{
sequences: [],
overview: [],
},
{ sequences: [] },
);
});
@@ -1,214 +0,0 @@
import { createCoverage, sequenceIdFromPick } from './coverage.js';
import { createSequences } from './sequences.js';
import { createMapillaryViewer } from './viewer.js';
import { passesImageryFilter } from '../../filter.js';
import { metresBetween } from '../../view.js';
import {
COLORS,
MAPILLARY_CREDIT_HTML,
MAPILLARY_KEY_ID,
MAPILLARY_LABEL,
MAPILLARY_NAME,
MAPILLARY_PROVIDER_ID,
MAPILLARY_SOURCE_METHODS,
NEAREST_LIMIT,
NEAREST_RADIUS_M,
PICK_PREFIX,
} from './policy.js';
/** Per-provider mutable state, created once per `create()`. */
function createProviderState(context) {
return {
context,
services: context.services,
viewer: null,
keyRequired: false,
/** Coverage waits for the first status: no tile requests without a key. */
statusKnown: false,
/** Mapillary refused the token: request nothing until re-enabled. */
keyRejected: false,
status: null,
coverage: {
zoom: null,
/** Current zoom's tiles by `z/x/y` key. */
tiles: new Map(),
/** 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. */
pending: new Map(),
lastError: null,
/** While Mapillary rate-limits: no requests before this time (ms). */
holdUntil: 0,
holdTimer: null,
debounceTimer: null,
removeCameraListener: null,
terrainReady: null,
hint: '',
kind: null,
},
sequence: {
selectedId: null,
images: [],
/** Recent sequences' thinned images, by sequence id. */
cache: new Map(),
collection: null,
loading: false,
abort: null,
},
};
}
/**
* 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 }) {
if (
!MAPILLARY_SOURCE_METHODS.every(
(method) => typeof source?.[method] === 'function',
)
)
throw new TypeError('A Mapillary source is required');
return Object.freeze({
id: MAPILLARY_PROVIDER_ID,
name: MAPILLARY_NAME,
label: MAPILLARY_LABEL,
requiresKeyId: MAPILLARY_KEY_ID,
pickPrefix: PICK_PREFIX.root,
colors: COLORS,
credit: Object.freeze({ html: MAPILLARY_CREDIT_HTML }),
create(context) {
const state = createProviderState(context);
const parts = {};
parts.coverage = createCoverage({ state, source, parts });
parts.sequences = createSequences({ state, source, parts });
const viewer = createMapillaryViewer({
source,
render: context.services?.render,
});
return {
async status() {
try {
state.status = await source.getStatus();
state.keyRequired = state.status?.configured !== true;
} catch {
state.status = null;
state.keyRequired = !source.hasToken();
}
const first = !state.statusKnown;
state.statusKnown = true;
// Coverage held back until now: draw it if the layer is on.
if (first) parts.coverage.refresh();
context.notify();
return { configured: !state.keyRequired };
},
init(cesiumViewer) {
state.viewer = cesiumViewer;
parts.sequences.ensureCollections(cesiumViewer);
parts.sequences.setVisible(false);
},
activate(cesiumViewer) {
state.viewer = cesiumViewer;
parts.sequences.setVisible(true);
parts.coverage.attach(cesiumViewer);
},
deactivate() {
parts.coverage.detach();
parts.coverage.clear();
parts.coverage.resetErrors();
parts.sequences.clearSelection();
parts.sequences.setVisible(false);
},
destroy(cesiumViewer) {
parts.coverage.detach();
parts.coverage.clear();
parts.coverage.resetErrors();
parts.sequences.destroy(cesiumViewer);
},
refreshCoverage: () => parts.coverage.refresh(),
/** The filter changed: redraw. Each draw reads it from the context. */
setFilter() {
parts.coverage.rebuild();
parts.sequences.rerender();
},
coverageStats() {
return {
count: parts.coverage.sequenceCount(),
zoom: state.coverage.zoom,
kind: state.coverage.kind,
loading: state.coverage.pending.size > 0,
hint: state.coverage.hint,
error: state.coverage.lastError,
keyRequired: state.keyRequired,
keyRejected: state.keyRejected,
};
},
handlePick(pickId) {
const sequenceId = sequenceIdFromPick(pickId);
if (sequenceId) {
parts.sequences.select(sequenceId);
return true;
}
if (pickId.startsWith(PICK_PREFIX.image)) {
context.actions.openImage(pickId.slice(PICK_PREFIX.image.length));
return true;
}
return false;
},
selectSequence: (sequenceId) => parts.sequences.select(sequenceId),
clearSequence: () => parts.sequences.clearSelection(),
sequenceStats: () => ({
selectedId: state.sequence.selectedId,
images: state.sequence.images.length,
loading: state.sequence.loading,
}),
/**
* Nearest image that passes the imagery filter, or null. The core
* aborts `signal` once a newer request overtakes the lookup.
*/
async nearestImage({ lat, lon }, { signal } = {}) {
const images = await source.nearestImages(
{ lat, lon, radius: NEAREST_RADIUS_M, limit: NEAREST_LIMIT },
{ signal },
);
// The API returns the images in the radius in no particular order.
const metres = (record) => {
const [lon2, lat2] = (record.computed_geometry || record.geometry)
?.coordinates || [NaN, NaN];
const d = metresBetween({ lon, lat }, { lon: lon2, lat: lat2 });
return Number.isFinite(d) ? d : Infinity;
};
const byDistance = [...images].sort((a, b) => metres(a) - metres(b));
const filter = context.getFilter();
const hit = byDistance.find((record) =>
passesImageryFilter(
{
isPano: record.is_pano === true,
capturedAt: Number(record.captured_at) || 0,
},
filter,
),
);
return hit ? String(hit.id) : null;
},
viewer,
};
},
});
}
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { nearestImageId } from './nearest.js';
import { COLORS, mapillaryImageUrl } from './policy.js';
import { sequenceIdFromPick } from './coverage.js';
import { thinImages } from './sequences.js';
import { resolveFilter } from '../../filter.js';
const ALL = Object.freeze({ pano: 'all', sinceMs: null });
/** A source answering nearest-image lookups with `nearest`, recording options. */
function fakeSource(nearest = []) {
const requests = [];
return {
requests,
nearestImages: async (query, options) => {
requests.push({ query, options });
return nearest;
},
};
}
const at = (id, lon, lat) => ({
id,
is_pano: false,
captured_at: 10,
geometry: { type: 'Point', coordinates: [lon, lat] },
});
test('Mapillary draws in its green; image deep links match the mapillary.com share format', () => {
assert.equal(COLORS.coverage, '#05cb63');
assert.equal(
mapillaryImageUrl(1814275685699406),
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
);
assert.equal(mapillaryImageUrl(' '), 'https://www.mapillary.com/app/');
});
test('nearestImageId honours the imagery filter and hands the signal to the source', async () => {
const source = fakeSource([
{ id: 1, is_pano: false, captured_at: 10 },
{ id: 2, is_pano: true, captured_at: 20 },
]);
const { signal } = new AbortController();
assert.equal(
await nearestImageId(source, { lat: 1, lon: 2 }, ALL, { signal }),
'1',
);
assert.equal(source.requests[0].options.signal, signal);
assert.equal(source.requests[0].query.radius, 50);
assert.equal(
await nearestImageId(
source,
{ lat: 1, lon: 2 },
{
pano: 'pano',
sinceMs: null,
},
),
'2',
);
assert.equal(
await nearestImageId(
source,
{ lat: 1, lon: 2 },
{
pano: 'all',
sinceMs: 100,
},
),
null,
);
});
test('any part of a multi-part sequence picks the whole sequence', () => {
assert.equal(sequenceIdFromPick('mly:seq:abc'), 'abc');
assert.equal(sequenceIdFromPick('mly:seq:abc~2'), 'abc');
assert.equal(sequenceIdFromPick('mly:img:abc'), null);
assert.equal(sequenceIdFromPick(null), null);
});
test('nearestImageId picks the closest image, not the first the API returned', async () => {
const source = fakeSource([
at('far', 2.0004, 1), // ~45 m east
at('near', 2.00005, 1), // ~5 m east
at('mid', 2, 1.0002), // ~22 m north
]);
assert.equal(await nearestImageId(source, { lat: 1, lon: 2 }, ALL), 'near');
});
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;
const source = fakeSource([
{ id: 'recent', is_pano: false, captured_at: capturedAt },
]);
// The layer resolves the stored filter on every lookup.
const lookup = () =>
nearestImageId(
source,
{ lat: 1, lon: 2 },
resolveFilter({ pano: 'all', sinceDays: 1 }),
);
assert.equal(await lookup(), 'recent');
t.mock.timers.tick(2 * day);
assert.equal(
await lookup(),
null,
'two days on, the image is older than the window',
);
});
test('nearestImageId measures the short way round the date line', async () => {
const east = fakeSource([
at('same-side', 179.9995, 0), // ~45 m west, on the camera's side
at('across', -179.9999, 0), // ~11 m east, across ±180°
]);
assert.equal(
await nearestImageId(east, { lat: 0, lon: 179.9999 }, ALL),
'across',
);
// And from the other side.
const west = fakeSource([
at('same-side', -179.9995, 0),
at('across', 179.9999, 0),
]);
assert.equal(
await nearestImageId(west, { lat: 0, lon: -179.9999 }, ALL),
'across',
);
});
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(
[
image('a', 179.99999),
image('b', -179.99999), // ~2 m from a, across the date line
image('c', -179.9999), // ~11 m from a
],
3,
);
assert.deepEqual(
kept.map(({ id }) => id),
['a', 'c'],
);
});
@@ -0,0 +1,38 @@
import { passesImageryFilter } from '../../filter.js';
import { metresBetween } from '../../view.js';
import { NEAREST_LIMIT, NEAREST_RADIUS_M } from './policy.js';
/**
* Id of the nearest image around a point that passes the resolved imagery
* filter, or null. An aborted `signal` gives up (the source may reject).
*/
export async function nearestImageId(
source,
{ lat, lon },
filter,
{ signal } = {},
) {
const images = await source.nearestImages(
{ lat, lon, radius: NEAREST_RADIUS_M, limit: NEAREST_LIMIT },
{ signal },
);
// The API returns the images in the radius in no particular order.
const metres = (record) => {
const [lon2, lat2] = (record.computed_geometry || record.geometry)
?.coordinates || [NaN, NaN];
const d = metresBetween({ lon, lat }, { lon: lon2, lat: lat2 });
return Number.isFinite(d) ? d : Infinity;
};
const hit = [...images]
.sort((a, b) => metres(a) - metres(b))
.find((record) =>
passesImageryFilter(
{
isPano: record.is_pano === true,
capturedAt: Number(record.captured_at) || 0,
},
filter,
),
);
return hit ? String(hit.id) : null;
}
@@ -1,15 +1,10 @@
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
import { COLORS as SHARED_COLORS } from '../../policy.js';
export { NEAREST_RADIUS_M } from '../../policy.js';
/** Identity and tuning for the Mapillary street-level provider. */
export const MAPILLARY_PROVIDER_ID = 'mapillary';
export const MAPILLARY_NAME = 'Mapillary';
export const MAPILLARY_LABEL = 'MAPILLARY';
/** Identity and tuning for Street Level's Mapillary imagery. */
export const MAPILLARY_KEY_ID = 'mapillary';
export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com';
/** Methods a source (./source.js or a stand-in) must have for the provider. */
/** Methods a source (./source.js or a stand-in) must have. */
export const MAPILLARY_SOURCE_METHODS = Object.freeze([
'getStatus',
'getTile',
@@ -24,17 +19,15 @@ export const PICK_PREFIX = Object.freeze({
image: 'mly:img:',
});
/** Mapillary green for lines, points and cones; GEV cyan for selection. */
/** Mapillary green for lines, cones and the legend; GEV cyan for selection. */
export const COLORS = Object.freeze({
coverage: PROVIDER_COLORS.mapillary,
coverage: '#05cb63',
selected: SHARED_COLORS.selected,
});
/** Camera-driven coverage refresh. */
export const COVERAGE_MOVE_DEBOUNCE_MS = 320;
export const COVERAGE_MAX_TILES = 9;
/** Overview (z0–5) coverage points seen from orbit. */
export const COVERAGE_OVERVIEW_MAX_TILES = 16;
/** Shown when Mapillary refuses the server's token (401/403). */
export const KEY_REJECTED_MESSAGE =
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
@@ -47,9 +40,6 @@ export const RATE_LIMITED_MESSAGE =
*/
export const SEQUENCE_VIEW_RANGE_MIN_M = 2_500;
export const SEQUENCE_VIEW_RANGE_PER_HEIGHT = 10;
/** Ground always covered around the camera at street zooms, in metres. */
export const SEQUENCE_VIEW_NEAR_M = 1_000;
export const COVERAGE_OVERVIEW_POINT_PX = 2.5;
export const COVERAGE_MAX_SEQUENCES = 6000;
export const COVERAGE_LINE_WIDTH_PX = 2.5;
@@ -58,7 +48,9 @@ export const SEQUENCE_IMAGES_LIMIT = 2000;
export const IMAGE_CONE_SIZE_PX = 26;
export const IMAGE_CONE_MIN_SPACING_M = 3;
/** Images asked for in a nearest-image search (radius: NEAREST_RADIUS_M). */
/** Nearest-image search radius in metres (the graph API caps it at 50 m). */
export const NEAREST_RADIUS_M = 50;
/** Images asked for in a nearest-image search. */
export const NEAREST_LIMIT = 8;
/** On-globe credit: Mapillary imagery is CC BY-SA 4.0 and needs attribution. */
@@ -1,252 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMapillaryProvider } from './index.js';
import { mapillaryImageUrl } from './policy.js';
import { sequenceIdFromPick } from './coverage.js';
import { thinImages } from './sequences.js';
import { validateProviders } from '../../registry.js';
import { PROVIDER_COLORS } from '../../policy.js';
import { resolveFilter } from '../../filter.js';
import * as Cesium from 'cesium';
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
function fakeSource({ nearest = [] } = {}) {
return {
hasToken: () => true,
getStatus: async () => ({ configured: true }),
getTile: async () => new Uint8Array(0),
getSequenceImages: async () => [],
nearestImages: async () => nearest,
};
}
function fakeContext(filter = { pano: 'all', sinceMs: null }) {
const opened = [];
return {
opened,
services: {},
getFilter: typeof filter === 'function' ? filter : () => filter,
isActive: () => true,
notify: () => {},
actions: {
openImage: (id) => {
opened.push(id);
},
reportError: () => {},
},
};
}
test('the definition satisfies the Street Level provider contract', () => {
const def = createMapillaryProvider({ source: fakeSource() });
assert.doesNotThrow(() => validateProviders([def]));
assert.equal(def.id, 'mapillary');
assert.equal(def.label, 'MAPILLARY');
assert.equal(def.requiresKeyId, 'mapillary');
assert.equal(def.pickPrefix, 'mly:');
assert.match(def.credit.html, /CC BY-SA 4\.0/);
// One colour per source: Mapillary is green everywhere it draws.
assert.deepEqual(Object.keys(def.colors).sort(), ['coverage', 'selected']);
assert.equal(def.colors.coverage, PROVIDER_COLORS.mapillary);
assert.equal(PROVIDER_COLORS.mapillary, '#05cb63');
assert.throws(() => createMapillaryProvider({ source: {} }), /source/);
});
test('image deep links match the mapillary.com share format', () => {
assert.equal(
mapillaryImageUrl(1814275685699406),
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
);
assert.equal(mapillaryImageUrl(' '), 'https://www.mapillary.com/app/');
});
test('picks route sequences to selection and images to the core opener', () => {
const context = fakeContext();
const instance = createMapillaryProvider({ source: fakeSource() }).create(
context,
);
assert.equal(instance.handlePick('mly:img:77'), true);
assert.deepEqual(context.opened, ['77']);
assert.equal(instance.handlePick('cctv:1'), false);
assert.deepEqual(instance.sequenceStats(), {
selectedId: null,
images: 0,
loading: false,
});
});
test('nearestImage honours the imagery filter and reports key status', async () => {
const nearest = [
{ id: 1, is_pano: false, captured_at: 10 },
{ id: 2, is_pano: true, captured_at: 20 },
];
const source = fakeSource({ nearest });
const flat = createMapillaryProvider({ source }).create(fakeContext());
assert.equal(await flat.nearestImage({ lat: 1, lon: 2 }), '1');
const pano = createMapillaryProvider({ source }).create(
fakeContext({ pano: 'pano', sinceMs: null }),
);
assert.equal(await pano.nearestImage({ lat: 1, lon: 2 }), '2');
const none = createMapillaryProvider({ source }).create(
fakeContext({ pano: 'all', sinceMs: 100 }),
);
assert.equal(await none.nearestImage({ lat: 1, lon: 2 }), null);
assert.deepEqual(await flat.status(), { configured: true });
assert.equal(flat.coverageStats().keyRequired, false);
});
test('any part of a multi-part sequence picks the whole sequence', () => {
assert.equal(sequenceIdFromPick('mly:seq:abc'), 'abc');
assert.equal(sequenceIdFromPick('mly:seq:abc~2'), 'abc');
assert.equal(sequenceIdFromPick('mly:img:abc'), null);
assert.equal(sequenceIdFromPick(null), null);
});
test('nearestImage picks the closest image, not the first the API returned', async () => {
const at = (id, lon, lat) => ({
id,
is_pano: false,
captured_at: 10,
geometry: { type: 'Point', coordinates: [lon, lat] },
});
const source = fakeSource({
nearest: [
at('far', 2.0004, 1), // ~45 m east
at('near', 2.00005, 1), // ~5 m east
at('mid', 2, 1.0002), // ~22 m north
],
});
const instance = createMapillaryProvider({ source }).create(fakeContext());
assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'near');
});
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;
const source = fakeSource({
nearest: [{ id: 'recent', is_pano: false, captured_at: capturedAt }],
});
// The core's context resolves the stored filter whenever it is asked.
const instance = createMapillaryProvider({ source }).create(
fakeContext(() => resolveFilter({ pano: 'all', sinceDays: 1 })),
);
assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'recent');
t.mock.timers.tick(2 * day);
assert.equal(
await instance.nearestImage({ lat: 1, lon: 2 }),
null,
'two days on, the image is older than the window',
);
});
test('nearestImage measures the short way round the date line', async () => {
const at = (id, lon, lat) => ({
id,
is_pano: false,
captured_at: 10,
geometry: { type: 'Point', coordinates: [lon, lat] },
});
const source = fakeSource({
nearest: [
at('same-side', 179.9995, 0), // ~45 m west, on the camera's side
at('across', -179.9999, 0), // ~11 m east, across ±180°
],
});
const instance = createMapillaryProvider({ source }).create(fakeContext());
assert.equal(
await instance.nearestImage({ lat: 0, lon: 179.9999 }),
'across',
);
// And from the other side.
const west = createMapillaryProvider({
source: fakeSource({
nearest: [at('same-side', -179.9995, 0), at('across', 179.9999, 0)],
}),
}).create(fakeContext());
assert.equal(await west.nearestImage({ lat: 0, lon: -179.9999 }), 'across');
});
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(
[
image('a', 179.99999),
image('b', -179.99999), // ~2 m from a, across the date line
image('c', -179.9999), // ~11 m from a
],
3,
);
assert.deepEqual(
kept.map(({ id }) => id),
['a', 'c'],
);
});
/** A viewer looking straight down on downtown Sacramento from 900 m. */
function streetViewer() {
const collection = () => ({
add: (item) => item,
remove: () => true,
contains: () => false,
get length() {
return 0;
},
});
const event = () => ({ addEventListener: () => () => {} });
const camera = Object.assign(
rayCamera({
lon: -121.4944,
lat: 38.5816,
altitude: 900,
pitch: -90,
width: 100,
height: 100,
}),
{ changed: event(), moveEnd: event() },
);
return {
camera,
scene: {
canvas: { clientWidth: 100, clientHeight: 100 },
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
primitives: collection(),
groundPrimitives: collection(),
preRender: event(),
postRender: event(),
requestRender() {},
},
};
}
test('coverage asks for no tiles before the key status is known', async () => {
for (const configured of [false, true]) {
let answer;
const tiles = [];
const source = {
...fakeSource(),
hasToken: () => configured,
getStatus: () => new Promise((resolve) => (answer = resolve)),
getTile: (...args) => {
tiles.push(args);
return new Promise(() => {});
},
};
const instance = createMapillaryProvider({ source }).create(fakeContext());
const viewer = streetViewer();
instance.init(viewer);
const status = instance.status();
instance.activate(viewer);
assert.equal(tiles.length, 0, 'nothing before the status answers');
answer({ configured });
await status;
if (configured)
assert.ok(tiles.length > 0, 'coverage loads as soon as the key is known');
else {
assert.equal(tiles.length, 0, 'a key-less install never asks');
assert.equal(instance.coverageStats().keyRequired, true);
}
instance.destroy(viewer);
}
});
@@ -1,6 +1,6 @@
import * as Cesium from 'cesium';
import { imageConeGlyph } from '../../glyphs.js';
import { passesImageryFilter } from '../../filter.js';
import { passesImageryFilter, resolveFilter } from '../../filter.js';
import { createHorizonCull, metresBetween } from '../../view.js';
import {
COLORS,
@@ -53,7 +53,12 @@ export function createSequences({ state, source, parts }) {
}
function notify() {
state.context.notify();
state.notify?.();
}
/** Show a sequence error, or withdraw it (null); see `reportSequenceError`. */
function reportError(message) {
parts.reportSequenceError?.(message);
}
/** Drawn cone billboards. */
@@ -95,7 +100,7 @@ export function createSequences({ state, source, parts }) {
pano: true,
});
// Resolved now, so a "since N days" window keeps up with the clock.
const filter = state.context.getFilter();
const filter = resolveFilter(state.filter);
for (const image of images) {
if (!passesImageryFilter(image, filter)) continue;
const billboard = collection.add({
@@ -146,7 +151,7 @@ export function createSequences({ state, source, parts }) {
state.sequence.loading = false;
state.sequence.images = cached;
renderCones(cached);
state.context.actions.reportError(null);
reportError(null);
notify();
return;
}
@@ -172,15 +177,13 @@ export function createSequences({ state, source, parts }) {
remember(sequenceId, images);
state.sequence.images = images;
renderCones(images);
state.context.actions.reportError(null);
reportError(null);
} catch (error) {
if (!controller.signal.aborted) {
// Nothing to show: drop the highlight, so a click asks again.
parts.coverage.recolorSequence(sequenceId, false);
state.sequence.selectedId = null;
state.context.actions.reportError(
error?.message || 'Sequence images unavailable',
);
reportError(error?.message || 'Sequence images unavailable');
}
} finally {
if (state.sequence.abort === controller) {
@@ -200,7 +203,7 @@ export function createSequences({ state, source, parts }) {
state.sequence.loading = false;
clearCones();
// An error about the sequence goes with it.
state.context.actions.reportError(null);
reportError(null);
requestRender();
notify();
}
@@ -62,11 +62,8 @@ function setup() {
const state = {
services: {},
viewer: {},
context: {
notify() {},
getFilter: () => ({ pano: 'all', sinceMs: null }),
actions: { reportError: (message) => errors.push(message) },
},
filter: { pano: 'all', sinceDays: 0 },
notify() {},
sequence: {
selectedId: null,
images: [],
@@ -80,6 +77,7 @@ function setup() {
coverage: {
recolorSequence: (id, selected) => colours.push([id, selected]),
},
reportSequenceError: (message) => errors.push(message),
};
const sequences = createSequences({ state, source, parts });
const drawn = () =>
@@ -1,18 +1,28 @@
import { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js';
import { mapillaryImageUrl } from './policy.js';
/**
* 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}
* @typedef {object} StreetPose
* @property {string} imageId
* @property {{lon: number, lat: number}} position
* @property {number|null} bearing
* @property {number|null} tilt
* @property {number|null} altitude
* @property {boolean} isPano
* @property {number|null} capturedAt Epoch milliseconds.
* @property {string|null} creator
* @property {string|null} sequenceId
* @property {string} externalUrl Deep link to the image on mapillary.com.
*/
/**
* The panel's photo viewer: loads MapillaryJS on the first `mount` and emits
* a pose on every image, view or position change.
*/
export function createMapillaryViewer({ source, render } = {}) {
let viewer = null;
let Library = null;
let container = null;
let pendingOpen = null;
let prewarming = null;
/** In-flight viewer construction, so pre-warm and open never build two. */
let creating = null;
/** Bumped by `unmount`, so a construction it overtook builds nothing. */
let generation = 0;
let renderMode = 'letterbox';
@@ -52,7 +62,7 @@ export function createMapillaryViewer({ source, render } = {}) {
}
}
/** Read MapillaryJS image metadata into the neutral pose shape. */
/** Read MapillaryJS image metadata into the pose shape. */
function describe(image) {
return {
imageId: String(image.id),
@@ -81,7 +91,6 @@ export function createMapillaryViewer({ source, render } = {}) {
if (image) current = describe(image);
if (!current || !viewer) return;
emit({
providerId: MAPILLARY_PROVIDER_ID,
...current,
position: { lon: lngLat.lng, lat: lngLat.lat },
bearing: Number.isFinite(pov?.bearing) ? pov.bearing : null,
@@ -94,17 +103,8 @@ export function createMapillaryViewer({ source, render } = {}) {
}
}
function ensureViewer(host) {
async function ensureViewer(host) {
if (viewer && container === host) return viewer;
if (creating?.container === host) return creating.promise;
const promise = createViewer(host).finally(() => {
if (creating?.promise === promise) creating = null;
});
creating = { container: host, promise };
return promise;
}
async function createViewer(host) {
const built = generation;
destroyViewer();
const { Viewer } = await ensureLibrary();
@@ -178,9 +178,8 @@ export function createMapillaryViewer({ source, render } = {}) {
unmount() {
pendingOpen = null;
// The next mount builds afresh rather than joining a stale build.
// A build still waiting for the library gives up when it arrives.
generation++;
creating = null;
destroyViewer();
},
@@ -192,26 +191,6 @@ export function createMapillaryViewer({ source, render } = {}) {
}
},
/** Build the viewer ahead of the first image. Safe to call repeatedly. */
async prewarm(host) {
if (!host) return;
// 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 () => {
try {
await ensureLibrary();
if (!viewer) await ensureViewer(host);
} catch {
/* the real open reports errors */
}
})();
prewarming = run;
await run;
if (prewarming === run) prewarming = null;
},
/** Show the whole image ('letterbox') or crop it to the frame ('fill'). */
setRenderMode(mode) {
renderMode = mode === 'fill' ? 'fill' : 'letterbox';
-131
View File
@@ -1,131 +0,0 @@
/**
* The contract an imagery provider registers with the Street Level layer.
*
* @typedef {object} StreetLevelProvider
* @property {string} id Stable id, e.g. 'mapillary'; also the share-link option key.
* @property {string} name Human name for captions and legends, e.g. 'Mapillary'.
* @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, 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.
* @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 {() => void} notify Ask the core to publish a new UI snapshot.
* @property {{openImage: (imageId: string) => Promise<void>, reportError: (message: string|null) => void}} actions
*
* @typedef {object} ProviderInstance
* @property {() => Promise<{configured: boolean}>} status
* @property {(viewer: object) => void} init Create collections, hidden.
* @property {(viewer: object) => void} activate Start drawing coverage for the camera.
* @property {() => void} deactivate Detach, clear coverage and selection, hide.
* @property {(viewer: object) => void} destroy
* @property {() => void} refreshCoverage
* @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter The filter changed: redraw (reading `getFilter()`).
* @property {() => {count: number, zoom: number|null, kind: string|null, loading: boolean, hint: string, error: string|null, keyRequired: boolean}} coverageStats
* @property {(pickId: string) => boolean} handlePick The id carries the provider's own prefix.
* @property {(sequenceId: string) => Promise<void>} [selectSequence]
* @property {() => void} [clearSequence]
* @property {() => {selectedId: string|null, images: number, loading: boolean}} [sequenceStats]
* @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal}) => Promise<string|null>} nearestImage An aborted lookup gives up (it may reject with an AbortError).
* @property {ViewerAdapter} viewer
*
* @typedef {object} ViewerAdapter
* @property {(host: HTMLElement) => Promise<void>} mount Idempotent; may lazy-load a library.
* @property {(imageId: string) => Promise<void>} open Resolves once the image is on screen and a pose was emitted.
* @property {() => void} close Drop the image, keep the instance warm.
* @property {() => void} unmount Destroy the instance (host handed to another provider).
* @property {() => void} resize
* @property {(host: HTMLElement) => Promise<void>} [prewarm]
* @property {(mode: 'letterbox'|'fill') => void} [setRenderMode]
* @property {(listener: (pose: StreetPose) => void) => () => void} onPose
*
* @typedef {object} StreetPose
* @property {string} providerId
* @property {string} imageId
* @property {{lon: number, lat: number}} position
* @property {number|null} bearing
* @property {number|null} tilt
* @property {number|null} altitude
* @property {boolean} isPano
* @property {number|null} capturedAt Epoch milliseconds.
* @property {string|null} creator
* @property {string|null} sequenceId
* @property {string} externalUrl Deep link to the image on the provider's site.
*/
const REQUIRED = Object.freeze([
'id',
'name',
'label',
'pickPrefix',
'colors',
'credit',
'create',
]);
const ID_GRAMMAR = /^[a-z][a-z0-9-]*$/;
/**
* Validate providers and freeze their order, which is also the chip order.
* @param {Array<StreetLevelProvider>} providers
* @returns {ReadonlyArray<StreetLevelProvider>}
*/
export function validateProviders(providers) {
if (!Array.isArray(providers) || providers.length === 0)
throw new TypeError('Street Level needs at least one imagery provider');
const ids = new Set();
const prefixes = [];
for (const provider of providers) {
const label = provider?.id ?? '(unnamed)';
for (const key of REQUIRED)
if (provider?.[key] === undefined || provider?.[key] === null)
throw new TypeError(`Street Level provider ${label} lacks ${key}`);
if (typeof provider.id !== 'string' || !ID_GRAMMAR.test(provider.id))
throw new TypeError(`Street Level provider id ${label} is not a slug`);
if (ids.has(provider.id))
throw new TypeError(`Duplicate Street Level provider ${provider.id}`);
if (typeof provider.create !== 'function')
throw new TypeError(
`Street Level provider ${label}: create is not a function`,
);
if (typeof provider.pickPrefix !== 'string' || !provider.pickPrefix)
throw new TypeError(`Street Level provider ${label} needs a pick prefix`);
for (const other of prefixes)
if (
other.startsWith(provider.pickPrefix) ||
provider.pickPrefix.startsWith(other)
)
throw new TypeError(
`Street Level provider ${label}: pick prefix ${provider.pickPrefix} overlaps ${other}`,
);
if (!provider.credit?.html)
throw new TypeError(`Street Level provider ${label} needs a credit`);
if (
typeof provider.colors?.coverage !== 'string' ||
!provider.colors.coverage
)
throw new TypeError(
`Street Level provider ${label} needs a coverage colour`,
);
ids.add(provider.id);
prefixes.push(provider.pickPrefix);
}
return Object.freeze([...providers]);
}
/**
* 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}
*/
export function requiresKeyIdFor(providers) {
const keys = new Set(providers.map((p) => p.requiresKeyId || null));
if (keys.size !== 1) return null;
return [...keys][0];
}
-98
View File
@@ -1,98 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { requiresKeyIdFor, validateProviders } from './registry.js';
const def = (overrides = {}) => ({
id: 'mapillary',
name: 'Mapillary',
label: 'MAPILLARY',
requiresKeyId: 'mapillary',
pickPrefix: 'mly:',
colors: { coverage: '#05cb63' },
credit: { html: 'Mapillary' },
create: () => ({}),
...overrides,
});
test('a valid list is frozen in registration order', () => {
const list = validateProviders([
def(),
def({ id: 'panoramax', pickPrefix: 'pnx:', requiresKeyId: null }),
]);
assert.ok(Object.isFrozen(list));
assert.deepEqual(
list.map((p) => p.id),
['mapillary', 'panoramax'],
);
});
test('an empty list, duplicate ids and malformed ids are rejected', () => {
assert.throws(() => validateProviders([]), /at least one/);
assert.throws(() => validateProviders(null), /at least one/);
assert.throws(() => validateProviders([def(), def()]), /Duplicate/);
assert.throws(() => validateProviders([def({ id: 'Bad Id' })]), /slug/);
});
test('every required field is checked by name', () => {
for (const key of [
'name',
'label',
'pickPrefix',
'colors',
'credit',
'create',
])
assert.throws(
() => validateProviders([def({ [key]: undefined })]),
new RegExp(`lacks ${key}`),
key,
);
assert.throws(() => validateProviders([def({ create: 1 })]), /create/);
assert.throws(() => validateProviders([def({ credit: {} })]), /credit/);
assert.throws(
() => validateProviders([def({ colors: { pano: '#f0f' } })]),
/coverage colour/,
);
assert.throws(
() => validateProviders([def({ pickPrefix: '' })]),
/pick prefix/,
);
});
test('overlapping pick prefixes are rejected either way round', () => {
assert.throws(
() => validateProviders([def(), def({ id: 'other', pickPrefix: 'mly:x' })]),
/overlaps/,
);
assert.throws(
() =>
validateProviders([def({ pickPrefix: 'mly:x' }), def({ id: 'other' })]),
/overlaps/,
);
assert.doesNotThrow(() =>
validateProviders([def(), def({ id: 'other', pickPrefix: 'ml:' })]),
);
});
test('requiresKeyIdFor is the shared key, or null once providers differ', () => {
assert.equal(requiresKeyIdFor([def()]), 'mapillary');
assert.equal(
requiresKeyIdFor([def(), def({ id: 'b', pickPrefix: 'b:' })]),
'mapillary',
);
assert.equal(
requiresKeyIdFor([
def(),
def({ id: 'b', pickPrefix: 'b:', requiresKeyId: null }),
]),
null,
);
assert.equal(
requiresKeyIdFor([
def(),
def({ id: 'b', pickPrefix: 'b:', requiresKeyId: 'google-maps' }),
]),
null,
);
assert.equal(requiresKeyIdFor([def({ requiresKeyId: null })]), null);
});
+19 -12
View File
@@ -1,30 +1,37 @@
import * as Cesium from 'cesium';
import { STREET_LEVEL_LAYER_ID } from './policy.js';
import { POSITION_PICK_ID, STREET_LEVEL_LAYER_ID } from './policy.js';
import { PICK_PREFIX } from './providers/mapillary/policy.js';
import { sequenceIdFromPick } from './providers/mapillary/coverage.js';
/** 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. */
/** One click/hover handler for coverage lines, image cones and the marker. */
export function createSelection({ state, parts }) {
const { picking, input } = state.services;
function ownsPick(pickedId) {
return parts.router.ownsPick(pickedId);
/** Lines and cones (`mly:`) and the position marker are this layer's. */
function ownsPick(id) {
return (
typeof id === 'string' &&
(id === POSITION_PICK_ID || id.startsWith(PICK_PREFIX.root))
);
}
/** A line selects its sequence; a cone opens its image. */
function onClick(click) {
const viewer = state.viewer;
if (!viewer || !state.enabled) return;
if (!viewer || !state.enabled || !state.providerOn) return;
if (input?.isPointerFree && !input.isPointerFree()) return;
const picked = viewer.scene.pick(click.position);
const id = picking?.resolvePickId
? picking.resolvePickId(picked)
: picked?.id;
const route = parts.router.resolve(id);
if (!route?.instance) return;
const entry = state.providers.get(route.providerId);
if (!entry?.on) return;
route.instance.handlePick(route.id);
if (typeof id !== 'string') return;
const sequenceId = sequenceIdFromPick(id);
if (sequenceId) parts.sequences.select(sequenceId);
else if (id.startsWith(PICK_PREFIX.image))
parts.openImage(id.slice(PICK_PREFIX.image.length));
}
/**
@@ -42,9 +49,9 @@ export function createSelection({ state, parts }) {
)
)
return;
if (!parts.hasSelectedSequence()) return;
if (!state.sequence.selectedId) return;
event.preventDefault();
parts.clearSequences();
parts.sequences.clearSelection();
}
let hoverQueued = false;
+11 -5
View File
@@ -58,17 +58,23 @@ function harness({ selected = false } = {}) {
const state = {
viewer,
enabled: true,
providerOn: true,
clickHandler: null,
services: { picking: null, input: null },
sequence: {
get selectedId() {
return sequences.selected ? 'seq-1' : null;
},
},
};
const selection = createSelection({
state,
parts: {
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
hasSelectedSequence: () => sequences.selected,
clearSequences() {
sequences.cleared++;
sequences.selected = false;
sequences: {
clearSelection() {
sequences.cleared++;
sequences.selected = false;
},
},
},
});
+46 -7
View File
@@ -7,9 +7,6 @@ import { FILTER_DEFAULT } from './policy.js';
export function freshStreet() {
return {
open: false,
providerId: null,
providerName: null,
providerLabel: null,
imageId: null,
position: null,
bearing: null,
@@ -25,7 +22,33 @@ export function freshStreet() {
};
}
/** Mutable core state, created once per layer instance. */
/** Coverage bookkeeping, before the first refresh. */
export function freshCoverage() {
return {
zoom: null,
/** Current zoom's tiles by `z/x/y` key. */
tiles: new Map(),
/** 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. */
pending: new Map(),
lastError: null,
/**
* Why tile requests are paused, or null: 'status' until the key status
* answers, 'no-key', 'rejected' (until the layer goes off) or
* 'rate-limited' (until `blockTimer` fires).
*/
blocked: 'status',
blockTimer: null,
debounceTimer: null,
removeCameraListener: null,
terrainReady: null,
hint: '',
};
}
/** Mutable layer state, created once per layer instance. */
export function createState({ services }) {
return {
services,
@@ -35,10 +58,10 @@ export function createState({ services }) {
destroyed: false,
listeners: new Set(),
notify: null,
/** Shared imagery filter, in the stored (relative-days) form. */
/** The share link's `mapillary` switch: off draws nothing. */
providerOn: true,
/** Imagery filter, in the stored (relative-days) form. */
filter: { ...FILTER_DEFAULT },
/** @type {Map<string, {def: object, instance: object, on: boolean}>} */
providers: new Map(),
street: {
host: null,
@@ -47,7 +70,23 @@ export function createState({ services }) {
...freshStreet(),
},
coverage: freshCoverage(),
sequence: {
selectedId: null,
images: [],
/** Recent sequences' thinned images, by sequence id. */
cache: new Map(),
collection: null,
loading: false,
abort: null,
},
marker: { collection: null, billboard: null },
clickHandler: null,
};
}
/** Coverage, cones and lookups run only while the layer and its switch are on. */
export function isActive(state) {
return state.enabled && state.providerOn;
}
+34 -84
View File
@@ -1,4 +1,4 @@
/** Web-Mercator tile math, without Cesium, shared by the browser layer and the server executor. */
/** Web-Mercator tile math for Street Level coverage, without Cesium. */
const MAX_LAT = 85.05112878;
@@ -77,78 +77,42 @@ export function normalizeBbox(input) {
}
/**
* Squared distance (in tiles) from tile (x, y)'s centre to the segment
* `from` → `to` at zoom z, measured the short way round the date line.
*/
function segmentDistance2(tile, from, to, z) {
const n = 2 ** z;
const px = tile.x + 0.5;
const py = tile.y + 0.5;
const ax = tileXAt(from.lon, z);
const ay = tileYAt(from.lat, z);
let bx = to ? tileXAt(to.lon, z) : ax;
const by = to ? tileYAt(to.lat, z) : ay;
if (bx - ax > n / 2) bx -= n;
else if (ax - bx > n / 2) bx += n;
let best = Infinity;
for (const shift of [-n, 0, n]) {
const x = px + shift;
const vx = bx - ax;
const vy = by - ay;
const len2 = vx * vx + vy * vy;
const t = len2
? Math.max(0, Math.min(1, ((x - ax) * vx + (py - ay) * vy) / len2))
: 0;
const dx = x - (ax + t * vx);
const dy = py - (ay + t * vy);
best = Math.min(best, dx * dx + dy * dy);
}
return best;
}
/**
* 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]
* Tiles at zoom z covering a [west, south, east, north] box (west > east
* crosses the date line), nearest to `from` first (default: the box centre),
* capped at `limit`. Distances are measured the short way round ±180°.
* @param {{limit?: number, from?: {lon: number, lat: number}|null}} [options]
* @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}}
*/
export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) {
export function tilesForBbox(bbox, z, { limit = Infinity, from = null } = {}) {
const [west, south, east, north] = Array.isArray(bbox)
? bbox.map(Number)
: [];
const across = west > east;
// A box across the date line is split at ±180°.
const halves = across
? [
[west, south, 180, north],
[-180, south, east, north],
]
: [bbox];
const tiles = halves
.map(normalizeBbox)
.filter(Boolean)
.flatMap((box) => tileGrid(box, z));
const n = 2 ** z;
const tiles = [];
let centre;
if (Array.isArray(bbox) && Number(bbox[0]) > Number(bbox[2])) {
// A box across the date line comes as west > east: split it at ±180°.
const [west, south, east, north] = bbox.map(Number);
for (const half of [
[west, south, 180, north],
[-180, south, east, north],
]) {
const box = normalizeBbox(half);
if (box) tiles.push(...tileGrid(box, z).tiles);
}
centre = {
x: ((lonToTileX(west, z) + lonToTileX(east, z) + n) / 2) % n,
y: (latToTileY(north, z) + latToTileY(south, z)) / 2,
};
} else {
const box = normalizeBbox(bbox);
if (!box) return { tiles: [], truncated: false, total: 0 };
const grid = tileGrid(box, z);
tiles.push(...grid.tiles);
centre = { x: (grid.x0 + grid.x1) / 2, y: (grid.y0 + grid.y1) / 2 };
}
const distance2 = focus?.from
? (tile) => segmentDistance2(tile, focus.from, focus.to, z)
: (tile) => {
// Measured the short way round the date line.
const dx = Math.abs(tile.x - centre.x);
return Math.min(dx, n - dx) ** 2 + (tile.y - centre.y) ** 2;
};
const centre = from || {
lon: wrapLon(west + (east - west + (across ? 360 : 0)) / 2),
lat: (south + north) / 2,
};
const cx = tileXAt(wrapLon(centre.lon), z);
const cy = tileYAt(centre.lat, z);
// Each tile's distance is computed once; ties keep their row order.
const ranked = tiles
.map((tile) => ({ tile, d: distance2(tile) }))
.map((tile) => {
const dx = Math.abs(tile.x + 0.5 - cx);
const d = Math.min(dx, n - dx) ** 2 + (tile.y + 0.5 - cy) ** 2;
return { tile, d };
})
.sort((a, b) => a.d - b.d)
.map(({ tile }) => tile);
const truncated = ranked.length > limit;
@@ -159,7 +123,7 @@ export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) {
};
}
/** The tiles at zoom z in a normalized box, row by row, and their range. */
/** The tiles at zoom z in a normalized box, row by row. */
function tileGrid(box, z) {
const x0 = lonToTileX(box.west, z);
const x1 = lonToTileX(box.east, z);
@@ -168,7 +132,7 @@ function tileGrid(box, z) {
const tiles = [];
for (let y = y0; y <= y1; y++)
for (let x = x0; x <= x1; x++) tiles.push({ x, y, z });
return { tiles, x0, x1, y0, y1 };
return tiles;
}
/** A vector-tile-local coordinate (0..extent) in tile (x, y, z) as [lon, lat]. */
@@ -190,17 +154,3 @@ export function coverageZoomForHeight(heightM) {
if (heightM > 1_800) return 13;
return 14;
}
/**
* 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;
if (heightM > 15_000_000) return 0;
if (heightM > 7_000_000) return 1;
if (heightM > 3_000_000) return 2;
if (heightM > 1_200_000) return 3;
if (heightM > 400_000) return 4;
return 5;
}
+22 -47
View File
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
coverageZoomForHeight,
overviewZoomForHeight,
latToTileY,
lonToTileX,
normalizeBbox,
@@ -74,16 +73,6 @@ test('coverageZoomForHeight steps from coarse to z14 as the camera descends', ()
assert.equal(coverageZoomForHeight(Number.NaN), null);
});
test('overviewZoomForHeight covers the globe above the sequence ceiling', () => {
assert.equal(overviewZoomForHeight(30_000), null);
assert.equal(overviewZoomForHeight(100_000), 5);
assert.equal(overviewZoomForHeight(900_000), 4);
assert.equal(overviewZoomForHeight(2_000_000), 3);
assert.equal(overviewZoomForHeight(5_000_000), 2);
assert.equal(overviewZoomForHeight(10_000_000), 1);
assert.equal(overviewZoomForHeight(25_000_000), 0);
});
test('longitude 180 is the last column, so a world view keeps the eastern hemisphere', () => {
assert.equal(lonToTileX(180, 1), 1);
assert.equal(lonToTileX(179.99, 1), 1);
@@ -108,65 +97,51 @@ test('a box across the date line takes tiles on both sides of it, not the far si
assert.ok(limited.every((tile) => tile.x <= 8 || tile.x >= 247));
});
test('a box across the date line ranks the tiles at the line first, without and with a focus', () => {
test('a box across the date line ranks from the line, or from the camera across it', () => {
const z = 14;
const n = 2 ** z;
const bbox = [179.9, 0, -179.9, 0.1];
const key = (t) => `${t.x}/${t.y}`;
const keys = (list) => new Set(list.map(key));
const [north, south] = [latToTileY(0.1, z), latToTileY(0, z)];
const plain = tilesForBbox(bbox, z);
assert.equal(plain.total, 10 * (south - north + 1), '5 columns either side');
// The box centre is the date line itself, not the middle of the map: the
// middle two rows of the columns touching it come first, then the ring
// around them, all within two columns of the line.
const [mid1, mid2] = [(north + south - 1) / 2, (north + south + 1) / 2];
assert.deepEqual(
keys(plain.tiles.slice(0, 4)),
new Set([`${n - 1}/${mid1}`, `${n - 1}/${mid2}`, `0/${mid1}`, `0/${mid2}`]),
);
// columns touching it come first, then the ones beside them.
for (const tile of plain.tiles.slice(0, 4))
assert.ok([n - 1, 0].includes(tile.x), `${key(tile)} at the line`);
for (const tile of plain.tiles.slice(4, 12))
assert.ok(
[n - 2, n - 1, 0, 1].includes(tile.x),
`${key(tile)} near the line`,
);
// A line of sight across the date line just north of the equator: the two
// tiles it crosses come first, then the two in the row below them.
// From a camera metres from the line: its tile, then the one across it.
const row = latToTileY(0.05, z);
const from = { lon: 179.99, lat: 0.05 };
const to = { lon: -179.99, lat: 0.05 };
for (const focus of [
{ from, to },
{ from: to, to: from },
]) {
const ranked = tilesForBbox(bbox, z, { focus }).tiles;
assert.deepEqual(
keys(ranked.slice(0, 2)),
new Set([`${n - 1}/${row}`, `0/${row}`]),
);
assert.deepEqual(
keys(ranked.slice(2, 4)),
new Set([`${n - 1}/${row + 1}`, `0/${row + 1}`]),
);
}
const { south: rowSouth, north: rowNorth } = tileBounds(0, row, z);
const lat = (rowSouth + rowNorth) / 2;
const west = tilesForBbox(bbox, z, { from: { lon: 179.999, lat } });
assert.deepEqual(west.tiles.slice(0, 2).map(key), [
`${n - 1}/${row}`,
`0/${row}`,
]);
const east = tilesForBbox(bbox, z, { from: { lon: -179.999, lat } });
assert.deepEqual(east.tiles.slice(0, 2).map(key), [
`0/${row}`,
`${n - 1}/${row}`,
]);
});
test('a focus ranks tiles along the line of sight, not from the box centre', () => {
test('ranking from the camera keeps the ground under it, not 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.
// box centre is kilometres from the camera.
const from = { lon: -121.4944, lat: 38.5816 };
const to = { lon: -121.4944, lat: 38.596 };
const bbox = [-121.6, 38.57, -121.38, 38.9];
const z = 14;
const key = (t) => `${t.x}/${t.y}`;
const under = `${lonToTileX(from.lon, z)}/${latToTileY(from.lat, z)}`;
const centre = `${lonToTileX(to.lon, z)}/${latToTileY(to.lat, z)}`;
const plain = tilesForBbox(bbox, z, { limit: 9 }).tiles.map(key);
assert.equal(plain.includes(under), false, 'the old ranking drops them');
const ranked = tilesForBbox(bbox, z, { limit: 9, focus: { from, to } });
const keys = ranked.tiles.map(key);
assert.ok(keys.includes(under), 'the tile under the camera');
assert.ok(keys.includes(centre), 'the tile at the centre of the screen');
assert.equal(plain.includes(under), false, 'the box centre drops it');
const ranked = tilesForBbox(bbox, z, { limit: 9, from });
assert.equal(key(ranked.tiles[0]), under, 'the tile under the camera first');
assert.equal(ranked.total > 9, true);
});
-74
View File
@@ -1,74 +0,0 @@
import { COLORS } from './policy.js';
/**
* @typedef {object} ProviderSnapshot
* @property {string} id
* @property {string} name
* @property {string} label
* @property {boolean} on
* @property {boolean|null} configured null until the status call answers
* @property {boolean} keyRequired no usable key: missing or rejected
* @property {boolean} [keyRejected] the provider refused the configured key
* @property {string|null} requiresKeyId
* @property {boolean} loading
* @property {number} count
* @property {string} hint
* @property {string|null} error
* @property {string} color The source's one colour.
*/
/**
* 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) {
const active = providers.filter((p) => p.on);
return {
count: active.reduce((sum, p) => sum + (p.count || 0), 0),
loading: active.some((p) => p.loading),
hint: active.find((p) => p.hint)?.hint || '',
error: active.find((p) => p.error)?.error || null,
keyRequired:
active.length > 0 && active.every((p) => p.keyRequired === true),
keyRejected:
active.length > 0 &&
active.every((p) => p.keyRequired === true) &&
active.some((p) => p.keyRejected === true),
};
}
/**
* 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({
enabled,
filter,
providers,
street,
sequence,
}) {
const active = providers.filter((p) => p.on);
const { keyRequired, keyRejected, ...coverage } =
summarizeCoverage(providers);
const legend = active.map((provider) => ({
key: provider.id,
label: provider.name,
color: provider.color,
}));
if (active.length)
legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected });
return {
enabled,
keyRequired,
keyRejected,
filter: { ...filter },
providers: providers.map((p) => ({ ...p })),
coverage,
legend,
sequence: { ...sequence },
street: { ...street },
};
}
-139
View File
@@ -1,139 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeUIState, summarizeCoverage } from './uiState.js';
import { COLORS } from './policy.js';
import { providerSnapshot } from '../../testSupport/streetLevelFakes.mjs';
const provider = (overrides = {}) =>
providerSnapshot({ count: 10, ...overrides });
const base = {
enabled: true,
filter: { pano: 'all', sinceDays: 0 },
street: { open: false },
sequence: { selectedId: null, images: 0, loading: false },
};
test('counts add up across active providers only', () => {
const ui = composeUIState({
...base,
providers: [
provider(),
provider({ id: 'panoramax', name: 'Panoramax', count: 5, on: false }),
provider({ id: 'kartaview', name: 'KartaView', count: 7, loading: true }),
],
});
assert.equal(ui.coverage.count, 17);
assert.equal(ui.coverage.loading, true);
});
test('the layer is key-gated only when every switched-on provider lacks its key', () => {
const gated = composeUIState({
...base,
providers: [provider({ keyRequired: true })],
});
assert.equal(gated.keyRequired, true);
const mixed = composeUIState({
...base,
providers: [
provider({ keyRequired: true }),
provider({ id: 'panoramax', name: 'Panoramax', requiresKeyId: null }),
],
});
assert.equal(mixed.keyRequired, false, 'a keyless provider still draws');
const offOnly = composeUIState({
...base,
providers: [provider({ keyRequired: true, on: false })],
});
assert.equal(offOnly.keyRequired, false, 'nothing switched on to gate');
});
test('the legend is one swatch per active source, in its colour, then Selected', () => {
const single = composeUIState({ ...base, providers: [provider()] });
assert.deepEqual(single.legend, [
{ key: 'mapillary', label: 'Mapillary', color: '#05cb63' },
{ key: 'selected', label: 'Selected', color: COLORS.selected },
]);
const two = composeUIState({
...base,
providers: [
provider(),
provider({ id: 'panoramax', name: 'Panoramax', color: '#a66bff' }),
],
});
assert.deepEqual(
two.legend.map((entry) => [entry.label, entry.color]),
[
['Mapillary', '#05cb63'],
['Panoramax', '#a66bff'],
['Selected', COLORS.selected],
],
);
const oneOff = composeUIState({
...base,
providers: [
provider(),
provider({ id: 'panoramax', name: 'Panoramax', on: false }),
],
});
assert.deepEqual(
oneOff.legend.map((entry) => entry.key),
['mapillary', 'selected'],
);
assert.deepEqual(
composeUIState({ ...base, providers: [provider({ on: false })] }).legend,
[],
);
});
test('hint and error come from the first active provider that has one', () => {
const ui = composeUIState({
...base,
providers: [
provider({ hint: '', error: null }),
provider({ id: 'b', name: 'B', hint: 'Look down', error: 'boom' }),
],
});
assert.equal(ui.coverage.hint, 'Look down');
assert.equal(ui.coverage.error, 'boom');
assert.deepEqual(ui.filter, base.filter);
assert.notEqual(ui.filter, base.filter, 'snapshot copies the filter');
});
test('summarizeCoverage is what getStats reports, without building a snapshot', () => {
assert.deepEqual(
summarizeCoverage([
provider({ count: 4, hint: 'Look down' }),
provider({ id: 'b', name: 'B', count: 6, loading: true, error: 'boom' }),
provider({ id: 'c', name: 'C', count: 99, on: false, keyRequired: true }),
]),
{
count: 10,
loading: true,
hint: 'Look down',
error: 'boom',
keyRequired: false,
keyRejected: false,
},
);
assert.equal(summarizeCoverage([]).keyRequired, false);
});
test('a rejected key gates like a missing one and is named as rejected', () => {
const rejected = provider({
keyRequired: true,
keyRejected: true,
error: 'Mapillary rejected MAPILLARY_CLIENT_TOKEN',
});
const summary = summarizeCoverage([rejected]);
assert.equal(summary.keyRequired, true);
assert.equal(summary.keyRejected, true);
assert.equal(summary.error, 'Mapillary rejected MAPILLARY_CLIENT_TOKEN');
const state = composeUIState({ ...base, providers: [rejected] });
assert.equal(state.keyRequired, true);
assert.equal(state.keyRejected, true);
// A provider that is merely missing its key is not "rejected".
assert.equal(
summarizeCoverage([provider({ keyRequired: true })]).keyRejected,
false,
);
});
+11 -73
View File
@@ -10,16 +10,6 @@ export function metresBetween(a, b) {
);
}
/**
* 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')
globalThis.requestIdleCallback(task, { timeout });
else setTimeout(() => task(null), 16);
}
/**
* Ellipsoidal height (m) of the globe terrain under the camera; null where
* the globe is hidden (Google 3D) or not yet loaded.
@@ -63,13 +53,14 @@ function groundHit(camera, point, ellipsoid, maxRange) {
/**
* 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, `maxRange` (m) drops
* near-horizon hits, and `nearRange` (m) always includes the ground around
* the camera.
* `groundHeight` raises the ellipsoid to the ground (rays to the bare
* ellipsoid under Denver land kilometres too far out) and `maxRange` (m)
* drops near-horizon hits; with it, too few hits give null rather than
* Cesium's view rectangle, which can span the horizon.
*/
export function visibleBbox(
viewer,
{ grid = 5, groundHeight = null, maxRange = null, nearRange = null } = {},
{ grid = 5, groundHeight = null, maxRange = null } = {},
) {
const scene = viewer?.scene;
const camera = viewer?.camera;
@@ -92,27 +83,6 @@ 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.
const nadir = camera.positionCartographic;
if (Number.isFinite(nearRange) && nearRange > 0 && nadir) {
const dLat = nearRange / 111_320;
const dLon = dLat / Math.max(0.05, Math.cos(nadir.latitude));
const lat = Cesium.Math.toDegrees(nadir.latitude);
const lon = Cesium.Math.toDegrees(nadir.longitude);
for (const [dx, dy] of [
[-1, -1],
[-1, 1],
[1, -1],
[1, 1],
])
hits.push(
Cesium.Cartographic.fromDegrees(
lon + dx * dLon,
Math.max(-85, Math.min(85, lat + dy * dLat)),
),
);
}
if (hits.length >= 4) {
let west = Infinity;
let south = Infinity;
@@ -145,6 +115,7 @@ export function visibleBbox(
}
if (west !== east && north - south > 0) return [west, south, east, north];
}
if (Number.isFinite(maxRange)) return null;
const rectangle = camera.computeViewRectangle?.(scene.globe?.ellipsoid);
if (!rectangle) return null;
return [
@@ -166,50 +137,17 @@ export function viewCentre(viewer) {
if (lon > 180) lon -= 360;
return { lat: (bbox[1] + bbox[3]) / 2, lon };
}
const carto = viewer.camera?.positionCartographic;
if (!carto) return null;
return {
lat: Cesium.Math.toDegrees(carto.latitude),
lon: Cesium.Math.toDegrees(carto.longitude),
};
return cameraNadir(viewer);
}
/**
* 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,
{ groundHeight = null, maxRange = null } = {},
) {
const camera = viewer?.camera;
const carto = camera?.positionCartographic;
/** The point on the ground under the camera, as {lon, lat} degrees. */
export function cameraNadir(viewer) {
const carto = viewer?.camera?.positionCartographic;
if (!carto) return null;
const nadir = {
return {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
};
const canvas = viewer.scene?.canvas;
const width = canvas?.clientWidth || canvas?.width || 0;
const height = canvas?.clientHeight || canvas?.height || 0;
let ahead = null;
if (width > 0 && height > 0) {
const hit = groundHit(
camera,
new Cesium.Cartesian2(width / 2, height / 2),
raisedEllipsoid(
viewer.scene?.globe?.ellipsoid || Cesium.Ellipsoid.WGS84,
groundHeight,
),
maxRange,
);
if (hit)
ahead = {
lon: Cesium.Math.toDegrees(hit.longitude),
lat: Cesium.Math.toDegrees(hit.latitude),
};
}
return { nadir, ahead };
}
/**
+23 -12
View File
@@ -2,11 +2,11 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
cameraNadir,
createHorizonCull,
groundUnderCamera,
metresBetween,
viewCentre,
viewFocus,
visibleBbox,
} from './view.js';
import { rayCamera } from '../../testSupport/streetLevelFakes.mjs';
@@ -113,20 +113,22 @@ test('a tilted street view over high ground boxes the streets it looks at, not t
// 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 };
const options = { groundHeight: 1610, maxRange: 6000 };
const [west, south, east, north] = visibleBbox(view, options);
assert.ok(north - south < 0.08 && east - west < 0.08, 'a street-sized box');
assert.ok(south < 39.74 && north > ahead, 'camera and screen centre inside');
const focus = viewFocus(view, options);
assert.ok(Math.abs(focus.nadir.lat - 39.74) < 1e-9);
assert.ok(south < ahead && north > ahead, 'the screen centre is inside');
assert.ok(
Math.abs(focus.ahead.lat - ahead) < 0.002,
'centre ray on the street',
south > 39.74,
'it starts ahead of the camera, where the view does',
);
assert.ok(west < -104.99 && east > -104.99);
const nadir = cameraNadir(view);
assert.ok(
Math.abs(nadir.lat - 39.74) < 1e-9 && Math.abs(nadir.lon + 104.99) < 1e-9,
);
assert.ok(Math.abs(focus.ahead.lon - -104.99) < 1e-6);
});
test('looking at the horizon from eye height still boxes the ground around the camera', () => {
test('looking at the horizon from eye height boxes the near ground, not the horizon', () => {
const view = pinhole({
lon: -121.4944,
lat: 38.5816,
@@ -137,10 +139,19 @@ test('looking at the horizon from eye height still boxes the ground around the c
const [west, south, east, north] = visibleBbox(view, {
groundHeight: 10,
maxRange: 2500,
nearRange: 1000,
});
assert.ok(north - 38.5816 > 0.008 && 38.5816 - south > 0.008);
assert.ok(east - -121.4944 > 0.008 && -121.4944 - west > 0.008);
assert.ok(south > 38.5816 && south - 38.5816 < 0.001, 'from just ahead');
assert.ok(north - 38.5816 < 2500 / 111_000, 'no further than the range');
assert.ok(west < -121.4944 && east > -121.4944);
// Looking at the sky, nothing is in range: no box, never the horizon's.
const sky = pinhole({
lon: -121.4944,
lat: 38.5816,
ground: 10,
agl: 2,
pitch: 60,
});
assert.equal(visibleBbox(sky, { groundHeight: 10, maxRange: 2500 }), null);
});
test('metresBetween measures the short way round the date line', () => {
+43 -116
View File
@@ -1,31 +1,25 @@
import { freshStreet } from './state.js';
/**
* Owns the panel's viewer element and the provider adapter mounted in it.
* Adapter poses update `state.street` and the marker.
* Owns the panel's viewer element and the photo viewer mounted in it (the
* MapillaryJS adapter, or a stand-in). Its poses update `state.street` and
* the marker; MapillaryJS loads on the first open.
*/
export function createViewerHost({ state, parts }) {
/** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */
let active = null;
/** @type {{id: string, promise: Promise<object>}|null} */
export function createViewerHost({ state, parts, adapter }) {
/** Set once mounted, until `unmount`. */
let unsubscribe = null;
/** The mount in flight, shared by concurrent opens. */
let mounting = null;
let openSeq = 0;
/** Prewarmed, inactive adapters; each holds a live viewer (WebGL context) until `unmount`. */
const warmed = new Map();
function notify() {
state.notify?.();
}
function providerEntry(providerId) {
return state.providers.get(providerId) || null;
}
function applyPose(pose) {
if (!active || !state.street.open || pose.providerId !== active.id) return;
if (!unsubscribe || !state.street.open) return;
const previousSequence = state.street.sequenceId;
Object.assign(state.street, {
providerId: pose.providerId,
imageId: pose.imageId,
position: pose.position ? { ...pose.position } : null,
bearing: Number.isFinite(pose.bearing) ? pose.bearing : null,
@@ -46,63 +40,43 @@ export function createViewerHost({ state, parts }) {
}
function selectCurrentSequence() {
const { sequenceId, providerId } = state.street;
const entry = providerEntry(providerId);
if (!sequenceId || !entry?.instance.selectSequence || !state.enabled)
return;
const current = entry.instance.sequenceStats?.()?.selectedId;
if (current !== sequenceId) entry.instance.selectSequence(sequenceId);
const { sequenceId } = state.street;
if (!sequenceId || !state.enabled) return;
if (state.sequence.selectedId !== sequenceId)
parts.sequences.select(sequenceId);
}
/**
* 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.
* Mount the viewer. It counts as mounted 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);
if (mounting?.id === entry.def.id) return mounting.promise;
if (active) {
active.adapter.close();
active.unsubscribe();
active.adapter.unmount();
active = null;
}
const adapter = entry.instance.viewer;
function mount() {
if (unsubscribe) return Promise.resolve();
if (mounting) return mounting;
const promise = (async () => {
await adapter.mount(state.street.host);
if (mounting?.promise !== promise) {
// Unmounted (layer off, provider switched) while loading. A newer
// mount of this provider shares the adapter and its viewer: tearing
// it down here would leave that mount active with no viewer.
const reused =
active?.id === entry.def.id || mounting?.id === entry.def.id;
if (!reused) adapter.unmount();
if (mounting !== promise) {
// Unmounted (layer off) while loading. A newer mount shares the
// viewer: tearing it down here would leave that mount without one.
if (!mounting && !unsubscribe) adapter.unmount();
throw new Error('Street-level viewer was closed');
}
// Listen only once current: an outdated mount releasing the same
// `applyPose` would otherwise unhook the mount that replaced it.
const unsubscribe = adapter.onPose(applyPose);
active = { id: entry.def.id, adapter, unsubscribe };
warmed.delete(entry.def.id);
unsubscribe = adapter.onPose(applyPose);
adapter.setRenderMode?.(state.street.renderMode);
return adapter;
})();
mounting = { id: entry.def.id, promise };
promise.then(
() => {
if (mounting?.promise === promise) mounting = null;
},
() => {
if (mounting?.promise === promise) mounting = null;
},
);
mounting = promise;
const settle = () => {
if (mounting === promise) mounting = null;
};
promise.then(settle, settle);
return promise;
}
/** Open an image: true once its first pose is in, false if it failed or was overtaken. */
async function open(providerId, imageId) {
const entry = providerEntry(providerId);
if (!entry || !imageId) return false;
async function open(imageId) {
if (!imageId) return false;
if (!state.street.host) {
// A nearest-image lookup may have set it; nothing is loading now.
state.street.loading = false;
@@ -114,17 +88,10 @@ export function createViewerHost({ state, parts }) {
const current = () => seq === openSeq;
// Claimed now, honoured after loading only if nothing newer took the camera.
const ticket = parts.framing.begin();
Object.assign(state.street, {
loading: true,
error: null,
open: true,
providerId,
providerName: entry.def.name,
providerLabel: entry.def.label,
});
Object.assign(state.street, { loading: true, error: null, open: true });
notify();
try {
const adapter = await mount(entry);
await mount();
if (!current()) return false;
await adapter.open(String(imageId));
if (!current()) return false;
@@ -141,11 +108,11 @@ export function createViewerHost({ state, parts }) {
return true;
}
/** Close the image and stop any framing flight; the adapter stays warm. */
/** Close the image and stop any framing flight; the viewer stays mounted. */
function close() {
openSeq++;
parts.framing.cancel();
active?.adapter.close();
if (unsubscribe) adapter.close();
Object.assign(state.street, freshStreet());
parts.marker.clear();
notify();
@@ -158,7 +125,7 @@ export function createViewerHost({ state, parts }) {
function resize() {
try {
active?.adapter.resize();
if (unsubscribe) adapter.resize();
} catch {
/* no-op */
}
@@ -167,61 +134,21 @@ export function createViewerHost({ state, parts }) {
function setRenderMode(mode) {
state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox';
try {
active?.adapter.setRenderMode?.(state.street.renderMode);
if (unsubscribe) adapter.setRenderMode?.(state.street.renderMode);
} catch {
/* adapter not ready */
}
notify();
}
/** Stand active providers' viewers up ahead of the first image. */
async function prewarm(entries) {
const host = state.street.host;
if (!host || !state.enabled) return;
for (const entry of entries) {
const adapter = entry.instance.viewer;
if (!adapter.prewarm || !entry.on) continue;
try {
await adapter.prewarm(host);
} catch {
/* the real open reports errors */
}
if (active?.id === entry.def.id) continue;
// Switched off while it loaded: release it now rather than keep it.
if (!state.enabled || !entry.on) adapter.unmount();
else warmed.set(entry.def.id, adapter);
}
/** Close the image and destroy the viewer (and its WebGL context). */
function unmount() {
close();
mounting = null;
unsubscribe?.();
unsubscribe = null;
adapter.unmount();
}
/** Tear down every viewer, or only `providerId`'s (active or prewarmed). */
function unmount(providerId) {
if (
!providerId ||
active?.id === providerId ||
mounting?.id === providerId
) {
close();
mounting = null;
if (active) {
active.unsubscribe();
active.adapter.unmount();
active = null;
}
}
for (const [id, adapter] of warmed) {
if (providerId && id !== providerId) continue;
warmed.delete(id);
adapter.unmount();
}
}
return {
attach,
open,
close,
resize,
setRenderMode,
prewarm,
unmount,
};
return { attach, open, close, resize, setRenderMode, unmount };
}
+35 -97
View File
@@ -33,27 +33,16 @@ const { createMapillaryViewer } =
/**
* A viewer host over `adapter`. `marker` records where the position marker
* was put (null once cleared), `framing` counts framing calls and
* `selected` every sequence the provider was asked to select.
* `selected` every sequence the layer was asked to select.
*/
function harness(adapter) {
const state = {
enabled: true,
notify() {},
providers: new Map(),
street: { host: {}, renderMode: 'letterbox' },
sequence: { selectedId: null },
};
const def = { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' };
const selected = [];
let selectedId = null;
const instance = {
viewer: adapter,
selectSequence(id) {
selected.push(id);
selectedId = id;
},
sequenceStats: () => ({ selectedId }),
};
state.providers.set('mapillary', { def, instance });
const framing = { begun: 0, started: 0, cancelled: 0 };
const marker = [];
const parts = {
@@ -67,17 +56,23 @@ function harness(adapter) {
frame: (ticket) => ticket && framing.started++,
cancel: () => framing.cancelled++,
},
sequences: {
select(id) {
selected.push(id);
state.sequence.selectedId = id;
},
},
};
return {
state,
framing,
marker,
selected,
/** Esc on the map: the provider drops its selected sequence. */
/** Esc on the map: the selected sequence is dropped. */
clearSequence() {
selectedId = null;
state.sequence.selectedId = null;
},
host: createViewerHost({ state, parts }),
host: createViewerHost({ state, parts, adapter }),
};
}
@@ -102,7 +97,6 @@ function fakeAdapter({ failMounts = 0, gate = null, openGate = null } = {}) {
/** A pose event from the library, as MapillaryJS fires them. */
emitPose(id, overrides = {}) {
emit?.({
providerId: 'mapillary',
imageId: id,
sequenceId: `seq-${id}`,
position: { lon: 1, lat: 2 },
@@ -139,10 +133,10 @@ function fakeAdapter({ failMounts = 0, gate = null, openGate = null } = {}) {
test('a failed mount is retried on the next open instead of being cached', async () => {
const adapter = fakeAdapter({ failMounts: 1 });
const { state, host } = harness(adapter);
await host.open('mapillary', 'a');
await host.open('a');
assert.equal(state.street.error, 'library failed to load');
assert.equal(adapter.calls.listeners, 0, 'the pose listener was released');
await host.open('mapillary', 'b');
await host.open('b');
assert.equal(state.street.error, null);
assert.equal(adapter.calls.mount, 2);
assert.deepEqual(adapter.calls.open, ['b']);
@@ -153,7 +147,7 @@ test('a failed mount is retried on the next open instead of being cached', async
test('concurrent opens share one mount and one pose listener', async () => {
const adapter = fakeAdapter();
const { host } = harness(adapter);
await Promise.all([host.open('mapillary', 'a'), host.open('mapillary', 'b')]);
await Promise.all([host.open('a'), host.open('b')]);
assert.equal(adapter.calls.mount, 1);
assert.equal(adapter.calls.listeners, 1);
});
@@ -161,62 +155,22 @@ test('concurrent opens share one mount and one pose listener', async () => {
test('unmounting while a mount is in flight does not leave it active', async () => {
const adapter = fakeAdapter();
const { state, host } = harness(adapter);
const opening = host.open('mapillary', 'a');
const opening = host.open('a');
host.unmount();
await opening;
assert.equal(adapter.calls.listeners, 0);
assert.equal(adapter.calls.unmount, 1);
assert.equal(adapter.calls.unmount, 2, 'layer off, then the late mount');
assert.equal(state.street.open, false);
// Not left active: the next open mounts again.
await host.open('mapillary', 'b');
await host.open('b');
assert.equal(adapter.calls.mount, 2);
assert.equal(state.street.imageId, 'b');
});
test('a prewarmed viewer is released when the layer goes off, or at once if it went off mid-load', async () => {
const adapter = fakeAdapter();
let release = null;
adapter.prewarm = () =>
new Promise((resolve) => {
release = resolve;
});
const { state, host } = harness(adapter);
const entry = state.providers.get('mapillary');
entry.on = true;
// Warmed, then the layer is switched off: unmount releases the warm viewer.
const warming = host.prewarm([entry]);
release();
await warming;
assert.equal(adapter.calls.unmount, 0);
host.unmount();
assert.equal(adapter.calls.unmount, 1, 'the WebGL viewer is destroyed');
// Switched off while the library was still loading: released on arrival.
const late = host.prewarm([entry]);
state.enabled = false;
release();
await late;
assert.equal(adapter.calls.unmount, 2);
});
test('switching one provider off releases only its viewer', async () => {
const adapter = fakeAdapter();
adapter.prewarm = async () => {};
const { state, host } = harness(adapter);
const entry = state.providers.get('mapillary');
entry.on = true;
await host.prewarm([entry]);
host.unmount('panoramax');
assert.equal(adapter.calls.unmount, 0, 'another provider leaves it alone');
host.unmount('mapillary');
assert.equal(adapter.calls.unmount, 1);
});
test('a pose that arrives after the photo was closed does not bring it back', async () => {
const adapter = fakeAdapter();
const { state, host } = harness(adapter);
await host.open('mapillary', 'a');
await host.open('a');
host.close();
const before = { ...state.street };
// The library's `image` event for a photo that was still loading.
@@ -239,16 +193,16 @@ test('switching the layer off and on during a cold first open keeps the shared v
const gate = deferred();
const adapter = fakeAdapter({ gate });
const { state, host } = harness(adapter);
const first = host.open('mapillary', 'a'); // the library is still loading
const first = host.open('a'); // the library is still loading
host.unmount(); // layer off …
const second = host.open('mapillary', 'b'); // … on again, and a new click
const second = host.open('b'); // … on again, and a new click
gate.resolve();
await Promise.all([first, second]);
assert.equal(adapter.calls.unmount, 0, 'the outdated mount left it alone');
assert.equal(adapter.calls.unmount, 1, 'only the layer going off');
assert.equal(adapter.calls.listeners, 1);
assert.equal(state.street.error, null);
assert.equal(state.street.imageId, 'b');
await host.open('mapillary', 'c');
await host.open('c');
assert.equal(state.street.error, null, 'later opens are not stuck');
assert.equal(state.street.imageId, 'c');
});
@@ -258,7 +212,7 @@ const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
test('every pose the viewer reports moves the marker; only the open frames the camera', async () => {
const adapter = fakeAdapter();
const { host, marker, framing } = harness(adapter);
await host.open('mapillary', 'a');
await host.open('a');
assert.deepEqual(marker, [{ position: { lon: 1, lat: 2 }, bearing: 90 }]);
// The user walks on: the marker goes along, the globe camera stays.
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 180 });
@@ -276,7 +230,7 @@ test('a pose on another sequence selects it, but not while the image still loads
const openGate = deferred();
const adapter = fakeAdapter({ openGate });
const { state, host, selected, clearSequence } = harness(adapter);
const opening = host.open('mapillary', 'a');
const opening = host.open('a');
await settle();
assert.equal(state.street.loading, true, 'the image is downloading');
assert.equal(state.street.sequenceId, 'seq-a', 'its pose is in');
@@ -299,7 +253,7 @@ test('a pose on another sequence selects it, but not while the image still loads
test('closing the photo clears the marker', async () => {
const adapter = fakeAdapter();
const { host, marker } = harness(adapter);
await host.open('mapillary', 'a');
await host.open('a');
assert.notEqual(marker.at(-1), null);
host.close();
assert.equal(marker.at(-1), null);
@@ -309,7 +263,7 @@ test('an open still mounting when the photo is closed does nothing when the moun
const gate = deferred();
const adapter = fakeAdapter({ gate });
const { state, host, framing, selected } = harness(adapter);
const opening = host.open('mapillary', 'a'); // MapillaryJS still loading
const opening = host.open('a'); // MapillaryJS still loading
await settle();
host.close();
gate.resolve();
@@ -322,7 +276,7 @@ test('an open still mounting when the photo is closed does nothing when the moun
[false, false, null],
);
// The mounted viewer serves the next open.
assert.equal(await host.open('mapillary', 'b'), true);
assert.equal(await host.open('b'), true);
assert.equal(state.street.imageId, 'b');
});
@@ -330,7 +284,7 @@ test('an image still opening when the photo is closed is neither framed nor sele
const openGate = deferred();
const adapter = fakeAdapter({ openGate });
const { state, host, framing, selected } = harness(adapter);
const opening = host.open('mapillary', 'a');
const opening = host.open('a');
await settle();
assert.deepEqual(adapter.calls.open, ['a'], 'the image is downloading');
host.close();
@@ -408,16 +362,16 @@ test('the Mapillary viewer survives the layer going off and on during its first
const { gate, viewers } = fakeLibrary();
const adapter = createMapillaryViewer({ source: { token: 't' } });
const { state, host } = harness(adapter);
const first = host.open('mapillary', 'img1');
const first = host.open('img1');
await Promise.resolve();
host.unmount();
const second = host.open('mapillary', 'img2');
const second = host.open('img2');
await Promise.resolve();
gate.resolve();
await Promise.all([first, second]);
assert.equal(state.street.error, null);
assert.equal(state.street.imageId, 'img2');
await host.open('mapillary', 'img3');
await host.open('img3');
assert.equal(state.street.error, null, 'later opens are not stuck');
assert.equal(state.street.imageId, 'img3');
assert.equal(viewers.live, 1);
@@ -439,20 +393,6 @@ test('a Mapillary viewer unmounted mid-download is never built; the next mount b
assert.equal(viewers.live, 0, 'nothing left holding a WebGL context');
});
test('a prewarm that gave up when the layer went off and on mid-download is followed by one that builds', async () => {
const { gate, viewers } = fakeLibrary();
const adapter = createMapillaryViewer({ source: { token: 't' } });
const element = {};
const first = adapter.prewarm(element);
adapter.unmount(); // layer off while MapillaryJS downloads
const second = adapter.prewarm(element); // layer on again
gate.resolve();
await Promise.all([first, second]);
assert.equal(viewers.created, 1, 'the second prewarm built the viewer');
assert.equal(viewers.live, 1);
adapter.unmount();
});
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();
@@ -467,7 +407,7 @@ test('closing the photo stops sequence playback in the hidden viewer', async ()
gate.resolve();
const adapter = createMapillaryViewer({ source: { token: 't' } });
const { host } = harness(adapter);
await host.open('mapillary', 'img1');
await host.open('img1');
const [viewer] = viewers.instances;
assert.equal(viewer.playback.stops, 0);
host.close();
@@ -478,13 +418,11 @@ test('closing the photo stops sequence playback in the hidden viewer', 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');
await host.open('a');
assert.equal(framing.started, 1, 'the open framed the photo');
host.close();
assert.equal(framing.cancelled, 1, 'closed: the globe stops flying to it');
await host.open('mapillary', 'b');
host.unmount('panoramax');
assert.equal(framing.cancelled, 1, 'another provider leaves it alone');
await host.open('b');
host.unmount();
assert.equal(framing.cancelled, 2, 'layer off: likewise');
});
@@ -557,7 +495,7 @@ test('a photo asks for the camera when it starts opening and frames with that ti
const openGate = deferred();
const adapter = fakeAdapter({ openGate });
const { host, framing } = harness(adapter);
const opening = host.open('mapillary', 'a');
const opening = host.open('a');
assert.equal(framing.begun, 1, 'claimed before the download');
await settle();
assert.equal(framing.started, 0);
+33 -67
View File
@@ -1,83 +1,49 @@
/** Shared Street Level test stand-ins: provider snapshot, minimal provider, ray-casting camera. */
/** Shared Street Level test stand-ins: Mapillary source, photo viewer, ray-casting camera. */
import * as Cesium from 'cesium';
import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js';
const RAD = Math.PI / 180;
/** One provider as `getUIState().providers` lists it. */
export function providerSnapshot(overrides = {}) {
/**
* A Mapillary source for the layer: a configured key, empty tiles, and the
* given lookups; `calls` records tile requests.
*/
export function fakeMapillarySource(overrides = {}) {
const calls = { tiles: [] };
return {
id: 'mapillary',
name: 'Mapillary',
label: 'MAPILLARY',
on: true,
configured: true,
keyRequired: false,
requiresKeyId: 'mapillary',
loading: false,
count: 0,
hint: '',
error: null,
color: '#05cb63',
calls,
hasToken: () => true,
getStatus: async () => ({ configured: true }),
getTile: async (...args) => {
calls.tiles.push(args);
return new Uint8Array(0);
},
getSequenceImages: async () => [],
nearestImages: async () => [],
...overrides,
};
}
/**
* The smallest provider the core accepts. Mutate `stats` to change what
* coverageStats() answers; `calls`, `filters` and `context()` record use.
* A photo viewer in place of MapillaryJS: `calls` records use, and `open`
* reports a pose for the image (when `pose` is given) as the real one does.
*/
export function fakeStreetLevelProvider({
id = 'mapillary',
pickPrefix = 'mly:',
html = MAPILLARY_CREDIT_HTML,
nearestImage = async () => null,
} = {}) {
const calls = { activate: 0, deactivate: 0, mount: 0, open: [], unmount: 0 };
const filters = [];
const stats = {
count: 0,
zoom: null,
kind: null,
loading: false,
hint: '',
error: null,
keyRequired: false,
};
let context = null;
export function fakePhotoViewer({ pose = null } = {}) {
const calls = { mount: 0, open: [], unmount: 0 };
let emit = null;
return {
calls,
filters,
stats,
context: () => context,
id,
name: 'Mapillary',
label: 'MAPILLARY',
requiresKeyId: null,
pickPrefix,
colors: { coverage: '#05cb63' },
credit: { html },
create: (providerContext) => {
context = providerContext;
return {
status: async () => ({ configured: true }),
init() {},
activate: () => calls.activate++,
deactivate: () => calls.deactivate++,
destroy() {},
refreshCoverage() {},
setFilter: (filter) => filters.push(filter),
coverageStats: () => ({ ...stats }),
handlePick: () => false,
nearestImage,
viewer: {
mount: async () => calls.mount++,
open: async (imageId) => calls.open.push(imageId),
close() {},
unmount: () => calls.unmount++,
resize() {},
onPose: () => () => {},
},
mount: async () => calls.mount++,
async open(imageId) {
calls.open.push(imageId);
if (pose) emit?.({ imageId, ...pose });
},
close() {},
unmount: () => calls.unmount++,
resize() {},
onPose(listener) {
emit = listener;
return () => {
emit = null;
};
},
};
+8 -8
View File
@@ -294,8 +294,8 @@ for (const [why, retryAfter] of [
test('normalizeTileAddress enforces layer names, zoom ranges and tile bounds', () => {
assert.equal(
normalizeTileAddress({ layer: 'coverage', z: 3, x: 1, y: 2 }).key,
'coverage/3/1/2',
normalizeTileAddress({ layer: 'coverage', z: 11, x: 1, y: 2 }).key,
'coverage/11/1/2',
);
assert.deepEqual(
normalizeTileAddress({ layer: 'coverage', z: '14', x: '5', y: '6' })
@@ -307,8 +307,8 @@ test('normalizeTileAddress enforces layer names, zoom ranges and tile bounds', (
{ layer: 'points', z: 14, x: 1, y: 1 },
{ layer: 'signs', z: 14, x: 1, y: 1 },
{ layer: 'coverage', z: 15, x: 1, y: 1 },
{ layer: 'coverage', z: 2, x: 4, y: 0 },
{ layer: 'coverage', z: 2, x: 1.5, y: 0 },
{ layer: 'coverage', z: 11, x: 2048, y: 0 },
{ layer: 'coverage', z: 11, x: 1.5, y: 0 },
{ layer: 'coverage', z: -1, x: 0, y: 0 },
])
assert.throws(
@@ -327,14 +327,14 @@ test('normalizeTileAddress refuses prototype keys as layer names', () => {
);
});
test('coverage zooms the app never requests (z6–10) are refused with a 400', async () => {
for (const z of [6, 7, 8, 9, 10])
test('coverage zooms the app never requests (below z11) are refused with a 400', async () => {
for (const z of [0, 3, 5, 6, 7, 8, 9, 10])
assert.throws(
() => normalizeTileAddress({ layer: 'coverage', z, x: 0, y: 0 }),
TileRequestError,
`z${z}`,
);
for (const z of [0, 5, 11, 14])
for (const z of [11, 12, 13, 14])
assert.equal(
normalizeTileAddress({ layer: 'coverage', z, x: 0, y: 0 }).z,
z,
@@ -345,7 +345,7 @@ test('coverage zooms the app never requests (z6–10) are refused with a 400', a
const res = await call('/api/mapillary/tiles', '/coverage/9/5/5');
assert.equal(res.statusCode, 400);
assert.deepEqual(json(res), {
error: 'Tile zoom for coverage must be 0–5 or 11–14',
error: 'Tile zoom for coverage must be 11–14',
});
assert.equal(calls.length, 0, 'Mapillary is not asked');
},
+6 -11
View File
@@ -4,7 +4,7 @@ import test from 'node:test';
import { createApplicationStreetLevel } from '../app/layers/streetLevel.js';
import {
fixtureTile,
EXPECTED_PROVIDERS,
LEGEND_SWATCHES,
isCollapsed,
PARK,
STRICT_ALLOWED_SKIPS,
@@ -25,13 +25,13 @@ import { redact } from '../../scripts/qa-browserEvidence.mjs';
globalThis.requestAnimationFrame ??= (callback) =>
setTimeout(() => callback(Date.now()), 0);
test('the harness expects exactly the providers the app registers, in order', () => {
// Any method a provider asks its source for is a no-op: only ids matter.
test('the harness expects the legend the app layer draws', () => {
// Any method the layer asks its source for is a no-op: only the legend matters.
const source = new Proxy({}, { get: () => () => {} });
const layer = createApplicationStreetLevel({
sources: { mapillary: source },
});
assert.deepEqual([...EXPECTED_PROVIDERS], [...layer.providerIds]);
assert.equal(layer.getUIState().legend.length, LEGEND_SWATCHES);
});
test('a panel reads as collapsed only with the collapsed class', () => {
@@ -68,13 +68,8 @@ test('fixture tiles give the hermetic gate something real to filter', async () =
const year = 365 * 86_400_000;
const old = grid.filter((s) => now - s.capturedAt > year).length;
assert.ok(old > 0 && old < 8, 'recent and older both present');
const orbit = decodeCoverageTile(fixtureTile(3, 1, 3, now), {
x: 1,
y: 3,
z: 3,
});
assert.ok(orbit.overview.length > 0, 'overview points from orbit');
assert.equal(fixtureTile(8, 1, 1, now).length, 0, 'nothing in between');
assert.equal(fixtureTile(3, 1, 3, now).length, 0, 'nothing from orbit');
assert.equal(fixtureTile(8, 1, 1, now).length, 0, 'nor in between');
});
/* ── The real status route probe ─────────────────────────────────────── */
+4 -9
View File
@@ -180,15 +180,10 @@ export class StreetLevelControls {
async _toggleEnabled() {
const enabled = this.actions.isEnabled?.() === true;
// The pill is the only switch: switching on also brings back a provider
// a share link or a tool turned off, or the layer would draw nothing.
const dark = enabled
? []
: (this._state?.providers || []).filter((entry) => entry.on === false);
if (dark.length)
this._setParams(
Object.fromEntries(dark.map((entry) => [entry.id, true])),
);
// The pill is the only switch: switching on also brings back Mapillary
// if a share link or a tool turned it off, or the layer would draw nothing.
if (!enabled && this._state?.providerOn === false)
this._setParams({ mapillary: true });
try {
await this.actions.setEnabled?.(!enabled);
} catch (error) {
+15 -37
View File
@@ -9,7 +9,7 @@ import {
REGISTERED_LAYER_IDS,
encodeLayerStateParams,
} from '../data/layerState.js';
import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
import { fakeMapillarySource } from '../testSupport/streetLevelFakes.mjs';
/* ── A small DOM: just what the panel controls touch ───────────────────── */
@@ -215,12 +215,7 @@ async function withDom(fn, options) {
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/* ── A Street Level layer with one stand-in provider ───────────────────── */
function standInProvider() {
const def = fakeStreetLevelProvider({ html: 'Mapillary' });
return { filters: def.filters, def };
}
/* ── A Street Level layer over a stand-in Mapillary source ─────────────── */
/** Every other registered layer, as a module with no options. */
function plainLayer(id) {
@@ -254,8 +249,7 @@ const memoryStorage = () => {
/** The production path: controls → dataManager → layer → durable layer state. */
async function productionPanel(dom) {
const provider = standInProvider();
const layer = createStreetLevelLayer({ providers: [provider.def] });
const layer = createStreetLevelLayer({ source: fakeMapillarySource() });
const manager = new DataLayerManager({});
for (const id of REGISTERED_LAYER_IDS)
manager.register(id === 'street-level' ? layer : plainLayer(id));
@@ -288,7 +282,7 @@ async function productionPanel(dom) {
encodeLayerStateParams(params, coordinator.getDurableState());
return (params.get('lo') || '').split('_');
};
return { provider, layer, manager, coordinator, controls, share };
return { layer, manager, coordinator, controls, share };
}
const TOKEN = LAYER_STATE_REGISTRY.find(
@@ -297,11 +291,14 @@ const TOKEN = LAYER_STATE_REGISTRY.find(
test('a 360° click reaches the share link through the data manager', () =>
withDom(async (dom) => {
const { provider, coordinator, controls, share } =
await productionPanel(dom);
const { layer, coordinator, controls, share } = await productionPanel(dom);
dom.root.querySelectorAll('[data-sl-pano]')[1].click(); // 360°
await settle();
assert.equal(provider.filters.at(-1).pano, 'pano', 'the layer applied it');
assert.equal(
layer.getUIState().filter.pano,
'pano',
'the layer applied it',
);
assert.equal(
coordinator.getDurableState().options['street-level'].pano,
'pano',
@@ -362,35 +359,20 @@ function uiState({
} = {}) {
return {
enabled,
providerOn: on,
keyRequired,
keyRejected: false,
filter: { pano, sinceDays: 0 },
providers: [
{
id: 'mapillary',
name: 'Mapillary',
label: 'MAPILLARY',
color: '#05cb63',
on,
configured: !keyRequired,
keyRequired,
requiresKeyId: 'mapillary',
loading: false,
count: 3,
hint: '',
error: null,
},
],
coverage: { count: 3, loading: false, hint: '', error: null },
legend: legend || [
{ key: 'mapillary', label: 'Mapillary', color: '#05cb63' },
{ key: 'selected', label: 'Selected', color: '#00d4ff' },
],
sequence: { providerId: null, selectedId: null, images: 0, loading: false },
sequence: { selectedId: null, images: 0, loading: false },
street: {
open,
loading: false,
renderMode,
providerId: open ? 'mapillary' : null,
imageId: open ? 'img-1' : null,
error: null,
},
@@ -422,18 +404,14 @@ function stubPanel(dom, state, extraActions = {}) {
return { layer, calls, controls };
}
test('the header pill switches the layer, bringing back a provider switched off elsewhere', () =>
test('the header pill switches the layer, bringing back Mapillary switched off elsewhere', () =>
withDom(async (dom) => {
const status = dom.root.querySelector('#sl-status');
const on = stubPanel(dom, uiState());
status.click();
await settle();
assert.deepEqual(on.calls.setEnabled, [false]);
assert.deepEqual(
on.calls.setParams,
[],
'switching off keeps the provider',
);
assert.deepEqual(on.calls.setParams, [], 'switching off keeps the switch');
on.controls.destroy();
const dark = stubPanel(dom, uiState({ enabled: false, on: false }));
status.click();
+6 -9
View File
@@ -67,13 +67,10 @@ function presentStatus(state) {
: { text: 'OFF', tone: '', pressed, title };
}
/** How to add the missing key, for the provider that needs one. */
function keyHint(state) {
const provider = (state.providers || []).find(
(entry) => entry.keyRequired === true && entry.requiresKeyId,
);
const requirement = provider && keySetupRequirement(provider.requiresKeyId);
return requirement ? `${provider.name}: ${requirement}` : null;
/** How to add the missing Mapillary key. */
function keyHint() {
const requirement = keySetupRequirement('mapillary');
return requirement ? `Mapillary: ${requirement}` : null;
}
function presentViewer(state) {
@@ -90,7 +87,7 @@ function presentViewer(state) {
captionLeft: street.creator ? `Image by ${street.creator}` : '',
captionRight: right.join(' · '),
link: street.externalUrl || null,
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
linkLabel: street.externalUrl ? 'MAPILLARY ↗' : '',
};
}
@@ -115,7 +112,7 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
status: presentStatus(state),
controlsDisabled: state.keyRequired === true,
error: keyMissing
? keyHint(state)
? keyHint()
: state.street.error || state.coverage.error || null,
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
since: presentSince(filter.sinceDays, now),
+5 -16
View File
@@ -5,29 +5,23 @@ import {
SINCE_STOPS,
sinceStopIndex,
} from './streetLevelPresentation.js';
import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
const provider = providerSnapshot;
function snapshot(overrides = {}) {
const base = {
enabled: false,
providerOn: true,
keyRequired: false,
keyRejected: false,
filter: { pano: 'all', sinceDays: 0 },
providers: [provider()],
coverage: { loading: false, count: 0, hint: '', error: null },
legend: [
{ key: 'mapillary', label: 'Mapillary', color: '#05cb63' },
{ key: 'selected', label: 'Selected', color: '#00d4ff' },
],
sequence: { providerId: null, selectedId: null, images: 0, loading: false },
sequence: { selectedId: null, images: 0, loading: false },
street: {
open: false,
loading: false,
error: null,
providerId: null,
providerName: null,
providerLabel: null,
imageId: null,
position: null,
bearing: null,
@@ -70,7 +64,6 @@ test('KEY REQUIRED says how to add the key instead of a raw error code', () => {
enabled: true,
keyRequired: true,
coverage: { error: 'no_key' },
providers: [provider({ keyRequired: true, error: 'no_key' })],
}),
);
assert.equal(view.status.text, 'KEY REQUIRED');
@@ -86,7 +79,6 @@ test('a key Mapillary rejected reads KEY REJECTED and names the fix', () => {
keyRequired: true,
keyRejected: true,
coverage: { error },
providers: [provider({ keyRequired: true, keyRejected: true, error })],
}),
);
assert.equal(view.status.text, 'KEY REJECTED');
@@ -96,7 +88,7 @@ test('a key Mapillary rejected reads KEY REJECTED and names the fix', () => {
});
test('a key problem outranks loading on the pill: KEY REJECTED, then KEY REQUIRED (M65)', () => {
// A keyless provider can still report loading (its status check, a retry):
// A keyless layer can still report loading (its status check, a retry):
// the pill must say what is wrong, not that something is on its way.
const loading = { enabled: true, coverage: { loading: true } };
assert.equal(
@@ -221,15 +213,12 @@ test('the meta line never mixes the visible-sequence count with the selected seq
assert.equal(hinted.meta, 'Point the camera at the globe');
});
test('viewer caption reads "Image by" left, date right, and links to the provider', () => {
test('viewer caption reads "Image by" left, date right, and links to Mapillary', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
street: {
open: true,
providerId: 'mapillary',
providerName: 'Mapillary',
providerLabel: 'MAPILLARY',
imageId: '1814275685699406',
creator: 'mapfool',
capturedAt: Date.UTC(2023, 9, 8),