mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
commit
9ba6f96d87
204 files changed
+24525
-314
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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*\}/,
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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 || [])
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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'),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">◀</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">◎</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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in new issue
Block a user