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 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-08 12:55:08 +01:00
1 parent 7bfcf177ac
commit ef396655fc
18 files changed
+505 -110

No files matched your search

+2 -1
View File
@@ -66,7 +66,8 @@
- Street Level: Google Street View is the second provider. On the Google 3D - 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 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 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 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`. to Google Maps and FOLLOW. Share links carry it as `g`.
+1 -1
View File
@@ -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. **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`. 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`.
+14 -6
View File
@@ -3060,7 +3060,10 @@ ground level. Framing a photo ignores mesh samples far below the bare earth
(unloaded tiles). (unloaded tiles).
The header pill is the layer switch. With one provider its chip is a layer 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 viewer sits under the header with EXPAND, FIT/FILL, FOLLOW and close above the
image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and image. FOLLOW needs the Google 3D map stack (`attachMapStackController`) and
stops when the stack changes. Camera moves go through the application's 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 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 panorama load is billed to the key. Google's terms forbid Street View beside
non-Google maps, so the provider declares `requiresMapStack: 'photoreal'`: non-Google maps, so the provider declares `requiresMapStack: 'photoreal'`:
off Google 3D its chip is dashed with the reason, lookups and opens skip it, off Google 3D it does not appear at all (no chip, legend swatch, globe
and leaving Google 3D closes its panorama. Google offers no coverage layer 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 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 (`scenePick.isSurfacePick`: nothing picked or Google 3D tile content, so a
Mapillary line under the pointer wins; no tool holding the pointer; camera 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 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 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 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 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 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 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 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 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 (no Google key, no Google 3D) the gate checks that nothing of Street View
click step skips as `no Google key`. shows off Google 3D, even switched on by a share link; the click and button
step skips as `no Google key`.
Street Level has two browser gates. `npm run qa:street-level -- --url <server>` Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
+107 -33
View File
@@ -653,7 +653,20 @@ async function main() {
), ),
].map((chip) => chip.dataset.chipId), ].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 // One swatch per switched-on source plus "Selected" (Street View
// starts off); each chip wears its colour. // starts off); each chip wears its colour.
const legend = await page.evaluate(() => ({ const legend = await page.evaluate(() => ({
@@ -1733,35 +1746,57 @@ async function main() {
Boolean(window.__GOOGLE_MAPS_API_KEY__), Boolean(window.__GOOGLE_MAPS_API_KEY__),
); );
await step( 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 () => { async () => {
const read = () => const module = () =>
page.$eval( window.__godsEyeView.dataManager.layers.get('street-level').module;
'#sl-provider-chips [data-chip-id="google"]',
(node) => ({
active: node.classList.contains('active'),
className: node.className,
title: node.title,
color: node.style.getPropertyValue('--chip-color'),
}),
);
const off = await read();
assert.equal( assert.equal(
off.active, await page.evaluate(`(${module})().getParams().google`),
false, false,
'off until lit: panoramas are billed', 'off until lit: panoramas are billed',
); );
assert.equal(off.color, '#4285f4'); const setStreetView = (on) =>
await clickPanelControl(page, googleChip); page.evaluate(
await uiUntil( (value) =>
(u) => u.providers.find((p) => p.id === 'google')?.on === true, 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(); assert.deepEqual(await shown(), {
if (googleKey) assert.match(lit.className, /chip-active/); chip: false,
else { legend: false,
assert.match(lit.className, /chip-error/, 'no key: an error chip'); credit: false,
assert.match(lit.title, /GOOGLE_MAPS_API_KEY/); button: false,
} });
const option = `${STREET_LEVEL_TOKEN}.g.1`; const option = `${STREET_LEVEL_TOKEN}.g.1`;
assert.ok( assert.ok(
await settle( await settle(
@@ -1772,13 +1807,34 @@ async function main() {
.includes(token), .includes(token),
option, option,
), ),
`the share link carries ${option}`, `the share link keeps the choice: ${option}`,
); );
assert.ok((await ui()).coverage.count > 0, 'Mapillary still draws'); if (await stacks.photoreal()) {
// Leave it off, so later steps run the same with and without a key. await stacks.set('photoreal');
await clickPanelControl(page, googleChip); 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( 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; const now = (await ui()).providers.find((p) => p.id === id)?.on;
if (now === on) return; if (now === on) return;
await clickPanelControl(page, selector); await clickPanelControl(page, selector);
await uiUntil( assert.ok(
(u, want) => await uiUntil(
u.providers.find((p) => p.id === want.id)?.on === want.on, (u, want) =>
{ id, on }, u.providers.find((p) => p.id === want.id)?.on === want.on,
{ id, on },
),
`the ${id} chip switched ${on ? 'on' : 'off'}`,
); );
}; };
await closePhoto(); await closePhoto();
@@ -1918,6 +1977,21 @@ async function main() {
), ),
'the pose bearing follows the drag', '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). // Leaving Google 3D closes the panorama (Google's terms).
await stacks.set('esri-imagery'); await stacks.set('esri-imagery');
assert.ok( assert.ok(
+4 -7
View File
@@ -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 options = createDefaultLayerState().options['street-level'];
const providers = layer.getUIState().providers; // Every registered provider, listed on this map or not.
assert.deepEqual( const started = layer.getParams();
providers.map((p) => p.id), for (const id of layer.providerIds)
layer.providerIds, assert.equal(started[id], options[id], id);
);
for (const provider of providers)
assert.equal(provider.on, options[provider.id], provider.id);
assert.equal(options.google, false, 'Street View is billed: off'); assert.equal(options.google, false, 'Street View is billed: off');
}); });
+12 -3
View File
@@ -15,15 +15,24 @@ export function groundClickHeightOk(viewer, groundAt) {
* street zoom opens the nearest image of the active, available providers * street zoom opens the nearest image of the active, available providers
* that have no coverage to click (`groundClick`, Google Street View). The * that have no coverage to click (`groundClick`, Google Street View). The
* camera stays put: the user chose the spot. * 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<boolean>|false} * @returns {(screenPosition: {x: number, y: number}, picked?: object) => Promise<boolean>|false}
*/ */
export function createGroundClick({ state, openNearest, isAvailable }) { export function createGroundClick({
state,
openNearest,
isAvailable,
groundAt = null,
}) {
return function openAtGround(screenPosition, picked) { return function openAtGround(screenPosition, picked) {
const scenePick = state.services.scenePick; const scenePick = state.services.scenePick;
if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition) if (!state.enabled || !state.viewer || !scenePick?.pickGroundPosition)
return false; return false;
if (!scenePick.isSurfacePick(picked) || !state.groundClickReady) if (
!scenePick.isSurfacePick(picked) ||
!groundClickHeightOk(state.viewer, groundAt)
)
return false; return false;
const providerIds = [...state.providers.values()] const providerIds = [...state.providers.values()]
.filter( .filter(
+10 -4
View File
@@ -16,7 +16,7 @@ function harness({
{ id: 'mapillary', groundClick: false }, { id: 'mapillary', groundClick: false },
{ id: 'google', groundClick: true }, { id: 'google', groundClick: true },
], ],
ready = true, height = 400,
enabled = true, enabled = true,
picked = GROUND, picked = GROUND,
ellipsoid = undefined, ellipsoid = undefined,
@@ -24,7 +24,6 @@ function harness({
const opened = []; const opened = [];
const state = { const state = {
enabled, enabled,
groundClickReady: ready,
services: { scenePick }, services: { scenePick },
providers: new Map( providers: new Map(
providers.map( providers.map(
@@ -42,7 +41,14 @@ function harness({
), ),
viewer: { viewer: {
scene: { pickPositionSupported: true, pickPosition: () => picked }, scene: { pickPositionSupported: true, pickPosition: () => picked },
camera: { pickEllipsoid: () => ellipsoid }, camera: {
pickEllipsoid: () => ellipsoid,
positionCartographic: Cesium.Cartographic.fromDegrees(
-121.4944,
38.5816,
height,
),
},
}, },
}; };
const openAtGround = createGroundClick({ 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: { id: 'flight:1' } }, label: 'an entity' },
{ picked: { id: 'cctv:7', primitive: TILESET }, label: 'an id on a tile' }, { picked: { id: 'cctv:7', primitive: TILESET }, label: 'an id on a tile' },
{ options: { picked: null, ellipsoid: undefined }, label: 'the sky' }, { options: { picked: null, ellipsoid: undefined }, label: 'the sky' },
{ options: { ready: false }, label: 'high up' }, { options: { height: 5000 }, label: 'high up' },
{ {
options: { options: {
providers: [{ id: 'google', groundClick: true, available: false }], providers: [{ id: 'google', groundClick: true, available: false }],
+72 -13
View File
@@ -1,3 +1,4 @@
import * as Cesium from 'cesium';
import { createState } from './state.js'; import { createState } from './state.js';
import { createMarker } from './marker.js'; import { createMarker } from './marker.js';
import { createCameraFollow } from './cameraFollow.js'; import { createCameraFollow } from './cameraFollow.js';
@@ -71,6 +72,7 @@ export function createStreetLevelLayer({
state, state,
openNearest: (point, options) => layer.openNearest(point, options), openNearest: (point, options) => layer.openNearest(point, options),
isAvailable: (entry) => providerAvailable(entry), isAvailable: (entry) => providerAvailable(entry),
groundAt: parts.groundCaster?.groundAt ?? null,
}); });
let notifyQueued = false; let notifyQueued = false;
@@ -155,11 +157,23 @@ export function createStreetLevelLayer({
return !required || mapStack?.getActiveId?.() === required; 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() { function syncProviderAvailability() {
const owner = state.providers.get(state.street.providerId); const owner = state.providers.get(state.street.providerId);
if (state.street.open && owner && !providerAvailable(owner)) if (state.street.open && owner && !providerAvailable(owner))
layer.closeViewer(); 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(); notify();
} }
@@ -256,9 +270,14 @@ export function createStreetLevelLayer({
function activate(entry) { function activate(entry) {
if (!state.viewer) return; if (!state.viewer) return;
entry.instance.activate(state.viewer); entry.instance.activate(state.viewer);
parts.credits.show(state.viewer, entry.def);
if (!entry.status) refreshStatus(entry); 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) { function deactivate(entry) {
@@ -275,6 +294,24 @@ export function createStreetLevelLayer({
nearestLookup = null; 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). */ /** `frame: false` keeps the camera where it is (the user picked the spot). */
function openImage(providerId, imageId, { frame = true } = {}) { function openImage(providerId, imageId, { frame = true } = {}) {
abortNearest(); abortNearest();
@@ -317,16 +354,18 @@ export function createStreetLevelLayer({
/** The hint a ground-click provider shows, or the provider's own. */ /** The hint a ground-click provider shows, or the provider's own. */
function hintFor(entry, stats) { function hintFor(entry, stats) {
if (!providerAvailable(entry)) return '';
if (entry.def.groundClick && stats.hint && !state.groundClickReady) if (entry.def.groundClick && stats.hint && !state.groundClickReady)
return `zoom in to a street to open ${entry.def.name}`; return `zoom in to a street to open ${entry.def.name}`;
return stats.hint || ''; 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() { function providerSnapshots() {
return [...state.providers.values()].map((entry) => { return listedEntries().map((entry) => {
const stats = entry.instance.coverageStats(); const stats = entry.instance.coverageStats();
const available = providerAvailable(entry);
return { return {
id: entry.def.id, id: entry.def.id,
name: entry.def.name, name: entry.def.name,
@@ -342,8 +381,8 @@ export function createStreetLevelLayer({
hint: hintFor(entry, stats), hint: hintFor(entry, stats),
error: stats.error || null, error: stats.error || null,
color: entry.def.colors.coverage, color: entry.def.colors.coverage,
/** Why the provider cannot be used right now, or null. */ /** No coverage to click: it opens where the user points. */
unavailable: available ? null : unavailableReason(entry), groundClick: entry.def.groundClick === true,
}; };
}); });
} }
@@ -371,6 +410,7 @@ export function createStreetLevelLayer({
const { host, ...street } = state.street; const { host, ...street } = state.street;
return composeUIState({ return composeUIState({
enabled: state.enabled, enabled: state.enabled,
groundClickReady: state.groundClickReady,
filter: state.filter, filter: state.filter,
providers: providerSnapshots(), providers: providerSnapshots(),
street, street,
@@ -383,7 +423,12 @@ export function createStreetLevelLayer({
id: STREET_LEVEL_LAYER_ID, id: STREET_LEVEL_LAYER_ID,
name: 'Street Level', name: 'Street Level',
icon: '📷', 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, updateInterval: 0,
statsRefreshInterval: 1000, statsRefreshInterval: 1000,
/** The key the switched-on providers share (or null), for the layer row. */ /** 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.loading) loadingLabel = 'loading coverage...';
else if (coverage.hint && state.enabled) loadingLabel = coverage.hint; else if (coverage.hint && state.enabled) loadingLabel = coverage.hint;
return { return {
source: layer.source,
count: coverage.count, count: coverage.count,
loading: coverage.loading, loading: coverage.loading,
keyRequired: coverage.keyRequired, keyRequired: coverage.keyRequired,
@@ -530,7 +576,13 @@ export function createStreetLevelLayer({
attachViewerHost(element) { attachViewerHost(element) {
parts.viewerHost.attach(element); parts.viewerHost.attach(element);
if (element && state.enabled) if (element && state.enabled)
whenIdle(() => parts.viewerHost.prewarm(activeEntries()), 1500); whenIdle(
() =>
parts.viewerHost.prewarm(
activeEntries().filter((entry) => providerAvailable(entry)),
),
1500,
);
}, },
setProviderEnabled, setProviderEnabled,
/** Imagery filter for coverage, cones and nearest-image lookups. */ /** Imagery filter for coverage, cones and nearest-image lookups. */
@@ -538,10 +590,17 @@ export function createStreetLevelLayer({
openImage, openImage,
/** /**
* Open the nearest image any active provider (or only `providerIds`) * 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 } = {}) { async openNearest(
const view = point || viewCentre(state.viewer); 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)) if (!Number.isFinite(view?.lat) || !Number.isFinite(view?.lon))
return false; return false;
abortNearest(); abortNearest();
+74 -12
View File
@@ -502,15 +502,16 @@ async function withStreetView(t, { mapStack = 'photoreal', nearest } = {}) {
}, },
}); });
mapillary.requiresKeyId = 'mapillary'; mapillary.requiresKeyId = 'mapillary';
const loader = fakeMapsLoader(fake.library);
const google = createGoogleProvider({ const google = createGoogleProvider({
getApiKey: () => 'browser-key', getApiKey: () => 'browser-key',
loader: fakeMapsLoader(fake.library), loader,
}); });
const { layer, viewer } = await enabledLayer(t, [mapillary, google]); const { layer, viewer } = await enabledLayer(t, [mapillary, google]);
const stack = fakeMapStack(mapStack); const stack = fakeMapStack(mapStack);
layer.attachMapStackController(stack); layer.attachMapStackController(stack);
layer.attachViewerHost(fakeHost()); 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) => { 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'); 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) => { test('Street View appears only on the Google 3D map: chip, legend, credit, source and images', async (t) => {
const { layer, stack } = await withStreetView(t, { const { layer, viewer, stack } = await withStreetView(t, {
mapStack: 'esri-imagery', mapStack: 'esri-imagery',
}); });
layer.setProviderEnabled('google', true); layer.setProviderEnabled('google', true);
await settle(); await settle();
const google = () => const ui = () => layer.getUIState();
layer.getUIState().providers.find((p) => p.id === 'google'); const listed = () => ui().providers.map((p) => p.id);
assert.match(google().unavailable, /needs the Google 3D map/); const credited = () =>
assert.equal(google().hint, '', 'no "click a street" off Google 3D'); 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.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'); 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(await layer.openImage('google', 'pano-a'), true);
assert.equal(layer.getUIState().street.providerId, 'google'); assert.equal(ui().street.providerId, 'google');
stack.switchTo('bing'); stack.switchTo('bing');
await settle(); 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) => { 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(layer.getUIState().street.imageId, 'pano-a');
assert.equal(viewer.flights.started, 0, 'no framing flight'); 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);
});
@@ -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) { onPose(listener) {
listeners.add(listener); listeners.add(listener);
return () => listeners.delete(listener); return () => listeners.delete(listener);
@@ -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')); assert.ok(!poses.some((pose) => pose.imageId === 'pano-a'));
}); });
test('unmounting removes the element and every listener; prewarm builds no panorama', async () => { test('unmounting removes the element and every listener', async () => {
const { fake, loader, viewer, host } = setup(); const { fake, viewer, host } = setup();
await viewer.prewarm(host);
assert.deepEqual(loader.imports, ['streetView']);
assert.equal(fake.built.length, 0, 'a panorama is billed: none on prewarm');
await viewer.mount(host); await viewer.mount(host);
const [panorama] = fake.built; const [panorama] = fake.built;
assert.ok(panorama.listenerCount() > 0); 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/); 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 () => { test('a key Google refuses fails the open at once, not after the timeout', async () => {
const { fake, loader, viewer, host } = setup(); const { fake, loader, viewer, host } = setup();
await viewer.mount(host); await viewer.mount(host);
+3
View File
@@ -45,6 +45,7 @@ export function summarizeCoverage(providers) {
*/ */
export function composeUIState({ export function composeUIState({
enabled, enabled,
groundClickReady = false,
filter, filter,
providers, providers,
street, street,
@@ -63,6 +64,8 @@ export function composeUIState({
legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected }); legend.push({ key: 'selected', label: 'Selected', color: COLORS.selected });
return { return {
enabled, enabled,
/** The camera is at street zoom: a ground-click provider can open here. */
groundClickReady: groundClickReady === true,
keyRequired, keyRequired,
keyRejected, keyRejected,
filter: { ...filter }, filter: { ...filter },
+36 -3
View File
@@ -66,6 +66,7 @@ export class StreetLevelControls {
status: byId('sl-status'), status: byId('sl-status'),
controls: byId('sl-controls'), controls: byId('sl-controls'),
providerChips: byId('sl-provider-chips'), providerChips: byId('sl-provider-chips'),
nearestBtn: byId('sl-nearest-btn'),
error: byId('sl-error'), error: byId('sl-error'),
errorText: byId('sl-error-text'), errorText: byId('sl-error-text'),
sinceRange: byId('sl-since'), sinceRange: byId('sl-since'),
@@ -103,9 +104,18 @@ export class StreetLevelControls {
this.listen(el.providerChips, 'click', (event) => { this.listen(el.providerChips, 'click', (event) => {
const button = event.target?.closest?.('.data-toggle-chip'); const button = event.target?.closest?.('.data-toggle-chip');
if (!button || button.disabled) return; if (!button || button.disabled) return;
// Without a key the chips stay focusable for their tooltip; a click // A chip without a usable key stays focusable for its tooltip; a click
// says the same thing rather than switching on a layer that cannot draw. // that would light it, or switch the layer off through it, says the
if (this._view?.controlsDisabled) { // 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); if (button.title) this.actions.showToast?.(button.title);
return; return;
} }
@@ -129,6 +139,17 @@ export class StreetLevelControls {
this.listen(el.sinceRange, 'change', () => this.listen(el.sinceRange, 'change', () =>
this._setParams({ sinceDays: sinceDays() }), 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.listen(el.followBtn, 'click', () => {
this.layer.setFollow?.(!(this._state?.street?.follow === true)); this.layer.setFollow?.(!(this._state?.street?.follow === true));
}); });
@@ -466,6 +487,7 @@ export class StreetLevelControls {
this._renderFilters(view); this._renderFilters(view);
this._renderViewer(view); this._renderViewer(view);
this._renderMeta(view); this._renderMeta(view);
this._renderNearest(view);
this._reactToTransitions(view, state); this._reactToTransitions(view, state);
} }
@@ -595,6 +617,17 @@ export class StreetLevelControls {
setProp(this._elements.coverageMeta, 'textContent', view.meta); 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: * Open the panel on a switch-on or when an image opens. Never persisted:
* the stored collapse state stays the user's own choice. * the stored collapse state stays the user's own choice.
+70
View File
@@ -305,6 +305,7 @@ function panelDom() {
const settings = add(main, 'div', { classes: ['sl-settings'] }); const settings = add(main, 'div', { classes: ['sl-settings'] });
for (const id of ['sl-provider-chips', 'sl-error', 'sl-error-text']) for (const id of ['sl-provider-chips', 'sl-error', 'sl-error-text'])
add(settings, 'div', { id }); add(settings, 'div', { id });
add(settings, 'button', { id: 'sl-nearest-btn' });
// A missing key gates the filters only; the chips stay outside the gate. // A missing key gates the filters only; the chips stay outside the gate.
const controls = add(settings, 'fieldset', { id: 'sl-controls' }); const controls = add(settings, 'fieldset', { id: 'sl-controls' });
for (const pano of ['all', 'pano', 'flat']) for (const pano of ['all', 'pano', 'flat'])
@@ -1136,3 +1137,72 @@ test('a <body> child added while the viewer is expanded goes inert with the rest
assert.equal(observer.watching(), false); 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);
}));
+36 -9
View File
@@ -80,8 +80,6 @@ function presentProviders(state) {
else if (keyRequired && provider.requiresKeyId) else if (keyRequired && provider.requiresKeyId)
title = `${provider.name}: ${keySetupRequirement(provider.requiresKeyId)}`; title = `${provider.name}: ${keySetupRequirement(provider.requiresKeyId)}`;
else if (provider.error) title = `${provider.name}: ${provider.error}`; 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 { return {
id: provider.id, id: provider.id,
label: provider.label, label: provider.label,
@@ -91,13 +89,11 @@ function presentProviders(state) {
disabled: false, disabled: false,
state: keyRequired state: keyRequired
? 'error' ? 'error'
: on && provider.unavailable : on && provider.loading
? 'warn' ? 'loading'
: on && provider.loading : on
? 'loading' ? 'active'
: on : 'idle',
? 'active'
: 'idle',
busy: on && provider.loading === true, 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) { function presentMeta(state) {
if (!state.enabled) return 'Switch a provider on to draw its coverage.'; if (!state.enabled) return 'Switch a provider on to draw its coverage.';
if (state.sequence.selectedId) if (state.sequence.selectedId)
@@ -173,6 +199,7 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
legend: state.legend || [], legend: state.legend || [],
viewer: presentViewer(state), viewer: presentViewer(state),
meta: presentMeta(state), meta: presentMeta(state),
nearest: presentNearest(state),
wantsOpen: state.street.open === true, wantsOpen: state.street.open === true,
}; };
} }
+49
View File
@@ -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`, `${(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,
);
});
+6 -4
View File
@@ -183,10 +183,12 @@ button.sl-status:focus-visible {
border-color: rgba(255, 179, 0, 0.55); border-color: rgba(255, 179, 0, 0.55);
} }
/* On, but not usable on this map stack (Street View off Google 3D). */ .sl-nearest-btn {
.sl-chips .data-toggle-chip.chip-warn { margin-left: auto;
opacity: 0.7; }
border-style: dashed;
.sl-nearest-btn[hidden] {
display: none;
} }
/* ── Errors ──────────────────────────────────────────────────────────────── */ /* ── Errors ──────────────────────────────────────────────────────────────── */
+1
View File
@@ -113,6 +113,7 @@
<div class="sl-providers" role="group" aria-label="Imagery providers"> <div class="sl-providers" role="group" aria-label="Imagery providers">
<span class="cctv-summary-label">PROVIDERS</span> <span class="cctv-summary-label">PROVIDERS</span>
<div id="sl-provider-chips" class="sl-chips"></div> <div id="sl-provider-chips" class="sl-chips"></div>
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" hidden>OPEN STREET VIEW</button>
</div> </div>
<div id="sl-error" class="sl-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="sl-error-text"></span></div> <div id="sl-error" class="sl-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="sl-error-text"></span></div>
<!-- A missing key gates the filters only: the chips above stay <!-- A missing key gates the filters only: the chips above stay