mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
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:
commit
a451c1ba6f
190 files changed
+9967
-17519
No files matched your search
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
+366
-622
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Vendored
+1
-1
@@ -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
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user