mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +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]
|
## [Unreleased]
|
||||||
|
|
||||||
- Street Level: a street-level imagery layer modelled on the iD editor's photo
|
- Street Level: Mapillary street-level imagery modelled on the iD editor's
|
||||||
overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0
|
photo overlay (free client token, CC BY-SA 4.0 imagery). One right-rail panel
|
||||||
imagery). One right-rail panel holds an ON/OFF pill, shared 360°/flat and
|
holds an ON/OFF pill, 360°/flat and captured-since filters, a legend, and an
|
||||||
captured-since filters, a legend, and an embedded viewer with full-screen
|
embedded viewer with full-screen EXPAND and FIT/FILL. Coverage lines (below
|
||||||
EXPAND and FIT/FILL. Coverage lines and image cones are drawn from orbit
|
60 km; from orbit the panel says to zoom in) and image cones are draped on
|
||||||
down to a single street, draped on the terrain and on Google 3D tiles;
|
the terrain and on Google 3D tiles; MapillaryJS loads on the first photo,
|
||||||
opening a photo frames it with one camera flight.
|
and opening one frames it with one camera flight.
|
||||||
Share links carry the provider switches, the filters and the panel's collapsed
|
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),
|
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
|
refuses cross-site requests, rate-limits per IP and backs off when Mapillary
|
||||||
rejects the token or rate-limits.
|
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
|
||||||
|
|
||||||
> 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**.
|
**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
|
## 🎖️ Field Missions
|
||||||
|
|
||||||
|
|||||||
+24
-26
@@ -2928,7 +2928,7 @@ its criteria cannot be silently ignored.
|
|||||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
||||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.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) |
|
| 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) |
|
| 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) |
|
| 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
|
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
|
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.
|
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
|
Like the iD editor's photo overlay, it shows Mapillary with 360°/flat and
|
||||||
(share options, no panel chips),
|
captured-since filters (relative days, so a link keeps its meaning), one
|
||||||
shared 360°/flat and captured-since filters (relative days, so a link keeps its
|
viewer host and an on-globe credit while the layer is on. Lines and cones are
|
||||||
meaning), one viewer host and one on-globe credit per active provider. Each
|
Mapillary green; 360° cones are rings and the selected sequence is GEV cyan.
|
||||||
provider draws in one colour (`PROVIDER_COLORS` in
|
Share options: `m` (Mapillary on/off; off draws nothing), `p` (`a`/`p`/`f`
|
||||||
`src/layers/streetLevel/policy.js`); 360° cones are rings and the selected
|
panoramas), `s` (since, days). Without `MAPILLARY_CLIENT_TOKEN` the panel
|
||||||
sequence is GEV cyan. Share options: `m` (Mapillary), `p` (`a`/`p`/`f`
|
reads KEY REQUIRED, its error line says which key to add, and the filters are
|
||||||
panoramas), `s` (since, days). Only Mapillary is registered. Without
|
disabled. Coverage is z11–14 sequence lines below 60 km; above that nothing is
|
||||||
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED, its error line says
|
drawn and the panel says to zoom in. Tiles come from the visible ground within
|
||||||
which key to add, and the filters are disabled. With it, coverage draws as z0–5 overview points from orbit and z11–14
|
range of the camera (rays meet the terrain height, not the bare ellipsoid),
|
||||||
sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
nearest the camera first, at most 9. The proxy serves only z11–14, strips the
|
||||||
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
|
unused `image` layer from z14 tiles (12 MB → ~80 KB) and shares one upstream
|
||||||
requests for a tile.
|
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
|
Sequence lines are `GroundPolylinePrimitive`s with
|
||||||
`ClassificationType.BOTH`, so they drape on the terrain globe and on Google 3D
|
`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
|
a horizon cull hides the ones behind the globe. Roads on elevated decks are
|
||||||
drawn at ground level.
|
drawn at ground level.
|
||||||
|
|
||||||
The header pill is the only layer switch; switching on also turns back on a
|
The header pill is the only layer switch; switching on also turns Mapillary
|
||||||
provider a share link or tool switched off. The viewer sits under the header
|
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
|
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
|
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
|
leaves; the button is hidden where the API is missing). MapillaryJS loads on the
|
||||||
the globe camera once to frame it; there is no camera follow. The flight goes
|
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` /
|
through the application's deferred navigation (`attachNavigation`: `begin` /
|
||||||
`reassert`), which releases aircraft and satellite tracking and is refused in
|
`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
|
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
|
cut-off date. At phone width (≤720 px) the viewer height is derived from the
|
||||||
rail band so the whole photo fits.
|
rail band so the whole photo fits.
|
||||||
|
|
||||||
Providers implement the contract in `src/layers/streetLevel/registry.js`. The
|
The layer (`src/layers/streetLevel/index.js`) owns the Mapillary parts
|
||||||
core routes clicks by pick prefix, swaps viewer adapters in the one host,
|
directly: `providers/mapillary/` holds the source, tile decoding, coverage,
|
||||||
manages each provider's credit and fans the filter out to every provider. To
|
sequence cones, nearest-image lookup and the MapillaryJS viewer.
|
||||||
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.
|
|
||||||
|
|
||||||
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
|
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
|
||||||
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
|
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
|
||||||
|
|||||||
@@ -260,7 +260,6 @@
|
|||||||
"node": "./server/providers/mapillary.js"
|
"node": "./server/providers/mapillary.js"
|
||||||
},
|
},
|
||||||
"./layers/street-level": "./src/layers/streetLevel/index.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",
|
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js",
|
||||||
"./tools": "./src/tools/index.js",
|
"./tools": "./src/tools/index.js",
|
||||||
"./tools/mcp": "./src/tools/mcp/index.js",
|
"./tools/mcp": "./src/tools/mcp/index.js",
|
||||||
|
|||||||
@@ -867,16 +867,11 @@
|
|||||||
"src/layers/streetLevel/index.js",
|
"src/layers/streetLevel/index.js",
|
||||||
"src/layers/streetLevel/policy.js",
|
"src/layers/streetLevel/policy.js",
|
||||||
"src/layers/streetLevel/filter.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/view.js",
|
||||||
"src/layers/streetLevel/marker.js",
|
"src/layers/streetLevel/marker.js",
|
||||||
"src/layers/streetLevel/cameraFraming.js",
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
"src/layers/streetLevel/credits.js",
|
"src/layers/streetLevel/credits.js",
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
|
||||||
"src/layers/streetLevel/state.js",
|
"src/layers/streetLevel/state.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/policy.js",
|
"src/layers/streetLevel/providers/mapillary/policy.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
"src/layers/streetLevel/providers/mapillary/coverage.js",
|
||||||
@@ -887,6 +882,7 @@
|
|||||||
"src/layers/streetLevel/selection.js",
|
"src/layers/streetLevel/selection.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/nearest.js",
|
||||||
"server/providers/mapillary.js",
|
"server/providers/mapillary.js",
|
||||||
"server/providers/mapillary/routes.js",
|
"server/providers/mapillary/routes.js",
|
||||||
"server/providers/mapillary/constants.js",
|
"server/providers/mapillary/constants.js",
|
||||||
@@ -898,12 +894,8 @@
|
|||||||
"scripts/qa-browserEvidence.mjs",
|
"scripts/qa-browserEvidence.mjs",
|
||||||
"scripts/fixtures/street-level/mapillaryGraph.mjs",
|
"scripts/fixtures/street-level/mapillaryGraph.mjs",
|
||||||
"src/layers/streetLevel/filter.test.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/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/decode.test.mjs",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverage.test.mjs",
|
"src/layers/streetLevel/providers/mapillary/coverage.test.mjs",
|
||||||
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||||
|
|||||||
@@ -1178,21 +1178,15 @@
|
|||||||
"street-level-layer": {
|
"street-level-layer": {
|
||||||
"exports": [
|
"exports": [
|
||||||
"./layers/street-level",
|
"./layers/street-level",
|
||||||
"./layers/street-level/mapillary",
|
|
||||||
"./layers/street-level/mapillary/source"
|
"./layers/street-level/mapillary/source"
|
||||||
],
|
],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/streetLevel/index.js",
|
"src/layers/streetLevel/index.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
"src/layers/streetLevel/credits.js",
|
"src/layers/streetLevel/credits.js",
|
||||||
"src/layers/streetLevel/cameraFraming.js",
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
"src/layers/streetLevel/marker.js",
|
"src/layers/streetLevel/marker.js",
|
||||||
"src/layers/streetLevel/view.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/filter.js",
|
||||||
"src/layers/streetLevel/policy.js",
|
"src/layers/streetLevel/policy.js",
|
||||||
"src/layers/streetLevel/state.js",
|
"src/layers/streetLevel/state.js",
|
||||||
@@ -1206,6 +1200,7 @@
|
|||||||
"src/layers/streetLevel/selection.js",
|
"src/layers/streetLevel/selection.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/nearest.js",
|
||||||
"scripts/qa-street-level.mjs"
|
"scripts/qa-street-level.mjs"
|
||||||
],
|
],
|
||||||
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
|
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
|
||||||
@@ -2100,16 +2095,11 @@
|
|||||||
"src/ui/streetLevelControls.js",
|
"src/ui/streetLevelControls.js",
|
||||||
"src/ui/streetLevelPresentation.js",
|
"src/ui/streetLevelPresentation.js",
|
||||||
"src/layers/streetLevel/index.js",
|
"src/layers/streetLevel/index.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
"src/layers/streetLevel/credits.js",
|
"src/layers/streetLevel/credits.js",
|
||||||
"src/layers/streetLevel/cameraFraming.js",
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
"src/layers/streetLevel/marker.js",
|
"src/layers/streetLevel/marker.js",
|
||||||
"src/layers/streetLevel/view.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/filter.js",
|
||||||
"src/layers/streetLevel/policy.js",
|
"src/layers/streetLevel/policy.js",
|
||||||
"src/layers/streetLevel/state.js",
|
"src/layers/streetLevel/state.js",
|
||||||
@@ -2123,6 +2113,7 @@
|
|||||||
"src/layers/streetLevel/selection.js",
|
"src/layers/streetLevel/selection.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/nearest.js",
|
||||||
"src/app/layers/streetLevel.js",
|
"src/app/layers/streetLevel.js",
|
||||||
"src/layers/alpr/tileRecords.js",
|
"src/layers/alpr/tileRecords.js",
|
||||||
"src/sources/capability.js",
|
"src/sources/capability.js",
|
||||||
@@ -2619,16 +2610,11 @@
|
|||||||
"src/layers/recentImagery/thumbnails.js",
|
"src/layers/recentImagery/thumbnails.js",
|
||||||
"src/maps/imageryHost.js",
|
"src/maps/imageryHost.js",
|
||||||
"src/layers/streetLevel/index.js",
|
"src/layers/streetLevel/index.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
|
||||||
"src/layers/streetLevel/viewerHost.js",
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
"src/layers/streetLevel/credits.js",
|
"src/layers/streetLevel/credits.js",
|
||||||
"src/layers/streetLevel/cameraFraming.js",
|
"src/layers/streetLevel/cameraFraming.js",
|
||||||
"src/layers/streetLevel/marker.js",
|
"src/layers/streetLevel/marker.js",
|
||||||
"src/layers/streetLevel/view.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/filter.js",
|
||||||
"src/layers/streetLevel/policy.js",
|
"src/layers/streetLevel/policy.js",
|
||||||
"src/layers/streetLevel/state.js",
|
"src/layers/streetLevel/state.js",
|
||||||
@@ -2642,6 +2628,7 @@
|
|||||||
"src/layers/streetLevel/selection.js",
|
"src/layers/streetLevel/selection.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||||
"src/layers/streetLevel/providers/mapillary/source.js",
|
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/nearest.js",
|
||||||
"src/app/layers/streetLevel.js",
|
"src/app/layers/streetLevel.js",
|
||||||
"src/layers/alpr/tileRecords.js",
|
"src/layers/alpr/tileRecords.js",
|
||||||
"src/sources/capability.js",
|
"src/sources/capability.js",
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export const VIEWPORTS = Object.freeze([
|
|||||||
{ width: 1280, height: 800 },
|
{ width: 1280, height: 800 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** Providers the layer registers: one legend swatch each, plus "Selected". */
|
/** Legend swatches: Mapillary, then "Selected". */
|
||||||
export const EXPECTED_PROVIDERS = Object.freeze(['mapillary']);
|
export const LEGEND_SWATCHES = 2;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Skips no setup can avoid: live runs cannot stage fixture-only cases.
|
* 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;
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deterministic coverage for any tile: a grid of mixed 360°/flat, new/old
|
* Deterministic coverage for any street-zoom tile: a grid of mixed 360°/flat,
|
||||||
* sequences plus the photo sequence (PHOTO_LINE), or a few overview points.
|
* new/old sequences plus the photo sequence (PHOTO_LINE).
|
||||||
* @returns {Uint8Array} empty for zooms that carry nothing
|
* @returns {Uint8Array} empty below z11, which the layer never asks for
|
||||||
*/
|
*/
|
||||||
export function fixtureTile(z, x, y, now = Date.now()) {
|
export function fixtureTile(z, x, y, now = Date.now()) {
|
||||||
const tile = { x, y, z };
|
const tile = { x, y, z };
|
||||||
const { west, east, south, north } = tileBounds(x, y, z);
|
const { west, east, south, north } = tileBounds(x, y, z);
|
||||||
const at = (t, lo, hi) => lo + (hi - lo) * t;
|
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);
|
if (z < 11) return new Uint8Array(0);
|
||||||
const margin = (east - west) * 0.05;
|
const margin = (east - west) * 0.05;
|
||||||
const sequences = [];
|
const sequences = [];
|
||||||
@@ -654,12 +644,12 @@ async function main() {
|
|||||||
const info = await panel();
|
const info = await panel();
|
||||||
assert.ok(!isCollapsed(info.classes));
|
assert.ok(!isCollapsed(info.classes));
|
||||||
assert.equal(info.bodyDisplay, 'flex');
|
assert.equal(info.bodyDisplay, 'flex');
|
||||||
// One swatch per source plus "Selected".
|
// Mapillary plus "Selected".
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
() => document.querySelectorAll('#sl-legend li').length,
|
() => document.querySelectorAll('#sl-legend li').length,
|
||||||
),
|
),
|
||||||
EXPECTED_PROVIDERS.length + 1,
|
LEGEND_SWATCHES,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -1016,7 +1006,7 @@ async function main() {
|
|||||||
await page.evaluate((imageId) => {
|
await page.evaluate((imageId) => {
|
||||||
void window.__godsEyeView.dataManager.layers
|
void window.__godsEyeView.dataManager.layers
|
||||||
.get('street-level')
|
.get('street-level')
|
||||||
.module.openImage('mapillary', imageId);
|
.module.openImage(imageId);
|
||||||
}, id);
|
}, id);
|
||||||
await waitForImage(id);
|
await waitForImage(id);
|
||||||
const street = (await ui()).street;
|
const street = (await ui()).street;
|
||||||
@@ -1149,7 +1139,6 @@ async function main() {
|
|||||||
assert.equal(view.hidden, false);
|
assert.equal(view.hidden, false);
|
||||||
assert.ok(view.width > 200);
|
assert.ok(view.width > 200);
|
||||||
assert.ok(view.when.length > 0, 'caption shows the capture date');
|
assert.ok(view.when.length > 0, 'caption shows the capture date');
|
||||||
assert.equal(street.providerId, 'mapillary');
|
|
||||||
firstImageId = street.imageId;
|
firstImageId = street.imageId;
|
||||||
assert.equal(view.link, 'MAPILLARY ↗');
|
assert.equal(view.link, 'MAPILLARY ↗');
|
||||||
if (fixtures) {
|
if (fixtures) {
|
||||||
@@ -1246,7 +1235,7 @@ async function main() {
|
|||||||
window.__godsEyeView.dataManager.layers.get(
|
window.__godsEyeView.dataManager.layers.get(
|
||||||
'street-level',
|
'street-level',
|
||||||
).module;
|
).module;
|
||||||
const opened = await module.openImage('mapillary', id);
|
const opened = await module.openImage(id);
|
||||||
const flying = Boolean(v.camera._currentFlight);
|
const flying = Boolean(v.camera._currentFlight);
|
||||||
const from = v.camera.positionCartographic.clone();
|
const from = v.camera.positionCartographic.clone();
|
||||||
module.closeViewer();
|
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. */
|
/** Public tile layer names this proxy exposes, mapped to Mapillary's ids. */
|
||||||
export const TILE_LAYERS = Object.freeze({
|
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
|
// z14 `image` layer is ~98% of a tile and unused (positions come from the
|
||||||
// graph API), so it is dropped in transit.
|
// graph API), so it is dropped in transit.
|
||||||
coverage: Object.freeze({
|
coverage: Object.freeze({
|
||||||
upstream: 'mly1_public',
|
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']),
|
dropLayers: Object.freeze(['image']),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
|
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 sprites from '../../data/spriteOrder.js';
|
||||||
import * as picking from '../../data/pickRegistry.js';
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
import * as input from '../../data/inputOwnership.js';
|
import * as input from '../../data/inputOwnership.js';
|
||||||
import * as render from '../../renderGovernor.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 }) {
|
export function createApplicationStreetLevel({ sources }) {
|
||||||
return createStreetLevelLayer({
|
return createStreetLevelLayer({
|
||||||
providers: [createMapillaryProvider({ source: sources.mapillary })],
|
source: sources.mapillary,
|
||||||
services: { sprites, picking, input, render },
|
services: { sprites, picking, input, render },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,38 +1,32 @@
|
|||||||
import * as Cesium from 'cesium';
|
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.
|
* visible attribution that goes away with the imagery.
|
||||||
*/
|
*/
|
||||||
export function createCredits() {
|
export function createCredit(html) {
|
||||||
/** @type {Map<string, object>} provider id → Cesium.Credit */
|
let shown = null;
|
||||||
const shown = new Map();
|
|
||||||
|
|
||||||
function show(viewer, def) {
|
function show(viewer) {
|
||||||
if (shown.has(def.id) || !viewer?.creditDisplay) return;
|
if (shown || !viewer?.creditDisplay) return;
|
||||||
try {
|
try {
|
||||||
const credit = new Cesium.Credit(def.credit.html, true);
|
const credit = new Cesium.Credit(html, true);
|
||||||
viewer.creditDisplay.addStaticCredit(credit);
|
viewer.creditDisplay.addStaticCredit(credit);
|
||||||
shown.set(def.id, credit);
|
shown = credit;
|
||||||
} catch {
|
} catch {
|
||||||
/* credit display unavailable */
|
/* credit display unavailable */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hide(viewer, def) {
|
function hide(viewer) {
|
||||||
const credit = shown.get(def.id);
|
if (!shown) return;
|
||||||
if (!credit) return;
|
|
||||||
try {
|
try {
|
||||||
viewer?.creditDisplay?.removeStaticCredit?.(credit);
|
viewer?.creditDisplay?.removeStaticCredit?.(shown);
|
||||||
} catch {
|
} catch {
|
||||||
/* credit display already torn down */
|
/* credit display already torn down */
|
||||||
}
|
}
|
||||||
shown.delete(def.id);
|
shown = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideAll(viewer) {
|
return { show, hide };
|
||||||
for (const id of [...shown.keys()]) hide(viewer, { id });
|
|
||||||
}
|
|
||||||
|
|
||||||
return { show, hide, hideAll };
|
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ export function sameFilter(a, b) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Stored filter (relative days, stable in share links) to the absolute form
|
* 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}}
|
* @returns {{pano: string, sinceMs: number|null}}
|
||||||
*/
|
*/
|
||||||
export function resolveFilter(filter, now = Date.now()) {
|
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 { createMarker } from './marker.js';
|
||||||
import { createCameraFraming } from './cameraFraming.js';
|
import { createCameraFraming } from './cameraFraming.js';
|
||||||
import { createCredits } from './credits.js';
|
import { createCredit } from './credits.js';
|
||||||
import { createPickRouter } from './pickRouter.js';
|
|
||||||
import { createSelection } from './selection.js';
|
import { createSelection } from './selection.js';
|
||||||
import { createViewerHost } from './viewerHost.js';
|
import { createViewerHost } from './viewerHost.js';
|
||||||
import { requiresKeyIdFor, validateProviders } from './registry.js';
|
|
||||||
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
|
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
|
||||||
import { decodeParams, encodeParams } from './params.js';
|
import { viewCentre } from './view.js';
|
||||||
import { composeUIState, summarizeCoverage } from './uiState.js';
|
import { COLORS, STREET_LEVEL_LAYER_ID } from './policy.js';
|
||||||
import { viewCentre, whenIdle } from './view.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 {
|
import {
|
||||||
|
COLORS as MAPILLARY_COLORS,
|
||||||
|
MAPILLARY_CREDIT_HTML,
|
||||||
|
MAPILLARY_KEY_ID,
|
||||||
|
MAPILLARY_SOURCE_METHODS,
|
||||||
NEAREST_RADIUS_M,
|
NEAREST_RADIUS_M,
|
||||||
POSITION_PICK_ID,
|
} from './providers/mapillary/policy.js';
|
||||||
STREET_LEVEL_LAYER_ID,
|
|
||||||
} from './policy.js';
|
|
||||||
|
|
||||||
export { STREET_LEVEL_LAYER_ID } from './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 Street Level layer: Mapillary coverage lines at street zoom, a selected
|
||||||
* The core owns what they share; providers own coverage, sequences, viewer.
|
* sequence's image cones, and the photo viewer in the panel.
|
||||||
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
|
* @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({
|
export function createStreetLevelLayer({
|
||||||
providers: definitions,
|
source,
|
||||||
services = {},
|
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 state = createState({ services });
|
||||||
const parts = {};
|
const parts = {};
|
||||||
const context = { state, parts };
|
const context = { state, parts };
|
||||||
parts.credits = createCredits();
|
const credit = createCredit(MAPILLARY_CREDIT_HTML);
|
||||||
parts.marker = createMarker(context);
|
parts.marker = createMarker(context);
|
||||||
parts.framing = createCameraFraming(context);
|
parts.framing = createCameraFraming(context);
|
||||||
parts.router = createPickRouter(() => state.providers.values(), {
|
parts.coverage = createCoverage({ state, source });
|
||||||
positionId: POSITION_PICK_ID,
|
parts.sequences = createSequences({ state, source, parts });
|
||||||
|
parts.viewerHost = createViewerHost({
|
||||||
|
state,
|
||||||
|
parts,
|
||||||
|
adapter:
|
||||||
|
photoViewer || createMapillaryViewer({ source, render: services.render }),
|
||||||
});
|
});
|
||||||
parts.viewerHost = createViewerHost(context);
|
parts.openImage = openImage;
|
||||||
parts.hasSelectedSequence = () =>
|
parts.reportSequenceError = reportSequenceError;
|
||||||
[...state.providers.values()].some(
|
|
||||||
(entry) => entry.instance.sequenceStats?.()?.selectedId,
|
|
||||||
);
|
|
||||||
parts.clearSequences = () => {
|
|
||||||
for (const entry of state.providers.values())
|
|
||||||
entry.instance.clearSequence?.();
|
|
||||||
};
|
|
||||||
parts.selection = createSelection(context);
|
parts.selection = createSelection(context);
|
||||||
|
|
||||||
let notifyQueued = false;
|
let notifyQueued = false;
|
||||||
@@ -66,70 +85,64 @@ export function createStreetLevelLayer({
|
|||||||
}
|
}
|
||||||
state.notify = notify;
|
state.notify = notify;
|
||||||
|
|
||||||
function providerContext(entry) {
|
/** The error a sequence lookup last showed, until it withdraws it. */
|
||||||
return Object.freeze({
|
let sequenceError = null;
|
||||||
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();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const def of definitionsFrozen) {
|
/**
|
||||||
const entry = {
|
* Show a sequence error, or withdraw it (null). A withdrawal clears only
|
||||||
def,
|
* that error, never a newer one from the viewer.
|
||||||
instance: null,
|
*/
|
||||||
on: true,
|
function reportSequenceError(message) {
|
||||||
status: null,
|
if (message) {
|
||||||
/** The error this provider last reported, until it withdraws it. */
|
state.street.error = sequenceError = message;
|
||||||
reportedError: null,
|
} else {
|
||||||
};
|
if (!sequenceError) return;
|
||||||
entry.instance = def.create(providerContext(entry));
|
if (state.street.error === sequenceError) state.street.error = null;
|
||||||
state.providers.set(def.id, entry);
|
sequenceError = null;
|
||||||
}
|
|
||||||
|
|
||||||
const activeEntries = () =>
|
|
||||||
[...state.providers.values()].filter((entry) => entry.on);
|
|
||||||
|
|
||||||
async function refreshStatus(entry) {
|
|
||||||
try {
|
|
||||||
entry.status = await entry.instance.status();
|
|
||||||
} catch {
|
|
||||||
entry.status = null;
|
|
||||||
}
|
}
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
function activate(entry) {
|
/** Coverage waits for the key status: no tile requests without a key. */
|
||||||
if (!state.viewer) return;
|
async function checkKey() {
|
||||||
entry.instance.activate(state.viewer);
|
let configured;
|
||||||
parts.credits.show(state.viewer, entry.def);
|
try {
|
||||||
if (!entry.status) refreshStatus(entry);
|
configured = (await source.getStatus())?.configured === true;
|
||||||
whenIdle(() => parts.viewerHost.prewarm([entry]), 1500);
|
} catch {
|
||||||
|
configured = source.hasToken?.() === true;
|
||||||
|
}
|
||||||
|
parts.coverage.setKeyStatus(configured);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deactivate(entry) {
|
function startDrawing() {
|
||||||
parts.viewerHost.unmount(entry.def.id);
|
const viewer = state.viewer;
|
||||||
entry.instance.deactivate();
|
if (!viewer) return;
|
||||||
parts.credits.hide(state.viewer, entry.def);
|
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. */
|
/** The nearest-image lookup in flight; any newer user action aborts it. */
|
||||||
@@ -140,97 +153,62 @@ export function createStreetLevelLayer({
|
|||||||
nearestLookup = null;
|
nearestLookup = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openImage(providerId, imageId) {
|
function openImage(imageId) {
|
||||||
abortNearest();
|
abortNearest();
|
||||||
return parts.viewerHost.open(providerId, imageId);
|
return parts.viewerHost.open(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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCoverageFilter(next) {
|
function setCoverageFilter(next) {
|
||||||
const filter = normalizeFilter(next, state.filter);
|
const filter = normalizeFilter(next, state.filter);
|
||||||
if (sameFilter(filter, state.filter)) return;
|
if (sameFilter(filter, state.filter)) return;
|
||||||
state.filter = filter;
|
state.filter = filter;
|
||||||
const resolved = resolveFilter(filter);
|
parts.coverage.rebuild();
|
||||||
for (const entry of state.providers.values())
|
parts.sequences.rerender();
|
||||||
entry.instance.setFilter(resolved);
|
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
function providerSnapshots() {
|
function coverageStats() {
|
||||||
return [...state.providers.values()].map((entry) => {
|
return state.providerOn ? parts.coverage.stats() : COVERAGE_OFF;
|
||||||
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 getUIState() {
|
function getUIState() {
|
||||||
const { host, ...street } = state.street;
|
const { host, ...street } = state.street;
|
||||||
return composeUIState({
|
const { keyRequired, keyRejected, ...coverage } = coverageStats();
|
||||||
|
return {
|
||||||
enabled: state.enabled,
|
enabled: state.enabled,
|
||||||
filter: state.filter,
|
providerOn: state.providerOn,
|
||||||
providers: providerSnapshots(),
|
// 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,
|
street,
|
||||||
sequence: sequenceSnapshot(),
|
};
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const layer = {
|
const layer = {
|
||||||
id: STREET_LEVEL_LAYER_ID,
|
id: STREET_LEVEL_LAYER_ID,
|
||||||
name: 'Street Level',
|
name: 'Street Level',
|
||||||
icon: '📷',
|
icon: '📷',
|
||||||
source: definitionsFrozen.map((def) => def.name).join(' · '),
|
source: 'Mapillary',
|
||||||
updateInterval: 0,
|
updateInterval: 0,
|
||||||
statsRefreshInterval: 1000,
|
statsRefreshInterval: 1000,
|
||||||
requiresKeyId: requiresKeyIdFor(definitionsFrozen),
|
requiresKeyId: MAPILLARY_KEY_ID,
|
||||||
/** Registered providers, in chip order. */
|
|
||||||
providerIds: definitionsFrozen.map((def) => def.id),
|
|
||||||
|
|
||||||
init(viewer) {
|
init(viewer) {
|
||||||
if (state.initialized)
|
if (state.initialized)
|
||||||
@@ -239,10 +217,9 @@ export function createStreetLevelLayer({
|
|||||||
state.initialized = true;
|
state.initialized = true;
|
||||||
parts.marker.ensure(viewer);
|
parts.marker.ensure(viewer);
|
||||||
parts.marker.setVisible(false);
|
parts.marker.setVisible(false);
|
||||||
for (const entry of state.providers.values()) {
|
parts.sequences.ensureCollections(viewer);
|
||||||
entry.instance.init(viewer);
|
parts.sequences.setVisible(false);
|
||||||
refreshStatus(entry);
|
checkKey();
|
||||||
}
|
|
||||||
console.log('[Data:StreetLevel] Initialized');
|
console.log('[Data:StreetLevel] Initialized');
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -251,16 +228,13 @@ export function createStreetLevelLayer({
|
|||||||
state.viewer = viewer;
|
state.viewer = viewer;
|
||||||
parts.marker.setVisible(true);
|
parts.marker.setVisible(true);
|
||||||
parts.selection.install(viewer);
|
parts.selection.install(viewer);
|
||||||
for (const entry of activeEntries()) activate(entry);
|
if (state.providerOn) startDrawing();
|
||||||
notify();
|
notify();
|
||||||
},
|
},
|
||||||
|
|
||||||
disable() {
|
disable() {
|
||||||
state.enabled = false;
|
state.enabled = false;
|
||||||
abortNearest();
|
stopDrawing();
|
||||||
parts.viewerHost.unmount();
|
|
||||||
for (const entry of state.providers.values()) entry.instance.deactivate();
|
|
||||||
parts.credits.hideAll(state.viewer);
|
|
||||||
parts.selection.uninstall();
|
parts.selection.uninstall();
|
||||||
parts.marker.setVisible(false);
|
parts.marker.setVisible(false);
|
||||||
notify();
|
notify();
|
||||||
@@ -272,8 +246,7 @@ export function createStreetLevelLayer({
|
|||||||
|
|
||||||
destroy(viewer = state.viewer) {
|
destroy(viewer = state.viewer) {
|
||||||
layer.disable();
|
layer.disable();
|
||||||
for (const entry of state.providers.values())
|
parts.sequences.destroy(viewer);
|
||||||
entry.instance.destroy(viewer);
|
|
||||||
parts.marker.destroy(viewer);
|
parts.marker.destroy(viewer);
|
||||||
parts.framing.attachNavigation(null);
|
parts.framing.attachNavigation(null);
|
||||||
state.listeners.clear();
|
state.listeners.clear();
|
||||||
@@ -283,10 +256,9 @@ export function createStreetLevelLayer({
|
|||||||
},
|
},
|
||||||
|
|
||||||
getStats() {
|
getStats() {
|
||||||
// The lifecycle polls this every second: summarise, don't snapshot.
|
const coverage = coverageStats();
|
||||||
const coverage = summarizeCoverage(providerSnapshots());
|
|
||||||
let loadingLabel = '';
|
|
||||||
const keyLabel = coverage.keyRejected ? 'KEY REJECTED' : 'KEY REQUIRED';
|
const keyLabel = coverage.keyRejected ? 'KEY REJECTED' : 'KEY REQUIRED';
|
||||||
|
let loadingLabel = '';
|
||||||
if (coverage.keyRequired) loadingLabel = keyLabel;
|
if (coverage.keyRequired) loadingLabel = keyLabel;
|
||||||
else if (coverage.loading) loadingLabel = 'loading coverage...';
|
else if (coverage.loading) loadingLabel = 'loading coverage...';
|
||||||
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
|
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
|
||||||
@@ -294,11 +266,11 @@ export function createStreetLevelLayer({
|
|||||||
count: coverage.count,
|
count: coverage.count,
|
||||||
loading: coverage.loading,
|
loading: coverage.loading,
|
||||||
keyRequired: coverage.keyRequired,
|
keyRequired: coverage.keyRequired,
|
||||||
error: coverage.keyRequired
|
// A rejected key's message names the fix; a missing one says so.
|
||||||
? coverage.keyRejected
|
error:
|
||||||
? coverage.error || keyLabel
|
coverage.keyRequired && !coverage.keyRejected
|
||||||
: keyLabel
|
? keyLabel
|
||||||
: coverage.error,
|
: coverage.error,
|
||||||
loadingLabel,
|
loadingLabel,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -312,21 +284,26 @@ export function createStreetLevelLayer({
|
|||||||
parts.framing.attachNavigation(navigation);
|
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() {
|
getParams() {
|
||||||
return encodeParams({
|
return {
|
||||||
providers: [...state.providers].map(([id, entry]) => [id, entry.on]),
|
mapillary: state.providerOn,
|
||||||
filter: state.filter,
|
pano: state.filter.pano,
|
||||||
});
|
sinceDays: state.filter.sinceDays,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Unknown keys and malformed values are ignored, so any link applies. */
|
||||||
setParams(params = {}) {
|
setParams(params = {}) {
|
||||||
const decoded = decodeParams(params, {
|
const input = params && typeof params === 'object' ? params : {};
|
||||||
providerIds: state.providers.keys(),
|
if (typeof input.mapillary === 'boolean') setProviderOn(input.mapillary);
|
||||||
filter: state.filter,
|
const next = {};
|
||||||
});
|
if ('pano' in input) next.pano = input.pano;
|
||||||
for (const [id, on] of decoded.providers) setProviderEnabled(id, on);
|
if ('sinceDays' in input) next.sinceDays = input.sinceDays;
|
||||||
setCoverageFilter(decoded.filter);
|
setCoverageFilter(next);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -337,80 +314,59 @@ export function createStreetLevelLayer({
|
|||||||
return () => state.listeners.delete(listener);
|
return () => state.listeners.delete(listener);
|
||||||
},
|
},
|
||||||
getUIState,
|
getUIState,
|
||||||
/** The DOM element provider viewers render into. */
|
/** The DOM element the photo viewer renders into. */
|
||||||
attachViewerHost(element) {
|
attachViewerHost(element) {
|
||||||
parts.viewerHost.attach(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. */
|
/** Imagery filter for coverage, cones and nearest-image lookups. */
|
||||||
setCoverageFilter,
|
setCoverageFilter,
|
||||||
openImage,
|
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) {
|
async openNearest(point) {
|
||||||
|
if (!isActive(state)) return false;
|
||||||
const view = point || viewCentre(state.viewer);
|
const view = point || viewCentre(state.viewer);
|
||||||
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
|
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
|
||||||
return false;
|
return false;
|
||||||
abortNearest();
|
abortNearest();
|
||||||
const lookup = new AbortController();
|
const lookup = new AbortController();
|
||||||
nearestLookup = lookup;
|
nearestLookup = lookup;
|
||||||
const { signal } = lookup;
|
|
||||||
state.street.loading = true;
|
state.street.loading = true;
|
||||||
state.street.error = null;
|
state.street.error = null;
|
||||||
notify();
|
notify();
|
||||||
let lastError = null;
|
let imageId = null;
|
||||||
for (const entry of activeEntries()) {
|
let failure = null;
|
||||||
let imageId = null;
|
try {
|
||||||
try {
|
imageId = await nearestImageId(
|
||||||
imageId = await entry.instance.nearestImage(
|
source,
|
||||||
{ lat: view.lat, lon: view.lon },
|
{ lat: view.lat, lon: view.lon },
|
||||||
{ signal },
|
resolveFilter(state.filter),
|
||||||
);
|
{ signal: lookup.signal },
|
||||||
} catch (error) {
|
);
|
||||||
// Overtaken: whatever replaced it owns the panel, errors included.
|
} catch (error) {
|
||||||
if (signal.aborted) return false;
|
failure = error;
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
if (nearestLookup === lookup) nearestLookup = null;
|
// Overtaken, closed or switched off: whatever did that owns the panel.
|
||||||
// Switched off while it looked: nothing to open, nothing to report.
|
if (lookup.signal.aborted) return false;
|
||||||
if (state.enabled && activeEntries().length)
|
nearestLookup = null;
|
||||||
state.street.error =
|
if (imageId) return openImage(imageId);
|
||||||
lastError?.message ||
|
state.street.error =
|
||||||
`No street-level imagery within ${NEAREST_RADIUS_M} m of the view centre`;
|
failure?.message ||
|
||||||
|
`No street-level imagery within ${NEAREST_RADIUS_M} m of the view centre`;
|
||||||
state.street.loading = false;
|
state.street.loading = false;
|
||||||
notify();
|
notify();
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
/** Close the image and deselect it everywhere on the map. */
|
/** Close the image and deselect its sequence on the map. */
|
||||||
closeViewer() {
|
closeViewer() {
|
||||||
abortNearest();
|
abortNearest();
|
||||||
parts.viewerHost.close();
|
parts.viewerHost.close();
|
||||||
parts.clearSequences();
|
parts.sequences.clearSelection();
|
||||||
},
|
},
|
||||||
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
|
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
|
||||||
resizeViewer: () => parts.viewerHost.resize(),
|
resizeViewer: () => parts.viewerHost.resize(),
|
||||||
selectSequence(
|
selectSequence: (sequenceId) => parts.sequences.select(sequenceId),
|
||||||
sequenceId,
|
clearSequence: () => parts.sequences.clearSelection(),
|
||||||
providerId = state.providers.keys().next().value,
|
refreshCoverage: () => parts.coverage.refresh(),
|
||||||
) {
|
|
||||||
return state.providers
|
|
||||||
.get(providerId)
|
|
||||||
?.instance.selectSequence?.(sequenceId);
|
|
||||||
},
|
|
||||||
clearSequence: () => parts.clearSequences(),
|
|
||||||
refreshCoverage() {
|
|
||||||
for (const entry of activeEntries()) entry.instance.refreshCoverage();
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
return layer;
|
return layer;
|
||||||
}
|
}
|
||||||
@@ -3,14 +3,19 @@ import test from 'node:test';
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createStreetLevelLayer } from './index.js';
|
import { createStreetLevelLayer } from './index.js';
|
||||||
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
|
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
|
||||||
import { createMapillaryProvider } from './providers/mapillary/index.js';
|
import {
|
||||||
import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs';
|
fakeMapillarySource,
|
||||||
|
fakePhotoViewer,
|
||||||
|
rayCamera,
|
||||||
|
} from '../../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A stand-in Cesium viewer the layer can be enabled on: a canvas for the
|
* 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 = [];
|
const credits = [];
|
||||||
/** Cesium's flight bookkeeping: a new flight cancels the current one. */
|
/** Cesium's flight bookkeeping: a new flight cancels the current one. */
|
||||||
let flight = null;
|
let flight = null;
|
||||||
@@ -26,19 +31,20 @@ function fakeViewer() {
|
|||||||
// Keep Cesium's handler on the canvas; there is no real document here.
|
// Keep Cesium's handler on the canvas; there is no real document here.
|
||||||
disableRootEvents: true,
|
disableRootEvents: true,
|
||||||
onwheel: null,
|
onwheel: null,
|
||||||
|
...(view ? { clientWidth: 100, clientHeight: 100 } : {}),
|
||||||
});
|
});
|
||||||
return {
|
const camera = Object.assign(
|
||||||
credits,
|
view
|
||||||
flights,
|
? rayCamera({
|
||||||
scene: {
|
lon: -121.4944,
|
||||||
canvas,
|
lat: 38.5816,
|
||||||
primitives: { add: (p) => p, remove() {} },
|
altitude: 900,
|
||||||
// Enough of a scene for the position marker to clamp to the ground.
|
pitch: -90,
|
||||||
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
width: 100,
|
||||||
updateHeight: () => () => {},
|
height: 100,
|
||||||
getHeight: () => undefined,
|
})
|
||||||
},
|
: {},
|
||||||
camera: {
|
{
|
||||||
changed: new Cesium.Event(),
|
changed: new Cesium.Event(),
|
||||||
moveStart: new Cesium.Event(),
|
moveStart: new Cesium.Event(),
|
||||||
moveEnd: new Cesium.Event(),
|
moveEnd: new Cesium.Event(),
|
||||||
@@ -51,6 +57,20 @@ function fakeViewer() {
|
|||||||
if (stopFlight()) flights.cancelled++;
|
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: {
|
creditDisplay: {
|
||||||
addStaticCredit: (credit) => credits.push(credit),
|
addStaticCredit: (credit) => credits.push(credit),
|
||||||
removeStaticCredit: (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;
|
const saved = globalThis.document;
|
||||||
// A pose moves the position marker, whose glyph is drawn on a canvas.
|
// A pose moves the position marker, whose glyph is drawn on a canvas.
|
||||||
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
||||||
globalThis.document = Object.assign(new EventTarget(), {
|
globalThis.document = Object.assign(new EventTarget(), {
|
||||||
createElement: () => ({ getContext: () => drawing }),
|
createElement: () => ({ getContext: () => drawing }),
|
||||||
});
|
});
|
||||||
const viewer = fakeViewer();
|
// Skip Cesium's one-time terrain table download for draped lines.
|
||||||
const layer = createStreetLevelLayer({ providers });
|
t.mock.method(
|
||||||
|
Cesium.GroundPolylinePrimitive,
|
||||||
|
'initializeTerrainHeights',
|
||||||
|
async () => {},
|
||||||
|
);
|
||||||
|
const viewer = fakeViewer({ view });
|
||||||
|
const layer = createStreetLevelLayer({ source, photoViewer });
|
||||||
layer.init(viewer);
|
layer.init(viewer);
|
||||||
layer.enable(viewer);
|
if (enable) layer.enable(viewer);
|
||||||
t.after(() => {
|
t.after(() => {
|
||||||
layer.destroy();
|
layer.destroy();
|
||||||
globalThis.document = saved;
|
globalThis.document = saved;
|
||||||
});
|
});
|
||||||
await settle();
|
await settle();
|
||||||
return { layer, viewer };
|
return { layer, viewer, source, photoViewer };
|
||||||
}
|
}
|
||||||
|
|
||||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
@@ -91,180 +128,194 @@ function deferred() {
|
|||||||
return { promise, resolve, reject };
|
return { promise, resolve, reject };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A provider whose viewer reports a pose for every image it opens. */
|
/** A photo viewer reporting a pose for every image it opens. */
|
||||||
function posingProvider() {
|
const posingViewer = () =>
|
||||||
const provider = fakeProvider();
|
fakePhotoViewer({ pose: { position: { lon: -121.49, lat: 38.58 } } });
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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() {
|
function slowLookups() {
|
||||||
const lookups = [];
|
const lookups = [];
|
||||||
const nearestImage = (point, { signal } = {}) => {
|
const nearestImages = (query, { signal } = {}) => {
|
||||||
const answer = deferred();
|
const answer = deferred();
|
||||||
lookups.push({ point, signal, answer });
|
lookups.push({ query, signal, answer });
|
||||||
return answer.promise;
|
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) => {
|
test('the layer is Mapillary under the codec keys it has always had', () => {
|
||||||
const { layer, viewer } = await enabledLayer(t);
|
const layer = createStreetLevelLayer({ source: fakeMapillarySource() });
|
||||||
const shown = () => viewer.credits.map((credit) => credit.html);
|
assert.equal(layer.id, 'street-level');
|
||||||
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on activate');
|
assert.equal(layer.requiresKeyId, 'mapillary');
|
||||||
assert.match(shown()[0], /CC BY-SA 4\.0/);
|
assert.deepEqual(layer.getParams(), {
|
||||||
layer.setProviderEnabled('mapillary', false);
|
mapillary: true,
|
||||||
assert.deepEqual(shown(), [], 'hidden with the provider');
|
pano: 'all',
|
||||||
layer.setProviderEnabled('mapillary', true);
|
sinceDays: 0,
|
||||||
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]);
|
});
|
||||||
layer.disable();
|
assert.throws(() => createStreetLevelLayer({ source: {} }), /source/);
|
||||||
assert.deepEqual(shown(), [], 'every credit goes with the layer');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getStats tells a rejected key from a missing one', async (t) => {
|
test('the CC BY-SA credit shows while Mapillary is on and goes with it', async (t) => {
|
||||||
const provider = fakeProvider();
|
const { layer, viewer } = await startLayer(t);
|
||||||
const { layer } = await enabledLayer(t, [provider]);
|
const shown = () => viewer.credits.map((credit) => credit.html);
|
||||||
provider.stats.loading = true;
|
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on enable');
|
||||||
assert.equal(layer.getStats().loadingLabel, 'loading coverage...');
|
assert.match(shown()[0], /CC BY-SA 4\.0/);
|
||||||
assert.equal(layer.getStats().keyRequired, false);
|
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;
|
test('coverage asks for no tiles before the key status is known, and none without a key', async (t) => {
|
||||||
assert.deepEqual(
|
for (const configured of [false, true]) {
|
||||||
[layer.getStats().loadingLabel, layer.getStats().error],
|
const status = deferred();
|
||||||
['KEY REQUIRED', 'KEY REQUIRED'],
|
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.
|
test('a rejected key gates the layer as KEY REJECTED until it goes off', async (t) => {
|
||||||
provider.stats.keyRequired = false;
|
const rejected = Object.assign(new Error('Mapillary rejected the token'), {
|
||||||
provider.stats.keyRejected = true;
|
keyRejected: true,
|
||||||
provider.stats.error = 'Mapillary rejected MAPILLARY_CLIENT_TOKEN';
|
});
|
||||||
|
const source = fakeMapillarySource({
|
||||||
|
getTile: async () => {
|
||||||
|
throw rejected;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { layer, viewer } = await startLayer(t, { source, view: true });
|
||||||
|
await settle();
|
||||||
const stats = layer.getStats();
|
const stats = layer.getStats();
|
||||||
assert.equal(stats.keyRequired, true);
|
assert.equal(stats.keyRequired, true);
|
||||||
assert.equal(stats.loadingLabel, 'KEY REJECTED');
|
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();
|
const ui = layer.getUIState();
|
||||||
assert.equal(ui.keyRequired, true);
|
assert.deepEqual([ui.keyRequired, ui.keyRejected], [true, true]);
|
||||||
assert.equal(ui.keyRejected, true);
|
layer.disable();
|
||||||
assert.equal(ui.providers[0].keyRequired, true);
|
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) => {
|
test('switching Mapillary off clears coverage and closes the image it shows', async (t) => {
|
||||||
const provider = fakeProvider();
|
const { layer, photoViewer } = await startLayer(t);
|
||||||
const { layer } = await enabledLayer(t, [provider]);
|
|
||||||
layer.attachViewerHost({});
|
layer.attachViewerHost({});
|
||||||
assert.equal(await layer.openImage('mapillary', 'img1'), true);
|
assert.equal(await layer.openImage('img1'), true);
|
||||||
assert.deepEqual(provider.calls.open, ['img1']);
|
assert.deepEqual(photoViewer.calls.open, ['img1']);
|
||||||
assert.equal(layer.getUIState().street.open, true);
|
assert.equal(layer.getUIState().street.open, true);
|
||||||
layer.setProviderEnabled('mapillary', false);
|
layer.setParams({ mapillary: false });
|
||||||
assert.equal(provider.calls.deactivate, 1);
|
assert.equal(photoViewer.calls.unmount, 1, 'its viewer is released');
|
||||||
assert.equal(provider.calls.unmount, 1, 'its viewer is released');
|
|
||||||
assert.equal(layer.getUIState().street.open, false);
|
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) => {
|
test('openNearest without the panel reports it instead of loading forever', async (t) => {
|
||||||
const provider = fakeProvider({ nearestImage: async () => 'img1' });
|
const source = fakeMapillarySource({
|
||||||
const { layer } = await enabledLayer(t, [provider]);
|
nearestImages: async () => [{ id: 1 }],
|
||||||
|
});
|
||||||
|
const { layer, photoViewer } = await startLayer(t, { source });
|
||||||
assert.equal(await layer.openNearest({ lat: 38.58, lon: -121.49 }), false);
|
assert.equal(await layer.openNearest({ lat: 38.58, lon: -121.49 }), false);
|
||||||
const { street } = layer.getUIState();
|
const { street } = layer.getUIState();
|
||||||
assert.equal(street.loading, false);
|
assert.equal(street.loading, false);
|
||||||
assert.match(street.error, /Open the Street Level panel/);
|
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) => {
|
test('openNearest opens nothing once the layer or its switch went off during the lookup', async (t) => {
|
||||||
const answer = deferred();
|
for (const off of [
|
||||||
const provider = fakeProvider({ nearestImage: () => answer.promise });
|
(layer) => layer.disable(),
|
||||||
const { layer } = await enabledLayer(t, [provider]);
|
(layer) => layer.setParams({ mapillary: false }),
|
||||||
layer.attachViewerHost({});
|
]) {
|
||||||
const opening = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
const { lookups, source } = slowLookups();
|
||||||
layer.disable();
|
const { layer, photoViewer } = await startLayer(t, { source });
|
||||||
answer.resolve('img1');
|
layer.attachViewerHost({});
|
||||||
assert.equal(await opening, false);
|
const opening = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||||||
assert.equal(provider.calls.mount, 0, 'no viewer was stood up');
|
off(layer);
|
||||||
const { street } = layer.getUIState();
|
lookups[0].answer.resolve('img1');
|
||||||
assert.equal(street.open, false);
|
assert.equal(await opening, false);
|
||||||
assert.equal(street.loading, false);
|
assert.equal(lookups[0].signal.aborted, true, 'the request was aborted');
|
||||||
assert.equal(street.error, null);
|
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) => {
|
test('a sequence error is withdrawn with the sequence, never the viewer’s own', async (t) => {
|
||||||
const answer = deferred();
|
const source = fakeMapillarySource({
|
||||||
const provider = fakeProvider({ nearestImage: () => answer.promise });
|
getSequenceImages: async () => {
|
||||||
const { layer } = await enabledLayer(t, [provider]);
|
throw new Error('Sequence images unavailable');
|
||||||
layer.attachViewerHost({});
|
},
|
||||||
const opening = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
});
|
||||||
layer.setProviderEnabled('mapillary', false);
|
const { layer } = await startLayer(t, { source });
|
||||||
answer.resolve('img1');
|
await layer.selectSequence('seq-1');
|
||||||
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');
|
|
||||||
assert.equal(layer.getUIState().street.error, 'Sequence images unavailable');
|
assert.equal(layer.getUIState().street.error, 'Sequence images unavailable');
|
||||||
actions.reportError(null);
|
layer.clearSequence();
|
||||||
assert.equal(layer.getUIState().street.error, null, 'withdrawn');
|
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);
|
layer.attachViewerHost(null);
|
||||||
actions.reportError('Sequence images unavailable');
|
await layer.openImage('img1');
|
||||||
await layer.openImage('mapillary', 'img1');
|
layer.clearSequence();
|
||||||
actions.reportError(null);
|
|
||||||
assert.match(layer.getUIState().street.error, /Open the Street Level panel/);
|
assert.match(layer.getUIState().street.error, /Open the Street Level panel/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('closing the photo stops the globe flying to it', async (t) => {
|
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({});
|
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');
|
assert.equal(viewer.flights.started, 1, 'the photo is framed');
|
||||||
layer.closeViewer();
|
layer.closeViewer();
|
||||||
assert.equal(viewer.flights.cancelled, 1);
|
assert.equal(viewer.flights.cancelled, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('switching the layer off or destroying it stops the framing flight', async (t) => {
|
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({});
|
layer.attachViewerHost({});
|
||||||
await layer.openImage('mapillary', 'img1');
|
await layer.openImage('img1');
|
||||||
layer.disable();
|
layer.disable();
|
||||||
assert.equal(viewer.flights.cancelled, 1, 'layer off');
|
assert.equal(viewer.flights.cancelled, 1, 'layer off');
|
||||||
|
|
||||||
layer.enable(viewer);
|
layer.enable(viewer);
|
||||||
await layer.openImage('mapillary', 'img2');
|
await layer.openImage('img2');
|
||||||
layer.destroy();
|
layer.destroy();
|
||||||
assert.equal(viewer.flights.cancelled, 2, 'destroyed');
|
assert.equal(viewer.flights.cancelled, 2, 'destroyed');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('closing the photo leaves a newer navigation flight alone', async (t) => {
|
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({});
|
layer.attachViewerHost({});
|
||||||
await layer.openImage('mapillary', 'img1');
|
await layer.openImage('img1');
|
||||||
assert.equal(viewer.flights.started, 1, 'the photo is being framed');
|
assert.equal(viewer.flights.started, 1, 'the photo is being framed');
|
||||||
// A search result flies the globe elsewhere before the framing lands.
|
// A search result flies the globe elsewhere before the framing lands.
|
||||||
viewer.camera.flyToBoundingSphere(null, {});
|
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) => {
|
test('an older nearest lookup that answers late cannot replace a newer one', async (t) => {
|
||||||
const { lookups, nearestImage } = slowLookups();
|
const { lookups, source } = slowLookups();
|
||||||
const provider = fakeProvider({ nearestImage });
|
const { layer, photoViewer } = await startLayer(t, { source });
|
||||||
const { layer } = await enabledLayer(t, [provider]);
|
|
||||||
layer.attachViewerHost({});
|
layer.attachViewerHost({});
|
||||||
const older = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
const older = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||||||
const newer = layer.openNearest({ lat: 38.59, lon: -121.48 });
|
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);
|
assert.equal(await newer, true);
|
||||||
lookups[0].answer.resolve('older');
|
lookups[0].answer.resolve('older');
|
||||||
assert.equal(await older, false);
|
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[0].signal?.aborted, true, 'its request was aborted');
|
||||||
assert.equal(lookups[1].signal?.aborted, false);
|
assert.equal(lookups[1].signal?.aborted, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an image picked during a nearest lookup wins over its late answer', async (t) => {
|
test('an image picked during a nearest lookup wins over its late answer', async (t) => {
|
||||||
const { lookups, nearestImage } = slowLookups();
|
const { lookups, source } = slowLookups();
|
||||||
const provider = fakeProvider({ nearestImage });
|
const { layer, photoViewer } = await startLayer(t, { source });
|
||||||
const { layer } = await enabledLayer(t, [provider]);
|
|
||||||
layer.attachViewerHost({});
|
layer.attachViewerHost({});
|
||||||
const lookup = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
const lookup = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||||||
// A cone click goes through the provider's openImage action.
|
// A cone click opens its image directly.
|
||||||
assert.equal(await provider.context().actions.openImage('picked'), true);
|
assert.equal(await layer.openImage('picked'), true);
|
||||||
lookups[0].answer.resolve('nearest');
|
lookups[0].answer.resolve('nearest');
|
||||||
assert.equal(await lookup, false);
|
assert.equal(await lookup, false);
|
||||||
assert.deepEqual(provider.calls.open, ['picked']);
|
assert.deepEqual(photoViewer.calls.open, ['picked']);
|
||||||
assert.equal(lookups[0].signal?.aborted, true);
|
assert.equal(lookups[0].signal?.aborted, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('closing the viewer or switching the layer off retires a nearest lookup', async (t) => {
|
test('closing the viewer or switching the layer off retires a nearest lookup', async (t) => {
|
||||||
const { lookups, nearestImage } = slowLookups();
|
const { lookups, source } = slowLookups();
|
||||||
const provider = fakeProvider({ nearestImage });
|
const { layer, viewer, photoViewer } = await startLayer(t, { source });
|
||||||
const { layer, viewer } = await enabledLayer(t, [provider]);
|
|
||||||
layer.attachViewerHost({});
|
layer.attachViewerHost({});
|
||||||
|
|
||||||
const closed = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
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);
|
layer.enable(viewer);
|
||||||
lookups[1].answer.resolve('img1');
|
lookups[1].answer.resolve('img1');
|
||||||
assert.equal(await disabled, false, 'nor does it open once back on');
|
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());
|
({ street } = layer.getUIState());
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
[street.open, street.loading, street.error],
|
[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');
|
assert.equal(lookups[1].signal?.aborted, true, 'layer off aborts it too');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the Mapillary provider hands the lookup signal to its source', async () => {
|
test('setParams takes "any date" (0 days) and the switch over the current values (share-link defaults)', async (t) => {
|
||||||
const requests = [];
|
const { layer } = await startLayer(t);
|
||||||
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]);
|
|
||||||
layer.setParams({ pano: 'flat', sinceDays: 365 });
|
layer.setParams({ pano: 'flat', sinceDays: 365 });
|
||||||
assert.equal(layer.getParams().sinceDays, 365);
|
assert.equal(layer.getParams().sinceDays, 365);
|
||||||
layer.setParams({ sinceDays: 0 });
|
layer.setParams({ sinceDays: 0 });
|
||||||
@@ -372,54 +392,39 @@ test('setParams takes "any date" (0 days) and a provider switch over the current
|
|||||||
pano: 'flat',
|
pano: 'flat',
|
||||||
sinceDays: 0,
|
sinceDays: 0,
|
||||||
});
|
});
|
||||||
assert.equal(
|
|
||||||
provider.filters.at(-1).sinceMs,
|
|
||||||
null,
|
|
||||||
'providers drop the cut-off',
|
|
||||||
);
|
|
||||||
layer.setParams({ mapillary: false });
|
layer.setParams({ mapillary: false });
|
||||||
assert.equal(layer.getParams().mapillary, false);
|
assert.equal(layer.getParams().mapillary, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** A layer initialised on a stand-in viewer but never enabled. */
|
test('setParams ignores unknown keys and malformed values', async (t) => {
|
||||||
function initialisedLayer(t, providers) {
|
const { layer } = await startLayer(t);
|
||||||
const saved = globalThis.document;
|
layer.setParams({ pano: 'pano', sinceDays: 730 });
|
||||||
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
layer.setParams({ mapillary: '0', kartaview: true, pano: 'weird', x: 1 });
|
||||||
globalThis.document = Object.assign(new EventTarget(), {
|
layer.setParams({ sinceDays: -3 });
|
||||||
createElement: () => ({ getContext: () => drawing }),
|
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) => {
|
test('switching Mapillary on while the layer is off draws nothing (M01)', async (t) => {
|
||||||
const provider = fakeProvider();
|
const { layer, viewer, source, photoViewer } = await startLayer(t, {
|
||||||
const prewarmed = [];
|
view: true,
|
||||||
const create = provider.create;
|
enable: false,
|
||||||
provider.create = (context) => {
|
});
|
||||||
const instance = create(context);
|
|
||||||
instance.viewer.prewarm = async (host) => prewarmed.push(host);
|
|
||||||
return instance;
|
|
||||||
};
|
|
||||||
const { layer, viewer } = initialisedLayer(t, [provider]);
|
|
||||||
layer.attachViewerHost({});
|
layer.attachViewerHost({});
|
||||||
layer.setProviderEnabled('mapillary', false);
|
|
||||||
layer.setProviderEnabled('mapillary', true);
|
|
||||||
layer.setParams({ mapillary: false });
|
layer.setParams({ mapillary: false });
|
||||||
layer.setParams({ mapillary: true });
|
layer.setParams({ mapillary: true });
|
||||||
await new Promise((resolve) => setTimeout(resolve, 40)); // past whenIdle
|
await settle();
|
||||||
assert.equal(provider.calls.activate, 0, 'no coverage drawn');
|
assert.equal(source.calls.tiles.length, 0, 'no coverage drawn');
|
||||||
assert.deepEqual(viewer.credits, [], 'no credit shown');
|
assert.deepEqual(viewer.credits, [], 'no credit shown');
|
||||||
assert.deepEqual(prewarmed, [], 'no viewer stood up');
|
assert.equal(photoViewer.calls.mount, 0, 'no viewer stood up');
|
||||||
assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept');
|
assert.equal(layer.getUIState().providerOn, true, 'the switch is kept');
|
||||||
// Enabling the layer is what activates it, once.
|
// Enabling the layer is what draws it.
|
||||||
layer.enable(viewer);
|
layer.enable(viewer);
|
||||||
assert.equal(provider.calls.activate, 1);
|
await settle();
|
||||||
|
assert.ok(source.calls.tiles.length > 0);
|
||||||
assert.equal(viewer.credits.length, 1);
|
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';
|
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';
|
export const POSITION_PICK_ID = 'sl:pos';
|
||||||
|
|
||||||
/** One colour per imagery source: chip, coverage lines, points, cones and legend. */
|
/** The selection highlight and the position marker. */
|
||||||
export const PROVIDER_COLORS = Object.freeze({
|
|
||||||
mapillary: '#05cb63',
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Colours every provider shares: the selection highlight and the marker. */
|
|
||||||
export const COLORS = Object.freeze({
|
export const COLORS = Object.freeze({
|
||||||
selected: '#00d4ff',
|
selected: '#00d4ff',
|
||||||
position: '#ffb300',
|
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). */
|
/** Longest "captured since" window the filter accepts, in days (~100 years). */
|
||||||
export const MAX_SINCE_DAYS = 36_500;
|
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 });
|
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 * as Cesium from 'cesium';
|
||||||
import { decodeCoverageTile } from './decode.js';
|
import { decodeCoverageTile } from './decode.js';
|
||||||
import { passesImageryFilter } from '../../filter.js';
|
import { passesImageryFilter, resolveFilter } from '../../filter.js';
|
||||||
import {
|
import { isActive } from '../../state.js';
|
||||||
createHorizonCull,
|
import { cameraNadir, groundUnderCamera, visibleBbox } from '../../view.js';
|
||||||
groundUnderCamera,
|
import { coverageZoomForHeight, tilesForBbox } from '../../tileMath.js';
|
||||||
metresBetween,
|
|
||||||
viewFocus,
|
|
||||||
visibleBbox,
|
|
||||||
} from '../../view.js';
|
|
||||||
import {
|
|
||||||
coverageZoomForHeight,
|
|
||||||
overviewZoomForHeight,
|
|
||||||
tileBounds,
|
|
||||||
tilesForBbox,
|
|
||||||
} from '../../tileMath.js';
|
|
||||||
import {
|
import {
|
||||||
COLORS,
|
COLORS,
|
||||||
COVERAGE_LINE_WIDTH_PX,
|
COVERAGE_LINE_WIDTH_PX,
|
||||||
COVERAGE_MAX_SEQUENCES,
|
COVERAGE_MAX_SEQUENCES,
|
||||||
COVERAGE_MAX_TILES,
|
COVERAGE_MAX_TILES,
|
||||||
COVERAGE_MOVE_DEBOUNCE_MS,
|
COVERAGE_MOVE_DEBOUNCE_MS,
|
||||||
COVERAGE_OVERVIEW_MAX_TILES,
|
|
||||||
COVERAGE_OVERVIEW_POINT_PX,
|
|
||||||
KEY_REJECTED_MESSAGE,
|
KEY_REJECTED_MESSAGE,
|
||||||
PICK_PREFIX,
|
PICK_PREFIX,
|
||||||
RATE_LIMITED_MESSAGE,
|
RATE_LIMITED_MESSAGE,
|
||||||
SEQUENCE_VIEW_NEAR_M,
|
|
||||||
SEQUENCE_VIEW_RANGE_MIN_M,
|
SEQUENCE_VIEW_RANGE_MIN_M,
|
||||||
SEQUENCE_VIEW_RANGE_PER_HEIGHT,
|
SEQUENCE_VIEW_RANGE_PER_HEIGHT,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
@@ -67,9 +54,15 @@ export function sequenceIdFromPick(pickId) {
|
|||||||
return cut === -1 ? rest : rest.slice(0, cut);
|
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
|
* Camera-driven coverage: Mapillary sequence lines (z11–14) near the ground,
|
||||||
* (z11–14) near the ground, draped on terrain and 3D tiles alike.
|
* draped on terrain and 3D tiles alike. Nothing is drawn from orbit.
|
||||||
*/
|
*/
|
||||||
export function createCoverage({ state, source }) {
|
export function createCoverage({ state, source }) {
|
||||||
const { render } = state.services;
|
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. */
|
/** The imagery filter, resolved now: "since N days" moves with the clock. */
|
||||||
function filter() {
|
function filter() {
|
||||||
return state.context.getFilter();
|
return resolveFilter(state.filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -154,65 +147,17 @@ export function createCoverage({ state, source }) {
|
|||||||
return primitives;
|
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) {
|
function attachPrimitive(entry) {
|
||||||
const scene = state.viewer?.scene;
|
const scene = state.viewer?.scene;
|
||||||
if (!scene) return;
|
if (!scene) return;
|
||||||
if (entry.kind === 'sequence') {
|
// Lookup, picking and counts follow what is drawn, not the whole tile.
|
||||||
// Lookup, picking and counts follow what is drawn, not the whole tile.
|
const sequences = drawnSequences(entry);
|
||||||
const sequences = drawnSequences(entry);
|
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
|
||||||
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
|
entry.primitives = buildSequencePrimitives(sequences);
|
||||||
entry.primitives = buildSequencePrimitives(sequences);
|
entry.count = sequences.length;
|
||||||
entry.count = sequences.length;
|
for (const { primitive } of entry.primitives)
|
||||||
for (const { primitive } of entry.primitives)
|
scene.groundPrimitives.add(primitive);
|
||||||
scene.groundPrimitives.add(primitive);
|
watchSelection();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function detachPrimitive(entry) {
|
function detachPrimitive(entry) {
|
||||||
@@ -225,15 +170,6 @@ export function createCoverage({ state, source }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
entry.primitives = [];
|
entry.primitives = [];
|
||||||
if (entry.primitive) {
|
|
||||||
try {
|
|
||||||
scene?.primitives?.remove(entry.primitive);
|
|
||||||
} catch {
|
|
||||||
/* already gone */
|
|
||||||
}
|
|
||||||
entry.primitive = null;
|
|
||||||
entry.overviewPoints = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeTile(key) {
|
function removeTile(key) {
|
||||||
@@ -243,7 +179,7 @@ export function createCoverage({ state, source }) {
|
|||||||
state.coverage.tiles.delete(key);
|
state.coverage.tiles.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTile(tile, kind) {
|
async function loadTile(tile) {
|
||||||
const key = tileKey(tile);
|
const key = tileKey(tile);
|
||||||
if (state.coverage.tiles.has(key) || state.coverage.pending.has(key))
|
if (state.coverage.tiles.has(key) || state.coverage.pending.has(key))
|
||||||
return;
|
return;
|
||||||
@@ -257,43 +193,23 @@ export function createCoverage({ state, source }) {
|
|||||||
source.getTile('coverage', tile.z, tile.x, tile.y, {
|
source.getTile('coverage', tile.z, tile.x, tile.y, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
}),
|
}),
|
||||||
kind === 'sequence' ? ensureTerrainReady() : null,
|
ensureTerrainReady(),
|
||||||
]);
|
]);
|
||||||
// Only an abort, a retire, a clear or a newer request for this tile
|
// 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.
|
// 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 (
|
if (
|
||||||
controller.signal.aborted ||
|
controller.signal.aborted ||
|
||||||
!current() ||
|
state.coverage.pending.get(key) !== controller ||
|
||||||
kind !== state.coverage.kind ||
|
state.coverage.tiles.has(key) ||
|
||||||
tile.z !== state.coverage.zoom
|
tile.z !== state.coverage.zoom
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
const decoded = decodeCoverageTile(bytes, tile);
|
const decoded = decodeCoverageTile(bytes, tile);
|
||||||
const entry = { kind, primitive: null, primitives: [], count: 0 };
|
const entry = { primitives: [], count: 0, sequences: new Map() };
|
||||||
// Padded: vector tiles carry a small buffer past their edge.
|
// Newest first; the per-tile cap applies to what passes the filter.
|
||||||
const bounds = tileBounds(tile.x, tile.y, tile.z);
|
entry.sequenceList = decoded.sequences.sort(
|
||||||
const pad = (bounds.east - bounds.west) * 0.05;
|
(a, b) => (b.capturedAt || 0) - (a.capturedAt || 0),
|
||||||
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;
|
|
||||||
}
|
|
||||||
attachPrimitive(entry);
|
attachPrimitive(entry);
|
||||||
state.coverage.tiles.set(key, entry);
|
state.coverage.tiles.set(key, entry);
|
||||||
state.coverage.lastError = null;
|
state.coverage.lastError = null;
|
||||||
@@ -304,14 +220,11 @@ export function createCoverage({ state, source }) {
|
|||||||
state.coverage.pending.get(key) !== controller
|
state.coverage.pending.get(key) !== controller
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
state.coverage.lastError = error?.message || 'Coverage tile failed';
|
// Every other tile would be refused too: stop asking.
|
||||||
if (error?.keyRequired) state.keyRequired = true;
|
if (error?.keyRejected) block('rejected');
|
||||||
if (error?.keyRejected) {
|
else if (error?.keyRequired) block('no-key');
|
||||||
// Every other tile would be refused too: stop asking.
|
else if (error?.retryAfterSec) block('rate-limited', error.retryAfterSec);
|
||||||
state.keyRejected = true;
|
else state.coverage.lastError = error?.message || 'Coverage tile failed';
|
||||||
state.coverage.lastError = KEY_REJECTED_MESSAGE;
|
|
||||||
}
|
|
||||||
if (error?.retryAfterSec) holdFor(error.retryAfterSec);
|
|
||||||
} finally {
|
} finally {
|
||||||
// Only the request that still owns the key settles it: a superseded one
|
// 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).
|
// 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
|
* Pause tile requests (see `blocked` in state.js). A rate limit keeps what
|
||||||
* wait is over, then refresh once.
|
* 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) {
|
function block(reason, retryAfterSec = 0) {
|
||||||
clearTimeout(state.coverage.holdTimer);
|
const current = state.coverage.blocked;
|
||||||
state.coverage.holdUntil = Date.now() + seconds * 1000;
|
if (current === 'rejected') return;
|
||||||
state.coverage.lastError = RATE_LIMITED_MESSAGE;
|
if (reason === 'rate-limited' && current === 'no-key') return;
|
||||||
state.coverage.holdTimer = setTimeout(() => {
|
clearTimeout(state.coverage.blockTimer);
|
||||||
state.coverage.holdTimer = null;
|
state.coverage.blockTimer = null;
|
||||||
state.coverage.holdUntil = 0;
|
state.coverage.blocked = reason;
|
||||||
if (state.coverage.lastError === RATE_LIMITED_MESSAGE)
|
if (reason === 'rate-limited')
|
||||||
state.coverage.lastError = null;
|
state.coverage.blockTimer = setTimeout(() => {
|
||||||
refresh();
|
state.coverage.blockTimer = null;
|
||||||
}, seconds * 1000);
|
state.coverage.blocked = null;
|
||||||
|
refresh();
|
||||||
|
}, retryAfterSec * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Forget refusals when the provider goes off, so the next run asks again. */
|
/** The layer went off: forget refusals and errors, so the next run asks again. */
|
||||||
function resetErrors() {
|
function unblock() {
|
||||||
clearTimeout(state.coverage.holdTimer);
|
clearTimeout(state.coverage.blockTimer);
|
||||||
state.coverage.holdTimer = null;
|
state.coverage.blockTimer = null;
|
||||||
state.coverage.holdUntil = 0;
|
if (['rejected', 'rate-limited'].includes(state.coverage.blocked))
|
||||||
|
state.coverage.blocked = null;
|
||||||
state.coverage.lastError = 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. */
|
/** Drop the previous zoom's tiles once the new ones are on screen. */
|
||||||
@@ -375,74 +301,56 @@ export function createCoverage({ state, source }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function notify() {
|
function notify() {
|
||||||
state.context.notify();
|
state.notify?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether the screen centre meets the ground further ahead than the camera
|
* The tiles for the camera: what the screen shows within range of it, at
|
||||||
* is high (a view tilted above 45°), or misses it.
|
* the zoom for its height above ground, nearest the camera first.
|
||||||
*/
|
*/
|
||||||
function isTilted({ nadir, ahead }, height) {
|
function wantedTiles(viewer) {
|
||||||
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;
|
|
||||||
const ground = groundUnderCamera(viewer);
|
const ground = groundUnderCamera(viewer);
|
||||||
const cameraHeight = viewer.camera?.positionCartographic?.height;
|
const cameraHeight = viewer.camera?.positionCartographic?.height;
|
||||||
const height = Number.isFinite(cameraHeight)
|
const height = Number.isFinite(cameraHeight)
|
||||||
? cameraHeight - (ground ?? 0)
|
? cameraHeight - (ground ?? 0)
|
||||||
: null;
|
: null;
|
||||||
const sequenceZoom = coverageZoomForHeight(height);
|
const zoom = coverageZoomForHeight(height);
|
||||||
const overviewZoom = sequenceZoom ? null : overviewZoomForHeight(height);
|
if (zoom == null) return { hint: ZOOM_IN_HINT };
|
||||||
const zoom = sequenceZoom ?? overviewZoom;
|
// Rays meet the ground where it really is (1,600 m up in Denver) and stop
|
||||||
const kind = sequenceZoom ? 'sequence' : 'overview';
|
// short of the horizon, which would stretch the box to the world.
|
||||||
// At street zooms the rays meet the ground where it really is (1,600 m up
|
const bbox = visibleBbox(viewer, {
|
||||||
// in Denver) and stop short of the horizon.
|
groundHeight: ground,
|
||||||
const ranged =
|
maxRange: Math.max(
|
||||||
kind === 'sequence'
|
SEQUENCE_VIEW_RANGE_MIN_M,
|
||||||
? {
|
height * SEQUENCE_VIEW_RANGE_PER_HEIGHT,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
if (kind === 'overview' && (!bbox || zoom <= 1))
|
if (!bbox) return { hint: NO_GROUND_HINT };
|
||||||
bbox = [-180, -85, 180, 85];
|
// Ranked from the ground under the camera: a tilted view's box centre
|
||||||
if (zoom == null || !bbox) {
|
// can sit kilometres ahead of anything near.
|
||||||
state.coverage.hint =
|
const { tiles } = tilesForBbox(bbox, zoom, {
|
||||||
'Point the camera at the globe for street-level coverage';
|
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();
|
clear();
|
||||||
notify();
|
notify();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.coverage.hint = '';
|
state.coverage.hint = '';
|
||||||
if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) {
|
if (zoom !== state.coverage.zoom) {
|
||||||
retire();
|
retire();
|
||||||
state.coverage.zoom = zoom;
|
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));
|
const wanted = new Set(tiles.map(tileKey));
|
||||||
for (const key of [...state.coverage.tiles.keys()])
|
for (const key of [...state.coverage.tiles.keys()])
|
||||||
if (!wanted.has(key)) removeTile(key);
|
if (!wanted.has(key)) removeTile(key);
|
||||||
@@ -453,7 +361,7 @@ export function createCoverage({ state, source }) {
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
state.coverage.pending.delete(key);
|
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();
|
if (!state.coverage.pending.size) purgeStale();
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
@@ -491,9 +399,7 @@ export function createCoverage({ state, source }) {
|
|||||||
for (const key of [...state.coverage.tiles.keys()]) removeTile(key);
|
for (const key of [...state.coverage.tiles.keys()]) removeTile(key);
|
||||||
purgeStale();
|
purgeStale();
|
||||||
state.coverage.zoom = null;
|
state.coverage.zoom = null;
|
||||||
state.coverage.kind = null;
|
|
||||||
stopSelectionWatch();
|
stopSelectionWatch();
|
||||||
horizon.stop();
|
|
||||||
requestRender();
|
requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,17 +496,32 @@ export function createCoverage({ state, source }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sequences (or overview points) drawn. A sequence that crosses a tile
|
* Sequences drawn. A sequence that crosses a tile edge is in both tiles'
|
||||||
* edge is in both tiles' lists, so sequences count once by id.
|
* lists, so sequences count once by id.
|
||||||
*/
|
*/
|
||||||
function sequenceCount() {
|
function sequenceCount() {
|
||||||
const ids = new Set();
|
const ids = new Set();
|
||||||
let points = 0;
|
|
||||||
for (const entry of state.coverage.tiles.values())
|
for (const entry of state.coverage.tiles.values())
|
||||||
if (entry.kind === 'sequence')
|
for (const id of entry.sequences.keys()) ids.add(id);
|
||||||
for (const id of entry.sequences.keys()) ids.add(id);
|
return ids.size;
|
||||||
else points += entry.count;
|
}
|
||||||
return ids.size + points;
|
|
||||||
|
/** 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 {
|
return {
|
||||||
@@ -608,9 +529,11 @@ export function createCoverage({ state, source }) {
|
|||||||
detach,
|
detach,
|
||||||
refresh,
|
refresh,
|
||||||
clear,
|
clear,
|
||||||
resetErrors,
|
unblock,
|
||||||
|
setKeyStatus,
|
||||||
rebuild,
|
rebuild,
|
||||||
recolorSequence,
|
recolorSequence,
|
||||||
sequenceCount,
|
sequenceCount,
|
||||||
|
stats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import * as Cesium from 'cesium';
|
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 { encodeCoverageTile } from './coverageFixture.mjs';
|
||||||
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
|
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
|
||||||
import {
|
import {
|
||||||
@@ -110,26 +111,15 @@ function setup() {
|
|||||||
const state = {
|
const state = {
|
||||||
viewer,
|
viewer,
|
||||||
services: {},
|
services: {},
|
||||||
keyRequired: false,
|
enabled: true,
|
||||||
context: {
|
providerOn: true,
|
||||||
filter: { pano: 'all', sinceMs: null },
|
filter: { pano: 'all', sinceDays: 0 },
|
||||||
getFilter: () => state.context.filter,
|
notify() {},
|
||||||
isActive: () => true,
|
|
||||||
notify() {},
|
|
||||||
},
|
|
||||||
coverage: {
|
coverage: {
|
||||||
zoom: null,
|
...freshCoverage(),
|
||||||
kind: null,
|
// The key status is in; skip Cesium's one-time terrain table download.
|
||||||
tiles: new Map(),
|
blocked: null,
|
||||||
stale: new Map(),
|
|
||||||
staleTimer: null,
|
|
||||||
pending: new Map(),
|
|
||||||
lastError: null,
|
|
||||||
debounceTimer: null,
|
|
||||||
removeCameraListener: null,
|
|
||||||
// Skip Cesium's one-time terrain table download.
|
|
||||||
terrainReady: Promise.resolve(),
|
terrainReady: Promise.resolve(),
|
||||||
hint: '',
|
|
||||||
},
|
},
|
||||||
sequence: { selectedId: null },
|
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
|
coverage.refresh(); // street zoom: request #1 for the tile
|
||||||
assert.equal(forTile().length, 1);
|
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();
|
coverage.refresh();
|
||||||
viewer.view.height = 900; // back in before #1 settles: request #2
|
viewer.view.height = 900; // back in before #1 settles: request #2
|
||||||
coverage.refresh();
|
coverage.refresh();
|
||||||
@@ -231,19 +221,49 @@ test('panning off a tile that is still loading stops counting it as loading', as
|
|||||||
coverage.clear();
|
coverage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('zoom 0 is a zoom: the whole-earth view still shows coverage', () => {
|
test('from orbit nothing is requested or drawn, and the hint says to zoom in', async () => {
|
||||||
const { viewer, source, state, coverage } = setup();
|
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;
|
viewer.view.height = 20_000_000;
|
||||||
coverage.refresh();
|
coverage.refresh();
|
||||||
assert.equal(state.coverage.zoom, 0);
|
assert.equal(source.calls.length, 1, 'no tile is asked for');
|
||||||
assert.equal(state.coverage.hint, '');
|
assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'nothing drawn');
|
||||||
assert.deepEqual(
|
assert.equal(state.coverage.zoom, null);
|
||||||
source.calls.map((call) => call.key),
|
assert.equal(coverage.stats().hint, ZOOM_IN_HINT);
|
||||||
['0/0/0'],
|
viewer.view.height = 900;
|
||||||
);
|
coverage.refresh();
|
||||||
|
assert.equal(coverage.stats().hint, '', 'back at street zoom');
|
||||||
|
assert.equal(source.calls.length, 2);
|
||||||
coverage.clear();
|
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 () => {
|
test('a rejected key stops coverage requests until the layer goes off', async () => {
|
||||||
const { viewer, source, state, coverage } = setup();
|
const { viewer, source, state, coverage } = setup();
|
||||||
coverage.refresh();
|
coverage.refresh();
|
||||||
@@ -255,16 +275,18 @@ test('a rejected key stops coverage requests until the layer goes off', async ()
|
|||||||
);
|
);
|
||||||
source.calls[0].reject(rejected);
|
source.calls[0].reject(rejected);
|
||||||
await settle();
|
await settle();
|
||||||
assert.equal(state.keyRejected, true);
|
assert.equal(state.coverage.blocked, 'rejected');
|
||||||
assert.match(state.coverage.lastError, /rejected MAPILLARY_CLIENT_TOKEN/);
|
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;
|
viewer.view.lon += 0.05;
|
||||||
coverage.refresh();
|
coverage.refresh();
|
||||||
assert.equal(source.calls.length, 1, 'panning asks for nothing more');
|
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.clear();
|
||||||
coverage.resetErrors();
|
coverage.unblock();
|
||||||
assert.equal(state.keyRejected, false);
|
assert.equal(coverage.stats().keyRejected, false);
|
||||||
assert.equal(state.coverage.lastError, null);
|
assert.equal(coverage.stats().error, null);
|
||||||
coverage.refresh();
|
coverage.refresh();
|
||||||
assert.equal(source.calls.length, 2);
|
assert.equal(source.calls.length, 2);
|
||||||
coverage.clear();
|
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();
|
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;
|
viewer.view.lon += 0.05;
|
||||||
coverage.refresh();
|
coverage.refresh();
|
||||||
assert.equal(source.calls.length, 1, 'held: nothing is requested');
|
assert.equal(source.calls.length, 1, 'held: nothing is requested');
|
||||||
t.mock.timers.tick(30_000);
|
t.mock.timers.tick(30_000);
|
||||||
assert.equal(source.calls.length, 2, 'one refresh once the wait is over');
|
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.clear();
|
||||||
coverage.resetErrors();
|
coverage.unblock();
|
||||||
});
|
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the per-tile cap applies after the imagery filter, so a dense tile keeps its 360° lines', async () => {
|
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,
|
capturedAt: 1_000_000_000_000 + i,
|
||||||
isPano: true,
|
isPano: true,
|
||||||
});
|
});
|
||||||
state.context.filter = { pano: 'pano', sinceMs: null };
|
state.filter = { pano: 'pano', sinceDays: 0 };
|
||||||
coverage.refresh();
|
coverage.refresh();
|
||||||
source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences }));
|
source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences }));
|
||||||
await settle();
|
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.
|
// Picking and recolouring look sequences up among the drawn lines.
|
||||||
assert.ok(entry.sequences.has('pano-0'), 'drawn lines can be looked up');
|
assert.ok(entry.sequences.has('pano-0'), 'drawn lines can be looked up');
|
||||||
assert.equal(entry.sequences.has('flat-699'), false);
|
assert.equal(entry.sequences.has('flat-699'), false);
|
||||||
state.context.filter = { pano: 'all', sinceMs: null };
|
state.filter = { pano: 'all', sinceDays: 0 };
|
||||||
coverage.rebuild();
|
coverage.rebuild();
|
||||||
assert.equal(entry.count, cap, 'all imagery is capped as before');
|
assert.equal(entry.count, cap, 'all imagery is capped as before');
|
||||||
assert.ok(entry.sequences.has('flat-699'), 'the newest flat line is drawn');
|
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.
|
// The screen centre meets the ground at the centre of the next tile east.
|
||||||
const { state, coverage, keys, offsets, key } = denverStreetView(
|
const { state, coverage, keys, offsets, key } = denverStreetView(
|
||||||
(tileWidthM) => tileWidthM,
|
(tileWidthM) => tileWidthM,
|
||||||
);
|
);
|
||||||
assert.equal(state.coverage.zoom, 14, 'height measured from the ground');
|
assert.equal(state.coverage.zoom, 14, 'height measured from the ground');
|
||||||
assert.deepEqual(
|
assert.equal(keys[0], key(0, 0), 'the tile under the camera first');
|
||||||
keys.slice(0, 2),
|
assert.ok(keys.includes(key(1, 0)), 'the tile the screen centre looks at');
|
||||||
[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');
|
|
||||||
for (const [dx, dy] of offsets)
|
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();
|
coverage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a street view toward the horizon ranks only the ground within range', () => {
|
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: the
|
// The screen centre meets the ground 6 km out, past the 3 km range.
|
||||||
// tiles are ranked around the camera, not along a line to the horizon.
|
|
||||||
const { coverage, keys, offsets, key } = denverStreetView(() => 6000);
|
const { coverage, keys, offsets, key } = denverStreetView(() => 6000);
|
||||||
assert.equal(keys[0], key(0, 0), 'the tile under the camera first');
|
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)
|
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();
|
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);
|
coverage.recolorSequence('seq-1', true);
|
||||||
assert.deepEqual(Array.from(line.attributes.color), colourValue(true));
|
assert.deepEqual(Array.from(line.attributes.color), colourValue(true));
|
||||||
// Zoom out: the street tile stays on screen while the new zoom loads...
|
// Zoom out: the street tile stays on screen while the new zoom loads...
|
||||||
viewer.view.height = 100_000;
|
viewer.view.height = 3000;
|
||||||
coverage.refresh();
|
coverage.refresh();
|
||||||
assert.equal(state.coverage.stale.size, 1, 'the old zoom is kept');
|
assert.equal(state.coverage.stale.size, 1, 'the old zoom is kept');
|
||||||
// ...and the selection is cleared meanwhile.
|
// ...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();
|
await settle();
|
||||||
const [entry] = state.coverage.tiles.values();
|
const [entry] = state.coverage.tiles.values();
|
||||||
const line = controlled(entry.primitives[0], false);
|
const line = controlled(entry.primitives[0], false);
|
||||||
viewer.view.height = 100_000;
|
viewer.view.height = 3000;
|
||||||
coverage.refresh();
|
coverage.refresh();
|
||||||
assert.equal(state.coverage.stale.size, 1);
|
assert.equal(state.coverage.stale.size, 1);
|
||||||
frame(viewer.postRender);
|
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);
|
t.mock.timers.tick(1000);
|
||||||
assert.ok(source.calls.length > 1, 'asked again once the wait is over');
|
assert.ok(source.calls.length > 1, 'asked again once the wait is over');
|
||||||
coverage.clear();
|
coverage.clear();
|
||||||
coverage.resetErrors();
|
coverage.unblock();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a tile that fails before the terrain table loads is reported at once, never unhandled', async () => {
|
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
|
* Encode a `mly1_public`-shaped tile from lon/lat features, for tests and
|
||||||
* the hermetic QA gate.
|
* the hermetic QA gate.
|
||||||
* @param {{x: number, y: number, z: number}} tile
|
* @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(
|
export function encodeCoverageTile(
|
||||||
tile,
|
tile,
|
||||||
{ sequences = [], overview = [], extent = 4096 } = {},
|
{ sequences = [], extent = 4096 } = {},
|
||||||
) {
|
) {
|
||||||
const pbf = new PbfWriter();
|
const pbf = new PbfWriter();
|
||||||
if (sequences.length) {
|
if (sequences.length) {
|
||||||
@@ -111,17 +111,5 @@ export function encodeCoverageTile(
|
|||||||
);
|
);
|
||||||
pbf.writeMessage(3, (_, out) => layer.write(out), null);
|
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());
|
return new Uint8Array(pbf.finish());
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,13 @@ import { VectorTile } from '@mapbox/vector-tile';
|
|||||||
import { tileLocalToLonLat } from '../../tileMath.js';
|
import { tileLocalToLonLat } from '../../tileMath.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode a `mly1_public` coverage tile into sequences and (z0–5) overview
|
* Decode a `mly1_public` coverage tile (z11–14) into sequences. The z14
|
||||||
* points. The z14 `image` layer, which can hold >150k points, is skipped.
|
* `image` layer, which can hold >150k points, is skipped.
|
||||||
* @param {Uint8Array} bytes
|
* @param {Uint8Array} bytes
|
||||||
* @param {{x:number,y:number,z:number}} address
|
* @param {{x:number,y:number,z:number}} address
|
||||||
*/
|
*/
|
||||||
export function decodeCoverageTile(bytes, address) {
|
export function decodeCoverageTile(bytes, address) {
|
||||||
const result = { sequences: [], overview: [] };
|
const result = { sequences: [] };
|
||||||
if (!bytes || !bytes.length) return result;
|
if (!bytes || !bytes.length) return result;
|
||||||
const tile = new VectorTile(new PbfReader(bytes));
|
const tile = new VectorTile(new PbfReader(bytes));
|
||||||
const { x, y, z } = address;
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,10 @@ function linePart(points) {
|
|||||||
return out;
|
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 } = {}) {
|
function sampleTile({ geometry = null } = {}) {
|
||||||
const writer = new PbfWriter();
|
const writer = new PbfWriter();
|
||||||
writer.writeMessage(
|
writer.writeMessage(
|
||||||
@@ -94,7 +97,7 @@ function sampleTile({ geometry = null } = {}) {
|
|||||||
return new Uint8Array(writer.finish());
|
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 });
|
const decoded = decodeCoverageTile(sampleTile(), { x: 0, y: 0, z: 1 });
|
||||||
assert.equal(decoded.sequences.length, 1);
|
assert.equal(decoded.sequences.length, 1);
|
||||||
const [sequence] = decoded.sequences;
|
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',
|
'west half of tile 0/0 at z1',
|
||||||
);
|
);
|
||||||
assert.ok(lat0 > 0 && lat1 < lat0, 'northern hemisphere, moving south');
|
assert.ok(lat0 > 0 && lat1 < lat0, 'northern hemisphere, moving south');
|
||||||
assert.equal(decoded.overview.length, 1);
|
assert.deepEqual(Object.keys(decoded), ['sequences']);
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an empty tile decodes to empty lists', () => {
|
test('an empty tile decodes to empty lists', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
decodeCoverageTile(new Uint8Array(0), { x: 0, y: 0, z: 0 }),
|
decodeCoverageTile(new Uint8Array(0), { x: 0, y: 0, z: 0 }),
|
||||||
{
|
{ sequences: [] },
|
||||||
sequences: [],
|
|
||||||
overview: [],
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 Street Level's Mapillary imagery. */
|
||||||
|
|
||||||
/** 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';
|
|
||||||
export const MAPILLARY_KEY_ID = 'mapillary';
|
export const MAPILLARY_KEY_ID = 'mapillary';
|
||||||
export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com';
|
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([
|
export const MAPILLARY_SOURCE_METHODS = Object.freeze([
|
||||||
'getStatus',
|
'getStatus',
|
||||||
'getTile',
|
'getTile',
|
||||||
@@ -24,17 +19,15 @@ export const PICK_PREFIX = Object.freeze({
|
|||||||
image: 'mly:img:',
|
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({
|
export const COLORS = Object.freeze({
|
||||||
coverage: PROVIDER_COLORS.mapillary,
|
coverage: '#05cb63',
|
||||||
selected: SHARED_COLORS.selected,
|
selected: SHARED_COLORS.selected,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Camera-driven coverage refresh. */
|
/** Camera-driven coverage refresh. */
|
||||||
export const COVERAGE_MOVE_DEBOUNCE_MS = 320;
|
export const COVERAGE_MOVE_DEBOUNCE_MS = 320;
|
||||||
export const COVERAGE_MAX_TILES = 9;
|
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). */
|
/** Shown when Mapillary refuses the server's token (401/403). */
|
||||||
export const KEY_REJECTED_MESSAGE =
|
export const KEY_REJECTED_MESSAGE =
|
||||||
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
|
'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_MIN_M = 2_500;
|
||||||
export const SEQUENCE_VIEW_RANGE_PER_HEIGHT = 10;
|
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_MAX_SEQUENCES = 6000;
|
||||||
export const COVERAGE_LINE_WIDTH_PX = 2.5;
|
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_SIZE_PX = 26;
|
||||||
export const IMAGE_CONE_MIN_SPACING_M = 3;
|
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;
|
export const NEAREST_LIMIT = 8;
|
||||||
|
|
||||||
/** On-globe credit: Mapillary imagery is CC BY-SA 4.0 and needs attribution. */
|
/** 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 * as Cesium from 'cesium';
|
||||||
import { imageConeGlyph } from '../../glyphs.js';
|
import { imageConeGlyph } from '../../glyphs.js';
|
||||||
import { passesImageryFilter } from '../../filter.js';
|
import { passesImageryFilter, resolveFilter } from '../../filter.js';
|
||||||
import { createHorizonCull, metresBetween } from '../../view.js';
|
import { createHorizonCull, metresBetween } from '../../view.js';
|
||||||
import {
|
import {
|
||||||
COLORS,
|
COLORS,
|
||||||
@@ -53,7 +53,12 @@ export function createSequences({ state, source, parts }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function notify() {
|
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. */
|
/** Drawn cone billboards. */
|
||||||
@@ -95,7 +100,7 @@ export function createSequences({ state, source, parts }) {
|
|||||||
pano: true,
|
pano: true,
|
||||||
});
|
});
|
||||||
// Resolved now, so a "since N days" window keeps up with the clock.
|
// 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) {
|
for (const image of images) {
|
||||||
if (!passesImageryFilter(image, filter)) continue;
|
if (!passesImageryFilter(image, filter)) continue;
|
||||||
const billboard = collection.add({
|
const billboard = collection.add({
|
||||||
@@ -146,7 +151,7 @@ export function createSequences({ state, source, parts }) {
|
|||||||
state.sequence.loading = false;
|
state.sequence.loading = false;
|
||||||
state.sequence.images = cached;
|
state.sequence.images = cached;
|
||||||
renderCones(cached);
|
renderCones(cached);
|
||||||
state.context.actions.reportError(null);
|
reportError(null);
|
||||||
notify();
|
notify();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -172,15 +177,13 @@ export function createSequences({ state, source, parts }) {
|
|||||||
remember(sequenceId, images);
|
remember(sequenceId, images);
|
||||||
state.sequence.images = images;
|
state.sequence.images = images;
|
||||||
renderCones(images);
|
renderCones(images);
|
||||||
state.context.actions.reportError(null);
|
reportError(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
// Nothing to show: drop the highlight, so a click asks again.
|
// Nothing to show: drop the highlight, so a click asks again.
|
||||||
parts.coverage.recolorSequence(sequenceId, false);
|
parts.coverage.recolorSequence(sequenceId, false);
|
||||||
state.sequence.selectedId = null;
|
state.sequence.selectedId = null;
|
||||||
state.context.actions.reportError(
|
reportError(error?.message || 'Sequence images unavailable');
|
||||||
error?.message || 'Sequence images unavailable',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (state.sequence.abort === controller) {
|
if (state.sequence.abort === controller) {
|
||||||
@@ -200,7 +203,7 @@ export function createSequences({ state, source, parts }) {
|
|||||||
state.sequence.loading = false;
|
state.sequence.loading = false;
|
||||||
clearCones();
|
clearCones();
|
||||||
// An error about the sequence goes with it.
|
// An error about the sequence goes with it.
|
||||||
state.context.actions.reportError(null);
|
reportError(null);
|
||||||
requestRender();
|
requestRender();
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,11 +62,8 @@ function setup() {
|
|||||||
const state = {
|
const state = {
|
||||||
services: {},
|
services: {},
|
||||||
viewer: {},
|
viewer: {},
|
||||||
context: {
|
filter: { pano: 'all', sinceDays: 0 },
|
||||||
notify() {},
|
notify() {},
|
||||||
getFilter: () => ({ pano: 'all', sinceMs: null }),
|
|
||||||
actions: { reportError: (message) => errors.push(message) },
|
|
||||||
},
|
|
||||||
sequence: {
|
sequence: {
|
||||||
selectedId: null,
|
selectedId: null,
|
||||||
images: [],
|
images: [],
|
||||||
@@ -80,6 +77,7 @@ function setup() {
|
|||||||
coverage: {
|
coverage: {
|
||||||
recolorSequence: (id, selected) => colours.push([id, selected]),
|
recolorSequence: (id, selected) => colours.push([id, selected]),
|
||||||
},
|
},
|
||||||
|
reportSequenceError: (message) => errors.push(message),
|
||||||
};
|
};
|
||||||
const sequences = createSequences({ state, source, parts });
|
const sequences = createSequences({ state, source, parts });
|
||||||
const drawn = () =>
|
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
|
* @typedef {object} StreetPose
|
||||||
* provider-neutral pose on every image, view or position change.
|
* @property {string} imageId
|
||||||
* @returns {import('../../registry.js').ViewerAdapter}
|
* @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 } = {}) {
|
export function createMapillaryViewer({ source, render } = {}) {
|
||||||
let viewer = null;
|
let viewer = null;
|
||||||
let Library = null;
|
let Library = null;
|
||||||
let container = null;
|
let container = null;
|
||||||
let pendingOpen = 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. */
|
/** Bumped by `unmount`, so a construction it overtook builds nothing. */
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
let renderMode = 'letterbox';
|
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) {
|
function describe(image) {
|
||||||
return {
|
return {
|
||||||
imageId: String(image.id),
|
imageId: String(image.id),
|
||||||
@@ -81,7 +91,6 @@ export function createMapillaryViewer({ source, render } = {}) {
|
|||||||
if (image) current = describe(image);
|
if (image) current = describe(image);
|
||||||
if (!current || !viewer) return;
|
if (!current || !viewer) return;
|
||||||
emit({
|
emit({
|
||||||
providerId: MAPILLARY_PROVIDER_ID,
|
|
||||||
...current,
|
...current,
|
||||||
position: { lon: lngLat.lng, lat: lngLat.lat },
|
position: { lon: lngLat.lng, lat: lngLat.lat },
|
||||||
bearing: Number.isFinite(pov?.bearing) ? pov.bearing : null,
|
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 (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;
|
const built = generation;
|
||||||
destroyViewer();
|
destroyViewer();
|
||||||
const { Viewer } = await ensureLibrary();
|
const { Viewer } = await ensureLibrary();
|
||||||
@@ -178,9 +178,8 @@ export function createMapillaryViewer({ source, render } = {}) {
|
|||||||
|
|
||||||
unmount() {
|
unmount() {
|
||||||
pendingOpen = null;
|
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++;
|
generation++;
|
||||||
creating = null;
|
|
||||||
destroyViewer();
|
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'). */
|
/** Show the whole image ('letterbox') or crop it to the frame ('fill'). */
|
||||||
setRenderMode(mode) {
|
setRenderMode(mode) {
|
||||||
renderMode = mode === 'fill' ? 'fill' : 'letterbox';
|
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 * 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. */
|
/** Hover picks at most ~8 times a second (like the CCTV layer), never per frame. */
|
||||||
const HOVER_PICK_INTERVAL_MS = 120;
|
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 }) {
|
export function createSelection({ state, parts }) {
|
||||||
const { picking, input } = state.services;
|
const { picking, input } = state.services;
|
||||||
|
|
||||||
function ownsPick(pickedId) {
|
/** Lines and cones (`mly:`) and the position marker are this layer's. */
|
||||||
return parts.router.ownsPick(pickedId);
|
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) {
|
function onClick(click) {
|
||||||
const viewer = state.viewer;
|
const viewer = state.viewer;
|
||||||
if (!viewer || !state.enabled) return;
|
if (!viewer || !state.enabled || !state.providerOn) return;
|
||||||
if (input?.isPointerFree && !input.isPointerFree()) return;
|
if (input?.isPointerFree && !input.isPointerFree()) return;
|
||||||
const picked = viewer.scene.pick(click.position);
|
const picked = viewer.scene.pick(click.position);
|
||||||
const id = picking?.resolvePickId
|
const id = picking?.resolvePickId
|
||||||
? picking.resolvePickId(picked)
|
? picking.resolvePickId(picked)
|
||||||
: picked?.id;
|
: picked?.id;
|
||||||
const route = parts.router.resolve(id);
|
if (typeof id !== 'string') return;
|
||||||
if (!route?.instance) return;
|
const sequenceId = sequenceIdFromPick(id);
|
||||||
const entry = state.providers.get(route.providerId);
|
if (sequenceId) parts.sequences.select(sequenceId);
|
||||||
if (!entry?.on) return;
|
else if (id.startsWith(PICK_PREFIX.image))
|
||||||
route.instance.handlePick(route.id);
|
parts.openImage(id.slice(PICK_PREFIX.image.length));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,9 +49,9 @@ export function createSelection({ state, parts }) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
if (!parts.hasSelectedSequence()) return;
|
if (!state.sequence.selectedId) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
parts.clearSequences();
|
parts.sequences.clearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
let hoverQueued = false;
|
let hoverQueued = false;
|
||||||
|
|||||||
@@ -58,17 +58,23 @@ function harness({ selected = false } = {}) {
|
|||||||
const state = {
|
const state = {
|
||||||
viewer,
|
viewer,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
providerOn: true,
|
||||||
clickHandler: null,
|
clickHandler: null,
|
||||||
services: { picking: null, input: null },
|
services: { picking: null, input: null },
|
||||||
|
sequence: {
|
||||||
|
get selectedId() {
|
||||||
|
return sequences.selected ? 'seq-1' : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const selection = createSelection({
|
const selection = createSelection({
|
||||||
state,
|
state,
|
||||||
parts: {
|
parts: {
|
||||||
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
|
sequences: {
|
||||||
hasSelectedSequence: () => sequences.selected,
|
clearSelection() {
|
||||||
clearSequences() {
|
sequences.cleared++;
|
||||||
sequences.cleared++;
|
sequences.selected = false;
|
||||||
sequences.selected = false;
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ import { FILTER_DEFAULT } from './policy.js';
|
|||||||
export function freshStreet() {
|
export function freshStreet() {
|
||||||
return {
|
return {
|
||||||
open: false,
|
open: false,
|
||||||
providerId: null,
|
|
||||||
providerName: null,
|
|
||||||
providerLabel: null,
|
|
||||||
imageId: null,
|
imageId: null,
|
||||||
position: null,
|
position: null,
|
||||||
bearing: 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 }) {
|
export function createState({ services }) {
|
||||||
return {
|
return {
|
||||||
services,
|
services,
|
||||||
@@ -35,10 +58,10 @@ export function createState({ services }) {
|
|||||||
destroyed: false,
|
destroyed: false,
|
||||||
listeners: new Set(),
|
listeners: new Set(),
|
||||||
notify: null,
|
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 },
|
filter: { ...FILTER_DEFAULT },
|
||||||
/** @type {Map<string, {def: object, instance: object, on: boolean}>} */
|
|
||||||
providers: new Map(),
|
|
||||||
|
|
||||||
street: {
|
street: {
|
||||||
host: null,
|
host: null,
|
||||||
@@ -47,7 +70,23 @@ export function createState({ services }) {
|
|||||||
...freshStreet(),
|
...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 },
|
marker: { collection: null, billboard: null },
|
||||||
clickHandler: 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;
|
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
|
* Tiles at zoom z covering a [west, south, east, north] box (west > east
|
||||||
* `from` → `to` at zoom z, measured the short way round the date line.
|
* crosses the date line), nearest to `from` first (default: the box centre),
|
||||||
*/
|
* capped at `limit`. Distances are measured the short way round ±180°.
|
||||||
function segmentDistance2(tile, from, to, z) {
|
* @param {{limit?: number, from?: {lon: number, lat: number}|null}} [options]
|
||||||
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]
|
|
||||||
* @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}}
|
* @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 n = 2 ** z;
|
||||||
const tiles = [];
|
const centre = from || {
|
||||||
let centre;
|
lon: wrapLon(west + (east - west + (across ? 360 : 0)) / 2),
|
||||||
if (Array.isArray(bbox) && Number(bbox[0]) > Number(bbox[2])) {
|
lat: (south + north) / 2,
|
||||||
// A box across the date line comes as west > east: split it at ±180°.
|
};
|
||||||
const [west, south, east, north] = bbox.map(Number);
|
const cx = tileXAt(wrapLon(centre.lon), z);
|
||||||
for (const half of [
|
const cy = tileYAt(centre.lat, z);
|
||||||
[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;
|
|
||||||
};
|
|
||||||
// Each tile's distance is computed once; ties keep their row order.
|
// Each tile's distance is computed once; ties keep their row order.
|
||||||
const ranked = tiles
|
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)
|
.sort((a, b) => a.d - b.d)
|
||||||
.map(({ tile }) => tile);
|
.map(({ tile }) => tile);
|
||||||
const truncated = ranked.length > limit;
|
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) {
|
function tileGrid(box, z) {
|
||||||
const x0 = lonToTileX(box.west, z);
|
const x0 = lonToTileX(box.west, z);
|
||||||
const x1 = lonToTileX(box.east, z);
|
const x1 = lonToTileX(box.east, z);
|
||||||
@@ -168,7 +132,7 @@ function tileGrid(box, z) {
|
|||||||
const tiles = [];
|
const tiles = [];
|
||||||
for (let y = y0; y <= y1; y++)
|
for (let y = y0; y <= y1; y++)
|
||||||
for (let x = x0; x <= x1; x++) tiles.push({ x, y, z });
|
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]. */
|
/** 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;
|
if (heightM > 1_800) return 13;
|
||||||
return 14;
|
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 test from 'node:test';
|
||||||
import {
|
import {
|
||||||
coverageZoomForHeight,
|
coverageZoomForHeight,
|
||||||
overviewZoomForHeight,
|
|
||||||
latToTileY,
|
latToTileY,
|
||||||
lonToTileX,
|
lonToTileX,
|
||||||
normalizeBbox,
|
normalizeBbox,
|
||||||
@@ -74,16 +73,6 @@ test('coverageZoomForHeight steps from coarse to z14 as the camera descends', ()
|
|||||||
assert.equal(coverageZoomForHeight(Number.NaN), null);
|
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', () => {
|
test('longitude 180 is the last column, so a world view keeps the eastern hemisphere', () => {
|
||||||
assert.equal(lonToTileX(180, 1), 1);
|
assert.equal(lonToTileX(180, 1), 1);
|
||||||
assert.equal(lonToTileX(179.99, 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));
|
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 z = 14;
|
||||||
const n = 2 ** z;
|
const n = 2 ** z;
|
||||||
const bbox = [179.9, 0, -179.9, 0.1];
|
const bbox = [179.9, 0, -179.9, 0.1];
|
||||||
const key = (t) => `${t.x}/${t.y}`;
|
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 [north, south] = [latToTileY(0.1, z), latToTileY(0, z)];
|
||||||
const plain = tilesForBbox(bbox, z);
|
const plain = tilesForBbox(bbox, z);
|
||||||
assert.equal(plain.total, 10 * (south - north + 1), '5 columns either side');
|
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
|
// 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
|
// columns touching it come first, then the ones beside them.
|
||||||
// around them, all within two columns of the line.
|
for (const tile of plain.tiles.slice(0, 4))
|
||||||
const [mid1, mid2] = [(north + south - 1) / 2, (north + south + 1) / 2];
|
assert.ok([n - 1, 0].includes(tile.x), `${key(tile)} at the line`);
|
||||||
assert.deepEqual(
|
|
||||||
keys(plain.tiles.slice(0, 4)),
|
|
||||||
new Set([`${n - 1}/${mid1}`, `${n - 1}/${mid2}`, `0/${mid1}`, `0/${mid2}`]),
|
|
||||||
);
|
|
||||||
for (const tile of plain.tiles.slice(4, 12))
|
for (const tile of plain.tiles.slice(4, 12))
|
||||||
assert.ok(
|
assert.ok(
|
||||||
[n - 2, n - 1, 0, 1].includes(tile.x),
|
[n - 2, n - 1, 0, 1].includes(tile.x),
|
||||||
`${key(tile)} near the line`,
|
`${key(tile)} near the line`,
|
||||||
);
|
);
|
||||||
// A line of sight across the date line just north of the equator: the two
|
// From a camera metres from the line: its tile, then the one across it.
|
||||||
// tiles it crosses come first, then the two in the row below them.
|
|
||||||
const row = latToTileY(0.05, z);
|
const row = latToTileY(0.05, z);
|
||||||
const from = { lon: 179.99, lat: 0.05 };
|
const { south: rowSouth, north: rowNorth } = tileBounds(0, row, z);
|
||||||
const to = { lon: -179.99, lat: 0.05 };
|
const lat = (rowSouth + rowNorth) / 2;
|
||||||
for (const focus of [
|
const west = tilesForBbox(bbox, z, { from: { lon: 179.999, lat } });
|
||||||
{ from, to },
|
assert.deepEqual(west.tiles.slice(0, 2).map(key), [
|
||||||
{ from: to, to: from },
|
`${n - 1}/${row}`,
|
||||||
]) {
|
`0/${row}`,
|
||||||
const ranked = tilesForBbox(bbox, z, { focus }).tiles;
|
]);
|
||||||
assert.deepEqual(
|
const east = tilesForBbox(bbox, z, { from: { lon: -179.999, lat } });
|
||||||
keys(ranked.slice(0, 2)),
|
assert.deepEqual(east.tiles.slice(0, 2).map(key), [
|
||||||
new Set([`${n - 1}/${row}`, `0/${row}`]),
|
`0/${row}`,
|
||||||
);
|
`${n - 1}/${row}`,
|
||||||
assert.deepEqual(
|
]);
|
||||||
keys(ranked.slice(2, 4)),
|
|
||||||
new Set([`${n - 1}/${row + 1}`, `0/${row + 1}`]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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
|
// 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 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 bbox = [-121.6, 38.57, -121.38, 38.9];
|
||||||
const z = 14;
|
const z = 14;
|
||||||
const key = (t) => `${t.x}/${t.y}`;
|
const key = (t) => `${t.x}/${t.y}`;
|
||||||
const under = `${lonToTileX(from.lon, z)}/${latToTileY(from.lat, z)}`;
|
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);
|
const plain = tilesForBbox(bbox, z, { limit: 9 }).tiles.map(key);
|
||||||
assert.equal(plain.includes(under), false, 'the old ranking drops them');
|
assert.equal(plain.includes(under), false, 'the box centre drops it');
|
||||||
const ranked = tilesForBbox(bbox, z, { limit: 9, focus: { from, to } });
|
const ranked = tilesForBbox(bbox, z, { limit: 9, from });
|
||||||
const keys = ranked.tiles.map(key);
|
assert.equal(key(ranked.tiles[0]), under, 'the tile under the camera first');
|
||||||
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(ranked.total > 9, true);
|
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
|
* Ellipsoidal height (m) of the globe terrain under the camera; null where
|
||||||
* the globe is hidden (Google 3D) or not yet loaded.
|
* 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
|
* Visible [west, south, east, north] degrees, or null. Only screen rays that
|
||||||
* hit count, so a horizon cannot inflate the box to the world.
|
* hit count, so a horizon cannot inflate the box to the world.
|
||||||
* `groundHeight` raises the ellipsoid to the ground, `maxRange` (m) drops
|
* `groundHeight` raises the ellipsoid to the ground (rays to the bare
|
||||||
* near-horizon hits, and `nearRange` (m) always includes the ground around
|
* ellipsoid under Denver land kilometres too far out) and `maxRange` (m)
|
||||||
* the camera.
|
* 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(
|
export function visibleBbox(
|
||||||
viewer,
|
viewer,
|
||||||
{ grid = 5, groundHeight = null, maxRange = null, nearRange = null } = {},
|
{ grid = 5, groundHeight = null, maxRange = null } = {},
|
||||||
) {
|
) {
|
||||||
const scene = viewer?.scene;
|
const scene = viewer?.scene;
|
||||||
const camera = viewer?.camera;
|
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) {
|
if (hits.length >= 4) {
|
||||||
let west = Infinity;
|
let west = Infinity;
|
||||||
let south = Infinity;
|
let south = Infinity;
|
||||||
@@ -145,6 +115,7 @@ export function visibleBbox(
|
|||||||
}
|
}
|
||||||
if (west !== east && north - south > 0) return [west, south, east, north];
|
if (west !== east && north - south > 0) return [west, south, east, north];
|
||||||
}
|
}
|
||||||
|
if (Number.isFinite(maxRange)) return null;
|
||||||
const rectangle = camera.computeViewRectangle?.(scene.globe?.ellipsoid);
|
const rectangle = camera.computeViewRectangle?.(scene.globe?.ellipsoid);
|
||||||
if (!rectangle) return null;
|
if (!rectangle) return null;
|
||||||
return [
|
return [
|
||||||
@@ -166,50 +137,17 @@ export function viewCentre(viewer) {
|
|||||||
if (lon > 180) lon -= 360;
|
if (lon > 180) lon -= 360;
|
||||||
return { lat: (bbox[1] + bbox[3]) / 2, lon };
|
return { lat: (bbox[1] + bbox[3]) / 2, lon };
|
||||||
}
|
}
|
||||||
const carto = viewer.camera?.positionCartographic;
|
return cameraNadir(viewer);
|
||||||
if (!carto) return null;
|
|
||||||
return {
|
|
||||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
|
||||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** The point on the ground under the camera, as {lon, lat} degrees. */
|
||||||
* Ground the camera looks at, for ranking tiles: `nadir` under the camera and
|
export function cameraNadir(viewer) {
|
||||||
* `ahead` at the screen centre (null on a miss or beyond `maxRange`).
|
const carto = viewer?.camera?.positionCartographic;
|
||||||
*/
|
|
||||||
export function viewFocus(
|
|
||||||
viewer,
|
|
||||||
{ groundHeight = null, maxRange = null } = {},
|
|
||||||
) {
|
|
||||||
const camera = viewer?.camera;
|
|
||||||
const carto = camera?.positionCartographic;
|
|
||||||
if (!carto) return null;
|
if (!carto) return null;
|
||||||
const nadir = {
|
return {
|
||||||
lon: Cesium.Math.toDegrees(carto.longitude),
|
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||||
lat: Cesium.Math.toDegrees(carto.latitude),
|
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 test from 'node:test';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import {
|
import {
|
||||||
|
cameraNadir,
|
||||||
createHorizonCull,
|
createHorizonCull,
|
||||||
groundUnderCamera,
|
groundUnderCamera,
|
||||||
metresBetween,
|
metresBetween,
|
||||||
viewCentre,
|
viewCentre,
|
||||||
viewFocus,
|
|
||||||
visibleBbox,
|
visibleBbox,
|
||||||
} from './view.js';
|
} from './view.js';
|
||||||
import { rayCamera } from '../../testSupport/streetLevelFakes.mjs';
|
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.
|
// 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[2] - unranged[0] > 0.2);
|
||||||
assert.ok(unranged[1] > ahead, 'the old box missed the street in view');
|
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);
|
const [west, south, east, north] = visibleBbox(view, options);
|
||||||
assert.ok(north - south < 0.08 && east - west < 0.08, 'a street-sized box');
|
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');
|
assert.ok(south < ahead && north > ahead, 'the screen centre is inside');
|
||||||
const focus = viewFocus(view, options);
|
|
||||||
assert.ok(Math.abs(focus.nadir.lat - 39.74) < 1e-9);
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
Math.abs(focus.ahead.lat - ahead) < 0.002,
|
south > 39.74,
|
||||||
'centre ray on the street',
|
'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({
|
const view = pinhole({
|
||||||
lon: -121.4944,
|
lon: -121.4944,
|
||||||
lat: 38.5816,
|
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, {
|
const [west, south, east, north] = visibleBbox(view, {
|
||||||
groundHeight: 10,
|
groundHeight: 10,
|
||||||
maxRange: 2500,
|
maxRange: 2500,
|
||||||
nearRange: 1000,
|
|
||||||
});
|
});
|
||||||
assert.ok(north - 38.5816 > 0.008 && 38.5816 - south > 0.008);
|
assert.ok(south > 38.5816 && south - 38.5816 < 0.001, 'from just ahead');
|
||||||
assert.ok(east - -121.4944 > 0.008 && -121.4944 - west > 0.008);
|
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', () => {
|
test('metresBetween measures the short way round the date line', () => {
|
||||||
|
|||||||
@@ -1,31 +1,25 @@
|
|||||||
import { freshStreet } from './state.js';
|
import { freshStreet } from './state.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns the panel's viewer element and the provider adapter mounted in it.
|
* Owns the panel's viewer element and the photo viewer mounted in it (the
|
||||||
* Adapter poses update `state.street` and the marker.
|
* 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 }) {
|
export function createViewerHost({ state, parts, adapter }) {
|
||||||
/** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */
|
/** Set once mounted, until `unmount`. */
|
||||||
let active = null;
|
let unsubscribe = null;
|
||||||
/** @type {{id: string, promise: Promise<object>}|null} */
|
/** The mount in flight, shared by concurrent opens. */
|
||||||
let mounting = null;
|
let mounting = null;
|
||||||
let openSeq = 0;
|
let openSeq = 0;
|
||||||
/** Prewarmed, inactive adapters; each holds a live viewer (WebGL context) until `unmount`. */
|
|
||||||
const warmed = new Map();
|
|
||||||
|
|
||||||
function notify() {
|
function notify() {
|
||||||
state.notify?.();
|
state.notify?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
function providerEntry(providerId) {
|
|
||||||
return state.providers.get(providerId) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPose(pose) {
|
function applyPose(pose) {
|
||||||
if (!active || !state.street.open || pose.providerId !== active.id) return;
|
if (!unsubscribe || !state.street.open) return;
|
||||||
const previousSequence = state.street.sequenceId;
|
const previousSequence = state.street.sequenceId;
|
||||||
Object.assign(state.street, {
|
Object.assign(state.street, {
|
||||||
providerId: pose.providerId,
|
|
||||||
imageId: pose.imageId,
|
imageId: pose.imageId,
|
||||||
position: pose.position ? { ...pose.position } : null,
|
position: pose.position ? { ...pose.position } : null,
|
||||||
bearing: Number.isFinite(pose.bearing) ? pose.bearing : null,
|
bearing: Number.isFinite(pose.bearing) ? pose.bearing : null,
|
||||||
@@ -46,63 +40,43 @@ export function createViewerHost({ state, parts }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function selectCurrentSequence() {
|
function selectCurrentSequence() {
|
||||||
const { sequenceId, providerId } = state.street;
|
const { sequenceId } = state.street;
|
||||||
const entry = providerEntry(providerId);
|
if (!sequenceId || !state.enabled) return;
|
||||||
if (!sequenceId || !entry?.instance.selectSequence || !state.enabled)
|
if (state.sequence.selectedId !== sequenceId)
|
||||||
return;
|
parts.sequences.select(sequenceId);
|
||||||
const current = entry.instance.sequenceStats?.()?.selectedId;
|
|
||||||
if (current !== sequenceId) entry.instance.selectSequence(sequenceId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mount a provider's adapter. It becomes `active` only on success, so a
|
* Mount the viewer. It counts as mounted only on success, so a failed
|
||||||
* failed mount is retried on the next open; concurrent opens share one.
|
* mount is retried on the next open; concurrent opens share one.
|
||||||
*/
|
*/
|
||||||
function mount(entry) {
|
function mount() {
|
||||||
if (active?.id === entry.def.id) return Promise.resolve(active.adapter);
|
if (unsubscribe) return Promise.resolve();
|
||||||
if (mounting?.id === entry.def.id) return mounting.promise;
|
if (mounting) return mounting;
|
||||||
if (active) {
|
|
||||||
active.adapter.close();
|
|
||||||
active.unsubscribe();
|
|
||||||
active.adapter.unmount();
|
|
||||||
active = null;
|
|
||||||
}
|
|
||||||
const adapter = entry.instance.viewer;
|
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
await adapter.mount(state.street.host);
|
await adapter.mount(state.street.host);
|
||||||
if (mounting?.promise !== promise) {
|
if (mounting !== promise) {
|
||||||
// Unmounted (layer off, provider switched) while loading. A newer
|
// Unmounted (layer off) while loading. A newer mount shares the
|
||||||
// mount of this provider shares the adapter and its viewer: tearing
|
// viewer: tearing it down here would leave that mount without one.
|
||||||
// it down here would leave that mount active with no viewer.
|
if (!mounting && !unsubscribe) adapter.unmount();
|
||||||
const reused =
|
|
||||||
active?.id === entry.def.id || mounting?.id === entry.def.id;
|
|
||||||
if (!reused) adapter.unmount();
|
|
||||||
throw new Error('Street-level viewer was closed');
|
throw new Error('Street-level viewer was closed');
|
||||||
}
|
}
|
||||||
// Listen only once current: an outdated mount releasing the same
|
// Listen only once current: an outdated mount releasing the same
|
||||||
// `applyPose` would otherwise unhook the mount that replaced it.
|
// `applyPose` would otherwise unhook the mount that replaced it.
|
||||||
const unsubscribe = adapter.onPose(applyPose);
|
unsubscribe = adapter.onPose(applyPose);
|
||||||
active = { id: entry.def.id, adapter, unsubscribe };
|
|
||||||
warmed.delete(entry.def.id);
|
|
||||||
adapter.setRenderMode?.(state.street.renderMode);
|
adapter.setRenderMode?.(state.street.renderMode);
|
||||||
return adapter;
|
|
||||||
})();
|
})();
|
||||||
mounting = { id: entry.def.id, promise };
|
mounting = promise;
|
||||||
promise.then(
|
const settle = () => {
|
||||||
() => {
|
if (mounting === promise) mounting = null;
|
||||||
if (mounting?.promise === promise) mounting = null;
|
};
|
||||||
},
|
promise.then(settle, settle);
|
||||||
() => {
|
|
||||||
if (mounting?.promise === promise) mounting = null;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open an image: true once its first pose is in, false if it failed or was overtaken. */
|
/** Open an image: true once its first pose is in, false if it failed or was overtaken. */
|
||||||
async function open(providerId, imageId) {
|
async function open(imageId) {
|
||||||
const entry = providerEntry(providerId);
|
if (!imageId) return false;
|
||||||
if (!entry || !imageId) return false;
|
|
||||||
if (!state.street.host) {
|
if (!state.street.host) {
|
||||||
// A nearest-image lookup may have set it; nothing is loading now.
|
// A nearest-image lookup may have set it; nothing is loading now.
|
||||||
state.street.loading = false;
|
state.street.loading = false;
|
||||||
@@ -114,17 +88,10 @@ export function createViewerHost({ state, parts }) {
|
|||||||
const current = () => seq === openSeq;
|
const current = () => seq === openSeq;
|
||||||
// Claimed now, honoured after loading only if nothing newer took the camera.
|
// Claimed now, honoured after loading only if nothing newer took the camera.
|
||||||
const ticket = parts.framing.begin();
|
const ticket = parts.framing.begin();
|
||||||
Object.assign(state.street, {
|
Object.assign(state.street, { loading: true, error: null, open: true });
|
||||||
loading: true,
|
|
||||||
error: null,
|
|
||||||
open: true,
|
|
||||||
providerId,
|
|
||||||
providerName: entry.def.name,
|
|
||||||
providerLabel: entry.def.label,
|
|
||||||
});
|
|
||||||
notify();
|
notify();
|
||||||
try {
|
try {
|
||||||
const adapter = await mount(entry);
|
await mount();
|
||||||
if (!current()) return false;
|
if (!current()) return false;
|
||||||
await adapter.open(String(imageId));
|
await adapter.open(String(imageId));
|
||||||
if (!current()) return false;
|
if (!current()) return false;
|
||||||
@@ -141,11 +108,11 @@ export function createViewerHost({ state, parts }) {
|
|||||||
return true;
|
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() {
|
function close() {
|
||||||
openSeq++;
|
openSeq++;
|
||||||
parts.framing.cancel();
|
parts.framing.cancel();
|
||||||
active?.adapter.close();
|
if (unsubscribe) adapter.close();
|
||||||
Object.assign(state.street, freshStreet());
|
Object.assign(state.street, freshStreet());
|
||||||
parts.marker.clear();
|
parts.marker.clear();
|
||||||
notify();
|
notify();
|
||||||
@@ -158,7 +125,7 @@ export function createViewerHost({ state, parts }) {
|
|||||||
|
|
||||||
function resize() {
|
function resize() {
|
||||||
try {
|
try {
|
||||||
active?.adapter.resize();
|
if (unsubscribe) adapter.resize();
|
||||||
} catch {
|
} catch {
|
||||||
/* no-op */
|
/* no-op */
|
||||||
}
|
}
|
||||||
@@ -167,61 +134,21 @@ export function createViewerHost({ state, parts }) {
|
|||||||
function setRenderMode(mode) {
|
function setRenderMode(mode) {
|
||||||
state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox';
|
state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox';
|
||||||
try {
|
try {
|
||||||
active?.adapter.setRenderMode?.(state.street.renderMode);
|
if (unsubscribe) adapter.setRenderMode?.(state.street.renderMode);
|
||||||
} catch {
|
} catch {
|
||||||
/* adapter not ready */
|
/* adapter not ready */
|
||||||
}
|
}
|
||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stand active providers' viewers up ahead of the first image. */
|
/** Close the image and destroy the viewer (and its WebGL context). */
|
||||||
async function prewarm(entries) {
|
function unmount() {
|
||||||
const host = state.street.host;
|
close();
|
||||||
if (!host || !state.enabled) return;
|
mounting = null;
|
||||||
for (const entry of entries) {
|
unsubscribe?.();
|
||||||
const adapter = entry.instance.viewer;
|
unsubscribe = null;
|
||||||
if (!adapter.prewarm || !entry.on) continue;
|
adapter.unmount();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tear down every viewer, or only `providerId`'s (active or prewarmed). */
|
return { attach, open, close, resize, setRenderMode, unmount };
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -33,27 +33,16 @@ const { createMapillaryViewer } =
|
|||||||
/**
|
/**
|
||||||
* A viewer host over `adapter`. `marker` records where the position marker
|
* A viewer host over `adapter`. `marker` records where the position marker
|
||||||
* was put (null once cleared), `framing` counts framing calls and
|
* 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) {
|
function harness(adapter) {
|
||||||
const state = {
|
const state = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
notify() {},
|
notify() {},
|
||||||
providers: new Map(),
|
|
||||||
street: { host: {}, renderMode: 'letterbox' },
|
street: { host: {}, renderMode: 'letterbox' },
|
||||||
|
sequence: { selectedId: null },
|
||||||
};
|
};
|
||||||
const def = { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' };
|
|
||||||
const selected = [];
|
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 framing = { begun: 0, started: 0, cancelled: 0 };
|
||||||
const marker = [];
|
const marker = [];
|
||||||
const parts = {
|
const parts = {
|
||||||
@@ -67,17 +56,23 @@ function harness(adapter) {
|
|||||||
frame: (ticket) => ticket && framing.started++,
|
frame: (ticket) => ticket && framing.started++,
|
||||||
cancel: () => framing.cancelled++,
|
cancel: () => framing.cancelled++,
|
||||||
},
|
},
|
||||||
|
sequences: {
|
||||||
|
select(id) {
|
||||||
|
selected.push(id);
|
||||||
|
state.sequence.selectedId = id;
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
state,
|
state,
|
||||||
framing,
|
framing,
|
||||||
marker,
|
marker,
|
||||||
selected,
|
selected,
|
||||||
/** Esc on the map: the provider drops its selected sequence. */
|
/** Esc on the map: the selected sequence is dropped. */
|
||||||
clearSequence() {
|
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. */
|
/** A pose event from the library, as MapillaryJS fires them. */
|
||||||
emitPose(id, overrides = {}) {
|
emitPose(id, overrides = {}) {
|
||||||
emit?.({
|
emit?.({
|
||||||
providerId: 'mapillary',
|
|
||||||
imageId: id,
|
imageId: id,
|
||||||
sequenceId: `seq-${id}`,
|
sequenceId: `seq-${id}`,
|
||||||
position: { lon: 1, lat: 2 },
|
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 () => {
|
test('a failed mount is retried on the next open instead of being cached', async () => {
|
||||||
const adapter = fakeAdapter({ failMounts: 1 });
|
const adapter = fakeAdapter({ failMounts: 1 });
|
||||||
const { state, host } = harness(adapter);
|
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(state.street.error, 'library failed to load');
|
||||||
assert.equal(adapter.calls.listeners, 0, 'the pose listener was released');
|
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(state.street.error, null);
|
||||||
assert.equal(adapter.calls.mount, 2);
|
assert.equal(adapter.calls.mount, 2);
|
||||||
assert.deepEqual(adapter.calls.open, ['b']);
|
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 () => {
|
test('concurrent opens share one mount and one pose listener', async () => {
|
||||||
const adapter = fakeAdapter();
|
const adapter = fakeAdapter();
|
||||||
const { host } = harness(adapter);
|
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.mount, 1);
|
||||||
assert.equal(adapter.calls.listeners, 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 () => {
|
test('unmounting while a mount is in flight does not leave it active', async () => {
|
||||||
const adapter = fakeAdapter();
|
const adapter = fakeAdapter();
|
||||||
const { state, host } = harness(adapter);
|
const { state, host } = harness(adapter);
|
||||||
const opening = host.open('mapillary', 'a');
|
const opening = host.open('a');
|
||||||
host.unmount();
|
host.unmount();
|
||||||
await opening;
|
await opening;
|
||||||
assert.equal(adapter.calls.listeners, 0);
|
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);
|
assert.equal(state.street.open, false);
|
||||||
// Not left active: the next open mounts again.
|
// 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(adapter.calls.mount, 2);
|
||||||
assert.equal(state.street.imageId, 'b');
|
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 () => {
|
test('a pose that arrives after the photo was closed does not bring it back', async () => {
|
||||||
const adapter = fakeAdapter();
|
const adapter = fakeAdapter();
|
||||||
const { state, host } = harness(adapter);
|
const { state, host } = harness(adapter);
|
||||||
await host.open('mapillary', 'a');
|
await host.open('a');
|
||||||
host.close();
|
host.close();
|
||||||
const before = { ...state.street };
|
const before = { ...state.street };
|
||||||
// The library's `image` event for a photo that was still loading.
|
// 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 gate = deferred();
|
||||||
const adapter = fakeAdapter({ gate });
|
const adapter = fakeAdapter({ gate });
|
||||||
const { state, host } = harness(adapter);
|
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 …
|
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();
|
gate.resolve();
|
||||||
await Promise.all([first, second]);
|
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(adapter.calls.listeners, 1);
|
||||||
assert.equal(state.street.error, null);
|
assert.equal(state.street.error, null);
|
||||||
assert.equal(state.street.imageId, 'b');
|
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.error, null, 'later opens are not stuck');
|
||||||
assert.equal(state.street.imageId, 'c');
|
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 () => {
|
test('every pose the viewer reports moves the marker; only the open frames the camera', async () => {
|
||||||
const adapter = fakeAdapter();
|
const adapter = fakeAdapter();
|
||||||
const { host, marker, framing } = harness(adapter);
|
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 }]);
|
assert.deepEqual(marker, [{ position: { lon: 1, lat: 2 }, bearing: 90 }]);
|
||||||
// The user walks on: the marker goes along, the globe camera stays.
|
// The user walks on: the marker goes along, the globe camera stays.
|
||||||
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 180 });
|
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 openGate = deferred();
|
||||||
const adapter = fakeAdapter({ openGate });
|
const adapter = fakeAdapter({ openGate });
|
||||||
const { state, host, selected, clearSequence } = harness(adapter);
|
const { state, host, selected, clearSequence } = harness(adapter);
|
||||||
const opening = host.open('mapillary', 'a');
|
const opening = host.open('a');
|
||||||
await settle();
|
await settle();
|
||||||
assert.equal(state.street.loading, true, 'the image is downloading');
|
assert.equal(state.street.loading, true, 'the image is downloading');
|
||||||
assert.equal(state.street.sequenceId, 'seq-a', 'its pose is in');
|
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 () => {
|
test('closing the photo clears the marker', async () => {
|
||||||
const adapter = fakeAdapter();
|
const adapter = fakeAdapter();
|
||||||
const { host, marker } = harness(adapter);
|
const { host, marker } = harness(adapter);
|
||||||
await host.open('mapillary', 'a');
|
await host.open('a');
|
||||||
assert.notEqual(marker.at(-1), null);
|
assert.notEqual(marker.at(-1), null);
|
||||||
host.close();
|
host.close();
|
||||||
assert.equal(marker.at(-1), null);
|
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 gate = deferred();
|
||||||
const adapter = fakeAdapter({ gate });
|
const adapter = fakeAdapter({ gate });
|
||||||
const { state, host, framing, selected } = harness(adapter);
|
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();
|
await settle();
|
||||||
host.close();
|
host.close();
|
||||||
gate.resolve();
|
gate.resolve();
|
||||||
@@ -322,7 +276,7 @@ test('an open still mounting when the photo is closed does nothing when the moun
|
|||||||
[false, false, null],
|
[false, false, null],
|
||||||
);
|
);
|
||||||
// The mounted viewer serves the next open.
|
// 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');
|
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 openGate = deferred();
|
||||||
const adapter = fakeAdapter({ openGate });
|
const adapter = fakeAdapter({ openGate });
|
||||||
const { state, host, framing, selected } = harness(adapter);
|
const { state, host, framing, selected } = harness(adapter);
|
||||||
const opening = host.open('mapillary', 'a');
|
const opening = host.open('a');
|
||||||
await settle();
|
await settle();
|
||||||
assert.deepEqual(adapter.calls.open, ['a'], 'the image is downloading');
|
assert.deepEqual(adapter.calls.open, ['a'], 'the image is downloading');
|
||||||
host.close();
|
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 { gate, viewers } = fakeLibrary();
|
||||||
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
||||||
const { state, host } = harness(adapter);
|
const { state, host } = harness(adapter);
|
||||||
const first = host.open('mapillary', 'img1');
|
const first = host.open('img1');
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
host.unmount();
|
host.unmount();
|
||||||
const second = host.open('mapillary', 'img2');
|
const second = host.open('img2');
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
gate.resolve();
|
gate.resolve();
|
||||||
await Promise.all([first, second]);
|
await Promise.all([first, second]);
|
||||||
assert.equal(state.street.error, null);
|
assert.equal(state.street.error, null);
|
||||||
assert.equal(state.street.imageId, 'img2');
|
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.error, null, 'later opens are not stuck');
|
||||||
assert.equal(state.street.imageId, 'img3');
|
assert.equal(state.street.imageId, 'img3');
|
||||||
assert.equal(viewers.live, 1);
|
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');
|
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 () => {
|
test('the Mapillary viewer leaves resizing to the panel, so a hidden one never asks for z=NaN', async () => {
|
||||||
const { gate, viewers } = fakeLibrary();
|
const { gate, viewers } = fakeLibrary();
|
||||||
gate.resolve();
|
gate.resolve();
|
||||||
@@ -467,7 +407,7 @@ test('closing the photo stops sequence playback in the hidden viewer', async ()
|
|||||||
gate.resolve();
|
gate.resolve();
|
||||||
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
||||||
const { host } = harness(adapter);
|
const { host } = harness(adapter);
|
||||||
await host.open('mapillary', 'img1');
|
await host.open('img1');
|
||||||
const [viewer] = viewers.instances;
|
const [viewer] = viewers.instances;
|
||||||
assert.equal(viewer.playback.stops, 0);
|
assert.equal(viewer.playback.stops, 0);
|
||||||
host.close();
|
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 () => {
|
test('closing the photo or switching the layer off stops its framing flight', async () => {
|
||||||
const adapter = fakeAdapter();
|
const adapter = fakeAdapter();
|
||||||
const { framing, host } = harness(adapter);
|
const { framing, host } = harness(adapter);
|
||||||
await host.open('mapillary', 'a');
|
await host.open('a');
|
||||||
assert.equal(framing.started, 1, 'the open framed the photo');
|
assert.equal(framing.started, 1, 'the open framed the photo');
|
||||||
host.close();
|
host.close();
|
||||||
assert.equal(framing.cancelled, 1, 'closed: the globe stops flying to it');
|
assert.equal(framing.cancelled, 1, 'closed: the globe stops flying to it');
|
||||||
await host.open('mapillary', 'b');
|
await host.open('b');
|
||||||
host.unmount('panoramax');
|
|
||||||
assert.equal(framing.cancelled, 1, 'another provider leaves it alone');
|
|
||||||
host.unmount();
|
host.unmount();
|
||||||
assert.equal(framing.cancelled, 2, 'layer off: likewise');
|
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 openGate = deferred();
|
||||||
const adapter = fakeAdapter({ openGate });
|
const adapter = fakeAdapter({ openGate });
|
||||||
const { host, framing } = harness(adapter);
|
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');
|
assert.equal(framing.begun, 1, 'claimed before the download');
|
||||||
await settle();
|
await settle();
|
||||||
assert.equal(framing.started, 0);
|
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 * as Cesium from 'cesium';
|
||||||
import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js';
|
|
||||||
|
|
||||||
const RAD = Math.PI / 180;
|
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 {
|
return {
|
||||||
id: 'mapillary',
|
calls,
|
||||||
name: 'Mapillary',
|
hasToken: () => true,
|
||||||
label: 'MAPILLARY',
|
getStatus: async () => ({ configured: true }),
|
||||||
on: true,
|
getTile: async (...args) => {
|
||||||
configured: true,
|
calls.tiles.push(args);
|
||||||
keyRequired: false,
|
return new Uint8Array(0);
|
||||||
requiresKeyId: 'mapillary',
|
},
|
||||||
loading: false,
|
getSequenceImages: async () => [],
|
||||||
count: 0,
|
nearestImages: async () => [],
|
||||||
hint: '',
|
|
||||||
error: null,
|
|
||||||
color: '#05cb63',
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The smallest provider the core accepts. Mutate `stats` to change what
|
* A photo viewer in place of MapillaryJS: `calls` records use, and `open`
|
||||||
* coverageStats() answers; `calls`, `filters` and `context()` record use.
|
* reports a pose for the image (when `pose` is given) as the real one does.
|
||||||
*/
|
*/
|
||||||
export function fakeStreetLevelProvider({
|
export function fakePhotoViewer({ pose = null } = {}) {
|
||||||
id = 'mapillary',
|
const calls = { mount: 0, open: [], unmount: 0 };
|
||||||
pickPrefix = 'mly:',
|
let emit = null;
|
||||||
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;
|
|
||||||
return {
|
return {
|
||||||
calls,
|
calls,
|
||||||
filters,
|
mount: async () => calls.mount++,
|
||||||
stats,
|
async open(imageId) {
|
||||||
context: () => context,
|
calls.open.push(imageId);
|
||||||
id,
|
if (pose) emit?.({ imageId, ...pose });
|
||||||
name: 'Mapillary',
|
},
|
||||||
label: 'MAPILLARY',
|
close() {},
|
||||||
requiresKeyId: null,
|
unmount: () => calls.unmount++,
|
||||||
pickPrefix,
|
resize() {},
|
||||||
colors: { coverage: '#05cb63' },
|
onPose(listener) {
|
||||||
credit: { html },
|
emit = listener;
|
||||||
create: (providerContext) => {
|
return () => {
|
||||||
context = providerContext;
|
emit = null;
|
||||||
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: () => () => {},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -294,8 +294,8 @@ for (const [why, retryAfter] of [
|
|||||||
|
|
||||||
test('normalizeTileAddress enforces layer names, zoom ranges and tile bounds', () => {
|
test('normalizeTileAddress enforces layer names, zoom ranges and tile bounds', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
normalizeTileAddress({ layer: 'coverage', z: 3, x: 1, y: 2 }).key,
|
normalizeTileAddress({ layer: 'coverage', z: 11, x: 1, y: 2 }).key,
|
||||||
'coverage/3/1/2',
|
'coverage/11/1/2',
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
normalizeTileAddress({ layer: 'coverage', z: '14', x: '5', y: '6' })
|
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: 'points', z: 14, x: 1, y: 1 },
|
||||||
{ layer: 'signs', 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: 15, x: 1, y: 1 },
|
||||||
{ layer: 'coverage', z: 2, x: 4, y: 0 },
|
{ layer: 'coverage', z: 11, x: 2048, y: 0 },
|
||||||
{ layer: 'coverage', z: 2, x: 1.5, y: 0 },
|
{ layer: 'coverage', z: 11, x: 1.5, y: 0 },
|
||||||
{ layer: 'coverage', z: -1, x: 0, y: 0 },
|
{ layer: 'coverage', z: -1, x: 0, y: 0 },
|
||||||
])
|
])
|
||||||
assert.throws(
|
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 () => {
|
test('coverage zooms the app never requests (below z11) are refused with a 400', async () => {
|
||||||
for (const z of [6, 7, 8, 9, 10])
|
for (const z of [0, 3, 5, 6, 7, 8, 9, 10])
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => normalizeTileAddress({ layer: 'coverage', z, x: 0, y: 0 }),
|
() => normalizeTileAddress({ layer: 'coverage', z, x: 0, y: 0 }),
|
||||||
TileRequestError,
|
TileRequestError,
|
||||||
`z${z}`,
|
`z${z}`,
|
||||||
);
|
);
|
||||||
for (const z of [0, 5, 11, 14])
|
for (const z of [11, 12, 13, 14])
|
||||||
assert.equal(
|
assert.equal(
|
||||||
normalizeTileAddress({ layer: 'coverage', z, x: 0, y: 0 }).z,
|
normalizeTileAddress({ layer: 'coverage', z, x: 0, y: 0 }).z,
|
||||||
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');
|
const res = await call('/api/mapillary/tiles', '/coverage/9/5/5');
|
||||||
assert.equal(res.statusCode, 400);
|
assert.equal(res.statusCode, 400);
|
||||||
assert.deepEqual(json(res), {
|
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');
|
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 { createApplicationStreetLevel } from '../app/layers/streetLevel.js';
|
||||||
import {
|
import {
|
||||||
fixtureTile,
|
fixtureTile,
|
||||||
EXPECTED_PROVIDERS,
|
LEGEND_SWATCHES,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
PARK,
|
PARK,
|
||||||
STRICT_ALLOWED_SKIPS,
|
STRICT_ALLOWED_SKIPS,
|
||||||
@@ -25,13 +25,13 @@ import { redact } from '../../scripts/qa-browserEvidence.mjs';
|
|||||||
globalThis.requestAnimationFrame ??= (callback) =>
|
globalThis.requestAnimationFrame ??= (callback) =>
|
||||||
setTimeout(() => callback(Date.now()), 0);
|
setTimeout(() => callback(Date.now()), 0);
|
||||||
|
|
||||||
test('the harness expects exactly the providers the app registers, in order', () => {
|
test('the harness expects the legend the app layer draws', () => {
|
||||||
// Any method a provider asks its source for is a no-op: only ids matter.
|
// Any method the layer asks its source for is a no-op: only the legend matters.
|
||||||
const source = new Proxy({}, { get: () => () => {} });
|
const source = new Proxy({}, { get: () => () => {} });
|
||||||
const layer = createApplicationStreetLevel({
|
const layer = createApplicationStreetLevel({
|
||||||
sources: { mapillary: source },
|
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', () => {
|
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 year = 365 * 86_400_000;
|
||||||
const old = grid.filter((s) => now - s.capturedAt > year).length;
|
const old = grid.filter((s) => now - s.capturedAt > year).length;
|
||||||
assert.ok(old > 0 && old < 8, 'recent and older both present');
|
assert.ok(old > 0 && old < 8, 'recent and older both present');
|
||||||
const orbit = decodeCoverageTile(fixtureTile(3, 1, 3, now), {
|
assert.equal(fixtureTile(3, 1, 3, now).length, 0, 'nothing from orbit');
|
||||||
x: 1,
|
assert.equal(fixtureTile(8, 1, 1, now).length, 0, 'nor in between');
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ── The real status route probe ─────────────────────────────────────── */
|
/* ── The real status route probe ─────────────────────────────────────── */
|
||||||
|
|||||||
@@ -180,15 +180,10 @@ export class StreetLevelControls {
|
|||||||
|
|
||||||
async _toggleEnabled() {
|
async _toggleEnabled() {
|
||||||
const enabled = this.actions.isEnabled?.() === true;
|
const enabled = this.actions.isEnabled?.() === true;
|
||||||
// The pill is the only switch: switching on also brings back a provider
|
// The pill is the only switch: switching on also brings back Mapillary
|
||||||
// a share link or a tool turned off, or the layer would draw nothing.
|
// if a share link or a tool turned it off, or the layer would draw nothing.
|
||||||
const dark = enabled
|
if (!enabled && this._state?.providerOn === false)
|
||||||
? []
|
this._setParams({ mapillary: true });
|
||||||
: (this._state?.providers || []).filter((entry) => entry.on === false);
|
|
||||||
if (dark.length)
|
|
||||||
this._setParams(
|
|
||||||
Object.fromEntries(dark.map((entry) => [entry.id, true])),
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
await this.actions.setEnabled?.(!enabled);
|
await this.actions.setEnabled?.(!enabled);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
REGISTERED_LAYER_IDS,
|
REGISTERED_LAYER_IDS,
|
||||||
encodeLayerStateParams,
|
encodeLayerStateParams,
|
||||||
} from '../data/layerState.js';
|
} 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 ───────────────────── */
|
/* ── 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));
|
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
|
||||||
/* ── A Street Level layer with one stand-in provider ───────────────────── */
|
/* ── A Street Level layer over a stand-in Mapillary source ─────────────── */
|
||||||
|
|
||||||
function standInProvider() {
|
|
||||||
const def = fakeStreetLevelProvider({ html: 'Mapillary' });
|
|
||||||
return { filters: def.filters, def };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Every other registered layer, as a module with no options. */
|
/** Every other registered layer, as a module with no options. */
|
||||||
function plainLayer(id) {
|
function plainLayer(id) {
|
||||||
@@ -254,8 +249,7 @@ const memoryStorage = () => {
|
|||||||
|
|
||||||
/** The production path: controls → dataManager → layer → durable layer state. */
|
/** The production path: controls → dataManager → layer → durable layer state. */
|
||||||
async function productionPanel(dom) {
|
async function productionPanel(dom) {
|
||||||
const provider = standInProvider();
|
const layer = createStreetLevelLayer({ source: fakeMapillarySource() });
|
||||||
const layer = createStreetLevelLayer({ providers: [provider.def] });
|
|
||||||
const manager = new DataLayerManager({});
|
const manager = new DataLayerManager({});
|
||||||
for (const id of REGISTERED_LAYER_IDS)
|
for (const id of REGISTERED_LAYER_IDS)
|
||||||
manager.register(id === 'street-level' ? layer : plainLayer(id));
|
manager.register(id === 'street-level' ? layer : plainLayer(id));
|
||||||
@@ -288,7 +282,7 @@ async function productionPanel(dom) {
|
|||||||
encodeLayerStateParams(params, coordinator.getDurableState());
|
encodeLayerStateParams(params, coordinator.getDurableState());
|
||||||
return (params.get('lo') || '').split('_');
|
return (params.get('lo') || '').split('_');
|
||||||
};
|
};
|
||||||
return { provider, layer, manager, coordinator, controls, share };
|
return { layer, manager, coordinator, controls, share };
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOKEN = LAYER_STATE_REGISTRY.find(
|
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', () =>
|
test('a 360° click reaches the share link through the data manager', () =>
|
||||||
withDom(async (dom) => {
|
withDom(async (dom) => {
|
||||||
const { provider, coordinator, controls, share } =
|
const { layer, coordinator, controls, share } = await productionPanel(dom);
|
||||||
await productionPanel(dom);
|
|
||||||
dom.root.querySelectorAll('[data-sl-pano]')[1].click(); // 360°
|
dom.root.querySelectorAll('[data-sl-pano]')[1].click(); // 360°
|
||||||
await settle();
|
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(
|
assert.equal(
|
||||||
coordinator.getDurableState().options['street-level'].pano,
|
coordinator.getDurableState().options['street-level'].pano,
|
||||||
'pano',
|
'pano',
|
||||||
@@ -362,35 +359,20 @@ function uiState({
|
|||||||
} = {}) {
|
} = {}) {
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
|
providerOn: on,
|
||||||
keyRequired,
|
keyRequired,
|
||||||
|
keyRejected: false,
|
||||||
filter: { pano, sinceDays: 0 },
|
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 },
|
coverage: { count: 3, loading: false, hint: '', error: null },
|
||||||
legend: legend || [
|
legend: legend || [
|
||||||
{ key: 'mapillary', label: 'Mapillary', color: '#05cb63' },
|
{ key: 'mapillary', label: 'Mapillary', color: '#05cb63' },
|
||||||
{ key: 'selected', label: 'Selected', color: '#00d4ff' },
|
{ key: 'selected', label: 'Selected', color: '#00d4ff' },
|
||||||
],
|
],
|
||||||
sequence: { providerId: null, selectedId: null, images: 0, loading: false },
|
sequence: { selectedId: null, images: 0, loading: false },
|
||||||
street: {
|
street: {
|
||||||
open,
|
open,
|
||||||
loading: false,
|
loading: false,
|
||||||
renderMode,
|
renderMode,
|
||||||
providerId: open ? 'mapillary' : null,
|
|
||||||
imageId: open ? 'img-1' : null,
|
imageId: open ? 'img-1' : null,
|
||||||
error: null,
|
error: null,
|
||||||
},
|
},
|
||||||
@@ -422,18 +404,14 @@ function stubPanel(dom, state, extraActions = {}) {
|
|||||||
return { layer, calls, controls };
|
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) => {
|
withDom(async (dom) => {
|
||||||
const status = dom.root.querySelector('#sl-status');
|
const status = dom.root.querySelector('#sl-status');
|
||||||
const on = stubPanel(dom, uiState());
|
const on = stubPanel(dom, uiState());
|
||||||
status.click();
|
status.click();
|
||||||
await settle();
|
await settle();
|
||||||
assert.deepEqual(on.calls.setEnabled, [false]);
|
assert.deepEqual(on.calls.setEnabled, [false]);
|
||||||
assert.deepEqual(
|
assert.deepEqual(on.calls.setParams, [], 'switching off keeps the switch');
|
||||||
on.calls.setParams,
|
|
||||||
[],
|
|
||||||
'switching off keeps the provider',
|
|
||||||
);
|
|
||||||
on.controls.destroy();
|
on.controls.destroy();
|
||||||
const dark = stubPanel(dom, uiState({ enabled: false, on: false }));
|
const dark = stubPanel(dom, uiState({ enabled: false, on: false }));
|
||||||
status.click();
|
status.click();
|
||||||
|
|||||||
@@ -67,13 +67,10 @@ function presentStatus(state) {
|
|||||||
: { text: 'OFF', tone: '', pressed, title };
|
: { text: 'OFF', tone: '', pressed, title };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How to add the missing key, for the provider that needs one. */
|
/** How to add the missing Mapillary key. */
|
||||||
function keyHint(state) {
|
function keyHint() {
|
||||||
const provider = (state.providers || []).find(
|
const requirement = keySetupRequirement('mapillary');
|
||||||
(entry) => entry.keyRequired === true && entry.requiresKeyId,
|
return requirement ? `Mapillary: ${requirement}` : null;
|
||||||
);
|
|
||||||
const requirement = provider && keySetupRequirement(provider.requiresKeyId);
|
|
||||||
return requirement ? `${provider.name}: ${requirement}` : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function presentViewer(state) {
|
function presentViewer(state) {
|
||||||
@@ -90,7 +87,7 @@ function presentViewer(state) {
|
|||||||
captionLeft: street.creator ? `Image by ${street.creator}` : '',
|
captionLeft: street.creator ? `Image by ${street.creator}` : '',
|
||||||
captionRight: right.join(' · '),
|
captionRight: right.join(' · '),
|
||||||
link: street.externalUrl || null,
|
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),
|
status: presentStatus(state),
|
||||||
controlsDisabled: state.keyRequired === true,
|
controlsDisabled: state.keyRequired === true,
|
||||||
error: keyMissing
|
error: keyMissing
|
||||||
? keyHint(state)
|
? keyHint()
|
||||||
: state.street.error || state.coverage.error || null,
|
: state.street.error || state.coverage.error || null,
|
||||||
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
|
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
|
||||||
since: presentSince(filter.sinceDays, now),
|
since: presentSince(filter.sinceDays, now),
|
||||||
|
|||||||
@@ -5,29 +5,23 @@ import {
|
|||||||
SINCE_STOPS,
|
SINCE_STOPS,
|
||||||
sinceStopIndex,
|
sinceStopIndex,
|
||||||
} from './streetLevelPresentation.js';
|
} from './streetLevelPresentation.js';
|
||||||
import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
|
|
||||||
|
|
||||||
const provider = providerSnapshot;
|
|
||||||
|
|
||||||
function snapshot(overrides = {}) {
|
function snapshot(overrides = {}) {
|
||||||
const base = {
|
const base = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
providerOn: true,
|
||||||
keyRequired: false,
|
keyRequired: false,
|
||||||
|
keyRejected: false,
|
||||||
filter: { pano: 'all', sinceDays: 0 },
|
filter: { pano: 'all', sinceDays: 0 },
|
||||||
providers: [provider()],
|
|
||||||
coverage: { loading: false, count: 0, hint: '', error: null },
|
coverage: { loading: false, count: 0, hint: '', error: null },
|
||||||
legend: [
|
legend: [
|
||||||
{ key: 'mapillary', label: 'Mapillary', color: '#05cb63' },
|
{ key: 'mapillary', label: 'Mapillary', color: '#05cb63' },
|
||||||
{ key: 'selected', label: 'Selected', color: '#00d4ff' },
|
{ key: 'selected', label: 'Selected', color: '#00d4ff' },
|
||||||
],
|
],
|
||||||
sequence: { providerId: null, selectedId: null, images: 0, loading: false },
|
sequence: { selectedId: null, images: 0, loading: false },
|
||||||
street: {
|
street: {
|
||||||
open: false,
|
open: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
providerId: null,
|
|
||||||
providerName: null,
|
|
||||||
providerLabel: null,
|
|
||||||
imageId: null,
|
imageId: null,
|
||||||
position: null,
|
position: null,
|
||||||
bearing: null,
|
bearing: null,
|
||||||
@@ -70,7 +64,6 @@ test('KEY REQUIRED says how to add the key instead of a raw error code', () => {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
keyRequired: true,
|
keyRequired: true,
|
||||||
coverage: { error: 'no_key' },
|
coverage: { error: 'no_key' },
|
||||||
providers: [provider({ keyRequired: true, error: 'no_key' })],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
assert.equal(view.status.text, 'KEY REQUIRED');
|
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,
|
keyRequired: true,
|
||||||
keyRejected: true,
|
keyRejected: true,
|
||||||
coverage: { error },
|
coverage: { error },
|
||||||
providers: [provider({ keyRequired: true, keyRejected: true, error })],
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
assert.equal(view.status.text, 'KEY REJECTED');
|
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)', () => {
|
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.
|
// the pill must say what is wrong, not that something is on its way.
|
||||||
const loading = { enabled: true, coverage: { loading: true } };
|
const loading = { enabled: true, coverage: { loading: true } };
|
||||||
assert.equal(
|
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');
|
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(
|
const view = presentStreetLevelPanel(
|
||||||
snapshot({
|
snapshot({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
street: {
|
street: {
|
||||||
open: true,
|
open: true,
|
||||||
providerId: 'mapillary',
|
|
||||||
providerName: 'Mapillary',
|
|
||||||
providerLabel: 'MAPILLARY',
|
|
||||||
imageId: '1814275685699406',
|
imageId: '1814275685699406',
|
||||||
creator: 'mapfool',
|
creator: 'mapfool',
|
||||||
capturedAt: Date.UTC(2023, 9, 8),
|
capturedAt: Date.UTC(2023, 9, 8),
|
||||||
|
|||||||
Reference in new issue
Block a user