docs(street-level): shorten docs and comments before merge

Trim the doc text this PR adds (README, CHANGELOG, CURRENT-STATE,
KNOWN-ISSUES, SECURITY, DATA_SOURCES, .env.example, the CI job comment),
drop code comments that restate the code, cut design-note JSDoc to a line
or two, and drop review tags from test names. Comments only; no code
changes.

Also: README key count (seven keys, two metered), CURRENT-STATE's gate
paragraph (CI runs a production build with a dummy token), the stacked
doc above meshBoxInRange and the orphaned inMeshRange line in
coverage.js, and a duplicate ALPR row in DATA_SOURCES that a merge had
brought back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-06 21:59:12 +01:00
1 parent c65feda4b1
commit 4ea6631425
85 files changed
+701 -1520

No files matched your search

+5 -9
View File
@@ -14,8 +14,8 @@
# and self-hosted setups. # and self-hosted setups.
# #
# NOTE ON CLIENT-EXPOSED KEYS: GOOGLE_MAPS_API_KEY, CESIUM_ION_TOKEN and # NOTE ON CLIENT-EXPOSED KEYS: GOOGLE_MAPS_API_KEY, CESIUM_ION_TOKEN and
# MAPILLARY_CLIENT_TOKEN are 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,13 +82,9 @@ 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 street-level imagery — coverage lines on the globe and # Optional: Mapillary imagery for the Street Level layer. Free client token from
# the embedded MapillaryJS viewer in the Street Level layer. # https://www.mapillary.com/dashboard/developers (register an app, copy its
# Free client token: https://www.mapillary.com/dashboard/developers (register an # Client Token). Client-exposed by design, like GOOGLE_MAPS_API_KEY.
# application, copy the "Client Token"). CLIENT-EXPOSED by design: MapillaryJS
# fetches imagery straight from Mapillary in the browser, so this token is in
# the bundle like GOOGLE_MAPS_API_KEY. Mapillary client tokens are meant to be
# public; the dev server also uses it to fetch and cache coverage tiles.
MAPILLARY_CLIENT_TOKEN= MAPILLARY_CLIENT_TOKEN=
# Optional: OpenSky auth mode (oauth | basic | auto | anon) # Optional: OpenSky auth mode (oauth | basic | auto | anon)
+3 -11
View File
@@ -60,22 +60,14 @@ jobs:
run: npm run build run: npm run build
street-level-browser: street-level-browser:
# The Street Level gate in its hermetic mode, against a production build # Hermetic: every Mapillary request is answered from fixtures, so the
# served by `vite preview`: coverage tiles, the Graph API and the image # dummy token below never leaves the page.
# CDN are answered from fixtures (scripts/fixtures/street-level), so no
# real token and no Mapillary network are needed, and the whole photo
# flow runs. The server gets a dummy, non-secret client token: the build
# bakes it into the bundle (the viewer needs one to ask for photos) and
# the preview server's status route reports it as configured. The page
# intercepts every *.mapillary.com request, so the token never leaves it.
name: Street Level browser gate name: Street Level browser gate
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
env: env:
GEV_REQUIRE_ALLOCATION_GATE: '0' GEV_REQUIRE_ALLOCATION_GATE: '0'
# A header press that misses while the panel stood still is a failure # Fail on a missed header press unless the panel itself moved.
# (the right rail now aligns to where the left rail settles); a miss the
# panel's own layout change explains is retried with a ::warning::.
QA_FAIL_ON_RETRY: '1' QA_FAIL_ON_RETRY: '1'
steps: steps:
- name: Check out source - name: Check out source
+16 -20
View File
@@ -2,26 +2,21 @@
## [Unreleased] ## [Unreleased]
- Rail panels can opt into a portable mode: dragging the CCTV or Street Level - Street Level: a street-level imagery layer modelled on the iD editor's photo
panel by its header lifts it out of the right rail into a floating window overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0
that resizes from any edge or corner, remembers its position and size across imagery). One right-rail panel holds a chip per provider, shared 360°/flat and
reloads, and snaps back on a header double-click. A panel can also ask to captured-since filters, and an embedded viewer with EXPAND, FIT/FILL and
dock when it is collapsed while floating (Street Level does). 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.
- Street Level: a provider-neutral street-level imagery layer modelled on the iD editor's photo overlay. One panel in the right rail carries a chip per imagery provider, shared 360°/flat and captured-since filters, coverage drawn on the globe from orbit down to a single street, image cones per sequence, and one embedded viewer whose adapter follows the provider of the open image; each active provider credits its imagery on the globe, and share links carry the provider switches and the filter. Mapillary is the first provider (free client token; CC BY-SA 4.0 imagery); Google Street View, KartaView and Panoramax are next. The panel is kept simple: the viewer opens at the top so the photo needs no scrolling, the header pill and the provider chips switch the layer, and SINCE is a slider. FOLLOW (the globe camera following the street-level view) is available on the Google 3D map only. - 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
- Street Level draws each imagery source in one colour, everywhere it appears: its PROVIDERS chip, coverage lines, overview points, image cones and a single legend swatch. Mapillary is green (no more age dimming or magenta panoramas; 360° cones are rings, and the 360°/FLAT filter still separates them); Panoramax will be purple and Google Street View blue. it again. The position persists across reloads.
- Street Level coverage no longer floats over trees and bridges on the Google 3D map. At street zoom, sequence lines, image cones and the position marker are placed on the bare earth from the terrain service (with a 2 m lift) instead of being draped on the top of the photoreal mesh, so buildings and tree canopies hide what is behind them; other maps and higher views keep draped lines. Opening a photo no longer flies the camera underground when the Google tiles under it have not loaded yet. Near the camera the placement is refined against the rendered Google 3D surface, so lines also follow freeways in trenches and steep streets, and stay on the road under trees.
- Street Level loads the coverage you are looking at in tilted views. Tiles are ranked along the line of sight, from the ground under the camera to the ground at the centre of the screen, and screen rays meet the ground at its real height (Denver is 1,600 m up), so the streets in front of the camera are no longer dropped for tiles near the horizon. On Google 3D, lines that are placed on the bare earth no longer fall back to draping over roofs when their finer points cross grid cells the coarse ones skipped.
- Street Level names a Mapillary token that Mapillary refuses: the panel reads KEY REJECTED and says to replace `MAPILLARY_CLIENT_TOKEN` in Provider Settings, instead of a raw "HTTP 403". The proxy stops asking Mapillary on every camera move after a refusal (for that token) or a rate limit (until its `Retry-After`), and the panel clears the message when the layer is switched off.
- Street Level polish from review: whole-earth coverage dots on the far side of the globe no longer show through it; a 360° or flat filter keeps a dense tile's matching lines instead of losing them to the per-tile cap; lines that lost their ground heights are cast again rather than left draped; a selection made while lines rebuild is applied once they are ready; hovering picks at most every 120 ms; Esc shrinks the expanded viewer wherever focus is, and closing a photo leaves focus in the panel; switching the layer off and on during the first photo load no longer leaves the viewer stuck. Portable panels lift only after a real mouse or pen drag, never on a click or a touch swipe. The MapillaryJS stylesheet now loads with the viewer instead of on every page (about 85 KB less render-blocking CSS). The tile proxy's disk cache is swept of expired tiles and capped at 1 GiB, keeps a cached tile's real age in memory, and refuses zoom levels the app never requests.
- Street Level, second review round: the panel no longer reopens itself on every page load (only a user, voice or tool switch-on or an opening photo opens it, and those automatic opens are never stored); a floating panel is pulled back on-screen when its content grows or the window shrinks, and its header can never be dropped under the command dock or the voice pill; the expanded photo hides in Clean View, recording and the cockpit; closing a photo stops sequence playback; a failed sequence load can be retried and leaves no stale image cones. The tile proxy refuses cross-site requests and has a per-IP limit, runs at most six upstream fetches at once, stops a response past its size cap as it streams, counts empty tiles against its caches and answers other upstream refusals with 502. The panel's collapsed state now travels in share links (`ui` token `t`).
- Street Level, third review round: closing a photo, switching the layer off or turning FOLLOW on stops the camera flight toward it (only while that flight is still Street Level's); a slow nearest-photo lookup can no longer replace a newer choice; distances, line densification and mesh sampling go the short way round the date line; image cones and the photo marker no longer show through the globe; a selection change recolours the previous zoom's lines still on screen; mesh sampling keeps to its time budget (a persistent priority queue, work only within range) and moves only the affected cones in place; the expanded viewer is a real modal (the rest of the app is inert while it shows); the tile proxy follows redirects only within the Mapillary tile origin; CI checks the real Mapillary routes.
- Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera - Add Norway's road cameras from Statens vegvesen as a CCTV pack. The camera
list is one keyless GeoJSON request to the agency's OGC API view of its list is one keyless GeoJSON request to the agency's OGC API view of its
@@ -458,7 +453,6 @@
Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347). Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347).
Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351). Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351).
- New Fire Perimeters layer (Events group): live NIFC WFIGS interagency - New Fire Perimeters layer (Events group): live NIFC WFIGS interagency
wildfire incident perimeters as ground-clamped polygons with a wildfire incident perimeters as ground-clamped polygons with a
containment-colored fire line, refreshed every 5 minutes from the public containment-colored fire line, refreshed every 5 minutes from the public
@@ -735,6 +729,7 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
### Other changes ### Other changes
- Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps. - Add ECMWF IFS model selection to Wind (#464, thanks @beneduzi), with model-scoped forecast-step caches, cancellation of replaced requests, and separate issue/valid timestamps.
- Add bounded Director feature actions with accessible controls, explicit camera/layer admission and cancellation; restore pack geometry on same-shot seek. Preserve existing scenes and content attribution. - Add bounded Director feature actions with accessible controls, explicit camera/layer admission and cancellation; restore pack geometry on same-shot seek. Preserve existing scenes and content attribution.
@@ -1543,3 +1538,4 @@ represent previously published GitHub Releases.
## [0.1.0] — 2026-02-09 ## [0.1.0] — 2026-02-09
- Initial project version. - Initial project version.
+1 -2
View File
@@ -20,8 +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" |
| **OpenStreetMap ALPR camera locations** (including DeFlock community mapping) | Optional mapped automatic license-plate-reader camera layer | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/); commercial use permitted with applicable attribution and database share-alike obligations | [© OpenStreetMap contributors](https://www.openstreetmap.org/copyright); [DeFlock](https://deflock.org) community mapping | | **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level layer, Mapillary provider: coverage lines, image cones and the embedded viewer | Imagery and derived data [CC BY-SA 4.0](https://www.mapillary.com/terms); API use under the Mapillary Platform Terms with a client token; coverage tiles are cached locally for 24 h | "Street Level: imagery © Mapillary contributors, CC BY-SA 4.0" — shown on the globe while the provider is on, and by the embedded viewer's attribution component |
| **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 |
+27 -33
View File
@@ -285,28 +285,28 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station
Twenty 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 |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
| 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search | | 🗺️ **Map Stack** | Esri satellite imagery, Google Photorealistic 3D, OSM, plus additional ion-hosted stacks | Esri / Google / Ion / OSM | 🟢 Esri satellite + OSM · 🟡 ion-hosted Google 3D + world terrain · 🔴 direct Google + place search |
| ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) | | ✈️ **Live Flights** | 11,000+ live aircraft + route history | OpenSky + adsb.lol | 🟢 (🟡 optional for more polling credits) |
| 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 | | 🎖️ **Military Flights** | ADS-B military traffic in amber | adsb.lol | 🟢 |
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 | | 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 | | 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 | | 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
| 🚗 **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 imagery from several providers behind one panel, modelled on the iD editor's photo overlay: a chip per provider, shared 360°/flat and captured-since filters, coverage drawn onto the streets from orbit down to a single road, image cones per sequence, and an embedded viewer with a **CAMERA FOLLOWS VIEW** mode. Mapillary ships in this build; Google Street View, KartaView and Panoramax are planned | Mapillary | 🟡 Mapillary client token | | 📷 **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 | 🟢 |
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | | 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
| 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 | | 🔥 **Active Fires** | Live NASA FIRMS detections, trailing 24h | NASA FIRMS | 🟡 |
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) | | 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🟡 optional token raises the allowance) |
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | | 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 | | 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 |
| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 | | 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 |
| 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 | | 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 |
**Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view. **Weather, keyless.** **Wind** animates 10 m forecast flow from NOAA GFS or ECMWF IFS, with an optional color field and a reading at map center. **Rain radar**, **Satellite clouds** and **Lightning density** are observations on one history timeline in the **WEATHER** panel (step back, **Play**, **Latest**); **Cyclone advisories** draw NHC and CPHC positions, forecast tracks and cones. On Google 3D Tiles the observed layers float above the city as translucent shells, sharpened around your view.
@@ -334,19 +334,13 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
## 📷 Street Level ## 📷 Street Level
> One panel for street-level imagery from any provider. This build registers **Mapillary**, which needs a free **Mapillary client token** in POWER UP; Google Street View, KartaView and Panoramax are the next providers. > Street-level photos from any provider in one panel. This build ships **Mapillary**, which needs a free client token.
Setup for Mapillary: **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**.
1. **Mapillary client token (free).** Sign in at [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), choose **Register application**, give it any name and website, tick **Read** access, and copy the **Client Token** (it starts with `MLY|`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart `npm run dev`. **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.
Without the token the STREET LEVEL panel reads **KEY REQUIRED**, the Mapillary chip turns amber and the controls stay greyed out. 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`.
Turn on **Street Level** under DATA LAYERS → Cameras, or open the STREET LEVEL strip in the right rail and press the **ON/OFF** pill in its header. The **PROVIDERS** row has one chip per registered provider, in that source's colour (Mapillary green; Panoramax will be purple and Google Street View blue): lighting a chip turns its imagery on (and the layer with it), and darkening the last lit chip turns the layer off. The **IMAGERY** filter (ALL / 360° / FLAT) and the **SINCE** slider (any date on the left, the last month on the right, with the cut-off date spelled out) apply to every provider at once. Each source draws in its one colour, so Mapillary's coverage is green ground-hugging lines, overview points and image cones (360° cones are rings); click a line for its image cones and a cone to open the photo. The photo opens at the top of the panel, so it is on screen without scrolling. The bar above it has **EXPAND** (most of the screen), **FIT** (the whole photo, with dark bars where its shape differs from the frame) / **FILL** (edge to edge, cropped) and **FOLLOW** (the globe camera moves wherever the street-level view looks; available on the Google 3D map only, since flat imagery has nothing to stand among); the caption links to the image on the provider's own site. Mapillary blurs faces and licence plates before publishing imagery, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while the provider is on.
The panel is also a window: drag its header to lift it out of the rail, and resize it from any edge or corner. A larger window gives the photo the room; only the settings below it scroll. Double-clicking the header, collapsing the window, or pressing SHRINK after EXPAND puts it back in the rail at its default size, so it never stays parked over the globe.
Share links carry the layer's provider switches and filter (`0.m.0` turns Mapillary off, `0.p.p` keeps only panoramas, `0.s.365` shows the last year). Adding a provider means implementing the contract in `src/layers/streetLevel/registry.js` and registering it in `src/app/layers/streetLevel.js`; the chip, credit and share bit follow.
## 🎖️ Field Missions ## 🎖️ Field Missions
@@ -429,7 +423,7 @@ names it.
### Choose the capabilities you want ### Choose the capabilities you want
Eight keys. Five have a free tier, and the three 🔴 ones are metered: Seven keys. Five have a free tier, and the two 🔴 ones are metered:
| | Key | Why | Get it | | | Key | Why | Get it |
| --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -439,7 +433,7 @@ Eight keys. Five have a free tier, and the three 🔴 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 on the globe (the Street Level layer's Mapillary provider). 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 | | 🟡 | **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 |
![Diving from city-scale live congestion straight into an intersection's public camera](docs/media/05-traffic-to-cctv.gif) ![Diving from city-scale live congestion straight into an intersection's public camera](docs/media/05-traffic-to-cctv.gif)
+2 -2
View File
@@ -28,7 +28,7 @@ These are designed to be used directly in the browser (like a Mapbox public toke
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers 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 — the Street Level layer's Mapillary provider: street-level imagery and coverage tiles) — used by the embedded MapillaryJS viewer and for Graph API lookups client-side. Mapillary client tokens are designed for browser use and can be scoped per application in the Mapillary developer dashboard; the dev server uses the same token for cached coverage-tile fetches. 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 three 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.
@@ -72,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`), quota-bearing (`/api/mapillary/status`, `/api/mapillary/tiles`, whose tile route also has a per-IP limit) 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
+1 -2
View File
@@ -81,8 +81,7 @@ 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',
// MapillaryJS is loaded on demand the first time a street-level // Loaded on demand when the first street-level image opens.
// image is opened.
'mapillary-js', 'mapillary-js',
], ],
}, },
+56 -102
View File
@@ -1474,10 +1474,10 @@ This is the current runtime/source-of-truth snapshot for the project.
> samples, `enabling` throughout, panel non-numeric) and across a failing > samples, `enabling` throughout, panel non-numeric) and across a failing
> one. Its `status: 'idle'` is not what saves it — the lifecycle is; the > one. Its `status: 'idle'` is not what saves it — the lifecycle is; the
> module has no `loading` status at all. > module has no `loading` status at all.
> Any new dependency that can be slow must report `loading` and `lastUpdate` > Any new dependency that can be slow must report `loading` and `lastUpdate`
> honestly for the contract to hold, and must be pinned against the shape its > honestly for the contract to hold, and must be pinned against the shape its
> own module really returns — a fixture that invents a status the module cannot > own module really returns — a fixture that invents a status the module cannot
> emit guards nothing (that is exactly how a hole here survived a green suite). > emit guards nothing (that is exactly how a hole here survived a green suite).
> - **A held ground snap is dropped when measured ground contradicts it > - **A held ground snap is dropped when measured ground contradicts it
> (2026-08-23):** the WARM hold described below answers on DISTANCE travelled, > (2026-08-23):** the WARM hold described below answers on DISTANCE travelled,
> which is only a proxy for whether the value still describes the ground. A > which is only a proxy for whether the value still describes the ground. A
@@ -2928,114 +2928,68 @@ 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 📷 | Provider-neutral street-level imagery; Mapillary registered (vector tiles + Graph API + MapillaryJS) | `src/layers/streetLevel/` (core) + `src/layers/streetLevel/providers/mapillary/` 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 coverage tiles, cached 24 h) | | 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 lives in the right context rail next to CCTV as an ordinary Street Level is a collapsible right-rail panel (`#street-level-panel`, layer
collapsible panel (`#street-level-panel`, layer share token `0`, option owner token `0`, option owner `street-level`, panel `ui` token `t`) that starts
`street-level`, panel `ui` token `t`) and starts collapsed. It opens itself collapsed. It opens itself only when the user, voice or a tool switches the
only when the user (or voice, or a tool) switches the layer on or a photo layer on or a photo opens, never on a restore, and those opens are not stored.
opens, never on a saved-state restore, and never stores those automatic opens. It is modelled on the iD editor's photo Like the iD editor's photo overlay, it has one chip per registered provider,
overlay: one panel, a PROVIDERS chip per registered imagery provider, shared shared 360°/flat and captured-since filters (relative days, so a link keeps its
360°/flat and captured-since filters (stored as relative days so a link keeps meaning), one viewer host and one on-globe credit per active provider. Each
its meaning), one viewer host, and one on-globe credit per active provider. provider draws in one colour (`PROVIDER_COLORS` in
Each source has one colour (`PROVIDER_COLORS` in `src/layers/streetLevel/policy.js`); 360° cones are rings and the selected
`src/layers/streetLevel/policy.js`: Mapillary green `#05cb63`, Panoramax sequence is GEV cyan. Share options: `m` (Mapillary), `p` (`a`/`p`/`f`
purple, Google Street View blue), used for its chip, coverage lines, overview panoramas), `s` (since, days). Only Mapillary is registered. Without
points, image cones and its single legend swatch; 360° cones are rings, and `MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are
the selected sequence is GEV cyan. Share options: `m` (Mapillary on/off), `p` (a/p/f panorama mode), `s` disabled. With it, coverage draws as z0–5 overview points from orbit and z11–14
(since, days). Only Mapillary is registered in this build. Without sequence lines below 60 km; the proxy strips the unused `image` layer from z14
`MAPILLARY_CLIENT_TOKEN` the header reads KEY REQUIRED, the Mapillary chip tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
turns amber and the controls stay disabled; with it, coverage draws as requests for a tile.
ground-clamped sequence lines (z0–5 overview points from orbit, z11–14
sequences below 60 km) and the proxy strips the unused `image` point layer
from z14 tiles in transit (12 MB → ~80 KB). Concurrent requests for one tile
share a single upstream fetch that owns its own abort controller: a request
that is abandoned leaves at once without cancelling the others, and the fetch
is cancelled only when its last waiter leaves.
On Google 3D (`photoreal`) at street zoom the layer switches its surface mode On Google 3D (`photoreal`) at street zoom (in below 1,400 m above ground, out
from `draped` to `terrain` (`getUIState().surface`; in below 1,400 m above the above 1,800 m) the surface mode switches from `draped` to `terrain`: draped
ground, out above 1,800 m, so the mode does not flap). Draped ground polylines lines land on roofs and tree tops, so `groundCast.js` instead places lines,
and clamped billboards land on the top of the photoreal mesh, so on a street cones and the marker 2 m above the bare earth from `/api/terrain/heights`, and
under trees or an overpass the coverage appears to float at roof height. In the mesh hides what is behind buildings and trees. A tile is drawn draped until
terrain mode `src/layers/streetLevel/groundCast.js` places lines, cones and the its heights arrive; a geoid fallback leaves it draped. Within 900 m of the
position marker on the bare earth from the terrain service camera, `meshSampler.js` samples the rendered surface (`scene.sampleHeight`,
(`/api/terrain/heights`) plus 2 m, and the mesh hides whatever is behind ~11 m cells, nearest first, 6 ms idle budget) so lines also follow trenches,
buildings and trees. Heights are sampled on the ground-floor grid (0.001°, steep streets and roads under trees. Roads on elevated decks are still drawn at
~111 m), shared with other layers' cache, and interpolated bilinearly; a z14 ground level. Framing a photo ignores mesh samples far below the bare earth
tile needs at most a few hundred lookups, sent once per tile and aborted when (unloaded tiles).
the tile is dropped. A tile is drawn draped first and swapped for its cast
lines when the heights arrive; a geoid fallback (terrain proxy down) counts as
unknown and leaves the lines draped. The bare-earth grid cannot see a
freeway in a trench, a steep street between grid corners or the road under a
tree, so `src/layers/streetLevel/meshSampler.js` then samples the rendered
Google 3D surface (`scene.sampleHeight`, overlays excluded) once per ~11 m
cell within 900 m of the camera, nearest first, in idle time within a 6 ms
budget, and `refineHeights` blends it in: mesh up to 3 m above bare earth is
the road and is followed 1 m above it; a higher mesh (canopy, deck, roof) takes
the road's offset from bare earth carried across from the road samples on
either side; anything else stays bare earth + 2 m. Tiles touched by new
samples are redrawn one per idle slice with the same no-blink swap. Roads on
elevated decks are still drawn at ground level under the deck. Cones and the marker keep drawing over
buildings so they stay clickable. Framing or following a photo also checks the
sampled mesh height against the bare earth: `sampleHeight` can return values
kilometres underground before the tiles under the photo have loaded, and such
a sample is ignored.
The panel has no separate ON/OFF or nearest-photo buttons. The header pill is The header pill is the layer switch. With one provider its chip is a layer
the layer switch, and a provider chip is lit only while the layer is on: with switch too; with several, darkening the last lit chip turns the layer off. The
one provider it is the layer switch too, and with several, darkening the last viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the
lit chip turns the layer off while leaving that provider switched on. The image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and
viewer sits first, under the header, with its tools (EXPAND, FIT/FILL, FOLLOW, stops when the stack changes. SINCE is a stepped slider whose readout names the
close) in a bar above the image so the provider's own overlays stay clear; cut-off date. The panel is portable (`street-level-panel` spec, minimum
the settings follow. FOLLOW is offered only while the map stack is Google 3D 320 × 280, `dockOnCollapse`): a floating window gives spare height to the
(`photoreal`): the layer takes the application map stack controller through viewer, and collapsing it, double-clicking the header or SHRINK after EXPAND
`attachMapStackController`, subscribes to stack switches, disables the button docks it. At phone width (≤720 px) the viewer height is derived from the rail
with a MAP SOURCE hint on other stacks, and stops following when the stack band so the whole photo fits.
leaves Google 3D. SINCE is a stepped range input (any date, 10, 5, 3, 2 and
1 years, 6, 3 and 1 months) whose readout names the cut-off date. The panel is
portable (`street-level-panel` spec in `PanelPositionControls`, minimum
320 × 280, `dockOnCollapse`): in a resized window the viewer takes the spare
height and only `.sl-settings` scrolls, and a user's collapse, a header
double-click or SHRINK after EXPAND docks it at its default size (cockpit
entry, restores and the Cyber accordion never dock a floating window). At
phone width (≤720 px) the rail is the band between mid-screen and the credit
line, so the viewer's height is derived from that band instead of 16:10 and
the whole photo and caption fit without scrolling.
Street-level providers implement the contract documented in Providers implement the contract in `src/layers/streetLevel/registry.js`. The
`src/layers/streetLevel/registry.js`: a definition (`id`, `name`, `label`, core routes clicks by pick prefix, swaps viewer adapters in the one host,
`requiresKeyId`, `pickPrefix`, `colors` (`{coverage}`, the source's one manages each provider's credit and fans the filter out to every provider. To
colour), `credit`, `capabilities`, `externalUrl`, `create`) whose `create(context)` returns an instance with add a provider: implement the definition, register it in
`status`, `init`/`activate`/`deactivate`/`destroy`, `refreshCoverage`,
`setFilter`, optional `setSurface`, `coverageStats`, `handlePick`, optional
sequence selection,
`nearestImage`, and a viewer adapter (`mount`/`open`/`close`/`unmount`/
`resize`/`onPose`) that emits a provider-neutral pose. The core routes clicks
by pick prefix, swaps viewer adapters in the one host, adds and removes each
provider's Cesium credit, and fans the filter out to every provider. The
context also carries `getSurface()` and `groundCaster` (bare-earth heights),
so a provider can draw on the ground in terrain mode. Adding a
provider: implement the definition, register it in
`src/app/layers/streetLevel.js`, add its boolean option to the `street-level` `src/app/layers/streetLevel.js`, add its boolean option to the `street-level`
group in `src/data/layerState.js` and its modules to group in `src/data/layerState.js` and its modules to
`scripts/package-boundaries.json`; the chip, credit and legend follow. Once a `scripts/package-boundaries.json`. Once a keyless provider registers, the
keyless provider registers, `requiresKeyId` on the layer becomes null and the layer's `requiresKeyId` becomes null and the key gate moves to the chips.
key gate moves to the chips, by design.
Street Level has two browser gates. `npm run qa:street-level -- --url <dev Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
server>` is the live run: with `MAPILLARY_CLIENT_TOKEN` it covers everything runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
up to opening, expanding and closing a photo. `npm run qa:street-level:fixtures -- --url <server>` answers every Mapillary request
qa:street-level:fixtures -- --url <dev server>` is hermetic: the page's from fixtures (`providers/mapillary/coverageFixture.mjs`,
Mapillary status and coverage-tile requests are answered from generated `scripts/fixtures/street-level/`), so it needs no network and covers the whole
fixtures (`providers/mapillary/coverageFixture.mjs`), so it needs no token or photo flow. CI runs the fixture gate and `qa:panel-resize`
Mapillary network, skips only the photo steps, and asserts that the 360° and against a production build served by `vite preview` with a dummy token.
SINCE filters actually remove sequences. CI runs the fixture gate and
`qa:panel-resize` against a key-less dev server.
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
@@ -4465,7 +4419,7 @@ 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>`. The rails lay docked panels out adaptively and write nothing for them; a portable panel (CCTV, Street Level) persists `{ left, top, width, height, floating: true }` under that key once a header drag lifts it out of the rail, and a header double-click removes the record again. Double presses are detected in the header's `pointerdown` handler (two presses within 400 ms and 6 px, no drag between), because that handler's `preventDefault()` suppresses the mouse events a native `dblclick` needs. A spec with `dockOnCollapse` (Street Level) also docks when collapsed while floating. The rail's startup move of panels into `#right-context-rail` leaves a restored floating window's geometry alone. 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 unless they describe a portable panel's floating window; 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`.
+2 -3
View File
@@ -58,9 +58,8 @@ Related keys (current versions):
`'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>` — written only for a - Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — written only for a
portable panel (CCTV, Street Level) lifted out of its rail, as floating portable panel (CCTV, Street Level). Deleting it, or double-clicking
`{ left, top, width, height, floating: true }`. Deleting it, or double-clicking the panel header, docks the panel again.
the panel header, returns that panel to the rail; docked panels store nothing.
- CCTV calibration: `godsEyeView.cctv.calibration.v2` - CCTV calibration: `godsEyeView.cctv.calibration.v2`
--- ---
@@ -1,32 +1,14 @@
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
/** /**
* Mapillary Graph API and image CDN, answered from fixtures, so the Street * Mapillary Graph API and image CDN answered from fixtures, covering every
* Level gate's photo flow runs with no network and no real token (the CI * request the provider and MapillaryJS make while opening, stepping and
* mode). It covers every request the app's provider and the embedded * closing a photo, so the gate needs no network and no real token.
* MapillaryJS 4 viewer make while opening, stepping and closing a photo:
* *
* provider (src/layers/streetLevel/providers/mapillary/source.js) * No `merge_cc` or `sfm_cluster` means MapillaryJS never asks for a mesh or
* GET graph /images?lat&lng&radius&limit&fields nearest lookup * cluster, and empty S2 cells mean no spatial edges, which the gate does not
* GET graph /images?sequence_ids&fields&limit a sequence's cones * use. The photo is generated for this repository, so it has no third-party
* MapillaryJS GraphDataProvider (node_modules/mapillary-js) * licence.
* GET graph /images?image_ids&fields image ents (core + spatial)
* GET graph /images?s2&fields core images of an S2 cell
* GET graph /image_ids?sequence_id a sequence's image ids
* GET graph /{imageId}/tiles?z&fields image tiles (imageTiling)
* GET thumb_1024_url / thumb_2048_url the photo itself
* and the CORS preflight of each Graph call (MapillaryJS sends an
* Authorization header).
*
* MapillaryJS never asks for a mesh or an SfM cluster here: the fixtures have
* no `merge_cc` (an unmerged image gets an empty mesh without a request) and
* no `sfm_cluster` url, and the spatial component that reads clusters is off.
* S2 cells answer empty: the viewer then builds no spatial edges, which the
* gate does not use (it steps along the sequence through the cones).
*
* The photo (street-640x320.jpg) is generated for this repository (a sky and
* ground gradient with posts; scripts/fixtures/street-level), so it carries
* no third-party licence.
*/ */
const DAY_MS = 86_400_000; const DAY_MS = 86_400_000;
@@ -40,12 +22,12 @@ export const PHOTO_LINE = Object.freeze({
}); });
/** Metres between consecutive photos on the line (≥ the 3 m cone thinning). */ /** Metres between consecutive photos on the line (≥ the 3 m cone thinning). */
export const PHOTO_SPACING_M = 30; export const PHOTO_SPACING_M = 30;
/** Host the fixture thumbnails are served from (answered, never reached). */ /** Fixture thumbnail host (answered, never reached). */
export const THUMB_HOST = 'qa-fixture.mapillary.com'; export const THUMB_HOST = 'qa-fixture.mapillary.com';
const METRES_PER_DEG_LAT = 110_540; const METRES_PER_DEG_LAT = 110_540;
/** Approximate ground distance in metres, as the app's metresBetween. */ /** Ground distance in metres, as the app's metresBetween. */
export function metresApart(a, b) { export function metresApart(a, b) {
const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180; const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180;
return Math.hypot( return Math.hypot(
@@ -55,8 +37,7 @@ export function metresApart(a, b) {
} }
/** /**
* The fixture photos along PHOTO_LINE, south to north: every third one a * Photos along PHOTO_LINE, south to north; every third one is a panorama.
* 360° panorama, the rest flat, all captured a month before `now`.
* @returns {Array<{id: string, lon: number, lat: number, isPano: boolean, capturedAt: number, compassAngle: number}>} * @returns {Array<{id: string, lon: number, lat: number, isPano: boolean, capturedAt: number, compassAngle: number}>}
*/ */
export function photoImages(now = Date.now()) { export function photoImages(now = Date.now()) {
@@ -112,7 +93,7 @@ function imageRecord(image) {
}; };
} }
/** Pick the requested fields (plus id), as the Graph API answers. */ /** The requested fields plus id, as the Graph API answers. */
function withFields(record, fields) { function withFields(record, fields) {
if (!fields) return record; if (!fields) return record;
const out = { id: record.id }; const out = { id: record.id };
@@ -136,16 +117,12 @@ const json = (status, body) => ({
}); });
let photoBytes = null; let photoBytes = null;
/** The checked-in fixture photo, read once. */
export function photoJpeg() { export function photoJpeg() {
photoBytes ??= readFileSync(new URL('./street-640x320.jpg', import.meta.url)); photoBytes ??= readFileSync(new URL('./street-640x320.jpg', import.meta.url));
return photoBytes; return photoBytes;
} }
/** /** Name a call by its parameter names only, so the token is never echoed. */
* The query parameters that name a Graph call (not their values), so a run
* can report which calls it answered without echoing the access token.
*/
export function describeCall(method, url) { export function describeCall(method, url) {
const keys = [...url.searchParams.keys()] const keys = [...url.searchParams.keys()]
.filter((key) => key !== 'access_token') .filter((key) => key !== 'access_token')
@@ -157,11 +134,10 @@ export function describeCall(method, url) {
} }
/** /**
* Answer one Mapillary request from fixtures. * 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 * @param {{method: string, url: string, images?: ReturnType<typeof photoImages>}} request
* @returns {{status: number, contentType?: string, headers?: object, body: string|Buffer, known: boolean}} * @returns {{status: number, contentType?: string, headers?: object, body: string|Buffer, known: boolean}}
* `known` is false for a request no fixture covers (answered 404); the gate
* fails on those so a new MapillaryJS call cannot pass unnoticed.
*/ */
export function answerMapillaryRequest({ method, url, images }) { export function answerMapillaryRequest({ method, url, images }) {
const address = new URL(url); const address = new URL(url);
+8 -20
View File
@@ -1,19 +1,12 @@
/** /**
* Evidence the browser QA gates (qa-street-level, qa-panel-resize) collect * Evidence the browser QA gates collect while they run and save under
* while they run and save when they fail: every page's console (last 200 * `qa-artifacts/<gate>/` when they fail (CI uploads that directory). Tokens
* messages), failed requests and HTTP errors, Cesium render-loop errors (the * and keys are masked before anything is printed or saved.
* "An error occurred while rendering" console line and `scene.renderError`)
* and, on failure, a full-page screenshot of every open page plus the Street
* Level UI state, under `qa-artifacts/<gate>/` (CI uploads that directory).
*
* Keys never reach the evidence: URLs lose their `access_token`, `key` (and
* similar) values, and `MLY|…` client tokens and Google `AIza…` keys are
* masked wherever they appear.
*/ */
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
/** How many console messages and failed requests each page keeps. */ /** Console messages and failed requests kept per page. */
export const EVIDENCE_LIMIT = 200; export const EVIDENCE_LIMIT = 200;
/** Cesium's console line when the render loop throws (CesiumWidget). */ /** Cesium's console line when the render loop throws (CesiumWidget). */
@@ -59,7 +52,6 @@ export function hookRenderErrors(page) {
}); });
} }
/** Render errors the in-page hook saw in the current document. */
export async function readRenderErrors(page) { export async function readRenderErrors(page) {
try { try {
return await page.evaluate(() => window.__qaRenderErrors ?? []); return await page.evaluate(() => window.__qaRenderErrors ?? []);
@@ -69,9 +61,8 @@ export async function readRenderErrors(page) {
} }
/** /**
* Watch one page: console, page errors, failed requests and HTTP errors. * Watch one page's console, errors and failed requests. `errors` is shared
* `errors` (shared across pages) receives page errors, render-loop console * across pages; `isError(text)` adds console lines a gate treats as errors.
* errors and the extra `isError(text)` console lines a gate cares about.
* @returns {{name: string, page: object, console: Array<object>, failed: Array<object>, renderErrors: Array<string>}} * @returns {{name: string, page: object, console: Array<object>, failed: Array<object>, renderErrors: Array<string>}}
*/ */
export function watchPage(page, { name, errors, isError = () => false }) { export function watchPage(page, { name, errors, isError = () => false }) {
@@ -115,7 +106,6 @@ const withTimeout = (promise, ms, label) =>
), ),
]); ]);
/** The Street Level layer's UI state on a page, or why it could not be read. */
function streetLevelState(page) { function streetLevelState(page) {
return page.evaluate(() => { return page.evaluate(() => {
const module = const module =
@@ -125,10 +115,8 @@ function streetLevelState(page) {
} }
/** /**
* Save the evidence of a failed run under `dir`: per page a full-page * Save a screenshot and JSON record per page, plus the error. A page that
* screenshot, the Street Level UI state, the console, failed requests and * cannot be captured records why instead.
* render errors; plus the error itself. Never throws: a page that cannot be
* captured records why instead.
* @returns {Promise<string>} the directory written * @returns {Promise<string>} the directory written
*/ */
export async function saveFailureArtifacts({ dir, browser, monitors, error }) { export async function saveFailureArtifacts({ dir, browser, monitors, error }) {
+12 -33
View File
@@ -1,12 +1,8 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Prove the portable CCTV panel in a real browser: a header drag lifts it out * Browser QA for the floating CCTV panel: lift, resize from every handle,
* of the right rail, every resize handle moves only its own edges, minimum * minimum size, reload, and dock again. `--fail-on-retry` (or
* sizes hold, the window survives a reload and a header double-click snaps * QA_FAIL_ON_RETRY=1) fails on any header-press retry.
* it back and forgets the stored position. Page errors and Cesium render-loop
* errors fail the run; a failed run saves screenshots, console and failed
* requests under qa-artifacts/panel-resize/ (scripts/qa-browserEvidence.mjs).
* `--fail-on-retry` (or QA_FAIL_ON_RETRY=1) fails on any header-press retry.
*/ */
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
@@ -28,7 +24,6 @@ import {
export const PANEL_ID = 'cctv-panel'; export const PANEL_ID = 'cctv-panel';
export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`; export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`;
export const MIN_SIZE = { width: 300, height: 160 }; export const MIN_SIZE = { width: 300, height: 160 };
/** Where a failed run leaves its screenshots and logs. */
export const ARTIFACT_DIR = 'qa-artifacts/panel-resize'; export const ARTIFACT_DIR = 'qa-artifacts/panel-resize';
const VIEWPORT = { width: 1400, height: 900 }; const VIEWPORT = { width: 1400, height: 900 };
const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' }; const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' };
@@ -67,7 +62,7 @@ export function handlePoint(rect, dir) {
return { x, y }; return { x, y };
} }
/** Edges of `after` that moved although a resize in `dir` should have pinned them. */ /** Pinned edges that moved during a resize in `dir`. */
export function driftedEdges(before, after, dir, tolerance = 1.5) { export function driftedEdges(before, after, dir, tolerance = 1.5) {
const edges = (box) => ({ const edges = (box) => ({
left: box.left, left: box.left,
@@ -116,7 +111,6 @@ async function main() {
timeout: 90_000, timeout: 90_000,
}); });
await page.waitForSelector(`#${PANEL_ID} .panel-header`); await page.waitForSelector(`#${PANEL_ID} .panel-header`);
// The first-run dialog sits over the globe until a choice is made.
await page.evaluate(() => { await page.evaluate(() => {
document.querySelector('.first-run-explore')?.click(); document.querySelector('.first-run-explore')?.click();
}); });
@@ -152,11 +146,9 @@ async function main() {
stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`), stored: localStorage.getItem(`godsEyeView.v8.panelPos.${id}`),
}; };
}, PANEL_ID); }, PANEL_ID);
/** Wait until the CCTV box has held still for ten animation frames. */
/** /**
* Best effort: wait until the CCTV box (to the whole pixel) has held still * Best effort: wait for the box to hold still for half a second. The lift
* for half a second. The lift is checked against the box at pointerdown, * is checked against the box at pointerdown, so a timeout only costs time.
* so a rail that keeps nudging the panel only costs the wait, not the run.
*/ */
const settle = async () => { const settle = async () => {
try { try {
@@ -200,7 +192,6 @@ async function main() {
}, STORAGE_KEY); }, STORAGE_KEY);
await boot(); await boot();
// Expand CCTV so the panel has a body worth resizing.
await page.evaluate((id) => { await page.evaluate((id) => {
const panel = document.getElementById(id); const panel = document.getElementById(id);
if (panel.classList.contains('collapsed')) if (panel.classList.contains('collapsed'))
@@ -220,10 +211,7 @@ async function main() {
assert.equal(docked.floating, false); assert.equal(docked.floating, false);
assert.equal(docked.inRail, true); assert.equal(docked.inRail, true);
// Lift out: drag the header 200px left and 100px up. The window must lift // Names whatever above CCTV in the rail shifted it, for a retry note.
// from wherever the panel sits when the pointer goes down, so record that
// box in the page instead of trusting the earlier read.
// What sits above CCTV in the rail, to name whatever shifted it.
const railAbove = () => const railAbove = () =>
page.evaluate((id) => { page.evaluate((id) => {
const out = []; const out = [];
@@ -237,11 +225,8 @@ async function main() {
return out.join(' '); return out.join(' ');
}, PANEL_ID); }, PANEL_ID);
const aboveAtDock = await railAbove(); const aboveAtDock = await railAbove();
// Press only where the header really is, recording what the press landed // On CI a panel above CCTV grows after load and can shift the header out
// on. A press the rail shifted out from under the header (seen on the CI // from under the press; the helper retries only such a provable miss.
// runner: something above CCTV grows ~32 px after load) proves nothing
// about the drag, so it is retried; a press that landed in the header and
// did not lift fails at once (scripts/qa-panelDrag.mjs).
const attempt = await liftPanelByHeader(page, PANEL_ID, { const attempt = await liftPanelByHeader(page, PANEL_ID, {
dx: -200, dx: -200,
dy: -100, dy: -100,
@@ -289,7 +274,7 @@ async function main() {
assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it'); assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it');
const railExcludes = await page.evaluate((id) => { const railExcludes = await page.evaluate((id) => {
const rail = document.getElementById('right-context-rail'); const rail = document.getElementById('right-context-rail');
// Same rule as the rail: hidden panels (a layer that is off) take no room. // Same rule as the rail: hidden panels take no room.
const counted = [...rail.children].filter((panel) => const counted = [...rail.children].filter((panel) =>
panel.matches( panel.matches(
'[data-panel-id]:not(.panel-floating):not(.collapsed):not([hidden])', '[data-panel-id]:not(.panel-floating):not(.collapsed):not([hidden])',
@@ -313,15 +298,13 @@ async function main() {
); );
console.log('PASS: header drag lifts CCTV out of the rail'); console.log('PASS: header drag lifts CCTV out of the rail');
// Park the window near the top: the voice dock sits above every panel at // Park near the top: the voice dock would cover the south corners once
// the bottom of the screen and would otherwise cover the south corners // the loop below has grown the window.
// once the loop below has grown the window.
{ {
const header = await headerPoint(); const header = await headerPoint();
await drag(header, 0, 120 - header.y); await drag(header, 0, 120 - header.y);
} }
// Every handle moves only its own edges.
const limits = { const limits = {
minWidth: MIN_SIZE.width, minWidth: MIN_SIZE.width,
minHeight: MIN_SIZE.height, minHeight: MIN_SIZE.height,
@@ -332,7 +315,6 @@ async function main() {
const before = await readPanel(); const before = await readPanel();
const { dx, dy } = growthDelta(dir, 40); const { dx, dy } = growthDelta(dir, 40);
const point = handlePoint(before, dir); const point = handlePoint(before, dir);
// Name what the pointer lands on, so a covered handle is obvious.
const hit = await page.evaluate(({ x, y }) => { const hit = await page.evaluate(({ x, y }) => {
const node = document.elementFromPoint(x, y); const node = document.elementFromPoint(x, y);
if (!node) return 'nothing'; if (!node) return 'nothing';
@@ -350,7 +332,6 @@ async function main() {
} }
console.log('PASS: eight resize handles keep the opposite edge pinned'); console.log('PASS: eight resize handles keep the opposite edge pinned');
// Minimum size stops the moving edge.
let before = await readPanel(); let before = await readPanel();
await drag(handlePoint(before, 'se'), -2000, -2000); await drag(handlePoint(before, 'se'), -2000, -2000);
let after = await readPanel(); let after = await readPanel();
@@ -365,7 +346,6 @@ async function main() {
assert.deepEqual(driftedEdges(before, after, 'nw', 2), []); assert.deepEqual(driftedEdges(before, after, 'nw', 2), []);
console.log('PASS: minimum size holds from both corners'); console.log('PASS: minimum size holds from both corners');
// Reload restores the window.
const saved = await readPanel(); const saved = await readPanel();
const record = JSON.parse(saved.stored); const record = JSON.parse(saved.stored);
assert.equal(record.floating, true); assert.equal(record.floating, true);
@@ -376,7 +356,6 @@ async function main() {
near(restored[key], record[key], 2, `restored ${key}`); near(restored[key], record[key], 2, `restored ${key}`);
console.log('PASS: reload restores position and size'); console.log('PASS: reload restores position and size');
// Double-click the header to snap back.
const dock = await dockPanelByDoubleClick(page, PANEL_ID); const dock = await dockPanelByDoubleClick(page, PANEL_ID);
const snapped = await readPanel(); const snapped = await readPanel();
assert.equal( assert.equal(
+20 -61
View File
@@ -1,22 +1,10 @@
/** /**
* Header press-and-verify for the browser QA gates that lift a rail panel * Header press-and-verify for the QA gates that lift a rail panel into a
* into a floating window (header drag) and dock it again (header * floating window and dock it again. The rail animates panel heights, so a
* double-click). The rail animates panel heights, so a point read once can * point read once can be under another panel by the time the pointer goes
* be under another panel by the time the pointer goes down. Each attempt * down. Only a provable miss (a recorded press outside the header) is
* re-resolves the header, presses only where the header really is, records * retried, with a GitHub `::warning::`. With QA_FAIL_ON_RETRY=1 a miss the
* what the press landed on, and waits for the panel's actual state. Only a * panel's own movement cannot explain fails the run instead.
* provable miss (a recorded press that landed outside the header) is
* retried; a press in the header that did not lift or dock, and a press that
* recorded no pointerdown at all, are real failures.
*
* Every retry is logged with what led to it (the pre-press hit check, the
* stillness wait, the panel box at the hit check and at the press) and as a
* GitHub `::warning::`. With QA_FAIL_ON_RETRY=1 (or `--fail-on-retry`) a
* miss the panel's own movement cannot explain fails the run instead: if its
* box was the same at the hit check and at the press, the pointer missed a
* header that stood still. A panel whose content changed its layout between
* the two (a photo finishing loading, a live count wrapping a line) is
* retried with a warning that names the move.
*/ */
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -34,18 +22,15 @@ export function failOnRetryDefault(
} }
/** /**
* Whether an attempt proves nothing about the panel: it did not reach the * A failed attempt whose press landed outside the header. No recorded press
* wanted state and the recorded press demonstrably landed outside the * is a failure, not a miss: a retry would only hide it.
* header. No recorded press (`pressed` null) is not a miss but a failure:
* the pointer never reached the page, which a retry would only hide.
* @param {{done: boolean, pressed: {inHeader: boolean}|null}} attempt * @param {{done: boolean, pressed: {inHeader: boolean}|null}} attempt
*/ */
export function pressMissed(attempt) { export function pressMissed(attempt) {
return !attempt.done && attempt.pressed?.inHeader === false; return !attempt.done && attempt.pressed?.inHeader === false;
} }
/** The panel's box, rounded, as `left,top widthxheight`. */ /** Whether the panel's box changed by more than 2 px between two reads. */
/** Whether the panel's box changed (by more than 2 px) between two reads. */
export function panelMoved(before, after) { export function panelMoved(before, after) {
if (!before || !after) return false; if (!before || !after) return false;
return ['left', 'top', 'width', 'height'].some( return ['left', 'top', 'width', 'height'].some(
@@ -53,6 +38,7 @@ export function panelMoved(before, after) {
); );
} }
/** The panel's box, rounded, as `left,top widthxheight`. */
export function formatBox(box) { export function formatBox(box) {
if (!box) return 'unknown'; if (!box) return 'unknown';
const r = (value) => Math.round(value ?? NaN); const r = (value) => Math.round(value ?? NaN);
@@ -66,7 +52,6 @@ function panelBox(page, panelId) {
}, panelId); }, panelId);
} }
/** The centre of the panel's header title, read now. */
export function headerPoint(page, panelId) { export function headerPoint(page, panelId) {
return page.evaluate((id) => { return page.evaluate((id) => {
const title = document.querySelector(`#${id} .panel-header .panel-title`); const title = document.querySelector(`#${id} .panel-header .panel-title`);
@@ -75,7 +60,7 @@ export function headerPoint(page, panelId) {
}, panelId); }, panelId);
} }
/** Name the element at a viewport point, and whether it is the panel's header. */ /** Name the element at a point, and whether it is the panel's header. */
export function hitAt(page, point, panelId) { export function hitAt(page, point, panelId) {
return page.evaluate( return page.evaluate(
({ x, y, id }) => { ({ x, y, id }) => {
@@ -91,9 +76,8 @@ export function hitAt(page, point, panelId) {
} }
/** /**
* The header point to press, re-resolved until it lands on the header (or the * Re-resolve the header point until it hits the header or the wait runs out
* bounded wait runs out; the press then records the miss). `timedOut` says * (the press then records the miss).
* the hit check never passed; `box` is the panel's box at the last check.
*/ */
export async function headerTarget( export async function headerTarget(
page, page,
@@ -112,10 +96,8 @@ export async function headerTarget(
} }
/** /**
* Best effort: wait until the panel's box (to the whole pixel) has held still * Best effort: wait until the panel's box has held still for `stillMs`, so
* for `stillMs`, so a rail still animating heights does not move the header * the header does not move between the hit check and the press.
* between the hit check and the press. Never fails the run; resolves to
* whether the box held still in time.
*/ */
export async function waitForStill( export async function waitForStill(
page, page,
@@ -145,10 +127,7 @@ export async function waitForStill(
} }
} }
/** /** Record the next pointerdown's target and the panel's box at that moment. */
* Record what the next pointerdown lands on, with the panel's box at that
* moment, in `window.__qaPressBox` (null until a press arrives).
*/
export function recordNextPress(page, panelId) { export function recordNextPress(page, panelId) {
return page.evaluate((id) => { return page.evaluate((id) => {
window.__qaPressBox = null; window.__qaPressBox = null;
@@ -162,7 +141,6 @@ export function recordNextPress(page, panelId) {
top: r.top, top: r.top,
width: r.width, width: r.width,
height: r.height, height: r.height,
// What the press landed on, so a failed press says why.
target: `${target.tagName?.toLowerCase()}${target.id ? `#${target.id}` : ''}.${[...(target.classList || [])].join('.')}`, target: `${target.tagName?.toLowerCase()}${target.id ? `#${target.id}` : ''}.${[...(target.classList || [])].join('.')}`,
inHeader: Boolean(target.closest?.(`#${id} .panel-header`)), inHeader: Boolean(target.closest?.(`#${id} .panel-header`)),
}; };
@@ -172,12 +150,10 @@ export function recordNextPress(page, panelId) {
}, panelId); }, panelId);
} }
/** The press recorded by `recordNextPress`, or null. */
function readPress(page) { function readPress(page) {
return page.evaluate(() => window.__qaPressBox ?? null); return page.evaluate(() => window.__qaPressBox ?? null);
} }
/** Wait (bounded) until the panel is, or is not, a floating window. */
export async function waitForFloating( export async function waitForFloating(
page, page,
panelId, panelId,
@@ -200,10 +176,7 @@ export async function waitForFloating(
} }
} }
/** /** Two paints: by then every layout change an input caused is on screen. */
* Wait for the page to paint twice: pointer handlers apply synchronously, so
* by then every layout change an input caused is on screen.
*/
export function nextFrames(page) { export function nextFrames(page) {
return page.evaluate( return page.evaluate(
() => () =>
@@ -213,7 +186,6 @@ export function nextFrames(page) {
); );
} }
/** Press at `from`, move by (dx, dy) in two legs, release. */
export async function dragPointer(page, from, dx, dy, { steps = 6 } = {}) { export async function dragPointer(page, from, dx, dy, { steps = 6 } = {}) {
await page.mouse.move(from.x, from.y); await page.mouse.move(from.x, from.y);
await page.mouse.down(); await page.mouse.down();
@@ -223,10 +195,6 @@ export async function dragPointer(page, from, dx, dy, { steps = 6 } = {}) {
await nextFrames(page); await nextFrames(page);
} }
/**
* One line naming why a press is retried: what it landed on, the pre-press
* hit check, the stillness wait and the panel's box at the check and press.
*/
export function describeRetry( export function describeRetry(
panelId, panelId,
attempt, attempt,
@@ -243,10 +211,7 @@ export function describeRetry(
.join('; '); .join('; ');
} }
/** /** Retry `press` only while it provably misses the header. */
* Run `press` (one attempt at the header) until it reaches the wanted state,
* a press in the header fails to, or the retries run out.
*/
async function pressUntilDecided( async function pressUntilDecided(
page, page,
panelId, panelId,
@@ -293,8 +258,6 @@ async function pressUntilDecided(
/** /**
* Lift a docked panel into a floating window with a header drag. * 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}>} * @returns {Promise<{point: {x: number, y: number}, hit: {target: string, inHeader: boolean}, done: boolean, pressed: object|null}>}
* `done` is whether the panel is floating; `pressed` names what the press
* landed on and the panel's box at that moment.
*/ */
export function liftPanelByHeader( export function liftPanelByHeader(
page, page,
@@ -320,11 +283,7 @@ export function liftPanelByHeader(
); );
} }
/** /** Dock a floating panel with a header double-click; same result as lifting. */
* Dock a floating panel with a header double-click, waiting on the docked
* state rather than a fixed sleep. Same result shape as `liftPanelByHeader`;
* `done` is whether the panel is docked.
*/
export function dockPanelByDoubleClick( export function dockPanelByDoubleClick(
page, page,
panelId, panelId,
@@ -348,7 +307,7 @@ export function dockPanelByDoubleClick(
); );
} }
/** One line naming where an attempt pressed, for an assertion message. */ /** Where an attempt pressed, for an assertion message. */
export function describePress(attempt) { export function describePress(attempt) {
const { point, hit, pressed } = attempt; const { point, hit, pressed } = attempt;
const where = `pressed ${Math.round(point.x)},${Math.round(point.y)}`; const where = `pressed ${Math.round(point.x)},${Math.round(point.y)}`;
+37 -99
View File
@@ -1,30 +1,16 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* Browser QA for the Street Level layer against a running server: the * Browser QA for the Street Level layer against a running server. Run with
* panel's place in the right rail, the provider chips (which switch the
* layer), the keyless gate, coverage and its credit, the imagery filter and
* SINCE slider, a click on the globe that selects a sequence, the viewer
* (opened, stepped, expanded as a modal dialog, closed), the framing flight,
* the panel as a floating, resizable window, a reload that keeps the panel
* collapsed, and a phone layout. Run with
* `npm run qa:street-level -- --url http://localhost:4173`. * `npm run qa:street-level -- --url http://localhost:4173`.
* *
* `--fixtures` makes the run hermetic for Mapillary (the CI mode): coverage * `--fixtures` (the CI mode) answers every *.mapillary.com request from
* tiles, the Graph API, the image CDN and every other *.mapillary.com * fixtures, so nothing reaches Mapillary. The server still needs a token
* request the page makes are answered from fixtures (fixtureTile and * because the bundle carries it; CI uses a dummy one. Fixture runs also stage
* scripts/fixtures/street-level/mapillaryGraph.mjs), and nothing reaches * a rejected key and a keyless second server.
* Mapillary. The server still needs a token, because the browser bundle
* carries it; CI gives it a dummy, non-secret one, which never leaves the
* browser. Fixture runs add what a live run cannot stage: a key Mapillary
* rejects, and a second page whose server has no key. Every run first asks
* the server's real status route from Node, before the browser intercepts
* anything, so a server without the Mapillary routes fails.
* *
* Steps print `ok N …`, or `skip (reason) N …` when they cannot run here or * `--strict` fails on any skip not in STRICT_ALLOWED_SKIPS. `--fail-on-retry`
* only part of them could. `--strict` fails on any skip whose reason is not * (or QA_FAIL_ON_RETRY=1) fails on any header-press retry. A failed run saves
* in STRICT_ALLOWED_SKIPS. A failed run saves screenshots, the Street Level * evidence under qa-artifacts/street-level/.
* UI state, console and failed requests under qa-artifacts/street-level/.
* `--fail-on-retry` (or QA_FAIL_ON_RETRY=1) fails on any header-press retry.
*/ */
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
@@ -56,37 +42,28 @@ const PANEL_ID = 'street-level-panel';
/** Where a failed run leaves its screenshots and logs. */ /** Where a failed run leaves its screenshots and logs. */
export const ARTIFACT_DIR = 'qa-artifacts/street-level'; export const ARTIFACT_DIR = 'qa-artifacts/street-level';
/** Viewports every layout assertion runs at. */
export const VIEWPORTS = Object.freeze([ export const VIEWPORTS = Object.freeze([
{ width: 1440, height: 900 }, { width: 1440, height: 900 },
{ width: 1280, height: 800 }, { width: 1280, height: 800 },
]); ]);
/** Provider chips the panel must show, in order (one per registered provider). */ /** Provider chips the panel must show, in order. */
export const EXPECTED_PROVIDERS = Object.freeze(['mapillary']); export const EXPECTED_PROVIDERS = Object.freeze(['mapillary']);
/** /**
* Skip reasons `--strict` accepts, because no setup of this gate can avoid * Skips no setup can avoid: CI forks have no Google key, and live runs cannot
* them: * stage fixture-only cases. `no Mapillary key` is not accepted: CI's dummy
* - `no Google 3D`: the map stack needs a Google key (CI forks have none); * token runs the keyed flow, so a keyless strict run means that wiring broke.
* the steps still run their non-Google half.
* - `fixtures only`: staging a rejected key or a keyless second server needs
* the fixture interception; a live run cannot.
* Not accepted: `no Mapillary key`. CI starts the server with a dummy token
* so the whole keyed flow runs hermetically; a keyless strict run means that
* wiring broke and the photo flow silently did not run.
*/ */
export const STRICT_ALLOWED_SKIPS = Object.freeze([ export const STRICT_ALLOWED_SKIPS = Object.freeze([
'no Google 3D', 'no Google 3D',
'fixtures only', 'fixtures only',
]); ]);
/** Skips `--strict` rejects. */
export function strictViolations(skips) { export function strictViolations(skips) {
return skips.filter((skip) => !STRICT_ALLOWED_SKIPS.includes(skip.reason)); return skips.filter((skip) => !STRICT_ALLOWED_SKIPS.includes(skip.reason));
} }
/** Street Level's share-link token, as the ledger assigned it. */
const STREET_LEVEL_TOKEN = LAYER_STATE_REGISTRY.find( const STREET_LEVEL_TOKEN = LAYER_STATE_REGISTRY.find(
(entry) => entry.id === 'street-level', (entry) => entry.id === 'street-level',
).token; ).token;
@@ -94,7 +71,7 @@ const STREET_LEVEL_TOKEN = LAYER_STATE_REGISTRY.find(
/** The Street Level panel's token in the share link's `ui` panel state. */ /** The Street Level panel's token in the share link's `ui` panel state. */
export const PANEL_UI_TOKEN = 't'; export const PANEL_UI_TOKEN = 't';
/** Expected right-rail order once the layout controller has run. */ /** Right-rail order once the layout controller has run. */
export const RAIL_ORDER = Object.freeze([ export const RAIL_ORDER = Object.freeze([
'pp-toggles', 'pp-toggles',
'cctv-panel', 'cctv-panel',
@@ -104,7 +81,7 @@ export const RAIL_ORDER = Object.freeze([
'global-context-panel', 'global-context-panel',
]); ]);
/** The parked camera: over downtown Sacramento, looking north and down. */ /** Over downtown Sacramento, looking north and down. */
export const PARK = Object.freeze({ export const PARK = Object.freeze({
lon: -121.4944, lon: -121.4944,
lat: 38.5816, lat: 38.5816,
@@ -121,11 +98,8 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const DAY_MS = 86_400_000; const DAY_MS = 86_400_000;
/** /**
* Synthetic coverage for any tile, so fixture runs are deterministic: at * Deterministic coverage for any tile: a grid of mixed 360°/flat, new/old
* street zooms a grid of four east-west and four north-south sequences (half * sequences plus the photo sequence (PHOTO_LINE), or a few overview points.
* 360°, half captured three years ago) plus, in the tiles it crosses, the
* photo sequence whose images the Graph fixtures serve (PHOTO_LINE, under
* the parked camera); at overview zooms a few points.
* @returns {Uint8Array} empty for zooms that carry nothing * @returns {Uint8Array} empty for zooms that carry nothing
*/ */
export function fixtureTile(z, x, y, now = Date.now()) { export function fixtureTile(z, x, y, now = Date.now()) {
@@ -193,12 +167,9 @@ export function fixtureTile(z, x, y, now = Date.now()) {
} }
/** /**
* Ask the server's real Mapillary status route, outside the browser's * Ask the real status route from Node, outside the page's interception, so a
* interception, and require its JSON shape. Fixture runs answer the route in * server that never registered it fails even in fixture runs. Node sends no
* the page, so without this a server that never registered it would pass. * Origin, so the same-site gate admits it.
* Node sends no Origin or Sec-Fetch-Site, so the same-site gate admits it.
* @param {string} url the application's base URL
* @param {typeof fetch} [fetchImpl]
* @returns {Promise<{configured: boolean}>} * @returns {Promise<{configured: boolean}>}
*/ */
export async function assertRealStatusRoute(url, fetchImpl = fetch) { export async function assertRealStatusRoute(url, fetchImpl = fetch) {
@@ -236,21 +207,13 @@ export async function assertRealStatusRoute(url, fetchImpl = fetch) {
} }
/** /**
* Answer the page's Mapillary requests from fixtures (CDP Fetch on just the * Answer the page's Mapillary requests from `fixture`, which steps may change
* Mapillary URLs); nothing reaches Mapillary. `fixture` is live: * mid-run. `configured: undefined` lets the server's real status route answer.
* - `configured`: what the status route says in the page; undefined lets the
* server's real route answer (the main page: CI's dummy token is real).
* - `tiles: 'rejected'` answers tiles as the proxy does when Mapillary
* refuses the token; `tileRequests` counts tile requests.
* - `calls` collects the Graph/CDN calls answered (parameter names only, no
* token); `unknown` the ones no fixture covers (answered 404).
* @param {object} page
* @param {{configured?: boolean, tiles: 'ok'|'rejected', tileRequests: number, images: Array<object>, calls: Set<string>, unknown: Array<string>}} fixture * @param {{configured?: boolean, tiles: 'ok'|'rejected', tileRequests: number, images: Array<object>, calls: Set<string>, unknown: Array<string>}} fixture
*/ */
export async function serveFixtures(page, fixture) { export async function serveFixtures(page, fixture) {
// Only these URLs pause. Puppeteer's page-wide interception would pause // CDP Fetch on just these URLs: Puppeteer's page-wide interception stalls
// Cesium's worker scripts too, and under it they stall: no terrain, no // Cesium's worker scripts, leaving nothing to click.
// ground polylines, nothing to click.
const cdp = await page.createCDPSession(); const cdp = await page.createCDPSession();
await cdp.send('Fetch.enable', { await cdp.send('Fetch.enable', {
patterns: [ patterns: [
@@ -351,7 +314,6 @@ async function settle(page, fn, arg, { timeout = 10_000 } = {}) {
} }
} }
/** Clear the first-run dialog once the app is up. */
async function clearFirstRun(page) { async function clearFirstRun(page) {
await page.waitForFunction(() => Boolean(window.__godsEyeView?.dataManager), { await page.waitForFunction(() => Boolean(window.__godsEyeView?.dataManager), {
timeout: 150_000, timeout: 150_000,
@@ -382,7 +344,6 @@ export async function boot(page, url) {
await clearFirstRun(page); await clearFirstRun(page);
} }
/** Panel geometry and the bits of state the steps assert on. */
function readPanel(page) { function readPanel(page) {
return page.evaluate(() => { return page.evaluate(() => {
const el = document.getElementById('street-level-panel'); const el = document.getElementById('street-level-panel');
@@ -417,7 +378,6 @@ function waitForCoverage(page, { timeout = 90_000 } = {}) {
); );
} }
/** Without a key on the server the panel gates its controls and says why. */
async function assertKeylessGate(page) { async function assertKeylessGate(page) {
await page.evaluate(() => await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('street-level', true, { window.__godsEyeView.dataManager.setEnabled('street-level', true, {
@@ -443,13 +403,9 @@ async function assertKeylessGate(page) {
} }
/** /**
* Find a canvas point where a real click picks the wanted Street Level item: * Runs in the page. Find a point where a real click picks `prefix` + `id` (any
* `prefix` + `id` (or any id with the prefix when `id` is null), searched * id when null), searched around `points` ([lon, lat]), the aimed billboards,
* around projected `points` ([lon, lat] at the ground) or, without points, * or else a grid mid-screen. Only points where the canvas is topmost count.
* on a grid over the middle of the screen. `billboardIds` aims at those
* billboards; `billboardPrefix` at every shown billboard whose id starts with
* it (a live run's cones, whose positions the gate does not know). Only points where the Cesium
* canvas is the topmost element count. Runs in the page.
* @returns {{x: number, y: number, id: string}|null} client coordinates * @returns {{x: number, y: number, id: string}|null} client coordinates
*/ */
export function findPickPoint({ export function findPickPoint({
@@ -464,7 +420,6 @@ export function findPickPoint({
const canvas = scene.canvas; const canvas = scene.canvas;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const Cartographic = viewer.camera.positionCartographic.constructor; const Cartographic = viewer.camera.positionCartographic.constructor;
// scene.pick reads x and y; a Cartesian2 when one is at hand.
let Cartesian2 = null; let Cartesian2 = null;
const windowPoint = (x, y) => (Cartesian2 ? new Cartesian2(x, y) : { x, y }); const windowPoint = (x, y) => (Cartesian2 ? new Cartesian2(x, y) : { x, y });
const wanted = (picked) => { const wanted = (picked) => {
@@ -553,10 +508,7 @@ async function main() {
}); });
let count = 0; let count = 0;
const skips = []; const skips = [];
/** /** `fn` gets `skip(reason)`: the step runs what it can but reports a skip. */
* Run one step. `fn` gets `skip(reason)`: the step still runs whatever it
* can, but prints (and records) as a skip instead of `ok`.
*/
const step = async (label, fn) => { const step = async (label, fn) => {
let skipped = null; let skipped = null;
const result = await fn({ const result = await fn({
@@ -575,7 +527,7 @@ async function main() {
const skipStep = (reason, label) => step(label, ({ skip }) => skip(reason)); const skipStep = (reason, label) => step(label, ({ skip }) => skip(reason));
const errors = []; const errors = [];
const monitors = []; const monitors = [];
/** Collect page errors, render-loop errors and the listener exceptions the layer only warns about. */ /** The layer only warns on listener exceptions, so count those as errors. */
const watch = async (page, name) => { const watch = async (page, name) => {
monitors.push( monitors.push(
watchPage(page, { watchPage(page, {
@@ -588,7 +540,6 @@ async function main() {
}; };
const renderErrorsSeen = []; const renderErrorsSeen = [];
try { try {
// Before any interception: fixture runs answer Mapillary in the page.
await step( await step(
'the server registers the real Mapillary status route', 'the server registers the real Mapillary status route',
async () => { async () => {
@@ -712,9 +663,8 @@ async function main() {
); );
async function keyedSteps() { async function keyedSteps() {
// The startup flight can still be running: cancel it, park the camera // A late startup flight only shows itself by moving the camera, so park
// over downtown Sacramento and confirm it stays put. A stability window, // and check it stays put rather than reading a state.
// not a state: a late startup flight can only show itself by moving.
const park = async () => { const park = async () => {
for (let attempt = 0; attempt < 6; attempt++) { for (let attempt = 0; attempt < 6; attempt++) {
await page.evaluate((at) => { await page.evaluate((at) => {
@@ -765,10 +715,7 @@ async function main() {
.get('street-level') .get('street-level')
.module.getUIState(), .module.getUIState(),
); );
/** /** `test(u, arg)` runs in the page, so it must be self-contained. */
* Wait until the UI state satisfies `test(u, arg)`, a self-contained
* function run in the page; false when it never does.
*/
const uiUntil = (test, arg = null, options) => const uiUntil = (test, arg = null, options) =>
settle( settle(
page, page,
@@ -954,8 +901,7 @@ async function main() {
await step( await step(
'the MapillaryJS viewer loads ahead of the first photo', 'the MapillaryJS viewer loads ahead of the first photo',
async () => { async () => {
// Prewarm stands the lazily imported viewer up in the panel's host; a // Catches a broken dynamic import before the first click would.
// broken dynamic import would otherwise only show at the first click.
await page.waitForSelector( await page.waitForSelector(
'#sl-viewer.mapillary-viewer .mapillary-dom', '#sl-viewer.mapillary-viewer .mapillary-dom',
{ timeout: 60_000 }, { timeout: 60_000 },
@@ -969,7 +915,6 @@ async function main() {
{ timeout: 30_000 }, { timeout: 30_000 },
text, text,
); );
/** Point the camera somewhere new and refresh coverage there now. */
const panTo = (lon, lat) => const panTo = (lon, lat) =>
page.evaluate( page.evaluate(
(at) => { (at) => {
@@ -987,10 +932,7 @@ async function main() {
}, },
{ lon, lat }, { lon, lat },
); );
/** /** Wait until no tile request has arrived for `quietMs`. */
* Wait until no tile request has arrived for `quietMs` (longer than the
* camera-move debounce, so a refresh the pan scheduled has had its turn).
*/
const tileRequestsQuiet = async (quietMs, timeout = 30_000) => { const tileRequestsQuiet = async (quietMs, timeout = 30_000) => {
const start = Date.now(); const start = Date.now();
let last = fixture.tileRequests; let last = fixture.tileRequests;
@@ -1547,10 +1489,8 @@ async function main() {
const r = node.getBoundingClientRect(); const r = node.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
}); });
// Lifting and docking re-resolve the header and retry only a press that // Right after a dock the rail is still animating, so the shared helpers
// provably missed it: right after a dock the rail is still animating, and // re-resolve the header and retry only a press that provably missed it.
// a title point read once can be under another panel (scripts/
// qa-panelDrag.mjs, shared with the panel-resize gate).
const floatPanel = async () => { const floatPanel = async () => {
const attempt = await liftPanelByHeader(page, PANEL_ID, { const attempt = await liftPanelByHeader(page, PANEL_ID, {
dx: -400, dx: -400,
@@ -1649,7 +1589,6 @@ async function main() {
); );
await step('a header double-click docks a floating window', async () => { await step('a header double-click docks a floating window', async () => {
await floatPanel(); await floatPanel();
// Waits on the docked state itself, not a fixed sleep.
const attempt = await dockPanelByDoubleClick(page, PANEL_ID); const attempt = await dockPanelByDoubleClick(page, PANEL_ID);
assert.equal( assert.equal(
attempt.done, attempt.done,
@@ -1751,9 +1690,8 @@ async function main() {
.get('street-level') .get('street-level')
.module.closeViewer(), .module.closeViewer(),
); );
// The button is what the user presses; a toast over it must not make // Press through the DOM: a toast over the button must not make this
// this step about pointer aim, so press it through the DOM and wait // step about pointer aim.
// for the panel to say it is collapsed.
await page.$eval( await page.$eval(
'.panel-collapse-btn[data-collapse-target="street-level-panel"]', '.panel-collapse-btn[data-collapse-target="street-level-panel"]',
(button) => { (button) => {
+2 -5
View File
@@ -1,11 +1,8 @@
import { installMapillaryRoutes } from './mapillary/routes.js'; import { installMapillaryRoutes } from './mapillary/routes.js';
/** /**
* Vite plugin: Mapillary street-level coverage. * Vite plugin: cached Mapillary coverage tiles (token added server-side) and a
* * status route that says whether a token is configured.
* Serves cached coverage vector tiles with the Mapillary token added
* server-side (and the unused z14 image point layer stripped in transit),
* plus a status route that says whether a token is configured.
*/ */
function mapillaryProxy() { function mapillaryProxy() {
return { return {
+16 -34
View File
@@ -5,11 +5,9 @@ export const MAPILLARY_TILE_HOST = 'https://tiles.mapillary.com/maps/vtp';
/** Public tile layer names this proxy exposes, mapped to Mapillary's ids. */ /** Public tile layer names this proxy exposes, mapped to Mapillary's ids. */
export const TILE_LAYERS = Object.freeze({ export const TILE_LAYERS = Object.freeze({
// Overview points (z0–5), sequences (z6–14) and image points (z14 only). // Region-sized z6–10 tiles are refused; the app never asks for them. The
// The app asks for overview z0–5 and sequences z11–14 only (see // z14 `image` layer is ~98% of a tile and unused (positions come from the
// src/layers/streetLevel/tileMath.js); region-sized z6–10 tiles are refused. // graph API), so it is dropped in transit.
// The z14 `image` point layer is ~98% of a 10 MB tile and unused here:
// image positions come from the graph API per sequence. Dropped in transit.
coverage: Object.freeze({ coverage: Object.freeze({
upstream: 'mly1_public', upstream: 'mly1_public',
zoomRanges: Object.freeze([Object.freeze([0, 5]), Object.freeze([11, 14])]), zoomRanges: Object.freeze([Object.freeze([0, 5]), Object.freeze([11, 14])]),
@@ -26,57 +24,41 @@ export const MAPILLARY_CACHE_DIR = path.join(
export const TILE_DISK_DIR = path.join(MAPILLARY_CACHE_DIR, 'tiles'); export const TILE_DISK_DIR = path.join(MAPILLARY_CACHE_DIR, 'tiles');
/** /**
* Tiles change when new imagery is processed, which is far slower than the * Longer than the 15-minute upstream Cache-Control: tiles change only when new
* 15-minute upstream Cache-Control. A day keeps a session over one city from * imagery is processed, and a day avoids re-downloading a city per camera move.
* re-downloading its tiles on every camera move.
*/ */
export const TILE_DISK_TTL_MS = 24 * 60 * 60 * 1000; export const TILE_DISK_TTL_MS = 24 * 60 * 60 * 1000;
/** /**
* Disk cache bound. A tile write starts a background sweep at most this often: * A tile write starts a background sweep at most this often: expired files go
* it deletes files past TILE_DISK_TTL_MS, then the oldest files until the * first, then the oldest until the cache fits in TILE_DISK_MAX_BYTES.
* cache fits in TILE_DISK_MAX_BYTES (thousands of trimmed street-level tiles).
*/ */
export const TILE_DISK_SWEEP_INTERVAL_MS = 15 * 60 * 1000; export const TILE_DISK_SWEEP_INTERVAL_MS = 15 * 60 * 1000;
export const TILE_DISK_MAX_BYTES = 1024 * 1024 * 1024; export const TILE_DISK_MAX_BYTES = 1024 * 1024 * 1024;
/** /** Per-file cost on top of its size, so empty tiles still count. */
* What each cached file costs against TILE_DISK_MAX_BYTES on top of its size:
* one filesystem block and its directory entry. An empty (no coverage) tile is
* 0 bytes but still a file, so without this ocean tiles would never count.
*/
export const TILE_DISK_FILE_OVERHEAD_BYTES = 4096; export const TILE_DISK_FILE_OVERHEAD_BYTES = 4096;
/** A z14 image tile over a dense city is ~11 MB; anything past this is wrong. */ /** 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_MAX_BYTES = 48 * 1024 * 1024;
/** In-memory tile cache budget (bytes) and upstream fetch timeout. */
export const TILE_MEMORY_BUDGET_BYTES = 96 * 1024 * 1024; export const TILE_MEMORY_BUDGET_BYTES = 96 * 1024 * 1024;
/** /** Per-entry cost on top of its bytes, so empty tiles still count. */
* What each in-memory entry costs against the budget on top of its bytes (the
* key, the map entry and the Buffer object), so empty tiles count too.
*/
export const TILE_MEMORY_ENTRY_OVERHEAD_BYTES = 1024; export const TILE_MEMORY_ENTRY_OVERHEAD_BYTES = 1024;
export const TILE_FETCH_TIMEOUT_MS = 60_000; export const TILE_FETCH_TIMEOUT_MS = 60_000;
/** /**
* Upstream tile fetches running at once; further misses queue for a slot. A * Upstream fetches at once; further misses queue. A view needs at most 25
* view asks for at most 25 tiles, so a few slots keep it quick while a burst * tiles, and the cap stops a burst of misses opening thousands of requests.
* of distinct misses cannot open thousands of buffered requests at once.
*/ */
export const TILE_UPSTREAM_CONCURRENCY = 6; export const TILE_UPSTREAM_CONCURRENCY = 6;
/** /**
* Tile requests per client IP per minute (cache hits included). The app asks * Tile requests per client IP per minute, cache hits included. 600 is 24 new
* only for tiles it has not loaded yet, at most 9 street + 16 overview tiles * 25-tile views a minute, more than a person flying the camera reaches, but it
* per view after a 320 ms camera debounce (src/layers/streetLevel/providers/ * stops one page draining the shared token into a Mapillary rate limit.
* mapillary/policy.js), and the browser keeps each answer for an hour. 600 is
* 24 entirely new views a minute, one every 2.5 s, which a person flying the
* camera does not reach; it still stops one page from draining the shared
* token into Mapillary rate limits that would hold every miss for everyone.
*/ */
export const TILE_ROUTE_MAX_PER_MIN = 600; export const TILE_ROUTE_MAX_PER_MIN = 600;
/** /**
* After Mapillary rejects the token (401/403), tile misses are answered from * How long a 401/403 answers tile misses before asking Mapillary again. A
* that verdict for this long instead of asking again on every camera move. * changed token is tried at once.
* A changed token is asked at once.
*/ */
export const TILE_KEY_REJECTED_HOLD_MS = 5 * 60 * 1000; export const TILE_KEY_REJECTED_HOLD_MS = 5 * 60 * 1000;
/** After a 429 without a usable Retry-After, hold tile misses this long. */ /** After a 429 without a usable Retry-After, hold tile misses this long. */
+3 -7
View File
@@ -70,8 +70,7 @@ async function handleTile(req, res, allow) {
retryAfter, retryAfter,
}); });
} }
// Any other refusal is Mapillary's fault, not the client's: an // An upstream 400 must not read as a malformed request to this proxy.
// upstream 400 must not read as a malformed request to this proxy.
return sendJson(res, 502, { error: error.message }); return sendJson(res, 502, { error: error.message });
} }
sendJson(res, 502, { error: error?.message || 'Tile fetch failed' }); sendJson(res, 502, { error: error?.message || 'Tile fetch failed' });
@@ -79,11 +78,8 @@ async function handleTile(req, res, allow) {
} }
/** /**
* Attach every Mapillary route to a connect-style middleware stack. Both * Attach the Mapillary routes. Both refuse cross-site requests so another page
* refuse cross-site browser requests (server/providers/common/same-site.js): * cannot spend the token or provoke a 429 that holds every tile miss.
* another page could otherwise spend the user's token, and one Mapillary 429
* it provokes holds every tile miss. Tiles are also rate-limited per client
* IP (TILE_ROUTE_MAX_PER_MIN).
*/ */
export function installMapillaryRoutes(middlewares) { export function installMapillaryRoutes(middlewares) {
const allow = makeRateLimiter({ const allow = makeRateLimiter({
+30 -79
View File
@@ -20,7 +20,7 @@ import {
mapillaryToken, mapillaryToken,
} from './constants.js'; } from './constants.js';
/** Log a background failure without its stack (never the token: none is in scope). */ /** Log without the stack; the token is never in scope here. */
function warn(what, error) { function warn(what, error) {
console.warn(`[Mapillary Proxy] ${what}:`, error?.message || error); console.warn(`[Mapillary Proxy] ${what}:`, error?.message || error);
} }
@@ -34,10 +34,7 @@ export class TileRequestError extends Error {
} }
} }
/** /** Mapillary answered with an error status; 401/403 set `keyRejected`. */
* Thrown when Mapillary answered with an error status. A 401/403 means the
* token was rejected (`keyRejected`); a 429 carries how long to wait.
*/
export class TileUpstreamError extends Error { export class TileUpstreamError extends Error {
constructor(status, message, { retryAfterSec = null } = {}) { constructor(status, message, { retryAfterSec = null } = {}) {
super(message || `Mapillary tiles HTTP ${status}`); super(message || `Mapillary tiles HTTP ${status}`);
@@ -49,9 +46,8 @@ export class TileUpstreamError extends Error {
} }
/** /**
* The last refusal from Mapillary that every tile miss shares: a rejected * A rejected token or rate limit that answers every tile miss until it expires
* token (until the token changes or the hold ends) or a rate limit (until its * (or the token changes). Cached tiles are still served meanwhile.
* Retry-After). Cached tiles are still served meanwhile.
* @type {{status: number, until: number, token: string}|null} * @type {{status: number, until: number, token: string}|null}
*/ */
let _upstreamHold = null; let _upstreamHold = null;
@@ -66,7 +62,6 @@ function retryAfterMs(header, now = Date.now()) {
return Math.min(ms, TILE_RATE_LIMIT_MAX_HOLD_MS); return Math.min(ms, TILE_RATE_LIMIT_MAX_HOLD_MS);
} }
/** The refusal to answer a miss with, while one holds for this token. */
function heldRefusal(token) { function heldRefusal(token) {
const hold = _upstreamHold; const hold = _upstreamHold;
if (!hold) return null; if (!hold) return null;
@@ -81,8 +76,7 @@ function heldRefusal(token) {
} }
/** /**
* Validate and normalize a tile address. Layer names are the proxy's public * Validate a tile address against the layer's zoom ranges.
* ids (coverage only); zoom must be inside one of the layer's zoom ranges.
* @returns {{layer:string, upstream:string, z:number, x:number, y:number, key:string}} * @returns {{layer:string, upstream:string, z:number, x:number, y:number, key:string}}
*/ */
export function normalizeTileAddress({ layer, z, x, y }) { export function normalizeTileAddress({ layer, z, x, y }) {
@@ -116,13 +110,11 @@ export function normalizeTileAddress({ layer, z, x, y }) {
/** @type {Map<string, {bytes: Buffer, at: number}>} insertion-ordered LRU */ /** @type {Map<string, {bytes: Buffer, at: number}>} insertion-ordered LRU */
const _memory = new Map(); const _memory = new Map();
/** Charged bytes: every entry's size plus a fixed overhead (empty tiles too). */
let _memoryBytes = 0; let _memoryBytes = 0;
const memoryCost = (bytes) => bytes.length + TILE_MEMORY_ENTRY_OVERHEAD_BYTES; const memoryCost = (bytes) => bytes.length + TILE_MEMORY_ENTRY_OVERHEAD_BYTES;
/** /**
* Upstream fetches in progress, shared by every request for the same tile. * Upstream fetches shared by every request for the same tile; the last waiter
* A flight owns its controller; callers only count as waiters, and the last * to leave cancels it (see `joinFlight`).
* waiter to leave cancels it (see `joinFlight`).
* @type {Map<string, {controller: AbortController, promise: Promise<Buffer>, waiters: number}>} * @type {Map<string, {controller: AbortController, promise: Promise<Buffer>, waiters: number}>}
*/ */
const _inFlight = new Map(); const _inFlight = new Map();
@@ -130,14 +122,13 @@ const _inFlight = new Map();
function memoryGet(key) { function memoryGet(key) {
const hit = _memory.get(key); const hit = _memory.get(key);
if (!hit) return null; if (!hit) return null;
// Same 24 h life as the disk copy: a long-running server must not keep // Same life as the disk copy, so a long-running server never serves stale
// serving stale (or empty) coverage until eviction. // coverage.
if (Date.now() - hit.at > TILE_DISK_TTL_MS) { if (Date.now() - hit.at > TILE_DISK_TTL_MS) {
_memory.delete(key); _memory.delete(key);
_memoryBytes -= memoryCost(hit.bytes); _memoryBytes -= memoryCost(hit.bytes);
return null; return null;
} }
// Re-insert to mark as most recently used.
_memory.delete(key); _memory.delete(key);
_memory.set(key, hit); _memory.set(key, hit);
return hit.bytes; return hit.bytes;
@@ -164,7 +155,6 @@ function diskPath({ layer, z, x, y }, root = _diskDir) {
return path.join(root, layer, String(z), `${x}-${y}.pbf`); return path.join(root, layer, String(z), `${x}-${y}.pbf`);
} }
/** Disk-cache read; the tile's age comes from the file's mtime. */
async function readDisk(address) { async function readDisk(address) {
const file = diskPath(address); const file = diskPath(address);
try { try {
@@ -176,14 +166,9 @@ async function readDisk(address) {
} }
} }
/** /** Unsettled background writes and sweeps; requests never wait on them. */
* Background disk work (tile writes and sweeps) not yet settled. Requests
* never wait on it; tests settle it before removing a cache directory.
* @type {Set<Promise<void>>}
*/
const _background = new Set(); const _background = new Set();
/** Track a background task until it settles; it never rejects. */
function track(task) { function track(task) {
const settled = task const settled = task
.catch(() => {}) .catch(() => {})
@@ -192,26 +177,20 @@ function track(task) {
return settled; return settled;
} }
/** /** @type {Map<string, number>} cache file to its newest write's sequence */
* Disk writes in progress, by cache file: the sequence number of the newest.
* @type {Map<string, number>}
*/
const _diskWrites = new Map(); const _diskWrites = new Map();
let _diskWriteSeq = 0; let _diskWriteSeq = 0;
/** Whether a write of this tile's cache file is still in progress. */
function diskWritePending(address) { function diskWritePending(address) {
return _diskWrites.has(diskPath(address)); return _diskWrites.has(diskPath(address));
} }
/** /**
* Write a tile in the background. `at` dates a rewrite of an existing tile * Write a tile in the background. `at` keeps a rewrite's original fetch time
* with its original fetch time, so the rewrite does not extend its life. * so it does not extend the tile's life. An overtaken write steps aside.
* Each write has its own temporary file, and a write that a newer one of the
* same tile overtook steps aside instead of renaming over it.
*/ */
function writeDisk(address, bytes, at = null) { function writeDisk(address, bytes, at = null) {
// The sweep after it covers this write's cache root, even if it has moved. // Sweep this write's root even if the cache dir has moved since.
const root = _diskDir; const root = _diskDir;
const file = diskPath(address, root); const file = diskPath(address, root);
const seq = ++_diskWriteSeq; const seq = ++_diskWriteSeq;
@@ -241,7 +220,6 @@ let _lastSweepAt = 0;
let _sweeping = null; let _sweeping = null;
let _sweepWarned = false; let _sweepWarned = false;
/** Start a background sweep of `root` unless one ran within the interval. */
function scheduleSweep(root) { function scheduleSweep(root) {
if (_sweeping || Date.now() - _lastSweepAt < TILE_DISK_SWEEP_INTERVAL_MS) if (_sweeping || Date.now() - _lastSweepAt < TILE_DISK_SWEEP_INTERVAL_MS)
return; return;
@@ -259,7 +237,7 @@ function scheduleSweep(root) {
_sweeping = tracked; _sweeping = tracked;
} }
/** Every file under `dir` with its size and mtime; a missing dir is empty. */ /** Every file under `dir` with size and mtime; a missing dir is empty. */
async function listCacheFiles(dir) { async function listCacheFiles(dir) {
let entries; let entries;
try { try {
@@ -273,7 +251,6 @@ async function listCacheFiles(dir) {
const file = path.join(dir, entry.name); const file = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...(await listCacheFiles(file))); if (entry.isDirectory()) files.push(...(await listCacheFiles(file)));
else if (entry.isFile()) { else if (entry.isFile()) {
// A file renamed or swept away meanwhile is simply skipped.
const stat = await fsp.stat(file).catch(() => null); const stat = await fsp.stat(file).catch(() => null);
if (stat) files.push({ file, size: stat.size, at: stat.mtimeMs }); if (stat) files.push({ file, size: stat.size, at: stat.mtimeMs });
} }
@@ -282,10 +259,7 @@ async function listCacheFiles(dir) {
} }
/** /**
* Bound the disk cache: delete tiles past TILE_DISK_TTL_MS, then the oldest * Delete expired tiles, then the oldest until the rest fit in `maxBytes`.
* until the rest fit in `maxBytes`. Each file is charged its size plus
* TILE_DISK_FILE_OVERHEAD_BYTES, so empty tiles count toward the cap.
* Runs in the background after tile writes.
* @returns {Promise<{removed: number, bytes: number}>} files deleted, charged bytes kept * @returns {Promise<{removed: number, bytes: number}>} files deleted, charged bytes kept
*/ */
export async function sweepTileDisk({ export async function sweepTileDisk({
@@ -308,15 +282,12 @@ export async function sweepTileDisk({
/** The one origin the tile token may be sent to, at every redirect hop. */ /** The one origin the tile token may be sent to, at every redirect hop. */
const TILE_ORIGIN = new URL(MAPILLARY_TILE_HOST).origin; const TILE_ORIGIN = new URL(MAPILLARY_TILE_HOST).origin;
/** Redirect hops followed before the upstream counts as broken. */
export const TILE_MAX_REDIRECTS = 3; export const TILE_MAX_REDIRECTS = 3;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
/** /**
* Fetch a tile URL, following redirects by hand: each hop must stay HTTPS on * Follow redirects by hand so every hop stays HTTPS on the tile origin and the
* the Mapillary tile origin (no credentials in the URL), so the access token * token in the query never reaches another host. A bad redirect is a 502.
* in the query is never sent to another host. A refused or endless redirect
* is an upstream fault (502).
*/ */
async function fetchPinned(url, init) { async function fetchPinned(url, init) {
let next = url; let next = url;
@@ -354,7 +325,6 @@ async function fetchUpstream(address, signal) {
if (held) throw held; if (held) throw held;
const url = `${MAPILLARY_TILE_HOST}/${address.upstream}/2/${address.z}/${address.x}/${address.y}?access_token=${encodeURIComponent(token)}`; 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 timeout = AbortSignal.timeout(TILE_FETCH_TIMEOUT_MS);
// Aborts the request itself once its body runs past the size cap.
const oversize = new AbortController(); const oversize = new AbortController();
const response = await fetchPinned(url, { const response = await fetchPinned(url, {
signal: AbortSignal.any([signal, timeout, oversize.signal].filter(Boolean)), signal: AbortSignal.any([signal, timeout, oversize.signal].filter(Boolean)),
@@ -389,9 +359,8 @@ async function fetchUpstream(address, signal) {
} }
/** /**
* Read a tile body, counting as it arrives: a chunked or compressed response * Read a body, aborting once it passes TILE_MAX_BYTES: chunked or compressed
* has no Content-Length to check first, so the request is aborted as soon as * responses have no Content-Length to check first.
* the running total passes TILE_MAX_BYTES rather than after buffering it all.
*/ */
async function readCapped(response, abortRequest) { async function readCapped(response, abortRequest) {
if (!response.body) return Buffer.alloc(0); if (!response.body) return Buffer.alloc(0);
@@ -412,17 +381,12 @@ async function readCapped(response, abortRequest) {
return Buffer.concat(chunks, total); return Buffer.concat(chunks, total);
} }
/** /** @type {{active: number, queue: Array<() => void>}} upstream slots */
* Upstream fetch slots: at most TILE_UPSTREAM_CONCURRENCY run at once, and
* further flights wait in order. Replaced whole by the test reset.
* @type {{active: number, queue: Array<() => void>}}
*/
let _slots = { active: 0, queue: [] }; let _slots = { active: 0, queue: [] };
/** /**
* Wait for an upstream fetch slot. Resolves with its release function. A * Resolve with a release function once a slot is free. An abort while
* flight aborted while it waits leaves the queue and never fetches. * queued leaves the queue.
* @param {AbortSignal} signal
* @returns {Promise<() => void>} * @returns {Promise<() => void>}
*/ */
function acquireUpstreamSlot(signal) { function acquireUpstreamSlot(signal) {
@@ -451,7 +415,6 @@ function acquireUpstreamSlot(signal) {
}); });
} }
/** Strip the layers this proxy never serves for the address's layer spec. */
function trim(address, bytes) { function trim(address, bytes) {
if (!address.dropLayers.length || !bytes.length) return bytes; if (!address.dropLayers.length || !bytes.length) return bytes;
try { try {
@@ -463,8 +426,8 @@ function trim(address, bytes) {
} }
/** /**
* Fetch one tile through memory, disk and in-flight coalescing, then * Fetch one tile from memory, disk, a shared flight, then Mapillary. Empty
* Mapillary. Returns the raw protobuf bytes (empty for a tile with no data). * bytes mean no coverage.
* @param {{layer:string,z:number|string,x:number|string,y:number|string}} request * @param {{layer:string,z:number|string,x:number|string,y:number|string}} request
* @param {{signal?: AbortSignal}} [options] * @param {{signal?: AbortSignal}} [options]
* @returns {Promise<{bytes: Buffer, source: 'memory'|'disk'|'inflight'|'upstream', address: object}>} * @returns {Promise<{bytes: Buffer, source: 'memory'|'disk'|'inflight'|'upstream', address: object}>}
@@ -475,9 +438,7 @@ export async function fetchTile(request, { signal } = {}) {
if (memory) return { bytes: memory, source: 'memory', address }; if (memory) return { bytes: memory, source: 'memory', address };
const disk = await readDisk(address); const disk = await readDisk(address);
if (disk) { if (disk) {
// Older cache files may still hold the untrimmed tile: trim and replace, // Older cache files may be untrimmed: rewrite once, keeping the fetch time.
// keeping the original fetch time so the tile still expires on schedule.
// Concurrent hits on one such tile rewrite it once.
const bytes = trim(address, disk.bytes); const bytes = trim(address, disk.bytes);
if (bytes !== disk.bytes && !diskWritePending(address)) if (bytes !== disk.bytes && !diskWritePending(address))
writeDisk(address, bytes, disk.at); writeDisk(address, bytes, disk.at);
@@ -493,10 +454,7 @@ export async function fetchTile(request, { signal } = {}) {
return { bytes, source: joined ? 'inflight' : 'upstream', address }; return { bytes, source: joined ? 'inflight' : 'upstream', address };
} }
/** /** Start the shared upstream fetch; it holds a slot until the body is read. */
* Start the one upstream fetch for a tile, owned by the flight itself. It
* waits for an upstream slot first; the slot is held until the body is read.
*/
function startFlight(address) { function startFlight(address) {
const controller = new AbortController(); const controller = new AbortController();
const flight = { controller, waiters: 0, promise: null }; const flight = { controller, waiters: 0, promise: null };
@@ -520,12 +478,8 @@ function startFlight(address) {
} }
/** /**
* Wait on a shared flight as one caller. The caller's own abort rejects it at * Wait on a shared flight. A caller's abort rejects only that caller; the
* once without disturbing the others; the upstream fetch is cancelled only * fetch is cancelled when the last waiter leaves.
* when the last waiter leaves.
* @param {{controller: AbortController, promise: Promise<Buffer>, waiters: number}} flight
* @param {AbortSignal} [signal]
* @returns {Promise<Buffer>}
*/ */
function joinFlight(flight, signal) { function joinFlight(flight, signal) {
signal?.throwIfAborted(); signal?.throwIfAborted();
@@ -566,10 +520,7 @@ export function _tileMemoryForTest() {
return { entries: _memory.size, bytes: _memoryBytes }; return { entries: _memory.size, bytes: _memoryBytes };
} }
/** /** Test seam: wait for every background write and the sweeps they start. */
* Test seam: wait until every background tile write and sweep has settled,
* including sweeps those writes start, so a test may remove its cache dir.
*/
export async function _settleTileWritesForTest() { export async function _settleTileWritesForTest() {
while (_background.size) await Promise.allSettled([..._background]); while (_background.size) await Promise.allSettled([..._background]);
} }
+3 -5
View File
@@ -17,10 +17,8 @@ function layerName(layerBytes) {
} }
/** /**
* Drop whole layers from a vector tile at the protobuf level. Nothing inside * Drop whole layers from a vector tile without decoding kept layers, so a
* a kept layer is decoded or re-encoded, so a 12 MB tile whose bulk is an * 12 MB tile trims in milliseconds. Returns the input when nothing was dropped.
* unused layer trims in a few milliseconds. Returns the input when nothing
* was dropped.
* @param {Buffer|Uint8Array} bytes * @param {Buffer|Uint8Array} bytes
* @param {Iterable<string>} dropNames * @param {Iterable<string>} dropNames
*/ */
@@ -31,7 +29,7 @@ export function stripTileLayers(bytes, dropNames) {
const writer = new PbfWriter(); const writer = new PbfWriter();
let dropped = false; let dropped = false;
reader.readFields((tag, _result, pbf) => { reader.readFields((tag, _result, pbf) => {
if (tag !== TILE_LAYER_FIELD) return; // pbf skips what we leave alone if (tag !== TILE_LAYER_FIELD) return;
const end = pbf.readVarint() + pbf.pos; const end = pbf.readVarint() + pbf.pos;
const layerBytes = bytes.subarray(pbf.pos, end); const layerBytes = bytes.subarray(pbf.pos, end);
pbf.pos = end; pbf.pos = end;
+1 -5
View File
@@ -5,11 +5,7 @@ import * as picking from '../../data/pickRegistry.js';
import * as input from '../../data/inputOwnership.js'; import * as input from '../../data/inputOwnership.js';
import * as render from '../../renderGovernor.js'; import * as render from '../../renderGovernor.js';
/** /** A new provider registers here; its chip, credit and share bit follow. */
* Construct the Street Level layer with every imagery provider this build
* ships, using the application scene owners and the supplied sources. A new
* provider registers here; its chip, credit and share bit follow.
*/
export function createApplicationStreetLevel({ surface, sources }) { export function createApplicationStreetLevel({ surface, sources }) {
return createStreetLevelLayer({ return createStreetLevelLayer({
providers: [createMapillaryProvider({ source: sources.mapillary })], providers: [createMapillaryProvider({ source: sources.mapillary })],
+2 -3
View File
@@ -418,9 +418,8 @@ const OPTION_GROUPS = Object.freeze({
pano: 'p', pano: 'p',
flat: 'f', flat: 'f',
}), }),
// "Captured since" as relative days, so a link means the same next year. // Relative days, so a link means the same next year. The max matches
// The filter's MAX_SINCE_DAYS (streetLevel/policy.js); layerState.test // MAX_SINCE_DAYS in streetLevel/policy.js (pinned by layerState.test).
// pins the two together.
boundedIntegerOption('sinceDays', 's', 0, { min: 0, max: 36500 }), boundedIntegerOption('sinceDays', 's', 0, { min: 0, max: 36500 }),
]), ]),
radio: Object.freeze([ radio: Object.freeze([
+1 -2
View File
@@ -2678,8 +2678,7 @@ test('Street Level: every option round-trips through a share link and stored sta
}); });
test('Street Level: the link codec accepts exactly the windows the filter keeps', () => { test('Street Level: the link codec accepts exactly the windows the filter keeps', () => {
// The codec's ceiling is the filter's MAX_SINCE_DAYS: a longer window is // A window past MAX_SINCE_DAYS reads as "any date".
// rejected (read as "any date"), the longest one is kept whole.
assert.equal( assert.equal(
streetLevelOptions(`0.s.${MAX_SINCE_DAYS}`).sinceDays, streetLevelOptions(`0.s.${MAX_SINCE_DAYS}`).sinceDays,
MAX_SINCE_DAYS, MAX_SINCE_DAYS,
+7 -20
View File
@@ -1,17 +1,14 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { FOLLOW_EYE_HEIGHT_M } from './policy.js'; import { FOLLOW_EYE_HEIGHT_M } from './policy.js';
/** Ellipsoidal heights a land surface can have (Dead Sea shore to Everest, with geoid slack). */ /** Plausible ellipsoidal land heights: Dead Sea to Everest, with geoid slack. */
const SURFACE_MIN_M = -500; const SURFACE_MIN_M = -500;
const SURFACE_MAX_M = 9000; const SURFACE_MAX_M = 9000;
/** Rendered mesh window around the bare earth: roofs and bridges, not whole towers. */ /** Rendered mesh window around the bare earth: roofs and bridges, not whole towers. */
const MESH_BELOW_DEM_M = 15; const MESH_BELOW_DEM_M = 15;
const MESH_ABOVE_DEM_M = 80; const MESH_ABOVE_DEM_M = 80;
/** /** Drive the globe camera from the street-level pose: continuously (follow) or once (framing). */
* Drive the globe camera from the street-level pose: either continuously
* ("camera follows view") or once, framing the image from behind.
*/
export function createCameraFollow({ state, parts }) { export function createCameraFollow({ state, parts }) {
const { render } = state.services; const { render } = state.services;
@@ -20,11 +17,9 @@ export function createCameraFollow({ state, parts }) {
} }
/** /**
* Ground under a photo for framing and following. The rendered surface is * Ground under a photo. `sampleHeight` can return kilometres below the
* sampled first, but `sampleHeight` can return nonsense before the tiles * ellipsoid before tiles load, so a sample counts only when plausible and
* under the point have loaded (kilometres below the ellipsoid), so a sample * within the mesh window around bare earth (once that is known).
* counts only when it is a plausible surface height and, once the bare-earth
* height is known, lies within the mesh window around it.
*/ */
function groundHeightAt(lon, lat, fallback) { function groundHeightAt(lon, lat, fallback) {
const scene = state.viewer?.scene; const scene = state.viewer?.scene;
@@ -70,12 +65,7 @@ export function createCameraFollow({ state, parts }) {
requestRender(); requestRender();
} }
/** /** Our framing flight while it is current; Cesium calls `cancel` when another flight starts. */
* The framing flight this module started, while it is still the camera's
* current flight. Cesium cancels it (calling `cancel`) when any other
* flight starts, so a stale token never cancels someone else's flight.
* @type {object|null}
*/
let framing = null; let framing = null;
/** Frame the current image from a short distance behind it. */ /** Frame the current image from a short distance behind it. */
@@ -108,10 +98,7 @@ export function createCameraFollow({ state, parts }) {
); );
} }
/** /** Stop our framing flight if it is still in the air; another feature's flight is left alone. */
* Stop the framing flight if it is still in the air (the image closed or
* the layer went off). A flight another feature started since is left be.
*/
function cancelFraming() { function cancelFraming() {
if (!framing) return; if (!framing) return;
// Give up ownership first: cancelFlight delivers `cancel` synchronously. // Give up ownership first: cancelFlight delivers `cancel` synchronously.
+6 -8
View File
@@ -9,10 +9,8 @@ const heightOf = (cartesian) =>
Cesium.Cartographic.fromCartesian(cartesian).height; Cesium.Cartographic.fromCartesian(cartesian).height;
/** /**
* A camera with Cesium's flight bookkeeping: starting a flight cancels the * A camera with Cesium's flight bookkeeping: a new flight or `cancelFlight`
* current one (calling its `cancel`), `cancelFlight` cancels whichever flight * cancels the current one, `land()` completes it, `cancelled` counts stops.
* is current, whoever started it, and `land()` completes it. `cancelled`
* counts the flights `cancelFlight` stopped.
*/ */
function flightCamera(flights, views) { function flightCamera(flights, views) {
let current = null; let current = null;
@@ -43,8 +41,8 @@ function flightCamera(flights, views) {
} }
/** /**
* A viewer whose scene sample is `sampled` (the Austin case: −14,886 m before * A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles
* the tiles under the photo had loaded) and whose bare earth is `dem`. * under the photo load) and whose bare earth is `dem`.
*/ */
function setup({ sampled, dem = null, globe = null, altitude = 149 }) { function setup({ sampled, dem = null, globe = null, altitude = 149 }) {
const flights = []; const flights = [];
@@ -118,7 +116,7 @@ test('following stands the camera at eye height above the checked ground', () =>
); );
}); });
test('cancelFraming stops the framing flight while it is still ours (review IC8 P1)', () => { test('cancelFraming stops the framing flight while it is still ours', () => {
const { follow, state } = setup({ sampled: 121, dem: 117 }); const { follow, state } = setup({ sampled: 121, dem: 117 });
const { camera } = state.viewer; const { camera } = state.viewer;
follow.lookAtPosition(); follow.lookAtPosition();
@@ -134,7 +132,7 @@ test('cancelFraming stops the framing flight while it is still ours (review IC8
assert.equal(camera.cancelled, 2); assert.equal(camera.cancelled, 2);
}); });
test('cancelFraming leaves a landed flight and a newer navigation flight alone (review IC8 P1)', () => { test('cancelFraming leaves a landed flight and a newer navigation flight alone', () => {
const { follow, state, flights } = setup({ sampled: 121, dem: 117 }); const { follow, state, flights } = setup({ sampled: 121, dem: 117 });
const { camera } = state.viewer; const { camera } = state.viewer;
follow.lookAtPosition(); follow.lookAtPosition();
+2 -3
View File
@@ -1,9 +1,8 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
/** /**
* One on-screen Cesium credit per active provider. Street-level imagery is * One static Cesium credit per active provider: CC BY-SA imagery needs
* typically CC BY-SA, which needs visible attribution that goes away with * visible attribution that goes away with the imagery.
* the imagery, so these are static credits added and removed per provider.
*/ */
export function createCredits() { export function createCredits() {
/** @type {Map<string, object>} provider id → Cesium.Credit */ /** @type {Map<string, object>} provider id → Cesium.Credit */
+6 -6
View File
@@ -3,8 +3,8 @@ import { FILTER_DEFAULT, MAX_SINCE_DAYS, PANO_MODES } from './policy.js';
const DAY_MS = 86_400_000; const DAY_MS = 86_400_000;
/** /**
* Merge a partial filter change into the current one. Unknown panorama modes * Merge a partial filter change; unknown pano modes and invalid day counts
* and negative or non-integer day counts leave the current value alone. * keep the current value.
* @param {{pano?: string, sinceDays?: number}|null} next * @param {{pano?: string, sinceDays?: number}|null} next
* @param {{pano: string, sinceDays: number}} [current] * @param {{pano: string, sinceDays: number}} [current]
* @returns {{pano: string, sinceDays: number}} * @returns {{pano: string, sinceDays: number}}
@@ -26,8 +26,8 @@ export function sameFilter(a, b) {
} }
/** /**
* Turn the stored filter (relative days, stable in share links) into the * Stored filter (relative days, stable in share links) to the absolute form
* absolute form providers compare capture times against. * providers compare capture times against.
* @returns {{pano: string, sinceMs: number|null}} * @returns {{pano: string, sinceMs: number|null}}
*/ */
export function resolveFilter(filter, now = Date.now()) { export function resolveFilter(filter, now = Date.now()) {
@@ -39,8 +39,8 @@ export function resolveFilter(filter, now = Date.now()) {
} }
/** /**
* Whether one capture passes the resolved imagery filter: panorama mode * Whether a capture passes the resolved filter: pano mode and an optional
* ('all' | 'pano' | 'flat') and an optional earliest capture time. * earliest capture time.
* @param {{isPano?: boolean, capturedAt?: number}} record * @param {{isPano?: boolean, capturedAt?: number}} record
* @param {{pano: string, sinceMs: number|null}|null} filter * @param {{pano: string, sinceMs: number|null}|null} filter
*/ */
+2 -8
View File
@@ -1,8 +1,4 @@
/** /** Canvas billboard glyphs (image cone, pano ring, position marker); no Cesium needed. */
* Canvas glyphs for billboards: a camera cone for images, a ring for
* panoramas and a position marker for the street-level viewer. Pure canvas
* drawing so it can run without Cesium.
*/
const _cache = new Map(); const _cache = new Map();
@@ -29,7 +25,6 @@ function wedge(ctx, c, radius, halfAngle, color, alpha) {
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
/** A stroked circle around the centre. */
function ring(ctx, c, radius, color, width, alpha) { function ring(ctx, c, radius, color, width, alpha) {
ctx.beginPath(); ctx.beginPath();
ctx.arc(c, c, radius, 0, Math.PI * 2); ctx.arc(c, c, radius, 0, Math.PI * 2);
@@ -40,7 +35,6 @@ function ring(ctx, c, radius, color, width, alpha) {
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
/** A filled dot at the centre with a dark outline. */
function dot(ctx, c, radius, color, outline, width) { function dot(ctx, c, radius, color, outline, width) {
ctx.beginPath(); ctx.beginPath();
ctx.arc(c, c, radius, 0, Math.PI * 2); ctx.arc(c, c, radius, 0, Math.PI * 2);
@@ -71,7 +65,7 @@ export function imageConeGlyph({
}); });
} }
/** Pulsing-looking marker for the image the viewer currently shows. */ /** Marker for the image the viewer currently shows. */
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) { export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => { return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => {
wedge(ctx, c, size * 0.48, 0.55, color, 0.55); wedge(ctx, c, size * 0.48, 0.55, color, 0.55);
+25 -46
View File
@@ -1,17 +1,9 @@
/** /**
* Ground casting for street-level overlays on Google Photorealistic 3D. * 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
* Draped lines and clamped billboards land on the top of the photoreal mesh, * (DEM) height plus a lift and lets the mesh hide what is behind buildings.
* so on a street lined with trees or under an overpass the coverage appears * Heights come from a coarse grid shared with the ground-floor service; a
* to float at roof height. Casting places the overlays at the bare-earth * geoid fallback (terrain proxy down) counts as unknown and stays draped.
* height from the terrain service (DEM) instead, plus a small lift, and lets
* the mesh hide what is behind buildings.
*
* Heights are sampled on a coarse grid and interpolated, so one z14 coverage
* tile costs at most a few hundred terrain lookups, and the grid matches the
* ground-floor service (3 decimals) so cached cells are shared. Only real DEM
* heights count; a geoid fallback (terrain proxy down) is treated as unknown
* and the caller keeps its draped placement.
*/ */
import { wrapLon } from './tileMath.js'; import { wrapLon } from './tileMath.js';
@@ -28,15 +20,12 @@ export const GROUND_CAST_MAX_CORNERS = 1024;
const GROUND_CAST_CACHE_MAX = 50_000; const GROUND_CAST_CACHE_MAX = 50_000;
/** /**
* Mesh refinement. The bare-earth grid cannot see a freeway in a trench, a * Mesh refinement, where the Google 3D surface was sampled (the DEM grid
* steep street between grid corners, or the road under a tree, so where the * misses trenches, steep streets and roads under trees):
* rendered Google 3D surface has been sampled it refines the height: * - mesh up to MESH_ROAD_ABOVE_M above bare earth is the road: follow it;
* - mesh at most MESH_ROAD_ABOVE_M above bare earth (and not absurdly far * - higher mesh is over the road (canopy, deck): interpolate the road's
* below it) is the road itself: follow it, MESH_LIFT_M above; * offset from the nearest road samples on both sides;
* - mesh higher than that is something over the road (canopy, deck, roof): * - otherwise bare earth plus GROUND_CAST_LIFT_M.
* carry the road's offset from bare earth across from the nearest road
* samples on both sides, within MESH_BRIDGE_MAX_POINTS;
* - otherwise bare earth plus GROUND_CAST_LIFT_M, as without the mesh.
*/ */
export const MESH_ROAD_ABOVE_M = 3; export const MESH_ROAD_ABOVE_M = 3;
/** A sample this far below bare earth is a bad probe, not a road. */ /** A sample this far below bare earth is a bad probe, not a road. */
@@ -49,8 +38,7 @@ export const MESH_BRIDGE_MAX_POINTS = 8;
export const MESH_DENSIFY_DEG = 0.0002; export const MESH_DENSIFY_DEG = 0.0002;
/** /**
* Heights for a line's points from bare earth and, where sampled, the mesh. * Heights for a line's points by the rule above.
* Pure, so the rule above is testable without a scene.
* @param {Array<{dem: number, mesh?: number|null}>} points * @param {Array<{dem: number, mesh?: number|null}>} points
* @param {{lift?: number}} [options] * @param {{lift?: number}} [options]
* @returns {Array<number>} * @returns {Array<number>}
@@ -93,9 +81,8 @@ export const SURFACE_TERRAIN_ENTER_M = 1400;
export const SURFACE_TERRAIN_EXIT_M = 1800; export const SURFACE_TERRAIN_EXIT_M = 1800;
/** /**
* The next surface mode for the camera. Terrain mode applies only on Google * Next surface mode: terrain only on Google 3D at street zoom, with
* 3D and only at street zoom; the gap between the enter and exit heights * hysteresis between the enter and exit heights.
* stops the mode from flapping while the camera hovers at the boundary.
* @param {'draped'|'terrain'} current * @param {'draped'|'terrain'} current
* @param {{photoreal: boolean, heightM: number|null, available: boolean}} view * @param {{photoreal: boolean, heightM: number|null, available: boolean}} view
* @returns {'draped'|'terrain'} * @returns {'draped'|'terrain'}
@@ -108,9 +95,8 @@ export function nextSurfaceMode(current, { photoreal, heightM, available }) {
} }
/** /**
* Split long segments so a line follows the terrain between its vertices. * Split long segments so a line follows the terrain. Segments across ±180°
* A segment across ±180° is split the short way round, and the points it * go the short way round, with added points wrapped into [-180, 180].
* adds are wrapped back into [-180, 180].
* @param {Array<[number, number]>} coords [lon, lat] pairs * @param {Array<[number, number]>} coords [lon, lat] pairs
* @param {number} [maxStep] longest segment, in degrees * @param {number} [maxStep] longest segment, in degrees
* @returns {Array<[number, number]>} * @returns {Array<[number, number]>}
@@ -172,9 +158,8 @@ export function createGroundCaster({
async function resolve(points, signal) { async function resolve(points, signal) {
if (signal?.aborted) return false; if (signal?.aborted) return false;
// Make room first: evicting after counting would drop corners this // Evict before counting, or we could drop corners this request counted
// request already treated as cached. Oldest first (a Map keeps insertion // as cached. Oldest first, so the tiles still on screen keep theirs.
// order), so the tiles cast last, likely still on screen, keep theirs.
for (const key of heights.keys()) { for (const key of heights.keys()) {
if (heights.size + maxCorners <= cacheMax) break; if (heights.size + maxCorners <= cacheMax) break;
heights.delete(key); heights.delete(key);
@@ -182,8 +167,7 @@ export function createGroundCaster({
const missing = missingCorners(points); const missing = missingCorners(points);
if (!missing.length) return true; if (!missing.length) return true;
if (missing.length > maxCorners) return false; if (missing.length > maxCorners) return false;
// A corner east of 180° (the far corner of a point on the meridian) is // A corner east of 180° is asked for at its wrapped longitude.
// asked for at its longitude on the other side.
const results = await terrain.resolveEllipsoidalGround( const results = await terrain.resolveEllipsoidalGround(
missing.map(([, { i, j }]) => ({ missing.map(([, { i, j }]) => ({
lon: Number(wrapLon(i * step).toFixed(6)), lon: Number(wrapLon(i * step).toFixed(6)),
@@ -202,9 +186,8 @@ export function createGroundCaster({
} }
/** /**
* Fetch the grid corners around the given points. Resolves true when every * Fetch the grid corners around the points. True when all can be cast;
* point can now be cast; false when some corners are unknown (terrain proxy * false when some are unknown, too many, or aborted. Never rejects.
* down, too many corners, or aborted). Never rejects.
* @param {Array<[number, number]>} points [lon, lat] pairs * @param {Array<[number, number]>} points [lon, lat] pairs
* @param {{signal?: AbortSignal}} [options] * @param {{signal?: AbortSignal}} [options]
*/ */
@@ -215,10 +198,8 @@ export function createGroundCaster({
} }
/** /**
* `prepare` for lines, including the points densifying adds. `castLine` * `prepare` for lines at both densities: the finer mesh points can fall in
* densifies finer when mesh heights refine it, and those points can fall in * cells the coarse ones skip, and a missing cell leaves the line draped.
* grid cells the coarse points skip, so both sets are prepared: otherwise
* the cast comes back null and the line stays draped over roofs and trees.
*/ */
function prepareLines(lines, options) { function prepareLines(lines, options) {
return prepare( return prepare(
@@ -250,10 +231,8 @@ export function createGroundCaster({
} }
/** /**
* A line as flat [lon, lat, height, ...] degrees and metres, densified; * Densified line as flat [lon, lat, height, ...]; null until every corner is
* null when any corner it needs is not cached yet. With `meshAt` (sampled * cached. With `meshAt` it is densified finer and refined by `refineHeights`.
* mesh heights, undefined or null where unknown) the line is densified
* finer and its heights refined by `refineHeights`.
* @param {Array<[number, number]>} coords * @param {Array<[number, number]>} coords
* @param {{meshAt?: (lon: number, lat: number) => number|null|undefined}} [options] * @param {{meshAt?: (lon: number, lat: number) => number|null|undefined}} [options]
*/ */
+7 -8
View File
@@ -83,7 +83,7 @@ test('densifyLine splits long segments and keeps the vertices', () => {
assert.deepEqual(densifyLine([[10, 50]]), [[10, 50]]); assert.deepEqual(densifyLine([[10, 50]]), [[10, 50]]);
}); });
test('densifyLine crosses the date line the short way and wraps what it adds (review IC8 P2)', () => { 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°. // 0.0002° apart: short enough to stay one segment, not 65 points via 0°.
const short = [ const short = [
[179.9999, 0], [179.9999, 0],
@@ -257,8 +257,8 @@ test('a full cache is cleared before a request counts its corners, so the reques
}); });
// One cell's four corners are cached... // One cell's four corners are cached...
assert.equal(await caster.prepare([[10.0005, 50.0005]]), true); assert.equal(await caster.prepare([[10.0005, 50.0005]]), true);
// ...then a line over that cell and the next needs two more. Clearing after // ...then a line over that cell and the next needs two more, and the first
// counting would have kept only the two new corners and lost the first cell. // cell must survive the eviction.
const line = [ const line = [
[10.0002, 50.0005], [10.0002, 50.0005],
[10.0018, 50.0005], [10.0018, 50.0005],
@@ -304,10 +304,9 @@ test('cancelling a prepare cancels its terrain request', async () => {
assert.equal(seen[0], controller.signal, 'the tile signal reaches the proxy'); 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 (review IC8 P1)', async () => { 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 densifying used to // Seeded walks with 50–300 m segments: the finer mesh points can land in
// land in grid cells the coarse points skipped, so most of them came back // grid cells the coarse points skip.
// null and stayed draped over roofs.
let seed = 7; let seed = 7;
const random = () => { const random = () => {
seed = (seed * 16807) % 2147483647; seed = (seed * 16807) % 2147483647;
@@ -332,7 +331,7 @@ test('prepared lines always cast with the mesh, however their segments cross the
assert.equal(failures, 0); assert.equal(failures, 0);
}); });
test('a line across the date line casts along the date line (review IC8 P2)', async () => { test('a line across the date line casts along the date line', async () => {
// Ground that rises northward only, so both sides of ±180° agree. // Ground that rises northward only, so both sides of ±180° agree.
const calls = []; const calls = [];
const terrain = { const terrain = {
+9 -27
View File
@@ -25,11 +25,8 @@ import {
export { STREET_LEVEL_LAYER_ID } from './policy.js'; export { STREET_LEVEL_LAYER_ID } from './policy.js';
/** /**
* Construct the Street Level layer from a list of imagery providers (see * The Street Level layer over imagery providers (contract in registry.js).
* registry.js for the contract). The core owns what every provider shares: * The core owns what they share; providers own coverage, sequences, viewer.
* the enable state and per-provider switches, the imagery filter, one click
* handler, one viewer host, the position marker, camera follow, credits and
* share-link parameters. Providers own their coverage, sequences and viewer.
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options * @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
*/ */
export function createStreetLevelLayer({ export function createStreetLevelLayer({
@@ -84,23 +81,17 @@ export function createStreetLevelLayer({
function providerContext(entry) { function providerContext(entry) {
return Object.freeze({ return Object.freeze({
services: state.services, services: state.services,
/** The imagery filter resolved against now; read it when filtering. */
getFilter: () => resolveFilter(state.filter), getFilter: () => resolveFilter(state.filter),
isActive: () => state.enabled && entry.on, isActive: () => state.enabled && entry.on,
/** Bare-earth heights (see groundCast.js), or null without a terrain service. */
groundCaster: parts.groundCaster, groundCaster: parts.groundCaster,
/** Sampled Google 3D surface heights refining the cast (meshSampler.js), or null. */
meshSampler: parts.meshSampler, meshSampler: parts.meshSampler,
/** 'terrain' when overlays should sit on the bare earth, else 'draped'. */
getSurface: () => state.surface, getSurface: () => state.surface,
notify, notify,
actions: { actions: {
openImage: (imageId) => openImage(entry.def.id, imageId), openImage: (imageId) => openImage(entry.def.id, imageId),
/** /**
* Show a provider error, or (null) withdraw the one it showed. A * Show a provider error, or withdraw it (null). A withdrawal clears
* withdrawal never clears an error the viewer or another provider * only this provider's own error, never a newer one from elsewhere.
* has shown since, and a stale one cannot mask a later coverage
* error such as KEY REJECTED.
*/ */
reportError: (message) => { reportError: (message) => {
if (message) { if (message) {
@@ -143,9 +134,8 @@ export function createStreetLevelLayer({
} }
/** /**
* Camera height above the bare earth under it, for the surface mode. The * Camera height above bare earth, for the surface mode. A cold grid cell is
* ground comes from the caster's coarse grid; a cold cell near enough to * fetched once and the mode rechecked when it lands.
* matter is fetched once and the mode checked again when it lands.
*/ */
function cameraHeightForSurface() { function cameraHeightForSurface() {
const carto = state.viewer?.camera?.positionCartographic; const carto = state.viewer?.camera?.positionCartographic;
@@ -163,10 +153,7 @@ export function createStreetLevelLayer({
return height; return height;
} }
/** /** Cast overlays to bare earth on Google 3D at street zoom; drape them elsewhere. */
* On Google 3D at street zoom, overlays are cast to the bare earth; draped
* lines would land on roofs and trees. Elsewhere they stay draped.
*/
function syncSurface() { function syncSurface() {
const available = Boolean(parts.groundCaster) && state.enabled; const available = Boolean(parts.groundCaster) && state.enabled;
const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID; const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
@@ -239,12 +226,7 @@ export function createStreetLevelLayer({
parts.credits.hide(state.viewer, entry.def); parts.credits.hide(state.viewer, entry.def);
} }
/** /** The nearest-image lookup in flight; any newer user action aborts it. */
* The nearest-image lookup in flight. Anything newer the user asks for (a
* later lookup, a picked image, closing, the layer going off) aborts it,
* so its late answer can never replace that choice.
* @type {AbortController|null}
*/
let nearestLookup = null; let nearestLookup = null;
function abortNearest() { function abortNearest() {
@@ -502,7 +484,7 @@ export function createStreetLevelLayer({
if (!state.enabled) break; if (!state.enabled) break;
// This provider was switched off while it looked: try the next. // This provider was switched off while it looked: try the next.
if (!imageId || !entry.on) continue; if (!imageId || !entry.on) continue;
nearestLookup = null; // answered: nothing left to abort nearestLookup = null;
return openImage(entry.def.id, imageId); return openImage(entry.def.id, imageId);
} }
if (nearestLookup === lookup) nearestLookup = null; if (nearestLookup === lookup) nearestLookup = null;
+11 -11
View File
@@ -242,7 +242,7 @@ test('switching a provider off deactivates it and closes the image it shows', as
assert.equal(layer.getUIState().street.providerId, null); assert.equal(layer.getUIState().street.providerId, null);
}); });
test('openNearest without the panel reports it instead of loading forever (review P3)', async (t) => { test('openNearest without the panel reports it instead of loading forever', async (t) => {
const provider = fakeProvider({ nearestImage: async () => 'img1' }); const provider = fakeProvider({ nearestImage: async () => 'img1' });
const { layer } = await enabledLayer(t, [provider]); const { layer } = await enabledLayer(t, [provider]);
assert.equal(await layer.openNearest({ lat: 38.58, lon: -121.49 }), false); assert.equal(await layer.openNearest({ lat: 38.58, lon: -121.49 }), false);
@@ -252,7 +252,7 @@ test('openNearest without the panel reports it instead of loading forever (revie
assert.equal(provider.calls.mount, 0); assert.equal(provider.calls.mount, 0);
}); });
test('openNearest opens nothing once the layer went off during the lookup (review P3)', async (t) => { test('openNearest opens nothing once the layer went off during the lookup', async (t) => {
const answer = deferred(); const answer = deferred();
const provider = fakeProvider({ nearestImage: () => answer.promise }); const provider = fakeProvider({ nearestImage: () => answer.promise });
const { layer } = await enabledLayer(t, [provider]); const { layer } = await enabledLayer(t, [provider]);
@@ -268,7 +268,7 @@ test('openNearest opens nothing once the layer went off during the lookup (revie
assert.equal(street.error, null); assert.equal(street.error, null);
}); });
test('openNearest skips a provider switched off during its lookup (review P3)', async (t) => { test('openNearest skips a provider switched off during its lookup', async (t) => {
const answer = deferred(); const answer = deferred();
const provider = fakeProvider({ nearestImage: () => answer.promise }); const provider = fakeProvider({ nearestImage: () => answer.promise });
const { layer } = await enabledLayer(t, [provider]); const { layer } = await enabledLayer(t, [provider]);
@@ -281,7 +281,7 @@ test('openNearest skips a provider switched off during its lookup (review P3)',
assert.equal(layer.getUIState().street.loading, false); assert.equal(layer.getUIState().street.loading, false);
}); });
test('a provider withdraws only the error it reported (review P3)', async (t) => { test('a provider withdraws only the error it reported', async (t) => {
const provider = fakeProvider(); const provider = fakeProvider();
const { layer } = await enabledLayer(t, [provider]); const { layer } = await enabledLayer(t, [provider]);
const { actions } = provider.context(); const { actions } = provider.context();
@@ -298,7 +298,7 @@ test('a provider withdraws only the error it reported (review P3)', async (t) =>
assert.match(layer.getUIState().street.error, /Open the Street Level panel/); assert.match(layer.getUIState().street.error, /Open the Street Level panel/);
}); });
test('closing the photo stops the globe flying to it (review IC8 P1)', async (t) => { test('closing the photo stops the globe flying to it', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]); const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
layer.attachViewerHost({}); layer.attachViewerHost({});
assert.equal(await layer.openImage('mapillary', 'img1'), true); assert.equal(await layer.openImage('mapillary', 'img1'), true);
@@ -307,7 +307,7 @@ test('closing the photo stops the globe flying to it (review IC8 P1)', async (t)
assert.equal(viewer.flights.cancelled, 1); assert.equal(viewer.flights.cancelled, 1);
}); });
test('switching the layer off or destroying it stops the framing flight (review IC8 P1)', async (t) => { test('switching the layer off or destroying it stops the framing flight', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]); const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
layer.attachViewerHost({}); layer.attachViewerHost({});
await layer.openImage('mapillary', 'img1'); await layer.openImage('mapillary', 'img1');
@@ -320,7 +320,7 @@ test('switching the layer off or destroying it stops the framing flight (review
assert.equal(viewer.flights.cancelled, 2, 'destroyed'); assert.equal(viewer.flights.cancelled, 2, 'destroyed');
}); });
test('closing the photo leaves a newer navigation flight alone (review IC8 P1)', async (t) => { test('closing the photo leaves a newer navigation flight alone', async (t) => {
const { layer, viewer } = await enabledLayer(t, [posingProvider()]); const { layer, viewer } = await enabledLayer(t, [posingProvider()]);
layer.attachViewerHost({}); layer.attachViewerHost({});
await layer.openImage('mapillary', 'img1'); await layer.openImage('mapillary', 'img1');
@@ -331,7 +331,7 @@ test('closing the photo leaves a newer navigation flight alone (review IC8 P1)',
assert.equal(viewer.flights.cancelled, 0, 'the search flight keeps going'); assert.equal(viewer.flights.cancelled, 0, 'the search flight keeps going');
}); });
test('an older nearest lookup that answers late cannot replace a newer one (review IC8 P2)', async (t) => { test('an older nearest lookup that answers late cannot replace a newer one', async (t) => {
const { lookups, nearestImage } = slowLookups(); const { lookups, nearestImage } = slowLookups();
const provider = fakeProvider({ nearestImage }); const provider = fakeProvider({ nearestImage });
const { layer } = await enabledLayer(t, [provider]); const { layer } = await enabledLayer(t, [provider]);
@@ -347,7 +347,7 @@ test('an older nearest lookup that answers late cannot replace a newer one (revi
assert.equal(lookups[1].signal?.aborted, false); assert.equal(lookups[1].signal?.aborted, false);
}); });
test('an image picked during a nearest lookup wins over its late answer (review IC8 P2)', async (t) => { test('an image picked during a nearest lookup wins over its late answer', async (t) => {
const { lookups, nearestImage } = slowLookups(); const { lookups, nearestImage } = slowLookups();
const provider = fakeProvider({ nearestImage }); const provider = fakeProvider({ nearestImage });
const { layer } = await enabledLayer(t, [provider]); const { layer } = await enabledLayer(t, [provider]);
@@ -361,7 +361,7 @@ test('an image picked during a nearest lookup wins over its late answer (review
assert.equal(lookups[0].signal?.aborted, true); assert.equal(lookups[0].signal?.aborted, true);
}); });
test('closing the viewer or switching the layer off retires a nearest lookup (review IC8 P2)', async (t) => { test('closing the viewer or switching the layer off retires a nearest lookup', async (t) => {
const { lookups, nearestImage } = slowLookups(); const { lookups, nearestImage } = slowLookups();
const provider = fakeProvider({ nearestImage }); const provider = fakeProvider({ nearestImage });
const { layer, viewer } = await enabledLayer(t, [provider]); const { layer, viewer } = await enabledLayer(t, [provider]);
@@ -393,7 +393,7 @@ test('closing the viewer or switching the layer off retires a nearest lookup (re
assert.equal(lookups[1].signal?.aborted, true, 'layer off aborts it too'); assert.equal(lookups[1].signal?.aborted, true, 'layer off aborts it too');
}); });
test('the Mapillary provider hands the lookup signal to its source (review IC8 P2)', async () => { test('the Mapillary provider hands the lookup signal to its source', async () => {
const requests = []; const requests = [];
const source = { const source = {
hasToken: () => true, hasToken: () => true,
+3 -8
View File
@@ -6,11 +6,7 @@ import { createHorizonCull } from './view.js';
const SPRITE_ID = 'street-level:marker'; const SPRITE_ID = 'street-level:marker';
/** /** On-globe marker for the open image; in terrain mode it stands on bare earth. */
* The on-globe marker for the image the viewer currently shows. In terrain
* mode (Google 3D at street zoom) it stands on the bare earth instead of
* clamping to the top of the photoreal mesh.
*/
export function createMarker({ state, parts }) { export function createMarker({ state, parts }) {
const { render, sprites } = state.services; const { render, sprites } = state.services;
let last = null; let last = null;
@@ -36,9 +32,8 @@ export function createMarker({ state, parts }) {
} }
/** /**
* Height for the position in terrain mode: bare earth, refined by the * Terrain-mode height (bare earth refined by mesh samples), or null to
* sampled Google 3D surface where it is known; null to clamp. Fetches a * clamp. Requests a missing terrain cell or mesh sample as a side effect.
* missing terrain cell and asks for a mesh sample under the marker.
*/ */
function castHeight(position) { function castHeight(position) {
const ground = parts?.groundCaster; const ground = parts?.groundCaster;
+1 -1
View File
@@ -52,7 +52,7 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
return { state, view, preRender, marker, frame }; return { state, view, preRender, marker, frame };
} }
test('the photo marker is hidden behind the globe, and shows from its side (review IC8 P2)', () => { test('the photo marker is hidden behind the globe, and shows from its side', () => {
const { state, view, preRender, marker, frame } = setup(); const { state, view, preRender, marker, frame } = setup();
// Placed on the far side of the Earth from the camera. // Placed on the far side of the Earth from the camera.
marker.set({ lon: 58.51, lat: -38.58 }, 90); marker.set({ lon: 58.51, lat: -38.58 }, 90);
+9 -29
View File
@@ -2,20 +2,11 @@ import * as Cesium from 'cesium';
import { metresBetween, whenIdle } from './view.js'; import { metresBetween, whenIdle } from './view.js';
/** /**
* Rendered-surface heights for ground casting on Google 3D (see groundCast.js * Google 3D surface heights for ground casting (groundCast.js refineHeights):
* refineHeights). One `scene.sampleHeight` probe per ~11 m cell, cached, so * one cached `scene.sampleHeight` probe per ~11 m cell. Each probe renders a
* the many sequences that share a street share their samples. * pick pass (1–3 ms), so probes run in budgeted idle slices, nearest the
* * camera first and only within MESH_SAMPLE_RADIUS_M. A miss (tiles not
* `sampleHeight` renders a pick pass per probe (about 1–3 ms), so probes run * streamed yet) is retried later. Probes hit 3D tilesets only.
* in idle time within a small budget, nearest the camera first, and only
* within MESH_SAMPLE_RADIUS_M of the point under the camera: the streamed
* detail is fine there, and an error is visible at all. Requests are taken
* in by the same budgeted slices, cells out of range then are not queued
* (callers ask again when the camera moves), and the queue is a heap ranked
* from where the camera was, re-ranked only once it has moved MESH_RERANK_M,
* so no slice walks or sorts the whole queue. A probe that hits
* nothing (tiles not streamed yet) is retried later rather than latched.
* Overlays are excluded from the probe, so only 3D tilesets are hit.
*/ */
/** Cell size, in degrees (~11 m): samples are shared within a cell. */ /** Cell size, in degrees (~11 m): samples are shared within a cell. */
@@ -35,10 +26,7 @@ const MESH_NOTIFY_MS = 700;
const cellOf = (value) => Math.round(value / MESH_CELL_DEG); 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). */
* The cache key of the mesh cell holding a point, one per ~11 m cell: a
* number (exact well within 2^53), as a string key would be built per point.
*/
export const meshCellKey = (lon, lat) => export const meshCellKey = (lon, lat) =>
(cellOf(lon) + 1_800_001) * 2_000_000 + cellOf(lat) + 1_000_000; (cellOf(lon) + 1_800_001) * 2_000_000 + cellOf(lat) + 1_000_000;
@@ -70,7 +58,6 @@ function siftDown(heap, i) {
heap[i] = cell; heap[i] = cell;
} }
/** Take the nearest cell off a min-heap. */
function heapPop(heap) { function heapPop(heap) {
const top = heap[0]; const top = heap[0];
const last = heap.pop(); const last = heap.pop();
@@ -104,11 +91,7 @@ export function createMeshSampler({
}) { }) {
/** cell key → sampled mesh height (ellipsoidal metres). */ /** cell key → sampled mesh height (ellipsoidal metres). */
const heights = new Map(); const heights = new Map();
/** /** cell key → retry time for probes that hit nothing; bounded like the height cache. */
* cell key → retry time for probes that hit nothing. An entry goes once
* its cell is sampled or asked for after the retry time, and with the
* height cache, so the map stays bounded.
*/
const misses = new Map(); const misses = new Map();
/** cell key → queued cell, so a cell is queued once. */ /** cell key → queued cell, so a cell is queued once. */
const wanted = new Map(); const wanted = new Map();
@@ -142,10 +125,8 @@ export function createMeshSampler({
} }
/** /**
* Ask for the cells under these [lon, lat] points (read later, so do not * Ask for the cells under [lon, lat] points; the list is read later, so do
* change the list). Nothing is done here: idle slices take requests in * not mutate it. Out-of-range cells are dropped; callers ask again on move.
* within their budget, and drop cells out of range of where the camera was
* when asked; callers ask again once it moves.
*/ */
function request(points) { function request(points) {
if (!enabled || !points?.length) return; if (!enabled || !points?.length) return;
@@ -288,7 +269,6 @@ export function createMeshSampler({
schedule(); schedule();
} }
/** Drop every sample and remembered miss. */
function forget() { function forget() {
heights.clear(); heights.clear();
misses.clear(); misses.clear();
+5 -5
View File
@@ -109,7 +109,7 @@ test('a missed cell is probed again after the retry window, not before', (t) =>
sampler.destroy(); sampler.destroy();
}); });
test('remembered misses are bounded like the samples (review P3)', (t) => { test('remembered misses are bounded like the samples', (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, probes } = fakeViewer({ holes: () => true }); const { viewer, probes } = fakeViewer({ holes: () => true });
const sampler = createMeshSampler({ getViewer: () => viewer, cacheMax: 2 }); const sampler = createMeshSampler({ getViewer: () => viewer, cacheMax: 2 });
@@ -191,7 +191,7 @@ function grid(lon, lat, n, step) {
return points; return points;
} }
test('cells out of range when asked are not queued, even once the camera reaches them (review IC8 P2)', (t) => { test('cells out of range when asked are not queued, even once the camera reaches them', (t) => {
const { sampler, probes, slice, moveTo } = clocked(t); const { sampler, probes, slice, moveTo } = clocked(t);
// 400 cells ~3.6 km east, past the sampler's range. // 400 cells ~3.6 km east, past the sampler's range.
sampler.request(grid(10.05, 50, 20, 0.0002)); sampler.request(grid(10.05, 50, 20, 0.0002));
@@ -206,7 +206,7 @@ test('cells out of range when asked are not queued, even once the camera reaches
sampler.destroy(); sampler.destroy();
}); });
test('a slice keeps to its budget, counting the work before its first probe (review IC8 P2)', (t) => { 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 }); const { sampler, probes, slice } = clocked(t, { probeMs: 2, cameraMs: 3 });
sampler.request(grid(10.001, 50, 5, 0.0002)); sampler.request(grid(10.001, 50, 5, 0.0002));
const lengths = []; const lengths = [];
@@ -217,7 +217,7 @@ test('a slice keeps to its budget, counting the work before its first probe (rev
sampler.destroy(); sampler.destroy();
}); });
test('a large queue is ranked once, not sorted again every slice (review IC8 P2)', (t) => { test('a large queue is ranked once, not sorted again every slice', (t) => {
const { sampler, probes, slice, moveTo } = clocked(t); const { sampler, probes, slice, moveTo } = clocked(t);
// 2,500 cells ~17 m apart around the camera, all within range. // 2,500 cells ~17 m apart around the camera, all within range.
const cells = grid(10, 50, 50, 0.00015); const cells = grid(10, 50, 50, 0.00015);
@@ -255,7 +255,7 @@ test('a large queue is ranked once, not sorted again every slice (review IC8 P2)
sampler.destroy(); sampler.destroy();
}); });
test('the sampling range is measured the short way round the date line (review IC8 P2)', (t) => { test('the sampling range is measured the short way round the date line', (t) => {
const { sampler, probes, slice, moveTo } = clocked(t); const { sampler, probes, slice, moveTo } = clocked(t);
moveTo(179.9999, 0); moveTo(179.9999, 0);
sampler.request([ sampler.request([
+4 -5
View File
@@ -1,9 +1,8 @@
import { normalizeFilter } from './filter.js'; import { normalizeFilter } from './filter.js';
/** /**
* Share-link and stored parameters: one boolean per registered provider plus * Share-link params: one boolean per provider plus the filter. The codec in
* the shared imagery filter. The codec in src/data/layerState.js names the * src/data/layerState.js names the same keys, so a new provider needs one there.
* same keys, so a provider added later also needs an option there.
* @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input * @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input
*/ */
export function encodeParams({ providers, filter }) { export function encodeParams({ providers, filter }) {
@@ -15,8 +14,8 @@ export function encodeParams({ providers, filter }) {
} }
/** /**
* Read parameters back. Unknown providers and malformed values are ignored, * Read params back, ignoring unknown providers and malformed values so a link
* so a link written for a build with more providers still applies cleanly. * from a build with more providers still applies.
* @param {object} params * @param {object} params
* @param {{providerIds: Iterable<string>, filter: {pano: string, sinceDays: number}}} current * @param {{providerIds: Iterable<string>, filter: {pano: string, sinceDays: number}}} current
* @returns {{providers: Map<string, boolean>, filter: {pano: string, sinceDays: number}}} * @returns {{providers: Map<string, boolean>, filter: {pano: string, sinceDays: number}}}
+2 -3
View File
@@ -1,7 +1,6 @@
/** /**
* Route picked primitive ids to the provider whose prefix they carry. The * Route picked ids to the provider whose prefix they carry; the registry
* registry guarantees prefixes never overlap, so the first match is the only * guarantees prefixes never overlap.
* match.
* @param {() => Iterable<{def: {id: string, pickPrefix: string}, instance: object}>} getProviders * @param {() => Iterable<{def: {id: string, pickPrefix: string}, instance: object}>} getProviders
* @param {{positionId?: string}} [options] Id of the core-owned position marker. * @param {{positionId?: string}} [options] Id of the core-owned position marker.
*/ */
+4 -13
View File
@@ -4,12 +4,7 @@ export const STREET_LEVEL_LAYER_ID = 'street-level';
/** Pick id of the viewer position marker; the core owns it, not a provider. */ /** Pick id of the viewer position marker; the core owns it, not a provider. */
export const POSITION_PICK_ID = 'sl:pos'; export const POSITION_PICK_ID = 'sl:pos';
/** /** One colour per imagery source: chip, coverage lines, points, cones and legend. */
* One colour per imagery source, used everywhere that source appears: its
* chip, coverage lines, overview points, image cones and legend swatch.
* Registered providers take their entry from here; a new source adds its
* colour together with its provider.
*/
export const PROVIDER_COLORS = Object.freeze({ export const PROVIDER_COLORS = Object.freeze({
mapillary: '#05cb63', mapillary: '#05cb63',
}); });
@@ -29,16 +24,12 @@ export const MAX_SINCE_DAYS = 36_500;
/** Filter every provider starts with: all imagery, any date. */ /** Filter every provider starts with: all imagery, any date. */
export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 }); export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
/** /** Nearest-image search radius in metres (Mapillary's graph API caps it at 50 m). */
* Nearest-image search when the user asks to look at a place, in metres
* (Mapillary's graph API answers within at most 50 m).
*/
export const NEAREST_RADIUS_M = 50; export const NEAREST_RADIUS_M = 50;
/** /**
* Map stack the globe camera may follow the street-level view on. Only Google * The only map stack camera follow is offered on: elsewhere a camera at eye
* Photorealistic 3D has buildings to stand among; on flat imagery or terrain * height looks at a smeared texture.
* a camera at eye height looks at a smeared texture.
*/ */
export const FOLLOW_MAP_STACK_ID = 'photoreal'; export const FOLLOW_MAP_STACK_ID = 'photoreal';
@@ -41,11 +41,7 @@ const REMESH_INTERVAL_MS = 1500;
const REMESH_STAGGER_MS = 120; const REMESH_STAGGER_MS = 120;
/** Old-zoom tiles are kept at most this long after a zoom change. */ /** Old-zoom tiles are kept at most this long after a zoom change. */
const STALE_TILE_MAX_MS = 6000; const STALE_TILE_MAX_MS = 6000;
/** /** Sequences per primitive: smaller batches build, and show, sooner. */
* Sequences per ground primitive. Ground polyline geometry is built on a
* worker in proportion to the instance count, so smaller batches put the
* first lines on screen sooner instead of one big batch arriving late.
*/
const SEQUENCE_PRIMITIVE_BATCH = 120; const SEQUENCE_PRIMITIVE_BATCH = 120;
const PER_TILE_SEQUENCE_CAP = Math.floor( const PER_TILE_SEQUENCE_CAP = Math.floor(
@@ -71,12 +67,7 @@ function partIds(sequence) {
); );
} }
/** /** Sequence id for any part's pick id (`mly:seq:<id>[~<part>]`), else null. */
* Sequence id for a picked line, whichever part was hit: `mly:seq:<id>` and
* `mly:seq:<id>~<part>` both give `<id>`; null for any other pick.
* @param {string} pickId
* @returns {string|null}
*/
export function sequenceIdFromPick(pickId) { export function sequenceIdFromPick(pickId) {
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX.sequence)) if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX.sequence))
return null; return null;
@@ -86,22 +77,11 @@ export function sequenceIdFromPick(pickId) {
} }
/** /**
* Camera-driven coverage: z0–5 `overview` points from orbit down to 60 km, * Whether any point of `box` ({west, south, east, north}) is within the mesh
* then z11–14 sequence polylines clamped to terrain and 3D tiles. Decoded * sampler's range of `centre`, the ground point under the camera.
* tiles are kept so the imagery filter can rebuild without refetching.
*
* In the core's terrain surface mode (Google 3D at street zoom) a tile's
* lines are cast to the bare earth instead: draped lines would land on roofs
* and tree tops. A tile is drawn draped first and swapped for its cast lines
* once the terrain heights are in, so coverage never waits on the terrain.
*/
/**
* Whether any point of a line's bounding `box` is within the mesh sampler's
* range of the camera ground point `centre`, measured the short way round
* the date line.
*/ */
export function meshBoxInRange(box, centre) { export function meshBoxInRange(box, centre) {
// Take the camera's longitude to the box's side of the date line first: // 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. // clamping 179.999 into a box at -179.99 would pick its far edge.
const lon = const lon =
centre.lon + centre.lon +
@@ -113,6 +93,12 @@ export function meshBoxInRange(box, centre) {
return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M; 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 }) { export function createCoverage({ state, source }) {
const { render } = state.services; const { render } = state.services;
@@ -142,9 +128,8 @@ export function createCoverage({ state, source }) {
} }
/** /**
* The sequences a tile draws: newest first, past the imagery filter, then * A tile's sequences, filtered then capped: capping first could leave a
* capped. Capping before the filter could leave a dense tile of newer flat * dense tile of newer flat captures with no 360° lines at all.
* captures with no 360° lines at all.
*/ */
function drawnSequences(entry) { function drawnSequences(entry) {
const drawn = []; const drawn = [];
@@ -157,11 +142,9 @@ export function createCoverage({ state, source }) {
} }
/** /**
* Primitives for a tile's sequences, in draw batches: ground primitives * Batched primitives for a tile's sequence parts: cast polylines where
* draped on the globe, plus (in terrain mode) plain polylines at the cast * terrain heights are cached, draped ground lines otherwise. Each batch
* heights for every part whose heights are cached. Each part of a sequence * records the selection its colours were built with.
* with a capture gap is its own line. Each batch remembers the selection
* its colours were built with.
*/ */
function buildSequencePrimitives(sequences) { function buildSequencePrimitives(sequences) {
const ground = terrainMode() ? state.context.groundCaster : null; const ground = terrainMode() ? state.context.groundCaster : null;
@@ -233,13 +216,11 @@ export function createCoverage({ state, source }) {
} }
/** /**
* Fetch the terrain heights a tile's lines need, then redraw the tile cast. * Fetch the terrain heights a tile's lines need, then redraw it cast. A
* `castRequested` holds while the tile's castable lines are cast; a cast * cast that resolves nothing clears `castRequested`, so the next refresh
* that resolves nothing (terrain proxy down, tile too big) clears it, so * retries instead of this looping.
* the next refresh tries again rather than this retrying in a loop.
*/ */
function castTile(entry) { function castTile(entry) {
// `castAbort` is set while a cast is in flight.
if (entry.castRequested || entry.castAbort || !terrainMode()) return; if (entry.castRequested || entry.castAbort || !terrainMode()) return;
entry.castRequested = true; entry.castRequested = true;
entry.castAbort = new AbortController(); entry.castAbort = new AbortController();
@@ -264,10 +245,7 @@ export function createCoverage({ state, source }) {
}); });
} }
/** /** Rebuild a tile's lines; the old ones stay up until the new are ready. */
* Rebuild a tile's lines (cast, or with new mesh heights), keeping the old
* ones on screen until the new ones are built, so nothing blinks.
*/
function redrawTile(entry) { function redrawTile(entry) {
const previous = entry.primitives; const previous = entry.primitives;
entry.primitives = []; entry.primitives = [];
@@ -275,7 +253,7 @@ export function createCoverage({ state, source }) {
removeWhenReady(entry, previous); removeWhenReady(entry, previous);
} }
/** The point under the camera: where the mesh sampler measures its range from. */ /** Lon/lat under the camera, where mesh range is measured from. */
function meshCentre() { function meshCentre() {
const carto = state.viewer?.camera?.positionCartographic; const carto = state.viewer?.camera?.positionCartographic;
if (!carto) return null; if (!carto) return null;
@@ -285,11 +263,9 @@ export function createCoverage({ state, source }) {
}; };
} }
/** Whether any of a {west, south, east, north} box is within the sampler's range. */
/** /**
* A tile's drawn lines with their bounding boxes, built once per filter; * A tile's drawn lines with bounding boxes, built once per filter; mesh
* each line's mesh points are densified the first time it is in range. * points are densified the first time a line is in range.
*/ */
function meshParts(entry) { function meshParts(entry) {
if (entry.meshParts) return entry.meshParts; if (entry.meshParts) return entry.meshParts;
@@ -332,10 +308,7 @@ export function createCoverage({ state, source }) {
if (points.length) sampler.request(points); if (points.length) sampler.request(points);
} }
/** /** Redraw the tiles new mesh samples fall in, throttled. */
* New mesh samples landed: redraw the tiles they fall in, throttled, with
* the same no-blink swap as the bare-earth cast.
*/
function onMeshSampled(batch) { function onMeshSampled(batch) {
if (!terrainMode() || !state.context.isActive()) return; if (!terrainMode() || !state.context.isActive()) return;
for (const entry of state.coverage.tiles.values()) { for (const entry of state.coverage.tiles.values()) {
@@ -543,9 +516,8 @@ export function createCoverage({ state, source }) {
}); });
if (kind === 'sequence') await ensureTerrainReady(); if (kind === 'sequence') await ensureTerrainReady();
const bytes = await fetching; const bytes = await fetching;
// A refresh that still wants this tile must not throw the bytes away; // Only an abort, a retire, a clear or a newer request for this tile
// only an abort (tile no longer wanted, or zoom changed) does. // discards the bytes; a refresh that still wants the tile keeps them.
// A retire, clear or newer request for this tile owns it now.
const current = () => const current = () =>
state.coverage.pending.get(key) === controller && state.coverage.pending.get(key) === controller &&
!state.coverage.tiles.has(key); !state.coverage.tiles.has(key);
@@ -644,10 +616,7 @@ export function createCoverage({ state, source }) {
requestRender(); requestRender();
} }
/** /** Move tiles to the stale set: the old zoom shows until the new one loads. */
* Move every loaded tile to the stale set instead of removing it, so the
* old zoom stays visible while the new zoom streams in (no blank globe).
*/
function retire() { function retire() {
for (const controller of state.coverage.pending.values()) for (const controller of state.coverage.pending.values())
controller.abort(); controller.abort();
@@ -812,9 +781,8 @@ export function createCoverage({ state, source }) {
} }
/** /**
* Redraw loaded tiles draped or cast after the core's surface mode changes, * Redraw tiles draped or cast for the new surface mode, then re-pick the
* then re-pick the zoom: the bare-earth height under the camera that the * zoom, since the ground height under the camera may have changed.
* change brings in can move it (a high city reads much closer to the street).
*/ */
function setSurface() { function setSurface() {
if (!state.context.isActive()) return; if (!state.context.isActive()) return;
@@ -839,10 +807,7 @@ export function createCoverage({ state, source }) {
} }
} }
/** /** Every tile on the globe: the current zoom's and the stale zoom's. */
* Every tile drawn on the globe: the current zoom's, and the old zoom's
* kept visible until the new one is in (see retire).
*/
function drawnEntries() { function drawnEntries() {
return [...state.coverage.tiles.values(), ...state.coverage.stale.values()]; return [...state.coverage.tiles.values(), ...state.coverage.stale.values()];
} }
@@ -886,10 +851,8 @@ export function createCoverage({ state, source }) {
} }
/** /**
* Colours are baked in when a tile's lines are built, and a primitive * A primitive cannot be recoloured until it is ready, so while any is
* cannot be recoloured until it is ready. Cast swaps and remeshes rebuild * building, catch each one up with the selection as it becomes ready.
* tiles all the time, so while any primitive is building, catch each one
* up with the selection made or cleared meanwhile as it becomes ready.
*/ */
function watchSelection() { function watchSelection() {
const scene = state.viewer?.scene; const scene = state.viewer?.scene;
@@ -171,7 +171,7 @@ function frame(listeners) {
for (const listener of [...listeners]) listener(); for (const listener of [...listeners]) listener();
} }
test('a superseded tile request cannot strand lines on the globe (review P0 #2)', async () => { test('a superseded tile request cannot strand lines on the globe', async () => {
const { viewer, source, state, coverage, bytes, tileKey, onGlobe } = setup(); const { viewer, source, state, coverage, bytes, tileKey, onGlobe } = setup();
const forTile = () => source.calls.filter((call) => call.key === tileKey); const forTile = () => source.calls.filter((call) => call.key === tileKey);
@@ -304,7 +304,7 @@ test('zoom 0 is a zoom: the whole-earth view still shows coverage', () => {
coverage.clear(); coverage.clear();
}); });
test('a rejected key stops coverage requests until the layer goes off (review IC8 P2)', async () => { test('a rejected key stops coverage requests until the layer goes off', async () => {
const { viewer, source, state, coverage } = setup(); const { viewer, source, state, coverage } = setup();
coverage.refresh(); coverage.refresh();
const rejected = Object.assign( const rejected = Object.assign(
@@ -330,7 +330,7 @@ test('a rejected key stops coverage requests until the layer goes off (review IC
coverage.clear(); coverage.clear();
}); });
test('a rate limit keeps the drawn tiles and asks again once the wait is over (review IC8 P2)', async (t) => { 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'] }); t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, source, state, coverage } = setup(); const { viewer, source, state, coverage } = setup();
coverage.refresh(); coverage.refresh();
@@ -566,7 +566,7 @@ test('a selection made or cleared while a tile builds is applied once it is read
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone'); assert.equal(viewer.postRender.size, 0, 'the selection watch is gone');
}); });
test('a sequence that crosses a tile edge is counted once (review P3)', async () => { test('a sequence that crosses a tile edge is counted once', async () => {
const { viewer, source, coverage, centre } = setup(); const { viewer, source, coverage, centre } = setup();
// Look straight down on the edge between this tile and its east neighbour. // Look straight down on the edge between this tile and its east neighbour.
const { east } = tileBounds(centre.tile.x, centre.tile.y, 14); const { east } = tileBounds(centre.tile.x, centre.tile.y, 14);
@@ -598,10 +598,7 @@ test('a sequence that crosses a tile edge is counted once (review P3)', async ()
coverage.clear(); coverage.clear();
}); });
/** /** A terrain-mode tile mid-swap, with a cast primitive the test readies. */
* In terrain mode with a cast tile mid-swap: the draped lines and the cast
* primitive whose readiness the test controls.
*/
async function midSwap() { async function midSwap() {
// Drawn draped first; castLine only answers once the heights are in. // Drawn draped first; castLine only answers once the heights are in.
let heightsReady = false; let heightsReady = false;
@@ -665,9 +662,8 @@ function tiltedCamera({ lon, lat, height, heading, pitch }) {
} }
/** /**
* Denver, 300 m above a street 1,600 m above the ellipsoid, at the centre of * Denver: 300 m above a street 1,600 m up, at a z14 tile centre, looking east
* a z14 tile and looking east with the screen centre `aheadM` metres out. * at ground `aheadM` metres out. Only the bare-earth caster knows the ground.
* Google 3D hides the globe, so only the bare-earth caster knows the ground.
*/ */
function denverStreetView(aheadM) { function denverStreetView(aheadM) {
const z = 14; const z = 14;
@@ -735,7 +731,7 @@ test('a street view toward the horizon ranks only the ground within range', () =
coverage.clear(); coverage.clear();
}); });
test('a selection cleared while the old zoom is still shown uncolours its lines (review IC8 P2)', async () => { test('a selection cleared while the old zoom is still shown uncolours its lines', async () => {
const { viewer, source, state, coverage, bytes } = setup(); const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh(); coverage.refresh();
source.calls[0].resolve(bytes); source.calls[0].resolve(bytes);
@@ -760,7 +756,7 @@ test('a selection cleared while the old zoom is still shown uncolours its lines
coverage.clear(); coverage.clear();
}); });
test('a selection made while the old zoom is still building is applied once it is ready (review IC8 P2)', async () => { 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(); const { viewer, source, state, coverage, bytes } = setup();
coverage.refresh(); coverage.refresh();
source.calls[0].resolve(bytes); source.calls[0].resolve(bytes);
@@ -784,7 +780,7 @@ test('a selection made while the old zoom is still building is applied once it i
assert.equal(viewer.postRender.size, 0, 'the selection watch is gone'); 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 (review IC8 P2)', async () => { test('only lines within the mesh sampler range of the camera are asked for', async () => {
let heightsReady = false; let heightsReady = false;
const groundCaster = { const groundCaster = {
prepareLines: async () => (heightsReady = true), prepareLines: async () => (heightsReady = true),
@@ -874,7 +870,7 @@ test('the old zoom goes as soon as the new zoom has loaded, not after the stale
coverage.clear(); coverage.clear();
}); });
test('a rate limit holds every refresh inside its wait, not only the first (review IC8 P2)', async (t) => { test('a rate limit holds every refresh inside its wait, not only the first', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] }); t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
const { viewer, source, coverage } = setup(); const { viewer, source, coverage } = setup();
coverage.refresh(); coverage.refresh();
@@ -898,7 +894,7 @@ test('a rate limit holds every refresh inside its wait, not only the first (revi
coverage.resetErrors(); coverage.resetErrors();
}); });
test('a line across the date line from the camera is in mesh range (gekh P3)', () => { 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. // 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 camera = { lon: 179.999, lat: 0 };
const east = { west: -180, east: -179.978, south: -0.001, north: 0.001 }; const east = { west: -180, east: -179.978, south: -0.001, north: 0.001 };
@@ -1,11 +1,5 @@
import { PbfWriter } from 'pbf'; import { PbfWriter } from 'pbf';
/**
* Encode a Mapillary-shaped coverage tile (mly1_public: `sequence` lines and
* `overview` points) from lon/lat features, for tests and the hermetic QA
* gate. The decoder in ./decode.js reads it like a real tile.
*/
const zigzag = (value) => (value << 1) ^ (value >> 31); const zigzag = (value) => (value << 1) ^ (value >> 31);
/** Tile-local [x, y] (0..extent) of a lon/lat inside tile (x, y, z). */ /** Tile-local [x, y] (0..extent) of a lon/lat inside tile (x, y, z). */
@@ -67,7 +61,7 @@ function layerWriter(name, extent) {
}; };
} }
/** MoveTo/LineTo commands for line parts, with the cursor carried across parts. */ /** MVT MoveTo/LineTo commands; the cursor carries over between parts. */
function lineGeometry(parts) { function lineGeometry(parts) {
const out = []; const out = [];
let cx = 0; let cx = 0;
@@ -89,9 +83,10 @@ function lineGeometry(parts) {
} }
/** /**
* Encode a `mly1_public`-shaped tile from lon/lat features, for tests and
* the hermetic QA gate.
* @param {{x: number, y: number, z: number}} tile * @param {{x: number, y: number, z: number}} tile
* @param {{sequences?: Array<{id: string, capturedAt?: number, isPano?: boolean, parts: Array<Array<[number, number]>>}>, overview?: Array<{id: string, lon: number, lat: number, capturedAt?: number, isPano?: boolean}>, extent?: number}} [content] * @param {{sequences?: Array<{id: string, capturedAt?: number, isPano?: boolean, parts: Array<Array<[number, number]>>}>, overview?: Array<{id: string, lon: number, lat: number, capturedAt?: number, isPano?: boolean}>, extent?: number}} [content]
* @returns {Uint8Array}
*/ */
export function encodeCoverageTile( export function encodeCoverageTile(
tile, tile,
@@ -3,13 +3,10 @@ import { VectorTile } from '@mapbox/vector-tile';
import { tileLocalToLonLat } from '../../tileMath.js'; import { tileLocalToLonLat } from '../../tileMath.js';
/** /**
* Decode a Mapillary coverage tile (`mly1_public`) in the browser. * Decode a `mly1_public` coverage tile into sequences and (z0–5) overview
* Sequences are LineStrings with capture metadata; z0–5 tiles carry * points. The z14 `image` layer, which can hold >150k points, is skipped.
* `overview` points instead. The z14 `image` layer (it can hold >150k
* points) is never read.
* @param {Uint8Array} bytes * @param {Uint8Array} bytes
* @param {{x:number,y:number,z:number}} address * @param {{x:number,y:number,z:number}} address
* @returns {{sequences: Array<object>, overview: Array<object>}}
*/ */
export function decodeCoverageTile(bytes, address) { export function decodeCoverageTile(bytes, address) {
const result = { sequences: [], overview: [] }; const result = { sequences: [], overview: [] };
@@ -44,7 +41,6 @@ export function decodeCoverageTile(bytes, address) {
}); });
} }
} }
// z0–5 tiles carry an `overview` point layer instead of sequences.
const overviewLayer = tile.layers.overview; const overviewLayer = tile.layers.overview;
if (overviewLayer) { if (overviewLayer) {
for (let i = 0; i < overviewLayer.length; i++) { for (let i = 0; i < overviewLayer.length; i++) {
@@ -23,19 +23,16 @@ function createProviderState(context) {
services: context.services, services: context.services,
viewer: null, viewer: null,
keyRequired: false, keyRequired: false,
/** /** Coverage waits for the first status: no tile requests without a key. */
* False until the first status answer: coverage asks for nothing before
* it, so a key-less install never sends tile requests that get 503.
*/
statusKnown: false, statusKnown: false,
/** Mapillary refused the configured token: nothing is requested until re-enabled. */ /** Mapillary refused the token: request nothing until re-enabled. */
keyRejected: false, keyRejected: false,
status: null, status: null,
coverage: { coverage: {
zoom: null, zoom: null,
/** @type {Map<string, {primitive: object|null, sequences: Map<string, object>, count: number}>} */ /** Current zoom's tiles by `z/x/y` key. */
tiles: new Map(), tiles: new Map(),
/** Tiles from the previous zoom, kept on screen until replacements land. */ /** Previous zoom's tiles, kept on screen until replacements land. */
stale: new Map(), stale: new Map(),
staleTimer: null, staleTimer: null,
/** Tile key → its request's controller; any entry means LOADING. */ /** Tile key → its request's controller; any entry means LOADING. */
@@ -53,7 +50,7 @@ function createProviderState(context) {
sequence: { sequence: {
selectedId: null, selectedId: null,
images: [], images: [],
/** @type {Map<string, Array<object>>} recent sequences' thinned images */ /** Recent sequences' thinned images, by sequence id. */
cache: new Map(), cache: new Map(),
collection: null, collection: null,
loading: false, loading: false,
@@ -63,9 +60,8 @@ function createProviderState(context) {
} }
/** /**
* Mapillary as a Street Level provider: coverage from cached vector tiles, * Mapillary Street Level provider: tile coverage, Graph API image cones and
* per-sequence image cones from the Graph API and the MapillaryJS viewer. * the MapillaryJS viewer. `source` is ./source.js or a stand-in.
* @param {{source: object}} options A source from ./source.js (or a stand-in with the same methods).
* @returns {import('../../registry.js').StreetLevelProvider} * @returns {import('../../registry.js').StreetLevelProvider}
*/ */
export function createMapillaryProvider({ source }) { export function createMapillaryProvider({ source }) {
@@ -24,10 +24,7 @@ export const PICK_PREFIX = Object.freeze({
image: 'mly:img:', image: 'mly:img:',
}); });
/** /** Mapillary green for lines, points and cones; GEV cyan for selection. */
* Mapillary draws in one colour, its brand green, for lines, overview points
* and cones alike (360° cones keep their ring shape); selection is GEV cyan.
*/
export const COLORS = Object.freeze({ export const COLORS = Object.freeze({
coverage: PROVIDER_COLORS.mapillary, coverage: PROVIDER_COLORS.mapillary,
selected: SHARED_COLORS.selected, selected: SHARED_COLORS.selected,
@@ -45,9 +42,8 @@ export const KEY_REJECTED_MESSAGE =
export const RATE_LIMITED_MESSAGE = export const RATE_LIMITED_MESSAGE =
'Coverage requests are being rate-limited — they resume on their own'; 'Coverage requests are being rate-limited — they resume on their own';
/** /**
* How far from the camera a street-zoom view may reach for coverage: a tilted * Street-zoom coverage reaches this far from the camera: camera height times
* view sees to the horizon, but only the ground within this range is ranked * the factor, never below the floor, even when a tilted view sees further.
* and boxed. Scales with camera height, never below the floor.
*/ */
export const SEQUENCE_VIEW_RANGE_MIN_M = 2_500; export const SEQUENCE_VIEW_RANGE_MIN_M = 2_500;
export const SEQUENCE_VIEW_RANGE_PER_HEIGHT = 10; export const SEQUENCE_VIEW_RANGE_PER_HEIGHT = 10;
@@ -65,10 +61,7 @@ export const IMAGE_CONE_MIN_SPACING_M = 3;
/** Images asked for in a nearest-image search (radius: NEAREST_RADIUS_M). */ /** Images asked for in a nearest-image search (radius: NEAREST_RADIUS_M). */
export const NEAREST_LIMIT = 8; export const NEAREST_LIMIT = 8;
/** /** On-globe credit: Mapillary imagery is CC BY-SA 4.0 and needs attribution. */
* On-globe credit shown while the provider is active. Mapillary imagery and
* derived data are CC BY-SA 4.0 and require visible attribution.
*/
export const MAPILLARY_CREDIT_HTML = 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'; 'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, CC BY-SA 4.0';
@@ -120,7 +120,7 @@ test('nearestImage picks the closest image, not the first the API returned', asy
assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'near'); assert.equal(await instance.nearestImage({ lat: 1, lon: 2 }), 'near');
}); });
test('a "since N days" window follows the clock in a long-open tab (review P3)', async (t) => { 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) }); t.mock.timers.enable({ apis: ['Date'], now: Date.UTC(2026, 9, 1) });
const day = 86_400_000; const day = 86_400_000;
const capturedAt = Date.now() - day / 2; const capturedAt = Date.now() - day / 2;
@@ -140,7 +140,7 @@ test('a "since N days" window follows the clock in a long-open tab (review P3)',
); );
}); });
test('nearestImage measures the short way round the date line (review IC8 P2)', async () => { test('nearestImage measures the short way round the date line', async () => {
const at = (id, lon, lat) => ({ const at = (id, lon, lat) => ({
id, id,
is_pano: false, is_pano: false,
@@ -167,7 +167,7 @@ test('nearestImage measures the short way round the date line (review IC8 P2)',
assert.equal(await west.nearestImage({ lat: 0, lon: -179.9999 }), 'across'); assert.equal(await west.nearestImage({ lat: 0, lon: -179.9999 }), 'across');
}); });
test('cone thinning across the date line drops images metres apart (review IC8 P2)', () => { test('cone thinning across the date line drops images metres apart', () => {
const image = (id, lon) => ({ id, lon, lat: 0 }); const image = (id, lon) => ({ id, lon, lat: 0 });
// A sequence driving east over ±180° with images ~1 m apart, then ~11 m on. // A sequence driving east over ±180° with images ~1 m apart, then ~11 m on.
const kept = thinImages( const kept = thinImages(
@@ -220,7 +220,7 @@ function streetViewer() {
}; };
} }
test('coverage asks for no tiles before the key status is known (gekh P3)', async () => { test('coverage asks for no tiles before the key status is known', async () => {
for (const configured of [false, true]) { for (const configured of [false, true]) {
let answer; let answer;
const tiles = []; const tiles = [];
@@ -58,12 +58,8 @@ export function createSequences({ state, source, parts }) {
state.context.notify(); state.context.notify();
} }
/**
* The cones on the globe: each drawn image's index, billboard and height
* (null while clamped), and the mesh cells under them, so a mesh sample
* moves only the cones it can affect.
*/
const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() }); const nothingDrawn = () => ({ images: [], cones: [], cells: new Set() });
/** Drawn cones, plus the mesh cells under them, so a sample moves only those. */
let drawn = nothingDrawn(); let drawn = nothingDrawn();
// The cones skip the depth test: hide the ones behind the globe. // The cones skip the depth test: hide the ones behind the globe.
@@ -105,11 +101,8 @@ export function createSequences({ state, source, parts }) {
} }
/** /**
* Heights for the cones in terrain mode (Google 3D at street zoom), so they * Terrain-mode cone heights on the street, not on roofs: bare earth refined
* stand on the street rather than on roofs and tree tops: bare earth, * by mesh samples. Null when draped or while terrain heights are fetched.
* refined along the sequence by the sampled Google 3D surface. Null when
* draped, or when a terrain height is not cached yet; that fetches the
* heights and draws the cones again.
*/ */
function coneHeights(images, { request = true } = {}) { function coneHeights(images, { request = true } = {}) {
const ground = state.context.groundCaster; const ground = state.context.groundCaster;
@@ -173,10 +166,8 @@ export function createSequences({ state, source, parts }) {
} }
/** /**
* New mesh samples landed: when any is under a cone, re-place the cones * Re-place cones whose height new mesh samples changed. A sample can move
* whose cast height changed, in place. A sample can move a neighbour too * a neighbour too, so every height is recomputed but only moved cones touched.
* (refineHeights bridges across covered points), so every height is
* recomputed (cheap arithmetic) but only moved cones are touched.
*/ */
function onMeshSampled(batch) { function onMeshSampled(batch) {
if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length) if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length)
@@ -91,7 +91,7 @@ function setup() {
return { state, source, sequences, colours, errors, drawn }; return { state, source, sequences, colours, errors, drawn };
} }
test('a failed sequence load leaves no stale cones and can be retried (review P2-4)', async () => { test('a failed sequence load leaves no stale cones and can be retried', async () => {
const { state, source, sequences, colours, errors, drawn } = setup(); const { state, source, sequences, colours, errors, drawn } = setup();
sequences.select('A'); sequences.select('A');
source.last('A').resolve(records('A')); source.last('A').resolve(records('A'));
@@ -174,9 +174,8 @@ test('clearSelection cancels the lookup and uncolours the sequence', async () =>
}); });
/** /**
* Terrain mode (Google 3D at street zoom): bare earth 10 m everywhere, a * Terrain mode with bare earth at 10 m, a mesh sampler the test drives, and
* mesh sampler whose samples the test sets and announces, and cones that * cones that log every change made to them.
* log every change made to them or their collection.
*/ */
function terrainSetup() { function terrainSetup() {
const context = setup(); const context = setup();
@@ -240,7 +239,7 @@ function terrainSetup() {
return { ...context, sequences, changes, items, sample, heightOf }; return { ...context, sequences, changes, items, sample, heightOf };
} }
test('a mesh sample away from the selected sequence changes no cone (review IC8 P1)', async () => { test('a mesh sample away from the selected sequence changes no cone', async () => {
const { source, sequences, changes, items, sample } = terrainSetup(); const { source, sequences, changes, items, sample } = terrainSetup();
sequences.select('A'); sequences.select('A');
source.last('A').resolve(records('A', 3)); source.last('A').resolve(records('A', 3));
@@ -251,7 +250,7 @@ test('a mesh sample away from the selected sequence changes no cone (review IC8
assert.deepEqual(changes, [], 'nothing rebuilt or moved'); assert.deepEqual(changes, [], 'nothing rebuilt or moved');
}); });
test('a mesh sample under a cone moves that cone in place, and no other (review IC8 P1)', async () => { test('a mesh sample under a cone moves that cone in place, and no other', async () => {
const { source, sequences, changes, items, sample, heightOf } = const { source, sequences, changes, items, sample, heightOf } =
terrainSetup(); terrainSetup();
sequences.select('A'); sequences.select('A');
@@ -282,7 +281,7 @@ test('a mesh sample under a cone moves that cone in place, and no other (review
assert.deepEqual([after[0], after[2]], [before[0], before[2]]); assert.deepEqual([after[0], after[2]], [before[0], before[2]]);
}); });
test('cones behind the globe are hidden, and show again from the other side (review IC8 P2)', async () => { test('cones behind the globe are hidden, and show again from the other side', async () => {
const { state, source, sequences } = setup(); const { state, source, sequences } = setup();
const view = { lon: -121.49, lat: 38.58, height: 20_000_000 }; const view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
const preRender = new Set(); const preRender = new Set();
@@ -7,11 +7,7 @@ import {
SEQUENCE_IMAGES_LIMIT, SEQUENCE_IMAGES_LIMIT,
} from './policy.js'; } from './policy.js';
/** /** HTTP error flagged `keyRequired`, `keyRejected` or `retryAfterSec` (429). */
* Error carrying the HTTP status and any server-provided payload: no token
* (`keyRequired`), a token Mapillary rejected (`keyRejected`), or a rate
* limit with the seconds to wait (`retryAfterSec`).
*/
export class MapillarySourceError extends Error { export class MapillarySourceError extends Error {
constructor(message, { status = 0, payload = null, retryAfterSec } = {}) { constructor(message, { status = 0, payload = null, retryAfterSec } = {}) {
super(message); super(message);
@@ -44,10 +40,8 @@ async function readJsonOrThrow(response, label) {
} }
/** /**
* Every network path the Mapillary layer uses. Coverage tiles go through the * Mapillary network access: tiles via the local proxy, which adds the token
* local dev-server proxy (which adds the token and caches them); image and * and caches; Graph API lookups direct with the client token, as MapillaryJS.
* sequence lookups go straight to graph.mapillary.com with the client token,
* exactly as the embedded MapillaryJS viewer does.
* @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options * @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options
*/ */
export function createMapillarySource({ export function createMapillarySource({
@@ -110,7 +110,7 @@ test('the source exposes only imagery lookups', () => {
assert.equal(source[gone], undefined, gone); assert.equal(source[gone], undefined, gone);
}); });
test('a rejected key and a rate limit are told apart from other tile errors (review IC8 P2)', async () => { test('a rejected key and a rate limit are told apart from other tile errors', async () => {
const rejectedSource = createMapillarySource({ const rejectedSource = createMapillarySource({
token: 'MLY|1|abc', token: 'MLY|1|abc',
fetchImpl: fakeFetch(() => fetchImpl: fakeFetch(() =>
@@ -1,9 +1,8 @@
import { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js'; import { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js';
/** /**
* The Mapillary viewer adapter: lazy-load MapillaryJS, mount it in the host * Viewer adapter: lazy-loads MapillaryJS into the core's host and emits a
* the core hands over, open images, and emit a provider-neutral pose for * provider-neutral pose on every image, view or position change.
* every image, point-of-view or position change.
* @returns {import('../../registry.js').ViewerAdapter} * @returns {import('../../registry.js').ViewerAdapter}
*/ */
export function createMapillaryViewer({ source, render } = {}) { export function createMapillaryViewer({ source, render } = {}) {
@@ -11,7 +10,6 @@ export function createMapillaryViewer({ source, render } = {}) {
let Library = null; let Library = null;
let container = null; let container = null;
let pendingOpen = null; let pendingOpen = null;
/** The prewarm in flight, if any. */
let prewarming = null; let prewarming = null;
/** In-flight viewer construction, so pre-warm and open never build two. */ /** In-flight viewer construction, so pre-warm and open never build two. */
let creating = null; let creating = null;
@@ -34,10 +32,8 @@ export function createMapillaryViewer({ source, render } = {}) {
async function ensureLibrary() { async function ensureLibrary() {
if (Library) return Library; if (Library) return Library;
// The stylesheet comes with the lazy chunk: Vite applies a dynamically // Lazy CSS: Vite applies it before the import resolves, so the viewer is
// imported stylesheet before its import resolves, so the viewer's chrome // styled from its first frame without ~90 KB of render-blocking CSS.
// is styled from the first frame without costing every page load ~90 KB
// of render-blocking CSS.
const [library] = await Promise.all([ const [library] = await Promise.all([
import('mapillary-js'), import('mapillary-js'),
import('mapillary-js/dist/mapillary.css'), import('mapillary-js/dist/mapillary.css'),
@@ -196,14 +192,11 @@ export function createMapillaryViewer({ source, render } = {}) {
} }
}, },
/** /** Build the viewer ahead of the first image. Safe to call repeatedly. */
* Load the library and stand the viewer up ahead of the first image, so
* opening one only costs the image download. Safe to call repeatedly.
*/
async prewarm(host) { async prewarm(host) {
if (!host) return; if (!host) return;
// A prewarm already in flight may give up (the layer went off and on // An in-flight prewarm may give up (layer toggled mid-download): wait
// while the library downloaded): wait for it, then build if it did not. // for it, then build if it did not.
if (prewarming) await prewarming; if (prewarming) await prewarming;
if (viewer) return; if (viewer) return;
const run = (async () => { const run = (async () => {
+7 -9
View File
@@ -7,17 +7,17 @@
* @property {string} label Chip text and link label, 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|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 {string} pickPrefix Every primitive id the provider creates starts with it.
* @property {{coverage: string}} colors The source's one colour (policy.js PROVIDER_COLORS): chip, lines, points, cones and legend. * @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 {{html: string}} credit On-globe attribution while the provider is active.
* @property {(context: ProviderContext) => ProviderInstance} create * @property {(context: ProviderContext) => ProviderInstance} create
* *
* @typedef {object} ProviderContext Handed to `create()` once by the core. * @typedef {object} ProviderContext Handed to `create()` once by the core.
* @property {object} services Scene services: picking, input, render, sprites, ground, terrain. * @property {object} services Scene services: picking, input, render, sprites, ground, terrain.
* @property {() => {pano: string, sinceMs: number|null}} getFilter The imagery filter resolved against now: read it whenever filtering, so a "since N days" window keeps up with the clock. * @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 {() => boolean} isActive Layer enabled and this provider switched on.
* @property {() => 'draped'|'terrain'} getSurface 'terrain' on Google 3D at street zoom: draw on the bare earth, not the mesh top. * @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 {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) that refine the cast; pass `meshAt` to `castLine`. * @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 {() => void} notify Ask the core to publish a new UI snapshot.
* @property {{openImage: (imageId: string) => Promise<void>, reportError: (message: string|null) => void}} actions * @property {{openImage: (imageId: string) => Promise<void>, reportError: (message: string|null) => void}} actions
* *
@@ -81,8 +81,7 @@ const REQUIRED = Object.freeze([
const ID_GRAMMAR = /^[a-z][a-z0-9-]*$/; const ID_GRAMMAR = /^[a-z][a-z0-9-]*$/;
/** /**
* Check a provider list once at construction and freeze its order, which is * Validate providers and freeze their order, which is also the chip order.
* also the chip order in the panel.
* @param {Array<StreetLevelProvider>} providers * @param {Array<StreetLevelProvider>} providers
* @returns {ReadonlyArray<StreetLevelProvider>} * @returns {ReadonlyArray<StreetLevelProvider>}
*/ */
@@ -130,9 +129,8 @@ export function validateProviders(providers) {
} }
/** /**
* The layer-level key requirement: the one key id every provider shares, or * The key id every provider shares, or null if any is keyless or they differ
* null as soon as any provider is keyless or they differ. Per-provider needs * (then each chip reports its own need).
* are then reported chip by chip instead of gating the whole layer.
* @param {ReadonlyArray<StreetLevelProvider>} providers * @param {ReadonlyArray<StreetLevelProvider>} providers
* @returns {string|null} * @returns {string|null}
*/ */
+5 -11
View File
@@ -1,10 +1,7 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { STREET_LEVEL_LAYER_ID } from './policy.js'; import { STREET_LEVEL_LAYER_ID } from './policy.js';
/** /** Hover picks at most ~8 times a second (like the CCTV layer), never per frame. */
* Minimum time between hover picks: ~8 `scene.pick` calls a second while the
* pointer moves, as the CCTV layer's hover does, never one per frame.
*/
const HOVER_PICK_INTERVAL_MS = 120; const HOVER_PICK_INTERVAL_MS = 120;
/** One click/hover handler for every provider's coverage and image cones. */ /** One click/hover handler for every provider's coverage and image cones. */
@@ -92,9 +89,8 @@ export function createSelection({ state, parts }) {
} }
/** /**
* Pointer cursor over anything this layer owns, so lines read as clickable. * Pointer cursor over anything this layer owns. Throttled; a move inside
* Picks the latest position at most every HOVER_PICK_INTERVAL_MS; a move * the interval is picked when it ends, so the cursor never sticks.
* inside the interval is picked when it ends, so the cursor never sticks.
*/ */
function onMove(movement) { function onMove(movement) {
const viewer = state.viewer; const viewer = state.viewer;
@@ -119,10 +115,8 @@ export function createSelection({ state, parts }) {
} }
/** /**
* Track held buttons (a drag picks nothing) and camera stops. A moving * Track held buttons (a drag picks nothing) and re-pick when the camera
* camera does not stop hover: an orbiting or tracking camera never stops, * rests. Hover keeps running while it moves: a tracking camera never stops.
* and the throttle already bounds the cost. When the camera comes to rest
* the pointer is picked once more, since what was under it has moved.
*/ */
function watchHover(viewer) { function watchHover(viewer) {
const canvas = viewer.scene.canvas; const canvas = viewer.scene.canvas;
+4 -5
View File
@@ -155,7 +155,7 @@ test('hover does not pick while a mouse button is held', () => {
} }
}); });
test('a camera that never stops (orbit, tracking) keeps hover throttled, not off (review P3)', () => { test('a camera that never stops (orbit, tracking) keeps hover throttled, not off', () => {
const h = harness(); const h = harness();
try { try {
// An orbiting or tracking camera raises moveStart and never moveEnd. // An orbiting or tracking camera raises moveStart and never moveEnd.
@@ -171,7 +171,7 @@ test('a camera that never stops (orbit, tracking) keeps hover throttled, not off
} }
}); });
test('when the camera stops, the pointer is picked again so the cursor cannot stick (review P3)', () => { test('when the camera stops, the pointer is picked again so the cursor cannot stick', () => {
const h = harness(); const h = harness();
try { try {
h.scene.under = 'mly:seq:1'; h.scene.under = 'mly:seq:1';
@@ -204,9 +204,8 @@ test('a hover frame queued before the layer went off does not pick', () => {
}); });
/** /**
* An element as Esc sees it: `closest` matches the tag names a selector list * An element as Esc sees it: `closest` matches tag names in a selector list;
* names, and `isContentEditable` is what the browser reports, inherited from * `isContentEditable` is inherited from an editing host, as in a browser.
* an editing host.
*/ */
function element(tag, { isContentEditable = false } = {}) { function element(tag, { isContentEditable = false } = {}) {
return { return {
+2 -3
View File
@@ -1,9 +1,8 @@
import { FILTER_DEFAULT } from './policy.js'; import { FILTER_DEFAULT } from './policy.js';
/** /**
* The open image's fields of `state.street`, as they are with no image open. * The per-image fields of `state.street` with no image open; closing resets
* Closing the viewer resets exactly these; the panel host, the render mode * exactly these (host, render mode and follow availability outlive an image).
* and whether following is available outlive any one image.
*/ */
export function freshStreet() { export function freshStreet() {
return { return {
+12 -42
View File
@@ -1,7 +1,4 @@
/** /** Web-Mercator tile math, without Cesium, shared by the browser layer and the server executor. */
* Web-Mercator tile arithmetic for Mapillary vector tiles. Pure functions,
* no Cesium dependency, shared by the browser layer and the server executor.
*/
const MAX_LAT = 85.05112878; const MAX_LAT = 85.05112878;
@@ -9,10 +6,7 @@ function clampLat(lat) {
return Math.max(-MAX_LAT, Math.min(MAX_LAT, 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. */
* A longitude, or the difference of two, wrapped into [-180, 180]: the short
* way round the date line. Values already in range come back unchanged.
*/
export function wrapLon(lon) { export function wrapLon(lon) {
if (lon >= -180 && lon <= 180) return lon; if (lon >= -180 && lon <= 180) return lon;
return ((((lon + 180) % 360) + 360) % 360) - 180; return ((((lon + 180) % 360) + 360) % 360) - 180;
@@ -31,7 +25,6 @@ function tileYAt(lat, z) {
); );
} }
/** Tile column for a longitude at zoom z. */
export function lonToTileX(lon, z) { export function lonToTileX(lon, z) {
const n = 2 ** z; const n = 2 ** z;
// 180° is the east edge of the last column, not the west edge of the first. // 180° is the east edge of the last column, not the west edge of the first.
@@ -39,7 +32,6 @@ export function lonToTileX(lon, z) {
return Math.min(n - 1, Math.max(0, Math.floor(tileXAt(wrapLon(lon), z)))); return Math.min(n - 1, Math.max(0, Math.floor(tileXAt(wrapLon(lon), z))));
} }
/** Tile row for a latitude at zoom z. */
export function latToTileY(lat, z) { export function latToTileY(lat, z) {
const n = 2 ** z; const n = 2 ** z;
return Math.min(n - 1, Math.max(0, Math.floor(tileYAt(lat, z)))); return Math.min(n - 1, Math.max(0, Math.floor(tileYAt(lat, z))));
@@ -56,10 +48,7 @@ function tileYToLat(y, z) {
return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n))); return (180 / Math.PI) * Math.atan(0.5 * (Math.exp(n) - Math.exp(-n)));
} }
/** /** @returns {{west:number,south:number,east:number,north:number}} degrees */
* Geographic bounds of one tile.
* @returns {{west:number,south:number,east:number,north:number}}
*/
export function tileBounds(x, y, z) { export function tileBounds(x, y, z) {
return { return {
west: tileXToLon(x, z), west: tileXToLon(x, z),
@@ -69,10 +58,7 @@ export function tileBounds(x, y, z) {
}; };
} }
/** /** A [west, south, east, north] array or object as finite, ordered bounds; null if unusable. */
* Normalize a bbox given as [west, south, east, north] (or an object) into
* finite, ordered numbers. Returns null when the input is not a usable box.
*/
export function normalizeBbox(input) { export function normalizeBbox(input) {
const values = Array.isArray(input) const values = Array.isArray(input)
? input ? input
@@ -121,16 +107,10 @@ function segmentDistance2(tile, from, to, z) {
} }
/** /**
* Enumerate the tiles at zoom z covering a bbox (west > east means it crosses * Tiles at zoom z covering a bbox (west > east crosses the date line), from
* the date line), ordered from the centre of * the centre outwards, capped at `limit`. With `focus`, tiles rank by distance
* the box outwards so a progressive renderer fills in what the user is most * from the segment `from` (ground under the camera) to `to` (screen centre):
* likely looking at first. `limit` caps the list; the result reports whether * a tilted view's box centre can sit kilometres ahead of both.
* the cap was hit.
*
* `focus` ranks by distance from the line between `focus.from` (the ground
* under the camera) and `focus.to` (the ground at the centre of the screen)
* instead: a tilted view's box reaches far past what the user looks at, and
* its centre can sit kilometres ahead of both.
* @param {{limit?: number, focus?: {from: {lon: number, lat: number}, to?: {lon: number, lat: number}|null}|null}} [options] * @param {{limit?: number, focus?: {from: {lon: number, lat: number}, to?: {lon: number, lat: number}|null}|null}} [options]
* @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}} * @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}}
*/ */
@@ -191,11 +171,7 @@ function tileGrid(box, z) {
return { tiles, x0, x1, y0, y1 }; return { tiles, x0, x1, y0, y1 };
} }
/** /** A vector-tile-local coordinate (0..extent) in tile (x, y, z) as [lon, lat]. */
* Convert a vector-tile-local coordinate (0..extent) into longitude and
* latitude for tile (x, y, z).
* @returns {[number, number]} [lon, lat]
*/
export function tileLocalToLonLat(px, py, extent, x, y, z) { export function tileLocalToLonLat(px, py, extent, x, y, z) {
const n = 2 ** z; const n = 2 ** z;
const lon = ((x + px / extent) / n) * 360 - 180; const lon = ((x + px / extent) / n) * 360 - 180;
@@ -205,11 +181,7 @@ export function tileLocalToLonLat(px, py, extent, x, y, z) {
return [lon, lat]; return [lon, lat];
} }
/** /** Sequence-tile zoom for a camera height above ground (m); Mapillary stops at z14. */
* Pick a coverage zoom for a camera height (metres above ground). Higher
* cameras get coarser sequence tiles; below the floor Mapillary has no
* finer tiles than z14.
*/
export function coverageZoomForHeight(heightM) { export function coverageZoomForHeight(heightM) {
if (!Number.isFinite(heightM)) return null; if (!Number.isFinite(heightM)) return null;
if (heightM > 60_000) return null; if (heightM > 60_000) return null;
@@ -220,10 +192,8 @@ export function coverageZoomForHeight(heightM) {
} }
/** /**
* Pick the low-zoom `overview` tile level for a camera far above the ground. * Overview-point zoom (z0–5) above 60 km, where sequence tiles are too heavy;
* Mapillary publishes coverage points at z0–5; sequences start at z6 and are * null below, where `coverageZoomForHeight` takes over.
* far too heavy for a continent-sized view. Returns null below the overview
* ceiling (60 km), where `coverageZoomForHeight` takes over.
*/ */
export function overviewZoomForHeight(heightM) { export function overviewZoomForHeight(heightM) {
if (!Number.isFinite(heightM) || heightM <= 60_000) return null; if (!Number.isFinite(heightM) || heightM <= 60_000) return null;
+1 -1
View File
@@ -151,7 +151,7 @@ test('a box across the date line ranks the tiles at the line first, without and
} }
}); });
test('a focus ranks tiles along the line of sight, not from the box centre (review IC8 P1)', () => { 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 // A box that runs far north of the camera, as a tilted view's does: the
// box centre is kilometres from both the camera and the screen centre. // box centre is kilometres from both the camera and the screen centre.
const from = { lon: -121.4944, lat: 38.5816 }; const from = { lon: -121.4944, lat: 38.5816 };
+4 -9
View File
@@ -18,9 +18,8 @@ import { COLORS } from './policy.js';
*/ */
/** /**
* What the switched-on providers add up to: summed counts, any loading, the * Totals over switched-on providers. Key-gated only when every one lacks its
* first hint and error, whether every one of them lacks its key, and whether * key; `keyRejected` when that is because a key was refused.
* that is because a provider rejected the key it was given.
* @param {Array<ProviderSnapshot>} providers * @param {Array<ProviderSnapshot>} providers
*/ */
export function summarizeCoverage(providers) { export function summarizeCoverage(providers) {
@@ -40,12 +39,8 @@ export function summarizeCoverage(providers) {
} }
/** /**
* Compose the snapshot the panel renders from the core state and one * The panel snapshot from core state and provider snapshots. The legend has
* snapshot per registered provider. Pure, so the merge rules are testable: * one swatch per active source, then the shared selection colour.
* counts add up across active providers, the layer is key-gated only when
* every switched-on provider lacks its key, and the legend lists one swatch
* per active source, in its colour and under its name, followed by the
* shared selection colour.
* @param {{enabled: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input * @param {{enabled: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
*/ */
export function composeUIState({ export function composeUIState({
+1 -1
View File
@@ -118,7 +118,7 @@ test('summarizeCoverage is what getStats reports, without building a snapshot',
assert.equal(summarizeCoverage([]).keyRequired, false); assert.equal(summarizeCoverage([]).keyRequired, false);
}); });
test('a rejected key gates like a missing one and is named as rejected (review IC8 P2)', () => { test('a rejected key gates like a missing one and is named as rejected', () => {
const rejected = provider({ const rejected = provider({
keyRequired: true, keyRequired: true,
keyRejected: true, keyRejected: true,
+22 -56
View File
@@ -1,11 +1,7 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { wrapLon } from './tileMath.js'; import { wrapLon } from './tileMath.js';
/** /** Equirectangular metres between {lon, lat} points, the short way round ±180°; street scale. */
* Approximate metres between two {lon, lat} points: equirectangular, good
* for the street-scale distances the layer measures. Measured the short way
* round, so neighbours either side of ±180° are metres apart, not a world.
*/
export function metresBetween(a, b) { export function metresBetween(a, b) {
const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180; const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180;
return Math.hypot( return Math.hypot(
@@ -15,11 +11,8 @@ export function metresBetween(a, b) {
} }
/** /**
* Run low-priority work when the browser is idle, within `timeout` ms; a * Run `task(deadline)` when idle, within `timeout` ms; without idle callbacks
* browser without idle callbacks (and Node) runs it a frame later. The task * (Node) it runs a frame later with a null deadline.
* gets the idle deadline, or null.
* @param {(deadline: IdleDeadline|null) => void} task
* @param {number} timeout
*/ */
export function whenIdle(task, timeout) { export function whenIdle(task, timeout) {
if (typeof globalThis.requestIdleCallback === 'function') if (typeof globalThis.requestIdleCallback === 'function')
@@ -28,10 +21,8 @@ export function whenIdle(task, timeout) {
} }
/** /**
* Height of the surface under the camera above the ellipsoid, in metres. The * Ellipsoidal height (m) of the surface under the camera: globe terrain, or
* globe's terrain answers when the globe is shown; Google 3D hides the globe, * `groundAt` where Google 3D hides the globe; null without a sample.
* so there the bare-earth height from `groundAt(lon, lat)` answers instead.
* Null when neither has a sample yet.
* @param {object} viewer * @param {object} viewer
* @param {{groundAt?: (lon: number, lat: number) => number|null}} [options] * @param {{groundAt?: (lon: number, lat: number) => number|null}} [options]
*/ */
@@ -48,13 +39,7 @@ export function groundUnderCamera(viewer, { groundAt } = {}) {
return Number.isFinite(ground) ? ground : null; return Number.isFinite(ground) ? ground : null;
} }
/** /** Camera height (m) above the ground under it (options as `groundUnderCamera`). */
* Camera height above the surface under the camera, in metres (without a
* ground sample a camera over a city 1,600 m up would read 1,600 m too
* high). Falls back to the ellipsoidal height when nothing has a sample yet.
* @param {object} viewer
* @param {{groundAt?: (lon: number, lat: number) => number|null}} [options]
*/
export function cameraHeightAboveGround(viewer, options = {}) { export function cameraHeightAboveGround(viewer, options = {}) {
const carto = viewer?.camera?.positionCartographic; const carto = viewer?.camera?.positionCartographic;
if (!carto) return null; if (!carto) return null;
@@ -90,18 +75,11 @@ function groundHit(camera, point, ellipsoid, maxRange) {
} }
/** /**
* Visible bbox as [west, south, east, north] degrees, or null when the camera * Visible [west, south, east, north] degrees, or null. Only screen rays that
* does not see the ground. A grid of screen rays is cast onto the ellipsoid * hit count, so a horizon cannot inflate the box to the world.
* and only the rays that hit count, so a view that includes the horizon (or a * `groundHeight` raises the ellipsoid to the ground (Google 3D hides the
* canvas whose frustum is stale) cannot inflate the box to the whole world; * globe), `maxRange` (m) drops near-horizon hits, and `nearRange` (m) always
* `computeViewRectangle` is the fallback when too few rays land. * includes the ground around the camera.
*
* `groundHeight` raises the ellipsoid to the ground under the camera: Google
* 3D hides the globe, and a city 1,600 m up otherwise puts every hit
* kilometres ahead of where the rays really meet the streets. `maxRange`
* (metres) drops hits near the horizon, which a street-level view would
* otherwise stretch into a box far bigger than anything it can show.
* `nearRange` (metres) always includes the ground that far around the camera.
*/ */
export function visibleBbox( export function visibleBbox(
viewer, viewer,
@@ -129,8 +107,7 @@ export function visibleBbox(
} }
} }
// The ground around the camera always counts: a street-level view's screen // The ground around the camera always counts: a street-level view's screen
// rows skip from the horizon to the first few metres, and turning the // rows skip from the horizon to the first few metres.
// camera should not wait for a reload.
const nadir = camera.positionCartographic; const nadir = camera.positionCartographic;
if (Number.isFinite(nearRange) && nearRange > 0 && nadir) { if (Number.isFinite(nearRange) && nearRange > 0 && nadir) {
const dLat = nearRange / 111_320; const dLat = nearRange / 111_320;
@@ -212,10 +189,8 @@ export function viewCentre(viewer) {
} }
/** /**
* The ground the camera is looking at, for ranking coverage tiles: the point * Ground the camera looks at, for ranking tiles: `nadir` under the camera and
* under the camera (`nadir`) and the ground hit at the centre of the screen * `ahead` at the screen centre (null on a miss or beyond `maxRange`).
* (`ahead`, null when the centre ray misses or is out of `maxRange`).
* @returns {{nadir: {lon: number, lat: number}, ahead: {lon: number, lat: number}|null}|null}
*/ */
export function viewFocus( export function viewFocus(
viewer, viewer,
@@ -251,7 +226,10 @@ export function viewFocus(
return { nadir, ahead }; return { nadir, ahead };
} }
/** How far below WGS84 the horizon cull's occluder sits, in metres. */ /**
* 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; export const HORIZON_CULL_DEPTH_M = 1000;
const HORIZON_CULL_ELLIPSOID = new Cesium.Ellipsoid( const HORIZON_CULL_ELLIPSOID = new Cesium.Ellipsoid(
Cesium.Ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M, Cesium.Ellipsoid.WGS84.radii.x - HORIZON_CULL_DEPTH_M,
@@ -260,18 +238,9 @@ const HORIZON_CULL_ELLIPSOID = new Cesium.Ellipsoid(
); );
/** /**
* Hide billboards and points behind the horizon, as the cyclones layer does. * Hide billboards and points behind the horizon: they skip the depth test, so
* Street Level's skip the depth test, so Google 3D and clouds never hide the * nothing else stops the far side drawing through the globe. Re-culls on
* near side's; nothing else then stops the far hemisphere's drawing through * pre-render after the camera moves; stops when nothing is left to cull.
* the globe, and a finite skip distance cannot, as the horizon's distance
* moves with the camera. Re-culls on pre-render only once the camera moved,
* and stops listening when there is nothing left to cull.
*
* The occluder is the WGS84 ellipsoid shrunk by HORIZON_CULL_DEPTH_M, below
* any real surface: on the ellipsoid itself a camera standing on ground that
* lies below it (NYC −22 m, Colombo −95 m) would see every lower point as
* behind the horizon and hide the cones around it. A kilometre changes
* nothing for the far side of the Earth seen from orbit.
* @param {{getViewer: () => object|null, items: () => Iterable<{position: object, show: boolean}>, onChange?: () => void}} options * @param {{getViewer: () => object|null, items: () => Iterable<{position: object, show: boolean}>, onChange?: () => void}} options
*/ */
export function createHorizonCull({ getViewer, items, onChange }) { export function createHorizonCull({ getViewer, items, onChange }) {
@@ -308,10 +277,7 @@ export function createHorizonCull({ getViewer, items, onChange }) {
if (!cullAll(position)) stop(); if (!cullAll(position)) stop();
} }
/** /** Cull `added` items (or all) now, then all again whenever the camera moves. */
* Cull `added` (new or moved items), or everything, now; then everything
* again whenever the camera moves.
*/
function update(added) { function update(added) {
const viewer = getViewer(); const viewer = getViewer();
const position = viewer?.camera?.positionWC; const position = viewer?.camera?.positionWC;
+7 -7
View File
@@ -103,7 +103,7 @@ function pinhole({ lon, lat, ground, agl, pitch, w = 1600, h = 900 }) {
}; };
} }
test('a tilted street view over high ground boxes the streets it looks at, not the horizon (review IC8 P1)', () => { 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 // 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. // the screen meets the street about 1.65 km north of the camera.
const view = pinhole({ const view = pinhole({
@@ -115,8 +115,8 @@ test('a tilted street view over high ground boxes the streets it looks at, not t
}); });
const ahead = 39.74 + 1648 / 111_000; const ahead = 39.74 + 1648 / 111_000;
const unranged = visibleBbox(view); const unranged = visibleBbox(view);
// Rays to the bare ellipsoid travel 1.6 km further down: the old box was // Without options, rays to the bare ellipsoid travel 1.6 km further down:
// 20 km wide and started past the street at the centre of the screen. // the box is 20 km wide and starts past the street at the screen centre.
assert.ok(unranged[2] - unranged[0] > 0.2); assert.ok(unranged[2] - unranged[0] > 0.2);
assert.ok(unranged[1] > ahead, 'the old box missed the street in view'); assert.ok(unranged[1] > ahead, 'the old box missed the street in view');
const options = { groundHeight: 1610, maxRange: 6000, nearRange: 1000 }; const options = { groundHeight: 1610, maxRange: 6000, nearRange: 1000 };
@@ -149,7 +149,7 @@ test('looking at the horizon from eye height still boxes the ground around the c
assert.ok(east - -121.4944 > 0.008 && -121.4944 - west > 0.008); assert.ok(east - -121.4944 > 0.008 && -121.4944 - west > 0.008);
}); });
test('metresBetween measures the short way round the date line (review IC8 P2)', () => { test('metresBetween measures the short way round the date line', () => {
const east = { lon: 179.9999, lat: 0 }; const east = { lon: 179.9999, lat: 0 };
const west = { 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. // 0.0002° of longitude at the equator, not 40,000 km round the other way.
@@ -284,7 +284,7 @@ function cullFrom(camera, points) {
return items.map((item) => item.show); return items.map((item) => item.show);
} }
test('cones and the marker stay visible on ground below the WGS84 ellipsoid (gekh P2)', () => { test('cones and the marker stay visible on ground below the WGS84 ellipsoid', () => {
// NYC in FOLLOW: eye 2.4 m above ground at -22 m, a cone 10 m away. // NYC in FOLLOW: eye 2.4 m above ground at -22 m, a cone 10 m away.
assert.deepEqual( assert.deepEqual(
cullFrom([-74.006, 40.7128, -19.6], [[-74.006, 40.71289, -22]]), cullFrom([-74.006, 40.7128, -19.6], [[-74.006, 40.71289, -22]]),
@@ -301,14 +301,14 @@ test('cones and the marker stay visible on ground below the WGS84 ellipsoid (gek
), ),
[true, true], [true, true],
); );
// Austin, ground well above the ellipsoid: as before. // Austin, ground well above the ellipsoid.
assert.deepEqual( assert.deepEqual(
cullFrom([-97.7431, 30.2672, 132], [[-97.7431, 30.2681, 130]]), cullFrom([-97.7431, 30.2672, 132], [[-97.7431, 30.2681, 130]]),
[true], [true],
); );
}); });
test('the horizon cull still hides the far side of the Earth from orbit (gekh P2)', () => { test('the horizon cull still hides the far side of the Earth from orbit', () => {
assert.deepEqual( assert.deepEqual(
cullFrom( cullFrom(
[0, 0, 20_000_000], [0, 0, 20_000_000],
+10 -30
View File
@@ -1,9 +1,8 @@
import { freshStreet } from './state.js'; import { freshStreet } from './state.js';
/** /**
* Own the one viewer element in the panel and whichever provider's viewer * Owns the panel's viewer element and the provider adapter mounted in it.
* adapter is mounted in it. Poses the adapter emits become `state.street`, * Adapter poses update `state.street`, the marker and the follow camera.
* move the marker and (optionally) the globe camera.
*/ */
export function createViewerHost({ state, parts }) { export function createViewerHost({ state, parts }) {
/** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */ /** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */
@@ -11,11 +10,7 @@ export function createViewerHost({ state, parts }) {
/** @type {{id: string, promise: Promise<object>}|null} */ /** @type {{id: string, promise: Promise<object>}|null} */
let mounting = null; let mounting = null;
let openSeq = 0; let openSeq = 0;
/** /** Prewarmed, inactive adapters; each holds a live viewer (WebGL context) until `unmount`. */
* Adapters stood up by `prewarm` that are not the active one: each holds a
* live viewer (a WebGL context for MapillaryJS) until `unmount` releases it.
* @type {Map<string, object>}
*/
const warmed = new Map(); const warmed = new Map();
function notify() { function notify() {
@@ -44,9 +39,8 @@ export function createViewerHost({ state, parts }) {
}); });
parts.marker.set(state.street.position, state.street.bearing); parts.marker.set(state.street.position, state.street.bearing);
parts.follow.followCamera(); parts.follow.followCamera();
// Providers highlight the selected image's sequence on the map; mirror // Select the sequence only once the image is on screen, so its lookup
// that once the image itself is on screen, so the sequence lookup never // never competes with the image download.
// competes with the image download.
if (!state.street.loading && state.street.sequenceId !== previousSequence) if (!state.street.loading && state.street.sequenceId !== previousSequence)
selectCurrentSequence(); selectCurrentSequence();
notify(); notify();
@@ -62,10 +56,8 @@ export function createViewerHost({ state, parts }) {
} }
/** /**
* Mount a provider's viewer adapter in the host. The adapter becomes * Mount a provider's adapter. It becomes `active` only on success, so a
* `active` only once its mount succeeded, so a failed mount (a library * failed mount is retried on the next open; concurrent opens share one.
* that did not load) is retried on the next open; concurrent opens for the
* same provider share one mount.
*/ */
function mount(entry) { function mount(entry) {
if (active?.id === entry.def.id) return Promise.resolve(active.adapter); if (active?.id === entry.def.id) return Promise.resolve(active.adapter);
@@ -108,12 +100,7 @@ export function createViewerHost({ state, parts }) {
return promise; return promise;
} }
/** /** Open an image: true once its first pose is in, false if it failed or was overtaken. */
* Open an image from one provider. Resolves once the first pose is in
* (true) or the open failed or was overtaken (false); later poses stream
* through `state.street` as the user navigates.
* @returns {Promise<boolean>}
*/
async function open(providerId, imageId, { frame = true } = {}) { async function open(providerId, imageId, { frame = true } = {}) {
const entry = providerEntry(providerId); const entry = providerEntry(providerId);
if (!entry || !imageId) return false; if (!entry || !imageId) return false;
@@ -153,10 +140,7 @@ export function createViewerHost({ state, parts }) {
return true; return true;
} }
/** /** Close the image and stop any framing flight; the adapter stays warm. */
* Close the image; the mounted adapter stays warm for the next open. The
* globe stops flying toward it too (unmount closes, so layer off does).
*/
function close() { function close() {
openSeq++; openSeq++;
parts.follow.cancelFraming(); parts.follow.cancelFraming();
@@ -208,11 +192,7 @@ export function createViewerHost({ state, parts }) {
} }
} }
/** /** Tear down every viewer, or only `providerId`'s (active or prewarmed). */
* Tear viewers down: every one (layer disabled or destroyed), or only the
* given provider's, active or prewarmed (that provider switched off).
* @param {string} [providerId]
*/
function unmount(providerId) { function unmount(providerId) {
if ( if (
!providerId || !providerId ||
+3 -3
View File
@@ -458,7 +458,7 @@ test('a prewarm that gave up when the layer went off and on mid-download is foll
adapter.unmount(); adapter.unmount();
}); });
test('the Mapillary viewer leaves resizing to the panel, so a hidden one never asks for z=NaN (gekh P3)', async () => { test('the Mapillary viewer leaves resizing to the panel, so a hidden one never asks for z=NaN', async () => {
const { gate, viewers } = fakeLibrary(); const { gate, viewers } = fakeLibrary();
gate.resolve(); gate.resolve();
const adapter = createMapillaryViewer({ source: { token: 't' } }); const adapter = createMapillaryViewer({ source: { token: 't' } });
@@ -467,7 +467,7 @@ test('the Mapillary viewer leaves resizing to the panel, so a hidden one never a
adapter.unmount(); adapter.unmount();
}); });
test('closing the photo stops sequence playback in the hidden viewer (review P2-6)', async () => { test('closing the photo stops sequence playback in the hidden viewer', async () => {
const { gate, viewers } = fakeLibrary(); const { gate, viewers } = fakeLibrary();
gate.resolve(); gate.resolve();
const adapter = createMapillaryViewer({ source: { token: 't' } }); const adapter = createMapillaryViewer({ source: { token: 't' } });
@@ -480,7 +480,7 @@ test('closing the photo stops sequence playback in the hidden viewer (review P2-
assert.equal(viewers.live, 1, 'the viewer itself stays warm'); assert.equal(viewers.live, 1, 'the viewer itself stays warm');
}); });
test('closing the photo or switching the layer off stops its framing flight (review IC8 P1)', async () => { test('closing the photo or switching the layer off stops its framing flight', async () => {
const adapter = fakeAdapter(); const adapter = fakeAdapter();
const { framing, host } = harness(adapter); const { framing, host } = harness(adapter);
await host.open('mapillary', 'a'); await host.open('mapillary', 'a');
+1 -1
View File
@@ -184,7 +184,7 @@ 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 (review P3)', () => { test('a collapsed Street Level panel survives the share-link round trip', () => {
const manager = makeManager(); const manager = makeManager();
manager.setPanelStateProvider(() => ({ specs: [ manager.setPanelStateProvider(() => ({ specs: [
{ id: 'cctv-panel', collapsed: false }, { id: 'cctv-panel', collapsed: false },
+3 -7
View File
@@ -83,8 +83,7 @@ test('the keyless state gates the controls rather than leaving dead buttons', ()
); );
assert.match(html, /<fieldset id="sl-controls"/); assert.match(html, /<fieldset id="sl-controls"/);
assert.match(html, /<div id="sl-provider-chips" class="sl-chips"><\/div>/); assert.match(html, /<div id="sl-provider-chips" class="sl-chips"><\/div>/);
// The gate holds the filters only: the provider chips stay reachable, so // The gate holds the filters only, so the chips' key tooltip stays reachable.
// their tooltip can say how to add the key (gekh P3).
const gate = html.slice( const gate = html.slice(
html.indexOf('<fieldset id="sl-controls"'), html.indexOf('<fieldset id="sl-controls"'),
html.indexOf('</fieldset>', html.indexOf('<fieldset id="sl-controls"')), html.indexOf('</fieldset>', html.indexOf('<fieldset id="sl-controls"')),
@@ -168,9 +167,8 @@ test('the viewer comes first and the panel is a portable, resizable window', ()
panelCss, panelCss,
/\.panel-floating\[style\*='height'\] \.sl-settings \{[^}]*overflow-y: auto/, /\.panel-floating\[style\*='height'\] \.sl-settings \{[^}]*overflow-y: auto/,
); );
// Shrinking docks it back at its default size rather than leaving a // Collapsing a floating panel, or SHRINK / Esc on the expanded viewer,
// window over the globe: collapsing a floating panel, or SHRINK / Esc on // docks it rather than leaving a window over the globe.
// the expanded viewer.
assert.match( assert.match(
position, position,
/id: 'street-level-panel',[\s\S]*?dockOnCollapse: true/, /id: 'street-level-panel',[\s\S]*?dockOnCollapse: true/,
@@ -191,8 +189,6 @@ test('the viewer comes first and the panel is a portable, resizable window', ()
); );
}); });
/* ── gekh review on #768 ───────────────────────────────────────────────── */
test('Clean View, recording and the cockpit hide the expanded viewer on <body> (P2-5)', () => { 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. // EXPAND moves the viewer out of every panel, past the panel-hiding rules.
assert.match( assert.match(
+5 -13
View File
@@ -1,8 +1,4 @@
/** /** Shared Street Level test stand-ins: provider snapshot, minimal provider, ray-casting camera. */
* Shared stand-ins for the Street Level tests: a provider snapshot as the
* core reports it, the smallest provider definition the core accepts, and a
* camera whose screen rays really meet the ellipsoid they are given.
*/
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js'; import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js';
@@ -28,10 +24,8 @@ export function providerSnapshot(overrides = {}) {
} }
/** /**
* The smallest provider definition the core accepts. `stats` is what * The smallest provider the core accepts. Mutate `stats` to change what
* coverageStats() answers (mutate it to change the answer), `calls` counts * coverageStats() answers; `calls`, `filters` and `context()` record use.
* what the core asked of the provider and its viewer, `filters` lists every
* filter it was given and `context()` is the context the core created it with.
*/ */
export function fakeStreetLevelProvider({ export function fakeStreetLevelProvider({
id = 'mapillary', id = 'mapillary',
@@ -90,10 +84,8 @@ export function fakeStreetLevelProvider({
} }
/** /**
* A pinhole camera at `altitude` metres above the WGS84 ellipsoid, looking * A pinhole camera `altitude` m above WGS84, at `heading` (deg from north) and
* along `heading` (degrees from north) and `pitch` (degrees, negative is * `pitch` (deg, negative down); `pickEllipsoid` hits whatever ellipsoid it gets.
* down), over a `width`×`height` canvas with the given fields of view. Its
* `pickEllipsoid` intersects whatever ellipsoid it is given, as Cesium's does.
*/ */
export function rayCamera({ export function rayCamera({
lon, lon,
+35 -50
View File
@@ -28,13 +28,11 @@ import {
_settleTileWritesForTest, _settleTileWritesForTest,
} from '../../server/providers/mapillary/tiles.js'; } from '../../server/providers/mapillary/tiles.js';
// Every tile these tests cache lands in a throwaway directory, never in the // Keep test tiles out of the developer's .gev-cache.
// developer's .gev-cache.
const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gev-mly-tiles-')); const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gev-mly-tiles-'));
_setTileCacheDirForTest(cacheDir); _setTileCacheDirForTest(cacheDir);
// The directory is not switched back: a background write still landing must // Not switched back, so a late background write can never sweep the real
// never sweep the real cache. Background writes and sweeps settle first, so // cache; writes settle before the directory is removed.
// none lands in the directory while it is being removed.
after(async () => { after(async () => {
await tiles._settleTileWritesForTest(); await tiles._settleTileWritesForTest();
await fsp.rm(cacheDir, { recursive: true, force: true }); await fsp.rm(cacheDir, { recursive: true, force: true });
@@ -43,7 +41,6 @@ const tileFile = ({ z, x, y }, root = cacheDir) =>
path.join(root, 'coverage', String(z), `${x}-${y}.pbf`); path.join(root, 'coverage', String(z), `${x}-${y}.pbf`);
const HOUR = 60 * 60 * 1000; const HOUR = 60 * 60 * 1000;
/** Write a cache file dated `ageMs` ago. */
async function writeAged(file, bytes, ageMs) { async function writeAged(file, bytes, ageMs) {
await fsp.mkdir(path.dirname(file), { recursive: true }); await fsp.mkdir(path.dirname(file), { recursive: true });
await fsp.writeFile(file, bytes); await fsp.writeFile(file, bytes);
@@ -67,9 +64,8 @@ const exists = (file) =>
); );
/** /**
* Mount the plugin and return callers keyed by route. `start` returns at once * Mount the plugin. `call` waits for the handler; `start` returns at once with
* with the response, its `close` trigger and the handler's `done` promise; * the response, its `close` trigger and the handler's `done` promise.
* `call` waits for the handler.
*/ */
function install(plugin, mode = 'configureServer') { function install(plugin, mode = 'configureServer') {
const routes = new Map(); const routes = new Map();
@@ -186,11 +182,7 @@ test('tile route validates the path and refuses to proxy without a token', async
} }
}); });
/** /** Drive the tile route against a scripted upstream `answer(call, url)`. */
* Drive the tile route against a scripted upstream: `answer(call, url)`
* returns the Response for each upstream fetch; `inits` holds each fetch's
* options. Refusals are never cached, so no disk tile is written.
*/
async function withUpstream(answer, run) { async function withUpstream(answer, run) {
const savedFetch = globalThis.fetch; const savedFetch = globalThis.fetch;
const savedToken = process.env.MAPILLARY_CLIENT_TOKEN; const savedToken = process.env.MAPILLARY_CLIENT_TOKEN;
@@ -217,7 +209,7 @@ async function withUpstream(answer, run) {
} }
for (const status of [401, 403]) for (const status of [401, 403])
test(`an upstream ${status} is a rejected key, and misses stop asking Mapillary (review IC8 P2)`, async () => { test(`an upstream ${status} is a rejected key, and misses stop asking Mapillary`, async () => {
await withUpstream( await withUpstream(
() => new Response('{}', { status }), () => new Response('{}', { status }),
async ({ calls, call }) => { async ({ calls, call }) => {
@@ -239,7 +231,7 @@ for (const status of [401, 403])
); );
}); });
test('a 429 passes its Retry-After on and holds misses until it is over (review IC8 P2)', async (t) => { test('a 429 passes its Retry-After on and holds misses until it is over', async (t) => {
await withUpstream( await withUpstream(
() => new Response('{}', { status: 429, headers: { 'Retry-After': '30' } }), () => new Response('{}', { status: 429, headers: { 'Retry-After': '30' } }),
async ({ calls, call }) => { async ({ calls, call }) => {
@@ -335,7 +327,7 @@ test('normalizeTileAddress refuses prototype keys as layer names', () => {
); );
}); });
test('coverage zooms the app never requests (z6–10) are refused with a 400 (review P2)', async () => { test('coverage zooms the app never requests (z6–10) are refused with a 400', async () => {
for (const z of [6, 7, 8, 9, 10]) for (const z of [6, 7, 8, 9, 10])
assert.throws( assert.throws(
() => normalizeTileAddress({ layer: 'coverage', z, x: 0, y: 0 }), () => normalizeTileAddress({ layer: 'coverage', z, x: 0, y: 0 }),
@@ -429,7 +421,7 @@ test('a tile held in memory past the 24 h TTL is fetched again', async (t) => {
}); });
}); });
test('a disk tile keeps its age in memory and through a retrim rewrite (review P2)', async (t) => { test('a disk tile keeps its age in memory and through a retrim rewrite', async (t) => {
const address = { layer: 'coverage', z: 14, x: 8, y: 8 }; const address = { layer: 'coverage', z: 14, x: 8, y: 8 };
const file = tileFile(address); const file = tileFile(address);
const untrimmed = tile([ const untrimmed = tile([
@@ -476,7 +468,7 @@ async function withCacheDir(run) {
} }
} }
test('the disk sweep removes expired tiles and keeps fresh ones (review P2)', async () => { test('the disk sweep removes expired tiles and keeps fresh ones', async () => {
await withCacheDir(async (dir) => { await withCacheDir(async (dir) => {
const expired = tileFile({ z: 14, x: 1, y: 1 }, dir); const expired = tileFile({ z: 14, x: 1, y: 1 }, dir);
const fresh = tileFile({ z: 14, x: 2, y: 2 }, dir); const fresh = tileFile({ z: 14, x: 2, y: 2 }, dir);
@@ -489,7 +481,7 @@ test('the disk sweep removes expired tiles and keeps fresh ones (review P2)', as
}); });
}); });
test('the disk sweep evicts the oldest tiles past the size cap (review P2)', async () => { test('the disk sweep evicts the oldest tiles past the size cap', async () => {
await withCacheDir(async (dir) => { await withCacheDir(async (dir) => {
const files = [3, 2, 1].map((age) => { const files = [3, 2, 1].map((age) => {
const file = tileFile({ z: 13, x: age, y: age }, dir); const file = tileFile({ z: 13, x: age, y: age }, dir);
@@ -506,7 +498,7 @@ test('the disk sweep evicts the oldest tiles past the size cap (review P2)', asy
}); });
}); });
test('caching a tile sweeps expired files off the disk in the background (review P2)', async () => { test('caching a tile sweeps expired files off the disk in the background', async () => {
await withCacheDir(async (dir) => { await withCacheDir(async (dir) => {
const expired = tileFile({ z: 12, x: 1, y: 1 }, dir); const expired = tileFile({ z: 12, x: 1, y: 1 }, dir);
const fresh = tileFile({ z: 12, x: 2, y: 2 }, dir); const fresh = tileFile({ z: 12, x: 2, y: 2 }, dir);
@@ -525,11 +517,7 @@ test('caching a tile sweeps expired files off the disk in the background (review
}); });
}); });
/** /** An upstream whose fetches wait until released and record their abort. */
* A deferred upstream for in-flight tests: every fetch waits until released,
* and records whether its signal was aborted. Their disk files are removed
* first.
*/
async function withDeferredUpstream(tiles, run) { async function withDeferredUpstream(tiles, run) {
const savedFetch = globalThis.fetch; const savedFetch = globalThis.fetch;
const savedToken = process.env.MAPILLARY_CLIENT_TOKEN; const savedToken = process.env.MAPILLARY_CLIENT_TOKEN;
@@ -681,7 +669,7 @@ test('a request arriving before a cancelled fetch has wound down starts a fresh
}); });
}); });
// ── Route: success path, refusals, gate and limiter (review gekh) ── // ── Route: success path, refusals, gate and limiter ──
/** Every header value and the body, as text, for a token-leak check. */ /** Every header value and the body, as text, for a token-leak check. */
const leakText = (res) => const leakText = (res) =>
@@ -690,7 +678,7 @@ const leakText = (res) =>
Buffer.isBuffer(res.body) ? res.body.toString('latin1') : String(res.body), Buffer.isBuffer(res.body) ? res.body.toString('latin1') : String(res.body),
].join('\n'); ].join('\n');
test('the tile route serves a trimmed protobuf tile, then from memory (review gekh P0)', async () => { test('the tile route serves a trimmed protobuf tile, then from memory', async () => {
const address = { layer: 'coverage', z: 14, x: 9, y: 9 }; const address = { layer: 'coverage', z: 14, x: 9, y: 9 };
await fsp.rm(tileFile(address), { force: true }); await fsp.rm(tileFile(address), { force: true });
const upstream = tile([ const upstream = tile([
@@ -723,7 +711,7 @@ test('the tile route serves a trimmed protobuf tile, then from memory (review ge
}); });
for (const status of [204, 404]) for (const status of [204, 404])
test(`an empty upstream tile (${status}) is a 204 with no body (review gekh P0)`, async () => { test(`an empty upstream tile (${status}) is a 204 with no body`, async () => {
const address = { layer: 'coverage', z: 14, x: 10, y: status }; const address = { layer: 'coverage', z: 14, x: 10, y: status };
await fsp.rm(tileFile(address), { force: true }); await fsp.rm(tileFile(address), { force: true });
await withUpstream( await withUpstream(
@@ -742,7 +730,7 @@ for (const status of [204, 404])
); );
}); });
test('closing the response mid-flight aborts the upstream fetch (review gekh P0)', async () => { test('closing the response mid-flight aborts the upstream fetch', async () => {
const address = { layer: 'coverage', z: 14, x: 11, y: 11 }; const address = { layer: 'coverage', z: 14, x: 11, y: 11 };
await withDeferredUpstream([address], async ({ upstream }) => { await withDeferredUpstream([address], async ({ upstream }) => {
const { start } = install(mapillaryProxy()); const { start } = install(mapillaryProxy());
@@ -757,7 +745,7 @@ test('closing the response mid-flight aborts the upstream fetch (review gekh P0)
}); });
for (const status of [400, 410, 422]) for (const status of [400, 410, 422])
test(`an upstream ${status} is a 502, not the client's fault (review gekh)`, async () => { test(`an upstream ${status} is a 502, not the client's fault`, async () => {
await withUpstream( await withUpstream(
() => new Response('{}', { status }), () => new Response('{}', { status }),
async ({ call }) => { async ({ call }) => {
@@ -776,7 +764,7 @@ const redirect = (location) =>
headers: location === null ? {} : { location }, headers: location === null ? {} : { location },
}); });
test('a redirect within the Mapillary tile origin is followed by hand (IC8 P2)', async () => { test('a redirect within the Mapillary tile origin is followed by hand', async () => {
await fsp.rm(tileFile({ z: 14, x: 13, y: 13 }), { force: true }); await fsp.rm(tileFile({ z: 14, x: 13, y: 13 }), { force: true });
await withUpstream( await withUpstream(
(n) => (n) =>
@@ -808,7 +796,7 @@ for (const [why, location] of [
['a scheme-relative host', '//evil.example/t'], ['a scheme-relative host', '//evil.example/t'],
['no Location', null], ['no Location', null],
]) ])
test(`a redirect off the tile origin (${why}) is a 502, and the token stays home (IC8 P2)`, async () => { test(`a redirect off the tile origin (${why}) is a 502, and the token stays home`, async () => {
await withUpstream( await withUpstream(
(n) => (n) =>
n === 1 n === 1
@@ -827,7 +815,7 @@ for (const [why, location] of [
); );
}); });
test('an endless same-origin redirect stops after a few hops (IC8 P2)', async () => { test('an endless same-origin redirect stops after a few hops', async () => {
await withUpstream( await withUpstream(
(n) => redirect(`/maps/vtp/mly1_public/2/14/15/15?hop=${n}`), (n) => redirect(`/maps/vtp/mly1_public/2/14/15/15?hop=${n}`),
async ({ calls, call }) => { async ({ calls, call }) => {
@@ -841,7 +829,7 @@ test('an endless same-origin redirect stops after a few hops (IC8 P2)', async ()
); );
}); });
test('cross-site requests are refused on both routes; the app itself passes (review gekh)', async () => { test('cross-site requests are refused on both routes; the app itself passes', async () => {
await withUpstream( await withUpstream(
() => new Response(tile([{ name: 'sequence' }]), { status: 200 }), () => new Response(tile([{ name: 'sequence' }]), { status: 200 }),
async ({ calls, call }) => { async ({ calls, call }) => {
@@ -893,7 +881,7 @@ test('cross-site requests are refused on both routes; the app itself passes (rev
); );
}); });
test('the tile route answers 429 past its per-IP budget (review gekh)', async () => { test('the tile route answers 429 past its per-IP budget', async () => {
const saved = process.env.MAPILLARY_CLIENT_TOKEN; const saved = process.env.MAPILLARY_CLIENT_TOKEN;
delete process.env.MAPILLARY_CLIENT_TOKEN; delete process.env.MAPILLARY_CLIENT_TOKEN;
try { try {
@@ -917,9 +905,9 @@ test('the tile route answers 429 past its per-IP budget (review gekh)', async ()
} }
}); });
// ── Tile cache and upstream bounds (review gekh) ── // ── Tile cache and upstream bounds ──
test('concurrent disk hits on an untrimmed tile rewrite it once, keeping its age (review gekh)', async (t) => { test('concurrent disk hits on an untrimmed tile rewrite it once, keeping its age', async (t) => {
const address = { layer: 'coverage', z: 14, x: 14, y: 14 }; const address = { layer: 'coverage', z: 14, x: 14, y: 14 };
const file = tileFile(address); const file = tileFile(address);
await writeAged( await writeAged(
@@ -956,7 +944,7 @@ test('concurrent disk hits on an untrimmed tile rewrite it once, keeping its age
assert.ok(Math.abs(stat.mtimeMs - writtenAt) < 1000, 'no fresh 24 h of life'); assert.ok(Math.abs(stat.mtimeMs - writtenAt) < 1000, 'no fresh 24 h of life');
}); });
test('a rewrite overtaken by a fresh tile write steps aside cleanly (review gekh)', async (t) => { test('a rewrite overtaken by a fresh tile write steps aside cleanly', async (t) => {
const address = { layer: 'coverage', z: 14, x: 15, y: 15 }; const address = { layer: 'coverage', z: 14, x: 15, y: 15 };
const file = tileFile(address); const file = tileFile(address);
await writeAged( await writeAged(
@@ -1012,7 +1000,7 @@ test('a rewrite overtaken by a fresh tile write steps aside cleanly (review gekh
); );
}); });
test('at most a few upstream fetches run at once; a queued abort never fetches (review gekh)', async () => { test('at most a few upstream fetches run at once; a queued abort never fetches', async () => {
const addresses = Array.from( const addresses = Array.from(
{ length: TILE_UPSTREAM_CONCURRENCY + 2 }, { length: TILE_UPSTREAM_CONCURRENCY + 2 },
(_, i) => ({ (_, i) => ({
@@ -1058,7 +1046,7 @@ test('at most a few upstream fetches run at once; a queued abort never fetches (
}); });
}); });
test('a caller gone while the disk cache is read starts no upstream fetch (review gekh)', async () => { test('a caller gone while the disk cache is read starts no upstream fetch', async () => {
const address = { layer: 'coverage', z: 13, x: 120, y: 100 }; const address = { layer: 'coverage', z: 13, x: 120, y: 100 };
await withDeferredUpstream([address], async ({ upstream, settle }) => { await withDeferredUpstream([address], async ({ upstream, settle }) => {
const controller = new AbortController(); const controller = new AbortController();
@@ -1070,7 +1058,7 @@ test('a caller gone while the disk cache is read starts no upstream fetch (revie
}); });
}); });
test('empty tiles are charged against the memory budget (review gekh)', async () => { test('empty tiles are charged against the memory budget', async () => {
const addresses = [16, 17, 18].map((x) => ({ const addresses = [16, 17, 18].map((x) => ({
layer: 'coverage', layer: 'coverage',
z: 14, z: 14,
@@ -1092,7 +1080,7 @@ test('empty tiles are charged against the memory budget (review gekh)', async ()
); );
}); });
/** A one-layer tile of exactly `size` bytes (a big opaque payload). */ /** A one-layer tile of exactly `size` bytes. */
function tileOfSize(size) { function tileOfSize(size) {
const probe = tile([{ name: 'sequence', payload: Buffer.alloc(size) }]); const probe = tile([{ name: 'sequence', payload: Buffer.alloc(size) }]);
const bytes = tile([ const bytes = tile([
@@ -1102,10 +1090,7 @@ function tileOfSize(size) {
return bytes; return bytes;
} }
/** /** Disk writes are off, so a tile memory dropped comes back from Mapillary. */
* Serve each address's big tile from a scripted upstream, with disk writes
* off: a tile memory dropped can only come back from Mapillary.
*/
async function withBigTiles(t, bodies, run) { async function withBigTiles(t, bodies, run) {
for (const address of bodies.keys()) for (const address of bodies.keys())
await fsp.rm(tileFile(address), { force: true }); await fsp.rm(tileFile(address), { force: true });
@@ -1212,7 +1197,7 @@ test('a tile costing more than half the memory budget is served but not kept', a
); );
}); });
test('empty tile files count toward the disk cap (review gekh)', async () => { test('empty tile files count toward the disk cap', async () => {
await withCacheDir(async (dir) => { await withCacheDir(async (dir) => {
const files = [3, 2, 1].map((age) => ({ const files = [3, 2, 1].map((age) => ({
file: tileFile({ z: 13, x: age, y: age }, dir), file: tileFile({ z: 13, x: age, y: age }, dir),
@@ -1232,7 +1217,7 @@ test('empty tile files count toward the disk cap (review gekh)', async () => {
}); });
}); });
test('a body past the size cap aborts the upstream request as it streams (review gekh)', async () => { test('a body past the size cap aborts the upstream request as it streams', async () => {
const address = { layer: 'coverage', z: 14, x: 19, y: 19 }; const address = { layer: 'coverage', z: 14, x: 19, y: 19 };
await fsp.rm(tileFile(address), { force: true }); await fsp.rm(tileFile(address), { force: true });
const MB = 1024 * 1024; const MB = 1024 * 1024;
@@ -1279,7 +1264,7 @@ test('a body past the size cap aborts the upstream request as it streams (review
} }
}); });
// ── Tile trimming (relocated from server/providers/mapillary/trim.test.mjs) ── // ── Tile trimming ──
/** Build a minimal MVT: layers with a name, a version and one opaque feature. */ /** Build a minimal MVT: layers with a name, a version and one opaque feature. */
function tile(layers) { function tile(layers) {
const writer = new PbfWriter(); const writer = new PbfWriter();
+1 -1
View File
@@ -110,7 +110,7 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno
['/api/gbfs/', 400], ['/api/gbfs/', 400],
['/api/tomtom/status', 200], ['/api/tomtom/status', 200],
// The Street Level QA gate intercepts these in the browser; this is // The Street Level QA gate intercepts these in the browser; this is
// the check that the real servers register them (review IC8 P2). // the check that the real servers register them.
['/api/mapillary/status', 200], ['/api/mapillary/status', 200],
['/api/mapillary/tiles/coverage/14/1/2', 503], ['/api/mapillary/tiles/coverage/14/1/2', 503],
['/api/radio/unknown', 404], ['/api/radio/unknown', 404],
+5 -8
View File
@@ -78,7 +78,7 @@ test('fixture tiles give the hermetic gate something real to filter', async () =
assert.equal(fixtureTile(8, 1, 1, now).length, 0, 'nothing in between'); assert.equal(fixtureTile(8, 1, 1, now).length, 0, 'nothing in between');
}); });
/* ── The real status route probe (review IC8 P2) ───────────────────────── */ /* ── The real status route probe ─────────────────────────────────────── */
test('the gate requires the server’s real Mapillary status route', async () => { test('the gate requires the server’s real Mapillary status route', async () => {
const { assertRealStatusRoute } = const { assertRealStatusRoute } =
@@ -124,14 +124,12 @@ test('the gate requires the server’s real Mapillary status route', async () =>
); );
}); });
/* ── Header press-and-verify shared with the panel gate (review IC8 P2) ── */ /* ── Header press-and-verify shared with the panel gate ───────────────── */
/** /**
* A page stand-in whose `evaluate` runs the page function in Node against a * A page stand-in that runs page functions in Node against a fake document.
* fake document. The hit check always sees the header; `presses` says where * The hit check always sees the header; `presses` says where each pointerdown
* each real pointerdown lands ('header', or 'other' when the rail moved the * lands ('header', 'other', 'moved' or 'none').
* panel between the check and the press). A drag from the header lifts the
* panel and a header double-click docks it, unless `stuck`.
*/ */
function fakePanelPage({ presses = [], floating = false, stuck = false } = {}) { function fakePanelPage({ presses = [], floating = false, stuck = false } = {}) {
const state = { floating, downs: 0, last: null, dragging: false, shift: 0 }; const state = { floating, downs: 0, last: null, dragging: false, shift: 0 };
@@ -164,7 +162,6 @@ function fakePanelPage({ presses = [], floating = false, stuck = false } = {}) {
const window = { const window = {
addEventListener: (type, listener) => listeners.push(listener), addEventListener: (type, listener) => listeners.push(listener),
}; };
/** Run page code (an evaluated function or a page listener) in the page. */
const inPage = (fn, ...args) => { const inPage = (fn, ...args) => {
const saved = { document: globalThis.document, window: globalThis.window }; const saved = { document: globalThis.document, window: globalThis.window };
Object.assign(globalThis, { document, window }); Object.assign(globalThis, { document, window });
+2 -8
View File
@@ -954,8 +954,7 @@ export class StyleManager extends ShellFacade {
this.setPanelCollapsed('street-level-panel', collapsed, options), this.setPanelCollapsed('street-level-panel', collapsed, options),
dockPanel: () => this._panelChrome.dockPanel('street-level-panel'), dockPanel: () => this._panelChrome.dockPanel('street-level-panel'),
showToast: (message) => this._showToast(message), showToast: (message) => this._showToast(message),
// Who asked for Street Level to switch on: only a user, voice or tool // Lets the panel open for a user, voice or tool switch-on, not a restore.
// request opens the panel, never a saved-state restore.
subscribeEnableRequests: (listener) => subscribeEnableRequests: (listener) =>
this._dataManager?.subscribeVisibilityRequests?.((change) => { this._dataManager?.subscribeVisibilityRequests?.((change) => {
if (change?.layerId === 'street-level' && change.enabled) if (change?.layerId === 'street-level' && change.enabled)
@@ -1509,12 +1508,7 @@ export class StyleManager extends ShellFacade {
this._initCockpitDisplayPortal(); this._initCockpitDisplayPortal();
} }
/** /** A portable panel was resized or docked: refit any viewport inside it. */
* A portable panel finished a resize or snapped back to its rail. A panel
* with a viewport of its own refits here.
* @param {string} panelId
* @returns {void}
*/
_onPanelResized(panelId) { _onPanelResized(panelId) {
if (panelId === 'cctv-panel') this._syncCctvPanelViewport(); if (panelId === 'cctv-panel') this._syncCctvPanelViewport();
if (panelId === 'street-level-panel') if (panelId === 'street-level-panel')
+1 -1
View File
@@ -28,7 +28,7 @@ export function syncChipGroup(container, chips = [], { before = null } = {}) {
`data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`, `data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`,
); );
set(button, 'textContent', chip.label); set(button, 'textContent', chip.label);
// Optional per-chip colour (a Street Level source); CSS decides its use. // Optional per-chip colour; CSS decides how it is used.
const color = chip.color || ''; const color = chip.color || '';
if ((button.dataset.chipColor || '') !== color) { if ((button.dataset.chipColor || '') !== color) {
if (color) { if (color) {
+4 -10
View File
@@ -388,11 +388,7 @@ export class PanelChrome {
this.shareLinkManager?.onPanelStateChange?.(); this.shareLinkManager?.onPanelStateChange?.();
} }
/** /** @returns {boolean} Whether the panel was floating and is now docked. */
* Dock a floating portable panel back into its rail at its default size.
* @param {string} panelId
* @returns {boolean} Whether the panel was floating.
*/
dockPanel(panelId) { dockPanel(panelId) {
return this._panelPosition?.dockPanel?.(panelId) === true; return this._panelPosition?.dockPanel?.(panelId) === true;
} }
@@ -419,8 +415,7 @@ export class PanelChrome {
panelEl.classList.contains('cyber-accordion-collapsed'); panelEl.classList.contains('cyber-accordion-collapsed');
// A user opening a Cyber rail panel owns the whole accordion, including // A user opening a Cyber rail panel owns the whole accordion, including
// peers that were only presentation-collapsed during a saved-state restore. // peers that were only presentation-collapsed during a saved-state restore.
// A floating window is outside the accordion both ways: opening one // A floating window is outside the accordion both ways.
// leaves the rail's panels as they are.
if ( if (
explicit && explicit &&
!restore && !restore &&
@@ -433,7 +428,6 @@ export class PanelChrome {
if ( if (
peer !== panelEl && peer !== panelEl &&
peer.matches('[data-panel-id]') && peer.matches('[data-panel-id]') &&
// A floating window is not part of the rail's accordion.
!peer.classList.contains('panel-floating') && !peer.classList.contains('panel-floating') &&
!peer.hidden !peer.hidden
) { ) {
@@ -562,8 +556,8 @@ export class PanelChrome {
} }
} }
panelEl.classList.toggle('collapsed', nextCollapsed); panelEl.classList.toggle('collapsed', nextCollapsed);
// Only a user's own collapse docks a floating window; cockpit entry, // Only a user's own collapse docks a floating window; restores, cockpit
// restores and accordion peers must not discard where they put it. // entry and accordion peers keep its place.
if (explicit && !restore) if (explicit && !restore)
this._panelPosition?.onPanelCollapsed?.(panelId, nextCollapsed); this._panelPosition?.onPanelCollapsed?.(panelId, nextCollapsed);
if ( if (
+3 -4
View File
@@ -377,10 +377,9 @@ test('a header press that travels under the drag threshold is a click, not a lif
}); });
/** /**
* Both portable panels wired through _initPanelDrag. Each box follows its * Both portable panels wired through _initPanelDrag. Headers are the top
* inline left/top/width/height like a fixed window (`box` holds the natural * 36 px; `obstacles` stand in for the command dock and voice pill.
* size), its header is the top 36 px, and `obstacles` stand in for the * ResizeObserver callbacks run on `observe()`.
* command dock and voice pill. ResizeObserver callbacks run on `observe()`.
*/ */
function portableShell(f, { stored = {}, box = {}, obstacles = {} } = {}) { function portableShell(f, { stored = {}, box = {}, obstacles = {} } = {}) {
const observers = []; const observers = [];
+1 -2
View File
@@ -205,8 +205,7 @@ export class PanelLayoutController {
this._streetLevelPanel, this._streetLevelPanel,
]) { ]) {
if (!panel) continue; if (!panel) continue;
// A portable panel restored as a floating window keeps the place and // A floating window keeps its restored place and size.
// size PanelPositionControls gave it; only docked panels are reset.
if (!panel.classList.contains('panel-floating')) { if (!panel.classList.contains('panel-floating')) {
for (const property of ['top', 'right', 'bottom', 'left', 'z-index']) for (const property of ['top', 'right', 'bottom', 'left', 'z-index'])
panel.style.removeProperty(property); panel.style.removeProperty(property);
+35 -99
View File
@@ -13,37 +13,29 @@ const PANEL_POSITION_STORAGE_VERSION = 'v8';
const PANEL_Z_BASE = 100; const PANEL_Z_BASE = 100;
const PANEL_Z_MAX = 139; const PANEL_Z_MAX = 139;
/** /**
* Lowest z a promoted (dragged or floating) panel may take: above every panel * Lowest z for a promoted panel: above every docked rail panel (#pp-toggles is
* docked in a rail, the highest of which is #pp-toggles at 110 (controls.css). * 110). Keep in step with `#right-context-rail > .panel-floating` in controls.css.
* Renumbering restarts here, so a floating window never slips under DISPLAY.
* Keep in step with `#right-context-rail > .panel-floating` in controls.css.
*/ */
const PANEL_Z_FLOATING_FLOOR = PANEL_Z_BASE + 11; const PANEL_Z_FLOATING_FLOOR = PANEL_Z_BASE + 11;
/** Pointer travel before a header press becomes a drag that lifts a portable panel out. */ /** Pointer travel before a press becomes a drag or resize. */
const DRAG_THRESHOLD_PX = 4; const DRAG_THRESHOLD_PX = 4;
/** /**
* Two header presses this close in time and space snap a floating panel * Double press that snaps a floating panel back. Detected from pointerdown
* back. Detected from pointerdown because the drag handler's * because the drag's preventDefault() suppresses a native dblclick.
* preventDefault() suppresses the mouse events a native dblclick needs.
*/ */
const DOUBLE_PRESS_MS = 400; const DOUBLE_PRESS_MS = 400;
const DOUBLE_PRESS_SLOP_PX = 6; const DOUBLE_PRESS_SLOP_PX = 6;
/** Viewport margin every positioned panel keeps clear. */
const VIEWPORT_MARGIN_PX = 6; const VIEWPORT_MARGIN_PX = 6;
/** /**
* Fixed chrome that paints over every panel: the rail is its own stacking * Fixed chrome that paints over the rail's stacking context; a floating
* context (z 110), below the command dock (145) and the voice pill (150). A * window keeps its header clear of it so it can always be grabbed again.
* floating window keeps its header clear of these so it can always be
* grabbed again.
*/ */
const PANEL_OBSTACLE_IDS = ['command-dock', 'gev-voice-control']; const PANEL_OBSTACLE_IDS = ['command-dock', 'gev-voice-control'];
/** One-time hint shown the first time a portable panel leaves its rail. */
const PANEL_FLOAT_HINT_STORAGE_KEY = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`; const PANEL_FLOAT_HINT_STORAGE_KEY = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`;
const PANEL_FLOAT_HINT = 'Double-click the header to snap the panel back'; const PANEL_FLOAT_HINT = 'Double-click the header to snap the panel back';
/** Header children whose own interaction wins over a drag or a snap-back. */ /** Header children whose own interaction wins over a drag or a snap-back. */
const INTERACTIVE_SELECTOR = const INTERACTIVE_SELECTOR =
'input, select, option, textarea, button, a, [role="button"]'; 'input, select, option, textarea, button, a, [role="button"]';
/** Inline geometry a snap-back clears so the rail owns placement again. */
const FLOATING_STYLE_PROPERTIES = [ const FLOATING_STYLE_PROPERTIES = [
'left', 'left',
'top', 'top',
@@ -51,7 +43,6 @@ const FLOATING_STYLE_PROPERTIES = [
'bottom', 'bottom',
'width', 'width',
'height', 'height',
// A docked panel takes the rail's stacking, not a floating window's.
'z-index', 'z-index',
]; ];
@@ -59,7 +50,6 @@ function clamp(value, min, max) {
return Math.max(min, Math.min(max, value)); return Math.max(min, Math.min(max, value));
} }
/** A window extent that fits the viewport margins but never drops under its minimum. */
function fitWindowExtent(value, minimum, viewportExtent) { function fitWindowExtent(value, minimum, viewportExtent) {
return clamp( return clamp(
Math.round(value), Math.round(value),
@@ -68,11 +58,6 @@ function fitWindowExtent(value, minimum, viewportExtent) {
); );
} }
/**
* Only a mouse or pen lifts and resizes a portable panel. On a phone the
* rail is a scroll container, so a finger on a header is a swipe.
*/
/** Pin a panel's top-left corner at (left, top) as a fixed window would sit. */
function placeAt(panelEl, left, top) { function placeAt(panelEl, left, top) {
panelEl.style.left = `${left}px`; panelEl.style.left = `${left}px`;
panelEl.style.top = `${top}px`; panelEl.style.top = `${top}px`;
@@ -80,27 +65,19 @@ function placeAt(panelEl, left, top) {
panelEl.style.bottom = 'auto'; panelEl.style.bottom = 'auto';
} }
/** On a phone the rail scrolls, so a finger on a header is a swipe, not a drag. */
function isPrecisePointer(event) { function isPrecisePointer(event) {
const pointerType = event.pointerType || 'mouse'; const pointerType = event.pointerType || 'mouse';
return pointerType === 'mouse' || pointerType === 'pen'; return pointerType === 'mouse' || pointerType === 'pen';
} }
/** Inline geometry a cancelled gesture puts back. */
function geometryOf(panelEl) { function geometryOf(panelEl) {
const { left, top, width, height } = panelEl.style; const { left, top, width, height } = panelEl.style;
return { left, top, width, height }; return { left, top, width, height };
} }
export class PanelPositionControls { export class PanelPositionControls {
/** /** `onPanelResized(panelId)` runs after a portable panel moves, resizes or docks. */
* @param {object} options
* @param {Function} options.syncPanelCollapseButton
* @param {Function} options.layoutRightPanels
* @param {Function} options.syncCctvPanelViewport
* @param {Function} options.showToast
* @param {(panelId: string) => void} [options.onPanelResized] Fires after a
* portable panel's resize gesture ends or the panel snaps back to its rail.
*/
constructor({ constructor({
syncPanelCollapseButton, syncPanelCollapseButton,
layoutRightPanels, layoutRightPanels,
@@ -117,7 +94,6 @@ export class PanelPositionControls {
this._panelZCounter = PANEL_Z_BASE + 10; this._panelZCounter = PANEL_Z_BASE + 10;
this._draggableResizeObserver = null; this._draggableResizeObserver = null;
this._cancelDrag = null; this._cancelDrag = null;
/** Portable panels by id: `{ panel, handle, min: { width, height }, dockOnCollapse }`. */
this._portablePanels = new Map(); this._portablePanels = new Map();
this._resizeHandles = []; this._resizeHandles = [];
this._dragInitialized = false; this._dragInitialized = false;
@@ -154,10 +130,7 @@ export class PanelPositionControls {
} }
} }
/** /** Bring a floating window on-screen; shrink it only if it exceeds the viewport. */
* Bring a floating window back on-screen. Its size is the user's, except
* that a window wider or taller than the viewport shrinks to fit it.
*/
_reclampPortablePanel(entry) { _reclampPortablePanel(entry) {
const { panel, min } = entry; const { panel, min } = entry;
const left = parseInt(panel.style.left, 10); const left = parseInt(panel.style.left, 10);
@@ -178,15 +151,8 @@ export class PanelPositionControls {
} }
/** /**
* Where a floating window may sit: inside the viewport margin, with its * Clamp a floating window inside the viewport margin with its header above
* header out of the command dock and the voice pill, measured now because * the command dock and voice pill, measured each time since the dock resizes.
* the dock grows and shrinks. Both sit at the foot of the screen, so the
* header goes above whichever it would land under.
* @param {object} entry A `_portablePanels` value.
* @param {number} left
* @param {number} top
* @param {{width: number, height: number, header: number}} [geometry]
* Window size and header depth; measured when omitted.
*/ */
_clampPortableWindow( _clampPortableWindow(
entry, entry,
@@ -224,7 +190,6 @@ export class PanelPositionControls {
return { left: x, top: y }; return { left: x, top: y };
} }
/** A window's size and how far down its header reaches. */
_windowGeometry({ panel, handle }, rect = panel.getBoundingClientRect()) { _windowGeometry({ panel, handle }, rect = panel.getBoundingClientRect()) {
const handleRect = handle?.getBoundingClientRect(); const handleRect = handle?.getBoundingClientRect();
const header = handleRect ? handleRect.bottom - rect.top : 0; const header = handleRect ? handleRect.bottom - rect.top : 0;
@@ -269,9 +234,8 @@ export class PanelPositionControls {
this._dragInitialized = true; this._dragInitialized = true;
const cctvPanel = document.getElementById('cctv-panel'); const cctvPanel = document.getElementById('cctv-panel');
const streetLevelPanel = document.getElementById('street-level-panel'); const streetLevelPanel = document.getElementById('street-level-panel');
// A `portable` spec lifts out of its rail on a header drag, resizes from // A `portable` panel lifts out of its rail on a header drag, resizes from
// every edge and remembers its window; the others only reposition in // every edge and remembers its window; the others only reposition.
// place. Another panel opts in by adding a spec here.
const dragSpecs = [ const dragSpecs = [
{ {
id: 'pp-toggles', id: 'pp-toggles',
@@ -316,8 +280,8 @@ export class PanelPositionControls {
} }
// Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its // Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its
// full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and // full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and
// the panel could still hang off the bottom (audit U2). Re-clamp on every size change. A floating // the panel could still hang off the bottom (audit U2). Re-clamp on every size change. Floating
// window grows the same way when its content does (an image opens, a collapsed strip expands). // windows grow the same way when their content does.
const observed = [ const observed = [
this._ppToggles, this._ppToggles,
...[...this._portablePanels.values()].map(({ panel }) => panel), ...[...this._portablePanels.values()].map(({ panel }) => panel),
@@ -403,14 +367,12 @@ export class PanelPositionControls {
const pos = JSON.parse(raw); const pos = JSON.parse(raw);
if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number') if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number')
return; return;
// A floating record only means something to a panel that can float; a // Only portable panels store, and accept, floating records.
// portable panel in turn only ever stores floating records.
const portable = this._portablePanels.get(panelId); const portable = this._portablePanels.get(panelId);
if (Boolean(pos.floating) !== Boolean(portable)) return; if (Boolean(pos.floating) !== Boolean(portable)) return;
if (portable) { if (portable) {
panelEl.classList.add('panel-floating', 'panel-draggable'); panelEl.classList.add('panel-floating', 'panel-draggable');
// A restored window must stack above the docked rail panels too, not // A restored window must stack above the docked rail panels too.
// only one that was lifted or clicked this session.
this._promotePanelZ(panelEl); this._promotePanelZ(panelEl);
if (Number.isFinite(pos.width)) { if (Number.isFinite(pos.width)) {
panelEl.style.width = `${fitWindowExtent( panelEl.style.width = `${fitWindowExtent(
@@ -504,7 +466,7 @@ export class PanelPositionControls {
if (localStorage.getItem(PANEL_FLOAT_HINT_STORAGE_KEY)) return; if (localStorage.getItem(PANEL_FLOAT_HINT_STORAGE_KEY)) return;
localStorage.setItem(PANEL_FLOAT_HINT_STORAGE_KEY, '1'); localStorage.setItem(PANEL_FLOAT_HINT_STORAGE_KEY, '1');
} catch { } catch {
// storage unavailable: still worth saying once this session // storage unavailable: still show it once this session
if (this._floatHintShown) return; if (this._floatHintShown) return;
this._floatHintShown = true; this._floatHintShown = true;
} }
@@ -512,9 +474,8 @@ export class PanelPositionControls {
} }
/** /**
* Switch a portable panel from rail flow to a fixed window at the place it * Turn a rail panel into a fixed window in place. Width is frozen so a
* currently occupies. Width is frozen so a `width: 100%` rail panel keeps * `width: 100%` panel keeps its size; height stays natural until a resize.
* its size once fixed-positioned; height stays natural until a resize.
*/ */
_liftPanelOut(panelId, panelEl, rect = panelEl.getBoundingClientRect()) { _liftPanelOut(panelId, panelEl, rect = panelEl.getBoundingClientRect()) {
if (panelEl.classList.contains('panel-floating')) return false; if (panelEl.classList.contains('panel-floating')) return false;
@@ -522,7 +483,6 @@ export class PanelPositionControls {
if (!panelEl.classList.contains('collapsed')) { if (!panelEl.classList.contains('collapsed')) {
panelEl.style.width = `${Math.round(rect.width)}px`; panelEl.style.width = `${Math.round(rect.width)}px`;
} }
// Rail bookkeeping no longer applies to a window the rail does not allocate.
panelEl.style.removeProperty('--right-panel-allocated-height'); panelEl.style.removeProperty('--right-panel-allocated-height');
panelEl.removeAttribute('aria-hidden'); panelEl.removeAttribute('aria-hidden');
panelEl.classList.add('panel-floating', 'panel-draggable'); panelEl.classList.add('panel-floating', 'panel-draggable');
@@ -619,13 +579,9 @@ export class PanelPositionControls {
} }
/** /**
* Follow one pointer gesture on the window; every panel gesture runs on * Track one pointer gesture. With a `threshold`, `onStart` and `onMove` wait
* this. With a `threshold`, a press only becomes a gesture once the pointer * until the pointer has travelled that far; `onEnd`/`onCancel` get whether
* has travelled that far: `onStart` runs then, and `onMove` (given the * it started. Stopping through `_cancelDrag` calls neither.
* travel so far) only after it. `onEnd` (pointerup) and `onCancel`
* (pointercancel, `onEnd` unless given) learn whether it started. A new
* press, a snap-back or destroy() stops it through `_cancelDrag`, which
* calls neither.
*/ */
_trackPointerGesture( _trackPointerGesture(
event, event,
@@ -665,18 +621,7 @@ export class PanelPositionControls {
window.addEventListener('pointercancel', cancel); window.addEventListener('pointercancel', cancel);
} }
/** /** @returns {boolean} Whether the panel was floating and is now docked. */
* Portable drag: a press on the header only becomes a drag after a few
* pixels of travel, at which point the panel lifts out of its rail. The
* collapse button still toggles on a plain click, and the click that ends
* a real drag is swallowed so it cannot toggle by accident. Double-clicking
* the header snaps the panel back.
*/
/**
* Put a floating portable panel back in its rail at its default size.
* @param {string} panelId
* @returns {boolean} Whether the panel was floating.
*/
dockPanel(panelId) { dockPanel(panelId) {
const portable = this._portablePanels.get(panelId); const portable = this._portablePanels.get(panelId);
if (!portable?.panel.classList.contains('panel-floating')) return false; if (!portable?.panel.classList.contains('panel-floating')) return false;
@@ -684,18 +629,17 @@ export class PanelPositionControls {
return true; return true;
} }
/** /** A `dockOnCollapse` panel returns to its rail when collapsed while floating. */
* A panel was collapsed or expanded. A portable panel that opted into
* `dockOnCollapse` returns to its rail when collapsed while floating.
* @param {string} panelId
* @param {boolean} collapsed
*/
onPanelCollapsed(panelId, collapsed) { onPanelCollapsed(panelId, collapsed) {
if (!collapsed || !this._portablePanels.get(panelId)?.dockOnCollapse) if (!collapsed || !this._portablePanels.get(panelId)?.dockOnCollapse)
return; return;
this.dockPanel(panelId); this.dockPanel(panelId);
} }
/**
* The collapse button still toggles on a plain click; the click that ends a
* real drag is swallowed so it cannot toggle by accident.
*/
_makePortablePanelDraggable(panelId, panelEl, handleEl) { _makePortablePanelDraggable(panelId, panelEl, handleEl) {
let swallowNextClick = false; let swallowNextClick = false;
this.listen( this.listen(
@@ -790,11 +734,7 @@ export class PanelPositionControls {
}); });
} }
/** /** On pointercancel, restore the panel as the gesture found it; save nothing. */
* The browser took a gesture back (pointercancel): leave no trace and save
* nothing. A panel the gesture lifted returns to its rail; a window that
* was already floating returns to where it was.
*/
_revertPanelGesture(panelId, panelEl, lifted, before) { _revertPanelGesture(panelId, panelEl, lifted, before) {
if (lifted) { if (lifted) {
this._resetPanelPosition(panelId); this._resetPanelPosition(panelId);
@@ -804,11 +744,7 @@ export class PanelPositionControls {
this._onPanelResized?.(panelId); this._onPanelResized?.(panelId);
} }
/** /** Resizing a docked panel lifts it out first, so handles work in both states. */
* Append a resize handle for every edge and corner. Seven are invisible
* strips; the bottom-right corner shows a grip. Resizing a docked panel
* lifts it out first, so the handles work in both states.
*/
_makePanelResizable(panelId, panelEl, min = {}) { _makePanelResizable(panelId, panelEl, min = {}) {
const limits = { width: 300, height: 160, ...min }; const limits = { width: 300, height: 160, ...min };
for (const dir of RESIZE_DIRECTIONS) { for (const dir of RESIZE_DIRECTIONS) {
@@ -832,8 +768,8 @@ export class PanelPositionControls {
if (event.button !== 0 || !isPrecisePointer(event)) return; if (event.button !== 0 || !isPrecisePointer(event)) return;
event.preventDefault(); event.preventDefault();
this._cancelDrag?.(); this._cancelDrag?.();
// Set once the pointer has travelled: a plain click on an edge (or on // Set once the pointer has travelled: a plain click on an edge must not
// the globe just beside a docked panel) must not lift it out. // lift the panel out.
let startBox = null; let startBox = null;
let lifted = false; let lifted = false;
let before = null; let before = null;
@@ -874,7 +810,7 @@ export class PanelPositionControls {
onStop: () => panelEl.classList.remove('panel-resizing'), onStop: () => panelEl.classList.remove('panel-resizing'),
onEnd: (resized) => { onEnd: (resized) => {
if (!resized) return; if (!resized) return;
// Re-clamping skipped the live gesture; a north edge may have carried // Re-clamping skipped the live gesture; a north edge may have moved
// the header under the command dock. // the header under the command dock.
const entry = this._portablePanels.get(panelId); const entry = this._portablePanels.get(panelId);
if (entry) this._reclampPortablePanel(entry); if (entry) this._reclampPortablePanel(entry);
+2 -4
View File
@@ -357,10 +357,8 @@ test('right rail aligns to the current left rail and excludes hidden obstacles',
assert.equal(f.stack.dataset.safeTop, '200.0'); assert.equal(f.stack.dataset.safeTop, '200.0');
}); });
// The left pass commits its top and requests this pass in the same frame, but // The left rail's `top` animates after the right pass runs, and nothing
// `top` then animates for 150 ms and nothing re-runs the right pass when it // re-runs it when the transition lands: the right rail must read the end value.
// lands. CI docked the right rail at 234 px (26vh) while the left settled at
// 266.4 px (29.6vh). Mid-flight the right rail must read the destination.
function animatingLeftRail({ keyframes = true } = {}) { function animatingLeftRail({ keyframes = true } = {}) {
const f = fixture('right'); const f = fixture('right');
const left = f.options.leftStack; const left = f.options.leftStack;
+3 -21
View File
@@ -1,11 +1,6 @@
/** // Pure resize geometry for floating panels; PanelPositionControls wires the DOM.
* Pure geometry for resizing a floating panel from any edge or corner. The
* DOM wiring lives in PanelPositionControls; this module only decides boxes.
*/
/** Viewport margin a resized panel keeps clear on every side. */
const EDGE_MARGIN_PX = 6; const EDGE_MARGIN_PX = 6;
/** Resize directions: compass edges first, then corners. */
export const RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'sw', 'se']; export const RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'sw', 'se'];
function clamp(value, min, max) { function clamp(value, min, max) {
@@ -13,21 +8,8 @@ function clamp(value, min, max) {
} }
/** /**
* Work out the box a resize gesture produces. Edges named in `dir` move with * Edges named in `dir` follow the pointer (dx, dy since gesture start) and the
* the pointer; the opposite edge stays put, so growing from the left keeps * opposite edge stays put. Minimum size and the viewport margin win.
* the right side pinned. Minimum size and the viewport margin win over the
* pointer.
* @param {{left: number, top: number, width: number, height: number}} box Box at gesture start.
* @param {string} dir One of RESIZE_DIRECTIONS.
* @param {number} dx Pointer travel since gesture start.
* @param {number} dy Pointer travel since gesture start.
* @param {object} [limits]
* @param {number} limits.minWidth
* @param {number} limits.minHeight
* @param {number} [limits.viewportWidth=Infinity]
* @param {number} [limits.viewportHeight=Infinity]
* @param {number} [limits.margin=6]
* @returns {{left: number, top: number, width: number, height: number}}
*/ */
export function resizeBox( export function resizeBox(
box, box,
+4 -12
View File
@@ -12,16 +12,9 @@ import { displayPanelScroller } from './displayPanelScroll.js';
const pendingCollapseRetries = new WeakSet(); const pendingCollapseRetries = new WeakSet();
/** /**
* Read where the left rail's top edge is going to settle, not where it is. * Where the left rail's top will settle, not where it is mid-transition:
* The left pass commits `--left-stack-safe-top` and requests this pass in the * nothing re-runs this pass when the `top` animation ends (ResizeObserver
* same frame, but `top` then animates; nothing re-runs this pass when the * ignores moves). Returns NaN when there is no left rail.
* animation ends, because ResizeObserver ignores moves. Mid-flight, take the
* running transition's end value (which also covers Cyber's fixed rail top),
* falling back to the left pass's committed target.
* @param {HTMLElement} leftStack Rail supplying the shared top baseline.
* @param {number} viewportHeight Current viewport height in px.
* @param {Function} getComputedStyle DOM style reader.
* @returns {number} Settled top in px, or NaN when there is no left rail.
*/ */
function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) { function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) {
const top = leftStack?.getBoundingClientRect().top; const top = leftStack?.getBoundingClientRect().top;
@@ -76,8 +69,7 @@ export function layoutRightPanelRail({
// A collapse may schedule one follow-up, which only measures and allocates. // A collapse may schedule one follow-up, which only measures and allocates.
const isCollapseRetry = pendingCollapseRetries.delete(stack); const isCollapseRetry = pendingCollapseRetries.delete(stack);
// A panel lifted out of the rail (PanelPositionControls' portable mode) // A floating (lifted-out) panel is not laid out by the rail.
// is a fixed window the rail neither measures nor allocates.
const panels = [...stack.children].filter( const panels = [...stack.children].filter(
(panel) => (panel) =>
panel.matches('[data-panel-id]:not(.panel-floating)') && !panel.hidden, panel.matches('[data-panel-id]:not(.panel-floating)') && !panel.hidden,
+24 -64
View File
@@ -8,7 +8,6 @@ import {
const RENDER_MODE_KEY = 'gev:street-level:render-mode'; const RENDER_MODE_KEY = 'gev:street-level:render-mode';
const FOCUSABLE = const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
/** Arrow keys inside a radiogroup: the step to the next radio. */
const RADIO_STEPS = Object.freeze({ const RADIO_STEPS = Object.freeze({
ArrowRight: 1, ArrowRight: 1,
ArrowDown: 1, ArrowDown: 1,
@@ -26,11 +25,8 @@ const setAttr = (node, key, value) => {
}; };
/** /**
* Own the Street Level panel: the provider chips, the imagery filters, the * Fills the Street Level panel body: provider chips, filters, legend and
* legend and the embedded street-level viewer. The panel itself is * viewer. The panel chrome itself belongs to the application shell.
* ordinary GEV chrome (collapse button, rail layout, persistence) driven by
* the application shell; this class only fills the body and asks the shell
* to open the panel when something worth seeing arrives.
*/ */
export class StreetLevelControls { export class StreetLevelControls {
constructor({ root, layer, actions }) { constructor({ root, layer, actions }) {
@@ -47,21 +43,14 @@ export class StreetLevelControls {
// Until the user first touches the app, an off → on is the saved layer // Until the user first touches the app, an off → on is the saved layer
// state being restored, not a user switching the layer on. // state being restored, not a user switching the layer on.
this._restoreWindow = true; this._restoreWindow = true;
/** // Whether the last switch-on was explicit (user, voice, tool) rather than
* Whether the last request to switch the layer on came from the user // a restore; null when the shell does not report request origins.
* (or voice, or a tool) rather than a restore; null when the shell does
* not report requests, and the restore window decides instead.
*/
this._explicitEnable = null; this._explicitEnable = null;
this._unsubscribeEnableRequests = null; this._unsubscribeEnableRequests = null;
this._resizeQueued = false; this._resizeQueued = false;
this._wrapHome = null; this._wrapHome = null;
this._expandReturnFocus = null; this._expandReturnFocus = null;
/** // Only the <body> children this class made inert are released again.
* The <body> children this class made inert while the expanded viewer is
* a modal on screen; only these are released again.
* @type {Set<Element>}
*/
this._inerted = new Set(); this._inerted = new Set();
this._modalObserver = null; this._modalObserver = null;
this._elements = this._collect(); this._elements = this._collect();
@@ -111,7 +100,6 @@ export class StreetLevelControls {
this.layer.attachViewerHost?.(el.viewer); this.layer.attachViewerHost?.(el.viewer);
this.listen(el.status, 'click', () => this._toggleEnabled()); this.listen(el.status, 'click', () => this._toggleEnabled());
// One delegated listener; chips are re-synced in place on every render.
this.listen(el.providerChips, 'click', (event) => { this.listen(el.providerChips, 'click', (event) => {
const button = event.target?.closest?.('.data-toggle-chip'); const button = event.target?.closest?.('.data-toggle-chip');
if (!button || button.disabled) return; if (!button || button.disabled) return;
@@ -185,11 +173,7 @@ export class StreetLevelControls {
} }
} }
/** /** ARIA radio keys. Selecting clicks the radio, so keys and pointer share a path. */
* One radiogroup (ARIA radio pattern): a single tab stop, arrow keys move
* the selection, Home and End jump to the ends. Selecting is the radio's
* own click, so the keyboard takes the same path as the pointer.
*/
_bindRadioGroup(buttons) { _bindRadioGroup(buttons) {
buttons.forEach((button, index) => { buttons.forEach((button, index) => {
this.listen(button, 'keydown', (event) => { this.listen(button, 'keydown', (event) => {
@@ -208,9 +192,8 @@ export class StreetLevelControls {
} }
/** /**
* Refit the viewer once, on the next frame, however many asked. A hidden * Refit the viewer once per frame. Skips a hidden (0×0) viewer, which would
* viewer measures 0×0 and would ask for tiles at z=NaN; the observer fires * ask for tiles at z=NaN; the observer fires again once it has a size.
* again once it has a size.
*/ */
_requestResize() { _requestResize() {
if (this._resizeQueued) return; if (this._resizeQueued) return;
@@ -247,11 +230,7 @@ export class StreetLevelControls {
} }
} }
/** /** Through the data manager, so saved state and share links record it. */
* Filter and provider changes go through the data manager as a user params
* request, so saved state and share links record them; the layer applies
* them through its own `setParams`.
*/
_setParams(params) { _setParams(params) {
if (this.actions.setParams) if (this.actions.setParams)
this.actions.setParams(params, { origin: 'user' }); this.actions.setParams(params, { origin: 'user' });
@@ -259,9 +238,8 @@ export class StreetLevelControls {
} }
/** /**
* A provider chip is the layer's switch: lighting one turns the layer on * Lighting a chip turns the layer on; darkening the last lit one turns the
* with that provider; darkening the last lit one turns the layer off (the * layer off but keeps the provider on, so the layer comes back with it.
* provider stays switched on, so the layer comes back with it).
*/ */
async _toggleProvider(providerId) { async _toggleProvider(providerId) {
const chip = this._view?.providers.find((entry) => entry.id === providerId); const chip = this._view?.providers.find((entry) => entry.id === providerId);
@@ -298,12 +276,10 @@ export class StreetLevelControls {
if (this.layer.getUIState) this.render(this.layer.getUIState()); if (this.layer.getUIState) this.render(this.layer.getUIState());
} }
/** The panel window was resized or docked again: refit the viewer. */
onPanelResized() { onPanelResized() {
this._requestResize(); this._requestResize();
} }
/** Ask the shell to open (or close) the rail panel. */
setCollapsed(collapsed, options = {}) { setCollapsed(collapsed, options = {}) {
this.actions.setPanelCollapsed?.(collapsed, options); this.actions.setPanelCollapsed?.(collapsed, options);
if (!collapsed) this._requestResize(); if (!collapsed) this._requestResize();
@@ -318,9 +294,8 @@ export class StreetLevelControls {
} }
/** /**
* Grow the street-level viewer to most of the screen, or shrink it back. * A user's shrink (`dock`) also returns a floating panel to its rail, so the
* A user's shrink (`dock`) also returns a floating panel to its rail at its * viewer does not land in a window over the globe.
* default size, so the viewer does not land in a window over the globe.
*/ */
setViewerExpanded(expanded, { dock = false } = {}) { setViewerExpanded(expanded, { dock = false } = {}) {
const wrap = this._elements.viewerWrap; const wrap = this._elements.viewerWrap;
@@ -384,10 +359,7 @@ export class StreetLevelControls {
target?.focus?.({ preventScroll: true }); target?.focus?.({ preventScroll: true });
} }
/** /** Clean View, recording and the cockpit hide the expanded viewer with CSS. */
* The expanded viewer holds Esc and Tab only while it is on screen: Clean
* View, recording and the cockpit hide it with CSS.
*/
_isDialogShown() { _isDialogShown() {
if (!this.isViewerExpanded()) return false; if (!this.isViewerExpanded()) return false;
const wrap = this._elements.viewerWrap; const wrap = this._elements.viewerWrap;
@@ -402,11 +374,9 @@ export class StreetLevelControls {
} }
/** /**
* Make the application modal-inert while the expanded viewer is on screen: * Make every other <body> child inert while the expanded viewer is shown.
* every other <body> child is `inert` (one already inert is left alone), so * A CSS-hidden viewer releases them so a hidden modal never leaves the app
* no control around the viewer takes a click or focus. A viewer that CSS * dead. Returns whether the dialog is shown.
* hides (Clean View, recording, cockpit) releases them, since a hidden
* modal must not leave the app dead. Returns whether the dialog is shown.
*/ */
_syncModal() { _syncModal() {
if (this.destroyed || !this._isDialogShown()) { if (this.destroyed || !this._isDialogShown()) {
@@ -422,17 +392,12 @@ export class StreetLevelControls {
return true; return true;
} }
/** Give the application back every node `_syncModal` made inert. */
_releaseModal() { _releaseModal() {
for (const node of this._inerted) node.inert = false; for (const node of this._inerted) node.inert = false;
this._inerted.clear(); this._inerted.clear();
} }
/** /** Re-check the modal when <body> changes class or gains a child. */
* While expanded, re-check the modal whenever <body> changes class (the
* modes that hide the viewer) or gains a child (which must be inert too).
* Shrinking stops watching and releases the application.
*/
_watchModal(on) { _watchModal(on) {
this._modalObserver?.disconnect(); this._modalObserver?.disconnect();
this._modalObserver = null; this._modalObserver = null;
@@ -534,7 +499,6 @@ export class StreetLevelControls {
setProp(el.errorText, 'textContent', view.error || ''); setProp(el.errorText, 'textContent', view.error || '');
} }
/** Mark the checked radio of a group and make it the group's tab stop. */
_renderRadios(buttons, isChecked) { _renderRadios(buttons, isChecked) {
for (const button of buttons) { for (const button of buttons) {
const checked = isChecked(button); const checked = isChecked(button);
@@ -629,16 +593,14 @@ export class StreetLevelControls {
} }
/** /**
* Open the panel at the moments a user would look for it. These opens are * Open the panel on a switch-on or when an image opens. Never persisted:
* never persisted: the stored collapse state stays the user's own choice. * the stored collapse state stays the user's own choice.
*/ */
_reactToTransitions(view, state) { _reactToTransitions(view, state) {
const enabled = view.enabled; const enabled = view.enabled;
if (enabled && this._wasEnabled === false) { if (enabled && this._wasEnabled === false) {
// A restore that turns the saved layer back on must not reopen a panel // A restore must not reopen a panel the user or a share link kept
// the user (or a share link) chose to keep collapsed. With the shell's // collapsed. Without request origins, the restore window decides.
// request origins the panel opens only for a user, voice or tool
// switch-on; without them, the restore window stands in.
const preference = this.root.dataset?.collapsedPreference; const preference = this.root.dataset?.collapsedPreference;
const restoring = const restoring =
this._explicitEnable === null this._explicitEnable === null
@@ -650,10 +612,8 @@ export class StreetLevelControls {
this._explicitEnable = this._explicitEnable === null ? null : false; this._explicitEnable = this._explicitEnable === null ? null : false;
} }
this._wasEnabled = enabled; this._wasEnabled = enabled;
// Size changes reach the viewer through the ResizeObserver; resizing on // Other size changes reach the viewer through the ResizeObserver; opening
// every render ran each frame while a panorama was dragged. Opening is the // asks for one resize because the element may not have a size yet.
// one moment the element may not have reported a size yet, and opening
// the panel asks for that one (coalesced) resize.
const open = state.street.open === true; const open = state.street.open === true;
if (open && !this._wasStreetOpen) if (open && !this._wasStreetOpen)
this.setCollapsed(false, { persist: false }); this.setCollapsed(false, { persist: false });
+11 -25
View File
@@ -13,10 +13,7 @@ import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
/* ── A small DOM: just what the panel controls touch ───────────────────── */ /* ── A small DOM: just what the panel controls touch ───────────────────── */
/** /** Every write a page would see as a mutation, even one to the same value. */
* Every write the page would see as a mutation: an attribute set or removed,
* or a reflected property assigned (even to the value it already has).
*/
const mutations = { count: 0 }; const mutations = { count: 0 };
const REFLECTED = ['hidden', 'disabled', 'textContent', 'title', 'value']; const REFLECTED = ['hidden', 'disabled', 'textContent', 'title', 'value'];
/** The reflected properties a MutationObserver sees, as the record it gets. */ /** The reflected properties a MutationObserver sees, as the record it gets. */
@@ -27,10 +24,8 @@ const REFLECTED_RECORDS = {
textContent: { type: 'childList' }, textContent: { type: 'childList' },
}; };
/** The fake MutationObservers currently observing (see fakeMutationObserver). */
const liveObservers = new Set(); const liveObservers = new Set();
/** Queue a mutation record for every observer whose options cover it. */
function recordMutation(target, { type, attributeName = null }) { function recordMutation(target, { type, attributeName = null }) {
for (const observer of liveObservers) for (const observer of liveObservers)
observer.consider(target, type, attributeName); observer.consider(target, type, attributeName);
@@ -116,10 +111,7 @@ class FakeNode {
const siblings = this.parent.children; const siblings = this.parent.children;
return siblings[siblings.indexOf(this) + 1] || null; return siblings[siblings.indexOf(this) + 1] || null;
} }
/** /** Null when this node or an ancestor is hidden or disconnected. */
* As layout reports it: null when this node or an ancestor is hidden (no
* box), or when it is not in the document; else its parent.
*/
get offsetParent() { get offsetParent() {
if (!this.isConnected) return null; if (!this.isConnected) return null;
for (let node = this; node; node = node.parent) for (let node = this; node; node = node.parent)
@@ -388,11 +380,7 @@ const memoryStorage = () => {
}; };
}; };
/** /** The production path: controls → dataManager → layer → durable layer state. */
* The production path a panel click takes: controls → dataManager
* .setLayerParams → layer.setParams → LayerStateCoordinator's durable state,
* which is what share links and saved state encode.
*/
async function productionPanel(dom) { async function productionPanel(dom) {
const provider = standInProvider(); const provider = standInProvider();
const layer = createStreetLevelLayer({ providers: [provider.def] }); const layer = createStreetLevelLayer({ providers: [provider.def] });
@@ -592,7 +580,7 @@ test('lighting a dark chip switches the provider on as a user params request, th
assert.deepEqual(calls.setEnabled, [true]); assert.deepEqual(calls.setEnabled, [true]);
})); }));
test('the legend rebuilds when a swatch changes, even at the same count (review #9)', () => test('the legend rebuilds when a swatch changes, even at the same count', () =>
withDom(async (dom) => { withDom(async (dom) => {
const { layer } = stubPanel(dom, uiState()); const { layer } = stubPanel(dom, uiState());
const legend = dom.root.querySelector('#sl-legend'); const legend = dom.root.querySelector('#sl-legend');
@@ -610,7 +598,7 @@ test('the legend rebuilds when a swatch changes, even at the same count (review
assert.equal(swatch(), '#a66bff'); assert.equal(swatch(), '#a66bff');
})); }));
test('the viewer is resized once when it opens, not on every render (review #8, gekh P3)', () => test('the viewer is resized once when it opens, not on every render', () =>
withDom(async (dom) => { withDom(async (dom) => {
const { layer } = stubPanel(dom, uiState()); const { layer } = stubPanel(dom, uiState());
await settle(); await settle();
@@ -714,7 +702,7 @@ test('an image closed while expanded does not return focus into the hidden viewe
controls.destroy(); controls.destroy();
})); }));
/* ── gekh review on #768 ───────────────────────────────────────────────── */ /* ── Restores, the modal viewer and keyboard rules ─────────────────────── */
test('a restored layer does not reopen a panel the user collapsed, and automatic opens are never stored (P2-1)', () => test('a restored layer does not reopen a panel the user collapsed, and automatic opens are never stored (P2-1)', () =>
withDom(async (dom) => { withDom(async (dom) => {
@@ -811,10 +799,8 @@ test('a hidden expanded viewer (Clean View, recording, cockpit) holds neither Es
})); }));
/** /**
* A MutationObserver stand-in. Like the real one it queues only the records * A MutationObserver stand-in: queues only the records its options cover and
* its `observe` options cover (childList, attributes / attributeFilter, * delivers them in a microtask; `fire()` delivers at once.
* subtree) and delivers them in a microtask, only when there are some;
* `fire()` delivers what is queued at once.
*/ */
function fakeMutationObserver() { function fakeMutationObserver() {
const observers = []; const observers = [];
@@ -888,7 +874,7 @@ const inertOutside = (dom) =>
.filter((node) => node.id !== 'sl-viewer-wrap') .filter((node) => node.id !== 'sl-viewer-wrap')
.map((node) => [node.id, node.inert === true]); .map((node) => [node.id, node.inert === true]);
test('the expanded viewer makes the rest of the app inert and gives it back on shrink (IC8 P2)', () => test('the expanded viewer makes the rest of the app inert and gives it back on shrink', () =>
withObserver(() => withObserver(() =>
withDom(async (dom) => { withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true })); const { controls } = stubPanel(dom, uiState({ open: true }));
@@ -919,7 +905,7 @@ test('the expanded viewer makes the rest of the app inert and gives it back on s
}), }),
)); ));
test('a CSS-hidden expanded viewer releases the app; showing it again, and new children, go inert (IC8 P2)', () => test('a CSS-hidden expanded viewer releases the app; showing it again, and new children, go inert', () =>
withObserver((observer) => withObserver((observer) =>
withDom(async (dom) => { withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true })); const { controls } = stubPanel(dom, uiState({ open: true }));
@@ -954,7 +940,7 @@ test('a CSS-hidden expanded viewer releases the app; showing it again, and new c
}), }),
)); ));
test('an image closed while expanded releases the app (IC8 P2)', () => test('an image closed while expanded releases the app', () =>
withObserver(() => withObserver(() =>
withDom(async (dom) => { withDom(async (dom) => {
const { layer, controls } = stubPanel(dom, uiState({ open: true })); const { layer, controls } = stubPanel(dom, uiState({ open: true }));
+7 -26
View File
@@ -1,17 +1,10 @@
import { keySetupRequirement } from '../keySetupCore.mjs'; import { keySetupRequirement } from '../keySetupCore.mjs';
/** // Street Level UI state to panel strings and flags. Pure, so it is testable.
* Turn the Street Level layer's UI state into the strings and flags the panel
* renders. Pure: no DOM, no layer calls, so every wording decision is testable.
*/
const DAY_MS = 86_400_000; const DAY_MS = 86_400_000;
/** /** SINCE slider stops, in relative days so a share link keeps its meaning. */
* Stops of the SINCE slider, oldest window on the left: position 0 shows
* every capture, the right end only the last month. Stored as relative days
* so a share link keeps its meaning over time.
*/
export const SINCE_STOPS = Object.freeze([ export const SINCE_STOPS = Object.freeze([
Object.freeze({ days: 0, label: 'ANY DATE' }), Object.freeze({ days: 0, label: 'ANY DATE' }),
Object.freeze({ days: 3652, label: 'LAST 10 YEARS' }), Object.freeze({ days: 3652, label: 'LAST 10 YEARS' }),
@@ -24,7 +17,7 @@ export const SINCE_STOPS = Object.freeze([
Object.freeze({ days: 30, label: 'LAST MONTH' }), Object.freeze({ days: 30, label: 'LAST MONTH' }),
]); ]);
/** Slider position for a day count: the exact stop, else the nearest one. */ /** The exact stop for a day count, else the nearest one. */
export function sinceStopIndex(days) { export function sinceStopIndex(days) {
const value = Number(days) || 0; const value = Number(days) || 0;
if (value <= 0) return 0; if (value <= 0) return 0;
@@ -38,7 +31,6 @@ export function sinceStopIndex(days) {
return best; return best;
} }
/** Readout beside the slider: the window, plus the cut-off date it means today. */
function presentSince(days, now) { function presentSince(days, now) {
const value = Number(days) || 0; const value = Number(days) || 0;
const index = sinceStopIndex(value); const index = sinceStopIndex(value);
@@ -61,7 +53,6 @@ function formatDate(ms) {
} }
} }
/** The header pill doubles as the layer's on/off switch. */
function presentStatus(state) { function presentStatus(state) {
const pressed = state.enabled === true; const pressed = state.enabled === true;
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on'; const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
@@ -76,11 +67,7 @@ function presentStatus(state) {
: { text: 'OFF', tone: '', pressed, title }; : { text: 'OFF', tone: '', pressed, title };
} }
/** /** A chip is lit only while the layer and its provider are both on. */
* One chip per registered provider; a keyless provider reads as an error
* chip. A chip is lit only while the layer is on and that provider is
* switched on, so with a single provider the chip is the layer's switch.
*/
function presentProviders(state) { function presentProviders(state) {
const enabled = state.enabled === true; const enabled = state.enabled === true;
return (state.providers || []).map((provider) => { return (state.providers || []).map((provider) => {
@@ -148,18 +135,13 @@ function presentMeta(state) {
return state.coverage.hint || ''; return state.coverage.hint || '';
} }
/** /** @param {{now?: number}} [options] Clock for the SINCE readout. */
* @param {object} state Snapshot from the layer's `getUIState()`.
* @param {{now?: number}} [options] Clock for the SINCE readout (tests pin it).
* @returns {object} Everything the panel needs, already worded.
*/
export function presentStreetLevelPanel(state, { now = Date.now() } = {}) { export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
const enabled = state.enabled === true; const enabled = state.enabled === true;
const keyRequired = state.keyRequired === true; const keyRequired = state.keyRequired === true;
const filter = state.filter || { pano: 'all', sinceDays: 0 }; const filter = state.filter || { pano: 'all', sinceDays: 0 };
// KEY REQUIRED already says it, and the chip's tooltip says how to fix it; // A missing key is already shown by the status and chip tooltip; only a
// the error under it would only repeat a raw code (`no_key`). A rejected // rejected key keeps its error line, since that message names the fix.
// key keeps its line: that message names the fix.
const keyMissing = keyRequired && state.keyRejected !== true; const keyMissing = keyRequired && state.keyRejected !== true;
return { return {
enabled, enabled,
@@ -175,7 +157,6 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
legend: state.legend || [], legend: state.legend || [],
viewer: presentViewer(state), viewer: presentViewer(state),
meta: presentMeta(state), meta: presentMeta(state),
/** The panel opens itself when an image opens (a native panel stays put otherwise). */
wantsOpen: state.street.open === true, wantsOpen: state.street.open === true,
}; };
} }
+2 -2
View File
@@ -65,7 +65,7 @@ test('a key-gated layer disables every control and flags KEY REQUIRED', () => {
assert.equal(view.status.tone, 'warn'); assert.equal(view.status.tone, 'warn');
}); });
test('KEY REQUIRED shows no raw error code under the pill (review P3)', () => { test('KEY REQUIRED shows no raw error code under the pill', () => {
const view = presentStreetLevelPanel( const view = presentStreetLevelPanel(
snapshot({ snapshot({
enabled: true, enabled: true,
@@ -80,7 +80,7 @@ test('KEY REQUIRED shows no raw error code under the pill (review P3)', () => {
assert.match(view.providers[0].title, /^Mapillary: Needs /); assert.match(view.providers[0].title, /^Mapillary: Needs /);
}); });
test('a key Mapillary rejected reads KEY REJECTED and names the fix (review IC8 P2)', () => { test('a key Mapillary rejected reads KEY REJECTED and names the fix', () => {
const error = const error =
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings'; 'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
const view = presentStreetLevelPanel( const view = presentStreetLevelPanel(
+5 -9
View File
@@ -104,10 +104,8 @@
} }
/* ── Portable rail panels ──────────────────── /* ── Portable rail panels ────────────────────
A header drag lifts a portable panel (PanelPositionControls) out of its Rail placement in layers.css is guarded with :not(.panel-floating), so
rail into a fixed window that remembers its place and size. The rail's these rules only have to beat a panel's base placement (one id). */
own placement rules in layers.css are guarded with :not(.panel-floating),
so these only have to beat a panel's base placement (one id). */
.panel-portable .panel-header { .panel-portable .panel-header {
cursor: grab; cursor: grab;
} }
@@ -117,16 +115,14 @@
#right-context-rail > .panel-floating { #right-context-rail > .panel-floating {
position: fixed; position: fixed;
inset: auto; inset: auto;
/* Above every docked rail panel (#pp-toggles is 110). PanelPositionControls /* Above every docked rail panel; keep in step with PANEL_Z_FLOATING_FLOOR. */
promotes floating windows from this floor (PANEL_Z_FLOATING_FLOOR). */
z-index: 111; z-index: 111;
max-height: calc(100vh - 12px); max-height: calc(100vh - 12px);
margin: 0; margin: 0;
flex: none; flex: none;
pointer-events: auto; pointer-events: auto;
} }
/* A collapsed window keeps its stored height for later but shows only its /* A collapsed window shows only its header strip; its inline height stays. */
header strip, so the resize handles have nothing to attach to. */
#right-context-rail > .panel-floating.collapsed { #right-context-rail > .panel-floating.collapsed {
max-height: max-content; max-height: max-content;
} }
@@ -1110,7 +1106,7 @@ body.ui-clean-view #cctv-panel,
body.ui-clean-view #radio-panel, body.ui-clean-view #radio-panel,
body.ui-clean-view #global-context-panel, body.ui-clean-view #global-context-panel,
body.ui-clean-view #scene-panel, body.ui-clean-view #scene-panel,
/* The expanded Street Level viewer lives on <body>, outside every panel. */ /* The expanded viewer lives on <body>, outside every panel. */
body.ui-clean-view .sl-viewer-wrap-expanded { body.ui-clean-view .sl-viewer-wrap-expanded {
opacity: 0 !important; opacity: 0 !important;
visibility: hidden !important; visibility: hidden !important;
+1 -1
View File
@@ -16,7 +16,7 @@ body.recording-mode #radio-panel,
body.recording-mode #global-context-panel, body.recording-mode #global-context-panel,
body.recording-mode #gev-voice-control, body.recording-mode #gev-voice-control,
body.recording-mode #scene-panel, body.recording-mode #scene-panel,
/* The expanded Street Level viewer lives on <body>, outside every panel. */ /* The expanded viewer lives on <body>, outside every panel. */
body.recording-mode .sl-viewer-wrap-expanded { body.recording-mode .sl-viewer-wrap-expanded {
opacity: 0 !important; opacity: 0 !important;
visibility: hidden !important; visibility: hidden !important;
+14 -25
View File
@@ -1,7 +1,6 @@
/* ── Street Level panel ───────────────────────────────────────── /* ── Street Level panel ─────────────────────────────────────────
Lives in the right context rail next to CCTV; the rail owns position and The rail owns position and width (layers.css). The expanded viewer is
width (see layers.css). Everything here is panel-internal. The expanded re-parented to <body>, so it carries the tokens too. */
viewer is re-parented to <body>, so it carries the tokens too. */
#street-level-panel, #street-level-panel,
.sl-viewer-wrap { .sl-viewer-wrap {
--sl-green: #05cb63; --sl-green: #05cb63;
@@ -111,8 +110,6 @@ button.sl-status:focus-visible {
min-width: 0; min-width: 0;
} }
/* The filters a missing key gates. The provider chips stay outside it, so
their tooltip (how to add the key) is still reachable. */
.sl-controls { .sl-controls {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -128,8 +125,7 @@ button.sl-status:focus-visible {
pointer-events: none; pointer-events: none;
} }
/* Everything but the viewer. Docked, the whole panel scrolls; in a resized /* In a resized window only this block scrolls; the viewer keeps its room. */
window the viewer keeps its room and only this block scrolls. */
.sl-settings { .sl-settings {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -151,8 +147,7 @@ button.sl-status:focus-visible {
gap: 4px; gap: 4px;
} }
/* One colour per source: a swatch dot, and the whole chip in that colour /* `--chip-color` comes from the provider. */
while the source is on. `--chip-color` comes from the provider. */
.sl-chips .data-toggle-chip { .sl-chips .data-toggle-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -246,7 +241,6 @@ button.sl-status:focus-visible {
color: var(--text-primary); color: var(--text-primary);
} }
/* SINCE: a stepped slider, oldest window left, newest right. */
.sl-since { .sl-since {
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto 1fr;
@@ -318,8 +312,7 @@ button.sl-status:focus-visible {
display: none; display: none;
} }
/* The image stage: the viewer and its loading veil, nothing on top of it, /* Nothing sits over the image, so the provider's own controls stay clear. */
so the provider's own controls (sequence bar, zoom, bearing) stay clear. */
.sl-viewer-stage { .sl-viewer-stage {
position: relative; position: relative;
display: flex; display: flex;
@@ -362,7 +355,6 @@ button.sl-status:focus-visible {
} }
} }
/* Our tools live in a bar above the image rather than over it. */
.sl-viewer-bar { .sl-viewer-bar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -485,16 +477,15 @@ button.sl-status:focus-visible {
} }
/* ── Floating window ─────────────────────────────────────────────────────── */ /* ── Floating window ─────────────────────────────────────────────────────── */
/* A window that was only dragged keeps its natural height; bound it to the /* A dragged window keeps its natural height, so bound it to the viewport:
viewport so nothing runs off-screen (the rail rule's max-height: 100% does the rail's max-height: 100% does not bind an auto-height parent.
not bind an auto-height parent). Id-qualified to outrank that rule. */ Id-qualified to outrank that rule. */
#right-context-rail > #street-level-panel.panel-floating > .street-level-panel-inner { #right-context-rail > #street-level-panel.panel-floating > .street-level-panel-inner {
max-height: calc(100vh - 12px); max-height: calc(100vh - 12px);
overflow-y: auto; overflow-y: auto;
} }
/* A resized window (PanelPositionControls writes an inline height) hands the /* A resized window (inline height) gives its spare height to the viewer. */
spare height to the viewer, so the image never needs scrolling to. */
.panel-floating[style*='height'] > .street-level-panel-inner { .panel-floating[style*='height'] > .street-level-panel-inner {
box-sizing: border-box; box-sizing: border-box;
height: 100%; height: 100%;
@@ -532,9 +523,8 @@ button.sl-status:focus-visible {
} }
/* ── Expanded viewer ─────────────────────────────────────────────────────── */ /* ── Expanded viewer ─────────────────────────────────────────────────────── */
/* The panel inner's backdrop-filter makes it the containing block for fixed /* Re-parented to <body>: the panel's backdrop-filter would contain a fixed
descendants, so the expanded viewer is re-parented to <body>. z-index sits child. z-index sits above panels (≤139), below the voice pill (150). */
above panels (≤139) and below the voice pill (150) and toasts (200). */
.sl-viewer-wrap-expanded { .sl-viewer-wrap-expanded {
position: fixed; position: fixed;
left: 4vw; left: 4vw;
@@ -604,10 +594,9 @@ button.sl-status:focus-visible {
} }
/* ── Phones ──────────────────────────────────────────────────────────────── */ /* ── Phones ──────────────────────────────────────────────────────────────── */
/* At this width the right rail is the band between mid-screen and the credit /* The phone rail spans 50vh + 8px to 2vh + 7.5rem from the bottom
line (responsive.css: top 50vh + 8px, bottom 2vh + 7.5rem). Size the photo (responsive.css). Size the photo to that band, less ~150px of panel chrome,
from that band, less the panel's header, tool bar and caption (~150px), so so it shows without scrolling. */
the whole image shows without scrolling; EXPAND still gives full screen. */
@media (max-width: 720px) { @media (max-width: 720px) {
.sl-viewer { .sl-viewer {
aspect-ratio: auto; aspect-ratio: auto;
+3 -4
View File
@@ -71,8 +71,7 @@
</div> </div>
</div> </div>
<!-- Street Level (Mapillary) Panel — moved into #right-context-rail at <!-- Street Level Panel (moved into #right-context-rail at runtime) -->
runtime next to CCTV (see PanelLayoutController). -->
<div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel"> <div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel">
<div class="panel-glow"></div> <div class="panel-glow"></div>
<div class="street-level-panel-inner" data-rail-scroller> <div class="street-level-panel-inner" data-rail-scroller>
@@ -116,8 +115,8 @@
<div id="sl-provider-chips" class="sl-chips"></div> <div id="sl-provider-chips" class="sl-chips"></div>
</div> </div>
<div id="sl-error" class="sl-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="sl-error-text"></span></div> <div id="sl-error" class="sl-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="sl-error-text"></span></div>
<!-- KEY REQUIRED gates the filters only: the provider chips above <!-- A missing key gates the filters only: the chips above stay
stay focusable so their tooltip can say how to add the key. --> focusable so their tooltip can say how to add the key. -->
<fieldset id="sl-controls" class="sl-controls"> <fieldset id="sl-controls" class="sl-controls">
<div class="sl-filters" role="group" aria-label="Imagery filters"> <div class="sl-filters" role="group" aria-label="Imagery filters">
<span class="cctv-summary-label">IMAGERY</span> <span class="cctv-summary-label">IMAGERY</span>