Merge branch 'main' into codex/fix-share-style-keys

Resolve docs/CURRENT-STATE.md (and scripts/format-scope.json) by keeping both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Opus 5.5 committed 2026-10-21 15:04:26 +01:00
commit a451c1ba6f
190 files changed
+9967 -17519

No files matched your search

+8 -12
View File
@@ -1,3 +1,5 @@
import { syncSegmentedControl } from './segmentedControl.js';
/** Own the shared aircraft display preference and its controls. */
export class AircraftDisplay {
constructor({ elements, readDataManager, layout }) {
@@ -28,12 +30,9 @@ export class AircraftDisplay {
this._models3dMode = options.models3dMode === 'all' ? 'all' : 'proximity';
this._syncModels3dButtonState();
this._models3dModeRow?.classList.toggle('visible', this._models3dEnabled);
for (const button of this._models3dModeBtns || []) {
if (!button) continue;
const active = button.dataset.mode === this._models3dMode;
button.classList.toggle('active', active);
button.setAttribute('aria-checked', String(active));
}
syncSegmentedControl(this._models3dModeBtns, this._models3dMode, {
valueOf: (button) => button.dataset.mode,
});
this._layoutRightPanels();
}
@@ -59,12 +58,9 @@ export class AircraftDisplay {
const normalized = mode === 'all' ? 'all' : 'proximity';
this._models3dMode = normalized;
this._setModels3dParams({ models3dMode: normalized });
for (const button of this._models3dModeBtns) {
if (!button) continue;
const active = button.dataset.mode === normalized;
button.classList.toggle('active', active);
button.setAttribute('aria-checked', String(active));
}
syncSegmentedControl(this._models3dModeBtns, normalized, {
valueOf: (button) => button.dataset.mode,
});
this._syncModels3dButtonState();
}
+4 -28
View File
@@ -53,8 +53,7 @@ import { applyLocaleChoice, mountLocalePicker } from './localePicker.js';
* Responsibilities:
* - Visual controls and presets backed by the VisualEffects controller.
* - Bloom and sharpen post-processing toggle/intensity control.
* - Draggable/collapsible panel system with localStorage persistence,
* z-order stacking, and viewport-clamped positioning.
* - Collapsible rail panels with their collapsed state kept in localStorage.
* - CCTV panel: camera selection, coverage toggle, projection, calibration
* sliders, auto-hop, and summary typewriter effect.
* - Location bar with city/POI preset pills, QWERTY key navigation,
@@ -115,7 +114,6 @@ export class StyleManager extends ShellFacade {
_setRadioDisclosure: (...args) => this._setRadioDisclosure(...args),
_syncCctvPanelViewport: (...args) =>
this._syncCctvPanelViewport(...args),
_onPanelResized: (...args) => this._onPanelResized(...args),
_syncContextRadioLauncherState: (...args) =>
this._syncContextRadioLauncherState(...args),
_showToast: (...args) => this._showToast(...args),
@@ -275,6 +273,7 @@ export class StyleManager extends ShellFacade {
_contextControls: this._contextControls,
_cctvControls: this._cctvControls,
_radioControls: this._radioControls,
_streetLevelControls: this._streetLevelControls,
}),
operations: {
_updateTrafficSyncChip: (...args) =>
@@ -1089,13 +1088,11 @@ export class StyleManager extends ShellFacade {
this._streetLevelControls?.destroy();
this._streetLevelControls = null;
if (!this._streetLevelPanel || !streetLevelLayer) return;
// Photo framing and FOLLOW release tracking like any explicit flight.
// Photo framing yields to newer navigation and releases tracking like
// any deferred flight.
streetLevelLayer.attachNavigation?.({
run: (noun, move) => this._runExplicitNavigation(noun, move),
begin: (noun) => this._beginDeferredNavigation(noun),
reassert: (generation) => this._reassertNavigationHandoff(generation),
subscribeHandoff: (listener) =>
this._navigation.subscribeCameraHandoff(listener),
});
this._streetLevelControls = new StreetLevelControls({
root: this._streetLevelPanel,
@@ -1110,7 +1107,6 @@ export class StyleManager extends ShellFacade {
this._dataManager?.setLayerParams('street-level', params, options),
setPanelCollapsed: (collapsed, options) =>
this.setPanelCollapsed('street-level-panel', collapsed, options),
dockPanel: () => this._panelChrome.dockPanel('street-level-panel'),
showToast: (message) => this._showToast(message),
// Lets the panel open for a user, voice or tool switch-on, not a restore.
subscribeEnableRequests: (listener) =>
@@ -1207,19 +1203,6 @@ export class StyleManager extends ShellFacade {
return true;
}
/**
* Makes a panel draggable via its handle element. Implements:
* - Z-order promotion: each pointerdown increments the global z-counter
* so the clicked panel floats above siblings.
* - Viewport clamping: drag moves are clamped to a 6px inset from all edges.
* - Right-rail pinning: pp-toggles panel is re-anchored right after drag.
* - CCTV viewport sync: cctv-panel recalculates scroll height after drag.
* @param {string} panelId - DOM id of the panel.
* @param {HTMLElement} panelEl - The panel DOM element.
* @param {HTMLElement} handleEl - The drag handle element within the panel.
* @returns {void}
*/
/**
* Programmatically collapses or expands a panel, persists the state,
* and triggers layout recalculation for dependent panels.
@@ -1666,13 +1649,6 @@ export class StyleManager extends ShellFacade {
this._initCockpitDisplayPortal();
}
/** 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')
this._streetLevelControls?.onPanelResized();
}
/**
* Recalculates the CCTV panel max-height based on its current top position
* and the window height, enabling internal scroll without viewport overflow.
-13
View File
@@ -85,16 +85,3 @@ test('non-Cyber explicit panels and restored preferences retain their existing p
} finally { globalThis.document = prior; }
}
});
test('opening a floating Cyber window leaves the docked rail panels open', () => {
const f = fixture(), prior = globalThis.document;
globalThis.document = f.doc;
try {
f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true });
f.cctv.classList.add('panel-floating');
f.owner.setPanelCollapsed('cctv-panel', false, { explicit: true });
assert.equal(f.cctv.classList.contains('collapsed'), false);
assert.equal(f.display.classList.contains('collapsed'), false, 'DISPLAY stays open');
assert.ok(!f.saved.some(([id, collapsed]) => id === 'pp-toggles' && collapsed));
} finally { globalThis.document = prior; }
});
+21 -6
View File
@@ -1,3 +1,5 @@
import { createSegmentedControl } from './segmentedControl.js';
/**
* Bind Display controls to application actions. Inputs remain native DOM controls;
* the caller owns settings and effect state. Destroy before replacing the view.
@@ -44,12 +46,25 @@ export function bindDisplayControls({ elements, actions }) {
listen(elements[name], 'input', action, integer);
for (const el of elements.styleButtons || [])
listen(el, 'click', 'setStyle', (el) => el.dataset.style);
for (const el of elements.allocationButtons || [])
listen(el, 'click', 'setAllocation', (el) => el.dataset.allocation);
for (const el of elements.modelModeButtons || [])
listen(el, 'click', 'setModelsMode', (el) =>
el.dataset.mode === 'all' ? 'all' : 'proximity',
);
for (const [buttons, valueOf, action] of [
[
elements.allocationButtons || [],
(el) => el.dataset.allocation,
'setAllocation',
],
[
elements.modelModeButtons || [],
(el) => (el.dataset.mode === 'all' ? 'all' : 'proximity'),
'setModelsMode',
],
]) {
const control = createSegmentedControl({
buttons,
valueOf,
onChange: (value) => actions[action](value),
});
removers.push(() => control.destroy());
}
for (const el of elements.fadeSliders || []) listen(el, 'input', 'setFade');
return {
destroy() {
+5
View File
@@ -55,6 +55,9 @@ export class LayerBindings {
get _radioControls() {
return this.readControls()._radioControls;
}
get _streetLevelControls() {
return this.readControls()._streetLevelControls;
}
observeCamera() {
this._cctvRequestFocusHandler = (event) =>
routeCctvFocusRequest(
@@ -235,6 +238,8 @@ export class LayerBindings {
this._syncContextModeButtons();
this._cctvControls.connect();
this._radioControls.connect();
// Its switch-on requests come from the manager, which arrives after it.
this._streetLevelControls?.connect();
this._connectDirectionsCamera();
this._connectWeatherCamera();
if (!this._awarenessSelectedHandler) {
+1 -18
View File
@@ -61,10 +61,7 @@ export class PanelChrome {
this._cockpitContextCollapsedForDataPanel = false;
this._panelPosition = new PanelPositionControls({
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
layoutRightPanels: () => this._layoutRightPanels(),
syncCctvPanelViewport: () => this._syncCctvPanelViewport(),
showToast: (message) => this._showToast(message),
onPanelResized: (panelId) => this._onPanelResized(panelId),
});
this._panelLayout = new PanelLayoutController({
readHud: () => ({
@@ -149,7 +146,6 @@ export class PanelChrome {
this._initCommandDockPins();
this._initCommandDockTrayMetrics();
this._maybeNotifyLayoutReset();
this._panelPosition._initPanelDrag();
}
_collapsePanelOnEscape(event, panelId) {
@@ -416,11 +412,6 @@ export class PanelChrome {
this.shareLinkManager?.onPanelStateChange?.();
}
/** @returns {boolean} Whether the panel was floating and is now docked. */
dockPanel(panelId) {
return this._panelPosition?.dockPanel?.(panelId) === true;
}
setPanelCollapsed(
panelId,
collapsed,
@@ -443,20 +434,17 @@ 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.
if (
explicit &&
!restore &&
!nextCollapsed &&
document.documentElement?.dataset.uiTheme === 'cyber' &&
panelEl.parentElement === this._rightPanelStack &&
!panelEl.classList.contains('panel-floating')
panelEl.parentElement === this._rightPanelStack
) {
for (const peer of this._rightPanelStack.children) {
if (
peer !== panelEl &&
peer.matches('[data-panel-id]') &&
!peer.classList.contains('panel-floating') &&
!peer.hidden
) {
this.setPanelCollapsed(peer.id, true, {
@@ -593,10 +581,6 @@ export class PanelChrome {
}
}
panelEl.classList.toggle('collapsed', nextCollapsed);
// 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 (
nextCollapsed &&
this.cockpitView?.active &&
@@ -666,7 +650,6 @@ export class PanelChrome {
if (this._disposed) return;
this._disposed = true;
this._lifetime.destroy();
this._panelPosition.destroy();
this._panelLayout.destroy();
for (const control of this._panelDisclosureControls || [])
control.destroy();
-580
View File
@@ -1,580 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PanelPositionControls } from './panelPositionControls.js';
/** Just enough DOM for PanelPositionControls' portable-panel paths. */
function fixture() {
const saved = Object.fromEntries(
['document', 'window', 'localStorage', 'performance', 'ResizeObserver'].map(
(key) => [key, globalThis[key]],
),
);
const store = new Map();
const element = () => {
const classes = new Set();
const style = {
removeProperty(name) {
// Like CSSStyleDeclaration: 'z-index' also clears style.zIndex.
delete this[name];
delete this[name.replace(/-([a-z])/g, (_, c) => c.toUpperCase())];
},
getPropertyValue: () => '',
};
return Object.assign(new EventTarget(), {
id: '',
style,
dataset: {},
classList: {
add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach((name) => classes.delete(name)),
contains: (name) => classes.has(name),
toggle: (name, on) => (on ? classes.add(name) : classes.delete(name)),
},
querySelector: () => null,
querySelectorAll: () => [],
closest: () => null,
setAttribute() {},
removeAttribute() {},
appendChild() {},
remove() {},
getBoundingClientRect: () => ({
left: 100,
top: 80,
width: 360,
height: 420,
right: 460,
bottom: 500,
}),
});
};
globalThis.document = Object.assign(new EventTarget(), {
getElementById: () => null,
querySelectorAll: () => [],
createElement: () => element(),
});
globalThis.window = Object.assign(new EventTarget(), {
innerWidth: 1440,
innerHeight: 900,
});
globalThis.localStorage = {
getItem: (key) => (store.has(key) ? store.get(key) : null),
setItem: (key, value) => store.set(key, String(value)),
removeItem: (key) => store.delete(key),
};
const calls = { layout: 0, resized: [] };
const owner = new PanelPositionControls({
syncPanelCollapseButton() {},
layoutRightPanels: () => calls.layout++,
syncCctvPanelViewport() {},
showToast() {},
onPanelResized: (id) => calls.resized.push(id),
});
return {
owner,
element,
store,
calls,
restore() {
owner.destroy();
Object.assign(globalThis, saved);
},
};
}
function floatingPanel(f, id, { dockOnCollapse = false } = {}) {
const panel = f.element();
const handle = f.element();
panel.id = id;
panel.classList.add('panel-floating', 'panel-draggable');
Object.assign(panel.style, {
left: '100px',
top: '80px',
width: '360px',
height: '420px',
});
f.store.set(`godsEyeView.v8.panelPos.${id}`, '{"floating":true}');
f.owner._portablePanels.set(id, {
panel,
min: { width: 320, height: 280 },
dockOnCollapse,
});
f.owner._makePanelDraggable(id, panel, handle);
return { panel, handle };
}
/** A left-button header press; `timeStamp` is a read-only getter on Event. */
function press(handle, { timeStamp, clientX = 200, clientY = 90 }) {
const event = new Event('pointerdown', { cancelable: true });
for (const [key, value] of Object.entries({
button: 0,
clientX,
clientY,
timeStamp,
}))
Object.defineProperty(event, key, { value });
handle.dispatchEvent(event);
}
test('dockPanel returns a floating window to its rail and forgets its place', () => {
const f = fixture();
try {
const { panel } = floatingPanel(f, 'street-level-panel');
assert.equal(f.owner.dockPanel('street-level-panel'), true);
assert.equal(panel.classList.contains('panel-floating'), false);
assert.equal(panel.style.width, undefined);
assert.equal(panel.style.height, undefined);
assert.equal(
f.store.has('godsEyeView.v8.panelPos.street-level-panel'),
false,
);
assert.deepEqual(f.calls.resized, ['street-level-panel']);
assert.equal(
f.owner.dockPanel('street-level-panel'),
false,
'already docked',
);
assert.equal(f.owner.dockPanel('unknown'), false);
} finally {
f.restore();
}
});
test('only a dockOnCollapse panel docks when collapsed, and only on collapse', () => {
const f = fixture();
try {
const street = floatingPanel(f, 'street-level-panel', {
dockOnCollapse: true,
});
const cctv = floatingPanel(f, 'cctv-panel');
f.owner.onPanelCollapsed('street-level-panel', false);
assert.equal(
street.panel.classList.contains('panel-floating'),
true,
'expanding keeps it',
);
f.owner.onPanelCollapsed('cctv-panel', true);
assert.equal(
cctv.panel.classList.contains('panel-floating'),
true,
'CCTV did not opt in',
);
f.owner.onPanelCollapsed('street-level-panel', true);
assert.equal(street.panel.classList.contains('panel-floating'), false);
} finally {
f.restore();
}
});
test('two quick header presses snap a floating panel back; slow or distant ones do not', () => {
const f = fixture();
try {
const { panel, handle } = floatingPanel(f, 'cctv-panel');
press(handle, { timeStamp: 1000 });
window.dispatchEvent(new Event('pointerup'));
press(handle, { timeStamp: 1600 });
window.dispatchEvent(new Event('pointerup'));
assert.equal(
panel.classList.contains('panel-floating'),
true,
'600 ms apart is two clicks',
);
press(handle, { timeStamp: 1700, clientX: 260 });
window.dispatchEvent(new Event('pointerup'));
assert.equal(
panel.classList.contains('panel-floating'),
true,
'60 px apart is two clicks',
);
press(handle, { timeStamp: 1900, clientX: 262 });
assert.equal(
panel.classList.contains('panel-floating'),
false,
'a double press docks',
);
} finally {
f.restore();
}
});
test('a restored floating window stacks above the docked DISPLAY panel (z 110)', () => {
const f = fixture();
try {
const panel = f.element();
panel.id = 'street-level-panel';
f.owner._portablePanels.set('street-level-panel', {
panel,
min: { width: 320, height: 280 },
dockOnCollapse: true,
});
f.store.set(
'godsEyeView.v8.panelPos.street-level-panel',
JSON.stringify({
left: 900,
top: 200,
width: 420,
height: 600,
floating: true,
}),
);
f.owner._restorePanelPosition('street-level-panel', panel);
assert.equal(panel.classList.contains('panel-floating'), true);
assert.ok(Number(panel.style.zIndex) > 110, `z ${panel.style.zIndex}`);
f.owner.dockPanel('street-level-panel');
assert.equal(panel.style.zIndex, undefined, 'docking drops the promotion');
} finally {
f.restore();
}
});
test('renumbering the z ladder never drops a window below the docked panels', () => {
const f = fixture();
try {
const windows = [f.element(), f.element(), f.element()];
windows.forEach((node, index) => {
node.classList.add('panel-draggable');
node.style.zIndex = String(130 + index);
});
globalThis.document.querySelectorAll = (selector) =>
selector === '.panel-draggable' ? windows : [];
f.owner._panelZCounter = 139;
const top = f.element();
top.classList.add('panel-draggable');
f.owner._promotePanelZ(top);
for (const node of [...windows, top])
assert.ok(Number(node.style.zIndex) > 110, `z ${node.style.zIndex}`);
assert.ok(Number(top.style.zIndex) > Number(windows[2].style.zIndex));
} finally {
f.restore();
}
});
/** A portable panel still in its rail, with its header and resize handles. */
function dockedPanel(f, id) {
const panel = f.element();
const handle = f.element();
panel.id = id;
const portable = { panel, min: { width: 320, height: 280 } };
f.owner._portablePanels.set(id, portable);
f.owner._makePanelDraggable(id, panel, handle);
f.owner._makePanelResizable(id, panel, portable.min);
const edges = Object.fromEntries(
f.owner._resizeHandles.map((node) => [node.dataset.dir, node]),
);
return { panel, handle, edges };
}
/** A pointer event; `pointerType` defaults to what a mouse reports. */
function pointer(target, type, { x = 200, y = 90, pointerType = 'mouse' }) {
const event = new Event(type, { cancelable: true });
for (const [key, value] of Object.entries({
button: 0,
clientX: x,
clientY: y,
pointerType,
}))
Object.defineProperty(event, key, { value });
target.dispatchEvent(event);
return event;
}
const POSITION_KEY = 'godsEyeView.v8.panelPos.street-level-panel';
test('a click on a docked panel’s resize edge leaves it docked and stores nothing', () => {
const f = fixture();
try {
const { panel, edges } = dockedPanel(f, 'street-level-panel');
pointer(edges.w, 'pointerdown', { x: 101, y: 200 });
pointer(window, 'pointermove', { x: 103, y: 201 }); // a jittery click
pointer(window, 'pointerup', { x: 103, y: 201 });
assert.equal(panel.classList.contains('panel-floating'), false);
assert.equal(panel.style.width, undefined, 'no size was pinned');
assert.equal(f.store.has(POSITION_KEY), false);
assert.deepEqual(f.calls.resized, []);
// A real resize still lifts it out and remembers the window.
pointer(edges.w, 'pointerdown', { x: 101, y: 200 });
pointer(window, 'pointermove', { x: 61, y: 200 });
pointer(window, 'pointerup', { x: 61, y: 200 });
assert.equal(panel.classList.contains('panel-floating'), true);
assert.equal(JSON.parse(f.store.get(POSITION_KEY)).floating, true);
} finally {
f.restore();
}
});
test('a touch on the header or an edge does not lift the panel, and the rail can still scroll', () => {
const f = fixture();
try {
const { panel, handle, edges } = dockedPanel(f, 'street-level-panel');
const press = pointer(handle, 'pointerdown', { pointerType: 'touch' });
assert.equal(press.defaultPrevented, false, 'the swipe can scroll');
pointer(window, 'pointermove', { y: 190, pointerType: 'touch' });
pointer(window, 'pointerup', { y: 190, pointerType: 'touch' });
pointer(edges.s, 'pointerdown', { y: 500, pointerType: 'touch' });
pointer(window, 'pointermove', { y: 600, pointerType: 'touch' });
pointer(window, 'pointerup', { y: 600, pointerType: 'touch' });
assert.equal(panel.classList.contains('panel-floating'), false);
assert.equal(f.store.has(POSITION_KEY), false);
} finally {
f.restore();
}
});
test('a pointercancel mid-drag saves nothing and puts the panel back', () => {
const f = fixture();
try {
const { panel, handle, edges } = dockedPanel(f, 'street-level-panel');
pointer(handle, 'pointerdown', {});
pointer(window, 'pointermove', { x: 260, y: 140 });
assert.equal(panel.classList.contains('panel-floating'), true, 'lifted');
pointer(window, 'pointercancel', { x: 260, y: 140 });
assert.equal(panel.classList.contains('panel-floating'), false, 'docked');
assert.equal(f.store.has(POSITION_KEY), false);
pointer(edges.e, 'pointerdown', { x: 459 });
pointer(window, 'pointermove', { x: 520 });
pointer(window, 'pointercancel', { x: 520 });
assert.equal(panel.classList.contains('panel-floating'), false);
assert.equal(f.store.has(POSITION_KEY), false);
} finally {
f.restore();
}
});
test('a pointercancel while dragging a floating window returns it to where it was', () => {
const f = fixture();
try {
const { panel, handle } = floatingPanel(f, 'cctv-panel');
const stored = f.store.get('godsEyeView.v8.panelPos.cctv-panel');
pointer(handle, 'pointerdown', {});
pointer(window, 'pointermove', { x: 400, y: 300 });
assert.notEqual(panel.style.left, '100px', 'it moved');
pointer(window, 'pointercancel', { x: 400, y: 300 });
assert.equal(panel.style.left, '100px');
assert.equal(panel.style.top, '80px');
assert.equal(panel.classList.contains('panel-floating'), true);
assert.equal(f.store.get('godsEyeView.v8.panelPos.cctv-panel'), stored);
} finally {
f.restore();
}
});
test('a header press that travels under the drag threshold is a click, not a lift', () => {
const f = fixture();
try {
const { panel, handle } = dockedPanel(f, 'street-level-panel');
pointer(handle, 'pointerdown', { x: 200, y: 90 });
pointer(window, 'pointermove', { x: 202, y: 90 });
pointer(window, 'pointerup', { x: 202, y: 90 });
assert.equal(panel.classList.contains('panel-floating'), false);
assert.equal(panel.classList.contains('panel-dragging'), false);
assert.equal(f.store.has(POSITION_KEY), false);
assert.equal(f.calls.layout, 0, 'the rail was not re-laid out');
assert.deepEqual(f.calls.resized, []);
} finally {
f.restore();
}
});
/**
* 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 = [];
globalThis.ResizeObserver = class {
constructor(callback) {
this.callback = callback;
this.targets = [];
observers.push(this);
}
observe(target) {
this.targets.push(target);
}
disconnect() {}
};
const nodes = new Map();
const panels = {};
for (const id of ['cctv-panel', 'street-level-panel']) {
const panel = f.element();
const handle = f.element();
const natural = { width: 360, height: 420, ...box[id] };
panel.id = id;
panel.querySelector = (selector) =>
selector === '.panel-header' ? handle : null;
panel.getBoundingClientRect = () => {
const left = parseFloat(panel.style.left) || 0;
const top = parseFloat(panel.style.top) || 0;
const width = parseFloat(panel.style.width) || natural.width;
const height = parseFloat(panel.style.height) || natural.height;
return {
left,
top,
width,
height,
right: left + width,
bottom: top + height,
};
};
handle.getBoundingClientRect = () => {
const rect = panel.getBoundingClientRect();
return { ...rect, height: 36, bottom: rect.top + 36 };
};
if (stored[id])
f.store.set(
`godsEyeView.v8.panelPos.${id}`,
JSON.stringify({ floating: true, ...stored[id] }),
);
nodes.set(id, panel);
panels[id] = { panel, handle, natural };
}
const obstacle = (id, rect) =>
nodes.set(id, {
getBoundingClientRect: () => ({
...rect,
width: rect.right - rect.left,
height: rect.bottom - rect.top,
}),
});
for (const [id, rect] of Object.entries(obstacles)) obstacle(id, rect);
globalThis.document.getElementById = (id) => nodes.get(id) ?? null;
f.owner._initPanelDrag();
return {
...panels,
obstacle,
observed: (node) => observers.some(({ targets }) => targets.includes(node)),
contentResized: () => observers.forEach(({ callback }) => callback([])),
};
}
test('a floating window whose content grows is pulled back on-screen', () => {
const f = fixture();
try {
const shell = portableShell(f, {
stored: { 'cctv-panel': { left: 900, top: 380 } },
});
const { panel, natural } = shell['cctv-panel'];
assert.equal(panel.style.top, '380px');
assert.equal(shell.observed(panel), true, 'its size is watched');
assert.equal(shell.observed(shell['street-level-panel'].panel), true);
// Expanding the collapsed strip, or an image opening in Street Level.
natural.height = 800;
shell.contentResized();
assert.equal(panel.style.top, `${900 - 800 - 6}px`);
assert.equal(panel.style.left, '900px');
} finally {
f.restore();
}
});
test('a live window resize shrinks a floating window larger than the viewport', () => {
const f = fixture();
try {
const shell = portableShell(f, {
stored: {
'cctv-panel': { left: 100, top: 80, width: 1300, height: 700 },
},
});
const { panel } = shell['cctv-panel'];
assert.equal(panel.style.width, '1300px');
Object.assign(window, { innerWidth: 1000, innerHeight: 500 });
window.dispatchEvent(new Event('resize'));
assert.equal(panel.style.width, '988px');
assert.equal(panel.style.height, '488px');
assert.equal(panel.style.left, '6px');
assert.equal(panel.style.top, '6px');
// A window that already fits keeps the size the user gave it.
Object.assign(window, { innerWidth: 1440, innerHeight: 900 });
window.dispatchEvent(new Event('resize'));
assert.equal(panel.style.width, '988px');
} finally {
f.restore();
}
});
const DOCK = { left: 300, top: 820, right: 1140, bottom: 882 };
test('a floating header never lands under the command dock or voice pill', () => {
const f = fixture();
try {
const shell = portableShell(f, {
stored: { 'cctv-panel': { left: 100, top: 80 } },
// A collapsed CCTV window is just its header strip.
box: { 'cctv-panel': { height: 36 } },
obstacles: { 'command-dock': DOCK },
});
const { panel, handle, natural } = shell['cctv-panel'];
const above = DOCK.top - 36 - 6;
// Drag: the strip dropped at the bottom centre stops above the dock.
pointer(handle, 'pointerdown', { x: 200, y: 90 });
pointer(window, 'pointermove', { x: 720, y: 880 });
assert.equal(panel.style.top, `${above}px`);
pointer(window, 'pointerup', { x: 720, y: 880 });
const key = 'godsEyeView.v8.panelPos.cctv-panel';
assert.equal(JSON.parse(f.store.get(key)).top, above);
// Restore: a place saved under the dock (older build) comes back clear.
f.store.set(key, JSON.stringify({ left: 620, top: 858, floating: true }));
f.owner._restorePanelPosition('cctv-panel', panel);
assert.equal(panel.style.top, `${above}px`);
// Re-clamp: the voice pill measured when the window resizes.
Object.assign(panel.style, { left: '1000px', top: '700px' });
shell.obstacle('gev-voice-control', {
left: 1190,
top: 690,
right: 1404,
bottom: 740,
});
window.dispatchEvent(new Event('resize'));
assert.equal(panel.style.top, `${690 - 36 - 6}px`);
// Only the header has to stay clear: a body over the dock is fine.
natural.height = 200;
Object.assign(panel.style, { left: '620px', top: '650px' });
window.dispatchEvent(new Event('resize'));
assert.equal(panel.style.top, '650px');
} finally {
f.restore();
}
});
test('a collapsed floating window keeps its chosen height through a drag and a reload', () => {
const f = fixture();
try {
const { panel, handle } = floatingPanel(f, 'cctv-panel');
panel.style.height = '500px';
panel.classList.add('collapsed');
// Collapsed, the window measures only its header strip.
panel.getBoundingClientRect = () => {
const left = parseFloat(panel.style.left);
const top = parseFloat(panel.style.top);
return {
left,
top,
width: 360,
height: 50,
right: left + 360,
bottom: top + 50,
};
};
pointer(handle, 'pointerdown', { x: 200, y: 90 });
pointer(window, 'pointermove', { x: 260, y: 140 });
pointer(window, 'pointerup', { x: 260, y: 140 });
const record = JSON.parse(
f.store.get('godsEyeView.v8.panelPos.cctv-panel'),
);
assert.equal(record.height, 500, 'the chosen height, not the strip');
assert.equal(record.width, 360);
// Reload: a fresh panel restores the size it expands to.
const reloaded = f.element();
reloaded.id = 'cctv-panel';
f.owner._restorePanelPosition('cctv-panel', reloaded);
assert.equal(reloaded.style.height, '500px');
} finally {
f.restore();
}
});
-13
View File
@@ -189,13 +189,6 @@ export class PanelLayoutController {
const stack = this._rightPanelStack;
if (!stack || !this._ppToggles) return;
this._ppToggles.style.removeProperty('top');
this._ppToggles.style.removeProperty('right');
this._ppToggles.style.removeProperty('bottom');
this._ppToggles.style.removeProperty('left');
this._ppToggles.style.removeProperty('z-index');
this._ppToggles.classList.remove('panel-draggable', 'panel-dragging');
this._ppToggles.querySelector('.pp-header-row')?.removeAttribute('title');
stack.prepend(this._ppToggles);
const globalContextPanel = document.getElementById('global-context-panel');
for (const panel of [
@@ -205,12 +198,6 @@ export class PanelLayoutController {
this._streetLevelPanel,
]) {
if (!panel) continue;
// 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);
panel.classList.remove('panel-draggable', 'panel-dragging');
}
stack.insertBefore(panel, globalContextPanel);
this._syncPanelCollapseButton(panel);
}
+18 -762
View File
@@ -1,217 +1,26 @@
/** Own panel position preferences, viewport clamping and drag listeners. */
import { t, msg } from '../i18n/index.js';
import { RESIZE_DIRECTIONS, resizeBox } from './panelResize.js';
/** Own panel collapsed-state preferences and the one-time layout-reset notice. */
import { t } from '../i18n/index.js';
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
/**
* Position keys are versioned separately from collapsed-state keys so layout
* default changes (e.g. right-rail origin) can reset positions without also
* resetting every panel's open/closed preference.
* Position keys are versioned separately from collapsed-state keys. The rails
* now lay panels out themselves and store no position; the version still names
* the layout-reset notice and the documented key.
*/
const PANEL_POSITION_STORAGE_VERSION = 'v8';
/** Z ladder: panels promote within [100, 139]; voice pill 150, toast 200, clean-view-exit 300. */
const PANEL_Z_BASE = 100;
const PANEL_Z_MAX = 139;
/**
* 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 press becomes a drag or resize. */
const DRAG_THRESHOLD_PX = 4;
/**
* 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;
const VIEWPORT_MARGIN_PX = 6;
/**
* 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'];
const PANEL_FLOAT_HINT_STORAGE_KEY = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`;
const PANEL_FLOAT_HINT = msg('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"]';
const FLOATING_STYLE_PROPERTIES = [
'left',
'top',
'right',
'bottom',
'width',
'height',
'z-index',
/** Floating panels' stored positions and hint flag: nothing reads them now. */
const RETIRED_PANEL_KEY_PREFIXES = [
`godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelPos.`,
`godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelFloatHintShown`,
];
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function fitWindowExtent(value, minimum, viewportExtent) {
return clamp(
Math.round(value),
minimum,
Math.max(minimum, viewportExtent - 2 * VIEWPORT_MARGIN_PX),
);
}
function placeAt(panelEl, left, top) {
panelEl.style.left = `${left}px`;
panelEl.style.top = `${top}px`;
panelEl.style.right = 'auto';
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';
}
function geometryOf(panelEl) {
const { left, top, width, height } = panelEl.style;
return { left, top, width, height };
}
export class PanelPositionControls {
/** `onPanelResized(panelId)` runs after a portable panel moves, resizes or docks. */
constructor({
syncPanelCollapseButton,
layoutRightPanels,
syncCctvPanelViewport,
showToast,
onPanelResized = null,
}) {
constructor({ syncPanelCollapseButton, showToast }) {
this._syncPanelCollapseButton = syncPanelCollapseButton;
this._layoutRightPanels = layoutRightPanels;
this._syncCctvPanelViewport = syncCctvPanelViewport;
this._showToast = showToast;
this._onPanelResized = onPanelResized;
this._ppToggles = document.getElementById('pp-toggles');
this._panelZCounter = PANEL_Z_BASE + 10;
this._draggableResizeObserver = null;
this._cancelDrag = null;
this._portablePanels = new Map();
this._resizeHandles = [];
this._dragInitialized = false;
this.removers = [];
this.destroyed = false;
}
listen(target, type, callback, options) {
if (this.destroyed || !target) return;
const listener = (event) => {
if (!this.destroyed) callback(event);
};
target.addEventListener(type, listener, options);
this.removers.push(() =>
target.removeEventListener(type, listener, options),
);
}
_reclampDraggablePanels() {
if (this.destroyed) return;
const el = this._ppToggles;
if (el && el.style.top && el.style.top !== 'auto') {
const top = parseInt(el.style.top, 10);
if (Number.isFinite(top)) {
el.style.top = `${this._clampToViewport(0, top, el).top}px`;
this._pinPanelToRight(el);
}
}
for (const entry of this._portablePanels.values()) {
const { classList } = entry.panel;
if (!classList.contains('panel-floating')) continue;
// A live gesture owns the box; it re-clamps when it ends.
if (classList.contains('panel-dragging')) continue;
if (classList.contains('panel-resizing')) continue;
this._reclampPortablePanel(entry);
}
}
/** 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);
const top = parseInt(panel.style.top, 10);
if (!Number.isFinite(left) || !Number.isFinite(top)) return;
for (const [property, viewportExtent] of [
['width', window.innerWidth],
['height', window.innerHeight],
]) {
const extent = parseFloat(panel.style[property]);
if (!Number.isFinite(extent)) continue;
const fitted = fitWindowExtent(extent, min[property], viewportExtent);
if (fitted < extent) panel.style[property] = `${fitted}px`;
}
const next = this._clampPortableWindow(entry, left, top);
panel.style.left = `${next.left}px`;
panel.style.top = `${next.top}px`;
}
/**
* 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,
left,
top,
geometry = this._windowGeometry(entry),
) {
const { width, height, header } = geometry;
const maxLeft = Math.max(
VIEWPORT_MARGIN_PX,
window.innerWidth - width - VIEWPORT_MARGIN_PX,
);
const maxTop = Math.max(
VIEWPORT_MARGIN_PX,
window.innerHeight - height - VIEWPORT_MARGIN_PX,
);
const x = clamp(left, VIEWPORT_MARGIN_PX, maxLeft);
let y = clamp(top, VIEWPORT_MARGIN_PX, maxTop);
const obstacles = this._panelObstacles();
for (let pass = 0; pass < obstacles.length; pass++) {
const hit = obstacles.find(
(rect) =>
rect.left < x + width &&
rect.right > x &&
rect.top < y + header &&
rect.bottom > y,
);
if (!hit) break;
const above = hit.top - header - VIEWPORT_MARGIN_PX;
y =
above >= VIEWPORT_MARGIN_PX
? above
: Math.min(maxTop, hit.bottom + VIEWPORT_MARGIN_PX);
}
return { left: x, top: y };
}
_windowGeometry({ panel, handle }, rect = panel.getBoundingClientRect()) {
const handleRect = handle?.getBoundingClientRect();
const header = handleRect ? handleRect.bottom - rect.top : 0;
return {
width: rect.width,
height: rect.height,
// Without a measurable header, keep the whole window clear.
header: header > 0 ? Math.min(header, rect.height) : rect.height,
};
}
_panelObstacles() {
const rects = [];
for (const id of PANEL_OBSTACLE_IDS) {
const rect = document.getElementById(id)?.getBoundingClientRect();
// A hidden dock, or a voice UI switched off, measures empty.
if (rect && rect.width > 0 && rect.height > 0) rects.push(rect);
}
return rects;
}
_maybeNotifyLayoutReset() {
this._dropRetiredPanelKeys();
try {
const marker = `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.layoutResetNotified`;
if (localStorage.getItem(marker)) return;
@@ -229,77 +38,14 @@ export class PanelPositionControls {
}
}
_initPanelDrag() {
if (this.destroyed || this._dragInitialized) return;
this._dragInitialized = true;
const cctvPanel = document.getElementById('cctv-panel');
const streetLevelPanel = document.getElementById('street-level-panel');
// 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',
panel: this._ppToggles,
handle: this._ppToggles?.querySelector('.panel-drag-handle.compact'),
},
{
id: 'cctv-panel',
panel: cctvPanel,
handle: cctvPanel?.querySelector('.panel-header'),
portable: true,
min: { width: 300, height: 160 },
},
{
id: 'street-level-panel',
panel: streetLevelPanel,
handle: streetLevelPanel?.querySelector('.panel-header'),
portable: true,
min: { width: 320, height: 280 },
// Shrinking the window to its strip docks it, so it never sits
// over the globe as a stray header.
dockOnCollapse: true,
},
].filter(Boolean);
for (const spec of dragSpecs) {
if (!spec.panel || !spec.handle) continue;
if (spec.portable) {
this._portablePanels.set(spec.id, {
panel: spec.panel,
handle: spec.handle,
min: { width: 300, height: 160, ...spec.min },
dockOnCollapse: spec.dockOnCollapse === true,
});
spec.panel.classList.add('panel-portable');
}
this._restorePanelPosition(spec.id, spec.panel);
this._makePanelDraggable(spec.id, spec.panel, spec.handle);
if (spec.portable) {
this._makePanelResizable(spec.id, spec.panel, spec.min);
}
_dropRetiredPanelKeys() {
try {
for (const key of Object.keys(localStorage))
if (RETIRED_PANEL_KEY_PREFIXES.some((prefix) => key.startsWith(prefix)))
localStorage.removeItem(key);
} catch {
// storage unavailable
}
// 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. Floating
// windows grow the same way when their content does.
const observed = [
this._ppToggles,
...[...this._portablePanels.values()].map(({ panel }) => panel),
].filter(Boolean);
if (observed.length && typeof ResizeObserver !== 'undefined') {
this._draggableResizeObserver = new ResizeObserver(() =>
this._reclampDraggablePanels(),
);
for (const panel of observed)
this._draggableResizeObserver.observe(panel);
}
if (this._portablePanels.size) {
this.listen(window, 'resize', () => this._reclampDraggablePanels());
}
}
_panelStorageKey(panelId) {
return `godsEyeView.${PANEL_POSITION_STORAGE_VERSION}.panelPos.${panelId}`;
}
_panelCollapseStorageKey(panelId) {
@@ -351,494 +97,4 @@ export class PanelPositionControls {
// storage unavailable
}
}
_pinPanelToRight(panelEl) {
if (!panelEl) return;
const rect = panelEl.getBoundingClientRect();
const rightOffset = Math.max(6, Math.round(window.innerWidth - rect.right));
panelEl.style.right = `${rightOffset}px`;
panelEl.style.left = 'auto';
}
_restorePanelPosition(panelId, panelEl) {
try {
const raw = localStorage.getItem(this._panelStorageKey(panelId));
if (!raw) return;
const pos = JSON.parse(raw);
if (!pos || typeof pos.left !== 'number' || typeof pos.top !== 'number')
return;
// 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.
this._promotePanelZ(panelEl);
if (Number.isFinite(pos.width)) {
panelEl.style.width = `${fitWindowExtent(
pos.width,
portable.min.width,
window.innerWidth,
)}px`;
}
if (Number.isFinite(pos.height)) {
panelEl.style.height = `${fitWindowExtent(
pos.height,
portable.min.height,
window.innerHeight,
)}px`;
}
}
// Clamp to the viewport: a position saved at one window size would otherwise land off-screen at
// another (audit U2 — observed a panel at x:-192). The drag handler clamps; restore must too,
// and a window saved under the command dock must not come back there.
const { left, top } = portable
? this._clampPortableWindow(
portable,
Math.round(pos.left),
Math.round(pos.top),
)
: this._clampToViewport(
Math.round(pos.left),
Math.round(pos.top),
panelEl,
);
placeAt(panelEl, left, top);
if (panelId === 'pp-toggles') {
this._pinPanelToRight(panelEl);
}
} catch {
// ignore malformed saved panel position
}
}
_clampToViewport(left, top, panelEl) {
const rect = panelEl.getBoundingClientRect();
const maxLeft = Math.max(6, window.innerWidth - rect.width - 6);
const maxTop = Math.max(6, window.innerHeight - rect.height - 6);
return {
left: Math.max(6, Math.min(maxLeft, left)),
top: Math.max(6, Math.min(maxTop, top)),
};
}
_savePanelPosition(panelId, panelEl) {
const rect = panelEl.getBoundingClientRect();
const record = {
left: Math.round(rect.left),
top: Math.round(rect.top),
};
if (this._portablePanels.has(panelId)) {
// Only an explicitly set size is remembered, and the inline value
// rather than the measured box: a collapsed window measures only its
// header but keeps the size the user chose for when it expands.
const chosen = (inline, measured) => {
const px = parseFloat(inline);
return Math.round(Number.isFinite(px) ? px : measured);
};
if (panelEl.style.width)
record.width = chosen(panelEl.style.width, rect.width);
if (panelEl.style.height)
record.height = chosen(panelEl.style.height, rect.height);
record.floating = true;
}
try {
localStorage.setItem(
this._panelStorageKey(panelId),
JSON.stringify(record),
);
} catch {
// storage unavailable
}
}
_promotePanelZ(panelEl) {
this._panelZCounter += 1;
if (this._panelZCounter > PANEL_Z_MAX) {
const promoted = [...document.querySelectorAll('.panel-draggable')]
.filter((el) => el.style.zIndex)
.sort((a, b) => Number(a.style.zIndex) - Number(b.style.zIndex));
let z = PANEL_Z_FLOATING_FLOOR;
for (const el of promoted) {
el.style.zIndex = String(z);
z += 1;
}
this._panelZCounter = z;
}
panelEl.style.zIndex = String(this._panelZCounter);
}
_maybeShowFloatHint() {
try {
if (localStorage.getItem(PANEL_FLOAT_HINT_STORAGE_KEY)) return;
localStorage.setItem(PANEL_FLOAT_HINT_STORAGE_KEY, '1');
} catch {
// storage unavailable: still show it once this session
if (this._floatHintShown) return;
this._floatHintShown = true;
}
this._showToast(t(PANEL_FLOAT_HINT));
}
/**
* 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;
placeAt(panelEl, Math.round(rect.left), Math.round(rect.top));
if (!panelEl.classList.contains('collapsed')) {
panelEl.style.width = `${Math.round(rect.width)}px`;
}
panelEl.style.removeProperty('--right-panel-allocated-height');
panelEl.removeAttribute('aria-hidden');
panelEl.classList.add('panel-floating', 'panel-draggable');
this._promotePanelZ(panelEl);
this._layoutRightPanels();
this._maybeShowFloatHint();
return true;
}
/** Snap a floating panel back into its rail and forget its window. */
_resetPanelPosition(panelId) {
const portable = this._portablePanels.get(panelId);
if (!portable) return;
const panelEl = portable.panel;
this._cancelDrag?.();
panelEl.classList.remove(
'panel-floating',
'panel-draggable',
'panel-dragging',
'panel-resizing',
);
for (const property of FLOATING_STYLE_PROPERTIES) {
panelEl.style.removeProperty(property);
}
try {
localStorage.removeItem(this._panelStorageKey(panelId));
} catch {
// storage unavailable
}
this._layoutRightPanels();
this._onPanelResized?.(panelId);
}
_makePanelDraggable(panelId, panelEl, handleEl) {
// Z-order promotion: bring clicked panel to front of the stacking context
this.listen(panelEl, 'pointerdown', () => {
this._promotePanelZ(panelEl);
});
if (this._portablePanels.has(panelId)) {
this._makePortablePanelDraggable(panelId, panelEl, handleEl);
return;
}
this.listen(handleEl, 'pointerdown', (event) => {
if (event.button !== 0) return;
if (event.target.closest('.panel-collapse-btn')) return;
if (
event.target.closest(
'input, select, option, button:not(.panel-collapse-btn)',
)
)
return;
this._cancelDrag?.();
event.preventDefault();
const rect = panelEl.getBoundingClientRect();
const offsetX = event.clientX - rect.left;
const offsetY = event.clientY - rect.top;
placeAt(panelEl, rect.left, rect.top);
panelEl.classList.add('panel-dragging');
this._promotePanelZ(panelEl);
this._trackPointerGesture(event, {
onMove: (moveEvent) => {
const nextLeftRaw = moveEvent.clientX - offsetX;
const nextTopRaw = moveEvent.clientY - offsetY;
const maxLeft = Math.max(6, window.innerWidth - rect.width - 6);
const maxTop = Math.max(6, window.innerHeight - rect.height - 6);
const nextLeft = Math.max(6, Math.min(maxLeft, nextLeftRaw));
const nextTop = Math.max(6, Math.min(maxTop, nextTopRaw));
panelEl.style.left = `${nextLeft}px`;
panelEl.style.top = `${nextTop}px`;
if (panelId === 'pp-toggles') {
this._layoutRightPanels();
}
if (panelId === 'cctv-panel') {
this._syncCctvPanelViewport();
}
},
onStop: () => panelEl.classList.remove('panel-dragging'),
// A cancelled press keeps the place too: there is no rail to revert to.
onEnd: () => {
if (panelId === 'pp-toggles') {
this._pinPanelToRight(panelEl);
}
this._savePanelPosition(panelId, panelEl);
if (panelId === 'cctv-panel') {
this._syncCctvPanelViewport();
}
},
});
});
}
/**
* 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,
{ threshold = 0, onStart, onMove, onStop, onEnd, onCancel = onEnd },
) {
const startX = event.clientX;
const startY = event.clientY;
let started = threshold <= 0;
const move = (moveEvent) => {
const dx = moveEvent.clientX - startX;
const dy = moveEvent.clientY - startY;
if (!started) {
if (Math.hypot(dx, dy) < threshold) return;
started = true;
onStart?.();
}
onMove(moveEvent, dx, dy);
};
const stop = () => {
onStop?.();
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
window.removeEventListener('pointercancel', cancel);
if (this._cancelDrag === stop) this._cancelDrag = null;
};
const up = () => {
stop();
onEnd?.(started);
};
const cancel = () => {
stop();
onCancel?.(started);
};
this._cancelDrag = stop;
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
window.addEventListener('pointercancel', cancel);
}
/** @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;
this._resetPanelPosition(panelId);
return true;
}
/** 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(
handleEl,
'click',
(event) => {
if (!swallowNextClick) return;
swallowNextClick = false;
event.stopImmediatePropagation();
event.preventDefault();
},
{ capture: true },
);
this.listen(handleEl, 'dblclick', (event) => {
if (event.target.closest?.(INTERACTIVE_SELECTOR)) return;
if (!panelEl.classList.contains('panel-floating')) return;
event.preventDefault();
this._resetPanelPosition(panelId);
});
let lastPress = null;
this.listen(handleEl, 'pointerdown', (event) => {
if (event.button !== 0 || !isPrecisePointer(event)) return;
const interactive = event.target.closest?.(INTERACTIVE_SELECTOR);
if (interactive && !interactive.matches('.panel-collapse-btn')) return;
const now = event.timeStamp || performance.now();
const doublePress =
!interactive &&
lastPress &&
now - lastPress.time <= DOUBLE_PRESS_MS &&
Math.hypot(event.clientX - lastPress.x, event.clientY - lastPress.y) <=
DOUBLE_PRESS_SLOP_PX;
lastPress = doublePress
? null
: { time: now, x: event.clientX, y: event.clientY };
if (doublePress && panelEl.classList.contains('panel-floating')) {
event.preventDefault();
this._resetPanelPosition(panelId);
return;
}
this._cancelDrag?.();
// A header press must not start a text selection across the page.
event.preventDefault();
const entry = this._portablePanels.get(panelId);
let lifted = false;
let before = null;
let offsetX = 0;
let offsetY = 0;
let geometry = null;
this._trackPointerGesture(event, {
threshold: DRAG_THRESHOLD_PX,
onStart: () => {
// A drag between two presses is not a double-click.
lastPress = null;
const rect = panelEl.getBoundingClientRect();
offsetX = event.clientX - rect.left;
offsetY = event.clientY - rect.top;
geometry = this._windowGeometry(entry, rect);
before = geometryOf(panelEl);
lifted = this._liftPanelOut(panelId, panelEl, rect);
panelEl.classList.add('panel-dragging');
this._promotePanelZ(panelEl);
},
onMove: (moveEvent) => {
moveEvent.preventDefault();
const next = this._clampPortableWindow(
entry,
moveEvent.clientX - offsetX,
moveEvent.clientY - offsetY,
geometry,
);
panelEl.style.left = `${next.left}px`;
panelEl.style.top = `${next.top}px`;
},
onStop: () => panelEl.classList.remove('panel-dragging'),
onEnd: (dragged) => {
if (!dragged) return;
swallowNextClick = true;
setTimeout(() => {
swallowNextClick = false;
}, 0);
this._savePanelPosition(panelId, panelEl);
this._onPanelResized?.(panelId);
},
onCancel: (dragged) => {
if (dragged)
this._revertPanelGesture(panelId, panelEl, lifted, before);
},
});
});
}
/** On pointercancel, restore the panel as the gesture found it; save nothing. */
_revertPanelGesture(panelId, panelEl, lifted, before) {
if (lifted) {
this._resetPanelPosition(panelId);
return;
}
Object.assign(panelEl.style, before);
this._onPanelResized?.(panelId);
}
/** 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) {
const handle = document.createElement('div');
handle.className =
dir === 'se' ? 'panel-resize-grip' : 'panel-resize-edge';
handle.dataset.dir = dir;
handle.setAttribute('aria-hidden', 'true');
if (dir === 'se') {
handle.title = t(
'Drag to resize · double-click the header to snap back',
);
}
panelEl.appendChild(handle);
this._resizeHandles.push(handle);
this.listen(handle, 'pointerdown', (event) =>
this._startPanelResize(event, panelId, panelEl, dir, limits),
);
}
}
_startPanelResize(event, panelId, panelEl, dir, limits) {
if (event.button !== 0 || !isPrecisePointer(event)) return;
event.preventDefault();
this._cancelDrag?.();
// 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;
const apply = ({ left, top, width, height }) => {
panelEl.style.left = `${left}px`;
panelEl.style.top = `${top}px`;
panelEl.style.width = `${width}px`;
panelEl.style.height = `${height}px`;
};
this._trackPointerGesture(event, {
threshold: DRAG_THRESHOLD_PX,
onStart: () => {
const rect = panelEl.getBoundingClientRect();
before = geometryOf(panelEl);
lifted = this._liftPanelOut(panelId, panelEl, rect);
startBox = {
left: Math.round(rect.left),
top: Math.round(rect.top),
width: Math.round(rect.width),
height: Math.round(rect.height),
};
panelEl.classList.add('panel-resizing');
this._promotePanelZ(panelEl);
apply(startBox);
},
onMove: (moveEvent, dx, dy) => {
moveEvent.preventDefault();
apply(
resizeBox(startBox, dir, dx, dy, {
minWidth: limits.width,
minHeight: limits.height,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
}),
);
},
onStop: () => panelEl.classList.remove('panel-resizing'),
onEnd: (resized) => {
if (!resized) return;
// 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);
this._savePanelPosition(panelId, panelEl);
this._onPanelResized?.(panelId);
},
onCancel: (resized) => {
if (resized) this._revertPanelGesture(panelId, panelEl, lifted, before);
},
});
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this._cancelDrag?.();
for (const remove of this.removers.splice(0)) remove();
for (const handle of this._resizeHandles.splice(0)) handle.remove();
this._draggableResizeObserver?.disconnect();
this._draggableResizeObserver = null;
}
}
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PanelPositionControls } from './panelPositionControls.js';
/** A localStorage stand-in whose keys enumerate like the real one. */
function fakeStorage(entries) {
const storage = {};
Object.defineProperties(storage, {
getItem: { value: (key) => (key in storage ? storage[key] : null) },
setItem: { value: (key, value) => (storage[key] = String(value)) },
removeItem: { value: (key) => delete storage[key] },
});
Object.assign(storage, entries);
return storage;
}
test('the floating panels’ stored positions and hint flag are dropped; collapse state stays', (t) => {
const saved = globalThis.localStorage;
globalThis.localStorage = fakeStorage({
'godsEyeView.v8.panelPos.cctv-panel': '{"x":1}',
'godsEyeView.v8.panelFloatHintShown': '1',
'godsEyeView.v6.panelCollapsed.cctv-panel': '1',
'godsEyeView.v8.layoutResetNotified': '1',
});
t.after(() => {
globalThis.localStorage = saved;
});
const controls = new PanelPositionControls({ showToast() {} });
controls._maybeNotifyLayoutReset();
assert.deepEqual(Object.keys(globalThis.localStorage).sort(), [
'godsEyeView.v6.panelCollapsed.cctv-panel',
'godsEyeView.v8.layoutResetNotified',
]);
});
+1 -26
View File
@@ -113,10 +113,7 @@ function element(
getBoundingClientRect() {
return this.rect;
},
matches: (selector) =>
selector === '[data-panel-id]' ||
(selector === '[data-panel-id]:not(.panel-floating)' &&
!classes.has('panel-floating')),
matches: (selector) => selector === '[data-panel-id]',
contains(target) {
return (
target === this || this.children.some((child) => child.contains(target))
@@ -325,28 +322,6 @@ test('right layout retains Display allocation during measurement and caps restor
);
});
test('right rail neither measures nor allocates a panel floating out of it', () => {
const f = fixture('right');
f.expand(f.first, 900);
f.expand(f.second, 300);
f.first.classList.add('panel-floating');
f.first.style.setProperty('--right-panel-allocated-height', '500px');
f.first.writes.length = 0;
f.run();
assert.equal(f.stack.dataset.expandedCount, '1');
assert.deepEqual(
f.first.writes,
[],
'a floating panel keeps whatever inline style it left the rail with',
);
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
assert.equal(f.first.classList.contains('layout-auto-collapsed'), false);
assert.ok(
f.second.style.getPropertyValue('--right-panel-allocated-height'),
'the docked panel still receives its allocation',
);
});
test('right rail aligns to the current left rail and excludes hidden obstacles', () => {
const f = fixture('right');
f.options.leftStack.rect.top = 200;
-47
View File
@@ -1,47 +0,0 @@
// Pure resize geometry for floating panels; PanelPositionControls wires the DOM.
const EDGE_MARGIN_PX = 6;
export const RESIZE_DIRECTIONS = ['n', 's', 'e', 'w', 'ne', 'nw', 'sw', 'se'];
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
/**
* 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,
dir,
dx,
dy,
{
minWidth,
minHeight,
viewportWidth = Infinity,
viewportHeight = Infinity,
margin = EDGE_MARGIN_PX,
} = {},
) {
let { left, top, width, height } = box;
const right = box.left + box.width;
const bottom = box.top + box.height;
if (dir.includes('e'))
width = clamp(box.width + dx, minWidth, viewportWidth - margin - box.left);
if (dir.includes('s'))
height = clamp(
box.height + dy,
minHeight,
viewportHeight - margin - box.top,
);
if (dir.includes('w')) {
width = clamp(box.width - dx, minWidth, right - margin);
left = right - width;
}
if (dir.includes('n')) {
height = clamp(box.height - dy, minHeight, bottom - margin);
top = bottom - height;
}
return { left, top, width, height };
}
-64
View File
@@ -1,64 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resizeBox } from './panelResize.js';
const box = { left: 100, top: 100, width: 400, height: 300 };
const limits = {
minWidth: 200,
minHeight: 150,
viewportWidth: 1000,
viewportHeight: 800,
};
test('east and south edges grow away from the anchored corner', () => {
assert.deepEqual(resizeBox(box, 'se', 50, 20, limits), {
left: 100,
top: 100,
width: 450,
height: 320,
});
assert.deepEqual(resizeBox(box, 'e', -30, 999, limits), {
left: 100,
top: 100,
width: 370,
height: 300,
});
});
test('west and north edges keep the opposite edge pinned', () => {
assert.deepEqual(resizeBox(box, 'w', -40, 0, limits), {
left: 60,
top: 100,
width: 440,
height: 300,
});
assert.deepEqual(resizeBox(box, 'nw', 40, 30, limits), {
left: 140,
top: 130,
width: 360,
height: 270,
});
assert.deepEqual(
resizeBox(box, 'n', -50, 0, limits).top,
100,
'horizontal motion does not move a north edge',
);
});
test('minimum size stops the moving edge, not the anchored one', () => {
const small = resizeBox(box, 'w', 350, 0, limits);
assert.deepEqual(small, { left: 300, top: 100, width: 200, height: 300 });
assert.equal(resizeBox(box, 'ne', 0, 400, limits).height, 150);
assert.equal(resizeBox(box, 'ne', 0, 400, limits).top, 250);
});
test('the viewport margin caps growth in every direction', () => {
assert.equal(resizeBox(box, 'e', 2000, 0, limits).width, 1000 - 6 - 100);
assert.equal(resizeBox(box, 's', 0, 2000, limits).height, 800 - 6 - 100);
const west = resizeBox(box, 'w', -2000, 0, limits);
assert.equal(west.left, 6);
assert.equal(west.left + west.width, 500);
const north = resizeBox(box, 'n', 0, -2000, limits);
assert.equal(north.top, 6);
assert.equal(north.top + north.height, 400);
});
+6 -6
View File
@@ -2,6 +2,7 @@ import { t } from '../i18n/index.js';
import { syncChipGroup } from './chipGroup.js';
import { syncRowList } from './rowList.js';
import { formatScaleLabel, unitLabel } from './unitLabels.js';
import { createSlider } from './slider.js';
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
@@ -90,12 +91,11 @@ export function createRailCardBlocks({ container, cardId, onParams }) {
'rail-card-setting-range-label',
block.rangeRow,
);
block.rangeInput = make(
'input',
'rail-card-setting-range',
block.rangeRow,
);
block.rangeInput.type = 'range';
block.rangeInput = createSlider({
documentRef: document,
className: 'rail-card-setting-range',
});
block.rangeRow.appendChild(block.rangeInput);
block.rangeValue = make(
'span',
'rail-card-setting-range-value',
+10 -8
View File
@@ -1,4 +1,5 @@
import { formatUtcDayMonth, t } from '../i18n/index.js';
import { createSlider } from './slider.js';
const set = (node, key, value) => {
if (node[key] !== value) node[key] = value;
};
@@ -43,14 +44,15 @@ export function createRailTimeline({
return node;
};
const previous = makeButton('‹', t('Earlier observation'), row);
const slider = document.createElement('input');
slider.type = 'range';
slider.className = `gev-quantitative-slider${sliderClassName ? ` ${sliderClassName}` : ''}`;
slider.min = '0';
slider.max = '0';
slider.step = '1';
slider.value = '0';
slider.setAttribute('aria-label', t('Observed history'));
const slider = createSlider({
documentRef: document,
className: sliderClassName,
min: '0',
max: '0',
step: '1',
value: '0',
ariaLabel: t('Observed history'),
});
row.appendChild(slider);
const next = makeButton('›', t('Later observation'), row);
const controls = document.createElement('div');
+20 -33
View File
@@ -12,6 +12,8 @@ import { formatNumber, msg, t, tn, translateKnown } from '../i18n/index.js';
import { createRailCardBlocks } from './railCardBlocks.js';
import { createRailCards } from './railCards.js';
import { createImagerySplit } from './imagerySplit.js';
import { createSlider } from './slider.js';
import { createSegmentedControl } from './segmentedControl.js';
import {
PRODUCTS,
hhmm,
@@ -286,13 +288,13 @@ export function createRecentImageryPanel({
const selection = el('section', 'ri-block ri-selection', root);
selection.id = 'ri-selection';
selection.setAttribute('aria-label', t('On the map'));
const modeGroup = el('div', 'ri-mode', selection);
const modeGroup = el('div', 'gev-segmented ri-mode', selection);
modeGroup.id = 'ri-mode';
modeGroup.setAttribute('role', 'radiogroup');
modeGroup.setAttribute('aria-label', t('Mode'));
const modeButtons = new Map(
MODE_LABELS.map(([mode, label]) => {
const node = el('button', 'data-toggle-chip ri-mode-btn', modeGroup);
const node = el('button', 'gev-segment ri-mode-btn', modeGroup);
node.type = 'button';
node.textContent = t(label);
node.dataset.mode = mode;
@@ -300,6 +302,15 @@ export function createRecentImageryPanel({
return [mode, node];
}),
);
const modeControl = createSegmentedControl({
buttons: modeButtons.values(),
valueOf: (button) => button.dataset.mode,
onChange: (mode) => layer.setMode(mode),
keyboard: 'arrows',
keyTarget: modeGroup,
readValue: () => snapshot.mode,
listen,
});
const slotRows = {};
for (const slotId of ['a', 'b']) {
const row = el('div', 'ri-slot', selection);
@@ -318,16 +329,17 @@ export function createRecentImageryPanel({
const opacityLabel = el('label', 'ri-opacity-label', controls);
el('span', '', opacityLabel, t('OPACITY'));
const opacityValue = el('span', 'ri-opacity-value', opacityLabel, '100%');
const opacity = el('input', 'ri-opacity', controls);
opacity.id = 'ri-opacity';
Object.assign(opacity, {
type: 'range',
const opacity = createSlider({
documentRef: document,
className: 'ri-opacity',
min: '0',
max: '100',
step: '1',
value: '100',
ariaLabel: t('Imagery opacity'),
});
opacity.setAttribute('aria-label', t('Imagery opacity'));
opacity.id = 'ri-opacity';
controls.appendChild(opacity);
opacityLabel.htmlFor = 'ri-opacity';
// Its slot is always there; it is only usable while a swipe is live.
const swap = el('button', 'data-toggle-chip ri-swap', controls, t('SWAP'));
@@ -668,12 +680,7 @@ export function createRecentImageryPanel({
function renderSelection() {
const { mode } = snapshot;
set(selection.dataset, 'mode', mode);
for (const [id, node] of modeButtons) {
const on = id === mode;
attribute(node, 'aria-checked', String(on));
toggle(node, 'active', on);
set(node, 'tabIndex', on ? 0 : -1);
}
modeControl.update(mode, { rovingFocus: true });
const ab = mode === 'ab';
for (const slotId of ['a', 'b']) {
const { row, tag, value, unpin } = slotRows[slotId];
@@ -1065,11 +1072,6 @@ export function createRecentImageryPanel({
strip.focus({ preventScroll: true });
return;
}
const mode = target?.closest?.('.ri-mode-btn');
if (mode) {
layer.setMode(mode.dataset.mode);
return;
}
if (target?.closest?.('.ri-swap') && !swap.disabled) {
layer.swapSides?.();
return;
@@ -1086,21 +1088,6 @@ export function createRecentImageryPanel({
if (event.target === opacity) layer.setAlpha(Number(opacity.value) / 100);
});
listen(modeGroup, 'keydown', (event) => {
const order = MODE_LABELS.map(([mode]) => mode);
const step =
{ ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[event.key] ||
0;
if (!step) return;
event.preventDefault();
const next =
order[
(order.indexOf(snapshot.mode) + step + order.length) % order.length
];
layer.setMode(next);
modeButtons.get(next)?.focus?.();
});
listen(strip, 'keydown', (event) => {
const total = snapshot.candidates.length;
const index = snapshot.focusIndex || 0;
+3 -3
View File
@@ -412,7 +412,7 @@ test('the body is a fixed stack of seven blocks in a fixed order, each with a fi
assert.match(rule('.recent-imagery-readout .ri-card'), /height: 112px/);
assert.match(
rule('.recent-imagery-readout .ri-selection'),
/grid-template-rows: 20px 20px 20px/,
/grid-template-rows: 26px 20px 20px/,
);
assert.match(rule('.recent-imagery-readout .ri-slot'), /height: 20px/);
assert.match(rule('.recent-imagery-readout .ri-controls'), /height: 22px/);
@@ -445,7 +445,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h
/**
* Every block that must keep its height: the node carrying the class, that
* class, and the rule it gets its height from. The mode row is the first
* 20 px track of the selection grid. The details header has no height of
* 26 px track of the selection grid. The details header has no height of
* its own: it is the last block, so it moves nothing below it, and its
* title is held to one line.
*/
@@ -491,7 +491,7 @@ test('no control changes place in the tree, no block is hidden and every fixed-h
mode.parentNode.children[0] === mode ? mode.parentNode : null,
'ri-selection',
readout('.ri-selection'),
/grid-template-rows: 20px 20px 20px/,
/grid-template-rows: 26px 20px 20px/,
],
'ri-slot-a': [
f.byId('ri-slot-a'),
+4 -4
View File
@@ -69,15 +69,15 @@ export function layoutRightPanelRail({
// A collapse may schedule one follow-up, which only measures and allocates.
const isCollapseRetry = pendingCollapseRetries.delete(stack);
// A floating (lifted-out) panel is not laid out by the rail. Neither is one
// that other CSS hides, such as an event view. Exclusive presentation hides
// collapsed panels itself; those stay in the rail so they can be restored.
// Other CSS may hide a panel, such as an event view: it is not laid out.
// Exclusive presentation hides collapsed panels itself; those stay in the
// rail so they can be restored.
const hiddenByRail = (panel) =>
stack.classList.contains('layout-exclusive') &&
panel.classList.contains('collapsed');
const panels = [...stack.children].filter(
(panel) =>
panel.matches('[data-panel-id]:not(.panel-floating)') &&
panel.matches('[data-panel-id]') &&
!panel.hidden &&
(hiddenByRail(panel) || getComputedStyle(panel).display !== 'none'),
);
+109
View File
@@ -0,0 +1,109 @@
const STEPS = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
/** Pure value navigation, also usable by a future template-based view. */
export function nextSegmentValue(values, value, key, { homeEnd = true } = {}) {
if (!values.length) return undefined;
if (homeEnd && key === 'Home') return values[0];
if (homeEnd && key === 'End') return values.at(-1);
const step = STEPS[key];
if (!step) return undefined;
return values[(values.indexOf(value) + step + values.length) % values.length];
}
/** Paint controlled selection without emitting changes or moving focus. */
export function syncSegmentedControl(
buttons,
value,
{ valueOf, activeClass = 'active', rovingFocus = false } = {},
) {
for (const button of buttons || []) {
if (!button) continue;
const selected = valueOf(button) === value;
if (button.classList.contains(activeClass) !== selected)
button.classList.toggle(activeClass, selected);
const set = (name, content) => {
if (button.getAttribute?.(name) !== content)
button.setAttribute(name, content);
};
set('aria-checked', String(selected));
if (rovingFocus) set('tabindex', selected ? '0' : '-1');
}
}
/**
* Controlled segmented-selector DOM adapter. Feature owners supply values and
* receive changes; rendering a restored value never triggers the callback.
* `keyboard` retains each existing group's policy: none, arrows, or radio
* (arrows plus Home/End). Pointer and native Enter/Space share the click path.
* An injected listener owner keeps the feature's existing teardown boundary.
* @param {object} options
* @param {Iterable<HTMLButtonElement>} options.buttons
* @param {Function} options.valueOf Maps a button to the feature's value.
* @param {Function} options.onChange Receives value and originating button.
* @param {'none'|'arrows'|'radio'} [options.keyboard]
* @param {HTMLElement} [options.keyTarget] Delegated keyboard target, if any.
* @param {Function} [options.readValue] Current controlled value for delegation.
* @param {Function} [options.listen] Feature-owned event registration.
* @returns {{ update: Function, destroy: Function }}
*/
export function createSegmentedControl({
buttons,
valueOf,
onChange,
keyboard = 'none',
keyTarget,
readValue,
listen,
}) {
const options = [...buttons].filter(Boolean);
const removers = [];
let destroyed = false;
const bind = (target, type, handler) => {
const owned = (event) => {
if (!destroyed) handler(event);
};
if (listen) listen(target, type, owned);
else {
target.addEventListener(type, owned);
removers.push(() => target.removeEventListener(type, owned));
}
};
for (const button of options)
bind(button, 'click', () => {
if (!button.disabled) onChange(valueOf(button), button);
});
const navigate = (event, from) => {
const enabled = options.filter((button) => !button.disabled);
const values = enabled.map(valueOf);
const next = nextSegmentValue(values, from, event.key, {
homeEnd: keyboard === 'radio',
});
if (next === undefined) return;
const button = enabled.find((option) => valueOf(option) === next);
event.preventDefault();
if (keyTarget) {
button.click();
button.focus();
} else {
button.focus();
if (next !== from) button.click();
}
};
if (keyboard !== 'none') {
if (keyTarget)
bind(keyTarget, 'keydown', (event) => navigate(event, readValue()));
else
for (const button of options)
bind(button, 'keydown', (event) => navigate(event, valueOf(button)));
}
return {
update(value, presentation) {
if (!destroyed)
syncSegmentedControl(options, value, { valueOf, ...presentation });
},
destroy() {
destroyed = true;
for (const remove of removers.splice(0)) remove();
},
};
}
+174
View File
@@ -0,0 +1,174 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { railFixture } from './railTestFixture.mjs';
import {
createSegmentedControl,
nextSegmentValue,
} from './segmentedControl.js';
function fixture(options = {}) {
let writes = 0;
const f = railFixture(() => writes++);
const buttons = ['a', 'b', 'c'].map((value) => {
const button = f.document.createElement('button');
button.dataset.value = value;
f.container.appendChild(button);
return button;
});
const calls = [];
const control = createSegmentedControl({
buttons,
valueOf: (button) => button.dataset.value,
onChange: (value) => calls.push(value),
...options,
});
const key = (target, value) => {
const event = new Event('keydown', { cancelable: true });
Object.defineProperty(event, 'key', { value });
target.dispatchEvent(event);
return event.defaultPrevented;
};
return { ...f, buttons, calls, control, key, writes: () => writes };
}
test('controlled restoration updates selection without emitting or stealing focus', () => {
const f = fixture();
f.buttons[0].focus();
f.control.update('b', { rovingFocus: true });
assert.deepEqual(f.calls, []);
assert.equal(f.document.activeElement, f.buttons[0]);
assert.deepEqual(
f.buttons.map((b) => b.getAttribute('aria-checked')),
['false', 'true', 'false'],
);
assert.deepEqual(
f.buttons.map((b) => b.getAttribute('tabindex')),
['-1', '0', '-1'],
);
const before = f.writes();
f.control.update('b', { rovingFocus: true });
assert.equal(
f.writes(),
before,
'unchanged renders must not retrigger rail observers',
);
f.control.destroy();
});
test('clicks emit current values once, including reselection; disabled controls do not emit', () => {
const f = fixture();
f.control.update('a');
f.buttons[0].click();
f.buttons[0].click();
f.buttons[1].dataset.value = 'renamed';
f.buttons[1].click();
f.buttons[2].disabled = true;
f.buttons[2].dispatchEvent(new Event('click'));
assert.deepEqual(f.calls, ['a', 'a', 'renamed']);
assert.equal(
f.buttons[0].getAttribute('aria-checked'),
'true',
'the owner controls selection',
);
f.control.destroy();
});
test('radio navigation wraps, skips disabled choices, and retains Home/End semantics', () => {
const f = fixture({ keyboard: 'radio' });
f.buttons[1].disabled = true;
assert.equal(f.key(f.buttons[0], 'ArrowRight'), true);
assert.equal(f.document.activeElement, f.buttons[2]);
assert.deepEqual(f.calls, ['c']);
f.key(f.buttons[2], 'ArrowDown');
assert.equal(f.document.activeElement, f.buttons[0]);
f.key(f.buttons[0], 'Home');
assert.deepEqual(
f.calls,
['c', 'a'],
'Home on the first button does not reselect it',
);
f.key(f.buttons[0], 'End');
assert.equal(f.document.activeElement, f.buttons[2]);
assert.deepEqual(f.calls, ['c', 'a', 'c']);
f.control.destroy();
});
test('legacy click-only groups leave arrows and tab order to their existing owner', () => {
const f = fixture();
f.control.update('b');
assert.equal(f.key(f.buttons[0], 'ArrowRight'), false);
assert.equal(f.key(f.buttons[0], 'Home'), false);
assert.deepEqual(f.calls, []);
assert.ok(
f.buttons.every((button) => button.getAttribute('tabindex') === null),
);
f.control.destroy();
});
test('delegated imagery keys navigate from the controlled value, not stale focus', () => {
const f = railFixture();
const buttons = ['a', 'b', 'c'].map((value) => {
const button = f.document.createElement('button');
button.dataset.value = value;
f.container.appendChild(button);
return button;
});
let selected = 'c';
const calls = [];
const control = createSegmentedControl({
buttons,
valueOf: (b) => b.dataset.value,
onChange: (value) => {
calls.push(value);
selected = value;
},
keyboard: 'arrows',
keyTarget: f.container,
readValue: () => selected,
});
const event = new Event('keydown', { cancelable: true });
Object.defineProperty(event, 'key', { value: 'ArrowRight' });
f.container.dispatchEvent(event);
assert.deepEqual(calls, ['a']);
assert.equal(f.document.activeElement, buttons[0]);
const home = new Event('keydown', { cancelable: true });
Object.defineProperty(home, 'key', { value: 'Home' });
f.container.dispatchEvent(home);
assert.equal(home.defaultPrevented, false);
assert.deepEqual(calls, ['a']);
control.destroy();
});
test('disposal revokes detached controls and replacement subscriptions fire once', () => {
const f = fixture({ keyboard: 'radio' });
f.control.destroy();
const next = [];
const replacement = createSegmentedControl({
buttons: f.buttons,
valueOf: (b) => b.dataset.value,
onChange: (value) => next.push(value),
keyboard: 'radio',
});
f.buttons[0].click();
f.key(f.buttons[0], 'End');
assert.deepEqual(f.calls, []);
assert.deepEqual(next, ['a', 'c']);
replacement.destroy();
replacement.destroy();
f.buttons[1].click();
f.key(f.buttons[0], 'End');
assert.deepEqual(next, ['a', 'c']);
});
test('pure value navigation can be reused without a DOM', () => {
assert.equal(nextSegmentValue(['fit', 'fill'], 'fill', 'ArrowRight'), 'fit');
assert.equal(nextSegmentValue(['fit', 'fill'], 'fit', 'ArrowUp'), 'fill');
assert.equal(nextSegmentValue(['fit', 'fill'], 'fill', 'Home'), 'fit');
assert.equal(nextSegmentValue(['fit', 'fill'], 'fit', 'End'), 'fill');
assert.equal(
nextSegmentValue(['fit', 'fill'], 'fit', 'Home', { homeEnd: false }),
undefined,
);
assert.equal(nextSegmentValue([], 'fit', 'ArrowRight'), undefined);
assert.equal(nextSegmentValue(['fit'], 'fit', 'Escape'), undefined);
});
-86
View File
@@ -576,16 +576,6 @@ export class ShellFacade {
return this._layerBindings._connectDirectionsCamera(...args);
}
/**
* On window resize, keep the draggable panel on-screen — a panel positioned near an edge can fall
* outside a now-smaller viewport (audit U2). pp-toggles is right-pinned, so re-pin (horizontal) and
* clamp its top. No-op until the panel has been positioned (explicit inline top).
* @returns {void}
*/
_reclampDraggablePanels() {
return this._panelPosition._reclampDraggablePanels();
}
/**
* Creates one CesiumJS PostProcessStage per visual style and registers
* it with the scene. Each stage starts with intensity 0 (invisible)
@@ -911,15 +901,6 @@ export class ShellFacade {
return this._panelChrome._maybeNotifyLayoutReset(...arguments);
}
/**
* Sets up drag-to-reposition for legacy floating controls. The right rail
* and left accordion remain fixed so their HUD alignment is deterministic.
* @returns {void}
*/
_initPanelDrag() {
return this._panelPosition._initPanelDrag();
}
_persistAwarenessSelection(...args) {
return this._layerBindings._persistAwarenessSelection(...args);
}
@@ -980,15 +961,6 @@ export class ShellFacade {
return this._radioControls?._renderRadioState?.(...args);
}
/**
* Returns the versioned localStorage key for a panel's saved position.
* @param {string} panelId - DOM id of the panel.
* @returns {string} localStorage key.
*/
_panelStorageKey(panelId) {
return this._panelPosition._panelStorageKey(panelId);
}
/**
* Returns the versioned localStorage key for a panel's collapsed state.
* @param {string} panelId - DOM id of the panel.
@@ -1083,64 +1055,6 @@ export class ShellFacade {
return this._panelChrome._syncPanelCollapseButton(...arguments);
}
/**
* Converts a panel from left-positioned to right-anchored so it expands
* leftward on resize. Used for the right-rail parameter panel.
* @param {HTMLElement} panelEl - The panel to re-anchor.
* @returns {void}
*/
_pinPanelToRight(panelEl) {
return this._panelPosition._pinPanelToRight(panelEl);
}
/**
* Restores a panel's top/left position from localStorage.
* Right-rail panels are additionally pinned to the right edge.
* @param {string} panelId - DOM id of the panel.
* @param {HTMLElement} panelEl - The panel DOM element.
* @returns {void}
*/
_restorePanelPosition(panelId, panelEl) {
return this._panelPosition._restorePanelPosition(panelId, panelEl);
}
/**
* Clamp a desired left/top so the panel stays fully on-screen (6px inset), matching the drag
* clamp (ui.js ~1822). Width/height are position-independent, so reading the rect first is safe.
* @param {number} left - desired left (px)
* @param {number} top - desired top (px)
* @param {HTMLElement} panelEl - the panel element
* @returns {{left:number, top:number}}
*/
_clampToViewport(left, top, panelEl) {
return this._panelPosition._clampToViewport(left, top, panelEl);
}
/**
* Persists a panel's current bounding-rect position to localStorage.
* @param {string} panelId - DOM id of the panel.
* @param {HTMLElement} panelEl - The panel DOM element.
* @returns {void}
*/
_savePanelPosition(panelId, panelEl) {
return this._panelPosition._savePanelPosition(panelId, panelEl);
}
/**
* Promotes a panel to the top of the panel z band [PANEL_Z_BASE, PANEL_Z_MAX].
* Renormalizes all promoted panels when the band is exhausted so panels can
* never climb above the voice pill (150), toasts (200), or clean-view exit (300).
* @param {HTMLElement} panelEl - Panel to bring to front.
* @returns {void}
*/
_promotePanelZ(panelEl) {
return this._panelPosition._promotePanelZ(panelEl);
}
_makePanelDraggable(panelId, panelEl, handleEl) {
return this._panelPosition._makePanelDraggable(panelId, panelEl, handleEl);
}
_buildSharePanelState() {
return this._panelChrome._buildSharePanelState(...arguments);
}
+3 -56
View File
@@ -3,7 +3,6 @@ import assert from 'node:assert/strict';
import { UiLifetime } from './uiLifetime.js';
import { PanelChrome } from './panelChrome.js';
import { PanelLayoutController } from './panelLayoutController.js';
import { PanelPositionControls } from './panelPositionControls.js';
import { ShellFeedback } from './shellFeedback.js';
import { RecordingControls } from './recordingControls.js';
@@ -174,46 +173,6 @@ test('panel layout coalesces each rail and cannot rearm after destruction', () =
}
});
test('panel disposal cancels a live drag without saving or accepting later pointer movement', () => {
const f = fixture();
try {
const owner = new PanelPositionControls({
syncPanelCollapseButton() {},
layoutRightPanels() {},
syncCctvPanelViewport() {},
showToast() {},
});
const panel = f.element(),
handle = f.element();
owner._makePanelDraggable('sample', panel, handle);
handle.dispatchEvent(
Object.assign(new Event('pointerdown', { cancelable: true }), {
button: 0,
clientX: 30,
clientY: 40,
}),
);
assert.equal(panel.classList.contains('panel-dragging'), true);
owner.destroy();
const before = { ...panel.style };
window.dispatchEvent(
Object.assign(new Event('pointermove'), { clientX: 500, clientY: 300 }),
);
window.dispatchEvent(new Event('pointerup'));
assert.deepEqual(panel.style, before);
assert.equal(panel.classList.contains('panel-dragging'), false);
owner._makePanelDraggable('sample', panel, handle);
owner._initPanelDrag();
owner._ppToggles.style.top = '10000px';
owner._reclampDraggablePanels();
assert.equal(owner._ppToggles.style.top, '10000px');
assert.equal(owner.removers.length, 0);
assert.deepEqual(f.writes, []);
} finally {
f.restore();
}
});
test('feedback disposal clears toast and polling work and rejects retained notices', () => {
const f = fixture();
try {
@@ -269,7 +228,7 @@ test('recording restores the original HUD after repeated entry and revokes retai
}
});
test('weather rail orders between CCTV and Context, resets positions, observes and syncs chrome', (t) => {
test('weather rail orders between CCTV and Context, observes and syncs chrome', (t) => {
const f = fixture();
const observed = new Set();
const previousResizeObserver = globalThis.ResizeObserver;
@@ -304,17 +263,8 @@ test('weather rail orders between CCTV and Context, resets positions, observes a
]) {
const panel = f.nodes.get(id) || f.element();
panel.id = id;
const positions = new Map(
['top', 'right', 'bottom', 'left', 'z-index'].map((key) => [
key,
'20px',
]),
);
panel.style = {
removeProperty: (key) => positions.delete(key),
positions,
};
panel.classList.add('panel-draggable', 'panel-dragging', 'collapsed');
panel.style = {};
panel.classList.add('collapsed');
const button = f.element();
button.closest = () => panel;
const attributes = new Map();
@@ -348,9 +298,6 @@ test('weather rail orders between CCTV and Context, resets positions, observes a
);
assert.ok(synced.includes('weather-panel'));
assert.ok(observed.has(weather));
assert.equal(weather.style.positions.size, 0);
assert.equal(weather.classList.contains('panel-draggable'), false);
assert.equal(weather.classList.contains('panel-dragging'), false);
assert.equal(weather.button.textContent, '◀');
assert.equal(weather.button.getAttribute('aria-expanded'), 'false');
assert.equal(weather.button.title, 'Expand WEATHER');
+9 -1
View File
@@ -80,6 +80,7 @@ function bindings(t) {
hud: { attachDataManager() {} },
_contextControls: { connect() {} },
_cctvControls: { connect() {} }, _radioControls: { connect() {} },
_streetLevelControls: { connects: 0, connect() { this.connects += 1; } },
};
const owner = new LayerBindings({
viewer: {}, services: {}, readControls: () => controls,
@@ -97,7 +98,7 @@ function bindings(t) {
subscribe() { events.push([id, 'subscribe']); return () => events.push([id, 'unsubscribe']); },
};
}
return { owner, manager, events };
return { owner, manager, events, controls };
}
test('manager replacement releases Directions even when the new manager has no route layer', (t) => {
@@ -116,6 +117,13 @@ test('manager replacement releases Directions even when the new manager has no r
assert.equal(events.length, count);
});
test('attaching the manager reconnects Street Level, so its switch-on requests reach the panel', (t) => {
const { owner, manager, controls } = bindings(t);
owner.attachDataManager(manager('first'));
assert.equal(controls._streetLevelControls.connects, 1);
owner.stop(); owner.disconnect();
});
test('camera-entry listeners are removed before any asynchronous layer cleanup', (t) => {
const { owner } = bindings(t);
let changes = 0;
-1
View File
@@ -28,7 +28,6 @@ const KEYS = [
'Exit cockpit to fly to a shared view',
'Go to expanded Radio section',
'Expand Radio section in Context',
'Double-click the header to snap the panel back',
'New scene name',
'Delete scene "{scene}" and all shots?',
'Detailed outline unavailable',
+32
View File
@@ -0,0 +1,32 @@
/**
* Build the shared native slider. Feature owners retain their input/change
* listeners, formatting and teardown; this component installs no handlers.
* Static sliders use the same gev-quantitative-slider class in their template.
* @param {object} options
* @param {Document} [options.documentRef] Document that owns the input.
* @param {string} [options.className] Additional feature/layout hooks.
* @param {number|string} [options.min]
* @param {number|string} [options.max]
* @param {number|string} [options.step]
* @param {number|string} [options.value]
* @param {string} [options.ariaLabel] Already translated accessible name.
* @returns {HTMLInputElement}
*/
export function createSlider({
documentRef = globalThis.document,
className = '',
min,
max,
step,
value,
ariaLabel,
} = {}) {
const input = documentRef.createElement('input');
input.type = 'range';
input.className = `gev-quantitative-slider${className ? ` ${className}` : ''}`;
for (const [key, setting] of Object.entries({ min, max, step, value })) {
if (setting !== undefined) input[key] = setting;
}
if (ariaLabel !== undefined) input.setAttribute('aria-label', ariaLabel);
return input;
}
+188 -356
View File
@@ -1,20 +1,18 @@
import { syncChipGroup } from './chipGroup.js';
import { isExplicitLayerStateOrigin } from '../data/layerState.js';
import { syncChipGroup } from './chipGroup.js';
import {
createSegmentedControl,
syncSegmentedControl,
} from './segmentedControl.js';
import {
presentStreetLevelPanel,
SINCE_STOPS,
} from './streetLevelPresentation.js';
import { t, translateKnown } from '../i18n/index.js';
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"])';
const RADIO_STEPS = Object.freeze({
ArrowRight: 1,
ArrowDown: 1,
ArrowLeft: -1,
ArrowUp: -1,
});
/** <body> classes of the modes that hide every panel; an expanded photo leaves with them. */
const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode'];
// Renders run on every layer notification, and the rail's MutationObserver
// relays out on each attribute write: write only what changed.
@@ -26,8 +24,8 @@ const setAttr = (node, key, value) => {
};
/**
* Fills the Street Level panel body: provider chips, filters, legend and
* viewer. The panel chrome itself belongs to the application shell.
* Fills the Street Level panel body: provider chips, filters and viewer. The
* panel chrome itself belongs to the application shell.
*/
export class StreetLevelControls {
constructor({ root, layer, actions }) {
@@ -40,20 +38,11 @@ export class StreetLevelControls {
this._state = null;
this._view = null;
this._wasEnabled = null;
this._wasStreetOpen = false;
// 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 switch-on was explicit (user, voice, tool) rather than
// a restore; null when the shell does not report request origins.
this._explicitEnable = null;
this._wasOpen = false;
// Whether the last switch-on was explicit (user, voice, tool), not a restore.
this._explicitEnable = false;
this._unsubscribeEnableRequests = null;
this._resizeQueued = false;
this._wrapHome = null;
this._expandReturnFocus = null;
// Only the <body> children this class made inert are released again.
this._inerted = new Set();
this._modalObserver = null;
this._elements = this._collect();
this._bind();
}
@@ -65,9 +54,9 @@ export class StreetLevelControls {
];
return {
status: byId('sl-status'),
controls: byId('sl-controls'),
providerChips: byId('sl-provider-chips'),
nearestBtn: byId('sl-nearest-btn'),
controls: byId('sl-controls'),
error: byId('sl-error'),
errorText: byId('sl-error-text'),
panoRow: byId('sl-pano-row'),
@@ -75,8 +64,6 @@ export class StreetLevelControls {
sinceRange: byId('sl-since'),
sinceLabel: byId('sl-since-label'),
sinceNote: byId('sl-since-note'),
legend: byId('sl-legend'),
followBtn: byId('sl-follow-btn'),
viewerWrap: byId('sl-viewer-wrap'),
viewerExpand: byId('sl-viewer-expand'),
viewerClose: byId('sl-viewer-close'),
@@ -87,7 +74,6 @@ export class StreetLevelControls {
imageLink: byId('sl-image-link'),
coverageMeta: byId('sl-coverage-meta'),
panoButtons: all('[data-sl-pano]'),
// Held, not re-queried: EXPAND moves them to <body> with the viewer.
renderButtons: all('[data-sl-render]'),
};
}
@@ -118,13 +104,13 @@ export class StreetLevelControls {
}
this._toggleProvider(button.dataset.chipId);
});
for (const button of el.panoButtons) {
this.listen(button, 'click', () =>
this._setParams({ pano: button.dataset.slPano }),
);
}
this._bindRadioGroup(el.panoButtons);
this._bindRadioGroup(el.renderButtons);
createSegmentedControl({
buttons: el.panoButtons,
valueOf: (button) => button.dataset.slPano,
onChange: (pano) => this._setParams({ pano }),
keyboard: 'radio',
listen: (target, type, handler) => this.listen(target, type, handler),
});
// The readout follows the thumb; coverage is rebuilt once, on release.
const sinceDays = () =>
SINCE_STOPS[Number(el.sinceRange?.value) || 0]?.days ?? 0;
@@ -145,24 +131,47 @@ export class StreetLevelControls {
aim: 'screen-centre',
});
});
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(), { dock: true }),
);
for (const button of el.renderButtons) {
this.listen(button, 'click', () => {
const mode = button.dataset.slRender;
// EXPAND is the browser's fullscreen. iPhone Safari has no element
// fullscreen, so there it is a top-layer popover; with neither, no button.
this._expandMode =
typeof el.viewerWrap?.requestFullscreen === 'function'
? 'fullscreen'
: typeof el.viewerWrap?.showPopover === 'function'
? 'popover'
: null;
this._popoverOpen = false;
if (this._expandMode) {
this.listen(el.viewerExpand, 'click', () => this._toggleExpanded());
if (this._expandMode === 'fullscreen')
this.listen(document, 'fullscreenchange', () => this._syncExpanded());
else
this.listen(el.viewerWrap, 'toggle', (event) =>
this._onPopoverToggle(event),
);
// Esc shrinks the photo before the panel's own Esc can collapse it, in
// a browser that hands the key to the page while full screen too.
this.listen(el.viewerWrap, 'keydown', (event) => {
if (event.key !== 'Escape' || !this.isViewerExpanded()) return;
event.preventDefault();
event.stopPropagation();
this._exitExpanded();
});
} else setProp(el.viewerExpand, 'hidden', true);
createSegmentedControl({
buttons: el.renderButtons,
valueOf: (button) => button.dataset.slRender,
keyboard: 'radio',
listen: (target, type, handler) => this.listen(target, type, handler),
onChange: (mode) => {
this.layer.setViewerRenderMode?.(mode);
try {
localStorage.setItem(RENDER_MODE_KEY, mode);
} catch {
/* storage unavailable */
}
});
}
},
});
try {
const stored = localStorage.getItem(RENDER_MODE_KEY);
if (stored === 'fill' || stored === 'letterbox')
@@ -170,18 +179,6 @@ export class StreetLevelControls {
} catch {
/* storage unavailable */
}
// The expanded viewer is a dialog: Esc shrinks it, Tab stays inside.
// Listen on the document, ahead of everything else, so both still hold
// once focus has left it (a click on the globe) and Esc never reaches
// the map's own handler, which would clear the selected sequence.
this.listen(document, 'keydown', (event) => this._onDialogKey(event), {
capture: true,
});
const endRestoreWindow = () => {
this._restoreWindow = false;
};
for (const type of ['pointerdown', 'keydown'])
this.listen(document, type, endRestoreWindow, { capture: true });
// MapillaryJS only tracks window resizes; the panel resizes on its own.
if (typeof ResizeObserver === 'function' && el.viewer) {
this._resizeObserver = new ResizeObserver(() => this._requestResize());
@@ -189,24 +186,6 @@ export class StreetLevelControls {
}
}
/** 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) => {
let next = null;
if (event.key in RADIO_STEPS) {
const count = buttons.length;
next = buttons[(index + RADIO_STEPS[event.key] + count) % count];
} else if (event.key === 'Home') next = buttons[0];
else if (event.key === 'End') next = buttons[buttons.length - 1];
if (!next) return;
event.preventDefault();
next.focus();
if (next !== button) next.click();
});
});
}
/**
* 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.
@@ -224,6 +203,17 @@ export class StreetLevelControls {
});
}
async _toggleEnabled() {
const enabled = this.actions.isEnabled?.() === true;
try {
await this.actions.setEnabled?.(!enabled);
} catch (error) {
this.actions.showToast?.(
translateKnown(error?.message || '') || t('Street Level toggle failed'),
);
}
}
async _ensureEnabled() {
if (this.actions.isEnabled?.()) return true;
try {
@@ -238,24 +228,6 @@ export class StreetLevelControls {
return this.actions.isEnabled?.() === true;
}
async _toggleEnabled() {
const enabled = this.actions.isEnabled?.() === true;
try {
await this.actions.setEnabled?.(!enabled);
} catch (error) {
this.actions.showToast?.(
translateKnown(error?.message || '') || t('Street Level toggle failed'),
);
}
}
/** Through the data manager, so saved state and share links record it. */
_setParams(params) {
if (this.actions.setParams)
this.actions.setParams(params, { origin: 'user' });
else this.layer.setParams?.(params);
}
/**
* Lighting a chip turns the layer on and darkens the others (one source at
* a time); darkening the lit one turns the layer off, keeping the provider.
@@ -283,211 +255,126 @@ export class StreetLevelControls {
}
}
/** Through the data manager, so saved state and share links record it. */
_setParams(params) {
this.actions.setParams(params, { origin: 'user' });
}
/** Whether EXPAND has the viewer full screen (or, on iPhone, open over the page). */
isViewerExpanded() {
const wrap = this._elements.viewerWrap;
if (!wrap) return false;
return this._expandMode === 'popover'
? this._popoverOpen
: document.fullscreenElement === wrap;
}
async _toggleExpanded() {
const wrap = this._elements.viewerWrap;
try {
if (this.isViewerExpanded()) this._exitExpanded();
else if (this._expandMode === 'fullscreen')
await wrap.requestFullscreen();
else {
// Only while open: a closed popover is hidden even inside the panel.
wrap.popover = 'auto';
wrap.showPopover();
}
} catch {
/* refused (no user gesture, policy): the panel view stays */
}
}
_exitExpanded() {
if (!this.isViewerExpanded()) return;
if (this._expandMode === 'popover') this._elements.viewerWrap.hidePopover();
else document.exitFullscreen().catch(() => {});
}
_onPopoverToggle(event) {
this._popoverOpen = event.newState === 'open';
if (!this._popoverOpen)
this._elements.viewerWrap.removeAttribute('popover');
this._syncExpanded();
}
/** Clean View, recording and the cockpit hide every panel, so they end EXPAND too. */
_watchPanelHidingModes(on) {
const body = document.body;
if (!on || !body) {
this._modesObserver?.disconnect();
this._modesObserver = null;
return;
}
const leaveIfHiding = () => {
if (PANEL_HIDING_MODES.some((mode) => body.classList.contains(mode)))
this._exitExpanded();
};
if (!this._modesObserver && typeof MutationObserver === 'function') {
this._modesObserver = new MutationObserver(leaveIfHiding);
this._modesObserver.observe(body, {
attributes: true,
attributeFilter: ['class'],
});
}
leaveIfHiding();
}
/** Mirror the expanded state on EXPAND, watch the panel-hiding modes, refit. */
_syncExpanded() {
const on = this.isViewerExpanded();
this._watchPanelHidingModes(on);
const button = this._elements.viewerExpand;
if (button) {
setProp(
button.querySelector('.sl-btn-icon'),
'textContent',
on ? '⤡' : '⤢',
);
setProp(
button.querySelector('.sl-btn-text'),
'textContent',
on ? t('SHRINK') : t('EXPAND'),
);
setAttr(button, 'aria-pressed', String(on));
setAttr(button, 'aria-label', on ? t('Shrink') : t('Expand'));
}
this._requestResize();
}
connect() {
this._unsubscribe?.();
this._unsubscribe = null;
if (this.destroyed || !this.root) return;
this._unsubscribeEnableRequests?.();
this._unsubscribeEnableRequests =
this.actions.subscribeEnableRequests?.((origin) => {
this._unsubscribeEnableRequests = this.actions.subscribeEnableRequests(
(origin) => {
this._explicitEnable = isExplicitLayerStateOrigin(origin);
}) || null;
},
);
this._unsubscribe = this.layer.subscribe?.((state) => this.render(state));
if (this.layer.getUIState) this.render(this.layer.getUIState());
}
onPanelResized() {
this._requestResize();
}
setCollapsed(collapsed, options = {}) {
this.actions.setPanelCollapsed?.(collapsed, options);
if (!collapsed) this._requestResize();
}
isViewerExpanded() {
return (
this._elements.viewerWrap?.classList.contains(
'sl-viewer-wrap-expanded',
) === true
);
}
/**
* 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;
if (!wrap) return;
const on = expanded === true;
if (on === this.isViewerExpanded()) return;
if (on) {
// Lift the viewer out of the panel: the panel's backdrop-filter would
// otherwise pin a fixed-position child inside it.
this._wrapHome = { parent: wrap.parentNode, next: wrap.nextSibling };
this._expandReturnFocus = document.activeElement;
document.body.appendChild(wrap);
wrap.classList.add('sl-viewer-wrap-expanded');
wrap.setAttribute('role', 'dialog');
wrap.setAttribute('aria-modal', 'true');
wrap.setAttribute('aria-label', t('Street-level image'));
wrap.tabIndex = -1;
wrap.focus({ preventScroll: true });
this._watchModal(true);
} else {
// Release the application first: the focus returned below may be in it.
this._watchModal(false);
wrap.classList.remove('sl-viewer-wrap-expanded');
wrap.removeAttribute('role');
wrap.removeAttribute('aria-modal');
wrap.removeAttribute('aria-label');
wrap.removeAttribute('tabindex');
if (this._wrapHome?.parent)
this._wrapHome.parent.insertBefore(wrap, this._wrapHome.next);
this._wrapHome = null;
const target = this._expandReturnFocus;
this._expandReturnFocus = null;
// A closed image hides the wrap: focus inside it would drop to <body>.
const reachable = (node) =>
node?.isConnected &&
typeof node.focus === 'function' &&
!(wrap.hidden && wrap.contains(node));
if (reachable(target)) target.focus({ preventScroll: true });
else if (reachable(this._elements.viewerExpand))
this._elements.viewerExpand.focus({ preventScroll: true });
else this._focusPanelControl();
}
const button = this._elements.viewerExpand;
if (button) {
const icon = button.querySelector('.sl-btn-icon');
const text = button.querySelector('.sl-btn-text');
if (icon) icon.textContent = on ? '⤡' : '⤢';
if (text) text.textContent = on ? t('SHRINK') : t('EXPAND');
button.setAttribute('aria-pressed', String(on));
button.setAttribute('aria-label', on ? t('Shrink') : t('Expand'));
}
if (!on && dock) this.actions.dockPanel?.();
this._requestResize();
}
/** Park focus on a control that stays on screen while the viewer is hidden. */
_focusPanelControl() {
const el = this._elements;
const target =
el.status || el.providerChips?.querySelector('.data-toggle-chip');
target?.focus?.({ preventScroll: true });
}
/** Clean View, recording and the cockpit hide the expanded viewer with CSS. */
_isDialogShown() {
if (!this.isViewerExpanded()) return false;
const wrap = this._elements.viewerWrap;
return (
typeof wrap.checkVisibility !== 'function' ||
// `checkVisibilityCSS` is the option's name before Chrome 121.
wrap.checkVisibility({
visibilityProperty: true,
checkVisibilityCSS: true,
})
);
}
/**
* 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()) {
this._releaseModal();
return false;
}
const wrap = this._elements.viewerWrap;
for (const node of [...(document.body?.children || [])]) {
if (node === wrap || node.inert) continue;
node.inert = true;
this._inerted.add(node);
}
return true;
}
_releaseModal() {
for (const node of this._inerted) node.inert = false;
this._inerted.clear();
}
/** Re-check the modal when <body> changes class or gains a child. */
_watchModal(on) {
this._modalObserver?.disconnect();
this._modalObserver = null;
if (!on) return this._releaseModal();
if (typeof MutationObserver === 'function' && document.body) {
this._modalObserver = new MutationObserver(() => this._syncModal());
this._modalObserver.observe(document.body, {
attributes: true,
attributeFilter: ['class'],
childList: true,
});
}
this._syncModal();
}
_onDialogKey(event) {
if (event.key !== 'Escape' && event.key !== 'Tab') return;
if (!this._syncModal()) return;
// Only keys meant for the viewer: from inside it, from nowhere (<body>)
// or from the globe. A field elsewhere (search) keeps its Esc and Tab.
const target = event.target;
const wrap = this._elements.viewerWrap;
if (
!wrap.contains(target) &&
target !== document.body &&
target !== document.documentElement &&
!target?.closest?.('#cesiumContainer')
)
return;
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.setViewerExpanded(false, { dock: true });
return;
}
// A radio off the roving tab stop (tabindex -1) is not a stop either.
const focusable = [...wrap.querySelectorAll(FOCUSABLE)].filter(
(node) =>
node.getAttribute('tabindex') !== '-1' &&
(node.offsetParent !== null || node === document.activeElement),
);
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
} else if (!wrap.contains(document.activeElement)) {
event.preventDefault();
first.focus();
}
}
render(state) {
if (this.destroyed || !state || !this.root) return;
this._state = state;
const view = presentStreetLevelPanel(state);
this._view = view;
this._renderHeader(view);
this._renderGate(view);
this._renderProviders(view);
setProp(this._elements.controls, 'disabled', view.controlsDisabled);
syncChipGroup(this._elements.providerChips, view.providers);
this._renderError(view);
this._renderFilters(view);
this._renderViewer(view);
this._renderMeta(view);
setProp(this._elements.coverageMeta, 'textContent', view.meta);
this._renderNearest(view);
this._reactToTransitions(view, state);
this._reactToTransitions(view);
}
_renderHeader(view) {
@@ -505,14 +392,6 @@ export class StreetLevelControls {
}
}
_renderGate(view) {
setProp(this._elements.controls, 'disabled', view.controlsDisabled);
}
_renderProviders(view) {
syncChipGroup(this._elements.providerChips, view.providers);
}
_renderError(view) {
const el = this._elements;
if (!el.error) return;
@@ -520,15 +399,6 @@ export class StreetLevelControls {
setProp(el.errorText, 'textContent', view.error || '');
}
_renderRadios(buttons, isChecked) {
for (const button of buttons) {
const checked = isChecked(button);
button.classList.toggle('is-active', checked);
setAttr(button, 'aria-checked', String(checked));
setAttr(button, 'tabindex', checked ? '0' : '-1');
}
}
_renderFilters(view) {
const el = this._elements;
const shows = view.filterControls || { pano: true, since: true };
@@ -538,11 +408,11 @@ export class StreetLevelControls {
setProp(el.sinceNote, 'textContent', view.sinceNote || '');
setProp(el.sinceNote, 'hidden', !view.sinceNote);
setProp(el.controls, 'hidden', !shows.pano && !shows.since);
setProp(el.legend, 'hidden', view.legend.length === 0);
this._renderRadios(
el.panoButtons,
(button) => button.dataset.slPano === view.filter.pano,
);
syncSegmentedControl(el.panoButtons, view.filter.pano, {
valueOf: (button) => button.dataset.slPano,
activeClass: 'is-active',
rovingFocus: true,
});
if (el.sinceRange) {
// Never move the thumb under the user's hand; once the committed value
// matches the thumb, show the full readout with its cut-off date.
@@ -557,55 +427,26 @@ export class StreetLevelControls {
setAttr(el.sinceRange, 'aria-valuetext', view.since.label);
}
}
// Rebuild when the swatches change, not only their count: one provider
// can replace another with the same number of entries.
const legendKey = view.legend
.map((entry) => `${entry.key}:${entry.color}:${entry.label}`)
.join('|');
if (el.legend && this._legendKey !== legendKey) {
this._legendKey = legendKey;
el.legend.replaceChildren(
...view.legend.map((entry) => {
const item = document.createElement('li');
const swatch = document.createElement('i');
swatch.className = 'sl-legend-swatch';
swatch.style.background = entry.color;
const label = document.createElement('span');
label.textContent = entry.label;
item.append(swatch, label);
return item;
}),
);
}
}
_renderViewer(view) {
const el = this._elements;
const { viewer } = view;
const wrap = el.viewerWrap;
if (!viewer.open) this._exitExpanded();
if (wrap) {
// Closing the image (its × button) hides the wrap: move focus out
// first, or it drops to <body>. A shrink below returns it itself.
if (
!viewer.open &&
!wrap.hidden &&
!this.isViewerExpanded() &&
wrap.contains(document.activeElement)
)
this._focusPanelControl();
// first, or it drops to <body>.
if (!viewer.open && !wrap.hidden && wrap.contains(document.activeElement))
el.status?.focus?.({ preventScroll: true });
setProp(wrap, 'hidden', !viewer.open);
}
if (!viewer.open && this.isViewerExpanded()) this.setViewerExpanded(false);
setProp(el.viewerPlaceholder, 'hidden', !viewer.loading);
if (el.followBtn) {
setAttr(el.followBtn, 'aria-pressed', String(viewer.follow.pressed));
setProp(el.followBtn, 'disabled', viewer.follow.disabled);
setProp(el.followBtn, 'title', viewer.follow.title);
}
this._renderRadios(
el.renderButtons,
(button) => button.dataset.slRender === viewer.renderMode,
);
syncSegmentedControl(el.renderButtons, viewer.renderMode, {
valueOf: (button) => button.dataset.slRender,
activeClass: 'is-active',
rovingFocus: true,
});
// Google's viewer always fills its frame: no FIT/FILL to offer.
const renderGroup = el.renderButtons[0]?.parentElement;
if (renderGroup) setProp(renderGroup, 'hidden', !viewer.renderModes);
@@ -620,10 +461,6 @@ export class StreetLevelControls {
}
}
_renderMeta(view) {
setProp(this._elements.coverageMeta, 'textContent', view.meta);
}
_renderNearest(view) {
const button = this._elements.nearestBtn;
if (!button) return;
@@ -639,35 +476,30 @@ export class StreetLevelControls {
* 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) {
_reactToTransitions(view) {
const enabled = view.enabled;
if (enabled && this._wasEnabled === false) {
// 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
? this._restoreWindow &&
(preference === 'stored' || preference === 'share')
: !this._explicitEnable;
if (!restoring) this.setCollapsed(false, { persist: false });
this._restoreWindow = false;
this._explicitEnable = this._explicitEnable === null ? null : false;
// A restore must not reopen a panel the user or a share link kept collapsed.
if (this._explicitEnable) this.setCollapsed(false, { persist: false });
this._explicitEnable = false;
}
this._wasEnabled = enabled;
// 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 });
this._wasStreetOpen = open;
const open = view.viewer.open;
if (open && !this._wasOpen) this.setCollapsed(false, { persist: false });
this._wasOpen = open;
}
destroy() {
if (this.destroyed) return;
this.destroyed = true;
this.setViewerExpanded(false);
this._watchModal(false);
this._exitExpanded();
// Its `toggle` is not heard once destroyed: a popover left on the wrap
// would hide the viewer even inside the panel.
if (this._expandMode === 'popover')
this._elements.viewerWrap?.removeAttribute('popover');
this._watchPanelHidingModes(false);
this.listeners.abort();
this._resizeObserver?.disconnect();
this._resizeObserver = null;
File diff suppressed because it is too large. Load diff
+26 -24
View File
@@ -188,18 +188,6 @@ function presentViewer(state) {
linkLabel: street.providerLabel
? `${translateKnown(street.providerLabel)} ↗`
: '',
follow: {
pressed: street.follow === true,
disabled: street.open !== true || street.followAvailable !== true,
title:
street.followAvailable === true
? t(
'Camera follows view: move the globe camera wherever the street-level view looks',
)
: t(
'Camera follow needs the Google 3D map: choose Google 3D under MAP SOURCE',
),
},
};
}
@@ -241,8 +229,16 @@ function translateHint(hint) {
}
function presentMeta(state) {
if (state.providers.length === 0) return translateHint(state.coverage.hint);
if (!state.enabled) return t('Switch a provider on to draw its coverage.');
const providers = state.providers || [];
if (providers.length === 0) return translateHint(state.coverage.hint);
if (!state.enabled) return t('Switch Street Level on to draw its coverage.');
// A lit source that cannot run on this map is not listed: the core's hint
// says which map it needs.
if (!providers.some((p) => p.on))
return (
translateHint(state.coverage.hint) ||
t('Every provider is switched off: light a chip to draw its coverage.')
);
// Key-gated with another source ready: say how to use it.
if (state.keyRequired && state.keyAlternatives?.length)
return keyGuidance(state);
@@ -260,7 +256,14 @@ function presentMeta(state) {
const countLine = coverageCountLine(state.coverage);
return [countLine, hint].filter(Boolean).join(' · ');
}
return hint;
if (hint) return hint;
// Still loading, or the error line already says why.
if (state.coverage.loading || state.keyRequired || state.coverage.error)
return '';
const filter = state.filter || {};
return (filter.pano || 'all') !== 'all' || Number(filter.sinceDays) > 0
? t('No sequences in view match the filter')
: t('No coverage in view');
}
/**
@@ -298,22 +301,23 @@ function coverageCountLine(coverage) {
/** @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 };
// 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.
// A missing key says how to add it; a rejected key's own error names the fix.
const keyMissing = keyRequired && state.keyRejected !== true;
return {
enabled,
enabled: state.enabled === true,
keyRequired,
status: presentStatus(state),
controlsDisabled: keyRequired || state.providers.length === 0,
controlsDisabled: keyRequired || (state.providers || []).length === 0,
providers: presentProviders(state),
error: keyMissing
? null
? keyGuidance(state) || null
: translateStreetLevelError(
state.street.error || state.coverage.error || '',
state.street.error ||
state.sequence.error ||
state.coverage.error ||
'',
) || null,
filter: { pano: filter.pano, sinceDays: Number(filter.sinceDays) || 0 },
filterControls: {
@@ -322,10 +326,8 @@ export function presentStreetLevelPanel(state, { now = Date.now() } = {}) {
},
since: presentSince(filter.sinceDays, now),
sinceNote: state.sinceNote || null,
legend: state.legend || [],
viewer: presentViewer(state),
meta: presentMeta(state),
nearest: presentNearest(state),
wantsOpen: state.street.open === true,
};
}
+46 -167
View File
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import {
presentStreetLevelPanel,
SINCE_STOPS,
sinceStopIndex,
} from './streetLevelPresentation.js';
import { providerSnapshot } from '../testSupport/streetLevelFakes.mjs';
@@ -16,7 +15,6 @@ test('an empty composition explains unavailable imagery even while disabled', ()
enabled,
providers: [],
coverage: { hint: 'No street-level imagery providers configured' },
legend: [],
}),
);
assert.equal(view.controlsDisabled, true);
@@ -29,22 +27,22 @@ test('an empty composition explains unavailable imagery even while disabled', ()
function snapshot(overrides = {}) {
const base = {
enabled: false,
keyRequired: false,
filter: { pano: 'all', sinceDays: 0 },
providers: [provider()],
coverage: { loading: false, count: 0, hint: '', error: null },
legend: [
{ key: 'mapillary', label: 'Mapillary', color: '#05cb63' },
{ key: 'selected', label: 'Selected', color: '#00d4ff' },
// The one provider's key state follows the summary the test asks for.
providers: [
provider({
keyRequired: overrides.keyRequired === true,
keyRejected: overrides.keyRejected === true,
}),
],
sequence: { providerId: null, selectedId: null, images: 0, loading: false },
keyRequired: false,
keyRejected: false,
filter: { pano: 'all', sinceDays: 0 },
coverage: { loading: false, count: 0, hint: '', error: null },
sequence: { selectedId: null, images: 0, loading: false },
street: {
open: false,
follow: false,
loading: false,
error: null,
providerId: null,
providerName: null,
providerLabel: null,
imageId: null,
position: null,
@@ -75,26 +73,18 @@ function deepMerge(target, source) {
return out;
}
test('a key-gated layer disables every control and flags KEY REQUIRED', () => {
const view = presentStreetLevelPanel(snapshot({ keyRequired: true }));
assert.equal(view.controlsDisabled, true);
assert.equal(view.status.text, 'KEY REQUIRED');
assert.equal(view.status.tone, 'warn');
});
test('KEY REQUIRED shows no raw error code under the pill', () => {
test('KEY REQUIRED disables every control and says how to add the key', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
keyRequired: true,
coverage: { error: 'no_key' },
providers: [provider({ keyRequired: true, error: 'no_key' })],
}),
);
assert.equal(view.controlsDisabled, true);
assert.equal(view.status.text, 'KEY REQUIRED');
assert.equal(view.error, null);
// The chip still says how to add the key.
assert.match(view.providers[0].title, /^Mapillary: Needs /);
assert.equal(view.status.tone, 'warn');
assert.match(view.error, /^Needs MAPILLARY_CLIENT_TOKEN/);
});
test('a key Mapillary rejected reads KEY REJECTED and names the fix', () => {
@@ -106,19 +96,16 @@ test('a key Mapillary rejected reads KEY REJECTED and names the fix', () => {
keyRequired: true,
keyRejected: true,
coverage: { error },
providers: [provider({ keyRequired: true, keyRejected: true, error })],
}),
);
assert.equal(view.status.text, 'KEY REJECTED');
assert.equal(view.status.tone, 'warn');
assert.equal(view.controlsDisabled, true);
assert.equal(view.error, error);
assert.equal(view.providers[0].state, 'error');
assert.equal(view.providers[0].title, `Mapillary: ${error}`);
});
test('a key problem outranks loading on the pill: KEY REJECTED, then KEY REQUIRED (M65)', () => {
// A keyless provider can still report loading (its status check, a retry):
// A keyless layer can still report loading (its status check, a retry):
// the pill must say what is wrong, not that something is on its way.
const loading = { enabled: true, coverage: { loading: true } };
assert.equal(
@@ -164,62 +151,7 @@ test('the header pill is the on/off switch and reads OFF, LOADING or ON', () =>
);
});
test('one chip per provider: on, off, loading, and keyless as an error chip', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
providers: [
provider(),
provider({
id: 'panoramax',
name: 'Panoramax',
label: 'PANORAMAX',
on: false,
requiresKeyId: null,
}),
provider({
id: 'kartaview',
name: 'KartaView',
label: 'KARTAVIEW',
loading: true,
requiresKeyId: null,
}),
provider({
id: 'google-street-view',
name: 'Google Street View',
label: 'STREET VIEW',
keyRequired: true,
requiresKeyId: 'google-maps',
}),
],
}),
);
assert.deepEqual(
view.providers.map((chip) => [chip.id, chip.active, chip.state, chip.busy]),
[
['mapillary', true, 'active', false],
['panoramax', false, 'idle', false],
['kartaview', true, 'loading', true],
['google-street-view', true, 'error', false],
],
);
assert.equal(view.providers[0].label, 'MAPILLARY');
assert.equal(view.providers[1].title, 'Panoramax imagery off');
assert.match(
view.providers[3].title,
/^Google Street View: Needs GOOGLE_MAPS_API_KEY/,
);
assert.ok(view.providers.every((chip) => chip.disabled === false));
});
test('a provider error is explained on its chip', () => {
const view = presentStreetLevelPanel(
snapshot({ providers: [provider({ error: 'Tile HTTP 502' })] }),
);
assert.equal(view.providers[0].title, 'Mapillary: Tile HTTP 502');
});
test('errors from the viewer or the coverage web surface in one alert', () => {
test('errors from the viewer, the sequence or the coverage surface in one alert', () => {
assert.equal(presentStreetLevelPanel(snapshot()).error, null);
assert.equal(
presentStreetLevelPanel(
@@ -227,6 +159,12 @@ test('errors from the viewer or the coverage web surface in one alert', () => {
).error,
'Image could not be opened',
);
assert.equal(
presentStreetLevelPanel(
snapshot({ sequence: { error: 'Sequence images unavailable' } }),
).error,
'Sequence images unavailable',
);
assert.equal(
presentStreetLevelPanel(snapshot({ coverage: { error: 'Tile HTTP 502' } }))
.error,
@@ -234,36 +172,10 @@ test('errors from the viewer or the coverage web surface in one alert', () => {
);
});
test('chips are dark while the layer is off, so the chip is the layer switch', () => {
const view = presentStreetLevelPanel(snapshot({ enabled: false }));
assert.deepEqual(
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],
);
test('a SINCE window from a link lands on the nearest slider stop', () => {
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(400), 5);
assert.equal(sinceStopIndex(-4), 0);
});
@@ -289,36 +201,10 @@ test('the SINCE readout names the window and the cut-off date it means today', (
assert.equal(custom.since.label, 'LAST 400 DAYS · SINCE 2025-08-21');
});
test('legend passes through in the layer’s order', () => {
const view = presentStreetLevelPanel(snapshot());
assert.deepEqual(
view.legend.map((entry) => entry.key),
['mapillary', 'selected'],
);
});
test('each provider chip carries its source colour', () => {
const view = presentStreetLevelPanel(
snapshot({
providers: [
provider(),
provider({ id: 'panoramax', label: 'PANORAMAX', color: '#a66bff' }),
],
}),
);
assert.deepEqual(
view.providers.map((chip) => [chip.id, chip.color]),
[
['mapillary', '#05cb63'],
['panoramax', '#a66bff'],
],
);
});
test('the meta line never mixes the visible-sequence count with the selected sequence', () => {
assert.equal(
presentStreetLevelPanel(snapshot()).meta,
'Switch a provider on to draw its coverage.',
'Switch Street Level on to draw its coverage.',
);
const browsing = presentStreetLevelPanel(
snapshot({ enabled: true, coverage: { count: 812 } }),
@@ -342,14 +228,30 @@ test('the meta line never mixes the visible-sequence count with the selected seq
assert.equal(hinted.meta, 'Point the camera at the globe');
});
test('viewer caption reads "Image by" left, date right, and links to the provider', () => {
test('an empty view says so, and whether the filter emptied it', () => {
const meta = (overrides) =>
presentStreetLevelPanel(snapshot({ enabled: true, ...overrides })).meta;
assert.equal(meta({}), 'No coverage in view');
assert.equal(
meta({ filter: { pano: 'flat', sinceDays: 0 } }),
'No sequences in view match the filter',
);
assert.equal(
meta({ filter: { pano: 'all', sinceDays: 365 } }),
'No sequences in view match the filter',
);
// Nothing to claim while it loads, or when the error line explains it.
assert.equal(meta({ coverage: { loading: true } }), '');
assert.equal(meta({ keyRequired: true }), '');
assert.equal(meta({ coverage: { error: 'Tile HTTP 502' } }), '');
});
test('viewer caption reads "Image by" left, date right, and links to Mapillary', () => {
const view = presentStreetLevelPanel(
snapshot({
enabled: true,
street: {
open: true,
providerId: 'mapillary',
providerName: 'Mapillary',
providerLabel: 'MAPILLARY',
imageId: '1814275685699406',
creator: 'mapfool',
@@ -358,7 +260,6 @@ test('viewer caption reads "Image by" left, date right, and links to the provide
isPano: true,
externalUrl:
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
followAvailable: true,
},
}),
);
@@ -369,8 +270,7 @@ test('viewer caption reads "Image by" left, date right, and links to the provide
'https://www.mapillary.com/app/?pKey=1814275685699406&focus=photo',
);
assert.equal(view.viewer.linkLabel, 'MAPILLARY ↗');
assert.equal(view.viewer.follow.disabled, false);
assert.equal(view.wantsOpen, true);
assert.equal(view.viewer.open, true);
});
test('an image without a creator name leaves the left caption empty', () => {
@@ -385,27 +285,6 @@ test('an image without a creator name leaves the left caption empty', () => {
assert.equal(view.viewer.linkLabel, '');
});
test('FOLLOW is disabled off Google 3D and says where to switch', () => {
const open = { open: true, imageId: '1' };
const off3d = presentStreetLevelPanel(
snapshot({ street: { ...open, followAvailable: false } }),
);
assert.equal(off3d.viewer.follow.disabled, true);
assert.match(
off3d.viewer.follow.title,
/needs the Google 3D map.*MAP SOURCE/,
);
const on3d = presentStreetLevelPanel(
snapshot({ street: { ...open, followAvailable: true } }),
);
assert.equal(on3d.viewer.follow.disabled, false);
assert.match(on3d.viewer.follow.title, /^Camera follows view/);
const closed = presentStreetLevelPanel(
snapshot({ street: { open: false, followAvailable: true } }),
);
assert.equal(closed.viewer.follow.disabled, true, 'nothing to follow');
});
test('a Street View caption names the place, dates it by month, and offers no FIT/FILL', () => {
const view = presentStreetLevelPanel(
snapshot({
+10 -8
View File
@@ -1,4 +1,5 @@
import { formatNumber, t } from '../i18n/index.js';
import { createSlider } from './slider.js';
/** A slider value in the UI language: 0.85 in English, 0,85 in Spanish. */
const formatParam = (value, digits) =>
@@ -41,14 +42,15 @@ export function createStyleParameters({
const label = documentRef.createElement('span');
label.className = 'param-label';
label.textContent = t(metadata.label);
const slider = documentRef.createElement('input');
slider.type = 'range';
slider.className = 'param-slider';
slider.setAttribute('aria-label', t(metadata.label));
slider.min = metadata.min;
slider.max = metadata.max;
slider.step = metadata.max <= 1 ? '0.01' : '0.1';
slider.value = readValue(name);
const slider = createSlider({
documentRef,
className: 'param-slider',
ariaLabel: t(metadata.label),
min: metadata.min,
max: metadata.max,
step: metadata.max <= 1 ? '0.01' : '0.1',
value: readValue(name),
});
const digits = metadata.max <= 1 ? 2 : 1;
const valueDisplay = documentRef.createElement('span');
valueDisplay.className = 'param-value';
+1 -2
View File
@@ -1984,8 +1984,7 @@ body.cockpit-mode
#command-dock,
#military-awareness-panel,
#military-awareness-direction-overlay,
#cockpit-entry,
.sl-viewer-wrap-expanded
#cockpit-entry
) {
display: none !important;
}
+5 -315
View File
@@ -72,146 +72,6 @@
text-shadow: 0 0 8px rgba(0, 212, 255, 0.3);
}
.panel-drag-handle {
cursor: grab;
user-select: none;
}
.panel-drag-handle:active {
cursor: grabbing;
}
.panel-drag-handle.compact {
margin-left: auto;
margin-bottom: 2px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 1.4px;
color: var(--text-dim);
}
.panel-drag-handle.compact:hover {
color: var(--text-primary);
border-color: var(--glass-border-hover);
}
.panel-dragging {
opacity: 0.96;
}
/* ── Portable rail panels ────────────────────
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;
}
.panel-portable.panel-dragging .panel-header {
cursor: grabbing;
}
#right-context-rail > .panel-floating {
position: fixed;
inset: auto;
/* 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 shows only its header strip; its inline height stays. */
#right-context-rail > .panel-floating.collapsed {
max-height: max-content;
}
.panel-floating[style*='height'] > .cctv-panel-inner {
box-sizing: border-box;
height: 100%;
max-height: none;
}
.panel-resize-edge,
.panel-resize-grip {
position: absolute;
z-index: 3;
touch-action: none;
}
.panel-resize-edge[data-dir='n'],
.panel-resize-edge[data-dir='s'] {
left: 10px;
right: 10px;
height: 7px;
cursor: ns-resize;
}
.panel-resize-edge[data-dir='n'] {
top: -3px;
}
.panel-resize-edge[data-dir='s'] {
bottom: -3px;
}
.panel-resize-edge[data-dir='e'],
.panel-resize-edge[data-dir='w'] {
top: 10px;
bottom: 10px;
width: 7px;
cursor: ew-resize;
}
.panel-resize-edge[data-dir='e'] {
right: -3px;
}
.panel-resize-edge[data-dir='w'] {
left: -3px;
}
.panel-resize-edge[data-dir='ne'],
.panel-resize-edge[data-dir='nw'],
.panel-resize-edge[data-dir='sw'] {
width: 14px;
height: 14px;
}
.panel-resize-edge[data-dir='ne'] {
top: -3px;
right: -3px;
cursor: nesw-resize;
}
.panel-resize-edge[data-dir='nw'] {
top: -3px;
left: -3px;
cursor: nwse-resize;
}
.panel-resize-edge[data-dir='sw'] {
bottom: -3px;
left: -3px;
cursor: nesw-resize;
}
.panel-resize-grip {
right: 3px;
bottom: 3px;
width: 14px;
height: 14px;
cursor: nwse-resize;
opacity: 0.55;
clip-path: polygon(100% 0, 100% 100%, 0 100%);
background: repeating-linear-gradient(
135deg,
transparent 0 3px,
rgba(0, 212, 255, 0.6) 3px 4px
);
transition: opacity var(--transition-fast);
}
.panel-portable:hover .panel-resize-grip,
.panel-resizing .panel-resize-grip {
opacity: 1;
}
.panel-collapsible.collapsed > .panel-resize-edge,
.panel-collapsible.collapsed > .panel-resize-grip {
display: none;
}
.panel-resizing,
.panel-resizing * {
user-select: none;
}
.panel-title {
font-family: var(--font-mono);
font-size: 9px;
@@ -672,110 +532,7 @@ html:not([lang='en']) .panel-collapse-btn {
.pp-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 3px;
background: rgba(255, 255, 255, 0.08);
border-radius: 2px;
outline: none;
cursor: pointer;
min-width: 60px;
}
.pp-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
cursor: pointer;
}
.pp-slider::-moz-range-thumb {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
border: none;
box-shadow: 0 0 6px var(--accent-glow);
cursor: pointer;
}
.pp-slider-value {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.5px;
color: var(--accent);
min-width: 28px;
text-align: right;
flex-shrink: 0;
}
/* Quantitative companion sliders share Display / Sharpen's visible rail,
* circular thumb, glow, and value treatment while retaining a taller hit area. */
.gev-quantitative-slider {
width: 100%;
min-width: 0;
height: 18px;
margin: 0;
-webkit-appearance: none;
appearance: none;
background: transparent;
outline: none;
cursor: pointer;
}
.gev-quantitative-slider::-webkit-slider-runnable-track {
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.08);
}
.gev-quantitative-slider::-webkit-slider-thumb {
width: 10px;
height: 10px;
margin-top: -3.5px;
border: 0;
border-radius: 50%;
-webkit-appearance: none;
appearance: none;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
cursor: pointer;
}
.gev-quantitative-slider::-moz-range-track,
.gev-quantitative-slider::-moz-range-progress {
height: 3px;
border: 0;
border-radius: 2px;
background: rgba(255, 255, 255, 0.08);
}
.gev-quantitative-slider::-moz-range-thumb {
width: 10px;
height: 10px;
border: 0;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
cursor: pointer;
}
.gev-quantitative-slider:focus-visible {
border-radius: 4px;
outline: 1px solid color-mix(in srgb, var(--accent) 80%, transparent);
outline-offset: 2px;
}
.gev-quantitative-slider:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.gev-slider-value {
min-width: 28px;
color: var(--accent);
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.5px;
text-align: right;
flex-shrink: 0;
}
.pp-slider-mini-label {
@@ -817,11 +574,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
padding: 5px 8px 6px;
}
.detection-allocation-seg .pp-mode-btn {
font-size: 8px;
letter-spacing: 0.35px;
}
#hud-layout-row {
min-width: 200px;
}
@@ -839,7 +591,7 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
min-width: 200px;
}
.cyber-sonar-toggle-row .pp-mode-btn {
.cyber-sonar-toggle-row .pp-mode-btn:not(.gev-segment) {
margin-left: auto;
min-width: 48px;
}
@@ -991,11 +743,6 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
#draw-mode-row {
min-width: 184px;
}
/* Equal thirds with room around the label, so a long translated shape name
never runs edge to edge in its segment. */
html:not([lang='en']) #draw-mode-row .pp-mode-btn {
padding: 0 4px;
}
#draw-label-row {
flex-wrap: wrap;
row-gap: 6px;
@@ -1124,11 +871,8 @@ body.gev-geo-import-drag #cesiumContainer {
.pp-mode-seg {
display: flex;
flex: 1;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
overflow: hidden;
}
.pp-mode-btn {
.pp-mode-btn:not(.gev-segment) {
flex: 1;
height: 22px;
background: rgba(255, 255, 255, 0.04);
@@ -1143,14 +887,11 @@ body.gev-geo-import-drag #cesiumContainer {
background-color var(--transition-fast),
color var(--transition-fast);
}
.pp-mode-btn + .pp-mode-btn {
border-left: 1px solid rgba(255, 255, 255, 0.14);
}
.pp-mode-btn:hover {
.pp-mode-btn:not(.gev-segment):hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
}
.pp-mode-btn.active {
.pp-mode-btn:not(.gev-segment).active {
background: var(--accent);
color: #000;
}
@@ -1168,12 +909,6 @@ body.gev-geo-import-drag #cesiumContainer {
box-shadow: inset 0 0 0 4px #061014;
}
.pp-slider:focus-visible,
.param-slider:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: 4px;
}
#clean-view-toggle {
width: var(--pp-expanded-width);
}
@@ -1214,9 +949,7 @@ body.ui-clean-view #data-panel,
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 viewer lives on <body>, outside every panel. */
body.ui-clean-view .sl-viewer-wrap-expanded {
body.ui-clean-view #scene-panel {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
@@ -1382,47 +1115,4 @@ html:not([lang='en']) .param-slider-row {
.param-slider {
flex: 1;
min-width: 0;
-webkit-appearance: none;
appearance: none;
height: 3px;
background: rgba(255, 255, 255, 0.08);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.param-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
cursor: pointer;
transition: box-shadow var(--transition-fast);
}
.param-slider::-webkit-slider-thumb:hover {
box-shadow: 0 0 12px var(--accent-glow);
}
.param-slider::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent);
border: none;
box-shadow: 0 0 6px var(--accent-glow);
cursor: pointer;
}
.param-value {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.5px;
color: var(--accent);
min-width: 32px;
text-align: right;
flex-shrink: 0;
}
+2
View File
@@ -506,11 +506,13 @@
0 0 12px rgba(196, 84, 84, 0.14);
}
:root[data-ui-theme='cyber'] .gev-segmented,
:root[data-ui-theme='cyber'] .pp-mode-seg {
border-color: rgba(206, 222, 226, 0.24);
border-radius: 1px;
}
:root[data-ui-theme='cyber'] .gev-segment[aria-checked='true'],
:root[data-ui-theme='cyber'] .pp-mode-btn.active {
background: var(--cyber-red);
color: #f7f8f7;
+1 -1
View File
@@ -381,7 +381,7 @@ body.cockpit-mode #cockpit-cloud-effects.active {
position: fixed;
right: 36px;
bottom: 36px;
/* Z ladder: panels 100-139, voice pill 150, toast 200, clean-view-exit 300 */
/* Z ladder: panels ~100, voice pill 150, toast 200, clean-view-exit 300 */
z-index: 150;
display: flex;
align-items: center;
+15 -20
View File
@@ -161,8 +161,8 @@
max-height var(--transition-fast);
}
#right-context-rail > #pp-toggles,
#right-context-rail > #cctv-panel:not(.panel-floating),
#right-context-rail > #street-level-panel:not(.panel-floating),
#right-context-rail > #cctv-panel,
#right-context-rail > #street-level-panel,
#right-context-rail > #weather-panel,
#right-context-rail > #recent-imagery-panel,
#right-context-rail > #global-context-panel {
@@ -176,24 +176,24 @@
max-height: 100%;
pointer-events: auto;
}
#right-context-rail #cctv-panel:not(.panel-floating),
#right-context-rail #street-level-panel:not(.panel-floating),
#right-context-rail #cctv-panel,
#right-context-rail #street-level-panel,
#right-context-rail #weather-panel,
#right-context-rail #recent-imagery-panel {
width: 100%;
max-height: 100%;
pointer-events: auto;
}
#right-context-rail > #weather-panel,
/* Collapsed, it must stay hideable by the rail's exclusive layout. */
/* Collapsed readouts must stay hideable by the rail's exclusive layout. */
#right-context-rail > #weather-panel:not(.collapsed),
#right-context-rail > #recent-imagery-panel:not(.collapsed) {
display: flex;
flex-direction: column;
flex-shrink: 1;
min-height: 0;
}
#right-context-rail #cctv-panel.collapsed:not(.panel-floating),
#right-context-rail #street-level-panel.collapsed:not(.panel-floating),
#right-context-rail #cctv-panel.collapsed,
#right-context-rail #street-level-panel.collapsed,
#right-context-rail #weather-panel.collapsed,
#right-context-rail #recent-imagery-panel.collapsed {
width: var(--right-display-collapsed-width);
@@ -257,24 +257,19 @@
#right-context-rail.layout-focus {
height: var(--right-stack-max-height);
}
#right-context-rail.layout-exclusive
> [data-panel-id].collapsed:not(.panel-floating) {
#right-context-rail.layout-exclusive > [data-panel-id].collapsed {
display: none;
}
#right-context-rail.layout-focus
> [data-panel-id].collapsed:not(.panel-floating) {
#right-context-rail.layout-focus > [data-panel-id].collapsed {
display: block;
flex: 0 0 auto;
}
#right-context-rail.layout-focus.layout-exclusive
> [data-panel-id].collapsed:not(.panel-floating) {
#right-context-rail.layout-focus.layout-exclusive > [data-panel-id].collapsed {
display: none;
}
#right-context-rail.layout-focus > #pp-toggles:not(.collapsed),
#right-context-rail.layout-focus
> #cctv-panel:not(.collapsed):not(.panel-floating),
#right-context-rail.layout-focus
> #street-level-panel:not(.collapsed):not(.panel-floating),
#right-context-rail.layout-focus > #cctv-panel:not(.collapsed),
#right-context-rail.layout-focus > #street-level-panel:not(.collapsed),
#right-context-rail.layout-focus > #weather-panel:not(.collapsed),
#right-context-rail.layout-focus > #recent-imagery-panel:not(.collapsed),
#right-context-rail.layout-focus > #global-context-panel:not(.collapsed) {
@@ -293,10 +288,10 @@
> #weather-panel:not(.collapsed)
.weather-panel-inner,
#right-context-rail.layout-focus
> #cctv-panel:not(.collapsed):not(.panel-floating)
> #cctv-panel:not(.collapsed)
.cctv-panel-inner,
#right-context-rail.layout-focus
> #street-level-panel:not(.collapsed):not(.panel-floating)
> #street-level-panel:not(.collapsed)
.street-level-panel-inner,
#right-context-rail.layout-focus
> #recent-imagery-panel:not(.collapsed)
+2 -13
View File
@@ -309,10 +309,10 @@ html:not([lang='en'])
display: none;
}
/* 4. The selection bar: mode, then two rows that are always there. */
/* 4. The selection bar: 24px mode buttons plus borders, then two stable rows. */
.recent-imagery-readout .ri-selection {
display: grid;
grid-template-rows: 20px 20px 20px;
grid-template-rows: 26px 20px 20px;
row-gap: 4px;
padding: 5px;
border: 1px solid var(--weather-card-border);
@@ -321,7 +321,6 @@ html:not([lang='en'])
.recent-imagery-readout .ri-mode {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 4px;
}
.recent-imagery-readout .ri-selection .data-toggle-chip {
height: 20px;
@@ -423,16 +422,6 @@ html:not([lang='en']) .recent-imagery-readout .ri-opacity-label {
}
.recent-imagery-readout .ri-opacity-value {
font-variant-numeric: tabular-nums;
color: var(--text-secondary);
}
.recent-imagery-readout .ri-opacity {
width: 100%;
min-width: 0;
margin: 0;
accent-color: var(--accent);
}
.recent-imagery-readout .ri-opacity:disabled {
opacity: 0.35;
}
.recent-imagery-readout .ri-swap {
width: 40px;
+1 -3
View File
@@ -15,9 +15,7 @@ body.recording-mode #cctv-panel,
body.recording-mode #radio-panel,
body.recording-mode #global-context-panel,
body.recording-mode #gev-voice-control,
body.recording-mode #scene-panel,
/* The expanded viewer lives on <body>, outside every panel. */
body.recording-mode .sl-viewer-wrap-expanded {
body.recording-mode #scene-panel {
opacity: 0 !important;
visibility: hidden !important;
pointer-events: none !important;
+4 -4
View File
@@ -216,10 +216,10 @@
}
#right-context-rail #pp-toggles.collapsed .pp-header-row,
#right-context-rail #cctv-panel:not(.panel-floating),
#right-context-rail #cctv-panel.collapsed:not(.panel-floating),
#right-context-rail #street-level-panel:not(.panel-floating),
#right-context-rail #street-level-panel.collapsed:not(.panel-floating),
#right-context-rail #cctv-panel,
#right-context-rail #cctv-panel.collapsed,
#right-context-rail #street-level-panel,
#right-context-rail #street-level-panel.collapsed,
#right-context-rail #recent-imagery-panel,
#right-context-rail #recent-imagery-panel.collapsed,
#right-context-rail #radio-panel,
+51
View File
@@ -0,0 +1,51 @@
/* Shared one-of-many control. Feature classes retain surrounding placement. */
.gev-segmented {
display: inline-flex;
min-width: 0;
border: 1px solid var(--glass-border-hover);
border-radius: 6px;
overflow: hidden;
}
.gev-segmented[hidden] {
display: none;
}
.gev-segment {
flex: 1;
min-width: min-content;
min-height: 24px;
margin: 0;
padding: 4px 8px;
border: 0;
border-radius: 0;
background: rgba(255, 255, 255, 0.04);
color: var(--text-secondary);
font: 9px/1.25 var(--font-mono);
letter-spacing: 0.6px;
text-transform: uppercase;
overflow-wrap: normal;
cursor: pointer;
transition:
background-color var(--transition-fast),
color var(--transition-fast);
}
.gev-segment + .gev-segment {
border-left: 1px solid var(--glass-border);
}
.gev-segment:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
}
.gev-segment[aria-checked='true'] {
background: var(--accent);
color: #000;
}
.gev-segment:focus-visible {
position: relative;
outline: 2px solid var(--text-primary);
outline-offset: -3px;
box-shadow: inset 0 0 0 4px #061014;
}
.gev-segment:disabled {
opacity: 0.42;
cursor: not-allowed;
}
+70
View File
@@ -0,0 +1,70 @@
/* Shared ordinary slider surface; feature classes only own surrounding layout.
* Native range inputs retain keyboard, pointer, bounds and step behavior. */
.gev-quantitative-slider {
width: 100%;
min-width: 0;
height: 18px;
margin: 0;
padding: 0;
border: 0;
-webkit-appearance: none;
appearance: none;
background: transparent;
outline: none;
cursor: pointer;
}
.gev-quantitative-slider::-webkit-slider-runnable-track {
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.08);
}
.gev-quantitative-slider::-webkit-slider-thumb {
width: 10px;
height: 10px;
margin-top: -3.5px;
border: 0;
border-radius: 50%;
-webkit-appearance: none;
appearance: none;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
cursor: pointer;
}
.gev-quantitative-slider::-moz-range-track,
.gev-quantitative-slider::-moz-range-progress {
height: 3px;
border: 0;
border-radius: 2px;
background: rgba(255, 255, 255, 0.08);
}
.gev-quantitative-slider::-moz-range-thumb {
width: 10px;
height: 10px;
border: 0;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 6px var(--accent-glow);
cursor: pointer;
}
.gev-quantitative-slider:focus-visible {
border-radius: 4px;
outline: 1px solid color-mix(in srgb, var(--accent) 80%, transparent);
outline-offset: 2px;
}
.gev-quantitative-slider:disabled {
opacity: 0.42;
cursor: not-allowed;
}
.pp-slider-value,
.param-value,
.ri-opacity-value,
.rail-card-setting-range-value,
.gev-slider-value {
min-width: 28px;
color: var(--accent);
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.5px;
text-align: right;
flex-shrink: 0;
}
+57 -213
View File
@@ -1,8 +1,6 @@
/* ── Street Level panel ─────────────────────────────────────────
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 {
The rail owns position and width (layers.css). */
#street-level-panel {
--sl-green: #05cb63;
/* KartaView's purple for lines; a lighter one where it is text (WCAG AA). */
--sl-kartaview: #635bff;
@@ -10,8 +8,7 @@
/* Panoramax's orange; the darker one sits behind white text. */
--sl-panoramax: #ff6f00;
--sl-panoramax-fill: #b84e00;
--sl-text: 0.62rem;
--sl-text-small: 0.6rem;
--sl-text: 0.6rem;
}
#street-level-panel .panel-glow {
@@ -52,30 +49,24 @@
}
.sl-status {
background: transparent;
font: inherit;
font-family: var(--font-mono);
font-size: var(--sl-text-small);
font-size: var(--sl-text);
letter-spacing: 0.1em;
padding: 2px 7px;
border-radius: 999px;
border: 1px solid var(--glass-border-hover);
color: var(--text-secondary);
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 {
.sl-status:hover,
.sl-status:focus-visible {
color: var(--text-primary);
border-color: var(--accent);
outline: none;
@@ -102,24 +93,18 @@ button.sl-status:focus-visible {
display: none;
}
.sl-body {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
.sl-main {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.sl-body,
.sl-controls {
display: flex;
flex-direction: column;
gap: 8px;
}
.sl-body {
min-height: 0;
}
.sl-controls {
min-width: 0;
margin: 0;
padding: 0;
@@ -131,14 +116,6 @@ button.sl-status:focus-visible {
pointer-events: none;
}
/* In a resized window only this block scrolls; the viewer keeps its room. */
.sl-settings {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
}
/* ── Providers ───────────────────────────────────────────────────────────── */
.sl-providers {
display: flex;
@@ -237,7 +214,7 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
color: #ff8a80;
}
/* ── Filters and legend ──────────────────────────────────────────────────── */
/* ── Filters ──────────────────────────────────────────────────── */
.sl-filters {
display: flex;
align-items: center;
@@ -245,56 +222,13 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
flex-wrap: wrap;
}
/* No overflow clipping: it would cut off the buttons' focus ring. The end
buttons round themselves instead. */
.sl-seg {
display: inline-flex;
border: 1px solid var(--glass-border-hover);
border-radius: 999px;
}
.sl-seg[hidden],
.sl-controls[hidden],
.sl-filters[hidden],
.sl-since[hidden],
.sl-legend[hidden] {
.sl-since[hidden] {
display: none;
}
.sl-seg-btn {
font-family: var(--font-mono);
font-size: var(--sl-text-small);
letter-spacing: 0.08em;
padding: 4px 9px;
border: 0;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.sl-seg-btn:first-child {
border-radius: 999px 0 0 999px;
}
.sl-seg-btn:last-child {
border-radius: 0 999px 999px 0;
}
.sl-seg-btn + .sl-seg-btn {
border-left: 1px solid var(--glass-border);
}
.sl-seg-btn:focus-visible {
position: relative;
outline: 1px solid var(--accent);
outline-offset: 2px;
}
.sl-seg-btn.is-active {
background: rgba(5, 203, 99, 0.22);
color: var(--text-primary);
}
.sl-since {
display: grid;
grid-template-columns: auto 1fr;
@@ -315,10 +249,20 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
outline-offset: 3px;
}
.sl-since-note {
/* The value and its note share a line: ANY DATE (by upload date). */
.sl-since-value {
grid-column: 2;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0 6px;
min-width: 0;
}
.sl-since-note {
font-family: var(--font-mono);
font-size: var(--sl-text-small);
font-size: 0.5rem;
letter-spacing: 0.08em;
color: var(--text-secondary);
}
@@ -327,39 +271,12 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
}
.sl-since-label {
grid-column: 2;
font-family: var(--font-mono);
font-size: var(--sl-text-small);
font-size: var(--sl-text);
letter-spacing: 0.08em;
color: var(--text-primary);
}
.sl-legend {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin: 0;
padding: 0;
list-style: none;
}
.sl-legend li {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: var(--sl-text-small);
letter-spacing: 0.06em;
color: var(--text-secondary);
}
.sl-legend-swatch {
width: 14px;
height: 3px;
border-radius: 2px;
display: inline-block;
}
/* ── Embedded viewer ─────────────────────────────────────────────────────── */
.sl-viewer-wrap {
position: relative;
@@ -440,7 +357,7 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
.sl-viewer-tools .scene-btn {
padding: 4px 8px;
font-size: var(--sl-text-small);
font-size: var(--sl-text);
background: rgba(10, 10, 15, 0.75);
flex: none;
min-width: 0;
@@ -449,6 +366,10 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
gap: 4px;
}
.sl-viewer-tools .scene-btn[hidden] {
display: none;
}
.sl-viewer-tools .sl-seg {
background: rgba(10, 10, 15, 0.75);
}
@@ -486,7 +407,7 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
flex: 0 0 auto;
padding: 6px 10px;
font-family: var(--font-mono);
font-size: var(--sl-text-small);
font-size: var(--sl-text);
letter-spacing: 0.06em;
color: var(--text-secondary);
background: rgba(0, 0, 0, 0.7);
@@ -532,7 +453,7 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
.sl-meta {
font-family: var(--font-mono);
font-size: var(--sl-text-small);
font-size: var(--sl-text);
letter-spacing: 0.06em;
color: var(--text-secondary);
}
@@ -541,113 +462,36 @@ html:not([lang='en']) .sl-meta-row .sl-nearest-btn {
display: none;
}
/* ── Floating window ─────────────────────────────────────────────────────── */
/* 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;
/* ── Full screen (EXPAND) ────────────────────────────────────────────────── */
/* The browser lifts the viewer into the top layer; the photo takes the rest.
iPhone Safari has no element fullscreen, so there the viewer is a popover
sized to the viewport instead. */
.sl-viewer-wrap:fullscreen {
border: 0;
border-radius: 0;
}
/* 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%;
max-height: none;
overflow: hidden;
}
.panel-floating[style*='height'] .sl-body,
.panel-floating[style*='height'] .sl-main {
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;
height: 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 ─────────────────────────────────────────────────────── */
/* 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 {
.sl-viewer-wrap:popover-open {
position: fixed;
left: 4vw;
right: 4vw;
top: 5vh;
bottom: 9vh;
z-index: 141;
display: flex;
flex-direction: column;
font-family: var(--font-mono);
border-color: rgba(0, 212, 255, 0.55);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7);
outline: none;
inset: 0;
width: 100vw;
height: 100dvh;
max-width: none;
max-height: none;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
}
.sl-viewer-wrap-expanded .sl-viewer-stage {
flex: 1 1 auto;
min-height: 0;
}
.sl-viewer-wrap-expanded .sl-viewer {
.sl-viewer-wrap:is(:fullscreen, :popover-open) .sl-viewer-stage,
.sl-viewer-wrap:is(:fullscreen, :popover-open) .sl-viewer {
flex: 1 1 auto;
height: auto;
min-height: 0;
aspect-ratio: auto;
}
.sl-viewer-wrap-expanded .sl-image-meta {
font-size: 0.66rem;
padding: 10px 14px;
}
.sl-viewer-wrap-expanded .sl-viewer-bar {
padding: 8px 10px;
}
.sl-viewer-wrap-expanded .sl-viewer-tools .scene-btn {
padding: 8px 14px;
font-size: 0.7rem;
letter-spacing: 0.12em;
color: var(--text-primary);
border-color: rgba(0, 212, 255, 0.6);
background: rgba(10, 10, 15, 0.82);
}
.sl-viewer-wrap-expanded #sl-viewer-expand::after {
content: ' · ESC';
color: var(--text-secondary);
}
.sl-viewer-wrap-expanded .sl-viewer-x {
width: 34px;
height: 34px;
font-size: 1.3rem;
}
/* ── Photo viewer (KartaView, Panoramax) ─────────────────────────────────── */
.sl-photo-viewer {
/* The lit source's colour: a focus ring, and behind the white ‹ › glyphs. */
-13
View File
@@ -362,22 +362,9 @@ html:not([lang='en']) .rail-card-setting {
font: 8px var(--font-mono);
white-space: nowrap;
}
.rail-card-setting-range {
width: 100%;
min-width: 0;
accent-color: var(--accent);
cursor: pointer;
}
.rail-card-setting-range:focus-visible {
outline: 2px solid var(--text-primary);
outline-offset: 3px;
}
.rail-card-setting-range-value {
flex: 0 0 4ch;
white-space: nowrap;
color: var(--text-primary);
font: 9px var(--font-mono);
text-align: right;
}
.rail-card-result {
display: grid;
+21 -21
View File
@@ -27,27 +27,27 @@
<output id="cyber-sonar-status" class="cyber-sonar-control" role="status"></output>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-rings-row">
<label class="pp-slider-mini-label" for="cyber-sonar-rings" data-i18n>Rings</label>
<input type="range" class="pp-slider" id="cyber-sonar-rings" min="3" max="12" step="1" value="8" aria-label="Sonar ring count" data-i18n-aria-label />
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-rings" min="3" max="12" step="1" value="8" aria-label="Sonar ring count" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-rings-value">8</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-range-row">
<label class="pp-slider-mini-label" for="cyber-sonar-range" data-i18n>Range</label>
<input type="range" class="pp-slider" id="cyber-sonar-range" min="60" max="120" step="1" value="100" aria-label="Sonar ring range" data-i18n-aria-label />
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-range" min="60" max="120" step="1" value="100" aria-label="Sonar ring range" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-range-value">100%</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-intensity-row">
<label class="pp-slider-mini-label" for="cyber-sonar-intensity" data-i18n>Power</label>
<input type="range" class="pp-slider" id="cyber-sonar-intensity" min="0" max="100" step="1" value="70" aria-label="Sonar intensity" data-i18n-aria-label />
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-intensity" min="0" max="100" step="1" value="70" aria-label="Sonar intensity" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-intensity-value">70%</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-opacity-row">
<label class="pp-slider-mini-label" for="cyber-sonar-opacity" data-i18n>Opacity</label>
<input type="range" class="pp-slider" id="cyber-sonar-opacity" min="35" max="100" step="1" value="84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" data-i18n-aria-label data-i18n-title />
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-opacity" min="35" max="100" step="1" value="84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="cyber-sonar-opacity-value">84%</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-sector-row">
<label class="pp-slider-mini-label" for="cyber-sonar-sector" data-i18n>Sector</label>
<input type="range" class="pp-slider" id="cyber-sonar-sector" min="8" max="60" step="1" value="24" aria-label="Sonar sector width" data-i18n-aria-label />
<input type="range" class="gev-quantitative-slider pp-slider" id="cyber-sonar-sector" min="8" max="60" step="1" value="24" aria-label="Sonar sector width" data-i18n-aria-label />
<span class="pp-slider-value" id="cyber-sonar-sector-value">24°</span>
</div>
</div>
@@ -58,24 +58,24 @@
</button>
<div class="pp-slider-row detection-slider-row" id="detection-slider-row">
<label class="pp-slider-mini-label" for="detection-density-slider" data-i18n>Density</label>
<input type="range" class="pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" aria-label="Detection label density" data-i18n-aria-label />
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" aria-label="Detection label density" data-i18n-aria-label />
<span class="pp-slider-value" id="detection-density-value">50%</span>
</div>
<div class="pp-slider-row detection-allocation-row" id="detection-allocation-row">
<span class="pp-slider-mini-label" data-i18n>Allocation</span>
<div class="pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation" data-i18n-aria-label>
<button class="pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true" data-i18n>Elastic</button>
<button class="pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false" data-i18n>Weighted</button>
<div class="gev-segmented pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation" data-i18n-aria-label>
<button class="gev-segment pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true" data-i18n>Elastic</button>
<button class="gev-segment pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false" data-i18n>Weighted</button>
</div>
</div>
<div class="pp-slider-row detection-fade-row" id="detection-fade-row">
<label class="pp-slider-mini-label" for="detection-fade-slider" data-i18n>Fade</label>
<input type="range" class="pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" data-i18n-aria-label data-i18n-title />
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="detection-fade-value">7%</span>
</div>
<div class="pp-slider-row detection-opacity-row" id="detection-opacity-row">
<label class="pp-slider-mini-label" for="detection-opacity-slider" data-i18n>Outside</label>
<input type="range" class="pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" data-i18n-aria-label data-i18n-title />
<input type="range" class="gev-quantitative-slider pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="detection-opacity-value">1%</span>
</div>
</div>
@@ -99,9 +99,9 @@
</button>
<div class="pp-slider-row visible" id="models3d-mode-row">
<span class="pp-slider-mini-label" data-i18n>Models</span>
<div class="pp-mode-seg" role="radiogroup" aria-label="3D model coverage" data-i18n-aria-label>
<button class="pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true" data-i18n>Proximity</button>
<button class="pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false" data-i18n>All</button>
<div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="3D model coverage" data-i18n-aria-label>
<button class="gev-segment pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true" data-i18n>Proximity</button>
<button class="gev-segment pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false" data-i18n>All</button>
</div>
</div>
</div>
@@ -117,7 +117,7 @@
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
softens the black mask edge; label fading is the separate Fade
slider. -->
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
<span class="pp-slider-value" id="scope-feather-value">11%</span>
</div>
</div>
@@ -128,10 +128,10 @@
</button>
<div class="pp-slider-row" id="draw-mode-row">
<span class="pp-slider-mini-label" data-i18n>Shape</span>
<div class="pp-mode-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
<button class="pp-mode-btn active" data-shape="area" role="radio" aria-checked="true" data-i18n>Area</button>
<button class="pp-mode-btn" data-shape="line" role="radio" aria-checked="false" data-i18n>Line</button>
<button class="pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" data-i18n>Pin</button>
<div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
<button class="gev-segment pp-mode-btn active" data-shape="area" role="radio" aria-checked="true" data-i18n>Area</button>
<button class="gev-segment pp-mode-btn" data-shape="line" role="radio" aria-checked="false" data-i18n>Line</button>
<button class="gev-segment pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" data-i18n>Pin</button>
</div>
</div>
<div class="pp-slider-row" id="draw-label-row">
@@ -172,7 +172,7 @@
<span class="pp-label" data-i18n>Bloom</span>
</button>
<div class="pp-slider-row" id="bloom-slider-row">
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" aria-label="Bloom intensity" data-i18n-aria-label />
<input type="range" class="gev-quantitative-slider pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" aria-label="Bloom intensity" data-i18n-aria-label />
<span class="pp-slider-value" id="bloom-intensity-value">0%</span>
</div>
</div>
@@ -182,7 +182,7 @@
<span class="pp-label" data-i18n>Sharpen</span>
</button>
<div class="pp-slider-row" id="sharpen-slider-row">
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" aria-label="Sharpen intensity" data-i18n-aria-label />
<input type="range" class="gev-quantitative-slider pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" aria-label="Sharpen intensity" data-i18n-aria-label />
<span class="pp-slider-value" id="sharpen-intensity-value">49%</span>
</div>
</div>
+48 -52
View File
@@ -83,64 +83,60 @@
<button class="panel-collapse-btn" data-collapse-target="street-level-panel" title="Expand panel" data-i18n-title>◀</button>
</div>
<div id="sl-body" class="sl-body">
<div class="sl-main">
<div id="sl-viewer-wrap" class="sl-viewer-wrap" hidden>
<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)" data-i18n-title><span class="sl-btn-icon" aria-hidden="true">⤢</span><span class="sl-btn-text" data-i18n>EXPAND</span></button>
<div class="sl-seg" role="radiogroup" aria-label="Image fit" data-i18n-aria-label>
<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" data-i18n-title data-i18n>FIT</button>
<button type="button" class="sl-seg-btn" data-sl-render="fill" role="radio" aria-checked="false" tabindex="-1" title="Fill: fill the frame edge to edge, cropping the photo" data-i18n-title data-i18n>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" data-i18n-aria-label data-i18n-title><span class="sl-btn-icon" aria-hidden="true">⌖</span><span class="sl-btn-text" data-i18n>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" data-i18n-title data-i18n-aria-label>×</button>
<div id="sl-viewer-wrap" class="sl-viewer-wrap" hidden>
<div class="sl-viewer-bar">
<div class="sl-viewer-tools">
<button id="sl-viewer-expand" class="scene-btn" type="button" aria-pressed="false" title="Show the street-level view full screen (Esc shrinks it)" data-i18n-title><span class="sl-btn-icon" aria-hidden="true">⤢</span><span class="sl-btn-text" data-i18n>EXPAND</span></button>
<div class="gev-segmented sl-seg" role="radiogroup" aria-label="Image fit" data-i18n-aria-label>
<button type="button" class="gev-segment 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" data-i18n-title data-i18n>FIT</button>
<button type="button" class="gev-segment sl-seg-btn" data-sl-render="fill" role="radio" aria-checked="false" tabindex="-1" title="Fill: fill the frame edge to edge, cropping the photo" data-i18n-title data-i18n>FILL</button>
</div>
</div>
<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" data-i18n-aria-label></div>
</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">
<span id="sl-image-when" class="sl-image-when"></span>
<a id="sl-image-link" class="sl-image-link" href="#" target="_blank" rel="noopener noreferrer" title="Open this image on the provider's site" data-i18n-title hidden></a>
</span>
<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" data-i18n-title data-i18n-aria-label>×</button>
</div>
</div>
<div class="sl-settings">
<div class="sl-providers" role="group" aria-label="Imagery providers" data-i18n-aria-label>
<span class="cctv-summary-label" data-i18n>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>
<!-- 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 id="sl-pano-row" class="sl-filters" role="group" aria-label="Imagery filters" data-i18n-aria-label>
<span class="cctv-summary-label" data-i18n>IMAGERY</span>
<div class="sl-seg" role="radiogroup" aria-label="Panorama filter" data-i18n-aria-label>
<button type="button" class="sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true" data-i18n>ALL</button>
<button type="button" class="sl-seg-btn" data-sl-pano="pano" role="radio" aria-checked="false" tabindex="-1">360°</button>
<button type="button" class="sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1" data-i18n>FLAT</button>
</div>
</div>
<div id="sl-since-row" class="sl-since">
<label class="cctv-summary-label" for="sl-since" data-i18n>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 sl-since-note" />
<output id="sl-since-label" class="sl-since-label" for="sl-since" data-i18n>ANY DATE</output>
<small id="sl-since-note" class="sl-since-note" hidden></small>
</div>
</fieldset>
<ul id="sl-legend" class="sl-legend" aria-label="Coverage colours" data-i18n-aria-label></ul>
<!-- The hint says how to open imagery; the button does it without a pointer. -->
<div class="sl-meta-row">
<div id="sl-coverage-meta" class="sl-meta"></div>
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" hidden data-i18n-runtime>OPEN STREET VIEW</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" data-i18n-aria-label></div>
</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">
<span id="sl-image-when" class="sl-image-when"></span>
<a id="sl-image-link" class="sl-image-link" href="#" target="_blank" rel="noopener noreferrer" title="Open this image on the provider's site" data-i18n-title hidden></a>
</span>
</div>
</div>
<div class="sl-providers" role="group" aria-label="Imagery providers" data-i18n-aria-label>
<span class="cctv-summary-label" data-i18n>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>
<!-- 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 id="sl-pano-row" class="sl-filters" role="group" aria-label="Imagery filters" data-i18n-aria-label>
<span class="cctv-summary-label" data-i18n>IMAGERY</span>
<div class="gev-segmented sl-seg" role="radiogroup" aria-label="Panorama filter" data-i18n-aria-label>
<button type="button" class="gev-segment sl-seg-btn is-active" data-sl-pano="all" role="radio" aria-checked="true" data-i18n>ALL</button>
<button type="button" class="gev-segment sl-seg-btn" data-sl-pano="pano" role="radio" aria-checked="false" tabindex="-1">360°</button>
<button type="button" class="gev-segment sl-seg-btn" data-sl-pano="flat" role="radio" aria-checked="false" tabindex="-1" data-i18n>FLAT</button>
</div>
</div>
<div id="sl-since-row" class="sl-since">
<label class="cctv-summary-label" for="sl-since" data-i18n>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 sl-since-note" />
<span class="sl-since-value">
<output id="sl-since-label" class="sl-since-label" for="sl-since" data-i18n>ANY DATE</output>
<small id="sl-since-note" class="sl-since-note" hidden></small>
</span>
</div>
</fieldset>
<!-- The hint says how to open imagery; the button does it without a pointer. -->
<div class="sl-meta-row">
<div id="sl-coverage-meta" class="sl-meta"></div>
<button id="sl-nearest-btn" class="scene-btn sl-nearest-btn" type="button" hidden data-i18n-runtime>OPEN STREET VIEW</button>
</div>
</div>
</div>
+1 -1
View File
@@ -176,7 +176,7 @@ test('parameter rows preserve labels, bounds, precision and initial values', ()
assert.equal(label.textContent, '<Amount>');
assert.equal(slider.attributes.get('aria-label'), '<Amount>');
assert.equal(slider.type, 'range');
assert.equal(slider.className, 'param-slider');
assert.equal(slider.className, 'gev-quantitative-slider param-slider');
assert.equal(slider.min, 0);
assert.equal(slider.max, 1);
assert.equal(slider.step, '0.01');
+4 -5
View File
@@ -1,4 +1,5 @@
import { STYLE_STATUS_LABELS } from './visualPresets.js';
import { syncSegmentedControl } from './segmentedControl.js';
import { t } from '../i18n/index.js';
import { UiLifetime } from './uiLifetime.js';
import {
@@ -557,11 +558,9 @@ export class VisualSettings {
const normalized = normalizeAllocationStrategy(raw);
this._detectionAllocationPreference = normalized;
setDetectionTuning({ allocationStrategy: normalized });
for (const button of this._detectionAllocationBtns) {
const active = button.dataset.allocation === normalized;
button.classList.toggle('active', active);
button.setAttribute('aria-checked', String(active));
}
syncSegmentedControl(this._detectionAllocationBtns, normalized, {
valueOf: (button) => button.dataset.allocation,
});
if (persist) {
try {
localStorage.setItem(DETECTION_ALLOCATION_STORAGE_KEY, normalized);