mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge remote-tracking branch 'origin/main' into feat/cctv-vegvesen-kartverket-heights
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
1e1ddfca0f
136 files changed
+23699
-510
No files matched your search
+8
-3
@@ -13,9 +13,9 @@
|
|||||||
# externally and never touched. This file remains the reference for headless
|
# externally and never touched. This file remains the reference for headless
|
||||||
# and self-hosted setups.
|
# and self-hosted setups.
|
||||||
#
|
#
|
||||||
# NOTE ON CLIENT-EXPOSED KEYS: GOOGLE_MAPS_API_KEY and CESIUM_ION_TOKEN are
|
# NOTE ON CLIENT-EXPOSED KEYS: GOOGLE_MAPS_API_KEY, CESIUM_ION_TOKEN and
|
||||||
# injected into the browser bundle by design (they're used client-side) and
|
# MAPILLARY_CLIENT_TOKEN are injected into the browser bundle by design
|
||||||
# WILL be visible in devtools. Restrict/scope them rather than trying to hide
|
# (they're used client-side) and WILL be visible in devtools. Restrict/scope them rather than trying to hide
|
||||||
# them (see SECURITY.md). All other keys below stay server-side.
|
# them (see SECURITY.md). All other keys below stay server-side.
|
||||||
|
|
||||||
# Optional: direct Google Photorealistic 3D Tiles and GEV place search.
|
# Optional: direct Google Photorealistic 3D Tiles and GEV place search.
|
||||||
@@ -82,6 +82,11 @@ OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
|
|||||||
# Limits (usage limits).
|
# Limits (usage limits).
|
||||||
# GEV_RATELIMIT_OPENAI_PER_MIN=30 # the default; uncomment only to change it
|
# GEV_RATELIMIT_OPENAI_PER_MIN=30 # the default; uncomment only to change it
|
||||||
|
|
||||||
|
# Optional: Mapillary imagery for the Street Level layer. Free client token from
|
||||||
|
# https://www.mapillary.com/dashboard/developers (register an app, copy its
|
||||||
|
# Client Token). Client-exposed by design, like GOOGLE_MAPS_API_KEY.
|
||||||
|
MAPILLARY_CLIENT_TOKEN=
|
||||||
|
|
||||||
# Optional: OpenSky auth mode (oauth | basic | auto | anon)
|
# Optional: OpenSky auth mode (oauth | basic | auto | anon)
|
||||||
# Default: oauth — requires client credentials below
|
# Default: oauth — requires client credentials below
|
||||||
# Set to "anon" to skip aircraft auth (rate-limited but works)
|
# Set to "anon" to skip aircraft auth (rate-limited but works)
|
||||||
|
|||||||
@@ -59,6 +59,68 @@ jobs:
|
|||||||
- name: Build production bundle
|
- name: Build production bundle
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
street-level-browser:
|
||||||
|
# Hermetic: every Mapillary request is answered from fixtures, so the
|
||||||
|
# dummy token below never leaves the page.
|
||||||
|
name: Street Level browser gate
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
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
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Use Node.js
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: 24.14.0
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install locked dependencies and headless Chrome
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build production bundle with a dummy Mapillary token
|
||||||
|
env:
|
||||||
|
MAPILLARY_CLIENT_TOKEN: 'MLY|0|qa-fixture'
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Start the preview server
|
||||||
|
env:
|
||||||
|
MAPILLARY_CLIENT_TOKEN: 'MLY|0|qa-fixture'
|
||||||
|
run: |
|
||||||
|
npx vite preview --port 4173 --strictPort > preview.log 2>&1 &
|
||||||
|
for attempt in $(seq 1 90); do
|
||||||
|
curl -sf http://localhost:4173/ > /dev/null && exit 0
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
cat preview.log
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- name: Upload gate evidence
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: browser-gate-evidence
|
||||||
|
path: qa-artifacts/
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
windows-onboarding:
|
windows-onboarding:
|
||||||
name: Windows onboarding
|
name: Windows onboarding
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ qa-shots/
|
|||||||
output/
|
output/
|
||||||
.gev-logs/
|
.gev-logs/
|
||||||
.gev-cache/
|
.gev-cache/
|
||||||
|
# Browser QA gate evidence (screenshots, console and network dumps).
|
||||||
|
qa-artifacts/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.gstack/
|
.gstack/
|
||||||
3d-models/
|
3d-models/
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
- Street Level: a street-level imagery layer modelled on the iD editor's photo
|
||||||
|
overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0
|
||||||
|
imagery). One right-rail panel holds 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.
|
||||||
|
|
||||||
- Give each Statens vegvesen camera its own ground height from Kartverket's
|
- Give each Statens vegvesen camera its own ground height from Kartverket's
|
||||||
keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so
|
keyless point-height API (CC BY 4.0) instead of one flat 150 m prior, so
|
||||||
mountain-pass cameras no longer sit hundreds of metres below the terrain
|
mountain-pass cameras no longer sit hundreds of metres below the terrain
|
||||||
|
|||||||
+2
-2
@@ -79,8 +79,8 @@ if its layer is later removed.
|
|||||||
|
|
||||||
New layers first use the remaining unreserved single-character digits (`0`
|
New layers first use the remaining unreserved single-character digits (`0`
|
||||||
through `9`), then the two-character base-36 namespace (`00` through `zz`). On the
|
through `9`), then the two-character base-36 namespace (`00` through `zz`). On the
|
||||||
current published ledger, `1` and `2` are already owned, so the next choices
|
current published ledger, `0`, `1` and `2` are already owned, so the next
|
||||||
are `0`, `3` through `9`, then `00`. After rebasing onto the latest `main`, run
|
choices are `3` through `9`, then `00`. After rebasing onto the latest `main`, run
|
||||||
`npm run layer-token:next -- <layer-id>`, add that result to both the permanent
|
`npm run layer-token:next -- <layer-id>`, add that result to both the permanent
|
||||||
JSON reservation ledger and registry entry, and keep the exact registered-layer
|
JSON reservation ledger and registry entry, and keep the exact registered-layer
|
||||||
count assertion up to date. The token is assigned against the merge-time base, not
|
count assertion up to date. The token is assigned against the merge-time base, not
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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 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 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" |
|
| **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 |
|
||||||
| **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 |
|
| **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 |
|
| **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 |
|
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station
|
|||||||
|
|
||||||
## 🛰️ What's on the Globe
|
## 🛰️ What's on the Globe
|
||||||
|
|
||||||
Nineteen layers and map sources. **Seventeen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
|
Twenty layers and map sources. **Seventeen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
|
||||||
|
|
||||||
| Layer | What you get | Source | Auth |
|
| Layer | What you get | Source | Auth |
|
||||||
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
@@ -296,6 +296,7 @@ Nineteen layers and map sources. **Seventeen have a keyless path.** Some offer a
|
|||||||
| 🚗 **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 |
|
| 🚗 **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** | ~3,900 public cameras 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). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
| 📹 **CCTV Mesh** | ~3,900 public cameras 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). 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) | 🟢 |
|
| 📷 **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 first; Panoramax, KartaView and Google Street View planned | Mapillary | 🟡 Mapillary client token |
|
||||||
| 📻 **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 | 🟢 |
|
| 📻 **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 | Operator GTFS-Realtime feeds | 🟢 |
|
| 🚌 **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 | Operator GTFS-Realtime feeds | 🟢 |
|
||||||
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
|
||||||
@@ -331,6 +332,16 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 📷 Street Level
|
||||||
|
|
||||||
|
> Street-level photos from any provider in one panel. This build ships **Mapillary**, which needs a free client token.
|
||||||
|
|
||||||
|
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**.
|
||||||
|
|
||||||
|
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green). The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to every provider. Click a coverage line for its image cones and a cone to open the photo. Above the photo: **EXPAND**, **FIT / FILL**, 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 "© Mapillary contributors, CC BY-SA 4.0" while Mapillary is on.
|
||||||
|
|
||||||
|
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.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
|
## 🎖️ Field Missions
|
||||||
|
|
||||||
Once the basics click, run these:
|
Once the basics click, run these:
|
||||||
@@ -412,7 +423,7 @@ names it.
|
|||||||
|
|
||||||
### Choose the capabilities you want
|
### Choose the capabilities you want
|
||||||
|
|
||||||
Six keys. Four have a free tier, and the two 🔴 ones are metered:
|
Seven keys. Five have a free tier, and the two 🔴 ones are metered:
|
||||||
|
|
||||||
| | Key | Why | Get it |
|
| | Key | Why | Get it |
|
||||||
| --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
@@ -422,6 +433,7 @@ Six keys. Four have a free tier, and the two 🔴 ones are metered:
|
|||||||
| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup |
|
| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup |
|
||||||
| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
|
| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
|
||||||
| 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [my.tomtom.com](https://my.tomtom.com/keys) — free tier available |
|
| 🟡 | **TomTom** | 🚦 Live flow speeds and congestion colors for the simulated traffic layer | [my.tomtom.com](https://my.tomtom.com/keys) — free tier available |
|
||||||
|
| 🟡 | **Mapillary** | 📷 Street Level coverage and photos. A public client token by design (see SECURITY.md) | [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers) — register an app, copy its Client Token |
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -22,14 +22,15 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr
|
|||||||
| OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/flights` |
|
| OpenSky OAuth (`OPENSKY_CLIENT_ID/SECRET`) | Server only | Server mints + refreshes the token behind `/api/flights` |
|
||||||
| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset |
|
| `GOOGLE_MAPS_SERVER_API_KEY` (optional, #33) | Server only | Server calls Places (`/api/google/nearby-places`, `/api/google/text-search`) and the Street View fallback with this key; falls back to `GOOGLE_MAPS_API_KEY` when unset |
|
||||||
|
|
||||||
### Two deliberately client-side keys — restrict them
|
### Three deliberately client-side keys — restrict them
|
||||||
|
|
||||||
These are designed to be used directly in the browser (like a Mapbox public token). They are injected into the client bundle via Vite's `define`, so they **will** be visible in browser devtools. Scope and restrict them rather than trying to hide them:
|
These are designed to be used directly in the browser (like a Mapbox public token). They are injected into the client bundle via Vite's `define`, so they **will** be visible in browser devtools. Scope and restrict them rather than trying to hide them:
|
||||||
|
|
||||||
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search. **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.
|
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search. **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.
|
||||||
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.
|
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.
|
||||||
|
|
||||||
> The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these two keys. Everything else stays server-side.
|
> The explicit browser `define` block in `build/vite.js` controls exactly what reaches the client: only these three keys. Everything else stays server-side.
|
||||||
|
|
||||||
**Places and Street View never needed to be on that list** (#33): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to the APIs the client loads, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its two jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them; it just has to cover every API both sides use.
|
**Places and Street View never needed to be on that list** (#33): they're called from the server-side proxies in the table above, which use `GOOGLE_MAPS_SERVER_API_KEY` when it's set. Splitting it from the browser-exposed key lets each key's Google Cloud restriction actually match what it does — the browser key referrer-restricted to the APIs the client loads, the server key IP-restricted (never a referrer, since it never leaves your server) to Places + Street View Static — instead of one key that has to be either over-permissioned or broken for one of its two jobs. A single shared `GOOGLE_MAPS_API_KEY` still works if you don't split them; it just has to cover every API both sides use.
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
|
|||||||
- **Sanitized errors** — internal error details are not echoed back to clients.
|
- **Sanitized errors** — internal error details are not echoed back to clients.
|
||||||
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
|
||||||
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
|
||||||
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
|
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
|
||||||
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
|
||||||
|
|
||||||
## Network exposure — the operator threat model
|
## Network exposure — the operator threat model
|
||||||
|
|||||||
+28
-2
@@ -1,8 +1,9 @@
|
|||||||
# Third-party software notices
|
# Third-party software notices
|
||||||
|
|
||||||
The project depends on third-party npm packages whose own licenses apply. This
|
The project depends on third-party npm packages whose own licenses apply. This
|
||||||
file records the packages added for the browser-local SDR feature; the complete
|
file records the packages added for the browser-local SDR feature and the
|
||||||
resolved dependency inventory remains in `package-lock.json`.
|
Street Level viewer; the complete resolved dependency inventory remains in
|
||||||
|
`package-lock.json`.
|
||||||
|
|
||||||
## Web RTL-SDR
|
## Web RTL-SDR
|
||||||
|
|
||||||
@@ -19,3 +20,28 @@ resolved dependency inventory remains in `package-lock.json`.
|
|||||||
- Source: <https://github.com/jtarrio/signals>
|
- Source: <https://github.com/jtarrio/signals>
|
||||||
- License: Apache License 2.0
|
- License: Apache License 2.0
|
||||||
- License text: <https://www.apache.org/licenses/LICENSE-2.0>
|
- License text: <https://www.apache.org/licenses/LICENSE-2.0>
|
||||||
|
|
||||||
|
## MapillaryJS
|
||||||
|
|
||||||
|
- Package: `mapillary-js` 4.1.2 (loaded on demand by the Street Level viewer,
|
||||||
|
with its stylesheet `dist/mapillary.css`)
|
||||||
|
- Author: Copyright (c) Facebook, Inc. and its affiliates
|
||||||
|
- Source: <https://github.com/mapillary/mapillary-js>
|
||||||
|
- License: MIT
|
||||||
|
- License text: <https://github.com/mapillary/mapillary-js/blob/main/LICENSE>
|
||||||
|
|
||||||
|
## RxJS (MapillaryJS dependency)
|
||||||
|
|
||||||
|
- Package: `rxjs` 7.8.2
|
||||||
|
- Author: Google, Inc., Netflix, Inc., Microsoft Corp. and contributors
|
||||||
|
- Source: <https://github.com/ReactiveX/rxjs>
|
||||||
|
- License: Apache License 2.0
|
||||||
|
- License text: <https://www.apache.org/licenses/LICENSE-2.0>
|
||||||
|
|
||||||
|
## three.js (MapillaryJS dependency)
|
||||||
|
|
||||||
|
- Package: `three` 0.134.0
|
||||||
|
- Author: Copyright © 2010-2021 three.js authors
|
||||||
|
- Source: <https://github.com/mrdoob/three.js>
|
||||||
|
- License: MIT
|
||||||
|
- License text: <https://github.com/mrdoob/three.js/blob/dev/LICENSE>
|
||||||
@@ -52,6 +52,7 @@ export function createBrowserViteConfig({
|
|||||||
publicDir,
|
publicDir,
|
||||||
googleApiKey,
|
googleApiKey,
|
||||||
cesiumToken,
|
cesiumToken,
|
||||||
|
mapillaryToken,
|
||||||
host = 'localhost',
|
host = 'localhost',
|
||||||
port = 4173,
|
port = 4173,
|
||||||
allowedHosts = DEFAULT_ALLOWED_HOSTS,
|
allowedHosts = DEFAULT_ALLOWED_HOSTS,
|
||||||
@@ -80,6 +81,8 @@ export function createBrowserViteConfig({
|
|||||||
'@jtarrio/signals/demod/modes.js',
|
'@jtarrio/signals/demod/modes.js',
|
||||||
'@jtarrio/webrtlsdr/rtlsdr.js',
|
'@jtarrio/webrtlsdr/rtlsdr.js',
|
||||||
'egm96-universal',
|
'egm96-universal',
|
||||||
|
// Loaded on demand when the first street-level image opens.
|
||||||
|
'mapillary-js',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
@@ -104,6 +107,10 @@ export function createBrowserViteConfig({
|
|||||||
define: {
|
define: {
|
||||||
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey),
|
'import.meta.env.GOOGLE_MAPS_API_KEY': JSON.stringify(googleApiKey),
|
||||||
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken),
|
'import.meta.env.CESIUM_ION_TOKEN': JSON.stringify(cesiumToken),
|
||||||
|
// Mapillary client tokens are public by design (see SECURITY.md).
|
||||||
|
'import.meta.env.MAPILLARY_CLIENT_TOKEN': JSON.stringify(
|
||||||
|
mapillaryToken ?? '',
|
||||||
|
),
|
||||||
},
|
},
|
||||||
build: { chunkSizeWarningLimit: 1500 },
|
build: { chunkSizeWarningLimit: 1500 },
|
||||||
};
|
};
|
||||||
|
|||||||
+73
-5
@@ -2928,11 +2928,79 @@ its criteria cannot be silently ignored.
|
|||||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
|
||||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
||||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||||
|
| Street Level 📷 | Street-level imagery; Mapillary is the first provider (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, cached 24 h) |
|
||||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||||
|
|
||||||
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.
|
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
|
||||||
|
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,
|
||||||
|
shared 360°/flat and captured-since filters (relative days, so a link keeps its
|
||||||
|
meaning), one viewer host and one on-globe credit per active provider. Each
|
||||||
|
provider draws in one colour (`PROVIDER_COLORS` in
|
||||||
|
`src/layers/streetLevel/policy.js`); 360° cones are rings and the selected
|
||||||
|
sequence is GEV cyan. Share options: `m` (Mapillary), `p` (`a`/`p`/`f`
|
||||||
|
panoramas), `s` (since, days). Only Mapillary is registered. Without
|
||||||
|
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED 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
|
||||||
|
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
|
||||||
|
requests for a tile.
|
||||||
|
|
||||||
|
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 header pill is the layer switch. With one provider its chip is a layer
|
||||||
|
switch too; with several, darkening the last lit chip turns the layer off. 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.
|
||||||
|
|
||||||
|
Providers implement the contract in `src/layers/streetLevel/registry.js`. The
|
||||||
|
core routes clicks by pick prefix, swaps viewer adapters in the one host,
|
||||||
|
manages each provider's credit and fans the filter out to every provider. To
|
||||||
|
add a provider: implement the definition, register it in
|
||||||
|
`src/app/layers/streetLevel.js`, add its boolean option to the `street-level`
|
||||||
|
group in `src/data/layerState.js` and its modules to
|
||||||
|
`scripts/package-boundaries.json`. Once a keyless provider registers, the
|
||||||
|
layer's `requiresKeyId` becomes null and the key gate moves to the chips.
|
||||||
|
|
||||||
|
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
|
||||||
|
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/`), 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.
|
||||||
|
|
||||||
Directions is a keyless front end to the routing the voice agent already
|
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
|
uses. Its row chips are the whole interface: DRIVE / WALK / BIKE pick the
|
||||||
profile; SET A and SET B arm the next globe click (Escape or a second press
|
profile; SET A and SET B arm the next globe click (Escape or a second press
|
||||||
@@ -3753,10 +3821,10 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
block its siblings.
|
block its siblings.
|
||||||
- Layer tokens are permanent public compatibility identifiers. The reservation
|
- Layer tokens are permanent public compatibility identifiers. The reservation
|
||||||
JSON ledger at `src/data/layerStateTokenReservations.json` pins all existing
|
JSON ledger at `src/data/layerStateTokenReservations.json` pins all existing
|
||||||
one-character assignments (all letters plus `1` and
|
one-character assignments (all letters plus `0`, `1` and
|
||||||
`2` on current `main`) even if a layer is later removed. New layers allocate
|
`2` on current `main`) even if a layer is later removed. New layers allocate
|
||||||
the remaining unreserved single-character digits first (`0`, then `3`
|
the remaining unreserved single-character digits first (`3` through `9` on
|
||||||
through `9` on current `main`), then the next unreserved two-character
|
current `main`), then the next unreserved two-character
|
||||||
base-36 token (`00` through `zz`) after rebasing onto the merge-time `main`;
|
base-36 token (`00` through `zz`) after rebasing onto the merge-time `main`;
|
||||||
the dot-delimited v2 codec accepts both widths without changing existing
|
the dot-delimited v2 codec accepts both widths without changing existing
|
||||||
links or the schema version. The contributor
|
links or the schema version. The contributor
|
||||||
@@ -4361,8 +4429,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
|
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
|
release; late one-shot shared-cell work is permitted during viewshed idle. The
|
||||||
A+B harness intentionally excludes citywide LOD assertions.
|
A+B harness intentionally excludes citywide LOD assertions.
|
||||||
- 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).
|
- 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; collapsed states still persist at `godsEyeView.v6.panelCollapsed.<panel-id>`.
|
- 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>`.
|
||||||
- Flight/military tracked entities cache dead-reckoned positions per frame to avoid callback desync flicker.
|
- 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`.
|
- 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.
|
- Annotation resolver behavior is pinned by `src/annotations/annotationResolver.test.mjs`; re-run that suite before changing place-resolution scoring.
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ Related keys (current versions):
|
|||||||
- Panel collapsed state: `godsEyeView.v6.panelCollapsed.<panel-id>` — `'0'` open,
|
- 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
|
`'1'` closed, absent means the panel's own default. A view opened from a share
|
||||||
link ignores the stored value entirely.
|
link ignores the stored value entirely.
|
||||||
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — the versioned name for a
|
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — written only for a
|
||||||
stored position. The current layout writes none, so deleting one changes
|
floating portable panel (CCTV, Street Level). Deleting it, or double-clicking
|
||||||
nothing.
|
the panel header, docks the panel again.
|
||||||
- CCTV calibration: `godsEyeView.cctv.calibration.v2`
|
- CCTV calibration: `godsEyeView.cctv.calibration.v2`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Generated
+342
-10
@@ -16,6 +16,7 @@
|
|||||||
"cesium": "^1.124.0",
|
"cesium": "^1.124.0",
|
||||||
"egm96-universal": "^1.1.1",
|
"egm96-universal": "^1.1.1",
|
||||||
"hls.js": "^1.7.3",
|
"hls.js": "^1.7.3",
|
||||||
|
"mapillary-js": "^4.1.2",
|
||||||
"mgrs": "^2.1.0",
|
"mgrs": "^2.1.0",
|
||||||
"pbf": "^5.1.2",
|
"pbf": "^5.1.2",
|
||||||
"satellite.js": "^6.0.2"
|
"satellite.js": "^6.0.2"
|
||||||
@@ -1536,6 +1537,12 @@
|
|||||||
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
|
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/earcut": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/earcut/-/earcut-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
@@ -1549,6 +1556,36 @@
|
|||||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pako": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/pbf": {
|
||||||
|
"version": "3.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz",
|
||||||
|
"integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/polylabel": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/polylabel/-/polylabel-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-9Zw2KoDpi+T4PZz2G6pO2xArE0m/GSMTW1MIxF2s8ZY8x9XDO6fv9um0ydRGvcbkFLlaq8yNK6eZxnmMZtDgWQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/rbush": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/rbush/-/rbush-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-knSt9cCW8jj1ZSFcFeBZaX++OucmfPxxHiRwTahZfJlnQsek7O0bazTJHWD2RVj9LEoejUYF2de3/stf+QXcXw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/three": {
|
||||||
|
"version": "0.134.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.134.0.tgz",
|
||||||
|
"integrity": "sha512-4YB+99Rgqq27EjiYTItEoZtdjLnTh8W9LxowgpC9eWsjaQJIL4Kn/ZcUKAnW3gB/jS4hqGN8iqmid+RcUZDzpA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
@@ -1556,6 +1593,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/virtual-dom": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/virtual-dom/-/virtual-dom-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-Y7L/frVydXRd16MevczslJZQu+QWsrqZlj6ytk7mST3xen0fkx7Ollw31By/89A8Wq+nfNWm/IoTR1ac/0fRhA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/w3c-web-usb": {
|
"node_modules/@types/w3c-web-usb": {
|
||||||
"version": "1.0.14",
|
"version": "1.0.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/w3c-web-usb/-/w3c-web-usb-1.0.14.tgz",
|
||||||
@@ -1630,6 +1673,21 @@
|
|||||||
"integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==",
|
"integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/browser-split": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/browser-split/-/browser-split-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-JhvgRb2ihQhsljNda3BI8/UcRHVzrVwo3Q+P8vDtSiyobXuFpuZ9mq+MbRGMnC22CjW3RrfXdg6j6ITX8M+7Ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/camelize": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cesium": {
|
"node_modules/cesium": {
|
||||||
"version": "1.138.0",
|
"version": "1.138.0",
|
||||||
"resolved": "https://registry.npmjs.org/cesium/-/cesium-1.138.0.tgz",
|
"resolved": "https://registry.npmjs.org/cesium/-/cesium-1.138.0.tgz",
|
||||||
@@ -1665,6 +1723,16 @@
|
|||||||
"devtools-protocol": "*"
|
"devtools-protocol": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chromium-bidi/node_modules/zod": {
|
||||||
|
"version": "3.25.76",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cliui": {
|
"node_modules/cliui": {
|
||||||
"version": "9.0.1",
|
"version": "9.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||||
@@ -1759,6 +1827,11 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-walk": {
|
||||||
|
"version": "0.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.2.tgz",
|
||||||
|
"integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w=="
|
||||||
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.4.15",
|
"version": "3.4.15",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz",
|
||||||
@@ -1817,6 +1890,16 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/error": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/error/-/error-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-SNDKualLUtT4StGFP7xNfuFybL2f6iJujFtrWuvJqGbVQGaN+adE23veqzPz1hjUjTunLi2EnJ+0SJxtbJreKw==",
|
||||||
|
"dependencies": {
|
||||||
|
"camelize": "^1.0.0",
|
||||||
|
"string-template": "~0.2.0",
|
||||||
|
"xtend": "~4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
@@ -1893,6 +1976,14 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ev-store": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ev-store/-/ev-store-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-otazchNRnGzp2YarBJ+GXKVGvhxVATB1zmaStxJBYet0Dyq7A9VhH8IUEB/gRcL6Ch52lfpgPTRJ2m49epyMsQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"individual": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -1975,6 +2066,16 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/global": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/global/-/global-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"min-document": "^2.19.0",
|
||||||
|
"process": "^0.11.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/graceful-fs": {
|
"node_modules/graceful-fs": {
|
||||||
"version": "4.2.11",
|
"version": "4.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
@@ -2015,6 +2116,31 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ieee754": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/individual": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/individual/-/individual-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-rUY5vtT748NMRbEMrTNiFfy29BgGZwGXUi2NFUVMWQrogSLzlJvQV9eeMWi+g1aVaQ53tpyLAQtd5x/JH0Nh1g=="
|
||||||
|
},
|
||||||
"node_modules/inherits": {
|
"node_modules/inherits": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
@@ -2022,6 +2148,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/is-object": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-reference": {
|
"node_modules/is-reference": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
|
||||||
@@ -2101,6 +2236,83 @@
|
|||||||
"sourcemap-codec": "^1.4.8"
|
"sourcemap-codec": "^1.4.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mapillary-js": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/mapillary-js/-/mapillary-js-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-LYj6xbp/Y9wfBkr/prRwgJGb3+KwvzUri0yl5QEpMEH7oC1p52ErshWmPXysKefmm6JaFML4D7FJ89viwJ4T5g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/earcut": "^2.1.1",
|
||||||
|
"@types/node": "^14.14.31",
|
||||||
|
"@types/pako": "^1.0.2",
|
||||||
|
"@types/pbf": "^3.0.2",
|
||||||
|
"@types/polylabel": "^1.0.5",
|
||||||
|
"@types/rbush": "^3.0.0",
|
||||||
|
"@types/three": "^0.134.0",
|
||||||
|
"@types/virtual-dom": "^2.1.1",
|
||||||
|
"earcut": "^2.2.3",
|
||||||
|
"martinez-polygon-clipping": "^0.7.1",
|
||||||
|
"pako": "^2.0.4",
|
||||||
|
"pbf": "^3.2.1",
|
||||||
|
"polylabel": "^1.1.0",
|
||||||
|
"rbush": "^3.0.1",
|
||||||
|
"rxjs": "^7.3.0",
|
||||||
|
"s2-geometry": "^1.2.10",
|
||||||
|
"three": "^0.134.0",
|
||||||
|
"virtual-dom": "^2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mapillary-js/node_modules/@types/node": {
|
||||||
|
"version": "14.18.63",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz",
|
||||||
|
"integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/mapillary-js/node_modules/earcut": {
|
||||||
|
"version": "2.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz",
|
||||||
|
"integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/mapillary-js/node_modules/pbf": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"ieee754": "^1.1.12",
|
||||||
|
"resolve-protobuf-schema": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pbf": "bin/pbf"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mapillary-js/node_modules/quickselect": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/mapillary-js/node_modules/rbush": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/rbush/-/rbush-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"quickselect": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/martinez-polygon-clipping": {
|
||||||
|
"version": "0.7.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/martinez-polygon-clipping/-/martinez-polygon-clipping-0.7.4.tgz",
|
||||||
|
"integrity": "sha512-jBEwrKtA0jTagUZj2bnmb4Yg2s4KnJGRePStgI7bAVjtcipKiF39R4LZ2V/UT61jMYWrTcBhPazexeqd6JAVtw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"robust-predicates": "^2.0.4",
|
||||||
|
"splaytree": "^0.1.4",
|
||||||
|
"tinyqueue": "^1.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mersenne-twister": {
|
"node_modules/mersenne-twister": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/mersenne-twister/-/mersenne-twister-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/mersenne-twister/-/mersenne-twister-1.1.0.tgz",
|
||||||
@@ -2132,6 +2344,15 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/min-document": {
|
||||||
|
"version": "2.19.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.2.tgz",
|
||||||
|
"integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-walk": "^0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mitt": {
|
"node_modules/mitt": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||||
@@ -2175,6 +2396,12 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/next-tick": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-f7h4svPtl+QidoBv4taKXUjJ70G2asaZ8G28nS0OkqaalX8dwwrtWtyxEDPK62AC00ur/+/E0pUwBwY5EPn15Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/nosleep.js": {
|
"node_modules/nosleep.js": {
|
||||||
"version": "0.12.0",
|
"version": "0.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/nosleep.js/-/nosleep.js-0.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/nosleep.js/-/nosleep.js-0.12.0.tgz",
|
||||||
@@ -2242,6 +2469,21 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/polylabel": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/polylabel/-/polylabel-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-bxaGcA40sL3d6M4hH72Z4NdLqxpXRsCFk8AITYg6x1rn1Ei3izf00UMLklerBZTO49aPA3CYrIwVulx2Bce2pA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"tinyqueue": "^2.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/polylabel/node_modules/tinyqueue": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.28",
|
"version": "8.5.28",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz",
|
||||||
@@ -2287,6 +2529,15 @@
|
|||||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process": {
|
||||||
|
"version": "0.11.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||||
|
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/protobufjs": {
|
"node_modules/protobufjs": {
|
||||||
"version": "8.8.0",
|
"version": "8.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.8.0.tgz",
|
||||||
@@ -2385,6 +2636,12 @@
|
|||||||
"integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==",
|
"integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/robust-predicates": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.62.0",
|
"version": "4.62.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz",
|
||||||
@@ -2430,6 +2687,33 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rxjs": {
|
||||||
|
"version": "7.8.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||||
|
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/s2-geometry": {
|
||||||
|
"version": "1.2.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/s2-geometry/-/s2-geometry-1.2.10.tgz",
|
||||||
|
"integrity": "sha512-5WejfQu1XZ25ZerW8uL6xP1sM2krcOYKhI6TbfybGRf+vTQLrm3E+4n0+1lWg+MYqFjPzoe51zKhn2sBRMCt5g==",
|
||||||
|
"license": "(MIT or Apache-2 or ISC)",
|
||||||
|
"dependencies": {
|
||||||
|
"long": "^3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/s2-geometry/node_modules/long": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/satellite.js": {
|
"node_modules/satellite.js": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/satellite.js/-/satellite.js-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/satellite.js/-/satellite.js-6.0.2.tgz",
|
||||||
@@ -2565,6 +2849,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/splaytree": {
|
||||||
|
"version": "0.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/splaytree/-/splaytree-0.1.4.tgz",
|
||||||
|
"integrity": "sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -2575,6 +2865,11 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-template": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-template/-/string-template-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-Yptehjogou2xm4UJbxJ4CxgZx12HBfeystp0y3x7s4Dj32ltVVG1Gg8YhKjHZkHicuKpZX/ffilA8505VbUbpw=="
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "8.2.2",
|
"version": "8.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
|
||||||
@@ -2608,6 +2903,12 @@
|
|||||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.134.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.134.0.tgz",
|
||||||
|
"integrity": "sha512-LbBerg7GaSPjYtTOnu41AMp7tV6efUNR3p4Wk5NzkSsNTBuA5mDGOfwwZL1jhhVMLx9V20HolIUo0+U3AXehbg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -2625,6 +2926,12 @@
|
|||||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tinyqueue": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-Qz9RgWuO9l8lT+Y9xvbzhPT2efIUIFd69N7eF7tJ9lnQl0iLj1M7peK7IoUGZL9DJHw9XftqLreccfxcQgYLxA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/toidentifier": {
|
"node_modules/toidentifier": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
@@ -2678,6 +2985,22 @@
|
|||||||
"integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==",
|
"integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/virtual-dom": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/virtual-dom/-/virtual-dom-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-wb6Qc9Lbqug0kRqo/iuApfBpJJAq14Sk1faAnSmtqXiwahg7PVTvWMs9L02Z8nNIMqbwsxzBAA90bbtRLbw0zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"browser-split": "0.0.1",
|
||||||
|
"error": "^4.3.0",
|
||||||
|
"ev-store": "^7.0.0",
|
||||||
|
"global": "^4.3.0",
|
||||||
|
"is-object": "^1.0.1",
|
||||||
|
"next-tick": "^0.2.2",
|
||||||
|
"x-is-array": "0.1.0",
|
||||||
|
"x-is-string": "0.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.3",
|
"version": "6.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
|
||||||
@@ -2873,6 +3196,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/x-is-array": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/x-is-array/-/x-is-array-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-goHPif61oNrr0jJgsXRfc8oqtYzvfiMJpTqwE7Z4y9uH+T3UozkGqQ4d2nX9mB9khvA8U2o/UbPOFjgC7hLWIA=="
|
||||||
|
},
|
||||||
|
"node_modules/x-is-string": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/x-is-string/-/x-is-string-0.1.0.tgz",
|
||||||
|
"integrity": "sha512-GojqklwG8gpzOVEVki5KudKNoq7MbbjYZCbyWzEz7tyPA7eleiE0+ePwOWQQRb5fm86rD3S8Tc0tSFf3AOv50w=="
|
||||||
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
@@ -2910,16 +3252,6 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"node_modules/zod": {
|
|
||||||
"version": "3.25.76",
|
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,6 +46,9 @@
|
|||||||
"test": "node scripts/run-unit-tests.mjs",
|
"test": "node scripts/run-unit-tests.mjs",
|
||||||
"test:track": "node scripts/track-regression.mjs",
|
"test:track": "node scripts/track-regression.mjs",
|
||||||
"qa:map-source-tray": "node scripts/qa-map-source-tray.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",
|
"check:boundaries": "node scripts/check-import-directions.mjs && node scripts/check-package-boundaries.mjs",
|
||||||
"qa:transit": "node scripts/qa-transit.mjs",
|
"qa:transit": "node scripts/qa-transit.mjs",
|
||||||
"mcp": "node server/mcp/stdio.js"
|
"mcp": "node server/mcp/stdio.js"
|
||||||
@@ -58,6 +61,7 @@
|
|||||||
"cesium": "^1.124.0",
|
"cesium": "^1.124.0",
|
||||||
"egm96-universal": "^1.1.1",
|
"egm96-universal": "^1.1.1",
|
||||||
"hls.js": "^1.7.3",
|
"hls.js": "^1.7.3",
|
||||||
|
"mapillary-js": "^4.1.2",
|
||||||
"mgrs": "^2.1.0",
|
"mgrs": "^2.1.0",
|
||||||
"pbf": "^5.1.2",
|
"pbf": "^5.1.2",
|
||||||
"satellite.js": "^6.0.2"
|
"satellite.js": "^6.0.2"
|
||||||
@@ -253,6 +257,12 @@
|
|||||||
"./layers/transit/source": "./src/layers/transit/source.js",
|
"./layers/transit/source": "./src/layers/transit/source.js",
|
||||||
"./sources/transit-service": "./src/sources/transitService.js",
|
"./sources/transit-service": "./src/sources/transitService.js",
|
||||||
"./sources/map-features": "./src/sources/overpassFeatures.js",
|
"./sources/map-features": "./src/sources/overpassFeatures.js",
|
||||||
|
"./server/providers/mapillary": {
|
||||||
|
"node": "./server/providers/mapillary.js"
|
||||||
|
},
|
||||||
|
"./layers/street-level": "./src/layers/streetLevel/index.js",
|
||||||
|
"./layers/street-level/mapillary": "./src/layers/streetLevel/providers/mapillary/index.js",
|
||||||
|
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js",
|
||||||
"./tools": "./src/tools/index.js",
|
"./tools": "./src/tools/index.js",
|
||||||
"./tools/mcp": "./src/tools/mcp/index.js",
|
"./tools/mcp": "./src/tools/mcp/index.js",
|
||||||
"./tools/panel": "./src/tools/panel.js",
|
"./tools/panel": "./src/tools/panel.js",
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
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.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* use. The photo is generated for this repository, so it has no third-party
|
||||||
|
* licence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
/** The one sequence with photos: a north-south line through the parked view. */
|
||||||
|
export const PHOTO_SEQUENCE_ID = 'fx-photo';
|
||||||
|
export const PHOTO_LINE = Object.freeze({
|
||||||
|
lon: -121.4944,
|
||||||
|
south: 38.579,
|
||||||
|
north: 38.591,
|
||||||
|
});
|
||||||
|
/** Metres between consecutive photos on the line (≥ the 3 m cone thinning). */
|
||||||
|
export const PHOTO_SPACING_M = 30;
|
||||||
|
/** Fixture thumbnail host (answered, never reached). */
|
||||||
|
export const THUMB_HOST = 'qa-fixture.mapillary.com';
|
||||||
|
|
||||||
|
const METRES_PER_DEG_LAT = 110_540;
|
||||||
|
|
||||||
|
/** Ground distance in metres, as the app's metresBetween. */
|
||||||
|
export function metresApart(a, b) {
|
||||||
|
const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180;
|
||||||
|
return Math.hypot(
|
||||||
|
(b.lon - a.lon) * 111_320 * Math.cos(lat),
|
||||||
|
(b.lat - a.lat) * METRES_PER_DEG_LAT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Photos along PHOTO_LINE, south to north; every third one is a panorama.
|
||||||
|
* @returns {Array<{id: string, lon: number, lat: number, isPano: boolean, capturedAt: number, compassAngle: number}>}
|
||||||
|
*/
|
||||||
|
export function photoImages(now = Date.now()) {
|
||||||
|
const step = PHOTO_SPACING_M / METRES_PER_DEG_LAT;
|
||||||
|
const count =
|
||||||
|
Math.floor((PHOTO_LINE.north - PHOTO_LINE.south) / step + 1e-9) + 1;
|
||||||
|
return Array.from({ length: count }, (_, i) => ({
|
||||||
|
id: String(9_100_000_000_000 + i),
|
||||||
|
lon: PHOTO_LINE.lon,
|
||||||
|
lat: Number((PHOTO_LINE.south + i * step).toFixed(7)),
|
||||||
|
isPano: i % 3 === 0,
|
||||||
|
capturedAt: now - 30 * DAY_MS - i * 1000,
|
||||||
|
compassAngle: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const point = (image) => ({
|
||||||
|
type: 'Point',
|
||||||
|
coordinates: [image.lon, image.lat],
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Every field either caller asks for; extra fields are harmless to both. */
|
||||||
|
function imageRecord(image) {
|
||||||
|
const thumb = `https://${THUMB_HOST}/thumb/${image.id}.jpg`;
|
||||||
|
return {
|
||||||
|
id: image.id,
|
||||||
|
sequence: PHOTO_SEQUENCE_ID,
|
||||||
|
geometry: point(image),
|
||||||
|
computed_geometry: point(image),
|
||||||
|
altitude: 12,
|
||||||
|
computed_altitude: 12,
|
||||||
|
atomic_scale: 1,
|
||||||
|
camera_parameters: image.isPano ? [] : [0.85, 0, 0],
|
||||||
|
camera_type: image.isPano ? 'spherical' : 'perspective',
|
||||||
|
captured_at: image.capturedAt,
|
||||||
|
compass_angle: image.compassAngle,
|
||||||
|
computed_compass_angle: image.compassAngle,
|
||||||
|
// Angle-axis world-to-camera rotation: +90° about east looks north, level.
|
||||||
|
computed_rotation: [Math.PI / 2, 0, 0],
|
||||||
|
creator: { id: '1', username: 'qa-fixture' },
|
||||||
|
exif_orientation: 1,
|
||||||
|
height: 320,
|
||||||
|
width: 640,
|
||||||
|
is_pano: image.isPano,
|
||||||
|
merge_cc: null,
|
||||||
|
mesh: null,
|
||||||
|
organization: null,
|
||||||
|
quality_score: 0.9,
|
||||||
|
sfm_cluster: null,
|
||||||
|
thumb_256_url: thumb,
|
||||||
|
thumb_1024_url: thumb,
|
||||||
|
thumb_2048_url: thumb,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The requested fields plus id, as the Graph API answers. */
|
||||||
|
function withFields(record, fields) {
|
||||||
|
if (!fields) return record;
|
||||||
|
const out = { id: record.id };
|
||||||
|
for (const field of fields.split(','))
|
||||||
|
if (field in record) out[field] = record[field];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORS = Object.freeze({
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||||
|
'Access-Control-Allow-Headers': 'Accept, Authorization, Content-Type',
|
||||||
|
'Access-Control-Max-Age': '600',
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = (status, body) => ({
|
||||||
|
status,
|
||||||
|
contentType: 'application/json',
|
||||||
|
headers: CORS,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
let photoBytes = null;
|
||||||
|
export function photoJpeg() {
|
||||||
|
photoBytes ??= readFileSync(new URL('./street-640x320.jpg', import.meta.url));
|
||||||
|
return photoBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Name a call by its parameter names only, so the token is never echoed. */
|
||||||
|
export function describeCall(method, url) {
|
||||||
|
const keys = [...url.searchParams.keys()]
|
||||||
|
.filter((key) => key !== 'access_token')
|
||||||
|
.sort();
|
||||||
|
const path = url.pathname
|
||||||
|
.replace(/^\/\d{6,}(?=\/|$)/, '/{imageId}')
|
||||||
|
.replace(/^\/thumb\/[\w-]+\.jpg$/, '/thumb/{imageId}.jpg');
|
||||||
|
return `${method} ${url.hostname}${path}${keys.length ? `?${keys.join('&')}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answer one Mapillary request. `known` is false for a request no fixture
|
||||||
|
* covers, so the gate fails on a new MapillaryJS call instead of missing it.
|
||||||
|
* @param {{method: string, url: string, images?: ReturnType<typeof photoImages>}} request
|
||||||
|
* @returns {{status: number, contentType?: string, headers?: object, body: string|Buffer, known: boolean}}
|
||||||
|
*/
|
||||||
|
export function answerMapillaryRequest({ method, url, images }) {
|
||||||
|
const address = new URL(url);
|
||||||
|
const photos = images ?? photoImages();
|
||||||
|
const byId = new Map(photos.map((image) => [image.id, image]));
|
||||||
|
if (method === 'OPTIONS')
|
||||||
|
return { status: 204, headers: CORS, body: '', known: true };
|
||||||
|
if (address.hostname === THUMB_HOST) {
|
||||||
|
if (/^\/thumb\/[\w-]+\.jpg$/.test(address.pathname))
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
headers: CORS,
|
||||||
|
body: photoJpeg(),
|
||||||
|
known: true,
|
||||||
|
};
|
||||||
|
return { ...json(404, { error: 'no such fixture' }), known: false };
|
||||||
|
}
|
||||||
|
if (address.hostname !== 'graph.mapillary.com')
|
||||||
|
return { ...json(404, { error: 'no such fixture host' }), known: false };
|
||||||
|
const q = address.searchParams;
|
||||||
|
const fields = q.get('fields');
|
||||||
|
const path = address.pathname;
|
||||||
|
if (path === '/images') {
|
||||||
|
if (q.has('image_ids')) {
|
||||||
|
const data = q
|
||||||
|
.get('image_ids')
|
||||||
|
.split(',')
|
||||||
|
.map((id) => byId.get(id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((image) => withFields(imageRecord(image), fields));
|
||||||
|
return { ...json(200, { data }), known: true };
|
||||||
|
}
|
||||||
|
if (q.has('sequence_ids')) {
|
||||||
|
const ids = q.get('sequence_ids').split(',');
|
||||||
|
const limit = Number(q.get('limit')) || photos.length;
|
||||||
|
const data = ids.includes(PHOTO_SEQUENCE_ID)
|
||||||
|
? photos
|
||||||
|
.slice(0, limit)
|
||||||
|
.map((image) => withFields(imageRecord(image), fields))
|
||||||
|
: [];
|
||||||
|
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' }),
|
||||||
|
known: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (path === '/image_ids' && q.has('sequence_id')) {
|
||||||
|
const data =
|
||||||
|
q.get('sequence_id') === PHOTO_SEQUENCE_ID
|
||||||
|
? photos.map((image) => ({ id: image.id }))
|
||||||
|
: [];
|
||||||
|
return { ...json(200, { data }), known: true };
|
||||||
|
}
|
||||||
|
const tiles = path.match(/^\/([\w-]+)\/tiles$/);
|
||||||
|
if (tiles) return { ...json(200, { data: [] }), known: true };
|
||||||
|
const entity = path.match(/^\/([\w-]+)$/);
|
||||||
|
if (entity && byId.has(entity[1]))
|
||||||
|
return {
|
||||||
|
...json(200, withFields(imageRecord(byId.get(entity[1])), fields)),
|
||||||
|
known: true,
|
||||||
|
};
|
||||||
|
return { ...json(404, { error: 'no such fixture' }), known: false };
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
@@ -36,6 +36,9 @@
|
|||||||
"src/main.js",
|
"src/main.js",
|
||||||
"docs/APPLICATION.md",
|
"docs/APPLICATION.md",
|
||||||
"scripts/qa-application.mjs",
|
"scripts/qa-application.mjs",
|
||||||
|
"scripts/qa-panel-resize.mjs",
|
||||||
|
"scripts/qa-panelDrag.mjs",
|
||||||
|
"src/tooling/panelResizeQa.test.mjs",
|
||||||
"scripts/qa-terrain-429.mjs",
|
"scripts/qa-terrain-429.mjs",
|
||||||
"src/app/application.js",
|
"src/app/application.js",
|
||||||
"src/app/application.test.mjs",
|
"src/app/application.test.mjs",
|
||||||
@@ -165,6 +168,7 @@
|
|||||||
"src/ui/panelRails.js",
|
"src/ui/panelRails.js",
|
||||||
"src/ui/leftPanelRail.js",
|
"src/ui/leftPanelRail.js",
|
||||||
"src/ui/rightPanelRail.js",
|
"src/ui/rightPanelRail.js",
|
||||||
|
"src/ui/panelResize.test.mjs",
|
||||||
"src/ui/panelMeasurement.js",
|
"src/ui/panelMeasurement.js",
|
||||||
"src/ui/panelRailGeometry.js",
|
"src/ui/panelRailGeometry.js",
|
||||||
"src/ui/panelRails.test.mjs",
|
"src/ui/panelRails.test.mjs",
|
||||||
@@ -271,6 +275,7 @@
|
|||||||
"src/ui/styles.js",
|
"src/ui/styles.js",
|
||||||
"src/ui/shellLifecycle.test.mjs",
|
"src/ui/shellLifecycle.test.mjs",
|
||||||
"src/testSupport/readStylesheet.mjs",
|
"src/testSupport/readStylesheet.mjs",
|
||||||
|
"src/testSupport/streetLevelFakes.mjs",
|
||||||
"src/splitFlap.js",
|
"src/splitFlap.js",
|
||||||
"style.css",
|
"style.css",
|
||||||
"src/ui/styles/overlays.css",
|
"src/ui/styles/overlays.css",
|
||||||
@@ -856,6 +861,68 @@
|
|||||||
"src/ui/recentImagery.test.mjs",
|
"src/ui/recentImagery.test.mjs",
|
||||||
"src/layers/recentImagery/testDoubles.mjs",
|
"src/layers/recentImagery/testDoubles.mjs",
|
||||||
"scripts/qa-recent-imagery.mjs",
|
"scripts/qa-recent-imagery.mjs",
|
||||||
|
"src/ui/streetLevelControls.js",
|
||||||
|
"src/ui/streetLevelPresentation.js",
|
||||||
|
"src/ui/streetLevelPresentation.test.mjs",
|
||||||
|
"src/app/layers/streetLevel.js",
|
||||||
|
"src/tooling/mapillaryProvider.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/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/glyphs.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||||
|
"src/layers/streetLevel/tileMath.js",
|
||||||
|
"src/layers/streetLevel/selection.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||||
|
"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",
|
||||||
|
"scripts/qa-street-level.mjs",
|
||||||
|
"src/tooling/streetLevelQa.test.mjs",
|
||||||
|
"scripts/qa-browserEvidence.mjs",
|
||||||
|
"scripts/fixtures/street-level/mapillaryGraph.mjs",
|
||||||
|
"src/layers/streetLevel/filter.test.mjs",
|
||||||
|
"src/layers/streetLevel/registry.test.mjs",
|
||||||
|
"src/layers/streetLevel/params.test.mjs",
|
||||||
|
"src/layers/streetLevel/pickRouter.test.mjs",
|
||||||
|
"src/layers/streetLevel/uiState.test.mjs",
|
||||||
|
"src/layers/streetLevel/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/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/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/selection.test.mjs",
|
||||||
|
"src/layers/streetLevel/index.test.mjs",
|
||||||
"scripts/qa-overpass-offload.mjs",
|
"scripts/qa-overpass-offload.mjs",
|
||||||
"src/overpassOffload.test.mjs",
|
"src/overpassOffload.test.mjs",
|
||||||
"src/sources/openFreeMap.test.mjs",
|
"src/sources/openFreeMap.test.mjs",
|
||||||
|
|||||||
@@ -183,6 +183,24 @@
|
|||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
|
"mapillary-provider": {
|
||||||
|
"runtime": "node",
|
||||||
|
"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",
|
||||||
|
"src/localRequestGate.mjs",
|
||||||
|
"server/providers/common/rate-limit.js",
|
||||||
|
"src/sources/rateLimit.js",
|
||||||
|
"server/providers/common/http.js",
|
||||||
|
"src/sources/httpBody.js"
|
||||||
|
],
|
||||||
|
"external": ["pbf"]
|
||||||
|
},
|
||||||
"radio-provider": {
|
"radio-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": ["./server/providers/radio"],
|
"exports": ["./server/providers/radio"],
|
||||||
@@ -605,6 +623,7 @@
|
|||||||
"src/ui/panelLayoutController.js",
|
"src/ui/panelLayoutController.js",
|
||||||
"src/ui/panelMeasurement.js",
|
"src/ui/panelMeasurement.js",
|
||||||
"src/ui/panelPositionControls.js",
|
"src/ui/panelPositionControls.js",
|
||||||
|
"src/ui/panelResize.js",
|
||||||
"src/ui/panelRailGeometry.js",
|
"src/ui/panelRailGeometry.js",
|
||||||
"src/ui/panelRails.js",
|
"src/ui/panelRails.js",
|
||||||
"src/ui/radio.js",
|
"src/ui/radio.js",
|
||||||
@@ -635,6 +654,8 @@
|
|||||||
"src/ui/layerBindings.js",
|
"src/ui/layerBindings.js",
|
||||||
"src/ui/displayBindings.js",
|
"src/ui/displayBindings.js",
|
||||||
"src/ui/shellFacade.js",
|
"src/ui/shellFacade.js",
|
||||||
|
"src/ui/streetLevelControls.js",
|
||||||
|
"src/ui/streetLevelPresentation.js",
|
||||||
"src/ui/localSdrControls.js",
|
"src/ui/localSdrControls.js",
|
||||||
"src/layers/localAdsb/feedNames.js",
|
"src/layers/localAdsb/feedNames.js",
|
||||||
"src/ui/localSdrPresentation.js",
|
"src/ui/localSdrPresentation.js",
|
||||||
@@ -1155,6 +1176,43 @@
|
|||||||
],
|
],
|
||||||
"external": ["hls.js", "cesium"]
|
"external": ["hls.js", "cesium"]
|
||||||
},
|
},
|
||||||
|
"street-level-layer": {
|
||||||
|
"exports": [
|
||||||
|
"./layers/street-level",
|
||||||
|
"./layers/street-level/mapillary",
|
||||||
|
"./layers/street-level/mapillary/source"
|
||||||
|
],
|
||||||
|
"modules": [
|
||||||
|
"src/layers/streetLevel/index.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||||
|
"src/layers/streetLevel/viewerHost.js",
|
||||||
|
"src/layers/streetLevel/credits.js",
|
||||||
|
"src/layers/streetLevel/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",
|
||||||
|
"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/glyphs.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||||
|
"src/layers/streetLevel/tileMath.js",
|
||||||
|
"src/layers/streetLevel/selection.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||||
|
"scripts/qa-street-level.mjs"
|
||||||
|
],
|
||||||
|
"external": ["cesium", "pbf", "@mapbox/vector-tile", "mapillary-js"]
|
||||||
|
},
|
||||||
"alpr-cameras": {
|
"alpr-cameras": {
|
||||||
"exports": ["./layers/alpr"],
|
"exports": ["./layers/alpr"],
|
||||||
"modules": [
|
"modules": [
|
||||||
@@ -1854,6 +1912,7 @@
|
|||||||
"src/ui/panelLayoutController.js",
|
"src/ui/panelLayoutController.js",
|
||||||
"src/ui/panelMeasurement.js",
|
"src/ui/panelMeasurement.js",
|
||||||
"src/ui/panelPositionControls.js",
|
"src/ui/panelPositionControls.js",
|
||||||
|
"src/ui/panelResize.js",
|
||||||
"src/ui/panelRailGeometry.js",
|
"src/ui/panelRailGeometry.js",
|
||||||
"src/ui/panelRails.js",
|
"src/ui/panelRails.js",
|
||||||
"src/ui/radio.js",
|
"src/ui/radio.js",
|
||||||
@@ -2042,6 +2101,35 @@
|
|||||||
"src/maps/imageryHost.js",
|
"src/maps/imageryHost.js",
|
||||||
"src/ui/recentImagery.js",
|
"src/ui/recentImagery.js",
|
||||||
"src/ui/imageryBoxTool.js",
|
"src/ui/imageryBoxTool.js",
|
||||||
|
"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",
|
||||||
|
"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/glyphs.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||||
|
"src/layers/streetLevel/tileMath.js",
|
||||||
|
"src/layers/streetLevel/selection.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||||
|
"src/app/layers/streetLevel.js",
|
||||||
"src/layers/alpr/tileRecords.js",
|
"src/layers/alpr/tileRecords.js",
|
||||||
"src/sources/capability.js",
|
"src/sources/capability.js",
|
||||||
"src/sources/openFreeMap.js",
|
"src/sources/openFreeMap.js",
|
||||||
@@ -2070,6 +2158,7 @@
|
|||||||
"mgrs",
|
"mgrs",
|
||||||
"pbf",
|
"pbf",
|
||||||
"satellite.js",
|
"satellite.js",
|
||||||
|
"mapillary-js",
|
||||||
"@jtarrio/webrtlsdr"
|
"@jtarrio/webrtlsdr"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -2535,6 +2624,33 @@
|
|||||||
"src/layers/recentImagery/rendering.js",
|
"src/layers/recentImagery/rendering.js",
|
||||||
"src/layers/recentImagery/thumbnails.js",
|
"src/layers/recentImagery/thumbnails.js",
|
||||||
"src/maps/imageryHost.js",
|
"src/maps/imageryHost.js",
|
||||||
|
"src/layers/streetLevel/index.js",
|
||||||
|
"src/layers/streetLevel/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",
|
||||||
|
"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/glyphs.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/decode.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/coverageFixture.mjs",
|
||||||
|
"src/layers/streetLevel/tileMath.js",
|
||||||
|
"src/layers/streetLevel/selection.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/viewer.js",
|
||||||
|
"src/layers/streetLevel/providers/mapillary/source.js",
|
||||||
|
"src/app/layers/streetLevel.js",
|
||||||
"src/layers/alpr/tileRecords.js",
|
"src/layers/alpr/tileRecords.js",
|
||||||
"src/sources/capability.js",
|
"src/sources/capability.js",
|
||||||
"src/sources/openFreeMap.js",
|
"src/sources/openFreeMap.js",
|
||||||
@@ -2559,6 +2675,7 @@
|
|||||||
"@mapbox/vector-tile",
|
"@mapbox/vector-tile",
|
||||||
"pbf",
|
"pbf",
|
||||||
"mgrs",
|
"mgrs",
|
||||||
|
"mapillary-js",
|
||||||
"@jtarrio/webrtlsdr"
|
"@jtarrio/webrtlsdr"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -2692,6 +2809,7 @@
|
|||||||
"src/ui/panelLayoutController.js",
|
"src/ui/panelLayoutController.js",
|
||||||
"src/ui/panelMeasurement.js",
|
"src/ui/panelMeasurement.js",
|
||||||
"src/ui/panelPositionControls.js",
|
"src/ui/panelPositionControls.js",
|
||||||
|
"src/ui/panelResize.js",
|
||||||
"src/ui/panelRailGeometry.js",
|
"src/ui/panelRailGeometry.js",
|
||||||
"src/ui/panelRails.js",
|
"src/ui/panelRails.js",
|
||||||
"src/ui/rightPanelRail.js",
|
"src/ui/rightPanelRail.js",
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Evidence the browser QA gates collect while they run and save under
|
||||||
|
* `qa-artifacts/<gate>/` when they fail (CI uploads that directory). Tokens
|
||||||
|
* and keys are masked before anything is printed or saved.
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
/** Console messages and failed requests kept per page. */
|
||||||
|
export 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;
|
||||||
|
|
||||||
|
/** Mask tokens in a URL or message before it is printed or saved. */
|
||||||
|
export function redact(text) {
|
||||||
|
return String(text ?? '')
|
||||||
|
.replace(
|
||||||
|
/([?&](?:access_token|token|key|api_key|apikey|client_secret|signature)=)[^&\s"']*/gi,
|
||||||
|
'$1REDACTED',
|
||||||
|
)
|
||||||
|
.replace(/MLY\|[^\s&"',)]+/g, 'MLY|REDACTED')
|
||||||
|
.replace(/AIza[\w-]{30,}/g, 'AIza…REDACTED');
|
||||||
|
}
|
||||||
|
|
||||||
|
function push(list, entry) {
|
||||||
|
list.push(entry);
|
||||||
|
if (list.length > EVIDENCE_LIMIT)
|
||||||
|
list.splice(0, list.length - EVIDENCE_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to `viewer.scene.renderError` in every document the page loads
|
||||||
|
* (reloads included), into `window.__qaRenderErrors`. Call before `goto`.
|
||||||
|
*/
|
||||||
|
export function hookRenderErrors(page) {
|
||||||
|
return page.evaluateOnNewDocument(() => {
|
||||||
|
window.__qaRenderErrors = [];
|
||||||
|
const hook = () => {
|
||||||
|
const scene = window.__godsEyeView?.viewer?.scene;
|
||||||
|
if (!scene?.renderError) return false;
|
||||||
|
scene.renderError.addEventListener((_, error) =>
|
||||||
|
window.__qaRenderErrors.push(
|
||||||
|
String(error?.stack || error?.message || error),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (hook()) clearInterval(timer);
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readRenderErrors(page) {
|
||||||
|
try {
|
||||||
|
return await page.evaluate(() => window.__qaRenderErrors ?? []);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch one page's console, errors and failed requests. `errors` is shared
|
||||||
|
* across pages; `isError(text)` adds console lines a gate treats as errors.
|
||||||
|
* @returns {{name: string, page: object, console: Array<object>, failed: Array<object>, renderErrors: Array<string>}}
|
||||||
|
*/
|
||||||
|
export function watchPage(page, { name, errors, isError = () => false }) {
|
||||||
|
const monitor = { name, page, console: [], failed: [], renderErrors: [] };
|
||||||
|
page.on('console', (message) => {
|
||||||
|
const text = redact(message.text());
|
||||||
|
push(monitor.console, { type: message.type(), text });
|
||||||
|
if (message.type() === 'error' && RENDER_ERROR_PATTERN.test(text)) {
|
||||||
|
monitor.renderErrors.push(text);
|
||||||
|
errors.push(`[${name}] render loop: ${text}`);
|
||||||
|
} else if (isError(text)) errors.push(`[${name}] ${text}`);
|
||||||
|
});
|
||||||
|
page.on('pageerror', (error) => {
|
||||||
|
const text = redact(error?.stack || error?.message || error);
|
||||||
|
push(monitor.console, { type: 'pageerror', text });
|
||||||
|
errors.push(`[${name}] ${text}`);
|
||||||
|
});
|
||||||
|
page.on('requestfailed', (request) =>
|
||||||
|
push(monitor.failed, {
|
||||||
|
url: redact(request.url()),
|
||||||
|
method: request.method(),
|
||||||
|
failure: request.failure()?.errorText ?? 'failed',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
page.on('response', (response) => {
|
||||||
|
if (response.status() < 400) return;
|
||||||
|
push(monitor.failed, {
|
||||||
|
url: redact(response.url()),
|
||||||
|
method: response.request().method(),
|
||||||
|
status: response.status(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return monitor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const withTimeout = (promise, ms, label) =>
|
||||||
|
Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(`${label} timed out`)), ms),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
function streetLevelState(page) {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const module =
|
||||||
|
window.__godsEyeView?.dataManager?.layers?.get('street-level')?.module;
|
||||||
|
return module?.getUIState ? module.getUIState() : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a screenshot and JSON record per page, plus the error. A page that
|
||||||
|
* cannot be captured records why instead.
|
||||||
|
* @returns {Promise<string>} the directory written
|
||||||
|
*/
|
||||||
|
export async function saveFailureArtifacts({ dir, browser, monitors, error }) {
|
||||||
|
await fs.mkdir(dir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(dir, 'error.txt'),
|
||||||
|
redact(error?.stack || error?.message || String(error)),
|
||||||
|
);
|
||||||
|
const known = new Map(monitors.map((monitor) => [monitor.page, monitor]));
|
||||||
|
let pages = [];
|
||||||
|
try {
|
||||||
|
pages = await browser.pages();
|
||||||
|
} catch {
|
||||||
|
pages = monitors.map((monitor) => monitor.page);
|
||||||
|
}
|
||||||
|
let index = 0;
|
||||||
|
for (const page of pages) {
|
||||||
|
const monitor = known.get(page);
|
||||||
|
// The browser's first blank tab holds nothing worth saving.
|
||||||
|
if (!monitor && page.url() === 'about:blank') continue;
|
||||||
|
const base = `${String(index++).padStart(2, '0')}-${monitor?.name ?? 'page'}`;
|
||||||
|
const record = { url: redact(page.url()) };
|
||||||
|
try {
|
||||||
|
await withTimeout(
|
||||||
|
page.screenshot({
|
||||||
|
path: path.join(dir, `${base}.png`),
|
||||||
|
fullPage: true,
|
||||||
|
}),
|
||||||
|
30_000,
|
||||||
|
'screenshot',
|
||||||
|
);
|
||||||
|
} catch (shotError) {
|
||||||
|
record.screenshotError = String(shotError?.message || shotError);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
record.streetLevel = await withTimeout(
|
||||||
|
streetLevelState(page),
|
||||||
|
10_000,
|
||||||
|
'getUIState',
|
||||||
|
);
|
||||||
|
} catch (stateError) {
|
||||||
|
record.streetLevelError = String(stateError?.message || stateError);
|
||||||
|
}
|
||||||
|
record.renderErrors = [
|
||||||
|
...(monitor?.renderErrors ?? []),
|
||||||
|
...(await withTimeout(readRenderErrors(page), 10_000, 'render errors')
|
||||||
|
.then((list) => list.map((text) => `scene.renderError: ${text}`))
|
||||||
|
.catch(() => [])),
|
||||||
|
].map(redact);
|
||||||
|
record.console = monitor?.console ?? [];
|
||||||
|
record.failedRequests = monitor?.failed ?? [];
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(dir, `${base}.json`),
|
||||||
|
redact(JSON.stringify(record, null, 2)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
@@ -0,0 +1,456 @@
|
|||||||
|
#!/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 near the top: the voice dock would cover the south corners once
|
||||||
|
// the loop below has grown the window.
|
||||||
|
{
|
||||||
|
const header = await headerPoint();
|
||||||
|
await drag(header, 0, 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);
|
||||||
|
};
|
||||||
|
await drag(handlePoint(await readPanel(), 'se'), 0, 200);
|
||||||
|
const chosen = await readPanel();
|
||||||
|
assert.ok(
|
||||||
|
chosen.height > MIN_SIZE.height + 150,
|
||||||
|
`the window grew to ${chosen.height}px`,
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
/**
|
||||||
|
* 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}`;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -34,6 +34,7 @@ export const CREDENTIALS = Object.freeze([
|
|||||||
)),
|
)),
|
||||||
},
|
},
|
||||||
{ name: 'LL2_API_TOKEN', label: 'Launch Library 2', keychain: [] },
|
{ name: 'LL2_API_TOKEN', label: 'Launch Library 2', keychain: [] },
|
||||||
|
{ name: 'MAPILLARY_CLIENT_TOKEN', label: 'Mapillary street level', keychain: [['mapillary', 'client-token'], ['mapillary', 'token']] },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function isConfiguredValue(value) {
|
export function isConfiguredValue(value) {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { aisLiveProxy } from './vessels/ais-live.js';
|
|||||||
import { trackBackfillProxies } from './aircraft/tracks.js';
|
import { trackBackfillProxies } from './aircraft/tracks.js';
|
||||||
import { openAiRealtimeProxy } from './openai.js';
|
import { openAiRealtimeProxy } from './openai.js';
|
||||||
import { googlePlacesContextProxy } from './places.js';
|
import { googlePlacesContextProxy } from './places.js';
|
||||||
|
import { mapillaryProxy } from './mapillary.js';
|
||||||
import { keySetupEndpoint } from '../standalone/key-setup.js';
|
import { keySetupEndpoint } from '../standalone/key-setup.js';
|
||||||
import { weatherProxy } from './weather.js';
|
import { weatherProxy } from './weather.js';
|
||||||
import { firePerimetersProxy } from './firePerimeters.js';
|
import { firePerimetersProxy } from './firePerimeters.js';
|
||||||
@@ -57,6 +58,7 @@ function localProviderPlugins({ realtime } = {}) {
|
|||||||
windProxy(),
|
windProxy(),
|
||||||
weatherProxy(),
|
weatherProxy(),
|
||||||
cycloneProxy(),
|
cycloneProxy(),
|
||||||
|
mapillaryProxy(),
|
||||||
firePerimetersProxy(),
|
firePerimetersProxy(),
|
||||||
keySetupEndpoint(),
|
keySetupEndpoint(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { installMapillaryRoutes } from './mapillary/routes.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vite plugin: cached Mapillary coverage tiles (token added server-side) and a
|
||||||
|
* status route that says whether a token is configured.
|
||||||
|
*/
|
||||||
|
function mapillaryProxy() {
|
||||||
|
return {
|
||||||
|
name: 'mapillary-proxy',
|
||||||
|
configureServer(server) {
|
||||||
|
installMapillaryRoutes(server.middlewares);
|
||||||
|
},
|
||||||
|
configurePreviewServer(server) {
|
||||||
|
installMapillaryRoutes(server.middlewares);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { mapillaryProxy };
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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`. */
|
||||||
|
export class TileUpstreamError extends Error {
|
||||||
|
constructor(status, message, { retryAfterSec = null } = {}) {
|
||||||
|
super(message || `Mapillary tiles HTTP ${status}`);
|
||||||
|
this.name = 'TileUpstreamError';
|
||||||
|
this.status = status;
|
||||||
|
this.keyRejected = status === 401 || status === 403;
|
||||||
|
this.retryAfterSec = retryAfterSec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function heldRefusal(token) {
|
||||||
|
const hold = _upstreamHold;
|
||||||
|
if (!hold) return null;
|
||||||
|
const left = hold.until - Date.now();
|
||||||
|
if (left <= 0 || hold.token !== token) {
|
||||||
|
_upstreamHold = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new TileUpstreamError(hold.status, undefined, {
|
||||||
|
retryAfterSec: 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}}
|
||||||
|
*/
|
||||||
|
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}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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);
|
||||||
|
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 });
|
||||||
|
} 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);
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
// 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. */
|
||||||
|
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))
|
||||||
|
.then((bytes) => {
|
||||||
|
memoryPut(address.key, bytes);
|
||||||
|
writeDisk(address, bytes);
|
||||||
|
return bytes;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (_inFlight.get(address.key) === flight) _inFlight.delete(address.key);
|
||||||
|
});
|
||||||
|
// Every waiter may have left before it settles; nobody awaits it then.
|
||||||
|
flight.promise.catch(() => {});
|
||||||
|
_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.
|
||||||
|
*/
|
||||||
|
function joinFlight(flight, signal) {
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { PbfReader, PbfWriter } from 'pbf';
|
||||||
|
|
||||||
|
/** Field number of `layers` in a Mapbox Vector Tile, and of `name` in a layer. */
|
||||||
|
const TILE_LAYER_FIELD = 3;
|
||||||
|
const LAYER_NAME_FIELD = 1;
|
||||||
|
|
||||||
|
/** Read a layer's name without touching its features. */
|
||||||
|
function layerName(layerBytes) {
|
||||||
|
const pbf = new PbfReader(layerBytes);
|
||||||
|
let name = '';
|
||||||
|
pbf.readFields((tag, _result, reader) => {
|
||||||
|
if (tag !== LAYER_NAME_FIELD) return;
|
||||||
|
name = reader.readString();
|
||||||
|
reader.pos = reader.length; // the name is all we need
|
||||||
|
}, null);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop whole layers from a vector tile without decoding kept layers, so a
|
||||||
|
* 12 MB tile trims in milliseconds. Returns the input when nothing was dropped.
|
||||||
|
* @param {Buffer|Uint8Array} bytes
|
||||||
|
* @param {Iterable<string>} dropNames
|
||||||
|
*/
|
||||||
|
export function stripTileLayers(bytes, dropNames) {
|
||||||
|
const drop = new Set(dropNames || []);
|
||||||
|
if (!bytes?.length || !drop.size) return bytes;
|
||||||
|
const reader = new PbfReader(bytes);
|
||||||
|
const writer = new PbfWriter();
|
||||||
|
let dropped = false;
|
||||||
|
reader.readFields((tag, _result, pbf) => {
|
||||||
|
if (tag !== TILE_LAYER_FIELD) return;
|
||||||
|
const end = pbf.readVarint() + pbf.pos;
|
||||||
|
const layerBytes = bytes.subarray(pbf.pos, end);
|
||||||
|
pbf.pos = end;
|
||||||
|
if (drop.has(layerName(layerBytes))) {
|
||||||
|
dropped = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writer.writeBytesField(TILE_LAYER_FIELD, layerBytes);
|
||||||
|
}, 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;
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ export default defineConfig(({ command, mode }) => {
|
|||||||
],
|
],
|
||||||
googleApiKey: process.env.GOOGLE_MAPS_API_KEY,
|
googleApiKey: process.env.GOOGLE_MAPS_API_KEY,
|
||||||
cesiumToken: process.env.CESIUM_ION_TOKEN,
|
cesiumToken: process.env.CESIUM_ION_TOKEN,
|
||||||
|
mapillaryToken: process.env.MAPILLARY_CLIENT_TOKEN,
|
||||||
host: process.env.HOST,
|
host: process.env.HOST,
|
||||||
port: process.env.PORT,
|
port: process.env.PORT,
|
||||||
allowedHosts: resolveAllowedHosts(process.env.GEV_ALLOWED_HOSTS),
|
allowedHosts: resolveAllowedHosts(process.env.GEV_ALLOWED_HOSTS),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
|
|||||||
militaryFlightsLayer: 'military',
|
militaryFlightsLayer: 'military',
|
||||||
satellitesLayer: 'satellites',
|
satellitesLayer: 'satellites',
|
||||||
cctvLayer: 'cctv',
|
cctvLayer: 'cctv',
|
||||||
|
streetLevelLayer: 'street-level',
|
||||||
radioLayer: 'radio',
|
radioLayer: 'radio',
|
||||||
bikeshareLayer: 'bikeshare',
|
bikeshareLayer: 'bikeshare',
|
||||||
transitLayer: 'transit',
|
transitLayer: 'transit',
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
|||||||
'military-awareness',
|
'military-awareness',
|
||||||
'military-installations',
|
'military-installations',
|
||||||
'rocket-launches',
|
'rocket-launches',
|
||||||
|
'street-level',
|
||||||
'local-adsb',
|
'local-adsb',
|
||||||
];
|
];
|
||||||
const layers = ids.map((id) => ({ id }));
|
const layers = ids.map((id) => ({ id }));
|
||||||
@@ -101,7 +102,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
|
|||||||
assert.equal(services.flightsLayer, layers[1]);
|
assert.equal(services.flightsLayer, layers[1]);
|
||||||
assert.equal(services.transitLayer, layers[7]);
|
assert.equal(services.transitLayer, layers[7]);
|
||||||
assert.equal(services.aisLiveVesselsLayer, layers[8]);
|
assert.equal(services.aisLiveVesselsLayer, layers[8]);
|
||||||
assert.equal(services.localAdsbLayer, layers[12]);
|
assert.equal(services.localAdsbLayer, layers[13]);
|
||||||
assert.equal(new Set(Object.values(services)).size, layers.length);
|
assert.equal(new Set(Object.values(services)).size, layers.length);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => catalogControlServices(createLayerCatalog([], [])),
|
() => catalogControlServices(createLayerCatalog([], [])),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createWeatherLayer } from '../layers/weather/index.js';
|
|||||||
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
||||||
import { createWindLayer } from '../layers/wind/index.js';
|
import { createWindLayer } from '../layers/wind/index.js';
|
||||||
import { createLayerCatalog } from './catalog.js';
|
import { createLayerCatalog } from './catalog.js';
|
||||||
|
import { MAPILLARY_SOURCE_METHODS } from '../layers/streetLevel/providers/mapillary/policy.js';
|
||||||
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
||||||
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
|
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
|
||||||
import { createApplicationFlights } from './layers/flights.js';
|
import { createApplicationFlights } from './layers/flights.js';
|
||||||
@@ -25,6 +26,7 @@ import { createApplicationFirms } from './layers/firms.js';
|
|||||||
import { createApplicationEarthquakes } from './layers/earthquakes.js';
|
import { createApplicationEarthquakes } from './layers/earthquakes.js';
|
||||||
import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
import { createApplicationFirePerimeters } from './layers/perimeters.js';
|
||||||
import { createApplicationCables } from './layers/submarineCables.js';
|
import { createApplicationCables } from './layers/submarineCables.js';
|
||||||
|
import { createApplicationStreetLevel } from './layers/streetLevel.js';
|
||||||
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
import { createInfrastructureLayers } from '../data/infrastructure.js';
|
||||||
import { localGeoJsonServices } from './localGeojsonServices.js';
|
import { localGeoJsonServices } from './localGeojsonServices.js';
|
||||||
import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
|
import { createBhoteKoshiEventLayer } from '../data/bhoteKoshiEvent.js';
|
||||||
@@ -55,6 +57,7 @@ const SOURCE_METHODS = Object.freeze({
|
|||||||
earthquakes: ['getSnapshot'],
|
earthquakes: ['getSnapshot'],
|
||||||
'fire-perimeters': ['getSnapshot'],
|
'fire-perimeters': ['getSnapshot'],
|
||||||
cables: ['fetch'],
|
cables: ['fetch'],
|
||||||
|
mapillary: MAPILLARY_SOURCE_METHODS,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,6 +151,10 @@ export function createApplicationCatalog({
|
|||||||
source: sources['fire-perimeters'],
|
source: sources['fire-perimeters'],
|
||||||
}),
|
}),
|
||||||
createApplicationAlpr({ surface, source: sources.alpr }),
|
createApplicationAlpr({ surface, source: sources.alpr }),
|
||||||
|
createApplicationStreetLevel({
|
||||||
|
surface,
|
||||||
|
sources: { mapillary: sources.mapillary },
|
||||||
|
}),
|
||||||
satellites,
|
satellites,
|
||||||
createApplicationLaunches({ source: sources.launches, satellites }),
|
createApplicationLaunches({ source: sources.launches, satellites }),
|
||||||
createApplicationTraffic({ source: sources.traffic, surface }),
|
createApplicationTraffic({ source: sources.traffic, surface }),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ test('catalogs construct distinct layers and classification from their supplied
|
|||||||
signal: b.signal,
|
signal: b.signal,
|
||||||
surface: fixtureSurface(b.signal),
|
surface: fixtureSurface(b.signal),
|
||||||
});
|
});
|
||||||
assert.equal(first.layers.length, 29);
|
assert.equal(first.layers.length, 30);
|
||||||
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
first.metadata.find(({ id }) => id === 'local-adsb'),
|
first.metadata.find(({ id }) => id === 'local-adsb'),
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
|
||||||
|
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
|
||||||
|
import * as sprites from '../../data/spriteOrder.js';
|
||||||
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
|
import * as input from '../../data/inputOwnership.js';
|
||||||
|
import * as render from '../../renderGovernor.js';
|
||||||
|
|
||||||
|
/** A new provider registers here; its chip, credit and share bit follow. */
|
||||||
|
export function createApplicationStreetLevel({ surface, sources }) {
|
||||||
|
return createStreetLevelLayer({
|
||||||
|
providers: [createMapillaryProvider({ source: sources.mapillary })],
|
||||||
|
services: {
|
||||||
|
sprites,
|
||||||
|
picking,
|
||||||
|
input,
|
||||||
|
render,
|
||||||
|
ground: surface?.groundFloor ?? null,
|
||||||
|
meshFloor: surface?.meshFloor ?? null,
|
||||||
|
terrain: surface?.terrain ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
+1069
-236
File diff suppressed because it is too large.
Load diff
@@ -410,6 +410,18 @@ const OPTION_GROUPS = Object.freeze({
|
|||||||
),
|
),
|
||||||
booleanOption('viirs', 'v', false),
|
booleanOption('viirs', 'v', false),
|
||||||
]),
|
]),
|
||||||
|
'street-level': Object.freeze([
|
||||||
|
// One switch per registered imagery provider (src/app/layers/streetLevel.js).
|
||||||
|
booleanOption('mapillary', 'm', true),
|
||||||
|
enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], {
|
||||||
|
all: 'a',
|
||||||
|
pano: 'p',
|
||||||
|
flat: 'f',
|
||||||
|
}),
|
||||||
|
// Relative days, so a link means the same next year. The max matches
|
||||||
|
// MAX_SINCE_DAYS in streetLevel/policy.js (pinned by layerState.test).
|
||||||
|
boundedIntegerOption('sinceDays', 's', 0, { min: 0, max: 36500 }),
|
||||||
|
]),
|
||||||
radio: Object.freeze([
|
radio: Object.freeze([
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
key: 'filter',
|
key: 'filter',
|
||||||
@@ -631,6 +643,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
|
|||||||
disposition: 'enabled+options',
|
disposition: 'enabled+options',
|
||||||
optionOwner: 'satellites',
|
optionOwner: 'satellites',
|
||||||
}),
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'street-level',
|
||||||
|
token: '0',
|
||||||
|
disposition: 'enabled+options',
|
||||||
|
optionOwner: 'street-level',
|
||||||
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'telegeography-submarine-cables',
|
id: 'telegeography-submarine-cables',
|
||||||
token: 'u',
|
token: 'u',
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
import { LayerStateCoordinator } from './layerStateCoordinator.js';
|
import { LayerStateCoordinator } from './layerStateCoordinator.js';
|
||||||
import radioLayer from './radio.js';
|
import radioLayer from './radio.js';
|
||||||
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
||||||
|
import { MAX_SINCE_DAYS } from '../layers/streetLevel/policy.js';
|
||||||
|
import { SINCE_STOPS } from '../ui/streetLevelPresentation.js';
|
||||||
|
|
||||||
function deferred() {
|
function deferred() {
|
||||||
let resolve;
|
let resolve;
|
||||||
@@ -199,8 +201,8 @@ function encode(state) {
|
|||||||
|
|
||||||
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
|
||||||
assert.equal(validateLayerStateRegistry(), true);
|
assert.equal(validateLayerStateRegistry(), true);
|
||||||
assert.equal(REGISTERED_LAYER_IDS.length, 28);
|
assert.equal(REGISTERED_LAYER_IDS.length, 29);
|
||||||
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 28);
|
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 29);
|
||||||
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
|
||||||
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
|
||||||
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
|
||||||
@@ -239,15 +241,15 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
|
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
|
||||||
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
|
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
|
||||||
}
|
}
|
||||||
assert.equal(nextLayerStateToken(), '0');
|
assert.equal(nextLayerStateToken(), '3');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '0', bravo: '3' }),
|
nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '3', bravo: '4' }),
|
||||||
'4',
|
'5',
|
||||||
);
|
);
|
||||||
const digitsExhausted = {
|
const digitsExhausted = {
|
||||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
[...'03456789'].map((digit) => [`prior-${digit}`, digit]),
|
[...'3456789'].map((digit) => [`prior-${digit}`, digit]),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
assert.equal(nextLayerStateToken(digitsExhausted), '00');
|
assert.equal(nextLayerStateToken(digitsExhausted), '00');
|
||||||
@@ -298,7 +300,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
assert.equal(
|
assert.equal(
|
||||||
validateLayerStateAllocations(
|
validateLayerStateAllocations(
|
||||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, future: '0', next: '3' },
|
{ ...LAYER_STATE_TOKEN_RESERVATIONS, future: '3', next: '4' },
|
||||||
),
|
),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -307,7 +309,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
future: '00',
|
future: '00',
|
||||||
}),
|
}),
|
||||||
/next free token 0/,
|
/next free token 3/,
|
||||||
);
|
);
|
||||||
const beforeLastDigit = { ...digitsExhausted };
|
const beforeLastDigit = { ...digitsExhausted };
|
||||||
delete beforeLastDigit['prior-9'];
|
delete beforeLastDigit['prior-9'];
|
||||||
@@ -328,10 +330,10 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
|||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
validateLayerStateAllocations(
|
validateLayerStateAllocations(
|
||||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '0' },
|
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3' },
|
||||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '0', competing: '0' },
|
{ ...LAYER_STATE_TOKEN_RESERVATIONS, merged: '3', competing: '3' },
|
||||||
),
|
),
|
||||||
/next free token 3/,
|
/next free token 4/,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
|
() => validateLayerStateAllocations({ future: '00' }, { future: '01' }),
|
||||||
@@ -2642,3 +2644,62 @@ test('fire perimeters uses digit 2 without colliding with wind or recent imagery
|
|||||||
decoded,
|
decoded,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Street Level options decoded from a v2 link with these `lo` assignments. */
|
||||||
|
function streetLevelOptions(lo) {
|
||||||
|
const params = new URLSearchParams([
|
||||||
|
['v', '2'],
|
||||||
|
['l', '0'],
|
||||||
|
]);
|
||||||
|
if (lo) params.set('lo', lo);
|
||||||
|
return decodeLayerStateParams(params).options['street-level'];
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Street Level: every option round-trips through a share link and stored state', () => {
|
||||||
|
const state = createDefaultLayerState();
|
||||||
|
state.enabledLayerIds = ['street-level'];
|
||||||
|
state.options['street-level'] = {
|
||||||
|
mapillary: false,
|
||||||
|
pano: 'flat',
|
||||||
|
sinceDays: MAX_SINCE_DAYS,
|
||||||
|
};
|
||||||
|
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
|
||||||
|
assert.deepEqual(decoded.options['street-level'], {
|
||||||
|
mapillary: false,
|
||||||
|
pano: 'flat',
|
||||||
|
sinceDays: MAX_SINCE_DAYS,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
parseStoredLayerState(serializeStoredLayerState(state)).options[
|
||||||
|
'street-level'
|
||||||
|
],
|
||||||
|
state.options['street-level'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Street Level: the link codec accepts exactly the windows the filter keeps', () => {
|
||||||
|
// A window past MAX_SINCE_DAYS reads as "any date".
|
||||||
|
assert.equal(
|
||||||
|
streetLevelOptions(`0.s.${MAX_SINCE_DAYS}`).sinceDays,
|
||||||
|
MAX_SINCE_DAYS,
|
||||||
|
);
|
||||||
|
assert.equal(streetLevelOptions(`0.s.${MAX_SINCE_DAYS + 1}`).sinceDays, 0);
|
||||||
|
// Every SINCE slider stop survives, the LAST 10 YEARS one included.
|
||||||
|
for (const { days } of SINCE_STOPS)
|
||||||
|
assert.equal(streetLevelOptions(`0.s.${days}`).sinceDays, days);
|
||||||
|
assert.equal(streetLevelOptions('0.s.3652').sinceDays, 3652);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Street Level: a link without the provider switch keeps Mapillary on', () => {
|
||||||
|
assert.deepEqual(streetLevelOptions(''), {
|
||||||
|
mapillary: true,
|
||||||
|
pano: 'all',
|
||||||
|
sinceDays: 0,
|
||||||
|
});
|
||||||
|
assert.equal(streetLevelOptions('0.p.f').mapillary, true);
|
||||||
|
assert.equal(streetLevelOptions('0.m.0').mapillary, false);
|
||||||
|
assert.equal(
|
||||||
|
createDefaultLayerState().options['street-level'].mapillary,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -122,10 +122,10 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
|||||||
{ ...parseLayerStateTokenReservations(reservationRows) },
|
{ ...parseLayerStateTokenReservations(reservationRows) },
|
||||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||||
);
|
);
|
||||||
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 28);
|
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 29);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
{ ...LAYER_STATE_TOKEN_RESERVATIONS },
|
||||||
{ ...LEGACY_LAYER_STATE_TOKENS },
|
{ ...LEGACY_LAYER_STATE_TOKENS, 'street-level': '0' },
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => parseLayerStateTokenReservations(reservationRows.slice(1)),
|
() => parseLayerStateTokenReservations(reservationRows.slice(1)),
|
||||||
@@ -145,9 +145,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
|||||||
/Duplicate layer-state token reservation/,
|
/Duplicate layer-state token reservation/,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
parseLayerStateTokenReservations([...reservationRows, ['future', '0']])
|
parseLayerStateTokenReservations([...reservationRows, ['future', '3']])
|
||||||
.future,
|
.future,
|
||||||
'0',
|
'3',
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
@@ -165,14 +165,14 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
|
|||||||
|
|
||||||
test('published retired digits and competing PRs advance the merge-time allocation', () => {
|
test('published retired digits and competing PRs advance the merge-time allocation', () => {
|
||||||
withPublishedBase(
|
withPublishedBase(
|
||||||
{ rows: [...reservationRows, ['retired-layer', '0']] },
|
{ rows: [...reservationRows, ['retired-layer', '3']] },
|
||||||
(cwd) => {
|
(cwd) => {
|
||||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||||
assert.equal(nextLayerStateToken(published), '3');
|
assert.equal(nextLayerStateToken(published), '4');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
validateLayerStateAllocations(published, {
|
validateLayerStateAllocations(published, {
|
||||||
...published,
|
...published,
|
||||||
newcomer: '3',
|
newcomer: '4',
|
||||||
}),
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -180,15 +180,15 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
|||||||
() =>
|
() =>
|
||||||
validateLayerStateAllocations(published, {
|
validateLayerStateAllocations(published, {
|
||||||
...published,
|
...published,
|
||||||
competing: '0',
|
competing: '3',
|
||||||
}),
|
}),
|
||||||
/next free token 3/,
|
/next free token 4/,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
validateLayerStateAllocations(published, {
|
validateLayerStateAllocations(published, {
|
||||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
newcomer: '0',
|
newcomer: '3',
|
||||||
}),
|
}),
|
||||||
/changed or removed: retired-layer/,
|
/changed or removed: retired-layer/,
|
||||||
);
|
);
|
||||||
@@ -196,8 +196,8 @@ test('published retired digits and competing PRs advance the merge-time allocati
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () => {
|
test('PR B manually replaces provisional 3 with 4 after PR A publishes 3', () => {
|
||||||
withPublishedBase({ rows: [...reservationRows, ['pr-a', '0']] }, (cwd) => {
|
withPublishedBase({ rows: [...reservationRows, ['pr-a', '3']] }, (cwd) => {
|
||||||
mkdirSync(path.join(cwd, 'scripts'));
|
mkdirSync(path.join(cwd, 'scripts'));
|
||||||
const fixtureChecker = path.join(
|
const fixtureChecker = path.join(
|
||||||
cwd,
|
cwd,
|
||||||
@@ -218,8 +218,8 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
|
|||||||
|
|
||||||
writeCodecFixture(
|
writeCodecFixture(
|
||||||
cwd,
|
cwd,
|
||||||
[...reservationRows, ['pr-b', '0']],
|
[...reservationRows, ['pr-b', '3']],
|
||||||
[{ id: 'pr-b', token: '0' }],
|
[{ id: 'pr-b', token: '3' }],
|
||||||
);
|
);
|
||||||
const beforeRebase = runCheck();
|
const beforeRebase = runCheck();
|
||||||
assert.equal(beforeRebase.status, 1);
|
assert.equal(beforeRebase.status, 1);
|
||||||
@@ -227,10 +227,10 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
|
|||||||
|
|
||||||
writeCodecFixture(
|
writeCodecFixture(
|
||||||
cwd,
|
cwd,
|
||||||
[...reservationRows, ['pr-a', '0'], ['pr-b', '0']],
|
[...reservationRows, ['pr-a', '3'], ['pr-b', '3']],
|
||||||
[
|
[
|
||||||
{ id: 'pr-a', token: '0' },
|
{ id: 'pr-a', token: '3' },
|
||||||
{ id: 'pr-b', token: '0' },
|
{ id: 'pr-b', token: '3' },
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const staleRebase = runCheck();
|
const staleRebase = runCheck();
|
||||||
@@ -238,16 +238,16 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
|
|||||||
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
|
assert.match(staleRebase.stderr, /Duplicate layer-state token reservation/);
|
||||||
|
|
||||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||||
assert.equal(nextLayerStateToken(published), '3');
|
assert.equal(nextLayerStateToken(published), '4');
|
||||||
const validBaselineRows = [...reservationRows, ['pr-a', '0']];
|
const validBaselineRows = [...reservationRows, ['pr-a', '3']];
|
||||||
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '0' }]);
|
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '3' }]);
|
||||||
const helper = spawnSync(
|
const helper = spawnSync(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
[realpathSync(fixtureNext), 'pr-b'],
|
[realpathSync(fixtureNext), 'pr-b'],
|
||||||
{ cwd, encoding: 'utf8' },
|
{ cwd, encoding: 'utf8' },
|
||||||
);
|
);
|
||||||
assert.equal(helper.status, 0, helper.stderr);
|
assert.equal(helper.status, 0, helper.stderr);
|
||||||
assert.match(helper.stdout, /pr-b: 3/);
|
assert.match(helper.stdout, /pr-b: 4/);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
|
JSON.parse(readFileSync(path.join(cwd, LEDGER_PATH), 'utf8')),
|
||||||
validBaselineRows,
|
validBaselineRows,
|
||||||
@@ -255,20 +255,20 @@ test('PR B manually replaces provisional 0 with 3 after PR A publishes 0', () =>
|
|||||||
);
|
);
|
||||||
writeCodecFixture(
|
writeCodecFixture(
|
||||||
cwd,
|
cwd,
|
||||||
[...reservationRows, ['pr-a', '0'], ['pr-b', '3']],
|
[...reservationRows, ['pr-a', '3'], ['pr-b', '4']],
|
||||||
[
|
[
|
||||||
{ id: 'pr-a', token: '0' },
|
{ id: 'pr-a', token: '3' },
|
||||||
{ id: 'pr-b', token: '3' },
|
{ id: 'pr-b', token: '4' },
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const corrected = runCheck();
|
const corrected = runCheck();
|
||||||
assert.equal(corrected.status, 0, corrected.stderr);
|
assert.equal(corrected.status, 0, corrected.stderr);
|
||||||
assert.match(corrected.stdout, /29 published, 1 new/);
|
assert.match(corrected.stdout, /30 published, 1 new/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
|
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
|
||||||
const digitRows = [...'03456789'].map((digit) => [`retired-${digit}`, digit]);
|
const digitRows = [...'3456789'].map((digit) => [`retired-${digit}`, digit]);
|
||||||
const allDigits = parseLayerStateTokenReservations([
|
const allDigits = parseLayerStateTokenReservations([
|
||||||
...reservationRows,
|
...reservationRows,
|
||||||
...digitRows,
|
...digitRows,
|
||||||
@@ -320,7 +320,7 @@ test('allocation batches cross the last digit and base-36 pair boundaries in ord
|
|||||||
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
|
test('an isolated valid two-character fixture round-trips an l field beyond the old 64-character cap', async () => {
|
||||||
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
|
const cwd = mkdtempSync(path.join(tmpdir(), 'gev-layer-token-width-'));
|
||||||
try {
|
try {
|
||||||
const priorDigits = [...'03456789'].map((digit) => [
|
const priorDigits = [...'3456789'].map((digit) => [
|
||||||
`qa-prior-digit-${digit}`,
|
`qa-prior-digit-${digit}`,
|
||||||
digit,
|
digit,
|
||||||
]);
|
]);
|
||||||
@@ -357,7 +357,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
|
|||||||
const params = new URLSearchParams([['v', '2']]);
|
const params = new URLSearchParams([['v', '2']]);
|
||||||
codec.encodeLayerStateParams(params, state);
|
codec.encodeLayerStateParams(params, state);
|
||||||
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
|
assert.ok(params.get('l').length > 64, 'fixture must cross the old cap');
|
||||||
assert.equal(params.get('l').length, 67);
|
assert.equal(params.get('l').length, 69);
|
||||||
const restored = codec.decodeLayerStateParams(params);
|
const restored = codec.decodeLayerStateParams(params);
|
||||||
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
|
assert.deepEqual(restored?.enabledLayerIds, expectedLayerIds);
|
||||||
assert.deepEqual(restored?.options, state.options);
|
assert.deepEqual(restored?.options, state.options);
|
||||||
@@ -375,7 +375,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
|
|||||||
},
|
},
|
||||||
(cwd) => {
|
(cwd) => {
|
||||||
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
const published = readPublishedLayerStateReservations('HEAD', cwd);
|
||||||
assert.equal(Object.keys(published).length, 29);
|
assert.equal(Object.keys(published).length, 30);
|
||||||
assert.equal(published['retired-layer'], '00');
|
assert.equal(published['retired-layer'], '00');
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
["recent-imagery", "1"],
|
["recent-imagery", "1"],
|
||||||
["rocket-launches", "x"],
|
["rocket-launches", "x"],
|
||||||
["satellites", "s"],
|
["satellites", "s"],
|
||||||
|
["street-level", "0"],
|
||||||
["telegeography-submarine-cables", "u"],
|
["telegeography-submarine-cables", "u"],
|
||||||
["traffic", "t"],
|
["traffic", "t"],
|
||||||
["transit", "j"],
|
["transit", "j"],
|
||||||
|
|||||||
+11
-1
@@ -28,7 +28,7 @@ export const KEY_SETUP_APPEND_HEADER =
|
|||||||
* Provider credentials, in display order — most magic per
|
* Provider credentials, in display order — most magic per
|
||||||
* minute first. `tier` mirrors the README's color legend: 'metered' (🔴) is a
|
* minute first. `tier` mirrors the README's color legend: 'metered' (🔴) is a
|
||||||
* billing-enabled account, 'free' (🟡) is a register-and-paste key.
|
* billing-enabled account, 'free' (🟡) is a register-and-paste key.
|
||||||
* `clientExposed` marks the two keys that are injected into the browser
|
* `clientExposed` marks the keys that are injected into the browser
|
||||||
* bundle by design (restrict them at the provider, per SECURITY.md).
|
* bundle by design (restrict them at the provider, per SECURITY.md).
|
||||||
* `hidden` keeps advanced configuration out of the panel and missing-key count.
|
* `hidden` keeps advanced configuration out of the panel and missing-key count.
|
||||||
*/
|
*/
|
||||||
@@ -101,6 +101,16 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
|||||||
envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']),
|
envVars: Object.freeze(['OPENSKY_CLIENT_ID', 'OPENSKY_CLIENT_SECRET']),
|
||||||
tier: 'free',
|
tier: 'free',
|
||||||
}),
|
}),
|
||||||
|
Object.freeze({
|
||||||
|
id: 'mapillary',
|
||||||
|
title: 'MAPILLARY',
|
||||||
|
unlocks:
|
||||||
|
'Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token',
|
||||||
|
getUrl: 'https://www.mapillary.com/dashboard/developers',
|
||||||
|
envVars: Object.freeze(['MAPILLARY_CLIENT_TOKEN']),
|
||||||
|
tier: 'free',
|
||||||
|
clientExposed: true,
|
||||||
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
id: 'launch-library',
|
id: 'launch-library',
|
||||||
title: 'LAUNCH LIBRARY',
|
title: 'LAUNCH LIBRARY',
|
||||||
|
|||||||
@@ -179,8 +179,24 @@ export function createFlightSnapshotRenderer({
|
|||||||
// as the enrichment path — the class's GLB/scale may have changed.
|
// as the enrichment path — the class's GLB/scale may have changed.
|
||||||
if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24);
|
if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24);
|
||||||
} else {
|
} else {
|
||||||
|
// Start where the fleet pass will draw it: the fleet renders behind
|
||||||
|
// real time, so a fresh contact's displayed position is its fix
|
||||||
|
// projected back to that delayed time, and a grounded contact is then
|
||||||
|
// lifted onto the floor under that delayed position. Creating it at
|
||||||
|
// the raw fix drew it ahead until the next fleet tick snapped it back;
|
||||||
|
// skipping the floor started a taxiing contact low and then raised it.
|
||||||
|
const delayed = motion._deadReckon(icao24, new Cesium.Cartesian3());
|
||||||
|
const displayed =
|
||||||
|
(delayed &&
|
||||||
|
motion._floorGroundedDisplayPosition(
|
||||||
|
icao24,
|
||||||
|
flightState.records.data.get(icao24) || meta,
|
||||||
|
delayed,
|
||||||
|
rendering._modelOwnsVisual(icao24),
|
||||||
|
)) ||
|
||||||
|
position;
|
||||||
const bb = flightState._billboardCollection.add({
|
const bb = flightState._billboardCollection.add({
|
||||||
position,
|
position: displayed,
|
||||||
image: aircraftIcon(rendering._iconKind(icao24, meta.klass)),
|
image: aircraftIcon(rendering._iconKind(icao24, meta.klass)),
|
||||||
width: isTracked ? 24 : 20,
|
width: isTracked ? 24 : 20,
|
||||||
height: isTracked ? 24 : 20,
|
height: isTracked ? 24 : 20,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function createMilitaryFlightLayer({
|
|||||||
militaryRegistry: services.militaryRegistry,
|
militaryRegistry: services.militaryRegistry,
|
||||||
rendering: parts.rendering,
|
rendering: parts.rendering,
|
||||||
tracking: parts.tracking,
|
tracking: parts.tracking,
|
||||||
|
motion: parts.motion,
|
||||||
queries: parts.queries,
|
queries: parts.queries,
|
||||||
});
|
});
|
||||||
parts.ingestion = createIngestion({
|
parts.ingestion = createIngestion({
|
||||||
|
|||||||
@@ -113,3 +113,101 @@ test('a normalized source can retain its stale reason without changing standalon
|
|||||||
assert.equal(layer.getStats().error, null);
|
assert.equal(layer.getStats().error, null);
|
||||||
assert.equal(layer.getStats().stale, true);
|
assert.equal(layer.getStats().stale, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a newly seen military contact appears at its delayed position, not ahead of it', async () => {
|
||||||
|
// The military fleet renders RENDER_DELAY_SEC behind real time, so a fresh
|
||||||
|
// contact's first displayed position is its fix projected back to that
|
||||||
|
// delayed time. Creating the billboard at the raw fix drew it ahead until
|
||||||
|
// the next fleet tick jumped it back.
|
||||||
|
const Cesium = await import('cesium');
|
||||||
|
const { RENDER_DELAY_SEC } = await import('./policy.js');
|
||||||
|
// The application's own scene services, as src/app/layers/militaryFlights.js
|
||||||
|
// composes them; only the surface samplers and registry are stubbed.
|
||||||
|
const supplied = {
|
||||||
|
picking: await import('../../data/pickRegistry.js'),
|
||||||
|
sprites: await import('../../data/spriteOrder.js'),
|
||||||
|
trails: await import('../../data/trailRenderer.js'),
|
||||||
|
aircraftPresentation: await import('../../data/tr3bRegistry.js'),
|
||||||
|
camera: await import('../../data/trackedCamera.js'),
|
||||||
|
labels: await import('../../data/detectionDraw.js'),
|
||||||
|
geoid: await import('../../data/geoid.js'),
|
||||||
|
focus: await import('../../data/focusDeemphasis.js'),
|
||||||
|
readout: await import('../../data/trackedReadout.js'),
|
||||||
|
context: await import('../../data/contextStore.js'),
|
||||||
|
render: await import('../../renderGovernor.js'),
|
||||||
|
recession: await import('../../data/aircraftRecession.js'),
|
||||||
|
groundFloor: await import('../../data/groundFloor.js'),
|
||||||
|
groundSnap: await import('../../data/groundSnap.js'),
|
||||||
|
meshFloor: { sampleMeshFloorCells: () => {} },
|
||||||
|
militaryRegistry: { registerMilitaryIcaos: () => {} },
|
||||||
|
};
|
||||||
|
const fixLon = -97.6;
|
||||||
|
const fixLat = 30.3;
|
||||||
|
const speedMps = 200;
|
||||||
|
const fixAgeMs = 2_000;
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const layer = createMilitaryFlightLayer({
|
||||||
|
services: supplied,
|
||||||
|
source: {
|
||||||
|
label: 'Fixture aircraft',
|
||||||
|
async getSnapshot() {
|
||||||
|
return {
|
||||||
|
source: 'Fixture aircraft',
|
||||||
|
complete: true,
|
||||||
|
observedAtMs: nowMs,
|
||||||
|
records: [
|
||||||
|
{
|
||||||
|
id: 'ae0001',
|
||||||
|
latitude: fixLat,
|
||||||
|
longitude: fixLon,
|
||||||
|
onGround: false,
|
||||||
|
baroAltitudeM: 9_000,
|
||||||
|
courseDeg: 90,
|
||||||
|
speedMps,
|
||||||
|
positionTimeMs: nowMs - fixAgeMs,
|
||||||
|
contactTimeMs: nowMs - fixAgeMs,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const added = [];
|
||||||
|
layer.testing._setTrackedMilitaryRefreshStateForTest({
|
||||||
|
icao24: 'seed00',
|
||||||
|
entity: null,
|
||||||
|
meta: { rawLat: 0, rawLon: 0, onGround: false },
|
||||||
|
billboard: {
|
||||||
|
show: false,
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(0, 0, 0),
|
||||||
|
},
|
||||||
|
billboardCollection: {
|
||||||
|
show: false,
|
||||||
|
add(options) {
|
||||||
|
const billboard = { ...options };
|
||||||
|
added.push(billboard);
|
||||||
|
return billboard;
|
||||||
|
},
|
||||||
|
remove() {},
|
||||||
|
},
|
||||||
|
viewer: { camera: { positionCartographic: null }, scene: {} },
|
||||||
|
tracked: false,
|
||||||
|
});
|
||||||
|
await layer.update({ camera: { positionCartographic: null }, scene: {} });
|
||||||
|
const billboard = added.find((entry) => entry.id === 'ae0001');
|
||||||
|
assert.ok(billboard, 'the new contact gets a billboard');
|
||||||
|
const shown = Cesium.Cartographic.fromCartesian(billboard.position);
|
||||||
|
const behindM = Cesium.Cartesian3.distance(
|
||||||
|
Cesium.Cartesian3.fromRadians(shown.longitude, shown.latitude, 0),
|
||||||
|
Cesium.Cartesian3.fromDegrees(fixLon, fixLat, 0),
|
||||||
|
);
|
||||||
|
const expectedM = speedMps * (RENDER_DELAY_SEC - fixAgeMs / 1000);
|
||||||
|
assert.ok(
|
||||||
|
Cesium.Math.toDegrees(shown.longitude) < fixLon,
|
||||||
|
'drawn behind the fix',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(behindM - expectedM) < speedMps * 3,
|
||||||
|
`expected about ${Math.round(expectedM)} m behind the fix, got ${Math.round(behindM)} m`,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -23,6 +23,7 @@ export function createMilitarySnapshotRenderer({
|
|||||||
militaryRegistry,
|
militaryRegistry,
|
||||||
rendering,
|
rendering,
|
||||||
tracking,
|
tracking,
|
||||||
|
motion,
|
||||||
queries,
|
queries,
|
||||||
}) {
|
}) {
|
||||||
const { warmGroundFloor, cachedGroundFloor, GROUND_FLOOR_LIFT_M } =
|
const { warmGroundFloor, cachedGroundFloor, GROUND_FLOOR_LIFT_M } =
|
||||||
@@ -142,8 +143,14 @@ export function createMilitarySnapshotRenderer({
|
|||||||
// Round 5: depth policy is uniform (always depth-test-free, see
|
// Round 5: depth policy is uniform (always depth-test-free, see
|
||||||
// _groundDepthDistance) — nothing to flip on landing/takeoff.
|
// _groundDepthDistance) — nothing to flip on landing/takeoff.
|
||||||
} else {
|
} else {
|
||||||
|
// Start where the fleet pass will draw it: the fleet renders behind
|
||||||
|
// real time, so a fresh contact's displayed position is its fix
|
||||||
|
// projected back to that delayed time. Creating it at the raw fix drew
|
||||||
|
// it ahead until the next fleet tick snapped it back.
|
||||||
|
const displayed =
|
||||||
|
motion._deadReckon(icao24, new Cesium.Cartesian3()) || position;
|
||||||
const bb = flightState._billboardCollection.add({
|
const bb = flightState._billboardCollection.add({
|
||||||
position,
|
position: displayed,
|
||||||
image: aircraftIcon(rendering._iconKind(icao24, meta.klass)),
|
image: aircraftIcon(rendering._iconKind(icao24, meta.klass)),
|
||||||
width: isTracked ? 24 : 20,
|
width: isTracked ? 24 : 20,
|
||||||
height: isTracked ? 24 : 20,
|
height: isTracked ? 24 : 20,
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
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,38 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One static Cesium credit per active provider: CC BY-SA imagery needs
|
||||||
|
* visible attribution that goes away with the imagery.
|
||||||
|
*/
|
||||||
|
export function createCredits() {
|
||||||
|
/** @type {Map<string, object>} provider id → Cesium.Credit */
|
||||||
|
const shown = new Map();
|
||||||
|
|
||||||
|
function show(viewer, def) {
|
||||||
|
if (shown.has(def.id) || !viewer?.creditDisplay) return;
|
||||||
|
try {
|
||||||
|
const credit = new Cesium.Credit(def.credit.html, true);
|
||||||
|
viewer.creditDisplay.addStaticCredit(credit);
|
||||||
|
shown.set(def.id, credit);
|
||||||
|
} catch {
|
||||||
|
/* credit display unavailable */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide(viewer, def) {
|
||||||
|
const credit = shown.get(def.id);
|
||||||
|
if (!credit) return;
|
||||||
|
try {
|
||||||
|
viewer?.creditDisplay?.removeStaticCredit?.(credit);
|
||||||
|
} catch {
|
||||||
|
/* credit display already torn down */
|
||||||
|
}
|
||||||
|
shown.delete(def.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAll(viewer) {
|
||||||
|
for (const id of [...shown.keys()]) hide(viewer, { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { show, hide, hideAll };
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { FILTER_DEFAULT, MAX_SINCE_DAYS, PANO_MODES } from './policy.js';
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge a partial filter change; unknown pano modes and invalid day counts
|
||||||
|
* keep the current value.
|
||||||
|
* @param {{pano?: string, sinceDays?: number}|null} next
|
||||||
|
* @param {{pano: string, sinceDays: number}} [current]
|
||||||
|
* @returns {{pano: string, sinceDays: number}}
|
||||||
|
*/
|
||||||
|
export function normalizeFilter(next, current = FILTER_DEFAULT) {
|
||||||
|
const pano = PANO_MODES.includes(next?.pano) ? next.pano : current.pano;
|
||||||
|
let sinceDays = current.sinceDays;
|
||||||
|
if (next && 'sinceDays' in next) {
|
||||||
|
const days = Number(next.sinceDays);
|
||||||
|
if (Number.isInteger(days) && days >= 0)
|
||||||
|
sinceDays = Math.min(days, MAX_SINCE_DAYS);
|
||||||
|
}
|
||||||
|
return { pano, sinceDays };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when two filters would show the same imagery. */
|
||||||
|
export function sameFilter(a, b) {
|
||||||
|
return a?.pano === b?.pano && a?.sinceDays === b?.sinceDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stored filter (relative days, stable in share links) to the absolute form
|
||||||
|
* providers compare capture times against.
|
||||||
|
* @returns {{pano: string, sinceMs: number|null}}
|
||||||
|
*/
|
||||||
|
export function resolveFilter(filter, now = Date.now()) {
|
||||||
|
const days = Number(filter?.sinceDays) || 0;
|
||||||
|
return {
|
||||||
|
pano: PANO_MODES.includes(filter?.pano) ? filter.pano : 'all',
|
||||||
|
sinceMs: days > 0 ? now - days * DAY_MS : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a capture passes the resolved filter: pano mode and an optional
|
||||||
|
* earliest capture time.
|
||||||
|
* @param {{isPano?: boolean, capturedAt?: number}} record
|
||||||
|
* @param {{pano: string, sinceMs: number|null}|null} filter
|
||||||
|
*/
|
||||||
|
export function passesImageryFilter(record, filter) {
|
||||||
|
if (!filter) return true;
|
||||||
|
if (filter.pano === 'pano' && !record.isPano) return false;
|
||||||
|
if (filter.pano === 'flat' && record.isPano) return false;
|
||||||
|
if (
|
||||||
|
Number.isFinite(filter.sinceMs) &&
|
||||||
|
filter.sinceMs > 0 &&
|
||||||
|
(record.capturedAt || 0) < filter.sinceMs
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
normalizeFilter,
|
||||||
|
passesImageryFilter,
|
||||||
|
resolveFilter,
|
||||||
|
sameFilter,
|
||||||
|
} from './filter.js';
|
||||||
|
import { MAX_SINCE_DAYS } from './policy.js';
|
||||||
|
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
|
||||||
|
test('normalizeFilter merges a partial change and ignores junk', () => {
|
||||||
|
const current = { pano: 'all', sinceDays: 0 };
|
||||||
|
assert.deepEqual(normalizeFilter({ pano: 'pano' }, current), {
|
||||||
|
pano: 'pano',
|
||||||
|
sinceDays: 0,
|
||||||
|
});
|
||||||
|
assert.deepEqual(normalizeFilter({ pano: 'sideways' }, current), current);
|
||||||
|
assert.deepEqual(normalizeFilter({ sinceDays: 365 }, current), {
|
||||||
|
pano: 'all',
|
||||||
|
sinceDays: 365,
|
||||||
|
});
|
||||||
|
assert.deepEqual(normalizeFilter({ sinceDays: -3 }, current), current);
|
||||||
|
assert.deepEqual(normalizeFilter({ sinceDays: 1.5 }, current), current);
|
||||||
|
assert.deepEqual(normalizeFilter({ sinceDays: 99_999_999 }, current), {
|
||||||
|
pano: 'all',
|
||||||
|
sinceDays: 36_500,
|
||||||
|
});
|
||||||
|
assert.deepEqual(normalizeFilter(null, current), current);
|
||||||
|
assert.deepEqual(normalizeFilter({ pano: 'flat' }), {
|
||||||
|
pano: 'flat',
|
||||||
|
sinceDays: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveFilter turns relative days into an absolute cut-off', () => {
|
||||||
|
const now = Date.UTC(2026, 8, 25);
|
||||||
|
assert.deepEqual(resolveFilter({ pano: 'all', sinceDays: 0 }, now), {
|
||||||
|
pano: 'all',
|
||||||
|
sinceMs: null,
|
||||||
|
});
|
||||||
|
assert.deepEqual(resolveFilter({ pano: 'flat', sinceDays: 10 }, now), {
|
||||||
|
pano: 'flat',
|
||||||
|
sinceMs: now - 10 * DAY,
|
||||||
|
});
|
||||||
|
assert.equal(resolveFilter({ pano: 'nope' }, now).pano, 'all');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('passesImageryFilter applies panorama mode and the cut-off', () => {
|
||||||
|
const pano = { isPano: true, capturedAt: 200 };
|
||||||
|
const flat = { isPano: false, capturedAt: 50 };
|
||||||
|
assert.equal(passesImageryFilter(pano, null), true);
|
||||||
|
assert.equal(
|
||||||
|
passesImageryFilter(pano, { pano: 'pano', sinceMs: null }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
passesImageryFilter(flat, { pano: 'pano', sinceMs: null }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
passesImageryFilter(flat, { pano: 'flat', sinceMs: null }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
passesImageryFilter(pano, { pano: 'flat', sinceMs: null }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(passesImageryFilter(flat, { pano: 'all', sinceMs: 100 }), false);
|
||||||
|
assert.equal(passesImageryFilter(pano, { pano: 'all', sinceMs: 100 }), true);
|
||||||
|
assert.equal(passesImageryFilter({}, { pano: 'all', sinceMs: 1 }), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sameFilter compares the stored form', () => {
|
||||||
|
assert.equal(
|
||||||
|
sameFilter({ pano: 'all', sinceDays: 0 }, { pano: 'all', sinceDays: 0 }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
sameFilter({ pano: 'all', sinceDays: 0 }, { pano: 'all', sinceDays: 1 }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeFilter takes "any date" (0 days) over a current window (share-link defaults)', () => {
|
||||||
|
// Dragging SINCE back to ANY DATE, or a link that says so, must stick.
|
||||||
|
assert.equal(
|
||||||
|
normalizeFilter({ sinceDays: 0 }, { pano: 'all', sinceDays: 365 })
|
||||||
|
.sinceDays,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
normalizeFilter(
|
||||||
|
{ sinceDays: MAX_SINCE_DAYS },
|
||||||
|
{ pano: 'all', sinceDays: 0 },
|
||||||
|
).sinceDays,
|
||||||
|
MAX_SINCE_DAYS,
|
||||||
|
'the longest window is kept whole',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/** Canvas billboard glyphs (image cone, pano ring, position marker); no Cesium needed. */
|
||||||
|
|
||||||
|
const _cache = new Map();
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 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 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cone glyph pointing "up"; rotate the billboard by the compass angle. */
|
||||||
|
export function imageConeGlyph({
|
||||||
|
size = 32,
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
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}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
import { createState } from './state.js';
|
||||||
|
import { createMarker } from './marker.js';
|
||||||
|
import { createCameraFollow } from './cameraFollow.js';
|
||||||
|
import { createCredits } from './credits.js';
|
||||||
|
import { createPickRouter } from './pickRouter.js';
|
||||||
|
import { createSelection } from './selection.js';
|
||||||
|
import { createViewerHost } from './viewerHost.js';
|
||||||
|
import { requiresKeyIdFor, validateProviders } from './registry.js';
|
||||||
|
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
|
||||||
|
import { decodeParams, encodeParams } from './params.js';
|
||||||
|
import { composeUIState, summarizeCoverage } from './uiState.js';
|
||||||
|
import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
|
||||||
|
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
|
||||||
|
import { createMeshSampler } from './meshSampler.js';
|
||||||
|
|
||||||
|
/** Above this camera height the ground under the camera is not worth fetching. */
|
||||||
|
const SURFACE_WARM_BELOW_M = 6000;
|
||||||
|
import {
|
||||||
|
FOLLOW_MAP_STACK_ID,
|
||||||
|
NEAREST_RADIUS_M,
|
||||||
|
POSITION_PICK_ID,
|
||||||
|
STREET_LEVEL_LAYER_ID,
|
||||||
|
} from './policy.js';
|
||||||
|
|
||||||
|
export { STREET_LEVEL_LAYER_ID } from './policy.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Street Level layer over imagery providers (contract in registry.js).
|
||||||
|
* The core owns what they share; providers own coverage, sequences, viewer.
|
||||||
|
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
|
||||||
|
*/
|
||||||
|
export function createStreetLevelLayer({
|
||||||
|
providers: definitions,
|
||||||
|
services = {},
|
||||||
|
}) {
|
||||||
|
const definitionsFrozen = 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.router = createPickRouter(() => state.providers.values(), {
|
||||||
|
positionId: POSITION_PICK_ID,
|
||||||
|
});
|
||||||
|
parts.viewerHost = createViewerHost(context);
|
||||||
|
parts.hasSelectedSequence = () =>
|
||||||
|
[...state.providers.values()].some(
|
||||||
|
(entry) => entry.instance.sequenceStats?.()?.selectedId,
|
||||||
|
);
|
||||||
|
parts.clearSequences = () => {
|
||||||
|
for (const entry of state.providers.values())
|
||||||
|
entry.instance.clearSequence?.();
|
||||||
|
};
|
||||||
|
parts.selection = createSelection(context);
|
||||||
|
|
||||||
|
let notifyQueued = false;
|
||||||
|
function notify() {
|
||||||
|
if (notifyQueued) return;
|
||||||
|
notifyQueued = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
notifyQueued = false;
|
||||||
|
const snapshot = getUIState();
|
||||||
|
for (const listener of [...state.listeners]) {
|
||||||
|
try {
|
||||||
|
listener(snapshot);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[Data:StreetLevel] listener error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
state.notify = notify;
|
||||||
|
|
||||||
|
function providerContext(entry) {
|
||||||
|
return Object.freeze({
|
||||||
|
services: state.services,
|
||||||
|
getFilter: () => resolveFilter(state.filter),
|
||||||
|
isActive: () => state.enabled && entry.on,
|
||||||
|
groundCaster: parts.groundCaster,
|
||||||
|
meshSampler: parts.meshSampler,
|
||||||
|
getSurface: () => state.surface,
|
||||||
|
notify,
|
||||||
|
actions: {
|
||||||
|
openImage: (imageId) => openImage(entry.def.id, imageId),
|
||||||
|
/**
|
||||||
|
* Show a provider error, or withdraw it (null). A withdrawal clears
|
||||||
|
* only this provider's own error, never a newer one from elsewhere.
|
||||||
|
*/
|
||||||
|
reportError: (message) => {
|
||||||
|
if (message) {
|
||||||
|
state.street.error = entry.reportedError = message;
|
||||||
|
} else {
|
||||||
|
if (!entry.reportedError) return;
|
||||||
|
if (state.street.error === entry.reportedError)
|
||||||
|
state.street.error = null;
|
||||||
|
entry.reportedError = null;
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const def of definitionsFrozen) {
|
||||||
|
const entry = {
|
||||||
|
def,
|
||||||
|
instance: null,
|
||||||
|
on: true,
|
||||||
|
status: null,
|
||||||
|
/** The error this provider last reported, until it withdraws it. */
|
||||||
|
reportedError: null,
|
||||||
|
};
|
||||||
|
entry.instance = def.create(providerContext(entry));
|
||||||
|
state.providers.set(def.id, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
function activate(entry) {
|
||||||
|
if (!state.viewer) return;
|
||||||
|
entry.instance.activate(state.viewer);
|
||||||
|
parts.credits.show(state.viewer, entry.def);
|
||||||
|
if (!entry.status) refreshStatus(entry);
|
||||||
|
whenIdle(() => parts.viewerHost.prewarm([entry]), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deactivate(entry) {
|
||||||
|
parts.viewerHost.unmount(entry.def.id);
|
||||||
|
entry.instance.deactivate();
|
||||||
|
parts.credits.hide(state.viewer, entry.def);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The nearest-image lookup in flight; any newer user action aborts it. */
|
||||||
|
let nearestLookup = null;
|
||||||
|
|
||||||
|
function abortNearest() {
|
||||||
|
nearestLookup?.abort();
|
||||||
|
nearestLookup = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImage(providerId, imageId) {
|
||||||
|
abortNearest();
|
||||||
|
return parts.viewerHost.open(providerId, imageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProviderEnabled(providerId, on) {
|
||||||
|
const entry = state.providers.get(providerId);
|
||||||
|
if (!entry) return false;
|
||||||
|
const next = on !== false;
|
||||||
|
if (entry.on === next) return true;
|
||||||
|
entry.on = next;
|
||||||
|
if (state.enabled) {
|
||||||
|
if (next) activate(entry);
|
||||||
|
else deactivate(entry);
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCoverageFilter(next) {
|
||||||
|
const filter = normalizeFilter(next, state.filter);
|
||||||
|
if (sameFilter(filter, state.filter)) return;
|
||||||
|
state.filter = filter;
|
||||||
|
const resolved = resolveFilter(filter);
|
||||||
|
for (const entry of state.providers.values())
|
||||||
|
entry.instance.setFilter(resolved);
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerSnapshots() {
|
||||||
|
return [...state.providers.values()].map((entry) => {
|
||||||
|
const stats = entry.instance.coverageStats();
|
||||||
|
return {
|
||||||
|
id: entry.def.id,
|
||||||
|
name: entry.def.name,
|
||||||
|
label: entry.def.label,
|
||||||
|
on: entry.on,
|
||||||
|
configured: entry.status ? entry.status.configured === true : null,
|
||||||
|
// A rejected key gates the provider exactly like a missing one.
|
||||||
|
keyRequired: stats.keyRequired === true || stats.keyRejected === true,
|
||||||
|
keyRejected: stats.keyRejected === true,
|
||||||
|
requiresKeyId: entry.def.requiresKeyId || null,
|
||||||
|
loading: stats.loading === true,
|
||||||
|
count: stats.count || 0,
|
||||||
|
hint: stats.hint || '',
|
||||||
|
error: stats.error || null,
|
||||||
|
color: entry.def.colors.coverage,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sequenceSnapshot() {
|
||||||
|
// The provider showing the open image answers first.
|
||||||
|
const owner = state.providers.get(state.street.providerId);
|
||||||
|
const candidates = owner
|
||||||
|
? [owner, ...[...state.providers.values()].filter((e) => e !== owner)]
|
||||||
|
: [...state.providers.values()];
|
||||||
|
for (const entry of candidates) {
|
||||||
|
const stats = entry.instance.sequenceStats?.();
|
||||||
|
if (stats?.selectedId || stats?.loading)
|
||||||
|
return {
|
||||||
|
providerId: entry.def.id,
|
||||||
|
selectedId: stats.selectedId || null,
|
||||||
|
images: stats.images || 0,
|
||||||
|
loading: stats.loading === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { providerId: null, selectedId: null, images: 0, loading: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUIState() {
|
||||||
|
const { host, ...street } = state.street;
|
||||||
|
return composeUIState({
|
||||||
|
enabled: state.enabled,
|
||||||
|
filter: state.filter,
|
||||||
|
providers: providerSnapshots(),
|
||||||
|
street,
|
||||||
|
sequence: sequenceSnapshot(),
|
||||||
|
surface: state.surface,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const layer = {
|
||||||
|
id: STREET_LEVEL_LAYER_ID,
|
||||||
|
name: 'Street Level',
|
||||||
|
icon: '📷',
|
||||||
|
source: definitionsFrozen.map((def) => def.name).join(' · '),
|
||||||
|
updateInterval: 0,
|
||||||
|
statsRefreshInterval: 1000,
|
||||||
|
requiresKeyId: requiresKeyIdFor(definitionsFrozen),
|
||||||
|
/** Registered providers, in chip order. */
|
||||||
|
providerIds: definitionsFrozen.map((def) => def.id),
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
console.log('[Data:StreetLevel] Initialized');
|
||||||
|
},
|
||||||
|
|
||||||
|
enable(viewer) {
|
||||||
|
state.enabled = true;
|
||||||
|
state.viewer = viewer;
|
||||||
|
parts.marker.setVisible(true);
|
||||||
|
parts.selection.install(viewer);
|
||||||
|
for (const entry of activeEntries()) activate(entry);
|
||||||
|
watchSurface(viewer);
|
||||||
|
syncSurface();
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
|
||||||
|
disable() {
|
||||||
|
state.enabled = false;
|
||||||
|
abortNearest();
|
||||||
|
unwatchSurface();
|
||||||
|
parts.viewerHost.unmount();
|
||||||
|
for (const entry of state.providers.values()) entry.instance.deactivate();
|
||||||
|
syncSurface();
|
||||||
|
parts.credits.hideAll(state.viewer);
|
||||||
|
parts.selection.uninstall();
|
||||||
|
parts.marker.setVisible(false);
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
|
||||||
|
async update() {
|
||||||
|
return state.enabled;
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy(viewer = state.viewer) {
|
||||||
|
layer.disable();
|
||||||
|
for (const entry of state.providers.values())
|
||||||
|
entry.instance.destroy(viewer);
|
||||||
|
parts.marker.destroy(viewer);
|
||||||
|
parts.meshSampler?.destroy();
|
||||||
|
parts.follow.destroy();
|
||||||
|
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.
|
||||||
|
const coverage = summarizeCoverage(providerSnapshots());
|
||||||
|
let loadingLabel = '';
|
||||||
|
const keyLabel = coverage.keyRejected ? 'KEY REJECTED' : 'KEY REQUIRED';
|
||||||
|
if (coverage.keyRequired) loadingLabel = keyLabel;
|
||||||
|
else if (coverage.loading) loadingLabel = 'loading coverage...';
|
||||||
|
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
|
||||||
|
return {
|
||||||
|
count: coverage.count,
|
||||||
|
loading: coverage.loading,
|
||||||
|
keyRequired: coverage.keyRequired,
|
||||||
|
error: coverage.keyRequired
|
||||||
|
? coverage.keyRejected
|
||||||
|
? coverage.error || keyLabel
|
||||||
|
: keyLabel
|
||||||
|
: coverage.error,
|
||||||
|
loadingLabel,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
attachNavigation(navigation) {
|
||||||
|
parts.follow.attachNavigation(navigation);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The application map stack; FOLLOW is available only on Google 3D. */
|
||||||
|
attachMapStackController(controller) {
|
||||||
|
unsubscribeMapStack?.();
|
||||||
|
mapStack = controller || null;
|
||||||
|
unsubscribeMapStack =
|
||||||
|
mapStack?.subscribe?.(() => {
|
||||||
|
syncFollowAvailability();
|
||||||
|
syncSurface();
|
||||||
|
}) || null;
|
||||||
|
syncFollowAvailability();
|
||||||
|
syncSurface();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Share-link and stored state: provider switches plus the filter. */
|
||||||
|
getParams() {
|
||||||
|
return encodeParams({
|
||||||
|
providers: [...state.providers].map(([id, entry]) => [id, entry.on]),
|
||||||
|
filter: state.filter,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setParams(params = {}) {
|
||||||
|
const decoded = decodeParams(params, {
|
||||||
|
providerIds: state.providers.keys(),
|
||||||
|
filter: state.filter,
|
||||||
|
});
|
||||||
|
for (const [id, on] of decoded.providers) setProviderEnabled(id, on);
|
||||||
|
setCoverageFilter(decoded.filter);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Public surface used by the panel ────────────────────────────────
|
||||||
|
subscribe(listener) {
|
||||||
|
if (typeof listener !== 'function') return () => {};
|
||||||
|
state.listeners.add(listener);
|
||||||
|
return () => state.listeners.delete(listener);
|
||||||
|
},
|
||||||
|
getUIState,
|
||||||
|
/** The DOM element provider viewers render into. */
|
||||||
|
attachViewerHost(element) {
|
||||||
|
parts.viewerHost.attach(element);
|
||||||
|
if (element && state.enabled)
|
||||||
|
whenIdle(() => parts.viewerHost.prewarm(activeEntries()), 1500);
|
||||||
|
},
|
||||||
|
setProviderEnabled,
|
||||||
|
/** Imagery filter for coverage, cones and nearest-image lookups. */
|
||||||
|
setCoverageFilter,
|
||||||
|
openImage,
|
||||||
|
/** Open the nearest image any active provider has around a point. */
|
||||||
|
async openNearest(point) {
|
||||||
|
const view = point || viewCentre(state.viewer);
|
||||||
|
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
|
||||||
|
return false;
|
||||||
|
abortNearest();
|
||||||
|
const lookup = new AbortController();
|
||||||
|
nearestLookup = lookup;
|
||||||
|
const { signal } = lookup;
|
||||||
|
state.street.loading = true;
|
||||||
|
state.street.error = null;
|
||||||
|
notify();
|
||||||
|
let lastError = null;
|
||||||
|
for (const entry of activeEntries()) {
|
||||||
|
let imageId = null;
|
||||||
|
try {
|
||||||
|
imageId = await entry.instance.nearestImage(
|
||||||
|
{ lat: view.lat, lon: view.lon },
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
// Overtaken: whatever replaced it owns the panel, errors included.
|
||||||
|
if (signal.aborted) return false;
|
||||||
|
// One provider failing (no key, offline) must not hide the others.
|
||||||
|
lastError = error;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (signal.aborted) return false;
|
||||||
|
if (!state.enabled) break;
|
||||||
|
// This provider was switched off while it looked: try the next.
|
||||||
|
if (!imageId || !entry.on) continue;
|
||||||
|
nearestLookup = null;
|
||||||
|
return openImage(entry.def.id, imageId);
|
||||||
|
}
|
||||||
|
if (nearestLookup === lookup) nearestLookup = null;
|
||||||
|
// Switched off while it looked: nothing to open, nothing to report.
|
||||||
|
if (state.enabled && activeEntries().length)
|
||||||
|
state.street.error =
|
||||||
|
lastError?.message ||
|
||||||
|
`No street-level imagery within ${NEAREST_RADIUS_M} m of the view centre`;
|
||||||
|
state.street.loading = false;
|
||||||
|
notify();
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
/** Close the image and deselect it everywhere 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { createStreetLevelLayer } from './index.js';
|
||||||
|
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
|
||||||
|
import { createMapillaryProvider } from './providers/mapillary/index.js';
|
||||||
|
import { fakeStreetLevelProvider as fakeProvider } from '../../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
function fakeViewer() {
|
||||||
|
const credits = [];
|
||||||
|
/** Cesium's flight bookkeeping: a new flight cancels the current one. */
|
||||||
|
let flight = null;
|
||||||
|
const flights = { started: 0, cancelled: 0 };
|
||||||
|
const stopFlight = () => {
|
||||||
|
const current = flight;
|
||||||
|
flight = null;
|
||||||
|
current?.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,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
credits,
|
||||||
|
flights,
|
||||||
|
scene: {
|
||||||
|
canvas,
|
||||||
|
primitives: { add: (p) => p, remove() {} },
|
||||||
|
// Enough of a scene for the position marker to clamp to the ground.
|
||||||
|
frameState: { mode: Cesium.SceneMode.SCENE3D },
|
||||||
|
updateHeight: () => () => {},
|
||||||
|
getHeight: () => undefined,
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
changed: new Cesium.Event(),
|
||||||
|
moveStart: new Cesium.Event(),
|
||||||
|
moveEnd: new Cesium.Event(),
|
||||||
|
flyToBoundingSphere(sphere, options = {}) {
|
||||||
|
stopFlight();
|
||||||
|
flight = options;
|
||||||
|
flights.started++;
|
||||||
|
},
|
||||||
|
cancelFlight() {
|
||||||
|
if (stopFlight()) flights.cancelled++;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
creditDisplay: {
|
||||||
|
addStaticCredit: (credit) => credits.push(credit),
|
||||||
|
removeStaticCredit: (credit) =>
|
||||||
|
credits.splice(credits.indexOf(credit), 1),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A layer over `providers`, initialised and enabled on a stand-in viewer. */
|
||||||
|
async function enabledLayer(t, providers = [fakeProvider()]) {
|
||||||
|
const saved = globalThis.document;
|
||||||
|
// A pose moves the position marker, whose glyph is drawn on a canvas.
|
||||||
|
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
||||||
|
globalThis.document = Object.assign(new EventTarget(), {
|
||||||
|
createElement: () => ({ getContext: () => drawing }),
|
||||||
|
});
|
||||||
|
const viewer = fakeViewer();
|
||||||
|
const layer = createStreetLevelLayer({ providers });
|
||||||
|
layer.init(viewer);
|
||||||
|
layer.enable(viewer);
|
||||||
|
t.after(() => {
|
||||||
|
layer.destroy();
|
||||||
|
globalThis.document = saved;
|
||||||
|
});
|
||||||
|
await settle();
|
||||||
|
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() {
|
||||||
|
const provider = fakeProvider();
|
||||||
|
const create = provider.create;
|
||||||
|
provider.create = (context) => {
|
||||||
|
const instance = create(context);
|
||||||
|
let emit = null;
|
||||||
|
instance.viewer.onPose = (listener) => {
|
||||||
|
emit = listener;
|
||||||
|
return () => {
|
||||||
|
emit = null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
instance.viewer.open = async (imageId) => {
|
||||||
|
provider.calls.open.push(imageId);
|
||||||
|
emit?.({
|
||||||
|
providerId: 'mapillary',
|
||||||
|
imageId,
|
||||||
|
position: { lon: -121.49, lat: 38.58 },
|
||||||
|
bearing: 90,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return instance;
|
||||||
|
};
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** nearestImage lookups that answer when the test says, with their signals. */
|
||||||
|
function slowLookups() {
|
||||||
|
const lookups = [];
|
||||||
|
const nearestImage = (point, { signal } = {}) => {
|
||||||
|
const answer = deferred();
|
||||||
|
lookups.push({ point, signal, answer });
|
||||||
|
return answer.promise;
|
||||||
|
};
|
||||||
|
return { lookups, nearestImage };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A map stack controller that can be switched between stacks. */
|
||||||
|
function fakeMapStack(initial) {
|
||||||
|
let active = initial;
|
||||||
|
const listeners = new Set();
|
||||||
|
return {
|
||||||
|
getActiveId: () => active,
|
||||||
|
subscribe(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
switchTo(id) {
|
||||||
|
active = id;
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
},
|
||||||
|
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');
|
||||||
|
layer.attachMapStackController(first);
|
||||||
|
assert.equal(first.listenerCount(), 1);
|
||||||
|
const second = fakeMapStack('photoreal');
|
||||||
|
layer.attachMapStackController(second);
|
||||||
|
assert.equal(first.listenerCount(), 0);
|
||||||
|
assert.equal(second.listenerCount(), 1);
|
||||||
|
layer.destroy();
|
||||||
|
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]);
|
||||||
|
assert.equal(await layer.openNearest({ lat: 38.58, lon: -121.49 }), false);
|
||||||
|
const { street } = layer.getUIState();
|
||||||
|
assert.equal(street.loading, false);
|
||||||
|
assert.match(street.error, /Open the Street Level panel/);
|
||||||
|
assert.equal(provider.calls.mount, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openNearest opens nothing once the layer went off during the lookup', async (t) => {
|
||||||
|
const answer = deferred();
|
||||||
|
const provider = fakeProvider({ nearestImage: () => answer.promise });
|
||||||
|
const { layer } = await enabledLayer(t, [provider]);
|
||||||
|
layer.attachViewerHost({});
|
||||||
|
const opening = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||||||
|
layer.disable();
|
||||||
|
answer.resolve('img1');
|
||||||
|
assert.equal(await opening, false);
|
||||||
|
assert.equal(provider.calls.mount, 0, 'no viewer was stood up');
|
||||||
|
const { street } = layer.getUIState();
|
||||||
|
assert.equal(street.open, false);
|
||||||
|
assert.equal(street.loading, false);
|
||||||
|
assert.equal(street.error, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openNearest skips a provider switched off during its lookup', async (t) => {
|
||||||
|
const answer = deferred();
|
||||||
|
const provider = fakeProvider({ nearestImage: () => answer.promise });
|
||||||
|
const { layer } = await enabledLayer(t, [provider]);
|
||||||
|
layer.attachViewerHost({});
|
||||||
|
const opening = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||||||
|
layer.setProviderEnabled('mapillary', false);
|
||||||
|
answer.resolve('img1');
|
||||||
|
assert.equal(await opening, false);
|
||||||
|
assert.deepEqual(provider.calls.open, []);
|
||||||
|
assert.equal(layer.getUIState().street.loading, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a provider withdraws only the error it reported', async (t) => {
|
||||||
|
const provider = fakeProvider();
|
||||||
|
const { layer } = await enabledLayer(t, [provider]);
|
||||||
|
const { actions } = provider.context();
|
||||||
|
actions.reportError('Sequence images unavailable');
|
||||||
|
assert.equal(layer.getUIState().street.error, 'Sequence images unavailable');
|
||||||
|
actions.reportError(null);
|
||||||
|
assert.equal(layer.getUIState().street.error, null, 'withdrawn');
|
||||||
|
|
||||||
|
// The viewer's own error is not the provider's to clear.
|
||||||
|
layer.attachViewerHost(null);
|
||||||
|
actions.reportError('Sequence images unavailable');
|
||||||
|
await layer.openImage('mapillary', 'img1');
|
||||||
|
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 });
|
||||||
|
const { layer } = await enabledLayer(t, [provider]);
|
||||||
|
layer.attachViewerHost({});
|
||||||
|
const older = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||||||
|
const newer = layer.openNearest({ lat: 38.59, lon: -121.48 });
|
||||||
|
lookups[1].answer.resolve('newer');
|
||||||
|
assert.equal(await newer, true);
|
||||||
|
lookups[0].answer.resolve('older');
|
||||||
|
assert.equal(await older, false);
|
||||||
|
assert.deepEqual(provider.calls.open, ['newer']);
|
||||||
|
assert.equal(lookups[0].signal?.aborted, true, 'its request was aborted');
|
||||||
|
assert.equal(lookups[1].signal?.aborted, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an image picked during a nearest lookup wins over its late answer', async (t) => {
|
||||||
|
const { lookups, nearestImage } = slowLookups();
|
||||||
|
const provider = fakeProvider({ nearestImage });
|
||||||
|
const { layer } = await enabledLayer(t, [provider]);
|
||||||
|
layer.attachViewerHost({});
|
||||||
|
const lookup = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||||||
|
// A cone click goes through the provider's openImage action.
|
||||||
|
assert.equal(await provider.context().actions.openImage('picked'), true);
|
||||||
|
lookups[0].answer.resolve('nearest');
|
||||||
|
assert.equal(await lookup, false);
|
||||||
|
assert.deepEqual(provider.calls.open, ['picked']);
|
||||||
|
assert.equal(lookups[0].signal?.aborted, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closing the viewer or switching the layer off retires a nearest lookup', async (t) => {
|
||||||
|
const { lookups, nearestImage } = slowLookups();
|
||||||
|
const provider = fakeProvider({ nearestImage });
|
||||||
|
const { layer, viewer } = await enabledLayer(t, [provider]);
|
||||||
|
layer.attachViewerHost({});
|
||||||
|
|
||||||
|
const closed = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||||||
|
layer.closeViewer();
|
||||||
|
// The aborted fetch rejects, as fetch does; that is not the user's error.
|
||||||
|
lookups[0].answer.reject(new DOMException('aborted', 'AbortError'));
|
||||||
|
assert.equal(await closed, false);
|
||||||
|
let { street } = layer.getUIState();
|
||||||
|
assert.deepEqual(
|
||||||
|
[street.open, street.loading, street.error],
|
||||||
|
[false, false, null],
|
||||||
|
);
|
||||||
|
assert.equal(lookups[0].signal?.aborted, true, 'closing aborts the request');
|
||||||
|
|
||||||
|
const disabled = layer.openNearest({ lat: 38.58, lon: -121.49 });
|
||||||
|
layer.disable();
|
||||||
|
layer.enable(viewer);
|
||||||
|
lookups[1].answer.resolve('img1');
|
||||||
|
assert.equal(await disabled, false, 'nor does it open once back on');
|
||||||
|
assert.deepEqual(provider.calls.open, []);
|
||||||
|
({ street } = layer.getUIState());
|
||||||
|
assert.deepEqual(
|
||||||
|
[street.open, street.loading, street.error],
|
||||||
|
[false, false, null],
|
||||||
|
);
|
||||||
|
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() {} }) });
|
||||||
|
globalThis.document = Object.assign(new EventTarget(), {
|
||||||
|
createElement: () => ({ getContext: () => drawing }),
|
||||||
|
});
|
||||||
|
const viewer = fakeViewer();
|
||||||
|
const layer = createStreetLevelLayer({ providers });
|
||||||
|
layer.init(viewer);
|
||||||
|
t.after(() => {
|
||||||
|
layer.destroy();
|
||||||
|
globalThis.document = saved;
|
||||||
|
});
|
||||||
|
return { layer, viewer };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('switching a provider on while the layer is off activates nothing (M01)', async (t) => {
|
||||||
|
const provider = fakeProvider();
|
||||||
|
const prewarmed = [];
|
||||||
|
const create = provider.create;
|
||||||
|
provider.create = (context) => {
|
||||||
|
const instance = create(context);
|
||||||
|
instance.viewer.prewarm = async (host) => prewarmed.push(host);
|
||||||
|
return instance;
|
||||||
|
};
|
||||||
|
const { layer, viewer } = initialisedLayer(t, [provider]);
|
||||||
|
layer.attachViewerHost({});
|
||||||
|
layer.setProviderEnabled('mapillary', false);
|
||||||
|
layer.setProviderEnabled('mapillary', true);
|
||||||
|
layer.setParams({ mapillary: false });
|
||||||
|
layer.setParams({ mapillary: true });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 40)); // past whenIdle
|
||||||
|
assert.equal(provider.calls.activate, 0, 'no coverage drawn');
|
||||||
|
assert.deepEqual(viewer.credits, [], 'no credit shown');
|
||||||
|
assert.deepEqual(prewarmed, [], 'no viewer stood up');
|
||||||
|
assert.equal(layer.getUIState().providers[0].on, true, 'the switch is kept');
|
||||||
|
// Enabling the layer is what activates it, once.
|
||||||
|
layer.enable(viewer);
|
||||||
|
assert.equal(provider.calls.activate, 1);
|
||||||
|
assert.equal(viewer.credits.length, 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { positionMarkerGlyph } 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';
|
||||||
|
|
||||||
|
/** On-globe marker for the open image; in terrain mode it stands on bare earth. */
|
||||||
|
export function createMarker({ state, parts }) {
|
||||||
|
const { render, sprites } = state.services;
|
||||||
|
let last = null;
|
||||||
|
|
||||||
|
function requestRender() {
|
||||||
|
render?.governorRequestRender?.('street-level-marker');
|
||||||
|
}
|
||||||
|
|
||||||
|
// It skips the depth test: hide it while it is behind the globe.
|
||||||
|
const horizon = createHorizonCull({
|
||||||
|
getViewer: () => state.viewer,
|
||||||
|
items: () => (state.marker.billboard ? [state.marker.billboard] : []),
|
||||||
|
onChange: requestRender,
|
||||||
|
});
|
||||||
|
|
||||||
|
function ensure(viewer) {
|
||||||
|
if (state.marker.collection) return;
|
||||||
|
state.marker.collection = new Cesium.BillboardCollection({
|
||||||
|
scene: viewer.scene,
|
||||||
|
});
|
||||||
|
viewer.scene.primitives.add(state.marker.collection);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Move (or create) the marker; a null position removes it. */
|
||||||
|
function set(position, bearing) {
|
||||||
|
const collection = state.marker.collection;
|
||||||
|
if (!collection) return;
|
||||||
|
last = position ? { position, bearing } : null;
|
||||||
|
if (!position) {
|
||||||
|
collection.removeAll();
|
||||||
|
state.marker.billboard = null;
|
||||||
|
horizon.stop();
|
||||||
|
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;
|
||||||
|
if (!state.marker.billboard) {
|
||||||
|
state.marker.billboard = collection.add({
|
||||||
|
id: POSITION_PICK_ID,
|
||||||
|
position: cartesian,
|
||||||
|
image: positionMarkerGlyph({ color: COLORS.position }),
|
||||||
|
imageId: 'sl-position',
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
alignedAxis: Cesium.Cartesian3.UNIT_Z,
|
||||||
|
heightReference,
|
||||||
|
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);
|
||||||
|
horizon.update([state.marker.billboard]);
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVisible(visible) {
|
||||||
|
if (state.marker.collection) state.marker.collection.show = visible;
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy(viewer) {
|
||||||
|
horizon.stop();
|
||||||
|
const collection = state.marker.collection;
|
||||||
|
if (!collection) return;
|
||||||
|
sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection);
|
||||||
|
viewer?.scene?.primitives?.remove(collection);
|
||||||
|
state.marker.collection = null;
|
||||||
|
state.marker.billboard = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-place the marker after the surface mode changes. */
|
||||||
|
function setSurface() {
|
||||||
|
if (last) set(last.position, last.bearing);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ensure,
|
||||||
|
set,
|
||||||
|
clear: () => set(null),
|
||||||
|
setVisible,
|
||||||
|
setSurface,
|
||||||
|
destroy,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
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' } = {}) {
|
||||||
|
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
|
||||||
|
const preRender = new Set();
|
||||||
|
const viewer = {
|
||||||
|
camera: {
|
||||||
|
get positionWC() {
|
||||||
|
return Cesium.Cartesian3.fromDegrees(view.lon, view.lat, view.height);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scene: {
|
||||||
|
primitives: { add: (p) => p, remove() {} },
|
||||||
|
preRender: {
|
||||||
|
addEventListener(listener) {
|
||||||
|
preRender.add(listener);
|
||||||
|
return () => preRender.delete(listener);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const state = {
|
||||||
|
services: {},
|
||||||
|
viewer,
|
||||||
|
surface,
|
||||||
|
marker: {
|
||||||
|
// Stands in for the billboard collection; ensure() keeps it.
|
||||||
|
collection: {
|
||||||
|
show: true,
|
||||||
|
add: (options) => ({ ...options }),
|
||||||
|
removeAll() {},
|
||||||
|
},
|
||||||
|
billboard: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const marker = createMarker({ state, parts });
|
||||||
|
const frame = () => {
|
||||||
|
for (const listener of [...preRender]) listener();
|
||||||
|
};
|
||||||
|
return { state, view, preRender, marker, frame };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the photo marker is hidden behind the globe, and shows from its side', () => {
|
||||||
|
const { state, view, preRender, marker, frame } = setup();
|
||||||
|
// Placed on the far side of the Earth from the camera.
|
||||||
|
marker.set({ lon: 58.51, lat: -38.58 }, 90);
|
||||||
|
const billboard = state.marker.billboard;
|
||||||
|
assert.equal(billboard.show, false, 'not drawn through the globe');
|
||||||
|
// Fly round to its side: it shows.
|
||||||
|
view.lon = 58.51;
|
||||||
|
view.lat = -38.58;
|
||||||
|
frame();
|
||||||
|
assert.equal(billboard.show, true);
|
||||||
|
// Moved to the far side again, without the camera moving.
|
||||||
|
marker.set({ lon: -121.49, lat: 38.58 }, 0);
|
||||||
|
assert.equal(billboard.show, false, 'culled where it now stands');
|
||||||
|
marker.clear();
|
||||||
|
assert.equal(preRender.size, 0, 'the cull listener is gone');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroying the marker stops the horizon cull', () => {
|
||||||
|
const { marker, preRender } = setup();
|
||||||
|
marker.set({ lon: -121.49, lat: 38.58 }, 0);
|
||||||
|
assert.equal(preRender.size, 1);
|
||||||
|
marker.destroy();
|
||||||
|
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' });
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { normalizeFilter } from './filter.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Share-link params: one boolean per provider plus the filter. The codec in
|
||||||
|
* src/data/layerState.js names the same keys, so a new provider needs one there.
|
||||||
|
* @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input
|
||||||
|
*/
|
||||||
|
export function encodeParams({ providers, filter }) {
|
||||||
|
const params = {};
|
||||||
|
for (const [id, on] of providers) params[id] = on === true;
|
||||||
|
params.pano = filter.pano;
|
||||||
|
params.sinceDays = filter.sinceDays;
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read params back, ignoring unknown providers and malformed values so a link
|
||||||
|
* from a build with more providers still applies.
|
||||||
|
* @param {object} params
|
||||||
|
* @param {{providerIds: Iterable<string>, filter: {pano: string, sinceDays: number}}} current
|
||||||
|
* @returns {{providers: Map<string, boolean>, filter: {pano: string, sinceDays: number}}}
|
||||||
|
*/
|
||||||
|
export function decodeParams(params, { providerIds, filter }) {
|
||||||
|
const providers = new Map();
|
||||||
|
const source = params && typeof params === 'object' ? params : {};
|
||||||
|
for (const id of providerIds)
|
||||||
|
if (typeof source[id] === 'boolean') providers.set(id, source[id]);
|
||||||
|
const next = {};
|
||||||
|
if ('pano' in source) next.pano = source.pano;
|
||||||
|
if ('sinceDays' in source) next.sinceDays = source.sinceDays;
|
||||||
|
return { providers, filter: normalizeFilter(next, filter) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { decodeParams, encodeParams } from './params.js';
|
||||||
|
|
||||||
|
test('encodeParams writes one boolean per provider plus the filter', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
encodeParams({
|
||||||
|
providers: [
|
||||||
|
['mapillary', true],
|
||||||
|
['panoramax', false],
|
||||||
|
],
|
||||||
|
filter: { pano: 'flat', sinceDays: 365 },
|
||||||
|
}),
|
||||||
|
{ mapillary: true, panoramax: false, pano: 'flat', sinceDays: 365 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decodeParams round-trips and ignores unknown or malformed keys', () => {
|
||||||
|
const current = { pano: 'all', sinceDays: 0 };
|
||||||
|
const decoded = decodeParams(
|
||||||
|
{ mapillary: false, kartaview: true, pano: 'pano', sinceDays: 730, x: 1 },
|
||||||
|
{ providerIds: ['mapillary'], filter: current },
|
||||||
|
);
|
||||||
|
assert.deepEqual([...decoded.providers], [['mapillary', false]]);
|
||||||
|
assert.deepEqual(decoded.filter, { pano: 'pano', sinceDays: 730 });
|
||||||
|
|
||||||
|
const partial = decodeParams(
|
||||||
|
{ mapillary: '1', pano: 'weird' },
|
||||||
|
{ providerIds: ['mapillary'], filter: { pano: 'flat', sinceDays: 5 } },
|
||||||
|
);
|
||||||
|
assert.equal(partial.providers.size, 0, 'a string is not a switch');
|
||||||
|
assert.deepEqual(partial.filter, { pano: 'flat', sinceDays: 5 });
|
||||||
|
|
||||||
|
const empty = decodeParams(null, {
|
||||||
|
providerIds: ['mapillary'],
|
||||||
|
filter: current,
|
||||||
|
});
|
||||||
|
assert.equal(empty.providers.size, 0);
|
||||||
|
assert.deepEqual(empty.filter, current);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('decodeParams applies "any date" (0 days) over a current window (share-link defaults)', () => {
|
||||||
|
const decoded = decodeParams(
|
||||||
|
{ sinceDays: 0 },
|
||||||
|
{ providerIds: ['mapillary'], filter: { pano: 'flat', sinceDays: 365 } },
|
||||||
|
);
|
||||||
|
assert.deepEqual(decoded.filter, { pano: 'flat', sinceDays: 0 });
|
||||||
|
// The provider switch off is a value too, not an absence.
|
||||||
|
assert.deepEqual(
|
||||||
|
[
|
||||||
|
...decodeParams(
|
||||||
|
{ mapillary: false },
|
||||||
|
{ providerIds: ['mapillary'], filter: { pano: 'all', sinceDays: 0 } },
|
||||||
|
).providers,
|
||||||
|
],
|
||||||
|
[['mapillary', false]],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Route picked ids to the provider whose prefix they carry; the registry
|
||||||
|
* guarantees prefixes never overlap.
|
||||||
|
* @param {() => Iterable<{def: {id: string, pickPrefix: string}, instance: object}>} getProviders
|
||||||
|
* @param {{positionId?: string}} [options] Id of the core-owned position marker.
|
||||||
|
*/
|
||||||
|
export function createPickRouter(getProviders, { positionId = null } = {}) {
|
||||||
|
function resolve(id) {
|
||||||
|
if (typeof id !== 'string' || !id) return null;
|
||||||
|
if (positionId && id === positionId)
|
||||||
|
return { providerId: null, instance: null, id };
|
||||||
|
for (const entry of getProviders())
|
||||||
|
if (id.startsWith(entry.def.pickPrefix))
|
||||||
|
return { providerId: entry.def.id, instance: entry.instance, id };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ownsPick: (id) => resolve(id) !== null,
|
||||||
|
resolve,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createPickRouter } from './pickRouter.js';
|
||||||
|
|
||||||
|
const providers = [
|
||||||
|
{ def: { id: 'mapillary', pickPrefix: 'mly:' }, instance: { name: 'm' } },
|
||||||
|
{ def: { id: 'panoramax', pickPrefix: 'pnx:' }, instance: { name: 'p' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('ids are routed by prefix to their provider instance', () => {
|
||||||
|
const router = createPickRouter(() => providers, { positionId: 'sl:pos' });
|
||||||
|
assert.deepEqual(router.resolve('mly:seq:1'), {
|
||||||
|
providerId: 'mapillary',
|
||||||
|
instance: providers[0].instance,
|
||||||
|
id: 'mly:seq:1',
|
||||||
|
});
|
||||||
|
assert.equal(router.resolve('pnx:img:9').providerId, 'panoramax');
|
||||||
|
assert.equal(router.ownsPick('mly:img:2'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the core-owned marker is owned but routes to no provider', () => {
|
||||||
|
const router = createPickRouter(() => providers, { positionId: 'sl:pos' });
|
||||||
|
assert.deepEqual(router.resolve('sl:pos'), {
|
||||||
|
providerId: null,
|
||||||
|
instance: null,
|
||||||
|
id: 'sl:pos',
|
||||||
|
});
|
||||||
|
assert.equal(router.ownsPick('sl:pos'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('foreign and malformed ids are not ours', () => {
|
||||||
|
const router = createPickRouter(() => providers);
|
||||||
|
for (const id of ['cctv:1', '', null, undefined, 42, {}])
|
||||||
|
assert.equal(router.ownsPick(id), false, String(id));
|
||||||
|
assert.equal(router.resolve('sl:pos'), null, 'no marker id configured');
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/** Identity and shared tuning for the provider-neutral Street Level layer. */
|
||||||
|
export const STREET_LEVEL_LAYER_ID = 'street-level';
|
||||||
|
|
||||||
|
/** Pick id of the viewer position marker; the core owns it, not a provider. */
|
||||||
|
export const POSITION_PICK_ID = 'sl:pos';
|
||||||
|
|
||||||
|
/** One colour per imagery source: chip, coverage lines, points, cones and legend. */
|
||||||
|
export const PROVIDER_COLORS = Object.freeze({
|
||||||
|
mapillary: '#05cb63',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Colours every provider shares: the selection highlight and the marker. */
|
||||||
|
export const COLORS = Object.freeze({
|
||||||
|
selected: '#00d4ff',
|
||||||
|
position: '#ffb300',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Panorama modes the imagery filter understands. */
|
||||||
|
export const PANO_MODES = Object.freeze(['all', 'pano', 'flat']);
|
||||||
|
|
||||||
|
/** Longest "captured since" window the filter accepts, in days (~100 years). */
|
||||||
|
export const MAX_SINCE_DAYS = 36_500;
|
||||||
|
|
||||||
|
/** Filter every provider starts with: all imagery, any date. */
|
||||||
|
export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
|
||||||
|
|
||||||
|
/** Nearest-image search radius in metres (Mapillary's graph API caps it at 50 m). */
|
||||||
|
export const NEAREST_RADIUS_M = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
@@ -0,0 +1,917 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { decodeCoverageTile } from './decode.js';
|
||||||
|
import { passesImageryFilter } from '../../filter.js';
|
||||||
|
import {
|
||||||
|
createHorizonCull,
|
||||||
|
groundUnderCamera,
|
||||||
|
metresBetween,
|
||||||
|
viewFocus,
|
||||||
|
visibleBbox,
|
||||||
|
whenIdle,
|
||||||
|
} from '../../view.js';
|
||||||
|
import { densifyLine, MESH_DENSIFY_DEG } from '../../groundCast.js';
|
||||||
|
import { MESH_SAMPLE_RADIUS_M } from '../../meshSampler.js';
|
||||||
|
import {
|
||||||
|
coverageZoomForHeight,
|
||||||
|
overviewZoomForHeight,
|
||||||
|
tileBounds,
|
||||||
|
tilesForBbox,
|
||||||
|
} from '../../tileMath.js';
|
||||||
|
import {
|
||||||
|
COLORS,
|
||||||
|
COVERAGE_LINE_WIDTH_PX,
|
||||||
|
COVERAGE_MAX_SEQUENCES,
|
||||||
|
COVERAGE_MAX_TILES,
|
||||||
|
COVERAGE_MOVE_DEBOUNCE_MS,
|
||||||
|
COVERAGE_OVERVIEW_MAX_TILES,
|
||||||
|
COVERAGE_OVERVIEW_POINT_PX,
|
||||||
|
KEY_REJECTED_MESSAGE,
|
||||||
|
PICK_PREFIX,
|
||||||
|
RATE_LIMITED_MESSAGE,
|
||||||
|
SEQUENCE_VIEW_NEAR_M,
|
||||||
|
SEQUENCE_VIEW_RANGE_MIN_M,
|
||||||
|
SEQUENCE_VIEW_RANGE_PER_HEIGHT,
|
||||||
|
} from './policy.js';
|
||||||
|
|
||||||
|
/** Draped lines stay at most this long while a tile's cast lines build. */
|
||||||
|
export const SWAP_MAX_WAIT_MS = 4000;
|
||||||
|
/** Tiles touched by new mesh samples are redrawn at most this often. */
|
||||||
|
const REMESH_INTERVAL_MS = 1500;
|
||||||
|
/** Gap between redrawing one dirty tile and the next. */
|
||||||
|
const REMESH_STAGGER_MS = 120;
|
||||||
|
/** Old-zoom tiles are kept at most this long after a zoom change. */
|
||||||
|
const STALE_TILE_MAX_MS = 6000;
|
||||||
|
/** Sequences per primitive: smaller batches build, and show, sooner. */
|
||||||
|
const SEQUENCE_PRIMITIVE_BATCH = 120;
|
||||||
|
|
||||||
|
const PER_TILE_SEQUENCE_CAP = Math.floor(
|
||||||
|
COVERAGE_MAX_SEQUENCES / COVERAGE_MAX_TILES,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Colour for a sequence: Mapillary green, GEV cyan while selected. */
|
||||||
|
function sequenceColor({ selected = false } = {}) {
|
||||||
|
return selected
|
||||||
|
? Cesium.Color.fromCssColorString(COLORS.selected)
|
||||||
|
: Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Separates a multi-part sequence's part index from its id in pick ids. */
|
||||||
|
const PART_SEPARATOR = '~';
|
||||||
|
|
||||||
|
/** Pick ids for every part of a sequence; the first part is unsuffixed. */
|
||||||
|
function partIds(sequence) {
|
||||||
|
return sequence.parts.map((_, index) =>
|
||||||
|
index
|
||||||
|
? `${PICK_PREFIX.sequence}${sequence.id}${PART_SEPARATOR}${index}`
|
||||||
|
: `${PICK_PREFIX.sequence}${sequence.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sequence id for any part's pick id (`mly:seq:<id>[~<part>]`), else null. */
|
||||||
|
export function sequenceIdFromPick(pickId) {
|
||||||
|
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX.sequence))
|
||||||
|
return null;
|
||||||
|
const rest = pickId.slice(PICK_PREFIX.sequence.length);
|
||||||
|
const cut = rest.indexOf(PART_SEPARATOR);
|
||||||
|
return cut === -1 ? rest : rest.slice(0, cut);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether any point of `box` ({west, south, east, north}) is within the mesh
|
||||||
|
* sampler's range of `centre`, the ground point under the camera.
|
||||||
|
*/
|
||||||
|
export function meshBoxInRange(box, centre) {
|
||||||
|
// Move the camera's longitude to the box's side of the date line first:
|
||||||
|
// clamping 179.999 into a box at -179.99 would pick its far edge.
|
||||||
|
const lon =
|
||||||
|
centre.lon +
|
||||||
|
360 * Math.round(((box.west + box.east) / 2 - centre.lon) / 360);
|
||||||
|
const nearest = {
|
||||||
|
lon: Math.min(box.east, Math.max(box.west, lon)),
|
||||||
|
lat: Math.min(box.north, Math.max(box.south, centre.lat)),
|
||||||
|
};
|
||||||
|
return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Camera-driven coverage: overview points (z0–5) from orbit, sequence lines
|
||||||
|
* (z11–14) near the ground. In terrain surface mode a tile is drawn draped
|
||||||
|
* first, then swapped for lines cast to the bare earth (draped lines would
|
||||||
|
* land on roofs), so coverage never waits on terrain heights.
|
||||||
|
*/
|
||||||
|
export function createCoverage({ state, source }) {
|
||||||
|
const { render } = state.services;
|
||||||
|
|
||||||
|
function requestRender() {
|
||||||
|
render?.governorRequestRender?.('mapillary-coverage');
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureTerrainReady() {
|
||||||
|
return (state.coverage.terrainReady ||=
|
||||||
|
Cesium.GroundPolylinePrimitive.initializeTerrainHeights());
|
||||||
|
}
|
||||||
|
|
||||||
|
function tileKey({ x, y, z }) {
|
||||||
|
return `${z}/${x}/${y}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The imagery filter, resolved now: "since N days" moves with the clock. */
|
||||||
|
function filter() {
|
||||||
|
return state.context.getFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
function terrainMode() {
|
||||||
|
return (
|
||||||
|
state.context.getSurface?.() === 'terrain' &&
|
||||||
|
Boolean(state.context.groundCaster)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tile's sequences, filtered then capped: capping first could leave a
|
||||||
|
* dense tile of newer flat captures with no 360° lines at all.
|
||||||
|
*/
|
||||||
|
function drawnSequences(entry) {
|
||||||
|
const drawn = [];
|
||||||
|
const current = filter();
|
||||||
|
for (const sequence of entry.sequenceList) {
|
||||||
|
if (drawn.length >= PER_TILE_SEQUENCE_CAP) break;
|
||||||
|
if (passesImageryFilter(sequence, current)) drawn.push(sequence);
|
||||||
|
}
|
||||||
|
return drawn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batched primitives for a tile's sequence parts: cast polylines where
|
||||||
|
* terrain heights are cached, draped ground lines otherwise. Each batch
|
||||||
|
* records the selection its colours were built with.
|
||||||
|
*/
|
||||||
|
function buildSequencePrimitives(sequences) {
|
||||||
|
const ground = terrainMode() ? state.context.groundCaster : null;
|
||||||
|
const meshAt = ground ? state.context.meshSampler?.meshAt : undefined;
|
||||||
|
const selectedId = state.sequence.selectedId ?? null;
|
||||||
|
const draped = [];
|
||||||
|
const cast = [];
|
||||||
|
for (const sequence of sequences) {
|
||||||
|
const color = Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||||
|
sequenceColor({ selected: sequence.id === selectedId }),
|
||||||
|
);
|
||||||
|
const ids = partIds(sequence);
|
||||||
|
sequence.parts.forEach((coordinates, index) => {
|
||||||
|
const flat = ground?.castLine(coordinates, { meshAt });
|
||||||
|
let positions;
|
||||||
|
try {
|
||||||
|
positions = flat
|
||||||
|
? Cesium.Cartesian3.fromDegreesArrayHeights(flat)
|
||||||
|
: Cesium.Cartesian3.fromDegreesArray(coordinates.flat());
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (positions.length < 2) return;
|
||||||
|
const geometry = flat
|
||||||
|
? new Cesium.PolylineGeometry({
|
||||||
|
positions,
|
||||||
|
width: COVERAGE_LINE_WIDTH_PX,
|
||||||
|
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||||
|
arcType: Cesium.ArcType.NONE,
|
||||||
|
})
|
||||||
|
: new Cesium.GroundPolylineGeometry({
|
||||||
|
positions,
|
||||||
|
width: COVERAGE_LINE_WIDTH_PX,
|
||||||
|
});
|
||||||
|
(flat ? cast : draped).push(
|
||||||
|
new Cesium.GeometryInstance({
|
||||||
|
geometry,
|
||||||
|
id: ids[index],
|
||||||
|
attributes: { color },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const primitives = [];
|
||||||
|
for (let i = 0; i < draped.length; i += SEQUENCE_PRIMITIVE_BATCH)
|
||||||
|
primitives.push({
|
||||||
|
onGround: true,
|
||||||
|
selectedId,
|
||||||
|
primitive: new Cesium.GroundPolylinePrimitive({
|
||||||
|
geometryInstances: draped.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
|
||||||
|
appearance: new Cesium.PolylineColorAppearance(),
|
||||||
|
classificationType: Cesium.ClassificationType.BOTH,
|
||||||
|
asynchronous: true,
|
||||||
|
allowPicking: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
for (let i = 0; i < cast.length; i += SEQUENCE_PRIMITIVE_BATCH)
|
||||||
|
primitives.push({
|
||||||
|
onGround: false,
|
||||||
|
selectedId,
|
||||||
|
primitive: new Cesium.Primitive({
|
||||||
|
geometryInstances: cast.slice(i, i + SEQUENCE_PRIMITIVE_BATCH),
|
||||||
|
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||||
|
asynchronous: true,
|
||||||
|
allowPicking: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return { primitives, draped, cast: cast.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the terrain heights a tile's lines need, then redraw it cast.
|
||||||
|
* `castRequested` stops the redraw from casting again at once; a refresh
|
||||||
|
* `retry`s a tile whose lines are still draped (a failed or partial cast),
|
||||||
|
* and it is redrawn only when more of its lines can be cast.
|
||||||
|
*/
|
||||||
|
function castTile(entry, { retry = false } = {}) {
|
||||||
|
if ((entry.castRequested && !retry) || entry.castAbort || !terrainMode())
|
||||||
|
return;
|
||||||
|
entry.castRequested = true;
|
||||||
|
entry.castAbort = new AbortController();
|
||||||
|
const { signal } = entry.castAbort;
|
||||||
|
const lines = [...entry.sequences.values()].flatMap(
|
||||||
|
(sequence) => sequence.parts,
|
||||||
|
);
|
||||||
|
const caster = state.context.groundCaster;
|
||||||
|
caster.prepareLines(lines, { signal }).then(() => {
|
||||||
|
if (entry.castAbort?.signal === signal) entry.castAbort = null;
|
||||||
|
const attached = [...state.coverage.tiles.values()].includes(entry);
|
||||||
|
if (signal.aborted || !attached || !terrainMode()) return;
|
||||||
|
// Nothing new to cast: keep what is drawn until the next refresh.
|
||||||
|
// Counted like buildSequencePrimitives: a cast line needs two points.
|
||||||
|
const castable = lines.filter(
|
||||||
|
(coords) => caster.castLine(coords)?.length >= 6,
|
||||||
|
).length;
|
||||||
|
if (castable <= (entry.castLines || 0)) {
|
||||||
|
entry.castRequested = (entry.castLines || 0) > 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A remesh may have cleared the flag while this was in flight.
|
||||||
|
entry.castRequested = true;
|
||||||
|
redrawTile(entry);
|
||||||
|
requestMesh(entry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuild a tile's lines; the old ones stay up until the new are ready. */
|
||||||
|
function redrawTile(entry) {
|
||||||
|
const previous = entry.primitives;
|
||||||
|
entry.primitives = [];
|
||||||
|
attachPrimitive(entry);
|
||||||
|
removeWhenReady(entry, previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lon/lat under the camera, where mesh range is measured from. */
|
||||||
|
function meshCentre() {
|
||||||
|
const carto = state.viewer?.camera?.positionCartographic;
|
||||||
|
if (!carto) return null;
|
||||||
|
return {
|
||||||
|
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||||
|
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tile's drawn lines with bounding boxes, built once per filter; mesh
|
||||||
|
* points are densified the first time a line is in range.
|
||||||
|
*/
|
||||||
|
function meshParts(entry) {
|
||||||
|
if (entry.meshParts) return entry.meshParts;
|
||||||
|
entry.meshParts = [];
|
||||||
|
for (const sequence of entry.sequences.values())
|
||||||
|
for (const part of sequence.parts) {
|
||||||
|
const box = {
|
||||||
|
west: Infinity,
|
||||||
|
south: Infinity,
|
||||||
|
east: -Infinity,
|
||||||
|
north: -Infinity,
|
||||||
|
};
|
||||||
|
for (const [lon, lat] of part) {
|
||||||
|
if (lon < box.west) box.west = lon;
|
||||||
|
if (lon > box.east) box.east = lon;
|
||||||
|
if (lat < box.south) box.south = lat;
|
||||||
|
if (lat > box.north) box.north = lat;
|
||||||
|
}
|
||||||
|
entry.meshParts.push({ part, box, points: null });
|
||||||
|
}
|
||||||
|
return entry.meshParts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask for mesh samples under a cast tile's lines within the sampler's range
|
||||||
|
* of the camera; the rest are asked for when a camera move brings them in.
|
||||||
|
*/
|
||||||
|
function requestMesh(entry) {
|
||||||
|
const sampler = state.context.meshSampler;
|
||||||
|
if (!sampler || entry.kind !== 'sequence' || !terrainMode()) return;
|
||||||
|
const centre = meshCentre();
|
||||||
|
if (!centre || !entry.bounds || !meshBoxInRange(entry.bounds, centre))
|
||||||
|
return;
|
||||||
|
const points = [];
|
||||||
|
for (const line of meshParts(entry)) {
|
||||||
|
if (!meshBoxInRange(line.box, centre)) continue;
|
||||||
|
line.points ||= densifyLine(line.part, MESH_DENSIFY_DEG);
|
||||||
|
for (const point of line.points) points.push(point);
|
||||||
|
}
|
||||||
|
if (points.length) sampler.request(points);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redraw the tiles new mesh samples fall in, throttled. */
|
||||||
|
function onMeshSampled(batch) {
|
||||||
|
if (!terrainMode() || !state.context.isActive()) return;
|
||||||
|
for (const entry of state.coverage.tiles.values()) {
|
||||||
|
if (entry.kind !== 'sequence' || !entry.bounds) continue;
|
||||||
|
const { west, south, east, north } = entry.bounds;
|
||||||
|
if (
|
||||||
|
batch.some(
|
||||||
|
([lon, lat]) =>
|
||||||
|
lon >= west && lon <= east && lat >= south && lat <= north,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
state.coverage.remeshDirty.add(entry);
|
||||||
|
}
|
||||||
|
if (!state.coverage.remeshDirty.size || state.coverage.remeshTimer) return;
|
||||||
|
const wait = Math.max(
|
||||||
|
0,
|
||||||
|
REMESH_INTERVAL_MS - (Date.now() - (state.coverage.remeshAt || 0)),
|
||||||
|
);
|
||||||
|
state.coverage.remeshTimer = setTimeout(remesh, wait);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redraw one dirty tile per idle slice, so a burst of samples never stalls a frame. */
|
||||||
|
function remesh() {
|
||||||
|
state.coverage.remeshTimer = null;
|
||||||
|
state.coverage.remeshAt = Date.now();
|
||||||
|
if (!terrainMode() || !state.context.isActive()) {
|
||||||
|
state.coverage.remeshDirty.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const attached = new Set(state.coverage.tiles.values());
|
||||||
|
const [entry] = state.coverage.remeshDirty;
|
||||||
|
if (!entry) return;
|
||||||
|
state.coverage.remeshDirty.delete(entry);
|
||||||
|
if (attached.has(entry)) {
|
||||||
|
redrawTile(entry);
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
if (state.coverage.remeshDirty.size)
|
||||||
|
state.coverage.remeshTimer = setTimeout(
|
||||||
|
() => whenIdle(remesh, 500),
|
||||||
|
REMESH_STAGGER_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.coverage.remeshDirty = new Set();
|
||||||
|
state.context.meshSampler?.onSampled(onMeshSampled);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a tile's previous primitives once all its current ones are ready
|
||||||
|
* (or after a few seconds); detaching the tile removes them at once.
|
||||||
|
*/
|
||||||
|
function removeWhenReady(entry, old) {
|
||||||
|
const scene = state.viewer?.scene;
|
||||||
|
finishSwap(entry);
|
||||||
|
if (!scene?.postRender) {
|
||||||
|
removePrimitives(old);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fresh = entry.primitives;
|
||||||
|
const started = Date.now();
|
||||||
|
const stop = scene.postRender.addEventListener(() => {
|
||||||
|
const ready = fresh.every(
|
||||||
|
({ primitive }) => primitive.ready || primitive.isDestroyed?.(),
|
||||||
|
);
|
||||||
|
if (!ready && Date.now() - started < SWAP_MAX_WAIT_MS) {
|
||||||
|
requestRender();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finishSwap(entry);
|
||||||
|
requestRender();
|
||||||
|
});
|
||||||
|
entry.swap = { old, stop };
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishSwap(entry) {
|
||||||
|
if (!entry.swap) return;
|
||||||
|
entry.swap.stop();
|
||||||
|
removePrimitives(entry.swap.old);
|
||||||
|
entry.swap = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOverviewCollection(points) {
|
||||||
|
const collection = new Cesium.PointPrimitiveCollection({
|
||||||
|
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
||||||
|
});
|
||||||
|
const green = Cesium.Color.fromCssColorString(COLORS.coverage).withAlpha(
|
||||||
|
0.85,
|
||||||
|
);
|
||||||
|
const drawn = [];
|
||||||
|
const current = filter();
|
||||||
|
for (const point of points) {
|
||||||
|
if (!passesImageryFilter(point, current)) continue;
|
||||||
|
drawn.push(
|
||||||
|
collection.add({
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(point.lon, point.lat),
|
||||||
|
color: green,
|
||||||
|
pixelSize: COVERAGE_OVERVIEW_POINT_PX,
|
||||||
|
// Google 3D terrain and clouds must not hide the near side's dots;
|
||||||
|
// the horizon cull hides the far side's, which this lets through.
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { collection, points: drawn };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every overview point on the globe, current zoom and old alike. */
|
||||||
|
function* overviewPoints() {
|
||||||
|
for (const entry of drawnEntries())
|
||||||
|
if (entry.overviewPoints) yield* entry.overviewPoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overview points skip the depth test: hide the far hemisphere's.
|
||||||
|
const horizon = createHorizonCull({
|
||||||
|
getViewer: () => state.viewer,
|
||||||
|
items: overviewPoints,
|
||||||
|
onChange: requestRender,
|
||||||
|
});
|
||||||
|
|
||||||
|
function attachPrimitive(entry) {
|
||||||
|
const scene = state.viewer?.scene;
|
||||||
|
if (!scene) return;
|
||||||
|
if (entry.kind === 'sequence') {
|
||||||
|
// Lookup, picking and counts follow what is drawn, not the whole tile.
|
||||||
|
const sequences = drawnSequences(entry);
|
||||||
|
entry.sequences = new Map(sequences.map((s) => [s.id, s]));
|
||||||
|
const { primitives, draped, cast } = buildSequencePrimitives(sequences);
|
||||||
|
entry.primitives = primitives;
|
||||||
|
entry.count = sequences.length;
|
||||||
|
for (const { primitive, onGround } of primitives)
|
||||||
|
(onGround ? scene.groundPrimitives : scene.primitives).add(primitive);
|
||||||
|
// Fewer lines cast than last time: the caster has dropped heights this
|
||||||
|
// tile used (a full cache), so it must be cast again.
|
||||||
|
if (cast < (entry.castLines || 0)) entry.castRequested = false;
|
||||||
|
entry.castLines = cast;
|
||||||
|
entry.drapedLines = draped.length;
|
||||||
|
if (draped.length) castTile(entry);
|
||||||
|
watchSelection();
|
||||||
|
} else {
|
||||||
|
const { collection, points } = buildOverviewCollection(entry.points);
|
||||||
|
entry.primitive = collection;
|
||||||
|
entry.overviewPoints = points;
|
||||||
|
entry.count = points.length;
|
||||||
|
scene.primitives.add(collection);
|
||||||
|
// Cull the new points now, then all of them whenever the camera moves.
|
||||||
|
horizon.update(points);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePrimitives(list) {
|
||||||
|
const scene = state.viewer?.scene;
|
||||||
|
for (const { primitive, onGround } of list || []) {
|
||||||
|
try {
|
||||||
|
(onGround ? scene?.groundPrimitives : scene?.primitives)?.remove(
|
||||||
|
primitive,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detachPrimitive(entry) {
|
||||||
|
const scene = state.viewer?.scene;
|
||||||
|
finishSwap(entry);
|
||||||
|
removePrimitives(entry.primitives);
|
||||||
|
entry.primitives = [];
|
||||||
|
if (entry.primitive) {
|
||||||
|
try {
|
||||||
|
scene?.primitives?.remove(entry.primitive);
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
entry.primitive = null;
|
||||||
|
entry.overviewPoints = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop a tile's pending terrain lookup (tile dropped or retired). */
|
||||||
|
function cancelCast(entry) {
|
||||||
|
entry.castAbort?.abort();
|
||||||
|
entry.castAbort = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTile(key) {
|
||||||
|
const entry = state.coverage.tiles.get(key);
|
||||||
|
if (!entry) return;
|
||||||
|
cancelCast(entry);
|
||||||
|
detachPrimitive(entry);
|
||||||
|
state.coverage.tiles.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTile(tile, kind) {
|
||||||
|
const key = tileKey(tile);
|
||||||
|
if (state.coverage.tiles.has(key) || state.coverage.pending.has(key))
|
||||||
|
return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
state.coverage.pending.set(key, controller);
|
||||||
|
notify();
|
||||||
|
try {
|
||||||
|
// Fetch and the one-time terrain-height table load run side by side,
|
||||||
|
// both handled from the start so an early tile failure is not unhandled.
|
||||||
|
const [bytes] = await Promise.all([
|
||||||
|
source.getTile('coverage', tile.z, tile.x, tile.y, {
|
||||||
|
signal: controller.signal,
|
||||||
|
}),
|
||||||
|
kind === 'sequence' ? ensureTerrainReady() : null,
|
||||||
|
]);
|
||||||
|
// Only an abort, a retire, a clear or a newer request for this tile
|
||||||
|
// discards the bytes; a refresh that still wants the tile keeps them.
|
||||||
|
const current = () =>
|
||||||
|
state.coverage.pending.get(key) === controller &&
|
||||||
|
!state.coverage.tiles.has(key);
|
||||||
|
if (
|
||||||
|
controller.signal.aborted ||
|
||||||
|
!current() ||
|
||||||
|
kind !== state.coverage.kind ||
|
||||||
|
tile.z !== state.coverage.zoom
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const decoded = decodeCoverageTile(bytes, tile);
|
||||||
|
const entry = { kind, primitive: null, primitives: [], count: 0 };
|
||||||
|
// Padded: vector tiles carry a small buffer past their edge.
|
||||||
|
const bounds = tileBounds(tile.x, tile.y, tile.z);
|
||||||
|
const pad = (bounds.east - bounds.west) * 0.05;
|
||||||
|
entry.bounds = {
|
||||||
|
west: bounds.west - pad,
|
||||||
|
east: bounds.east + pad,
|
||||||
|
south: bounds.south - pad,
|
||||||
|
north: bounds.north + pad,
|
||||||
|
};
|
||||||
|
if (kind === 'sequence') {
|
||||||
|
// Newest first; the per-tile cap applies to what passes the filter.
|
||||||
|
entry.sequenceList = decoded.sequences.sort(
|
||||||
|
(a, b) => (b.capturedAt || 0) - (a.capturedAt || 0),
|
||||||
|
);
|
||||||
|
entry.sequences = new Map();
|
||||||
|
entry.total = decoded.sequences.length;
|
||||||
|
} else {
|
||||||
|
entry.points = decoded.overview;
|
||||||
|
entry.sequences = new Map();
|
||||||
|
entry.total = decoded.overview.length;
|
||||||
|
}
|
||||||
|
attachPrimitive(entry);
|
||||||
|
state.coverage.tiles.set(key, entry);
|
||||||
|
state.coverage.lastError = null;
|
||||||
|
requestRender();
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
controller.signal.aborted ||
|
||||||
|
state.coverage.pending.get(key) !== controller
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
state.coverage.lastError = error?.message || 'Coverage tile failed';
|
||||||
|
if (error?.keyRequired) state.keyRequired = true;
|
||||||
|
if (error?.keyRejected) {
|
||||||
|
// Every other tile would be refused too: stop asking.
|
||||||
|
state.keyRejected = true;
|
||||||
|
state.coverage.lastError = KEY_REJECTED_MESSAGE;
|
||||||
|
}
|
||||||
|
if (error?.retryAfterSec) holdFor(error.retryAfterSec);
|
||||||
|
} finally {
|
||||||
|
// Only the request that still owns the key settles it: a superseded one
|
||||||
|
// must not drop a newer request's entry (which is what counts as loading).
|
||||||
|
if (state.coverage.pending.get(key) === controller) {
|
||||||
|
state.coverage.pending.delete(key);
|
||||||
|
if (!state.coverage.pending.size) purgeStale();
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapillary is rate-limiting: keep what is drawn, request nothing until the
|
||||||
|
* wait is over, then refresh once.
|
||||||
|
*/
|
||||||
|
function holdFor(seconds) {
|
||||||
|
clearTimeout(state.coverage.holdTimer);
|
||||||
|
state.coverage.holdUntil = Date.now() + seconds * 1000;
|
||||||
|
state.coverage.lastError = RATE_LIMITED_MESSAGE;
|
||||||
|
state.coverage.holdTimer = setTimeout(() => {
|
||||||
|
state.coverage.holdTimer = null;
|
||||||
|
state.coverage.holdUntil = 0;
|
||||||
|
if (state.coverage.lastError === RATE_LIMITED_MESSAGE)
|
||||||
|
state.coverage.lastError = null;
|
||||||
|
refresh();
|
||||||
|
}, seconds * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget refusals when the provider goes off, so the next run asks again. */
|
||||||
|
function resetErrors() {
|
||||||
|
clearTimeout(state.coverage.holdTimer);
|
||||||
|
state.coverage.holdTimer = null;
|
||||||
|
state.coverage.holdUntil = 0;
|
||||||
|
state.coverage.lastError = null;
|
||||||
|
state.keyRejected = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the previous zoom's tiles once the new ones are on screen. */
|
||||||
|
function purgeStale() {
|
||||||
|
clearTimeout(state.coverage.staleTimer);
|
||||||
|
state.coverage.staleTimer = null;
|
||||||
|
if (!state.coverage.stale.size) return;
|
||||||
|
for (const entry of state.coverage.stale.values()) detachPrimitive(entry);
|
||||||
|
state.coverage.stale.clear();
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move tiles to the stale set: the old zoom shows until the new one loads. */
|
||||||
|
function retire() {
|
||||||
|
for (const controller of state.coverage.pending.values())
|
||||||
|
controller.abort();
|
||||||
|
state.coverage.pending.clear();
|
||||||
|
for (const [key, entry] of state.coverage.tiles) {
|
||||||
|
cancelCast(entry);
|
||||||
|
const previous = state.coverage.stale.get(key);
|
||||||
|
if (previous) detachPrimitive(previous);
|
||||||
|
state.coverage.stale.set(key, entry);
|
||||||
|
}
|
||||||
|
state.coverage.tiles.clear();
|
||||||
|
clearTimeout(state.coverage.staleTimer);
|
||||||
|
state.coverage.staleTimer = setTimeout(purgeStale, STALE_TILE_MAX_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
state.context.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the screen centre meets the ground further ahead than the camera
|
||||||
|
* is high (a view tilted above 45°), or misses it.
|
||||||
|
*/
|
||||||
|
function isTilted({ nadir, ahead }, height) {
|
||||||
|
if (!ahead) return true;
|
||||||
|
return metresBetween(nadir, ahead) > Math.max(1, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recompute the tile set for the current camera and reconcile primitives. */
|
||||||
|
function refresh() {
|
||||||
|
const viewer = state.viewer;
|
||||||
|
if (!viewer || !state.context.isActive() || state.keyRequired) return;
|
||||||
|
if (state.statusKnown === false) return;
|
||||||
|
if (state.keyRejected || state.coverage.holdUntil > Date.now()) return;
|
||||||
|
const ground = groundUnderCamera(viewer, {
|
||||||
|
groundAt: state.context.groundCaster?.groundAt,
|
||||||
|
});
|
||||||
|
const cameraHeight = viewer.camera?.positionCartographic?.height;
|
||||||
|
const height = Number.isFinite(cameraHeight)
|
||||||
|
? cameraHeight - (ground ?? 0)
|
||||||
|
: null;
|
||||||
|
const sequenceZoom = coverageZoomForHeight(height);
|
||||||
|
const overviewZoom = sequenceZoom ? null : overviewZoomForHeight(height);
|
||||||
|
const zoom = sequenceZoom ?? overviewZoom;
|
||||||
|
const kind = sequenceZoom ? 'sequence' : 'overview';
|
||||||
|
// At street zooms the rays meet the ground where it really is (1,600 m up
|
||||||
|
// in Denver; Google 3D hides the globe) and stop short of the horizon.
|
||||||
|
const ranged =
|
||||||
|
kind === 'sequence'
|
||||||
|
? {
|
||||||
|
groundHeight: ground,
|
||||||
|
maxRange: Math.max(
|
||||||
|
SEQUENCE_VIEW_RANGE_MIN_M,
|
||||||
|
height * SEQUENCE_VIEW_RANGE_PER_HEIGHT,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
// Tilted street views rank tiles along the line of sight, from the ground
|
||||||
|
// under the camera to the ground at the centre of the screen.
|
||||||
|
const focus = kind === 'sequence' ? viewFocus(viewer, ranged) : null;
|
||||||
|
let bbox = visibleBbox(viewer, {
|
||||||
|
...ranged,
|
||||||
|
// A tilted view's screen rows jump from the first metres to the horizon:
|
||||||
|
// keep the ground around the camera too. Looking down needs no help.
|
||||||
|
nearRange: focus && isTilted(focus, height) ? SEQUENCE_VIEW_NEAR_M : null,
|
||||||
|
});
|
||||||
|
if (kind === 'overview' && (!bbox || zoom <= 1))
|
||||||
|
bbox = [-180, -85, 180, 85];
|
||||||
|
if (zoom == null || !bbox) {
|
||||||
|
state.coverage.hint =
|
||||||
|
'Point the camera at the globe for street-level coverage';
|
||||||
|
clear();
|
||||||
|
notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.coverage.hint = '';
|
||||||
|
if (zoom !== state.coverage.zoom || kind !== state.coverage.kind) {
|
||||||
|
retire();
|
||||||
|
state.coverage.zoom = zoom;
|
||||||
|
state.coverage.kind = kind;
|
||||||
|
}
|
||||||
|
const { tiles } = tilesForBbox(bbox, zoom, {
|
||||||
|
limit:
|
||||||
|
kind === 'sequence' ? COVERAGE_MAX_TILES : COVERAGE_OVERVIEW_MAX_TILES,
|
||||||
|
focus: focus && { from: focus.nadir, to: focus.ahead },
|
||||||
|
});
|
||||||
|
const wanted = new Set(tiles.map(tileKey));
|
||||||
|
for (const key of [...state.coverage.tiles.keys()])
|
||||||
|
if (!wanted.has(key)) removeTile(key);
|
||||||
|
for (const [key, controller] of [...state.coverage.pending])
|
||||||
|
if (!wanted.has(key)) {
|
||||||
|
// The aborted request no longer owns its key, so its `finally` will
|
||||||
|
// not settle it: settle it here.
|
||||||
|
controller.abort();
|
||||||
|
state.coverage.pending.delete(key);
|
||||||
|
}
|
||||||
|
for (const tile of tiles) loadTile(tile, kind);
|
||||||
|
if (!state.coverage.pending.size) purgeStale();
|
||||||
|
// The camera moved: cells that were out of the sampler's range may not be,
|
||||||
|
// and lines left draped by a failed, partial or dropped cast get another try.
|
||||||
|
if (terrainMode())
|
||||||
|
for (const entry of state.coverage.tiles.values()) {
|
||||||
|
if (entry.castRequested) requestMesh(entry);
|
||||||
|
if (entry.drapedLines) castTile(entry, { retry: true });
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRefresh() {
|
||||||
|
clearTimeout(state.coverage.debounceTimer);
|
||||||
|
state.coverage.debounceTimer = setTimeout(
|
||||||
|
refresh,
|
||||||
|
COVERAGE_MOVE_DEBOUNCE_MS,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attach(viewer) {
|
||||||
|
detach();
|
||||||
|
const remove = viewer.camera.changed.addEventListener(scheduleRefresh);
|
||||||
|
const removeEnd = viewer.camera.moveEnd.addEventListener(scheduleRefresh);
|
||||||
|
state.coverage.removeCameraListener = () => {
|
||||||
|
remove();
|
||||||
|
removeEnd();
|
||||||
|
};
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function detach() {
|
||||||
|
clearTimeout(state.coverage.debounceTimer);
|
||||||
|
state.coverage.debounceTimer = null;
|
||||||
|
state.coverage.removeCameraListener?.();
|
||||||
|
state.coverage.removeCameraListener = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
for (const controller of state.coverage.pending.values())
|
||||||
|
controller.abort();
|
||||||
|
state.coverage.pending.clear();
|
||||||
|
for (const key of [...state.coverage.tiles.keys()]) removeTile(key);
|
||||||
|
purgeStale();
|
||||||
|
state.coverage.zoom = null;
|
||||||
|
state.coverage.kind = null;
|
||||||
|
clearTimeout(state.coverage.remeshTimer);
|
||||||
|
state.coverage.remeshTimer = null;
|
||||||
|
state.coverage.remeshDirty.clear();
|
||||||
|
stopSelectionWatch();
|
||||||
|
horizon.stop();
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rebuild every loaded tile from its decoded cache (after a filter change). */
|
||||||
|
function rebuild() {
|
||||||
|
purgeStale();
|
||||||
|
for (const entry of state.coverage.tiles.values()) {
|
||||||
|
cancelCast(entry);
|
||||||
|
entry.castRequested = false;
|
||||||
|
entry.meshParts = null;
|
||||||
|
detachPrimitive(entry);
|
||||||
|
attachPrimitive(entry);
|
||||||
|
}
|
||||||
|
requestRender();
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redraw tiles draped or cast for the new surface mode, then re-pick the
|
||||||
|
* zoom, since the ground height under the camera may have changed.
|
||||||
|
*/
|
||||||
|
function setSurface() {
|
||||||
|
if (!state.context.isActive()) return;
|
||||||
|
rebuild();
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recolour every part of a sequence in one ready primitive. */
|
||||||
|
function recolorInstances(primitive, sequence, selected) {
|
||||||
|
const value = sequenceColor({ selected });
|
||||||
|
for (const instanceId of partIds(sequence)) {
|
||||||
|
try {
|
||||||
|
const attributes = primitive.getGeometryInstanceAttributes(instanceId);
|
||||||
|
if (attributes)
|
||||||
|
attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue(
|
||||||
|
value,
|
||||||
|
attributes.color,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* instance not in this primitive */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every tile on the globe: the current zoom's and the stale zoom's. */
|
||||||
|
function drawnEntries() {
|
||||||
|
return [...state.coverage.tiles.values(), ...state.coverage.stale.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tile's primitives on the globe: its own, and the ones a swap still shows. */
|
||||||
|
function drawnRecords(entry) {
|
||||||
|
const records = entry.primitives || [];
|
||||||
|
return entry.swap ? [...records, ...entry.swap.old] : records;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recolour one sequence, every part of it, in place (selection highlight). */
|
||||||
|
function recolorSequence(id, selected) {
|
||||||
|
for (const entry of drawnEntries()) {
|
||||||
|
const sequence = entry.sequences.get(id);
|
||||||
|
if (!sequence) continue;
|
||||||
|
for (const record of drawnRecords(entry)) {
|
||||||
|
// Still building: `syncSelection` catches it up once it is ready.
|
||||||
|
if (!record.primitive.ready) continue;
|
||||||
|
recolorInstances(record.primitive, sequence, selected);
|
||||||
|
if (selected) record.selectedId = id;
|
||||||
|
else if (record.selectedId === id) record.selectedId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bring a ready primitive's highlight up to the current selection, when
|
||||||
|
* it was built with another one (or none). True when it changed.
|
||||||
|
*/
|
||||||
|
function syncSelection(entry, record) {
|
||||||
|
const current = state.sequence.selectedId ?? null;
|
||||||
|
if (record.selectedId === current || !record.primitive.ready) return false;
|
||||||
|
const previous =
|
||||||
|
record.selectedId && entry.sequences.get(record.selectedId);
|
||||||
|
if (previous) recolorInstances(record.primitive, previous, false);
|
||||||
|
const next = current && entry.sequences.get(current);
|
||||||
|
if (next) recolorInstances(record.primitive, next, true);
|
||||||
|
record.selectedId = current;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A primitive cannot be recoloured until it is ready, so while any is
|
||||||
|
* building, catch each one up with the selection as it becomes ready.
|
||||||
|
*/
|
||||||
|
function watchSelection() {
|
||||||
|
const scene = state.viewer?.scene;
|
||||||
|
if (state.coverage.stopSelectionWatch || !scene?.postRender) return;
|
||||||
|
const stop = scene.postRender.addEventListener(() => {
|
||||||
|
let building = false;
|
||||||
|
let changed = false;
|
||||||
|
for (const entry of drawnEntries()) {
|
||||||
|
for (const record of entry.primitives || []) {
|
||||||
|
if (!record.primitive.ready) building = true;
|
||||||
|
else if (syncSelection(entry, record)) changed = true;
|
||||||
|
}
|
||||||
|
// Lines a swap still shows were ready (or never show): catch up only.
|
||||||
|
for (const record of entry.swap?.old || [])
|
||||||
|
if (syncSelection(entry, record)) changed = true;
|
||||||
|
}
|
||||||
|
if (changed) requestRender();
|
||||||
|
if (!building) stopSelectionWatch();
|
||||||
|
});
|
||||||
|
state.coverage.stopSelectionWatch = stop;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSelectionWatch() {
|
||||||
|
state.coverage.stopSelectionWatch?.();
|
||||||
|
state.coverage.stopSelectionWatch = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sequences (or overview points) drawn. A sequence that crosses a tile
|
||||||
|
* edge is in both tiles' lists, so sequences count once by id.
|
||||||
|
*/
|
||||||
|
function sequenceCount() {
|
||||||
|
const ids = new Set();
|
||||||
|
let points = 0;
|
||||||
|
for (const entry of state.coverage.tiles.values())
|
||||||
|
if (entry.kind === 'sequence')
|
||||||
|
for (const id of entry.sequences.keys()) ids.add(id);
|
||||||
|
else points += entry.count;
|
||||||
|
return ids.size + points;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
attach,
|
||||||
|
detach,
|
||||||
|
refresh,
|
||||||
|
clear,
|
||||||
|
resetErrors,
|
||||||
|
rebuild,
|
||||||
|
setSurface,
|
||||||
|
recolorSequence,
|
||||||
|
sequenceCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,989 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { PbfWriter } from 'pbf';
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One MVT layer writer with deduplicated keys and appended values. */
|
||||||
|
function layerWriter(name, extent) {
|
||||||
|
const keys = new Map();
|
||||||
|
const values = [];
|
||||||
|
const features = [];
|
||||||
|
function tags(properties) {
|
||||||
|
const out = [];
|
||||||
|
for (const [key, value] of Object.entries(properties)) {
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
if (!keys.has(key)) keys.set(key, keys.size);
|
||||||
|
out.push(keys.get(key), values.length);
|
||||||
|
values.push(value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
add(type, geometry, properties, id) {
|
||||||
|
features.push({ type, geometry, tags: tags(properties), id });
|
||||||
|
},
|
||||||
|
write(pbf) {
|
||||||
|
pbf.writeVarintField(15, 2);
|
||||||
|
pbf.writeStringField(1, name);
|
||||||
|
pbf.writeVarintField(5, extent);
|
||||||
|
for (const feature of features)
|
||||||
|
pbf.writeMessage(
|
||||||
|
2,
|
||||||
|
(f, out) => {
|
||||||
|
if (Number.isInteger(f.id)) out.writeVarintField(1, f.id);
|
||||||
|
out.writePackedVarint(2, f.tags);
|
||||||
|
out.writeVarintField(3, f.type);
|
||||||
|
out.writePackedVarint(4, f.geometry);
|
||||||
|
},
|
||||||
|
feature,
|
||||||
|
);
|
||||||
|
for (const key of keys.keys()) pbf.writeStringField(3, key);
|
||||||
|
for (const value of values)
|
||||||
|
pbf.writeMessage(
|
||||||
|
4,
|
||||||
|
(v, out) => {
|
||||||
|
if (typeof v === 'boolean') out.writeBooleanField(7, v);
|
||||||
|
else if (typeof v === 'number') out.writeVarintField(5, v);
|
||||||
|
else out.writeStringField(1, String(v));
|
||||||
|
},
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MVT MoveTo/LineTo commands; the cursor carries over between parts. */
|
||||||
|
function lineGeometry(parts) {
|
||||||
|
const out = [];
|
||||||
|
let cx = 0;
|
||||||
|
let cy = 0;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.length < 2) continue;
|
||||||
|
const [x0, y0] = part[0];
|
||||||
|
out.push(9, zigzag(x0 - cx), zigzag(y0 - cy));
|
||||||
|
cx = x0;
|
||||||
|
cy = y0;
|
||||||
|
out.push(((part.length - 1) << 3) | 2);
|
||||||
|
for (const [x, y] of part.slice(1)) {
|
||||||
|
out.push(zigzag(x - cx), zigzag(y - cy));
|
||||||
|
cx = x;
|
||||||
|
cy = y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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]
|
||||||
|
*/
|
||||||
|
export function encodeCoverageTile(
|
||||||
|
tile,
|
||||||
|
{ sequences = [], overview = [], extent = 4096 } = {},
|
||||||
|
) {
|
||||||
|
const pbf = new PbfWriter();
|
||||||
|
if (sequences.length) {
|
||||||
|
const layer = layerWriter('sequence', extent);
|
||||||
|
for (const sequence of sequences)
|
||||||
|
layer.add(
|
||||||
|
2,
|
||||||
|
lineGeometry(
|
||||||
|
sequence.parts.map((part) =>
|
||||||
|
part.map((point) => lonLatToTileLocal(point, tile, extent)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
id: sequence.id,
|
||||||
|
captured_at: sequence.capturedAt ?? Date.now(),
|
||||||
|
is_pano: sequence.isPano === true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
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());
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { PbfReader } from 'pbf';
|
||||||
|
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.
|
||||||
|
* @param {Uint8Array} bytes
|
||||||
|
* @param {{x:number,y:number,z:number}} address
|
||||||
|
*/
|
||||||
|
export function decodeCoverageTile(bytes, address) {
|
||||||
|
const result = { sequences: [], overview: [] };
|
||||||
|
if (!bytes || !bytes.length) return result;
|
||||||
|
const tile = new VectorTile(new PbfReader(bytes));
|
||||||
|
const { x, y, z } = address;
|
||||||
|
const sequenceLayer = tile.layers.sequence;
|
||||||
|
if (sequenceLayer) {
|
||||||
|
for (let i = 0; i < sequenceLayer.length; i++) {
|
||||||
|
const feature = sequenceLayer.feature(i);
|
||||||
|
const props = feature.properties || {};
|
||||||
|
// A sequence with a capture gap is a multi-line: keep each part as its
|
||||||
|
// own line, so no straight segment is drawn across the gap.
|
||||||
|
const parts = [];
|
||||||
|
for (const line of feature.loadGeometry()) {
|
||||||
|
const part = line.map((point) =>
|
||||||
|
tileLocalToLonLat(point.x, point.y, sequenceLayer.extent, x, y, z),
|
||||||
|
);
|
||||||
|
if (part.length >= 2) parts.push(part);
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { PbfWriter } from 'pbf';
|
||||||
|
import { decodeCoverageTile } from './decode.js';
|
||||||
|
|
||||||
|
const zigzag = (value) => (value << 1) ^ (value >> 31);
|
||||||
|
|
||||||
|
/** One line part: MoveTo the first point, LineTo the rest (relative). */
|
||||||
|
function linePart(points) {
|
||||||
|
const out = [9, zigzag(points[0][0]), zigzag(points[0][1])];
|
||||||
|
out.push(((points.length - 1) << 3) | 2);
|
||||||
|
for (let i = 1; i < points.length; i++)
|
||||||
|
out.push(
|
||||||
|
zigzag(points[i][0] - points[i - 1][0]),
|
||||||
|
zigzag(points[i][1] - points[i - 1][1]),
|
||||||
|
);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A minimal mly1_public-shaped tile: one sequence line and one overview point. */
|
||||||
|
function sampleTile({ geometry = null } = {}) {
|
||||||
|
const writer = new PbfWriter();
|
||||||
|
writer.writeMessage(
|
||||||
|
3,
|
||||||
|
(_, layer) => {
|
||||||
|
layer.writeVarintField(15, 2);
|
||||||
|
layer.writeStringField(1, 'sequence');
|
||||||
|
layer.writeVarintField(5, 4096);
|
||||||
|
for (const key of ['id', 'captured_at', 'is_pano'])
|
||||||
|
layer.writeStringField(3, key);
|
||||||
|
layer.writeMessage(
|
||||||
|
4,
|
||||||
|
(__, value) => value.writeStringField(1, 'seq-42'),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
layer.writeMessage(
|
||||||
|
4,
|
||||||
|
(__, value) => value.writeVarintField(5, 1_700_000_000),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
layer.writeMessage(
|
||||||
|
4,
|
||||||
|
(__, value) => value.writeBooleanField(7, true),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
layer.writeMessage(
|
||||||
|
2,
|
||||||
|
(__, feature) => {
|
||||||
|
feature.writeVarintField(1, 7);
|
||||||
|
feature.writePackedVarint(2, [0, 0, 1, 1, 2, 2]);
|
||||||
|
feature.writeVarintField(3, 2); // LineString
|
||||||
|
feature.writePackedVarint(
|
||||||
|
4,
|
||||||
|
geometry || [
|
||||||
|
9,
|
||||||
|
zigzag(100),
|
||||||
|
zigzag(100), // MoveTo
|
||||||
|
10,
|
||||||
|
zigzag(200),
|
||||||
|
zigzag(200), // LineTo ×1
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
writer.writeMessage(
|
||||||
|
3,
|
||||||
|
(_, layer) => {
|
||||||
|
layer.writeVarintField(15, 2);
|
||||||
|
layer.writeStringField(1, 'overview');
|
||||||
|
layer.writeVarintField(5, 4096);
|
||||||
|
layer.writeStringField(3, 'is_pano');
|
||||||
|
layer.writeMessage(
|
||||||
|
4,
|
||||||
|
(__, value) => value.writeBooleanField(7, false),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
layer.writeMessage(
|
||||||
|
2,
|
||||||
|
(__, feature) => {
|
||||||
|
feature.writeVarintField(1, 3);
|
||||||
|
feature.writePackedVarint(2, [0, 0]);
|
||||||
|
feature.writeVarintField(3, 1); // Point
|
||||||
|
feature.writePackedVarint(4, [9, zigzag(2048), zigzag(2048)]);
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
return new Uint8Array(writer.finish());
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sequences and overview points decode into lon/lat records', () => {
|
||||||
|
const decoded = decodeCoverageTile(sampleTile(), { x: 0, y: 0, z: 1 });
|
||||||
|
assert.equal(decoded.sequences.length, 1);
|
||||||
|
const [sequence] = decoded.sequences;
|
||||||
|
assert.equal(sequence.id, 'seq-42');
|
||||||
|
assert.equal(sequence.capturedAt, 1_700_000_000);
|
||||||
|
assert.equal(sequence.isPano, true);
|
||||||
|
assert.equal(sequence.parts.length, 1);
|
||||||
|
assert.equal(sequence.parts[0].length, 2);
|
||||||
|
const [[lon0, lat0], [lon1, lat1]] = sequence.parts[0];
|
||||||
|
assert.ok(
|
||||||
|
lon0 > -180 && lon0 < 0 && lon1 > lon0,
|
||||||
|
'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');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty tile decodes to empty lists', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
decodeCoverageTile(new Uint8Array(0), { x: 0, y: 0, z: 0 }),
|
||||||
|
{
|
||||||
|
sequences: [],
|
||||||
|
overview: [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a sequence with a capture gap keeps its parts as separate lines', () => {
|
||||||
|
// Two parts with a gap between (300,300) and (1000,1000); the second part's
|
||||||
|
// MoveTo is relative to the end of the first, as in the MVT encoding.
|
||||||
|
const first = [
|
||||||
|
[100, 100],
|
||||||
|
[300, 300],
|
||||||
|
];
|
||||||
|
const second = [
|
||||||
|
[700, 700],
|
||||||
|
[900, 700],
|
||||||
|
];
|
||||||
|
const geometry = [
|
||||||
|
...linePart(first),
|
||||||
|
9,
|
||||||
|
zigzag(second[0][0] - first[1][0]),
|
||||||
|
zigzag(second[0][1] - first[1][1]),
|
||||||
|
((second.length - 1) << 3) | 2,
|
||||||
|
zigzag(second[1][0] - second[0][0]),
|
||||||
|
zigzag(second[1][1] - second[0][1]),
|
||||||
|
];
|
||||||
|
const [sequence] = decodeCoverageTile(sampleTile({ geometry }), {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
z: 1,
|
||||||
|
}).sequences;
|
||||||
|
assert.equal(sequence.parts.length, 2);
|
||||||
|
assert.equal(sequence.parts[0].length, 2);
|
||||||
|
assert.equal(sequence.parts[1].length, 2);
|
||||||
|
// No part ends where the next begins: the gap is not bridged.
|
||||||
|
assert.notDeepEqual(sequence.parts[0].at(-1), sequence.parts[1][0]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { createCoverage, sequenceIdFromPick } from './coverage.js';
|
||||||
|
import { createSequences } from './sequences.js';
|
||||||
|
import { createMapillaryViewer } from './viewer.js';
|
||||||
|
import { passesImageryFilter } from '../../filter.js';
|
||||||
|
import { metresBetween } from '../../view.js';
|
||||||
|
import {
|
||||||
|
COLORS,
|
||||||
|
MAPILLARY_CREDIT_HTML,
|
||||||
|
MAPILLARY_KEY_ID,
|
||||||
|
MAPILLARY_LABEL,
|
||||||
|
MAPILLARY_NAME,
|
||||||
|
MAPILLARY_PROVIDER_ID,
|
||||||
|
MAPILLARY_SOURCE_METHODS,
|
||||||
|
NEAREST_LIMIT,
|
||||||
|
NEAREST_RADIUS_M,
|
||||||
|
PICK_PREFIX,
|
||||||
|
} from './policy.js';
|
||||||
|
|
||||||
|
/** Per-provider mutable state, created once per `create()`. */
|
||||||
|
function createProviderState(context) {
|
||||||
|
return {
|
||||||
|
context,
|
||||||
|
services: context.services,
|
||||||
|
viewer: null,
|
||||||
|
keyRequired: false,
|
||||||
|
/** Coverage waits for the first status: no tile requests without a key. */
|
||||||
|
statusKnown: false,
|
||||||
|
/** Mapillary refused the token: request nothing until re-enabled. */
|
||||||
|
keyRejected: false,
|
||||||
|
status: null,
|
||||||
|
coverage: {
|
||||||
|
zoom: null,
|
||||||
|
/** Current zoom's tiles by `z/x/y` key. */
|
||||||
|
tiles: new Map(),
|
||||||
|
/** Previous zoom's tiles, kept on screen until replacements land. */
|
||||||
|
stale: new Map(),
|
||||||
|
staleTimer: null,
|
||||||
|
/** Tile key → its request's controller; any entry means LOADING. */
|
||||||
|
pending: new Map(),
|
||||||
|
lastError: null,
|
||||||
|
/** While Mapillary rate-limits: no requests before this time (ms). */
|
||||||
|
holdUntil: 0,
|
||||||
|
holdTimer: null,
|
||||||
|
debounceTimer: null,
|
||||||
|
removeCameraListener: null,
|
||||||
|
terrainReady: null,
|
||||||
|
hint: '',
|
||||||
|
kind: null,
|
||||||
|
},
|
||||||
|
sequence: {
|
||||||
|
selectedId: null,
|
||||||
|
images: [],
|
||||||
|
/** Recent sequences' thinned images, by sequence id. */
|
||||||
|
cache: new Map(),
|
||||||
|
collection: null,
|
||||||
|
loading: false,
|
||||||
|
abort: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapillary Street Level provider: tile coverage, Graph API image cones and
|
||||||
|
* the MapillaryJS viewer. `source` is ./source.js or a stand-in.
|
||||||
|
* @returns {import('../../registry.js').StreetLevelProvider}
|
||||||
|
*/
|
||||||
|
export function createMapillaryProvider({ source }) {
|
||||||
|
if (
|
||||||
|
!MAPILLARY_SOURCE_METHODS.every(
|
||||||
|
(method) => typeof source?.[method] === 'function',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new TypeError('A Mapillary source is required');
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
id: MAPILLARY_PROVIDER_ID,
|
||||||
|
name: MAPILLARY_NAME,
|
||||||
|
label: MAPILLARY_LABEL,
|
||||||
|
requiresKeyId: MAPILLARY_KEY_ID,
|
||||||
|
pickPrefix: PICK_PREFIX.root,
|
||||||
|
colors: COLORS,
|
||||||
|
credit: Object.freeze({ html: MAPILLARY_CREDIT_HTML }),
|
||||||
|
|
||||||
|
create(context) {
|
||||||
|
const state = createProviderState(context);
|
||||||
|
const parts = {};
|
||||||
|
parts.coverage = createCoverage({ state, source, parts });
|
||||||
|
parts.sequences = createSequences({ state, source, parts });
|
||||||
|
const viewer = createMapillaryViewer({
|
||||||
|
source,
|
||||||
|
render: context.services?.render,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
async status() {
|
||||||
|
try {
|
||||||
|
state.status = await source.getStatus();
|
||||||
|
state.keyRequired = state.status?.configured !== true;
|
||||||
|
} catch {
|
||||||
|
state.status = null;
|
||||||
|
state.keyRequired = !source.hasToken();
|
||||||
|
}
|
||||||
|
const first = !state.statusKnown;
|
||||||
|
state.statusKnown = true;
|
||||||
|
// Coverage held back until now: draw it if the layer is on.
|
||||||
|
if (first) parts.coverage.refresh();
|
||||||
|
context.notify();
|
||||||
|
return { configured: !state.keyRequired };
|
||||||
|
},
|
||||||
|
|
||||||
|
init(cesiumViewer) {
|
||||||
|
state.viewer = cesiumViewer;
|
||||||
|
parts.sequences.ensureCollections(cesiumViewer);
|
||||||
|
parts.sequences.setVisible(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
activate(cesiumViewer) {
|
||||||
|
state.viewer = cesiumViewer;
|
||||||
|
parts.sequences.setVisible(true);
|
||||||
|
parts.coverage.attach(cesiumViewer);
|
||||||
|
},
|
||||||
|
|
||||||
|
deactivate() {
|
||||||
|
parts.coverage.detach();
|
||||||
|
parts.coverage.clear();
|
||||||
|
parts.coverage.resetErrors();
|
||||||
|
parts.sequences.clearSelection();
|
||||||
|
parts.sequences.setVisible(false);
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy(cesiumViewer) {
|
||||||
|
parts.coverage.detach();
|
||||||
|
parts.coverage.clear();
|
||||||
|
parts.coverage.resetErrors();
|
||||||
|
parts.sequences.destroy(cesiumViewer);
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshCoverage: () => parts.coverage.refresh(),
|
||||||
|
|
||||||
|
/** The filter changed: redraw. Each draw reads it from the context. */
|
||||||
|
setFilter() {
|
||||||
|
parts.coverage.rebuild();
|
||||||
|
parts.sequences.rerender();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
handlePick(pickId) {
|
||||||
|
const sequenceId = sequenceIdFromPick(pickId);
|
||||||
|
if (sequenceId) {
|
||||||
|
parts.sequences.select(sequenceId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (pickId.startsWith(PICK_PREFIX.image)) {
|
||||||
|
context.actions.openImage(pickId.slice(PICK_PREFIX.image.length));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
selectSequence: (sequenceId) => parts.sequences.select(sequenceId),
|
||||||
|
clearSequence: () => parts.sequences.clearSelection(),
|
||||||
|
sequenceStats: () => ({
|
||||||
|
selectedId: state.sequence.selectedId,
|
||||||
|
images: state.sequence.images.length,
|
||||||
|
loading: state.sequence.loading,
|
||||||
|
}),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nearest image that passes the imagery filter, or null. The core
|
||||||
|
* aborts `signal` once a newer request overtakes the lookup.
|
||||||
|
*/
|
||||||
|
async nearestImage({ lat, lon }, { signal } = {}) {
|
||||||
|
const images = await source.nearestImages(
|
||||||
|
{ lat, lon, radius: NEAREST_RADIUS_M, limit: NEAREST_LIMIT },
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
// The API returns the images in the radius in no particular order.
|
||||||
|
const metres = (record) => {
|
||||||
|
const [lon2, lat2] = (record.computed_geometry || record.geometry)
|
||||||
|
?.coordinates || [NaN, NaN];
|
||||||
|
const d = metresBetween({ lon, lat }, { lon: lon2, lat: lat2 });
|
||||||
|
return Number.isFinite(d) ? d : Infinity;
|
||||||
|
};
|
||||||
|
const byDistance = [...images].sort((a, b) => metres(a) - metres(b));
|
||||||
|
const filter = context.getFilter();
|
||||||
|
const hit = byDistance.find((record) =>
|
||||||
|
passesImageryFilter(
|
||||||
|
{
|
||||||
|
isPano: record.is_pano === true,
|
||||||
|
capturedAt: Number(record.captured_at) || 0,
|
||||||
|
},
|
||||||
|
filter,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return hit ? String(hit.id) : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
viewer,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
|
||||||
|
|
||||||
|
export { NEAREST_RADIUS_M } from '../../policy.js';
|
||||||
|
|
||||||
|
/** Identity and tuning for the Mapillary street-level provider. */
|
||||||
|
export const MAPILLARY_PROVIDER_ID = 'mapillary';
|
||||||
|
export const MAPILLARY_NAME = 'Mapillary';
|
||||||
|
export const MAPILLARY_LABEL = 'MAPILLARY';
|
||||||
|
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. */
|
||||||
|
export const MAPILLARY_SOURCE_METHODS = Object.freeze([
|
||||||
|
'getStatus',
|
||||||
|
'getTile',
|
||||||
|
'getSequenceImages',
|
||||||
|
'nearestImages',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Stable id prefixes for picked primitives; every one starts with `mly:`. */
|
||||||
|
export const PICK_PREFIX = Object.freeze({
|
||||||
|
root: 'mly:',
|
||||||
|
sequence: 'mly:seq:',
|
||||||
|
image: 'mly:img:',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Mapillary green for lines, points and cones; GEV cyan for 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 =
|
||||||
|
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
|
||||||
|
/** Shown while Mapillary rate-limits tile requests (429). */
|
||||||
|
export const RATE_LIMITED_MESSAGE =
|
||||||
|
'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;
|
||||||
|
|
||||||
|
/** Per-sequence image cones after a sequence is selected. */
|
||||||
|
export const SEQUENCE_IMAGES_LIMIT = 2000;
|
||||||
|
export const IMAGE_CONE_SIZE_PX = 26;
|
||||||
|
export const IMAGE_CONE_MIN_SPACING_M = 3;
|
||||||
|
|
||||||
|
/** Images asked for in a nearest-image search (radius: NEAREST_RADIUS_M). */
|
||||||
|
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';
|
||||||
|
|
||||||
|
/** Deep link to an image on mapillary.com, as the web app shares them. */
|
||||||
|
export function mapillaryImageUrl(imageId) {
|
||||||
|
const id = String(imageId || '').trim();
|
||||||
|
if (!id) return 'https://www.mapillary.com/app/';
|
||||||
|
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';
|
||||||
|
export const SEQUENCE_IMAGE_FIELDS =
|
||||||
|
'id,captured_at,compass_angle,geometry,is_pano,computed_altitude';
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createMapillaryProvider } from './index.js';
|
||||||
|
import { mapillaryImageUrl } from './policy.js';
|
||||||
|
import { sequenceIdFromPick } from './coverage.js';
|
||||||
|
import { thinImages } from './sequences.js';
|
||||||
|
import { validateProviders } from '../../registry.js';
|
||||||
|
import { PROVIDER_COLORS } from '../../policy.js';
|
||||||
|
import { resolveFilter } from '../../filter.js';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { rayCamera } from '../../../../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
|
function fakeSource({ nearest = [] } = {}) {
|
||||||
|
return {
|
||||||
|
hasToken: () => true,
|
||||||
|
getStatus: async () => ({ configured: true }),
|
||||||
|
getTile: async () => new Uint8Array(0),
|
||||||
|
getSequenceImages: async () => [],
|
||||||
|
nearestImages: async () => nearest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeContext(filter = { pano: 'all', sinceMs: null }) {
|
||||||
|
const opened = [];
|
||||||
|
return {
|
||||||
|
opened,
|
||||||
|
services: {},
|
||||||
|
getFilter: typeof filter === 'function' ? filter : () => filter,
|
||||||
|
isActive: () => true,
|
||||||
|
notify: () => {},
|
||||||
|
actions: {
|
||||||
|
openImage: (id) => {
|
||||||
|
opened.push(id);
|
||||||
|
},
|
||||||
|
reportError: () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the definition satisfies the Street Level provider contract', () => {
|
||||||
|
const def = createMapillaryProvider({ source: fakeSource() });
|
||||||
|
assert.doesNotThrow(() => validateProviders([def]));
|
||||||
|
assert.equal(def.id, 'mapillary');
|
||||||
|
assert.equal(def.label, 'MAPILLARY');
|
||||||
|
assert.equal(def.requiresKeyId, 'mapillary');
|
||||||
|
assert.equal(def.pickPrefix, 'mly:');
|
||||||
|
assert.match(def.credit.html, /CC BY-SA 4\.0/);
|
||||||
|
// One colour per source: Mapillary is green everywhere it draws.
|
||||||
|
assert.deepEqual(Object.keys(def.colors).sort(), ['coverage', 'selected']);
|
||||||
|
assert.equal(def.colors.coverage, PROVIDER_COLORS.mapillary);
|
||||||
|
assert.equal(PROVIDER_COLORS.mapillary, '#05cb63');
|
||||||
|
assert.throws(() => createMapillaryProvider({ source: {} }), /source/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('image deep links match the mapillary.com share format', () => {
|
||||||
|
assert.equal(
|
||||||
|
mapillaryImageUrl(1814275685699406),
|
||||||
|
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
|
||||||
|
);
|
||||||
|
assert.equal(mapillaryImageUrl(' '), 'https://www.mapillary.com/app/');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picks route sequences to selection and images to the core opener', () => {
|
||||||
|
const context = fakeContext();
|
||||||
|
const instance = createMapillaryProvider({ source: fakeSource() }).create(
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
assert.equal(instance.handlePick('mly:img:77'), true);
|
||||||
|
assert.deepEqual(context.opened, ['77']);
|
||||||
|
assert.equal(instance.handlePick('cctv:1'), false);
|
||||||
|
assert.deepEqual(instance.sequenceStats(), {
|
||||||
|
selectedId: null,
|
||||||
|
images: 0,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nearestImage honours the imagery filter and reports key status', async () => {
|
||||||
|
const nearest = [
|
||||||
|
{ id: 1, is_pano: false, captured_at: 10 },
|
||||||
|
{ id: 2, is_pano: true, captured_at: 20 },
|
||||||
|
];
|
||||||
|
const source = fakeSource({ nearest });
|
||||||
|
const flat = createMapillaryProvider({ source }).create(fakeContext());
|
||||||
|
assert.equal(await flat.nearestImage({ lat: 1, lon: 2 }), '1');
|
||||||
|
const pano = createMapillaryProvider({ source }).create(
|
||||||
|
fakeContext({ pano: 'pano', sinceMs: null }),
|
||||||
|
);
|
||||||
|
assert.equal(await pano.nearestImage({ lat: 1, lon: 2 }), '2');
|
||||||
|
const none = createMapillaryProvider({ source }).create(
|
||||||
|
fakeContext({ pano: 'all', sinceMs: 100 }),
|
||||||
|
);
|
||||||
|
assert.equal(await none.nearestImage({ lat: 1, lon: 2 }), null);
|
||||||
|
assert.deepEqual(await flat.status(), { configured: true });
|
||||||
|
assert.equal(flat.coverageStats().keyRequired, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('any part of a multi-part sequence picks the whole sequence', () => {
|
||||||
|
assert.equal(sequenceIdFromPick('mly:seq:abc'), 'abc');
|
||||||
|
assert.equal(sequenceIdFromPick('mly:seq:abc~2'), 'abc');
|
||||||
|
assert.equal(sequenceIdFromPick('mly:img:abc'), null);
|
||||||
|
assert.equal(sequenceIdFromPick(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nearestImage picks the closest image, not the first the API returned', async () => {
|
||||||
|
const at = (id, lon, lat) => ({
|
||||||
|
id,
|
||||||
|
is_pano: false,
|
||||||
|
captured_at: 10,
|
||||||
|
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||||
|
});
|
||||||
|
const source = fakeSource({
|
||||||
|
nearest: [
|
||||||
|
at('far', 2.0004, 1), // ~45 m east
|
||||||
|
at('near', 2.00005, 1), // ~5 m east
|
||||||
|
at('mid', 2, 1.0002), // ~22 m north
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const instance = createMapillaryProvider({ source }).create(fakeContext());
|
||||||
|
assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'near');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a "since N days" window follows the clock in a long-open tab', async (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['Date'], now: Date.UTC(2026, 9, 1) });
|
||||||
|
const day = 86_400_000;
|
||||||
|
const capturedAt = Date.now() - day / 2;
|
||||||
|
const source = fakeSource({
|
||||||
|
nearest: [{ id: 'recent', is_pano: false, captured_at: capturedAt }],
|
||||||
|
});
|
||||||
|
// The core's context resolves the stored filter whenever it is asked.
|
||||||
|
const instance = createMapillaryProvider({ source }).create(
|
||||||
|
fakeContext(() => resolveFilter({ pano: 'all', sinceDays: 1 })),
|
||||||
|
);
|
||||||
|
assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'recent');
|
||||||
|
t.mock.timers.tick(2 * day);
|
||||||
|
assert.equal(
|
||||||
|
await instance.nearestImage({ lat: 1, lon: 2 }),
|
||||||
|
null,
|
||||||
|
'two days on, the image is older than the window',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nearestImage measures the short way round the date line', async () => {
|
||||||
|
const at = (id, lon, lat) => ({
|
||||||
|
id,
|
||||||
|
is_pano: false,
|
||||||
|
captured_at: 10,
|
||||||
|
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||||
|
});
|
||||||
|
const source = fakeSource({
|
||||||
|
nearest: [
|
||||||
|
at('same-side', 179.9995, 0), // ~45 m west, on the camera's side
|
||||||
|
at('across', -179.9999, 0), // ~11 m east, across ±180°
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const instance = createMapillaryProvider({ source }).create(fakeContext());
|
||||||
|
assert.equal(
|
||||||
|
await instance.nearestImage({ lat: 0, lon: 179.9999 }),
|
||||||
|
'across',
|
||||||
|
);
|
||||||
|
// And from the other side.
|
||||||
|
const west = createMapillaryProvider({
|
||||||
|
source: fakeSource({
|
||||||
|
nearest: [at('same-side', -179.9995, 0), at('across', 179.9999, 0)],
|
||||||
|
}),
|
||||||
|
}).create(fakeContext());
|
||||||
|
assert.equal(await west.nearestImage({ lat: 0, lon: -179.9999 }), 'across');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cone thinning across the date line drops images metres apart', () => {
|
||||||
|
const image = (id, lon) => ({ id, lon, lat: 0 });
|
||||||
|
// A sequence driving east over ±180° with images ~1 m apart, then ~11 m on.
|
||||||
|
const kept = thinImages(
|
||||||
|
[
|
||||||
|
image('a', 179.99999),
|
||||||
|
image('b', -179.99999), // ~2 m from a, across the date line
|
||||||
|
image('c', -179.9999), // ~11 m from a
|
||||||
|
],
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
kept.map(({ id }) => id),
|
||||||
|
['a', 'c'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A viewer looking straight down on downtown Sacramento from 900 m. */
|
||||||
|
function streetViewer() {
|
||||||
|
const collection = () => ({
|
||||||
|
add: (item) => item,
|
||||||
|
remove: () => true,
|
||||||
|
contains: () => false,
|
||||||
|
get length() {
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const event = () => ({ addEventListener: () => () => {} });
|
||||||
|
const camera = Object.assign(
|
||||||
|
rayCamera({
|
||||||
|
lon: -121.4944,
|
||||||
|
lat: 38.5816,
|
||||||
|
altitude: 900,
|
||||||
|
pitch: -90,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
}),
|
||||||
|
{ changed: event(), moveEnd: event() },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
camera,
|
||||||
|
scene: {
|
||||||
|
canvas: { clientWidth: 100, clientHeight: 100 },
|
||||||
|
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||||
|
primitives: collection(),
|
||||||
|
groundPrimitives: collection(),
|
||||||
|
preRender: event(),
|
||||||
|
postRender: event(),
|
||||||
|
requestRender() {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('coverage asks for no tiles before the key status is known', async () => {
|
||||||
|
for (const configured of [false, true]) {
|
||||||
|
let answer;
|
||||||
|
const tiles = [];
|
||||||
|
const source = {
|
||||||
|
...fakeSource(),
|
||||||
|
hasToken: () => configured,
|
||||||
|
getStatus: () => new Promise((resolve) => (answer = resolve)),
|
||||||
|
getTile: (...args) => {
|
||||||
|
tiles.push(args);
|
||||||
|
return new Promise(() => {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const instance = createMapillaryProvider({ source }).create(fakeContext());
|
||||||
|
const viewer = streetViewer();
|
||||||
|
instance.init(viewer);
|
||||||
|
const status = instance.status();
|
||||||
|
instance.activate(viewer);
|
||||||
|
assert.equal(tiles.length, 0, 'nothing before the status answers');
|
||||||
|
answer({ configured });
|
||||||
|
await status;
|
||||||
|
if (configured)
|
||||||
|
assert.ok(tiles.length > 0, 'coverage loads as soon as the key is known');
|
||||||
|
else {
|
||||||
|
assert.equal(tiles.length, 0, 'a key-less install never asks');
|
||||||
|
assert.equal(instance.coverageStats().keyRequired, true);
|
||||||
|
}
|
||||||
|
instance.destroy(viewer);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
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';
|
||||||
|
import {
|
||||||
|
COLORS,
|
||||||
|
IMAGE_CONE_MIN_SPACING_M,
|
||||||
|
IMAGE_CONE_SIZE_PX,
|
||||||
|
PICK_PREFIX,
|
||||||
|
} from './policy.js';
|
||||||
|
|
||||||
|
const SPRITE_ID = 'street-level:mapillary-cones';
|
||||||
|
|
||||||
|
/** How many recently viewed sequences keep their image list in memory. */
|
||||||
|
const SEQUENCE_CACHE_SIZE = 40;
|
||||||
|
|
||||||
|
/** 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop images closer than the spacing to the previous kept one. */
|
||||||
|
export function thinImages(images, spacingM = IMAGE_CONE_MIN_SPACING_M) {
|
||||||
|
const kept = [];
|
||||||
|
let last = null;
|
||||||
|
for (const image of images) {
|
||||||
|
if (!last || metresBetween(last, image) >= spacingM) {
|
||||||
|
kept.push(image);
|
||||||
|
last = image;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Image cones for one selected sequence. */
|
||||||
|
export function createSequences({ state, source, parts }) {
|
||||||
|
const { render, sprites } = state.services;
|
||||||
|
|
||||||
|
function requestRender() {
|
||||||
|
render?.governorRequestRender?.('mapillary-sequence');
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
onChange: requestRender,
|
||||||
|
});
|
||||||
|
|
||||||
|
function ensureCollections(viewer) {
|
||||||
|
if (state.sequence.collection) return;
|
||||||
|
state.sequence.collection = new Cesium.BillboardCollection({
|
||||||
|
scene: viewer.scene,
|
||||||
|
});
|
||||||
|
viewer.scene.primitives.add(state.sequence.collection);
|
||||||
|
sprites?.registerSpriteCollection?.(SPRITE_ID, state.sequence.collection);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCones() {
|
||||||
|
state.sequence.collection?.removeAll();
|
||||||
|
state.sequence.images = [];
|
||||||
|
drawn = nothingDrawn();
|
||||||
|
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);
|
||||||
|
// One colour per source; 360° images keep the ring shape.
|
||||||
|
const cone = imageConeGlyph({ size: 32, color: COLORS.coverage });
|
||||||
|
const ring = imageConeGlyph({
|
||||||
|
size: 32,
|
||||||
|
color: COLORS.coverage,
|
||||||
|
pano: true,
|
||||||
|
});
|
||||||
|
// Resolved now, so a "since N days" window keeps up with the clock.
|
||||||
|
const filter = state.context.getFilter();
|
||||||
|
images.forEach((image, index) => {
|
||||||
|
if (!passesImageryFilter(image, filter)) return;
|
||||||
|
const height = heights?.[index] ?? null;
|
||||||
|
const billboard = collection.add({
|
||||||
|
id: `${PICK_PREFIX.image}${image.id}`,
|
||||||
|
...placement(image, height),
|
||||||
|
image: image.isPano ? ring : cone,
|
||||||
|
imageId: image.isPano ? 'mly-cone-pano' : 'mly-cone',
|
||||||
|
width: IMAGE_CONE_SIZE_PX,
|
||||||
|
height: IMAGE_CONE_SIZE_PX,
|
||||||
|
rotation: -Cesium.Math.toRadians(image.compassAngle),
|
||||||
|
alignedAxis: Cesium.Cartesian3.UNIT_Z,
|
||||||
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
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. */
|
||||||
|
async function select(sequenceId) {
|
||||||
|
if (!sequenceId || !state.viewer) return;
|
||||||
|
// Already on screen, or already on its way.
|
||||||
|
if (
|
||||||
|
state.sequence.selectedId === sequenceId &&
|
||||||
|
(state.sequence.images.length || state.sequence.abort)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (state.sequence.selectedId && state.sequence.selectedId !== sequenceId)
|
||||||
|
parts.coverage.recolorSequence(state.sequence.selectedId, false);
|
||||||
|
state.sequence.abort?.abort();
|
||||||
|
state.sequence.selectedId = sequenceId;
|
||||||
|
parts.coverage.recolorSequence(sequenceId, true);
|
||||||
|
const cached = 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;
|
||||||
|
}
|
||||||
|
// The previous sequence's cones must not stay clickable under the new
|
||||||
|
// highlight while this one loads, nor after its load fails.
|
||||||
|
clearCones();
|
||||||
|
requestRender();
|
||||||
|
const controller = new AbortController();
|
||||||
|
state.sequence.abort = controller;
|
||||||
|
state.sequence.loading = true;
|
||||||
|
notify();
|
||||||
|
try {
|
||||||
|
const records = await source.getSequenceImages(sequenceId, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
const images = thinImages(
|
||||||
|
records
|
||||||
|
.map(normalizeSequenceImage)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => a.capturedAt - b.capturedAt),
|
||||||
|
);
|
||||||
|
remember(sequenceId, images);
|
||||||
|
state.sequence.images = images;
|
||||||
|
renderCones(images);
|
||||||
|
state.context.actions.reportError(null);
|
||||||
|
} catch (error) {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
// Nothing to show: drop the highlight, so a click asks again.
|
||||||
|
parts.coverage.recolorSequence(sequenceId, false);
|
||||||
|
state.sequence.selectedId = null;
|
||||||
|
state.context.actions.reportError(
|
||||||
|
error?.message || 'Sequence images unavailable',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (state.sequence.abort === controller) {
|
||||||
|
state.sequence.loading = false;
|
||||||
|
state.sequence.abort = null;
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelection() {
|
||||||
|
state.sequence.abort?.abort();
|
||||||
|
state.sequence.abort = null;
|
||||||
|
if (state.sequence.selectedId)
|
||||||
|
parts.coverage.recolorSequence(state.sequence.selectedId, false);
|
||||||
|
state.sequence.selectedId = null;
|
||||||
|
state.sequence.loading = false;
|
||||||
|
clearCones();
|
||||||
|
// An error about the sequence goes with it.
|
||||||
|
state.context.actions.reportError(null);
|
||||||
|
requestRender();
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-draw the current sequence's cones (after an imagery filter change). */
|
||||||
|
function rerender() {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy(viewer) {
|
||||||
|
state.sequence.abort?.abort();
|
||||||
|
horizon.stop();
|
||||||
|
drawn = nothingDrawn();
|
||||||
|
stopMeshListener?.();
|
||||||
|
const collection = state.sequence.collection;
|
||||||
|
if (collection) {
|
||||||
|
sprites?.unregisterSpriteCollection?.(SPRITE_ID, collection);
|
||||||
|
viewer?.scene?.primitives?.remove(collection);
|
||||||
|
state.sequence.collection = null;
|
||||||
|
}
|
||||||
|
state.sequence.images = [];
|
||||||
|
state.sequence.selectedId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ensureCollections,
|
||||||
|
select,
|
||||||
|
clearSelection,
|
||||||
|
rerender,
|
||||||
|
setVisible,
|
||||||
|
destroy,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
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');
|
||||||
|
});
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import {
|
||||||
|
IMAGE_FIELDS,
|
||||||
|
MAPILLARY_GRAPH_HOST,
|
||||||
|
NEAREST_LIMIT,
|
||||||
|
NEAREST_RADIUS_M,
|
||||||
|
SEQUENCE_IMAGE_FIELDS,
|
||||||
|
SEQUENCE_IMAGES_LIMIT,
|
||||||
|
} from './policy.js';
|
||||||
|
|
||||||
|
/** HTTP error flagged `keyRequired`, `keyRejected` or `retryAfterSec` (429). */
|
||||||
|
export class MapillarySourceError extends Error {
|
||||||
|
constructor(message, { status = 0, payload = null, retryAfterSec } = {}) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'MapillarySourceError';
|
||||||
|
this.status = status;
|
||||||
|
this.payload = payload;
|
||||||
|
this.keyRequired = payload?.keyRequired ?? payload?.error === 'no_key';
|
||||||
|
this.keyRejected = payload?.keyRejected === true;
|
||||||
|
const wait = Number(retryAfterSec ?? payload?.retryAfter);
|
||||||
|
this.retryAfterSec =
|
||||||
|
status === 429 ? (Number.isFinite(wait) && wait > 0 ? wait : 60) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonOrThrow(response, label) {
|
||||||
|
let payload = null;
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch {
|
||||||
|
/* status is authoritative */
|
||||||
|
}
|
||||||
|
if (!response.ok)
|
||||||
|
throw new MapillarySourceError(
|
||||||
|
payload?.error?.message ||
|
||||||
|
payload?.error ||
|
||||||
|
`${label} HTTP ${response.status}`,
|
||||||
|
{ status: response.status, payload },
|
||||||
|
);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapillary network access: tiles via the local proxy, which adds the token
|
||||||
|
* and caches; Graph API lookups direct with the client token, as MapillaryJS.
|
||||||
|
* @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options
|
||||||
|
*/
|
||||||
|
export function createMapillarySource({
|
||||||
|
token = '',
|
||||||
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
|
endpoints = {},
|
||||||
|
} = {}) {
|
||||||
|
const urls = {
|
||||||
|
status: '/api/mapillary/status',
|
||||||
|
tiles: '/api/mapillary/tiles',
|
||||||
|
graph: MAPILLARY_GRAPH_HOST,
|
||||||
|
...endpoints,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function graph(path, params = {}, { signal } = {}) {
|
||||||
|
if (!token)
|
||||||
|
throw new MapillarySourceError('Mapillary token not configured', {
|
||||||
|
status: 503,
|
||||||
|
payload: { keyRequired: true },
|
||||||
|
});
|
||||||
|
const search = new URLSearchParams({ access_token: token, ...params });
|
||||||
|
const response = await fetchImpl(`${urls.graph}/${path}?${search}`, {
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
return readJsonOrThrow(response, 'Mapillary graph');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get token() {
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
hasToken: () => Boolean(token),
|
||||||
|
|
||||||
|
async getStatus({ signal } = {}) {
|
||||||
|
const response = await fetchImpl(urls.status, {
|
||||||
|
signal,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
return readJsonOrThrow(response, 'Mapillary status');
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Raw protobuf bytes for one tile; an empty array means no data. */
|
||||||
|
async getTile(layer, z, x, y, { signal } = {}) {
|
||||||
|
const response = await fetchImpl(
|
||||||
|
`${urls.tiles}/${layer}/${z}/${x}/${y}`,
|
||||||
|
{
|
||||||
|
signal,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.status === 204) return new Uint8Array(0);
|
||||||
|
if (!response.ok) {
|
||||||
|
let payload = null;
|
||||||
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
throw new MapillarySourceError(
|
||||||
|
payload?.error || `Tile HTTP ${response.status}`,
|
||||||
|
{
|
||||||
|
status: response.status,
|
||||||
|
payload,
|
||||||
|
retryAfterSec: response.headers?.get?.('retry-after') ?? undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Uint8Array(await response.arrayBuffer());
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSequenceImages(
|
||||||
|
sequenceId,
|
||||||
|
{ signal, limit = SEQUENCE_IMAGES_LIMIT } = {},
|
||||||
|
) {
|
||||||
|
const payload = await graph(
|
||||||
|
'images',
|
||||||
|
{
|
||||||
|
sequence_ids: String(sequenceId),
|
||||||
|
fields: SEQUENCE_IMAGE_FIELDS,
|
||||||
|
limit: String(limit),
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
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 : [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createMapillarySource, MapillarySourceError } from './source.js';
|
||||||
|
|
||||||
|
function fakeFetch(handler) {
|
||||||
|
const calls = [];
|
||||||
|
const fetchImpl = async (url, init = {}) => {
|
||||||
|
calls.push({ url: String(url), init });
|
||||||
|
return handler(String(url), init);
|
||||||
|
};
|
||||||
|
return { calls, fetchImpl };
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonResponse = (payload, status = 200) =>
|
||||||
|
new Response(JSON.stringify(payload), {
|
||||||
|
status,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tiles go through the local proxy and never carry the token', async () => {
|
||||||
|
const { calls, fetchImpl } = fakeFetch(
|
||||||
|
() => new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
|
||||||
|
);
|
||||||
|
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
|
||||||
|
const bytes = await source.getTile('coverage', 14, 2662, 6285);
|
||||||
|
assert.deepEqual([...bytes], [1, 2, 3]);
|
||||||
|
assert.equal(calls[0].url, '/api/mapillary/tiles/coverage/14/2662/6285');
|
||||||
|
assert.doesNotMatch(calls[0].url, /MLY/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty tile (204) is an empty byte array, not an error', async () => {
|
||||||
|
const { fetchImpl } = fakeFetch(() => new Response(null, { status: 204 }));
|
||||||
|
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
|
||||||
|
assert.equal((await source.getTile('coverage', 14, 1, 1)).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing key on the proxy surfaces as keyRequired', async () => {
|
||||||
|
const { fetchImpl } = fakeFetch(() =>
|
||||||
|
jsonResponse({ error: 'no_key', keyRequired: true }, 503),
|
||||||
|
);
|
||||||
|
const source = createMapillarySource({ token: '', fetchImpl });
|
||||||
|
assert.equal(source.hasToken(), false);
|
||||||
|
await assert.rejects(
|
||||||
|
() => source.getTile('coverage', 14, 1, 1),
|
||||||
|
(error) =>
|
||||||
|
error instanceof MapillarySourceError &&
|
||||||
|
error.status === 503 &&
|
||||||
|
error.keyRequired === true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('graph lookups call graph.mapillary.com with the client token and requested fields', async () => {
|
||||||
|
const { calls, fetchImpl } = fakeFetch(() =>
|
||||||
|
jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }),
|
||||||
|
);
|
||||||
|
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
|
||||||
|
const images = await source.getSequenceImages('seq-1');
|
||||||
|
assert.equal(images.length, 1);
|
||||||
|
const url = new URL(calls[0].url);
|
||||||
|
assert.equal(url.host, 'graph.mapillary.com');
|
||||||
|
assert.equal(url.pathname, '/images');
|
||||||
|
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 () => {
|
||||||
|
const { calls, fetchImpl } = fakeFetch(() => jsonResponse({}));
|
||||||
|
const source = createMapillarySource({ token: '', fetchImpl });
|
||||||
|
await assert.rejects(
|
||||||
|
() => source.getSequenceImages('123'),
|
||||||
|
(error) =>
|
||||||
|
error instanceof MapillarySourceError && error.keyRequired === true,
|
||||||
|
);
|
||||||
|
assert.equal(calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('graph API errors carry the upstream message and status', async () => {
|
||||||
|
const { fetchImpl } = fakeFetch(() =>
|
||||||
|
jsonResponse({ error: { message: 'Invalid OAuth 2.0 Access Token' } }, 400),
|
||||||
|
);
|
||||||
|
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
|
||||||
|
await assert.rejects(
|
||||||
|
() => source.getSequenceImages('999'),
|
||||||
|
(error) =>
|
||||||
|
error instanceof MapillarySourceError &&
|
||||||
|
error.status === 400 &&
|
||||||
|
/Invalid OAuth/.test(error.message),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
fetchImpl: fakeFetch(() =>
|
||||||
|
jsonResponse(
|
||||||
|
{ error: 'Mapillary rejected the access token', keyRejected: true },
|
||||||
|
403,
|
||||||
|
),
|
||||||
|
).fetchImpl,
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() => rejectedSource.getTile('coverage', 14, 1, 1),
|
||||||
|
(error) =>
|
||||||
|
error.keyRejected === true &&
|
||||||
|
error.keyRequired === false &&
|
||||||
|
error.retryAfterSec === null,
|
||||||
|
);
|
||||||
|
const limitedSource = createMapillarySource({
|
||||||
|
token: 'MLY|1|abc',
|
||||||
|
fetchImpl: fakeFetch(
|
||||||
|
() =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({ error: 'rate limited', retryAfter: 45 }),
|
||||||
|
{
|
||||||
|
status: 429,
|
||||||
|
headers: { 'retry-after': '30' },
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).fetchImpl,
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() => limitedSource.getTile('coverage', 14, 1, 1),
|
||||||
|
(error) => error.retryAfterSec === 30 && error.keyRejected === false,
|
||||||
|
'the Retry-After header wins over the body',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { MAPILLARY_PROVIDER_ID, 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}
|
||||||
|
*/
|
||||||
|
export function createMapillaryViewer({ source, render } = {}) {
|
||||||
|
let viewer = null;
|
||||||
|
let Library = null;
|
||||||
|
let container = null;
|
||||||
|
let pendingOpen = null;
|
||||||
|
let prewarming = null;
|
||||||
|
/** In-flight viewer construction, so pre-warm and open never build two. */
|
||||||
|
let creating = null;
|
||||||
|
/** Bumped by `unmount`, so a construction it overtook builds nothing. */
|
||||||
|
let generation = 0;
|
||||||
|
let renderMode = 'letterbox';
|
||||||
|
/** Metadata of the image on screen; pov/position events reuse it. */
|
||||||
|
let current = null;
|
||||||
|
const listeners = new Set();
|
||||||
|
|
||||||
|
function requestRender() {
|
||||||
|
render?.governorRequestRender?.('mapillary-viewer');
|
||||||
|
}
|
||||||
|
|
||||||
|
function libraryRenderMode(mode) {
|
||||||
|
const { RenderMode } = Library || {};
|
||||||
|
if (!RenderMode) return undefined;
|
||||||
|
return mode === 'fill' ? RenderMode.Fill : RenderMode.Letterbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureLibrary() {
|
||||||
|
if (Library) return Library;
|
||||||
|
// Lazy CSS: Vite applies it before the import resolves, so the viewer is
|
||||||
|
// styled from its first frame without ~90 KB of render-blocking CSS.
|
||||||
|
const [library] = await Promise.all([
|
||||||
|
import('mapillary-js'),
|
||||||
|
import('mapillary-js/dist/mapillary.css'),
|
||||||
|
]);
|
||||||
|
Library = library;
|
||||||
|
return Library;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(pose) {
|
||||||
|
for (const listener of [...listeners]) {
|
||||||
|
try {
|
||||||
|
listener(pose);
|
||||||
|
} catch {
|
||||||
|
/* listener errors are the core's to log */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read MapillaryJS image metadata into the neutral pose shape. */
|
||||||
|
function describe(image) {
|
||||||
|
return {
|
||||||
|
imageId: String(image.id),
|
||||||
|
isPano: image.cameraType === 'spherical',
|
||||||
|
capturedAt: image.capturedAt ?? null,
|
||||||
|
sequenceId: image.sequenceId ?? null,
|
||||||
|
altitude: Number.isFinite(image.computedAltitude)
|
||||||
|
? image.computedAltitude
|
||||||
|
: Number.isFinite(image.originalAltitude)
|
||||||
|
? image.originalAltitude
|
||||||
|
: null,
|
||||||
|
creator: image.creatorUsername || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishPose(image) {
|
||||||
|
if (!viewer) return;
|
||||||
|
try {
|
||||||
|
const [lngLat, pov] = await Promise.all([
|
||||||
|
viewer.getPosition(),
|
||||||
|
viewer.getPointOfView(),
|
||||||
|
]);
|
||||||
|
// Closed (or never opened) while the pose was in flight: a late
|
||||||
|
// `image` event must not bring the photo back.
|
||||||
|
if (pendingOpen === null) return;
|
||||||
|
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();
|
||||||
|
} catch {
|
||||||
|
/* viewer torn down mid-flight */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureViewer(host) {
|
||||||
|
if (viewer && container === host) return viewer;
|
||||||
|
if (creating?.container === host) return creating.promise;
|
||||||
|
const promise = createViewer(host).finally(() => {
|
||||||
|
if (creating?.promise === promise) creating = null;
|
||||||
|
});
|
||||||
|
creating = { container: host, promise };
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createViewer(host) {
|
||||||
|
const built = generation;
|
||||||
|
destroyViewer();
|
||||||
|
const { Viewer } = await ensureLibrary();
|
||||||
|
// Unmounted (layer off) while the library loaded: a viewer built now
|
||||||
|
// would hold a WebGL context nobody releases.
|
||||||
|
if (built !== generation) throw new Error('Mapillary viewer was unmounted');
|
||||||
|
viewer = new Viewer({
|
||||||
|
accessToken: source.token,
|
||||||
|
container: host,
|
||||||
|
component: {
|
||||||
|
cover: false,
|
||||||
|
bearing: true,
|
||||||
|
zoom: true,
|
||||||
|
attribution: true,
|
||||||
|
},
|
||||||
|
// The panel's ResizeObserver resizes the viewer, and skips it while it
|
||||||
|
// is hidden (0x0); MapillaryJS's own window tracking would resize a
|
||||||
|
// hidden viewer and ask for a tile at z=NaN.
|
||||||
|
trackResize: false,
|
||||||
|
renderMode: libraryRenderMode(renderMode),
|
||||||
|
});
|
||||||
|
container = host;
|
||||||
|
viewer.on('image', (event) => publishPose(event.image));
|
||||||
|
viewer.on('pov', () => publishPose(null));
|
||||||
|
viewer.on('position', () => publishPose(null));
|
||||||
|
return viewer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyViewer() {
|
||||||
|
if (viewer) {
|
||||||
|
try {
|
||||||
|
viewer.remove();
|
||||||
|
} catch {
|
||||||
|
/* already removed */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
viewer = null;
|
||||||
|
container = null;
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
async mount(host) {
|
||||||
|
if (!host) throw new Error('Street Level viewer has no host element');
|
||||||
|
await ensureViewer(host);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Resolves once the image is on screen and its first pose was emitted. */
|
||||||
|
async open(imageId) {
|
||||||
|
const id = String(imageId);
|
||||||
|
if (!container) throw new Error('Mapillary viewer is not mounted');
|
||||||
|
pendingOpen = id;
|
||||||
|
const instance = await ensureViewer(container);
|
||||||
|
if (pendingOpen !== id) return;
|
||||||
|
const image = await instance.moveTo(id);
|
||||||
|
if (pendingOpen !== id) return;
|
||||||
|
await publishPose(image);
|
||||||
|
},
|
||||||
|
|
||||||
|
close() {
|
||||||
|
pendingOpen = null;
|
||||||
|
current = null;
|
||||||
|
// A playing sequence would keep stepping through (and downloading)
|
||||||
|
// images in the hidden viewer.
|
||||||
|
try {
|
||||||
|
viewer?.getComponent('sequence')?.stop();
|
||||||
|
} catch {
|
||||||
|
/* sequence component not available */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
unmount() {
|
||||||
|
pendingOpen = null;
|
||||||
|
// The next mount builds afresh rather than joining a stale build.
|
||||||
|
generation++;
|
||||||
|
creating = null;
|
||||||
|
destroyViewer();
|
||||||
|
},
|
||||||
|
|
||||||
|
resize() {
|
||||||
|
try {
|
||||||
|
viewer?.resize();
|
||||||
|
} catch {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 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';
|
||||||
|
try {
|
||||||
|
const value = libraryRenderMode(renderMode);
|
||||||
|
if (viewer && value !== undefined) viewer.setRenderMode(value);
|
||||||
|
} catch {
|
||||||
|
/* viewer not ready */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onPose(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* The contract an imagery provider registers with the Street Level layer.
|
||||||
|
*
|
||||||
|
* @typedef {object} StreetLevelProvider
|
||||||
|
* @property {string} id Stable id, e.g. 'mapillary'; also the share-link option key.
|
||||||
|
* @property {string} name Human name for captions and legends, e.g. 'Mapillary'.
|
||||||
|
* @property {string} label Chip text and link label, e.g. 'MAPILLARY'.
|
||||||
|
* @property {string|null} requiresKeyId Key-setup id the provider needs, or null when keyless.
|
||||||
|
* @property {string} pickPrefix Every primitive id the provider creates starts with it.
|
||||||
|
* @property {{coverage: string}} colors The source's one colour, from policy.js PROVIDER_COLORS.
|
||||||
|
* @property {{html: string}} credit On-globe attribution while the provider is active.
|
||||||
|
* @property {(context: ProviderContext) => ProviderInstance} create
|
||||||
|
*
|
||||||
|
* @typedef {object} ProviderContext Handed to `create()` once by the core.
|
||||||
|
* @property {object} services Scene services: picking, input, render, sprites, ground, terrain.
|
||||||
|
* @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) => Promise<void>, reportError: (message: string|null) => void}} actions
|
||||||
|
*
|
||||||
|
* @typedef {object} ProviderInstance
|
||||||
|
* @property {() => Promise<{configured: boolean}>} status
|
||||||
|
* @property {(viewer: object) => void} init Create collections, hidden.
|
||||||
|
* @property {(viewer: object) => void} activate Start drawing coverage for the camera.
|
||||||
|
* @property {() => void} deactivate Detach, clear coverage and selection, hide.
|
||||||
|
* @property {(viewer: object) => void} destroy
|
||||||
|
* @property {() => void} refreshCoverage
|
||||||
|
* @property {(filter: {pano: string, sinceMs: number|null}) => void} setFilter The filter changed: redraw (reading `getFilter()`).
|
||||||
|
* @property {(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 {(pickId: string) => boolean} handlePick The id carries the provider's own prefix.
|
||||||
|
* @property {(sequenceId: string) => Promise<void>} [selectSequence]
|
||||||
|
* @property {() => void} [clearSequence]
|
||||||
|
* @property {() => {selectedId: string|null, images: number, loading: boolean}} [sequenceStats]
|
||||||
|
* @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal}) => Promise<string|null>} nearestImage An aborted lookup gives up (it may reject with an AbortError).
|
||||||
|
* @property {ViewerAdapter} viewer
|
||||||
|
*
|
||||||
|
* @typedef {object} 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 {(listener: (pose: StreetPose) => void) => () => void} onPose
|
||||||
|
*
|
||||||
|
* @typedef {object} StreetPose
|
||||||
|
* @property {string} providerId
|
||||||
|
* @property {string} imageId
|
||||||
|
* @property {{lon: number, lat: number}} position
|
||||||
|
* @property {number|null} bearing
|
||||||
|
* @property {number|null} tilt
|
||||||
|
* @property {number|null} altitude
|
||||||
|
* @property {boolean} isPano
|
||||||
|
* @property {number|null} capturedAt Epoch milliseconds.
|
||||||
|
* @property {string|null} creator
|
||||||
|
* @property {string|null} sequenceId
|
||||||
|
* @property {string} externalUrl Deep link to the image on the provider's site.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const REQUIRED = Object.freeze([
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'label',
|
||||||
|
'pickPrefix',
|
||||||
|
'colors',
|
||||||
|
'credit',
|
||||||
|
'create',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ID_GRAMMAR = /^[a-z][a-z0-9-]*$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate providers and freeze their order, which is also the chip order.
|
||||||
|
* @param {Array<StreetLevelProvider>} providers
|
||||||
|
* @returns {ReadonlyArray<StreetLevelProvider>}
|
||||||
|
*/
|
||||||
|
export function validateProviders(providers) {
|
||||||
|
if (!Array.isArray(providers) || providers.length === 0)
|
||||||
|
throw new TypeError('Street Level needs at least one imagery provider');
|
||||||
|
const ids = new Set();
|
||||||
|
const prefixes = [];
|
||||||
|
for (const provider of providers) {
|
||||||
|
const label = provider?.id ?? '(unnamed)';
|
||||||
|
for (const key of REQUIRED)
|
||||||
|
if (provider?.[key] === undefined || provider?.[key] === null)
|
||||||
|
throw new TypeError(`Street Level provider ${label} lacks ${key}`);
|
||||||
|
if (typeof provider.id !== 'string' || !ID_GRAMMAR.test(provider.id))
|
||||||
|
throw new TypeError(`Street Level provider id ${label} is not a slug`);
|
||||||
|
if (ids.has(provider.id))
|
||||||
|
throw new TypeError(`Duplicate Street Level provider ${provider.id}`);
|
||||||
|
if (typeof provider.create !== 'function')
|
||||||
|
throw new TypeError(
|
||||||
|
`Street Level provider ${label}: create is not a function`,
|
||||||
|
);
|
||||||
|
if (typeof provider.pickPrefix !== 'string' || !provider.pickPrefix)
|
||||||
|
throw new TypeError(`Street Level provider ${label} needs a pick prefix`);
|
||||||
|
for (const other of prefixes)
|
||||||
|
if (
|
||||||
|
other.startsWith(provider.pickPrefix) ||
|
||||||
|
provider.pickPrefix.startsWith(other)
|
||||||
|
)
|
||||||
|
throw new TypeError(
|
||||||
|
`Street Level provider ${label}: pick prefix ${provider.pickPrefix} overlaps ${other}`,
|
||||||
|
);
|
||||||
|
if (!provider.credit?.html)
|
||||||
|
throw new TypeError(`Street Level provider ${label} needs a credit`);
|
||||||
|
if (
|
||||||
|
typeof provider.colors?.coverage !== 'string' ||
|
||||||
|
!provider.colors.coverage
|
||||||
|
)
|
||||||
|
throw new TypeError(
|
||||||
|
`Street Level provider ${label} needs a coverage colour`,
|
||||||
|
);
|
||||||
|
ids.add(provider.id);
|
||||||
|
prefixes.push(provider.pickPrefix);
|
||||||
|
}
|
||||||
|
return Object.freeze([...providers]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key id every provider shares, or null if any is keyless or they differ
|
||||||
|
* (then each chip reports its own need).
|
||||||
|
* @param {ReadonlyArray<StreetLevelProvider>} providers
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function requiresKeyIdFor(providers) {
|
||||||
|
const keys = new Set(providers.map((p) => p.requiresKeyId || null));
|
||||||
|
if (keys.size !== 1) return null;
|
||||||
|
return [...keys][0];
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { requiresKeyIdFor, validateProviders } from './registry.js';
|
||||||
|
|
||||||
|
const def = (overrides = {}) => ({
|
||||||
|
id: 'mapillary',
|
||||||
|
name: 'Mapillary',
|
||||||
|
label: 'MAPILLARY',
|
||||||
|
requiresKeyId: 'mapillary',
|
||||||
|
pickPrefix: 'mly:',
|
||||||
|
colors: { coverage: '#05cb63' },
|
||||||
|
credit: { html: 'Mapillary' },
|
||||||
|
create: () => ({}),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a valid list is frozen in registration order', () => {
|
||||||
|
const list = validateProviders([
|
||||||
|
def(),
|
||||||
|
def({ id: 'panoramax', pickPrefix: 'pnx:', requiresKeyId: null }),
|
||||||
|
]);
|
||||||
|
assert.ok(Object.isFrozen(list));
|
||||||
|
assert.deepEqual(
|
||||||
|
list.map((p) => p.id),
|
||||||
|
['mapillary', 'panoramax'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty list, duplicate ids and malformed ids are rejected', () => {
|
||||||
|
assert.throws(() => validateProviders([]), /at least one/);
|
||||||
|
assert.throws(() => validateProviders(null), /at least one/);
|
||||||
|
assert.throws(() => validateProviders([def(), def()]), /Duplicate/);
|
||||||
|
assert.throws(() => validateProviders([def({ id: 'Bad Id' })]), /slug/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every required field is checked by name', () => {
|
||||||
|
for (const key of [
|
||||||
|
'name',
|
||||||
|
'label',
|
||||||
|
'pickPrefix',
|
||||||
|
'colors',
|
||||||
|
'credit',
|
||||||
|
'create',
|
||||||
|
])
|
||||||
|
assert.throws(
|
||||||
|
() => validateProviders([def({ [key]: undefined })]),
|
||||||
|
new RegExp(`lacks ${key}`),
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
assert.throws(() => validateProviders([def({ create: 1 })]), /create/);
|
||||||
|
assert.throws(() => validateProviders([def({ credit: {} })]), /credit/);
|
||||||
|
assert.throws(
|
||||||
|
() => validateProviders([def({ colors: { pano: '#f0f' } })]),
|
||||||
|
/coverage colour/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateProviders([def({ pickPrefix: '' })]),
|
||||||
|
/pick prefix/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overlapping pick prefixes are rejected either way round', () => {
|
||||||
|
assert.throws(
|
||||||
|
() => validateProviders([def(), def({ id: 'other', pickPrefix: 'mly:x' })]),
|
||||||
|
/overlaps/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateProviders([def({ pickPrefix: 'mly:x' }), def({ id: 'other' })]),
|
||||||
|
/overlaps/,
|
||||||
|
);
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
validateProviders([def(), def({ id: 'other', pickPrefix: 'ml:' })]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requiresKeyIdFor is the shared key, or null once providers differ', () => {
|
||||||
|
assert.equal(requiresKeyIdFor([def()]), 'mapillary');
|
||||||
|
assert.equal(
|
||||||
|
requiresKeyIdFor([def(), def({ id: 'b', pickPrefix: 'b:' })]),
|
||||||
|
'mapillary',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
requiresKeyIdFor([
|
||||||
|
def(),
|
||||||
|
def({ id: 'b', pickPrefix: 'b:', requiresKeyId: null }),
|
||||||
|
]),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
requiresKeyIdFor([
|
||||||
|
def(),
|
||||||
|
def({ id: 'b', pickPrefix: 'b:', requiresKeyId: 'google-maps' }),
|
||||||
|
]),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(requiresKeyIdFor([def({ requiresKeyId: null })]), null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { 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. */
|
||||||
|
export function createSelection({ state, parts }) {
|
||||||
|
const { picking, input } = state.services;
|
||||||
|
|
||||||
|
function ownsPick(pickedId) {
|
||||||
|
return parts.router.ownsPick(pickedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 id = picking?.resolvePickId
|
||||||
|
? picking.resolvePickId(picked)
|
||||||
|
: picked?.id;
|
||||||
|
const route = parts.router.resolve(id);
|
||||||
|
if (!route?.instance) return;
|
||||||
|
const entry = state.providers.get(route.providerId);
|
||||||
|
if (!entry?.on) return;
|
||||||
|
route.instance.handlePick(route.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Esc clears the selected sequence, but only once nothing closer to the
|
||||||
|
* user wants it: an expanded viewer, an open panel or a text field.
|
||||||
|
*/
|
||||||
|
function onKeyDown(event) {
|
||||||
|
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
||||||
|
// A text field keeps its Esc, rich-text editors (contenteditable) included.
|
||||||
|
const target = event.target;
|
||||||
|
if (
|
||||||
|
target?.isContentEditable === true ||
|
||||||
|
target?.closest?.(
|
||||||
|
'.panel-collapsible, [role="dialog"], input, textarea, select',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (!parts.hasSelectedSequence()) return;
|
||||||
|
event.preventDefault();
|
||||||
|
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(
|
||||||
|
viewer.scene.canvas,
|
||||||
|
);
|
||||||
|
state.clickHandler.setInputAction(
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
state.clickHandler = null;
|
||||||
|
document.removeEventListener('keydown', onKeyDown);
|
||||||
|
picking?.unregisterPickOwner?.(STREET_LEVEL_LAYER_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { install, uninstall, ownsPick };
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test, { mock } from 'node:test';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { createSelection } from './selection.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 } = {}) {
|
||||||
|
const saved = {
|
||||||
|
document: globalThis.document,
|
||||||
|
requestAnimationFrame: globalThis.requestAnimationFrame,
|
||||||
|
};
|
||||||
|
mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||||
|
const frames = [];
|
||||||
|
globalThis.requestAnimationFrame = (task) => frames.push(task);
|
||||||
|
globalThis.document = new EventTarget();
|
||||||
|
// The document's keydown listeners, so a test can hand them any target.
|
||||||
|
const keyListeners = new Set();
|
||||||
|
const addListener = globalThis.document.addEventListener.bind(
|
||||||
|
globalThis.document,
|
||||||
|
);
|
||||||
|
const removeListener = globalThis.document.removeEventListener.bind(
|
||||||
|
globalThis.document,
|
||||||
|
);
|
||||||
|
globalThis.document.addEventListener = (type, listener, options) => {
|
||||||
|
if (type === 'keydown') keyListeners.add(listener);
|
||||||
|
addListener(type, listener, options);
|
||||||
|
};
|
||||||
|
globalThis.document.removeEventListener = (type, listener, options) => {
|
||||||
|
if (type === 'keydown') keyListeners.delete(listener);
|
||||||
|
removeListener(type, listener, options);
|
||||||
|
};
|
||||||
|
const sequences = { selected, cleared: 0 };
|
||||||
|
const picks = [];
|
||||||
|
/** What the pointer is over: a line this layer owns, 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,
|
||||||
|
};
|
||||||
|
const state = {
|
||||||
|
viewer,
|
||||||
|
enabled: true,
|
||||||
|
clickHandler: null,
|
||||||
|
services: { picking: null, input: null },
|
||||||
|
};
|
||||||
|
const selection = createSelection({
|
||||||
|
state,
|
||||||
|
parts: {
|
||||||
|
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
|
||||||
|
hasSelectedSequence: () => sequences.selected,
|
||||||
|
clearSequences() {
|
||||||
|
sequences.cleared++;
|
||||||
|
sequences.selected = false;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
selection.install(viewer);
|
||||||
|
const onMove = state.clickHandler.getInputAction(
|
||||||
|
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
sequences,
|
||||||
|
/** A keydown reaching the document from `target`; the event as handled. */
|
||||||
|
key(key, target, { defaultPrevented = false } = {}) {
|
||||||
|
const event = {
|
||||||
|
key,
|
||||||
|
target,
|
||||||
|
defaultPrevented,
|
||||||
|
preventDefault() {
|
||||||
|
this.defaultPrevented = true;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (const listener of [...keyListeners]) listener(event);
|
||||||
|
return event;
|
||||||
|
},
|
||||||
|
scene,
|
||||||
|
canvas,
|
||||||
|
camera,
|
||||||
|
picks,
|
||||||
|
move: (x) => onMove({ endPosition: { x, y: 10 } }),
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('hover picks at most every 120 ms through a burst of moves, ending on the last one', () => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
} finally {
|
||||||
|
h.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An element as Esc sees it: `closest` matches tag names in a selector list;
|
||||||
|
* `isContentEditable` is inherited from an editing host, as in a browser.
|
||||||
|
*/
|
||||||
|
function element(tag, { isContentEditable = false } = {}) {
|
||||||
|
return {
|
||||||
|
tagName: tag.toUpperCase(),
|
||||||
|
isContentEditable,
|
||||||
|
closest(selector) {
|
||||||
|
const names = selector.split(',').map((part) => part.trim());
|
||||||
|
return names.includes(tag) ? this : null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Esc on the globe clears the selected sequence', () => {
|
||||||
|
const h = harness({ selected: true });
|
||||||
|
try {
|
||||||
|
const event = h.key('Escape', element('canvas'));
|
||||||
|
assert.equal(h.sequences.cleared, 1);
|
||||||
|
assert.equal(event.defaultPrevented, true, 'and claims the key');
|
||||||
|
h.key('Escape', element('canvas'));
|
||||||
|
assert.equal(h.sequences.cleared, 1, 'nothing left to clear');
|
||||||
|
} finally {
|
||||||
|
h.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an Esc something else already handled keeps the selection (M60)', () => {
|
||||||
|
const h = harness({ selected: true });
|
||||||
|
try {
|
||||||
|
h.key('Escape', element('canvas'), { defaultPrevented: true });
|
||||||
|
assert.equal(h.sequences.cleared, 0);
|
||||||
|
} finally {
|
||||||
|
h.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Esc in a text field or rich-text editor keeps the selection (M61)', () => {
|
||||||
|
const h = harness({ selected: true });
|
||||||
|
try {
|
||||||
|
for (const tag of ['input', 'textarea', 'select']) {
|
||||||
|
const event = h.key('Escape', element(tag));
|
||||||
|
assert.equal(h.sequences.cleared, 0, `typing in a <${tag}>`);
|
||||||
|
assert.equal(event.defaultPrevented, false, `<${tag}> keeps its Esc`);
|
||||||
|
}
|
||||||
|
// A <div contenteditable> (or anything inside one) is a text field too.
|
||||||
|
h.key('Escape', element('div', { isContentEditable: true }));
|
||||||
|
assert.equal(h.sequences.cleared, 0, 'typing in a contenteditable');
|
||||||
|
} finally {
|
||||||
|
h.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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).
|
||||||
|
*/
|
||||||
|
export function freshStreet() {
|
||||||
|
return {
|
||||||
|
open: false,
|
||||||
|
follow: false,
|
||||||
|
providerId: null,
|
||||||
|
providerName: null,
|
||||||
|
providerLabel: null,
|
||||||
|
imageId: null,
|
||||||
|
position: null,
|
||||||
|
bearing: null,
|
||||||
|
tilt: null,
|
||||||
|
altitude: null,
|
||||||
|
isPano: false,
|
||||||
|
capturedAt: null,
|
||||||
|
sequenceId: null,
|
||||||
|
creator: null,
|
||||||
|
externalUrl: null,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mutable core state, created once per layer instance. */
|
||||||
|
export function createState({ services }) {
|
||||||
|
return {
|
||||||
|
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}>} */
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
|
||||||
|
/** 'terrain' on Google 3D at street zoom (overlays on the bare earth), else 'draped'. */
|
||||||
|
surface: 'draped',
|
||||||
|
|
||||||
|
marker: { collection: null, billboard: null },
|
||||||
|
clickHandler: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/** Web-Mercator tile math, without Cesium, shared by the browser layer and the server executor. */
|
||||||
|
|
||||||
|
const MAX_LAT = 85.05112878;
|
||||||
|
|
||||||
|
function clampLat(lat) {
|
||||||
|
return Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap a longitude, or a difference of two, into [-180, 180]: the short way round. */
|
||||||
|
export function wrapLon(lon) {
|
||||||
|
if (lon >= -180 && lon <= 180) return lon;
|
||||||
|
return ((((lon + 180) % 360) + 360) % 360) - 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fractional tile column of a longitude in [-180, 180] at zoom z. */
|
||||||
|
function tileXAt(lon, z) {
|
||||||
|
return ((lon + 180) / 360) * 2 ** z;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fractional tile row of a latitude at zoom z. */
|
||||||
|
function tileYAt(lat, z) {
|
||||||
|
const rad = (clampLat(lat) * Math.PI) / 180;
|
||||||
|
return (
|
||||||
|
((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * 2 ** z
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lonToTileX(lon, z) {
|
||||||
|
const n = 2 ** z;
|
||||||
|
// 180° is the east edge of the last column, not the west edge of the first.
|
||||||
|
if (lon >= 180) return n - 1;
|
||||||
|
return Math.min(n - 1, Math.max(0, Math.floor(tileXAt(wrapLon(lon), z))));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function latToTileY(lat, z) {
|
||||||
|
const n = 2 ** z;
|
||||||
|
return Math.min(n - 1, Math.max(0, Math.floor(tileYAt(lat, z))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** West longitude of tile column x at zoom z. */
|
||||||
|
function tileXToLon(x, z) {
|
||||||
|
return (x / 2 ** z) * 360 - 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** North latitude of tile row y at zoom z. */
|
||||||
|
function tileYToLat(y, z) {
|
||||||
|
const n = Math.PI - (2 * Math.PI * y) / 2 ** z;
|
||||||
|
return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {{west:number,south:number,east:number,north:number}} degrees */
|
||||||
|
export function tileBounds(x, y, z) {
|
||||||
|
return {
|
||||||
|
west: tileXToLon(x, z),
|
||||||
|
east: tileXToLon(x + 1, z),
|
||||||
|
north: tileYToLat(y, z),
|
||||||
|
south: tileYToLat(y + 1, z),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A [west, south, east, north] array or object 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 (![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));
|
||||||
|
const south = Math.max(-MAX_LAT, Math.min(s, n));
|
||||||
|
const north = Math.min(MAX_LAT, Math.max(s, n));
|
||||||
|
if (east - west <= 0 || north - south <= 0) return null;
|
||||||
|
return { west, south, east, north };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Squared distance (in tiles) from tile (x, y)'s centre to the segment
|
||||||
|
* `from` → `to` at zoom z, measured the short way round the date line.
|
||||||
|
*/
|
||||||
|
function segmentDistance2(tile, from, to, z) {
|
||||||
|
const n = 2 ** z;
|
||||||
|
const px = tile.x + 0.5;
|
||||||
|
const py = tile.y + 0.5;
|
||||||
|
const ax = tileXAt(from.lon, z);
|
||||||
|
const ay = tileYAt(from.lat, z);
|
||||||
|
let bx = to ? tileXAt(to.lon, z) : ax;
|
||||||
|
const by = to ? tileYAt(to.lat, z) : ay;
|
||||||
|
if (bx - ax > n / 2) bx -= n;
|
||||||
|
else if (ax - bx > n / 2) bx += n;
|
||||||
|
let best = Infinity;
|
||||||
|
for (const shift of [-n, 0, n]) {
|
||||||
|
const x = px + shift;
|
||||||
|
const vx = bx - ax;
|
||||||
|
const vy = by - ay;
|
||||||
|
const len2 = vx * vx + vy * vy;
|
||||||
|
const t = len2
|
||||||
|
? Math.max(0, Math.min(1, ((x - ax) * vx + (py - ay) * vy) / len2))
|
||||||
|
: 0;
|
||||||
|
const dx = x - (ax + t * vx);
|
||||||
|
const dy = py - (ay + t * vy);
|
||||||
|
best = Math.min(best, dx * dx + dy * dy);
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiles at zoom z covering a bbox (west > east crosses the date line), from
|
||||||
|
* the centre outwards, capped at `limit`. With `focus`, tiles rank by distance
|
||||||
|
* from the segment `from` (ground under the camera) to `to` (screen centre):
|
||||||
|
* a tilted view's box centre can sit kilometres ahead of both.
|
||||||
|
* @param {{limit?: number, focus?: {from: {lon: number, lat: number}, to?: {lon: number, lat: number}|null}|null}} [options]
|
||||||
|
* @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;
|
||||||
|
};
|
||||||
|
// Each tile's distance is computed once; ties keep their row order.
|
||||||
|
const ranked = tiles
|
||||||
|
.map((tile) => ({ tile, d: distance2(tile) }))
|
||||||
|
.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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tiles at zoom z in a normalized box, row by row, and their range. */
|
||||||
|
function tileGrid(box, z) {
|
||||||
|
const x0 = lonToTileX(box.west, z);
|
||||||
|
const x1 = lonToTileX(box.east, z);
|
||||||
|
const y0 = latToTileY(box.north, z);
|
||||||
|
const y1 = latToTileY(box.south, 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A vector-tile-local coordinate (0..extent) in tile (x, y, z) as [lon, lat]. */
|
||||||
|
export function tileLocalToLonLat(px, py, extent, x, y, z) {
|
||||||
|
const n = 2 ** z;
|
||||||
|
const lon = ((x + px / extent) / n) * 360 - 180;
|
||||||
|
const merc = Math.PI - (2 * Math.PI * (y + py / extent)) / n;
|
||||||
|
const lat =
|
||||||
|
(180 / Math.PI) * Math.atan(0.5 * (Math.exp(merc) - Math.exp(-merc)));
|
||||||
|
return [lon, lat];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sequence-tile zoom for a camera height above ground (m); Mapillary stops at z14. */
|
||||||
|
export function coverageZoomForHeight(heightM) {
|
||||||
|
if (!Number.isFinite(heightM)) return null;
|
||||||
|
if (heightM > 60_000) return null;
|
||||||
|
if (heightM > 12_000) return 11;
|
||||||
|
if (heightM > 5_000) return 12;
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
coverageZoomForHeight,
|
||||||
|
overviewZoomForHeight,
|
||||||
|
latToTileY,
|
||||||
|
lonToTileX,
|
||||||
|
normalizeBbox,
|
||||||
|
tileBounds,
|
||||||
|
tileLocalToLonLat,
|
||||||
|
tilesForBbox,
|
||||||
|
wrapLon,
|
||||||
|
} from './tileMath.js';
|
||||||
|
|
||||||
|
test('lon/lat to tile matches the Sacramento reference tile', () => {
|
||||||
|
assert.equal(lonToTileX(-121.4944, 14), 2662);
|
||||||
|
assert.equal(latToTileY(38.5816, 14), 6286);
|
||||||
|
const bounds = tileBounds(2662, 6286, 14);
|
||||||
|
assert.ok(bounds.west < -121.4944 && -121.4944 < bounds.east);
|
||||||
|
assert.ok(bounds.south < 38.5816 && 38.5816 < bounds.north);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tile-local coordinates round-trip into the tile bounds', () => {
|
||||||
|
const bounds = tileBounds(2662, 6286, 14);
|
||||||
|
const [lon, lat] = tileLocalToLonLat(0, 0, 4096, 2662, 6286, 14);
|
||||||
|
assert.ok(Math.abs(lon - bounds.west) < 1e-9);
|
||||||
|
assert.ok(Math.abs(lat - bounds.north) < 1e-9);
|
||||||
|
const [lon2, lat2] = tileLocalToLonLat(4096, 4096, 4096, 2662, 6286, 14);
|
||||||
|
assert.ok(Math.abs(lon2 - bounds.east) < 1e-9);
|
||||||
|
assert.ok(Math.abs(lat2 - bounds.south) < 1e-9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeBbox orders and clamps coordinates', () => {
|
||||||
|
assert.deepEqual(normalizeBbox([-121.36, 38.69, -121.56, 38.44]), {
|
||||||
|
west: -121.56,
|
||||||
|
south: 38.44,
|
||||||
|
east: -121.36,
|
||||||
|
north: 38.69,
|
||||||
|
});
|
||||||
|
assert.equal(normalizeBbox([0, 0, 0, 1]), null);
|
||||||
|
assert.equal(normalizeBbox('nope'), 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, {
|
||||||
|
limit: 5,
|
||||||
|
});
|
||||||
|
assert.equal(result.total, 160);
|
||||||
|
assert.equal(result.truncated, true);
|
||||||
|
assert.equal(result.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);
|
||||||
|
const full = tilesForBbox([-121.56, 38.44, -121.36, 38.69], 14);
|
||||||
|
assert.equal(full.truncated, false);
|
||||||
|
assert.equal(full.tiles.length, 160);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coverageZoomForHeight steps from coarse to z14 as the camera descends', () => {
|
||||||
|
assert.equal(coverageZoomForHeight(100_000), null);
|
||||||
|
assert.equal(coverageZoomForHeight(30_000), 11);
|
||||||
|
assert.equal(coverageZoomForHeight(8_000), 12);
|
||||||
|
assert.equal(coverageZoomForHeight(3_000), 13);
|
||||||
|
assert.equal(coverageZoomForHeight(500), 14);
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
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 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');
|
||||||
|
assert.ok(
|
||||||
|
[...columns].every((x) => x <= 1 || x >= 254),
|
||||||
|
'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;
|
||||||
|
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', () => {
|
||||||
|
const z = 14;
|
||||||
|
const n = 2 ** z;
|
||||||
|
const bbox = [179.9, 0, -179.9, 0.1];
|
||||||
|
const key = (t) => `${t.x}/${t.y}`;
|
||||||
|
const keys = (list) => new Set(list.map(key));
|
||||||
|
const [north, south] = [latToTileY(0.1, z), latToTileY(0, z)];
|
||||||
|
const plain = tilesForBbox(bbox, z);
|
||||||
|
assert.equal(plain.total, 10 * (south - north + 1), '5 columns either side');
|
||||||
|
// The box centre is the date line itself, not the middle of the map: the
|
||||||
|
// middle two rows of the columns touching it come first, then the ring
|
||||||
|
// around them, all within two columns of the line.
|
||||||
|
const [mid1, mid2] = [(north + south - 1) / 2, (north + south + 1) / 2];
|
||||||
|
assert.deepEqual(
|
||||||
|
keys(plain.tiles.slice(0, 4)),
|
||||||
|
new Set([`${n - 1}/${mid1}`, `${n - 1}/${mid2}`, `0/${mid1}`, `0/${mid2}`]),
|
||||||
|
);
|
||||||
|
for (const tile of plain.tiles.slice(4, 12))
|
||||||
|
assert.ok(
|
||||||
|
[n - 2, n - 1, 0, 1].includes(tile.x),
|
||||||
|
`${key(tile)} near the line`,
|
||||||
|
);
|
||||||
|
// A line of sight across the date line just north of the equator: the two
|
||||||
|
// tiles it crosses come first, then the two in the row below them.
|
||||||
|
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}`]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a focus ranks tiles along the line of sight, not from 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.
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrapLon wraps longitudes and their differences into [-180, 180]', () => {
|
||||||
|
assert.equal(wrapLon(10.0005), 10.0005, 'in range: unchanged');
|
||||||
|
assert.equal(wrapLon(180), 180);
|
||||||
|
assert.equal(wrapLon(-180), -180);
|
||||||
|
assert.ok(Math.abs(wrapLon(180.001) - -179.999) < 1e-9);
|
||||||
|
assert.ok(Math.abs(wrapLon(-359.9998) - 0.0002) < 1e-9);
|
||||||
|
assert.ok(Math.abs(wrapLon(359.9998) - -0.0002) < 1e-9);
|
||||||
|
assert.ok(Math.abs(wrapLon(540.5) - -179.5) < 1e-9);
|
||||||
|
assert.ok(Number.isNaN(wrapLon(NaN)));
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { COLORS } from './policy.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} ProviderSnapshot
|
||||||
|
* @property {string} id
|
||||||
|
* @property {string} name
|
||||||
|
* @property {string} label
|
||||||
|
* @property {boolean} on
|
||||||
|
* @property {boolean|null} configured null until the status call answers
|
||||||
|
* @property {boolean} keyRequired no usable key: missing or rejected
|
||||||
|
* @property {boolean} [keyRejected] the provider refused the configured key
|
||||||
|
* @property {string|null} requiresKeyId
|
||||||
|
* @property {boolean} loading
|
||||||
|
* @property {number} count
|
||||||
|
* @property {string} hint
|
||||||
|
* @property {string|null} error
|
||||||
|
* @property {string} color The source's one colour.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Totals over switched-on providers. Key-gated only when every one lacks its
|
||||||
|
* key; `keyRejected` when that is because a key was refused.
|
||||||
|
* @param {Array<ProviderSnapshot>} providers
|
||||||
|
*/
|
||||||
|
export function summarizeCoverage(providers) {
|
||||||
|
const active = providers.filter((p) => p.on);
|
||||||
|
return {
|
||||||
|
count: active.reduce((sum, p) => sum + (p.count || 0), 0),
|
||||||
|
loading: active.some((p) => p.loading),
|
||||||
|
hint: active.find((p) => p.hint)?.hint || '',
|
||||||
|
error: active.find((p) => p.error)?.error || null,
|
||||||
|
keyRequired:
|
||||||
|
active.length > 0 && active.every((p) => p.keyRequired === true),
|
||||||
|
keyRejected:
|
||||||
|
active.length > 0 &&
|
||||||
|
active.every((p) => p.keyRequired === true) &&
|
||||||
|
active.some((p) => p.keyRejected === true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The panel snapshot from core state and provider snapshots. The legend has
|
||||||
|
* one swatch per active source, then the shared selection colour.
|
||||||
|
* @param {{enabled: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
|
||||||
|
*/
|
||||||
|
export function composeUIState({
|
||||||
|
enabled,
|
||||||
|
filter,
|
||||||
|
providers,
|
||||||
|
street,
|
||||||
|
sequence,
|
||||||
|
surface = 'draped',
|
||||||
|
}) {
|
||||||
|
const active = providers.filter((p) => p.on);
|
||||||
|
const { keyRequired, keyRejected, ...coverage } =
|
||||||
|
summarizeCoverage(providers);
|
||||||
|
const legend = active.map((provider) => ({
|
||||||
|
key: provider.id,
|
||||||
|
label: provider.name,
|
||||||
|
color: provider.color,
|
||||||
|
}));
|
||||||
|
if (active.length)
|
||||||
|
legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected });
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
keyRequired,
|
||||||
|
keyRejected,
|
||||||
|
filter: { ...filter },
|
||||||
|
providers: providers.map((p) => ({ ...p })),
|
||||||
|
coverage,
|
||||||
|
legend,
|
||||||
|
sequence: { ...sequence },
|
||||||
|
street: { ...street },
|
||||||
|
surface,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { composeUIState, summarizeCoverage } from './uiState.js';
|
||||||
|
import { COLORS } from './policy.js';
|
||||||
|
import { providerSnapshot } from '../../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
|
const provider = (overrides = {}) =>
|
||||||
|
providerSnapshot({ count: 10, ...overrides });
|
||||||
|
const base = {
|
||||||
|
enabled: true,
|
||||||
|
filter: { pano: 'all', sinceDays: 0 },
|
||||||
|
street: { open: false },
|
||||||
|
sequence: { selectedId: null, images: 0, loading: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
test('counts add up across active providers only', () => {
|
||||||
|
const ui = composeUIState({
|
||||||
|
...base,
|
||||||
|
providers: [
|
||||||
|
provider(),
|
||||||
|
provider({ id: 'panoramax', name: 'Panoramax', count: 5, on: false }),
|
||||||
|
provider({ id: 'kartaview', name: 'KartaView', count: 7, loading: true }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(ui.coverage.count, 17);
|
||||||
|
assert.equal(ui.coverage.loading, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the layer is key-gated only when every switched-on provider lacks its key', () => {
|
||||||
|
const gated = composeUIState({
|
||||||
|
...base,
|
||||||
|
providers: [provider({ keyRequired: true })],
|
||||||
|
});
|
||||||
|
assert.equal(gated.keyRequired, true);
|
||||||
|
const mixed = composeUIState({
|
||||||
|
...base,
|
||||||
|
providers: [
|
||||||
|
provider({ keyRequired: true }),
|
||||||
|
provider({ id: 'panoramax', name: 'Panoramax', requiresKeyId: null }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(mixed.keyRequired, false, 'a keyless provider still draws');
|
||||||
|
const offOnly = composeUIState({
|
||||||
|
...base,
|
||||||
|
providers: [provider({ keyRequired: true, on: false })],
|
||||||
|
});
|
||||||
|
assert.equal(offOnly.keyRequired, false, 'nothing switched on to gate');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the legend is one swatch per active source, in its colour, then Selected', () => {
|
||||||
|
const single = composeUIState({ ...base, providers: [provider()] });
|
||||||
|
assert.deepEqual(single.legend, [
|
||||||
|
{ key: 'mapillary', label: 'Mapillary', color: '#05cb63' },
|
||||||
|
{ key: 'selected', label: 'Selected', color: COLORS.selected },
|
||||||
|
]);
|
||||||
|
const two = composeUIState({
|
||||||
|
...base,
|
||||||
|
providers: [
|
||||||
|
provider(),
|
||||||
|
provider({ id: 'panoramax', name: 'Panoramax', color: '#a66bff' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
two.legend.map((entry) => [entry.label, entry.color]),
|
||||||
|
[
|
||||||
|
['Mapillary', '#05cb63'],
|
||||||
|
['Panoramax', '#a66bff'],
|
||||||
|
['Selected', COLORS.selected],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const oneOff = composeUIState({
|
||||||
|
...base,
|
||||||
|
providers: [
|
||||||
|
provider(),
|
||||||
|
provider({ id: 'panoramax', name: 'Panoramax', on: false }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
oneOff.legend.map((entry) => entry.key),
|
||||||
|
['mapillary', 'selected'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
composeUIState({ ...base, providers: [provider({ on: false })] }).legend,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hint and error come from the first active provider that has one', () => {
|
||||||
|
const ui = composeUIState({
|
||||||
|
...base,
|
||||||
|
providers: [
|
||||||
|
provider({ hint: '', error: null }),
|
||||||
|
provider({ id: 'b', name: 'B', hint: 'Look down', error: 'boom' }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(ui.coverage.hint, 'Look down');
|
||||||
|
assert.equal(ui.coverage.error, 'boom');
|
||||||
|
assert.deepEqual(ui.filter, base.filter);
|
||||||
|
assert.notEqual(ui.filter, base.filter, 'snapshot copies the filter');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeCoverage is what getStats reports, without building a snapshot', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
summarizeCoverage([
|
||||||
|
provider({ count: 4, hint: 'Look down' }),
|
||||||
|
provider({ id: 'b', name: 'B', count: 6, loading: true, error: 'boom' }),
|
||||||
|
provider({ id: 'c', name: 'C', count: 99, on: false, keyRequired: true }),
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
count: 10,
|
||||||
|
loading: true,
|
||||||
|
hint: 'Look down',
|
||||||
|
error: 'boom',
|
||||||
|
keyRequired: false,
|
||||||
|
keyRejected: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(summarizeCoverage([]).keyRequired, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rejected key gates like a missing one and is named as rejected', () => {
|
||||||
|
const rejected = provider({
|
||||||
|
keyRequired: true,
|
||||||
|
keyRejected: true,
|
||||||
|
error: 'Mapillary rejected MAPILLARY_CLIENT_TOKEN',
|
||||||
|
});
|
||||||
|
const summary = summarizeCoverage([rejected]);
|
||||||
|
assert.equal(summary.keyRequired, true);
|
||||||
|
assert.equal(summary.keyRejected, true);
|
||||||
|
assert.equal(summary.error, 'Mapillary rejected MAPILLARY_CLIENT_TOKEN');
|
||||||
|
const state = composeUIState({ ...base, providers: [rejected] });
|
||||||
|
assert.equal(state.keyRequired, true);
|
||||||
|
assert.equal(state.keyRejected, true);
|
||||||
|
// A provider that is merely missing its key is not "rejected".
|
||||||
|
assert.equal(
|
||||||
|
summarizeCoverage([provider({ keyRequired: true })]).keyRejected,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { wrapLon } from './tileMath.js';
|
||||||
|
|
||||||
|
/** Equirectangular metres between {lon, lat} points, the short way round ±180°; street scale. */
|
||||||
|
export function metresBetween(a, b) {
|
||||||
|
const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180;
|
||||||
|
return Math.hypot(
|
||||||
|
wrapLon(b.lon - a.lon) * 111_320 * Math.cos(lat),
|
||||||
|
(b.lat - a.lat) * 110_540,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `task(deadline)` when idle, within `timeout` ms; without idle callbacks
|
||||||
|
* (Node) it runs a frame later with a null deadline.
|
||||||
|
*/
|
||||||
|
export function whenIdle(task, timeout) {
|
||||||
|
if (typeof globalThis.requestIdleCallback === 'function')
|
||||||
|
globalThis.requestIdleCallback(task, { timeout });
|
||||||
|
else setTimeout(() => task(null), 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ellipsoidal height (m) of the 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]
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Camera height (m) above the ground under it (options as `groundUnderCamera`). */
|
||||||
|
export function cameraHeightAboveGround(viewer, options = {}) {
|
||||||
|
const carto = viewer?.camera?.positionCartographic;
|
||||||
|
if (!carto) return null;
|
||||||
|
return carto.height - (groundUnderCamera(viewer, options) ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ellipsoid `height` metres above `ellipsoid` (the ground at that height). */
|
||||||
|
function raisedEllipsoid(ellipsoid, height) {
|
||||||
|
if (!Number.isFinite(height) || Math.abs(height) < 1) return ellipsoid;
|
||||||
|
const { x, y, z } = ellipsoid.radii;
|
||||||
|
return new Cesium.Ellipsoid(x + height, y + height, z + height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a screen point's ray meets the ground, as a Cartographic, or null.
|
||||||
|
* `maxRange` (metres from the camera) drops hits near the horizon.
|
||||||
|
*/
|
||||||
|
function groundHit(camera, point, ellipsoid, maxRange) {
|
||||||
|
let cartesian = null;
|
||||||
|
try {
|
||||||
|
cartesian = camera.pickEllipsoid(point, ellipsoid);
|
||||||
|
} catch {
|
||||||
|
cartesian = null;
|
||||||
|
}
|
||||||
|
if (!cartesian) return null;
|
||||||
|
if (
|
||||||
|
Number.isFinite(maxRange) &&
|
||||||
|
camera.positionWC &&
|
||||||
|
Cesium.Cartesian3.distance(cartesian, camera.positionWC) > maxRange
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
return Cesium.Cartographic.fromCartesian(cartesian, ellipsoid) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function visibleBbox(
|
||||||
|
viewer,
|
||||||
|
{ grid = 5, groundHeight = null, maxRange = null, nearRange = null } = {},
|
||||||
|
) {
|
||||||
|
const scene = viewer?.scene;
|
||||||
|
const camera = viewer?.camera;
|
||||||
|
if (!scene || !camera) return null;
|
||||||
|
const width = scene.canvas?.clientWidth || scene.canvas?.width || 0;
|
||||||
|
const height = scene.canvas?.clientHeight || scene.canvas?.height || 0;
|
||||||
|
const hits = [];
|
||||||
|
if (width > 0 && height > 0) {
|
||||||
|
const ellipsoid = raisedEllipsoid(
|
||||||
|
scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84,
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
let east = -Infinity;
|
||||||
|
let north = -Infinity;
|
||||||
|
for (const carto of hits) {
|
||||||
|
const lon = Cesium.Math.toDegrees(carto.longitude);
|
||||||
|
const lat = Cesium.Math.toDegrees(carto.latitude);
|
||||||
|
west = Math.min(west, lon);
|
||||||
|
east = Math.max(east, lon);
|
||||||
|
south = Math.min(south, lat);
|
||||||
|
north = Math.max(north, lat);
|
||||||
|
}
|
||||||
|
// Hits on both sides of ±180° read as a box around the whole globe;
|
||||||
|
// measured in 0–360° they are a narrow box across the date line, which
|
||||||
|
// comes back as west > east (the convention tilesForBbox splits).
|
||||||
|
if (east - west > 180) {
|
||||||
|
let west360 = Infinity;
|
||||||
|
let east360 = -Infinity;
|
||||||
|
for (const carto of hits) {
|
||||||
|
const lon = Cesium.Math.toDegrees(carto.longitude);
|
||||||
|
const shifted = lon < 0 ? lon + 360 : lon;
|
||||||
|
west360 = Math.min(west360, shifted);
|
||||||
|
east360 = Math.max(east360, shifted);
|
||||||
|
}
|
||||||
|
if (east360 - west360 < east - west) {
|
||||||
|
west = west360 > 180 ? west360 - 360 : west360;
|
||||||
|
east = east360 > 180 ? east360 - 360 : east360;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
if (!carto) return null;
|
||||||
|
return {
|
||||||
|
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||||
|
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ground the camera looks at, for ranking tiles: `nadir` under the camera and
|
||||||
|
* `ahead` at the screen centre (null on a miss or beyond `maxRange`).
|
||||||
|
*/
|
||||||
|
export function viewFocus(
|
||||||
|
viewer,
|
||||||
|
{ groundHeight = null, maxRange = null } = {},
|
||||||
|
) {
|
||||||
|
const camera = viewer?.camera;
|
||||||
|
const carto = camera?.positionCartographic;
|
||||||
|
if (!carto) return null;
|
||||||
|
const nadir = {
|
||||||
|
lon: Cesium.Math.toDegrees(carto.longitude),
|
||||||
|
lat: Cesium.Math.toDegrees(carto.latitude),
|
||||||
|
};
|
||||||
|
const canvas = viewer.scene?.canvas;
|
||||||
|
const width = canvas?.clientWidth || canvas?.width || 0;
|
||||||
|
const height = canvas?.clientHeight || canvas?.height || 0;
|
||||||
|
let ahead = null;
|
||||||
|
if (width > 0 && height > 0) {
|
||||||
|
const hit = groundHit(
|
||||||
|
camera,
|
||||||
|
new Cesium.Cartesian2(width / 2, height / 2),
|
||||||
|
raisedEllipsoid(
|
||||||
|
viewer.scene?.globe?.ellipsoid || Cesium.Ellipsoid.WGS84,
|
||||||
|
groundHeight,
|
||||||
|
),
|
||||||
|
maxRange,
|
||||||
|
);
|
||||||
|
if (hit)
|
||||||
|
ahead = {
|
||||||
|
lon: Cesium.Math.toDegrees(hit.longitude),
|
||||||
|
lat: Cesium.Math.toDegrees(hit.latitude),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { nadir, ahead };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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_ELLIPSOID = new Cesium.Ellipsoid(
|
||||||
|
Cesium.Ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M,
|
||||||
|
Cesium.Ellipsoid.WGS84.radii.y - HORIZON_CULL_DEPTH_M,
|
||||||
|
Cesium.Ellipsoid.WGS84.radii.z - HORIZON_CULL_DEPTH_M,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide billboards and points behind the horizon: they skip the depth test, so
|
||||||
|
* nothing else stops the far side drawing through the globe. Re-culls on
|
||||||
|
* pre-render after the camera moves; stops when nothing is left to cull.
|
||||||
|
* @param {{getViewer: () => object|null, items: () => Iterable<{position: object, show: boolean}>, onChange?: () => void}} options
|
||||||
|
*/
|
||||||
|
export function createHorizonCull({ getViewer, items, onChange }) {
|
||||||
|
const occluder = new Cesium.EllipsoidalOccluder(HORIZON_CULL_ELLIPSOID);
|
||||||
|
let from = null;
|
||||||
|
let stopListening = null;
|
||||||
|
|
||||||
|
/** Show what is in front of the horizon, hide the rest; the count seen. */
|
||||||
|
function cull(list, position) {
|
||||||
|
occluder.cameraPosition = position;
|
||||||
|
let seen = 0;
|
||||||
|
let changed = false;
|
||||||
|
for (const item of list) {
|
||||||
|
seen++;
|
||||||
|
const show = occluder.isPointVisible(item.position);
|
||||||
|
if (item.show !== show) {
|
||||||
|
item.show = show;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) onChange?.();
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cull everything from the camera at `position`; the count seen. */
|
||||||
|
function cullAll(position) {
|
||||||
|
from = Cesium.Cartesian3.clone(position, from);
|
||||||
|
return cull(items(), position);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPreRender() {
|
||||||
|
const position = getViewer()?.camera?.positionWC;
|
||||||
|
if (!position || (from && Cesium.Cartesian3.equals(from, position))) return;
|
||||||
|
if (!cullAll(position)) stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cull `added` items (or all) now, then all again whenever the camera moves. */
|
||||||
|
function update(added) {
|
||||||
|
const viewer = getViewer();
|
||||||
|
const position = viewer?.camera?.positionWC;
|
||||||
|
if (position && !(added ? cull(added, position) : cullAll(position)))
|
||||||
|
return;
|
||||||
|
stopListening ||=
|
||||||
|
viewer?.scene?.preRender?.addEventListener(onPreRender) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
stopListening?.();
|
||||||
|
stopListening = null;
|
||||||
|
from = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { update, stop };
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import {
|
||||||
|
cameraHeightAboveGround,
|
||||||
|
createHorizonCull,
|
||||||
|
metresBetween,
|
||||||
|
viewCentre,
|
||||||
|
viewFocus,
|
||||||
|
visibleBbox,
|
||||||
|
} from './view.js';
|
||||||
|
import { rayCamera } from '../../testSupport/streetLevelFakes.mjs';
|
||||||
|
|
||||||
|
const RAD = Math.PI / 180;
|
||||||
|
/** A viewer 300 m above a street 1,600 m up (Denver-like). */
|
||||||
|
function viewer({ globeShown, globeHeight }) {
|
||||||
|
return {
|
||||||
|
camera: {
|
||||||
|
positionCartographic: {
|
||||||
|
longitude: -104.99 * RAD,
|
||||||
|
latitude: 39.74 * RAD,
|
||||||
|
height: 1900,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scene: { globe: { show: globeShown, getHeight: () => globeHeight } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a shown globe answers the ground under the camera', () => {
|
||||||
|
assert.equal(
|
||||||
|
cameraHeightAboveGround(viewer({ globeShown: true, globeHeight: 1600 })),
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
// 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]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A camera whose screen rays land on a grid of lon/lat points. */
|
||||||
|
function gridViewer(lons, lats) {
|
||||||
|
const points = [];
|
||||||
|
for (const lat of lats)
|
||||||
|
for (const lon of lons)
|
||||||
|
points.push(Cesium.Cartesian3.fromDegrees(lon, lat));
|
||||||
|
let next = 0;
|
||||||
|
return {
|
||||||
|
scene: { canvas: { clientWidth: 100, clientHeight: 100 }, globe: {} },
|
||||||
|
camera: {
|
||||||
|
pickEllipsoid: () => points[next++ % points.length],
|
||||||
|
positionCartographic: { longitude: 0, latitude: 0, height: 1000 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a view across the date line is a narrow box with west > east', () => {
|
||||||
|
const viewer = gridViewer([178, 179, -179, -178], [-1, 0, 1]);
|
||||||
|
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. */
|
||||||
|
function pinhole({ lon, lat, ground, agl, pitch, w = 1600, h = 900 }) {
|
||||||
|
return {
|
||||||
|
scene: {
|
||||||
|
canvas: { clientWidth: w, clientHeight: h },
|
||||||
|
globe: { show: false, ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||||
|
},
|
||||||
|
camera: rayCamera({
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
altitude: ground + agl,
|
||||||
|
pitch,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a tilted street view over high ground boxes the streets it looks at, not the horizon', () => {
|
||||||
|
// Denver, 600 m above a street 1,610 m up, looking 20° down: the centre of
|
||||||
|
// the screen meets the street about 1.65 km north of the camera.
|
||||||
|
const view = pinhole({
|
||||||
|
lon: -104.99,
|
||||||
|
lat: 39.74,
|
||||||
|
ground: 1610,
|
||||||
|
agl: 600,
|
||||||
|
pitch: -20,
|
||||||
|
});
|
||||||
|
const ahead = 39.74 + 1648 / 111_000;
|
||||||
|
const unranged = visibleBbox(view);
|
||||||
|
// Without options, rays to the bare ellipsoid travel 1.6 km further down:
|
||||||
|
// 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 [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(
|
||||||
|
Math.abs(focus.ahead.lat - ahead) < 0.002,
|
||||||
|
'centre ray on the street',
|
||||||
|
);
|
||||||
|
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', () => {
|
||||||
|
const view = pinhole({
|
||||||
|
lon: -121.4944,
|
||||||
|
lat: 38.5816,
|
||||||
|
ground: 10,
|
||||||
|
agl: 2,
|
||||||
|
pitch: -5,
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('metresBetween measures the short way round the date line', () => {
|
||||||
|
const east = { lon: 179.9999, lat: 0 };
|
||||||
|
const west = { lon: -179.9999, lat: 0 };
|
||||||
|
// 0.0002° of longitude at the equator, not 40,000 km round the other way.
|
||||||
|
assert.ok(Math.abs(metresBetween(east, west) - 22.264) < 1e-6);
|
||||||
|
assert.ok(Math.abs(metresBetween(west, east) - 22.264) < 1e-6);
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(
|
||||||
|
metresBetween({ lon: 180, lat: 0 }, { lon: -180, lat: 0.0001 }) - 11.054,
|
||||||
|
) < 1e-6,
|
||||||
|
'the two names of the same meridian',
|
||||||
|
);
|
||||||
|
// Away from the date line nothing changes.
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(
|
||||||
|
metresBetween({ lon: 10, lat: 0 }, { lon: 10.001, lat: 0 }) - 111.32,
|
||||||
|
) < 1e-9,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A horizon cull over `items` for a camera the test moves, with the frame's
|
||||||
|
* pre-render listeners and a count of the points tested against the horizon.
|
||||||
|
*/
|
||||||
|
function cullHarness(t, items) {
|
||||||
|
const visible = t.mock.method(
|
||||||
|
Cesium.EllipsoidalOccluder.prototype,
|
||||||
|
'isPointVisible',
|
||||||
|
);
|
||||||
|
const preRender = new Set();
|
||||||
|
const camera = { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 1e7) };
|
||||||
|
const viewer = {
|
||||||
|
camera,
|
||||||
|
scene: {
|
||||||
|
preRender: {
|
||||||
|
addEventListener(listener) {
|
||||||
|
preRender.add(listener);
|
||||||
|
return () => preRender.delete(listener);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let changes = 0;
|
||||||
|
const cull = createHorizonCull({
|
||||||
|
getViewer: () => viewer,
|
||||||
|
items: () => items,
|
||||||
|
onChange: () => changes++,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
cull,
|
||||||
|
preRender,
|
||||||
|
tested: () => visible.mock.callCount(),
|
||||||
|
changes: () => changes,
|
||||||
|
frame: () => {
|
||||||
|
for (const listener of [...preRender]) listener();
|
||||||
|
},
|
||||||
|
// Moved in place, as Cesium updates positionWC.
|
||||||
|
moveTo: (lon, lat) =>
|
||||||
|
Cesium.Cartesian3.fromDegrees(
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
1e7,
|
||||||
|
undefined,
|
||||||
|
camera.positionWC,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const point = (lon, lat) => ({
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(lon, lat),
|
||||||
|
show: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the horizon cull does no work while the camera stands still', (t) => {
|
||||||
|
const near = point(0, 0);
|
||||||
|
const far = point(180, 0);
|
||||||
|
const h = cullHarness(t, [near, far]);
|
||||||
|
h.cull.update();
|
||||||
|
assert.equal(h.tested(), 2);
|
||||||
|
assert.deepEqual([near.show, far.show], [true, false]);
|
||||||
|
assert.equal(h.preRender.size, 1, 'listening for camera moves');
|
||||||
|
for (let i = 0; i < 5; i++) h.frame();
|
||||||
|
assert.equal(h.tested(), 2, 'a still camera re-culls nothing');
|
||||||
|
h.moveTo(180, 0);
|
||||||
|
h.frame();
|
||||||
|
assert.equal(h.tested(), 4, 'a moved camera re-culls everything once');
|
||||||
|
assert.deepEqual([near.show, far.show], [false, true]);
|
||||||
|
h.frame();
|
||||||
|
h.frame();
|
||||||
|
assert.equal(h.tested(), 4, 'and then rests again');
|
||||||
|
h.cull.stop();
|
||||||
|
assert.equal(h.preRender.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the horizon cull stops listening once nothing is left, and starts again for new items', (t) => {
|
||||||
|
const items = [point(0, 0)];
|
||||||
|
const h = cullHarness(t, items);
|
||||||
|
h.cull.update();
|
||||||
|
assert.equal(h.preRender.size, 1);
|
||||||
|
// Every item goes (the tiles are dropped); the next camera move finds none.
|
||||||
|
items.length = 0;
|
||||||
|
h.moveTo(10, 0);
|
||||||
|
h.frame();
|
||||||
|
assert.equal(h.preRender.size, 0, 'the listener is removed');
|
||||||
|
// New items arrive: they are culled now, and camera moves are watched again.
|
||||||
|
const far = point(-170, 0);
|
||||||
|
items.push(far);
|
||||||
|
h.cull.update([far]);
|
||||||
|
assert.equal(far.show, false, 'culled on arrival');
|
||||||
|
assert.equal(h.preRender.size, 1, 'the listener is back');
|
||||||
|
h.moveTo(-170, 0);
|
||||||
|
h.frame();
|
||||||
|
assert.equal(far.show, true, 're-culled when the camera moves');
|
||||||
|
assert.equal(h.changes(), 2, 'a redraw for each change');
|
||||||
|
h.cull.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A cull over `points` ([lon, lat, height]) seen from a camera at `camera`. */
|
||||||
|
function cullFrom(camera, points) {
|
||||||
|
const items = points.map(([lon, lat, height]) => ({
|
||||||
|
position: Cesium.Cartesian3.fromDegrees(lon, lat, height),
|
||||||
|
show: true,
|
||||||
|
}));
|
||||||
|
const horizon = createHorizonCull({
|
||||||
|
getViewer: () => ({
|
||||||
|
camera: { positionWC: Cesium.Cartesian3.fromDegrees(...camera) },
|
||||||
|
scene: { preRender: { addEventListener: () => () => {} } },
|
||||||
|
}),
|
||||||
|
items: () => items,
|
||||||
|
});
|
||||||
|
horizon.update();
|
||||||
|
horizon.stop();
|
||||||
|
return items.map((item) => item.show);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
assert.deepEqual(
|
||||||
|
cullFrom([-74.006, 40.7128, -19.6], [[-74.006, 40.71289, -22]]),
|
||||||
|
[true],
|
||||||
|
);
|
||||||
|
// Colombo framing view: camera at -21 m, cones at -95 m about 80 m away.
|
||||||
|
assert.deepEqual(
|
||||||
|
cullFrom(
|
||||||
|
[79.8612, 6.9271, -21],
|
||||||
|
[
|
||||||
|
[79.8612, 6.92782, -95],
|
||||||
|
[79.86192, 6.9271, -95],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
[true, true],
|
||||||
|
);
|
||||||
|
// Austin, ground well above the ellipsoid.
|
||||||
|
assert.deepEqual(
|
||||||
|
cullFrom([-97.7431, 30.2672, 132], [[-97.7431, 30.2681, 130]]),
|
||||||
|
[true],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the horizon cull still hides the far side of the Earth from orbit', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
cullFrom(
|
||||||
|
[0, 0, 20_000_000],
|
||||||
|
[
|
||||||
|
[10, 10, 0],
|
||||||
|
[180, 0, 0],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
[true, false],
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
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.
|
||||||
|
*/
|
||||||
|
export function createViewerHost({ state, parts }) {
|
||||||
|
/** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */
|
||||||
|
let active = null;
|
||||||
|
/** @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;
|
||||||
|
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,
|
||||||
|
sequenceId: pose.sequenceId ?? null,
|
||||||
|
creator: pose.creator || null,
|
||||||
|
externalUrl: pose.externalUrl || null,
|
||||||
|
});
|
||||||
|
parts.marker.set(state.street.position, state.street.bearing);
|
||||||
|
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)
|
||||||
|
selectCurrentSequence();
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCurrentSequence() {
|
||||||
|
const { sequenceId, providerId } = state.street;
|
||||||
|
const entry = providerEntry(providerId);
|
||||||
|
if (!sequenceId || !entry?.instance.selectSequence || !state.enabled)
|
||||||
|
return;
|
||||||
|
const current = entry.instance.sequenceStats?.()?.selectedId;
|
||||||
|
if (current !== sequenceId) entry.instance.selectSequence(sequenceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
function mount(entry) {
|
||||||
|
if (active?.id === entry.def.id) return Promise.resolve(active.adapter);
|
||||||
|
if (mounting?.id === entry.def.id) return mounting.promise;
|
||||||
|
if (active) {
|
||||||
|
active.adapter.close();
|
||||||
|
active.unsubscribe();
|
||||||
|
active.adapter.unmount();
|
||||||
|
active = null;
|
||||||
|
}
|
||||||
|
const adapter = entry.instance.viewer;
|
||||||
|
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();
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open an image: true once its first pose is in, false if it failed or was overtaken. */
|
||||||
|
async function open(providerId, imageId, { frame = true } = {}) {
|
||||||
|
const entry = providerEntry(providerId);
|
||||||
|
if (!entry || !imageId) return false;
|
||||||
|
if (!state.street.host) {
|
||||||
|
// A nearest-image lookup may have set it; nothing is loading now.
|
||||||
|
state.street.loading = false;
|
||||||
|
state.street.error = 'Open the Street Level panel to view imagery';
|
||||||
|
notify();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
Object.assign(state.street, {
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
open: true,
|
||||||
|
providerId,
|
||||||
|
providerName: entry.def.name,
|
||||||
|
providerLabel: entry.def.label,
|
||||||
|
});
|
||||||
|
notify();
|
||||||
|
try {
|
||||||
|
const adapter = await mount(entry);
|
||||||
|
if (!current()) return false;
|
||||||
|
await adapter.open(String(imageId));
|
||||||
|
if (!current()) return false;
|
||||||
|
if (ticket && !state.street.follow) parts.follow.lookAtPosition(ticket);
|
||||||
|
} catch (error) {
|
||||||
|
if (current())
|
||||||
|
state.street.error = error?.message || 'Image could not be opened';
|
||||||
|
} finally {
|
||||||
|
if (current()) state.street.loading = false;
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
if (!current() || state.street.error) return false;
|
||||||
|
selectCurrentSequence();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the image and stop any framing flight; the adapter stays warm. */
|
||||||
|
function close() {
|
||||||
|
openSeq++;
|
||||||
|
parts.follow.cancelFraming();
|
||||||
|
active?.adapter.close();
|
||||||
|
Object.assign(state.street, freshStreet());
|
||||||
|
parts.marker.clear();
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function attach(element) {
|
||||||
|
state.street.host = element || null;
|
||||||
|
if (element && state.street.open) resize();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize() {
|
||||||
|
try {
|
||||||
|
active?.adapter.resize();
|
||||||
|
} catch {
|
||||||
|
/* no-op */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRenderMode(mode) {
|
||||||
|
state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox';
|
||||||
|
try {
|
||||||
|
active?.adapter.setRenderMode?.(state.street.renderMode);
|
||||||
|
} catch {
|
||||||
|
/* adapter not ready */
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stand active providers' viewers up ahead of the first image. */
|
||||||
|
async function prewarm(entries) {
|
||||||
|
const host = state.street.host;
|
||||||
|
if (!host || !state.enabled) return;
|
||||||
|
for (const entry of entries) {
|
||||||
|
const adapter = entry.instance.viewer;
|
||||||
|
if (!adapter.prewarm || !entry.on) continue;
|
||||||
|
try {
|
||||||
|
await adapter.prewarm(host);
|
||||||
|
} catch {
|
||||||
|
/* the real open reports errors */
|
||||||
|
}
|
||||||
|
if (active?.id === entry.def.id) continue;
|
||||||
|
// Switched off while it loaded: release it now rather than keep it.
|
||||||
|
if (!state.enabled || !entry.on) adapter.unmount();
|
||||||
|
else warmed.set(entry.def.id, adapter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tear down every viewer, or only `providerId`'s (active or prewarmed). */
|
||||||
|
function unmount(providerId) {
|
||||||
|
if (
|
||||||
|
!providerId ||
|
||||||
|
active?.id === providerId ||
|
||||||
|
mounting?.id === providerId
|
||||||
|
) {
|
||||||
|
close();
|
||||||
|
mounting = null;
|
||||||
|
if (active) {
|
||||||
|
active.unsubscribe();
|
||||||
|
active.adapter.unmount();
|
||||||
|
active = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [id, adapter] of warmed) {
|
||||||
|
if (providerId && id !== providerId) continue;
|
||||||
|
warmed.delete(id);
|
||||||
|
adapter.unmount();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
attach,
|
||||||
|
open,
|
||||||
|
close,
|
||||||
|
resize,
|
||||||
|
setRenderMode,
|
||||||
|
prewarm,
|
||||||
|
unmount,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { registerHooks } from 'node:module';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { createViewerHost } from './viewerHost.js';
|
||||||
|
|
||||||
|
// MapillaryJS needs a browser; the adapter tests swap in a stand-in library.
|
||||||
|
// Every import gets a fresh copy that waits for the current test's gate, so
|
||||||
|
// each test controls when its "download" finishes.
|
||||||
|
let libraryImports = 0;
|
||||||
|
registerHooks({
|
||||||
|
resolve(specifier, context, next) {
|
||||||
|
if (!specifier.startsWith('mapillary-js')) return next(specifier, context);
|
||||||
|
const kind = specifier === 'mapillary-js' ? 'library' : 'asset';
|
||||||
|
return {
|
||||||
|
url: `gev-test-stub:mapillary-js/${kind}?${++libraryImports}`,
|
||||||
|
shortCircuit: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
load(url, context, next) {
|
||||||
|
if (!url.startsWith('gev-test-stub:mapillary-js/'))
|
||||||
|
return next(url, context);
|
||||||
|
const source = url.includes('/library?')
|
||||||
|
? 'const lib = await globalThis.__gevMapillaryLibrary();\n' +
|
||||||
|
'export const Viewer = lib.Viewer;\n' +
|
||||||
|
'export const RenderMode = lib.RenderMode;\n'
|
||||||
|
: 'export default {};\n';
|
||||||
|
return { format: 'module', shortCircuit: true, source };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { createMapillaryViewer } =
|
||||||
|
await import('./providers/mapillary/viewer.js');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
function harness(adapter) {
|
||||||
|
const state = {
|
||||||
|
enabled: true,
|
||||||
|
notify() {},
|
||||||
|
providers: new Map(),
|
||||||
|
street: { host: {}, renderMode: 'letterbox', follow: false },
|
||||||
|
};
|
||||||
|
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++,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
framing,
|
||||||
|
marker,
|
||||||
|
followed,
|
||||||
|
selected,
|
||||||
|
/** Esc on the map: the provider drops its selected sequence. */
|
||||||
|
clearSequence() {
|
||||||
|
selectedId = null;
|
||||||
|
},
|
||||||
|
host: createViewerHost({ state, parts }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stand-in adapter. Like the real one, concurrent mounts share one
|
||||||
|
* construction (`gate` holds it open) and `unmount` destroys the viewer that
|
||||||
|
* every later `open` needs.
|
||||||
|
*/
|
||||||
|
function fakeAdapter({ failMounts = 0, gate = null, openGate = null } = {}) {
|
||||||
|
const calls = { mount: 0, open: [], unmount: 0, listeners: 0 };
|
||||||
|
let emit = null;
|
||||||
|
let mounted = false;
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
async mount() {
|
||||||
|
calls.mount++;
|
||||||
|
const attempt = calls.mount;
|
||||||
|
await gate?.promise;
|
||||||
|
if (attempt <= failMounts) throw new Error('library failed to load');
|
||||||
|
mounted = true;
|
||||||
|
},
|
||||||
|
/** 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 },
|
||||||
|
bearing: 90,
|
||||||
|
isPano: false,
|
||||||
|
externalUrl: `https://example.test/${id}`,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/** Like MapillaryJS: the image event comes first, then `moveTo` settles. */
|
||||||
|
async open(id) {
|
||||||
|
if (!mounted) throw new Error('viewer is not mounted');
|
||||||
|
calls.open.push(id);
|
||||||
|
this.emitPose(id);
|
||||||
|
await openGate?.promise;
|
||||||
|
},
|
||||||
|
close() {},
|
||||||
|
unmount() {
|
||||||
|
calls.unmount++;
|
||||||
|
mounted = false;
|
||||||
|
},
|
||||||
|
resize() {},
|
||||||
|
onPose(listener) {
|
||||||
|
calls.listeners++;
|
||||||
|
emit = listener;
|
||||||
|
return () => {
|
||||||
|
calls.listeners--;
|
||||||
|
emit = null;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a failed mount is retried on the next open instead of being cached', async () => {
|
||||||
|
const adapter = fakeAdapter({ failMounts: 1 });
|
||||||
|
const { state, host } = harness(adapter);
|
||||||
|
await host.open('mapillary', 'a');
|
||||||
|
assert.equal(state.street.error, 'library failed to load');
|
||||||
|
assert.equal(adapter.calls.listeners, 0, 'the pose listener was released');
|
||||||
|
await host.open('mapillary', 'b');
|
||||||
|
assert.equal(state.street.error, null);
|
||||||
|
assert.equal(adapter.calls.mount, 2);
|
||||||
|
assert.deepEqual(adapter.calls.open, ['b']);
|
||||||
|
assert.equal(state.street.imageId, 'b');
|
||||||
|
assert.equal(state.street.externalUrl, 'https://example.test/b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent opens share one mount and one pose listener', async () => {
|
||||||
|
const adapter = fakeAdapter();
|
||||||
|
const { host } = harness(adapter);
|
||||||
|
await Promise.all([host.open('mapillary', 'a'), host.open('mapillary', 'b')]);
|
||||||
|
assert.equal(adapter.calls.mount, 1);
|
||||||
|
assert.equal(adapter.calls.listeners, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unmounting while a mount is in flight does not leave it active', async () => {
|
||||||
|
const adapter = fakeAdapter();
|
||||||
|
const { state, host } = harness(adapter);
|
||||||
|
const opening = host.open('mapillary', 'a');
|
||||||
|
host.unmount();
|
||||||
|
await opening;
|
||||||
|
assert.equal(adapter.calls.listeners, 0);
|
||||||
|
assert.equal(adapter.calls.unmount, 1);
|
||||||
|
assert.equal(state.street.open, false);
|
||||||
|
// Not left active: the next open mounts again.
|
||||||
|
await host.open('mapillary', 'b');
|
||||||
|
assert.equal(adapter.calls.mount, 2);
|
||||||
|
assert.equal(state.street.imageId, 'b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a prewarmed viewer is released when the layer goes off, or at once if it went off mid-load', async () => {
|
||||||
|
const adapter = fakeAdapter();
|
||||||
|
let release = null;
|
||||||
|
adapter.prewarm = () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const { state, host } = harness(adapter);
|
||||||
|
const entry = state.providers.get('mapillary');
|
||||||
|
entry.on = true;
|
||||||
|
|
||||||
|
// Warmed, then the layer is switched off: unmount releases the warm viewer.
|
||||||
|
const warming = host.prewarm([entry]);
|
||||||
|
release();
|
||||||
|
await warming;
|
||||||
|
assert.equal(adapter.calls.unmount, 0);
|
||||||
|
host.unmount();
|
||||||
|
assert.equal(adapter.calls.unmount, 1, 'the WebGL viewer is destroyed');
|
||||||
|
|
||||||
|
// Switched off while the library was still loading: released on arrival.
|
||||||
|
const late = host.prewarm([entry]);
|
||||||
|
state.enabled = false;
|
||||||
|
release();
|
||||||
|
await late;
|
||||||
|
assert.equal(adapter.calls.unmount, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('switching one provider off releases only its viewer', async () => {
|
||||||
|
const adapter = fakeAdapter();
|
||||||
|
adapter.prewarm = async () => {};
|
||||||
|
const { state, host } = harness(adapter);
|
||||||
|
const entry = state.providers.get('mapillary');
|
||||||
|
entry.on = true;
|
||||||
|
await host.prewarm([entry]);
|
||||||
|
host.unmount('panoramax');
|
||||||
|
assert.equal(adapter.calls.unmount, 0, 'another provider leaves it alone');
|
||||||
|
host.unmount('mapillary');
|
||||||
|
assert.equal(adapter.calls.unmount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pose that arrives after the photo was closed does not bring it back', async () => {
|
||||||
|
const adapter = fakeAdapter();
|
||||||
|
const { state, host } = harness(adapter);
|
||||||
|
await host.open('mapillary', 'a');
|
||||||
|
host.close();
|
||||||
|
const before = { ...state.street };
|
||||||
|
// The library's `image` event for a photo that was still loading.
|
||||||
|
adapter.emitPose('a');
|
||||||
|
assert.equal(state.street.open, false);
|
||||||
|
assert.equal(state.street.imageId, null);
|
||||||
|
assert.equal(state.street.sequenceId, null);
|
||||||
|
assert.deepEqual(state.street, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve;
|
||||||
|
const promise = new Promise((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
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 () => {
|
||||||
|
const adapter = fakeAdapter();
|
||||||
|
const { state, host, marker, followed } = 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;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a pose on another sequence selects it, but not while the image still loads', async () => {
|
||||||
|
const openGate = deferred();
|
||||||
|
const adapter = fakeAdapter({ openGate });
|
||||||
|
const { state, host, selected, clearSequence } = harness(adapter);
|
||||||
|
const opening = host.open('mapillary', 'a');
|
||||||
|
await settle();
|
||||||
|
assert.equal(state.street.loading, true, 'the image is downloading');
|
||||||
|
assert.equal(state.street.sequenceId, 'seq-a', 'its pose is in');
|
||||||
|
assert.deepEqual(selected, [], 'no sequence lookup competes with it');
|
||||||
|
openGate.resolve();
|
||||||
|
assert.equal(await opening, true);
|
||||||
|
assert.deepEqual(selected, ['seq-a']);
|
||||||
|
// Stepping onto the next sequence (an arrow at a junction) selects it …
|
||||||
|
adapter.emitPose('b');
|
||||||
|
assert.deepEqual(selected, ['seq-a', 'seq-b']);
|
||||||
|
// … and walking along it does not ask again.
|
||||||
|
adapter.emitPose('c', { sequenceId: 'seq-b' });
|
||||||
|
assert.deepEqual(selected, ['seq-a', 'seq-b']);
|
||||||
|
// Once Esc cleared it, looking around on the same sequence leaves it so.
|
||||||
|
clearSequence();
|
||||||
|
adapter.emitPose('c', { sequenceId: 'seq-b', bearing: 10 });
|
||||||
|
assert.deepEqual(selected, ['seq-a', 'seq-b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closing the photo clears the marker', async () => {
|
||||||
|
const adapter = fakeAdapter();
|
||||||
|
const { host, marker } = harness(adapter);
|
||||||
|
await host.open('mapillary', 'a');
|
||||||
|
assert.notEqual(marker.at(-1), null);
|
||||||
|
host.close();
|
||||||
|
assert.equal(marker.at(-1), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an open still mounting when the photo is closed does nothing when the mount lands', async () => {
|
||||||
|
const gate = deferred();
|
||||||
|
const adapter = fakeAdapter({ gate });
|
||||||
|
const { state, host, framing, selected } = harness(adapter);
|
||||||
|
const opening = host.open('mapillary', 'a'); // MapillaryJS still loading
|
||||||
|
await settle();
|
||||||
|
host.close();
|
||||||
|
gate.resolve();
|
||||||
|
assert.equal(await opening, false);
|
||||||
|
assert.deepEqual(adapter.calls.open, [], 'the image is never opened');
|
||||||
|
assert.equal(framing.started, 0, 'the globe does not fly to it');
|
||||||
|
assert.deepEqual(selected, []);
|
||||||
|
assert.deepEqual(
|
||||||
|
[state.street.open, state.street.loading, state.street.imageId],
|
||||||
|
[false, false, null],
|
||||||
|
);
|
||||||
|
// The mounted viewer serves the next open.
|
||||||
|
assert.equal(await host.open('mapillary', 'b'), true);
|
||||||
|
assert.equal(state.street.imageId, 'b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an image still opening when the photo is closed is neither framed nor selected', async () => {
|
||||||
|
const openGate = deferred();
|
||||||
|
const adapter = fakeAdapter({ openGate });
|
||||||
|
const { state, host, framing, selected } = harness(adapter);
|
||||||
|
const opening = host.open('mapillary', 'a');
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(adapter.calls.open, ['a'], 'the image is downloading');
|
||||||
|
host.close();
|
||||||
|
openGate.resolve();
|
||||||
|
assert.equal(await opening, false);
|
||||||
|
assert.equal(framing.started, 0, 'the globe does not fly to it');
|
||||||
|
assert.deepEqual(selected, []);
|
||||||
|
assert.deepEqual(
|
||||||
|
[state.street.open, state.street.loading, state.street.imageId],
|
||||||
|
[false, false, null],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MapillaryJS, counting live viewers (each holds a WebGL context). A test can
|
||||||
|
* hold `moveTo(id)` on `viewers.moveGates.get(id)` and every position read on
|
||||||
|
* `viewers.positionGate`, and fire a viewer's events through `handlers`.
|
||||||
|
*/
|
||||||
|
function fakeLibrary() {
|
||||||
|
const gate = deferred();
|
||||||
|
const viewers = {
|
||||||
|
created: 0,
|
||||||
|
live: 0,
|
||||||
|
instances: [],
|
||||||
|
moveGates: new Map(),
|
||||||
|
moves: [],
|
||||||
|
positionGate: null,
|
||||||
|
};
|
||||||
|
class Viewer {
|
||||||
|
constructor(options) {
|
||||||
|
this.options = options;
|
||||||
|
viewers.created++;
|
||||||
|
viewers.live++;
|
||||||
|
viewers.instances.push(this);
|
||||||
|
this.removed = false;
|
||||||
|
this.playback = { stops: 0 };
|
||||||
|
this.handlers = {};
|
||||||
|
}
|
||||||
|
/** The sequence component's play/stop API, as MapillaryJS 4 exposes it. */
|
||||||
|
getComponent(name) {
|
||||||
|
if (name !== 'sequence') return undefined;
|
||||||
|
return { stop: () => this.playback.stops++ };
|
||||||
|
}
|
||||||
|
on(type, handler) {
|
||||||
|
this.handlers[type] = handler;
|
||||||
|
}
|
||||||
|
async getPosition() {
|
||||||
|
await viewers.positionGate?.promise;
|
||||||
|
return { lng: 1, lat: 2 };
|
||||||
|
}
|
||||||
|
async getPointOfView() {
|
||||||
|
return { bearing: 10, tilt: 0 };
|
||||||
|
}
|
||||||
|
async moveTo(id) {
|
||||||
|
viewers.moves.push(id);
|
||||||
|
await viewers.moveGates.get(id)?.promise;
|
||||||
|
return { id, cameraType: 'perspective', sequenceId: `seq-${id}` };
|
||||||
|
}
|
||||||
|
remove() {
|
||||||
|
if (this.removed) return;
|
||||||
|
this.removed = true;
|
||||||
|
viewers.live--;
|
||||||
|
}
|
||||||
|
setRenderMode() {}
|
||||||
|
resize() {}
|
||||||
|
}
|
||||||
|
globalThis.__gevMapillaryLibrary = async () => {
|
||||||
|
await gate.promise;
|
||||||
|
return { Viewer, RenderMode: {} };
|
||||||
|
};
|
||||||
|
return { gate, viewers };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the Mapillary viewer survives the layer going off and on during its first download', async () => {
|
||||||
|
const { gate, viewers } = fakeLibrary();
|
||||||
|
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
||||||
|
const { state, host } = harness(adapter);
|
||||||
|
const first = host.open('mapillary', 'img1');
|
||||||
|
await Promise.resolve();
|
||||||
|
host.unmount();
|
||||||
|
const second = host.open('mapillary', 'img2');
|
||||||
|
await Promise.resolve();
|
||||||
|
gate.resolve();
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
assert.equal(state.street.error, null);
|
||||||
|
assert.equal(state.street.imageId, 'img2');
|
||||||
|
await host.open('mapillary', 'img3');
|
||||||
|
assert.equal(state.street.error, null, 'later opens are not stuck');
|
||||||
|
assert.equal(state.street.imageId, 'img3');
|
||||||
|
assert.equal(viewers.live, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Mapillary viewer unmounted mid-download is never built; the next mount builds afresh', async () => {
|
||||||
|
const { gate, viewers } = fakeLibrary();
|
||||||
|
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
||||||
|
const element = {};
|
||||||
|
const stale = adapter.mount(element);
|
||||||
|
adapter.unmount();
|
||||||
|
const fresh = adapter.mount(element);
|
||||||
|
gate.resolve();
|
||||||
|
await assert.rejects(stale, /unmounted/);
|
||||||
|
await fresh;
|
||||||
|
assert.equal(viewers.created, 1, 'only the fresh mount built one');
|
||||||
|
await adapter.open('x');
|
||||||
|
adapter.unmount();
|
||||||
|
assert.equal(viewers.live, 0, 'nothing left holding a WebGL context');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a prewarm that gave up when the layer went off and on mid-download is followed by one that builds', async () => {
|
||||||
|
const { gate, viewers } = fakeLibrary();
|
||||||
|
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
||||||
|
const element = {};
|
||||||
|
const first = adapter.prewarm(element);
|
||||||
|
adapter.unmount(); // layer off while MapillaryJS downloads
|
||||||
|
const second = adapter.prewarm(element); // layer on again
|
||||||
|
gate.resolve();
|
||||||
|
await Promise.all([first, second]);
|
||||||
|
assert.equal(viewers.created, 1, 'the second prewarm built the viewer');
|
||||||
|
assert.equal(viewers.live, 1);
|
||||||
|
adapter.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Mapillary viewer leaves resizing to the panel, so a hidden one never asks for z=NaN', async () => {
|
||||||
|
const { gate, viewers } = fakeLibrary();
|
||||||
|
gate.resolve();
|
||||||
|
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
||||||
|
await adapter.mount({});
|
||||||
|
assert.equal(viewers.instances[0].options.trackResize, false);
|
||||||
|
adapter.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closing the photo stops sequence playback in the hidden viewer', async () => {
|
||||||
|
const { gate, viewers } = fakeLibrary();
|
||||||
|
gate.resolve();
|
||||||
|
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
||||||
|
const { host } = harness(adapter);
|
||||||
|
await host.open('mapillary', 'img1');
|
||||||
|
const [viewer] = viewers.instances;
|
||||||
|
assert.equal(viewer.playback.stops, 0);
|
||||||
|
host.close();
|
||||||
|
assert.equal(viewer.playback.stops, 1, 'playback stopped with the photo');
|
||||||
|
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();
|
||||||
|
library.gate.resolve();
|
||||||
|
const adapter = createMapillaryViewer({ source: { token: 't' } });
|
||||||
|
const poses = [];
|
||||||
|
adapter.onPose((pose) => poses.push(pose));
|
||||||
|
await adapter.mount({});
|
||||||
|
return { ...library, adapter, poses };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a Mapillary pose still being read when the photo closes is never emitted (M57)', async () => {
|
||||||
|
const { viewers, adapter, poses } = await mountedMapillary();
|
||||||
|
await adapter.open('a');
|
||||||
|
assert.deepEqual(
|
||||||
|
poses.map((pose) => pose.imageId),
|
||||||
|
['a'],
|
||||||
|
);
|
||||||
|
// The library steps to the next image; its position is still being read.
|
||||||
|
const [viewer] = viewers.instances;
|
||||||
|
viewers.positionGate = deferred();
|
||||||
|
viewer.handlers.image({
|
||||||
|
image: { id: 'b', cameraType: 'perspective', sequenceId: 'seq-b' },
|
||||||
|
});
|
||||||
|
adapter.close();
|
||||||
|
viewers.positionGate.resolve();
|
||||||
|
await settle();
|
||||||
|
assert.deepEqual(
|
||||||
|
poses.map((pose) => pose.imageId),
|
||||||
|
['a'],
|
||||||
|
'the closed photo does not come back',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a Mapillary image overtaken by a newer open emits no pose when it lands (M58)', async () => {
|
||||||
|
const { viewers, adapter, poses } = await mountedMapillary();
|
||||||
|
const gateA = deferred();
|
||||||
|
const gateB = deferred();
|
||||||
|
viewers.moveGates.set('a', gateA);
|
||||||
|
viewers.moveGates.set('b', gateB);
|
||||||
|
const openA = adapter.open('a');
|
||||||
|
await settle(); // A's image is downloading …
|
||||||
|
const openB = adapter.open('b'); // … when the user picks another one
|
||||||
|
gateA.resolve();
|
||||||
|
await openA;
|
||||||
|
assert.deepEqual(poses, [], 'nothing for the image the user left');
|
||||||
|
gateB.resolve();
|
||||||
|
await openB;
|
||||||
|
assert.deepEqual(
|
||||||
|
poses.map((pose) => [pose.imageId, pose.sequenceId]),
|
||||||
|
[['b', 'seq-b']],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('back-to-back Mapillary opens download only the newer image', async () => {
|
||||||
|
const { viewers, adapter, poses } = await mountedMapillary();
|
||||||
|
await Promise.all([adapter.open('a'), adapter.open('b')]);
|
||||||
|
assert.deepEqual(viewers.moves, ['b']);
|
||||||
|
assert.deepEqual(
|
||||||
|
poses.map((pose) => pose.imageId),
|
||||||
|
['b'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 opening = host.open('mapillary', 'a');
|
||||||
|
assert.equal(framing.begun, 1, 'claimed before the download');
|
||||||
|
await settle();
|
||||||
|
assert.equal(framing.started, 0);
|
||||||
|
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]);
|
||||||
|
});
|
||||||
@@ -841,6 +841,7 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
|
|||||||
query,
|
query,
|
||||||
result && !outcome.fallbackUsed ? { lat, lon: lng } : null,
|
result && !outcome.fallbackUsed ? { lat, lon: lng } : null,
|
||||||
signal,
|
signal,
|
||||||
|
placeSearch,
|
||||||
);
|
);
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
if (recovered) {
|
if (recovered) {
|
||||||
|
|||||||
@@ -599,6 +599,21 @@ test('search without an authority hook preserves the existing caller contract',
|
|||||||
assert.equal(viewer.flights.length, 1);
|
assert.equal(viewer.flights.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('near-view recovery searches through the application place search', async () => {
|
||||||
|
// Recovery must use the place search the application configured, not the module default.
|
||||||
|
const viewer = stubViewer();
|
||||||
|
const placeSearch = createStandalonePlaceSearch({ resolveApiKey: () => 'test-key' });
|
||||||
|
let received = null;
|
||||||
|
await runSearch(viewer, {
|
||||||
|
placeSearch,
|
||||||
|
recoverNearView: async (...args) => {
|
||||||
|
received = args[4];
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(received, placeSearch);
|
||||||
|
});
|
||||||
|
|
||||||
test('a precise search without an outline frames against the resolved ground, not sea level', async () => {
|
test('a precise search without an outline frames against the resolved ground, not sea level', async () => {
|
||||||
// Camp Mabry field report: with no detailed outline (no configured Overpass)
|
// Camp Mabry field report: with no detailed outline (no configured Overpass)
|
||||||
// framing used the 250 m landmark range from a sea-level target, and the eye
|
// framing used the 250 m landmark range from a sea-level target, and the eye
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ test('desktop Display participates in Tactical exclusivity without changing mobi
|
|||||||
/panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/,
|
/panel\.id !== 'pp-toggles' && !panel\.classList\.contains\('collapsed'\)/,
|
||||||
);
|
);
|
||||||
assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/);
|
assert.match(ui, /if \(exclusive && panel\.classList\.contains\('collapsed'\)\)\s*panel\.setAttribute\('aria-hidden', 'true'\)/);
|
||||||
assert.match(css, /#right-context-rail\.layout-exclusive > \[data-panel-id\]\.collapsed \{/);
|
// 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\) \{/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('explicit Contacts, Space Missions, and Cockpit actions expand Global Context after success', () => {
|
test('explicit Contacts, Space Missions, and Cockpit actions expand Global Context after success', () => {
|
||||||
|
|||||||
@@ -195,5 +195,9 @@ export function createMeshFloorSampler({
|
|||||||
() => eventTarget?.removeEventListener('gev:map-stack-changed', onStack),
|
() => eventTarget?.removeEventListener('gev:map-stack-changed', onStack),
|
||||||
{ once: true },
|
{ once: true },
|
||||||
);
|
);
|
||||||
return { sampleMeshFloorCells, cachedGroundFloor };
|
return {
|
||||||
|
sampleMeshFloorCells,
|
||||||
|
cachedGroundFloor,
|
||||||
|
visibleTilesetLoaded: _visibleTilesetLoaded,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
+163
-95
@@ -27,21 +27,31 @@ test('doctor distinguishes supported, usable EOL, and unsupported Node versions'
|
|||||||
// A FUTURE Node is a warning, never an install-bricking refusal: the
|
// A FUTURE Node is a warning, never an install-bricking refusal: the
|
||||||
// no-terminal user it would stop cannot act on "install Node 24".
|
// no-terminal user it would stop cannot act on "install Node 24".
|
||||||
assert.equal(classifyNodeVersion('27.0.0').level, 'warn');
|
assert.equal(classifyNodeVersion('27.0.0').level, 'warn');
|
||||||
assert.match(classifyNodeVersion('27.0.0').summary, /newer than this release has verified/);
|
assert.match(
|
||||||
|
classifyNodeVersion('27.0.0').summary,
|
||||||
|
/newer than this release has verified/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('doctor rejects an empty node_modules and requires every direct package', () => {
|
test('doctor rejects an empty node_modules and requires every direct package', () => {
|
||||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-doctor-deps-'));
|
const root = mkdtempSync(path.join(tmpdir(), 'gev-doctor-deps-'));
|
||||||
try {
|
try {
|
||||||
writeFileSync(path.join(root, 'package.json'), JSON.stringify({
|
writeFileSync(
|
||||||
dependencies: { vite: '1.0.0' },
|
path.join(root, 'package.json'),
|
||||||
devDependencies: { '@scope/tool': '1.0.0' },
|
JSON.stringify({
|
||||||
}));
|
dependencies: { vite: '1.0.0' },
|
||||||
|
devDependencies: { '@scope/tool': '1.0.0' },
|
||||||
|
}),
|
||||||
|
);
|
||||||
mkdirSync(path.join(root, 'node_modules'));
|
mkdirSync(path.join(root, 'node_modules'));
|
||||||
assert.equal(hasRequiredDependencies(root), false);
|
assert.equal(hasRequiredDependencies(root), false);
|
||||||
|
|
||||||
for (const packagePath of ['vite', '@scope/tool']) {
|
for (const packagePath of ['vite', '@scope/tool']) {
|
||||||
const directory = path.join(root, 'node_modules', ...packagePath.split('/'));
|
const directory = path.join(
|
||||||
|
root,
|
||||||
|
'node_modules',
|
||||||
|
...packagePath.split('/'),
|
||||||
|
);
|
||||||
mkdirSync(directory, { recursive: true });
|
mkdirSync(directory, { recursive: true });
|
||||||
writeFileSync(path.join(directory, 'package.json'), '{}');
|
writeFileSync(path.join(directory, 'package.json'), '{}');
|
||||||
}
|
}
|
||||||
@@ -59,36 +69,33 @@ test('placeholder values are never counted as configured credentials', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('doctor selects a Windows-safe npm process without changing Unix behavior', () => {
|
test('doctor selects a Windows-safe npm process without changing Unix behavior', () => {
|
||||||
assert.deepEqual(npmProcessSpec('win32'), { command: 'npm.cmd', shell: true });
|
assert.deepEqual(npmProcessSpec('win32'), {
|
||||||
|
command: 'npm.cmd',
|
||||||
|
shell: true,
|
||||||
|
});
|
||||||
assert.deepEqual(npmProcessSpec('darwin'), { command: 'npm', shell: false });
|
assert.deepEqual(npmProcessSpec('darwin'), { command: 'npm', shell: false });
|
||||||
assert.deepEqual(npmProcessSpec('linux'), { command: 'npm', shell: false });
|
assert.deepEqual(npmProcessSpec('linux'), { command: 'npm', shell: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('doctor recognizes every OpenSky OAuth keychain alias used by dev-fresh', () => {
|
test('doctor recognizes every OpenSky OAuth keychain alias used by dev-fresh', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(credential('OPENSKY_CLIENT_ID').keychain, [
|
||||||
credential('OPENSKY_CLIENT_ID').keychain,
|
['opensky-network', 'client_id'],
|
||||||
[
|
['opensky-network', 'client-id'],
|
||||||
['opensky-network', 'client_id'],
|
['opensky-network', 'client'],
|
||||||
['opensky-network', 'client-id'],
|
['opensky-network', 'api-key'],
|
||||||
['opensky-network', 'client'],
|
['opensky', 'client_id'],
|
||||||
['opensky-network', 'api-key'],
|
['opensky', 'client-id'],
|
||||||
['opensky', 'client_id'],
|
['opensky', 'client'],
|
||||||
['opensky', 'client-id'],
|
['opensky', 'api-key'],
|
||||||
['opensky', 'client'],
|
]);
|
||||||
['opensky', 'api-key'],
|
assert.deepEqual(credential('OPENSKY_CLIENT_SECRET').keychain, [
|
||||||
],
|
['opensky-network', 'client_secret'],
|
||||||
);
|
['opensky-network', 'client-secret'],
|
||||||
assert.deepEqual(
|
['opensky-network', 'secret'],
|
||||||
credential('OPENSKY_CLIENT_SECRET').keychain,
|
['opensky', 'client_secret'],
|
||||||
[
|
['opensky', 'client-secret'],
|
||||||
['opensky-network', 'client_secret'],
|
['opensky', 'secret'],
|
||||||
['opensky-network', 'client-secret'],
|
]);
|
||||||
['opensky-network', 'secret'],
|
|
||||||
['opensky', 'client_secret'],
|
|
||||||
['opensky', 'client-secret'],
|
|
||||||
['opensky', 'secret'],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Pinokio-scoped diagnosis ignores Keychain items its start path does not import', () => {
|
test('Pinokio-scoped diagnosis ignores Keychain items its start path does not import', () => {
|
||||||
@@ -96,17 +103,23 @@ test('Pinokio-scoped diagnosis ignores Keychain items its start path does not im
|
|||||||
try {
|
try {
|
||||||
const spec = credential('OPENAI_API_KEY');
|
const spec = credential('OPENAI_API_KEY');
|
||||||
const keychainLookup = () => true;
|
const keychainLookup = () => true;
|
||||||
assert.deepEqual(resolveCredential(spec, {
|
assert.deepEqual(
|
||||||
environment: {},
|
resolveCredential(spec, {
|
||||||
rootDir: root,
|
environment: {},
|
||||||
keychainLookup,
|
rootDir: root,
|
||||||
}), { configured: true, source: 'macOS Keychain' });
|
keychainLookup,
|
||||||
assert.deepEqual(resolveCredential(spec, {
|
}),
|
||||||
includeKeychain: false,
|
{ configured: true, source: 'macOS Keychain' },
|
||||||
environment: {},
|
);
|
||||||
rootDir: root,
|
assert.deepEqual(
|
||||||
keychainLookup,
|
resolveCredential(spec, {
|
||||||
}), { configured: false, source: null });
|
includeKeychain: false,
|
||||||
|
environment: {},
|
||||||
|
rootDir: root,
|
||||||
|
keychainLookup,
|
||||||
|
}),
|
||||||
|
{ configured: false, source: null },
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(root, { recursive: true, force: true });
|
rmSync(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -116,18 +129,27 @@ test('Pinokio-scoped diagnosis does not count dotenv values shadowed by blank ap
|
|||||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-pinokio-doctor-env-'));
|
const root = mkdtempSync(path.join(tmpdir(), 'gev-pinokio-doctor-env-'));
|
||||||
try {
|
try {
|
||||||
const spec = credential('GOOGLE_MAPS_API_KEY');
|
const spec = credential('GOOGLE_MAPS_API_KEY');
|
||||||
writeFileSync(path.join(root, '.env.local'), 'GOOGLE_MAPS_API_KEY=dotenv-only\n');
|
writeFileSync(
|
||||||
assert.deepEqual(resolveCredential(spec, {
|
path.join(root, '.env.local'),
|
||||||
environment: { GOOGLE_MAPS_API_KEY: '' },
|
'GOOGLE_MAPS_API_KEY=dotenv-only\n',
|
||||||
rootDir: root,
|
);
|
||||||
keychainLookup: () => false,
|
assert.deepEqual(
|
||||||
}), { configured: true, source: 'dotenv files' });
|
resolveCredential(spec, {
|
||||||
assert.deepEqual(resolveCredential(spec, {
|
environment: { GOOGLE_MAPS_API_KEY: '' },
|
||||||
authoritativeEnvironment: true,
|
rootDir: root,
|
||||||
environment: { GOOGLE_MAPS_API_KEY: '' },
|
keychainLookup: () => false,
|
||||||
rootDir: root,
|
}),
|
||||||
keychainLookup: () => false,
|
{ configured: true, source: 'dotenv files' },
|
||||||
}), { configured: false, source: null });
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
resolveCredential(spec, {
|
||||||
|
authoritativeEnvironment: true,
|
||||||
|
environment: { GOOGLE_MAPS_API_KEY: '' },
|
||||||
|
rootDir: root,
|
||||||
|
keychainLookup: () => false,
|
||||||
|
}),
|
||||||
|
{ configured: false, source: null },
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(root, { recursive: true, force: true });
|
rmSync(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -138,7 +160,10 @@ test('doctor reads the dotenv ladder without requiring Vite to be installed', ()
|
|||||||
try {
|
try {
|
||||||
writeFileSync(path.join(root, '.env'), 'GEV_TEST_KEY=base\n');
|
writeFileSync(path.join(root, '.env'), 'GEV_TEST_KEY=base\n');
|
||||||
writeFileSync(path.join(root, '.env.local'), 'GEV_TEST_KEY=local\n');
|
writeFileSync(path.join(root, '.env.local'), 'GEV_TEST_KEY=local\n');
|
||||||
writeFileSync(path.join(root, '.env.development.local'), 'GEV_TEST_KEY=mode-local\n');
|
writeFileSync(
|
||||||
|
path.join(root, '.env.development.local'),
|
||||||
|
'GEV_TEST_KEY=mode-local\n',
|
||||||
|
);
|
||||||
assert.equal(readDoctorDotenvValue('GEV_TEST_KEY', root), 'mode-local');
|
assert.equal(readDoctorDotenvValue('GEV_TEST_KEY', root), 'mode-local');
|
||||||
assert.equal(readDoctorDotenvValue('not valid', root), '');
|
assert.equal(readDoctorDotenvValue('not valid', root), '');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -150,10 +175,31 @@ test('doctor resolves OpenSky auth mode with launcher-compatible precedence', ()
|
|||||||
const root = mkdtempSync(path.join(tmpdir(), 'gev-doctor-opensky-mode-'));
|
const root = mkdtempSync(path.join(tmpdir(), 'gev-doctor-opensky-mode-'));
|
||||||
try {
|
try {
|
||||||
writeFileSync(path.join(root, '.env'), 'OPENSKY_AUTH_MODE=anon\n');
|
writeFileSync(path.join(root, '.env'), 'OPENSKY_AUTH_MODE=anon\n');
|
||||||
assert.equal(resolveOpenSkyAuthMode({ environment: {}, rootDir: root }), 'anon');
|
assert.equal(
|
||||||
assert.equal(resolveOpenSkyAuthMode({ environment: { OPENSKY_AUTH_MODE: 'oauth' }, rootDir: root }), 'oauth');
|
resolveOpenSkyAuthMode({ environment: {}, rootDir: root }),
|
||||||
assert.equal(resolveOpenSkyAuthMode({ environment: { OPENSKY_AUTH_MODE: '' }, rootDir: root }), 'anon');
|
'anon',
|
||||||
assert.equal(resolveOpenSkyAuthMode({ environment: { OPENSKY_AUTH_MODE: 'invalid' }, rootDir: root }), 'oauth');
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveOpenSkyAuthMode({
|
||||||
|
environment: { OPENSKY_AUTH_MODE: 'oauth' },
|
||||||
|
rootDir: root,
|
||||||
|
}),
|
||||||
|
'oauth',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveOpenSkyAuthMode({
|
||||||
|
environment: { OPENSKY_AUTH_MODE: '' },
|
||||||
|
rootDir: root,
|
||||||
|
}),
|
||||||
|
'anon',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resolveOpenSkyAuthMode({
|
||||||
|
environment: { OPENSKY_AUTH_MODE: 'invalid' },
|
||||||
|
rootDir: root,
|
||||||
|
}),
|
||||||
|
'oauth',
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(root, { recursive: true, force: true });
|
rmSync(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -219,13 +265,20 @@ test('doctor describes the credential ladder without exposing values', () => {
|
|||||||
OPENSKY_CLIENT_ID: { configured: false },
|
OPENSKY_CLIENT_ID: { configured: false },
|
||||||
OPENSKY_CLIENT_SECRET: { configured: false },
|
OPENSKY_CLIENT_SECRET: { configured: false },
|
||||||
LL2_API_TOKEN: { configured: true, source: 'environment' },
|
LL2_API_TOKEN: { configured: true, source: 'environment' },
|
||||||
|
MAPILLARY_CLIENT_TOKEN: { configured: false },
|
||||||
};
|
};
|
||||||
const capabilities = buildCapabilitySummary(credentials);
|
const capabilities = buildCapabilitySummary(credentials);
|
||||||
assert.match(capabilities.map, /Google Photorealistic 3D Tiles through Cesium ion/);
|
assert.match(
|
||||||
|
capabilities.map,
|
||||||
|
/Google Photorealistic 3D Tiles through Cesium ion/,
|
||||||
|
);
|
||||||
assert.match(capabilities.map, /Bing and world-terrain stacks/);
|
assert.match(capabilities.map, /Bing and world-terrain stacks/);
|
||||||
assert.equal(capabilities.voice, 'available');
|
assert.equal(capabilities.voice, 'available');
|
||||||
assert.match(capabilities.missions, /token allowance/);
|
assert.match(capabilities.missions, /token allowance/);
|
||||||
assert.equal(capabilities.flights, 'OpenSky keyless anonymous access (rate-limited)');
|
assert.equal(
|
||||||
|
capabilities.flights,
|
||||||
|
'OpenSky keyless anonymous access (rate-limited)',
|
||||||
|
);
|
||||||
|
|
||||||
const report = formatSetupReport({
|
const report = formatSetupReport({
|
||||||
ready: true,
|
ready: true,
|
||||||
@@ -239,34 +292,46 @@ test('doctor describes the credential ladder without exposing values', () => {
|
|||||||
assert.match(report, /Cesium ion \(environment\)/);
|
assert.match(report, /Cesium ion \(environment\)/);
|
||||||
assert.match(report, /Launch Library 2 \(environment\)/);
|
assert.match(report, /Launch Library 2 \(environment\)/);
|
||||||
|
|
||||||
const pinokioReport = formatSetupReport({
|
const pinokioReport = formatSetupReport(
|
||||||
ready: true,
|
{
|
||||||
node: { version: '24.14.0', level: 'ok', summary: 'supported' },
|
ready: true,
|
||||||
npm: { available: true, version: '11.0.0' },
|
node: { version: '24.14.0', level: 'ok', summary: 'supported' },
|
||||||
dependenciesInstalled: true,
|
npm: { available: true, version: '11.0.0' },
|
||||||
credentials,
|
dependenciesInstalled: true,
|
||||||
capabilities,
|
credentials,
|
||||||
}, { readyMessage: 'Ready. Return to Pinokio and choose Start.' });
|
capabilities,
|
||||||
|
},
|
||||||
|
{ readyMessage: 'Ready. Return to Pinokio and choose Start.' },
|
||||||
|
);
|
||||||
assert.match(pinokioReport, /Return to Pinokio and choose Start/);
|
assert.match(pinokioReport, /Return to Pinokio and choose Start/);
|
||||||
assert.doesNotMatch(pinokioReport, /npm run dev/);
|
assert.doesNotMatch(pinokioReport, /npm run dev/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('doctor sends Keychain-backed reports to dev-fresh and describes OpenSky as presence only', () => {
|
test('doctor sends Keychain-backed reports to dev-fresh and describes OpenSky as presence only', () => {
|
||||||
const credentials = Object.fromEntries([
|
const credentials = Object.fromEntries(
|
||||||
'GOOGLE_MAPS_API_KEY',
|
[
|
||||||
'GOOGLE_MAPS_SERVER_API_KEY',
|
'GOOGLE_MAPS_API_KEY',
|
||||||
'CESIUM_ION_TOKEN',
|
'GOOGLE_MAPS_SERVER_API_KEY',
|
||||||
'OPENAI_API_KEY',
|
'CESIUM_ION_TOKEN',
|
||||||
'AISSTREAM_API_KEY',
|
'OPENAI_API_KEY',
|
||||||
'FIRMS_MAP_KEY',
|
'AISSTREAM_API_KEY',
|
||||||
'TOMTOM_API_KEY',
|
'FIRMS_MAP_KEY',
|
||||||
'OPENSKY_CLIENT_ID',
|
'TOMTOM_API_KEY',
|
||||||
'OPENSKY_CLIENT_SECRET',
|
'OPENSKY_CLIENT_ID',
|
||||||
'LL2_API_TOKEN',
|
'OPENSKY_CLIENT_SECRET',
|
||||||
].map((name) => [name, { configured: false }]));
|
'LL2_API_TOKEN',
|
||||||
credentials.GOOGLE_MAPS_API_KEY = { configured: true, source: 'macOS Keychain' };
|
'MAPILLARY_CLIENT_TOKEN',
|
||||||
|
].map((name) => [name, { configured: false }]),
|
||||||
|
);
|
||||||
|
credentials.GOOGLE_MAPS_API_KEY = {
|
||||||
|
configured: true,
|
||||||
|
source: 'macOS Keychain',
|
||||||
|
};
|
||||||
credentials.OPENSKY_CLIENT_ID = { configured: true, source: 'environment' };
|
credentials.OPENSKY_CLIENT_ID = { configured: true, source: 'environment' };
|
||||||
credentials.OPENSKY_CLIENT_SECRET = { configured: true, source: 'environment' };
|
credentials.OPENSKY_CLIENT_SECRET = {
|
||||||
|
configured: true,
|
||||||
|
source: 'environment',
|
||||||
|
};
|
||||||
const capabilities = buildCapabilitySummary(credentials);
|
const capabilities = buildCapabilitySummary(credentials);
|
||||||
const output = formatSetupReport({
|
const output = formatSetupReport({
|
||||||
ready: true,
|
ready: true,
|
||||||
@@ -284,18 +349,21 @@ test('doctor sends Keychain-backed reports to dev-fresh and describes OpenSky as
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('doctor never calls a dependency-missing setup ready', () => {
|
test('doctor never calls a dependency-missing setup ready', () => {
|
||||||
const credentials = Object.fromEntries([
|
const credentials = Object.fromEntries(
|
||||||
'GOOGLE_MAPS_API_KEY',
|
[
|
||||||
'GOOGLE_MAPS_SERVER_API_KEY',
|
'GOOGLE_MAPS_API_KEY',
|
||||||
'CESIUM_ION_TOKEN',
|
'GOOGLE_MAPS_SERVER_API_KEY',
|
||||||
'OPENAI_API_KEY',
|
'CESIUM_ION_TOKEN',
|
||||||
'AISSTREAM_API_KEY',
|
'OPENAI_API_KEY',
|
||||||
'FIRMS_MAP_KEY',
|
'AISSTREAM_API_KEY',
|
||||||
'TOMTOM_API_KEY',
|
'FIRMS_MAP_KEY',
|
||||||
'OPENSKY_CLIENT_ID',
|
'TOMTOM_API_KEY',
|
||||||
'OPENSKY_CLIENT_SECRET',
|
'OPENSKY_CLIENT_ID',
|
||||||
'LL2_API_TOKEN',
|
'OPENSKY_CLIENT_SECRET',
|
||||||
].map((name) => [name, { configured: false }]));
|
'LL2_API_TOKEN',
|
||||||
|
'MAPILLARY_CLIENT_TOKEN',
|
||||||
|
].map((name) => [name, { configured: false }]),
|
||||||
|
);
|
||||||
const output = formatSetupReport({
|
const output = formatSetupReport({
|
||||||
ready: false,
|
ready: false,
|
||||||
node: { level: 'ok', version: '24.14.0', summary: 'supported' },
|
node: { level: 'ok', version: '24.14.0', summary: 'supported' },
|
||||||
|
|||||||
@@ -184,6 +184,24 @@ test('a collapsed Recent Imagery panel survives the share-link round trip beside
|
|||||||
] });
|
] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a collapsed Street Level panel survives the share-link round trip', () => {
|
||||||
|
const manager = makeManager();
|
||||||
|
manager.setPanelStateProvider(() => ({ specs: [
|
||||||
|
{ id: 'cctv-panel', collapsed: false },
|
||||||
|
{ id: 'street-level-panel', collapsed: true },
|
||||||
|
] }));
|
||||||
|
clearTimeout(manager._debounceTimer);
|
||||||
|
manager._updateHash();
|
||||||
|
const params = new URLSearchParams(window.location.hash.slice(1));
|
||||||
|
assert.equal(params.get('ui'), 'v.c.0_t.c.1');
|
||||||
|
const restored = makeManager(`#v=2&lat=10&lon=20&ui=${params.get('ui')}`)
|
||||||
|
.parseInitialHash();
|
||||||
|
assert.deepEqual(restored.panelState, { specs: [
|
||||||
|
{ id: 'cctv-panel', collapsed: false, pinned: null },
|
||||||
|
{ id: 'street-level-panel', collapsed: true, pinned: null },
|
||||||
|
] });
|
||||||
|
});
|
||||||
|
|
||||||
test('camera-only, partial, and malformed panel shares remain valid incoming state', () => {
|
test('camera-only, partial, and malformed panel shares remain valid incoming state', () => {
|
||||||
const cameraOnly = makeManager('#lat=10&lon=20').parseInitialHash();
|
const cameraOnly = makeManager('#lat=10&lon=20').parseInitialHash();
|
||||||
assert.ok(cameraOnly);
|
assert.ok(cameraOnly);
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const SHARE_PANEL_STATE_REGISTRY = Object.freeze([
|
|||||||
{ id: 'data-panel', token: 'd', pinnable: false },
|
{ id: 'data-panel', token: 'd', pinnable: false },
|
||||||
{ id: 'cctv-panel', token: 'v', pinnable: false },
|
{ id: 'cctv-panel', token: 'v', pinnable: false },
|
||||||
{ id: 'recent-imagery-panel', token: 'i', pinnable: false },
|
{ id: 'recent-imagery-panel', token: 'i', pinnable: false },
|
||||||
|
{ id: 'street-level-panel', token: 't', pinnable: false },
|
||||||
{ id: 'radio-panel', token: 'r', pinnable: false },
|
{ id: 'radio-panel', token: 'r', pinnable: false },
|
||||||
{ id: 'scene-panel', token: 's', pinnable: false },
|
{ id: 'scene-panel', token: 's', pinnable: false },
|
||||||
{ id: 'global-context-panel', token: 'g', pinnable: false },
|
{ id: 'global-context-panel', token: 'g', pinnable: false },
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { createWeatherSource } from '../layers/weather/source.js';
|
|||||||
import { createCycloneSource } from '../layers/cyclones/source.js';
|
import { createCycloneSource } from '../layers/cyclones/source.js';
|
||||||
import { createWindSource } from '../layers/wind/source.js';
|
import { createWindSource } from '../layers/wind/source.js';
|
||||||
import { createFirmsSource } from '../layers/firms/source.js';
|
import { createFirmsSource } from '../layers/firms/source.js';
|
||||||
|
import { createMapillarySource } from '../layers/streetLevel/providers/mapillary/source.js';
|
||||||
import { createReferenceSources } from '../sources/reference.js';
|
import { createReferenceSources } from '../sources/reference.js';
|
||||||
export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js';
|
export { createReferenceSources as createStandaloneReferenceSources } from '../sources/reference.js';
|
||||||
|
|
||||||
@@ -46,5 +47,8 @@ export function createStandaloneLayerSources() {
|
|||||||
wind: createWindSource(),
|
wind: createWindSource(),
|
||||||
weather: createWeatherSource(),
|
weather: createWeatherSource(),
|
||||||
cyclones: createCycloneSource(),
|
cyclones: createCycloneSource(),
|
||||||
|
mapillary: createMapillarySource({
|
||||||
|
token: import.meta.env?.MAPILLARY_CLIENT_TOKEN || '',
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { expandApplicationHtml } from '../build/application-html.js';
|
||||||
|
import { readStylesheet } from './testSupport/readStylesheet.mjs';
|
||||||
|
|
||||||
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
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 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', () => {
|
||||||
|
assert.match(
|
||||||
|
html,
|
||||||
|
/<div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel">/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
html,
|
||||||
|
/<button class="panel-collapse-btn" data-collapse-target="street-level-panel"/,
|
||||||
|
);
|
||||||
|
assert.match(html, /<span class="panel-title">STREET LEVEL<\/span>/);
|
||||||
|
// Provider-neutral header: no vendor mark; the inner restores its scroll.
|
||||||
|
assert.doesNotMatch(html, /sl-mark|mly-mark/);
|
||||||
|
assert.match(
|
||||||
|
html,
|
||||||
|
/<div class="street-level-panel-inner" data-rail-scroller>/,
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(
|
||||||
|
html,
|
||||||
|
/mapillary-dock|sl-minimized|sl-3d-btn|sl-photoreal-btn/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the panel is registered with panel chrome, cockpit entry and the right rail', () => {
|
||||||
|
assert.match(panelChrome, /\{ id: 'street-level-panel' \}/);
|
||||||
|
assert.match(
|
||||||
|
panelChrome,
|
||||||
|
/COCKPIT_ENTRY_COLLAPSE_PANEL_IDS = Object\.freeze\(\[[\s\S]*'street-level-panel'/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
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\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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/);
|
||||||
|
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.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/,
|
||||||
|
);
|
||||||
|
for (const [group, checked] of [
|
||||||
|
['data-sl-render', 'letterbox'],
|
||||||
|
['data-sl-pano', 'all'],
|
||||||
|
]) {
|
||||||
|
const radios = [
|
||||||
|
...html.matchAll(
|
||||||
|
new RegExp(`<button [^>]*${group}="([a-z]+)"[^>]*>`, 'g'),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
assert.ok(radios.length >= 2, group);
|
||||||
|
for (const [tag, value] of radios)
|
||||||
|
assert.equal(
|
||||||
|
/tabindex="-1"/.test(tag),
|
||||||
|
value !== checked,
|
||||||
|
`${group}="${value}" roving tab stop`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the Cyber theme frames the Street Level panel like its rail peers (P3)', () => {
|
||||||
|
const cyber = read('src/ui/styles/cyber.css');
|
||||||
|
const inner = (cyber.match(/\.recent-imagery-panel-inner,/g) || []).length;
|
||||||
|
const panel = (cyber.match(/#recent-imagery-panel,/g) || []).length;
|
||||||
|
assert.ok(inner > 0 && panel > 0);
|
||||||
|
assert.equal(
|
||||||
|
(cyber.match(/\.street-level-panel-inner,/g) || []).length,
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
assert.equal((cyber.match(/#street-level-panel,/g) || []).length, panel);
|
||||||
|
// A theme header rule must be able to outrank the panel's own.
|
||||||
|
assert.doesNotMatch(panelCss, /#street-level-panel \.panel-header/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/** Shared Street Level test stand-ins: provider snapshot, minimal provider, ray-casting camera. */
|
||||||
|
import * as Cesium from 'cesium';
|
||||||
|
import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js';
|
||||||
|
|
||||||
|
const RAD = Math.PI / 180;
|
||||||
|
|
||||||
|
/** One provider as `getUIState().providers` lists it. */
|
||||||
|
export function providerSnapshot(overrides = {}) {
|
||||||
|
return {
|
||||||
|
id: 'mapillary',
|
||||||
|
name: 'Mapillary',
|
||||||
|
label: 'MAPILLARY',
|
||||||
|
on: true,
|
||||||
|
configured: true,
|
||||||
|
keyRequired: false,
|
||||||
|
requiresKeyId: 'mapillary',
|
||||||
|
loading: false,
|
||||||
|
count: 0,
|
||||||
|
hint: '',
|
||||||
|
error: null,
|
||||||
|
color: '#05cb63',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The smallest provider the core accepts. Mutate `stats` to change what
|
||||||
|
* coverageStats() answers; `calls`, `filters` and `context()` record use.
|
||||||
|
*/
|
||||||
|
export function fakeStreetLevelProvider({
|
||||||
|
id = 'mapillary',
|
||||||
|
pickPrefix = 'mly:',
|
||||||
|
html = MAPILLARY_CREDIT_HTML,
|
||||||
|
nearestImage = async () => null,
|
||||||
|
} = {}) {
|
||||||
|
const calls = { activate: 0, deactivate: 0, mount: 0, open: [], unmount: 0 };
|
||||||
|
const filters = [];
|
||||||
|
const stats = {
|
||||||
|
count: 0,
|
||||||
|
zoom: null,
|
||||||
|
kind: null,
|
||||||
|
loading: false,
|
||||||
|
hint: '',
|
||||||
|
error: null,
|
||||||
|
keyRequired: false,
|
||||||
|
};
|
||||||
|
let context = null;
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
filters,
|
||||||
|
stats,
|
||||||
|
context: () => context,
|
||||||
|
id,
|
||||||
|
name: 'Mapillary',
|
||||||
|
label: 'MAPILLARY',
|
||||||
|
requiresKeyId: null,
|
||||||
|
pickPrefix,
|
||||||
|
colors: { coverage: '#05cb63' },
|
||||||
|
credit: { html },
|
||||||
|
create: (providerContext) => {
|
||||||
|
context = providerContext;
|
||||||
|
return {
|
||||||
|
status: async () => ({ configured: true }),
|
||||||
|
init() {},
|
||||||
|
activate: () => calls.activate++,
|
||||||
|
deactivate: () => calls.deactivate++,
|
||||||
|
destroy() {},
|
||||||
|
refreshCoverage() {},
|
||||||
|
setFilter: (filter) => filters.push(filter),
|
||||||
|
coverageStats: () => ({ ...stats }),
|
||||||
|
handlePick: () => false,
|
||||||
|
nearestImage,
|
||||||
|
viewer: {
|
||||||
|
mount: async () => calls.mount++,
|
||||||
|
open: async (imageId) => calls.open.push(imageId),
|
||||||
|
close() {},
|
||||||
|
unmount: () => calls.unmount++,
|
||||||
|
resize() {},
|
||||||
|
onPose: () => () => {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pinhole camera `altitude` m above WGS84, at `heading` (deg from north) and
|
||||||
|
* `pitch` (deg, negative down); `pickEllipsoid` hits whatever ellipsoid it gets.
|
||||||
|
*/
|
||||||
|
export function rayCamera({
|
||||||
|
lon,
|
||||||
|
lat,
|
||||||
|
altitude,
|
||||||
|
heading = 0,
|
||||||
|
pitch,
|
||||||
|
width = 1600,
|
||||||
|
height = 900,
|
||||||
|
fovY = 60,
|
||||||
|
fovX = null,
|
||||||
|
}) {
|
||||||
|
const position = Cesium.Cartesian3.fromDegrees(lon, lat, altitude);
|
||||||
|
const frame = Cesium.Transforms.eastNorthUpToFixedFrame(position);
|
||||||
|
const h = heading * RAD;
|
||||||
|
const p = pitch * RAD;
|
||||||
|
// East-north-up axes of the view: forward, right and up.
|
||||||
|
const forward = [
|
||||||
|
Math.sin(h) * Math.cos(p),
|
||||||
|
Math.cos(h) * Math.cos(p),
|
||||||
|
Math.sin(p),
|
||||||
|
];
|
||||||
|
const right = [Math.cos(h), -Math.sin(h), 0];
|
||||||
|
const up = [
|
||||||
|
-Math.sin(h) * Math.sin(p),
|
||||||
|
-Math.cos(h) * Math.sin(p),
|
||||||
|
Math.cos(p),
|
||||||
|
];
|
||||||
|
const halfY = Math.tan((fovY / 2) * RAD);
|
||||||
|
const halfX =
|
||||||
|
fovX == null ? halfY * (width / height) : Math.tan((fovX / 2) * RAD);
|
||||||
|
return {
|
||||||
|
positionWC: position,
|
||||||
|
positionCartographic: Cesium.Cartographic.fromDegrees(lon, lat, altitude),
|
||||||
|
pickEllipsoid(point, ellipsoid = Cesium.Ellipsoid.WGS84) {
|
||||||
|
const sx = ((2 * point.x) / width - 1) * halfX;
|
||||||
|
const sy = (1 - (2 * point.y) / height) * halfY;
|
||||||
|
const local = [0, 1, 2].map(
|
||||||
|
(i) => forward[i] + sx * right[i] + sy * up[i],
|
||||||
|
);
|
||||||
|
const direction = Cesium.Cartesian3.normalize(
|
||||||
|
Cesium.Matrix4.multiplyByPointAsVector(
|
||||||
|
frame,
|
||||||
|
new Cesium.Cartesian3(...local),
|
||||||
|
new Cesium.Cartesian3(),
|
||||||
|
),
|
||||||
|
new Cesium.Cartesian3(),
|
||||||
|
);
|
||||||
|
const ray = new Cesium.Ray(position, direction);
|
||||||
|
const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid);
|
||||||
|
return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined;
|
||||||
|
},
|
||||||
|
computeViewRectangle: () => undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user