mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge remote-tracking branch 'upstream/main' into pr1209-merge
This commit is contained in:
commit
11aad05dc9
807 files changed
+83004
-20993
No files matched your search
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Create an ordinary native action button. Static templates use the same CSS
|
||||
* classes. Labels are already translated; feature owners retain callbacks,
|
||||
* disabled-state updates and listener cleanup. Construction binds no listeners.
|
||||
* @param {object} options
|
||||
* @param {Document} [options.documentRef] Document that owns the button.
|
||||
* @param {string} options.label Visible text, never interpreted as markup.
|
||||
* @param {string} [options.className] Additional feature/layout hooks.
|
||||
* @param {'default'|'danger'} [options.variant]
|
||||
* @param {'default'|'compact'} [options.size]
|
||||
* @param {boolean} [options.disabled]
|
||||
* @param {string} [options.ariaLabel] Accessible name when visible text needs context.
|
||||
* @param {string} [options.title]
|
||||
* @returns {HTMLButtonElement}
|
||||
*/
|
||||
export function createActionButton({
|
||||
documentRef = globalThis.document,
|
||||
label,
|
||||
className = '',
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
title,
|
||||
}) {
|
||||
const button = documentRef.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = [
|
||||
'gev-action-button',
|
||||
variant === 'danger' && 'gev-action-button--danger',
|
||||
size === 'compact' && 'gev-action-button--compact',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
button.textContent = label;
|
||||
button.disabled = disabled;
|
||||
if (ariaLabel !== undefined) button.setAttribute('aria-label', ariaLabel);
|
||||
if (title !== undefined) button.title = title;
|
||||
return button;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -74,7 +70,6 @@ export class AircraftDisplay {
|
||||
// a screen reader without this. It matters more now that the button ships
|
||||
// ACTIVE from markup (default-on, 2026-08-22): the very first thing assistive
|
||||
// tech reported was an unpressed-looking control over an armed layer.
|
||||
// Mirrors #scope-toggle, which has always carried aria-pressed.
|
||||
this._models3dBtn?.setAttribute(
|
||||
'aria-pressed',
|
||||
String(this._models3dEnabled),
|
||||
|
||||
+97
-33
@@ -3,6 +3,7 @@ import { ShellFacade } from './shellFacade.js';
|
||||
import { AircraftDisplay } from './aircraftDisplay.js';
|
||||
import { LayerBindings } from './layerBindings.js';
|
||||
import { PanelChrome } from './panelChrome.js';
|
||||
import { initMobileShell } from './mobileShell.js';
|
||||
import { VisualSettings } from './visualSettings.js';
|
||||
import { NavigationController } from './navigationController.js';
|
||||
import { ShareRestoration } from './shareRestoration.js';
|
||||
@@ -34,6 +35,10 @@ import * as Cesium from 'cesium';
|
||||
import { aircraftTrackingTarget } from '../cockpitTracking.js';
|
||||
|
||||
import { ShellFeedback } from './shellFeedback.js';
|
||||
import {
|
||||
applyShareFollowOffset,
|
||||
readShareFollowSubject,
|
||||
} from './shareFollow.js';
|
||||
|
||||
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
|
||||
|
||||
@@ -53,8 +58,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,
|
||||
@@ -74,7 +78,13 @@ export class StyleManager extends ShellFacade {
|
||||
*/
|
||||
constructor(
|
||||
viewer,
|
||||
{ mapStackController = null, placeSearch, services, requestServices } = {},
|
||||
{
|
||||
mapStackController = null,
|
||||
placeSearch,
|
||||
services,
|
||||
requestServices,
|
||||
initialShareCode = null,
|
||||
} = {},
|
||||
) {
|
||||
super();
|
||||
const {
|
||||
@@ -115,7 +125,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),
|
||||
@@ -126,6 +135,8 @@ export class StyleManager extends ShellFacade {
|
||||
readInitialShare: () => this._initialShareState,
|
||||
readScrollRestoreOwner: () => this._displayPortalScrollRestoreOwner,
|
||||
readDisplayScrollTop: () => this._standardDisplayScrollTop,
|
||||
readSharedCollapsed: (panelId) =>
|
||||
this._mobileShell?.sharedCollapsed(panelId),
|
||||
});
|
||||
this._feedback = new ShellFeedback({
|
||||
readLayers: () => this._dataManager?.getAll?.() || [],
|
||||
@@ -146,6 +157,7 @@ export class StyleManager extends ShellFacade {
|
||||
},
|
||||
searchInput: this._locationSearch,
|
||||
interruptCameraMotion: services.interruptCameraMotion,
|
||||
interruptGlobeRotation: services.interruptGlobeRotation,
|
||||
isCockpitActive: () => !!this.cockpitView?.active,
|
||||
clearLocation: () => this.clearSearchedLocation(),
|
||||
cancelShareSelection: () => this._shareRestoration.cancelSelection(),
|
||||
@@ -163,6 +175,8 @@ export class StyleManager extends ShellFacade {
|
||||
this._showGlobalStatusNotice(message, options),
|
||||
feedback: this._feedback,
|
||||
updateFeedback: () => this._updateGlobalLoadingFeedback(),
|
||||
applyFollowOffset: (entity, offset) =>
|
||||
applyShareFollowOffset(viewer, entity, offset),
|
||||
});
|
||||
|
||||
this._visualSettings = new VisualSettings({
|
||||
@@ -275,6 +289,7 @@ export class StyleManager extends ShellFacade {
|
||||
_contextControls: this._contextControls,
|
||||
_cctvControls: this._cctvControls,
|
||||
_radioControls: this._radioControls,
|
||||
_streetLevelControls: this._streetLevelControls,
|
||||
}),
|
||||
operations: {
|
||||
_updateTrafficSyncChip: (...args) =>
|
||||
@@ -320,6 +335,7 @@ export class StyleManager extends ShellFacade {
|
||||
GLOBE_VIEW: services.GLOBE_VIEW,
|
||||
flyToGlobeView: services.flyToGlobeView,
|
||||
interruptCameraMotion: services.interruptCameraMotion,
|
||||
interruptGlobeRotation: services.interruptGlobeRotation,
|
||||
flightsLayer: services.flightsLayer,
|
||||
militaryFlightsLayer: services.militaryFlightsLayer,
|
||||
satellitesLayer: services.satellitesLayer,
|
||||
@@ -332,6 +348,7 @@ export class StyleManager extends ShellFacade {
|
||||
_poiRow: this._poiRow,
|
||||
_locationBarDivider: this._locationBarDivider,
|
||||
_locationSearch: this._locationSearch,
|
||||
_locationSearchError: this._locationSearchError,
|
||||
_searchToggle: this._searchToggle,
|
||||
_resetGlobeBtn: this._resetGlobeBtn,
|
||||
_cockpitResetGlobeBtn: this._cockpitResetGlobeBtn,
|
||||
@@ -426,7 +443,12 @@ export class StyleManager extends ShellFacade {
|
||||
isNavigationCurrent: (generation) =>
|
||||
generation === this._navigationGeneration,
|
||||
cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(),
|
||||
// A host may open a share code without a `/share/<code>` address.
|
||||
initialShareCode,
|
||||
});
|
||||
this.shareLinkManager.setFollowSubjectProvider(({ nowMs }) =>
|
||||
readShareFollowSubject({ viewer: this.viewer, nowMs }),
|
||||
);
|
||||
this.shareLinkManager.setPanelStateProvider(() =>
|
||||
this._buildSharePanelState(),
|
||||
);
|
||||
@@ -588,6 +610,7 @@ export class StyleManager extends ShellFacade {
|
||||
this._initStreetLevelPanel();
|
||||
this._initGlobalContextPanel();
|
||||
this._initLocationBar();
|
||||
this._initMobileShell();
|
||||
this._initShareButton();
|
||||
this._initCameraOrientationControls();
|
||||
this._initClearSelectedLayersButton();
|
||||
@@ -849,6 +872,48 @@ export class StyleManager extends ShellFacade {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phone layout: a bottom tab bar presenting one panel sheet at a time
|
||||
* (src/ui/mobileShell.js). Inactive above 720px.
|
||||
* @returns {void}
|
||||
*/
|
||||
_initMobileShell() {
|
||||
this._mobileShell = initMobileShell({
|
||||
setPanelCollapsed: (panelId, collapsed, options) =>
|
||||
this._panelChrome.setPanelCollapsed(panelId, collapsed, options),
|
||||
onSheetsClosed: () => this._syncShareState(),
|
||||
onTabOpened: (sheet) => {
|
||||
void this._onMobileTabOpened(sheet);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Reveal a tracked aircraft through Contacts when its phone tab is opened. */
|
||||
async _onMobileTabOpened(sheet) {
|
||||
const context = this.getContextModeState();
|
||||
if (
|
||||
sheet !== 'context' ||
|
||||
this._disposed ||
|
||||
!context ||
|
||||
context.mode ||
|
||||
context.changing ||
|
||||
context.entering ||
|
||||
!this.getAircraftTrackingTarget()
|
||||
)
|
||||
return false;
|
||||
try {
|
||||
const result = await this.setContextMode('contacts');
|
||||
if (result?.ok) return true;
|
||||
if (result?.cancelled || this._disposed) return false;
|
||||
} catch {
|
||||
if (this._disposed) return false;
|
||||
}
|
||||
this._showToast(
|
||||
t('Contacts could not complete the requested transition; try again'),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the owner-approved map stack chip row from the matching controller
|
||||
* entries. Cesium ion/Bing chips remain keyboard-focusable but unavailable,
|
||||
@@ -1089,13 +1154,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 +1173,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 +1269,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.
|
||||
@@ -1621,14 +1670,35 @@ export class StyleManager extends ShellFacade {
|
||||
// ── Share Button ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Wires the share button click to copy the current share link to the clipboard.
|
||||
* Wires the share button. By default it copies the current share link to the
|
||||
* clipboard; a host may pass `services.shareLink(url)` to share it another way
|
||||
* (a system share sheet, for example). It resolves to 'shared', 'copied',
|
||||
* 'cancelled' (no toast) or false (failed).
|
||||
* @returns {void}
|
||||
*/
|
||||
_initShareButton() {
|
||||
this._lifetime.listen(this._shareBtn, 'click', async () => {
|
||||
const success = await this.shareLinkManager.copyLink();
|
||||
if (!this._disposed)
|
||||
this._showToast(success ? t('Link copied!') : t('Copy failed'));
|
||||
const share = this.services.shareLink;
|
||||
let result;
|
||||
if (typeof share === 'function') {
|
||||
// A host's hook may return a plain value or throw; both are answers.
|
||||
result = await Promise.resolve()
|
||||
.then(() => {
|
||||
const href = this.shareLinkManager.buildLink();
|
||||
return href ? share(href) : false;
|
||||
})
|
||||
.catch(() => false);
|
||||
} else {
|
||||
result = (await this.shareLinkManager.copyLink()) ? 'copied' : false;
|
||||
}
|
||||
if (this._disposed || result === 'cancelled') return;
|
||||
this._showToast(
|
||||
result === 'shared'
|
||||
? t('Link shared')
|
||||
: result === 'copied'
|
||||
? t('Link copied!')
|
||||
: t('Copy failed'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1666,13 +1736,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.
|
||||
@@ -1731,6 +1794,7 @@ export class StyleManager extends ShellFacade {
|
||||
this._localePicker?.destroy();
|
||||
this._localePicker = null;
|
||||
this._recording.destroy();
|
||||
this._mobileShell?.destroy();
|
||||
this._panelChrome.destroy();
|
||||
this._feedback.destroy();
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ export function readCameraTargetFrame(viewer) {
|
||||
}
|
||||
|
||||
/** Apply an orbit frame, then return the camera to Cesium's fixed-world frame. */
|
||||
export function setCameraTargetFrame(viewer, frame) {
|
||||
function setCameraTargetFrame(viewer, frame) {
|
||||
const camera = viewer?.camera;
|
||||
if (!camera || !frame?.target || !Number.isFinite(frame.range)) return false;
|
||||
try {
|
||||
@@ -156,7 +156,7 @@ export function setCameraTargetFrame(viewer, frame) {
|
||||
*
|
||||
* @param {{pitch: number}|null} frame From {@link readCameraTargetFrame}.
|
||||
*/
|
||||
export function frameIsTilted(frame) {
|
||||
function frameIsTilted(frame) {
|
||||
const pitch = Number(frame?.pitch);
|
||||
return Number.isFinite(pitch) ? pitch > OBLIQUE_THRESHOLD : false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CctvControls } from './cctvControls.js';
|
||||
import { railFixture } from './railTestFixture.mjs';
|
||||
import { setLocale, t } from '../i18n/index.js';
|
||||
|
||||
function fixture(t) {
|
||||
const { document } = railFixture();
|
||||
const controls = new CctvControls({
|
||||
elements: {
|
||||
_cctvQualityChip: document.createElement('span'),
|
||||
_cctvSourceBadge: document.createElement('div'),
|
||||
},
|
||||
cctv: {},
|
||||
actions: { isEnabled: () => true, setPanelCollapsed() {} },
|
||||
});
|
||||
t.after(() => controls.destroy());
|
||||
return { controls, document };
|
||||
}
|
||||
|
||||
test('source and frame states retain their distinct meanings, including retained pixels', (testContext) => {
|
||||
const { controls, document } = fixture(testContext);
|
||||
const camera = { sourceKind: 'snapshot', sourceStatus: 'degraded' };
|
||||
controls._cctvFrame = document.createElement('img');
|
||||
controls._cctvFrameWrap = document.createElement('div');
|
||||
controls._cctvFrame.removeAttribute = () => {};
|
||||
for (const node of [controls._cctvFrame, controls._cctvFrameWrap]) {
|
||||
node.classList.remove = (...names) =>
|
||||
names.forEach((name) => node.classList.toggle(name, false));
|
||||
}
|
||||
const expect = (label, state, active = camera, enabled = true) => {
|
||||
controls._syncCctvSourceBadge(active, enabled);
|
||||
assert.equal(controls._cctvSourceBadge.textContent, label);
|
||||
assert.equal(controls._cctvSourceBadge.dataset.frameState, state);
|
||||
};
|
||||
expect('SOURCE · UNKNOWN', 'idle', camera, false);
|
||||
expect('SOURCE · UNKNOWN', 'idle', null);
|
||||
controls._cctvFrame.dataset.loading = 'true';
|
||||
expect('FRAME · LOADING', 'loading');
|
||||
controls._cctvFrame.dataset.loading = '';
|
||||
controls._cctvFrame.dataset.error = 'true';
|
||||
expect('FRAME · UNAVAILABLE', 'error');
|
||||
// A refresh failure keeps the settled pixels and the provider's source status.
|
||||
controls._cctvFrameWrap.classList.toggle('has-frame', true);
|
||||
expect('SNAPSHOT · DEGRADED', 'ready');
|
||||
controls._cctvFrame.dataset.loading = 'true';
|
||||
expect('SNAPSHOT · DEGRADED', 'ready');
|
||||
controls._cctvFrame.dataset.loading = '';
|
||||
controls._cctvFrame.dataset.error = '';
|
||||
for (const status of ['ok', 'error', 'unknown', 'vendor-specific']) {
|
||||
expect(`SNAPSHOT · ${status.toUpperCase()}`, 'ready', {
|
||||
...camera,
|
||||
sourceStatus: status,
|
||||
});
|
||||
}
|
||||
expect('LIVE · OK', 'ready', { feedType: 'live', sourceStatus: 'ok' });
|
||||
});
|
||||
|
||||
test('calibration labels follow supplied provenance and dirty/save/reset transitions without writes to the camera', (testContext) => {
|
||||
const { controls } = fixture(testContext);
|
||||
const cases = [
|
||||
['raw-prior', false, 'CAL · RAW PRIOR'],
|
||||
['curated', false, 'CAL · CURATED'],
|
||||
['curated', true, 'CAL · EDITED (UNSAVED)'],
|
||||
['calibrated', false, 'CAL · CALIBRATED'],
|
||||
['calibrated', true, 'CAL · EDITED (UNSAVED)'],
|
||||
['raw-prior', false, 'CAL · RAW PRIOR'],
|
||||
[null, false, 'CAL · --'],
|
||||
];
|
||||
for (const [calBadge, calDirty, label] of cases) {
|
||||
const activeCamera = Object.freeze({ id: 'a', calBadge, calDirty });
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
activeCamera,
|
||||
});
|
||||
assert.equal(controls._cctvQualityChip.textContent, label);
|
||||
assert.equal(
|
||||
controls._cctvQualityChip.dataset.calBadge,
|
||||
calDirty ? 'edited' : calBadge || '',
|
||||
);
|
||||
}
|
||||
controls._renderCctvState(null);
|
||||
assert.equal(controls._cctvQualityChip.textContent, 'CAL · --');
|
||||
assert.equal(controls._cctvQualityChip.dataset.calBadge, '');
|
||||
});
|
||||
|
||||
test('localization changes badge copy while preserving machine state ids', (testContext) => {
|
||||
const { controls } = fixture(testContext);
|
||||
testContext.after(() => setLocale('en'));
|
||||
setLocale('ru');
|
||||
controls._syncCctvSourceBadge(
|
||||
{ sourceKind: 'snapshot', sourceStatus: 'degraded' },
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
controls._cctvSourceBadge.textContent,
|
||||
`${t('SNAPSHOT')} · ${t('DEGRADED')}`,
|
||||
);
|
||||
assert.notEqual(controls._cctvSourceBadge.textContent, 'SNAPSHOT · DEGRADED');
|
||||
assert.equal(controls._cctvSourceBadge.dataset.frameState, 'ready');
|
||||
controls._renderCctvState({
|
||||
enabled: true,
|
||||
activeCameraId: 'a',
|
||||
activeCamera: { id: 'a', calBadge: 'curated', calDirty: true },
|
||||
});
|
||||
assert.equal(
|
||||
controls._cctvQualityChip.textContent,
|
||||
t('CAL · EDITED (UNSAVED)'),
|
||||
);
|
||||
assert.equal(controls._cctvQualityChip.dataset.calBadge, 'edited');
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { formatNumber, t, msg } from '../i18n/index.js';
|
||||
import { createNumberInput } from './formField.js';
|
||||
|
||||
/** Shortest-wrap signed degrees, for heading offsets typed as absolute values. */
|
||||
const signedNormalizeDeg = (deg) => ((((deg + 180) % 360) + 360) % 360) - 180;
|
||||
@@ -117,11 +118,12 @@ export function _beginCctvCalValueEdit(chip) {
|
||||
const basePose = { ...activeCamera.basePose };
|
||||
const editListeners = new AbortController();
|
||||
const startValue = field.get(activeCamera);
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.step = field.decimals > 0 ? '0.1' : '1';
|
||||
input.value = Number(startValue).toFixed(field.decimals);
|
||||
input.className = 'cctv-cal-input';
|
||||
const input = createNumberInput({
|
||||
className: 'gev-field--inline cctv-cal-input',
|
||||
step: field.decimals > 0 ? '0.1' : '1',
|
||||
value: Number(startValue).toFixed(field.decimals),
|
||||
ariaLabel: t(field.label),
|
||||
});
|
||||
chip.textContent = `${t(field.label)} `;
|
||||
chip.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
@@ -105,6 +105,9 @@ function calibrationFixture(t) {
|
||||
Object.assign(input, {
|
||||
focus() {},
|
||||
select() {},
|
||||
setAttribute(name, value) {
|
||||
this[name] = value;
|
||||
},
|
||||
remove() {
|
||||
this.parent.input = null;
|
||||
},
|
||||
|
||||
+10
-6
@@ -1,3 +1,4 @@
|
||||
import { updateStatusBadge } from './statusBadge.js';
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
@@ -19,6 +20,7 @@ const SOURCE_KIND_LABELS = Object.freeze({
|
||||
});
|
||||
|
||||
const SOURCE_STATUS_LABELS = Object.freeze({
|
||||
ok: msg('OK'),
|
||||
degraded: msg('DEGRADED'),
|
||||
error: msg('ERROR'),
|
||||
unknown: msg('UNKNOWN'),
|
||||
@@ -44,14 +46,14 @@ const SOURCE_LABELS = Object.freeze({
|
||||
});
|
||||
|
||||
/** "SNAPSHOT" / "СНИМОК"; unknown codes (HLS, MJPEG…) stay as uppercase codes. */
|
||||
export function cctvSourceKindLabel(kind) {
|
||||
function cctvSourceKindLabel(kind) {
|
||||
const key = String(kind || 'unknown').toLowerCase();
|
||||
return SOURCE_KIND_LABELS[key]
|
||||
? t(SOURCE_KIND_LABELS[key])
|
||||
: key.toUpperCase();
|
||||
}
|
||||
|
||||
/** "DEGRADED" / "ПЕРЕБОИ"; OK and unknown codes stay as uppercase codes. */
|
||||
/** "DEGRADED" / "ПЕРЕБОИ"; unknown codes stay as uppercase codes. */
|
||||
export function cctvSourceStatusLabel(status) {
|
||||
const key = String(status || 'unknown').toLowerCase();
|
||||
return SOURCE_STATUS_LABELS[key]
|
||||
@@ -164,19 +166,21 @@ export function _settleCctvFrame(token, src, ok) {
|
||||
export function _syncCctvSourceBadge(activeCamera, enabled) {
|
||||
if (!this._cctvSourceBadge) return;
|
||||
if (!enabled || !activeCamera) {
|
||||
this._cctvSourceBadge.textContent = t('SOURCE · UNKNOWN');
|
||||
updateStatusBadge(this._cctvSourceBadge, { label: t('SOURCE · UNKNOWN') });
|
||||
this._cctvSourceBadge.dataset.frameState = 'idle';
|
||||
return;
|
||||
}
|
||||
const hasDisplayedFrame =
|
||||
this._cctvFrameWrap?.classList.contains('has-frame');
|
||||
if (this._cctvFrame?.dataset.loading === 'true' && !hasDisplayedFrame) {
|
||||
this._cctvSourceBadge.textContent = t('FRAME · LOADING');
|
||||
updateStatusBadge(this._cctvSourceBadge, { label: t('FRAME · LOADING') });
|
||||
this._cctvSourceBadge.dataset.frameState = 'loading';
|
||||
return;
|
||||
}
|
||||
if (this._cctvFrame?.dataset.error === 'true' && !hasDisplayedFrame) {
|
||||
this._cctvSourceBadge.textContent = t('FRAME · UNAVAILABLE');
|
||||
updateStatusBadge(this._cctvSourceBadge, {
|
||||
label: t('FRAME · UNAVAILABLE'),
|
||||
});
|
||||
this._cctvSourceBadge.dataset.frameState = 'error';
|
||||
return;
|
||||
}
|
||||
@@ -184,6 +188,6 @@ export function _syncCctvSourceBadge(activeCamera, enabled) {
|
||||
activeCamera.sourceKind || activeCamera.feedType || 'unknown',
|
||||
);
|
||||
const status = cctvSourceStatusLabel(activeCamera.sourceStatus || 'unknown');
|
||||
this._cctvSourceBadge.textContent = `${kind} · ${status}`;
|
||||
updateStatusBadge(this._cctvSourceBadge, { label: `${kind} · ${status}` });
|
||||
this._cctvSourceBadge.dataset.frameState = 'ready';
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { updateStatusBadge } from './statusBadge.js';
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
import { cctvSourceLabelText, cctvSourceMessageText } from './cctvFrames.js';
|
||||
|
||||
@@ -137,11 +138,13 @@ export function _renderCctvState(state) {
|
||||
// promotes to CALIBRATED, RESET CAL clears.
|
||||
const badge = activeCamera?.calBadge || null;
|
||||
const dirty = !!activeCamera?.calDirty;
|
||||
this._cctvQualityChip.textContent = dirty
|
||||
? t('CAL · EDITED (UNSAVED)')
|
||||
: t('CAL · {badge}', {
|
||||
badge: this._calBadgeLabel(badge),
|
||||
});
|
||||
updateStatusBadge(this._cctvQualityChip, {
|
||||
label: dirty
|
||||
? t('CAL · EDITED (UNSAVED)')
|
||||
: t('CAL · {badge}', {
|
||||
badge: this._calBadgeLabel(badge),
|
||||
}),
|
||||
});
|
||||
this._cctvQualityChip.dataset.calBadge = dirty ? 'edited' : badge || '';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Contact readouts and nearby-signal presentation for the Cockpit controller. */
|
||||
import * as Cesium from 'cesium';
|
||||
import { formatNumber, t, tn } from '../i18n/index.js';
|
||||
import { formatNumber, getLocale, t, tn } from '../i18n/index.js';
|
||||
import { unitLabel } from './unitLabels.js';
|
||||
import {
|
||||
bearingBetweenCoordinates,
|
||||
@@ -15,7 +15,7 @@ const LOCALIZED_CONTEXT_SCOPE = Object.freeze({
|
||||
window: ({ label, radiusKm }) =>
|
||||
t('{label} · {radius} AIR/SEA WINDOW', {
|
||||
label,
|
||||
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${unitLabel('km').toUpperCase()}`,
|
||||
radius: `${radiusKm === null ? '—' : formatNumber(radiusKm)}\u00a0${getLocale().startsWith('zh-') ? unitLabel('km') : unitLabel('km').toUpperCase()}`,
|
||||
}),
|
||||
installations: ({ scope, coverage }) =>
|
||||
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
|
||||
|
||||
@@ -64,7 +64,8 @@ export function onKeyDown(event) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if (!this.active && !this.isEntryAllowed()) return;
|
||||
const changed = this.active ? this.exit() : this.enter();
|
||||
if (this.active) this.exit();
|
||||
else this.enter();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
formatAltitudeRulerTick,
|
||||
formatCompassDivision,
|
||||
formatCockpitEmergency,
|
||||
formatHeadingReadout,
|
||||
formatSpeedRulerTick,
|
||||
normalizeHeading,
|
||||
relativeBearing,
|
||||
@@ -238,7 +239,7 @@ export function updateRoute(info) {
|
||||
}
|
||||
if (this.routeDirectionLabel) {
|
||||
this.routeDirectionLabel.textContent = t('DEST {bearing}', {
|
||||
bearing: `${String(Math.round(destinationBearing)).padStart(3, '0')}°`,
|
||||
bearing: `${formatHeadingReadout(destinationBearing)}°`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Pure formatting and fixed presentation constants used by Cockpit components. */
|
||||
import { formatNumber, t, msg } from '../i18n/index.js';
|
||||
import { formatNumber, getLocale, t, msg } from '../i18n/index.js';
|
||||
import { formatQuantity } from './unitLabels.js';
|
||||
|
||||
export const COCKPIT_HEADING_SLEW_DPS = 28;
|
||||
@@ -145,17 +145,20 @@ export function formatCockpitCoordinate(value, positive, negative) {
|
||||
*/
|
||||
export function formatCockpitDistance(distanceM) {
|
||||
const digits = distanceM < 10000 ? 1 : 0;
|
||||
return formatQuantity(distanceM / 1000, 'km', {
|
||||
const distance = formatQuantity(distanceM / 1000, 'km', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
}).toUpperCase();
|
||||
});
|
||||
return getLocale().startsWith('zh-') ? distance : distance.toUpperCase();
|
||||
}
|
||||
|
||||
export function formatCockpitWindDirection(value) {
|
||||
if (!Number.isFinite(value)) return t('DIR UNKNOWN');
|
||||
const labels = Object.keys(COMPASS_POINT_LABELS);
|
||||
const normalized = ((value % 360) + 360) % 360;
|
||||
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${Math.round(normalized)}°`;
|
||||
// 359.5° and above is north. Modulo keeps that from printing 360°.
|
||||
const degrees = Math.round(normalized) % 360;
|
||||
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`;
|
||||
}
|
||||
|
||||
// Digit grouping and decimal marks (`26,175`, `26 175`, `26.175`) take a
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { formatCockpitWindDirection } from './cockpitPresentation.js';
|
||||
|
||||
test('wind just west of north reads 0°, not 360°', () => {
|
||||
assert.equal(formatCockpitWindDirection(90), 'E · 90°');
|
||||
assert.equal(formatCockpitWindDirection(0), 'N · 0°');
|
||||
assert.equal(formatCockpitWindDirection(359.4), 'N · 359°');
|
||||
assert.equal(formatCockpitWindDirection(359.6), 'N · 0°');
|
||||
});
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
flyToPOI,
|
||||
searchAndFlyTo,
|
||||
} from '../locations.js';
|
||||
import { interruptCameraMotion } from '../cameraVerbs.js';
|
||||
import {
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation,
|
||||
} from '../cameraVerbs.js';
|
||||
import { IntelHUD } from '../hud.js';
|
||||
import { ShareLinkManager } from '../sharelink.js';
|
||||
import { OrbitController } from '../orbit.js';
|
||||
@@ -73,6 +76,7 @@ export class StyleManager extends ApplicationShell {
|
||||
flyToPOI,
|
||||
searchAndFlyTo,
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation,
|
||||
IntelHUD,
|
||||
ShareLinkManager,
|
||||
OrbitController,
|
||||
|
||||
@@ -86,6 +86,9 @@ export async function _restoreContextSession({
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Clear All may have run while Contacts settled. It owns the selection
|
||||
// now, so the snapshot must not come back behind it.
|
||||
if (restoreState.cancelled) return;
|
||||
await this._dataManager.restoreEnabledLayerIds(
|
||||
restoreState.enabledLayerIds,
|
||||
{
|
||||
|
||||
@@ -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,4 @@
|
||||
import { getScopeMaskFeather, isScopeMaskEnabled, setScopeMaskEnabled, setScopeMaskFeather } from '../scopeMask.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
@@ -271,6 +272,35 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
|
||||
assert.equal(voice.owner.activeStyle, 'snow');
|
||||
});
|
||||
|
||||
test('scope choice and expanded feather survive every HUD layout and both restore paths', async (t) => {
|
||||
const { owner } = styleOwner(t, 'tactical');
|
||||
const previous = { enabled: isScopeMaskEnabled(), feather: getScopeMaskFeather() };
|
||||
t.after(() => { setScopeMaskEnabled(previous.enabled); setScopeMaskFeather(previous.feather); });
|
||||
Object.assign(owner.services, { setScopeMaskEnabled, setScopeMaskFeather });
|
||||
owner._scopeBtn = { checked: true };
|
||||
owner._scopeFeatherSlider = { value: '11', disabled: false };
|
||||
owner._scopeFeatherValue = {};
|
||||
await owner.restoreShareState({ scopeEnabled: false, scopeFeatherPct: 200 });
|
||||
assert.equal(owner._scopeBtn.checked, false);
|
||||
assert.equal(owner._scopeFeatherSlider.disabled, true);
|
||||
assert.equal(owner._scopeFeatherSlider.value, '200');
|
||||
for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) {
|
||||
owner._setHudVariant(layout, { applyVisualDefaults: true });
|
||||
assert.equal(isScopeMaskEnabled(), false, layout);
|
||||
assert.equal(getScopeMaskFeather(), 2, layout);
|
||||
assert.equal(owner._scopeBtn.checked, false, layout);
|
||||
}
|
||||
await owner.applyVisualState({ scope: { enabled: true, featherPct: 175 } });
|
||||
assert.equal(isScopeMaskEnabled(), true);
|
||||
assert.equal(getScopeMaskFeather(), 1.75);
|
||||
assert.equal(owner._scopeBtn.checked, true);
|
||||
assert.equal(owner._scopeFeatherSlider.disabled, false);
|
||||
assert.equal(owner._scopeFeatherValue.textContent, '175%');
|
||||
await owner.applyVisualState({ scope: { featherPct: 500 } });
|
||||
assert.equal(getScopeMaskFeather(), 2);
|
||||
assert.equal(owner._scopeFeatherSlider.value, '200');
|
||||
});
|
||||
|
||||
test('scene and share HUD transitions do not restore a local pre-Cyber preset', async (t) => {
|
||||
const { owner, hud } = styleOwner(t, 'tactical');
|
||||
owner.setStyle('noir');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
|
||||
import { createFrameRateMonitor } from './frameRateMonitor.js';
|
||||
import { bindApplicationShortcuts } from './visualInput.js';
|
||||
import { bindDisplayControls } from './displayControls.js';
|
||||
import { bindDisplayControls, syncScopeControls } from './displayControls.js';
|
||||
import { canonicalizeDensity } from '../data/detectionPolicy.js';
|
||||
|
||||
/** Own keyboard/display event subscriptions; settings remain with their state owners. */
|
||||
@@ -92,6 +93,11 @@ export class DisplayBindings {
|
||||
},
|
||||
});
|
||||
|
||||
syncScopeControls(
|
||||
this._scopeBtn,
|
||||
this._scopeFeatherSlider,
|
||||
isScopeMaskEnabled(),
|
||||
);
|
||||
this._displayControls?.destroy();
|
||||
this._displayControls = bindDisplayControls({
|
||||
elements: {
|
||||
@@ -100,7 +106,7 @@ export class DisplayBindings {
|
||||
bloomSlider: this._bloomSlider,
|
||||
sharpenButton: this._sharpenBtn,
|
||||
sharpenSlider: this._sharpenSlider,
|
||||
scopeButton: this._scopeBtn,
|
||||
scopeToggle: this._scopeBtn,
|
||||
scopeFeatherSlider: this._scopeFeatherSlider,
|
||||
hudLayout: this._hudLayoutSelect,
|
||||
hudButton: this._hudBtn,
|
||||
@@ -134,17 +140,15 @@ export class DisplayBindings {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setSharpenEnabled(!this.sharpenEnabled);
|
||||
},
|
||||
toggleScope: () => {
|
||||
setScopeEnabled: (enabled) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
const next = !isScopeMaskEnabled();
|
||||
setScopeMaskEnabled(next);
|
||||
this._scopeBtn.classList.toggle('active', next);
|
||||
this._scopeBtn.setAttribute('aria-pressed', String(next));
|
||||
setScopeMaskEnabled(enabled);
|
||||
syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, enabled);
|
||||
this._syncShareState();
|
||||
},
|
||||
setScopeFeather: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
const pct = Math.max(0, Math.min(100, value || 0));
|
||||
const pct = clampScopeFeatherPct(value);
|
||||
if (this._scopeFeatherValue)
|
||||
this._scopeFeatherValue.textContent = `${pct}%`;
|
||||
setScopeMaskFeather(pct / 100);
|
||||
|
||||
@@ -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.
|
||||
@@ -16,7 +18,6 @@ export function bindDisplayControls({ elements, actions }) {
|
||||
for (const [name, action] of [
|
||||
['bloomButton', 'toggleBloom'],
|
||||
['sharpenButton', 'toggleSharpen'],
|
||||
['scopeButton', 'toggleScope'],
|
||||
['cleanViewButton', 'toggleCleanView'],
|
||||
['cleanViewExitButton', 'exitCleanView'],
|
||||
['celestialButton', 'toggleCelestial'],
|
||||
@@ -32,6 +33,7 @@ export function bindDisplayControls({ elements, actions }) {
|
||||
['scopeFeatherSlider', 'setScopeFeather'],
|
||||
])
|
||||
listen(elements[name], 'input', action, integer);
|
||||
listen(elements.scopeToggle, 'change', 'setScopeEnabled', (el) => el.checked);
|
||||
listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value);
|
||||
listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value);
|
||||
for (const [name, action] of [
|
||||
@@ -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() {
|
||||
@@ -57,3 +72,9 @@ export function bindDisplayControls({ elements, actions }) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep the native Scope checkbox and feather availability in step with the mask. */
|
||||
export function syncScopeControls(toggle, featherSlider, enabled) {
|
||||
if (toggle) toggle.checked = enabled;
|
||||
if (featherSlider) featherSlider.disabled = !enabled;
|
||||
}
|
||||
@@ -158,3 +158,29 @@ test('optional controls are absent safely and subscriptions stay instance-owned'
|
||||
assert.deepEqual(calls, ['b']);
|
||||
two.destroy();
|
||||
});
|
||||
|
||||
test('the native Scope checkbox reads checked state on change and disposes its listener', () => {
|
||||
const scopeToggle = element();
|
||||
scopeToggle.checked = true;
|
||||
const scopeFeatherSlider = element('200');
|
||||
const calls = [];
|
||||
const controls = bindDisplayControls({
|
||||
elements: { scopeToggle, scopeFeatherSlider },
|
||||
actions: {
|
||||
setScopeEnabled: (value) => calls.push(['enabled', value]),
|
||||
setScopeFeather: (value) => calls.push(['feather', value]),
|
||||
},
|
||||
});
|
||||
scopeToggle.checked = false;
|
||||
scopeToggle.dispatchEvent(new Event('change'));
|
||||
scopeToggle.checked = true;
|
||||
scopeToggle.dispatchEvent(new Event('change'));
|
||||
scopeFeatherSlider.dispatchEvent(new Event('input'));
|
||||
controls.destroy();
|
||||
scopeToggle.dispatchEvent(new Event('change'));
|
||||
assert.deepEqual(calls, [
|
||||
['enabled', false],
|
||||
['enabled', true],
|
||||
['feather', 200],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Build a native numeric field without listeners, parsing or commit policy.
|
||||
* Owners retain drafts, validation, input/change timing and disposal. Static
|
||||
* selects and number inputs share the gev-field surface in their templates;
|
||||
* options remain owned by each feature (no replacement popup or keyboard code).
|
||||
* @param {object} options
|
||||
* @param {Document} [options.documentRef]
|
||||
* @param {string} [options.className] Additional density/layout hooks.
|
||||
* @param {number|string} [options.min]
|
||||
* @param {number|string} [options.max]
|
||||
* @param {number|string} [options.step]
|
||||
* @param {number|string} [options.value]
|
||||
* @param {boolean} [options.disabled]
|
||||
* @param {string} [options.ariaLabel] Already translated accessible name.
|
||||
* @returns {HTMLInputElement}
|
||||
*/
|
||||
export function createNumberInput({
|
||||
documentRef = globalThis.document,
|
||||
className = '',
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
value,
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
} = {}) {
|
||||
const input = documentRef.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = `gev-field${className ? ` ${className}` : ''}`;
|
||||
for (const [key, setting] of Object.entries({ min, max, step, value })) {
|
||||
if (setting !== undefined) input[key] = setting;
|
||||
}
|
||||
input.disabled = disabled;
|
||||
if (ariaLabel !== undefined) input.setAttribute('aria-label', ariaLabel);
|
||||
return input;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export function createFrameRateMonitor({ viewer, documentRef = document }) {
|
||||
if (!host || !frameEvent) return { destroy() {} };
|
||||
|
||||
const readout = documentRef.createElement('div');
|
||||
readout.className = 'frame-rate-readout';
|
||||
readout.className = 'frame-rate-readout scene-text-holdout';
|
||||
readout.hidden = true;
|
||||
// i18n-ignore: FPS is a unit code, the same in every language
|
||||
readout.textContent = 'FPS —';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -34,6 +34,7 @@ export const LAYER_NAME_KEYS = Object.freeze({
|
||||
'recent-imagery': msg('Recent Imagery'),
|
||||
'fire-perimeters': msg('Fire Perimeters'),
|
||||
'gnss-integrity': msg('GNSS Integrity'),
|
||||
's2-ghostmaps': msg('S2 GhostMaps'),
|
||||
wind: msg('Wind'),
|
||||
'weather-radar': msg('Rain radar'),
|
||||
'weather-satellite': msg('Satellite clouds'),
|
||||
|
||||
@@ -73,6 +73,7 @@ const PANEL_GROUPS = [
|
||||
'local-firms',
|
||||
'fire-perimeters',
|
||||
'gnss-integrity',
|
||||
's2-ghostmaps',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -20,6 +20,11 @@ export const LOCALE_SEARCH_THRESHOLD = 8;
|
||||
/** Type-ahead keystrokes closer together than this extend one prefix. */
|
||||
export const TYPEAHEAD_RESET_MS = 500;
|
||||
|
||||
const CHINESE_LABELS = {
|
||||
'zh-CN': 'Chinese (Simplified)',
|
||||
'zh-Hant': 'Chinese (Traditional)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Text folded for search: no diacritics, lower case. 'Español' → 'espanol',
|
||||
* 'Ёжик' → 'ежик'.
|
||||
@@ -464,12 +469,13 @@ export function mountLocalePicker({
|
||||
item.dataset.locale = option.code;
|
||||
const name = element(documentRef, 'span', 'locale-picker-native');
|
||||
name.textContent = option.nativeName;
|
||||
// Every row reads the same: the language named in itself, then its code.
|
||||
// The interface-language name only helps the search find it.
|
||||
// Chinese variants are distinguished by script rather than region.
|
||||
const meta = element(documentRef, 'span', 'locale-picker-meta');
|
||||
const codeLabel = element(documentRef, 'span', 'locale-picker-option-code');
|
||||
codeLabel.setAttribute('translate', 'no');
|
||||
codeLabel.textContent = option.code.toUpperCase();
|
||||
codeLabel.textContent =
|
||||
CHINESE_LABELS[option.code] || option.code.toUpperCase();
|
||||
if (CHINESE_LABELS[option.code]) codeLabel.lang = 'en';
|
||||
meta.append(codeLabel);
|
||||
const text = element(documentRef, 'span', 'locale-picker-text');
|
||||
text.append(name, meta);
|
||||
|
||||
@@ -40,7 +40,7 @@ test('the current language comes first, the rest by their own names', () => {
|
||||
nativeNames: LOCALE_NAMES,
|
||||
}),
|
||||
),
|
||||
['ru', 'en', 'es'],
|
||||
['ru', 'en', 'es', 'zh-CN', 'zh-Hant'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
codes(
|
||||
@@ -50,7 +50,7 @@ test('the current language comes first, the rest by their own names', () => {
|
||||
nativeNames: LOCALE_NAMES,
|
||||
}),
|
||||
),
|
||||
['es', 'en', 'ru'],
|
||||
['es', 'en', 'ru', 'zh-CN', 'zh-Hant'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
codes(
|
||||
@@ -60,7 +60,7 @@ test('the current language comes first, the rest by their own names', () => {
|
||||
nativeNames: LOCALE_NAMES,
|
||||
}),
|
||||
),
|
||||
['en', 'es', 'ru'],
|
||||
['en', 'es', 'ru', 'zh-CN', 'zh-Hant'],
|
||||
);
|
||||
const rows = buildLocaleOptions({
|
||||
locales: MANY,
|
||||
@@ -263,7 +263,7 @@ test('keyboard model: open on the current language, move, choose, close', () =>
|
||||
assert.deepEqual(model.keydown({ key: 'ArrowDown' }), { handled: true });
|
||||
assert.equal(model.activeOption.code, 'en');
|
||||
model.keydown({ key: 'End' });
|
||||
assert.equal(model.activeOption.code, 'es');
|
||||
assert.equal(model.activeOption.code, 'zh-Hant');
|
||||
model.keydown({ key: 'Home' });
|
||||
assert.equal(model.activeOption.code, 'ru');
|
||||
model.keydown({ key: 'ArrowUp' });
|
||||
|
||||
@@ -22,6 +22,7 @@ export class LocationControls {
|
||||
onCity,
|
||||
onPoi,
|
||||
onSearch,
|
||||
onSearchEdit,
|
||||
onReset,
|
||||
doc = document,
|
||||
requestFrame = (callback) => requestAnimationFrame(callback),
|
||||
@@ -74,6 +75,11 @@ export class LocationControls {
|
||||
this.bind(elements.search, 'keydown', (event) => {
|
||||
if (event.key === 'Enter') void onSearch(elements.search.value);
|
||||
});
|
||||
// Typing corrects a failed query; closing the field (which empties it) drops its error.
|
||||
this.bind(elements.search, 'input', () => onSearchEdit?.());
|
||||
this.bind(elements.search, 'blur', () => {
|
||||
if (!elements.search.value) onSearchEdit?.();
|
||||
});
|
||||
for (const button of elements.resetButtons)
|
||||
this.bind(button, 'click', onReset);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ function fixture() {
|
||||
onCity: (id) => calls.push(['city', id]),
|
||||
onPoi: (id, index) => calls.push(['poi', id, index]),
|
||||
onSearch: (query) => calls.push(['search', query]),
|
||||
onSearchEdit: () => calls.push(['edit']),
|
||||
onReset: () => calls.push(['reset']),
|
||||
doc,
|
||||
requestFrame: (fn) => {
|
||||
@@ -147,3 +148,13 @@ test('location and POI keys route once while form controls retain typing', () =>
|
||||
f.elements.resetButtons[1].fire('click');
|
||||
assert.deepEqual(f.calls, [['poi', 'a', 1], ['reset']]);
|
||||
});
|
||||
test('typing clears a failed search error and closing the emptied field drops it too', () => {
|
||||
const f = fixture();
|
||||
f.elements.search.value = 'Pariss';
|
||||
f.elements.search.fire('blur');
|
||||
assert.deepEqual(f.calls, [], 'leaving a kept query keeps its error');
|
||||
f.elements.search.fire('input');
|
||||
f.elements.search.value = '';
|
||||
f.elements.search.fire('blur');
|
||||
assert.deepEqual(f.calls, [['edit'], ['edit']]);
|
||||
});
|
||||
@@ -80,19 +80,27 @@ export class LocationNavigation {
|
||||
|
||||
_handleLocationSearchState(state, change) {
|
||||
if (this._disposed || !change) return;
|
||||
if (change.type === 'started')
|
||||
if (change.type === 'started') {
|
||||
this._activeLocationSearchGeneration = change.generation;
|
||||
else if (change.type === 'found') {
|
||||
this._setLocationSearchError(null);
|
||||
} else if (change.type === 'found') {
|
||||
this._searchedLocationLabel = state.destination.label || state.query;
|
||||
this._setActiveLocation(null);
|
||||
this._currentPoi = null;
|
||||
this._collapsePOIRow();
|
||||
this._updateLocationMiniStatus();
|
||||
} else if (change.type === 'missing')
|
||||
this._showToast(t('Location not found'));
|
||||
else if (change.type === 'failed') this._showToast(t('Search failed'));
|
||||
this._setLocationSearchError(
|
||||
`${t('Location not found')}. ${t('Check the spelling and press Enter to search again.')}`,
|
||||
);
|
||||
else if (change.type === 'failed')
|
||||
this._setLocationSearchError(
|
||||
`${t('Search failed')}. ${t('Press Enter to try again.')}`,
|
||||
);
|
||||
else if (change.type === 'settled')
|
||||
this._settleLocationSearchUi(change.generation);
|
||||
this._settleLocationSearchUi(change.generation, {
|
||||
keepQuery: state.status === 'missing' || state.status === 'failed',
|
||||
});
|
||||
else if (
|
||||
change.type === 'reset' &&
|
||||
this._activeLocationSearchGeneration !== null
|
||||
@@ -101,6 +109,21 @@ export class LocationNavigation {
|
||||
}
|
||||
}
|
||||
|
||||
/** Show, or with null clear, the announced error under the search field. */
|
||||
_setLocationSearchError(message) {
|
||||
const input = this._locationSearch;
|
||||
const error = this._locationSearchError;
|
||||
if (error) error.textContent = message || '';
|
||||
if (!input) return;
|
||||
if (message) {
|
||||
input.setAttribute('aria-invalid', 'true');
|
||||
if (error?.id) input.setAttribute('aria-describedby', error.id);
|
||||
} else {
|
||||
input.removeAttribute('aria-invalid');
|
||||
input.removeAttribute('aria-describedby');
|
||||
}
|
||||
}
|
||||
|
||||
_initLocationBar() {
|
||||
const { CITY_POIS, searchAndFlyTo, LocationSearch } = this.services;
|
||||
this._locationControls?.destroy();
|
||||
@@ -144,6 +167,7 @@ export class LocationNavigation {
|
||||
onCity: (id) => this._onCityPillClick(id),
|
||||
onPoi: (id, index) => this._onPoiClick(id, index),
|
||||
onSearch: (query) => this._locationLookup.run(query),
|
||||
onSearchEdit: () => this._setLocationSearchError(null),
|
||||
onReset: () => this.resetToGlobeView(),
|
||||
});
|
||||
}
|
||||
@@ -283,6 +307,7 @@ export class LocationNavigation {
|
||||
return;
|
||||
}
|
||||
|
||||
this.services.interruptGlobeRotation?.('orbit-intent');
|
||||
const isActive = this.orbitController.toggle(this._currentTarget, {
|
||||
radius: this._currentPoi?.alt || 500,
|
||||
pitch: this._currentPoi?.pitch || -30,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { NavigationController } from './navigationController.js';
|
||||
import { LocationNavigation } from './locationNavigation.js';
|
||||
|
||||
function input() {
|
||||
const classes = new Set(['expanded', 'searching']);
|
||||
const attributes = new Map();
|
||||
return {
|
||||
id: 'location-search',
|
||||
value: 'Pariss',
|
||||
focused: false,
|
||||
selected: false,
|
||||
classList: {
|
||||
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
||||
contains: (name) => classes.has(name),
|
||||
},
|
||||
focus() {
|
||||
this.focused = true;
|
||||
},
|
||||
blur() {
|
||||
this.focused = false;
|
||||
},
|
||||
select() {
|
||||
this.selected = true;
|
||||
},
|
||||
setAttribute: (name, value) => attributes.set(name, value),
|
||||
removeAttribute: (name) => attributes.delete(name),
|
||||
getAttribute: (name) => attributes.get(name) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function settle(searchInput, options) {
|
||||
const owner = { _activeLocationSearchGeneration: 4, searchInput };
|
||||
NavigationController.prototype._settleLocationSearchUi.call(
|
||||
owner,
|
||||
4,
|
||||
options,
|
||||
);
|
||||
return owner;
|
||||
}
|
||||
|
||||
test('a search that found nothing keeps its query, the open field and focus', () => {
|
||||
const field = input();
|
||||
const owner = settle(field, { keepQuery: true });
|
||||
assert.equal(owner._activeLocationSearchGeneration, null);
|
||||
assert.equal(field.value, 'Pariss');
|
||||
assert.equal(field.classList.contains('expanded'), true);
|
||||
assert.equal(field.classList.contains('searching'), false);
|
||||
assert.equal(field.focused, true);
|
||||
assert.equal(field.selected, true);
|
||||
});
|
||||
|
||||
test('a search that arrived still clears and closes the field', () => {
|
||||
const field = input();
|
||||
field.focused = true;
|
||||
settle(field);
|
||||
assert.equal(field.value, '');
|
||||
assert.equal(field.classList.contains('expanded'), false);
|
||||
assert.equal(field.focused, false);
|
||||
});
|
||||
|
||||
function navigation() {
|
||||
const field = input();
|
||||
const error = { id: 'location-search-error', textContent: '' };
|
||||
const settled = [];
|
||||
const toasts = [];
|
||||
const owner = {
|
||||
_disposed: false,
|
||||
_locationSearch: field,
|
||||
_locationSearchError: error,
|
||||
_activeLocationSearchGeneration: null,
|
||||
_settleLocationSearchUi: (generation, options) =>
|
||||
settled.push([generation, options]),
|
||||
_showToast: (message) => toasts.push(message),
|
||||
_setLocationSearchError:
|
||||
LocationNavigation.prototype._setLocationSearchError,
|
||||
};
|
||||
const handle = (status, type) =>
|
||||
LocationNavigation.prototype._handleLocationSearchState.call(
|
||||
owner,
|
||||
{ status, query: field.value },
|
||||
{ type, generation: 7 },
|
||||
);
|
||||
return { owner, field, error, settled, toasts, handle };
|
||||
}
|
||||
|
||||
for (const [status, text] of [
|
||||
['missing', 'Location not found'],
|
||||
['failed', 'Search failed'],
|
||||
]) {
|
||||
test(`a ${status} search announces a lasting error on the field instead of a toast`, () => {
|
||||
const n = navigation();
|
||||
n.handle('searching', 'started');
|
||||
n.handle(status, status);
|
||||
n.handle(status, 'settled');
|
||||
assert.match(n.error.textContent, new RegExp(`^${text}\\. `));
|
||||
assert.equal(n.field.getAttribute('aria-invalid'), 'true');
|
||||
assert.equal(
|
||||
n.field.getAttribute('aria-describedby'),
|
||||
'location-search-error',
|
||||
);
|
||||
assert.deepEqual(n.settled, [[7, { keepQuery: true }]]);
|
||||
assert.deepEqual(n.toasts, []);
|
||||
n.handle('searching', 'started');
|
||||
assert.equal(n.error.textContent, '');
|
||||
assert.equal(n.field.getAttribute('aria-invalid'), null);
|
||||
assert.equal(n.field.getAttribute('aria-describedby'), null);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,520 @@
|
||||
/**
|
||||
* Phone shell for narrow viewports (≤720px). The globe stays full screen and a
|
||||
* bottom tab bar opens one sheet at a time; each sheet shows existing panels,
|
||||
* which stay where they are in the rails and dock while CSS presents them
|
||||
* (styles/mobile-shell.css). Desktop never sees the class or the sheet state.
|
||||
*
|
||||
* Sheets borrow panels: the panels a sheet expands are put back exactly as
|
||||
* they were when the sheets close, and nothing a sheet does is persisted or
|
||||
* treated as the user's own panel choice.
|
||||
*
|
||||
* Adapted from the phone tab-shell proposed in public PR #203 by blearymoth.
|
||||
*/
|
||||
|
||||
import { EXCLUSIVE_SURFACE_CLASSES } from '../exclusiveSurfaces.js';
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
export const MOBILE_SHELL_MEDIA_QUERY = '(max-width: 720px)';
|
||||
export const MOBILE_SHELL_CLASS = 'mobile-shell';
|
||||
export const MOBILE_SHEET_ATTR = 'data-mobile-sheet';
|
||||
|
||||
/**
|
||||
* Panels each sheet expands. A sheet also shows its rail's other panels
|
||||
* collapsed (Scenes under Layers), per styles/mobile-shell.css.
|
||||
*/
|
||||
export const MOBILE_SHEET_PANELS = Object.freeze({
|
||||
layers: Object.freeze(['data-panel']),
|
||||
cameras: Object.freeze(['cctv-panel', 'street-level-panel']),
|
||||
context: Object.freeze(['global-context-panel']),
|
||||
search: Object.freeze(['location-bar']),
|
||||
more: Object.freeze(['control-panel', 'pp-toggles']),
|
||||
});
|
||||
|
||||
export const MOBILE_TABS = Object.freeze([
|
||||
Object.freeze({ sheet: 'layers', label: msg('Layers'), icon: 'layers' }),
|
||||
Object.freeze({ sheet: 'cameras', label: msg('Cameras'), icon: 'videocam' }),
|
||||
Object.freeze({ sheet: 'context', label: msg('Context'), icon: 'radar' }),
|
||||
Object.freeze({ sheet: 'search', label: msg('Search'), icon: 'search' }),
|
||||
Object.freeze({ sheet: 'more', label: msg('More'), icon: 'more_horiz' }),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Surfaces that own the screen close the open sheet and keep sheets closed.
|
||||
* Scene playback is the exception: its STOP control lives in the Scenes panel
|
||||
* (Layers sheet), and a phone has no Escape key to stop it otherwise.
|
||||
*/
|
||||
const SHEET_BLOCKING_CLASSES = Object.freeze(
|
||||
EXCLUSIVE_SURFACE_CLASSES.filter((name) => name !== 'scene-playback-mode'),
|
||||
);
|
||||
|
||||
/** Every panel any sheet borrows; the dock trays close each other. */
|
||||
const BORROWED_PANELS = Object.freeze([
|
||||
...new Set(Object.values(MOBILE_SHEET_PANELS).flat()),
|
||||
]);
|
||||
|
||||
const BORROW_OPTIONS = Object.freeze({ persist: false, syncShare: false });
|
||||
|
||||
/**
|
||||
* @param {string} panelId
|
||||
* @returns {string|null} The sheet that presents `panelId`.
|
||||
*/
|
||||
export function sheetForPanelId(panelId) {
|
||||
for (const [sheet, ids] of Object.entries(MOBILE_SHEET_PANELS)) {
|
||||
if (ids.includes(panelId)) return sheet;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet after a tab press: the same tab closes its sheet, another opens.
|
||||
* @param {string|null} current
|
||||
* @param {string|null} pressed
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function nextSheet(current, pressed) {
|
||||
if (!pressed || !Object.hasOwn(MOBILE_SHEET_PANELS, pressed)) return null;
|
||||
return current === pressed ? null : pressed;
|
||||
}
|
||||
|
||||
function sheetBlocked(body) {
|
||||
return SHEET_BLOCKING_CLASSES.some((name) => body.classList.contains(name));
|
||||
}
|
||||
|
||||
function currentSheetOf(body) {
|
||||
const value = body.getAttribute(MOBILE_SHEET_ATTR);
|
||||
return value && Object.hasOwn(MOBILE_SHEET_PANELS, value) ? value : null;
|
||||
}
|
||||
|
||||
function buildChrome(documentRef) {
|
||||
const backdrop = documentRef.createElement('div');
|
||||
backdrop.id = 'mobile-sheet-backdrop';
|
||||
backdrop.hidden = true;
|
||||
|
||||
const tabBar = documentRef.createElement('nav');
|
||||
tabBar.id = 'mobile-tab-bar';
|
||||
tabBar.setAttribute('aria-label', t('Panels'));
|
||||
for (const tab of MOBILE_TABS) {
|
||||
const button = documentRef.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'mobile-tab';
|
||||
button.dataset.mobileSheet = tab.sheet;
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
const icon = documentRef.createElement('span');
|
||||
icon.className = 'material-symbols-outlined mobile-tab-icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = tab.icon;
|
||||
const label = documentRef.createElement('span');
|
||||
label.className = 'mobile-tab-label';
|
||||
label.textContent = t(tab.label);
|
||||
button.append(icon, label);
|
||||
tabBar.append(button);
|
||||
}
|
||||
documentRef.body.append(backdrop, tabBar);
|
||||
return { backdrop, tabBar };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the phone shell: the media gate, tab bar, backdrop, Escape, focus,
|
||||
* and closing for full-screen modes.
|
||||
* @param {object} input
|
||||
* @param {(panelId: string, collapsed: boolean, options?: object) => void} input.setPanelCollapsed
|
||||
* @param {Document} [input.documentRef]
|
||||
* @param {(query: string) => MediaQueryList} [input.matchMedia]
|
||||
* @param {typeof MutationObserver} [input.MutationObserverImpl]
|
||||
* @param {typeof ResizeObserver} [input.ResizeObserverImpl]
|
||||
* @param {(callback: () => void) => number} [input.requestFrame]
|
||||
* @param {() => void} [input.onSheetsClosed] Runs after the last sheet closes
|
||||
* and its panels are put back (e.g. to refresh the share link).
|
||||
* @param {(sheet: string) => void} [input.onTabOpened] Runs only when the user
|
||||
* opens a sheet from its tab, never on close or programmatic presentation.
|
||||
* @returns {{ destroy: () => void, setSheet: (sheet: string|null) => void, getSheet: () => string|null, sharedCollapsed: (panelId: string) => boolean|undefined }}
|
||||
*/
|
||||
export function initMobileShell({
|
||||
setPanelCollapsed,
|
||||
documentRef = globalThis.document,
|
||||
matchMedia = globalThis.matchMedia?.bind(globalThis),
|
||||
MutationObserverImpl = globalThis.MutationObserver,
|
||||
ResizeObserverImpl = globalThis.ResizeObserver,
|
||||
requestFrame = globalThis.requestAnimationFrame?.bind(globalThis),
|
||||
onSheetsClosed = () => {},
|
||||
onTabOpened = () => {},
|
||||
} = {}) {
|
||||
const body = documentRef.body;
|
||||
const { backdrop, tabBar } = buildChrome(documentRef);
|
||||
const viewport = documentRef.querySelector('meta[name="viewport"]');
|
||||
const viewportContent = viewport?.getAttribute('content') ?? null;
|
||||
const removers = [];
|
||||
const listen = (target, type, handler, options) => {
|
||||
target.addEventListener(type, handler, options);
|
||||
removers.push(() => target.removeEventListener(type, handler, options));
|
||||
};
|
||||
const element = (id) => documentRef.getElementById(id);
|
||||
const rails = ['left-panel-stack', 'right-context-rail']
|
||||
.map(element)
|
||||
.filter(Boolean);
|
||||
const dock = element('command-dock');
|
||||
const shown = (node) =>
|
||||
node && globalThis.getComputedStyle?.(node).display !== 'none';
|
||||
|
||||
/**
|
||||
* Panel states from before the first sheet opened (`before`) and right after
|
||||
* the open sheet was set up (`applied`); null when closed.
|
||||
*/
|
||||
let borrowed = null;
|
||||
let returnFocus = null;
|
||||
let swipe = null;
|
||||
const written = new Map();
|
||||
|
||||
const writeVar = (name, value) => {
|
||||
if (written.get(name) === value) return;
|
||||
written.set(name, value);
|
||||
body.style.setProperty(name, value);
|
||||
};
|
||||
|
||||
// The dock (Presets, Search) sits on top of whichever rail is the sheet,
|
||||
// and the required map attribution sits above everything the sheet covers.
|
||||
const measureSheet = () => {
|
||||
if (!body.classList.contains(MOBILE_SHELL_CLASS)) return;
|
||||
const height = Math.max(
|
||||
0,
|
||||
...rails.map((rail) =>
|
||||
shown(rail) ? rail.getBoundingClientRect().height : 0,
|
||||
),
|
||||
);
|
||||
writeVar('--mobile-sheet-height', `${Math.round(height)}px`);
|
||||
const viewportHeight = globalThis.innerHeight || 0;
|
||||
const cover = Math.max(
|
||||
0,
|
||||
...[...rails, currentSheetOf(body) ? dock : null]
|
||||
.filter(shown)
|
||||
.map((node) => viewportHeight - node.getBoundingClientRect().top),
|
||||
);
|
||||
writeVar('--mobile-sheet-cover', `${Math.round(cover)}px`);
|
||||
};
|
||||
|
||||
const resize =
|
||||
typeof ResizeObserverImpl === 'function'
|
||||
? new ResizeObserverImpl(measureSheet)
|
||||
: null;
|
||||
removers.push(() => resize?.disconnect());
|
||||
|
||||
const isCollapsed = (panelId) =>
|
||||
element(panelId)?.classList.contains('collapsed') ?? true;
|
||||
// The user's preference, as share links record it: a collapse that layout
|
||||
// applies only for space (Tactical, Cyber) still counts as expanded.
|
||||
const prefersCollapsed = (panelId) => {
|
||||
const classes = element(panelId)?.classList;
|
||||
if (!classes) return true;
|
||||
return (
|
||||
classes.contains('collapsed') &&
|
||||
!classes.contains('layout-auto-collapsed') &&
|
||||
!classes.contains('cyber-accordion-collapsed')
|
||||
);
|
||||
};
|
||||
|
||||
const panelStates = () =>
|
||||
new Map(
|
||||
BORROWED_PANELS.map((panelId) => [panelId, prefersCollapsed(panelId)]),
|
||||
);
|
||||
|
||||
// Undo only what opening the sheet did (including side effects, such as dock
|
||||
// trays closing each other). A panel the user changed since stays as they
|
||||
// left it, and becomes the state later sheets put back.
|
||||
const restoreBorrowed = () => {
|
||||
if (!borrowed) return;
|
||||
for (const [panelId, before] of borrowed.before) {
|
||||
const now = prefersCollapsed(panelId);
|
||||
if (now !== borrowed.applied.get(panelId))
|
||||
borrowed.before.set(panelId, now);
|
||||
else if (now !== before)
|
||||
setPanelCollapsed(panelId, before, BORROW_OPTIONS);
|
||||
}
|
||||
};
|
||||
|
||||
const present = (sheet) => {
|
||||
if (sheet) body.setAttribute(MOBILE_SHEET_ATTR, sheet);
|
||||
else body.removeAttribute(MOBILE_SHEET_ATTR);
|
||||
backdrop.hidden = !sheet;
|
||||
for (const button of tabBar.querySelectorAll('[data-mobile-sheet]'))
|
||||
button.setAttribute(
|
||||
'aria-expanded',
|
||||
String(button.dataset.mobileSheet === sheet),
|
||||
);
|
||||
measureSheet();
|
||||
};
|
||||
|
||||
/** The first visible control of the sheet's first panel, for focus on open. */
|
||||
const sheetFocusTarget = (sheet) => {
|
||||
if (sheet === 'search') return element('location-search');
|
||||
const panel = element(MOBILE_SHEET_PANELS[sheet][0]);
|
||||
const controls =
|
||||
panel?.querySelectorAll?.(
|
||||
'button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
) ?? [];
|
||||
// Sheets hide some panel chrome (tray toggles, pins) that stays in the
|
||||
// DOM, and a panel's own collapse control is a poor first stop.
|
||||
return (
|
||||
[...controls].find(
|
||||
(control) =>
|
||||
control.getClientRects().length > 0 &&
|
||||
!control.matches?.('.panel-collapse-btn'),
|
||||
) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const setSheet = (requested) => {
|
||||
swipe = null;
|
||||
if (!body.classList.contains(MOBILE_SHELL_CLASS)) return;
|
||||
const sheet = sheetBlocked(body) ? null : requested;
|
||||
const previous = currentSheetOf(body);
|
||||
if (previous === sheet) return;
|
||||
if (!previous) {
|
||||
borrowed = { before: panelStates(), applied: panelStates() };
|
||||
returnFocus = documentRef.activeElement ?? null;
|
||||
} else {
|
||||
if (previous === 'search') element('location-search')?.blur?.();
|
||||
restoreBorrowed();
|
||||
}
|
||||
present(sheet);
|
||||
if (!sheet) {
|
||||
borrowed = null;
|
||||
onSheetsClosed();
|
||||
const focus = returnFocus;
|
||||
returnFocus = null;
|
||||
if (focus && focus !== body && body.contains?.(focus)) focus.focus?.();
|
||||
return;
|
||||
}
|
||||
for (const panelId of MOBILE_SHEET_PANELS[sheet])
|
||||
if (isCollapsed(panelId))
|
||||
setPanelCollapsed(panelId, false, BORROW_OPTIONS);
|
||||
borrowed.applied = panelStates();
|
||||
// Rail layout settles on a later frame; what it does is part of opening.
|
||||
const opening = (borrowed.opening ?? 0) + 1;
|
||||
borrowed.opening = opening;
|
||||
const opened = borrowed;
|
||||
requestFrame?.(() => {
|
||||
if (borrowed === opened && opened.opening === opening)
|
||||
opened.applied = panelStates();
|
||||
});
|
||||
measureSheet();
|
||||
sheetFocusTarget(sheet)?.focus?.();
|
||||
};
|
||||
|
||||
const tabFor = (sheet) =>
|
||||
[...tabBar.querySelectorAll('[data-mobile-sheet]')].find(
|
||||
(button) => button.dataset.mobileSheet === sheet,
|
||||
);
|
||||
|
||||
const onMedia = (matches) => {
|
||||
if (matches) {
|
||||
body.classList.add(MOBILE_SHELL_CLASS);
|
||||
// Lets the page draw under the notch and system bars; the shell's CSS
|
||||
// keeps its chrome clear of them.
|
||||
if (viewport && !/viewport-fit\s*=/.test(viewportContent || ''))
|
||||
viewport.setAttribute(
|
||||
'content',
|
||||
`${viewportContent || ''}, viewport-fit=cover`,
|
||||
);
|
||||
for (const node of [...rails, dock].filter(Boolean))
|
||||
resize?.observe(node);
|
||||
measureSheet();
|
||||
} else {
|
||||
// Crossing to desktop closes the sheet, which puts its panels back.
|
||||
setSheet(null);
|
||||
body.classList.remove(MOBILE_SHELL_CLASS);
|
||||
present(null);
|
||||
resize?.disconnect();
|
||||
if (viewport && viewportContent !== null)
|
||||
viewport.setAttribute('content', viewportContent);
|
||||
}
|
||||
tabBar.setAttribute('aria-hidden', String(!matches));
|
||||
};
|
||||
|
||||
const media =
|
||||
typeof matchMedia === 'function'
|
||||
? matchMedia(MOBILE_SHELL_MEDIA_QUERY)
|
||||
: null;
|
||||
onMedia(Boolean(media?.matches));
|
||||
const onChange = (event) => onMedia(event.matches);
|
||||
if (media?.addEventListener) {
|
||||
media.addEventListener('change', onChange);
|
||||
removers.push(() => media.removeEventListener('change', onChange));
|
||||
} else if (media?.addListener) {
|
||||
// Safari before 14 only offers the legacy listener API.
|
||||
media.addListener(onChange);
|
||||
removers.push(() => media.removeListener(onChange));
|
||||
}
|
||||
|
||||
listen(tabBar, 'click', (event) => {
|
||||
const button = event.target?.closest?.('[data-mobile-sheet]');
|
||||
if (!button) return;
|
||||
event.preventDefault();
|
||||
const sheet = nextSheet(currentSheetOf(body), button.dataset.mobileSheet);
|
||||
// Closing from the tab bar returns focus to the tab that was pressed.
|
||||
if (!sheet) returnFocus = button;
|
||||
setSheet(sheet);
|
||||
if (sheet && currentSheetOf(body) === sheet) onTabOpened(sheet);
|
||||
});
|
||||
listen(backdrop, 'click', () => setSheet(null));
|
||||
// Touch events let native scrolling continue until a downward pull at the
|
||||
// top is intentional. Pointer capture/touch-action:none would also seize
|
||||
// ordinary vertical scrolling inside the borrowed panels.
|
||||
const sheetRoot = (target, sheet) => {
|
||||
const roots =
|
||||
sheet === 'layers'
|
||||
? [element('left-panel-stack')]
|
||||
: sheet === 'search'
|
||||
? [dock]
|
||||
: sheet === 'more'
|
||||
? [element('right-context-rail'), dock]
|
||||
: [element('right-context-rail')];
|
||||
return roots.find((root) => root?.contains(target));
|
||||
};
|
||||
const scrolled = (target, root) => {
|
||||
for (let node = target; node; node = node.parentElement) {
|
||||
if (node.scrollTop > 0) return true;
|
||||
if (node === root) break;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
listen(
|
||||
documentRef,
|
||||
'touchstart',
|
||||
(event) => {
|
||||
swipe = null;
|
||||
const sheet = currentSheetOf(body);
|
||||
const target = event.target;
|
||||
if (
|
||||
!sheet ||
|
||||
!body.classList.contains(MOBILE_SHELL_CLASS) ||
|
||||
event.defaultPrevented ||
|
||||
event.touches.length !== 1 ||
|
||||
target?.closest?.(
|
||||
'button, a, input, select, textarea, label, [contenteditable]:not([contenteditable="false"]), [role="slider"], [role="button"]',
|
||||
)
|
||||
)
|
||||
return;
|
||||
const root = sheetRoot(target, sheet);
|
||||
if (!root || scrolled(target, root)) return;
|
||||
const touch = event.touches[0];
|
||||
swipe = {
|
||||
sheet,
|
||||
target,
|
||||
root,
|
||||
id: touch.identifier,
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
claimed: false,
|
||||
};
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
listen(
|
||||
documentRef,
|
||||
'touchmove',
|
||||
(event) => {
|
||||
if (!swipe) return;
|
||||
const touch = event.touches[0];
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.touches.length !== 1 ||
|
||||
touch.identifier !== swipe.id ||
|
||||
currentSheetOf(body) !== swipe.sheet ||
|
||||
scrolled(swipe.target, swipe.root)
|
||||
) {
|
||||
swipe = null;
|
||||
return;
|
||||
}
|
||||
const dx = touch.clientX - swipe.x;
|
||||
const dy = touch.clientY - swipe.y;
|
||||
if (!swipe.claimed) {
|
||||
// Claim direction before the browser crosses its native scroll slop.
|
||||
// Android can make the next move non-cancelable after a ~9px first move.
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) < 4) return;
|
||||
if (dy <= Math.abs(dx) * 1.25 || !event.cancelable) {
|
||||
swipe = null;
|
||||
return;
|
||||
}
|
||||
swipe.claimed = true;
|
||||
}
|
||||
// Cancelling only the claimed pull leaves taps, sliders and normal scroll
|
||||
// native, and suppresses the synthetic click at the end of a dismissal.
|
||||
event.preventDefault();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
listen(
|
||||
documentRef,
|
||||
'touchend',
|
||||
(event) => {
|
||||
const pull = swipe;
|
||||
swipe = null;
|
||||
if (!pull?.claimed || event.touches.length) return;
|
||||
const touch = [...event.changedTouches].find(
|
||||
(item) => item.identifier === pull.id,
|
||||
);
|
||||
if (!touch || currentSheetOf(body) !== pull.sheet) return;
|
||||
const dx = Math.abs(touch.clientX - pull.x);
|
||||
const dy = touch.clientY - pull.y;
|
||||
if (dy < 64 || dy <= dx * 1.25) return;
|
||||
returnFocus = tabFor(pull.sheet) ?? returnFocus;
|
||||
setSheet(null);
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
listen(
|
||||
documentRef,
|
||||
'touchcancel',
|
||||
() => {
|
||||
swipe = null;
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
// Bubble phase, so fields cancelling an edit, lightboxes, viewers and tools
|
||||
// take Escape first; panels yield it to the sheet (PanelChrome), and an open
|
||||
// dialog keeps its own native close.
|
||||
listen(documentRef, 'keydown', (event) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
||||
const sheet = currentSheetOf(body);
|
||||
if (!sheet || documentRef.querySelector('dialog[open]')) return;
|
||||
event.preventDefault();
|
||||
returnFocus = tabFor(sheet) ?? returnFocus;
|
||||
setSheet(null);
|
||||
});
|
||||
|
||||
if (typeof MutationObserverImpl === 'function') {
|
||||
const observer = new MutationObserverImpl(() => {
|
||||
if (currentSheetOf(body) && sheetBlocked(body)) setSheet(null);
|
||||
});
|
||||
observer.observe(body, { attributes: true, attributeFilter: ['class'] });
|
||||
removers.push(() => observer.disconnect());
|
||||
}
|
||||
|
||||
return {
|
||||
setSheet,
|
||||
getSheet: () => currentSheetOf(body),
|
||||
/**
|
||||
* The collapsed state to share for a panel a sheet borrowed: its state
|
||||
* before the sheets, or the user's own change since. Undefined otherwise.
|
||||
*/
|
||||
sharedCollapsed(panelId) {
|
||||
if (!borrowed?.before.has(panelId)) return undefined;
|
||||
const now = prefersCollapsed(panelId);
|
||||
return now !== borrowed.applied.get(panelId)
|
||||
? now
|
||||
: borrowed.before.get(panelId);
|
||||
},
|
||||
destroy() {
|
||||
setSheet(null);
|
||||
for (const remove of removers.splice(0)) remove();
|
||||
body.classList.remove(MOBILE_SHELL_CLASS);
|
||||
body.removeAttribute(MOBILE_SHEET_ATTR);
|
||||
body.style.removeProperty('--mobile-sheet-height');
|
||||
body.style.removeProperty('--mobile-sheet-cover');
|
||||
if (viewport && viewportContent !== null)
|
||||
viewport.setAttribute('content', viewportContent);
|
||||
backdrop.remove();
|
||||
tabBar.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { StyleManager } from './applicationShell.js';
|
||||
import { ContextControls } from './contextControls.js';
|
||||
import {
|
||||
MOBILE_SHEET_PANELS,
|
||||
MOBILE_TABS,
|
||||
initMobileShell,
|
||||
nextSheet,
|
||||
sheetForPanelId,
|
||||
} from './mobileShell.js';
|
||||
|
||||
function element(tag, document) {
|
||||
const node = new EventTarget();
|
||||
const attributes = new Map();
|
||||
const classes = new Set();
|
||||
Object.assign(node, {
|
||||
tagName: tag.toUpperCase(),
|
||||
children: [],
|
||||
dataset: {},
|
||||
hidden: false,
|
||||
textContent: '',
|
||||
className: '',
|
||||
parent: null,
|
||||
scrollTop: 0,
|
||||
parentElement: null,
|
||||
classList: {
|
||||
add: (...names) => names.forEach((name) => classes.add(name)),
|
||||
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
||||
contains: (name) => classes.has(name),
|
||||
},
|
||||
style: {
|
||||
values: new Map(),
|
||||
setProperty(name, value) {
|
||||
this.values.set(name, value);
|
||||
},
|
||||
removeProperty(name) {
|
||||
this.values.delete(name);
|
||||
},
|
||||
},
|
||||
setAttribute: (key, value) => attributes.set(key, String(value)),
|
||||
getAttribute: (key) => attributes.get(key) ?? null,
|
||||
removeAttribute: (key) => attributes.delete(key),
|
||||
append(...children) {
|
||||
for (const child of children) {
|
||||
child.parent = node;
|
||||
child.parentElement = node;
|
||||
node.children.push(child);
|
||||
if (child.id) document.ids.set(child.id, child);
|
||||
}
|
||||
},
|
||||
remove() {
|
||||
const siblings = node.parent?.children;
|
||||
siblings?.splice(siblings.indexOf(node), 1);
|
||||
document.ids.delete(node.id);
|
||||
},
|
||||
contains(other) {
|
||||
for (let current = other; current; current = current.parent)
|
||||
if (current === node) return true;
|
||||
return false;
|
||||
},
|
||||
closest(selector) {
|
||||
for (let current = node; current; current = current.parent) {
|
||||
if (
|
||||
selector.startsWith('button, a') &&
|
||||
(['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL'].includes(
|
||||
current.tagName,
|
||||
) ||
|
||||
['slider', 'button'].includes(current.getAttribute('role')) ||
|
||||
(current.getAttribute('contenteditable') !== null &&
|
||||
current.getAttribute('contenteditable') !== 'false'))
|
||||
)
|
||||
return current;
|
||||
if (selector === '[data-mobile-sheet]' && current.dataset.mobileSheet)
|
||||
return current;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
const found = [];
|
||||
const matches = (child) =>
|
||||
selector === '[data-mobile-sheet]'
|
||||
? Boolean(child.dataset.mobileSheet)
|
||||
: child.tagName === 'BUTTON';
|
||||
const walk = (parent) => {
|
||||
for (const child of parent.children) {
|
||||
if (matches(child)) found.push(child);
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(node);
|
||||
return found;
|
||||
},
|
||||
getBoundingClientRect: () => ({ top: 0, height: 0 }),
|
||||
getClientRects: () => (node.hidden ? [] : [{}]),
|
||||
matches: (selector) =>
|
||||
selector === '.panel-collapse-btn' &&
|
||||
node.className.split(' ').includes('panel-collapse-btn'),
|
||||
focus() {
|
||||
document.activeElement = node;
|
||||
},
|
||||
blur() {
|
||||
if (document.activeElement === node)
|
||||
document.activeElement = document.body;
|
||||
},
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
const PANEL_IDS = [...new Set(Object.values(MOBILE_SHEET_PANELS).flat())];
|
||||
|
||||
function fixture({
|
||||
mobile = true,
|
||||
expanded = [],
|
||||
onTabOpened = () => {},
|
||||
} = {}) {
|
||||
const document = new EventTarget();
|
||||
document.ids = new Map();
|
||||
document.createElement = (tag) => element(tag, document);
|
||||
document.getElementById = (id) => document.ids.get(id) ?? null;
|
||||
const viewport = element('meta', document);
|
||||
viewport.setAttribute('content', 'width=device-width, initial-scale=1.0');
|
||||
document.querySelector = (selector) =>
|
||||
selector === 'meta[name="viewport"]'
|
||||
? viewport
|
||||
: selector === 'dialog[open]' && document.openDialog
|
||||
? {}
|
||||
: null;
|
||||
document.body = element('body', document);
|
||||
document.activeElement = document.body;
|
||||
const search = element('input', document);
|
||||
search.id = 'location-search';
|
||||
document.body.append(search);
|
||||
const rails = {};
|
||||
for (const id of ['left-panel-stack', 'right-context-rail', 'command-dock']) {
|
||||
rails[id] = element('div', document);
|
||||
rails[id].id = id;
|
||||
document.body.append(rails[id]);
|
||||
}
|
||||
for (const id of PANEL_IDS) {
|
||||
const panel = element('section', document);
|
||||
panel.id = id;
|
||||
if (!expanded.includes(id)) panel.classList.add('collapsed');
|
||||
// A hidden tray toggle and a collapse control first, like the panels'
|
||||
// own chrome in a sheet.
|
||||
const toggle = element('button', document);
|
||||
toggle.hidden = true;
|
||||
const collapse = element('button', document);
|
||||
collapse.className = 'panel-collapse-btn';
|
||||
panel.append(toggle, collapse, element('button', document));
|
||||
const root =
|
||||
id === 'data-panel'
|
||||
? 'left-panel-stack'
|
||||
: ['location-bar', 'control-panel'].includes(id)
|
||||
? 'command-dock'
|
||||
: 'right-context-rail';
|
||||
rails[root].append(panel);
|
||||
}
|
||||
|
||||
const media = new EventTarget();
|
||||
media.matches = mobile;
|
||||
const calls = [];
|
||||
const frames = [];
|
||||
let closedCount = 0;
|
||||
let observed = null;
|
||||
class FakeMutationObserver {
|
||||
constructor(callback) {
|
||||
observed = callback;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {
|
||||
observed = null;
|
||||
}
|
||||
}
|
||||
const shell = initMobileShell({
|
||||
documentRef: document,
|
||||
onTabOpened,
|
||||
matchMedia: () => media,
|
||||
MutationObserverImpl: FakeMutationObserver,
|
||||
ResizeObserverImpl: null,
|
||||
requestFrame: (callback) => frames.push(callback),
|
||||
onSheetsClosed: () => {
|
||||
closedCount += 1;
|
||||
},
|
||||
setPanelCollapsed: (panelId, collapsed, options) => {
|
||||
calls.push({ panelId, collapsed, ...options });
|
||||
const panel = document.getElementById(panelId);
|
||||
if (collapsed) panel.classList.add('collapsed');
|
||||
else panel.classList.remove('collapsed');
|
||||
},
|
||||
});
|
||||
const tab = (sheet) =>
|
||||
document
|
||||
.getElementById('mobile-tab-bar')
|
||||
.children.find((button) => button.dataset.mobileSheet === sheet);
|
||||
const press = (sheet) => {
|
||||
const event = new Event('click', { cancelable: true });
|
||||
Object.defineProperty(event, 'target', { value: tab(sheet) });
|
||||
document.getElementById('mobile-tab-bar').dispatchEvent(event);
|
||||
};
|
||||
const resize = (matches) => {
|
||||
media.matches = matches;
|
||||
const event = new Event('change');
|
||||
event.matches = matches;
|
||||
media.dispatchEvent(event);
|
||||
};
|
||||
const setBodyClass = (name) => {
|
||||
document.body.classList.add(name);
|
||||
observed?.();
|
||||
};
|
||||
const collapsed = (id) =>
|
||||
document.getElementById(id).classList.contains('collapsed');
|
||||
const runFrames = () => frames.splice(0).forEach((callback) => callback());
|
||||
return {
|
||||
document,
|
||||
shell,
|
||||
calls,
|
||||
runFrames,
|
||||
closedCount: () => closedCount,
|
||||
press,
|
||||
tab,
|
||||
resize,
|
||||
viewport,
|
||||
search,
|
||||
setBodyClass,
|
||||
collapsed,
|
||||
};
|
||||
}
|
||||
|
||||
test('every tab names a sheet and every sheet panel maps back to its sheet', () => {
|
||||
assert.deepEqual(
|
||||
MOBILE_TABS.map(({ sheet }) => sheet),
|
||||
Object.keys(MOBILE_SHEET_PANELS),
|
||||
);
|
||||
for (const [sheet, panels] of Object.entries(MOBILE_SHEET_PANELS))
|
||||
for (const panelId of panels) assert.equal(sheetForPanelId(panelId), sheet);
|
||||
assert.equal(sheetForPanelId('unknown-panel'), null);
|
||||
});
|
||||
|
||||
test('pressing a tab opens its sheet, pressing it again closes it', () => {
|
||||
assert.equal(nextSheet(null, 'layers'), 'layers');
|
||||
assert.equal(nextSheet('layers', 'layers'), null);
|
||||
assert.equal(nextSheet('layers', 'more'), 'more');
|
||||
assert.equal(nextSheet('layers', 'nonsense'), null);
|
||||
assert.equal(nextSheet(null, 'constructor'), null);
|
||||
});
|
||||
|
||||
test('one sheet at a time, presented with aria-expanded and a backdrop', () => {
|
||||
const { document, shell, press, tab } = fixture();
|
||||
assert.equal(document.body.classList.contains('mobile-shell'), true);
|
||||
press('layers');
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
assert.equal(document.body.getAttribute('data-mobile-sheet'), 'layers');
|
||||
assert.equal(tab('layers').getAttribute('aria-expanded'), 'true');
|
||||
assert.equal(document.getElementById('mobile-sheet-backdrop').hidden, false);
|
||||
press('more');
|
||||
assert.equal(shell.getSheet(), 'more');
|
||||
assert.equal(tab('layers').getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(tab('more').getAttribute('aria-expanded'), 'true');
|
||||
press('more');
|
||||
assert.equal(shell.getSheet(), null);
|
||||
assert.equal(document.body.getAttribute('data-mobile-sheet'), null);
|
||||
assert.equal(document.getElementById('mobile-sheet-backdrop').hidden, true);
|
||||
});
|
||||
|
||||
test('sheets borrow panels and put them back exactly as they were', () => {
|
||||
// The user left Data Layers and Presets open on desktop.
|
||||
const kept = ['data-panel', 'control-panel'];
|
||||
const { calls, press, collapsed } = fixture({ expanded: kept });
|
||||
press('cameras');
|
||||
assert.equal(collapsed('cctv-panel'), false);
|
||||
assert.equal(collapsed('street-level-panel'), false);
|
||||
press('search');
|
||||
assert.equal(collapsed('cctv-panel'), true, 'Cameras put back on switch');
|
||||
assert.equal(collapsed('location-bar'), false);
|
||||
press('search');
|
||||
for (const id of PANEL_IDS)
|
||||
assert.equal(collapsed(id), !kept.includes(id), `${id} is back as it was`);
|
||||
// Nothing a sheet does is the user's choice, persisted or shared.
|
||||
assert.ok(calls.length > 0);
|
||||
for (const call of calls) {
|
||||
assert.equal(call.explicit, undefined, `${call.panelId} is not explicit`);
|
||||
assert.equal(call.persist, false);
|
||||
assert.equal(call.syncShare, false);
|
||||
}
|
||||
// Already-open panels are not touched.
|
||||
assert.ok(!calls.some(({ panelId }) => kept.includes(panelId)));
|
||||
});
|
||||
|
||||
test('a panel the user changes inside a sheet stays as they left it', () => {
|
||||
const { press, collapsed, document } = fixture();
|
||||
press('cameras');
|
||||
// The user collapses CCTV inside the sheet (their own, persisted choice).
|
||||
document.getElementById('cctv-panel').classList.add('collapsed');
|
||||
press('cameras');
|
||||
assert.equal(collapsed('cctv-panel'), true, 'their collapse is kept');
|
||||
assert.equal(
|
||||
collapsed('street-level-panel'),
|
||||
true,
|
||||
'the borrowed one is put back',
|
||||
);
|
||||
|
||||
// Expanding one in a sheet is kept too, and later sheets respect it.
|
||||
press('layers');
|
||||
document.getElementById('cctv-panel').classList.remove('collapsed');
|
||||
press('more');
|
||||
press('more');
|
||||
assert.equal(collapsed('cctv-panel'), false);
|
||||
});
|
||||
|
||||
test('focus moves into the sheet and returns to the tab on close', () => {
|
||||
const { document, press, search, tab } = fixture();
|
||||
press('search');
|
||||
assert.equal(document.activeElement, search);
|
||||
press('search');
|
||||
assert.equal(document.activeElement, tab('search'));
|
||||
|
||||
press('layers');
|
||||
const dataPanel = document.getElementById('data-panel');
|
||||
// The first visible control that is not the panel's own collapse button.
|
||||
assert.equal(document.activeElement, dataPanel.children[2]);
|
||||
const escape = new Event('keydown', { cancelable: true });
|
||||
escape.key = 'Escape';
|
||||
document.dispatchEvent(escape);
|
||||
assert.equal(escape.defaultPrevented, true);
|
||||
assert.equal(document.activeElement, tab('layers'));
|
||||
});
|
||||
|
||||
test('the share link keeps the state from before the sheets', () => {
|
||||
const { shell, press, document, closedCount } = fixture({
|
||||
expanded: ['street-level-panel'],
|
||||
});
|
||||
assert.equal(shell.sharedCollapsed('data-panel'), undefined);
|
||||
press('layers');
|
||||
// Data Layers is open only because the sheet borrowed it.
|
||||
assert.equal(shell.sharedCollapsed('data-panel'), true);
|
||||
assert.equal(shell.sharedCollapsed('street-level-panel'), false);
|
||||
assert.equal(shell.sharedCollapsed('unrelated-panel'), undefined);
|
||||
// A user change inside the sheet is theirs to share.
|
||||
document.getElementById('cctv-panel').classList.remove('collapsed');
|
||||
assert.equal(shell.sharedCollapsed('cctv-panel'), false);
|
||||
press('more');
|
||||
assert.equal(closedCount(), 0, 'switching sheets is not closing');
|
||||
press('more');
|
||||
assert.equal(closedCount(), 1, 'closing refreshes the share link once');
|
||||
assert.equal(shell.sharedCollapsed('data-panel'), undefined);
|
||||
});
|
||||
|
||||
test('Escape closes the sheet only when nothing else took it', () => {
|
||||
const { document, shell, press } = fixture();
|
||||
const escape = () => {
|
||||
const event = new Event('keydown', { cancelable: true, bubbles: true });
|
||||
event.key = 'Escape';
|
||||
return event;
|
||||
};
|
||||
press('layers');
|
||||
// A field cancelling its edit (or a lightbox closing) handles it first.
|
||||
const handled = escape();
|
||||
handled.preventDefault();
|
||||
document.dispatchEvent(handled);
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
// An open dialog keeps Escape for its native close.
|
||||
document.openDialog = true;
|
||||
document.dispatchEvent(escape());
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
document.openDialog = false;
|
||||
const plain = escape();
|
||||
document.dispatchEvent(plain);
|
||||
assert.equal(shell.getSheet(), null);
|
||||
assert.equal(plain.defaultPrevented, true);
|
||||
});
|
||||
|
||||
test('share state and restores follow the preference, not layout collapse', () => {
|
||||
const { shell, press, collapsed, document } = fixture();
|
||||
// Layout collapsed Street Level only for space; the user left it open.
|
||||
const street = document.getElementById('street-level-panel');
|
||||
street.classList.add('layout-auto-collapsed');
|
||||
press('cameras');
|
||||
assert.equal(shell.sharedCollapsed('street-level-panel'), false);
|
||||
press('cameras');
|
||||
assert.equal(
|
||||
collapsed('street-level-panel'),
|
||||
false,
|
||||
'not turned into a real collapse',
|
||||
);
|
||||
});
|
||||
|
||||
test('layout settling after a sheet opens is part of opening', () => {
|
||||
const { press, runFrames, collapsed, document } = fixture({
|
||||
expanded: ['street-level-panel'],
|
||||
});
|
||||
press('cameras');
|
||||
// A rail layout pass on the next frame collapses Street Level.
|
||||
document.getElementById('street-level-panel').classList.add('collapsed');
|
||||
runFrames();
|
||||
press('cameras');
|
||||
assert.equal(collapsed('street-level-panel'), false, 'put back as it was');
|
||||
});
|
||||
|
||||
test('the backdrop closes the open sheet', () => {
|
||||
const { document, shell, press } = fixture();
|
||||
press('context');
|
||||
document
|
||||
.getElementById('mobile-sheet-backdrop')
|
||||
.dispatchEvent(new Event('click'));
|
||||
assert.equal(shell.getSheet(), null);
|
||||
});
|
||||
|
||||
test('surfaces that own the screen close sheets; scene playback does not', () => {
|
||||
for (const mode of ['cockpit-mode', 'recording-mode', 'ui-clean-view']) {
|
||||
const { shell, press, setBodyClass, collapsed } = fixture();
|
||||
press('layers');
|
||||
setBodyClass(mode);
|
||||
assert.equal(shell.getSheet(), null, `${mode} closes the sheet`);
|
||||
assert.equal(collapsed('data-panel'), true, `${mode} puts panels back`);
|
||||
press('layers');
|
||||
assert.equal(shell.getSheet(), null, `${mode} keeps sheets closed`);
|
||||
}
|
||||
// Scene STOP lives in the Scenes panel of the Layers sheet.
|
||||
const playback = fixture();
|
||||
playback.setBodyClass('scene-playback-mode');
|
||||
playback.press('layers');
|
||||
assert.equal(playback.shell.getSheet(), 'layers');
|
||||
});
|
||||
|
||||
test('desktop widths never see the shell; crossing to desktop restores it', () => {
|
||||
const desktop = fixture({ mobile: false });
|
||||
assert.equal(desktop.document.body.classList.contains('mobile-shell'), false);
|
||||
desktop.press('layers');
|
||||
assert.equal(desktop.shell.getSheet(), null);
|
||||
assert.equal(
|
||||
desktop.viewport.getAttribute('content'),
|
||||
'width=device-width, initial-scale=1.0',
|
||||
);
|
||||
|
||||
const phone = fixture({ expanded: ['street-level-panel'] });
|
||||
assert.match(phone.viewport.getAttribute('content'), /viewport-fit=cover$/);
|
||||
phone.press('cameras');
|
||||
phone.resize(false);
|
||||
assert.equal(phone.shell.getSheet(), null);
|
||||
assert.equal(phone.document.body.classList.contains('mobile-shell'), false);
|
||||
assert.equal(phone.collapsed('cctv-panel'), true);
|
||||
assert.equal(phone.collapsed('street-level-panel'), false);
|
||||
assert.equal(
|
||||
phone.viewport.getAttribute('content'),
|
||||
'width=device-width, initial-scale=1.0',
|
||||
);
|
||||
});
|
||||
|
||||
test('destroy puts panels back and removes the shell', () => {
|
||||
const { document, shell, press, viewport, collapsed } = fixture();
|
||||
press('layers');
|
||||
shell.destroy();
|
||||
assert.equal(collapsed('data-panel'), true);
|
||||
assert.equal(document.getElementById('mobile-tab-bar'), null);
|
||||
assert.equal(document.getElementById('mobile-sheet-backdrop'), null);
|
||||
assert.equal(document.body.classList.contains('mobile-shell'), false);
|
||||
assert.equal(document.body.getAttribute('data-mobile-sheet'), null);
|
||||
assert.equal(document.body.style.values.size, 0);
|
||||
assert.equal(
|
||||
viewport.getAttribute('content'),
|
||||
'width=device-width, initial-scale=1.0',
|
||||
);
|
||||
});
|
||||
|
||||
test('the phone shell keeps the required attribution visible', () => {
|
||||
// Whitespace-normalised, so formatter line wrapping cannot hide a rule.
|
||||
const css = readFileSync(
|
||||
new URL('./styles/mobile-shell.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\( /g, '(')
|
||||
.replace(/ \)/g, ')');
|
||||
/** The declaration block of the rule whose selector list ends with `selector`. */
|
||||
const rule = (selector) => {
|
||||
const end = css.indexOf(`${selector} {`);
|
||||
assert.notEqual(end, -1, `missing rule ending in ${selector}`);
|
||||
const open = css.indexOf('{', end);
|
||||
return css.slice(open, css.indexOf('}', open));
|
||||
};
|
||||
const lift = (block) =>
|
||||
Number(
|
||||
/bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px\)/.exec(block)?.[1],
|
||||
);
|
||||
const z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]);
|
||||
const idleCredit = rule(
|
||||
'body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) #cesium-credits',
|
||||
);
|
||||
const dock = rule('body.mobile-shell #command-dock');
|
||||
const voice = rule('body.mobile-shell #command-dock #gev-voice-control');
|
||||
const voiceHeightPx = Number(/height: ([\d.]+)rem/.exec(voice)[1]) * 16;
|
||||
// Idle: the credit line clears the voice bar, which clears the tab bar.
|
||||
assert.ok(lift(idleCredit) > lift(dock) + voiceHeightPx);
|
||||
// It must beat the dock's :has() placements, so the offset is !important.
|
||||
assert.match(idleCredit, /\) !important;/);
|
||||
assert.doesNotMatch(
|
||||
idleCredit,
|
||||
/display:\s*none|visibility:\s*hidden|opacity:\s*0/,
|
||||
);
|
||||
// Open sheet: above everything the sheet covers, drawn above the sheets.
|
||||
const sheetCredit = rule(
|
||||
'body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) #cesium-credits',
|
||||
);
|
||||
assert.match(
|
||||
sheetCredit,
|
||||
/bottom: calc\(var\(--mobile-sheet-cover, 0px\) \+ 6px\) !important;/,
|
||||
);
|
||||
const openDock = rule('body.mobile-shell[data-mobile-sheet] #command-dock');
|
||||
const railSheet = rule(
|
||||
"body.mobile-shell[data-mobile-sheet='more'] #right-context-rail",
|
||||
);
|
||||
assert.ok(z(sheetCredit) > z(openDock));
|
||||
assert.ok(z(sheetCredit) > z(railSheet));
|
||||
});
|
||||
|
||||
function touchEvent(
|
||||
document,
|
||||
type,
|
||||
target,
|
||||
{ x = 10, y = 10, count = 1, id = 1, cancelable = true } = {},
|
||||
) {
|
||||
const event = new Event(type, { cancelable });
|
||||
const touch = { identifier: id, clientX: x, clientY: y };
|
||||
Object.defineProperty(event, 'target', { value: target });
|
||||
event.touches =
|
||||
type === 'touchend' || type === 'touchcancel'
|
||||
? []
|
||||
: Array.from({ length: count }, (_, i) => ({
|
||||
...touch,
|
||||
identifier: id + i,
|
||||
}));
|
||||
event.changedTouches = [touch];
|
||||
document.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
test('downward sheet pull closes through borrowed-panel lifecycle and returns tab focus', () => {
|
||||
for (const sheet of Object.keys(MOBILE_SHEET_PANELS)) {
|
||||
const phone = fixture();
|
||||
const { document, shell, press, tab } = phone;
|
||||
press(sheet);
|
||||
const target = document.getElementById(MOBILE_SHEET_PANELS[sheet][0]);
|
||||
touchEvent(document, 'touchstart', target);
|
||||
const move = touchEvent(document, 'touchmove', target, { y: 90 });
|
||||
assert.equal(move.defaultPrevented, true);
|
||||
assert.equal(shell.getSheet(), sheet, 'waits until finger lifts');
|
||||
touchEvent(document, 'touchend', target, { y: 90 });
|
||||
assert.equal(shell.getSheet(), null);
|
||||
assert.equal(phone.closedCount(), 1);
|
||||
assert.equal(document.activeElement, tab(sheet));
|
||||
for (const id of MOBILE_SHEET_PANELS[sheet])
|
||||
assert.equal(phone.collapsed(id), true);
|
||||
assert.ok(
|
||||
phone.calls.every(
|
||||
(call) => call.persist === false && call.syncShare === false,
|
||||
),
|
||||
);
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('content scroll stays native, including a scroll that reaches the top mid-gesture', () => {
|
||||
for (const nested of [false, true]) {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
const scroller = nested
|
||||
? panel
|
||||
: document.getElementById('left-panel-stack');
|
||||
scroller.scrollTop = 40;
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
scroller.scrollTop = 0;
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('taps, short pulls, upward and horizontal gestures never dismiss', () => {
|
||||
for (const path of [
|
||||
[],
|
||||
[{ x: 12, y: 14 }],
|
||||
[{ y: 40 }],
|
||||
[{ y: -30 }, { y: 120 }],
|
||||
[{ x: 60, y: 12 }, { y: 120 }],
|
||||
]) {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
for (const point of path) touchEvent(document, 'touchmove', panel, point);
|
||||
touchEvent(document, 'touchend', panel, path.at(-1) ?? {});
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('native controls and custom sliders keep touch gestures', () => {
|
||||
for (const tag of [
|
||||
'button',
|
||||
'a',
|
||||
'input',
|
||||
'select',
|
||||
'textarea',
|
||||
'label',
|
||||
'slider',
|
||||
'editable',
|
||||
]) {
|
||||
const { document, shell, press } = fixture();
|
||||
press('more');
|
||||
const control = document.createElement(
|
||||
['slider', 'editable'].includes(tag) ? 'div' : tag,
|
||||
);
|
||||
if (tag === 'slider') control.setAttribute('role', 'slider');
|
||||
if (tag === 'editable') control.setAttribute('contenteditable', 'true');
|
||||
const child = document.createElement('span');
|
||||
control.append(child);
|
||||
document.getElementById('control-panel').append(control);
|
||||
touchEvent(document, 'touchstart', child);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', child, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', child, { y: 120 });
|
||||
assert.equal(shell.getSheet(), 'more');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('multitouch, cancellation, sheet changes and desktop transitions abandon the gesture', () => {
|
||||
for (const cancel of [
|
||||
'multitouch',
|
||||
'cancel',
|
||||
'sheet',
|
||||
'desktop',
|
||||
'noncancelable',
|
||||
]) {
|
||||
const { document, shell, press, resize } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
if (cancel === 'multitouch')
|
||||
touchEvent(document, 'touchstart', document.body, { count: 2 });
|
||||
if (cancel === 'cancel') touchEvent(document, 'touchcancel', panel);
|
||||
if (cancel === 'sheet') press('context');
|
||||
if (cancel === 'desktop') {
|
||||
resize(false);
|
||||
resize(true);
|
||||
press('layers');
|
||||
}
|
||||
if (cancel === 'noncancelable')
|
||||
touchEvent(document, 'touchmove', panel, { y: 30, cancelable: false });
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), cancel === 'sheet' ? 'context' : 'layers');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('touches outside the active sheet and destroyed listeners cannot dismiss or claim a gesture', () => {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const hiddenPanel = document.getElementById('global-context-panel');
|
||||
touchEvent(document, 'touchstart', hiddenPanel);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', hiddenPanel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', hiddenPanel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
shell.destroy();
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), null);
|
||||
});
|
||||
|
||||
test('the tab-open callback reports only a successful manual opening', () => {
|
||||
const opened = [];
|
||||
const phone = fixture({ onTabOpened: (sheet) => opened.push(sheet) });
|
||||
phone.shell.setSheet('context');
|
||||
assert.deepEqual(opened, []);
|
||||
phone.press('context');
|
||||
assert.deepEqual(opened, [], 'closing is not a new context request');
|
||||
phone.press('context');
|
||||
assert.deepEqual(opened, ['context']);
|
||||
phone.setBodyClass('cockpit-mode');
|
||||
phone.press('context');
|
||||
assert.deepEqual(
|
||||
opened,
|
||||
['context'],
|
||||
'blocked sheets cannot activate a mode',
|
||||
);
|
||||
phone.shell.destroy();
|
||||
});
|
||||
|
||||
test('manual Context opens the existing transaction without disabling the tracked aircraft', async () => {
|
||||
const calls = [];
|
||||
const tracked = { layerId: 'flights', icao24: 'abc123', callsign: 'EJA225' };
|
||||
const viewer = { trackedEntity: { gevTrackedId: 'flights:abc123' } };
|
||||
const entity = viewer.trackedEntity;
|
||||
const enabled = new Set(['flights', 'weather']);
|
||||
const controls = new ContextControls({
|
||||
elements: {},
|
||||
installations: {},
|
||||
actions: {
|
||||
getCockpit: () => null,
|
||||
claimVisualAuthority: () => calls.push('claim'),
|
||||
syncDetection() {},
|
||||
scheduleLayout() {},
|
||||
showToast() {},
|
||||
},
|
||||
});
|
||||
controls.connect({
|
||||
layers: new Map(
|
||||
['flights', 'weather', 'military-awareness'].map((id) => [id, {}]),
|
||||
),
|
||||
getEnabledLayerIds: () => [...enabled],
|
||||
getLayerParams: () => ({}),
|
||||
isEffectivelyEnabled: (id) => enabled.has(id),
|
||||
isEnabled: (id) => enabled.has(id),
|
||||
setLayerParams: (id, params) => calls.push({ id, params }),
|
||||
setEnabled: async (id, value) => {
|
||||
calls.push({ id, value });
|
||||
if (!value) enabled.delete(id);
|
||||
if (id === 'flights' && !value) viewer.trackedEntity = null;
|
||||
return true;
|
||||
},
|
||||
_setEnabledWithIntent: (id, value) => {
|
||||
calls.push({ id, value });
|
||||
enabled.add(id);
|
||||
return { intentEpoch: 1, promise: Promise.resolve(true) };
|
||||
},
|
||||
});
|
||||
const shell = {
|
||||
cockpitView: {
|
||||
readAircraftInfo: () => (viewer.trackedEntity ? tracked : null),
|
||||
},
|
||||
getAircraftTrackingTarget: StyleManager.prototype.getAircraftTrackingTarget,
|
||||
getContextModeState: () => controls.getContextModeState(),
|
||||
setContextMode: (mode) => controls.setContextMode(mode),
|
||||
_showToast: () => assert.fail('successful entry must not report a failure'),
|
||||
};
|
||||
try {
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
|
||||
true,
|
||||
);
|
||||
assert.equal(controls.getContextModeState().mode, 'flights');
|
||||
assert.equal(viewer.trackedEntity, entity);
|
||||
assert.deepEqual(shell.getAircraftTrackingTarget(), {
|
||||
layerId: 'flights',
|
||||
id: 'abc123',
|
||||
});
|
||||
assert.ok(enabled.has('flights'));
|
||||
assert.ok(
|
||||
calls.some(
|
||||
(call) => call.id === 'military-awareness' && call.value === true,
|
||||
),
|
||||
);
|
||||
assert.ok(!calls.some((call) => call.id === 'flights'));
|
||||
const count = calls.length;
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
calls.length,
|
||||
count,
|
||||
'an already active Contacts session is not restarted',
|
||||
);
|
||||
} finally {
|
||||
controls.stop();
|
||||
controls.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('manual Context respects Space Missions, busy transitions, missing tracks and disposal', async () => {
|
||||
for (const state of [
|
||||
{ mode: 'space-missions' },
|
||||
{ mode: 'flights' },
|
||||
{ changing: true },
|
||||
{ entering: 'space-missions' },
|
||||
{ noTrack: true },
|
||||
{ disposed: true },
|
||||
{ sheet: 'layers' },
|
||||
]) {
|
||||
const shell = {
|
||||
_disposed: state.disposed,
|
||||
getContextModeState: () => state,
|
||||
getAircraftTrackingTarget: () =>
|
||||
state.noTrack ? null : { layerId: 'flights', id: 'abc123' },
|
||||
setContextMode: () =>
|
||||
assert.fail('guarded tab must not mutate Context or tracking'),
|
||||
};
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(
|
||||
shell,
|
||||
state.sheet || 'context',
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('failed mobile Contacts entry uses the existing notice and cancelled/disposed entries stay quiet', async () => {
|
||||
for (const outcome of ['failure', 'throw', 'cancelled', 'disposed']) {
|
||||
const notices = [];
|
||||
const shell = {
|
||||
getContextModeState: () => ({ mode: null }),
|
||||
getAircraftTrackingTarget: () => ({ layerId: 'flights', id: 'abc123' }),
|
||||
setContextMode: async () => {
|
||||
if (outcome === 'throw') throw new Error('source unavailable');
|
||||
if (outcome === 'disposed') shell._disposed = true;
|
||||
return { ok: false, cancelled: outcome === 'cancelled' };
|
||||
},
|
||||
_showToast: (message) => notices.push(message),
|
||||
};
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
notices.length,
|
||||
['failure', 'throw'].includes(outcome) ? 1 : 0,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a slow downward pull is claimed before Android native scroll takes ownership', () => {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel, { y: 100 });
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 102 }).defaultPrevented,
|
||||
false,
|
||||
'tap jitter is left native',
|
||||
);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 109.3 }).defaultPrevented,
|
||||
true,
|
||||
'cancel the first directional move while Android still allows it',
|
||||
);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 131 }).defaultPrevented,
|
||||
true,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 175 });
|
||||
assert.equal(shell.getSheet(), null);
|
||||
shell.destroy();
|
||||
});
|
||||
@@ -14,6 +14,7 @@ export class NavigationController {
|
||||
tracking,
|
||||
searchInput,
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation = () => {},
|
||||
isCockpitActive,
|
||||
clearLocation,
|
||||
cancelShareSelection,
|
||||
@@ -27,6 +28,7 @@ export class NavigationController {
|
||||
tracking,
|
||||
searchInput,
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation,
|
||||
isCockpitActive,
|
||||
clearLocation,
|
||||
cancelShareSelection,
|
||||
@@ -46,6 +48,7 @@ export class NavigationController {
|
||||
cancelPendingSelection = true,
|
||||
clearSearchedLocation = true,
|
||||
} = {}) {
|
||||
this.interruptGlobeRotation('navigation-intent');
|
||||
cancelCameraArrival(this.viewer);
|
||||
this.cancelOrientation();
|
||||
const { flightsLayer, militaryFlightsLayer, satellitesLayer } =
|
||||
@@ -116,10 +119,17 @@ export class NavigationController {
|
||||
return this._navigationGeneration;
|
||||
}
|
||||
|
||||
_settleLocationSearchUi(generation) {
|
||||
/** A search that found nothing keeps its query and focus so it can be corrected. */
|
||||
_settleLocationSearchUi(generation, { keepQuery = false } = {}) {
|
||||
if (this._activeLocationSearchGeneration !== generation) return;
|
||||
this._activeLocationSearchGeneration = null;
|
||||
this.searchInput?.classList.remove('searching', 'expanded');
|
||||
this.searchInput?.classList.remove('searching');
|
||||
if (keepQuery) {
|
||||
this.searchInput?.focus();
|
||||
this.searchInput?.select?.();
|
||||
return;
|
||||
}
|
||||
this.searchInput?.classList.remove('expanded');
|
||||
if (this.searchInput) this.searchInput.value = '';
|
||||
this.searchInput?.blur();
|
||||
}
|
||||
@@ -238,7 +248,7 @@ export class NavigationController {
|
||||
});
|
||||
}
|
||||
|
||||
_reassertNavigationHandoff(generation) {
|
||||
_reassertNavigationHandoff(generation, { trackingOrigin = 'tool' } = {}) {
|
||||
return reassertNavigationHandoff({
|
||||
generation,
|
||||
currentGeneration: this._navigationGeneration,
|
||||
@@ -250,7 +260,7 @@ export class NavigationController {
|
||||
// was refused by the cockpit leaves the old readout standing.
|
||||
release: () => {
|
||||
this.clearLocation();
|
||||
return this._releaseFollowCamera();
|
||||
return this._releaseFollowCamera({ trackingOrigin });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+40
-20
@@ -46,6 +46,7 @@ export class PanelChrome {
|
||||
readInitialShare,
|
||||
readScrollRestoreOwner,
|
||||
readDisplayScrollTop,
|
||||
readSharedCollapsed = () => undefined,
|
||||
}) {
|
||||
Object.assign(this, elements, operations, {
|
||||
readHud,
|
||||
@@ -54,6 +55,7 @@ export class PanelChrome {
|
||||
readInitialShare,
|
||||
readScrollRestoreOwner,
|
||||
readDisplayScrollTop,
|
||||
readSharedCollapsed,
|
||||
});
|
||||
this._disposed = false;
|
||||
this._lifetime = new UiLifetime();
|
||||
@@ -61,10 +63,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: () => ({
|
||||
@@ -72,7 +71,8 @@ export class PanelChrome {
|
||||
variant: this.hud.getVariant(),
|
||||
}),
|
||||
scheduleCockpitLayout: () => this.cockpitView?.scheduleContextLayout(),
|
||||
syncPanelCollapseButton: (panel) => this._syncPanelCollapseButton(panel),
|
||||
syncPanelCollapseButton: (panel) =>
|
||||
this._syncRailPanelCollapseButton(panel),
|
||||
readDisplayScrollTop: () =>
|
||||
this._displayPortalScrollRestoreOwner === 'standard'
|
||||
? this._standardDisplayScrollTop
|
||||
@@ -148,10 +148,12 @@ export class PanelChrome {
|
||||
this._initCommandDockPins();
|
||||
this._initCommandDockTrayMetrics();
|
||||
this._maybeNotifyLayoutReset();
|
||||
this._panelPosition._initPanelDrag();
|
||||
}
|
||||
|
||||
_collapsePanelOnEscape(event, panelId) {
|
||||
// In a phone sheet, Escape closes the sheet (mobileShell.js), not a panel.
|
||||
if (globalThis.document?.body?.hasAttribute?.('data-mobile-sheet'))
|
||||
return false;
|
||||
return collapsePanelOnEscape(event, {
|
||||
panel: document.getElementById(panelId),
|
||||
onChange: (collapsed, options) =>
|
||||
@@ -253,7 +255,10 @@ export class PanelChrome {
|
||||
disclosure: panel.querySelector(`[data-dock-toggle-target="${panelId}"]`),
|
||||
openDelayMs,
|
||||
closeDelayMs,
|
||||
isActive: () => !this._disposed,
|
||||
// A phone sheet holds its trays open; the tab bar closes them.
|
||||
isActive: () =>
|
||||
!this._disposed &&
|
||||
!globalThis.document?.body?.hasAttribute?.('data-mobile-sheet'),
|
||||
onChange: (collapsed, options) =>
|
||||
this.setPanelCollapsed(panelId, collapsed, options),
|
||||
onEscape: (event) => this._collapsePanelOnEscape(event, panelId),
|
||||
@@ -287,6 +292,19 @@ export class PanelChrome {
|
||||
return this._panelLayout._scheduleLeftPanelLayout(options0);
|
||||
}
|
||||
|
||||
_syncRailPanelCollapseButton(panelEl) {
|
||||
// Adaptive rail layout may directly restore an auto-collapsed Context
|
||||
// without invoking setPanelCollapsed(). Enforce Radio ownership there too.
|
||||
if (
|
||||
panelEl?.id === 'global-context-panel' &&
|
||||
!panelEl.classList.contains('collapsed') &&
|
||||
this._contextRadioDock?.classList.contains('disclosure-open')
|
||||
) {
|
||||
this._setRadioDisclosure?.(false);
|
||||
}
|
||||
this._syncPanelCollapseButton(panelEl);
|
||||
}
|
||||
|
||||
_syncPanelCollapseButton(panelEl) {
|
||||
const isRightRail = [
|
||||
'pp-toggles',
|
||||
@@ -370,7 +388,11 @@ export class PanelChrome {
|
||||
panelEl.classList.contains('cyber-accordion-collapsed')
|
||||
? false
|
||||
: panelEl.classList.contains('collapsed');
|
||||
const entry = { id: spec.id, collapsed };
|
||||
// A panel the phone shell only borrowed for a sheet shares its own state.
|
||||
const entry = {
|
||||
id: spec.id,
|
||||
collapsed: this.readSharedCollapsed(spec.id) ?? collapsed,
|
||||
};
|
||||
if (spec.pinnable)
|
||||
entry.pinned = panelEl.classList.contains('dock-pinned');
|
||||
specs.push(entry);
|
||||
@@ -402,11 +424,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,
|
||||
@@ -429,20 +446,19 @@ 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')
|
||||
// Phone sheets show several rail panels at once.
|
||||
!document.body?.classList?.contains('mobile-shell')
|
||||
) {
|
||||
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, {
|
||||
@@ -501,6 +517,15 @@ export class PanelChrome {
|
||||
syncShare,
|
||||
});
|
||||
}
|
||||
// A repeated Context-open request must clear a stale compact Radio mini
|
||||
// even when no collapsed class changes.
|
||||
if (
|
||||
!nextCollapsed &&
|
||||
panelId === 'global-context-panel' &&
|
||||
this._contextRadioDock?.classList.contains('disclosure-open')
|
||||
) {
|
||||
this._setRadioDisclosure?.(false);
|
||||
}
|
||||
if (
|
||||
panelEl.classList.contains('collapsed') === nextCollapsed &&
|
||||
!wasAutoCollapsed
|
||||
@@ -570,10 +595,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 &&
|
||||
@@ -643,7 +664,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();
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { PanelChrome } from './panelChrome.js';
|
||||
import { PanelLayoutController } from './panelLayoutController.js';
|
||||
|
||||
function classList(initial = []) {
|
||||
const values = new Set(initial);
|
||||
return {
|
||||
contains: (value) => values.has(value),
|
||||
add: (...items) => items.forEach((item) => values.add(item)),
|
||||
remove: (...items) => items.forEach((item) => values.delete(item)),
|
||||
toggle(value, force) {
|
||||
if (force === true) values.add(value);
|
||||
else if (force === false) values.delete(value);
|
||||
else if (values.has(value)) values.delete(value);
|
||||
else values.add(value);
|
||||
return values.has(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('opening an already-open Context still closes a stale compact Radio disclosure', (t) => {
|
||||
const priorDocument = globalThis.document;
|
||||
const contextPanel = {
|
||||
id: 'global-context-panel',
|
||||
classList: classList(),
|
||||
};
|
||||
globalThis.document = {
|
||||
documentElement: { dataset: { uiTheme: 'tactical' } },
|
||||
getElementById(id) {
|
||||
return id === 'global-context-panel' ? contextPanel : null;
|
||||
},
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.document = priorDocument;
|
||||
});
|
||||
|
||||
const radioDock = { classList: classList(['disclosure-open']) };
|
||||
const calls = [];
|
||||
const owner = {
|
||||
_contextRadioDock: radioDock,
|
||||
_setRadioDisclosure(open) {
|
||||
calls.push(open);
|
||||
radioDock.classList.toggle('disclosure-open', open);
|
||||
},
|
||||
_leftPanelStack: null,
|
||||
_rightPanelStack: null,
|
||||
_panelLayout: {
|
||||
_leftStackPreferredPanelId: null,
|
||||
_rightStackPreferredPanelId: null,
|
||||
},
|
||||
_syncPanelCollapseButton() {},
|
||||
_scheduleLeftPanelLayout() {},
|
||||
_scheduleRightPanelLayout() {},
|
||||
shareLinkManager: null,
|
||||
cockpitView: null,
|
||||
};
|
||||
|
||||
PanelChrome.prototype.setPanelCollapsed.call(
|
||||
owner,
|
||||
'global-context-panel',
|
||||
false,
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.equal(radioDock.classList.contains('disclosure-open'), false);
|
||||
});
|
||||
|
||||
test('adaptive rail restore hides compact Radio before revealing Context', (t) => {
|
||||
const priorRaf = globalThis.requestAnimationFrame;
|
||||
let nextFrame;
|
||||
globalThis.requestAnimationFrame = (callback) => {
|
||||
nextFrame = callback;
|
||||
return 1;
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.requestAnimationFrame = priorRaf;
|
||||
});
|
||||
|
||||
// Valid starting state: the tactical rail automatically collapsed Context,
|
||||
// and the user opened the compact Radio controls from its header.
|
||||
const contextPanel = {
|
||||
id: 'global-context-panel',
|
||||
classList: classList(['collapsed', 'layout-auto-collapsed']),
|
||||
};
|
||||
const radioDock = { classList: classList(['disclosure-open']) };
|
||||
const radioMini = { hidden: false };
|
||||
const calls = [];
|
||||
const chrome = {
|
||||
_contextRadioDock: radioDock,
|
||||
_setRadioDisclosure(open) {
|
||||
calls.push(`disclosure:${open}`);
|
||||
radioDock.classList.toggle('disclosure-open', open);
|
||||
radioMini.hidden = !open;
|
||||
},
|
||||
_syncPanelCollapseButton(panel) {
|
||||
calls.push(`button:${panel.id}`);
|
||||
},
|
||||
};
|
||||
const layout = {
|
||||
destroyed: false,
|
||||
_rightStackLayoutFrame: null,
|
||||
_rightStackReconsiderAutoCollapse: false,
|
||||
_rightPanelStack: {
|
||||
querySelectorAll: (selector) =>
|
||||
selector === '.layout-auto-collapsed' ? [contextPanel] : [],
|
||||
},
|
||||
_syncPanelCollapseButton(panel) {
|
||||
PanelChrome.prototype._syncRailPanelCollapseButton.call(chrome, panel);
|
||||
},
|
||||
_syncRightPanelAdaptiveLayout() {
|
||||
calls.push('layout');
|
||||
},
|
||||
};
|
||||
|
||||
PanelLayoutController.prototype._scheduleRightPanelLayout.call(layout, {
|
||||
reconsiderAutoCollapse: true,
|
||||
});
|
||||
assert.equal(contextPanel.classList.contains('collapsed'), true);
|
||||
assert.equal(radioMini.hidden, false);
|
||||
nextFrame();
|
||||
|
||||
assert.equal(contextPanel.classList.contains('collapsed'), false);
|
||||
assert.equal(contextPanel.classList.contains('layout-auto-collapsed'), false);
|
||||
assert.equal(radioDock.classList.contains('disclosure-open'), false);
|
||||
assert.equal(radioMini.hidden, true);
|
||||
assert.deepEqual(calls, [
|
||||
'disclosure:false',
|
||||
'button:global-context-panel',
|
||||
'layout',
|
||||
]);
|
||||
});
|
||||
|
||||
test('real PanelChrome constructor wires rail restore to compact Radio owner', (t) => {
|
||||
const priorDocument = globalThis.document;
|
||||
const priorRaf = globalThis.requestAnimationFrame;
|
||||
let nextFrame;
|
||||
const contextPanel = {
|
||||
id: 'global-context-panel',
|
||||
classList: classList(['collapsed', 'layout-auto-collapsed']),
|
||||
};
|
||||
const rightRail = {
|
||||
querySelectorAll(selector) {
|
||||
return selector === '.layout-auto-collapsed' ? [contextPanel] : [];
|
||||
},
|
||||
};
|
||||
globalThis.document = {
|
||||
getElementById(id) {
|
||||
return id === 'right-context-rail' ? rightRail : null;
|
||||
},
|
||||
};
|
||||
globalThis.requestAnimationFrame = (callback) => {
|
||||
nextFrame = callback;
|
||||
return 1;
|
||||
};
|
||||
t.after(() => {
|
||||
globalThis.document = priorDocument;
|
||||
globalThis.requestAnimationFrame = priorRaf;
|
||||
});
|
||||
|
||||
const dock = { classList: classList(['disclosure-open']) };
|
||||
const calls = [];
|
||||
const chrome = new PanelChrome({
|
||||
elements: { _contextRadioDock: dock },
|
||||
operations: {
|
||||
_setRadioDisclosure(open) {
|
||||
calls.push(`disclosure:${open}`);
|
||||
dock.classList.toggle('disclosure-open', open);
|
||||
},
|
||||
_syncPanelCollapseButton(panel) {
|
||||
calls.push(`button:${panel.id}`);
|
||||
},
|
||||
},
|
||||
readHud: () => ({ visible: true, getVariant: () => 'tactical' }),
|
||||
readCockpit: () => null,
|
||||
readShareLinks: () => null,
|
||||
readInitialShare: () => null,
|
||||
readScrollRestoreOwner: () => null,
|
||||
readDisplayScrollTop: () => 0,
|
||||
});
|
||||
chrome._panelLayout._syncRightPanelAdaptiveLayout = () => {
|
||||
calls.push('layout');
|
||||
};
|
||||
|
||||
chrome._panelLayout._scheduleRightPanelLayout({
|
||||
reconsiderAutoCollapse: true,
|
||||
});
|
||||
assert.equal(typeof nextFrame, 'function');
|
||||
assert.equal(dock.classList.contains('disclosure-open'), true);
|
||||
nextFrame();
|
||||
|
||||
assert.equal(contextPanel.classList.contains('collapsed'), false);
|
||||
assert.equal(dock.classList.contains('disclosure-open'), false);
|
||||
assert.deepEqual(calls, [
|
||||
'disclosure:false',
|
||||
'button:global-context-panel',
|
||||
'layout',
|
||||
]);
|
||||
});
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
+18
-27
@@ -21,7 +21,7 @@ test('Display uses an inner scroll body only in Cyber', () => {
|
||||
});
|
||||
|
||||
test('Cyber restores one expanded owner, keeps launchers, and restores other theme preferences', () => {
|
||||
for (const mobile of [false, true]) {
|
||||
for (const mobile of [false]) {
|
||||
const f = fixture('right', {
|
||||
mobile,
|
||||
hud: { visible: true, variant: 'cyber' },
|
||||
@@ -50,6 +50,22 @@ test('Cyber restores one expanded owner, keeps launchers, and restores other the
|
||||
}
|
||||
});
|
||||
|
||||
test('Cyber keeps no accordion on phones, where the rail is presented as tab sheets', () => {
|
||||
// A sheet (for example Cameras: CCTV with Street Level) shows several of the
|
||||
// rail's panels at once; the accordion would collapse all but one.
|
||||
const f = fixture('right', {
|
||||
mobile: true,
|
||||
hud: { visible: true, variant: 'cyber' },
|
||||
});
|
||||
f.expand(f.first, 250);
|
||||
f.expand(f.second, 250);
|
||||
f.options.preferredPanelId = f.second.id;
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||||
assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), false);
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
});
|
||||
|
||||
test('Cyber restored accordion prefers keyboard focus when no explicit owner exists', () => {
|
||||
const f = fixture('right', { hud: { visible: true, variant: 'cyber' } });
|
||||
f.expand(f.first, 250);
|
||||
@@ -113,10 +129,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 +338,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);
|
||||
});
|
||||
@@ -114,13 +114,14 @@ export function renderRadioState(state) {
|
||||
...state.categories.map((category) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = category.id;
|
||||
option.textContent = `● ${category.label} (${category.count})`;
|
||||
const label = t('{category} ({count})', {
|
||||
category: category.label,
|
||||
count: category.count,
|
||||
});
|
||||
option.textContent = `● ${label}`;
|
||||
option.dataset.radioColor = category.color;
|
||||
option.style.color = category.color;
|
||||
option.setAttribute(
|
||||
'aria-label',
|
||||
`${category.label} (${category.count})`,
|
||||
);
|
||||
option.setAttribute('aria-label', label);
|
||||
return option;
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
+11
-7
@@ -1,4 +1,5 @@
|
||||
import { createRailCardBlocks } from './railCardBlocks.js';
|
||||
import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
|
||||
|
||||
const set = (node, key, value) => {
|
||||
if (node[key] !== value) node[key] = value;
|
||||
@@ -53,11 +54,12 @@ export function createRailCards({
|
||||
'rail-card-title rail-card-nowrap',
|
||||
heading,
|
||||
);
|
||||
const badge = make(
|
||||
'span',
|
||||
`rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`,
|
||||
labels,
|
||||
);
|
||||
const badge = createStatusBadge({
|
||||
documentRef: document,
|
||||
appearance: 'plain',
|
||||
className: `rail-card-badge${badgeClassName ? ` ${badgeClassName}` : ''}`,
|
||||
});
|
||||
labels.appendChild(badge);
|
||||
const disclosure = make('span', 'rail-card-disclosure', header);
|
||||
disclosure.setAttribute('aria-hidden', 'true');
|
||||
const compact = make('span', 'rail-card-compact', header);
|
||||
@@ -90,8 +92,10 @@ export function createRailCards({
|
||||
row.title.setAttribute('title', card.title);
|
||||
set(row.icon, 'textContent', card.icon || '');
|
||||
set(row.icon, 'hidden', !card.icon);
|
||||
set(row.badge, 'textContent', card.badge || '');
|
||||
set(row.badge, 'hidden', !card.badge);
|
||||
updateStatusBadge(row.badge, {
|
||||
label: card.badge || '',
|
||||
hidden: !card.badge,
|
||||
});
|
||||
const open = card.open !== false;
|
||||
set(row.element.dataset, 'open', String(open));
|
||||
row.element.classList.toggle('is-open', open);
|
||||
|
||||
+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'),
|
||||
|
||||
+15
-10
@@ -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'),
|
||||
);
|
||||
@@ -91,7 +91,9 @@ export function layoutRightPanelRail({
|
||||
}
|
||||
// Normalize restored state and theme entry without overwriting saved panel
|
||||
// preferences. This marker is distinct from Tactical's space-based collapse.
|
||||
if (hud.variant === 'cyber') {
|
||||
// Phones present the rail as tab sheets that show several panels at once.
|
||||
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
|
||||
if (hud.variant === 'cyber' && !isMobile) {
|
||||
const expanded = panels.filter(
|
||||
(panel) => !panel.classList.contains('collapsed'),
|
||||
);
|
||||
@@ -111,16 +113,19 @@ export function layoutRightPanelRail({
|
||||
onCollapse(panel);
|
||||
}
|
||||
}
|
||||
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
|
||||
const hasExpandedPanel = panels.some(
|
||||
(panel) =>
|
||||
!panel.classList.contains('collapsed') &&
|
||||
(!isMobile || panel.id !== 'pp-toggles'),
|
||||
);
|
||||
const exclusive = shouldHideCollapsedRightPanels({
|
||||
hudVariant: hud.variant,
|
||||
hasExpandedPanel,
|
||||
});
|
||||
// Phones present the rail as tab sheets (mobileShell.js), which choose what
|
||||
// shows; hiding collapsed siblings would empty a sheet and its a11y tree.
|
||||
const exclusive =
|
||||
!isMobile &&
|
||||
shouldHideCollapsedRightPanels({
|
||||
hudVariant: hud.variant,
|
||||
hasExpandedPanel,
|
||||
});
|
||||
stack.classList.toggle('layout-exclusive', exclusive);
|
||||
for (const panel of panels) {
|
||||
if (exclusive && panel.classList.contains('collapsed'))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Scene panel elements and text-only project presentation. */
|
||||
import { formatNumber, msg, t } from '../i18n/index.js';
|
||||
import { createActionButton } from './actionButton.js';
|
||||
|
||||
// Labels for what a shot stores; anything else shows as stored. Style names
|
||||
// match STYLE_STATUS_LABELS in visualPresets.js, which this package cannot
|
||||
@@ -121,13 +122,18 @@ export function renderSceneShots(
|
||||
});
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'scene-shot-actions';
|
||||
const loadButton = document.createElement('button');
|
||||
loadButton.className = 'scene-shot-btn';
|
||||
loadButton.textContent = t('LOAD');
|
||||
const loadButton = createActionButton({
|
||||
label: t('LOAD'),
|
||||
className: 'scene-shot-btn',
|
||||
size: 'compact',
|
||||
});
|
||||
listen(loadButton, 'click', () => load(scene.id, shot.id));
|
||||
const deleteButton = document.createElement('button');
|
||||
deleteButton.className = 'scene-shot-btn scene-shot-danger';
|
||||
deleteButton.textContent = t('DEL');
|
||||
const deleteButton = createActionButton({
|
||||
label: t('DEL'),
|
||||
className: 'scene-shot-btn scene-shot-danger',
|
||||
size: 'compact',
|
||||
variant: 'danger',
|
||||
});
|
||||
listen(deleteButton, 'click', () => remove(scene.id, shot.id));
|
||||
actions.appendChild(loadButton);
|
||||
actions.appendChild(deleteButton);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t, tn } from '../i18n/index.js';
|
||||
import { createActionButton } from './actionButton.js';
|
||||
|
||||
/** Small owned modal for scene authoring and import review; text never becomes markup. */
|
||||
export function createSceneDialog(title, onClose) {
|
||||
@@ -185,10 +186,7 @@ export function mountSceneSharing(
|
||||
];
|
||||
const removers = [];
|
||||
for (const [text, fn] of entries) {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = text;
|
||||
b.className = 'scene-btn';
|
||||
const b = createActionButton({ label: text, className: 'scene-btn' });
|
||||
b.addEventListener('click', fn);
|
||||
removers.push(() => b.removeEventListener('click', fn));
|
||||
bar.append(b);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// The share button copies by default; a host's `services.shareLink` shares the
|
||||
// link its own way and reports how it went.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { StyleManager } from './applicationShell.js';
|
||||
import { ShareLinkManager } from '../sharelink.js';
|
||||
import { parseSharePath } from '../share/shareLinkPath.js';
|
||||
import { decodeShareView } from '../share/viewCodec.js';
|
||||
|
||||
/** The decoded view of a `/share/<code>` link. */
|
||||
const sharedState = (href) =>
|
||||
decodeShareView(parseSharePath(new URL(href).pathname).code).state;
|
||||
const SHARE_LINK = /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/;
|
||||
|
||||
function makeManager(href, { positioned = true } = {}) {
|
||||
globalThis.window = { location: { href, hash: new URL(href).hash } };
|
||||
globalThis.history = { replaceState() {} };
|
||||
const viewer = {
|
||||
camera: {
|
||||
changed: { addEventListener() {} },
|
||||
positionCartographic: positioned
|
||||
? { latitude: 0, longitude: 0, height: 1000 }
|
||||
: undefined,
|
||||
heading: 0,
|
||||
pitch: -Math.PI / 2,
|
||||
roll: 0,
|
||||
},
|
||||
};
|
||||
return new ShareLinkManager(viewer);
|
||||
}
|
||||
|
||||
function installClipboard(writeText) {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: { clipboard: { writeText } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Wire the real share button handler onto a minimal shell; return a click. */
|
||||
function shell({ manager, services = {} }) {
|
||||
const toasts = [];
|
||||
let click;
|
||||
const owner = {
|
||||
services,
|
||||
shareLinkManager: manager,
|
||||
_shareBtn: {},
|
||||
_disposed: false,
|
||||
_lifetime: { listen: (_el, _type, handler) => (click = handler) },
|
||||
_showToast: (message) => toasts.push(message),
|
||||
};
|
||||
StyleManager.prototype._initShareButton.call(owner);
|
||||
return { toasts, click: () => click() };
|
||||
}
|
||||
|
||||
test('buildLink is a /share link stamped with its creation time, without ?lang', () => {
|
||||
const manager = makeManager('https://gev.example/?lang=ru#v=2&lat=10&lon=20');
|
||||
const href = manager.buildLink({ nowMs: 5_000_000 });
|
||||
assert.match(href, SHARE_LINK);
|
||||
const link = new URL(href);
|
||||
assert.equal(link.searchParams.get('lang'), null);
|
||||
assert.equal(link.hash, '');
|
||||
assert.equal(sharedState(href).sharedAtMs, 5_000_000);
|
||||
// The camera is the live one, not the address bar's.
|
||||
assert.equal(sharedState(href).lat, 0);
|
||||
assert.equal(
|
||||
makeManager('https://gev.example/', { positioned: false }).buildLink(),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('copyLink copies exactly what buildLink builds', async () => {
|
||||
let copied = null;
|
||||
installClipboard(async (url) => {
|
||||
copied = url;
|
||||
});
|
||||
const manager = makeManager('https://gev.example/#v=2');
|
||||
assert.equal(await manager.copyLink({ nowMs: 7_000_000 }), true);
|
||||
assert.equal(copied, manager.buildLink({ nowMs: 7_000_000 }));
|
||||
});
|
||||
|
||||
test('without a host hook the button copies the link', async () => {
|
||||
let copied = null;
|
||||
installClipboard(async (url) => {
|
||||
copied = url;
|
||||
});
|
||||
const { toasts, click } = shell({
|
||||
manager: makeManager('https://gev.example/#v=2'),
|
||||
});
|
||||
await click();
|
||||
assert.match(copied, SHARE_LINK);
|
||||
assert.deepEqual(toasts, ['Link copied!']);
|
||||
|
||||
installClipboard(async () => {
|
||||
throw new Error('denied');
|
||||
});
|
||||
await click();
|
||||
assert.deepEqual(toasts, ['Link copied!', 'Copy failed']);
|
||||
});
|
||||
|
||||
test('a host hook receives the link and its result picks the toast', async () => {
|
||||
installClipboard(async () => assert.fail('the host shares instead'));
|
||||
const shared = [];
|
||||
const results = ['shared', 'copied', 'cancelled', false];
|
||||
const { toasts, click } = shell({
|
||||
manager: makeManager('https://gev.example/#v=2'),
|
||||
services: {
|
||||
shareLink: async (url) => {
|
||||
shared.push(url);
|
||||
return results.shift();
|
||||
},
|
||||
},
|
||||
});
|
||||
for (let i = 0; i < 4; i++) await click();
|
||||
assert.equal(shared.length, 4);
|
||||
assert.match(shared[0], SHARE_LINK);
|
||||
// 'cancelled' (the user closed the sheet) shows nothing.
|
||||
assert.deepEqual(toasts, ['Link shared', 'Link copied!', 'Copy failed']);
|
||||
});
|
||||
|
||||
test('a failing host hook, or no camera position, reports a failure', async () => {
|
||||
const failing = shell({
|
||||
manager: makeManager('https://gev.example/#v=2'),
|
||||
services: { shareLink: async () => Promise.reject(new Error('no sheet')) },
|
||||
});
|
||||
await failing.click();
|
||||
assert.deepEqual(failing.toasts, ['Copy failed']);
|
||||
|
||||
let called = false;
|
||||
const unpositioned = shell({
|
||||
manager: makeManager('https://gev.example/', { positioned: false }),
|
||||
services: {
|
||||
shareLink: async () => {
|
||||
called = true;
|
||||
return 'shared';
|
||||
},
|
||||
},
|
||||
});
|
||||
await unpositioned.click();
|
||||
assert.equal(called, false);
|
||||
assert.deepEqual(unpositioned.toasts, ['Copy failed']);
|
||||
});
|
||||
|
||||
test('a host hook that answers without a promise, or throws, still gets a toast', async () => {
|
||||
for (const [shareLink, toast] of [
|
||||
[() => 'shared', 'Link shared'],
|
||||
[
|
||||
() => {
|
||||
throw new Error('no sheet');
|
||||
},
|
||||
'Copy failed',
|
||||
],
|
||||
]) {
|
||||
const { toasts, click } = shell({
|
||||
manager: makeManager('https://gev.example/#v=2'),
|
||||
services: { shareLink },
|
||||
});
|
||||
await click();
|
||||
assert.deepEqual(toasts, [toast]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { trackedDisplayPositionForCamera } from '../data/trackedCamera.js';
|
||||
import {
|
||||
SHARE_VIEW_ENTITY_KINDS,
|
||||
followOffsetFromEnu,
|
||||
followOffsetToEnu,
|
||||
} from '../share/follow.js';
|
||||
|
||||
/**
|
||||
* The bridge between the camera following an entity and a share link's thing
|
||||
* view: what the camera follows now, in the share format's terms, and the
|
||||
* stored follow offset applied to an entity the camera has started to follow.
|
||||
*/
|
||||
|
||||
const KIND_BY_LAYER = new Map(
|
||||
SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.layerId, entry.kind]),
|
||||
);
|
||||
const MAX_FRAMES = 120;
|
||||
|
||||
/** `position`'s local east/north/up offset from `origin`, in metres. */
|
||||
function enuOffset(origin, position) {
|
||||
const frame = Cesium.Transforms.eastNorthUpToFixedFrame(origin);
|
||||
const local = Cesium.Matrix4.multiplyByPoint(
|
||||
Cesium.Matrix4.inverseTransformation(frame, new Cesium.Matrix4()),
|
||||
position,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
return { east: local.x, north: local.y, up: local.z };
|
||||
}
|
||||
|
||||
/**
|
||||
* The entity the camera follows as `{ kind, id, offset, lastKnown }`, with
|
||||
* the camera's offset from it and its displayed position as of `nowMs`; null
|
||||
* when the camera follows nothing a share link can name. A selected vessel is
|
||||
* not followed (the camera stays where the user puts it), so a link made then
|
||||
* keeps its own camera.
|
||||
*/
|
||||
export function readShareFollowSubject({ viewer, nowMs = Date.now() }) {
|
||||
const cameraPosition = viewer?.camera?.positionWC;
|
||||
if (!cameraPosition) return null;
|
||||
const tracked = viewer.trackedEntity;
|
||||
const trackedId = String(tracked?.gevTrackedId || '');
|
||||
const separator = trackedId.indexOf(':');
|
||||
if (separator <= 0) return null;
|
||||
const kind = KIND_BY_LAYER.get(trackedId.slice(0, separator));
|
||||
const id = trackedId.slice(separator + 1);
|
||||
const target = kind
|
||||
? trackedDisplayPositionForCamera(
|
||||
tracked,
|
||||
viewer.clock.currentTime,
|
||||
new Cesium.Cartesian3(),
|
||||
)
|
||||
: null;
|
||||
if (!kind || !id || !target) return null;
|
||||
const offset = followOffsetFromEnu(enuOffset(target, cameraPosition));
|
||||
const fix = Cesium.Cartographic.fromCartesian(target);
|
||||
if (!offset || !fix) return null;
|
||||
return {
|
||||
kind,
|
||||
id,
|
||||
offset,
|
||||
lastKnown: {
|
||||
lat: Cesium.Math.toDegrees(fix.latitude),
|
||||
lon: Cesium.Math.toDegrees(fix.longitude),
|
||||
alt: fix.height,
|
||||
observedAtMs: nowMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Largest turn, in radians, the camera makes around the entity per frame. */
|
||||
const MAX_TURN_PER_FRAME = Math.PI / 3;
|
||||
|
||||
/**
|
||||
* Put the camera at a shared follow offset from `entity` once following it
|
||||
* has framed the camera. Cesium keeps the offset afterwards, as it keeps one
|
||||
* the user orbits to. The camera swings round the entity at most 60° a frame:
|
||||
* the follow's own guard treats a jump to the far side as the camera crossing
|
||||
* through the entity and puts it back. Returns a disposer.
|
||||
*/
|
||||
export function applyShareFollowOffset(viewer, entity, offset) {
|
||||
if (!viewer?.scene?.preUpdate || !entity || !(offset?.range > 0))
|
||||
return () => {};
|
||||
const local = followOffsetToEnu(offset);
|
||||
let frames = 0;
|
||||
let remove = null;
|
||||
const stop = () => {
|
||||
remove?.();
|
||||
remove = null;
|
||||
};
|
||||
remove = viewer.scene.preUpdate.addEventListener(() => {
|
||||
if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
frames += 1;
|
||||
const camera = viewer.camera;
|
||||
// Let the follow frame the camera first (its first frame), then move it.
|
||||
const framed =
|
||||
frames > 1 &&
|
||||
!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY);
|
||||
const target = framed
|
||||
? trackedDisplayPositionForCamera(
|
||||
entity,
|
||||
viewer.clock.currentTime,
|
||||
new Cesium.Cartesian3(),
|
||||
)
|
||||
: null;
|
||||
if (!target) {
|
||||
if (frames >= MAX_FRAMES) stop();
|
||||
return;
|
||||
}
|
||||
// The offset is east/north/up at the entity; the follow's own frame may
|
||||
// differ, so place the camera in world space and re-express it there.
|
||||
const world = Cesium.Matrix4.multiplyByPoint(
|
||||
Cesium.Transforms.eastNorthUpToFixedFrame(target),
|
||||
new Cesium.Cartesian3(local.east, local.north, local.up),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const goal = Cesium.Matrix4.multiplyByPoint(
|
||||
Cesium.Matrix4.inverseTransformation(
|
||||
camera.transform,
|
||||
new Cesium.Matrix4(),
|
||||
),
|
||||
world,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const from = Cesium.Cartesian3.clone(
|
||||
camera.position,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const angle =
|
||||
Cesium.Cartesian3.magnitude(from) > 0
|
||||
? Cesium.Cartesian3.angleBetween(from, goal)
|
||||
: 0;
|
||||
const done = !(angle > MAX_TURN_PER_FRAME);
|
||||
const next = done ? goal : turnToward(from, goal, MAX_TURN_PER_FRAME);
|
||||
camera.lookAtTransform(camera.transform, next);
|
||||
viewer.scene.requestRender?.();
|
||||
if (done) stop();
|
||||
});
|
||||
return stop;
|
||||
}
|
||||
|
||||
/**
|
||||
* `from` turned `step` radians toward `to`, with its length moved the same
|
||||
* fraction of the way: one frame of the swing round the entity.
|
||||
*/
|
||||
function turnToward(from, to, step) {
|
||||
const angle = Cesium.Cartesian3.angleBetween(from, to);
|
||||
const unitFrom = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3());
|
||||
// The direction perpendicular to `from` in the plane toward `to` (or any
|
||||
// perpendicular when they are opposite).
|
||||
let toward = Cesium.Cartesian3.subtract(
|
||||
Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3()),
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
unitFrom,
|
||||
Math.cos(angle),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
if (Cesium.Cartesian3.magnitude(toward) < 1e-9)
|
||||
toward = Cesium.Cartesian3.cross(
|
||||
unitFrom,
|
||||
Math.abs(unitFrom.z) < 0.9
|
||||
? Cesium.Cartesian3.UNIT_Z
|
||||
: Cesium.Cartesian3.UNIT_X,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
Cesium.Cartesian3.normalize(toward, toward);
|
||||
const length =
|
||||
Cesium.Cartesian3.magnitude(from) +
|
||||
(Cesium.Cartesian3.magnitude(to) - Cesium.Cartesian3.magnitude(from)) *
|
||||
(step / angle);
|
||||
const direction = Cesium.Cartesian3.add(
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
unitFrom,
|
||||
Math.cos(step),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
toward,
|
||||
Math.sin(step),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
return Cesium.Cartesian3.multiplyByScalar(direction, length, direction);
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
// Short share links: `/share/<code>` opens through the start-up restore
|
||||
// pipeline, the address then follows the live view, and the share button
|
||||
// makes `/share/<code>` links (following an entity when the camera does).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { ShareLinkManager } from '../sharelink.js';
|
||||
import { ShareRestoration, sharedFixNotice } from './shareRestoration.js';
|
||||
import {
|
||||
applyShareFollowOffset,
|
||||
readShareFollowSubject,
|
||||
} from './shareFollow.js';
|
||||
import { createDefaultLayerState } from '../data/layerState.js';
|
||||
import { parseSharePath, shareLinkUrl } from '../share/shareLinkPath.js';
|
||||
import {
|
||||
decodeShareView,
|
||||
encodeShareView,
|
||||
followCameraFor,
|
||||
followOffsetToEnu,
|
||||
} from '../share/viewCodec.js';
|
||||
|
||||
const NOW_MS = Date.now();
|
||||
const FIX = { lat: 37.6213, lon: -122.379, alt: 3200 };
|
||||
const OFFSET = { range: 5800, heading: 0, pitch: -35 };
|
||||
|
||||
const PLACE_CODE = encodeShareView({
|
||||
lat: 48.8584,
|
||||
lon: 2.2945,
|
||||
alt: 1200,
|
||||
heading: 120,
|
||||
pitch: -25,
|
||||
style: 'noir',
|
||||
panelState: { specs: [{ id: 'data-panel', collapsed: false }] },
|
||||
sharedAtMs: NOW_MS - 60_000,
|
||||
});
|
||||
|
||||
function aircraftCode() {
|
||||
const layerState = createDefaultLayerState();
|
||||
layerState.enabledLayerIds = ['flights'];
|
||||
return encodeShareView({
|
||||
view: {
|
||||
kind: 'thing',
|
||||
entity: { kind: 'aircraft', id: 'a1b2c3' },
|
||||
offset: OFFSET,
|
||||
lastKnown: { ...FIX, observedAtMs: NOW_MS - 120_000 },
|
||||
},
|
||||
layerState,
|
||||
sharedAtMs: NOW_MS - 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A page at `href`. `history.replaceState` rewrites the address in place and
|
||||
* is counted; `pushState` (a new history entry) is counted too.
|
||||
*/
|
||||
function installPage(t, href) {
|
||||
const prior = { window: globalThis.window, history: globalThis.history };
|
||||
const target = new EventTarget();
|
||||
const location = new URL(href);
|
||||
target.location = location;
|
||||
globalThis.window = target;
|
||||
const writes = { replaced: 0, pushed: 0 };
|
||||
globalThis.history = {
|
||||
replaceState(_state, _title, next) {
|
||||
writes.replaced += 1;
|
||||
location.href = new URL(next, location.href).href;
|
||||
},
|
||||
pushState() {
|
||||
writes.pushed += 1;
|
||||
},
|
||||
};
|
||||
t.after(() => Object.assign(globalThis, prior));
|
||||
return { location, writes, target };
|
||||
}
|
||||
|
||||
function installManualTimers(t) {
|
||||
const prior = {
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
};
|
||||
const timers = new Map();
|
||||
let timerId = 0;
|
||||
globalThis.setTimeout = (fn) => {
|
||||
timers.set(++timerId, fn);
|
||||
return timerId;
|
||||
};
|
||||
globalThis.clearTimeout = (id) => timers.delete(id);
|
||||
t.after(() => Object.assign(globalThis, prior));
|
||||
return {
|
||||
timers,
|
||||
runAll() {
|
||||
for (const [id, fn] of [...timers]) {
|
||||
timers.delete(id);
|
||||
fn();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A viewer whose camera flights land at once. */
|
||||
function fakeViewer() {
|
||||
const flights = [];
|
||||
return {
|
||||
flights,
|
||||
canvas: new EventTarget(),
|
||||
camera: {
|
||||
changed: { addEventListener: () => () => {} },
|
||||
positionCartographic: { latitude: 0, longitude: 0, height: 1000 },
|
||||
heading: 0,
|
||||
pitch: -Math.PI / 2,
|
||||
roll: 0,
|
||||
flyTo(options) {
|
||||
flights.push(options);
|
||||
options.complete?.();
|
||||
},
|
||||
setView() {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
/** A ShareRestoration over a real ShareLinkManager, as the shell wires them. */
|
||||
function openPage(t, href, { managerOptions = {}, tracking = null } = {}) {
|
||||
const page = installPage(t, href);
|
||||
const clock = installManualTimers(t);
|
||||
const viewer = fakeViewer();
|
||||
const restores = [];
|
||||
const manager = new ShareLinkManager(viewer, {
|
||||
onRestore: async (state) => restores.push(state),
|
||||
isNavigationCurrent: () => true,
|
||||
...managerOptions,
|
||||
});
|
||||
const notices = [];
|
||||
let generation = 0;
|
||||
const owner = new ShareRestoration({
|
||||
viewer,
|
||||
navigation: {
|
||||
_beginDeferredNavigation: () => ++generation,
|
||||
_reassertNavigationHandoff: () => true,
|
||||
_stampNavigation() {},
|
||||
},
|
||||
syncShareState() {},
|
||||
syncModels3d() {},
|
||||
showStatus() {},
|
||||
feedback: {},
|
||||
updateFeedback() {},
|
||||
applyFollowOffset: (entity, offset) =>
|
||||
applyShareFollowOffset(viewer, entity, offset),
|
||||
});
|
||||
// Notices are checked by message; their start-up-cover timing is not.
|
||||
owner._presentAfterStartupCover = (message) => notices.push(message);
|
||||
owner.attachLinks(manager);
|
||||
owner._layerStateCoordinator = {
|
||||
applyShareLayerState: () => Promise.resolve([]),
|
||||
getDurableState: () => null,
|
||||
cancelPendingShareTracking: () => false,
|
||||
restoreShareTrackingSelection: async () => tracking?.(owner) ?? null,
|
||||
destroy() {},
|
||||
};
|
||||
owner.start();
|
||||
t.after(() => owner.destroy());
|
||||
return { ...page, ...clock, viewer, manager, owner, restores, notices };
|
||||
}
|
||||
|
||||
/** Run the delayed start-up restore and let it settle. */
|
||||
async function restore(page) {
|
||||
page.runAll();
|
||||
const result = await page.owner.initialRestorePromise;
|
||||
await flush();
|
||||
return result;
|
||||
}
|
||||
|
||||
test('a /share/<code> address opens its place view through the start-up restore', async (t) => {
|
||||
const page = openPage(t, `http://localhost/share/${PLACE_CODE}`);
|
||||
assert.equal(page.timers.size, 1, 'the restore waits for its delayed flight');
|
||||
assert.equal(page.manager._initialRestorePending, true);
|
||||
const result = await restore(page);
|
||||
assert.equal(result.status, 'settled');
|
||||
assert.equal(result.share.camera, 'applied');
|
||||
assert.equal(page.restores[0].style, 'noir');
|
||||
assert.deepEqual(page.restores[0].panelState.specs, [
|
||||
{ id: 'data-panel', collapsed: false, pinned: null },
|
||||
]);
|
||||
const destination = Cesium.Cartographic.fromCartesian(
|
||||
page.viewer.flights[0].destination,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(destination.latitude) - 48.8584) < 1e-6,
|
||||
);
|
||||
assert.equal(page.manager._initialRestorePending, false);
|
||||
assert.deepEqual(page.notices, []);
|
||||
});
|
||||
|
||||
test('after a restore the address becomes the app root and the long hash, with no new history entry', async (t) => {
|
||||
for (const [href, root] of [
|
||||
[`http://localhost/share/${PLACE_CODE}`, 'http://localhost/'],
|
||||
[
|
||||
`http://localhost:4173/gev/share/${PLACE_CODE}`,
|
||||
'http://localhost:4173/gev/',
|
||||
],
|
||||
]) {
|
||||
const page = openPage(t, href);
|
||||
await restore(page);
|
||||
page.manager._updateHash();
|
||||
assert.equal(`${page.location.origin}${page.location.pathname}`, root);
|
||||
// The live view's long form, as written for any other page.
|
||||
const params = new URLSearchParams(page.location.hash.slice(1));
|
||||
assert.equal(params.get('v'), '2');
|
||||
assert.ok(params.has('lat') && params.has('style'));
|
||||
assert.equal(page.writes.pushed, 0);
|
||||
assert.equal(page.writes.replaced, 1);
|
||||
page.manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('a host can pass the share code instead of the address', async (t) => {
|
||||
const page = openPage(t, 'http://localhost/', {
|
||||
managerOptions: { initialShareCode: PLACE_CODE },
|
||||
});
|
||||
await restore(page);
|
||||
assert.equal(page.restores[0].style, 'noir');
|
||||
});
|
||||
|
||||
test('a code that fails its check restores nothing and says so', async (t) => {
|
||||
// One character changed: the check byte no longer matches.
|
||||
const last = PLACE_CODE.at(-2) === 'A' ? 'B' : 'A';
|
||||
const typo = `${PLACE_CODE.slice(0, -2)}${last}${PLACE_CODE.at(-1)}`;
|
||||
const page = openPage(t, `http://localhost/share/${typo}`);
|
||||
assert.equal(page.manager.rejectedShareCode, 'checksum');
|
||||
assert.notEqual(page.owner._hasShareState, true);
|
||||
assert.equal(page.timers.size, 0, 'nothing to restore');
|
||||
assert.equal(
|
||||
(await page.owner.initialRestorePromise).status,
|
||||
'not-requested',
|
||||
);
|
||||
assert.deepEqual(page.notices, ["This share link can't be opened"]);
|
||||
// The default start's first address write leaves the bad link behind.
|
||||
page.manager._updateHash();
|
||||
assert.equal(page.location.pathname, '/');
|
||||
});
|
||||
|
||||
test('a thing view turns its layer on, follows by id and frames the shared offset', async (t) => {
|
||||
let entity = null;
|
||||
const page = openPage(t, `http://localhost/share/${aircraftCode()}`, {
|
||||
tracking: (owner) => {
|
||||
// What the coordinator does when the aircraft is in the feed.
|
||||
owner.viewer.trackedEntity = entity;
|
||||
owner._handleShareTrackingRestoreStatus({
|
||||
layerId: 'flights',
|
||||
targetId: 'a1b2c3',
|
||||
classification: 'followed',
|
||||
});
|
||||
return { classification: 'followed' };
|
||||
},
|
||||
});
|
||||
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
|
||||
const listeners = [];
|
||||
const looks = [];
|
||||
Object.assign(page.viewer, {
|
||||
clock: { currentTime: Cesium.JulianDate.now() },
|
||||
scene: {
|
||||
preUpdate: {
|
||||
addEventListener(fn) {
|
||||
listeners.push(fn);
|
||||
return () => listeners.splice(listeners.indexOf(fn), 1);
|
||||
},
|
||||
},
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
});
|
||||
Object.assign(page.viewer.camera, {
|
||||
transform: Cesium.Transforms.eastNorthUpToFixedFrame(target),
|
||||
// The app's own follow framing: south of the aircraft and above it.
|
||||
position: new Cesium.Cartesian3(0, -4800, 3300),
|
||||
lookAtTransform(transform, offset) {
|
||||
looks.push(offset);
|
||||
this.position = Cesium.Cartesian3.clone(offset);
|
||||
},
|
||||
});
|
||||
entity = {
|
||||
gevTrackedId: 'flights:a1b2c3',
|
||||
gevDisplayPosition: () => target,
|
||||
};
|
||||
|
||||
const parsed = page.manager._parseShareCode(aircraftCode());
|
||||
assert.deepEqual(parsed.layerState.enabledLayerIds, ['flights']);
|
||||
assert.equal(
|
||||
parsed.layerState.options.flights.selectedFlightsTrackingId,
|
||||
'a1b2c3',
|
||||
);
|
||||
await restore(page);
|
||||
// The opening flight goes where following the fix puts the camera.
|
||||
const camera = followCameraFor(FIX, OFFSET);
|
||||
const destination = Cesium.Cartographic.fromCartesian(
|
||||
page.viewer.flights[0].destination,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(destination.latitude) - camera.lat) < 1e-3,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(destination.longitude) - camera.lon) < 1e-3,
|
||||
);
|
||||
// Once followed, the camera takes the shared offset after the follow's
|
||||
// own first frame.
|
||||
assert.equal(listeners.length, 1);
|
||||
listeners[0]();
|
||||
assert.equal(looks.length, 0);
|
||||
listeners[0]?.();
|
||||
assert.equal(looks.length, 1);
|
||||
const expected = followOffsetToEnu(OFFSET);
|
||||
assert.ok(Math.abs(looks[0].x - expected.east) < 1);
|
||||
assert.ok(Math.abs(looks[0].y - expected.north) < 1);
|
||||
assert.ok(Math.abs(looks[0].z - expected.up) < 1);
|
||||
assert.equal(listeners.length, 0, 'the offset is applied once');
|
||||
assert.deepEqual(page.notices, []);
|
||||
});
|
||||
|
||||
test('a thing that is not reporting leaves the view at its last-known fix, with a notice', async (t) => {
|
||||
const page = openPage(t, `http://localhost/share/${aircraftCode()}`, {
|
||||
tracking: (owner) => {
|
||||
// The coordinator's verdict once the follow window has passed.
|
||||
const result = {
|
||||
layerId: 'flights',
|
||||
targetId: 'a1b2c3',
|
||||
label: 'flight',
|
||||
classification: 'expired',
|
||||
};
|
||||
owner._handleShareTrackingRestoreStatus(result);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
await restore(page);
|
||||
await flush();
|
||||
assert.equal(page.notices.length, 1);
|
||||
assert.match(
|
||||
page.notices[0],
|
||||
/^A1B2C3 isn't reporting now — showing where it was at .+/,
|
||||
);
|
||||
// A long link names no fix and keeps the existing notice.
|
||||
page.owner._initialShareState = { shareView: null };
|
||||
page.owner._handleShareTrackingRestoreStatus({
|
||||
layerId: 'flights',
|
||||
targetId: 'a1b2c3',
|
||||
label: 'flight',
|
||||
classification: 'expired',
|
||||
});
|
||||
assert.equal(page.notices[1], 'Shared flight follow expired');
|
||||
});
|
||||
|
||||
test('the not-reporting notice names the id and the time of the fix', () => {
|
||||
const at = new Date(2026, 9, 26, 14, 5).getTime();
|
||||
assert.match(
|
||||
sharedFixNotice({ kind: 'aircraft', id: 'a1b2c3' }, at, at + 60_000),
|
||||
/^A1B2C3 isn't reporting now — showing where it was at .*05/,
|
||||
);
|
||||
assert.equal(
|
||||
sharedFixNotice({ kind: 'vessel', id: '366999712' }, null),
|
||||
"366999712 isn't reporting now — showing where it was last seen",
|
||||
);
|
||||
});
|
||||
|
||||
test('a shared vessel is selected once the vessel feed has it', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: NOW_MS });
|
||||
const page = openPage(t, 'http://localhost/');
|
||||
const selected = [];
|
||||
let present = false;
|
||||
page.owner._dataManager = {
|
||||
layers: new Map([
|
||||
[
|
||||
'ais-live-vessels',
|
||||
{
|
||||
module: {
|
||||
hasContact: (id) => present && id === '366999712',
|
||||
selectById: (id) => selected.push(id) > 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
const view = {
|
||||
kind: 'thing',
|
||||
entity: { kind: 'vessel', id: '366999712' },
|
||||
lastKnown: { lat: 1, lon: 2, alt: 0, observedAtMs: NOW_MS - 30_000 },
|
||||
};
|
||||
const found = page.owner._restoreSharedVessel(view, () => true);
|
||||
await flush();
|
||||
present = true;
|
||||
page.runAll();
|
||||
assert.equal(await found, true);
|
||||
assert.deepEqual(selected, ['366999712']);
|
||||
|
||||
present = false;
|
||||
const missing = page.owner._restoreSharedVessel(view, () => true);
|
||||
for (let i = 0; i < 61; i += 1) {
|
||||
await flush();
|
||||
t.mock.timers.tick(2_000);
|
||||
page.runAll();
|
||||
}
|
||||
assert.equal(await missing, false);
|
||||
assert.match(page.notices.at(-1), /^366999712 isn't reporting now/);
|
||||
});
|
||||
|
||||
test('the share button links to /share/<code>, following the followed entity', (t) => {
|
||||
installPage(t, 'https://gev.example/?lang=ru#v=2&lat=1&lon=2');
|
||||
const manager = new ShareLinkManager(fakeViewer());
|
||||
// A fresh session's look, as the app reports it at start-up.
|
||||
manager.onToggleChange(false, true, {
|
||||
hudVisible: true,
|
||||
detectionMode: 'DENSE',
|
||||
detectionDensity: 75,
|
||||
});
|
||||
// No entity: a place view at the live camera.
|
||||
const place = manager.buildLink({ nowMs: NOW_MS });
|
||||
assert.match(place, /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/);
|
||||
assert.equal(
|
||||
decodeShareView(parseSharePath(new URL(place).pathname).code).state.view
|
||||
.kind,
|
||||
'place',
|
||||
);
|
||||
|
||||
manager.setFollowSubjectProvider(() => ({
|
||||
kind: 'aircraft',
|
||||
id: 'a1b2c3',
|
||||
offset: OFFSET,
|
||||
lastKnown: { ...FIX, observedAtMs: NOW_MS },
|
||||
}));
|
||||
const href = manager.buildLink({ nowMs: NOW_MS });
|
||||
const code = parseSharePath(new URL(href).pathname).code;
|
||||
const { state } = decodeShareView(code, { nowMs: NOW_MS });
|
||||
assert.deepEqual(state.view, {
|
||||
kind: 'thing',
|
||||
entity: { kind: 'aircraft', id: 'a1b2c3' },
|
||||
offset: OFFSET,
|
||||
lastKnown: { ...FIX, observedAtMs: Math.floor(NOW_MS / 1000) * 1000 },
|
||||
});
|
||||
// The followed aircraft's layer is implied; the camera is derived.
|
||||
assert.ok(code.length <= 45, `${code.length} characters`);
|
||||
assert.equal(href, shareLinkUrl(code, 'https://gev.example/'));
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test('the followed entity is read in the share format, and its offset round-trips', () => {
|
||||
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
|
||||
const offset = { range: 2400, heading: 30, pitch: -20 };
|
||||
const local = followOffsetToEnu(offset);
|
||||
const cameraPosition = Cesium.Matrix4.multiplyByPoint(
|
||||
Cesium.Transforms.eastNorthUpToFixedFrame(target),
|
||||
new Cesium.Cartesian3(local.east, local.north, local.up),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const viewer = {
|
||||
camera: { positionWC: cameraPosition },
|
||||
clock: { currentTime: Cesium.JulianDate.now() },
|
||||
trackedEntity: {
|
||||
gevTrackedId: 'military:ae1234',
|
||||
gevDisplayPosition: () => target,
|
||||
},
|
||||
};
|
||||
const subject = readShareFollowSubject({ viewer, nowMs: NOW_MS });
|
||||
assert.equal(subject.kind, 'military_aircraft');
|
||||
assert.equal(subject.id, 'ae1234');
|
||||
assert.deepEqual(subject.offset, offset);
|
||||
assert.ok(Math.abs(subject.lastKnown.lat - FIX.lat) < 1e-9);
|
||||
assert.ok(Math.abs(subject.lastKnown.alt - FIX.alt) < 1e-3);
|
||||
// A followed entity the share format has no kind for is not a thing view.
|
||||
viewer.trackedEntity.gevTrackedId = 'local-adsb:abc';
|
||||
assert.equal(readShareFollowSubject({ viewer }), null);
|
||||
// Nor is a camera that follows nothing (a selected vessel is not followed).
|
||||
viewer.trackedEntity = undefined;
|
||||
assert.equal(readShareFollowSubject({ viewer }), null);
|
||||
});
|
||||
|
||||
test('a shared offset on the far side swings round, never jumping through the entity', () => {
|
||||
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
|
||||
const listeners = [];
|
||||
const looks = [];
|
||||
const entity = { gevDisplayPosition: () => target };
|
||||
const viewer = {
|
||||
clock: { currentTime: Cesium.JulianDate.now() },
|
||||
scene: {
|
||||
preUpdate: {
|
||||
addEventListener(fn) {
|
||||
listeners.push(fn);
|
||||
return () => listeners.splice(listeners.indexOf(fn), 1);
|
||||
},
|
||||
},
|
||||
},
|
||||
trackedEntity: entity,
|
||||
camera: {
|
||||
transform: Cesium.Transforms.eastNorthUpToFixedFrame(target),
|
||||
position: new Cesium.Cartesian3(0, -4800, 3300),
|
||||
lookAtTransform(transform, offset) {
|
||||
looks.push(Cesium.Cartesian3.clone(offset));
|
||||
this.position = Cesium.Cartesian3.clone(offset);
|
||||
},
|
||||
},
|
||||
};
|
||||
// Looking south at the nose: the opposite side of the app's framing.
|
||||
const offset = { range: 5800, heading: 180, pitch: -35 };
|
||||
applyShareFollowOffset(viewer, entity, offset);
|
||||
let previous = viewer.camera.position;
|
||||
for (let frame = 0; frame < 10 && listeners.length; frame += 1) {
|
||||
listeners[0]();
|
||||
const now = viewer.camera.position;
|
||||
// The follow's guard treats a turn of 90° or more as a crossing.
|
||||
assert.ok(Cesium.Cartesian3.angleBetween(previous, now) < Math.PI / 2);
|
||||
previous = now;
|
||||
}
|
||||
assert.equal(listeners.length, 0, 'it arrives');
|
||||
assert.ok(looks.length >= 2, `${looks.length} steps`);
|
||||
const expected = followOffsetToEnu(offset);
|
||||
const last = looks.at(-1);
|
||||
assert.ok(Math.abs(last.x - expected.east) < 1);
|
||||
assert.ok(Math.abs(last.y - expected.north) < 1);
|
||||
assert.ok(Math.abs(last.z - expected.up) < 1);
|
||||
});
|
||||
|
||||
test('applying a follow offset gives up when the follow ends', () => {
|
||||
const listeners = [];
|
||||
const viewer = {
|
||||
scene: {
|
||||
preUpdate: {
|
||||
addEventListener(fn) {
|
||||
listeners.push(fn);
|
||||
return () => listeners.splice(listeners.indexOf(fn), 1);
|
||||
},
|
||||
},
|
||||
},
|
||||
trackedEntity: null,
|
||||
};
|
||||
applyShareFollowOffset(viewer, {}, OFFSET);
|
||||
listeners[0]();
|
||||
assert.equal(listeners.length, 0);
|
||||
});
|
||||
|
||||
test('long links and hash navigation are unaffected by a share code start', async (t) => {
|
||||
// A long link still opens from the hash.
|
||||
const long = openPage(t, 'http://localhost/#v=2&lat=10&lon=20&style=nvg');
|
||||
await restore(long);
|
||||
assert.equal(long.restores[0].style, 'surveillance');
|
||||
assert.equal(long.restores[0].shareView, undefined);
|
||||
long.manager._updateHash();
|
||||
assert.equal(long.location.pathname, '/');
|
||||
|
||||
// After a /share start, navigating to a long link restores it (#1416).
|
||||
const page = openPage(t, `http://localhost/share/${PLACE_CODE}`);
|
||||
await restore(page);
|
||||
page.location.hash = 'v=2&lat=1.0000&lon=2.0000&style=normal';
|
||||
page.target.dispatchEvent(new Event('hashchange'));
|
||||
await flush();
|
||||
assert.equal(page.restores.length, 2);
|
||||
assert.equal(page.restores[1].style, 'normal');
|
||||
});
|
||||
+292
-47
@@ -1,9 +1,18 @@
|
||||
import { LayerStateCoordinator } from '../data/layerStateCoordinator.js';
|
||||
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
||||
import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { t, msg, formatDate } from '../i18n/index.js';
|
||||
import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js';
|
||||
import { UiLifetime } from './uiLifetime.js';
|
||||
|
||||
/** How long a shared vessel may take to appear in the vessel feed. */
|
||||
export const SHARED_VESSEL_WAIT_MS = 120_000;
|
||||
const SHARED_VESSEL_POLL_MS = 2_000;
|
||||
const DAY_MS = 86_400_000;
|
||||
const ENTITY_BY_KIND = new Map(
|
||||
SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.kind, entry]),
|
||||
);
|
||||
|
||||
// One ready-made sentence per restore policy (SHARE_TRACKING_RESTORE_POLICIES
|
||||
// label) and outcome: a label dropped into a template cannot take the case or
|
||||
// article the sentence needs in every language. The generic template stays
|
||||
@@ -51,6 +60,36 @@ export function shareTrackingRestoreNotice(result) {
|
||||
: t('Shared {subject} is unavailable', { subject });
|
||||
}
|
||||
|
||||
/**
|
||||
* The notice for a shared entity that is not reporting: the view shows where
|
||||
* it was when the link was made.
|
||||
* @param {{kind: string, id: string}} entity
|
||||
* @param {number|null} observedAtMs
|
||||
* @param {number} [nowMs]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function sharedFixNotice(entity, observedAtMs, nowMs = Date.now()) {
|
||||
// ICAO addresses read as they are written on the aircraft: upper case.
|
||||
const id =
|
||||
ENTITY_BY_KIND.get(entity.kind)?.idForm === 'hex24'
|
||||
? String(entity.id).toUpperCase()
|
||||
: String(entity.id);
|
||||
if (!Number.isFinite(observedAtMs))
|
||||
return t("{id} isn't reporting now — showing where it was last seen", {
|
||||
id,
|
||||
});
|
||||
const time = formatDate(
|
||||
observedAtMs,
|
||||
nowMs - observedAtMs < DAY_MS
|
||||
? { hour: '2-digit', minute: '2-digit' }
|
||||
: { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' },
|
||||
);
|
||||
return t("{id} isn't reporting now — showing where it was at {time}", {
|
||||
id,
|
||||
time,
|
||||
});
|
||||
}
|
||||
|
||||
/** Own initial share restoration, durable layer state and restoration notices. */
|
||||
export class ShareRestoration {
|
||||
constructor({
|
||||
@@ -61,6 +100,7 @@ export class ShareRestoration {
|
||||
showStatus,
|
||||
feedback,
|
||||
updateFeedback,
|
||||
applyFollowOffset = null,
|
||||
}) {
|
||||
Object.assign(this, {
|
||||
viewer,
|
||||
@@ -70,6 +110,9 @@ export class ShareRestoration {
|
||||
showStatus,
|
||||
feedback,
|
||||
updateFeedback,
|
||||
// (entity, offset) => disposer: frame a followed entity from a shared
|
||||
// follow offset (shareFollow.js).
|
||||
applyFollowOffset,
|
||||
});
|
||||
this._lifetime = new UiLifetime();
|
||||
this._disposed = false;
|
||||
@@ -80,6 +123,14 @@ export class ShareRestoration {
|
||||
this._initialShareRestoreTimeout = null;
|
||||
this._layerStateCoordinator = null;
|
||||
this._layerStateRestorePromise = null;
|
||||
// Bumped by every share opened after start-up; an older restore that
|
||||
// settles later must neither apply follow-up work nor release URL writes.
|
||||
this._shareRestoreGeneration = 0;
|
||||
this._navigatedShareRestoreActive = false;
|
||||
this._stopFollowOffset = null;
|
||||
// Bumped to abandon the wait for a shared vessel.
|
||||
this._sharedVesselGeneration = 0;
|
||||
this._sharedVesselPending = false;
|
||||
}
|
||||
attachLinks(shareLinkManager) {
|
||||
this.shareLinkManager = shareLinkManager;
|
||||
@@ -108,54 +159,35 @@ export class ShareRestoration {
|
||||
const applyCamera =
|
||||
Number.isInteger(generation) &&
|
||||
this.navigation._reassertNavigationHandoff(generation);
|
||||
void (async () => {
|
||||
try {
|
||||
const share = await this.shareLinkManager.applyState(savedState, {
|
||||
applyCamera,
|
||||
navigationToken: generation,
|
||||
});
|
||||
const layers = await (this._layerStateRestorePromise ||
|
||||
Promise.resolve([]));
|
||||
const tracking =
|
||||
share.camera === 'applied'
|
||||
? await this._layerStateCoordinator?.restoreShareTrackingSelection?.()
|
||||
: {
|
||||
status: 'superseded',
|
||||
cleared:
|
||||
this._layerStateCoordinator?.cancelPendingShareTracking?.(
|
||||
'shared-camera-superseded',
|
||||
{ clearSelection: true },
|
||||
) === true,
|
||||
};
|
||||
this.shareLinkManager.completeInitialRestore();
|
||||
this._settleInitialShareRestore({
|
||||
status: 'settled',
|
||||
share,
|
||||
layers,
|
||||
tracking,
|
||||
});
|
||||
} catch (error) {
|
||||
this.shareLinkManager.completeInitialRestore();
|
||||
this._settleInitialShareRestore({
|
||||
status: 'failed',
|
||||
error: String(error?.message || error),
|
||||
share: null,
|
||||
layers: [],
|
||||
});
|
||||
}
|
||||
})();
|
||||
void this._runShareRestore(savedState, {
|
||||
applyCamera,
|
||||
navigationToken: generation,
|
||||
layersPromise: this._layerStateRestorePromise,
|
||||
settle: (result) => this._settleInitialShareRestore(result),
|
||||
});
|
||||
}, 1500);
|
||||
} else {
|
||||
this.syncShareState();
|
||||
if (this.shareLinkManager?.rejectedShareCode)
|
||||
this._presentAfterStartupCover(
|
||||
t("This share link can't be opened"),
|
||||
++this._shareTrackingNoticeGeneration,
|
||||
);
|
||||
}
|
||||
// A recipient can orbit before or during the delayed share flight. That
|
||||
// gesture keeps ordinary layer state but revokes the passive base camera
|
||||
// and selected-subject Follow so delayed work cannot seize navigation.
|
||||
this._initialShareGestureHandler = () => {
|
||||
// The recipient taking the camera also ends the wait for a vessel.
|
||||
if (this._sharedVesselPending) {
|
||||
this._sharedVesselGeneration += 1;
|
||||
this._sharedVesselPending = false;
|
||||
}
|
||||
if (
|
||||
this._disposed ||
|
||||
!this._hasShareState ||
|
||||
!this._resolveInitialShareRestore
|
||||
(!this._resolveInitialShareRestore &&
|
||||
!this._navigatedShareRestoreActive)
|
||||
)
|
||||
return;
|
||||
stampInitialShareGesture((options) =>
|
||||
@@ -176,6 +208,126 @@ export class ShareRestoration {
|
||||
passive: true,
|
||||
},
|
||||
);
|
||||
// A different share opened in this tab later (pasted address, link,
|
||||
// Back/Forward) runs the same restore as start-up.
|
||||
this.shareLinkManager?.watchNavigation?.((state) =>
|
||||
this._restoreNavigatedShare(state),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one incoming share: camera, visual, map and panels, then layers and
|
||||
* any shared Follow. Only the newest share may finish: a superseded run
|
||||
* stops before follow-up work and leaves URL writes suppressed for the
|
||||
* newer one.
|
||||
*/
|
||||
async _runShareRestore(
|
||||
state,
|
||||
{ applyCamera, navigationToken, layersPromise, settle },
|
||||
) {
|
||||
const generation = this._shareRestoreGeneration;
|
||||
const current = () =>
|
||||
!this._disposed && generation === this._shareRestoreGeneration;
|
||||
try {
|
||||
const share = await this.shareLinkManager.applyState(state, {
|
||||
applyCamera,
|
||||
navigationToken,
|
||||
});
|
||||
const layers = await (layersPromise || Promise.resolve([]));
|
||||
if (!current()) return;
|
||||
const tracking =
|
||||
share.camera === 'applied'
|
||||
? await this._layerStateCoordinator?.restoreShareTrackingSelection?.()
|
||||
: {
|
||||
status: 'superseded',
|
||||
cleared:
|
||||
this._layerStateCoordinator?.cancelPendingShareTracking?.(
|
||||
'shared-camera-superseded',
|
||||
{ clearSelection: true },
|
||||
) === true,
|
||||
};
|
||||
if (!current()) return;
|
||||
this.shareLinkManager.completeInitialRestore(state.restoreToken);
|
||||
// Selecting a vessel leaves the camera alone, so it does not depend on
|
||||
// the shared flight having landed.
|
||||
const vessel = this._sharedVessel(state);
|
||||
if (vessel) void this._restoreSharedVessel(vessel, current);
|
||||
settle({ status: 'settled', share, layers, tracking });
|
||||
} catch (error) {
|
||||
if (!current()) return;
|
||||
this.shareLinkManager.completeInitialRestore(state.restoreToken);
|
||||
settle({
|
||||
status: 'failed',
|
||||
error: String(error?.message || error),
|
||||
share: null,
|
||||
layers: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore a share opened in this tab after start-up, superseding any other. */
|
||||
_restoreNavigatedShare(state) {
|
||||
if (this._disposed || !state) return;
|
||||
this._shareRestoreGeneration += 1;
|
||||
const settle = (result) => {
|
||||
this._navigatedShareRestoreActive = false;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('gev:share-navigation-restore-settled', {
|
||||
detail: result,
|
||||
}),
|
||||
);
|
||||
};
|
||||
try {
|
||||
return this._beginNavigatedShareRestore(state, settle);
|
||||
} catch (error) {
|
||||
// Never leave the address bar frozen on a restore that did not start.
|
||||
this.shareLinkManager.completeInitialRestore(state.restoreToken);
|
||||
settle({
|
||||
status: 'failed',
|
||||
error: String(error?.message || error),
|
||||
share: null,
|
||||
layers: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_beginNavigatedShareRestore(state, settle) {
|
||||
// A start-up restore still waiting for its delayed flight never runs.
|
||||
clearTimeout(this._initialShareRestoreTimeout);
|
||||
this._initialShareRestoreTimeout = null;
|
||||
this._settleInitialShareRestore({
|
||||
status: 'superseded',
|
||||
share: null,
|
||||
layers: [],
|
||||
});
|
||||
this._initialShareState = state;
|
||||
this._initialShareSelectionSuperseded = false;
|
||||
this._hasShareState = true;
|
||||
this._navigatedShareRestoreActive = true;
|
||||
// A new deferred navigation generation retires any older share flight.
|
||||
const navigationToken = this.navigation._beginDeferredNavigation(
|
||||
'shared view',
|
||||
{ cancelPendingSelection: false },
|
||||
);
|
||||
// Releasing the previous Follow is part of the passive share restore and
|
||||
// must not be saved as the recipient's own layer preference.
|
||||
const applyCamera =
|
||||
Number.isInteger(navigationToken) &&
|
||||
this.navigation._reassertNavigationHandoff(navigationToken, {
|
||||
trackingOrigin: 'programmatic',
|
||||
});
|
||||
const layersPromise = this._layerStateCoordinator
|
||||
? this._layerStateCoordinator.applyShareLayerState({
|
||||
shareLayerState: state.layerState || null,
|
||||
shareCreatedAtMs: state.sharedAtMs ?? null,
|
||||
})
|
||||
: null;
|
||||
return this._runShareRestore(state, {
|
||||
applyCamera,
|
||||
navigationToken,
|
||||
layersPromise,
|
||||
settle,
|
||||
});
|
||||
}
|
||||
connect(dataManager) {
|
||||
this._dataManager = dataManager;
|
||||
@@ -210,7 +362,79 @@ export class ShareRestoration {
|
||||
});
|
||||
}
|
||||
}
|
||||
/** The shared vessel a share's thing view selects, if it names one. */
|
||||
_sharedVessel(state) {
|
||||
const view = state?.shareView;
|
||||
return view?.kind === 'thing' && view.entity.kind === 'vessel'
|
||||
? view
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a shared vessel once the vessel feed has it. Vessels are selected,
|
||||
* not followed: the camera is already at the shared view. If the vessel does
|
||||
* not appear in time, the view stays where it was when the link was made.
|
||||
*/
|
||||
async _restoreSharedVessel(view, current) {
|
||||
const generation = ++this._sharedVesselGeneration;
|
||||
const live = () => current() && generation === this._sharedVesselGeneration;
|
||||
const deadline = Date.now() + SHARED_VESSEL_WAIT_MS;
|
||||
const { id } = view.entity;
|
||||
const { layerId } = ENTITY_BY_KIND.get(view.entity.kind);
|
||||
this._sharedVesselPending = true;
|
||||
try {
|
||||
while (live()) {
|
||||
const vessels = this._dataManager?.layers?.get?.(layerId)?.module;
|
||||
if (vessels?.hasContact?.(id) && vessels.selectById(id)) return true;
|
||||
if (Date.now() >= deadline) break;
|
||||
await new Promise((resolve) =>
|
||||
this._lifetime.timeout(resolve, SHARED_VESSEL_POLL_MS),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (generation === this._sharedVesselGeneration)
|
||||
this._sharedVesselPending = false;
|
||||
}
|
||||
if (!live()) return false;
|
||||
this._presentAfterStartupCover(
|
||||
view.lastKnown
|
||||
? sharedFixNotice(view.entity, view.lastKnown.observedAtMs)
|
||||
: t('Shared vessel is unavailable'),
|
||||
++this._shareTrackingNoticeGeneration,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The current share's thing view when `result` is about its entity. */
|
||||
_sharedThingFor(result) {
|
||||
const view = this._initialShareState?.shareView;
|
||||
if (view?.kind !== 'thing') return null;
|
||||
const entry = ENTITY_BY_KIND.get(view.entity.kind);
|
||||
return entry?.tracking === result?.layerId &&
|
||||
String(view.entity.id) === String(result?.targetId)
|
||||
? view
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Frame a shared entity the camera now follows from the shared offset. */
|
||||
_applySharedFollowOffset(result) {
|
||||
const view = this._sharedThingFor(result);
|
||||
const entity = this.viewer?.trackedEntity;
|
||||
// Only a link made while following carries a real offset (with the fix);
|
||||
// a long link's follow keeps the app's own framing.
|
||||
if (
|
||||
!view?.lastKnown ||
|
||||
String(entity?.gevTrackedId || '') !==
|
||||
`${result.layerId}:${result.targetId}`
|
||||
)
|
||||
return;
|
||||
this._stopFollowOffset?.();
|
||||
this._stopFollowOffset = this.applyFollowOffset?.(entity, view.offset);
|
||||
}
|
||||
|
||||
cancelSelection() {
|
||||
// An explicit navigation also ends the wait for a shared vessel.
|
||||
this._sharedVesselGeneration += 1;
|
||||
if (
|
||||
this._hasShareState &&
|
||||
this._resolveInitialShareRestore &&
|
||||
@@ -256,17 +480,39 @@ export class ShareRestoration {
|
||||
this.updateFeedback();
|
||||
}
|
||||
}
|
||||
if (
|
||||
result.classification === 'followed' ||
|
||||
result.classification === 'cancelled'
|
||||
)
|
||||
if (result.classification === 'followed') {
|
||||
this._applySharedFollowOffset(result);
|
||||
return;
|
||||
}
|
||||
if (result.classification === 'cancelled') return;
|
||||
// A stale terminal result must never replace a newer target's acquisition.
|
||||
if (this._shareTrackingAcquiringKey) return;
|
||||
const noticeGeneration = ownsAcquiringNotice
|
||||
? this._shareTrackingNoticeGeneration
|
||||
: ++this._shareTrackingNoticeGeneration;
|
||||
const message = shareTrackingRestoreNotice(result);
|
||||
// A share link that knows where the entity was says so; the view is
|
||||
// already there.
|
||||
const fix = this._sharedThingFor(result)?.lastKnown;
|
||||
const message = fix
|
||||
? sharedFixNotice(
|
||||
this._initialShareState.shareView.entity,
|
||||
fix.observedAtMs,
|
||||
)
|
||||
: shareTrackingRestoreNotice(result);
|
||||
if (this._resolveInitialShareRestore) {
|
||||
void this.initialRestorePromise.then(() =>
|
||||
this._presentAfterStartupCover(message, noticeGeneration),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._presentAfterStartupCover(message, noticeGeneration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a status notice once the start-up cover is gone, unless a newer
|
||||
* notice (generation) has taken its place.
|
||||
*/
|
||||
_presentAfterStartupCover(message, noticeGeneration) {
|
||||
const showAfterStartupCover = () => {
|
||||
this._lifetime.frame(() => {
|
||||
if (
|
||||
@@ -308,10 +554,6 @@ export class ShareRestoration {
|
||||
fallbackTimer = this._lifetime.timeout(showOnce, 1000);
|
||||
});
|
||||
};
|
||||
if (this._resolveInitialShareRestore) {
|
||||
void this.initialRestorePromise.then(showAfterStartupCover);
|
||||
return;
|
||||
}
|
||||
showAfterStartupCover();
|
||||
}
|
||||
_settleInitialShareRestore(result) {
|
||||
@@ -328,6 +570,9 @@ export class ShareRestoration {
|
||||
this._disposed = true;
|
||||
this._shareTrackingNoticeGeneration += 1;
|
||||
this._shareTrackingAcquiringKey = null;
|
||||
this._sharedVesselGeneration += 1;
|
||||
this._stopFollowOffset?.();
|
||||
this._stopFollowOffset = null;
|
||||
this._layerStateCoordinator?.destroy();
|
||||
this._layerStateCoordinator = null;
|
||||
this._layerStateRestorePromise = null;
|
||||
|
||||
@@ -180,6 +180,7 @@ export function readShellElements(document = globalThis.document) {
|
||||
_cctvSyncProgress: document.getElementById('cctv-sync-progress'),
|
||||
_toast: document.getElementById('toast'),
|
||||
_locationSearch: document.getElementById('location-search'),
|
||||
_locationSearchError: document.getElementById('location-search-error'),
|
||||
_searchToggle: document.getElementById('search-toggle'),
|
||||
_locationPills: document.getElementById('location-pills'),
|
||||
_poiRow: document.getElementById('poi-row'),
|
||||
|
||||
+1
-87
@@ -550,7 +550,7 @@ export class ShellFacade {
|
||||
}
|
||||
|
||||
/** Settle only the search generation that still owns the shared input UI. */
|
||||
_settleLocationSearchUi(generation) {
|
||||
_settleLocationSearchUi(generation, options) {
|
||||
return this._navigation._settleLocationSearchUi(...arguments);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Ordinary read-only badge view. Labels are already translated. Feature owners
|
||||
* retain status derivation, machine-state attributes and announcement policy.
|
||||
* No listeners, timers, state inference or automatic live regions are installed.
|
||||
* Vue can replace this DOM adapter while retaining the label/hidden contract
|
||||
* and the gev-status-badge CSS surface.
|
||||
* @param {object} options
|
||||
* @param {Document} [options.documentRef]
|
||||
* @param {string} [options.label]
|
||||
* @param {'pill'|'box'|'plain'} [options.appearance]
|
||||
* @param {string} [options.className] Feature/layout hooks.
|
||||
* @param {boolean} [options.hidden]
|
||||
* @returns {HTMLSpanElement}
|
||||
*/
|
||||
export function createStatusBadge({
|
||||
documentRef = globalThis.document,
|
||||
label = '',
|
||||
appearance = 'pill',
|
||||
className = '',
|
||||
hidden = false,
|
||||
} = {}) {
|
||||
const badge = documentRef.createElement('span');
|
||||
badge.className = [
|
||||
'gev-status-badge',
|
||||
appearance === 'box' && 'gev-status-badge--box',
|
||||
appearance === 'plain' && 'gev-status-badge--plain',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
updateStatusBadge(badge, { label, hidden });
|
||||
return badge;
|
||||
}
|
||||
|
||||
/** Paint controlled text/visibility without replacing nodes or redundant writes. */
|
||||
export function updateStatusBadge(badge, { label, hidden = false }) {
|
||||
if (badge.textContent !== label) badge.textContent = label;
|
||||
if (badge.hidden !== hidden) badge.hidden = hidden;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStatusBadge, updateStatusBadge } from './statusBadge.js';
|
||||
import { createRailCards } from './railCards.js';
|
||||
import { railFixture } from './railTestFixture.mjs';
|
||||
|
||||
test('badges paint literal translated text without installing announcement or interaction semantics', () => {
|
||||
const { document } = railFixture();
|
||||
for (const appearance of ['pill', 'box', 'plain']) {
|
||||
const badge = createStatusBadge({
|
||||
documentRef: document,
|
||||
appearance,
|
||||
className: 'feature-hook',
|
||||
label: '<img src=x onerror=alert(1)> · НЕИЗВЕСТНО',
|
||||
});
|
||||
assert.equal(badge.tagName, 'SPAN');
|
||||
assert.equal(
|
||||
badge.textContent,
|
||||
'<img src=x onerror=alert(1)> · НЕИЗВЕСТНО',
|
||||
);
|
||||
assert.equal(badge.children.length, 0);
|
||||
assert.equal(badge.classList.contains('gev-status-badge'), true);
|
||||
assert.equal(badge.classList.contains('feature-hook'), true);
|
||||
assert.equal(badge.getAttribute('role'), null);
|
||||
assert.equal(badge.getAttribute('aria-live'), null);
|
||||
assert.equal(badge.getAttribute('tabindex'), null);
|
||||
assert.equal(
|
||||
badge.classList.contains(`gev-status-badge--${appearance}`),
|
||||
appearance !== 'pill',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('painting preserves feature state, live regions and focus with zero identical writes', () => {
|
||||
let writes = 0;
|
||||
const { document } = railFixture(() => writes++);
|
||||
const badge = createStatusBadge({ documentRef: document, hidden: true });
|
||||
badge.setAttribute('role', 'status');
|
||||
badge.setAttribute('aria-live', 'polite');
|
||||
badge.dataset.frameState = 'ready';
|
||||
const focusOwner = document.createElement('button');
|
||||
focusOwner.focus();
|
||||
updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
|
||||
assert.equal(badge.hidden, false);
|
||||
writes = 0;
|
||||
updateStatusBadge(badge, { label: 'SNAPSHOT · DEGRADED' });
|
||||
assert.equal(writes, 0);
|
||||
assert.equal(badge.getAttribute('role'), 'status');
|
||||
assert.equal(badge.getAttribute('aria-live'), 'polite');
|
||||
assert.equal(badge.dataset.frameState, 'ready');
|
||||
assert.equal(document.activeElement, focusOwner);
|
||||
});
|
||||
|
||||
test('coverage badges keep keyed identity, hide empty coverage and never claim feed health', () => {
|
||||
const f = railFixture();
|
||||
const view = createRailCards({ ...f, badgeClassName: 'weather-coverage' });
|
||||
const card = { id: 'radar', title: 'Rain radar', badge: 'CONUS', blocks: [] };
|
||||
view.update([card]);
|
||||
const badge = f.find((node) => node.classList.contains('gev-status-badge'));
|
||||
const header = f.container.children[0].children[0];
|
||||
header.focus();
|
||||
assert.equal(badge.classList.contains('gev-status-badge--plain'), true);
|
||||
assert.equal(badge.classList.contains('weather-coverage'), true);
|
||||
view.update([{ ...card, badge: '' }]);
|
||||
assert.equal(badge.hidden, true);
|
||||
view.update([{ ...card, badge: 'Global · 1° grid' }]);
|
||||
assert.equal(badge.hidden, false);
|
||||
assert.equal(badge.textContent, 'Global · 1° grid');
|
||||
assert.equal(
|
||||
f.find((node) => node.classList.contains('gev-status-badge')),
|
||||
badge,
|
||||
);
|
||||
assert.deepEqual({ ...badge.dataset }, {});
|
||||
assert.equal(f.document.activeElement, header);
|
||||
view.destroy();
|
||||
view.update([card]);
|
||||
assert.equal(f.container.children.length, 0);
|
||||
});
|
||||
@@ -250,7 +250,7 @@ function statusPatternText(piece) {
|
||||
}
|
||||
|
||||
/** One piece of a status line in the UI language, or as it came. */
|
||||
export function translateStatusPiece(piece) {
|
||||
function translateStatusPiece(piece) {
|
||||
const known = translateKnown(piece);
|
||||
if (known !== piece) return known;
|
||||
return statusPatternText(piece) ?? piece;
|
||||
|
||||
+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
@@ -168,8 +168,9 @@ function presentViewer(state) {
|
||||
const { street } = state;
|
||||
const right = [];
|
||||
if (street.isPano) right.push('360°');
|
||||
// 359.5° and above rounds to north; modulo keeps it from printing 360°.
|
||||
if (Number.isFinite(street.bearing))
|
||||
right.push(`${Math.round(street.bearing)}°`);
|
||||
right.push(`${Math.round(street.bearing) % 360}°`);
|
||||
if (street.capturedAt)
|
||||
right.push(formatDate(street.capturedAt, street.capturedAtPrecision));
|
||||
const left = [];
|
||||
@@ -188,18 +189,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 +230,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 +257,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 +302,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 +327,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,15 @@ 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('a bearing that rounds to north reads 0°, not 360°', () => {
|
||||
const caption = (bearing) =>
|
||||
presentStreetLevelPanel(snapshot({ street: { open: true, bearing } }))
|
||||
.viewer.captionRight;
|
||||
assert.equal(caption(359.4), '359°');
|
||||
assert.equal(caption(359.6), '0°');
|
||||
});
|
||||
|
||||
test('an image without a creator name leaves the left caption empty', () => {
|
||||
@@ -385,27 +293,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';
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/* Ordinary actions share one surface; feature classes own placement and width.
|
||||
* Legacy scene-btn hooks keep untouched toggles/Street Level controls stable. */
|
||||
.gev-action-button,
|
||||
.scene-btn {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 7px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 1.2px;
|
||||
padding: 6px 9px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
opacity var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gev-action-button:hover:not(:disabled),
|
||||
.scene-btn:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: rgba(0, 212, 255, 0.14);
|
||||
}
|
||||
|
||||
.gev-action-button:disabled,
|
||||
.scene-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.gev-action-button--danger:hover:not(:disabled),
|
||||
.scene-btn-danger:hover:not(:disabled) {
|
||||
color: #ff7272;
|
||||
border-color: #ff7272;
|
||||
background: rgba(255, 88, 88, 0.15);
|
||||
}
|
||||
|
||||
/* Shot-row actions retain their smaller footprint and quieter hover treatment. */
|
||||
.gev-action-button--compact {
|
||||
border-radius: 6px;
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.8px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.gev-action-button--compact:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--glass-border-hover);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.gev-action-button--compact.gev-action-button--danger:hover:not(:disabled) {
|
||||
color: #ff7e7e;
|
||||
border-color: #ff7e7e;
|
||||
}
|
||||
+22
-72
@@ -109,6 +109,10 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.cctv-cal-input {
|
||||
width: 58px;
|
||||
}
|
||||
|
||||
.cctv-cal-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
@@ -129,26 +133,6 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
||||
background: rgba(0, 212, 255, 0.08);
|
||||
}
|
||||
|
||||
.cctv-cal-input {
|
||||
width: 58px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.7px;
|
||||
color: var(--text-primary);
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
border: 1px solid rgba(0, 212, 255, 0.5);
|
||||
border-radius: 4px;
|
||||
padding: 1px 3px;
|
||||
outline: none;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.cctv-cal-input::-webkit-outer-spin-button,
|
||||
.cctv-cal-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cctv-calibration-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -162,26 +146,9 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
||||
#cctv-camera-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 7px;
|
||||
color: var(--text-primary);
|
||||
padding: 6px 8px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#cctv-camera-select:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
#cctv-camera-select:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
#cctv-enable-btn.active,
|
||||
@@ -194,41 +161,32 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
||||
}
|
||||
|
||||
#cctv-quality-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 90px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 1px;
|
||||
color: #8fe7ff;
|
||||
background: rgba(0, 212, 255, 0.08);
|
||||
text-transform: uppercase;
|
||||
--status-badge-border: rgba(0, 212, 255, 0.2);
|
||||
--status-badge-color: #8fe7ff;
|
||||
--status-badge-background: rgba(0, 212, 255, 0.08);
|
||||
}
|
||||
|
||||
/* CAL badge states (cctv-v2 §3b, amended by LOCKED §9.2 — panel-only, no
|
||||
in-world tint). Three visually distinct states: calibrated / curated /
|
||||
raw-prior. */
|
||||
#cctv-quality-chip[data-cal-badge='calibrated'] {
|
||||
color: #adffbf;
|
||||
border-color: rgba(141, 255, 135, 0.42);
|
||||
background: rgba(141, 255, 135, 0.12);
|
||||
--status-badge-color: #adffbf;
|
||||
--status-badge-border: rgba(141, 255, 135, 0.42);
|
||||
--status-badge-background: rgba(141, 255, 135, 0.12);
|
||||
}
|
||||
|
||||
#cctv-quality-chip[data-cal-badge='curated'] {
|
||||
color: #ffe6a2;
|
||||
border-color: rgba(255, 217, 122, 0.35);
|
||||
background: rgba(255, 217, 122, 0.1);
|
||||
--status-badge-color: #ffe6a2;
|
||||
--status-badge-border: rgba(255, 217, 122, 0.35);
|
||||
--status-badge-background: rgba(255, 217, 122, 0.1);
|
||||
}
|
||||
|
||||
/* Unsaved live edits (save-gated persistence, viewshed design §3e). */
|
||||
#cctv-quality-chip[data-cal-badge='edited'] {
|
||||
color: #ffd08a;
|
||||
border-color: rgba(255, 176, 82, 0.5);
|
||||
background: rgba(255, 176, 82, 0.14);
|
||||
--status-badge-color: #ffd08a;
|
||||
--status-badge-border: rgba(255, 176, 82, 0.5);
|
||||
--status-badge-background: rgba(255, 176, 82, 0.14);
|
||||
}
|
||||
|
||||
.cctv-frame-wrap {
|
||||
@@ -346,26 +304,18 @@ html:not([lang='en']) .cctv-controls .scene-btn {
|
||||
|
||||
#cctv-source-badge {
|
||||
align-self: flex-start;
|
||||
border: 1px solid rgba(0, 212, 255, 0.32);
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 212, 255, 0.09);
|
||||
color: #9defff;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.3px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
#cctv-source-badge[data-frame-state='loading'] {
|
||||
color: #ffe59b;
|
||||
border-color: rgba(255, 205, 92, 0.45);
|
||||
background: rgba(255, 205, 92, 0.1);
|
||||
--status-badge-color: #ffe59b;
|
||||
--status-badge-border: rgba(255, 205, 92, 0.45);
|
||||
--status-badge-background: rgba(255, 205, 92, 0.1);
|
||||
}
|
||||
|
||||
#cctv-source-badge[data-frame-state='error'] {
|
||||
color: #ffb0b0;
|
||||
border-color: rgba(255, 102, 102, 0.5);
|
||||
background: rgba(255, 72, 72, 0.11);
|
||||
--status-badge-color: #ffb0b0;
|
||||
--status-badge-border: rgba(255, 102, 102, 0.5);
|
||||
--status-badge-background: rgba(255, 72, 72, 0.11);
|
||||
}
|
||||
|
||||
.cctv-summary-label {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/* Tight tracking keeps Chinese labels readable; preserve Latin tracking for
|
||||
intel HUD readouts and content marked translate="no", including its containers. */
|
||||
html:lang(zh)
|
||||
body
|
||||
:not(
|
||||
#intel-hud,
|
||||
#intel-hud *,
|
||||
[translate='no'],
|
||||
[translate='no'] *,
|
||||
:has([translate='no'])
|
||||
) {
|
||||
letter-spacing: 0.02em !important;
|
||||
}
|
||||
|
||||
/* Keep small Chinese labels legible at a minimum of 9px. */
|
||||
html:lang(zh) #command-dock .gev-mic-label,
|
||||
html:lang(zh) #command-dock #gev-voice-detail,
|
||||
html:lang(zh) .gev-voice-tier-btn,
|
||||
html:lang(zh) .panel-layer-toggle {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
/* A two-glyph button label must not break between its glyphs. */
|
||||
html:lang(zh) .pp-mode-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Keep unit symbols in their catalog case, including the cockpit readouts. */
|
||||
html:lang(zh) #intel-hud *,
|
||||
html:lang(zh) #cockpit-hud * {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
/* Move short travel modes (步行 / 骑行) to the next line as whole words. */
|
||||
html:lang(zh) [data-layer-id='directions'] .data-toggle-meta {
|
||||
word-break: keep-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Leave room for the complete sensor name and resolution on two lines. */
|
||||
html:lang(zh) .recent-imagery-readout .ri-strip {
|
||||
height: 131px;
|
||||
}
|
||||
html:lang(zh) .recent-imagery-readout .ri-card {
|
||||
height: 123px;
|
||||
grid-template-rows: 42px 13px 22px 11px 18px;
|
||||
}
|
||||
html:lang(zh) .recent-imagery-readout .ri-card-sensor {
|
||||
white-space: normal;
|
||||
text-overflow: clip;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -478,6 +478,12 @@ body:not(.ui-clean-view):not(.recording-mode):has(
|
||||
bottom: calc(2vh + 5.4rem);
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud[data-variant='minimal']
|
||||
.hud-bottom-left {
|
||||
bottom: calc(2vh + 6.5rem);
|
||||
}
|
||||
|
||||
#intel-hud[data-variant='minimal'] .hud-bottom-bar {
|
||||
bottom: calc(2vh + 9rem);
|
||||
}
|
||||
|
||||
+26
-325
@@ -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;
|
||||
@@ -641,6 +501,27 @@ html:not([lang='en']) .panel-collapse-btn {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.pp-scope-toggle input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0 0 0 auto;
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pp-scope-toggle:has(input:checked) .pp-label {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.pp-scope-toggle:has(input:focus-visible) {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
#scope-slider-row:has(input:disabled) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* ── Bloom Slider (inline with toggle) ───────── */
|
||||
.pp-toggle-group {
|
||||
display: flex;
|
||||
@@ -672,110 +553,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 +595,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 +612,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;
|
||||
}
|
||||
@@ -975,27 +748,12 @@ html:not([lang='en']) .pp-slider-row > .pp-slider-mini-label {
|
||||
.pp-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 6px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.6px;
|
||||
padding: 2px 6px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* DISPLAY ▸ Draw: shape segment, label field, colour, and the one-line hint */
|
||||
#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 +882,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 +898,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 +920,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 +960,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 +1126,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;
|
||||
}
|
||||
+13
-1
@@ -171,6 +171,7 @@
|
||||
.dock-tray-toggle,
|
||||
.dock-popover,
|
||||
.scene-btn,
|
||||
.gev-action-button:not(.gev-action-button--compact),
|
||||
.context-mode-button,
|
||||
.map-stack-chip,
|
||||
.key-setup-chip,
|
||||
@@ -506,11 +507,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;
|
||||
@@ -541,12 +544,21 @@
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] :where(.pp-select, select, input, textarea) {
|
||||
:root[data-ui-theme='cyber']
|
||||
:where(.pp-select, select, input, textarea):where(:not(.gev-field)) {
|
||||
border-color: rgba(206, 222, 226, 0.24);
|
||||
background-color: rgba(26, 35, 39, 0.96);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Shared fields consume theme palettes through their own surface rule.
|
||||
Feature palettes (Radio) retain precedence without restyling native fields. */
|
||||
:root[data-ui-theme='cyber'] .gev-field {
|
||||
--gev-field-border-color: rgba(206, 222, 226, 0.24);
|
||||
--gev-field-background: rgba(26, 35, 39, 0.96);
|
||||
--gev-field-color: var(--text-primary);
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber']
|
||||
:where(
|
||||
.panel-title,
|
||||
|
||||
@@ -209,6 +209,49 @@
|
||||
letter-spacing: 0.055rem;
|
||||
}
|
||||
|
||||
.first-run-dismiss {
|
||||
position: relative;
|
||||
padding: 0 0 0 0.75rem;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* A 44px touch target drawn outside the layout, so the footer row keeps the
|
||||
height of its one line of text; a 44px button box would nearly treble it. */
|
||||
.first-run-dismiss::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 44px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.first-run-dismiss:hover,
|
||||
.first-run-dismiss:focus-visible {
|
||||
color: rgba(54, 220, 255, 0.86);
|
||||
}
|
||||
|
||||
.first-run-dismiss-touch {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* No hover and a coarse pointer: a phone or tablet, where ESC means nothing. */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.first-run-dismiss-key {
|
||||
display: none;
|
||||
}
|
||||
.first-run-dismiss-touch {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.first-run-suppress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -270,8 +313,9 @@ body.scene-playback-mode #first-run-launcher {
|
||||
#first-run-launcher.visible {
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
}
|
||||
/* Five tiles have to clear the dock on a 375 x 812 phone, so the rows tighten
|
||||
and the redundant "ESC to dismiss" hint (no ESC key on a phone) drops. */
|
||||
/* Five tiles have to clear the dock on a 375 x 812 phone, so the rows
|
||||
tighten. The dismiss button stays: it is the only way to close the card
|
||||
without choosing a mission on a phone, which has no ESC key. */
|
||||
#first-run-launcher h2 {
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
@@ -289,9 +333,6 @@ body.scene-playback-mode #first-run-launcher {
|
||||
.first-run-choices small {
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
.first-run-footer > span:last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Short viewports — landscape phones, small laptop windows. The chrome tightens
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/* Native selects and numeric inputs. This file owns the field surface/states;
|
||||
features and themes supply palette variables and retain their own layout. */
|
||||
.gev-field {
|
||||
min-width: 0;
|
||||
background: var(--gev-field-background, rgba(255, 255, 255, 0.06));
|
||||
border: 1px solid var(--gev-field-border-color, rgba(255, 255, 255, 0.14));
|
||||
border-radius: 7px;
|
||||
color: var(--gev-field-color, var(--text-primary));
|
||||
padding: 6px 8px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gev-field:focus-visible {
|
||||
outline: 2px solid var(--text-primary);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
|
||||
.gev-field:disabled {
|
||||
opacity: var(--gev-field-disabled-opacity, 0.45);
|
||||
}
|
||||
|
||||
.gev-field--compact {
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.6px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
/* Compact inline editors share density and hide native number spinners.
|
||||
Their containing feature sets width to fit the surrounding readout. */
|
||||
.gev-field--inline {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.7px;
|
||||
--gev-field-background: rgba(0, 212, 255, 0.1);
|
||||
--gev-field-border-color: rgba(0, 212, 255, 0.5);
|
||||
border-radius: 4px;
|
||||
padding: 1px 3px;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.gev-field--inline::-webkit-outer-spin-button,
|
||||
.gev-field--inline::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -298,3 +298,20 @@
|
||||
#location-search.searching {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.location-search-error {
|
||||
/* Its own line under the search row, which clips overflow in the dock. */
|
||||
margin: 6px 0 0;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(255, 99, 99, 0.45);
|
||||
border-radius: 6px;
|
||||
background: rgba(30, 8, 10, 0.92);
|
||||
color: #ffb3b3;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.location-search-error:empty {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
/* ── Phone shell (≤720px, body.mobile-shell; src/ui/mobileShell.js) ─────────
|
||||
* The globe is full screen. A bottom tab bar opens one sheet; the sheet shows
|
||||
* the panels of that tab from the rails or dock, everything else stays hidden.
|
||||
*/
|
||||
:root {
|
||||
--mobile-tab-height: 58px;
|
||||
--mobile-bottom-inset: env(safe-area-inset-bottom, 0px);
|
||||
--mobile-top-inset: env(safe-area-inset-top, 0px);
|
||||
--mobile-tab-space: calc(
|
||||
var(--mobile-tab-height) + var(--mobile-bottom-inset)
|
||||
);
|
||||
--mobile-sheet-max-height: min(62vh, calc(100vh - 168px));
|
||||
--mobile-dock-sheet-max-height: 34vh;
|
||||
--mobile-rail-under-dock-max-height: 40vh;
|
||||
}
|
||||
|
||||
/* Dynamic viewport units keep sheets inside the area a mobile browser's
|
||||
toolbars leave visible. */
|
||||
@supports (height: 1dvh) {
|
||||
:root {
|
||||
--mobile-sheet-max-height: min(62dvh, calc(100dvh - 168px));
|
||||
--mobile-dock-sheet-max-height: 34dvh;
|
||||
--mobile-rail-under-dock-max-height: 40dvh;
|
||||
}
|
||||
}
|
||||
|
||||
#mobile-tab-bar,
|
||||
#mobile-sheet-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell #mobile-tab-bar {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 170;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
height: var(--mobile-tab-space);
|
||||
padding: 0 max(4px, env(safe-area-inset-right, 0px))
|
||||
var(--mobile-bottom-inset) max(4px, env(safe-area-inset-left, 0px));
|
||||
background: rgba(8, 14, 22, 0.94);
|
||||
border-top: 1px solid var(--glass-border);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
body.mobile-shell .mobile-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
padding: 6px 0 4px;
|
||||
color: var(--text-secondary);
|
||||
background: none;
|
||||
border: 0;
|
||||
font: 500 10px/1.2 var(--font-sans);
|
||||
letter-spacing: 0.04em;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body.mobile-shell .mobile-tab-icon {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
body.mobile-shell .mobile-tab[aria-expanded='true'] {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
body.mobile-shell .mobile-tab:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet] #mobile-sheet-backdrop:not([hidden]) {
|
||||
position: fixed;
|
||||
inset: 0 0 var(--mobile-tab-space);
|
||||
z-index: 155;
|
||||
display: block;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
/* ── Top bar: logo left, globe actions right ───────────────────────────── */
|
||||
body.mobile-shell #title-bar {
|
||||
top: calc(var(--mobile-top-inset) + 10px);
|
||||
left: 14px;
|
||||
}
|
||||
|
||||
body.mobile-shell #title-bar .title-logo {
|
||||
width: 58px;
|
||||
}
|
||||
|
||||
body.mobile-shell #title-bar h1 > :not(.title-logo),
|
||||
body.mobile-shell #title-bar .subtitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell #top-center-actions {
|
||||
top: calc(var(--mobile-top-inset) + 10px);
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
/* ── HUD: one compact readout under the top bar ────────────────────────── */
|
||||
body.mobile-shell #intel-hud .hud-edge,
|
||||
body.mobile-shell #intel-hud .hud-corner,
|
||||
body.mobile-shell #intel-hud .hud-top-bar,
|
||||
body.mobile-shell #intel-hud .hud-bottom-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell #intel-hud .hud-bottom-left {
|
||||
top: calc(var(--mobile-top-inset) + 62px);
|
||||
bottom: auto;
|
||||
left: 16px;
|
||||
font-size: 9px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ── Panels: hidden unless their tab's sheet is open ───────────────────── */
|
||||
body.mobile-shell #left-panel-stack,
|
||||
body.mobile-shell #right-context-rail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack,
|
||||
body.mobile-shell[data-mobile-sheet='cameras'] #right-context-rail,
|
||||
body.mobile-shell[data-mobile-sheet='context'] #right-context-rail,
|
||||
body.mobile-shell[data-mobile-sheet='more'] #right-context-rail {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
right: 0;
|
||||
bottom: var(--mobile-tab-space);
|
||||
left: 0;
|
||||
z-index: 160;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
max-height: var(--mobile-sheet-max-height);
|
||||
padding: 14px 10px 10px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
pointer-events: auto;
|
||||
background: rgba(8, 14, 22, 0.96);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-bottom: 0;
|
||||
border-radius: 18px 18px 0 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
body.mobile-shell #left-panel-stack > *,
|
||||
body.mobile-shell #right-context-rail > *,
|
||||
/* Rail rules show expanded Weather and Recent Imagery by id; on phones they
|
||||
belong to the Context sheet only. */
|
||||
body.mobile-shell #right-context-rail > #weather-panel,
|
||||
body.mobile-shell #right-context-rail > #recent-imagery-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Rail-scoped ids so a rail's own hiding rules (for example the Tactical HUD
|
||||
hiding collapsed siblings) never take a panel out of an open sheet. */
|
||||
body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #data-panel,
|
||||
body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #scene-panel,
|
||||
body.mobile-shell[data-mobile-sheet='cameras']
|
||||
#right-context-rail
|
||||
> #cctv-panel,
|
||||
body.mobile-shell[data-mobile-sheet='cameras']
|
||||
#right-context-rail
|
||||
> #street-level-panel,
|
||||
body.mobile-shell[data-mobile-sheet='context']
|
||||
#right-context-rail
|
||||
> #global-context-panel,
|
||||
body.mobile-shell[data-mobile-sheet='context']
|
||||
#right-context-rail
|
||||
> #weather-panel:not([hidden]),
|
||||
body.mobile-shell[data-mobile-sheet='context']
|
||||
#right-context-rail
|
||||
> #recent-imagery-panel:not([hidden]),
|
||||
body.mobile-shell[data-mobile-sheet='more'] #right-context-rail > #pp-toggles {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ── Dock: voice floats above the tab bar; search and presets are sheets ─ */
|
||||
body.mobile-shell #command-dock {
|
||||
/* The dock's tray rules size voice by this basis; a column makes it a height. */
|
||||
--dock-voice-width: auto;
|
||||
top: auto;
|
||||
bottom: calc(var(--mobile-tab-space) + 10px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
padding: 0 10px;
|
||||
pointer-events: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
/* The desktop dock centres itself with left: 50% and a translate under
|
||||
:has() selectors that outrank any phone selector; on phones it spans the
|
||||
width, so these few geometry properties must win. */
|
||||
body.mobile-shell #command-dock {
|
||||
right: 0 !important;
|
||||
left: 0 !important;
|
||||
width: auto !important;
|
||||
max-width: none !important;
|
||||
transform: none !important;
|
||||
translate: none !important;
|
||||
}
|
||||
|
||||
body.mobile-shell #command-dock > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
body.mobile-shell #location-bar,
|
||||
body.mobile-shell #control-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet] #command-dock {
|
||||
z-index: 165;
|
||||
bottom: calc(var(--mobile-tab-space) + var(--mobile-sheet-height, 0px) + 8px);
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='search'] #command-dock > #location-bar,
|
||||
body.mobile-shell[data-mobile-sheet='more'] #command-dock > #control-panel {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
display: flex;
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Search and More: the dock becomes the top of the sheet, flush with the rail
|
||||
sheet below it (More) or alone above the tab bar (Search). */
|
||||
body.mobile-shell[data-mobile-sheet='search'] #command-dock,
|
||||
body.mobile-shell[data-mobile-sheet='more'] #command-dock {
|
||||
bottom: calc(var(--mobile-tab-space) + var(--mobile-sheet-height, 0px));
|
||||
align-items: stretch;
|
||||
max-height: var(--mobile-dock-sheet-max-height);
|
||||
padding: 14px 10px 8px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
pointer-events: auto;
|
||||
background: rgba(8, 14, 22, 0.96);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-bottom: 0;
|
||||
border-radius: 18px 18px 0 0;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='more'] #right-context-rail {
|
||||
max-height: var(--mobile-rail-under-dock-max-height);
|
||||
padding-top: 4px;
|
||||
border-top: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Presets and Location show their popover inline instead of as a floating
|
||||
tray; the dock scrolls, so a tray above it would be clipped. */
|
||||
body.mobile-shell[data-mobile-sheet='more']
|
||||
#command-dock
|
||||
#control-panel
|
||||
.panel-inner,
|
||||
body.mobile-shell[data-mobile-sheet='more']
|
||||
#command-dock
|
||||
#control-panel-popover,
|
||||
body.mobile-shell[data-mobile-sheet='search']
|
||||
#command-dock
|
||||
#location-bar
|
||||
.location-inner,
|
||||
body.mobile-shell[data-mobile-sheet='search']
|
||||
#command-dock
|
||||
#location-bar-popover {
|
||||
position: static;
|
||||
inset: auto;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* The tab names the sheet; the tray toggle and pin have nothing to do here. */
|
||||
body.mobile-shell[data-mobile-sheet='more'] #command-dock #control-panel-toggle,
|
||||
body.mobile-shell[data-mobile-sheet='more']
|
||||
#command-dock
|
||||
#control-panel
|
||||
.dock-pin-btn,
|
||||
body.mobile-shell[data-mobile-sheet='search']
|
||||
#command-dock
|
||||
#location-bar-toggle,
|
||||
body.mobile-shell[data-mobile-sheet='search']
|
||||
#command-dock
|
||||
#location-bar
|
||||
.panel-collapse-btn,
|
||||
body.mobile-shell[data-mobile-sheet='search']
|
||||
#command-dock
|
||||
#location-bar
|
||||
.dock-pin-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='more'] #style-buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Search: the field takes the full width; the place pills scroll beneath it. */
|
||||
body.mobile-shell[data-mobile-sheet='search'] #location-bar .location-city-row {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='search']
|
||||
#location-bar
|
||||
.location-search-wrap {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='search'] #location-search {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='search'] #location-bar .location-pills {
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* With a sheet open, voice steps aside; Search and More own the dock. */
|
||||
body.mobile-shell[data-mobile-sheet] #command-dock > #gev-voice-control {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell #command-dock #gev-voice-control {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
flex: none;
|
||||
align-self: center;
|
||||
width: min(280px, 80vw);
|
||||
height: 5.35rem;
|
||||
margin: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ── Credits and corner chips clear the tab bar ────────────────────────── */
|
||||
/* While the tab bar shows, the credit line sits above the voice bar. The
|
||||
dock's placements (including the Minimal HUD's :has() variant) outrank any
|
||||
phone selector, so the offset must win. Clean view keeps its own corner. */
|
||||
body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view)
|
||||
#cesium-credits {
|
||||
right: auto;
|
||||
bottom: calc(var(--mobile-tab-space) + 106px) !important;
|
||||
left: 10px;
|
||||
max-width: calc(100vw - 20px);
|
||||
transform-origin: left bottom;
|
||||
scale: 0.8;
|
||||
}
|
||||
|
||||
/* An open sheet lifts the required attribution above everything it covers. */
|
||||
body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not(
|
||||
.cockpit-mode
|
||||
):not(.ui-clean-view)
|
||||
#cesium-credits {
|
||||
bottom: calc(var(--mobile-sheet-cover, 0px) + 6px) !important;
|
||||
z-index: 166;
|
||||
}
|
||||
|
||||
/* The view switcher clears the tab bar and the voice bar, opposite the credit. */
|
||||
body.mobile-shell:not(.cockpit-mode) #view-switcher {
|
||||
right: 12px;
|
||||
bottom: calc(var(--mobile-tab-space) + 106px);
|
||||
}
|
||||
|
||||
body.mobile-shell #corner-actions {
|
||||
top: calc(var(--mobile-top-inset) + 60px);
|
||||
right: 12px;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
/* Full-screen modes hide the phone chrome like the desktop chrome. Scene
|
||||
playback keeps it: the Scenes panel (Layers tab) holds its STOP control. */
|
||||
body.mobile-shell.ui-clean-view #mobile-tab-bar,
|
||||
body.mobile-shell.recording-mode #mobile-tab-bar,
|
||||
body.mobile-shell.cockpit-mode #mobile-tab-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell.ui-clean-view #command-dock,
|
||||
body.mobile-shell.cockpit-mode #command-dock {
|
||||
bottom: calc(var(--mobile-bottom-inset) + 10px);
|
||||
}
|
||||
@@ -371,3 +371,134 @@
|
||||
#intel-hud[data-variant='minimal'] .hud-summary-wrap {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
/* Scope OFF adds text-sized backing; Scope ON retains the original HUD. */
|
||||
.hud-readout {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
.hud-readout,
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber']) .scene-text-holdout {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
box-shadow: none;
|
||||
border-radius: 2px;
|
||||
padding-block: 0;
|
||||
padding-inline: 6px;
|
||||
margin-block: 0;
|
||||
margin-inline: -6px;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
box-sizing: content-box;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
.hud-readout {
|
||||
display: block;
|
||||
width: max-content;
|
||||
min-width: 0;
|
||||
box-sizing: content-box;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
:is(.hud-top-right, .hud-bottom-right)
|
||||
.hud-readout,
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
#style-indicator
|
||||
.scene-text-holdout {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
/* Preserve glyph opacity without dimming the vertical backplates. */
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
|
||||
.hud-edge {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
|
||||
.hud-edge
|
||||
.hud-readout
|
||||
> * {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off'] #intel-hud[data-variant='operator'] .hud-corner,
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
|
||||
:is(.hud-top-bar, .hud-bottom-bar) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
|
||||
:is(.hud-top-bar, .hud-bottom-bar) {
|
||||
width: fit-content;
|
||||
max-width: calc(100vw - 120px);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
|
||||
:is(.hud-top-bar, .hud-bottom-bar)
|
||||
.hud-readout {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
#title-bar
|
||||
h1::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.12em -6px;
|
||||
z-index: -1;
|
||||
border-radius: 2px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
:is(#title-bar .subtitle, #style-indicator .indicator-label) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber']) #title-bar .subtitle {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
#style-indicator
|
||||
.indicator-value {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud[data-variant='minimal']
|
||||
.hud-readout:has(> :is(.hud-classification, .hud-system)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
#title-bar
|
||||
.frame-rate-readout {
|
||||
margin-top: 6px;
|
||||
}
|
||||
+10
-6
@@ -49,13 +49,15 @@
|
||||
min-height: 36px;
|
||||
}
|
||||
#radio-filter {
|
||||
min-width: 0;
|
||||
/* Radio owns its compact density and arrow space; shared fields paint the
|
||||
palette and disabled state, including this intentional theme exception. */
|
||||
padding: 7px 24px 7px 8px;
|
||||
border: 1px solid rgba(65, 220, 231, 0.28);
|
||||
border-radius: 5px;
|
||||
color: #c5faff;
|
||||
background: rgba(5, 24, 31, 0.88);
|
||||
font: 9px/1.2 var(--font-mono);
|
||||
--gev-field-border-color: rgba(65, 220, 231, 0.28);
|
||||
--gev-field-color: #c5faff;
|
||||
--gev-field-background: rgba(5, 24, 31, 0.88);
|
||||
--gev-field-disabled-opacity: 0.42;
|
||||
}
|
||||
.radio-station-card {
|
||||
display: grid;
|
||||
@@ -273,11 +275,13 @@ html:not([lang='en']) .radio-transport button {
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
.radio-transport button:disabled,
|
||||
#radio-filter:disabled {
|
||||
.radio-transport button:disabled {
|
||||
opacity: 0.42;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
#radio-filter:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
#radio-play-btn.active {
|
||||
color: #fff;
|
||||
border-color: rgba(101, 247, 255, 0.8);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -153,14 +153,6 @@
|
||||
#scene-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 7px;
|
||||
color: var(--text-primary);
|
||||
padding: 6px 8px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* A translated scene name needs the width: the select takes its own row,
|
||||
@@ -184,21 +176,6 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
||||
}
|
||||
|
||||
.scene-btn {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 7px;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 1.2px;
|
||||
padding: 6px 9px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
opacity var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 72px;
|
||||
}
|
||||
@@ -295,49 +272,6 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.scene-shot-btn {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.8px;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.scene-shot-btn:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--glass-border-hover);
|
||||
}
|
||||
|
||||
.scene-shot-btn.scene-shot-danger:hover {
|
||||
color: #ff7e7e;
|
||||
border-color: #ff7e7e;
|
||||
}
|
||||
|
||||
.scene-btn:hover:not(:disabled) {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: rgba(0, 212, 255, 0.14);
|
||||
}
|
||||
|
||||
.scene-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.scene-btn-danger:hover:not(:disabled) {
|
||||
color: #ff7272;
|
||||
border-color: #ff7272;
|
||||
background: rgba(255, 88, 88, 0.15);
|
||||
}
|
||||
|
||||
.scene-progress {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
@@ -429,7 +363,8 @@ html:not([lang='en']) #scene-panel .scene-controls .scene-btn {
|
||||
#param-slider-panel.collapsed #param-sliders,
|
||||
#location-bar.collapsed .poi-row-container,
|
||||
#location-bar.collapsed .location-bar-divider,
|
||||
#location-bar.collapsed .location-city-row {
|
||||
#location-bar.collapsed .location-city-row,
|
||||
#location-bar.collapsed .location-search-error {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/* Read-only badges. Feature CSS supplies meaning through explicit colors;
|
||||
appearance variants change density/shape without interpreting status codes. */
|
||||
.gev-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--status-badge-border, rgba(0, 212, 255, 0.32));
|
||||
border-radius: 999px;
|
||||
background: var(--status-badge-background, rgba(0, 212, 255, 0.09));
|
||||
color: var(--status-badge-color, #9defff);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
letter-spacing: 1.3px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gev-status-badge--box {
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
font-size: 9px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gev-status-badge--plain {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: none;
|
||||
color: var(--status-badge-color, var(--text-dim));
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Explicitly beat the component display rule, including in older browsers. */
|
||||
.gev-status-badge[hidden] {
|
||||
display: none;
|
||||
}
|
||||
+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. */
|
||||
|
||||
@@ -387,3 +387,249 @@ body.recording-mode .gev-pointer-reticle {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Opt-in shared panel: header stays fixed while only the body scrolls. */
|
||||
#gev-voice-control .gev-voice-panel {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 0.7rem);
|
||||
left: 50%;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: clamp(18rem, 26vw, 22rem);
|
||||
max-width: calc(100vw - 1.5rem);
|
||||
max-height: min(
|
||||
28rem,
|
||||
max(7.5rem, calc(100dvh - 12rem - var(--gev-provider-lift, 0px)))
|
||||
);
|
||||
box-sizing: border-box;
|
||||
padding: 0.6rem 0.7rem 0.65rem;
|
||||
color: rgba(223, 245, 250, 0.92);
|
||||
background: rgba(7, 22, 31, 0.96);
|
||||
border: 1px solid rgba(54, 220, 255, 0.34);
|
||||
border-radius: 0.6rem;
|
||||
box-shadow:
|
||||
0 0.85rem 2rem rgba(0, 0, 0, 0.58),
|
||||
0 0 1.2rem rgba(0, 212, 255, 0.08);
|
||||
backdrop-filter: blur(24px) saturate(1.3);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.3);
|
||||
color-scheme: dark;
|
||||
font: 0.75rem/1.45 var(--font-sans);
|
||||
text-align: left;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
:root[data-ui-theme='cyber'] #gev-voice-control .gev-voice-panel {
|
||||
background-color: var(--cyber-panel-surface);
|
||||
background-image: var(--cyber-panel-surface-detail);
|
||||
border-color: var(--glass-border);
|
||||
box-shadow: var(--cyber-panel-shadow);
|
||||
}
|
||||
#gev-voice-control .gev-voice-panel[hidden],
|
||||
#gev-voice-control .gev-voice-panel [hidden] {
|
||||
display: none;
|
||||
}
|
||||
#gev-voice-control .gev-voice-panel-header {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.35rem;
|
||||
padding: 0 0 0.5rem;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
}
|
||||
.gev-voice-panel-title {
|
||||
color: var(--voice-panel-accent, var(--accent));
|
||||
font: 600 0.65rem/1 var(--font-mono);
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
.gev-voice-panel-tabs {
|
||||
margin-left: auto;
|
||||
}
|
||||
#gev-voice-control .gev-voice-panel-header .gev-voice-panel-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
padding: 0;
|
||||
}
|
||||
#gev-voice-control .gev-voice-panel-tab-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
.gev-voice-panel-header [role='tablist'] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
#gev-voice-control
|
||||
.gev-voice-panel
|
||||
:is(.gev-voice-panel-header button, select, input[type='number']) {
|
||||
min-height: 2rem;
|
||||
box-sizing: border-box;
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 0.3rem;
|
||||
font: inherit;
|
||||
}
|
||||
#gev-voice-control .gev-voice-panel .gev-voice-panel-header button {
|
||||
padding: 0 0.6rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
#gev-voice-control #gev-voice-settings-close {
|
||||
width: 2rem;
|
||||
padding: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
#gev-voice-control .gev-voice-panel-header button:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--voice-panel-accent, var(--accent));
|
||||
}
|
||||
#gev-voice-control .gev-voice-panel [role='tab'][aria-selected='true'] {
|
||||
color: var(--voice-panel-accent, var(--accent));
|
||||
border-color: currentColor;
|
||||
box-shadow: 0 -2px 0 currentColor inset;
|
||||
}
|
||||
#gev-voice-control .gev-voice-panel [role='tab']:focus,
|
||||
#gev-voice-control .gev-voice-panel :focus-visible {
|
||||
outline: 2px solid var(--voice-panel-accent, var(--accent));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.gev-voice-panel-body {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
padding: 0.55rem 0.75rem 3px 3px;
|
||||
line-height: 1.45;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.gev-voice-panel-body .gev-voice-card-who {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.gev-voice-panel-body p {
|
||||
margin: 0;
|
||||
}
|
||||
.gev-voice-preference {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.gev-voice-preference-toggle {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
min-height: 2.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.gev-voice-preference-toggle input {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: 0;
|
||||
accent-color: var(--voice-panel-accent, var(--accent));
|
||||
}
|
||||
.gev-voice-setting-title {
|
||||
margin-bottom: 0.4rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.gev-voice-timeout-group {
|
||||
margin-top: 0.65rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid var(--glass-border);
|
||||
}
|
||||
.gev-voice-timeout-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
#gev-voice-timeout-select {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
padding: 0 0.45rem;
|
||||
}
|
||||
#gev-voice-timeout-input {
|
||||
flex: 0 0 4.5rem;
|
||||
width: 4.5rem;
|
||||
padding: 0 0.45rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.gev-voice-timeout-unit {
|
||||
order: 1;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.63rem;
|
||||
}
|
||||
.gev-voice-timeout-controls:has(#gev-voice-timeout-input[hidden])
|
||||
.gev-voice-timeout-unit {
|
||||
display: none;
|
||||
}
|
||||
.gev-voice-settings-note {
|
||||
color: rgba(190, 221, 230, 0.72);
|
||||
font-size: 0.63rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.gev-voice-settings-note:not(:empty) {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
#gev-voice-control .gev-voice-panel .gev-voice-card {
|
||||
position: static;
|
||||
width: 100%;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
animation: none;
|
||||
}
|
||||
#command-dock > #gev-voice-control:has(.gev-voice-panel:not([hidden])) {
|
||||
z-index: 101;
|
||||
}
|
||||
#gev-voice-control:has(.gev-voice-panel:not([hidden])) .gev-voice-help-tray {
|
||||
display: none;
|
||||
}
|
||||
#command-dock #gev-voice-control[data-provider-choice='true'] .gev-voice-panel {
|
||||
bottom: min(
|
||||
calc(100% + 4.8rem + var(--gev-provider-lift)),
|
||||
calc(100dvh - 7.75rem)
|
||||
);
|
||||
}
|
||||
|
||||
#command-dock.dock-has-pinned-tray
|
||||
#gev-voice-control:not([data-provider-choice='true'])
|
||||
.gev-voice-panel {
|
||||
bottom: min(
|
||||
calc(100% + 1.7rem + var(--dock-pinned-stack-height)),
|
||||
calc(100dvh - 18rem)
|
||||
);
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
#gev-voice-control
|
||||
.gev-voice-panel
|
||||
:is(
|
||||
.gev-voice-panel-header button,
|
||||
select,
|
||||
input[type='number'],
|
||||
.gev-voice-card-dismiss
|
||||
) {
|
||||
min-height: 44px;
|
||||
}
|
||||
#gev-voice-control
|
||||
.gev-voice-panel
|
||||
:is(.gev-voice-panel-header button, .gev-voice-card-dismiss) {
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* A connection error remains reachable even while the manual panel is open. */
|
||||
#gev-voice-control:has(.gev-voice-panel) .gev-voice-error-tray {
|
||||
z-index: 12;
|
||||
}
|
||||
@@ -62,6 +62,15 @@
|
||||
color: #ffc46b;
|
||||
}
|
||||
|
||||
.gev-voice-cost-warning {
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.gev-voice-cost-warning[data-level='unknown'] {
|
||||
color: #ffc46b;
|
||||
}
|
||||
|
||||
.gev-voice-cost-value[data-level='cap'] {
|
||||
color: #ff6b6b;
|
||||
font-weight: 600;
|
||||
@@ -111,7 +120,7 @@ html:not([lang='en']) #command-dock .gev-voice-cost {
|
||||
padding-left: 0.1rem;
|
||||
}
|
||||
|
||||
html:not([lang='en']) #command-dock .gev-voice-tier-btn {
|
||||
html:not([lang='en']) #command-dock .gev-voice-heading .gev-voice-tier-btn {
|
||||
min-width: 0;
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
@@ -140,3 +149,418 @@ html:not([lang='en'])
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
#gev-voice-control[data-provider-choice='true'] {
|
||||
--gev-provider-lift: 0px;
|
||||
}
|
||||
|
||||
/* Provider choice belongs to the existing voice control, not a separate panel. */
|
||||
.gev-voice-provider-field {
|
||||
position: absolute;
|
||||
bottom: calc(100% + var(--gev-provider-lift, 0px));
|
||||
z-index: 9;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 8px 6px;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-panel, #09121b);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.5rem;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.gev-voice-provider-field[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.gev-voice-provider-field select {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
min-height: 30px;
|
||||
color: var(--text-primary);
|
||||
background: #09121b;
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
#gev-voice-control:hover .gev-voice-provider-field,
|
||||
#gev-voice-control:focus-within .gev-voice-provider-field {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#command-dock
|
||||
#gev-voice-control[data-provider-choice='true']
|
||||
.gev-voice-help-tray,
|
||||
#command-dock #gev-voice-control[data-provider-choice='true'] .gev-voice-card,
|
||||
#command-dock
|
||||
#gev-voice-control[data-provider-choice='true']
|
||||
.gev-voice-error-tray {
|
||||
bottom: calc(100% + 4.8rem + var(--gev-provider-lift));
|
||||
}
|
||||
#command-dock.dock-has-pinned-tray
|
||||
#gev-voice-control[data-provider-choice='true'] {
|
||||
--gev-provider-lift: calc(1.7rem + var(--dock-pinned-stack-height));
|
||||
}
|
||||
/* Pinned trays separate the chooser from its hover owner. Keep it reachable
|
||||
across that gap without requiring keyboard focus to hold it open. */
|
||||
#command-dock.dock-has-pinned-tray .gev-voice-provider-field {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
/* The attribution clearance also creates a gap for mouse users in a
|
||||
narrow window, so the raised chooser must not depend on root hover. */
|
||||
.gev-voice-provider-field {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#command-dock #gev-voice-control[data-provider-choice='true'] {
|
||||
--gev-provider-lift: 3rem;
|
||||
}
|
||||
#command-dock.dock-has-pinned-tray
|
||||
#gev-voice-control[data-provider-choice='true'] {
|
||||
--gev-provider-lift: calc(3.5rem + var(--dock-pinned-stack-height));
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
#command-dock #gev-voice-control[data-provider-choice='true'] {
|
||||
--gev-provider-lift: calc(2vh + 2.5rem);
|
||||
}
|
||||
#command-dock.dock-has-pinned-tray
|
||||
#gev-voice-control[data-provider-choice='true'] {
|
||||
--gev-provider-lift: calc(2vh + 3rem + var(--dock-pinned-stack-height));
|
||||
}
|
||||
}
|
||||
@media (hover: none) {
|
||||
.gev-voice-provider-field {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* The opt-in provider and gear share a discoverable row at the existing anchor. */
|
||||
#gev-voice-control:has(> .gev-voice-settings-button) {
|
||||
--gev-voice-settings-gap: 0.75rem;
|
||||
--gev-voice-tray-clearance: 0px;
|
||||
}
|
||||
#gev-voice-control:has(> .gev-voice-settings-button)
|
||||
.gev-voice-provider-field:not([hidden]) {
|
||||
bottom: calc(
|
||||
100% + var(--gev-provider-lift, 0px) + var(--gev-voice-settings-gap)
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 46px 6px 6px;
|
||||
border-color: rgba(123, 189, 211, 0.28);
|
||||
background: rgba(9, 18, 27, 0.88);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.46);
|
||||
color-scheme: dark;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
#gev-voice-control:has(> .gev-voice-settings-button)
|
||||
.gev-voice-provider-field
|
||||
> span {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
#gev-voice-control:has(> .gev-voice-settings-button)
|
||||
.gev-voice-provider-field
|
||||
select {
|
||||
min-height: 34px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
#gev-voice-control .gev-voice-settings-button {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 0.25rem;
|
||||
z-index: 11;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
min-width: 34px;
|
||||
min-height: 34px;
|
||||
padding: 0;
|
||||
color: rgba(190, 221, 230, 0.72);
|
||||
background: rgba(9, 18, 27, 0.92);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 0.3rem;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
#gev-voice-control[data-provider-choice='true']:has(
|
||||
.gev-voice-provider-field:not([hidden])
|
||||
)
|
||||
.gev-voice-settings-button {
|
||||
top: auto;
|
||||
right: 7px;
|
||||
bottom: calc(
|
||||
100% + var(--gev-provider-lift, 0px) + var(--gev-voice-settings-gap) + 7px
|
||||
);
|
||||
}
|
||||
#gev-voice-control
|
||||
.gev-voice-settings-button:is(:hover, [aria-expanded='true']) {
|
||||
color: var(--voice-panel-accent, var(--accent));
|
||||
border-color: currentColor;
|
||||
background: rgba(0, 212, 255, 0.12);
|
||||
}
|
||||
#gev-voice-control .gev-voice-settings-button:focus-visible {
|
||||
outline: 2px solid var(--voice-panel-accent, var(--accent));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
:root[data-ui-theme='cyber']
|
||||
#gev-voice-control:has(> .gev-voice-settings-button) {
|
||||
--voice-panel-accent: var(--cyber-red-bright);
|
||||
}
|
||||
:root[data-ui-theme='cyber']
|
||||
#gev-voice-control:has(> .gev-voice-settings-button)
|
||||
.gev-voice-provider-field {
|
||||
background: var(--cyber-panel-surface);
|
||||
border-color: var(--glass-border);
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
#gev-voice-control .gev-voice-settings-button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
}
|
||||
#gev-voice-control:has(> .gev-voice-settings-button)
|
||||
.gev-voice-provider-field:not([hidden]) {
|
||||
padding-right: 56px;
|
||||
}
|
||||
#gev-voice-control:has(> .gev-voice-settings-button)
|
||||
.gev-voice-provider-field
|
||||
select {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the row and popup on the same lift, with a small gap above pinned
|
||||
trays. On very short screens the shared lift still yields to the viewport. */
|
||||
#command-dock.dock-has-pinned-tray
|
||||
#gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
) {
|
||||
--gev-voice-settings-gap: 0.375rem;
|
||||
--gev-provider-lift: min(
|
||||
calc(var(--dock-pinned-stack-height) + var(--gev-voice-tray-clearance)),
|
||||
max(0px, calc(100dvh - 20rem))
|
||||
);
|
||||
}
|
||||
|
||||
/* Match the attribution-aware tray offsets without moving the trays. The
|
||||
popup can scroll before the shared viewport budget caps its lift. */
|
||||
@media (max-width: 900px) {
|
||||
#gev-voice-control:has(> .gev-voice-settings-button) {
|
||||
--gev-voice-tray-clearance: calc(3rem - 1.2rem);
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
#gev-voice-control:has(> .gev-voice-settings-button) {
|
||||
--gev-voice-tray-clearance: calc(2vh + 2.5rem - 1.2rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Short touch landscapes have room beside the dock, not above its pinned
|
||||
stack. Keep the same chooser and gear together in that empty lower gutter;
|
||||
the open popup remains a bounded, scrollable overlay above the microphone. */
|
||||
@media (pointer: coarse) and (orientation: landscape) and (min-width: 480px) and (max-height: 500px) {
|
||||
#command-dock
|
||||
> #gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
),
|
||||
#command-dock.dock-has-pinned-tray
|
||||
> #gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
) {
|
||||
--gev-provider-lift: 0px;
|
||||
--gev-landscape-row-width: clamp(
|
||||
108px,
|
||||
calc((100vw - var(--dock-unified-width)) / 2 - 0.875rem),
|
||||
calc(100% - 2px)
|
||||
);
|
||||
}
|
||||
#command-dock
|
||||
> #gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
)
|
||||
> .gev-voice-provider-field:not([hidden]) {
|
||||
left: calc(50% - 50vw + 0.5rem);
|
||||
right: auto;
|
||||
bottom: 0.375rem;
|
||||
width: var(--gev-landscape-row-width);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
#command-dock
|
||||
> #gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
)
|
||||
> .gev-voice-settings-button {
|
||||
left: calc(50% - 50vw + 0.5rem + var(--gev-landscape-row-width) - 51px);
|
||||
right: auto;
|
||||
top: auto;
|
||||
bottom: calc(0.375rem + 7px);
|
||||
}
|
||||
}
|
||||
|
||||
/* The revealed row clears pinned wings without making the Voice column's
|
||||
noninteractive gaps cover their controls. Keep hit surfaces stable so hover
|
||||
promotion cannot oscillate when the pointer crosses root padding. */
|
||||
#command-dock
|
||||
> #gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
) {
|
||||
pointer-events: none;
|
||||
}
|
||||
#command-dock
|
||||
> #gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
)
|
||||
> :is(
|
||||
#gev-voice-button,
|
||||
.gev-voice-heading,
|
||||
.gev-voice-readout,
|
||||
.gev-voice-provider-field,
|
||||
.gev-voice-settings-button,
|
||||
.gev-voice-panel,
|
||||
.gev-voice-error-tray
|
||||
) {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
#command-dock.dock-has-pinned-tray
|
||||
> #gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
):is(
|
||||
:hover,
|
||||
[data-row-grace],
|
||||
:has(
|
||||
:focus-visible,
|
||||
#gev-voice-provider:focus,
|
||||
.gev-voice-panel[data-view='settings']:not([hidden])
|
||||
)
|
||||
) {
|
||||
z-index: 101;
|
||||
}
|
||||
}
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
#command-dock.dock-has-pinned-tray
|
||||
> #gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
) {
|
||||
z-index: 101;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pointer grace crosses pinned trays without covering their controls. Keyboard
|
||||
focus, the provider select and an open panel hold the shared row visible. */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
#command-dock
|
||||
#gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
)
|
||||
:is(.gev-voice-provider-field, .gev-voice-settings-button) {
|
||||
transition:
|
||||
opacity 160ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
#command-dock
|
||||
#gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
):not(:hover):not([data-row-grace]):not(
|
||||
:has(
|
||||
:focus-visible,
|
||||
#gev-voice-provider:focus,
|
||||
.gev-voice-panel[data-view='settings']:not([hidden])
|
||||
)
|
||||
)
|
||||
:is(.gev-voice-provider-field, .gev-voice-settings-button) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
#command-dock:not(.dock-has-pinned-tray)
|
||||
#gev-voice-control[data-provider-choice='true']:has(
|
||||
> .gev-voice-settings-button
|
||||
)
|
||||
.gev-voice-provider-field::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: calc(var(--gev-voice-settings-gap) + 8px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Existing tier and estimate controls are readable Settings content in panel mode. */
|
||||
.gev-voice-cost-settings {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.7rem;
|
||||
border-top: 1px solid var(--glass-border);
|
||||
}
|
||||
.gev-voice-cost-settings:has(#gev-voice-tier[hidden]):has(
|
||||
#gev-voice-cost-value[hidden]
|
||||
),
|
||||
.gev-voice-cost-setting-row:has(> [hidden]) {
|
||||
display: none;
|
||||
}
|
||||
.gev-voice-cost-setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
min-height: 2rem;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
.gev-voice-cost-setting-row > span {
|
||||
min-width: 0;
|
||||
}
|
||||
#gev-voice-control .gev-voice-cost-settings .gev-voice-tier-btn {
|
||||
flex: 0 0 auto;
|
||||
min-height: 2rem;
|
||||
min-width: 3.25rem;
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.67rem;
|
||||
}
|
||||
.gev-voice-cost-settings .gev-voice-cost-value {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.gev-voice-cost-settings .gev-voice-cost-value[data-level='ok'] {
|
||||
color: var(--voice-panel-accent, var(--accent));
|
||||
}
|
||||
#gev-voice-control .gev-voice-settings-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
#gev-voice-control .gev-voice-cost-settings .gev-voice-tier-btn {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#command-dock
|
||||
#gev-voice-control:has(> .gev-voice-settings-button)
|
||||
:is(.gev-voice-provider-field, .gev-voice-settings-button) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,6 @@
|
||||
margin-left: auto;
|
||||
text-align: right;
|
||||
}
|
||||
.rail-card-badge,
|
||||
.rail-card-line,
|
||||
.rail-timeline-readout {
|
||||
font-family: var(--font-mono);
|
||||
@@ -304,9 +303,6 @@ html:not([lang='en']) .rail-card-zero::after {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.rail-card-badge {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.rail-card-compact {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
@@ -362,22 +358,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;
|
||||
|
||||
@@ -91,6 +91,7 @@
|
||||
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" data-i18n-aria-label data-i18n-placeholder />
|
||||
</div>
|
||||
</div>
|
||||
<p id="location-search-error" class="location-search-error" role="alert"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loaded 100 of 807 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user