feat(street-level): Google Street View as the second imagery provider

Google Street View registers beside Mapillary as provider `google`. It runs
on the Maps JavaScript API with the browser GOOGLE_MAPS_API_KEY, loaded on
first use (mapsLoader.js), and uses Google's own StreetViewPanorama as its
viewer adapter, emitting provider-neutral poses (address and month in the
caption, a link to Google Maps, steps along Google's arrows, FOLLOW on
Google 3D). Its chip starts off because every panorama is billed to the
key; the share option is `g`.

Google offers no coverage layer for other maps, so the provider draws
none: with it on, a click on bare ground at street zoom opens its nearest
Google-collected outdoor panorama (groundClick.js). A key Google refuses
reads KEY REJECTED and fails a pending open at once; a lookup Google never
answers fails after 10 s.

Core: providers may declare `groundClick` and `defaultOn`; poses may carry
`capturedAtPrecision` and `title`; the coverage line keeps every
provider's hint; opening one provider's image releases another's
prewarmed viewer; FIT/FILL hides for viewers without render modes. CSP
script-src admits https://maps.googleapis.com. The browser gate gains two
Street View steps (the keyed one skips as 'no Google key' in CI).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-08 02:32:06 +01:00
1 parent 95fa816232
commit 22b0009138
42 files changed
+1876 -63

No files matched your search

