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.
#
# 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
# WILL be visible in devtools. Restrict/scope them rather than trying to hide
# MAPILLARY_CLIENT_TOKEN are injected into the browser bundle by design
# (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.
# Optional: direct Google Photorealistic 3D Tiles and GEV place search.
@@ -82,13 +82,9 @@ OPENAI_HUD_SUMMARY_MODEL=gpt-5-nano
# Limits (usage limits).
# GEV_RATELIMIT_OPENAI_PER_MIN=30 # the default; uncomment only to change it
# Optional: Mapillary street-level imagery — coverage lines on the globe and
# the embedded MapillaryJS viewer in the Street Level layer.
# Free client token: https://www.mapillary.com/dashboard/developers (register an
# 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.
# Optional: Mapillary imagery for the Street Level layer. Free client token from
# https://www.mapillary.com/dashboard/developers (register an app, copy its
# Client Token). Client-exposed by design, like GOOGLE_MAPS_API_KEY.
MAPILLARY_CLIENT_TOKEN=
# Optional: OpenSky auth mode (oauth | basic | auto | anon)
+3 -11
View File
@@ -60,22 +60,14 @@ jobs:
run: npm run build
street-level-browser:
# The Street Level gate in its hermetic mode, against a production build
# served by `vite preview`: coverage tiles, the Graph API and the image
# 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.
# Hermetic: every Mapillary request is answered from fixtures, so the
# dummy token below never leaves the page.
name: Street Level browser gate
runs-on: ubuntu-latest
timeout-minutes: 30
env:
GEV_REQUIRE_ALLOCATION_GATE: '0'
# A header press that misses while the panel stood still is a failure
# (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::.
# Fail on a missed header press unless the panel itself moved.
QA_FAIL_ON_RETRY: '1'
steps:
- name: Check out source
+16 -20
View File
@@ -2,26 +2,21 @@
## [Unreleased]
- Rail panels can opt into a portable mode: dragging the CCTV or Street Level
panel by its header lifts it out of the right rail into a floating window
that resizes from any edge or corner, remembers its position and size across
reloads, and snaps back on a header double-click. A panel can also ask to
dock when it is collapsed while floating (Street Level does).
- Street Level: a street-level imagery layer modelled on the iD editor's photo
overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0
imagery). One right-rail panel holds a chip per provider, shared 360°/flat and
captured-since filters, and an embedded viewer with EXPAND, FIT/FILL and
FOLLOW (Google 3D only). Coverage lines and image cones are drawn from orbit
down to a single street; on Google 3D they sit on the bare earth, refined
against the rendered mesh near the camera, so trees and buildings hide them.
Share links carry the provider switches, the filters and the panel's collapsed
state. A server proxy caches Mapillary coverage tiles (24 h, 1 GiB on disk),
refuses cross-site requests, rate-limits per IP and backs off when Mapillary
rejects the token or rate-limits.
- 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.
- 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.
- 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.
- The CCTV and Street Level panels are portable: drag the header to float the
panel, resize it from any edge or corner, and double-click the header to dock
it again. The position persists across reloads.
- 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
@@ -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).
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
wildfire incident perimeters as ground-clamped polygons with a
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
- 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.
@@ -1543,3 +1538,4 @@ represent previously published GitHub Releases.
## [0.1.0] — 2026-02-09
- 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 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" |
| **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 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 |
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on |
| **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors |
| **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 |
+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.)
| 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 |
| ✈️ **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 | 🟢 |
| 🚢 **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 | 🟢 |
| 🌍 **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 |
| 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 |
| ✈️ **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 | 🟢 |
| 🚢 **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 | 🟢 |
| 🌍 **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 |
| 📹 **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) | 🟢 |
| 📷 **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 |
| 📻 **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 | 🟢 |
| 🚲 **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) | 🟢 |
| 🔥 **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) |
| 🎖️ **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 | 🟢 |
| 🌦️ **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 | 🟢 |
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary first; Panoramax, KartaView and Google Street View planned | Mapillary | 🟡 Mapillary client token |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
| 🚌 **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 | 🟢 |
| 🧭 **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 | 🟡 |
| 🚀 **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 | 🟢 |
| 🌬️ **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 | 🟢 |
| 🌀 **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.
@@ -334,19 +334,13 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
## 📷 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.
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.
Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
## 🎖️ Field Missions
@@ -429,7 +423,7 @@ names it.
### 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 |
| --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -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 |
| 🟡 | **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 |
| 🟡 | **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)
+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.
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.
@@ -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.
- **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.
- **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.
## Network exposure — the operator threat model
+1 -2
View File
@@ -81,8 +81,7 @@ export function createBrowserViteConfig({
'@jtarrio/signals/demod/modes.js',
'@jtarrio/webrtlsdr/rtlsdr.js',
'egm96-universal',
// MapillaryJS is loaded on demand the first time a street-level
// image is opened.
// Loaded on demand when the first street-level image opens.
'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
> one. Its `status: 'idle'` is not what saves it — the lifecycle is; the
> module has no `loading` status at all.
> 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
> 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).
> 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
> 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).
> - **A held ground snap is dropped when measured ground contradicts it
> (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
@@ -2928,114 +2928,68 @@ its criteria cannot be silently ignored.
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/localLayers.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | 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) |
| 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.
Street Level lives in the right context rail next to CCTV as an ordinary
collapsible panel (`#street-level-panel`, layer share token `0`, option owner
`street-level`, panel `ui` token `t`) and starts collapsed. It opens itself
only when the user (or voice, or a tool) switches the layer on or a photo
opens, never on a saved-state restore, and never stores those automatic opens. It is modelled on the iD editor's photo
overlay: one panel, a PROVIDERS chip per registered imagery provider, shared
360°/flat and captured-since filters (stored as relative days so a link keeps
its meaning), one viewer host, and one on-globe credit per active provider.
Each source has one colour (`PROVIDER_COLORS` in
`src/layers/streetLevel/policy.js`: Mapillary green `#05cb63`, Panoramax
purple, Google Street View blue), used for its chip, coverage lines, overview
points, image cones and its single legend swatch; 360° cones are rings, and
the selected sequence is GEV cyan. Share options: `m` (Mapillary on/off), `p` (a/p/f panorama mode), `s`
(since, days). Only Mapillary is registered in this build. Without
`MAPILLARY_CLIENT_TOKEN` the header reads KEY REQUIRED, the Mapillary chip
turns amber and the controls stay disabled; with it, coverage draws as
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.
Street Level is a collapsible right-rail panel (`#street-level-panel`, layer
token `0`, option owner `street-level`, panel `ui` token `t`) that starts
collapsed. It opens itself only when the user, voice or a tool switches the
layer on or a photo opens, never on a restore, and those opens are not stored.
Like the iD editor's photo overlay, it has one chip per registered provider,
shared 360°/flat and captured-since filters (relative days, so a link keeps its
meaning), one viewer host and one on-globe credit per active provider. Each
provider draws in one colour (`PROVIDER_COLORS` in
`src/layers/streetLevel/policy.js`); 360° cones are rings and the selected
sequence is GEV cyan. Share options: `m` (Mapillary), `p` (`a`/`p`/`f`
panoramas), `s` (since, days). Only Mapillary is registered. Without
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are
disabled. With it, coverage draws as z0–5 overview points from orbit and z11–14
sequence lines below 60 km; the proxy strips the unused `image` layer from z14
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
requests for a tile.
On Google 3D (`photoreal`) at street zoom the layer switches its surface mode
from `draped` to `terrain` (`getUIState().surface`; in below 1,400 m above the
ground, out above 1,800 m, so the mode does not flap). Draped ground polylines
and clamped billboards land on the top of the photoreal mesh, so on a street
under trees or an overpass the coverage appears to float at roof height. In
terrain mode `src/layers/streetLevel/groundCast.js` places lines, cones and the
position marker on the bare earth from the terrain service
(`/api/terrain/heights`) plus 2 m, and the mesh hides whatever is behind
buildings and trees. Heights are sampled on the ground-floor grid (0.001°,
~111 m), shared with other layers' cache, and interpolated bilinearly; a z14
tile needs at most a few hundred lookups, sent once per tile and aborted when
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.
On Google 3D (`photoreal`) at street zoom (in below 1,400 m above ground, out
above 1,800 m) the surface mode switches from `draped` to `terrain`: draped
lines land on roofs and tree tops, so `groundCast.js` instead places lines,
cones and the marker 2 m above the bare earth from `/api/terrain/heights`, and
the mesh hides what is behind buildings and trees. A tile is drawn draped until
its heights arrive; a geoid fallback leaves it draped. Within 900 m of the
camera, `meshSampler.js` samples the rendered surface (`scene.sampleHeight`,
~11 m cells, nearest first, 6 ms idle budget) so lines also follow trenches,
steep streets and roads under trees. Roads on elevated decks are still drawn at
ground level. Framing a photo ignores mesh samples far below the bare earth
(unloaded tiles).
The panel has no separate ON/OFF or nearest-photo buttons. The header pill is
the layer switch, and a provider chip is lit only while the layer is on: with
one provider it is the layer switch too, and with several, darkening the last
lit chip turns the layer off while leaving that provider switched on. The
viewer sits first, under the header, with its tools (EXPAND, FIT/FILL, FOLLOW,
close) in a bar above the image so the provider's own overlays stay clear;
the settings follow. FOLLOW is offered only while the map stack is Google 3D
(`photoreal`): the layer takes the application map stack controller through
`attachMapStackController`, subscribes to stack switches, disables the button
with a MAP SOURCE hint on other stacks, and stops following when the stack
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.
The header pill is the layer switch. With one provider its chip is a layer
switch too; with several, darkening the last lit chip turns the layer off. The
viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the
image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and
stops when the stack changes. SINCE is a stepped slider whose readout names the
cut-off date. The panel is portable (`street-level-panel` spec, minimum
320 × 280, `dockOnCollapse`): a floating window gives spare height to the
viewer, and collapsing it, double-clicking the header or SHRINK after EXPAND
docks it. At phone width (≤720 px) the viewer height is derived from the rail
band so the whole photo fits.
Street-level providers implement the contract documented in
`src/layers/streetLevel/registry.js`: a definition (`id`, `name`, `label`,
`requiresKeyId`, `pickPrefix`, `colors` (`{coverage}`, the source's one
colour), `credit`, `capabilities`, `externalUrl`, `create`) whose `create(context)` returns an instance with
`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
Providers implement the contract in `src/layers/streetLevel/registry.js`. The
core routes clicks by pick prefix, swaps viewer adapters in the one host,
manages each provider's credit and fans the filter out to every provider. To
add a provider: implement the definition, register it in
`src/app/layers/streetLevel.js`, add its boolean option to the `street-level`
group in `src/data/layerState.js` and its modules to
`scripts/package-boundaries.json`; the chip, credit and legend follow. Once a
keyless provider registers, `requiresKeyId` on the layer becomes null and the
key gate moves to the chips, by design.
`scripts/package-boundaries.json`. Once a keyless provider registers, the
layer's `requiresKeyId` becomes null and the key gate moves to the chips.
Street Level has two browser gates. `npm run qa:street-level -- --url <dev
server>` is the live run: with `MAPILLARY_CLIENT_TOKEN` it covers everything
up to opening, expanding and closing a photo. `npm run
qa:street-level:fixtures -- --url <dev server>` is hermetic: the page's
Mapillary status and coverage-tile requests are answered from generated
fixtures (`providers/mapillary/coverageFixture.mjs`), so it needs no token or
Mapillary network, skips only the photo steps, and asserts that the 360° and
SINCE filters actually remove sequences. CI runs the fixture gate and
`qa:panel-resize` against a key-less dev server.
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
qa:street-level:fixtures -- --url <server>` answers every Mapillary request
from fixtures (`providers/mapillary/coverageFixture.mjs`,
`scripts/fixtures/street-level/`), so it needs no network and covers the whole
photo flow. CI runs the fixture gate and `qa:panel-resize`
against a production build served by `vite preview` with a dummy token.
Directions is a keyless front end to the routing the voice agent already
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
release; late one-shot shared-cell work is permitted during viewshed idle. The
A+B harness intentionally excludes citywide LOD assertions.
- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.<panel-id>`. 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>`.
- 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`.
+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
link ignores the stored value entirely.
- Panel positions: `godsEyeView.v8.panelPos.<panel-id>` — written only for a
portable panel (CCTV, Street Level) lifted out of its rail, as
`{ left, top, width, height, floating: true }`. Deleting it, or double-clicking
the panel header, returns that panel to the rail; docked panels store nothing.
floating portable panel (CCTV, Street Level). Deleting it, or double-clicking
the panel header, docks the panel again.
- CCTV calibration: `godsEyeView.cctv.calibration.v2`
---
@@ -1,32 +1,14 @@
import { readFileSync } from 'node:fs';
/**
* Mapillary Graph API and image CDN, answered from fixtures, so the Street
* Level gate's photo flow runs with no network and no real token (the CI
* mode). It covers every request the app's provider and the embedded
* MapillaryJS 4 viewer make while opening, stepping and closing a photo:
* Mapillary Graph API and image CDN answered from fixtures, covering every
* request the provider and MapillaryJS make while opening, stepping and
* closing a photo, so the gate needs no network and no real token.
*
* provider (src/layers/streetLevel/providers/mapillary/source.js)
* GET graph /images?lat&lng&radius&limit&fields nearest lookup
* GET graph /images?sequence_ids&fields&limit a sequence's cones
* MapillaryJS GraphDataProvider (node_modules/mapillary-js)
* 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.
* No `merge_cc` or `sfm_cluster` means MapillaryJS never asks for a mesh or
* cluster, and empty S2 cells mean no spatial edges, which the gate does not
* use. The photo is generated for this repository, so it has no third-party
* licence.
*/
const DAY_MS = 86_400_000;
@@ -40,12 +22,12 @@ export const PHOTO_LINE = Object.freeze({
});
/** Metres between consecutive photos on the line (≥ the 3 m cone thinning). */
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';
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) {
const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180;
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
* 360° panorama, the rest flat, all captured a month before `now`.
* Photos along PHOTO_LINE, south to north; every third one is a panorama.
* @returns {Array<{id: string, lon: number, lat: number, isPano: boolean, capturedAt: number, compassAngle: number}>}
*/
export function photoImages(now = Date.now()) {
@@ -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) {
if (!fields) return record;
const out = { id: record.id };
@@ -136,16 +117,12 @@ const json = (status, body) => ({
});
let photoBytes = null;
/** The checked-in fixture photo, read once. */
export function photoJpeg() {
photoBytes ??= readFileSync(new URL('./street-640x320.jpg', import.meta.url));
return photoBytes;
}
/**
* 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.
*/
/** Name a call by its parameter names only, so the token is never echoed. */
export function describeCall(method, url) {
const keys = [...url.searchParams.keys()]
.filter((key) => key !== 'access_token')
@@ -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
* @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 }) {
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
* while they run and save when they fail: every page's console (last 200
* messages), failed requests and HTTP errors, Cesium render-loop errors (the
* "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.
* Evidence the browser QA gates collect while they run and save under
* `qa-artifacts/<gate>/` when they fail (CI uploads that directory). Tokens
* and keys are masked before anything is printed or saved.
*/
import fs from 'node:fs/promises';
import path from 'node:path';
/** How many console messages and failed requests each page keeps. */
/** Console messages and failed requests kept per page. */
export const EVIDENCE_LIMIT = 200;
/** 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) {
try {
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.
* `errors` (shared across pages) receives page errors, render-loop console
* errors and the extra `isError(text)` console lines a gate cares about.
* Watch one page's console, errors and failed requests. `errors` is shared
* across pages; `isError(text)` adds console lines a gate treats as errors.
* @returns {{name: string, page: object, console: Array<object>, failed: Array<object>, renderErrors: Array<string>}}
*/
export function watchPage(page, { name, errors, isError = () => false }) {
@@ -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) {
return page.evaluate(() => {
const module =
@@ -125,10 +115,8 @@ function streetLevelState(page) {
}
/**
* Save the evidence of a failed run under `dir`: per page a full-page
* screenshot, the Street Level UI state, the console, failed requests and
* render errors; plus the error itself. Never throws: a page that cannot be
* captured records why instead.
* Save a screenshot and JSON record per page, plus the error. A page that
* cannot be captured records why instead.
* @returns {Promise<string>} the directory written
*/
export async function saveFailureArtifacts({ dir, browser, monitors, error }) {
+12 -33
View File
@@ -1,12 +1,8 @@
#!/usr/bin/env node
/**
* Prove the portable CCTV panel in a real browser: a header drag lifts it out
* of the right rail, every resize handle moves only its own edges, minimum
* sizes hold, the window survives a reload and a header double-click snaps
* 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.
* Browser QA for the floating CCTV panel: lift, resize from every handle,
* minimum size, reload, and dock again. `--fail-on-retry` (or
* QA_FAIL_ON_RETRY=1) fails on any header-press retry.
*/
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
@@ -28,7 +24,6 @@ import {
export const PANEL_ID = 'cctv-panel';
export const STORAGE_KEY = `godsEyeView.v8.panelPos.${PANEL_ID}`;
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';
const VIEWPORT = { width: 1400, height: 900 };
const EDGE_BY_SIDE = { n: 'top', s: 'bottom', e: 'right', w: 'left' };
@@ -67,7 +62,7 @@ export function handlePoint(rect, dir) {
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) {
const edges = (box) => ({
left: box.left,
@@ -116,7 +111,6 @@ async function main() {
timeout: 90_000,
});
await page.waitForSelector(`#${PANEL_ID} .panel-header`);
// The first-run dialog sits over the globe until a choice is made.
await page.evaluate(() => {
document.querySelector('.first-run-explore')?.click();
});
@@ -152,11 +146,9 @@ async function main() {
stored: localStorage.getItem(`godsEyeView.v8.panelPos.${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
* for half a second. The lift is checked against the box at pointerdown,
* so a rail that keeps nudging the panel only costs the wait, not the run.
* Best effort: wait for the box to hold still for half a second. The lift
* is checked against the box at pointerdown, so a timeout only costs time.
*/
const settle = async () => {
try {
@@ -200,7 +192,6 @@ async function main() {
}, STORAGE_KEY);
await boot();
// Expand CCTV so the panel has a body worth resizing.
await page.evaluate((id) => {
const panel = document.getElementById(id);
if (panel.classList.contains('collapsed'))
@@ -220,10 +211,7 @@ async function main() {
assert.equal(docked.floating, false);
assert.equal(docked.inRail, true);
// Lift out: drag the header 200px left and 100px up. The window must lift
// 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.
// Names whatever above CCTV in the rail shifted it, for a retry note.
const railAbove = () =>
page.evaluate((id) => {
const out = [];
@@ -237,11 +225,8 @@ async function main() {
return out.join(' ');
}, PANEL_ID);
const aboveAtDock = await railAbove();
// Press only where the header really is, recording what the press landed
// on. A press the rail shifted out from under the header (seen on the CI
// 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).
// On CI a panel above CCTV grows after load and can shift the header out
// from under the press; the helper retries only such a provable miss.
const attempt = await liftPanelByHeader(page, PANEL_ID, {
dx: -200,
dy: -100,
@@ -289,7 +274,7 @@ async function main() {
assert.equal(afterLayout.allocated, '', 'the rail no longer allocates it');
const railExcludes = await page.evaluate((id) => {
const rail = document.getElementById('right-context-rail');
// Same rule as the rail: hidden panels (a layer that is off) take no room.
// Same rule as the rail: hidden panels take no room.
const counted = [...rail.children].filter((panel) =>
panel.matches(
'[data-panel-id]:not(.panel-floating):not(.collapsed):not([hidden])',
@@ -313,15 +298,13 @@ async function main() {
);
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
// the bottom of the screen and would otherwise cover the south corners
// once the loop below has grown the window.
// Park near the top: the voice dock would cover the south corners once
// the loop below has grown the window.
{
const header = await headerPoint();
await drag(header, 0, 120 - header.y);
}
// Every handle moves only its own edges.
const limits = {
minWidth: MIN_SIZE.width,
minHeight: MIN_SIZE.height,
@@ -332,7 +315,6 @@ async function main() {
const before = await readPanel();
const { dx, dy } = growthDelta(dir, 40);
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 node = document.elementFromPoint(x, y);
if (!node) return 'nothing';
@@ -350,7 +332,6 @@ async function main() {
}
console.log('PASS: eight resize handles keep the opposite edge pinned');
// Minimum size stops the moving edge.
let before = await readPanel();
await drag(handlePoint(before, 'se'), -2000, -2000);
let after = await readPanel();
@@ -365,7 +346,6 @@ async function main() {
assert.deepEqual(driftedEdges(before, after, 'nw', 2), []);
console.log('PASS: minimum size holds from both corners');
// Reload restores the window.
const saved = await readPanel();
const record = JSON.parse(saved.stored);
assert.equal(record.floating, true);
@@ -376,7 +356,6 @@ async function main() {
near(restored[key], record[key], 2, `restored ${key}`);
console.log('PASS: reload restores position and size');
// Double-click the header to snap back.
const dock = await dockPanelByDoubleClick(page, PANEL_ID);
const snapped = await readPanel();
assert.equal(
+20 -61
View File
@@ -1,22 +1,10 @@
/**
* Header press-and-verify for the browser QA gates that lift a rail panel
* into a floating window (header drag) and dock it again (header
* double-click). The rail animates panel heights, so a point read once can
* be under another panel by the time the pointer goes down. Each attempt
* re-resolves the header, presses only where the header really is, records
* what the press landed on, and waits for the panel's actual state. Only a
* 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.
* Header press-and-verify for the QA gates that lift a rail panel into a
* floating window and dock it again. The rail animates panel heights, so a
* point read once can be under another panel by the time the pointer goes
* down. Only a provable miss (a recorded press outside the header) is
* retried, with a GitHub `::warning::`. With QA_FAIL_ON_RETRY=1 a miss the
* panel's own movement cannot explain fails the run instead.
*/
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -34,18 +22,15 @@ export function failOnRetryDefault(
}
/**
* Whether an attempt proves nothing about the panel: it did not reach the
* wanted state and the recorded press demonstrably landed outside the
* 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.
* A failed attempt whose press landed outside the header. No recorded press
* is a failure, not a miss: a retry would only hide it.
* @param {{done: boolean, pressed: {inHeader: boolean}|null}} attempt
*/
export function pressMissed(attempt) {
return !attempt.done && attempt.pressed?.inHeader === false;
}
/** 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) {
if (!before || !after) return false;
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) {
if (!box) return 'unknown';
const r = (value) => Math.round(value ?? NaN);
@@ -66,7 +52,6 @@ function panelBox(page, panelId) {
}, panelId);
}
/** The centre of the panel's header title, read now. */
export function headerPoint(page, panelId) {
return page.evaluate((id) => {
const title = document.querySelector(`#${id} .panel-header .panel-title`);
@@ -75,7 +60,7 @@ export function headerPoint(page, 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) {
return page.evaluate(
({ 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
* bounded wait runs out; the press then records the miss). `timedOut` says
* the hit check never passed; `box` is the panel's box at the last check.
* Re-resolve the header point until it hits the header or the wait runs out
* (the press then records the miss).
*/
export async function headerTarget(
page,
@@ -112,10 +96,8 @@ export async function headerTarget(
}
/**
* Best effort: wait until the panel's box (to the whole pixel) has held still
* for `stillMs`, so a rail still animating heights does not move the header
* between the hit check and the press. Never fails the run; resolves to
* whether the box held still in time.
* Best effort: wait until the panel's box has held still for `stillMs`, so
* the header does not move between the hit check and the press.
*/
export async function waitForStill(
page,
@@ -145,10 +127,7 @@ export async function waitForStill(
}
}
/**
* Record what the next pointerdown lands on, with the panel's box at that
* moment, in `window.__qaPressBox` (null until a press arrives).
*/
/** Record the next pointerdown's target and the panel's box at that moment. */
export function recordNextPress(page, panelId) {
return page.evaluate((id) => {
window.__qaPressBox = null;
@@ -162,7 +141,6 @@ export function recordNextPress(page, panelId) {
top: r.top,
width: r.width,
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('.')}`,
inHeader: Boolean(target.closest?.(`#${id} .panel-header`)),
};
@@ -172,12 +150,10 @@ export function recordNextPress(page, panelId) {
}, panelId);
}
/** The press recorded by `recordNextPress`, or null. */
function readPress(page) {
return page.evaluate(() => window.__qaPressBox ?? null);
}
/** Wait (bounded) until the panel is, or is not, a floating window. */
export async function waitForFloating(
page,
panelId,
@@ -200,10 +176,7 @@ export async function waitForFloating(
}
}
/**
* Wait for the page to paint twice: pointer handlers apply synchronously, so
* by then every layout change an input caused is on screen.
*/
/** Two paints: by then every layout change an input caused is on screen. */
export function nextFrames(page) {
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 } = {}) {
await page.mouse.move(from.x, from.y);
await page.mouse.down();
@@ -223,10 +195,6 @@ export async function dragPointer(page, from, dx, dy, { steps = 6 } = {}) {
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(
panelId,
attempt,
@@ -243,10 +211,7 @@ export function describeRetry(
.join('; ');
}
/**
* 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.
*/
/** Retry `press` only while it provably misses the header. */
async function pressUntilDecided(
page,
panelId,
@@ -293,8 +258,6 @@ async function pressUntilDecided(
/**
* 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}>}
* `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(
page,
@@ -320,11 +283,7 @@ export function liftPanelByHeader(
);
}
/**
* 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.
*/
/** Dock a floating panel with a header double-click; same result as lifting. */
export function dockPanelByDoubleClick(
page,
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) {
const { point, hit, pressed } = attempt;
const where = `pressed ${Math.round(point.x)},${Math.round(point.y)}`;
+37 -99
View File
@@ -1,30 +1,16 @@
#!/usr/bin/env node
/**
* Browser QA for the Street Level layer against a running server: the
* 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
* Browser QA for the Street Level layer against a running server. Run with
* `npm run qa:street-level -- --url http://localhost:4173`.
*
* `--fixtures` makes the run hermetic for Mapillary (the CI mode): coverage
* tiles, the Graph API, the image CDN and every other *.mapillary.com
* request the page makes are answered from fixtures (fixtureTile and
* scripts/fixtures/street-level/mapillaryGraph.mjs), and nothing reaches
* 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.
* `--fixtures` (the CI mode) answers every *.mapillary.com request from
* fixtures, so nothing reaches Mapillary. The server still needs a token
* because the bundle carries it; CI uses a dummy one. Fixture runs also stage
* a rejected key and a keyless second server.
*
* Steps print `ok N …`, or `skip (reason) N …` when they cannot run here or
* only part of them could. `--strict` fails on any skip whose reason is not
* in STRICT_ALLOWED_SKIPS. A failed run saves screenshots, the 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.
* `--strict` fails on any skip not in STRICT_ALLOWED_SKIPS. `--fail-on-retry`
* (or QA_FAIL_ON_RETRY=1) fails on any header-press retry. A failed run saves
* evidence under qa-artifacts/street-level/.
*/
import assert from 'node:assert/strict';
import { pathToFileURL } from 'node:url';
@@ -56,37 +42,28 @@ const PANEL_ID = 'street-level-panel';
/** Where a failed run leaves its screenshots and logs. */
export const ARTIFACT_DIR = 'qa-artifacts/street-level';
/** Viewports every layout assertion runs at. */
export const VIEWPORTS = Object.freeze([
{ width: 1440, height: 900 },
{ 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']);
/**
* Skip reasons `--strict` accepts, because no setup of this gate can avoid
* them:
* - `no Google 3D`: the map stack needs a Google key (CI forks have none);
* 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.
* Skips no setup can avoid: CI forks have no Google key, and live runs cannot
* stage fixture-only cases. `no Mapillary key` is not accepted: CI's dummy
* token runs the keyed flow, so a keyless strict run means that wiring broke.
*/
export const STRICT_ALLOWED_SKIPS = Object.freeze([
'no Google 3D',
'fixtures only',
]);
/** Skips `--strict` rejects. */
export function strictViolations(skips) {
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(
(entry) => entry.id === 'street-level',
).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. */
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([
'pp-toggles',
'cctv-panel',
@@ -104,7 +81,7 @@ export const RAIL_ORDER = Object.freeze([
'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({
lon: -121.4944,
lat: 38.5816,
@@ -121,11 +98,8 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const DAY_MS = 86_400_000;
/**
* Synthetic coverage for any tile, so fixture runs are deterministic: at
* street zooms a grid of four east-west and four north-south sequences (half
* 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.
* Deterministic coverage for any tile: a grid of mixed 360°/flat, new/old
* sequences plus the photo sequence (PHOTO_LINE), or a few overview points.
* @returns {Uint8Array} empty for zooms that carry nothing
*/
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
* interception, and require its JSON shape. Fixture runs answer the route in
* the page, so without this a server that never registered it would pass.
* 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]
* Ask the real status route from Node, outside the page's interception, so a
* server that never registered it fails even in fixture runs. Node sends no
* Origin, so the same-site gate admits it.
* @returns {Promise<{configured: boolean}>}
*/
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
* Mapillary URLs); nothing reaches Mapillary. `fixture` is live:
* - `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
* Answer the page's Mapillary requests from `fixture`, which steps may change
* mid-run. `configured: undefined` lets the server's real status route answer.
* @param {{configured?: boolean, tiles: 'ok'|'rejected', tileRequests: number, images: Array<object>, calls: Set<string>, unknown: Array<string>}} fixture
*/
export async function serveFixtures(page, fixture) {
// Only these URLs pause. Puppeteer's page-wide interception would pause
// Cesium's worker scripts too, and under it they stall: no terrain, no
// ground polylines, nothing to click.
// CDP Fetch on just these URLs: Puppeteer's page-wide interception stalls
// Cesium's worker scripts, leaving nothing to click.
const cdp = await page.createCDPSession();
await cdp.send('Fetch.enable', {
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) {
await page.waitForFunction(() => Boolean(window.__godsEyeView?.dataManager), {
timeout: 150_000,
@@ -382,7 +344,6 @@ export async function boot(page, url) {
await clearFirstRun(page);
}
/** Panel geometry and the bits of state the steps assert on. */
function readPanel(page) {
return page.evaluate(() => {
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) {
await page.evaluate(() =>
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:
* `prefix` + `id` (or any id with the prefix when `id` is null), searched
* around projected `points` ([lon, lat] at the ground) or, without points,
* 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.
* Runs in the page. Find a point where a real click picks `prefix` + `id` (any
* id when null), searched around `points` ([lon, lat]), the aimed billboards,
* or else a grid mid-screen. Only points where the canvas is topmost count.
* @returns {{x: number, y: number, id: string}|null} client coordinates
*/
export function findPickPoint({
@@ -464,7 +420,6 @@ export function findPickPoint({
const canvas = scene.canvas;
const rect = canvas.getBoundingClientRect();
const Cartographic = viewer.camera.positionCartographic.constructor;
// scene.pick reads x and y; a Cartesian2 when one is at hand.
let Cartesian2 = null;
const windowPoint = (x, y) => (Cartesian2 ? new Cartesian2(x, y) : { x, y });
const wanted = (picked) => {
@@ -553,10 +508,7 @@ async function main() {
});
let count = 0;
const skips = [];
/**
* Run one step. `fn` gets `skip(reason)`: the step still runs whatever it
* can, but prints (and records) as a skip instead of `ok`.
*/
/** `fn` gets `skip(reason)`: the step runs what it can but reports a skip. */
const step = async (label, fn) => {
let skipped = null;
const result = await fn({
@@ -575,7 +527,7 @@ async function main() {
const skipStep = (reason, label) => step(label, ({ skip }) => skip(reason));
const errors = [];
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) => {
monitors.push(
watchPage(page, {
@@ -588,7 +540,6 @@ async function main() {
};
const renderErrorsSeen = [];
try {
// Before any interception: fixture runs answer Mapillary in the page.
await step(
'the server registers the real Mapillary status route',
async () => {
@@ -712,9 +663,8 @@ async function main() {
);
async function keyedSteps() {
// The startup flight can still be running: cancel it, park the camera
// over downtown Sacramento and confirm it stays put. A stability window,
// not a state: a late startup flight can only show itself by moving.
// A late startup flight only shows itself by moving the camera, so park
// and check it stays put rather than reading a state.
const park = async () => {
for (let attempt = 0; attempt < 6; attempt++) {
await page.evaluate((at) => {
@@ -765,10 +715,7 @@ async function main() {
.get('street-level')
.module.getUIState(),
);
/**
* Wait until the UI state satisfies `test(u, arg)`, a self-contained
* function run in the page; false when it never does.
*/
/** `test(u, arg)` runs in the page, so it must be self-contained. */
const uiUntil = (test, arg = null, options) =>
settle(
page,
@@ -954,8 +901,7 @@ async function main() {
await step(
'the MapillaryJS viewer loads ahead of the first photo',
async () => {
// Prewarm stands the lazily imported viewer up in the panel's host; a
// broken dynamic import would otherwise only show at the first click.
// Catches a broken dynamic import before the first click would.
await page.waitForSelector(
'#sl-viewer.mapillary-viewer .mapillary-dom',
{ timeout: 60_000 },
@@ -969,7 +915,6 @@ async function main() {
{ timeout: 30_000 },
text,
);
/** Point the camera somewhere new and refresh coverage there now. */
const panTo = (lon, lat) =>
page.evaluate(
(at) => {
@@ -987,10 +932,7 @@ async function main() {
},
{ lon, lat },
);
/**
* 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).
*/
/** Wait until no tile request has arrived for `quietMs`. */
const tileRequestsQuiet = async (quietMs, timeout = 30_000) => {
const start = Date.now();
let last = fixture.tileRequests;
@@ -1547,10 +1489,8 @@ async function main() {
const r = node.getBoundingClientRect();
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
// provably missed it: right after a dock the rail is still animating, and
// a title point read once can be under another panel (scripts/
// qa-panelDrag.mjs, shared with the panel-resize gate).
// Right after a dock the rail is still animating, so the shared helpers
// re-resolve the header and retry only a press that provably missed it.
const floatPanel = async () => {
const attempt = await liftPanelByHeader(page, PANEL_ID, {
dx: -400,
@@ -1649,7 +1589,6 @@ async function main() {
);
await step('a header double-click docks a floating window', async () => {
await floatPanel();
// Waits on the docked state itself, not a fixed sleep.
const attempt = await dockPanelByDoubleClick(page, PANEL_ID);
assert.equal(
attempt.done,
@@ -1751,9 +1690,8 @@ async function main() {
.get('street-level')
.module.closeViewer(),
);
// The button is what the user presses; a toast over it must not make
// this step about pointer aim, so press it through the DOM and wait
// for the panel to say it is collapsed.
// Press through the DOM: a toast over the button must not make this
// step about pointer aim.
await page.$eval(
'.panel-collapse-btn[data-collapse-target="street-level-panel"]',
(button) => {
+2 -5
View File
@@ -1,11 +1,8 @@
import { installMapillaryRoutes } from './mapillary/routes.js';
/**
* Vite plugin: Mapillary street-level coverage.
*
* 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.
* Vite plugin: cached Mapillary coverage tiles (token added server-side) and a
* status route that says whether a token is configured.
*/
function mapillaryProxy() {
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. */
export const TILE_LAYERS = Object.freeze({
// Overview points (z0–5), sequences (z6–14) and image points (z14 only).
// The app asks for overview z0–5 and sequences z11–14 only (see
// src/layers/streetLevel/tileMath.js); region-sized z6–10 tiles are refused.
// 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.
// Region-sized z6–10 tiles are refused; the app never asks for them. The
// z14 `image` layer is ~98% of a tile and unused (positions come from the
// graph API), so it is dropped in transit.
coverage: Object.freeze({
upstream: 'mly1_public',
zoomRanges: Object.freeze([Object.freeze([0, 5]), Object.freeze([11, 14])]),
@@ -26,57 +24,41 @@ export const MAPILLARY_CACHE_DIR = path.join(
export const TILE_DISK_DIR = path.join(MAPILLARY_CACHE_DIR, 'tiles');
/**
* Tiles change when new imagery is processed, which is far slower than the
* 15-minute upstream Cache-Control. A day keeps a session over one city from
* re-downloading its tiles on every camera move.
* Longer than the 15-minute upstream Cache-Control: tiles change only when new
* imagery is processed, and a day avoids re-downloading a city per camera move.
*/
export const TILE_DISK_TTL_MS = 24 * 60 * 60 * 1000;
/**
* Disk cache bound. A tile write starts a background sweep at most this often:
* it deletes files past TILE_DISK_TTL_MS, then the oldest files until the
* cache fits in TILE_DISK_MAX_BYTES (thousands of trimmed street-level tiles).
* A tile write starts a background sweep at most this often: expired files go
* first, then the oldest until the cache fits in TILE_DISK_MAX_BYTES.
*/
export const TILE_DISK_SWEEP_INTERVAL_MS = 15 * 60 * 1000;
export const TILE_DISK_MAX_BYTES = 1024 * 1024 * 1024;
/**
* 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.
*/
/** Per-file cost on top of its size, so empty tiles still count. */
export const TILE_DISK_FILE_OVERHEAD_BYTES = 4096;
/** A z14 image tile over a dense city is ~11 MB; anything past this is wrong. */
export const TILE_MAX_BYTES = 48 * 1024 * 1024;
/** In-memory tile cache budget (bytes) and upstream fetch timeout. */
export const TILE_MEMORY_BUDGET_BYTES = 96 * 1024 * 1024;
/**
* 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.
*/
/** Per-entry cost on top of its bytes, so empty tiles still count. */
export const TILE_MEMORY_ENTRY_OVERHEAD_BYTES = 1024;
export const TILE_FETCH_TIMEOUT_MS = 60_000;
/**
* Upstream tile fetches running at once; further misses queue for a slot. A
* view asks for at most 25 tiles, so a few slots keep it quick while a burst
* of distinct misses cannot open thousands of buffered requests at once.
* Upstream fetches at once; further misses queue. A view needs at most 25
* tiles, and the cap stops a burst of misses opening thousands of requests.
*/
export const TILE_UPSTREAM_CONCURRENCY = 6;
/**
* Tile requests per client IP per minute (cache hits included). The app asks
* only for tiles it has not loaded yet, at most 9 street + 16 overview tiles
* per view after a 320 ms camera debounce (src/layers/streetLevel/providers/
* 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.
* Tile requests per client IP per minute, cache hits included. 600 is 24 new
* 25-tile views a minute, more than a person flying the camera reaches, but it
* stops one page draining the shared token into a Mapillary rate limit.
*/
export const TILE_ROUTE_MAX_PER_MIN = 600;
/**
* After Mapillary rejects the token (401/403), tile misses are answered from
* that verdict for this long instead of asking again on every camera move.
* A changed token is asked at once.
* How long a 401/403 answers tile misses before asking Mapillary again. A
* changed token is tried at once.
*/
export const TILE_KEY_REJECTED_HOLD_MS = 5 * 60 * 1000;
/** After a 429 without a usable Retry-After, hold tile misses this long. */
+3 -7
View File
@@ -70,8 +70,7 @@ async function handleTile(req, res, allow) {
retryAfter,
});
}
// Any other refusal is Mapillary's fault, not the client's: an
// upstream 400 must not read as a malformed request to this proxy.
// An upstream 400 must not read as a malformed request to this proxy.
return sendJson(res, 502, { error: error.message });
}
sendJson(res, 502, { error: error?.message || 'Tile fetch failed' });
@@ -79,11 +78,8 @@ async function handleTile(req, res, allow) {
}
/**
* Attach every Mapillary route to a connect-style middleware stack. Both
* refuse cross-site browser requests (server/providers/common/same-site.js):
* 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).
* Attach the Mapillary routes. Both refuse cross-site requests so another page
* cannot spend the token or provoke a 429 that holds every tile miss.
*/
export function installMapillaryRoutes(middlewares) {
const allow = makeRateLimiter({
+30 -79
View File
@@ -20,7 +20,7 @@ import {
mapillaryToken,
} 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) {
console.warn(`[Mapillary Proxy] ${what}:`, error?.message || error);
}
@@ -34,10 +34,7 @@ export class TileRequestError extends Error {
}
}
/**
* Thrown when Mapillary answered with an error status. A 401/403 means the
* token was rejected (`keyRejected`); a 429 carries how long to wait.
*/
/** Mapillary answered with an error status; 401/403 set `keyRejected`. */
export class TileUpstreamError extends Error {
constructor(status, message, { retryAfterSec = null } = {}) {
super(message || `Mapillary tiles HTTP ${status}`);
@@ -49,9 +46,8 @@ export class TileUpstreamError extends Error {
}
/**
* The last refusal from Mapillary that every tile miss shares: a rejected
* token (until the token changes or the hold ends) or a rate limit (until its
* Retry-After). Cached tiles are still served meanwhile.
* A rejected token or rate limit that answers every tile miss until it expires
* (or the token changes). Cached tiles are still served meanwhile.
* @type {{status: number, until: number, token: string}|null}
*/
let _upstreamHold = null;
@@ -66,7 +62,6 @@ function retryAfterMs(header, now = Date.now()) {
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) {
const hold = _upstreamHold;
if (!hold) return null;
@@ -81,8 +76,7 @@ function heldRefusal(token) {
}
/**
* Validate and normalize a tile address. Layer names are the proxy's public
* ids (coverage only); zoom must be inside one of the layer's zoom ranges.
* Validate a tile address against the layer's zoom ranges.
* @returns {{layer:string, upstream:string, z:number, x:number, y:number, key:string}}
*/
export function normalizeTileAddress({ layer, z, x, y }) {
@@ -116,13 +110,11 @@ export function normalizeTileAddress({ layer, z, x, y }) {
/** @type {Map<string, {bytes: Buffer, at: number}>} insertion-ordered LRU */
const _memory = new Map();
/** Charged bytes: every entry's size plus a fixed overhead (empty tiles too). */
let _memoryBytes = 0;
const memoryCost = (bytes) => bytes.length + TILE_MEMORY_ENTRY_OVERHEAD_BYTES;
/**
* Upstream fetches in progress, shared by every request for the same tile.
* A flight owns its controller; callers only count as waiters, and the last
* waiter to leave cancels it (see `joinFlight`).
* Upstream fetches shared by every request for the same tile; the last waiter
* to leave cancels it (see `joinFlight`).
* @type {Map<string, {controller: AbortController, promise: Promise<Buffer>, waiters: number}>}
*/
const _inFlight = new Map();
@@ -130,14 +122,13 @@ const _inFlight = new Map();
function memoryGet(key) {
const hit = _memory.get(key);
if (!hit) return null;
// Same 24 h life as the disk copy: a long-running server must not keep
// serving stale (or empty) coverage until eviction.
// Same life as the disk copy, so a long-running server never serves stale
// coverage.
if (Date.now() - hit.at > TILE_DISK_TTL_MS) {
_memory.delete(key);
_memoryBytes -= memoryCost(hit.bytes);
return null;
}
// Re-insert to mark as most recently used.
_memory.delete(key);
_memory.set(key, hit);
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`);
}
/** Disk-cache read; the tile's age comes from the file's mtime. */
async function readDisk(address) {
const file = diskPath(address);
try {
@@ -176,14 +166,9 @@ async function readDisk(address) {
}
}
/**
* 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>>}
*/
/** Unsettled background writes and sweeps; requests never wait on them. */
const _background = new Set();
/** Track a background task until it settles; it never rejects. */
function track(task) {
const settled = task
.catch(() => {})
@@ -192,26 +177,20 @@ function track(task) {
return settled;
}
/**
* Disk writes in progress, by cache file: the sequence number of the newest.
* @type {Map<string, number>}
*/
/** @type {Map<string, number>} cache file to its newest write's sequence */
const _diskWrites = new Map();
let _diskWriteSeq = 0;
/** Whether a write of this tile's cache file is still in progress. */
function diskWritePending(address) {
return _diskWrites.has(diskPath(address));
}
/**
* Write a tile in the background. `at` dates a rewrite of an existing tile
* with its original fetch time, so the rewrite does not extend its life.
* 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.
* Write a tile in the background. `at` keeps a rewrite's original fetch time
* so it does not extend the tile's life. An overtaken write steps aside.
*/
function writeDisk(address, bytes, at = null) {
// 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 file = diskPath(address, root);
const seq = ++_diskWriteSeq;
@@ -241,7 +220,6 @@ let _lastSweepAt = 0;
let _sweeping = null;
let _sweepWarned = false;
/** Start a background sweep of `root` unless one ran within the interval. */
function scheduleSweep(root) {
if (_sweeping || Date.now() - _lastSweepAt < TILE_DISK_SWEEP_INTERVAL_MS)
return;
@@ -259,7 +237,7 @@ function scheduleSweep(root) {
_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) {
let entries;
try {
@@ -273,7 +251,6 @@ async function listCacheFiles(dir) {
const file = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...(await listCacheFiles(file)));
else if (entry.isFile()) {
// A file renamed or swept away meanwhile is simply skipped.
const stat = await fsp.stat(file).catch(() => null);
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
* 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.
* Delete expired tiles, then the oldest until the rest fit in `maxBytes`.
* @returns {Promise<{removed: number, bytes: number}>} files deleted, charged bytes kept
*/
export async function sweepTileDisk({
@@ -308,15 +282,12 @@ export async function sweepTileDisk({
/** The one origin the tile token may be sent to, at every redirect hop. */
const TILE_ORIGIN = new URL(MAPILLARY_TILE_HOST).origin;
/** Redirect hops followed before the upstream counts as broken. */
export const TILE_MAX_REDIRECTS = 3;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
/**
* Fetch a tile URL, following redirects by hand: each hop must stay HTTPS on
* the Mapillary tile origin (no credentials in the URL), so the access token
* in the query is never sent to another host. A refused or endless redirect
* is an upstream fault (502).
* Follow redirects by hand so every hop stays HTTPS on the tile origin and the
* token in the query never reaches another host. A bad redirect is a 502.
*/
async function fetchPinned(url, init) {
let next = url;
@@ -354,7 +325,6 @@ async function fetchUpstream(address, signal) {
if (held) throw held;
const url = `${MAPILLARY_TILE_HOST}/${address.upstream}/2/${address.z}/${address.x}/${address.y}?access_token=${encodeURIComponent(token)}`;
const timeout = AbortSignal.timeout(TILE_FETCH_TIMEOUT_MS);
// Aborts the request itself once its body runs past the size cap.
const oversize = new AbortController();
const response = await fetchPinned(url, {
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
* has no Content-Length to check first, so the request is aborted as soon as
* the running total passes TILE_MAX_BYTES rather than after buffering it all.
* Read a body, aborting once it passes TILE_MAX_BYTES: chunked or compressed
* responses have no Content-Length to check first.
*/
async function readCapped(response, abortRequest) {
if (!response.body) return Buffer.alloc(0);
@@ -412,17 +381,12 @@ async function readCapped(response, abortRequest) {
return Buffer.concat(chunks, total);
}
/**
* 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>}}
*/
/** @type {{active: number, queue: Array<() => void>}} upstream slots */
let _slots = { active: 0, queue: [] };
/**
* Wait for an upstream fetch slot. Resolves with its release function. A
* flight aborted while it waits leaves the queue and never fetches.
* @param {AbortSignal} signal
* Resolve with a release function once a slot is free. An abort while
* queued leaves the queue.
* @returns {Promise<() => void>}
*/
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) {
if (!address.dropLayers.length || !bytes.length) return bytes;
try {
@@ -463,8 +426,8 @@ function trim(address, bytes) {
}
/**
* Fetch one tile through memory, disk and in-flight coalescing, then
* Mapillary. Returns the raw protobuf bytes (empty for a tile with no data).
* Fetch one tile from memory, disk, a shared flight, then Mapillary. Empty
* bytes mean no coverage.
* @param {{layer:string,z:number|string,x:number|string,y:number|string}} request
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<{bytes: Buffer, source: 'memory'|'disk'|'inflight'|'upstream', address: object}>}
@@ -475,9 +438,7 @@ export async function fetchTile(request, { signal } = {}) {
if (memory) return { bytes: memory, source: 'memory', address };
const disk = await readDisk(address);
if (disk) {
// Older cache files may still hold the untrimmed tile: trim and replace,
// keeping the original fetch time so the tile still expires on schedule.
// Concurrent hits on one such tile rewrite it once.
// Older cache files may be untrimmed: rewrite once, keeping the fetch time.
const bytes = trim(address, disk.bytes);
if (bytes !== disk.bytes && !diskWritePending(address))
writeDisk(address, bytes, disk.at);
@@ -493,10 +454,7 @@ export async function fetchTile(request, { signal } = {}) {
return { bytes, source: joined ? 'inflight' : 'upstream', address };
}
/**
* 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.
*/
/** Start the shared upstream fetch; it holds a slot until the body is read. */
function startFlight(address) {
const controller = new AbortController();
const flight = { controller, waiters: 0, promise: null };
@@ -520,12 +478,8 @@ function startFlight(address) {
}
/**
* Wait on a shared flight as one caller. The caller's own abort rejects it at
* once without disturbing the others; the upstream fetch is cancelled only
* when the last waiter leaves.
* @param {{controller: AbortController, promise: Promise<Buffer>, waiters: number}} flight
* @param {AbortSignal} [signal]
* @returns {Promise<Buffer>}
* Wait on a shared flight. A caller's abort rejects only that caller; the
* fetch is cancelled when the last waiter leaves.
*/
function joinFlight(flight, signal) {
signal?.throwIfAborted();
@@ -566,10 +520,7 @@ export function _tileMemoryForTest() {
return { entries: _memory.size, bytes: _memoryBytes };
}
/**
* 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.
*/
/** Test seam: wait for every background write and the sweeps they start. */
export async function _settleTileWritesForTest() {
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
* a kept layer is decoded or re-encoded, so a 12 MB tile whose bulk is an
* unused layer trims in a few milliseconds. Returns the input when nothing
* was dropped.
* Drop whole layers from a vector tile without decoding kept layers, so a
* 12 MB tile trims in milliseconds. Returns the input when nothing was dropped.
* @param {Buffer|Uint8Array} bytes
* @param {Iterable<string>} dropNames
*/
@@ -31,7 +29,7 @@ export function stripTileLayers(bytes, dropNames) {
const writer = new PbfWriter();
let dropped = false;
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 layerBytes = bytes.subarray(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 render from '../../renderGovernor.js';
/**
* 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.
*/
/** A new provider registers here; its chip, credit and share bit follow. */
export function createApplicationStreetLevel({ surface, sources }) {
return createStreetLevelLayer({
providers: [createMapillaryProvider({ source: sources.mapillary })],
+2 -3
View File
@@ -418,9 +418,8 @@ const OPTION_GROUPS = Object.freeze({
pano: 'p',
flat: 'f',
}),
// "Captured since" as relative days, so a link means the same next year.
// The filter's MAX_SINCE_DAYS (streetLevel/policy.js); layerState.test
// pins the two together.
// Relative days, so a link means the same next year. The max matches
// MAX_SINCE_DAYS in streetLevel/policy.js (pinned by layerState.test).
boundedIntegerOption('sinceDays', 's', 0, { min: 0, max: 36500 }),
]),
radio: Object.freeze([
+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', () => {
// The codec's ceiling is the filter's MAX_SINCE_DAYS: a longer window is
// rejected (read as "any date"), the longest one is kept whole.
// A window past MAX_SINCE_DAYS reads as "any date".
assert.equal(
streetLevelOptions(`0.s.${MAX_SINCE_DAYS}`).sinceDays,
MAX_SINCE_DAYS,
+7 -20
View File
@@ -1,17 +1,14 @@
import * as Cesium from 'cesium';
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_MAX_M = 9000;
/** Rendered mesh window around the bare earth: roofs and bridges, not whole towers. */
const MESH_BELOW_DEM_M = 15;
const MESH_ABOVE_DEM_M = 80;
/**
* Drive the globe camera from the street-level pose: either continuously
* ("camera follows view") or once, framing the image from behind.
*/
/** Drive the globe camera from the street-level pose: continuously (follow) or once (framing). */
export function createCameraFollow({ state, parts }) {
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
* sampled first, but `sampleHeight` can return nonsense before the tiles
* under the point have loaded (kilometres below the ellipsoid), so a sample
* counts only when it is a plausible surface height and, once the bare-earth
* height is known, lies within the mesh window around it.
* Ground under a photo. `sampleHeight` can return kilometres below the
* ellipsoid before tiles load, so a sample counts only when plausible and
* within the mesh window around bare earth (once that is known).
*/
function groundHeightAt(lon, lat, fallback) {
const scene = state.viewer?.scene;
@@ -70,12 +65,7 @@ export function createCameraFollow({ state, parts }) {
requestRender();
}
/**
* 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}
*/
/** Our framing flight while it is current; Cesium calls `cancel` when another flight starts. */
let framing = null;
/** Frame the current image from a short distance behind it. */
@@ -108,10 +98,7 @@ export function createCameraFollow({ state, parts }) {
);
}
/**
* 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.
*/
/** Stop our framing flight if it is still in the air; another feature's flight is left alone. */
function cancelFraming() {
if (!framing) return;
// Give up ownership first: cancelFlight delivers `cancel` synchronously.
+6 -8
View File
@@ -9,10 +9,8 @@ const heightOf = (cartesian) =>
Cesium.Cartographic.fromCartesian(cartesian).height;
/**
* A camera with Cesium's flight bookkeeping: starting a flight cancels the
* current one (calling its `cancel`), `cancelFlight` cancels whichever flight
* is current, whoever started it, and `land()` completes it. `cancelled`
* counts the flights `cancelFlight` stopped.
* A camera with Cesium's flight bookkeeping: a new flight or `cancelFlight`
* cancels the current one, `land()` completes it, `cancelled` counts stops.
*/
function flightCamera(flights, views) {
let current = null;
@@ -43,8 +41,8 @@ function flightCamera(flights, views) {
}
/**
* A viewer whose scene sample is `sampled` (the Austin case: −14,886 m before
* the tiles under the photo had loaded) and whose bare earth is `dem`.
* A viewer whose scene sample is `sampled` (e.g. −14,886 m before the tiles
* under the photo load) and whose bare earth is `dem`.
*/
function setup({ sampled, dem = null, globe = null, altitude = 149 }) {
const flights = [];
@@ -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 { camera } = state.viewer;
follow.lookAtPosition();
@@ -134,7 +132,7 @@ test('cancelFraming stops the framing flight while it is still ours (review IC8
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 { camera } = state.viewer;
follow.lookAtPosition();
+2 -3
View File
@@ -1,9 +1,8 @@
import * as Cesium from 'cesium';
/**
* One on-screen Cesium credit per active provider. Street-level imagery is
* typically CC BY-SA, which needs visible attribution that goes away with
* the imagery, so these are static credits added and removed per provider.
* One static Cesium credit per active provider: CC BY-SA imagery needs
* visible attribution that goes away with the imagery.
*/
export function createCredits() {
/** @type {Map<string, object>} provider id → Cesium.Credit */
+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;
/**
* Merge a partial filter change into the current one. Unknown panorama modes
* and negative or non-integer day counts leave the current value alone.
* Merge a partial filter change; unknown pano modes and invalid day counts
* keep the current value.
* @param {{pano?: string, sinceDays?: number}|null} next
* @param {{pano: string, sinceDays: number}} [current]
* @returns {{pano: string, sinceDays: number}}
@@ -26,8 +26,8 @@ export function sameFilter(a, b) {
}
/**
* Turn the stored filter (relative days, stable in share links) into the
* absolute form providers compare capture times against.
* Stored filter (relative days, stable in share links) to the absolute form
* providers compare capture times against.
* @returns {{pano: string, sinceMs: number|null}}
*/
export function resolveFilter(filter, now = Date.now()) {
@@ -39,8 +39,8 @@ export function resolveFilter(filter, now = Date.now()) {
}
/**
* Whether one capture passes the resolved imagery filter: panorama mode
* ('all' | 'pano' | 'flat') and an optional earliest capture time.
* Whether a capture passes the resolved filter: pano mode and an optional
* earliest capture time.
* @param {{isPano?: boolean, capturedAt?: number}} record
* @param {{pano: string, sinceMs: number|null}|null} filter
*/
+2 -8
View File
@@ -1,8 +1,4 @@
/**
* 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.
*/
/** Canvas billboard glyphs (image cone, pano ring, position marker); no Cesium needed. */
const _cache = new Map();
@@ -29,7 +25,6 @@ function wedge(ctx, c, radius, halfAngle, color, alpha) {
ctx.globalAlpha = 1;
}
/** A stroked circle around the centre. */
function ring(ctx, c, radius, color, width, alpha) {
ctx.beginPath();
ctx.arc(c, c, radius, 0, Math.PI * 2);
@@ -40,7 +35,6 @@ function ring(ctx, c, radius, color, width, alpha) {
ctx.globalAlpha = 1;
}
/** A filled dot at the centre with a dark outline. */
function dot(ctx, c, radius, color, outline, width) {
ctx.beginPath();
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' } = {}) {
return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => {
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.
*
* Draped lines and clamped billboards land on the top of the photoreal mesh,
* so on a street lined with trees or under an overpass the coverage appears
* to float at roof height. Casting places the overlays at the bare-earth
* 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.
* Ground casting for street-level overlays on Google 3D. Draped overlays land
* on the mesh top (trees, overpasses), so casting places them at bare-earth
* (DEM) height plus a lift and lets the mesh hide what is behind buildings.
* Heights come from a coarse grid shared with the ground-floor service; a
* geoid fallback (terrain proxy down) counts as unknown and stays draped.
*/
import { wrapLon } from './tileMath.js';
@@ -28,15 +20,12 @@ export const GROUND_CAST_MAX_CORNERS = 1024;
const GROUND_CAST_CACHE_MAX = 50_000;
/**
* Mesh refinement. The bare-earth grid cannot see a freeway in a trench, a
* steep street between grid corners, or the road under a tree, so where the
* rendered Google 3D surface has been sampled it refines the height:
* - mesh at most MESH_ROAD_ABOVE_M above bare earth (and not absurdly far
* below it) is the road itself: follow it, MESH_LIFT_M above;
* - mesh higher than that is something over the road (canopy, deck, roof):
* 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.
* Mesh refinement, where the Google 3D surface was sampled (the DEM grid
* misses trenches, steep streets and roads under trees):
* - mesh up to MESH_ROAD_ABOVE_M above bare earth is the road: follow it;
* - higher mesh is over the road (canopy, deck): interpolate the road's
* offset from the nearest road samples on both sides;
* - otherwise bare earth plus GROUND_CAST_LIFT_M.
*/
export const MESH_ROAD_ABOVE_M = 3;
/** A sample this far below bare earth is a bad probe, not a road. */
@@ -49,8 +38,7 @@ export const MESH_BRIDGE_MAX_POINTS = 8;
export const MESH_DENSIFY_DEG = 0.0002;
/**
* Heights for a line's points from bare earth and, where sampled, the mesh.
* Pure, so the rule above is testable without a scene.
* Heights for a line's points by the rule above.
* @param {Array<{dem: number, mesh?: number|null}>} points
* @param {{lift?: number}} [options]
* @returns {Array<number>}
@@ -93,9 +81,8 @@ export const SURFACE_TERRAIN_ENTER_M = 1400;
export const SURFACE_TERRAIN_EXIT_M = 1800;
/**
* The next surface mode for the camera. Terrain mode applies only on Google
* 3D and only at street zoom; the gap between the enter and exit heights
* stops the mode from flapping while the camera hovers at the boundary.
* Next surface mode: terrain only on Google 3D at street zoom, with
* hysteresis between the enter and exit heights.
* @param {'draped'|'terrain'} current
* @param {{photoreal: boolean, heightM: number|null, available: boolean}} view
* @returns {'draped'|'terrain'}
@@ -108,9 +95,8 @@ export function nextSurfaceMode(current, { photoreal, heightM, available }) {
}
/**
* Split long segments so a line follows the terrain between its vertices.
* A segment across ±180° is split the short way round, and the points it
* adds are wrapped back into [-180, 180].
* Split long segments so a line follows the terrain. Segments across ±180°
* go the short way round, with added points wrapped into [-180, 180].
* @param {Array<[number, number]>} coords [lon, lat] pairs
* @param {number} [maxStep] longest segment, in degrees
* @returns {Array<[number, number]>}
@@ -172,9 +158,8 @@ export function createGroundCaster({
async function resolve(points, signal) {
if (signal?.aborted) return false;
// Make room first: evicting after counting would drop corners this
// request already treated as cached. Oldest first (a Map keeps insertion
// order), so the tiles cast last, likely still on screen, keep theirs.
// Evict before counting, or we could drop corners this request counted
// as cached. Oldest first, so the tiles still on screen keep theirs.
for (const key of heights.keys()) {
if (heights.size + maxCorners <= cacheMax) break;
heights.delete(key);
@@ -182,8 +167,7 @@ export function createGroundCaster({
const missing = missingCorners(points);
if (!missing.length) return true;
if (missing.length > maxCorners) return false;
// A corner east of 180° (the far corner of a point on the meridian) is
// asked for at its longitude on the other side.
// A corner east of 180° is asked for at its wrapped longitude.
const results = await terrain.resolveEllipsoidalGround(
missing.map(([, { i, j }]) => ({
lon: Number(wrapLon(i * step).toFixed(6)),
@@ -202,9 +186,8 @@ export function createGroundCaster({
}
/**
* Fetch the grid corners around the given points. Resolves true when every
* point can now be cast; false when some corners are unknown (terrain proxy
* down, too many corners, or aborted). Never rejects.
* Fetch the grid corners around the points. True when all can be cast;
* false when some are unknown, too many, or aborted. Never rejects.
* @param {Array<[number, number]>} points [lon, lat] pairs
* @param {{signal?: AbortSignal}} [options]
*/
@@ -215,10 +198,8 @@ export function createGroundCaster({
}
/**
* `prepare` for lines, including the points densifying adds. `castLine`
* densifies finer when mesh heights refine it, and those points can fall in
* 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.
* `prepare` for lines at both densities: the finer mesh points can fall in
* cells the coarse ones skip, and a missing cell leaves the line draped.
*/
function prepareLines(lines, options) {
return prepare(
@@ -250,10 +231,8 @@ export function createGroundCaster({
}
/**
* A line as flat [lon, lat, height, ...] degrees and metres, densified;
* null when any corner it needs is not cached yet. With `meshAt` (sampled
* mesh heights, undefined or null where unknown) the line is densified
* finer and its heights refined by `refineHeights`.
* Densified line as flat [lon, lat, height, ...]; null until every corner is
* cached. With `meshAt` it is densified finer and refined by `refineHeights`.
* @param {Array<[number, number]>} coords
* @param {{meshAt?: (lon: number, lat: number) => number|null|undefined}} [options]
*/
+7 -8
View File
@@ -83,7 +83,7 @@ test('densifyLine splits long segments and keeps the vertices', () => {
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°.
const short = [
[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...
assert.equal(await caster.prepare([[10.0005, 50.0005]]), true);
// ...then a line over that cell and the next needs two more. Clearing after
// counting would have kept only the two new corners and lost the first cell.
// ...then a line over that cell and the next needs two more, and the first
// cell must survive the eviction.
const line = [
[10.0002, 50.0005],
[10.0018, 50.0005],
@@ -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');
});
test('prepared lines always cast with the mesh, however their segments cross the grid (review IC8 P1)', async () => {
// Seeded walks with 50–300 m segments: the finer mesh densifying used to
// land in grid cells the coarse points skipped, so most of them came back
// null and stayed draped over roofs.
test('prepared lines always cast with the mesh, however their segments cross the grid', async () => {
// Seeded walks with 50–300 m segments: the finer mesh points can land in
// grid cells the coarse points skip.
let seed = 7;
const random = () => {
seed = (seed * 16807) % 2147483647;
@@ -332,7 +331,7 @@ test('prepared lines always cast with the mesh, however their segments cross the
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.
const calls = [];
const terrain = {
+9 -27
View File
@@ -25,11 +25,8 @@ import {
export { STREET_LEVEL_LAYER_ID } from './policy.js';
/**
* Construct the Street Level layer from a list of imagery providers (see
* registry.js for the contract). The core owns what every provider shares:
* 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.
* The Street Level layer over imagery providers (contract in registry.js).
* The core owns what they share; providers own coverage, sequences, viewer.
* @param {{providers: Array<import('./registry.js').StreetLevelProvider>, services?: object}} options
*/
export function createStreetLevelLayer({
@@ -84,23 +81,17 @@ export function createStreetLevelLayer({
function providerContext(entry) {
return Object.freeze({
services: state.services,
/** The imagery filter resolved against now; read it when filtering. */
getFilter: () => resolveFilter(state.filter),
isActive: () => state.enabled && entry.on,
/** Bare-earth heights (see groundCast.js), or null without a terrain service. */
groundCaster: parts.groundCaster,
/** Sampled Google 3D surface heights refining the cast (meshSampler.js), or null. */
meshSampler: parts.meshSampler,
/** 'terrain' when overlays should sit on the bare earth, else 'draped'. */
getSurface: () => state.surface,
notify,
actions: {
openImage: (imageId) => openImage(entry.def.id, imageId),
/**
* Show a provider error, or (null) withdraw the one it showed. A
* withdrawal never clears an error the viewer or another provider
* has shown since, and a stale one cannot mask a later coverage
* error such as KEY REJECTED.
* Show a provider error, or withdraw it (null). A withdrawal clears
* only this provider's own error, never a newer one from elsewhere.
*/
reportError: (message) => {
if (message) {
@@ -143,9 +134,8 @@ export function createStreetLevelLayer({
}
/**
* Camera height above the bare earth under it, for the surface mode. The
* ground comes from the caster's coarse grid; a cold cell near enough to
* matter is fetched once and the mode checked again when it lands.
* Camera height above bare earth, for the surface mode. A cold grid cell is
* fetched once and the mode rechecked when it lands.
*/
function cameraHeightForSurface() {
const carto = state.viewer?.camera?.positionCartographic;
@@ -163,10 +153,7 @@ export function createStreetLevelLayer({
return height;
}
/**
* 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.
*/
/** Cast overlays to bare earth on Google 3D at street zoom; drape them elsewhere. */
function syncSurface() {
const available = Boolean(parts.groundCaster) && state.enabled;
const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
@@ -239,12 +226,7 @@ export function createStreetLevelLayer({
parts.credits.hide(state.viewer, entry.def);
}
/**
* 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}
*/
/** The nearest-image lookup in flight; any newer user action aborts it. */
let nearestLookup = null;
function abortNearest() {
@@ -502,7 +484,7 @@ export function createStreetLevelLayer({
if (!state.enabled) break;
// This provider was switched off while it looked: try the next.
if (!imageId || !entry.on) continue;
nearestLookup = null; // answered: nothing left to abort
nearestLookup = null;
return openImage(entry.def.id, imageId);
}
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);
});
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 { layer } = await enabledLayer(t, [provider]);
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);
});
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 provider = fakeProvider({ nearestImage: () => answer.promise });
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);
});
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 provider = fakeProvider({ nearestImage: () => answer.promise });
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);
});
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 { layer } = await enabledLayer(t, [provider]);
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/);
});
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()]);
layer.attachViewerHost({});
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);
});
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()]);
layer.attachViewerHost({});
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');
});
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()]);
layer.attachViewerHost({});
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');
});
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 provider = fakeProvider({ nearestImage });
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);
});
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 provider = fakeProvider({ nearestImage });
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);
});
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 provider = fakeProvider({ nearestImage });
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');
});
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 source = {
hasToken: () => true,
+3 -8
View File
@@ -6,11 +6,7 @@ import { createHorizonCull } from './view.js';
const SPRITE_ID = 'street-level:marker';
/**
* 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.
*/
/** On-globe marker for the open image; in terrain mode it stands on bare earth. */
export function createMarker({ state, parts }) {
const { render, sprites } = state.services;
let last = null;
@@ -36,9 +32,8 @@ export function createMarker({ state, parts }) {
}
/**
* Height for the position in terrain mode: bare earth, refined by the
* sampled Google 3D surface where it is known; null to clamp. Fetches a
* missing terrain cell and asks for a mesh sample under the marker.
* Terrain-mode height (bare earth refined by mesh samples), or null to
* clamp. Requests a missing terrain cell or mesh sample as a side effect.
*/
function castHeight(position) {
const ground = parts?.groundCaster;
+1 -1
View File
@@ -52,7 +52,7 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
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();
// Placed on the far side of the Earth from the camera.
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';
/**
* Rendered-surface heights for ground casting on Google 3D (see groundCast.js
* refineHeights). One `scene.sampleHeight` probe per ~11 m cell, cached, so
* the many sequences that share a street share their samples.
*
* `sampleHeight` renders a pick pass per probe (about 1–3 ms), so probes run
* 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.
* Google 3D surface heights for ground casting (groundCast.js refineHeights):
* one cached `scene.sampleHeight` probe per ~11 m cell. Each probe renders a
* pick pass (1–3 ms), so probes run in budgeted idle slices, nearest the
* camera first and only within MESH_SAMPLE_RADIUS_M. A miss (tiles not
* streamed yet) is retried later. Probes hit 3D tilesets only.
*/
/** 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);
/**
* 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.
*/
/** Numeric key of the cell holding a point (exact within 2^53; no per-point string). */
export const meshCellKey = (lon, lat) =>
(cellOf(lon) + 1_800_001) * 2_000_000 + cellOf(lat) + 1_000_000;
@@ -70,7 +58,6 @@ function siftDown(heap, i) {
heap[i] = cell;
}
/** Take the nearest cell off a min-heap. */
function heapPop(heap) {
const top = heap[0];
const last = heap.pop();
@@ -104,11 +91,7 @@ export function createMeshSampler({
}) {
/** cell key → sampled mesh height (ellipsoidal metres). */
const heights = new Map();
/**
* 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.
*/
/** cell key → retry time for probes that hit nothing; bounded like the height cache. */
const misses = new Map();
/** cell key → queued cell, so a cell is queued once. */
const wanted = new Map();
@@ -142,10 +125,8 @@ export function createMeshSampler({
}
/**
* Ask for the cells under these [lon, lat] points (read later, so do not
* change the list). Nothing is done here: idle slices take requests in
* within their budget, and drop cells out of range of where the camera was
* when asked; callers ask again once it moves.
* Ask for the cells under [lon, lat] points; the list is read later, so do
* not mutate it. Out-of-range cells are dropped; callers ask again on move.
*/
function request(points) {
if (!enabled || !points?.length) return;
@@ -288,7 +269,6 @@ export function createMeshSampler({
schedule();
}
/** Drop every sample and remembered miss. */
function forget() {
heights.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();
});
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'] });
const { viewer, probes } = fakeViewer({ holes: () => true });
const sampler = createMeshSampler({ getViewer: () => viewer, cacheMax: 2 });
@@ -191,7 +191,7 @@ function grid(lon, lat, n, step) {
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);
// 400 cells ~3.6 km east, past the sampler's range.
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();
});
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 });
sampler.request(grid(10.001, 50, 5, 0.0002));
const lengths = [];
@@ -217,7 +217,7 @@ test('a slice keeps to its budget, counting the work before its first probe (rev
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);
// 2,500 cells ~17 m apart around the camera, all within range.
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();
});
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);
moveTo(179.9999, 0);
sampler.request([
+4 -5
View File
@@ -1,9 +1,8 @@
import { normalizeFilter } from './filter.js';
/**
* Share-link and stored parameters: one boolean per registered provider plus
* the shared imagery filter. The codec in src/data/layerState.js names the
* same keys, so a provider added later also needs an option there.
* Share-link params: one boolean per provider plus the filter. The codec in
* src/data/layerState.js names the same keys, so a new provider needs one there.
* @param {{providers: Iterable<[string, boolean]>, filter: {pano: string, sinceDays: number}}} input
*/
export function encodeParams({ providers, filter }) {
@@ -15,8 +14,8 @@ export function encodeParams({ providers, filter }) {
}
/**
* Read parameters back. Unknown providers and malformed values are ignored,
* so a link written for a build with more providers still applies cleanly.
* Read params back, ignoring unknown providers and malformed values so a link
* from a build with more providers still applies.
* @param {object} params
* @param {{providerIds: Iterable<string>, filter: {pano: string, sinceDays: number}}} current
* @returns {{providers: Map<string, boolean>, filter: {pano: string, sinceDays: number}}}
+2 -3
View File
@@ -1,7 +1,6 @@
/**
* Route picked primitive ids to the provider whose prefix they carry. The
* registry guarantees prefixes never overlap, so the first match is the only
* match.
* Route picked ids to the provider whose prefix they carry; the registry
* guarantees prefixes never overlap.
* @param {() => Iterable<{def: {id: string, pickPrefix: string}, instance: object}>} getProviders
* @param {{positionId?: string}} [options] Id of the core-owned position marker.
*/
+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. */
export const POSITION_PICK_ID = 'sl:pos';
/**
* 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.
*/
/** One colour per imagery source: chip, coverage lines, points, cones and legend. */
export const PROVIDER_COLORS = Object.freeze({
mapillary: '#05cb63',
});
@@ -29,16 +24,12 @@ export const MAX_SINCE_DAYS = 36_500;
/** Filter every provider starts with: all imagery, any date. */
export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
/**
* Nearest-image search when the user asks to look at a place, in metres
* (Mapillary's graph API answers within at most 50 m).
*/
/** Nearest-image search radius in metres (Mapillary's graph API caps it at 50 m). */
export const NEAREST_RADIUS_M = 50;
/**
* Map stack the globe camera may follow the street-level view on. Only Google
* Photorealistic 3D has buildings to stand among; on flat imagery or terrain
* a camera at eye height looks at a smeared texture.
* The only map stack camera follow is offered on: elsewhere a camera at eye
* height looks at a smeared texture.
*/
export const FOLLOW_MAP_STACK_ID = 'photoreal';
@@ -41,11 +41,7 @@ const REMESH_INTERVAL_MS = 1500;
const REMESH_STAGGER_MS = 120;
/** Old-zoom tiles are kept at most this long after a zoom change. */
const STALE_TILE_MAX_MS = 6000;
/**
* Sequences per 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.
*/
/** Sequences per primitive: smaller batches build, and show, sooner. */
const SEQUENCE_PRIMITIVE_BATCH = 120;
const PER_TILE_SEQUENCE_CAP = Math.floor(
@@ -71,12 +67,7 @@ function partIds(sequence) {
);
}
/**
* 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}
*/
/** Sequence id for any part's pick id (`mly:seq:<id>[~<part>]`), else null. */
export function sequenceIdFromPick(pickId) {
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX.sequence))
return null;
@@ -86,22 +77,11 @@ export function sequenceIdFromPick(pickId) {
}
/**
* Camera-driven coverage: z0–5 `overview` points from orbit down to 60 km,
* then z11–14 sequence polylines clamped to terrain and 3D tiles. Decoded
* 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.
* Whether any point of `box` ({west, south, east, north}) is within the mesh
* sampler's range of `centre`, the ground point under the camera.
*/
export function meshBoxInRange(box, centre) {
// 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.
const lon =
centre.lon +
@@ -113,6 +93,12 @@ export function meshBoxInRange(box, centre) {
return metresBetween(centre, nearest) <= MESH_SAMPLE_RADIUS_M;
}
/**
* Camera-driven coverage: overview points (z0–5) from orbit, sequence lines
* (z11–14) near the ground. In terrain surface mode a tile is drawn draped
* first, then swapped for lines cast to the bare earth (draped lines would
* land on roofs), so coverage never waits on terrain heights.
*/
export function createCoverage({ state, source }) {
const { render } = state.services;
@@ -142,9 +128,8 @@ export function createCoverage({ state, source }) {
}
/**
* The sequences a tile draws: newest first, past the imagery filter, then
* capped. Capping before the filter could leave a dense tile of newer flat
* captures with no 360° lines at all.
* A tile's sequences, filtered then capped: capping first could leave a
* dense tile of newer flat captures with no 360° lines at all.
*/
function drawnSequences(entry) {
const drawn = [];
@@ -157,11 +142,9 @@ export function createCoverage({ state, source }) {
}
/**
* Primitives for a tile's sequences, in draw batches: ground primitives
* draped on the globe, plus (in terrain mode) plain polylines at the cast
* heights for every part whose heights are cached. Each part of a sequence
* with a capture gap is its own line. Each batch remembers the selection
* its colours were built with.
* Batched primitives for a tile's sequence parts: cast polylines where
* terrain heights are cached, draped ground lines otherwise. Each batch
* records the selection its colours were built with.
*/
function buildSequencePrimitives(sequences) {
const ground = terrainMode() ? state.context.groundCaster : null;
@@ -233,13 +216,11 @@ export function createCoverage({ state, source }) {
}
/**
* Fetch the terrain heights a tile's lines need, then redraw the tile cast.
* `castRequested` holds while the tile's castable lines are cast; a cast
* that resolves nothing (terrain proxy down, tile too big) clears it, so
* the next refresh tries again rather than this retrying in a loop.
* Fetch the terrain heights a tile's lines need, then redraw it cast. A
* cast that resolves nothing clears `castRequested`, so the next refresh
* retries instead of this looping.
*/
function castTile(entry) {
// `castAbort` is set while a cast is in flight.
if (entry.castRequested || entry.castAbort || !terrainMode()) return;
entry.castRequested = true;
entry.castAbort = new AbortController();
@@ -264,10 +245,7 @@ export function createCoverage({ state, source }) {
});
}
/**
* 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.
*/
/** Rebuild a tile's lines; the old ones stay up until the new are ready. */
function redrawTile(entry) {
const previous = entry.primitives;
entry.primitives = [];
@@ -275,7 +253,7 @@ export function createCoverage({ state, source }) {
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() {
const carto = state.viewer?.camera?.positionCartographic;
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;
* each line's mesh points are densified the first time it is in range.
* A tile's drawn lines with bounding boxes, built once per filter; mesh
* points are densified the first time a line is in range.
*/
function meshParts(entry) {
if (entry.meshParts) return entry.meshParts;
@@ -332,10 +308,7 @@ export function createCoverage({ state, source }) {
if (points.length) sampler.request(points);
}
/**
* New mesh samples landed: redraw the tiles they fall in, throttled, with
* the same no-blink swap as the bare-earth cast.
*/
/** Redraw the tiles new mesh samples fall in, throttled. */
function onMeshSampled(batch) {
if (!terrainMode() || !state.context.isActive()) return;
for (const entry of state.coverage.tiles.values()) {
@@ -543,9 +516,8 @@ export function createCoverage({ state, source }) {
});
if (kind === 'sequence') await ensureTerrainReady();
const bytes = await fetching;
// A refresh that still wants this tile must not throw the bytes away;
// only an abort (tile no longer wanted, or zoom changed) does.
// A retire, clear or newer request for this tile owns it now.
// Only an abort, a retire, a clear or a newer request for this tile
// discards the bytes; a refresh that still wants the tile keeps them.
const current = () =>
state.coverage.pending.get(key) === controller &&
!state.coverage.tiles.has(key);
@@ -644,10 +616,7 @@ export function createCoverage({ state, source }) {
requestRender();
}
/**
* 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).
*/
/** Move tiles to the stale set: the old zoom shows until the new one loads. */
function retire() {
for (const controller of state.coverage.pending.values())
controller.abort();
@@ -812,9 +781,8 @@ export function createCoverage({ state, source }) {
}
/**
* Redraw loaded tiles draped or cast after the core's surface mode changes,
* then re-pick the zoom: the bare-earth height under the camera that the
* change brings in can move it (a high city reads much closer to the street).
* Redraw tiles draped or cast for the new surface mode, then re-pick the
* zoom, since the ground height under the camera may have changed.
*/
function setSurface() {
if (!state.context.isActive()) return;
@@ -839,10 +807,7 @@ export function createCoverage({ state, source }) {
}
}
/**
* 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).
*/
/** Every tile on the globe: the current zoom's and the stale zoom's. */
function drawnEntries() {
return [...state.coverage.tiles.values(), ...state.coverage.stale.values()];
}
@@ -886,10 +851,8 @@ export function createCoverage({ state, source }) {
}
/**
* Colours are baked in when a tile's lines are built, and a primitive
* cannot be recoloured until it is ready. Cast swaps and remeshes rebuild
* 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.
* A primitive cannot be recoloured until it is ready, so while any is
* building, catch each one up with the selection as it becomes ready.
*/
function watchSelection() {
const scene = state.viewer?.scene;
@@ -171,7 +171,7 @@ function frame(listeners) {
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 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();
});
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();
coverage.refresh();
const rejected = Object.assign(
@@ -330,7 +330,7 @@ test('a rejected key stops coverage requests until the layer goes off (review IC
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'] });
const { viewer, source, state, coverage } = setup();
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');
});
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();
// Look straight down on the edge between this tile and its east neighbour.
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();
});
/**
* In terrain mode with a cast tile mid-swap: the draped lines and the cast
* primitive whose readiness the test controls.
*/
/** A terrain-mode tile mid-swap, with a cast primitive the test readies. */
async function midSwap() {
// Drawn draped first; castLine only answers once the heights are in.
let heightsReady = false;
@@ -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
* a z14 tile and looking east with the screen centre `aheadM` metres out.
* Google 3D hides the globe, so only the bare-earth caster knows the ground.
* Denver: 300 m above a street 1,600 m up, at a z14 tile centre, looking east
* at ground `aheadM` metres out. Only the bare-earth caster knows the ground.
*/
function denverStreetView(aheadM) {
const z = 14;
@@ -735,7 +731,7 @@ test('a street view toward the horizon ranks only the ground within range', () =
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();
coverage.refresh();
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();
});
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();
coverage.refresh();
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');
});
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;
const groundCaster = {
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();
});
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'] });
const { viewer, source, coverage } = setup();
coverage.refresh();
@@ -898,7 +894,7 @@ test('a rate limit holds every refresh inside its wait, not only the first (revi
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.
const camera = { lon: 179.999, lat: 0 };
const east = { west: -180, east: -179.978, south: -0.001, north: 0.001 };
@@ -1,11 +1,5 @@
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);
/** 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) {
const out = [];
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 {{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(
tile,
@@ -3,13 +3,10 @@ import { VectorTile } from '@mapbox/vector-tile';
import { tileLocalToLonLat } from '../../tileMath.js';
/**
* Decode a Mapillary coverage tile (`mly1_public`) in the browser.
* Sequences are LineStrings with capture metadata; z0–5 tiles carry
* `overview` points instead. The z14 `image` layer (it can hold >150k
* points) is never read.
* Decode a `mly1_public` coverage tile into sequences and (z0–5) overview
* points. The z14 `image` layer, which can hold >150k points, is skipped.
* @param {Uint8Array} bytes
* @param {{x:number,y:number,z:number}} address
* @returns {{sequences: Array<object>, overview: Array<object>}}
*/
export function decodeCoverageTile(bytes, address) {
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;
if (overviewLayer) {
for (let i = 0; i < overviewLayer.length; i++) {
@@ -23,19 +23,16 @@ function createProviderState(context) {
services: context.services,
viewer: null,
keyRequired: false,
/**
* False until the first status answer: coverage asks for nothing before
* it, so a key-less install never sends tile requests that get 503.
*/
/** Coverage waits for the first status: no tile requests without a key. */
statusKnown: false,
/** Mapillary refused the configured token: nothing is requested until re-enabled. */
/** Mapillary refused the token: request nothing until re-enabled. */
keyRejected: false,
status: null,
coverage: {
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 from the previous zoom, kept on screen until replacements land. */
/** Previous zoom's tiles, kept on screen until replacements land. */
stale: new Map(),
staleTimer: null,
/** Tile key → its request's controller; any entry means LOADING. */
@@ -53,7 +50,7 @@ function createProviderState(context) {
sequence: {
selectedId: null,
images: [],
/** @type {Map<string, Array<object>>} recent sequences' thinned images */
/** Recent sequences' thinned images, by sequence id. */
cache: new Map(),
collection: null,
loading: false,
@@ -63,9 +60,8 @@ function createProviderState(context) {
}
/**
* Mapillary as a Street Level provider: coverage from cached vector tiles,
* per-sequence image cones from the Graph API and the MapillaryJS viewer.
* @param {{source: object}} options A source from ./source.js (or a stand-in with the same methods).
* Mapillary Street Level provider: tile coverage, Graph API image cones and
* the MapillaryJS viewer. `source` is ./source.js or a stand-in.
* @returns {import('../../registry.js').StreetLevelProvider}
*/
export function createMapillaryProvider({ source }) {
@@ -24,10 +24,7 @@ export const PICK_PREFIX = Object.freeze({
image: 'mly:img:',
});
/**
* 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.
*/
/** Mapillary green for lines, points and cones; GEV cyan for selection. */
export const COLORS = Object.freeze({
coverage: PROVIDER_COLORS.mapillary,
selected: SHARED_COLORS.selected,
@@ -45,9 +42,8 @@ export const KEY_REJECTED_MESSAGE =
export const RATE_LIMITED_MESSAGE =
'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
* view sees to the horizon, but only the ground within this range is ranked
* and boxed. Scales with camera height, never below the floor.
* Street-zoom coverage reaches this far from the camera: camera height times
* the factor, never below the floor, even when a tilted view sees further.
*/
export const SEQUENCE_VIEW_RANGE_MIN_M = 2_500;
export const SEQUENCE_VIEW_RANGE_PER_HEIGHT = 10;
@@ -65,10 +61,7 @@ export const IMAGE_CONE_MIN_SPACING_M = 3;
/** Images asked for in a nearest-image search (radius: NEAREST_RADIUS_M). */
export const NEAREST_LIMIT = 8;
/**
* On-globe credit shown while the provider is active. Mapillary imagery and
* derived data are CC BY-SA 4.0 and require visible attribution.
*/
/** On-globe credit: Mapillary imagery is CC BY-SA 4.0 and needs attribution. */
export const MAPILLARY_CREDIT_HTML =
'Street Level: imagery © <a href="https://www.mapillary.com" target="_blank" rel="noopener">Mapillary</a> contributors, CC BY-SA 4.0';
@@ -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');
});
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) });
const day = 86_400_000;
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) => ({
id,
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');
});
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 });
// A sequence driving east over ±180° with images ~1 m apart, then ~11 m on.
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]) {
let answer;
const tiles = [];
@@ -58,12 +58,8 @@ export function createSequences({ state, source, parts }) {
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() });
/** Drawn cones, plus the mesh cells under them, so a sample moves only those. */
let drawn = nothingDrawn();
// The cones skip the depth test: hide the ones behind the globe.
@@ -105,11 +101,8 @@ export function createSequences({ state, source, parts }) {
}
/**
* Heights for the cones in terrain mode (Google 3D at street zoom), so they
* stand on the street rather than on roofs and tree tops: bare earth,
* 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.
* Terrain-mode cone heights on the street, not on roofs: bare earth refined
* by mesh samples. Null when draped or while terrain heights are fetched.
*/
function coneHeights(images, { request = true } = {}) {
const ground = state.context.groundCaster;
@@ -173,10 +166,8 @@ export function createSequences({ state, source, parts }) {
}
/**
* New mesh samples landed: when any is under a cone, re-place the cones
* whose cast height changed, in place. A sample can move a neighbour too
* (refineHeights bridges across covered points), so every height is
* recomputed (cheap arithmetic) but only moved cones are touched.
* Re-place cones whose height new mesh samples changed. A sample can move
* a neighbour too, so every height is recomputed but only moved cones touched.
*/
function onMeshSampled(batch) {
if (state.context.getSurface?.() !== 'terrain' || !drawn.cones.length)
@@ -91,7 +91,7 @@ function setup() {
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();
sequences.select('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
* mesh sampler whose samples the test sets and announces, and cones that
* log every change made to them or their collection.
* Terrain mode with bare earth at 10 m, a mesh sampler the test drives, and
* cones that log every change made to them.
*/
function terrainSetup() {
const context = setup();
@@ -240,7 +239,7 @@ function terrainSetup() {
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();
sequences.select('A');
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');
});
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 } =
terrainSetup();
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]]);
});
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 view = { lon: -121.49, lat: 38.58, height: 20_000_000 };
const preRender = new Set();
@@ -7,11 +7,7 @@ import {
SEQUENCE_IMAGES_LIMIT,
} from './policy.js';
/**
* 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`).
*/
/** HTTP error flagged `keyRequired`, `keyRejected` or `retryAfterSec` (429). */
export class MapillarySourceError extends Error {
constructor(message, { status = 0, payload = null, retryAfterSec } = {}) {
super(message);
@@ -44,10 +40,8 @@ async function readJsonOrThrow(response, label) {
}
/**
* Every network path the Mapillary layer uses. Coverage tiles go through the
* local dev-server proxy (which adds the token and caches them); image and
* sequence lookups go straight to graph.mapillary.com with the client token,
* exactly as the embedded MapillaryJS viewer does.
* Mapillary network access: tiles via the local proxy, which adds the token
* and caches; Graph API lookups direct with the client token, as MapillaryJS.
* @param {{token?: string, fetchImpl?: Function, endpoints?: object}} options
*/
export function createMapillarySource({
@@ -110,7 +110,7 @@ test('the source exposes only imagery lookups', () => {
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({
token: 'MLY|1|abc',
fetchImpl: fakeFetch(() =>
@@ -1,9 +1,8 @@
import { MAPILLARY_PROVIDER_ID, mapillaryImageUrl } from './policy.js';
/**
* The Mapillary viewer adapter: lazy-load MapillaryJS, mount it in the host
* the core hands over, open images, and emit a provider-neutral pose for
* every image, point-of-view or position change.
* Viewer adapter: lazy-loads MapillaryJS into the core's host and emits a
* provider-neutral pose on every image, view or position change.
* @returns {import('../../registry.js').ViewerAdapter}
*/
export function createMapillaryViewer({ source, render } = {}) {
@@ -11,7 +10,6 @@ export function createMapillaryViewer({ source, render } = {}) {
let Library = null;
let container = null;
let pendingOpen = null;
/** The prewarm in flight, if any. */
let prewarming = null;
/** In-flight viewer construction, so pre-warm and open never build two. */
let creating = null;
@@ -34,10 +32,8 @@ export function createMapillaryViewer({ source, render } = {}) {
async function ensureLibrary() {
if (Library) return Library;
// The stylesheet comes with the lazy chunk: Vite applies a dynamically
// imported stylesheet before its import resolves, so the viewer's chrome
// is styled from the first frame without costing every page load ~90 KB
// of render-blocking CSS.
// Lazy CSS: Vite applies it before the import resolves, so the viewer is
// styled from its first frame without ~90 KB of render-blocking CSS.
const [library] = await Promise.all([
import('mapillary-js'),
import('mapillary-js/dist/mapillary.css'),
@@ -196,14 +192,11 @@ export function createMapillaryViewer({ source, render } = {}) {
}
},
/**
* 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.
*/
/** Build the viewer ahead of the first image. Safe to call repeatedly. */
async prewarm(host) {
if (!host) return;
// A prewarm already in flight may give up (the layer went off and on
// while the library downloaded): wait for it, then build if it did not.
// An in-flight prewarm may give up (layer toggled mid-download): wait
// for it, then build if it did not.
if (prewarming) await prewarming;
if (viewer) return;
const run = (async () => {
+7 -9
View File
@@ -7,17 +7,17 @@
* @property {string} label Chip text and link label, e.g. 'MAPILLARY'.
* @property {string|null} requiresKeyId Key-setup id the provider needs, or null when keyless.
* @property {string} pickPrefix Every primitive id the provider creates starts with it.
* @property {{coverage: string}} colors The source's one colour (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 {(context: ProviderContext) => ProviderInstance} create
*
* @typedef {object} ProviderContext Handed to `create()` once by the core.
* @property {object} services Scene services: picking, input, render, sprites, ground, terrain.
* @property {() => {pano: string, sinceMs: number|null}} getFilter 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 {() => '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 {{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 {{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-]*$/;
/**
* Check a provider list once at construction and freeze its order, which is
* also the chip order in the panel.
* Validate providers and freeze their order, which is also the chip order.
* @param {Array<StreetLevelProvider>} providers
* @returns {ReadonlyArray<StreetLevelProvider>}
*/
@@ -130,9 +129,8 @@ export function validateProviders(providers) {
}
/**
* The layer-level key requirement: the one key id every provider shares, or
* null as soon as any provider is keyless or they differ. Per-provider needs
* are then reported chip by chip instead of gating the whole layer.
* The key id every provider shares, or null if any is keyless or they differ
* (then each chip reports its own need).
* @param {ReadonlyArray<StreetLevelProvider>} providers
* @returns {string|null}
*/
+5 -11
View File
@@ -1,10 +1,7 @@
import * as Cesium from 'cesium';
import { STREET_LEVEL_LAYER_ID } from './policy.js';
/**
* 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.
*/
/** Hover picks at most ~8 times a second (like the CCTV layer), never per frame. */
const HOVER_PICK_INTERVAL_MS = 120;
/** One click/hover handler for every provider's coverage and image cones. */
@@ -92,9 +89,8 @@ export function createSelection({ state, parts }) {
}
/**
* Pointer cursor over anything this layer owns, so lines read as clickable.
* Picks the latest position at most every HOVER_PICK_INTERVAL_MS; a move
* inside the interval is picked when it ends, so the cursor never sticks.
* Pointer cursor over anything this layer owns. Throttled; a move inside
* the interval is picked when it ends, so the cursor never sticks.
*/
function onMove(movement) {
const viewer = state.viewer;
@@ -119,10 +115,8 @@ export function createSelection({ state, parts }) {
}
/**
* Track held buttons (a drag picks nothing) and camera stops. A moving
* camera does not stop hover: an orbiting or 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.
* Track held buttons (a drag picks nothing) and re-pick when the camera
* rests. Hover keeps running while it moves: a tracking camera never stops.
*/
function watchHover(viewer) {
const canvas = viewer.scene.canvas;
+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();
try {
// 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();
try {
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
* names, and `isContentEditable` is what the browser reports, inherited from
* an editing host.
* An element as Esc sees it: `closest` matches tag names in a selector list;
* `isContentEditable` is inherited from an editing host, as in a browser.
*/
function element(tag, { isContentEditable = false } = {}) {
return {
+2 -3
View File
@@ -1,9 +1,8 @@
import { FILTER_DEFAULT } from './policy.js';
/**
* The open image's fields of `state.street`, as they are with no image open.
* Closing the viewer resets exactly these; the panel host, the render mode
* and whether following is available outlive any one image.
* The per-image fields of `state.street` with no image open; closing resets
* exactly these (host, render mode and follow availability outlive an image).
*/
export function freshStreet() {
return {
+12 -42
View File
@@ -1,7 +1,4 @@
/**
* Web-Mercator tile arithmetic for Mapillary vector tiles. Pure functions,
* no Cesium dependency, shared by the browser layer and the server executor.
*/
/** Web-Mercator tile math, without Cesium, shared by the browser layer and the server executor. */
const MAX_LAT = 85.05112878;
@@ -9,10 +6,7 @@ function clampLat(lat) {
return Math.max(-MAX_LAT, Math.min(MAX_LAT, lat));
}
/**
* 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.
*/
/** Wrap a longitude, or a difference of two, into [-180, 180]: the short way round. */
export function wrapLon(lon) {
if (lon >= -180 && lon <= 180) return lon;
return ((((lon + 180) % 360) + 360) % 360) - 180;
@@ -31,7 +25,6 @@ function tileYAt(lat, z) {
);
}
/** Tile column for a longitude at zoom z. */
export function lonToTileX(lon, z) {
const n = 2 ** z;
// 180° is the east edge of the last column, not the west edge of the first.
@@ -39,7 +32,6 @@ export function lonToTileX(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) {
const n = 2 ** 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)));
}
/**
* Geographic bounds of one tile.
* @returns {{west:number,south:number,east:number,north:number}}
*/
/** @returns {{west:number,south:number,east:number,north:number}} degrees */
export function tileBounds(x, y, z) {
return {
west: tileXToLon(x, z),
@@ -69,10 +58,7 @@ export function tileBounds(x, y, z) {
};
}
/**
* 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.
*/
/** A [west, south, east, north] array or object as finite, ordered bounds; null if unusable. */
export function normalizeBbox(input) {
const values = Array.isArray(input)
? input
@@ -121,16 +107,10 @@ function segmentDistance2(tile, from, to, z) {
}
/**
* Enumerate the tiles at zoom z covering a bbox (west > east means it crosses
* the date line), ordered from the centre of
* the box outwards so a progressive renderer fills in what the user is most
* likely looking at first. `limit` caps the list; the result reports whether
* 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.
* Tiles at zoom z covering a bbox (west > east crosses the date line), from
* the centre outwards, capped at `limit`. With `focus`, tiles rank by distance
* from the segment `from` (ground under the camera) to `to` (screen centre):
* a tilted view's box centre can sit kilometres ahead of both.
* @param {{limit?: number, focus?: {from: {lon: number, lat: number}, to?: {lon: number, lat: number}|null}|null}} [options]
* @returns {{tiles: Array<{x:number,y:number,z:number}>, truncated: boolean, total: number}}
*/
@@ -191,11 +171,7 @@ function tileGrid(box, z) {
return { tiles, x0, x1, y0, y1 };
}
/**
* Convert a vector-tile-local coordinate (0..extent) into longitude and
* latitude for tile (x, y, z).
* @returns {[number, number]} [lon, lat]
*/
/** A vector-tile-local coordinate (0..extent) in tile (x, y, z) as [lon, lat]. */
export function tileLocalToLonLat(px, py, extent, x, y, z) {
const n = 2 ** z;
const lon = ((x + px / extent) / n) * 360 - 180;
@@ -205,11 +181,7 @@ export function tileLocalToLonLat(px, py, extent, x, y, z) {
return [lon, lat];
}
/**
* 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.
*/
/** Sequence-tile zoom for a camera height above ground (m); Mapillary stops at z14. */
export function coverageZoomForHeight(heightM) {
if (!Number.isFinite(heightM)) return null;
if (heightM > 60_000) return null;
@@ -220,10 +192,8 @@ export function coverageZoomForHeight(heightM) {
}
/**
* Pick the low-zoom `overview` tile level for a camera far above the ground.
* Mapillary publishes coverage points at z0–5; sequences start at z6 and are
* far too heavy for a continent-sized view. Returns null below the overview
* ceiling (60 km), where `coverageZoomForHeight` takes over.
* Overview-point zoom (z0–5) above 60 km, where sequence tiles are too heavy;
* null below, where `coverageZoomForHeight` takes over.
*/
export function overviewZoomForHeight(heightM) {
if (!Number.isFinite(heightM) || heightM <= 60_000) return null;
+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
// box centre is kilometres from both the camera and the screen centre.
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
* first hint and error, whether every one of them lacks its key, and whether
* that is because a provider rejected the key it was given.
* Totals over switched-on providers. Key-gated only when every one lacks its
* key; `keyRejected` when that is because a key was refused.
* @param {Array<ProviderSnapshot>} providers
*/
export function summarizeCoverage(providers) {
@@ -40,12 +39,8 @@ export function summarizeCoverage(providers) {
}
/**
* Compose the snapshot the panel renders from the core state and one
* snapshot per registered provider. Pure, so the merge rules are testable:
* 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.
* The panel snapshot from core state and provider snapshots. The legend has
* one swatch per active source, then the shared selection colour.
* @param {{enabled: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
*/
export function composeUIState({
+1 -1
View File
@@ -118,7 +118,7 @@ test('summarizeCoverage is what getStats reports, without building a snapshot',
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({
keyRequired: true,
keyRejected: true,
+22 -56
View File
@@ -1,11 +1,7 @@
import * as Cesium from 'cesium';
import { wrapLon } from './tileMath.js';
/**
* 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.
*/
/** Equirectangular metres between {lon, lat} points, the short way round ±180°; street scale. */
export function metresBetween(a, b) {
const lat = (((a.lat + b.lat) / 2) * Math.PI) / 180;
return Math.hypot(
@@ -15,11 +11,8 @@ export function metresBetween(a, b) {
}
/**
* Run low-priority work when the browser is idle, within `timeout` ms; a
* browser without idle callbacks (and Node) runs it a frame later. The task
* gets the idle deadline, or null.
* @param {(deadline: IdleDeadline|null) => void} task
* @param {number} timeout
* Run `task(deadline)` when idle, within `timeout` ms; without idle callbacks
* (Node) it runs a frame later with a null deadline.
*/
export function whenIdle(task, timeout) {
if (typeof globalThis.requestIdleCallback === 'function')
@@ -28,10 +21,8 @@ export function whenIdle(task, timeout) {
}
/**
* Height of the surface under the camera above the ellipsoid, in metres. The
* globe's terrain answers when the globe is shown; Google 3D hides the globe,
* so there the bare-earth height from `groundAt(lon, lat)` answers instead.
* Null when neither has a sample yet.
* Ellipsoidal height (m) of the surface under the camera: globe terrain, or
* `groundAt` where Google 3D hides the globe; null without a sample.
* @param {object} viewer
* @param {{groundAt?: (lon: number, lat: number) => number|null}} [options]
*/
@@ -48,13 +39,7 @@ export function groundUnderCamera(viewer, { groundAt } = {}) {
return Number.isFinite(ground) ? ground : null;
}
/**
* 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]
*/
/** Camera height (m) above the ground under it (options as `groundUnderCamera`). */
export function cameraHeightAboveGround(viewer, options = {}) {
const carto = viewer?.camera?.positionCartographic;
if (!carto) return null;
@@ -90,18 +75,11 @@ function groundHit(camera, point, ellipsoid, maxRange) {
}
/**
* Visible bbox as [west, south, east, north] degrees, or null when the camera
* does not see the ground. A grid of screen rays is cast onto the ellipsoid
* and only the rays that hit count, so a view that includes the horizon (or a
* canvas whose frustum is stale) cannot inflate the box to the whole world;
* `computeViewRectangle` is the fallback when too few rays land.
*
* `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.
* Visible [west, south, east, north] degrees, or null. Only screen rays that
* hit count, so a horizon cannot inflate the box to the world.
* `groundHeight` raises the ellipsoid to the ground (Google 3D hides the
* globe), `maxRange` (m) drops near-horizon hits, and `nearRange` (m) always
* includes the ground around the camera.
*/
export function visibleBbox(
viewer,
@@ -129,8 +107,7 @@ export function visibleBbox(
}
}
// The ground around the camera always counts: a street-level view's screen
// rows skip from the horizon to the first few metres, and turning the
// camera should not wait for a reload.
// rows skip from the horizon to the first few metres.
const nadir = camera.positionCartographic;
if (Number.isFinite(nearRange) && nearRange > 0 && nadir) {
const dLat = nearRange / 111_320;
@@ -212,10 +189,8 @@ export function viewCentre(viewer) {
}
/**
* The ground the camera is looking at, for ranking coverage tiles: the point
* under the camera (`nadir`) and the ground hit at the centre of the screen
* (`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}
* Ground the camera looks at, for ranking tiles: `nadir` under the camera and
* `ahead` at the screen centre (null on a miss or beyond `maxRange`).
*/
export function viewFocus(
viewer,
@@ -251,7 +226,10 @@ export function viewFocus(
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;
const HORIZON_CULL_ELLIPSOID = new Cesium.Ellipsoid(
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.
* Street Level's skip the depth test, so Google 3D and clouds never hide the
* near side's; nothing else then stops the far hemisphere's drawing through
* 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.
* Hide billboards and points behind the horizon: they skip the depth test, so
* nothing else stops the far side drawing through the globe. Re-culls on
* pre-render after the camera moves; stops when nothing is left to cull.
* @param {{getViewer: () => object|null, items: () => Iterable<{position: object, show: boolean}>, onChange?: () => void}} options
*/
export function createHorizonCull({ getViewer, items, onChange }) {
@@ -308,10 +277,7 @@ export function createHorizonCull({ getViewer, items, onChange }) {
if (!cullAll(position)) stop();
}
/**
* Cull `added` (new or moved items), or everything, now; then everything
* again whenever the camera moves.
*/
/** Cull `added` items (or all) now, then all again whenever the camera moves. */
function update(added) {
const viewer = getViewer();
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
// the screen meets the street about 1.65 km north of the camera.
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 unranged = visibleBbox(view);
// Rays to the bare ellipsoid travel 1.6 km further down: the old box was
// 20 km wide and started past the street at the centre of the screen.
// Without options, rays to the bare ellipsoid travel 1.6 km further down:
// the box is 20 km wide and starts past the street at the screen centre.
assert.ok(unranged[2] - unranged[0] > 0.2);
assert.ok(unranged[1] > ahead, 'the old box missed the street in view');
const options = { groundHeight: 1610, maxRange: 6000, nearRange: 1000 };
@@ -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);
});
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 west = { lon: -179.9999, lat: 0 };
// 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);
}
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.
assert.deepEqual(
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],
);
// Austin, ground well above the ellipsoid: as before.
// Austin, ground well above the ellipsoid.
assert.deepEqual(
cullFrom([-97.7431, 30.2672, 132], [[-97.7431, 30.2681, 130]]),
[true],
);
});
test('the horizon cull still hides the far side of the Earth from orbit (gekh P2)', () => {
test('the horizon cull still hides the far side of the Earth from orbit', () => {
assert.deepEqual(
cullFrom(
[0, 0, 20_000_000],
+10 -30
View File
@@ -1,9 +1,8 @@
import { freshStreet } from './state.js';
/**
* Own the one viewer element in the panel and whichever provider's viewer
* adapter is mounted in it. Poses the adapter emits become `state.street`,
* move the marker and (optionally) the globe camera.
* Owns the panel's viewer element and the provider adapter mounted in it.
* Adapter poses update `state.street`, the marker and the follow camera.
*/
export function createViewerHost({ state, parts }) {
/** @type {{id: string, adapter: object, unsubscribe: () => void}|null} */
@@ -11,11 +10,7 @@ export function createViewerHost({ state, parts }) {
/** @type {{id: string, promise: Promise<object>}|null} */
let mounting = null;
let openSeq = 0;
/**
* 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>}
*/
/** Prewarmed, inactive adapters; each holds a live viewer (WebGL context) until `unmount`. */
const warmed = new Map();
function notify() {
@@ -44,9 +39,8 @@ export function createViewerHost({ state, parts }) {
});
parts.marker.set(state.street.position, state.street.bearing);
parts.follow.followCamera();
// Providers highlight the selected image's sequence on the map; mirror
// that once the image itself is on screen, so the sequence lookup never
// competes with the image download.
// Select the sequence only once the image is on screen, so its lookup
// never competes with the image download.
if (!state.street.loading && state.street.sequenceId !== previousSequence)
selectCurrentSequence();
notify();
@@ -62,10 +56,8 @@ export function createViewerHost({ state, parts }) {
}
/**
* Mount a provider's viewer adapter in the host. The adapter becomes
* `active` only once its mount succeeded, so a failed mount (a library
* that did not load) is retried on the next open; concurrent opens for the
* same provider share one mount.
* Mount a provider's adapter. It becomes `active` only on success, so a
* failed mount is retried on the next open; concurrent opens share one.
*/
function mount(entry) {
if (active?.id === entry.def.id) return Promise.resolve(active.adapter);
@@ -108,12 +100,7 @@ export function createViewerHost({ state, parts }) {
return promise;
}
/**
* 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>}
*/
/** Open an image: true once its first pose is in, false if it failed or was overtaken. */
async function open(providerId, imageId, { frame = true } = {}) {
const entry = providerEntry(providerId);
if (!entry || !imageId) return false;
@@ -153,10 +140,7 @@ export function createViewerHost({ state, parts }) {
return true;
}
/**
* 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).
*/
/** Close the image and stop any framing flight; the adapter stays warm. */
function close() {
openSeq++;
parts.follow.cancelFraming();
@@ -208,11 +192,7 @@ export function createViewerHost({ state, parts }) {
}
}
/**
* 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]
*/
/** Tear down every viewer, or only `providerId`'s (active or prewarmed). */
function unmount(providerId) {
if (
!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();
});
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();
gate.resolve();
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();
});
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();
gate.resolve();
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');
});
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 { framing, host } = harness(adapter);
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();
manager.setPanelStateProvider(() => ({ specs: [
{ 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, /<div id="sl-provider-chips" class="sl-chips"><\/div>/);
// The gate holds the filters only: the provider chips stay reachable, so
// their tooltip can say how to add the key (gekh P3).
// The gate holds the filters only, so the chips' key tooltip stays reachable.
const gate = html.slice(
html.indexOf('<fieldset id="sl-controls"'),
html.indexOf('</fieldset>', html.indexOf('<fieldset id="sl-controls"')),
@@ -168,9 +167,8 @@ test('the viewer comes first and the panel is a portable, resizable window', ()
panelCss,
/\.panel-floating\[style\*='height'\] \.sl-settings \{[^}]*overflow-y: auto/,
);
// Shrinking docks it back at its default size rather than leaving a
// window over the globe: collapsing a floating panel, or SHRINK / Esc on
// the expanded viewer.
// Collapsing a floating panel, or SHRINK / Esc on the expanded viewer,
// docks it rather than leaving a window over the globe.
assert.match(
position,
/id: 'street-level-panel',[\s\S]*?dockOnCollapse: true/,
@@ -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)', () => {
// EXPAND moves the viewer out of every panel, past the panel-hiding rules.
assert.match(
+5 -13
View File
@@ -1,8 +1,4 @@
/**
* 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.
*/
/** Shared Street Level test stand-ins: provider snapshot, minimal provider, ray-casting camera. */
import * as Cesium from 'cesium';
import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js';
@@ -28,10 +24,8 @@ export function providerSnapshot(overrides = {}) {
}
/**
* The smallest provider definition the core accepts. `stats` is what
* coverageStats() answers (mutate it to change the answer), `calls` counts
* 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.
* The smallest provider the core accepts. Mutate `stats` to change what
* coverageStats() answers; `calls`, `filters` and `context()` record use.
*/
export function fakeStreetLevelProvider({
id = 'mapillary',
@@ -90,10 +84,8 @@ export function fakeStreetLevelProvider({
}
/**
* A pinhole camera at `altitude` metres above the WGS84 ellipsoid, looking
* along `heading` (degrees from north) and `pitch` (degrees, negative is
* down), over a `width`×`height` canvas with the given fields of view. Its
* `pickEllipsoid` intersects whatever ellipsoid it is given, as Cesium's does.
* A pinhole camera `altitude` m above WGS84, at `heading` (deg from north) and
* `pitch` (deg, negative down); `pickEllipsoid` hits whatever ellipsoid it gets.
*/
export function rayCamera({
lon,
+35 -50
View File
@@ -28,13 +28,11 @@ import {
_settleTileWritesForTest,
} from '../../server/providers/mapillary/tiles.js';
// Every tile these tests cache lands in a throwaway directory, never in the
// developer's .gev-cache.
// Keep test tiles out of the developer's .gev-cache.
const cacheDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'gev-mly-tiles-'));
_setTileCacheDirForTest(cacheDir);
// The directory is not switched back: a background write still landing must
// never sweep the real cache. Background writes and sweeps settle first, so
// none lands in the directory while it is being removed.
// Not switched back, so a late background write can never sweep the real
// cache; writes settle before the directory is removed.
after(async () => {
await tiles._settleTileWritesForTest();
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`);
const HOUR = 60 * 60 * 1000;
/** Write a cache file dated `ageMs` ago. */
async function writeAged(file, bytes, ageMs) {
await fsp.mkdir(path.dirname(file), { recursive: true });
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
* with the response, its `close` trigger and the handler's `done` promise;
* `call` waits for the handler.
* Mount the plugin. `call` waits for the handler; `start` returns at once with
* the response, its `close` trigger and the handler's `done` promise.
*/
function install(plugin, mode = 'configureServer') {
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)`
* returns the Response for each upstream fetch; `inits` holds each fetch's
* options. Refusals are never cached, so no disk tile is written.
*/
/** Drive the tile route against a scripted upstream `answer(call, url)`. */
async function withUpstream(answer, run) {
const savedFetch = globalThis.fetch;
const savedToken = process.env.MAPILLARY_CLIENT_TOKEN;
@@ -217,7 +209,7 @@ async function withUpstream(answer, run) {
}
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(
() => new Response('{}', { status }),
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(
() => new Response('{}', { status: 429, headers: { 'Retry-After': '30' } }),
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])
assert.throws(
() => 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 file = tileFile(address);
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) => {
const expired = tileFile({ z: 14, x: 1, y: 1 }, 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) => {
const files = [3, 2, 1].map((age) => {
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) => {
const expired = tileFile({ z: 12, x: 1, y: 1 }, 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
});
});
/**
* 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.
*/
/** An upstream whose fetches wait until released and record their abort. */
async function withDeferredUpstream(tiles, run) {
const savedFetch = globalThis.fetch;
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. */
const leakText = (res) =>
@@ -690,7 +678,7 @@ const leakText = (res) =>
Buffer.isBuffer(res.body) ? res.body.toString('latin1') : String(res.body),
].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 };
await fsp.rm(tileFile(address), { force: true });
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])
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 };
await fsp.rm(tileFile(address), { force: true });
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 };
await withDeferredUpstream([address], async ({ upstream }) => {
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])
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(
() => new Response('{}', { status }),
async ({ call }) => {
@@ -776,7 +764,7 @@ const redirect = (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 withUpstream(
(n) =>
@@ -808,7 +796,7 @@ for (const [why, location] of [
['a scheme-relative host', '//evil.example/t'],
['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(
(n) =>
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(
(n) => redirect(`/maps/vtp/mly1_public/2/14/15/15?hop=${n}`),
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(
() => new Response(tile([{ name: 'sequence' }]), { status: 200 }),
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;
delete process.env.MAPILLARY_CLIENT_TOKEN;
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 file = tileFile(address);
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');
});
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 file = tileFile(address);
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(
{ length: TILE_UPSTREAM_CONCURRENCY + 2 },
(_, 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 };
await withDeferredUpstream([address], async ({ upstream, settle }) => {
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) => ({
layer: 'coverage',
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) {
const probe = tile([{ name: 'sequence', payload: Buffer.alloc(size) }]);
const bytes = tile([
@@ -1102,10 +1090,7 @@ function tileOfSize(size) {
return bytes;
}
/**
* Serve each address's big tile from a scripted upstream, with disk writes
* off: a tile memory dropped can only come back from Mapillary.
*/
/** Disk writes are off, so a tile memory dropped comes back from Mapillary. */
async function withBigTiles(t, bodies, run) {
for (const address of bodies.keys())
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) => {
const files = [3, 2, 1].map((age) => ({
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 };
await fsp.rm(tileFile(address), { force: true });
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. */
function tile(layers) {
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/tomtom/status', 200],
// 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/tiles/coverage/14/1/2', 503],
['/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');
});
/* ── 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 () => {
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
* fake document. The hit check always sees the header; `presses` says where
* each real pointerdown lands ('header', or 'other' when the rail moved the
* panel between the check and the press). A drag from the header lifts the
* panel and a header double-click docks it, unless `stuck`.
* A page stand-in that runs page functions in Node against a fake document.
* The hit check always sees the header; `presses` says where each pointerdown
* lands ('header', 'other', 'moved' or 'none').
*/
function fakePanelPage({ presses = [], floating = false, stuck = false } = {}) {
const state = { floating, downs: 0, last: null, dragging: false, shift: 0 };
@@ -164,7 +162,6 @@ function fakePanelPage({ presses = [], floating = false, stuck = false } = {}) {
const window = {
addEventListener: (type, listener) => listeners.push(listener),
};
/** Run page code (an evaluated function or a page listener) in the page. */
const inPage = (fn, ...args) => {
const saved = { document: globalThis.document, window: globalThis.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),
dockPanel: () => this._panelChrome.dockPanel('street-level-panel'),
showToast: (message) => this._showToast(message),
// Who asked for Street Level to switch on: only a user, voice or tool
// request opens the panel, never a saved-state restore.
// Lets the panel open for a user, voice or tool switch-on, not a restore.
subscribeEnableRequests: (listener) =>
this._dataManager?.subscribeVisibilityRequests?.((change) => {
if (change?.layerId === 'street-level' && change.enabled)
@@ -1509,12 +1508,7 @@ export class StyleManager extends ShellFacade {
this._initCockpitDisplayPortal();
}
/**
* 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}
*/
/** A portable panel was resized or docked: refit any viewport inside it. */
_onPanelResized(panelId) {
if (panelId === 'cctv-panel') this._syncCctvPanelViewport();
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' : ''}`,
);
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 || '';
if ((button.dataset.chipColor || '') !== color) {
if (color) {
+4 -10
View File
@@ -388,11 +388,7 @@ export class PanelChrome {
this.shareLinkManager?.onPanelStateChange?.();
}
/**
* Dock a floating portable panel back into its rail at its default size.
* @param {string} panelId
* @returns {boolean} Whether the panel was floating.
*/
/** @returns {boolean} Whether the panel was floating and is now docked. */
dockPanel(panelId) {
return this._panelPosition?.dockPanel?.(panelId) === true;
}
@@ -419,8 +415,7 @@ export class PanelChrome {
panelEl.classList.contains('cyber-accordion-collapsed');
// A user opening a Cyber rail panel owns the whole accordion, including
// peers that were only presentation-collapsed during a saved-state restore.
// A floating window is outside the accordion both ways: opening one
// leaves the rail's panels as they are.
// A floating window is outside the accordion both ways.
if (
explicit &&
!restore &&
@@ -433,7 +428,6 @@ export class PanelChrome {
if (
peer !== panelEl &&
peer.matches('[data-panel-id]') &&
// A floating window is not part of the rail's accordion.
!peer.classList.contains('panel-floating') &&
!peer.hidden
) {
@@ -562,8 +556,8 @@ export class PanelChrome {
}
}
panelEl.classList.toggle('collapsed', nextCollapsed);
// Only a user's own collapse docks a floating window; cockpit entry,
// restores and accordion peers must not discard where they put it.
// Only a user's own collapse docks a floating window; restores, cockpit
// entry and accordion peers keep its place.
if (explicit && !restore)
this._panelPosition?.onPanelCollapsed?.(panelId, nextCollapsed);
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
* inline left/top/width/height like a fixed window (`box` holds the natural
* size), its header is the top 36 px, and `obstacles` stand in for the
* command dock and voice pill. ResizeObserver callbacks run on `observe()`.
* Both portable panels wired through _initPanelDrag. Headers are the top
* 36 px; `obstacles` stand in for the command dock and voice pill.
* ResizeObserver callbacks run on `observe()`.
*/
function portableShell(f, { stored = {}, box = {}, obstacles = {} } = {}) {
const observers = [];
+1 -2
View File
@@ -205,8 +205,7 @@ export class PanelLayoutController {
this._streetLevelPanel,
]) {
if (!panel) continue;
// A portable panel restored as a floating window keeps the place and
// size PanelPositionControls gave it; only docked panels are reset.
// A floating window keeps its restored place and size.
if (!panel.classList.contains('panel-floating')) {
for (const property of ['top', 'right', 'bottom', 'left', 'z-index'])
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_MAX = 139;
/**
* Lowest z a promoted (dragged or floating) panel may take: above every panel
* docked in a rail, the highest of which is #pp-toggles at 110 (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.
* Lowest z for a promoted panel: above every docked rail panel (#pp-toggles is
* 110). Keep in step with `#right-context-rail > .panel-floating` in controls.css.
*/
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;
/**
* Two header presses this close in time and space snap a floating panel
* back. Detected from pointerdown because the drag handler's
* preventDefault() suppresses the mouse events a native dblclick needs.
* Double press that snaps a floating panel back. Detected from pointerdown
* because the drag's preventDefault() suppresses a native dblclick.
*/
const DOUBLE_PRESS_MS = 400;
const DOUBLE_PRESS_SLOP_PX = 6;
/** Viewport margin every positioned panel keeps clear. */
const VIEWPORT_MARGIN_PX = 6;
/**
* Fixed chrome that paints over every panel: the rail is its own stacking
* context (z 110), below the command dock (145) and the voice pill (150). A
* floating window keeps its header clear of these so it can always be
* grabbed again.
* Fixed chrome that paints over the rail's stacking context; a floating
* window keeps its header clear of it so it can always be grabbed again.
*/
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 = 'Double-click the header to snap the panel back';
/** Header children whose own interaction wins over a drag or a snap-back. */
const INTERACTIVE_SELECTOR =
'input, select, option, textarea, button, a, [role="button"]';
/** Inline geometry a snap-back clears so the rail owns placement again. */
const FLOATING_STYLE_PROPERTIES = [
'left',
'top',
@@ -51,7 +43,6 @@ const FLOATING_STYLE_PROPERTIES = [
'bottom',
'width',
'height',
// A docked panel takes the rail's stacking, not a floating window's.
'z-index',
];
@@ -59,7 +50,6 @@ function clamp(value, min, max) {
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) {
return clamp(
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) {
panelEl.style.left = `${left}px`;
panelEl.style.top = `${top}px`;
@@ -80,27 +65,19 @@ function placeAt(panelEl, left, top) {
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) {
const pointerType = event.pointerType || 'mouse';
return pointerType === 'mouse' || pointerType === 'pen';
}
/** Inline geometry a cancelled gesture puts back. */
function geometryOf(panelEl) {
const { left, top, width, height } = panelEl.style;
return { left, top, width, height };
}
export class PanelPositionControls {
/**
* @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.
*/
/** `onPanelResized(panelId)` runs after a portable panel moves, resizes or docks. */
constructor({
syncPanelCollapseButton,
layoutRightPanels,
@@ -117,7 +94,6 @@ export class PanelPositionControls {
this._panelZCounter = PANEL_Z_BASE + 10;
this._draggableResizeObserver = null;
this._cancelDrag = null;
/** Portable panels by id: `{ panel, handle, min: { width, height }, dockOnCollapse }`. */
this._portablePanels = new Map();
this._resizeHandles = [];
this._dragInitialized = false;
@@ -154,10 +130,7 @@ export class PanelPositionControls {
}
}
/**
* 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.
*/
/** Bring a floating window on-screen; shrink it only if it exceeds the viewport. */
_reclampPortablePanel(entry) {
const { panel, min } = entry;
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
* header out of the command dock and the voice pill, measured now because
* 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.
* Clamp a floating window inside the viewport margin with its header above
* the command dock and voice pill, measured each time since the dock resizes.
*/
_clampPortableWindow(
entry,
@@ -224,7 +190,6 @@ export class PanelPositionControls {
return { left: x, top: y };
}
/** A window's size and how far down its header reaches. */
_windowGeometry({ panel, handle }, rect = panel.getBoundingClientRect()) {
const handleRect = handle?.getBoundingClientRect();
const header = handleRect ? handleRect.bottom - rect.top : 0;
@@ -269,9 +234,8 @@ export class PanelPositionControls {
this._dragInitialized = true;
const cctvPanel = document.getElementById('cctv-panel');
const streetLevelPanel = document.getElementById('street-level-panel');
// A `portable` spec lifts out of its rail on a header drag, resizes from
// every edge and remembers its window; the others only reposition in
// place. Another panel opts in by adding a spec here.
// A `portable` panel lifts out of its rail on a header drag, resizes from
// every edge and remembers its window; the others only reposition.
const dragSpecs = [
{
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
// 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
// window grows the same way when its content does (an image opens, a collapsed strip expands).
// the panel could still hang off the bottom (audit U2). Re-clamp on every size change. Floating
// windows grow the same way when their content does.
const observed = [
this._ppToggles,
...[...this._portablePanels.values()].map(({ panel }) => panel),
@@ -403,14 +367,12 @@ export class PanelPositionControls {
const pos = JSON.parse(raw);
if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number')
return;
// A floating record only means something to a panel that can float; a
// portable panel in turn only ever stores floating records.
// Only portable panels store, and accept, floating records.
const portable = this._portablePanels.get(panelId);
if (Boolean(pos.floating) !== Boolean(portable)) return;
if (portable) {
panelEl.classList.add('panel-floating', 'panel-draggable');
// A restored window must stack above the docked rail panels too, not
// only one that was lifted or clicked this session.
// A restored window must stack above the docked rail panels too.
this._promotePanelZ(panelEl);
if (Number.isFinite(pos.width)) {
panelEl.style.width = `${fitWindowExtent(
@@ -504,7 +466,7 @@ export class PanelPositionControls {
if (localStorage.getItem(PANEL_FLOAT_HINT_STORAGE_KEY)) return;
localStorage.setItem(PANEL_FLOAT_HINT_STORAGE_KEY, '1');
} catch {
// storage unavailable: still worth saying once this session
// storage unavailable: still show it once this session
if (this._floatHintShown) return;
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
* currently occupies. Width is frozen so a `width: 100%` rail panel keeps
* its size once fixed-positioned; height stays natural until a resize.
* Turn a rail panel into a fixed window in place. Width is frozen so a
* `width: 100%` panel keeps its size; height stays natural until a resize.
*/
_liftPanelOut(panelId, panelEl, rect = panelEl.getBoundingClientRect()) {
if (panelEl.classList.contains('panel-floating')) return false;
@@ -522,7 +483,6 @@ export class PanelPositionControls {
if (!panelEl.classList.contains('collapsed')) {
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.removeAttribute('aria-hidden');
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
* this. With a `threshold`, a press only becomes a gesture once the pointer
* has travelled that far: `onStart` runs then, and `onMove` (given the
* 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.
* Track one pointer gesture. With a `threshold`, `onStart` and `onMove` wait
* until the pointer has travelled that far; `onEnd`/`onCancel` get whether
* it started. Stopping through `_cancelDrag` calls neither.
*/
_trackPointerGesture(
event,
@@ -665,18 +621,7 @@ export class PanelPositionControls {
window.addEventListener('pointercancel', cancel);
}
/**
* 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.
*/
/** @returns {boolean} Whether the panel was floating and is now docked. */
dockPanel(panelId) {
const portable = this._portablePanels.get(panelId);
if (!portable?.panel.classList.contains('panel-floating')) return false;
@@ -684,18 +629,17 @@ export class PanelPositionControls {
return true;
}
/**
* 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
*/
/** A `dockOnCollapse` panel returns to its rail when collapsed while floating. */
onPanelCollapsed(panelId, collapsed) {
if (!collapsed || !this._portablePanels.get(panelId)?.dockOnCollapse)
return;
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) {
let swallowNextClick = false;
this.listen(
@@ -790,11 +734,7 @@ export class PanelPositionControls {
});
}
/**
* 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.
*/
/** On pointercancel, restore the panel as the gesture found it; save nothing. */
_revertPanelGesture(panelId, panelEl, lifted, before) {
if (lifted) {
this._resetPanelPosition(panelId);
@@ -804,11 +744,7 @@ export class PanelPositionControls {
this._onPanelResized?.(panelId);
}
/**
* 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.
*/
/** Resizing a docked panel lifts it out first, so handles work in both states. */
_makePanelResizable(panelId, panelEl, min = {}) {
const limits = { width: 300, height: 160, ...min };
for (const dir of RESIZE_DIRECTIONS) {
@@ -832,8 +768,8 @@ export class PanelPositionControls {
if (event.button !== 0 || !isPrecisePointer(event)) return;
event.preventDefault();
this._cancelDrag?.();
// Set once the pointer has travelled: a plain click on an edge (or on
// the globe just beside a docked panel) must not lift it out.
// Set once the pointer has travelled: a plain click on an edge must not
// lift the panel out.
let startBox = null;
let lifted = false;
let before = null;
@@ -874,7 +810,7 @@ export class PanelPositionControls {
onStop: () => panelEl.classList.remove('panel-resizing'),
onEnd: (resized) => {
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.
const entry = this._portablePanels.get(panelId);
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');
});
// The left pass commits its top and requests this pass in the same frame, but
// `top` then animates for 150 ms and nothing re-runs the right pass when it
// 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.
// The left rail's `top` animates after the right pass runs, and nothing
// re-runs it when the transition lands: the right rail must read the end value.
function animatingLeftRail({ keyframes = true } = {}) {
const f = fixture('right');
const left = f.options.leftStack;
+3 -21
View File
@@ -1,11 +1,6 @@
/**
* Pure geometry for resizing a floating panel from any edge or corner. The
* DOM wiring lives in PanelPositionControls; this module only decides boxes.
*/
// Pure resize geometry for floating panels; PanelPositionControls wires the DOM.
/** Viewport margin a resized panel keeps clear on every side. */
const EDGE_MARGIN_PX = 6;
/** Resize directions: compass edges first, then corners. */
export const RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'sw', 'se'];
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
* the pointer; the opposite edge stays put, so growing from the left keeps
* 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}}
* Edges named in `dir` follow the pointer (dx, dy since gesture start) and the
* opposite edge stays put. Minimum size and the viewport margin win.
*/
export function resizeBox(
box,
+4 -12
View File
@@ -12,16 +12,9 @@ import { displayPanelScroller } from './displayPanelScroll.js';
const pendingCollapseRetries = new WeakSet();
/**
* Read where the left rail's top edge is going to settle, not where it is.
* The left pass commits `--left-stack-safe-top` and requests this pass in the
* same frame, but `top` then animates; nothing re-runs this pass when the
* 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.
* Where the left rail's top will settle, not where it is mid-transition:
* nothing re-runs this pass when the `top` animation ends (ResizeObserver
* ignores moves). Returns NaN when there is no left rail.
*/
function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) {
const top = leftStack?.getBoundingClientRect().top;
@@ -76,8 +69,7 @@ export function layoutRightPanelRail({
// A collapse may schedule one follow-up, which only measures and allocates.
const isCollapseRetry = pendingCollapseRetries.delete(stack);
// A panel lifted out of the rail (PanelPositionControls' portable mode)
// is a fixed window the rail neither measures nor allocates.
// A floating (lifted-out) panel is not laid out by the rail.
const panels = [...stack.children].filter(
(panel) =>
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 FOCUSABLE =
'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({
ArrowRight: 1,
ArrowDown: 1,
@@ -26,11 +25,8 @@ const setAttr = (node, key, value) => {
};
/**
* Own the Street Level panel: the provider chips, the imagery filters, the
* legend and the embedded street-level viewer. The panel itself is
* 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.
* Fills the Street Level panel body: provider chips, filters, legend and
* viewer. The panel chrome itself belongs to the application shell.
*/
export class StreetLevelControls {
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
// state being restored, not a user switching the layer on.
this._restoreWindow = true;
/**
* Whether the last request to switch the layer on came from the user
* (or voice, or a tool) rather than a restore; null when the shell does
* not report requests, and the restore window decides instead.
*/
// Whether the last switch-on was explicit (user, voice, tool) rather than
// a restore; null when the shell does not report request origins.
this._explicitEnable = null;
this._unsubscribeEnableRequests = null;
this._resizeQueued = false;
this._wrapHome = null;
this._expandReturnFocus = null;
/**
* The <body> children this class made inert while the expanded viewer is
* a modal on screen; only these are released again.
* @type {Set<Element>}
*/
// Only the <body> children this class made inert are released again.
this._inerted = new Set();
this._modalObserver = null;
this._elements = this._collect();
@@ -111,7 +100,6 @@ export class StreetLevelControls {
this.layer.attachViewerHost?.(el.viewer);
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) => {
const button = event.target?.closest?.('.data-toggle-chip');
if (!button || button.disabled) return;
@@ -185,11 +173,7 @@ export class StreetLevelControls {
}
}
/**
* 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.
*/
/** ARIA radio keys. Selecting clicks the radio, so keys and pointer share a path. */
_bindRadioGroup(buttons) {
buttons.forEach((button, index) => {
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
* viewer measures 0×0 and would ask for tiles at z=NaN; the observer fires
* again once it has a size.
* Refit the viewer once per frame. Skips a hidden (0×0) viewer, which would
* ask for tiles at z=NaN; the observer fires again once it has a size.
*/
_requestResize() {
if (this._resizeQueued) return;
@@ -247,11 +230,7 @@ export class StreetLevelControls {
}
}
/**
* 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`.
*/
/** Through the data manager, so saved state and share links record it. */
_setParams(params) {
if (this.actions.setParams)
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
* with that provider; darkening the last lit one turns the layer off (the
* provider stays switched on, so the layer comes back with it).
* Lighting a chip turns the layer on; darkening the last lit one turns the
* layer off but keeps the provider on, so the layer comes back with it.
*/
async _toggleProvider(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());
}
/** The panel window was resized or docked again: refit the viewer. */
onPanelResized() {
this._requestResize();
}
/** Ask the shell to open (or close) the rail panel. */
setCollapsed(collapsed, options = {}) {
this.actions.setPanelCollapsed?.(collapsed, options);
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 at its
* default size, so the viewer does not land in a window over the globe.
* A user's shrink (`dock`) also returns a floating panel to its rail, so the
* viewer does not land in a window over the globe.
*/
setViewerExpanded(expanded, { dock = false } = {}) {
const wrap = this._elements.viewerWrap;
@@ -384,10 +359,7 @@ export class StreetLevelControls {
target?.focus?.({ preventScroll: true });
}
/**
* The expanded viewer holds Esc and Tab only while it is on screen: Clean
* View, recording and the cockpit hide it with CSS.
*/
/** Clean View, recording and the cockpit hide the expanded viewer with CSS. */
_isDialogShown() {
if (!this.isViewerExpanded()) return false;
const wrap = this._elements.viewerWrap;
@@ -402,11 +374,9 @@ export class StreetLevelControls {
}
/**
* Make the application modal-inert while the expanded viewer is on screen:
* every other <body> child is `inert` (one already inert is left alone), so
* no control around the viewer takes a click or focus. A viewer that CSS
* hides (Clean View, recording, cockpit) releases them, since a hidden
* modal must not leave the app dead. Returns whether the dialog is shown.
* Make every other <body> child inert while the expanded viewer is shown.
* A CSS-hidden viewer releases them so a hidden modal never leaves the app
* dead. Returns whether the dialog is shown.
*/
_syncModal() {
if (this.destroyed || !this._isDialogShown()) {
@@ -422,17 +392,12 @@ export class StreetLevelControls {
return true;
}
/** Give the application back every node `_syncModal` made inert. */
_releaseModal() {
for (const node of this._inerted) node.inert = false;
this._inerted.clear();
}
/**
* 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.
*/
/** Re-check the modal when <body> changes class or gains a child. */
_watchModal(on) {
this._modalObserver?.disconnect();
this._modalObserver = null;
@@ -534,7 +499,6 @@ export class StreetLevelControls {
setProp(el.errorText, 'textContent', view.error || '');
}
/** Mark the checked radio of a group and make it the group's tab stop. */
_renderRadios(buttons, isChecked) {
for (const button of buttons) {
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
* never persisted: the stored collapse state stays the user's own choice.
* Open the panel on a switch-on or when an image opens. Never persisted:
* the stored collapse state stays the user's own choice.
*/
_reactToTransitions(view, state) {
const enabled = view.enabled;
if (enabled && this._wasEnabled === false) {
// A restore that turns the saved layer back on must not reopen a panel
// the user (or a share link) chose to keep collapsed. With the shell's
// request origins the panel opens only for a user, voice or tool
// switch-on; without them, the restore window stands in.
// A restore must not reopen a panel the user or a share link kept
// collapsed. Without request origins, the restore window decides.
const preference = this.root.dataset?.collapsedPreference;
const restoring =
this._explicitEnable === null
@@ -650,10 +612,8 @@ export class StreetLevelControls {
this._explicitEnable = this._explicitEnable === null ? null : false;
}
this._wasEnabled = enabled;
// Size changes reach the viewer through the ResizeObserver; resizing on
// every render ran each frame while a panorama was dragged. Opening is the
// one moment the element may not have reported a size yet, and opening
// the panel asks for that one (coalesced) resize.
// Other size changes reach the viewer through the ResizeObserver; opening
// asks for one resize because the element may not have a size yet.
const open = state.street.open === true;
if (open && !this._wasStreetOpen)
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 ───────────────────── */
/**
* 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).
*/
/** Every write a page would see as a mutation, even one to the same value. */
const mutations = { count: 0 };
const REFLECTED = ['hidden', 'disabled', 'textContent', 'title', 'value'];
/** The reflected properties a MutationObserver sees, as the record it gets. */
@@ -27,10 +24,8 @@ const REFLECTED_RECORDS = {
textContent: { type: 'childList' },
};
/** The fake MutationObservers currently observing (see fakeMutationObserver). */
const liveObservers = new Set();
/** Queue a mutation record for every observer whose options cover it. */
function recordMutation(target, { type, attributeName = null }) {
for (const observer of liveObservers)
observer.consider(target, type, attributeName);
@@ -116,10 +111,7 @@ class FakeNode {
const siblings = this.parent.children;
return siblings[siblings.indexOf(this) + 1] || null;
}
/**
* 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.
*/
/** Null when this node or an ancestor is hidden or disconnected. */
get offsetParent() {
if (!this.isConnected) return null;
for (let node = this; node; node = node.parent)
@@ -388,11 +380,7 @@ const memoryStorage = () => {
};
};
/**
* 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.
*/
/** The production path: controls → dataManager → layer → durable layer state. */
async function productionPanel(dom) {
const provider = standInProvider();
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]);
}));
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) => {
const { layer } = stubPanel(dom, uiState());
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');
}));
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) => {
const { layer } = stubPanel(dom, uiState());
await settle();
@@ -714,7 +702,7 @@ test('an image closed while expanded does not return focus into the hidden viewe
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)', () =>
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
* its `observe` options cover (childList, attributes / attributeFilter,
* subtree) and delivers them in a microtask, only when there are some;
* `fire()` delivers what is queued at once.
* A MutationObserver stand-in: queues only the records its options cover and
* delivers them in a microtask; `fire()` delivers at once.
*/
function fakeMutationObserver() {
const observers = [];
@@ -888,7 +874,7 @@ const inertOutside = (dom) =>
.filter((node) => node.id !== 'sl-viewer-wrap')
.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(() =>
withDom(async (dom) => {
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) =>
withDom(async (dom) => {
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(() =>
withDom(async (dom) => {
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
+7 -26
View File
@@ -1,17 +1,10 @@
import { keySetupRequirement } from '../keySetupCore.mjs';
/**
* 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.
*/
// Street Level UI state to panel strings and flags. Pure, so it is testable.
const DAY_MS = 86_400_000;
/**
* 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.
*/
/** SINCE slider stops, in relative days so a share link keeps its meaning. */
export const SINCE_STOPS = Object.freeze([
Object.freeze({ days: 0, label: 'ANY DATE' }),
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' }),
]);
/** 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) {
const value = Number(days) || 0;
if (value <= 0) return 0;
@@ -38,7 +31,6 @@ export function sinceStopIndex(days) {
return best;
}
/** Readout beside the slider: the window, plus the cut-off date it means today. */
function presentSince(days, now) {
const value = Number(days) || 0;
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) {
const pressed = state.enabled === true;
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
@@ -76,11 +67,7 @@ function presentStatus(state) {
: { text: 'OFF', tone: '', pressed, title };
}
/**
* 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.
*/
/** A chip is lit only while the layer and its provider are both on. */
function presentProviders(state) {
const enabled = state.enabled === true;
return (state.providers || []).map((provider) => {
@@ -148,18 +135,13 @@ function presentMeta(state) {
return state.coverage.hint || '';
}
/**
* @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.
*/
/** @param {{now?: number}} [options] Clock for the SINCE readout. */
export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
const enabled = state.enabled === true;
const keyRequired = state.keyRequired === true;
const filter = state.filter || { pano: 'all', sinceDays: 0 };
// KEY REQUIRED already says it, and the chip's tooltip says how to fix it;
// the error under it would only repeat a raw code (`no_key`). A rejected
// key keeps its line: that message names the fix.
// A missing key is already shown by the status and chip tooltip; only a
// rejected key keeps its error line, since that message names the fix.
const keyMissing = keyRequired && state.keyRejected !== true;
return {
enabled,
@@ -175,7 +157,6 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
legend: state.legend || [],
viewer: presentViewer(state),
meta: presentMeta(state),
/** The panel opens itself when an image opens (a native panel stays put otherwise). */
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');
});
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(
snapshot({
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 /);
});
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 =
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
const view = presentStreetLevelPanel(
+5 -9
View File
@@ -104,10 +104,8 @@
}
/* ── Portable rail panels ────────────────────
A header drag lifts a portable panel (PanelPositionControls) out of its
rail into a fixed window that remembers its place and size. The rail's
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). */
Rail placement in layers.css is guarded with :not(.panel-floating), so
these rules only have to beat a panel's base placement (one id). */
.panel-portable .panel-header {
cursor: grab;
}
@@ -117,16 +115,14 @@
#right-context-rail > .panel-floating {
position: fixed;
inset: auto;
/* Above every docked rail panel (#pp-toggles is 110). PanelPositionControls
promotes floating windows from this floor (PANEL_Z_FLOATING_FLOOR). */
/* Above every docked rail panel; keep in step with PANEL_Z_FLOATING_FLOOR. */
z-index: 111;
max-height: calc(100vh - 12px);
margin: 0;
flex: none;
pointer-events: auto;
}
/* A collapsed window keeps its stored height for later but shows only its
header strip, so the resize handles have nothing to attach to. */
/* A collapsed window shows only its header strip; its inline height stays. */
#right-context-rail > .panel-floating.collapsed {
max-height: max-content;
}
@@ -1110,7 +1106,7 @@ body.ui-clean-view #cctv-panel,
body.ui-clean-view #radio-panel,
body.ui-clean-view #global-context-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 {
opacity: 0 !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 #gev-voice-control,
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 {
opacity: 0 !important;
visibility: hidden !important;
+14 -25
View File
@@ -1,7 +1,6 @@
/* ── Street Level panel ─────────────────────────────────────────
Lives in the right context rail next to CCTV; the rail owns position and
width (see layers.css). Everything here is panel-internal. The expanded
viewer is re-parented to <body>, so it carries the tokens too. */
The rail owns position and width (layers.css). The expanded viewer is
re-parented to <body>, so it carries the tokens too. */
#street-level-panel,
.sl-viewer-wrap {
--sl-green: #05cb63;
@@ -111,8 +110,6 @@ button.sl-status:focus-visible {
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 {
display: flex;
flex-direction: column;
@@ -128,8 +125,7 @@ button.sl-status:focus-visible {
pointer-events: none;
}
/* Everything but the viewer. Docked, the whole panel scrolls; in a resized
window the viewer keeps its room and only this block scrolls. */
/* In a resized window only this block scrolls; the viewer keeps its room. */
.sl-settings {
display: flex;
flex-direction: column;
@@ -151,8 +147,7 @@ button.sl-status:focus-visible {
gap: 4px;
}
/* One colour per source: a swatch dot, and the whole chip in that colour
while the source is on. `--chip-color` comes from the provider. */
/* `--chip-color` comes from the provider. */
.sl-chips .data-toggle-chip {
display: inline-flex;
align-items: center;
@@ -246,7 +241,6 @@ button.sl-status:focus-visible {
color: var(--text-primary);
}
/* SINCE: a stepped slider, oldest window left, newest right. */
.sl-since {
display: grid;
grid-template-columns: auto 1fr;
@@ -318,8 +312,7 @@ button.sl-status:focus-visible {
display: none;
}
/* The image stage: the viewer and its loading veil, nothing on top of it,
so the provider's own controls (sequence bar, zoom, bearing) stay clear. */
/* Nothing sits over the image, so the provider's own controls stay clear. */
.sl-viewer-stage {
position: relative;
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 {
display: flex;
align-items: center;
@@ -485,16 +477,15 @@ button.sl-status:focus-visible {
}
/* ── Floating window ─────────────────────────────────────────────────────── */
/* A window that was only dragged keeps its natural height; bound it to the
viewport so nothing runs off-screen (the rail rule's max-height: 100% does
not bind an auto-height parent). Id-qualified to outrank that rule. */
/* A dragged window keeps its natural height, so bound it to the viewport:
the rail's max-height: 100% does not bind an auto-height parent.
Id-qualified to outrank that rule. */
#right-context-rail > #street-level-panel.panel-floating > .street-level-panel-inner {
max-height: calc(100vh - 12px);
overflow-y: auto;
}
/* A resized window (PanelPositionControls writes an inline height) hands the
spare height to the viewer, so the image never needs scrolling to. */
/* A resized window (inline height) gives its spare height to the viewer. */
.panel-floating[style*='height'] > .street-level-panel-inner {
box-sizing: border-box;
height: 100%;
@@ -532,9 +523,8 @@ button.sl-status:focus-visible {
}
/* ── Expanded viewer ─────────────────────────────────────────────────────── */
/* The panel inner's backdrop-filter makes it the containing block for fixed
descendants, so the expanded viewer is re-parented to <body>. z-index sits
above panels (≤139) and below the voice pill (150) and toasts (200). */
/* Re-parented to <body>: the panel's backdrop-filter would contain a fixed
child. z-index sits above panels (≤139), below the voice pill (150). */
.sl-viewer-wrap-expanded {
position: fixed;
left: 4vw;
@@ -604,10 +594,9 @@ button.sl-status:focus-visible {
}
/* ── Phones ──────────────────────────────────────────────────────────────── */
/* At this width the right rail is the band between mid-screen and the credit
line (responsive.css: top 50vh + 8px, bottom 2vh + 7.5rem). Size the photo
from that band, less the panel's header, tool bar and caption (~150px), so
the whole image shows without scrolling; EXPAND still gives full screen. */
/* The phone rail spans 50vh + 8px to 2vh + 7.5rem from the bottom
(responsive.css). Size the photo to that band, less ~150px of panel chrome,
so it shows without scrolling. */
@media (max-width: 720px) {
.sl-viewer {
aspect-ratio: auto;
+3 -4
View File
@@ -71,8 +71,7 @@
</div>
</div>
<!-- Street Level (Mapillary) Panel — moved into #right-context-rail at
runtime next to CCTV (see PanelLayoutController). -->
<!-- Street Level Panel (moved into #right-context-rail at runtime) -->
<div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel">
<div class="panel-glow"></div>
<div class="street-level-panel-inner" data-rail-scroller>
@@ -116,8 +115,8 @@
<div id="sl-provider-chips" class="sl-chips"></div>
</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
stay focusable so their tooltip can say how to add the key. -->
<!-- A missing key gates the filters only: the chips above stay
focusable so their tooltip can say how to add the key. -->
<fieldset id="sl-controls" class="sl-controls">
<div class="sl-filters" role="group" aria-label="Imagery filters">
<span class="cctv-summary-label">IMAGERY</span>