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

+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 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');
});
+12 -3
View File
@@ -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<boolean>|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(
+10 -4
View File
@@ -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 }],
+72 -13
View File
@@ -1,3 +1,4 @@
import * as Cesium from 'cesium';
import { createState } from './state.js';
import { createMarker } from './marker.js';
import { createCameraFollow } from './cameraFollow.js';
@@ -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();
+74 -12
View File
@@ -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);
});
@@ -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);
@@ -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);
+3
View File
@@ -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 },
+36 -3
View File
@@ -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.
+70
View File
@@ -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 <body> child added while the viewer is expanded goes inert with the rest
assert.equal(observer.watching(), false);
}),
));
test('OPEN STREET VIEW opens the nearest panorama at the view centre without moving the camera', () =>
withDom(async (dom) => {
const state = uiState();
state.providers.push({
...state.providers[0],
id: 'google',
name: 'Google Street View',
label: 'STREET VIEW',
groundClick: true,
count: 0,
});
state.groundClickReady = true;
const { layer } = stubPanel(dom, state);
const asked = [];
layer.openNearest = (point, options) => asked.push([point, options]);
const button = dom.root.querySelector('#sl-nearest-btn');
assert.equal(button.hidden, false);
assert.equal(button.textContent, 'OPEN STREET VIEW');
button.click();
assert.deepEqual(asked, [
[
undefined,
{ providerIds: ['google'], frame: false, aim: 'screen-centre' },
],
]);
// Too high to mean a street: disabled, and a click does nothing.
layer.publish({ ...state, groundClickReady: false });
assert.equal(button.disabled, true);
button.click();
assert.equal(asked.length, 1);
// Off Google 3D the provider is not listed: no button.
layer.publish({ ...state, providers: [state.providers[0]] });
assert.equal(button.hidden, true);
}));
test('a refused 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)
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,
};
}
+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`,
);
});
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);
}
/* 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 ──────────────────────────────────────────────────────────────── */
+1
View File
@@ -113,6 +113,7 @@
<div class="sl-providers" role="group" aria-label="Imagery providers">
<span class="cctv-summary-label">PROVIDERS</span>
<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 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