mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
129 files changed
+5468
-17032
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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 вернул недопустимую частоту дискретизации аудио",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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. */
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]', () => {
|
||||
|
||||
@@ -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,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
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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'],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+659
-1273
File diff suppressed because it is too large.
Load diff
@@ -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
Reference in new issue
Block a user