docs(street-level): shorten docs and comments before merge

Trim the doc text this PR adds (README, CHANGELOG, CURRENT-STATE,
KNOWN-ISSUES, SECURITY, DATA_SOURCES, .env.example, the CI job comment),
drop code comments that restate the code, cut design-note JSDoc to a line
or two, and drop review tags from test names. Comments only; no code
changes.

Also: README key count (seven keys, two metered), CURRENT-STATE's gate
paragraph (CI runs a production build with a dummy token), the stacked
doc above meshBoxInRange and the orphaned inMeshRange line in
coverage.js, and a duplicate ALPR row in DATA_SOURCES that a merge had
brought back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-06 21:59:12 +01:00
1 parent c65feda4b1
commit 4ea6631425
85 files changed
+701 -1520

No files matched your search

+2 -8
View File
@@ -954,8 +954,7 @@ export class StyleManager extends ShellFacade {
this.setPanelCollapsed('street-level-panel', collapsed, options),
dockPanel: () => this._panelChrome.dockPanel('street-level-panel'),
showToast: (message) => this._showToast(message),
// Who asked for Street Level to switch on: only a user, voice or tool
// request opens the panel, never a saved-state restore.
// Lets the panel open for a user, voice or tool switch-on, not a restore.
subscribeEnableRequests: (listener) =>
this._dataManager?.subscribeVisibilityRequests?.((change) => {
if (change?.layerId === 'street-level' && change.enabled)
@@ -1509,12 +1508,7 @@ export class StyleManager extends ShellFacade {
this._initCockpitDisplayPortal();
}
/**
* 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}
*/
/** A portable panel was resized or docked: refit any viewport inside it. */
_onPanelResized(panelId) {
if (panelId === 'cctv-panel') this._syncCctvPanelViewport();
if (panelId === 'street-level-panel')
+1 -1
View File
@@ -28,7 +28,7 @@ export function syncChipGroup(container, chips = [], { before = null } = {}) {
`data-toggle-chip chip-${state}${chip.active ? ' active' : ''}`,
);
set(button, 'textContent', chip.label);
// Optional per-chip colour (a Street Level source); CSS decides its use.
// Optional per-chip colour; CSS decides how it is used.
const color = chip.color || '';
if ((button.dataset.chipColor || '') !== color) {
if (color) {
+4 -10
View File
@@ -388,11 +388,7 @@ export class PanelChrome {
this.shareLinkManager?.onPanelStateChange?.();
}
/**
* Dock a floating portable panel back into its rail at its default size.
* @param {string} panelId
* @returns {boolean} Whether the panel was floating.
*/
/** @returns {boolean} Whether the panel was floating and is now docked. */
dockPanel(panelId) {
return this._panelPosition?.dockPanel?.(panelId) === true;
}
@@ -419,8 +415,7 @@ export class PanelChrome {
panelEl.classList.contains('cyber-accordion-collapsed');
// A user opening a Cyber rail panel owns the whole accordion, including
// peers that were only presentation-collapsed during a saved-state restore.
// A floating window is outside the accordion both ways: opening one
// leaves the rail's panels as they are.
// A floating window is outside the accordion both ways.
if (
explicit &&
!restore &&
@@ -433,7 +428,6 @@ export class PanelChrome {
if (
peer !== panelEl &&
peer.matches('[data-panel-id]') &&
// A floating window is not part of the rail's accordion.
!peer.classList.contains('panel-floating') &&
!peer.hidden
) {
@@ -562,8 +556,8 @@ export class PanelChrome {
}
}
panelEl.classList.toggle('collapsed', nextCollapsed);
// Only a user's own collapse docks a floating window; cockpit entry,
// restores and accordion peers must not discard where they put it.
// Only a user's own collapse docks a floating window; restores, cockpit
// entry and accordion peers keep its place.
if (explicit && !restore)
this._panelPosition?.onPanelCollapsed?.(panelId, nextCollapsed);
if (
+3 -4
View File
@@ -377,10 +377,9 @@ test('a header press that travels under the drag threshold is a click, not a lif
});
/**
* Both portable panels wired through _initPanelDrag. Each box follows its
* inline left/top/width/height like a fixed window (`box` holds the natural
* size), its header is the top 36 px, and `obstacles` stand in for the
* command dock and voice pill. ResizeObserver callbacks run on `observe()`.
* Both portable panels wired through _initPanelDrag. Headers are the top
* 36 px; `obstacles` stand in for the command dock and voice pill.
* ResizeObserver callbacks run on `observe()`.
*/
function portableShell(f, { stored = {}, box = {}, obstacles = {} } = {}) {
const observers = [];
+1 -2
View File
@@ -205,8 +205,7 @@ export class PanelLayoutController {
this._streetLevelPanel,
]) {
if (!panel) continue;
// A portable panel restored as a floating window keeps the place and
// size PanelPositionControls gave it; only docked panels are reset.
// A floating window keeps its restored place and size.
if (!panel.classList.contains('panel-floating')) {
for (const property of ['top', 'right', 'bottom', 'left', 'z-index'])
panel.style.removeProperty(property);
+35 -99
View File
@@ -13,37 +13,29 @@ const PANEL_POSITION_STORAGE_VERSION = 'v8';
const PANEL_Z_BASE = 100;
const PANEL_Z_MAX = 139;
/**
* Lowest z a promoted (dragged or floating) panel may take: above every panel
* docked in a rail, the highest of which is #pp-toggles at 110 (controls.css).
* Renumbering restarts here, so a floating window never slips under DISPLAY.
* Keep in step with `#right-context-rail > .panel-floating` in controls.css.
* Lowest z for a promoted panel: above every docked rail panel (#pp-toggles is
* 110). Keep in step with `#right-context-rail > .panel-floating` in controls.css.
*/
const PANEL_Z_FLOATING_FLOOR = PANEL_Z_BASE + 11;
/** Pointer travel before a header press becomes a drag that lifts a portable panel out. */
/** Pointer travel before a press becomes a drag or resize. */
const DRAG_THRESHOLD_PX = 4;
/**
* Two header presses this close in time and space snap a floating panel
* back. Detected from pointerdown because the drag handler's
* preventDefault() suppresses the mouse events a native dblclick needs.
* Double press that snaps a floating panel back. Detected from pointerdown
* because the drag's preventDefault() suppresses a native dblclick.
*/
const DOUBLE_PRESS_MS = 400;
const DOUBLE_PRESS_SLOP_PX = 6;
/** Viewport margin every positioned panel keeps clear. */
const VIEWPORT_MARGIN_PX = 6;
/**
* Fixed chrome that paints over every panel: the rail is its own stacking
* context (z 110), below the command dock (145) and the voice pill (150). A
* floating window keeps its header clear of these so it can always be
* grabbed again.
* Fixed chrome that paints over the rail's stacking context; a floating
* window keeps its header clear of it so it can always be grabbed again.
*/
const PANEL_OBSTACLE_IDS = ['command-dock', 'gev-voice-control'];
/** One-time hint shown the first time a portable panel leaves its rail. */
const PANEL_FLOAT_HINT_STORAGE_KEY = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`;
const PANEL_FLOAT_HINT = 'Double-click the header to snap the panel back';
/** Header children whose own interaction wins over a drag or a snap-back. */
const INTERACTIVE_SELECTOR =
'input, select, option, textarea, button, a, [role="button"]';
/** Inline geometry a snap-back clears so the rail owns placement again. */
const FLOATING_STYLE_PROPERTIES = [
'left',
'top',
@@ -51,7 +43,6 @@ const FLOATING_STYLE_PROPERTIES = [
'bottom',
'width',
'height',
// A docked panel takes the rail's stacking, not a floating window's.
'z-index',
];
@@ -59,7 +50,6 @@ function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
/** A window extent that fits the viewport margins but never drops under its minimum. */
function fitWindowExtent(value, minimum, viewportExtent) {
return clamp(
Math.round(value),
@@ -68,11 +58,6 @@ function fitWindowExtent(value, minimum, viewportExtent) {
);
}
/**
* Only a mouse or pen lifts and resizes a portable panel. On a phone the
* rail is a scroll container, so a finger on a header is a swipe.
*/
/** Pin a panel's top-left corner at (left, top) as a fixed window would sit. */
function placeAt(panelEl, left, top) {
panelEl.style.left = `${left}px`;
panelEl.style.top = `${top}px`;
@@ -80,27 +65,19 @@ function placeAt(panelEl, left, top) {
panelEl.style.bottom = 'auto';
}
/** On a phone the rail scrolls, so a finger on a header is a swipe, not a drag. */
function isPrecisePointer(event) {
const pointerType = event.pointerType || 'mouse';
return pointerType === 'mouse' || pointerType === 'pen';
}
/** Inline geometry a cancelled gesture puts back. */
function geometryOf(panelEl) {
const { left, top, width, height } = panelEl.style;
return { left, top, width, height };
}
export class PanelPositionControls {
/**
* @param {object} options
* @param {Function} options.syncPanelCollapseButton
* @param {Function} options.layoutRightPanels
* @param {Function} options.syncCctvPanelViewport
* @param {Function} options.showToast
* @param {(panelId: string) => void} [options.onPanelResized] Fires after a
* portable panel's resize gesture ends or the panel snaps back to its rail.
*/
/** `onPanelResized(panelId)` runs after a portable panel moves, resizes or docks. */
constructor({
syncPanelCollapseButton,
layoutRightPanels,
@@ -117,7 +94,6 @@ export class PanelPositionControls {
this._panelZCounter = PANEL_Z_BASE + 10;
this._draggableResizeObserver = null;
this._cancelDrag = null;
/** Portable panels by id: `{ panel, handle, min: { width, height }, dockOnCollapse }`. */
this._portablePanels = new Map();
this._resizeHandles = [];
this._dragInitialized = false;
@@ -154,10 +130,7 @@ export class PanelPositionControls {
}
}
/**
* Bring a floating window back on-screen. Its size is the user's, except
* that a window wider or taller than the viewport shrinks to fit it.
*/
/** Bring a floating window on-screen; shrink it only if it exceeds the viewport. */
_reclampPortablePanel(entry) {
const { panel, min } = entry;
const left = parseInt(panel.style.left, 10);
@@ -178,15 +151,8 @@ export class PanelPositionControls {
}
/**
* Where a floating window may sit: inside the viewport margin, with its
* header out of the command dock and the voice pill, measured now because
* the dock grows and shrinks. Both sit at the foot of the screen, so the
* header goes above whichever it would land under.
* @param {object} entry A `_portablePanels` value.
* @param {number} left
* @param {number} top
* @param {{width: number, height: number, header: number}} [geometry]
* Window size and header depth; measured when omitted.
* Clamp a floating window inside the viewport margin with its header above
* the command dock and voice pill, measured each time since the dock resizes.
*/
_clampPortableWindow(
entry,
@@ -224,7 +190,6 @@ export class PanelPositionControls {
return { left: x, top: y };
}
/** A window's size and how far down its header reaches. */
_windowGeometry({ panel, handle }, rect = panel.getBoundingClientRect()) {
const handleRect = handle?.getBoundingClientRect();
const header = handleRect ? handleRect.bottom - rect.top : 0;
@@ -269,9 +234,8 @@ export class PanelPositionControls {
this._dragInitialized = true;
const cctvPanel = document.getElementById('cctv-panel');
const streetLevelPanel = document.getElementById('street-level-panel');
// A `portable` spec lifts out of its rail on a header drag, resizes from
// every edge and remembers its window; the others only reposition in
// place. Another panel opts in by adding a spec here.
// A `portable` panel lifts out of its rail on a header drag, resizes from
// every edge and remembers its window; the others only reposition.
const dragSpecs = [
{
id: 'pp-toggles',
@@ -316,8 +280,8 @@ export class PanelPositionControls {
}
// Keep a positioned panel on-screen when its HEIGHT changes after restore — it expands to its
// full row set a frame or two later, so the restore-time clamp used a stale (shorter) height and
// the panel could still hang off the bottom (audit U2). Re-clamp on every size change. A floating
// window grows the same way when its content does (an image opens, a collapsed strip expands).
// the panel could still hang off the bottom (audit U2). Re-clamp on every size change. Floating
// windows grow the same way when their content does.
const observed = [
this._ppToggles,
...[...this._portablePanels.values()].map(({ panel }) => panel),
@@ -403,14 +367,12 @@ export class PanelPositionControls {
const pos = JSON.parse(raw);
if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number')
return;
// A floating record only means something to a panel that can float; a
// portable panel in turn only ever stores floating records.
// Only portable panels store, and accept, floating records.
const portable = this._portablePanels.get(panelId);
if (Boolean(pos.floating) !== Boolean(portable)) return;
if (portable) {
panelEl.classList.add('panel-floating', 'panel-draggable');
// A restored window must stack above the docked rail panels too, not
// only one that was lifted or clicked this session.
// A restored window must stack above the docked rail panels too.
this._promotePanelZ(panelEl);
if (Number.isFinite(pos.width)) {
panelEl.style.width = `${fitWindowExtent(
@@ -504,7 +466,7 @@ export class PanelPositionControls {
if (localStorage.getItem(PANEL_FLOAT_HINT_STORAGE_KEY)) return;
localStorage.setItem(PANEL_FLOAT_HINT_STORAGE_KEY, '1');
} catch {
// storage unavailable: still worth saying once this session
// storage unavailable: still show it once this session
if (this._floatHintShown) return;
this._floatHintShown = true;
}
@@ -512,9 +474,8 @@ export class PanelPositionControls {
}
/**
* Switch a portable panel from rail flow to a fixed window at the place it
* currently occupies. Width is frozen so a `width: 100%` rail panel keeps
* its size once fixed-positioned; height stays natural until a resize.
* Turn a rail panel into a fixed window in place. Width is frozen so a
* `width: 100%` panel keeps its size; height stays natural until a resize.
*/
_liftPanelOut(panelId, panelEl, rect = panelEl.getBoundingClientRect()) {
if (panelEl.classList.contains('panel-floating')) return false;
@@ -522,7 +483,6 @@ export class PanelPositionControls {
if (!panelEl.classList.contains('collapsed')) {
panelEl.style.width = `${Math.round(rect.width)}px`;
}
// Rail bookkeeping no longer applies to a window the rail does not allocate.
panelEl.style.removeProperty('--right-panel-allocated-height');
panelEl.removeAttribute('aria-hidden');
panelEl.classList.add('panel-floating', 'panel-draggable');
@@ -619,13 +579,9 @@ export class PanelPositionControls {
}
/**
* Follow one pointer gesture on the window; every panel gesture runs on
* this. With a `threshold`, a press only becomes a gesture once the pointer
* has travelled that far: `onStart` runs then, and `onMove` (given the
* travel so far) only after it. `onEnd` (pointerup) and `onCancel`
* (pointercancel, `onEnd` unless given) learn whether it started. A new
* press, a snap-back or destroy() stops it through `_cancelDrag`, which
* calls neither.
* Track one pointer gesture. With a `threshold`, `onStart` and `onMove` wait
* until the pointer has travelled that far; `onEnd`/`onCancel` get whether
* it started. Stopping through `_cancelDrag` calls neither.
*/
_trackPointerGesture(
event,
@@ -665,18 +621,7 @@ export class PanelPositionControls {
window.addEventListener('pointercancel', cancel);
}
/**
* Portable drag: a press on the header only becomes a drag after a few
* pixels of travel, at which point the panel lifts out of its rail. The
* collapse button still toggles on a plain click, and the click that ends
* a real drag is swallowed so it cannot toggle by accident. Double-clicking
* the header snaps the panel back.
*/
/**
* Put a floating portable panel back in its rail at its default size.
* @param {string} panelId
* @returns {boolean} Whether the panel was floating.
*/
/** @returns {boolean} Whether the panel was floating and is now docked. */
dockPanel(panelId) {
const portable = this._portablePanels.get(panelId);
if (!portable?.panel.classList.contains('panel-floating')) return false;
@@ -684,18 +629,17 @@ export class PanelPositionControls {
return true;
}
/**
* A panel was collapsed or expanded. A portable panel that opted into
* `dockOnCollapse` returns to its rail when collapsed while floating.
* @param {string} panelId
* @param {boolean} collapsed
*/
/** A `dockOnCollapse` panel returns to its rail when collapsed while floating. */
onPanelCollapsed(panelId, collapsed) {
if (!collapsed || !this._portablePanels.get(panelId)?.dockOnCollapse)
return;
this.dockPanel(panelId);
}
/**
* The collapse button still toggles on a plain click; the click that ends a
* real drag is swallowed so it cannot toggle by accident.
*/
_makePortablePanelDraggable(panelId, panelEl, handleEl) {
let swallowNextClick = false;
this.listen(
@@ -790,11 +734,7 @@ export class PanelPositionControls {
});
}
/**
* The browser took a gesture back (pointercancel): leave no trace and save
* nothing. A panel the gesture lifted returns to its rail; a window that
* was already floating returns to where it was.
*/
/** On pointercancel, restore the panel as the gesture found it; save nothing. */
_revertPanelGesture(panelId, panelEl, lifted, before) {
if (lifted) {
this._resetPanelPosition(panelId);
@@ -804,11 +744,7 @@ export class PanelPositionControls {
this._onPanelResized?.(panelId);
}
/**
* Append a resize handle for every edge and corner. Seven are invisible
* strips; the bottom-right corner shows a grip. Resizing a docked panel
* lifts it out first, so the handles work in both states.
*/
/** Resizing a docked panel lifts it out first, so handles work in both states. */
_makePanelResizable(panelId, panelEl, min = {}) {
const limits = { width: 300, height: 160, ...min };
for (const dir of RESIZE_DIRECTIONS) {
@@ -832,8 +768,8 @@ export class PanelPositionControls {
if (event.button !== 0 || !isPrecisePointer(event)) return;
event.preventDefault();
this._cancelDrag?.();
// Set once the pointer has travelled: a plain click on an edge (or on
// the globe just beside a docked panel) must not lift it out.
// Set once the pointer has travelled: a plain click on an edge must not
// lift the panel out.
let startBox = null;
let lifted = false;
let before = null;
@@ -874,7 +810,7 @@ export class PanelPositionControls {
onStop: () => panelEl.classList.remove('panel-resizing'),
onEnd: (resized) => {
if (!resized) return;
// Re-clamping skipped the live gesture; a north edge may have carried
// Re-clamping skipped the live gesture; a north edge may have moved
// the header under the command dock.
const entry = this._portablePanels.get(panelId);
if (entry) this._reclampPortablePanel(entry);
+2 -4
View File
@@ -357,10 +357,8 @@ test('right rail aligns to the current left rail and excludes hidden obstacles',
assert.equal(f.stack.dataset.safeTop, '200.0');
});
// The left pass commits its top and requests this pass in the same frame, but
// `top` then animates for 150 ms and nothing re-runs the right pass when it
// lands. CI docked the right rail at 234 px (26vh) while the left settled at
// 266.4 px (29.6vh). Mid-flight the right rail must read the destination.
// The left rail's `top` animates after the right pass runs, and nothing
// re-runs it when the transition lands: the right rail must read the end value.
function animatingLeftRail({ keyframes = true } = {}) {
const f = fixture('right');
const left = f.options.leftStack;
+3 -21
View File
@@ -1,11 +1,6 @@
/**
* Pure geometry for resizing a floating panel from any edge or corner. The
* DOM wiring lives in PanelPositionControls; this module only decides boxes.
*/
// Pure resize geometry for floating panels; PanelPositionControls wires the DOM.
/** Viewport margin a resized panel keeps clear on every side. */
const EDGE_MARGIN_PX = 6;
/** Resize directions: compass edges first, then corners. */
export const RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'sw', 'se'];
function clamp(value, min, max) {
@@ -13,21 +8,8 @@ function clamp(value, min, max) {
}
/**
* Work out the box a resize gesture produces. Edges named in `dir` move with
* the pointer; the opposite edge stays put, so growing from the left keeps
* the right side pinned. Minimum size and the viewport margin win over the
* pointer.
* @param {{left: number, top: number, width: number, height: number}} box Box at gesture start.
* @param {string} dir One of RESIZE_DIRECTIONS.
* @param {number} dx Pointer travel since gesture start.
* @param {number} dy Pointer travel since gesture start.
* @param {object} [limits]
* @param {number} limits.minWidth
* @param {number} limits.minHeight
* @param {number} [limits.viewportWidth=Infinity]
* @param {number} [limits.viewportHeight=Infinity]
* @param {number} [limits.margin=6]
* @returns {{left: number, top: number, width: number, height: number}}
* Edges named in `dir` follow the pointer (dx, dy since gesture start) and the
* opposite edge stays put. Minimum size and the viewport margin win.
*/
export function resizeBox(
box,
+4 -12
View File
@@ -12,16 +12,9 @@ import { displayPanelScroller } from './displayPanelScroll.js';
const pendingCollapseRetries = new WeakSet();
/**
* Read where the left rail's top edge is going to settle, not where it is.
* The left pass commits `--left-stack-safe-top` and requests this pass in the
* same frame, but `top` then animates; nothing re-runs this pass when the
* animation ends, because ResizeObserver ignores moves. Mid-flight, take the
* running transition's end value (which also covers Cyber's fixed rail top),
* falling back to the left pass's committed target.
* @param {HTMLElement} leftStack Rail supplying the shared top baseline.
* @param {number} viewportHeight Current viewport height in px.
* @param {Function} getComputedStyle DOM style reader.
* @returns {number} Settled top in px, or NaN when there is no left rail.
* Where the left rail's top will settle, not where it is mid-transition:
* nothing re-runs this pass when the `top` animation ends (ResizeObserver
* ignores moves). Returns NaN when there is no left rail.
*/
function settledLeftRailTop(leftStack, viewportHeight, getComputedStyle) {
const top = leftStack?.getBoundingClientRect().top;
@@ -76,8 +69,7 @@ export function layoutRightPanelRail({
// A collapse may schedule one follow-up, which only measures and allocates.
const isCollapseRetry = pendingCollapseRetries.delete(stack);
// A panel lifted out of the rail (PanelPositionControls' portable mode)
// is a fixed window the rail neither measures nor allocates.
// A floating (lifted-out) panel is not laid out by the rail.
const panels = [...stack.children].filter(
(panel) =>
panel.matches('[data-panel-id]:not(.panel-floating)') && !panel.hidden,
+24 -64
View File
@@ -8,7 +8,6 @@ import {
const RENDER_MODE_KEY = 'gev:street-level:render-mode';
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
/** Arrow keys inside a radiogroup: the step to the next radio. */
const RADIO_STEPS = Object.freeze({
ArrowRight: 1,
ArrowDown: 1,
@@ -26,11 +25,8 @@ const setAttr = (node, key, value) => {
};
/**
* Own the Street Level panel: the provider chips, the imagery filters, the
* legend and the embedded street-level viewer. The panel itself is
* ordinary GEV chrome (collapse button, rail layout, persistence) driven by
* the application shell; this class only fills the body and asks the shell
* to open the panel when something worth seeing arrives.
* Fills the Street Level panel body: provider chips, filters, legend and
* viewer. The panel chrome itself belongs to the application shell.
*/
export class StreetLevelControls {
constructor({ root, layer, actions }) {
@@ -47,21 +43,14 @@ export class StreetLevelControls {
// Until the user first touches the app, an off → on is the saved layer
// state being restored, not a user switching the layer on.
this._restoreWindow = true;
/**
* Whether the last request to switch the layer on came from the user
* (or voice, or a tool) rather than a restore; null when the shell does
* not report requests, and the restore window decides instead.
*/
// Whether the last switch-on was explicit (user, voice, tool) rather than
// a restore; null when the shell does not report request origins.
this._explicitEnable = null;
this._unsubscribeEnableRequests = null;
this._resizeQueued = false;
this._wrapHome = null;
this._expandReturnFocus = null;
/**
* The <body> children this class made inert while the expanded viewer is
* a modal on screen; only these are released again.
* @type {Set<Element>}
*/
// Only the <body> children this class made inert are released again.
this._inerted = new Set();
this._modalObserver = null;
this._elements = this._collect();
@@ -111,7 +100,6 @@ export class StreetLevelControls {
this.layer.attachViewerHost?.(el.viewer);
this.listen(el.status, 'click', () => this._toggleEnabled());
// One delegated listener; chips are re-synced in place on every render.
this.listen(el.providerChips, 'click', (event) => {
const button = event.target?.closest?.('.data-toggle-chip');
if (!button || button.disabled) return;
@@ -185,11 +173,7 @@ export class StreetLevelControls {
}
}
/**
* One radiogroup (ARIA radio pattern): a single tab stop, arrow keys move
* the selection, Home and End jump to the ends. Selecting is the radio's
* own click, so the keyboard takes the same path as the pointer.
*/
/** ARIA radio keys. Selecting clicks the radio, so keys and pointer share a path. */
_bindRadioGroup(buttons) {
buttons.forEach((button, index) => {
this.listen(button, 'keydown', (event) => {
@@ -208,9 +192,8 @@ export class StreetLevelControls {
}
/**
* Refit the viewer once, on the next frame, however many asked. A hidden
* viewer measures 0×0 and would ask for tiles at z=NaN; the observer fires
* again once it has a size.
* Refit the viewer once per frame. Skips a hidden (0×0) viewer, which would
* ask for tiles at z=NaN; the observer fires again once it has a size.
*/
_requestResize() {
if (this._resizeQueued) return;
@@ -247,11 +230,7 @@ export class StreetLevelControls {
}
}
/**
* Filter and provider changes go through the data manager as a user params
* request, so saved state and share links record them; the layer applies
* them through its own `setParams`.
*/
/** Through the data manager, so saved state and share links record it. */
_setParams(params) {
if (this.actions.setParams)
this.actions.setParams(params, { origin: 'user' });
@@ -259,9 +238,8 @@ 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).
* Lighting a chip turns the layer on; darkening the last lit one turns the
* layer off but keeps the provider on, so the layer comes back with it.
*/
async _toggleProvider(providerId) {
const chip = this._view?.providers.find((entry) => entry.id === providerId);
@@ -298,12 +276,10 @@ export class StreetLevelControls {
if (this.layer.getUIState) this.render(this.layer.getUIState());
}
/** The panel window was resized or docked again: refit the viewer. */
onPanelResized() {
this._requestResize();
}
/** Ask the shell to open (or close) the rail panel. */
setCollapsed(collapsed, options = {}) {
this.actions.setPanelCollapsed?.(collapsed, options);
if (!collapsed) this._requestResize();
@@ -318,9 +294,8 @@ export class StreetLevelControls {
}
/**
* 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.
* A user's shrink (`dock`) also returns a floating panel to its rail, so the
* viewer does not land in a window over the globe.
*/
setViewerExpanded(expanded, { dock = false } = {}) {
const wrap = this._elements.viewerWrap;
@@ -384,10 +359,7 @@ export class StreetLevelControls {
target?.focus?.({ preventScroll: true });
}
/**
* The expanded viewer holds Esc and Tab only while it is on screen: Clean
* View, recording and the cockpit hide it with CSS.
*/
/** Clean View, recording and the cockpit hide the expanded viewer with CSS. */
_isDialogShown() {
if (!this.isViewerExpanded()) return false;
const wrap = this._elements.viewerWrap;
@@ -402,11 +374,9 @@ export class StreetLevelControls {
}
/**
* Make the application modal-inert while the expanded viewer is on screen:
* every other <body> child is `inert` (one already inert is left alone), so
* no control around the viewer takes a click or focus. A viewer that CSS
* hides (Clean View, recording, cockpit) releases them, since a hidden
* modal must not leave the app dead. Returns whether the dialog is shown.
* Make every other <body> child inert while the expanded viewer is shown.
* A CSS-hidden viewer releases them so a hidden modal never leaves the app
* dead. Returns whether the dialog is shown.
*/
_syncModal() {
if (this.destroyed || !this._isDialogShown()) {
@@ -422,17 +392,12 @@ export class StreetLevelControls {
return true;
}
/** Give the application back every node `_syncModal` made inert. */
_releaseModal() {
for (const node of this._inerted) node.inert = false;
this._inerted.clear();
}
/**
* While expanded, re-check the modal whenever <body> changes class (the
* modes that hide the viewer) or gains a child (which must be inert too).
* Shrinking stops watching and releases the application.
*/
/** Re-check the modal when <body> changes class or gains a child. */
_watchModal(on) {
this._modalObserver?.disconnect();
this._modalObserver = null;
@@ -534,7 +499,6 @@ export class StreetLevelControls {
setProp(el.errorText, 'textContent', view.error || '');
}
/** Mark the checked radio of a group and make it the group's tab stop. */
_renderRadios(buttons, isChecked) {
for (const button of buttons) {
const checked = isChecked(button);
@@ -629,16 +593,14 @@ export class StreetLevelControls {
}
/**
* Open the panel at the moments a user would look for it. These opens are
* never persisted: the stored collapse state stays the user's own choice.
* Open the panel on a switch-on or when an image opens. Never persisted:
* the stored collapse state stays the user's own choice.
*/
_reactToTransitions(view, state) {
const enabled = view.enabled;
if (enabled && this._wasEnabled === false) {
// A restore that turns the saved layer back on must not reopen a panel
// the user (or a share link) chose to keep collapsed. With the shell's
// request origins the panel opens only for a user, voice or tool
// switch-on; without them, the restore window stands in.
// A restore must not reopen a panel the user or a share link kept
// collapsed. Without request origins, the restore window decides.
const preference = this.root.dataset?.collapsedPreference;
const restoring =
this._explicitEnable === null
@@ -650,10 +612,8 @@ export class StreetLevelControls {
this._explicitEnable = this._explicitEnable === null ? null : false;
}
this._wasEnabled = enabled;
// Size changes reach the viewer through the ResizeObserver; resizing on
// every render ran each frame while a panorama was dragged. Opening is the
// one moment the element may not have reported a size yet, and opening
// the panel asks for that one (coalesced) resize.
// Other size changes reach the viewer through the ResizeObserver; opening
// asks for one resize because the element may not have a size yet.
const open = state.street.open === true;
if (open && !this._wasStreetOpen)
this.setCollapsed(false, { persist: false });
+11 -25
View File
@@ -13,10 +13,7 @@ import { fakeStreetLevelProvider } from '../testSupport/streetLevelFakes.mjs';
/* ── A small DOM: just what the panel controls touch ───────────────────── */
/**
* Every write the page would see as a mutation: an attribute set or removed,
* or a reflected property assigned (even to the value it already has).
*/
/** Every write a page would see as a mutation, even one to the same value. */
const mutations = { count: 0 };
const REFLECTED = ['hidden', 'disabled', 'textContent', 'title', 'value'];
/** The reflected properties a MutationObserver sees, as the record it gets. */
@@ -27,10 +24,8 @@ const REFLECTED_RECORDS = {
textContent: { type: 'childList' },
};
/** The fake MutationObservers currently observing (see fakeMutationObserver). */
const liveObservers = new Set();
/** Queue a mutation record for every observer whose options cover it. */
function recordMutation(target, { type, attributeName = null }) {
for (const observer of liveObservers)
observer.consider(target, type, attributeName);
@@ -116,10 +111,7 @@ class FakeNode {
const siblings = this.parent.children;
return siblings[siblings.indexOf(this) + 1] || null;
}
/**
* As layout reports it: null when this node or an ancestor is hidden (no
* box), or when it is not in the document; else its parent.
*/
/** Null when this node or an ancestor is hidden or disconnected. */
get offsetParent() {
if (!this.isConnected) return null;
for (let node = this; node; node = node.parent)
@@ -388,11 +380,7 @@ const memoryStorage = () => {
};
};
/**
* The production path a panel click takes: controls → dataManager
* .setLayerParams → layer.setParams → LayerStateCoordinator's durable state,
* which is what share links and saved state encode.
*/
/** The production path: controls → dataManager → layer → durable layer state. */
async function productionPanel(dom) {
const provider = standInProvider();
const layer = createStreetLevelLayer({ providers: [provider.def] });
@@ -592,7 +580,7 @@ test('lighting a dark chip switches the provider on as a user params request, th
assert.deepEqual(calls.setEnabled, [true]);
}));
test('the legend rebuilds when a swatch changes, even at the same count (review #9)', () =>
test('the legend rebuilds when a swatch changes, even at the same count', () =>
withDom(async (dom) => {
const { layer } = stubPanel(dom, uiState());
const legend = dom.root.querySelector('#sl-legend');
@@ -610,7 +598,7 @@ test('the legend rebuilds when a swatch changes, even at the same count (review
assert.equal(swatch(), '#a66bff');
}));
test('the viewer is resized once when it opens, not on every render (review #8, gekh P3)', () =>
test('the viewer is resized once when it opens, not on every render', () =>
withDom(async (dom) => {
const { layer } = stubPanel(dom, uiState());
await settle();
@@ -714,7 +702,7 @@ test('an image closed while expanded does not return focus into the hidden viewe
controls.destroy();
}));
/* ── gekh review on #768 ───────────────────────────────────────────────── */
/* ── Restores, the modal viewer and keyboard rules ─────────────────────── */
test('a restored layer does not reopen a panel the user collapsed, and automatic opens are never stored (P2-1)', () =>
withDom(async (dom) => {
@@ -811,10 +799,8 @@ test('a hidden expanded viewer (Clean View, recording, cockpit) holds neither Es
}));
/**
* A MutationObserver stand-in. Like the real one it queues only the records
* its `observe` options cover (childList, attributes / attributeFilter,
* subtree) and delivers them in a microtask, only when there are some;
* `fire()` delivers what is queued at once.
* A MutationObserver stand-in: queues only the records its options cover and
* delivers them in a microtask; `fire()` delivers at once.
*/
function fakeMutationObserver() {
const observers = [];
@@ -888,7 +874,7 @@ const inertOutside = (dom) =>
.filter((node) => node.id !== 'sl-viewer-wrap')
.map((node) => [node.id, node.inert === true]);
test('the expanded viewer makes the rest of the app inert and gives it back on shrink (IC8 P2)', () =>
test('the expanded viewer makes the rest of the app inert and gives it back on shrink', () =>
withObserver(() =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
@@ -919,7 +905,7 @@ test('the expanded viewer makes the rest of the app inert and gives it back on s
}),
));
test('a CSS-hidden expanded viewer releases the app; showing it again, and new children, go inert (IC8 P2)', () =>
test('a CSS-hidden expanded viewer releases the app; showing it again, and new children, go inert', () =>
withObserver((observer) =>
withDom(async (dom) => {
const { controls } = stubPanel(dom, uiState({ open: true }));
@@ -954,7 +940,7 @@ test('a CSS-hidden expanded viewer releases the app; showing it again, and new c
}),
));
test('an image closed while expanded releases the app (IC8 P2)', () =>
test('an image closed while expanded releases the app', () =>
withObserver(() =>
withDom(async (dom) => {
const { layer, controls } = stubPanel(dom, uiState({ open: true }));
+7 -26
View File
@@ -1,17 +1,10 @@
import { keySetupRequirement } from '../keySetupCore.mjs';
/**
* Turn the Street Level layer's UI state into the strings and flags the panel
* renders. Pure: no DOM, no layer calls, so every wording decision is testable.
*/
// Street Level UI state to panel strings and flags. Pure, so it is testable.
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.
*/
/** SINCE slider stops, in relative days so a share link keeps its meaning. */
export const SINCE_STOPS = Object.freeze([
Object.freeze({ days: 0, label: 'ANY DATE' }),
Object.freeze({ days: 3652, label: 'LAST 10 YEARS' }),
@@ -24,7 +17,7 @@ export const SINCE_STOPS = Object.freeze([
Object.freeze({ days: 30, label: 'LAST MONTH' }),
]);
/** Slider position for a day count: the exact stop, else the nearest one. */
/** The exact stop for a day count, else the nearest one. */
export function sinceStopIndex(days) {
const value = Number(days) || 0;
if (value <= 0) return 0;
@@ -38,7 +31,6 @@ export function sinceStopIndex(days) {
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);
@@ -61,7 +53,6 @@ function formatDate(ms) {
}
}
/** The header pill doubles as the layer's on/off switch. */
function presentStatus(state) {
const pressed = state.enabled === true;
const title = pressed ? 'Turn Street Level off' : 'Turn Street Level on';
@@ -76,11 +67,7 @@ function presentStatus(state) {
: { text: 'OFF', tone: '', pressed, title };
}
/**
* 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.
*/
/** A chip is lit only while the layer and its provider are both on. */
function presentProviders(state) {
const enabled = state.enabled === true;
return (state.providers || []).map((provider) => {
@@ -148,18 +135,13 @@ function presentMeta(state) {
return state.coverage.hint || '';
}
/**
* @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.
*/
/** @param {{now?: number}} [options] Clock for the SINCE readout. */
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 };
// KEY REQUIRED already says it, and the chip's tooltip says how to fix it;
// the error under it would only repeat a raw code (`no_key`). A rejected
// key keeps its line: that message names the fix.
// A missing key is already shown by the status and chip tooltip; only a
// rejected key keeps its error line, since that message names the fix.
const keyMissing = keyRequired && state.keyRejected !== true;
return {
enabled,
@@ -175,7 +157,6 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
legend: state.legend || [],
viewer: presentViewer(state),
meta: presentMeta(state),
/** The panel opens itself when an image opens (a native panel stays put otherwise). */
wantsOpen: state.street.open === true,
};
}
+2 -2
View File
@@ -65,7 +65,7 @@ test('a key-gated layer disables every control and flags KEY REQUIRED', () => {
assert.equal(view.status.tone, 'warn');
});
test('KEY REQUIRED shows no raw error code under the pill (review P3)', () => {
test('KEY REQUIRED shows no raw error code under the pill', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
@@ -80,7 +80,7 @@ test('KEY REQUIRED shows no raw error code under the pill (review P3)', () => {
assert.match(view.providers[0].title, /^Mapillary: Needs /);
});
test('a key Mapillary rejected reads KEY REJECTED and names the fix (review IC8 P2)', () => {
test('a key Mapillary rejected reads KEY REJECTED and names the fix', () => {
const error =
'Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings';
const view = presentStreetLevelPanel(
+5 -9
View File
@@ -104,10 +104,8 @@
}
/* ── Portable rail panels ────────────────────
A header drag lifts a portable panel (PanelPositionControls) out of its
rail into a fixed window that remembers its place and size. The rail's
own placement rules in layers.css are guarded with :not(.panel-floating),
so these only have to beat a panel's base placement (one id). */
Rail placement in layers.css is guarded with :not(.panel-floating), so
these rules only have to beat a panel's base placement (one id). */
.panel-portable .panel-header {
cursor: grab;
}
@@ -117,16 +115,14 @@
#right-context-rail > .panel-floating {
position: fixed;
inset: auto;
/* Above every docked rail panel (#pp-toggles is 110). PanelPositionControls
promotes floating windows from this floor (PANEL_Z_FLOATING_FLOOR). */
/* Above every docked rail panel; keep in step with PANEL_Z_FLOATING_FLOOR. */
z-index: 111;
max-height: calc(100vh - 12px);
margin: 0;
flex: none;
pointer-events: auto;
}
/* A collapsed window keeps its stored height for later but shows only its
header strip, so the resize handles have nothing to attach to. */
/* A collapsed window shows only its header strip; its inline height stays. */
#right-context-rail > .panel-floating.collapsed {
max-height: max-content;
}
@@ -1110,7 +1106,7 @@ body.ui-clean-view #cctv-panel,
body.ui-clean-view #radio-panel,
body.ui-clean-view #global-context-panel,
body.ui-clean-view #scene-panel,
/* The expanded Street Level viewer lives on <body>, outside every panel. */
/* The expanded viewer lives on <body>, outside every panel. */
body.ui-clean-view .sl-viewer-wrap-expanded {
opacity: 0 !important;
visibility: hidden !important;
+1 -1
View File
@@ -16,7 +16,7 @@ body.recording-mode #radio-panel,
body.recording-mode #global-context-panel,
body.recording-mode #gev-voice-control,
body.recording-mode #scene-panel,
/* The expanded Street Level viewer lives on <body>, outside every panel. */
/* The expanded viewer lives on <body>, outside every panel. */
body.recording-mode .sl-viewer-wrap-expanded {
opacity: 0 !important;
visibility: hidden !important;
+14 -25
View File
@@ -1,7 +1,6 @@
/* ── Street Level panel ─────────────────────────────────────────
Lives in the right context rail next to CCTV; the rail owns position and
width (see layers.css). Everything here is panel-internal. The expanded
viewer is re-parented to <body>, so it carries the tokens too. */
The rail owns position and width (layers.css). The expanded viewer is
re-parented to <body>, so it carries the tokens too. */
#street-level-panel,
.sl-viewer-wrap {
--sl-green: #05cb63;
@@ -111,8 +110,6 @@ button.sl-status:focus-visible {
min-width: 0;
}
/* The filters a missing key gates. The provider chips stay outside it, so
their tooltip (how to add the key) is still reachable. */
.sl-controls {
display: flex;
flex-direction: column;
@@ -128,8 +125,7 @@ button.sl-status:focus-visible {
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. */
/* In a resized window only this block scrolls; the viewer keeps its room. */
.sl-settings {
display: flex;
flex-direction: column;
@@ -151,8 +147,7 @@ button.sl-status:focus-visible {
gap: 4px;
}
/* One colour per source: a swatch dot, and the whole chip in that colour
while the source is on. `--chip-color` comes from the provider. */
/* `--chip-color` comes from the provider. */
.sl-chips .data-toggle-chip {
display: inline-flex;
align-items: center;
@@ -246,7 +241,6 @@ button.sl-status:focus-visible {
color: var(--text-primary);
}
/* SINCE: a stepped slider, oldest window left, newest right. */
.sl-since {
display: grid;
grid-template-columns: auto 1fr;
@@ -318,8 +312,7 @@ button.sl-status:focus-visible {
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. */
/* Nothing sits over the image, so the provider's own controls stay clear. */
.sl-viewer-stage {
position: relative;
display: flex;
@@ -362,7 +355,6 @@ button.sl-status:focus-visible {
}
}
/* Our tools live in a bar above the image rather than over it. */
.sl-viewer-bar {
display: flex;
align-items: center;
@@ -485,16 +477,15 @@ button.sl-status:focus-visible {
}
/* ── Floating window ─────────────────────────────────────────────────────── */
/* A window that was only dragged keeps its natural height; bound it to the
viewport so nothing runs off-screen (the rail rule's max-height: 100% does
not bind an auto-height parent). Id-qualified to outrank that rule. */
/* A dragged window keeps its natural height, so bound it to the viewport:
the rail's max-height: 100% does not bind an auto-height parent.
Id-qualified to outrank that rule. */
#right-context-rail > #street-level-panel.panel-floating > .street-level-panel-inner {
max-height: calc(100vh - 12px);
overflow-y: auto;
}
/* A resized window (PanelPositionControls writes an inline height) hands the
spare height to the viewer, so the image never needs scrolling to. */
/* A resized window (inline height) gives its spare height to the viewer. */
.panel-floating[style*='height'] > .street-level-panel-inner {
box-sizing: border-box;
height: 100%;
@@ -532,9 +523,8 @@ button.sl-status:focus-visible {
}
/* ── 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
above panels (≤139) and below the voice pill (150) and toasts (200). */
/* Re-parented to <body>: the panel's backdrop-filter would contain a fixed
child. z-index sits above panels (≤139), below the voice pill (150). */
.sl-viewer-wrap-expanded {
position: fixed;
left: 4vw;
@@ -604,10 +594,9 @@ button.sl-status:focus-visible {
}
/* ── Phones ──────────────────────────────────────────────────────────────── */
/* At this width the right rail is the band between mid-screen and the credit
line (responsive.css: top 50vh + 8px, bottom 2vh + 7.5rem). Size the photo
from that band, less the panel's header, tool bar and caption (~150px), so
the whole image shows without scrolling; EXPAND still gives full screen. */
/* The phone rail spans 50vh + 8px to 2vh + 7.5rem from the bottom
(responsive.css). Size the photo to that band, less ~150px of panel chrome,
so it shows without scrolling. */
@media (max-width: 720px) {
.sl-viewer {
aspect-ratio: auto;
+3 -4
View File
@@ -71,8 +71,7 @@
</div>
</div>
<!-- Street Level (Mapillary) Panel — moved into #right-context-rail at
runtime next to CCTV (see PanelLayoutController). -->
<!-- Street Level Panel (moved into #right-context-rail at runtime) -->
<div id="street-level-panel" class="panel-collapsible collapsed" data-panel-id="street-level-panel">
<div class="panel-glow"></div>
<div class="street-level-panel-inner" data-rail-scroller>
@@ -116,8 +115,8 @@
<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>
<!-- KEY REQUIRED gates the filters only: the provider chips above
stay focusable so their tooltip can say how to add the key. -->
<!-- A missing key gates the filters only: the chips above stay
focusable so their tooltip can say how to add the key. -->
<fieldset id="sl-controls" class="sl-controls">
<div class="sl-filters" role="group" aria-label="Imagery filters">
<span class="cctv-summary-label">IMAGERY</span>