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

+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