mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
0f41fb04cd
commit
37a96072fa
51 files changed
+1240
-2659
No files matched your search
+8
-8
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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']),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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]],
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
@@ -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 ─────────────────────────────────────── */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
Reference in new issue
Block a user