diff --git a/CHANGELOG.md b/CHANGELOG.md index 9dae7d8af..c2f9d00bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 7810ca83c..048a7613b 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index cd3a18e4f..38be0445a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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.`. 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.` (`'0'` open, `'1'` closed, absent means the panel's own default). +- Panel positions have a versioned storage name, `godsEyeView.v8.panelPos.`. 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.` (`'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.`. - 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`. diff --git a/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 1733888f6..a06a9aa18 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -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 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, []); }); diff --git a/src/streetLevelMarkup.test.mjs b/src/streetLevelMarkup.test.mjs index b39f0a38b..8742533e1 100644 --- a/src/streetLevelMarkup.test.mjs +++ b/src/streetLevelMarkup.test.mjs @@ -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, /
<\/div>/); - assert.match(html, / - - - - - - - -
    -
    - -