mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Merge current main into S2 GhostMaps and preserve updated import and voice behavior
This commit is contained in:
commit
4da002bd42
515 files changed
+17681
-2202
No files matched your search
@@ -70,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),
|
||||
|
||||
@@ -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';
|
||||
@@ -124,6 +125,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?.() || [],
|
||||
@@ -144,6 +147,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(),
|
||||
@@ -319,6 +323,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,
|
||||
@@ -587,6 +592,7 @@ export class StyleManager extends ShellFacade {
|
||||
this._initStreetLevelPanel();
|
||||
this._initGlobalContextPanel();
|
||||
this._initLocationBar();
|
||||
this._initMobileShell();
|
||||
this._initShareButton();
|
||||
this._initCameraOrientationControls();
|
||||
this._initClearSelectedLayersButton();
|
||||
@@ -848,6 +854,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,
|
||||
@@ -1707,6 +1755,7 @@ export class StyleManager extends ShellFacade {
|
||||
this._localePicker?.destroy();
|
||||
this._localePicker = null;
|
||||
this._recording.destroy();
|
||||
this._mobileShell?.destroy();
|
||||
this._panelChrome.destroy();
|
||||
this._feedback.destroy();
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ export function readCameraTargetFrame(viewer) {
|
||||
}
|
||||
|
||||
/** Apply an orbit frame, then return the camera to Cesium's fixed-world frame. */
|
||||
export function setCameraTargetFrame(viewer, frame) {
|
||||
function setCameraTargetFrame(viewer, frame) {
|
||||
const camera = viewer?.camera;
|
||||
if (!camera || !frame?.target || !Number.isFinite(frame.range)) return false;
|
||||
try {
|
||||
@@ -156,7 +156,7 @@ export function setCameraTargetFrame(viewer, frame) {
|
||||
*
|
||||
* @param {{pitch: number}|null} frame From {@link readCameraTargetFrame}.
|
||||
*/
|
||||
export function frameIsTilted(frame) {
|
||||
function frameIsTilted(frame) {
|
||||
const pitch = Number(frame?.pitch);
|
||||
return Number.isFinite(pitch) ? pitch > OBLIQUE_THRESHOLD : false;
|
||||
}
|
||||
|
||||
@@ -20,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'),
|
||||
@@ -45,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]
|
||||
|
||||
@@ -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() === 'zh-CN' ? unitLabel('km') : unitLabel('km').toUpperCase()}`,
|
||||
}),
|
||||
installations: ({ scope, coverage }) =>
|
||||
t('{scope} · INSTALLATIONS {coverage}', { scope, coverage }),
|
||||
|
||||
@@ -64,7 +64,8 @@ export function onKeyDown(event) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if (!this.active && !this.isEntryAllowed()) return;
|
||||
const changed = this.active ? this.exit() : this.enter();
|
||||
if (this.active) this.exit();
|
||||
else this.enter();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
formatAltitudeRulerTick,
|
||||
formatCompassDivision,
|
||||
formatCockpitEmergency,
|
||||
formatHeadingReadout,
|
||||
formatSpeedRulerTick,
|
||||
normalizeHeading,
|
||||
relativeBearing,
|
||||
@@ -238,7 +239,7 @@ export function updateRoute(info) {
|
||||
}
|
||||
if (this.routeDirectionLabel) {
|
||||
this.routeDirectionLabel.textContent = t('DEST {bearing}', {
|
||||
bearing: `${String(Math.round(destinationBearing)).padStart(3, '0')}°`,
|
||||
bearing: `${formatHeadingReadout(destinationBearing)}°`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Pure formatting and fixed presentation constants used by Cockpit components. */
|
||||
import { formatNumber, t, msg } from '../i18n/index.js';
|
||||
import { formatNumber, getLocale, t, msg } from '../i18n/index.js';
|
||||
import { formatQuantity } from './unitLabels.js';
|
||||
|
||||
export const COCKPIT_HEADING_SLEW_DPS = 28;
|
||||
@@ -145,17 +145,20 @@ export function formatCockpitCoordinate(value, positive, negative) {
|
||||
*/
|
||||
export function formatCockpitDistance(distanceM) {
|
||||
const digits = distanceM < 10000 ? 1 : 0;
|
||||
return formatQuantity(distanceM / 1000, 'km', {
|
||||
const distance = formatQuantity(distanceM / 1000, 'km', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
}).toUpperCase();
|
||||
});
|
||||
return getLocale() === 'zh-CN' ? distance : distance.toUpperCase();
|
||||
}
|
||||
|
||||
export function formatCockpitWindDirection(value) {
|
||||
if (!Number.isFinite(value)) return t('DIR UNKNOWN');
|
||||
const labels = Object.keys(COMPASS_POINT_LABELS);
|
||||
const normalized = ((value % 360) + 360) % 360;
|
||||
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${Math.round(normalized)}°`;
|
||||
// 359.5° and above is north. Modulo keeps that from printing 360°.
|
||||
const degrees = Math.round(normalized) % 360;
|
||||
return `${compassPointLabel(labels[Math.round(normalized / 45) % labels.length])} · ${degrees}°`;
|
||||
}
|
||||
|
||||
// Digit grouping and decimal marks (`26,175`, `26 175`, `26.175`) take a
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { formatCockpitWindDirection } from './cockpitPresentation.js';
|
||||
|
||||
test('wind just west of north reads 0°, not 360°', () => {
|
||||
assert.equal(formatCockpitWindDirection(90), 'E · 90°');
|
||||
assert.equal(formatCockpitWindDirection(0), 'N · 0°');
|
||||
assert.equal(formatCockpitWindDirection(359.4), 'N · 359°');
|
||||
assert.equal(formatCockpitWindDirection(359.6), 'N · 0°');
|
||||
});
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
flyToPOI,
|
||||
searchAndFlyTo,
|
||||
} from '../locations.js';
|
||||
import { interruptCameraMotion } from '../cameraVerbs.js';
|
||||
import {
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation,
|
||||
} from '../cameraVerbs.js';
|
||||
import { IntelHUD } from '../hud.js';
|
||||
import { ShareLinkManager } from '../sharelink.js';
|
||||
import { OrbitController } from '../orbit.js';
|
||||
@@ -73,6 +76,7 @@ export class StyleManager extends ApplicationShell {
|
||||
flyToPOI,
|
||||
searchAndFlyTo,
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation,
|
||||
IntelHUD,
|
||||
ShareLinkManager,
|
||||
OrbitController,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getScopeMaskFeather, isScopeMaskEnabled, setScopeMaskEnabled, setScopeMaskFeather } from '../scopeMask.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
@@ -271,6 +272,35 @@ test('Display and voice HUD routes preserve explicit Cyber entry and exit author
|
||||
assert.equal(voice.owner.activeStyle, 'snow');
|
||||
});
|
||||
|
||||
test('scope choice and expanded feather survive every HUD layout and both restore paths', async (t) => {
|
||||
const { owner } = styleOwner(t, 'tactical');
|
||||
const previous = { enabled: isScopeMaskEnabled(), feather: getScopeMaskFeather() };
|
||||
t.after(() => { setScopeMaskEnabled(previous.enabled); setScopeMaskFeather(previous.feather); });
|
||||
Object.assign(owner.services, { setScopeMaskEnabled, setScopeMaskFeather });
|
||||
owner._scopeBtn = { checked: true };
|
||||
owner._scopeFeatherSlider = { value: '11', disabled: false };
|
||||
owner._scopeFeatherValue = {};
|
||||
await owner.restoreShareState({ scopeEnabled: false, scopeFeatherPct: 200 });
|
||||
assert.equal(owner._scopeBtn.checked, false);
|
||||
assert.equal(owner._scopeFeatherSlider.disabled, true);
|
||||
assert.equal(owner._scopeFeatherSlider.value, '200');
|
||||
for (const layout of ['minimal', 'operator', 'cyber', 'tactical']) {
|
||||
owner._setHudVariant(layout, { applyVisualDefaults: true });
|
||||
assert.equal(isScopeMaskEnabled(), false, layout);
|
||||
assert.equal(getScopeMaskFeather(), 2, layout);
|
||||
assert.equal(owner._scopeBtn.checked, false, layout);
|
||||
}
|
||||
await owner.applyVisualState({ scope: { enabled: true, featherPct: 175 } });
|
||||
assert.equal(isScopeMaskEnabled(), true);
|
||||
assert.equal(getScopeMaskFeather(), 1.75);
|
||||
assert.equal(owner._scopeBtn.checked, true);
|
||||
assert.equal(owner._scopeFeatherSlider.disabled, false);
|
||||
assert.equal(owner._scopeFeatherValue.textContent, '175%');
|
||||
await owner.applyVisualState({ scope: { featherPct: 500 } });
|
||||
assert.equal(getScopeMaskFeather(), 2);
|
||||
assert.equal(owner._scopeFeatherSlider.value, '200');
|
||||
});
|
||||
|
||||
test('scene and share HUD transitions do not restore a local pre-Cyber preset', async (t) => {
|
||||
const { owner, hud } = styleOwner(t, 'tactical');
|
||||
owner.setStyle('noir');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
|
||||
import { createFrameRateMonitor } from './frameRateMonitor.js';
|
||||
import { bindApplicationShortcuts } from './visualInput.js';
|
||||
import { bindDisplayControls } from './displayControls.js';
|
||||
import { bindDisplayControls, syncScopeControls } from './displayControls.js';
|
||||
import { canonicalizeDensity } from '../data/detectionPolicy.js';
|
||||
|
||||
/** Own keyboard/display event subscriptions; settings remain with their state owners. */
|
||||
@@ -92,6 +93,11 @@ export class DisplayBindings {
|
||||
},
|
||||
});
|
||||
|
||||
syncScopeControls(
|
||||
this._scopeBtn,
|
||||
this._scopeFeatherSlider,
|
||||
isScopeMaskEnabled(),
|
||||
);
|
||||
this._displayControls?.destroy();
|
||||
this._displayControls = bindDisplayControls({
|
||||
elements: {
|
||||
@@ -100,7 +106,7 @@ export class DisplayBindings {
|
||||
bloomSlider: this._bloomSlider,
|
||||
sharpenButton: this._sharpenBtn,
|
||||
sharpenSlider: this._sharpenSlider,
|
||||
scopeButton: this._scopeBtn,
|
||||
scopeToggle: this._scopeBtn,
|
||||
scopeFeatherSlider: this._scopeFeatherSlider,
|
||||
hudLayout: this._hudLayoutSelect,
|
||||
hudButton: this._hudBtn,
|
||||
@@ -134,17 +140,15 @@ export class DisplayBindings {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setSharpenEnabled(!this.sharpenEnabled);
|
||||
},
|
||||
toggleScope: () => {
|
||||
setScopeEnabled: (enabled) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
const next = !isScopeMaskEnabled();
|
||||
setScopeMaskEnabled(next);
|
||||
this._scopeBtn.classList.toggle('active', next);
|
||||
this._scopeBtn.setAttribute('aria-pressed', String(next));
|
||||
setScopeMaskEnabled(enabled);
|
||||
syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, enabled);
|
||||
this._syncShareState();
|
||||
},
|
||||
setScopeFeather: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
const pct = Math.max(0, Math.min(100, value || 0));
|
||||
const pct = clampScopeFeatherPct(value);
|
||||
if (this._scopeFeatherValue)
|
||||
this._scopeFeatherValue.textContent = `${pct}%`;
|
||||
setScopeMaskFeather(pct / 100);
|
||||
|
||||
@@ -18,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'],
|
||||
@@ -34,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 [
|
||||
@@ -72,3 +72,9 @@ export function bindDisplayControls({ elements, actions }) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Keep the native Scope checkbox and feather availability in step with the mask. */
|
||||
export function syncScopeControls(toggle, featherSlider, enabled) {
|
||||
if (toggle) toggle.checked = enabled;
|
||||
if (featherSlider) featherSlider.disabled = !enabled;
|
||||
}
|
||||
@@ -158,3 +158,29 @@ test('optional controls are absent safely and subscriptions stay instance-owned'
|
||||
assert.deepEqual(calls, ['b']);
|
||||
two.destroy();
|
||||
});
|
||||
|
||||
test('the native Scope checkbox reads checked state on change and disposes its listener', () => {
|
||||
const scopeToggle = element();
|
||||
scopeToggle.checked = true;
|
||||
const scopeFeatherSlider = element('200');
|
||||
const calls = [];
|
||||
const controls = bindDisplayControls({
|
||||
elements: { scopeToggle, scopeFeatherSlider },
|
||||
actions: {
|
||||
setScopeEnabled: (value) => calls.push(['enabled', value]),
|
||||
setScopeFeather: (value) => calls.push(['feather', value]),
|
||||
},
|
||||
});
|
||||
scopeToggle.checked = false;
|
||||
scopeToggle.dispatchEvent(new Event('change'));
|
||||
scopeToggle.checked = true;
|
||||
scopeToggle.dispatchEvent(new Event('change'));
|
||||
scopeFeatherSlider.dispatchEvent(new Event('input'));
|
||||
controls.destroy();
|
||||
scopeToggle.dispatchEvent(new Event('change'));
|
||||
assert.deepEqual(calls, [
|
||||
['enabled', false],
|
||||
['enabled', true],
|
||||
['feather', 200],
|
||||
]);
|
||||
});
|
||||
@@ -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 —';
|
||||
|
||||
@@ -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'],
|
||||
);
|
||||
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'],
|
||||
);
|
||||
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'],
|
||||
);
|
||||
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-CN');
|
||||
model.keydown({ key: 'Home' });
|
||||
assert.equal(model.activeOption.code, 'ru');
|
||||
model.keydown({ key: 'ArrowUp' });
|
||||
|
||||
@@ -283,6 +283,7 @@ export class LocationNavigation {
|
||||
return;
|
||||
}
|
||||
|
||||
this.services.interruptGlobeRotation?.('orbit-intent');
|
||||
const isActive = this.orbitController.toggle(this._currentTarget, {
|
||||
radius: this._currentPoi?.alt || 500,
|
||||
pitch: this._currentPoi?.pitch || -30,
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
/**
|
||||
* Phone shell for narrow viewports (≤720px). The globe stays full screen and a
|
||||
* bottom tab bar opens one sheet at a time; each sheet shows existing panels,
|
||||
* which stay where they are in the rails and dock while CSS presents them
|
||||
* (styles/mobile-shell.css). Desktop never sees the class or the sheet state.
|
||||
*
|
||||
* Sheets borrow panels: the panels a sheet expands are put back exactly as
|
||||
* they were when the sheets close, and nothing a sheet does is persisted or
|
||||
* treated as the user's own panel choice.
|
||||
*
|
||||
* Adapted from the phone tab-shell proposed in public PR #203 by blearymoth.
|
||||
*/
|
||||
|
||||
import { EXCLUSIVE_SURFACE_CLASSES } from '../exclusiveSurfaces.js';
|
||||
import { msg, t } from '../i18n/index.js';
|
||||
|
||||
export const MOBILE_SHELL_MEDIA_QUERY = '(max-width: 720px)';
|
||||
export const MOBILE_SHELL_CLASS = 'mobile-shell';
|
||||
export const MOBILE_SHEET_ATTR = 'data-mobile-sheet';
|
||||
|
||||
/**
|
||||
* Panels each sheet expands. A sheet also shows its rail's other panels
|
||||
* collapsed (Scenes under Layers), per styles/mobile-shell.css.
|
||||
*/
|
||||
export const MOBILE_SHEET_PANELS = Object.freeze({
|
||||
layers: Object.freeze(['data-panel']),
|
||||
cameras: Object.freeze(['cctv-panel', 'street-level-panel']),
|
||||
context: Object.freeze(['global-context-panel']),
|
||||
search: Object.freeze(['location-bar']),
|
||||
more: Object.freeze(['control-panel', 'pp-toggles']),
|
||||
});
|
||||
|
||||
export const MOBILE_TABS = Object.freeze([
|
||||
Object.freeze({ sheet: 'layers', label: msg('Layers'), icon: 'layers' }),
|
||||
Object.freeze({ sheet: 'cameras', label: msg('Cameras'), icon: 'videocam' }),
|
||||
Object.freeze({ sheet: 'context', label: msg('Context'), icon: 'radar' }),
|
||||
Object.freeze({ sheet: 'search', label: msg('Search'), icon: 'search' }),
|
||||
Object.freeze({ sheet: 'more', label: msg('More'), icon: 'more_horiz' }),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Surfaces that own the screen close the open sheet and keep sheets closed.
|
||||
* Scene playback is the exception: its STOP control lives in the Scenes panel
|
||||
* (Layers sheet), and a phone has no Escape key to stop it otherwise.
|
||||
*/
|
||||
const SHEET_BLOCKING_CLASSES = Object.freeze(
|
||||
EXCLUSIVE_SURFACE_CLASSES.filter((name) => name !== 'scene-playback-mode'),
|
||||
);
|
||||
|
||||
/** Every panel any sheet borrows; the dock trays close each other. */
|
||||
const BORROWED_PANELS = Object.freeze([
|
||||
...new Set(Object.values(MOBILE_SHEET_PANELS).flat()),
|
||||
]);
|
||||
|
||||
const BORROW_OPTIONS = Object.freeze({ persist: false, syncShare: false });
|
||||
|
||||
/**
|
||||
* @param {string} panelId
|
||||
* @returns {string|null} The sheet that presents `panelId`.
|
||||
*/
|
||||
export function sheetForPanelId(panelId) {
|
||||
for (const [sheet, ids] of Object.entries(MOBILE_SHEET_PANELS)) {
|
||||
if (ids.includes(panelId)) return sheet;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sheet after a tab press: the same tab closes its sheet, another opens.
|
||||
* @param {string|null} current
|
||||
* @param {string|null} pressed
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function nextSheet(current, pressed) {
|
||||
if (!pressed || !Object.hasOwn(MOBILE_SHEET_PANELS, pressed)) return null;
|
||||
return current === pressed ? null : pressed;
|
||||
}
|
||||
|
||||
function sheetBlocked(body) {
|
||||
return SHEET_BLOCKING_CLASSES.some((name) => body.classList.contains(name));
|
||||
}
|
||||
|
||||
function currentSheetOf(body) {
|
||||
const value = body.getAttribute(MOBILE_SHEET_ATTR);
|
||||
return value && Object.hasOwn(MOBILE_SHEET_PANELS, value) ? value : null;
|
||||
}
|
||||
|
||||
function buildChrome(documentRef) {
|
||||
const backdrop = documentRef.createElement('div');
|
||||
backdrop.id = 'mobile-sheet-backdrop';
|
||||
backdrop.hidden = true;
|
||||
|
||||
const tabBar = documentRef.createElement('nav');
|
||||
tabBar.id = 'mobile-tab-bar';
|
||||
tabBar.setAttribute('aria-label', t('Panels'));
|
||||
for (const tab of MOBILE_TABS) {
|
||||
const button = documentRef.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'mobile-tab';
|
||||
button.dataset.mobileSheet = tab.sheet;
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
const icon = documentRef.createElement('span');
|
||||
icon.className = 'material-symbols-outlined mobile-tab-icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.textContent = tab.icon;
|
||||
const label = documentRef.createElement('span');
|
||||
label.className = 'mobile-tab-label';
|
||||
label.textContent = t(tab.label);
|
||||
button.append(icon, label);
|
||||
tabBar.append(button);
|
||||
}
|
||||
documentRef.body.append(backdrop, tabBar);
|
||||
return { backdrop, tabBar };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the phone shell: the media gate, tab bar, backdrop, Escape, focus,
|
||||
* and closing for full-screen modes.
|
||||
* @param {object} input
|
||||
* @param {(panelId: string, collapsed: boolean, options?: object) => void} input.setPanelCollapsed
|
||||
* @param {Document} [input.documentRef]
|
||||
* @param {(query: string) => MediaQueryList} [input.matchMedia]
|
||||
* @param {typeof MutationObserver} [input.MutationObserverImpl]
|
||||
* @param {typeof ResizeObserver} [input.ResizeObserverImpl]
|
||||
* @param {(callback: () => void) => number} [input.requestFrame]
|
||||
* @param {() => void} [input.onSheetsClosed] Runs after the last sheet closes
|
||||
* and its panels are put back (e.g. to refresh the share link).
|
||||
* @param {(sheet: string) => void} [input.onTabOpened] Runs only when the user
|
||||
* opens a sheet from its tab, never on close or programmatic presentation.
|
||||
* @returns {{ destroy: () => void, setSheet: (sheet: string|null) => void, getSheet: () => string|null, sharedCollapsed: (panelId: string) => boolean|undefined }}
|
||||
*/
|
||||
export function initMobileShell({
|
||||
setPanelCollapsed,
|
||||
documentRef = globalThis.document,
|
||||
matchMedia = globalThis.matchMedia?.bind(globalThis),
|
||||
MutationObserverImpl = globalThis.MutationObserver,
|
||||
ResizeObserverImpl = globalThis.ResizeObserver,
|
||||
requestFrame = globalThis.requestAnimationFrame?.bind(globalThis),
|
||||
onSheetsClosed = () => {},
|
||||
onTabOpened = () => {},
|
||||
} = {}) {
|
||||
const body = documentRef.body;
|
||||
const { backdrop, tabBar } = buildChrome(documentRef);
|
||||
const viewport = documentRef.querySelector('meta[name="viewport"]');
|
||||
const viewportContent = viewport?.getAttribute('content') ?? null;
|
||||
const removers = [];
|
||||
const listen = (target, type, handler, options) => {
|
||||
target.addEventListener(type, handler, options);
|
||||
removers.push(() => target.removeEventListener(type, handler, options));
|
||||
};
|
||||
const element = (id) => documentRef.getElementById(id);
|
||||
const rails = ['left-panel-stack', 'right-context-rail']
|
||||
.map(element)
|
||||
.filter(Boolean);
|
||||
const dock = element('command-dock');
|
||||
const shown = (node) =>
|
||||
node && globalThis.getComputedStyle?.(node).display !== 'none';
|
||||
|
||||
/**
|
||||
* Panel states from before the first sheet opened (`before`) and right after
|
||||
* the open sheet was set up (`applied`); null when closed.
|
||||
*/
|
||||
let borrowed = null;
|
||||
let returnFocus = null;
|
||||
let swipe = null;
|
||||
const written = new Map();
|
||||
|
||||
const writeVar = (name, value) => {
|
||||
if (written.get(name) === value) return;
|
||||
written.set(name, value);
|
||||
body.style.setProperty(name, value);
|
||||
};
|
||||
|
||||
// The dock (Presets, Search) sits on top of whichever rail is the sheet,
|
||||
// and the required map attribution sits above everything the sheet covers.
|
||||
const measureSheet = () => {
|
||||
if (!body.classList.contains(MOBILE_SHELL_CLASS)) return;
|
||||
const height = Math.max(
|
||||
0,
|
||||
...rails.map((rail) =>
|
||||
shown(rail) ? rail.getBoundingClientRect().height : 0,
|
||||
),
|
||||
);
|
||||
writeVar('--mobile-sheet-height', `${Math.round(height)}px`);
|
||||
const viewportHeight = globalThis.innerHeight || 0;
|
||||
const cover = Math.max(
|
||||
0,
|
||||
...[...rails, currentSheetOf(body) ? dock : null]
|
||||
.filter(shown)
|
||||
.map((node) => viewportHeight - node.getBoundingClientRect().top),
|
||||
);
|
||||
writeVar('--mobile-sheet-cover', `${Math.round(cover)}px`);
|
||||
};
|
||||
|
||||
const resize =
|
||||
typeof ResizeObserverImpl === 'function'
|
||||
? new ResizeObserverImpl(measureSheet)
|
||||
: null;
|
||||
removers.push(() => resize?.disconnect());
|
||||
|
||||
const isCollapsed = (panelId) =>
|
||||
element(panelId)?.classList.contains('collapsed') ?? true;
|
||||
// The user's preference, as share links record it: a collapse that layout
|
||||
// applies only for space (Tactical, Cyber) still counts as expanded.
|
||||
const prefersCollapsed = (panelId) => {
|
||||
const classes = element(panelId)?.classList;
|
||||
if (!classes) return true;
|
||||
return (
|
||||
classes.contains('collapsed') &&
|
||||
!classes.contains('layout-auto-collapsed') &&
|
||||
!classes.contains('cyber-accordion-collapsed')
|
||||
);
|
||||
};
|
||||
|
||||
const panelStates = () =>
|
||||
new Map(
|
||||
BORROWED_PANELS.map((panelId) => [panelId, prefersCollapsed(panelId)]),
|
||||
);
|
||||
|
||||
// Undo only what opening the sheet did (including side effects, such as dock
|
||||
// trays closing each other). A panel the user changed since stays as they
|
||||
// left it, and becomes the state later sheets put back.
|
||||
const restoreBorrowed = () => {
|
||||
if (!borrowed) return;
|
||||
for (const [panelId, before] of borrowed.before) {
|
||||
const now = prefersCollapsed(panelId);
|
||||
if (now !== borrowed.applied.get(panelId))
|
||||
borrowed.before.set(panelId, now);
|
||||
else if (now !== before)
|
||||
setPanelCollapsed(panelId, before, BORROW_OPTIONS);
|
||||
}
|
||||
};
|
||||
|
||||
const present = (sheet) => {
|
||||
if (sheet) body.setAttribute(MOBILE_SHEET_ATTR, sheet);
|
||||
else body.removeAttribute(MOBILE_SHEET_ATTR);
|
||||
backdrop.hidden = !sheet;
|
||||
for (const button of tabBar.querySelectorAll('[data-mobile-sheet]'))
|
||||
button.setAttribute(
|
||||
'aria-expanded',
|
||||
String(button.dataset.mobileSheet === sheet),
|
||||
);
|
||||
measureSheet();
|
||||
};
|
||||
|
||||
/** The first visible control of the sheet's first panel, for focus on open. */
|
||||
const sheetFocusTarget = (sheet) => {
|
||||
if (sheet === 'search') return element('location-search');
|
||||
const panel = element(MOBILE_SHEET_PANELS[sheet][0]);
|
||||
const controls =
|
||||
panel?.querySelectorAll?.(
|
||||
'button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
) ?? [];
|
||||
// Sheets hide some panel chrome (tray toggles, pins) that stays in the
|
||||
// DOM, and a panel's own collapse control is a poor first stop.
|
||||
return (
|
||||
[...controls].find(
|
||||
(control) =>
|
||||
control.getClientRects().length > 0 &&
|
||||
!control.matches?.('.panel-collapse-btn'),
|
||||
) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const setSheet = (requested) => {
|
||||
swipe = null;
|
||||
if (!body.classList.contains(MOBILE_SHELL_CLASS)) return;
|
||||
const sheet = sheetBlocked(body) ? null : requested;
|
||||
const previous = currentSheetOf(body);
|
||||
if (previous === sheet) return;
|
||||
if (!previous) {
|
||||
borrowed = { before: panelStates(), applied: panelStates() };
|
||||
returnFocus = documentRef.activeElement ?? null;
|
||||
} else {
|
||||
if (previous === 'search') element('location-search')?.blur?.();
|
||||
restoreBorrowed();
|
||||
}
|
||||
present(sheet);
|
||||
if (!sheet) {
|
||||
borrowed = null;
|
||||
onSheetsClosed();
|
||||
const focus = returnFocus;
|
||||
returnFocus = null;
|
||||
if (focus && focus !== body && body.contains?.(focus)) focus.focus?.();
|
||||
return;
|
||||
}
|
||||
for (const panelId of MOBILE_SHEET_PANELS[sheet])
|
||||
if (isCollapsed(panelId))
|
||||
setPanelCollapsed(panelId, false, BORROW_OPTIONS);
|
||||
borrowed.applied = panelStates();
|
||||
// Rail layout settles on a later frame; what it does is part of opening.
|
||||
const opening = (borrowed.opening ?? 0) + 1;
|
||||
borrowed.opening = opening;
|
||||
const opened = borrowed;
|
||||
requestFrame?.(() => {
|
||||
if (borrowed === opened && opened.opening === opening)
|
||||
opened.applied = panelStates();
|
||||
});
|
||||
measureSheet();
|
||||
sheetFocusTarget(sheet)?.focus?.();
|
||||
};
|
||||
|
||||
const tabFor = (sheet) =>
|
||||
[...tabBar.querySelectorAll('[data-mobile-sheet]')].find(
|
||||
(button) => button.dataset.mobileSheet === sheet,
|
||||
);
|
||||
|
||||
const onMedia = (matches) => {
|
||||
if (matches) {
|
||||
body.classList.add(MOBILE_SHELL_CLASS);
|
||||
// Lets the page draw under the notch and system bars; the shell's CSS
|
||||
// keeps its chrome clear of them.
|
||||
if (viewport && !/viewport-fit\s*=/.test(viewportContent || ''))
|
||||
viewport.setAttribute(
|
||||
'content',
|
||||
`${viewportContent || ''}, viewport-fit=cover`,
|
||||
);
|
||||
for (const node of [...rails, dock].filter(Boolean))
|
||||
resize?.observe(node);
|
||||
measureSheet();
|
||||
} else {
|
||||
// Crossing to desktop closes the sheet, which puts its panels back.
|
||||
setSheet(null);
|
||||
body.classList.remove(MOBILE_SHELL_CLASS);
|
||||
present(null);
|
||||
resize?.disconnect();
|
||||
if (viewport && viewportContent !== null)
|
||||
viewport.setAttribute('content', viewportContent);
|
||||
}
|
||||
tabBar.setAttribute('aria-hidden', String(!matches));
|
||||
};
|
||||
|
||||
const media =
|
||||
typeof matchMedia === 'function'
|
||||
? matchMedia(MOBILE_SHELL_MEDIA_QUERY)
|
||||
: null;
|
||||
onMedia(Boolean(media?.matches));
|
||||
const onChange = (event) => onMedia(event.matches);
|
||||
if (media?.addEventListener) {
|
||||
media.addEventListener('change', onChange);
|
||||
removers.push(() => media.removeEventListener('change', onChange));
|
||||
} else if (media?.addListener) {
|
||||
// Safari before 14 only offers the legacy listener API.
|
||||
media.addListener(onChange);
|
||||
removers.push(() => media.removeListener(onChange));
|
||||
}
|
||||
|
||||
listen(tabBar, 'click', (event) => {
|
||||
const button = event.target?.closest?.('[data-mobile-sheet]');
|
||||
if (!button) return;
|
||||
event.preventDefault();
|
||||
const sheet = nextSheet(currentSheetOf(body), button.dataset.mobileSheet);
|
||||
// Closing from the tab bar returns focus to the tab that was pressed.
|
||||
if (!sheet) returnFocus = button;
|
||||
setSheet(sheet);
|
||||
if (sheet && currentSheetOf(body) === sheet) onTabOpened(sheet);
|
||||
});
|
||||
listen(backdrop, 'click', () => setSheet(null));
|
||||
// Touch events let native scrolling continue until a downward pull at the
|
||||
// top is intentional. Pointer capture/touch-action:none would also seize
|
||||
// ordinary vertical scrolling inside the borrowed panels.
|
||||
const sheetRoot = (target, sheet) => {
|
||||
const roots =
|
||||
sheet === 'layers'
|
||||
? [element('left-panel-stack')]
|
||||
: sheet === 'search'
|
||||
? [dock]
|
||||
: sheet === 'more'
|
||||
? [element('right-context-rail'), dock]
|
||||
: [element('right-context-rail')];
|
||||
return roots.find((root) => root?.contains(target));
|
||||
};
|
||||
const scrolled = (target, root) => {
|
||||
for (let node = target; node; node = node.parentElement) {
|
||||
if (node.scrollTop > 0) return true;
|
||||
if (node === root) break;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
listen(
|
||||
documentRef,
|
||||
'touchstart',
|
||||
(event) => {
|
||||
swipe = null;
|
||||
const sheet = currentSheetOf(body);
|
||||
const target = event.target;
|
||||
if (
|
||||
!sheet ||
|
||||
!body.classList.contains(MOBILE_SHELL_CLASS) ||
|
||||
event.defaultPrevented ||
|
||||
event.touches.length !== 1 ||
|
||||
target?.closest?.(
|
||||
'button, a, input, select, textarea, label, [contenteditable]:not([contenteditable="false"]), [role="slider"], [role="button"]',
|
||||
)
|
||||
)
|
||||
return;
|
||||
const root = sheetRoot(target, sheet);
|
||||
if (!root || scrolled(target, root)) return;
|
||||
const touch = event.touches[0];
|
||||
swipe = {
|
||||
sheet,
|
||||
target,
|
||||
root,
|
||||
id: touch.identifier,
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
claimed: false,
|
||||
};
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
listen(
|
||||
documentRef,
|
||||
'touchmove',
|
||||
(event) => {
|
||||
if (!swipe) return;
|
||||
const touch = event.touches[0];
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.touches.length !== 1 ||
|
||||
touch.identifier !== swipe.id ||
|
||||
currentSheetOf(body) !== swipe.sheet ||
|
||||
scrolled(swipe.target, swipe.root)
|
||||
) {
|
||||
swipe = null;
|
||||
return;
|
||||
}
|
||||
const dx = touch.clientX - swipe.x;
|
||||
const dy = touch.clientY - swipe.y;
|
||||
if (!swipe.claimed) {
|
||||
// Claim direction before the browser crosses its native scroll slop.
|
||||
// Android can make the next move non-cancelable after a ~9px first move.
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) < 4) return;
|
||||
if (dy <= Math.abs(dx) * 1.25 || !event.cancelable) {
|
||||
swipe = null;
|
||||
return;
|
||||
}
|
||||
swipe.claimed = true;
|
||||
}
|
||||
// Cancelling only the claimed pull leaves taps, sliders and normal scroll
|
||||
// native, and suppresses the synthetic click at the end of a dismissal.
|
||||
event.preventDefault();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
listen(
|
||||
documentRef,
|
||||
'touchend',
|
||||
(event) => {
|
||||
const pull = swipe;
|
||||
swipe = null;
|
||||
if (!pull?.claimed || event.touches.length) return;
|
||||
const touch = [...event.changedTouches].find(
|
||||
(item) => item.identifier === pull.id,
|
||||
);
|
||||
if (!touch || currentSheetOf(body) !== pull.sheet) return;
|
||||
const dx = Math.abs(touch.clientX - pull.x);
|
||||
const dy = touch.clientY - pull.y;
|
||||
if (dy < 64 || dy <= dx * 1.25) return;
|
||||
returnFocus = tabFor(pull.sheet) ?? returnFocus;
|
||||
setSheet(null);
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
listen(
|
||||
documentRef,
|
||||
'touchcancel',
|
||||
() => {
|
||||
swipe = null;
|
||||
},
|
||||
{ passive: true },
|
||||
);
|
||||
// Bubble phase, so fields cancelling an edit, lightboxes, viewers and tools
|
||||
// take Escape first; panels yield it to the sheet (PanelChrome), and an open
|
||||
// dialog keeps its own native close.
|
||||
listen(documentRef, 'keydown', (event) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
||||
const sheet = currentSheetOf(body);
|
||||
if (!sheet || documentRef.querySelector('dialog[open]')) return;
|
||||
event.preventDefault();
|
||||
returnFocus = tabFor(sheet) ?? returnFocus;
|
||||
setSheet(null);
|
||||
});
|
||||
|
||||
if (typeof MutationObserverImpl === 'function') {
|
||||
const observer = new MutationObserverImpl(() => {
|
||||
if (currentSheetOf(body) && sheetBlocked(body)) setSheet(null);
|
||||
});
|
||||
observer.observe(body, { attributes: true, attributeFilter: ['class'] });
|
||||
removers.push(() => observer.disconnect());
|
||||
}
|
||||
|
||||
return {
|
||||
setSheet,
|
||||
getSheet: () => currentSheetOf(body),
|
||||
/**
|
||||
* The collapsed state to share for a panel a sheet borrowed: its state
|
||||
* before the sheets, or the user's own change since. Undefined otherwise.
|
||||
*/
|
||||
sharedCollapsed(panelId) {
|
||||
if (!borrowed?.before.has(panelId)) return undefined;
|
||||
const now = prefersCollapsed(panelId);
|
||||
return now !== borrowed.applied.get(panelId)
|
||||
? now
|
||||
: borrowed.before.get(panelId);
|
||||
},
|
||||
destroy() {
|
||||
setSheet(null);
|
||||
for (const remove of removers.splice(0)) remove();
|
||||
body.classList.remove(MOBILE_SHELL_CLASS);
|
||||
body.removeAttribute(MOBILE_SHEET_ATTR);
|
||||
body.style.removeProperty('--mobile-sheet-height');
|
||||
body.style.removeProperty('--mobile-sheet-cover');
|
||||
if (viewport && viewportContent !== null)
|
||||
viewport.setAttribute('content', viewportContent);
|
||||
backdrop.remove();
|
||||
tabBar.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,865 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import { StyleManager } from './applicationShell.js';
|
||||
import { ContextControls } from './contextControls.js';
|
||||
import {
|
||||
MOBILE_SHEET_PANELS,
|
||||
MOBILE_TABS,
|
||||
initMobileShell,
|
||||
nextSheet,
|
||||
sheetForPanelId,
|
||||
} from './mobileShell.js';
|
||||
|
||||
function element(tag, document) {
|
||||
const node = new EventTarget();
|
||||
const attributes = new Map();
|
||||
const classes = new Set();
|
||||
Object.assign(node, {
|
||||
tagName: tag.toUpperCase(),
|
||||
children: [],
|
||||
dataset: {},
|
||||
hidden: false,
|
||||
textContent: '',
|
||||
className: '',
|
||||
parent: null,
|
||||
scrollTop: 0,
|
||||
parentElement: null,
|
||||
classList: {
|
||||
add: (...names) => names.forEach((name) => classes.add(name)),
|
||||
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
||||
contains: (name) => classes.has(name),
|
||||
},
|
||||
style: {
|
||||
values: new Map(),
|
||||
setProperty(name, value) {
|
||||
this.values.set(name, value);
|
||||
},
|
||||
removeProperty(name) {
|
||||
this.values.delete(name);
|
||||
},
|
||||
},
|
||||
setAttribute: (key, value) => attributes.set(key, String(value)),
|
||||
getAttribute: (key) => attributes.get(key) ?? null,
|
||||
removeAttribute: (key) => attributes.delete(key),
|
||||
append(...children) {
|
||||
for (const child of children) {
|
||||
child.parent = node;
|
||||
child.parentElement = node;
|
||||
node.children.push(child);
|
||||
if (child.id) document.ids.set(child.id, child);
|
||||
}
|
||||
},
|
||||
remove() {
|
||||
const siblings = node.parent?.children;
|
||||
siblings?.splice(siblings.indexOf(node), 1);
|
||||
document.ids.delete(node.id);
|
||||
},
|
||||
contains(other) {
|
||||
for (let current = other; current; current = current.parent)
|
||||
if (current === node) return true;
|
||||
return false;
|
||||
},
|
||||
closest(selector) {
|
||||
for (let current = node; current; current = current.parent) {
|
||||
if (
|
||||
selector.startsWith('button, a') &&
|
||||
(['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL'].includes(
|
||||
current.tagName,
|
||||
) ||
|
||||
['slider', 'button'].includes(current.getAttribute('role')) ||
|
||||
(current.getAttribute('contenteditable') !== null &&
|
||||
current.getAttribute('contenteditable') !== 'false'))
|
||||
)
|
||||
return current;
|
||||
if (selector === '[data-mobile-sheet]' && current.dataset.mobileSheet)
|
||||
return current;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
const found = [];
|
||||
const matches = (child) =>
|
||||
selector === '[data-mobile-sheet]'
|
||||
? Boolean(child.dataset.mobileSheet)
|
||||
: child.tagName === 'BUTTON';
|
||||
const walk = (parent) => {
|
||||
for (const child of parent.children) {
|
||||
if (matches(child)) found.push(child);
|
||||
walk(child);
|
||||
}
|
||||
};
|
||||
walk(node);
|
||||
return found;
|
||||
},
|
||||
getBoundingClientRect: () => ({ top: 0, height: 0 }),
|
||||
getClientRects: () => (node.hidden ? [] : [{}]),
|
||||
matches: (selector) =>
|
||||
selector === '.panel-collapse-btn' &&
|
||||
node.className.split(' ').includes('panel-collapse-btn'),
|
||||
focus() {
|
||||
document.activeElement = node;
|
||||
},
|
||||
blur() {
|
||||
if (document.activeElement === node)
|
||||
document.activeElement = document.body;
|
||||
},
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
const PANEL_IDS = [...new Set(Object.values(MOBILE_SHEET_PANELS).flat())];
|
||||
|
||||
function fixture({
|
||||
mobile = true,
|
||||
expanded = [],
|
||||
onTabOpened = () => {},
|
||||
} = {}) {
|
||||
const document = new EventTarget();
|
||||
document.ids = new Map();
|
||||
document.createElement = (tag) => element(tag, document);
|
||||
document.getElementById = (id) => document.ids.get(id) ?? null;
|
||||
const viewport = element('meta', document);
|
||||
viewport.setAttribute('content', 'width=device-width, initial-scale=1.0');
|
||||
document.querySelector = (selector) =>
|
||||
selector === 'meta[name="viewport"]'
|
||||
? viewport
|
||||
: selector === 'dialog[open]' && document.openDialog
|
||||
? {}
|
||||
: null;
|
||||
document.body = element('body', document);
|
||||
document.activeElement = document.body;
|
||||
const search = element('input', document);
|
||||
search.id = 'location-search';
|
||||
document.body.append(search);
|
||||
const rails = {};
|
||||
for (const id of ['left-panel-stack', 'right-context-rail', 'command-dock']) {
|
||||
rails[id] = element('div', document);
|
||||
rails[id].id = id;
|
||||
document.body.append(rails[id]);
|
||||
}
|
||||
for (const id of PANEL_IDS) {
|
||||
const panel = element('section', document);
|
||||
panel.id = id;
|
||||
if (!expanded.includes(id)) panel.classList.add('collapsed');
|
||||
// A hidden tray toggle and a collapse control first, like the panels'
|
||||
// own chrome in a sheet.
|
||||
const toggle = element('button', document);
|
||||
toggle.hidden = true;
|
||||
const collapse = element('button', document);
|
||||
collapse.className = 'panel-collapse-btn';
|
||||
panel.append(toggle, collapse, element('button', document));
|
||||
const root =
|
||||
id === 'data-panel'
|
||||
? 'left-panel-stack'
|
||||
: ['location-bar', 'control-panel'].includes(id)
|
||||
? 'command-dock'
|
||||
: 'right-context-rail';
|
||||
rails[root].append(panel);
|
||||
}
|
||||
|
||||
const media = new EventTarget();
|
||||
media.matches = mobile;
|
||||
const calls = [];
|
||||
const frames = [];
|
||||
let closedCount = 0;
|
||||
let observed = null;
|
||||
class FakeMutationObserver {
|
||||
constructor(callback) {
|
||||
observed = callback;
|
||||
}
|
||||
observe() {}
|
||||
disconnect() {
|
||||
observed = null;
|
||||
}
|
||||
}
|
||||
const shell = initMobileShell({
|
||||
documentRef: document,
|
||||
onTabOpened,
|
||||
matchMedia: () => media,
|
||||
MutationObserverImpl: FakeMutationObserver,
|
||||
ResizeObserverImpl: null,
|
||||
requestFrame: (callback) => frames.push(callback),
|
||||
onSheetsClosed: () => {
|
||||
closedCount += 1;
|
||||
},
|
||||
setPanelCollapsed: (panelId, collapsed, options) => {
|
||||
calls.push({ panelId, collapsed, ...options });
|
||||
const panel = document.getElementById(panelId);
|
||||
if (collapsed) panel.classList.add('collapsed');
|
||||
else panel.classList.remove('collapsed');
|
||||
},
|
||||
});
|
||||
const tab = (sheet) =>
|
||||
document
|
||||
.getElementById('mobile-tab-bar')
|
||||
.children.find((button) => button.dataset.mobileSheet === sheet);
|
||||
const press = (sheet) => {
|
||||
const event = new Event('click', { cancelable: true });
|
||||
Object.defineProperty(event, 'target', { value: tab(sheet) });
|
||||
document.getElementById('mobile-tab-bar').dispatchEvent(event);
|
||||
};
|
||||
const resize = (matches) => {
|
||||
media.matches = matches;
|
||||
const event = new Event('change');
|
||||
event.matches = matches;
|
||||
media.dispatchEvent(event);
|
||||
};
|
||||
const setBodyClass = (name) => {
|
||||
document.body.classList.add(name);
|
||||
observed?.();
|
||||
};
|
||||
const collapsed = (id) =>
|
||||
document.getElementById(id).classList.contains('collapsed');
|
||||
const runFrames = () => frames.splice(0).forEach((callback) => callback());
|
||||
return {
|
||||
document,
|
||||
shell,
|
||||
calls,
|
||||
runFrames,
|
||||
closedCount: () => closedCount,
|
||||
press,
|
||||
tab,
|
||||
resize,
|
||||
viewport,
|
||||
search,
|
||||
setBodyClass,
|
||||
collapsed,
|
||||
};
|
||||
}
|
||||
|
||||
test('every tab names a sheet and every sheet panel maps back to its sheet', () => {
|
||||
assert.deepEqual(
|
||||
MOBILE_TABS.map(({ sheet }) => sheet),
|
||||
Object.keys(MOBILE_SHEET_PANELS),
|
||||
);
|
||||
for (const [sheet, panels] of Object.entries(MOBILE_SHEET_PANELS))
|
||||
for (const panelId of panels) assert.equal(sheetForPanelId(panelId), sheet);
|
||||
assert.equal(sheetForPanelId('unknown-panel'), null);
|
||||
});
|
||||
|
||||
test('pressing a tab opens its sheet, pressing it again closes it', () => {
|
||||
assert.equal(nextSheet(null, 'layers'), 'layers');
|
||||
assert.equal(nextSheet('layers', 'layers'), null);
|
||||
assert.equal(nextSheet('layers', 'more'), 'more');
|
||||
assert.equal(nextSheet('layers', 'nonsense'), null);
|
||||
assert.equal(nextSheet(null, 'constructor'), null);
|
||||
});
|
||||
|
||||
test('one sheet at a time, presented with aria-expanded and a backdrop', () => {
|
||||
const { document, shell, press, tab } = fixture();
|
||||
assert.equal(document.body.classList.contains('mobile-shell'), true);
|
||||
press('layers');
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
assert.equal(document.body.getAttribute('data-mobile-sheet'), 'layers');
|
||||
assert.equal(tab('layers').getAttribute('aria-expanded'), 'true');
|
||||
assert.equal(document.getElementById('mobile-sheet-backdrop').hidden, false);
|
||||
press('more');
|
||||
assert.equal(shell.getSheet(), 'more');
|
||||
assert.equal(tab('layers').getAttribute('aria-expanded'), 'false');
|
||||
assert.equal(tab('more').getAttribute('aria-expanded'), 'true');
|
||||
press('more');
|
||||
assert.equal(shell.getSheet(), null);
|
||||
assert.equal(document.body.getAttribute('data-mobile-sheet'), null);
|
||||
assert.equal(document.getElementById('mobile-sheet-backdrop').hidden, true);
|
||||
});
|
||||
|
||||
test('sheets borrow panels and put them back exactly as they were', () => {
|
||||
// The user left Data Layers and Presets open on desktop.
|
||||
const kept = ['data-panel', 'control-panel'];
|
||||
const { calls, press, collapsed } = fixture({ expanded: kept });
|
||||
press('cameras');
|
||||
assert.equal(collapsed('cctv-panel'), false);
|
||||
assert.equal(collapsed('street-level-panel'), false);
|
||||
press('search');
|
||||
assert.equal(collapsed('cctv-panel'), true, 'Cameras put back on switch');
|
||||
assert.equal(collapsed('location-bar'), false);
|
||||
press('search');
|
||||
for (const id of PANEL_IDS)
|
||||
assert.equal(collapsed(id), !kept.includes(id), `${id} is back as it was`);
|
||||
// Nothing a sheet does is the user's choice, persisted or shared.
|
||||
assert.ok(calls.length > 0);
|
||||
for (const call of calls) {
|
||||
assert.equal(call.explicit, undefined, `${call.panelId} is not explicit`);
|
||||
assert.equal(call.persist, false);
|
||||
assert.equal(call.syncShare, false);
|
||||
}
|
||||
// Already-open panels are not touched.
|
||||
assert.ok(!calls.some(({ panelId }) => kept.includes(panelId)));
|
||||
});
|
||||
|
||||
test('a panel the user changes inside a sheet stays as they left it', () => {
|
||||
const { press, collapsed, document } = fixture();
|
||||
press('cameras');
|
||||
// The user collapses CCTV inside the sheet (their own, persisted choice).
|
||||
document.getElementById('cctv-panel').classList.add('collapsed');
|
||||
press('cameras');
|
||||
assert.equal(collapsed('cctv-panel'), true, 'their collapse is kept');
|
||||
assert.equal(
|
||||
collapsed('street-level-panel'),
|
||||
true,
|
||||
'the borrowed one is put back',
|
||||
);
|
||||
|
||||
// Expanding one in a sheet is kept too, and later sheets respect it.
|
||||
press('layers');
|
||||
document.getElementById('cctv-panel').classList.remove('collapsed');
|
||||
press('more');
|
||||
press('more');
|
||||
assert.equal(collapsed('cctv-panel'), false);
|
||||
});
|
||||
|
||||
test('focus moves into the sheet and returns to the tab on close', () => {
|
||||
const { document, press, search, tab } = fixture();
|
||||
press('search');
|
||||
assert.equal(document.activeElement, search);
|
||||
press('search');
|
||||
assert.equal(document.activeElement, tab('search'));
|
||||
|
||||
press('layers');
|
||||
const dataPanel = document.getElementById('data-panel');
|
||||
// The first visible control that is not the panel's own collapse button.
|
||||
assert.equal(document.activeElement, dataPanel.children[2]);
|
||||
const escape = new Event('keydown', { cancelable: true });
|
||||
escape.key = 'Escape';
|
||||
document.dispatchEvent(escape);
|
||||
assert.equal(escape.defaultPrevented, true);
|
||||
assert.equal(document.activeElement, tab('layers'));
|
||||
});
|
||||
|
||||
test('the share link keeps the state from before the sheets', () => {
|
||||
const { shell, press, document, closedCount } = fixture({
|
||||
expanded: ['street-level-panel'],
|
||||
});
|
||||
assert.equal(shell.sharedCollapsed('data-panel'), undefined);
|
||||
press('layers');
|
||||
// Data Layers is open only because the sheet borrowed it.
|
||||
assert.equal(shell.sharedCollapsed('data-panel'), true);
|
||||
assert.equal(shell.sharedCollapsed('street-level-panel'), false);
|
||||
assert.equal(shell.sharedCollapsed('unrelated-panel'), undefined);
|
||||
// A user change inside the sheet is theirs to share.
|
||||
document.getElementById('cctv-panel').classList.remove('collapsed');
|
||||
assert.equal(shell.sharedCollapsed('cctv-panel'), false);
|
||||
press('more');
|
||||
assert.equal(closedCount(), 0, 'switching sheets is not closing');
|
||||
press('more');
|
||||
assert.equal(closedCount(), 1, 'closing refreshes the share link once');
|
||||
assert.equal(shell.sharedCollapsed('data-panel'), undefined);
|
||||
});
|
||||
|
||||
test('Escape closes the sheet only when nothing else took it', () => {
|
||||
const { document, shell, press } = fixture();
|
||||
const escape = () => {
|
||||
const event = new Event('keydown', { cancelable: true, bubbles: true });
|
||||
event.key = 'Escape';
|
||||
return event;
|
||||
};
|
||||
press('layers');
|
||||
// A field cancelling its edit (or a lightbox closing) handles it first.
|
||||
const handled = escape();
|
||||
handled.preventDefault();
|
||||
document.dispatchEvent(handled);
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
// An open dialog keeps Escape for its native close.
|
||||
document.openDialog = true;
|
||||
document.dispatchEvent(escape());
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
document.openDialog = false;
|
||||
const plain = escape();
|
||||
document.dispatchEvent(plain);
|
||||
assert.equal(shell.getSheet(), null);
|
||||
assert.equal(plain.defaultPrevented, true);
|
||||
});
|
||||
|
||||
test('share state and restores follow the preference, not layout collapse', () => {
|
||||
const { shell, press, collapsed, document } = fixture();
|
||||
// Layout collapsed Street Level only for space; the user left it open.
|
||||
const street = document.getElementById('street-level-panel');
|
||||
street.classList.add('layout-auto-collapsed');
|
||||
press('cameras');
|
||||
assert.equal(shell.sharedCollapsed('street-level-panel'), false);
|
||||
press('cameras');
|
||||
assert.equal(
|
||||
collapsed('street-level-panel'),
|
||||
false,
|
||||
'not turned into a real collapse',
|
||||
);
|
||||
});
|
||||
|
||||
test('layout settling after a sheet opens is part of opening', () => {
|
||||
const { press, runFrames, collapsed, document } = fixture({
|
||||
expanded: ['street-level-panel'],
|
||||
});
|
||||
press('cameras');
|
||||
// A rail layout pass on the next frame collapses Street Level.
|
||||
document.getElementById('street-level-panel').classList.add('collapsed');
|
||||
runFrames();
|
||||
press('cameras');
|
||||
assert.equal(collapsed('street-level-panel'), false, 'put back as it was');
|
||||
});
|
||||
|
||||
test('the backdrop closes the open sheet', () => {
|
||||
const { document, shell, press } = fixture();
|
||||
press('context');
|
||||
document
|
||||
.getElementById('mobile-sheet-backdrop')
|
||||
.dispatchEvent(new Event('click'));
|
||||
assert.equal(shell.getSheet(), null);
|
||||
});
|
||||
|
||||
test('surfaces that own the screen close sheets; scene playback does not', () => {
|
||||
for (const mode of ['cockpit-mode', 'recording-mode', 'ui-clean-view']) {
|
||||
const { shell, press, setBodyClass, collapsed } = fixture();
|
||||
press('layers');
|
||||
setBodyClass(mode);
|
||||
assert.equal(shell.getSheet(), null, `${mode} closes the sheet`);
|
||||
assert.equal(collapsed('data-panel'), true, `${mode} puts panels back`);
|
||||
press('layers');
|
||||
assert.equal(shell.getSheet(), null, `${mode} keeps sheets closed`);
|
||||
}
|
||||
// Scene STOP lives in the Scenes panel of the Layers sheet.
|
||||
const playback = fixture();
|
||||
playback.setBodyClass('scene-playback-mode');
|
||||
playback.press('layers');
|
||||
assert.equal(playback.shell.getSheet(), 'layers');
|
||||
});
|
||||
|
||||
test('desktop widths never see the shell; crossing to desktop restores it', () => {
|
||||
const desktop = fixture({ mobile: false });
|
||||
assert.equal(desktop.document.body.classList.contains('mobile-shell'), false);
|
||||
desktop.press('layers');
|
||||
assert.equal(desktop.shell.getSheet(), null);
|
||||
assert.equal(
|
||||
desktop.viewport.getAttribute('content'),
|
||||
'width=device-width, initial-scale=1.0',
|
||||
);
|
||||
|
||||
const phone = fixture({ expanded: ['street-level-panel'] });
|
||||
assert.match(phone.viewport.getAttribute('content'), /viewport-fit=cover$/);
|
||||
phone.press('cameras');
|
||||
phone.resize(false);
|
||||
assert.equal(phone.shell.getSheet(), null);
|
||||
assert.equal(phone.document.body.classList.contains('mobile-shell'), false);
|
||||
assert.equal(phone.collapsed('cctv-panel'), true);
|
||||
assert.equal(phone.collapsed('street-level-panel'), false);
|
||||
assert.equal(
|
||||
phone.viewport.getAttribute('content'),
|
||||
'width=device-width, initial-scale=1.0',
|
||||
);
|
||||
});
|
||||
|
||||
test('destroy puts panels back and removes the shell', () => {
|
||||
const { document, shell, press, viewport, collapsed } = fixture();
|
||||
press('layers');
|
||||
shell.destroy();
|
||||
assert.equal(collapsed('data-panel'), true);
|
||||
assert.equal(document.getElementById('mobile-tab-bar'), null);
|
||||
assert.equal(document.getElementById('mobile-sheet-backdrop'), null);
|
||||
assert.equal(document.body.classList.contains('mobile-shell'), false);
|
||||
assert.equal(document.body.getAttribute('data-mobile-sheet'), null);
|
||||
assert.equal(document.body.style.values.size, 0);
|
||||
assert.equal(
|
||||
viewport.getAttribute('content'),
|
||||
'width=device-width, initial-scale=1.0',
|
||||
);
|
||||
});
|
||||
|
||||
test('the phone shell keeps the required attribution visible', () => {
|
||||
// Whitespace-normalised, so formatter line wrapping cannot hide a rule.
|
||||
const css = readFileSync(
|
||||
new URL('./styles/mobile-shell.css', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\( /g, '(')
|
||||
.replace(/ \)/g, ')');
|
||||
/** The declaration block of the rule whose selector list ends with `selector`. */
|
||||
const rule = (selector) => {
|
||||
const end = css.indexOf(`${selector} {`);
|
||||
assert.notEqual(end, -1, `missing rule ending in ${selector}`);
|
||||
const open = css.indexOf('{', end);
|
||||
return css.slice(open, css.indexOf('}', open));
|
||||
};
|
||||
const lift = (block) =>
|
||||
Number(
|
||||
/bottom: calc\(var\(--mobile-tab-space\) \+ (\d+)px\)/.exec(block)?.[1],
|
||||
);
|
||||
const z = (block) => Number(/z-index: (\d+);/.exec(block)?.[1]);
|
||||
const idleCredit = rule(
|
||||
'body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) #cesium-credits',
|
||||
);
|
||||
const dock = rule('body.mobile-shell #command-dock');
|
||||
const voice = rule('body.mobile-shell #command-dock #gev-voice-control');
|
||||
const voiceHeightPx = Number(/height: ([\d.]+)rem/.exec(voice)[1]) * 16;
|
||||
// Idle: the credit line clears the voice bar, which clears the tab bar.
|
||||
assert.ok(lift(idleCredit) > lift(dock) + voiceHeightPx);
|
||||
// It must beat the dock's :has() placements, so the offset is !important.
|
||||
assert.match(idleCredit, /\) !important;/);
|
||||
assert.doesNotMatch(
|
||||
idleCredit,
|
||||
/display:\s*none|visibility:\s*hidden|opacity:\s*0/,
|
||||
);
|
||||
// Open sheet: above everything the sheet covers, drawn above the sheets.
|
||||
const sheetCredit = rule(
|
||||
'body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view) #cesium-credits',
|
||||
);
|
||||
assert.match(
|
||||
sheetCredit,
|
||||
/bottom: calc\(var\(--mobile-sheet-cover, 0px\) \+ 6px\) !important;/,
|
||||
);
|
||||
const openDock = rule('body.mobile-shell[data-mobile-sheet] #command-dock');
|
||||
const railSheet = rule(
|
||||
"body.mobile-shell[data-mobile-sheet='more'] #right-context-rail",
|
||||
);
|
||||
assert.ok(z(sheetCredit) > z(openDock));
|
||||
assert.ok(z(sheetCredit) > z(railSheet));
|
||||
});
|
||||
|
||||
function touchEvent(
|
||||
document,
|
||||
type,
|
||||
target,
|
||||
{ x = 10, y = 10, count = 1, id = 1, cancelable = true } = {},
|
||||
) {
|
||||
const event = new Event(type, { cancelable });
|
||||
const touch = { identifier: id, clientX: x, clientY: y };
|
||||
Object.defineProperty(event, 'target', { value: target });
|
||||
event.touches =
|
||||
type === 'touchend' || type === 'touchcancel'
|
||||
? []
|
||||
: Array.from({ length: count }, (_, i) => ({
|
||||
...touch,
|
||||
identifier: id + i,
|
||||
}));
|
||||
event.changedTouches = [touch];
|
||||
document.dispatchEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
test('downward sheet pull closes through borrowed-panel lifecycle and returns tab focus', () => {
|
||||
for (const sheet of Object.keys(MOBILE_SHEET_PANELS)) {
|
||||
const phone = fixture();
|
||||
const { document, shell, press, tab } = phone;
|
||||
press(sheet);
|
||||
const target = document.getElementById(MOBILE_SHEET_PANELS[sheet][0]);
|
||||
touchEvent(document, 'touchstart', target);
|
||||
const move = touchEvent(document, 'touchmove', target, { y: 90 });
|
||||
assert.equal(move.defaultPrevented, true);
|
||||
assert.equal(shell.getSheet(), sheet, 'waits until finger lifts');
|
||||
touchEvent(document, 'touchend', target, { y: 90 });
|
||||
assert.equal(shell.getSheet(), null);
|
||||
assert.equal(phone.closedCount(), 1);
|
||||
assert.equal(document.activeElement, tab(sheet));
|
||||
for (const id of MOBILE_SHEET_PANELS[sheet])
|
||||
assert.equal(phone.collapsed(id), true);
|
||||
assert.ok(
|
||||
phone.calls.every(
|
||||
(call) => call.persist === false && call.syncShare === false,
|
||||
),
|
||||
);
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('content scroll stays native, including a scroll that reaches the top mid-gesture', () => {
|
||||
for (const nested of [false, true]) {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
const scroller = nested
|
||||
? panel
|
||||
: document.getElementById('left-panel-stack');
|
||||
scroller.scrollTop = 40;
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
scroller.scrollTop = 0;
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('taps, short pulls, upward and horizontal gestures never dismiss', () => {
|
||||
for (const path of [
|
||||
[],
|
||||
[{ x: 12, y: 14 }],
|
||||
[{ y: 40 }],
|
||||
[{ y: -30 }, { y: 120 }],
|
||||
[{ x: 60, y: 12 }, { y: 120 }],
|
||||
]) {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
for (const point of path) touchEvent(document, 'touchmove', panel, point);
|
||||
touchEvent(document, 'touchend', panel, path.at(-1) ?? {});
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('native controls and custom sliders keep touch gestures', () => {
|
||||
for (const tag of [
|
||||
'button',
|
||||
'a',
|
||||
'input',
|
||||
'select',
|
||||
'textarea',
|
||||
'label',
|
||||
'slider',
|
||||
'editable',
|
||||
]) {
|
||||
const { document, shell, press } = fixture();
|
||||
press('more');
|
||||
const control = document.createElement(
|
||||
['slider', 'editable'].includes(tag) ? 'div' : tag,
|
||||
);
|
||||
if (tag === 'slider') control.setAttribute('role', 'slider');
|
||||
if (tag === 'editable') control.setAttribute('contenteditable', 'true');
|
||||
const child = document.createElement('span');
|
||||
control.append(child);
|
||||
document.getElementById('control-panel').append(control);
|
||||
touchEvent(document, 'touchstart', child);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', child, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', child, { y: 120 });
|
||||
assert.equal(shell.getSheet(), 'more');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('multitouch, cancellation, sheet changes and desktop transitions abandon the gesture', () => {
|
||||
for (const cancel of [
|
||||
'multitouch',
|
||||
'cancel',
|
||||
'sheet',
|
||||
'desktop',
|
||||
'noncancelable',
|
||||
]) {
|
||||
const { document, shell, press, resize } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
if (cancel === 'multitouch')
|
||||
touchEvent(document, 'touchstart', document.body, { count: 2 });
|
||||
if (cancel === 'cancel') touchEvent(document, 'touchcancel', panel);
|
||||
if (cancel === 'sheet') press('context');
|
||||
if (cancel === 'desktop') {
|
||||
resize(false);
|
||||
resize(true);
|
||||
press('layers');
|
||||
}
|
||||
if (cancel === 'noncancelable')
|
||||
touchEvent(document, 'touchmove', panel, { y: 30, cancelable: false });
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), cancel === 'sheet' ? 'context' : 'layers');
|
||||
shell.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('touches outside the active sheet and destroyed listeners cannot dismiss or claim a gesture', () => {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const hiddenPanel = document.getElementById('global-context-panel');
|
||||
touchEvent(document, 'touchstart', hiddenPanel);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', hiddenPanel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', hiddenPanel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), 'layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel);
|
||||
shell.destroy();
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 120 }).defaultPrevented,
|
||||
false,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 120 });
|
||||
assert.equal(shell.getSheet(), null);
|
||||
});
|
||||
|
||||
test('the tab-open callback reports only a successful manual opening', () => {
|
||||
const opened = [];
|
||||
const phone = fixture({ onTabOpened: (sheet) => opened.push(sheet) });
|
||||
phone.shell.setSheet('context');
|
||||
assert.deepEqual(opened, []);
|
||||
phone.press('context');
|
||||
assert.deepEqual(opened, [], 'closing is not a new context request');
|
||||
phone.press('context');
|
||||
assert.deepEqual(opened, ['context']);
|
||||
phone.setBodyClass('cockpit-mode');
|
||||
phone.press('context');
|
||||
assert.deepEqual(
|
||||
opened,
|
||||
['context'],
|
||||
'blocked sheets cannot activate a mode',
|
||||
);
|
||||
phone.shell.destroy();
|
||||
});
|
||||
|
||||
test('manual Context opens the existing transaction without disabling the tracked aircraft', async () => {
|
||||
const calls = [];
|
||||
const tracked = { layerId: 'flights', icao24: 'abc123', callsign: 'EJA225' };
|
||||
const viewer = { trackedEntity: { gevTrackedId: 'flights:abc123' } };
|
||||
const entity = viewer.trackedEntity;
|
||||
const enabled = new Set(['flights', 'weather']);
|
||||
const controls = new ContextControls({
|
||||
elements: {},
|
||||
installations: {},
|
||||
actions: {
|
||||
getCockpit: () => null,
|
||||
claimVisualAuthority: () => calls.push('claim'),
|
||||
syncDetection() {},
|
||||
scheduleLayout() {},
|
||||
showToast() {},
|
||||
},
|
||||
});
|
||||
controls.connect({
|
||||
layers: new Map(
|
||||
['flights', 'weather', 'military-awareness'].map((id) => [id, {}]),
|
||||
),
|
||||
getEnabledLayerIds: () => [...enabled],
|
||||
getLayerParams: () => ({}),
|
||||
isEffectivelyEnabled: (id) => enabled.has(id),
|
||||
isEnabled: (id) => enabled.has(id),
|
||||
setLayerParams: (id, params) => calls.push({ id, params }),
|
||||
setEnabled: async (id, value) => {
|
||||
calls.push({ id, value });
|
||||
if (!value) enabled.delete(id);
|
||||
if (id === 'flights' && !value) viewer.trackedEntity = null;
|
||||
return true;
|
||||
},
|
||||
_setEnabledWithIntent: (id, value) => {
|
||||
calls.push({ id, value });
|
||||
enabled.add(id);
|
||||
return { intentEpoch: 1, promise: Promise.resolve(true) };
|
||||
},
|
||||
});
|
||||
const shell = {
|
||||
cockpitView: {
|
||||
readAircraftInfo: () => (viewer.trackedEntity ? tracked : null),
|
||||
},
|
||||
getAircraftTrackingTarget: StyleManager.prototype.getAircraftTrackingTarget,
|
||||
getContextModeState: () => controls.getContextModeState(),
|
||||
setContextMode: (mode) => controls.setContextMode(mode),
|
||||
_showToast: () => assert.fail('successful entry must not report a failure'),
|
||||
};
|
||||
try {
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
|
||||
true,
|
||||
);
|
||||
assert.equal(controls.getContextModeState().mode, 'flights');
|
||||
assert.equal(viewer.trackedEntity, entity);
|
||||
assert.deepEqual(shell.getAircraftTrackingTarget(), {
|
||||
layerId: 'flights',
|
||||
id: 'abc123',
|
||||
});
|
||||
assert.ok(enabled.has('flights'));
|
||||
assert.ok(
|
||||
calls.some(
|
||||
(call) => call.id === 'military-awareness' && call.value === true,
|
||||
),
|
||||
);
|
||||
assert.ok(!calls.some((call) => call.id === 'flights'));
|
||||
const count = calls.length;
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
calls.length,
|
||||
count,
|
||||
'an already active Contacts session is not restarted',
|
||||
);
|
||||
} finally {
|
||||
controls.stop();
|
||||
controls.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
test('manual Context respects Space Missions, busy transitions, missing tracks and disposal', async () => {
|
||||
for (const state of [
|
||||
{ mode: 'space-missions' },
|
||||
{ mode: 'flights' },
|
||||
{ changing: true },
|
||||
{ entering: 'space-missions' },
|
||||
{ noTrack: true },
|
||||
{ disposed: true },
|
||||
{ sheet: 'layers' },
|
||||
]) {
|
||||
const shell = {
|
||||
_disposed: state.disposed,
|
||||
getContextModeState: () => state,
|
||||
getAircraftTrackingTarget: () =>
|
||||
state.noTrack ? null : { layerId: 'flights', id: 'abc123' },
|
||||
setContextMode: () =>
|
||||
assert.fail('guarded tab must not mutate Context or tracking'),
|
||||
};
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(
|
||||
shell,
|
||||
state.sheet || 'context',
|
||||
),
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('failed mobile Contacts entry uses the existing notice and cancelled/disposed entries stay quiet', async () => {
|
||||
for (const outcome of ['failure', 'throw', 'cancelled', 'disposed']) {
|
||||
const notices = [];
|
||||
const shell = {
|
||||
getContextModeState: () => ({ mode: null }),
|
||||
getAircraftTrackingTarget: () => ({ layerId: 'flights', id: 'abc123' }),
|
||||
setContextMode: async () => {
|
||||
if (outcome === 'throw') throw new Error('source unavailable');
|
||||
if (outcome === 'disposed') shell._disposed = true;
|
||||
return { ok: false, cancelled: outcome === 'cancelled' };
|
||||
},
|
||||
_showToast: (message) => notices.push(message),
|
||||
};
|
||||
assert.equal(
|
||||
await StyleManager.prototype._onMobileTabOpened.call(shell, 'context'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
notices.length,
|
||||
['failure', 'throw'].includes(outcome) ? 1 : 0,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('a slow downward pull is claimed before Android native scroll takes ownership', () => {
|
||||
const { document, shell, press } = fixture();
|
||||
press('layers');
|
||||
const panel = document.getElementById('data-panel');
|
||||
touchEvent(document, 'touchstart', panel, { y: 100 });
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 102 }).defaultPrevented,
|
||||
false,
|
||||
'tap jitter is left native',
|
||||
);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 109.3 }).defaultPrevented,
|
||||
true,
|
||||
'cancel the first directional move while Android still allows it',
|
||||
);
|
||||
assert.equal(
|
||||
touchEvent(document, 'touchmove', panel, { y: 131 }).defaultPrevented,
|
||||
true,
|
||||
);
|
||||
touchEvent(document, 'touchend', panel, { y: 175 });
|
||||
assert.equal(shell.getSheet(), null);
|
||||
shell.destroy();
|
||||
});
|
||||
@@ -14,6 +14,7 @@ export class NavigationController {
|
||||
tracking,
|
||||
searchInput,
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation = () => {},
|
||||
isCockpitActive,
|
||||
clearLocation,
|
||||
cancelShareSelection,
|
||||
@@ -27,6 +28,7 @@ export class NavigationController {
|
||||
tracking,
|
||||
searchInput,
|
||||
interruptCameraMotion,
|
||||
interruptGlobeRotation,
|
||||
isCockpitActive,
|
||||
clearLocation,
|
||||
cancelShareSelection,
|
||||
@@ -46,6 +48,7 @@ export class NavigationController {
|
||||
cancelPendingSelection = true,
|
||||
clearSearchedLocation = true,
|
||||
} = {}) {
|
||||
this.interruptGlobeRotation('navigation-intent');
|
||||
cancelCameraArrival(this.viewer);
|
||||
this.cancelOrientation();
|
||||
const { flightsLayer, militaryFlightsLayer, satellitesLayer } =
|
||||
|
||||
+17
-3
@@ -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();
|
||||
@@ -149,6 +151,9 @@ export class PanelChrome {
|
||||
}
|
||||
|
||||
_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) =>
|
||||
@@ -250,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),
|
||||
@@ -380,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);
|
||||
@@ -439,7 +451,9 @@ export class PanelChrome {
|
||||
!restore &&
|
||||
!nextCollapsed &&
|
||||
document.documentElement?.dataset.uiTheme === 'cyber' &&
|
||||
panelEl.parentElement === this._rightPanelStack
|
||||
panelEl.parentElement === this._rightPanelStack &&
|
||||
// Phone sheets show several rail panels at once.
|
||||
!document.body?.classList?.contains('mobile-shell')
|
||||
) {
|
||||
for (const peer of this._rightPanelStack.children) {
|
||||
if (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -218,7 +218,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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -501,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;
|
||||
|
||||
@@ -209,6 +209,49 @@
|
||||
letter-spacing: 0.055rem;
|
||||
}
|
||||
|
||||
.first-run-dismiss {
|
||||
position: relative;
|
||||
padding: 0 0 0 0.75rem;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
letter-spacing: inherit;
|
||||
text-transform: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* A 44px touch target drawn outside the layout, so the footer row keeps the
|
||||
height of its one line of text; a 44px button box would nearly treble it. */
|
||||
.first-run-dismiss::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 44px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.first-run-dismiss:hover,
|
||||
.first-run-dismiss:focus-visible {
|
||||
color: rgba(54, 220, 255, 0.86);
|
||||
}
|
||||
|
||||
.first-run-dismiss-touch {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* No hover and a coarse pointer: a phone or tablet, where ESC means nothing. */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.first-run-dismiss-key {
|
||||
display: none;
|
||||
}
|
||||
.first-run-dismiss-touch {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.first-run-suppress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -270,8 +313,9 @@ body.scene-playback-mode #first-run-launcher {
|
||||
#first-run-launcher.visible {
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
}
|
||||
/* Five tiles have to clear the dock on a 375 x 812 phone, so the rows tighten
|
||||
and the redundant "ESC to dismiss" hint (no ESC key on a phone) drops. */
|
||||
/* Five tiles have to clear the dock on a 375 x 812 phone, so the rows
|
||||
tighten. The dismiss button stays: it is the only way to close the card
|
||||
without choosing a mission on a phone, which has no ESC key. */
|
||||
#first-run-launcher h2 {
|
||||
margin-top: 0.45rem;
|
||||
}
|
||||
@@ -289,9 +333,6 @@ body.scene-playback-mode #first-run-launcher {
|
||||
.first-run-choices small {
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
.first-run-footer > span:last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Short viewports — landscape phones, small laptop windows. The chrome tightens
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
/* ── Phone shell (≤720px, body.mobile-shell; src/ui/mobileShell.js) ─────────
|
||||
* The globe is full screen. A bottom tab bar opens one sheet; the sheet shows
|
||||
* the panels of that tab from the rails or dock, everything else stays hidden.
|
||||
*/
|
||||
:root {
|
||||
--mobile-tab-height: 58px;
|
||||
--mobile-bottom-inset: env(safe-area-inset-bottom, 0px);
|
||||
--mobile-top-inset: env(safe-area-inset-top, 0px);
|
||||
--mobile-tab-space: calc(
|
||||
var(--mobile-tab-height) + var(--mobile-bottom-inset)
|
||||
);
|
||||
--mobile-sheet-max-height: min(62vh, calc(100vh - 168px));
|
||||
--mobile-dock-sheet-max-height: 34vh;
|
||||
--mobile-rail-under-dock-max-height: 40vh;
|
||||
}
|
||||
|
||||
/* Dynamic viewport units keep sheets inside the area a mobile browser's
|
||||
toolbars leave visible. */
|
||||
@supports (height: 1dvh) {
|
||||
:root {
|
||||
--mobile-sheet-max-height: min(62dvh, calc(100dvh - 168px));
|
||||
--mobile-dock-sheet-max-height: 34dvh;
|
||||
--mobile-rail-under-dock-max-height: 40dvh;
|
||||
}
|
||||
}
|
||||
|
||||
#mobile-tab-bar,
|
||||
#mobile-sheet-backdrop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell #mobile-tab-bar {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 170;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
height: var(--mobile-tab-space);
|
||||
padding: 0 max(4px, env(safe-area-inset-right, 0px))
|
||||
var(--mobile-bottom-inset) max(4px, env(safe-area-inset-left, 0px));
|
||||
background: rgba(8, 14, 22, 0.94);
|
||||
border-top: 1px solid var(--glass-border);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
body.mobile-shell .mobile-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
padding: 6px 0 4px;
|
||||
color: var(--text-secondary);
|
||||
background: none;
|
||||
border: 0;
|
||||
font: 500 10px/1.2 var(--font-sans);
|
||||
letter-spacing: 0.04em;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body.mobile-shell .mobile-tab-icon {
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
body.mobile-shell .mobile-tab[aria-expanded='true'] {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
body.mobile-shell .mobile-tab:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet] #mobile-sheet-backdrop:not([hidden]) {
|
||||
position: fixed;
|
||||
inset: 0 0 var(--mobile-tab-space);
|
||||
z-index: 155;
|
||||
display: block;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
/* ── Top bar: logo left, globe actions right ───────────────────────────── */
|
||||
body.mobile-shell #title-bar {
|
||||
top: calc(var(--mobile-top-inset) + 10px);
|
||||
left: 14px;
|
||||
}
|
||||
|
||||
body.mobile-shell #title-bar .title-logo {
|
||||
width: 58px;
|
||||
}
|
||||
|
||||
body.mobile-shell #title-bar h1 > :not(.title-logo),
|
||||
body.mobile-shell #title-bar .subtitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell #top-center-actions {
|
||||
top: calc(var(--mobile-top-inset) + 10px);
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
/* ── HUD: one compact readout under the top bar ────────────────────────── */
|
||||
body.mobile-shell #intel-hud .hud-edge,
|
||||
body.mobile-shell #intel-hud .hud-corner,
|
||||
body.mobile-shell #intel-hud .hud-top-bar,
|
||||
body.mobile-shell #intel-hud .hud-bottom-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell #intel-hud .hud-bottom-left {
|
||||
top: calc(var(--mobile-top-inset) + 62px);
|
||||
bottom: auto;
|
||||
left: 16px;
|
||||
font-size: 9px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ── Panels: hidden unless their tab's sheet is open ───────────────────── */
|
||||
body.mobile-shell #left-panel-stack,
|
||||
body.mobile-shell #right-context-rail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack,
|
||||
body.mobile-shell[data-mobile-sheet='cameras'] #right-context-rail,
|
||||
body.mobile-shell[data-mobile-sheet='context'] #right-context-rail,
|
||||
body.mobile-shell[data-mobile-sheet='more'] #right-context-rail {
|
||||
position: fixed;
|
||||
top: auto;
|
||||
right: 0;
|
||||
bottom: var(--mobile-tab-space);
|
||||
left: 0;
|
||||
z-index: 160;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
max-height: var(--mobile-sheet-max-height);
|
||||
padding: 14px 10px 10px;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
pointer-events: auto;
|
||||
background: rgba(8, 14, 22, 0.96);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-bottom: 0;
|
||||
border-radius: 18px 18px 0 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
body.mobile-shell #left-panel-stack > *,
|
||||
body.mobile-shell #right-context-rail > *,
|
||||
/* Rail rules show expanded Weather and Recent Imagery by id; on phones they
|
||||
belong to the Context sheet only. */
|
||||
body.mobile-shell #right-context-rail > #weather-panel,
|
||||
body.mobile-shell #right-context-rail > #recent-imagery-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Rail-scoped ids so a rail's own hiding rules (for example the Tactical HUD
|
||||
hiding collapsed siblings) never take a panel out of an open sheet. */
|
||||
body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #data-panel,
|
||||
body.mobile-shell[data-mobile-sheet='layers'] #left-panel-stack > #scene-panel,
|
||||
body.mobile-shell[data-mobile-sheet='cameras']
|
||||
#right-context-rail
|
||||
> #cctv-panel,
|
||||
body.mobile-shell[data-mobile-sheet='cameras']
|
||||
#right-context-rail
|
||||
> #street-level-panel,
|
||||
body.mobile-shell[data-mobile-sheet='context']
|
||||
#right-context-rail
|
||||
> #global-context-panel,
|
||||
body.mobile-shell[data-mobile-sheet='context']
|
||||
#right-context-rail
|
||||
> #weather-panel:not([hidden]),
|
||||
body.mobile-shell[data-mobile-sheet='context']
|
||||
#right-context-rail
|
||||
> #recent-imagery-panel:not([hidden]),
|
||||
body.mobile-shell[data-mobile-sheet='more'] #right-context-rail > #pp-toggles {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ── Dock: voice floats above the tab bar; search and presets are sheets ─ */
|
||||
body.mobile-shell #command-dock {
|
||||
/* The dock's tray rules size voice by this basis; a column makes it a height. */
|
||||
--dock-voice-width: auto;
|
||||
top: auto;
|
||||
bottom: calc(var(--mobile-tab-space) + 10px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
padding: 0 10px;
|
||||
pointer-events: none;
|
||||
background: none;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
/* The desktop dock centres itself with left: 50% and a translate under
|
||||
:has() selectors that outrank any phone selector; on phones it spans the
|
||||
width, so these few geometry properties must win. */
|
||||
body.mobile-shell #command-dock {
|
||||
right: 0 !important;
|
||||
left: 0 !important;
|
||||
width: auto !important;
|
||||
max-width: none !important;
|
||||
transform: none !important;
|
||||
translate: none !important;
|
||||
}
|
||||
|
||||
body.mobile-shell #command-dock > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
body.mobile-shell #location-bar,
|
||||
body.mobile-shell #control-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet] #command-dock {
|
||||
z-index: 165;
|
||||
bottom: calc(var(--mobile-tab-space) + var(--mobile-sheet-height, 0px) + 8px);
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='search'] #command-dock > #location-bar,
|
||||
body.mobile-shell[data-mobile-sheet='more'] #command-dock > #control-panel {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
display: flex;
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Search and More: the dock becomes the top of the sheet, flush with the rail
|
||||
sheet below it (More) or alone above the tab bar (Search). */
|
||||
body.mobile-shell[data-mobile-sheet='search'] #command-dock,
|
||||
body.mobile-shell[data-mobile-sheet='more'] #command-dock {
|
||||
bottom: calc(var(--mobile-tab-space) + var(--mobile-sheet-height, 0px));
|
||||
align-items: stretch;
|
||||
max-height: var(--mobile-dock-sheet-max-height);
|
||||
padding: 14px 10px 8px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
pointer-events: auto;
|
||||
background: rgba(8, 14, 22, 0.96);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-bottom: 0;
|
||||
border-radius: 18px 18px 0 0;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='more'] #right-context-rail {
|
||||
max-height: var(--mobile-rail-under-dock-max-height);
|
||||
padding-top: 4px;
|
||||
border-top: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Presets and Location show their popover inline instead of as a floating
|
||||
tray; the dock scrolls, so a tray above it would be clipped. */
|
||||
body.mobile-shell[data-mobile-sheet='more']
|
||||
#command-dock
|
||||
#control-panel
|
||||
.panel-inner,
|
||||
body.mobile-shell[data-mobile-sheet='more']
|
||||
#command-dock
|
||||
#control-panel-popover,
|
||||
body.mobile-shell[data-mobile-sheet='search']
|
||||
#command-dock
|
||||
#location-bar
|
||||
.location-inner,
|
||||
body.mobile-shell[data-mobile-sheet='search']
|
||||
#command-dock
|
||||
#location-bar-popover {
|
||||
position: static;
|
||||
inset: auto;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
margin: 0;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: none;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* The tab names the sheet; the tray toggle and pin have nothing to do here. */
|
||||
body.mobile-shell[data-mobile-sheet='more'] #command-dock #control-panel-toggle,
|
||||
body.mobile-shell[data-mobile-sheet='more']
|
||||
#command-dock
|
||||
#control-panel
|
||||
.dock-pin-btn,
|
||||
body.mobile-shell[data-mobile-sheet='search']
|
||||
#command-dock
|
||||
#location-bar-toggle,
|
||||
body.mobile-shell[data-mobile-sheet='search']
|
||||
#command-dock
|
||||
#location-bar
|
||||
.panel-collapse-btn,
|
||||
body.mobile-shell[data-mobile-sheet='search']
|
||||
#command-dock
|
||||
#location-bar
|
||||
.dock-pin-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='more'] #style-buttons {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Search: the field takes the full width; the place pills scroll beneath it. */
|
||||
body.mobile-shell[data-mobile-sheet='search'] #location-bar .location-city-row {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='search']
|
||||
#location-bar
|
||||
.location-search-wrap {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='search'] #location-search {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
body.mobile-shell[data-mobile-sheet='search'] #location-bar .location-pills {
|
||||
flex: 1 1 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* With a sheet open, voice steps aside; Search and More own the dock. */
|
||||
body.mobile-shell[data-mobile-sheet] #command-dock > #gev-voice-control {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell #command-dock #gev-voice-control {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
flex: none;
|
||||
align-self: center;
|
||||
width: min(280px, 80vw);
|
||||
height: 5.35rem;
|
||||
margin: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ── Credits and corner chips clear the tab bar ────────────────────────── */
|
||||
/* While the tab bar shows, the credit line sits above the voice bar. The
|
||||
dock's placements (including the Minimal HUD's :has() variant) outrank any
|
||||
phone selector, so the offset must win. Clean view keeps its own corner. */
|
||||
body.mobile-shell:not(.recording-mode):not(.cockpit-mode):not(.ui-clean-view)
|
||||
#cesium-credits {
|
||||
right: auto;
|
||||
bottom: calc(var(--mobile-tab-space) + 106px) !important;
|
||||
left: 10px;
|
||||
max-width: calc(100vw - 20px);
|
||||
transform-origin: left bottom;
|
||||
scale: 0.8;
|
||||
}
|
||||
|
||||
/* An open sheet lifts the required attribution above everything it covers. */
|
||||
body.mobile-shell[data-mobile-sheet]:not(.recording-mode):not(
|
||||
.cockpit-mode
|
||||
):not(.ui-clean-view)
|
||||
#cesium-credits {
|
||||
bottom: calc(var(--mobile-sheet-cover, 0px) + 6px) !important;
|
||||
z-index: 166;
|
||||
}
|
||||
|
||||
/* The view switcher clears the tab bar and the voice bar, opposite the credit. */
|
||||
body.mobile-shell:not(.cockpit-mode) #view-switcher {
|
||||
right: 12px;
|
||||
bottom: calc(var(--mobile-tab-space) + 106px);
|
||||
}
|
||||
|
||||
body.mobile-shell #corner-actions {
|
||||
top: calc(var(--mobile-top-inset) + 60px);
|
||||
right: 12px;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
/* Full-screen modes hide the phone chrome like the desktop chrome. Scene
|
||||
playback keeps it: the Scenes panel (Layers tab) holds its STOP control. */
|
||||
body.mobile-shell.ui-clean-view #mobile-tab-bar,
|
||||
body.mobile-shell.recording-mode #mobile-tab-bar,
|
||||
body.mobile-shell.cockpit-mode #mobile-tab-bar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body.mobile-shell.ui-clean-view #command-dock,
|
||||
body.mobile-shell.cockpit-mode #command-dock {
|
||||
bottom: calc(var(--mobile-bottom-inset) + 10px);
|
||||
}
|
||||
@@ -371,3 +371,134 @@
|
||||
#intel-hud[data-variant='minimal'] .hud-summary-wrap {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
/* Scope OFF adds text-sized backing; Scope ON retains the original HUD. */
|
||||
.hud-readout {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
.hud-readout,
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber']) .scene-text-holdout {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
box-shadow: none;
|
||||
border-radius: 2px;
|
||||
padding-block: 0;
|
||||
padding-inline: 6px;
|
||||
margin-block: 0;
|
||||
margin-inline: -6px;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
box-sizing: content-box;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
.hud-readout {
|
||||
display: block;
|
||||
width: max-content;
|
||||
min-width: 0;
|
||||
box-sizing: content-box;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is(
|
||||
[data-variant='minimal'],
|
||||
[data-variant='operator'],
|
||||
[data-variant='tactical']
|
||||
)
|
||||
:is(.hud-top-right, .hud-bottom-right)
|
||||
.hud-readout,
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
#style-indicator
|
||||
.scene-text-holdout {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
/* Preserve glyph opacity without dimming the vertical backplates. */
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
|
||||
.hud-edge {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='operator'], [data-variant='tactical'])
|
||||
.hud-edge
|
||||
.hud-readout
|
||||
> * {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off'] #intel-hud[data-variant='operator'] .hud-corner,
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
|
||||
:is(.hud-top-bar, .hud-bottom-bar) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
|
||||
:is(.hud-top-bar, .hud-bottom-bar) {
|
||||
width: fit-content;
|
||||
max-width: calc(100vw - 120px);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud:is([data-variant='minimal'], [data-variant='operator'])
|
||||
:is(.hud-top-bar, .hud-bottom-bar)
|
||||
.hud-readout {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
#title-bar
|
||||
h1::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -0.12em -6px;
|
||||
z-index: -1;
|
||||
border-radius: 2px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
:is(#title-bar .subtitle, #style-indicator .indicator-label) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber']) #title-bar .subtitle {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
#style-indicator
|
||||
.indicator-value {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']
|
||||
#intel-hud[data-variant='minimal']
|
||||
.hud-readout:has(> :is(.hud-classification, .hud-system)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root[data-scope-mask='off']:not([data-ui-theme='cyber'])
|
||||
#title-bar
|
||||
.frame-rate-readout {
|
||||
margin-top: 6px;
|
||||
}
|
||||
@@ -106,19 +106,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn active" id="scope-toggle" title="Scope — the circular viewport mask" aria-pressed="true" data-i18n-title>
|
||||
<label class="pp-toggle-btn pp-scope-toggle" title="Scope — the circular viewport mask" data-i18n-title>
|
||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span>
|
||||
<span class="pp-label" data-i18n>Scope</span>
|
||||
</button>
|
||||
<input type="checkbox" id="scope-toggle" checked />
|
||||
</label>
|
||||
<div class="pp-slider-row visible" id="scope-slider-row">
|
||||
<span class="pp-slider-mini-label" data-i18n>Feather</span>
|
||||
<!-- Feather starts at an 11% soft scope-mask edge (owner final lock
|
||||
2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings);
|
||||
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
|
||||
softens the black mask edge; label fading is the separate Fade
|
||||
slider. -->
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="scope-feather-value">11%</span>
|
||||
<input type="range" class="gev-quantitative-slider pp-slider" id="scope-feather-slider" min="0" max="200" value="100" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" data-i18n-aria-label data-i18n-title />
|
||||
<span class="pp-slider-value" id="scope-feather-value">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
<div id="title-bar">
|
||||
<div class="title-glow"></div>
|
||||
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span translate="no">GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
|
||||
<p class="subtitle" data-i18n>NO PLACE LEFT BEHIND</p>
|
||||
<p class="subtitle scene-text-holdout" data-i18n>NO PLACE LEFT BEHIND</p>
|
||||
</div>
|
||||
|
||||
<!-- Style indicator -->
|
||||
<div id="style-indicator">
|
||||
<span class="indicator-label" data-i18n>ACTIVE STYLE</span>
|
||||
<span class="indicator-value" id="active-style-name" data-i18n>NORMAL</span>
|
||||
<span class="indicator-label scene-text-holdout" data-i18n>ACTIVE STYLE</span>
|
||||
<span class="indicator-value scene-text-holdout" id="active-style-name" data-i18n>NORMAL</span>
|
||||
</div>
|
||||
|
||||
<!-- Top-center camera and share actions -->
|
||||
|
||||
@@ -44,7 +44,13 @@
|
||||
<input type="checkbox" data-first-run-suppress />
|
||||
<span data-i18n>Don't show this again</span>
|
||||
</label>
|
||||
<span data-i18n>ESC to dismiss</span>
|
||||
<!-- A button, not just a hint: phones and tablets have no ESC key. It
|
||||
closes exactly as ESC does, for this session only. Touch screens
|
||||
see DISMISS; a keyboard keeps the ESC hint (first-run.css). -->
|
||||
<button type="button" class="first-run-dismiss" data-first-run-dismiss>
|
||||
<span class="first-run-dismiss-key" data-i18n>ESC to dismiss</span>
|
||||
<span class="first-run-dismiss-touch" data-i18n>DISMISS</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Kept out of the footer row: it swaps to mission progress and errors, and
|
||||
a live region that also holds layout makes the row jump as it speaks. -->
|
||||
|
||||
+13
-1
@@ -159,18 +159,30 @@ export class VisualEffects {
|
||||
this.requestRender('bloom');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sharpen runs only when it is on and its slider is above 0, as the style
|
||||
* stages do: the pass costs the same at any amount (~1.5 ms GPU per frame
|
||||
* on the Arc, 2026-10-24), and `0.1 + value * 2` still sharpened at 0.
|
||||
*/
|
||||
syncSharpenEnabled() {
|
||||
if (this.stopped || !this.sharpenStage) return;
|
||||
this.sharpenStage.enabled =
|
||||
this.sharpenEnabled && this.sharpenIntensity > 0.001;
|
||||
}
|
||||
|
||||
applySharpenIntensity(value) {
|
||||
if (this.stopped) return;
|
||||
this.sharpenIntensity = value;
|
||||
if (this.sharpenStage)
|
||||
this.sharpenStage.uniforms.amount = 0.1 + value * 2.0;
|
||||
this.syncSharpenEnabled();
|
||||
this.requestRender('sharpen');
|
||||
}
|
||||
|
||||
setSharpenEnabled(enabled) {
|
||||
if (this.stopped) return;
|
||||
this.sharpenEnabled = !!enabled;
|
||||
if (this.sharpenStage) this.sharpenStage.enabled = this.sharpenEnabled;
|
||||
this.syncSharpenEnabled();
|
||||
this.requestRender('sharpen');
|
||||
}
|
||||
|
||||
|
||||
@@ -224,6 +224,24 @@ test('sharpen uses the existing uniform scale and keeps values while disabled',
|
||||
f.effects.destroy();
|
||||
});
|
||||
|
||||
test('sharpen at 0 runs no pass, and comes back with the slider', () => {
|
||||
const f = fixture();
|
||||
f.effects.initPostProcess(0.49);
|
||||
f.effects.setSharpenEnabled(true);
|
||||
assert.equal(f.effects.sharpenStage.enabled, true);
|
||||
f.effects.applySharpenIntensity(0);
|
||||
assert.equal(
|
||||
f.effects.sharpenStage.enabled,
|
||||
false,
|
||||
'no residual sharpen at 0',
|
||||
);
|
||||
f.effects.setSharpenEnabled(true);
|
||||
assert.equal(f.effects.sharpenStage.enabled, false, 'on at 0 is still off');
|
||||
f.effects.applySharpenIntensity(0.2);
|
||||
assert.equal(f.effects.sharpenStage.enabled, true);
|
||||
f.effects.destroy();
|
||||
});
|
||||
|
||||
test('stop revokes pending work immediately but retains stages until final destruction', () => {
|
||||
const f = fixture();
|
||||
f.effects.initStyles();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { clampScopeFeatherPct } from '../scopeMaskPolicy.js';
|
||||
import { syncScopeControls } from './displayControls.js';
|
||||
import { STYLE_STATUS_LABELS } from './visualPresets.js';
|
||||
import { syncSegmentedControl } from './segmentedControl.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
@@ -10,7 +12,6 @@ import {
|
||||
MILITARY_DETECTION_PRESET,
|
||||
} from './effects.js';
|
||||
import { createStyleParameters } from './visualInput.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
BLOOM_SCALE_VERSION,
|
||||
clampBloomIntensity,
|
||||
@@ -137,11 +138,10 @@ export class VisualSettings {
|
||||
}
|
||||
if (typeof scopeEnabled === 'boolean') {
|
||||
setScopeMaskEnabled(scopeEnabled);
|
||||
this._scopeBtn?.classList.toggle('active', scopeEnabled);
|
||||
this._scopeBtn?.setAttribute('aria-pressed', String(scopeEnabled));
|
||||
syncScopeControls(this._scopeBtn, this._scopeFeatherSlider, scopeEnabled);
|
||||
}
|
||||
if (typeof scopeFeatherPct === 'number' && this._scopeFeatherSlider) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(scopeFeatherPct)));
|
||||
const pct = clampScopeFeatherPct(scopeFeatherPct);
|
||||
this._scopeFeatherSlider.value = String(pct);
|
||||
if (this._scopeFeatherValue)
|
||||
this._scopeFeatherValue.textContent = `${pct}%`;
|
||||
@@ -1303,11 +1303,14 @@ export class VisualSettings {
|
||||
const scopeState = state.scope || {};
|
||||
if (typeof scopeState.enabled === 'boolean') {
|
||||
setScopeMaskEnabled(scopeState.enabled);
|
||||
this._scopeBtn?.classList.toggle('active', scopeState.enabled);
|
||||
this._scopeBtn?.setAttribute('aria-pressed', String(scopeState.enabled));
|
||||
syncScopeControls(
|
||||
this._scopeBtn,
|
||||
this._scopeFeatherSlider,
|
||||
scopeState.enabled,
|
||||
);
|
||||
}
|
||||
if (typeof scopeState.featherPct === 'number' && this._scopeFeatherSlider) {
|
||||
const pct = Math.max(0, Math.min(100, Math.round(scopeState.featherPct)));
|
||||
const pct = clampScopeFeatherPct(scopeState.featherPct);
|
||||
this._scopeFeatherSlider.value = String(pct);
|
||||
if (this._scopeFeatherValue)
|
||||
this._scopeFeatherValue.textContent = `${pct}%`;
|
||||
@@ -1538,6 +1541,11 @@ export class VisualSettings {
|
||||
preserveCyberRestore = false,
|
||||
} = {},
|
||||
) {
|
||||
if (
|
||||
typeof styleName !== 'string' ||
|
||||
(styleName !== 'normal' && !Object.hasOwn(STYLES, styleName))
|
||||
)
|
||||
return;
|
||||
const { setDetectionStyle } = this.services;
|
||||
if (!restore) this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
if (this.hud.getVariant() === 'cyber') {
|
||||
|
||||
Reference in new issue
Block a user