mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +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
+6
-5
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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, []);
|
||||
});
|
||||
|
||||
@@ -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