feat(street-level): movable, simplified panel with the photo first

- The viewer opens at the top of the panel, under the header, so the photo
  is on screen without scrolling; its tools (EXPAND, FIT/FILL, FOLLOW, close)
  sit in a bar above the image, clear of the provider's own overlays, and the
  caption wraps instead of truncating.
- No separate ON/OFF or OPEN NEAREST PHOTO buttons: the header pill is the
  layer switch, and a provider chip is lit only while the layer is on (the
  last lit chip switches the layer off).
- SINCE is a stepped slider (any date to the last month) whose readout names
  the cut-off date and is announced via aria-valuetext.
- The panel floats and resizes (portable spec); in a resized window the
  viewer takes the spare height and only the settings scroll. SHRINK after
  EXPAND, collapsing, or a header double-click docks it at its default size.
- FIT/FILL tooltips say what they do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-09-25 20:45:17 +01:00
1 parent c69dd2e89c
commit 5877f24f0b
11 files changed
+740 -208

No files matched your search

+8 -5
View File
@@ -950,6 +950,7 @@ export class StyleManager extends ShellFacade {
}),
setPanelCollapsed: (collapsed, options) =>
this.setPanelCollapsed('street-level-panel', collapsed, options),
dockPanel: () => this._panelChrome.dockPanel('street-level-panel'),
showToast: (message) => this._showToast(message),
},
});
@@ -1497,13 +1498,15 @@ export class StyleManager extends ShellFacade {
}
/**
* A portable panel finished a resize or snapped back to its rail. Nothing
* in the shell depends on it yet; a panel with a viewport of its own can
* refit here.
* @param {string} _panelId
* A portable panel finished a resize or snapped back to its rail. A panel
* with a viewport of its own refits here.
* @param {string} panelId
* @returns {void}
*/
_onPanelResized(_panelId) {}
_onPanelResized(panelId) {
if (panelId === 'street-level-panel')
this._streetLevelControls?.onPanelResized();
}
/**
* Recalculates the CCTV panel max-height based on its current top position
+67 -39
View File
@@ -1,5 +1,8 @@
import { syncChipGroup } from './chipGroup.js';
import { presentStreetLevelPanel } from './streetLevelPresentation.js';
import {
presentStreetLevelPanel,
SINCE_STOPS,
} from './streetLevelPresentation.js';
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
/** Key the panel used before it became provider-neutral. */
@@ -37,12 +40,11 @@ export class StreetLevelControls {
return {
status: byId('sl-status'),
controls: byId('sl-controls'),
enableBtn: byId('sl-enable-btn'),
lookBtn: byId('sl-look-btn'),
providerChips: byId('sl-provider-chips'),
error: byId('sl-error'),
errorText: byId('sl-error-text'),
sinceSelect: byId('sl-since'),
sinceRange: byId('sl-since'),
sinceLabel: byId('sl-since-label'),
legend: byId('sl-legend'),
followBtn: byId('sl-follow-btn'),
viewerWrap: byId('sl-viewer-wrap'),
@@ -69,39 +71,35 @@ export class StreetLevelControls {
if (!this.root) return;
this.layer.attachViewerHost?.(el.viewer);
this.listen(el.enableBtn, 'click', () => this._toggleEnabled());
this.listen(el.lookBtn, 'click', async () => {
if (!(await this._ensureEnabled())) return;
this.layer.openNearest?.();
});
this.listen(el.status, 'click', () => this._toggleEnabled());
// One delegated listener; chips are re-synced in place on every render.
this.listen(el.providerChips, 'click', async (event) => {
this.listen(el.providerChips, 'click', (event) => {
const button = event.target?.closest?.('.data-toggle-chip');
if (!button || button.disabled) return;
const chip = this._view?.providers.find(
(entry) => entry.id === button.dataset.chipId,
);
if (!chip) return;
const next = !chip.active;
if (next && !(await this._ensureEnabled())) return;
this.layer.setProviderEnabled?.(chip.id, next);
this._toggleProvider(button.dataset.chipId);
});
for (const button of this.root.querySelectorAll('[data-sl-pano]')) {
this.listen(button, 'click', () =>
this.layer.setCoverageFilter?.({ pano: button.dataset.slPano }),
);
}
this.listen(el.sinceSelect, 'change', () => {
this.layer.setCoverageFilter?.({
sinceDays: Number(el.sinceSelect.value) || 0,
});
// The readout follows the thumb; coverage is rebuilt once, on release.
const sinceDays = () =>
SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0;
this.listen(el.sinceRange, 'input', () => {
const label = SINCE_STOPS[Number(el.sinceRange.value) || 0].label;
if (el.sinceLabel) el.sinceLabel.textContent = label;
el.sinceRange.setAttribute('aria-valuetext', label);
});
this.listen(el.sinceRange, 'change', () =>
this.layer.setCoverageFilter?.({ sinceDays: sinceDays() }),
);
this.listen(el.followBtn, 'click', () => {
this.layer.setFollow?.(!(this._state?.street?.follow === true));
});
this.listen(el.viewerClose, 'click', () => this.layer.closeViewer?.());
this.listen(el.viewerExpand, 'click', () =>
this.setViewerExpanded(!this.isViewerExpanded()),
this.setViewerExpanded(!this.isViewerExpanded(), { dock: true }),
);
for (const button of this.root.querySelectorAll('[data-sl-render]')) {
this.listen(button, 'click', () => {
@@ -162,6 +160,33 @@ export class StreetLevelControls {
}
}
/**
* A provider chip is the layer's switch: lighting one turns the layer on
* with that provider; darkening the last lit one turns the layer off (the
* provider stays switched on, so the layer comes back with it).
*/
async _toggleProvider(providerId) {
const chip = this._view?.providers.find((entry) => entry.id === providerId);
if (!chip) return;
if (!chip.active) {
this.layer.setProviderEnabled?.(providerId, true);
await this._ensureEnabled();
return;
}
const othersLit = this._view.providers.some(
(entry) => entry.id !== providerId && entry.active,
);
if (othersLit) {
this.layer.setProviderEnabled?.(providerId, false);
return;
}
try {
await this.actions.setEnabled?.(false);
} catch (error) {
this.actions.showToast?.(error?.message || 'Street Level toggle failed');
}
}
connect() {
this._unsubscribe?.();
this._unsubscribe = null;
@@ -170,6 +195,11 @@ export class StreetLevelControls {
if (this.layer.getUIState) this.render(this.layer.getUIState());
}
/** The panel window was resized or docked again: refit the viewer. */
onPanelResized() {
requestAnimationFrame(() => this.layer.resizeViewer?.());
}
/** Ask the shell to open (or close) the rail panel. */
setCollapsed(collapsed, options = {}) {
this.actions.setPanelCollapsed?.(collapsed, options);
@@ -184,8 +214,12 @@ export class StreetLevelControls {
);
}
/** Grow the street-level viewer to most of the screen, or shrink it back. */
setViewerExpanded(expanded) {
/**
* Grow the street-level viewer to most of the screen, or shrink it back.
* A user's shrink (`dock`) also returns a floating panel to its rail at its
* default size, so the viewer does not land in a window over the globe.
*/
setViewerExpanded(expanded, { dock = false } = {}) {
const wrap = this._elements.viewerWrap;
if (!wrap) return;
const on = expanded === true;
@@ -226,6 +260,7 @@ export class StreetLevelControls {
button.setAttribute('aria-pressed', String(on));
button.setAttribute('aria-label', on ? 'Shrink' : 'Expand');
}
if (!on && dock) this.actions.dockPanel?.();
requestAnimationFrame(() => this.layer.resizeViewer?.());
}
@@ -234,7 +269,7 @@ export class StreetLevelControls {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.setViewerExpanded(false);
this.setViewerExpanded(false, { dock: true });
return;
}
if (event.key !== 'Tab') return;
@@ -278,13 +313,8 @@ export class StreetLevelControls {
if (el.status) {
el.status.textContent = view.status.text;
el.status.className = `sl-status${view.status.tone ? ` is-${view.status.tone}` : ''}`;
}
if (el.enableBtn) {
el.enableBtn.textContent = view.enableButton.text;
el.enableBtn.setAttribute(
'aria-pressed',
String(view.enableButton.pressed),
);
el.status.setAttribute('aria-pressed', String(view.status.pressed));
el.status.title = view.status.title;
}
}
@@ -311,13 +341,11 @@ export class StreetLevelControls {
button.classList.toggle('is-active', active);
button.setAttribute('aria-checked', String(active));
}
if (el.sinceSelect) {
const value = String(view.filter.sinceDays);
if (
el.sinceSelect.value !== value &&
[...el.sinceSelect.options].some((option) => option.value === value)
)
el.sinceSelect.value = value;
if (el.sinceRange && document.activeElement !== el.sinceRange) {
const index = String(view.since.index);
if (el.sinceRange.value !== index) el.sinceRange.value = index;
if (el.sinceLabel) el.sinceLabel.textContent = view.since.label;
el.sinceRange.setAttribute('aria-valuetext', view.since.label);
}
if (el.legend && el.legend.childElementCount !== view.legend.length) {
el.legend.replaceChildren(
+71 -22
View File
@@ -5,15 +5,53 @@ import { keySetupRequirement } from '../keySetupCore.mjs';
* renders. Pure: no DOM, no layer calls, so every wording decision is testable.
*/
/** Relative "captured since" windows the panel offers, in days. */
export const SINCE_OPTIONS = Object.freeze([
Object.freeze({ days: 0, label: 'any date' }),
Object.freeze({ days: 365, label: 'last year' }),
Object.freeze({ days: 730, label: '2 years' }),
Object.freeze({ days: 1826, label: '5 years' }),
Object.freeze({ days: 3652, label: '10 years' }),
const DAY_MS = 86_400_000;
/**
* Stops of the SINCE slider, oldest window on the left: position 0 shows
* every capture, the right end only the last month. Stored as relative days
* so a share link keeps its meaning over time.
*/
export const SINCE_STOPS = Object.freeze([
Object.freeze({ days: 0, label: 'ANY DATE' }),
Object.freeze({ days: 3652, label: 'LAST 10 YEARS' }),
Object.freeze({ days: 1826, label: 'LAST 5 YEARS' }),
Object.freeze({ days: 1095, label: 'LAST 3 YEARS' }),
Object.freeze({ days: 730, label: 'LAST 2 YEARS' }),
Object.freeze({ days: 365, label: 'LAST YEAR' }),
Object.freeze({ days: 182, label: 'LAST 6 MONTHS' }),
Object.freeze({ days: 91, label: 'LAST 3 MONTHS' }),
Object.freeze({ days: 30, label: 'LAST MONTH' }),
]);
/** Slider position for a day count: the exact stop, else the nearest one. */
export function sinceStopIndex(days) {
const value = Number(days) || 0;
if (value <= 0) return 0;
let best = 1;
for (let i = 1; i < SINCE_STOPS.length; i++)
if (
Math.abs(SINCE_STOPS[i].days - value) <
Math.abs(SINCE_STOPS[best].days - value)
)
best = i;
return best;
}
/** Readout beside the slider: the window, plus the cut-off date it means today. */
function presentSince(days, now) {
const value = Number(days) || 0;
const index = sinceStopIndex(value);
if (value <= 0) return { index, days: 0, label: 'ANY DATE' };
const stop = SINCE_STOPS[index];
const window = stop.days === value ? stop.label : `LAST ${value} DAYS`;
return {
index,
days: value,
label: `${window} · SINCE ${formatDate(now - value * DAY_MS)}`,
};
}
function formatDate(ms) {
if (!Number.isFinite(ms)) return '';
try {
@@ -23,17 +61,30 @@ function formatDate(ms) {
}
}
/** The header pill doubles as the layer's on/off switch. */
function presentStatus(state) {
if (state.keyRequired) return { text: 'KEY REQUIRED', tone: 'warn' };
if (state.coverage.loading) return { text: 'LOADING', tone: 'busy' };
return state.enabled ? { text: 'ON', tone: 'on' } : { text: 'OFF', tone: '' };
const pressed = state.enabled === true;
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
if (state.keyRequired)
return { text: 'KEY REQUIRED', tone: 'warn', pressed, title };
if (state.coverage.loading)
return { text: 'LOADING', tone: 'busy', pressed, title };
return pressed
? { text: 'ON', tone: 'on', pressed, title }
: { text: 'OFF', tone: '', pressed, title };
}
/** One chip per registered provider; a keyless provider reads as an error chip. */
/**
* One chip per registered provider; a keyless provider reads as an error
* chip. A chip is lit only while the layer is on and that provider is
* switched on, so with a single provider the chip is the layer's switch.
*/
function presentProviders(state) {
const enabled = state.enabled === true;
return (state.providers || []).map((provider) => {
const keyRequired = provider.keyRequired === true;
let title = `${provider.name} imagery ${provider.on ? 'on' : 'off'}`;
const on = enabled && provider.on === true;
let title = `${provider.name} imagery ${on ? 'on' : 'off'}`;
if (keyRequired && provider.requiresKeyId)
title = `${provider.name}: ${keySetupRequirement(provider.requiresKeyId)}`;
else if (provider.error) title = `${provider.name}: ${provider.error}`;
@@ -41,16 +92,16 @@ function presentProviders(state) {
id: provider.id,
label: provider.label,
title,
active: provider.on === true,
active: on,
disabled: false,
state: keyRequired
? 'error'
: provider.loading
: on && provider.loading
? 'loading'
: provider.on
: on
? 'active'
: 'idle',
busy: provider.loading === true,
busy: on && provider.loading === true,
};
});
}
@@ -78,7 +129,7 @@ function presentViewer(state) {
}
function presentMeta(state) {
if (!state.enabled) return '';
if (!state.enabled) return 'Switch a provider on to draw its coverage.';
if (state.sequence.selectedId)
return state.sequence.loading
? 'Loading this sequence…'
@@ -90,9 +141,10 @@ function presentMeta(state) {
/**
* @param {object} state Snapshot from the layer's `getUIState()`.
* @param {{now?: number}} [options] Clock for the SINCE readout (tests pin it).
* @returns {object} Everything the panel needs, already worded.
*/
export function presentStreetLevelPanel(state) {
export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
const enabled = state.enabled === true;
const keyRequired = state.keyRequired === true;
const filter = state.filter || { pano: 'all', sinceDays: 0 };
@@ -101,13 +153,10 @@ export function presentStreetLevelPanel(state) {
keyRequired,
status: presentStatus(state),
controlsDisabled: keyRequired,
enableButton: {
text: enabled ? 'STREET LEVEL ON' : 'STREET LEVEL OFF',
pressed: enabled,
},
providers: presentProviders(state),
error: state.street.error || state.coverage.error || null,
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
since: presentSince(filter.sinceDays, now),
legend: state.legend || [],
viewer: presentViewer(state),
meta: presentMeta(state),
+76 -13
View File
@@ -2,7 +2,8 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
presentStreetLevelPanel,
SINCE_OPTIONS,
SINCE_STOPS,
sinceStopIndex,
} from './streetLevelPresentation.js';
const provider = (overrides = {}) => ({
@@ -75,29 +76,44 @@ function deepMerge(target, source) {
test('a key-gated layer disables every control and flags KEY REQUIRED', () => {
const view = presentStreetLevelPanel(snapshot({ keyRequired: true }));
assert.equal(view.controlsDisabled, true);
assert.deepEqual(view.status, { text: 'KEY REQUIRED', tone: 'warn' });
assert.equal(view.status.text, 'KEY REQUIRED');
assert.equal(view.status.tone, 'warn');
});
test('status reads LOADING while coverage streams, then ON or OFF', () => {
test('the header pill is the on/off switch and reads OFF, LOADING or ON', () => {
assert.deepEqual(presentStreetLevelPanel(snapshot()).status, {
text: 'OFF',
tone: '',
pressed: false,
title: 'Turn Street Level on',
});
assert.deepEqual(
presentStreetLevelPanel(
snapshot({ enabled: true, coverage: { loading: true } }),
).status,
{ text: 'LOADING', tone: 'busy' },
{
text: 'LOADING',
tone: 'busy',
pressed: true,
title: 'Turn Street Level off',
},
);
assert.deepEqual(
presentStreetLevelPanel(snapshot({ enabled: true })).status,
{ text: 'ON', tone: 'on' },
{ text: 'ON', tone: 'on', pressed: true, title: 'Turn Street Level off' },
);
assert.equal(
presentStreetLevelPanel(snapshot({ enabled: true, keyRequired: true }))
.status.pressed,
true,
'a keyless layer that is on can still be switched off',
);
});
test('one chip per provider: on, off, loading, and keyless as an error chip', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
providers: [
provider(),
provider({
@@ -164,15 +180,59 @@ test('errors from the viewer or the coverage web surface in one alert', () => {
);
});
test('the filter passes through as the select and segment values', () => {
const view = presentStreetLevelPanel(
snapshot({ filter: { pano: 'pano', sinceDays: 730 } }),
);
assert.deepEqual(view.filter, { pano: 'pano', sinceDays: 730 });
test('chips are dark while the layer is off, so the chip is the layer switch', () => {
const view = presentStreetLevelPanel(snapshot({ enabled: false }));
assert.deepEqual(
SINCE_OPTIONS.map((option) => option.days),
[0, 365, 730, 1826, 3652],
view.providers.map((chip) => [chip.id, chip.active, chip.state]),
[['mapillary', false, 'idle']],
);
assert.equal(view.providers[0].title, 'Mapillary imagery off');
assert.equal(view.enableButton, undefined, 'no separate ON/OFF button');
const keyless = presentStreetLevelPanel(
snapshot({ providers: [provider({ keyRequired: true })] }),
);
assert.equal(
keyless.providers[0].state,
'error',
'a keyless chip still says why',
);
});
test('the SINCE slider runs from any date on the left to the last month on the right', () => {
assert.deepEqual(
SINCE_STOPS.map((stop) => stop.days),
[0, 3652, 1826, 1095, 730, 365, 182, 91, 30],
);
assert.equal(sinceStopIndex(0), 0);
assert.equal(sinceStopIndex(365), 5);
assert.equal(
sinceStopIndex(400),
5,
'an off-stop link lands on the nearest stop',
);
assert.equal(sinceStopIndex(-4), 0);
});
test('the SINCE readout names the window and the cut-off date it means today', () => {
const now = Date.UTC(2026, 8, 25);
const any = presentStreetLevelPanel(snapshot(), { now });
assert.deepEqual(any.since, { index: 0, days: 0, label: 'ANY DATE' });
const year = presentStreetLevelPanel(
snapshot({ filter: { pano: 'pano', sinceDays: 365 } }),
{ now },
);
assert.deepEqual(year.filter, { pano: 'pano', sinceDays: 365 });
assert.deepEqual(year.since, {
index: 5,
days: 365,
label: 'LAST YEAR · SINCE 2025-09-25',
});
const custom = presentStreetLevelPanel(
snapshot({ filter: { pano: 'all', sinceDays: 400 } }),
{ now },
);
assert.equal(custom.since.index, 5);
assert.equal(custom.since.label, 'LAST 400 DAYS · SINCE 2025-08-21');
});
test('legend passes through in the layer’s order', () => {
@@ -184,7 +244,10 @@ test('legend passes through in the layer’s order', () => {
});
test('the meta line never mixes the visible-sequence count with the selected sequence', () => {
assert.equal(presentStreetLevelPanel(snapshot()).meta, '');
assert.equal(
presentStreetLevelPanel(snapshot()).meta,
'Switch a provider on to draw its coverage.',
);
const browsing = presentStreetLevelPanel(
snapshot({ enabled: true, coverage: { count: 812 } }),
);
+135 -45
View File
@@ -54,6 +54,25 @@
white-space: nowrap;
}
button.sl-status {
background: transparent;
font: inherit;
font-family: var(--font-mono);
font-size: var(--sl-text-small);
letter-spacing: 0.1em;
cursor: pointer;
transition:
border-color var(--transition-fast),
color var(--transition-fast);
}
button.sl-status:hover,
button.sl-status:focus-visible {
color: var(--text-primary);
border-color: var(--accent);
outline: none;
}
.sl-status.is-on {
color: var(--sl-green);
border-color: rgba(5, 203, 99, 0.55);
@@ -97,6 +116,15 @@
pointer-events: none;
}
/* Everything but the viewer. Docked, the whole panel scrolls; in a resized
window the viewer keeps its room and only this block scrolls. */
.sl-settings {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
/* ── Providers ───────────────────────────────────────────────────────────── */
.sl-providers {
display: flex;
@@ -159,19 +187,33 @@
color: var(--text-primary);
}
.sl-select {
font-family: var(--font-mono);
font-size: var(--sl-text-small);
letter-spacing: 0.06em;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid var(--glass-border-hover);
background: rgba(0, 0, 0, 0.35);
color: var(--text-primary);
/* SINCE: a stepped slider, oldest window left, newest right. */
.sl-since {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 4px 10px;
}
.sl-select option {
background: #0a0a0f;
.sl-range {
width: 100%;
min-width: 0;
margin: 0;
accent-color: var(--sl-green);
cursor: pointer;
}
.sl-range:focus-visible {
outline: 1px solid var(--accent);
outline-offset: 3px;
}
.sl-since-label {
grid-column: 2;
font-family: var(--font-mono);
font-size: var(--sl-text-small);
letter-spacing: 0.08em;
color: var(--text-primary);
}
.sl-legend {
@@ -217,6 +259,15 @@
display: none;
}
/* The image stage: the viewer and its loading veil, nothing on top of it,
so the provider's own controls (sequence bar, zoom, bearing) stay clear. */
.sl-viewer-stage {
position: relative;
display: flex;
flex-direction: column;
flex: 0 0 auto;
}
.sl-viewer {
width: 100%;
aspect-ratio: 16 / 10;
@@ -225,7 +276,7 @@
.sl-viewer-placeholder {
position: absolute;
inset: 0 0 26px;
inset: 0;
display: grid;
place-items: center;
z-index: 3;
@@ -252,26 +303,23 @@
}
}
/* Our tools live in a bar above the image rather than over it. */
.sl-viewer-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 5px 6px;
background: rgba(10, 10, 15, 0.85);
border-bottom: 1px solid var(--glass-border);
}
.sl-viewer-tools {
position: absolute;
top: 6px;
left: 6px;
/* Above MapillaryJS's overlays (z 10); its sequence bar is top-centre and
bearing/zoom/attribution bottom-right, so the top corners are free. */
z-index: 12;
display: flex;
align-items: center;
gap: 4px;
pointer-events: none;
}
.sl-viewer-tools > * {
pointer-events: auto;
}
.sl-viewer-tools.sl-viewer-tools-right {
left: auto;
right: 6px;
min-width: 0;
flex-wrap: wrap;
}
.sl-viewer-tools .scene-btn {
@@ -296,8 +344,9 @@
}
.sl-viewer-x {
width: 30px;
height: 30px;
flex: none;
width: 26px;
height: 26px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(10, 10, 15, 0.8);
@@ -326,24 +375,28 @@
color: var(--text-secondary);
background: rgba(0, 0, 0, 0.7);
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 10px;
gap: 2px 10px;
min-height: 26px;
}
.sl-image-by {
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow-wrap: anywhere;
min-width: 0;
}
.sl-image-by:empty {
display: none;
}
.sl-image-right {
display: inline-flex;
align-items: center;
gap: 10px;
margin-left: auto;
white-space: nowrap;
}
@@ -372,6 +425,44 @@
display: none;
}
/* ── Floating window ─────────────────────────────────────────────────────── */
/* A resized window (PanelPositionControls writes an inline height) hands the
spare height to the viewer, so the image never needs scrolling to. */
.panel-floating[style*='height'] > .street-level-panel-inner {
box-sizing: border-box;
height: 100%;
max-height: none;
overflow: hidden;
}
.panel-floating[style*='height'] .sl-body,
.panel-floating[style*='height'] .sl-controls {
flex: 1 1 auto;
min-height: 0;
}
.panel-floating[style*='height'] .sl-viewer-wrap {
flex: 1 1 auto;
min-height: 0;
}
.panel-floating[style*='height'] .sl-viewer-stage {
flex: 1 1 auto;
min-height: 140px;
}
.panel-floating[style*='height'] .sl-viewer {
flex: 1 1 auto;
min-height: 0;
aspect-ratio: auto;
}
.panel-floating[style*='height'] .sl-settings {
flex: 0 1 auto;
overflow-y: auto;
scrollbar-width: thin;
}
/* ── Expanded viewer ─────────────────────────────────────────────────────── */
/* The panel inner's backdrop-filter makes it the containing block for fixed
descendants, so the expanded viewer is re-parented to <body>. z-index sits
@@ -391,6 +482,11 @@
outline: none;
}
.sl-viewer-wrap-expanded .sl-viewer-stage {
flex: 1 1 auto;
min-height: 0;
}
.sl-viewer-wrap-expanded .sl-viewer {
flex: 1 1 auto;
min-height: 0;
@@ -402,14 +498,8 @@
padding: 10px 14px;
}
.sl-viewer-wrap-expanded .sl-viewer-tools {
top: 10px;
left: 10px;
}
.sl-viewer-wrap-expanded .sl-viewer-tools.sl-viewer-tools-right {
left: auto;
right: 10px;
.sl-viewer-wrap-expanded .sl-viewer-bar {
padding: 8px 10px;
}
.sl-viewer-wrap-expanded .sl-viewer-tools .scene-btn {
@@ -427,9 +517,9 @@
}
.sl-viewer-wrap-expanded .sl-viewer-x {
width: 40px;
height: 40px;
font-size: 1.5rem;
width: 34px;
height: 34px;
font-size: 1.3rem;
}
/* ── Short viewports ─────────────────────────────────────────────────────── */
+37 -40
View File
@@ -79,53 +79,29 @@
<div class="panel-header">
<span class="panel-title">STREET LEVEL</span>
<span class="panel-divider"></span>
<span id="sl-status" class="sl-status" role="status" aria-live="polite">OFF</span>
<button id="sl-status" class="sl-status" type="button" aria-pressed="false" title="Turn Street Level on or off">OFF</button>
<button class="panel-collapse-btn" data-collapse-target="street-level-panel" title="Expand panel">◀</button>
</div>
<div id="sl-body" class="sl-body">
<fieldset id="sl-controls" class="sl-controls">
<div class="cctv-controls">
<button id="sl-enable-btn" class="scene-btn" type="button" aria-pressed="false" title="Turn the Street Level layer on or off">STREET LEVEL OFF</button>
<button id="sl-look-btn" class="scene-btn" type="button" title="Open the nearest street-level photo to the view centre">OPEN NEAREST PHOTO</button>
</div>
<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>
</div>
<div id="sl-error" class="sl-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="sl-error-text"></span></div>
<div class="sl-filters" role="group" aria-label="Imagery filters">
<span class="cctv-summary-label">IMAGERY</span>
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter">
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true">ALL</button>
<button type="button" class="sl-seg-btn" data-sl-pano="pano" role="radio" aria-checked="false">360°</button>
<button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false">FLAT</button>
</div>
<label class="cctv-summary-label" for="sl-since">SINCE</label>
<select id="sl-since" class="sl-select" aria-label="Show imagery captured since">
<option value="0">any date</option>
<option value="365">last year</option>
<option value="730">2 years</option>
<option value="1826">5 years</option>
<option value="3652">10 years</option>
</select>
</div>
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours"></ul>
<div class="cctv-controls">
<button id="sl-follow-btn" class="scene-btn" type="button" aria-pressed="false" title="Move the globe camera wherever the street-level view looks">CAMERA FOLLOWS VIEW</button>
</div>
<div id="sl-viewer-wrap" class="sl-viewer-wrap" hidden>
<div class="sl-viewer-tools">
<button id="sl-viewer-expand" class="scene-btn" type="button" aria-pressed="false" title="Expand the street-level view (Esc shrinks it)"><span class="sl-btn-icon" aria-hidden="true">⤢</span><span class="sl-btn-text">EXPAND</span></button>
<div class="sl-seg" role="radiogroup" aria-label="Image fit">
<button type="button" class="sl-seg-btn is-active" data-sl-render="letterbox" role="radio" aria-checked="true" title="Show the whole image">FIT</button>
<button type="button" class="sl-seg-btn" data-sl-render="fill" role="radio" aria-checked="false" title="Fill the frame (cropped)">FILL</button>
<div class="sl-viewer-bar">
<div class="sl-viewer-tools">
<button id="sl-viewer-expand" class="scene-btn" type="button" aria-pressed="false" title="Expand the street-level view (Esc shrinks it)"><span class="sl-btn-icon" aria-hidden="true">⤢</span><span class="sl-btn-text">EXPAND</span></button>
<div class="sl-seg" role="radiogroup" aria-label="Image fit">
<button type="button" class="sl-seg-btn is-active" data-sl-render="letterbox" role="radio" aria-checked="true" title="Fit: show the whole photo, with dark bars where its shape differs from the frame">FIT</button>
<button type="button" class="sl-seg-btn" data-sl-render="fill" role="radio" aria-checked="false" title="Fill: fill the frame edge to edge, cropping the photo">FILL</button>
</div>
<button id="sl-follow-btn" class="scene-btn" type="button" aria-pressed="false" aria-label="Camera follows view" title="Camera follows view: move the globe camera wherever the street-level view looks"><span class="sl-btn-icon" aria-hidden="true">⌖</span><span class="sl-btn-text">FOLLOW</span></button>
</div>
<div class="sl-viewer-tools sl-viewer-tools-right">
<button id="sl-viewer-close" class="sl-viewer-x" type="button" title="Close the image and deselect it on the map" aria-label="Close the street-level image">×</button>
</div>
</div>
<div class="sl-viewer-tools sl-viewer-tools-right">
<button id="sl-viewer-close" class="sl-viewer-x" type="button" title="Close the image and deselect it on the map" aria-label="Close the street-level image">×</button>
<div class="sl-viewer-stage">
<div id="sl-viewer-placeholder" class="sl-viewer-placeholder" hidden></div>
<div id="sl-viewer" class="sl-viewer" aria-label="Street-level viewer"></div>
</div>
<div id="sl-viewer-placeholder" class="sl-viewer-placeholder" hidden></div>
<div id="sl-viewer" class="sl-viewer" aria-label="Street-level viewer"></div>
<div id="sl-image-meta" class="sl-image-meta">
<span id="sl-image-by" class="sl-image-by"></span>
<span class="sl-image-right">
@@ -134,7 +110,28 @@
</span>
</div>
</div>
<div id="sl-coverage-meta" class="sl-meta"></div>
<div class="sl-settings">
<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>
</div>
<div id="sl-error" class="sl-error" role="alert" hidden><span aria-hidden="true">⚠</span> <span id="sl-error-text"></span></div>
<div class="sl-filters" role="group" aria-label="Imagery filters">
<span class="cctv-summary-label">IMAGERY</span>
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter">
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true">ALL</button>
<button type="button" class="sl-seg-btn" data-sl-pano="pano" role="radio" aria-checked="false">360°</button>
<button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false">FLAT</button>
</div>
</div>
<div class="sl-since">
<label class="cctv-summary-label" for="sl-since">SINCE</label>
<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1" value="0" aria-valuetext="ANY DATE" aria-describedby="sl-since-label" />
<output id="sl-since-label" class="sl-since-label" for="sl-since">ANY DATE</output>
</div>
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours"></ul>
<div id="sl-coverage-meta" class="sl-meta"></div>
</div>
</fieldset>
</div>
</div>