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

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

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

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

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

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