mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
1 parent
c69dd2e89c
commit
5877f24f0b
11 files changed
+740
-208
No files matched your search
@@ -54,11 +54,15 @@ test('the panel is registered with panel chrome, cockpit entry and the right rai
|
||||
/for \(const panel of \[[\s\S]*?this\._streetLevelPanel,[\s\S]*?\]\) \{[\s\S]*?stack\.insertBefore\(panel, globalContextPanel\)/,
|
||||
);
|
||||
for (const rule of [
|
||||
'#right-context-rail > #street-level-panel',
|
||||
'#right-context-rail #street-level-panel.collapsed',
|
||||
'#right-context-rail.layout-focus > #street-level-panel:not(.collapsed)',
|
||||
'#right-context-rail > #street-level-panel:not(.panel-floating)',
|
||||
'#right-context-rail #street-level-panel.collapsed:not(.panel-floating)',
|
||||
])
|
||||
assert.ok(css.includes(rule), `layers.css names ${rule}`);
|
||||
// Rail placement never applies to the panel once it floats as a window.
|
||||
assert.match(
|
||||
css,
|
||||
/#right-context-rail\.layout-focus\s*>\s*#street-level-panel:not\(\.collapsed\):not\(\.panel-floating\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('panel styles stay inside GEV conventions: no !important, no fixed panel', () => {
|
||||
@@ -79,11 +83,23 @@ test('the keyless state gates the controls rather than leaving dead buttons', ()
|
||||
);
|
||||
assert.match(html, /<fieldset id="sl-controls"/);
|
||||
assert.match(html, /<div id="sl-provider-chips" class="sl-chips"><\/div>/);
|
||||
assert.match(html, /<select id="sl-since"[\s\S]*?value="3652"/);
|
||||
assert.doesNotMatch(html, /value="year:/);
|
||||
assert.match(
|
||||
html,
|
||||
/<input id="sl-since" class="sl-range" type="range" min="0" max="8" step="1"/,
|
||||
);
|
||||
assert.match(html, /<output id="sl-since-label"/);
|
||||
assert.doesNotMatch(html, /<select id="sl-since"|value="year:/);
|
||||
assert.match(html, /<ul id="sl-legend"/);
|
||||
assert.match(html, /id="sl-error"[^>]*role="alert"/);
|
||||
assert.match(html, /id="sl-status"[^>]*role="status"/);
|
||||
// The header pill is the layer's on/off switch, not just a readout.
|
||||
assert.match(
|
||||
html,
|
||||
/<button id="sl-status" class="sl-status" type="button" aria-pressed="false"/,
|
||||
);
|
||||
assert.match(
|
||||
controls,
|
||||
/this\.listen\(el\.status, 'click', \(\) => this\._toggleEnabled\(\)\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the expanded viewer is a modal dialog that restores focus', () => {
|
||||
@@ -94,13 +110,58 @@ test('the expanded viewer is a modal dialog that restores focus', () => {
|
||||
|
||||
test('labels say what the buttons do', () => {
|
||||
for (const label of [
|
||||
'OPEN NEAREST PHOTO',
|
||||
'CAMERA FOLLOWS VIEW',
|
||||
'aria-label="Camera follows view"',
|
||||
'PROVIDERS',
|
||||
'SINCE',
|
||||
])
|
||||
assert.ok(html.includes(label), label);
|
||||
// The provider chips are the on/off switch; no separate buttons.
|
||||
assert.doesNotMatch(
|
||||
html,
|
||||
/LOOK HERE|STREET COCKPIT|>FRAME<|ZOOM TO RESULTS|ASK IN PLAIN ENGLISH/,
|
||||
/OPEN NEAREST PHOTO|STREET LEVEL OFF|sl-enable-btn|sl-look-btn|LOOK HERE|STREET COCKPIT|>FRAME<|ZOOM TO RESULTS|ASK IN PLAIN ENGLISH/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the viewer comes first and the panel is a portable, resizable window', () => {
|
||||
const controlsBlock = html.slice(html.indexOf('<fieldset id="sl-controls"'));
|
||||
assert.ok(
|
||||
controlsBlock.indexOf('id="sl-viewer-wrap"') <
|
||||
controlsBlock.indexOf('class="sl-settings"'),
|
||||
'imagery sits above the settings, visible without scrolling',
|
||||
);
|
||||
assert.match(
|
||||
controlsBlock.slice(0, controlsBlock.indexOf('id="sl-viewer"')),
|
||||
/id="sl-follow-btn"/,
|
||||
'follow lives in the viewer toolbar',
|
||||
);
|
||||
const position = read('src/ui/panelPositionControls.js');
|
||||
assert.match(
|
||||
position,
|
||||
/id: 'street-level-panel',[\s\S]*?portable: true,[\s\S]*?min: \{ width: 320, height: 280 \}/,
|
||||
);
|
||||
assert.match(
|
||||
panelCss,
|
||||
/\.panel-floating\[style\*='height'\] \.sl-settings \{[^}]*overflow-y: auto/,
|
||||
);
|
||||
// Shrinking docks it back at its default size rather than leaving a
|
||||
// window over the globe: collapsing a floating panel, or SHRINK / Esc on
|
||||
// the expanded viewer.
|
||||
assert.match(
|
||||
position,
|
||||
/id: 'street-level-panel',[\s\S]*?dockOnCollapse: true/,
|
||||
);
|
||||
assert.match(
|
||||
read('src/ui/panelChrome.js'),
|
||||
/classList\.toggle\('collapsed', nextCollapsed\);\s*this\._panelPosition\?\.onPanelCollapsed\?\.\(panelId, nextCollapsed\)/,
|
||||
);
|
||||
assert.match(
|
||||
controls,
|
||||
/setViewerExpanded\(!this\.isViewerExpanded\(\), \{ dock: true \}\)/,
|
||||
);
|
||||
assert.match(controls, /this\.setViewerExpanded\(false, \{ dock: true \}\)/);
|
||||
assert.match(controls, /if \(!on && dock\) this\.actions\.dockPanel\?\.\(\)/);
|
||||
assert.match(
|
||||
read('src/ui/applicationShell.js'),
|
||||
/panelId === 'street-level-panel'\)\s*this\._streetLevelControls\?\.onPanelResized\(\)/,
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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 ─────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user