Merge remote-tracking branch 'upstream/main' into pr1209-merge

This commit is contained in:
Said Turksever committed 2026-10-28 14:05:40 +00:00
commit 11aad05dc9
807 files changed
+83004 -20993

No files matched your search

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