mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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:
1 parent
95fa816232
commit
22b0009138
42 files changed
+1879
-66
No files matched your search
+2
-1
@@ -18,7 +18,8 @@
|
||||
# (they're used client-side) and WILL be visible in devtools. Restrict/scope them rather than trying to hide
|
||||
# them (see SECURITY.md). All other keys below stay server-side.
|
||||
|
||||
# Optional: direct Google Photorealistic 3D Tiles and GEV place search.
|
||||
# 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
|
||||
# neither credential, the app starts on keyless Esri World Imagery with OSM
|
||||
# available in the map tray and as the automatic provider-failure fallback.
|
||||
|
||||
@@ -63,6 +63,13 @@
|
||||
|
||||
## [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
|
||||
process can make requests through another. Concurrent malformed-key repairs
|
||||
now select one winner; unavailable storage or a busy repair retains the
|
||||
|
||||
@@ -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 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 |
|
||||
| **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 |
|
||||
| **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 |
|
||||
|
||||
@@ -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 |
|
||||
| 📹 **CCTV Mesh** | ~3,900 public cameras projected _into_ the 3D space — Austin · Texas (TxDOT) · California (Caltrans) · London (TfL) · Ontario (511) · Finland (Fintraffic) · British Columbia (DriveBC) · Estonia (Tallinn, Tarktee) · Delaware (DelDOT live video) · New South Wales (Live Traffic NSW) · Calgary · Norway (Statens vegvesen). Positions are published; poses are estimated priors **you calibrate by dragging a gizmo on the camera itself** | City APIs | 🟢 |
|
||||
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
|
||||
| 📷 **Street Level** | Street-level 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 | 🟢 |
|
||||
| 🚌 **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 | 🟢 |
|
||||
@@ -334,13 +334,13 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
|
||||
|
||||
## 📷 Street Level
|
||||
|
||||
> Street-level photos from any provider in one panel. This build ships **Mapillary**, which needs a free client token.
|
||||
> 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
|
||||
|
||||
@@ -428,7 +428,7 @@ Seven keys. Five have a free tier, and the two 🔴 ones are metered:
|
||||
| | 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/) |
|
||||
| 🔴 | **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 |
|
||||
| 🟡 | **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 |
|
||||
|
||||
+2
-2
@@ -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:
|
||||
|
||||
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.
|
||||
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).
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
|
||||
+3
-2
@@ -20,8 +20,9 @@ import { panelBuildPlugin } from './panel.js';
|
||||
export const BROWSER_CSP = [
|
||||
"default-src 'self'",
|
||||
// The Bhote Koshi event's embedded media: the YouTube player API, the
|
||||
// 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",
|
||||
// Facebook video SDK and X's post widget (src/data/bhoteKoshiEmbeddedMedia.js);
|
||||
// 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",
|
||||
"font-src 'self' data: https://fonts.gstatic.com",
|
||||
"img-src 'self' data: blob: https:",
|
||||
|
||||
+20
-4
@@ -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
|
||||
provider draws in one colour (`PROVIDER_COLORS` in
|
||||
`src/layers/streetLevel/policy.js`); 360° cones are rings and the selected
|
||||
sequence is GEV cyan. Share options: `m` (Mapillary), `p` (`a`/`p`/`f`
|
||||
panoramas), `s` (since, days). Only Mapillary is registered. Without
|
||||
sequence is GEV cyan. Share options: `m` (Mapillary), `g` (Google Street
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
`src/app/layers/streetLevel.js`, add its boolean option to the `street-level`
|
||||
group in `src/data/layerState.js` and its modules to
|
||||
`scripts/package-boundaries.json`. Once a keyless provider registers, the
|
||||
layer's `requiresKeyId` becomes null and the key gate moves to the chips.
|
||||
`scripts/package-boundaries.json`. With providers on different keys the
|
||||
layer's `requiresKeyId` is null and each chip reports its own key.
|
||||
|
||||
Google Street View (`providers/google/`) runs on the Maps JavaScript API with
|
||||
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>`
|
||||
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
|
||||
|
||||
@@ -263,6 +263,7 @@
|
||||
"./layers/street-level": "./src/layers/streetLevel/index.js",
|
||||
"./layers/street-level/mapillary": "./src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"./layers/street-level/mapillary/source": "./src/layers/streetLevel/providers/mapillary/source.js",
|
||||
"./layers/street-level/google": "./src/layers/streetLevel/providers/google/index.js",
|
||||
"./tools": "./src/tools/index.js",
|
||||
"./tools/mcp": "./src/tools/mcp/index.js",
|
||||
"./tools/panel": "./src/tools/panel.js",
|
||||
|
||||
@@ -893,6 +893,16 @@
|
||||
"src/layers/streetLevel/selection.js",
|
||||
"src/layers/streetLevel/providers/mapillary/viewer.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/routes.js",
|
||||
"server/providers/mapillary/constants.js",
|
||||
|
||||
@@ -1188,7 +1188,8 @@
|
||||
"exports": [
|
||||
"./layers/street-level",
|
||||
"./layers/street-level/mapillary",
|
||||
"./layers/street-level/mapillary/source"
|
||||
"./layers/street-level/mapillary/source",
|
||||
"./layers/street-level/google"
|
||||
],
|
||||
"modules": [
|
||||
"src/layers/streetLevel/index.js",
|
||||
@@ -1216,6 +1217,11 @@
|
||||
"src/layers/streetLevel/tileMath.js",
|
||||
"src/layers/streetLevel/selection.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",
|
||||
"scripts/qa-street-level.mjs"
|
||||
],
|
||||
@@ -2164,6 +2170,11 @@
|
||||
"src/layers/streetLevel/tileMath.js",
|
||||
"src/layers/streetLevel/selection.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/app/layers/streetLevel.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
@@ -2686,6 +2697,11 @@
|
||||
"src/layers/streetLevel/tileMath.js",
|
||||
"src/layers/streetLevel/selection.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/app/layers/streetLevel.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
|
||||
+182
-11
@@ -49,18 +49,27 @@ export const VIEWPORTS = Object.freeze([
|
||||
]);
|
||||
|
||||
/** 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
|
||||
* stage fixture-only cases. `no Mapillary key` is not accepted: CI's dummy
|
||||
* token runs the keyed flow, so a keyless strict run means that wiring broke.
|
||||
* Skips no setup can avoid: CI forks have no Google key (so neither Google 3D
|
||||
* nor Street View), and live runs cannot stage fixture-only cases. `no
|
||||
* Mapillary key` is not accepted: CI's dummy token runs the keyed flow, so a
|
||||
* keyless strict run means that wiring broke. Nor is `Google key refused`: a
|
||||
* key that is set must work.
|
||||
*/
|
||||
export const STRICT_ALLOWED_SKIPS = Object.freeze([
|
||||
'no Google 3D',
|
||||
'no Google key',
|
||||
'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) {
|
||||
return skips.filter((skip) => !STRICT_ALLOWED_SKIPS.includes(skip.reason));
|
||||
}
|
||||
@@ -644,13 +653,16 @@ async function main() {
|
||||
].map((chip) => chip.dataset.chipId),
|
||||
);
|
||||
assert.deepEqual(chips, EXPECTED_PROVIDERS);
|
||||
// One swatch per source plus "Selected"; each chip wears its colour.
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.querySelectorAll('#sl-legend li').length,
|
||||
),
|
||||
EXPECTED_PROVIDERS.length + 1,
|
||||
);
|
||||
// One swatch per switched-on source plus "Selected" (Street View
|
||||
// starts off); each chip wears its colour.
|
||||
const legend = await page.evaluate(() => ({
|
||||
swatches: document.querySelectorAll('#sl-legend li').length,
|
||||
on: window.__godsEyeView.dataManager.layers
|
||||
.get('street-level')
|
||||
.module.getUIState()
|
||||
.providers.filter((provider) => provider.on).length,
|
||||
}));
|
||||
assert.equal(legend.swatches, legend.on + 1);
|
||||
assert.equal(
|
||||
await page.$eval('[data-chip-id="mapillary"]', (chip) =>
|
||||
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(
|
||||
'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token',
|
||||
async () => {
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { createStreetLevelLayer } from '../../layers/streetLevel/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 picking from '../../data/pickRegistry.js';
|
||||
import * as input from '../../data/inputOwnership.js';
|
||||
import * as scenePick from '../../data/scenePick.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. */
|
||||
export function createApplicationStreetLevel({ surface, sources }) {
|
||||
return createStreetLevelLayer({
|
||||
providers: [createMapillaryProvider({ source: sources.mapillary })],
|
||||
providers: [
|
||||
createMapillaryProvider({ source: sources.mapillary }),
|
||||
createGoogleProvider({ getApiKey: googleApiKey }),
|
||||
],
|
||||
services: {
|
||||
sprites,
|
||||
picking,
|
||||
input,
|
||||
scenePick,
|
||||
render,
|
||||
ground: surface?.groundFloor ?? null,
|
||||
meshFloor: surface?.meshFloor ?? null,
|
||||
|
||||
@@ -413,6 +413,8 @@ const OPTION_GROUPS = Object.freeze({
|
||||
'street-level': Object.freeze([
|
||||
// One switch per registered imagery provider (src/app/layers/streetLevel.js).
|
||||
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'], {
|
||||
all: 'a',
|
||||
pano: 'p',
|
||||
|
||||
@@ -243,7 +243,11 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
}
|
||||
assert.equal(nextLayerStateToken(), '3');
|
||||
assert.equal(
|
||||
nextLayerStateToken({ ...LAYER_STATE_TOKEN_RESERVATIONS, alpha: '3', bravo: '4' }),
|
||||
nextLayerStateToken({
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
alpha: '3',
|
||||
bravo: '4',
|
||||
}),
|
||||
'5',
|
||||
);
|
||||
const digitsExhausted = {
|
||||
@@ -276,7 +280,9 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
[
|
||||
...'0123456789',
|
||||
...[...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]),
|
||||
),
|
||||
@@ -298,33 +304,37 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
{ ...LAYER_STATE_TOKEN_RESERVATIONS, future: '3', next: '4' },
|
||||
),
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '3',
|
||||
next: '4',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.throws(
|
||||
() => validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
}),
|
||||
() =>
|
||||
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
|
||||
...LAYER_STATE_TOKEN_RESERVATIONS,
|
||||
future: '00',
|
||||
}),
|
||||
/next free token 3/,
|
||||
);
|
||||
const beforeLastDigit = { ...digitsExhausted };
|
||||
delete beforeLastDigit['prior-9'];
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
beforeLastDigit,
|
||||
{ ...beforeLastDigit, futurePair: '00', futureDigit: '9' },
|
||||
),
|
||||
validateLayerStateAllocations(beforeLastDigit, {
|
||||
...beforeLastDigit,
|
||||
futurePair: '00',
|
||||
futureDigit: '9',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
validateLayerStateAllocations(
|
||||
digitsExhausted,
|
||||
{ ...digitsExhausted, pairB: '01', pairA: '00' },
|
||||
),
|
||||
validateLayerStateAllocations(digitsExhausted, {
|
||||
...digitsExhausted,
|
||||
pairB: '01',
|
||||
pairA: '00',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
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.options['street-level'] = {
|
||||
mapillary: false,
|
||||
google: true,
|
||||
pano: 'flat',
|
||||
sinceDays: MAX_SINCE_DAYS,
|
||||
};
|
||||
const decoded = decodeLayerStateParams(new URLSearchParams(encode(state)));
|
||||
assert.deepEqual(decoded.options['street-level'], {
|
||||
mapillary: false,
|
||||
google: true,
|
||||
pano: 'flat',
|
||||
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);
|
||||
});
|
||||
|
||||
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(''), {
|
||||
mapillary: true,
|
||||
google: false,
|
||||
pano: 'all',
|
||||
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.m.0').mapillary, false);
|
||||
assert.equal(
|
||||
|
||||
@@ -36,7 +36,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: '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',
|
||||
envVars: Object.freeze(['GOOGLE_MAPS_API_KEY']),
|
||||
tier: 'metered',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
|
||||
import { decodeParams, encodeParams } from './params.js';
|
||||
import { composeUIState, summarizeCoverage } from './uiState.js';
|
||||
import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
|
||||
import { createGroundClick } from './groundClick.js';
|
||||
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
|
||||
import { createMeshSampler } from './meshSampler.js';
|
||||
|
||||
@@ -66,6 +67,11 @@ export function createStreetLevelLayer({
|
||||
entry.instance.clearSequence?.();
|
||||
};
|
||||
parts.selection = createSelection(context);
|
||||
parts.openAtGround = createGroundClick({
|
||||
state,
|
||||
parts,
|
||||
openNearest: (point, options) => layer.openNearest(point, options),
|
||||
});
|
||||
|
||||
let notifyQueued = false;
|
||||
function notify() {
|
||||
@@ -119,7 +125,7 @@ export function createStreetLevelLayer({
|
||||
const entry = {
|
||||
def,
|
||||
instance: null,
|
||||
on: true,
|
||||
on: def.defaultOn !== false,
|
||||
status: null,
|
||||
/** The error this provider last reported, until it withdraws it. */
|
||||
reportedError: null,
|
||||
@@ -472,8 +478,11 @@ export function createStreetLevelLayer({
|
||||
/** Imagery filter for coverage, cones and nearest-image lookups. */
|
||||
setCoverageFilter,
|
||||
openImage,
|
||||
/** Open the nearest image any active provider has around a point. */
|
||||
async openNearest(point) {
|
||||
/**
|
||||
* 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);
|
||||
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
|
||||
return false;
|
||||
@@ -485,7 +494,10 @@ export function createStreetLevelLayer({
|
||||
state.street.error = null;
|
||||
notify();
|
||||
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;
|
||||
try {
|
||||
imageId = await entry.instance.nearestImage(
|
||||
@@ -508,10 +520,13 @@ export function createStreetLevelLayer({
|
||||
}
|
||||
if (nearestLookup === lookup) nearestLookup = null;
|
||||
// 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 =
|
||||
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;
|
||||
notify();
|
||||
return false;
|
||||
|
||||
@@ -7,6 +7,7 @@ export const POSITION_PICK_ID = 'sl:pos';
|
||||
/** One colour per imagery source: chip, coverage lines, points, cones and legend. */
|
||||
export const PROVIDER_COLORS = Object.freeze({
|
||||
mapillary: '#05cb63',
|
||||
google: '#4285f4',
|
||||
});
|
||||
|
||||
/** 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. */
|
||||
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');
|
||||
});
|
||||
@@ -9,6 +9,8 @@
|
||||
* @property {string} pickPrefix Every primitive id the provider creates starts with it.
|
||||
* @property {{coverage: string}} colors The source's one colour, from policy.js PROVIDER_COLORS.
|
||||
* @property {{html: string}} credit On-globe attribution while the provider is active.
|
||||
* @property {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
|
||||
*
|
||||
* @typedef {object} ProviderContext Handed to `create()` once by the core.
|
||||
@@ -63,6 +65,8 @@
|
||||
* @property {number|null} altitude
|
||||
* @property {boolean} isPano
|
||||
* @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} sequenceId
|
||||
* @property {string} externalUrl Deep link to the image on the provider's site.
|
||||
|
||||
@@ -21,7 +21,11 @@ export function createSelection({ state, parts }) {
|
||||
? picking.resolvePickId(picked)
|
||||
: picked?.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);
|
||||
if (!entry?.on) return;
|
||||
route.instance.handlePick(route.id);
|
||||
|
||||
@@ -34,6 +34,7 @@ function harness({ selected = false } = {}) {
|
||||
removeListener(type, listener, options);
|
||||
};
|
||||
const sequences = { selected, cleared: 0 };
|
||||
const groundClicks = [];
|
||||
const picks = [];
|
||||
/** What the pointer is over: a line this layer owns, or nothing. */
|
||||
const scene = { under: null };
|
||||
@@ -66,6 +67,7 @@ function harness({ selected = false } = {}) {
|
||||
parts: {
|
||||
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
|
||||
hasSelectedSequence: () => sequences.selected,
|
||||
openAtGround: (position) => groundClicks.push(position),
|
||||
clearSequences() {
|
||||
sequences.cleared++;
|
||||
sequences.selected = false;
|
||||
@@ -76,6 +78,9 @@ function harness({ selected = false } = {}) {
|
||||
const onMove = state.clickHandler.getInputAction(
|
||||
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
|
||||
);
|
||||
const onClick = state.clickHandler.getInputAction(
|
||||
Cesium.ScreenSpaceEventType.LEFT_CLICK,
|
||||
);
|
||||
return {
|
||||
state,
|
||||
sequences,
|
||||
@@ -97,6 +102,8 @@ function harness({ selected = false } = {}) {
|
||||
camera,
|
||||
picks,
|
||||
move: (x) => onMove({ endPosition: { x, y: 10 } }),
|
||||
click: (x) => onClick({ position: { x, y: 10 } }),
|
||||
groundClicks,
|
||||
frame() {
|
||||
for (const task of frames.splice(0)) task();
|
||||
},
|
||||
@@ -256,3 +263,17 @@ test('Esc in a text field or rich-text editor keeps the selection (M61)', () =>
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -18,6 +18,12 @@ export function freshStreet() {
|
||||
altitude: null,
|
||||
isPano: false,
|
||||
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,
|
||||
creator: null,
|
||||
externalUrl: null,
|
||||
|
||||
@@ -27,7 +27,7 @@ export function summarizeCoverage(providers) {
|
||||
return {
|
||||
count: active.reduce((sum, p) => sum + (p.count || 0), 0),
|
||||
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,
|
||||
keyRequired:
|
||||
active.length > 0 && active.every((p) => p.keyRequired === true),
|
||||
|
||||
@@ -149,3 +149,22 @@ test('the surface mode defaults to draped and passes through', () => {
|
||||
'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',
|
||||
);
|
||||
});
|
||||
@@ -295,3 +295,34 @@ export function createHorizonCull({ getViewer, items, onChange }) {
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -33,6 +33,9 @@ export function createViewerHost({ state, parts }) {
|
||||
altitude: Number.isFinite(pose.altitude) ? pose.altitude : null,
|
||||
isPano: pose.isPano === true,
|
||||
capturedAt: pose.capturedAt ?? null,
|
||||
capturedAtPrecision:
|
||||
pose.capturedAtPrecision === 'month' ? 'month' : 'day',
|
||||
title: pose.title || null,
|
||||
sequenceId: pose.sequenceId ?? null,
|
||||
creator: pose.creator || null,
|
||||
externalUrl: pose.externalUrl || null,
|
||||
@@ -68,7 +71,15 @@ export function createViewerHost({ state, parts }) {
|
||||
active.adapter.unmount();
|
||||
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;
|
||||
state.street.renderModes = typeof adapter.setRenderMode === 'function';
|
||||
const promise = (async () => {
|
||||
await adapter.mount(state.street.host);
|
||||
if (mounting?.promise !== promise) {
|
||||
@@ -183,6 +194,8 @@ export function createViewerHost({ state, parts }) {
|
||||
for (const entry of entries) {
|
||||
const adapter = entry.instance.viewer;
|
||||
if (!adapter.prewarm || !entry.on) continue;
|
||||
// Another provider's viewer is showing in the host: do not build under it.
|
||||
if (active && active.id !== entry.def.id) continue;
|
||||
try {
|
||||
await adapter.prewarm(host);
|
||||
} catch {
|
||||
|
||||
@@ -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.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],
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -515,15 +515,20 @@ test('evidence never carries a Mapillary token', () => {
|
||||
test('--strict accepts only the documented skips', () => {
|
||||
assert.deepEqual(
|
||||
[...STRICT_ALLOWED_SKIPS],
|
||||
['no Google 3D', 'fixtures only'],
|
||||
['no Google 3D', 'no Google key', 'fixtures only'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
strictViolations([
|
||||
{ reason: 'no Google 3D', label: 'terrain' },
|
||||
{ reason: 'no Google key', label: 'street view' },
|
||||
{ reason: 'fixtures only', label: 'keyless page' },
|
||||
{ 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' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 } =
|
||||
await import('../../src/data/bhoteKoshiEmbeddedMedia.js');
|
||||
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',
|
||||
])
|
||||
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(
|
||||
scripts.filter(
|
||||
(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://connect.facebook.net',
|
||||
'https://platform.twitter.com',
|
||||
'https://maps.googleapis.com',
|
||||
],
|
||||
);
|
||||
assert.ok(!scripts.some((source) => source.includes('*')));
|
||||
|
||||
@@ -577,6 +577,9 @@ export class StreetLevelControls {
|
||||
el.renderButtons,
|
||||
(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;
|
||||
setProp(el.imageBy, 'textContent', viewer.captionLeft);
|
||||
setProp(el.imageWhen, 'textContent', viewer.captionRight);
|
||||
|
||||
@@ -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 '';
|
||||
try {
|
||||
return new Date(ms).toISOString().slice(0, 10);
|
||||
return new Date(ms).toISOString().slice(0, precision === 'month' ? 7 : 10);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
@@ -104,12 +105,17 @@ function presentViewer(state) {
|
||||
if (street.isPano) right.push('360°');
|
||||
if (Number.isFinite(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 {
|
||||
open: street.open === true,
|
||||
loading: street.loading === true && !street.imageId,
|
||||
renderMode: street.renderMode === 'fill' ? 'fill' : 'letterbox',
|
||||
captionLeft: street.creator ? `Image by ${street.creator}` : '',
|
||||
renderModes: street.renderModes !== false,
|
||||
captionLeft: left.join(' · '),
|
||||
captionRight: right.join(' · '),
|
||||
link: street.externalUrl || null,
|
||||
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
|
||||
@@ -130,9 +136,15 @@ function presentMeta(state) {
|
||||
return state.sequence.loading
|
||||
? 'Loading this sequence…'
|
||||
: `${state.sequence.images.toLocaleString()} images in this sequence · Esc clears`;
|
||||
const hint = state.coverage.hint || '';
|
||||
if (state.coverage.count > 0)
|
||||
return `${state.coverage.count.toLocaleString()} sequences in view · click a line for its photos`;
|
||||
return state.coverage.hint || '';
|
||||
return [
|
||||
`${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. */
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
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`,
|
||||
);
|
||||
});
|
||||
@@ -207,6 +207,10 @@ button.sl-status:focus-visible {
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.sl-seg[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sl-seg-btn {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--sl-text-small);
|
||||
|
||||
Reference in new issue
Block a user