From 513dc87a0e929658a4a2d99799f48492f6c62742 Mon Sep 17 00:00:00 2001 From: manjunath22466 Date: Thu, 24 Sep 2026 09:01:56 +0530 Subject: [PATCH] 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 Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 11 + docs/CURRENT-STATE.md | 39 +- scripts/package-boundaries.json | 31 +- scripts/qa-cockpit-utility.mjs | 92 + server/providers/openai/instructions.js | 3 +- server/providers/openai/toolDescriptions.js | 23 + src/app/data.js | 2 + src/cockpitMarkup.test.mjs | 6 +- src/cockpitUtilityLayout.js | 45 +- src/cockpitUtilityLayout.test.mjs | 79 +- src/cyberSonar.js | 562 ++++ src/cyberSonar.test.mjs | 370 +++ src/cyberSonarGpu.js | 295 ++ src/cyberSonarGpu.test.mjs | 354 +++ src/cyberSonarScene.js | 211 ++ src/cyberSonarScene.test.mjs | 314 +++ src/data/detection.js | 67 +- src/data/detectionPolicy.js | 4 + src/data/detectionPolicy.test.mjs | 14 + src/data/detectionRenderDemand.test.mjs | 10 +- src/data/detectionSonarAdmission.test.mjs | 47 + src/data/rocketLaunches.test.mjs | 4 + src/firstRunExperience.test.mjs | 7 +- src/hud.js | 20 +- src/hudLayoutPolicy.js | 7 + src/hudLayouts.js | 64 + src/keySetup.js | 32 +- src/keySetup.test.mjs | 35 + src/layers/awareness/rendering.js | 16 + src/layers/flights/rendering.js | 8 +- src/layers/flights/snapshotRenderer.js | 3 +- src/layers/flights/state.js | 2 + src/layers/launches/overlays.js | 24 + src/layers/launches/panel.js | 2 +- src/layers/launches/panelScroll.test.mjs | 34 + src/layers/military/rendering.js | 8 +- src/layers/military/state.js | 2 + src/overlays/worldOverlay.js | 31 +- src/panelStackLayout.test.mjs | 8 +- src/radioMarkup.test.mjs | 20 +- src/ui/applicationShell.js | 36 +- src/ui/cameraOrientationControls.js | 1 + src/ui/cameraOrientationControls.test.mjs | 27 + src/ui/cockpitDisplayPortal.js | 17 +- src/ui/cockpitLayout.js | 141 +- src/ui/cyberPanelDisclosure.test.mjs | 87 + src/ui/cyberSonarControls.js | 86 + src/ui/cyberSonarControls.test.mjs | 161 ++ src/ui/cyberTheme.test.mjs | 573 ++++ src/ui/displayBindings.js | 16 +- src/ui/displayControls.js | 9 + src/ui/displayControls.test.mjs | 58 + src/ui/displayPanelScroll.js | 6 + src/ui/layerPanel.js | 1 + src/ui/panelChrome.js | 63 +- src/ui/panelLayoutController.js | 3 + src/ui/panelRails.test.mjs | 53 + src/ui/radioControls.js | 18 +- src/ui/radioPanelPlacement.js | 17 + src/ui/radioPanelPlacement.test.mjs | 105 + src/ui/rightPanelRail.js | 29 +- src/ui/shellElements.js | 15 + src/ui/shellFacade.js | 8 + src/ui/styles/controls.css | 37 +- src/ui/styles/cyber.css | 2714 +++++++++++++++++++ src/ui/templates/cockpit.html | 44 + src/ui/templates/context.html | 2 + src/ui/templates/display-controls.html | 33 + src/ui/templates/layer-panels.html | 2 + src/ui/visualSettings.js | 91 +- src/voice/actionSchemas.js | 19 +- src/voice/actionSchemas.test.mjs | 20 +- src/voice/gevActions.js | 11 + style.css | 1 + 74 files changed, 7320 insertions(+), 90 deletions(-) create mode 100644 src/cyberSonar.js create mode 100644 src/cyberSonar.test.mjs create mode 100644 src/cyberSonarGpu.js create mode 100644 src/cyberSonarGpu.test.mjs create mode 100644 src/cyberSonarScene.js create mode 100644 src/cyberSonarScene.test.mjs create mode 100644 src/data/detectionSonarAdmission.test.mjs create mode 100644 src/hudLayoutPolicy.js create mode 100644 src/hudLayouts.js create mode 100644 src/layers/launches/panelScroll.test.mjs create mode 100644 src/ui/cyberPanelDisclosure.test.mjs create mode 100644 src/ui/cyberSonarControls.js create mode 100644 src/ui/cyberSonarControls.test.mjs create mode 100644 src/ui/cyberTheme.test.mjs create mode 100644 src/ui/displayPanelScroll.js create mode 100644 src/ui/radioPanelPlacement.js create mode 100644 src/ui/radioPanelPlacement.test.mjs create mode 100644 src/ui/styles/cyber.css diff --git a/CHANGELOG.md b/CHANGELOG.md index ccfdccb2c..dfe29059d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 361c6dee5..ac87e8f3f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 14e10f898..0184b4684 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/scripts/qa-cockpit-utility.mjs b/scripts/qa-cockpit-utility.mjs index 9213dcaa5..607889bbe 100644 --- a/scripts/qa-cockpit-utility.mjs +++ b/scripts/qa-cockpit-utility.mjs @@ -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; diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index 52c8c0b95..fa957ac7c 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -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 " (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.', diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index 82f747171..48177dde5 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -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.', diff --git a/src/app/data.js b/src/app/data.js index 254c825c5..85a3183fc 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -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 }; } diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 7843946aa..6aa614520 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -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', () => { diff --git a/src/cockpitUtilityLayout.js b/src/cockpitUtilityLayout.js index 0d6c09327..1f7bcf8e8 100644 --- a/src/cockpitUtilityLayout.js +++ b/src/cockpitUtilityLayout.js @@ -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, + }; +} diff --git a/src/cockpitUtilityLayout.test.mjs b/src/cockpitUtilityLayout.test.mjs index 2494b3a11..b1998c2f1 100644 --- a/src/cockpitUtilityLayout.test.mjs +++ b/src/cockpitUtilityLayout.test.mjs @@ -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, }); }); - diff --git a/src/cyberSonar.js b/src/cyberSonar.js new file mode 100644 index 000000000..3e0d1c453 --- /dev/null +++ b/src/cyberSonar.js @@ -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), + ); +} diff --git a/src/cyberSonar.test.mjs b/src/cyberSonar.test.mjs new file mode 100644 index 000000000..50d0338ca --- /dev/null +++ b/src/cyberSonar.test.mjs @@ -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); +}); diff --git a/src/cyberSonarGpu.js b/src/cyberSonarGpu.js new file mode 100644 index 000000000..bb4aff4d6 --- /dev/null +++ b/src/cyberSonarGpu.js @@ -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(); + }, + }; +} diff --git a/src/cyberSonarGpu.test.mjs b/src/cyberSonarGpu.test.mjs new file mode 100644 index 000000000..8507620ba --- /dev/null +++ b/src/cyberSonarGpu.test.mjs @@ -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); +}); diff --git a/src/cyberSonarScene.js b/src/cyberSonarScene.js new file mode 100644 index 000000000..f07159cbf --- /dev/null +++ b/src/cyberSonarScene.js @@ -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); + }; +} diff --git a/src/cyberSonarScene.test.mjs b/src/cyberSonarScene.test.mjs new file mode 100644 index 000000000..7d0db8551 --- /dev/null +++ b/src/cyberSonarScene.test.mjs @@ -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; + } + }); +} diff --git a/src/data/detection.js b/src/data/detection.js index 83d7949b5..5c5e90396 100644 --- a/src/data/detection.js +++ b/src/data/detection.js @@ -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; diff --git a/src/data/detectionPolicy.js b/src/data/detectionPolicy.js index 2ad55dcfc..bf47785fd 100644 --- a/src/data/detectionPolicy.js +++ b/src/data/detectionPolicy.js @@ -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); } diff --git a/src/data/detectionPolicy.test.mjs b/src/data/detectionPolicy.test.mjs index afddb8bd1..a596be7c4 100644 --- a/src/data/detectionPolicy.test.mjs +++ b/src/data/detectionPolicy.test.mjs @@ -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 diff --git a/src/data/detectionRenderDemand.test.mjs b/src/data/detectionRenderDemand.test.mjs index c140bdd22..e104684cc 100644 --- a/src/data/detectionRenderDemand.test.mjs +++ b/src/data/detectionRenderDemand.test.mjs @@ -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 diff --git a/src/data/detectionSonarAdmission.test.mjs b/src/data/detectionSonarAdmission.test.mjs new file mode 100644 index 000000000..cecf34846 --- /dev/null +++ b/src/data/detectionSonarAdmission.test.mjs @@ -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); + } + } + } + } +}); diff --git a/src/data/rocketLaunches.test.mjs b/src/data/rocketLaunches.test.mjs index 07b7666f6..a1a3fab44 100644 --- a/src/data/rocketLaunches.test.mjs +++ b/src/data/rocketLaunches.test.mjs @@ -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; } } diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 538a006dd..347b8c9ef 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -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'); diff --git a/src/hud.js b/src/hud.js index 236df7013..eb8c0ab34 100644 --- a/src/hud.js +++ b/src/hud.js @@ -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 = ` + +
TOP SECRET // SI-TK // NOFORN ${this._missionId} @@ -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); } /** diff --git a/src/hudLayoutPolicy.js b/src/hudLayoutPolicy.js new file mode 100644 index 000000000..6b3e2287b --- /dev/null +++ b/src/hudLayoutPolicy.js @@ -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', +]); diff --git a/src/hudLayouts.js b/src/hudLayouts.js new file mode 100644 index 000000000..d232ca427 --- /dev/null +++ b/src/hudLayouts.js @@ -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 so theme tokens are available to every UI surface. + * @param {HTMLElement|{dataset?: Record}|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; +} diff --git a/src/keySetup.js b/src/keySetup.js index d94dac58f..ee1d3b2dd 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -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; diff --git a/src/keySetup.test.mjs b/src/keySetup.test.mjs index 2918e6f5c..c0d9520c0 100644 --- a/src/keySetup.test.mjs +++ b/src/keySetup.test.mjs @@ -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'); diff --git a/src/layers/awareness/rendering.js b/src/layers/awareness/rendering.js index 8ef9b2afe..471c11b51 100644 --- a/src/layers/awareness/rendering.js +++ b/src/layers/awareness/rendering.js @@ -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); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index 222dd0888..44e1b02c3 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -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: diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index 626488a90..636e691fd 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -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 || diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index cb870a3b3..2978ceba4 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -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(); diff --git a/src/layers/launches/overlays.js b/src/layers/launches/overlays.js index 600b5c781..682007e84 100644 --- a/src/layers/launches/overlays.js +++ b/src/layers/launches/overlays.js @@ -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 }) {
`; 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( diff --git a/src/layers/launches/panel.js b/src/layers/launches/panel.js index 1555b5a66..c97077d42 100644 --- a/src/layers/launches/panel.js +++ b/src/layers/launches/panel.js @@ -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; } diff --git a/src/layers/launches/panelScroll.test.mjs b/src/layers/launches/panelScroll.test.mjs new file mode 100644 index 000000000..b43e634e1 --- /dev/null +++ b/src/layers/launches/panelScroll.test.mjs @@ -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); + } +}); diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index 6af91c792..9ebd03180 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -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, diff --git a/src/layers/military/state.js b/src/layers/military/state.js index 38ac280b4..f1a2d691f 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -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(); diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index e2e8375a4..fd1248f0c 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -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. diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index 5a899ebc8..2a472e246 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -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\(\);/, diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 06a290112..5b161ba0e 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -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/); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 7477b2280..ec51ac1ab 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -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: { diff --git a/src/ui/cameraOrientationControls.js b/src/ui/cameraOrientationControls.js index a76d8cf7e..887f59d0b 100644 --- a/src/ui/cameraOrientationControls.js +++ b/src/ui/cameraOrientationControls.js @@ -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', diff --git a/src/ui/cameraOrientationControls.test.mjs b/src/ui/cameraOrientationControls.test.mjs index 264c68deb..accd003f8 100644 --- a/src/ui/cameraOrientationControls.test.mjs +++ b/src/ui/cameraOrientationControls.test.mjs @@ -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); diff --git a/src/ui/cockpitDisplayPortal.js b/src/ui/cockpitDisplayPortal.js index 8c653c6f6..1012c087d 100644 --- a/src/ui/cockpitDisplayPortal.js +++ b/src/ui/cockpitDisplayPortal.js @@ -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; } diff --git a/src/ui/cockpitLayout.js b/src/ui/cockpitLayout.js index cb6af30e1..0122a4b6e 100644 --- a/src/ui/cockpitLayout.js +++ b/src/ui/cockpitLayout.js @@ -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( diff --git a/src/ui/cyberPanelDisclosure.test.mjs b/src/ui/cyberPanelDisclosure.test.mjs new file mode 100644 index 000000000..ae0256d4c --- /dev/null +++ b/src/ui/cyberPanelDisclosure.test.mjs @@ -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; } + } +}); diff --git a/src/ui/cyberSonarControls.js b/src/ui/cyberSonarControls.js new file mode 100644 index 000000000..4e16028c6 --- /dev/null +++ b/src/ui/cyberSonarControls.js @@ -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) }; +} diff --git a/src/ui/cyberSonarControls.test.mjs b/src/ui/cyberSonarControls.test.mjs new file mode 100644 index 000000000..b819d6f3d --- /dev/null +++ b/src/ui/cyberSonarControls.test.mjs @@ -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); + } +}); diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs new file mode 100644 index 000000000..033d0053e --- /dev/null +++ b/src/ui/cyberTheme.test.mjs @@ -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, /