From ef396655fc5989968b6ccfe5ee0ec76150df3ae7 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 8 Oct 2026 12:55:08 +0100 Subject: [PATCH] feat(street-level): OPEN STREET VIEW, and Street View only on Google 3D - OPEN STREET VIEW in the PROVIDERS row opens Street View's nearest panorama at the ground under the middle of the screen, without moving the camera: Street View no longer needs a pointer. It is disabled (and says so) until the camera is at street zoom. - Off the Google 3D map Street View does not appear at all: no chip, legend swatch, globe credit, layer source or button, and Google's script is not loaded (the viewer has no prewarm; the script loads on first use). The share option keeps the user's choice for when Google 3D returns. - A ground click measures the camera height at the click rather than reading the cached flag. - A chip without a usable key explains it instead of lighting; it darkens beside a lit chip, and the other chips keep switching, so a refused Street View key cannot lock Mapillary out. - The gate checks that nothing of Street View shows off Google 3D even when a share link switches it on, presses OPEN STREET VIEW on Google 3D, and asserts that each chip it switches really switched. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +- README.md | 2 +- docs/CURRENT-STATE.md | 20 ++- scripts/qa-street-level.mjs | 140 +++++++++++++----- src/app/layers/streetLevel.test.mjs | 11 +- src/layers/streetLevel/groundClick.js | 15 +- src/layers/streetLevel/groundClick.test.mjs | 14 +- src/layers/streetLevel/index.js | 85 +++++++++-- src/layers/streetLevel/index.test.mjs | 86 +++++++++-- .../streetLevel/providers/google/viewer.js | 9 -- .../providers/google/viewer.test.mjs | 13 +- src/layers/streetLevel/uiState.js | 3 + src/ui/streetLevelControls.js | 39 ++++- src/ui/streetLevelControls.test.mjs | 70 +++++++++ src/ui/streetLevelPresentation.js | 45 ++++-- src/ui/streetLevelPresentation.test.mjs | 49 ++++++ src/ui/styles/street-level.css | 10 +- src/ui/templates/layer-panels.html | 1 + 18 files changed, 505 insertions(+), 110 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f44267f83..7b1cb05cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,7 +66,8 @@ - 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 + Maps JavaScript API) and click a street, or press OPEN STREET VIEW, to + open Google's own panorama in the panel, with its arrows, the address and month in the caption, a link to Google Maps and FOLLOW. Share links carry it as `g`. diff --git a/README.md b/README.md index 02558f41c..5f7150780 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 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"). +**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. On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND**, **FIT / FILL** (Mapillary), and **FOLLOW** (the globe camera follows the photo's view; Google 3D map only). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google"). Drag the panel header to float the panel, and resize it from any edge. Double-click the header or collapse it to put it back in the rail. Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.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/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1e8b8936e..66ad33e52 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3060,7 +3060,10 @@ ground level. Framing a photo ignores mesh samples far below the bare earth (unloaded tiles). The header pill is the layer switch. With one provider its chip is a layer -switch too; with several, darkening the last lit chip turns the layer off. The +switch too; with several, darkening the last lit chip turns the layer off. A +chip without a usable key explains the key instead of lighting (or of +switching the layer off as the last lit chip); it still darkens beside a lit +one, and the other chips keep switching. The viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and stops when the stack changes. Camera moves go through the application's @@ -3090,14 +3093,18 @@ 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'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 +off Google 3D it does not appear at all (no chip, legend swatch, globe +credit, layer source or button, and Google's script is not loaded), lookups +and opens skip it, and leaving Google 3D closes its panorama; the share +option keeps the user's choice for when Google 3D returns. Google offers no coverage layer for other maps, so it draws none: with it on, a click on the map itself (`scenePick.isSurfacePick`: nothing picked or Google 3D tile content, so a Mapillary line under the pointer wins; no tool holding the pointer; camera below 3 km) opens its nearest Google-collected outdoor panorama within 50 m and leaves the camera where it is (`groundClick.js`). Higher up the hint -says to zoom in. The viewer is Google's `StreetViewPanorama` in its own +says to zoom in. **OPEN STREET VIEW** in the PROVIDERS row (`#sl-nearest-btn`) +does the same at the view centre, so Street View needs no pointer; it is +disabled until the camera is at street zoom. 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 @@ -3105,8 +3112,9 @@ 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`. +(no Google key, no Google 3D) the gate checks that nothing of Street View +shows off Google 3D, even switched on by a share link; the click and button +step skips as `no Google key`. Street Level has two browser gates. `npm run qa:street-level -- --url ` runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index b2d33360e..81cf50821 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -653,7 +653,20 @@ async function main() { ), ].map((chip) => chip.dataset.chipId), ); - assert.deepEqual(chips, EXPECTED_PROVIDERS); + // Every registered provider usable on this map, in order (Street View + // only on Google 3D). + const listed = await page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.getUIState() + .providers.map((provider) => provider.id), + ); + assert.deepEqual(chips, listed); + assert.deepEqual( + EXPECTED_PROVIDERS.filter((id) => listed.includes(id)), + listed, + ); + assert.ok(listed.includes('mapillary')); // One swatch per switched-on source plus "Selected" (Street View // starts off); each chip wears its colour. const legend = await page.evaluate(() => ({ @@ -1733,35 +1746,57 @@ async function main() { Boolean(window.__GOOGLE_MAPS_API_KEY__), ); await step( - 'Street View starts off, lights from its chip, and travels in the share link', + 'Street View starts off, appears only on the Google 3D map, and travels in the share link', async () => { - const read = () => - page.$eval( - '#sl-provider-chips [data-chip-id="google"]', - (node) => ({ - active: node.classList.contains('active'), - className: node.className, - title: node.title, - color: node.style.getPropertyValue('--chip-color'), - }), - ); - const off = await read(); + const module = () => + window.__godsEyeView.dataManager.layers.get('street-level').module; assert.equal( - off.active, + await page.evaluate(`(${module})().getParams().google`), false, 'off until lit: panoramas are billed', ); - assert.equal(off.color, '#4285f4'); - await clickPanelControl(page, googleChip); - await uiUntil( - (u) => u.providers.find((p) => p.id === 'google')?.on === true, + const setStreetView = (on) => + page.evaluate( + (value) => + window.__godsEyeView.dataManager.setLayerParams( + 'street-level', + { google: value }, + { origin: 'user' }, + ), + on, + ); + const shown = () => + page.evaluate(() => ({ + chip: Boolean( + document.querySelector( + '#sl-provider-chips [data-chip-id="google"]', + ), + ), + legend: [...document.querySelectorAll('#sl-legend li')].some( + (item) => /Street View/.test(item.textContent), + ), + credit: document.body.innerHTML.includes('Street View imagery'), + button: !document.getElementById('sl-nearest-btn').hidden, + })); + const original = await stacks.active(); + // Off Google 3D (CI's only map) nothing of Street View shows, even + // switched on: Google's terms keep it off other maps. + if (original === 'photoreal') await stacks.set('esri-imagery'); + await setStreetView(true); + await uiUntil((u) => u.providers.every((p) => p.id !== 'google')); + assert.ok( + await settle( + page, + () => !document.body.innerHTML.includes('Street View imagery'), + ), + 'no Google credit on another map', ); - const lit = await read(); - if (googleKey) assert.match(lit.className, /chip-active/); - else { - assert.match(lit.className, /chip-error/, 'no key: an error chip'); - assert.match(lit.title, /GOOGLE_MAPS_API_KEY/); - } + assert.deepEqual(await shown(), { + chip: false, + legend: false, + credit: false, + button: false, + }); const option = `${STREET_LEVEL_TOKEN}.g.1`; assert.ok( await settle( @@ -1772,13 +1807,34 @@ async function main() { .includes(token), option, ), - `the share link carries ${option}`, + `the share link keeps the choice: ${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); + if (await stacks.photoreal()) { + await stacks.set('photoreal'); + await uiUntil((u) => u.providers.some((p) => p.id === 'google')); + const chipNow = await page.$eval(googleChip, (node) => ({ + className: node.className, + title: node.title, + color: node.style.getPropertyValue('--chip-color'), + })); + assert.equal(chipNow.color, '#4285f4'); + if (googleKey) assert.match(chipNow.className, /chip-active/); + else { + assert.match(chipNow.className, /chip-error/); + assert.match(chipNow.title, /GOOGLE_MAPS_API_KEY/); + } + assert.ok( + await settle(page, () => + document.body.innerHTML.includes('Street View imagery'), + ), + 'the Google credit shows on Google 3D', + ); + } + // Back off, as the next steps expect. + await setStreetView(false); + await stacks.set(original); await uiUntil( - (u) => u.providers.find((p) => p.id === 'google')?.on === false, + (u) => u.providers.find((p) => p.id === 'google')?.on !== true, ); }, ); @@ -1799,10 +1855,13 @@ async function main() { const now = (await ui()).providers.find((p) => p.id === id)?.on; if (now === on) return; await clickPanelControl(page, selector); - await uiUntil( - (u, want) => - u.providers.find((p) => p.id === want.id)?.on === want.on, - { id, on }, + assert.ok( + await uiUntil( + (u, want) => + u.providers.find((p) => p.id === want.id)?.on === want.on, + { id, on }, + ), + `the ${id} chip switched ${on ? 'on' : 'off'}`, ); }; await closePhoto(); @@ -1918,6 +1977,21 @@ async function main() { ), 'the pose bearing follows the drag', ); + // The keyboard path: OPEN STREET VIEW at the view centre. + await closePhoto(); + await uiUntil((u) => u.street.open === false); + await clickPanelControl(page, '#sl-nearest-btn'); + assert.ok( + await uiUntil( + (u) => + u.street.providerId === 'google' && + Boolean(u.street.imageId) && + !u.street.loading, + null, + { timeout: 30_000 }, + ), + 'OPEN STREET VIEW opens the panorama at the view centre', + ); // Leaving Google 3D closes the panorama (Google's terms). await stacks.set('esri-imagery'); assert.ok( diff --git a/src/app/layers/streetLevel.test.mjs b/src/app/layers/streetLevel.test.mjs index b61eb7ccf..7a2a95962 100644 --- a/src/app/layers/streetLevel.test.mjs +++ b/src/app/layers/streetLevel.test.mjs @@ -16,12 +16,9 @@ test('each provider starts as its share-link option does, so a fresh page and a }, }); 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); + // Every registered provider, listed on this map or not. + const started = layer.getParams(); + for (const id of layer.providerIds) + assert.equal(started[id], options[id], id); assert.equal(options.google, false, 'Street View is billed: off'); }); diff --git a/src/layers/streetLevel/groundClick.js b/src/layers/streetLevel/groundClick.js index ff95d3e11..0f69f74d8 100644 --- a/src/layers/streetLevel/groundClick.js +++ b/src/layers/streetLevel/groundClick.js @@ -15,15 +15,24 @@ export function groundClickHeightOk(viewer, groundAt) { * 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 + * The height is measured at the click, not read from the cached flag. + * @param {{state: object, openNearest: Function, isAvailable: (entry: object) => boolean, groundAt?: Function}} options * @returns {(screenPosition: {x: number, y: number}, picked?: object) => Promise|false} */ -export function createGroundClick({ state, openNearest, isAvailable }) { +export function createGroundClick({ + state, + openNearest, + isAvailable, + groundAt = null, +}) { return function openAtGround(screenPosition, picked) { const scenePick = state.services.scenePick; if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition) return false; - if (!scenePick.isSurfacePick(picked) || !state.groundClickReady) + if ( + !scenePick.isSurfacePick(picked) || + !groundClickHeightOk(state.viewer, groundAt) + ) return false; const providerIds = [...state.providers.values()] .filter( diff --git a/src/layers/streetLevel/groundClick.test.mjs b/src/layers/streetLevel/groundClick.test.mjs index f47285939..b7cfd2b7e 100644 --- a/src/layers/streetLevel/groundClick.test.mjs +++ b/src/layers/streetLevel/groundClick.test.mjs @@ -16,7 +16,7 @@ function harness({ { id: 'mapillary', groundClick: false }, { id: 'google', groundClick: true }, ], - ready = true, + height = 400, enabled = true, picked = GROUND, ellipsoid = undefined, @@ -24,7 +24,6 @@ function harness({ const opened = []; const state = { enabled, - groundClickReady: ready, services: { scenePick }, providers: new Map( providers.map( @@ -42,7 +41,14 @@ function harness({ ), viewer: { scene: { pickPositionSupported: true, pickPosition: () => picked }, - camera: { pickEllipsoid: () => ellipsoid }, + camera: { + pickEllipsoid: () => ellipsoid, + positionCartographic: Cesium.Cartographic.fromDegrees( + -121.4944, + 38.5816, + height, + ), + }, }, }; const openAtGround = createGroundClick({ @@ -87,7 +93,7 @@ test('nothing opens over another layer’s object, the sky, from high up, off it { 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: { height: 5000 }, label: 'high up' }, { options: { providers: [{ id: 'google', groundClick: true, available: false }], diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 382011c5a..6cee26b71 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -1,3 +1,4 @@ +import * as Cesium from 'cesium'; import { createState } from './state.js'; import { createMarker } from './marker.js'; import { createCameraFollow } from './cameraFollow.js'; @@ -71,6 +72,7 @@ export function createStreetLevelLayer({ state, openNearest: (point, options) => layer.openNearest(point, options), isAvailable: (entry) => providerAvailable(entry), + groundAt: parts.groundCaster?.groundAt ?? null, }); let notifyQueued = false; @@ -155,11 +157,23 @@ export function createStreetLevelLayer({ return !required || mapStack?.getActiveId?.() === required; } - /** Leaving a provider's map stack closes the image it shows. */ + /** + * A provider appears only on its map stack: leaving it closes the image it + * shows, releases its viewer and takes its credit off the globe. + */ function syncProviderAvailability() { const owner = state.providers.get(state.street.providerId); if (state.street.open && owner && !providerAvailable(owner)) layer.closeViewer(); + for (const entry of state.providers.values()) { + if (!entry.def.requiresMapStack) continue; + if (state.enabled && entry.on && providerAvailable(entry)) + parts.credits.show(state.viewer, entry.def); + else { + parts.credits.hide(state.viewer, entry.def); + parts.viewerHost.unmount(entry.def.id); + } + } notify(); } @@ -256,9 +270,14 @@ export function createStreetLevelLayer({ function activate(entry) { if (!state.viewer) return; entry.instance.activate(state.viewer); - parts.credits.show(state.viewer, entry.def); if (!entry.status) refreshStatus(entry); - whenIdle(() => parts.viewerHost.prewarm([entry]), 1500); + // Off its map stack a provider shows nothing: no credit, no viewer load. + if (!providerAvailable(entry)) return; + parts.credits.show(state.viewer, entry.def); + // Checked again when it runs: the map may have changed meanwhile. + whenIdle(() => { + if (providerAvailable(entry)) parts.viewerHost.prewarm([entry]); + }, 1500); } function deactivate(entry) { @@ -275,6 +294,24 @@ export function createStreetLevelLayer({ nearestLookup = null; } + /** The ground under the middle of the screen, or null (no scenePick, sky). */ + function screenCentreGround() { + const canvas = state.viewer?.scene?.canvas; + const pick = state.services.scenePick?.pickGroundPosition; + if (!pick || !canvas?.clientWidth || !canvas?.clientHeight) return null; + const position = pick(state.viewer, { + x: canvas.clientWidth / 2, + y: canvas.clientHeight / 2, + }); + const carto = position && Cesium.Cartographic.fromCartesian(position); + return carto + ? { + lon: Cesium.Math.toDegrees(carto.longitude), + lat: Cesium.Math.toDegrees(carto.latitude), + } + : null; + } + /** `frame: false` keeps the camera where it is (the user picked the spot). */ function openImage(providerId, imageId, { frame = true } = {}) { abortNearest(); @@ -317,16 +354,18 @@ export function createStreetLevelLayer({ /** 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 || ''; } + /** Providers usable on this map stack; the others are not shown at all. */ + const listedEntries = () => + [...state.providers.values()].filter((entry) => providerAvailable(entry)); + function providerSnapshots() { - return [...state.providers.values()].map((entry) => { + return listedEntries().map((entry) => { const stats = entry.instance.coverageStats(); - const available = providerAvailable(entry); return { id: entry.def.id, name: entry.def.name, @@ -342,8 +381,8 @@ export function createStreetLevelLayer({ 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), + /** No coverage to click: it opens where the user points. */ + groundClick: entry.def.groundClick === true, }; }); } @@ -371,6 +410,7 @@ export function createStreetLevelLayer({ const { host, ...street } = state.street; return composeUIState({ enabled: state.enabled, + groundClickReady: state.groundClickReady, filter: state.filter, providers: providerSnapshots(), street, @@ -383,7 +423,12 @@ export function createStreetLevelLayer({ id: STREET_LEVEL_LAYER_ID, name: 'Street Level', icon: '📷', - source: definitionsFrozen.map((def) => def.name).join(' · '), + /** The sources usable on this map (Street View only on Google 3D). */ + get source() { + return listedEntries() + .map((entry) => entry.def.name) + .join(' · '); + }, updateInterval: 0, statsRefreshInterval: 1000, /** The key the switched-on providers share (or null), for the layer row. */ @@ -463,6 +508,7 @@ export function createStreetLevelLayer({ else if (coverage.loading) loadingLabel = 'loading coverage...'; else if (coverage.hint && state.enabled) loadingLabel = coverage.hint; return { + source: layer.source, count: coverage.count, loading: coverage.loading, keyRequired: coverage.keyRequired, @@ -530,7 +576,13 @@ export function createStreetLevelLayer({ attachViewerHost(element) { parts.viewerHost.attach(element); if (element && state.enabled) - whenIdle(() => parts.viewerHost.prewarm(activeEntries()), 1500); + whenIdle( + () => + parts.viewerHost.prewarm( + activeEntries().filter((entry) => providerAvailable(entry)), + ), + 1500, + ); }, setProviderEnabled, /** Imagery filter for coverage, cones and nearest-image lookups. */ @@ -538,10 +590,17 @@ export function createStreetLevelLayer({ openImage, /** * Open the nearest image any active provider (or only `providerIds`) - * has around a point, by default the view centre. + * has around a point; without one, around the view centre, or with + * `aim: 'screen-centre'` the ground under the middle of the screen. */ - async openNearest(point, { providerIds = null, frame = true } = {}) { - const view = point || viewCentre(state.viewer); + async openNearest( + point, + { providerIds = null, frame = true, aim = 'view' } = {}, + ) { + const view = + point || + (aim === 'screen-centre' && screenCentreGround()) || + viewCentre(state.viewer); if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon)) return false; abortNearest(); diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index a46254901..88d921e38 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -502,15 +502,16 @@ async function withStreetView(t, { mapStack = 'photoreal', nearest } = {}) { }, }); mapillary.requiresKeyId = 'mapillary'; + const loader = fakeMapsLoader(fake.library); const google = createGoogleProvider({ getApiKey: () => 'browser-key', - loader: fakeMapsLoader(fake.library), + loader, }); const { layer, viewer } = await enabledLayer(t, [mapillary, google]); const stack = fakeMapStack(mapStack); layer.attachMapStackController(stack); layer.attachViewerHost(fakeHost()); - return { layer, viewer, stack, fake, mapillaryLookups }; + return { layer, viewer, stack, fake, loader, mapillaryLookups }; } test('Street View starts off, and the layer row names the key of the providers that are on', async (t) => { @@ -523,27 +524,42 @@ test('Street View starts off, and the layer row names the key of the providers t 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, { +test('Street View appears only on the Google 3D map: chip, legend, credit, source and images', async (t) => { + const { layer, viewer, stack } = await withStreetView(t, { mapStack: 'esri-imagery', }); layer.setProviderEnabled('google', true); await settle(); - const 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'); + const ui = () => layer.getUIState(); + const listed = () => ui().providers.map((p) => p.id); + const credited = () => + viewer.credits.some((credit) => /Street View imagery/.test(credit.html)); + assert.deepEqual(listed(), ['mapillary'], 'no Street View chip'); + assert.ok(!ui().legend.some((item) => item.key === 'google')); + assert.equal(credited(), false, 'no Google credit on another map'); + assert.equal(layer.source, 'Mapillary'); + assert.equal(layer.getStats().source, 'Mapillary'); assert.equal(await layer.openImage('google', 'pano-a'), false); - assert.match(layer.getUIState().street.error, /needs the Google 3D map/); + assert.match(ui().street.error, /needs the Google 3D map/); + assert.equal(layer.getParams().google, true, 'the choice is kept'); stack.switchTo('photoreal'); - assert.equal(google().unavailable, null); + assert.deepEqual(listed(), ['mapillary', 'google']); + assert.equal(credited(), true); + assert.equal(layer.source, 'Mapillary · Google Street View'); assert.equal(await layer.openImage('google', 'pano-a'), true); - assert.equal(layer.getUIState().street.providerId, 'google'); + assert.equal(ui().street.providerId, 'google'); stack.switchTo('bing'); await settle(); - assert.equal(layer.getUIState().street.open, false, 'closed with the map'); + assert.equal(ui().street.open, false, 'closed with the map'); + assert.equal(credited(), false); + assert.deepEqual(listed(), ['mapillary']); +}); + +test('the UI state says when a click (or the button) can open Street View here', async (t) => { + const { layer } = await withStreetView(t); + assert.equal(typeof layer.getUIState().groundClickReady, 'boolean'); }); test('a lookup for the click-to-open providers asks only them and reports "that point"', async (t) => { @@ -575,3 +591,49 @@ test('an image opened from a ground click leaves the camera where it is', async assert.equal(layer.getUIState().street.imageId, 'pano-a'); assert.equal(viewer.flights.started, 0, 'no framing flight'); }); + +test('off Google 3D not even Google\u2019s script is loaded for Street View', async (t) => { + const { layer, loader } = await withStreetView(t, { + mapStack: 'esri-imagery', + }); + layer.setProviderEnabled('google', true); + layer.attachViewerHost({ ownerDocument: { createElement: () => ({}) } }); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.deepEqual(loader.imports, [], 'no prewarm, no lookup'); +}); + +test('the button’s “centre of the view” is the ground under the middle of the screen', async (t) => { + const { createStreetLevelLayer: create } = await import('./index.js'); + const scenePick = await import('../../data/scenePick.js'); + const lookups = []; + const provider = fakeProvider({ + nearestImage: async (point) => { + lookups.push(point); + return null; + }, + }); + const saved = globalThis.document; + const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) }); + globalThis.document = Object.assign(new EventTarget(), { + createElement: () => ({ getContext: () => drawing }), + }); + const viewer = fakeViewer(); + const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0); + Object.assign(viewer.scene, { + pickPositionSupported: true, + pickPosition: (at) => (at.x === 500 && at.y === 300 ? centre : undefined), + }); + viewer.scene.canvas.clientWidth = 1000; + viewer.scene.canvas.clientHeight = 600; + const layer = create({ providers: [provider], services: { scenePick } }); + layer.init(viewer); + layer.enable(viewer); + t.after(() => { + layer.destroy(); + globalThis.document = saved; + }); + await layer.openNearest(undefined, { aim: 'screen-centre' }); + assert.equal(lookups.length, 1); + assert.ok(Math.abs(lookups[0].lat - 38.5799) < 1e-9); + assert.ok(Math.abs(lookups[0].lon - -121.4937) < 1e-9); +}); diff --git a/src/layers/streetLevel/providers/google/viewer.js b/src/layers/streetLevel/providers/google/viewer.js index bdcf445ed..34a60126e 100644 --- a/src/layers/streetLevel/providers/google/viewer.js +++ b/src/layers/streetLevel/providers/google/viewer.js @@ -254,15 +254,6 @@ export function createGoogleViewer({ loader, getService, render } = {}) { } }, - /** Load the library ahead of the first panorama; a panorama is billed, so none is built. */ - async prewarm() { - try { - await ensureLibrary(); - } catch { - /* the real open reports errors */ - } - }, - onPose(listener) { listeners.add(listener); return () => listeners.delete(listener); diff --git a/src/layers/streetLevel/providers/google/viewer.test.mjs b/src/layers/streetLevel/providers/google/viewer.test.mjs index f6815bec1..5f2d7522d 100644 --- a/src/layers/streetLevel/providers/google/viewer.test.mjs +++ b/src/layers/streetLevel/providers/google/viewer.test.mjs @@ -130,11 +130,8 @@ test('an open overtaken by a newer one only shows the newer panorama', async () assert.ok(!poses.some((pose) => pose.imageId === 'pano-a')); }); -test('unmounting removes the element and every listener; prewarm builds no panorama', async () => { - const { fake, loader, viewer, host } = setup(); - await viewer.prewarm(host); - assert.deepEqual(loader.imports, ['streetView']); - assert.equal(fake.built.length, 0, 'a panorama is billed: none on prewarm'); +test('unmounting removes the element and every listener', async () => { + const { fake, viewer, host } = setup(); await viewer.mount(host); const [panorama] = fake.built; assert.ok(panorama.listenerCount() > 0); @@ -144,6 +141,12 @@ test('unmounting removes the element and every listener; prewarm builds no panor await assert.rejects(viewer.open('pano-a'), /not mounted/); }); +test('there is no prewarm: Google\u2019s script loads on first use only, never on another map', () => { + const { viewer, loader } = setup(); + assert.equal(viewer.prewarm, undefined); + assert.deepEqual(loader.imports, []); +}); + test('a key Google refuses fails the open at once, not after the timeout', async () => { const { fake, loader, viewer, host } = setup(); await viewer.mount(host); diff --git a/src/layers/streetLevel/uiState.js b/src/layers/streetLevel/uiState.js index 71affb9b0..700ca41c3 100644 --- a/src/layers/streetLevel/uiState.js +++ b/src/layers/streetLevel/uiState.js @@ -45,6 +45,7 @@ export function summarizeCoverage(providers) { */ export function composeUIState({ enabled, + groundClickReady = false, filter, providers, street, @@ -63,6 +64,8 @@ export function composeUIState({ legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected }); return { enabled, + /** The camera is at street zoom: a ground-click provider can open here. */ + groundClickReady: groundClickReady === true, keyRequired, keyRejected, filter: { ...filter }, diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index aaab0f878..9e4bf40bc 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -66,6 +66,7 @@ export class StreetLevelControls { status: byId('sl-status'), controls: byId('sl-controls'), providerChips: byId('sl-provider-chips'), + nearestBtn: byId('sl-nearest-btn'), error: byId('sl-error'), errorText: byId('sl-error-text'), sinceRange: byId('sl-since'), @@ -103,9 +104,18 @@ export class StreetLevelControls { this.listen(el.providerChips, 'click', (event) => { const button = event.target?.closest?.('.data-toggle-chip'); if (!button || button.disabled) return; - // Without a key the chips stay focusable for their tooltip; a click - // says the same thing rather than switching on a layer that cannot draw. - if (this._view?.controlsDisabled) { + // A chip without a usable key stays focusable for its tooltip; a click + // that would light it, or switch the layer off through it, says the + // same thing instead. Other chips still switch (a refused Street View + // key cannot lock Mapillary out), and it darkens beside a lit one. + const providers = this._view?.providers || []; + const chip = providers.find( + (entry) => entry.id === button.dataset.chipId, + ); + const othersLit = providers.some( + (entry) => entry.id !== chip?.id && entry.active, + ); + if (chip?.state === 'error' && (!chip.active || !othersLit)) { if (button.title) this.actions.showToast?.(button.title); return; } @@ -129,6 +139,17 @@ export class StreetLevelControls { this.listen(el.sinceRange, 'change', () => this._setParams({ sinceDays: sinceDays() }), ); + // The keyboard path to Street View: its nearest image at the view centre, + // without moving the camera. + this.listen(el.nearestBtn, 'click', () => { + const nearest = this._view?.nearest; + if (!nearest?.visible || nearest.disabled) return; + this.layer.openNearest?.(undefined, { + providerIds: nearest.providerIds, + frame: false, + aim: 'screen-centre', + }); + }); this.listen(el.followBtn, 'click', () => { this.layer.setFollow?.(!(this._state?.street?.follow === true)); }); @@ -466,6 +487,7 @@ export class StreetLevelControls { this._renderFilters(view); this._renderViewer(view); this._renderMeta(view); + this._renderNearest(view); this._reactToTransitions(view, state); } @@ -595,6 +617,17 @@ export class StreetLevelControls { setProp(this._elements.coverageMeta, 'textContent', view.meta); } + _renderNearest(view) { + const button = this._elements.nearestBtn; + if (!button) return; + const { nearest } = view; + setProp(button, 'hidden', !nearest.visible); + if (!nearest.visible) return; + setProp(button, 'textContent', nearest.label); + setProp(button, 'title', nearest.title); + setProp(button, 'disabled', nearest.disabled); + } + /** * Open the panel on a switch-on or when an image opens. Never persisted: * the stored collapse state stays the user's own choice. diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index bcf39cda2..20c8115e0 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -305,6 +305,7 @@ function panelDom() { const settings = add(main, 'div', { classes: ['sl-settings'] }); for (const id of ['sl-provider-chips', 'sl-error', 'sl-error-text']) add(settings, 'div', { id }); + add(settings, 'button', { id: 'sl-nearest-btn' }); // A missing key gates the filters only; the chips stay outside the gate. const controls = add(settings, 'fieldset', { id: 'sl-controls' }); for (const pano of ['all', 'pano', 'flat']) @@ -1136,3 +1137,72 @@ test('a child added while the viewer is expanded goes inert with the rest assert.equal(observer.watching(), false); }), )); + +test('OPEN STREET VIEW opens the nearest panorama at the view centre without moving the camera', () => + withDom(async (dom) => { + const state = uiState(); + state.providers.push({ + ...state.providers[0], + id: 'google', + name: 'Google Street View', + label: 'STREET VIEW', + groundClick: true, + count: 0, + }); + state.groundClickReady = true; + const { layer } = stubPanel(dom, state); + const asked = []; + layer.openNearest = (point, options) => asked.push([point, options]); + const button = dom.root.querySelector('#sl-nearest-btn'); + assert.equal(button.hidden, false); + assert.equal(button.textContent, 'OPEN STREET VIEW'); + button.click(); + assert.deepEqual(asked, [ + [ + undefined, + { providerIds: ['google'], frame: false, aim: 'screen-centre' }, + ], + ]); + // Too high to mean a street: disabled, and a click does nothing. + layer.publish({ ...state, groundClickReady: false }); + assert.equal(button.disabled, true); + button.click(); + assert.equal(asked.length, 1); + // Off Google 3D the provider is not listed: no button. + layer.publish({ ...state, providers: [state.providers[0]] }); + assert.equal(button.hidden, true); + })); + +test('a refused key on one provider leaves the other chip switchable, and the refused chip darkens beside it', () => + withDom(async (dom) => { + const state = uiState({ on: false, keyRequired: true }); + state.providers.push({ + ...state.providers[0], + id: 'google', + name: 'Google Street View', + label: 'STREET VIEW', + on: true, + keyRequired: true, + keyRejected: true, + error: 'Google refused GOOGLE_MAPS_API_KEY for Street View', + }); + state.providers[0].keyRequired = false; + const { calls, layer } = stubPanel(dom, state); + const chips = () => dom.root.querySelector('#sl-provider-chips').children; + chips()[0].click(); // Mapillary: configured, dark + await settle(); + assert.deepEqual(calls.setParams, [ + [{ mapillary: true }, { origin: 'user' }], + ]); + // With Mapillary lit, the refused Street View chip can be darkened. + state.providers[0].on = true; + state.keyRequired = false; + layer.publish({ ...state }); + chips()[1].click(); + await settle(); + assert.deepEqual(calls.setParams.at(-1), [ + { google: false }, + { origin: 'user' }, + ]); + assert.equal(calls.toasts.length, 0); + })); diff --git a/src/ui/streetLevelPresentation.js b/src/ui/streetLevelPresentation.js index be6bb6f88..40f8d9adf 100644 --- a/src/ui/streetLevelPresentation.js +++ b/src/ui/streetLevelPresentation.js @@ -80,8 +80,6 @@ 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, @@ -91,13 +89,11 @@ function presentProviders(state) { disabled: false, state: keyRequired ? 'error' - : on && provider.unavailable - ? 'warn' - : on && provider.loading - ? 'loading' - : on - ? 'active' - : 'idle', + : on && provider.loading + ? 'loading' + : on + ? 'active' + : 'idle', busy: on && provider.loading === true, }; }); @@ -134,6 +130,36 @@ function presentViewer(state) { }; } +/** + * A button for the providers with no coverage to click (Street View): opens + * their nearest image at the view centre, so they need no pointer. + */ +function presentNearest(state) { + const targets = (state.providers || []).filter( + (provider) => + provider.on && provider.groundClick && provider.keyRequired !== true, + ); + if (state.enabled !== true || !targets.length) + return { + visible: false, + label: '', + title: '', + disabled: true, + providerIds: [], + }; + const names = targets.map((provider) => provider.name).join(' or '); + const ready = state.groundClickReady === true; + return { + visible: true, + label: `OPEN ${targets.map((provider) => provider.label).join(' / ')}`, + title: ready + ? `Open ${names} at the centre of the view` + : `Zoom in to a street to open ${names} here`, + disabled: !ready || state.street.loading === true, + providerIds: targets.map((provider) => provider.id), + }; +} + function presentMeta(state) { if (!state.enabled) return 'Switch a provider on to draw its coverage.'; if (state.sequence.selectedId) @@ -173,6 +199,7 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) { legend: state.legend || [], viewer: presentViewer(state), meta: presentMeta(state), + nearest: presentNearest(state), wantsOpen: state.street.open === true, }; } diff --git a/src/ui/streetLevelPresentation.test.mjs b/src/ui/streetLevelPresentation.test.mjs index 4522db062..d837f5333 100644 --- a/src/ui/streetLevelPresentation.test.mjs +++ b/src/ui/streetLevelPresentation.test.mjs @@ -434,3 +434,52 @@ test('the meta line keeps the sequence count and adds the other providers’ hin `${(2514).toLocaleString()} sequences in view · click a line for its photos · click a street to open Street View`, ); }); + +test('OPEN STREET VIEW shows while Street View is on, and waits for street zoom', () => { + const streetView = provider({ + id: 'google', + name: 'Google Street View', + label: 'STREET VIEW', + groundClick: true, + requiresKeyId: 'google-maps', + }); + const view = (overrides) => + presentStreetLevelPanel( + snapshot({ + enabled: true, + providers: [provider(), streetView], + ...overrides, + }), + ).nearest; + const ready = view({ groundClickReady: true }); + assert.deepEqual(ready, { + visible: true, + label: 'OPEN STREET VIEW', + title: 'Open Google Street View at the centre of the view', + disabled: false, + providerIds: ['google'], + }); + const high = view({ groundClickReady: false }); + assert.equal(high.disabled, true); + assert.equal( + high.title, + 'Zoom in to a street to open Google Street View here', + ); + // Hidden with Street View off, without its key, or with the layer off + // (off Google 3D the provider is not listed at all). + assert.equal( + view({ providers: [provider(), { ...streetView, on: false }] }).visible, + false, + ); + assert.equal( + view({ providers: [provider(), { ...streetView, keyRequired: true }] }) + .visible, + false, + ); + assert.equal(view({ providers: [provider()] }).visible, false); + assert.equal( + presentStreetLevelPanel(snapshot({ providers: [provider(), streetView] })) + .nearest.visible, + false, + ); +}); diff --git a/src/ui/styles/street-level.css b/src/ui/styles/street-level.css index af353407f..d9d0590a9 100644 --- a/src/ui/styles/street-level.css +++ b/src/ui/styles/street-level.css @@ -183,10 +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; +.sl-nearest-btn { + margin-left: auto; +} + +.sl-nearest-btn[hidden] { + display: none; } /* ── Errors ──────────────────────────────────────────────────────────────── */ diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html index f6a0202e8..89da6d54b 100644 --- a/src/ui/templates/layer-panels.html +++ b/src/ui/templates/layer-panels.html @@ -113,6 +113,7 @@
PROVIDERS
+