+2 -1
View File
@@ -18,7 +18,8 @@
# (they're used client-side) and WILL be visible in devtools. Restrict/scope them rather than trying to hide # (they're used client-side) and WILL be visible in devtools. Restrict/scope them rather than trying to hide
# them (see SECURITY.md). All other keys below stay server-side. # them (see SECURITY.md). All other keys below stay server-side.
# Optional: direct Google Photorealistic 3D Tiles and GEV place search. # Optional: direct Google Photorealistic 3D Tiles, GEV place search and Street
# View (Street View also needs the Maps JavaScript API enabled for the key).
# Without it, a Cesium ion token can still load ion-hosted Google 3D; with # Without it, a Cesium ion token can still load ion-hosted Google 3D; with
# neither credential, the app starts on keyless Esri World Imagery with OSM # neither credential, the app starts on keyless Esri World Imagery with OSM
# available in the map tray and as the automatic provider-failure fallback. # available in the map tray and as the automatic provider-failure fallback.
+7
View File
@@ -63,6 +63,13 @@
## [Unreleased] ## [Unreleased]
- Street Level: Google Street View is the second provider. Light its chip
(off by default: every panorama is billed to the Google Maps key, which
needs the Maps JavaScript API) and click a street to open Google's own
panorama in the panel, with its arrows, the address and month in the
caption, a link to Google Maps and FOLLOW on Google 3D. Share links carry
it as `g`.
- Stdio servers from one install share a panel key so a page read from one - Stdio servers from one install share a panel key so a page read from one
process can make requests through another. Concurrent malformed-key repairs process can make requests through another. Concurrent malformed-key repairs
now select one winner; unavailable storage or a busy repair retains the now select one winner; unavailable storage or a busy repair retains the
+1
View File
@@ -21,6 +21,7 @@ How to read this:
| **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" | | **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" |
| **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" | | **NOAA NHC / CPHC advisories** (`www.nhc.noaa.gov/CurrentStorms.json` + the `mapservices.weather.noaa.gov` tropical weather summary MapServer) | Cyclone advisories: storm positions, forecast tracks, points and cones for the Atlantic and eastern/central North Pacific. Keyless through `/api/cyclones`; cached for 5 minutes | [NWS public-data terms](https://www.weather.gov/disclaimer); no endorsement implied | "NOAA/NWS NHC / CPHC" |
| **Mapillary** (vector tiles, Graph API, MapillaryJS) | Street Level: coverage lines, image cones and the embedded viewer. Coverage tiles cached for 24 h | Imagery [CC BY-SA 4.0](https://www.mapillary.com/terms); API under the Mapillary terms with a client token | "© Mapillary contributors, CC BY-SA 4.0", shown on the globe while the provider is on | | **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 |
| **Google Street View** (Maps JavaScript API) | Street Level: Google's panorama viewer and nearest-panorama lookups; nothing cached | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer |
| **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors | | **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors |
| **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required | | **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required |
| **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org | | **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org |
+6 -6
View File
@@ -296,7 +296,7 @@ Twenty layers and map sources. **Seventeen have a keyless path.** Some offer add
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds | | 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
| 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 | | 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary first; Panoramax, KartaView and Google Street View planned | Mapillary | 🟡 Mapillary client token | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer and **FOLLOW**. Mapillary and Google Street View; Panoramax and KartaView planned | Mapillary · Google | 🟡 Mapillary token · 🔴 Google Maps key |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 | | 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland | Operator GTFS-Realtime feeds | 🟢 |
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
@@ -334,13 +334,13 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
## 📷 Street Level ## 📷 Street Level
> Street-level photos from any provider in one panel. This build ships **Mapillary**, which needs a free client token. > Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token) and **Google Street View** (your Google Maps key).
**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**. **Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key.
**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. **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. With **STREET VIEW** lit, click a street (from below 3 km) to open Google's panorama there, and step along its arrows. Above the photo: **EXPAND**, **FIT / FILL**, and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while Mapillary is on.
Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`. 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.g.1` Street View on, `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
## 🎖️ Field Missions ## 🎖️ Field Missions
@@ -428,7 +428,7 @@ Seven keys. Five have a free tier, and the two 🔴 ones are metered:
| | Key | Why | Get it | | | Key | Why | Get it |
| --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🟡 | **Cesium ion** | 🗺️ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) — use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) | | 🟡 | **Cesium ion** | 🗺️ Google Photorealistic 3D, world terrain, and additional ion-hosted imagery stacks. The free Community plan is for eligible individual, personal/non-commercial use and has quotas | [cesium.com/ion](https://cesium.com/ion) — use a public `assets:read` token and check current [pricing/eligibility](https://cesium.com/platform/cesium-ion/pricing/) |
| 🔴 | **Google Maps** | Direct Google Photorealistic 3D + Google place search ([Map Tiles API](https://developers.google.com/maps/documentation/tile)) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it | | 🔴 | **Google Maps** | Direct Google Photorealistic 3D, Google place search and Street View ([Map Tiles API](https://developers.google.com/maps/documentation/tile); Street View also needs the Maps JavaScript API) | [Google Cloud Console](https://console.cloud.google.com/) — URL-restrict it |
| 🔴 | **OpenAI** | 🎙️ The voice experience + AI HUD summary. The mini model works; the standard model is noticeably smarter. Want Gemini or another provider behind the mic? PRs welcome | [platform.openai.com](https://platform.openai.com) — metered, see costs below | | 🔴 | **OpenAI** | 🎙️ The voice experience + AI HUD summary. The mini model works; the standard model is noticeably smarter. Want Gemini or another provider behind the mic? PRs welcome | [platform.openai.com](https://platform.openai.com) — metered, see costs below |
| 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup | | 🟡 | **AISStream** | 🚢 Live global ships | [aisstream.io](https://aisstream.io) — free signup |
| 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free | | 🟡 | **NASA FIRMS** | 🔥 Live active fires | [firms.modaps.eosdis.nasa.gov](https://firms.modaps.eosdis.nasa.gov/api/map_key/) — free |
+2 -2
View File
@@ -26,7 +26,7 @@ The golden rule: **secret-bearing API keys stay on the server side.** The dev/pr
These are designed to be used directly in the browser (like a Mapbox public token). They are injected into the client bundle via Vite's `define`, so they **will** be visible in browser devtools. Scope and restrict them rather than trying to hide them: These are designed to be used directly in the browser (like a Mapbox public token). They are injected into the client bundle via Vite's `define`, so they **will** be visible in browser devtools. Scope and restrict them rather than trying to hide them:
1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search. **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. 1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search and Street View (Maps JavaScript API). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you.
2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota. 2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota.
3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy. Mapillary client tokens are meant for browser use. 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.
@@ -73,7 +73,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky). - **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing. - **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, 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. - **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control.
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements. - **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
## Network exposure — the operator threat model ## Network exposure — the operator threat model
+3 -2
View File
@@ -20,8 +20,9 @@ import { panelBuildPlugin } from './panel.js';
export const BROWSER_CSP = [ export const BROWSER_CSP = [
"default-src 'self'", "default-src 'self'",
// The Bhote Koshi event's embedded media: the YouTube player API, the // The Bhote Koshi event's embedded media: the YouTube player API, the
// Facebook video SDK and X's post widget (src/data/bhoteKoshiEmbeddedMedia.js). // Facebook video SDK and X's post widget (src/data/bhoteKoshiEmbeddedMedia.js);
"script-src 'self' 'unsafe-eval' blob: https://www.youtube.com https://connect.facebook.net https://platform.twitter.com", // and the Maps JavaScript API that Street Level's Google Street View runs on.
"script-src 'self' 'unsafe-eval' blob: https://www.youtube.com https://connect.facebook.net https://platform.twitter.com https://maps.googleapis.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
"font-src 'self' data: https://fonts.gstatic.com", "font-src 'self' data: https://fonts.gstatic.com",
"img-src 'self' data: blob: https:", "img-src 'self' data: blob: https:",
+20 -4
View File
@@ -3033,8 +3033,9 @@ 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 meaning), one viewer host and one on-globe credit per active provider. Each
provider draws in one colour (`PROVIDER_COLORS` in provider draws in one colour (`PROVIDER_COLORS` in
`src/layers/streetLevel/policy.js`); 360° cones are rings and the selected `src/layers/streetLevel/policy.js`); 360° cones are rings and the selected
sequence is GEV cyan. Share options: `m` (Mapillary), `p` (`a`/`p`/`f` sequence is GEV cyan. Share options: `m` (Mapillary), `g` (Google Street
panoramas), `s` (since, days). Only Mapillary is registered. Without View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary and Google
Street View are registered. Without
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are `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 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 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
@@ -3080,8 +3081,23 @@ manages each provider's credit and fans the filter out to every provider. To
add a provider: implement the definition, register it in add a provider: implement the definition, register it in
`src/app/layers/streetLevel.js`, add its boolean option to the `street-level` `src/app/layers/streetLevel.js`, add its boolean option to the `street-level`
group in `src/data/layerState.js` and its modules to group in `src/data/layerState.js` and its modules to
`scripts/package-boundaries.json`. Once a keyless provider registers, the `scripts/package-boundaries.json`. With providers on different keys the
layer's `requiresKeyId` becomes null and the key gate moves to the chips. layer's `requiresKeyId` is null and each chip reports its own key.
Google Street View (`providers/google/`) runs on the Maps JavaScript API with
the browser `GOOGLE_MAPS_API_KEY`, which needs that API enabled; it is loaded
on first use (`mapsLoader.js`, CSP `script-src https://maps.googleapis.com`).
Its chip starts off (`defaultOn: false`, option default `g` off) because every
panorama load is billed to the key. Google offers no coverage layer for
other maps, so it draws none: with it on, a click on bare ground (nothing
picked, so a Mapillary line under the pointer wins; no tool holding the
pointer; camera below 3 km) opens its nearest
Google-collected outdoor panorama within 50 m (`groundClick.js`). The viewer
is Google's `StreetViewPanorama` in its own element in the viewer host,
without Google's fullscreen, close and address controls (the address goes in
the caption); its poses carry the month the panorama was taken. A key Google
refuses (`gm_authFailure`) reads KEY REJECTED and fails a pending open at
once. The gate's Street View steps skip with `no Google key` in CI.
Street Level has two browser gates. `npm run qa:street-level -- --url <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 runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
+1
View File
@@ -263,6 +263,7 @@
"./layers/street-level": "./src/layers/streetLevel/index.js", "./layers/street-level": "./src/layers/streetLevel/index.js",
"./layers/street-level/mapillary": "./src/layers/streetLevel/providers/mapillary/index.js", "./layers/street-level/mapillary": "./src/layers/streetLevel/providers/mapillary/index.js",
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js", "./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js",
"./layers/street-level/google": "./src/layers/streetLevel/providers/google/index.js",
"./tools": "./src/tools/index.js", "./tools": "./src/tools/index.js",
"./tools/mcp": "./src/tools/mcp/index.js", "./tools/mcp": "./src/tools/mcp/index.js",
"./tools/panel": "./src/tools/panel.js", "./tools/panel": "./src/tools/panel.js",
+10
View File
@@ -893,6 +893,16 @@
"src/layers/streetLevel/selection.js", "src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js", "src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/providers/mapillary/source.js", "src/layers/streetLevel/providers/mapillary/source.js",
"src/layers/streetLevel/groundClick.js",
"src/layers/streetLevel/providers/google/index.js",
"src/layers/streetLevel/providers/google/policy.js",
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/groundClick.test.mjs",
"src/layers/streetLevel/providers/google/provider.test.mjs",
"src/layers/streetLevel/providers/google/viewer.test.mjs",
"src/layers/streetLevel/providers/google/mapsLoader.test.mjs",
"src/testSupport/googleStreetViewFakes.mjs",
"server/providers/mapillary.js", "server/providers/mapillary.js",
"server/providers/mapillary/routes.js", "server/providers/mapillary/routes.js",
"server/providers/mapillary/constants.js", "server/providers/mapillary/constants.js",
+17 -1
View File
@@ -1188,7 +1188,8 @@
"exports": [ "exports": [
"./layers/street-level", "./layers/street-level",
"./layers/street-level/mapillary", "./layers/street-level/mapillary",
"./layers/street-level/mapillary/source" "./layers/street-level/mapillary/source",
"./layers/street-level/google"
], ],
"modules": [ "modules": [
"src/layers/streetLevel/index.js", "src/layers/streetLevel/index.js",
@@ -1216,6 +1217,11 @@
"src/layers/streetLevel/tileMath.js", "src/layers/streetLevel/tileMath.js",
"src/layers/streetLevel/selection.js", "src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js", "src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/groundClick.js",
"src/layers/streetLevel/providers/google/index.js",
"src/layers/streetLevel/providers/google/policy.js",
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/providers/mapillary/source.js", "src/layers/streetLevel/providers/mapillary/source.js",
"scripts/qa-street-level.mjs" "scripts/qa-street-level.mjs"
], ],
@@ -2164,6 +2170,11 @@
"src/layers/streetLevel/tileMath.js", "src/layers/streetLevel/tileMath.js",
"src/layers/streetLevel/selection.js", "src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js", "src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/groundClick.js",
"src/layers/streetLevel/providers/google/index.js",
"src/layers/streetLevel/providers/google/policy.js",
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/providers/mapillary/source.js", "src/layers/streetLevel/providers/mapillary/source.js",
"src/app/layers/streetLevel.js", "src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js", "src/layers/alpr/tileRecords.js",
@@ -2686,6 +2697,11 @@
"src/layers/streetLevel/tileMath.js", "src/layers/streetLevel/tileMath.js",
"src/layers/streetLevel/selection.js", "src/layers/streetLevel/selection.js",
"src/layers/streetLevel/providers/mapillary/viewer.js", "src/layers/streetLevel/providers/mapillary/viewer.js",
"src/layers/streetLevel/groundClick.js",
"src/layers/streetLevel/providers/google/index.js",
"src/layers/streetLevel/providers/google/policy.js",
"src/layers/streetLevel/providers/google/viewer.js",
"src/layers/streetLevel/providers/google/mapsLoader.js",
"src/layers/streetLevel/providers/mapillary/source.js", "src/layers/streetLevel/providers/mapillary/source.js",
"src/app/layers/streetLevel.js", "src/app/layers/streetLevel.js",
"src/layers/alpr/tileRecords.js", "src/layers/alpr/tileRecords.js",
+182 -11
View File
@@ -49,18 +49,27 @@ export const VIEWPORTS = Object.freeze([
]); ]);
/** Provider chips the panel must show, in order. */ /** Provider chips the panel must show, in order. */
export const EXPECTED_PROVIDERS = Object.freeze(['mapillary']); export const EXPECTED_PROVIDERS = Object.freeze(['mapillary', 'google']);
/** /**
* Skips no setup can avoid: CI forks have no Google key, and live runs cannot * Skips no setup can avoid: CI forks have no Google key (so neither Google 3D
* stage fixture-only cases. `no Mapillary key` is not accepted: CI's dummy * nor Street View), and live runs cannot stage fixture-only cases. `no
* token runs the keyed flow, so a keyless strict run means that wiring broke. * Mapillary key` is not accepted: CI's dummy token runs the keyed flow, so a
* keyless strict run means that wiring broke. Nor is `Google key refused`: a
* key that is set must work.
*/ */
export const STRICT_ALLOWED_SKIPS = Object.freeze([ export const STRICT_ALLOWED_SKIPS = Object.freeze([
'no Google 3D', 'no Google 3D',
'no Google key',
'fixtures only', 'fixtures only',
]); ]);
/** A Sacramento street with Google Street View (10th St, by the Capitol). */
export const STREET_VIEW_STREET = Object.freeze({
lon: -121.49372,
lat: 38.57986,
});
export function strictViolations(skips) { export function strictViolations(skips) {
return skips.filter((skip) => !STRICT_ALLOWED_SKIPS.includes(skip.reason)); return skips.filter((skip) => !STRICT_ALLOWED_SKIPS.includes(skip.reason));
} }
@@ -644,13 +653,16 @@ async function main() {
].map((chip) => chip.dataset.chipId), ].map((chip) => chip.dataset.chipId),
); );
assert.deepEqual(chips, EXPECTED_PROVIDERS); assert.deepEqual(chips, EXPECTED_PROVIDERS);
// One swatch per source plus "Selected"; each chip wears its colour. // One swatch per switched-on source plus "Selected" (Street View
assert.equal( // starts off); each chip wears its colour.
await page.evaluate( const legend = await page.evaluate(() => ({
() => document.querySelectorAll('#sl-legend li').length, swatches: document.querySelectorAll('#sl-legend li').length,
), on: window.__godsEyeView.dataManager.layers
EXPECTED_PROVIDERS.length + 1, .get('street-level')
); .module.getUIState()
.providers.filter((provider) => provider.on).length,
}));
assert.equal(legend.swatches, legend.on + 1);
assert.equal( assert.equal(
await page.$eval('[data-chip-id="mapillary"]', (chip) => await page.$eval('[data-chip-id="mapillary"]', (chip) =>
chip.style.getPropertyValue('--chip-color'), chip.style.getPropertyValue('--chip-color'),
@@ -1715,6 +1727,165 @@ async function main() {
); );
}, },
); );
const googleChip = '#sl-provider-chips [data-chip-id="google"]';
const googleKey = await page.evaluate(() =>
Boolean(window.__GOOGLE_MAPS_API_KEY__),
);
await step(
'Street View starts off, lights from its chip, and travels in the share link',
async () => {
const read = () =>
page.$eval(
'#sl-provider-chips [data-chip-id="google"]',
(node) => ({
active: node.classList.contains('active'),
className: node.className,
title: node.title,
color: node.style.getPropertyValue('--chip-color'),
}),
);
const off = await read();
assert.equal(
off.active,
false,
'off until lit: panoramas are billed',
);
assert.equal(off.color, '#4285f4');
await clickPanelControl(page, googleChip);
await uiUntil(
(u) => u.providers.find((p) => p.id === 'google')?.on === true,
);
const lit = await read();
if (googleKey) assert.match(lit.className, /chip-active/);
else {
assert.match(lit.className, /chip-error/, 'no key: an error chip');
assert.match(lit.title, /GOOGLE_MAPS_API_KEY/);
}
const option = `${STREET_LEVEL_TOKEN}.g.1`;
assert.ok(
await settle(
page,
(token) =>
(new URLSearchParams(location.hash.slice(1)).get('lo') || '')
.split('_')
.includes(token),
option,
),
`the share link carries ${option}`,
);
assert.ok((await ui()).coverage.count > 0, 'Mapillary still draws');
},
);
await step(
'a click on a street opens Google Street View, captioned and linked, and its view drives the pose',
async ({ skip }) => {
if (!googleKey) return skip('no Google key');
// A Mapillary line under the pointer wins the click: aim at bare
// ground with only Street View on.
await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.closeViewer(),
);
await clickPanelControl(page, chip);
await uiUntil(
(u) => u.providers.find((p) => p.id === 'mapillary')?.on === false,
);
const street = STREET_VIEW_STREET;
await page.evaluate((at) => {
const v = window.__godsEyeView.viewer;
v.camera.cancelFlight?.();
const C = v.camera.positionCartographic.constructor;
v.camera.setView({
destination: v.scene.globe.ellipsoid.cartographicToCartesian(
C.fromDegrees(at.lon, at.lat, 300),
),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
}, street);
await sleep(1500);
const at = await page.evaluate((point) => {
const v = window.__godsEyeView.viewer;
const C = v.camera.positionCartographic.constructor;
const ground = v.scene.globe.ellipsoid.cartographicToCartesian(
C.fromDegrees(point.lon, point.lat, 0),
);
const canvas = v.scene.cartesianToCanvasCoordinates(ground);
const box = v.scene.canvas.getBoundingClientRect();
return canvas && { x: box.left + canvas.x, y: box.top + canvas.y };
}, street);
assert.ok(at, 'the street is on screen');
await page.mouse.click(at.x, at.y);
await uiUntil(
(u) =>
Boolean(u.street.error) ||
(u.street.providerId === 'google' &&
Boolean(u.street.imageId) &&
!u.street.loading),
null,
{ timeout: 30_000 },
);
const opened = (await ui()).street;
if (/GOOGLE_MAPS_API_KEY/.test(opened.error || '')) {
await clickPanelControl(page, chip);
return skip('Google key refused');
}
assert.equal(opened.error, null);
assert.equal(opened.providerId, 'google');
const shown = await page.evaluate(() => ({
by: document.getElementById('sl-image-by').textContent,
when: document.getElementById('sl-image-when').textContent,
label: document.getElementById('sl-image-link').textContent,
href: document.getElementById('sl-image-link').href,
fitFillHidden:
document.querySelector('[data-sl-render]').parentElement.hidden,
panorama: Boolean(
document.querySelector('#sl-viewer .sl-google-panorama'),
),
mapillaryLeft: Boolean(
document.querySelector('#sl-viewer .mapillary-dom'),
),
}));
assert.match(shown.by, /Image by /);
assert.match(shown.when, /\d{4}-\d{2}$/, 'dated by month');
assert.equal(shown.label, 'STREET VIEW ↗');
assert.match(shown.href, /map_action=pano&pano=/);
assert.equal(shown.fitFillHidden, true, 'no FIT/FILL for Google');
assert.equal(shown.panorama, true);
assert.equal(shown.mapillaryLeft, false, 'no viewer left under it');
// A real drag turns Google's view, and the pose follows it.
const box = await page.$eval(
'#sl-viewer .sl-google-panorama',
(node) => {
const r = node.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
},
);
await page.mouse.move(box.x, box.y);
await page.mouse.down();
await page.mouse.move(box.x - 120, box.y, { steps: 12 });
await page.mouse.up();
assert.ok(
await uiUntil(
(u, before) =>
Number.isFinite(u.street.bearing) &&
Math.abs(u.street.bearing - before) > 5,
opened.bearing ?? 0,
{ timeout: 10_000 },
),
'the pose bearing follows the drag',
);
await clickPanelControl(page, '#sl-viewer-close');
await uiUntil((u) => u.street.open === false);
await clickPanelControl(page, chip);
await clickPanelControl(page, googleChip);
await uiUntil(
(u) =>
u.providers.find((p) => p.id === 'google')?.on === false &&
u.providers.find((p) => p.id === 'mapillary')?.on === true,
);
},
);
await step( await step(
'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token', 'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token',
async () => { async () => {
+10 -1
View File
@@ -1,18 +1,27 @@
import { createStreetLevelLayer } from '../../layers/streetLevel/index.js'; import { createStreetLevelLayer } from '../../layers/streetLevel/index.js';
import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js'; import { createMapillaryProvider } from '../../layers/streetLevel/providers/mapillary/index.js';
import { createGoogleProvider } from '../../layers/streetLevel/providers/google/index.js';
import * as sprites from '../../data/spriteOrder.js'; import * as sprites from '../../data/spriteOrder.js';
import * as picking from '../../data/pickRegistry.js'; import * as picking from '../../data/pickRegistry.js';
import * as input from '../../data/inputOwnership.js'; import * as input from '../../data/inputOwnership.js';
import * as scenePick from '../../data/scenePick.js';
import * as render from '../../renderGovernor.js'; import * as render from '../../renderGovernor.js';
/** The browser Maps key the scene was started with (src/app/scene.js). */
const googleApiKey = () => globalThis.window?.__GOOGLE_MAPS_API_KEY__ || null;
/** 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 }) { export function createApplicationStreetLevel({ surface, sources }) {
return createStreetLevelLayer({ return createStreetLevelLayer({
providers: [createMapillaryProvider({ source: sources.mapillary })], providers: [
createMapillaryProvider({ source: sources.mapillary }),
createGoogleProvider({ getApiKey: googleApiKey }),
],
services: { services: {
sprites, sprites,
picking, picking,
input, input,
scenePick,
render, render,
ground: surface?.groundFloor ?? null, ground: surface?.groundFloor ?? null,
meshFloor: surface?.meshFloor ?? null, meshFloor: surface?.meshFloor ?? null,
+2
View File
@@ -413,6 +413,8 @@ const OPTION_GROUPS = Object.freeze({
'street-level': Object.freeze([ 'street-level': Object.freeze([
// One switch per registered imagery provider (src/app/layers/streetLevel.js). // One switch per registered imagery provider (src/app/layers/streetLevel.js).
booleanOption('mapillary', 'm', true), booleanOption('mapillary', 'm', true),
// Off until lit: every Street View panorama is billed to the Maps key.
booleanOption('google', 'g', false),
enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], { enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], {
all: 'a', all: 'a',
pano: 'p', pano: 'p',
+31 -16
View File
@@ -243,7 +243,11 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
} }
assert.equal(nextLayerStateToken(), '3'); assert.equal(nextLayerStateToken(), '3');
assert.equal( assert.equal(
nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '3', bravo: '4' }), nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS,
alpha: '3',
bravo: '4',
}),
'5', '5',
); );
const digitsExhausted = { const digitsExhausted = {
@@ -276,7 +280,9 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
[ [
...'0123456789', ...'0123456789',
...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) => ...[...LAYER_STATE_TOKEN_ALPHABET].flatMap((first) =>
[...LAYER_STATE_TOKEN_ALPHABET].map((second) => `${first}${second}`), [...LAYER_STATE_TOKEN_ALPHABET].map(
(second) => `${first}${second}`,
),
), ),
].map((token, index) => [`occupied-${index}`, token]), ].map((token, index) => [`occupied-${index}`, token]),
), ),
@@ -298,14 +304,16 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
true, true,
); );
assert.equal( assert.equal(
validateLayerStateAllocations( validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
LAYER_STATE_TOKEN_RESERVATIONS, ...LAYER_STATE_TOKEN_RESERVATIONS,
{ ...LAYER_STATE_TOKEN_RESERVATIONS, future: '3', next: '4' }, future: '3',
), next: '4',
}),
true, true,
); );
assert.throws( assert.throws(
() => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { () =>
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS, ...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00', future: '00',
}), }),
@@ -314,17 +322,19 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
const beforeLastDigit = { ...digitsExhausted }; const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9']; delete beforeLastDigit['prior-9'];
assert.equal( assert.equal(
validateLayerStateAllocations( validateLayerStateAllocations(beforeLastDigit, {
beforeLastDigit, ...beforeLastDigit,
{ ...beforeLastDigit, futurePair: '00', futureDigit: '9' }, futurePair: '00',
), futureDigit: '9',
}),
true, true,
); );
assert.equal( assert.equal(
validateLayerStateAllocations( validateLayerStateAllocations(digitsExhausted, {
digitsExhausted, ...digitsExhausted,
{ ...digitsExhausted, pairB: '01', pairA: '00' }, pairB: '01',
), pairA: '00',
}),
true, true,
); );
assert.throws( assert.throws(
@@ -2660,12 +2670,14 @@ test('Street Level: every option round-trips through a share link and stored sta
state.enabledLayerIds = ['street-level']; state.enabledLayerIds = ['street-level'];
state.options['street-level'] = { state.options['street-level'] = {
mapillary: false, mapillary: false,
google: true,
pano: 'flat', pano: 'flat',
sinceDays: MAX_SINCE_DAYS, sinceDays: MAX_SINCE_DAYS,
}; };
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state))); const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
assert.deepEqual(decoded.options['street-level'], { assert.deepEqual(decoded.options['street-level'], {
mapillary: false, mapillary: false,
google: true,
pano: 'flat', pano: 'flat',
sinceDays: MAX_SINCE_DAYS, sinceDays: MAX_SINCE_DAYS,
}); });
@@ -2690,12 +2702,15 @@ test('Street Level: the link codec accepts exactly the windows the filter keeps'
assert.equal(streetLevelOptions('0.s.3652').sinceDays, 3652); assert.equal(streetLevelOptions('0.s.3652').sinceDays, 3652);
}); });
test('Street Level: a link without the provider switch keeps Mapillary on', () => { test('Street Level: a link without the provider switches keeps Mapillary on and Street View off', () => {
assert.deepEqual(streetLevelOptions(''), { assert.deepEqual(streetLevelOptions(''), {
mapillary: true, mapillary: true,
google: false,
pano: 'all', pano: 'all',
sinceDays: 0, sinceDays: 0,
}); });
assert.equal(streetLevelOptions('0.g.1').google, true);
assert.equal(createDefaultLayerState().options['street-level'].google, false);
assert.equal(streetLevelOptions('0.p.f').mapillary, true); assert.equal(streetLevelOptions('0.p.f').mapillary, true);
assert.equal(streetLevelOptions('0.m.0').mapillary, false); assert.equal(streetLevelOptions('0.m.0').mapillary, false);
assert.equal( assert.equal(
+1 -1
View File
@@ -36,7 +36,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({ Object.freeze({
id: 'google-maps', id: 'google-maps',
title: 'GOOGLE MAPS', title: 'GOOGLE MAPS',
unlocks: 'The photorealistic 3D planet + place search', unlocks: 'The photorealistic 3D planet, place search + Street View',
getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key', getUrl: 'https://developers.google.com/maps/documentation/tile/get-api-key',
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']), envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
tier: 'metered', tier: 'metered',
+31
View File
@@ -0,0 +1,31 @@
import { GROUND_CLICK_MAX_HEIGHT_M } from './policy.js';
import { cameraHeightAboveGround, groundPointAt } from './view.js';
/**
* A click on bare ground at street zoom opens the nearest image of the active
* providers that have no coverage to click (`groundClick`, Google Street
* View). `openNearest(point, {providerIds})` is the layer's lookup.
* @returns {(screenPosition: {x: number, y: number}) => Promise<boolean>|false}
*/
export function createGroundClick({ state, parts, openNearest }) {
return function openAtGround(screenPosition) {
const isWorldPosition = state.services.scenePick?.isPickedWorldPosition;
if (!state.enabled || !state.viewer || !isWorldPosition) return false;
const providerIds = [...state.providers.values()]
.filter(
(entry) =>
entry.on && entry.def.groundClick && entry.status?.configured,
)
.map((entry) => entry.def.id);
if (!providerIds.length) return false;
// From higher up a click is a misclick, not a street.
const height = cameraHeightAboveGround(state.viewer, {
groundAt: parts.groundCaster?.groundAt,
});
if (!(height <= GROUND_CLICK_MAX_HEIGHT_M)) return false;
const point = groundPointAt(state.viewer, screenPosition, {
isWorldPosition,
});
return point ? openNearest(point, { providerIds }) : false;
};
}
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createGroundClick } from './groundClick.js';
import { isPickedWorldPosition } from '../../data/scenePick.js';
const GROUND = Cesium.Cartesian3.fromDegrees(-121.4944, 38.5816, 10);
/** A layer state with providers `{id, on, groundClick, configured}` and a camera `height` m up. */
function harness({
providers = [
{ id: 'mapillary', groundClick: false },
{ id: 'google', groundClick: true },
],
height = 400,
enabled = true,
picked = GROUND,
} = {}) {
const opened = [];
const state = {
enabled,
services: { scenePick: { isPickedWorldPosition } },
providers: new Map(
providers.map(({ id, on = true, groundClick, configured = true }) => [
id,
{
on,
def: { id, groundClick },
status: { configured },
},
]),
),
viewer: {
scene: {
pickPositionSupported: true,
pickPosition: () => picked,
globe: { getHeight: () => 10 },
},
camera: {
positionCartographic: Cesium.Cartographic.fromDegrees(
-121.4944,
38.5816,
10 + height,
),
pickEllipsoid: () => undefined,
},
},
};
const openAtGround = createGroundClick({
state,
parts: {},
openNearest: (point, options) => {
opened.push({ point, options });
return Promise.resolve(true);
},
});
return { state, opened, openAtGround };
}
test('a ground click at street zoom opens the nearest image of the click-to-open providers there', () => {
const { opened, openAtGround } = harness();
openAtGround({ x: 10, y: 20 });
assert.equal(opened.length, 1);
const [{ point, options }] = opened;
assert.ok(Math.abs(point.lon - -121.4944) < 1e-9);
assert.ok(Math.abs(point.lat - 38.5816) < 1e-9);
assert.deepEqual(options, { providerIds: ['google'] });
});
test('nothing opens from high up, over the sky, with the provider off or unconfigured, or the layer off', () => {
for (const options of [
{ height: 5000 },
{ picked: null },
{ picked: new Cesium.Cartesian3(500, 0, 0) },
{ providers: [{ id: 'google', groundClick: true, on: false }] },
{ providers: [{ id: 'google', groundClick: true, configured: false }] },
{ providers: [{ id: 'mapillary', groundClick: false }] },
{ enabled: false },
]) {
const { opened, openAtGround } = harness(options);
assert.equal(openAtGround({ x: 1, y: 1 }), false, JSON.stringify(options));
assert.equal(opened.length, 0);
}
});
+21 -6
View File
@@ -10,6 +10,7 @@ import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
import { decodeParams, encodeParams } from './params.js'; import { decodeParams, encodeParams } from './params.js';
import { composeUIState, summarizeCoverage } from './uiState.js'; import { composeUIState, summarizeCoverage } from './uiState.js';
import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js'; import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
import { createGroundClick } from './groundClick.js';
import { createGroundCaster, nextSurfaceMode } from './groundCast.js'; import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
import { createMeshSampler } from './meshSampler.js'; import { createMeshSampler } from './meshSampler.js';
@@ -66,6 +67,11 @@ export function createStreetLevelLayer({
entry.instance.clearSequence?.(); entry.instance.clearSequence?.();
}; };
parts.selection = createSelection(context); parts.selection = createSelection(context);
parts.openAtGround = createGroundClick({
state,
parts,
openNearest: (point, options) => layer.openNearest(point, options),
});
let notifyQueued = false; let notifyQueued = false;
function notify() { function notify() {
@@ -119,7 +125,7 @@ export function createStreetLevelLayer({
const entry = { const entry = {
def, def,
instance: null, instance: null,
on: true, on: def.defaultOn !== false,
status: null, status: null,
/** The error this provider last reported, until it withdraws it. */ /** The error this provider last reported, until it withdraws it. */
reportedError: null, reportedError: null,
@@ -472,8 +478,11 @@ export function createStreetLevelLayer({
/** Imagery filter for coverage, cones and nearest-image lookups. */ /** Imagery filter for coverage, cones and nearest-image lookups. */
setCoverageFilter, setCoverageFilter,
openImage, openImage,
/** Open the nearest image any active provider has around a point. */ /**
async openNearest(point) { * Open the nearest image any active provider (or only `providerIds`)
* has around a point, by default the view centre.
*/
async openNearest(point, { providerIds = null } = {}) {
const view = point || viewCentre(state.viewer); const view = point || viewCentre(state.viewer);
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon)) if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
return false; return false;
@@ -485,7 +494,10 @@ export function createStreetLevelLayer({
state.street.error = null; state.street.error = null;
notify(); notify();
let lastError = null; let lastError = null;
for (const entry of activeEntries()) { const entries = activeEntries().filter(
(entry) => !providerIds || providerIds.includes(entry.def.id),
);
for (const entry of entries) {
let imageId = null; let imageId = null;
try { try {
imageId = await entry.instance.nearestImage( imageId = await entry.instance.nearestImage(
@@ -508,10 +520,13 @@ export function createStreetLevelLayer({
} }
if (nearestLookup === lookup) nearestLookup = null; if (nearestLookup === lookup) nearestLookup = null;
// Switched off while it looked: nothing to open, nothing to report. // Switched off while it looked: nothing to open, nothing to report.
if (state.enabled && activeEntries().length) const stillOn = activeEntries().some(
(entry) => !providerIds || providerIds.includes(entry.def.id),
);
if (state.enabled && stillOn)
state.street.error = state.street.error =
lastError?.message || lastError?.message ||
`No street-level imagery within ${NEAREST_RADIUS_M} m of the view centre`; `No street-level imagery within ${NEAREST_RADIUS_M} m of ${point ? 'that point' : 'the view centre'}`;
state.street.loading = false; state.street.loading = false;
notify(); notify();
return false; return false;
+7
View File
@@ -7,6 +7,7 @@ export const POSITION_PICK_ID = 'sl:pos';
/** One colour per imagery source: chip, coverage lines, points, cones and legend. */ /** One colour per imagery source: chip, coverage lines, points, cones and legend. */
export const PROVIDER_COLORS = Object.freeze({ export const PROVIDER_COLORS = Object.freeze({
mapillary: '#05cb63', mapillary: '#05cb63',
google: '#4285f4',
}); });
/** Colours every provider shares: the selection highlight and the marker. */ /** Colours every provider shares: the selection highlight and the marker. */
@@ -35,3 +36,9 @@ export const FOLLOW_MAP_STACK_ID = 'photoreal';
/** Eye height above the sampled ground when the globe camera follows the viewer. */ /** Eye height above the sampled ground when the globe camera follows the viewer. */
export const FOLLOW_EYE_HEIGHT_M = 2.4; export const FOLLOW_EYE_HEIGHT_M = 2.4;
/**
* A ground click opens the nearest image of a provider without coverage lines
* (Google Street View) only from this close: from higher up it is a misclick.
*/
export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
@@ -0,0 +1,176 @@
import { passesImageryFilter } from '../../filter.js';
import { createMapsLoader } from './mapsLoader.js';
import { createGoogleViewer } from './viewer.js';
import {
COLORS,
GOOGLE_CREDIT_HTML,
GOOGLE_KEY_ID,
GOOGLE_LABEL,
GOOGLE_NAME,
GOOGLE_PROVIDER_ID,
GROUND_CLICK_HINT,
KEY_REJECTED_MESSAGE,
NEAREST_RADIUS_M,
NEAREST_TIMEOUT_MS,
NO_ANSWER_MESSAGE,
PICK_PREFIX,
imageDateMs,
} from './policy.js';
/**
* `promise`, unless `ms` pass first (NO_ANSWER_MESSAGE), `signal` aborts, or
* Google refuses the key: a lookup Google never answers must not keep the
* panel loading.
*/
function settleWithin(promise, { ms, signal, loader }) {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => finish(reject, new Error(NO_ANSWER_MESSAGE)),
ms,
);
const onAbort = () => finish(reject, signal.reason);
signal?.addEventListener('abort', onAbort, { once: true });
const stopAuthWatch = loader.onAuthFailure(() =>
finish(reject, new Error(KEY_REJECTED_MESSAGE)),
);
promise.then(
(value) => finish(resolve, value),
(error) => finish(reject, error),
);
function finish(settle, value) {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
stopAuthWatch();
settle(value);
}
});
}
/**
* Google Street View Street Level provider: Google's own panorama viewer and
* nearest-panorama lookups. Google offers no coverage layer for other maps,
* so it draws none; a click on a street opens the panorama there.
* `getApiKey` returns the browser Maps key; `loader` is ./mapsLoader.js or a stand-in.
* @returns {import('../../registry.js').StreetLevelProvider}
*/
export function createGoogleProvider({
getApiKey,
loader = createMapsLoader({ getApiKey }),
}) {
if (typeof getApiKey !== 'function')
throw new TypeError('Google Street View needs getApiKey');
return Object.freeze({
id: GOOGLE_PROVIDER_ID,
name: GOOGLE_NAME,
label: GOOGLE_LABEL,
requiresKeyId: GOOGLE_KEY_ID,
pickPrefix: PICK_PREFIX.root,
colors: COLORS,
credit: Object.freeze({ html: GOOGLE_CREDIT_HTML }),
groundClick: true,
// Every panorama is billed to the Maps key: the user lights the chip.
defaultOn: false,
create(context) {
let keyRequired = !getApiKey();
let active = false;
let service = null;
const viewer = createGoogleViewer({
loader,
render: context.services?.render,
});
const stopAuthWatch = loader.onAuthFailure(() => {
context.actions.reportError(KEY_REJECTED_MESSAGE);
context.notify();
});
return {
async status() {
keyRequired = !getApiKey();
context.notify();
return { configured: !keyRequired };
},
init() {},
activate() {
active = true;
},
deactivate() {
active = false;
},
destroy() {
active = false;
stopAuthWatch();
},
refreshCoverage() {},
/** Nothing drawn to redraw: lookups read the filter when they run. */
setFilter() {},
coverageStats() {
const keyRejected = loader.authFailed();
return {
count: 0,
zoom: null,
kind: null,
loading: false,
hint:
active && !keyRequired && !keyRejected ? GROUND_CLICK_HINT : '',
error: keyRejected ? KEY_REJECTED_MESSAGE : null,
keyRequired,
keyRejected,
};
},
handlePick: () => false,
/**
* Nearest Google-collected outdoor panorama that passes the imagery
* filter, or null. Every Street View panorama is 360°.
*/
async nearestImage({ lat, lon }, { signal } = {}) {
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
signal?.throwIfAborted();
const filter = context.getFilter();
if (filter.pano === 'flat') return null;
const lookup = (async () => {
const lib = await loader.importLibrary('streetView');
service ||= new lib.StreetViewService();
const { GOOGLE, OUTDOOR } = lib.StreetViewSource;
try {
const { data } = await service.getPanorama({
location: { lat, lng: lon },
radius: NEAREST_RADIUS_M,
sources: GOOGLE ? [GOOGLE, OUTDOOR] : [OUTDOOR],
preference: lib.StreetViewPreference.NEAREST,
});
return data;
} catch (error) {
if (error?.code === 'ZERO_RESULTS') return null;
throw error;
}
})();
const data = await settleWithin(lookup, {
ms: NEAREST_TIMEOUT_MS,
signal,
loader,
});
const panoId = data?.location?.pano;
if (!panoId) return null;
const passes = passesImageryFilter(
{ isPano: true, capturedAt: imageDateMs(data.imageDate) ?? 0 },
filter,
);
return passes ? panoId : null;
},
viewer,
};
},
});
}
@@ -0,0 +1,84 @@
/**
* Loads the Google Maps JavaScript API once per page, on first use, and hands
* out its libraries. Google calls `gm_authFailure` when it refuses the key
* (an unknown key, a referrer it does not allow, or the Maps JavaScript API
* not enabled for it); that reaches every `onAuthFailure` listener.
*/
const SCRIPT_ORIGIN = 'https://maps.googleapis.com';
const READY_CALLBACK = '__gevGoogleMapsReady';
/**
* @param {{getApiKey: () => string|null|undefined, document?: Document, global?: object}} options
*/
export function createMapsLoader({
getApiKey,
document = globalThis.document,
global = globalThis,
}) {
/** The script load in flight or done; cleared when it fails, so a retry can. */
let loading = null;
let authFailed = false;
const authListeners = new Set();
function installAuthHandler() {
const previous = global.gm_authFailure;
global.gm_authFailure = () => {
authFailed = true;
for (const listener of [...authListeners]) {
try {
listener();
} catch {
/* a listener's error is its own */
}
}
if (typeof previous === 'function') previous();
};
}
function load() {
if (loading) return loading;
const key = getApiKey?.();
if (!key)
return Promise.reject(new Error('GOOGLE_MAPS_API_KEY is not set'));
if (global.google?.maps?.importLibrary)
return (loading = Promise.resolve(global.google.maps));
installAuthHandler();
loading = new Promise((resolve, reject) => {
global[READY_CALLBACK] = () => {
delete global[READY_CALLBACK];
resolve(global.google.maps);
};
const script = document.createElement('script');
script.async = true;
script.src = `${SCRIPT_ORIGIN}/maps/api/js?${new URLSearchParams({
key,
v: 'weekly',
loading: 'async',
callback: READY_CALLBACK,
})}`;
script.onerror = () => {
loading = null;
script.remove();
delete global[READY_CALLBACK];
reject(new Error('The Google Maps JavaScript API could not load'));
};
document.head.append(script);
});
return loading;
}
return {
/** A Maps library ('streetView', …), loading the API first. */
async importLibrary(name) {
const maps = await load();
return maps.importLibrary(name);
},
/** Whether Google has refused the key on this page. */
authFailed: () => authFailed,
onAuthFailure(listener) {
authListeners.add(listener);
return () => authListeners.delete(listener);
},
};
}
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createMapsLoader } from './mapsLoader.js';
/** A document whose appended scripts the test loads or fails. */
function fakePage() {
const scripts = [];
const document = {
createElement: () => {
const script = {
remove() {
scripts.splice(scripts.indexOf(script), 1);
},
};
return script;
},
head: { append: (script) => scripts.push(script) },
};
const global = {};
const library = { name: 'streetView' };
/** Google's script arrived: it defines google.maps and calls back. */
const loadScript = () => {
global.google = {
maps: { importLibrary: async (name) => ({ ...library, name }) },
};
const callback = new URL(scripts.at(-1).src).searchParams.get('callback');
global[callback]();
};
return { document, global, scripts, loadScript };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
test('the API loads once, with the key, asynchronously, and hands out libraries', async () => {
const page = fakePage();
const loader = createMapsLoader({ getApiKey: () => 'k-1', ...page });
const first = loader.importLibrary('streetView');
const second = loader.importLibrary('streetView');
await settle();
assert.equal(page.scripts.length, 1, 'one script for concurrent imports');
const url = new URL(page.scripts[0].src);
assert.equal(
url.origin + url.pathname,
'https://maps.googleapis.com/maps/api/js',
);
assert.equal(url.searchParams.get('key'), 'k-1');
assert.equal(url.searchParams.get('loading'), 'async');
assert.equal(page.scripts[0].async, true);
page.loadScript();
assert.equal((await first).name, 'streetView');
assert.equal((await second).name, 'streetView');
const callback = url.searchParams.get('callback');
assert.equal(page.global[callback], undefined, 'the callback is cleaned up');
});
test('without a key nothing loads', async () => {
const page = fakePage();
const loader = createMapsLoader({ getApiKey: () => null, ...page });
await assert.rejects(loader.importLibrary('streetView'), /not set/);
assert.equal(page.scripts.length, 0);
});
test('a script that fails to load can be retried', async () => {
const page = fakePage();
const loader = createMapsLoader({ getApiKey: () => 'k', ...page });
const failed = loader.importLibrary('streetView');
await settle();
page.scripts[0].onerror();
await assert.rejects(failed, /could not load/);
assert.equal(page.scripts.length, 0, 'the failed script is removed');
const retry = loader.importLibrary('streetView');
await settle();
assert.equal(page.scripts.length, 1);
page.loadScript();
assert.equal((await retry).name, 'streetView');
});
test("Google's refusal of the key reaches every listener and the page's own handler", async () => {
const page = fakePage();
let before = 0;
page.global.gm_authFailure = () => before++;
const loader = createMapsLoader({ getApiKey: () => 'k', ...page });
const heard = [];
const stop = loader.onAuthFailure(() => heard.push('a'));
loader.importLibrary('streetView');
await settle();
assert.equal(loader.authFailed(), false);
page.global.gm_authFailure();
assert.equal(loader.authFailed(), true);
assert.deepEqual(heard, ['a']);
assert.equal(before, 1, 'a handler installed earlier still runs');
stop();
page.global.gm_authFailure();
assert.deepEqual(heard, ['a']);
});
@@ -0,0 +1,65 @@
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
export { NEAREST_RADIUS_M } from '../../policy.js';
/** Identity of the Google Street View street-level provider. */
export const GOOGLE_PROVIDER_ID = 'google';
export const GOOGLE_NAME = 'Google Street View';
export const GOOGLE_LABEL = 'STREET VIEW';
/** The browser Maps key; Street View also needs the Maps JavaScript API on it. */
export const GOOGLE_KEY_ID = 'google-maps';
/** Every primitive id this provider creates starts with it. */
export const PICK_PREFIX = Object.freeze({ root: 'gsv:' });
export const COLORS = Object.freeze({
coverage: PROVIDER_COLORS.google,
selected: SHARED_COLORS.selected,
});
export const GOOGLE_CREDIT_HTML =
'Street View imagery © <a href="https://www.google.com/streetview/" target="_blank" rel="noopener">Google</a>';
/** Shown when Google refuses the key for the Maps JavaScript API. */
export const KEY_REJECTED_MESSAGE =
'Google rejected GOOGLE_MAPS_API_KEY for Street View — enable the Maps JavaScript API for it';
/** A nearest-panorama lookup that takes longer has failed. */
export const NEAREST_TIMEOUT_MS = 10_000;
/** Google answered nothing (most often: the Maps JavaScript API is not enabled). */
export const NO_ANSWER_MESSAGE =
'Street View did not answer — check that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY';
/** Shown while the provider is on, in place of coverage lines it does not have. */
export const GROUND_CLICK_HINT = 'click a street to open Street View';
/** Deep link to a panorama on Google Maps (Maps URLs). */
export function streetViewUrl(panoId, { heading = 0, pitch = 0 } = {}) {
const params = new URLSearchParams({
api: '1',
map_action: 'pano',
pano: String(panoId),
heading: String(Math.round(heading)),
pitch: String(Math.round(pitch)),
});
return `https://www.google.com/maps/@?${params}`;
}
/** Epoch ms for a Street View image date ('YYYY-MM'), or null. */
export function imageDateMs(imageDate) {
const match = /^(\d{4})-(\d{2})/.exec(String(imageDate ?? ''));
if (!match) return null;
const month = Number(match[2]);
if (month < 1 || month > 12) return null;
return Date.UTC(Number(match[1]), month - 1, 1);
}
/** The photographer from a copyright line ('© 2024 Google' → 'Google'). */
export function creatorFrom(copyright) {
const text = String(copyright ?? '')
.replace(/^©\s*(\d{4}\s+)?/, '')
.replace(/^From the Owner,\s*Photo by:\s*/i, '')
.trim();
return text || null;
}
@@ -0,0 +1,192 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGoogleProvider } from './index.js';
import {
GROUND_CLICK_HINT,
KEY_REJECTED_MESSAGE,
creatorFrom,
imageDateMs,
streetViewUrl,
} from './policy.js';
import { PROVIDER_COLORS } from '../../policy.js';
import { requiresKeyIdFor, validateProviders } from '../../registry.js';
import { createMapillaryProvider } from '../mapillary/index.js';
import {
fakeMapsLoader,
fakeStreetViewLibrary,
} from '../../../../testSupport/googleStreetViewFakes.mjs';
const PANORAMAS = {
fresh: { lat: 38.5816, lng: -121.4944, imageDate: '2025-06' },
old: { lat: 38.6, lng: -121.5, imageDate: '2009-03' },
};
function setup({ key = 'browser-key', nearest = () => 'fresh' } = {}) {
const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS, nearest });
const loader = fakeMapsLoader(fake.library);
let apiKey = key;
const def = createGoogleProvider({ getApiKey: () => apiKey, loader });
const reported = [];
let filter = { pano: 'all', sinceMs: null };
const context = {
services: {},
getFilter: () => filter,
notify() {},
actions: { reportError: (message) => reported.push(message) },
};
const instance = def.create(context);
return {
def,
instance,
fake,
loader,
reported,
setFilter: (next) => (filter = next),
setKey: (next) => (apiKey = next),
};
}
test('the provider registers beside Mapillary, off by default, gated on the browser Maps key', () => {
const { def } = setup();
assert.equal(def.id, 'google');
assert.equal(def.label, 'STREET VIEW');
assert.equal(def.requiresKeyId, 'google-maps');
assert.equal(def.defaultOn, false, 'every panorama is billed');
assert.equal(def.groundClick, true);
assert.equal(def.colors.coverage, PROVIDER_COLORS.google);
assert.match(def.credit.html, /Street View imagery © .*Google/);
const mapillary = createMapillaryProvider({
source: {
getStatus() {},
getTile() {},
getSequenceImages() {},
nearestImages() {},
},
});
const both = validateProviders([mapillary, def]);
assert.equal(requiresKeyIdFor(both), null, 'each chip gates on its own key');
assert.throws(() => createGoogleProvider({}), /getApiKey/);
});
test('status follows the browser Maps key', async () => {
const { instance, setKey } = setup({ key: '' });
assert.deepEqual(await instance.status(), { configured: false });
assert.equal(instance.coverageStats().keyRequired, true);
setKey('browser-key');
assert.deepEqual(await instance.status(), { configured: true });
assert.equal(instance.coverageStats().keyRequired, false);
});
test('the nearest lookup asks Google for its own outdoor imagery within 50 m', async () => {
const { instance, fake } = setup();
assert.equal(
await instance.nearestImage({ lat: 38.58, lon: -121.49 }),
'fresh',
);
const [request] = fake.lookups;
assert.deepEqual(request.location, { lat: 38.58, lng: -121.49 });
assert.equal(request.radius, 50);
assert.deepEqual(request.sources, ['google', 'outdoor']);
assert.equal(request.preference, 'nearest');
});
test('no panorama nearby, or one the imagery filter excludes, is null', async () => {
const none = setup({ nearest: () => null });
assert.equal(await none.instance.nearestImage({ lat: 0, lon: 0 }), null);
const { instance, fake, setFilter } = setup({ nearest: () => 'old' });
setFilter({ pano: 'all', sinceMs: Date.UTC(2020, 0, 1) });
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), null);
// Every Street View panorama is 360°: FLAT asks Google nothing.
setFilter({ pano: 'flat', sinceMs: null });
const asked = fake.lookups.length;
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), null);
assert.equal(fake.lookups.length, asked);
setFilter({ pano: 'pano', sinceMs: null });
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), 'old');
});
test('an aborted lookup gives up before and after asking Google', async () => {
const { instance } = setup();
const controller = new AbortController();
controller.abort();
await assert.rejects(
instance.nearestImage({ lat: 0, lon: 0 }, { signal: controller.signal }),
{ name: 'AbortError' },
);
});
test('a key Google refuses reads KEY REJECTED, shows why, and stops lookups', async () => {
const { instance, loader, reported } = setup();
instance.activate();
assert.equal(instance.coverageStats().hint, GROUND_CLICK_HINT);
loader.refuseKey();
const stats = instance.coverageStats();
assert.equal(stats.keyRejected, true);
assert.equal(stats.error, KEY_REJECTED_MESSAGE);
assert.equal(stats.hint, '', 'no "click a street" hint over a dead key');
assert.deepEqual(reported, [KEY_REJECTED_MESSAGE]);
await assert.rejects(instance.nearestImage({ lat: 0, lon: 0 }), {
message: KEY_REJECTED_MESSAGE,
});
instance.destroy();
assert.equal(loader.listenerCount(), 0, 'destroy stops listening');
});
test('the hint shows only while the provider is on', () => {
const { instance } = setup();
assert.equal(instance.coverageStats().hint, '');
instance.activate();
assert.equal(instance.coverageStats().hint, GROUND_CLICK_HINT);
instance.deactivate();
assert.equal(instance.coverageStats().hint, '');
});
test('image dates, photographers and deep links read the way Google writes them', () => {
assert.equal(imageDateMs('2024-05'), Date.UTC(2024, 4, 1));
assert.equal(imageDateMs('2024-13'), null);
assert.equal(imageDateMs(undefined), null);
assert.equal(creatorFrom('© 2024 Google'), 'Google');
assert.equal(creatorFrom('From the Owner, Photo by: Ada'), 'Ada');
assert.equal(creatorFrom(''), null);
assert.equal(
streetViewUrl('a b', { heading: 90.4, pitch: -9.6 }),
'https://www.google.com/maps/@?api=1&map_action=pano&pano=a+b&heading=90&pitch=-10',
);
});
test('a lookup Google never answers fails after the timeout, and a refused key fails it at once', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { NEAREST_TIMEOUT_MS, NO_ANSWER_MESSAGE } = await import('./policy.js');
const { instance, fake, loader } = setup();
fake.library.StreetViewService.prototype.getPanorama = () =>
new Promise(() => {});
const hung = instance.nearestImage({ lat: 0, lon: 0 });
await Promise.resolve();
await Promise.resolve();
t.mock.timers.tick(NEAREST_TIMEOUT_MS);
await assert.rejects(hung, { message: NO_ANSWER_MESSAGE });
const refused = instance.nearestImage({ lat: 0, lon: 0 });
await Promise.resolve();
loader.refuseKey();
await assert.rejects(refused, { message: KEY_REJECTED_MESSAGE });
assert.equal(
loader.listenerCount(),
1,
'only the provider’s own watch is left',
);
});
test('aborting a lookup in flight gives up at once', async () => {
const { instance, fake } = setup();
fake.library.StreetViewService.prototype.getPanorama = () =>
new Promise(() => {});
const controller = new AbortController();
const lookup = instance.nearestImage(
{ lat: 0, lon: 0 },
{ signal: controller.signal },
);
controller.abort();
await assert.rejects(lookup, { name: 'AbortError' });
});
@@ -0,0 +1,259 @@
import {
GOOGLE_PROVIDER_ID,
KEY_REJECTED_MESSAGE,
creatorFrom,
imageDateMs,
streetViewUrl,
} from './policy.js';
/** An open that sees no panorama by then has failed. */
const OPEN_TIMEOUT_MS = 20_000;
/** Google's own controls, minus those the Street Level panel already has. */
const PANORAMA_OPTIONS = Object.freeze({
visible: false,
// The address goes in the panel caption: Google's card covers half a
// docked panorama.
addressControl: false,
linksControl: true,
panControl: true,
zoomControl: true,
clickToGo: true,
showRoadLabels: true,
// The panel has EXPAND and ×; Google's fullscreen would leave the modal.
fullscreenControl: false,
enableCloseButton: false,
motionTracking: false,
motionTrackingControl: false,
});
/**
* Viewer adapter over Google's StreetViewPanorama. It renders in its own
* element inside the core's host, and emits a provider-neutral pose for every
* panorama and view change, including steps along Google's own arrows.
* @param {{loader: ReturnType<import('./mapsLoader.js').createMapsLoader>, render?: object}} options
* @returns {import('../../registry.js').ViewerAdapter}
*/
export function createGoogleViewer({ loader, render } = {}) {
let library = null;
let panorama = null;
let element = null;
let host = null;
let service = null;
let pendingOpen = null;
/** Bumped by `unmount`, so a construction it overtook builds nothing. */
let generation = 0;
let subscriptions = [];
/** Panorama id → its {capturedAt, creator} lookup. */
const metadata = new Map();
const listeners = new Set();
function requestRender() {
render?.governorRequestRender?.('google-street-view');
}
async function ensureLibrary() {
library ||= await loader.importLibrary('streetView');
return library;
}
function emit(pose) {
for (const listener of [...listeners]) {
try {
listener(pose);
} catch {
/* listener errors are the core's to log */
}
}
}
/**
* Date and photographer of a panorama, looked up once: pano_changed and
* position_changed arrive together, so the lookup itself is shared.
*/
function describe(panoId) {
if (!metadata.has(panoId)) metadata.set(panoId, lookUp(panoId));
return metadata.get(panoId);
}
async function lookUp(panoId) {
try {
service ||= new library.StreetViewService();
const { data } = await service.getPanorama({ pano: panoId });
return {
capturedAt: imageDateMs(data?.imageDate),
creator: creatorFrom(data?.copyright),
title: data?.location?.description || null,
};
} catch {
// The pose still goes out, without a date.
return { capturedAt: null, creator: null, title: null };
}
}
async function publishPose() {
const shown = panorama;
const panoId = shown?.getPano?.();
const position = shown?.getPosition?.();
if (!panoId || !position || pendingOpen === null) return;
const meta = await describe(panoId);
// Closed, unmounted or moved on while the date was looked up.
if (pendingOpen === null || shown !== panorama) return;
if (panorama.getPano() !== panoId) return;
const pov = panorama.getPov() || {};
const latLng = panorama.getPosition() || position;
emit({
providerId: GOOGLE_PROVIDER_ID,
imageId: panoId,
position: { lon: latLng.lng(), lat: latLng.lat() },
bearing: Number.isFinite(pov.heading) ? pov.heading : null,
tilt: Number.isFinite(pov.pitch) ? pov.pitch : 0,
altitude: null,
isPano: true,
capturedAt: meta.capturedAt,
capturedAtPrecision: 'month',
creator: meta.creator,
title: meta.title,
sequenceId: null,
externalUrl: streetViewUrl(panoId, {
heading: pov.heading || 0,
pitch: pov.pitch || 0,
}),
});
requestRender();
}
async function ensurePanorama(target) {
if (panorama && host === target) return panorama;
const built = generation;
const { StreetViewPanorama } = await ensureLibrary();
if (built !== generation) throw new Error('Street View was unmounted');
if (panorama && host === target) return panorama;
destroyPanorama();
element = target.ownerDocument.createElement('div');
element.className = 'sl-google-panorama';
element.style.cssText = 'position:absolute;inset:0;';
target.append(element);
host = target;
panorama = new StreetViewPanorama(element, PANORAMA_OPTIONS);
subscriptions = ['pano_changed', 'position_changed', 'pov_changed'].map(
(name) => panorama.addListener(name, () => publishPose()),
);
return panorama;
}
function destroyPanorama() {
for (const subscription of subscriptions) subscription.remove();
subscriptions = [];
try {
panorama?.setVisible(false);
} catch {
/* already gone */
}
element?.remove();
panorama = null;
element = null;
host = null;
}
/**
* Resolves once `panoId` shows; rejects when Google has no imagery for it,
* or refuses the key (which it reports apart from the panorama's status).
*/
function shown(instance, panoId) {
if (instance.getPano() === panoId && instance.getStatus() === 'OK')
return Promise.resolve();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
done();
reject(new Error('Street View did not answer'));
}, OPEN_TIMEOUT_MS);
const stopAuthWatch = loader.onAuthFailure(() => {
done();
reject(new Error(KEY_REJECTED_MESSAGE));
});
const subscription = instance.addListener('status_changed', () => {
if (pendingOpen !== panoId) {
done();
resolve();
return;
}
// Google may answer with a newer id for the same place: any OK counts.
const status = instance.getStatus();
if (status === 'OK') {
done();
resolve();
} else if (status && status !== 'OK') {
done();
reject(new Error('Street View has no imagery for this place'));
}
});
function done() {
clearTimeout(timer);
subscription.remove();
stopAuthWatch();
}
});
}
return {
async mount(target) {
if (!target) throw new Error('Street Level viewer has no host element');
await ensurePanorama(target);
},
/** Resolves once the panorama is on screen and its first pose was emitted. */
async open(imageId) {
const panoId = String(imageId);
if (!host) throw new Error('Street View is not mounted');
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
pendingOpen = panoId;
const instance = await ensurePanorama(host);
if (pendingOpen !== panoId) return;
const ready = shown(instance, panoId);
instance.setPano(panoId);
instance.setVisible(true);
await ready;
if (pendingOpen !== panoId) return;
await publishPose();
},
close() {
pendingOpen = null;
try {
panorama?.setVisible(false);
} catch {
/* torn down */
}
},
unmount() {
pendingOpen = null;
generation++;
destroyPanorama();
},
resize() {
if (!panorama) return;
try {
globalThis.google?.maps?.event?.trigger(panorama, 'resize');
} catch {
/* no-op */
}
},
/** Load the library ahead of the first panorama; a panorama is billed, so none is built. */
async prewarm() {
try {
await ensureLibrary();
} catch {
/* the real open reports errors */
}
},
onPose(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
@@ -0,0 +1,159 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGoogleViewer } from './viewer.js';
import {
fakeHost,
fakeMapsLoader,
fakeStreetViewLibrary,
} from '../../../../testSupport/googleStreetViewFakes.mjs';
const PANORAMAS = {
'pano-a': {
lat: 38.5816,
lng: -121.4944,
imageDate: '2024-05',
copyright: '© 2024 Google',
description: '10th St',
},
'pano-b': {
lat: 38.5817,
lng: -121.4944,
imageDate: '2019-08',
copyright: 'From the Owner, Photo by: Ada',
},
};
function setup() {
const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS });
const loader = fakeMapsLoader(fake.library);
const viewer = createGoogleViewer({ loader });
const poses = [];
viewer.onPose((pose) => poses.push(pose));
const host = fakeHost();
return { fake, loader, viewer, poses, host };
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
test('opening a panorama shows it and emits a provider-neutral pose', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
assert.equal(host.children.length, 1, 'renders in its own element');
const [panorama] = fake.built;
assert.equal(panorama.options.fullscreenControl, false);
assert.equal(panorama.options.addressControl, false, 'the caption has it');
assert.equal(panorama.options.enableCloseButton, false);
assert.equal(panorama.visible, false, 'nothing shows before an open');
await viewer.open('pano-a');
assert.equal(panorama.visible, true);
const pose = poses.at(-1);
assert.deepEqual(
{
providerId: pose.providerId,
imageId: pose.imageId,
position: pose.position,
isPano: pose.isPano,
capturedAt: pose.capturedAt,
capturedAtPrecision: pose.capturedAtPrecision,
creator: pose.creator,
title: pose.title,
},
{
providerId: 'google',
imageId: 'pano-a',
position: { lon: -121.4944, lat: 38.5816 },
isPano: true,
capturedAt: Date.UTC(2024, 4, 1),
capturedAtPrecision: 'month',
creator: 'Google',
title: '10th St',
},
);
assert.match(pose.externalUrl, /map_action=pano&pano=pano-a/);
});
test('dragging the view and stepping along an arrow both emit poses; the date is looked up once per panorama', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
await viewer.open('pano-a');
const [panorama] = fake.built;
panorama.setPov({ heading: 90, pitch: -10 });
await settle();
assert.deepEqual(
[poses.at(-1).bearing, poses.at(-1).tilt],
[90, -10],
'the view follows the drag',
);
// Google's own arrow: the panorama changes without an open.
panorama.setPano('pano-b');
await settle();
await settle();
assert.equal(poses.at(-1).imageId, 'pano-b');
assert.equal(poses.at(-1).creator, 'Ada');
const dateLookups = fake.lookups.filter((request) => request.pano);
assert.deepEqual(
dateLookups.map((request) => request.pano),
['pano-a', 'pano-b'],
);
});
test('a panorama Google has no imagery for fails the open', async () => {
const { viewer, host } = setup();
await viewer.mount(host);
await assert.rejects(viewer.open('gone'), /no imagery/);
});
test('a closed panorama is hidden and its late view changes emit nothing', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
await viewer.open('pano-a');
const [panorama] = fake.built;
viewer.close();
assert.equal(panorama.visible, false);
const count = poses.length;
panorama.setPov({ heading: 180, pitch: 0 });
await settle();
assert.equal(poses.length, count);
});
test('an open overtaken by a newer one only shows the newer panorama', async () => {
const { viewer, poses, host } = setup();
await viewer.mount(host);
const first = viewer.open('pano-a');
const second = viewer.open('pano-b');
await Promise.all([first, second]);
assert.deepEqual(
[...new Set(poses.map((pose) => pose.imageId))].at(-1),
'pano-b',
);
assert.ok(!poses.some((pose) => pose.imageId === 'pano-a'));
});
test('unmounting removes the element and every listener; prewarm builds no panorama', async () => {
const { fake, loader, viewer, host } = setup();
await viewer.prewarm(host);
assert.deepEqual(loader.imports, ['streetView']);
assert.equal(fake.built.length, 0, 'a panorama is billed: none on prewarm');
await viewer.mount(host);
const [panorama] = fake.built;
assert.ok(panorama.listenerCount() > 0);
viewer.unmount();
assert.equal(host.children.length, 0);
assert.equal(panorama.listenerCount(), 0);
await assert.rejects(viewer.open('pano-a'), /not mounted/);
});
test('a key Google refuses fails the open at once, not after the timeout', async () => {
const { fake, loader, viewer, host } = setup();
await viewer.mount(host);
const [panorama] = fake.built;
// Google refuses the key while the panorama loads; it never reports OK.
panorama.setPano = (id) => {
panorama.pano = id;
queueMicrotask(() => loader.refuseKey());
};
await assert.rejects(viewer.open('pano-a'), /enable the Maps JavaScript API/);
await assert.rejects(viewer.open('pano-b'), /enable the Maps JavaScript API/);
assert.equal(loader.listenerCount(), 0, 'no auth listener is left behind');
});
+4
View File
@@ -9,6 +9,8 @@
* @property {string} pickPrefix Every primitive id the provider creates starts with it. * @property {string} pickPrefix Every primitive id the provider creates starts with it.
* @property {{coverage: string}} colors The source's one colour, from policy.js PROVIDER_COLORS. * @property {{coverage: string}} colors The source's one colour, from policy.js PROVIDER_COLORS.
* @property {{html: string}} credit On-globe attribution while the provider is active. * @property {{html: string}} credit On-globe attribution while the provider is active.
* @property {boolean} [groundClick] No coverage to click: a click on the ground at street zoom opens its nearest image.
* @property {boolean} [defaultOn] Switched on until the user says otherwise (default true); match the share-link option default.
* @property {(context: ProviderContext) => ProviderInstance} create * @property {(context: ProviderContext) => ProviderInstance} create
* *
* @typedef {object} ProviderContext Handed to `create()` once by the core. * @typedef {object} ProviderContext Handed to `create()` once by the core.
@@ -63,6 +65,8 @@
* @property {number|null} altitude * @property {number|null} altitude
* @property {boolean} isPano * @property {boolean} isPano
* @property {number|null} capturedAt Epoch milliseconds. * @property {number|null} capturedAt Epoch milliseconds.
* @property {'day'|'month'} [capturedAtPrecision] How exactly the provider dates images (default 'day').
* @property {string|null} [title] A place name for the caption, e.g. the address.
* @property {string|null} creator * @property {string|null} creator
* @property {string|null} sequenceId * @property {string|null} sequenceId
* @property {string} externalUrl Deep link to the image on the provider's site. * @property {string} externalUrl Deep link to the image on the provider's site.
+5 -1
View File
@@ -21,7 +21,11 @@ export function createSelection({ state, parts }) {
? picking.resolvePickId(picked) ? picking.resolvePickId(picked)
: picked?.id; : picked?.id;
const route = parts.router.resolve(id); const route = parts.router.resolve(id);
if (!route?.instance) return; // Nothing of ours, nor anyone else's: the ground itself.
if (!route?.instance) {
if (!id) parts.openAtGround?.(click.position);
return;
}
const entry = state.providers.get(route.providerId); const entry = state.providers.get(route.providerId);
if (!entry?.on) return; if (!entry?.on) return;
route.instance.handlePick(route.id); route.instance.handlePick(route.id);
+21
View File
@@ -34,6 +34,7 @@ function harness({ selected = false } = {}) {
removeListener(type, listener, options); removeListener(type, listener, options);
}; };
const sequences = { selected, cleared: 0 }; const sequences = { selected, cleared: 0 };
const groundClicks = [];
const picks = []; const picks = [];
/** What the pointer is over: a line this layer owns, or nothing. */ /** What the pointer is over: a line this layer owns, or nothing. */
const scene = { under: null }; const scene = { under: null };
@@ -66,6 +67,7 @@ function harness({ selected = false } = {}) {
parts: { parts: {
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null }, router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
hasSelectedSequence: () => sequences.selected, hasSelectedSequence: () => sequences.selected,
openAtGround: (position) => groundClicks.push(position),
clearSequences() { clearSequences() {
sequences.cleared++; sequences.cleared++;
sequences.selected = false; sequences.selected = false;
@@ -76,6 +78,9 @@ function harness({ selected = false } = {}) {
const onMove = state.clickHandler.getInputAction( const onMove = state.clickHandler.getInputAction(
Cesium.ScreenSpaceEventType.MOUSE_MOVE, Cesium.ScreenSpaceEventType.MOUSE_MOVE,
); );
const onClick = state.clickHandler.getInputAction(
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
return { return {
state, state,
sequences, sequences,
@@ -97,6 +102,8 @@ function harness({ selected = false } = {}) {
camera, camera,
picks, picks,
move: (x) => onMove({ endPosition: { x, y: 10 } }), move: (x) => onMove({ endPosition: { x, y: 10 } }),
click: (x) => onClick({ position: { x, y: 10 } }),
groundClicks,
frame() { frame() {
for (const task of frames.splice(0)) task(); for (const task of frames.splice(0)) task();
}, },
@@ -256,3 +263,17 @@ test('Esc in a text field or rich-text editor keeps the selection (M61)', () =>
h.restore(); h.restore();
} }
}); });
test('a click on bare ground goes to the ground handler; a click on anything with an id does not', () => {
const h = harness();
try {
h.click(5);
assert.deepEqual(h.groundClicks, [{ x: 5, y: 10 }]);
// Another layer's aircraft, or an id nobody registered: not the ground.
h.scene.under = 'flight:abc';
h.click(6);
assert.equal(h.groundClicks.length, 1);
} finally {
h.restore();
}
});
+6
View File
@@ -18,6 +18,12 @@ export function freshStreet() {
altitude: null, altitude: null,
isPano: false, isPano: false,
capturedAt: null, capturedAt: null,
/** 'day', or 'month' for providers that date images by month (Google). */
capturedAtPrecision: 'day',
/** A place name for the caption (Google's address), or null. */
title: null,
/** Whether the open viewer has FIT/FILL render modes. */
renderModes: true,
sequenceId: null, sequenceId: null,
creator: null, creator: null,
externalUrl: null, externalUrl: null,
+1 -1
View File
@@ -27,7 +27,7 @@ export function summarizeCoverage(providers) {
return { return {
count: active.reduce((sum, p) => sum + (p.count || 0), 0), count: active.reduce((sum, p) => sum + (p.count || 0), 0),
loading: active.some((p) => p.loading), loading: active.some((p) => p.loading),
hint: active.find((p) => p.hint)?.hint || '', hint: [...new Set(active.map((p) => p.hint).filter(Boolean))].join(' · '),
error: active.find((p) => p.error)?.error || null, error: active.find((p) => p.error)?.error || null,
keyRequired: keyRequired:
active.length > 0 && active.every((p) => p.keyRequired === true), active.length > 0 && active.every((p) => p.keyRequired === true),
+19
View File
@@ -149,3 +149,22 @@ test('the surface mode defaults to draped and passes through', () => {
'terrain', 'terrain',
); );
}); });
test('every switched-on provider’s hint is kept, once each', async () => {
const { summarizeCoverage } = await import('./uiState.js');
const { providerSnapshot } =
await import('../../testSupport/streetLevelFakes.mjs');
const summary = summarizeCoverage([
providerSnapshot({ hint: 'zoom in for sequences' }),
providerSnapshot({
id: 'google',
hint: 'click a street to open Street View',
}),
providerSnapshot({ id: 'off', on: false, hint: 'not shown' }),
providerSnapshot({ id: 'dup', hint: 'zoom in for sequences' }),
]);
assert.equal(
summary.hint,
'zoom in for sequences · click a street to open Street View',
);
});
+31
View File
@@ -295,3 +295,34 @@ export function createHorizonCull({ getViewer, items, onChange }) {
return { update, stop }; return { update, stop };
} }
/**
* The ground under a screen position as {lon, lat}: the rendered surface
* (Google 3D, terrain) where it was picked, else the ellipsoid. Null over sky
* or for a pick that names no place (`isWorldPosition` rejects it).
*/
export function groundPointAt(viewer, screenPosition, { isWorldPosition }) {
const scene = viewer?.scene;
let cartesian = null;
if (scene?.pickPositionSupported) {
try {
cartesian = scene.pickPosition(screenPosition);
} catch {
cartesian = null;
}
}
if (!isWorldPosition(cartesian)) {
try {
cartesian = viewer?.camera?.pickEllipsoid(screenPosition);
} catch {
cartesian = null;
}
}
if (!isWorldPosition(cartesian)) return null;
const carto = Cesium.Cartographic.fromCartesian(cartesian);
if (!carto) return null;
return {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
};
}
+13
View File
@@ -33,6 +33,9 @@ export function createViewerHost({ state, parts }) {
altitude: Number.isFinite(pose.altitude) ? pose.altitude : null, altitude: Number.isFinite(pose.altitude) ? pose.altitude : null,
isPano: pose.isPano === true, isPano: pose.isPano === true,
capturedAt: pose.capturedAt ?? null, capturedAt: pose.capturedAt ?? null,
capturedAtPrecision:
pose.capturedAtPrecision === 'month' ? 'month' : 'day',
title: pose.title || null,
sequenceId: pose.sequenceId ?? null, sequenceId: pose.sequenceId ?? null,
creator: pose.creator || null, creator: pose.creator || null,
externalUrl: pose.externalUrl || null, externalUrl: pose.externalUrl || null,
@@ -68,7 +71,15 @@ export function createViewerHost({ state, parts }) {
active.adapter.unmount(); active.adapter.unmount();
active = null; active = null;
} }
// Another provider's prewarmed viewer shares the host: it would sit
// under this one, holding its WebGL context.
for (const [id, warm] of warmed) {
if (id === entry.def.id) continue;
warmed.delete(id);
warm.unmount();
}
const adapter = entry.instance.viewer; const adapter = entry.instance.viewer;
state.street.renderModes = typeof adapter.setRenderMode === 'function';
const promise = (async () => { const promise = (async () => {
await adapter.mount(state.street.host); await adapter.mount(state.street.host);
if (mounting?.promise !== promise) { if (mounting?.promise !== promise) {
@@ -183,6 +194,8 @@ export function createViewerHost({ state, parts }) {
for (const entry of entries) { for (const entry of entries) {
const adapter = entry.instance.viewer; const adapter = entry.instance.viewer;
if (!adapter.prewarm || !entry.on) continue; if (!adapter.prewarm || !entry.on) continue;
// Another provider's viewer is showing in the host: do not build under it.
if (active && active.id !== entry.def.id) continue;
try { try {
await adapter.prewarm(host); await adapter.prewarm(host);
} catch { } catch {
@@ -576,3 +576,60 @@ test('a photo asks for the camera when it starts opening and frames with that ti
assert.equal(await host.open('mapillary', 'b'), true); assert.equal(await host.open('mapillary', 'b'), true);
assert.deepEqual([framing.begun, framing.started], [1, 1]); assert.deepEqual([framing.begun, framing.started], [1, 1]);
}); });
/** The harness with a second provider, `google`, whose adapter has no render modes. */
function twoProviders() {
const mapillary = fakeAdapter();
mapillary.prewarm = async () => {};
mapillary.setRenderMode = () => {};
const google = fakeAdapter();
google.prewarm = async () => {};
const h = harness(mapillary);
h.state.providers.set('google', {
def: { id: 'google', name: 'Google Street View', label: 'STREET VIEW' },
instance: { viewer: google },
on: true,
});
h.state.providers.get('mapillary').on = true;
return { ...h, mapillary, google };
}
test("opening one provider's image releases another's prewarmed viewer in the shared host", async () => {
const { state, host, mapillary, google } = twoProviders();
await host.prewarm([state.providers.get('mapillary')]);
assert.equal(mapillary.calls.unmount, 0, 'warm');
await host.open('google', 'g-1');
assert.equal(mapillary.calls.unmount, 1, 'not left under the panorama');
assert.equal(google.calls.unmount, 0);
// While Google shows, Mapillary is not built again under it.
let built = 0;
mapillary.prewarm = async () => built++;
await host.prewarm([...state.providers.values()]);
assert.equal(built, 0);
});
test('the render modes follow the viewer that is open', async () => {
const { state, host } = twoProviders();
await host.open('mapillary', 'm-1');
assert.equal(state.street.renderModes, true);
await host.open('google', 'g-1');
assert.equal(state.street.renderModes, false, 'Google always fills');
});
test('a pose can date its image by month and name its place', async () => {
const { state, host, google } = twoProviders();
await host.open('google', 'g-1');
google.emitPose('g-1', {
providerId: 'google',
capturedAtPrecision: 'month',
title: '1014 10th St',
capturedAt: Date.UTC(2023, 0, 1),
});
assert.equal(state.street.capturedAtPrecision, 'month');
assert.equal(state.street.title, '1014 10th St');
host.close();
assert.deepEqual(
[state.street.capturedAtPrecision, state.street.title],
['day', null],
);
});
+169
View File
@@ -0,0 +1,169 @@
/**
* Stand-ins for the Google Maps JavaScript API's Street View library and the
* loader that hands it out. Panoramas are {id: {lat, lng, imageDate, copyright}};
* `nearest(location, radius)` answers location lookups with an id or null.
*/
function latLng(lat, lng) {
return { lat: () => lat, lng: () => lng };
}
export function fakeStreetViewLibrary({
panoramas = {},
nearest = () => null,
} = {}) {
const built = [];
const lookups = [];
class StreetViewPanorama {
constructor(element, options) {
this.element = element;
this.options = options;
this.visible = options?.visible ?? true;
this.pano = null;
this.status = null;
this.pov = { heading: 0, pitch: 0 };
this.listeners = new Map();
built.push(this);
}
addListener(name, listener) {
if (!this.listeners.has(name)) this.listeners.set(name, new Set());
this.listeners.get(name).add(listener);
return { remove: () => this.listeners.get(name)?.delete(listener) };
}
fire(name) {
for (const listener of [...(this.listeners.get(name) || [])]) listener();
}
listenerCount() {
let count = 0;
for (const set of this.listeners.values()) count += set.size;
return count;
}
/** Like Google: the panorama loads asynchronously, then reports. */
setPano(id) {
this.pano = id;
queueMicrotask(() => {
if (this.pano !== id) return;
const known = panoramas[id];
this.status = known ? 'OK' : 'ZERO_RESULTS';
if (known) {
this.fire('pano_changed');
this.fire('position_changed');
}
this.fire('status_changed');
});
}
getPano() {
return this.pano;
}
getStatus() {
return this.status;
}
getPosition() {
const known = panoramas[this.pano];
return known ? latLng(known.lat, known.lng) : null;
}
getPov() {
return { ...this.pov };
}
/** The user drags the view. */
setPov(pov) {
this.pov = { ...pov };
this.fire('pov_changed');
}
setVisible(visible) {
this.visible = visible;
}
}
class StreetViewService {
async getPanorama(request) {
lookups.push(request);
const id = request.pano ?? nearest(request.location, request.radius);
const known = id && panoramas[id];
if (!known)
throw Object.assign(new Error('ZERO_RESULTS'), {
code: 'ZERO_RESULTS',
});
return {
data: {
location: {
pano: id,
latLng: latLng(known.lat, known.lng),
description: known.description,
},
imageDate: known.imageDate,
copyright: known.copyright,
},
};
}
}
return {
library: {
StreetViewPanorama,
StreetViewService,
StreetViewSource: { GOOGLE: 'google', OUTDOOR: 'outdoor' },
StreetViewPreference: { NEAREST: 'nearest', BEST: 'best' },
},
built,
lookups,
};
}
/** A loader over `library`; `refuseKey()` plays Google's gm_authFailure. */
export function fakeMapsLoader(library) {
let failed = false;
const listeners = new Set();
const imports = [];
return {
imports,
async importLibrary(name) {
imports.push(name);
return library;
},
authFailed: () => failed,
onAuthFailure(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
refuseKey() {
failed = true;
for (const listener of [...listeners]) listener();
},
listenerCount: () => listeners.size,
};
}
/** A host element with just what the viewer adapter touches. */
export function fakeHost() {
const children = [];
const host = {
children,
ownerDocument: {
createElement: () => {
const element = {
style: {},
className: '',
remove() {
const index = children.indexOf(element);
if (index !== -1) children.splice(index, 1);
},
};
return element;
},
},
append: (element) => children.push(element),
};
return host;
}
+7 -2
View File
@@ -515,15 +515,20 @@ test('evidence never carries a Mapillary token', () => {
test('--strict accepts only the documented skips', () => { test('--strict accepts only the documented skips', () => {
assert.deepEqual( assert.deepEqual(
[...STRICT_ALLOWED_SKIPS], [...STRICT_ALLOWED_SKIPS],
['no Google 3D', 'fixtures only'], ['no Google 3D', 'no Google key', 'fixtures only'],
); );
assert.deepEqual( assert.deepEqual(
strictViolations([ strictViolations([
{ reason: 'no Google 3D', label: 'terrain' }, { reason: 'no Google 3D', label: 'terrain' },
{ reason: 'no Google key', label: 'street view' },
{ reason: 'fixtures only', label: 'keyless page' }, { reason: 'fixtures only', label: 'keyless page' },
{ reason: 'no Mapillary key', label: 'the keyed steps' }, { reason: 'no Mapillary key', label: 'the keyed steps' },
{ reason: 'Google key refused', label: 'street view' },
]), ]),
[{ reason: 'no Mapillary key', label: 'the keyed steps' }], [
{ reason: 'no Mapillary key', label: 'the keyed steps' },
{ reason: 'Google key refused', label: 'street view' },
],
); );
}); });
+4 -2
View File
@@ -183,7 +183,7 @@ test('only embed-mode documents may be framed, and only by the allowed ancestors
} }
}); });
test('the CSP admits the event media embeds, and no other script origin', async () => { test('the CSP admits the event media embeds and the Maps JavaScript API, and no other script origin', async () => {
const { embeddedMediaFrameUrl, resolveEmbeddedMediaSource } = const { embeddedMediaFrameUrl, resolveEmbeddedMediaSource } =
await import('../../src/data/bhoteKoshiEmbeddedMedia.js'); await import('../../src/data/bhoteKoshiEmbeddedMedia.js');
const directive = (name) => const directive = (name) =>
@@ -201,7 +201,8 @@ test('the CSP admits the event media embeds, and no other script origin', async
'https://platform.twitter.com', 'https://platform.twitter.com',
]) ])
assert.ok(scripts.includes(origin), origin); assert.ok(scripts.includes(origin), origin);
// Only those three, and never a wildcard or a scheme. // Only those three and Google Street View's Maps JavaScript API, never a
// wildcard or a scheme.
assert.deepEqual( assert.deepEqual(
scripts.filter( scripts.filter(
(source) => source.startsWith('http') || source === 'https:', (source) => source.startsWith('http') || source === 'https:',
@@ -210,6 +211,7 @@ test('the CSP admits the event media embeds, and no other script origin', async
'https://www.youtube.com', 'https://www.youtube.com',
'https://connect.facebook.net', 'https://connect.facebook.net',
'https://platform.twitter.com', 'https://platform.twitter.com',
'https://maps.googleapis.com',
], ],
); );
assert.ok(!scripts.some((source) => source.includes('*'))); assert.ok(!scripts.some((source) => source.includes('*')));
+3
View File
@@ -577,6 +577,9 @@ export class StreetLevelControls {
el.renderButtons, el.renderButtons,
(button) => button.dataset.slRender === viewer.renderMode, (button) => button.dataset.slRender === viewer.renderMode,
); );
// Google's viewer always fills its frame: no FIT/FILL to offer.
const renderGroup = el.renderButtons[0]?.parentElement;
if (renderGroup) setProp(renderGroup, 'hidden', !viewer.renderModes);
if (!viewer.open) return; if (!viewer.open) return;
setProp(el.imageBy, 'textContent', viewer.captionLeft); setProp(el.imageBy, 'textContent', viewer.captionLeft);
setProp(el.imageWhen, 'textContent', viewer.captionRight); setProp(el.imageWhen, 'textContent', viewer.captionRight);
+18 -6
View File
@@ -44,10 +44,11 @@ function presentSince(days, now) {
}; };
} }
function formatDate(ms) { /** YYYY-MM-DD, or YYYY-MM for a provider that dates images by month. */
function formatDate(ms, precision = 'day') {
if (!Number.isFinite(ms)) return ''; if (!Number.isFinite(ms)) return '';
try { try {
return new Date(ms).toISOString().slice(0, 10); return new Date(ms).toISOString().slice(0, precision === 'month' ? 7 : 10);
} catch { } catch {
return ''; return '';
} }
@@ -104,12 +105,17 @@ function presentViewer(state) {
if (street.isPano) right.push('360°'); if (street.isPano) right.push('360°');
if (Number.isFinite(street.bearing)) if (Number.isFinite(street.bearing))
right.push(`${Math.round(street.bearing)}°`); right.push(`${Math.round(street.bearing)}°`);
if (street.capturedAt) right.push(formatDate(street.capturedAt)); if (street.capturedAt)
right.push(formatDate(street.capturedAt, street.capturedAtPrecision));
const left = [];
if (street.title) left.push(street.title);
if (street.creator) left.push(`Image by ${street.creator}`);
return { return {
open: street.open === true, open: street.open === true,
loading: street.loading === true && !street.imageId, loading: street.loading === true && !street.imageId,
renderMode: street.renderMode === 'fill' ? 'fill' : 'letterbox', renderMode: street.renderMode === 'fill' ? 'fill' : 'letterbox',
captionLeft: street.creator ? `Image by ${street.creator}` : '', renderModes: street.renderModes !== false,
captionLeft: left.join(' · '),
captionRight: right.join(' · '), captionRight: right.join(' · '),
link: street.externalUrl || null, link: street.externalUrl || null,
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '', linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
@@ -130,9 +136,15 @@ function presentMeta(state) {
return state.sequence.loading return state.sequence.loading
? 'Loading this sequence…' ? 'Loading this sequence…'
: `${state.sequence.images.toLocaleString()} images in this sequence · Esc clears`; : `${state.sequence.images.toLocaleString()} images in this sequence · Esc clears`;
const hint = state.coverage.hint || '';
if (state.coverage.count > 0) if (state.coverage.count > 0)
return `${state.coverage.count.toLocaleString()} sequences in view · click a line for its photos`; return [
return state.coverage.hint || ''; `${state.coverage.count.toLocaleString()} sequences in view · click a line for its photos`,
hint,
]
.filter(Boolean)
.join(' · ');
return hint;
} }
/** @param {{now?: number}} [options] Clock for the SINCE readout. */ /** @param {{now?: number}} [options] Clock for the SINCE readout. */
+46
View File
@@ -388,3 +388,49 @@ test('FOLLOW is disabled off Google 3D and says where to switch', () => {
); );
assert.equal(closed.viewer.follow.disabled, true, 'nothing to follow'); assert.equal(closed.viewer.follow.disabled, true, 'nothing to follow');
}); });
test('a Street View caption names the place, dates it by month, and offers no FIT/FILL', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
street: {
open: true,
providerId: 'google',
providerName: 'Google Street View',
providerLabel: 'STREET VIEW',
imageId: 'pano-1',
title: '1014 10th St',
creator: 'Google',
capturedAt: Date.UTC(2023, 0, 1),
capturedAtPrecision: 'month',
bearing: 0,
isPano: true,
renderModes: false,
externalUrl:
'https://www.google.com/maps/@?api=1&map_action=pano&pano=pano-1',
},
}),
);
assert.equal(view.viewer.captionLeft, '1014 10th St · Image by Google');
assert.equal(view.viewer.captionRight, '360° · 0° · 2023-01');
assert.equal(view.viewer.linkLabel, 'STREET VIEW ↗');
assert.equal(view.viewer.renderModes, false);
});
test('the meta line keeps the sequence count and adds the other providers’ hints', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
coverage: {
loading: false,
count: 2514,
hint: 'click a street to open Street View',
error: null,
},
}),
);
assert.equal(
view.meta,
`${(2514).toLocaleString()} sequences in view · click a line for its photos · click a street to open Street View`,
);
});
+4
View File
@@ -207,6 +207,10 @@ button.sl-status:focus-visible {
border-radius: 999px; border-radius: 999px;
} }
.sl-seg[hidden] {
display: none;
}
.sl-seg-btn { .sl-seg-btn {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: var(--sl-text-small); font-size: var(--sl-text-small);