Merge upstream/main into pr/mapillary-layer

Upstream took share token '2' for fire perimeters, so Street Level moves to
'3'. mapillary-js joins upstream's optimizeDeps include list instead of
overriding it; catalog and registry tests count the new upstream layers.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Said TurkseverandClaude Fable 5.1 committed 2026-09-25 17:58:27 +01:00
commit 9ba6f96d87
204 files changed
+24525 -314

No files matched your search

+61 -5
View File
@@ -17,11 +17,17 @@ import { ContextControls } from './context.js';
import { CctvControls } from './cctv.js';
import { MapillaryControls } from './mapillaryControls.js';
import { RadioControls } from './radio.js';
import { LocalSdrControls } from './localSdrControls.js';
import { LocationNavigation } from './locationNavigation.js';
import { bindClearLayersControl } from './layers.js';
import { bindCameraOrientationControls } from './cameraOrientationControls.js';
import { createMapSourceControls } from './mapSource.js';
import { STYLES } from './effects.js';
import { isHudLayout } from '../hudLayouts.js';
import {
getCyberSonarControlState,
setCyberSonarControls,
} from './cyberSonarControls.js';
import * as Cesium from 'cesium';
@@ -78,6 +84,7 @@ export class StyleManager extends ShellFacade {
transitLayer,
aisLiveVesselsLayer,
militaryAwarenessLayer,
localAdsbLayer,
} = services;
this.services = services;
this._lifetime = new UiLifetime();
@@ -190,6 +197,17 @@ export class StyleManager extends ShellFacade {
_detectionOpacityValue: this._detectionOpacityValue,
_detectionSliderRow: this._detectionSliderRow,
_hudBtn: this._hudBtn,
_cyberSonarBtn: this._cyberSonarBtn,
_cyberSonarRings: this._cyberSonarRings,
_cyberSonarRingsValue: this._cyberSonarRingsValue,
_cyberSonarRange: this._cyberSonarRange,
_cyberSonarRangeValue: this._cyberSonarRangeValue,
_cyberSonarIntensity: this._cyberSonarIntensity,
_cyberSonarIntensityValue: this._cyberSonarIntensityValue,
_cyberSonarOpacity: this._cyberSonarOpacity,
_cyberSonarOpacityValue: this._cyberSonarOpacityValue,
_cyberSonarSector: this._cyberSonarSector,
_cyberSonarSectorValue: this._cyberSonarSectorValue,
_hudLayoutRow: this._hudLayoutRow,
_hudLayoutSelect: this._hudLayoutSelect,
_ppToggles: this._ppToggles,
@@ -450,12 +468,13 @@ export class StyleManager extends ShellFacade {
trafficLayer,
flightsLayer,
militaryFlightsLayer,
localAdsbLayer,
satellitesLayer,
cctvLayer,
bikeshareLayer,
transitLayer,
aisLiveVesselsLayer,
],
].filter(Boolean),
(modeLabel) => {
this._updateDetectionButton(modeLabel);
},
@@ -484,6 +503,12 @@ export class StyleManager extends ShellFacade {
_scopeFeatherSlider: this._scopeFeatherSlider,
_hudLayoutSelect: this._hudLayoutSelect,
_hudBtn: this._hudBtn,
_cyberSonarBtn: this._cyberSonarBtn,
_cyberSonarRings: this._cyberSonarRings,
_cyberSonarRange: this._cyberSonarRange,
_cyberSonarIntensity: this._cyberSonarIntensity,
_cyberSonarOpacity: this._cyberSonarOpacity,
_cyberSonarSector: this._cyberSonarSector,
_cleanViewBtn: this._cleanViewBtn,
_cleanViewExitBtn: this._cleanViewExitBtn,
_detectionDensitySlider: this._detectionDensitySlider,
@@ -509,6 +534,8 @@ export class StyleManager extends ShellFacade {
_applySharpenIntensity: (...args) =>
this._applySharpenIntensity(...args),
_setHudVariant: (...args) => this._setHudVariant(...args),
_setCyberSonarEnabled: (...args) => this._setCyberSonarEnabled(...args),
_setCyberSonarSetting: (...args) => this._setCyberSonarSetting(...args),
_applyDetectionDensityFromUi: (...args) =>
this._applyDetectionDensityFromUi(...args),
_setDetectionAllocation: (...args) =>
@@ -862,11 +889,33 @@ export class StyleManager extends ShellFacade {
isCockpitActive: () => this.cockpitView?.active,
signalUserCollapsed: () => this.cockpitView?.signalUserCollapsed,
layoutCockpit: () => this.cockpitView?.scheduleContextLayout(),
// An open local receiver keeps the shared Radio panel expanded.
preservePanelStateDuringClear: () =>
this._preservePanelStateDuringLayerClear,
this._preservePanelStateDuringLayerClear ||
Boolean(this._localSdrControls?.isActive()),
scheduleLayout: () => this._scheduleRightPanelLayout(),
},
});
this._localSdrControls?.destroy();
this._localSdrControls = null;
const receiver = this.services.localAdsbLayer?.receiver;
if (receiver) {
this._localSdrControls = new LocalSdrControls({
document,
receiver,
feeds: this.services.localAdsbLayer?.feeds || null,
radio: radioLayer,
actions: {
isLocalAdsbEnabled: () =>
Boolean(this._dataManager?.isEnabled('local-adsb')),
setLocalAdsbEnabled: (enabled) =>
this._dataManager?.setEnabled('local-adsb', enabled, {
origin: 'user',
}),
scheduleLayout: () => this._scheduleRightPanelLayout(),
},
});
}
}
/**
@@ -1070,16 +1119,16 @@ export class StyleManager extends ShellFacade {
/**
* Switches the HUD layout variant.
* @param {'tactical'|'operator'|'minimal'} variantName - Layout variant.
* @param {'tactical'|'operator'|'minimal'|'cyber'} variantName - Layout variant.
* @returns {{ok: boolean, layout?: string, visible?: boolean, error?: string}}
*/
setHudLayout(variantName) {
const variant = String(variantName ?? '').toLowerCase();
if (!['tactical', 'operator', 'minimal'].includes(variant)) {
if (!isHudLayout(variant)) {
return { ok: false, error: `Unknown HUD layout: ${variantName}` };
}
this.shareLinkManager?.claimRestoreLane?.('visual');
this._setHudVariant(variant);
this._setHudVariant(variant, { applyVisualDefaults: true });
return {
ok: true,
layout: this.hud.getVariant(),
@@ -1195,6 +1244,11 @@ export class StyleManager extends ShellFacade {
};
}
/** Apply Cyber-only sonar settings through the shared visual controls. */
setCyberSonar(settings) {
return setCyberSonarControls(this, settings);
}
/**
* Full control-state snapshot — single source for voice read-back so the
* agent confirms from the same state it acted on.
@@ -1207,6 +1261,7 @@ export class StyleManager extends ShellFacade {
hud: {
visible: !!this.hud?.visible,
layout: this.hud?.getVariant?.() || null,
sonar: getCyberSonarControlState(),
},
detection: this.getDetectionState(),
bloom: {
@@ -1504,6 +1559,7 @@ export class StyleManager extends ShellFacade {
this._cctvControls?.destroy();
this._mapillaryControls?.destroy();
this._radioControls?.destroy();
this._localSdrControls?.destroy();
this._cockpitCoordinator.stop();
this._visualSettings.stop();
this.shareLinkManager?.destroy();
+1
View File
@@ -293,6 +293,7 @@ export function bindCameraOrientationControls({
);
}
if (!applied || applied.heading !== next.heading) {
northButton?.setAttribute('data-north-up', String(next.heading === 0));
northButton?.style?.setProperty('--camera-heading', `${next.heading}deg`);
northButton?.setAttribute(
'aria-label',
+27
View File
@@ -337,6 +337,33 @@ test('the needle follows a rotation too small for the camera-changed threshold',
controls.destroy();
});
test('north-up indicator follows real heading including the 360-degree seam', () => {
const { viewer } = createRealViewer(AUSTIN_GROUND);
const northButton = new FakeButton();
const controls = bindCameraOrientationControls({
viewer,
elements: { northButton },
runNavigation: (_noun, navigate) => navigate(),
});
for (const [heading, active] of [
[0, true],
[15, false],
[359.8, true],
[359, false],
[360, true],
[90, false],
]) {
orbit(viewer, AUSTIN_GROUND, heading, Cesium.Math.toRadians(-80), 5_000);
viewer.scene.preRender.raiseEvent();
assert.equal(
northButton.getAttribute('data-north-up'),
String(active),
`heading ${heading}`,
);
}
controls.destroy();
});
test('a settled camera writes nothing, and teardown releases every signal', () => {
const { viewer } = createRealViewer(AUSTIN_GROUND);
orbit(viewer, AUSTIN_GROUND, 0, Cesium.Math.toRadians(-80), 5_000);
+12 -5
View File
@@ -1,3 +1,5 @@
import { displayPanelScroller } from './displayPanelScroll.js';
/** Move the existing Display groups between their two presentation surfaces. */
export class CockpitDisplayPortal {
constructor({ standardPanel, cockpitPanel, groups, layout }) {
@@ -20,15 +22,18 @@ export class CockpitDisplayPortal {
group.before(anchor);
return [{ name, group, slot, anchor }];
});
this.standardScrollTop = standardPanel?.scrollTop || 0;
this.standardScrollTop =
displayPanelScroller(standardPanel)?.scrollTop || 0;
this.cockpitScrollTop = cockpitPanel?.scrollTop || 0;
const options = { passive: true, signal: this.listeners.signal };
standardPanel?.addEventListener(
'scroll',
() => {
if (!this.active) this.standardScrollTop = standardPanel.scrollTop;
if (!this.active)
this.standardScrollTop =
displayPanelScroller(standardPanel).scrollTop;
},
options,
{ ...options, capture: true },
);
cockpitPanel?.addEventListener(
'scroll',
@@ -85,7 +90,8 @@ export class CockpitDisplayPortal {
if (nextActive && this.cockpitPanel)
this.cockpitPanel.scrollTop = this.cockpitScrollTop;
if (!nextActive && this.standardPanel)
this.standardPanel.scrollTop = this.standardScrollTop;
displayPanelScroller(this.standardPanel).scrollTop =
this.standardScrollTop;
};
if (!settle) {
restoreScroll();
@@ -121,7 +127,8 @@ export class CockpitDisplayPortal {
}
this.cockpitPanel?.classList.remove('uses-shared-display-controls');
if (this.standardPanel)
this.standardPanel.scrollTop = this.standardScrollTop;
displayPanelScroller(this.standardPanel).scrollTop =
this.standardScrollTop;
this.records = [];
this.restoreOwner = null;
}
+135 -6
View File
@@ -2,6 +2,7 @@
import {
resolveCockpitUtilityAnchor,
resolveCockpitUtilityLayout,
resolveCyberCockpitPanelLane,
} from '../cockpitUtilityLayout.js';
import {
COCKPIT_UTILITY_REC_GAP_PX,
@@ -90,7 +91,53 @@ export function syncContextLayout() {
'--cockpit-context-left',
`${desktopInset.toFixed(1)}px`,
);
this.context.style.removeProperty('--cockpit-context-top');
if (document.documentElement.dataset.uiTheme !== 'cyber') {
this.context.style.removeProperty('--cockpit-context-top');
this.context.style.removeProperty('--cockpit-context-max-height');
return;
}
const defaultTop = Math.max(255, Math.min(300, window.innerHeight * 0.28));
const dataPanel = document.querySelector(
'#left-panel-stack > #data-panel:not(.collapsed)',
);
let desiredTop = defaultTop;
if (this.contextCollapsed && dataPanel) {
desiredTop = Math.max(
desiredTop,
dataPanel.getBoundingClientRect().bottom + 8,
);
}
const contextBounds = this.context.getBoundingClientRect();
let safeBottom = window.innerHeight - 24;
for (const selector of [
'#cesium-credits',
'#command-dock',
'#view-switcher',
]) {
const obstacle = document.querySelector(selector);
if (!obstacle) continue;
const style = window.getComputedStyle(obstacle);
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
Number(style.opacity) === 0
)
continue;
const bounds = obstacle.getBoundingClientRect();
const overlapsHorizontally =
bounds.right > contextBounds.left && bounds.left < contextBounds.right;
if (overlapsHorizontally && bounds.top > desiredTop) {
safeBottom = Math.min(safeBottom, bounds.top - 8);
}
}
const boundedTop = Math.max(
defaultTop,
Math.min(desiredTop, safeBottom - contextBounds.height),
);
this.context.style.setProperty(
'--cockpit-context-top',
`${boundedTop.toFixed(1)}px`,
);
this.context.style.removeProperty('--cockpit-context-max-height');
}
@@ -127,6 +174,34 @@ export function syncSignalLayout() {
);
this.signalStream.style.removeProperty('--cockpit-signal-top');
this.signalStream.style.removeProperty('--cockpit-signal-max-height');
const cyber = document.documentElement.dataset.uiTheme === 'cyber';
const leftPanel = cyber
? document.querySelector('#left-panel-stack > #data-panel')
: null;
const alignedTop = isRenderedOnScreen(leftPanel)
? leftPanel.getBoundingClientRect().top
: null;
// Cyber's two rails share a top edge. Move the briefing below the collapsed
// strip rather than pulling the right rail upward to avoid it.
if (alignedTop !== null && utilityControls) {
const expanded = utilityControls.querySelector(
'.cockpit-utility-control.is-expanded',
);
if (!expanded) {
const signalTop = Math.max(
Math.max(255, Math.min(300, window.innerHeight * 0.28)),
alignedTop + utilityControls.getBoundingClientRect().height + 28,
);
this.signalStream.style.setProperty(
'--cockpit-signal-top',
`${signalTop.toFixed(1)}px`,
);
this.signalStream.style.setProperty(
'--cockpit-signal-max-height',
`${Math.max(44, window.innerHeight - signalTop - 80).toFixed(1)}px`,
);
}
}
const signalBounds = this.signalStream.getBoundingClientRect();
const utilityBounds = utilityControls?.getBoundingClientRect();
if (utilityBounds) {
@@ -151,9 +226,51 @@ export function syncSignalLayout() {
collapsedLauncher.scrollHeight,
)
: 0;
// Cockpit owns this anchor outright. The strip used to inherit the left
// accordion's committed top, which is solved against left-lane obstacles
// and dropped the strip straight through the briefing card below it.
if (alignedTop !== null) {
const lane = resolveCyberCockpitPanelLane({
viewportHeight: window.innerHeight,
top: alignedTop,
launcherHeight: Math.max(
collapsedHeight,
collapsedLauncher?.getBoundingClientRect().height || 60,
),
signalHeight: this.signalCollapsed ? signalBounds.height : 44,
contactHeight:
this.contextCollapsed && this.context
? this.context.getBoundingClientRect().height
: 64,
});
document.body.style.setProperty(
'--cyber-cockpit-panel-height',
`${lane.panelHeight.toFixed(2)}px`,
);
this.hud?.style.setProperty(
'--cockpit-utility-top',
`${alignedTop.toFixed(2)}px`,
);
this.hud?.style.setProperty(
'--cockpit-utility-max-height',
`${lane.utilityHeight.toFixed(2)}px`,
);
this.hud?.style.setProperty(
'--cockpit-utility-expanded-max-height',
`${lane.panelHeight.toFixed(2)}px`,
);
utilityControls.classList.remove('layout-primary-only');
utilityControls
.querySelectorAll('.cockpit-utility-control')
.forEach((control) => control.setAttribute('aria-hidden', 'false'));
if (expandedControl) {
this.signalStream.style.setProperty(
'--cockpit-signal-top',
`${lane.signalTop.toFixed(2)}px`,
);
}
this.syncContextLayout();
return;
}
// Non-Cyber themes retain their independent REC/briefing corridor. Cyber
// uses the rendered Data Layers top after reserving briefing space above.
// The readout only anchors the strip while it is genuinely on screen:
// the Minimal variant drops it with `display:none`, but HUD Off hides the
// whole Intel HUD with `visibility`/`opacity`, which keeps its rect.
@@ -169,8 +286,20 @@ export function syncSignalLayout() {
collapsedHeight,
recGap: COCKPIT_UTILITY_REC_GAP_PX,
signalGap: COCKPIT_UTILITY_SIGNAL_GAP_PX,
minTopFloor: COCKPIT_UTILITY_MIN_TOP_PX,
minTopRatio: COCKPIT_UTILITY_MIN_TOP_RATIO,
minTopFloor: alignedTop ?? COCKPIT_UTILITY_MIN_TOP_PX,
minTopRatio: alignedTop !== null ? 0 : COCKPIT_UTILITY_MIN_TOP_RATIO,
signalCollapsed: this.signalCollapsed,
utilityExpanded: Boolean(expandedControl),
// Only Cyber relocates its collapsed briefing below the utility lane.
// Other themes keep the measured visible card as the lower boundary.
reserveViewportLane: document.documentElement.dataset.uiTheme === 'cyber',
// Cyber keeps the collapsed briefing reachable below the utility instead
// of hiding it. Reserve its actual height, including wrapped headings.
signalHeight:
document.documentElement.dataset.uiTheme === 'cyber' &&
this.signalCollapsed
? signalBounds.height
: 0,
});
const availableHeight = utilityAnchor.maxHeight;
this.hud?.style.setProperty(
+87
View File
@@ -0,0 +1,87 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { PanelChrome } from './panelChrome.js';
function fixture(theme = 'cyber') {
const nodes = new Map();
const node = (id, collapsed = true) => {
const classes = new Set(collapsed ? ['collapsed'] : []);
const value = { id, hidden: false, children: [],
matches: () => true,
contains(target) { return this === target || this.children.some((child) => child.contains(target)); },
classList: {
contains: (name) => classes.has(name),
add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach((name) => classes.delete(name)),
toggle: (name, on) => on ? classes.add(name) : classes.delete(name),
},
};
nodes.set(id, value);
return value;
};
const rail = node('rail');
const display = node('pp-toggles'), context = node('global-context-panel'), cctv = node('cctv-panel'), radio = node('radio-panel');
rail.children = [display, context, cctv];
for (const panel of rail.children) panel.parentElement = rail;
context.children = [radio];
radio.parentElement = context;
const saved = [], claimed = [];
const owner = {
_rightPanelStack: rail,
_panelLayout: {},
_lifetime: { frame() {} },
_syncPanelCollapseButton() {}, _scheduleRightPanelLayout() {},
_scheduleLeftPanelLayout() {}, _layoutRightPanels() {}, _syncCctvPanelViewport() {},
_savePanelCollapsedState: (...args) => saved.push(args),
shareLinkManager: { claimRestoreLane: (...args) => claimed.push(args), onPanelStateChange() {} },
};
owner.setPanelCollapsed = PanelChrome.prototype.setPanelCollapsed.bind(owner);
return { display, context, cctv, radio, owner, saved, claimed,
doc: { documentElement: { dataset: { uiTheme: theme } }, getElementById: (id) => nodes.get(id) },
};
}
test('explicit Cyber opening collapses peers and claims their restoration lanes', () => {
const f = fixture(), prior = globalThis.document;
globalThis.document = f.doc;
try {
f.owner.setPanelCollapsed('global-context-panel', false, { explicit: true });
f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true });
assert.equal(f.display.classList.contains('collapsed'), false);
assert.equal(f.context.classList.contains('collapsed'), true);
assert.ok(f.saved.some(([id, collapsed]) => id === 'global-context-panel' && collapsed));
assert.ok(f.claimed.some(([, id]) => id === 'global-context-panel'));
f.cctv.classList.add('cyber-accordion-collapsed');
f.owner.setPanelCollapsed('cctv-panel', true, { explicit: true });
assert.equal(f.cctv.classList.contains('cyber-accordion-collapsed'), false);
} finally { globalThis.document = prior; }
});
test('opening already-expanded Radio reveals Context and closes Display', () => {
const f = fixture(), prior = globalThis.document;
globalThis.document = f.doc;
try {
f.owner.setPanelCollapsed('radio-panel', false, { explicit: true });
f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true });
assert.equal(f.radio.classList.contains('collapsed'), false);
assert.equal(f.context.classList.contains('collapsed'), true);
f.owner.setPanelCollapsed('radio-panel', false, { explicit: true });
assert.equal(f.context.classList.contains('collapsed'), false);
assert.equal(f.display.classList.contains('collapsed'), true);
} finally { globalThis.document = prior; }
});
test('non-Cyber explicit panels and restored preferences retain their existing policy', () => {
for (const theme of ['tactical', 'operator', 'minimal', 'cyber']) {
const f = fixture(theme), prior = globalThis.document;
globalThis.document = f.doc;
try {
const options = theme === 'cyber' ? { restore: true, persist: false } : { explicit: true };
f.owner.setPanelCollapsed('pp-toggles', false, options);
f.owner.setPanelCollapsed('cctv-panel', false, options);
assert.equal(f.display.classList.contains('collapsed'), false);
assert.equal(f.cctv.classList.contains('collapsed'), false);
if (theme === 'cyber') assert.deepEqual(f.saved, []);
} finally { globalThis.document = prior; }
}
});
+86
View File
@@ -0,0 +1,86 @@
import {
isCyberSonarEnabled,
isCyberSonarActive,
isCyberContactSonarActive,
readCyberSonarSettings,
} from '../cyberSonar.js';
const SETTING_FIELDS = Object.freeze({
rings: ['rings', 3, 12],
rangePct: ['range', 60, 120],
intensityPct: ['intensity', 0, 100],
opacityPct: ['opacity', 35, 100],
sectorDeg: ['sector', 8, 60],
});
/** Read configuration separately from whether map effects can currently run. */
export function getCyberSonarControlState(doc = globalThis.document) {
const root = doc?.documentElement;
const settings = readCyberSonarSettings(root);
const options = {
root,
body: doc?.body,
hud: doc?.getElementById?.('intel-hud') ?? null,
};
return {
enabled: isCyberSonarEnabled(root),
rings: settings.rings,
rangePct: settings.range,
intensityPct: settings.intensity,
opacityPct: settings.opacity,
sectorDeg: settings.sector,
cyberSelected: root?.dataset?.uiTheme === 'cyber',
mapSweepActive: isCyberSonarActive(options),
contactSweepActive:
root?.dataset?.cyberSonarGpu === 'supported' &&
isCyberContactSonarActive(options),
contactRenderer: root?.dataset?.cyberSonarGpu ?? 'not-initialized',
};
}
/** Validate every field before mutating through the same owner as the sliders. */
export function setCyberSonarControls(
owner,
request,
doc = globalThis.document,
) {
const fail = (error) => ({
ok: false,
error,
sonar: getCyberSonarControlState(doc),
});
if (!request || typeof request !== 'object' || Array.isArray(request)) {
return fail('Provide Cyber sonar settings as an object.');
}
const fields = Object.keys(request);
if (!fields.length) return fail('Provide at least one Cyber sonar setting.');
for (const field of fields) {
const value = request[field];
if (field === 'enabled') {
if (typeof value !== 'boolean') return fail('enabled must be a boolean.');
continue;
}
if (!Object.hasOwn(SETTING_FIELDS, field)) {
return fail('Unknown Cyber sonar setting.');
}
const [, min, max] = SETTING_FIELDS[field];
if (!Number.isInteger(value) || value < min || value > max) {
return fail(`${field} must be an integer from ${min} to ${max}.`);
}
}
if (
owner.hud?.getVariant?.() !== 'cyber' ||
doc?.documentElement?.dataset?.uiTheme !== 'cyber'
) {
return fail('Cyber sonar controls require the Cyber HUD layout.');
}
for (const [field, [setting]] of Object.entries(SETTING_FIELDS)) {
if (Object.hasOwn(request, field)) {
owner._setCyberSonarSetting(setting, request[field]);
}
}
if (Object.hasOwn(request, 'enabled')) {
owner._setCyberSonarEnabled(request.enabled);
}
return { ok: true, sonar: getCyberSonarControlState(doc) };
}
+161
View File
@@ -0,0 +1,161 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getCyberSonarControlState,
setCyberSonarControls,
} from './cyberSonarControls.js';
import { GEV_ACTION_SCHEMAS } from '../voice/actionSchemas.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import { createGevActionRunner } from '../voice/gevActions.js';
import { StyleManager } from './applicationShell.js';
import { VisualSettings } from './visualSettings.js';
import { readFileSync } from 'node:fs';
function fixture(theme = 'cyber') {
const root = {
dataset: { uiTheme: theme, cyberSonarGpu: 'supported' },
style: { setProperty() {} },
};
const flags = new Set();
const hudFlags = new Set(['active']);
const doc = {
documentElement: root,
body: { classList: { contains: (name) => flags.has(name) } },
getElementById: () => ({
classList: { contains: (name) => hudFlags.has(name) },
}),
};
const calls = [];
const owner = {
hud: { getVariant: () => theme },
_setCyberSonarSetting(name, value) {
calls.push([name, value]);
const suffix = name[0].toUpperCase() + name.slice(1);
root.dataset['cyberSonar' + suffix] = String(value);
},
_setCyberSonarEnabled(value) {
calls.push(['enabled', value]);
root.dataset.cyberSonar = value ? 'on' : 'off';
},
};
return { doc, owner, calls, flags, hudFlags, root };
}
test('real runner and facade use visual settings, preserve visibility and return actual values', async () => {
const f = fixture();
const previousDocument = globalThis.document;
globalThis.document = f.doc;
const renders = [];
const visual = Object.create(VisualSettings.prototype);
visual.services = { governorRequestRender: (reason) => renders.push(reason) };
const owner = Object.create(StyleManager.prototype);
owner._visualSettings = visual;
Object.defineProperty(owner, 'hud', { value: f.owner.hud });
const viewer = {
camera: { moveEnd: { addEventListener() {} } },
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: new EventTarget() },
};
try {
const runner = createGevActionRunner({ styleManager: owner, viewer, dataManager: {} });
const result = await runner('set_cyber_sonar', { enabled: false, intensityPct: 73, sectorDeg: 9 });
assert.equal(result.ok, true);
assert.equal(result.sonar.intensityPct, 73);
assert.equal(result.sonar.sectorDeg, 9);
assert.equal(result.sonar.enabled, false);
assert.equal(f.root.dataset.uiTheme, 'cyber');
assert.equal(f.hudFlags.has('active'), true);
assert.equal(renders.length, 3);
const before = getCyberSonarControlState();
const invalid = await runner('set_cyber_sonar', { rings: 6, opacityPct: 0 });
assert.equal(invalid.ok, false);
assert.deepEqual(getCyberSonarControlState(), before);
assert.equal(renders.length, 3);
const zero = await runner('set_cyber_sonar', { intensityPct: 0 });
assert.equal(zero.sonar.intensityPct, 0);
assert.equal(zero.sonar.enabled, false);
const unavailable = createGevActionRunner({ styleManager: {}, viewer, dataManager: {} });
assert.equal((await unavailable('set_cyber_sonar', { enabled: true })).ok, false);
} finally {
globalThis.document = previousDocument;
}
});
test('native sonar sliders represent every accepted integer without browser snapping', () => {
const html = readFileSync(new URL('./templates/display-controls.html', import.meta.url), 'utf8');
for (const name of ['rings', 'range', 'intensity', 'opacity', 'sector']) {
assert.match(html, new RegExp(`id="cyber-sonar-${name}"[^>]*step="1"`));
}
});
test('sonar action is available to both shared and server voice schemas', () => {
for (const tools of [GEV_ACTION_SCHEMAS, GEV_REALTIME_TOOLS]) {
const action = tools.find((tool) => tool.name === 'set_cyber_sonar');
assert.ok(action);
assert.equal(action.parameters.additionalProperties, false);
assert.deepEqual(Object.keys(action.parameters.properties), [
'enabled', 'rings', 'rangePct', 'intensityPct', 'opacityPct', 'sectorDeg',
]);
assert.equal(action.parameters.properties.intensityPct.minimum, 0);
assert.equal(action.parameters.properties.opacityPct.minimum, 35);
}
});
test('maps all settings to the slider owner and reads actual state, including zero', () => {
const f = fixture();
const result = setCyberSonarControls(f.owner, {
enabled: false, rings: 6, rangePct: 80,
intensityPct: 0, opacityPct: 90, sectorDeg: 30,
}, f.doc);
assert.equal(result.ok, true);
assert.deepEqual(f.calls, [
['rings', 6], ['range', 80], ['intensity', 0],
['opacity', 90], ['sector', 30], ['enabled', false],
]);
assert.equal(result.sonar.enabled, false);
assert.equal(result.sonar.intensityPct, 0);
assert.equal(result.sonar.contactSweepActive, false);
assert.deepEqual(result.sonar, getCyberSonarControlState(f.doc));
});
test('rejects the whole invalid request before any mutation', () => {
for (const request of [
{}, null, [], { enabled: 'false' }, { rings: 2 }, { rangePct: 121 },
{ opacityPct: NaN }, { intensityPct: Infinity }, { sectorDeg: 8.5 },
{ intensityPct: '50' }, { enabled: false, unknown: 1 },
{ rings: 6, sectorDeg: 61 },
]) {
const f = fixture();
assert.equal(setCyberSonarControls(f.owner, request, f.doc).ok, false);
assert.deepEqual(f.calls, []);
}
});
test('requires Cyber and preserves off state and every omitted setting', () => {
for (const theme of ['tactical', 'operator', 'minimal']) {
const f = fixture(theme);
assert.equal(setCyberSonarControls(f.owner, { enabled: true }, f.doc).ok, false);
assert.deepEqual(f.calls, []);
}
const f = fixture();
f.root.dataset.cyberSonar = 'off';
const before = getCyberSonarControlState(f.doc);
const result = setCyberSonarControls(f.owner, { rings: 12 }, f.doc);
assert.deepEqual(result.sonar, { ...before, rings: 12 });
assert.deepEqual(f.calls, [['rings', 12]]);
});
test('reports inactive map effects in Cockpit, hidden HUD, unsupported and uninitialized GPU', () => {
const f = fixture();
f.flags.add('cockpit-mode');
assert.equal(getCyberSonarControlState(f.doc).mapSweepActive, false);
f.flags.clear();
f.hudFlags.clear();
assert.equal(getCyberSonarControlState(f.doc).contactSweepActive, false);
f.hudFlags.add('active');
for (const value of ['unsupported', undefined]) {
if (value === undefined) delete f.root.dataset.cyberSonarGpu;
else f.root.dataset.cyberSonarGpu = value;
assert.equal(getCyberSonarControlState(f.doc).contactSweepActive, false);
}
});
+573
View File
@@ -0,0 +1,573 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
applyHudUiTheme,
CYBER_VISUAL_DEFAULTS,
cyberVisualDefaultsForHudTransition,
HUD_LAYOUTS,
normalizeHudLayout,
} from '../hudLayouts.js';
import { GEV_ACTION_SCHEMAS } from '../voice/actionSchemas.js';
import { VisualSettings } from './visualSettings.js';
import { STYLES } from './visualPresets.js';
test('CCTV and Context keep their headers outside Cyber-only scroll bodies', () => {
for (const file of ['layer-panels.html', 'context.html']) {
const markup = readFileSync(
new URL(`./templates/${file}`, import.meta.url),
'utf8',
);
assert.match(markup, /class="cyber-panel-body" data-rail-scroller/);
assert.ok(
markup.indexOf('class="panel-header"') <
markup.indexOf('class="cyber-panel-body"'),
);
}
const shared = readFileSync(
new URL('./styles/controls.css', import.meta.url),
'utf8',
);
const cyber = readFileSync(
new URL('./styles/cyber.css', import.meta.url),
'utf8',
);
assert.match(shared, /\.cyber-panel-body\s*\{\s*display: contents;/);
assert.match(
cyber,
/:root\[data-ui-theme='cyber'\] \.cyber-panel-body\s*\{[^}]*overflow-y: auto;[^}]*overscroll-behavior: contain;/,
);
});
function styleOwner(t, initialVariant = 'cyber') {
const previous = { document: globalThis.document, window: globalThis.window };
const root = { dataset: {} };
globalThis.document = { documentElement: root, querySelectorAll: () => [] };
globalThis.window = { dispatchEvent() {} };
t.after(() => Object.assign(globalThis, previous));
let variant = initialVariant;
const calls = [];
const hud = {
getVariant: () => variant,
setVariant: (value) => {
variant = value;
applyHudUiTheme(root, value);
},
setMode: (value) => calls.push(['mode', value]),
onStyleChange: (value) => calls.push(['style', value]),
};
hud.setVariant(initialVariant);
const owner = Object.create(VisualSettings.prototype);
Object.assign(owner, {
activeStyle: 'normal',
readHud: () => hud,
readShareLinks: () => ({ onStyleChange() {} }),
_visualEffects: {
stages: Object.fromEntries(
Object.entries(STYLES).map(([name, config]) => [
name,
{ uniforms: { ...config.uniforms, intensity: 0 } },
]),
),
},
services: { governorRequestRender() {}, setDetectionStyle() {} },
_hudLayoutSelect: { value: initialVariant },
_styleIndicator: {},
_setBloomEnabled: (value) => calls.push(['bloom', value]),
_setSharpenEnabled: (value) => calls.push(['sharpen', value]),
_applyDetectionPreset: (value) => calls.push(['detection', value]),
_syncShareState: () => calls.push(['share', variant]),
});
for (const name of [
'setCelestialRingEnabled',
'_startTransition',
'_updateStyleMiniStatus',
'_updateSliderPanel',
'_updateHudButtonState',
'_syncIrBoost',
'_syncCockpitInheritedStyle',
'_syncCyberSonarControl',
'_scheduleAdaptivePanelLayout',
'_applyDetectionFadeFromUi',
])
owner[name] = () => {};
return { owner, hud, root, calls };
}
test('visual styles retain Cyber through real preset application without dropping other defaults', (t) => {
const { owner, hud, root, calls } = styleOwner(t);
for (const style of [
'surveillance',
'normal',
'noir',
'anime',
'snow',
'thermal',
'retro',
]) {
owner.setStyle(style);
assert.equal(owner.activeStyle, style);
assert.equal(hud.getVariant(), 'cyber');
assert.equal(root.dataset.uiTheme, 'cyber');
assert.equal(owner._hudLayoutSelect.value, 'cyber');
assert.deepEqual(calls.at(-1), ['share', 'cyber']);
}
assert.equal(owner.stages.thermal.uniforms.sensitivity, 0.85);
assert.equal(calls.filter(([name]) => name === 'detection').length, 3);
assert.equal(
calls.filter(([name, value]) => name === 'bloom' && value === false).length,
3,
);
assert.equal(
calls.filter(([name, value]) => name === 'sharpen' && value === true)
.length,
3,
);
});
test('non-Cyber style presets and explicit HUD choices remain authoritative', (t) => {
const { owner, hud, root } = styleOwner(t);
for (const variant of ['tactical', 'operator', 'minimal']) {
owner._setHudVariant(variant);
assert.equal(hud.getVariant(), variant);
assert.equal(root.dataset.uiTheme, undefined);
owner.setStyle('normal');
owner.setStyle('surveillance');
assert.equal(hud.getVariant(), 'tactical');
}
});
test('scene and share restoration override Cyber and preserve restored Cyber parameters', async (t) => {
const { owner, hud } = styleOwner(t);
await owner.applyVisualState({ style: 'retro', hud: { variant: 'minimal' } });
assert.equal(hud.getVariant(), 'minimal');
owner._setHudVariant('cyber');
await owner.restoreShareState({
style: 'surveillance',
hudVariant: 'operator',
});
assert.equal(hud.getVariant(), 'operator');
await owner.restoreShareState({
style: 'thermal',
hudVariant: 'cyber',
styleParams: { palette: 0.73 },
});
assert.equal(hud.getVariant(), 'cyber');
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner.stages.thermal.uniforms.palette, 0.73);
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner.stages.thermal.uniforms.palette, 0.73);
owner._setHudVariant('tactical');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner.stages.thermal.uniforms.palette, 0.42);
});
const read = (relative) =>
readFileSync(new URL(relative, import.meta.url), 'utf8');
test('Cyber setup uses a compact toolbar icon and highlights the north-up letter', () => {
const css = read('./styles/cyber.css');
assert.match(
css,
/#top-center-actions #key-setup-chip\s*\{[^}]*inset: auto;[^}]*width: 36px;/,
);
assert.match(css, /#key-setup-chip\[hidden\]\s*\{\s*display: none;/);
assert.match(
css,
/#north-up-view\[data-north-up='true'\]\s+\.camera-compass-n\s*\{[^}]*color: var\(--cyber-red-bright\)/,
);
});
const cyberStyles = read('./styles/cyber.css');
test('Cyber clipped expand controls retain an inset keyboard-focus outline in map and cockpit', () => {
assert.match(
cyberStyles,
/:root\[data-ui-theme='cyber'\]\s+:is\(\s*\.panel-collapse-btn,\s*body\.cockpit-mode \.cockpit-utility-glyph\s*\):focus-visible\s*\{[^}]*outline: 2px solid var\(--cyber-red-bright\) !important;[^}]*outline-offset: -3px;[^}]*box-shadow: none;/,
);
assert.match(
cyberStyles,
/:root\[data-ui-theme='cyber'\]\s+:is\(\.panel-collapse-btn, body\.cockpit-mode \.cockpit-utility-glyph\):is\(\s*:hover,\s*:focus-visible\s*\)\s*\{[^}]*border-color: var\(--cyber-red-bright\);/,
);
});
test('Cyber Radio retains upstream nested Context ownership', () => {
assert.doesNotMatch(cyberStyles, /#right-context-rail\s*>\s*#radio-panel/);
});
test('Cyber cockpit launchers share Data Layers frame and expand-control treatment', () => {
assert.match(
cyberStyles,
/:is\(\.panel-collapse-btn, body\.cockpit-mode \.cockpit-utility-glyph\)/,
);
assert.match(
cyberStyles,
/body\.cockpit-mode \.cockpit-utility-launcher \{[^}]*width: var\(--cyber-panel-width\);[^}]*min-height: 60px;[^}]*clip-path: var\(--cyber-panel-frame\);/,
);
assert.match(
cyberStyles,
/\.cockpit-utility-glyph::after \{\s*content: '\+';/,
);
assert.match(
cyberStyles,
/\.cockpit-utility-glyph\[aria-expanded='true'\]::after \{\s*content: '−';/,
);
assert.match(
cyberStyles,
/\.cockpit-utility-control\.is-expanded\s+\.cockpit-utility-launcher \{[^}]*width: 100%;[^}]*clip-path: none;/,
);
});
test('Cyber side panels share one width and one framed surface material', () => {
assert.match(cyberStyles, /--cyber-panel-width: 272px;/);
assert.match(cyberStyles, /--cyber-right-panel-width: 312px;/);
assert.match(cyberStyles, /--cyber-panel-collapsed-width: 200px;/);
assert.match(cyberStyles, /--cyber-panel-control-size: 30px;/);
assert.match(
cyberStyles,
/--cyber-panel-surface: rgba\(18, 24, 27, 0\.92\);/,
);
assert.match(
cyberStyles,
/\.cockpit-utility-control\.is-expanded \{[\s\S]*?width: var\(--cyber-right-panel-width\);[\s\S]*?background-color: var\(--cyber-panel-surface\);[\s\S]*?background-image: var\(--cyber-panel-surface-detail\);[\s\S]*?clip-path: var\(--cyber-panel-frame\);/,
);
assert.match(
cyberStyles,
/\.cockpit-utility-control\.is-expanded::after \{[\s\S]*?width: 36px;[\s\S]*?border-top: 1px solid rgba\(225, 102, 102, 0\.84\);/,
);
assert.match(
cyberStyles,
/#global-context-panel #radio-panel\.collapsed \{\s*width: 100%;/,
);
assert.match(
cyberStyles,
/#left-panel-stack\s*> :is\(#data-panel, #scene-panel\)\.collapsed \{\s*width: var\(--cyber-panel-collapsed-width\);/,
);
assert.match(
cyberStyles,
/:is\(#data-panel, #scene-panel\)\.collapsed\s+:is\(\.data-panel-inner, \.scene-panel-inner\) \{[\s\S]*?height: 50px;[\s\S]*?min-height: 50px;/,
);
assert.match(
cyberStyles,
/\.cockpit-utility-control:not\(\.is-expanded\)\s+\.cockpit-utility-launcher \{\s*width: var\(--cyber-panel-collapsed-width\);/,
);
assert.match(
cyberStyles,
/#pp-toggles\s*> \.pp-header-row\s+\.pp-collapse-btn \{[\s\S]*?width: var\(--cyber-panel-control-size\);[\s\S]*?height: var\(--cyber-panel-control-size\);/,
);
assert.doesNotMatch(
cyberStyles,
/\.cockpit-utility-control\.is-expanded\s*\{[^}]*order:/,
'expanded Cockpit utilities must retain Display-then-Radio DOM order',
);
});
test('Cyber is a canonical HUD layout with a safe Tactical fallback', () => {
assert.deepEqual(HUD_LAYOUTS, ['tactical', 'operator', 'minimal', 'cyber']);
assert.equal(normalizeHudLayout('CYBER'), 'cyber');
assert.equal(normalizeHudLayout('unknown'), 'tactical');
});
test('the Cyber HUD owns the global shell skin and leaving it restores defaults', () => {
const root = { dataset: { unrelated: 'preserved' } };
assert.equal(applyHudUiTheme(root, 'cyber'), 'cyber');
assert.equal(root.dataset.uiTheme, 'cyber');
assert.equal(applyHudUiTheme(root, 'minimal'), null);
assert.equal(root.dataset.uiTheme, undefined);
assert.equal(root.dataset.unrelated, 'preserved');
});
test('an explicit transition into Cyber selects FLIR with Ironbow 0.42 once', () => {
assert.deepEqual(CYBER_VISUAL_DEFAULTS, {
style: 'thermal',
ironbow: 0.42,
});
assert.equal(
cyberVisualDefaultsForHudTransition('tactical', 'cyber', {
explicit: true,
}),
CYBER_VISUAL_DEFAULTS,
);
assert.equal(
cyberVisualDefaultsForHudTransition('cyber', 'cyber', {
explicit: true,
}),
null,
);
assert.equal(cyberVisualDefaultsForHudTransition('tactical', 'cyber'), null);
assert.equal(
cyberVisualDefaultsForHudTransition('cyber', 'operator', {
explicit: true,
}),
null,
);
});
test('Cyber visual defaults update the style, thermal palette and render state', () => {
const calls = [];
const owner = {
stages: { thermal: { uniforms: { palette: 0 } } },
services: {
governorRequestRender: (reason) => calls.push(['render', reason]),
},
setStyle: (style, options) => calls.push(['style', style, options]),
_updateSliderPanel: (style, options) =>
calls.push(['sliders', style, options]),
};
VisualSettings.prototype._applyCyberVisualDefaults.call(
owner,
CYBER_VISUAL_DEFAULTS,
);
assert.equal(owner.stages.thermal.uniforms.palette, 0.42);
assert.deepEqual(calls, [
['style', 'thermal', { applyPreset: false, revealParameters: false }],
['sliders', 'thermal', { reveal: false }],
['render', 'cyber-visual-defaults'],
]);
});
test('the Display selector, voice schema and final stylesheet expose Cyber', () => {
const display = read('./templates/display-controls.html');
const stylesheet = read('../../style.css');
const hud = read('../hud.js');
const detection = read('../data/detection.js');
const civilRendering = read('../layers/flights/rendering.js');
const militaryRendering = read('../layers/military/rendering.js');
const setHud = GEV_ACTION_SCHEMAS.find((schema) => schema.name === 'set_hud');
assert.match(display, /<option value="cyber">Cyber<\/option>/);
assert.match(display, /id="cyber-sonar-toggle"/);
assert.match(
display,
/id="cyber-sonar-opacity"[^>]*value="84"[^>]*aria-label="Cyber contact and label opacity"/,
);
assert.match(display, /id="cyber-sonar-sector"/);
assert.deepEqual(setHud.parameters.properties.layout.enum, HUD_LAYOUTS);
assert.match(stylesheet, /@import '\.\/src\/ui\/styles\/cyber\.css';\s*$/);
assert.match(cyberStyles, /:root\[data-ui-theme='cyber'\]/);
assert.match(cyberStyles, /\.material-symbols-outlined/);
assert.match(cyberStyles, /\.pp-label/);
assert.match(cyberStyles, /#intel-hud\[data-variant='cyber'\]/);
assert.match(cyberStyles, /#left-panel-stack/);
assert.match(cyberStyles, /#right-context-rail/);
assert.match(cyberStyles, /#command-dock/);
assert.match(cyberStyles, /--cyber-panel-frame: polygon/);
assert.match(cyberStyles, /\.data-panel-inner,/);
assert.match(cyberStyles, /#pp-toggles\.collapsed > \.pp-header-row/);
assert.match(
cyberStyles,
/\.panel-collapsible:not\(#location-bar\):not\(#control-panel\)::after/,
);
assert.match(cyberStyles, /clip-path: var\(--cyber-panel-frame\);/);
assert.match(hud, /class="hud-sonar" aria-hidden="true"/);
assert.match(cyberStyles, /\.hud-sonar::before/);
assert.match(cyberStyles, /\.hud-sonar::after/);
assert.match(cyberStyles, /@keyframes cyber-sonar-sweep/);
assert.match(
cyberStyles,
/animation-delay:\s*var\(--cyber-sonar-phase-delay, 0ms\)/,
);
assert.doesNotMatch(cyberStyles, /--cyber-sonar-angle/);
assert.match(cyberStyles, /prefers-reduced-motion: reduce/);
assert.match(detection, /candidate\.sonarFactor \?\? 1/);
assert.match(detection, /sonarActive && !obj\.skipLabel/);
assert.match(detection, /const bracketAlpha = admissionAlpha \* sonarFactor/);
assert.match(detection, /sonarSampler\.label\(sonarFactor\)/);
assert.match(detection, /createCyberSonarSampler\(width, height, now\)/);
assert.match(cyberStyles, /data-cyber-sonar='off'/);
assert.match(detection, /governorRequestRender\('cyber-sonar-cadence'\)/);
assert.match(
detection,
/const animatingCount = countAnimatingRenderEntries\(renderEntries\);/,
);
assert.doesNotMatch(detection, /sonarActive \? 1 : 0/);
assert.match(
civilRendering,
/alpha: flightState\._irBoost \? 1 : treatment\.alpha/,
);
assert.match(
militaryRendering,
/baseAlpha:[\s\S]*?alpha: flightState\._irBoost \? 1 : treatment\.alpha/,
);
assert.match(
cyberStyles,
/#title-bar \.title-logo #globe \{[\s\S]*?fill: var\(--cyber-red\);[\s\S]*?stroke: var\(--cyber-red-bright\);/,
);
assert.match(cyberStyles, /#title-bar \.title-logo #globe_cage/);
assert.match(
cyberStyles,
/#top-center-actions button\s*\{[\s\S]*?border-radius:\s*0;[\s\S]*?clip-path:\s*polygon\(\s*29% 0,\s*71% 0,[\s\S]*?0 29%\s*\);/,
);
assert.match(cyberStyles, /#top-center-actions button::before/);
assert.match(cyberStyles, /\.hud-top-left/);
assert.match(cyberStyles, /transparent 0 min\(40vw, 52vh\)/);
assert.match(cyberStyles, /\.hud-bottom-bar \{\s*display: none;/);
assert.match(
cyberStyles,
/#intel-hud\[data-variant='cyber'\] \.hud-top-bar \{\s*display: none;/,
);
// Cyber shares the measured rail/credit keep-out rather than overriding it.
assert.doesNotMatch(
cyberStyles,
/#right-context-rail\s*\{[^}]*\b(?:top|bottom|height|max-height):/,
);
assert.match(cyberStyles, /--cyber-hud-card-top: max\(100px, 10\.25vh\);/);
assert.match(
cyberStyles,
/--cyber-left-rail-top: max\([\s\S]*?252px,[\s\S]*?calc\(var\(--cyber-hud-card-top\) \+ 146px\)/,
);
assert.match(
cyberStyles,
/\.hud-top-left \{[\s\S]*?top: var\(--cyber-hud-card-top\);/,
);
assert.match(cyberStyles, /\.hud-bottom-left \{\s*bottom: 60px;/);
assert.match(
cyberStyles,
/#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;/,
);
assert.match(
cyberStyles,
/\.hud-left-edge \{\s*top: calc\(var\(--cyber-left-rail-top\) - 18px\);\s*left: 14px;/,
);
assert.match(
cyberStyles,
/--right-display-collapsed-width: var\(--cyber-panel-collapsed-width\);/,
);
assert.match(
cyberStyles,
/clip-path: polygon\([\s\S]*?12px 0,[\s\S]*?0 12px/,
);
assert.match(cyberStyles, /\.hud-corner::before/);
assert.match(
cyberStyles,
/--cyber-telemetry-fill: rgba\(13, 20, 23, 0\.985\);/,
);
assert.match(cyberStyles, /var\(--cyber-telemetry-fill\)/);
assert.match(cyberStyles, />\s*\.panel-header\s+\.panel-title::before/);
assert.match(cyberStyles, /\.panel-collapsible\.collapsed::after/);
assert.match(cyberStyles, /\.panel-collapsible:not\(\.collapsed\)::after/);
assert.match(cyberStyles, /\.context-mode-standby:not\(\[hidden\]\)/);
assert.match(cyberStyles, /mask-image: linear-gradient\(/);
assert.match(cyberStyles, /flex: 1 1 auto;/);
assert.match(cyberStyles, /#left-panel-stack:has/);
assert.match(cyberStyles, /left: 54px;/);
assert.match(cyberStyles, /right: 62px;/);
assert.match(cyberStyles, /bottom: calc\(2vh \+ 11rem\);/);
assert.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/);
assert.match(cyberStyles, /data-layer-id='satellites'/);
assert.match(cyberStyles, /#search-toggle::before/);
assert.match(cyberStyles, /VISUAL MATRIX \/\/ 03/);
assert.match(cyberStyles, /body\.cockpit-mode #cockpit-hud/);
assert.match(cyberStyles, /TARGET SCANNING \/\/ LIVE VECTOR/);
assert.match(
cyberStyles,
/body\.cockpit-mode #global-loading-status \{[\s\S]*?top: 60px;[\s\S]*?z-index: 146;/,
);
assert.match(
cyberStyles,
/body\.cockpit-mode #global-loading-detail \{[\s\S]*?color: var\(--cockpit-accent\);/,
);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-visor-shell::before/);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-scan-array/);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-target-side-arcs/);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-target-ticks/);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-target-core/);
assert.match(cyberStyles, /width: min\(48vw, 60vh\);/);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-context-window/);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-signal-window/);
assert.match(cyberStyles, /body\.cockpit-mode #intel-hud\.active/);
});
test('Cyber voice telemetry renders a live scan scope without replacing audio ownership', () => {
assert.match(
cyberStyles,
/#gev-voice-control\s*\{[\s\S]*?--cyber-voice-wave:/,
);
assert.match(cyberStyles, /\.gev-voice-visualizer::before/);
assert.match(cyberStyles, /\.gev-voice-visualizer::after/);
assert.match(cyberStyles, /@keyframes gev-cyber-voice-scan/);
assert.match(cyberStyles, /@keyframes gev-cyber-voice-lock/);
assert.match(cyberStyles, /\[data-speaker='user'\]/);
assert.match(cyberStyles, /\[data-speaker='ai'\]/);
assert.match(
cyberStyles,
/prefers-reduced-motion: reduce[\s\S]*?gev-voice-visualizer::after[\s\S]*?animation: none/,
);
});
test('Cyber Cockpit Display uses the released briefing lane instead of the legacy viewport cap', () => {
assert.match(
cyberStyles,
/body\.cockpit-mode\s+#cockpit-display-panel\s*\{[\s\S]*?max-height:\s*calc\(var\(--cockpit-utility-expanded-max-height, 72vh\) - 62px\)/,
);
assert.match(
cyberStyles,
/#cockpit-hud:has\(\.cockpit-utility-control\.is-expanded\)[\s\S]*?\.cockpit-signal-window\[data-collapsed='true'\][\s\S]*?bottom: 80px;[\s\S]*?visibility:\s*visible/,
);
});
test('Cyber Cockpit keeps Data Layers inside its bounded rail', () => {
assert.match(
cyberStyles,
/body:not\(\.cockpit-mode\)\s+#left-panel-stack:has\([\s\S]*?max-height:\s*none;/,
);
assert.match(
cyberStyles,
/body\.cockpit-mode #left-panel-stack > #data-panel \{[\s\S]*?max-height: min\(64vh, 610px\);/,
);
});
test('Cyber desktop cockpit collapsed utility frames match the 50px Data Layers frame', () => {
const desktop = cyberStyles.slice(
cyberStyles.indexOf('@media (min-width: 961px) and (min-height: 650px)'),
cyberStyles.indexOf('@media (max-width: 720px)'),
);
assert.match(
desktop,
/:root\[data-ui-theme='cyber'\]\s+body\.cockpit-mode\s+\.cockpit-utility-control:not\(\.is-expanded\)\s+\.cockpit-utility-launcher\s*\{\s*height: 50px;\s*min-height: 50px;\s*padding: 8px 18px 10px 24px;/,
);
assert.match(
desktop,
/\.cockpit-utility-control:not\(\.is-expanded\)\s+\.cockpit-utility-launcher::after\s*\{\s*bottom: 6px;/,
);
});
test('Cyber right panels preserve intrinsic sizing and the shared rail allocation', () => {
assert.doesNotMatch(
cyberStyles.slice(cyberStyles.indexOf(":root[data-ui-theme='cyber'] {")),
/#right-context-rail\s*>[^{}]+\{[^{}]*\b(?:flex|height|max-height):/,
);
});
test('Cyber panels retain clean edges without the added footer band', () => {
assert.doesNotMatch(cyberStyles, /--cyber-panel-foot-/);
assert.doesNotMatch(cyberStyles, /border-bottom: 24px solid transparent/);
});
test('Cyber keeps Sonar on/off without the removed CSS effect switch or dimmer', () => {
const template = readFileSync(
new URL('./templates/display-controls.html', import.meta.url),
'utf8',
);
assert.match(template, /id="cyber-sonar-toggle"/);
assert.doesNotMatch(template, /data-sonar-mode|cyber-sonar-mode-row/);
assert.doesNotMatch(cyberStyles, /cyber-scene-dimmer|data-cyber-sonar-mode/);
});
test('Cyber Display uses a red title without a panel-specific center tab', () => {
assert.match(
cyberStyles,
/#pp-toggles \.pp-header-label\s*\{\s*color: var\(--cyber-red-bright\);/,
);
assert.doesNotMatch(cyberStyles, /#pp-toggles[^,{]*::after\s*\{/);
assert.match(cyberStyles, /isolation: isolate;\s*clip-path: none;/);
assert.match(cyberStyles, /#pp-toggles:not\(\.collapsed\)::before/);
});
test('Cyber omits only the fictional classification and system header lines', () => {
assert.match(
cyberStyles,
/#intel-hud\[data-variant='cyber'\] \.hud-top-left \.hud-classification,\s*#intel-hud\[data-variant='cyber'\] \.hud-top-left \.hud-system\s*\{\s*display: none;\s*\}/,
);
});
+15 -1
View File
@@ -104,6 +104,12 @@ export class DisplayBindings {
scopeFeatherSlider: this._scopeFeatherSlider,
hudLayout: this._hudLayoutSelect,
hudButton: this._hudBtn,
sonarButton: this._cyberSonarBtn,
sonarRingsSlider: this._cyberSonarRings,
sonarRangeSlider: this._cyberSonarRange,
sonarIntensitySlider: this._cyberSonarIntensity,
sonarOpacitySlider: this._cyberSonarOpacity,
sonarSectorSlider: this._cyberSonarSector,
cleanViewButton: this._cleanViewBtn,
cleanViewExitButton: this._cleanViewExitBtn,
densitySlider: this._detectionDensitySlider,
@@ -153,7 +159,7 @@ export class DisplayBindings {
},
setHudLayout: (value) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._setHudVariant(value);
this._setHudVariant(value, { applyVisualDefaults: true });
},
toggleCleanView: () => this.toggleCleanView(),
exitCleanView: () => this.toggleCleanView(false),
@@ -191,6 +197,14 @@ export class DisplayBindings {
this._updateHudButtonState();
this._syncShareState();
},
toggleSonar: () => this._setCyberSonarEnabled(),
setSonarRings: (value) => this._setCyberSonarSetting('rings', value),
setSonarRange: (value) => this._setCyberSonarSetting('range', value),
setSonarIntensity: (value) =>
this._setCyberSonarSetting('intensity', value),
setSonarOpacity: (value) =>
this._setCyberSonarSetting('opacity', value),
setSonarSector: (value) => this._setCyberSonarSetting('sector', value),
cycleDetection: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this.claimDetection();
+9
View File
@@ -21,6 +21,7 @@ export function bindDisplayControls({ elements, actions }) {
['cleanViewExitButton', 'exitCleanView'],
['celestialButton', 'toggleCelestial'],
['hudButton', 'toggleHud'],
['sonarButton', 'toggleSonar'],
['detectionButton', 'cycleDetection'],
['modelsButton', 'toggleModels'],
])
@@ -33,6 +34,14 @@ export function bindDisplayControls({ elements, actions }) {
listen(elements[name], 'input', action, integer);
listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value);
listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value);
for (const [name, action] of [
['sonarRingsSlider', 'setSonarRings'],
['sonarRangeSlider', 'setSonarRange'],
['sonarIntensitySlider', 'setSonarIntensity'],
['sonarOpacitySlider', 'setSonarOpacity'],
['sonarSectorSlider', 'setSonarSector'],
])
listen(elements[name], 'input', action, integer);
for (const el of elements.styleButtons || [])
listen(el, 'click', 'setStyle', (el) => el.dataset.style);
for (const el of elements.allocationButtons || [])
+58
View File
@@ -9,6 +9,19 @@ function element(value = '') {
return el;
}
test('sonar on/off control cannot fire after disposal', () => {
const sonarButton = element();
const calls = [];
const control = bindDisplayControls({
elements: { sonarButton },
actions: { toggleSonar: () => calls.push('toggle') },
});
sonarButton.dispatchEvent(new Event('click'));
control.destroy();
sonarButton.dispatchEvent(new Event('click'));
assert.deepEqual(calls, ['toggle']);
});
test('controls read current values without preventing native input behavior', () => {
const bloomSlider = element('24');
const hudLayout = element('tactical');
@@ -54,6 +67,51 @@ test('destroyed and replaced controls cannot issue stale actions', () => {
assert.equal(newCalls, 1);
});
test('the Cyber sonar control is an optional owned display action', () => {
const sonarButton = element();
const sonarRingsSlider = element('9');
const sonarRangeSlider = element('110');
const sonarIntensitySlider = element('65');
const sonarOpacitySlider = element('84');
const sonarSectorSlider = element('32');
const calls = [];
const control = bindDisplayControls({
elements: {
sonarButton,
sonarRingsSlider,
sonarRangeSlider,
sonarIntensitySlider,
sonarOpacitySlider,
sonarSectorSlider,
},
actions: {
toggleSonar: () => calls.push('toggle'),
setSonarRings: (value) => calls.push(['rings', value]),
setSonarRange: (value) => calls.push(['range', value]),
setSonarIntensity: (value) => calls.push(['intensity', value]),
setSonarOpacity: (value) => calls.push(['opacity', value]),
setSonarSector: (value) => calls.push(['sector', value]),
},
});
sonarButton.dispatchEvent(new Event('click'));
sonarRingsSlider.dispatchEvent(new Event('input'));
sonarRangeSlider.dispatchEvent(new Event('input'));
sonarIntensitySlider.dispatchEvent(new Event('input'));
sonarOpacitySlider.dispatchEvent(new Event('input'));
sonarSectorSlider.dispatchEvent(new Event('input'));
assert.deepEqual(calls, [
'toggle',
['rings', 9],
['range', 110],
['intensity', 65],
['opacity', 84],
['sector', 32],
]);
control.destroy();
sonarButton.dispatchEvent(new Event('click'));
assert.equal(calls.length, 6);
});
test('style, allocation and model choices retain their current data attributes', () => {
const style = element();
const allocation = element();
+6
View File
@@ -0,0 +1,6 @@
/** Cyber keeps the frame fixed; other layouts retain their existing scroller. */
export function displayPanelScroller(panel) {
return panel?.ownerDocument?.documentElement?.dataset.uiTheme === 'cyber'
? panel.querySelector('.pp-panel-body') || panel
: panel;
}
+3 -1
View File
@@ -23,6 +23,7 @@ const PANEL_GROUPS = [
'satellites',
'flights',
'military',
'local-adsb',
'ais-live-vessels',
'traffic',
'transit',
@@ -45,7 +46,7 @@ const PANEL_GROUPS = [
},
{
label: 'Events',
ids: ['rocket-launches', 'earthquakes', 'local-firms'],
ids: ['rocket-launches', 'earthquakes', 'local-firms', 'fire-perimeters'],
},
{
label: 'Weather',
@@ -222,6 +223,7 @@ export class LayerPanel {
left.className = 'data-toggle-left';
const icon = document.createElement('span');
icon.className = 'data-icon';
icon.setAttribute('aria-hidden', 'true');
icon.textContent = layer.icon;
const name = document.createElement('span');
name.className = 'data-name';
+1
View File
@@ -21,6 +21,7 @@ test('panel presentation places Transit between Street Traffic and Bike Share in
'satellites',
'flights',
'military',
'local-adsb',
'ais-live-vessels',
'traffic',
'transit',
+297
View File
@@ -0,0 +1,297 @@
import {
internetRadioAudioActive,
localSdrCardView,
localSdrFeedLine,
localSdrFmAudioActive,
} from './localSdrPresentation.js';
const ELEMENT_IDS = Object.freeze({
card: 'sdr-radio-card',
connectionState: 'sdr-connection-state',
connect: 'sdr-connect-btn',
locate: 'sdr-locate-btn',
changeDevice: 'sdr-change-device-btn',
modeFm: 'sdr-mode-fm-btn',
modeAdsb: 'sdr-mode-adsb-btn',
gain: 'sdr-gain-select',
stats: 'sdr-adsb-stats',
statRate: 'sdr-stat-rate',
statHeard: 'sdr-stat-heard',
statPositioned: 'sdr-stat-positioned',
statIq: 'sdr-stat-iq',
frequency: 'sdr-frequency-input',
tune: 'sdr-tune-btn',
seekBack: 'sdr-seek-back-btn',
seekForward: 'sdr-seek-forward-btn',
volume: 'sdr-volume',
volumeValue: 'sdr-volume-value',
status: 'sdr-status',
feedStatus: 'sdr-feed-status',
});
function setText(element, text) {
if (element && element.textContent !== text) element.textContent = text;
}
/**
* Local RTL-SDR card in the Radio panel.
*
* Owns the card's listeners and its receiver/radio subscriptions. The Radio
* panel is one listening surface: starting local FM stops internet radio, and
* starting internet radio stops local FM.
*/
export class LocalSdrControls {
/**
* @param {object} options
* @param {Document} options.document Document containing the card.
* @param {object} options.receiver Local RTL-SDR session.
* @param {object} [options.feeds] Decoder-feed session (getState,
* subscribe, probe); the card shows one read-only line when the server
* has feeds configured.
* @param {object} [options.radio] Internet-radio port (subscribe, stopPlayback).
* @param {object} [options.actions] Application actions:
* setLocalAdsbEnabled(enabled), isLocalAdsbEnabled(), scheduleLayout().
*/
constructor({
document: doc,
receiver,
feeds = null,
radio = null,
actions = {},
}) {
this.receiver = receiver;
this.feeds = feeds;
this.radio = radio;
this.actions = actions;
this.elements = Object.fromEntries(
Object.entries(ELEMENT_IDS).map(([key, id]) => [
key,
doc?.getElementById?.(id) || null,
]),
);
this._listeners = new AbortController();
this._state = receiver.getState();
this._radioState = null;
this._fmAudioActive = localSdrFmAudioActive(this._state);
this._radioAudioActive = false;
this._renderFrame = null;
this._destroyed = false;
if (!this.elements.card) return;
this._bind();
this._receiverUnsubscribe = receiver.subscribe((state) =>
this._onReceiverState(state),
);
this._radioUnsubscribe =
radio?.subscribe?.((state) => this._onRadioState(state)) || null;
this._feedsUnsubscribe =
feeds?.subscribe?.(() => this._scheduleRender()) || null;
// One request, no polling: learn whether the server has decoder feeds.
void feeds?.probe?.()?.catch?.(() => {});
this._render();
}
/** Whether the receiver session is open (keeps the Radio panel expanded). */
isActive() {
return Boolean(this._state?.connected);
}
_listen(element, type, handler) {
element?.addEventListener(type, handler, {
signal: this._listeners.signal,
});
}
_stopInternetRadio() {
if (internetRadioAudioActive(this._radioState))
this.radio?.stopPlayback?.({ origin: 'user' });
}
async _enableLayerWhenReceiving() {
const state = this.receiver.getState();
if (state.connected && state.mode === 'adsb')
await this.actions.setLocalAdsbEnabled?.(true);
}
_bind() {
const el = this.elements;
this._listen(el.connect, 'click', async () => {
const before = this.receiver.getState();
if (before.connected) {
await this.receiver.stop();
return;
}
if (before.mode === 'fm') this._stopInternetRadio();
if (await this.receiver.connect(before.mode))
await this._enableLayerWhenReceiving();
});
this._listen(el.locate, 'click', () => {
void this.receiver.requestReceiverLocation();
});
this._listen(el.changeDevice, 'click', async () => {
if (this.receiver.getState().mode === 'fm') this._stopInternetRadio();
if (await this.receiver.changeDevice())
await this._enableLayerWhenReceiving();
});
this._listen(el.modeFm, 'click', async () => {
this._stopInternetRadio();
if (this.actions.isLocalAdsbEnabled?.())
await this.actions.setLocalAdsbEnabled?.(false);
await this.receiver.setMode('fm');
});
this._listen(el.modeAdsb, 'click', async () => {
if (await this.receiver.setMode('adsb'))
await this._enableLayerWhenReceiving();
});
this._listen(el.gain, 'change', () => {
void this.receiver.setGain(el.gain.value);
});
const tune = () => {
const mhz = Number(el.frequency?.value);
if (!Number.isFinite(mhz)) return;
this._stopInternetRadio();
void this.receiver.tuneFm(mhz * 1_000_000);
};
this._listen(el.tune, 'click', tune);
this._listen(el.frequency, 'keydown', (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
tune();
});
for (const [button, direction] of [
[el.seekBack, -1],
[el.seekForward, 1],
]) {
this._listen(button, 'click', () => {
this._stopInternetRadio();
void this.receiver.seekFm(direction);
});
}
this._listen(el.volume, 'input', () => {
const value = Number(el.volume.value);
this.receiver.setVolume(value / 100);
setText(el.volumeValue, `${value}%`);
});
}
_onReceiverState(state) {
this._state = state;
const fmAudioActive = localSdrFmAudioActive(state);
// Local FM just started: it takes over from internet radio.
if (fmAudioActive && !this._fmAudioActive) this._stopInternetRadio();
this._fmAudioActive = fmAudioActive;
this._scheduleRender();
}
_onRadioState(state) {
this._radioState = state;
const radioAudioActive = internetRadioAudioActive(state);
// Internet radio just started: it takes over from local FM.
if (radioAudioActive && !this._radioAudioActive && this._fmAudioActive) {
void this.receiver.stop({
message: 'Local FM stopped: internet radio started',
});
}
this._radioAudioActive = radioAudioActive;
}
_scheduleRender() {
if (this._destroyed || this._renderFrame !== null) return;
const schedule =
typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: (callback) => setTimeout(callback, 16);
this._renderFrame = schedule(() => {
this._renderFrame = null;
if (!this._destroyed) this._render();
});
}
_render() {
const el = this.elements;
const view = localSdrCardView(this._state);
const doc = el.card?.ownerDocument;
setText(el.connectionState, view.connectionLabel);
el.connectionState?.classList.toggle('active', view.connectionActive);
if (el.connect) {
setText(el.connect, view.connectLabel);
el.connect.disabled = view.connectDisabled;
el.connect.classList.toggle('active', view.connectPressed);
el.connect.setAttribute('aria-pressed', String(view.connectPressed));
}
for (const [button, active] of [
[el.modeFm, view.fmActive],
[el.modeAdsb, view.adsbActive],
]) {
if (!button) continue;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
button.disabled = view.modeDisabled;
}
if (el.locate) {
setText(el.locate, view.locateLabel);
el.locate.disabled = view.locateDisabled;
el.locate.classList.toggle('active', view.locateActive);
}
if (el.changeDevice) el.changeDevice.disabled = view.changeDeviceDisabled;
if (el.gain) {
if (doc?.activeElement !== el.gain && el.gain.value !== view.gainValue)
el.gain.value = view.gainValue;
el.gain.disabled = view.gainDisabled;
}
if (el.stats) el.stats.hidden = view.statsHidden;
setText(el.statRate, view.stats.rate);
setText(el.statHeard, view.stats.heard);
setText(el.statPositioned, view.stats.positioned);
setText(el.statIq, view.stats.iq);
if (el.frequency) {
el.frequency.disabled = view.frequencyDisabled;
if (doc?.activeElement !== el.frequency && view.frequencyValue !== null)
el.frequency.value = view.frequencyValue;
}
if (el.tune) el.tune.disabled = view.tuneDisabled;
if (el.seekBack) el.seekBack.disabled = view.seekDisabled;
if (el.seekForward) el.seekForward.disabled = view.seekDisabled;
if (el.volume) {
if (doc?.activeElement !== el.volume)
el.volume.value = String(view.volumePercent);
el.volume.disabled = view.volumeDisabled;
}
setText(el.volumeValue, `${view.volumePercent}%`);
if (el.status) {
setText(el.status, view.statusText);
el.status.classList.toggle('error', view.statusError);
}
const feedLine = localSdrFeedLine(this.feeds?.getState?.() || null);
if (el.feedStatus) {
el.feedStatus.hidden = feedLine === null;
setText(el.feedStatus, feedLine || '');
}
el.card
?.closest?.('#radio-panel')
?.classList.toggle('sdr-connected', view.connectionActive);
// Only structural changes move the rail; per-block text updates do not.
const layoutKey = `${view.statsHidden}|${view.connectionActive}|${feedLine === null}`;
if (layoutKey !== this._layoutKey) {
this._layoutKey = layoutKey;
this.actions.scheduleLayout?.();
}
}
/** Release listeners and subscriptions; the receiver session is not closed. */
destroy() {
this._destroyed = true;
this._listeners.abort();
this._receiverUnsubscribe?.();
this._radioUnsubscribe?.();
this._feedsUnsubscribe?.();
this._feedsUnsubscribe = null;
this._receiverUnsubscribe = null;
this._radioUnsubscribe = null;
if (this._renderFrame !== null) {
if (typeof cancelAnimationFrame === 'function')
cancelAnimationFrame(this._renderFrame);
else clearTimeout(this._renderFrame);
this._renderFrame = null;
}
}
}
+207
View File
@@ -0,0 +1,207 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { LocalSdrControls } from './localSdrControls.js';
function stubDocument() {
const elements = new Map();
const doc = {
activeElement: null,
getElementById(id) {
if (!elements.has(id)) {
const listeners = new Map();
elements.set(id, {
id,
value: '',
hidden: false,
disabled: false,
textContent: '',
ownerDocument: doc,
classList: { toggle() {} },
setAttribute() {},
closest: () => null,
addEventListener(type, handler) {
listeners.set(type, handler);
},
fire: (type, event = {}) => listeners.get(type)?.(event),
});
}
return elements.get(id);
},
};
return doc;
}
function stubReceiver() {
const listeners = new Set();
const calls = [];
let state = {
webUsbSupported: true,
connected: false,
status: 'idle',
message: 'Connect an RTL-SDR to begin.',
mode: 'fm',
frequencyHz: 98_500_000,
volume: 0.8,
gain: 'auto',
locationStatus: 'unknown',
};
return {
calls,
getState: () => ({ ...state }),
subscribe(listener) {
listeners.add(listener);
listener({ ...state });
return () => listeners.delete(listener);
},
emit(patch) {
state = { ...state, ...patch };
for (const listener of listeners) listener({ ...state });
},
async stop(options) {
calls.push(['stop', options]);
this.emit({ connected: false, status: 'idle' });
return true;
},
async setMode(mode) {
calls.push(['setMode', mode]);
this.emit({ mode });
return true;
},
async setGain(value) {
calls.push(['setGain', value]);
return value;
},
async tuneFm(hz) {
calls.push(['tuneFm', hz]);
return true;
},
};
}
function stubRadio() {
const listeners = new Set();
const calls = [];
return {
calls,
subscribe(listener) {
listeners.add(listener);
listener({ audioState: 'idle' });
return () => listeners.delete(listener);
},
emit(state) {
for (const listener of listeners) listener(state);
},
stopPlayback(options) {
calls.push(options);
this.emit({ audioState: 'idle' });
},
};
}
test('starting internet radio stops local FM, and local FM stops internet radio', async (t) => {
const receiver = stubReceiver();
const radio = stubRadio();
const controls = new LocalSdrControls({
document: stubDocument(),
receiver,
radio,
});
t.after(() => controls.destroy());
receiver.emit({ connected: true, status: 'streaming', mode: 'fm' });
assert.equal(radio.calls.length, 0, 'nothing to stop while radio is silent');
radio.emit({ audioState: 'loading' });
assert.deepEqual(receiver.calls.at(-1), [
'stop',
{ message: 'Local FM stopped: internet radio started' },
]);
radio.emit({ audioState: 'playing' });
receiver.emit({ connected: true, status: 'streaming', mode: 'fm' });
assert.deepEqual(radio.calls, [{ origin: 'user' }]);
});
test('ADS-B reception and internet radio play together', async (t) => {
const receiver = stubReceiver();
const radio = stubRadio();
const controls = new LocalSdrControls({
document: stubDocument(),
receiver,
radio,
});
t.after(() => controls.destroy());
receiver.emit({ connected: true, status: 'streaming', mode: 'adsb' });
radio.emit({ audioState: 'playing' });
assert.equal(receiver.calls.length, 0);
assert.equal(radio.calls.length, 0);
});
test('card controls drive gain, FM mode and the Local ADS-B layer', async (t) => {
const doc = stubDocument();
const receiver = stubReceiver();
const layerCalls = [];
const controls = new LocalSdrControls({
document: doc,
receiver,
actions: {
isLocalAdsbEnabled: () => true,
setLocalAdsbEnabled: async (enabled) => layerCalls.push(enabled),
},
});
t.after(() => controls.destroy());
const gain = doc.getElementById('sdr-gain-select');
gain.value = '20.7';
gain.fire('change');
assert.deepEqual(receiver.calls.at(-1), ['setGain', '20.7']);
receiver.emit({ connected: true, status: 'streaming', mode: 'adsb' });
await doc.getElementById('sdr-mode-fm-btn').fire('click');
assert.deepEqual(layerCalls, [false]);
assert.deepEqual(receiver.calls.at(-1), ['setMode', 'fm']);
await doc.getElementById('sdr-mode-adsb-btn').fire('click');
assert.deepEqual(layerCalls, [false, true]);
assert.equal(controls.isActive(), true);
});
test('the card shows one read-only decoder-feed line after a single probe', async (t) => {
const doc = stubDocument();
const listeners = new Set();
let probes = 0;
let state = { configured: null, polling: false, feeds: [] };
const feeds = {
getState: () => state,
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
async probe() {
probes += 1;
},
set(patch) {
state = { ...state, ...patch };
for (const listener of listeners) listener(state);
},
};
const controls = new LocalSdrControls({
document: doc,
receiver: stubReceiver(),
feeds,
});
t.after(() => controls.destroy());
const line = doc.getElementById('sdr-feed-status');
assert.equal(probes, 1);
assert.equal(line.hidden, true);
feeds.set({
configured: true,
polling: true,
feeds: [
{ band: '1090', status: 'live' },
{ band: '978', status: 'live' },
],
});
await new Promise((resolve) => setTimeout(resolve, 30));
assert.equal(line.hidden, false);
assert.equal(line.textContent, 'Decoder feeds: 1090 live · 978 live');
});
+75
View File
@@ -0,0 +1,75 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { expandApplicationHtml } from '../../build/application-html.js';
import { createBrowserViteConfig } from '../../build/vite.js';
import { R820T_GAIN_STEPS_DB } from '../sdr/gain.js';
const html = expandApplicationHtml(
readFileSync(new URL('../../index.html', import.meta.url), 'utf8'),
);
const css = readFileSync(
new URL('./styles/radio.css', import.meta.url),
'utf8',
);
test('the Local RTL-SDR card lives inside the Radio panel with every control', () => {
const radioStart = html.indexOf('id="radio-panel"');
const cardStart = html.indexOf('id="sdr-radio-card"');
const radioEnd = html.indexOf('</section>', radioStart);
assert.ok(radioStart >= 0 && cardStart > radioStart && cardStart < radioEnd);
for (const id of [
'sdr-connection-state',
'sdr-connect-btn',
'sdr-locate-btn',
'sdr-change-device-btn',
'sdr-mode-fm-btn',
'sdr-mode-adsb-btn',
'sdr-gain-select',
'sdr-adsb-stats',
'sdr-stat-rate',
'sdr-stat-heard',
'sdr-stat-positioned',
'sdr-stat-iq',
'sdr-frequency-input',
'sdr-tune-btn',
'sdr-seek-back-btn',
'sdr-seek-forward-btn',
'sdr-volume',
'sdr-status',
'sdr-feed-status',
]) {
assert.match(html, new RegExp(`id="${id}"`), `${id} is missing`);
}
assert.match(html, /id="sdr-status"[^>]*>Connect an RTL-SDR to begin\.</);
assert.match(css, /\.sdr-radio-card\s*\{/);
});
test('the gain control offers AUTO plus every R820T step', () => {
const start = html.indexOf('id="sdr-gain-select"');
const select = html.slice(start, html.indexOf('</select>', start));
const values = [...select.matchAll(/<option value="([^"]+)"/g)].map(
(match) => match[1],
);
assert.deepEqual(values, [
'auto',
...R820T_GAIN_STEPS_DB.map((step) => step.toFixed(1)),
]);
});
test('worker-reached SDR dependencies are pre-bundled and builds keep a separate cache', () => {
const serve = createBrowserViteConfig();
for (const dependency of [
'@jtarrio/signals/demod/demodulator.js',
'@jtarrio/signals/demod/modes.js',
'@jtarrio/webrtlsdr/rtlsdr.js',
]) {
assert.ok(serve.optimizeDeps.include.includes(dependency), dependency);
}
assert.equal(serve.cacheDir, undefined);
assert.equal(
createBrowserViteConfig({ command: 'build' }).cacheDir,
'node_modules/.vite-build',
);
});
+146
View File
@@ -0,0 +1,146 @@
import { localReceiverFeedName } from '../layers/localAdsb/feedNames.js';
/**
* Pure presentation of the Local RTL-SDR card in the Radio panel. Receives the
* receiver session snapshot and returns what each control should show.
*/
function formatGain(gain) {
return gain === 'auto' || !Number.isFinite(gain) ? 'auto' : gain.toFixed(1);
}
function formatIq(state) {
return Number.isFinite(state.iqLevelDbfs)
? `${state.iqLevelDbfs.toFixed(1)} dBFS`
: '--';
}
function statusText(state) {
const fmActive = state.mode === 'fm';
const iqStatus =
state.samplesPerSecond > 0
? `${(state.samplesPerSecond / 1_000_000).toFixed(2)} MS/s IQ`
: 'waiting for IQ';
const device = state.deviceLabel ? ` · ${state.deviceLabel}` : '';
if (state.connected && !fmActive) {
return `${state.message}${device} · ${iqStatus} · ${state.decodedMessages} messages`;
}
const detail = state.seeking
? state.seekMessage
: state.seekMessage || state.message;
if (state.connected && fmActive) {
const workerStatus =
state.workerBlocks > 0
? `DSP ${state.workerBlocks} blocks`
: 'DSP waiting';
const rfStatus = `RF ${formatIq(state)}`;
const audioSignal = Number.isFinite(state.audioLevelDbfs)
? `${state.audioLevelDbfs.toFixed(1)} dBFS audio`
: 'audio signal --';
return `${detail}${device} · ${iqStatus} · ${workerStatus} · ${rfStatus} · ${audioSignal} · audio ${state.audioState}`;
}
return detail;
}
/**
* Derive the card's control states from one receiver snapshot.
* @param {object} state SdrController.getState() snapshot.
* @returns {object} Control presentation.
*/
export function localSdrCardView(state) {
const transitional =
state.status === 'connecting' || state.status === 'tuning';
const fmActive = state.mode === 'fm';
const adsbActive = state.mode === 'adsb';
const fmInteractive =
state.connected && fmActive && state.status === 'streaming';
return {
connectionLabel: state.connected
? adsbActive
? `${state.aircraftHeard || 0} HEARD`
: 'STREAMING'
: String(state.status || 'idle').toUpperCase(),
connectionActive: Boolean(state.connected),
connectLabel: transitional
? String(state.status).toUpperCase()
: state.connected
? 'DISCONNECT'
: 'CONNECT',
connectDisabled: transitional || !state.webUsbSupported,
connectPressed: Boolean(state.connected),
fmActive,
adsbActive,
modeDisabled: transitional,
locateLabel:
state.locationStatus === 'requesting'
? 'LOCATING…'
: state.locationStatus === 'ready'
? 'LOCATED'
: 'LOCATE',
locateDisabled: state.locationStatus === 'requesting',
locateActive: state.locationStatus === 'ready',
changeDeviceDisabled: transitional || !state.webUsbSupported,
gainValue: formatGain(state.gain),
gainDisabled: transitional,
statsHidden: !(adsbActive && state.connected),
stats: {
rate: Number.isFinite(state.messagesPerSecond)
? state.messagesPerSecond.toFixed(1)
: '--',
heard: String(state.aircraftHeard || 0),
positioned: String(state.aircraftPositioned || 0),
iq: formatIq(state),
},
frequencyDisabled: !fmActive || transitional,
frequencyValue: fmActive
? (state.frequencyHz / 1_000_000).toFixed(1)
: null,
tuneDisabled: !state.connected || !fmActive || transitional,
seekDisabled: !fmInteractive || Boolean(state.seeking),
volumePercent: Math.round((Number(state.volume) || 0) * 100),
volumeDisabled: !fmActive,
statusText: statusText(state),
statusError: state.status === 'error' || state.status === 'unsupported',
};
}
/**
* Whether the receiver is producing FM audio (the state that must not overlap
* internet-radio playback).
* @param {object} state SdrController.getState() snapshot.
* @returns {boolean}
*/
export function localSdrFmAudioActive(state) {
return Boolean(
state?.connected && state.mode === 'fm' && state.status === 'streaming',
);
}
/**
* Whether internet radio is loading or playing a station.
* @param {object} state Radio UI state.
* @returns {boolean}
*/
export function internetRadioAudioActive(state) {
return ['loading', 'buffering', 'playing'].includes(state?.audioState);
}
/**
* One-line decoder-feed summary for the card, or null when the server has no
* feed configured. Statuses show while Local ADS-B polls the feeds; otherwise
* the line names the configured bands.
* @param {object|null} feedState Local receiver feed session snapshot.
* @returns {string|null}
*/
export function localSdrFeedLine(feedState) {
const feeds = feedState?.configured === true ? feedState.feeds : null;
if (!Array.isArray(feeds) || !feeds.length) return null;
const names = feeds.map((feed) => {
const name = localReceiverFeedName(feed, feeds);
return feedState.polling || feed?.status === 'invalid'
? `${name} ${feed?.status || 'unreachable'}`
: name;
});
const suffix = feedState.polling ? '' : ' · read while Local ADS-B is on';
return `Decoder feeds: ${names.join(' · ')}${suffix}`;
}
+155
View File
@@ -0,0 +1,155 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
internetRadioAudioActive,
localSdrCardView,
localSdrFeedLine,
localSdrFmAudioActive,
} from './localSdrPresentation.js';
const idle = Object.freeze({
webUsbSupported: true,
connected: false,
status: 'idle',
message: 'Connect an RTL-SDR to begin.',
mode: 'fm',
frequencyHz: 98_500_000,
volume: 0.8,
gain: 'auto',
seeking: false,
seekMessage: '',
decodedMessages: 0,
messagesPerSecond: null,
aircraftHeard: 0,
aircraftPositioned: 0,
deviceLabel: null,
locationStatus: 'unknown',
audioState: 'idle',
samplesPerSecond: 0,
workerBlocks: 0,
iqLevelDbfs: null,
audioLevelDbfs: null,
});
test('an idle card invites a connection and hides receiver stats', () => {
const view = localSdrCardView(idle);
assert.equal(view.statusText, 'Connect an RTL-SDR to begin.');
assert.equal(view.connectLabel, 'CONNECT');
assert.equal(view.connectDisabled, false);
assert.equal(view.connectionLabel, 'IDLE');
assert.equal(view.gainValue, 'auto');
assert.equal(view.statsHidden, true);
assert.equal(view.tuneDisabled, true);
assert.equal(view.seekDisabled, true);
assert.equal(view.frequencyValue, '98.5');
const unsupported = localSdrCardView({
...idle,
webUsbSupported: false,
status: 'unsupported',
message: 'WebUSB is unavailable in this browser',
});
assert.equal(unsupported.connectDisabled, true);
assert.equal(unsupported.changeDeviceDisabled, true);
assert.equal(unsupported.statusError, true);
});
test('streaming ADS-B shows message rate, heard, positioned and IQ level', () => {
const view = localSdrCardView({
...idle,
connected: true,
status: 'streaming',
mode: 'adsb',
message: 'Receiving 1090 MHz ADS-B',
gain: 20.7,
messagesPerSecond: 13.8,
aircraftHeard: 4,
aircraftPositioned: 2,
iqLevelDbfs: -21.44,
samplesPerSecond: 2_000_000,
decodedMessages: 276,
deviceLabel: 'FlyCatcher_ADS_B',
});
assert.equal(view.statsHidden, false);
assert.deepEqual(view.stats, {
rate: '13.8',
heard: '4',
positioned: '2',
iq: '-21.4 dBFS',
});
assert.equal(view.gainValue, '20.7');
assert.equal(view.connectionLabel, '4 HEARD');
assert.equal(view.connectLabel, 'DISCONNECT');
assert.equal(view.frequencyDisabled, true);
assert.equal(view.volumeDisabled, true);
assert.equal(
view.statusText,
'Receiving 1090 MHz ADS-B · FlyCatcher_ADS_B · 2.00 MS/s IQ · 276 messages',
);
});
test('local FM and internet radio audio states drive the one-listener rule', () => {
assert.equal(localSdrFmAudioActive(idle), false);
assert.equal(
localSdrFmAudioActive({ ...idle, connected: true, status: 'streaming' }),
true,
);
assert.equal(
localSdrFmAudioActive({
...idle,
connected: true,
status: 'streaming',
mode: 'adsb',
}),
false,
'ADS-B produces no audio',
);
for (const audioState of ['loading', 'buffering', 'playing'])
assert.equal(internetRadioAudioActive({ audioState }), true);
assert.equal(internetRadioAudioActive({ audioState: 'paused' }), false);
assert.equal(internetRadioAudioActive(null), false);
});
test('the decoder-feed line appears only when feeds are configured', () => {
assert.equal(localSdrFeedLine(null), null);
assert.equal(
localSdrFeedLine({ configured: false, feeds: [], polling: false }),
null,
);
const feeds = [
{ band: '1090', label: '1090 MHz', status: 'live' },
{ band: '978', label: '978 MHz UAT', status: 'unreachable' },
];
assert.equal(
localSdrFeedLine({ configured: true, polling: true, feeds }),
'Decoder feeds: 1090 live · 978 unreachable',
);
assert.equal(
localSdrFeedLine({ configured: true, polling: false, feeds }),
'Decoder feeds: 1090 · 978 · read while Local ADS-B is on',
);
assert.equal(
localSdrFeedLine({
configured: true,
polling: false,
feeds: [{ band: null, label: 'entry 2', status: 'invalid' }],
}),
'Decoder feeds: entry 2 invalid · read while Local ADS-B is on',
);
});
test('two feeds on one band are told apart by their ordinal labels', () => {
const feeds = [
{ band: '1090', label: '1090 MHz', status: 'live' },
{ band: '978', label: '978 MHz UAT #1', status: 'live' },
{ band: '978', label: '978 MHz UAT #2', status: 'stale' },
];
assert.equal(
localSdrFeedLine({ configured: true, polling: true, feeds }),
'Decoder feeds: 1090 live · 978 MHz UAT #1 live · 978 MHz UAT #2 stale',
);
assert.equal(
localSdrFeedLine({ configured: true, polling: false, feeds }),
'Decoder feeds: 1090 · 978 MHz UAT #1 · 978 MHz UAT #2 · read while Local ADS-B is on',
);
});
+55 -8
View File
@@ -1,4 +1,5 @@
import { UiLifetime } from './uiLifetime.js';
import { displayPanelScroller } from './displayPanelScroll.js';
import { PanelPositionControls } from './panelPositionControls.js';
import { PanelLayoutController } from './panelLayoutController.js';
import {
@@ -73,7 +74,7 @@ export class PanelChrome {
readDisplayScrollTop: () =>
this._displayPortalScrollRestoreOwner === 'standard'
? this._standardDisplayScrollTop
: this._ppToggles?.scrollTop || 0,
: displayPanelScroller(this._ppToggles)?.scrollTop || 0,
});
}
get hud() {
@@ -348,9 +349,11 @@ export class PanelChrome {
if (!panelEl) continue;
// Responsive auto-collapse is presentation only; the recipient should
// restore the user's explicit expanded preference at its own viewport.
const collapsed = panelEl.classList.contains('layout-auto-collapsed')
? false
: panelEl.classList.contains('collapsed');
const collapsed =
panelEl.classList.contains('layout-auto-collapsed') ||
panelEl.classList.contains('cyber-accordion-collapsed')
? false
: panelEl.classList.contains('collapsed');
const entry = { id: spec.id, collapsed };
if (spec.pinnable)
entry.pinned = panelEl.classList.contains('dock-pinned');
@@ -400,9 +403,32 @@ export class PanelChrome {
if (explicit && !restore)
this.shareLinkManager?.claimRestoreLane?.('panel', panelId);
const nextCollapsed = Boolean(collapsed);
const wasAutoCollapsed = panelEl.classList.contains(
'layout-auto-collapsed',
);
const wasAutoCollapsed =
panelEl.classList.contains('layout-auto-collapsed') ||
panelEl.classList.contains('cyber-accordion-collapsed');
// A user opening a Cyber rail panel owns the whole accordion, including
// peers that were only presentation-collapsed during a saved-state restore.
if (
explicit &&
!restore &&
!nextCollapsed &&
document.documentElement?.dataset.uiTheme === 'cyber' &&
panelEl.parentElement === this._rightPanelStack
) {
for (const peer of this._rightPanelStack.children) {
if (
peer !== panelEl &&
peer.matches('[data-panel-id]') &&
!peer.hidden
) {
this.setPanelCollapsed(peer.id, true, {
explicit,
persist,
syncShare: false,
});
}
}
}
const leftOwnerPanel = this._leftPanelStack?.contains(panelEl)
? panelEl
: null;
@@ -434,6 +460,23 @@ export class PanelChrome {
) {
this._panelLayout._rightStackPreferredPanelId = null;
}
// Reveal Radio's owner before the no-op check: the nested section may
// already be expanded while its Context parent is closed.
if (
document.documentElement?.dataset.uiTheme === 'cyber' &&
!nextCollapsed &&
panelId === 'radio-panel' &&
document
.getElementById('global-context-panel')
?.classList.contains('collapsed')
) {
this.setPanelCollapsed('global-context-panel', false, {
explicit,
restore,
persist,
syncShare,
});
}
if (
panelEl.classList.contains('collapsed') === nextCollapsed &&
!wasAutoCollapsed
@@ -447,7 +490,10 @@ export class PanelChrome {
}
return;
}
panelEl.classList.remove('layout-auto-collapsed');
panelEl.classList.remove(
'layout-auto-collapsed',
'cyber-accordion-collapsed',
);
if (
!nextCollapsed &&
this.cockpitView?.active &&
@@ -469,6 +515,7 @@ export class PanelChrome {
if (
!nextCollapsed &&
panelId === 'radio-panel' &&
document.documentElement?.dataset.uiTheme !== 'cyber' &&
document
.getElementById('global-context-panel')
?.classList.contains('collapsed')
+3
View File
@@ -1,5 +1,6 @@
/** Own rail measurement, layout scheduling and dock tray observation. */
import { layoutLeftPanelRail, layoutRightPanelRail } from './panelRails.js';
import { syncRadioPanelPlacement } from './radioPanelPlacement.js';
const COCKPIT_LAYOUT_SETTLE_MS = 240;
/**
* Fixed UI regions that can occupy the left accordion's vertical lane.
@@ -232,6 +233,7 @@ export class PanelLayoutController {
this._recentImageryPanel,
this._mapillaryPanel,
globalContextPanel,
document.getElementById('radio-panel'),
]) {
if (panel) this._rightStackResizeObserver.observe(panel);
}
@@ -304,6 +306,7 @@ export class PanelLayoutController {
_syncRightPanelAdaptiveLayout() {
if (this.destroyed) return;
syncRadioPanelPlacement(document);
layoutRightPanelRail({
stack: this._rightPanelStack,
obstacles: document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR),
+53
View File
@@ -1,4 +1,5 @@
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
import { displayPanelScroller } from './displayPanelScroll.js';
import assert from 'node:assert/strict';
import test from 'node:test';
import {
@@ -7,6 +8,58 @@ import {
measurePanelNaturalHeight,
} from './panelRails.js';
test('Display uses an inner scroll body only in Cyber', () => {
const body = { scrollTop: 95 };
const panel = {
ownerDocument: { documentElement: { dataset: { uiTheme: 'cyber' } } },
querySelector: () => body,
};
assert.equal(displayPanelScroller(panel), body);
panel.ownerDocument.documentElement.dataset.uiTheme = 'tactical';
assert.equal(displayPanelScroller(panel), panel);
assert.equal(displayPanelScroller(null), null);
});
test('Cyber restores one expanded owner, keeps launchers, and restores other theme preferences', () => {
for (const mobile of [false, true]) {
const f = fixture('right', {
mobile,
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('cyber-accordion-collapsed'), true);
assert.equal(f.second.classList.contains('collapsed'), false);
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
f.run();
assert.equal(
f.first.classList.contains('collapsed'),
true,
'layout does not reopen a peer',
);
f.options.hud.variant = 'operator';
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);
f.expand(f.second, 250);
f.options.documentRef.activeElement = f.second;
f.run();
assert.equal(f.first.classList.contains('collapsed'), true);
assert.equal(f.second.classList.contains('collapsed'), false);
});
function element(
id,
{ height = 42, top = 234, left = 20, width = 272, collapsed = false } = {},
+14 -4
View File
@@ -59,7 +59,10 @@ export class RadioControls {
*/
async _revealRadioControlsAfterExplicitEnable(trigger) {
const contextPanel = document.getElementById('global-context-panel');
const scroller = contextPanel?.querySelector('.global-context-panel-inner');
const scroller =
(document.documentElement?.dataset.uiTheme === 'cyber' &&
contextPanel?.querySelector('.cyber-panel-body')) ||
contextPanel?.querySelector('.global-context-panel-inner');
const directory = this._radioPanel?.querySelector('.radio-directory-row');
const transport = this._radioPanel?.querySelector('.radio-transport');
if (
@@ -113,7 +116,10 @@ export class RadioControls {
*/
async _revealRadioPanelInsideContext({ focusTarget = null } = {}) {
const contextPanel = document.getElementById('global-context-panel');
const scroller = contextPanel?.querySelector('.global-context-panel-inner');
const scroller =
(document.documentElement?.dataset.uiTheme === 'cyber' &&
contextPanel?.querySelector('.cyber-panel-body')) ||
contextPanel?.querySelector('.global-context-panel-inner');
if (
!contextPanel ||
contextPanel.classList.contains('collapsed') ||
@@ -154,10 +160,12 @@ export class RadioControls {
return moved;
}
/** Keep the Context header Radio shortcut truthful for its current route. */
/** Keep the launcher truthful for the active theme and Context route. */
_syncContextRadioLauncherState() {
if (this.destroyed || !this._contextRadioToggleBtn) return;
const contextPanel = document.getElementById('global-context-panel');
const contextPanel = globalThis.document?.getElementById(
'global-context-panel',
);
const contextExpanded = Boolean(
contextPanel && !contextPanel.classList.contains('collapsed'),
);
@@ -177,6 +185,8 @@ export class RadioControls {
this._contextRadioToggleBtn.title = label;
return;
}
// This launcher toggles the compact player even when the detailed panel
// is open elsewhere. Its ARIA state must describe that same disclosure.
const compactOpen = Boolean(
this._contextRadioDock?.classList.contains('disclosure-open'),
);
+17
View File
@@ -0,0 +1,17 @@
/** Keep Radio in Context in every theme, reusing the same playback controls. */
export function syncRadioPanelPlacement(doc) {
if (!doc?.getElementById) return;
const panel = doc.getElementById('radio-panel');
const context = doc.getElementById('global-context-panel');
const dock = doc.getElementById('context-radio-dock');
const mini = doc.getElementById('context-radio-mini');
const parent =
context?.querySelector('.cyber-panel-body') ||
context?.querySelector('.global-context-panel-inner');
const header = context?.querySelector('.panel-header');
if (!panel || !parent || !header || !dock || !mini) return;
if (panel.parentElement !== parent) parent.append(panel);
if (dock.parentElement !== header)
header.insertBefore(dock, header.querySelector('.panel-collapse-btn'));
if (mini.parentElement !== dock) dock.append(mini);
}
+105
View File
@@ -0,0 +1,105 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { syncRadioPanelPlacement } from './radioPanelPlacement.js';
function node() {
return {
children: [],
selectors: {},
parentElement: null,
moves: 0,
querySelector(selector) {
return this.selectors[selector] || null;
},
append(child) {
this.insertBefore(child, null);
},
insertBefore(child, before) {
if (child.parentElement) {
const siblings = child.parentElement.children;
siblings.splice(siblings.indexOf(child), 1);
}
const index = before
? this.children.indexOf(before)
: this.children.length;
this.children.splice(index, 0, child);
child.parentElement = this;
child.moves++;
},
};
}
test('all themes keep the same Radio controls nested in Context', () => {
const panel = node(),
context = node(),
rail = node(),
dock = node(),
mini = node();
const inner = node(),
contextHeader = node(),
radioHeader = node();
const contextCollapse = node(),
radioCollapse = node();
context.selectors['.global-context-panel-inner'] = inner;
const body = node();
context.selectors['.cyber-panel-body'] = body;
context.selectors['.panel-header'] = contextHeader;
panel.selectors['.panel-header'] = radioHeader;
contextHeader.selectors['.panel-collapse-btn'] = contextCollapse;
radioHeader.selectors['.panel-collapse-btn'] = radioCollapse;
inner.append(panel);
contextHeader.append(dock);
contextHeader.append(contextCollapse);
radioHeader.append(radioCollapse);
dock.append(mini);
const nodes = {
'radio-panel': panel,
'global-context-panel': context,
'right-context-rail': rail,
'context-radio-dock': dock,
'context-radio-mini': mini,
};
const doc = {
documentElement: { dataset: {} },
getElementById: (id) => nodes[id],
};
for (let cycle = 0; cycle < 3; cycle++) {
doc.documentElement.dataset.uiTheme = 'cyber';
syncRadioPanelPlacement(doc);
assert.equal(panel.parentElement, body);
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
assert.equal(mini.parentElement, dock);
const moves = [panel.moves, dock.moves, mini.moves];
syncRadioPanelPlacement(doc);
assert.deepEqual(
[panel.moves, dock.moves, mini.moves],
moves,
'no mutation feedback loop',
);
delete doc.documentElement.dataset.uiTheme;
syncRadioPanelPlacement(doc);
assert.equal(panel.parentElement, body);
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
assert.equal(mini.parentElement, dock);
}
// Recover a hierarchy left by the previous Cyber implementation without
// recreating the player, changing playback, or mutating collapse state.
rail.append(panel);
radioHeader.insertBefore(dock, radioCollapse);
panel.append(mini);
doc.documentElement.dataset.uiTheme = 'cyber';
syncRadioPanelPlacement(doc);
assert.equal(panel.parentElement, body);
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
assert.equal(mini.parentElement, dock);
});
test('placement is inert before the complete shell exists', () => {
assert.doesNotThrow(() => syncRadioPanelPlacement(null));
assert.doesNotThrow(() =>
syncRadioPanelPlacement({
documentElement: { dataset: { uiTheme: 'cyber' } },
getElementById: () => null,
}),
);
});
+30
View File
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8');
// The rail's measuring pass lifts these panel scrollers to their natural
// height, which clamps their scroll offset to zero. Only scrollers tagged
// `data-rail-scroller` get their offset restored afterwards, so every lifted
// scroller must carry the tag or a layout pass snaps it back to the top.
test('every scroller the rail measuring pass lifts restores its scroll offset', () => {
const css = read('./styles/layers.css');
const templates = [
read('./templates/context.html'),
read('./templates/layer-panels.html'),
].join('\n');
const lifted = [
...css.matchAll(
/#right-context-rail\[data-rail-measuring\]\s*>\s*\[data-panel-id\]:not\(\.collapsed\)\s*>\s*\.([a-z-]+)/g,
),
].map((match) => match[1]);
assert.ok(lifted.length > 0, 'measuring CSS lists lifted scrollers');
for (const className of lifted) {
const tag = templates.match(
new RegExp(`<div class="${className}"[^>]*>`),
)?.[0];
if (!tag) continue;
assert.match(tag, /data-rail-scroller/, `${className} restores its scroll`);
}
});
+27 -2
View File
@@ -7,6 +7,8 @@ import {
shouldHideCollapsedRightPanels,
} from './panelRailGeometry.js';
import { displayPanelScroller } from './displayPanelScroll.js';
const pendingCollapseRetries = new WeakSet();
/**
@@ -56,6 +58,28 @@ export function layoutRightPanelRail({
onCollapse(panel);
}
}
// 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') {
const expanded = panels.filter(
(panel) => !panel.classList.contains('collapsed'),
);
const owner =
expanded.find((panel) => panel.id === preferredPanelId) ||
expanded.find((panel) => panel.contains(documentRef?.activeElement)) ||
expanded[0];
for (const panel of expanded) {
if (panel === owner) continue;
panel.classList.add('collapsed', 'cyber-accordion-collapsed');
onCollapse(panel);
}
} else {
for (const panel of panels) {
if (!panel.classList.contains('cyber-accordion-collapsed')) continue;
panel.classList.remove('collapsed', 'cyber-accordion-collapsed');
onCollapse(panel);
}
}
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
const hasExpandedPanel = panels.some(
(panel) =>
@@ -276,10 +300,11 @@ export function layoutRightPanelRail({
stack.dataset.expandedCount = String(expandedPanels.length);
if (displayPanel && expandedPanels.includes(displayPanel)) {
const scroller = displayPanelScroller(displayPanel);
const maxScrollTop = Math.max(
0,
displayPanel.scrollHeight - displayPanel.clientHeight,
scroller.scrollHeight - scroller.clientHeight,
);
displayPanel.scrollTop = Math.min(displayScrollTop, maxScrollTop);
scroller.scrollTop = Math.min(displayScrollTop, maxScrollTop);
}
}
+15
View File
@@ -17,6 +17,21 @@ export function readShellElements(document = globalThis.document) {
_hudBtn: document.getElementById('hud-toggle'),
_hudLayoutRow: document.getElementById('hud-layout-row'),
_hudLayoutSelect: document.getElementById('hud-layout-select'),
_cyberSonarBtn: document.getElementById('cyber-sonar-toggle'),
_cyberSonarRings: document.getElementById('cyber-sonar-rings'),
_cyberSonarRingsValue: document.getElementById('cyber-sonar-rings-value'),
_cyberSonarRange: document.getElementById('cyber-sonar-range'),
_cyberSonarRangeValue: document.getElementById('cyber-sonar-range-value'),
_cyberSonarIntensity: document.getElementById('cyber-sonar-intensity'),
_cyberSonarIntensityValue: document.getElementById(
'cyber-sonar-intensity-value',
),
_cyberSonarOpacity: document.getElementById('cyber-sonar-opacity'),
_cyberSonarOpacityValue: document.getElementById(
'cyber-sonar-opacity-value',
),
_cyberSonarSector: document.getElementById('cyber-sonar-sector'),
_cyberSonarSectorValue: document.getElementById('cyber-sonar-sector-value'),
_detectionSliderRow: document.getElementById('detection-slider-row'),
_detectionDensitySlider: document.getElementById(
'detection-density-slider',
+8
View File
@@ -762,6 +762,14 @@ export class ShellFacade {
return this._visualSettings._setHudVariant(...arguments);
}
_setCyberSonarEnabled(enabled) {
return this._visualSettings._setCyberSonarEnabled(...arguments);
}
_setCyberSonarSetting(name, value) {
return this._visualSettings._setCyberSonarSetting(...arguments);
}
/**
* Keeps both responsive panel lanes and Cockpit's utility strip on the same
* measured layout commit. HUD visibility transitions can outlive the first
+34 -3
View File
@@ -362,11 +362,20 @@
}
#pp-toggles:not(.collapsed) > .pp-header-row,
#pp-toggles:not(.collapsed) > .pp-toggle-group,
#pp-toggles:not(.collapsed) > #clean-view-toggle {
#pp-toggles:not(.collapsed) > .pp-panel-body > .pp-toggle-group,
#pp-toggles:not(.collapsed) > .pp-panel-body > #clean-view-toggle {
width: 100%;
}
.pp-panel-body,
.cyber-panel-body {
display: contents;
}
.panel-collapsible.collapsed > .global-context-panel-inner > .cyber-panel-body {
display: none;
}
.pp-toggle-btn {
display: flex;
align-items: center;
@@ -683,6 +692,28 @@
min-width: 200px;
}
.cyber-sonar-control {
display: none;
}
:root[data-ui-theme='cyber'] .cyber-sonar-control {
display: flex;
}
.cyber-sonar-toggle-row,
.cyber-sonar-setting {
min-width: 200px;
}
.cyber-sonar-toggle-row .pp-mode-btn {
margin-left: auto;
min-width: 48px;
}
:root[data-cyber-sonar='off'] .cyber-sonar-setting {
display: none;
}
/* Map Source tiles live with Visual Presets in the bottom command tray. */
.map-source-section {
display: grid;
@@ -1055,7 +1086,7 @@ body.ui-clean-view #scene-runtime.active {
/* Display owns the constrained desktop scroll surface. Keeping Parameters as
* a second scroll container traps wheel/touch input at its boundary and lets
* the flex column compress newly added preset rows below the visible panel. */
#pp-toggles:not(.collapsed) > #param-slider-panel.active {
#pp-toggles:not(.collapsed) > .pp-panel-body > #param-slider-panel.active {
flex: 0 0 auto;
max-height: none;
overflow-y: visible;
File diff suppressed because it is too large. Load diff
+129
View File
@@ -899,3 +899,132 @@
padding-top: 3px;
overflow-wrap: anywhere;
}
/* ── Local RTL-SDR card (FM + Local ADS-B receiver) ── */
.sdr-radio-card {
display: grid;
gap: 7px;
margin-top: 3px;
padding: 9px;
border: 1px solid rgba(255, 170, 60, 0.28);
border-radius: 7px;
background: linear-gradient(
180deg,
rgba(31, 18, 4, 0.58),
rgba(4, 20, 24, 0.72)
);
}
.sdr-radio-heading,
.sdr-radio-actions,
.sdr-mode-row,
.sdr-gain-row,
.sdr-frequency-row,
.sdr-seek-row {
display: flex;
align-items: center;
gap: 6px;
}
.sdr-radio-heading {
justify-content: space-between;
}
.sdr-radio-heading strong {
color: #ffd38a;
font: 9px/1 var(--font-mono);
letter-spacing: 0.1em;
}
#sdr-connection-state {
color: #9e865f;
font: 7px/1 var(--font-mono);
letter-spacing: 0.1em;
}
#sdr-connection-state.active {
color: #74f7c4;
}
.sdr-radio-card > p {
margin: 0;
color: rgba(213, 230, 225, 0.58);
font: 7px/1.4 var(--font-mono);
}
.sdr-radio-card button,
.sdr-radio-card select,
.sdr-radio-card input[type='number'] {
min-height: 28px;
border: 1px solid rgba(255, 183, 82, 0.28);
border-radius: 5px;
color: #ffd79a;
background: rgba(38, 25, 8, 0.52);
font: 8px/1 var(--font-mono);
}
.sdr-radio-card button {
padding: 6px 8px;
cursor: pointer;
}
.sdr-radio-card button:hover:not(:disabled),
.sdr-radio-card button:focus-visible:not(:disabled),
.sdr-radio-card button.active {
border-color: #ffd38a;
color: #fff;
outline: none;
background: rgba(130, 83, 18, 0.28);
}
.sdr-radio-card button:disabled,
.sdr-radio-card select:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.sdr-radio-actions > *,
.sdr-mode-row > *,
.sdr-seek-row > * {
flex: 1;
}
.sdr-gain-row label,
.sdr-frequency-row label {
color: #9e865f;
font: 7px/1 var(--font-mono);
letter-spacing: 0.08em;
}
.sdr-gain-row select {
flex: 1;
min-width: 0;
padding: 5px 7px;
}
.sdr-adsb-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px 8px;
color: #9e865f;
font: 7px/1.3 var(--font-mono);
letter-spacing: 0.08em;
}
.sdr-adsb-stats[hidden] {
display: none;
}
.sdr-adsb-stats output {
color: #ffd79a;
font: 9px/1 var(--font-mono);
}
.sdr-frequency-row input[type='number'] {
width: 78px;
min-width: 0;
padding: 5px 7px;
}
.sdr-frequency-row button {
margin-left: auto;
}
.sdr-radio-card .radio-volume-row {
color: #9e865f;
}
.sdr-radio-card .radio-playback-state {
color: #ddbe86;
}
.sdr-radio-card .radio-playback-state.error {
color: #ff9d8f;
}
.sdr-feed-status {
color: #9e865f;
font: 8px/1.3 var(--font-mono);
letter-spacing: 0.08em;
overflow-wrap: anywhere;
}
.sdr-feed-status[hidden] {
display: none;
}
+2 -1
View File
@@ -259,8 +259,9 @@
}
@media (max-width: 520px) {
/* 16px keeps the Radio broadcast waves (15px above the logo) on-screen. */
#title-bar {
top: 14px;
top: 16px;
left: 16px;
}
+44
View File
@@ -1,5 +1,49 @@
<!-- Aircraft first-person view. It can be entered from the visible view switcher. -->
<section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden>
<!-- One optical coordinate system: every targeting mark shares this center.
Decorative only; telemetry and interactive panels remain separate. -->
<svg class="cockpit-frame-lines" viewBox="0 0 1200 720" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<g fill="none" stroke="currentColor">
<path d="M42 106H234L266 126H334 M866 126H934L966 106H1158 M42 114H226L258 136H318 M882 136H942L974 114H1158"/>
<path d="M42 616H226L254 598H320 M880 598H946L974 616H1158 M42 624H238L266 606H330 M870 606H934L962 624H1158"/>
<path d="M322 650H878L858 622H728L708 634H492L472 622H342Z M348 659H852"/>
<path d="M72 160H144L158 174V186 M1056 160H1128L1142 174V186 M72 562H144L158 548V536 M1056 562H1128L1142 548V536"/>
</g>
<path class="cockpit-frame-warning" d="M338 645H476L490 654H710L724 645H862" fill="none"/>
<path class="cockpit-frame-meter" d="M372 642H418 M782 642H828"/>
</svg>
<div class="cockpit-scan-array" aria-hidden="true">
<svg class="cockpit-target-geometry" viewBox="0 0 600 600" fill="none" focusable="false">
<g class="cockpit-target-hairline">
<circle cx="300" cy="300" r="276"/>
<circle class="cockpit-target-drift" cx="300" cy="300" r="250" stroke-dasharray="1 13"/>
<circle cx="300" cy="300" r="164"/>
<path d="M190 237V363 M410 237V363 M99 227H139 M461 227H501 M99 373H139 M461 373H501"/>
<path d="M86 113H118L134 129 M514 113H482L466 129 M86 487H118L134 471 M514 487H482L466 471"/>
</g>
<g class="cockpit-target-side-arcs">
<path d="M70 167A266 266 0 0 0 70 433 M530 167A266 266 0 0 1 530 433"/>
</g>
<g class="cockpit-target-ticks">
<path d="M166 125A220 220 0 0 1 434 125 M166 475A220 220 0 0 0 434 475"/>
</g>
<g class="cockpit-target-dots">
<path d="M180 130A208 208 0 0 1 420 130 M180 470A208 208 0 0 0 420 470"/>
</g>
<g class="cockpit-target-warning-dots">
<path d="M172 112A227 227 0 0 1 428 112 M172 488A227 227 0 0 0 428 488"/>
</g>
<g class="cockpit-target-core">
<!-- One outer ring and a spaced rotor leave the aiming center open. -->
<circle class="cockpit-target-orbit" cx="300" cy="300" r="132" stroke-dasharray="186 21"/>
<circle class="cockpit-target-rotor" cx="300" cy="300" r="104" stroke-dasharray="128 35" transform="rotate(9 300 300)"/>
<path class="cockpit-target-spokes" d="M206 206L250 250 M350 350L394 394 M394 206L350 250 M250 350L206 394"/>
<path d="M168 291H180V309H168 M432 291H420V309H432"/>
<circle cx="206" cy="206" r="3"/><circle cx="394" cy="206" r="3"/>
<circle cx="206" cy="394" r="3"/><circle cx="394" cy="394" r="3"/>
</g>
</svg>
</div>
<div class="cockpit-visor-shell" aria-hidden="true">
<div class="cockpit-roll-arc">
<span style="--slot:-3;--depth:3">−30</span>
+79 -4
View File
@@ -2,7 +2,7 @@
<aside id="right-context-rail">
<div id="weather-panel" class="panel-collapsible" data-panel-id="weather-panel" hidden>
<div class="panel-glow"></div>
<div class="weather-panel-inner">
<div class="weather-panel-inner" data-rail-scroller>
<div class="panel-header">
<span class="panel-title">WEATHER</span>
<span id="weather-panel-count" aria-label="Active weather products">0</span>
@@ -14,7 +14,7 @@
</div>
<div id="recent-imagery-panel" class="panel-collapsible" data-panel-id="recent-imagery-panel" hidden>
<div class="panel-glow"></div>
<div class="recent-imagery-panel-inner">
<div class="recent-imagery-panel-inner" data-rail-scroller>
<div class="panel-header">
<span class="panel-title">RECENT IMAGERY</span>
<span id="recent-imagery-panel-count" aria-label="Imagery days in the box"></span>
@@ -26,7 +26,7 @@
</div>
<div id="global-context-panel" class="panel-collapsible collapsed" data-panel-id="global-context-panel">
<div class="panel-glow"></div>
<div class="global-context-panel-inner">
<div class="global-context-panel-inner" data-rail-scroller>
<div class="panel-header">
<span class="panel-title">CONTEXT</span>
<span class="panel-divider"></span>
@@ -62,6 +62,7 @@
</div>
<button class="panel-collapse-btn" data-collapse-target="global-context-panel" title="Expand panel">▶</button>
</div>
<div class="cyber-panel-body" data-rail-scroller>
<div class="global-context-modes" role="tablist" aria-label="Context mode">
<button id="global-context-flights-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-flights-view" aria-label="CONTACTS" title="Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.">
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
@@ -109,7 +110,7 @@
</div>
</div>
</section>
<section id="radio-panel" class="panel-collapsible collapsed" data-panel-id="radio-panel" aria-label="Internet radio companion">
<section id="radio-panel" class="panel-collapsible collapsed" data-panel-id="radio-panel" aria-label="Internet radio and local SDR">
<div class="radio-panel-inner">
<div class="panel-header">
<span class="panel-title">RADIO</span>
@@ -162,8 +163,82 @@
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">DIRECTORY: RADIO BROWSER</a>
</div>
<p class="radio-privacy">Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
<div id="sdr-radio-card" class="sdr-radio-card" role="group" aria-label="Local RTL-SDR receiver">
<div class="sdr-radio-heading">
<strong>LOCAL RTL-SDR</strong>
<span id="sdr-connection-state">NO USB</span>
</div>
<p>Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.</p>
<div class="sdr-radio-actions">
<button id="sdr-connect-btn" class="panel-layer-toggle" type="button" aria-pressed="false">CONNECT</button>
<button id="sdr-locate-btn" type="button" title="Use your location for faster ADS-B position decoding">LOCATE</button>
<button id="sdr-change-device-btn" type="button" title="Choose a different RTL-SDR or receiver channel">CHANGE DEVICE</button>
</div>
<div class="sdr-mode-row" role="group" aria-label="RTL-SDR receiver mode">
<button id="sdr-mode-fm-btn" class="active" type="button" aria-pressed="true">FM</button>
<button id="sdr-mode-adsb-btn" type="button" aria-pressed="false">ADS-B · 1090</button>
</div>
<div class="sdr-gain-row">
<label for="sdr-gain-select">GAIN</label>
<select id="sdr-gain-select" aria-label="RTL-SDR tuner gain for the current mode">
<option value="auto">AUTO</option>
<option value="0.0">0.0 dB</option>
<option value="0.9">0.9 dB</option>
<option value="1.4">1.4 dB</option>
<option value="2.7">2.7 dB</option>
<option value="3.7">3.7 dB</option>
<option value="7.7">7.7 dB</option>
<option value="8.7">8.7 dB</option>
<option value="12.5">12.5 dB</option>
<option value="14.4">14.4 dB</option>
<option value="15.7">15.7 dB</option>
<option value="16.6">16.6 dB</option>
<option value="19.7">19.7 dB</option>
<option value="20.7">20.7 dB</option>
<option value="22.9">22.9 dB</option>
<option value="25.4">25.4 dB</option>
<option value="28.0">28.0 dB</option>
<option value="29.7">29.7 dB</option>
<option value="32.8">32.8 dB</option>
<option value="33.8">33.8 dB</option>
<option value="36.4">36.4 dB</option>
<option value="37.2">37.2 dB</option>
<option value="38.6">38.6 dB</option>
<option value="40.2">40.2 dB</option>
<option value="42.1">42.1 dB</option>
<option value="43.4">43.4 dB</option>
<option value="43.9">43.9 dB</option>
<option value="44.5">44.5 dB</option>
<option value="48.0">48.0 dB</option>
<option value="49.6">49.6 dB</option>
</select>
</div>
<div id="sdr-adsb-stats" class="sdr-adsb-stats" role="status" aria-live="off" hidden>
<span><output id="sdr-stat-rate">--</output> MSG/S</span>
<span><output id="sdr-stat-heard">0</output> HEARD</span>
<span><output id="sdr-stat-positioned">0</output> POSITIONED</span>
<span>IQ <output id="sdr-stat-iq">--</output></span>
</div>
<div class="sdr-frequency-row">
<label for="sdr-frequency-input">FM MHZ</label>
<input id="sdr-frequency-input" type="number" min="87.5" max="108" step="0.1" value="98.5" inputmode="decimal" />
<button id="sdr-tune-btn" type="button">TUNE</button>
</div>
<div class="sdr-seek-row" role="group" aria-label="Seek broadcast FM stations">
<button id="sdr-seek-back-btn" type="button" disabled>◀ SEEK</button>
<button id="sdr-seek-forward-btn" type="button" disabled>SEEK ▶</button>
</div>
<label class="radio-volume-row" for="sdr-volume">
<span>SDR VOL</span>
<input id="sdr-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Local SDR volume" />
<output id="sdr-volume-value" class="gev-slider-value" for="sdr-volume">80%</output>
</label>
<div id="sdr-status" class="radio-playback-state" role="status" aria-live="polite">Connect an RTL-SDR to begin.</div>
<div id="sdr-feed-status" class="sdr-feed-status" role="status" aria-live="off" hidden></div>
</div>
</div>
</section>
</div>
</div>
</div>
</aside>
+33
View File
@@ -5,6 +5,7 @@
<span class="panel-divider" aria-hidden="true"></span>
<button class="panel-collapse-btn pp-collapse-btn" data-collapse-target="pp-toggles" title="Collapse panel">&#x25C0;</button>
</div>
<div class="pp-panel-body" data-rail-scroller>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)">
<span class="pp-icon">&#x25CE;</span>
@@ -16,8 +17,39 @@
<option value="tactical">Tactical</option>
<option value="operator">Operator</option>
<option value="minimal">Minimal</option>
<option value="cyber">Cyber</option>
</select>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-toggle-row" id="cyber-sonar-row">
<span class="pp-slider-mini-label">Sonar</span>
<button class="pp-mode-btn active" id="cyber-sonar-toggle" title="Cyber sonar contact scanner" aria-label="Cyber sonar contact scanner" aria-pressed="true">ON</button>
</div>
<output id="cyber-sonar-status" class="cyber-sonar-control" role="status"></output>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-rings-row">
<label class="pp-slider-mini-label" for="cyber-sonar-rings">Rings</label>
<input type="range" class="pp-slider" id="cyber-sonar-rings" min="3" max="12" step="1" value="8" aria-label="Sonar ring count" />
<span class="pp-slider-value" id="cyber-sonar-rings-value">8</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-range-row">
<label class="pp-slider-mini-label" for="cyber-sonar-range">Range</label>
<input type="range" class="pp-slider" id="cyber-sonar-range" min="60" max="120" step="1" value="100" aria-label="Sonar ring range" />
<span class="pp-slider-value" id="cyber-sonar-range-value">100%</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-intensity-row">
<label class="pp-slider-mini-label" for="cyber-sonar-intensity">Power</label>
<input type="range" class="pp-slider" id="cyber-sonar-intensity" min="0" max="100" step="1" value="70" aria-label="Sonar intensity" />
<span class="pp-slider-value" id="cyber-sonar-intensity-value">70%</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-opacity-row">
<label class="pp-slider-mini-label" for="cyber-sonar-opacity">Opacity</label>
<input type="range" class="pp-slider" id="cyber-sonar-opacity" min="35" max="100" step="1" value="84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" />
<span class="pp-slider-value" id="cyber-sonar-opacity-value">84%</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-sector-row">
<label class="pp-slider-mini-label" for="cyber-sonar-sector">Sector</label>
<input type="range" class="pp-slider" id="cyber-sonar-sector" min="8" max="60" step="1" value="24" aria-label="Sonar sector width" />
<span class="pp-slider-value" id="cyber-sonar-sector-value">24°</span>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="detection-toggle" title="Detection Overlay (D)" aria-label="Detection overlay" aria-pressed="false">
@@ -146,4 +178,5 @@
</div>
</div>
</div>
</div>
<button id="clean-view-exit" title="Return UI controls">EXIT CLEAN VIEW</button>
+3 -1
View File
@@ -15,12 +15,13 @@
<!-- CCTV Coverage Panel -->
<div id="cctv-panel" class="panel-collapsible collapsed" data-panel-id="cctv-panel">
<div class="panel-glow"></div>
<div class="cctv-panel-inner">
<div class="cctv-panel-inner" data-rail-scroller>
<div class="panel-header">
<span class="panel-title">CCTV</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel">+</button>
</div>
<div class="cyber-panel-body" data-rail-scroller>
<div id="cctv-frame-wrap" class="cctv-frame-wrap">
<img id="cctv-frame" alt="CCTV feed frame" />
<canvas id="cctv-video" class="cctv-frame" hidden></canvas>
@@ -66,6 +67,7 @@
</div>
<div class="cctv-summary-label">SCENE SUMMARY</div>
<div id="cctv-summary">Enable CCTV to start camera-linked intelligence summaries.</div>
</div>
</div>
</div>
+84 -7
View File
@@ -32,6 +32,13 @@ import {
shareCacheNeedsHeal,
shareableDetectionState,
} from '../contactsDetectionPolicy.js';
import {
applyCyberSonarSettings,
isCyberSonarEnabled,
readCyberSonarSettings,
setCyberSonarEnabled,
} from '../cyberSonar.js';
import { cyberVisualDefaultsForHudTransition } from '../hudLayouts.js';
const DETECTION_ALLOCATION_STORAGE_KEY = 'gev:detection-allocation:v1';
/** Own visual preferences, detection overrides and display-control state. */
@@ -602,19 +609,87 @@ export class VisualSettings {
this._syncShareState();
}
_setHudVariant(variantName) {
_setHudVariant(variantName, { applyVisualDefaults = false } = {}) {
if (!variantName) return;
const previousVariant = this.hud.getVariant();
this.hud.setVariant(variantName);
if (
this._hudLayoutSelect &&
this._hudLayoutSelect.value !== this.hud.getVariant()
) {
this._hudLayoutSelect.value = this.hud.getVariant();
const nextVariant = this.hud.getVariant();
if (this._hudLayoutSelect && this._hudLayoutSelect.value !== nextVariant) {
this._hudLayoutSelect.value = nextVariant;
}
const visualDefaults = cyberVisualDefaultsForHudTransition(
previousVariant,
nextVariant,
{ explicit: applyVisualDefaults },
);
if (visualDefaults) this._applyCyberVisualDefaults(visualDefaults);
this._syncCyberSonarControl();
this._syncShareState();
this._scheduleAdaptivePanelLayout({ settle: true });
}
_applyCyberVisualDefaults({ style, ironbow }) {
this.setStyle(style, {
applyPreset: false,
revealParameters: false,
});
const thermalUniforms = this.stages?.thermal?.uniforms;
if (!thermalUniforms || thermalUniforms.palette === undefined) return;
thermalUniforms.palette = ironbow;
this._updateSliderPanel('thermal', { reveal: false });
this.services.governorRequestRender('cyber-visual-defaults');
}
_syncCyberSonarControl() {
if (!this._cyberSonarBtn) return;
const enabled = isCyberSonarEnabled();
const settings = applyCyberSonarSettings(readCyberSonarSettings());
this._cyberSonarBtn.classList.toggle('active', enabled);
this._cyberSonarBtn.setAttribute('aria-pressed', String(enabled));
this._cyberSonarBtn.textContent = enabled ? 'ON' : 'OFF';
for (const [input, output, value, suffix] of [
[this._cyberSonarRings, this._cyberSonarRingsValue, settings.rings, ''],
[this._cyberSonarRange, this._cyberSonarRangeValue, settings.range, '%'],
[
this._cyberSonarIntensity,
this._cyberSonarIntensityValue,
settings.intensity,
'%',
],
[
this._cyberSonarOpacity,
this._cyberSonarOpacityValue,
settings.opacity,
'%',
],
[
this._cyberSonarSector,
this._cyberSonarSectorValue,
settings.sector,
'°',
],
]) {
if (input) input.value = String(value);
if (output) output.textContent = `${value}${suffix}`;
}
}
_setCyberSonarEnabled(enabled = !isCyberSonarEnabled()) {
const next = setCyberSonarEnabled(!!enabled);
this._syncCyberSonarControl();
this.services.governorRequestRender('cyber-sonar-toggle');
return next;
}
_setCyberSonarSetting(name, value) {
const settings = readCyberSonarSettings();
if (!Object.hasOwn(settings, name)) return settings;
const next = applyCyberSonarSettings({ ...settings, [name]: value });
this._syncCyberSonarControl();
this.services.governorRequestRender(`cyber-sonar-${name}`);
return next;
}
_applyStylePresetDefaults(styleName) {
const { governorRequestRender } = this.services;
const preset = STYLE_PRESET_DEFAULTS[styleName];
@@ -656,7 +731,9 @@ export class VisualSettings {
this._setSharpenEnabled(sharpenInput.enabled);
}
if (preset.hudVariant) {
// Cyber is an explicit shell choice, independent of the imagery filter.
// Scene/share restoration still applies its own HUD through _setHudVariant.
if (preset.hudVariant && this.hud.getVariant() !== 'cyber') {
this._setHudVariant(preset.hudVariant);
}
if (typeof preset.hudVisible === 'boolean') {