feat(hud): Cyber HUD theme with GPU contact sonar (#706)

Add the opt-in Cyber HUD layout with coordinated map and cockpit panel
styling, GPU-only contact sonar (rings, range, power, opacity, sector) with
native-rendering fallback, and set_cyber_sonar voice controls. Sonar visual
style inspired by kk376's tactical naval sonar (#214).

Co-authored-by: Sameh Khamis <[email protected]>
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-09-23 20:31:56 -07:00
1 parent f913066cf2
commit 513dc87a0e
74 files changed
+7320 -90

No files matched your search

+2
View File
@@ -1,5 +1,6 @@
import { LayerLifecycle } from '../data/lifecycle.js';
import { LayerPresentation } from './layerPresentation.js';
import { createCyberSonarScene } from '../cyberSonarScene.js';
/** Register the application layer catalog before allowing state restoration. */
export function createApplicationData({
scene: { viewer, mapStackController },
@@ -55,6 +56,7 @@ export function createApplicationData({
}
presentation.mount(document.getElementById('data-toggles'));
styleManager.attachDataManager(dataManager);
defer(createCyberSonarScene(viewer, dataManager));
return { dataManager, catalog, presentation };
}
+3 -3
View File
@@ -342,11 +342,11 @@ test('Clear Selected Layers uses one adopted batch and discards Context restorat
test('Cockpit Display portal retains both scroll owners across round trips', () => {
const portal = CockpitDisplayPortal.toString();
assert.match(portal, /this\.standardScrollTop = standardPanel\?\.scrollTop \|\| 0/);
assert.match(portal, /this\.standardScrollTop =\s*displayPanelScroller\(standardPanel\)\?\.scrollTop \|\| 0/);
assert.match(portal, /this\.cockpitScrollTop = cockpitPanel\?\.scrollTop \|\| 0/);
assert.match(portal, /if \(!this\.active\)[\s\S]*?this\.standardScrollTop = standardPanel\.scrollTop/);
assert.match(portal, /if \(!this\.active\)[\s\S]*?this\.standardScrollTop =\s*displayPanelScroller\(standardPanel\)\.scrollTop/);
assert.match(portal, /if \(this\.active\)[\s\S]*?this\.cockpitScrollTop = cockpitPanel\.scrollTop/);
assert.match(portal, /this\.cockpitPanel\.scrollTop = this\.cockpitScrollTop[\s\S]*?this\.standardPanel\.scrollTop = this\.standardScrollTop/);
assert.match(portal, /this\.cockpitPanel\.scrollTop = this\.cockpitScrollTop[\s\S]*?displayPanelScroller\(this\.standardPanel\)\.scrollTop =\s*this\.standardScrollTop/);
});
test('Cockpit side surfaces behave as two single-expanded accordions', () => {
+44 -1
View File
@@ -17,6 +17,11 @@
* @param {number} [input.signalGap] Clearance above the briefing card.
* @param {number} [input.minTopFloor] Absolute ceiling for the strip, in px.
* @param {number} [input.minTopRatio] Viewport-relative ceiling, 0..1.
* @param {boolean} [input.signalCollapsed] Whether the briefing card is collapsed.
* @param {boolean} [input.utilityExpanded] Whether Display or Radio is expanded.
* @param {number} [input.bottomInset] Reserved viewport space below an expanded panel.
* @param {number} [input.signalHeight] Collapsed briefing header reserved below the utility.
* @param {boolean} [input.reserveViewportLane] Theme relocates the briefing below the expanded utility.
* @returns {{ top: number, maxHeight: number }}
*/
export function resolveCockpitUtilityAnchor({
@@ -29,13 +34,29 @@ export function resolveCockpitUtilityAnchor({
signalGap = 8,
minTopFloor = 96,
minTopRatio = 0.12,
signalCollapsed = false,
utilityExpanded = false,
bottomInset = 80,
signalHeight = 0,
reserveViewportLane = false,
}) {
const viewport = Math.max(0, Number(viewportHeight) || 0);
const minTop = Math.max(minTopFloor, viewport * minTopRatio);
const anchoredTop = Math.max(minTop, (Number(recBottom) || 0) + recGap);
const strip = Math.max(0, Number(stripHeight) || 0);
const signal = Number(signalTop);
const lowerBound = Number.isFinite(signal) ? signal : viewport;
const expandedLaneBottom = Math.max(
0,
viewport -
Math.max(0, Number(bottomInset) || 0) -
Math.max(0, Number(signalHeight) || 0),
);
const lowerBound =
reserveViewportLane && signalCollapsed && utilityExpanded
? expandedLaneBottom
: Number.isFinite(signal)
? signal
: viewport;
const clearedTop = lowerBound - signalGap - strip;
const top = Math.max(minTop, Math.min(anchoredTop, clearedTop));
const floor = Math.max(0, Number(collapsedHeight) || 0) || 50;
@@ -79,3 +100,25 @@ export function resolveCockpitUtilityLayout({
),
};
}
/** Shared Cyber panel slot, with footer controls above the lower frame (83vh). */
export function resolveCyberCockpitPanelLane({
viewportHeight,
top,
launcherHeight = 60,
signalHeight = 44,
contactHeight = 64,
}) {
const gap = 8;
const footerHeight = Math.max(
contactHeight + gap,
launcherHeight + signalHeight + gap * 2,
);
const bottom = viewportHeight * 0.82;
const panelHeight = Math.max(120, bottom - top - footerHeight);
return {
panelHeight,
utilityHeight: panelHeight + gap + launcherHeight,
signalTop: top + panelHeight + launcherHeight + gap * 2,
};
}
+78 -1
View File
@@ -4,11 +4,58 @@ import assert from 'node:assert/strict';
import {
resolveCockpitUtilityAnchor,
resolveCockpitUtilityLayout,
resolveCyberCockpitPanelLane,
} from './cockpitUtilityLayout.js';
// 1512x790, the height the strip used to collide with the briefing card at.
const desktop = { viewportHeight: 790, stripHeight: 107, collapsedHeight: 50 };
test('Cyber shared panel slot reserves both footer lanes above the decorative frame', () => {
for (const viewportHeight of [600, 673, 713, 986, 1376]) {
for (const signalHeight of [44, 76]) {
for (const contactHeight of [44, 84, 120]) {
const top = Math.max(164, Math.min(204, viewportHeight * 0.19));
const lane = resolveCyberCockpitPanelLane({ viewportHeight, top, signalHeight, contactHeight });
assert.ok(lane.panelHeight >= 120);
assert.ok(top + lane.panelHeight + 8 + contactHeight <= viewportHeight * 0.82 + .01);
assert.ok(lane.signalTop + signalHeight <= viewportHeight * 0.82 + .01);
assert.equal(lane.signalTop, top + lane.utilityHeight + 8);
}
}
}
});
test('Cyber keeps the Data Layers top while reserving briefing clearance', () => {
for (const viewportHeight of [673, 713, 986, 1376]) {
const leftTop = Math.max(164, Math.min(204, viewportHeight * 0.19));
const collapsed = resolveCockpitUtilityAnchor({
viewportHeight, stripHeight: 128, collapsedHeight: 60, recBottom: 0,
signalTop: leftTop + 128 + 28, minTopFloor: leftTop, minTopRatio: 0,
});
assert.equal(collapsed.top, leftTop);
assert.ok(collapsed.maxHeight >= 128);
const expanded = resolveCockpitUtilityAnchor({
viewportHeight, stripHeight: 700, collapsedHeight: 60, recBottom: 0,
signalTop: 255, minTopFloor: leftTop, minTopRatio: 0,
signalCollapsed: true, utilityExpanded: true, reserveViewportLane: true, signalHeight: 44,
});
assert.equal(expanded.top, leftTop);
assert.equal(expanded.top + expanded.maxHeight + 8 + 44 + 80, viewportHeight);
}
});
test('expanded utilities reserve the visible collapsed briefing header at every desktop height', () => {
for (const viewportHeight of [640, 720, 987, 1376]) {
for (const signalHeight of [48, 76, 100]) {
const { top, maxHeight } = resolveCockpitUtilityAnchor({
...desktop, viewportHeight, recBottom: 0, signalTop: 255,
signalCollapsed: true, utilityExpanded: true, signalHeight, reserveViewportLane: true,
});
assert.equal(top + maxHeight + 8, viewportHeight - 80 - signalHeight);
}
}
});
test('the strip hangs 12px under the REC readout when the briefing card leaves room', () => {
const { top } = resolveCockpitUtilityAnchor({ ...desktop, recBottom: 148.1, signalTop: 420 });
assert.equal(Number(top.toFixed(1)), 160.1);
@@ -54,6 +101,37 @@ test('a missing REC readout leaves the strip on the viewport ceiling', () => {
assert.equal(top, 96);
});
test('an expanded utility claims the vertical lane released by a collapsed briefing', () => {
const { top, maxHeight } = resolveCockpitUtilityAnchor({
...desktop,
recBottom: 148.1,
signalTop: 265.4,
signalCollapsed: true,
utilityExpanded: true,
reserveViewportLane: true,
});
assert.equal(Number(top.toFixed(1)), 160.1);
assert.equal(Number(maxHeight.toFixed(1)), 541.9);
});
test('non-relocating themes preserve the visible briefing boundary even when collapsed', () => {
const { top, maxHeight } = resolveCockpitUtilityAnchor({
...desktop, recBottom: 148.1, signalTop: 265.4,
signalCollapsed: true, utilityExpanded: true,
});
assert.equal(Number((top + maxHeight).toFixed(1)), 257.4);
});
test('a collapsed briefing still bounds the utility strip until a panel expands', () => {
const { maxHeight } = resolveCockpitUtilityAnchor({
...desktop,
recBottom: 148.1,
signalTop: 265.4,
signalCollapsed: true,
});
assert.equal(Number(maxHeight.toFixed(1)), 107);
});
test('keeps the collapsed sibling visible when both Cockpit utilities fit', () => {
assert.deepEqual(resolveCockpitUtilityLayout({
availableHeight: 320,
@@ -94,4 +172,3 @@ test('clamps malformed or short corridors to the minimum expanded height', () =>
expandedMaxHeight: 120,
});
});
+562
View File
@@ -0,0 +1,562 @@
/**
* Scanner design inspired by kk376's tactical naval sonar visual style:
* https://github.com/bilawalsidhu/gods-eye-view/pull/214
*
* This is a separate HUD/contact treatment, not a port of that PR's shader.
*/
export const CYBER_SONAR_PERIOD_MS = 5_200;
// Parked-scene wake-ups and shared overlays use the fleet cadence. Native
// contacts still compose after layer updates on each actual draw so owner
// color writes cannot flash through during camera/fleet animation.
export const CYBER_SONAR_RENDER_INTERVAL_MS = 80;
export const CYBER_SONAR_SWEEP_DEG = 24;
export const CYBER_SONAR_FEATHER_DEG = 10;
// The sector should still read as a clear acquisition pass, but contacts
// outside it must remain immediately legible against FLIR terrain. Icons and
// dots keep a strong operator-tunable opacity floor; labels retain an even
// brighter floor so typography never disappears between passes.
export const CYBER_SONAR_DIM_FACTOR = 0.84;
export const CYBER_SONAR_LABEL_DIM_FACTOR = 0.92;
export const CYBER_SONAR_DEFAULTS = Object.freeze({
rings: 8,
range: 100,
intensity: 70,
opacity: 84,
sector: 24,
});
const clamp01 = (value) => Math.min(1, Math.max(0, value));
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const primitiveColors = new WeakMap();
const sweepAnimations = new WeakMap();
/** Pin the compositor animation to the absolute clock used by contact sampling.
* A negative delay alone is relative to the animation's eventual start, which
* may be much later than theme initialization (or restart after HUD hiding).
* Cache the running animation: no per-frame style writes or animation queries.
*/
export function syncCyberSonarAnimation(
root = globalThis.document?.documentElement,
) {
if (!root) return;
const cached = sweepAnimations.get(root);
if (cached?.element.isConnected && cached.animation.playState !== 'idle')
return;
const element = root.querySelector?.('.hud-sonar');
const animation = element
?.getAnimations?.({ subtree: true })
.find((item) => item.animationName === 'cyber-sonar-sweep');
if (!animation) return;
// The document animation timeline and performance.now share the navigation
// time origin. Zero both offsets so delayed starts cannot shift the sector.
animation.effect.updateTiming({ delay: 0 });
animation.startTime = 0;
sweepAnimations.set(root, { element, animation });
}
const copyColor = (color) => {
if (!color || !Number.isFinite(color.alpha)) return null;
if (typeof color.clone === 'function') return color.clone();
return { ...color };
};
/** Read owner opacity before a hue-only restyle, never our composited opacity.
* An independently written color still wins, including real freshness changes.
*/
export function cyberSonarBaseAlpha(visual, property = 'color') {
const current = visual?.[property];
const state = primitiveColors.get(visual)?.[property];
const applied = state?.lastAppliedColor;
return applied &&
current &&
current.red === applied.red &&
current.green === applied.green &&
current.blue === applied.blue &&
current.alpha === applied.alpha
? state.base.alpha
: (current?.alpha ?? 1);
}
/** Apply the sector factor to a Cesium point/billboard without owning its base style. */
export function applyCyberSonarPrimitive(
visual,
factor,
active = true,
sampler,
property = 'color',
opaque = false,
) {
if (!visual) return;
const current = visual[property];
let slots = primitiveColors.get(visual);
let state = slots?.[property];
const ownsCurrentColor =
state &&
current &&
current.red === state.lastAppliedColor.red &&
current.green === state.lastAppliedColor.green &&
current.blue === state.lastAppliedColor.blue &&
current.alpha === state.lastAppliedColor.alpha;
if (!active) {
// An owner refresh between the last paint and teardown wins over our cache.
if (ownsCurrentColor)
visual[property] = state.undefinedBase
? undefined
: copyColor(state.base);
if (slots) delete slots[property];
return;
}
if (!current && property !== 'color') return;
const nextFactor = clamp01(factor);
// Cesium setters copy into a stable internal Color instead of retaining the
// assigned object. Compare its channels with our snapshot as well, so an
// owner's in-place style refresh is still recognized as a new base.
if (
ownsCurrentColor &&
Math.abs(nextFactor - state.lastAppliedFactor) <= 0.002 &&
state.floor === sampler?.floor
) {
return;
}
if (!ownsCurrentColor) {
if (!state) {
state = {
base: copyColor(current) || { red: 1, green: 1, blue: 1, alpha: 1 },
lastAppliedFactor: Number.NaN,
};
if (!slots) primitiveColors.set(visual, (slots = Object.create(null)));
slots[property] = state;
} else {
state.base.red = current?.red ?? 1;
state.base.green = current?.green ?? 1;
state.base.blue = current?.blue ?? 1;
state.base.alpha = current?.alpha ?? 1;
}
state.undefinedBase = current === undefined;
}
const next = state.lastAppliedColor || copyColor(state.base);
if (!next) return;
const tint =
property === 'outlineColor' || property === 'backgroundColor'
? state.base
: sampler
? sampler.tint(state.base, nextFactor, next)
: cyberSonarTintColor(state.base, nextFactor);
next.red = tint.red * (opaque ? nextFactor : 1);
next.green = tint.green * (opaque ? nextFactor : 1);
next.blue = tint.blue * (opaque ? nextFactor : 1);
// Keep models in their owner's opaque/translucent render pass. Dimming RGB
// avoids rebuilding model draw commands twice on every sector crossing.
next.alpha = state.base.alpha * (opaque ? 1 : nextFactor);
visual[property] = next;
state.appliedColor = visual[property];
state.lastAppliedColor = next;
state.lastAppliedFactor = nextFactor;
state.floor = sampler?.floor;
}
/** One settings read and one clock/viewport calculation per paint, not contact. */
export function createCyberSonarSampler(
width,
height,
now,
root = globalThis.document?.documentElement,
) {
if (now === undefined) now = globalThis.performance?.now?.() ?? 0;
const settings = readCyberSonarSettings(root);
const floor = settings.opacity / 100;
const radius = (Math.min(width * 0.4, height * 0.52) * settings.range) / 100;
const radiusSquared = radius * radius;
const angle = cyberSonarAngleDeg(now);
const feather = Math.min(CYBER_SONAR_FEATHER_DEG, settings.sector * 0.42);
return {
floor,
neutral: floor + (1 - floor) * 0.46,
at(x, y) {
if (
!Number.isFinite(x) ||
!Number.isFinite(y) ||
width <= 0 ||
height <= 0
)
return 1;
const dx = x - width / 2,
dy = y - height / 2;
const distanceSquared = dx * dx + dy * dy;
if (distanceSquared > radiusSquared) return floor;
if (distanceSquared < radiusSquared * 0.000625) return 1;
const delta =
((((Math.atan2(dx, -dy) * 180) / Math.PI - angle) % 360) + 360) % 360;
if (delta > settings.sector) return floor;
return (
floor + (1 - floor) * Math.min(1, (settings.sector - delta) / feather)
);
},
label(factor) {
return (1 + factor) * 0.5;
},
tint(base, factor, out) {
const coverage =
floor >= 0.9999 ? 1 : clamp01((factor - floor) / (1 - floor));
out.red = base.red * 0.28 + (0.62 + 0.38 * coverage) * 0.72;
out.green = base.green * 0.28 + (0.68 + 0.16 * coverage) * 0.72;
out.blue = base.blue * 0.28 + (0.72 + 0.12 * coverage) * 0.72;
return out;
},
};
}
/** Remaining delay before the next contact-projection refresh is due. */
export function cyberSonarFrameDelay(
lastPaintAt,
nowMs,
intervalMs = CYBER_SONAR_RENDER_INTERVAL_MS,
) {
if (
!Number.isFinite(lastPaintAt) ||
!Number.isFinite(nowMs) ||
!Number.isFinite(intervalMs) ||
intervalMs <= 0
) {
return 0;
}
return Math.max(0, intervalMs - (nowMs - lastPaintAt));
}
const datasetNumber = (root, key, fallback) => {
const value = Number(root?.dataset?.[key]);
return Number.isFinite(value) ? value : fallback;
};
export function readCyberSonarSettings(
root = globalThis.document?.documentElement,
) {
return {
rings: Math.round(
clamp(
datasetNumber(root, 'cyberSonarRings', CYBER_SONAR_DEFAULTS.rings),
3,
12,
),
),
range: Math.round(
clamp(
datasetNumber(root, 'cyberSonarRange', CYBER_SONAR_DEFAULTS.range),
60,
120,
),
),
intensity: Math.round(
clamp(
datasetNumber(
root,
'cyberSonarIntensity',
CYBER_SONAR_DEFAULTS.intensity,
),
0,
100,
),
),
opacity: Math.round(
clamp(
datasetNumber(root, 'cyberSonarOpacity', CYBER_SONAR_DEFAULTS.opacity),
35,
100,
),
),
sector: Math.round(
clamp(
datasetNumber(root, 'cyberSonarSector', CYBER_SONAR_DEFAULTS.sector),
8,
60,
),
),
};
}
export function applyCyberSonarSettings(
settings,
root = globalThis.document?.documentElement,
) {
if (!root?.dataset || !root?.style) return readCyberSonarSettings(root);
const next = {
...readCyberSonarSettings(root),
...(settings || {}),
};
// Ignore stale session input from the removed effect selector.
delete next.mode;
delete root.dataset.cyberSonarMode;
next.rings = Math.round(
clamp(Number(next.rings) || CYBER_SONAR_DEFAULTS.rings, 3, 12),
);
next.range = Math.round(
clamp(Number(next.range) || CYBER_SONAR_DEFAULTS.range, 60, 120),
);
next.intensity = Math.round(
clamp(
Number.isFinite(Number(next.intensity))
? Number(next.intensity)
: CYBER_SONAR_DEFAULTS.intensity,
0,
100,
),
);
next.opacity = Math.round(
clamp(
Number.isFinite(Number(next.opacity))
? Number(next.opacity)
: CYBER_SONAR_DEFAULTS.opacity,
35,
100,
),
);
next.sector = Math.round(
clamp(Number(next.sector) || CYBER_SONAR_DEFAULTS.sector, 8, 60),
);
root.dataset.cyberSonarRings = String(next.rings);
root.dataset.cyberSonarRange = String(next.range);
root.dataset.cyberSonarIntensity = String(next.intensity);
root.dataset.cyberSonarOpacity = String(next.opacity);
root.dataset.cyberSonarSector = String(next.sector);
root.style.setProperty('--cyber-sonar-ring-step', `${100 / next.rings}%`);
root.style.setProperty(
'--cyber-sonar-size-vw',
`${(80 * next.range) / 100}vw`,
);
root.style.setProperty(
'--cyber-sonar-size-vh',
`${(104 * next.range) / 100}vh`,
);
root.style.setProperty(
'--cyber-sonar-visual-intensity',
String(0.22 + 0.5 * (next.intensity / 100)),
);
root.style.setProperty(
'--cyber-sonar-sector-quarter',
`${next.sector * 0.25}deg`,
);
root.style.setProperty(
'--cyber-sonar-sector-middle',
`${next.sector * 0.6}deg`,
);
root.style.setProperty('--cyber-sonar-sector-width', `${next.sector}deg`);
return next;
}
const cyberSonarDimFactor = (root = globalThis.document?.documentElement) =>
readCyberSonarSettings(root).opacity / 100;
const cyberSonarLabelDimFactor = (
root = globalThis.document?.documentElement,
) => {
const contactFloor = cyberSonarDimFactor(root);
return Number((contactFloor + (1 - contactFloor) * 0.5).toFixed(4));
};
export function cyberSonarNeutralIntensity(
root = globalThis.document?.documentElement,
) {
const dimFactor = cyberSonarDimFactor(root);
return dimFactor + (1 - dimFactor) * 0.46;
}
export function cyberSonarTintColor(
baseColor,
contactIntensity,
root = globalThis.document?.documentElement,
) {
const dimFactor = cyberSonarDimFactor(root);
const remainingRange = 1 - dimFactor;
const coverage =
remainingRange <= 0.0001
? 1
: clamp01((contactIntensity - dimFactor) / remainingRange);
const target = {
red: 0.62 + 0.38 * coverage,
green: 0.68 + 0.16 * coverage,
blue: 0.72 + 0.12 * coverage,
};
const retain = 0.28;
return {
red: (baseColor?.red ?? 1) * retain + target.red * (1 - retain),
green: (baseColor?.green ?? 1) * retain + target.green * (1 - retain),
blue: (baseColor?.blue ?? 1) * retain + target.blue * (1 - retain),
alpha: baseColor?.alpha ?? 1,
};
}
const normalizedPhaseMs = (nowMs) => {
if (!Number.isFinite(nowMs)) return 0;
return (
((nowMs % CYBER_SONAR_PERIOD_MS) + CYBER_SONAR_PERIOD_MS) %
CYBER_SONAR_PERIOD_MS
);
};
/** Clockwise sweep heading, where 0 degrees is the top of the keyhole. */
export function cyberSonarAngleDeg(nowMs) {
return (normalizedPhaseMs(nowMs) / CYBER_SONAR_PERIOD_MS) * 360;
}
/** Negative CSS animation delay that synchronizes the sector to the shared clock. */
export function cyberSonarAnimationDelay(nowMs) {
return `${-normalizedPhaseMs(nowMs)}ms`;
}
/**
* Brightness/alpha multiplier for one screen-projected contact.
* Invalid projections fail open so a render edge case never hides a contact.
*/
export function cyberSonarIntensityAtScreenPoint(
x,
y,
viewportWidth,
viewportHeight,
nowMs,
root = globalThis.document?.documentElement,
) {
if (
![x, y, viewportWidth, viewportHeight, nowMs].every(Number.isFinite) ||
viewportWidth <= 0 ||
viewportHeight <= 0
) {
return 1;
}
const centerX = viewportWidth / 2;
const centerY = viewportHeight / 2;
const dx = x - centerX;
const dy = y - centerY;
const settings = readCyberSonarSettings(root);
const dimFactor = cyberSonarDimFactor(root);
const radius =
Math.min(viewportWidth * 0.4, viewportHeight * 0.52) *
(settings.range / 100);
const distance = Math.hypot(dx, dy);
if (distance > radius) return dimFactor;
if (distance < radius * 0.025) return 1;
const pointAngle = ((Math.atan2(dx, -dy) * 180) / Math.PI + 360) % 360;
const delta = (pointAngle - cyberSonarAngleDeg(nowMs) + 360) % 360;
const sweepDeg = settings.sector;
const featherDeg = Math.min(CYBER_SONAR_FEATHER_DEG, sweepDeg * 0.42);
if (delta > sweepDeg) return dimFactor;
const featherStart = sweepDeg - featherDeg;
const coverage =
delta <= featherStart ? 1 : clamp01((sweepDeg - delta) / featherDeg);
return dimFactor + (1 - dimFactor) * coverage;
}
/** Preserve label legibility while keeping it synchronized with the stronger contact sweep. */
export function cyberSonarLabelIntensity(
contactIntensity,
root = globalThis.document?.documentElement,
) {
const dimFactor = cyberSonarDimFactor(root);
const labelDimFactor = cyberSonarLabelDimFactor(root);
const remainingRange = 1 - dimFactor;
if (remainingRange <= 0.0001) return 1;
const normalized = clamp01((contactIntensity - dimFactor) / remainingRange);
return labelDimFactor + (1 - labelDimFactor) * normalized;
}
export function isCyberSonarEnabled(
root = globalThis.document?.documentElement,
) {
return root?.dataset?.cyberSonar !== 'off';
}
export function setCyberSonarEnabled(
enabled,
root = globalThis.document?.documentElement,
) {
if (!root?.dataset) return false;
root.dataset.cyberSonar = enabled ? 'on' : 'off';
if (root.dataset.uiTheme === 'cyber') {
applyCyberSonarThemePhase(root, enabled);
}
return enabled;
}
/** Whether the map-mode sweep should alter flight presentation. */
export function isCyberSonarActive({
root = globalThis.document?.documentElement,
body = globalThis.document?.body,
hud,
reducedMotion,
} = {}) {
// Keep optional method calls in the body: production downleveling of nested
// parameter initializers can otherwise lose the method's receiver binding.
if (hud === undefined)
hud = globalThis.document?.getElementById?.('intel-hud');
if (reducedMotion === undefined)
reducedMotion =
globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ??
false;
return !!(
root?.dataset?.uiTheme === 'cyber' &&
isCyberSonarEnabled(root) &&
hud?.classList?.contains?.('active') &&
!body?.classList?.contains?.('cockpit-mode') &&
!reducedMotion
);
}
export function isCyberMapThemeActive({
root = globalThis.document?.documentElement,
body = globalThis.document?.body,
hud,
} = {}) {
if (hud === undefined)
hud = globalThis.document?.getElementById?.('intel-hud');
return !!(
root?.dataset?.uiTheme === 'cyber' &&
hud?.classList?.contains?.('active') &&
!body?.classList?.contains?.('cockpit-mode')
);
}
// Unsupported engines retain their native contact rendering, without a dimmer
// fallback. Decorative rings remain independent from the contact adapter.
export function isCyberContactThemeActive(options = {}) {
const root = options.root ?? globalThis.document?.documentElement;
return (
isCyberMapThemeActive(options) &&
root?.dataset?.cyberSonarGpu !== 'unsupported'
);
}
export function isCyberContactSonarActive(options = {}) {
return isCyberContactThemeActive(options) && isCyberSonarActive(options);
}
/** Set or clear the shared visual phase used by the sector and contact sweep. */
export function applyCyberSonarThemePhase(root, enabled, nowMs) {
if (!root?.style) return;
if (!enabled || !isCyberSonarEnabled(root)) {
root.style.removeProperty?.('--cyber-sonar-angle');
root.style.removeProperty?.('--cyber-sonar-phase-delay');
return;
}
const clock = Number.isFinite(nowMs)
? nowMs
: (globalThis.performance?.now?.() ?? 0);
// The visual sweep is compositor-animated. Writing a custom property on the
// document root every frame invalidates styles across the entire HUD, so JS
// only seeds the shared clock when the theme or sonar state changes.
root.style.removeProperty?.('--cyber-sonar-angle');
const currentDelay = root.style
.getPropertyValue?.('--cyber-sonar-phase-delay')
?.trim();
// setVariant() may be called repeatedly with the already-active layout.
// Preserve the running animation's original timeline in that case; changing
// its negative delay after it has started would shift the sector off the
// absolute clock used by contact highlighting.
if (currentDelay) return;
root.style.setProperty?.(
'--cyber-sonar-phase-delay',
cyberSonarAnimationDelay(clock),
);
}
+370
View File
@@ -0,0 +1,370 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFile } from 'node:fs/promises';
import { transformWithEsbuild } from 'vite';
import { BillboardCollection, Color, PointPrimitive } from 'cesium';
import {
syncCyberSonarAnimation,
applyCyberSonarPrimitive,
cyberSonarBaseAlpha,
applyCyberSonarThemePhase,
cyberSonarAngleDeg,
cyberSonarAnimationDelay,
cyberSonarFrameDelay,
cyberSonarIntensityAtScreenPoint,
cyberSonarLabelIntensity,
cyberSonarTintColor,
applyCyberSonarSettings,
CYBER_SONAR_DIM_FACTOR,
CYBER_SONAR_LABEL_DIM_FACTOR,
CYBER_SONAR_PERIOD_MS,
CYBER_SONAR_RENDER_INTERVAL_MS,
isCyberSonarEnabled,
isCyberSonarActive,
setCyberSonarEnabled,
} from './cyberSonar.js';
test('production-transformed sonar defaults retain the document method receiver', async () => {
const source = await readFile(
new URL('./cyberSonar.js', import.meta.url),
'utf8',
);
const { code } = await transformWithEsbuild(source, 'cyberSonar.js', {
target: 'es2019',
minify: true,
});
const compiled = await import(
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
);
const previous = Object.getOwnPropertyDescriptor(globalThis, 'document');
const hud = { classList: { contains: (name) => name === 'active' } };
const document = {
documentElement: { dataset: { uiTheme: 'cyber' } },
body: { classList: { contains: () => false } },
getElementById(id) {
assert.equal(this, document);
assert.equal(id, 'intel-hud');
return hud;
},
};
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: document,
});
try {
assert.equal(compiled.isCyberSonarActive(), true);
assert.equal(compiled.isCyberMapThemeActive(), true);
assert.ok(
Number.isFinite(compiled.createCyberSonarSampler(800, 600).at(400, 300)),
);
document.documentElement.dataset.uiTheme = 'tactical';
assert.equal(compiled.isCyberSonarActive(), false);
assert.equal(compiled.isCyberMapThemeActive(), false);
} finally {
if (previous) Object.defineProperty(globalThis, 'document', previous);
else delete globalThis.document;
}
});
test('hue-only aircraft restyles retain owner alpha across sonar opacity changes', () => {
const collection = new BillboardCollection();
const visual = collection.add({ color: Color.WHITE.withAlpha(0.8) });
try {
for (const opacity of [0.84, 0.35, 0.81, 1, 0.84]) {
for (let i = 0; i < 8; i++) {
visual.color = Color.CYAN.withAlpha(cyberSonarBaseAlpha(visual));
applyCyberSonarPrimitive(visual, opacity);
assert.equal(visual.color.alpha, 0.8 * opacity);
}
}
// A true owner freshness update must not be mistaken for our old alpha.
visual.color = Color.YELLOW.withAlpha(0.4);
assert.equal(cyberSonarBaseAlpha(visual), 0.4);
applyCyberSonarPrimitive(visual, 0.35);
visual.color = Color.CYAN.withAlpha(cyberSonarBaseAlpha(visual));
applyCyberSonarPrimitive(visual, 0.84);
applyCyberSonarPrimitive(visual, 1, false);
assert.ok(Color.equals(visual.color, Color.CYAN.withAlpha(0.4)));
} finally {
collection.destroy();
}
});
test('the Cyber sonar clock advances clockwise and wraps cleanly', () => {
assert.equal(cyberSonarAngleDeg(0), 0);
assert.equal(cyberSonarAngleDeg(CYBER_SONAR_PERIOD_MS / 4), 90);
assert.equal(cyberSonarAngleDeg(CYBER_SONAR_PERIOD_MS), 0);
assert.equal(cyberSonarAnimationDelay(CYBER_SONAR_PERIOD_MS / 2), '-2600ms');
});
test('contact refresh cadence waits only until the next bounded sonar frame', () => {
assert.equal(CYBER_SONAR_RENDER_INTERVAL_MS, 80);
assert.equal(cyberSonarFrameDelay(1_000, 1_000), 80);
assert.equal(cyberSonarFrameDelay(1_000, 1_035), 45);
assert.equal(cyberSonarFrameDelay(1_000, 1_080), 0);
assert.equal(cyberSonarFrameDelay(Number.NEGATIVE_INFINITY, 1_000), 0);
});
test('the sweep brightens contacts inside its sector and dims the remainder', () => {
const width = 1_000;
const height = 800;
const centerX = width / 2;
const centerY = height / 2;
assert.equal(
cyberSonarIntensityAtScreenPoint(centerX, centerY - 200, width, height, 0),
1,
);
assert.equal(
cyberSonarIntensityAtScreenPoint(centerX + 200, centerY, width, height, 0),
CYBER_SONAR_DIM_FACTOR,
);
assert.equal(
cyberSonarIntensityAtScreenPoint(
centerX + 200,
centerY,
width,
height,
CYBER_SONAR_PERIOD_MS / 4,
),
1,
);
assert.equal(
cyberSonarIntensityAtScreenPoint(centerX, centerY - 401, width, height, 0),
CYBER_SONAR_DIM_FACTOR,
);
assert.equal(
cyberSonarIntensityAtScreenPoint(Number.NaN, 0, width, height, 0),
1,
);
});
test('the sonar activates only for a visible Cyber map HUD with motion enabled', () => {
const root = { dataset: { uiTheme: 'cyber' } };
const classes = (...values) => ({
contains: (value) => values.includes(value),
});
assert.equal(
isCyberSonarActive({
root,
body: { classList: classes() },
hud: { classList: classes('active') },
reducedMotion: false,
}),
true,
);
assert.equal(
isCyberSonarActive({
root,
body: { classList: classes('cockpit-mode') },
hud: { classList: classes('active') },
reducedMotion: false,
}),
false,
);
assert.equal(
isCyberSonarActive({
root,
body: { classList: classes() },
hud: { classList: classes('active') },
reducedMotion: true,
}),
false,
);
root.dataset.cyberSonar = 'off';
assert.equal(
isCyberSonarActive({
root,
body: { classList: classes() },
hud: { classList: classes('active') },
reducedMotion: false,
}),
false,
);
});
test('labels keep a more readable baseline while tracking the same sweep', () => {
assert.equal(CYBER_SONAR_DIM_FACTOR, 0.84);
assert.equal(CYBER_SONAR_LABEL_DIM_FACTOR, 0.92);
assert.equal(
cyberSonarLabelIntensity(CYBER_SONAR_DIM_FACTOR),
CYBER_SONAR_LABEL_DIM_FACTOR,
);
assert.equal(cyberSonarLabelIntensity(1), 1);
assert.ok(
cyberSonarLabelIntensity(CYBER_SONAR_DIM_FACTOR) > CYBER_SONAR_DIM_FACTOR,
);
});
test('the opacity setting controls every contact floor and preserves brighter labels', () => {
const values = new Map();
const root = {
dataset: {},
style: { setProperty: (name, value) => values.set(name, value) },
};
const settings = applyCyberSonarSettings({ opacity: 62 }, root);
assert.equal(settings.opacity, 62);
assert.equal(root.dataset.cyberSonarOpacity, '62');
assert.equal(
cyberSonarIntensityAtScreenPoint(900, 400, 1_000, 800, 0, root),
0.62,
);
assert.equal(cyberSonarLabelIntensity(0.62, root), 0.81);
assert.equal(cyberSonarLabelIntensity(1, root), 1);
applyCyberSonarSettings({ opacity: 100 }, root);
assert.equal(cyberSonarLabelIntensity(1, root), 1);
assert.deepEqual(
cyberSonarTintColor({ red: 1, green: 1, blue: 1, alpha: 1 }, 1, root),
{ red: 1, green: 0.8848, blue: 0.8848, alpha: 1 },
);
});
test('the sonar control defaults on and records explicit operator state', () => {
const root = { dataset: {} };
assert.equal(isCyberSonarEnabled(root), true);
assert.equal(setCyberSonarEnabled(false, root), false);
assert.equal(root.dataset.cyberSonar, 'off');
assert.equal(isCyberSonarEnabled(root), false);
assert.equal(setCyberSonarEnabled(true, root), true);
assert.equal(root.dataset.cyberSonar, 'on');
});
test('real Cesium points and billboards recover after repeated low/high opacity cycles', () => {
const collection = new BillboardCollection();
const base = new Color(0.7, 0.5, 0.25, 0.8);
const visuals = [
new PointPrimitive({ color: base }),
collection.add({ color: base }),
];
try {
for (const visual of visuals) {
for (const factor of [0.35, 1, 0.5, 1, 0.35]) {
for (let frame = 0; frame < 60; frame++) {
applyCyberSonarPrimitive(visual, factor);
}
assert.equal(visual.color.alpha, base.alpha * factor);
}
// Owner setters mutate the same Cesium Color object, even when the new
// alpha equals the last sonar output. Its new RGB must become the base.
const refreshed = new Color(0.2, 0.8, 0.4, visual.color.alpha);
visual.color = refreshed;
applyCyberSonarPrimitive(visual, 0.5);
assert.equal(visual.color.alpha, refreshed.alpha * 0.5);
applyCyberSonarPrimitive(visual, 1, false);
assert.ok(Color.equals(visual.color, refreshed));
}
} finally {
collection.destroy();
}
});
test('generic Cesium primitives dim, accept owner style refreshes, and restore', () => {
const makeColor = (alpha, red = 1) => ({
red,
green: 0.5,
blue: 0.25,
alpha,
clone() {
return makeColor(this.alpha, this.red);
},
});
const visual = { color: makeColor(0.8) };
applyCyberSonarPrimitive(visual, 0.25, true);
assert.equal(visual.color.alpha, 0.2);
const paintedColor = visual.color;
applyCyberSonarPrimitive(visual, 0.25, true);
assert.equal(visual.color, paintedColor);
visual.color = makeColor(0.6, 0.7);
applyCyberSonarPrimitive(visual, 0.5, true);
assert.equal(visual.color.alpha, 0.3);
assert.notEqual(visual.color.red, 0.7);
applyCyberSonarPrimitive(visual, 1, false);
assert.equal(visual.color.alpha, 0.6);
assert.equal(visual.color.red, 0.7);
});
test('Cyber contact tint moves from cool-muted to warm-bright with the sector', () => {
const root = { dataset: { cyberSonarIntensity: '70' } };
const base = { red: 0.2, green: 0.8, blue: 1, alpha: 0.75 };
const dim = cyberSonarTintColor(base, CYBER_SONAR_DIM_FACTOR, root);
const bright = cyberSonarTintColor(base, 1, root);
assert.ok(bright.red > dim.red);
assert.ok(bright.green > dim.green);
assert.equal(bright.alpha, 0.75);
});
test('unswept white map symbols retain a bright readable Cyber tint', () => {
const root = { dataset: { cyberSonarIntensity: '70' } };
const dim = cyberSonarTintColor(
{ red: 1, green: 1, blue: 1, alpha: 1 },
CYBER_SONAR_DIM_FACTOR,
root,
);
assert.ok(dim.red >= 0.72);
assert.ok(dim.green >= 0.76);
assert.ok(dim.blue >= 0.79);
assert.equal(dim.alpha, 1);
});
test('the sweep corrects delayed starts and reacquires restarted animations without per-frame writes', () => {
let queries = 0,
writes = 0;
const makeAnimation = () => ({
animationName: 'cyber-sonar-sweep',
playState: 'running',
startTime: 1800,
effect: {
updateTiming(value) {
assert.deepEqual(value, { delay: 0 });
writes++;
},
},
});
let animation = makeAnimation();
let element = {
isConnected: true,
getAnimations() {
queries++;
return [animation];
},
};
const root = { querySelector: () => element };
syncCyberSonarAnimation(root);
assert.equal(animation.startTime, 0);
for (let i = 0; i < 100; i++) syncCyberSonarAnimation(root);
assert.equal(queries, 1);
assert.equal(writes, 1);
animation.playState = 'idle';
animation = makeAnimation();
syncCyberSonarAnimation(root);
assert.equal(animation.startTime, 0);
assert.equal(writes, 2);
element.isConnected = false;
animation = makeAnimation();
element = { isConnected: true, getAnimations: () => [animation] };
syncCyberSonarAnimation(root);
assert.equal(animation.startTime, 0);
assert.equal(writes, 3);
assert.doesNotThrow(() => syncCyberSonarAnimation({}));
});
test('the selected Cyber theme seeds and releases the compositor sweep phase', () => {
const values = new Map();
const root = {
dataset: {},
style: {
setProperty: (name, value) => values.set(name, value),
removeProperty: (name) => values.delete(name),
getPropertyValue: (name) => values.get(name) || '',
},
};
applyCyberSonarThemePhase(root, true, CYBER_SONAR_PERIOD_MS / 4);
assert.equal(values.has('--cyber-sonar-angle'), false);
assert.equal(values.get('--cyber-sonar-phase-delay'), '-1300ms');
applyCyberSonarThemePhase(root, true, CYBER_SONAR_PERIOD_MS / 2);
assert.equal(values.get('--cyber-sonar-phase-delay'), '-1300ms');
applyCyberSonarThemePhase(root, false, 0);
assert.equal(values.has('--cyber-sonar-angle'), false);
assert.equal(values.has('--cyber-sonar-phase-delay'), false);
});
+295
View File
@@ -0,0 +1,295 @@
import * as Cesium from 'cesium';
// Narrow, version-tested engine adapter. Cesium has no public custom shader
// option for native point/billboard collections. Do not patch its prototypes or
// access EntityCluster's private collections. An engine upgrade must revalidate
// this adapter; unsupported versions keep the original commands unchanged.
export const CYBER_SONAR_CESIUM_VERSION = '1.138.0';
// Follow every public DrawCommand setter, including pick/shadow/culling flags.
// Copying native.dirty would keep derived pipelines dirty forever: the scene
// consumes our command, so it never clears that flag on the native command.
const commandProperties = Object.entries(
Object.getOwnPropertyDescriptors(Cesium.DrawCommand.prototype),
)
.filter(
([key, descriptor]) =>
descriptor.set && key !== 'shaderProgram' && key !== 'uniformMap',
)
.map(([key]) => key);
export function syncCyberSonarCommand(
native,
record,
program,
uniforms,
labelBackground,
) {
if (!record.command) {
record.command = new Cesium.DrawCommand();
record.uniformMap = {
...uniforms,
u_gevSonarLabelBackground: () => record.labelBackground,
};
}
const command = record.command;
record.labelBackground = labelBackground;
// Uniform values (angle/viewport/etc.) change through stable callbacks, not
// new maps/functions. Owner callback replacement and removal still propagate.
for (const key in record.uniformMap) {
if (
!(key in uniforms) &&
key !== 'u_gevSonarLabelBackground' &&
!(key in native.uniformMap)
) {
delete record.uniformMap[key];
command.dirty = true;
}
}
for (const key in native.uniformMap) {
if (record.uniformMap[key] !== native.uniformMap[key]) {
record.uniformMap[key] = native.uniformMap[key];
command.dirty = true;
}
}
for (const key of commandProperties) command[key] = native[key];
command.shaderProgram = program;
command.uniformMap = record.uniformMap;
return command;
}
export function sonarVertexSource(source, kind) {
const text = source.sources.join('\n');
const attribute =
kind === 'point' ? 'positionHighAndSize' : 'positionHighAndScale';
const lowAttribute =
kind === 'point' ? 'positionLowAndOutline' : 'positionLowAndRotation';
if (
!text.includes(attribute) ||
!text.includes(lowAttribute) ||
!text.includes('v_color') ||
!/void\s+main\s*\(\s*\)/.test(text)
)
return null;
const next = source.clone();
next.sources = [
text.replace(/void\s+main\s*\(\s*\)/, 'void gev_sonar_main()'),
`
uniform vec4 u_gevSonar; // angle, radius, opacity floor, sweep enabled
uniform vec2 u_gevSonarViewport;
uniform float u_gevSonarSector;
uniform float u_gevSonarLabelBackground;
void main() {
gev_sonar_main();
// All corners/glyphs share the contact anchor, before native screen offsets.
// Leave gl_Position untouched so appearance never changes geometry or picks.
vec4 anchor = czm_modelViewProjectionRelativeToEye * czm_translateRelativeToEye(${attribute}.xyz, ${lowAttribute}.xyz);
if (anchor.w <= 0.0) return;
vec2 pixel = anchor.xy / anchor.w * u_gevSonarViewport * 0.5;
float floorValue = u_gevSonar.z;
float factor = floorValue + (1.0 - floorValue) * 0.46;
if (u_gevSonar.w > 0.5) {
float distanceSquared = dot(pixel, pixel);
float radiusSquared = u_gevSonar.y * u_gevSonar.y;
float delta = mod(degrees(atan(pixel.x, pixel.y)) - u_gevSonar.x + 360.0, 360.0);
if (delta > 359.9999) delta = 0.0;
factor = floorValue;
if (distanceSquared < radiusSquared * 0.000625) factor = 1.0;
else if (distanceSquared <= radiusSquared && delta <= u_gevSonarSector)
factor += (1.0 - floorValue) * min(1.0, (u_gevSonarSector - delta) / min(10.0, u_gevSonarSector * 0.42));
}
#ifdef SDF
factor = (1.0 + factor) * 0.5;
#else
if (u_gevSonarLabelBackground > 0.5) factor = (1.0 + factor) * 0.5;
#endif
float coverage = floorValue >= 0.9999 ? 1.0 : clamp((factor - floorValue) / (1.0 - floorValue), 0.0, 1.0);
if (u_gevSonarLabelBackground < 0.5)
v_color.rgb = v_color.rgb * 0.28 + (vec3(0.62, 0.68, 0.72) + vec3(0.38, 0.16, 0.12) * coverage) * 0.72;
// Apply before native fragment pass classification, never after main().
v_color.a *= factor;
${kind === 'point' ? 'v_outlineColor.a *= factor;' : '#ifdef SDF\n v_outlineColor.a *= factor;\n #endif'}
}
`,
];
return next;
}
// Use native batch bounds, never individual contacts, for idle render demand.
// Like Cesium's frustum builder, exclude the far plane (it fits multiple frusta).
export function isCyberSonarCommandVisible(command, frameState, culling) {
const bounds = command.boundingVolume;
if (!bounds || !command.cull) return true;
culling ??= new Cesium.CullingVolume(
frameState.cullingVolume.planes.slice(0, 5),
);
if (culling.computeVisibility(bounds) === Cesium.Intersect.OUTSIDE)
return false;
return !(
frameState.mode === Cesium.SceneMode.SCENE3D &&
frameState.occluder &&
command.occlude &&
bounds.isOccluded(frameState.occluder)
);
}
/** Work scales with draw commands, not the number of contacts in a collection.
* Source programs, colors, pick IDs, render states and other scenes stay owned
* by Cesium. Only this scene's rendered command slice receives derived shaders.
*/
export function createCyberSonarGpu(scene, readFrame) {
const root = scene.primitives;
const original = root.update;
const programs = new Map();
let commands = new WeakMap();
let disposed = false;
const stats = {
commands: 0,
visibleCommands: 0,
programs: 0,
supported: Cesium.VERSION === CYBER_SONAR_CESIUM_VERSION,
error: null,
};
const uniforms = new Cesium.Cartesian4();
const viewport = new Cesium.Cartesian2();
let sector = 24;
const sonarUniforms = {
u_gevSonar: () => uniforms,
u_gevSonarViewport: () => viewport,
u_gevSonarSector: () => sector,
};
const culling = new Cesium.CullingVolume();
const used = new Set();
const replaced = [];
const release = () => {
for (const { program } of programs.values())
if (!program.isDestroyed()) program.destroy();
programs.clear();
commands = new WeakMap();
used.clear();
replaced.length = 0;
stats.programs = 0;
};
const wrapped = function (frameState) {
const start = frameState.commandList.length;
original.call(this, frameState);
stats.commands = 0;
stats.visibleCommands = 0;
if (
disposed ||
!stats.supported ||
!frameState.passes.render ||
frameState.passes.pick
)
return;
const frame = readFrame();
if (!frame.enabled) {
if (programs.size) release();
return;
}
viewport.x = scene.drawingBufferWidth;
viewport.y = scene.drawingBufferHeight;
uniforms.x = frame.angle;
uniforms.y =
(Math.min(viewport.x * 0.4, viewport.y * 0.52) * frame.range) / 100;
uniforms.z = frame.opacity / 100;
uniforms.w = frame.active ? 1 : 0;
sector = frame.sector;
used.clear();
replaced.length = 0;
if (frameState.cullingVolume)
for (let i = 0; i < 5; i++)
culling.planes[i] = frameState.cullingVolume.planes[i];
try {
for (let i = start; i < frameState.commandList.length; i++) {
const native = frameState.commandList[i];
const kind =
native.owner instanceof Cesium.PointPrimitiveCollection
? 'point'
: native.owner instanceof Cesium.BillboardCollection
? 'billboard'
: null;
if (!kind || !native.shaderProgram || !native.uniformMap) continue;
const source = native.shaderProgram;
// Label backgrounds are non-SDF billboard batches. The version-tested
// engine pick association distinguishes them without enumerating labels
// or reaching into EntityCluster. One representative per draw batch;
// Cesium's label glyph/background collections are homogeneous.
const labelBackground =
kind === 'billboard' &&
!source.vertexShaderSource.defines.includes('SDF') &&
native.owner.length > 0 &&
native.owner.get(0).pickPrimitive instanceof Cesium.Label
? 1
: 0;
let entry = programs.get(source);
if (!entry) {
const vertex = sonarVertexSource(source.vertexShaderSource, kind);
if (!vertex) throw new Error('Unsupported native contact shader');
const attributes = Object.fromEntries(
Object.entries(source.vertexAttributes).map(([name, value]) => [
name,
value.index,
]),
);
const program = Cesium.ShaderProgram.fromCache({
context: frameState.context,
vertexShaderSource: vertex,
fragmentShaderSource: source.fragmentShaderSource,
attributeLocations: attributes,
});
// Compile while errors can still leave the whole native command slice
// intact, rather than stopping the viewer's render loop at draw time.
try {
void program.allUniforms;
} catch (error) {
program.destroy();
throw error;
}
programs.set(source, (entry = { program }));
}
used.add(source);
let record = commands.get(native);
if (!record) commands.set(native, (record = {}));
const derived = syncCyberSonarCommand(
native,
record,
entry.program,
sonarUniforms,
labelBackground,
);
replaced.push(i, native);
frameState.commandList[i] = derived;
stats.commands++;
if (isCyberSonarCommandVisible(native, frameState, culling))
stats.visibleCommands++;
}
for (const [source, entry] of programs)
if (!used.has(source)) {
if (!entry.program.isDestroyed()) entry.program.destroy();
programs.delete(source);
}
stats.programs = programs.size;
} catch (error) {
// Recover the original commands already replaced in this slice.
for (let i = 0; i < replaced.length; i += 2)
frameState.commandList[replaced[i]] = replaced[i + 1];
stats.supported = false;
stats.commands = 0;
stats.visibleCommands = 0;
stats.error = String(error?.message || error);
release();
}
};
root.update = wrapped;
return {
stats,
destroy() {
if (disposed) return;
disposed = true;
if (root.update === wrapped && !root.isDestroyed?.())
root.update = original;
release();
},
};
}
+354
View File
@@ -0,0 +1,354 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
createCyberSonarGpu,
sonarVertexSource,
CYBER_SONAR_CESIUM_VERSION,
isCyberSonarCommandVisible,
syncCyberSonarCommand,
} from './cyberSonarGpu.js';
test('stable commands reuse uniforms and derived pipelines while all native fields remain live', () => {
const native = new Cesium.DrawCommand({
count: 6,
uniformMap: { value: () => 1 },
});
const record = {},
program = {},
uniforms = { u_gevSonar: () => 42 };
const derived = syncCyberSonarCommand(native, record, program, uniforms, 0);
const map = derived.uniformMap;
derived.dirty = false;
derived.lastDirtyTime = 7;
assert.equal(native.dirty, true);
assert.equal(
syncCyberSonarCommand(native, record, program, uniforms, 1),
derived,
);
assert.equal(
derived.dirty,
false,
'stale native dirty flag must not rebuild pipelines',
);
assert.equal(derived.lastDirtyTime, 7);
assert.equal(derived.uniformMap, map);
assert.equal(map.u_gevSonarLabelBackground(), 1);
for (const [key, value] of Object.entries({
count: 12,
offset: 2,
instanceCount: 3,
pickId: 'pick',
pass: Cesium.Pass.TRANSLUCENT,
cull: false,
occlude: false,
castShadows: true,
receiveShadows: true,
pickOnly: true,
renderState: {},
vertexArray: {},
modelMatrix: Cesium.Matrix4.IDENTITY,
framebuffer: {},
})) {
derived.dirty = false;
native[key] = value;
syncCyberSonarCommand(native, record, program, uniforms, 0);
assert.equal(derived[key], value, key);
assert.equal(derived.dirty, true, key);
}
native.uniformMap.value = () => 2;
syncCyberSonarCommand(native, record, program, uniforms, 0);
assert.equal(map.value(), 2);
native.uniformMap = { replacement: () => 3 };
syncCyberSonarCommand(native, record, program, uniforms, 0);
assert.equal(map.value, undefined);
assert.equal(map.replacement(), 3);
assert.equal(map.u_gevSonar(), 42);
const nextProgram = {};
syncCyberSonarCommand(native, record, nextProgram, uniforms, 0);
assert.equal(derived.shaderProgram, nextProgram);
assert.equal(native.shaderProgram, undefined);
});
test('GPU shader transform is isolated, settings-driven, and precedes native fragment classification', () => {
assert.equal(
Cesium.VERSION,
CYBER_SONAR_CESIUM_VERSION,
'engine upgrades require GPU revalidation',
);
for (const [kind, attr, low] of [
['point', 'positionHighAndSize', 'positionLowAndOutline'],
['billboard', 'positionHighAndScale', 'positionLowAndRotation'],
]) {
const source = new Cesium.ShaderSource({
defines: ['SDF'],
sources: [
`in vec4 ${attr}; in vec4 ${low}; out vec4 v_color; void main() { v_color=vec4(1.); }`,
],
});
const original = source.sources[0];
const next = sonarVertexSource(source, kind);
assert.equal(source.sources[0], original);
assert.notEqual(next, source);
assert.deepEqual(next.defines, ['SDF']);
assert.match(next.sources[0], /void gev_sonar_main/);
assert.match(next.sources[1], /v_color.a \*= factor/);
assert.match(next.sources[1], /u_gevSonarSector/);
assert.match(next.sources[1], /floorValue \+ \(1.0 - floorValue\) \* 0.46/);
assert.ok(
next.sources[1].includes(
`czm_translateRelativeToEye(${attr}.xyz, ${low}.xyz)`,
),
);
assert.match(next.sources[1], /anchor.xy \/ anchor.w/);
assert.doesNotMatch(next.sources[1], /gl_Position\s*[=\/]/);
}
assert.equal(
sonarVertexSource(
new Cesium.ShaderSource({ sources: ['void main() {}'] }),
'point',
),
null,
);
});
test('render demand follows batch bounds, including horizon culling, without far-plane rejection', () => {
const planes = Array.from(
{ length: 6 },
(_, i) => new Cesium.Cartesian4(1, 0, 0, i),
);
const frame = {
cullingVolume: { planes },
mode: Cesium.SceneMode.SCENE3D,
occluder: {},
};
let intersections = 0;
let occluded = false;
const command = {
cull: true,
occlude: true,
boundingVolume: {
intersectPlane(plane) {
intersections++;
return plane.distance === 5
? Cesium.Intersect.OUTSIDE
: Cesium.Intersect.INSIDE;
},
isOccluded() {
return occluded;
},
},
};
assert.equal(isCyberSonarCommandVisible(command, frame), true);
assert.equal(intersections, 5);
occluded = true;
assert.equal(isCyberSonarCommandVisible(command, frame), false);
frame.mode = Cesium.SceneMode.SCENE2D;
assert.equal(isCyberSonarCommandVisible(command, frame), true);
command.boundingVolume.intersectPlane = () => Cesium.Intersect.OUTSIDE;
assert.equal(isCyberSonarCommandVisible(command, frame), false);
command.cull = false;
assert.equal(isCyberSonarCommandVisible(command, frame), true);
assert.equal(isCyberSonarCommandVisible({}, frame), true);
});
test('adapter never walks contacts, touches other scenes, or transforms pick-only passes', () => {
let calls = 0;
const original = function (fs) {
assert.equal(this, root);
calls++;
fs.commandList.push({ owner: {}, id: 'terrain' });
};
const root = { update: original };
const other = { update: original };
const adapter = createCyberSonarGpu({ primitives: root }, () => ({
enabled: false,
}));
const frame = { commandList: [], passes: { render: true, pick: false } };
root.update(frame);
assert.equal(calls, 1);
assert.equal(frame.commandList[0].id, 'terrain');
assert.equal(other.update, original);
root.update({ commandList: [], passes: { render: false, pick: true } });
assert.equal(calls, 2);
adapter.destroy();
adapter.destroy();
assert.equal(root.update, original);
});
test('unknown native shaders fail closed without losing native commands', () => {
const points = new Cesium.PointPrimitiveCollection();
const command = {
owner: points,
uniformMap: {},
shaderProgram: {
vertexShaderSource: new Cesium.ShaderSource({
sources: ['void main() {}'],
}),
},
};
const root = {
update(fs) {
fs.commandList.push(command);
},
};
const adapter = createCyberSonarGpu(
{ primitives: root, drawingBufferWidth: 800, drawingBufferHeight: 600 },
() => ({
enabled: true,
active: true,
angle: 0,
range: 100,
opacity: 84,
sector: 24,
}),
);
const frame = { commandList: [], passes: { render: true } };
root.update(frame);
assert.equal(frame.commandList[0], command);
assert.equal(adapter.stats.supported, false);
assert.match(adapter.stats.error, /Unsupported native contact shader/);
assert.equal(adapter.stats.programs, 0);
adapter.destroy();
points.destroy();
});
test('program swaps, layer removal and late shader failure release only adapter resources', () => {
const points = new Cesium.PointPrimitiveCollection();
const source = () => ({
vertexShaderSource: new Cesium.ShaderSource({
sources: [
'in vec4 positionHighAndSize; in vec4 positionLowAndOutline; out vec4 v_color; void main() {}',
],
}),
vertexAttributes: {},
fragmentShaderSource: new Cesium.ShaderSource({
sources: ['void main() {}'],
}),
});
const native = new Cesium.DrawCommand({
owner: points,
uniformMap: {},
shaderProgram: source(),
cull: false,
vertexArray: { generation: 1 },
pickId: 'native-pick',
});
const unknown = new Cesium.DrawCommand({
owner: points,
uniformMap: {},
shaderProgram: {
vertexShaderSource: new Cesium.ShaderSource({
sources: ['void main() {}'],
}),
},
});
let nativeCommands = [native];
let enabled = true;
const root = {
update(frame) {
frame.commandList.push(...nativeCommands);
},
};
const fromCache = Cesium.ShaderProgram.fromCache;
const created = [];
Cesium.ShaderProgram.fromCache = () => {
const program = {
allUniforms: {},
destroyed: false,
isDestroyed() {
return this.destroyed;
},
destroy() {
assert.equal(this.destroyed, false);
this.destroyed = true;
},
};
created.push(program);
return program;
};
const adapter = createCyberSonarGpu(
{ primitives: root, drawingBufferWidth: 800, drawingBufferHeight: 600 },
() => ({
enabled,
active: true,
angle: 0,
range: 100,
opacity: 84,
sector: 24,
}),
);
const draw = () => {
const frame = { commandList: [], passes: { render: true } };
root.update(frame);
return frame.commandList;
};
try {
const first = draw()[0];
assert.notEqual(first, native);
first.dirty = false;
assert.equal(draw()[0], first);
assert.equal(first.dirty, false);
assert.equal(created.length, 1, 'stable draws reuse the program');
const buffer = { generation: 2 };
native.vertexArray = buffer;
native.shaderProgram = source();
const swapped = draw()[0];
assert.equal(swapped, first);
assert.equal(swapped.vertexArray, buffer);
assert.equal(swapped.pickId, 'native-pick');
assert.equal(swapped.shaderProgram, created[1]);
assert.equal(created[0].destroyed, true);
assert.equal(adapter.stats.programs, 1);
nativeCommands = [];
draw();
assert.equal(created[1].destroyed, true);
assert.equal(
adapter.stats.programs,
0,
'disabled layers retain no adapter program',
);
nativeCommands = [native];
draw();
enabled = false;
assert.equal(draw()[0], native);
assert.equal(
created[2].destroyed,
true,
'theme/cockpit exit releases the derived program',
);
enabled = true;
nativeCommands = [native, unknown];
assert.deepEqual(
draw(),
nativeCommands,
'a later failure rolls back earlier replacements in the same slice',
);
assert.equal(created[3].destroyed, true);
assert.equal(adapter.stats.supported, false);
assert.equal(adapter.stats.programs, 0);
assert.equal(adapter.stats.commands, 0);
assert.equal(adapter.stats.visibleCommands, 0);
assert.deepEqual(draw(), nativeCommands);
assert.equal(
created.length,
4,
'unsupported draws do not retry compilation',
);
} finally {
adapter.destroy();
Cesium.ShaderProgram.fromCache = fromCache;
points.destroy();
}
});
test('disposal does not overwrite a successor update owner', () => {
const root = { update() {} };
const adapter = createCyberSonarGpu({ primitives: root }, () => ({
enabled: false,
}));
const successor = () => {};
root.update = successor;
adapter.destroy();
assert.equal(root.update, successor);
});
+211
View File
@@ -0,0 +1,211 @@
import * as Cesium from 'cesium';
import { createCyberSonarGpu } from './cyberSonarGpu.js';
import {
applyCyberSonarPrimitive,
createCyberSonarSampler,
isCyberContactThemeActive,
isCyberContactSonarActive,
isCyberSonarActive,
readCyberSonarSettings,
cyberSonarAngleDeg,
syncCyberSonarAnimation,
CYBER_SONAR_RENDER_INTERVAL_MS,
} from './cyberSonar.js';
const alive = (value) => value && !value.isDestroyed?.();
const diagnostics = new WeakMap();
export const getCyberSonarSceneDiagnostics = (viewer) => ({
...diagnostics.get(viewer),
});
export function visitCyberSonarModels(primitive, visit, visible = true) {
if (!alive(primitive)) return;
visible = visible && primitive.show !== false;
if (primitive instanceof Cesium.PrimitiveCollection) {
for (let i = 0; i < primitive.length; i++)
visitCyberSonarModels(primitive.get(i), visit, visible);
} else if (primitive instanceof Cesium.Model) {
visit(primitive, primitive, 'model', visible && primitive.ready);
}
}
/** Native dots, icons and glyphs use GPU draw-command treatment. Models retain
* their existing reversible CPU color treatment without walking native records.
*/
export function createCyberSonarScene(viewer, manager) {
const scene = viewer?.scene;
if (
!scene?.postUpdate?.addEventListener ||
!scene?.preRender?.addEventListener
)
return () => {};
const tracked = new Map();
const matrix = new Cesium.Matrix4();
const position = new Cesium.Cartesian3();
let epoch = 0,
timer = null,
disposed = false;
let sampler, width, height, active, count;
const stats = { contacts: 0, updateMs: 0, frames: 0 };
const root = globalThis.document?.documentElement;
const gpu = createCyberSonarGpu(scene, () => ({
...readCyberSonarSettings(),
enabled: isCyberContactThemeActive(),
active: isCyberContactSonarActive(),
angle: cyberSonarAngleDeg(performance.now()),
}));
stats.gpu = gpu.stats;
let gpuSupported = gpu.stats.supported;
diagnostics.set(viewer, stats);
const restore = (visual, state) => {
if (!alive(state.owner)) return;
for (const key of state.keys)
applyCyberSonarPrimitive(visual, 1, false, undefined, key);
};
const visit = (visual, owner, kind, visible) => {
let state = tracked.get(visual);
if (!visible) {
if (state) {
restore(visual, state);
tracked.delete(visual);
}
return;
}
let p = visual.position;
if (kind === 'model')
p = Cesium.Matrix4.getTranslation(visual.modelMatrix, position);
else if (
p &&
owner.modelMatrix &&
!Cesium.Matrix4.equals(owner.modelMatrix, Cesium.Matrix4.IDENTITY)
)
p = Cesium.Matrix4.multiplyByPoint(owner.modelMatrix, p, position);
if (!p) return;
const w = matrix[3] * p.x + matrix[7] * p.y + matrix[11] * p.z + matrix[15];
const x =
(matrix[0] * p.x + matrix[4] * p.y + matrix[8] * p.z + matrix[12]) / w;
const y =
(matrix[1] * p.x + matrix[5] * p.y + matrix[9] * p.z + matrix[13]) / w;
if (w <= 0 || Math.abs(x) > 1.1 || Math.abs(y) > 1.1) return;
if (!state) {
state = {
owner,
keys:
kind === 'label'
? ['fillColor', 'outlineColor', 'backgroundColor']
: kind === 'marker' && visual.outlineColor
? ['color', 'outlineColor']
: ['color'],
epoch,
};
tracked.set(visual, state);
}
state.epoch = epoch;
const factor = active
? sampler.at((x + 1) * width * 0.5, (1 - y) * height * 0.5)
: sampler.neutral;
for (const key of state.keys) {
if (key === 'outlineColor' && visual.outlineWidth === 0) continue;
if (key === 'backgroundColor' && !visual.showBackground) continue;
applyCyberSonarPrimitive(
visual,
kind === 'label' ? sampler.label(factor) : factor,
true,
sampler,
key,
kind === 'model',
);
}
count++;
};
const update = () => {
if (disposed) return;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
if (!isCyberContactThemeActive()) {
for (const [visual, state] of tracked) restore(visual, state);
tracked.clear();
stats.contacts = 0;
stats.updateMs = 0;
return;
}
const started = performance.now();
width = scene.canvas.clientWidth;
height = scene.canvas.clientHeight;
sampler = createCyberSonarSampler(width, height, started);
active = isCyberContactSonarActive();
Cesium.Matrix4.multiply(
scene.camera.frustum.projectionMatrix,
scene.camera.viewMatrix,
matrix,
);
epoch++;
count = 0;
visitCyberSonarModels(scene.primitives, visit);
for (const [visual, state] of tracked)
if (state.epoch !== epoch) {
restore(visual, state);
tracked.delete(visual);
}
stats.contacts = count;
stats.updateMs = performance.now() - started;
stats.frames++;
// No contacts, no scanner-owned render demand. In continuous scenes the
// next rendered frame cancels this timer; parked scenes refresh at fleet
// cadence. Idle update-only ticks must not continually postpone the timer.
};
let removeRender;
// Aircraft fleet writers run in preRender, while tracked models and entity
// visualizers update earlier. Compose AFTER all those owners and BEFORE
// Cesium uploads primitive attributes for this draw. Requeue at postUpdate
// so writers installed later (enable/re-enable/track) cannot overwrite sonar.
// This only reorders one callback; the contact walk runs once per actual draw.
const removeUpdate = scene.postUpdate.addEventListener(() => {
if (isCyberSonarActive()) syncCyberSonarAnimation();
const supportChanged = gpuSupported !== gpu.stats.supported;
gpuSupported = gpu.stats.supported;
const support = gpu.stats.supported ? 'supported' : 'unsupported';
if (root?.dataset && root.dataset.cyberSonarGpu !== support)
root.dataset.cyberSonarGpu = support;
// Shader setup runs after preRender's model tint. If it fails while the
// scene is parked (including Sonar OFF/reduced motion), draw once more so
// the now-unsupported contact path restores models before becoming idle.
if (supportChanged) scene.requestRender();
const status = globalThis.document?.getElementById?.('cyber-sonar-status');
const message = gpu.stats.supported
? ''
: 'Contact GPU unavailable. Native contacts remain visible.';
if (status && status.textContent !== message) status.textContent = message;
removeRender?.();
removeRender = scene.preRender.addEventListener(update);
});
const removePostRender = scene.postRender?.addEventListener(() => {
if (timer !== null) clearTimeout(timer);
timer = null;
if (isCyberContactSonarActive() && (count || gpu.stats.visibleCommands))
timer = setTimeout(() => {
timer = null;
if (!disposed && isCyberContactSonarActive()) scene.requestRender();
}, CYBER_SONAR_RENDER_INTERVAL_MS);
});
const unsubscribe = manager?.subscribe?.((change) => {
if (change.type !== 'visibility') return;
scene.requestRender();
});
return () => {
if (disposed) return;
disposed = true;
removeUpdate();
removeRender?.();
removePostRender?.();
gpu.destroy();
if (root?.dataset) delete root.dataset.cyberSonarGpu;
unsubscribe?.();
if (timer !== null) clearTimeout(timer);
for (const [visual, state] of tracked) restore(visual, state);
tracked.clear();
diagnostics.delete(viewer);
};
}
+314
View File
@@ -0,0 +1,314 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
createCyberSonarSampler,
cyberSonarIntensityAtScreenPoint,
applyCyberSonarPrimitive,
applyCyberSonarSettings,
isCyberContactSonarActive,
isCyberSonarActive,
} from './cyberSonar.js';
import {
createCyberSonarScene,
visitCyberSonarModels,
getCyberSonarSceneDiagnostics,
} from './cyberSonarScene.js';
test('frame sampler matches reference without per-contact settings access', () => {
let reads = 0;
const values = {
cyberSonarOpacity: '62',
cyberSonarSector: '40',
cyberSonarRange: '110',
};
const root = {
dataset: new Proxy(values, {
get(o, k) {
reads++;
return o[k];
},
}),
};
const frame = createCyberSonarSampler(1000, 800, 1234, root),
once = reads;
for (let x = 0; x < 1000; x += 33)
for (let y = 0; y < 800; y += 31) frame.at(x, y);
assert.equal(reads, once);
for (let x = 0; x < 1000; x += 33)
for (let y = 0; y < 800; y += 31)
assert.ok(
Math.abs(
frame.at(x, y) -
cyberSonarIntensityAtScreenPoint(x, y, 1000, 800, 1234, {
dataset: values,
}),
) < 1e-10,
);
});
test('model walk never enumerates native contact or EntityCluster records', () => {
const root = new Cesium.PrimitiveCollection();
const points = root.add(new Cesium.PointPrimitiveCollection());
points.add();
points.get = () => {
throw new Error('must not enumerate contacts');
};
const cluster = root.add(new Cesium.EntityCluster());
Object.defineProperty(cluster, '_billboardCollection', {
get() {
throw new Error('must not access cluster internals');
},
configurable: true,
});
const seen = [];
visitCyberSonarModels(root, (...v) => seen.push(v));
assert.equal(seen.length, 0);
delete cluster._billboardCollection;
root.destroy();
});
test('models retain render-pass alpha and owner refresh survives teardown', () => {
const frame = createCyberSonarSampler(1000, 800, 0);
const model = { color: new Cesium.Color(1, 0.5, 0.2, 1) };
applyCyberSonarPrimitive(model, 0.5, true, frame, 'color', true);
assert.equal(model.color.alpha, 1);
assert.ok(model.color.red < 1);
const owner = new Cesium.Color(0.2, 0.3, 0.4, 0.7);
model.color = owner;
applyCyberSonarPrimitive(model, 1, false);
assert.equal(model.color, owner);
const unset = {};
applyCyberSonarPrimitive(unset, 0.5, true, frame, 'color', true);
applyCyberSonarPrimitive(unset, 1, false);
assert.equal(unset.color, undefined);
});
test('removed CSS mode cannot disable GPU contacts; unsupported adapters fail closed', () => {
const root = { dataset: { uiTheme: 'cyber' }, style: { setProperty() {} } };
const options = {
root,
body: { classList: { contains: () => false } },
hud: { classList: { contains: () => true } },
reducedMotion: false,
};
root.dataset.cyberSonarMode = 'css';
assert.equal(applyCyberSonarSettings({ mode: 'css' }, root).mode, undefined);
assert.equal(root.dataset.cyberSonarMode, undefined);
assert.equal(isCyberSonarActive(options), true);
assert.equal(isCyberContactSonarActive(options), true);
assert.equal(
applyCyberSonarSettings({ mode: 'invalid' }, root).mode,
undefined,
);
assert.equal(isCyberContactSonarActive(options), true);
root.dataset.cyberSonarGpu = 'unsupported';
assert.equal(isCyberContactSonarActive(options), false);
});
test('native owner colors remain untouched and scene teardown releases listeners', () => {
const previous = globalThis.document;
const root = { dataset: { uiTheme: 'cyber', cyberSonar: 'on' } };
let cockpit = false;
globalThis.document = {
documentElement: root,
body: { classList: { contains: () => cockpit } },
getElementById: () => ({ classList: { contains: () => true } }),
};
const primitives = new Cesium.PrimitiveCollection();
const points = primitives.add(new Cesium.PointPrimitiveCollection());
const point = points.add({ color: new Cesium.Color(0.3, 0.7, 0.5, 0.8) });
const original = primitives.update;
const scene = {
primitives,
postUpdate: new Cesium.Event(),
preRender: new Cesium.Event(),
postRender: new Cesium.Event(),
canvas: { clientWidth: 1000, clientHeight: 800 },
camera: {
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
viewMatrix: Cesium.Matrix4.IDENTITY,
},
requestRender() {},
};
const viewer = { scene };
const dispose = createCyberSonarScene(viewer);
try {
for (const enabled of ['on', 'off', 'on']) {
root.dataset.cyberSonar = enabled;
for (const opacity of [35, 84, 100]) {
root.dataset.cyberSonarOpacity = String(opacity);
point.color = new Cesium.Color(0.6, 0.4, 0.2, 0.7);
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
assert.equal(point.color.alpha, 0.7);
assert.equal(point.color.red, 0.6);
}
}
const previousTimeout = globalThis.setTimeout;
const previousClear = globalThis.clearTimeout;
let timers = 0;
globalThis.setTimeout = () => {
timers++;
return 1;
};
globalThis.clearTimeout = () => {};
try {
const gpu = getCyberSonarSceneDiagnostics(viewer).gpu;
gpu.commands = 6;
gpu.visibleCommands = 0;
scene.postRender.raiseEvent();
assert.equal(timers, 0, 'culled batches must not schedule a sonar draw');
gpu.visibleCommands = 1;
scene.postRender.raiseEvent();
assert.equal(timers, 1, 'a visible batch keeps the sweep moving');
root.dataset.cyberSonar = 'off';
scene.postRender.raiseEvent();
assert.equal(timers, 1, 'OFF does not schedule contact draws');
} finally {
globalThis.setTimeout = previousTimeout;
globalThis.clearTimeout = previousClear;
}
cockpit = true;
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
root.dataset.uiTheme = 'tactical';
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
dispose();
dispose();
assert.equal(primitives.update, original);
assert.equal(scene.postUpdate.numberOfListeners, 0);
assert.equal(scene.preRender.numberOfListeners, 0);
assert.equal(scene.postRender.numberOfListeners, 0);
assert.equal(root.dataset.cyberSonarGpu, undefined);
} finally {
dispose();
primitives.destroy();
globalThis.document = previous;
}
});
for (const reducedMotion of [false, true]) {
test(`shader failure requests one parked restoration draw with ${reducedMotion ? 'reduced motion' : 'Sonar OFF'}`, () => {
const previousDocument = globalThis.document;
const previousMatchMedia = globalThis.matchMedia;
const previousTimeout = globalThis.setTimeout;
const previousClear = globalThis.clearTimeout;
const root = {
dataset: { uiTheme: 'cyber', cyberSonar: reducedMotion ? 'on' : 'off' },
};
const status = { textContent: '' };
globalThis.document = {
documentElement: root,
body: { classList: { contains: () => false } },
getElementById: (id) =>
id === 'cyber-sonar-status'
? status
: { classList: { contains: () => true } },
};
globalThis.matchMedia = () => ({ matches: reducedMotion });
let scheduled = 0;
globalThis.setTimeout = () => {
scheduled++;
return 1;
};
globalThis.clearTimeout = () => {};
// A model-shaped fixture exercises the actual scene walk without loading a
// glTF or allocating a WebGL context. Rendering remains the browser gate.
const nativeColor = new Cesium.Color(0.2, 0.4, 0.8, 1);
const model = Object.create(Cesium.Model.prototype, {
ready: { value: true },
show: { value: true },
color: { value: nativeColor.clone(), writable: true },
modelMatrix: { value: Cesium.Matrix4.IDENTITY },
});
const primitives = new Cesium.PrimitiveCollection({
destroyPrimitives: false,
});
primitives.add(model);
const points = new Cesium.PointPrimitiveCollection();
const nativeCommand = {
owner: points,
uniformMap: {},
shaderProgram: {
vertexShaderSource: new Cesium.ShaderSource({
sources: ['void main() {}'],
}),
},
};
primitives.update = (frame) => frame.commandList.push(nativeCommand);
let requests = 0;
const scene = {
primitives,
postUpdate: new Cesium.Event(),
preRender: new Cesium.Event(),
postRender: new Cesium.Event(),
canvas: { clientWidth: 1000, clientHeight: 800 },
drawingBufferWidth: 1000,
drawingBufferHeight: 800,
camera: {
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
viewMatrix: Cesium.Matrix4.IDENTITY,
},
requestRender() {
requests++;
},
};
const viewer = { scene };
const dispose = createCyberSonarScene(viewer);
try {
scene.postUpdate.raiseEvent();
scene.preRender.raiseEvent();
assert.notDeepEqual(
model.color,
nativeColor,
'Cyber neutral treatment precedes shader compilation',
);
const frame = { commandList: [], passes: { render: true } };
primitives.update(frame);
assert.equal(getCyberSonarSceneDiagnostics(viewer).gpu.supported, false);
assert.equal(frame.commandList[0], nativeCommand);
scene.postRender.raiseEvent();
assert.equal(
scheduled,
0,
'inactive sweep has no timer to perform recovery',
);
scene.postUpdate.raiseEvent();
assert.equal(root.dataset.cyberSonarGpu, 'unsupported');
assert.match(status.textContent, /Native contacts remain visible/);
assert.equal(
requests,
1,
'support loss explicitly wakes the parked scene',
);
scene.preRender.raiseEvent();
assert.deepEqual(
model.color,
nativeColor,
'the requested draw restores native model color',
);
assert.equal(getCyberSonarSceneDiagnostics(viewer).contacts, 0);
scene.postRender.raiseEvent();
for (let i = 0; i < 3; i++) scene.postUpdate.raiseEvent();
assert.equal(
requests,
1,
'unsupported idle ticks do not create a render loop',
);
assert.equal(scheduled, 0);
dispose();
scene.postUpdate.raiseEvent();
assert.equal(requests, 1, 'disposed callbacks cannot wake the scene');
} finally {
dispose();
points.destroy();
primitives.destroy();
globalThis.document = previousDocument;
globalThis.matchMedia = previousMatchMedia;
globalThis.setTimeout = previousTimeout;
globalThis.clearTimeout = previousClear;
}
});
}
+63 -4
View File
@@ -48,6 +48,12 @@ import {
viewScaleForAltitude,
} from './detectionPolicy.js';
import { detectionBracketOpacity } from './detectionPresentation.js';
import {
createCyberSonarSampler,
cyberSonarFrameDelay,
isCyberContactSonarActive as isCyberSonarActive,
isCyberContactThemeActive as isCyberMapThemeActive,
} from '../cyberSonar.js';
/**
* @module detection
@@ -206,6 +212,8 @@ let _lastRenderMs = 0;
let _lastPaintMs = 0;
let _lastSolveMs = 0;
let _throttleSkipCount = 0;
let _lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
let _cyberSonarRenderTimer = null;
/** @type {Function|null} External callback invoked when the detection mode changes */
let _onModeChange = null;
/** @type {{surface:HTMLCanvasElement|null,setActive:Function,requestPaint:Function,unregister:Function}|null} */
@@ -325,6 +333,7 @@ export function initDetection(viewer, layers, onModeChange) {
/** Release the host lane and all retained detection runtime state. */
export function destroyDetection() {
_clearCyberSonarRenderTimer();
if (_cockpitModeListener && typeof window !== 'undefined') {
window.removeEventListener(
'gev:cockpit-mode-changed',
@@ -362,6 +371,25 @@ export function destroyDetection() {
_lastPaintMs = 0;
_lastSolveMs = 0;
_throttleSkipCount = 0;
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
}
function _clearCyberSonarRenderTimer() {
if (_cyberSonarRenderTimer != null) {
globalThis.clearTimeout?.(_cyberSonarRenderTimer);
_cyberSonarRenderTimer = null;
}
}
function _scheduleCyberSonarRender(nowMs) {
if (_cyberSonarRenderTimer != null || !globalThis.setTimeout) return;
const delay = cyberSonarFrameDelay(_lastCyberSonarPaintAt, nowMs);
_cyberSonarRenderTimer = globalThis.setTimeout(() => {
_cyberSonarRenderTimer = null;
if (isCyberSonarActive()) {
governorRequestRender('cyber-sonar-cadence');
}
}, delay);
}
/**
@@ -682,7 +710,18 @@ function _applyModeState() {
function _shouldPaintDetectionLane(frame) {
if (_mode === MODE_OFF || _suspended) return false;
const layoutChanged = frame.layoutRevision !== _hostLayoutRevision;
const nowMs = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
_frameCount++;
if (isCyberSonarActive()) {
const sonarDelay = cyberSonarFrameDelay(_lastCyberSonarPaintAt, nowMs);
if (!layoutChanged && sonarDelay > 0) {
_scheduleCyberSonarRender(nowMs);
return false;
}
} else {
_clearCyberSonarRenderTimer();
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
}
// The skip and the follow-up request come from ONE decision, so the valve
// cannot drop a frame without handing its request forward — see
// `detectionPaintSkipDecision`. (Skipping is a deferral, never a cancellation:
@@ -729,6 +768,14 @@ function _paintDetectionLane(frame) {
_lastRenderMs = performance.now() - start;
_lastSolveMs = result.solveMs || 0;
_lastPaintMs = Math.max(0, _lastRenderMs - _lastSolveMs);
const nowMs = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
if (isCyberSonarActive() && _lastDiagnostics?.visibleCount > 0) {
_lastCyberSonarPaintAt = nowMs;
_scheduleCyberSonarRender(nowMs);
} else {
_clearCyberSonarRenderTimer();
_lastCyberSonarPaintAt = Number.NEGATIVE_INFINITY;
}
if (_lastDiagnostics) {
_lastDiagnostics.frameTotalMs = _lastRenderMs;
_lastDiagnostics.paintMs = _lastPaintMs;
@@ -748,7 +795,7 @@ function _paintDetectionLane(frame) {
// The frame's OWN timestamp, not a fresh sample — re-reading the clock here
// is what dropped the terminal frame of a fade (paint at 219 ms, policy at
// 220 ms, and the settled alpha never painted).
nowMs: Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs(),
nowMs,
enabledAtMs: _enableTime,
fadeMs: FADE_MS,
animatingLabelCount: result.animatingCount || 0,
@@ -996,7 +1043,8 @@ function _stashCallout(entry, acquireFade, keyhole) {
placement.centerY,
keyhole,
);
const alpha = acquireFade * temporalAlpha * radialAlpha;
const alpha =
acquireFade * temporalAlpha * radialAlpha * (candidate.sonarFactor ?? 1);
if (alpha <= 0.001) return;
let row = _calloutPool[_calloutCount];
@@ -1146,6 +1194,7 @@ function _materializeCandidate(
keyholeAlpha,
placements,
color: obj._candidateColor,
sonarFactor: obj._candidateSonarFactor ?? 1,
// Costed here rather than in the object sweep: the sweep walks every
// observation in view, while this runs only for callouts that actually
// placed — a budgeted handful per frame.
@@ -1180,6 +1229,9 @@ function _drawOverlay(frame) {
// with every arbiter call in this function — so the frame that paints an
// animation's final state is the same frame that ends its demand.
const now = Number.isFinite(frame.timestamp) ? frame.timestamp : _nowMs();
const sonarActive = isCyberSonarActive();
const cyberMapActive = isCyberMapThemeActive();
const sonarSampler = createCyberSonarSampler(width, height, now);
const bracketPresentationOpacity = detectionBracketOpacity(_cockpitActive);
const shouldSolve =
_labelSolveDirty || now - _lastLabelSolveAt >= LABEL_SOLVE_INTERVAL_MS;
@@ -1362,11 +1414,16 @@ function _drawOverlay(frame) {
// The bracket follows the same linear radial keyhole fade as its callout.
const color = colorFor(resolveTier(obj));
const keyholeAlpha = keyholeLabelAlphaFromGeometry(sx, sy, keyhole);
const bracketAlpha = detectionBracketAlpha(
const sonarFactor =
sonarActive && !obj.skipLabel ? sonarSampler.at(sx, sy) : 1;
const sonarLabelFactor = sonarSampler.label(sonarFactor);
const admissionAlpha = detectionBracketAlpha(
obj.type,
keyholeAlpha,
keyholeOutsideOpacity,
cyberMapActive,
);
const bracketAlpha = admissionAlpha * sonarFactor;
if (bracketAlpha > 0) {
const transit = obj.tier?.startsWith('transit_');
(transit ? appendTransitBracket : appendCornerBracket)(
@@ -1404,13 +1461,15 @@ function _drawOverlay(frame) {
obj._cohortSourceId = sourceId;
obj._cohortPriority = _semanticPriority(obj);
obj._cohortBand = bracketAlpha >= 0.999 ? 8 : Math.floor(bracketAlpha * 8);
obj._cohortBand =
admissionAlpha >= 0.999 ? 8 : Math.floor(admissionAlpha * 8);
obj._cohortHash = stableIdentityHash(layerId, sourceId);
obj._candidateScreenX = sx;
obj._candidateScreenY = sy;
obj._candidateHalfW = halfW;
obj._candidateHalfH = halfH;
obj._candidateColor = color;
obj._candidateSonarFactor = sonarLabelFactor;
obj._candidatePrimary = primary;
obj._candidateMicro = micro;
+4
View File
@@ -92,14 +92,18 @@ export function aircraftBracketAlphaFloor(outsideOpacity) {
* @param {number} keyholeAlpha - Radial keyhole alpha for this contact.
* @param {number} [outsideOpacity] - Live OUTSIDE slider value. Omitted means
* the default, so a caller that does not know it reproduces the shipped look.
* @param {boolean} [strictKeyholeFade] - When true, do not apply the AIR
* readability floor; the bracket follows the radial keyhole alpha exactly.
* @returns {number} Paint alpha, 0-1.
*/
export function detectionBracketAlpha(
type,
keyholeAlpha,
outsideOpacity = AIRCRAFT_BRACKET_FLOOR_ANCHOR,
strictKeyholeFade = false,
) {
const alpha = Math.max(0, Math.min(1, Number(keyholeAlpha) || 0));
if (strictKeyholeFade) return alpha;
if (String(type || '').toUpperCase() !== 'AIR' || alpha <= 0) return alpha;
return Math.max(aircraftBracketAlphaFloor(outsideOpacity), alpha);
}
+14
View File
@@ -28,6 +28,20 @@ test('side aircraft brackets stay readable without changing zero-opacity intent'
assert.equal(detectionBracketAlpha('SAT', 0.05), 0.05);
});
test('strict keyhole fading bypasses the aircraft floor for themed brackets', () => {
assert.equal(
detectionBracketAlpha(
'AIR',
0.01,
KEYHOLE_OUTSIDE_OPACITY_DEFAULT,
true,
),
0.01,
);
assert.equal(detectionBracketAlpha('AIR', 0.42, 1, true), 0.42);
assert.equal(detectionBracketAlpha('AIR', 1, 0, true), 1);
});
test('the bracket floor anchor mirrors the real keyhole default it is calibrated to', () => {
// detectionPolicy stays Cesium-free, so the anchor is a mirror. If the keyhole
// default ever moves, this fails rather than silently shifting the shipped
+9 -1
View File
@@ -285,7 +285,15 @@ test('detection holds nothing, and asks for its own frames instead', async () =>
assert.ok(paintLane, 'detection.js still has a paint lane');
assert.doesNotMatch(paintLane, /nowMs: Date\.now\(\)/,
'demand must not re-sample the clock — that is the dropped terminal frame');
assert.match(paintLane, /nowMs: Number\.isFinite\(frame\.timestamp\) \? frame\.timestamp : _nowMs\(\)/);
assert.match(
paintLane,
/const nowMs = Number\.isFinite\(frame\.timestamp\) \? frame\.timestamp : _nowMs\(\)/,
);
assert.match(
paintLane,
/detectionNeedsFollowUpFrame\(\{[\s\S]*?\n\s*nowMs,/,
'the sonar cadence and fade demand share the same frame-clock sample',
);
assert.match(paintLane, /animatingLabelCount: result\.animatingCount/,
'demand counts fades in both directions');
// …and `animatingCount` must really be the BOTH-directions count. Feeding the
+47
View File
@@ -0,0 +1,47 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { detectionBracketAlpha } from './detectionPolicy.js';
// Exercise the actual renderer's admission and paint expressions. A sweep is
// appearance only: it must not reshuffle candidates at the cohort cutoff.
const source = readFileSync(new URL('./detection.js', import.meta.url), 'utf8');
const alphaBlock = source.match(
/const admissionAlpha = detectionBracketAlpha\([\s\S]*?const bracketAlpha = [^;]+;/,
)?.[0];
const bandAssignment = source.match(/obj\._cohortBand\s*=\s*[^;]+;/)?.[0];
test('Sonar changes paint alpha without changing detection cohort admission', () => {
assert.ok(alphaBlock, 'renderer separates admission from paint');
assert.ok(bandAssignment);
for (const cyberMapActive of [false, true]) {
for (const type of ['AIR', 'SAT', 'SEA']) {
for (const keyholeAlpha of [0, 0.01, 0.125, 0.5, 0.999, 1]) {
const alpha = detectionBracketAlpha(
type,
keyholeAlpha,
0.35,
cyberMapActive,
);
const expectedBand = alpha >= 0.999 ? 8 : Math.floor(alpha * 8);
for (const sonarFactor of [0.01, 0.12, 0.5, 1]) {
const state = {
obj: { type },
keyholeAlpha,
keyholeOutsideOpacity: 0.35,
cyberMapActive,
sonarFactor,
detectionBracketAlpha,
};
vm.runInNewContext(
alphaBlock + bandAssignment + '; result = bracketAlpha;',
state,
);
assert.equal(state.obj._cohortBand, expectedBand);
assert.equal(state.result, alpha * sonarFactor);
}
}
}
}
});
+4
View File
@@ -1059,6 +1059,10 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
this.parentElement = null;
}
setAttribute() {}
// This fixture has no parsed DOM children; it exercises native label
// ownership, not the replay overlay's separately rendered icon children.
querySelectorAll() { return []; }
querySelector() { return null; }
getBoundingClientRect() { return { left: 0, top: 0, width: 1600, height: 900 }; }
getContext() { return this.tagName === 'CANVAS' ? context : null; }
}
+6 -1
View File
@@ -661,7 +661,12 @@ test('the voice TOOL SCHEMA matches the pinned release — the mission mapping i
// Canonical serialization pins every tool name, description, property and
// ordering while allowing source formatting. Derived from the unchanged
// release schema before formatting (the previous source-byte pin passed).
const block = JSON.stringify(GEV_REALTIME_TOOLS);
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter((tool) => tool.name !== 'set_cyber_sonar');
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud').parameters.properties.layout;
assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']);
// Cyber deliberately adds one layout; first-run missions still change no tools.
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
const block = JSON.stringify(legacyTools);
// Re-derived for the additive `local-adsb` set_layer_visibility value and
// its common-name mapping; the missions still ride existing tools.
assert.equal(block.length, 27432, 'serialized tool schema length drifted');
+13 -7
View File
@@ -30,6 +30,11 @@ import {
hudTelemetryProvenanceTag,
isHudSummaryUnconfigured,
} from './hudSummaryResponse.js';
import {
applyHudUiTheme,
DEFAULT_HUD_LAYOUT,
normalizeHudLayout,
} from './hudLayouts.js';
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
const HUD_COLORS = {
@@ -58,8 +63,6 @@ const HUD_COLORS = {
/** Shader modes that automatically show the HUD overlay. */
const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']);
/** Allowed HUD layout variants. */
const HUD_VARIANTS = new Set(['tactical', 'operator', 'minimal']);
const HUD_SUMMARY_INTERVAL_MS = 15000;
/**
@@ -110,7 +113,7 @@ export class IntelHUD {
this._autoMode = true; // auto show/hide based on style
this._currentStyle = 'normal';
this._el = null;
this._variant = 'tactical';
this._variant = DEFAULT_HUD_LAYOUT;
this._recBlinkState = true;
this._updateInterval = null;
this._recBlinkInterval = null;
@@ -183,6 +186,8 @@ export class IntelHUD {
if (!this._el) return;
this._el.innerHTML = `
<div class="hud-sonar" aria-hidden="true"></div>
<div class="hud-top-bar">
<span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span>
<span class="hud-top-bar-center">${this._missionId}</span>
@@ -879,15 +884,16 @@ export class IntelHUD {
/**
* Switch the HUD layout variant. Falls back to `'tactical'` if the
* name is unrecognized.
* @param {string} variantName - One of `'tactical'`, `'operator'`, `'minimal'`.
* name is unrecognized. Cyber also applies a coordinated skin to the full
* application shell; leaving Cyber restores the standard shell tokens.
* @param {string} variantName - One of `'tactical'`, `'operator'`, `'minimal'`, `'cyber'`.
*/
setVariant(variantName) {
const normalized = String(variantName || '').toLowerCase();
this._variant = HUD_VARIANTS.has(normalized) ? normalized : 'tactical';
this._variant = normalizeHudLayout(variantName);
if (this._el) {
this._el.dataset.variant = this._variant;
}
applyHudUiTheme(document.documentElement, this._variant);
}
/**
+7
View File
@@ -0,0 +1,7 @@
/** Browser-free layout vocabulary shared by UI and server-side voice tools. */
export const HUD_LAYOUTS = Object.freeze([
'tactical',
'operator',
'minimal',
'cyber',
]);
+64
View File
@@ -0,0 +1,64 @@
import { applyCyberSonarThemePhase } from './cyberSonar.js';
import { syncRadioPanelPlacement } from './ui/radioPanelPlacement.js';
import { HUD_LAYOUTS } from './hudLayoutPolicy.js';
export { HUD_LAYOUTS } from './hudLayoutPolicy.js';
/** Canonical HUD layouts shared by the UI, voice schema and HUD renderer. */
export const DEFAULT_HUD_LAYOUT = 'tactical';
/** Visual treatment applied only when an operator explicitly enters Cyber. */
export const CYBER_VISUAL_DEFAULTS = Object.freeze({
style: 'thermal',
ironbow: 0.42,
});
const HUD_LAYOUT_SET = new Set(HUD_LAYOUTS);
/** @param {unknown} value */
export function isHudLayout(value) {
return HUD_LAYOUT_SET.has(String(value ?? '').toLowerCase());
}
/** @param {unknown} value */
export function normalizeHudLayout(value) {
const normalized = String(value ?? '').toLowerCase();
return isHudLayout(normalized) ? normalized : DEFAULT_HUD_LAYOUT;
}
/**
* Resolve the opt-in visual defaults for an explicit layout transition.
* Restores and repeated Cyber selections return null so they never overwrite
* a shared link or a user's subsequent FLIR tuning.
*/
export function cyberVisualDefaultsForHudTransition(
previousLayout,
nextLayout,
{ explicit = false } = {},
) {
if (!explicit) return null;
if (normalizeHudLayout(previousLayout) === 'cyber') return null;
if (normalizeHudLayout(nextLayout) !== 'cyber') return null;
return CYBER_VISUAL_DEFAULTS;
}
/**
* Keep the global shell skin derived from the selected HUD layout. The data
* attribute lives on <html> so theme tokens are available to every UI surface.
* @param {HTMLElement|{dataset?: Record<string, string>}|null|undefined} root
* @param {unknown} layout
* @returns {'cyber'|null}
*/
export function applyHudUiTheme(root, layout) {
if (!root?.dataset) return null;
if (normalizeHudLayout(layout) === 'cyber') {
root.dataset.uiTheme = 'cyber';
applyCyberSonarThemePhase(root, true);
syncRadioPanelPlacement(root.ownerDocument);
return 'cyber';
}
delete root.dataset.uiTheme;
applyCyberSonarThemePhase(root, false);
syncRadioPanelPlacement(root.ownerDocument);
return null;
}
+28 -4
View File
@@ -147,10 +147,29 @@ function buildRow(documentRef, key) {
return row;
}
/**
* Wire the chip + dialog. Fire-and-forget from main.js; resolves to null when
* the surface has no business existing (prod build, LAN visitor, no markup).
*/
/** Move the existing setup action with the theme, retaining its listeners. */
export function bindKeySetupPlacement(documentRef, chip, root) {
const theme = documentRef?.documentElement;
const toolbar = documentRef?.getElementById?.('top-center-actions');
const Observer = documentRef?.defaultView?.MutationObserver;
if (!theme || !toolbar || !Observer) return () => {};
const sync = () => {
if (theme.dataset.uiTheme === 'cyber') {
if (chip.parentNode !== toolbar) toolbar.append(chip);
} else if (chip.parentNode !== root.parentNode) {
root.before(chip);
}
};
const observer = new Observer(sync);
observer.observe(theme, {
attributes: true,
attributeFilter: ['data-ui-theme'],
});
sync();
return () => observer.disconnect();
}
/** Wire the dev-only setup surface; unavailable endpoints remove it entirely. */
export async function initKeySetup({
documentRef = globalThis.document,
fetchImpl,
@@ -163,12 +182,14 @@ export async function initKeySetup({
const lifetime = new AbortController();
let disposed = false;
let disposeControls = () => {};
let disposePlacement = () => {};
const destroy = () => {
if (disposed) return;
disposed = true;
lifetime.abort();
signal?.removeEventListener('abort', destroy);
disposeControls();
disposePlacement();
chip.remove();
root.remove();
};
@@ -196,6 +217,7 @@ export async function initKeySetup({
}
const rowsHost = root.querySelector('[data-key-setup-rows]');
disposePlacement = bindKeySetupPlacement(documentRef, chip, root);
const applyButton = root.querySelector('[data-key-setup-apply]');
const closeButton = root.querySelector('[data-key-setup-close]');
const chipLabel = chip.querySelector('[data-key-setup-chip-label]') || chip;
@@ -208,6 +230,8 @@ export async function initKeySetup({
if (disposed) return;
status = nextStatus;
chipLabel.textContent = keySetupChipLabel(status);
chip.title = `Project keys: ${keySetupChipLabel(status)}`;
chip.setAttribute('aria-label', chip.title);
// Fully powered is the owner's clean screen: the chip retires. The dialog
// stays reachable this session (and via ?setup=1) to swap or verify keys.
chip.hidden = status.setCount >= status.total;
+35
View File
@@ -4,8 +4,43 @@ import {
collectKeyUpdates,
keySetupChipLabel,
stripKeylessBasemapFromHash,
bindKeySetupPlacement,
} from './keySetup.js';
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
const home = {};
const chip = { parentNode: home, hidden: true };
const root = { parentNode: home, before: (node) => { node.parentNode = home; } };
let moves = 0;
const toolbar = { append: (node) => { moves++; node.parentNode = toolbar; } };
const theme = { dataset: { uiTheme: 'tactical' } };
let notify, disconnected = false;
class Observer {
constructor(callback) { notify = callback; }
observe(target, options) {
assert.equal(target, theme);
assert.deepEqual(options.attributeFilter, ['data-ui-theme']);
}
disconnect() { disconnected = true; }
}
const dispose = bindKeySetupPlacement({
documentElement: theme,
getElementById: () => toolbar,
defaultView: { MutationObserver: Observer },
}, chip, root);
assert.equal(chip.parentNode, home);
for (const variant of ['cyber', 'operator', 'cyber', 'minimal']) {
theme.dataset.uiTheme = variant;
notify();
assert.equal(chip.parentNode, variant === 'cyber' ? toolbar : home);
notify();
}
assert.equal(moves, 2, 'identical theme sync does not reparent again');
assert.equal(chip.hidden, true, 'placement cannot reveal a retired button');
dispose();
assert.equal(disconnected, true);
});
test('the chip counts what is missing, and retires the count at zero', () => {
assert.equal(keySetupChipLabel({ setCount: 0, total: 8 }), 'POWER UP · 8 KEYS WAITING');
assert.equal(keySetupChipLabel({ setCount: 7, total: 8 }), 'POWER UP · 1 KEY WAITING');
+16
View File
@@ -1,4 +1,8 @@
import * as Cesium from 'cesium';
import {
createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive,
} from '../../cyberSonar.js';
import {
formatAwarenessLabel,
formatAwarenessDistance,
@@ -105,6 +109,9 @@ export function createRendering({
const canvas = layerState.viewer.scene.canvas;
const width = canvas.clientWidth || canvas.width;
const height = canvas.clientHeight || canvas.height;
const sonar = isCyberSonarActive()
? createCyberSonarSampler(width, height)
: null;
const geometry = getKeyholeGeometry(width, height);
if (!(geometry.radius > 0)) {
root.hidden = true;
@@ -182,6 +189,15 @@ export function createRendering({
marker.style.left = `${geometry.centerX + Math.cos(projection.angle) * markerRadius}px`;
marker.style.top = `${geometry.centerY + Math.sin(projection.angle) * markerRadius}px`;
marker.style.opacity = String(projection.opacity);
const sonarFactor =
sonar?.at(
geometry.centerX + Math.cos(projection.angle) * markerRadius,
geometry.centerY + Math.sin(projection.angle) * markerRadius,
) ?? 1;
marker._arrow.style.opacity = String(sonarFactor);
marker._label.style.opacity = String(
sonar ? sonar.label(sonarFactor) : 1,
);
marker._arrow.style.transform = `translate(-50%, -50%) rotate(${projection.angle}rad)`;
marker._label.style.transform = `translate(-50%, -50%) translate(${-Math.cos(projection.angle) * 56}px, ${-Math.sin(projection.angle) * 56}px)`;
const label = formatAwarenessLabel(item);
+5 -3
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import { selectModelEligible } from '../../data/modelEligibility.js';
import { civilAircraftModelSpec } from './modelSpec.js';
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
@@ -122,7 +123,7 @@ export function createRendering({
const isCockpitNear =
isCockpitContact && flightState._cockpitNearContacts.has(icao24);
if (isCockpitContact && !isCockpitNear) {
const freshnessAlpha = bb.color?.alpha ?? 1;
const freshnessAlpha = cyberSonarBaseAlpha(bb);
bb.image = cockpitContactDotImage();
bb.width = COCKPIT_CONTACT_SIZE_PX;
bb.height = COCKPIT_CONTACT_SIZE_PX;
@@ -144,7 +145,7 @@ export function createRendering({
bb.height = icao24 === flightState._trackedIcao ? 24 : 20;
bb.scale = _fleetBillboardScale(icao24, meta?.klass) * limbScale;
bb.scaleByDistance = _normalBillboardScaleByDistance();
bb.color = _fleetBillboardColor(icao24).withAlpha(bb.color?.alpha ?? 1);
bb.color = _fleetBillboardColor(icao24).withAlpha(cyberSonarBaseAlpha(bb));
}
/**
@@ -1006,12 +1007,13 @@ export function createRendering({
const isCockpitNear =
flightState._cockpitContactMode &&
flightState._cockpitNearContacts.has(icao24);
const baseColor =
const layerBaseColor =
flightState._cockpitContactMode && !isCockpitNear
? isMilitaryIcao(icao24)
? MIL_TINT
: COCKPIT_CIVILIAN_COLOR
: _fleetBillboardColor(icao24);
const baseColor = layerBaseColor;
const treatment = applyAircraftBillboardTreatment({
billboard: bb,
baseScale:
+2 -1
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import {
turnRateFromFixHistory,
liftRepeatedGroundFix,
@@ -165,7 +166,7 @@ export function createFlightSnapshotRenderer({
// freshness × focus × horizon alpha composition.
bb.color = rendering
._fleetBillboardColor(icao24)
.withAlpha(bb.color?.alpha ?? 1);
.withAlpha(cyberSonarBaseAlpha(bb));
}
if (
prevMeta?.klass !== meta.klass ||
+2
View File
@@ -250,6 +250,8 @@ export function createFlightState({ source, services }) {
flightState._scratchFleetPos = new Cesium.Cartesian3();
flightState._scratchSonarScreen = new Cesium.Cartesian2();
flightState._scratchDrRaw = new Cesium.Cartesian3();
flightState._scratchWarmupTime = new Cesium.JulianDate();
+24
View File
@@ -1,4 +1,8 @@
import * as Cesium from 'cesium';
import {
createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive,
} from '../../cyberSonar.js';
import {
ROCKET_MISSION_AMBIENT_OVERLAY_COHORT_LIMIT,
ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID,
@@ -303,6 +307,15 @@ export function createOverlays({ state: layerState, services, parts, source }) {
<span data-replay-overlay-detail></span>
</div>`;
host.appendChild(layerState._replayVehicleOverlay);
layerState._replayVehicleOverlay._sonarIcons = [
...layerState._replayVehicleOverlay.querySelectorAll(
'.mission-replay-flight-symbol, .mission-replay-orbit-dot',
),
];
layerState._replayVehicleOverlay._sonarLabel =
layerState._replayVehicleOverlay.querySelector(
'.mission-replay-overlay-callout',
);
}
function hideReplayVehicleOverlay() {
@@ -502,6 +515,17 @@ export function createOverlays({ state: layerState, services, parts, source }) {
: windowPosition;
track.lastOverlayWindowPosition = renderedWindowPosition;
track.lastOverlayMode = mode;
const sonar = isCyberSonarActive()
? createCyberSonarSampler(canvas.clientWidth, canvas.clientHeight)
: null;
const factor =
sonar?.at(renderedWindowPosition.x, renderedWindowPosition.y) ?? 1;
for (const icon of layerState._replayVehicleOverlay._sonarIcons || [])
icon.style.opacity = String(factor);
if (layerState._replayVehicleOverlay._sonarLabel)
layerState._replayVehicleOverlay._sonarLabel.style.opacity = String(
sonar ? sonar.label(factor) : 1,
);
layerState._replayVehicleOverlay.hidden = false;
layerState._replayVehicleOverlay.dataset.mode = mode;
layerState._replayVehicleOverlay.classList.toggle(
+1 -1
View File
@@ -268,7 +268,7 @@ export function createPanel({ state: layerState, services, parts, source }) {
index < 0 || index >= layerState._launches.length - 1;
parts.replay.syncReplayButton();
const panelScroller = layerState._missionPanel.closest(
'.global-context-panel-inner',
":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner",
);
if (panelScroller) panelScroller.scrollTop = 0;
}
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createPanel } from './panel.js';
test('rendering a selected mission resets the nearest theme-appropriate Context scroller', () => {
for (const cyber of [true, false]) {
const body = { scrollTop: 180 };
const inner = { scrollTop: 90 };
const output = { closest: () => null, removeAttribute() {} };
const state = {
_missionPanel: {
querySelector: () => output,
closest(selector) {
assert.equal(selector, ":root[data-ui-theme='cyber'] .cyber-panel-body, .global-context-panel-inner");
return cyber ? body : inner;
},
},
_selectedLaunchId: 'mission-1',
_launches: [{ id: 'mission-1', name: 'Example', payloads: [], recoveryStages: [] }],
_replayTracks: new Map(),
};
const panel = createPanel({
state,
parts: {
overlays: { shortMissionLabel: (name) => name },
policyHelpers: { missionPathPresentation: () => ({}) },
replay: { syncReplayButton() {} },
},
});
panel.renderMissionPanel();
assert.equal(body.scrollTop, cyber ? 0 : 180);
assert.equal(inner.scrollTop, cyber ? 90 : 0);
}
});
+5 -3
View File
@@ -9,6 +9,7 @@ import {
} from '../../data/modelVisualAnchor.js';
import * as Cesium from 'cesium';
import { selectModelEligible } from '../../data/modelEligibility.js';
import { cyberSonarBaseAlpha } from '../../cyberSonar.js';
import { cockpitContactDotImage } from '../../data/cockpitContactDot.js';
import { aircraftIcon, TRACKED_ICON_PX } from '../../data/aircraftIcons.js';
import {
@@ -152,7 +153,7 @@ export function createRendering({
const isCockpitNear =
isCockpitContact && flightState._cockpitNearContacts.has(icao24);
if (isCockpitContact && !isCockpitNear) {
const freshnessAlpha = bb.color?.alpha ?? 1;
const freshnessAlpha = cyberSonarBaseAlpha(bb);
bb.image = cockpitContactDotImage();
bb.width = COCKPIT_CONTACT_SIZE_PX;
bb.height = COCKPIT_CONTACT_SIZE_PX;
@@ -172,7 +173,7 @@ export function createRendering({
bb.height = icao24 === flightState._trackedIcao ? 24 : 20;
bb.scale = _militaryBillboardScale(icao24) * limbScale;
bb.scaleByDistance = _normalBillboardScaleByDistance();
bb.color = MIL_ICON_COLOR.withAlpha(bb.color?.alpha ?? 1);
bb.color = MIL_ICON_COLOR.withAlpha(cyberSonarBaseAlpha(bb));
}
/** Sprite kind for one contact's billboard. Identity for every aircraft except
@@ -964,6 +965,7 @@ export function createRendering({
const isCockpitNear =
flightState._cockpitContactMode &&
flightState._cockpitNearContacts.has(icao24);
const baseColor = MIL_ICON_COLOR;
const treatment = applyAircraftBillboardTreatment({
billboard: bb,
baseScale:
@@ -971,7 +973,7 @@ export function createRendering({
? 1
: _militaryBillboardScale(icao24),
baseAlpha: flightState.records.missingPolls.get(icao24) ? 0.45 : 1,
baseColor: MIL_ICON_COLOR,
baseColor,
focusFactor: focus.factor,
cameraDistanceM,
cameraHeightM: camera.positionCartographic?.height,
+2
View File
@@ -227,6 +227,8 @@ export function createFlightState({ source, services }) {
flightState._scratchFleetPos = new Cesium.Cartesian3();
flightState._scratchSonarScreen = new Cesium.Cartesian2();
flightState._scratchDrRaw = new Cesium.Cartesian3();
flightState._scratchWarmupTime = new Cesium.JulianDate();
+28 -3
View File
@@ -1,4 +1,9 @@
import * as Cesium from 'cesium';
import {
createCyberSonarSampler,
isCyberContactSonarActive as isCyberSonarActive,
CYBER_SONAR_RENDER_INTERVAL_MS,
} from '../cyberSonar.js';
import {
getKeyholeFadeTuning,
getKeyholeGeometry,
@@ -170,6 +175,7 @@ let _resizeObserver = null;
let _mutationObserver = null;
let _observedOccluderElements = new WeakSet();
let _occluderRefreshTimer = null;
let _sonarRenderTimer = null;
let _cockpitModeHandler = null;
let _windowResizeHandler = null;
let _cockpitActive = false;
@@ -2443,7 +2449,7 @@ function paintCustomLane(lane) {
}
}
function paintEntryItem(item, keyhole) {
function paintEntryItem(item, keyhole, sonar) {
const { record, placement } = item;
const entry = record.entry;
let keyholeAlpha = 1;
@@ -2468,7 +2474,8 @@ function paintEntryItem(item, keyhole) {
item.temporalAlpha *
record.distanceAlpha *
record.altitudeAlpha *
keyholeAlpha;
keyholeAlpha *
(sonar ? sonar.label(sonar.at(placement.anchorX, placement.anchorY)) : 1);
if (finalAlpha <= 0.001) return;
if (record.paintScale === 1) {
paintOverlayEntry(
@@ -2508,6 +2515,9 @@ function paintEntryItem(item, keyhole) {
function paintFrame(keyhole) {
const started = nowMs();
const sonar = isCyberSonarActive()
? createCyberSonarSampler(_canvasWidth, _canvasHeight, started)
: null;
clearCanvas(true, false);
_detectionSurfacePrepared = false;
if (
@@ -2528,7 +2538,7 @@ function paintFrame(keyhole) {
// retains its former z5 position below every ordinary host entry at z6.
paintCustomLane(lane);
while (itemIndex < _paintCount && _paintQueue[itemIndex].lane === lane) {
paintEntryItem(_paintQueue[itemIndex], keyhole);
paintEntryItem(_paintQueue[itemIndex], keyhole, sonar);
itemIndex++;
}
}
@@ -2581,6 +2591,10 @@ function resetFrameDiagnostics() {
}
function drawWorldOverlay() {
if (_sonarRenderTimer !== null) {
clearTimeout(_sonarRenderTimer);
_sonarRenderTimer = null;
}
if (_destroyed || !_viewer || !_canvas || !_ctx) return;
const timestamp = nowMs();
if (!overlayHasPaintWork(timestamp)) {
@@ -2622,6 +2636,15 @@ function drawWorldOverlay() {
_diagnostics.projectionMs = nowMs() - projectionStarted;
solveDomains(timestamp);
paintFrame(keyhole);
// Labels-only layers need a bounded sweep refresh even with detection off.
// Empty/hidden hosts and teardown never retain scanner render demand.
if (_paintRectCount > 0 && isCyberSonarActive()) {
_sonarRenderTimer = setTimeout(() => {
_sonarRenderTimer = null;
if (!_destroyed && isCyberSonarActive())
_viewer?.scene?.requestRender?.();
}, CYBER_SONAR_RENDER_INTERVAL_MS);
}
const animationsRemaining =
activeFadeCount(timestamp) + activeLeaderAnimationCount(timestamp);
if (animationsRemaining > 0) _viewer.scene.requestRender?.();
@@ -2679,6 +2702,8 @@ export function initWorldOverlay(viewer) {
/** Remove all listeners, observers, entries, diagnostics, and overlay DOM. */
export function destroyWorldOverlay() {
if (_sonarRenderTimer !== null) clearTimeout(_sonarRenderTimer);
_sonarRenderTimer = null;
// Tearing down a host that was never initialized must not arm the
// post-destroy guard: sources are allowed to publish before the first
// `initWorldOverlay`, and that buffering has to survive a stray destroy.
+4 -4
View File
@@ -197,7 +197,7 @@ test('share-panel state excludes responsive collapse and preserves recipient pre
assert.match(
ui,
/const collapsed = panelEl\.classList\.contains\('layout-auto-collapsed'\)\s*\? false\s*: panelEl\.classList\.contains\('collapsed'\);/,
/const collapsed =[\s\S]*?panelEl\.classList\.contains\('layout-auto-collapsed'\)[\s\S]*?panelEl\.classList\.contains\('cyber-accordion-collapsed'\)[\s\S]*?\? false\s*: panelEl\.classList\.contains\('collapsed'\);/,
'responsive auto-collapse must serialize the explicit expanded preference',
);
assert.match(
@@ -218,9 +218,9 @@ test('parameterized Display presets keep one stable scroll owner', () => {
const ui = readShellSource();
const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match(css, /#pp-toggles:not\(\.collapsed\) > #param-slider-panel\.active\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?max-height:\s*none;[\s\S]*?overflow-y:\s*visible;/);
assert.match(ui, /readDisplayScrollTop: \(\) =>\s*this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?this\._ppToggles\?\.scrollTop \|\| 0/);
assert.match(rightRail, /displayPanel\.scrollTop = Math\.min\(displayScrollTop, maxScrollTop\);/);
assert.match(css, /#pp-toggles:not\(\.collapsed\) > \.pp-panel-body > #param-slider-panel\.active\s*\{[\s\S]*?flex:\s*0 0 auto;[\s\S]*?max-height:\s*none;[\s\S]*?overflow-y:\s*visible;/);
assert.match(ui, /readDisplayScrollTop: \(\) =>\s*this\._displayPortalScrollRestoreOwner === 'standard'[\s\S]*?this\._standardDisplayScrollTop[\s\S]*?displayPanelScroller\(this\._ppToggles\)\?\.scrollTop \|\| 0/);
assert.match(rightRail, /scroller\.scrollTop = Math\.min\(displayScrollTop, maxScrollTop\);/);
assert.match(
ui,
/this\._sliderPanel\.classList\.remove\('active'\);\s*this\._scheduleRightPanelLayout\(\);/,
+15 -5
View File
@@ -25,11 +25,11 @@ const css = readStylesheet(new URL('../style.css', import.meta.url));
function realtimeTools() { return GEV_REALTIME_TOOLS; }
test('Realtime schema exposes the authoritative 29-tool inventory', () => {
test('Realtime schema exposes the authoritative 30-tool inventory', () => {
const tools = realtimeTools();
assert.equal(tools.length, 29);
assert.equal(tools.length, 30);
const names = tools.map((tool) => tool.name);
assert.equal(new Set(names).size, 29, 'tool names are unique');
assert.equal(new Set(names).size, 30, 'tool names are unique');
assert.ok(names.includes('set_context_mode'));
assert.ok(names.includes('control_cockpit'));
assert.ok(names.includes('select_nearest_aircraft'));
@@ -173,6 +173,7 @@ test('no unchanged Realtime tool definition drifts silently', () => {
// in the mic-test brief which tools moved — the session cache busts on any
// schema change.
const TOUCHED = new Set([
'set_cyber_sonar',
'set_context_mode',
'control_cockpit',
'set_panel_open',
@@ -188,7 +189,13 @@ test('no unchanged Realtime tool definition drifts silently', () => {
]);
const unchanged = realtimeTools()
.filter((tool) => !TOUCHED.has(tool.name))
.sort((a, b) => a.name.localeCompare(b.name));
.sort((a, b) => a.name.localeCompare(b.name))
.map((tool) => structuredClone(tool))
.filter((tool) => tool.name !== 'set_cyber_sonar');
// Cyber adds one HUD choice and the sonar tool; retain the existing pin for every legacy field.
const hudLayout = unchanged.find((tool) => tool.name === 'set_hud').parameters.properties.layout;
assert.deepEqual(hudLayout.enum, ['tactical', 'operator', 'minimal', 'cyber']);
hudLayout.enum = hudLayout.enum.filter((layout) => layout !== 'cyber');
assert.equal(unchanged.length, 18);
const digest = createHash('sha256')
.update(JSON.stringify(unchanged))
@@ -274,7 +281,10 @@ test('Radio is nested inside Context with separate disclosure and power controls
assert.match(radioControlsSource, /classList\.remove\('radio-broadcasting'\)/);
assert.match(radioBindings, /this\.radio\.getTunerStations\(750\)/);
assert.match(radioBindings, /radioTunerPointerPosition\(/);
assert.doesNotMatch(css, /#right-context-rail\s*>\s*#radio-panel/);
// Only the Cyber skin promotes Radio to a peer panel. Other themes retain
// the embedded Context layout; theme round-trip behavior has separate tests.
const baseCss = css.replace(readFileSync(new URL('./ui/styles/cyber.css', import.meta.url), 'utf8'), '');
assert.doesNotMatch(baseCss, /#right-context-rail\s*>\s*#radio-panel/);
assert.match(css, /#global-context-panel #radio-panel\.collapsed/);
assert.doesNotMatch(css, /\.context-radio-dock\.active:hover \.context-radio-mini/);
assert.doesNotMatch(css, /\.context-radio-dock\.active:focus-within \.context-radio-mini/);
+33 -3
View File
@@ -22,6 +22,11 @@ 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';
@@ -191,6 +196,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,
@@ -486,6 +502,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,
@@ -511,6 +533,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) =>
@@ -1071,16 +1095,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(),
@@ -1196,6 +1220,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.
@@ -1208,6 +1237,7 @@ export class StyleManager extends ShellFacade {
hud: {
visible: !!this.hud?.visible,
layout: this.hud?.getVariant?.() || null,
sonar: getCyberSonarControlState(),
},
detection: this.getDetectionState(),
bloom: {
+1
View File
@@ -293,6 +293,7 @@ export function bindCameraOrientationControls({
);
}
if (!applied || applied.heading !== next.heading) {
northButton?.setAttribute('data-north-up', String(next.heading === 0));
northButton?.style?.setProperty('--camera-heading', `${next.heading}deg`);
northButton?.setAttribute(
'aria-label',
+27
View File
@@ -337,6 +337,33 @@ test('the needle follows a rotation too small for the camera-changed threshold',
controls.destroy();
});
test('north-up indicator follows real heading including the 360-degree seam', () => {
const { viewer } = createRealViewer(AUSTIN_GROUND);
const northButton = new FakeButton();
const controls = bindCameraOrientationControls({
viewer,
elements: { northButton },
runNavigation: (_noun, navigate) => navigate(),
});
for (const [heading, active] of [
[0, true],
[15, false],
[359.8, true],
[359, false],
[360, true],
[90, false],
]) {
orbit(viewer, AUSTIN_GROUND, heading, Cesium.Math.toRadians(-80), 5_000);
viewer.scene.preRender.raiseEvent();
assert.equal(
northButton.getAttribute('data-north-up'),
String(active),
`heading ${heading}`,
);
}
controls.destroy();
});
test('a settled camera writes nothing, and teardown releases every signal', () => {
const { viewer } = createRealViewer(AUSTIN_GROUND);
orbit(viewer, AUSTIN_GROUND, 0, Cesium.Math.toRadians(-80), 5_000);
+12 -5
View File
@@ -1,3 +1,5 @@
import { displayPanelScroller } from './displayPanelScroll.js';
/** Move the existing Display groups between their two presentation surfaces. */
export class CockpitDisplayPortal {
constructor({ standardPanel, cockpitPanel, groups, layout }) {
@@ -20,15 +22,18 @@ export class CockpitDisplayPortal {
group.before(anchor);
return [{ name, group, slot, anchor }];
});
this.standardScrollTop = standardPanel?.scrollTop || 0;
this.standardScrollTop =
displayPanelScroller(standardPanel)?.scrollTop || 0;
this.cockpitScrollTop = cockpitPanel?.scrollTop || 0;
const options = { passive: true, signal: this.listeners.signal };
standardPanel?.addEventListener(
'scroll',
() => {
if (!this.active) this.standardScrollTop = standardPanel.scrollTop;
if (!this.active)
this.standardScrollTop =
displayPanelScroller(standardPanel).scrollTop;
},
options,
{ ...options, capture: true },
);
cockpitPanel?.addEventListener(
'scroll',
@@ -85,7 +90,8 @@ export class CockpitDisplayPortal {
if (nextActive && this.cockpitPanel)
this.cockpitPanel.scrollTop = this.cockpitScrollTop;
if (!nextActive && this.standardPanel)
this.standardPanel.scrollTop = this.standardScrollTop;
displayPanelScroller(this.standardPanel).scrollTop =
this.standardScrollTop;
};
if (!settle) {
restoreScroll();
@@ -121,7 +127,8 @@ export class CockpitDisplayPortal {
}
this.cockpitPanel?.classList.remove('uses-shared-display-controls');
if (this.standardPanel)
this.standardPanel.scrollTop = this.standardScrollTop;
displayPanelScroller(this.standardPanel).scrollTop =
this.standardScrollTop;
this.records = [];
this.restoreOwner = null;
}
+135 -6
View File
@@ -2,6 +2,7 @@
import {
resolveCockpitUtilityAnchor,
resolveCockpitUtilityLayout,
resolveCyberCockpitPanelLane,
} from '../cockpitUtilityLayout.js';
import {
COCKPIT_UTILITY_REC_GAP_PX,
@@ -90,7 +91,53 @@ export function syncContextLayout() {
'--cockpit-context-left',
`${desktopInset.toFixed(1)}px`,
);
this.context.style.removeProperty('--cockpit-context-top');
if (document.documentElement.dataset.uiTheme !== 'cyber') {
this.context.style.removeProperty('--cockpit-context-top');
this.context.style.removeProperty('--cockpit-context-max-height');
return;
}
const defaultTop = Math.max(255, Math.min(300, window.innerHeight * 0.28));
const dataPanel = document.querySelector(
'#left-panel-stack > #data-panel:not(.collapsed)',
);
let desiredTop = defaultTop;
if (this.contextCollapsed && dataPanel) {
desiredTop = Math.max(
desiredTop,
dataPanel.getBoundingClientRect().bottom + 8,
);
}
const contextBounds = this.context.getBoundingClientRect();
let safeBottom = window.innerHeight - 24;
for (const selector of [
'#cesium-credits',
'#command-dock',
'#view-switcher',
]) {
const obstacle = document.querySelector(selector);
if (!obstacle) continue;
const style = window.getComputedStyle(obstacle);
if (
style.display === 'none' ||
style.visibility === 'hidden' ||
Number(style.opacity) === 0
)
continue;
const bounds = obstacle.getBoundingClientRect();
const overlapsHorizontally =
bounds.right > contextBounds.left && bounds.left < contextBounds.right;
if (overlapsHorizontally && bounds.top > desiredTop) {
safeBottom = Math.min(safeBottom, bounds.top - 8);
}
}
const boundedTop = Math.max(
defaultTop,
Math.min(desiredTop, safeBottom - contextBounds.height),
);
this.context.style.setProperty(
'--cockpit-context-top',
`${boundedTop.toFixed(1)}px`,
);
this.context.style.removeProperty('--cockpit-context-max-height');
}
@@ -127,6 +174,34 @@ export function syncSignalLayout() {
);
this.signalStream.style.removeProperty('--cockpit-signal-top');
this.signalStream.style.removeProperty('--cockpit-signal-max-height');
const cyber = document.documentElement.dataset.uiTheme === 'cyber';
const leftPanel = cyber
? document.querySelector('#left-panel-stack > #data-panel')
: null;
const alignedTop = isRenderedOnScreen(leftPanel)
? leftPanel.getBoundingClientRect().top
: null;
// Cyber's two rails share a top edge. Move the briefing below the collapsed
// strip rather than pulling the right rail upward to avoid it.
if (alignedTop !== null && utilityControls) {
const expanded = utilityControls.querySelector(
'.cockpit-utility-control.is-expanded',
);
if (!expanded) {
const signalTop = Math.max(
Math.max(255, Math.min(300, window.innerHeight * 0.28)),
alignedTop + utilityControls.getBoundingClientRect().height + 28,
);
this.signalStream.style.setProperty(
'--cockpit-signal-top',
`${signalTop.toFixed(1)}px`,
);
this.signalStream.style.setProperty(
'--cockpit-signal-max-height',
`${Math.max(44, window.innerHeight - signalTop - 80).toFixed(1)}px`,
);
}
}
const signalBounds = this.signalStream.getBoundingClientRect();
const utilityBounds = utilityControls?.getBoundingClientRect();
if (utilityBounds) {
@@ -151,9 +226,51 @@ export function syncSignalLayout() {
collapsedLauncher.scrollHeight,
)
: 0;
// Cockpit owns this anchor outright. The strip used to inherit the left
// accordion's committed top, which is solved against left-lane obstacles
// and dropped the strip straight through the briefing card below it.
if (alignedTop !== null) {
const lane = resolveCyberCockpitPanelLane({
viewportHeight: window.innerHeight,
top: alignedTop,
launcherHeight: Math.max(
collapsedHeight,
collapsedLauncher?.getBoundingClientRect().height || 60,
),
signalHeight: this.signalCollapsed ? signalBounds.height : 44,
contactHeight:
this.contextCollapsed && this.context
? this.context.getBoundingClientRect().height
: 64,
});
document.body.style.setProperty(
'--cyber-cockpit-panel-height',
`${lane.panelHeight.toFixed(2)}px`,
);
this.hud?.style.setProperty(
'--cockpit-utility-top',
`${alignedTop.toFixed(2)}px`,
);
this.hud?.style.setProperty(
'--cockpit-utility-max-height',
`${lane.utilityHeight.toFixed(2)}px`,
);
this.hud?.style.setProperty(
'--cockpit-utility-expanded-max-height',
`${lane.panelHeight.toFixed(2)}px`,
);
utilityControls.classList.remove('layout-primary-only');
utilityControls
.querySelectorAll('.cockpit-utility-control')
.forEach((control) => control.setAttribute('aria-hidden', 'false'));
if (expandedControl) {
this.signalStream.style.setProperty(
'--cockpit-signal-top',
`${lane.signalTop.toFixed(2)}px`,
);
}
this.syncContextLayout();
return;
}
// Non-Cyber themes retain their independent REC/briefing corridor. Cyber
// uses the rendered Data Layers top after reserving briefing space above.
// The readout only anchors the strip while it is genuinely on screen:
// the Minimal variant drops it with `display:none`, but HUD Off hides the
// whole Intel HUD with `visibility`/`opacity`, which keeps its rect.
@@ -169,8 +286,20 @@ export function syncSignalLayout() {
collapsedHeight,
recGap: COCKPIT_UTILITY_REC_GAP_PX,
signalGap: COCKPIT_UTILITY_SIGNAL_GAP_PX,
minTopFloor: COCKPIT_UTILITY_MIN_TOP_PX,
minTopRatio: COCKPIT_UTILITY_MIN_TOP_RATIO,
minTopFloor: alignedTop ?? COCKPIT_UTILITY_MIN_TOP_PX,
minTopRatio: alignedTop !== null ? 0 : COCKPIT_UTILITY_MIN_TOP_RATIO,
signalCollapsed: this.signalCollapsed,
utilityExpanded: Boolean(expandedControl),
// Only Cyber relocates its collapsed briefing below the utility lane.
// Other themes keep the measured visible card as the lower boundary.
reserveViewportLane: document.documentElement.dataset.uiTheme === 'cyber',
// Cyber keeps the collapsed briefing reachable below the utility instead
// of hiding it. Reserve its actual height, including wrapped headings.
signalHeight:
document.documentElement.dataset.uiTheme === 'cyber' &&
this.signalCollapsed
? signalBounds.height
: 0,
});
const availableHeight = utilityAnchor.maxHeight;
this.hud?.style.setProperty(
+87
View File
@@ -0,0 +1,87 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { PanelChrome } from './panelChrome.js';
function fixture(theme = 'cyber') {
const nodes = new Map();
const node = (id, collapsed = true) => {
const classes = new Set(collapsed ? ['collapsed'] : []);
const value = { id, hidden: false, children: [],
matches: () => true,
contains(target) { return this === target || this.children.some((child) => child.contains(target)); },
classList: {
contains: (name) => classes.has(name),
add: (...names) => names.forEach((name) => classes.add(name)),
remove: (...names) => names.forEach((name) => classes.delete(name)),
toggle: (name, on) => on ? classes.add(name) : classes.delete(name),
},
};
nodes.set(id, value);
return value;
};
const rail = node('rail');
const display = node('pp-toggles'), context = node('global-context-panel'), cctv = node('cctv-panel'), radio = node('radio-panel');
rail.children = [display, context, cctv];
for (const panel of rail.children) panel.parentElement = rail;
context.children = [radio];
radio.parentElement = context;
const saved = [], claimed = [];
const owner = {
_rightPanelStack: rail,
_panelLayout: {},
_lifetime: { frame() {} },
_syncPanelCollapseButton() {}, _scheduleRightPanelLayout() {},
_scheduleLeftPanelLayout() {}, _layoutRightPanels() {}, _syncCctvPanelViewport() {},
_savePanelCollapsedState: (...args) => saved.push(args),
shareLinkManager: { claimRestoreLane: (...args) => claimed.push(args), onPanelStateChange() {} },
};
owner.setPanelCollapsed = PanelChrome.prototype.setPanelCollapsed.bind(owner);
return { display, context, cctv, radio, owner, saved, claimed,
doc: { documentElement: { dataset: { uiTheme: theme } }, getElementById: (id) => nodes.get(id) },
};
}
test('explicit Cyber opening collapses peers and claims their restoration lanes', () => {
const f = fixture(), prior = globalThis.document;
globalThis.document = f.doc;
try {
f.owner.setPanelCollapsed('global-context-panel', false, { explicit: true });
f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true });
assert.equal(f.display.classList.contains('collapsed'), false);
assert.equal(f.context.classList.contains('collapsed'), true);
assert.ok(f.saved.some(([id, collapsed]) => id === 'global-context-panel' && collapsed));
assert.ok(f.claimed.some(([, id]) => id === 'global-context-panel'));
f.cctv.classList.add('cyber-accordion-collapsed');
f.owner.setPanelCollapsed('cctv-panel', true, { explicit: true });
assert.equal(f.cctv.classList.contains('cyber-accordion-collapsed'), false);
} finally { globalThis.document = prior; }
});
test('opening already-expanded Radio reveals Context and closes Display', () => {
const f = fixture(), prior = globalThis.document;
globalThis.document = f.doc;
try {
f.owner.setPanelCollapsed('radio-panel', false, { explicit: true });
f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true });
assert.equal(f.radio.classList.contains('collapsed'), false);
assert.equal(f.context.classList.contains('collapsed'), true);
f.owner.setPanelCollapsed('radio-panel', false, { explicit: true });
assert.equal(f.context.classList.contains('collapsed'), false);
assert.equal(f.display.classList.contains('collapsed'), true);
} finally { globalThis.document = prior; }
});
test('non-Cyber explicit panels and restored preferences retain their existing policy', () => {
for (const theme of ['tactical', 'operator', 'minimal', 'cyber']) {
const f = fixture(theme), prior = globalThis.document;
globalThis.document = f.doc;
try {
const options = theme === 'cyber' ? { restore: true, persist: false } : { explicit: true };
f.owner.setPanelCollapsed('pp-toggles', false, options);
f.owner.setPanelCollapsed('cctv-panel', false, options);
assert.equal(f.display.classList.contains('collapsed'), false);
assert.equal(f.cctv.classList.contains('collapsed'), false);
if (theme === 'cyber') assert.deepEqual(f.saved, []);
} finally { globalThis.document = prior; }
}
});
+86
View File
@@ -0,0 +1,86 @@
import {
isCyberSonarEnabled,
isCyberSonarActive,
isCyberContactSonarActive,
readCyberSonarSettings,
} from '../cyberSonar.js';
const SETTING_FIELDS = Object.freeze({
rings: ['rings', 3, 12],
rangePct: ['range', 60, 120],
intensityPct: ['intensity', 0, 100],
opacityPct: ['opacity', 35, 100],
sectorDeg: ['sector', 8, 60],
});
/** Read configuration separately from whether map effects can currently run. */
export function getCyberSonarControlState(doc = globalThis.document) {
const root = doc?.documentElement;
const settings = readCyberSonarSettings(root);
const options = {
root,
body: doc?.body,
hud: doc?.getElementById?.('intel-hud') ?? null,
};
return {
enabled: isCyberSonarEnabled(root),
rings: settings.rings,
rangePct: settings.range,
intensityPct: settings.intensity,
opacityPct: settings.opacity,
sectorDeg: settings.sector,
cyberSelected: root?.dataset?.uiTheme === 'cyber',
mapSweepActive: isCyberSonarActive(options),
contactSweepActive:
root?.dataset?.cyberSonarGpu === 'supported' &&
isCyberContactSonarActive(options),
contactRenderer: root?.dataset?.cyberSonarGpu ?? 'not-initialized',
};
}
/** Validate every field before mutating through the same owner as the sliders. */
export function setCyberSonarControls(
owner,
request,
doc = globalThis.document,
) {
const fail = (error) => ({
ok: false,
error,
sonar: getCyberSonarControlState(doc),
});
if (!request || typeof request !== 'object' || Array.isArray(request)) {
return fail('Provide Cyber sonar settings as an object.');
}
const fields = Object.keys(request);
if (!fields.length) return fail('Provide at least one Cyber sonar setting.');
for (const field of fields) {
const value = request[field];
if (field === 'enabled') {
if (typeof value !== 'boolean') return fail('enabled must be a boolean.');
continue;
}
if (!Object.hasOwn(SETTING_FIELDS, field)) {
return fail('Unknown Cyber sonar setting.');
}
const [, min, max] = SETTING_FIELDS[field];
if (!Number.isInteger(value) || value < min || value > max) {
return fail(`${field} must be an integer from ${min} to ${max}.`);
}
}
if (
owner.hud?.getVariant?.() !== 'cyber' ||
doc?.documentElement?.dataset?.uiTheme !== 'cyber'
) {
return fail('Cyber sonar controls require the Cyber HUD layout.');
}
for (const [field, [setting]] of Object.entries(SETTING_FIELDS)) {
if (Object.hasOwn(request, field)) {
owner._setCyberSonarSetting(setting, request[field]);
}
}
if (Object.hasOwn(request, 'enabled')) {
owner._setCyberSonarEnabled(request.enabled);
}
return { ok: true, sonar: getCyberSonarControlState(doc) };
}
+161
View File
@@ -0,0 +1,161 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
getCyberSonarControlState,
setCyberSonarControls,
} from './cyberSonarControls.js';
import { GEV_ACTION_SCHEMAS } from '../voice/actionSchemas.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import { createGevActionRunner } from '../voice/gevActions.js';
import { StyleManager } from './applicationShell.js';
import { VisualSettings } from './visualSettings.js';
import { readFileSync } from 'node:fs';
function fixture(theme = 'cyber') {
const root = {
dataset: { uiTheme: theme, cyberSonarGpu: 'supported' },
style: { setProperty() {} },
};
const flags = new Set();
const hudFlags = new Set(['active']);
const doc = {
documentElement: root,
body: { classList: { contains: (name) => flags.has(name) } },
getElementById: () => ({
classList: { contains: (name) => hudFlags.has(name) },
}),
};
const calls = [];
const owner = {
hud: { getVariant: () => theme },
_setCyberSonarSetting(name, value) {
calls.push([name, value]);
const suffix = name[0].toUpperCase() + name.slice(1);
root.dataset['cyberSonar' + suffix] = String(value);
},
_setCyberSonarEnabled(value) {
calls.push(['enabled', value]);
root.dataset.cyberSonar = value ? 'on' : 'off';
},
};
return { doc, owner, calls, flags, hudFlags, root };
}
test('real runner and facade use visual settings, preserve visibility and return actual values', async () => {
const f = fixture();
const previousDocument = globalThis.document;
globalThis.document = f.doc;
const renders = [];
const visual = Object.create(VisualSettings.prototype);
visual.services = { governorRequestRender: (reason) => renders.push(reason) };
const owner = Object.create(StyleManager.prototype);
owner._visualSettings = visual;
Object.defineProperty(owner, 'hud', { value: f.owner.hud });
const viewer = {
camera: { moveEnd: { addEventListener() {} } },
clock: { onTick: { addEventListener: () => () => {} } },
scene: { canvas: new EventTarget() },
};
try {
const runner = createGevActionRunner({ styleManager: owner, viewer, dataManager: {} });
const result = await runner('set_cyber_sonar', { enabled: false, intensityPct: 73, sectorDeg: 9 });
assert.equal(result.ok, true);
assert.equal(result.sonar.intensityPct, 73);
assert.equal(result.sonar.sectorDeg, 9);
assert.equal(result.sonar.enabled, false);
assert.equal(f.root.dataset.uiTheme, 'cyber');
assert.equal(f.hudFlags.has('active'), true);
assert.equal(renders.length, 3);
const before = getCyberSonarControlState();
const invalid = await runner('set_cyber_sonar', { rings: 6, opacityPct: 0 });
assert.equal(invalid.ok, false);
assert.deepEqual(getCyberSonarControlState(), before);
assert.equal(renders.length, 3);
const zero = await runner('set_cyber_sonar', { intensityPct: 0 });
assert.equal(zero.sonar.intensityPct, 0);
assert.equal(zero.sonar.enabled, false);
const unavailable = createGevActionRunner({ styleManager: {}, viewer, dataManager: {} });
assert.equal((await unavailable('set_cyber_sonar', { enabled: true })).ok, false);
} finally {
globalThis.document = previousDocument;
}
});
test('native sonar sliders represent every accepted integer without browser snapping', () => {
const html = readFileSync(new URL('./templates/display-controls.html', import.meta.url), 'utf8');
for (const name of ['rings', 'range', 'intensity', 'opacity', 'sector']) {
assert.match(html, new RegExp(`id="cyber-sonar-${name}"[^>]*step="1"`));
}
});
test('sonar action is available to both shared and server voice schemas', () => {
for (const tools of [GEV_ACTION_SCHEMAS, GEV_REALTIME_TOOLS]) {
const action = tools.find((tool) => tool.name === 'set_cyber_sonar');
assert.ok(action);
assert.equal(action.parameters.additionalProperties, false);
assert.deepEqual(Object.keys(action.parameters.properties), [
'enabled', 'rings', 'rangePct', 'intensityPct', 'opacityPct', 'sectorDeg',
]);
assert.equal(action.parameters.properties.intensityPct.minimum, 0);
assert.equal(action.parameters.properties.opacityPct.minimum, 35);
}
});
test('maps all settings to the slider owner and reads actual state, including zero', () => {
const f = fixture();
const result = setCyberSonarControls(f.owner, {
enabled: false, rings: 6, rangePct: 80,
intensityPct: 0, opacityPct: 90, sectorDeg: 30,
}, f.doc);
assert.equal(result.ok, true);
assert.deepEqual(f.calls, [
['rings', 6], ['range', 80], ['intensity', 0],
['opacity', 90], ['sector', 30], ['enabled', false],
]);
assert.equal(result.sonar.enabled, false);
assert.equal(result.sonar.intensityPct, 0);
assert.equal(result.sonar.contactSweepActive, false);
assert.deepEqual(result.sonar, getCyberSonarControlState(f.doc));
});
test('rejects the whole invalid request before any mutation', () => {
for (const request of [
{}, null, [], { enabled: 'false' }, { rings: 2 }, { rangePct: 121 },
{ opacityPct: NaN }, { intensityPct: Infinity }, { sectorDeg: 8.5 },
{ intensityPct: '50' }, { enabled: false, unknown: 1 },
{ rings: 6, sectorDeg: 61 },
]) {
const f = fixture();
assert.equal(setCyberSonarControls(f.owner, request, f.doc).ok, false);
assert.deepEqual(f.calls, []);
}
});
test('requires Cyber and preserves off state and every omitted setting', () => {
for (const theme of ['tactical', 'operator', 'minimal']) {
const f = fixture(theme);
assert.equal(setCyberSonarControls(f.owner, { enabled: true }, f.doc).ok, false);
assert.deepEqual(f.calls, []);
}
const f = fixture();
f.root.dataset.cyberSonar = 'off';
const before = getCyberSonarControlState(f.doc);
const result = setCyberSonarControls(f.owner, { rings: 12 }, f.doc);
assert.deepEqual(result.sonar, { ...before, rings: 12 });
assert.deepEqual(f.calls, [['rings', 12]]);
});
test('reports inactive map effects in Cockpit, hidden HUD, unsupported and uninitialized GPU', () => {
const f = fixture();
f.flags.add('cockpit-mode');
assert.equal(getCyberSonarControlState(f.doc).mapSweepActive, false);
f.flags.clear();
f.hudFlags.clear();
assert.equal(getCyberSonarControlState(f.doc).contactSweepActive, false);
f.hudFlags.add('active');
for (const value of ['unsupported', undefined]) {
if (value === undefined) delete f.root.dataset.cyberSonarGpu;
else f.root.dataset.cyberSonarGpu = value;
assert.equal(getCyberSonarControlState(f.doc).contactSweepActive, false);
}
});
+573
View File
@@ -0,0 +1,573 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import {
applyHudUiTheme,
CYBER_VISUAL_DEFAULTS,
cyberVisualDefaultsForHudTransition,
HUD_LAYOUTS,
normalizeHudLayout,
} from '../hudLayouts.js';
import { GEV_ACTION_SCHEMAS } from '../voice/actionSchemas.js';
import { VisualSettings } from './visualSettings.js';
import { STYLES } from './visualPresets.js';
test('CCTV and Context keep their headers outside Cyber-only scroll bodies', () => {
for (const file of ['layer-panels.html', 'context.html']) {
const markup = readFileSync(
new URL(`./templates/${file}`, import.meta.url),
'utf8',
);
assert.match(markup, /class="cyber-panel-body" data-rail-scroller/);
assert.ok(
markup.indexOf('class="panel-header"') <
markup.indexOf('class="cyber-panel-body"'),
);
}
const shared = readFileSync(
new URL('./styles/controls.css', import.meta.url),
'utf8',
);
const cyber = readFileSync(
new URL('./styles/cyber.css', import.meta.url),
'utf8',
);
assert.match(shared, /\.cyber-panel-body\s*\{\s*display: contents;/);
assert.match(
cyber,
/:root\[data-ui-theme='cyber'\] \.cyber-panel-body\s*\{[^}]*overflow-y: auto;[^}]*overscroll-behavior: contain;/,
);
});
function styleOwner(t, initialVariant = 'cyber') {
const previous = { document: globalThis.document, window: globalThis.window };
const root = { dataset: {} };
globalThis.document = { documentElement: root, querySelectorAll: () => [] };
globalThis.window = { dispatchEvent() {} };
t.after(() => Object.assign(globalThis, previous));
let variant = initialVariant;
const calls = [];
const hud = {
getVariant: () => variant,
setVariant: (value) => {
variant = value;
applyHudUiTheme(root, value);
},
setMode: (value) => calls.push(['mode', value]),
onStyleChange: (value) => calls.push(['style', value]),
};
hud.setVariant(initialVariant);
const owner = Object.create(VisualSettings.prototype);
Object.assign(owner, {
activeStyle: 'normal',
readHud: () => hud,
readShareLinks: () => ({ onStyleChange() {} }),
_visualEffects: {
stages: Object.fromEntries(
Object.entries(STYLES).map(([name, config]) => [
name,
{ uniforms: { ...config.uniforms, intensity: 0 } },
]),
),
},
services: { governorRequestRender() {}, setDetectionStyle() {} },
_hudLayoutSelect: { value: initialVariant },
_styleIndicator: {},
_setBloomEnabled: (value) => calls.push(['bloom', value]),
_setSharpenEnabled: (value) => calls.push(['sharpen', value]),
_applyDetectionPreset: (value) => calls.push(['detection', value]),
_syncShareState: () => calls.push(['share', variant]),
});
for (const name of [
'setCelestialRingEnabled',
'_startTransition',
'_updateStyleMiniStatus',
'_updateSliderPanel',
'_updateHudButtonState',
'_syncIrBoost',
'_syncCockpitInheritedStyle',
'_syncCyberSonarControl',
'_scheduleAdaptivePanelLayout',
'_applyDetectionFadeFromUi',
])
owner[name] = () => {};
return { owner, hud, root, calls };
}
test('visual styles retain Cyber through real preset application without dropping other defaults', (t) => {
const { owner, hud, root, calls } = styleOwner(t);
for (const style of [
'surveillance',
'normal',
'noir',
'anime',
'snow',
'thermal',
'retro',
]) {
owner.setStyle(style);
assert.equal(owner.activeStyle, style);
assert.equal(hud.getVariant(), 'cyber');
assert.equal(root.dataset.uiTheme, 'cyber');
assert.equal(owner._hudLayoutSelect.value, 'cyber');
assert.deepEqual(calls.at(-1), ['share', 'cyber']);
}
assert.equal(owner.stages.thermal.uniforms.sensitivity, 0.85);
assert.equal(calls.filter(([name]) => name === 'detection').length, 3);
assert.equal(
calls.filter(([name, value]) => name === 'bloom' && value === false).length,
3,
);
assert.equal(
calls.filter(([name, value]) => name === 'sharpen' && value === true)
.length,
3,
);
});
test('non-Cyber style presets and explicit HUD choices remain authoritative', (t) => {
const { owner, hud, root } = styleOwner(t);
for (const variant of ['tactical', 'operator', 'minimal']) {
owner._setHudVariant(variant);
assert.equal(hud.getVariant(), variant);
assert.equal(root.dataset.uiTheme, undefined);
owner.setStyle('normal');
owner.setStyle('surveillance');
assert.equal(hud.getVariant(), 'tactical');
}
});
test('scene and share restoration override Cyber and preserve restored Cyber parameters', async (t) => {
const { owner, hud } = styleOwner(t);
await owner.applyVisualState({ style: 'retro', hud: { variant: 'minimal' } });
assert.equal(hud.getVariant(), 'minimal');
owner._setHudVariant('cyber');
await owner.restoreShareState({
style: 'surveillance',
hudVariant: 'operator',
});
assert.equal(hud.getVariant(), 'operator');
await owner.restoreShareState({
style: 'thermal',
hudVariant: 'cyber',
styleParams: { palette: 0.73 },
});
assert.equal(hud.getVariant(), 'cyber');
assert.equal(owner.activeStyle, 'thermal');
assert.equal(owner.stages.thermal.uniforms.palette, 0.73);
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner.stages.thermal.uniforms.palette, 0.73);
owner._setHudVariant('tactical');
owner._setHudVariant('cyber', { applyVisualDefaults: true });
assert.equal(owner.stages.thermal.uniforms.palette, 0.42);
});
const read = (relative) =>
readFileSync(new URL(relative, import.meta.url), 'utf8');
test('Cyber setup uses a compact toolbar icon and highlights the north-up letter', () => {
const css = read('./styles/cyber.css');
assert.match(
css,
/#top-center-actions #key-setup-chip\s*\{[^}]*inset: auto;[^}]*width: 36px;/,
);
assert.match(css, /#key-setup-chip\[hidden\]\s*\{\s*display: none;/);
assert.match(
css,
/#north-up-view\[data-north-up='true'\]\s+\.camera-compass-n\s*\{[^}]*color: var\(--cyber-red-bright\)/,
);
});
const cyberStyles = read('./styles/cyber.css');
test('Cyber clipped expand controls retain an inset keyboard-focus outline in map and cockpit', () => {
assert.match(
cyberStyles,
/:root\[data-ui-theme='cyber'\]\s+:is\(\s*\.panel-collapse-btn,\s*body\.cockpit-mode \.cockpit-utility-glyph\s*\):focus-visible\s*\{[^}]*outline: 2px solid var\(--cyber-red-bright\) !important;[^}]*outline-offset: -3px;[^}]*box-shadow: none;/,
);
assert.match(
cyberStyles,
/:root\[data-ui-theme='cyber'\]\s+:is\(\.panel-collapse-btn, body\.cockpit-mode \.cockpit-utility-glyph\):is\(\s*:hover,\s*:focus-visible\s*\)\s*\{[^}]*border-color: var\(--cyber-red-bright\);/,
);
});
test('Cyber Radio retains upstream nested Context ownership', () => {
assert.doesNotMatch(cyberStyles, /#right-context-rail\s*>\s*#radio-panel/);
});
test('Cyber cockpit launchers share Data Layers frame and expand-control treatment', () => {
assert.match(
cyberStyles,
/:is\(\.panel-collapse-btn, body\.cockpit-mode \.cockpit-utility-glyph\)/,
);
assert.match(
cyberStyles,
/body\.cockpit-mode \.cockpit-utility-launcher \{[^}]*width: var\(--cyber-panel-width\);[^}]*min-height: 60px;[^}]*clip-path: var\(--cyber-panel-frame\);/,
);
assert.match(
cyberStyles,
/\.cockpit-utility-glyph::after \{\s*content: '\+';/,
);
assert.match(
cyberStyles,
/\.cockpit-utility-glyph\[aria-expanded='true'\]::after \{\s*content: '−';/,
);
assert.match(
cyberStyles,
/\.cockpit-utility-control\.is-expanded\s+\.cockpit-utility-launcher \{[^}]*width: 100%;[^}]*clip-path: none;/,
);
});
test('Cyber side panels share one width and one framed surface material', () => {
assert.match(cyberStyles, /--cyber-panel-width: 272px;/);
assert.match(cyberStyles, /--cyber-right-panel-width: 312px;/);
assert.match(cyberStyles, /--cyber-panel-collapsed-width: 200px;/);
assert.match(cyberStyles, /--cyber-panel-control-size: 30px;/);
assert.match(
cyberStyles,
/--cyber-panel-surface: rgba\(18, 24, 27, 0\.92\);/,
);
assert.match(
cyberStyles,
/\.cockpit-utility-control\.is-expanded \{[\s\S]*?width: var\(--cyber-right-panel-width\);[\s\S]*?background-color: var\(--cyber-panel-surface\);[\s\S]*?background-image: var\(--cyber-panel-surface-detail\);[\s\S]*?clip-path: var\(--cyber-panel-frame\);/,
);
assert.match(
cyberStyles,
/\.cockpit-utility-control\.is-expanded::after \{[\s\S]*?width: 36px;[\s\S]*?border-top: 1px solid rgba\(225, 102, 102, 0\.84\);/,
);
assert.match(
cyberStyles,
/#global-context-panel #radio-panel\.collapsed \{\s*width: 100%;/,
);
assert.match(
cyberStyles,
/#left-panel-stack\s*> :is\(#data-panel, #scene-panel\)\.collapsed \{\s*width: var\(--cyber-panel-collapsed-width\);/,
);
assert.match(
cyberStyles,
/:is\(#data-panel, #scene-panel\)\.collapsed\s+:is\(\.data-panel-inner, \.scene-panel-inner\) \{[\s\S]*?height: 50px;[\s\S]*?min-height: 50px;/,
);
assert.match(
cyberStyles,
/\.cockpit-utility-control:not\(\.is-expanded\)\s+\.cockpit-utility-launcher \{\s*width: var\(--cyber-panel-collapsed-width\);/,
);
assert.match(
cyberStyles,
/#pp-toggles\s*> \.pp-header-row\s+\.pp-collapse-btn \{[\s\S]*?width: var\(--cyber-panel-control-size\);[\s\S]*?height: var\(--cyber-panel-control-size\);/,
);
assert.doesNotMatch(
cyberStyles,
/\.cockpit-utility-control\.is-expanded\s*\{[^}]*order:/,
'expanded Cockpit utilities must retain Display-then-Radio DOM order',
);
});
test('Cyber is a canonical HUD layout with a safe Tactical fallback', () => {
assert.deepEqual(HUD_LAYOUTS, ['tactical', 'operator', 'minimal', 'cyber']);
assert.equal(normalizeHudLayout('CYBER'), 'cyber');
assert.equal(normalizeHudLayout('unknown'), 'tactical');
});
test('the Cyber HUD owns the global shell skin and leaving it restores defaults', () => {
const root = { dataset: { unrelated: 'preserved' } };
assert.equal(applyHudUiTheme(root, 'cyber'), 'cyber');
assert.equal(root.dataset.uiTheme, 'cyber');
assert.equal(applyHudUiTheme(root, 'minimal'), null);
assert.equal(root.dataset.uiTheme, undefined);
assert.equal(root.dataset.unrelated, 'preserved');
});
test('an explicit transition into Cyber selects FLIR with Ironbow 0.42 once', () => {
assert.deepEqual(CYBER_VISUAL_DEFAULTS, {
style: 'thermal',
ironbow: 0.42,
});
assert.equal(
cyberVisualDefaultsForHudTransition('tactical', 'cyber', {
explicit: true,
}),
CYBER_VISUAL_DEFAULTS,
);
assert.equal(
cyberVisualDefaultsForHudTransition('cyber', 'cyber', {
explicit: true,
}),
null,
);
assert.equal(cyberVisualDefaultsForHudTransition('tactical', 'cyber'), null);
assert.equal(
cyberVisualDefaultsForHudTransition('cyber', 'operator', {
explicit: true,
}),
null,
);
});
test('Cyber visual defaults update the style, thermal palette and render state', () => {
const calls = [];
const owner = {
stages: { thermal: { uniforms: { palette: 0 } } },
services: {
governorRequestRender: (reason) => calls.push(['render', reason]),
},
setStyle: (style, options) => calls.push(['style', style, options]),
_updateSliderPanel: (style, options) =>
calls.push(['sliders', style, options]),
};
VisualSettings.prototype._applyCyberVisualDefaults.call(
owner,
CYBER_VISUAL_DEFAULTS,
);
assert.equal(owner.stages.thermal.uniforms.palette, 0.42);
assert.deepEqual(calls, [
['style', 'thermal', { applyPreset: false, revealParameters: false }],
['sliders', 'thermal', { reveal: false }],
['render', 'cyber-visual-defaults'],
]);
});
test('the Display selector, voice schema and final stylesheet expose Cyber', () => {
const display = read('./templates/display-controls.html');
const stylesheet = read('../../style.css');
const hud = read('../hud.js');
const detection = read('../data/detection.js');
const civilRendering = read('../layers/flights/rendering.js');
const militaryRendering = read('../layers/military/rendering.js');
const setHud = GEV_ACTION_SCHEMAS.find((schema) => schema.name === 'set_hud');
assert.match(display, /<option value="cyber">Cyber<\/option>/);
assert.match(display, /id="cyber-sonar-toggle"/);
assert.match(
display,
/id="cyber-sonar-opacity"[^>]*value="84"[^>]*aria-label="Cyber contact and label opacity"/,
);
assert.match(display, /id="cyber-sonar-sector"/);
assert.deepEqual(setHud.parameters.properties.layout.enum, HUD_LAYOUTS);
assert.match(stylesheet, /@import '\.\/src\/ui\/styles\/cyber\.css';\s*$/);
assert.match(cyberStyles, /:root\[data-ui-theme='cyber'\]/);
assert.match(cyberStyles, /\.material-symbols-outlined/);
assert.match(cyberStyles, /\.pp-label/);
assert.match(cyberStyles, /#intel-hud\[data-variant='cyber'\]/);
assert.match(cyberStyles, /#left-panel-stack/);
assert.match(cyberStyles, /#right-context-rail/);
assert.match(cyberStyles, /#command-dock/);
assert.match(cyberStyles, /--cyber-panel-frame: polygon/);
assert.match(cyberStyles, /\.data-panel-inner,/);
assert.match(cyberStyles, /#pp-toggles\.collapsed > \.pp-header-row/);
assert.match(
cyberStyles,
/\.panel-collapsible:not\(#location-bar\):not\(#control-panel\)::after/,
);
assert.match(cyberStyles, /clip-path: var\(--cyber-panel-frame\);/);
assert.match(hud, /class="hud-sonar" aria-hidden="true"/);
assert.match(cyberStyles, /\.hud-sonar::before/);
assert.match(cyberStyles, /\.hud-sonar::after/);
assert.match(cyberStyles, /@keyframes cyber-sonar-sweep/);
assert.match(
cyberStyles,
/animation-delay:\s*var\(--cyber-sonar-phase-delay, 0ms\)/,
);
assert.doesNotMatch(cyberStyles, /--cyber-sonar-angle/);
assert.match(cyberStyles, /prefers-reduced-motion: reduce/);
assert.match(detection, /candidate\.sonarFactor \?\? 1/);
assert.match(detection, /sonarActive && !obj\.skipLabel/);
assert.match(detection, /const bracketAlpha = admissionAlpha \* sonarFactor/);
assert.match(detection, /sonarSampler\.label\(sonarFactor\)/);
assert.match(detection, /createCyberSonarSampler\(width, height, now\)/);
assert.match(cyberStyles, /data-cyber-sonar='off'/);
assert.match(detection, /governorRequestRender\('cyber-sonar-cadence'\)/);
assert.match(
detection,
/const animatingCount = countAnimatingRenderEntries\(renderEntries\);/,
);
assert.doesNotMatch(detection, /sonarActive \? 1 : 0/);
assert.match(
civilRendering,
/alpha: flightState\._irBoost \? 1 : treatment\.alpha/,
);
assert.match(
militaryRendering,
/baseAlpha:[\s\S]*?alpha: flightState\._irBoost \? 1 : treatment\.alpha/,
);
assert.match(
cyberStyles,
/#title-bar \.title-logo #globe \{[\s\S]*?fill: var\(--cyber-red\);[\s\S]*?stroke: var\(--cyber-red-bright\);/,
);
assert.match(cyberStyles, /#title-bar \.title-logo #globe_cage/);
assert.match(
cyberStyles,
/#top-center-actions button\s*\{[\s\S]*?border-radius:\s*0;[\s\S]*?clip-path:\s*polygon\(\s*29% 0,\s*71% 0,[\s\S]*?0 29%\s*\);/,
);
assert.match(cyberStyles, /#top-center-actions button::before/);
assert.match(cyberStyles, /\.hud-top-left/);
assert.match(cyberStyles, /transparent 0 min\(40vw, 52vh\)/);
assert.match(cyberStyles, /\.hud-bottom-bar \{\s*display: none;/);
assert.match(
cyberStyles,
/#intel-hud\[data-variant='cyber'\] \.hud-top-bar \{\s*display: none;/,
);
// Cyber shares the measured rail/credit keep-out rather than overriding it.
assert.doesNotMatch(
cyberStyles,
/#right-context-rail\s*\{[^}]*\b(?:top|bottom|height|max-height):/,
);
assert.match(cyberStyles, /--cyber-hud-card-top: max\(100px, 10\.25vh\);/);
assert.match(
cyberStyles,
/--cyber-left-rail-top: max\([\s\S]*?252px,[\s\S]*?calc\(var\(--cyber-hud-card-top\) \+ 146px\)/,
);
assert.match(
cyberStyles,
/\.hud-top-left \{[\s\S]*?top: var\(--cyber-hud-card-top\);/,
);
assert.match(cyberStyles, /\.hud-bottom-left \{\s*bottom: 60px;/);
assert.match(
cyberStyles,
/#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;/,
);
assert.match(
cyberStyles,
/\.hud-left-edge \{\s*top: calc\(var\(--cyber-left-rail-top\) - 18px\);\s*left: 14px;/,
);
assert.match(
cyberStyles,
/--right-display-collapsed-width: var\(--cyber-panel-collapsed-width\);/,
);
assert.match(
cyberStyles,
/clip-path: polygon\([\s\S]*?12px 0,[\s\S]*?0 12px/,
);
assert.match(cyberStyles, /\.hud-corner::before/);
assert.match(
cyberStyles,
/--cyber-telemetry-fill: rgba\(13, 20, 23, 0\.985\);/,
);
assert.match(cyberStyles, /var\(--cyber-telemetry-fill\)/);
assert.match(cyberStyles, />\s*\.panel-header\s+\.panel-title::before/);
assert.match(cyberStyles, /\.panel-collapsible\.collapsed::after/);
assert.match(cyberStyles, /\.panel-collapsible:not\(\.collapsed\)::after/);
assert.match(cyberStyles, /\.context-mode-standby:not\(\[hidden\]\)/);
assert.match(cyberStyles, /mask-image: linear-gradient\(/);
assert.match(cyberStyles, /flex: 1 1 auto;/);
assert.match(cyberStyles, /#left-panel-stack:has/);
assert.match(cyberStyles, /left: 54px;/);
assert.match(cyberStyles, /right: 62px;/);
assert.match(cyberStyles, /bottom: calc\(2vh \+ 11rem\);/);
assert.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/);
assert.match(cyberStyles, /data-layer-id='satellites'/);
assert.match(cyberStyles, /#search-toggle::before/);
assert.match(cyberStyles, /VISUAL MATRIX \/\/ 03/);
assert.match(cyberStyles, /body\.cockpit-mode #cockpit-hud/);
assert.match(cyberStyles, /TARGET SCANNING \/\/ LIVE VECTOR/);
assert.match(
cyberStyles,
/body\.cockpit-mode #global-loading-status \{[\s\S]*?top: 60px;[\s\S]*?z-index: 146;/,
);
assert.match(
cyberStyles,
/body\.cockpit-mode #global-loading-detail \{[\s\S]*?color: var\(--cockpit-accent\);/,
);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-visor-shell::before/);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-scan-array/);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-target-side-arcs/);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-target-ticks/);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-target-core/);
assert.match(cyberStyles, /width: min\(48vw, 60vh\);/);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-context-window/);
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-signal-window/);
assert.match(cyberStyles, /body\.cockpit-mode #intel-hud\.active/);
});
test('Cyber voice telemetry renders a live scan scope without replacing audio ownership', () => {
assert.match(
cyberStyles,
/#gev-voice-control\s*\{[\s\S]*?--cyber-voice-wave:/,
);
assert.match(cyberStyles, /\.gev-voice-visualizer::before/);
assert.match(cyberStyles, /\.gev-voice-visualizer::after/);
assert.match(cyberStyles, /@keyframes gev-cyber-voice-scan/);
assert.match(cyberStyles, /@keyframes gev-cyber-voice-lock/);
assert.match(cyberStyles, /\[data-speaker='user'\]/);
assert.match(cyberStyles, /\[data-speaker='ai'\]/);
assert.match(
cyberStyles,
/prefers-reduced-motion: reduce[\s\S]*?gev-voice-visualizer::after[\s\S]*?animation: none/,
);
});
test('Cyber Cockpit Display uses the released briefing lane instead of the legacy viewport cap', () => {
assert.match(
cyberStyles,
/body\.cockpit-mode\s+#cockpit-display-panel\s*\{[\s\S]*?max-height:\s*calc\(var\(--cockpit-utility-expanded-max-height, 72vh\) - 62px\)/,
);
assert.match(
cyberStyles,
/#cockpit-hud:has\(\.cockpit-utility-control\.is-expanded\)[\s\S]*?\.cockpit-signal-window\[data-collapsed='true'\][\s\S]*?bottom: 80px;[\s\S]*?visibility:\s*visible/,
);
});
test('Cyber Cockpit keeps Data Layers inside its bounded rail', () => {
assert.match(
cyberStyles,
/body:not\(\.cockpit-mode\)\s+#left-panel-stack:has\([\s\S]*?max-height:\s*none;/,
);
assert.match(
cyberStyles,
/body\.cockpit-mode #left-panel-stack > #data-panel \{[\s\S]*?max-height: min\(64vh, 610px\);/,
);
});
test('Cyber desktop cockpit collapsed utility frames match the 50px Data Layers frame', () => {
const desktop = cyberStyles.slice(
cyberStyles.indexOf('@media (min-width: 961px) and (min-height: 650px)'),
cyberStyles.indexOf('@media (max-width: 720px)'),
);
assert.match(
desktop,
/:root\[data-ui-theme='cyber'\]\s+body\.cockpit-mode\s+\.cockpit-utility-control:not\(\.is-expanded\)\s+\.cockpit-utility-launcher\s*\{\s*height: 50px;\s*min-height: 50px;\s*padding: 8px 18px 10px 24px;/,
);
assert.match(
desktop,
/\.cockpit-utility-control:not\(\.is-expanded\)\s+\.cockpit-utility-launcher::after\s*\{\s*bottom: 6px;/,
);
});
test('Cyber right panels preserve intrinsic sizing and the shared rail allocation', () => {
assert.doesNotMatch(
cyberStyles.slice(cyberStyles.indexOf(":root[data-ui-theme='cyber'] {")),
/#right-context-rail\s*>[^{}]+\{[^{}]*\b(?:flex|height|max-height):/,
);
});
test('Cyber panels retain clean edges without the added footer band', () => {
assert.doesNotMatch(cyberStyles, /--cyber-panel-foot-/);
assert.doesNotMatch(cyberStyles, /border-bottom: 24px solid transparent/);
});
test('Cyber keeps Sonar on/off without the removed CSS effect switch or dimmer', () => {
const template = readFileSync(
new URL('./templates/display-controls.html', import.meta.url),
'utf8',
);
assert.match(template, /id="cyber-sonar-toggle"/);
assert.doesNotMatch(template, /data-sonar-mode|cyber-sonar-mode-row/);
assert.doesNotMatch(cyberStyles, /cyber-scene-dimmer|data-cyber-sonar-mode/);
});
test('Cyber Display uses a red title without a panel-specific center tab', () => {
assert.match(
cyberStyles,
/#pp-toggles \.pp-header-label\s*\{\s*color: var\(--cyber-red-bright\);/,
);
assert.doesNotMatch(cyberStyles, /#pp-toggles[^,{]*::after\s*\{/);
assert.match(cyberStyles, /isolation: isolate;\s*clip-path: none;/);
assert.match(cyberStyles, /#pp-toggles:not\(\.collapsed\)::before/);
});
test('Cyber omits only the fictional classification and system header lines', () => {
assert.match(
cyberStyles,
/#intel-hud\[data-variant='cyber'\] \.hud-top-left \.hud-classification,\s*#intel-hud\[data-variant='cyber'\] \.hud-top-left \.hud-system\s*\{\s*display: none;\s*\}/,
);
});
+15 -1
View File
@@ -104,6 +104,12 @@ export class DisplayBindings {
scopeFeatherSlider: this._scopeFeatherSlider,
hudLayout: this._hudLayoutSelect,
hudButton: this._hudBtn,
sonarButton: this._cyberSonarBtn,
sonarRingsSlider: this._cyberSonarRings,
sonarRangeSlider: this._cyberSonarRange,
sonarIntensitySlider: this._cyberSonarIntensity,
sonarOpacitySlider: this._cyberSonarOpacity,
sonarSectorSlider: this._cyberSonarSector,
cleanViewButton: this._cleanViewBtn,
cleanViewExitButton: this._cleanViewExitBtn,
densitySlider: this._detectionDensitySlider,
@@ -153,7 +159,7 @@ export class DisplayBindings {
},
setHudLayout: (value) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._setHudVariant(value);
this._setHudVariant(value, { applyVisualDefaults: true });
},
toggleCleanView: () => this.toggleCleanView(),
exitCleanView: () => this.toggleCleanView(false),
@@ -191,6 +197,14 @@ export class DisplayBindings {
this._updateHudButtonState();
this._syncShareState();
},
toggleSonar: () => this._setCyberSonarEnabled(),
setSonarRings: (value) => this._setCyberSonarSetting('rings', value),
setSonarRange: (value) => this._setCyberSonarSetting('range', value),
setSonarIntensity: (value) =>
this._setCyberSonarSetting('intensity', value),
setSonarOpacity: (value) =>
this._setCyberSonarSetting('opacity', value),
setSonarSector: (value) => this._setCyberSonarSetting('sector', value),
cycleDetection: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this.claimDetection();
+9
View File
@@ -21,6 +21,7 @@ export function bindDisplayControls({ elements, actions }) {
['cleanViewExitButton', 'exitCleanView'],
['celestialButton', 'toggleCelestial'],
['hudButton', 'toggleHud'],
['sonarButton', 'toggleSonar'],
['detectionButton', 'cycleDetection'],
['modelsButton', 'toggleModels'],
])
@@ -33,6 +34,14 @@ export function bindDisplayControls({ elements, actions }) {
listen(elements[name], 'input', action, integer);
listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value);
listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value);
for (const [name, action] of [
['sonarRingsSlider', 'setSonarRings'],
['sonarRangeSlider', 'setSonarRange'],
['sonarIntensitySlider', 'setSonarIntensity'],
['sonarOpacitySlider', 'setSonarOpacity'],
['sonarSectorSlider', 'setSonarSector'],
])
listen(elements[name], 'input', action, integer);
for (const el of elements.styleButtons || [])
listen(el, 'click', 'setStyle', (el) => el.dataset.style);
for (const el of elements.allocationButtons || [])
+58
View File
@@ -9,6 +9,19 @@ function element(value = '') {
return el;
}
test('sonar on/off control cannot fire after disposal', () => {
const sonarButton = element();
const calls = [];
const control = bindDisplayControls({
elements: { sonarButton },
actions: { toggleSonar: () => calls.push('toggle') },
});
sonarButton.dispatchEvent(new Event('click'));
control.destroy();
sonarButton.dispatchEvent(new Event('click'));
assert.deepEqual(calls, ['toggle']);
});
test('controls read current values without preventing native input behavior', () => {
const bloomSlider = element('24');
const hudLayout = element('tactical');
@@ -54,6 +67,51 @@ test('destroyed and replaced controls cannot issue stale actions', () => {
assert.equal(newCalls, 1);
});
test('the Cyber sonar control is an optional owned display action', () => {
const sonarButton = element();
const sonarRingsSlider = element('9');
const sonarRangeSlider = element('110');
const sonarIntensitySlider = element('65');
const sonarOpacitySlider = element('84');
const sonarSectorSlider = element('32');
const calls = [];
const control = bindDisplayControls({
elements: {
sonarButton,
sonarRingsSlider,
sonarRangeSlider,
sonarIntensitySlider,
sonarOpacitySlider,
sonarSectorSlider,
},
actions: {
toggleSonar: () => calls.push('toggle'),
setSonarRings: (value) => calls.push(['rings', value]),
setSonarRange: (value) => calls.push(['range', value]),
setSonarIntensity: (value) => calls.push(['intensity', value]),
setSonarOpacity: (value) => calls.push(['opacity', value]),
setSonarSector: (value) => calls.push(['sector', value]),
},
});
sonarButton.dispatchEvent(new Event('click'));
sonarRingsSlider.dispatchEvent(new Event('input'));
sonarRangeSlider.dispatchEvent(new Event('input'));
sonarIntensitySlider.dispatchEvent(new Event('input'));
sonarOpacitySlider.dispatchEvent(new Event('input'));
sonarSectorSlider.dispatchEvent(new Event('input'));
assert.deepEqual(calls, [
'toggle',
['rings', 9],
['range', 110],
['intensity', 65],
['opacity', 84],
['sector', 32],
]);
control.destroy();
sonarButton.dispatchEvent(new Event('click'));
assert.equal(calls.length, 6);
});
test('style, allocation and model choices retain their current data attributes', () => {
const style = element();
const allocation = element();
+6
View File
@@ -0,0 +1,6 @@
/** Cyber keeps the frame fixed; other layouts retain their existing scroller. */
export function displayPanelScroller(panel) {
return panel?.ownerDocument?.documentElement?.dataset.uiTheme === 'cyber'
? panel.querySelector('.pp-panel-body') || panel
: panel;
}
+1
View File
@@ -222,6 +222,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';
+55 -8
View File
@@ -1,4 +1,5 @@
import { UiLifetime } from './uiLifetime.js';
import { displayPanelScroller } from './displayPanelScroll.js';
import { PanelPositionControls } from './panelPositionControls.js';
import { PanelLayoutController } from './panelLayoutController.js';
import {
@@ -71,7 +72,7 @@ export class PanelChrome {
readDisplayScrollTop: () =>
this._displayPortalScrollRestoreOwner === 'standard'
? this._standardDisplayScrollTop
: this._ppToggles?.scrollTop || 0,
: displayPanelScroller(this._ppToggles)?.scrollTop || 0,
});
}
get hud() {
@@ -345,9 +346,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');
@@ -397,9 +400,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;
@@ -431,6 +457,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
@@ -444,7 +487,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 &&
@@ -466,6 +512,7 @@ export class PanelChrome {
if (
!nextCollapsed &&
panelId === 'radio-panel' &&
document.documentElement?.dataset.uiTheme !== 'cyber' &&
document
.getElementById('global-context-panel')
?.classList.contains('collapsed')
+3
View File
@@ -1,5 +1,6 @@
/** Own rail measurement, layout scheduling and dock tray observation. */
import { layoutLeftPanelRail, layoutRightPanelRail } from './panelRails.js';
import { syncRadioPanelPlacement } from './radioPanelPlacement.js';
const COCKPIT_LAYOUT_SETTLE_MS = 240;
/**
* Fixed UI regions that can occupy the left accordion's vertical lane.
@@ -229,6 +230,7 @@ export class PanelLayoutController {
this._weatherPanel,
this._recentImageryPanel,
globalContextPanel,
document.getElementById('radio-panel'),
]) {
if (panel) this._rightStackResizeObserver.observe(panel);
}
@@ -301,6 +303,7 @@ export class PanelLayoutController {
_syncRightPanelAdaptiveLayout() {
if (this.destroyed) return;
syncRadioPanelPlacement(document);
layoutRightPanelRail({
stack: this._rightPanelStack,
obstacles: document.querySelectorAll(RIGHT_STACK_OBSTACLE_SELECTOR),
+53
View File
@@ -1,4 +1,5 @@
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
import { displayPanelScroller } from './displayPanelScroll.js';
import assert from 'node:assert/strict';
import test from 'node:test';
import {
@@ -7,6 +8,58 @@ import {
measurePanelNaturalHeight,
} from './panelRails.js';
test('Display uses an inner scroll body only in Cyber', () => {
const body = { scrollTop: 95 };
const panel = {
ownerDocument: { documentElement: { dataset: { uiTheme: 'cyber' } } },
querySelector: () => body,
};
assert.equal(displayPanelScroller(panel), body);
panel.ownerDocument.documentElement.dataset.uiTheme = 'tactical';
assert.equal(displayPanelScroller(panel), panel);
assert.equal(displayPanelScroller(null), null);
});
test('Cyber restores one expanded owner, keeps launchers, and restores other theme preferences', () => {
for (const mobile of [false, true]) {
const f = fixture('right', {
mobile,
hud: { visible: true, variant: 'cyber' },
});
f.expand(f.first, 250);
f.expand(f.second, 250);
f.options.preferredPanelId = f.second.id;
f.run();
assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), true);
assert.equal(f.second.classList.contains('collapsed'), false);
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
f.run();
assert.equal(
f.first.classList.contains('collapsed'),
true,
'layout does not reopen a peer',
);
f.options.hud.variant = 'operator';
f.run();
assert.equal(f.first.classList.contains('collapsed'), false);
assert.equal(
f.first.classList.contains('cyber-accordion-collapsed'),
false,
);
assert.equal(f.second.classList.contains('collapsed'), false);
}
});
test('Cyber restored accordion prefers keyboard focus when no explicit owner exists', () => {
const f = fixture('right', { hud: { visible: true, variant: 'cyber' } });
f.expand(f.first, 250);
f.expand(f.second, 250);
f.options.documentRef.activeElement = f.second;
f.run();
assert.equal(f.first.classList.contains('collapsed'), true);
assert.equal(f.second.classList.contains('collapsed'), false);
});
function element(
id,
{ height = 42, top = 234, left = 20, width = 272, collapsed = false } = {},
+14 -4
View File
@@ -59,7 +59,10 @@ export class RadioControls {
*/
async _revealRadioControlsAfterExplicitEnable(trigger) {
const contextPanel = document.getElementById('global-context-panel');
const scroller = contextPanel?.querySelector('.global-context-panel-inner');
const scroller =
(document.documentElement?.dataset.uiTheme === 'cyber' &&
contextPanel?.querySelector('.cyber-panel-body')) ||
contextPanel?.querySelector('.global-context-panel-inner');
const directory = this._radioPanel?.querySelector('.radio-directory-row');
const transport = this._radioPanel?.querySelector('.radio-transport');
if (
@@ -113,7 +116,10 @@ export class RadioControls {
*/
async _revealRadioPanelInsideContext({ focusTarget = null } = {}) {
const contextPanel = document.getElementById('global-context-panel');
const scroller = contextPanel?.querySelector('.global-context-panel-inner');
const scroller =
(document.documentElement?.dataset.uiTheme === 'cyber' &&
contextPanel?.querySelector('.cyber-panel-body')) ||
contextPanel?.querySelector('.global-context-panel-inner');
if (
!contextPanel ||
contextPanel.classList.contains('collapsed') ||
@@ -154,10 +160,12 @@ export class RadioControls {
return moved;
}
/** Keep the Context header Radio shortcut truthful for its current route. */
/** Keep the launcher truthful for the active theme and Context route. */
_syncContextRadioLauncherState() {
if (this.destroyed || !this._contextRadioToggleBtn) return;
const contextPanel = document.getElementById('global-context-panel');
const contextPanel = globalThis.document?.getElementById(
'global-context-panel',
);
const contextExpanded = Boolean(
contextPanel && !contextPanel.classList.contains('collapsed'),
);
@@ -177,6 +185,8 @@ export class RadioControls {
this._contextRadioToggleBtn.title = label;
return;
}
// This launcher toggles the compact player even when the detailed panel
// is open elsewhere. Its ARIA state must describe that same disclosure.
const compactOpen = Boolean(
this._contextRadioDock?.classList.contains('disclosure-open'),
);
+17
View File
@@ -0,0 +1,17 @@
/** Keep Radio in Context in every theme, reusing the same playback controls. */
export function syncRadioPanelPlacement(doc) {
if (!doc?.getElementById) return;
const panel = doc.getElementById('radio-panel');
const context = doc.getElementById('global-context-panel');
const dock = doc.getElementById('context-radio-dock');
const mini = doc.getElementById('context-radio-mini');
const parent =
context?.querySelector('.cyber-panel-body') ||
context?.querySelector('.global-context-panel-inner');
const header = context?.querySelector('.panel-header');
if (!panel || !parent || !header || !dock || !mini) return;
if (panel.parentElement !== parent) parent.append(panel);
if (dock.parentElement !== header)
header.insertBefore(dock, header.querySelector('.panel-collapse-btn'));
if (mini.parentElement !== dock) dock.append(mini);
}
+105
View File
@@ -0,0 +1,105 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { syncRadioPanelPlacement } from './radioPanelPlacement.js';
function node() {
return {
children: [],
selectors: {},
parentElement: null,
moves: 0,
querySelector(selector) {
return this.selectors[selector] || null;
},
append(child) {
this.insertBefore(child, null);
},
insertBefore(child, before) {
if (child.parentElement) {
const siblings = child.parentElement.children;
siblings.splice(siblings.indexOf(child), 1);
}
const index = before
? this.children.indexOf(before)
: this.children.length;
this.children.splice(index, 0, child);
child.parentElement = this;
child.moves++;
},
};
}
test('all themes keep the same Radio controls nested in Context', () => {
const panel = node(),
context = node(),
rail = node(),
dock = node(),
mini = node();
const inner = node(),
contextHeader = node(),
radioHeader = node();
const contextCollapse = node(),
radioCollapse = node();
context.selectors['.global-context-panel-inner'] = inner;
const body = node();
context.selectors['.cyber-panel-body'] = body;
context.selectors['.panel-header'] = contextHeader;
panel.selectors['.panel-header'] = radioHeader;
contextHeader.selectors['.panel-collapse-btn'] = contextCollapse;
radioHeader.selectors['.panel-collapse-btn'] = radioCollapse;
inner.append(panel);
contextHeader.append(dock);
contextHeader.append(contextCollapse);
radioHeader.append(radioCollapse);
dock.append(mini);
const nodes = {
'radio-panel': panel,
'global-context-panel': context,
'right-context-rail': rail,
'context-radio-dock': dock,
'context-radio-mini': mini,
};
const doc = {
documentElement: { dataset: {} },
getElementById: (id) => nodes[id],
};
for (let cycle = 0; cycle < 3; cycle++) {
doc.documentElement.dataset.uiTheme = 'cyber';
syncRadioPanelPlacement(doc);
assert.equal(panel.parentElement, body);
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
assert.equal(mini.parentElement, dock);
const moves = [panel.moves, dock.moves, mini.moves];
syncRadioPanelPlacement(doc);
assert.deepEqual(
[panel.moves, dock.moves, mini.moves],
moves,
'no mutation feedback loop',
);
delete doc.documentElement.dataset.uiTheme;
syncRadioPanelPlacement(doc);
assert.equal(panel.parentElement, body);
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
assert.equal(mini.parentElement, dock);
}
// Recover a hierarchy left by the previous Cyber implementation without
// recreating the player, changing playback, or mutating collapse state.
rail.append(panel);
radioHeader.insertBefore(dock, radioCollapse);
panel.append(mini);
doc.documentElement.dataset.uiTheme = 'cyber';
syncRadioPanelPlacement(doc);
assert.equal(panel.parentElement, body);
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
assert.equal(mini.parentElement, dock);
});
test('placement is inert before the complete shell exists', () => {
assert.doesNotThrow(() => syncRadioPanelPlacement(null));
assert.doesNotThrow(() =>
syncRadioPanelPlacement({
documentElement: { dataset: { uiTheme: 'cyber' } },
getElementById: () => null,
}),
);
});
+27 -2
View File
@@ -7,6 +7,8 @@ import {
shouldHideCollapsedRightPanels,
} from './panelRailGeometry.js';
import { displayPanelScroller } from './displayPanelScroll.js';
const pendingCollapseRetries = new WeakSet();
/**
@@ -56,6 +58,28 @@ export function layoutRightPanelRail({
onCollapse(panel);
}
}
// Normalize restored state and theme entry without overwriting saved panel
// preferences. This marker is distinct from Tactical's space-based collapse.
if (hud.variant === 'cyber') {
const expanded = panels.filter(
(panel) => !panel.classList.contains('collapsed'),
);
const owner =
expanded.find((panel) => panel.id === preferredPanelId) ||
expanded.find((panel) => panel.contains(documentRef?.activeElement)) ||
expanded[0];
for (const panel of expanded) {
if (panel === owner) continue;
panel.classList.add('collapsed', 'cyber-accordion-collapsed');
onCollapse(panel);
}
} else {
for (const panel of panels) {
if (!panel.classList.contains('cyber-accordion-collapsed')) continue;
panel.classList.remove('collapsed', 'cyber-accordion-collapsed');
onCollapse(panel);
}
}
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
const hasExpandedPanel = panels.some(
(panel) =>
@@ -276,10 +300,11 @@ export function layoutRightPanelRail({
stack.dataset.expandedCount = String(expandedPanels.length);
if (displayPanel && expandedPanels.includes(displayPanel)) {
const scroller = displayPanelScroller(displayPanel);
const maxScrollTop = Math.max(
0,
displayPanel.scrollHeight - displayPanel.clientHeight,
scroller.scrollHeight - scroller.clientHeight,
);
displayPanel.scrollTop = Math.min(displayScrollTop, maxScrollTop);
scroller.scrollTop = Math.min(displayScrollTop, maxScrollTop);
}
}
+15
View File
@@ -17,6 +17,21 @@ export function readShellElements(document = globalThis.document) {
_hudBtn: document.getElementById('hud-toggle'),
_hudLayoutRow: document.getElementById('hud-layout-row'),
_hudLayoutSelect: document.getElementById('hud-layout-select'),
_cyberSonarBtn: document.getElementById('cyber-sonar-toggle'),
_cyberSonarRings: document.getElementById('cyber-sonar-rings'),
_cyberSonarRingsValue: document.getElementById('cyber-sonar-rings-value'),
_cyberSonarRange: document.getElementById('cyber-sonar-range'),
_cyberSonarRangeValue: document.getElementById('cyber-sonar-range-value'),
_cyberSonarIntensity: document.getElementById('cyber-sonar-intensity'),
_cyberSonarIntensityValue: document.getElementById(
'cyber-sonar-intensity-value',
),
_cyberSonarOpacity: document.getElementById('cyber-sonar-opacity'),
_cyberSonarOpacityValue: document.getElementById(
'cyber-sonar-opacity-value',
),
_cyberSonarSector: document.getElementById('cyber-sonar-sector'),
_cyberSonarSectorValue: document.getElementById('cyber-sonar-sector-value'),
_detectionSliderRow: document.getElementById('detection-slider-row'),
_detectionDensitySlider: document.getElementById(
'detection-density-slider',
+8
View File
@@ -762,6 +762,14 @@ export class ShellFacade {
return this._visualSettings._setHudVariant(...arguments);
}
_setCyberSonarEnabled(enabled) {
return this._visualSettings._setCyberSonarEnabled(...arguments);
}
_setCyberSonarSetting(name, value) {
return this._visualSettings._setCyberSonarSetting(...arguments);
}
/**
* Keeps both responsive panel lanes and Cockpit's utility strip on the same
* measured layout commit. HUD visibility transitions can outlive the first
+34 -3
View File
@@ -362,11 +362,20 @@
}
#pp-toggles:not(.collapsed) > .pp-header-row,
#pp-toggles:not(.collapsed) > .pp-toggle-group,
#pp-toggles:not(.collapsed) > #clean-view-toggle {
#pp-toggles:not(.collapsed) > .pp-panel-body > .pp-toggle-group,
#pp-toggles:not(.collapsed) > .pp-panel-body > #clean-view-toggle {
width: 100%;
}
.pp-panel-body,
.cyber-panel-body {
display: contents;
}
.panel-collapsible.collapsed > .global-context-panel-inner > .cyber-panel-body {
display: none;
}
.pp-toggle-btn {
display: flex;
align-items: center;
@@ -683,6 +692,28 @@
min-width: 200px;
}
.cyber-sonar-control {
display: none;
}
:root[data-ui-theme='cyber'] .cyber-sonar-control {
display: flex;
}
.cyber-sonar-toggle-row,
.cyber-sonar-setting {
min-width: 200px;
}
.cyber-sonar-toggle-row .pp-mode-btn {
margin-left: auto;
min-width: 48px;
}
:root[data-cyber-sonar='off'] .cyber-sonar-setting {
display: none;
}
/* Map Source tiles live with Visual Presets in the bottom command tray. */
.map-source-section {
display: grid;
@@ -1055,7 +1086,7 @@ body.ui-clean-view #scene-runtime.active {
/* Display owns the constrained desktop scroll surface. Keeping Parameters as
* a second scroll container traps wheel/touch input at its boundary and lets
* the flex column compress newly added preset rows below the visible panel. */
#pp-toggles:not(.collapsed) > #param-slider-panel.active {
#pp-toggles:not(.collapsed) > .pp-panel-body > #param-slider-panel.active {
flex: 0 0 auto;
max-height: none;
overflow-y: visible;
File diff suppressed because it is too large. Load diff
+44
View File
@@ -1,5 +1,49 @@
<!-- Aircraft first-person view. It can be entered from the visible view switcher. -->
<section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden>
<!-- One optical coordinate system: every targeting mark shares this center.
Decorative only; telemetry and interactive panels remain separate. -->
<svg class="cockpit-frame-lines" viewBox="0 0 1200 720" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<g fill="none" stroke="currentColor">
<path d="M42 106H234L266 126H334 M866 126H934L966 106H1158 M42 114H226L258 136H318 M882 136H942L974 114H1158"/>
<path d="M42 616H226L254 598H320 M880 598H946L974 616H1158 M42 624H238L266 606H330 M870 606H934L962 624H1158"/>
<path d="M322 650H878L858 622H728L708 634H492L472 622H342Z M348 659H852"/>
<path d="M72 160H144L158 174V186 M1056 160H1128L1142 174V186 M72 562H144L158 548V536 M1056 562H1128L1142 548V536"/>
</g>
<path class="cockpit-frame-warning" d="M338 645H476L490 654H710L724 645H862" fill="none"/>
<path class="cockpit-frame-meter" d="M372 642H418 M782 642H828"/>
</svg>
<div class="cockpit-scan-array" aria-hidden="true">
<svg class="cockpit-target-geometry" viewBox="0 0 600 600" fill="none" focusable="false">
<g class="cockpit-target-hairline">
<circle cx="300" cy="300" r="276"/>
<circle class="cockpit-target-drift" cx="300" cy="300" r="250" stroke-dasharray="1 13"/>
<circle cx="300" cy="300" r="164"/>
<path d="M190 237V363 M410 237V363 M99 227H139 M461 227H501 M99 373H139 M461 373H501"/>
<path d="M86 113H118L134 129 M514 113H482L466 129 M86 487H118L134 471 M514 487H482L466 471"/>
</g>
<g class="cockpit-target-side-arcs">
<path d="M70 167A266 266 0 0 0 70 433 M530 167A266 266 0 0 1 530 433"/>
</g>
<g class="cockpit-target-ticks">
<path d="M166 125A220 220 0 0 1 434 125 M166 475A220 220 0 0 0 434 475"/>
</g>
<g class="cockpit-target-dots">
<path d="M180 130A208 208 0 0 1 420 130 M180 470A208 208 0 0 0 420 470"/>
</g>
<g class="cockpit-target-warning-dots">
<path d="M172 112A227 227 0 0 1 428 112 M172 488A227 227 0 0 0 428 488"/>
</g>
<g class="cockpit-target-core">
<!-- One outer ring and a spaced rotor leave the aiming center open. -->
<circle class="cockpit-target-orbit" cx="300" cy="300" r="132" stroke-dasharray="186 21"/>
<circle class="cockpit-target-rotor" cx="300" cy="300" r="104" stroke-dasharray="128 35" transform="rotate(9 300 300)"/>
<path class="cockpit-target-spokes" d="M206 206L250 250 M350 350L394 394 M394 206L350 250 M250 350L206 394"/>
<path d="M168 291H180V309H168 M432 291H420V309H432"/>
<circle cx="206" cy="206" r="3"/><circle cx="394" cy="206" r="3"/>
<circle cx="206" cy="394" r="3"/><circle cx="394" cy="394" r="3"/>
</g>
</svg>
</div>
<div class="cockpit-visor-shell" aria-hidden="true">
<div class="cockpit-roll-arc">
<span style="--slot:-3;--depth:3">−30</span>
+2
View File
@@ -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>
@@ -237,6 +238,7 @@
</div>
</div>
</section>
</div>
</div>
</div>
</aside>
+33
View File
@@ -5,6 +5,7 @@
<span class="panel-divider" aria-hidden="true"></span>
<button class="panel-collapse-btn pp-collapse-btn" data-collapse-target="pp-toggles" title="Collapse panel">&#x25C0;</button>
</div>
<div class="pp-panel-body" data-rail-scroller>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)">
<span class="pp-icon">&#x25CE;</span>
@@ -16,8 +17,39 @@
<option value="tactical">Tactical</option>
<option value="operator">Operator</option>
<option value="minimal">Minimal</option>
<option value="cyber">Cyber</option>
</select>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-toggle-row" id="cyber-sonar-row">
<span class="pp-slider-mini-label">Sonar</span>
<button class="pp-mode-btn active" id="cyber-sonar-toggle" title="Cyber sonar contact scanner" aria-label="Cyber sonar contact scanner" aria-pressed="true">ON</button>
</div>
<output id="cyber-sonar-status" class="cyber-sonar-control" role="status"></output>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-rings-row">
<label class="pp-slider-mini-label" for="cyber-sonar-rings">Rings</label>
<input type="range" class="pp-slider" id="cyber-sonar-rings" min="3" max="12" step="1" value="8" aria-label="Sonar ring count" />
<span class="pp-slider-value" id="cyber-sonar-rings-value">8</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-range-row">
<label class="pp-slider-mini-label" for="cyber-sonar-range">Range</label>
<input type="range" class="pp-slider" id="cyber-sonar-range" min="60" max="120" step="1" value="100" aria-label="Sonar ring range" />
<span class="pp-slider-value" id="cyber-sonar-range-value">100%</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-intensity-row">
<label class="pp-slider-mini-label" for="cyber-sonar-intensity">Power</label>
<input type="range" class="pp-slider" id="cyber-sonar-intensity" min="0" max="100" step="1" value="70" aria-label="Sonar intensity" />
<span class="pp-slider-value" id="cyber-sonar-intensity-value">70%</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-opacity-row">
<label class="pp-slider-mini-label" for="cyber-sonar-opacity">Opacity</label>
<input type="range" class="pp-slider" id="cyber-sonar-opacity" min="35" max="100" step="1" value="84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" />
<span class="pp-slider-value" id="cyber-sonar-opacity-value">84%</span>
</div>
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-sector-row">
<label class="pp-slider-mini-label" for="cyber-sonar-sector">Sector</label>
<input type="range" class="pp-slider" id="cyber-sonar-sector" min="8" max="60" step="1" value="24" aria-label="Sonar sector width" />
<span class="pp-slider-value" id="cyber-sonar-sector-value">24°</span>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="detection-toggle" title="Detection Overlay (D)" aria-label="Detection overlay" aria-pressed="false">
@@ -146,4 +178,5 @@
</div>
</div>
</div>
</div>
<button id="clean-view-exit" title="Return UI controls">EXIT CLEAN VIEW</button>
+2
View File
@@ -21,6 +21,7 @@
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel">+</button>
</div>
<div class="cyber-panel-body" data-rail-scroller>
<div id="cctv-frame-wrap" class="cctv-frame-wrap">
<img id="cctv-frame" alt="CCTV feed frame" />
<canvas id="cctv-video" class="cctv-frame" hidden></canvas>
@@ -66,6 +67,7 @@
</div>
<div class="cctv-summary-label">SCENE SUMMARY</div>
<div id="cctv-summary">Enable CCTV to start camera-linked intelligence summaries.</div>
</div>
</div>
</div>
+84 -7
View File
@@ -32,6 +32,13 @@ import {
shareCacheNeedsHeal,
shareableDetectionState,
} from '../contactsDetectionPolicy.js';
import {
applyCyberSonarSettings,
isCyberSonarEnabled,
readCyberSonarSettings,
setCyberSonarEnabled,
} from '../cyberSonar.js';
import { cyberVisualDefaultsForHudTransition } from '../hudLayouts.js';
const DETECTION_ALLOCATION_STORAGE_KEY = 'gev:detection-allocation:v1';
/** Own visual preferences, detection overrides and display-control state. */
@@ -602,19 +609,87 @@ export class VisualSettings {
this._syncShareState();
}
_setHudVariant(variantName) {
_setHudVariant(variantName, { applyVisualDefaults = false } = {}) {
if (!variantName) return;
const previousVariant = this.hud.getVariant();
this.hud.setVariant(variantName);
if (
this._hudLayoutSelect &&
this._hudLayoutSelect.value !== this.hud.getVariant()
) {
this._hudLayoutSelect.value = this.hud.getVariant();
const nextVariant = this.hud.getVariant();
if (this._hudLayoutSelect && this._hudLayoutSelect.value !== nextVariant) {
this._hudLayoutSelect.value = nextVariant;
}
const visualDefaults = cyberVisualDefaultsForHudTransition(
previousVariant,
nextVariant,
{ explicit: applyVisualDefaults },
);
if (visualDefaults) this._applyCyberVisualDefaults(visualDefaults);
this._syncCyberSonarControl();
this._syncShareState();
this._scheduleAdaptivePanelLayout({ settle: true });
}
_applyCyberVisualDefaults({ style, ironbow }) {
this.setStyle(style, {
applyPreset: false,
revealParameters: false,
});
const thermalUniforms = this.stages?.thermal?.uniforms;
if (!thermalUniforms || thermalUniforms.palette === undefined) return;
thermalUniforms.palette = ironbow;
this._updateSliderPanel('thermal', { reveal: false });
this.services.governorRequestRender('cyber-visual-defaults');
}
_syncCyberSonarControl() {
if (!this._cyberSonarBtn) return;
const enabled = isCyberSonarEnabled();
const settings = applyCyberSonarSettings(readCyberSonarSettings());
this._cyberSonarBtn.classList.toggle('active', enabled);
this._cyberSonarBtn.setAttribute('aria-pressed', String(enabled));
this._cyberSonarBtn.textContent = enabled ? 'ON' : 'OFF';
for (const [input, output, value, suffix] of [
[this._cyberSonarRings, this._cyberSonarRingsValue, settings.rings, ''],
[this._cyberSonarRange, this._cyberSonarRangeValue, settings.range, '%'],
[
this._cyberSonarIntensity,
this._cyberSonarIntensityValue,
settings.intensity,
'%',
],
[
this._cyberSonarOpacity,
this._cyberSonarOpacityValue,
settings.opacity,
'%',
],
[
this._cyberSonarSector,
this._cyberSonarSectorValue,
settings.sector,
'°',
],
]) {
if (input) input.value = String(value);
if (output) output.textContent = `${value}${suffix}`;
}
}
_setCyberSonarEnabled(enabled = !isCyberSonarEnabled()) {
const next = setCyberSonarEnabled(!!enabled);
this._syncCyberSonarControl();
this.services.governorRequestRender('cyber-sonar-toggle');
return next;
}
_setCyberSonarSetting(name, value) {
const settings = readCyberSonarSettings();
if (!Object.hasOwn(settings, name)) return settings;
const next = applyCyberSonarSettings({ ...settings, [name]: value });
this._syncCyberSonarControl();
this.services.governorRequestRender(`cyber-sonar-${name}`);
return next;
}
_applyStylePresetDefaults(styleName) {
const { governorRequestRender } = this.services;
const preset = STYLE_PRESET_DEFAULTS[styleName];
@@ -656,7 +731,9 @@ export class VisualSettings {
this._setSharpenEnabled(sharpenInput.enabled);
}
if (preset.hudVariant) {
// Cyber is an explicit shell choice, independent of the imagery filter.
// Scene/share restoration still applies its own HUD through _setHudVariant.
if (preset.hudVariant && this.hud.getVariant() !== 'cyber') {
this._setHudVariant(preset.hudVariant);
}
if (typeof preset.hudVisible === 'boolean') {
+18 -1
View File
@@ -1,3 +1,5 @@
import { HUD_LAYOUTS } from '../hudLayoutPolicy.js';
// Canonical action arguments. Descriptive wording is supplied separately.
const schemas = [
{
@@ -313,11 +315,26 @@ const schemas = [
},
layout: {
type: 'string',
enum: ['tactical', 'operator', 'minimal'],
enum: [...HUD_LAYOUTS],
},
},
},
},
{
name: 'set_cyber_sonar',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
enabled: { type: 'boolean' },
rings: { type: 'integer', minimum: 3, maximum: 12 },
rangePct: { type: 'integer', minimum: 60, maximum: 120 },
intensityPct: { type: 'integer', minimum: 0, maximum: 100 },
opacityPct: { type: 'integer', minimum: 35, maximum: 100 },
sectorDeg: { type: 'integer', minimum: 8, maximum: 60 },
},
},
},
{
name: 'set_detection',
parameters: {
+15 -5
View File
@@ -15,14 +15,21 @@ const stable = (value) =>
)
: value;
test('the complete Realtime tool payload pins the additive analyst, satellite and Local ADS-B release', () => {
test('the complete Realtime tool payload pins the additive analyst, satellite, Local ADS-B and Cyber release', () => {
const digest = createHash('sha256')
.update(JSON.stringify(stable(GEV_REALTIME_TOOLS)))
.update(
JSON.stringify(
stable(
GEV_REALTIME_TOOLS.filter((tool) => tool.name !== 'set_cyber_sonar'),
),
),
)
.digest('hex');
assert.equal(
digest,
// Re-derived for the additive `local-adsb` set_layer_visibility value.
'4de5c78425d8233794cfd37fdb797605ae93611ba71b7208f4d912b587a0e859',
// Re-derived for the additive `local-adsb` set_layer_visibility value and
// the Cyber HUD layout; the separate sonar tool is excluded above.
'590d537d93e132ac64ac5e211ad5bb9d7d1b1f22e2dd963dda5465fab4510a3b',
);
});
@@ -80,7 +87,7 @@ test('metadata cannot add tools, fields, types or enum values', () => {
test('all legacy action arguments are byte-identical after removing the deliberate additions', () => {
const legacy = structuredClone(GEV_ACTION_SCHEMAS).filter(
(tool) => tool.name !== 'next_satellite_pass',
(tool) => !['next_satellite_pass', 'set_cyber_sonar'].includes(tool.name),
);
const layers = legacy.find((tool) => tool.name === 'analyst_query').parameters
.properties.layers.items;
@@ -106,6 +113,9 @@ test('all legacy action arguments are byte-identical after removing the delibera
}
}
// Independently derived by executing trusted c9f9896 actionSchemas in the restricted container.
const hud = legacy.find((tool) => tool.name === 'set_hud').parameters
.properties.layout;
hud.enum = hud.enum.filter((layout) => layout !== 'cyber');
assert.equal(
createHash('sha256').update(JSON.stringify(legacy)).digest('hex'),
'820fff21658f6907e1010b2b79c5431a77f4e34afd2277d62d8de46c368b6f8c',
+11
View File
@@ -1058,6 +1058,17 @@ export function createGevActionRunner({
return { ...out, hud: styleManager.getControlState().hud };
}
if (name === 'set_cyber_sonar') {
if (typeof styleManager?.setCyberSonar !== 'function') {
return {
ok: false,
action: 'set_cyber_sonar',
error: 'Cyber sonar controls are unavailable.',
};
}
return { action: 'set_cyber_sonar', ...styleManager.setCyberSonar(args) };
}
if (name === 'set_detection') {
const result = styleManager.setDetection({
enabled: typeof args.enabled === 'boolean' ? args.enabled : undefined,