mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
74 files changed
+7320
-90
No files matched your search
@@ -6,6 +6,17 @@
|
||||
panel method.
|
||||
- On phones the title bar sits 16 px from the top so both Radio broadcast
|
||||
waves stay on-screen.
|
||||
- Keep Cyber right-rail panels mutually exclusive and Display, CCTV and Context
|
||||
headers and frames fixed during content scrolling. Restore Radio's nested Context placement and compact
|
||||
player. Add Cyber Sonar voice controls with settings and effect-state readback.
|
||||
Keep keyboard-focus outlines visible inside Cyber's clipped map and cockpit
|
||||
expand/collapse buttons, with a matching red hover border.
|
||||
|
||||
- Add the opt-in Cyber HUD layout with coordinated map and cockpit panel
|
||||
styling. Display exposes Sonar on/off, rings, range, power, opacity and sector.
|
||||
Native point, billboard and label highlighting uses GPU draw commands; there
|
||||
is no scene-dimming effect selector. Unsupported shaders retain native contact
|
||||
rendering, and leaving Cyber restores the standard shell and contact treatment.
|
||||
|
||||
- Add a **Recent Imagery** data layer (NASA GIBS · HLS + VIIRS, keyless).
|
||||
Select a box (drag, the current view, or around a pin; up to 1,000 km a
|
||||
|
||||
+38
-1
@@ -1,5 +1,42 @@
|
||||
# God's Eye View Current State
|
||||
|
||||
## Cyber HUD — September 23, 2026
|
||||
|
||||
Display > HUD > Layout includes Cyber, also available through the HUD voice
|
||||
action and shared visual state. An explicit first transition into Cyber selects
|
||||
FLIR with Ironbow 0.42; restored links and subsequent visual tuning remain
|
||||
authoritative. The skin uses shared red/slate panel treatments in map and cockpit,
|
||||
with compact 200px collapsed controls and wider expanded panels. Other HUD
|
||||
layouts retain their existing presentation.
|
||||
Chamfered panel expand/collapse controls use an inset keyboard-focus outline and
|
||||
red hover border in both map and cockpit, so clipping does not hide the highlight.
|
||||
|
||||
Cyber's map-side right rail opens one panel at a time while keeping collapsed
|
||||
launchers available. Display, CCTV and Context scroll their contents inside fixed
|
||||
headers and decorative frames. The narrow-screen rail remains scrollable to reach
|
||||
each panel. Radio retains the shared nested Context player and compact
|
||||
header disclosure, without relocating playback controls on theme changes.
|
||||
|
||||
Sonar has one contact-highlighting method. Native Cesium points, billboards and
|
||||
labels are treated in GPU draw commands without replacing their positions,
|
||||
identities or pick commands. Model and canvas/DOM overlays retain their own
|
||||
rendering paths. Sonar OFF stops the sweep while retaining Cyber's neutral
|
||||
contact treatment. Reduced motion disables the animated sweep; cockpit and
|
||||
hidden HUD states do not apply the map treatment. If the native shader is not
|
||||
supported, native contacts remain available and Display reports the limitation.
|
||||
No CSS scene-dimming fallback or effect selector is exposed.
|
||||
|
||||
The Sonar controls adjust rings, range, power, opacity and sector. Sweep opacity
|
||||
affects detection painting rather than label cohort admission. Theme exit and
|
||||
renderer teardown release the derived GPU resources and restore owned model
|
||||
colors without replacing newer layer-owned color changes.
|
||||
|
||||
The `set_cyber_sonar` voice action adjusts the same controls without switching
|
||||
themes or enabling omitted settings. State readback distinguishes configured
|
||||
settings from active map/contact effects. Sonar settings are session-only and
|
||||
return to their defaults on reload. Sliders accept integer values matching the
|
||||
action's ranges.
|
||||
|
||||
Wind appears in the Weather group before Utilities. The surface-weather prototype
|
||||
uses keyless NOAA GFS or ECMWF IFS forecasts on an approximately 1° display grid.
|
||||
It defaults to 10 m wind trails over the existing basemap. Speed shading is an explicit choice; earlier v2 links retain their original speed-shading meaning.
|
||||
@@ -1013,7 +1050,7 @@ Non-object or array-valued properties reject the response instead of being treat
|
||||
|
||||
Launch payloads with missing records now say PAYLOAD DATA UNAVAILABLE. Missing names use Unnamed payload; absent or invalid mass stays unknown instead of appearing as 0 KG.
|
||||
|
||||
Updated: September 15, 2026
|
||||
Updated: September 23, 2026
|
||||
|
||||
## Aircraft and vessel server modules
|
||||
|
||||
|
||||
@@ -257,6 +257,7 @@
|
||||
"runtime": "node",
|
||||
"exports": ["./server/providers/openai"],
|
||||
"modules": [
|
||||
"src/hudLayoutPolicy.js",
|
||||
"server/providers/common/rate-limit.js",
|
||||
"server/providers/common/request.js",
|
||||
"server/providers/common/source-root.js",
|
||||
@@ -327,6 +328,7 @@
|
||||
"exports": ["./ui/layout"],
|
||||
"modules": [
|
||||
"src/ui/panelRails.js",
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
"src/ui/rightPanelRail.js",
|
||||
"src/ui/panelMeasurement.js",
|
||||
@@ -462,6 +464,7 @@
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
@@ -487,6 +490,10 @@
|
||||
"ui-shell": {
|
||||
"exports": ["./ui/shell"],
|
||||
"modules": [
|
||||
"src/hudLayouts.js",
|
||||
"src/hudLayoutPolicy.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/ui/radioPanelPlacement.js",
|
||||
"src/app/stateChannel.js",
|
||||
"src/bloom.js",
|
||||
"src/cctvFocusPolicy.js",
|
||||
@@ -517,6 +524,7 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/cyberSonarControls.js",
|
||||
"src/ui/locationNavigation.js",
|
||||
"src/ui/cockpitCoordinator.js",
|
||||
"src/ui/cameraOrientationControls.js",
|
||||
@@ -535,6 +543,7 @@
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
@@ -727,6 +736,7 @@
|
||||
"civil-flights": {
|
||||
"exports": ["./layers/flights"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftIcons.js",
|
||||
"src/data/aircraftMeta.js",
|
||||
@@ -767,6 +777,7 @@
|
||||
"military-flights": {
|
||||
"exports": ["./layers/military"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/data/aircraftClass.js",
|
||||
"src/data/aircraftIcons.js",
|
||||
"src/data/aircraftMeta.js",
|
||||
@@ -921,6 +932,7 @@
|
||||
"launches-layer": {
|
||||
"exports": ["./layers/launches"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/layers/launches/camera.js",
|
||||
"src/layers/launches/controls.js",
|
||||
"src/layers/launches/index.js",
|
||||
@@ -967,6 +979,7 @@
|
||||
"awareness-layer": {
|
||||
"exports": ["./layers/awareness"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/cockpitMath.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/militaryAwarenessEngine.js",
|
||||
@@ -1247,6 +1260,12 @@
|
||||
"./standalone/catalog"
|
||||
],
|
||||
"modules": [
|
||||
"src/cyberSonarScene.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/hudLayouts.js",
|
||||
"src/hudLayoutPolicy.js",
|
||||
"src/ui/radioPanelPlacement.js",
|
||||
"src/cyberSonarGpu.js",
|
||||
"src/scenes/nepalEvidencePack.js",
|
||||
"src/data/bhoteKoshiEvent.js",
|
||||
"src/ui/imagerySplit.js",
|
||||
@@ -1685,6 +1704,7 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/ui/cyberSonarControls.js",
|
||||
"src/ui/locationNavigation.js",
|
||||
"src/ui/cockpitCoordinator.js",
|
||||
"src/ui/cameraOrientationControls.js",
|
||||
@@ -1703,6 +1723,7 @@
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/cockpitInput.js",
|
||||
"src/ui/cockpitInstruments.js",
|
||||
"src/ui/cockpitLayout.js",
|
||||
@@ -1950,6 +1971,7 @@
|
||||
"application-layer-construction": {
|
||||
"exports": ["./application/layers"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/data/bhoteKoshiEvent.js",
|
||||
"src/ui/imagerySplit.js",
|
||||
"src/maps/imageryComparison.js",
|
||||
@@ -2481,6 +2503,10 @@
|
||||
"ui-visual-settings": {
|
||||
"exports": ["./ui/visual-settings"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/hudLayouts.js",
|
||||
"src/hudLayoutPolicy.js",
|
||||
"src/ui/radioPanelPlacement.js",
|
||||
"src/bloom.js",
|
||||
"src/cockpitVisionPolicy.js",
|
||||
"src/contactsDetectionPolicy.js",
|
||||
@@ -2505,6 +2531,8 @@
|
||||
"ui-panel-chrome": {
|
||||
"exports": ["./ui/panel-chrome"],
|
||||
"modules": [
|
||||
"src/ui/displayPanelScroll.js",
|
||||
"src/ui/radioPanelPlacement.js",
|
||||
"src/panelStackLayout.js",
|
||||
"src/ui/leftPanelRail.js",
|
||||
"src/ui/panelChrome.js",
|
||||
@@ -2595,7 +2623,7 @@
|
||||
},
|
||||
"voice-action-schemas": {
|
||||
"exports": ["./voice/action-schemas"],
|
||||
"modules": ["src/voice/actionSchemas.js"],
|
||||
"modules": ["src/voice/actionSchemas.js", "src/hudLayoutPolicy.js"],
|
||||
"external": []
|
||||
},
|
||||
"voice-session": {
|
||||
@@ -2757,6 +2785,7 @@
|
||||
"transit-standalone": {
|
||||
"exports": ["./layers/transit/standalone"],
|
||||
"modules": [
|
||||
"src/cyberSonar.js",
|
||||
"src/app/sourceSlot.js",
|
||||
"src/celestialRing.js",
|
||||
"src/data/cameraSensitivity.js",
|
||||
|
||||
@@ -1943,6 +1943,98 @@ try {
|
||||
}),
|
||||
);
|
||||
|
||||
// Cyber relocates its collapsed briefing header into the expanded utility
|
||||
// lane. Exercise actual height changes without reload and measure both cards.
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager._setHudVariant('cyber');
|
||||
manager.hud.setMode('on');
|
||||
manager._setCockpitDisclosure('display', false);
|
||||
manager._setCockpitDisclosure('radio', true);
|
||||
});
|
||||
const cyberRadioOrder = await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
const display = document
|
||||
.getElementById('cockpit-display-toggle-btn')
|
||||
.closest('.cockpit-utility-control');
|
||||
const radio = document
|
||||
.getElementById('cockpit-radio-toggle-btn')
|
||||
.closest('.cockpit-utility-control');
|
||||
const displayRect = display.getBoundingClientRect();
|
||||
const radioRect = radio.getBoundingClientRect();
|
||||
return {
|
||||
display: displayRect.toJSON(),
|
||||
radio: radioRect.toJSON(),
|
||||
displayExpanded: display.classList.contains('is-expanded'),
|
||||
radioExpanded: radio.classList.contains('is-expanded'),
|
||||
gap: radioRect.top - displayRect.bottom,
|
||||
};
|
||||
});
|
||||
check(
|
||||
'Cyber expanding Radio preserves the authored Display-then-Radio order',
|
||||
!cyberRadioOrder.displayExpanded
|
||||
&& cyberRadioOrder.radioExpanded
|
||||
&& cyberRadioOrder.display.height > 0
|
||||
&& cyberRadioOrder.radio.height > 0
|
||||
&& cyberRadioOrder.gap >= 7,
|
||||
JSON.stringify(cyberRadioOrder),
|
||||
);
|
||||
await page.screenshot({ path: path.join(shotsDir, 'cyber-radio-order.png') });
|
||||
await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
manager._setCockpitDisclosure('radio', false);
|
||||
manager._setCockpitDisclosure('display', true);
|
||||
});
|
||||
const cyberStyleStates = await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const states = [];
|
||||
for (const style of ['normal', 'surveillance', 'thermal', 'retro']) {
|
||||
document.querySelector(`.style-btn[data-style="${style}"]`).click();
|
||||
states.push({
|
||||
requestedStyle: style,
|
||||
activeStyle: manager.activeStyle,
|
||||
hud: manager.hud.getVariant(),
|
||||
theme: document.documentElement.dataset.uiTheme,
|
||||
selector: document.getElementById('hud-layout-select').value,
|
||||
savedHud: manager.getVisualState().hud.variant,
|
||||
});
|
||||
}
|
||||
return states;
|
||||
});
|
||||
check('Cockpit visual-style controls retain Cyber HUD, selector, shell and saved layout',
|
||||
cyberStyleStates.every(state => state.activeStyle === state.requestedStyle
|
||||
&& state.hud === 'cyber' && state.theme === 'cyber'
|
||||
&& state.selector === 'cyber' && state.savedHud === 'cyber'),
|
||||
JSON.stringify(cyberStyleStates));
|
||||
for (const height of [987, 720, 640, 900]) {
|
||||
await page.setViewport({ width: 1440, height, deviceScaleFactor: 1 });
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
const geometry = await page.evaluate(() => {
|
||||
const signal = document.getElementById('cockpit-signal-stream');
|
||||
const utility = document.getElementById('cockpit-utility-controls');
|
||||
const expanded = utility.querySelector('.cockpit-utility-control.is-expanded');
|
||||
const s = signal.getBoundingClientRect();
|
||||
const u = expanded.getBoundingClientRect();
|
||||
const toggle = document.getElementById('cockpit-signal-toggle');
|
||||
const t = toggle.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(t.x + t.width / 2, t.y + t.height / 2);
|
||||
return { height: innerHeight, theme: document.documentElement.dataset.uiTheme,
|
||||
collapsed: signal.dataset.collapsed, signal: s.toJSON(), utility: u.toJSON(),
|
||||
reachable: Boolean(hit && toggle.contains(hit)),
|
||||
contained: u.bottom <= s.top - 7 && s.bottom < innerHeight,
|
||||
};
|
||||
});
|
||||
check(`Cyber collapsed Live Signals remains visible and reachable after resize to ${height}px`,
|
||||
geometry.theme === 'cyber' && geometry.collapsed === 'true'
|
||||
&& geometry.signal.width > 0 && geometry.signal.height > 0
|
||||
&& geometry.reachable && geometry.contained, JSON.stringify(geometry));
|
||||
if (height === 640 || height === 900) {
|
||||
await page.screenshot({ path: path.join(shotsDir, `cyber-resize-${height}.png`) });
|
||||
}
|
||||
}
|
||||
await page.evaluate(() => window.__godsEyeView.styleManager._setHudVariant('operator'));
|
||||
|
||||
const signalTransition = await page.evaluate(() => {
|
||||
const manager = window.__godsEyeView.styleManager;
|
||||
const cockpit = manager.cockpitView;
|
||||
|
||||
@@ -49,7 +49,8 @@ function realtimeInstructions(
|
||||
'NAMED VIEWS are shorthand for tool calls you already have — there is no "mode" tool for them. Treat ONLY these as the shorthand: "infrastructure mode" / "the infrastructure view" / "show me global infrastructure" means three set_layer_visibility calls (local-datacenters, local-dams, telegeography-submarine-cables) plus zoom_to_globe; "environmental mode" / "earth watch" / "active events", said as the name of a view, means set_layer_visibility for local-firms and earthquakes plus zoom_to_globe. Anything vaguer is NOT this shorthand — an open-ended question about the world or the news is an ordinary question: answer it, or use analyst_query over the layers already on. Never switch a whole view on to answer a question nobody asked to see. When you do run one, make every call before speaking, then give one confirmation naming the resulting state; if the fires layer comes back unavailable because no FIRMS key is configured, say so plainly — the earthquakes still loaded. "Live contacts" and "space missions" are NOT this pattern: they stay set_context_mode{mode:"contacts"} and set_context_mode{mode:"space-missions"}.',
|
||||
'For visual filter requests, call set_visual_style with one of the allowed style IDs.',
|
||||
'Disambiguation table — basemap vs layer vs style: basemap switching requires an explicit stack name — "Bing aerial" means set_map_stack bing-aerial, "aerial with labels" means bing-labels, "OSM"/"road map" means osm, "Esri"/"Esri imagery" means esri-imagery, "Google 3D"/"photorealistic" means photoreal. Any mention of "satellite" or "satellites" ALWAYS means the satellites DATA LAYER via set_layer_visibility, never a basemap. "surveillance"/"night vision"/"thermal" are visual STYLES via set_visual_style.',
|
||||
'HUD requests ("hud on/off", "switch to operator/minimal/tactical layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
|
||||
'HUD requests ("hud on/off", "switch to operator/minimal/tactical/cyber layout") use set_hud. Detection requests ("detection on", "dense mode", "balanced mode", "sparse mode", "set density to 25", "use weighted allocation") use set_detection. Density snaps to 0/25/50/75/100 and derives Sparse/Balanced/Dense; panoptic is a legacy alias for Dense.',
|
||||
'Cyber sonar requests use set_cyber_sonar: enabled, rings, rangePct, intensityPct (Power), opacityPct, sectorDeg. For settings questions read get_current_view_state.controls.hud.sonar. Sonar is not detection. Do not silently switch themes, reveal the HUD or enable sonar when changing a slider. If the user explicitly requests Cyber plus sonar settings, call set_hud first, wait for success, then set_cyber_sonar. Confirm returned values; mapSweepActive and contactSweepActive distinguish saved settings from active map effects, including in Cockpit or reduced motion. Do not claim visible contacts solely because enabled is true.',
|
||||
'Bloom/sharpen requests use set_post_processing. Scene requests ("play orbital watch", "stop the scene", "what scenes are there") use control_scene. CCTV camera requests ("next camera", "nearest camera", "select the Congress camera", "show coverage") use control_cctv — the CCTV layer must be enabled first.',
|
||||
'Radio playback requests use control_radio. "Turn on/start the radio" means action=play; action=enable only reveals Radio markers and must be reserved for explicit "show/enable the Radio layer/markers" requests. After a prepared playback result, briefly confirm any other completed actions and say "Turning on the radio"—never claim it is already playing. The client keeps Radio muted until playback is verified, then closes voice before restoring Radio volume. Examples: "play news near Austin" → select category=news locationId=austin; "play US news" → select category=news country=US; "Radio volume 30" → volume; pause/resume/stop/next/previous use the matching action. Radio selection never moves the camera.',
|
||||
'"Track/follow <something specific>" (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.',
|
||||
|
||||
@@ -182,6 +182,29 @@ export const ACTION_DESCRIPTIONS = {
|
||||
},
|
||||
},
|
||||
},
|
||||
set_cyber_sonar: {
|
||||
description:
|
||||
'Adjust Cyber-only sonar controls. Requires the Cyber HUD layout; never switches layout or HUD visibility. Omitted fields stay unchanged. Returns actual settings and separate map/contact sweep activity. A saved setting does not mean the effect is active.',
|
||||
$position: 1,
|
||||
parameters: {
|
||||
properties: {
|
||||
enabled: { description: 'Explicitly turn sonar on or off.' },
|
||||
rings: { description: 'Number of decorative sonar rings, 3–12.' },
|
||||
rangePct: {
|
||||
description:
|
||||
'Visual ring range, 60–120 percent; not geographic distance.',
|
||||
},
|
||||
intensityPct: {
|
||||
description: 'Sonar Power slider, 0–100 percent. Zero is valid.',
|
||||
},
|
||||
opacityPct: {
|
||||
description:
|
||||
'Contact opacity floor between passes, 35–100 percent; labels have a derived floor. Not whole-scene dimming.',
|
||||
},
|
||||
sectorDeg: { description: 'Sonar sweep sector width, 8–60 degrees.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
set_detection: {
|
||||
description:
|
||||
'Control the detection overlay: on/off, density-derived Sparse/Balanced/Dense profile, and Elastic/Weighted layer allocation.',
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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\(\);/,
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -293,6 +293,7 @@ export function bindCameraOrientationControls({
|
||||
);
|
||||
}
|
||||
if (!applied || applied.heading !== next.heading) {
|
||||
northButton?.setAttribute('data-north-up', String(next.heading === 0));
|
||||
northButton?.style?.setProperty('--camera-heading', `${next.heading}deg`);
|
||||
northButton?.setAttribute(
|
||||
'aria-label',
|
||||
|
||||
@@ -337,6 +337,33 @@ test('the needle follows a rotation too small for the camera-changed threshold',
|
||||
controls.destroy();
|
||||
});
|
||||
|
||||
test('north-up indicator follows real heading including the 360-degree seam', () => {
|
||||
const { viewer } = createRealViewer(AUSTIN_GROUND);
|
||||
const northButton = new FakeButton();
|
||||
const controls = bindCameraOrientationControls({
|
||||
viewer,
|
||||
elements: { northButton },
|
||||
runNavigation: (_noun, navigate) => navigate(),
|
||||
});
|
||||
for (const [heading, active] of [
|
||||
[0, true],
|
||||
[15, false],
|
||||
[359.8, true],
|
||||
[359, false],
|
||||
[360, true],
|
||||
[90, false],
|
||||
]) {
|
||||
orbit(viewer, AUSTIN_GROUND, heading, Cesium.Math.toRadians(-80), 5_000);
|
||||
viewer.scene.preRender.raiseEvent();
|
||||
assert.equal(
|
||||
northButton.getAttribute('data-north-up'),
|
||||
String(active),
|
||||
`heading ${heading}`,
|
||||
);
|
||||
}
|
||||
controls.destroy();
|
||||
});
|
||||
|
||||
test('a settled camera writes nothing, and teardown releases every signal', () => {
|
||||
const { viewer } = createRealViewer(AUSTIN_GROUND);
|
||||
orbit(viewer, AUSTIN_GROUND, 0, Cesium.Math.toRadians(-80), 5_000);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { displayPanelScroller } from './displayPanelScroll.js';
|
||||
|
||||
/** Move the existing Display groups between their two presentation surfaces. */
|
||||
export class CockpitDisplayPortal {
|
||||
constructor({ standardPanel, cockpitPanel, groups, layout }) {
|
||||
@@ -20,15 +22,18 @@ export class CockpitDisplayPortal {
|
||||
group.before(anchor);
|
||||
return [{ name, group, slot, anchor }];
|
||||
});
|
||||
this.standardScrollTop = standardPanel?.scrollTop || 0;
|
||||
this.standardScrollTop =
|
||||
displayPanelScroller(standardPanel)?.scrollTop || 0;
|
||||
this.cockpitScrollTop = cockpitPanel?.scrollTop || 0;
|
||||
const options = { passive: true, signal: this.listeners.signal };
|
||||
standardPanel?.addEventListener(
|
||||
'scroll',
|
||||
() => {
|
||||
if (!this.active) this.standardScrollTop = standardPanel.scrollTop;
|
||||
if (!this.active)
|
||||
this.standardScrollTop =
|
||||
displayPanelScroller(standardPanel).scrollTop;
|
||||
},
|
||||
options,
|
||||
{ ...options, capture: true },
|
||||
);
|
||||
cockpitPanel?.addEventListener(
|
||||
'scroll',
|
||||
@@ -85,7 +90,8 @@ export class CockpitDisplayPortal {
|
||||
if (nextActive && this.cockpitPanel)
|
||||
this.cockpitPanel.scrollTop = this.cockpitScrollTop;
|
||||
if (!nextActive && this.standardPanel)
|
||||
this.standardPanel.scrollTop = this.standardScrollTop;
|
||||
displayPanelScroller(this.standardPanel).scrollTop =
|
||||
this.standardScrollTop;
|
||||
};
|
||||
if (!settle) {
|
||||
restoreScroll();
|
||||
@@ -121,7 +127,8 @@ export class CockpitDisplayPortal {
|
||||
}
|
||||
this.cockpitPanel?.classList.remove('uses-shared-display-controls');
|
||||
if (this.standardPanel)
|
||||
this.standardPanel.scrollTop = this.standardScrollTop;
|
||||
displayPanelScroller(this.standardPanel).scrollTop =
|
||||
this.standardScrollTop;
|
||||
this.records = [];
|
||||
this.restoreOwner = null;
|
||||
}
|
||||
|
||||
+135
-6
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
resolveCockpitUtilityAnchor,
|
||||
resolveCockpitUtilityLayout,
|
||||
resolveCyberCockpitPanelLane,
|
||||
} from '../cockpitUtilityLayout.js';
|
||||
import {
|
||||
COCKPIT_UTILITY_REC_GAP_PX,
|
||||
@@ -90,7 +91,53 @@ export function syncContextLayout() {
|
||||
'--cockpit-context-left',
|
||||
`${desktopInset.toFixed(1)}px`,
|
||||
);
|
||||
this.context.style.removeProperty('--cockpit-context-top');
|
||||
if (document.documentElement.dataset.uiTheme !== 'cyber') {
|
||||
this.context.style.removeProperty('--cockpit-context-top');
|
||||
this.context.style.removeProperty('--cockpit-context-max-height');
|
||||
return;
|
||||
}
|
||||
const defaultTop = Math.max(255, Math.min(300, window.innerHeight * 0.28));
|
||||
const dataPanel = document.querySelector(
|
||||
'#left-panel-stack > #data-panel:not(.collapsed)',
|
||||
);
|
||||
let desiredTop = defaultTop;
|
||||
if (this.contextCollapsed && dataPanel) {
|
||||
desiredTop = Math.max(
|
||||
desiredTop,
|
||||
dataPanel.getBoundingClientRect().bottom + 8,
|
||||
);
|
||||
}
|
||||
const contextBounds = this.context.getBoundingClientRect();
|
||||
let safeBottom = window.innerHeight - 24;
|
||||
for (const selector of [
|
||||
'#cesium-credits',
|
||||
'#command-dock',
|
||||
'#view-switcher',
|
||||
]) {
|
||||
const obstacle = document.querySelector(selector);
|
||||
if (!obstacle) continue;
|
||||
const style = window.getComputedStyle(obstacle);
|
||||
if (
|
||||
style.display === 'none' ||
|
||||
style.visibility === 'hidden' ||
|
||||
Number(style.opacity) === 0
|
||||
)
|
||||
continue;
|
||||
const bounds = obstacle.getBoundingClientRect();
|
||||
const overlapsHorizontally =
|
||||
bounds.right > contextBounds.left && bounds.left < contextBounds.right;
|
||||
if (overlapsHorizontally && bounds.top > desiredTop) {
|
||||
safeBottom = Math.min(safeBottom, bounds.top - 8);
|
||||
}
|
||||
}
|
||||
const boundedTop = Math.max(
|
||||
defaultTop,
|
||||
Math.min(desiredTop, safeBottom - contextBounds.height),
|
||||
);
|
||||
this.context.style.setProperty(
|
||||
'--cockpit-context-top',
|
||||
`${boundedTop.toFixed(1)}px`,
|
||||
);
|
||||
this.context.style.removeProperty('--cockpit-context-max-height');
|
||||
}
|
||||
|
||||
@@ -127,6 +174,34 @@ export function syncSignalLayout() {
|
||||
);
|
||||
this.signalStream.style.removeProperty('--cockpit-signal-top');
|
||||
this.signalStream.style.removeProperty('--cockpit-signal-max-height');
|
||||
const cyber = document.documentElement.dataset.uiTheme === 'cyber';
|
||||
const leftPanel = cyber
|
||||
? document.querySelector('#left-panel-stack > #data-panel')
|
||||
: null;
|
||||
const alignedTop = isRenderedOnScreen(leftPanel)
|
||||
? leftPanel.getBoundingClientRect().top
|
||||
: null;
|
||||
// Cyber's two rails share a top edge. Move the briefing below the collapsed
|
||||
// strip rather than pulling the right rail upward to avoid it.
|
||||
if (alignedTop !== null && utilityControls) {
|
||||
const expanded = utilityControls.querySelector(
|
||||
'.cockpit-utility-control.is-expanded',
|
||||
);
|
||||
if (!expanded) {
|
||||
const signalTop = Math.max(
|
||||
Math.max(255, Math.min(300, window.innerHeight * 0.28)),
|
||||
alignedTop + utilityControls.getBoundingClientRect().height + 28,
|
||||
);
|
||||
this.signalStream.style.setProperty(
|
||||
'--cockpit-signal-top',
|
||||
`${signalTop.toFixed(1)}px`,
|
||||
);
|
||||
this.signalStream.style.setProperty(
|
||||
'--cockpit-signal-max-height',
|
||||
`${Math.max(44, window.innerHeight - signalTop - 80).toFixed(1)}px`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const signalBounds = this.signalStream.getBoundingClientRect();
|
||||
const utilityBounds = utilityControls?.getBoundingClientRect();
|
||||
if (utilityBounds) {
|
||||
@@ -151,9 +226,51 @@ export function syncSignalLayout() {
|
||||
collapsedLauncher.scrollHeight,
|
||||
)
|
||||
: 0;
|
||||
// Cockpit owns this anchor outright. The strip used to inherit the left
|
||||
// accordion's committed top, which is solved against left-lane obstacles
|
||||
// and dropped the strip straight through the briefing card below it.
|
||||
if (alignedTop !== null) {
|
||||
const lane = resolveCyberCockpitPanelLane({
|
||||
viewportHeight: window.innerHeight,
|
||||
top: alignedTop,
|
||||
launcherHeight: Math.max(
|
||||
collapsedHeight,
|
||||
collapsedLauncher?.getBoundingClientRect().height || 60,
|
||||
),
|
||||
signalHeight: this.signalCollapsed ? signalBounds.height : 44,
|
||||
contactHeight:
|
||||
this.contextCollapsed && this.context
|
||||
? this.context.getBoundingClientRect().height
|
||||
: 64,
|
||||
});
|
||||
document.body.style.setProperty(
|
||||
'--cyber-cockpit-panel-height',
|
||||
`${lane.panelHeight.toFixed(2)}px`,
|
||||
);
|
||||
this.hud?.style.setProperty(
|
||||
'--cockpit-utility-top',
|
||||
`${alignedTop.toFixed(2)}px`,
|
||||
);
|
||||
this.hud?.style.setProperty(
|
||||
'--cockpit-utility-max-height',
|
||||
`${lane.utilityHeight.toFixed(2)}px`,
|
||||
);
|
||||
this.hud?.style.setProperty(
|
||||
'--cockpit-utility-expanded-max-height',
|
||||
`${lane.panelHeight.toFixed(2)}px`,
|
||||
);
|
||||
utilityControls.classList.remove('layout-primary-only');
|
||||
utilityControls
|
||||
.querySelectorAll('.cockpit-utility-control')
|
||||
.forEach((control) => control.setAttribute('aria-hidden', 'false'));
|
||||
if (expandedControl) {
|
||||
this.signalStream.style.setProperty(
|
||||
'--cockpit-signal-top',
|
||||
`${lane.signalTop.toFixed(2)}px`,
|
||||
);
|
||||
}
|
||||
this.syncContextLayout();
|
||||
return;
|
||||
}
|
||||
// Non-Cyber themes retain their independent REC/briefing corridor. Cyber
|
||||
// uses the rendered Data Layers top after reserving briefing space above.
|
||||
// The readout only anchors the strip while it is genuinely on screen:
|
||||
// the Minimal variant drops it with `display:none`, but HUD Off hides the
|
||||
// whole Intel HUD with `visibility`/`opacity`, which keeps its rect.
|
||||
@@ -169,8 +286,20 @@ export function syncSignalLayout() {
|
||||
collapsedHeight,
|
||||
recGap: COCKPIT_UTILITY_REC_GAP_PX,
|
||||
signalGap: COCKPIT_UTILITY_SIGNAL_GAP_PX,
|
||||
minTopFloor: COCKPIT_UTILITY_MIN_TOP_PX,
|
||||
minTopRatio: COCKPIT_UTILITY_MIN_TOP_RATIO,
|
||||
minTopFloor: alignedTop ?? COCKPIT_UTILITY_MIN_TOP_PX,
|
||||
minTopRatio: alignedTop !== null ? 0 : COCKPIT_UTILITY_MIN_TOP_RATIO,
|
||||
signalCollapsed: this.signalCollapsed,
|
||||
utilityExpanded: Boolean(expandedControl),
|
||||
// Only Cyber relocates its collapsed briefing below the utility lane.
|
||||
// Other themes keep the measured visible card as the lower boundary.
|
||||
reserveViewportLane: document.documentElement.dataset.uiTheme === 'cyber',
|
||||
// Cyber keeps the collapsed briefing reachable below the utility instead
|
||||
// of hiding it. Reserve its actual height, including wrapped headings.
|
||||
signalHeight:
|
||||
document.documentElement.dataset.uiTheme === 'cyber' &&
|
||||
this.signalCollapsed
|
||||
? signalBounds.height
|
||||
: 0,
|
||||
});
|
||||
const availableHeight = utilityAnchor.maxHeight;
|
||||
this.hud?.style.setProperty(
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { PanelChrome } from './panelChrome.js';
|
||||
|
||||
function fixture(theme = 'cyber') {
|
||||
const nodes = new Map();
|
||||
const node = (id, collapsed = true) => {
|
||||
const classes = new Set(collapsed ? ['collapsed'] : []);
|
||||
const value = { id, hidden: false, children: [],
|
||||
matches: () => true,
|
||||
contains(target) { return this === target || this.children.some((child) => child.contains(target)); },
|
||||
classList: {
|
||||
contains: (name) => classes.has(name),
|
||||
add: (...names) => names.forEach((name) => classes.add(name)),
|
||||
remove: (...names) => names.forEach((name) => classes.delete(name)),
|
||||
toggle: (name, on) => on ? classes.add(name) : classes.delete(name),
|
||||
},
|
||||
};
|
||||
nodes.set(id, value);
|
||||
return value;
|
||||
};
|
||||
const rail = node('rail');
|
||||
const display = node('pp-toggles'), context = node('global-context-panel'), cctv = node('cctv-panel'), radio = node('radio-panel');
|
||||
rail.children = [display, context, cctv];
|
||||
for (const panel of rail.children) panel.parentElement = rail;
|
||||
context.children = [radio];
|
||||
radio.parentElement = context;
|
||||
const saved = [], claimed = [];
|
||||
const owner = {
|
||||
_rightPanelStack: rail,
|
||||
_panelLayout: {},
|
||||
_lifetime: { frame() {} },
|
||||
_syncPanelCollapseButton() {}, _scheduleRightPanelLayout() {},
|
||||
_scheduleLeftPanelLayout() {}, _layoutRightPanels() {}, _syncCctvPanelViewport() {},
|
||||
_savePanelCollapsedState: (...args) => saved.push(args),
|
||||
shareLinkManager: { claimRestoreLane: (...args) => claimed.push(args), onPanelStateChange() {} },
|
||||
};
|
||||
owner.setPanelCollapsed = PanelChrome.prototype.setPanelCollapsed.bind(owner);
|
||||
return { display, context, cctv, radio, owner, saved, claimed,
|
||||
doc: { documentElement: { dataset: { uiTheme: theme } }, getElementById: (id) => nodes.get(id) },
|
||||
};
|
||||
}
|
||||
|
||||
test('explicit Cyber opening collapses peers and claims their restoration lanes', () => {
|
||||
const f = fixture(), prior = globalThis.document;
|
||||
globalThis.document = f.doc;
|
||||
try {
|
||||
f.owner.setPanelCollapsed('global-context-panel', false, { explicit: true });
|
||||
f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true });
|
||||
assert.equal(f.display.classList.contains('collapsed'), false);
|
||||
assert.equal(f.context.classList.contains('collapsed'), true);
|
||||
assert.ok(f.saved.some(([id, collapsed]) => id === 'global-context-panel' && collapsed));
|
||||
assert.ok(f.claimed.some(([, id]) => id === 'global-context-panel'));
|
||||
f.cctv.classList.add('cyber-accordion-collapsed');
|
||||
f.owner.setPanelCollapsed('cctv-panel', true, { explicit: true });
|
||||
assert.equal(f.cctv.classList.contains('cyber-accordion-collapsed'), false);
|
||||
} finally { globalThis.document = prior; }
|
||||
});
|
||||
|
||||
test('opening already-expanded Radio reveals Context and closes Display', () => {
|
||||
const f = fixture(), prior = globalThis.document;
|
||||
globalThis.document = f.doc;
|
||||
try {
|
||||
f.owner.setPanelCollapsed('radio-panel', false, { explicit: true });
|
||||
f.owner.setPanelCollapsed('pp-toggles', false, { explicit: true });
|
||||
assert.equal(f.radio.classList.contains('collapsed'), false);
|
||||
assert.equal(f.context.classList.contains('collapsed'), true);
|
||||
f.owner.setPanelCollapsed('radio-panel', false, { explicit: true });
|
||||
assert.equal(f.context.classList.contains('collapsed'), false);
|
||||
assert.equal(f.display.classList.contains('collapsed'), true);
|
||||
} finally { globalThis.document = prior; }
|
||||
});
|
||||
|
||||
test('non-Cyber explicit panels and restored preferences retain their existing policy', () => {
|
||||
for (const theme of ['tactical', 'operator', 'minimal', 'cyber']) {
|
||||
const f = fixture(theme), prior = globalThis.document;
|
||||
globalThis.document = f.doc;
|
||||
try {
|
||||
const options = theme === 'cyber' ? { restore: true, persist: false } : { explicit: true };
|
||||
f.owner.setPanelCollapsed('pp-toggles', false, options);
|
||||
f.owner.setPanelCollapsed('cctv-panel', false, options);
|
||||
assert.equal(f.display.classList.contains('collapsed'), false);
|
||||
assert.equal(f.cctv.classList.contains('collapsed'), false);
|
||||
if (theme === 'cyber') assert.deepEqual(f.saved, []);
|
||||
} finally { globalThis.document = prior; }
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import {
|
||||
isCyberSonarEnabled,
|
||||
isCyberSonarActive,
|
||||
isCyberContactSonarActive,
|
||||
readCyberSonarSettings,
|
||||
} from '../cyberSonar.js';
|
||||
|
||||
const SETTING_FIELDS = Object.freeze({
|
||||
rings: ['rings', 3, 12],
|
||||
rangePct: ['range', 60, 120],
|
||||
intensityPct: ['intensity', 0, 100],
|
||||
opacityPct: ['opacity', 35, 100],
|
||||
sectorDeg: ['sector', 8, 60],
|
||||
});
|
||||
|
||||
/** Read configuration separately from whether map effects can currently run. */
|
||||
export function getCyberSonarControlState(doc = globalThis.document) {
|
||||
const root = doc?.documentElement;
|
||||
const settings = readCyberSonarSettings(root);
|
||||
const options = {
|
||||
root,
|
||||
body: doc?.body,
|
||||
hud: doc?.getElementById?.('intel-hud') ?? null,
|
||||
};
|
||||
return {
|
||||
enabled: isCyberSonarEnabled(root),
|
||||
rings: settings.rings,
|
||||
rangePct: settings.range,
|
||||
intensityPct: settings.intensity,
|
||||
opacityPct: settings.opacity,
|
||||
sectorDeg: settings.sector,
|
||||
cyberSelected: root?.dataset?.uiTheme === 'cyber',
|
||||
mapSweepActive: isCyberSonarActive(options),
|
||||
contactSweepActive:
|
||||
root?.dataset?.cyberSonarGpu === 'supported' &&
|
||||
isCyberContactSonarActive(options),
|
||||
contactRenderer: root?.dataset?.cyberSonarGpu ?? 'not-initialized',
|
||||
};
|
||||
}
|
||||
|
||||
/** Validate every field before mutating through the same owner as the sliders. */
|
||||
export function setCyberSonarControls(
|
||||
owner,
|
||||
request,
|
||||
doc = globalThis.document,
|
||||
) {
|
||||
const fail = (error) => ({
|
||||
ok: false,
|
||||
error,
|
||||
sonar: getCyberSonarControlState(doc),
|
||||
});
|
||||
if (!request || typeof request !== 'object' || Array.isArray(request)) {
|
||||
return fail('Provide Cyber sonar settings as an object.');
|
||||
}
|
||||
const fields = Object.keys(request);
|
||||
if (!fields.length) return fail('Provide at least one Cyber sonar setting.');
|
||||
for (const field of fields) {
|
||||
const value = request[field];
|
||||
if (field === 'enabled') {
|
||||
if (typeof value !== 'boolean') return fail('enabled must be a boolean.');
|
||||
continue;
|
||||
}
|
||||
if (!Object.hasOwn(SETTING_FIELDS, field)) {
|
||||
return fail('Unknown Cyber sonar setting.');
|
||||
}
|
||||
const [, min, max] = SETTING_FIELDS[field];
|
||||
if (!Number.isInteger(value) || value < min || value > max) {
|
||||
return fail(`${field} must be an integer from ${min} to ${max}.`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
owner.hud?.getVariant?.() !== 'cyber' ||
|
||||
doc?.documentElement?.dataset?.uiTheme !== 'cyber'
|
||||
) {
|
||||
return fail('Cyber sonar controls require the Cyber HUD layout.');
|
||||
}
|
||||
for (const [field, [setting]] of Object.entries(SETTING_FIELDS)) {
|
||||
if (Object.hasOwn(request, field)) {
|
||||
owner._setCyberSonarSetting(setting, request[field]);
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(request, 'enabled')) {
|
||||
owner._setCyberSonarEnabled(request.enabled);
|
||||
}
|
||||
return { ok: true, sonar: getCyberSonarControlState(doc) };
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
getCyberSonarControlState,
|
||||
setCyberSonarControls,
|
||||
} from './cyberSonarControls.js';
|
||||
import { GEV_ACTION_SCHEMAS } from '../voice/actionSchemas.js';
|
||||
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
|
||||
import { createGevActionRunner } from '../voice/gevActions.js';
|
||||
import { StyleManager } from './applicationShell.js';
|
||||
import { VisualSettings } from './visualSettings.js';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
function fixture(theme = 'cyber') {
|
||||
const root = {
|
||||
dataset: { uiTheme: theme, cyberSonarGpu: 'supported' },
|
||||
style: { setProperty() {} },
|
||||
};
|
||||
const flags = new Set();
|
||||
const hudFlags = new Set(['active']);
|
||||
const doc = {
|
||||
documentElement: root,
|
||||
body: { classList: { contains: (name) => flags.has(name) } },
|
||||
getElementById: () => ({
|
||||
classList: { contains: (name) => hudFlags.has(name) },
|
||||
}),
|
||||
};
|
||||
const calls = [];
|
||||
const owner = {
|
||||
hud: { getVariant: () => theme },
|
||||
_setCyberSonarSetting(name, value) {
|
||||
calls.push([name, value]);
|
||||
const suffix = name[0].toUpperCase() + name.slice(1);
|
||||
root.dataset['cyberSonar' + suffix] = String(value);
|
||||
},
|
||||
_setCyberSonarEnabled(value) {
|
||||
calls.push(['enabled', value]);
|
||||
root.dataset.cyberSonar = value ? 'on' : 'off';
|
||||
},
|
||||
};
|
||||
return { doc, owner, calls, flags, hudFlags, root };
|
||||
}
|
||||
|
||||
test('real runner and facade use visual settings, preserve visibility and return actual values', async () => {
|
||||
const f = fixture();
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = f.doc;
|
||||
const renders = [];
|
||||
const visual = Object.create(VisualSettings.prototype);
|
||||
visual.services = { governorRequestRender: (reason) => renders.push(reason) };
|
||||
const owner = Object.create(StyleManager.prototype);
|
||||
owner._visualSettings = visual;
|
||||
Object.defineProperty(owner, 'hud', { value: f.owner.hud });
|
||||
const viewer = {
|
||||
camera: { moveEnd: { addEventListener() {} } },
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: new EventTarget() },
|
||||
};
|
||||
try {
|
||||
const runner = createGevActionRunner({ styleManager: owner, viewer, dataManager: {} });
|
||||
const result = await runner('set_cyber_sonar', { enabled: false, intensityPct: 73, sectorDeg: 9 });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.sonar.intensityPct, 73);
|
||||
assert.equal(result.sonar.sectorDeg, 9);
|
||||
assert.equal(result.sonar.enabled, false);
|
||||
assert.equal(f.root.dataset.uiTheme, 'cyber');
|
||||
assert.equal(f.hudFlags.has('active'), true);
|
||||
assert.equal(renders.length, 3);
|
||||
const before = getCyberSonarControlState();
|
||||
const invalid = await runner('set_cyber_sonar', { rings: 6, opacityPct: 0 });
|
||||
assert.equal(invalid.ok, false);
|
||||
assert.deepEqual(getCyberSonarControlState(), before);
|
||||
assert.equal(renders.length, 3);
|
||||
const zero = await runner('set_cyber_sonar', { intensityPct: 0 });
|
||||
assert.equal(zero.sonar.intensityPct, 0);
|
||||
assert.equal(zero.sonar.enabled, false);
|
||||
const unavailable = createGevActionRunner({ styleManager: {}, viewer, dataManager: {} });
|
||||
assert.equal((await unavailable('set_cyber_sonar', { enabled: true })).ok, false);
|
||||
} finally {
|
||||
globalThis.document = previousDocument;
|
||||
}
|
||||
});
|
||||
|
||||
test('native sonar sliders represent every accepted integer without browser snapping', () => {
|
||||
const html = readFileSync(new URL('./templates/display-controls.html', import.meta.url), 'utf8');
|
||||
for (const name of ['rings', 'range', 'intensity', 'opacity', 'sector']) {
|
||||
assert.match(html, new RegExp(`id="cyber-sonar-${name}"[^>]*step="1"`));
|
||||
}
|
||||
});
|
||||
|
||||
test('sonar action is available to both shared and server voice schemas', () => {
|
||||
for (const tools of [GEV_ACTION_SCHEMAS, GEV_REALTIME_TOOLS]) {
|
||||
const action = tools.find((tool) => tool.name === 'set_cyber_sonar');
|
||||
assert.ok(action);
|
||||
assert.equal(action.parameters.additionalProperties, false);
|
||||
assert.deepEqual(Object.keys(action.parameters.properties), [
|
||||
'enabled', 'rings', 'rangePct', 'intensityPct', 'opacityPct', 'sectorDeg',
|
||||
]);
|
||||
assert.equal(action.parameters.properties.intensityPct.minimum, 0);
|
||||
assert.equal(action.parameters.properties.opacityPct.minimum, 35);
|
||||
}
|
||||
});
|
||||
|
||||
test('maps all settings to the slider owner and reads actual state, including zero', () => {
|
||||
const f = fixture();
|
||||
const result = setCyberSonarControls(f.owner, {
|
||||
enabled: false, rings: 6, rangePct: 80,
|
||||
intensityPct: 0, opacityPct: 90, sectorDeg: 30,
|
||||
}, f.doc);
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(f.calls, [
|
||||
['rings', 6], ['range', 80], ['intensity', 0],
|
||||
['opacity', 90], ['sector', 30], ['enabled', false],
|
||||
]);
|
||||
assert.equal(result.sonar.enabled, false);
|
||||
assert.equal(result.sonar.intensityPct, 0);
|
||||
assert.equal(result.sonar.contactSweepActive, false);
|
||||
assert.deepEqual(result.sonar, getCyberSonarControlState(f.doc));
|
||||
});
|
||||
|
||||
test('rejects the whole invalid request before any mutation', () => {
|
||||
for (const request of [
|
||||
{}, null, [], { enabled: 'false' }, { rings: 2 }, { rangePct: 121 },
|
||||
{ opacityPct: NaN }, { intensityPct: Infinity }, { sectorDeg: 8.5 },
|
||||
{ intensityPct: '50' }, { enabled: false, unknown: 1 },
|
||||
{ rings: 6, sectorDeg: 61 },
|
||||
]) {
|
||||
const f = fixture();
|
||||
assert.equal(setCyberSonarControls(f.owner, request, f.doc).ok, false);
|
||||
assert.deepEqual(f.calls, []);
|
||||
}
|
||||
});
|
||||
|
||||
test('requires Cyber and preserves off state and every omitted setting', () => {
|
||||
for (const theme of ['tactical', 'operator', 'minimal']) {
|
||||
const f = fixture(theme);
|
||||
assert.equal(setCyberSonarControls(f.owner, { enabled: true }, f.doc).ok, false);
|
||||
assert.deepEqual(f.calls, []);
|
||||
}
|
||||
const f = fixture();
|
||||
f.root.dataset.cyberSonar = 'off';
|
||||
const before = getCyberSonarControlState(f.doc);
|
||||
const result = setCyberSonarControls(f.owner, { rings: 12 }, f.doc);
|
||||
assert.deepEqual(result.sonar, { ...before, rings: 12 });
|
||||
assert.deepEqual(f.calls, [['rings', 12]]);
|
||||
});
|
||||
|
||||
test('reports inactive map effects in Cockpit, hidden HUD, unsupported and uninitialized GPU', () => {
|
||||
const f = fixture();
|
||||
f.flags.add('cockpit-mode');
|
||||
assert.equal(getCyberSonarControlState(f.doc).mapSweepActive, false);
|
||||
f.flags.clear();
|
||||
f.hudFlags.clear();
|
||||
assert.equal(getCyberSonarControlState(f.doc).contactSweepActive, false);
|
||||
f.hudFlags.add('active');
|
||||
for (const value of ['unsupported', undefined]) {
|
||||
if (value === undefined) delete f.root.dataset.cyberSonarGpu;
|
||||
else f.root.dataset.cyberSonarGpu = value;
|
||||
assert.equal(getCyberSonarControlState(f.doc).contactSweepActive, false);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,573 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyHudUiTheme,
|
||||
CYBER_VISUAL_DEFAULTS,
|
||||
cyberVisualDefaultsForHudTransition,
|
||||
HUD_LAYOUTS,
|
||||
normalizeHudLayout,
|
||||
} from '../hudLayouts.js';
|
||||
import { GEV_ACTION_SCHEMAS } from '../voice/actionSchemas.js';
|
||||
import { VisualSettings } from './visualSettings.js';
|
||||
import { STYLES } from './visualPresets.js';
|
||||
|
||||
test('CCTV and Context keep their headers outside Cyber-only scroll bodies', () => {
|
||||
for (const file of ['layer-panels.html', 'context.html']) {
|
||||
const markup = readFileSync(
|
||||
new URL(`./templates/${file}`, import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(markup, /class="cyber-panel-body" data-rail-scroller/);
|
||||
assert.ok(
|
||||
markup.indexOf('class="panel-header"') <
|
||||
markup.indexOf('class="cyber-panel-body"'),
|
||||
);
|
||||
}
|
||||
const shared = readFileSync(
|
||||
new URL('./styles/controls.css', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const cyber = readFileSync(
|
||||
new URL('./styles/cyber.css', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(shared, /\.cyber-panel-body\s*\{\s*display: contents;/);
|
||||
assert.match(
|
||||
cyber,
|
||||
/:root\[data-ui-theme='cyber'\] \.cyber-panel-body\s*\{[^}]*overflow-y: auto;[^}]*overscroll-behavior: contain;/,
|
||||
);
|
||||
});
|
||||
|
||||
function styleOwner(t, initialVariant = 'cyber') {
|
||||
const previous = { document: globalThis.document, window: globalThis.window };
|
||||
const root = { dataset: {} };
|
||||
globalThis.document = { documentElement: root, querySelectorAll: () => [] };
|
||||
globalThis.window = { dispatchEvent() {} };
|
||||
t.after(() => Object.assign(globalThis, previous));
|
||||
let variant = initialVariant;
|
||||
const calls = [];
|
||||
const hud = {
|
||||
getVariant: () => variant,
|
||||
setVariant: (value) => {
|
||||
variant = value;
|
||||
applyHudUiTheme(root, value);
|
||||
},
|
||||
setMode: (value) => calls.push(['mode', value]),
|
||||
onStyleChange: (value) => calls.push(['style', value]),
|
||||
};
|
||||
hud.setVariant(initialVariant);
|
||||
const owner = Object.create(VisualSettings.prototype);
|
||||
Object.assign(owner, {
|
||||
activeStyle: 'normal',
|
||||
readHud: () => hud,
|
||||
readShareLinks: () => ({ onStyleChange() {} }),
|
||||
_visualEffects: {
|
||||
stages: Object.fromEntries(
|
||||
Object.entries(STYLES).map(([name, config]) => [
|
||||
name,
|
||||
{ uniforms: { ...config.uniforms, intensity: 0 } },
|
||||
]),
|
||||
),
|
||||
},
|
||||
services: { governorRequestRender() {}, setDetectionStyle() {} },
|
||||
_hudLayoutSelect: { value: initialVariant },
|
||||
_styleIndicator: {},
|
||||
_setBloomEnabled: (value) => calls.push(['bloom', value]),
|
||||
_setSharpenEnabled: (value) => calls.push(['sharpen', value]),
|
||||
_applyDetectionPreset: (value) => calls.push(['detection', value]),
|
||||
_syncShareState: () => calls.push(['share', variant]),
|
||||
});
|
||||
for (const name of [
|
||||
'setCelestialRingEnabled',
|
||||
'_startTransition',
|
||||
'_updateStyleMiniStatus',
|
||||
'_updateSliderPanel',
|
||||
'_updateHudButtonState',
|
||||
'_syncIrBoost',
|
||||
'_syncCockpitInheritedStyle',
|
||||
'_syncCyberSonarControl',
|
||||
'_scheduleAdaptivePanelLayout',
|
||||
'_applyDetectionFadeFromUi',
|
||||
])
|
||||
owner[name] = () => {};
|
||||
return { owner, hud, root, calls };
|
||||
}
|
||||
|
||||
test('visual styles retain Cyber through real preset application without dropping other defaults', (t) => {
|
||||
const { owner, hud, root, calls } = styleOwner(t);
|
||||
for (const style of [
|
||||
'surveillance',
|
||||
'normal',
|
||||
'noir',
|
||||
'anime',
|
||||
'snow',
|
||||
'thermal',
|
||||
'retro',
|
||||
]) {
|
||||
owner.setStyle(style);
|
||||
assert.equal(owner.activeStyle, style);
|
||||
assert.equal(hud.getVariant(), 'cyber');
|
||||
assert.equal(root.dataset.uiTheme, 'cyber');
|
||||
assert.equal(owner._hudLayoutSelect.value, 'cyber');
|
||||
assert.deepEqual(calls.at(-1), ['share', 'cyber']);
|
||||
}
|
||||
assert.equal(owner.stages.thermal.uniforms.sensitivity, 0.85);
|
||||
assert.equal(calls.filter(([name]) => name === 'detection').length, 3);
|
||||
assert.equal(
|
||||
calls.filter(([name, value]) => name === 'bloom' && value === false).length,
|
||||
3,
|
||||
);
|
||||
assert.equal(
|
||||
calls.filter(([name, value]) => name === 'sharpen' && value === true)
|
||||
.length,
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
test('non-Cyber style presets and explicit HUD choices remain authoritative', (t) => {
|
||||
const { owner, hud, root } = styleOwner(t);
|
||||
for (const variant of ['tactical', 'operator', 'minimal']) {
|
||||
owner._setHudVariant(variant);
|
||||
assert.equal(hud.getVariant(), variant);
|
||||
assert.equal(root.dataset.uiTheme, undefined);
|
||||
owner.setStyle('normal');
|
||||
owner.setStyle('surveillance');
|
||||
assert.equal(hud.getVariant(), 'tactical');
|
||||
}
|
||||
});
|
||||
|
||||
test('scene and share restoration override Cyber and preserve restored Cyber parameters', async (t) => {
|
||||
const { owner, hud } = styleOwner(t);
|
||||
await owner.applyVisualState({ style: 'retro', hud: { variant: 'minimal' } });
|
||||
assert.equal(hud.getVariant(), 'minimal');
|
||||
owner._setHudVariant('cyber');
|
||||
await owner.restoreShareState({
|
||||
style: 'surveillance',
|
||||
hudVariant: 'operator',
|
||||
});
|
||||
assert.equal(hud.getVariant(), 'operator');
|
||||
await owner.restoreShareState({
|
||||
style: 'thermal',
|
||||
hudVariant: 'cyber',
|
||||
styleParams: { palette: 0.73 },
|
||||
});
|
||||
assert.equal(hud.getVariant(), 'cyber');
|
||||
assert.equal(owner.activeStyle, 'thermal');
|
||||
assert.equal(owner.stages.thermal.uniforms.palette, 0.73);
|
||||
owner._setHudVariant('cyber', { applyVisualDefaults: true });
|
||||
assert.equal(owner.stages.thermal.uniforms.palette, 0.73);
|
||||
owner._setHudVariant('tactical');
|
||||
owner._setHudVariant('cyber', { applyVisualDefaults: true });
|
||||
assert.equal(owner.stages.thermal.uniforms.palette, 0.42);
|
||||
});
|
||||
|
||||
const read = (relative) =>
|
||||
readFileSync(new URL(relative, import.meta.url), 'utf8');
|
||||
|
||||
test('Cyber setup uses a compact toolbar icon and highlights the north-up letter', () => {
|
||||
const css = read('./styles/cyber.css');
|
||||
assert.match(
|
||||
css,
|
||||
/#top-center-actions #key-setup-chip\s*\{[^}]*inset: auto;[^}]*width: 36px;/,
|
||||
);
|
||||
assert.match(css, /#key-setup-chip\[hidden\]\s*\{\s*display: none;/);
|
||||
assert.match(
|
||||
css,
|
||||
/#north-up-view\[data-north-up='true'\]\s+\.camera-compass-n\s*\{[^}]*color: var\(--cyber-red-bright\)/,
|
||||
);
|
||||
});
|
||||
const cyberStyles = read('./styles/cyber.css');
|
||||
|
||||
test('Cyber clipped expand controls retain an inset keyboard-focus outline in map and cockpit', () => {
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/:root\[data-ui-theme='cyber'\]\s+:is\(\s*\.panel-collapse-btn,\s*body\.cockpit-mode \.cockpit-utility-glyph\s*\):focus-visible\s*\{[^}]*outline: 2px solid var\(--cyber-red-bright\) !important;[^}]*outline-offset: -3px;[^}]*box-shadow: none;/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/:root\[data-ui-theme='cyber'\]\s+:is\(\.panel-collapse-btn, body\.cockpit-mode \.cockpit-utility-glyph\):is\(\s*:hover,\s*:focus-visible\s*\)\s*\{[^}]*border-color: var\(--cyber-red-bright\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Cyber Radio retains upstream nested Context ownership', () => {
|
||||
assert.doesNotMatch(cyberStyles, /#right-context-rail\s*>\s*#radio-panel/);
|
||||
});
|
||||
|
||||
test('Cyber cockpit launchers share Data Layers frame and expand-control treatment', () => {
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/:is\(\.panel-collapse-btn, body\.cockpit-mode \.cockpit-utility-glyph\)/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/body\.cockpit-mode \.cockpit-utility-launcher \{[^}]*width: var\(--cyber-panel-width\);[^}]*min-height: 60px;[^}]*clip-path: var\(--cyber-panel-frame\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.cockpit-utility-glyph::after \{\s*content: '\+';/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.cockpit-utility-glyph\[aria-expanded='true'\]::after \{\s*content: '−';/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.cockpit-utility-control\.is-expanded\s+\.cockpit-utility-launcher \{[^}]*width: 100%;[^}]*clip-path: none;/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Cyber side panels share one width and one framed surface material', () => {
|
||||
assert.match(cyberStyles, /--cyber-panel-width: 272px;/);
|
||||
assert.match(cyberStyles, /--cyber-right-panel-width: 312px;/);
|
||||
assert.match(cyberStyles, /--cyber-panel-collapsed-width: 200px;/);
|
||||
assert.match(cyberStyles, /--cyber-panel-control-size: 30px;/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/--cyber-panel-surface: rgba\(18, 24, 27, 0\.92\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.cockpit-utility-control\.is-expanded \{[\s\S]*?width: var\(--cyber-right-panel-width\);[\s\S]*?background-color: var\(--cyber-panel-surface\);[\s\S]*?background-image: var\(--cyber-panel-surface-detail\);[\s\S]*?clip-path: var\(--cyber-panel-frame\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.cockpit-utility-control\.is-expanded::after \{[\s\S]*?width: 36px;[\s\S]*?border-top: 1px solid rgba\(225, 102, 102, 0\.84\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#global-context-panel #radio-panel\.collapsed \{\s*width: 100%;/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#left-panel-stack\s*> :is\(#data-panel, #scene-panel\)\.collapsed \{\s*width: var\(--cyber-panel-collapsed-width\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/:is\(#data-panel, #scene-panel\)\.collapsed\s+:is\(\.data-panel-inner, \.scene-panel-inner\) \{[\s\S]*?height: 50px;[\s\S]*?min-height: 50px;/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.cockpit-utility-control:not\(\.is-expanded\)\s+\.cockpit-utility-launcher \{\s*width: var\(--cyber-panel-collapsed-width\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#pp-toggles\s*> \.pp-header-row\s+\.pp-collapse-btn \{[\s\S]*?width: var\(--cyber-panel-control-size\);[\s\S]*?height: var\(--cyber-panel-control-size\);/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
cyberStyles,
|
||||
/\.cockpit-utility-control\.is-expanded\s*\{[^}]*order:/,
|
||||
'expanded Cockpit utilities must retain Display-then-Radio DOM order',
|
||||
);
|
||||
});
|
||||
|
||||
test('Cyber is a canonical HUD layout with a safe Tactical fallback', () => {
|
||||
assert.deepEqual(HUD_LAYOUTS, ['tactical', 'operator', 'minimal', 'cyber']);
|
||||
assert.equal(normalizeHudLayout('CYBER'), 'cyber');
|
||||
assert.equal(normalizeHudLayout('unknown'), 'tactical');
|
||||
});
|
||||
|
||||
test('the Cyber HUD owns the global shell skin and leaving it restores defaults', () => {
|
||||
const root = { dataset: { unrelated: 'preserved' } };
|
||||
assert.equal(applyHudUiTheme(root, 'cyber'), 'cyber');
|
||||
assert.equal(root.dataset.uiTheme, 'cyber');
|
||||
assert.equal(applyHudUiTheme(root, 'minimal'), null);
|
||||
assert.equal(root.dataset.uiTheme, undefined);
|
||||
assert.equal(root.dataset.unrelated, 'preserved');
|
||||
});
|
||||
|
||||
test('an explicit transition into Cyber selects FLIR with Ironbow 0.42 once', () => {
|
||||
assert.deepEqual(CYBER_VISUAL_DEFAULTS, {
|
||||
style: 'thermal',
|
||||
ironbow: 0.42,
|
||||
});
|
||||
assert.equal(
|
||||
cyberVisualDefaultsForHudTransition('tactical', 'cyber', {
|
||||
explicit: true,
|
||||
}),
|
||||
CYBER_VISUAL_DEFAULTS,
|
||||
);
|
||||
assert.equal(
|
||||
cyberVisualDefaultsForHudTransition('cyber', 'cyber', {
|
||||
explicit: true,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(cyberVisualDefaultsForHudTransition('tactical', 'cyber'), null);
|
||||
assert.equal(
|
||||
cyberVisualDefaultsForHudTransition('cyber', 'operator', {
|
||||
explicit: true,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('Cyber visual defaults update the style, thermal palette and render state', () => {
|
||||
const calls = [];
|
||||
const owner = {
|
||||
stages: { thermal: { uniforms: { palette: 0 } } },
|
||||
services: {
|
||||
governorRequestRender: (reason) => calls.push(['render', reason]),
|
||||
},
|
||||
setStyle: (style, options) => calls.push(['style', style, options]),
|
||||
_updateSliderPanel: (style, options) =>
|
||||
calls.push(['sliders', style, options]),
|
||||
};
|
||||
|
||||
VisualSettings.prototype._applyCyberVisualDefaults.call(
|
||||
owner,
|
||||
CYBER_VISUAL_DEFAULTS,
|
||||
);
|
||||
|
||||
assert.equal(owner.stages.thermal.uniforms.palette, 0.42);
|
||||
assert.deepEqual(calls, [
|
||||
['style', 'thermal', { applyPreset: false, revealParameters: false }],
|
||||
['sliders', 'thermal', { reveal: false }],
|
||||
['render', 'cyber-visual-defaults'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('the Display selector, voice schema and final stylesheet expose Cyber', () => {
|
||||
const display = read('./templates/display-controls.html');
|
||||
const stylesheet = read('../../style.css');
|
||||
const hud = read('../hud.js');
|
||||
const detection = read('../data/detection.js');
|
||||
const civilRendering = read('../layers/flights/rendering.js');
|
||||
const militaryRendering = read('../layers/military/rendering.js');
|
||||
const setHud = GEV_ACTION_SCHEMAS.find((schema) => schema.name === 'set_hud');
|
||||
|
||||
assert.match(display, /<option value="cyber">Cyber<\/option>/);
|
||||
assert.match(display, /id="cyber-sonar-toggle"/);
|
||||
assert.match(
|
||||
display,
|
||||
/id="cyber-sonar-opacity"[^>]*value="84"[^>]*aria-label="Cyber contact and label opacity"/,
|
||||
);
|
||||
assert.match(display, /id="cyber-sonar-sector"/);
|
||||
assert.deepEqual(setHud.parameters.properties.layout.enum, HUD_LAYOUTS);
|
||||
assert.match(stylesheet, /@import '\.\/src\/ui\/styles\/cyber\.css';\s*$/);
|
||||
assert.match(cyberStyles, /:root\[data-ui-theme='cyber'\]/);
|
||||
assert.match(cyberStyles, /\.material-symbols-outlined/);
|
||||
assert.match(cyberStyles, /\.pp-label/);
|
||||
assert.match(cyberStyles, /#intel-hud\[data-variant='cyber'\]/);
|
||||
assert.match(cyberStyles, /#left-panel-stack/);
|
||||
assert.match(cyberStyles, /#right-context-rail/);
|
||||
assert.match(cyberStyles, /#command-dock/);
|
||||
assert.match(cyberStyles, /--cyber-panel-frame: polygon/);
|
||||
assert.match(cyberStyles, /\.data-panel-inner,/);
|
||||
assert.match(cyberStyles, /#pp-toggles\.collapsed > \.pp-header-row/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.panel-collapsible:not\(#location-bar\):not\(#control-panel\)::after/,
|
||||
);
|
||||
assert.match(cyberStyles, /clip-path: var\(--cyber-panel-frame\);/);
|
||||
assert.match(hud, /class="hud-sonar" aria-hidden="true"/);
|
||||
assert.match(cyberStyles, /\.hud-sonar::before/);
|
||||
assert.match(cyberStyles, /\.hud-sonar::after/);
|
||||
assert.match(cyberStyles, /@keyframes cyber-sonar-sweep/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/animation-delay:\s*var\(--cyber-sonar-phase-delay, 0ms\)/,
|
||||
);
|
||||
assert.doesNotMatch(cyberStyles, /--cyber-sonar-angle/);
|
||||
assert.match(cyberStyles, /prefers-reduced-motion: reduce/);
|
||||
assert.match(detection, /candidate\.sonarFactor \?\? 1/);
|
||||
assert.match(detection, /sonarActive && !obj\.skipLabel/);
|
||||
assert.match(detection, /const bracketAlpha = admissionAlpha \* sonarFactor/);
|
||||
assert.match(detection, /sonarSampler\.label\(sonarFactor\)/);
|
||||
assert.match(detection, /createCyberSonarSampler\(width, height, now\)/);
|
||||
assert.match(cyberStyles, /data-cyber-sonar='off'/);
|
||||
assert.match(detection, /governorRequestRender\('cyber-sonar-cadence'\)/);
|
||||
assert.match(
|
||||
detection,
|
||||
/const animatingCount = countAnimatingRenderEntries\(renderEntries\);/,
|
||||
);
|
||||
assert.doesNotMatch(detection, /sonarActive \? 1 : 0/);
|
||||
assert.match(
|
||||
civilRendering,
|
||||
/alpha: flightState\._irBoost \? 1 : treatment\.alpha/,
|
||||
);
|
||||
assert.match(
|
||||
militaryRendering,
|
||||
/baseAlpha:[\s\S]*?alpha: flightState\._irBoost \? 1 : treatment\.alpha/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#title-bar \.title-logo #globe \{[\s\S]*?fill: var\(--cyber-red\);[\s\S]*?stroke: var\(--cyber-red-bright\);/,
|
||||
);
|
||||
assert.match(cyberStyles, /#title-bar \.title-logo #globe_cage/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#top-center-actions button\s*\{[\s\S]*?border-radius:\s*0;[\s\S]*?clip-path:\s*polygon\(\s*29% 0,\s*71% 0,[\s\S]*?0 29%\s*\);/,
|
||||
);
|
||||
assert.match(cyberStyles, /#top-center-actions button::before/);
|
||||
assert.match(cyberStyles, /\.hud-top-left/);
|
||||
assert.match(cyberStyles, /transparent 0 min\(40vw, 52vh\)/);
|
||||
assert.match(cyberStyles, /\.hud-bottom-bar \{\s*display: none;/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#intel-hud\[data-variant='cyber'\] \.hud-top-bar \{\s*display: none;/,
|
||||
);
|
||||
// Cyber shares the measured rail/credit keep-out rather than overriding it.
|
||||
assert.doesNotMatch(
|
||||
cyberStyles,
|
||||
/#right-context-rail\s*\{[^}]*\b(?:top|bottom|height|max-height):/,
|
||||
);
|
||||
assert.match(cyberStyles, /--cyber-hud-card-top: max\(100px, 10\.25vh\);/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/--cyber-left-rail-top: max\([\s\S]*?252px,[\s\S]*?calc\(var\(--cyber-hud-card-top\) \+ 146px\)/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.hud-top-left \{[\s\S]*?top: var\(--cyber-hud-card-top\);/,
|
||||
);
|
||||
assert.match(cyberStyles, /\.hud-bottom-left \{\s*bottom: 60px;/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#left-panel-stack \{[\s\S]*?top: var\(--cyber-left-rail-top\) !important;/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/\.hud-left-edge \{\s*top: calc\(var\(--cyber-left-rail-top\) - 18px\);\s*left: 14px;/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/--right-display-collapsed-width: var\(--cyber-panel-collapsed-width\);/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/clip-path: polygon\([\s\S]*?12px 0,[\s\S]*?0 12px/,
|
||||
);
|
||||
assert.match(cyberStyles, /\.hud-corner::before/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/--cyber-telemetry-fill: rgba\(13, 20, 23, 0\.985\);/,
|
||||
);
|
||||
assert.match(cyberStyles, /var\(--cyber-telemetry-fill\)/);
|
||||
assert.match(cyberStyles, />\s*\.panel-header\s+\.panel-title::before/);
|
||||
assert.match(cyberStyles, /\.panel-collapsible\.collapsed::after/);
|
||||
assert.match(cyberStyles, /\.panel-collapsible:not\(\.collapsed\)::after/);
|
||||
assert.match(cyberStyles, /\.context-mode-standby:not\(\[hidden\]\)/);
|
||||
assert.match(cyberStyles, /mask-image: linear-gradient\(/);
|
||||
assert.match(cyberStyles, /flex: 1 1 auto;/);
|
||||
assert.match(cyberStyles, /#left-panel-stack:has/);
|
||||
assert.match(cyberStyles, /left: 54px;/);
|
||||
assert.match(cyberStyles, /right: 62px;/);
|
||||
assert.match(cyberStyles, /bottom: calc\(2vh \+ 11rem\);/);
|
||||
assert.doesNotMatch(cyberStyles, /NAV 01 \/\/ COMMAND BUS \/\/ VIS 03/);
|
||||
assert.match(cyberStyles, /data-layer-id='satellites'/);
|
||||
assert.match(cyberStyles, /#search-toggle::before/);
|
||||
assert.match(cyberStyles, /VISUAL MATRIX \/\/ 03/);
|
||||
assert.match(cyberStyles, /body\.cockpit-mode #cockpit-hud/);
|
||||
assert.match(cyberStyles, /TARGET SCANNING \/\/ LIVE VECTOR/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/body\.cockpit-mode #global-loading-status \{[\s\S]*?top: 60px;[\s\S]*?z-index: 146;/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/body\.cockpit-mode #global-loading-detail \{[\s\S]*?color: var\(--cockpit-accent\);/,
|
||||
);
|
||||
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-visor-shell::before/);
|
||||
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-scan-array/);
|
||||
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-target-side-arcs/);
|
||||
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-target-ticks/);
|
||||
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-target-core/);
|
||||
assert.match(cyberStyles, /width: min\(48vw, 60vh\);/);
|
||||
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-context-window/);
|
||||
assert.match(cyberStyles, /body\.cockpit-mode \.cockpit-signal-window/);
|
||||
assert.match(cyberStyles, /body\.cockpit-mode #intel-hud\.active/);
|
||||
});
|
||||
|
||||
test('Cyber voice telemetry renders a live scan scope without replacing audio ownership', () => {
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#gev-voice-control\s*\{[\s\S]*?--cyber-voice-wave:/,
|
||||
);
|
||||
assert.match(cyberStyles, /\.gev-voice-visualizer::before/);
|
||||
assert.match(cyberStyles, /\.gev-voice-visualizer::after/);
|
||||
assert.match(cyberStyles, /@keyframes gev-cyber-voice-scan/);
|
||||
assert.match(cyberStyles, /@keyframes gev-cyber-voice-lock/);
|
||||
assert.match(cyberStyles, /\[data-speaker='user'\]/);
|
||||
assert.match(cyberStyles, /\[data-speaker='ai'\]/);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/prefers-reduced-motion: reduce[\s\S]*?gev-voice-visualizer::after[\s\S]*?animation: none/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Cyber Cockpit Display uses the released briefing lane instead of the legacy viewport cap', () => {
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/body\.cockpit-mode\s+#cockpit-display-panel\s*\{[\s\S]*?max-height:\s*calc\(var\(--cockpit-utility-expanded-max-height, 72vh\) - 62px\)/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#cockpit-hud:has\(\.cockpit-utility-control\.is-expanded\)[\s\S]*?\.cockpit-signal-window\[data-collapsed='true'\][\s\S]*?bottom: 80px;[\s\S]*?visibility:\s*visible/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Cyber Cockpit keeps Data Layers inside its bounded rail', () => {
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/body:not\(\.cockpit-mode\)\s+#left-panel-stack:has\([\s\S]*?max-height:\s*none;/,
|
||||
);
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/body\.cockpit-mode #left-panel-stack > #data-panel \{[\s\S]*?max-height: min\(64vh, 610px\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Cyber desktop cockpit collapsed utility frames match the 50px Data Layers frame', () => {
|
||||
const desktop = cyberStyles.slice(
|
||||
cyberStyles.indexOf('@media (min-width: 961px) and (min-height: 650px)'),
|
||||
cyberStyles.indexOf('@media (max-width: 720px)'),
|
||||
);
|
||||
assert.match(
|
||||
desktop,
|
||||
/:root\[data-ui-theme='cyber'\]\s+body\.cockpit-mode\s+\.cockpit-utility-control:not\(\.is-expanded\)\s+\.cockpit-utility-launcher\s*\{\s*height: 50px;\s*min-height: 50px;\s*padding: 8px 18px 10px 24px;/,
|
||||
);
|
||||
assert.match(
|
||||
desktop,
|
||||
/\.cockpit-utility-control:not\(\.is-expanded\)\s+\.cockpit-utility-launcher::after\s*\{\s*bottom: 6px;/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Cyber right panels preserve intrinsic sizing and the shared rail allocation', () => {
|
||||
assert.doesNotMatch(
|
||||
cyberStyles.slice(cyberStyles.indexOf(":root[data-ui-theme='cyber'] {")),
|
||||
/#right-context-rail\s*>[^{}]+\{[^{}]*\b(?:flex|height|max-height):/,
|
||||
);
|
||||
});
|
||||
|
||||
test('Cyber panels retain clean edges without the added footer band', () => {
|
||||
assert.doesNotMatch(cyberStyles, /--cyber-panel-foot-/);
|
||||
assert.doesNotMatch(cyberStyles, /border-bottom: 24px solid transparent/);
|
||||
});
|
||||
|
||||
test('Cyber keeps Sonar on/off without the removed CSS effect switch or dimmer', () => {
|
||||
const template = readFileSync(
|
||||
new URL('./templates/display-controls.html', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(template, /id="cyber-sonar-toggle"/);
|
||||
assert.doesNotMatch(template, /data-sonar-mode|cyber-sonar-mode-row/);
|
||||
assert.doesNotMatch(cyberStyles, /cyber-scene-dimmer|data-cyber-sonar-mode/);
|
||||
});
|
||||
|
||||
test('Cyber Display uses a red title without a panel-specific center tab', () => {
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#pp-toggles \.pp-header-label\s*\{\s*color: var\(--cyber-red-bright\);/,
|
||||
);
|
||||
assert.doesNotMatch(cyberStyles, /#pp-toggles[^,{]*::after\s*\{/);
|
||||
assert.match(cyberStyles, /isolation: isolate;\s*clip-path: none;/);
|
||||
assert.match(cyberStyles, /#pp-toggles:not\(\.collapsed\)::before/);
|
||||
});
|
||||
|
||||
test('Cyber omits only the fictional classification and system header lines', () => {
|
||||
assert.match(
|
||||
cyberStyles,
|
||||
/#intel-hud\[data-variant='cyber'\] \.hud-top-left \.hud-classification,\s*#intel-hud\[data-variant='cyber'\] \.hud-top-left \.hud-system\s*\{\s*display: none;\s*\}/,
|
||||
);
|
||||
});
|
||||
@@ -104,6 +104,12 @@ export class DisplayBindings {
|
||||
scopeFeatherSlider: this._scopeFeatherSlider,
|
||||
hudLayout: this._hudLayoutSelect,
|
||||
hudButton: this._hudBtn,
|
||||
sonarButton: this._cyberSonarBtn,
|
||||
sonarRingsSlider: this._cyberSonarRings,
|
||||
sonarRangeSlider: this._cyberSonarRange,
|
||||
sonarIntensitySlider: this._cyberSonarIntensity,
|
||||
sonarOpacitySlider: this._cyberSonarOpacity,
|
||||
sonarSectorSlider: this._cyberSonarSector,
|
||||
cleanViewButton: this._cleanViewBtn,
|
||||
cleanViewExitButton: this._cleanViewExitBtn,
|
||||
densitySlider: this._detectionDensitySlider,
|
||||
@@ -153,7 +159,7 @@ export class DisplayBindings {
|
||||
},
|
||||
setHudLayout: (value) => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this._setHudVariant(value);
|
||||
this._setHudVariant(value, { applyVisualDefaults: true });
|
||||
},
|
||||
toggleCleanView: () => this.toggleCleanView(),
|
||||
exitCleanView: () => this.toggleCleanView(false),
|
||||
@@ -191,6 +197,14 @@ export class DisplayBindings {
|
||||
this._updateHudButtonState();
|
||||
this._syncShareState();
|
||||
},
|
||||
toggleSonar: () => this._setCyberSonarEnabled(),
|
||||
setSonarRings: (value) => this._setCyberSonarSetting('rings', value),
|
||||
setSonarRange: (value) => this._setCyberSonarSetting('range', value),
|
||||
setSonarIntensity: (value) =>
|
||||
this._setCyberSonarSetting('intensity', value),
|
||||
setSonarOpacity: (value) =>
|
||||
this._setCyberSonarSetting('opacity', value),
|
||||
setSonarSector: (value) => this._setCyberSonarSetting('sector', value),
|
||||
cycleDetection: () => {
|
||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||
this.claimDetection();
|
||||
|
||||
@@ -21,6 +21,7 @@ export function bindDisplayControls({ elements, actions }) {
|
||||
['cleanViewExitButton', 'exitCleanView'],
|
||||
['celestialButton', 'toggleCelestial'],
|
||||
['hudButton', 'toggleHud'],
|
||||
['sonarButton', 'toggleSonar'],
|
||||
['detectionButton', 'cycleDetection'],
|
||||
['modelsButton', 'toggleModels'],
|
||||
])
|
||||
@@ -33,6 +34,14 @@ export function bindDisplayControls({ elements, actions }) {
|
||||
listen(elements[name], 'input', action, integer);
|
||||
listen(elements.densitySlider, 'input', 'setDensity', (el) => el.value);
|
||||
listen(elements.hudLayout, 'change', 'setHudLayout', (el) => el.value);
|
||||
for (const [name, action] of [
|
||||
['sonarRingsSlider', 'setSonarRings'],
|
||||
['sonarRangeSlider', 'setSonarRange'],
|
||||
['sonarIntensitySlider', 'setSonarIntensity'],
|
||||
['sonarOpacitySlider', 'setSonarOpacity'],
|
||||
['sonarSectorSlider', 'setSonarSector'],
|
||||
])
|
||||
listen(elements[name], 'input', action, integer);
|
||||
for (const el of elements.styleButtons || [])
|
||||
listen(el, 'click', 'setStyle', (el) => el.dataset.style);
|
||||
for (const el of elements.allocationButtons || [])
|
||||
|
||||
@@ -9,6 +9,19 @@ function element(value = '') {
|
||||
return el;
|
||||
}
|
||||
|
||||
test('sonar on/off control cannot fire after disposal', () => {
|
||||
const sonarButton = element();
|
||||
const calls = [];
|
||||
const control = bindDisplayControls({
|
||||
elements: { sonarButton },
|
||||
actions: { toggleSonar: () => calls.push('toggle') },
|
||||
});
|
||||
sonarButton.dispatchEvent(new Event('click'));
|
||||
control.destroy();
|
||||
sonarButton.dispatchEvent(new Event('click'));
|
||||
assert.deepEqual(calls, ['toggle']);
|
||||
});
|
||||
|
||||
test('controls read current values without preventing native input behavior', () => {
|
||||
const bloomSlider = element('24');
|
||||
const hudLayout = element('tactical');
|
||||
@@ -54,6 +67,51 @@ test('destroyed and replaced controls cannot issue stale actions', () => {
|
||||
assert.equal(newCalls, 1);
|
||||
});
|
||||
|
||||
test('the Cyber sonar control is an optional owned display action', () => {
|
||||
const sonarButton = element();
|
||||
const sonarRingsSlider = element('9');
|
||||
const sonarRangeSlider = element('110');
|
||||
const sonarIntensitySlider = element('65');
|
||||
const sonarOpacitySlider = element('84');
|
||||
const sonarSectorSlider = element('32');
|
||||
const calls = [];
|
||||
const control = bindDisplayControls({
|
||||
elements: {
|
||||
sonarButton,
|
||||
sonarRingsSlider,
|
||||
sonarRangeSlider,
|
||||
sonarIntensitySlider,
|
||||
sonarOpacitySlider,
|
||||
sonarSectorSlider,
|
||||
},
|
||||
actions: {
|
||||
toggleSonar: () => calls.push('toggle'),
|
||||
setSonarRings: (value) => calls.push(['rings', value]),
|
||||
setSonarRange: (value) => calls.push(['range', value]),
|
||||
setSonarIntensity: (value) => calls.push(['intensity', value]),
|
||||
setSonarOpacity: (value) => calls.push(['opacity', value]),
|
||||
setSonarSector: (value) => calls.push(['sector', value]),
|
||||
},
|
||||
});
|
||||
sonarButton.dispatchEvent(new Event('click'));
|
||||
sonarRingsSlider.dispatchEvent(new Event('input'));
|
||||
sonarRangeSlider.dispatchEvent(new Event('input'));
|
||||
sonarIntensitySlider.dispatchEvent(new Event('input'));
|
||||
sonarOpacitySlider.dispatchEvent(new Event('input'));
|
||||
sonarSectorSlider.dispatchEvent(new Event('input'));
|
||||
assert.deepEqual(calls, [
|
||||
'toggle',
|
||||
['rings', 9],
|
||||
['range', 110],
|
||||
['intensity', 65],
|
||||
['opacity', 84],
|
||||
['sector', 32],
|
||||
]);
|
||||
control.destroy();
|
||||
sonarButton.dispatchEvent(new Event('click'));
|
||||
assert.equal(calls.length, 6);
|
||||
});
|
||||
|
||||
test('style, allocation and model choices retain their current data attributes', () => {
|
||||
const style = element();
|
||||
const allocation = element();
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/** Cyber keeps the frame fixed; other layouts retain their existing scroller. */
|
||||
export function displayPanelScroller(panel) {
|
||||
return panel?.ownerDocument?.documentElement?.dataset.uiTheme === 'cyber'
|
||||
? panel.querySelector('.pp-panel-body') || panel
|
||||
: panel;
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readStylesheet } from '../testSupport/readStylesheet.mjs';
|
||||
import { displayPanelScroller } from './displayPanelScroll.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
@@ -7,6 +8,58 @@ import {
|
||||
measurePanelNaturalHeight,
|
||||
} from './panelRails.js';
|
||||
|
||||
test('Display uses an inner scroll body only in Cyber', () => {
|
||||
const body = { scrollTop: 95 };
|
||||
const panel = {
|
||||
ownerDocument: { documentElement: { dataset: { uiTheme: 'cyber' } } },
|
||||
querySelector: () => body,
|
||||
};
|
||||
assert.equal(displayPanelScroller(panel), body);
|
||||
panel.ownerDocument.documentElement.dataset.uiTheme = 'tactical';
|
||||
assert.equal(displayPanelScroller(panel), panel);
|
||||
assert.equal(displayPanelScroller(null), null);
|
||||
});
|
||||
|
||||
test('Cyber restores one expanded owner, keeps launchers, and restores other theme preferences', () => {
|
||||
for (const mobile of [false, true]) {
|
||||
const f = fixture('right', {
|
||||
mobile,
|
||||
hud: { visible: true, variant: 'cyber' },
|
||||
});
|
||||
f.expand(f.first, 250);
|
||||
f.expand(f.second, 250);
|
||||
f.options.preferredPanelId = f.second.id;
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('cyber-accordion-collapsed'), true);
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
assert.equal(f.first.getAttribute('aria-hidden'), undefined);
|
||||
f.run();
|
||||
assert.equal(
|
||||
f.first.classList.contains('collapsed'),
|
||||
true,
|
||||
'layout does not reopen a peer',
|
||||
);
|
||||
f.options.hud.variant = 'operator';
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('collapsed'), false);
|
||||
assert.equal(
|
||||
f.first.classList.contains('cyber-accordion-collapsed'),
|
||||
false,
|
||||
);
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('Cyber restored accordion prefers keyboard focus when no explicit owner exists', () => {
|
||||
const f = fixture('right', { hud: { visible: true, variant: 'cyber' } });
|
||||
f.expand(f.first, 250);
|
||||
f.expand(f.second, 250);
|
||||
f.options.documentRef.activeElement = f.second;
|
||||
f.run();
|
||||
assert.equal(f.first.classList.contains('collapsed'), true);
|
||||
assert.equal(f.second.classList.contains('collapsed'), false);
|
||||
});
|
||||
|
||||
function element(
|
||||
id,
|
||||
{ height = 42, top = 234, left = 20, width = 272, collapsed = false } = {},
|
||||
|
||||
+14
-4
@@ -59,7 +59,10 @@ export class RadioControls {
|
||||
*/
|
||||
async _revealRadioControlsAfterExplicitEnable(trigger) {
|
||||
const contextPanel = document.getElementById('global-context-panel');
|
||||
const scroller = contextPanel?.querySelector('.global-context-panel-inner');
|
||||
const scroller =
|
||||
(document.documentElement?.dataset.uiTheme === 'cyber' &&
|
||||
contextPanel?.querySelector('.cyber-panel-body')) ||
|
||||
contextPanel?.querySelector('.global-context-panel-inner');
|
||||
const directory = this._radioPanel?.querySelector('.radio-directory-row');
|
||||
const transport = this._radioPanel?.querySelector('.radio-transport');
|
||||
if (
|
||||
@@ -113,7 +116,10 @@ export class RadioControls {
|
||||
*/
|
||||
async _revealRadioPanelInsideContext({ focusTarget = null } = {}) {
|
||||
const contextPanel = document.getElementById('global-context-panel');
|
||||
const scroller = contextPanel?.querySelector('.global-context-panel-inner');
|
||||
const scroller =
|
||||
(document.documentElement?.dataset.uiTheme === 'cyber' &&
|
||||
contextPanel?.querySelector('.cyber-panel-body')) ||
|
||||
contextPanel?.querySelector('.global-context-panel-inner');
|
||||
if (
|
||||
!contextPanel ||
|
||||
contextPanel.classList.contains('collapsed') ||
|
||||
@@ -154,10 +160,12 @@ export class RadioControls {
|
||||
return moved;
|
||||
}
|
||||
|
||||
/** Keep the Context header Radio shortcut truthful for its current route. */
|
||||
/** Keep the launcher truthful for the active theme and Context route. */
|
||||
_syncContextRadioLauncherState() {
|
||||
if (this.destroyed || !this._contextRadioToggleBtn) return;
|
||||
const contextPanel = document.getElementById('global-context-panel');
|
||||
const contextPanel = globalThis.document?.getElementById(
|
||||
'global-context-panel',
|
||||
);
|
||||
const contextExpanded = Boolean(
|
||||
contextPanel && !contextPanel.classList.contains('collapsed'),
|
||||
);
|
||||
@@ -177,6 +185,8 @@ export class RadioControls {
|
||||
this._contextRadioToggleBtn.title = label;
|
||||
return;
|
||||
}
|
||||
// This launcher toggles the compact player even when the detailed panel
|
||||
// is open elsewhere. Its ARIA state must describe that same disclosure.
|
||||
const compactOpen = Boolean(
|
||||
this._contextRadioDock?.classList.contains('disclosure-open'),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Keep Radio in Context in every theme, reusing the same playback controls. */
|
||||
export function syncRadioPanelPlacement(doc) {
|
||||
if (!doc?.getElementById) return;
|
||||
const panel = doc.getElementById('radio-panel');
|
||||
const context = doc.getElementById('global-context-panel');
|
||||
const dock = doc.getElementById('context-radio-dock');
|
||||
const mini = doc.getElementById('context-radio-mini');
|
||||
const parent =
|
||||
context?.querySelector('.cyber-panel-body') ||
|
||||
context?.querySelector('.global-context-panel-inner');
|
||||
const header = context?.querySelector('.panel-header');
|
||||
if (!panel || !parent || !header || !dock || !mini) return;
|
||||
if (panel.parentElement !== parent) parent.append(panel);
|
||||
if (dock.parentElement !== header)
|
||||
header.insertBefore(dock, header.querySelector('.panel-collapse-btn'));
|
||||
if (mini.parentElement !== dock) dock.append(mini);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { syncRadioPanelPlacement } from './radioPanelPlacement.js';
|
||||
|
||||
function node() {
|
||||
return {
|
||||
children: [],
|
||||
selectors: {},
|
||||
parentElement: null,
|
||||
moves: 0,
|
||||
querySelector(selector) {
|
||||
return this.selectors[selector] || null;
|
||||
},
|
||||
append(child) {
|
||||
this.insertBefore(child, null);
|
||||
},
|
||||
insertBefore(child, before) {
|
||||
if (child.parentElement) {
|
||||
const siblings = child.parentElement.children;
|
||||
siblings.splice(siblings.indexOf(child), 1);
|
||||
}
|
||||
const index = before
|
||||
? this.children.indexOf(before)
|
||||
: this.children.length;
|
||||
this.children.splice(index, 0, child);
|
||||
child.parentElement = this;
|
||||
child.moves++;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('all themes keep the same Radio controls nested in Context', () => {
|
||||
const panel = node(),
|
||||
context = node(),
|
||||
rail = node(),
|
||||
dock = node(),
|
||||
mini = node();
|
||||
const inner = node(),
|
||||
contextHeader = node(),
|
||||
radioHeader = node();
|
||||
const contextCollapse = node(),
|
||||
radioCollapse = node();
|
||||
context.selectors['.global-context-panel-inner'] = inner;
|
||||
const body = node();
|
||||
context.selectors['.cyber-panel-body'] = body;
|
||||
context.selectors['.panel-header'] = contextHeader;
|
||||
panel.selectors['.panel-header'] = radioHeader;
|
||||
contextHeader.selectors['.panel-collapse-btn'] = contextCollapse;
|
||||
radioHeader.selectors['.panel-collapse-btn'] = radioCollapse;
|
||||
inner.append(panel);
|
||||
contextHeader.append(dock);
|
||||
contextHeader.append(contextCollapse);
|
||||
radioHeader.append(radioCollapse);
|
||||
dock.append(mini);
|
||||
const nodes = {
|
||||
'radio-panel': panel,
|
||||
'global-context-panel': context,
|
||||
'right-context-rail': rail,
|
||||
'context-radio-dock': dock,
|
||||
'context-radio-mini': mini,
|
||||
};
|
||||
const doc = {
|
||||
documentElement: { dataset: {} },
|
||||
getElementById: (id) => nodes[id],
|
||||
};
|
||||
for (let cycle = 0; cycle < 3; cycle++) {
|
||||
doc.documentElement.dataset.uiTheme = 'cyber';
|
||||
syncRadioPanelPlacement(doc);
|
||||
assert.equal(panel.parentElement, body);
|
||||
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
|
||||
assert.equal(mini.parentElement, dock);
|
||||
const moves = [panel.moves, dock.moves, mini.moves];
|
||||
syncRadioPanelPlacement(doc);
|
||||
assert.deepEqual(
|
||||
[panel.moves, dock.moves, mini.moves],
|
||||
moves,
|
||||
'no mutation feedback loop',
|
||||
);
|
||||
delete doc.documentElement.dataset.uiTheme;
|
||||
syncRadioPanelPlacement(doc);
|
||||
assert.equal(panel.parentElement, body);
|
||||
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
|
||||
assert.equal(mini.parentElement, dock);
|
||||
}
|
||||
// Recover a hierarchy left by the previous Cyber implementation without
|
||||
// recreating the player, changing playback, or mutating collapse state.
|
||||
rail.append(panel);
|
||||
radioHeader.insertBefore(dock, radioCollapse);
|
||||
panel.append(mini);
|
||||
doc.documentElement.dataset.uiTheme = 'cyber';
|
||||
syncRadioPanelPlacement(doc);
|
||||
assert.equal(panel.parentElement, body);
|
||||
assert.deepEqual(contextHeader.children, [dock, contextCollapse]);
|
||||
assert.equal(mini.parentElement, dock);
|
||||
});
|
||||
|
||||
test('placement is inert before the complete shell exists', () => {
|
||||
assert.doesNotThrow(() => syncRadioPanelPlacement(null));
|
||||
assert.doesNotThrow(() =>
|
||||
syncRadioPanelPlacement({
|
||||
documentElement: { dataset: { uiTheme: 'cyber' } },
|
||||
getElementById: () => null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
shouldHideCollapsedRightPanels,
|
||||
} from './panelRailGeometry.js';
|
||||
|
||||
import { displayPanelScroller } from './displayPanelScroll.js';
|
||||
|
||||
const pendingCollapseRetries = new WeakSet();
|
||||
|
||||
/**
|
||||
@@ -56,6 +58,28 @@ export function layoutRightPanelRail({
|
||||
onCollapse(panel);
|
||||
}
|
||||
}
|
||||
// Normalize restored state and theme entry without overwriting saved panel
|
||||
// preferences. This marker is distinct from Tactical's space-based collapse.
|
||||
if (hud.variant === 'cyber') {
|
||||
const expanded = panels.filter(
|
||||
(panel) => !panel.classList.contains('collapsed'),
|
||||
);
|
||||
const owner =
|
||||
expanded.find((panel) => panel.id === preferredPanelId) ||
|
||||
expanded.find((panel) => panel.contains(documentRef?.activeElement)) ||
|
||||
expanded[0];
|
||||
for (const panel of expanded) {
|
||||
if (panel === owner) continue;
|
||||
panel.classList.add('collapsed', 'cyber-accordion-collapsed');
|
||||
onCollapse(panel);
|
||||
}
|
||||
} else {
|
||||
for (const panel of panels) {
|
||||
if (!panel.classList.contains('cyber-accordion-collapsed')) continue;
|
||||
panel.classList.remove('collapsed', 'cyber-accordion-collapsed');
|
||||
onCollapse(panel);
|
||||
}
|
||||
}
|
||||
const isMobile = windowRef.matchMedia('(max-width: 720px)').matches;
|
||||
const hasExpandedPanel = panels.some(
|
||||
(panel) =>
|
||||
@@ -276,10 +300,11 @@ export function layoutRightPanelRail({
|
||||
stack.dataset.expandedCount = String(expandedPanels.length);
|
||||
|
||||
if (displayPanel && expandedPanels.includes(displayPanel)) {
|
||||
const scroller = displayPanelScroller(displayPanel);
|
||||
const maxScrollTop = Math.max(
|
||||
0,
|
||||
displayPanel.scrollHeight - displayPanel.clientHeight,
|
||||
scroller.scrollHeight - scroller.clientHeight,
|
||||
);
|
||||
displayPanel.scrollTop = Math.min(displayScrollTop, maxScrollTop);
|
||||
scroller.scrollTop = Math.min(displayScrollTop, maxScrollTop);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,21 @@ export function readShellElements(document = globalThis.document) {
|
||||
_hudBtn: document.getElementById('hud-toggle'),
|
||||
_hudLayoutRow: document.getElementById('hud-layout-row'),
|
||||
_hudLayoutSelect: document.getElementById('hud-layout-select'),
|
||||
_cyberSonarBtn: document.getElementById('cyber-sonar-toggle'),
|
||||
_cyberSonarRings: document.getElementById('cyber-sonar-rings'),
|
||||
_cyberSonarRingsValue: document.getElementById('cyber-sonar-rings-value'),
|
||||
_cyberSonarRange: document.getElementById('cyber-sonar-range'),
|
||||
_cyberSonarRangeValue: document.getElementById('cyber-sonar-range-value'),
|
||||
_cyberSonarIntensity: document.getElementById('cyber-sonar-intensity'),
|
||||
_cyberSonarIntensityValue: document.getElementById(
|
||||
'cyber-sonar-intensity-value',
|
||||
),
|
||||
_cyberSonarOpacity: document.getElementById('cyber-sonar-opacity'),
|
||||
_cyberSonarOpacityValue: document.getElementById(
|
||||
'cyber-sonar-opacity-value',
|
||||
),
|
||||
_cyberSonarSector: document.getElementById('cyber-sonar-sector'),
|
||||
_cyberSonarSectorValue: document.getElementById('cyber-sonar-sector-value'),
|
||||
_detectionSliderRow: document.getElementById('detection-slider-row'),
|
||||
_detectionDensitySlider: document.getElementById(
|
||||
'detection-density-slider',
|
||||
|
||||
@@ -762,6 +762,14 @@ export class ShellFacade {
|
||||
return this._visualSettings._setHudVariant(...arguments);
|
||||
}
|
||||
|
||||
_setCyberSonarEnabled(enabled) {
|
||||
return this._visualSettings._setCyberSonarEnabled(...arguments);
|
||||
}
|
||||
|
||||
_setCyberSonarSetting(name, value) {
|
||||
return this._visualSettings._setCyberSonarSetting(...arguments);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps both responsive panel lanes and Cockpit's utility strip on the same
|
||||
* measured layout commit. HUD visibility transitions can outlive the first
|
||||
|
||||
@@ -362,11 +362,20 @@
|
||||
}
|
||||
|
||||
#pp-toggles:not(.collapsed) > .pp-header-row,
|
||||
#pp-toggles:not(.collapsed) > .pp-toggle-group,
|
||||
#pp-toggles:not(.collapsed) > #clean-view-toggle {
|
||||
#pp-toggles:not(.collapsed) > .pp-panel-body > .pp-toggle-group,
|
||||
#pp-toggles:not(.collapsed) > .pp-panel-body > #clean-view-toggle {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pp-panel-body,
|
||||
.cyber-panel-body {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.panel-collapsible.collapsed > .global-context-panel-inner > .cyber-panel-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pp-toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -683,6 +692,28 @@
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.cyber-sonar-control {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root[data-ui-theme='cyber'] .cyber-sonar-control {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cyber-sonar-toggle-row,
|
||||
.cyber-sonar-setting {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.cyber-sonar-toggle-row .pp-mode-btn {
|
||||
margin-left: auto;
|
||||
min-width: 48px;
|
||||
}
|
||||
|
||||
:root[data-cyber-sonar='off'] .cyber-sonar-setting {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Map Source tiles live with Visual Presets in the bottom command tray. */
|
||||
.map-source-section {
|
||||
display: grid;
|
||||
@@ -1055,7 +1086,7 @@ body.ui-clean-view #scene-runtime.active {
|
||||
/* Display owns the constrained desktop scroll surface. Keeping Parameters as
|
||||
* a second scroll container traps wheel/touch input at its boundary and lets
|
||||
* the flex column compress newly added preset rows below the visible panel. */
|
||||
#pp-toggles:not(.collapsed) > #param-slider-panel.active {
|
||||
#pp-toggles:not(.collapsed) > .pp-panel-body > #param-slider-panel.active {
|
||||
flex: 0 0 auto;
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<span class="panel-divider" aria-hidden="true"></span>
|
||||
<button class="panel-collapse-btn pp-collapse-btn" data-collapse-target="pp-toggles" title="Collapse panel">◀</button>
|
||||
</div>
|
||||
<div class="pp-panel-body" data-rail-scroller>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)">
|
||||
<span class="pp-icon">◎</span>
|
||||
@@ -16,8 +17,39 @@
|
||||
<option value="tactical">Tactical</option>
|
||||
<option value="operator">Operator</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<option value="cyber">Cyber</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-toggle-row" id="cyber-sonar-row">
|
||||
<span class="pp-slider-mini-label">Sonar</span>
|
||||
<button class="pp-mode-btn active" id="cyber-sonar-toggle" title="Cyber sonar contact scanner" aria-label="Cyber sonar contact scanner" aria-pressed="true">ON</button>
|
||||
</div>
|
||||
<output id="cyber-sonar-status" class="cyber-sonar-control" role="status"></output>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-rings-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-rings">Rings</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-rings" min="3" max="12" step="1" value="8" aria-label="Sonar ring count" />
|
||||
<span class="pp-slider-value" id="cyber-sonar-rings-value">8</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-range-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-range">Range</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-range" min="60" max="120" step="1" value="100" aria-label="Sonar ring range" />
|
||||
<span class="pp-slider-value" id="cyber-sonar-range-value">100%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-intensity-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-intensity">Power</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-intensity" min="0" max="100" step="1" value="70" aria-label="Sonar intensity" />
|
||||
<span class="pp-slider-value" id="cyber-sonar-intensity-value">70%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-opacity-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-opacity">Opacity</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-opacity" min="35" max="100" step="1" value="84" aria-label="Cyber contact and label opacity" title="Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes" />
|
||||
<span class="pp-slider-value" id="cyber-sonar-opacity-value">84%</span>
|
||||
</div>
|
||||
<div class="pp-slider-row cyber-sonar-control cyber-sonar-setting" id="cyber-sonar-sector-row">
|
||||
<label class="pp-slider-mini-label" for="cyber-sonar-sector">Sector</label>
|
||||
<input type="range" class="pp-slider" id="cyber-sonar-sector" min="8" max="60" step="1" value="24" aria-label="Sonar sector width" />
|
||||
<span class="pp-slider-value" id="cyber-sonar-sector-value">24°</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pp-toggle-group">
|
||||
<button class="pp-toggle-btn" id="detection-toggle" title="Detection Overlay (D)" aria-label="Detection overlay" aria-pressed="false">
|
||||
@@ -146,4 +178,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="clean-view-exit" title="Return UI controls">EXIT CLEAN VIEW</button>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user