Merge origin/main: Google Street View provider, translated

Main added the Google Street View street-level provider and key guidance
that offers another source. Its new text goes through t()/msg() with ru
and es entries; the key guidance the panel shows is built in the UI
language by src/ui/keyGuidance.js, while keySetupCore.mjs keeps the
English sentences the server and AI tools use.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
German KhokhlovandClaude Opus 5.5 committed 2026-10-13 20:34:43 +04:00
commit 8b0bb031b6
63 files changed
+4097 -210

No files matched your search

+2 -1
View File
@@ -18,7 +18,8 @@
# (they're used client-side) and WILL be visible in devtools. Restrict/scope them rather than trying to hide
# 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.
+13
View File
@@ -109,6 +109,19 @@
## [Unreleased]
- Street Level: Google Street View is the second provider. On the Google 3D
map (Google's terms keep it off other maps), 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, or press OPEN STREET VIEW, 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. Share links carry it in the provider switches
(`r.google-1`). One provider is lit at a time (lighting one switches the
other off), Mapillary by default since it is free. Street View's panoramas
are all 360°, so the IMAGERY and SINCE filters and the legend apply to
Mapillary only and hide while GOOGLE STREET VIEW is lit; its globe marker
is a Google-blue view cone.
- Add an experimental ChatGPT/Codex OAuth option for cloud voice in Provider
Settings. API-key voice remains the default. Local sign-in can start from
the auth button and reports completion, failure, timeout, or expired
+1
View File
@@ -21,6 +21,7 @@ How to read this:
| **NOAA nowCOAST lightning density** (`nowcoast.noaa.gov/geoserver/observations/lightning_detection/ows`) | Lightning density: 15-minute accumulated strike density on an approximately 8 km grid, Americas and Pacific. Keyless; layer metadata refreshed every 10 minutes, exact-time images cached for up to 24 hours | NOAA Level-5 derived product from Vaisala NLDN/GLD360; the [product description](https://ocean.weather.gov/lightning/lightning_pdd.php) permits public distribution of this density product, not of raw Vaisala detections | "NOAA/NWS nowCOAST; derived from Vaisala NLDN/GLD360" |
| **NOAA 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, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | 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 |
+6 -6
View File
@@ -302,7 +302,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 | 🟢 |
@@ -340,13 +340,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), and one is lit at a time: lighting one switches the other off. Mapillary, which is free, is lit by default. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary; Street View panoramas are all 360°, so with Street View lit the filters and legend are hidden. Click a coverage line for its image cones and a cone to open the photo. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary), 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 each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google").
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.r.google-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
@@ -434,7 +434,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** | 🎙️ Voice + AI HUD summary through an API key. Provider Settings also offers **USE CHATGPT OAUTH** for cloud voice without removing the API-key option | [platform.openai.com](https://platform.openai.com) — API-key usage is 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 |
+4 -1
View File
@@ -27,7 +27,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; add it to the key's API restrictions). **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.
@@ -74,6 +74,9 @@ 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 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.
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
- **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.
- **ChatGPT OAuth voice reuses the local Codex sign-in.** `/api/realtime/oauth-status`, `/api/realtime/oauth-login` and `/api/realtime/token?auth=oauth` answer only loopback connections, behind the same cross-site gate as the token endpoint; the login route also refuses a foreign `Origin`. None of them returns the access token. The login route runs `codex login` (or the executable named by `CODEX_BIN`) with an allowlisted environment that leaves out GEV's provider keys, keeps one attempt pending at a time and cancels it after two minutes or when the server stops. **Limits:** any program on this machine that can reach the server can mint Realtime sessions on the signed-in ChatGPT account, as it can with the API key; a local reverse proxy that hides the client's address defeats the loopback check; and OpenAI has not documented this use of Codex credentials for Realtime, so it may stop working or fall outside the account's terms.
+3 -2
View File
@@ -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:",
+62 -6
View File
@@ -1184,6 +1184,10 @@ The Node-only `gods-eye-view/server/providers/space` export supplies factories;
`sources/space` supplies fixed upstream URL builders with no I/O or environment
access. Callers retain validation, transport and response policy.
The shared TLE text parser skips incomplete named sets and resumes at the next
complete set. A missing orbital line does not discard the later satellites in
the catalog; satellite layers and next-pass tools use the same parser.
## Build configuration and local provider boundaries
`vite.config.js` delegates to `server/standalone/vite.config.js`, which loads
@@ -3105,8 +3109,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), `r` (the other providers' switches by id,
e.g. `google-1` for 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
@@ -3130,8 +3135,15 @@ miss cooldown. Roads on elevated decks are still drawn at
ground level. Framing a photo ignores mesh samples far below the bare earth
(unloaded tiles).
The header pill is the layer switch. With one provider its chip is a layer
switch too; with several, darkening the last lit chip turns the layer off. The
The header pill is the layer switch. Lighting a chip swaps the source;
clicking the lit chip turns the layer off. A chip without a usable key
explains the key instead of switching, and the other chips keep switching.
Lit off its map stack, a provider leaves the panel saying which map it
needs. When the switched-on providers lack
their keys but a switched-off one would draw with a key already set (no
Mapillary token, Street View on Google 3D with a Google key), the layer
reports it (`keyAlternatives`), and the panel's KEY REQUIRED, its coverage
line and the layer row's tooltip say to light it. The
viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the
image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and
stops when the stack changes. Camera moves go through the application's
@@ -3152,8 +3164,52 @@ 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`; a link without its switch keeps it off) because every
panorama load is billed to the key. Google's terms forbid Street View beside
non-Google maps, so the provider declares `requiresMapStack: 'photoreal'`:
off Google 3D it does not appear at all (no chip, legend swatch, globe
credit, layer source or button, and Google's script is not loaded), lookups
and opens skip it, and leaving Google 3D closes its panorama; the share
option keeps the user's choice for when Google 3D returns. Google offers no coverage layer
for other maps, so it draws none: with it on, a click on the map itself
(`scenePick.isSurfacePick`: nothing picked or Google 3D tile content, 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
and leaves the camera where it is (`groundClick.js`). Higher up the hint
says to zoom in. **OPEN STREET VIEW** beside that hint (`#sl-nearest-btn`)
does the same at the view centre, so Street View needs no pointer; it is
disabled until the camera is at street zoom (and aimed at the sky, it opens
nothing and says to aim at a street). One provider is lit at a time:
`setProviderEnabled(id, true)` darkens the others, a chip click sends every
switch in one update so the share link records the swap, and params that
light several keep the one already on, else the first in chip order
(`litByParams`; reapplying them never flips). Mapillary, free, is lit by
default. The chip reads GOOGLE STREET VIEW, and an open panorama's globe
marker is a Google-blue view cone (`marker: {kind: 'view-cone'}`) in place
of GEV's amber pin. Its
panoramas are all 360° and it draws no coverage, so it declares `filters: []`
(a provider's `filters` default to both `pano` and `since`): lookups ignore
the IMAGERY and SINCE filters, the panel hides each filter row no lit provider
honours (`filterControls`), and the legend lists only providers that draw
lines, with "Selected" only beside one of them. 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); a pose goes out at once and
again when the panorama's month, photographer and address arrive (one
successful lookup per panorama per session, seeded by the nearest lookup;
a details lookup that fails or takes over ten seconds waits five seconds
before a view event can retry, and an unmount drops lookups in flight). A key
Google refuses (`gm_authFailure`: the API not enabled, a referrer it does
not allow, billing off) reads KEY REJECTED and fails a pending open at
once; a lookup or open Google never answers fails after 10 or 20 s. In CI
(no Google key, no Google 3D) the gate checks that nothing of Street View
shows off Google 3D, even switched on by a share link; the click and button
step skips as `no Google key`.
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
+1
View File
@@ -266,6 +266,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",
+11
View File
@@ -897,6 +897,17 @@
"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/app/layers/streetLevel.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",
+23 -4
View File
@@ -456,7 +456,8 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js"
"src/ui/unitLabels.js",
"src/ui/keyGuidance.js"
],
"external": []
},
@@ -717,7 +718,8 @@
"src/i18n/locale.js",
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js"
"src/ui/unitLabels.js",
"src/ui/keyGuidance.js"
],
"external": ["cesium"]
},
@@ -1299,7 +1301,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",
@@ -1328,6 +1331,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",
"src/i18n/index.js",
@@ -2307,6 +2315,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",
@@ -2333,7 +2346,8 @@
"src/i18n/locales/es.json",
"src/i18n/locales/ru.json",
"src/ui/unitLabels.js",
"src/layers/recentImagery/display.js"
"src/layers/recentImagery/display.js",
"src/ui/keyGuidance.js"
],
"external": [
"hls.js",
@@ -2839,6 +2853,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",
+28 -3
View File
@@ -381,11 +381,36 @@ async function main() {
);
await nextFrames(page);
};
await drag(handlePoint(await readPanel(), 'se'), 0, 200);
const chosen = await readPanel();
// Grow it from the corner once the restored window holds still, with the
// pointer really on the handle (a press beside it selects page text).
let chosen = await readPanel();
const tried = [];
for (
let attempt = 0;
attempt < 3 && chosen.height <= MIN_SIZE.height + 150;
attempt++
) {
await settle();
const box = await readPanel();
const corner = handlePoint(box, 'se');
const hit = await page.evaluate(({ x, y }) => {
const node = document.elementFromPoint(x, y);
return {
dir: node?.dataset?.dir || null,
target: node
? `${node.tagName.toLowerCase()}${node.id ? `#${node.id}` : ''}.${[...node.classList].join('.')}`
: 'nothing',
};
}, corner);
tried.push(
`${Math.round(corner.x)},${Math.round(corner.y)} on ${hit.target} (box ${Math.round(box.left)},${Math.round(box.top)} ${Math.round(box.width)}x${Math.round(box.height)})`,
);
if (hit.dir === 'se') await drag(corner, 0, 200);
chosen = await readPanel();
}
assert.ok(
chosen.height > MIN_SIZE.height + 150,
`the window grew to ${chosen.height}px`,
`the window grew to ${chosen.height}px; tried ${tried.join('; ')}`,
);
await setCollapsed(true);
const strip = await readPanel();
+317 -12
View File
@@ -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));
}
@@ -619,7 +628,8 @@ async function main() {
}
await page.setViewport(VIEWPORTS[0]);
const expandStrip = async (target = page) => {
await target.click(
await clickPanelControl(
target,
'.panel-collapse-btn[data-collapse-target="street-level-panel"]',
);
assert.ok(
@@ -649,14 +659,30 @@ 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,
// Every registered provider usable on this map, in order (Street View
// only on Google 3D).
const listed = await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState()
.providers.map((provider) => provider.id),
);
assert.deepEqual(chips, listed);
assert.deepEqual(
EXPECTED_PROVIDERS.filter((id) => listed.includes(id)),
listed,
);
assert.ok(listed.includes('mapillary'));
// 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'),
@@ -1721,6 +1747,285 @@ 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, appears only on the Google 3D map, and travels in the share link',
async () => {
const module = () =>
window.__godsEyeView.dataManager.layers.get('street-level').module;
assert.equal(
await page.evaluate(`(${module})().getParams().google`),
false,
'off until lit: panoramas are billed',
);
const setStreetView = (on) =>
page.evaluate(
(value) =>
window.__godsEyeView.dataManager.setLayerParams(
'street-level',
{ google: value },
{ origin: 'user' },
),
on,
);
const shown = () =>
page.evaluate(() => ({
chip: Boolean(
document.querySelector(
'#sl-provider-chips [data-chip-id="google"]',
),
),
legend: [...document.querySelectorAll('#sl-legend li')].some(
(item) => /Street View/.test(item.textContent),
),
credit: document.body.innerHTML.includes('Street View imagery'),
button: !document.getElementById('sl-nearest-btn').hidden,
}));
const original = await stacks.active();
// Off Google 3D (CI's only map) nothing of Street View shows, even
// switched on: Google's terms keep it off other maps.
if (original === 'photoreal') await stacks.set('esri-imagery');
await setStreetView(true);
await uiUntil((u) => u.providers.every((p) => p.id !== 'google'));
assert.ok(
await settle(
page,
() => !document.body.innerHTML.includes('Street View imagery'),
),
'no Google credit on another map',
);
assert.deepEqual(await shown(), {
chip: false,
legend: false,
credit: false,
button: false,
});
// Street View rides the shared provider-switch field (r).
const option = `${STREET_LEVEL_TOKEN}.r.google-1`;
assert.ok(
await settle(
page,
(token) =>
(new URLSearchParams(location.hash.slice(1)).get('lo') || '')
.split('_')
.includes(token),
option,
// The link is written once the view rests for 500 ms; under a
// software renderer with a photo open that can take a while.
{ timeout: 30_000 },
),
`the share link keeps the choice: ${option}`,
);
if (await stacks.photoreal()) {
await stacks.set('photoreal');
await uiUntil((u) => u.providers.some((p) => p.id === 'google'));
const chipNow = await page.$eval(googleChip, (node) => ({
className: node.className,
title: node.title,
color: node.style.getPropertyValue('--chip-color'),
}));
assert.equal(chipNow.color, '#4285f4');
if (googleKey) assert.match(chipNow.className, /chip-active/);
else {
assert.match(chipNow.className, /chip-error/);
assert.match(chipNow.title, /GOOGLE_MAPS_API_KEY/);
}
assert.ok(
await settle(page, () =>
document.body.innerHTML.includes('Street View imagery'),
),
'the Google credit shows on Google 3D',
);
}
// Back to Mapillary (one source at a time), as the next steps expect.
await page.evaluate(() =>
window.__godsEyeView.dataManager.setLayerParams(
'street-level',
{ mapillary: true, google: false },
{ origin: 'user' },
),
);
await stacks.set(original);
await uiUntil(
(u) => u.providers.find((p) => p.id === 'google')?.on !== true,
);
},
);
await step(
'on Google 3D a click on a street opens Google Street View where it is, captioned and linked, and its view drives the pose',
async ({ skip }) => {
if (!googleKey) return skip('no Google key');
// Google's terms: Street View only beside Google's own map.
if (!(await stacks.photoreal())) return skip('no Google 3D');
const original = await stacks.active();
const closePhoto = () =>
page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.closeViewer(),
);
const setOn = async (selector, id, on) => {
const now = (await ui()).providers.find((p) => p.id === id)?.on;
if (now === on) return;
await clickPanelControl(page, selector);
assert.ok(
await uiUntil(
(u, want) =>
u.providers.find((p) => p.id === want.id)?.on === want.on,
{ id, on },
),
`the ${id} chip switched ${on ? 'on' : 'off'}`,
);
};
await closePhoto();
await stacks.set('photoreal');
// A Mapillary line under the pointer wins the click: aim at bare
// ground with only Street View on (lit first: the last lit chip
// would switch the layer off).
await setOn(googleChip, 'google', true);
await setOn(chip, 'mapillary', false);
try {
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);
// At street zoom the hint invites the click.
assert.ok(
await uiUntil(
(u) =>
u.providers.find((p) => p.id === 'google')?.hint ===
'click a street to open Street View',
),
'the click hint shows at street zoom',
);
const camera = () =>
page.evaluate(() => {
const c = window.__godsEyeView.viewer.camera.positionWC;
return [c.x, c.y, c.z];
});
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');
const before = await camera();
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 (/refused GOOGLE_MAPS_API_KEY/.test(opened.error || ''))
return skip('Google key refused');
if (/did not answer/.test(opened.error || ''))
return skip('Google did not answer');
assert.equal(opened.error, null);
assert.equal(opened.providerId, 'google');
const after = await camera();
assert.ok(
Math.hypot(...after.map((v, i) => v - before[i])) < 1,
'the camera stays where the user clicked',
);
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, 'GOOGLE 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, start) =>
Number.isFinite(u.street.bearing) &&
Math.abs(u.street.bearing - start) > 5,
opened.bearing ?? 0,
{ timeout: 10_000 },
),
'the pose bearing follows the drag',
);
// The keyboard path: OPEN STREET VIEW at the view centre.
await closePhoto();
await uiUntil((u) => u.street.open === false);
await clickPanelControl(page, '#sl-nearest-btn');
assert.ok(
await uiUntil(
(u) =>
u.street.providerId === 'google' &&
Boolean(u.street.imageId) &&
!u.street.loading,
null,
{ timeout: 30_000 },
),
'OPEN STREET VIEW opens the panorama at the view centre',
);
// Leaving Google 3D closes the panorama (Google's terms).
await stacks.set('esri-imagery');
assert.ok(
await uiUntil((u) => u.street.open === false),
'the panorama closes off Google 3D',
);
} finally {
await closePhoto();
// Off Google 3D the Street View chip is hidden: return to it
// before switching the chip, or the cleanup throws and hides
// the step's own result.
await stacks.set('photoreal');
await setOn(chip, 'mapillary', true);
await setOn(googleChip, 'google', false);
await stacks.set(original);
}
},
);
await step(
'a reload with the layer on keeps a collapsed panel collapsed, and the share link carries its ui token',
async () => {
+19 -6
View File
@@ -1,21 +1,33 @@
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;
/** Mapillary and Google Street View, once the Mapillary source is supplied; else none. */
function defaultProviders(sources) {
if (sources.mapillary == null) return [];
return [
createMapillaryProvider({ source: sources.mapillary }),
createGoogleProvider({ getApiKey: googleApiKey }),
];
}
/**
* Inject providers, or use Mapillary only when its source is supplied.
* An explicit provider list replaces the default; unused sources.mapillary is
* neither constructed nor validated, including when the list is empty.
* Inject providers, or use the defaults. An explicit provider list replaces
* them; an unused sources.mapillary is neither constructed nor validated,
* including when the list is empty.
*/
export function createApplicationStreetLevel({
surface,
sources = {},
providers = sources.mapillary == null
? []
: [createMapillaryProvider({ source: sources.mapillary })],
providers = defaultProviders(sources),
}) {
return createStreetLevelLayer({
providers,
@@ -23,6 +35,7 @@ export function createApplicationStreetLevel({
sprites,
picking,
input,
scenePick,
render,
ground: surface?.groundFloor ?? null,
meshFloor: surface?.meshFloor ?? null,
+22
View File
@@ -27,3 +27,25 @@ test('explicit providers replace the default source and an empty list removes it
/array/,
);
});
test('the defaults are Mapillary (given its source) and Street View, which starts off and travels as a provider switch', async () => {
const { createDefaultLayerState } = await import('../../data/layerState.js');
const mapillary = {
getStatus() {},
getTile() {},
getSequenceImages() {},
nearestImages() {},
};
const layer = createApplicationStreetLevel({ sources: { mapillary } });
assert.deepEqual(layer.providerIds, ['mapillary', 'google']);
// A fresh page and a restored default agree: Street View is billed, so off.
layer.setParams(createDefaultLayerState().options['street-level']);
assert.equal(layer.getParams().google, false);
assert.equal(layer.getParams().mapillary, true);
// A link that lit Street View carries it in the shared switch field.
layer.setParams({ providerSwitches: 'google-1' });
assert.equal(layer.getParams().google, true);
assert.equal(layer.getParams().providerSwitches, 'google-1');
// Without the application's Street Level source there are none.
assert.deepEqual(createApplicationStreetLevel({}).providerIds, []);
});
+7 -1
View File
@@ -2703,13 +2703,19 @@ 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,
providerSwitches: '',
pano: 'all',
sinceDays: 0,
});
// Street View rides the shared provider-switch field, off unless lit.
assert.equal(streetLevelOptions('0.r.google-1').providerSwitches, 'google-1');
assert.equal(
createDefaultLayerState().options['street-level'].providerSwitches,
'',
);
assert.equal(streetLevelOptions('0.p.f').mapillary, true);
assert.equal(streetLevelOptions('0.m.0').mapillary, false);
assert.equal(
+49
View File
@@ -47,3 +47,52 @@ export function isPickedWorldPosition(position) {
const magnitude = Math.hypot(x, y, z);
return magnitude >= MIN_PICK_MAGNITUDE_M && magnitude <= MAX_PICK_MAGNITUDE_M;
}
/**
* The world position under a screen point: the rendered surface where the
* depth buffer has it, else the ellipsoid; null over the sky.
*
* @param {object} viewer A Cesium viewer.
* @param {{x: number, y: number}} screenPosition
* @returns {{x: number, y: number, z: number}|null} A Cartesian3.
*/
export function pickGroundPosition(viewer, screenPosition) {
const scene = viewer?.scene;
let position = null;
if (
scene?.pickPositionSupported &&
typeof scene.pickPosition === 'function'
) {
try {
position = scene.pickPosition(screenPosition);
} catch {
position = null;
}
}
if (
!isPickedWorldPosition(position) &&
typeof viewer?.camera?.pickEllipsoid === 'function'
) {
try {
position = viewer.camera.pickEllipsoid(screenPosition);
} catch {
position = null;
}
}
return isPickedWorldPosition(position) ? position : null;
}
/**
* Whether a `scene.pick()` result is the map itself: nothing, or 3D tile
* content (which has no id).
*
* @param {object|null|undefined} picked
* @returns {boolean}
*/
export function isSurfacePick(picked) {
if (!picked) return true;
if (picked.id !== undefined) return false;
return (
picked.content !== undefined || picked.primitive?.isCesium3DTileset === true
);
}
+68 -13
View File
@@ -3,9 +3,8 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { isPickedWorldPosition } from './scenePick.js';
const describe = (position) => (position
? `(${position.x}, ${position.y}, ${position.z})`
: String(position));
const describe = (position) =>
position ? `(${position.x}, ${position.y}, ${position.z})` : String(position);
test('a degenerate depth pick is rejected before it reaches Cesium', () => {
// These are the shapes `scene.pickPosition()` can produce when the depth read
@@ -21,7 +20,11 @@ test('a degenerate depth pick is rejected before it reaches Cesium', () => {
new Cesium.Cartesian3(0.2, -0.3, 0.1),
];
for (const position of degenerate) {
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
assert.equal(
isPickedWorldPosition(position),
false,
`must reject ${describe(position)}`,
);
}
});
@@ -38,11 +41,18 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
new Cesium.Cartesian3(0, 0, 5_999_999),
];
for (const position of belowTheFloor) {
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
assert.equal(
isPickedWorldPosition(position),
false,
`must reject ${describe(position)}`,
);
// Each one converts WITHOUT throwing — that is exactly what makes it dangerous.
const carto = Cesium.Cartographic.fromCartesian(position);
assert.ok(carto, `Cesium converts ${describe(position)} without complaint`);
assert.ok(carto.height < -300_000, 'and puts the "target" hundreds of km underground');
assert.ok(
carto.height < -300_000,
'and puts the "target" hundreds of km underground',
);
}
const aboveTheCeiling = [
@@ -52,7 +62,11 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
new Cesium.Cartesian3(1e155, 0, 0),
];
for (const position of aboveTheCeiling) {
assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`);
assert.equal(
isPickedWorldPosition(position),
false,
`must reject ${describe(position)}`,
);
}
assert.throws(
() => Cesium.Cartographic.fromCartesian(new Cesium.Cartesian3(1e155, 0, 0)),
@@ -61,8 +75,14 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => {
);
// Both edges of the band are inclusive, and both are real magnitudes.
assert.equal(isPickedWorldPosition(new Cesium.Cartesian3(6_000_000, 0, 0)), true);
assert.equal(isPickedWorldPosition(new Cesium.Cartesian3(1_000_000_000, 0, 0)), true);
assert.equal(
isPickedWorldPosition(new Cesium.Cartesian3(6_000_000, 0, 0)),
true,
);
assert.equal(
isPickedWorldPosition(new Cesium.Cartesian3(1_000_000_000, 0, 0)),
true,
);
});
test('a real picked position is accepted', () => {
@@ -81,13 +101,22 @@ test('a real picked position is accepted', () => {
Cesium.Cartesian3.fromDegrees(0, 0, 35_786_000),
];
for (const position of real) {
assert.equal(isPickedWorldPosition(position), true, `must accept ${describe(position)}`);
assert.equal(
isPickedWorldPosition(position),
true,
`must accept ${describe(position)}`,
);
}
// The floor has real margin under the smallest legitimate magnitude, so no
// plausible pick sits anywhere near the boundary.
const polarSurface = Cesium.Cartesian3.magnitude(Cesium.Cartesian3.fromDegrees(0, 90, -10_935));
assert.ok(polarSurface > 6_300_000, `smallest real magnitude was ${polarSurface}`);
const polarSurface = Cesium.Cartesian3.magnitude(
Cesium.Cartesian3.fromDegrees(0, 90, -10_935),
);
assert.ok(
polarSurface > 6_300_000,
`smallest real magnitude was ${polarSurface}`,
);
});
test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
@@ -118,7 +147,10 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
// Earth's core. That is the quietest failure of the three and the reason
// the guard needs a magnitude band, not just a finiteness check.
const nearCenter = new Cesium.Cartesian3(0.2, -0.3, 0.1);
assert.ok(Cesium.Cartographic.fromCartesian(nearCenter), 'Cesium converts it without complaint');
assert.ok(
Cesium.Cartographic.fromCartesian(nearCenter),
'Cesium converts it without complaint',
);
assert.equal(isPickedWorldPosition(nearCenter), false);
// …and every position the guard accepts must convert to the right place.
@@ -129,3 +161,26 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => {
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.longitude) + 97.7431) < 1e-6);
assert.ok(Math.abs(Cesium.Math.toDegrees(carto.latitude) - 30.2672) < 1e-6);
});
test('the ground under a click: the rendered surface, else the ellipsoid, never the sky', async () => {
const { pickGroundPosition } = await import('./scenePick.js');
const ground = { x: 6378137, y: 0, z: 0 };
const sky = { x: 500, y: 0, z: 0 };
const viewer = (picked, ellipsoid) => ({
scene: { pickPositionSupported: true, pickPosition: () => picked },
camera: { pickEllipsoid: () => ellipsoid },
});
assert.equal(pickGroundPosition(viewer(ground, null), {}), ground);
assert.equal(pickGroundPosition(viewer(sky, ground), {}), ground);
assert.equal(pickGroundPosition(viewer(undefined, undefined), {}), null);
assert.equal(pickGroundPosition(null, {}), null);
});
test('a surface pick is nothing, or Google 3D tile content without an id', async () => {
const { isSurfacePick } = await import('./scenePick.js');
assert.equal(isSurfacePick(undefined), true);
assert.equal(isSurfacePick({ content: {} }), true);
assert.equal(isSurfacePick({ primitive: { isCesium3DTileset: true } }), true);
assert.equal(isSurfacePick({ id: 'flight:1', content: {} }), false);
assert.equal(isSurfacePick({ primitive: {} }), false, 'an unnamed primitive');
});
+17 -2
View File
@@ -832,7 +832,6 @@
"REMOVE": "ELIMINAR",
"Remove {title} from this app's saved keys": "Elimina {title} de las claves guardadas de esta app",
"Needs {envVars} — add it in Provider Settings": "Necesita {envVars} — añádela en Ajustes de proveedores",
"The photorealistic 3D planet + place search": "El planeta 3D fotorrealista + búsqueda de lugares",
"Places context + Street View fallback; optional separate key": "Contexto de Places + respaldo de Street View; clave separada opcional",
"Live ships, worldwide": "Buques en vivo, en todo el mundo",
"Live active-fire detections": "Detecciones de incendios activos en vivo",
@@ -2032,5 +2031,21 @@
"Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "Necesita {envVars} — añádela en Ajustes de proveedores — o un token de Cesium ion para la ruta alojada en ion",
"Comparison in use by another scene": "La comparación está en uso por otra escena",
"A location reading needs a loaded forecast and the Earth at the center of the view.": "Una lectura en un punto necesita un pronóstico cargado y la Tierra en el centro de la vista.",
"{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} · {band} · {locality} | {region} | ALT {alt} | VENTANA {window} | SUN {sun}° | ONA {ona}° | {zone}"
"{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} · {band} · {locality} | {region} | ALT {alt} | VENTANA {window} | SUN {sun}° | ONA {ona}° | {zone}",
"light {source} to use your {name} key": "activa {source} para usar tu clave de {name}",
"light {source}": "activa {source}",
", or ": ", o ",
"{requirement}, or {offer}": "{requirement}, o {offer}",
"The photorealistic 3D planet, place search + Street View": "El planeta 3D fotorrealista, búsqueda de lugares + Street View",
"{name} needs the Google 3D map: choose it under MAP SOURCE": "{name} requiere el mapa Google 3D: elígelo en FUENTE DEL MAPA",
"zoom in to a street to open {name}": "acércate a una calle para abrir {name}",
"Google Street View": "Google Street View",
"GOOGLE STREET VIEW": "GOOGLE STREET VIEW",
"Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer": "Google rechazó GOOGLE_MAPS_API_KEY para Street View — comprueba que la API Maps JavaScript esté habilitada para ella y que este sitio sea un referente permitido",
"Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY": "Street View no respondió — comprueba la red y que la API Maps JavaScript esté habilitada para GOOGLE_MAPS_API_KEY",
"click a street to open Street View": "haz clic en una calle para abrir Street View",
" or ": " o ",
"OPEN {labels}": "ABRIR {labels}",
"Open {names} at the centre of the view": "Abrir {names} en el centro de la vista",
"Zoom in to a street to open {names} here": "Acércate a una calle para abrir {names} aquí"
}
+17 -2
View File
@@ -75,7 +75,6 @@
"More flight-polling credits (anonymous works without)": "Больше кредитов на опрос рейсов (анонимно работает и без ключа)",
"Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Уличные снимки и покрытие в слое «Уличные снимки». Бесплатно: зарегистрируйте приложение в панели разработчика Mapillary и вставьте его клиентский токен (Client Token)",
"Higher space-missions request allowance": "Повышенный лимит запросов для космических миссий",
"The photorealistic 3D planet + place search": "Фотореалистичная 3D-планета + поиск мест",
"Places context + Street View fallback; optional separate key": "Контекст Places + резерв: Просмотр улиц; отдельный ключ — по желанию",
"Voice control — API key or ChatGPT OAuth": "Голосовое управление — ключ API или ChatGPT OAuth",
"Live ships, worldwide": "Суда в реальном времени по всему миру",
@@ -2064,5 +2063,21 @@
"Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "Нужен {envVars} — добавьте его в «Настройках провайдеров» — или токен Cesium ion для маршрута через ion",
"Comparison in use by another scene": "Сравнение занято другой сценой",
"A location reading needs a loaded forecast and the Earth at the center of the view.": "Для показания в точке нужен загруженный прогноз и Земля в центре вида.",
"{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} · {band} · {locality} | {region} | ALT {alt} | ОКНО {window} | SUN {sun}° | ONA {ona}° | {zone}"
"{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} · {band} · {locality} | {region} | ALT {alt} | ОКНО {window} | SUN {sun}° | ONA {ona}° | {zone}",
"light {source} to use your {name} key": "включите {source}, чтобы использовать ваш ключ {name}",
"light {source}": "включите {source}",
", or ": ", или ",
"{requirement}, or {offer}": "{requirement}, или {offer}",
"The photorealistic 3D planet, place search + Street View": "Фотореалистичная 3D-планета, поиск мест + Просмотр улиц",
"{name} needs the Google 3D map: choose it under MAP SOURCE": "Для {name} нужна карта Google 3D: выберите её в разделе «ИСТОЧНИК КАРТЫ»",
"zoom in to a street to open {name}": "приблизьтесь к улице, чтобы открыть {name}",
"Google Street View": "Google Просмотр улиц",
"GOOGLE STREET VIEW": "GOOGLE ПРОСМОТР УЛИЦ",
"Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer": "Google отклонил GOOGLE_MAPS_API_KEY для Просмотра улиц — проверьте, что для ключа включён Maps JavaScript API, а этот сайт есть среди разрешённых referrer",
"Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY": "Просмотр улиц не ответил — проверьте сеть и что для GOOGLE_MAPS_API_KEY включён Maps JavaScript API",
"click a street to open Street View": "нажмите на улицу, чтобы открыть Просмотр улиц",
" or ": " или ",
"OPEN {labels}": "ОТКРЫТЬ {labels}",
"Open {names} at the centre of the view": "Открыть {names} в центре вида",
"Zoom in to a street to open {names} here": "Приблизьтесь к улице, чтобы открыть здесь {names}"
}
+23 -1
View File
@@ -40,7 +40,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({
id: 'google-maps',
title: 'GOOGLE MAPS', // i18n-ignore: provider product name
unlocks: msg('The photorealistic 3D planet + place search'),
unlocks: msg('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',
@@ -372,6 +372,28 @@ export function keySetupRequirement(id) {
return `Needs ${entry.envVars.join(' + ')} — add it in Provider Settings`;
}
/**
* Key guidance that also names switched-off sources whose key is already set.
* @param {string} requirement What `keySetupRequirement` said ('' allowed).
* @param {Array<{label: string, requiresKeyId?: string|null}>} [alternatives]
*/
export function keySetupWithAlternatives(requirement, alternatives = []) {
const options = alternatives.map(({ label, requiresKeyId }) => {
const entry = KEY_SETUP_KEYS.find(
(candidate) => candidate.id === requiresKeyId,
);
const name = entry
? entry.title
.toLowerCase()
.replace(/\b\w/g, (letter) => letter.toUpperCase())
: null;
return name ? `light ${label} to use your ${name} key` : `light ${label}`;
});
if (!options.length) return requirement;
const offer = `or ${options.join(', or ')}`;
return requirement ? `${requirement}, ${offer}` : offer.replace(/^or l/, 'L');
}
/**
* Decide whether a live provider value belongs to a source outside the store
* Provider Settings is allowed to edit. `wasExternalAtBoot` carries source
+14
View File
@@ -9,6 +9,7 @@ import {
isKeySetupExternallyManaged,
keySetupStatus,
keySetupRequirement,
keySetupWithAlternatives,
knownKeySetupEnvVars,
parseWindowsUserSid,
upsertDotenvValues,
@@ -362,3 +363,16 @@ test('server Google key remains supported without appearing in setup or its miss
assert.ok(!JSON.stringify(status).includes('GOOGLE_MAPS_SERVER_API_KEY'));
assert.ok(!JSON.stringify(status).includes(secret));
});
test('key guidance can offer another source that works with a key already set', () => {
const streetView = { label: 'STREET VIEW', requiresKeyId: 'google-maps' };
assert.equal(
keySetupWithAlternatives(keySetupRequirement('mapillary'), [streetView]),
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key',
);
assert.equal(
keySetupWithAlternatives('', [streetView]),
'Light STREET VIEW to use your Google Maps key',
);
assert.equal(keySetupWithAlternatives('Needs X', []), 'Needs X');
});
+5 -26
View File
@@ -863,32 +863,11 @@ export function createDirectionsLayer({ services, source }) {
}
function pickGround(screenPosition) {
const scene = _viewer?.scene;
let cartesian = null;
if (
scene?.pickPositionSupported &&
typeof scene.pickPosition === 'function'
) {
try {
cartesian = scene.pickPosition(screenPosition);
} catch {
cartesian = null;
}
}
if (
!services.scenePick.isPickedWorldPosition(cartesian) &&
typeof _viewer?.camera?.pickEllipsoid === 'function'
) {
try {
cartesian = _viewer.camera.pickEllipsoid(
screenPosition,
Cesium.Ellipsoid.WGS84,
);
} catch {
cartesian = null;
}
}
if (!services.scenePick.isPickedWorldPosition(cartesian)) return null;
const cartesian = services.scenePick.pickGroundPosition(
_viewer,
screenPosition,
);
if (!cartesian) return null;
const carto = Cesium.Cartographic.fromCartesian(cartesian);
if (!carto) return null;
return {
+9
View File
@@ -65,6 +65,15 @@ export function imageConeGlyph({
});
}
/** Street View-style marker: a field-of-view cone from a white-ringed dot. */
export function viewConeMarkerGlyph({ size = 56, color = '#4285f4' } = {}) {
return cachedGlyph(`view-cone:${size}:${color}`, size, (ctx, c) => {
wedge(ctx, c, size * 0.48, 0.62, color, 0.38);
wedge(ctx, c, size * 0.3, 0.62, color, 0.3);
dot(ctx, c, size * 0.13, color, '#ffffff', Math.max(2, size * 0.05));
});
}
/** Marker for the image the viewer currently shows. */
export function positionMarkerGlyph({ size = 44, color = '#ffb300' } = {}) {
return cachedGlyph(`pos:${size}:${color}`, size, (ctx, c) => {
+56
View File
@@ -0,0 +1,56 @@
import * as Cesium from 'cesium';
import { GROUND_CLICK_MAX_HEIGHT_M } from './policy.js';
import { cameraHeightAboveGround } from './view.js';
/** Whether the camera is low enough for a click on the ground to mean a street. */
export function groundClickHeightOk(viewer, groundAt) {
if (!viewer?.camera?.positionCartographic) return false;
return (
cameraHeightAboveGround(viewer, { groundAt }) <= GROUND_CLICK_MAX_HEIGHT_M
);
}
/**
* A click on the map itself at street zoom opens the nearest image of the
* active `groundClick` providers, without moving the camera. The height is
* measured at the click, not read from the cached flag.
* @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean, groundAt?: Function}} options
* @returns {(screenPosition: {x: number, y: number}, picked?: object) => Promise<boolean>|false}
*/
export function createGroundClick({
state,
openNearest,
isAvailable,
groundAt = null,
}) {
return function openAtGround(screenPosition, picked) {
const scenePick = state.services.scenePick;
if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition)
return false;
if (
!scenePick.isSurfacePick(picked) ||
!groundClickHeightOk(state.viewer, groundAt)
)
return false;
const providerIds = [...state.providers.values()]
.filter(
(entry) =>
entry.on &&
entry.def.groundClick &&
entry.status?.configured &&
isAvailable(entry),
)
.map((entry) => entry.def.id);
if (!providerIds.length) return false;
const position = scenePick.pickGroundPosition(state.viewer, screenPosition);
const carto = position && Cesium.Cartographic.fromCartesian(position);
if (!carto) return false;
return openNearest(
{
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
},
{ providerIds, frame: false },
);
};
}
+133
View File
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import * as scenePick from '../../data/scenePick.js';
const GROUND = Cesium.Cartesian3.fromDegrees(-121.4944, 38.5816, 10);
const TILESET = { isCesium3DTileset: true };
/**
* A layer state with providers `{id, on, groundClick, configured, available}`;
* `picked` is where the depth buffer lands, `ellipsoid` the fallback hit.
*/
function harness({
providers = [
{ id: 'mapillary', groundClick: false },
{ id: 'google', groundClick: true },
],
height = 400,
enabled = true,
picked = GROUND,
ellipsoid = undefined,
} = {}) {
const opened = [];
const state = {
enabled,
services: { scenePick },
providers: new Map(
providers.map(
({
id,
on = true,
groundClick,
configured = true,
available = true,
}) => [
id,
{ on, def: { id, groundClick }, status: { configured }, available },
],
),
),
viewer: {
scene: { pickPositionSupported: true, pickPosition: () => picked },
camera: {
pickEllipsoid: () => ellipsoid,
positionCartographic: Cesium.Cartographic.fromDegrees(
-121.4944,
38.5816,
height,
),
},
},
};
const openAtGround = createGroundClick({
state,
openNearest: (point, options) => {
opened.push({ point, options });
return Promise.resolve(true);
},
isAvailable: (entry) => entry.available,
});
return { opened, openAtGround };
}
const near = (point) =>
Math.abs(point.lon - -121.4944) < 1e-9 &&
Math.abs(point.lat - 38.5816) < 1e-9;
test('a click on the map opens the click-to-open providers there, without moving the camera', () => {
for (const picked of [undefined, { primitive: TILESET }, { content: {} }]) {
const { opened, openAtGround } = harness();
openAtGround({ x: 10, y: 20 }, picked);
assert.equal(opened.length, 1, JSON.stringify(picked));
assert.ok(near(opened[0].point));
assert.deepEqual(opened[0].options, {
providerIds: ['google'],
frame: false,
});
}
});
test('where the depth buffer names no place, the ellipsoid answers', () => {
const { opened, openAtGround } = harness({
picked: new Cesium.Cartesian3(500, 0, 0),
ellipsoid: GROUND,
});
openAtGround({ x: 1, y: 1 });
assert.ok(near(opened[0].point));
});
test('nothing opens over another layer’s object, the sky, from high up, off its map stack, or with the provider off', () => {
const cases = [
{ picked: { id: { id: 'flight:1' } }, label: 'an entity' },
{ picked: { id: 'cctv:7', primitive: TILESET }, label: 'an id on a tile' },
{ options: { picked: null, ellipsoid: undefined }, label: 'the sky' },
{ options: { height: 5000 }, label: 'high up' },
{
options: {
providers: [{ id: 'google', groundClick: true, available: false }],
},
label: 'not Google 3D',
},
{
options: { providers: [{ id: 'google', groundClick: true, on: false }] },
label: 'off',
},
{
options: {
providers: [{ id: 'google', groundClick: true, configured: false }],
},
label: 'no key',
},
{ options: { enabled: false }, label: 'layer off' },
];
for (const { picked, options = {}, label } of cases) {
const { opened, openAtGround } = harness(options);
assert.equal(openAtGround({ x: 1, y: 1 }, picked), false, label);
assert.equal(opened.length, 0, label);
}
});
test('street zoom is measured above the ground under the camera', () => {
const viewer = (height) => ({
camera: {
positionCartographic: Cesium.Cartographic.fromDegrees(0, 0, height),
},
scene: { globe: { getHeight: () => 1500 } },
});
// 1,500 m up a plateau, 2,000 m above it: still a street.
assert.equal(groundClickHeightOk(viewer(3500)), true);
assert.equal(groundClickHeightOk(viewer(5000)), false);
assert.equal(groundClickHeightOk(null), false);
});
+216 -31
View File
@@ -1,3 +1,4 @@
import * as Cesium from 'cesium';
import { createState } from './state.js';
import { createMarker } from './marker.js';
import { createCameraFollow } from './cameraFollow.js';
@@ -5,17 +6,24 @@ import { createCredits } from './credits.js';
import { createPickRouter } from './pickRouter.js';
import { createSelection } from './selection.js';
import { createViewerHost } from './viewerHost.js';
import { requiresKeyIdFor, validateProviders } from './registry.js';
import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js';
import { normalizeFilter, resolveFilter, sameFilter } from './filter.js';
import { decodeParams, encodeParams } from './params.js';
import { composeUIState, summarizeCoverage } from './uiState.js';
import {
composeUIState,
keyAlternatives,
summarizeCoverage,
} from './uiState.js';
import { cameraHeightAboveGround, viewCentre, whenIdle } from './view.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import { createGroundCaster, nextSurfaceMode } from './groundCast.js';
import { createMeshSampler } from './meshSampler.js';
import { t, translateKnown } from '../../i18n/index.js';
/** Above this camera height the ground under the camera is not worth fetching. */
const SURFACE_WARM_BELOW_M = 6000;
import {
AIM_AT_GROUND_MESSAGE,
FOLLOW_MAP_STACK_ID,
NEAREST_RADIUS_M,
POSITION_PICK_ID,
@@ -66,6 +74,12 @@ export function createStreetLevelLayer({
entry.instance.clearSequence?.();
};
parts.selection = createSelection(context);
parts.openAtGround = createGroundClick({
state,
openNearest: (point, options) => layer.openNearest(point, options),
isAvailable: (entry) => providerAvailable(entry),
groundAt: parts.groundCaster?.groundAt ?? null,
});
let notifyQueued = false;
function notify() {
@@ -115,11 +129,13 @@ export function createStreetLevelLayer({
});
}
// One source at a time: the first provider that defaults on.
const startsOn = definitionsFrozen.find((def) => def.defaultOn !== false);
for (const def of definitionsFrozen) {
const entry = {
def,
instance: null,
on: true,
on: def === startsOn,
status: null,
/** The error this provider last reported, until it withdraws it. */
reportedError: null,
@@ -140,6 +156,39 @@ export function createStreetLevelLayer({
notify();
}
/** Usable only on its map stack (Google's terms: Street View on Google 3D only). */
function providerAvailable(entry) {
const required = entry.def.requiresMapStack;
return !required || mapStack?.getActiveId?.() === required;
}
/** Leaving a provider's map stack closes its image, viewer and credit. */
function syncProviderAvailability() {
const owner = state.providers.get(state.street.providerId);
if (state.street.open && owner && !providerAvailable(owner))
layer.closeViewer();
for (const entry of state.providers.values()) {
if (!entry.def.requiresMapStack) continue;
if (state.enabled && entry.on && providerAvailable(entry))
parts.credits.show(state.viewer, entry.def);
else {
parts.credits.hide(state.viewer, entry.def);
parts.viewerHost.unmount(entry.def.id);
}
}
notify();
}
/** Whether a ground click can open an image: the camera is at street zoom. */
function syncGroundClickReady() {
const ready =
state.enabled &&
groundClickHeightOk(state.viewer, parts.groundCaster?.groundAt);
if (ready === state.groundClickReady) return;
state.groundClickReady = ready;
notify();
}
/**
* Camera height above bare earth, for the surface mode. A cold grid cell is
* fetched once and the mode rechecked when it lands.
@@ -162,6 +211,7 @@ export function createStreetLevelLayer({
/** Cast overlays to bare earth on Google 3D at street zoom; drape them elsewhere. */
function syncSurface() {
syncGroundClickReady();
const available = Boolean(parts.groundCaster) && state.enabled;
const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID;
const mode = nextSurfaceMode(state.surface, {
@@ -222,9 +272,13 @@ export function createStreetLevelLayer({
function activate(entry) {
if (!state.viewer) return;
entry.instance.activate(state.viewer);
parts.credits.show(state.viewer, entry.def);
if (!entry.status) refreshStatus(entry);
whenIdle(() => parts.viewerHost.prewarm([entry]), 1500);
if (!providerAvailable(entry)) return;
parts.credits.show(state.viewer, entry.def);
// The map may have changed by then.
whenIdle(() => {
if (providerAvailable(entry)) parts.viewerHost.prewarm([entry]);
}, 1500);
}
function deactivate(entry) {
@@ -241,25 +295,82 @@ export function createStreetLevelLayer({
nearestLookup = null;
}
function openImage(providerId, imageId) {
abortNearest();
return parts.viewerHost.open(providerId, imageId);
/** Ground under the screen centre; null for sky, undefined when it cannot aim. */
function screenCentreGround() {
const canvas = state.viewer?.scene?.canvas;
const pick = state.services.scenePick?.pickGroundPosition;
if (!pick || !canvas?.clientWidth || !canvas?.clientHeight)
return undefined;
const position = pick(state.viewer, {
x: canvas.clientWidth / 2,
y: canvas.clientHeight / 2,
});
const carto = position && Cesium.Cartographic.fromCartesian(position);
return carto
? {
lon: Cesium.Math.toDegrees(carto.longitude),
lat: Cesium.Math.toDegrees(carto.latitude),
}
: null;
}
/** `frame: false` keeps the camera where it is (the user picked the spot). */
function openImage(providerId, imageId, { frame = true } = {}) {
abortNearest();
const entry = state.providers.get(providerId);
if (entry && !providerAvailable(entry)) {
state.street.loading = false;
state.street.error = unavailableReason(entry);
notify();
return Promise.resolve(false);
}
return parts.viewerHost.open(providerId, imageId, { frame });
}
function unavailableReason(entry) {
return t('{name} needs the Google 3D map: choose it under MAP SOURCE', {
name: translateKnown(entry.def.name),
});
}
/** One imagery source at a time: lighting a provider darkens the others. */
function setProviderEnabled(providerId, on) {
const entry = state.providers.get(providerId);
if (!entry) return false;
const next = on !== false;
if (entry.on === next) return true;
entry.on = next;
if (state.enabled) {
if (next) activate(entry);
else deactivate(entry);
}
if (next)
for (const other of state.providers.values())
if (other !== entry && other.on) switchProvider(other, false);
switchProvider(entry, next);
notify();
return true;
}
function switchProvider(entry, on) {
if (entry.on === on) return;
entry.on = on;
if (!state.enabled) return;
if (on) activate(entry);
else deactivate(entry);
}
/**
* The one provider params leave lit: of those they switch on, the one
* already on (reapplying never flips), else the first; with none, the lit
* one unless they switch it off.
*/
function litByParams(switches) {
const asked = [...state.providers.keys()].filter(
(id) => switches.get(id) === true,
);
const current = [...state.providers]
.filter(([, entry]) => entry.on)
.map(([id]) => id);
if (asked.length)
return asked.find((id) => current.includes(id)) ?? asked[0];
return current.find((id) => switches.get(id) !== false) ?? null;
}
function setCoverageFilter(next) {
const filter = normalizeFilter(next, state.filter);
if (sameFilter(filter, state.filter)) return;
@@ -270,8 +381,21 @@ export function createStreetLevelLayer({
notify();
}
/** The hint a ground-click provider shows, or the provider's own. */
function hintFor(entry, stats) {
if (entry.def.groundClick && stats.hint && !state.groundClickReady)
return t('zoom in to a street to open {name}', {
name: translateKnown(entry.def.name),
});
return stats.hint || '';
}
/** Providers usable on this map stack; the others are not shown at all. */
const listedEntries = () =>
[...state.providers.values()].filter((entry) => providerAvailable(entry));
function providerSnapshots() {
return [...state.providers.values()].map((entry) => {
return listedEntries().map((entry) => {
const stats = entry.instance.coverageStats();
return {
id: entry.def.id,
@@ -285,9 +409,11 @@ export function createStreetLevelLayer({
requiresKeyId: entry.def.requiresKeyId || null,
loading: stats.loading === true,
count: stats.count || 0,
hint: stats.hint || '',
hint: hintFor(entry, stats),
error: stats.error || null,
color: entry.def.colors.coverage,
groundClick: entry.def.groundClick === true,
filters: [...filtersOf(entry.def)],
};
});
}
@@ -313,24 +439,43 @@ export function createStreetLevelLayer({
function getUIState() {
const { host, ...street } = state.street;
return composeUIState({
const ui = composeUIState({
enabled: state.enabled,
groundClickReady: state.groundClickReady,
filter: state.filter,
providers: providerSnapshots(),
street,
sequence: sequenceSnapshot(),
surface: state.surface,
});
// The lit source cannot run on this map: say so, not a blank panel.
const stranded = [...state.providers.values()].find(
(entry) => entry.on && !providerAvailable(entry),
);
if (stranded && !ui.providers.some((p) => p.on))
ui.coverage.hint = unavailableReason(stranded);
return ui;
}
const layer = {
id: STREET_LEVEL_LAYER_ID,
name: 'Street Level',
icon: '📷',
source: definitionsFrozen.map((def) => def.name).join(' · '),
/** The sources usable on this map (Street View only on Google 3D). */
get source() {
return listedEntries()
.map((entry) => entry.def.name)
.join(' · ');
},
updateInterval: 0,
statsRefreshInterval: 1000,
requiresKeyId: requiresKeyIdFor(definitionsFrozen),
/** The key the switched-on providers share (or null), for the layer row. */
get requiresKeyId() {
const on = [...state.providers.values()].filter((entry) => entry.on);
return requiresKeyIdFor(
on.length ? on.map((entry) => entry.def) : definitionsFrozen,
);
},
/** Registered providers, in chip order. */
providerIds: definitionsFrozen.map((def) => def.id),
getSourceAvailability() {
@@ -404,16 +549,19 @@ export function createStreetLevelLayer({
getStats() {
// The lifecycle polls this every second: summarise, don't snapshot.
const coverage = summarizeCoverage(providerSnapshots());
const snapshots = providerSnapshots();
const coverage = summarizeCoverage(snapshots);
let loadingLabel = '';
const keyLabel = coverage.keyRejected ? 'KEY REJECTED' : 'KEY REQUIRED';
if (coverage.keyRequired) loadingLabel = keyLabel;
else if (coverage.loading) loadingLabel = 'loading coverage...';
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
return {
source: layer.source,
count: coverage.count,
loading: coverage.loading,
keyRequired: coverage.keyRequired,
keyAlternatives: keyAlternatives(snapshots),
error: coverage.keyRequired
? coverage.keyRejected
? coverage.error || keyLabel
@@ -441,9 +589,11 @@ export function createStreetLevelLayer({
unsubscribeMapStack =
mapStack?.subscribe?.(() => {
syncFollowAvailability();
syncProviderAvailability();
syncSurface();
}) || null;
syncFollowAvailability();
syncProviderAvailability();
syncSurface();
},
@@ -460,7 +610,11 @@ export function createStreetLevelLayer({
providerIds: state.providers.keys(),
filter: state.filter,
});
for (const [id, on] of decoded.providers) setProviderEnabled(id, on);
const lit = litByParams(decoded.providers);
for (const [id, entry] of state.providers)
if (id !== lit) switchProvider(entry, false);
if (lit) switchProvider(state.providers.get(lit), true);
notify();
setCoverageFilter(decoded.filter);
return true;
},
@@ -476,15 +630,39 @@ export function createStreetLevelLayer({
attachViewerHost(element) {
parts.viewerHost.attach(element);
if (element && state.enabled)
whenIdle(() => parts.viewerHost.prewarm(activeEntries()), 1500);
whenIdle(
() =>
parts.viewerHost.prewarm(
activeEntries().filter((entry) => providerAvailable(entry)),
),
1500,
);
},
setProviderEnabled,
/** Imagery filter for coverage, cones and nearest-image lookups. */
setCoverageFilter,
openImage,
/** Open the nearest image any active provider has around a point. */
async openNearest(point) {
const view = point || viewCentre(state.viewer);
/**
* Open the nearest image of the active providers (or `providerIds`) near
* a point, else the view centre, or with `aim: 'screen-centre'` the ground
* under the screen centre (sky: nothing).
*/
async openNearest(
point,
{ providerIds = null, frame = true, aim = 'view' } = {},
) {
let view = point;
if (!view && aim === 'screen-centre') {
view = screenCentreGround();
if (view === null) {
abortNearest();
state.street.loading = false;
state.street.error = AIM_AT_GROUND_MESSAGE;
notify();
return false;
}
}
view ||= viewCentre(state.viewer);
if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
return false;
abortNearest();
@@ -495,7 +673,11 @@ export function createStreetLevelLayer({
state.street.error = null;
notify();
let lastError = null;
for (const entry of activeEntries()) {
const asked = (entry) =>
(!providerIds || providerIds.includes(entry.def.id)) &&
providerAvailable(entry);
const entries = activeEntries().filter(asked);
for (const entry of entries) {
let imageId = null;
try {
imageId = await entry.instance.nearestImage(
@@ -511,17 +693,20 @@ export function createStreetLevelLayer({
}
if (signal.aborted) return false;
if (!state.enabled) break;
// This provider was switched off while it looked: try the next.
if (!imageId || !entry.on) continue;
// Switched off, or its map left, while it looked: try the next.
if (!imageId || !entry.on || !providerAvailable(entry)) continue;
nearestLookup = null;
return openImage(entry.def.id, imageId);
return openImage(entry.def.id, imageId, { frame });
}
if (nearestLookup === lookup) nearestLookup = null;
// Switched off while it looked: nothing to open, nothing to report.
if (state.enabled && activeEntries().length)
const stillOn = entries.some(
(entry) => entry.on && providerAvailable(entry),
);
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;
+322
View File
@@ -483,6 +483,328 @@ test('switching a provider on while the layer is off activates nothing (M01)', a
assert.equal(viewer.credits.length, 1);
});
/** The real Google provider over the Street View fakes, beside a Mapillary stand-in. */
async function withStreetView(
t,
{ mapStack = 'photoreal', nearest, mapillaryKeyless = false } = {},
) {
const { createGoogleProvider } = await import('./providers/google/index.js');
const { fakeHost, fakeMapsLoader, fakeStreetViewLibrary } =
await import('../../testSupport/googleStreetViewFakes.mjs');
const fake = fakeStreetViewLibrary({
panoramas: {
'pano-a': { lat: 38.5816, lng: -121.4944, imageDate: '2024-05' },
},
nearest: nearest ?? (() => 'pano-a'),
});
const mapillaryLookups = [];
const mapillary = fakeProvider({
nearestImage: async (point) => {
mapillaryLookups.push(point);
return null;
},
});
mapillary.requiresKeyId = 'mapillary';
if (mapillaryKeyless) {
mapillary.stats.keyRequired = true;
const create = mapillary.create;
mapillary.create = (context) => ({
...create(context),
status: async () => ({ configured: false }),
});
}
const loader = fakeMapsLoader(fake.library);
const google = createGoogleProvider({
getApiKey: () => 'browser-key',
loader,
});
const { layer, viewer } = await enabledLayer(t, [mapillary, google]);
const stack = fakeMapStack(mapStack);
layer.attachMapStackController(stack);
layer.attachViewerHost(fakeHost());
return { layer, viewer, stack, fake, loader, mapillaryLookups };
}
test('Street View starts off, and the layer row names the key of the providers that are on', async (t) => {
const { layer } = await withStreetView(t);
const google = () =>
layer.getUIState().providers.find((p) => p.id === 'google');
assert.equal(google().on, false);
assert.equal(layer.requiresKeyId, 'mapillary');
layer.setProviderEnabled('google', true);
assert.equal(layer.requiresKeyId, 'google-maps', 'Street View alone');
});
test('the layer starts with one source lit: the first that defaults on', async (t) => {
const { layer } = await enabledLayer(t, [
fakeProvider(),
fakeProvider({ id: 'panoramax', pickPrefix: 'pmx:' }),
]);
assert.deepEqual(
layer
.getUIState()
.providers.filter((p) => p.on)
.map((p) => p.id),
['mapillary'],
);
});
test('leaving Google 3D during a Street View lookup ends it without an error or a stuck spinner', async (t) => {
const { layer, stack, fake } = await withStreetView(t);
layer.setProviderEnabled('google', true);
let answer;
fake.library.StreetViewService.prototype.getPanorama = () =>
new Promise((resolve) => {
answer = resolve;
});
const opening = layer.openNearest({ lat: 38.5816, lon: -121.4944 });
await settle();
assert.equal(layer.getUIState().street.loading, true);
stack.switchTo('bing');
answer({ data: { location: { pano: 'pano-a' }, imageDate: '2024-05' } });
assert.equal(await opening, false);
const { street } = layer.getUIState();
assert.equal(street.loading, false, 'OPEN STREET VIEW is usable again');
assert.equal(street.error, null);
assert.equal(street.open, false);
});
test('a source swap during a lookup reports no false "no imagery"', async (t) => {
let answer;
const mapillary = fakeProvider({
nearestImage: () =>
new Promise((resolve) => {
answer = resolve;
}),
});
const other = fakeProvider({ id: 'panoramax', pickPrefix: 'pmx:' });
const { layer } = await enabledLayer(t, [mapillary, other]);
const opening = layer.openNearest({ lat: 1, lon: 2 });
layer.setProviderEnabled('panoramax', true);
answer('img-1');
assert.equal(await opening, false);
assert.equal(layer.getUIState().street.error, null);
assert.equal(layer.getUIState().street.loading, false);
});
test('Street View lit off Google 3D says why the panel is empty', async (t) => {
const { layer, stack } = await withStreetView(t);
layer.setProviderEnabled('google', true);
stack.switchTo('esri-imagery');
assert.match(
layer.getUIState().coverage.hint,
/Google Street View needs the Google 3D map/,
);
layer.setProviderEnabled('mapillary', true);
assert.doesNotMatch(layer.getUIState().coverage.hint, /Google 3D/);
});
test('one imagery source at a time: lighting one darkens the other, either way', async (t) => {
const { layer } = await withStreetView(t);
const lit = () =>
layer
.getUIState()
.providers.filter((p) => p.on)
.map((p) => p.id);
assert.deepEqual(lit(), ['mapillary'], 'Mapillary by default: it is free');
layer.setProviderEnabled('google', true);
assert.deepEqual(lit(), ['google']);
assert.equal(layer.getParams().mapillary, false);
layer.setProviderEnabled('mapillary', true);
assert.deepEqual(lit(), ['mapillary']);
assert.equal(layer.getParams().google, false);
// Params that light one switch to it; reapplying them changes nothing.
layer.setParams({ google: true });
assert.deepEqual(lit(), ['google']);
layer.setParams({ google: true });
assert.deepEqual(lit(), ['google']);
// Params lighting both keep the one already on, however often applied.
for (let i = 0; i < 2; i++) {
layer.setParams({ mapillary: true, providerSwitches: 'google-1' });
assert.deepEqual(lit(), ['google']);
}
layer.setParams({ mapillary: true, google: false });
assert.deepEqual(lit(), ['mapillary']);
// Filter-only params leave the source alone.
layer.setParams({ pano: 'pano' });
assert.deepEqual(lit(), ['mapillary']);
// Darkening the only lit one leaves none: the panel turns the layer off.
layer.setProviderEnabled('mapillary', false);
assert.deepEqual(lit(), []);
});
test('Street View appears only on the Google 3D map: chip, legend, credit, source and images', async (t) => {
const { layer, viewer, stack } = await withStreetView(t, {
mapStack: 'esri-imagery',
});
layer.setProviderEnabled('google', true);
await settle();
const ui = () => layer.getUIState();
const listed = () => ui().providers.map((p) => p.id);
const credited = () =>
viewer.credits.some((credit) => /Street View imagery/.test(credit.html));
assert.deepEqual(listed(), ['mapillary'], 'no Street View chip');
assert.ok(!ui().legend.some((item) => item.key === 'google'));
assert.equal(credited(), false, 'no Google credit on another map');
assert.equal(layer.source, 'Mapillary');
assert.equal(layer.getStats().source, 'Mapillary');
assert.equal(await layer.openImage('google', 'pano-a'), false);
assert.match(ui().street.error, /needs the Google 3D map/);
assert.equal(layer.getParams().google, true, 'the choice is kept');
stack.switchTo('photoreal');
assert.deepEqual(listed(), ['mapillary', 'google']);
assert.equal(credited(), true);
assert.equal(layer.source, 'Mapillary · Google Street View');
assert.equal(await layer.openImage('google', 'pano-a'), true);
assert.equal(ui().street.providerId, 'google');
stack.switchTo('bing');
await settle();
assert.equal(ui().street.open, false, 'closed with the map');
assert.equal(credited(), false);
assert.deepEqual(listed(), ['mapillary']);
});
test('the UI state says when a click (or the button) can open Street View here', async (t) => {
const { layer } = await withStreetView(t);
assert.equal(typeof layer.getUIState().groundClickReady, 'boolean');
});
test('a lookup for the click-to-open providers asks only them and reports "that point"', async (t) => {
const { layer, mapillaryLookups } = await withStreetView(t, {
nearest: () => null,
});
layer.setProviderEnabled('google', true);
await settle();
const opened = await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false },
);
assert.equal(opened, false);
assert.deepEqual(mapillaryLookups, [], 'Mapillary was not asked');
assert.equal(
layer.getUIState().street.error,
'No street-level imagery within 50 m of that point',
);
});
test('an image opened from a ground click leaves the camera where it is', async (t) => {
const { layer, viewer } = await withStreetView(t);
layer.setProviderEnabled('google', true);
await settle();
await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false },
);
assert.equal(layer.getUIState().street.imageId, 'pano-a');
assert.equal(viewer.flights.started, 0, 'no framing flight');
});
test('off Google 3D not even Google\u2019s script is loaded for Street View', async (t) => {
const { layer, loader } = await withStreetView(t, {
mapStack: 'esri-imagery',
});
layer.setProviderEnabled('google', true);
layer.attachViewerHost({ ownerDocument: { createElement: () => ({}) } });
await new Promise((resolve) => setTimeout(resolve, 100));
assert.deepEqual(loader.imports, [], 'no prewarm, no lookup');
});
test('the button’s “centre of the view” is the ground under the middle of the screen', async (t) => {
const { createStreetLevelLayer: create } = await import('./index.js');
const scenePick = await import('../../data/scenePick.js');
const lookups = [];
const provider = fakeProvider({
nearestImage: async (point) => {
lookups.push(point);
return null;
},
});
const saved = globalThis.document;
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0);
Object.assign(viewer.scene, {
pickPositionSupported: true,
pickPosition: (at) => (at.x === 500 && at.y === 300 ? centre : undefined),
});
viewer.scene.canvas.clientWidth = 1000;
viewer.scene.canvas.clientHeight = 600;
const layer = create({ providers: [provider], services: { scenePick } });
layer.init(viewer);
layer.enable(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
await layer.openNearest(undefined, { aim: 'screen-centre' });
assert.equal(lookups.length, 1);
assert.ok(Math.abs(lookups[0].lat - 38.5799) < 1e-9);
assert.ok(Math.abs(lookups[0].lon - -121.4937) < 1e-9);
});
test('the button aimed at the sky opens nothing, and says to aim at a street', async (t) => {
const { createStreetLevelLayer: create } = await import('./index.js');
const scenePick = await import('../../data/scenePick.js');
const lookups = [];
const provider = fakeProvider({
nearestImage: async (point) => {
lookups.push(point);
return null;
},
});
const saved = globalThis.document;
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
// Every ground pick misses: the middle of the screen is sky.
Object.assign(viewer.scene, {
pickPositionSupported: true,
pickPosition: () => undefined,
});
viewer.camera.pickEllipsoid = () => undefined;
viewer.scene.canvas.clientWidth = 1000;
viewer.scene.canvas.clientHeight = 600;
const layer = create({ providers: [provider], services: { scenePick } });
layer.init(viewer);
layer.enable(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
assert.equal(
await layer.openNearest(undefined, { aim: 'screen-centre' }),
false,
);
assert.deepEqual(lookups, [], 'no lookup at the view centre instead');
const { street } = layer.getUIState();
assert.match(street.error, /Aim the middle of the view at a street/);
assert.equal(street.loading, false);
});
test('with no Mapillary token, the layer points at Street View, on Google 3D only', async (t) => {
const { layer, stack } = await withStreetView(t, { mapillaryKeyless: true });
await settle();
const stats = layer.getStats();
assert.equal(stats.keyRequired, true);
assert.deepEqual(stats.keyAlternatives, [
{ id: 'google', label: 'GOOGLE STREET VIEW', requiresKeyId: 'google-maps' },
]);
assert.deepEqual(layer.getUIState().keyAlternatives, stats.keyAlternatives);
stack.switchTo('esri-imagery');
assert.deepEqual(layer.getStats().keyAlternatives, [], 'not off Google 3D');
// Lit, Street View draws: the layer is no longer key-gated.
stack.switchTo('photoreal');
layer.setProviderEnabled('google', true);
assert.equal(layer.getStats().keyRequired, false);
});
test('an empty provider composition enables and tears down without acquiring imagery', async (t) => {
const { layer, viewer } = await enabledLayer(t, []);
assert.deepEqual(layer.providerIds, []);
+35 -11
View File
@@ -1,11 +1,27 @@
import * as Cesium from 'cesium';
import { positionMarkerGlyph } from './glyphs.js';
import { positionMarkerGlyph, viewConeMarkerGlyph } from './glyphs.js';
import { COLORS, POSITION_PICK_ID } from './policy.js';
import { refineHeights } from './groundCast.js';
import { createHorizonCull } from './view.js';
const SPRITE_ID = 'street-level:marker';
function markerLook(style) {
if (style?.kind === 'view-cone') {
const color = style.color || COLORS.position;
return {
key: `view-cone:${color}`,
image: viewConeMarkerGlyph({ color }),
size: 48,
};
}
return {
key: 'pin',
image: positionMarkerGlyph({ color: COLORS.position }),
size: 40,
};
}
/** On-globe marker for the open image; in terrain mode it stands on bare earth. */
export function createMarker({ state, parts }) {
const { render, sprites } = state.services;
@@ -42,7 +58,8 @@ export function createMarker({ state, parts }) {
const dem = ground.groundAt(lon, lat);
if (dem === null) {
ground.prepare([[lon, lat]]).then((ready) => {
if (ready && last?.position === position) set(position, last.bearing);
if (ready && last?.position === position)
set(position, last.bearing, last.style);
});
return null;
}
@@ -53,14 +70,15 @@ export function createMarker({ state, parts }) {
// A new mesh sample may move the marker onto (or off) the road surface.
parts?.meshSampler?.onSampled(() => {
if (last && state.surface === 'terrain') set(last.position, last.bearing);
if (last && state.surface === 'terrain')
set(last.position, last.bearing, last.style);
});
/** Move (or create) the marker; a null position removes it. */
function set(position, bearing) {
/** Move (or create) the marker; null removes it. `style`: the provider's `marker`. */
function set(position, bearing, style = null) {
const collection = state.marker.collection;
if (!collection) return;
last = position ? { position, bearing } : null;
last = position ? { position, bearing, style } : null;
if (!position) {
collection.removeAll();
state.marker.billboard = null;
@@ -78,14 +96,20 @@ export function createMarker({ state, parts }) {
height === null
? Cesium.HeightReference.CLAMP_TO_GROUND
: Cesium.HeightReference.NONE;
const look = markerLook(style);
if (state.marker.billboard && state.marker.look !== look.key) {
collection.remove?.(state.marker.billboard);
state.marker.billboard = null;
}
if (!state.marker.billboard) {
state.marker.look = look.key;
state.marker.billboard = collection.add({
id: POSITION_PICK_ID,
position: cartesian,
image: positionMarkerGlyph({ color: COLORS.position }),
imageId: 'sl-position',
width: 40,
height: 40,
image: look.image,
imageId: `sl-position:${look.key}`,
width: look.size,
height: look.size,
alignedAxis: Cesium.Cartesian3.UNIT_Z,
heightReference,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
@@ -117,7 +141,7 @@ export function createMarker({ state, parts }) {
/** Re-place the marker after the surface mode changes. */
function setSurface() {
if (last) set(last.position, last.bearing);
if (last) set(last.position, last.bearing, last.style);
}
return {
+26
View File
@@ -40,6 +40,7 @@ function setup({ parts = {}, surface = 'draped' } = {}) {
collection: {
show: true,
add: (options) => ({ ...options }),
remove() {},
removeAll() {},
},
billboard: null,
@@ -187,3 +188,28 @@ test('draped mode clamps the marker to the globe whatever the ground says', () =
);
assert.equal(ground.prepares.length, 0);
});
test('a Street View photo gets a blue view cone; a Mapillary photo GEV\u2019s pin', () => {
const { state, marker } = setup();
const removed = [];
state.marker.collection.remove = (billboard) => removed.push(billboard);
const streetView = { kind: 'view-cone', color: '#4285f4' };
marker.set({ lon: -121.49, lat: 38.58 }, 45, streetView);
const cone = state.marker.billboard;
assert.equal(cone.imageId, 'sl-position:view-cone:#4285f4');
assert.equal(
cone.rotation,
-Cesium.Math.toRadians(45),
'it turns with the view',
);
// Dragging the panorama keeps the look and the billboard.
marker.set({ lon: -121.49, lat: 38.58 }, 90, streetView);
assert.equal(state.marker.billboard, cone);
// Re-placed for a surface change: still the cone.
marker.setSurface();
assert.equal(state.marker.billboard, cone);
// A Mapillary photo next (its provider has no marker): the pin.
marker.set({ lon: -121.49, lat: 38.58 }, 90, undefined);
assert.equal(state.marker.billboard.imageId, 'sl-position:pin');
assert.deepEqual(removed, [cone]);
});
+7
View File
@@ -7,6 +7,7 @@ export const POSITION_PICK_ID = 'sl:pos';
/** One colour per imagery source: chip, coverage lines, points, cones and legend. */
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;
/** Above this, a ground click is a misclick, not a street. */
export const GROUND_CLICK_MAX_HEIGHT_M = 3_000;
export const AIM_AT_GROUND_MESSAGE =
'Aim the middle of the view at a street to look for imagery there';
@@ -0,0 +1,184 @@
import { FOLLOW_MAP_STACK_ID } from '../../policy.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,
} from './policy.js';
/** `promise`, unless it times out, `signal` aborts or Google refuses the key. */
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 provider: Google's panorama viewer and nearest-panorama
* lookups. It draws no coverage; a click on a street opens the panorama.
* @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,
// Google's terms forbid Street View beside non-Google maps.
requiresMapStack: FOLLOW_MAP_STACK_ID,
// Every panorama is billed to the Maps key.
defaultOn: false,
// All 360°, and no coverage for a date filter to thin.
filters: [],
marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }),
create(context) {
let keyRequired = !getApiKey();
let active = false;
let service = null;
async function getService() {
const lib = await loader.importLibrary('streetView');
service ||= new lib.StreetViewService();
return service;
}
const viewer = createGoogleViewer({
loader,
getService,
render: context.services?.render,
});
const stopAuthWatch = loader.onAuthFailure(() => {
// Switched off: its chip says so.
if (context.isActive?.())
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() {},
/** No filter applies to Street View (see `filters`). */
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, or null. The imagery
* filters do not apply (see `filters`).
*/
async nearestImage({ lat, lon }, { signal } = {}) {
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
signal?.throwIfAborted();
// Set once the caller has its answer (aborted, timed out, refused):
// a lookup still waiting on Google's script then asks nothing.
let settled = false;
const lookup = (async () => {
const lib = await loader.importLibrary('streetView');
await getService();
if (settled) return null;
const { GOOGLE, OUTDOOR } = lib.StreetViewSource;
try {
const { data } = await service.getPanorama({
location: { lat, lng: lon },
radius: NEAREST_RADIUS_M,
// Both: Google's own imagery, outdoors (sources intersect).
sources: [GOOGLE, 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,
}).finally(() => {
settled = true;
});
const panoId = data?.location?.pano;
if (!panoId) return null;
viewer.remember(panoId, data);
return panoId;
},
viewer,
};
},
});
}
@@ -0,0 +1,85 @@
/**
* Loads the Google Maps JavaScript API once per page, on first use. Google's
* `gm_authFailure` (key refused) reaches every `onAuthFailure` listener.
*/
const SCRIPT_ORIGIN = 'https://maps.googleapis.com';
const READY_CALLBACK = '__gevGoogleMapsReady';
/** The page's one script load, shared by every loader on it. */
const PAGE_LOAD = Symbol.for('gev.googleMapsLoad');
/**
* @param {{getApiKey: () => string|null|undefined, document?: Document, global?: object}} options
*/
export function createMapsLoader({
getApiKey,
document = globalThis.document,
global = globalThis,
}) {
let authFailed = false;
let authHandlerInstalled = false;
const authListeners = new Set();
/** Once per loader: a retry must not wrap its own handler. */
function installAuthHandler() {
if (authHandlerInstalled) return;
authHandlerInstalled = true;
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() {
const key = getApiKey?.();
if (!key)
return Promise.reject(new Error('GOOGLE_MAPS_API_KEY is not set'));
installAuthHandler();
if (global[PAGE_LOAD]) return global[PAGE_LOAD];
if (global.google?.maps?.importLibrary)
return Promise.resolve(global.google.maps);
const 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 = () => {
if (global[PAGE_LOAD] === loading) delete global[PAGE_LOAD];
script.remove();
delete global[READY_CALLBACK];
reject(new Error('The Google Maps JavaScript API could not load'));
};
document.head.append(script);
});
global[PAGE_LOAD] = loading;
return loading;
}
return {
async importLibrary(name) {
const maps = await load();
return maps.importLibrary(name);
},
authFailed: () => authFailed,
onAuthFailure(listener) {
authListeners.add(listener);
return () => authListeners.delete(listener);
},
};
}
@@ -0,0 +1,144 @@
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('two loaders on one page share one script and both get the API', async () => {
const page = fakePage();
const first = createMapsLoader({ getApiKey: () => 'k', ...page });
const second = createMapsLoader({ getApiKey: () => 'k', ...page });
const one = first.importLibrary('streetView');
const two = second.importLibrary('streetView');
await settle();
assert.equal(page.scripts.length, 1, 'one script for the page');
page.loadScript();
assert.equal((await one).name, 'streetView');
assert.equal((await two).name, 'streetView');
assert.equal((await first.importLibrary('streetView')).name, 'streetView');
});
test('after a failed script, either loader on the page can retry it', async () => {
const page = fakePage();
const first = createMapsLoader({ getApiKey: () => 'k', ...page });
const second = createMapsLoader({ getApiKey: () => 'k', ...page });
const failed = first.importLibrary('streetView');
await settle();
page.scripts[0].onerror();
await assert.rejects(failed, /could not load/);
const retry = second.importLibrary('streetView');
const again = first.importLibrary('streetView');
await settle();
assert.equal(page.scripts.length, 1);
page.loadScript();
assert.equal((await retry).name, 'streetView');
assert.equal((await again).name, 'streetView');
});
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']);
});
test('a retried load still reports a refusal once, and the page handler once', async () => {
const page = fakePage();
let before = 0;
page.global.gm_authFailure = () => before++;
const loader = createMapsLoader({ getApiKey: () => 'k', ...page });
const heard = [];
loader.onAuthFailure(() => heard.push('a'));
const failed = loader.importLibrary('streetView');
await settle();
page.scripts[0].onerror();
await assert.rejects(failed);
loader.importLibrary('streetView');
await settle();
page.global.gm_authFailure();
assert.deepEqual(heard, ['a']);
assert.equal(before, 1);
});
@@ -0,0 +1,68 @@
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
import { msg } from '../../../../i18n/index.js';
export { NEAREST_RADIUS_M } from '../../policy.js';
export const GOOGLE_PROVIDER_ID = 'google';
export const GOOGLE_NAME = msg('Google Street View');
export const GOOGLE_LABEL = msg('GOOGLE STREET VIEW');
/** The browser Maps key; Street View also needs the Maps JavaScript API on it. */
export const GOOGLE_KEY_ID = 'google-maps';
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 (gm_authFailure). */
export const KEY_REJECTED_MESSAGE = msg(
'Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer',
);
/** Past these, a nearest lookup, an open or a details lookup has failed. */
export const NEAREST_TIMEOUT_MS = 10_000;
export const OPEN_TIMEOUT_MS = 20_000;
export const DETAILS_TIMEOUT_MS = 10_000;
/** Pause after a failed details lookup before a view event can retry. */
export const DETAILS_RETRY_COOLDOWN_MS = 5_000;
export const NO_ANSWER_MESSAGE = msg(
'Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY',
);
export const GROUND_CLICK_HINT = msg('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,293 @@
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 {
fakeHost,
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 };
let active = false;
const context = {
services: {},
getFilter: () => filter,
isActive: () => active,
notify() {},
actions: { reportError: (message) => reported.push(message) },
};
const created = def.create(context);
// The core switches it on and off; mirror that for isActive().
const instance = {
...created,
activate() {
active = true;
created.activate();
},
deactivate() {
active = false;
created.deactivate();
},
};
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, 'GOOGLE STREET VIEW');
assert.deepEqual(def.filters, [], 'no imagery filter applies');
assert.deepEqual(def.marker, { kind: 'view-cone', color: '#4285f4' });
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 is null; the imagery filters (Mapillary\u2019s) do not apply', async () => {
const none = setup({ nearest: () => null });
assert.equal(await none.instance.nearestImage({ lat: 0, lon: 0 }), null);
const { instance, setFilter } = setup({ nearest: () => 'old' }); // 2009
for (const filter of [
{ pano: 'all', sinceMs: Date.UTC(2020, 0, 1) },
{ pano: 'flat', sinceMs: null },
{ pano: 'pano', sinceMs: Date.UTC(2025, 0, 1) },
]) {
setFilter(filter);
assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), 'old');
}
assert.equal(instance.coverageStats().hint, '', 'off: no hint');
instance.activate();
setFilter({ pano: 'flat', sinceMs: null });
assert.equal(instance.coverageStats().hint, GROUND_CLICK_HINT);
});
test('an aborted lookup gives up before 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' });
});
test('a lookup cancelled while Google\u2019s script loads asks Google nothing', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { NEAREST_TIMEOUT_MS } = await import('./policy.js');
const fake = fakeStreetViewLibrary({
panoramas: PANORAMAS,
nearest: () => 'fresh',
});
const loader = fakeMapsLoader(fake.library, { deferred: true });
const instance = createGoogleProvider({
getApiKey: () => 'browser-key',
loader,
}).create({
services: {},
getFilter: () => ({ pano: 'all', sinceMs: null }),
isActive: () => true,
notify() {},
actions: { reportError() {} },
});
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
// Aborted, then timed out, while the script still loads.
const controller = new AbortController();
const aborted = instance.nearestImage(
{ lat: 0, lon: 0 },
{ signal: controller.signal },
);
controller.abort();
await assert.rejects(aborted, { name: 'AbortError' });
const expired = instance.nearestImage({ lat: 0, lon: 0 });
t.mock.timers.tick(NEAREST_TIMEOUT_MS);
await assert.rejects(expired, /did not answer/);
// The script arrives: a current lookup still gets its answer.
const current = instance.nearestImage({ lat: 0, lon: 0 });
loader.release();
await flush();
assert.equal(await current, 'fresh');
assert.equal(fake.lookups.length, 1, 'only the current lookup asked Google');
});
test('a Google outage is an error, not "no imagery here"', async () => {
const { instance, fake } = setup();
fake.library.StreetViewService.prototype.getPanorama = () =>
Promise.reject(
Object.assign(new Error('UNKNOWN_ERROR'), {
code: 'UNKNOWN_ERROR',
endpoint: 'STREETVIEW_GET_PANORAMA',
}),
);
await assert.rejects(instance.nearestImage({ lat: 0, lon: 0 }), {
code: 'UNKNOWN_ERROR',
});
});
test('a refusal while the provider is off reports no error over another photo', () => {
const { instance, loader, reported } = setup();
loader.refuseKey();
assert.deepEqual(reported, []);
assert.equal(
instance.coverageStats().keyRejected,
true,
'its chip still says so',
);
});
test('the panorama a lookup found is dated without a second lookup', async () => {
const { instance, fake } = setup();
await instance.nearestImage({ lat: 38.58, lon: -121.49 });
const host = fakeHost();
const poses = [];
instance.viewer.onPose((pose) => poses.push(pose));
await instance.viewer.mount(host);
await instance.viewer.open('fresh');
assert.equal(poses.at(-1).capturedAt, Date.UTC(2025, 5, 1));
assert.deepEqual(
fake.lookups.filter((request) => request.pano),
[],
'no getPanorama({pano}) for a panorama the lookup already described',
);
});
@@ -0,0 +1,286 @@
import {
DETAILS_RETRY_COOLDOWN_MS,
DETAILS_TIMEOUT_MS,
GOOGLE_PROVIDER_ID,
KEY_REJECTED_MESSAGE,
NO_ANSWER_MESSAGE,
OPEN_TIMEOUT_MS,
creatorFrom,
imageDateMs,
streetViewUrl,
} from './policy.js';
const PANORAMA_OPTIONS = Object.freeze({
visible: false,
// The caption has the address; Google's card covers a docked panorama.
addressControl: false,
linksControl: true,
panControl: true,
zoomControl: true,
clickToGo: true,
showRoadLabels: true,
// The panel has EXPAND and ×.
fullscreenControl: false,
enableCloseButton: false,
motionTracking: false,
motionTrackingControl: false,
});
/**
* Viewer adapter over Google's StreetViewPanorama: emits a pose for every
* panorama and view change, including steps along Google's arrows.
* @param {{loader: ReturnType<import('./mapsLoader.js').createMapsLoader>, getService?: () => Promise<object>, render?: object}} options
* @returns {import('../../registry.js').ViewerAdapter & {remember: Function}}
*/
export function createGoogleViewer({ loader, getService, render } = {}) {
let library = null;
let panorama = null;
let element = null;
let host = null;
let service = null;
let pendingOpen = null;
let cancelShown = null;
/** Bumped by `unmount`: a construction it overtook builds nothing. */
let generation = 0;
let subscriptions = [];
const described = new Map();
/** Panorama id → its lookup in flight; a late answer to a dropped one is ignored. */
const lookingUp = new Map();
const retryAfter = 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 */
}
}
}
function metaFrom(data) {
return {
capturedAt: imageDateMs(data?.imageDate),
creator: creatorFrom(data?.copyright),
title: data?.location?.description || null,
};
}
/**
* Look up a panorama's date, photographer and address once, then re-emit
* the pose. A failed or timed-out lookup can retry after the cooldown.
*/
async function describe(panoId) {
if (described.has(panoId) || lookingUp.has(panoId)) return;
if (Date.now() < (retryAfter.get(panoId) ?? 0)) return;
const lookup = { timer: null };
lookingUp.set(panoId, lookup);
const current = () => lookingUp.get(panoId) === lookup;
try {
const { data } = await new Promise((resolve, reject) => {
lookup.timer = setTimeout(
() => reject(new Error(NO_ANSWER_MESSAGE)),
DETAILS_TIMEOUT_MS,
);
(async () => {
service ||= getService
? await getService()
: new (await ensureLibrary()).StreetViewService();
return service.getPanorama({ pano: panoId });
})().then(resolve, reject);
});
if (!current()) return;
described.set(panoId, metaFrom(data));
retryAfter.delete(panoId);
} catch {
if (current())
retryAfter.set(panoId, Date.now() + DETAILS_RETRY_COOLDOWN_MS);
return;
} finally {
clearTimeout(lookup.timer);
if (current()) lookingUp.delete(panoId);
}
publishPose();
}
function releaseLookups() {
for (const lookup of lookingUp.values()) clearTimeout(lookup.timer);
lookingUp.clear();
}
function publishPose() {
const panoId = panorama?.getPano?.();
const latLng = panorama?.getPosition?.();
if (!panoId || !latLng || pendingOpen === null) return;
const meta = described.get(panoId);
if (!meta) describe(panoId);
const pov = panorama.getPov() || {};
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 ?? null,
capturedAtPrecision: 'month',
creator: meta?.creator ?? null,
title: meta?.title ?? null,
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);
// Not pano_changed: it can fire with the old panorama's position.
subscriptions = ['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 (OK status, or a new position when the
* status stays OK); rejects on no imagery, a refused key or no answer.
*/
function shown(instance, panoId) {
if (instance.getPano() === panoId && instance.getStatus() === 'OK')
return Promise.resolve();
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => finish(reject, new Error(NO_ANSWER_MESSAGE)),
OPEN_TIMEOUT_MS,
);
const stopAuthWatch = loader.onAuthFailure(() =>
finish(reject, new Error(KEY_REJECTED_MESSAGE)),
);
cancelShown = () => finish(resolve);
// At a position event the status may still be the last panorama's failure.
const onStatus = () => {
if (pendingOpen !== panoId) return finish(resolve);
const status = instance.getStatus();
if (status === 'OK') finish(resolve);
else if (status)
finish(
reject,
new Error('Street View has no imagery for this place'),
);
};
const onPosition = () => {
if (pendingOpen !== panoId || instance.getStatus() === 'OK')
finish(resolve);
};
const subscriptions = [
instance.addListener('status_changed', onStatus),
instance.addListener('position_changed', onPosition),
];
function finish(settle, value) {
cancelShown = null;
clearTimeout(timer);
for (const subscription of subscriptions) subscription.remove();
stopAuthWatch();
settle(value);
}
});
}
return {
async mount(target) {
if (!target) throw new Error('Street Level viewer has no host element');
await ensurePanorama(target);
},
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;
publishPose();
},
close() {
pendingOpen = null;
cancelShown?.();
try {
panorama?.setVisible(false);
} catch {
/* torn down */
}
},
unmount() {
pendingOpen = null;
cancelShown?.();
releaseLookups();
generation++;
destroyPanorama();
},
resize() {
if (!panorama) return;
try {
globalThis.google?.maps?.event?.trigger(panorama, 'resize');
} catch {
/* no-op */
}
},
onPose(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
/** Seed the details cache from a nearest lookup's answer. */
remember(panoId, data) {
if (panoId && data) described.set(String(panoId), metaFrom(data));
},
};
}
@@ -0,0 +1,405 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGoogleViewer } from './viewer.js';
import { DETAILS_RETRY_COOLDOWN_MS, DETAILS_TIMEOUT_MS } from './policy.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);
await settle(); // the pose is dated once its lookup answers
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', async () => {
const { fake, viewer, host } = setup();
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('there is no prewarm: Google\u2019s script loads on first use only, never on another map', () => {
const { viewer, loader } = setup();
assert.equal(viewer.prewarm, undefined);
assert.deepEqual(loader.imports, []);
});
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'), /Maps JavaScript API is enabled/);
await assert.rejects(viewer.open('pano-b'), /Maps JavaScript API is enabled/);
assert.equal(loader.listenerCount(), 0, 'no auth listener is left behind');
});
test('a viewer unmounted while the library loads builds no panorama (none is billed)', async () => {
const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS });
const loader = fakeMapsLoader(fake.library, { deferred: true });
const viewer = createGoogleViewer({ loader });
const host = fakeHost();
const mounting = viewer.mount(host);
viewer.unmount();
loader.release();
await assert.rejects(mounting, /unmounted/);
assert.equal(fake.built.length, 0);
assert.equal(host.children.length, 0);
});
test('a date that arrives after the photo was closed emits nothing', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
await viewer.open('pano-a');
const [panorama] = fake.built;
let answer;
fake.library.StreetViewService.prototype.getPanorama = () =>
new Promise((resolve) => {
answer = resolve;
});
panorama.setPano('pano-b'); // Google's arrow; its date is still unknown
await settle();
assert.equal(poses.at(-1).imageId, 'pano-b', 'the step shows at once');
assert.equal(
poses.at(-1).capturedAt,
null,
'undated until the lookup answers',
);
viewer.close();
const count = poses.length;
answer({ data: { imageDate: '2019-08' } });
await settle();
assert.equal(poses.length, count);
});
test('an open never waits on the date lookup', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
fake.library.StreetViewService.prototype.getPanorama = () =>
new Promise(() => {}); // the metadata never answers
await viewer.open('pano-a');
assert.equal(poses.at(-1).imageId, 'pano-a');
viewer.unmount();
});
test('closing while a panorama loads settles the open and drops its listeners', async () => {
const { fake, loader, viewer, host } = setup();
await viewer.mount(host);
const [panorama] = fake.built;
const before = panorama.listenerCount();
panorama.setPano = (id) => {
panorama.pano = id; // still loading
};
const opening = viewer.open('pano-a');
await settle();
viewer.close();
await opening;
assert.equal(panorama.listenerCount(), before);
assert.equal(loader.listenerCount(), 0);
});
test('a second open whose status stays OK still resolves, on the new position', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
await viewer.open('pano-a');
assert.equal(fake.built[0].getStatus(), 'OK');
await viewer.open('pano-b'); // OK → OK: no status_changed
assert.equal(poses.at(-1).imageId, 'pano-b');
});
test('Google answering with a newer id for the place shows that id', async () => {
const fake = fakeStreetViewLibrary({
panoramas: PANORAMAS,
substitutes: { 'old-id': 'pano-a' },
});
const viewer = createGoogleViewer({ loader: fakeMapsLoader(fake.library) });
const poses = [];
viewer.onPose((pose) => poses.push(pose));
await viewer.mount(fakeHost());
await viewer.open('old-id');
assert.equal(poses.at(-1).imageId, 'pano-a');
});
test('a panorama that never shows fails after the timeout and leaves no listener', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { OPEN_TIMEOUT_MS, NO_ANSWER_MESSAGE } = await import('./policy.js');
const { fake, loader, viewer, host } = setup();
await viewer.mount(host);
const [panorama] = fake.built;
const before = panorama.listenerCount();
panorama.setPano = (id) => {
panorama.pano = id; // Google never answers
};
const opening = viewer.open('pano-a');
await Promise.resolve();
await Promise.resolve();
t.mock.timers.tick(OPEN_TIMEOUT_MS);
await assert.rejects(opening, { message: NO_ANSWER_MESSAGE });
assert.equal(panorama.listenerCount(), before);
assert.equal(loader.listenerCount(), 0);
});
test('failed details lookups cool down through repeated drags and retry after the pause', async (t) => {
let now = 1_000;
t.mock.method(Date, 'now', () => now);
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
let failures = 2;
let calls = 0;
fake.library.StreetViewService.prototype.getPanorama = function (request) {
calls++;
if (failures-- > 0) return Promise.reject(new Error('offline'));
return getPanorama.call(this, request);
};
await viewer.open('pano-a');
assert.equal(poses.at(-1).capturedAt, null, 'undated while it failed');
assert.equal(calls, 1);
for (let attempt = 0; attempt < 2; attempt++) {
for (let heading = 0; heading < 20; heading++) {
fake.built[0].setPov({ heading, pitch: 0 });
await settle();
}
assert.equal(calls, attempt + 1, 'drags do not retry during the pause');
assert.equal(poses.at(-1).bearing, 19, 'pose updates still follow drags');
now += DETAILS_RETRY_COOLDOWN_MS - 1;
fake.built[0].setPov({ heading: 20, pitch: 0 });
await settle();
assert.equal(calls, attempt + 1, 'still paused just before the deadline');
now++;
fake.built[0].setPov({ heading: 21, pitch: 0 });
await settle();
assert.equal(calls, attempt + 2, 'one retry at the deadline');
}
assert.equal(poses.at(-1).capturedAt, Date.UTC(2024, 4, 1));
now += DETAILS_RETRY_COOLDOWN_MS;
fake.built[0].setPov({ heading: 10, pitch: 0 });
await settle();
assert.equal(calls, 3, 'successful details stay cached');
});
test('a failed panorama does not delay details for another panorama', async (t) => {
t.mock.method(Date, 'now', () => 1_000);
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
const requests = [];
fake.library.StreetViewService.prototype.getPanorama = function (request) {
requests.push(request.pano);
if (request.pano === 'pano-a') return Promise.reject(new Error('offline'));
return getPanorama.call(this, request);
};
await viewer.open('pano-a');
fake.built[0].setPano('pano-b');
await settle();
assert.deepEqual(requests, ['pano-a', 'pano-b']);
assert.equal(poses.at(-1).creator, 'Ada');
fake.built[0].setPano('pano-a');
await settle();
assert.deepEqual(
requests,
['pano-a', 'pano-b'],
'returning respects the pause',
);
});
test('a retry after a failed open is not failed by the old panorama\u2019s status', async () => {
const { viewer, poses, host } = setup();
await viewer.mount(host);
await assert.rejects(viewer.open('gone'), /no imagery/);
// The new panorama moves first; its OK status follows.
await viewer.open('pano-a');
assert.equal(poses.at(-1).imageId, 'pano-a');
});
test('a details lookup that never answers times out, cools down and retries', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 1_000 });
const flush = async () => {
for (let i = 0; i < 10; i++) await Promise.resolve();
};
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
const requests = [];
let hang = true;
fake.library.StreetViewService.prototype.getPanorama = function (request) {
requests.push(request.pano);
return hang ? new Promise(() => {}) : getPanorama.call(this, request);
};
const opening = viewer.open('pano-a');
await flush();
await opening;
assert.deepEqual(requests, ['pano-a']);
fake.built[0].setPov({ heading: 5, pitch: 0 });
await flush();
assert.deepEqual(requests, ['pano-a'], 'one lookup in flight at a time');
t.mock.timers.tick(DETAILS_TIMEOUT_MS);
await flush();
hang = false;
fake.built[0].setPov({ heading: 6, pitch: 0 });
await flush();
assert.deepEqual(requests, ['pano-a'], 'the cooldown follows a timeout');
t.mock.timers.tick(DETAILS_RETRY_COOLDOWN_MS);
fake.built[0].setPov({ heading: 7, pitch: 0 });
await flush();
assert.deepEqual(requests, ['pano-a', 'pano-a']);
assert.equal(poses.at(-1).capturedAt, Date.UTC(2024, 4, 1));
assert.equal(poses.at(-1).bearing, 7);
});
test('a remount looks up again a panorama whose lookup hung', async () => {
const { fake, viewer, poses, host } = setup();
await viewer.mount(host);
const getPanorama = fake.library.StreetViewService.prototype.getPanorama;
const requests = [];
let answerHung;
fake.library.StreetViewService.prototype.getPanorama = function (request) {
requests.push(request.pano);
if (requests.length === 1)
return new Promise((resolve) => {
answerHung = () => resolve(getPanorama.call(this, request));
});
return getPanorama.call(this, request);
};
await viewer.open('pano-b');
viewer.unmount();
await viewer.mount(host);
await viewer.open('pano-b');
await settle();
assert.deepEqual(requests, ['pano-b', 'pano-b']);
assert.equal(poses.at(-1).creator, 'Ada');
// The hung answer arriving now is for a viewer that is gone: dropped.
const count = poses.length;
answerHung();
await settle();
assert.equal(poses.length, count);
});
+34 -1
View File
@@ -1,5 +1,11 @@
import { encodeIdSwitches } from '../../data/idSwitches.js';
export const IMAGERY_FILTERS = Object.freeze(['pano', 'since']);
export function filtersOf(provider) {
return provider?.filters ?? IMAGERY_FILTERS;
}
/**
* The contract an imagery provider registers with the Street Level layer.
*
@@ -11,10 +17,15 @@ import { encodeIdSwitches } from '../../data/idSwitches.js';
* @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: a ground click at street zoom opens its nearest image.
* @property {string} [requiresMapStack] Usable only on this map stack.
* @property {boolean} [defaultOn] Default true.
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
* @property {Array<'pano'|'since'>} [filters] Filters it honours (default both).
* @property {(context: ProviderContext) => ProviderInstance} create
*
* @typedef {object} ProviderContext Handed to `create()` once by the core.
* @property {object} services Scene services: picking, input, render, sprites, ground, terrain.
* @property {object} services Scene services: picking, input, render, sprites, ground, terrain, scenePick (ground clicks need it).
* @property {() => {pano: string, sinceMs: number|null}} getFilter Filter resolved against now; read it on every use so "since N days" keeps up.
* @property {() => boolean} isActive Layer enabled and this provider switched on.
* @property {() => 'draped'|'terrain'} getSurface 'terrain' on Google 3D at street zoom: draw on bare earth.
@@ -65,6 +76,8 @@ import { encodeIdSwitches } from '../../data/idSwitches.js';
* @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.
@@ -124,6 +137,26 @@ export function validateProviders(providers) {
throw new TypeError(
`Street Level provider ${label} needs a coverage colour`,
);
if (
provider.marker !== undefined &&
!(
provider.marker?.kind === 'view-cone' &&
typeof provider.marker.color === 'string'
)
)
throw new TypeError(
`Street Level provider ${label}: marker must be {kind: 'view-cone', color}`,
);
if (
provider.filters !== undefined &&
!(
Array.isArray(provider.filters) &&
provider.filters.every((key) => IMAGERY_FILTERS.includes(key))
)
)
throw new TypeError(
`Street Level provider ${label}: filters must list ${IMAGERY_FILTERS.join(' or ')}`,
);
ids.add(provider.id);
prefixes.push(provider.pickPrefix);
}
+18 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { requiresKeyIdFor, validateProviders } from './registry.js';
import { filtersOf, requiresKeyIdFor, validateProviders } from './registry.js';
const def = (overrides = {}) => ({
id: 'mapillary',
@@ -101,3 +101,20 @@ test('requiresKeyIdFor is the shared key, or null once providers differ', () =>
);
assert.equal(requiresKeyIdFor([def({ requiresKeyId: null })]), null);
});
test('a provider may narrow the filters it honours and pick a view-cone marker', () => {
assert.deepEqual(filtersOf(def()), ['pano', 'since'], 'both by default');
const narrowed = def({
filters: [],
marker: { kind: 'view-cone', color: '#4285f4' },
});
assert.deepEqual(filtersOf(validateProviders([narrowed])[0]), []);
assert.throws(
() => validateProviders([def({ filters: ['colour'] })]),
/filters must list pano or since/,
);
assert.throws(
() => validateProviders([def({ marker: { kind: 'pegman' } })]),
/marker must be/,
);
});
+5 -1
View File
@@ -21,7 +21,11 @@ export function createSelection({ state, parts }) {
? picking.resolvePickId(picked)
: picked?.id;
const route = parts.router.resolve(id);
if (!route?.instance) return;
// Not ours: the ground handler decides whether it is the map itself.
if (!route?.instance) {
parts.openAtGround?.(click.position, picked);
return;
}
const entry = state.providers.get(route.providerId);
if (!entry?.on) return;
route.instance.handlePick(route.id);
+26
View File
@@ -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,8 @@ function harness({ selected = false } = {}) {
parts: {
router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null },
hasSelectedSequence: () => sequences.selected,
openAtGround: (position, picked) =>
groundClicks.push({ position, picked }),
clearSequences() {
sequences.cleared++;
sequences.selected = false;
@@ -76,6 +79,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 +103,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 +264,21 @@ test('Esc in a text field or rich-text editor keeps the selection (M61)', () =>
h.restore();
}
});
test('a click on nothing of ours goes to the ground handler with the pick, which decides', () => {
const h = harness();
try {
h.click(5);
h.scene.under = 'flight:abc';
h.click(6);
assert.deepEqual(
h.groundClicks.map(({ position, picked }) => [position.x, picked?.id]),
[
[5, undefined],
[6, 'flight:abc'],
],
);
} finally {
h.restore();
}
});
+10 -1
View File
@@ -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,
@@ -50,10 +56,13 @@ export function createState({ services }) {
...freshStreet(),
},
/** Whether the camera is low enough for a ground click to open an image. */
groundClickReady: false,
/** 'terrain' on Google 3D at street zoom (overlays on the bare earth), else 'draped'. */
surface: 'draped',
marker: { collection: null, billboard: null },
marker: { collection: null, billboard: null, look: null },
clickHandler: null,
};
}
+41 -8
View File
@@ -16,6 +16,8 @@ import { msg, t } from '../../i18n/index.js';
* @property {string} hint
* @property {string|null} error
* @property {string} color The source's one colour.
* @property {boolean} [groundClick]
* @property {Array<'pano'|'since'>} [filters]
*/
/**
@@ -31,7 +33,7 @@ export function summarizeCoverage(providers) {
hint:
providers.length === 0
? msg('No street-level imagery providers configured')
: active.find((p) => p.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),
@@ -42,13 +44,39 @@ export function summarizeCoverage(providers) {
};
}
/**
* Switched-off providers that would draw with a key already set, offered
* while the switched-on ones lack theirs.
* @param {Array<ProviderSnapshot>} providers
* @returns {Array<{id: string, label: string, requiresKeyId: string|null}>}
*/
export function keyAlternatives(providers) {
if (!summarizeCoverage(providers).keyRequired) return [];
return providers
.filter((p) => !p.on && p.configured === true && p.keyRequired !== true)
.map(({ id, label, requiresKeyId }) => ({ id, label, requiresKeyId }));
}
/**
* The filter controls a switched-on provider honours (both when none is on).
* @param {Array<ProviderSnapshot>} providers
*/
export function filterControls(providers) {
const active = providers.filter((p) => p.on);
const honoured = (key) =>
active.length === 0 ||
active.some((p) => (p.filters ?? ['pano', 'since']).includes(key));
return { pano: honoured('pano'), since: honoured('since') };
}
/**
* The panel snapshot from core state and provider snapshots. The legend has
* one swatch per active source, then the shared selection colour.
* one swatch per active source that draws coverage lines, then Selected.
* @param {{enabled: boolean, filter: object, providers: Array<ProviderSnapshot>, street: object, sequence: object}} input
*/
export function composeUIState({
enabled,
groundClickReady = false,
filter,
providers,
street,
@@ -58,12 +86,14 @@ export function composeUIState({
const active = providers.filter((p) => p.on);
const { keyRequired, keyRejected, ...coverage } =
summarizeCoverage(providers);
const legend = active.map((provider) => ({
key: provider.id,
label: provider.name,
color: provider.color,
}));
if (active.length)
const legend = active
.filter((provider) => provider.groundClick !== true)
.map((provider) => ({
key: provider.id,
label: provider.name,
color: provider.color,
}));
if (legend.length)
legend.push({
key: 'selected',
label: t('Selected'),
@@ -71,9 +101,12 @@ export function composeUIState({
});
return {
enabled,
groundClickReady: groundClickReady === true,
keyRequired,
keyAlternatives: keyAlternatives(providers),
keyRejected,
filter: { ...filter },
filterControls: filterControls(providers),
providers: providers.map((p) => ({ ...p })),
coverage,
legend,
+86
View File
@@ -85,6 +85,49 @@ test('the legend is one swatch per active source, in its colour, then Selected',
);
});
test('Street View draws no lines: no legend swatch, and no Selected on its own', () => {
const streetView = provider({
id: 'google',
name: 'Google Street View',
color: '#4285f4',
count: 0,
groundClick: true,
filters: [],
});
assert.deepEqual(
composeUIState({ ...base, providers: [streetView] }).legend,
[],
);
assert.deepEqual(
composeUIState({
...base,
providers: [provider(), streetView],
}).legend.map((entry) => entry.key),
['mapillary', 'selected'],
);
});
test('the filter controls show only for a lit provider that honours them', () => {
const streetView = provider({ id: 'google', groundClick: true, filters: [] });
const controls = (providers) =>
composeUIState({ ...base, providers }).filterControls;
assert.deepEqual(controls([streetView]), { pano: false, since: false });
assert.deepEqual(controls([provider(), streetView]), {
pano: true,
since: true,
});
assert.deepEqual(
controls([provider({ on: false }), streetView]),
{ pano: false, since: false },
'Mapillary switched off',
);
assert.deepEqual(
controls([provider({ on: false }), { ...streetView, on: false }]),
{ pano: true, since: true },
'nothing lit: both, ready for the first chip',
);
});
test('hint and error come from the first active provider that has one', () => {
const ui = composeUIState({
...base,
@@ -149,3 +192,46 @@ 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',
);
});
test('a key-gated layer offers the switched-off sources that would draw with a key already set', async () => {
const { keyAlternatives } = await import('./uiState.js');
const mapillary = providerSnapshot({ keyRequired: true, configured: false });
const streetView = providerSnapshot({
id: 'google',
label: 'STREET VIEW',
on: false,
configured: true,
requiresKeyId: 'google-maps',
});
assert.deepEqual(keyAlternatives([mapillary, streetView]), [
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
]);
// Nothing to offer: Mapillary has its key, or Street View has none either.
assert.deepEqual(keyAlternatives([providerSnapshot(), streetView]), []);
assert.deepEqual(
keyAlternatives([
mapillary,
{ ...streetView, configured: false, keyRequired: true },
]),
[],
);
});
+22 -4
View File
@@ -33,11 +33,18 @@ 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,
});
parts.marker.set(state.street.position, state.street.bearing);
parts.marker.set(
state.street.position,
state.street.bearing,
providerEntry(pose.providerId)?.def.marker,
);
parts.follow.followCamera();
// Select the sequence only once the image is on screen, so its lookup
// never competes with the image download.
@@ -68,6 +75,12 @@ export function createViewerHost({ state, parts }) {
active.adapter.unmount();
active = null;
}
// A prewarmed viewer would sit under this one, holding its WebGL context.
for (const [id, warm] of warmed) {
if (id === entry.def.id) continue;
warmed.delete(id);
warm.unmount();
}
const adapter = entry.instance.viewer;
const promise = (async () => {
await adapter.mount(state.street.host);
@@ -128,6 +141,8 @@ export function createViewerHost({ state, parts }) {
try {
const adapter = await mount(entry);
if (!current()) return false;
// Per open: close() resets it, and a reopen skips mount().
state.street.renderModes = typeof adapter.setRenderMode === 'function';
await adapter.open(String(imageId));
if (!current()) return false;
if (ticket && !state.street.follow) parts.follow.lookAtPosition(ticket);
@@ -183,14 +198,17 @@ 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 {
/* the real open reports errors */
}
if (active?.id === entry.def.id) continue;
// Switched off while it loaded: release it now rather than keep it.
if (!state.enabled || !entry.on) adapter.unmount();
if (active?.id === entry.def.id || mounting?.id === entry.def.id)
continue;
// Switched off, or another viewer took the host meanwhile.
if (!state.enabled || !entry.on || active || mounting) adapter.unmount();
else warmed.set(entry.def.id, adapter);
}
}
@@ -576,3 +576,86 @@ 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],
);
});
test('a prewarm that lands after another provider took the host is released, not built under it', async () => {
const { state, host, mapillary } = twoProviders();
let release;
mapillary.prewarm = () =>
new Promise((resolve) => {
release = resolve;
});
const warming = host.prewarm([state.providers.get('mapillary')]);
await host.open('google', 'g-1'); // the user opens Street View meanwhile
release();
await warming;
assert.equal(mapillary.calls.unmount, 1, 'released at once');
// Not kept warm: the next Mapillary open mounts afresh.
await host.open('mapillary', 'm-1');
assert.equal(state.street.providerId, 'mapillary');
});
test('FIT/FILL stays hidden for a second Street View panorama after a close', async () => {
const { state, host } = twoProviders();
await host.open('google', 'g-1');
assert.equal(state.street.renderModes, false);
host.close();
await host.open('google', 'g-2');
assert.equal(state.street.renderModes, false);
});
+3 -2
View File
@@ -1,6 +1,6 @@
/**
* Parse three-line TLE catalog text into `{ name, line1, line2 }` entries.
* Blocks whose second and third lines are not TLE lines 1 and 2 are skipped.
* Malformed sets are skipped without dropping the valid sets that follow.
*/
export function parseTleText(text) {
const lines = String(text)
@@ -9,12 +9,13 @@ export function parseTleText(text) {
.map((line) => line.trim())
.filter((line) => line.length > 0);
const result = [];
for (let i = 0; i < lines.length - 2; i += 3) {
for (let i = 0; i < lines.length - 2; i++) {
const name = lines[i];
const line1 = lines[i + 1];
const line2 = lines[i + 2];
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
result.push({ name, line1, line2 });
i += 2;
}
}
return result;
+11
View File
@@ -256,3 +256,14 @@ test('the Cyber theme frames the Street Level panel like its rail peers (P3)', (
// A theme header rule must be able to outrank the panel's own.
assert.doesNotMatch(panelCss, /#street-level-panel \.panel-header/);
});
test('OPEN STREET VIEW keeps its width beside a long coverage line', () => {
// .scene-btn grows and shrinks (flex: 1) and loads after this stylesheet:
// only a more specific rule keeps the button from being squeezed.
assert.match(css, /\.scene-btn \{[^}]*flex: 1;/);
assert.match(
panelCss,
/\.sl-meta-row \.sl-nearest-btn \{\s*flex: 0 0 auto;\s*\}/,
);
assert.match(panelCss, /\.sl-meta-row \.sl-meta \{[^}]*min-width: 0;/);
});
+194
View File
@@ -0,0 +1,194 @@
/**
* 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, description}}; `nearest(location, radius)` answers location
* lookups with an id or null; `substitutes` maps an id Google answers with a
* newer one. Like Google, `setPano` sets the id (pano_changed) at once, and
* the position and status follow when the data arrives; status_changed fires
* only when the status changes.
*/
function latLng(lat, lng) {
return { lat: () => lat, lng: () => lng };
}
export function fakeStreetViewLibrary({
panoramas = {},
nearest = () => null,
substitutes = {},
} = {}) {
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.position = 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 id is set at once; the data loads asynchronously. */
setPano(id) {
this.pano = id;
this.fire('pano_changed');
queueMicrotask(() => {
if (this.pano !== id) return;
const shownId = substitutes[id] ?? id;
const known = panoramas[shownId];
const status = known ? 'OK' : 'ZERO_RESULTS';
if (shownId !== id) {
this.pano = shownId;
this.fire('pano_changed');
}
if (known) {
this.position = latLng(known.lat, known.lng);
this.fire('position_changed');
}
if (status !== this.status) {
this.status = status;
this.fire('status_changed');
}
});
}
getPano() {
return this.pano;
}
getStatus() {
return this.status;
}
getPosition() {
return this.position;
}
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. With
* `deferred`, imports wait for `release()`.
*/
export function fakeMapsLoader(library, { deferred = false } = {}) {
let failed = false;
const listeners = new Set();
const imports = [];
let release = null;
const gate = deferred
? new Promise((resolve) => {
release = resolve;
})
: null;
return {
imports,
async importLibrary(name) {
imports.push(name);
await gate;
return library;
},
release: () => release?.(),
authFailed: () => failed,
onAuthFailure(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
refuseKey() {
failed = true;
for (const listener of [...listeners]) listener();
},
listenerCount: () => listeners.size,
};
}
/** A host element with just what the viewer adapter touches. */
export function fakeHost() {
const children = [];
const host = {
children,
ownerDocument: {
createElement: () => {
const element = {
style: {},
className: '',
remove() {
const index = children.indexOf(element);
if (index !== -1) children.splice(index, 1);
},
};
return element;
},
},
append: (element) => children.push(element),
};
return host;
}
+7 -2
View File
@@ -515,15 +515,20 @@ test('evidence never carries a Mapillary token', () => {
test('--strict accepts only the documented skips', () => {
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' },
],
);
});
+4 -2
View File
@@ -183,7 +183,7 @@ test('only embed-mode documents may be framed, and only by the allowed ancestors
}
});
test('the CSP admits the event media embeds, and no other script origin', async () => {
test('the CSP admits the event media embeds and the Maps JavaScript API, and no other script origin', async () => {
const { embeddedMediaFrameUrl, resolveEmbeddedMediaSource } =
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('*')));
+5
View File
@@ -37,6 +37,11 @@ const PROVIDER_ORIGINS = Object.freeze([
'https://fonts.googleapis.com',
'https://fonts.gstatic.com',
'https://graph.mapillary.com',
// Google Street View's tiles, controls and contributed photos (the Maps
// JavaScript API itself comes from maps.googleapis.com above).
'https://streetviewpixels-pa.googleapis.com',
'https://maps.gstatic.com',
'https://*.ggpht.com',
// Mapillary photos come from regional hosts, e.g. scontent-man2-1.xx.fbcdn.net.
'https://*.xx.fbcdn.net',
]);
+17
View File
@@ -169,3 +169,20 @@ test('the package exports the panel resource and its runtime together', async ()
assert.equal(panel.PANEL_BASE, '/panel/');
assert.match(resource.text, /panelRuntime|function/);
});
test('the panel lets Street Level reach Google Street View from inside the embed', () => {
const { csp } = createGlobePanelResource({
runtime: panelRuntime,
panelKey: 'test-key',
})._meta.ui;
// The API script and lookups, panorama tiles, control images, contributed photos.
for (const url of [
'https://maps.googleapis.com/maps/api/js?key=k&v=weekly',
'https://streetviewpixels-pa.googleapis.com/v1/tile?panoid=p&x=0&y=0&zoom=1',
'https://maps.gstatic.com/mapfiles/api-3/images/cb_scout5_hdpi.png',
'https://lh3.ggpht.com/p/photo.jpg',
]) {
assert.ok(allows(csp.connectDomains, url), `connect-src allows ${url}`);
assert.ok(allows(csp.resourceDomains, url), `img-src allows ${url}`);
}
});
+21
View File
@@ -30,6 +30,27 @@ test('TLE text parses into named entries with catalog numbers', () => {
assert.equal(tleCatalogNumber('1 xx'), null);
});
for (const missingLine of [1, 2]) {
test(`TLE parsing resumes after a set missing line ${missingLine}`, () => {
const sets = Array.from({ length: 8 }, (_, i) => {
const lines = TLE.trim().split('\n').slice(0, 3);
lines[0] = `SAT ${i}`;
return lines;
});
sets[1].splice(missingLine, 1);
const entries = parseTleText(sets.flat().join('\r\n'));
assert.deepEqual(
entries.map((entry) => entry.name),
['SAT 0', 'SAT 2', 'SAT 3', 'SAT 4', 'SAT 5', 'SAT 6', 'SAT 7'],
);
assert.ok(
entries.every(
(entry) => entry.line1.startsWith('1 ') && entry.line2.startsWith('2 '),
),
);
});
}
test('the next pass defaults to the ISS and reports times, peak and direction', async () => {
const catalog = composeCatalog({
tools: coreTools,
+49
View File
@@ -0,0 +1,49 @@
/**
* Key guidance in the UI language. keySetupCore.mjs builds the same sentences
* in English for the server and the AI tools; these are the ones people read.
*/
import {
KEY_SETUP_KEYS,
keySetupRequirementEnvVars,
} from '../keySetupCore.mjs';
import { t, translateKnown } from '../i18n/index.js';
/** "Needs X — add it in Provider Settings", or '' for an unknown or hidden key. */
export function keyRequirementText(id) {
const envVars = id ? keySetupRequirementEnvVars(id) : null;
return envVars
? t('Needs {envVars} — add it in Provider Settings', { envVars })
: '';
}
/** The key's product name in title case: GOOGLE MAPS → Google Maps. */
function keyName(id) {
const entry = KEY_SETUP_KEYS.find((candidate) => candidate.id === id);
return entry
? entry.title
.toLowerCase()
.replace(/\b\w/g, (letter) => letter.toUpperCase())
: null;
}
/**
* The requirement, then the switched-off sources that would draw with a key
* already set (see keySetupWithAlternatives).
*
* @param {string} requirement
* @param {Array<{label: string, requiresKeyId: string|null}>} [alternatives]
*/
export function keyGuidanceText(requirement, alternatives = []) {
const options = alternatives.map(({ label, requiresKeyId }) => {
const name = keyName(requiresKeyId);
const source = translateKnown(label);
return name
? t('light {source} to use your {name} key', { source, name })
: t('light {source}', { source });
});
if (!options.length) return requirement;
const offer = options.join(t(', or '));
if (requirement)
return t('{requirement}, or {offer}', { requirement, offer });
return offer.charAt(0).toLocaleUpperCase() + offer.slice(1);
}
+16
View File
@@ -141,3 +141,19 @@ test('the manager publishes the declared key id on the row', async () => {
);
assert.equal(layerKeyRequirementTooltip(ungated), '');
});
test('a key-gated layer with another source ready says how to use it', () => {
const text = layerKeyRequirementTooltip({
requiresKeyId: 'mapillary',
stats: {
keyRequired: true,
keyAlternatives: [
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
],
},
});
assert.equal(
text,
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key',
);
});
+18 -10
View File
@@ -3,7 +3,7 @@ import { syncRowList } from './rowList.js';
import { layerFeedState } from '../data/feedState.js';
export { layerFeedState } from '../data/feedState.js';
import { GUIDANCE_STATUSES } from '../loadingFeedback.js';
import { keySetupRequirementEnvVars } from '../keySetupCore.mjs';
import { keyGuidanceText, keyRequirementText } from './keyGuidance.js';
import { createWeatherPanel } from './weatherPanel.js';
import { msg, t, translateKnown } from '../i18n/index.js';
@@ -205,15 +205,23 @@ function sourceLabel(source) {
export function layerKeyRequirementTooltip(layer = {}) {
if (layer?.stats?.keyRequired !== true) return '';
const requiresKeyId = String(layer.requiresKeyId || '').trim();
const envVars = requiresKeyId
? keySetupRequirementEnvVars(requiresKeyId)
: null;
return envVars
? t('Needs {envVars} — add it in Provider Settings', { envVars })
: '';
// A layer with several sources also names the ones it could use instead.
return keyGuidanceText(
requiresKeyId ? keyRequirementText(requiresKeyId) : '',
layer.stats.keyAlternatives || [],
);
}
const ROW_TEXT_SEPARATOR = ' · ';
/** A loading label is English data, at times several joined by " · ". */
function translateLoadingLabel(label) {
return label
.trim()
.split(ROW_TEXT_SEPARATOR)
.map(translateKnown)
.join(ROW_TEXT_SEPARATOR);
}
// A short Latin code joined by hyphens. Longer runs (a URL, a token in an
// error) keep their break opportunities so they cannot overflow the rail.
const HYPHENATED_CODE = /[A-Za-z0-9]+(?:-[A-Za-z0-9]+)+/g;
@@ -754,14 +762,14 @@ export class LayerPanel {
if (stats.loading) {
const loadingLabel =
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? translateKnown(stats.loadingLabel.trim())
? translateLoadingLabel(stats.loadingLabel)
: t('loading...');
return t('{source} · {loadingLabel}', { source, loadingLabel });
}
if (feedState === 'fallback') {
const detail =
typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()
? translateKnown(stats.loadingLabel.trim())
? translateLoadingLabel(stats.loadingLabel)
: stats.coverage || ago;
return t('{state} · {source} · {detail}', {
state: stateLabel,
@@ -806,7 +814,7 @@ export class LayerPanel {
if (typeof stats.loadingLabel === 'string' && stats.loadingLabel.trim()) {
return t('{source} · {loadingLabel}', {
source,
loadingLabel: translateKnown(stats.loadingLabel.trim()),
loadingLabel: translateLoadingLabel(stats.loadingLabel),
});
}
return t('{source} · {ago}', { source, ago });
+46 -13
View File
@@ -67,8 +67,11 @@ export class StreetLevelControls {
status: byId('sl-status'),
controls: byId('sl-controls'),
providerChips: byId('sl-provider-chips'),
nearestBtn: byId('sl-nearest-btn'),
error: byId('sl-error'),
errorText: byId('sl-error-text'),
panoRow: byId('sl-pano-row'),
sinceRow: byId('sl-since-row'),
sinceRange: byId('sl-since'),
sinceLabel: byId('sl-since-label'),
legend: byId('sl-legend'),
@@ -104,9 +107,11 @@ export class StreetLevelControls {
this.listen(el.providerChips, 'click', (event) => {
const button = event.target?.closest?.('.data-toggle-chip');
if (!button || button.disabled) return;
// Without a key the chips stay focusable for their tooltip; a click
// says the same thing rather than switching on a layer that cannot draw.
if (this._view?.controlsDisabled) {
// A keyless chip shows its tooltip instead of switching; others switch.
const chip = this._view?.providers.find(
(entry) => entry.id === button.dataset.chipId,
);
if (chip?.state === 'error') {
if (button.title) this.actions.showToast?.(button.title);
return;
}
@@ -130,6 +135,15 @@ export class StreetLevelControls {
this.listen(el.sinceRange, 'change', () =>
this._setParams({ sinceDays: sinceDays() }),
);
this.listen(el.nearestBtn, 'click', () => {
const nearest = this._view?.nearest;
if (!nearest?.visible || nearest.disabled) return;
this.layer.openNearest?.(undefined, {
providerIds: nearest.providerIds,
frame: false,
aim: 'screen-centre',
});
});
this.listen(el.followBtn, 'click', () => {
this.layer.setFollow?.(!(this._state?.street?.follow === true));
});
@@ -241,24 +255,23 @@ export class StreetLevelControls {
}
/**
* Lighting a chip turns the layer on; darkening the last lit one turns the
* layer off but keeps the provider on, so the layer comes back with it.
* Lighting a chip turns the layer on and darkens the others (one source at
* a time); darkening the lit one turns the layer off, keeping the provider.
*/
async _toggleProvider(providerId) {
const chip = this._view?.providers.find((entry) => entry.id === providerId);
if (!chip) return;
if (!chip.active) {
this._setParams({ [providerId]: true });
// Every registered provider in one update (those hidden on this map
// too), so the share link records the swap.
const ids =
this.layer.providerIds || this._view.providers.map((entry) => entry.id);
this._setParams(
Object.fromEntries(ids.map((id) => [id, id === providerId])),
);
await this._ensureEnabled();
return;
}
const othersLit = this._view.providers.some(
(entry) => entry.id !== providerId && entry.active,
);
if (othersLit) {
this._setParams({ [providerId]: false });
return;
}
try {
await this.actions.setEnabled?.(false);
} catch (error) {
@@ -471,6 +484,7 @@ export class StreetLevelControls {
this._renderFilters(view);
this._renderViewer(view);
this._renderMeta(view);
this._renderNearest(view);
this._reactToTransitions(view, state);
}
@@ -515,6 +529,11 @@ export class StreetLevelControls {
_renderFilters(view) {
const el = this._elements;
const shows = view.filterControls || { pano: true, since: true };
setProp(el.panoRow, 'hidden', !shows.pano);
setProp(el.sinceRow, 'hidden', !shows.since);
setProp(el.controls, 'hidden', !shows.pano && !shows.since);
setProp(el.legend, 'hidden', view.legend.length === 0);
this._renderRadios(
el.panoButtons,
(button) => button.dataset.slPano === view.filter.pano,
@@ -582,6 +601,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);
@@ -597,6 +619,17 @@ export class StreetLevelControls {
setProp(this._elements.coverageMeta, 'textContent', view.meta);
}
_renderNearest(view) {
const button = this._elements.nearestBtn;
if (!button) return;
const { nearest } = view;
setProp(button, 'hidden', !nearest.visible);
if (!nearest.visible) return;
setProp(button, 'textContent', nearest.label);
setProp(button, 'title', nearest.title);
setProp(button, 'disabled', nearest.disabled);
}
/**
* Open the panel on a switch-on or when an image opens. Never persisted:
* the stored collapse state stays the user's own choice.
+127 -4
View File
@@ -307,12 +307,16 @@ function panelDom() {
add(settings, 'div', { id });
// A missing key gates the filters only; the chips stay outside the gate.
const controls = add(settings, 'fieldset', { id: 'sl-controls' });
const panoRow = add(controls, 'div', { id: 'sl-pano-row' });
for (const pano of ['all', 'pano', 'flat'])
add(controls, 'button', { dataset: { slPano: pano } });
add(controls, 'input', { id: 'sl-since' });
add(controls, 'output', { id: 'sl-since-label' });
add(panoRow, 'button', { dataset: { slPano: pano } });
const sinceRow = add(controls, 'div', { id: 'sl-since-row' });
add(sinceRow, 'input', { id: 'sl-since' });
add(sinceRow, 'output', { id: 'sl-since-label' });
add(settings, 'ul', { id: 'sl-legend' });
add(settings, 'div', { id: 'sl-coverage-meta' });
const metaRow = add(settings, 'div', { classes: ['sl-meta-row'] });
add(metaRow, 'div', { id: 'sl-coverage-meta' });
add(metaRow, 'button', { id: 'sl-nearest-btn' });
// The globe's canvas is focusable too (tabindex=0).
const globe = add(
add(document.body, 'div', { id: 'cesiumContainer' }),
@@ -1136,3 +1140,122 @@ test('a <body> child added while the viewer is expanded goes inert with the rest
assert.equal(observer.watching(), false);
}),
));
test('OPEN STREET VIEW opens the nearest panorama at the view centre without moving the camera', () =>
withDom(async (dom) => {
const state = uiState();
state.providers.push({
...state.providers[0],
id: 'google',
name: 'Google Street View',
label: 'STREET VIEW',
groundClick: true,
count: 0,
});
state.groundClickReady = true;
const { layer } = stubPanel(dom, state);
const asked = [];
layer.openNearest = (point, options) => asked.push([point, options]);
const button = dom.root.querySelector('#sl-nearest-btn');
assert.equal(button.hidden, false);
assert.equal(button.textContent, 'OPEN STREET VIEW');
button.click();
assert.deepEqual(asked, [
[
undefined,
{ providerIds: ['google'], frame: false, aim: 'screen-centre' },
],
]);
// Too high to mean a street: disabled, and a click does nothing.
layer.publish({ ...state, groundClickReady: false });
assert.equal(button.disabled, true);
button.click();
assert.equal(asked.length, 1);
// Off Google 3D the provider is not listed: no button.
layer.publish({ ...state, providers: [state.providers[0]] });
assert.equal(button.hidden, true);
}));
test('a refused Street View key cannot lock Mapillary out; its chip only explains itself', () =>
withDom(async (dom) => {
const state = uiState({ on: false, keyRequired: true });
state.providers.push({
...state.providers[0],
id: 'google',
name: 'Google Street View',
label: 'STREET VIEW',
on: true,
keyRequired: true,
keyRejected: true,
error: 'Google refused GOOGLE_MAPS_API_KEY for Street View',
});
state.providers[0].keyRequired = false;
const { calls, layer } = stubPanel(dom, state);
const chips = () => dom.root.querySelector('#sl-provider-chips').children;
chips()[0].click(); // Mapillary: configured, dark
await settle();
// One source at a time: lighting Mapillary darkens Street View too.
assert.deepEqual(calls.setParams, [
[{ mapillary: true, google: false }, { origin: 'user' }],
]);
// Now dark, the refused chip says why instead of lighting.
state.providers[0].on = true;
state.providers[1].on = false;
state.keyRequired = false;
layer.publish({ ...state });
chips()[1].click();
await settle();
assert.equal(calls.setParams.length, 1);
assert.deepEqual(calls.toasts, [
dom.root.querySelector('#sl-provider-chips').children[1].title,
]);
}));
test('a swap names every registered provider, also those hidden on this map', () =>
withDom(async (dom) => {
const state = uiState({ on: false });
const { calls, layer, chip } = stubPanel(dom, state);
// Off Google 3D the Street View chip is not listed, but stays registered.
layer.providerIds = ['mapillary', 'google'];
chip().click();
await settle();
assert.deepEqual(calls.setParams, [
[{ mapillary: true, google: false }, { origin: 'user' }],
]);
}));
test('Street View lit hides the imagery filters and the legend; Mapillary lit shows them', () =>
withDom(async (dom) => {
const state = uiState();
const { layer } = stubPanel(dom, state);
const shown = () => ({
pano: !dom.root.querySelector('#sl-pano-row').hidden,
since: !dom.root.querySelector('#sl-since-row').hidden,
controls: !dom.root.querySelector('#sl-controls').hidden,
legend: !dom.root.querySelector('#sl-legend').hidden,
});
assert.deepEqual(shown(), {
pano: true,
since: true,
controls: true,
legend: true,
});
layer.publish({
...state,
filterControls: { pano: false, since: false },
legend: [],
});
assert.deepEqual(shown(), {
pano: false,
since: false,
controls: false,
legend: false,
});
layer.publish(state);
assert.deepEqual(shown(), {
pano: true,
since: true,
controls: true,
legend: true,
});
}));
+99 -29
View File
@@ -1,5 +1,5 @@
import { keySetupRequirementEnvVars } from '../keySetupCore.mjs';
import { formatNumber, msg, t, tn } from '../i18n/index.js';
import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
import { keyGuidanceText, keyRequirementText } from './keyGuidance.js';
// Street Level UI state to panel strings and flags. Pure, so it is testable.
@@ -51,21 +51,29 @@ 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 '';
}
}
/** The Provider Settings hint for a missing key, or '' for a hidden key. */
function keyRequirement(id) {
const envVars = keySetupRequirementEnvVars(id);
return envVars
? t('Needs {envVars} — add it in Provider Settings', { envVars })
: '';
/**
* What the key-gated layer needs: each switched-on provider's key, and the
* switched-off ones that would draw with a key already set.
*/
function keyGuidance(state) {
const needs = (state.providers || [])
.filter((p) => p.on && p.keyRequired && p.requiresKeyId)
.map((p) => keyRequirementText(p.requiresKeyId))
.filter(Boolean);
return keyGuidanceText(
[...new Set(needs)].join('; '),
state.keyAlternatives || [],
);
}
function presentStatus(state) {
@@ -76,7 +84,12 @@ function presentStatus(state) {
if (state.keyRejected)
return { text: t('KEY REJECTED'), tone: 'warn', pressed, title };
if (state.keyRequired)
return { text: t('KEY REQUIRED'), tone: 'warn', pressed, title };
return {
text: t('KEY REQUIRED'),
tone: 'warn',
pressed,
title: keyGuidance(state) || title,
};
if (state.coverage.loading)
return { text: t('LOADING'), tone: 'busy', pressed, title };
return pressed
@@ -94,13 +107,14 @@ function presentProviders(state) {
? t('{name} imagery on', { name: provider.name })
: t('{name} imagery off', { name: provider.name });
if (provider.keyRejected && provider.error)
title = `${provider.name}: ${provider.error}`;
title = `${provider.name}: ${translateKnown(provider.error)}`;
else if (keyRequired && provider.requiresKeyId)
title = `${provider.name}: ${keyRequirement(provider.requiresKeyId)}`;
else if (provider.error) title = `${provider.name}: ${provider.error}`;
title = `${provider.name}: ${keyRequirementText(provider.requiresKeyId)}`;
else if (provider.error)
title = `${provider.name}: ${translateKnown(provider.error)}`;
return {
id: provider.id,
label: provider.label,
label: translateKnown(provider.label),
color: provider.color || null,
title,
active: on,
@@ -123,17 +137,23 @@ 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(t('Image by {creator}', { creator: street.creator }));
return {
open: street.open === true,
loading: street.loading === true && !street.imageId,
renderMode: street.renderMode === 'fill' ? 'fill' : 'letterbox',
captionLeft: street.creator
? t('Image by {creator}', { creator: street.creator })
: '',
renderModes: street.renderModes !== false,
captionLeft: left.join(' · '),
captionRight: right.join(' · '),
link: street.externalUrl || null,
linkLabel: street.providerLabel ? `${street.providerLabel} ↗` : '',
linkLabel: street.providerLabel
? `${translateKnown(street.providerLabel)} ↗`
: '',
follow: {
pressed: street.follow === true,
disabled: street.open !== true || street.followAvailable !== true,
@@ -149,10 +169,49 @@ function presentViewer(state) {
};
}
/** OPEN STREET VIEW: the keyboard path to providers with no coverage to click. */
function presentNearest(state) {
const targets = (state.providers || []).filter(
(provider) =>
provider.on && provider.groundClick && provider.keyRequired !== true,
);
if (state.enabled !== true || !targets.length)
return {
visible: false,
label: '',
title: '',
disabled: true,
providerIds: [],
};
const names = targets
.map((provider) => translateKnown(provider.name))
.join(t(' or '));
const labels = targets
.map((provider) => translateKnown(provider.label))
.join(' / ');
const ready = state.groundClickReady === true;
return {
visible: true,
label: t('OPEN {labels}', { labels }),
title: ready
? t('Open {names} at the centre of the view', { names })
: t('Zoom in to a street to open {names} here', { names }),
disabled: !ready || state.street.loading === true,
providerIds: targets.map((provider) => provider.id),
};
}
/** Provider hints are English data joined by " · "; translate each part. */
function translateHint(hint) {
return hint ? hint.split(' · ').map(translateKnown).join(' · ') : '';
}
function presentMeta(state) {
if (state.providers.length === 0)
return state.coverage.hint ? t(state.coverage.hint) : '';
if (state.providers.length === 0) return translateHint(state.coverage.hint);
if (!state.enabled) return t('Switch a provider on to draw its coverage.');
// Key-gated with another source ready: say how to use it.
if (state.keyRequired && state.keyAlternatives?.length)
return keyGuidance(state);
if (state.sequence.selectedId)
return state.sequence.loading
? t('Loading this sequence…')
@@ -162,14 +221,20 @@ function presentMeta(state) {
'{n} images in this sequence · Esc clears',
{ n: formatNumber(state.sequence.images) },
);
const hint = translateHint(state.coverage.hint);
if (state.coverage.count > 0)
return tn(
state.coverage.count,
'{n} sequence in view · click a line for its photos',
'{n} sequences in view · click a line for its photos',
{ n: formatNumber(state.coverage.count) },
);
return state.coverage.hint ? t(state.coverage.hint) : '';
return [
tn(
state.coverage.count,
'{n} sequence in view · click a line for its photos',
'{n} sequences in view · click a line for its photos',
{ n: formatNumber(state.coverage.count) },
),
hint,
]
.filter(Boolean)
.join(' · ');
return hint;
}
/** @param {{now?: number}} [options] Clock for the SINCE readout. */
@@ -190,10 +255,15 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
? null
: state.street.error || state.coverage.error || null,
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
filterControls: {
pano: state.filterControls?.pano !== false,
since: state.filterControls?.since !== false,
},
since: presentSince(filter.sinceDays, now),
legend: state.legend || [],
viewer: presentViewer(state),
meta: presentMeta(state),
nearest: presentNearest(state),
wantsOpen: state.street.open === true,
};
}
+134
View File
@@ -405,3 +405,137 @@ 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`,
);
});
test('OPEN STREET VIEW shows while Street View is on, and waits for street zoom', () => {
const streetView = provider({
id: 'google',
name: 'Google Street View',
label: 'STREET VIEW',
groundClick: true,
requiresKeyId: 'google-maps',
});
const view = (overrides) =>
presentStreetLevelPanel(
snapshot({
enabled: true,
providers: [provider(), streetView],
...overrides,
}),
).nearest;
const ready = view({ groundClickReady: true });
assert.deepEqual(ready, {
visible: true,
label: 'OPEN STREET VIEW',
title: 'Open Google Street View at the centre of the view',
disabled: false,
providerIds: ['google'],
});
const high = view({ groundClickReady: false });
assert.equal(high.disabled, true);
assert.equal(
high.title,
'Zoom in to a street to open Google Street View here',
);
// Hidden with Street View off, without its key, or with the layer off
// (off Google 3D the provider is not listed at all).
assert.equal(
view({ providers: [provider(), { ...streetView, on: false }] }).visible,
false,
);
assert.equal(
view({ providers: [provider(), { ...streetView, keyRequired: true }] })
.visible,
false,
);
assert.equal(view({ providers: [provider()] }).visible, false);
assert.equal(
presentStreetLevelPanel(snapshot({ providers: [provider(), streetView] }))
.nearest.visible,
false,
);
});
test('KEY REQUIRED points at Street View when it would draw with the Google key', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
keyRequired: true,
providers: [
provider({ keyRequired: true, configured: false }),
provider({
id: 'google',
name: 'Google Street View',
label: 'STREET VIEW',
on: false,
configured: true,
requiresKeyId: 'google-maps',
}),
],
keyAlternatives: [
{ id: 'google', label: 'STREET VIEW', requiresKeyId: 'google-maps' },
],
}),
);
const guidance =
'Needs MAPILLARY_CLIENT_TOKEN — add it in Provider Settings, or light STREET VIEW to use your Google Maps key';
assert.equal(view.status.text, 'KEY REQUIRED');
assert.equal(view.status.title, guidance);
assert.equal(view.meta, guidance);
// Off Google 3D Street View is not listed: plain KEY REQUIRED.
const plain = presentStreetLevelPanel(
snapshot({
enabled: true,
keyRequired: true,
providers: [provider({ keyRequired: true, configured: false })],
keyAlternatives: [],
}),
);
assert.equal(plain.status.text, 'KEY REQUIRED');
assert.notEqual(plain.meta, guidance);
});
+33 -1
View File
@@ -183,6 +183,28 @@ button.sl-status:focus-visible {
border-color: rgba(255, 179, 0, 0.55);
}
/* The coverage hint and OPEN STREET VIEW share a line; the hint wraps. */
.sl-meta-row {
display: flex;
align-items: center;
gap: 8px;
}
.sl-meta-row .sl-meta {
flex: 1 1 auto;
min-width: 0;
}
/* Scoped to outrank .scene-btn's flex: 1, which squeezed the button to its
min-width beside a long coverage line and clipped its one-line label. */
.sl-meta-row .sl-nearest-btn {
flex: 0 0 auto;
}
.sl-nearest-btn[hidden] {
display: none;
}
/* ── Errors ──────────────────────────────────────────────────────────────── */
.sl-error {
font-family: var(--font-sans);
@@ -207,6 +229,14 @@ button.sl-status:focus-visible {
border-radius: 999px;
}
.sl-seg[hidden],
.sl-controls[hidden],
.sl-filters[hidden],
.sl-since[hidden],
.sl-legend[hidden] {
display: none;
}
.sl-seg-btn {
font-family: var(--font-mono);
font-size: var(--sl-text-small);
@@ -480,7 +510,9 @@ button.sl-status:focus-visible {
/* A dragged window keeps its natural height, so bound it to the viewport:
the rail's max-height: 100% does not bind an auto-height parent.
Id-qualified to outrank that rule. */
#right-context-rail > #street-level-panel.panel-floating > .street-level-panel-inner {
#right-context-rail
> #street-level-panel.panel-floating
> .street-level-panel-inner {
max-height: calc(100vh - 12px);
overflow-y: auto;
}
+7 -3
View File
@@ -118,7 +118,7 @@
<!-- A missing key gates the filters only: the chips above stay
focusable so their tooltip can say how to add the key. -->
<fieldset id="sl-controls" class="sl-controls">
<div class="sl-filters" role="group" aria-label="Imagery filters" data-i18n-aria-label>
<div id="sl-pano-row" class="sl-filters" role="group" aria-label="Imagery filters" data-i18n-aria-label>
<span class="cctv-summary-label" data-i18n>IMAGERY</span>
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter" data-i18n-aria-label>
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true" data-i18n>ALL</button>
@@ -126,14 +126,18 @@
<button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1" data-i18n>FLAT</button>
</div>
</div>
<div class="sl-since">
<div id="sl-since-row" class="sl-since">
<label class="cctv-summary-label" for="sl-since" data-i18n>SINCE</label>
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label" />
<output id="sl-since-label" class="sl-since-label" for="sl-since" data-i18n>ANY DATE</output>
</div>
</fieldset>
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours" data-i18n-aria-label></ul>
<div id="sl-coverage-meta" class="sl-meta"></div>
<!-- The hint says how to open imagery; the button does it without a pointer. -->
<div class="sl-meta-row">
<div id="sl-coverage-meta" class="sl-meta"></div>
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" hidden data-i18n-runtime>OPEN STREET VIEW</button>
</div>
</div>
</div>
</div>