From 7bfcf177acbf39755b16dc57651aed3f0a2da421 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 8 Oct 2026 02:59:51 +0100 Subject: [PATCH] fix(street-level): IC8 review of the Street View provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google's Maps Platform terms forbid Street View imagery beside non-Google maps, so the provider declares requiresMapStack: 'photoreal': off Google 3D its chip is dashed with the reason, lookups and opens skip it, and leaving Google 3D closes its panorama. - Ground clicks: only a click on the map itself (scenePick.isSurfacePick: nothing picked or Google 3D tile content) at street zoom opens Street View, and the camera stays put; higher up the hint says to zoom in. scenePick.pickGroundPosition replaces the copy in Directions. - Viewer: poses go out at once and again when the date and address arrive, so an open never hangs on that lookup; one lookup per panorama, seeded by the nearest lookup; a close settles a pending open; failed lookups are asked again; poses follow position changes, not pano_changed. - Loader: the gm_authFailure handler is installed once, so a retried load reports a refusal once. - Viewer host: a prewarm that lands after another provider took the host is released; FIT/FILL availability is set on every open. - Provider: a refusal while switched off reports no error over another photo; SINCE compares against the end of Google's month; messages name the referrer and network causes; FLAT says Street View is 360° only. - The layer row names the key of the providers that are on; the MCP panel CSP admits Street View's tiles, control images and photos. - Tests for each, against fakes that now follow Google's event order; the browser gate restores its state, waits instead of sleeping, aims its panel clicks and corner drags, and asserts the camera stays put. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 +- DATA_SOURCES.md | 2 +- README.md | 2 +- SECURITY.md | 2 +- docs/CURRENT-STATE.md | 29 +- scripts/format-scope.json | 1 + scripts/qa-panel-resize.mjs | 19 +- scripts/qa-street-level.mjs | 254 +++++++++++------- src/app/layers/streetLevel.test.mjs | 27 ++ src/data/scenePick.js | 50 ++++ src/data/scenePick.test.mjs | 81 +++++- src/layers/directions/index.js | 31 +-- src/layers/streetLevel/groundClick.js | 56 ++-- src/layers/streetLevel/groundClick.test.mjs | 143 ++++++---- src/layers/streetLevel/index.js | 85 +++++- src/layers/streetLevel/index.test.mjs | 93 +++++++ .../streetLevel/providers/google/index.js | 34 ++- .../providers/google/mapsLoader.js | 4 + .../providers/google/mapsLoader.test.mjs | 18 ++ .../streetLevel/providers/google/policy.js | 27 +- .../providers/google/provider.test.mjs | 68 ++++- .../streetLevel/providers/google/viewer.js | 151 ++++++----- .../providers/google/viewer.test.mjs | 125 ++++++++- src/layers/streetLevel/registry.js | 3 +- src/layers/streetLevel/selection.js | 4 +- src/layers/streetLevel/selection.test.mjs | 15 +- src/layers/streetLevel/state.js | 3 + src/layers/streetLevel/view.js | 31 --- src/layers/streetLevel/viewerHost.js | 11 +- src/layers/streetLevel/viewerHost.test.mjs | 26 ++ src/testSupport/googleStreetViewFakes.mjs | 47 +++- src/tools/globePanel.js | 5 + src/tools/globePanel.test.mjs | 17 ++ src/ui/streetLevelPresentation.js | 14 +- src/ui/styles/street-level.css | 6 + 35 files changed, 1108 insertions(+), 388 deletions(-) create mode 100644 src/app/layers/streetLevel.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 23afcbc2f..f44267f83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,12 +63,12 @@ ## [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`. +- Street Level: Google Street View is the second provider. On the Google 3D + map (Google's terms keep it off other maps), light its chip (off by + default: every panorama is billed to the Google Maps key, which needs the + Maps JavaScript API) and click a street to open Google's own panorama in + the panel, with its arrows, the address and month in the caption, a link + to Google Maps and FOLLOW. Share links carry it 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 diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 0b9177b7c..040e53afc 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -21,7 +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 | +| **Google Street View** (Maps JavaScript API) | Street Level, on the Google 3D map only (Google's terms): Google's panorama viewer and nearest-panorama lookups; panorama dates and addresses kept for the session only | Google Maps Platform ToS (your own key + billing; every panorama load is billed) | "Street View imagery © Google" on the globe while the provider is on, and Google's logo and terms in the viewer | | **OpenStreetMap** | Map and place data (roads, military areas and names, ALPR, place search, outlines); via vector tiles, hourly ALPR extract, bundled data, Nominatim and optional self-configured Overpass | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | © OpenStreetMap contributors | | **Google Map Tiles API** (Photorealistic 3D Tiles) + Places/Geocoding | The 3D globe, voice scene context, and on-demand nearby installation search | Google Maps Platform ToS (proprietary, your own key + billing) | "Google" / "Google Maps" logo — **shown in-app**, required | | **OpenSky Network** | Primary worldwide live-flight snapshot | Non-commercial research/education license | Schäfer et al., _"Bringing Up OpenSky"_, IPSN 2014 + opensky-network.org | diff --git a/README.md b/README.md index 9b4757cf4..02558f41c 100644 --- a/README.md +++ b/README.md @@ -338,7 +338,7 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED **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. 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. +**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 on the **Google 3D** map (Google's terms keep Street View off other maps), click a street from below 3 km to open Google's panorama there, and step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google"). Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.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`. diff --git a/SECURITY.md b/SECURITY.md index 503139b4b..b5c79fb21 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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 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. +1. **Google Maps API key** — loads Photorealistic 3D Tiles directly and powers GEV place search and Street View (Maps JavaScript API; add it to the key's API restrictions). **Restrict it** (HTTP referrer + API restriction to the required Google APIs) in the Google Cloud Console. An unrestricted key in a public deployment can be abused and billed to you. 2. **Cesium ion token** (`CESIUM_ION_TOKEN`, optional — for ion-hosted Google Photorealistic 3D Tiles, Bing world imagery, and world terrain) — used as `Cesium.Ion.defaultAccessToken` client-side. Use a public **`assets:read`** token with **URL restrictions** for any hosted deployment. The Community plan has eligibility and usage limits; a public token is not a secret, but it can still consume the account's quota. 3. **Mapillary client token** (`MAPILLARY_CLIENT_TOKEN`, optional, Street Level) — used by the MapillaryJS viewer and Graph API lookups in the browser, and by the server's coverage-tile proxy. Mapillary client tokens are meant for browser use. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 76061bbf6..1e8b8936e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3088,16 +3088,25 @@ 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. +panorama load is billed to the key. Google's terms forbid Street View beside +non-Google maps, so the provider declares `requiresMapStack: 'photoreal'`: +off Google 3D its chip is dashed with the reason, lookups and opens skip it, +and leaving Google 3D closes its panorama. Google offers no coverage layer +for other maps, so it draws none: with it on, a click on the map itself +(`scenePick.isSurfacePick`: nothing picked or Google 3D tile content, so a +Mapillary line under the pointer wins; no tool holding the pointer; camera +below 3 km) opens its nearest Google-collected outdoor panorama within 50 m +and leaves the camera where it is (`groundClick.js`). Higher up the hint +says to zoom in. The viewer is Google's `StreetViewPanorama` in its own +element in the viewer host, without Google's fullscreen, close and address +controls (the address goes in the caption); a pose goes out at once and +again when the panorama's month, photographer and address arrive (one +lookup per panorama per session, seeded by the nearest lookup). A key +Google refuses (`gm_authFailure`: the API not enabled, a referrer it does +not allow, billing off) reads KEY REJECTED and fails a pending open at +once; a lookup or open Google never answers fails after 10 or 20 s. In CI +(no Google key, no Google 3D) only the chip and share-link step runs; the +click step skips as `no Google key`. Street Level has two browser gates. `npm run qa:street-level -- --url ` runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 9c0d0d7aa..2d1c50f4f 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -899,6 +899,7 @@ "src/layers/streetLevel/providers/google/viewer.js", "src/layers/streetLevel/providers/google/mapsLoader.js", "src/layers/streetLevel/groundClick.test.mjs", + "src/app/layers/streetLevel.test.mjs", "src/layers/streetLevel/providers/google/provider.test.mjs", "src/layers/streetLevel/providers/google/viewer.test.mjs", "src/layers/streetLevel/providers/google/mapsLoader.test.mjs", diff --git a/scripts/qa-panel-resize.mjs b/scripts/qa-panel-resize.mjs index 026b6f2fb..e9826ef23 100644 --- a/scripts/qa-panel-resize.mjs +++ b/scripts/qa-panel-resize.mjs @@ -381,8 +381,23 @@ async function main() { ); await nextFrames(page); }; - await drag(handlePoint(await readPanel(), 'se'), 0, 200); - const chosen = await readPanel(); + // Grow it from the corner once the restored window holds still, with the + // pointer really on the handle (a press beside it selects page text). + let chosen = await readPanel(); + for ( + let attempt = 0; + attempt < 3 && chosen.height <= MIN_SIZE.height + 150; + attempt++ + ) { + await settle(); + const corner = handlePoint(await readPanel(), 'se'); + const onHandle = await page.evaluate( + ({ x, y }) => document.elementFromPoint(x, y)?.dataset?.dir === 'se', + corner, + ); + if (onHandle) await drag(corner, 0, 200); + chosen = await readPanel(); + } assert.ok( chosen.height > MIN_SIZE.height + 150, `the window grew to ${chosen.height}px`, diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index f9b21b080..b2d33360e 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -628,7 +628,8 @@ async function main() { } await page.setViewport(VIEWPORTS[0]); const expandStrip = async (target = page) => { - await target.click( + await clickPanelControl( + target, '.panel-collapse-btn[data-collapse-target="street-level-panel"]', ); await settle( @@ -1774,116 +1775,161 @@ async function main() { `the share link carries ${option}`, ); assert.ok((await ui()).coverage.count > 0, 'Mapillary still draws'); + // Leave it off, so later steps run the same with and without a key. + await clickPanelControl(page, googleChip); + await uiUntil( + (u) => u.providers.find((p) => p.id === 'google')?.on === false, + ); }, ); await step( - 'a click on a street opens Google Street View, captioned and linked, and its view drives the pose', + 'on Google 3D a click on a street opens Google Street View where it is, captioned and linked, and its view drives the pose', async ({ skip }) => { if (!googleKey) return skip('no Google key'); - // 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), + // Google's terms: Street View only beside Google's own map. + if (!(await stacks.photoreal())) return skip('no Google 3D'); + const original = await stacks.active(); + const closePhoto = () => + page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.closeViewer(), ); - const 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( + const setOn = async (selector, id, on) => { + const now = (await ui()).providers.find((p) => p.id === id)?.on; + if (now === on) return; + await clickPanelControl(page, selector); 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, - ); + (u, want) => + u.providers.find((p) => p.id === want.id)?.on === want.on, + { id, on }, + ); + }; + await closePhoto(); + await stacks.set('photoreal'); + // A Mapillary line under the pointer wins the click: aim at bare + // ground with only Street View on (lit first: the last lit chip + // would switch the layer off). + await setOn(googleChip, 'google', true); + await setOn(chip, 'mapillary', false); + try { + const street = STREET_VIEW_STREET; + await page.evaluate((at) => { + const v = window.__godsEyeView.viewer; + v.camera.cancelFlight?.(); + const C = v.camera.positionCartographic.constructor; + v.camera.setView({ + destination: v.scene.globe.ellipsoid.cartographicToCartesian( + C.fromDegrees(at.lon, at.lat, 300), + ), + orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, + }); + }, street); + // At street zoom the hint invites the click. + assert.ok( + await uiUntil( + (u) => + u.providers.find((p) => p.id === 'google')?.hint === + 'click a street to open Street View', + ), + 'the click hint shows at street zoom', + ); + const camera = () => + page.evaluate(() => { + const c = window.__godsEyeView.viewer.camera.positionWC; + return [c.x, c.y, c.z]; + }); + const at = await page.evaluate((point) => { + const v = window.__godsEyeView.viewer; + const C = v.camera.positionCartographic.constructor; + const ground = v.scene.globe.ellipsoid.cartographicToCartesian( + C.fromDegrees(point.lon, point.lat, 0), + ); + const canvas = v.scene.cartesianToCanvasCoordinates(ground); + const box = v.scene.canvas.getBoundingClientRect(); + return ( + canvas && { x: box.left + canvas.x, y: box.top + canvas.y } + ); + }, street); + assert.ok(at, 'the street is on screen'); + const before = await camera(); + await page.mouse.click(at.x, at.y); + await uiUntil( + (u) => + Boolean(u.street.error) || + (u.street.providerId === 'google' && + Boolean(u.street.imageId) && + !u.street.loading), + null, + { timeout: 30_000 }, + ); + const opened = (await ui()).street; + if (/refused GOOGLE_MAPS_API_KEY/.test(opened.error || '')) + return skip('Google key refused'); + if (/did not answer/.test(opened.error || '')) + return skip('Google did not answer'); + assert.equal(opened.error, null); + assert.equal(opened.providerId, 'google'); + const after = await camera(); + assert.ok( + Math.hypot(...after.map((v, i) => v - before[i])) < 1, + 'the camera stays where the user clicked', + ); + const shown = await page.evaluate(() => ({ + by: document.getElementById('sl-image-by').textContent, + when: document.getElementById('sl-image-when').textContent, + label: document.getElementById('sl-image-link').textContent, + href: document.getElementById('sl-image-link').href, + fitFillHidden: + document.querySelector('[data-sl-render]').parentElement.hidden, + panorama: Boolean( + document.querySelector('#sl-viewer .sl-google-panorama'), + ), + mapillaryLeft: Boolean( + document.querySelector('#sl-viewer .mapillary-dom'), + ), + })); + assert.match(shown.by, /Image by /); + assert.match(shown.when, /\d{4}-\d{2}$/, 'dated by month'); + assert.equal(shown.label, 'STREET VIEW ↗'); + assert.match(shown.href, /map_action=pano&pano=/); + assert.equal(shown.fitFillHidden, true, 'no FIT/FILL for Google'); + assert.equal(shown.panorama, true); + assert.equal(shown.mapillaryLeft, false, 'no viewer left under it'); + // A real drag turns Google's view, and the pose follows it. + const box = await page.$eval( + '#sl-viewer .sl-google-panorama', + (node) => { + const r = node.getBoundingClientRect(); + return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + }, + ); + await page.mouse.move(box.x, box.y); + await page.mouse.down(); + await page.mouse.move(box.x - 120, box.y, { steps: 12 }); + await page.mouse.up(); + assert.ok( + await uiUntil( + (u, start) => + Number.isFinite(u.street.bearing) && + Math.abs(u.street.bearing - start) > 5, + opened.bearing ?? 0, + { timeout: 10_000 }, + ), + 'the pose bearing follows the drag', + ); + // Leaving Google 3D closes the panorama (Google's terms). + await stacks.set('esri-imagery'); + assert.ok( + await uiUntil((u) => u.street.open === false), + 'the panorama closes off Google 3D', + ); + } finally { + await closePhoto(); + await setOn(chip, 'mapillary', true); + await setOn(googleChip, 'google', false); + await stacks.set(original); + } }, ); await step( diff --git a/src/app/layers/streetLevel.test.mjs b/src/app/layers/streetLevel.test.mjs new file mode 100644 index 000000000..b61eb7ccf --- /dev/null +++ b/src/app/layers/streetLevel.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createApplicationStreetLevel } from './streetLevel.js'; +import { createDefaultLayerState } from '../../data/layerState.js'; + +test('each provider starts as its share-link option does, so a fresh page and a restored one agree', () => { + const layer = createApplicationStreetLevel({ + surface: null, + sources: { + mapillary: { + getStatus() {}, + getTile() {}, + getSequenceImages() {}, + nearestImages() {}, + }, + }, + }); + const options = createDefaultLayerState().options['street-level']; + const providers = layer.getUIState().providers; + assert.deepEqual( + providers.map((p) => p.id), + layer.providerIds, + ); + for (const provider of providers) + assert.equal(provider.on, options[provider.id], provider.id); + assert.equal(options.google, false, 'Street View is billed: off'); +}); diff --git a/src/data/scenePick.js b/src/data/scenePick.js index c5cb492c8..2f3bee1b7 100644 --- a/src/data/scenePick.js +++ b/src/data/scenePick.js @@ -47,3 +47,53 @@ export function isPickedWorldPosition(position) { const magnitude = Math.hypot(x, y, z); return magnitude >= MIN_PICK_MAGNITUDE_M && magnitude <= MAX_PICK_MAGNITUDE_M; } + +/** + * The world position under a screen point: the rendered surface (terrain, + * Google 3D) where the depth buffer has it, else the ellipsoid. Null over the + * sky or for a pick that names no place. + * + * @param {object} viewer A Cesium viewer. + * @param {{x: number, y: number}} screenPosition + * @returns {{x: number, y: number, z: number}|null} A Cartesian3. + */ +export function pickGroundPosition(viewer, screenPosition) { + const scene = viewer?.scene; + let position = null; + if ( + scene?.pickPositionSupported && + typeof scene.pickPosition === 'function' + ) { + try { + position = scene.pickPosition(screenPosition); + } catch { + position = null; + } + } + if ( + !isPickedWorldPosition(position) && + typeof viewer?.camera?.pickEllipsoid === 'function' + ) { + try { + position = viewer.camera.pickEllipsoid(screenPosition); + } catch { + position = null; + } + } + return isPickedWorldPosition(position) ? position : null; +} + +/** + * Whether a `scene.pick()` result is the map itself: nothing, or Google 3D + * tile content, which picks without an id. + * + * @param {object|null|undefined} picked + * @returns {boolean} + */ +export function isSurfacePick(picked) { + if (!picked) return true; + if (picked.id !== undefined) return false; + return ( + picked.content !== undefined || picked.primitive?.isCesium3DTileset === true + ); +} diff --git a/src/data/scenePick.test.mjs b/src/data/scenePick.test.mjs index adaedbaec..630ce4003 100644 --- a/src/data/scenePick.test.mjs +++ b/src/data/scenePick.test.mjs @@ -3,9 +3,8 @@ import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { isPickedWorldPosition } from './scenePick.js'; -const describe = (position) => (position - ? `(${position.x}, ${position.y}, ${position.z})` - : String(position)); +const describe = (position) => + position ? `(${position.x}, ${position.y}, ${position.z})` : String(position); test('a degenerate depth pick is rejected before it reaches Cesium', () => { // These are the shapes `scene.pickPosition()` can produce when the depth read @@ -21,7 +20,11 @@ test('a degenerate depth pick is rejected before it reaches Cesium', () => { new Cesium.Cartesian3(0.2, -0.3, 0.1), ]; for (const position of degenerate) { - assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`); + assert.equal( + isPickedWorldPosition(position), + false, + `must reject ${describe(position)}`, + ); } }); @@ -38,11 +41,18 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => { new Cesium.Cartesian3(0, 0, 5_999_999), ]; for (const position of belowTheFloor) { - assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`); + assert.equal( + isPickedWorldPosition(position), + false, + `must reject ${describe(position)}`, + ); // Each one converts WITHOUT throwing — that is exactly what makes it dangerous. const carto = Cesium.Cartographic.fromCartesian(position); assert.ok(carto, `Cesium converts ${describe(position)} without complaint`); - assert.ok(carto.height < -300_000, 'and puts the "target" hundreds of km underground'); + assert.ok( + carto.height < -300_000, + 'and puts the "target" hundreds of km underground', + ); } const aboveTheCeiling = [ @@ -52,7 +62,11 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => { new Cesium.Cartesian3(1e155, 0, 0), ]; for (const position of aboveTheCeiling) { - assert.equal(isPickedWorldPosition(position), false, `must reject ${describe(position)}`); + assert.equal( + isPickedWorldPosition(position), + false, + `must reject ${describe(position)}`, + ); } assert.throws( () => Cesium.Cartographic.fromCartesian(new Cesium.Cartesian3(1e155, 0, 0)), @@ -61,8 +75,14 @@ test('the guard is an Earth-sized band, not just a non-zero check', () => { ); // Both edges of the band are inclusive, and both are real magnitudes. - assert.equal(isPickedWorldPosition(new Cesium.Cartesian3(6_000_000, 0, 0)), true); - assert.equal(isPickedWorldPosition(new Cesium.Cartesian3(1_000_000_000, 0, 0)), true); + assert.equal( + isPickedWorldPosition(new Cesium.Cartesian3(6_000_000, 0, 0)), + true, + ); + assert.equal( + isPickedWorldPosition(new Cesium.Cartesian3(1_000_000_000, 0, 0)), + true, + ); }); test('a real picked position is accepted', () => { @@ -81,13 +101,22 @@ test('a real picked position is accepted', () => { Cesium.Cartesian3.fromDegrees(0, 0, 35_786_000), ]; for (const position of real) { - assert.equal(isPickedWorldPosition(position), true, `must accept ${describe(position)}`); + assert.equal( + isPickedWorldPosition(position), + true, + `must accept ${describe(position)}`, + ); } // The floor has real margin under the smallest legitimate magnitude, so no // plausible pick sits anywhere near the boundary. - const polarSurface = Cesium.Cartesian3.magnitude(Cesium.Cartesian3.fromDegrees(0, 90, -10_935)); - assert.ok(polarSurface > 6_300_000, `smallest real magnitude was ${polarSurface}`); + const polarSurface = Cesium.Cartesian3.magnitude( + Cesium.Cartesian3.fromDegrees(0, 90, -10_935), + ); + assert.ok( + polarSurface > 6_300_000, + `smallest real magnitude was ${polarSurface}`, + ); }); test('the guard rejects each way Cesium mishandles a degenerate pick', () => { @@ -118,7 +147,10 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => { // Earth's core. That is the quietest failure of the three and the reason // the guard needs a magnitude band, not just a finiteness check. const nearCenter = new Cesium.Cartesian3(0.2, -0.3, 0.1); - assert.ok(Cesium.Cartographic.fromCartesian(nearCenter), 'Cesium converts it without complaint'); + assert.ok( + Cesium.Cartographic.fromCartesian(nearCenter), + 'Cesium converts it without complaint', + ); assert.equal(isPickedWorldPosition(nearCenter), false); // …and every position the guard accepts must convert to the right place. @@ -129,3 +161,26 @@ test('the guard rejects each way Cesium mishandles a degenerate pick', () => { assert.ok(Math.abs(Cesium.Math.toDegrees(carto.longitude) + 97.7431) < 1e-6); assert.ok(Math.abs(Cesium.Math.toDegrees(carto.latitude) - 30.2672) < 1e-6); }); + +test('the ground under a click: the rendered surface, else the ellipsoid, never the sky', async () => { + const { pickGroundPosition } = await import('./scenePick.js'); + const ground = { x: 6378137, y: 0, z: 0 }; + const sky = { x: 500, y: 0, z: 0 }; + const viewer = (picked, ellipsoid) => ({ + scene: { pickPositionSupported: true, pickPosition: () => picked }, + camera: { pickEllipsoid: () => ellipsoid }, + }); + assert.equal(pickGroundPosition(viewer(ground, null), {}), ground); + assert.equal(pickGroundPosition(viewer(sky, ground), {}), ground); + assert.equal(pickGroundPosition(viewer(undefined, undefined), {}), null); + assert.equal(pickGroundPosition(null, {}), null); +}); + +test('a surface pick is nothing, or Google 3D tile content without an id', async () => { + const { isSurfacePick } = await import('./scenePick.js'); + assert.equal(isSurfacePick(undefined), true); + assert.equal(isSurfacePick({ content: {} }), true); + assert.equal(isSurfacePick({ primitive: { isCesium3DTileset: true } }), true); + assert.equal(isSurfacePick({ id: 'flight:1', content: {} }), false); + assert.equal(isSurfacePick({ primitive: {} }), false, 'an unnamed primitive'); +}); diff --git a/src/layers/directions/index.js b/src/layers/directions/index.js index bc1ec7296..8fa01bace 100644 --- a/src/layers/directions/index.js +++ b/src/layers/directions/index.js @@ -618,32 +618,11 @@ export function createDirectionsLayer({ services }) { } function pickGround(screenPosition) { - const scene = _viewer?.scene; - let cartesian = null; - if ( - scene?.pickPositionSupported && - typeof scene.pickPosition === 'function' - ) { - try { - cartesian = scene.pickPosition(screenPosition); - } catch { - cartesian = null; - } - } - if ( - !services.scenePick.isPickedWorldPosition(cartesian) && - typeof _viewer?.camera?.pickEllipsoid === 'function' - ) { - try { - cartesian = _viewer.camera.pickEllipsoid( - screenPosition, - Cesium.Ellipsoid.WGS84, - ); - } catch { - cartesian = null; - } - } - if (!services.scenePick.isPickedWorldPosition(cartesian)) return null; + const cartesian = services.scenePick.pickGroundPosition( + _viewer, + screenPosition, + ); + if (!cartesian) return null; const carto = Cesium.Cartographic.fromCartesian(cartesian); if (!carto) return null; return { diff --git a/src/layers/streetLevel/groundClick.js b/src/layers/streetLevel/groundClick.js index 95d64b131..ff95d3e11 100644 --- a/src/layers/streetLevel/groundClick.js +++ b/src/layers/streetLevel/groundClick.js @@ -1,31 +1,49 @@ +import * as Cesium from 'cesium'; import { GROUND_CLICK_MAX_HEIGHT_M } from './policy.js'; -import { cameraHeightAboveGround, groundPointAt } from './view.js'; +import { cameraHeightAboveGround } from './view.js'; + +/** Whether the camera is low enough for a click on the ground to mean a street. */ +export function groundClickHeightOk(viewer, groundAt) { + if (!viewer?.camera?.positionCartographic) return false; + return ( + cameraHeightAboveGround(viewer, { groundAt }) <= GROUND_CLICK_MAX_HEIGHT_M + ); +} /** - * A click on 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|false} + * A click on the map itself (nothing picked, or Google 3D tile content) at + * street zoom opens the nearest image of the active, available providers + * that have no coverage to click (`groundClick`, Google Street View). The + * camera stays put: the user chose the spot. + * @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean}} options + * @returns {(screenPosition: {x: number, y: number}, picked?: object) => Promise|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; +export function createGroundClick({ state, openNearest, isAvailable }) { + return function openAtGround(screenPosition, picked) { + const scenePick = state.services.scenePick; + if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition) + return false; + if (!scenePick.isSurfacePick(picked) || !state.groundClickReady) + return false; const providerIds = [...state.providers.values()] .filter( (entry) => - entry.on && entry.def.groundClick && entry.status?.configured, + entry.on && + entry.def.groundClick && + entry.status?.configured && + isAvailable(entry), ) .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; + const position = scenePick.pickGroundPosition(state.viewer, screenPosition); + const carto = position && Cesium.Cartographic.fromCartesian(position); + if (!carto) return false; + return openNearest( + { + lon: Cesium.Math.toDegrees(carto.longitude), + lat: Cesium.Math.toDegrees(carto.latitude), + }, + { providerIds, frame: false }, + ); }; } diff --git a/src/layers/streetLevel/groundClick.test.mjs b/src/layers/streetLevel/groundClick.test.mjs index c1a9f0c92..f47285939 100644 --- a/src/layers/streetLevel/groundClick.test.mjs +++ b/src/layers/streetLevel/groundClick.test.mjs @@ -1,84 +1,127 @@ 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'; +import { createGroundClick, groundClickHeightOk } from './groundClick.js'; +import * as scenePick from '../../data/scenePick.js'; const GROUND = Cesium.Cartesian3.fromDegrees(-121.4944, 38.5816, 10); +const TILESET = { isCesium3DTileset: true }; -/** A layer state with providers `{id, on, groundClick, configured}` and a camera `height` m up. */ +/** + * A layer state with providers `{id, on, groundClick, configured, available}`; + * `picked` is where the depth buffer lands, `ellipsoid` the fallback hit. + */ function harness({ providers = [ { id: 'mapillary', groundClick: false }, { id: 'google', groundClick: true }, ], - height = 400, + ready = true, enabled = true, picked = GROUND, + ellipsoid = undefined, } = {}) { const opened = []; const state = { enabled, - services: { scenePick: { isPickedWorldPosition } }, + groundClickReady: ready, + services: { scenePick }, providers: new Map( - providers.map(({ id, on = true, groundClick, configured = true }) => [ - id, - { - on, - def: { id, groundClick }, - status: { configured }, - }, - ]), + providers.map( + ({ + id, + on = true, + groundClick, + configured = true, + available = true, + }) => [ + id, + { on, def: { id, groundClick }, status: { configured }, available }, + ], + ), ), viewer: { - scene: { - pickPositionSupported: true, - pickPosition: () => picked, - globe: { getHeight: () => 10 }, - }, - camera: { - positionCartographic: Cesium.Cartographic.fromDegrees( - -121.4944, - 38.5816, - 10 + height, - ), - pickEllipsoid: () => undefined, - }, + scene: { pickPositionSupported: true, pickPosition: () => picked }, + camera: { pickEllipsoid: () => ellipsoid }, }, }; const openAtGround = createGroundClick({ state, - parts: {}, openNearest: (point, options) => { opened.push({ point, options }); return Promise.resolve(true); }, + isAvailable: (entry) => entry.available, }); - return { state, opened, openAtGround }; + return { 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'] }); -}); +const near = (point) => + Math.abs(point.lon - -121.4944) < 1e-9 && + Math.abs(point.lat - 38.5816) < 1e-9; -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); +test('a click on the map opens the click-to-open providers there, without moving the camera', () => { + for (const picked of [undefined, { primitive: TILESET }, { content: {} }]) { + const { opened, openAtGround } = harness(); + openAtGround({ x: 10, y: 20 }, picked); + assert.equal(opened.length, 1, JSON.stringify(picked)); + assert.ok(near(opened[0].point)); + assert.deepEqual(opened[0].options, { + providerIds: ['google'], + frame: false, + }); } }); + +test('where the depth buffer names no place, the ellipsoid answers', () => { + const { opened, openAtGround } = harness({ + picked: new Cesium.Cartesian3(500, 0, 0), + ellipsoid: GROUND, + }); + openAtGround({ x: 1, y: 1 }); + assert.ok(near(opened[0].point)); +}); + +test('nothing opens over another layer’s object, the sky, from high up, off its map stack, or with the provider off', () => { + const cases = [ + { picked: { id: { id: 'flight:1' } }, label: 'an entity' }, + { picked: { id: 'cctv:7', primitive: TILESET }, label: 'an id on a tile' }, + { options: { picked: null, ellipsoid: undefined }, label: 'the sky' }, + { options: { ready: false }, label: 'high up' }, + { + options: { + providers: [{ id: 'google', groundClick: true, available: false }], + }, + label: 'not Google 3D', + }, + { + options: { providers: [{ id: 'google', groundClick: true, on: false }] }, + label: 'off', + }, + { + options: { + providers: [{ id: 'google', groundClick: true, configured: false }], + }, + label: 'no key', + }, + { options: { enabled: false }, label: 'layer off' }, + ]; + for (const { picked, options = {}, label } of cases) { + const { opened, openAtGround } = harness(options); + assert.equal(openAtGround({ x: 1, y: 1 }, picked), false, label); + assert.equal(opened.length, 0, label); + } +}); + +test('street zoom is measured above the ground under the camera', () => { + const viewer = (height) => ({ + camera: { + positionCartographic: Cesium.Cartographic.fromDegrees(0, 0, height), + }, + scene: { globe: { getHeight: () => 1500 } }, + }); + // 1,500 m up a plateau, 2,000 m above it: still a street. + assert.equal(groundClickHeightOk(viewer(3500)), true); + assert.equal(groundClickHeightOk(viewer(5000)), false); + assert.equal(groundClickHeightOk(null), false); +}); diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 666b97dc0..382011c5a 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -10,7 +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 { createGroundClick, groundClickHeightOk } from './groundClick.js'; import { createGroundCaster, nextSurfaceMode } from './groundCast.js'; import { createMeshSampler } from './meshSampler.js'; @@ -69,8 +69,8 @@ export function createStreetLevelLayer({ parts.selection = createSelection(context); parts.openAtGround = createGroundClick({ state, - parts, openNearest: (point, options) => layer.openNearest(point, options), + isAvailable: (entry) => providerAvailable(entry), }); let notifyQueued = false; @@ -146,6 +146,33 @@ export function createStreetLevelLayer({ notify(); } + /** + * A provider that names a map stack (Google Street View: Google's terms + * forbid its imagery beside non-Google maps) is usable only on that stack. + */ + function providerAvailable(entry) { + const required = entry.def.requiresMapStack; + return !required || mapStack?.getActiveId?.() === required; + } + + /** Leaving a provider's map stack closes the image it shows. */ + function syncProviderAvailability() { + const owner = state.providers.get(state.street.providerId); + if (state.street.open && owner && !providerAvailable(owner)) + layer.closeViewer(); + notify(); + } + + /** Whether a ground click can open an image: the camera is at street zoom. */ + function syncGroundClickReady() { + const ready = + state.enabled && + groundClickHeightOk(state.viewer, parts.groundCaster?.groundAt); + if (ready === state.groundClickReady) return; + state.groundClickReady = ready; + notify(); + } + /** * Camera height above bare earth, for the surface mode. A cold grid cell is * fetched once and the mode rechecked when it lands. @@ -168,6 +195,7 @@ export function createStreetLevelLayer({ /** Cast overlays to bare earth on Google 3D at street zoom; drape them elsewhere. */ function syncSurface() { + syncGroundClickReady(); const available = Boolean(parts.groundCaster) && state.enabled; const photoreal = mapStack?.getActiveId?.() === FOLLOW_MAP_STACK_ID; const mode = nextSurfaceMode(state.surface, { @@ -247,9 +275,20 @@ export function createStreetLevelLayer({ nearestLookup = null; } - function openImage(providerId, imageId) { + /** `frame: false` keeps the camera where it is (the user picked the spot). */ + function openImage(providerId, imageId, { frame = true } = {}) { abortNearest(); - return parts.viewerHost.open(providerId, imageId); + const entry = state.providers.get(providerId); + if (entry && !providerAvailable(entry)) { + state.street.error = unavailableReason(entry); + notify(); + return Promise.resolve(false); + } + return parts.viewerHost.open(providerId, imageId, { frame }); + } + + function unavailableReason(entry) { + return `${entry.def.name} needs the Google 3D map: choose it under MAP SOURCE`; } function setProviderEnabled(providerId, on) { @@ -276,9 +315,18 @@ export function createStreetLevelLayer({ notify(); } + /** The hint a ground-click provider shows, or the provider's own. */ + function hintFor(entry, stats) { + if (!providerAvailable(entry)) return ''; + if (entry.def.groundClick && stats.hint && !state.groundClickReady) + return `zoom in to a street to open ${entry.def.name}`; + return stats.hint || ''; + } + function providerSnapshots() { return [...state.providers.values()].map((entry) => { const stats = entry.instance.coverageStats(); + const available = providerAvailable(entry); return { id: entry.def.id, name: entry.def.name, @@ -291,9 +339,11 @@ export function createStreetLevelLayer({ requiresKeyId: entry.def.requiresKeyId || null, loading: stats.loading === true, count: stats.count || 0, - hint: stats.hint || '', + hint: hintFor(entry, stats), error: stats.error || null, color: entry.def.colors.coverage, + /** Why the provider cannot be used right now, or null. */ + unavailable: available ? null : unavailableReason(entry), }; }); } @@ -336,7 +386,13 @@ export function createStreetLevelLayer({ source: definitionsFrozen.map((def) => def.name).join(' · '), updateInterval: 0, statsRefreshInterval: 1000, - requiresKeyId: requiresKeyIdFor(definitionsFrozen), + /** The key the switched-on providers share (or null), for the layer row. */ + get requiresKeyId() { + const on = [...state.providers.values()].filter((entry) => entry.on); + return requiresKeyIdFor( + on.length ? on.map((entry) => entry.def) : definitionsFrozen, + ); + }, /** Registered providers, in chip order. */ providerIds: definitionsFrozen.map((def) => def.id), @@ -437,9 +493,11 @@ export function createStreetLevelLayer({ unsubscribeMapStack = mapStack?.subscribe?.(() => { syncFollowAvailability(); + syncProviderAvailability(); syncSurface(); }) || null; syncFollowAvailability(); + syncProviderAvailability(); syncSurface(); }, @@ -482,7 +540,7 @@ export function createStreetLevelLayer({ * Open the nearest image any active provider (or only `providerIds`) * has around a point, by default the view centre. */ - async openNearest(point, { providerIds = null } = {}) { + async openNearest(point, { providerIds = null, frame = true } = {}) { const view = point || viewCentre(state.viewer); if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon)) return false; @@ -494,9 +552,10 @@ export function createStreetLevelLayer({ state.street.error = null; notify(); let lastError = null; - const entries = activeEntries().filter( - (entry) => !providerIds || providerIds.includes(entry.def.id), - ); + const asked = (entry) => + (!providerIds || providerIds.includes(entry.def.id)) && + providerAvailable(entry); + const entries = activeEntries().filter(asked); for (const entry of entries) { let imageId = null; try { @@ -516,13 +575,11 @@ export function createStreetLevelLayer({ // This provider was switched off while it looked: try the next. if (!imageId || !entry.on) continue; nearestLookup = null; - return openImage(entry.def.id, imageId); + return openImage(entry.def.id, imageId, { frame }); } if (nearestLookup === lookup) nearestLookup = null; // Switched off while it looked: nothing to open, nothing to report. - const stillOn = activeEntries().some( - (entry) => !providerIds || providerIds.includes(entry.def.id), - ); + const stillOn = activeEntries().some(asked); if (state.enabled && stillOn) state.street.error = lastError?.message || diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index d4990c6ab..a46254901 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -482,3 +482,96 @@ test('switching a provider on while the layer is off activates nothing (M01)', a assert.equal(provider.calls.activate, 1); assert.equal(viewer.credits.length, 1); }); + +/** The real Google provider over the Street View fakes, beside a Mapillary stand-in. */ +async function withStreetView(t, { mapStack = 'photoreal', nearest } = {}) { + const { createGoogleProvider } = await import('./providers/google/index.js'); + const { fakeHost, fakeMapsLoader, fakeStreetViewLibrary } = + await import('../../testSupport/googleStreetViewFakes.mjs'); + const fake = fakeStreetViewLibrary({ + panoramas: { + 'pano-a': { lat: 38.5816, lng: -121.4944, imageDate: '2024-05' }, + }, + nearest: nearest ?? (() => 'pano-a'), + }); + const mapillaryLookups = []; + const mapillary = fakeProvider({ + nearestImage: async (point) => { + mapillaryLookups.push(point); + return null; + }, + }); + mapillary.requiresKeyId = 'mapillary'; + const google = createGoogleProvider({ + getApiKey: () => 'browser-key', + loader: fakeMapsLoader(fake.library), + }); + const { layer, viewer } = await enabledLayer(t, [mapillary, google]); + const stack = fakeMapStack(mapStack); + layer.attachMapStackController(stack); + layer.attachViewerHost(fakeHost()); + return { layer, viewer, stack, fake, mapillaryLookups }; +} + +test('Street View starts off, and the layer row names the key of the providers that are on', async (t) => { + const { layer } = await withStreetView(t); + const google = () => + layer.getUIState().providers.find((p) => p.id === 'google'); + assert.equal(google().on, false); + assert.equal(layer.requiresKeyId, 'mapillary'); + layer.setProviderEnabled('google', true); + assert.equal(layer.requiresKeyId, null, 'two keys: each chip names its own'); +}); + +test('Street View opens only on the Google 3D map, and leaving that map closes it', async (t) => { + const { layer, stack } = await withStreetView(t, { + mapStack: 'esri-imagery', + }); + layer.setProviderEnabled('google', true); + await settle(); + const google = () => + layer.getUIState().providers.find((p) => p.id === 'google'); + assert.match(google().unavailable, /needs the Google 3D map/); + assert.equal(google().hint, '', 'no "click a street" off Google 3D'); + assert.equal(await layer.openImage('google', 'pano-a'), false); + assert.match(layer.getUIState().street.error, /needs the Google 3D map/); + + stack.switchTo('photoreal'); + assert.equal(google().unavailable, null); + assert.equal(await layer.openImage('google', 'pano-a'), true); + assert.equal(layer.getUIState().street.providerId, 'google'); + + stack.switchTo('bing'); + await settle(); + assert.equal(layer.getUIState().street.open, false, 'closed with the map'); +}); + +test('a lookup for the click-to-open providers asks only them and reports "that point"', async (t) => { + const { layer, mapillaryLookups } = await withStreetView(t, { + nearest: () => null, + }); + layer.setProviderEnabled('google', true); + await settle(); + const opened = await layer.openNearest( + { lat: 38.58, lon: -121.49 }, + { providerIds: ['google'], frame: false }, + ); + assert.equal(opened, false); + assert.deepEqual(mapillaryLookups, [], 'Mapillary was not asked'); + assert.equal( + layer.getUIState().street.error, + 'No street-level imagery within 50 m of that point', + ); +}); + +test('an image opened from a ground click leaves the camera where it is', async (t) => { + const { layer, viewer } = await withStreetView(t); + layer.setProviderEnabled('google', true); + await settle(); + await layer.openNearest( + { lat: 38.58, lon: -121.49 }, + { providerIds: ['google'], frame: false }, + ); + assert.equal(layer.getUIState().street.imageId, 'pano-a'); + assert.equal(viewer.flights.started, 0, 'no framing flight'); +}); diff --git a/src/layers/streetLevel/providers/google/index.js b/src/layers/streetLevel/providers/google/index.js index 59f559611..7ac750801 100644 --- a/src/layers/streetLevel/providers/google/index.js +++ b/src/layers/streetLevel/providers/google/index.js @@ -1,4 +1,5 @@ import { passesImageryFilter } from '../../filter.js'; +import { FOLLOW_MAP_STACK_ID } from '../../policy.js'; import { createMapsLoader } from './mapsLoader.js'; import { createGoogleViewer } from './viewer.js'; import { @@ -8,13 +9,14 @@ import { GOOGLE_LABEL, GOOGLE_NAME, GOOGLE_PROVIDER_ID, + FLAT_FILTER_HINT, GROUND_CLICK_HINT, KEY_REJECTED_MESSAGE, NEAREST_RADIUS_M, NEAREST_TIMEOUT_MS, NO_ANSWER_MESSAGE, PICK_PREFIX, - imageDateMs, + imageMonthEndMs, } from './policy.js'; /** @@ -69,6 +71,8 @@ export function createGoogleProvider({ colors: COLORS, credit: Object.freeze({ html: GOOGLE_CREDIT_HTML }), groundClick: true, + // Google's terms forbid Street View beside non-Google maps. + requiresMapStack: FOLLOW_MAP_STACK_ID, // Every panorama is billed to the Maps key: the user lights the chip. defaultOn: false, @@ -76,12 +80,21 @@ export function createGoogleProvider({ let keyRequired = !getApiKey(); let active = false; let service = null; + /** The one StreetViewService the lookups and the viewer share. */ + async function getService() { + const lib = await loader.importLibrary('streetView'); + service ||= new lib.StreetViewService(); + return service; + } const viewer = createGoogleViewer({ loader, + getService, render: context.services?.render, }); const stopAuthWatch = loader.onAuthFailure(() => { - context.actions.reportError(KEY_REJECTED_MESSAGE); + // Switched off since: its chip says so; no error over another photo. + if (context.isActive?.()) + context.actions.reportError(KEY_REJECTED_MESSAGE); context.notify(); }); @@ -120,7 +133,11 @@ export function createGoogleProvider({ kind: null, loading: false, hint: - active && !keyRequired && !keyRejected ? GROUND_CLICK_HINT : '', + !active || keyRequired || keyRejected + ? '' + : context.getFilter().pano === 'flat' + ? FLAT_FILTER_HINT + : GROUND_CLICK_HINT, error: keyRejected ? KEY_REJECTED_MESSAGE : null, keyRequired, keyRejected, @@ -140,13 +157,14 @@ export function createGoogleProvider({ if (filter.pano === 'flat') return null; const lookup = (async () => { const lib = await loader.importLibrary('streetView'); - service ||= new lib.StreetViewService(); + await getService(); const { GOOGLE, OUTDOOR } = lib.StreetViewSource; try { const { data } = await service.getPanorama({ location: { lat, lng: lon }, radius: NEAREST_RADIUS_M, - sources: GOOGLE ? [GOOGLE, OUTDOOR] : [OUTDOOR], + // Both: Google's own imagery, outdoors (sources intersect). + sources: [GOOGLE, OUTDOOR], preference: lib.StreetViewPreference.NEAREST, }); return data; @@ -163,10 +181,12 @@ export function createGoogleProvider({ const panoId = data?.location?.pano; if (!panoId) return null; const passes = passesImageryFilter( - { isPano: true, capturedAt: imageDateMs(data.imageDate) ?? 0 }, + { isPano: true, capturedAt: imageMonthEndMs(data.imageDate) ?? 0 }, filter, ); - return passes ? panoId : null; + if (!passes) return null; + viewer.remember(panoId, data); + return panoId; }, viewer, diff --git a/src/layers/streetLevel/providers/google/mapsLoader.js b/src/layers/streetLevel/providers/google/mapsLoader.js index 32b875d28..8b338e40b 100644 --- a/src/layers/streetLevel/providers/google/mapsLoader.js +++ b/src/layers/streetLevel/providers/google/mapsLoader.js @@ -19,9 +19,13 @@ export function createMapsLoader({ /** The script load in flight or done; cleared when it fails, so a retry can. */ let loading = null; let authFailed = false; + let authHandlerInstalled = false; const authListeners = new Set(); + /** Once per loader: a retried load must not wrap its own handler again. */ function installAuthHandler() { + if (authHandlerInstalled) return; + authHandlerInstalled = true; const previous = global.gm_authFailure; global.gm_authFailure = () => { authFailed = true; diff --git a/src/layers/streetLevel/providers/google/mapsLoader.test.mjs b/src/layers/streetLevel/providers/google/mapsLoader.test.mjs index 02605c269..ac7f8fe91 100644 --- a/src/layers/streetLevel/providers/google/mapsLoader.test.mjs +++ b/src/layers/streetLevel/providers/google/mapsLoader.test.mjs @@ -93,3 +93,21 @@ test("Google's refusal of the key reaches every listener and the page's own hand page.global.gm_authFailure(); assert.deepEqual(heard, ['a']); }); + +test('a retried load still reports a refusal once, and the page handler once', async () => { + const page = fakePage(); + let before = 0; + page.global.gm_authFailure = () => before++; + const loader = createMapsLoader({ getApiKey: () => 'k', ...page }); + const heard = []; + loader.onAuthFailure(() => heard.push('a')); + const failed = loader.importLibrary('streetView'); + await settle(); + page.scripts[0].onerror(); + await assert.rejects(failed); + loader.importLibrary('streetView'); + await settle(); + page.global.gm_authFailure(); + assert.deepEqual(heard, ['a']); + assert.equal(before, 1); +}); diff --git a/src/layers/streetLevel/providers/google/policy.js b/src/layers/streetLevel/providers/google/policy.js index f3706b0c1..7493dd5f6 100644 --- a/src/layers/streetLevel/providers/google/policy.js +++ b/src/layers/streetLevel/providers/google/policy.js @@ -20,16 +20,24 @@ export const COLORS = Object.freeze({ export const GOOGLE_CREDIT_HTML = 'Street View imagery © Google'; -/** Shown when Google refuses the key for the Maps JavaScript API. */ +/** + * Shown when Google refuses the key (gm_authFailure): the Maps JavaScript API + * is not enabled for it, this site is not an allowed referrer, or billing is off. + */ export const KEY_REJECTED_MESSAGE = - 'Google rejected GOOGLE_MAPS_API_KEY for Street View — enable the Maps JavaScript API for it'; + 'Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer'; /** A nearest-panorama lookup that takes longer has failed. */ export const NEAREST_TIMEOUT_MS = 10_000; +/** A panorama that has not shown by then has failed to open. */ +export const OPEN_TIMEOUT_MS = 20_000; -/** Google answered nothing (most often: the Maps JavaScript API is not enabled). */ +/** Google answered nothing: the network, or a key Google ignores. */ export const NO_ANSWER_MESSAGE = - 'Street View did not answer — check that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY'; + 'Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY'; + +/** Under FLAT, in place of the click hint: every Street View photo is 360°. */ +export const FLAT_FILTER_HINT = 'Street View has 360° photos only'; /** 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'; @@ -55,6 +63,17 @@ export function imageDateMs(imageDate) { return Date.UTC(Number(match[1]), month - 1, 1); } +/** + * The last millisecond of an image's month: Google dates by month, so a + * "since" window keeps a panorama taken any time in its cut-off month. + */ +export function imageMonthEndMs(imageDate) { + const start = imageDateMs(imageDate); + if (start === null) return null; + const date = new Date(start); + return Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 1) - 1; +} + /** The photographer from a copyright line ('© 2024 Google' → 'Google'). */ export function creatorFrom(copyright) { const text = String(copyright ?? '') diff --git a/src/layers/streetLevel/providers/google/provider.test.mjs b/src/layers/streetLevel/providers/google/provider.test.mjs index 63861f376..874e3a94f 100644 --- a/src/layers/streetLevel/providers/google/provider.test.mjs +++ b/src/layers/streetLevel/providers/google/provider.test.mjs @@ -12,6 +12,7 @@ import { PROVIDER_COLORS } from '../../policy.js'; import { requiresKeyIdFor, validateProviders } from '../../registry.js'; import { createMapillaryProvider } from '../mapillary/index.js'; import { + fakeHost, fakeMapsLoader, fakeStreetViewLibrary, } from '../../../../testSupport/googleStreetViewFakes.mjs'; @@ -28,13 +29,27 @@ function setup({ key = 'browser-key', nearest = () => 'fresh' } = {}) { const def = createGoogleProvider({ getApiKey: () => apiKey, loader }); const reported = []; let filter = { pano: 'all', sinceMs: null }; + let active = false; const context = { services: {}, getFilter: () => filter, + isActive: () => active, notify() {}, actions: { reportError: (message) => reported.push(message) }, }; - const instance = def.create(context); + const created = def.create(context); + // The core switches it on and off; mirror that for isActive(). + const instance = { + ...created, + activate() { + active = true; + created.activate(); + }, + deactivate() { + active = false; + created.deactivate(); + }, + }; return { def, instance, @@ -106,7 +121,7 @@ test('no panorama nearby, or one the imagery filter excludes, is null', async () assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), 'old'); }); -test('an aborted lookup gives up before and after asking Google', async () => { +test('an aborted lookup gives up before asking Google', async () => { const { instance } = setup(); const controller = new AbortController(); controller.abort(); @@ -190,3 +205,52 @@ test('aborting a lookup in flight gives up at once', async () => { controller.abort(); await assert.rejects(lookup, { name: 'AbortError' }); }); + +test('a Google outage is an error, not "no imagery here"', async () => { + const { instance, fake } = setup(); + fake.library.StreetViewService.prototype.getPanorama = () => + Promise.reject( + Object.assign(new Error('UNKNOWN_ERROR'), { + code: 'UNKNOWN_ERROR', + endpoint: 'STREETVIEW_GET_PANORAMA', + }), + ); + await assert.rejects(instance.nearestImage({ lat: 0, lon: 0 }), { + code: 'UNKNOWN_ERROR', + }); +}); + +test('a refusal while the provider is off reports no error over another photo', () => { + const { instance, loader, reported } = setup(); + loader.refuseKey(); + assert.deepEqual(reported, []); + assert.equal( + instance.coverageStats().keyRejected, + true, + 'its chip still says so', + ); +}); + +test('a since window keeps a panorama taken any time in its cut-off month', async () => { + const { instance, setFilter } = setup({ nearest: () => 'fresh' }); // 2025-06 + setFilter({ pano: 'all', sinceMs: Date.UTC(2025, 5, 20) }); + assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), 'fresh'); + setFilter({ pano: 'all', sinceMs: Date.UTC(2025, 6, 1) }); + assert.equal(await instance.nearestImage({ lat: 0, lon: 0 }), null); +}); + +test('the panorama a lookup found is dated without a second lookup', async () => { + const { instance, fake } = setup(); + await instance.nearestImage({ lat: 38.58, lon: -121.49 }); + const host = fakeHost(); + const poses = []; + instance.viewer.onPose((pose) => poses.push(pose)); + await instance.viewer.mount(host); + await instance.viewer.open('fresh'); + assert.equal(poses.at(-1).capturedAt, Date.UTC(2025, 5, 1)); + assert.deepEqual( + fake.lookups.filter((request) => request.pano), + [], + 'no getPanorama({pano}) for a panorama the lookup already described', + ); +}); diff --git a/src/layers/streetLevel/providers/google/viewer.js b/src/layers/streetLevel/providers/google/viewer.js index c4a449820..bdcf445ed 100644 --- a/src/layers/streetLevel/providers/google/viewer.js +++ b/src/layers/streetLevel/providers/google/viewer.js @@ -1,14 +1,13 @@ import { GOOGLE_PROVIDER_ID, KEY_REJECTED_MESSAGE, + NO_ANSWER_MESSAGE, + OPEN_TIMEOUT_MS, 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, @@ -31,21 +30,26 @@ const PANORAMA_OPTIONS = Object.freeze({ * 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, render?: object}} options - * @returns {import('../../registry.js').ViewerAdapter} + * `getService` hands out the StreetViewService the provider shares. + * @param {{loader: ReturnType, getService?: () => Promise, render?: object}} options + * @returns {import('../../registry.js').ViewerAdapter & {remember: Function}} */ -export function createGoogleViewer({ loader, render } = {}) { +export function createGoogleViewer({ loader, getService, render } = {}) { let library = null; let panorama = null; let element = null; let host = null; let service = null; let pendingOpen = null; + /** Settles the open waiting for its panorama, when a close overtakes it. */ + let cancelShown = 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(); + /** Panorama id → its {capturedAt, creator, title}. */ + const described = new Map(); + /** Panorama ids whose lookup is in flight. */ + const lookingUp = new Set(); const listeners = new Set(); function requestRender() { @@ -67,41 +71,44 @@ export function createGoogleViewer({ loader, render } = {}) { } } + function metaFrom(data) { + return { + capturedAt: imageDateMs(data?.imageDate), + creator: creatorFrom(data?.copyright), + title: data?.location?.description || null, + }; + } + /** - * Date and photographer of a panorama, looked up once: pano_changed and - * position_changed arrive together, so the lookup itself is shared. + * Look up a panorama's date, photographer and address once; the pose + * already out is sent again with them. A failed lookup is asked again on + * the next event. */ - function describe(panoId) { - if (!metadata.has(panoId)) metadata.set(panoId, lookUp(panoId)); - return metadata.get(panoId); - } - - async function lookUp(panoId) { + async function describe(panoId) { + if (described.has(panoId) || lookingUp.has(panoId)) return; + lookingUp.add(panoId); try { - service ||= new library.StreetViewService(); + service ||= getService + ? await getService() + : new (await ensureLibrary()).StreetViewService(); const { data } = await service.getPanorama({ pano: panoId }); - return { - capturedAt: imageDateMs(data?.imageDate), - creator: creatorFrom(data?.copyright), - title: data?.location?.description || null, - }; + described.set(panoId, metaFrom(data)); } catch { - // The pose still goes out, without a date. - return { capturedAt: null, creator: null, title: null }; + return; + } finally { + lookingUp.delete(panoId); } + publishPose(); } - 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; + /** Emit the pose on screen now, dated once its lookup has answered. */ + function publishPose() { + const panoId = panorama?.getPano?.(); + const latLng = panorama?.getPosition?.(); + if (!panoId || !latLng || pendingOpen === null) return; + const meta = described.get(panoId); + if (!meta) describe(panoId); const pov = panorama.getPov() || {}; - const latLng = panorama.getPosition() || position; emit({ providerId: GOOGLE_PROVIDER_ID, imageId: panoId, @@ -110,10 +117,10 @@ export function createGoogleViewer({ loader, render } = {}) { tilt: Number.isFinite(pov.pitch) ? pov.pitch : 0, altitude: null, isPano: true, - capturedAt: meta.capturedAt, + capturedAt: meta?.capturedAt ?? null, capturedAtPrecision: 'month', - creator: meta.creator, - title: meta.title, + creator: meta?.creator ?? null, + title: meta?.title ?? null, sequenceId: null, externalUrl: streetViewUrl(panoId, { heading: pov.heading || 0, @@ -136,8 +143,10 @@ export function createGoogleViewer({ loader, render } = {}) { target.append(element); host = target; panorama = new StreetViewPanorama(element, PANORAMA_OPTIONS); - subscriptions = ['pano_changed', 'position_changed', 'pov_changed'].map( - (name) => panorama.addListener(name, () => publishPose()), + // Not pano_changed: it can fire before the new panorama's data, with the + // old position. A step along an arrow moves the position. + subscriptions = ['position_changed', 'pov_changed'].map((name) => + panorama.addListener(name, () => publishPose()), ); return panorama; } @@ -157,41 +166,42 @@ export function createGoogleViewer({ loader, render } = {}) { } /** - * 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). + * Resolves once `panoId` shows: an OK status, or (when the status was + * already OK and does not change) the new position. Rejects when Google has + * no imagery for it, refuses the key (reported apart from the status), or + * does not answer. Google may show a newer id for the same place. */ 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 timer = setTimeout( + () => finish(reject, new Error(NO_ANSWER_MESSAGE)), + OPEN_TIMEOUT_MS, + ); + const stopAuthWatch = loader.onAuthFailure(() => + finish(reject, new Error(KEY_REJECTED_MESSAGE)), + ); + cancelShown = () => finish(resolve); + const check = () => { + if (pendingOpen !== panoId) return finish(resolve); 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() { + if (status === 'OK') finish(resolve); + else if (status) + finish( + reject, + new Error('Street View has no imagery for this place'), + ); + }; + const subscriptions = ['status_changed', 'position_changed'].map((name) => + instance.addListener(name, check), + ); + function finish(settle, value) { + cancelShown = null; clearTimeout(timer); - subscription.remove(); + for (const subscription of subscriptions) subscription.remove(); stopAuthWatch(); + settle(value); } }); } @@ -215,11 +225,12 @@ export function createGoogleViewer({ loader, render } = {}) { instance.setVisible(true); await ready; if (pendingOpen !== panoId) return; - await publishPose(); + publishPose(); }, close() { pendingOpen = null; + cancelShown?.(); try { panorama?.setVisible(false); } catch { @@ -229,6 +240,7 @@ export function createGoogleViewer({ loader, render } = {}) { unmount() { pendingOpen = null; + cancelShown?.(); generation++; destroyPanorama(); }, @@ -255,5 +267,10 @@ export function createGoogleViewer({ loader, render } = {}) { listeners.add(listener); return () => listeners.delete(listener); }, + + /** Keep a lookup's answer, so the panorama it found needs no second one. */ + remember(panoId, data) { + if (panoId && data) described.set(String(panoId), metaFrom(data)); + }, }; } diff --git a/src/layers/streetLevel/providers/google/viewer.test.mjs b/src/layers/streetLevel/providers/google/viewer.test.mjs index ab7546a61..f6815bec1 100644 --- a/src/layers/streetLevel/providers/google/viewer.test.mjs +++ b/src/layers/streetLevel/providers/google/viewer.test.mjs @@ -153,7 +153,128 @@ test('a key Google refuses fails the open at once, not after the timeout', async 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/); + await assert.rejects(viewer.open('pano-a'), /Maps JavaScript API is enabled/); + await assert.rejects(viewer.open('pano-b'), /Maps JavaScript API is enabled/); assert.equal(loader.listenerCount(), 0, 'no auth listener is left behind'); }); + +test('a viewer unmounted while the library loads builds no panorama (none is billed)', async () => { + const fake = fakeStreetViewLibrary({ panoramas: PANORAMAS }); + const loader = fakeMapsLoader(fake.library, { deferred: true }); + const viewer = createGoogleViewer({ loader }); + const host = fakeHost(); + const mounting = viewer.mount(host); + viewer.unmount(); + loader.release(); + await assert.rejects(mounting, /unmounted/); + assert.equal(fake.built.length, 0); + assert.equal(host.children.length, 0); +}); + +test('a date that arrives after the photo was closed emits nothing', async () => { + const { fake, viewer, poses, host } = setup(); + await viewer.mount(host); + await viewer.open('pano-a'); + const [panorama] = fake.built; + let answer; + fake.library.StreetViewService.prototype.getPanorama = () => + new Promise((resolve) => { + answer = resolve; + }); + panorama.setPano('pano-b'); // Google's arrow; its date is still unknown + await settle(); + assert.equal(poses.at(-1).imageId, 'pano-b', 'the step shows at once'); + assert.equal( + poses.at(-1).capturedAt, + null, + 'undated until the lookup answers', + ); + viewer.close(); + const count = poses.length; + answer({ data: { imageDate: '2019-08' } }); + await settle(); + assert.equal(poses.length, count); +}); + +test('an open never waits on the date lookup', async () => { + const { fake, viewer, poses, host } = setup(); + await viewer.mount(host); + fake.library.StreetViewService.prototype.getPanorama = () => + new Promise(() => {}); // the metadata never answers + await viewer.open('pano-a'); + assert.equal(poses.at(-1).imageId, 'pano-a'); +}); + +test('closing while a panorama loads settles the open and drops its listeners', async () => { + const { fake, loader, viewer, host } = setup(); + await viewer.mount(host); + const [panorama] = fake.built; + const before = panorama.listenerCount(); + panorama.setPano = (id) => { + panorama.pano = id; // still loading + }; + const opening = viewer.open('pano-a'); + await settle(); + viewer.close(); + await opening; + assert.equal(panorama.listenerCount(), before); + assert.equal(loader.listenerCount(), 0); +}); + +test('a second open whose status stays OK still resolves, on the new position', async () => { + const { fake, viewer, poses, host } = setup(); + await viewer.mount(host); + await viewer.open('pano-a'); + assert.equal(fake.built[0].getStatus(), 'OK'); + await viewer.open('pano-b'); // OK → OK: no status_changed + assert.equal(poses.at(-1).imageId, 'pano-b'); +}); + +test('Google answering with a newer id for the place shows that id', async () => { + const fake = fakeStreetViewLibrary({ + panoramas: PANORAMAS, + substitutes: { 'old-id': 'pano-a' }, + }); + const viewer = createGoogleViewer({ loader: fakeMapsLoader(fake.library) }); + const poses = []; + viewer.onPose((pose) => poses.push(pose)); + await viewer.mount(fakeHost()); + await viewer.open('old-id'); + assert.equal(poses.at(-1).imageId, 'pano-a'); +}); + +test('a panorama that never shows fails after the timeout and leaves no listener', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { OPEN_TIMEOUT_MS, NO_ANSWER_MESSAGE } = await import('./policy.js'); + const { fake, loader, viewer, host } = setup(); + await viewer.mount(host); + const [panorama] = fake.built; + const before = panorama.listenerCount(); + panorama.setPano = (id) => { + panorama.pano = id; // Google never answers + }; + const opening = viewer.open('pano-a'); + await Promise.resolve(); + await Promise.resolve(); + t.mock.timers.tick(OPEN_TIMEOUT_MS); + await assert.rejects(opening, { message: NO_ANSWER_MESSAGE }); + assert.equal(panorama.listenerCount(), before); + assert.equal(loader.listenerCount(), 0); +}); + +test('a date lookup that failed is asked again on the next view change', async () => { + const { fake, viewer, poses, host } = setup(); + await viewer.mount(host); + const getPanorama = fake.library.StreetViewService.prototype.getPanorama; + let failures = 1; + fake.library.StreetViewService.prototype.getPanorama = function (request) { + if (failures-- > 0) return Promise.reject(new Error('offline')); + return getPanorama.call(this, request); + }; + await viewer.open('pano-a'); + assert.equal(poses.at(-1).capturedAt, null, 'undated while it failed'); + fake.built[0].setPov({ heading: 10, pitch: 0 }); + await settle(); + await settle(); + assert.equal(poses.at(-1).capturedAt, Date.UTC(2024, 4, 1)); +}); diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index 0d36929d0..7be23c54e 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -10,11 +10,12 @@ * @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 {string} [requiresMapStack] Usable only on this map stack (Google Street View: 'photoreal', per Google's terms). * @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. - * @property {object} services Scene services: picking, input, render, sprites, ground, terrain. + * @property {object} services Scene services: picking, input, render, sprites, ground, terrain, scenePick (ground clicks need it). * @property {() => {pano: string, sinceMs: number|null}} getFilter Filter resolved against now; read it on every use so "since N days" keeps up. * @property {() => boolean} isActive Layer enabled and this provider switched on. * @property {() => 'draped'|'terrain'} getSurface 'terrain' on Google 3D at street zoom: draw on bare earth. diff --git a/src/layers/streetLevel/selection.js b/src/layers/streetLevel/selection.js index 2c1984d96..429608449 100644 --- a/src/layers/streetLevel/selection.js +++ b/src/layers/streetLevel/selection.js @@ -21,9 +21,9 @@ export function createSelection({ state, parts }) { ? picking.resolvePickId(picked) : picked?.id; const route = parts.router.resolve(id); - // Nothing of ours, nor anyone else's: the ground itself. + // Not ours: the ground handler decides whether it is the map itself. if (!route?.instance) { - if (!id) parts.openAtGround?.(click.position); + parts.openAtGround?.(click.position, picked); return; } const entry = state.providers.get(route.providerId); diff --git a/src/layers/streetLevel/selection.test.mjs b/src/layers/streetLevel/selection.test.mjs index fd9d62221..f35cd4361 100644 --- a/src/layers/streetLevel/selection.test.mjs +++ b/src/layers/streetLevel/selection.test.mjs @@ -67,7 +67,8 @@ function harness({ selected = false } = {}) { parts: { router: { ownsPick: (id) => id === 'mly:seq:1', resolve: () => null }, hasSelectedSequence: () => sequences.selected, - openAtGround: (position) => groundClicks.push(position), + openAtGround: (position, picked) => + groundClicks.push({ position, picked }), clearSequences() { sequences.cleared++; sequences.selected = false; @@ -264,15 +265,19 @@ test('Esc in a text field or rich-text editor keeps the selection (M61)', () => } }); -test('a click on bare ground goes to the ground handler; a click on anything with an id does not', () => { +test('a click on nothing of ours goes to the ground handler with the pick, which decides', () => { 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); + assert.deepEqual( + h.groundClicks.map(({ position, picked }) => [position.x, picked?.id]), + [ + [5, undefined], + [6, 'flight:abc'], + ], + ); } finally { h.restore(); } diff --git a/src/layers/streetLevel/state.js b/src/layers/streetLevel/state.js index 127565ab6..99abeae06 100644 --- a/src/layers/streetLevel/state.js +++ b/src/layers/streetLevel/state.js @@ -56,6 +56,9 @@ export function createState({ services }) { ...freshStreet(), }, + /** Whether the camera is low enough for a ground click to open an image. */ + groundClickReady: false, + /** 'terrain' on Google 3D at street zoom (overlays on the bare earth), else 'draped'. */ surface: 'draped', diff --git a/src/layers/streetLevel/view.js b/src/layers/streetLevel/view.js index b7985aed9..09bc516ed 100644 --- a/src/layers/streetLevel/view.js +++ b/src/layers/streetLevel/view.js @@ -295,34 +295,3 @@ 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), - }; -} diff --git a/src/layers/streetLevel/viewerHost.js b/src/layers/streetLevel/viewerHost.js index 31e9144dc..913dae78a 100644 --- a/src/layers/streetLevel/viewerHost.js +++ b/src/layers/streetLevel/viewerHost.js @@ -79,7 +79,6 @@ export function createViewerHost({ state, parts }) { 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) { @@ -139,6 +138,8 @@ export function createViewerHost({ state, parts }) { try { const adapter = await mount(entry); if (!current()) return false; + // Per open: close() resets it, and a reopen skips mount(). + state.street.renderModes = typeof adapter.setRenderMode === 'function'; await adapter.open(String(imageId)); if (!current()) return false; if (ticket && !state.street.follow) parts.follow.lookAtPosition(ticket); @@ -201,9 +202,11 @@ export function createViewerHost({ state, parts }) { } catch { /* the real open reports errors */ } - if (active?.id === entry.def.id) continue; - // Switched off while it loaded: release it now rather than keep it. - if (!state.enabled || !entry.on) adapter.unmount(); + if (active?.id === entry.def.id || mounting?.id === entry.def.id) + continue; + // Switched off while it loaded, or another provider's viewer took the + // host meanwhile: release it now rather than keep it under that one. + if (!state.enabled || !entry.on || active || mounting) adapter.unmount(); else warmed.set(entry.def.id, adapter); } } diff --git a/src/layers/streetLevel/viewerHost.test.mjs b/src/layers/streetLevel/viewerHost.test.mjs index 005f87bd3..f7da6412e 100644 --- a/src/layers/streetLevel/viewerHost.test.mjs +++ b/src/layers/streetLevel/viewerHost.test.mjs @@ -633,3 +633,29 @@ test('a pose can date its image by month and name its place', async () => { ['day', null], ); }); + +test('a prewarm that lands after another provider took the host is released, not built under it', async () => { + const { state, host, mapillary } = twoProviders(); + let release; + mapillary.prewarm = () => + new Promise((resolve) => { + release = resolve; + }); + const warming = host.prewarm([state.providers.get('mapillary')]); + await host.open('google', 'g-1'); // the user opens Street View meanwhile + release(); + await warming; + assert.equal(mapillary.calls.unmount, 1, 'released at once'); + // Not kept warm: the next Mapillary open mounts afresh. + await host.open('mapillary', 'm-1'); + assert.equal(state.street.providerId, 'mapillary'); +}); + +test('FIT/FILL stays hidden for a second Street View panorama after a close', async () => { + const { state, host } = twoProviders(); + await host.open('google', 'g-1'); + assert.equal(state.street.renderModes, false); + host.close(); + await host.open('google', 'g-2'); + assert.equal(state.street.renderModes, false); +}); diff --git a/src/testSupport/googleStreetViewFakes.mjs b/src/testSupport/googleStreetViewFakes.mjs index 67d93c4d6..81816d4f7 100644 --- a/src/testSupport/googleStreetViewFakes.mjs +++ b/src/testSupport/googleStreetViewFakes.mjs @@ -1,7 +1,11 @@ /** * 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. + * loader that hands it out. Panoramas are {id: {lat, lng, imageDate, + * copyright, description}}; `nearest(location, radius)` answers location + * lookups with an id or null; `substitutes` maps an id Google answers with a + * newer one. Like Google, `setPano` sets the id (pano_changed) at once, and + * the position and status follow when the data arrives; status_changed fires + * only when the status changes. */ function latLng(lat, lng) { @@ -11,6 +15,7 @@ function latLng(lat, lng) { export function fakeStreetViewLibrary({ panoramas = {}, nearest = () => null, + substitutes = {}, } = {}) { const built = []; const lookups = []; @@ -22,6 +27,7 @@ export function fakeStreetViewLibrary({ this.visible = options?.visible ?? true; this.pano = null; this.status = null; + this.position = null; this.pov = { heading: 0, pitch: 0 }; this.listeners = new Map(); built.push(this); @@ -43,18 +49,27 @@ export function fakeStreetViewLibrary({ return count; } - /** Like Google: the panorama loads asynchronously, then reports. */ + /** Like Google: the id is set at once; the data loads asynchronously. */ setPano(id) { this.pano = id; + this.fire('pano_changed'); queueMicrotask(() => { if (this.pano !== id) return; - const known = panoramas[id]; - this.status = known ? 'OK' : 'ZERO_RESULTS'; - if (known) { + const shownId = substitutes[id] ?? id; + const known = panoramas[shownId]; + const status = known ? 'OK' : 'ZERO_RESULTS'; + if (shownId !== id) { + this.pano = shownId; this.fire('pano_changed'); + } + if (known) { + this.position = latLng(known.lat, known.lng); this.fire('position_changed'); } - this.fire('status_changed'); + if (status !== this.status) { + this.status = status; + this.fire('status_changed'); + } }); } @@ -67,8 +82,7 @@ export function fakeStreetViewLibrary({ } getPosition() { - const known = panoramas[this.pano]; - return known ? latLng(known.lat, known.lng) : null; + return this.position; } getPov() { @@ -121,17 +135,28 @@ export function fakeStreetViewLibrary({ }; } -/** A loader over `library`; `refuseKey()` plays Google's gm_authFailure. */ -export function fakeMapsLoader(library) { +/** + * A loader over `library`; `refuseKey()` plays Google's gm_authFailure. With + * `deferred`, imports wait for `release()`. + */ +export function fakeMapsLoader(library, { deferred = false } = {}) { let failed = false; const listeners = new Set(); const imports = []; + let release = null; + const gate = deferred + ? new Promise((resolve) => { + release = resolve; + }) + : null; return { imports, async importLibrary(name) { imports.push(name); + await gate; return library; }, + release: () => release?.(), authFailed: () => failed, onAuthFailure(listener) { listeners.add(listener); diff --git a/src/tools/globePanel.js b/src/tools/globePanel.js index 226bd0528..ae5fe5ae3 100644 --- a/src/tools/globePanel.js +++ b/src/tools/globePanel.js @@ -37,6 +37,11 @@ const PROVIDER_ORIGINS = Object.freeze([ 'https://fonts.googleapis.com', 'https://fonts.gstatic.com', 'https://graph.mapillary.com', + // Google Street View's tiles, controls and contributed photos (the Maps + // JavaScript API itself comes from maps.googleapis.com above). + 'https://streetviewpixels-pa.googleapis.com', + 'https://maps.gstatic.com', + 'https://*.ggpht.com', // Mapillary photos come from regional hosts, e.g. scontent-man2-1.xx.fbcdn.net. 'https://*.xx.fbcdn.net', ]); diff --git a/src/tools/globePanel.test.mjs b/src/tools/globePanel.test.mjs index 5c65596bf..34cbadd06 100644 --- a/src/tools/globePanel.test.mjs +++ b/src/tools/globePanel.test.mjs @@ -169,3 +169,20 @@ test('the package exports the panel resource and its runtime together', async () assert.equal(panel.PANEL_BASE, '/panel/'); assert.match(resource.text, /panelRuntime|function/); }); + +test('the panel lets Street Level reach Google Street View from inside the embed', () => { + const { csp } = createGlobePanelResource({ + runtime: panelRuntime, + panelKey: 'test-key', + })._meta.ui; + // The API script and lookups, panorama tiles, control images, contributed photos. + for (const url of [ + 'https://maps.googleapis.com/maps/api/js?key=k&v=weekly', + 'https://streetviewpixels-pa.googleapis.com/v1/tile?panoid=p&x=0&y=0&zoom=1', + 'https://maps.gstatic.com/mapfiles/api-3/images/cb_scout5_hdpi.png', + 'https://lh3.ggpht.com/p/photo.jpg', + ]) { + assert.ok(allows(csp.connectDomains, url), `connect-src allows ${url}`); + assert.ok(allows(csp.resourceDomains, url), `img-src allows ${url}`); + } +}); diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index 4b84c03cb..be6bb6f88 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -80,6 +80,8 @@ function presentProviders(state) { else if (keyRequired && provider.requiresKeyId) title = `${provider.name}: ${keySetupRequirement(provider.requiresKeyId)}`; else if (provider.error) title = `${provider.name}: ${provider.error}`; + // Usable only on another map stack (Street View off Google 3D). + else if (provider.unavailable) title = provider.unavailable; return { id: provider.id, label: provider.label, @@ -89,11 +91,13 @@ function presentProviders(state) { disabled: false, state: keyRequired ? 'error' - : on && provider.loading - ? 'loading' - : on - ? 'active' - : 'idle', + : on && provider.unavailable + ? 'warn' + : on && provider.loading + ? 'loading' + : on + ? 'active' + : 'idle', busy: on && provider.loading === true, }; }); diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index 1235e0766..af353407f 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -183,6 +183,12 @@ button.sl-status:focus-visible { border-color: rgba(255, 179, 0, 0.55); } +/* On, but not usable on this map stack (Street View off Google 3D). */ +.sl-chips .data-toggle-chip.chip-warn { + opacity: 0.7; + border-style: dashed; +} + /* ── Errors ──────────────────────────────────────────────────────────────── */ .sl-error { font-family: var(--font-sans);