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

+6 -5
View File
@@ -1,11 +1,12 @@
# Changelog
- Rail panels can opt into a portable mode: dragging the CCTV panel by its
header lifts it out of the right rail into a floating window that resizes
from any edge or corner, remembers its position and size, and snaps back on
a header double-click.
- Rail panels can opt into a portable mode: dragging the CCTV or Street Level
panel by its header lifts it out of the right rail into a floating window
that resizes from any edge or corner, remembers its position and size across
reloads, and snaps back on a header double-click. A panel can also ask to
dock when it is collapsed while floating (Street Level does).
- Street Level: a provider-neutral street-level imagery layer modelled on the iD editor's photo overlay. One panel in the right rail carries a chip per imagery provider, shared 360°/flat and captured-since filters, coverage drawn on the globe from orbit down to a single street, image cones per sequence, and one embedded viewer whose adapter follows the provider of the open image; each active provider credits its imagery on the globe, and share links carry the provider switches and the filter. Mapillary is the first provider (free client token; CC BY-SA 4.0 imagery); Google Street View, KartaView and Panoramax are next.
- Street Level: a provider-neutral street-level imagery layer modelled on the iD editor's photo overlay. One panel in the right rail carries a chip per imagery provider, shared 360°/flat and captured-since filters, coverage drawn on the globe from orbit down to a single street, image cones per sequence, and one embedded viewer whose adapter follows the provider of the open image; each active provider credits its imagery on the globe, and share links carry the provider switches and the filter. Mapillary is the first provider (free client token; CC BY-SA 4.0 imagery); Google Street View, KartaView and Panoramax are next. The panel is kept simple: the viewer opens at the top so the photo needs no scrolling, the header pill and the provider chips switch the layer, and SINCE is a slider.
- CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived
from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the
+3 -1
View File
@@ -336,7 +336,9 @@ Setup for Mapillary:
Without the token the STREET LEVEL panel reads **KEY REQUIRED**, the Mapillary chip turns amber and the controls stay greyed out.
Turn on **Street Level** under DATA LAYERS → Cameras, or open the STREET LEVEL strip in the right rail. The **PROVIDERS** row has one chip per registered provider; switch a provider off and its coverage, its on-globe credit and its share-link bit go with it. The **IMAGERY** filter (ALL / 360° / FLAT) and the **SINCE** window apply to every provider at once. Mapillary's coverage draws as ground-hugging lines (recent captures bright green, older ones dim, 360° panoramas magenta); click a line for its image cones and a cone to open the photo in the embedded viewer, or press **OPEN NEAREST PHOTO** for the closest image to the view centre. **EXPAND** grows the viewer to most of the screen and **CAMERA FOLLOWS VIEW** drives the globe camera from the panorama; the caption links to the image on the provider's own site. Mapillary blurs faces and licence plates before publishing imagery, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while the provider is on.
Turn on **Street Level** under DATA LAYERS → Cameras, or open the STREET LEVEL strip in the right rail and press the **ON/OFF** pill in its header. The **PROVIDERS** row has one chip per registered provider: lighting a chip turns its imagery on (and the layer with it), and darkening the last lit chip turns the layer off. The **IMAGERY** filter (ALL / 360° / FLAT) and the **SINCE** slider (any date on the left, the last month on the right, with the cut-off date spelled out) apply to every provider at once. Mapillary's coverage draws as ground-hugging lines (recent captures bright green, older ones dim, 360° panoramas magenta); click a line for its image cones and a cone to open the photo. The photo opens at the top of the panel, so it is on screen without scrolling. The bar above it has **EXPAND** (most of the screen), **FIT** (the whole photo, with dark bars where its shape differs from the frame) / **FILL** (edge to edge, cropped) and **FOLLOW** (the globe camera moves wherever the street-level view looks); the caption links to the image on the provider's own site. Mapillary blurs faces and licence plates before publishing imagery, and the globe credits "© Mapillary contributors, CC BY-SA 4.0" while the provider is on.
The panel is also a window: drag its header to lift it out of the rail, and resize it from any edge or corner. A larger window gives the photo the room; only the settings below it scroll. Double-clicking the header, collapsing the window, or pressing SHRINK after EXPAND puts it back in the rail at its default size, so it never stays parked over the globe.
Share links carry the layer's provider switches and filter (`3.m.0` turns Mapillary off, `3.p.p` keeps only panoramas, `3.s.365` shows the last year). Adding a provider means implementing the contract in `src/layers/streetLevel/registry.js` and registering it in `src/app/layers/streetLevel.js`; the chip, credit and share bit follow.
+14 -1
View File
@@ -2786,6 +2786,19 @@ ground-clamped sequence lines (z0–5 overview points from orbit, z11–14
sequences below 60 km) and the proxy strips the unused `image` point layer
from z14 tiles in transit (12 MB → ~80 KB).
The panel has no separate ON/OFF or nearest-photo buttons. The header pill is
the layer switch, and a provider chip is lit only while the layer is on: with
one provider it is the layer switch too, and with several, darkening the last
lit chip turns the layer off while leaving that provider switched on. The
viewer sits first, under the header, with its tools (EXPAND, FIT/FILL, FOLLOW,
close) in a bar above the image so the provider's own overlays stay clear;
the settings follow. SINCE is a stepped range input (any date, 10, 5, 3, 2 and
1 years, 6, 3 and 1 months) whose readout names the cut-off date. The panel is
portable (`street-level-panel` spec in `PanelPositionControls`, minimum
320 × 280, `dockOnCollapse`): in a resized window the viewer takes the spare
height and only `.sl-settings` scrolls, and collapsing the window or SHRINK
after EXPAND docks it at its default size.
Street-level providers implement the contract documented in
`src/layers/streetLevel/registry.js`: a definition (`id`, `name`, `label`,
`requiresKeyId`, `pickPrefix`, `colors`, `credit`, `capabilities`, `legend`,
@@ -4094,7 +4107,7 @@ easier to meet (detection is now on more often), but does not create it.
samples and constant elevation during E/N drag; one shared-floor resolution on
release; late one-shot shared-cell work is permitted during viewshed idle. The
A+B harness intentionally excludes citywide LOD assertions.
- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.<panel-id>`. The rails lay docked panels out adaptively and write nothing for them; a portable panel (currently CCTV) persists `{ left, top, width, height, floating: true }` under that key once a header drag lifts it out of the rail, and a header double-click removes the record again. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.<panel-id>` (`'0'` open, `'1'` closed, absent means the panel's own default).
- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.<panel-id>`. The rails lay docked panels out adaptively and write nothing for them; a portable panel (CCTV, Street Level) persists `{ left, top, width, height, floating: true }` under that key once a header drag lifts it out of the rail, and a header double-click removes the record again. Double presses are detected in the header's `pointerdown` handler (two presses within 400 ms and 6 px, no drag between), because that handler's `preventDefault()` suppresses the mouse events a native `dblclick` needs. A spec with `dockOnCollapse` (Street Level) also docks when collapsed while floating. The rail's startup move of panels into `#right-context-rail` leaves a restored floating window's geometry alone. Collapsed state does persist for every panel at `godsEyeView.v6.panelCollapsed.<panel-id>` (`'0'` open, `'1'` closed, absent means the panel's own default).
- Legacy draggable-panel position keys may remain in local storage for backward compatibility, but the map-mode right rail ignores them unless they describe a portable panel's floating window; collapsed states still persist at `godsEyeView.v6.panelCollapsed.<panel-id>`.
- Flight/military tracked entities cache dead-reckoned positions per frame to avoid callback desync flicker.
- Aircraft 3D-model and tracking invariants are covered by `npm run test:track`; run this before touching `flights.js`, `militaryFlights.js`, `detection.js`, or `trackedReadout.js`.
+253 -28
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env node
/**
* Browser QA for the Street Level layer against a running dev
* server: the panel's place in the right rail, the provider chips, the
* keyless gate, coverage and its credit, switching a provider off and on,
* the imagery filter, the embedded viewer and its expanded dialog. Run with `npm run qa:street-level -- --url http://localhost:4173`.
* server: the panel's place in the right rail, the provider chips (which
* switch the layer), the keyless gate, coverage and its credit, the imagery
* filter and SINCE slider, the viewer (visible without scrolling, expanded
* dialog, close) and the panel as a floating, resizable window. Run with `npm run qa:street-level -- --url http://localhost:4173`.
* Without MAPILLARY_CLIENT_TOKEN on the server only the keyless steps run.
*/
import assert from 'node:assert/strict';
@@ -228,47 +229,98 @@ async function main() {
.get('street-level')
.module.getUIState(),
);
const chip = '#sl-provider-chips [data-chip-id="mapillary"]';
let firstImageId = null;
await step(
'switching the provider chip off clears its coverage and credit',
'the header ON/OFF pill switches the layer off and on',
async () => {
await page.click('#sl-provider-chips [data-chip-id="mapillary"]');
assert.equal(
await page.$eval('#sl-status', (node) =>
[
node.tagName,
node.textContent,
node.getAttribute('aria-pressed'),
].join(':'),
),
'BUTTON:ON:true',
);
await page.click('#sl-status');
await page.waitForFunction(
() => !window.__godsEyeView.dataManager.isEnabled('street-level'),
{ timeout: 15_000 },
);
await page.waitForFunction(
() => document.getElementById('sl-status').textContent === 'OFF',
{ timeout: 5_000 },
);
assert.equal((await ui()).coverage.count, 0);
await page.click('#sl-status');
await page.waitForFunction(
() => {
const dm = window.__godsEyeView.dataManager;
const u = dm.layers.get('street-level').module.getUIState();
return (
dm.isEnabled('street-level') &&
u.coverage.count > 0 &&
!u.coverage.loading
);
},
{ timeout: 90_000 },
);
assert.equal(
await page.$eval('#sl-status', (node) =>
node.getAttribute('aria-pressed'),
),
'true',
);
},
);
await step(
'the only lit provider chip switches the whole layer off, credit and all',
async () => {
await page.click(chip);
await page.waitForFunction(
() =>
!window.__godsEyeView.dataManager.isEnabled('street-level') &&
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState().coverage.count === 0,
{ timeout: 15_000 },
);
const state = await ui();
assert.equal(state.providers[0].on, false);
assert.deepEqual(state.legend, [], 'no active provider, no legend');
await page.waitForFunction(
() => !document.body.innerHTML.includes('Mapillary</a> contributors'),
{ timeout: 15_000 },
);
assert.deepEqual(
await page.evaluate(() =>
window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getParams(),
),
{ mapillary: false, pano: 'all', sinceDays: 0 },
assert.equal(
await page.$eval(chip, (node) => node.getAttribute('aria-pressed')),
'false',
);
// The provider stays switched on, so the layer comes back with it.
assert.equal((await ui()).providers[0].on, true);
},
);
await step(
'lighting the chip turns the layer back on with coverage',
async () => {
await page.click(chip);
await page.waitForFunction(
() => {
const dm = window.__godsEyeView.dataManager;
const u = dm.layers.get('street-level').module.getUIState();
return (
dm.isEnabled('street-level') &&
u.coverage.count > 0 &&
!u.coverage.loading
);
},
{ timeout: 90_000 },
);
assert.equal(
await page.$eval(chip, (node) => node.getAttribute('aria-pressed')),
'true',
);
},
);
await step('switching it back on restores coverage', async () => {
await page.click('#sl-provider-chips [data-chip-id="mapillary"]');
await page.waitForFunction(
() => {
const u = window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState();
return u.coverage.count > 0 && !u.coverage.loading;
},
{ timeout: 90_000 },
);
assert.equal((await ui()).providers[0].on, true);
});
await step(
'the 360° filter keeps at most the unfiltered sequence count',
async () => {
@@ -283,6 +335,41 @@ async function main() {
assert.equal((await ui()).coverage.count, before);
},
);
await step(
'the SINCE slider narrows coverage and names its cut-off date',
async () => {
const before = (await ui()).coverage.count;
const setStop = (index) =>
page.$eval(
'#sl-since',
(input, value) => {
input.value = String(value);
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
},
index,
);
await setStop(5);
await sleep(600);
const narrowed = await ui();
assert.equal(narrowed.filter.sinceDays, 365);
assert.ok(
narrowed.coverage.count <= before,
`${narrowed.coverage.count} ≤ ${before}`,
);
assert.match(
await page.$eval('#sl-since-label', (node) => node.textContent),
/^LAST YEAR · SINCE \d{4}-\d{2}-\d{2}$/,
);
await setStop(0);
await sleep(600);
assert.equal((await ui()).filter.sinceDays, 0);
assert.equal(
await page.$eval('#sl-since-label', (node) => node.textContent),
'ANY DATE',
);
},
);
await step(
'opening the nearest image shows the viewer with a caption',
async () => {
@@ -347,7 +434,29 @@ async function main() {
assert.ok(view.width > 200);
assert.ok(view.when.length > 0, 'caption shows the capture date');
assert.equal(street.providerId, 'mapillary');
firstImageId = street.imageId;
assert.equal(view.link, 'MAPILLARY ↗');
const fit = await page.evaluate(() => {
const inner = document.querySelector('.street-level-panel-inner');
const box = inner.getBoundingClientRect();
const wrap = document
.getElementById('sl-viewer-wrap')
.getBoundingClientRect();
return {
scrollTop: inner.scrollTop,
top: wrap.top - box.top,
overflowBottom: wrap.bottom - box.bottom,
};
});
assert.equal(fit.scrollTop, 0, 'panel not scrolled');
assert.ok(
fit.top >= 0 && fit.top < 80,
`viewer right under the header (${fit.top}px)`,
);
assert.ok(
fit.overflowBottom <= 1,
`whole viewer visible (${fit.overflowBottom}px cut)`,
);
assert.match(view.href, /mapillary\.com\/app\/\?pKey=/);
},
);
@@ -396,6 +505,122 @@ async function main() {
assert.equal(after.open, false);
assert.equal(after.sequence, null);
});
const floating = () =>
page.$eval('#street-level-panel', (node) =>
node.classList.contains('panel-floating'),
);
const center = (selector) =>
page.$eval(selector, (node) => {
const r = node.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
});
const floatPanel = async () => {
const title = await center('#street-level-panel .panel-title');
await page.mouse.move(title.x, title.y);
await page.mouse.down();
await page.mouse.move(title.x - 200, title.y + 40, { steps: 8 });
await page.mouse.move(title.x - 400, title.y + 80, { steps: 8 });
await page.mouse.up();
await sleep(400);
assert.equal(
await floating(),
true,
'a header drag lifts the panel out of the rail',
);
};
await step(
'the panel floats on a header drag, resizes, and the viewer takes the room',
async () => {
await page.evaluate((id) => {
void window.__godsEyeView.dataManager.layers
.get('street-level')
.module.openImage('mapillary', id);
}, firstImageId);
await page.waitForFunction(
() => {
const s = window.__godsEyeView.dataManager.layers
.get('street-level')
.module.getUIState().street;
return (s.imageId && !s.loading) || s.error;
},
{ timeout: 90_000 },
);
assert.equal((await ui()).street.error, null);
await floatPanel();
const viewerHeight = () =>
page.$eval('#sl-viewer', (node) =>
Math.round(node.getBoundingClientRect().height),
);
const before = await viewerHeight();
const grip = await center('#street-level-panel .panel-resize-grip');
await page.mouse.move(grip.x, grip.y);
await page.mouse.down();
await page.mouse.move(grip.x + 120, grip.y + 160, { steps: 10 });
await page.mouse.up();
await sleep(600);
const after = await viewerHeight();
assert.ok(
after > before + 60,
`viewer grew with the window (${before} → ${after}px)`,
);
assert.equal(
await page.$eval(
'.sl-settings',
(node) => getComputedStyle(node).overflowY,
),
'auto',
'only the settings block scrolls',
);
},
);
await step(
'SHRINK after EXPAND docks the window back in the rail at its default size',
async () => {
await page.click('#sl-viewer-expand');
await sleep(500);
await page.click('#sl-viewer-expand');
await sleep(600);
assert.equal(await floating(), false, 'docked again');
const style = await page.$eval('#street-level-panel', (node) => ({
width: node.style.width,
height: node.style.height,
parent: node.parentElement.id,
}));
assert.deepEqual(style, {
width: '',
height: '',
parent: 'right-context-rail',
});
},
);
await step('a header double-click docks a floating window', async () => {
await floatPanel();
const title = await center('#street-level-panel .panel-title');
await page.mouse.click(title.x, title.y, { clickCount: 1 });
await page.mouse.click(title.x, title.y, { clickCount: 2 });
await sleep(400);
assert.equal(await floating(), false, 'docked again');
});
await step(
'collapsing a floating window docks it as the rail strip',
async () => {
await floatPanel();
await page.click(
'.panel-collapse-btn[data-collapse-target="street-level-panel"]',
);
await sleep(600);
const state = await page.$eval('#street-level-panel', (node) => ({
floating: node.classList.contains('panel-floating'),
collapsed: node.classList.contains('collapsed'),
height: node.style.height,
}));
assert.deepEqual(state, {
floating: false,
collapsed: true,
height: '',
});
},
);
await step('no page errors', () => {
assert.deepEqual(errors, []);
});
+70 -9
View File
@@ -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\(\)/,
);
});
+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>