Merge pull request #974 from bilawalsidhu/german/street-level-slim

refactor(street-level): one provider, docked panels, memory-only proxy
This commit is contained in:
Said Turksever authored and GitHub committed 2026-10-20 13:15:47 +01:00
commit 7164373fb3
129 files changed
+5468 -17032

No files matched your search

-6
View File
@@ -67,8 +67,6 @@ jobs:
timeout-minutes: 30
env:
GEV_REQUIRE_ALLOCATION_GATE: '0'
# Fail on a missed header press unless the panel itself moved.
QA_FAIL_ON_RETRY: '1'
steps:
- name: Check out source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -104,10 +102,6 @@ jobs:
- name: Street Level gate with Mapillary fixtures
run: npm run qa:street-level:fixtures -- --strict --url http://localhost:4173
- name: Portable panel gate
if: success() || failure()
run: npm run qa:panel-resize -- --url http://localhost:4173
- name: Preview server log
if: failure()
run: cat preview.log
+4
View File
@@ -16,6 +16,10 @@ output/
.gev-cache/
# Browser QA gate evidence (screenshots, console and network dumps).
qa-artifacts/
# Playwright MCP session logs: browser consoles can carry tokens in URLs.
.playwright-mcp/
# Local Claude Code settings and preview configs.
.claude/
.DS_Store
.gstack/
3d-models/
+14 -20
View File
@@ -317,9 +317,8 @@
[NotProniss](https://github.com/NotProniss) (#1084).
- Street Level: Panoramax is the fourth provider, keyless. Light its chip
(orange; off by default, one provider at a time): from orbit dots show
where the federated commons has pictures, and below about 60 km its
sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a
(orange; off by default, one provider at a time): below about 60 km the
federated commons' sequences draw as lines from `api.panoramax.xyz` vector tiles. A click on a
line opens that sequence's picture nearest the click without moving the
camera, flat or 360°, with cones along the sequence around it and ‹ › /
← → following the picture's own links. The 360° and since filters apply,
@@ -377,10 +376,10 @@
Maps JavaScript API) and click a street, or press OPEN STREET VIEW, to
open Google's own panorama in
the panel, with its arrows, the address and month in the caption, a link
to Google Maps and FOLLOW. Share links carry it in the provider switches
to Google Maps. Share links carry it in the provider switches
(`r.google-1`). One provider is lit at a time (lighting one switches the
other off), Mapillary by default since it is free. Street View's panoramas
are all 360°, so the IMAGERY and SINCE filters and the legend apply to
are all 360°, so the IMAGERY and SINCE filters apply to
Mapillary only and hide while GOOGLE STREET VIEW is lit; its globe marker
is a Google-blue view cone.
@@ -419,21 +418,16 @@
proposed diagnosis in #927, and [kvnloo](https://github.com/kvnloo) for identifying
the repair race. Windows/Claude Desktop Cowork confirmation remains outstanding.
- Street Level: a street-level imagery layer modelled on the iD editor's photo
overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0
imagery). One right-rail panel holds a chip per provider, shared 360°/flat and
captured-since filters, and an embedded viewer with EXPAND, FIT/FILL and
FOLLOW (Google 3D only). Coverage lines and image cones are drawn from orbit
down to a single street; on Google 3D they sit on the bare earth, refined
against the rendered mesh near the camera, so trees and buildings hide them.
Share links carry the provider switches, the filters and the panel's collapsed
state. A server proxy caches Mapillary coverage tiles (24 h, 1 GiB on disk),
refuses cross-site requests, rate-limits per IP and backs off when Mapillary
rejects the token or rate-limits.
- The CCTV and Street Level panels are portable: drag the header to float the
panel, resize it from any edge or corner, and double-click the header to dock
it again. The position persists across reloads.
- Street Level: street-level imagery from providers, Mapillary first (free
client token, CC BY-SA 4.0). A right-rail panel holds a chip per provider,
360°/flat and captured-since filters and an embedded viewer with EXPAND and
FIT/FILL. Coverage lines (below 60 km) and image cones drape on terrain and
Google 3D tiles; opening a photo frames it with one camera flight. Share
links carry the provider switches, the filters and the panel's collapsed
state. A server proxy adds the token to Mapillary coverage tiles, trims and
caches them for 24 h, refuses cross-site requests and redirects, and limits
load per IP and upstream. The panel reads KEY REQUIRED without a token and
KEY REJECTED when Mapillary refuses it.
- Add Norway's city bikes to the Bike Share layer: Oslo, Bergen and
Trondheim Bysykkel (Urban Sharing), Kolumbus Bysykkel in Stavanger and
+6 -8
View File
@@ -54,9 +54,8 @@ head -40 scripts/qa-radio.mjs # what this runnable gate proves and how to run i
Runnable gate entrypoints document what they assert and how to invoke them.
Most need a dev server; some also need a specific provider key or port. Follow
the entrypoint's header rather than assuming every matching file accepts the
same arguments. Five have an `npm run` alias: `qa:map-source-tray`,
`qa:panel-resize`, `qa:street-level`, `qa:street-level:fixtures` and
`qa:transit`.
same arguments. Four have an `npm run` alias: `qa:map-source-tray`,
`qa:street-level`, `qa:street-level:fixtures` and `qa:transit`.
If you aren't sure which gate covers your change, search `docs/CURRENT-STATE.md`
for the feature: it names the gate for many of them, and it's the authoritative
@@ -66,11 +65,10 @@ gate for the feature you changed.
> **CI covers only two of these for you.** The workflow runs the setup policy
> checks, the formatting and package-boundary checks, the unit suite and the
> production build, plus a Windows onboarding job, and against the built preview
> it runs `qa:street-level:fixtures` and `qa:panel-resize`. It does not run
> `npm run test:track` or any other `qa-*.mjs` gate — those need a live dev
> server and a browser. Include the applicable local run in your PR's
> validation evidence.
> production build, plus a Windows onboarding job. It runs neither
> `npm run test:track` nor any `qa-*.mjs` gate except the Street Level fixture
> gate (`street-level-browser` job) — they need a live dev server and a
> browser. Include the applicable local run in your PR's validation evidence.
## Share-link layer tokens
+2 -2
View File
@@ -20,10 +20,10 @@ How to read this:
| **NOAA nowCOAST observed weather** (`nowcoast.noaa.gov/geoserver/observations/{weather_radar,satellite}/ows`, WMS 1.1.1) | Rain radar (MRMS base reflectivity, contiguous US, about 4-minute updates) and Satellite clouds (GOES-19/18 Band 14 regional infrared, about 5-minute updates; global infrared mosaic, hourly). Keyless; layer metadata refreshed every 2 minutes, exact-time images cached for up to 24 hours | [NOAA disclaimer](https://oceanservice.noaa.gov/disclaimer.html) | "NOAA nowCOAST · NWS/OAR MRMS radar; NESDIS GOES and global satellite partners" |
| **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" |
| **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" |
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles kept in server memory for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer |
| **KartaView** (`api.kartaview.org` coverage tiles and photo API, `cdn.kartaview.org` image proxy, `storage*.openstreetcam.org` photos) | Street Level: coverage lines (z13–14 JSON tiles; with the imagery filter on, KartaView's filtered PNG tiles as a mask), nearest-photo lookups, the photo's sequence and the photo itself, fetched by the browser. Keyless; nothing cached beyond the session | Imagery [CC BY-SA 4.0](https://kartaview.org/terms) (KartaView terms §4); the public API needs no token | "© KartaView contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines and density points, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence |
| **Panoramax** (`api.panoramax.xyz` meta-catalogue: vector tiles and STAC search; pictures from the federated instance holding each one, e.g. `panoramax.openstreetmap.fr`, `panoramax.ign.fr`) | Street Level: coverage lines, nearest-picture lookups, a sequence's pictures as cones, and the picture itself, fetched by the browser. Keyless; tiles cached by the browser for an hour (the API's own header) | Per picture: mostly [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/), IGN's [Licence Ouverte 2.0](https://www.etalab.gouv.fr/licence-ouverte-open-licence/); the API needs no token | "© Panoramax contributors, licence per picture" on the globe while the provider is on; the caption names each picture's author and licence |
| **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors |
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
+3 -3
View File
@@ -383,7 +383,7 @@ Twenty layers and map sources. **Eighteen have a keyless path.** Some offer addi
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
| 📹 **CCTV Mesh** | ~4,000 public cameras (shared cap) projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen) · Toronto · King County (WA) · Washington (WSDOT) · Catalonia (Servei Català de Trànsit), plus opt-in New York City (NYC DOT TMC, needs your own NYC DOT agreement), opt-in Lithuania (Via Lietuva / Eismoinfo, photo licence unconfirmed) and opt-in Florida (FL511, individual personal use only; other use needs FDOT's written consent). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
@@ -425,9 +425,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
**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**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds, and the unfiltered lines show meanwhile. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): from orbit its dots show where it has pictures, and below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it under the slider; KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `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 provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `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`.
## 🎖️ Field Missions
+1 -1
View File
@@ -29,7 +29,7 @@ These are designed to be used directly in the browser (like a Mapbox public toke
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers Street View (Maps JavaScript API; add it to the key's API restrictions). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. Restrict it to the **Map Tiles API and Maps JavaScript API only**: a referrer restriction applies to the Maps JavaScript API and the client SDKs, not to the web services, so a key carrying Geocoding under one is refused with `API keys with referer restrictions cannot be used with this API`. Search does not need it — geocoding goes through the server proxies above (#363).
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota.
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy. Mapillary client tokens are meant for browser use.
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy, which sends it only to `tiles.mapillary.com` over HTTPS (an upstream redirect is refused and answered 502, never followed) and caches tiles in memory only. Mapillary client tokens are meant for browser use.
> The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these three keys. Everything else stays server-side.
+6
View File
@@ -0,0 +1,6 @@
- Street Level is rebuilt around one small provider registry: Mapillary,
KartaView, Panoramax and Google Street View share one coverage drawer and
one photo viewer, one source lit at a time. Floating and docked panels,
FOLLOW, surface modes, the legend and the Mapillary proxy's disk cache are
gone; `.gev-cache/mapillary/` from earlier runs is no longer read and can be
deleted (gekh, #974).
+2 -2
View File
@@ -356,8 +356,8 @@ drag or final change. Feature adapters own data mapping and service subscription
generic modules own DOM/listeners/timers and release them in `destroy()`.
Keep persistence with panel chrome: collapse uses
`godsEyeView.v6.panelCollapsed.<panelId>`, position uses
`godsEyeView.v8.panelPos.<panelId>`, and share state uses the registered panel id.
`godsEyeView.v6.panelCollapsed.<panelId>`, positions are
not stored (`godsEyeView.v8.panelPos.<panelId>` stays reserved), and share state uses the registered panel id.
Do not rename existing
ids or reset persistence versions when adding a body. The WEATHER adapter is
`src/ui/weatherPanel.js`; its observed-history selection remains transient.
+58 -67
View File
@@ -651,7 +651,7 @@ The UI facade composes separate owners for navigation authority, share restorati
visual settings and panel chrome. Navigation owns request generations and follow
handoff; restoration owns its coordinator, deferred notices, gestures and timer;
visual settings own style/detection overrides and sensor cleanup; panel chrome
owns disclosure, docking and Cockpit panel snapshots. Each takes explicit
owns disclosure and Cockpit panel snapshots. Each takes explicit
operations and reads instead of the complete shell. Existing controls, share
formats and one-application-per-page behavior are preserved.
@@ -981,10 +981,9 @@ start with current state, isolate listener failures and stop on disposal.
The standalone entry composes the UI with the application's existing layer,
terrain, navigation and rendering operations. The shell receives those instances
and assembles the controls. Panel layout scheduling, position preferences and
active drags, loading notices, recording presentation and DOM lookup have focused
and assembles the controls. Panel layout scheduling, loading notices, recording presentation and DOM lookup have focused
owners. Disposal revokes queued presentation and listeners before asynchronous
Context restoration, cancels an unfinished drag without saving it, restores the
Context restoration, restores the
recording HUD and releases status decoration without replacing accessible text.
The ordinary control snapshot includes the current 3D model toggle and mode.
@@ -3468,7 +3467,7 @@ its criteria cannot be silently ignored.
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Street-level imagery; Mapillary is the first provider (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, cached 24 h) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 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) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| 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) |
@@ -3500,8 +3499,8 @@ by `scripts/qa-emergency-squawk.mjs`. Both accept `--timeout-ms` for slow boot.
Fire Perimeters uses capped, timed server reads with stale-on-error caching and a per-client limit. Unchanged snapshots retain geometry; link checks cancel on disable or selection change, and the row legend shows reported containment.
Street Level is a collapsible right-rail panel (`#street-level-panel`, layer
token `0`, option owner `street-level`, panel `ui` token `t`) that starts
Street Level is a docked, collapsible right-rail panel (`#street-level-panel`,
layer token `0`, option owner `street-level`, panel `ui` token `t`) that starts
collapsed. It opens itself only when the user, voice or a tool switches the
layer on or a photo opens, never on a restore, and those opens are not stored.
Like the iD editor's photo overlay, it has one chip per registered provider,
@@ -3513,31 +3512,30 @@ sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers'
e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google
Street View, KartaView and Panoramax are registered. Without
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are
disabled. With it, coverage draws as z0–5 overview points from orbit and z11–14
sequence lines below 60 km; the proxy strips the unused `image` layer from z14
disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
requests for a tile.
Mapillary coverage ranks unique tile addresses before applying the 16-tile
overview budget. Overlapping dateline halves in a polar view therefore do not
waste overview slots on the same tile; loading already skips duplicate keys.
Coverage is z11–14 sequence lines below 60 km; above that nothing is drawn and
the panel says to zoom in. Tiles come from the visible ground within range of
the camera, nearest first, at most 9 (the halves of a view across
the date line count a shared tile once). Lines are draped
`GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain
and Google 3D tiles alike; cones and the photo marker are ground-clamped
billboards with a horizon cull. Without `MAPILLARY_CLIENT_TOKEN` the panel
reads KEY REQUIRED, says which key to add and disables the filters; a rejected
key reads KEY REJECTED and stops tile requests until the layer goes off; a 429
pauses them for its Retry-After.
On Google 3D (`photoreal`) at street zoom (in below 1,400 m above ground, out
above 1,800 m) the surface mode switches from `draped` to `terrain`: draped
lines land on roofs and tree tops, so `groundCast.js` instead places lines,
cones and the marker 2 m above the bare earth from `/api/terrain/heights`, and
the mesh hides what is behind buildings and trees. A tile is drawn draped until
its heights arrive; a geoid fallback leaves it draped. Within 900 m of the
camera, `meshSampler.js` samples the rendered surface (`scene.sampleHeight`,
~11 m cells, nearest first, 6 ms idle budget) so lines also follow trenches,
steep streets and roads under trees. It uses the mesh floor's rules (the
visible tileset has finished streaming, a real bare-earth prior, the shared
mesh window) and probes a cell again once the camera is half as far from it,
down to 40 m, so coarse early samples are replaced as finer tiles load. A
failed probe keeps the last good sample and distance and is retried after the
miss cooldown. Roads on elevated decks are still drawn at
ground level. Framing a photo ignores mesh samples far below the bare earth
(unloaded tiles).
The server proxy (`server/providers/mapillary.js`) adds the token to
coverage-tile requests (the browser holds it too, for MapillaryJS and the Graph
API), serves only z11–14 coverage, refuses upstream redirects, strips the
unused `image` layer, shares one fetch per tile, runs at most six at once
(cancelling those every client has left) and keeps tiles in memory for 24 h
(a body too big to be a trimmed tile is served but not kept). While Mapillary
rate-limits, the proxy answers 429 itself until the Retry-After (seconds or an
HTTP date, at most 10 minutes) is over, so the rest of a view does not go on
asking.
The header pill is the layer switch. Lighting a chip swaps the source;
clicking the lit chip turns the layer off. A chip without a usable key
@@ -3548,19 +3546,18 @@ their keys but a switched-off one would draw with a key already set (no
Mapillary token, Street View on Google 3D with a Google key), the layer
reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage
line and the layer row's tooltip say to light it. The
viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the
image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and
stops when the stack changes. Camera moves go through the application's
navigation (`attachNavigation`), which releases aircraft and satellite tracking
and is refused in the cockpit: turning FOLLOW on claims the camera at once, and
FOLLOW stops when another feature takes it; a photo takes a deferred ticket
when it starts opening and frames only if nothing newer took the camera while
it loaded. SINCE is a stepped slider whose readout names the
cut-off date. The panel is portable (`street-level-panel` spec, minimum
320 × 280, `dockOnCollapse`): a floating window gives spare height to the
viewer, and collapsing it, double-clicking the header or SHRINK after EXPAND
docks it. At phone width (≤720 px) the viewer height is derived from the rail
band so the whole photo fits.
viewer sits under the header with EXPAND, FIT/FILL and close above the
image. SINCE is a stepped slider whose readout names the cut-off date.
MapillaryJS loads on the first photo. Opening one frames it with a single
camera flight through the application's deferred navigation (`begin` /
`reassert`): it releases tracking, is refused in the cockpit, and is skipped
if newer navigation took the camera while the photo loaded; closing the photo
cancels it. EXPAND is the browser's Fullscreen API on the viewer element; on
iPhone, which has no element fullscreen, the viewer opens as a top-layer
popover instead (hidden where neither API exists). Clean View, recording and
the cockpit take an expanded photo out of full screen. At phone width the
viewer height comes from the rail band so the whole photo fits.
Providers implement the contract in `src/layers/streetLevel/registry.js`. The
core routes clicks by pick prefix, swaps viewer adapters in the one host,
@@ -3569,9 +3566,10 @@ add a provider: implement the definition, register it in
`src/app/layers/streetLevel.js` and add its modules to
`scripts/package-boundaries.json`; its share switch travels in `r` by id.
A provider with line coverage builds it on `lineCoverage.js` (camera-driven
XYZ tiles, draped or cast to bare earth, selection recolouring) and its cones
XYZ tiles near the ground, draped, the selected sequence drawn again over
them) and its cones
on `sequenceCones.js`, passing its tile fetch, zoom ladder, colours and pick
prefixes; Mapillary's `coverage.js` and `sequences.js` are such wrappers. With providers on different keys the
prefixes; Mapillary, KartaView and Panoramax all do. With providers on different keys the
layer's `requiresKeyId` is null and each chip reports its own key.
Google Street View (`providers/google/`) runs on the Maps JavaScript API with
@@ -3580,7 +3578,7 @@ on first use (`mapsLoader.js`, CSP `script-src https://maps.googleapis.com`).
Its chip starts off (`defaultOn: false`; a link without its switch keeps it off) because every
panorama load is billed to the key. Google's terms forbid Street View beside
non-Google maps, so the provider declares `requiresMapStack: 'photoreal'`:
off Google 3D it does not appear at all (no chip, legend swatch, globe
off Google 3D it does not appear at all (no chip, globe
credit, layer source or button, and Google's script is not loaded), lookups
and opens skip it, and leaving Google 3D closes its panorama; the share
option keeps the user's choice for when Google 3D returns. Google offers no coverage layer
@@ -3602,9 +3600,8 @@ marker is a Google-blue view cone (`marker: {kind: 'view-cone'}`) in place
of GEV's amber pin. Its
panoramas are all 360° and it draws no coverage, so it declares `filters: []`
(a provider's `filters` default to both `pano` and `since`): lookups ignore
the IMAGERY and SINCE filters, the panel hides each filter row no lit provider
honours (`filterControls`), and the legend lists only providers that draw
lines, with "Selected" only beside one of them. The viewer is Google's `StreetViewPanorama` in its own
the IMAGERY and SINCE filters, and the panel hides each filter row no lit provider
honours (`filterControls`). The viewer is Google's `StreetViewPanorama` in its own
element in the viewer host, without Google's fullscreen, close and address
controls (the address goes in the caption); a pose goes out at once and
again when the panorama's month, photographer and address arrive (one
@@ -3631,8 +3628,7 @@ are no KartaView photos in it. A tile's lines are OSM way segments with
photos and carry no sequence, date or projection, so `decode.js` chains
segments that meet end to start (about four times fewer lines; linear time,
at most 20,000 segments a tile and 5,000 points a line), they are
drawn as `kv:line:<z/x/y/n>`, up to 1,600 per tile, and never highlighted
(`highlightsSequence: false`, so the legend has no "Selected"), and the
drawn as `kv:line:<z/x/y/n>`, up to 1,600 per tile, and never highlighted, and the
provider declares `filters: ['pano', 'since']` and `coverageLabel` ("N
streets in view · click a line for its nearest photo"). KartaView filters
coverage only in its PNG tiles (`/sequence/tiles/{x}/{y}/{z}.png?fieldOfView=360|plane&startDate=YYYY-MM-DD`;
@@ -3641,8 +3637,7 @@ asked for), so a filtered tile is drawn from the cached JSON segments that
lie on the filtered PNG's drawn pixels (`maskedSegments`: at least half the
points sampled every 2 px have a drawn pixel within one; on Jakarta's 360°
filter, 332 of 1,041 segments lie on it, 676 miss it, 33 fall between).
A filter change reloads the lines (`lineCoverage.reload`: the drawn ones show
until the new ones are in), keeps the JSON segments, and caches masks per
A filter change reloads the lines (`lineCoverage.reload`), keeps the JSON segments, and caches masks per
filter and tile. A dense filtered PNG can take KartaView over a minute the
first time (it answers 504 at 60 s and keeps drawing), so a timeout or 5xx is
asked again 4 s later, four tries in all; then the panel says it did not
@@ -3669,7 +3664,7 @@ remembered; a storage timeout or 5xx is asked again. The open photo's
sequence draws as cones from the 250-photo page holding it
(`/2.0/sequence/{id}/photos`); the panel counts the drive's own photos
(`countActivePhotos`, asked once per sequence). The camera direction (the
marker, cones, FOLLOW, and a panorama's centre) is the direction of travel
marker, cones and a panorama's centre) is the direction of travel
to the next photo a few metres on (`assignCourses`): KartaView is dashcam
imagery, while its `heading` is the device compass, which reads 0 for whole
drives without one and can sit a steady 110° off inside a car. Until the
@@ -3726,23 +3721,18 @@ to 30 km and z10 to 60 km, as Mapillary's (a dense z10 tile over Paris is
line (Douglas–Peucker to the segment, 4 units of a 4,096 tile: about a
pixel where it is drawn, 6–10% of a GPS track's points), and the tiles in
view draw their longest lines within 90,000 points (`maxPoints`,
`keepOrder`), shared out once loading settles so a sparse tile keeps all
it has and a dense one (a city centre) takes the rest; the selected
sequence is always drawn. Every point becomes ~8 ground-line vertices:
`keepOrder`), split evenly between the tiles in view (10,000 points
each). Every point becomes ~8 ground-line vertices:
unsimplified, the lines hung the tab. Decoded tiles are kept while the
provider is on (16, ten minutes, `tileCache.js`, shared with KartaView),
and above
60 km the `grid` layer's 0.1°
density cells, binned to at most 96×96 points a tile (a z0 tile holds
~70,000 cells) and not counted as sequences: the panel says to zoom in.
provider is on (16, ten minutes, `tileCache.js`, shared with KartaView).
Above 60 km nothing is drawn and the panel says to zoom in.
A tile decodes in slices of about 8 ms, the page getting a turn between
them (a 2 MB z10 tile is ~200 ms of work). A busy API (429/503) holds the
tiles back for its Retry-After, which the browser cannot read here (the API
exposes no headers), so the wait doubles from 5 s to 5 minutes until it
answers; an answer may take as long as it keeps arriving (silent for 15 s
is given up), so a slow link still gets its megabyte tiles. Both
filters apply: lines carry their day and camera type, and a cell stays while
it holds pictures of the kind asked for (cells are undated). A click on a
filters apply: lines carry their day and camera type. A click on a
line asks the core (`actions.openNearest`) for that sequence's picture
nearest the click, within the camera height / 100 (50–400 m: from high up
a click lands well off a line), without framing, then any sequence's within
@@ -3791,8 +3781,9 @@ runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
from fixtures (`providers/mapillary/coverageFixture.mjs`,
`scripts/fixtures/street-level/`), and KartaView's and Panoramax's too, so it
needs no network and covers the whole photo flow. CI runs the fixture gate and `qa:panel-resize`
against a production build served by `vite preview` with a dummy token.
needs no network. It opens and closes a photo from Mapillary, KartaView and
Panoramax; stepping, EXPAND, FIT/FILL, the filters and Street View stay with
the unit tests. CI runs the fixture gate against a production build served by `vite preview` with a dummy token.
Directions is a keyless front end to the routing the voice agent already
uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the
@@ -5096,7 +5087,7 @@ pins and share links already supply complete boxes.
Pixel-mode pinch deltas receive bounded amplification before reaching the
canvas; ordinary wheel, line/page-mode and native touch-pinch paths are left
unchanged. The application scene owns and removes the relay listener.
- Z ladder: panels promote within 100–139 (renormalized on wrap), voice pill 150, toast 200, clean-view exit 300.
- Z ladder: panels around 100, voice pill 150, toast 200, clean-view exit 300.
- Panel POSITION keys are versioned `v8` (`godsEyeView.v8.panelPos.<id>`); collapsed-state keys remain `v6`. The one-time position reset clears stale DISPLAY placements that could overlap the Context rail.
- Map Source lives in the bottom Visual Presets tray. The left accordion contains no MAP STACK panel, and the `k` panel token that addressed it is gone from the share registry, so legacy `ui=k...` state takes the ordinary unknown-token skip.
- A dock popover (Visual Presets, Location) auto-dismisses on mouse-away unless pinned. Focus inside the tray defers that dismissal only when the browser reports `:focus-visible` — keyboard focus and typed-into fields hold the tray open; a mouse-clicked tile does not, because Chromium focuses a `<button>` on press.
@@ -5307,8 +5298,8 @@ easier to meet (detection is now on more often), but does not create it.
samples and constant elevation during E/N drag; one shared-floor resolution on
release; late one-shot shared-cell work is permitted during viewshed idle. The
A+B harness intentionally excludes citywide LOD assertions.
- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.<panel-id>`. Docked panels store nothing; a portable panel (CCTV, Street Level) stores `{ left, top, width, height, floating: true }` once a header drag lifts it out of the rail, and a header double-click removes it. Double presses are detected in the header's `pointerdown` handler, because its `preventDefault()` suppresses native `dblclick`. Collapsed state persists for every panel at `godsEyeView.v6.panelCollapsed.<panel-id>` (`'0'` open, `'1'` closed, absent means the panel's own default).
- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them unless they describe a portable panel's floating window; collapsed states still persist at `godsEyeView.v6.panelCollapsed.<panel-id>`.
- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.<panel-id>`, but the current rails lay panels out adaptively and write none. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.<panel-id>` (`'0'` open, `'1'` closed, absent means the panel's own default).
- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them; collapsed states still persist at `godsEyeView.v6.panelCollapsed.<panel-id>`.
- Flight/military tracked entities cache dead-reckoned positions per frame to avoid callback desync flicker.
- Aircraft 3D-model and tracking invariants are covered by `npm run test:track`; run this before touching `flights.js`, `militaryFlights.js`, `detection.js`, or `trackedReadout.js`.
- Annotation resolver behavior is pinned by `src/annotations/annotationResolver.test.mjs`; re-run that suite before changing place-resolution scoring.
+2 -3
View File
@@ -57,9 +57,8 @@ Related keys (current versions):
- Panel collapsed state: `godsEyeView.v6.panelCollapsed.<panel-id>` — `'0'` open,
`'1'` closed, absent means the panel's own default. A view opened from a share
link ignores the stored value entirely.
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — written only for a
floating portable panel (CCTV, Street Level). Deleting it, or double-clicking
the panel header, docks the panel again.
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — the current layout
writes none.
- CCTV calibration: `godsEyeView.cctv.calibration.v2`
---
-1
View File
@@ -49,7 +49,6 @@
"test": "node scripts/run-unit-tests.mjs",
"test:track": "node scripts/track-regression.mjs",
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
"qa:panel-resize": "node scripts/qa-panel-resize.mjs",
"qa:street-level": "node scripts/qa-street-level.mjs",
"qa:street-level:fixtures": "node scripts/qa-street-level.mjs --fixtures",
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
@@ -49,11 +49,6 @@ export function resetKartaViewFixture() {
nearestLookups = 0;
}
/** Nearest lookups answered since the last reset (the first got a 429). */
export function kartaViewLookups() {
return nearestLookups;
}
/** The drive's photos, south to north; every tenth is 360°, every odd one archived. */
export function kartaViewPhotos() {
const step = KV_SPACING_M / METRES_PER_DEG_LAT;
@@ -2,8 +2,8 @@ import { readFileSync } from 'node:fs';
/**
* Mapillary Graph API and image CDN answered from fixtures, covering every
* request the provider and MapillaryJS make while opening, stepping and
* closing a photo, so the gate needs no network and no real token.
* request the provider and MapillaryJS make while opening and closing a
* photo, so the gate needs no network and no real token.
*
* No `merge_cc` or `sfm_cluster` means MapillaryJS never asks for a mesh or
* cluster, and empty S2 cells mean no spatial edges, which the gate does not
@@ -21,7 +21,7 @@ export const PHOTO_LINE = Object.freeze({
north: 38.591,
});
/** Metres between consecutive photos on the line (≥ the 3 m cone thinning). */
export const PHOTO_SPACING_M = 30;
const PHOTO_SPACING_M = 30;
/** Fixture thumbnail host (answered, never reached). */
export const THUMB_HOST = 'qa-fixture.mapillary.com';
@@ -181,18 +181,6 @@ export function answerMapillaryRequest({ method, url, images }) {
: [];
return { ...json(200, { data }), known: true };
}
if (q.has('lat') && q.has('lng')) {
const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) };
const radius = Math.min(50, Number(q.get('radius')) || 50);
const limit = Number(q.get('limit')) || 10;
// In no particular order, as the real API answers.
const data = photos
.filter((image) => metresApart(at, image) <= radius)
.reverse()
.slice(0, limit)
.map((image) => withFields(imageRecord(image), fields));
return { ...json(200, { data }), known: true };
}
if (q.has('s2')) return { ...json(200, { data: [] }), known: true };
return {
...json(400, { error: 'unsupported images query' }),
+1 -1
View File
@@ -28,7 +28,7 @@ export const PNX_PARK = Object.freeze({
height: 700,
pitch: -1.4,
});
export const PNX_CREATOR = 'Fixture Mapper';
const PNX_CREATOR = 'Fixture Mapper';
const PNX_SPACING_M = 10;
const METRES_PER_DEG_LON = 111_320 * Math.cos((PNX_LINE.lat * Math.PI) / 180);
const API = 'https://api.panoramax.xyz/api';
+21 -32
View File
@@ -46,9 +46,6 @@
"scripts/qa-voice-auth-focus.mjs",
"src/codexOauthRealtime.test.mjs",
"src/voice/cloudVoiceAuth.test.mjs",
"scripts/qa-panel-resize.mjs",
"scripts/qa-panelDrag.mjs",
"src/tooling/panelResizeQa.test.mjs",
"scripts/qa-terrain-429.mjs",
"src/app/application.js",
"src/app/application.test.mjs",
@@ -182,7 +179,6 @@
"src/ui/panelRails.js",
"src/ui/leftPanelRail.js",
"src/ui/rightPanelRail.js",
"src/ui/panelResize.test.mjs",
"src/ui/panelMeasurement.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.test.mjs",
@@ -285,6 +281,7 @@
"src/ui/applicationShell.js",
"src/ui/panelLayoutController.js",
"src/ui/panelPositionControls.js",
"src/ui/panelPositionControls.test.mjs",
"src/ui/recordingControls.js",
"src/ui/shellElements.js",
"src/ui/shellFeedback.js",
@@ -913,27 +910,32 @@
"src/ui/streetLevelPresentation.test.mjs",
"src/app/layers/streetLevel.js",
"src/tooling/mapillaryProvider.test.mjs",
"src/app/layers/streetLevel.test.mjs",
"src/data/idSwitches.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/params.test.mjs",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/pickRouter.test.mjs",
"src/layers/streetLevel/providerSwitches.test.mjs",
"src/layers/streetLevel/registry.js",
"src/layers/streetLevel/registry.test.mjs",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/uiState.test.mjs",
"src/layers/streetLevel/providers/mapillary/source.test.mjs",
"src/streetLevelMarkup.test.mjs",
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/registry.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/cameraFollow.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/tileMath.js",
@@ -946,7 +948,6 @@
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/groundClick.test.mjs",
"src/app/layers/streetLevel.test.mjs",
"src/layers/streetLevel/providers/google/provider.test.mjs",
"src/layers/streetLevel/providers/google/viewer.test.mjs",
"src/layers/streetLevel/providers/google/mapsLoader.test.mjs",
@@ -956,8 +957,6 @@
"src/layers/streetLevel/providers/kartaview/decode.js",
"src/layers/streetLevel/providers/kartaview/viewer.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/kartaview/policy.test.mjs",
"src/layers/streetLevel/providers/kartaview/decode.test.mjs",
"src/layers/streetLevel/providers/kartaview/source.test.mjs",
@@ -986,8 +985,6 @@
"src/testSupport/kartaViewFakes.mjs",
"src/testSupport/googleStreetViewFakes.mjs",
"server/providers/mapillary.js",
"server/providers/mapillary/routes.js",
"server/providers/mapillary/constants.js",
"server/providers/mapillary/tiles.js",
"server/providers/mapillary/trim.js",
"src/layers/streetLevel/tileMath.test.mjs",
@@ -1000,23 +997,15 @@
"scripts/fixtures/street-level/kartaView.mjs",
"scripts/fixtures/street-level/panoramax.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/groundCast.test.mjs",
"src/layers/streetLevel/meshSampler.test.mjs",
"src/layers/streetLevel/view.test.mjs",
"src/layers/streetLevel/providers/mapillary/provider.test.mjs",
"src/layers/streetLevel/providers/mapillary/mapillary.test.mjs",
"src/layers/streetLevel/providers/mapillary/decode.test.mjs",
"src/layers/streetLevel/providers/mapillary/coverage.test.mjs",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
"src/layers/streetLevel/cameraFollow.test.mjs",
"src/layers/streetLevel/cameraFraming.test.mjs",
"src/ui/streetLevelControls.test.mjs",
"src/ui/panelDock.test.mjs",
"src/layers/streetLevel/marker.test.mjs",
"src/layers/streetLevel/viewerHost.test.mjs",
"src/layers/streetLevel/providers/mapillary/sequences.test.mjs",
"src/layers/streetLevel/sequenceCones.test.mjs",
"src/layers/streetLevel/selection.test.mjs",
"src/layers/streetLevel/index.test.mjs",
"scripts/qa-overpass-offload.mjs",
+28 -44
View File
@@ -214,8 +214,6 @@
"exports": ["./server/providers/mapillary"],
"modules": [
"server/providers/mapillary.js",
"server/providers/mapillary/routes.js",
"server/providers/mapillary/constants.js",
"server/providers/mapillary/tiles.js",
"server/providers/mapillary/trim.js",
"server/providers/common/same-site.js",
@@ -724,7 +722,6 @@
"src/ui/panelLayoutController.js",
"src/ui/panelMeasurement.js",
"src/ui/panelPositionControls.js",
"src/ui/panelResize.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.js",
"src/ui/radio.js",
@@ -757,6 +754,7 @@
"src/ui/shellFacade.js",
"src/ui/streetLevelControls.js",
"src/ui/streetLevelPresentation.js",
"src/layers/streetLevel/policy.js",
"src/ui/localSdrControls.js",
"src/layers/localAdsb/feedNames.js",
"src/ui/localSdrPresentation.js",
@@ -1383,15 +1381,13 @@
],
"modules": [
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFollow.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/pickRouter.js",
"src/data/idSwitches.js",
"src/layers/streetLevel/params.js",
@@ -1399,9 +1395,8 @@
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
@@ -1428,14 +1423,13 @@
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"scripts/qa-street-level.mjs",
"src/i18n/index.js",
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json"
"src/i18n/locales/ru.json",
"src/layers/streetLevel/providers/mapillary/policy.js"
],
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
},
@@ -2203,7 +2197,6 @@
"src/ui/panelLayoutController.js",
"src/ui/panelMeasurement.js",
"src/ui/panelPositionControls.js",
"src/ui/panelResize.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.js",
"src/ui/radio.js",
@@ -2423,24 +2416,22 @@
"src/ui/streetLevelControls.js",
"src/ui/streetLevelPresentation.js",
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFollow.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/registry.js",
"scripts/qa-street-level.mjs",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
@@ -2467,8 +2458,6 @@
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js",
@@ -2481,7 +2470,6 @@
"src/data/cameraArrival.js",
"src/layers/alpr/floors.js",
"src/data/militaryNames.js",
"src/sources/requestDeadline.js",
"src/data/local_data/osm_military_names/names.json",
"src/layers/installations/namedMarkers.js",
"src/data/infrastructureOverlayEntry.js",
@@ -2506,6 +2494,7 @@
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js",
"src/ui/statusText.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/voice/providerCommands.js",
"src/voice/providerCapabilities.js",
"src/voice/geminiSession.js",
@@ -3005,24 +2994,22 @@
"src/layers/recentImagery/thumbnails.js",
"src/maps/imageryHost.js",
"src/layers/streetLevel/index.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/cameraFollow.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js",
"src/layers/streetLevel/groundCast.js",
"src/layers/streetLevel/meshSampler.js",
"src/layers/streetLevel/pickRouter.js",
"src/layers/streetLevel/params.js",
"src/layers/streetLevel/registry.js",
"scripts/qa-street-level.mjs",
"src/layers/streetLevel/credits.js",
"src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/cameraFraming.js",
"src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js",
"src/layers/streetLevel/filter.js",
"src/layers/streetLevel/policy.js",
"src/layers/streetLevel/state.js",
"src/layers/streetLevel/providers/mapillary/policy.js",
"src/layers/streetLevel/providers/mapillary/coverage.js",
"src/layers/streetLevel/providers/mapillary/sequences.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/glyphs.js",
"src/layers/streetLevel/providers/mapillary/decode.js",
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
@@ -3049,8 +3036,6 @@
"src/layers/streetLevel/imageSize.js",
"src/layers/streetLevel/tileCache.js",
"src/layers/streetLevel/panorama.js",
"src/layers/streetLevel/lineCoverage.js",
"src/layers/streetLevel/sequenceCones.js",
"src/layers/streetLevel/providers/mapillary/source.js",
"src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js",
@@ -3063,7 +3048,6 @@
"src/data/cameraArrival.js",
"src/layers/alpr/floors.js",
"src/data/militaryNames.js",
"src/sources/requestDeadline.js",
"src/data/local_data/osm_military_names/names.json",
"src/layers/installations/namedMarkers.js",
"src/data/infrastructureOverlayEntry.js",
@@ -3081,7 +3065,8 @@
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js",
"src/ui/statusText.js",
"src/ui/layerNames.js"
"src/ui/layerNames.js",
"src/layers/streetLevel/providers/mapillary/policy.js"
],
"external": [
"hls.js",
@@ -3245,7 +3230,6 @@
"src/ui/panelLayoutController.js",
"src/ui/panelMeasurement.js",
"src/ui/panelPositionControls.js",
"src/ui/panelResize.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.js",
"src/ui/rightPanelRail.js",
+2 -2
View File
@@ -7,10 +7,10 @@ import fs from 'node:fs/promises';
import path from 'node:path';
/** Console messages and failed requests kept per page. */
export const EVIDENCE_LIMIT = 200;
const EVIDENCE_LIMIT = 200;
/** Cesium's console line when the render loop throws (CesiumWidget). */
export const RENDER_ERROR_PATTERN = /An error occurred while rendering/i;
const RENDER_ERROR_PATTERN = /An error occurred while rendering/i;
/** Mask tokens in a URL or message before it is printed or saved. */
export function redact(text) {
-482
View File
@@ -1,482 +0,0 @@
#!/usr/bin/env node
/**
* Browser QA for the floating CCTV panel: lift, resize from every handle,
* minimum size, reload, and dock again. `--fail-on-retry` (or
* QA_FAIL_ON_RETRY=1) fails on any header-press retry.
*/
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
import { RESIZE_DIRECTIONS, resizeBox } from '../src/ui/panelResize.js';
import {
describePress,
dockPanelByDoubleClick,
dragPointer,
liftPanelByHeader,
nextFrames,
} from './qa-panelDrag.mjs';
import {
hookRenderErrors,
readRenderErrors,
saveFailureArtifacts,
watchPage,
} from './qa-browserEvidence.mjs';
export const PANEL_ID = 'cctv-panel';
export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`;
export const MIN_SIZE = { width: 300, height: 160 };
export const ARTIFACT_DIR = 'qa-artifacts/panel-resize';
const VIEWPORT = { width: 1400, height: 900 };
const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' };
/** Box edges a resize in `dir` must leave exactly where they were. */
export function pinnedEdges(dir) {
return Object.entries(EDGE_BY_SIDE)
.filter(([side]) => !dir.includes(side))
.map(([, edge]) => edge);
}
/** Pointer travel that grows a panel by `amount` on every edge named in `dir`. */
export function growthDelta(dir, amount) {
return {
dx: dir.includes('e') ? amount : dir.includes('w') ? -amount : 0,
dy: dir.includes('s') ? amount : dir.includes('n') ? -amount : 0,
};
}
/**
* Viewport point inside the handle for `dir`, given the panel's client rect.
* Edge strips straddle the border by 3px; corners and the grip sit inside.
*/
export function handlePoint(rect, dir) {
const inset = dir.length === 2 ? 4 : 1;
const x = dir.includes('w')
? rect.left + inset
: dir.includes('e')
? rect.right - inset
: rect.left + rect.width / 2;
const y = dir.includes('n')
? rect.top + inset
: dir.includes('s')
? rect.bottom - inset
: rect.top + rect.height / 2;
return { x, y };
}
/** Pinned edges that moved during a resize in `dir`. */
export function driftedEdges(before, after, dir, tolerance = 1.5) {
const edges = (box) => ({
left: box.left,
top: box.top,
right: box.left + box.width,
bottom: box.top + box.height,
});
const a = edges(before);
const b = edges(after);
return pinnedEdges(dir).filter(
(edge) => Math.abs(a[edge] - b[edge]) > tolerance,
);
}
function near(actual, expected, tolerance, label) {
assert.ok(
Math.abs(actual - expected) <= tolerance,
`${label}: expected ${expected} ± ${tolerance}, got ${actual}`,
);
}
async function main() {
const { default: puppeteer } = await import('puppeteer');
const args = process.argv.slice(2);
const urlIndex = args.indexOf('--url');
const url = urlIndex >= 0 ? args[urlIndex + 1] : 'http://localhost:4173';
if (args.includes('--fail-on-retry')) process.env.QA_FAIL_ON_RETRY = '1';
const browser = await puppeteer.launch({
headless: true,
executablePath:
process.env.PUPPETEER_EXECUTABLE_PATH ||
(await puppeteer.executablePath()),
args: ['--no-sandbox', '--use-gl=angle', '--use-angle=swiftshader'],
});
const monitors = [];
try {
const page = await browser.newPage();
await page.setViewport(VIEWPORT);
const errors = [];
monitors.push(watchPage(page, { name: 'panel', errors }));
await hookRenderErrors(page);
const boot = async () => {
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.viewer, {
timeout: 90_000,
});
await page.waitForSelector(`#${PANEL_ID} .panel-header`);
await page.evaluate(() => {
document.querySelector('.first-run-explore')?.click();
});
await page.keyboard.press('Escape');
await page.evaluate(() => {
document.getElementById('first-run-launcher')?.remove();
for (const node of document.querySelectorAll('[class*=first-run]'))
node.remove();
});
await page.mouse.click(VIEWPORT.width / 2, VIEWPORT.height / 2);
await nextFrames(page);
};
const readPanel = () =>
page.evaluate((id) => {
const panel = document.getElementById(id);
const rect = panel.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
// handlePoint() reads the far edges for s/e handles.
right: rect.right,
bottom: rect.bottom,
floating: panel.classList.contains('panel-floating'),
collapsed: panel.classList.contains('collapsed'),
inRail: panel.parentElement?.id === 'right-context-rail',
allocated: panel.style.getPropertyValue(
'--right-panel-allocated-height',
),
inlineWidth: panel.style.width,
inlineHeight: panel.style.height,
stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`),
};
}, PANEL_ID);
/**
* Best effort: wait for the box to hold still for half a second. The lift
* is checked against the box at pointerdown, so a timeout only costs time.
*/
const settle = async () => {
try {
await page.waitForFunction(
(id) => {
const r = document.getElementById(id).getBoundingClientRect();
const box = [r.left, r.top, r.width, r.height]
.map(Math.round)
.join(',');
const now = performance.now();
if (window.__qaLastBox !== box) {
window.__qaLastBox = box;
window.__qaSince = now;
}
return now - window.__qaSince >= 500;
},
{ polling: 100, timeout: 20_000 },
PANEL_ID,
);
} catch (error) {
if (error?.name !== 'TimeoutError') throw error;
console.log('note: the CCTV panel was still moving; lifting anyway');
}
};
const headerPoint = async () => {
const rect = await page.evaluate((id) => {
const title = document.querySelector(
`#${id} .panel-header .panel-title`,
);
const r = title.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}, PANEL_ID);
return rect;
};
const drag = (from, dx, dy) => dragPointer(page, from, dx, dy);
await page.goto(`${url}/`, { waitUntil: 'domcontentloaded' });
await page.evaluate((key) => {
localStorage.removeItem(key);
localStorage.removeItem('godsEyeView.v8.panelFloatHintShown');
}, STORAGE_KEY);
await boot();
await page.evaluate((id) => {
const panel = document.getElementById(id);
if (panel.classList.contains('collapsed'))
document
.querySelector(`.panel-collapse-btn[data-collapse-target="${id}"]`)
.click();
}, PANEL_ID);
await page.waitForFunction(
(id) => !document.getElementById(id).classList.contains('collapsed'),
{},
PANEL_ID,
);
// The rail animates panel heights, and on a slow software renderer the
// panels above CCTV can still be moving after a fixed sleep.
await settle();
const docked = await readPanel();
assert.equal(docked.floating, false);
assert.equal(docked.inRail, true);
// Names whatever above CCTV in the rail shifted it, for a retry note.
const railAbove = () =>
page.evaluate((id) => {
const out = [];
for (const node of document.getElementById('right-context-rail')
?.children || []) {
if (node.id === id) break;
out.push(
`${node.id || node.className}:${Math.round(node.offsetHeight)}`,
);
}
return out.join(' ');
}, PANEL_ID);
const aboveAtDock = await railAbove();
// On CI a panel above CCTV grows after load and can shift the header out
// from under the press; the helper retries only such a provable miss.
const attempt = await liftPanelByHeader(page, PANEL_ID, {
dx: -200,
dy: -100,
note: async () =>
`rail above CCTV ${aboveAtDock} -> ${await railAbove()}`,
});
const { pressed } = attempt;
const lifted = await readPanel();
assert.equal(
lifted.floating,
true,
`a header drag lifts the panel out (${describePress(attempt)}; panel docked at ${docked.top}, at press ${pressed?.top})`,
);
assert.equal(
lifted.collapsed,
false,
'the drag-ending click must not collapse',
);
const moved = `docked at ${docked.left},${docked.top}; pressed at ${pressed.left},${pressed.top}`;
near(
lifted.left,
Math.max(6, pressed.left - 200),
2,
`lifted left (${moved})`,
);
near(
lifted.top,
Math.max(6, pressed.top - 100),
2,
`lifted top (${moved})`,
);
near(lifted.width, pressed.width, 2, 'lifted width');
// The rail lets go of the lifted panel on its next layout pass.
await page
.waitForFunction(
(id) =>
document
.getElementById(id)
.style.getPropertyValue('--right-panel-allocated-height') === '',
{ timeout: 5_000 },
PANEL_ID,
)
.catch(() => {});
const afterLayout = await readPanel();
assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it');
const railExcludes = await page.evaluate((id) => {
const rail = document.getElementById('right-context-rail');
// Same rule as the rail: hidden panels take no room.
const counted = [...rail.children].filter((panel) =>
panel.matches(
'[data-panel-id]:not(.panel-floating):not(.collapsed):not([hidden])',
),
);
return {
expandedCount: rail.dataset.expandedCount,
counted: counted.length,
includesPanel: counted.some((panel) => panel.id === id),
};
}, PANEL_ID);
assert.equal(railExcludes.includesPanel, false);
if (railExcludes.expandedCount !== undefined)
assert.equal(Number(railExcludes.expandedCount), railExcludes.counted);
const storedAfterLift = JSON.parse(afterLayout.stored);
assert.equal(storedAfterLift.floating, true);
assert.equal(
'height' in storedAfterLift,
false,
'a drag alone must not freeze the measured height',
);
console.log('PASS: header drag lifts CCTV out of the rail');
// Park above and right of the voice dock and its raised provider chooser:
// growing every edge must leave the south handles clear of both controls.
{
const panel = await readPanel();
const header = await headerPoint();
await drag(header, 900 - panel.left, 120 - header.y);
}
const limits = {
minWidth: MIN_SIZE.width,
minHeight: MIN_SIZE.height,
viewportWidth: VIEWPORT.width,
viewportHeight: VIEWPORT.height,
};
for (const dir of RESIZE_DIRECTIONS) {
const before = await readPanel();
const { dx, dy } = growthDelta(dir, 40);
const point = handlePoint(before, dir);
const hit = await page.evaluate(({ x, y }) => {
const node = document.elementFromPoint(x, y);
if (!node) return 'nothing';
const dirAttr = node.dataset?.dir
? `[data-dir=${node.dataset.dir}]`
: '';
return `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}${dirAttr}`;
}, point);
await drag(point, dx, dy);
const after = await readPanel();
const expected = resizeBox(before, dir, dx, dy, limits);
for (const key of ['left', 'top', 'width', 'height'])
near(after[key], expected[key], 2, `${dir} ${key} (pointer on ${hit})`);
assert.deepEqual(driftedEdges(before, after, dir, 2), [], `${dir} pins`);
}
console.log('PASS: eight resize handles keep the opposite edge pinned');
let before = await readPanel();
await drag(handlePoint(before, 'se'), -2000, -2000);
let after = await readPanel();
near(after.width, MIN_SIZE.width, 2, 'min width (se)');
near(after.height, MIN_SIZE.height, 2, 'min height (se)');
assert.deepEqual(driftedEdges(before, after, 'se', 2), []);
before = after;
await drag(handlePoint(before, 'nw'), 2000, 2000);
after = await readPanel();
near(after.width, MIN_SIZE.width, 2, 'min width (nw)');
near(after.height, MIN_SIZE.height, 2, 'min height (nw)');
assert.deepEqual(driftedEdges(before, after, 'nw', 2), []);
console.log('PASS: minimum size holds from both corners');
const saved = await readPanel();
const record = JSON.parse(saved.stored);
assert.equal(record.floating, true);
await boot();
const restored = await readPanel();
assert.equal(restored.floating, true, 'floating survives a reload');
for (const key of ['left', 'top', 'width', 'height'])
near(restored[key], record[key], 2, `restored ${key}`);
console.log('PASS: reload restores position and size');
// A collapsed window keeps the height chosen for it, through a drag of
// its header strip and a reload.
const setCollapsed = async (collapsed) => {
await page.evaluate(
(id, want) => {
const panel = document.getElementById(id);
if (panel.classList.contains('collapsed') !== want)
document
.querySelector(
`.panel-collapse-btn[data-collapse-target="${id}"]`,
)
.click();
},
PANEL_ID,
collapsed,
);
await page.waitForFunction(
(id, want) =>
document.getElementById(id).classList.contains('collapsed') === want,
{},
PANEL_ID,
collapsed,
);
await nextFrames(page);
};
// Grow it from the corner once the restored window holds still, with the
// pointer really on the handle (a press beside it selects page text).
let chosen = await readPanel();
const tried = [];
for (
let attempt = 0;
attempt < 3 && chosen.height <= MIN_SIZE.height + 150;
attempt++
) {
await settle();
const box = await readPanel();
const corner = handlePoint(box, 'se');
const hit = await page.evaluate(({ x, y }) => {
const node = document.elementFromPoint(x, y);
return {
dir: node?.dataset?.dir || null,
target: node
? `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`
: 'nothing',
};
}, corner);
tried.push(
`${Math.round(corner.x)},${Math.round(corner.y)} on ${hit.target} (box ${Math.round(box.left)},${Math.round(box.top)} ${Math.round(box.width)}x${Math.round(box.height)})`,
);
if (hit.dir === 'se') await drag(corner, 0, 200);
chosen = await readPanel();
}
assert.ok(
chosen.height > MIN_SIZE.height + 150,
`the window grew to ${chosen.height}px; tried ${tried.join('; ')}`,
);
await setCollapsed(true);
const strip = await readPanel();
assert.ok(strip.height < chosen.height - 100, 'collapsed to its header');
await drag(await headerPoint(), 40, 30);
const movedStrip = await readPanel();
assert.equal(movedStrip.floating, true, 'the strip stays a window');
near(
JSON.parse(movedStrip.stored).height,
chosen.height,
2,
'stored height while collapsed',
);
await boot();
await setCollapsed(false);
near(
(await readPanel()).height,
chosen.height,
2,
'height after expanding',
);
console.log(
'PASS: a collapsed window keeps its height through a drag and a reload',
);
const dock = await dockPanelByDoubleClick(page, PANEL_ID);
const snapped = await readPanel();
assert.equal(
snapped.floating,
false,
`double-click snaps back (${describePress(dock)})`,
);
assert.equal(snapped.inRail, true);
assert.equal(snapped.inlineWidth, '');
assert.equal(snapped.inlineHeight, '');
assert.equal(snapped.stored, null, 'the storage key is gone');
console.log('PASS: header double-click returns the panel to the rail');
const renderErrors = await readRenderErrors(page);
assert.deepEqual(
[...errors, ...renderErrors.map((text) => `scene.renderError: ${text}`)],
[],
'no page errors and no Cesium render-loop errors',
);
console.log('PASS: no page errors and no render-loop errors');
} catch (error) {
const dir = await saveFailureArtifacts({
dir: ARTIFACT_DIR,
browser,
monitors,
error,
}).catch(() => null);
if (dir) console.error(`failure evidence saved in ${dir}/`);
throw error;
} finally {
await browser.close();
}
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
main().catch((error) => {
console.error(error.stack || error.message);
process.exit(1);
});
}
-319
View File
@@ -1,319 +0,0 @@
/**
* Header press-and-verify for the QA gates that lift a rail panel into a
* floating window and dock it again. The rail animates panel heights, so a
* point read once can be under another panel by the time the pointer goes
* down. Only a provable miss (a recorded press outside the header) is
* retried, with a GitHub `::warning::`. With QA_FAIL_ON_RETRY=1 a miss the
* panel's own movement cannot explain fails the run instead.
*/
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** Retries after the first attempt, and the pause before each. */
export const PRESS_RETRIES = 2;
const RETRY_PAUSE_MS = 1000;
/** Whether a retry fails the run (QA_FAIL_ON_RETRY=1 or --fail-on-retry). */
export function failOnRetryDefault(
env = process.env,
argv = process.argv.slice(2),
) {
return env.QA_FAIL_ON_RETRY === '1' || argv.includes('--fail-on-retry');
}
/**
* A failed attempt whose press landed outside the header. No recorded press
* is a failure, not a miss: a retry would only hide it.
* @param {{done: boolean, pressed: {inHeader: boolean}|null}} attempt
*/
export function pressMissed(attempt) {
return !attempt.done && attempt.pressed?.inHeader === false;
}
/** Whether the panel's box changed by more than 2 px between two reads. */
export function panelMoved(before, after) {
if (!before || !after) return false;
return ['left', 'top', 'width', 'height'].some(
(key) => Math.abs((after[key] ?? NaN) - (before[key] ?? NaN)) > 2,
);
}
/** The panel's box, rounded, as `left,top widthxheight`. */
export function formatBox(box) {
if (!box) return 'unknown';
const r = (value) => Math.round(value ?? NaN);
return `${r(box.left)},${r(box.top)} ${r(box.width)}x${r(box.height)}`;
}
function panelBox(page, panelId) {
return page.evaluate((id) => {
const r = document.getElementById(id).getBoundingClientRect();
return { left: r.left, top: r.top, width: r.width, height: r.height };
}, panelId);
}
export function headerPoint(page, panelId) {
return page.evaluate((id) => {
const title = document.querySelector(`#${id} .panel-header .panel-title`);
const r = title.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}, panelId);
}
/** Name the element at a point, and whether it is the panel's header. */
export function hitAt(page, point, panelId) {
return page.evaluate(
({ x, y, id }) => {
const node = document.elementFromPoint(x, y);
if (!node) return { target: 'nothing', inHeader: false };
return {
target: `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`,
inHeader: Boolean(node.closest(`#${id} .panel-header`)),
};
},
{ ...point, id: panelId },
);
}
/**
* Re-resolve the header point until it hits the header or the wait runs out
* (the press then records the miss).
*/
export async function headerTarget(
page,
panelId,
{ tries = 20, intervalMs = 500 } = {},
) {
let point = await headerPoint(page, panelId);
let hit = await hitAt(page, point, panelId);
for (let i = 0; i < tries && !hit.inHeader; i++) {
await delay(intervalMs);
point = await headerPoint(page, panelId);
hit = await hitAt(page, point, panelId);
}
const box = await panelBox(page, panelId);
return { point, hit, timedOut: !hit.inHeader, box };
}
/**
* Best effort: wait until the panel's box has held still for `stillMs`, so
* the header does not move between the hit check and the press.
*/
export async function waitForStill(
page,
panelId,
{ stillMs = 300, timeout = 5000 } = {},
) {
try {
await page.waitForFunction(
(id, ms) => {
const r = document.getElementById(id).getBoundingClientRect();
const box = [r.left, r.top, r.width, r.height].map(Math.round).join();
const now = performance.now();
if (window.__qaStillBox !== box) {
window.__qaStillBox = box;
window.__qaStillSince = now;
}
return now - window.__qaStillSince >= ms;
},
{ polling: 100, timeout },
panelId,
stillMs,
);
return true;
} catch (error) {
if (error?.name !== 'TimeoutError') throw error;
return false;
}
}
/** Record the next pointerdown's target and the panel's box at that moment. */
export function recordNextPress(page, panelId) {
return page.evaluate((id) => {
window.__qaPressBox = null;
window.addEventListener(
'pointerdown',
(event) => {
const r = document.getElementById(id).getBoundingClientRect();
const target = event.target;
window.__qaPressBox = {
left: r.left,
top: r.top,
width: r.width,
height: r.height,
target: `${target.tagName?.toLowerCase()}${target.id ? `#${target.id}` : ''}.${[...(target.classList || [])].join('.')}`,
inHeader: Boolean(target.closest?.(`#${id} .panel-header`)),
};
},
{ capture: true, once: true },
);
}, panelId);
}
function readPress(page) {
return page.evaluate(() => window.__qaPressBox ?? null);
}
export async function waitForFloating(
page,
panelId,
floating,
{ timeout = 3000 } = {},
) {
try {
await page.waitForFunction(
(id, want) =>
document.getElementById(id).classList.contains('panel-floating') ===
want,
{ polling: 50, timeout },
panelId,
floating,
);
return true;
} catch (error) {
if (error?.name !== 'TimeoutError') throw error;
return false;
}
}
/** Two paints: by then every layout change an input caused is on screen. */
export function nextFrames(page) {
return page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
}
export async function dragPointer(page, from, dx, dy, { steps = 6 } = {}) {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
await page.mouse.move(from.x + dx / 2, from.y + dy / 2, { steps });
await page.mouse.move(from.x + dx, from.y + dy, { steps });
await page.mouse.up();
await nextFrames(page);
}
export function describeRetry(
panelId,
attempt,
{ round, retries, extra = '' },
) {
const { hit, pressed, timedOut, still, box } = attempt;
return [
`retry ${round + 1}/${retries}: the press missed the ${panelId} header (on ${pressed?.target ?? hit.target})`,
`pre-press hit check ${timedOut ? `timed out (on ${hit.target})` : 'passed'}`,
`stillness ${still === false ? 'timed out' : 'held'}`,
`panel box at hit check ${formatBox(box)} -> at press ${formatBox(pressed)}`,
]
.concat(extra ? [extra] : [])
.join('; ');
}
/** Retry `press` only while it provably misses the header. */
async function pressUntilDecided(
page,
panelId,
press,
{
retries,
log,
note,
pauseMs = RETRY_PAUSE_MS,
failOnRetry = failOnRetryDefault(),
},
) {
for (let round = 0; ; round++) {
const still = await waitForStill(page, panelId);
await recordNextPress(page, panelId);
const { point, hit, timedOut, box } = await headerTarget(page, panelId);
const done = await press(point);
const attempt = {
point,
hit,
timedOut,
still,
box,
done,
pressed: await readPress(page),
retries: round,
};
if (round >= retries || !pressMissed(attempt)) return attempt;
const why = describeRetry(panelId, attempt, {
round,
retries,
extra: note ? await note() : '',
});
if (failOnRetry && !panelMoved(box, attempt.pressed))
throw new Error(
`QA_FAIL_ON_RETRY=1 turns this retry into a failure: ${why}`,
);
log(`note: ${why}; retrying`);
log(`::warning title=Panel press retried::${why}`);
await delay(pauseMs);
}
}
/**
* Lift a docked panel into a floating window with a header drag.
* @returns {Promise<{point: {x: number, y: number}, hit: {target: string, inHeader: boolean}, done: boolean, pressed: object|null}>}
*/
export function liftPanelByHeader(
page,
panelId,
{
dx,
dy,
retries = PRESS_RETRIES,
log = console.log,
note,
pauseMs,
failOnRetry,
} = {},
) {
return pressUntilDecided(
page,
panelId,
async (point) => {
await dragPointer(page, point, dx, dy);
return waitForFloating(page, panelId, true);
},
{ retries, log, note, pauseMs, failOnRetry },
);
}
/** Dock a floating panel with a header double-click; same result as lifting. */
export function dockPanelByDoubleClick(
page,
panelId,
{
retries = PRESS_RETRIES,
log = console.log,
note,
pauseMs,
failOnRetry,
} = {},
) {
return pressUntilDecided(
page,
panelId,
async (point) => {
await page.mouse.click(point.x, point.y, { clickCount: 1 });
await page.mouse.click(point.x, point.y, { clickCount: 2 });
return waitForFloating(page, panelId, false);
},
{ retries, log, note, pauseMs, failOnRetry },
);
}
/** Where an attempt pressed, for an assertion message. */
export function describePress(attempt) {
const { point, hit, pressed } = attempt;
const where = `pressed ${Math.round(point.x)},${Math.round(point.y)}`;
if (!pressed)
return `${where}: no pointerdown reached the page (hit check on ${hit.target}, in header: ${hit.inHeader})`;
const n = attempt.retries || 0;
const retried = n ? `, after ${n} ${n === 1 ? 'retry' : 'retries'}` : '';
return `${where} on ${pressed.target}, in header: ${pressed.inHeader}${retried}`;
}
+506 -2774
View File
File diff suppressed because it is too large. Load diff
+2 -12
View File
@@ -43,11 +43,7 @@ try {
const fpsReadout = document.querySelector('.frame-rate-readout');
const fpsWasVisible = fpsReadout && !fpsReadout.hidden;
const watchObserver = (name) => {
const owner =
name === '_draggableResizeObserver'
? ui._panelPosition
: ui._panelLayout;
const observer = owner[name];
const observer = ui._panelLayout[name];
if (!observer) return false;
const disconnect = observer.disconnect.bind(observer);
counts[name] = 0;
@@ -59,7 +55,6 @@ try {
};
const observed = [
'_commandDockTrayObserver',
'_draggableResizeObserver',
'_leftStackResizeObserver',
'_rightStackResizeObserver',
'_leftStackMutationObserver',
@@ -139,7 +134,6 @@ try {
const focusBefore = document.activeElement;
const stoppedBeforeRestoration =
ui._panelLayout.destroyed &&
ui._panelPosition.destroyed &&
ui._feedback.destroyed &&
ui._recording.destroyed &&
cockpit.destroyed &&
@@ -194,11 +188,7 @@ try {
counts[field] === 1 && ui._contextControls[field] === null,
),
observersReleased: observed.every(
(name) =>
counts[name] === 1 &&
(name === '_draggableResizeObserver'
? ui._panelPosition
: ui._panelLayout)[name] === null,
(name) => counts[name] === 1 && ui._panelLayout[name] === null,
),
subscriptionReleased:
counts.cctv === 1 && ui._cctvControls._cctvUnsubscribe === null,
+101 -14
View File
@@ -1,19 +1,106 @@
import { installMapillaryRoutes } from './mapillary/routes.js';
import { sameSiteGated } from './common/same-site.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import {
fetchTile,
mapillaryToken,
parseTilePath,
retryAfterWait,
TileUpstreamError,
} from './mapillary/tiles.js';
/**
* Vite plugin: cached Mapillary coverage tiles (token added server-side) and a
* status route that says whether a token is configured.
* Tile requests per client IP per minute, cache hits included: over 60 new
* 9-tile views a minute, more than a person flying the camera reaches.
*/
function mapillaryProxy() {
return {
name: 'mapillary-proxy',
configureServer(server) {
installMapillaryRoutes(server.middlewares);
},
configurePreviewServer(server) {
installMapillaryRoutes(server.middlewares);
},
};
export const TILE_ROUTE_MAX_PER_MIN = 600;
function sendJson(res, status, payload) {
res.statusCode = status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify(payload));
}
export { mapillaryProxy };
/** GET /api/mapillary/status — whether a token is configured, never its value. */
function handleStatus(req, res) {
if (req.method !== 'GET')
return sendJson(res, 405, { error: 'Method not allowed' });
sendJson(res, 200, { configured: Boolean(mapillaryToken()) });
}
/** GET /api/mapillary/tiles/coverage/{z}/{x}/{y} — trimmed protobuf tile. */
async function handleTile(req, res, allow) {
if (req.method !== 'GET')
return sendJson(res, 405, { error: 'Method not allowed' });
if (!allow(clientKey(req))) {
res.setHeader('Retry-After', '5');
return sendJson(res, 429, {
error: 'Too many tile requests',
retryAfter: 5,
});
}
const address = parseTilePath((req.url || '').split('?')[0]);
if (!address)
return sendJson(res, 400, {
error: 'Tile path must be /coverage/{z}/{x}/{y} with z 11–14',
});
if (!mapillaryToken())
return sendJson(res, 503, { error: 'no_key', keyRequired: true });
// A client that leaves (a tile the camera moved past) cancels its fetch.
const left = new AbortController();
res.on?.('close', () => {
if (!res.writableEnded) left.abort();
});
try {
const { bytes, source } = await fetchTile(address, { signal: left.signal });
res.statusCode = bytes.length ? 200 : 204;
res.setHeader('Content-Type', 'application/x-protobuf');
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('X-Gev-Cache', source);
res.end(bytes.length ? bytes : undefined);
} catch (error) {
if (left.signal.aborted) return;
const status = error instanceof TileUpstreamError ? error.status : 0;
// A rejected token is a key problem the panel can name, not a fault.
if (status === 401 || status === 403)
return sendJson(res, 403, {
error: 'Mapillary rejected the access token',
keyRejected: true,
});
if (status === 429) {
const retryAfter = retryAfterWait(error.retryAfter);
res.setHeader('Retry-After', String(retryAfter));
return sendJson(res, 429, {
error: 'Mapillary is rate-limiting tile requests',
retryAfter,
});
}
// An upstream 400 must not read as a malformed request to this proxy.
sendJson(res, 502, { error: error?.message || 'Tile fetch failed' });
}
}
/**
* Vite plugin: Mapillary coverage tiles with the token added server-side, and
* a status route. Both refuse cross-site requests so another page cannot
* spend the token.
*/
export function mapillaryProxy() {
const install = (middlewares) => {
const allow = makeRateLimiter({
windowMs: 60_000,
max: TILE_ROUTE_MAX_PER_MIN,
globalMax: TILE_ROUTE_MAX_PER_MIN * 4,
});
middlewares.use('/api/mapillary/status', sameSiteGated(handleStatus));
middlewares.use(
'/api/mapillary/tiles',
sameSiteGated((req, res) => handleTile(req, res, allow)),
);
};
return {
name: 'mapillary-proxy',
configureServer: (server) => install(server.middlewares),
configurePreviewServer: (server) => install(server.middlewares),
};
}
-72
View File
@@ -1,72 +0,0 @@
import path from 'node:path';
/** Mapillary vector tile root; the path template is /{layer}/2/{z}/{x}/{y}. */
export const MAPILLARY_TILE_HOST = 'https://tiles.mapillary.com/maps/vtp';
/** Public tile layer names this proxy exposes, mapped to Mapillary's ids. */
export const TILE_LAYERS = Object.freeze({
// Region-sized z6–10 tiles are refused; the app never asks for them. The
// z14 `image` layer is ~98% of a tile and unused (positions come from the
// graph API), so it is dropped in transit.
coverage: Object.freeze({
upstream: 'mly1_public',
zoomRanges: Object.freeze([Object.freeze([0, 5]), Object.freeze([11, 14])]),
dropLayers: Object.freeze(['image']),
}),
});
/** Disk cache root, alongside the other providers' caches. */
export const MAPILLARY_CACHE_DIR = path.join(
process.cwd(),
'.gev-cache',
'mapillary',
);
export const TILE_DISK_DIR = path.join(MAPILLARY_CACHE_DIR, 'tiles');
/**
* Longer than the 15-minute upstream Cache-Control: tiles change only when new
* imagery is processed, and a day avoids re-downloading a city per camera move.
*/
export const TILE_DISK_TTL_MS = 24 * 60 * 60 * 1000;
/**
* A tile write starts a background sweep at most this often: expired files go
* first, then the oldest until the cache fits in TILE_DISK_MAX_BYTES.
*/
export const TILE_DISK_SWEEP_INTERVAL_MS = 15 * 60 * 1000;
export const TILE_DISK_MAX_BYTES = 1024 * 1024 * 1024;
/** Per-file cost on top of its size, so empty tiles still count. */
export const TILE_DISK_FILE_OVERHEAD_BYTES = 4096;
/** A z14 image tile over a dense city is ~11 MB; anything past this is wrong. */
export const TILE_MAX_BYTES = 48 * 1024 * 1024;
export const TILE_MEMORY_BUDGET_BYTES = 96 * 1024 * 1024;
/** Per-entry cost on top of its bytes, so empty tiles still count. */
export const TILE_MEMORY_ENTRY_OVERHEAD_BYTES = 1024;
export const TILE_FETCH_TIMEOUT_MS = 60_000;
/**
* Upstream fetches at once; further misses queue. A view needs at most 25
* tiles, and the cap stops a burst of misses opening thousands of requests.
*/
export const TILE_UPSTREAM_CONCURRENCY = 6;
/**
* Tile requests per client IP per minute, cache hits included. 600 is 24 new
* 25-tile views a minute, more than a person flying the camera reaches, but it
* stops one page draining the shared token into a Mapillary rate limit.
*/
export const TILE_ROUTE_MAX_PER_MIN = 600;
/**
* How long a 401/403 answers tile misses before asking Mapillary again. A
* changed token is tried at once.
*/
export const TILE_KEY_REJECTED_HOLD_MS = 5 * 60 * 1000;
/** After a 429 without a usable Retry-After, hold tile misses this long. */
export const TILE_RATE_LIMIT_HOLD_MS = 60_000;
/** The longest Retry-After honoured, so a bad header cannot stall coverage. */
export const TILE_RATE_LIMIT_MAX_HOLD_MS = 10 * 60 * 1000;
/** The client token lives in the browser by design; the server adds it to tile URLs too. */
export function mapillaryToken() {
return String(process.env.MAPILLARY_CLIENT_TOKEN || '').trim();
}
-95
View File
@@ -1,95 +0,0 @@
import { sameSiteGated } from '../common/same-site.js';
import { makeRateLimiter, clientKey } from '../common/rate-limit.js';
import { fetchTile, TileRequestError, TileUpstreamError } from './tiles.js';
import { mapillaryToken, TILE_ROUTE_MAX_PER_MIN } from './constants.js';
function sendJson(res, status, payload) {
res.statusCode = status;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
res.end(JSON.stringify(payload));
}
/** GET /api/mapillary/status — whether a token is configured, never its value. */
function handleStatus(req, res) {
if (req.method !== 'GET')
return sendJson(res, 405, { error: 'Method not allowed' });
sendJson(res, 200, { configured: Boolean(mapillaryToken()) });
}
/** GET /api/mapillary/tiles/coverage/{z}/{x}/{y} — cached protobuf tile. */
async function handleTile(req, res, allow) {
if (req.method !== 'GET')
return sendJson(res, 405, { error: 'Method not allowed' });
if (!allow(clientKey(req))) {
res.setHeader('Retry-After', '5');
return sendJson(res, 429, {
error: 'Too many tile requests',
retryAfter: 5,
});
}
const match = /^\/(coverage)\/(\d{1,2})\/(\d{1,6})\/(\d{1,6})$/.exec(
(req.url || '').split('?')[0],
);
if (!match)
return sendJson(res, 400, {
error: 'Tile path must be /coverage/{z}/{x}/{y}',
});
if (!mapillaryToken())
return sendJson(res, 503, { error: 'no_key', keyRequired: true });
const abandoned = new AbortController();
req.on?.('aborted', () => abandoned.abort());
res.on?.('close', () => abandoned.abort());
try {
const { bytes, source } = await fetchTile(
{ layer: match[1], z: match[2], x: match[3], y: match[4] },
{ signal: abandoned.signal },
);
if (res.writableEnded) return;
res.statusCode = bytes.length ? 200 : 204;
res.setHeader('Content-Type', 'application/x-protobuf');
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('X-Gev-Cache', source);
res.end(bytes.length ? bytes : undefined);
} catch (error) {
if (res.writableEnded || abandoned.signal.aborted) return;
if (error instanceof TileRequestError)
return sendJson(res, error.status, { error: error.message });
if (error instanceof TileUpstreamError) {
// A rejected token is a key problem the panel can name, not a fault.
if (error.keyRejected)
return sendJson(res, 403, {
error: 'Mapillary rejected the access token',
keyRejected: true,
});
if (error.status === 429) {
const retryAfter = error.retryAfterSec || 60;
res.setHeader('Retry-After', String(retryAfter));
return sendJson(res, 429, {
error: 'Mapillary is rate-limiting tile requests',
retryAfter,
});
}
// An upstream 400 must not read as a malformed request to this proxy.
return sendJson(res, 502, { error: error.message });
}
sendJson(res, 502, { error: error?.message || 'Tile fetch failed' });
}
}
/**
* Attach the Mapillary routes. Both refuse cross-site requests so another page
* cannot spend the token or provoke a 429 that holds every tile miss.
*/
export function installMapillaryRoutes(middlewares) {
const allow = makeRateLimiter({
windowMs: 60_000,
max: TILE_ROUTE_MAX_PER_MIN,
globalMax: TILE_ROUTE_MAX_PER_MIN * 4,
});
middlewares.use('/api/mapillary/status', sameSiteGated(handleStatus));
middlewares.use(
'/api/mapillary/tiles',
sameSiteGated((req, res) => handleTile(req, res, allow)),
);
}
+216 -469
View File
@@ -1,518 +1,265 @@
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import { readResponseBytesCapped } from '../common/http.js';
import { stripTileLayers } from './trim.js';
import {
MAPILLARY_TILE_HOST,
TILE_LAYERS,
TILE_DISK_DIR,
TILE_DISK_TTL_MS,
TILE_DISK_SWEEP_INTERVAL_MS,
TILE_DISK_MAX_BYTES,
TILE_DISK_FILE_OVERHEAD_BYTES,
TILE_MAX_BYTES,
TILE_MEMORY_BUDGET_BYTES,
TILE_MEMORY_ENTRY_OVERHEAD_BYTES,
TILE_FETCH_TIMEOUT_MS,
TILE_UPSTREAM_CONCURRENCY,
TILE_KEY_REJECTED_HOLD_MS,
TILE_RATE_LIMIT_HOLD_MS,
TILE_RATE_LIMIT_MAX_HOLD_MS,
mapillaryToken,
} from './constants.js';
/** Log without the stack; the token is never in scope here. */
function warn(what, error) {
console.warn(`[Mapillary Proxy] ${what}:`, error?.message || error);
/** Mapillary vector tiles, /{layer}/2/{z}/{x}/{y}: the only host the token goes to. */
const TILE_HOST = 'https://tiles.mapillary.com/maps/vtp';
/** Only the street zooms the app draws are proxied. */
const MIN_ZOOM = 11;
const MAX_ZOOM = 14;
/**
* The z14 `image` layer is ~98% of a tile (12 MB over a dense city) and unused
* (positions come from the Graph API), so it is dropped in transit.
*/
const DROP_LAYERS = ['image'];
/** A z14 tile with its image layer is ~12 MB; anything past this is wrong. */
export const TILE_MAX_BYTES = 48 * 1024 * 1024;
const FETCH_TIMEOUT_MS = 60_000;
/** Upstream fetches at once; more wait their turn. */
export const TILE_UPSTREAM_CONCURRENCY = 6;
/** Tiles change only when new imagery is processed. */
export const TILE_TTL_MS = 24 * 60 * 60 * 1000;
/** Trimmed tiles are ~80 KB: a dozen 9-tile views, well under 100 MB. */
export const TILE_MEMORY_MAX_TILES = 120;
/** A trimmed tile is ~80 KB; a body this big failed to trim and is not kept. */
export const TILE_MEMORY_MAX_TILE_BYTES = 4 * 1024 * 1024;
/** The longest upstream Retry-After honoured, in seconds. */
export const MAX_RETRY_AFTER_SEC = 600;
/** The client token lives in the browser by design; the server adds it to tile URLs too. */
export function mapillaryToken() {
return String(process.env.MAPILLARY_CLIENT_TOKEN || '').trim();
}
/** Thrown for a request this proxy refuses before contacting Mapillary. */
export class TileRequestError extends Error {
constructor(message, status = 400) {
super(message);
this.name = 'TileRequestError';
this.status = status;
}
}
/** Mapillary answered with an error status; 401/403 set `keyRejected`. */
/** Mapillary answered with an error status (or a size this proxy refuses). */
export class TileUpstreamError extends Error {
constructor(status, message, { retryAfterSec = null } = {}) {
constructor(status, message, retryAfter = null) {
super(message || `Mapillary tiles HTTP ${status}`);
this.name = 'TileUpstreamError';
this.status = status;
this.keyRejected = status === 401 || status === 403;
this.retryAfterSec = retryAfterSec;
/** The upstream Retry-After header, as sent. */
this.retryAfter = retryAfter;
}
}
/**
* A rejected token or rate limit that answers every tile miss until it expires
* (or the token changes). Cached tiles are still served meanwhile.
* @type {{status: number, until: number, token: string}|null}
*/
let _upstreamHold = null;
/** Milliseconds to wait from a Retry-After header (seconds or an HTTP date). */
function retryAfterMs(header, now = Date.now()) {
/** Seconds a Retry-After header asks for (an integer or an HTTP date), else null. */
export function retryAfterSeconds(header, now = Date.now()) {
const text = String(header ?? '').trim();
let ms = NaN;
if (/^\d+$/.test(text)) ms = Number(text) * 1000;
else if (text) ms = Date.parse(text) - now;
if (!Number.isFinite(ms) || ms <= 0) return TILE_RATE_LIMIT_HOLD_MS;
return Math.min(ms, TILE_RATE_LIMIT_MAX_HOLD_MS);
if (!text) return null;
const seconds = /^\d+$/.test(text)
? Number(text)
: (Date.parse(text) - now) / 1000;
return Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds) : null;
}
function heldRefusal(token) {
const hold = _upstreamHold;
if (!hold) return null;
const left = hold.until - Date.now();
if (left <= 0 || hold.token !== token) {
_upstreamHold = null;
/** The wait a 429 asks for, bounded; a missing or malformed header means a minute. */
export function retryAfterWait(header, now = Date.now()) {
return Math.min(retryAfterSeconds(header, now) ?? 60, MAX_RETRY_AFTER_SEC);
}
/**
* While Mapillary rate-limits, tile misses are refused here until the wait is
* over, so the rest of a view (and the fetches queued behind the first 429)
* do not go on asking. Scoped to the token that was refused.
* @type {{until: number, token: string}|null}
*/
let refusal = null;
/** The refusal still in force for `token`, as the error to answer with. */
function refusedNow(token) {
if (!refusal) return null;
const left = refusal.until - Date.now();
if (left <= 0 || refusal.token !== token) {
refusal = null;
return null;
}
return new TileUpstreamError(hold.status, undefined, {
retryAfterSec: Math.ceil(left / 1000),
});
return new TileUpstreamError(429, undefined, String(Math.ceil(left / 1000)));
}
/**
* Validate a tile address against the layer's zoom ranges.
* @returns {{layer:string, upstream:string, z:number, x:number, y:number, key:string}}
* Parse `/coverage/{z}/{x}/{y}`; null unless z is 11–14 and x/y are on the grid.
* @returns {{z: number, x: number, y: number, key: string}|null}
*/
export function normalizeTileAddress({ layer, z, x, y }) {
const spec = Object.hasOwn(TILE_LAYERS, layer) ? TILE_LAYERS[layer] : null;
if (!spec) throw new TileRequestError(`Unknown tile layer: ${layer}`);
const zi = Number(z);
const xi = Number(x);
const yi = Number(y);
if (
![zi, xi, yi].every((v) => Number.isInteger(v) && v >= 0) ||
!spec.zoomRanges.some(([min, max]) => zi >= min && zi <= max)
)
throw new TileRequestError(
`Tile zoom for ${layer} must be ${spec.zoomRanges
.map(([min, max]) => `${min}–${max}`)
.join(' or ')}`,
);
const n = 2 ** zi;
if (xi >= n || yi >= n)
throw new TileRequestError('Tile address out of range');
return {
layer,
upstream: spec.upstream,
dropLayers: spec.dropLayers || [],
z: zi,
x: xi,
y: yi,
key: `${layer}/${zi}/${xi}/${yi}`,
};
export function parseTilePath(pathname) {
const match = /^\/coverage\/(\d{1,2})\/(\d{1,6})\/(\d{1,6})$/.exec(pathname);
if (!match) return null;
const [z, x, y] = match.slice(1).map(Number);
if (z < MIN_ZOOM || z > MAX_ZOOM || x >= 2 ** z || y >= 2 ** z) return null;
return { z, x, y, key: `${z}/${x}/${y}` };
}
/** @type {Map<string, {bytes: Buffer, at: number}>} insertion-ordered LRU */
const _memory = new Map();
let _memoryBytes = 0;
const memoryCost = (bytes) => bytes.length + TILE_MEMORY_ENTRY_OVERHEAD_BYTES;
/**
* Upstream fetches shared by every request for the same tile; the last waiter
* to leave cancels it (see `joinFlight`).
* @type {Map<string, {controller: AbortController, promise: Promise<Buffer>, waiters: number}>}
*/
const _inFlight = new Map();
function memoryGet(key) {
const hit = _memory.get(key);
if (!hit) return null;
// Same life as the disk copy, so a long-running server never serves stale
// coverage.
if (Date.now() - hit.at > TILE_DISK_TTL_MS) {
_memory.delete(key);
_memoryBytes -= memoryCost(hit.bytes);
return null;
}
_memory.delete(key);
_memory.set(key, hit);
return hit.bytes;
}
/** `at` is when Mapillary served the bytes (a disk tile's mtime). */
function memoryPut(key, bytes, at = Date.now()) {
if (memoryCost(bytes) > TILE_MEMORY_BUDGET_BYTES / 2) return;
const existing = _memory.get(key);
if (existing) _memoryBytes -= memoryCost(existing.bytes);
_memory.set(key, { bytes, at });
_memoryBytes += memoryCost(bytes);
while (_memoryBytes > TILE_MEMORY_BUDGET_BYTES && _memory.size) {
const [oldest, entry] = _memory.entries().next().value;
_memory.delete(oldest);
_memoryBytes -= memoryCost(entry.bytes);
}
}
/** Disk cache root; tests point it at a temporary directory. */
let _diskDir = TILE_DISK_DIR;
function diskPath({ layer, z, x, y }, root = _diskDir) {
return path.join(root, layer, String(z), `${x}-${y}.pbf`);
}
async function readDisk(address) {
const file = diskPath(address);
function trim(bytes) {
try {
const stat = await fsp.stat(file);
if (Date.now() - stat.mtimeMs > TILE_DISK_TTL_MS) return null;
return { bytes: await fsp.readFile(file), at: stat.mtimeMs };
} catch {
return null;
}
}
/** Unsettled background writes and sweeps; requests never wait on them. */
const _background = new Set();
function track(task) {
const settled = task
.catch(() => {})
.finally(() => _background.delete(settled));
_background.add(settled);
return settled;
}
/** @type {Map<string, number>} cache file to its newest write's sequence */
const _diskWrites = new Map();
let _diskWriteSeq = 0;
function diskWritePending(address) {
return _diskWrites.has(diskPath(address));
}
/**
* Write a tile in the background. `at` keeps a rewrite's original fetch time
* so it does not extend the tile's life. An overtaken write steps aside.
*/
function writeDisk(address, bytes, at = null) {
// Sweep this write's root even if the cache dir has moved since.
const root = _diskDir;
const file = diskPath(address, root);
const seq = ++_diskWriteSeq;
const tmp = `${file}.${process.pid}-${seq}.tmp`;
const newest = () => _diskWrites.get(file) === seq;
_diskWrites.set(file, seq);
return track(
fsp
.mkdir(path.dirname(file), { recursive: true })
.then(() => fsp.writeFile(tmp, bytes))
.then(() => at != null && fsp.utimes(tmp, new Date(), new Date(at)))
.then(() => {
if (!newest()) return fsp.rm(tmp, { force: true });
return fsp.rename(tmp, file).then(() => scheduleSweep(root));
})
.catch(async (error) => {
await fsp.rm(tmp, { force: true }).catch(() => {});
warn('tile cache write failed', error);
})
.finally(() => {
if (newest()) _diskWrites.delete(file);
}),
);
}
let _lastSweepAt = 0;
let _sweeping = null;
let _sweepWarned = false;
function scheduleSweep(root) {
if (_sweeping || Date.now() - _lastSweepAt < TILE_DISK_SWEEP_INTERVAL_MS)
return;
_lastSweepAt = Date.now();
const sweep = sweepTileDisk({ root })
.catch((error) => {
if (_sweepWarned) return;
_sweepWarned = true;
warn('tile cache sweep failed', error);
})
.finally(() => {
if (_sweeping === tracked) _sweeping = null;
});
const tracked = track(sweep);
_sweeping = tracked;
}
/** Every file under `dir` with size and mtime; a missing dir is empty. */
async function listCacheFiles(dir) {
let entries;
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
return stripTileLayers(bytes, DROP_LAYERS);
} catch (error) {
if (error?.code === 'ENOENT') return [];
throw error;
}
const files = [];
for (const entry of entries) {
const file = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...(await listCacheFiles(file)));
else if (entry.isFile()) {
const stat = await fsp.stat(file).catch(() => null);
if (stat) files.push({ file, size: stat.size, at: stat.mtimeMs });
}
}
return files;
}
/**
* Delete expired tiles, then the oldest until the rest fit in `maxBytes`.
* @returns {Promise<{removed: number, bytes: number}>} files deleted, charged bytes kept
*/
export async function sweepTileDisk({
root = _diskDir,
maxBytes = TILE_DISK_MAX_BYTES,
} = {}) {
const now = Date.now();
const files = (await listCacheFiles(root)).sort((a, b) => a.at - b.at);
const cost = (size) => size + TILE_DISK_FILE_OVERHEAD_BYTES;
let bytes = files.reduce((sum, { size }) => sum + cost(size), 0);
let removed = 0;
for (const { file, size, at } of files) {
if (now - at <= TILE_DISK_TTL_MS && bytes <= maxBytes) break;
await fsp.rm(file, { force: true });
bytes -= cost(size);
removed++;
}
return { removed, bytes };
}
/** The one origin the tile token may be sent to, at every redirect hop. */
const TILE_ORIGIN = new URL(MAPILLARY_TILE_HOST).origin;
export const TILE_MAX_REDIRECTS = 3;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
/**
* Follow redirects by hand so every hop stays HTTPS on the tile origin and the
* token in the query never reaches another host. A bad redirect is a 502.
*/
async function fetchPinned(url, init) {
let next = url;
for (let hop = 0; ; hop++) {
const response = await fetch(next, { ...init, redirect: 'manual' });
if (!REDIRECT_STATUSES.has(response.status)) return response;
await response.body?.cancel().catch(() => {});
if (hop >= TILE_MAX_REDIRECTS)
throw new TileUpstreamError(502, 'Mapillary tile redirected too often');
let target = null;
try {
target = new URL(response.headers.get('location') ?? '', next);
} catch {
/* unparseable Location */
}
if (
!response.headers.get('location') ||
target?.protocol !== 'https:' ||
target.origin !== TILE_ORIGIN ||
target.username ||
target.password
)
throw new TileUpstreamError(
502,
'Mapillary tile redirect left the tile origin',
);
next = target.href;
}
}
async function fetchUpstream(address, signal) {
const token = mapillaryToken();
if (!token) throw new TileRequestError('Mapillary token not configured', 503);
const held = heldRefusal(token);
if (held) throw held;
const url = `${MAPILLARY_TILE_HOST}/${address.upstream}/2/${address.z}/${address.x}/${address.y}?access_token=${encodeURIComponent(token)}`;
const timeout = AbortSignal.timeout(TILE_FETCH_TIMEOUT_MS);
const oversize = new AbortController();
const response = await fetchPinned(url, {
signal: AbortSignal.any([signal, timeout, oversize.signal].filter(Boolean)),
headers: { Accept: 'application/x-protobuf' },
});
if (response.status === 404 || response.status === 204) {
await response.body?.cancel().catch(() => {});
return Buffer.alloc(0);
}
if (!response.ok) {
await response.body?.cancel().catch(() => {});
const error = new TileUpstreamError(response.status);
if (error.keyRejected)
_upstreamHold = {
status: response.status,
until: Date.now() + TILE_KEY_REJECTED_HOLD_MS,
token,
};
else if (response.status === 429) {
const wait = retryAfterMs(response.headers.get('retry-after'));
_upstreamHold = { status: 429, until: Date.now() + wait, token };
error.retryAfterSec = Math.ceil(wait / 1000);
}
throw error;
}
// Chunked or compressed bodies have no Content-Length: the cap applies as
// the body streams, and an oversized one also aborts the request.
try {
const bytes = await readResponseBytesCapped(
response,
TILE_MAX_BYTES,
signal,
);
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
} catch (error) {
if (error?.code !== 'RESPONSE_TOO_LARGE') throw error;
oversize.abort();
throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap');
}
}
/** @type {{active: number, queue: Array<() => void>}} upstream slots */
let _slots = { active: 0, queue: [] };
/**
* Resolve with a release function once a slot is free. An abort while
* queued leaves the queue.
* @returns {Promise<() => void>}
*/
function acquireUpstreamSlot(signal) {
const slots = _slots;
return new Promise((resolve, reject) => {
if (signal.aborted) return reject(signal.reason);
const onAbort = () => {
const index = slots.queue.indexOf(grant);
if (index !== -1) slots.queue.splice(index, 1);
reject(signal.reason);
};
function grant() {
signal.removeEventListener('abort', onAbort);
slots.active++;
let released = false;
resolve(() => {
if (released) return;
released = true;
slots.active--;
slots.queue.shift()?.();
});
}
if (slots.active < TILE_UPSTREAM_CONCURRENCY) return grant();
signal.addEventListener('abort', onAbort, { once: true });
slots.queue.push(grant);
});
}
function trim(address, bytes) {
if (!address.dropLayers.length || !bytes.length) return bytes;
try {
return stripTileLayers(bytes, address.dropLayers);
} catch (error) {
warn('tile trim failed, serving raw', error);
console.warn('[Mapillary Proxy] tile trim failed:', error?.message);
return bytes;
}
}
/**
* Fetch one tile from memory, disk, a shared flight, then Mapillary. Empty
* bytes mean no coverage.
* @param {{layer:string,z:number|string,x:number|string,y:number|string}} request
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<{bytes: Buffer, source: 'memory'|'disk'|'inflight'|'upstream', address: object}>}
*/
export async function fetchTile(request, { signal } = {}) {
const address = normalizeTileAddress(request);
const memory = memoryGet(address.key);
if (memory) return { bytes: memory, source: 'memory', address };
const disk = await readDisk(address);
if (disk) {
// Older cache files may be untrimmed: rewrite once, keeping the fetch time.
const bytes = trim(address, disk.bytes);
if (bytes !== disk.bytes && !diskWritePending(address))
writeDisk(address, bytes, disk.at);
memoryPut(address.key, bytes, disk.at);
return { bytes, source: 'disk', address };
/** One tile from Mapillary, trimmed; empty bytes mean no coverage. */
async function fetchUpstream({ z, x, y }, signal) {
const token = mapillaryToken();
const refused = refusedNow(token);
if (refused) throw refused;
const response = await fetch(
`${TILE_HOST}/mly1_public/2/${z}/${x}/${y}?access_token=${encodeURIComponent(token)}`,
{
signal: AbortSignal.any([signal, AbortSignal.timeout(FETCH_TIMEOUT_MS)]),
// The token is in the query: a redirect is never followed (a 3xx is a 502).
redirect: 'manual',
headers: { Accept: 'application/x-protobuf' },
},
);
if (!response.ok || response.status === 204) {
await response.body?.cancel().catch(() => {});
if (response.status === 204 || response.status === 404)
return Buffer.alloc(0);
const retryAfter = response.headers.get('retry-after');
if (response.status === 429)
refusal = {
token,
until: Date.now() + retryAfterWait(retryAfter) * 1000,
};
throw new TileUpstreamError(response.status, undefined, retryAfter);
}
try {
const bytes = await readResponseBytesCapped(response, TILE_MAX_BYTES);
return trim(Buffer.from(bytes.buffer, bytes.byteOffset, bytes.length));
} catch (error) {
if (error?.code !== 'RESPONSE_TOO_LARGE') throw error;
throw new TileUpstreamError(502, 'Mapillary tile exceeds size cap');
}
// A caller gone while the disk was read must not start a fetch nobody joins.
signal?.throwIfAborted();
let flight = _inFlight.get(address.key);
const joined = Boolean(flight) && !flight.controller.signal.aborted;
if (!joined) flight = startFlight(address);
const bytes = await joinFlight(flight, signal);
return { bytes, source: joined ? 'inflight' : 'upstream', address };
}
/** Start the shared upstream fetch; it holds a slot until the body is read. */
let upstreamActive = 0;
/** @type {Array<() => void>} fetches waiting for a slot, oldest first */
const upstreamWaiting = [];
/**
* Run `task` in an upstream slot, waiting for one unless `signal` aborts
* first. A freed slot passes straight to the oldest waiter, still counted, so
* a request arriving meanwhile cannot take it first.
*/
async function withUpstreamSlot(signal, task) {
if (upstreamActive < TILE_UPSTREAM_CONCURRENCY) upstreamActive++;
else
await new Promise((resolve, reject) => {
const wake = () => {
signal.removeEventListener('abort', leave);
resolve();
};
const leave = () => {
const at = upstreamWaiting.indexOf(wake);
if (at !== -1) upstreamWaiting.splice(at, 1);
reject(signal.reason);
};
upstreamWaiting.push(wake);
signal.addEventListener('abort', leave, { once: true });
});
try {
// Handed a slot after leaving: it goes on to the next waiter.
signal.throwIfAborted();
return await task();
} finally {
const next = upstreamWaiting.shift();
if (next) next();
else upstreamActive--;
}
}
/** `promise`, or a rejection as soon as `signal` aborts. */
function unlessAborted(promise, signal) {
if (!signal) return promise;
signal.throwIfAborted();
return new Promise((resolve, reject) => {
const leave = () => reject(signal.reason);
signal.addEventListener('abort', leave, { once: true });
promise
.then(resolve, reject)
.finally(() => signal.removeEventListener('abort', leave));
});
}
/** @type {Map<string, {bytes: Buffer, at: number}>} least recently used first */
const memory = new Map();
/**
* Upstream fetches shared per tile, with the requests still waiting on each.
* @type {Map<string, {promise: Promise<Buffer>, controller: AbortController, waiters: number}>}
*/
const inFlight = new Map();
function remember(key, bytes, at = Date.now()) {
if (bytes.length > TILE_MEMORY_MAX_TILE_BYTES) return;
memory.delete(key);
memory.set(key, { bytes, at });
for (const oldest of memory.keys()) {
if (memory.size <= TILE_MEMORY_MAX_TILES) break;
memory.delete(oldest);
}
}
function recall(key) {
const hit = memory.get(key);
if (!hit) return null;
if (Date.now() - hit.at > TILE_TTL_MS) {
memory.delete(key);
return null;
}
remember(key, hit.bytes, hit.at); // most recently used again
return hit.bytes;
}
/** Start the upstream fetch for a tile, shared until its last request leaves. */
function startFlight(address) {
const controller = new AbortController();
const flight = { controller, waiters: 0, promise: null };
flight.promise = acquireUpstreamSlot(controller.signal)
.then((release) =>
fetchUpstream(address, controller.signal).finally(release),
)
.then((raw) => trim(address, raw))
const flight = { controller, waiters: 0 };
flight.promise = withUpstreamSlot(controller.signal, () =>
fetchUpstream(address, controller.signal),
)
.then((bytes) => {
memoryPut(address.key, bytes);
writeDisk(address, bytes);
remember(address.key, bytes);
return bytes;
})
.finally(() => {
if (_inFlight.get(address.key) === flight) _inFlight.delete(address.key);
if (inFlight.get(address.key) === flight) inFlight.delete(address.key);
});
// Every waiter may have left before it settles; nobody awaits it then.
// Every request may have left; the rejection is then nobody's.
flight.promise.catch(() => {});
_inFlight.set(address.key, flight);
inFlight.set(address.key, flight);
return flight;
}
/**
* Wait on a shared flight. A caller's abort rejects only that caller; the
* fetch is cancelled when the last waiter leaves.
* One tile from memory, a shared in-flight fetch, or Mapillary. `signal`
* aborts when the request's client leaves; the fetch stops once all have.
* @param {{z: number, x: number, y: number, key: string}} address from parseTilePath
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<{bytes: Buffer, source: 'memory'|'inflight'|'upstream'}>}
*/
function joinFlight(flight, signal) {
signal?.throwIfAborted();
export async function fetchTile(address, { signal } = {}) {
const cached = recall(address.key);
if (cached) return { bytes: cached, source: 'memory' };
let flight = inFlight.get(address.key);
const shared = Boolean(flight);
flight ||= startFlight(address);
flight.waiters++;
return new Promise((resolve, reject) => {
let done = false;
const leave = () => {
if (done) return false;
done = true;
signal?.removeEventListener('abort', onAbort);
flight.waiters--;
return true;
};
const onAbort = () => {
if (!leave()) return;
if (flight.waiters === 0) flight.controller.abort();
reject(signal.reason);
};
signal?.addEventListener('abort', onAbort, { once: true });
flight.promise.then(
(bytes) => leave() && resolve(bytes),
(error) => leave() && reject(error),
);
});
try {
const bytes = await unlessAborted(flight.promise, signal);
return { bytes, source: shared ? 'inflight' : 'upstream' };
} finally {
if (--flight.waiters === 0) {
// A later request for the tile must not join a cancelled fetch.
if (inFlight.get(address.key) === flight) inFlight.delete(address.key);
flight.controller.abort();
}
}
}
/** Test seam: forget every cached tile held in memory. */
export function _resetTileMemoryForTest() {
_memory.clear();
_memoryBytes = 0;
_inFlight.clear();
_upstreamHold = null;
_slots = { active: 0, queue: [] };
}
/** Test seam: how many tiles memory holds and the bytes charged for them. */
export function _tileMemoryForTest() {
return { entries: _memory.size, bytes: _memoryBytes };
}
/** Test seam: wait for every background write and the sweeps they start. */
export async function _settleTileWritesForTest() {
while (_background.size) await Promise.allSettled([..._background]);
}
/** Test seam: keep the disk cache in `dir` (null restores the default). */
export function _setTileCacheDirForTest(dir) {
_diskDir = dir || TILE_DISK_DIR;
_lastSweepAt = 0;
/** Test seam: forget every cached and in-flight tile. */
export function _resetTileCacheForTest() {
memory.clear();
inFlight.clear();
refusal = null;
upstreamActive = 0;
upstreamWaiting.length = 0;
}
-13
View File
@@ -41,16 +41,3 @@ export function stripTileLayers(bytes, dropNames) {
}, null);
return dropped ? Buffer.from(writer.finish()) : bytes;
}
/** Names of the layers inside a vector tile, in order. */
export function listTileLayers(bytes) {
const names = [];
if (!bytes?.length) return names;
new PbfReader(bytes).readFields((tag, _result, pbf) => {
if (tag !== TILE_LAYER_FIELD) return;
const end = pbf.readVarint() + pbf.pos;
names.push(layerName(bytes.subarray(pbf.pos, end)));
pbf.pos = end;
}, null);
return names;
}
+1 -11
View File
@@ -32,21 +32,11 @@ function defaultProviders(sources) {
* including when the list is empty.
*/
export function createApplicationStreetLevel({
surface,
sources = {},
providers = defaultProviders(sources),
}) {
return createStreetLevelLayer({
providers,
services: {
sprites,
picking,
input,
scenePick,
render,
ground: surface?.groundFloor ?? null,
meshFloor: surface?.meshFloor ?? null,
terrain: surface?.terrain ?? null,
},
services: { sprites, picking, input, scenePick, render },
});
}
+16
View File
@@ -311,6 +311,22 @@ export const DATA_CREDITS = [
'Traffic cameras (Toronto): contains information licensed under the ' +
'<a href="https://www.toronto.ca/city-government/data-research-maps/open-data/open-data-licence/" target="_blank" rel="noopener">Open Government Licence – Toronto</a>',
},
{
key: 'mapillary',
html: 'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>',
},
{
key: 'google-street-view',
html: 'Street Level: Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>',
},
{
key: 'kartaview',
html: 'Street Level: imagery © <a href="https://kartaview.org" target="_blank" rel="noopener">KartaView</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/4.0/" target="_blank" rel="noopener">CC BY-SA 4.0</a>',
},
{
key: 'panoramax',
html: 'Street Level: imagery © <a href="https://panoramax.fr" target="_blank" rel="noopener">Panoramax</a> contributors, licence per picture',
},
{
key: 'gbfs',
html:
+13
View File
@@ -128,3 +128,16 @@ test('the NYC camera credit carries NYC DOT’s requested disclaimer', () => {
assert.match(credit.html, /make no representation as to the accuracy/);
assert.match(credit.html, /observe posted signage/);
});
test('every Street Level imagery source is in the attribution lightbox', () => {
for (const key of [
'mapillary',
'google-street-view',
'kartaview',
'panoramax',
])
assert.ok(
DATA_CREDITS.some((entry) => entry.key === key),
`${key} has no credit`,
);
});
+8 -12
View File
@@ -908,9 +908,7 @@
"Red": "Rojo",
"Clear": "Borrar",
"Remove every mark from the board": "Quitar todas las marcas del tablero",
"Point the camera at the globe for street-level coverage": "Apunta la cámara al globo para ver la cobertura a pie de calle",
"No street-level imagery providers configured": "No hay proveedores de imágenes a pie de calle configurados",
"Selected": "Seleccionada",
"Live Vessels": "Buques en vivo",
"Bike Share": "Bicis compartidas",
"Cameras": "Cámaras",
@@ -920,7 +918,6 @@
"Active Fires": "Incendios activos",
"Street Level could not start": "No se pudo iniciar A pie de calle",
"Street Level toggle failed": "No se pudo activar o desactivar la capa A pie de calle",
"Street-level image": "Imagen a pie de calle",
"SHRINK": "REDUCIR",
"EXPAND": "AMPLIAR",
"Shrink": "Reducir",
@@ -954,9 +951,6 @@
"many": "{n} calles a la vista · haz clic en una línea para su foto más cercana",
"other": "{n} calles a la vista · haz clic en una línea para su foto más cercana"
},
"Camera follows view: move the globe camera wherever the street-level view looks": "La cámara sigue la vista: mueve la cámara del globo hacia donde mira la vista a pie de calle",
"Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE": "Seguir con la cámara requiere el mapa Google 3D: elige Google 3D en FUENTE DEL MAPA",
"Switch a provider on to draw its coverage.": "Activa un proveedor para dibujar su cobertura.",
"Loading this sequence…": "Cargando esta secuencia…",
"{n} images in this sequence · Esc clears": {
"one": "{n} imagen en esta secuencia · Esc quita la selección",
@@ -969,14 +963,11 @@
"other": "{n} secuencias a la vista · haz clic en una línea para ver sus fotos"
},
"STREET LEVEL": "A PIE DE CALLE",
"Expand the street-level view (Esc shrinks it)": "Ampliar la vista a pie de calle (Esc la reduce)",
"Image fit": "Ajuste de la imagen",
"FIT": "AJUSTAR",
"Fit: show the whole photo, with dark bars where its shape differs from the frame": "Ajustar: muestra la foto entera, con barras oscuras donde su forma no coincide con el marco",
"FILL": "RELLENAR",
"Fill: fill the frame edge to edge, cropping the photo": "Rellenar: llena el marco de borde a borde, recortando la foto",
"Camera follows view": "La cámara sigue la vista",
"FOLLOW": "SEGUIR",
"Close the image and deselect it on the map": "Cerrar la imagen y quitar la selección en el mapa",
"Close the street-level image": "Cerrar la imagen a pie de calle",
"Street-level viewer": "Visor a pie de calle",
@@ -989,7 +980,6 @@
"ALL": "TODAS",
"FLAT": "PLANAS",
"SINCE": "DESDE",
"Coverage colours": "Colores de cobertura",
"That shape already has {count} points — finish it or press Backspace.": "Esa forma ya tiene {count} puntos — termínala o pulsa Backspace.",
"Could not place the shape: {error}": "No se pudo colocar la forma: {error}",
"{owner} is using the pointer — close it first.": "Puntero en uso: {owner}. Ciérralo primero.",
@@ -1506,7 +1496,6 @@
"DISCONNECT": "DESCONECTAR",
"CONNECT": "CONECTAR",
"LOCATING…": "UBICANDO…",
"Drag to resize · double-click the header to snap back": "Arrastra para cambiar el tamaño · doble clic en el encabezado para restaurarlo",
"CH {index} / {total}": "CANAL {index} / {total}",
"Station unavailable after directory refresh": "Emisora no disponible tras actualizar el directorio",
"Clear reading": "Borrar lectura",
@@ -2443,7 +2432,6 @@
"Exit cockpit to fly to a scene": "Sal de la cabina para volar a una escena",
"Go to expanded Radio section": "Ir a la sección de Radio expandida",
"Expand Radio section in Context": "Expandir la sección de Radio en Contexto",
"Double-click the header to snap the panel back": "Haz doble clic en el encabezado para devolver el panel a su sitio",
"New scene name": "Nombre de la nueva escena",
"Delete scene \"{scene}\" and all shots?": "¿Eliminar la escena \"{scene}\" y todos sus planos?",
"Detailed outline unavailable": "Contorno detallado no disponible",
@@ -2891,6 +2879,14 @@
"SDR decoder did not start; receiver stopped": "El decodificador SDR no arrancó; receptor detenido",
"SDR decoder stopped responding; receiver stopped": "El decodificador SDR dejó de responder; receptor detenido",
"SDR decoder fell behind; receiver stopped": "El decodificador SDR se quedó atrás; receptor detenido",
"Zoom in to see street-level coverage": "Acércate para ver la cobertura a pie de calle",
"Point the camera at the ground for street-level coverage": "Apunta la cámara al suelo para ver la cobertura a pie de calle",
"Sequence images unavailable": "Las imágenes de la secuencia no están disponibles",
"Switch Street Level on to draw its coverage.": "Activa A pie de calle para dibujar su cobertura.",
"Every provider is switched off: light a chip to draw its coverage.": "Todos los proveedores están apagados: enciende uno para dibujar su cobertura.",
"No sequences in view match the filter": "Ninguna secuencia a la vista coincide con el filtro",
"No coverage in view": "No hay cobertura a la vista",
"Show the street-level view full screen (Esc shrinks it)": "Mostrar la vista a pie de calle a pantalla completa (Esc la reduce)",
"Gemini returned an oversized audio chunk": "Gemini devolvió un fragmento de audio demasiado grande",
"Gemini returned an unsupported audio format": "Gemini devolvió un formato de audio no compatible",
"Gemini returned an invalid audio sample rate": "Gemini devolvió una frecuencia de muestreo de audio no válida",
+8 -12
View File
@@ -916,9 +916,7 @@
"Red": "Красный",
"Clear": "Очистить",
"Remove every mark from the board": "Убрать все пометки с доски",
"Point the camera at the globe for street-level coverage": "Наведите камеру на глобус, чтобы увидеть покрытие уличных снимков",
"No street-level imagery providers configured": "Источники уличных снимков не настроены",
"Selected": "Выбрано",
"Live Vessels": "Суда онлайн",
"Bike Share": "Прокат велосипедов",
"Cameras": "Камеры",
@@ -928,7 +926,6 @@
"Active Fires": "Активные пожары",
"Street Level could not start": "Не удалось запустить «Уличные снимки»",
"Street Level toggle failed": "Не удалось переключить «Уличные снимки»",
"Street-level image": "Уличный снимок",
"SHRINK": "СВЕРНУТЬ",
"EXPAND": "РАЗВЕРНУТЬ",
"Shrink": "Свернуть",
@@ -964,9 +961,6 @@
"many": "{n} улиц в кадре · щёлкните линию, чтобы открыть ближайшую фотографию",
"other": "{n} улицы в кадре · щёлкните линию, чтобы открыть ближайшую фотографию"
},
"Camera follows view: move the globe camera wherever the street-level view looks": "Камера следует за видом: камера глобуса поворачивается туда, куда смотрит уличный снимок",
"Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE": "Слежению камеры нужна карта Google 3D: выберите Google 3D в разделе «ИСТОЧНИК КАРТЫ»",
"Switch a provider on to draw its coverage.": "Включите источник, чтобы показать его покрытие.",
"Loading this sequence…": "Загрузка последовательности…",
"{n} images in this sequence · Esc clears": {
"one": "{n} снимок в последовательности · Esc — сбросить",
@@ -981,14 +975,11 @@
"other": "{n} последовательности в кадре · щёлкните по линии, чтобы открыть снимки"
},
"STREET LEVEL": "УЛИЧНЫЕ СНИМКИ",
"Expand the street-level view (Esc shrinks it)": "Развернуть уличный снимок (Esc сворачивает)",
"Image fit": "Масштаб снимка",
"FIT": "ВПИСАТЬ",
"Fit: show the whole photo, with dark bars where its shape differs from the frame": "Вписать: показать снимок целиком, с тёмными полосами там, где его форма не совпадает с рамкой",
"FILL": "ЗАПОЛНИТЬ",
"Fill: fill the frame edge to edge, cropping the photo": "Заполнить: заполнить рамку целиком, обрезав края снимка",
"Camera follows view": "Камера следует за видом",
"FOLLOW": "СЛЕДИТЬ",
"Close the image and deselect it on the map": "Закрыть снимок и снять выделение на карте",
"Close the street-level image": "Закрыть уличный снимок",
"Street-level viewer": "Просмотр уличных снимков",
@@ -1001,7 +992,6 @@
"ALL": "ВСЕ",
"FLAT": "ПЛОСКИЕ",
"SINCE": "С ДАТЫ",
"Coverage colours": "Цвета покрытия",
"That shape already has {count} points — finish it or press Backspace.": "В этой фигуре уже точек: {count} — завершите её или нажмите Backspace.",
"Could not place the shape: {error}": "Не удалось разместить фигуру: {error}",
"{owner} is using the pointer — close it first.": "Указатель занят: «{owner}». Закройте его.",
@@ -1531,7 +1521,6 @@
"DISCONNECT": "ОТКЛЮЧИТЬ",
"CONNECT": "ПОДКЛЮЧИТЬ",
"LOCATING…": "ОПРЕДЕЛЯЮ…",
"Drag to resize · double-click the header to snap back": "Перетащите, чтобы изменить размер · двойной щелчок по заголовку — вернуть на место",
"CH {index} / {total}": "КАН. {index} / {total}",
"Station unavailable after directory refresh": "Станция недоступна после обновления каталога",
"Clear reading": "Очистить показания",
@@ -2484,7 +2473,6 @@
"Exit cockpit to fly to a scene": "Выйдите из кабины, чтобы перелететь к сцене",
"Go to expanded Radio section": "Перейти к развёрнутому разделу радио",
"Expand Radio section in Context": "Развернуть раздел «Радио» в панели «Контекст»",
"Double-click the header to snap the panel back": "Двойной щелчок по заголовку — вернуть панель на место",
"New scene name": "Название новой сцены",
"Delete scene \"{scene}\" and all shots?": "Удалить сцену «{scene}» и все кадры?",
"Detailed outline unavailable": "Подробный контур недоступен",
@@ -2958,6 +2946,14 @@
"SDR decoder did not start; receiver stopped": "Декодер SDR не запустился; приёмник остановлен",
"SDR decoder stopped responding; receiver stopped": "Декодер SDR перестал отвечать; приёмник остановлен",
"SDR decoder fell behind; receiver stopped": "Декодер SDR не успевает; приёмник остановлен",
"Zoom in to see street-level coverage": "Приблизьтесь, чтобы увидеть покрытие уличных снимков",
"Point the camera at the ground for street-level coverage": "Направьте камеру на землю, чтобы увидеть покрытие уличных снимков",
"Sequence images unavailable": "Снимки последовательности недоступны",
"Switch Street Level on to draw its coverage.": "Включите «Уличные снимки», чтобы показать покрытие.",
"Every provider is switched off: light a chip to draw its coverage.": "Все источники выключены: включите один, чтобы показать его покрытие.",
"No sequences in view match the filter": "Ни одна последовательность в кадре не подходит под фильтр",
"No coverage in view": "В кадре нет покрытия",
"Show the street-level view full screen (Esc shrinks it)": "Показать уличный снимок на весь экран (Esc сворачивает)",
"Gemini returned an oversized audio chunk": "Gemini вернул слишком большой фрагмент аудио",
"Gemini returned an unsupported audio format": "Gemini вернул неподдерживаемый формат аудио",
"Gemini returned an invalid audio sample rate": "Gemini вернул недопустимую частоту дискретизации аудио",
-209
View File
@@ -1,209 +0,0 @@
import * as Cesium from 'cesium';
import { FOLLOW_EYE_HEIGHT_M } from './policy.js';
/** Plausible ellipsoidal land heights: Dead Sea to Everest, with geoid slack. */
const SURFACE_MIN_M = -500;
const SURFACE_MAX_M = 9000;
/** Drive the globe camera from the street-level pose: continuously (follow) or once (framing). */
export function createCameraFollow({ state, parts }) {
const { render } = state.services;
function requestRender() {
render?.governorRequestRender?.('street-level-follow');
}
/**
* The application's camera authority (`attachNavigation`): `run(noun, move)`
* releases tracking and other owners before `move`, or refuses (cockpit);
* `begin(noun)` takes a deferred ticket (a generation, or false) that
* `reassert(generation)` honours only if nothing newer took the camera;
* `subscribeHandoff` reports a newer owner. Without it, moves run directly.
*/
let navigation = null;
let unsubscribeHandoff = null;
/** Set while our own claim stamps a handoff, so it does not end FOLLOW. */
let claiming = false;
/** Take the camera for `move`; false when the application refuses. */
function claim(move) {
if (!navigation?.run) {
move();
return true;
}
claiming = true;
try {
return (
navigation.run('photo', () => {
move();
return true;
}) === true
);
} finally {
claiming = false;
}
}
/**
* Ask for the camera as a photo starts opening. The ticket goes to
* `lookAtPosition`; null when the application refuses (cockpit).
*/
function beginFraming() {
if (!navigation?.begin || !navigation.reassert) return { generation: null };
claiming = true;
try {
const generation = navigation.begin('photo');
return generation === false ? null : { generation };
} finally {
claiming = false;
}
}
/** Another feature took the camera: stop following it. */
function onHandoff() {
if (claiming || !state.street.follow) return;
state.street.follow = false;
state.notify?.();
}
function attachNavigation(next) {
unsubscribeHandoff?.();
navigation = next || null;
unsubscribeHandoff = navigation?.subscribeHandoff?.(onHandoff) || null;
}
/**
* Ground under a photo. `sampleHeight` can return kilometres below the
* ellipsoid before tiles load, so a sample counts only when plausible and
* within the mesh window around bare earth (once that is known).
*/
function groundHeightAt(lon, lat, fallback) {
const scene = state.viewer?.scene;
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
const caster = parts?.groundCaster;
const dem = caster?.groundAt(lon, lat) ?? null;
if (dem === null) caster?.prepare([[lon, lat]]);
// The application's mesh window around bare earth, once that is known.
const withinPrior = state.services.ground?.meshFloorSampleWithinPrior;
const plausible = (height) =>
Number.isFinite(height) &&
height >= SURFACE_MIN_M &&
height <= SURFACE_MAX_M &&
(dem === null || !withinPrior || withinPrior(height, dem));
let height = null;
try {
if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto);
} catch {
/* not sampleable yet */
}
if (!plausible(height)) height = dem;
if (!plausible(height)) height = scene?.globe?.getHeight?.(carto);
if (!plausible(height)) height = fallback;
return plausible(height) ? height : 0;
}
/** Put the Cesium camera where the street-level camera is. */
function followCamera() {
const { position, bearing, tilt, follow, altitude } = state.street;
if (!follow || !position || !state.viewer) return;
const ground = groundHeightAt(position.lon, position.lat, altitude);
state.viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(
position.lon,
position.lat,
ground + FOLLOW_EYE_HEIGHT_M,
),
orientation: {
heading: Cesium.Math.toRadians(bearing || 0),
pitch: Cesium.Math.toRadians(Number.isFinite(tilt) ? tilt : 0),
roll: 0,
},
});
requestRender();
}
/** Our framing flight while it is current; Cesium calls `cancel` when another flight starts. */
let framing = null;
/**
* Frame the current image from a short distance behind it, unless another
* action took the camera since `ticket` (from `beginFraming`) was issued.
*/
function lookAtPosition(ticket) {
const { position, bearing, altitude } = state.street;
if (!ticket || !position || !state.viewer) return;
if (
ticket.generation !== null &&
!navigation?.reassert?.(ticket.generation)
)
return;
const ground = groundHeightAt(position.lon, position.lat, altitude);
const fly = () => {
// Owned before the call: starting it cancels the previous flight (ours
// included) synchronously, and a zero-length one completes at once.
const flight = {};
framing = flight;
const release = () => {
if (framing === flight) framing = null;
};
state.viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
4,
),
{
offset: new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(bearing || 0),
Cesium.Math.toRadians(-32),
140,
),
duration: 1.6,
complete: release,
cancel: release,
},
);
};
// A reasserted ticket has already released the other owners.
if (ticket.generation === null) claim(fly);
else fly();
}
/** Stop our framing flight if it is still in the air; another feature's flight is left alone. */
function cancelFraming() {
if (!framing) return;
// Give up ownership first: cancelFlight delivers `cancel` synchronously.
framing = null;
state.viewer?.camera?.cancelFlight?.();
}
function setFollow(enabled) {
const wanted = enabled === true && state.street.followAvailable;
if (wanted && !state.street.follow) {
// Following owns the camera until another feature takes it.
const granted = claim(() => {
state.street.follow = true;
// The framing tween would keep overwriting the follow view until it lands.
cancelFraming();
followCamera();
});
if (!granted) state.street.follow = false;
} else if (!wanted) {
state.street.follow = false;
}
state.notify?.();
}
function destroy() {
attachNavigation(null);
}
return {
followCamera,
beginFraming,
lookAtPosition,
cancelFraming,
setFollow,
attachNavigation,
destroy,
};
}
@@ -1,265 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createCameraFollow } from './cameraFollow.js';
import { FOLLOW_EYE_HEIGHT_M } from './policy.js';
/** Height (m) of a Cartesian above the WGS84 ellipsoid. */
const heightOf = (cartesian) =>
Cesium.Cartographic.fromCartesian(cartesian).height;
/**
* A camera with Cesium's flight bookkeeping: a new flight or `cancelFlight`
* cancels the current one, `land()` completes it, `cancelled` counts stops.
*/
function flightCamera(flights, views) {
let current = null;
function stop() {
const flight = current;
current = null;
flight?.options.cancel?.();
return Boolean(flight);
}
const camera = {
cancelled: 0,
flyToBoundingSphere(sphere, options = {}) {
stop();
current = { sphere, options };
flights.push(current);
},
cancelFlight() {
if (stop()) camera.cancelled++;
},
land() {
const flight = current;
current = null;
flight?.options.complete?.();
},
setView: (view) => views.push(view),
};
return camera;
}
/**
* A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles
* under the photo load) and whose bare earth is `dem`.
*/
function setup({ sampled, dem = null, globe = null, altitude = 149 }) {
const flights = [];
const views = [];
const state = {
services: {},
street: {
position: { lon: -97.7364, lat: 30.2672 },
bearing: 105,
tilt: -10,
altitude,
follow: false,
followAvailable: true,
},
viewer: {
scene: {
sampleHeightSupported: true,
sampleHeight: () => sampled,
globe: { getHeight: () => globe },
},
camera: flightCamera(flights, views),
},
};
const parts = {
groundCaster: dem === null ? null : { groundAt: () => dem, prepare() {} },
};
return {
follow: createCameraFollow({ state, parts }),
state,
flights,
views,
};
}
test('framing a photo ignores a sample kilometres underground and uses bare earth', () => {
const { follow, flights } = setup({ sampled: -14_886, dem: 117 });
follow.lookAtPosition(follow.beginFraming());
assert.equal(flights.length, 1);
const centre = heightOf(flights[0].sphere.center);
assert.ok(
Math.abs(centre - 119) < 0.01,
`centre at ${centre} m, not underground`,
);
});
test('a plausible mesh sample near bare earth wins (a street, or a modest roof)', () => {
const { follow, flights } = setup({ sampled: 121, dem: 117 });
follow.lookAtPosition(follow.beginFraming());
assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 123) < 0.01);
});
test('without bare earth an absurd sample falls back to the image altitude, never below the surface range', () => {
const { follow, flights } = setup({
sampled: -14_886,
dem: null,
globe: undefined,
altitude: 149,
});
follow.lookAtPosition(follow.beginFraming());
assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 151) < 0.01);
});
test('following stands the camera at eye height above the checked ground', () => {
const { follow, state, views } = setup({ sampled: -14_886, dem: 117 });
state.street.follow = true;
follow.followCamera();
assert.equal(views.length, 1);
assert.ok(
Math.abs(heightOf(views[0].destination) - (117 + FOLLOW_EYE_HEIGHT_M)) <
0.01,
);
});
test('cancelFraming stops the framing flight while it is still ours', () => {
const { follow, state } = setup({ sampled: 121, dem: 117 });
const { camera } = state.viewer;
follow.lookAtPosition(follow.beginFraming());
follow.cancelFraming();
assert.equal(camera.cancelled, 1, 'the flight toward the closed photo stops');
follow.cancelFraming();
assert.equal(camera.cancelled, 1, 'and only once');
// Re-framing (the next photo) supersedes the first flight, not the claim.
follow.lookAtPosition(follow.beginFraming());
follow.lookAtPosition(follow.beginFraming());
follow.cancelFraming();
assert.equal(camera.cancelled, 2);
});
test('cancelFraming leaves a landed flight and a newer navigation flight alone', () => {
const { follow, state, flights } = setup({ sampled: 121, dem: 117 });
const { camera } = state.viewer;
follow.lookAtPosition(follow.beginFraming());
camera.land();
follow.cancelFraming();
assert.equal(camera.cancelled, 0, 'landed: nothing to stop');
follow.lookAtPosition(follow.beginFraming());
// A search result flies the globe elsewhere; Cesium cancels ours first.
camera.flyToBoundingSphere(null, {});
assert.equal(flights.length, 3);
follow.cancelFraming();
assert.equal(camera.cancelled, 0, 'the newer flight keeps going');
});
test('turning FOLLOW on stops the framing flight so it cannot fight the follow view', () => {
const { follow, state, views } = setup({ sampled: 121, dem: 117 });
const { camera } = state.viewer;
follow.lookAtPosition(follow.beginFraming());
follow.setFollow(true);
assert.equal(camera.cancelled, 1, 'the framing tween stops');
assert.equal(views.length, 1, 'and the camera stands at the photo');
});
/**
* The application's camera authority: `run` releases tracking (here, a tracked
* entity) and stamps a handoff before the move, or refuses like the cockpit.
*/
function navigationFor(state, { refuse = false } = {}) {
const listeners = new Set();
let generation = 0;
const stamp = () => {
generation++;
for (const listener of listeners) listener({ generation });
return generation;
};
const nav = {
runs: [],
begins: [],
run(noun, move) {
nav.runs.push(noun);
if (refuse) return false;
const current = stamp();
state.viewer.trackedEntity = undefined;
return move(current);
},
/** Deferred: stamp now, release only on a successful reassert. */
begin(noun) {
nav.begins.push(noun);
return refuse ? false : stamp();
},
reassert(ticket) {
if (ticket !== generation) return false;
state.viewer.trackedEntity = undefined;
return true;
},
subscribeHandoff(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
/** Another feature takes the camera. */
handOff: () => stamp(),
listeners,
};
return nav;
}
test('a photo claims the camera when it starts opening and frames once loaded, releasing tracking', () => {
const { follow, state, flights } = setup({ sampled: 160 });
state.viewer.trackedEntity = { id: 'aircraft' };
const nav = navigationFor(state);
follow.attachNavigation(nav);
const ticket = follow.beginFraming();
assert.deepEqual(nav.begins, ['photo']);
assert.ok(state.viewer.trackedEntity, 'nothing is released while it loads');
follow.lookAtPosition(ticket);
assert.equal(state.viewer.trackedEntity, undefined);
assert.equal(flights.length, 1);
});
test('a photo that finishes loading after newer navigation does not frame', () => {
const { follow, state, flights } = setup({ sampled: 160 });
const nav = navigationFor(state);
follow.attachNavigation(nav);
const ticket = follow.beginFraming();
// The user flies to Sacramento, and then tracks an aircraft, while it loads.
nav.handOff();
state.viewer.trackedEntity = { id: 'aircraft' };
follow.lookAtPosition(ticket);
assert.equal(flights.length, 0, 'no flight back to the photo');
assert.deepEqual(state.viewer.trackedEntity, { id: 'aircraft' });
});
test('a refused claim (cockpit) neither frames the photo nor turns FOLLOW on', () => {
const { follow, state, flights, views } = setup({ sampled: 160 });
follow.attachNavigation(navigationFor(state, { refuse: true }));
follow.lookAtPosition(follow.beginFraming());
follow.setFollow(true);
assert.equal(flights.length, 0);
assert.equal(views.length, 0);
assert.equal(state.street.follow, false);
});
test('FOLLOW claims the camera once and ends when another feature takes it', () => {
const { follow, state, views } = setup({ sampled: 160 });
let notified = 0;
state.notify = () => notified++;
const nav = navigationFor(state);
follow.attachNavigation(nav);
follow.setFollow(true);
assert.equal(state.street.follow, true, 'our own handoff keeps FOLLOW on');
assert.deepEqual(nav.runs, ['photo']);
follow.followCamera();
assert.deepEqual(nav.runs, ['photo'], 'pose updates do not claim again');
assert.equal(views.length, 2);
nav.handOff();
assert.equal(state.street.follow, false);
assert.ok(notified >= 2);
follow.followCamera();
assert.equal(views.length, 2, 'the camera is no longer driven');
});
test('destroying the follow part stops listening for handoffs', () => {
const { follow, state } = setup({ sampled: 160 });
const nav = navigationFor(state);
follow.attachNavigation(nav);
assert.equal(nav.listeners.size, 1);
follow.destroy();
assert.equal(nav.listeners.size, 0);
});
+83
View File
@@ -0,0 +1,83 @@
import * as Cesium from 'cesium';
import { plausibleSurfaceHeight, sampledSurfaceHeight } from './view.js';
/** Fly the globe camera once to frame a photo after it opens. */
export function createCameraFraming({ state }) {
/**
* The application's camera authority: `reassert(begin())` succeeds only if
* nothing newer took the camera. Without it, framing flies directly.
*/
let navigation = null;
/** Our framing flight while it is current; Cesium calls `cancel` when another flight starts. */
let flight = null;
function attachNavigation(next) {
navigation = next || null;
}
/** Ask for the camera as a photo starts opening: a ticket for `frame`, null when refused (cockpit). */
function begin() {
if (!navigation?.begin || !navigation.reassert) return { generation: null };
const generation = navigation.begin('photo');
return generation === false ? null : { generation };
}
/**
* Ground under a photo. `sampleHeight` can return kilometres below the
* ellipsoid before tiles load, so only a plausible height counts.
*/
function groundHeightAt(lon, lat, fallback) {
const scene = state.viewer?.scene;
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
let height = sampledSurfaceHeight(scene, carto);
if (height === null) height = scene?.globe?.getHeight?.(carto);
if (!plausibleSurfaceHeight(height)) height = fallback;
return plausibleSurfaceHeight(height) ? height : 0;
}
/**
* Frame the open photo from a short distance behind it, unless another
* action took the camera since `ticket` (from `begin`) was issued.
*/
function frame(ticket) {
const { position, bearing, altitude } = state.street;
if (!ticket || !position || !state.viewer) return;
// Detached (shell teardown) since the ticket was issued: nothing to frame for.
if (ticket.generation !== null && !navigation?.reassert(ticket.generation))
return;
const ground = groundHeightAt(position.lon, position.lat, altitude);
// Owned before the call: starting it cancels the previous flight (ours
// included) synchronously, and a zero-length one completes at once.
const current = {};
flight = current;
const release = () => {
if (flight === current) flight = null;
};
state.viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
4,
),
{
offset: new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(bearing || 0),
Cesium.Math.toRadians(-32),
140,
),
duration: 1.6,
complete: release,
cancel: release,
},
);
}
/** Stop our framing flight if it is still in the air; another feature's flight is left alone. */
function cancel() {
if (!flight) return;
// Give up ownership first: cancelFlight delivers `cancel` synchronously.
flight = null;
state.viewer?.camera?.cancelFlight?.();
}
return { attachNavigation, begin, frame, cancel };
}
@@ -0,0 +1,159 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createCameraFraming } from './cameraFraming.js';
import { fakeCesiumViewer } from '../../testSupport/streetLevelFakes.mjs';
/** Height (m) of a Cartesian above the WGS84 ellipsoid. */
const heightOf = (cartesian) =>
Cesium.Cartographic.fromCartesian(cartesian).height;
/**
* A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles
* under the photo load) and whose globe terrain is `globe`.
*/
function setup({ sampled, globe = null, altitude = 149 }) {
const viewer = fakeCesiumViewer();
Object.assign(viewer.scene, {
sampleHeightSupported: true,
sampleHeight: () => sampled,
globe: { getHeight: () => globe },
});
const state = {
services: {},
street: {
position: { lon: -97.7364, lat: 30.2672 },
bearing: 105,
altitude,
},
viewer,
};
return {
framing: createCameraFraming({ state }),
state,
viewer,
flights: viewer.flights.list,
};
}
test('framing a photo stands the sphere on a plausible scene sample', () => {
const { framing, flights } = setup({ sampled: 121 });
framing.frame(framing.begin());
assert.equal(flights.length, 1);
assert.ok(Math.abs(heightOf(flights[0].sphere.center) - 123) < 0.01);
});
test('a sample kilometres underground falls back to the globe, then the image altitude', () => {
const globe = setup({ sampled: -14_886, globe: 117 });
globe.framing.frame(globe.framing.begin());
assert.ok(Math.abs(heightOf(globe.flights[0].sphere.center) - 119) < 0.01);
const altitude = setup({
sampled: -14_886,
globe: undefined,
altitude: 149,
});
altitude.framing.frame(altitude.framing.begin());
assert.ok(Math.abs(heightOf(altitude.flights[0].sphere.center) - 151) < 0.01);
});
test('cancel stops the framing flight while it is still ours', () => {
const { framing, viewer } = setup({ sampled: 121 });
framing.frame(framing.begin());
framing.cancel();
assert.equal(
viewer.flights.cancelled,
1,
'the flight toward the photo stops',
);
framing.cancel();
assert.equal(viewer.flights.cancelled, 1, 'and only once');
// Re-framing (the next photo) supersedes the first flight, not the claim.
framing.frame(framing.begin());
framing.frame(framing.begin());
framing.cancel();
assert.equal(viewer.flights.cancelled, 2);
});
test('cancel leaves a landed flight and a newer navigation flight alone', () => {
const { framing, viewer, flights } = setup({ sampled: 121 });
framing.frame(framing.begin());
viewer.flights.land();
framing.cancel();
assert.equal(viewer.flights.cancelled, 0, 'landed: nothing to stop');
framing.frame(framing.begin());
// A search result flies the globe elsewhere; Cesium cancels ours first.
viewer.camera.flyToBoundingSphere(null, {});
assert.equal(flights.length, 3);
framing.cancel();
assert.equal(viewer.flights.cancelled, 0, 'the newer flight keeps going');
});
/**
* The application's camera authority: `begin` stamps a generation (or refuses
* like the cockpit), `reassert` releases tracking (here, a tracked entity)
* only while that generation is current.
*/
function navigationFor(state, { refuse = false } = {}) {
let generation = 0;
const stamp = () => ++generation;
const nav = {
begins: [],
/** Deferred: stamp now, release only on a successful reassert. */
begin(noun) {
nav.begins.push(noun);
return refuse ? false : stamp();
},
reassert(ticket) {
if (ticket !== generation) return false;
state.viewer.trackedEntity = undefined;
return true;
},
/** Another feature takes the camera. */
handOff: () => stamp(),
};
return nav;
}
test('a photo claims the camera when it starts opening and frames once loaded, releasing tracking', () => {
const { framing, state, flights } = setup({ sampled: 160 });
state.viewer.trackedEntity = { id: 'aircraft' };
const nav = navigationFor(state);
framing.attachNavigation(nav);
const ticket = framing.begin();
assert.deepEqual(nav.begins, ['photo']);
assert.ok(state.viewer.trackedEntity, 'nothing is released while it loads');
framing.frame(ticket);
assert.equal(state.viewer.trackedEntity, undefined);
assert.equal(flights.length, 1);
});
test('a photo that finishes loading after newer navigation does not frame', () => {
const { framing, state, flights } = setup({ sampled: 160 });
const nav = navigationFor(state);
framing.attachNavigation(nav);
const ticket = framing.begin();
// The user flies to Sacramento, and then tracks an aircraft, while it loads.
nav.handOff();
state.viewer.trackedEntity = { id: 'aircraft' };
framing.frame(ticket);
assert.equal(flights.length, 0, 'no flight back to the photo');
assert.deepEqual(state.viewer.trackedEntity, { id: 'aircraft' });
});
test('a refused claim (cockpit) does not frame the photo', () => {
const { framing, state, flights } = setup({ sampled: 160 });
framing.attachNavigation(navigationFor(state, { refuse: true }));
framing.frame(framing.begin());
assert.equal(flights.length, 0);
});
test('a photo that loads after the navigation was detached does not frame', () => {
const { framing, state, flights } = setup({ sampled: 160 });
framing.attachNavigation(navigationFor(state));
const ticket = framing.begin();
framing.attachNavigation(null);
assert.doesNotThrow(() => framing.frame(ticket));
assert.equal(flights.length, 0);
});
+1 -1
View File
@@ -27,7 +27,7 @@ export function sameFilter(a, b) {
/**
* Stored filter (relative days, stable in share links) to the absolute form
* providers compare capture times against.
* coverage, cones and lookups compare capture times against.
* @returns {{pano: string, sinceMs: number|null}}
*/
export function resolveFilter(filter, now = Date.now()) {
+41 -58
View File
@@ -1,48 +1,27 @@
/** Canvas billboard glyphs (image cone, pano ring, position marker); no Cesium needed. */
/** Billboard glyphs as SVG data URLs: the image cone, the 360° ring and the position markers. */
const _cache = new Map();
const px = (n) => Number(n.toFixed(2));
/** A square canvas glyph drawn once per key, centred at (c, c). */
function cachedGlyph(key, size, draw) {
if (_cache.has(key)) return _cache.get(key);
const element = document.createElement('canvas');
element.width = size;
element.height = size;
draw(element.getContext('2d'), size / 2);
_cache.set(key, element);
return element;
function svg(size, body) {
const markup = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">${body}</svg>`;
return `data:image/svg+xml;utf8,${encodeURIComponent(markup)}`;
}
/** A filled wedge from the centre, pointing "up", `halfAngle` radians wide. */
function wedge(ctx, c, radius, halfAngle, color, alpha) {
ctx.beginPath();
ctx.moveTo(c, c);
ctx.arc(c, c, radius, -Math.PI / 2 - halfAngle, -Math.PI / 2 + halfAngle);
ctx.closePath();
ctx.fillStyle = color;
ctx.globalAlpha = alpha;
ctx.fill();
ctx.globalAlpha = 1;
function wedge(size, radius, halfAngle, color, opacity) {
const c = size / 2;
const dx = px(radius * Math.sin(halfAngle));
const dy = px(radius * Math.cos(halfAngle));
const r = px(radius);
return `<path d="M${c} ${c}L${px(c - dx)} ${px(c - dy)}A${r} ${r} 0 0 1 ${px(c + dx)} ${px(c - dy)}Z" fill="${color}" fill-opacity="${opacity}"/>`;
}
function ring(ctx, c, radius, color, width, alpha) {
ctx.beginPath();
ctx.arc(c, c, radius, 0, Math.PI * 2);
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.globalAlpha = alpha;
ctx.stroke();
ctx.globalAlpha = 1;
function ring(size, radius, color, width, opacity) {
return `<circle cx="${size / 2}" cy="${size / 2}" r="${px(radius)}" fill="none" stroke="${color}" stroke-width="${px(width)}" stroke-opacity="${opacity}"/>`;
}
function dot(ctx, c, radius, color, outline, width) {
ctx.beginPath();
ctx.arc(c, c, radius, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.lineWidth = width;
ctx.strokeStyle = outline;
ctx.stroke();
function dot(size, radius, color, outline, width) {
return `<circle cx="${size / 2}" cy="${size / 2}" r="${px(radius)}" fill="${color}" stroke="${outline}" stroke-width="${px(width)}"/>`;
}
/** Cone glyph pointing "up"; rotate the billboard by the compass angle. */
@@ -51,34 +30,38 @@ export function imageConeGlyph({
color = '#e8eaed',
pano = false,
} = {}) {
return cachedGlyph(`cone:${size}:${color}:${pano}`, size, (ctx, c) => {
if (pano) ring(ctx, c, size * 0.4, color, Math.max(1.5, size * 0.09), 0.5);
else wedge(ctx, c, size * 0.46, 0.62, color, 0.42);
dot(
ctx,
c,
size * 0.16,
color,
'rgba(10,10,15,0.85)',
Math.max(1, size * 0.05),
);
});
const shape = pano
? ring(size, size * 0.4, color, Math.max(1.5, size * 0.09), 0.5)
: wedge(size, size * 0.46, 0.62, color, 0.42);
return svg(
size,
shape +
dot(
size,
size * 0.16,
color,
'rgba(10,10,15,0.85)',
Math.max(1, size * 0.05),
),
);
}
/** Street View-style marker: a field-of-view cone from a white-ringed dot. */
export function viewConeMarkerGlyph({ size = 56, color = '#4285f4' } = {}) {
return cachedGlyph(`view-cone:${size}:${color}`, size, (ctx, c) => {
wedge(ctx, c, size * 0.48, 0.62, color, 0.38);
wedge(ctx, c, size * 0.3, 0.62, color, 0.3);
dot(ctx, c, size * 0.13, color, '#ffffff', Math.max(2, size * 0.05));
});
return svg(
size,
wedge(size, size * 0.48, 0.62, color, 0.38) +
wedge(size, size * 0.3, 0.62, color, 0.3) +
dot(size, size * 0.13, color, '#ffffff', Math.max(2, size * 0.05)),
);
}
/** Marker for the image the viewer currently shows. */
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => {
wedge(ctx, c, size * 0.48, 0.55, color, 0.55);
dot(ctx, c, size * 0.2, color, '#0a0a0f', 2);
ring(ctx, c, size * 0.3, color, 1.5, 0.8);
});
return svg(
size,
wedge(size, size * 0.48, 0.55, color, 0.55) +
dot(size, size * 0.2, color, '#0a0a0f', 2) +
ring(size, size * 0.3, color, 1.5, 0.8),
);
}
-256
View File
@@ -1,256 +0,0 @@
/**
* Ground casting for street-level overlays on Google 3D. Draped overlays land
* on the mesh top (trees, overpasses), so casting places them at bare-earth
* (DEM) height plus a lift and lets the mesh hide what is behind buildings.
* Heights come from a coarse grid shared with the ground-floor service; a
* geoid fallback (terrain proxy down) counts as unknown and stays draped.
*/
import { wrapLon } from './tileMath.js';
/** Grid spacing in degrees (~111 m north-south): the ground-floor grid. */
export const GROUND_CAST_STEP_DEG = 0.001;
/** Longest line segment left undivided, in degrees, so lines follow the grid. */
export const GROUND_CAST_DENSIFY_DEG = 0.0005;
/** Metres above the bare earth, so lines sit on the road instead of in it. */
export const GROUND_CAST_LIFT_M = 2;
/** Grid corners one prepare call may request; more stays draped. */
export const GROUND_CAST_MAX_CORNERS = 1024;
/** Cached grid corners before the oldest are dropped to make room. */
const GROUND_CAST_CACHE_MAX = 50_000;
/**
* Mesh refinement, where the Google 3D surface was sampled (the DEM grid
* misses trenches, steep streets and roads under trees):
* - mesh up to MESH_ROAD_ABOVE_M above bare earth is the road: follow it;
* - higher mesh is over the road (canopy, deck): interpolate the road's
* offset from the nearest road samples on both sides;
* - otherwise bare earth plus GROUND_CAST_LIFT_M.
*/
export const MESH_ROAD_ABOVE_M = 3;
/** A sample this far below bare earth is a bad probe, not a road. */
export const MESH_ROAD_BELOW_M = 30;
/** Metres above the sampled road surface. */
export const MESH_LIFT_M = 1;
/** Longest run of covered points bridged from the road on either side. */
export const MESH_BRIDGE_MAX_POINTS = 8;
/** Line spacing, in degrees (~22 m), while the mesh refines heights. */
export const MESH_DENSIFY_DEG = 0.0002;
/**
* Heights for a line's points by the rule above.
* @param {Array<{dem: number, mesh?: number|null}>} points
* @param {{lift?: number}} [options]
* @returns {Array<number>}
*/
export function refineHeights(points, { lift = GROUND_CAST_LIFT_M } = {}) {
const road = points.map(({ dem, mesh }) =>
Number.isFinite(mesh) &&
mesh <= dem + MESH_ROAD_ABOVE_M &&
mesh >= dem - MESH_ROAD_BELOW_M
? mesh - dem
: null,
);
return points.map(({ dem, mesh }, i) => {
if (road[i] !== null) return dem + road[i] + MESH_LIFT_M;
if (Number.isFinite(mesh) && mesh > dem + MESH_ROAD_ABOVE_M) {
let a = i - 1;
while (a >= 0 && i - a <= MESH_BRIDGE_MAX_POINTS && road[a] === null) a--;
let b = i + 1;
while (
b < points.length &&
b - i <= MESH_BRIDGE_MAX_POINTS &&
road[b] === null
)
b++;
const left = a >= 0 && i - a <= MESH_BRIDGE_MAX_POINTS ? road[a] : null;
const right =
b < points.length && b - i <= MESH_BRIDGE_MAX_POINTS ? road[b] : null;
if (left !== null && right !== null) {
const t = (i - a) / (b - a);
return dem + left + (right - left) * t + MESH_LIFT_M;
}
}
return dem + lift;
});
}
/** Camera height (m above ground) below which Google 3D switches to terrain mode. */
export const SURFACE_TERRAIN_ENTER_M = 1400;
/** Camera height above which terrain mode switches back to draped. */
export const SURFACE_TERRAIN_EXIT_M = 1800;
/**
* Next surface mode: terrain only on Google 3D at street zoom, with
* hysteresis between the enter and exit heights.
* @param {'draped'|'terrain'} current
* @param {{photoreal: boolean, heightM: number|null, available: boolean}} view
* @returns {'draped'|'terrain'}
*/
export function nextSurfaceMode(current, { photoreal, heightM, available }) {
if (!available || !photoreal || !Number.isFinite(heightM)) return 'draped';
if (current === 'terrain')
return heightM > SURFACE_TERRAIN_EXIT_M ? 'draped' : 'terrain';
return heightM < SURFACE_TERRAIN_ENTER_M ? 'terrain' : 'draped';
}
/**
* Split long segments so a line follows the terrain. Segments across ±180°
* go the short way round, with added points wrapped into [-180, 180].
* @param {Array<[number, number]>} coords [lon, lat] pairs
* @param {number} [maxStep] longest segment, in degrees
* @returns {Array<[number, number]>}
*/
export function densifyLine(coords, maxStep = GROUND_CAST_DENSIFY_DEG) {
if (!Array.isArray(coords) || coords.length < 2) return coords || [];
const out = [coords[0]];
for (let i = 1; i < coords.length; i++) {
const [lon0, lat0] = coords[i - 1];
const [lon1, lat1] = coords[i];
const dLon = wrapLon(lon1 - lon0);
const span = Math.max(Math.abs(dLon), Math.abs(lat1 - lat0));
const parts = Math.min(64, Math.ceil(span / maxStep - 1e-9));
for (let k = 1; k < parts; k++) {
const t = k / parts;
out.push([wrapLon(lon0 + dLon * t), lat0 + (lat1 - lat0) * t]);
}
out.push(coords[i]);
}
return out;
}
/**
* A bare-earth height cache over the terrain service.
* @param {{terrain: {resolveEllipsoidalGround: Function}, step?: number, lift?: number, maxCorners?: number, cacheMax?: number}} options
*/
export function createGroundCaster({
terrain,
step = GROUND_CAST_STEP_DEG,
lift = GROUND_CAST_LIFT_M,
maxCorners = GROUND_CAST_MAX_CORNERS,
cacheMax = GROUND_CAST_CACHE_MAX,
}) {
if (typeof terrain?.resolveEllipsoidalGround !== 'function')
throw new TypeError('Ground casting requires a terrain service');
/** "i,j" grid corner → ellipsoidal ground height in metres. */
const heights = new Map();
/** Prepares run one at a time, so neighbouring tiles share their corners. */
let queue = Promise.resolve();
const cornerKey = (i, j) => `${i},${j}`;
function missingCorners(points) {
const missing = new Map();
for (const point of points) {
const [lon, lat] = point;
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
const i0 = Math.floor(lon / step);
const j0 = Math.floor(lat / step);
for (let di = 0; di <= 1; di++)
for (let dj = 0; dj <= 1; dj++) {
const key = cornerKey(i0 + di, j0 + dj);
if (!heights.has(key) && !missing.has(key))
missing.set(key, { i: i0 + di, j: j0 + dj });
}
}
return [...missing.entries()];
}
async function resolve(points, signal) {
if (signal?.aborted) return false;
// Evict before counting, or we could drop corners this request counted
// as cached. Oldest first, so the tiles still on screen keep theirs.
for (const key of heights.keys()) {
if (heights.size + maxCorners <= cacheMax) break;
heights.delete(key);
}
const missing = missingCorners(points);
if (!missing.length) return true;
if (missing.length > maxCorners) return false;
// A corner east of 180° is asked for at its wrapped longitude.
const results = await terrain.resolveEllipsoidalGround(
missing.map(([, { i, j }]) => ({
lon: Number(wrapLon(i * step).toFixed(6)),
lat: Number((j * step).toFixed(6)),
})),
{ signal },
);
let complete = true;
missing.forEach(([key], n) => {
const result = results?.[n];
if (result?.source === 'reearth' && Number.isFinite(result.ellipsoid))
heights.set(key, result.ellipsoid);
else complete = false;
});
return complete && !signal?.aborted;
}
/**
* Fetch the grid corners around the points. True when all can be cast;
* false when some are unknown, too many, or aborted. Never rejects.
* @param {Array<[number, number]>} points [lon, lat] pairs
* @param {{signal?: AbortSignal}} [options]
*/
function prepare(points, { signal } = {}) {
const run = queue.then(() => resolve(points, signal)).catch(() => false);
queue = run;
return run;
}
/**
* `prepare` for lines at both densities: the finer mesh points can fall in
* cells the coarse ones skip, and a missing cell leaves the line draped.
*/
function prepareLines(lines, options) {
return prepare(
lines.flatMap((coords) => [
...densifyLine(coords),
...densifyLine(coords, MESH_DENSIFY_DEG),
]),
options,
);
}
/** Bare-earth ellipsoidal height at a point, from cached corners only; null when unknown. */
function groundAt(lon, lat) {
const x = lon / step;
const y = lat / step;
const i0 = Math.floor(x);
const j0 = Math.floor(y);
const h00 = heights.get(cornerKey(i0, j0));
const h10 = heights.get(cornerKey(i0 + 1, j0));
const h01 = heights.get(cornerKey(i0, j0 + 1));
const h11 = heights.get(cornerKey(i0 + 1, j0 + 1));
if (h00 === undefined || h10 === undefined) return null;
if (h01 === undefined || h11 === undefined) return null;
const fx = x - i0;
const fy = y - j0;
const south = h00 + (h10 - h00) * fx;
const north = h01 + (h11 - h01) * fx;
return south + (north - south) * fy;
}
/**
* Densified line as flat [lon, lat, height, ...]; null until every corner is
* cached. With `meshAt` it is densified finer and refined by `refineHeights`.
* @param {Array<[number, number]>} coords
* @param {{meshAt?: (lon: number, lat: number) => number|null|undefined}} [options]
*/
function castLine(coords, { meshAt = null } = {}) {
const rows = [];
const points = meshAt
? densifyLine(coords, MESH_DENSIFY_DEG)
: densifyLine(coords);
for (const [lon, lat] of points) {
const dem = groundAt(lon, lat);
if (dem === null) return null;
rows.push({ lon, lat, dem, mesh: meshAt ? meshAt(lon, lat) : null });
}
const heights = refineHeights(rows, { lift });
const flat = [];
rows.forEach(({ lon, lat }, i) => flat.push(lon, lat, heights[i]));
return flat;
}
return { prepare, prepareLines, groundAt, castLine };
}
-370
View File
@@ -1,370 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
GROUND_CAST_LIFT_M,
MESH_LIFT_M,
refineHeights,
SURFACE_TERRAIN_ENTER_M,
SURFACE_TERRAIN_EXIT_M,
createGroundCaster,
densifyLine,
nextSurfaceMode,
} from './groundCast.js';
/** A terrain service whose ground rises 1 m per 0.001° east and 2 m per 0.001° north. */
function fakeTerrain({ source = 'reearth', fail = false } = {}) {
const calls = [];
return {
calls,
async resolveEllipsoidalGround(coords) {
calls.push(coords);
if (fail) throw new Error('proxy down');
return coords.map(({ lon, lat }) => ({
ellipsoid: 100 + (lon - 10) * 1000 + (lat - 50) * 2000,
source,
}));
},
};
}
test('surface mode: terrain only on Google 3D at street zoom, with hysteresis', () => {
const view = { photoreal: true, available: true };
assert.equal(nextSurfaceMode('draped', { ...view, heightM: 500 }), 'terrain');
assert.equal(
nextSurfaceMode('draped', {
...view,
heightM: (SURFACE_TERRAIN_ENTER_M + SURFACE_TERRAIN_EXIT_M) / 2,
}),
'draped',
);
assert.equal(
nextSurfaceMode('terrain', {
...view,
heightM: (SURFACE_TERRAIN_ENTER_M + SURFACE_TERRAIN_EXIT_M) / 2,
}),
'terrain',
);
assert.equal(
nextSurfaceMode('terrain', {
...view,
heightM: SURFACE_TERRAIN_EXIT_M + 1,
}),
'draped',
);
assert.equal(
nextSurfaceMode('terrain', { ...view, photoreal: false, heightM: 200 }),
'draped',
);
assert.equal(
nextSurfaceMode('terrain', { ...view, available: false, heightM: 200 }),
'draped',
);
assert.equal(
nextSurfaceMode('terrain', { ...view, heightM: null }),
'draped',
);
});
test('densifyLine splits long segments and keeps the vertices', () => {
const line = [
[10, 50],
[10.002, 50],
];
const dense = densifyLine(line, 0.0005);
assert.equal(dense.length, 5);
assert.deepEqual(dense[0], [10, 50]);
assert.deepEqual(dense.at(-1), [10.002, 50]);
assert.ok(Math.abs(dense[2][0] - 10.001) < 1e-9);
const short = [
[10, 50],
[10.0001, 50],
];
assert.deepEqual(densifyLine(short, 0.0005), short);
assert.deepEqual(densifyLine([[10, 50]]), [[10, 50]]);
});
test('densifyLine crosses the date line the short way and wraps what it adds', () => {
// 0.0002° apart: short enough to stay one segment, not 65 points via 0°.
const short = [
[179.9999, 0],
[-179.9999, 0],
];
assert.deepEqual(densifyLine(short), short);
const line = [
[179.999, 50.0005],
[-179.999, 50.0005],
];
const dense = densifyLine(line, 0.0005);
assert.equal(dense.length, 5);
assert.deepEqual(dense[0], line[0]);
assert.deepEqual(dense.at(-1), line[1]);
for (const [lon] of dense)
assert.ok(lon >= -180 && lon <= 180 && Math.abs(lon) >= 179.999, `${lon}`);
assert.ok(Math.abs(dense[1][0] - 179.9995) < 1e-9);
assert.ok(Math.abs(Math.abs(dense[2][0]) - 180) < 1e-9);
assert.ok(Math.abs(dense[3][0] - -179.9995) < 1e-9);
// Westward across the date line too.
const back = densifyLine([...line].reverse(), 0.0005);
assert.equal(back.length, 5);
assert.ok(Math.abs(back[1][0] - -179.9995) < 1e-9);
});
test('the caster interpolates cached grid corners and adds the lift', async () => {
const terrain = fakeTerrain();
const caster = createGroundCaster({ terrain, step: 0.001 });
assert.equal(caster.groundAt(10.0005, 50.0005), null);
assert.equal(await caster.prepare([[10.0005, 50.0005]]), true);
assert.equal(terrain.calls.length, 1);
assert.equal(terrain.calls[0].length, 4);
// Centre of the cell: mean of 100, 101, 102, 103.
assert.ok(Math.abs(caster.groundAt(10.0005, 50.0005) - 101.5) < 1e-6);
// A cast line sits the lift above it.
const point = [10.0005, 50.0005];
const [, , height] = caster.castLine([point, point]);
assert.ok(Math.abs(height - (101.5 + GROUND_CAST_LIFT_M)) < 1e-6);
// Same cell again: nothing to fetch.
assert.equal(await caster.prepare([[10.0002, 50.0008]]), true);
assert.equal(terrain.calls.length, 1);
});
test('castLine returns lon, lat, height triples or null when a corner is missing', async () => {
const caster = createGroundCaster({ terrain: fakeTerrain(), step: 0.001 });
const line = [
[10.0001, 50.0001],
[10.0009, 50.0001],
];
assert.equal(caster.castLine(line), null);
await caster.prepareLines([line]);
const flat = caster.castLine(line);
assert.equal(flat.length % 3, 0);
assert.ok(flat.length >= 6);
assert.equal(flat[0], 10.0001);
assert.equal(flat[1], 50.0001);
assert.ok(flat[2] > 100 && flat[2] < 110);
});
test('a geoid fallback or a failing proxy leaves the heights unknown', async () => {
const fallback = createGroundCaster({
terrain: fakeTerrain({ source: 'geoid-fallback' }),
});
assert.equal(await fallback.prepare([[10.0005, 50.0005]]), false);
assert.equal(fallback.groundAt(10.0005, 50.0005), null);
const down = createGroundCaster({ terrain: fakeTerrain({ fail: true }) });
assert.equal(await down.prepare([[10.0005, 50.0005]]), false);
assert.equal(down.groundAt(10.0005, 50.0005), null);
});
test('too many corners are not requested, and aborted prepares do nothing', async () => {
const terrain = fakeTerrain();
const caster = createGroundCaster({ terrain, step: 0.001, maxCorners: 8 });
const wide = [];
for (let i = 0; i < 10; i++) wide.push([10 + i * 0.001 + 0.0005, 50.0005]);
assert.equal(await caster.prepare(wide), false);
assert.equal(terrain.calls.length, 0);
const controller = new AbortController();
controller.abort();
assert.equal(
await caster.prepare([[10.0005, 50.0005]], { signal: controller.signal }),
false,
);
assert.equal(terrain.calls.length, 0);
});
test('prepares run one at a time so neighbours share corners', async () => {
const terrain = fakeTerrain();
const caster = createGroundCaster({ terrain, step: 0.001 });
const [a, b] = await Promise.all([
caster.prepare([[10.0005, 50.0005]]),
caster.prepare([[10.0015, 50.0005]]),
]);
assert.equal(a, true);
assert.equal(b, true);
// The second cell shares its west corners with the first.
assert.equal(terrain.calls[1].length, 2);
});
test('the caster needs a terrain service', () => {
assert.throws(() => createGroundCaster({ terrain: null }), TypeError);
});
test('refineHeights follows the mesh where it is the road', () => {
// A freeway trench 6 m below the bare-earth grid, and a steep street 2 m above it.
const heights = refineHeights([
{ dem: 100, mesh: 94 },
{ dem: 100, mesh: 102 },
{ dem: 100, mesh: null },
]);
assert.deepEqual(heights, [
94 + MESH_LIFT_M,
102 + MESH_LIFT_M,
100 + GROUND_CAST_LIFT_M,
]);
});
test('refineHeights carries the road under a canopy from both sides', () => {
// Road 1 m and 3 m above bare earth either side of a tree 12 m tall.
const heights = refineHeights([
{ dem: 50, mesh: 51 },
{ dem: 50, mesh: 62 },
{ dem: 50, mesh: 62 },
{ dem: 50, mesh: 53 },
]);
assert.equal(heights[0], 51 + MESH_LIFT_M);
assert.ok(Math.abs(heights[1] - (50 + 1 + 2 / 3 + MESH_LIFT_M)) < 1e-9);
assert.ok(Math.abs(heights[2] - (50 + 1 + 4 / 3 + MESH_LIFT_M)) < 1e-9);
assert.equal(heights[3], 53 + MESH_LIFT_M);
});
test('refineHeights keeps bare earth under a deck with no road beside it, and rejects bad probes', () => {
assert.deepEqual(
refineHeights([
{ dem: 20, mesh: 35 },
{ dem: 20, mesh: 35 },
]),
[20 + GROUND_CAST_LIFT_M, 20 + GROUND_CAST_LIFT_M],
);
// A probe kilometres under the ground is not a road.
assert.deepEqual(refineHeights([{ dem: 20, mesh: -14000 }]), [
20 + GROUND_CAST_LIFT_M,
]);
});
test('castLine refines heights with sampled mesh and densifies finer', async () => {
const caster = createGroundCaster({ terrain: fakeTerrain(), step: 0.001 });
const line = [
[10.0001, 50.0001],
[10.0009, 50.0001],
];
await caster.prepareLines([line]);
const plain = caster.castLine(line);
const meshed = caster.castLine(line, { meshAt: () => 98 });
assert.ok(meshed.length > plain.length, 'finer spacing with the mesh');
for (let i = 2; i < meshed.length; i += 3)
assert.equal(meshed[i], 98 + MESH_LIFT_M);
// Unsampled points keep the bare-earth height.
const unsampled = caster.castLine(line, { meshAt: () => undefined });
assert.ok(unsampled[2] > 100 && unsampled[2] < 110);
});
test('a full cache is cleared before a request counts its corners, so the request still casts', async () => {
const caster = createGroundCaster({
terrain: fakeTerrain(),
step: 0.001,
maxCorners: 8,
cacheMax: 5,
});
// One cell's four corners are cached...
assert.equal(await caster.prepare([[10.0005, 50.0005]]), true);
// ...then a line over that cell and the next needs two more, and the first
// cell must survive the eviction.
const line = [
[10.0002, 50.0005],
[10.0018, 50.0005],
];
assert.equal(await caster.prepareLines([line]), true);
assert.ok(caster.castLine(line), 'the whole line casts');
});
test('a full cache drops its oldest corners, not every corner', async () => {
const caster = createGroundCaster({
terrain: fakeTerrain(),
step: 0.001,
maxCorners: 4,
cacheMax: 12,
});
// Three cells apart from each other, four corners each: the cache is full.
const cells = [10.0005, 10.0105, 10.0205, 10.0305].map((lon) => [
lon,
50.0005,
]);
for (const cell of cells.slice(0, 3))
assert.equal(await caster.prepare([cell]), true);
// A fourth makes room by dropping the first cell only.
assert.equal(await caster.prepare([cells[3]]), true);
assert.equal(caster.groundAt(...cells[0]), null, 'oldest dropped');
for (const cell of cells.slice(1))
assert.notEqual(caster.groundAt(...cell), null, 'newer cells kept');
});
test('cancelling a prepare cancels its terrain request', async () => {
const seen = [];
const caster = createGroundCaster({
terrain: {
async resolveEllipsoidalGround(coords, options) {
seen.push(options?.signal);
return coords.map(() => ({ ellipsoid: 100, source: 'reearth' }));
},
},
});
const controller = new AbortController();
await caster.prepare([[10.0005, 50.0005]], { signal: controller.signal });
assert.equal(seen.length, 1);
assert.equal(seen[0], controller.signal, 'the tile signal reaches the proxy');
});
test('prepared lines always cast with the mesh, however their segments cross the grid', async () => {
// Seeded walks with 50–300 m segments: the finer mesh points can land in
// grid cells the coarse points skip.
let seed = 7;
const random = () => {
seed = (seed * 16807) % 2147483647;
return seed / 2147483647;
};
let failures = 0;
for (let n = 0; n < 300; n++) {
const caster = createGroundCaster({ terrain: fakeTerrain() });
const line = [[10 + random() * 0.01, 50 + random() * 0.01]];
for (let k = 0; k < 10; k++) {
const [lon, lat] = line[line.length - 1];
const length = (50 + random() * 250) / 111_000;
const angle = random() * 2 * Math.PI;
line.push([
lon + length * Math.cos(angle),
lat + length * Math.sin(angle),
]);
}
assert.equal(await caster.prepareLines([line]), true);
if (caster.castLine(line, { meshAt: () => undefined }) === null) failures++;
}
assert.equal(failures, 0);
});
test('a line across the date line casts along the date line', async () => {
// Ground that rises northward only, so both sides of ±180° agree.
const calls = [];
const terrain = {
async resolveEllipsoidalGround(coords) {
calls.push(...coords);
return coords.map(({ lat }) => ({
ellipsoid: 100 + (lat - 50) * 2000,
source: 'reearth',
}));
},
};
const caster = createGroundCaster({ terrain, step: 0.001 });
const line = [
[179.9995, 50.0005],
[180, 50.0005],
[-179.9995, 50.0005],
];
assert.equal(await caster.prepareLines([line]), true);
for (const meshAt of [null, () => undefined]) {
const flat = caster.castLine(line, { meshAt });
assert.ok(flat, 'every point casts');
assert.ok(
flat.length / 3 <= 8,
`${flat.length / 3} points, not a line round the world`,
);
for (let i = 0; i < flat.length; i += 3) {
assert.ok(Math.abs(flat[i]) >= 179.999 && Math.abs(flat[i]) <= 180);
assert.ok(Math.abs(flat[i + 2] - (101 + GROUND_CAST_LIFT_M)) < 1e-6);
}
}
// Only ground near the date line is asked for, and every longitude is a
// real one (the far corner of a point on 180° is asked for at -179.999°).
assert.ok(calls.length <= 16, `${calls.length} corners`);
for (const { lon } of calls)
assert.ok(Math.abs(lon) >= 179.999 && Math.abs(lon) <= 180, `${lon}`);
});
+5 -15
View File
@@ -3,34 +3,24 @@ import { GROUND_CLICK_MAX_HEIGHT_M } from './policy.js';
import { cameraHeightAboveGround } from './view.js';
/** Whether the camera is low enough for a click on the ground to mean a street. */
export function groundClickHeightOk(viewer, groundAt) {
export function groundClickHeightOk(viewer) {
if (!viewer?.camera?.positionCartographic) return false;
return (
cameraHeightAboveGround(viewer, { groundAt }) <= GROUND_CLICK_MAX_HEIGHT_M
);
return cameraHeightAboveGround(viewer) <= GROUND_CLICK_MAX_HEIGHT_M;
}
/**
* A click on the map itself at street zoom opens the nearest image of the
* active `groundClick` providers, without moving the camera. The height is
* measured at the click, not read from the cached flag.
* @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean, groundAt?: Function}} options
* @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean}} options
* @returns {(screenPosition: {x: number, y: number}, picked?: object) => Promise<boolean>|false}
*/
export function createGroundClick({
state,
openNearest,
isAvailable,
groundAt = null,
}) {
export function createGroundClick({ state, openNearest, isAvailable }) {
return function openAtGround(screenPosition, picked) {
const scenePick = state.services.scenePick;
if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition)
return false;
if (
!scenePick.isSurfacePick(picked) ||
!groundClickHeightOk(state.viewer, groundAt)
)
if (!scenePick.isSurfacePick(picked) || !groundClickHeightOk(state.viewer))
return false;
const providerIds = [...state.providers.values()]
.filter(
+108 -192
View File
@@ -1,7 +1,7 @@
import * as Cesium from 'cesium';
import { createState } from './state.js';
import { createMarker } from './marker.js';
import { createCameraFollow } from './cameraFollow.js';
import { createCameraFraming } from './cameraFraming.js';
import { createCredits } from './credits.js';
import { createPickRouter } from './pickRouter.js';
import { createSelection } from './selection.js';
@@ -14,17 +14,12 @@ import {
keyAlternatives,
summarizeCoverage,
} from './uiState.js';
import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
import { groundUnderCamera, viewCentre } from './view.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
import { createMeshSampler } from './meshSampler.js';
import { msg, t, translateKnown } from '../../i18n/index.js';
/** Above this camera height the ground under the camera is not worth fetching. */
const SURFACE_WARM_BELOW_M = 6000;
import {
AIM_AT_GROUND_MESSAGE,
FOLLOW_MAP_STACK_ID,
keyStatusLabel,
NEAREST_RADIUS_M,
POSITION_PICK_ID,
STREET_LEVEL_LAYER_ID,
@@ -32,35 +27,31 @@ import {
export { STREET_LEVEL_LAYER_ID } from './policy.js';
/** Longest wait for a provider's key status; past it, the provider decides. */
const KEY_STATUS_TIMEOUT_MS = 10_000;
/** Re-measures of a ground not loaded yet (Google 3D), a second apart. */
const GROUND_RETRY_LIMIT = 5;
const GROUND_RETRY_MS = 1000;
/**
* The Street Level layer over imagery providers (contract in registry.js).
* The core owns what they share; providers own coverage, sequences, viewer.
* The core owns what they share: the filter and switches, the photo viewer
* host, the position marker, the click handler and nearest-image lookups.
* Providers own coverage, sequences and their viewer. One source is lit at
* a time.
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
*/
export function createStreetLevelLayer({
providers: definitions,
services = {},
}) {
const definitionsFrozen = validateProviders(definitions);
const defs = validateProviders(definitions);
const state = createState({ services });
const parts = {};
const context = { state, parts };
parts.groundCaster = services.terrain?.resolveEllipsoidalGround
? createGroundCaster({ terrain: services.terrain })
: null;
// Mesh samples follow the application's rules: a real bare-earth prior,
// its mesh window, and a tileset that has finished streaming.
parts.meshSampler = parts.groundCaster
? createMeshSampler({
getViewer: () => state.viewer,
groundAt: parts.groundCaster.groundAt,
withinPrior: services.ground?.meshFloorSampleWithinPrior ?? null,
tilesReady: services.meshFloor?.visibleTilesetLoaded ?? null,
})
: null;
parts.credits = createCredits();
parts.marker = createMarker(context);
parts.follow = createCameraFollow(context);
parts.framing = createCameraFraming(context);
parts.router = createPickRouter(() => state.providers.values(), {
positionId: POSITION_PICK_ID,
});
@@ -78,7 +69,6 @@ export function createStreetLevelLayer({
state,
openNearest: (point, options) => layer.openNearest(point, options),
isAvailable: (entry) => providerAvailable(entry),
groundAt: parts.groundCaster?.groundAt ?? null,
});
let notifyQueued = false;
@@ -102,11 +92,10 @@ export function createStreetLevelLayer({
function providerContext(entry) {
return Object.freeze({
services: state.services,
/** Resolved against now, so "since N days" keeps up with the clock. */
getFilter: () => resolveFilter(state.filter),
/** The layer and this provider's switch are both on. */
isActive: () => state.enabled && entry.on,
groundCaster: parts.groundCaster,
meshSampler: parts.meshSampler,
getSurface: () => state.surface,
notify,
actions: {
/** `{frame: false}` opens it without moving the camera. */
@@ -138,13 +127,15 @@ export function createStreetLevelLayer({
}
// One source at a time: the first provider that defaults on.
const startsOn = definitionsFrozen.find((def) => def.defaultOn !== false);
for (const def of definitionsFrozen) {
const startsOn = defs.find((def) => def.defaultOn !== false);
for (const def of defs) {
const entry = {
def,
instance: null,
on: def === startsOn,
status: null,
/** The status request in flight; overlapping checks share it. */
statusCheck: null,
/** The error this provider last reported, until it withdraws it. */
reportedError: null,
};
@@ -155,15 +146,6 @@ export function createStreetLevelLayer({
let mapStack = null;
let unsubscribeMapStack = null;
/** Follow is offered only on the Google 3D stack; leaving it stops following. */
function syncFollowAvailability() {
const available = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
if (available === state.street.followAvailable) return;
state.street.followAvailable = available;
if (!available && state.street.follow) parts.follow.setFollow(false);
notify();
}
/** Usable only on its map stack (Google's terms: Street View on Google 3D only). */
function providerAvailable(entry) {
const required = entry.def.requiresMapStack;
@@ -184,109 +166,79 @@ export function createStreetLevelLayer({
parts.viewerHost.unmount(entry.def.id);
}
}
remeasureGround();
notify();
}
/** Whether a ground click can open an image: the camera is at street zoom. */
let groundRetry = null;
let groundRetries = 0;
function cancelGroundRetry() {
clearTimeout(groundRetry);
groundRetry = null;
}
/**
* Whether a ground click can open an image: the camera is at street zoom.
* Under Google 3D the ground is unknown until its tiles load, so an unknown
* ground is measured again a few times instead of waiting for the next move.
*/
function syncGroundClickReady() {
const ready =
cancelGroundRetry();
const ready = state.enabled && groundClickHeightOk(state.viewer);
if (
state.enabled &&
groundClickHeightOk(state.viewer, parts.groundCaster?.groundAt);
!ready &&
groundRetries < GROUND_RETRY_LIMIT &&
groundUnderCamera(state.viewer) === null
) {
groundRetries++;
groundRetry = setTimeout(syncGroundClickReady, GROUND_RETRY_MS);
}
if (ready === state.groundClickReady) return;
state.groundClickReady = ready;
notify();
}
/**
* Camera height above bare earth, for the surface mode. A cold grid cell is
* fetched once and the mode rechecked when it lands.
*/
function cameraHeightForSurface() {
const carto = state.viewer?.camera?.positionCartographic;
if (!carto) return null;
const caster = parts.groundCaster;
const height = cameraHeightAboveGround(state.viewer, {
groundAt: caster.groundAt,
});
const lon = (carto.longitude * 180) / Math.PI;
const lat = (carto.latitude * 180) / Math.PI;
if (caster.groundAt(lon, lat) === null && height < SURFACE_WARM_BELOW_M)
caster.prepare([[lon, lat]]).then((ready) => {
if (ready) scheduleSurfaceSync();
});
return height;
}
/** Cast overlays to bare earth on Google 3D at street zoom; drape them elsewhere. */
function syncSurface() {
/** A camera move or a map change measures afresh. */
function remeasureGround() {
groundRetries = 0;
syncGroundClickReady();
const available = Boolean(parts.groundCaster) && state.enabled;
const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
const mode = nextSurfaceMode(state.surface, {
available,
photoreal,
heightM: available && photoreal ? cameraHeightForSurface() : null,
});
if (mode === state.surface) return;
state.surface = mode;
parts.meshSampler?.setEnabled(mode === 'terrain');
parts.marker.setSurface(mode);
for (const entry of state.providers.values())
entry.instance.setSurface?.(mode);
notify();
}
let surfaceTimer = null;
function scheduleSurfaceSync() {
clearTimeout(surfaceTimer);
surfaceTimer = setTimeout(syncSurface, 150);
}
let removeSurfaceListeners = null;
function watchSurface(viewer) {
removeSurfaceListeners?.();
const camera = viewer?.camera;
if (!camera?.changed || !camera?.moveEnd) {
removeSurfaceListeners = null;
return;
}
const removeChanged = camera.changed.addEventListener(scheduleSurfaceSync);
const removeEnd = camera.moveEnd.addEventListener(scheduleSurfaceSync);
removeSurfaceListeners = () => {
removeChanged();
removeEnd();
};
}
function unwatchSurface() {
removeSurfaceListeners?.();
removeSurfaceListeners = null;
clearTimeout(surfaceTimer);
surfaceTimer = null;
let removeCameraListener = null;
function watchCamera(viewer) {
removeCameraListener?.();
removeCameraListener =
viewer?.camera?.moveEnd?.addEventListener(remeasureGround) || 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();
/** A provider's key status. A hung server must not hold LOADING, or the next check, forever. */
function checkStatus(entry) {
entry.statusCheck ||= (async () => {
try {
entry.status = await entry.instance.status({
signal: AbortSignal.timeout(KEY_STATUS_TIMEOUT_MS),
});
} catch {
entry.status = null;
}
notify();
})().finally(() => {
entry.statusCheck = null;
});
return entry.statusCheck;
}
function activate(entry) {
if (!state.viewer) return;
entry.instance.activate(state.viewer);
if (!entry.status) refreshStatus(entry);
if (!providerAvailable(entry)) return;
parts.credits.show(state.viewer, entry.def);
// The map may have changed by then.
whenIdle(() => {
if (providerAvailable(entry)) parts.viewerHost.prewarm([entry]);
}, 1500);
// Without a known key, a switch-on asks again: the server may be back,
// or have a key now.
if (entry.status?.configured !== true) checkStatus(entry);
if (providerAvailable(entry)) parts.credits.show(state.viewer, entry.def);
}
function deactivate(entry) {
@@ -442,7 +394,6 @@ export function createStreetLevelLayer({
error: stats.error || null,
color: entry.def.colors.coverage,
coverageLabel: entry.def.coverageLabel || null,
highlightsSequence: entry.def.highlightsSequence !== false,
sinceBasis: entry.def.sinceBasis === 'upload' ? 'upload' : 'capture',
groundClick: entry.def.groundClick === true,
filters: [...filtersOf(entry.def)],
@@ -450,23 +401,30 @@ export function createStreetLevelLayer({
});
}
/** The selected sequence; the provider showing the open image answers first. */
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)
if (stats?.selectedId || stats?.loading || stats?.error)
return {
providerId: entry.def.id,
selectedId: stats.selectedId || null,
images: stats.images || 0,
loading: stats.loading === true,
error: stats.error || null,
};
}
return { providerId: null, selectedId: null, images: 0, loading: false };
return {
providerId: null,
selectedId: null,
images: 0,
loading: false,
error: null,
};
}
function getUIState() {
@@ -478,7 +436,6 @@ export function createStreetLevelLayer({
providers: providerSnapshots(),
street,
sequence: sequenceSnapshot(),
surface: state.surface,
});
// The lit source cannot run on this map: say so, not a blank panel.
const stranded = [...state.providers.values()].find(
@@ -503,15 +460,13 @@ export function createStreetLevelLayer({
statsRefreshInterval: 1000,
/** The key the switched-on providers share (or null), for the layer row. */
get requiresKeyId() {
const on = [...state.providers.values()].filter((entry) => entry.on);
return requiresKeyIdFor(
on.length ? on.map((entry) => entry.def) : definitionsFrozen,
);
const on = activeEntries();
return requiresKeyIdFor(on.length ? on.map((entry) => entry.def) : defs);
},
/** Registered providers, in chip order. */
providerIds: definitionsFrozen.map((def) => def.id),
providerIds: defs.map((def) => def.id),
getSourceAvailability() {
const available = definitionsFrozen.length > 0;
const available = defs.length > 0;
return {
available,
source: STREET_LEVEL_LAYER_ID,
@@ -522,15 +477,12 @@ export function createStreetLevelLayer({
},
init(viewer) {
if (state.initialized)
throw new Error('Street Level layer is already initialized');
state.viewer = viewer;
state.initialized = true;
parts.marker.ensure(viewer);
parts.marker.setVisible(false);
for (const entry of state.providers.values()) {
entry.instance.init(viewer);
refreshStatus(entry);
checkStatus(entry);
}
console.log('[Data:StreetLevel] Initialized');
},
@@ -541,18 +493,20 @@ export function createStreetLevelLayer({
parts.marker.setVisible(true);
parts.selection.install(viewer);
for (const entry of activeEntries()) activate(entry);
watchSurface(viewer);
syncSurface();
watchCamera(viewer);
remeasureGround();
notify();
},
disable() {
state.enabled = false;
abortNearest();
unwatchSurface();
removeCameraListener?.();
removeCameraListener = null;
cancelGroundRetry();
parts.viewerHost.unmount();
for (const entry of state.providers.values()) entry.instance.deactivate();
syncSurface();
syncGroundClickReady();
parts.credits.hideAll(state.viewer);
parts.selection.uninstall();
parts.marker.setVisible(false);
@@ -568,27 +522,22 @@ export function createStreetLevelLayer({
for (const entry of state.providers.values())
entry.instance.destroy(viewer);
parts.marker.destroy(viewer);
parts.meshSampler?.destroy();
parts.follow.destroy();
parts.framing.attachNavigation(null);
unsubscribeMapStack?.();
unsubscribeMapStack = null;
mapStack = null;
state.listeners.clear();
state.viewer = null;
state.initialized = false;
state.destroyed = true;
},
getStats() {
// The lifecycle polls this every second: summarise, don't snapshot.
// English data: the layer panel translates these through translateKnown.
const snapshots = providerSnapshots();
const coverage = summarizeCoverage(snapshots);
// English data: the layer panel translates these through translateKnown.
const keyLabel = keyStatusLabel(coverage);
let loadingLabel = '';
const keyLabel = coverage.keyRejected
? msg('KEY REJECTED')
: msg('KEY REQUIRED');
if (coverage.keyRequired) loadingLabel = keyLabel;
if (keyLabel) loadingLabel = keyLabel;
else if (coverage.loading) loadingLabel = msg('loading coverage...');
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
return {
@@ -597,7 +546,8 @@ export function createStreetLevelLayer({
loading: coverage.loading,
keyRequired: coverage.keyRequired,
keyAlternatives: keyAlternatives(snapshots),
error: coverage.keyRequired
// A rejected key's message names the fix; a missing one says so.
error: keyLabel
? coverage.keyRejected
? coverage.error || keyLabel
: keyLabel
@@ -606,30 +556,18 @@ export function createStreetLevelLayer({
};
},
/**
* The application's camera authority: `run(noun, move)`, the deferred
* `begin(noun)` / `reassert(generation)` pair and
* `subscribeHandoff(listener)`. FOLLOW claims the camera with `run`; a
* photo claims it when opening starts and frames only if it still owns
* it once loaded. FOLLOW stops when another feature takes the camera.
*/
/** A photo frames itself only if it still owns the camera once loaded. */
attachNavigation(navigation) {
parts.follow.attachNavigation(navigation);
parts.framing.attachNavigation(navigation);
},
/** The application map stack; FOLLOW is available only on Google 3D. */
/** The application map stack; Street View is usable only on Google 3D. */
attachMapStackController(controller) {
unsubscribeMapStack?.();
mapStack = controller || null;
unsubscribeMapStack =
mapStack?.subscribe?.(() => {
syncFollowAvailability();
syncProviderAvailability();
syncSurface();
}) || null;
syncFollowAvailability();
mapStack?.subscribe?.(syncProviderAvailability) || null;
syncProviderAvailability();
syncSurface();
},
/** Share-link and stored state: provider switches plus the filter. */
@@ -640,6 +578,7 @@ export function createStreetLevelLayer({
});
},
/** Unknown providers and malformed values are ignored, so any link applies. */
setParams(params = {}) {
const decoded = decodeParams(params, {
providerIds: state.providers.keys(),
@@ -666,23 +605,14 @@ export function createStreetLevelLayer({
/** The DOM element provider viewers render into. */
attachViewerHost(element) {
parts.viewerHost.attach(element);
if (element && state.enabled)
whenIdle(
() =>
parts.viewerHost.prewarm(
activeEntries().filter((entry) => providerAvailable(entry)),
),
1500,
);
},
setProviderEnabled,
/** Imagery filter for coverage, cones and nearest-image lookups. */
setCoverageFilter,
openImage,
/**
* Open the nearest image of the active providers (or `providerIds`) near
* a point, else the view centre, or with `aim: 'screen-centre'` the ground
* under the screen centre (sky: nothing).
* under the screen centre (sky: nothing). Providers without
* `nearestImage` are skipped.
*/
async openNearest(
point,
@@ -712,6 +642,7 @@ export function createStreetLevelLayer({
let lastError = null;
const asked = (entry) =>
(!providerIds || providerIds.includes(entry.def.id)) &&
typeof entry.instance.nearestImage === 'function' &&
providerAvailable(entry);
const entries = activeEntries().filter(asked);
for (const entry of entries) {
@@ -751,35 +682,20 @@ export function createStreetLevelLayer({
})
: t(
'No street-level imagery within {radius} m of the view centre',
{
radius: NEAREST_RADIUS_M,
},
{ radius: NEAREST_RADIUS_M },
));
state.street.loading = false;
notify();
return false;
},
/** Close the image and deselect it everywhere on the map. */
/** Close the image and deselect its sequence on the map. */
closeViewer() {
abortNearest();
parts.viewerHost.close();
parts.clearSequences();
},
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
setFollow: (enabled) => parts.follow.setFollow(enabled),
resizeViewer: () => parts.viewerHost.resize(),
selectSequence(
sequenceId,
providerId = state.providers.keys().next().value,
) {
return state.providers
.get(providerId)
?.instance.selectSequence?.(sequenceId);
},
clearSequence: () => parts.clearSequences(),
refreshCoverage() {
for (const entry of activeEntries()) entry.instance.refreshCoverage();
},
};
return layer;
}
+371 -206
View File
@@ -5,9 +5,306 @@ import { createStreetLevelLayer } from './index.js';
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
import { createMapillaryProvider } from './providers/mapillary/index.js';
import { createKartaViewProvider } from './providers/kartaview/index.js';
import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs';
import {
fakeCesiumViewer,
fakeMapillarySource,
fakePhotoViewer,
fakeStreetLevelProvider as fakeProvider,
} from '../../testSupport/streetLevelFakes.mjs';
import { setLocale } from '../../i18n/index.js';
/**
* 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(),
providers = [createMapillaryProvider({ source, photoViewer })],
view = false,
enable = true,
} = {},
) {
const saved = globalThis.document;
// The click handler listens for Esc on the document.
globalThis.document = new EventTarget();
// Skip Cesium's one-time terrain table download for draped lines.
t.mock.method(
Cesium.GroundPolylinePrimitive,
'initializeTerrainHeights',
async () => {},
);
const viewer = fakeCesiumViewer({ view });
const layer = createStreetLevelLayer({ providers });
layer.init(viewer);
if (enable) layer.enable(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
await settle();
return { layer, viewer, source, photoViewer };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
function deferred() {
let resolve;
let reject;
const promise = new Promise((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
}
/** A photo viewer reporting a pose for every image it opens. */
const posingViewer = () =>
fakePhotoViewer({ pose: { position: { lon: -121.49, lat: 38.58 } } });
test('the CC BY-SA credit shows while Mapillary is on and goes with it', async (t) => {
const { layer, viewer } = await startLayer(t);
const shown = () => viewer.credits.map((credit) => credit.html);
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on enable');
assert.match(shown()[0], /CC BY-SA 4\.0/);
layer.setParams({ mapillary: false });
assert.deepEqual(shown(), [], 'hidden with the switch');
layer.setParams({ mapillary: true });
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]);
layer.disable();
assert.deepEqual(shown(), [], 'the credit goes with the layer');
});
test('coverage asks for no tiles before the key status is known, and none without a key', async (t) => {
for (const configured of [false, true]) {
const status = deferred();
const source = fakeMapillarySource({ getStatus: () => status.promise });
const { layer } = await startLayer(t, { source, view: true });
assert.equal(source.calls.tiles.length, 0, 'nothing before the status');
status.resolve({ configured });
await settle();
if (configured) assert.ok(source.calls.tiles.length > 0, 'then coverage');
else {
assert.equal(
source.calls.tiles.length,
0,
'a key-less install never asks',
);
assert.deepEqual(
[layer.getStats().loadingLabel, layer.getStats().error],
['KEY REQUIRED', 'KEY REQUIRED'],
);
assert.equal(layer.getUIState().keyRequired, true);
assert.equal(layer.getUIState().keyRejected, false);
}
layer.destroy();
}
});
test('a key status that failed is asked again on the next switch-on', async (t) => {
let up = false;
let asked = 0;
const source = fakeMapillarySource({
hasToken: () => false,
getStatus: async () => {
asked++;
if (!up) throw new TypeError('fetch failed');
return { configured: true };
},
});
const { layer, viewer } = await startLayer(t, { source, view: true });
assert.equal(asked, 1, 'one check for init and the first switch-on');
assert.equal(layer.getUIState().keyRequired, true);
up = true; // the server is reachable again
layer.disable();
layer.enable(viewer);
await settle();
assert.equal(asked, 2);
assert.equal(layer.getUIState().keyRequired, false);
assert.ok(source.calls.tiles.length > 0, 'coverage loads');
// A known key is not asked again.
layer.disable();
layer.enable(viewer);
await settle();
assert.equal(asked, 2);
});
test('a key status that never answers gives up, so the next switch-on asks again', async (t) => {
// The check's timeout, fired when the test says.
const timeouts = [];
t.mock.method(AbortSignal, 'timeout', (ms) => {
const controller = new AbortController();
timeouts.push({ ms, fire: () => controller.abort(new Error('timed out')) });
return controller.signal;
});
let asked = 0;
const source = fakeMapillarySource({
hasToken: () => false,
getStatus: ({ signal } = {}) =>
new Promise((_, reject) => {
asked++;
signal?.addEventListener('abort', () => reject(signal.reason));
}),
});
const { layer, viewer } = await startLayer(t, { source, view: true });
assert.equal(asked, 1);
assert.equal(timeouts[0].ms, 10_000, 'the check can be cut short');
assert.equal(layer.getUIState().coverage.loading, true, 'LOADING meanwhile');
timeouts[0].fire();
await settle();
assert.equal(layer.getUIState().coverage.loading, false);
assert.equal(layer.getUIState().keyRequired, true, 'no token: KEY REQUIRED');
layer.disable();
layer.enable(viewer);
assert.equal(asked, 2, 'asked again, not stuck on the first');
});
test('a rejected key gates the layer as KEY REJECTED until it goes off', async (t) => {
const rejected = Object.assign(new Error('Mapillary rejected the token'), {
keyRejected: true,
});
const source = fakeMapillarySource({
getTile: async () => {
throw rejected;
},
});
const { layer, viewer } = await startLayer(t, { source, view: true });
await settle();
const stats = layer.getStats();
assert.equal(stats.keyRequired, true);
assert.equal(stats.loadingLabel, 'KEY REJECTED');
assert.match(stats.error, /rejected MAPILLARY_CLIENT_TOKEN/);
const ui = layer.getUIState();
assert.deepEqual([ui.keyRequired, ui.keyRejected], [true, true]);
layer.disable();
layer.enable(viewer);
assert.equal(layer.getUIState().keyRejected, false, 'asked again once on');
});
test('switching Mapillary off clears coverage and closes the image it shows', async (t) => {
const { layer, photoViewer } = await startLayer(t);
layer.attachViewerHost({});
assert.equal(await layer.openImage('mapillary', 'img1'), true);
assert.deepEqual(photoViewer.calls.open, ['img1']);
assert.equal(layer.getUIState().street.open, true);
layer.setParams({ mapillary: false });
assert.equal(photoViewer.calls.unmount, 1, 'its viewer is released');
assert.equal(layer.getUIState().street.open, false);
assert.equal(layer.getUIState().providers[0].on, false);
});
test('a sequence that fails to load reports it apart from the photo, and the error goes with it', async (t) => {
const source = fakeMapillarySource({
getSequenceImages: async () => {
throw new Error('Sequence images unavailable');
},
});
// The photo's pose names its sequence, which opening selects.
const photoViewer = fakePhotoViewer({
pose: { position: { lon: -121.49, lat: 38.58 }, sequenceId: 'seq-1' },
});
const { layer } = await startLayer(t, { source, photoViewer });
layer.attachViewerHost({});
assert.equal(await layer.openImage('mapillary', 'img1'), true);
await settle();
let ui = layer.getUIState();
assert.equal(ui.sequence.error, 'Sequence images unavailable');
assert.equal(ui.street.error, null, 'the photo itself is fine');
layer.closeViewer();
ui = layer.getUIState();
assert.equal(ui.sequence.error, null, 'withdrawn with the sequence');
});
test('closing the photo, switching the layer off or destroying it stops the framing flight', async (t) => {
const { layer, viewer } = await startLayer(t, {
photoViewer: posingViewer(),
});
layer.attachViewerHost({});
assert.equal(await layer.openImage('mapillary', 'img1'), true);
assert.equal(viewer.flights.started, 1, 'the photo is framed');
layer.closeViewer();
assert.equal(viewer.flights.cancelled, 1, 'closed');
await layer.openImage('mapillary', 'img2');
layer.disable();
assert.equal(viewer.flights.cancelled, 2, 'layer off');
layer.enable(viewer);
await layer.openImage('mapillary', 'img3');
layer.destroy();
assert.equal(viewer.flights.cancelled, 3, 'destroyed');
});
test('setParams takes "any date" (0 days) and the switch over the current values (share-link defaults)', async (t) => {
const { layer } = await startLayer(t);
assert.deepEqual(layer.getParams(), {
mapillary: true,
pano: 'all',
sinceDays: 0,
});
layer.setParams({ pano: 'flat', sinceDays: 365 });
assert.equal(layer.getParams().sinceDays, 365);
layer.setParams({ sinceDays: 0 });
assert.deepEqual(layer.getParams(), {
mapillary: true,
pano: 'flat',
sinceDays: 0,
});
layer.setParams({ mapillary: false });
assert.equal(layer.getParams().mapillary, false);
});
test('setParams ignores unknown keys and malformed values', async (t) => {
const { layer } = await startLayer(t);
layer.setParams({ pano: 'pano', sinceDays: 730 });
layer.setParams({ mapillary: '0', kartaview: true, pano: 'weird', x: 1 });
layer.setParams({ sinceDays: -3 });
layer.setParams(null);
assert.deepEqual(layer.getParams(), {
mapillary: true,
pano: 'pano',
sinceDays: 730,
});
});
test('switching Mapillary on while the layer is off draws nothing', async (t) => {
const { layer, viewer, source, photoViewer } = await startLayer(t, {
view: true,
enable: false,
});
layer.attachViewerHost({});
layer.setParams({ mapillary: false });
layer.setParams({ mapillary: true });
await settle();
assert.equal(source.calls.tiles.length, 0, 'no coverage drawn');
assert.deepEqual(viewer.credits, [], 'no credit shown');
assert.equal(photoViewer.calls.mount, 0, 'no viewer stood up');
assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept');
// Enabling the layer is what draws it.
layer.enable(viewer);
await settle();
assert.ok(source.calls.tiles.length > 0);
assert.equal(viewer.credits.length, 1);
});
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
const { layer, viewer } = await startLayer(t, { providers: [] });
assert.deepEqual(layer.providerIds, []);
assert.deepEqual(layer.getUIState().providers, []);
assert.equal(layer.getUIState().keyRequired, false);
assert.match(
layer.getUIState().coverage.hint,
/No street-level imagery providers/,
);
layer.disable();
layer.enable(viewer);
assert.deepEqual(layer.getUIState().providers, []);
});
// ── Several providers: one source at a time, Google 3D, nearest lookups ──
/**
* 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.
@@ -55,8 +352,11 @@ function fakeViewer() {
},
creditDisplay: {
addStaticCredit: (credit) => credits.push(credit),
removeStaticCredit: (credit) =>
credits.splice(credits.indexOf(credit), 1),
removeStaticCredit: (credit) => {
// Cesium ignores a credit it does not hold.
const at = credits.indexOf(credit);
if (at !== -1) credits.splice(at, 1);
},
},
};
}
@@ -81,19 +381,6 @@ async function enabledLayer(t, providers = [fakeProvider()]) {
return { layer, viewer };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
function deferred() {
let resolve;
let reject;
const promise = new Promise((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
}
/** A provider whose viewer reports a pose for every image it opens. */
function posingProvider(options) {
const provider = fakeProvider(options);
const create = provider.create;
@@ -148,35 +435,6 @@ function fakeMapStack(initial) {
listenerCount: () => listeners.size,
};
}
test('FOLLOW is available only while the Google 3D map stack is active', () => {
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
assert.equal(
layer.getUIState().street.followAvailable,
false,
'unknown stack: off',
);
const stack = fakeMapStack('esri-imagery');
layer.attachMapStackController(stack);
assert.equal(layer.getUIState().street.followAvailable, false);
layer.setFollow(true);
assert.equal(layer.getUIState().street.follow, false, 'refused on Esri');
stack.switchTo('photoreal');
assert.equal(layer.getUIState().street.followAvailable, true);
layer.setFollow(true);
assert.equal(layer.getUIState().street.follow, true);
stack.switchTo('osm');
assert.equal(layer.getUIState().street.followAvailable, false);
assert.equal(
layer.getUIState().street.follow,
false,
'leaving Google 3D stops following',
);
});
test('the layer lets go of the map stack when destroyed or re-attached', () => {
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
const first = fakeMapStack('photoreal');
@@ -190,60 +448,6 @@ test('the layer lets go of the map stack when destroyed or re-attached', () => {
assert.equal(second.listenerCount(), 0);
});
test('the CC BY-SA credit shows while a provider is on and goes with it', async (t) => {
const { layer, viewer } = await enabledLayer(t);
const shown = () => viewer.credits.map((credit) => credit.html);
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML], 'shown on activate');
assert.match(shown()[0], /CC BY-SA 4\.0/);
layer.setProviderEnabled('mapillary', false);
assert.deepEqual(shown(), [], 'hidden with the provider');
layer.setProviderEnabled('mapillary', true);
assert.deepEqual(shown(), [MAPILLARY_CREDIT_HTML]);
layer.disable();
assert.deepEqual(shown(), [], 'every credit goes with the layer');
});
test('getStats tells a rejected key from a missing one', async (t) => {
const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]);
provider.stats.loading = true;
assert.equal(layer.getStats().loadingLabel, 'loading coverage...');
assert.equal(layer.getStats().keyRequired, false);
provider.stats.keyRequired = true;
assert.deepEqual(
[layer.getStats().loadingLabel, layer.getStats().error],
['KEY REQUIRED', 'KEY REQUIRED'],
);
// A rejected key gates the provider exactly like a missing one.
provider.stats.keyRequired = false;
provider.stats.keyRejected = true;
provider.stats.error = 'Mapillary rejected MAPILLARY_CLIENT_TOKEN';
const stats = layer.getStats();
assert.equal(stats.keyRequired, true);
assert.equal(stats.loadingLabel, 'KEY REJECTED');
assert.equal(stats.error, 'Mapillary rejected MAPILLARY_CLIENT_TOKEN');
const ui = layer.getUIState();
assert.equal(ui.keyRequired, true);
assert.equal(ui.keyRejected, true);
assert.equal(ui.providers[0].keyRequired, true);
});
test('switching a provider off deactivates it and closes the image it shows', async (t) => {
const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
assert.equal(await layer.openImage('mapillary', 'img1'), true);
assert.deepEqual(provider.calls.open, ['img1']);
assert.equal(layer.getUIState().street.open, true);
layer.setProviderEnabled('mapillary', false);
assert.equal(provider.calls.deactivate, 1);
assert.equal(provider.calls.unmount, 1, 'its viewer is released');
assert.equal(layer.getUIState().street.open, false);
assert.equal(layer.getUIState().street.providerId, null);
});
test('openNearest without the panel reports it instead of loading forever', async (t) => {
const provider = fakeProvider({ nearestImage: async () => 'img1' });
const { layer } = await enabledLayer(t, [provider]);
@@ -299,40 +503,6 @@ test('a provider withdraws only the error it reported', async (t) => {
actions.reportError(null);
assert.match(layer.getUIState().street.error, /Open the Street Level panel/);
});
test('closing the photo stops the globe flying to it', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
layer.attachViewerHost({});
assert.equal(await layer.openImage('mapillary', 'img1'), true);
assert.equal(viewer.flights.started, 1, 'the photo is framed');
layer.closeViewer();
assert.equal(viewer.flights.cancelled, 1);
});
test('switching the layer off or destroying it stops the framing flight', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
layer.attachViewerHost({});
await layer.openImage('mapillary', 'img1');
layer.disable();
assert.equal(viewer.flights.cancelled, 1, 'layer off');
layer.enable(viewer);
await layer.openImage('mapillary', 'img2');
layer.destroy();
assert.equal(viewer.flights.cancelled, 2, 'destroyed');
});
test('closing the photo leaves a newer navigation flight alone', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
layer.attachViewerHost({});
await layer.openImage('mapillary', 'img1');
assert.equal(viewer.flights.started, 1, 'the photo is being framed');
// A search result flies the globe elsewhere before the framing lands.
viewer.camera.flyToBoundingSphere(null, {});
layer.closeViewer();
assert.equal(viewer.flights.cancelled, 0, 'the search flight keeps going');
});
test('an older nearest lookup that answers late cannot replace a newer one', async (t) => {
const { lookups, nearestImage } = slowLookups();
const provider = fakeProvider({ nearestImage });
@@ -395,54 +565,6 @@ test('closing the viewer or switching the layer off retires a nearest lookup', a
assert.equal(lookups[1].signal?.aborted, true, 'layer off aborts it too');
});
test('the Mapillary provider hands the lookup signal to its source', async () => {
const requests = [];
const source = {
hasToken: () => true,
getStatus: async () => ({ configured: true }),
getTile: async () => new Uint8Array(0),
getSequenceImages: async () => [],
nearestImages: async (query, options) => {
requests.push(options);
return [{ id: 7, is_pano: false, captured_at: 10 }];
},
};
const instance = createMapillaryProvider({ source }).create({
services: {},
getFilter: () => ({ pano: 'all', sinceMs: null }),
isActive: () => true,
notify() {},
actions: { openImage() {}, reportError() {} },
});
const { signal } = new AbortController();
assert.equal(
await instance.nearestImage({ lat: 1, lon: 2 }, { signal }),
'7',
);
assert.equal(requests[0]?.signal, signal);
});
test('setParams takes "any date" (0 days) and a provider switch over the current values (share-link defaults)', async (t) => {
const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]);
layer.setParams({ pano: 'flat', sinceDays: 365 });
assert.equal(layer.getParams().sinceDays, 365);
layer.setParams({ sinceDays: 0 });
assert.deepEqual(layer.getParams(), {
mapillary: true,
pano: 'flat',
sinceDays: 0,
});
assert.equal(
provider.filters.at(-1).sinceMs,
null,
'providers drop the cut-off',
);
layer.setParams({ mapillary: false });
assert.equal(layer.getParams().mapillary, false);
});
/** A layer initialised on a stand-in viewer but never enabled. */
function initialisedLayer(t, providers) {
const saved = globalThis.document;
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
@@ -635,7 +757,7 @@ test('one imagery source at a time: lighting one darkens the other, either way',
assert.deepEqual(lit(), []);
});
test('Street View appears only on the Google 3D map: chip, legend, credit, source and images', async (t) => {
test('Street View appears only on the Google 3D map: chip, credit, source and images', async (t) => {
const { layer, viewer, stack } = await withStreetView(t, {
mapStack: 'esri-imagery',
});
@@ -646,7 +768,6 @@ test('Street View appears only on the Google 3D map: chip, legend, credit, sourc
const credited = () =>
viewer.credits.some((credit) => /Street View imagery/.test(credit.html));
assert.deepEqual(listed(), ['mapillary'], 'no Street View chip');
assert.ok(!ui().legend.some((item) => item.key === 'google'));
assert.equal(credited(), false, 'no Google credit on another map');
assert.equal(layer.source, 'Mapillary');
assert.equal(layer.getStats().source, 'Mapillary');
@@ -668,6 +789,62 @@ test('Street View appears only on the Google 3D map: chip, legend, credit, sourc
assert.deepEqual(listed(), ['mapillary']);
});
test('under Google 3D the ground is measured again once its tiles load, without a move', async (t) => {
const saved = globalThis.document;
globalThis.document = new EventTarget();
t.after(() => {
globalThis.document = saved;
});
t.mock.timers.enable({ apis: ['setTimeout'] });
const viewer = fakeViewer();
// 4 km up the ellipsoid, 2 km over a city whose tiles are still loading.
let ground;
viewer.camera.positionCartographic = { height: 4000 };
viewer.scene.globe = { show: false };
viewer.scene.sampleHeightSupported = true;
viewer.scene.sampleHeight = () => ground;
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
layer.init(viewer);
layer.enable(viewer);
t.after(() => layer.destroy());
assert.equal(layer.getUIState().groundClickReady, false);
ground = 2000;
t.mock.timers.tick(1000);
assert.equal(layer.getUIState().groundClickReady, true);
});
test('an unknown ground is measured only a few times, and not after the layer is off', (t) => {
const saved = globalThis.document;
globalThis.document = new EventTarget();
t.after(() => {
globalThis.document = saved;
});
t.mock.timers.enable({ apis: ['setTimeout'] });
const viewer = fakeViewer();
let samples = 0;
viewer.camera.positionCartographic = { height: 4000 };
viewer.scene.globe = { show: false };
viewer.scene.sampleHeightSupported = true;
viewer.scene.sampleHeight = () => {
samples++;
return undefined;
};
const layer = createStreetLevelLayer({ providers: [fakeProvider()] });
layer.init(viewer);
layer.enable(viewer);
t.after(() => layer.destroy());
t.mock.timers.tick(60_000);
const measured = samples;
assert.ok(measured > 1 && measured <= 12, `measured ${measured} times`);
viewer.camera.moveEnd.raiseEvent();
layer.disable();
const atDisable = samples;
t.mock.timers.tick(60_000);
assert.equal(samples, atDisable, 'no measuring once the layer is off');
});
test('the UI state says when a click (or the button) can open Street View here', async (t) => {
const { layer } = await withStreetView(t);
assert.equal(typeof layer.getUIState().groundClickReady, 'boolean');
@@ -825,20 +1002,6 @@ test('with no Mapillary token, the layer points at Street View, on Google 3D onl
assert.equal(layer.getStats().keyRequired, false);
});
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
const { layer, viewer } = await enabledLayer(t, []);
assert.deepEqual(layer.providerIds, []);
assert.deepEqual(layer.getUIState().providers, []);
assert.equal(layer.getUIState().keyRequired, false);
assert.match(
layer.getUIState().coverage.hint,
/No street-level imagery providers/,
);
layer.disable();
layer.enable(viewer);
assert.deepEqual(layer.getUIState().providers, []);
});
test('a failed lookup’s error goes when another source is lit; a provider can open an image without framing', async (t) => {
const failing = fakeProvider({
nearestImage: async () => {
@@ -890,7 +1053,7 @@ test('a provider’s openNearest (a line click) asks only that provider and leav
assert.equal(viewer.flights.started, 0, 'the user picked the spot');
});
test('KartaView through the core: its own swatch without Selected, its lines counted as streets', async (t) => {
test('KartaView through the core: its lines counted as streets', async (t) => {
const kartaView = createKartaViewProvider({
source: {
getTile: async () => ({ lines: [] }),
@@ -906,15 +1069,7 @@ test('KartaView through the core: its own swatch without Selected, its lines cou
layer.setProviderEnabled('kartaview', true);
await settle();
const ui = layer.getUIState();
assert.deepEqual(
ui.legend.map((entry) => entry.key),
['kartaview'],
);
assert.equal(ui.coverage.label?.noun, 'streets');
assert.equal(
ui.providers.find((p) => p.id === 'kartaview').highlightsSequence,
false,
);
});
test('switching source by params (the chip path) stops a running lookup and clears its spinner and error', async (t) => {
@@ -962,3 +1117,13 @@ test('switching source by params clears a failed lookup’s error; reapplying th
second.setParams({ mapillary: false, other: true });
assert.equal(second.getUIState().street.error, kept);
});
test('the panel says why a lit Street View draws nothing off Google 3D', async (t) => {
const { presentStreetLevelPanel } =
await import('../../ui/streetLevelPresentation.js');
const { layer, stack } = await withStreetView(t);
layer.setProviderEnabled('google', true);
stack.switchTo('esri-imagery');
const view = presentStreetLevelPanel(layer.getUIState());
assert.match(view.meta, /Google Street View needs the Google 3D map/);
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+4 -50
View File
@@ -1,7 +1,6 @@
import * as Cesium from 'cesium';
import { positionMarkerGlyph, viewConeMarkerGlyph } from './glyphs.js';
import { COLORS, POSITION_PICK_ID } from './policy.js';
import { refineHeights } from './groundCast.js';
import { createHorizonCull } from './view.js';
const SPRITE_ID = 'street-level:marker';
@@ -22,10 +21,9 @@ function markerLook(style) {
};
}
/** On-globe marker for the open image; in terrain mode it stands on bare earth. */
export function createMarker({ state, parts }) {
/** On-globe marker for the open image, clamped to the ground. */
export function createMarker({ state }) {
const { render, sprites } = state.services;
let last = null;
function requestRender() {
render?.governorRequestRender?.('street-level-marker');
@@ -47,38 +45,10 @@ export function createMarker({ state, parts }) {
sprites?.registerSpriteCollection?.(SPRITE_ID, state.marker.collection);
}
/**
* Terrain-mode height (bare earth refined by mesh samples), or null to
* clamp. Requests a missing terrain cell or mesh sample as a side effect.
*/
function castHeight(position) {
const ground = parts?.groundCaster;
if (state.surface !== 'terrain' || !ground) return null;
const { lon, lat } = position;
const dem = ground.groundAt(lon, lat);
if (dem === null) {
ground.prepare([[lon, lat]]).then((ready) => {
if (ready && last?.position === position)
set(position, last.bearing, last.style);
});
return null;
}
const mesh = parts.meshSampler?.meshAt(lon, lat);
if (mesh === undefined) parts.meshSampler?.request([[lon, lat]]);
return refineHeights([{ dem, mesh }])[0];
}
// A new mesh sample may move the marker onto (or off) the road surface.
parts?.meshSampler?.onSampled(() => {
if (last && state.surface === 'terrain')
set(last.position, last.bearing, last.style);
});
/** Move (or create) the marker; null removes it. `style`: the provider's `marker`. */
function set(position, bearing, style = null) {
const collection = state.marker.collection;
if (!collection) return;
last = position ? { position, bearing, style } : null;
if (!position) {
collection.removeAll();
state.marker.billboard = null;
@@ -86,16 +56,7 @@ export function createMarker({ state, parts }) {
requestRender();
return;
}
const height = castHeight(position);
const cartesian = Cesium.Cartesian3.fromDegrees(
position.lon,
position.lat,
height ?? 0,
);
const heightReference =
height === null
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE;
const cartesian = Cesium.Cartesian3.fromDegrees(position.lon, position.lat);
const look = markerLook(style);
if (state.marker.billboard && state.marker.look !== look.key) {
collection.remove?.(state.marker.billboard);
@@ -111,12 +72,11 @@ export function createMarker({ state, parts }) {
width: look.size,
height: look.size,
alignedAxis: Cesium.Cartesian3.UNIT_Z,
heightReference,
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
});
} else {
state.marker.billboard.heightReference = heightReference;
state.marker.billboard.position = cartesian;
}
state.marker.billboard.rotation = -Cesium.Math.toRadians(bearing || 0);
@@ -139,17 +99,11 @@ export function createMarker({ state, parts }) {
state.marker.billboard = null;
}
/** Re-place the marker after the surface mode changes. */
function setSurface() {
if (last) set(last.position, last.bearing, last.style);
}
return {
ensure,
set,
clear: () => set(null),
setVisible,
setSurface,
destroy,
};
}
+11 -114
View File
@@ -2,17 +2,9 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createMarker } from './marker.js';
import { GROUND_CAST_LIFT_M, MESH_LIFT_M } from './groundCast.js';
// The marker glyph draws on a canvas; Node has none, so drawing is a no-op.
const noop = () => {};
const context2d = new Proxy({}, { get: () => noop, set: () => true });
globalThis.document ??= {
createElement: () => ({ getContext: () => context2d }),
};
/** A viewer whose camera is 20,000 km over `view`, and its pre-render listeners. */
function setup({ parts = {}, surface = 'draped' } = {}) {
function setup() {
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
const preRender = new Set();
const viewer = {
@@ -34,7 +26,6 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
const state = {
services: {},
viewer,
surface,
marker: {
// Stands in for the billboard collection; ensure() keeps it.
collection: {
@@ -46,7 +37,7 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
billboard: null,
},
};
const marker = createMarker({ state, parts });
const marker = createMarker({ state });
const frame = () => {
for (const listener of [...preRender]) listener();
};
@@ -79,114 +70,23 @@ test('destroying the marker stops the horizon cull', () => {
assert.equal(preRender.size, 0);
});
/**
* Bare earth from a ground caster (null until its cell is fetched) and the
* sampled Google 3D surface from a mesh sampler (undefined until sampled).
*/
function terrainParts({ dem = null, mesh } = {}) {
const ground = { dem, prepares: [] };
const sampler = { mesh, requests: [], listeners: [] };
return {
ground,
sampler,
parts: {
groundCaster: {
groundAt: () => ground.dem,
prepare(points) {
let resolve;
const promise = new Promise((done) => {
resolve = done;
});
ground.prepares.push({ points, resolve });
return promise;
},
},
meshSampler: {
meshAt: () => sampler.mesh,
request: (points) => sampler.requests.push(points),
onSampled: (listener) => sampler.listeners.push(listener),
},
},
};
}
const heightOf = (billboard) =>
Cesium.Cartographic.fromCartesian(billboard.position).height;
const SPOT = { lon: -121.49, lat: 38.58 };
test('in terrain mode the marker stands on the sampled road surface (mesh near bare earth)', () => {
const { parts } = terrainParts({ dem: 10, mesh: 11.5 });
const { state, marker } = setup({ parts, surface: 'terrain' });
marker.set(SPOT, 45);
const billboard = state.marker.billboard;
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
assert.ok(
Math.abs(heightOf(billboard) - (11.5 + MESH_LIFT_M)) < 1e-3,
`height ${heightOf(billboard)}`,
);
});
test('in terrain mode without a mesh sample the marker stands on bare earth and asks for one', () => {
const { parts, sampler } = terrainParts({ dem: 10 });
const { state, marker } = setup({ parts, surface: 'terrain' });
marker.set(SPOT, 45);
const billboard = state.marker.billboard;
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
assert.ok(
Math.abs(heightOf(billboard) - (10 + GROUND_CAST_LIFT_M)) < 1e-3,
`height ${heightOf(billboard)}`,
);
assert.deepEqual(sampler.requests, [[[SPOT.lon, SPOT.lat]]]);
// The sample lands: the marker moves onto the road surface.
sampler.mesh = 10.5;
for (const listener of sampler.listeners) listener();
assert.ok(Math.abs(heightOf(billboard) - (10.5 + MESH_LIFT_M)) < 1e-3);
});
test('in terrain mode with no ground yet the marker clamps, then stands up once the ground arrives', async () => {
const { parts, ground } = terrainParts({ mesh: 11 });
const { state, marker } = setup({ parts, surface: 'terrain' });
test('the marker is clamped to the ground and turned to the bearing', () => {
const { state, marker } = setup();
marker.set(SPOT, 45);
const billboard = state.marker.billboard;
assert.equal(
billboard.heightReference,
Cesium.HeightReference.CLAMP_TO_GROUND,
);
assert.deepEqual(ground.prepares[0].points, [[SPOT.lon, SPOT.lat]]);
ground.dem = 10;
ground.prepares[0].resolve(true);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(billboard.heightReference, Cesium.HeightReference.NONE);
assert.ok(Math.abs(heightOf(billboard) - (11 + MESH_LIFT_M)) < 1e-3);
assert.equal(billboard.rotation, -Cesium.Math.toRadians(45), 'bearing kept');
});
test('ground that arrives after the marker moved on does not move it back', async () => {
const { parts, ground } = terrainParts();
const { state, marker } = setup({ parts, surface: 'terrain' });
marker.set(SPOT, 0);
const elsewhere = { lon: -121.5, lat: 38.6 };
marker.set(elsewhere, 0);
ground.dem = 10;
ground.prepares[0].resolve(true);
await new Promise((resolve) => setTimeout(resolve, 0));
const placed = Cesium.Cartographic.fromCartesian(
state.marker.billboard.position,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(placed.longitude) - elsewhere.lon) < 1e-9,
);
});
test('draped mode clamps the marker to the globe whatever the ground says', () => {
const { parts, ground } = terrainParts({ dem: 10, mesh: 11 });
const { state, marker } = setup({ parts, surface: 'draped' });
marker.set(SPOT, 0);
assert.equal(
state.marker.billboard.heightReference,
Cesium.HeightReference.CLAMP_TO_GROUND,
);
assert.equal(ground.prepares.length, 0);
assert.equal(billboard.rotation, -Cesium.Math.toRadians(45));
// Moving it keeps the same billboard.
marker.set({ lon: -121.5, lat: 38.6 }, 90);
assert.equal(state.marker.billboard, billboard);
const placed = Cesium.Cartographic.fromCartesian(billboard.position);
assert.ok(Math.abs(Cesium.Math.toDegrees(placed.longitude) + 121.5) < 1e-9);
assert.equal(billboard.rotation, -Cesium.Math.toRadians(90));
});
test('a Street View photo gets a blue view cone; a Mapillary photo GEV\u2019s pin', () => {
@@ -205,9 +105,6 @@ test('a Street View photo gets a blue view cone; a Mapillary photo GEV\u2019s pi
// Dragging the panorama keeps the look and the billboard.
marker.set({ lon: -121.49, lat: 38.58 }, 90, streetView);
assert.equal(state.marker.billboard, cone);
// Re-placed for a surface change: still the cone.
marker.setSurface();
assert.equal(state.marker.billboard, cone);
// A Mapillary photo next (its provider has no marker): the pin.
marker.set({ lon: -121.49, lat: 38.58 }, 90, undefined);
assert.equal(state.marker.billboard.imageId, 'sl-position:pin');
-371
View File
@@ -1,371 +0,0 @@
import * as Cesium from 'cesium';
import { metresBetween, whenIdle } from './view.js';
/**
* Google 3D surface heights for ground casting (groundCast.js refineHeights):
* one cached `scene.sampleHeight` probe per ~11 m cell. Each probe renders a
* pick pass (1–3 ms), so probes run in budgeted idle slices, nearest the
* camera first and only within MESH_SAMPLE_RADIUS_M. Like the application's
* mesh floor, a probe waits for the visible tileset to finish streaming and a
* sample counts only within the shared window around a real bare-earth height;
* anything else is a miss, retried later. Probes hit 3D tilesets only.
*/
/** Cell size, in degrees (~11 m): samples are shared within a cell. */
export const MESH_CELL_DEG = 0.0001;
/** Cells farther than this from the point under the camera are not probed. */
export const MESH_SAMPLE_RADIUS_M = 900;
/** Main-thread time per idle slice, taking requests in and ranking included. */
export const MESH_SAMPLE_BUDGET_MS = 6;
/** The queue is re-ranked once the camera has moved this far, in metres. */
export const MESH_RERANK_M = 100;
/** A cell whose probe missed is tried again after this long. */
export const MESH_MISS_RETRY_MS = 8000;
/** While the visible tileset is streaming, the next probe waits this long. */
export const MESH_STREAMING_WAIT_MS = 500;
/**
* A sampled cell is probed again once the camera is half as far from it as at
* its last probe (finer tiles have loaded by then), down to this distance:
* at most a handful of probes per cell.
*/
export const MESH_REFRESH_MIN_M = 40;
/** A refreshed sample that moved less than this is not redrawn. */
const MESH_REFRESH_CHANGE_M = 0.5;
/** Cached cells (and remembered misses) before they are dropped and refilled. */
const MESH_CACHE_MAX = 80_000;
/** Listeners hear about new samples at most this often. */
const MESH_NOTIFY_MS = 700;
const cellOf = (value) => Math.round(value / MESH_CELL_DEG);
/** Numeric key of the cell holding a point (exact within 2^53; no per-point string). */
export const meshCellKey = (lon, lat) =>
(cellOf(lon) + 1_800_001) * 2_000_000 + cellOf(lat) + 1_000_000;
/** Add a queued cell to a min-heap on `distance`. */
function heapPush(heap, cell) {
let i = heap.push(cell) - 1;
while (i > 0) {
const parent = (i - 1) >> 1;
if (heap[parent].distance <= cell.distance) break;
heap[i] = heap[parent];
i = parent;
}
heap[i] = cell;
}
/** Restore the heap below `i` after its cell was replaced. */
function siftDown(heap, i) {
const cell = heap[i];
const n = heap.length;
for (;;) {
let child = 2 * i + 1;
if (child >= n) break;
if (child + 1 < n && heap[child + 1].distance < heap[child].distance)
child++;
if (heap[child].distance >= cell.distance) break;
heap[i] = heap[child];
i = child;
}
heap[i] = cell;
}
function heapPop(heap) {
const top = heap[0];
const last = heap.pop();
if (heap.length) {
heap[0] = last;
siftDown(heap, 0);
}
return top;
}
/** Every top-level primitive that is not a 3D tileset: what a probe skips. */
function overlays(scene) {
const out = [];
const primitives = scene.primitives;
for (let i = 0; i < primitives.length; i++) {
const primitive = primitives.get(i);
if (!(primitive instanceof Cesium.Cesium3DTileset)) out.push(primitive);
}
return out;
}
/**
* `groundAt(lon, lat)` is the bare-earth height or null, `withinPrior(height,
* prior)` the application's mesh window, and `tilesReady(scene)` whether the
* visible tileset has finished streaming.
* @param {{getViewer: () => object|null, groundAt?: Function, withinPrior?: Function, tilesReady?: Function, budgetMs?: number, radiusM?: number, cacheMax?: number, now?: () => number}} options
*/
export function createMeshSampler({
getViewer,
groundAt = null,
withinPrior = null,
tilesReady = null,
budgetMs = MESH_SAMPLE_BUDGET_MS,
radiusM = MESH_SAMPLE_RADIUS_M,
cacheMax = MESH_CACHE_MAX,
now = () => performance.now(),
}) {
/** cell key → sampled mesh height (ellipsoidal metres). */
const heights = new Map();
/** cell key → camera distance (m) at the cell's last probe. */
const probedFrom = new Map();
/** cell key → retry time for probes that hit nothing; bounded like the height cache. */
const misses = new Map();
/** cell key → queued cell, so a cell is queued once. */
const wanted = new Map();
/** Queued {key, lon, lat, distance} cells, a min-heap on distance from `rankedFrom`. */
let queue = [];
/** Requests not yet taken in: {points, next, centre} (centre: the camera then). */
let intake = [];
/** Where the camera was when the queue was ranked. */
let rankedFrom = null;
/** Running estimate of one probe's cost, so a slice stops before overrunning. */
let probeMs = 1;
const listeners = new Set();
let enabled = false;
let running = false;
let fresh = [];
let notifyTimer = null;
/** The sampled mesh height for a point, or undefined when not sampled. */
function meshAt(lon, lat) {
return heights.get(meshCellKey(lon, lat));
}
/** The point under the camera, with the camera's height, or null. */
function cameraCentre() {
const carto = getViewer()?.camera?.positionCartographic;
if (!carto) return null;
return {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
height: carto.height,
};
}
/** Camera distance (m) to a cell at `height`, from its ground distance. */
function cameraDistance(ground, centre, height) {
const above = (centre.height ?? 0) - (height ?? 0);
return Number.isFinite(above)
? Math.hypot(ground, Math.max(0, above))
: ground;
}
/** A sampled cell is probed again once the camera is half as far as before. */
function dueForRefresh(key, distance) {
const last = probedFrom.get(key);
return last > MESH_REFRESH_MIN_M && distance < last / 2;
}
/** A probe result the application would accept, or undefined. */
function validated(height, lon, lat) {
if (!Number.isFinite(height)) return undefined;
if (!groundAt) return height;
const prior = groundAt(lon, lat);
if (!Number.isFinite(prior)) return undefined;
return !withinPrior || withinPrior(height, prior) ? height : undefined;
}
/**
* Ask for the cells under [lon, lat] points; the list is read later, so do
* not mutate it. Out-of-range cells are dropped; callers ask again on move.
*/
function request(points) {
if (!enabled || !points?.length) return;
const centre = cameraCentre();
if (!centre) return;
intake.push({ points, next: 0, centre });
schedule();
}
/** Queue requested cells, oldest request first, until the slice reaches `until`. */
function takeIn(until) {
const time = Date.now();
while (intake.length) {
const pending = intake[0];
const { points, centre } = pending;
while (pending.next < points.length) {
if ((pending.next & 255) === 255 && now() > until) return;
const [lon, lat] = points[pending.next++];
if (!Number.isFinite(lon) || !Number.isFinite(lat)) continue;
const key = meshCellKey(lon, lat);
if (wanted.has(key)) continue;
const cell = {
key,
lon: cellOf(lon) * MESH_CELL_DEG,
lat: cellOf(lat) * MESH_CELL_DEG,
distance: 0,
};
const ground = metresBetween(cell, centre);
if (ground > radiusM) continue;
if (
heights.has(key) &&
!dueForRefresh(key, cameraDistance(ground, centre, heights.get(key)))
)
continue;
const retryAt = misses.get(key);
if (retryAt !== undefined) {
if (retryAt > time) continue;
misses.delete(key);
}
cell.distance = metresBetween(cell, rankedFrom);
wanted.set(key, cell);
heapPush(queue, cell);
}
intake.shift();
}
}
/**
* Rank the queue from where the camera is now, dropping cells it has left
* out of range: one linear heapify, run only after the camera has moved.
*/
function rerank(centre) {
rankedFrom = centre;
const kept = [];
for (const cell of queue) {
cell.distance = metresBetween(cell, centre);
if (cell.distance > radiusM) wanted.delete(cell.key);
else kept.push(cell);
}
queue = kept;
for (let i = (queue.length >> 1) - 1; i >= 0; i--) siftDown(queue, i);
}
function clearQueue() {
intake = [];
wanted.clear();
queue = [];
rankedFrom = null;
}
function schedule() {
if (running || !enabled || !(queue.length || intake.length)) return;
running = true;
whenIdle(step, 300);
}
function emit() {
notifyTimer = null;
const batch = fresh;
fresh = [];
for (const listener of [...listeners]) {
try {
listener(batch);
} catch (error) {
console.warn('[Data:StreetLevel] mesh listener error:', error);
}
}
}
function step(deadline) {
running = false;
// The slice starts now: taking requests in and ranking count too.
const started = now();
const until =
started +
Math.min(budgetMs, Math.max(1, deadline?.timeRemaining?.() ?? budgetMs));
const scene = getViewer()?.scene;
const centre = cameraCentre();
if (!enabled || !scene?.sampleHeightSupported || !centre) return;
// Mid-stream tiles answer with coarse heights: wait for the stream.
if (tilesReady && !tilesReady(scene)) {
running = true;
setTimeout(() => {
running = false;
schedule();
}, MESH_STREAMING_WAIT_MS);
return;
}
// Nearest first, ranked from where the camera was; out-of-range cells
// are dropped (they stay bare earth).
let worked = false;
if (!rankedFrom || metresBetween(rankedFrom, centre) > MESH_RERANK_M) {
rerank(centre);
worked = true;
}
if (intake.length) {
// Leave room for a probe, so a stream of requests cannot starve them.
takeIn(until - probeMs);
worked = true;
}
const exclude = overlays(scene);
let probed = 0;
while (queue.length) {
// Stop before a probe would overrun the slice. A slice that did
// nothing else always probes once, so the queue drains.
if ((probed || worked) && now() + probeMs > until) break;
const { key, lon, lat } = heapPop(queue);
wanted.delete(key);
// Ranked from up to MESH_RERANK_M away: check the range from here.
const ground = metresBetween({ lon, lat }, centre);
if (ground > radiusM) continue;
probed++;
const probeStarted = now();
let height;
try {
height = scene.sampleHeight(
Cesium.Cartographic.fromDegrees(lon, lat),
exclude,
);
} catch {
height = undefined;
}
probeMs = probeMs * 0.75 + (now() - probeStarted) * 0.25;
height = validated(height, lon, lat);
const previous = heights.get(key);
if (height === undefined) {
// A miss, or a failed refresh: the old sample (if any) and the
// distance it was taken from stay, and the probe is retried after
// the same cooldown.
if (misses.size >= cacheMax) misses.clear();
misses.set(key, Date.now() + MESH_MISS_RETRY_MS);
continue;
}
if (previous === undefined && heights.size >= cacheMax) forget();
misses.delete(key);
heights.set(key, height);
probedFrom.set(key, cameraDistance(ground, centre, height));
if (
previous === undefined ||
Math.abs(height - previous) >= MESH_REFRESH_CHANGE_M
)
fresh.push([lon, lat]);
}
if (fresh.length && !notifyTimer)
notifyTimer = setTimeout(emit, MESH_NOTIFY_MS);
schedule();
}
function forget() {
heights.clear();
probedFrom.clear();
misses.clear();
}
/** Probe only while overlays are cast (Google 3D at street zoom). */
function setEnabled(on) {
enabled = on === true;
if (!enabled) clearQueue();
else schedule();
}
/** Hear about newly sampled cells, as [lon, lat] cell centres. */
function onSampled(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
function destroy() {
enabled = false;
forget();
clearQueue();
listeners.clear();
clearTimeout(notifyTimer);
notifyTimer = null;
}
return { meshAt, request, setEnabled, onSampled, destroy };
}
-405
View File
@@ -1,405 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
MESH_CELL_DEG,
MESH_MISS_RETRY_MS,
MESH_REFRESH_MIN_M,
MESH_SAMPLE_BUDGET_MS,
MESH_STREAMING_WAIT_MS,
createMeshSampler,
} from './meshSampler.js';
import { meshFloorSampleWithinPrior } from '../../data/groundFloor.js';
import { metresBetween } from './view.js';
const settle = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms));
const RAD = Math.PI / 180;
/** A scene whose mesh is 100 m everywhere except where `holes` say. */
function fakeViewer({ holes = () => false } = {}) {
const tileset = Object.create(Cesium.Cesium3DTileset.prototype);
const overlay = { name: 'lines' };
const probes = [];
const scene = {
sampleHeightSupported: true,
primitives: {
length: 2,
get: (i) => [tileset, overlay][i],
},
sampleHeight(carto, exclude) {
const lon = carto.longitude / RAD;
const lat = carto.latitude / RAD;
probes.push({ lon, lat, exclude });
return holes(lon, lat) ? undefined : 100;
},
};
return {
probes,
overlay,
viewer: {
scene,
camera: {
positionCartographic: { longitude: 10 * RAD, latitude: 50 * RAD },
},
},
};
}
test('cells are probed once, nearest first, with overlays excluded', async () => {
const { viewer, probes, overlay } = fakeViewer();
const sampler = createMeshSampler({ getViewer: () => viewer });
sampler.setEnabled(true);
const heard = [];
sampler.onSampled((batch) => heard.push(batch.length));
sampler.request([
[10.004, 50],
[10.001, 50],
[10.00101, 50.00001], // same ~11 m cell as the previous point
]);
await settle(1000);
assert.equal(probes.length, 2);
assert.ok(probes[0].lon < probes[1].lon, 'nearest the camera first');
assert.deepEqual(probes[0].exclude, [overlay], 'only tilesets are hit');
assert.equal(sampler.meshAt(10.001, 50), 100);
assert.equal(sampler.meshAt(10.00101, 50.00001), 100);
assert.deepEqual(heard, [2]);
sampler.request([[10.001, 50]]);
await settle();
assert.equal(probes.length, 2, 'a sampled cell is not probed again');
sampler.destroy();
});
test('far cells are skipped, misses are not latched, and nothing runs while disabled', async () => {
const { viewer, probes } = fakeViewer({ holes: (lon) => lon > 10.0049 });
const sampler = createMeshSampler({ getViewer: () => viewer });
sampler.request([[10.001, 50]]);
await settle();
assert.equal(probes.length, 0, 'disabled: nothing queued');
sampler.setEnabled(true);
sampler.request([
[10.05, 50], // ~3.6 km away
[10.005, 50], // a hole: tiles not streamed
]);
await settle();
assert.equal(probes.length, 1);
assert.equal(sampler.meshAt(10.05, 50), undefined);
assert.equal(sampler.meshAt(10.005, 50), undefined);
assert.ok(Math.abs(probes[0].lon - 10.005) < MESH_CELL_DEG);
sampler.destroy();
});
/** Probes run on a 16 ms timer headless; with mocked timers, run them now. */
function probeNow(t) {
t.mock.timers.tick(20);
}
test('a missed cell is probed again after the retry window, not before', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, probes } = fakeViewer({ holes: () => true });
const sampler = createMeshSampler({ getViewer: () => viewer });
sampler.setEnabled(true);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 1, 'probed, and missed');
t.mock.timers.tick(MESH_MISS_RETRY_MS - 100);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 1, 'within the window: not probed');
t.mock.timers.tick(100 + 1);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 2, 'after the window: probed again');
sampler.destroy();
});
test('remembered misses are bounded like the samples', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, probes } = fakeViewer({ holes: () => true });
const sampler = createMeshSampler({ getViewer: () => viewer, cacheMax: 2 });
sampler.setEnabled(true);
for (const lon of [10.001, 10.002, 10.003]) {
sampler.request([[lon, 50]]);
probeNow(t);
}
assert.equal(probes.length, 3);
// The third miss overflowed the list, which was dropped: the first cell
// is asked about again rather than remembered forever.
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 4);
sampler.destroy();
});
test('destroy forgets the samples', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer } = fakeViewer();
const sampler = createMeshSampler({ getViewer: () => viewer });
sampler.setEnabled(true);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(sampler.meshAt(10.001, 50), 100);
sampler.destroy();
assert.equal(sampler.meshAt(10.001, 50), undefined);
});
/**
* A sampler over `fakeViewer` on a fake clock: each probe costs `probeMs`
* and each read of the camera `cameraMs` (work done before any probe).
*/
function clocked(t, { probeMs = 2, cameraMs = 0, holes } = {}) {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const fake = fakeViewer({ holes });
const clock = { now: 0 };
const { scene, camera } = fake.viewer;
const probe = scene.sampleHeight;
scene.sampleHeight = (...args) => {
clock.now += probeMs;
return probe(...args);
};
const position = { ...camera.positionCartographic };
Object.defineProperty(camera, 'positionCartographic', {
get: () => {
clock.now += cameraMs;
return position;
},
});
const sampler = createMeshSampler({
getViewer: () => fake.viewer,
now: () => clock.now,
});
sampler.setEnabled(true);
/** Run one idle slice; its length on the fake clock. */
function slice() {
const started = clock.now;
probeNow(t);
return clock.now - started;
}
/** Put the camera over a point. */
function moveTo(lon, lat) {
position.longitude = lon * RAD;
position.latitude = lat * RAD;
}
return { ...fake, sampler, slice, moveTo };
}
/** Points on a grid `step` degrees apart, `n`×`n`, centred on a point. */
function grid(lon, lat, n, step) {
const points = [];
for (let i = 0; i < n; i++)
for (let j = 0; j < n; j++)
points.push([
lon + (i - (n - 1) / 2) * step,
lat + (j - (n - 1) / 2) * step,
]);
return points;
}
test('cells out of range when asked are not queued, even once the camera reaches them', (t) => {
const { sampler, probes, slice, moveTo } = clocked(t);
// 400 cells ~3.6 km east, past the sampler's range.
sampler.request(grid(10.05, 50, 20, 0.0002));
// The camera gets there before any idle slice runs.
moveTo(10.05, 50);
for (let i = 0; i < 5; i++) slice();
assert.equal(probes.length, 0, 'nothing was queued for later');
// Asked again from there (as a camera move does), they are probed.
sampler.request([[10.05, 50]]);
slice();
assert.equal(probes.length, 1);
sampler.destroy();
});
test('a slice keeps to its budget, counting the work before its first probe', (t) => {
const { sampler, probes, slice } = clocked(t, { probeMs: 2, cameraMs: 3 });
sampler.request(grid(10.001, 50, 5, 0.0002));
const lengths = [];
while (probes.length < 25 && lengths.length < 40) lengths.push(slice());
assert.equal(probes.length, 25, 'every cell is probed in the end');
for (const length of lengths)
assert.ok(length <= MESH_SAMPLE_BUDGET_MS, `a ${length} ms slice`);
sampler.destroy();
});
test('a large queue is ranked once, not sorted again every slice', (t) => {
const { sampler, probes, slice, moveTo } = clocked(t);
// 2,500 cells ~17 m apart around the camera, all within range.
const cells = grid(10, 50, 50, 0.00015);
sampler.request(cells);
const sort = Array.prototype.sort;
let sorted = 0;
t.after(() => (Array.prototype.sort = sort));
Array.prototype.sort = function (...args) {
if (this.length >= 100) sorted += this.length;
return sort.apply(this, args);
};
for (let i = 0; i < 10; i++) slice();
const first = probes.map((probe) =>
metresBetween(probe, { lon: 10, lat: 50 }),
);
assert.ok(first.length >= 10, 'probes ran');
first.forEach((distance, i) =>
assert.ok(
!i || distance >= first[i - 1] - 0.01,
'nearest the camera first, across slices',
),
);
Array.prototype.sort = sort;
assert.equal(sorted, 0, `${sorted} queued cells sorted in 10 slices`);
// The camera moves ~200 m east: the queue is ranked from there.
const done = probes.length;
moveTo(10.003, 50);
slice();
slice();
const after = probes
.slice(done)
.map((probe) => metresBetween(probe, { lon: 10.003, lat: 50 }));
assert.ok(after.length > 0);
assert.ok(after[0] < 20, `nearest the new view first (${after[0]} m)`);
sampler.destroy();
});
test('the sampling range is measured the short way round the date line', (t) => {
const { sampler, probes, slice, moveTo } = clocked(t);
moveTo(179.9999, 0);
sampler.request([
[-179.9999, 0], // ~22 m away, across the date line
[179.99, 0], // ~1.1 km away, past the range
]);
for (let i = 0; i < 5; i++) slice();
assert.equal(probes.length, 1, 'only the neighbour across ±180°');
assert.ok(Math.abs(Math.abs(probes[0].lon) - 179.9999) < MESH_CELL_DEG);
sampler.destroy();
});
/** A scene whose mesh height can change (finer tiles) and a camera that can move. */
function liveViewer({ surface = 80, height = 700, ready = () => true } = {}) {
const tileset = Object.create(Cesium.Cesium3DTileset.prototype);
const probes = [];
const viewer = {
surface,
ready,
scene: {
sampleHeightSupported: true,
primitives: { length: 1, get: () => tileset },
sampleHeight(carto) {
probes.push(carto);
return viewer.surface;
},
},
camera: {
positionCartographic: { longitude: 10 * RAD, latitude: 50 * RAD, height },
},
};
return { viewer, probes };
}
function liveSampler(viewer, options = {}) {
return createMeshSampler({
getViewer: () => viewer,
groundAt: () => 75,
withinPrior: meshFloorSampleWithinPrior,
tilesReady: () => viewer.ready(),
...options,
});
}
test('a sample is probed again once the camera is twice as close, and the change is redrawn', async () => {
const { viewer, probes } = liveViewer({ surface: 80, height: 700 });
const sampler = liveSampler(viewer);
sampler.setEnabled(true);
const heard = [];
sampler.onSampled((batch) => heard.push(batch.length));
sampler.request([[10.001, 50]]);
await settle(900);
assert.equal(sampler.meshAt(10.001, 50), 80);
// Finer tiles load: the surface is really 100 m.
viewer.surface = 100;
sampler.request([[10.001, 50]]);
await settle();
assert.equal(probes.length, 1, 'no new probe from the same distance');
assert.equal(sampler.meshAt(10.001, 50), 80);
viewer.camera.positionCartographic.height = 250;
sampler.request([[10.001, 50]]);
await settle(900);
assert.equal(probes.length, 2);
assert.equal(sampler.meshAt(10.001, 50), 100);
assert.deepEqual(heard, [1, 1], 'the new height is announced for a redraw');
sampler.destroy();
});
test('refreshing stops once a cell was probed from close by', async () => {
const { viewer, probes } = liveViewer({ surface: 80, height: 110 });
viewer.camera.positionCartographic.longitude = 10.001 * RAD;
const sampler = liveSampler(viewer);
sampler.setEnabled(true);
sampler.request([[10.001, 50]]);
await settle();
viewer.camera.positionCartographic.height = 85;
sampler.request([[10.001, 50]]);
await settle();
assert.equal(probes.length, 1, `probed from under ${MESH_REFRESH_MIN_M} m`);
sampler.destroy();
});
test('probes wait while the visible tileset is still streaming', async () => {
let ready = false;
const { viewer, probes } = liveViewer({ ready: () => ready });
const sampler = liveSampler(viewer);
sampler.setEnabled(true);
sampler.request([[10.001, 50]]);
await settle(MESH_STREAMING_WAIT_MS * 2);
assert.equal(probes.length, 0);
ready = true;
await settle(MESH_STREAMING_WAIT_MS * 2);
assert.equal(probes.length, 1);
assert.equal(sampler.meshAt(10.001, 50), 80);
sampler.destroy();
});
test('a sample needs a real bare-earth prior and must sit in the shared mesh window', async () => {
const { viewer } = liveViewer({ surface: 300 });
const unknown = liveSampler(viewer, { groundAt: () => null });
unknown.setEnabled(true);
unknown.request([[10.001, 50]]);
const outside = liveSampler(viewer);
outside.setEnabled(true);
outside.request([[10.001, 50]]);
await settle();
assert.equal(unknown.meshAt(10.001, 50), undefined, 'no prior: not kept');
assert.equal(
outside.meshAt(10.001, 50),
undefined,
'225 m above it: not kept',
);
unknown.destroy();
outside.destroy();
});
test('a failed refresh keeps the last good distance and is retried after the cooldown', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, probes } = liveViewer({ surface: 90, height: 700 });
viewer.camera.positionCartographic.longitude = 10.001 * RAD;
const sampler = liveSampler(viewer);
sampler.setEnabled(true);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(sampler.meshAt(10.001, 50), 90);
// The camera comes down to 20 m and the refresh misses (tiles in flux).
viewer.camera.positionCartographic.height = 110;
viewer.surface = undefined;
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 2);
assert.equal(sampler.meshAt(10.001, 50), 90, 'the old sample stays');
viewer.surface = 100;
t.mock.timers.tick(MESH_MISS_RETRY_MS - 100);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 2, 'not before the cooldown');
t.mock.timers.tick(100);
sampler.request([[10.001, 50]]);
probeNow(t);
assert.equal(probes.length, 3, 'retried after it');
assert.equal(sampler.meshAt(10.001, 50), 100);
sampler.destroy();
});
+20 -4
View File
@@ -6,6 +6,16 @@ import {
import { imageSize } from './imageSize.js';
import { msg, t } from '../../i18n/index.js';
/**
* EXPAND puts the viewer's wrap full screen, or (iPhone) opens it as a
* popover. Each is tried on its own: a browser that cannot parse one
* (Safari before 16.4) throws on the selector.
*/
const EXPANDED_SELECTORS = [
'.sl-viewer-wrap:fullscreen',
'.sl-viewer-wrap:popover-open',
];
/** A photo download (tens of kilobytes to a few megabytes). */
export const IMAGE_TIMEOUT_MS = 15_000;
@@ -535,14 +545,14 @@ export function createPhotoViewer({
/**
* Docked, Esc anywhere in the viewer's box (the photo, its toolbar, its
* caption) is the provider's before the panel's, which would collapse;
* expanded, the dialog's own Esc shrinks it.
* expanded, the panel's own Esc shrinks it.
*/
function onEscapeKey(event) {
if (
event.key !== 'Escape' ||
event.shiftKey ||
event.defaultPrevented ||
root?.closest?.('.sl-viewer-wrap-expanded') ||
isExpanded() ||
!onEscape()
)
return;
@@ -739,9 +749,15 @@ export function createPhotoViewer({
}
}
/** The viewer is the expanded dialog, not docked in the panel. */
/** The viewer is expanded (full screen, or the iPhone popover), not docked in the panel. */
function isExpanded() {
return Boolean(root?.closest?.('.sl-viewer-wrap-expanded'));
return EXPANDED_SELECTORS.some((selector) => {
try {
return Boolean(root?.closest?.(selector));
} catch {
return false;
}
});
}
/** Another photo, or none: a sharper panorama still on its way is not needed. */
+36 -1
View File
@@ -160,7 +160,7 @@ test('Esc: the docked viewer offers it to the provider first; expanded, it is th
assert.equal(asked, 1);
assert.equal(docked.prevented && docked.stopped, true);
root.closest = (selector) =>
selector.includes('sl-viewer-wrap-expanded') ? {} : null;
selector.includes('sl-viewer-wrap:fullscreen') ? {} : null;
const expanded = press();
assert.equal(asked, 1, 'not asked while expanded');
assert.equal(expanded.prevented, false);
@@ -536,3 +536,38 @@ test('an <img> that turns out too large is refused and let go', async (t) => {
await assert.rejects(aborted);
assert.equal(images.at(-1).src, '', 'its download stops');
});
test('a browser without :fullscreen / :popover-open (Safari < 16.4) still opens a flat photo and hands Esc on', async () => {
let asked = 0;
const viewer = createPhotoViewer({
providerId: 'test',
name: 'Test',
sources: SOURCES,
getPhoto: async (id) => PHOTOS[id],
step: async () => null,
onStep() {},
onEscape: () => ++asked > 0,
load: async (url) => ({ src: url }),
});
const host = fakeHost();
await viewer.mount(host);
const root = host.children[0];
// Safari 16.0–16.3: neither pseudo-class parses, so closest() throws.
root.closest = (selector) => {
if (/:fullscreen|:popover-open/.test(selector))
throw new SyntaxError(`'${selector}' is not a valid selector`);
return null;
};
let failure = null;
try {
await viewer.open('f');
} catch (error) {
failure = error;
}
assert.equal(failure, null, 'opening a flat photo does not throw');
const event = { key: 'Escape' };
event.preventDefault = () => {};
event.stopPropagation = () => {};
assert.doesNotThrow(() => root.fire('keydown', event), 'Esc does not throw');
assert.equal(asked, 1, 'Esc reaches the provider');
});
+16 -10
View File
@@ -5,10 +5,16 @@ const msg = (text) => text;
/** Identity and shared tuning for the provider-neutral Street Level layer. */
export const STREET_LEVEL_LAYER_ID = 'street-level';
/**
* The pixel window a click picks in: coverage lines are 2.5 px wide and there
* is no hover cue, so a click a few pixels off still finds the line.
*/
export const CLICK_PICK_WINDOW_PX = 9;
/** Pick id of the viewer position marker; the core owns it, not a provider. */
export const POSITION_PICK_ID = 'sl:pos';
/** One colour per imagery source: chip, coverage lines, points, cones and legend. */
/** One colour per imagery source: chip, coverage lines and cones. */
export const PROVIDER_COLORS = Object.freeze({
mapillary: '#05cb63',
google: '#4285f4',
@@ -31,17 +37,11 @@ export const MAX_SINCE_DAYS = 36_500;
/** Filter every provider starts with: all imagery, any date. */
export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
/** Nearest-image search radius in metres (Mapillary's graph API caps it at 50 m). */
/** Nearest-image search radius in metres. */
export const NEAREST_RADIUS_M = 50;
/**
* The only map stack camera follow is offered on: elsewhere a camera at eye
* height looks at a smeared texture.
*/
export const FOLLOW_MAP_STACK_ID = 'photoreal';
/** Eye height above the sampled ground when the globe camera follows the viewer. */
export const FOLLOW_EYE_HEIGHT_M = 2.4;
/** The Google 3D map stack: Street View may be shown only on it. */
export const GOOGLE_3D_MAP_STACK_ID = 'photoreal';
/** Above this, a ground click is a misclick, not a street. */
export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
@@ -50,3 +50,9 @@ export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
export const AIM_AT_GROUND_MESSAGE = msg(
'Aim the middle of the view at a street to look for imagery there',
);
/** The key gate's label for the pill and the layer list, or null when the key is fine. */
export function keyStatusLabel({ keyRequired, keyRejected }) {
if (keyRejected) return msg('KEY REJECTED');
return keyRequired ? msg('KEY REQUIRED') : null;
}
@@ -1,4 +1,4 @@
import { FOLLOW_MAP_STACK_ID } from '../../policy.js';
import { GOOGLE_3D_MAP_STACK_ID } from '../../policy.js';
import { createMapsLoader } from './mapsLoader.js';
import { getLocale } from '../../../../i18n/index.js';
import { createGoogleViewer } from './viewer.js';
@@ -64,7 +64,7 @@ export function createGoogleProvider({
credit: Object.freeze({ html: GOOGLE_CREDIT_HTML }),
groundClick: true,
// Google's terms forbid Street View beside non-Google maps.
requiresMapStack: FOLLOW_MAP_STACK_ID,
requiresMapStack: GOOGLE_3D_MAP_STACK_ID,
// Every panorama is billed to the Maps key.
defaultOn: false,
// All 360°, and no coverage for a date filter to thin.
@@ -114,8 +114,6 @@ export function createGoogleProvider({
stopAuthWatch();
},
refreshCoverage() {},
/** No filter applies to Street View (see `filters`). */
setFilter() {},
@@ -123,8 +121,6 @@ export function createGoogleProvider({
const keyRejected = loader.authFailed();
return {
count: 0,
zoom: null,
kind: null,
loading: false,
hint:
!active || keyRequired || keyRejected ? '' : GROUND_CLICK_HINT,
@@ -40,7 +40,7 @@ export function tileSegments(payload) {
* fourfold.
* @param {Array<Array<[number, number]>>} segments
* @param {{x: number, y: number, z: number}} address
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<never>}}
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>}}
*/
export function chainSegments(segments, { x, y, z }) {
const key = ([lon, lat]) => `${lon},${lat}`;
@@ -84,7 +84,7 @@ export function chainSegments(segments, { x, y, z }) {
isPano: false,
});
});
return { sequences, overview: [] };
return { sequences };
}
/** Decode a KartaView coverage tile into the shared coverage's line records. */
@@ -13,7 +13,7 @@ const at = (lng, lat) => ({ lat: String(lat), lng: String(lng) });
const TILE = { x: 4412, y: 6061, z: 14 };
test('segments that meet end to start chain into one line', () => {
const { sequences, overview } = decodeCoverageTile(
const { sequences } = decodeCoverageTile(
{
lines: [
{ track: [at(1, 1), at(2, 1)], matched: '1' },
@@ -23,7 +23,6 @@ test('segments that meet end to start chain into one line', () => {
},
TILE,
);
assert.deepEqual(overview, []);
assert.equal(sequences.length, 2);
assert.deepEqual(sequences[0].parts, [
[
@@ -1,4 +1,4 @@
import { createLineCoverage } from '../../lineCoverage.js';
import { createLineCoverage, freshCoverage } from '../../lineCoverage.js';
import { createLru, createTileCache } from '../../tileCache.js';
export { createTileCache } from '../../tileCache.js';
@@ -49,10 +49,7 @@ const PAGE_CACHE_SIZE = 12;
/** z14 near the street, z13 a little higher, nothing above. */
export function kartaViewZoomFor(heightM) {
if (!Number.isFinite(heightM) || heightM > COVERAGE_MAX_HEIGHT_M) return null;
return {
zoom: heightM > COVERAGE_STREET_ZOOM_BELOW_M ? 13 : 14,
kind: 'sequence',
};
return heightM > COVERAGE_STREET_ZOOM_BELOW_M ? 13 : 14;
}
/** The 1-based page of a sequence that holds `index`, if no photo is missing. */
@@ -88,8 +85,6 @@ export function createKartaViewProvider({
filters: Object.freeze(['pano', 'since']),
// SINCE is the upload date for KartaView: the panel notes it.
sinceBasis: 'upload',
// Its lines are streets, not sequences: a selected drive shows as cones.
highlightsSequence: false,
coverageLabel: Object.freeze({
noun: COVERAGE_NOUN,
action: LINE_CLICK_HINT,
@@ -100,30 +95,13 @@ export function createKartaViewProvider({
context,
services: context.services,
viewer: null,
keyRequired: false,
keyRejected: false,
statusKnown: true,
coverage: {
zoom: null,
tiles: new Map(),
stale: new Map(),
staleTimer: null,
pending: new Map(),
lastError: null,
holdUntil: 0,
holdTimer: null,
debounceTimer: null,
removeCameraListener: null,
terrainReady: null,
hint: '',
kind: null,
},
coverage: freshCoverage({ keyless: true }),
sequence: {
selectedId: null,
images: [],
cache: new Map(),
collection: null,
loading: false,
error: null,
abort: null,
},
};
@@ -349,6 +327,8 @@ export function createKartaViewProvider({
// Filtered on the way in (fetchTile): a line has no date to test.
passes: () => true,
});
// Its lines are streets, not drives: a selected drive shows as cones
// only, so the coverage is never highlighted.
const cones = createSequenceCones({
state,
@@ -480,7 +460,7 @@ export function createKartaViewProvider({
deactivate() {
coverage.detach();
coverage.clear();
coverage.resetErrors();
coverage.unblock();
cones.clearSelection();
cones.setVisible(false);
drawnPage = null;
@@ -492,17 +472,15 @@ export function createKartaViewProvider({
destroy(cesiumViewer) {
coverage.detach();
coverage.clear();
coverage.resetErrors();
coverage.unblock();
cones.destroy(cesiumViewer);
tiles.clear();
masks.clear();
},
refreshCoverage: () => coverage.refresh(),
/**
* The filter changed: the lines come from KartaView again under it
* (the old ones show meanwhile), the cones are drawn again.
* (cleared until they come), the cones are drawn again.
*/
setFilter() {
const query = tileFilterQuery(context.getFilter());
@@ -513,29 +491,17 @@ export function createKartaViewProvider({
cones.rerender();
},
setSurface() {
coverage.setSurface();
cones.rerender();
},
/** Keyless; an empty view that has loaded says there is nothing here. */
coverageStats() {
const { hint } = state.coverage;
const count = coverage.sequenceCount();
const loading = state.coverage.pending.size > 0;
const stats = coverage.stats();
const empty =
state.coverage.zoom !== null &&
!loading &&
count === 0 &&
!state.coverage.lastError;
!stats.loading &&
stats.count === 0 &&
!stats.error;
return {
count,
zoom: state.coverage.zoom,
kind: state.coverage.kind,
loading,
hint: hint || (empty ? NO_COVERAGE_HINT : ''),
error: state.coverage.lastError,
keyRequired: false,
keyRejected: false,
...stats,
hint: stats.hint || (empty ? NO_COVERAGE_HINT : ''),
};
},
@@ -553,7 +519,7 @@ export function createKartaViewProvider({
return false;
},
// Never from the cache: it is keyed by sequence, the cones by page.
// Always loaded afresh: the cones show the page of the photo open now.
selectSequence: (sequenceId) =>
cones.select(sequenceId, { force: true }),
clearSequence: () => {
@@ -567,6 +533,7 @@ export function createKartaViewProvider({
counts.get(state.sequence.selectedId) ??
state.sequence.images.length,
loading: state.sequence.loading,
error: state.sequence.error,
}),
/**
@@ -37,16 +37,15 @@ export const COVERAGE_STREET_ZOOM_BELOW_M = 2_500;
export const COVERAGE_MAX_HEIGHT_M = 6_000;
export const COVERAGE_TUNING = Object.freeze({
maxTiles: 9,
overviewMaxTiles: 0,
// Chained ways are short: a dense downtown z13 tile holds ~1,500, so each
// tile may draw 1,600 (a frame stays at 16.7 ms with 4,400 drawn).
maxSequences: 14_400,
lineWidthPx: 2.5,
overviewPointPx: 2.5,
moveDebounceMs: 320,
viewRangeMinM: 2_500,
viewRangePerHeight: 10,
viewNearM: 1_000,
// Lines carry no date to sort by: keep KartaView's order.
keepOrder: true,
});
export const TOO_HIGH_HINT = msg('zoom in to see KartaView coverage');
export const NO_COVERAGE_HINT = msg('no KartaView photos in view');
@@ -6,6 +6,7 @@ import {
kartaViewZoomFor,
} from './index.js';
import {
COVERAGE_TUNING,
FILTERED_OUT_MESSAGE,
NO_COVERAGE_HINT,
SEQUENCE_PAGE_SIZE,
@@ -100,9 +101,6 @@ function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) {
services: {},
getFilter: () => actions.filter,
isActive: () => true,
groundCaster: null,
meshSampler: null,
getSurface: () => 'draped',
notify() {},
actions: {
// As the core does: the provider's own viewer opens it.
@@ -153,11 +151,6 @@ test('KartaView registers keyless, off by default, with Mapillary’s filters an
'upload',
'SINCE is KartaView’s upload date',
);
assert.equal(
definition.highlightsSequence,
false,
'its lines are streets: a selected drive shows as cones',
);
assert.match(
definition.credit.html,
/KartaView<\/a> contributors, CC BY-SA 4\.0/,
@@ -176,14 +169,10 @@ test('KartaView registers keyless, off by default, with Mapillary’s filters an
});
test('coverage is drawn from z14 near the street and z13 a little higher; never from orbit', () => {
assert.deepEqual(kartaViewZoomFor(300), { zoom: 14, kind: 'sequence' });
assert.deepEqual(kartaViewZoomFor(2_500), { zoom: 14, kind: 'sequence' });
assert.deepEqual(kartaViewZoomFor(4_000), { zoom: 13, kind: 'sequence' });
assert.deepEqual(
kartaViewZoomFor(6_000),
{ zoom: 13, kind: 'sequence' },
'the top of the range still draws',
);
assert.equal(kartaViewZoomFor(300), 14);
assert.equal(kartaViewZoomFor(2_500), 14);
assert.equal(kartaViewZoomFor(4_000), 13);
assert.equal(kartaViewZoomFor(6_000), 13, 'the top of the range still draws');
assert.equal(kartaViewZoomFor(6_001), null);
assert.equal(kartaViewZoomFor(NaN), null);
});
@@ -515,7 +504,7 @@ test('coverage tiles are kept for a while: panning back asks KartaView nothing',
const tiles = createTileCache({
load: async (tile) => {
asked.push(`${tile.z}/${tile.x}/${tile.y}`);
return { sequences: [], overview: [] };
return { sequences: [] };
},
size: 2,
ttlMs: 1000,
@@ -789,6 +778,14 @@ function coverageSetup(
};
}
/** The camera came to rest: coverage refreshes once its debounce is over. */
async function moved(viewer) {
viewer.camera.moveEnd.raiseEvent();
await new Promise((resolve) =>
setTimeout(resolve, COVERAGE_TUNING.moveDebounceMs + 20),
);
}
test('a filter thins the lines to those its PNG mask draws; without one they all show', async (t) => {
const { lon, lat } = tileCentre(-83.0458, 42.3314);
// Across the tile's middle row (mask row ~256), and a quarter up (~130).
@@ -811,15 +808,16 @@ test('a filter thins the lines to those its PNG mask draws; without one they all
assert.ok(await until(() => env.instance.coverageStats().count === 2));
assert.equal(env.tiles.length, 1, 'the segments are kept');
// SINCE a day: uploaded since yesterday (UTC) — none here. The lines
// drawn now stay on the globe while KartaView draws the filtered tile.
// drawn now go while KartaView draws the filtered tile.
const since = Date.now() - 86_400_000;
const release = env.holdMasks();
env.actions.filter = { pano: 'all', sinceMs: since };
env.instance.setFilter();
assert.ok(await until(() => env.instance.coverageStats().loading));
assert.ok(
env.viewer.scene.groundPrimitives.items.size > 0,
'the unfiltered lines show meanwhile',
assert.equal(
env.viewer.scene.groundPrimitives.items.size,
0,
'the unfiltered lines are not shown as filtered ones',
);
release();
assert.ok(
@@ -834,10 +832,7 @@ test('a filter thins the lines to those its PNG mask draws; without one they all
`startDate=${new Date(since).toISOString().slice(0, 10)}`,
);
assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT);
assert.ok(
await until(() => env.viewer.scene.groundPrimitives.items.size === 0),
'and go once it is in',
);
assert.equal(env.viewer.scene.groundPrimitives.items.size, 0);
// The same filter again asks KartaView for nothing.
env.instance.setFilter();
for (let i = 0; i < 5; i++) await settle();
@@ -851,7 +846,7 @@ test('coverage hints: zoom in from too high, “no photos” only once an empty
assert.deepEqual(env.tiles, [], 'nothing asked from up there');
const release = env.holdTiles();
env.viewer.view.height = 900;
env.instance.refreshCoverage();
await moved(env.viewer);
assert.ok(await until(() => env.instance.coverageStats().loading));
assert.equal(env.instance.coverageStats().hint, '', 'not while it loads');
release();
@@ -871,7 +866,7 @@ test('coverage tiles are kept while KartaView is on and dropped once it is switc
});
env.instance.activate(env.viewer);
assert.ok(await until(() => env.instance.coverageStats().count === 1));
env.instance.refreshCoverage();
await moved(env.viewer);
assert.ok(await until(() => env.instance.coverageStats().count === 1));
assert.equal(env.tiles.length, 1, 'kept while on');
// Switched off: the decoded lines are not kept, so on again asks again.
@@ -1,70 +0,0 @@
import {
createLineCoverage,
sequenceIdFromPick as lineSequenceIdFromPick,
} from '../../lineCoverage.js';
import { decodeCoverageTile } from './decode.js';
import {
coverageZoomForHeight,
overviewZoomForHeight,
} from '../../tileMath.js';
import {
COLORS,
COVERAGE_LINE_WIDTH_PX,
COVERAGE_MAX_SEQUENCES,
COVERAGE_MAX_TILES,
COVERAGE_MOVE_DEBOUNCE_MS,
COVERAGE_OVERVIEW_MAX_TILES,
COVERAGE_OVERVIEW_POINT_PX,
KEY_REJECTED_MESSAGE,
PICK_PREFIX,
RATE_LIMITED_MESSAGE,
SEQUENCE_VIEW_NEAR_M,
SEQUENCE_VIEW_RANGE_MIN_M,
SEQUENCE_VIEW_RANGE_PER_HEIGHT,
} from './policy.js';
export { SWAP_MAX_WAIT_MS, meshBoxInRange } from '../../lineCoverage.js';
/** Overview points (z0–5) from orbit, sequence lines (z11–14) below 60 km. */
export function mapillaryZoomFor(heightM) {
const sequence = coverageZoomForHeight(heightM);
if (sequence != null) return { zoom: sequence, kind: 'sequence' };
const overview = overviewZoomForHeight(heightM);
return overview != null ? { zoom: overview, kind: 'overview' } : null;
}
/** Sequence id for any part's pick id (`mly:seq:<id>[~<part>]`), else null. */
export function sequenceIdFromPick(pickId) {
return lineSequenceIdFromPick(pickId, PICK_PREFIX.sequence);
}
/** Mapillary's vector-tile coverage through the server proxy. */
export function createCoverage({ state, source }) {
return createLineCoverage({
state,
fetchTile: async (tile, { signal }) =>
decodeCoverageTile(
await source.getTile('coverage', tile.z, tile.x, tile.y, { signal }),
tile,
),
zoomFor: mapillaryZoomFor,
colors: COLORS,
pickPrefix: PICK_PREFIX.sequence,
renderKey: 'mapillary-coverage',
tuning: {
maxTiles: COVERAGE_MAX_TILES,
overviewMaxTiles: COVERAGE_OVERVIEW_MAX_TILES,
maxSequences: COVERAGE_MAX_SEQUENCES,
lineWidthPx: COVERAGE_LINE_WIDTH_PX,
overviewPointPx: COVERAGE_OVERVIEW_POINT_PX,
moveDebounceMs: COVERAGE_MOVE_DEBOUNCE_MS,
viewRangeMinM: SEQUENCE_VIEW_RANGE_MIN_M,
viewRangePerHeight: SEQUENCE_VIEW_RANGE_PER_HEIGHT,
viewNearM: SEQUENCE_VIEW_NEAR_M,
},
messages: {
keyRejected: KEY_REJECTED_MESSAGE,
rateLimited: RATE_LIMITED_MESSAGE,
},
});
}
@@ -1,989 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
SWAP_MAX_WAIT_MS,
createCoverage,
meshBoxInRange,
} from './coverage.js';
import { encodeCoverageTile } from './coverageFixture.mjs';
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
import {
COLORS,
COVERAGE_MAX_SEQUENCES,
COVERAGE_MAX_TILES,
} from './policy.js';
import { createGroundCaster } from '../../groundCast.js';
import { lonToTileX, latToTileY, tileBounds } from '../../tileMath.js';
const RAD = Math.PI / 180;
// Node has no WebGL context to report line-width limits; a browser's
// context sets these on startup, and appearances validate against them.
Cesium.ContextLimits._minimumAliasedLineWidth = 1;
Cesium.ContextLimits._maximumAliasedLineWidth = 10;
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/** A scene collection that remembers what is on the globe. */
function collection() {
const items = new Set();
return {
items,
add(primitive) {
items.add(primitive);
return primitive;
},
remove(primitive) {
return items.delete(primitive);
},
};
}
/** The centre of the z14 tile holding a point, so a small view is one tile. */
function tileCentre(lon, lat) {
const x = lonToTileX(lon, 14);
const y = latToTileY(lat, 14);
const { west, east, south, north } = tileBounds(x, y, 14);
return {
tile: { x, y, z: 14 },
lon: (west + east) / 2,
lat: (south + north) / 2,
};
}
/** A viewer looking straight down on a 0.004° square around `view`. */
function fakeViewer(view) {
const postRender = new Set();
const preRender = new Set();
return {
view,
postRender,
preRender,
scene: {
canvas: { clientWidth: 100, clientHeight: 100 },
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
groundPrimitives: collection(),
primitives: collection(),
postRender: {
addEventListener(listener) {
postRender.add(listener);
return () => postRender.delete(listener);
},
},
preRender: {
addEventListener(listener) {
preRender.add(listener);
return () => preRender.delete(listener);
},
},
},
camera: {
get positionCartographic() {
return {
longitude: view.lon * RAD,
latitude: view.lat * RAD,
height: view.height,
};
},
pickEllipsoid(point) {
return Cesium.Cartesian3.fromDegrees(
view.lon - 0.002 + (point.x / 100) * 0.004,
view.lat + 0.002 - (point.y / 100) * 0.004,
);
},
computeViewRectangle: () => undefined,
},
};
}
/** A source whose tile requests the test resolves, one call at a time. */
function deferredSource() {
const calls = [];
return {
calls,
getTile(layer, z, x, y, { signal } = {}) {
return new Promise((resolve, reject) => {
calls.push({ key: `${z}/${x}/${y}`, z, signal, resolve, reject });
});
},
};
}
function setup({
surface = 'draped',
groundCaster = null,
meshSampler = null,
} = {}) {
const centre = tileCentre(-121.4944, 38.5816);
const viewer = fakeViewer({ lon: centre.lon, lat: centre.lat, height: 900 });
const source = deferredSource();
const state = {
viewer,
services: {},
keyRequired: false,
context: {
filter: { pano: 'all', sinceMs: null },
getFilter: () => state.context.filter,
isActive: () => true,
notify() {},
getSurface: () => surface,
groundCaster,
meshSampler,
},
coverage: {
zoom: null,
kind: null,
tiles: new Map(),
stale: new Map(),
staleTimer: null,
pending: new Map(),
lastError: null,
debounceTimer: null,
removeCameraListener: null,
// Skip Cesium's one-time terrain table download.
terrainReady: Promise.resolve(),
hint: '',
},
sequence: { selectedId: null },
};
const coverage = createCoverage({ state, source });
const bytes = encodeCoverageTile(centre.tile, {
sequences: [
{
id: 'seq-1',
parts: [
[
[centre.lon - 0.001, centre.lat],
[centre.lon + 0.001, centre.lat],
],
],
},
],
});
const tileKey = `14/${centre.tile.x}/${centre.tile.y}`;
const onGlobe = () =>
viewer.scene.groundPrimitives.items.size +
viewer.scene.primitives.items.size;
return { viewer, source, state, coverage, bytes, tileKey, onGlobe, centre };
}
/** Run one frame's listeners, as Cesium's render loop would. */
function frame(listeners) {
for (const listener of [...listeners]) listener();
}
test('a superseded tile request cannot strand lines on the globe', async () => {
const { viewer, source, state, coverage, bytes, tileKey, onGlobe } = setup();
const forTile = () => source.calls.filter((call) => call.key === tileKey);
coverage.refresh(); // street zoom: request #1 for the tile
assert.equal(forTile().length, 1);
viewer.view.height = 100_000; // zoom out: retire() drops request #1
coverage.refresh();
viewer.view.height = 900; // back in before #1 settles: request #2
coverage.refresh();
assert.equal(forTile().length, 2);
await settle();
// The superseded request finishes late. It must not settle request #2's
// key, or the next refresh would start a duplicate load.
forTile()[0].resolve(bytes);
await settle();
assert.equal(
state.coverage.pending.has(tileKey),
true,
'#2 still owns the key',
);
assert.equal(state.coverage.pending.size, 1, 'still loading');
coverage.refresh();
assert.equal(forTile().length, 2, 'no duplicate request');
forTile()[1].resolve(bytes);
await settle();
assert.equal(state.coverage.tiles.size, 1);
assert.ok(onGlobe() > 0, 'the tile drew its lines');
// Everything drawn can be removed: nothing is orphaned.
coverage.clear();
assert.equal(onGlobe(), 0);
});
test('in terrain mode a tile draws draped, then swaps to cast lines without a blank frame', async () => {
let heightsReady = false;
const groundCaster = {
prepareLines: async () => {
heightsReady = true;
return true;
},
castLine: (coords) =>
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
};
const { viewer, source, coverage, bytes, onGlobe } = setup({
surface: 'terrain',
groundCaster,
});
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
await settle();
const { groundPrimitives, primitives } = viewer.scene;
// Both are on the globe while the cast lines build: no blink.
assert.equal(groundPrimitives.items.size, 1, 'draped lines kept');
assert.equal(primitives.items.size, 1, 'cast lines added');
assert.ok(
[...primitives.items][0] instanceof Cesium.Primitive,
'cast lines are plain polylines at terrain height',
);
// Leaving mid-swap removes both sets, not just the new one.
coverage.clear();
assert.equal(onGlobe(), 0);
assert.equal(viewer.postRender.size, 0, 'the swap listener is gone');
});
test('a tile whose lines cannot be cast is not rebuilt for nothing', async () => {
let prepares = 0;
const groundCaster = {
prepareLines: async () => {
prepares++;
return false; // terrain proxy down, or tile too big
},
castLine: () => null,
};
const { viewer, source, coverage, bytes } = setup({
surface: 'terrain',
groundCaster,
});
const added = [];
const add = viewer.scene.groundPrimitives.add.bind(
viewer.scene.groundPrimitives,
);
viewer.scene.groundPrimitives.add = (primitive) => {
added.push(primitive);
return add(primitive);
};
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
await settle();
assert.equal(prepares, 1);
assert.equal(added.length, 1, 'drawn once, draped; no second draped build');
assert.equal(viewer.scene.primitives.items.size, 0);
coverage.clear();
});
test('panning off a tile that is still loading stops counting it as loading', async () => {
const { viewer, source, state, coverage } = setup();
coverage.refresh();
assert.equal(state.coverage.pending.size, 1);
// Same zoom, two tiles east: the first request is dropped, a new one starts.
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 2);
assert.equal(source.calls[0].signal.aborted, true);
assert.equal(
state.coverage.pending.size,
1,
'only the live request counts as loading',
);
source.calls[0].reject(new DOMException('aborted', 'AbortError'));
source.calls[1].resolve(new Uint8Array());
await settle();
assert.equal(state.coverage.pending.size, 0, 'LOADING clears');
coverage.clear();
});
test('zoom 0 is a zoom: the whole-earth view still shows coverage', () => {
const { viewer, source, state, coverage } = setup();
viewer.view.height = 20_000_000;
coverage.refresh();
assert.equal(state.coverage.zoom, 0);
assert.equal(state.coverage.hint, '');
assert.deepEqual(
source.calls.map((call) => call.key),
['0/0/0'],
);
coverage.clear();
});
test('a rejected key stops coverage requests until the layer goes off', async () => {
const { viewer, source, state, coverage } = setup();
coverage.refresh();
const rejected = Object.assign(
new Error('Mapillary rejected the access token'),
{
keyRejected: true,
},
);
source.calls[0].reject(rejected);
await settle();
assert.equal(state.keyRejected, true);
assert.match(state.coverage.lastError, /rejected MAPILLARY_CLIENT_TOKEN/);
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 1, 'panning asks for nothing more');
// Turning the provider off forgets the verdict; the next run asks again.
coverage.clear();
coverage.resetErrors();
assert.equal(state.keyRejected, false);
assert.equal(state.coverage.lastError, null);
coverage.refresh();
assert.equal(source.calls.length, 2);
coverage.clear();
});
test('a rate limit keeps the drawn tiles and asks again once the wait is over', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, source, state, coverage } = setup();
coverage.refresh();
source.calls[0].reject(
Object.assign(new Error('rate limited'), { retryAfterSec: 30 }),
);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
assert.match(state.coverage.lastError, /rate-limited/);
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 1, 'held: nothing is requested');
t.mock.timers.tick(30_000);
assert.equal(source.calls.length, 2, 'one refresh once the wait is over');
assert.equal(state.coverage.lastError, null);
coverage.clear();
coverage.resetErrors();
});
test('overview points behind the horizon are hidden, not drawn through the globe', async () => {
const { viewer, source, state, coverage } = setup();
const { view } = viewer;
view.height = 20_000_000;
Object.defineProperty(viewer.camera, 'positionWC', {
get: () => Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height),
});
coverage.refresh();
// One dot under the camera (Sacramento), one near its antipode.
source.calls[0].resolve(
encodeCoverageTile(
{ x: 0, y: 0, z: 0 },
{
overview: [
{ id: 'near', lon: -121.5, lat: 38.6 },
{ id: 'far', lon: 58.5, lat: -38.6 },
],
},
),
);
await settle();
const [entry] = state.coverage.tiles.values();
const points = [0, 1].map((i) => entry.primitive.get(i));
const west = (point) =>
Cesium.Cartographic.fromCartesian(point.position).longitude < 0;
const near = points.find(west);
const far = points.find((point) => !west(point));
frame(viewer.preRender);
assert.equal(near.show, true, 'the near side stays visible');
assert.equal(far.show, false, 'the far side does not show through');
// Fly round to the other hemisphere: the two swap.
view.lon = 58.5;
view.lat = -38.6;
frame(viewer.preRender);
assert.equal(near.show, false);
assert.equal(far.show, true);
coverage.clear();
assert.equal(viewer.preRender.size, 0, 'the cull listener is gone');
});
test('a cast tile whose heights were evicted is cast again, not left draped', async () => {
const terrain = {
calls: 0,
async resolveEllipsoidalGround(coords) {
this.calls++;
return coords.map(() => ({ ellipsoid: 20, source: 'reearth' }));
},
};
// Room for one tile's corners at a time.
const groundCaster = createGroundCaster({
terrain,
maxCorners: 16,
cacheMax: 20,
});
let sampled = null;
const meshSampler = {
onSampled: (listener) => (sampled = listener),
request() {},
meshAt: () => undefined,
};
const { source, state, coverage, bytes, centre } = setup({
surface: 'terrain',
groundCaster,
meshSampler,
});
const isCast = () => {
const [entry] = state.coverage.tiles.values();
return (
entry.primitives.length > 0 &&
entry.primitives.every(({ onGround }) => !onGround)
);
};
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
await settle();
assert.ok(isCast(), 'cast once the heights are in');
// Another region fills the cache and evicts this tile's corners...
await groundCaster.prepare([[centre.lon + 0.05, centre.lat + 0.05]]);
assert.equal(groundCaster.groundAt(centre.lon - 0.001, centre.lat), null);
// ...then mesh samples redraw the tile, which can only drape now.
sampled([[centre.lon, centre.lat]]);
await new Promise((resolve) => setTimeout(resolve, 5));
coverage.refresh();
await settle();
await settle();
assert.ok(isCast(), 'the tile is cast again');
coverage.clear();
});
test('a tile left draped by a failed terrain lookup is cast on the next refresh', async () => {
let up = false;
let prepares = 0;
let heightsReady = false;
const groundCaster = {
prepareLines: async () => {
prepares++;
heightsReady = up;
return up;
},
castLine: (coords) =>
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
};
const { viewer, source, coverage, bytes } = setup({
surface: 'terrain',
groundCaster,
});
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
await settle();
assert.equal(prepares, 1);
assert.equal(viewer.scene.primitives.items.size, 0, 'draped');
await settle();
assert.equal(prepares, 1, 'no retry loop while the proxy is down');
up = true; // the proxy is back
coverage.refresh();
await settle();
assert.equal(prepares, 2, 'the next refresh tries again');
assert.equal(viewer.scene.primitives.items.size, 1, 'cast lines added');
coverage.clear();
});
test('the per-tile cap applies after the imagery filter, so a dense tile keeps its 360° lines', async () => {
const { state, source, coverage, centre } = setup();
const line = (n) => [
[
[centre.lon - 0.001, centre.lat + n * 1e-6],
[centre.lon + 0.001, centre.lat + n * 1e-6],
],
];
// 700 newer flat sequences, then 300 older 360° ones.
const sequences = [];
for (let i = 0; i < 700; i++)
sequences.push({
id: `flat-${i}`,
parts: line(i),
capturedAt: 2_000_000_000_000 + i,
});
for (let i = 0; i < 300; i++)
sequences.push({
id: `pano-${i}`,
parts: line(i),
capturedAt: 1_000_000_000_000 + i,
isPano: true,
});
state.context.filter = { pano: 'pano', sinceMs: null };
coverage.refresh();
source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences }));
await settle();
const [entry] = state.coverage.tiles.values();
const cap = Math.floor(COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES);
assert.equal(entry.count, Math.min(300, cap), 'every 360° line is drawn');
assert.equal(coverage.sequenceCount(), Math.min(300, cap));
// Picking and recolouring look sequences up among the drawn lines.
assert.ok(entry.sequences.has('pano-0'), 'drawn lines can be looked up');
assert.equal(entry.sequences.has('flat-699'), false);
state.context.filter = { pano: 'all', sinceMs: null };
coverage.rebuild();
assert.equal(entry.count, cap, 'all imagery is capped as before');
assert.ok(entry.sequences.has('flat-699'), 'the newest flat line is drawn');
assert.equal(entry.sequences.has('pano-299'), false, 'over the cap');
coverage.clear();
});
/** The colour attribute value of a line, selected or not. */
function colourValue(selected) {
return Array.from(
Cesium.ColorGeometryInstanceAttribute.toValue(
selected
? Cesium.Color.fromCssColorString(COLORS.selected)
: Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92),
),
);
}
/** Take over a drawn primitive's readiness and seq-1's colour attribute. */
function controlled(record, ready) {
const control = { ready, attributes: { color: undefined } };
Object.defineProperty(record.primitive, 'ready', {
get: () => control.ready,
});
record.primitive.getGeometryInstanceAttributes = (id) =>
id === 'mly:seq:seq-1' ? control.attributes : undefined;
return control;
}
test('a selection made or cleared while a tile builds is applied once it is ready', async () => {
const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
const [entry] = state.coverage.tiles.values();
// Selected while its line is still building: nothing to recolour yet...
const first = controlled(entry.primitives[0], false);
state.sequence.selectedId = 'seq-1';
coverage.recolorSequence('seq-1', true);
assert.equal(first.attributes.color, undefined);
// ...so the highlight lands once it is ready.
first.ready = true;
frame(viewer.postRender);
assert.deepEqual(Array.from(first.attributes.color), colourValue(true));
// Rebuilt with the highlight baked in, then cleared mid-build: not stuck.
coverage.rebuild();
const second = controlled(entry.primitives[0], false);
coverage.recolorSequence('seq-1', false);
state.sequence.selectedId = null;
second.ready = true;
frame(viewer.postRender);
assert.deepEqual(Array.from(second.attributes.color), colourValue(false));
coverage.clear();
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
});
test('a sequence that crosses a tile edge is counted once', async () => {
const { viewer, source, coverage, centre } = setup();
// Look straight down on the edge between this tile and its east neighbour.
const { east } = tileBounds(centre.tile.x, centre.tile.y, 14);
viewer.view.lon = east;
coverage.refresh();
assert.equal(source.calls.length, 2, 'two tiles in view');
const across = [
[
[east - 0.001, centre.lat],
[east + 0.001, centre.lat],
],
];
for (const call of source.calls) {
const [, x, y] = call.key.split('/').map(Number);
call.resolve(
encodeCoverageTile(
{ x, y, z: 14 },
{
sequences: [
{ id: 'across', parts: across },
{ id: `only-${x}`, parts: across },
],
},
),
);
}
await settle();
assert.equal(coverage.sequenceCount(), 3, 'across, plus one per tile');
coverage.clear();
});
/** A terrain-mode tile mid-swap, with a cast primitive the test readies. */
async function midSwap() {
// Drawn draped first; castLine only answers once the heights are in.
let heightsReady = false;
const groundCaster = {
prepareLines: async () => (heightsReady = true),
castLine: (coords) =>
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
};
const context = setup({ surface: 'terrain', groundCaster });
context.coverage.refresh();
context.source.calls[0].resolve(context.bytes);
await settle();
await settle();
const { groundPrimitives, primitives } = context.viewer.scene;
assert.equal(groundPrimitives.items.size, 1, 'draped lines kept');
assert.equal(primitives.items.size, 1, 'cast lines building');
const [cast] = primitives.items;
const control = { ready: false };
Object.defineProperty(cast, 'ready', { get: () => control.ready });
return { ...context, control };
}
test('the draped lines go once the cast lines are ready', async () => {
const { viewer, coverage, control } = await midSwap();
frame(viewer.postRender);
assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'still building');
control.ready = true;
frame(viewer.postRender);
assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'draped removed');
assert.equal(viewer.scene.primitives.items.size, 1, 'cast lines stay');
assert.equal(viewer.postRender.size, 0, 'nothing left watching');
coverage.clear();
});
test('the draped lines go after the wait even if the cast lines never get ready', async (t) => {
t.mock.timers.enable({ apis: ['Date'] });
const { viewer, coverage } = await midSwap();
t.mock.timers.tick(SWAP_MAX_WAIT_MS - 1);
frame(viewer.postRender);
assert.equal(viewer.scene.groundPrimitives.items.size, 1, 'still waiting');
t.mock.timers.tick(2);
frame(viewer.postRender);
assert.equal(viewer.scene.groundPrimitives.items.size, 0, 'draped removed');
assert.equal(viewer.scene.primitives.items.size, 1);
coverage.clear();
});
/** A camera over a 100×100 canvas with a 60°×40° view (see rayCamera). */
function tiltedCamera({ lon, lat, height, heading, pitch }) {
return rayCamera({
lon,
lat,
altitude: height,
heading,
pitch,
width: 100,
height: 100,
fovX: 60,
fovY: 40,
});
}
/**
* Denver: 300 m above a street 1,600 m up, at a z14 tile centre, looking east
* at ground `aheadM` metres out. Only the bare-earth caster knows the ground.
*/
function denverStreetView(aheadM) {
const z = 14;
const x = lonToTileX(-104.99, z);
const y = latToTileY(39.74, z);
const tile = tileBounds(x, y, z);
const lon = (tile.west + tile.east) / 2;
const lat = (tile.south + tile.north) / 2;
const ground = 1600;
const above = 300;
const context = setup({ groundCaster: { groundAt: () => ground } });
const tileWidthM = (tile.east - tile.west) * 111_320 * Math.cos(lat * RAD);
context.viewer.camera = tiltedCamera({
lon,
lat,
height: ground + above,
heading: 90,
pitch: -Math.atan(above / aheadM(tileWidthM)) / RAD,
});
context.coverage.refresh();
const keys = context.source.calls.map((call) => call.key);
/** Requested tiles as [dx, dy] from the tile under the camera. */
const offsets = keys.map((k) => {
const [, tx, ty] = k.split('/').map(Number);
return [tx - x, ty - y];
});
return {
...context,
keys,
offsets,
key: (dx, dy) => `${z}/${x + dx}/${y + dy}`,
};
}
test('a tilted street view over ground 1,600 m up asks for the tiles it looks at first', () => {
// The screen centre meets the ground at the centre of the next tile east.
const { state, coverage, keys, offsets, key } = denverStreetView(
(tileWidthM) => tileWidthM,
);
assert.equal(state.coverage.zoom, 14, 'height measured from the ground');
assert.deepEqual(
keys.slice(0, 2),
[key(0, 0), key(1, 0)],
'the tile under the camera, then the one the screen centre looks at',
);
assert.ok(keys.includes(key(-1, 0)), 'the ground behind the camera too');
for (const [dx, dy] of offsets)
assert.ok(dx >= -1 && dx <= 2 && Math.abs(dy) <= 1, `${dx},${dy} in reach`);
coverage.clear();
});
test('a street view toward the horizon ranks only the ground within range', () => {
// The screen centre meets the ground 6 km out, past the 3 km range: the
// tiles are ranked around the camera, not along a line to the horizon.
const { coverage, keys, offsets, key } = denverStreetView(() => 6000);
assert.equal(keys[0], key(0, 0), 'the tile under the camera first');
// Behind and ahead are equally near the camera (ties keep row order); a
// line of sight to the far ground would rank the tile ahead first.
assert.ok(
keys.indexOf(key(-1, 0)) < keys.indexOf(key(1, 0)),
'the tile ahead is not pulled forward',
);
for (const [dx, dy] of offsets)
assert.ok(Math.abs(dx) <= 1 && Math.abs(dy) <= 1, `${dx},${dy} around it`);
coverage.clear();
});
test('a selection cleared while the old zoom is still shown uncolours its lines', async () => {
const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
const [entry] = state.coverage.tiles.values();
const line = controlled(entry.primitives[0], true);
state.sequence.selectedId = 'seq-1';
coverage.recolorSequence('seq-1', true);
assert.deepEqual(Array.from(line.attributes.color), colourValue(true));
// Zoom out: the street tile stays on screen while the new zoom loads...
viewer.view.height = 100_000;
coverage.refresh();
assert.equal(state.coverage.stale.size, 1, 'the old zoom is kept');
// ...and the selection is cleared meanwhile.
state.sequence.selectedId = null;
coverage.recolorSequence('seq-1', false);
assert.deepEqual(
Array.from(line.attributes.color),
colourValue(false),
'the retained line is not left cyan',
);
coverage.clear();
});
test('a selection made while the old zoom is still building is applied once it is ready', async () => {
const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
const [entry] = state.coverage.tiles.values();
const line = controlled(entry.primitives[0], false);
viewer.view.height = 100_000;
coverage.refresh();
assert.equal(state.coverage.stale.size, 1);
frame(viewer.postRender);
state.sequence.selectedId = 'seq-1';
coverage.recolorSequence('seq-1', true);
line.ready = true;
frame(viewer.postRender);
assert.deepEqual(
line.attributes.color && Array.from(line.attributes.color),
colourValue(true),
'the highlight lands on the retained line',
);
coverage.clear();
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
});
test('only lines within the mesh sampler range of the camera are asked for', async () => {
let heightsReady = false;
const groundCaster = {
prepareLines: async () => (heightsReady = true),
castLine: (coords) =>
heightsReady ? coords.flatMap(([lon, lat]) => [lon, lat, 30]) : null,
};
const requested = [];
const meshSampler = {
onSampled() {},
request: (points) => requested.push(...points),
meshAt: () => undefined,
};
const { source, coverage, centre } = setup({
surface: 'terrain',
groundCaster,
meshSampler,
});
const { east, north } = tileBounds(centre.tile.x, centre.tile.y, 14);
coverage.refresh();
source.calls[0].resolve(
encodeCoverageTile(centre.tile, {
sequences: [
{
id: 'here',
parts: [
[
[centre.lon - 0.001, centre.lat],
[centre.lon + 0.001, centre.lat],
],
],
},
// ~1.1 km from the camera, in the tile's north-east corner.
{
id: 'corner',
parts: [
[
[east - 0.002, north - 0.002],
[east - 0.0005, north - 0.0005],
],
],
},
],
}),
);
await settle();
await settle();
assert.ok(requested.length > 0, 'the near line is asked for');
const farthest = Math.max(
...requested.map(([lon, lat]) =>
Math.hypot(
(lon - centre.lon) * 111_320 * Math.cos(centre.lat * RAD),
(lat - centre.lat) * 110_540,
),
),
);
assert.ok(farthest < 200, `nothing from the corner (${farthest} m)`);
coverage.clear();
});
test('the old zoom goes as soon as the new zoom has loaded, not after the stale wait', async () => {
const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh();
source.calls[0].resolve(bytes);
await settle();
const [old] = viewer.scene.groundPrimitives.items;
assert.ok(old, 'the z14 lines are drawn');
viewer.view.height = 3000; // zoom out to z13
coverage.refresh();
assert.equal(state.coverage.zoom, 13);
const fresh = source.calls.slice(1);
assert.ok(fresh.length > 0, 'the new zoom is loading');
assert.equal(state.coverage.stale.size, 1, 'the old zoom stays meanwhile');
assert.ok(viewer.scene.groundPrimitives.items.has(old));
for (const call of fresh) {
assert.ok(
viewer.scene.groundPrimitives.items.has(old),
'kept while a new tile is still loading',
);
call.resolve(new Uint8Array());
await settle();
}
// The last new tile is in: the old lines go now, long before the wait.
assert.equal(state.coverage.pending.size, 0);
assert.equal(state.coverage.stale.size, 0);
assert.equal(viewer.scene.groundPrimitives.items.has(old), false);
assert.equal(state.coverage.staleTimer, null, 'the stale wait is cancelled');
coverage.clear();
});
test('a rate limit holds every refresh inside its wait, not only the first', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, source, coverage } = setup();
coverage.refresh();
source.calls[0].reject(
Object.assign(new Error('rate limited'), { retryAfterSec: 30 }),
);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
t.mock.timers.tick(1000);
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 1, 'held 1 s into a 30 s wait');
t.mock.timers.tick(28_000);
viewer.view.lon += 0.05;
coverage.refresh();
assert.equal(source.calls.length, 1, 'held 29 s into a 30 s wait');
t.mock.timers.tick(1000);
assert.ok(source.calls.length > 1, 'asked again once the wait is over');
coverage.clear();
coverage.resetErrors();
});
test('a line across the date line from the camera is in mesh range', () => {
// A z14 tile's width of lines just past -180, the camera 111 m short of it.
const camera = { lon: 179.999, lat: 0 };
const east = { west: -180, east: -179.978, south: -0.001, north: 0.001 };
assert.equal(meshBoxInRange(east, camera), true);
// The mirror case, from the other side.
const west = { west: 179.978, east: 180, south: -0.001, north: 0.001 };
assert.equal(meshBoxInRange(west, { lon: -179.999, lat: 0 }), true);
// ~2.2 km away across the line: out of range either way.
const far = { west: -179.99, east: -179.97, south: -0.001, north: 0.001 };
assert.equal(meshBoxInRange(far, camera), false);
});
test('a tile that fails before the terrain table loads is reported at once, never unhandled', async () => {
const { source, state, coverage, tileKey } = setup();
let terrainLoaded;
state.coverage.terrainReady = new Promise((resolve) => {
terrainLoaded = resolve;
});
const unhandled = [];
const onUnhandled = (reason) => unhandled.push(reason);
process.on('unhandledRejection', onUnhandled);
try {
coverage.refresh();
source.calls
.find((call) => call.key === tileKey)
.reject(new Error('Mapillary tiles HTTP 500'));
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(state.coverage.lastError, 'Mapillary tiles HTTP 500');
assert.deepEqual(unhandled, []);
} finally {
terrainLoaded();
await settle();
process.off('unhandledRejection', onUnhandled);
}
});
test('lines a partial terrain lookup left draped are cast once the terrain recovers', async () => {
// Two lines: the west one's heights arrive, the east one's fail at first.
let eastUp = false;
let prepares = 0;
const groundCaster = {
prepareLines: async () => {
prepares++;
return eastUp;
},
castLine: (coords) =>
coords[0][0] < centreLon || eastUp
? coords.flatMap(([lon, lat]) => [lon, lat, 30])
: null,
};
const { state, source, coverage, centre } = setup({
surface: 'terrain',
groundCaster,
});
const centreLon = centre.lon;
const line = (from, to) => [
[
[from, centre.lat],
[to, centre.lat],
],
];
coverage.refresh();
source.calls[0].resolve(
encodeCoverageTile(centre.tile, {
sequences: [
{ id: 'west', parts: line(centre.lon - 0.002, centre.lon - 0.001) },
{ id: 'east', parts: line(centre.lon + 0.001, centre.lon + 0.002) },
],
}),
);
await settle();
await settle();
const [entry] = state.coverage.tiles.values();
assert.equal(prepares, 1);
assert.deepEqual([entry.castLines, entry.drapedLines], [1, 1], 'partial');
await settle();
assert.equal(prepares, 1, 'no retry loop while the lookup fails');
// A refresh while still failing asks again but rebuilds nothing.
const drawn = entry.primitives;
coverage.refresh();
await settle();
assert.equal(prepares, 2);
assert.equal(entry.primitives, drawn, 'no rebuild without new heights');
eastUp = true; // the terrain proxy recovers
coverage.refresh();
await settle();
assert.equal(prepares, 3, 'the next refresh tries the draped line again');
assert.deepEqual([entry.castLines, entry.drapedLines], [2, 0], 'all cast');
coverage.clear();
});
@@ -3,7 +3,7 @@ import { PbfWriter } from 'pbf';
export const zigzag = (value) => (value << 1) ^ (value >> 31);
/** Tile-local [x, y] (0..extent) of a lon/lat inside tile (x, y, z). */
export function lonLatToTileLocal([lon, lat], { x, y, z }, extent = 4096) {
function lonLatToTileLocal([lon, lat], { x, y, z }, extent = 4096) {
const n = 2 ** z;
const rad = (lat * Math.PI) / 180;
const tx = ((lon + 180) / 360) * n;
@@ -86,11 +86,11 @@ export function lineGeometry(parts) {
* Encode a `mly1_public`-shaped tile from lon/lat features, for tests and
* the hermetic QA gate.
* @param {{x: number, y: number, z: number}} tile
* @param {{sequences?: Array<{id: string, capturedAt?: number, isPano?: boolean, parts: Array<Array<[number, number]>>}>, overview?: Array<{id: string, lon: number, lat: number, capturedAt?: number, isPano?: boolean}>, extent?: number}} [content]
* @param {{sequences?: Array<{id: string, capturedAt?: number, isPano?: boolean, parts: Array<Array<[number, number]>>}>, extent?: number}} [content]
*/
export function encodeCoverageTile(
tile,
{ sequences = [], overview = [], extent = 4096 } = {},
{ sequences = [], extent = 4096 } = {},
) {
const pbf = new PbfWriter();
if (sequences.length) {
@@ -111,17 +111,5 @@ export function encodeCoverageTile(
);
pbf.writeMessage(3, (_, out) => layer.write(out), null);
}
if (overview.length) {
const layer = layerWriter('overview', extent);
for (const point of overview) {
const [px, py] = lonLatToTileLocal([point.lon, point.lat], tile, extent);
layer.add(1, [9, zigzag(px), zigzag(py)], {
id: point.id,
captured_at: point.capturedAt ?? Date.now(),
is_pano: point.isPano === true,
});
}
pbf.writeMessage(3, (_, out) => layer.write(out), null);
}
return new Uint8Array(pbf.finish());
}
@@ -3,13 +3,13 @@ import { VectorTile } from '@mapbox/vector-tile';
import { tileLocalToLonLat } from '../../tileMath.js';
/**
* Decode a `mly1_public` coverage tile into sequences and (z0–5) overview
* points. The z14 `image` layer, which can hold >150k points, is skipped.
* Decode a `mly1_public` coverage tile (z11–14) into sequences. The z14
* `image` layer, which can hold >150k points, is skipped.
* @param {Uint8Array} bytes
* @param {{x:number,y:number,z:number}} address
*/
export function decodeCoverageTile(bytes, address) {
const result = { sequences: [], overview: [] };
const result = { sequences: [] };
if (!bytes || !bytes.length) return result;
const tile = new VectorTile(new PbfReader(bytes));
const { x, y, z } = address;
@@ -30,41 +30,11 @@ export function decodeCoverageTile(bytes, address) {
if (!parts.length) continue;
result.sequences.push({
id: String(props.id ?? feature.id ?? `${x}/${y}/${i}`),
imageId: props.image_id != null ? String(props.image_id) : null,
capturedAt: Number(props.captured_at) || 0,
isPano: props.is_pano === true,
onFoot: props.foot === true,
quality: Number.isFinite(props.quality_score)
? props.quality_score
: null,
parts,
});
}
}
const overviewLayer = tile.layers.overview;
if (overviewLayer) {
for (let i = 0; i < overviewLayer.length; i++) {
const feature = overviewLayer.feature(i);
const props = feature.properties || {};
const point = feature.loadGeometry()?.[0]?.[0];
if (!point) continue;
const [lon, lat] = tileLocalToLonLat(
point.x,
point.y,
overviewLayer.extent,
x,
y,
z,
);
result.overview.push({
id: String(props.id ?? feature.id ?? i),
lon,
lat,
capturedAt: Number(props.captured_at) || 0,
isPano: props.is_pano === true,
sequenceId: props.sequence_id ? String(props.sequence_id) : null,
});
}
}
return result;
}
@@ -17,7 +17,10 @@ function linePart(points) {
return out;
}
/** A minimal mly1_public-shaped tile: one sequence line and one overview point. */
/**
* A minimal mly1_public-shaped tile: one sequence line, plus one point in the
* z14 `image` layer the decoder skips.
*/
function sampleTile({ geometry = null } = {}) {
const writer = new PbfWriter();
writer.writeMessage(
@@ -70,7 +73,7 @@ function sampleTile({ geometry = null } = {}) {
3,
(_, layer) => {
layer.writeVarintField(15, 2);
layer.writeStringField(1, 'overview');
layer.writeStringField(1, 'image');
layer.writeVarintField(5, 4096);
layer.writeStringField(3, 'is_pano');
layer.writeMessage(
@@ -94,7 +97,7 @@ function sampleTile({ geometry = null } = {}) {
return new Uint8Array(writer.finish());
}
test('sequences and overview points decode into lon/lat records', () => {
test('sequences decode into lon/lat records; image points are skipped', () => {
const decoded = decodeCoverageTile(sampleTile(), { x: 0, y: 0, z: 1 });
assert.equal(decoded.sequences.length, 1);
const [sequence] = decoded.sequences;
@@ -109,19 +112,13 @@ test('sequences and overview points decode into lon/lat records', () => {
'west half of tile 0/0 at z1',
);
assert.ok(lat0 > 0 && lat1 < lat0, 'northern hemisphere, moving south');
assert.equal(decoded.overview.length, 1);
assert.equal(decoded.overview[0].id, '3');
assert.equal(decoded.overview[0].isPano, false);
assert.ok(Math.abs(decoded.overview[0].lon - -90) < 1e-6, 'tile centre');
assert.deepEqual(Object.keys(decoded), ['sequences']);
});
test('an empty tile decodes to empty lists', () => {
assert.deepEqual(
decodeCoverageTile(new Uint8Array(0), { x: 0, y: 0, z: 0 }),
{
sequences: [],
overview: [],
},
{ sequences: [] },
);
});
@@ -1,70 +1,67 @@
import { createCoverage, sequenceIdFromPick } from './coverage.js';
import { createSequences } from './sequences.js';
import {
createLineCoverage,
freshCoverage,
sequenceIdFromPick,
} from '../../lineCoverage.js';
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
import { coverageZoomForHeight } from '../../tileMath.js';
import { decodeCoverageTile } from './decode.js';
import { createMapillaryViewer } from './viewer.js';
import { passesImageryFilter } from '../../filter.js';
import { metresBetween } from '../../view.js';
import {
COLORS,
COVERAGE_TUNING,
KEY_REJECTED_MESSAGE,
MAPILLARY_CREDIT_HTML,
MAPILLARY_KEY_ID,
MAPILLARY_LABEL,
MAPILLARY_NAME,
MAPILLARY_PROVIDER_ID,
MAPILLARY_SOURCE_METHODS,
NEAREST_LIMIT,
NEAREST_RADIUS_M,
PICK_PREFIX,
RATE_LIMITED_MESSAGE,
} from './policy.js';
/** Per-provider mutable state, created once per `create()`. */
/** Normalize a graph image record into the shape the cones use. */
function normalizeSequenceImage(record) {
const coordinates = record?.geometry?.coordinates;
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
return {
id: String(record.id),
lon: Number(coordinates[0]),
lat: Number(coordinates[1]),
compassAngle: Number(record.compass_angle) || 0,
capturedAt: Number(record.captured_at) || 0,
isPano: record.is_pano === true,
};
}
/** Per-provider mutable state; coverage and cones share it. */
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,
},
coverage: freshCoverage(),
sequence: {
selectedId: null,
images: [],
/** Recent sequences' thinned images, by sequence id. */
cache: new Map(),
collection: null,
loading: false,
/** Why the selected sequence's images could not load, until it goes. */
error: null,
abort: null,
},
};
}
/**
* Mapillary Street Level provider: tile coverage, Graph API image cones and
* the MapillaryJS viewer. `source` is ./source.js or a stand-in.
* The Mapillary provider: coverage lines from vector tiles, image cones from
* the Graph API and the MapillaryJS viewer.
* @param {{source: object, photoViewer?: object}} options `source` is
* source.js or a stand-in; `photoViewer` stands in for the viewer adapter.
* @returns {import('../../registry.js').StreetLevelProvider}
*/
export function createMapillaryProvider({ source }) {
export function createMapillaryProvider({ source, photoViewer = null }) {
if (
!MAPILLARY_SOURCE_METHODS.every(
(method) => typeof source?.[method] === 'function',
@@ -84,86 +81,97 @@ export function createMapillaryProvider({ source }) {
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,
parts.coverage = createLineCoverage({
state,
fetchTile: async (tile, { signal }) =>
decodeCoverageTile(
await source.getTile('coverage', tile.z, tile.x, tile.y, {
signal,
}),
tile,
),
zoomFor: coverageZoomForHeight,
colors: COLORS,
pickPrefix: PICK_PREFIX.sequence,
renderKey: 'mapillary-coverage',
tuning: COVERAGE_TUNING,
messages: {
keyRejected: KEY_REJECTED_MESSAGE,
rateLimited: RATE_LIMITED_MESSAGE,
},
});
parts.sequences = createSequenceCones({
state,
async loadImages(sequenceId, { signal }) {
const records = await source.getSequenceImages(sequenceId, {
signal,
});
return thinImages(
records
.map(normalizeSequenceImage)
.filter(Boolean)
.sort((a, b) => a.capturedAt - b.capturedAt),
);
},
colors: COLORS,
pickPrefix: PICK_PREFIX.image,
spriteId: 'street-level:mapillary-cones',
renderKey: 'mapillary-sequence',
onHighlight: (sequenceId) => parts.coverage.highlight(sequenceId),
});
return {
async status() {
/** Coverage waits for the answer: no tile requests without a key. */
async status({ signal } = {}) {
let configured;
try {
state.status = await source.getStatus();
state.keyRequired = state.status?.configured !== true;
configured =
(await source.getStatus({ signal }))?.configured === true;
} catch {
state.status = null;
state.keyRequired = !source.hasToken();
configured = source.hasToken?.() === true;
}
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 };
parts.coverage.setKeyStatus(configured);
return { configured };
},
init(cesiumViewer) {
state.viewer = cesiumViewer;
parts.sequences.ensureCollections(cesiumViewer);
init(viewer) {
state.viewer = viewer;
parts.sequences.ensureCollections(viewer);
parts.sequences.setVisible(false);
},
activate(cesiumViewer) {
state.viewer = cesiumViewer;
activate(viewer) {
state.viewer = viewer;
parts.sequences.setVisible(true);
parts.coverage.attach(cesiumViewer);
parts.coverage.attach(viewer);
},
/** Clear lines and cones, forget refusals. */
deactivate() {
parts.coverage.detach();
parts.coverage.clear();
parts.coverage.resetErrors();
parts.coverage.unblock();
parts.sequences.clearSelection();
parts.sequences.setVisible(false);
},
destroy(cesiumViewer) {
destroy(viewer) {
parts.coverage.detach();
parts.coverage.clear();
parts.coverage.resetErrors();
parts.sequences.destroy(cesiumViewer);
parts.sequences.destroy(viewer);
},
refreshCoverage: () => parts.coverage.refresh(),
/** The filter changed: redraw. Each draw reads it from the context. */
setFilter() {
parts.coverage.rebuild();
parts.sequences.rerender();
},
/** Core surface mode changed: redraw lines and cones draped or cast. */
setSurface() {
parts.coverage.setSurface();
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,
};
},
coverageStats: () => parts.coverage.stats(),
/** A line selects its sequence; a cone opens its image. */
handlePick(pickId) {
const sequenceId = sequenceIdFromPick(pickId);
const sequenceId = sequenceIdFromPick(pickId, PICK_PREFIX.sequence);
if (sequenceId) {
parts.sequences.select(sequenceId);
return true;
@@ -181,39 +189,12 @@ export function createMapillaryProvider({ source }) {
selectedId: state.sequence.selectedId,
images: state.sequence.images.length,
loading: state.sequence.loading,
error: state.sequence.error,
}),
/**
* 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,
viewer:
photoViewer ||
createMapillaryViewer({ source, render: context.services?.render }),
};
},
});
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { mapillaryImageUrl } from './policy.js';
import { sequenceIdFromPick } from '../../lineCoverage.js';
import { thinImages } from '../../sequenceCones.js';
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('a sequence line pick names its sequence; other picks do not', () => {
assert.equal(sequenceIdFromPick('mly:seq:abc', 'mly:seq:'), 'abc');
assert.equal(sequenceIdFromPick('mly:img:abc', 'mly:seq:'), null);
assert.equal(sequenceIdFromPick(null, 'mly:seq:'), null);
});
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'],
);
});
@@ -4,8 +4,6 @@ import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
// i18n import: the messages stay English data, translated where shown.
const msg = (text) => text;
export { NEAREST_RADIUS_M } from '../../policy.js';
/** Identity and tuning for the Mapillary street-level provider. */
export const MAPILLARY_PROVIDER_ID = 'mapillary';
export const MAPILLARY_NAME = 'Mapillary';
@@ -13,12 +11,11 @@ export const MAPILLARY_LABEL = 'MAPILLARY';
export const MAPILLARY_KEY_ID = 'mapillary';
export const MAPILLARY_GRAPH_HOST = 'https://graph.mapillary.com';
/** Methods a source (./source.js or a stand-in) must have for the provider. */
/** What the provider needs from its source (source.js or a stand-in). */
export const MAPILLARY_SOURCE_METHODS = Object.freeze([
'getStatus',
'getTile',
'getSequenceImages',
'nearestImages',
]);
/** Stable id prefixes for picked primitives; every one starts with `mly:`. */
@@ -28,17 +25,12 @@ export const PICK_PREFIX = Object.freeze({
image: 'mly:img:',
});
/** Mapillary green for lines, points and cones; GEV cyan for selection. */
/** Mapillary green for lines and cones; GEV cyan for the selection. */
export const COLORS = Object.freeze({
coverage: PROVIDER_COLORS.mapillary,
selected: SHARED_COLORS.selected,
});
/** Camera-driven coverage refresh. */
export const COVERAGE_MOVE_DEBOUNCE_MS = 320;
export const COVERAGE_MAX_TILES = 9;
/** Overview (z0–5) coverage points seen from orbit. */
export const COVERAGE_OVERVIEW_MAX_TILES = 16;
/** Shown when Mapillary refuses the server's token (401/403). */
export const KEY_REJECTED_MESSAGE = msg(
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings',
@@ -47,24 +39,25 @@ export const KEY_REJECTED_MESSAGE = msg(
export const RATE_LIMITED_MESSAGE = msg(
'Coverage requests are being rate-limited — they resume on their own',
);
/**
* Street-zoom coverage reaches this far from the camera: camera height times
* the factor, never below the floor, even when a tilted view sees further.
*/
export const SEQUENCE_VIEW_RANGE_MIN_M = 2_500;
export const SEQUENCE_VIEW_RANGE_PER_HEIGHT = 10;
/** Ground always covered around the camera at street zooms, in metres. */
export const SEQUENCE_VIEW_NEAR_M = 1_000;
export const COVERAGE_OVERVIEW_POINT_PX = 2.5;
export const COVERAGE_MAX_SEQUENCES = 6000;
export const COVERAGE_LINE_WIDTH_PX = 2.5;
/** Coverage tuning (see LineCoverageTuning in lineCoverage.js). */
export const COVERAGE_TUNING = Object.freeze({
maxTiles: 9,
maxSequences: 6000,
lineWidthPx: 2.5,
moveDebounceMs: 320,
/**
* Street-zoom coverage reaches this far from the camera: camera height
* times the factor, never below the floor, even when a tilted view sees
* further.
*/
viewRangeMinM: 2_500,
viewRangePerHeight: 10,
});
/** Per-sequence image cones after a sequence is selected. */
export const SEQUENCE_IMAGES_LIMIT = 2000;
/** Images asked for in a nearest-image search (radius: NEAREST_RADIUS_M). */
export const NEAREST_LIMIT = 8;
/** On-globe credit: Mapillary imagery is CC BY-SA 4.0 and needs attribution. */
export const MAPILLARY_CREDIT_HTML =
'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, CC BY-SA 4.0';
@@ -76,8 +69,6 @@ export function mapillaryImageUrl(imageId) {
return `https://www.mapillary.com/app/?pKey=${encodeURIComponent(id)}&focus=photo`;
}
/** Fields requested from the graph API. */
export const IMAGE_FIELDS =
'id,captured_at,compass_angle,computed_compass_angle,geometry,computed_geometry,computed_altitude,is_pano,sequence,thumb_256_url,creator,quality_score';
/** Fields requested for a sequence's images. */
export const SEQUENCE_IMAGE_FIELDS =
'id,captured_at,compass_angle,geometry,is_pano,computed_altitude';
'id,captured_at,compass_angle,geometry,is_pano';
@@ -1,44 +1,30 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createMapillaryProvider } from './index.js';
import { mapillaryImageUrl } from './policy.js';
import { sequenceIdFromPick } from './coverage.js';
import { thinImages } from './sequences.js';
import { sequenceIdFromPick } from '../../lineCoverage.js';
import { thinImages } from '../../sequenceCones.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,
};
}
import {
fakeMapillarySource,
rayCamera,
} from '../../../../testSupport/streetLevelFakes.mjs';
function fakeContext(filter = { pano: 'all', sinceMs: null }) {
const opened = [];
return {
opened,
services: {},
getFilter: typeof filter === 'function' ? filter : () => filter,
getFilter: () => filter,
isActive: () => true,
notify: () => {},
actions: {
openImage: (id) => {
opened.push(id);
},
reportError: () => {},
},
actions: { openImage: (id) => opened.push(id) },
};
}
test('the definition satisfies the Street Level provider contract', () => {
const def = createMapillaryProvider({ source: fakeSource() });
const def = createMapillaryProvider({ source: fakeMapillarySource() });
assert.doesNotThrow(() => validateProviders([def]));
assert.equal(def.id, 'mapillary');
assert.equal(def.label, 'MAPILLARY');
@@ -46,9 +32,7 @@ test('the definition satisfies the Street Level provider contract', () => {
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.equal(def.colors.coverage, '#05cb63');
assert.throws(() => createMapillaryProvider({ source: {} }), /source/);
});
@@ -62,9 +46,9 @@ test('image deep links match the mapillary.com share format', () => {
test('picks route sequences to selection and images to the core opener', () => {
const context = fakeContext();
const instance = createMapillaryProvider({ source: fakeSource() }).create(
context,
);
const instance = createMapillaryProvider({
source: fakeMapillarySource(),
}).create(context);
assert.equal(instance.handlePick('mly:img:77'), true);
assert.deepEqual(context.opened, ['77']);
assert.equal(instance.handlePick('cctv:1'), false);
@@ -72,99 +56,14 @@ test('picks route sequences to selection and images to the core opener', () => {
selectedId: null,
images: 0,
loading: false,
error: null,
});
});
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('a line pick names its sequence; anything else is not a sequence', () => {
assert.equal(sequenceIdFromPick('mly:seq:abc', 'mly:seq:'), 'abc');
assert.equal(sequenceIdFromPick('mly:img:abc', 'mly:seq:'), null);
assert.equal(sequenceIdFromPick(null, 'mly:seq:'), null);
});
test('cone thinning across the date line drops images metres apart', () => {
@@ -220,19 +119,24 @@ function streetViewer() {
};
}
test('coverage asks for no tiles before the key status is known', async () => {
test('coverage asks for no tiles before the key status is known, and none without a key', async (t) => {
// Skip Cesium's one-time terrain table download for draped lines.
t.mock.method(
Cesium.GroundPolylinePrimitive,
'initializeTerrainHeights',
async () => {},
);
for (const configured of [false, true]) {
let answer;
const tiles = [];
const source = {
...fakeSource(),
const source = fakeMapillarySource({
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);
@@ -240,7 +144,7 @@ test('coverage asks for no tiles before the key status is known', async () => {
instance.activate(viewer);
assert.equal(tiles.length, 0, 'nothing before the status answers');
answer({ configured });
await status;
assert.deepEqual(await status, { configured });
if (configured)
assert.ok(tiles.length > 0, 'coverage loads as soon as the key is known');
else {
@@ -250,3 +154,19 @@ test('coverage asks for no tiles before the key status is known', async () => {
instance.destroy(viewer);
}
});
test('a status the server never answers falls back to the bundled token once the core gives up', async () => {
const source = fakeMapillarySource({
hasToken: () => false,
getStatus: ({ signal } = {}) =>
new Promise((_, reject) =>
signal?.addEventListener('abort', () => reject(signal.reason)),
),
});
const instance = createMapillaryProvider({ source }).create(fakeContext());
const controller = new AbortController();
const status = instance.status({ signal: controller.signal });
controller.abort(new Error('timed out'));
assert.deepEqual(await status, { configured: false });
assert.equal(instance.coverageStats().keyRequired, true);
});
@@ -1,43 +0,0 @@
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
import { COLORS, PICK_PREFIX } from './policy.js';
export { thinImages } from '../../sequenceCones.js';
/** Normalize a graph image record into the shape the cones use. */
export function normalizeSequenceImage(record) {
const coordinates = record?.geometry?.coordinates;
if (!Array.isArray(coordinates) || coordinates.length < 2) return null;
return {
id: String(record.id),
lon: Number(coordinates[0]),
lat: Number(coordinates[1]),
compassAngle: Number(record.compass_angle) || 0,
capturedAt: Number(record.captured_at) || 0,
isPano: record.is_pano === true,
altitude: Number.isFinite(record.computed_altitude)
? record.computed_altitude
: null,
};
}
/** Image cones for the selected Mapillary sequence, its line highlighted. */
export function createSequences({ state, source, parts }) {
return createSequenceCones({
state,
async loadImages(sequenceId, { signal }) {
const records = await source.getSequenceImages(sequenceId, { signal });
return thinImages(
records
.map(normalizeSequenceImage)
.filter(Boolean)
.sort((a, b) => a.capturedAt - b.capturedAt),
);
},
colors: COLORS,
pickPrefix: PICK_PREFIX.image,
spriteId: 'street-level:mapillary-cones',
renderKey: 'mapillary-sequence',
onHighlight: (sequenceId, selected) =>
parts.coverage.recolorSequence(sequenceId, selected),
});
}
@@ -1,325 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { MESH_CELL_DEG } from '../../meshSampler.js';
import { createSequences } from './sequences.js';
// The cone glyphs draw on a canvas; Node has none, so any drawing is a no-op.
const noop = () => {};
const context2d = new Proxy({}, { get: () => noop, set: () => true });
globalThis.document ??= {
createElement: () => ({ getContext: () => context2d }),
};
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/** A billboard collection that remembers the cones on the globe. */
function cones() {
const items = [];
return {
items,
show: true,
add(options) {
items.push(options);
return options;
},
removeAll() {
items.length = 0;
},
};
}
/** A source whose sequence lookups the test answers, one call at a time. */
function deferredSource() {
const calls = [];
return {
calls,
getSequenceImages(sequenceId, { signal } = {}) {
return new Promise((resolve, reject) => {
calls.push({ sequenceId, signal, resolve, reject });
});
},
/** The latest lookup for a sequence. */
last(sequenceId) {
return calls.filter((call) => call.sequenceId === sequenceId).at(-1);
},
};
}
/** Graph image records for a sequence, a few metres apart. */
function records(sequenceId, count = 2) {
return Array.from({ length: count }, (_, i) => ({
id: `${sequenceId}-${i}`,
geometry: { coordinates: [-121.49 + i * 0.001, 38.58] },
compass_angle: 90,
captured_at: 1_700_000_000_000 + i,
}));
}
function setup() {
const source = deferredSource();
const colours = [];
const errors = [];
const state = {
services: {},
viewer: {},
context: {
notify() {},
getFilter: () => ({ pano: 'all', sinceMs: null }),
getSurface: () => 'draped',
groundCaster: null,
meshSampler: null,
actions: { reportError: (message) => errors.push(message) },
},
sequence: {
selectedId: null,
images: [],
cache: new Map(),
collection: cones(),
loading: false,
abort: null,
},
};
const parts = {
coverage: {
recolorSequence: (id, selected) => colours.push([id, selected]),
},
};
const sequences = createSequences({ state, source, parts });
const drawn = () =>
state.sequence.collection.items.map((cone) => cone.id.split(':').at(-1));
return { state, source, sequences, colours, errors, drawn };
}
test('a failed sequence load leaves no stale cones and can be retried', async () => {
const { state, source, sequences, colours, errors, drawn } = setup();
sequences.select('A');
source.last('A').resolve(records('A'));
await settle();
assert.deepEqual(drawn(), ['A-0', 'A-1']);
sequences.select('B');
assert.deepEqual(drawn(), [], "A's cones go while B loads");
source.last('B').reject(new Error('Sequence images unavailable'));
await settle();
assert.deepEqual(drawn(), [], 'nothing of A is left to click');
assert.equal(state.sequence.loading, false);
assert.equal(state.sequence.selectedId, null, 'B is not left highlighted');
assert.deepEqual(colours.at(-1), ['B', false]);
assert.deepEqual(errors, [null, 'Sequence images unavailable']);
// Clicking B again asks again.
sequences.select('B');
assert.equal(source.calls.filter((c) => c.sequenceId === 'B').length, 2);
source.last('B').resolve(records('B'));
await settle();
assert.deepEqual(drawn(), ['B-0', 'B-1']);
assert.equal(state.sequence.selectedId, 'B');
assert.equal(errors.at(-1), null, 'the error is cleared once B loads');
});
test('a late answer for a superseded sequence neither draws nor is cached', async () => {
const { state, source, sequences, drawn } = setup();
sequences.select('A');
sequences.select('B');
assert.equal(source.last('A').signal.aborted, true, 'A was cancelled');
source.last('B').resolve(records('B'));
await settle();
source.last('A').resolve(records('A'));
await settle();
assert.deepEqual(drawn(), ['B-0', 'B-1']);
assert.equal(state.sequence.selectedId, 'B');
assert.equal(state.sequence.cache.has('A'), false);
assert.equal(state.sequence.loading, false);
});
test('a second click on a loading sequence does not start another lookup', async () => {
const { source, sequences } = setup();
sequences.select('A');
sequences.select('A');
assert.equal(source.calls.length, 1);
assert.equal(source.calls[0].signal.aborted, false);
});
test('a cached sequence is re-selected without a lookup', async () => {
const { state, source, sequences, drawn } = setup();
sequences.select('A');
source.last('A').resolve(records('A'));
await settle();
sequences.select('B');
source.last('B').resolve(records('B'));
await settle();
sequences.select('A');
assert.equal(source.calls.length, 2, 'A came from the cache');
assert.deepEqual(drawn(), ['A-0', 'A-1'], 'drawn at once');
assert.equal(state.sequence.loading, false);
assert.equal(state.sequence.selectedId, 'A');
});
test('clearSelection cancels the lookup and uncolours the sequence', async () => {
const { state, source, sequences, colours, errors, drawn } = setup();
sequences.select('A');
sequences.clearSelection();
assert.equal(source.last('A').signal.aborted, true);
assert.deepEqual(colours, [
['A', true],
['A', false],
]);
assert.equal(state.sequence.selectedId, null);
assert.equal(state.sequence.loading, false);
assert.equal(errors.at(-1), null, 'an error about it goes too');
source.last('A').resolve(records('A'));
await settle();
assert.deepEqual(drawn(), [], 'the late answer draws nothing');
});
/**
* Terrain mode with bare earth at 10 m, a mesh sampler the test drives, and
* cones that log every change made to them.
*/
function terrainSetup() {
const context = setup();
const { state } = context;
const mesh = new Map();
const cell = (lon, lat) =>
`${Math.round(lon / MESH_CELL_DEG)},${Math.round(lat / MESH_CELL_DEG)}`;
let announce = null;
state.context.getSurface = () => 'terrain';
state.context.groundCaster = {
groundAt: () => 10,
prepare: async () => true,
};
state.context.meshSampler = {
request() {},
meshAt: (lon, lat) => mesh.get(cell(lon, lat)),
onSampled: (listener) => {
announce = listener;
return () => (announce = null);
},
};
const changes = [];
const items = [];
state.sequence.collection = {
items,
show: true,
add(options) {
changes.push(['add', options.id]);
const cone = new Proxy(options, {
set(target, key, value) {
changes.push([key, target.id]);
target[key] = value;
return true;
},
});
items.push(cone);
return cone;
},
removeAll() {
changes.push(['removeAll']);
items.length = 0;
},
};
// Created again, so it listens to this sampler.
const sequences = createSequences({
state,
source: context.source,
parts: { coverage: { recolorSequence() {} } },
});
/** Sample the mesh under a point, then announce it as the sampler does. */
function sample(lon, lat, height) {
const centre = [
Math.round(lon / MESH_CELL_DEG) * MESH_CELL_DEG,
Math.round(lat / MESH_CELL_DEG) * MESH_CELL_DEG,
];
mesh.set(cell(lon, lat), height);
announce([centre]);
}
const heightOf = (cone) =>
Cesium.Cartographic.fromCartesian(cone.position).height;
return { ...context, sequences, changes, items, sample, heightOf };
}
test('a mesh sample away from the selected sequence changes no cone', async () => {
const { source, sequences, changes, items, sample } = terrainSetup();
sequences.select('A');
source.last('A').resolve(records('A', 3));
await settle();
assert.equal(items.length, 3);
changes.length = 0;
sample(-121.3, 38.7, 12); // a street elsewhere in the tile
assert.deepEqual(changes, [], 'nothing rebuilt or moved');
});
test('a mesh sample under a cone moves that cone in place, and no other', async () => {
const { source, sequences, changes, items, sample, heightOf } =
terrainSetup();
sequences.select('A');
source.last('A').resolve(records('A', 3));
await settle();
const before = items.map(heightOf);
changes.length = 0;
// The road under the second image is 2.5 m above the bare earth.
sample(-121.489, 38.58, 12.5);
assert.ok(
changes.every(([, id]) => id === 'mly:img:A-1'),
`only A-1 changed: ${JSON.stringify(changes)}`,
);
assert.ok(
changes.some(([key]) => key === 'position'),
'A-1 moved',
);
assert.equal(
changes.some(([key]) => key === 'removeAll' || key === 'add'),
false,
'the collection is not rebuilt',
);
const after = items.map(heightOf);
assert.ok(
Math.abs(after[1] - 13.5) < 0.01,
`A-1 stands on the road (${after[1]})`,
);
assert.deepEqual([after[0], after[2]], [before[0], before[2]]);
});
test('cones behind the globe are hidden, and show again from the other side', async () => {
const { state, source, sequences } = setup();
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
const preRender = new Set();
state.viewer = {
camera: {
get positionWC() {
return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height);
},
},
scene: {
preRender: {
addEventListener(listener) {
preRender.add(listener);
return () => preRender.delete(listener);
},
},
},
};
sequences.select('A');
// One image under the camera, one near its antipode.
source.last('A').resolve([
...records('A', 1),
{
id: 'A-far',
geometry: { coordinates: [58.51, -38.58] },
captured_at: 1_800_000_000_000,
},
]);
await settle();
const [near, far] = state.sequence.collection.items;
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.51;
view.lat = -38.58;
for (const listener of [...preRender]) listener();
assert.equal(near.show, false);
assert.equal(far.show, true);
sequences.clearSelection();
assert.equal(preRender.size, 0, 'the cull listener is gone');
});
@@ -1,8 +1,5 @@
import {
IMAGE_FIELDS,
MAPILLARY_GRAPH_HOST,
NEAREST_LIMIT,
NEAREST_RADIUS_M,
SEQUENCE_IMAGE_FIELDS,
SEQUENCE_IMAGES_LIMIT,
} from './policy.js';
@@ -126,23 +123,5 @@ export function createMapillarySource({
);
return Array.isArray(payload?.data) ? payload.data : [];
},
async nearestImages(
{ lat, lon, radius = NEAREST_RADIUS_M, limit = NEAREST_LIMIT },
{ signal } = {},
) {
const payload = await graph(
'images',
{
lat: String(lat),
lng: String(lon),
radius: String(Math.min(50, Math.max(1, radius))),
limit: String(Math.min(100, Math.max(1, limit))),
fields: IMAGE_FIELDS,
},
{ signal },
);
return Array.isArray(payload?.data) ? payload.data : [];
},
};
}
@@ -62,21 +62,6 @@ test('graph lookups call graph.mapillary.com with the client token and requested
assert.equal(url.searchParams.get('access_token'), 'MLY|1|abc');
assert.equal(url.searchParams.get('sequence_ids'), 'seq-1');
assert.match(url.searchParams.get('fields'), /geometry/);
const nearest = await source.nearestImages({
lat: 38.5,
lon: -121.5,
radius: 500,
limit: 500,
});
assert.equal(nearest.length, 1);
const nearestUrl = new URL(calls[1].url);
assert.equal(
nearestUrl.searchParams.get('radius'),
'50',
'radius is clamped to the API maximum',
);
assert.equal(nearestUrl.searchParams.get('limit'), '100');
});
test('graph calls without a token fail fast as keyRequired without a request', async () => {
@@ -104,12 +89,6 @@ test('graph API errors carry the upstream message and status', async () => {
);
});
test('the source exposes only imagery lookups', () => {
const source = createMapillarySource({ token: 'MLY|1|abc' });
for (const gone of ['queryFeatures', 'plan', 'geocode', 'spriteUrl'])
assert.equal(source[gone], undefined, gone);
});
test('a rejected key and a rate limit are told apart from other tile errors', async () => {
const rejectedSource = createMapillarySource({
token: 'MLY|1|abc',
@@ -1,24 +1,36 @@
import { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js';
import { mapillaryImageUrl } from './policy.js';
/**
* Viewer adapter: lazy-loads MapillaryJS into the core's host and emits a
* provider-neutral pose on every image, view or position change.
* @returns {import('../../registry.js').ViewerAdapter}
* @typedef {object} StreetPose
* @property {string} imageId
* @property {{lon: number, lat: number}} position
* @property {number|null} bearing
* @property {number|null} altitude
* @property {boolean} isPano
* @property {number|null} capturedAt Epoch milliseconds.
* @property {string|null} creator
* @property {string|null} sequenceId
* @property {string} externalUrl Deep link to the image on mapillary.com.
*/
/**
* The panel's photo viewer: loads MapillaryJS on the first `mount` and emits
* a pose on every image, view or position change.
*/
export function createMapillaryViewer({ source, render } = {}) {
let viewer = null;
let Library = null;
let container = null;
/** The viewer being built for a host: a second mount joins it, not a twin. */
let building = null;
let pendingOpen = null;
let prewarming = null;
/** In-flight viewer construction, so pre-warm and open never build two. */
let creating = null;
/** Bumped by `unmount`, so a construction it overtook builds nothing. */
let generation = 0;
let renderMode = 'letterbox';
/** Metadata of the image on screen; pov/position events reuse it. */
let current = null;
const listeners = new Set();
/** The pose listener (the viewer host); one at a time. */
let poseListener = null;
function requestRender() {
render?.governorRequestRender?.('mapillary-viewer');
@@ -43,16 +55,14 @@ export function createMapillaryViewer({ source, render } = {}) {
}
function emit(pose) {
for (const listener of [...listeners]) {
try {
listener(pose);
} catch {
/* listener errors are the core's to log */
}
try {
poseListener?.(pose);
} catch {
/* listener errors are the core's to log */
}
}
/** Read MapillaryJS image metadata into the neutral pose shape. */
/** Read MapillaryJS image metadata into the pose shape. */
function describe(image) {
return {
imageId: String(image.id),
@@ -81,11 +91,9 @@ export function createMapillaryViewer({ source, render } = {}) {
if (image) current = describe(image);
if (!current || !viewer) return;
emit({
providerId: MAPILLARY_PROVIDER_ID,
...current,
position: { lon: lngLat.lng, lat: lngLat.lat },
bearing: Number.isFinite(pov?.bearing) ? pov.bearing : null,
tilt: Number.isFinite(pov?.tilt) ? pov.tilt : 0,
externalUrl: mapillaryImageUrl(current.imageId),
});
requestRender();
@@ -95,16 +103,16 @@ export function createMapillaryViewer({ source, render } = {}) {
}
function ensureViewer(host) {
if (viewer && container === host) return viewer;
if (creating?.container === host) return creating.promise;
const promise = createViewer(host).finally(() => {
if (creating?.promise === promise) creating = null;
if (viewer && container === host) return Promise.resolve(viewer);
if (building?.host === host) return building.promise;
const promise = buildViewer(host).finally(() => {
if (building?.promise === promise) building = null;
});
creating = { container: host, promise };
building = { host, promise };
return promise;
}
async function createViewer(host) {
async function buildViewer(host) {
const built = generation;
destroyViewer();
const { Viewer } = await ensureLibrary();
@@ -178,9 +186,9 @@ export function createMapillaryViewer({ source, render } = {}) {
unmount() {
pendingOpen = null;
// The next mount builds afresh rather than joining a stale build.
// A build still waiting for the library gives up when it arrives.
generation++;
creating = null;
building = null;
destroyViewer();
},
@@ -192,26 +200,6 @@ export function createMapillaryViewer({ source, render } = {}) {
}
},
/** Build the viewer ahead of the first image. Safe to call repeatedly. */
async prewarm(host) {
if (!host) return;
// An in-flight prewarm may give up (layer toggled mid-download): wait
// for it, then build if it did not.
if (prewarming) await prewarming;
if (viewer) return;
const run = (async () => {
try {
await ensureLibrary();
if (!viewer) await ensureViewer(host);
} catch {
/* the real open reports errors */
}
})();
prewarming = run;
await run;
if (prewarming === run) prewarming = null;
},
/** Show the whole image ('letterbox') or crop it to the frame ('fill'). */
setRenderMode(mode) {
renderMode = mode === 'fill' ? 'fill' : 'letterbox';
@@ -224,8 +212,10 @@ export function createMapillaryViewer({ source, render } = {}) {
},
onPose(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
poseListener = listener;
return () => {
if (poseListener === listener) poseListener = null;
};
},
};
}
@@ -3,7 +3,6 @@ import { VectorTile } from '@mapbox/vector-tile';
import { tileLocalToLonLat } from '../../tileMath.js';
import {
DECODE_SLICE_MS,
OVERVIEW_BINS_PER_AXIS,
SIMPLIFY_TOLERANCE_UNITS,
panoramaxTimeMs,
} from './policy.js';
@@ -63,16 +62,14 @@ function dayMs(value) {
* - `sequences` (z7–15): one line per sequence, its parts kept apart so no
* segment is drawn across a gap, with its day and camera type; longest
* first, so a cap on lines drawn drops the shortest.
* - `grid` (z0–6): picture-density cells, as overview points binned to at
* most OVERVIEW_BINS_PER_AXIS² a tile (a z0 tile holds ~70,000 cells). A
* cell has 360° and flat counts but no date.
* - `grid` (z0–6, density cells) is not read: nothing is drawn from orbit.
* - `pictures` (z15) is left for `decodePictures`.
*
* In one go; `decodeCoverageTileSliced` gives the page a turn between
* slices.
* @param {Uint8Array} bytes
* @param {{x: number, y: number, z: number}} address
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>, overview: Array<{id: string, lon: number, lat: number, capturedAt: null, isPano: boolean, hasPano: boolean, hasFlat: boolean, weight: number}>}}
* @returns {{sequences: Array<{id: string, parts: Array<Array<[number, number]>>, capturedAt: number, isPano: boolean}>}}
*/
export function decodeCoverageTile(bytes, address) {
const steps = coverageSteps(bytes, address);
@@ -123,7 +120,7 @@ export async function decodeCoverageTileSliced(
/** The decode, a step a feature (see `decodeCoverageTile`). */
function* coverageSteps(bytes, address) {
const result = { sequences: [], overview: [] };
const result = { sequences: [] };
if (!bytes || !bytes.length) return result;
const tile = new VectorTile(new PbfReader(bytes));
const { x, y, z } = address;
@@ -174,56 +171,6 @@ function* coverageSteps(bytes, address) {
result.sequences = measured.map(({ sequence }) => sequence);
}
const grid = tile.layers.grid;
if (grid) {
const bins = new Map();
const size = grid.extent / OVERVIEW_BINS_PER_AXIS;
for (let i = 0; i < grid.length; i++) {
const feature = grid.feature(i);
const props = feature.properties || {};
const point = feature.loadGeometry()?.[0]?.[0];
if (i % 64 === 63) yield;
if (!point) continue;
const pano = Number(props.nb_360_pictures) || 0;
const flat = Number(props.nb_flat_pictures) || 0;
const weight = Number(props.nb_pictures) || pano + flat;
if (weight <= 0) continue;
const key = `${Math.floor(point.x / size)}:${Math.floor(point.y / size)}`;
const bin = bins.get(key);
if (!bin) {
bins.set(key, { point, weight, pano, flat, top: weight });
continue;
}
// The bin sits on its busiest cell and counts them all.
bin.weight += weight;
bin.pano += pano;
bin.flat += flat;
if (weight > bin.top) {
bin.top = weight;
bin.point = point;
}
}
for (const [key, bin] of bins) {
const [lon, lat] = tileLocalToLonLat(
bin.point.x,
bin.point.y,
grid.extent,
x,
y,
z,
);
result.overview.push({
id: `${z}/${x}/${y}/${key}`,
lon,
lat,
capturedAt: null,
isPano: bin.pano > bin.flat,
hasPano: bin.pano > 0,
hasFlat: bin.flat > 0,
weight: bin.weight,
});
}
}
return result;
}
@@ -6,7 +6,6 @@ import {
decodePictures,
simplifyLine,
} from './decode.js';
import { OVERVIEW_BINS_PER_AXIS } from './policy.js';
import { encodePanoramaxTile } from '../../../../testSupport/panoramaxFixtures.mjs';
import { tileCentre } from '../../../../testSupport/streetLevelFakes.mjs';
@@ -42,8 +41,7 @@ test('sequence lines keep their parts apart, with their day and camera type', ()
},
],
});
const { sequences, overview } = decodeCoverageTile(bytes, tile);
assert.equal(overview.length, 0);
const { sequences } = decodeCoverageTile(bytes, tile);
assert.equal(sequences.length, 2);
const pano = sequences.find((s) => s.id === 'pano-seq');
const flat = sequences.find((s) => s.id === 'flat-seq');
@@ -72,33 +70,13 @@ test('sequence lines keep their parts apart, with their day and camera type', ()
assert.equal(untyped.isPano, null, 'no camera type: neither');
});
test('density cells bin to a bounded number of points that keep the counts and kinds', () => {
test('the density grid is not read: nothing is drawn from orbit', () => {
const tile = { x: 0, y: 0, z: 0 };
// Two cells in one bin (3.75° wide at z0) far enough apart to tell
// which it sits on, one far away, one empty.
const grid = [
{ lon: 0.3, lat: 48.85, pano: 0, flat: 5 },
{ lon: 3.4, lat: 48.85, pano: 30, flat: 0 },
{ lon: -73.9, lat: 40.7, pano: 1, flat: 0 },
{ lon: 10, lat: 10, pano: 0, flat: 0 },
];
const { overview } = decodeCoverageTile(
encodePanoramaxTile(tile, { grid }),
tile,
const grid = [{ lon: 2.35, lat: 48.85, pano: 3, flat: 5 }];
assert.deepEqual(
decodeCoverageTile(encodePanoramaxTile(tile, { grid }), tile),
{ sequences: [] },
);
assert.equal(overview.length, 2, 'Paris cells share a bin; empty cells go');
const paris = overview.find((p) => p.lon > 0);
assert.equal(paris.weight, 35);
assert.equal(paris.hasPano, true);
assert.equal(paris.hasFlat, true);
assert.equal(paris.isPano, true, 'mostly 360°');
assert.equal(paris.capturedAt, null, 'cells are undated');
assert.ok(
near(paris.lon, 3.4, 0.2),
`sits on the busiest cell: ${paris.lon}`,
);
const nyc = overview.find((p) => p.lon < 0);
assert.deepEqual([nyc.hasPano, nyc.hasFlat, nyc.weight], [true, false, 1]);
});
test('lines come longest first, so a cap on lines drawn drops the shortest', () => {
@@ -124,25 +102,6 @@ test('lines come longest first, so a cap on lines drawn drops the shortest', ()
);
});
test('a dense grid tile never draws more than the bins allow', () => {
const tile = { x: 0, y: 0, z: 0 };
const grid = [];
for (let i = 0; i < 300; i++)
for (let j = 0; j < 60; j++)
grid.push({ lon: -170 + i * 1.1, lat: -60 + j * 2 });
const { overview } = decodeCoverageTile(
encodePanoramaxTile(tile, { grid }),
tile,
);
assert.ok(overview.length <= OVERVIEW_BINS_PER_AXIS ** 2);
assert.ok(overview.length > 1000);
assert.equal(
overview.reduce((sum, p) => sum + p.weight, 0),
grid.length,
'every picture is still counted',
);
});
/** A tile with many lines and a dense grid, for the sliced decode. */
function busyTile() {
const { tile, lon, lat } = tileCentre(2.35, 48.85);
@@ -212,7 +171,6 @@ test('an empty tile decodes to nothing', () => {
for (const bytes of [new Uint8Array(0), null, undefined])
assert.deepEqual(decodeCoverageTile(bytes, { x: 0, y: 0, z: 0 }), {
sequences: [],
overview: [],
});
assert.deepEqual(
decodePictures(new Uint8Array(0), { x: 0, y: 0, z: 15 }, 's'),
@@ -1,12 +1,11 @@
import { createLineCoverage, sequenceIdFromPick } from '../../lineCoverage.js';
import {
createLineCoverage,
freshCoverage,
sequenceIdFromPick,
} from '../../lineCoverage.js';
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
import { passesImageryFilter } from '../../filter.js';
import {
lonToTileX,
latToTileY,
overviewZoomForHeight,
tileBounds,
} from '../../tileMath.js';
import { lonToTileX, latToTileY, tileBounds } from '../../tileMath.js';
import { metresBetween } from '../../view.js';
import { createLru, createTileCache } from '../../tileCache.js';
import { decodeCoverageTileSliced, decodePictures } from './decode.js';
@@ -29,7 +28,6 @@ import {
NEAREST_RADIUS_M,
FILTERED_HINT,
NO_COVERAGE_HINT,
OVERVIEW_HINT,
PANORAMAX_CREDIT_HTML,
PANORAMAX_LABEL,
PANORAMAX_NAME,
@@ -39,6 +37,7 @@ import {
PICTURE_TILE_ZOOM,
RATE_LIMITED_MESSAGE,
UNREADABLE_TILE_MESSAGE,
ZOOM_IN_HINT,
normalizeItem,
} from './policy.js';
@@ -59,15 +58,6 @@ async function readable(decode) {
}
}
/**
* A density dot's size: busier places draw bigger (2.5 px for one picture,
* 8 px from a few hundred thousand), so a city does not read like farmland.
*/
export function overviewPointSize(point) {
const weight = Math.max(1, Number(point?.weight) || 1);
return Math.min(8, 2.5 + Math.log10(weight));
}
/** Pictures kept in memory by id (lookups and ‹ › fill it). */
const PHOTO_CACHE_SIZE = 2000;
/** Sequences whose cone centre is remembered. */
@@ -78,44 +68,31 @@ const COVERAGE_TILE_CACHE_SIZE = 16;
const PICTURE_LIST_CACHE_SIZE = 32;
/**
* Sequence lines at z10–14 below 60 km; above, the density grid at z5 and
* coarser.
* Sequence lines at z10–14 below 60 km; above, nothing is drawn (the hint
* says to zoom in).
*/
export function panoramaxZoomFor(heightM) {
if (!Number.isFinite(heightM)) return null;
if (heightM <= COVERAGE_LINES_BELOW_M) {
const zoom =
heightM <= COVERAGE_Z14_BELOW_M
? 14
: heightM <= COVERAGE_Z13_BELOW_M
? 13
: heightM <= COVERAGE_Z12_BELOW_M
? 12
: heightM <= COVERAGE_Z11_BELOW_M
? 11
: 10;
return { zoom, kind: 'sequence' };
}
return {
zoom: Math.min(5, overviewZoomForHeight(heightM) ?? 5),
kind: 'overview',
};
if (!Number.isFinite(heightM) || heightM > COVERAGE_LINES_BELOW_M)
return null;
return heightM <= COVERAGE_Z14_BELOW_M
? 14
: heightM <= COVERAGE_Z13_BELOW_M
? 13
: heightM <= COVERAGE_Z12_BELOW_M
? 12
: heightM <= COVERAGE_Z11_BELOW_M
? 11
: 10;
}
/**
* The imagery filter for lines and grid cells. A cell (undated, with 360°
* and flat counts) stays while it holds pictures of the kind asked for. A
* line is dated by its sequence's first day, so a sequence still growing
* may hide under "since" while its newer pictures can be opened; a line
* with no camera type or no day is kept (a click finds out).
* The imagery filter for lines. A line is dated by its sequence's first
* day, so a sequence still growing may hide under "since" while its newer
* pictures can be opened; a line with no camera type or no day is kept (a
* click finds out).
*/
export function passesCoverage(record, filter) {
if (!filter) return true;
if ('hasPano' in record) {
if (filter.pano === 'pano') return record.hasPano;
if (filter.pano === 'flat') return record.hasFlat;
return true;
}
return passesImageryFilter(record, {
pano: typeof record.isPano === 'boolean' ? filter.pano : 'all',
sinceMs: record.capturedAt ? filter.sinceMs : null,
@@ -159,8 +136,8 @@ const tileKey = ({ x, y, z }) => `${z}/${x}/${y}`;
/**
* Panoramax Street Level provider: the federated, keyless, open
* street-level commons. Coverage from its vector tiles (density points
* from orbit, sequence lines near the ground); a click on a line opens that
* street-level commons. Coverage from its vector tiles (sequence lines
* near the ground, nothing from orbit); a click on a line opens that
* sequence's picture nearest the click, with cones along the sequence
* around it and ‹ › to step through it. Flat pictures show as photos, 360°
* ones on the shared WebGL panorama.
@@ -190,30 +167,13 @@ export function createPanoramaxProvider({
context,
services: context.services,
viewer: null,
keyRequired: false,
keyRejected: false,
statusKnown: true,
coverage: {
zoom: null,
tiles: new Map(),
stale: new Map(),
staleTimer: null,
pending: new Map(),
lastError: null,
holdUntil: 0,
holdTimer: null,
debounceTimer: null,
removeCameraListener: null,
terrainReady: null,
hint: '',
kind: null,
},
coverage: freshCoverage({ keyless: true }),
sequence: {
selectedId: null,
images: [],
cache: new Map(),
collection: null,
loading: false,
error: null,
abort: null,
},
};
@@ -354,9 +314,8 @@ export function createPanoramaxProvider({
pickPrefix: PICK_PREFIX.sequence,
renderKey: 'panoramax-coverage',
tuning: COVERAGE_TUNING,
messages: { rateLimited: RATE_LIMITED_MESSAGE },
messages: { rateLimited: RATE_LIMITED_MESSAGE, tooHigh: ZOOM_IN_HINT },
passes: passesCoverage,
overviewPointSize,
});
const cones = createSequenceCones({
@@ -406,8 +365,7 @@ export function createPanoramaxProvider({
pickPrefix: PICK_PREFIX.image,
spriteId: 'street-level:panoramax-cones',
renderKey: 'panoramax-sequence',
onHighlight: (sequenceId, selected) =>
coverage.recolorSequence(sequenceId, selected),
onHighlight: (sequenceId) => coverage.highlight(sequenceId),
});
/**
@@ -500,7 +458,7 @@ export function createPanoramaxProvider({
deactivate() {
coverage.detach();
coverage.clear();
coverage.resetErrors();
coverage.unblock();
cones.clearSelection();
cones.setVisible(false);
drawn = null;
@@ -513,55 +471,30 @@ export function createPanoramaxProvider({
destroy(cesiumViewer) {
coverage.detach();
coverage.clear();
coverage.resetErrors();
coverage.unblock();
cones.destroy(cesiumViewer);
},
refreshCoverage: () => coverage.refresh(),
/** The filter changed: redraw. Each draw reads it from the context. */
setFilter() {
coverage.rebuild();
cones.rerender();
},
setSurface() {
coverage.setSurface();
cones.rerender();
},
coverageStats() {
const { kind } = state.coverage;
const lines = kind === 'sequence';
const shown = coverage.sequenceCount();
// Density dots are not sequences: they are not counted as such.
const count = lines ? shown : 0;
const loading = state.coverage.pending.size > 0;
const settled = !loading && !state.coverage.lastError;
const stats = coverage.stats();
let fallback = '';
// Tiles that held something the filter hid all of.
const filteredOut = [...state.coverage.tiles.values()].some(
(tile) => tile.total > 0,
);
if (kind === 'overview')
fallback =
shown > 0 || !settled
? OVERVIEW_HINT
: filteredOut
? FILTERED_HINT
: NO_COVERAGE_HINT;
else if (lines && settled && count === 0)
// Lines drawn for the camera (a zoom), all settled, none shown.
const settled =
state.coverage.zoom != null && !stats.loading && !stats.error;
if (settled && stats.count === 0) {
// Tiles that held lines the filter hid all of.
const filteredOut = [...state.coverage.tiles.values()].some(
(tile) => tile.total > 0,
);
fallback = filteredOut ? FILTERED_HINT : NO_COVERAGE_HINT;
return {
count,
zoom: state.coverage.zoom,
kind: state.coverage.kind,
loading,
hint: state.coverage.hint || fallback,
error: state.coverage.lastError,
keyRequired: false,
keyRejected: false,
};
}
return { ...stats, hint: stats.hint || fallback };
},
/**
@@ -599,6 +532,7 @@ export function createPanoramaxProvider({
selectedId: state.sequence.selectedId,
images: state.sequence.images.length,
loading: state.sequence.loading,
error: state.sequence.error,
}),
/**
@@ -32,9 +32,7 @@ export const COLORS = Object.freeze({
/**
* Sequence lines come from z7–15 tiles; z10–14 are drawn below 60 km, as
* Mapillary's are (a dense tile over Paris: z10 ~2.1 MB and 8,600
* sequences, z11 ~1.4 MB, z12 ~800 kB). Higher up, the z0–5 `grid` layer's
* density cells (0.1°, ~10 km) draw as points: lower down they would be a
* handful of dots over a whole city, which reads as no coverage.
* sequences, z11 ~1.4 MB, z12 ~800 kB). Higher up nothing is drawn.
*/
export const COVERAGE_Z14_BELOW_M = 1_800;
export const COVERAGE_Z13_BELOW_M = 5_000;
@@ -43,23 +41,21 @@ export const COVERAGE_Z11_BELOW_M = 30_000;
export const COVERAGE_LINES_BELOW_M = 60_000;
export const COVERAGE_TUNING = Object.freeze({
maxTiles: 9,
overviewMaxTiles: 6,
// Each tile draws at most 1,600 lines, its longest first (as decoded,
// `keepOrder`): a dense z11
// tile over Paris holds 3,800, and a lower cap left the city centre
// looking emptier than its suburbs. (KartaView draws as many.)
maxSequences: 14_400,
keepOrder: true,
// Points drawn at most, over every tile (each becomes ~8 ground-line
// vertices, ~1.4 kB of geometry): the simplified Paris z10 tile alone
// holds 57,000, unsimplified 750,000, which hung the tab.
// Points drawn at most, over every tile, a ninth of it a tile (each
// becomes ~8 ground-line vertices, ~1.4 kB of geometry): the simplified
// Paris z10 tile alone holds 57,000, unsimplified 750,000, which hung
// the tab.
maxPoints: 90_000,
lineWidthPx: 2.5,
overviewPointPx: 4,
moveDebounceMs: 320,
viewRangeMinM: 2_500,
viewRangePerHeight: 10,
viewNearM: 1_000,
});
/**
* Lines are simplified (Douglas–Peucker) to this many tile units of a
@@ -74,12 +70,6 @@ export const SIMPLIFY_TOLERANCE_UNITS = 4;
*/
export const DECODE_SLICE_MS = 8;
/**
* Grid cells per overview tile, at most: a z0 tile holds ~70,000 cells
* (1.9 MB), drawn as this many binned points.
*/
export const OVERVIEW_BINS_PER_AXIS = 96;
export const RATE_LIMITED_MESSAGE = msg(
'Panoramax is busy — coverage resumes on its own',
);
@@ -90,8 +80,8 @@ export const UNREADABLE_TILE_MESSAGE = msg(
'Panoramax sent a map tile that could not be read',
);
export const GONE_MESSAGE = msg('This Panoramax photo is no longer available');
/** From high up the dots are where pictures are, not sequences to click. */
export const OVERVIEW_HINT = msg('zoom in to see Panoramax coverage');
/** Above the line ceiling nothing is drawn. */
export const ZOOM_IN_HINT = msg('zoom in to see Panoramax coverage');
/** Cones come from z15 tiles' `pictures` layer (~110 bytes a picture). */
export const PICTURE_TILE_ZOOM = 15;
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
createPanoramaxProvider,
overviewPointSize,
panoramaxZoomFor,
passesCoverage,
pictureTilesAround,
@@ -10,8 +9,8 @@ import {
import {
FILTERED_HINT,
NO_COVERAGE_HINT,
OVERVIEW_HINT,
PICK_PREFIX,
ZOOM_IN_HINT,
} from './policy.js';
import { validateProviders } from '../../registry.js';
import { tileBounds } from '../../tileMath.js';
@@ -95,9 +94,6 @@ function fakeContext({ filter = { pano: 'all', sinceMs: null } } = {}) {
services: {},
getFilter: () => filter,
isActive: () => true,
groundCaster: null,
meshSampler: null,
getSurface: () => 'draped',
notify() {},
actions: {
openImage: async (id, options) => {
@@ -152,19 +148,17 @@ test('Panoramax registers keyless and off by default, with both filters and its
assert.doesNotThrow(() => validateProviders([definition]));
});
test('lines at z10–14 below 60 km, as Mapillary’s; density points from z5 down to z0 above', () => {
assert.deepEqual(panoramaxZoomFor(500), { zoom: 14, kind: 'sequence' });
assert.deepEqual(panoramaxZoomFor(1_800), { zoom: 14, kind: 'sequence' });
assert.deepEqual(panoramaxZoomFor(4_000), { zoom: 13, kind: 'sequence' });
assert.deepEqual(panoramaxZoomFor(12_000), { zoom: 12, kind: 'sequence' });
assert.deepEqual(panoramaxZoomFor(25_000), { zoom: 11, kind: 'sequence' });
assert.deepEqual(panoramaxZoomFor(30_000), { zoom: 11, kind: 'sequence' });
assert.deepEqual(panoramaxZoomFor(30_001), { zoom: 10, kind: 'sequence' });
assert.deepEqual(panoramaxZoomFor(60_000), { zoom: 10, kind: 'sequence' });
assert.deepEqual(panoramaxZoomFor(60_001), { zoom: 5, kind: 'overview' });
assert.deepEqual(panoramaxZoomFor(100_000), { zoom: 5, kind: 'overview' });
assert.deepEqual(panoramaxZoomFor(2_000_000), { zoom: 3, kind: 'overview' });
assert.deepEqual(panoramaxZoomFor(20_000_000), { zoom: 0, kind: 'overview' });
test('lines at z10–14 below 60 km, as Mapillary’s; nothing above', () => {
assert.equal(panoramaxZoomFor(500), 14);
assert.equal(panoramaxZoomFor(1_800), 14);
assert.equal(panoramaxZoomFor(4_000), 13);
assert.equal(panoramaxZoomFor(12_000), 12);
assert.equal(panoramaxZoomFor(25_000), 11);
assert.equal(panoramaxZoomFor(30_000), 11);
assert.equal(panoramaxZoomFor(30_001), 10);
assert.equal(panoramaxZoomFor(60_000), 10);
assert.equal(panoramaxZoomFor(60_001), null);
assert.equal(panoramaxZoomFor(20_000_000), null);
assert.equal(panoramaxZoomFor(NaN), null);
});
@@ -182,7 +176,7 @@ test('a line with no camera type or no day passes the filters: a click finds out
);
});
test('the imagery filter: lines by type and day, density cells by what they hold', () => {
test('the imagery filter: lines by type and day', () => {
const old = Date.UTC(2020, 0, 1);
const since = { pano: 'all', sinceMs: Date.UTC(2024, 0, 1) };
assert.equal(
@@ -199,11 +193,7 @@ test('the imagery filter: lines by type and day, density cells by what they hold
),
true,
);
const cell = { hasPano: false, hasFlat: true, capturedAt: null };
assert.equal(passesCoverage(cell, { pano: 'pano', sinceMs: null }), false);
assert.equal(passesCoverage(cell, { pano: 'flat', sinceMs: null }), true);
assert.equal(passesCoverage(cell, since), true, 'cells are undated: kept');
assert.equal(passesCoverage(cell, null), true);
assert.equal(passesCoverage({ isPano: false, capturedAt: old }, null), true);
});
test('cones read the z15 tile around a picture, and its neighbours near an edge', () => {
@@ -249,7 +239,7 @@ test('a click on a line opens that sequence’s picture nearest the click, not a
];
const { instance, actions, source } = setup({ items });
assert.equal(
instance.handlePick(`${PICK_PREFIX.sequence}clicked~1`, {
instance.handlePick(`${PICK_PREFIX.sequence}clicked`, {
groundPoint: () => east(1),
}),
true,
@@ -569,8 +559,13 @@ test('a broken neighbour z15 tile only thins the cones; an unreadable home tile
await withCones(t, env2);
await env2.instance.viewer.open('c-0');
await env2.instance.selectSequence('c');
assert.ok(await until(() => env2.actions.errors.some(Boolean)));
assert.match(env2.actions.errors.filter(Boolean).at(-1), /could not be read/);
assert.ok(await until(() => env2.instance.sequenceStats().error));
assert.match(env2.instance.sequenceStats().error, /could not be read/);
assert.deepEqual(
env2.actions.errors.filter(Boolean),
[],
'a sequence error is the sequence’s, not the viewer’s',
);
});
test('cones re-centred into tiles already read do not read them again', async (t) => {
@@ -629,16 +624,13 @@ test('the cones re-centre when › walks into another z15 tile', async (t) => {
});
/** The provider drawing coverage on a stand-in viewer above one z14 tile. */
function coverageSetup(
t,
{ sequences = [], grid = [], filter, height = 900 } = {},
) {
function coverageSetup(t, { sequences = [], filter, height = 900 } = {}) {
const centre = tileCentre(ORIGIN.lon, ORIGIN.lat);
let hold = null;
const env = setup(
{
tiles: (tile) => {
const bytes = encodePanoramaxTile(tile, { sequences, grid });
const bytes = encodePanoramaxTile(tile, { sequences });
return hold ? hold.then(() => bytes) : bytes;
},
},
@@ -682,8 +674,8 @@ test('sequence lines draw near the ground, and the imagery filter thins them', a
});
env.instance.activate(env.viewer);
assert.ok(await until(() => env.instance.coverageStats().count === 2));
assert.equal(env.instance.coverageStats().kind, 'sequence');
assert.equal(env.instance.coverageStats().keyRequired, false);
assert.equal(env.instance.coverageStats().error, null);
env.instance.deactivate();
const filtered = coverageSetup(t, {
@@ -697,6 +689,18 @@ test('sequence lines draw near the ground, and the imagery filter thins them', a
assert.ok(await until(() => filtered.instance.coverageStats().count === 1));
});
test('the selected sequence’s line is drawn again over the coverage, and goes with it', async (t) => {
const env = coverageSetup(t, { sequences: [line('a'), line('b')] });
env.instance.activate(env.viewer);
assert.ok(await until(() => env.instance.coverageStats().count === 2));
const ground = env.viewer.scene.groundPrimitives.items;
const drawn = ground.size;
await env.instance.selectSequence('a');
assert.equal(ground.size, drawn + 1, 'one highlight over the lines');
env.instance.clearSequence();
assert.equal(ground.size, drawn);
});
test('coverage hints: nothing while loading, “no pictures” once an empty view has loaded', async (t) => {
const env = coverageSetup(t);
const release = env.holdTiles();
@@ -708,19 +712,16 @@ test('coverage hints: nothing while loading, “no pictures” once an empty vie
assert.equal(env.instance.coverageStats().hint, NO_COVERAGE_HINT);
});
test('from high up the density grid draws points, not counted as sequences, and the panel says to zoom in', async (t) => {
const env = coverageSetup(t, {
height: 200_000,
grid: [{ lon: ORIGIN.lon, lat: ORIGIN.lat, pano: 2, flat: 3 }],
});
test('from high up nothing is drawn or fetched, and the panel says to zoom in', async (t) => {
const env = coverageSetup(t, { height: 200_000, sequences: [line('a')] });
env.instance.activate(env.viewer);
assert.ok(
await until(() => env.instance.coverageStats().kind === 'overview'),
await until(() => env.instance.coverageStats().hint === ZOOM_IN_HINT),
);
assert.ok(await until(() => !env.instance.coverageStats().loading));
assert.equal(env.instance.coverageStats().zoom, 5);
assert.equal(env.instance.coverageStats().count, 0, 'dots are not sequences');
assert.equal(env.instance.coverageStats().hint, OVERVIEW_HINT);
assert.equal(env.instance.coverageStats().count, 0);
assert.equal(env.instance.coverageStats().loading, false);
assert.deepEqual(env.source.calls.tiles, []);
assert.equal(env.viewer.scene.groundPrimitives.items.size, 0);
});
test('switching off clears the cones and forgets a pending line click', async (t) => {
@@ -747,41 +748,12 @@ test('lines in view that the filter hides all of say so, not that there is nothi
sequences: [line('flat-only')],
});
env.instance.activate(env.viewer);
assert.ok(
await until(
() =>
!env.instance.coverageStats().loading &&
env.instance.coverageStats().zoom !== null,
),
);
assert.ok(
await until(() => env.instance.coverageStats().hint === FILTERED_HINT),
);
assert.equal(env.instance.coverageStats().count, 0);
});
test('from high up over empty sea the panel says there is nothing, not to zoom in', async (t) => {
const env = coverageSetup(t, { height: 200_000, grid: [] });
env.instance.activate(env.viewer);
assert.ok(
await until(() => env.instance.coverageStats().kind === 'overview'),
);
assert.ok(
await until(() => env.instance.coverageStats().hint === NO_COVERAGE_HINT),
);
});
test('density dots grow with the pictures they stand for', () => {
assert.equal(overviewPointSize({ weight: 1 }), 2.5);
assert.equal(overviewPointSize({ weight: 100 }), 4.5);
assert.equal(overviewPointSize({ weight: 1e9 }), 8);
assert.equal(overviewPointSize({}), 2.5);
assert.ok(
overviewPointSize({ weight: 50_000 }) >
overviewPointSize({ weight: 50 }) + 2,
);
});
test('Esc in the docked viewer clears the selected sequence before the panel can collapse', async (t) => {
const items = sequence('s', 3);
const env = await mounted({ items, tiles: pictureTiles(items) });
@@ -890,42 +862,7 @@ test('cones re-centre on a picture that opens while they are still loading', asy
);
});
test('a filter that hides every density dot says so', async (t) => {
const env = coverageSetup(t, {
height: 200_000,
filter: { pano: 'pano', sinceMs: null },
grid: [{ lon: ORIGIN.lon, lat: ORIGIN.lat, pano: 0, flat: 5 }],
});
env.instance.activate(env.viewer);
assert.ok(
await until(() => env.instance.coverageStats().hint === FILTERED_HINT),
);
});
test('dots are drawn at their own sizes, and the busiest is the biggest', async (t) => {
const env = coverageSetup(t, {
height: 200_000,
grid: [
{ lon: ORIGIN.lon, lat: ORIGIN.lat, pano: 0, flat: 1 },
{ lon: ORIGIN.lon + 3, lat: ORIGIN.lat, pano: 30_000, flat: 20_000 },
],
});
env.instance.activate(env.viewer);
const sizes = () => {
const out = [];
for (const primitive of env.viewer.scene.primitives.items)
for (let i = 0; i < (primitive.length ?? 0); i++)
if (primitive.get(i).pixelSize) out.push(primitive.get(i).pixelSize);
return out.sort((a, b) => a - b);
};
assert.ok(await until(() => sizes().length === 2));
assert.deepEqual(sizes(), [
overviewPointSize({ weight: 1 }),
overviewPointSize({ weight: 50_000 }),
]);
});
test('lines keep the decoded (longest-first) order under a budget of points', async () => {
test('lines keep the decoded (longest-first) order under a cap on points', async () => {
const { COVERAGE_TUNING } = await import('./policy.js');
assert.equal(COVERAGE_TUNING.keepOrder, true);
assert.ok(COVERAGE_TUNING.maxPoints > 0);
@@ -943,15 +880,16 @@ test('a coverage tile decoded a moment ago is not fetched again; switched off, t
assert.ok(await until(() => env.instance.coverageStats().count === 1));
const z14 = asked.length;
// Up a zoom band and back down: the z14 tile comes from memory.
// (Activating again refreshes at once, without the camera debounce.)
env.viewer.view.height = 3_000;
env.instance.refreshCoverage();
assert.ok(await until(() => env.instance.coverageStats().zoom === 13));
assert.ok(await until(() => !env.instance.coverageStats().loading));
env.instance.activate(env.viewer);
assert.ok(await until(() => asked.some((key) => key.startsWith('13/'))));
assert.ok(await until(() => env.instance.coverageStats().count === 1));
const withZ13 = asked.length;
env.viewer.view.height = 900;
env.instance.refreshCoverage();
assert.ok(await until(() => env.instance.coverageStats().zoom === 14));
env.instance.activate(env.viewer);
assert.ok(await until(() => env.instance.coverageStats().count === 1));
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(asked.length, withZ13, 'nothing fetched again');
assert.ok(z14 > 0);
// Off and on: the memory went back, so the tile is fetched again.
@@ -1240,7 +1178,7 @@ test('a busy or timed-out tile keeps its own message; only a body that is not a
await withCones(t, env);
await env.instance.viewer.open('s-0');
await env.instance.selectSequence('s');
assert.ok(await until(() => env.actions.errors.some(Boolean)));
assert.equal(env.actions.errors.filter(Boolean).at(-1), failure.message);
assert.ok(await until(() => env.instance.sequenceStats().error));
assert.equal(env.instance.sequenceStats().error, failure.message);
}
});
@@ -160,7 +160,7 @@ test('a 360° picture shows its SD equirectangular image, then HD shrunk to 4,09
assert.equal(loads.length, 1, 'docked: still SD only');
const root = canvas.parentNode;
root.closest = (selector) =>
selector.includes('sl-viewer-wrap-expanded') ? {} : null;
selector.includes('sl-viewer-wrap:fullscreen') ? {} : null;
// EXPAND resizes more than once while the panel settles.
viewer.resize();
viewer.resize();
@@ -280,7 +280,7 @@ test('a picture whose SD fails falls back to the thumbnail; the original only fo
// Expanded, the original is worth its megabytes.
tried.length = 0;
parts(host).root.closest = (selector) =>
selector.includes('sl-viewer-wrap-expanded') ? {} : null;
selector.includes('sl-viewer-wrap:fullscreen') ? {} : null;
await viewer.open('flat');
assert.deepEqual(tried, [
PHOTOS.flat.urls.sd,
+10 -20
View File
@@ -11,7 +11,7 @@ export function filtersOf(provider) {
*
* @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} name Human name for captions, 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.
@@ -19,54 +19,44 @@ export function filtersOf(provider) {
* @property {{html: string}} credit On-globe attribution while the provider is active.
* @property {boolean} [groundClick] No coverage: a ground click at street zoom opens its nearest image.
* @property {string} [requiresMapStack] Usable only on this map stack.
* @property {boolean} [defaultOn] Default true.
* @property {boolean} [defaultOn] Default true; the first that defaults on is lit.
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
* @property {Array<'pano'|'since'>} [filters] Filters it honours (default both).
* @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos").
* @property {boolean} [highlightsSequence] Its lines show the selected sequence (default true; the legend's "Selected" needs one).
* @property {'capture'|'upload'} [sinceBasis] What SINCE compares (default 'capture'); 'upload' puts a note under the slider while the provider is lit.
* @property {(context: ProviderContext) => ProviderInstance} create
*
* @typedef {object} ProviderContext Handed to `create()` once by the core.
* @property {object} services Scene services: picking, input, render, sprites, ground, terrain, scenePick (ground clicks need it).
* @property {object} services Scene services: picking, input, render, sprites, scenePick (ground clicks need it).
* @property {() => {pano: string, sinceMs: number|null}} getFilter Filter resolved against now; read it on every use so "since N days" keeps up.
* @property {() => boolean} isActive Layer enabled and this provider switched on.
* @property {() => 'draped'|'terrain'} getSurface 'terrain' on Google 3D at street zoom: draw on bare earth.
* @property {GroundCaster|null} groundCaster Bare-earth heights for terrain mode (groundCast.js); null without a terrain service.
* @property {{meshAt: (lon: number, lat: number) => number|undefined, request: (points: Array<[number, number]>) => void, onSampled: (listener: (cells: Array<[number, number]>) => void) => () => void}|null} meshSampler Sampled Google 3D surface heights (meshSampler.js); pass `meshAt` to `castLine`.
* @property {() => void} notify Ask the core to publish a new UI snapshot.
* @property {{openImage: (imageId: string, options?: {frame?: boolean}) => Promise<boolean>, openNearest: (point: {lon: number, lat: number}) => Promise<boolean>, reportError: (message: string|null) => void}} actions
* `openNearest` opens this provider's image nearest a point the user picked
* (needs `nearestImage`); `reportError` shows a viewer or key error in the
* panel, or withdraws this provider's own (null).
*
* @typedef {object} ProviderInstance
* @property {() => Promise<{configured: boolean}>} status
* @property {(options?: {signal?: AbortSignal}) => Promise<{configured: boolean}>} status Whether a usable key is set; the core gives up on a hung answer through `signal`.
* @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 {(mode: 'draped'|'terrain') => void} [setSurface] Redraw coverage and cones for the new surface mode.
* @property {() => {count: number, zoom: number|null, kind: string|null, loading: boolean, hint: string, error: string|null, keyRequired: boolean}} coverageStats
* @property {() => {count: number, loading: boolean, hint: string, error: string|null, keyRequired: boolean, keyRejected?: boolean}} coverageStats
* @property {(pickId: string, click?: {groundPoint: () => {lon: number, lat: number}|null}) => boolean} handlePick The id carries the provider's own prefix; `groundPoint` is where the click met the ground.
* @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 {() => {selectedId: string|null, images: number, loading: boolean, error?: string|null}} [sequenceStats]
* @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal}) => Promise<string|null>} [nearestImage] For ground clicks and `openNearest`; an aborted lookup gives up (it may reject with an AbortError).
* @property {ViewerAdapter} viewer
*
* @typedef {object} GroundCaster
* @property {(points: Array<[number, number]>, options?: {signal?: AbortSignal}) => Promise<boolean>} prepare Fetch the heights around [lon, lat] points; true when all are known.
* @property {(lines: Array<Array<[number, number]>>, options?: {signal?: AbortSignal}) => Promise<boolean>} prepareLines
* @property {(lon: number, lat: number) => number|null} groundAt Cached bare-earth ellipsoidal height.
* @property {(coords: Array<[number, number]>) => Array<number>|null} castLine Densified [lon, lat, height, ...] or null.
*
* @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 {() => boolean} [supportsRenderModes] False while the image has no frame to fit (a panorama): FIT / FILL hide.
* @property {(listener: (pose: StreetPose) => void) => () => void} onPose
+14 -127
View File
@@ -1,33 +1,31 @@
import * as Cesium from 'cesium';
import { STREET_LEVEL_LAYER_ID } from './policy.js';
import { CLICK_PICK_WINDOW_PX, STREET_LEVEL_LAYER_ID } from './policy.js';
/** Hover picks at most ~8 times a second (like the CCTV layer), never per frame. */
const HOVER_PICK_INTERVAL_MS = 120;
/** One click/hover handler for every provider's coverage and image cones. */
/** One click handler for every provider's lines and cones, and the marker. */
export function createSelection({ state, parts }) {
const { picking, input } = state.services;
function ownsPick(pickedId) {
return parts.router.ownsPick(pickedId);
}
/** The id carries a provider's prefix: that provider handles the pick. */
function onClick(click) {
const viewer = state.viewer;
if (!viewer || !state.enabled) return;
if (input?.isPointerFree && !input.isPointerFree()) return;
const picked = viewer.scene.pick(click.position);
const picked = viewer.scene.pick(
click.position,
CLICK_PICK_WINDOW_PX,
CLICK_PICK_WINDOW_PX,
);
const id = picking?.resolvePickId
? picking.resolvePickId(picked)
: picked?.id;
const route = parts.router.resolve(id);
// Not ours: the ground handler decides whether it is the map itself.
if (!route?.instance) {
if (!route) {
parts.openAtGround?.(click.position, picked);
return;
}
const entry = state.providers.get(route.providerId);
if (!entry?.on) return;
// The position marker is the core's own: nothing to select.
if (!route.instance) return;
route.instance.handlePick(route.id, {
groundPoint: () => groundPointAt(viewer, click.position),
});
@@ -68,106 +66,6 @@ export function createSelection({ state, parts }) {
parts.clearSequences();
}
let hoverQueued = false;
let hoverCursor = false;
let hoverPosition = null;
let hoverLastPickAt = -Infinity;
let hoverTimer = null;
let pointerButtons = 0;
let removeHoverWatchers = null;
function setHoverCursor(canvas, hit) {
if (hit && !hoverCursor) {
canvas.style.cursor = 'pointer';
hoverCursor = true;
} else if (!hit && hoverCursor) {
canvas.style.cursor = '';
hoverCursor = false;
}
}
function pickHover() {
hoverQueued = false;
const viewer = state.viewer;
// Switched off (or torn down) since the frame was queued.
if (!viewer || !state.enabled || !state.clickHandler) return;
// A drag in progress: the pointer is the camera's.
if (pointerButtons !== 0) return;
const canvas = viewer.scene?.canvas;
if (!canvas || viewer.isDestroyed?.()) return;
hoverLastPickAt = Date.now();
let hit = false;
try {
const picked = viewer.scene.pick(hoverPosition);
const id = picking?.resolvePickId
? picking.resolvePickId(picked)
: picked?.id;
hit = ownsPick(id);
} catch {
hit = false;
}
setHoverCursor(canvas, hit);
}
/**
* Pointer cursor over anything this layer owns. Throttled; a move inside
* the interval is picked when it ends, so the cursor never sticks.
*/
function onMove(movement) {
const viewer = state.viewer;
if (!viewer || !state.enabled) return;
hoverPosition = movement.endPosition;
queueHoverPick();
}
/** Pick `hoverPosition` on a coming frame, within the throttle. */
function queueHoverPick() {
if (hoverQueued) return;
hoverQueued = true;
const wait = hoverLastPickAt + HOVER_PICK_INTERVAL_MS - Date.now();
if (wait <= 0) {
requestAnimationFrame(pickHover);
return;
}
hoverTimer = setTimeout(() => {
hoverTimer = null;
requestAnimationFrame(pickHover);
}, wait);
}
/**
* Track held buttons (a drag picks nothing) and re-pick when the camera
* rests. Hover keeps running while it moves: a tracking camera never stops.
*/
function watchHover(viewer) {
const canvas = viewer.scene.canvas;
const camera = viewer.camera;
const onButtons = (event) => {
pointerButtons = event.buttons ?? 0;
};
for (const type of ['pointerdown', 'pointermove', 'pointerup'])
canvas.addEventListener?.(type, onButtons);
const removeEnd = camera?.moveEnd?.addEventListener(() => {
if (hoverPosition) queueHoverPick();
});
removeHoverWatchers = () => {
for (const type of ['pointerdown', 'pointermove', 'pointerup'])
canvas.removeEventListener?.(type, onButtons);
removeEnd?.();
};
}
function unwatchHover() {
removeHoverWatchers?.();
removeHoverWatchers = null;
clearTimeout(hoverTimer);
hoverTimer = null;
hoverQueued = false;
hoverPosition = null;
hoverLastPickAt = -Infinity;
pointerButtons = 0;
}
function install(viewer) {
if (state.clickHandler) return;
state.clickHandler = new Cesium.ScreenSpaceEventHandler(
@@ -177,29 +75,18 @@ export function createSelection({ state, parts }) {
onClick,
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
state.clickHandler.setInputAction(
onMove,
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
);
watchHover(viewer);
document.addEventListener('keydown', onKeyDown);
picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, ownsPick);
picking?.registerPickOwner?.(STREET_LEVEL_LAYER_ID, parts.router.ownsPick);
}
function uninstall() {
// Nothing to undo for a layer that was never enabled.
if (!state.clickHandler) return;
unwatchHover();
if (hoverCursor && state.viewer?.scene?.canvas) {
state.viewer.scene.canvas.style.cursor = '';
hoverCursor = false;
}
if (state.clickHandler && !state.clickHandler.isDestroyed())
state.clickHandler.destroy();
if (!state.clickHandler.isDestroyed()) state.clickHandler.destroy();
state.clickHandler = null;
document.removeEventListener('keydown', onKeyDown);
picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID);
}
return { install, uninstall, ownsPick };
return { install, uninstall, ownsPick: parts.router.ownsPick };
}
+84 -162
View File
@@ -1,26 +1,12 @@
import assert from 'node:assert/strict';
import test, { mock } from 'node:test';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createSelection } from './selection.js';
import { createPickRouter } from './pickRouter.js';
/**
* The selection installed on a stand-in viewer. Animation frames queue until
* `frame()` runs them; timers and Date.now() are mocked so a burst of moves
* has a known timeline.
*/
function harness({
selected = false,
router = null,
providers = new Map(),
services = {},
} = {}) {
const saved = {
document: globalThis.document,
requestAnimationFrame: globalThis.requestAnimationFrame,
};
mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const frames = [];
globalThis.requestAnimationFrame = (task) => frames.push(task);
/** The selection installed on a stand-in viewer, with what its clicks reach. */
function harness({ selected = false, services = {}, providers = [] } = {}) {
const saved = globalThis.document;
globalThis.document = new EventTarget();
// The document's keydown listeners, so a test can hand them any target.
const keyListeners = new Set();
@@ -38,62 +24,64 @@ function harness({
if (type === 'keydown') keyListeners.delete(listener);
removeListener(type, listener, options);
};
const sequences = { selected, cleared: 0 };
const sequences = { selected, cleared: 0, selects: [] };
const opened = [];
const groundClicks = [];
const picks = [];
/** What the pointer is over: a line this layer owns, or nothing. */
/** What the pointer is over: a pick id, or nothing. */
const scene = { under: null };
const canvas = Object.assign(new EventTarget(), {
style: {},
// Keep Cesium's handler on the canvas; there is no real document here.
disableRootEvents: true,
onwheel: null,
});
const camera = { moveStart: new Cesium.Event(), moveEnd: new Cesium.Event() };
const viewer = {
scene: {
canvas,
pick(position) {
picks.push(position);
return scene.under && { id: scene.under };
},
},
camera,
isDestroyed: () => false,
scene: { canvas, pick: () => scene.under && { id: scene.under } },
};
const state = {
viewer,
enabled: true,
clickHandler: null,
providers,
services: { picking: null, input: null, ...services },
providers: new Map([
[
'mapillary',
{
def: { id: 'mapillary', pickPrefix: 'mly:' },
instance: {
handlePick(id) {
if (id.startsWith('mly:seq:'))
sequences.selects.push(id.slice(8));
else if (id.startsWith('mly:img:')) opened.push(id.slice(8));
return true;
},
},
},
],
...providers,
]),
};
const selection = createSelection({
state,
parts: {
router: router || {
ownsPick: (id) => id === 'mly:seq:1',
resolve: () => null,
},
hasSelectedSequence: () => sequences.selected,
openAtGround: (position, picked) =>
groundClicks.push({ position, picked }),
clearSequences() {
sequences.cleared++;
sequences.selected = false;
},
const parts = {
router: createPickRouter(() => state.providers.values(), {
positionId: 'sl:pos',
}),
hasSelectedSequence: () => sequences.selected,
openAtGround: (position, picked) => groundClicks.push({ position, picked }),
clearSequences() {
sequences.cleared++;
sequences.selected = false;
},
});
};
const selection = createSelection({ state, parts });
selection.install(viewer);
const onMove = state.clickHandler.getInputAction(
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
);
const onClick = state.clickHandler.getInputAction(
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
const action = (type) =>
state.clickHandler.getInputAction(Cesium.ScreenSpaceEventType[type]);
return {
state,
sequences,
opened,
groundClicks,
scene,
click: (x = 10) => action('LEFT_CLICK')({ position: { x, y: 10 } }),
/** A keydown reaching the document from `target`; the event as handled. */
key(key, target, { defaultPrevented = false } = {}) {
const event = {
@@ -107,114 +95,32 @@ function harness({
for (const listener of [...keyListeners]) listener(event);
return event;
},
scene,
canvas,
camera,
picks,
move: (x) => onMove({ endPosition: { x, y: 10 } }),
click: (x) => onClick({ position: { x, y: 10 } }),
groundClicks,
frame() {
for (const task of frames.splice(0)) task();
},
/** Let `ms` pass, running the frames that come due on the way. */
wait(ms) {
this.frame();
mock.timers.tick(ms);
this.frame();
},
buttons(type, buttons) {
const event = new Event(type);
Object.defineProperty(event, 'buttons', { value: buttons });
canvas.dispatchEvent(event);
},
restore() {
selection.uninstall();
mock.timers.reset();
Object.assign(globalThis, saved);
globalThis.document = saved;
},
};
}
test('hover picks at most every 120 ms through a burst of moves, ending on the last one', () => {
test('a click on a line selects its sequence, on a cone opens its image, elsewhere does nothing', () => {
const h = harness();
try {
// One second of a pointer moving at 60 Hz.
for (let i = 0; i < 60; i++) {
h.move(i);
h.wait(16);
h.scene.under = 'mly:seq:abc';
h.click();
assert.deepEqual(h.sequences.selects, ['abc']);
h.scene.under = 'mly:img:42';
h.click();
assert.deepEqual(h.opened, ['42']);
for (const under of [null, 'cctv:7', 'sl:pos']) {
h.scene.under = under;
h.click();
}
assert.ok(h.picks.length <= 9, `${h.picks.length} picks in ~1 s`);
assert.ok(h.picks.length >= 7, `${h.picks.length} picks in ~1 s`);
h.wait(200);
assert.deepEqual(h.picks.at(-1), { x: 59, y: 10 }, 'the resting position');
const settled = h.picks.length;
h.wait(1000);
assert.equal(h.picks.length, settled, 'a still pointer costs nothing');
} finally {
h.restore();
}
});
test('hover does not pick while a mouse button is held', () => {
const h = harness();
try {
h.buttons('pointerdown', 1);
h.move(1);
h.wait(200);
assert.equal(h.picks.length, 0, 'a drag in progress');
h.buttons('pointerup', 0);
h.move(2);
h.wait(200);
assert.equal(h.picks.length, 1);
} finally {
h.restore();
}
});
test('a camera that never stops (orbit, tracking) keeps hover throttled, not off', () => {
const h = harness();
try {
// An orbiting or tracking camera raises moveStart and never moveEnd.
h.camera.moveStart.raiseEvent();
for (let i = 0; i < 60; i++) {
h.move(i);
h.wait(16);
}
assert.ok(h.picks.length >= 7, `${h.picks.length} picks in ~1 s`);
assert.ok(h.picks.length <= 9, `${h.picks.length} picks in ~1 s`);
} finally {
h.restore();
}
});
test('when the camera stops, the pointer is picked again so the cursor cannot stick', () => {
const h = harness();
try {
h.scene.under = 'mly:seq:1';
h.move(5);
h.wait(200);
assert.equal(h.canvas.style.cursor, 'pointer', 'over a line');
// The camera moves the line out from under a still pointer.
h.camera.moveStart.raiseEvent();
h.scene.under = null;
h.camera.moveEnd.raiseEvent();
h.wait(200);
assert.equal(h.picks.length, 2, 'one pick when the camera rests');
assert.deepEqual(h.picks.at(-1), { x: 5, y: 10 });
assert.equal(h.canvas.style.cursor, '', 'the pointer cursor is gone');
} finally {
h.restore();
}
});
test('a hover frame queued before the layer went off does not pick', () => {
const h = harness();
try {
h.move(1);
h.state.enabled = false;
h.wait(200);
assert.equal(h.picks.length, 0);
assert.deepEqual([h.sequences.selects, h.opened], [['abc'], ['42']]);
// The marker is ours; nothing and another layer's pick go to the ground.
assert.deepEqual(
h.groundClicks.map(({ picked }) => picked?.id),
[undefined, 'cctv:7'],
);
} finally {
h.restore();
}
@@ -274,6 +180,19 @@ test('Esc in a text field or rich-text editor keeps the selection (M61)', () =>
}
});
test('a click on the position marker neither selects nor opens at the ground', () => {
const h = harness();
try {
h.scene.under = 'sl:pos';
h.click();
assert.deepEqual(h.groundClicks, []);
assert.deepEqual(h.opened, []);
assert.deepEqual(h.sequences.selects, []);
} finally {
h.restore();
}
});
test('a click on nothing of ours goes to the ground handler with the pick, which decides', () => {
const h = harness();
try {
@@ -294,16 +213,19 @@ test('a click on nothing of ours goes to the ground handler with the pick, which
test('a click on a provider line hands it the ground under the click, measured only if asked', () => {
const handled = [];
const instance = {
handlePick: (id, click) => handled.push({ id, click }),
};
const groundPicks = [];
const h = harness({
router: {
ownsPick: () => true,
resolve: (id) => ({ providerId: 'kartaview', instance, id }),
},
providers: new Map([['kartaview', { on: true }]]),
providers: [
[
'kartaview',
{
def: { id: 'kartaview', pickPrefix: 'kv:' },
instance: {
handlePick: (id, click) => handled.push({ id, click }),
},
},
],
],
services: {
scenePick: {
pickGroundPosition: (viewer, position) => {
+28 -131
View File
@@ -1,12 +1,8 @@
import * as Cesium from 'cesium';
import { imageConeGlyph } from './glyphs.js';
import { passesImageryFilter } from './filter.js';
import { refineHeights } from './groundCast.js';
import { createHorizonCull, metresBetween } from './view.js';
import { meshCellKey } from './meshSampler.js';
/** How many recently viewed sequences keep their image list in memory. */
const SEQUENCE_CACHE_SIZE = 40;
import { msg } from '../../i18n/index.js';
/** Cones closer than this to the previous kept one are dropped. */
export const IMAGE_CONE_MIN_SPACING_M = 3;
@@ -36,8 +32,10 @@ export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
}
/**
* Image cones for one selected sequence, any provider's. A cone's pick id is
* `pickPrefix` + the image id; 360° images draw as rings.
* Image cones for one selected sequence, any provider's, clamped to the
* ground. A cone's pick id is `pickPrefix` + the image id; 360° images draw
* as rings. Why a sequence could not load stays in `state.sequence.error`
* until it goes.
*
* @param {object} options
* @param {object} options.state Provider state: `context`, `services`, `viewer`, `sequence`.
@@ -47,8 +45,8 @@ export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
* @param {string} options.pickPrefix Pick-id prefix of the cones.
* @param {string} options.spriteId Sprite-order id of the cone collection.
* @param {string} options.renderKey Render-governor reason.
* @param {(sequenceId: string, selected: boolean) => void} [options.onHighlight]
* Highlight (or stop highlighting) the sequence's coverage line.
* @param {(sequenceId: string|null) => void} [options.onHighlight]
* Highlight the sequence's coverage line (null: none).
* @param {(image: ConeImage, filter: object) => boolean} [options.passes]
* Whether the imagery filter keeps a cone.
*/
@@ -72,14 +70,13 @@ export function createSequenceCones({
state.context.notify();
}
const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() });
/** Drawn cones, plus the mesh cells under them, so a sample moves only those. */
let drawn = nothingDrawn();
/** Drawn cone billboards. */
let cones = [];
// The cones skip the depth test: hide the ones behind the globe.
const horizon = createHorizonCull({
getViewer: () => state.viewer,
items: () => drawn.cones.map((cone) => cone.billboard),
items: () => cones,
onChange: requestRender,
});
@@ -95,58 +92,15 @@ export function createSequenceCones({
function clearCones() {
state.sequence.collection?.removeAll();
state.sequence.images = [];
drawn = nothingDrawn();
cones = [];
horizon.stop();
}
/** Where a cone stands: at `height`, or clamped to the ground when null. */
function placement(image, height) {
return {
position: Cesium.Cartesian3.fromDegrees(
image.lon,
image.lat,
height ?? 0,
),
heightReference:
height === null
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE,
};
}
/**
* Terrain-mode cone heights on the street, not on roofs: bare earth refined
* by mesh samples. Null when draped or while terrain heights are fetched.
*/
function coneHeights(images, { request = true } = {}) {
const ground = state.context.groundCaster;
if (state.context.getSurface?.() !== 'terrain' || !ground) return null;
const dems = images.map((image) => ground.groundAt(image.lon, image.lat));
if (dems.includes(null)) {
ground
.prepare(images.map((image) => [image.lon, image.lat]))
.then((ready) => {
if (ready && state.sequence.images === images) renderCones(images);
});
return null;
}
const sampler = state.context.meshSampler;
if (request)
sampler?.request(images.map((image) => [image.lon, image.lat]));
return refineHeights(
images.map((image, i) => ({
dem: dems[i],
mesh: sampler?.meshAt(image.lon, image.lat),
})),
);
}
function renderCones(images) {
const collection = state.sequence.collection;
if (!collection) return;
collection.removeAll();
drawn = { ...nothingDrawn(), images };
const heights = coneHeights(images);
cones = [];
// One colour per source; 360° images keep the ring shape.
const cone = imageConeGlyph({ size: 32, color: colors.coverage });
const ring = imageConeGlyph({
@@ -156,12 +110,12 @@ export function createSequenceCones({
});
// Resolved now, so a "since N days" window keeps up with the clock.
const filter = state.context.getFilter();
images.forEach((image, index) => {
if (!passes(image, filter)) return;
const height = heights?.[index] ?? null;
for (const image of images) {
if (!passes(image, filter)) continue;
const billboard = collection.add({
id: `${pickPrefix}${image.id}`,
...placement(image, height),
position: Cesium.Cartesian3.fromDegrees(image.lon, image.lat),
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
image: image.isPano ? ring : cone,
imageId: image.isPano ? `${spriteId}:pano` : `${spriteId}:flat`,
width: IMAGE_CONE_SIZE_PX,
@@ -172,52 +126,12 @@ export function createSequenceCones({
verticalOrigin: Cesium.VerticalOrigin.CENTER,
scaleByDistance: new Cesium.NearFarScalar(200, 1.1, 6000, 0.35),
});
drawn.cones.push({ index, billboard, height });
drawn.cells.add(meshCellKey(image.lon, image.lat));
});
cones.push(billboard);
}
horizon.update();
requestRender();
}
/**
* Re-place cones whose height new mesh samples changed. A sample can move
* a neighbour too, so every height is recomputed but only moved cones touched.
*/
function onMeshSampled(batch) {
if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length)
return;
if (!batch.some(([lon, lat]) => drawn.cells.has(meshCellKey(lon, lat))))
return;
const { images, cones } = drawn;
// Already asked for when drawn; a missing terrain cell redraws them all.
const heights = coneHeights(images, { request: false });
if (!heights) return;
const moved = [];
for (const cone of cones) {
const height = heights[cone.index] ?? null;
if (height === cone.height) continue;
cone.height = height;
const { position, heightReference } = placement(
images[cone.index],
height,
);
cone.billboard.heightReference = heightReference;
cone.billboard.position = position;
moved.push(cone.billboard);
}
if (!moved.length) return;
horizon.update(moved);
requestRender();
}
function remember(sequenceId, images) {
const { cache } = state.sequence;
cache.delete(sequenceId);
cache.set(sequenceId, images);
while (cache.size > SEQUENCE_CACHE_SIZE)
cache.delete(cache.keys().next().value);
}
/**
* Select a sequence: highlight its line and load its image cones. `force`
* reloads a selected one (a provider that loads part of a sequence moved on).
@@ -231,21 +145,9 @@ export function createSequenceCones({
(state.sequence.images.length || state.sequence.abort)
)
return;
if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId)
onHighlight(state.sequence.selectedId, false);
state.sequence.abort?.abort();
state.sequence.selectedId = sequenceId;
onHighlight(sequenceId, true);
const cached = force ? null : state.sequence.cache.get(sequenceId);
if (cached) {
state.sequence.abort = null;
state.sequence.loading = false;
state.sequence.images = cached;
renderCones(cached);
state.context.actions.reportError(null);
notify();
return;
}
onHighlight(sequenceId);
// The previous sequence's cones must not stay clickable under the new
// highlight while this one loads, nor after its load fails.
clearCones();
@@ -253,24 +155,24 @@ export function createSequenceCones({
const controller = new AbortController();
state.sequence.abort = controller;
state.sequence.loading = true;
// An earlier sequence's error does not belong to this one.
state.sequence.error = null;
notify();
try {
const images = await loadImages(sequenceId, {
signal: controller.signal,
});
if (controller.signal.aborted) return;
remember(sequenceId, images);
state.sequence.images = images;
renderCones(images);
state.context.actions.reportError(null);
state.sequence.error = null;
} catch (error) {
if (!controller.signal.aborted) {
// Nothing to show: drop the highlight, so a click asks again.
onHighlight(sequenceId, false);
onHighlight(null);
state.sequence.selectedId = null;
state.context.actions.reportError(
error?.message || 'Sequence images unavailable',
);
state.sequence.error =
error?.message || msg('Sequence images unavailable');
}
} finally {
if (state.sequence.abort === controller) {
@@ -284,13 +186,12 @@ export function createSequenceCones({
function clearSelection() {
state.sequence.abort?.abort();
state.sequence.abort = null;
if (state.sequence.selectedId)
onHighlight(state.sequence.selectedId, false);
if (state.sequence.selectedId) onHighlight(null);
state.sequence.selectedId = null;
state.sequence.loading = false;
clearCones();
// An error about the sequence goes with it.
state.context.actions.reportError(null);
state.sequence.error = null;
requestRender();
notify();
}
@@ -300,9 +201,6 @@ export function createSequenceCones({
if (state.sequence.images.length) renderCones(state.sequence.images);
}
// New mesh samples may move the cones onto (or off) the road surface.
const stopMeshListener = state.context.meshSampler?.onSampled(onMeshSampled);
function setVisible(visible) {
if (state.sequence.collection) state.sequence.collection.show = visible;
requestRender();
@@ -311,8 +209,7 @@ export function createSequenceCones({
function destroy(viewer) {
state.sequence.abort?.abort();
horizon.stop();
drawn = nothingDrawn();
stopMeshListener?.();
cones = [];
const collection = state.sequence.collection;
if (collection) {
sprites?.unregisterSpriteCollection?.(spriteId, collection);
@@ -0,0 +1,217 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { resolveFilter } from './filter.js';
import { createSequenceCones, thinImages } from './sequenceCones.js';
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
/** A billboard collection that remembers the cones on the globe. */
function cones() {
const items = [];
return {
items,
show: true,
add(options) {
items.push(options);
return options;
},
removeAll() {
items.length = 0;
},
};
}
/** A source whose sequence lookups the test answers, one call at a time. */
function deferredSource() {
const calls = [];
return {
calls,
getSequenceImages(sequenceId, { signal } = {}) {
return new Promise((resolve, reject) => {
calls.push({ sequenceId, signal, resolve, reject });
});
},
/** The latest lookup for a sequence. */
last(sequenceId) {
return calls.filter((call) => call.sequenceId === sequenceId).at(-1);
},
};
}
/** Graph image records for a sequence, a few metres apart. */
function records(sequenceId, count = 2) {
return Array.from({ length: count }, (_, i) => ({
id: `${sequenceId}-${i}`,
geometry: { coordinates: [-121.49 + i * 0.001, 38.58] },
compass_angle: 90,
captured_at: 1_700_000_000_000 + i,
}));
}
function setup() {
const source = deferredSource();
/** Every highlight asked of the coverage: a sequence id, or null for none. */
const highlights = [];
const state = {
services: {},
viewer: {},
filter: { pano: 'all', sinceDays: 0 },
context: {
getFilter: () => resolveFilter(state.filter),
notify() {},
},
sequence: {
selectedId: null,
images: [],
collection: cones(),
loading: false,
error: null,
abort: null,
},
};
const sequences = createSequenceCones({
state,
async loadImages(sequenceId, { signal }) {
const list = await source.getSequenceImages(sequenceId, { signal });
return thinImages(
list.map((record) => ({
id: String(record.id),
lon: record.geometry.coordinates[0],
lat: record.geometry.coordinates[1],
compassAngle: Number(record.compass_angle) || 0,
capturedAt: Number(record.captured_at) || 0,
isPano: record.is_pano === true,
})),
);
},
colors: { coverage: '#05cb63' },
pickPrefix: 'mly:img:',
spriteId: 'street-level:test-cones',
renderKey: 'test-sequence',
onHighlight: (id) => highlights.push(id),
});
const drawn = () =>
state.sequence.collection.items.map((cone) => cone.id.split(':').at(-1));
return { state, source, sequences, highlights, drawn };
}
test('a failed sequence load leaves no stale cones and can be retried', async () => {
const { state, source, sequences, highlights, drawn } = setup();
sequences.select('A');
source.last('A').resolve(records('A'));
await settle();
assert.deepEqual(drawn(), ['A-0', 'A-1']);
sequences.select('B');
assert.deepEqual(drawn(), [], "A's cones go while B loads");
source.last('B').reject(new Error('Sequence images unavailable'));
await settle();
assert.deepEqual(drawn(), [], 'nothing of A is left to click');
assert.equal(state.sequence.loading, false);
assert.equal(state.sequence.selectedId, null, 'B is not left highlighted');
assert.equal(highlights.at(-1), null);
assert.equal(state.sequence.error, 'Sequence images unavailable');
// Clicking B again asks again, without the old error.
sequences.select('B');
assert.equal(source.calls.filter((c) => c.sequenceId === 'B').length, 2);
assert.equal(state.sequence.error, null, 'not shown while B loads again');
source.last('B').resolve(records('B'));
await settle();
assert.deepEqual(drawn(), ['B-0', 'B-1']);
assert.equal(state.sequence.selectedId, 'B');
assert.equal(state.sequence.error, null, 'cleared once B loads');
});
test('a late answer for a superseded sequence draws nothing', async () => {
const { state, source, sequences, drawn } = setup();
sequences.select('A');
sequences.select('B');
assert.equal(source.last('A').signal.aborted, true, 'A was cancelled');
source.last('B').resolve(records('B'));
await settle();
source.last('A').resolve(records('A'));
await settle();
assert.deepEqual(drawn(), ['B-0', 'B-1']);
assert.equal(state.sequence.selectedId, 'B');
assert.equal(state.sequence.loading, false);
});
test('a second click on a loading sequence does not start another lookup', async () => {
const { source, sequences } = setup();
sequences.select('A');
sequences.select('A');
assert.equal(source.calls.length, 1);
assert.equal(source.calls[0].signal.aborted, false);
});
test('clearSelection cancels the lookup and drops the highlight', async () => {
const { state, source, sequences, highlights, drawn } = setup();
sequences.select('A');
sequences.clearSelection();
assert.equal(source.last('A').signal.aborted, true);
assert.deepEqual(highlights, ['A', null]);
assert.equal(state.sequence.selectedId, null);
assert.equal(state.sequence.loading, false);
assert.equal(state.sequence.error, null, 'an error about it goes too');
source.last('A').resolve(records('A'));
await settle();
assert.deepEqual(drawn(), [], 'the late answer draws nothing');
});
test('cones are clamped to the ground and turned to their compass angle', async () => {
const { state, source, sequences } = setup();
sequences.select('A');
source.last('A').resolve(records('A', 2));
await settle();
const items = state.sequence.collection.items;
assert.equal(items.length, 2);
for (const cone of items) {
assert.equal(cone.heightReference, Cesium.HeightReference.CLAMP_TO_GROUND);
assert.equal(cone.rotation, -Cesium.Math.toRadians(90));
}
});
test('cones behind the globe are hidden, and show again from the other side', async () => {
const { state, source, sequences } = setup();
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
const preRender = new Set();
state.viewer = {
camera: {
get positionWC() {
return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height);
},
},
scene: {
preRender: {
addEventListener(listener) {
preRender.add(listener);
return () => preRender.delete(listener);
},
},
},
};
sequences.select('A');
// One image under the camera, one near its antipode.
source.last('A').resolve([
...records('A', 1),
{
id: 'A-far',
geometry: { coordinates: [58.51, -38.58] },
captured_at: 1_800_000_000_000,
},
]);
await settle();
const [near, far] = state.sequence.collection.items;
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.51;
view.lat = -38.58;
for (const listener of [...preRender]) listener();
assert.equal(near.show, false);
assert.equal(far.show, true);
sequences.clearSelection();
assert.equal(preRender.size, 0, 'the cull listener is gone');
});
+2 -10
View File
@@ -2,12 +2,11 @@ import { FILTER_DEFAULT } from './policy.js';
/**
* The per-image fields of `state.street` with no image open; closing resets
* exactly these (host, render mode and follow availability outlive an image).
* exactly these (host and render mode outlive an image).
*/
export function freshStreet() {
return {
open: false,
follow: false,
providerId: null,
providerName: null,
providerLabel: null,
@@ -39,19 +38,15 @@ export function createState({ services }) {
services,
viewer: null,
enabled: false,
initialized: false,
destroyed: false,
listeners: new Set(),
notify: null,
/** Shared imagery filter, in the stored (relative-days) form. */
filter: { ...FILTER_DEFAULT },
/** @type {Map<string, {def: object, instance: object, on: boolean}>} */
/** @type {Map<string, {def: object, instance: object, on: boolean, status: object|null}>} */
providers: new Map(),
street: {
host: null,
/** Whether the active map stack allows following (Google 3D only). */
followAvailable: false,
/** 'letterbox' shows the whole image; 'fill' crops it to the frame. */
renderMode: 'letterbox',
...freshStreet(),
@@ -60,9 +55,6 @@ export function createState({ services }) {
/** Whether the camera is low enough for a ground click to open an image. */
groundClickReady: false,
/** 'terrain' on Google 3D at street zoom (overlays on the bare earth), else 'draped'. */
surface: 'draped',
marker: { collection: null, billboard: null, look: null },
clickHandler: null,
};
@@ -2,30 +2,17 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { tilesForBbox } from './tileMath.js';
test('reachable polar dateline coverage ranks 24 distinct tiles before overview truncation', () => {
// coverage uses the actual view bbox above zoom 1; polar views can wrap.
const bbox = [-10, 60, -20, 85];
const result = tilesForBbox(bbox, 3);
const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`);
const keysOf = (tiles) => tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`);
test('a polar view across the date line ranks 24 distinct tiles', () => {
// The halves of [-10, 60, -20, 85] share a column at z3.
const keys = keysOf(tilesForBbox([-10, 60, -20, 85], 3));
assert.equal(keys.length, 24);
assert.equal(new Set(keys).size, 24);
assert.equal(result.total, 24);
assert.equal(result.truncated, false);
const overview = tilesForBbox(bbox, 3, { limit: 16 });
assert.equal(overview.total, 24);
assert.equal(overview.tiles.length, 16);
assert.equal(
new Set(overview.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`)).size,
16,
);
assert.equal(overview.truncated, true);
});
test('a limit that fits every distinct tile is not spent on repeats', () => {
// Before de-duplication this box yielded 27 entries for 24 tiles, so a
// 24-slot overview held repeats and still reported itself truncated.
const result = tilesForBbox([-10, 60, -20, 85], 3, { limit: 24 });
const keys = result.tiles.map((tile) => `${tile.x}/${tile.y}/${tile.z}`);
assert.equal(new Set(keys).size, 24);
assert.equal(result.truncated, false);
test('a limit is not spent on repeats', () => {
const keys = keysOf(tilesForBbox([-10, 60, -20, 85], 3, { limit: 16 }));
assert.equal(keys.length, 16);
assert.equal(new Set(keys).size, 16);
});
+46 -107
View File
@@ -1,4 +1,4 @@
/** Web-Mercator tile math, without Cesium, shared by the browser layer and the server executor. */
/** Web-Mercator tile math for Street Level coverage, without Cesium. */
const MAX_LAT = 85.05112878;
@@ -58,15 +58,10 @@ export function tileBounds(x, y, z) {
};
}
/** A [west, south, east, north] array or object as finite, ordered bounds; null if unusable. */
/** A [west, south, east, north] array as finite, ordered bounds; null if unusable. */
export function normalizeBbox(input) {
const values = Array.isArray(input)
? input
: input && typeof input === 'object'
? [input.west, input.south, input.east, input.north]
: null;
if (!values || values.length !== 4) return null;
const [w, s, e, n] = values.map(Number);
if (!Array.isArray(input) || input.length !== 4) return null;
const [w, s, e, n] = input.map(Number);
if (![w, s, e, n].every(Number.isFinite)) return null;
const west = Math.max(-180, Math.min(w, e));
const east = Math.min(180, Math.max(w, e));
@@ -77,97 +72,55 @@ export function normalizeBbox(input) {
}
/**
* Squared distance (in tiles) from tile (x, y)'s centre to the segment
* `from` → `to` at zoom z, measured the short way round the date line.
* Tiles at zoom z covering a [west, south, east, north] box (west > east
* crosses the date line), nearest to `from` first (default: the box centre),
* capped at `limit`. Distances are measured the short way round ±180°.
* @param {{limit?: number, from?: {lon: number, lat: number}|null}} [options]
* @returns {Array<{x:number,y:number,z:number}>}
*/
function segmentDistance2(tile, from, to, z) {
const n = 2 ** z;
const px = tile.x + 0.5;
const py = tile.y + 0.5;
const ax = tileXAt(from.lon, z);
const ay = tileYAt(from.lat, z);
let bx = to ? tileXAt(to.lon, z) : ax;
const by = to ? tileYAt(to.lat, z) : ay;
if (bx - ax > n / 2) bx -= n;
else if (ax - bx > n / 2) bx += n;
let best = Infinity;
for (const shift of [-n, 0, n]) {
const x = px + shift;
const vx = bx - ax;
const vy = by - ay;
const len2 = vx * vx + vy * vy;
const t = len2
? Math.max(0, Math.min(1, ((x - ax) * vx + (py - ay) * vy) / len2))
: 0;
const dx = x - (ax + t * vx);
const dy = py - (ay + t * vy);
best = Math.min(best, dx * dx + dy * dy);
}
return best;
}
/**
* Tiles at zoom z covering a bbox (west > east crosses the date line), from
* the centre outwards, capped at `limit`. With `focus`, tiles rank by distance
* from the segment `from` (ground under the camera) to `to` (screen centre):
* a tilted view's box centre can sit kilometres ahead of both.
* @param {{limit?: number, focus?: {from: {lon: number, lat: number}, to?: {lon: number, lat: number}|null}|null}} [options]
* @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}}
*/
export function tilesForBbox(bbox, z, { limit = Infinity, focus = null } = {}) {
const n = 2 ** z;
const tiles = [];
let centre;
if (Array.isArray(bbox) && Number(bbox[0]) > Number(bbox[2])) {
// A box across the date line comes as west > east: split it at ±180°.
const [west, south, east, north] = bbox.map(Number);
for (const half of [
[west, south, 180, north],
[-180, south, east, north],
]) {
const box = normalizeBbox(half);
if (box) tiles.push(...tileGrid(box, z).tiles);
}
centre = {
x: ((lonToTileX(west, z) + lonToTileX(east, z) + n) / 2) % n,
y: (latToTileY(north, z) + latToTileY(south, z)) / 2,
};
} else {
const box = normalizeBbox(bbox);
if (!box) return { tiles: [], truncated: false, total: 0 };
const grid = tileGrid(box, z);
tiles.push(...grid.tiles);
centre = { x: (grid.x0 + grid.x1) / 2, y: (grid.y0 + grid.y1) / 2 };
}
const distance2 = focus?.from
? (tile) => segmentDistance2(tile, focus.from, focus.to, z)
: (tile) => {
// Measured the short way round the date line.
const dx = Math.abs(tile.x - centre.x);
return Math.min(dx, n - dx) ** 2 + (tile.y - centre.y) ** 2;
};
// The date-line halves share a column when the gap between east and west is
// narrower than a tile; count that column once so a capped overview does not
// spend its slots on repeats. Each tile's distance is computed once; ties keep
// their row order.
const unique = [
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];
// The halves share a column when the gap between east and west is
// narrower than a tile: count it once, so a capped set holds no repeats.
const grid = [
...new Map(
tiles.map((tile) => [`${tile.x}/${tile.y}/${tile.z}`, tile]),
halves
.map(normalizeBbox)
.filter(Boolean)
.flatMap((box) => tileGrid(box, z))
.map((tile) => [`${tile.x}/${tile.y}`, tile]),
).values(),
];
const ranked = unique
.map((tile) => ({ tile, d: distance2(tile) }))
const n = 2 ** z;
const centre = from || {
lon: wrapLon(west + (east - west + (across ? 360 : 0)) / 2),
lat: (south + north) / 2,
};
const cx = tileXAt(wrapLon(centre.lon), z);
const cy = tileYAt(centre.lat, z);
// Each tile's distance is computed once; ties keep their row order.
const ranked = grid
.map((tile) => {
const dx = Math.abs(tile.x + 0.5 - cx);
const d = Math.min(dx, n - dx) ** 2 + (tile.y + 0.5 - cy) ** 2;
return { tile, d };
})
.sort((a, b) => a.d - b.d)
.map(({ tile }) => tile);
const truncated = ranked.length > limit;
return {
tiles: truncated ? ranked.slice(0, limit) : ranked,
truncated,
total: ranked.length,
};
return ranked.slice(0, limit);
}
/** The tiles at zoom z in a normalized box, row by row, and their range. */
/** The tiles at zoom z in a normalized box, row by row. */
function tileGrid(box, z) {
const x0 = lonToTileX(box.west, z);
const x1 = lonToTileX(box.east, z);
@@ -176,7 +129,7 @@ function tileGrid(box, z) {
const tiles = [];
for (let y = y0; y <= y1; y++)
for (let x = x0; x <= x1; x++) tiles.push({ x, y, z });
return { tiles, x0, x1, y0, y1 };
return tiles;
}
/** A vector-tile-local coordinate (0..extent) in tile (x, y, z) as [lon, lat]. */
@@ -198,17 +151,3 @@ export function coverageZoomForHeight(heightM) {
if (heightM > 1_800) return 13;
return 14;
}
/**
* Overview-point zoom (z0–5) above 60 km, where sequence tiles are too heavy;
* null below, where `coverageZoomForHeight` takes over.
*/
export function overviewZoomForHeight(heightM) {
if (!Number.isFinite(heightM) || heightM <= 60_000) return null;
if (heightM > 15_000_000) return 0;
if (heightM > 7_000_000) return 1;
if (heightM > 3_000_000) return 2;
if (heightM > 1_200_000) return 3;
if (heightM > 400_000) return 4;
return 5;
}
+28 -70
View File
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
coverageZoomForHeight,
overviewZoomForHeight,
latToTileY,
lonToTileX,
normalizeBbox,
@@ -38,31 +37,21 @@ test('normalizeBbox orders and clamps coordinates', () => {
north: 38.69,
});
assert.equal(normalizeBbox([0, 0, 0, 1]), null);
assert.equal(normalizeBbox('nope'), null);
assert.equal(normalizeBbox([1, 2, 3]), null);
assert.equal(normalizeBbox([1, 2, Number.NaN, 3]), null);
});
test('city-scale boxes span many z14 tiles', () => {
const sacramento = [-121.56, 38.44, -121.36, 38.69];
assert.equal(tilesForBbox(sacramento, 14).total, 160);
const detroit = [-83.29, 42.25, -82.91, 42.45];
assert.equal(tilesForBbox(detroit, 14).total, 234);
});
test('tilesForBbox orders from the centre outwards and honours the cap', () => {
const result = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14, {
const tiles = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14, {
limit: 5,
});
assert.equal(result.total, 160);
assert.equal(result.truncated, true);
assert.equal(result.tiles.length, 5);
assert.equal(tiles.length, 5);
const centreX = lonToTileX(-121.46, 14);
const centreY = latToTileY(38.565, 14);
assert.ok(Math.abs(result.tiles[0].x - centreX) <= 1);
assert.ok(Math.abs(result.tiles[0].y - centreY) <= 1);
assert.ok(Math.abs(tiles[0].x - centreX) <= 1);
assert.ok(Math.abs(tiles[0].y - centreY) <= 1);
const full = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14);
assert.equal(full.truncated, false);
assert.equal(full.tiles.length, 160);
assert.equal(full.length, 160);
});
test('coverageZoomForHeight steps from coarse to z14 as the camera descends', () => {
@@ -74,28 +63,18 @@ test('coverageZoomForHeight steps from coarse to z14 as the camera descends', ()
assert.equal(coverageZoomForHeight(Number.NaN), null);
});
test('overviewZoomForHeight covers the globe above the sequence ceiling', () => {
assert.equal(overviewZoomForHeight(30_000), null);
assert.equal(overviewZoomForHeight(100_000), 5);
assert.equal(overviewZoomForHeight(900_000), 4);
assert.equal(overviewZoomForHeight(2_000_000), 3);
assert.equal(overviewZoomForHeight(5_000_000), 2);
assert.equal(overviewZoomForHeight(10_000_000), 1);
assert.equal(overviewZoomForHeight(25_000_000), 0);
});
test('longitude 180 is the last column, so a world view keeps the eastern hemisphere', () => {
assert.equal(lonToTileX(180, 1), 1);
assert.equal(lonToTileX(179.99, 1), 1);
assert.equal(lonToTileX(-180, 1), 0);
const columns = new Set(
tilesForBbox([-180, -85, 180, 85], 1).tiles.map((tile) => tile.x),
tilesForBbox([-180, -85, 180, 85], 1).map((tile) => tile.x),
);
assert.deepEqual([...columns].sort(), [0, 1]);
});
test('a box across the date line takes tiles on both sides of it, not the far side of the globe', () => {
const { tiles } = tilesForBbox([179, -1, -179, 1], 8);
const tiles = tilesForBbox([179, -1, -179, 1], 8);
const columns = new Set(tiles.map((tile) => tile.x));
assert.ok(columns.has(255), 'west of the line');
assert.ok(columns.has(0), 'east of the line');
@@ -104,70 +83,49 @@ test('a box across the date line takes tiles on both sides of it, not the far si
'nothing from the middle of the map',
);
// The limit keeps the tiles nearest the line.
const limited = tilesForBbox([170, -1, -170, 1], 8, { limit: 4 }).tiles;
const limited = tilesForBbox([170, -1, -170, 1], 8, { limit: 4 });
assert.ok(limited.every((tile) => tile.x <= 8 || tile.x >= 247));
});
test('a box across the date line ranks the tiles at the line first, without and with a focus', () => {
test('a box across the date line ranks from the line, or from the camera across it', () => {
const z = 14;
const n = 2 ** z;
const bbox = [179.9, 0, -179.9, 0.1];
const key = (t) => `${t.x}/${t.y}`;
const keys = (list) => new Set(list.map(key));
const [north, south] = [latToTileY(0.1, z), latToTileY(0, z)];
const plain = tilesForBbox(bbox, z);
assert.equal(plain.total, 10 * (south - north + 1), '5 columns either side');
assert.equal(plain.length, 10 * (south - north + 1), '5 columns either side');
// The box centre is the date line itself, not the middle of the map: the
// middle two rows of the columns touching it come first, then the ring
// around them, all within two columns of the line.
const [mid1, mid2] = [(north + south - 1) / 2, (north + south + 1) / 2];
assert.deepEqual(
keys(plain.tiles.slice(0, 4)),
new Set([`${n - 1}/${mid1}`, `${n - 1}/${mid2}`, `0/${mid1}`, `0/${mid2}`]),
);
for (const tile of plain.tiles.slice(4, 12))
// columns touching it come first, then the ones beside them.
for (const tile of plain.slice(0, 4))
assert.ok([n - 1, 0].includes(tile.x), `${key(tile)} at the line`);
for (const tile of plain.slice(4, 12))
assert.ok(
[n - 2, n - 1, 0, 1].includes(tile.x),
`${key(tile)} near the line`,
);
// A line of sight across the date line just north of the equator: the two
// tiles it crosses come first, then the two in the row below them.
// From a camera metres from the line: its tile, then the one across it.
const row = latToTileY(0.05, z);
const from = { lon: 179.99, lat: 0.05 };
const to = { lon: -179.99, lat: 0.05 };
for (const focus of [
{ from, to },
{ from: to, to: from },
]) {
const ranked = tilesForBbox(bbox, z, { focus }).tiles;
assert.deepEqual(
keys(ranked.slice(0, 2)),
new Set([`${n - 1}/${row}`, `0/${row}`]),
);
assert.deepEqual(
keys(ranked.slice(2, 4)),
new Set([`${n - 1}/${row + 1}`, `0/${row + 1}`]),
);
}
const { south: rowSouth, north: rowNorth } = tileBounds(0, row, z);
const lat = (rowSouth + rowNorth) / 2;
const west = tilesForBbox(bbox, z, { from: { lon: 179.999, lat } });
assert.deepEqual(west.slice(0, 2).map(key), [`${n - 1}/${row}`, `0/${row}`]);
const east = tilesForBbox(bbox, z, { from: { lon: -179.999, lat } });
assert.deepEqual(east.slice(0, 2).map(key), [`0/${row}`, `${n - 1}/${row}`]);
});
test('a focus ranks tiles along the line of sight, not from the box centre', () => {
test('ranking from the camera keeps the ground under it, not the box centre', () => {
// A box that runs far north of the camera, as a tilted view's does: the
// box centre is kilometres from both the camera and the screen centre.
// box centre is kilometres from the camera.
const from = { lon: -121.4944, lat: 38.5816 };
const to = { lon: -121.4944, lat: 38.596 };
const bbox = [-121.6, 38.57, -121.38, 38.9];
const z = 14;
const key = (t) => `${t.x}/${t.y}`;
const under = `${lonToTileX(from.lon, z)}/${latToTileY(from.lat, z)}`;
const centre = `${lonToTileX(to.lon, z)}/${latToTileY(to.lat, z)}`;
const plain = tilesForBbox(bbox, z, { limit: 9 }).tiles.map(key);
assert.equal(plain.includes(under), false, 'the old ranking drops them');
const ranked = tilesForBbox(bbox, z, { limit: 9, focus: { from, to } });
const keys = ranked.tiles.map(key);
assert.ok(keys.includes(under), 'the tile under the camera');
assert.ok(keys.includes(centre), 'the tile at the centre of the screen');
assert.equal(ranked.total > 9, true);
const plain = tilesForBbox(bbox, z, { limit: 9 }).map(key);
assert.equal(plain.includes(under), false, 'the box centre drops it');
const ranked = tilesForBbox(bbox, z, { limit: 9, from });
assert.equal(key(ranked[0]), under, 'the tile under the camera first');
});
test('wrapLon wraps longitudes and their differences into [-180, 180]', () => {
+3 -29
View File
@@ -1,6 +1,5 @@
import { COLORS } from './policy.js';
import { msg, t } from '../../i18n/index.js';
/**
* @typedef {object} ProviderSnapshot
* @property {string} id
@@ -19,7 +18,6 @@ import { msg, t } from '../../i18n/index.js';
* @property {boolean} [groundClick]
* @property {Array<'pano'|'since'>} [filters]
* @property {{noun: string, action: string}|null} [coverageLabel]
* @property {boolean} [highlightsSequence] Its lines turn cyan when their sequence is selected.
* @property {'capture'|'upload'} [sinceBasis] What its SINCE compares.
*/
@@ -89,9 +87,8 @@ export function sinceNote(providers) {
}
/**
* The panel snapshot from core state and provider snapshots. The legend has
* one swatch per active source that draws coverage lines, then Selected.
* @param {{enabled: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
* The panel snapshot from core state and provider snapshots.
* @param {{enabled: boolean, groundClickReady?: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
*/
export function composeUIState({
enabled,
@@ -100,30 +97,9 @@ export function composeUIState({
providers,
street,
sequence,
surface = 'draped',
}) {
const active = providers.filter((p) => p.on);
const { keyRequired, keyRejected, ...coverage } =
summarizeCoverage(providers);
const legend = active
.filter((provider) => provider.groundClick !== true)
.map((provider) => ({
key: provider.id,
label: provider.name,
color: provider.color,
}));
// "Selected" only beside a source whose lines turn cyan when selected.
if (
active.some(
(provider) =>
provider.groundClick !== true && provider.highlightsSequence !== false,
)
)
legend.push({
key: 'selected',
label: t('Selected'),
color: COLORS.selected,
});
return {
enabled,
groundClickReady: groundClickReady === true,
@@ -135,9 +111,7 @@ export function composeUIState({
sinceNote: sinceNote(providers),
providers: providers.map((p) => ({ ...p })),
coverage,
legend,
sequence: { ...sequence },
street: { ...street },
surface,
};
}
+1 -79
View File
@@ -1,7 +1,6 @@
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 = {}) =>
@@ -47,66 +46,6 @@ test('the layer is key-gated only when every switched-on provider lacks its key'
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('Street View draws no lines: no legend swatch, and no Selected on its own', () => {
const streetView = provider({
id: 'google',
name: 'Google Street View',
color: '#4285f4',
count: 0,
groundClick: true,
filters: [],
});
assert.deepEqual(
composeUIState({ ...base, providers: [streetView] }).legend,
[],
);
assert.deepEqual(
composeUIState({
...base,
providers: [provider(), streetView],
}).legend.map((entry) => entry.key),
['mapillary', 'selected'],
);
});
test('the filter controls show only for a lit provider that honours them', () => {
const streetView = provider({ id: 'google', groundClick: true, filters: [] });
const controls = (providers) =>
@@ -194,18 +133,6 @@ test('a rejected key gates like a missing one and is named as rejected', () => {
);
});
test('the surface mode defaults to draped and passes through', () => {
assert.equal(
composeUIState({ ...base, providers: [provider()] }).surface,
'draped',
);
assert.equal(
composeUIState({ ...base, providers: [provider()], surface: 'terrain' })
.surface,
'terrain',
);
});
test('every switched-on provider’s hint is kept, once each', async () => {
const { summarizeCoverage } = await import('./uiState.js');
const { providerSnapshot } =
@@ -249,22 +176,17 @@ test('a key-gated layer offers the switched-off sources that would draw with a k
);
});
test('a source whose lines never show a selection lists no Selected swatch, and names its own lines', () => {
test('a source names its own lines', () => {
const kartaView = provider({
id: 'kartaview',
name: 'KartaView',
color: '#635bff',
highlightsSequence: false,
coverageLabel: {
noun: 'streets',
action: 'click a line for its nearest photo',
},
});
const ui = composeUIState({ ...base, providers: [kartaView] });
assert.deepEqual(
ui.legend.map((entry) => entry.key),
['kartaview'],
);
assert.deepEqual(ui.coverage.label, {
noun: 'streets',
action: 'click a line for its nearest photo',
+96 -109
View File
@@ -10,40 +10,42 @@ 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);
/** Whether a height (m) is plausible ellipsoidal land: Dead Sea to Everest, with geoid slack. */
export function plausibleSurfaceHeight(height) {
return Number.isFinite(height) && height >= -500 && height <= 9000;
}
/**
* Ellipsoidal height (m) of the surface under the camera: globe terrain, or
* `groundAt` where Google 3D hides the globe; null without a sample.
* @param {object} viewer
* @param {{groundAt?: (lon: number, lat: number) => number|null}} [options]
* Height (m) of the rendered surface, 3D tiles included, at `carto`; null
* when unsampleable or implausible (`sampleHeight` reads kilometres
* underground before tiles load).
*/
export function groundUnderCamera(viewer, { groundAt } = {}) {
const carto = viewer?.camera?.positionCartographic;
if (!carto) return null;
const globe = viewer.scene?.globe;
let ground = globe?.show === false ? null : globe?.getHeight?.(carto);
if (!Number.isFinite(ground))
ground = groundAt?.(
Cesium.Math.toDegrees(carto.longitude),
Cesium.Math.toDegrees(carto.latitude),
);
return Number.isFinite(ground) ? ground : null;
export function sampledSurfaceHeight(scene, carto) {
let height = null;
try {
if (scene?.sampleHeightSupported) height = scene.sampleHeight(carto);
} catch {
/* not sampleable yet */
}
return plausibleSurfaceHeight(height) ? height : null;
}
/** Camera height (m) above the ground under it (options as `groundUnderCamera`). */
export function cameraHeightAboveGround(viewer, options = {}) {
/**
* Ellipsoidal height (m) of the ground under the camera, or null. Under
* Google 3D (globe hidden) the rendered surface counts, minus any roof above
* the camera.
*/
export function groundUnderCamera(viewer) {
const carto = viewer?.camera?.positionCartographic;
if (!carto) return null;
return carto.height - (groundUnderCamera(viewer, options) ?? 0);
const scene = viewer?.scene;
if (!carto || !scene) return null;
const globe = scene.globe;
if (globe && globe.show !== false) {
const ground = globe.getHeight?.(carto);
if (Number.isFinite(ground)) return ground;
}
const sampled = sampledSurfaceHeight(scene, carto);
return sampled !== null && sampled < carto.height ? sampled : null;
}
/** The ellipsoid `height` metres above `ellipsoid` (the ground at that height). */
@@ -74,16 +76,24 @@ function groundHit(camera, point, ellipsoid, maxRange) {
return Cesium.Cartographic.fromCartesian(cartesian, ellipsoid) || null;
}
/** Camera height (m) above the ground under it. */
export function cameraHeightAboveGround(viewer) {
const carto = viewer?.camera?.positionCartographic;
if (!carto) return null;
return carto.height - (groundUnderCamera(viewer) ?? 0);
}
/** Screen rays per side, less one, that `visibleBbox` casts. */
const BBOX_GRID = 5;
/**
* Visible [west, south, east, north] degrees, or null. Only screen rays that
* hit count, so a horizon cannot inflate the box to the world.
* `groundHeight` raises the ellipsoid to the ground (Google 3D hides the
* globe), `maxRange` (m) drops near-horizon hits, and `nearRange` (m) always
* includes the ground around the camera.
* Visible [west, south, east, north] degrees from the rays that hit the
* ground, or null when fewer than four do. `groundHeight` raises the ellipsoid
* to the ground (Denver is 1,600 m up); `maxRange` (m) drops far hits.
*/
export function visibleBbox(
viewer,
{ grid = 5, groundHeight = null, maxRange = null, nearRange = null } = {},
{ groundHeight = null, maxRange = null } = {},
) {
const scene = viewer?.scene;
const camera = viewer?.camera;
@@ -97,36 +107,15 @@ export function visibleBbox(
groundHeight,
);
const point = new Cesium.Cartesian2();
for (let i = 0; i <= grid; i++) {
for (let j = 0; j <= grid; j++) {
point.x = (width * i) / grid;
point.y = (height * j) / grid;
for (let i = 0; i <= BBOX_GRID; i++) {
for (let j = 0; j <= BBOX_GRID; j++) {
point.x = (width * i) / BBOX_GRID;
point.y = (height * j) / BBOX_GRID;
const carto = groundHit(camera, point, ellipsoid, maxRange);
if (carto) hits.push(carto);
}
}
}
// The ground around the camera always counts: a street-level view's screen
// rows skip from the horizon to the first few metres.
const nadir = camera.positionCartographic;
if (Number.isFinite(nearRange) && nearRange > 0 && nadir) {
const dLat = nearRange / 111_320;
const dLon = dLat / Math.max(0.05, Math.cos(nadir.latitude));
const lat = Cesium.Math.toDegrees(nadir.latitude);
const lon = Cesium.Math.toDegrees(nadir.longitude);
for (const [dx, dy] of [
[-1, -1],
[-1, 1],
[1, -1],
[1, 1],
])
hits.push(
Cesium.Cartographic.fromDegrees(
lon + dx * dLon,
Math.max(-85, Math.min(85, lat + dy * dLat)),
),
);
}
if (hits.length >= 4) {
let west = Infinity;
let south = Infinity;
@@ -159,78 +148,57 @@ export function visibleBbox(
}
if (west !== east && north - south > 0) return [west, south, east, north];
}
const rectangle = camera.computeViewRectangle?.(scene.globe?.ellipsoid);
if (!rectangle) return null;
return [
Cesium.Math.toDegrees(rectangle.west),
Cesium.Math.toDegrees(rectangle.south),
Cesium.Math.toDegrees(rectangle.east),
Cesium.Math.toDegrees(rectangle.north),
];
return null;
}
/** Centre of the visible ground, or the camera's own footprint. */
export function viewCentre(viewer) {
if (!viewer) return null;
const bbox = visibleBbox(viewer);
if (bbox) {
// Across the date line (west > east) the middle is on the far side of 0°.
const span = bbox[2] - bbox[0] + (bbox[0] > bbox[2] ? 360 : 0);
let lon = bbox[0] + span / 2;
if (lon > 180) lon -= 360;
return { lat: (bbox[1] + bbox[3]) / 2, lon };
}
const carto = viewer.camera?.positionCartographic;
/** The point on the ground under the camera, as {lon, lat} degrees. */
export function cameraNadir(viewer) {
const carto = viewer?.camera?.positionCartographic;
if (!carto) return null;
return {
lat: Cesium.Math.toDegrees(carto.latitude),
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
};
}
/**
* Ground the camera looks at, for ranking tiles: `nadir` under the camera and
* `ahead` at the screen centre (null on a miss or beyond `maxRange`).
* Where coverage tiles are ranked from: halfway between the nadir and the
* screen centre's ground, or the nadir when that is out of `maxRange`.
*/
export function viewFocus(
viewer,
{ groundHeight = null, maxRange = null } = {},
) {
const camera = viewer?.camera;
const carto = camera?.positionCartographic;
if (!carto) return null;
const nadir = {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
const nadir = cameraNadir(viewer);
const scene = viewer?.scene;
const width = scene?.canvas?.clientWidth || scene?.canvas?.width || 0;
const height = scene?.canvas?.clientHeight || scene?.canvas?.height || 0;
if (!nadir || !width || !height) return nadir;
const hit = groundHit(
viewer.camera,
new Cesium.Cartesian2(width / 2, height / 2),
raisedEllipsoid(
scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84,
groundHeight,
),
maxRange,
);
if (!hit) return nadir;
const ahead = {
lon: Cesium.Math.toDegrees(hit.longitude),
lat: Cesium.Math.toDegrees(hit.latitude),
};
return {
lon: wrapLon(nadir.lon + wrapLon(ahead.lon - nadir.lon) / 2),
lat: (nadir.lat + ahead.lat) / 2,
};
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 };
}
/**
* Horizon occluder depth below WGS84 (m): ground can lie below the ellipsoid
* (NYC −22 m), and an occluder at 0 m would hide the cones around it.
*/
export const HORIZON_CULL_DEPTH_M = 1000;
const HORIZON_CULL_DEPTH_M = 1000;
const HORIZON_CULL_ELLIPSOID = new Cesium.Ellipsoid(
Cesium.Ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M,
Cesium.Ellipsoid.WGS84.radii.y - HORIZON_CULL_DEPTH_M,
@@ -295,3 +263,22 @@ export function createHorizonCull({ getViewer, items, onChange }) {
return { update, stop };
}
/** The middle of the visible ground, else the point under the camera; null without a camera. */
export function viewCentre(viewer) {
if (!viewer) return null;
const bbox = visibleBbox(viewer);
if (bbox) {
// Across the date line (west > east) the middle is on the far side of 0°.
const span = bbox[2] - bbox[0] + (bbox[0] > bbox[2] ? 360 : 0);
let lon = bbox[0] + span / 2;
if (lon > 180) lon -= 360;
return { lat: (bbox[1] + bbox[3]) / 2, lon };
}
const carto = viewer.camera?.positionCartographic;
if (!carto) return null;
return {
lat: Cesium.Math.toDegrees(carto.latitude),
lon: Cesium.Math.toDegrees(carto.longitude),
};
}
+100 -35
View File
@@ -2,13 +2,14 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
cameraHeightAboveGround,
cameraNadir,
createHorizonCull,
groundUnderCamera,
metresBetween,
viewCentre,
viewFocus,
visibleBbox,
} from './view.js';
import { latToTileY, lonToTileX, tilesForBbox } from './tileMath.js';
import { rayCamera } from '../../testSupport/streetLevelFakes.mjs';
const RAD = Math.PI / 180;
@@ -28,28 +29,42 @@ function viewer({ globeShown, globeHeight }) {
test('a shown globe answers the ground under the camera', () => {
assert.equal(
cameraHeightAboveGround(viewer({ globeShown: true, globeHeight: 1600 })),
300,
groundUnderCamera(viewer({ globeShown: true, globeHeight: 1600 })),
1600,
);
});
test('a hidden globe (Google 3D) falls back to the bare-earth height', () => {
const calls = [];
const groundAt = (lon, lat) => {
calls.push([+lon.toFixed(2), +lat.toFixed(2)]);
return 1600;
};
test('a hidden globe (Google 3D) or a missing sample gives no ground', () => {
// A hidden globe's getHeight is ignored even when it returns a number.
const hidden = viewer({ globeShown: false, globeHeight: 0 });
assert.equal(cameraHeightAboveGround(hidden, { groundAt }), 300);
assert.deepEqual(calls, [[-104.99, 39.74]]);
assert.equal(
groundUnderCamera(viewer({ globeShown: false, globeHeight: 0 })),
null,
);
assert.equal(
groundUnderCamera(viewer({ globeShown: true, globeHeight: undefined })),
null,
);
assert.equal(groundUnderCamera({}), null);
});
test('with no ground sample the ellipsoidal height is used', () => {
const hidden = viewer({ globeShown: false, globeHeight: 0 });
assert.equal(cameraHeightAboveGround(hidden), 1900);
assert.equal(cameraHeightAboveGround(hidden, { groundAt: () => null }), 1900);
assert.equal(cameraHeightAboveGround({}), null);
/** The Denver viewer with Google 3D: no globe, a sampleable rendered surface. */
function google3d(sample) {
const view = viewer({ globeShown: false, globeHeight: 0 });
view.scene.sampleHeightSupported = true;
view.scene.sampleHeight = () => sample;
return view;
}
test('on Google 3D the rendered surface answers the ground under the camera', () => {
assert.equal(groundUnderCamera(google3d(1612)), 1612);
});
test('an implausible or above-camera surface sample gives no ground', () => {
// Before tiles stream in, sampleHeight can read kilometres underground.
assert.equal(groundUnderCamera(google3d(-14_886)), null);
// A roof above the camera (1,900 m) would put the camera underground.
assert.equal(groundUnderCamera(google3d(1950)), null);
assert.equal(groundUnderCamera(google3d(undefined)), null);
});
/** A camera whose screen rays land on a grid of lon/lat points. */
@@ -73,16 +88,11 @@ test('a view across the date line is a narrow box with west > east', () => {
const bbox = visibleBbox(viewer);
assert.ok(bbox[0] > 177 && bbox[0] < 179, `west ${bbox[0]}`);
assert.ok(bbox[2] < -177 && bbox[2] > -179, `east ${bbox[2]}`);
const centre = viewCentre(viewer);
assert.ok(Math.abs(Math.abs(centre.lon) - 180) < 0.5, `centre ${centre.lon}`);
});
test('an ordinary view keeps west < east', () => {
const bbox = visibleBbox(gridViewer([10, 11, 12], [50, 51]));
assert.ok(bbox[0] < bbox[2]);
assert.ok(
Math.abs(viewCentre(gridViewer([10, 11, 12], [50, 51])).lon - 11) < 1e-6,
);
});
/** A pinhole viewer `agl` m above ground `ground` m up, looking north. */
@@ -119,20 +129,66 @@ test('a tilted street view over high ground boxes the streets it looks at, not t
// the box is 20 km wide and starts past the street at the screen centre.
assert.ok(unranged[2] - unranged[0] > 0.2);
assert.ok(unranged[1] > ahead, 'the old box missed the street in view');
const options = { groundHeight: 1610, maxRange: 6000, nearRange: 1000 };
const options = { groundHeight: 1610, maxRange: 6000 };
const [west, south, east, north] = visibleBbox(view, options);
assert.ok(north - south < 0.08 && east - west < 0.08, 'a street-sized box');
assert.ok(south < 39.74 && north > ahead, 'camera and screen centre inside');
const focus = viewFocus(view, options);
assert.ok(Math.abs(focus.nadir.lat - 39.74) < 1e-9);
assert.ok(south < ahead && north > ahead, 'the screen centre is inside');
assert.ok(
Math.abs(focus.ahead.lat - ahead) < 0.002,
'centre ray on the street',
south > 39.74,
'it starts ahead of the camera, where the view does',
);
assert.ok(west < -104.99 && east > -104.99);
const nadir = cameraNadir(view);
assert.ok(
Math.abs(nadir.lat - 39.74) < 1e-9 && Math.abs(nadir.lon + 104.99) < 1e-9,
);
assert.ok(Math.abs(focus.ahead.lon - -104.99) < 1e-6);
});
test('looking at the horizon from eye height still boxes the ground around the camera', () => {
test('a tilted view from 3 km loads the tile at the centre of the screen', () => {
// 3 km up, 70° from straight down: the screen centre meets the ground
// 8.2 km ahead, past the 3 × 3 z13 tiles around the camera's ground point.
const lon = -121.49;
const lat = 38.58;
const view = pinhole({ lon, lat, ground: 10, agl: 3000, pitch: -20 });
const ranged = { groundHeight: 10, maxRange: 30_000 };
const bbox = visibleBbox(view, ranged);
const ahead = lat + 3000 / Math.tan((20 * Math.PI) / 180) / 110_540;
const centreTile = `${lonToTileX(lon, 13)}/${latToTileY(ahead, 13)}`;
const chosen = (from) =>
tilesForBbox(bbox, 13, { limit: 9, from }).map(
(tile) => `${tile.x}/${tile.y}`,
);
assert.ok(
!chosen(cameraNadir(view)).includes(centreTile),
'ranked from the nadir alone, the screen centre is left out',
);
const focus = viewFocus(view, ranged);
assert.ok(focus.lat > lat && focus.lat < ahead, 'between camera and centre');
assert.ok(chosen(focus).includes(centreTile), 'the screen centre is loaded');
// The bottom edge of the screen (50° down) meets the ground 2.5 km ahead.
const nearest = lat + 3000 / Math.tan((50 * Math.PI) / 180) / 110_540;
assert.ok(
chosen(focus).includes(`${lonToTileX(lon, 13)}/${latToTileY(nearest, 13)}`),
'and so is the nearest ground in view',
);
});
test('looking straight down or at the sky, the focus is the ground under the camera', () => {
const lon = -121.49;
const lat = 38.58;
const down = viewFocus(
pinhole({ lon, lat, ground: 10, agl: 500, pitch: -90 }),
{ groundHeight: 10, maxRange: 5000 },
);
assert.ok(Math.abs(down.lat - lat) < 1e-4 && Math.abs(down.lon - lon) < 1e-4);
const sky = viewFocus(pinhole({ lon, lat, ground: 10, agl: 2, pitch: 60 }), {
groundHeight: 10,
maxRange: 2500,
});
assert.ok(Math.abs(sky.lat - lat) < 1e-9 && Math.abs(sky.lon - lon) < 1e-9);
});
test('looking at the horizon from eye height boxes the near ground, not the horizon', () => {
const view = pinhole({
lon: -121.4944,
lat: 38.5816,
@@ -143,10 +199,19 @@ test('looking at the horizon from eye height still boxes the ground around the c
const [west, south, east, north] = visibleBbox(view, {
groundHeight: 10,
maxRange: 2500,
nearRange: 1000,
});
assert.ok(north - 38.5816 > 0.008 && 38.5816 - south > 0.008);
assert.ok(east - -121.4944 > 0.008 && -121.4944 - west > 0.008);
assert.ok(south > 38.5816 && south - 38.5816 < 0.001, 'from just ahead');
assert.ok(north - 38.5816 < 2500 / 111_000, 'no further than the range');
assert.ok(west < -121.4944 && east > -121.4944);
// Looking at the sky, nothing is in range: no box, never the horizon's.
const sky = pinhole({
lon: -121.4944,
lat: 38.5816,
ground: 10,
agl: 2,
pitch: 60,
});
assert.equal(visibleBbox(sky, { groundHeight: 10, maxRange: 2500 }), null);
});
test('metresBetween measures the short way round the date line', () => {
@@ -285,7 +350,7 @@ function cullFrom(camera, points) {
}
test('cones and the marker stay visible on ground below the WGS84 ellipsoid', () => {
// NYC in FOLLOW: eye 2.4 m above ground at -22 m, a cone 10 m away.
// NYC at street level: eye 2.4 m above ground at -22 m, a cone 10 m away.
assert.deepEqual(
cullFrom([-74.006, 40.7128, -19.6], [[-74.006, 40.71289, -22]]),
[true],
+48 -111
View File
@@ -2,35 +2,27 @@ import { msg } from '../../i18n/index.js';
import { freshStreet } from './state.js';
/**
* Owns the panel's viewer element and the provider adapter mounted in it.
* Adapter poses update `state.street`, the marker and the follow camera.
* The panel's photo viewer element and the provider adapter mounted in it.
* Adapter poses update `state.street` and the marker.
*/
export function createViewerHost({ state, parts }) {
/** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */
let active = null;
/** @type {{id: string, promise: Promise<object>}|null} */
/** The mount in flight, shared by concurrent opens. @type {{id: string, promise: Promise<object>}|null} */
let mounting = null;
let openSeq = 0;
/** Prewarmed, inactive adapters; each holds a live viewer (WebGL context) until `unmount`. */
const warmed = new Map();
function notify() {
state.notify?.();
}
function providerEntry(providerId) {
return state.providers.get(providerId) || null;
}
function applyPose(pose) {
if (!active || !state.street.open || pose.providerId !== active.id) return;
if (!active || !state.street.open) return;
const previousSequence = state.street.sequenceId;
Object.assign(state.street, {
providerId: pose.providerId,
imageId: pose.imageId,
position: pose.position ? { ...pose.position } : null,
bearing: Number.isFinite(pose.bearing) ? pose.bearing : null,
tilt: Number.isFinite(pose.tilt) ? pose.tilt : 0,
altitude: Number.isFinite(pose.altitude) ? pose.altitude : null,
isPano: pose.isPano === true,
capturedAt: pose.capturedAt ?? null,
@@ -49,9 +41,8 @@ export function createViewerHost({ state, parts }) {
parts.marker.set(
state.street.position,
state.street.bearing,
providerEntry(pose.providerId)?.def.marker,
state.providers.get(active.id)?.def.marker,
);
parts.follow.followCamera();
// Select the sequence only once the image is on screen, so its lookup
// never competes with the image download.
if (!state.street.loading && state.street.sequenceId !== previousSequence)
@@ -61,67 +52,58 @@ export function createViewerHost({ state, parts }) {
function selectCurrentSequence() {
const { sequenceId, providerId } = state.street;
const entry = providerEntry(providerId);
const entry = state.providers.get(providerId);
if (!sequenceId || !entry?.instance.selectSequence || !state.enabled)
return;
const current = entry.instance.sequenceStats?.()?.selectedId;
if (current !== sequenceId) entry.instance.selectSequence(sequenceId);
if (entry.instance.sequenceStats?.()?.selectedId !== sequenceId)
entry.instance.selectSequence(sequenceId);
}
/**
* Mount a provider's adapter. It becomes `active` only on success, so a
* failed mount is retried on the next open; concurrent opens share one.
* Mount a provider's adapter, releasing another provider's first. It
* counts as mounted only on success, so a failed mount is retried on the
* next open; concurrent opens share one.
*/
function mount(entry) {
if (active?.id === entry.def.id) return Promise.resolve(active.adapter);
if (mounting?.id === entry.def.id) return mounting.promise;
const id = entry.def.id;
if (active?.id === id) return Promise.resolve(active.adapter);
if (mounting?.id === id) return mounting.promise;
if (active) {
active.adapter.close();
active.unsubscribe();
active.adapter.unmount();
active = null;
}
// A prewarmed viewer would sit under this one, holding its WebGL context.
for (const [id, warm] of warmed) {
if (id === entry.def.id) continue;
warmed.delete(id);
warm.unmount();
}
const adapter = entry.instance.viewer;
const promise = (async () => {
await adapter.mount(state.street.host);
if (mounting?.promise !== promise) {
// Unmounted (layer off, provider switched) while loading. A newer
// mount of this provider shares the adapter and its viewer: tearing
// it down here would leave that mount active with no viewer.
const reused =
active?.id === entry.def.id || mounting?.id === entry.def.id;
if (!reused) adapter.unmount();
// mount of this provider shares the adapter: tearing it down here
// would leave that mount without a viewer.
if (active?.id !== id && mounting?.id !== id) adapter.unmount();
throw new Error('Street-level viewer was closed');
}
// Listen only once current: an outdated mount releasing the same
// `applyPose` would otherwise unhook the mount that replaced it.
const unsubscribe = adapter.onPose(applyPose);
active = { id: entry.def.id, adapter, unsubscribe };
warmed.delete(entry.def.id);
active = { id, adapter, unsubscribe: adapter.onPose(applyPose) };
adapter.setRenderMode?.(state.street.renderMode);
return adapter;
})();
mounting = { id: entry.def.id, promise };
promise.then(
() => {
if (mounting?.promise === promise) mounting = null;
},
() => {
if (mounting?.promise === promise) mounting = null;
},
);
mounting = { id, adapter, promise };
const settle = () => {
if (mounting?.promise === promise) mounting = null;
};
promise.then(settle, settle);
return promise;
}
/** Open an image: true once its first pose is in, false if it failed or was overtaken. */
/**
* Open an image: true once its first pose is in, false if it failed or was
* overtaken. `frame: false` leaves the camera where it is.
*/
async function open(providerId, imageId, { frame = true } = {}) {
const entry = providerEntry(providerId);
const entry = state.providers.get(providerId);
if (!entry || !imageId) return false;
if (!state.street.host) {
// A nearest-image lookup may have set it; nothing is loading now.
@@ -134,8 +116,7 @@ export function createViewerHost({ state, parts }) {
const seq = ++openSeq;
const current = () => seq === openSeq;
// Claimed now, honoured after loading only if nothing newer took the camera.
const ticket =
frame && !state.street.follow ? parts.follow.beginFraming() : null;
const ticket = frame ? parts.framing.begin() : null;
Object.assign(state.street, {
loading: true,
error: null,
@@ -152,7 +133,7 @@ export function createViewerHost({ state, parts }) {
state.street.renderModes = typeof adapter.setRenderMode === 'function';
await adapter.open(String(imageId));
if (!current()) return false;
if (ticket && !state.street.follow) parts.follow.lookAtPosition(ticket);
if (ticket) parts.framing.frame(ticket);
} catch (error) {
if (current())
state.street.error = error?.message || msg('Image could not be opened');
@@ -165,10 +146,10 @@ export function createViewerHost({ state, parts }) {
return true;
}
/** Close the image and stop any framing flight; the adapter stays warm. */
/** Close the image and stop any framing flight; the adapter stays mounted. */
function close() {
openSeq++;
parts.follow.cancelFraming();
parts.framing.cancel();
active?.adapter.close();
Object.assign(state.street, freshStreet());
parts.marker.clear();
@@ -181,74 +162,30 @@ export function createViewerHost({ state, parts }) {
}
function resize() {
try {
active?.adapter.resize();
} catch {
/* no-op */
}
active?.adapter.resize();
}
function setRenderMode(mode) {
state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox';
try {
active?.adapter.setRenderMode?.(state.street.renderMode);
} catch {
/* adapter not ready */
}
active?.adapter.setRenderMode?.(state.street.renderMode);
notify();
}
/** Stand active providers' viewers up ahead of the first image. */
async function prewarm(entries) {
const host = state.street.host;
if (!host || !state.enabled) return;
for (const entry of entries) {
const adapter = entry.instance.viewer;
if (!adapter.prewarm || !entry.on) continue;
// Another provider's viewer is showing in the host: do not build under it.
if (active && active.id !== entry.def.id) continue;
try {
await adapter.prewarm(host);
} catch {
/* the real open reports errors */
}
if (active?.id === entry.def.id || mounting?.id === entry.def.id)
continue;
// Switched off, or another viewer took the host meanwhile.
if (!state.enabled || !entry.on || active || mounting) adapter.unmount();
else warmed.set(entry.def.id, adapter);
}
}
/** Tear down every viewer, or only `providerId`'s (active or prewarmed). */
/**
* Close the image and destroy the viewer (and its WebGL context): every
* provider's, or only `providerId`'s. A mount still in flight is told too,
* so it builds nothing when its library lands.
*/
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();
}
if (providerId && active?.id !== providerId && mounting?.id !== providerId)
return;
close();
const pending = mounting;
mounting = null;
active?.unsubscribe();
(active?.adapter ?? pending?.adapter)?.unmount();
active = null;
}
return {
attach,
open,
close,
resize,
setRenderMode,
prewarm,
unmount,
};
return { attach, open, close, resize, setRenderMode, unmount };
}
+38 -158
View File
@@ -32,53 +32,55 @@ const { createMapillaryViewer } =
/**
* A viewer host over `adapter`. `marker` records where the position marker
* was put (null once cleared), `followed` counts camera-follow updates and
* `selected` every sequence the provider was asked to select.
* was put (null once cleared), `framing` counts framing calls and
* `selected` every sequence the layer was asked to select.
*/
function harness(adapter) {
const selected = [];
const state = {
enabled: true,
notify() {},
providers: new Map(),
street: { host: {}, renderMode: 'letterbox', follow: false },
street: { host: {}, renderMode: 'letterbox' },
sequence: { selectedId: null },
providers: new Map([
[
'mapillary',
{
def: { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' },
instance: {
viewer: adapter,
selectSequence(id) {
selected.push(id);
state.sequence.selectedId = id;
},
sequenceStats: () => ({ selectedId: state.sequence.selectedId }),
},
},
],
]),
};
const def = { id: 'mapillary', name: 'Mapillary', label: 'MAPILLARY' };
const selected = [];
let selectedId = null;
const instance = {
viewer: adapter,
selectSequence(id) {
selected.push(id);
selectedId = id;
},
sequenceStats: () => ({ selectedId }),
};
state.providers.set('mapillary', { def, instance });
const framing = { begun: 0, started: 0, cancelled: 0 };
const marker = [];
const followed = { count: 0 };
const parts = {
marker: {
set: (position, bearing) =>
marker.push(position && { position, bearing }),
clear: () => marker.push(null),
},
follow: {
followCamera: () => followed.count++,
beginFraming: () => ({ generation: ++framing.begun }),
lookAtPosition: (ticket) => ticket && framing.started++,
cancelFraming: () => framing.cancelled++,
framing: {
begin: () => ({ generation: ++framing.begun }),
frame: (ticket) => ticket && framing.started++,
cancel: () => framing.cancelled++,
},
};
return {
state,
framing,
marker,
followed,
selected,
/** Esc on the map: the provider drops its selected sequence. */
/** Esc on the map: the selected sequence is dropped. */
clearSequence() {
selectedId = null;
state.sequence.selectedId = null;
},
host: createViewerHost({ state, parts }),
};
@@ -105,7 +107,6 @@ function fakeAdapter({ failMounts = 0, gate = null, openGate = null } = {}) {
/** A pose event from the library, as MapillaryJS fires them. */
emitPose(id, overrides = {}) {
emit?.({
providerId: 'mapillary',
imageId: id,
sequenceId: `seq-${id}`,
position: { lon: 1, lat: 2 },
@@ -168,7 +169,7 @@ test('unmounting while a mount is in flight does not leave it active', async ()
host.unmount();
await opening;
assert.equal(adapter.calls.listeners, 0);
assert.equal(adapter.calls.unmount, 1);
assert.equal(adapter.calls.unmount, 2, 'layer off, then the late mount');
assert.equal(state.street.open, false);
// Not left active: the next open mounts again.
await host.open('mapillary', 'b');
@@ -176,46 +177,6 @@ test('unmounting while a mount is in flight does not leave it active', async ()
assert.equal(state.street.imageId, 'b');
});
test('a prewarmed viewer is released when the layer goes off, or at once if it went off mid-load', async () => {
const adapter = fakeAdapter();
let release = null;
adapter.prewarm = () =>
new Promise((resolve) => {
release = resolve;
});
const { state, host } = harness(adapter);
const entry = state.providers.get('mapillary');
entry.on = true;
// Warmed, then the layer is switched off: unmount releases the warm viewer.
const warming = host.prewarm([entry]);
release();
await warming;
assert.equal(adapter.calls.unmount, 0);
host.unmount();
assert.equal(adapter.calls.unmount, 1, 'the WebGL viewer is destroyed');
// Switched off while the library was still loading: released on arrival.
const late = host.prewarm([entry]);
state.enabled = false;
release();
await late;
assert.equal(adapter.calls.unmount, 2);
});
test('switching one provider off releases only its viewer', async () => {
const adapter = fakeAdapter();
adapter.prewarm = async () => {};
const { state, host } = harness(adapter);
const entry = state.providers.get('mapillary');
entry.on = true;
await host.prewarm([entry]);
host.unmount('panoramax');
assert.equal(adapter.calls.unmount, 0, 'another provider leaves it alone');
host.unmount('mapillary');
assert.equal(adapter.calls.unmount, 1);
});
test('a pose that arrives after the photo was closed does not bring it back', async () => {
const adapter = fakeAdapter();
const { state, host } = harness(adapter);
@@ -238,44 +199,23 @@ function deferred() {
return { promise, resolve };
}
test('switching the layer off and on during a cold first open keeps the shared viewer', async () => {
const gate = deferred();
const adapter = fakeAdapter({ gate });
const { state, host } = harness(adapter);
const first = host.open('mapillary', 'a'); // the library is still loading
host.unmount(); // layer off …
const second = host.open('mapillary', 'b'); // … on again, and a new click
gate.resolve();
await Promise.all([first, second]);
assert.equal(adapter.calls.unmount, 0, 'the outdated mount left it alone');
assert.equal(adapter.calls.listeners, 1);
assert.equal(state.street.error, null);
assert.equal(state.street.imageId, 'b');
await host.open('mapillary', 'c');
assert.equal(state.street.error, null, 'later opens are not stuck');
assert.equal(state.street.imageId, 'c');
});
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
test('every pose the viewer reports moves the marker and the followed camera', async () => {
test('every pose the viewer reports moves the marker; only the open frames the camera', async () => {
const adapter = fakeAdapter();
const { state, host, marker, followed } = harness(adapter);
const { host, marker, framing } = harness(adapter);
await host.open('mapillary', 'a');
assert.deepEqual(marker, [{ position: { lon: 1, lat: 2 }, bearing: 90 }]);
assert.equal(followed.count, 1);
// The user walks on, with FOLLOW on: the globe camera goes along.
state.street.follow = true;
// The user walks on: the marker goes along, the globe camera stays.
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 180 });
assert.deepEqual(marker.at(-1), {
position: { lon: 3, lat: 4 },
bearing: 180,
});
assert.equal(followed.count, 2);
// Looking around in place is a pose too.
adapter.emitPose('b', { position: { lon: 3, lat: 4 }, bearing: 270 });
assert.equal(marker.at(-1).bearing, 270);
assert.equal(followed.count, 3);
assert.deepEqual([framing.begun, framing.started], [1, 1]);
});
test('a pose on another sequence selects it, but not while the image still loads', async () => {
@@ -445,27 +385,17 @@ test('a Mapillary viewer unmounted mid-download is never built; the next mount b
assert.equal(viewers.live, 0, 'nothing left holding a WebGL context');
});
test('a prewarm that gave up when the layer went off and on mid-download is followed by one that builds', async () => {
test('two mounts on one host during the first download build one Mapillary viewer', 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
const first = adapter.mount(element);
const second = adapter.mount(element);
gate.resolve();
await Promise.all([first, second]);
assert.equal(viewers.created, 1, 'the second prewarm built the viewer');
assert.equal(viewers.live, 1);
adapter.unmount();
});
test('the Mapillary viewer leaves resizing to the panel, so a hidden one never asks for z=NaN', async () => {
const { gate, viewers } = fakeLibrary();
gate.resolve();
const adapter = createMapillaryViewer({ source: { token: 't' } });
await adapter.mount({});
assert.equal(viewers.instances[0].options.trackResize, false);
assert.equal(viewers.created, 1);
adapter.unmount();
assert.equal(viewers.live, 0, 'nothing left holding a WebGL context');
});
test('closing the photo stops sequence playback in the hidden viewer', async () => {
@@ -481,20 +411,6 @@ test('closing the photo stops sequence playback in the hidden viewer', async ()
assert.equal(viewers.live, 1, 'the viewer itself stays warm');
});
test('closing the photo or switching the layer off stops its framing flight', async () => {
const adapter = fakeAdapter();
const { framing, host } = harness(adapter);
await host.open('mapillary', 'a');
assert.equal(framing.started, 1, 'the open framed the photo');
host.close();
assert.equal(framing.cancelled, 1, 'closed: the globe stops flying to it');
await host.open('mapillary', 'b');
host.unmount('panoramax');
assert.equal(framing.cancelled, 1, 'another provider leaves it alone');
host.unmount();
assert.equal(framing.cancelled, 2, 'layer off: likewise');
});
/** The real Mapillary adapter, mounted on a ready library, and its poses. */
async function mountedMapillary() {
const library = fakeLibrary();
@@ -562,7 +478,7 @@ test('back-to-back Mapillary opens download only the newer image', async () => {
test('a photo asks for the camera when it starts opening and frames with that ticket once loaded', async () => {
const openGate = deferred();
const adapter = fakeAdapter({ openGate });
const { state, host, framing } = harness(adapter);
const { host, framing } = harness(adapter);
const opening = host.open('mapillary', 'a');
assert.equal(framing.begun, 1, 'claimed before the download');
await settle();
@@ -570,11 +486,6 @@ test('a photo asks for the camera when it starts opening and frames with that ti
openGate.resolve();
assert.equal(await opening, true);
assert.equal(framing.started, 1);
// FOLLOW owns the camera already: an open neither claims nor frames.
state.street.follow = true;
assert.equal(await host.open('mapillary', 'b'), true);
assert.deepEqual([framing.begun, framing.started], [1, 1]);
});
/** The harness with a second provider, `google`, whose adapter has no render modes. */
@@ -594,20 +505,6 @@ function twoProviders() {
return { ...h, mapillary, google };
}
test("opening one provider's image releases another's prewarmed viewer in the shared host", async () => {
const { state, host, mapillary, google } = twoProviders();
await host.prewarm([state.providers.get('mapillary')]);
assert.equal(mapillary.calls.unmount, 0, 'warm');
await host.open('google', 'g-1');
assert.equal(mapillary.calls.unmount, 1, 'not left under the panorama');
assert.equal(google.calls.unmount, 0);
// While Google shows, Mapillary is not built again under it.
let built = 0;
mapillary.prewarm = async () => built++;
await host.prewarm([...state.providers.values()]);
assert.equal(built, 0);
});
test('the render modes follow the viewer that is open', async () => {
const { state, host } = twoProviders();
await host.open('mapillary', 'm-1');
@@ -634,23 +531,6 @@ test('a pose can date its image by month and name its place', async () => {
);
});
test('a prewarm that lands after another provider took the host is released, not built under it', async () => {
const { state, host, mapillary } = twoProviders();
let release;
mapillary.prewarm = () =>
new Promise((resolve) => {
release = resolve;
});
const warming = host.prewarm([state.providers.get('mapillary')]);
await host.open('google', 'g-1'); // the user opens Street View meanwhile
release();
await warming;
assert.equal(mapillary.calls.unmount, 1, 'released at once');
// Not kept warm: the next Mapillary open mounts afresh.
await host.open('mapillary', 'm-1');
assert.equal(state.street.providerId, 'mapillary');
});
test('FIT/FILL stays hidden for a second Street View panorama after a close', async () => {
const { state, host } = twoProviders();
await host.open('google', 'g-1');
+1 -5
View File
@@ -45,11 +45,7 @@ test('desktop Display participates in Tactical exclusivity without changing mobi
/panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/,
);
assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/);
// A panel floating out of the rail (portable mode) is exempt from exclusivity.
assert.match(
css,
/#right-context-rail\.layout-exclusive\s*> \[data-panel-id\]\.collapsed:not\(\.panel-floating\) \{/,
);
assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/);
});
test('explicit Contacts, Space Missions, and Cockpit actions expand Global Context after success', () => {
+1 -5
View File
@@ -187,9 +187,5 @@ export function createMeshFloorSampler({
() => eventTarget?.removeEventListener('gev:map-stack-changed', onStack),
{ once: true },
);
return {
sampleMeshFloorCells,
cachedGroundFloor,
visibleTilesetLoaded: _visibleTilesetLoaded,
};
return { sampleMeshFloorCells, cachedGroundFloor };
}
+12 -165
View File
@@ -11,12 +11,11 @@ const read = (file) => fs.readFileSync(path.join(ROOT, file), 'utf8');
const html = expandApplicationHtml(read('index.html'));
const css = readStylesheet(path.join(ROOT, 'style.css'));
const panelCss = read('src/ui/styles/street-level.css');
const panelChrome = read('src/ui/panelChrome.js');
const layoutController = read('src/ui/panelLayoutController.js');
const panelCss = read('src/ui/styles/street-level.css');
const controls = read('src/ui/streetLevelControls.js');
test('Street Level is an ordinary collapsible GEV panel that starts collapsed', () => {
test('Street Level is a collapsible right-rail panel that starts collapsed', () => {
assert.match(
html,
/<div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel">/,
@@ -51,179 +50,27 @@ test('the panel is registered with panel chrome, cockpit entry and the right rai
panelChrome,
/const isRightRail = \[[\s\S]*'street-level-panel'/,
);
// The layout controller moves every rail panel in one loop; ours is listed.
assert.match(
layoutController,
/for \(const panel of \[[\s\S]*?this\._streetLevelPanel,[\s\S]*?\]\) \{[\s\S]*?stack\.insertBefore\(panel, globalContextPanel\)/,
);
for (const rule of [
'#right-context-rail > #street-level-panel:not(.panel-floating)',
'#right-context-rail #street-level-panel.collapsed:not(.panel-floating)',
])
assert.ok(css.includes(rule), `layers.css names ${rule}`);
// Rail placement never applies to the panel once it floats as a window.
assert.match(
css,
/#right-context-rail\.layout-focus\s*>\s*#street-level-panel:not\(\.collapsed\):not\(\.panel-floating\)/,
);
assert.ok(css.includes('#right-context-rail > #street-level-panel'));
});
test('panel styles stay inside GEV conventions: no !important, no fixed panel', () => {
assert.equal((panelCss.match(/!important/g) || []).length, 0);
const fixed = panelCss
.replace(/\/\*[\s\S]*?\*\//g, '')
.split('}')
.filter((block) => /position:\s*fixed/.test(block))
.map((block) => block.slice(0, block.indexOf('{')).trim());
assert.deepEqual(fixed, ['.sl-viewer-wrap-expanded']);
});
test('the keyless state gates the controls rather than leaving dead buttons', () => {
// The key requirement is documented (README, .env.example), not repeated in the panel.
assert.doesNotMatch(
html,
/sl-keyless|sl-query|sl-results|data-sl-suggestion/,
);
assert.match(html, /<fieldset id="sl-controls"/);
assert.match(html, /<div id="sl-provider-chips" class="sl-chips"><\/div>/);
// The gate holds the filters only, so the chips' key tooltip stays reachable.
const gate = html.slice(
html.indexOf('<fieldset id="sl-controls"'),
html.indexOf('</fieldset>', html.indexOf('<fieldset id="sl-controls"')),
);
assert.doesNotMatch(gate, /sl-provider-chips/);
test('the key gate holds every filter, with the error line outside it', () => {
const start = html.indexOf('<fieldset id="sl-controls"');
assert.ok(start > 0);
const gate = html.slice(start, html.indexOf('</fieldset>', start));
assert.match(gate, /data-sl-pano="all"/);
assert.match(gate, /id="sl-since"/);
assert.match(
html,
/<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1"/,
);
assert.match(html, /<output id="sl-since-label"/);
assert.doesNotMatch(html, /<select id="sl-since"|value="year:/);
assert.match(html, /<ul id="sl-legend"/);
assert.ok(html.indexOf('id="sl-error"') < start);
// The chips stay outside the gate, so their key tooltip is reachable.
assert.match(html, /<div id="sl-provider-chips" class="sl-chips"><\/div>/);
assert.doesNotMatch(gate, /sl-provider-chips/);
assert.match(html, /id="sl-error"[^>]*role="alert"/);
// The header pill is the layer's on/off switch, not just a readout.
assert.match(
html,
/<button id="sl-status" class="sl-status" type="button" aria-pressed="false"/,
);
assert.match(
controls,
/this\.listen\(el\.status, 'click', \(\) => this\._toggleEnabled\(\)\)/,
);
});
test('the expanded viewer is a modal dialog that restores focus', () => {
assert.match(controls, /setAttribute\('role', 'dialog'\)/);
assert.match(controls, /setAttribute\('aria-modal', 'true'\)/);
assert.match(controls, /_expandReturnFocus/);
// A real modal: the rest of the application is inert while it is shown.
assert.match(controls, /node\.inert = true/);
assert.match(controls, /node\.inert = false/);
});
test('labels say what the buttons do', () => {
for (const label of [
'aria-label="Camera follows view"',
'PROVIDERS',
'SINCE',
])
assert.ok(html.includes(label), label);
// The provider chips are the on/off switch; no separate buttons.
assert.doesNotMatch(
html,
/OPEN NEAREST PHOTO|STREET LEVEL OFF|sl-enable-btn|sl-look-btn|LOOK HERE|STREET COCKPIT|>FRAME<|ZOOM TO RESULTS|ASK IN PLAIN ENGLISH/,
);
});
test('on phones the viewer is sized from the rail band, not its aspect ratio', () => {
assert.match(
panelCss,
/@media \(max-width: 720px\) \{\s*\.sl-viewer \{[^}]*height: clamp\(/,
);
// Floating and expanded viewers must not inherit the phone height.
for (const selector of [
".panel-floating\\[style\\*='height'\\] \\.sl-viewer",
'\\.sl-viewer-wrap-expanded \\.sl-viewer',
])
assert.match(panelCss, new RegExp(`${selector} \\{[^}]*height: auto`));
});
test('the viewer comes first and the panel is a portable, resizable window', () => {
const controlsBlock = html.slice(html.indexOf('<div class="sl-main">'));
assert.ok(
controlsBlock.indexOf('id="sl-viewer-wrap"') <
controlsBlock.indexOf('class="sl-settings"'),
'imagery sits above the settings, visible without scrolling',
);
assert.match(
controlsBlock.slice(0, controlsBlock.indexOf('id="sl-viewer"')),
/id="sl-follow-btn"/,
'follow lives in the viewer toolbar',
);
const position = read('src/ui/panelPositionControls.js');
assert.match(
position,
/id: 'street-level-panel',[\s\S]*?portable: true,[\s\S]*?min: \{ width: 320, height: 280 \}/,
);
assert.match(
panelCss,
/\.panel-floating\[style\*='height'\] \.sl-settings \{[^}]*overflow-y: auto/,
);
// Collapsing a floating panel, or SHRINK / Esc on the expanded viewer,
// docks it rather than leaving a window over the globe.
assert.match(
position,
/id: 'street-level-panel',[\s\S]*?dockOnCollapse: true/,
);
assert.match(
read('src/ui/panelChrome.js'),
/classList\.toggle\('collapsed', nextCollapsed\);[\s\S]{0,200}?if \(explicit && !restore\)\s*this\._panelPosition\?\.onPanelCollapsed\?\.\(panelId, nextCollapsed\)/,
);
assert.match(
controls,
/setViewerExpanded\(!this\.isViewerExpanded\(\), \{ dock: true \}\)/,
);
assert.match(controls, /this\.setViewerExpanded\(false, \{ dock: true \}\)/);
assert.match(controls, /if \(!on && dock\) this\.actions\.dockPanel\?\.\(\)/);
assert.match(
read('src/ui/applicationShell.js'),
/panelId === 'street-level-panel'\)\s*this\._streetLevelControls\?\.onPanelResized\(\)/,
);
});
test('Clean View, recording and the cockpit hide the expanded viewer on <body> (P2-5)', () => {
// EXPAND moves the viewer out of every panel, past the panel-hiding rules.
assert.match(
read('src/ui/styles/controls.css'),
/body\.ui-clean-view \.sl-viewer-wrap-expanded \{\s*opacity: 0 !important;\s*visibility: hidden !important;/,
);
assert.match(
read('src/ui/styles/recording.css'),
/body\.recording-mode \.sl-viewer-wrap-expanded \{\s*opacity: 0 !important;\s*visibility: hidden !important;/,
);
assert.match(
read('src/ui/styles/cockpit.css'),
/body\.cockpit-mode\s*:is\([^)]*\.sl-viewer-wrap-expanded\s*\)\s*\{\s*display: none !important;/,
);
// Hidden, it must not keep holding Esc and Tab.
assert.match(controls, /_isDialogShown\(\)/);
});
test('the expanded viewer keeps the panel tokens (P3)', () => {
assert.match(
panelCss,
/#street-level-panel,\s*\.sl-viewer-wrap \{\s*--sl-green:[^}]*--sl-text:[^}]*--sl-text-small:/,
);
});
test('radiogroups are one tab stop and their focus ring is not clipped (P3)', () => {
const seg = /\.sl-seg \{([^}]*)\}/.exec(panelCss)[1];
assert.doesNotMatch(seg, /overflow/);
assert.match(
panelCss,
/\.sl-seg-btn:focus-visible \{[^}]*outline: 1px solid/,
);
test('each radiogroup is one tab stop, on its checked button', () => {
for (const [group, checked] of [
['data-sl-render', 'letterbox'],
['data-sl-pano', 'all'],
+10 -1
View File
@@ -4,10 +4,19 @@ import { PbfWriter } from 'pbf';
import {
layerWriter,
lineGeometry,
lonLatToTileLocal,
zigzag,
} from '../layers/streetLevel/providers/mapillary/coverageFixture.mjs';
/** Tile-local [x, y] (0..extent) of a lon/lat inside tile (x, y, z). */
function lonLatToTileLocal([lon, lat], { x, y, z }, extent = 4096) {
const n = 2 ** z;
const rad = (lat * Math.PI) / 180;
const tx = ((lon + 180) / 360) * n;
const ty =
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n;
return [Math.round((tx - x) * extent), Math.round((ty - y) * extent)];
}
/**
* Encode a Panoramax `/api/map/{z}/{x}/{y}.mvt` tile from lon/lat features.
* @param {{x: number, y: number, z: number}} tile
+130 -5
View File
@@ -1,4 +1,4 @@
/** Shared Street Level test stand-ins: provider snapshot, minimal provider, ray-casting camera. */
/** Shared Street Level test stand-ins: provider snapshot and provider, Mapillary source, photo viewer, Cesium viewer, ray-casting camera. */
import * as Cesium from 'cesium';
import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js';
import {
@@ -42,8 +42,6 @@ export function fakeStreetLevelProvider({
const filters = [];
const stats = {
count: 0,
zoom: null,
kind: null,
loading: false,
hint: '',
error: null,
@@ -70,7 +68,6 @@ export function fakeStreetLevelProvider({
activate: () => calls.activate++,
deactivate: () => calls.deactivate++,
destroy() {},
refreshCoverage() {},
setFilter: (filter) => filters.push(filter),
coverageStats: () => ({ ...stats }),
handlePick: () => false,
@@ -88,6 +85,135 @@ export function fakeStreetLevelProvider({
};
}
/**
* A Mapillary source for the provider: a configured key, empty tiles, and the
* given lookups; `calls` records tile requests.
*/
export function fakeMapillarySource(overrides = {}) {
const calls = { tiles: [] };
return {
calls,
hasToken: () => true,
getStatus: async () => ({ configured: true }),
getTile: async (...args) => {
calls.tiles.push(args);
return new Uint8Array(0);
},
getSequenceImages: async () => [],
...overrides,
};
}
/**
* A photo viewer in place of MapillaryJS: `calls` records use, and `open`
* reports a pose for the image (when `pose` is given) as the real one does.
*/
export function fakePhotoViewer({ pose = null } = {}) {
const calls = { mount: 0, open: [], unmount: 0 };
let emit = null;
return {
calls,
mount: async () => calls.mount++,
async open(imageId) {
calls.open.push(imageId);
if (pose) emit?.({ imageId, ...pose });
},
close() {},
unmount: () => calls.unmount++,
resize() {},
onPose(listener) {
emit = listener;
return () => {
emit = null;
};
},
};
}
/**
* A stand-in Cesium viewer a layer can be enabled on: a canvas for the click
* handler, camera events with Cesium's flight bookkeeping (`flights` counts
* them, lists them and can `land` the current one), primitive lists and a
* credit display. With `view`, the camera looks straight down on Sacramento
* from 900 m, so coverage asks for tiles.
*/
export function fakeCesiumViewer({ view = false } = {}) {
const credits = [];
let flight = null;
const flights = {
started: 0,
cancelled: 0,
/** Every flight asked for, oldest first: `{ sphere, options }`. */
list: [],
land() {
const current = flight;
flight = null;
current?.options.complete?.();
},
};
const stopFlight = () => {
const current = flight;
flight = null;
current?.options.cancel?.();
return Boolean(current);
};
const canvas = Object.assign(new EventTarget(), {
style: {},
// Keep Cesium's handler on the canvas; there is no real document here.
disableRootEvents: true,
onwheel: null,
...(view ? { clientWidth: 100, clientHeight: 100 } : {}),
});
const camera = Object.assign(
view
? rayCamera({
lon: -121.4944,
lat: 38.5816,
altitude: 900,
pitch: -90,
width: 100,
height: 100,
})
: {},
{
changed: new Cesium.Event(),
moveStart: new Cesium.Event(),
moveEnd: new Cesium.Event(),
flyToBoundingSphere(sphere, options = {}) {
stopFlight();
flight = { sphere, options };
flights.list.push(flight);
flights.started++;
},
cancelFlight() {
if (stopFlight()) flights.cancelled++;
},
},
);
return {
credits,
flights,
scene: {
canvas,
primitives: { add: (p) => p, remove() {} },
groundPrimitives: { add: (p) => p, remove() {} },
// Enough of a scene for the position marker to clamp to the ground.
frameState: { mode: Cesium.SceneMode.SCENE3D },
updateHeight: () => () => {},
getHeight: () => undefined,
},
camera,
creditDisplay: {
addStaticCredit: (credit) => credits.push(credit),
removeStaticCredit: (credit) => {
// Cesium ignores a credit it does not hold.
const at = credits.indexOf(credit);
if (at !== -1) credits.splice(at, 1);
},
},
};
}
/**
* A pinhole camera `altitude` m above WGS84, at `heading` (deg from north) and
* `pitch` (deg, negative down); `pickEllipsoid` hits whatever ellipsoid it gets.
@@ -143,7 +269,6 @@ export function rayCamera({
const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid);
return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined;
},
computeViewRectangle: () => undefined,
};
}
File diff suppressed because it is too large. Load diff
-57
View File
@@ -1,57 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
driftedEdges,
growthDelta,
handlePoint,
MIN_SIZE,
pinnedEdges,
STORAGE_KEY,
} from '../../scripts/qa-panel-resize.mjs';
import { RESIZE_DIRECTIONS } from '../ui/panelResize.js';
test('the harness targets the key and minimums the controls use', () => {
assert.equal(STORAGE_KEY, 'godsEyeView.v8.panelPos.cctv-panel');
assert.deepEqual(MIN_SIZE, { width: 300, height: 160 });
});
test('pinned edges are exactly the sides a direction does not name', () => {
assert.deepEqual(pinnedEdges('n'), ['bottom', 'right', 'left']);
assert.deepEqual(pinnedEdges('se'), ['top', 'left']);
assert.deepEqual(pinnedEdges('nw'), ['bottom', 'right']);
for (const dir of RESIZE_DIRECTIONS)
assert.equal(pinnedEdges(dir).length, 4 - dir.length);
});
test('growth deltas push each named edge outward', () => {
assert.deepEqual(growthDelta('n', 40), { dx: 0, dy: -40 });
assert.deepEqual(growthDelta('e', 40), { dx: 40, dy: 0 });
assert.deepEqual(growthDelta('sw', 40), { dx: -40, dy: 40 });
assert.deepEqual(growthDelta('se', 40), { dx: 40, dy: 40 });
});
test('handle points land inside the strip or corner for every direction', () => {
const rect = { left: 100, top: 200, width: 400, height: 300 };
rect.right = rect.left + rect.width;
rect.bottom = rect.top + rect.height;
assert.deepEqual(handlePoint(rect, 'n'), { x: 300, y: 201 });
assert.deepEqual(handlePoint(rect, 'w'), { x: 101, y: 350 });
assert.deepEqual(handlePoint(rect, 'se'), { x: 496, y: 496 });
assert.deepEqual(handlePoint(rect, 'nw'), { x: 104, y: 204 });
for (const dir of RESIZE_DIRECTIONS) {
const { x, y } = handlePoint(rect, dir);
assert.ok(x >= rect.left && x <= rect.right, `${dir} x inside`);
assert.ok(y >= rect.top && y <= rect.bottom, `${dir} y inside`);
}
});
test('drift detection reports only pinned edges that moved', () => {
const before = { left: 100, top: 100, width: 400, height: 300 };
const grewEast = { left: 100, top: 100, width: 440, height: 300 };
assert.deepEqual(driftedEdges(before, grewEast, 'e'), []);
const slidWest = { left: 60, top: 100, width: 440, height: 300 };
assert.deepEqual(driftedEdges(before, slidWest, 'e'), ['left']);
assert.deepEqual(driftedEdges(before, slidWest, 'w'), []);
const wobble = { left: 101, top: 100, width: 439, height: 300 };
assert.deepEqual(driftedEdges(before, wobble, 'e'), []);
});
Loaded 100 of 129 files, more files were not shown because too many files have changed in this diff. Show more