From c03b4a76436a715402daaf3adf90a6eab72027ba Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 31 Oct 2026 22:18:50 -0700 Subject: [PATCH] feat(hud): render the Intel HUD from a store IntelHUD no longer builds #intel-hud with innerHTML or writes its readouts by id: it publishes its readouts, typing summary, REC blink, visibility, layout variant and style colors through its publish option to the gev-hud store, and the intel-hud application view renders them. The corners render from mount so the panel layout controllers register them as obstacles. The vessel layer presents the selected vessel's readout through src/hudReadouts.js, which IntelHUD follows, instead of writing into the HUD. The Cyber sonar state answers a HUD toggle from IntelHUD's visibility rather than the class the view renders on its next update. --- CHANGELOG.md | 9 ++ docs/APPLICATION.md | 6 +- docs/VUE-MIGRATION.md | 8 +- index.html | 2 +- scripts/format-scope.json | 6 +- scripts/package-boundaries.json | 14 +- src/app/layers/aisLiveVessels.js | 2 + src/cyberSonar.js | 12 +- src/data/aisLiveVessels.test.mjs | 35 ++--- src/hud.js | 222 +++++++++++---------------- src/hudAltitudeDatum.test.mjs | 42 ++--- src/hudDms.test.mjs | 17 +- src/hudPageVisibility.test.mjs | 15 +- src/hudReadouts.js | 29 ++++ src/hudSummaryGate.test.mjs | 21 +-- src/layers/vessels/cards.js | 29 ++-- src/testSupport/hudReadouts.mjs | 35 +++++ src/tooling/applicationHtml.test.mjs | 2 +- src/ui/applicationShell.js | 7 +- src/ui/cyberSonarControls.js | 20 ++- src/ui/cyberSonarControls.test.mjs | 16 ++ src/ui/cyberTheme.test.mjs | 3 +- src/ui/stores/hud.js | 19 +++ src/ui/views/IntelHudView.vue | 63 ++++++++ src/ui/views/index.js | 2 + src/ui/views/intelHudView.test.mjs | 163 ++++++++++++++++++++ 26 files changed, 556 insertions(+), 243 deletions(-) create mode 100644 src/hudReadouts.js create mode 100644 src/testSupport/hudReadouts.mjs create mode 100644 src/ui/stores/hud.js create mode 100644 src/ui/views/IntelHudView.vue create mode 100644 src/ui/views/intelHudView.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e8b3915d..905de92a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,15 @@ `showOrbit(active)`, which adds the ORBIT indicator to the presented tray as `orbit: { active }` (null until offered) (samehkhamis). +- `IntelHUD` (`./ui/hud`) no longer builds or writes `#intel-hud`: it hands + its readouts, visibility, layout variant and style colors to its `publish` + option, and Core's `intel-hud` view renders them. A page places that view + with `
` instead of a bare + `#intel-hud` element. `./layers/vessels` shows the selected vessel's HUD + readout through `services.readouts.presentSelectedVessel()` instead of + writing `#hud-ais-vessel`; the application passes `src/hudReadouts.js`, + which IntelHUD follows (samehkhamis). + ## [0.3.0] — 2026-10-27 — Street Level, On Your Phone, in Your Language - Show a Street Level view facing 359.5° or more as 0°. The viewer caption diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index 2ca681150..8848601d1 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -213,8 +213,10 @@ indicator, the FPS readout, the Scene actions panel, the data-pack attribution cards, the wind inspection marker, the Space Missions replay vehicle, the full-globe celestial ring, the screen-space map annotations, the visible map target list and the Global Context compass; -leaving out `command-dock` the voice control and its pointer reticle; and -leaving out `context` the Bhote Koshi reconstruction panel. An unknown +leaving out `command-dock` the voice control and its pointer reticle; +leaving out `context` the Bhote Koshi reconstruction panel; and leaving out +`intel-hud` the Intel HUD overlay (`#intel-hud`), whose readouts the HUD +implementation publishes to the `gev-hud` store. An unknown view name is an error. A host that does not initialize Core's locale passes `localePicker: false` to `createApplicationControls` instead of editing the markup. Node tests that import application modules register the component diff --git a/docs/VUE-MIGRATION.md b/docs/VUE-MIGRATION.md index c442246d7..8965938f1 100644 --- a/docs/VUE-MIGRATION.md +++ b/docs/VUE-MIGRATION.md @@ -194,7 +194,13 @@ a feature supplying only its accent. the globe container what the awareness layer presents through `subscribeDirectionOverlay()`, which the application shell publishes to the `gev-awareness-direction` store, so the portable `./layers/awareness` - entry needs no Vue), then + entry needs no Vue), the Intel HUD (the `intel-hud` view renders + `#intel-hud`, its corners, edges and bottom bar from the `gev-hud` + store, which IntelHUD publishes to through its `publish` option: the + readouts, the typing summary, the REC blink, visibility, layout variant + and style colors; the vessel layer presents the selected vessel's + readout through `src/hudReadouts.js` instead of writing into the HUD), + then Cockpit (in progress: `CockpitBriefing` renders the briefing card's pages, cycle toggle, regional news and local conditions, and `CockpitContext` the Contact card's scope, navigation, counts, nearest diff --git a/index.html b/index.html index 9916a9f41..4d734574a 100644 --- a/index.html +++ b/index.html @@ -38,7 +38,7 @@
-
+
diff --git a/scripts/format-scope.json b/scripts/format-scope.json index f45225c19..501c50540 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1153,5 +1153,9 @@ "src/ui/stores/worldOverlayActions.js", "src/overlays/worldOverlayActionsView.test.mjs", "src/ui/stores/awarenessDirection.js", - "src/layers/awareness/directionOverlay.test.mjs" + "src/layers/awareness/directionOverlay.test.mjs", + "src/hudReadouts.js", + "src/ui/stores/hud.js", + "src/ui/views/intelHudView.test.mjs", + "src/testSupport/hudReadouts.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 23bf720f0..e69cfaa6b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -115,7 +115,9 @@ "src/ui/views/panels/ScreenAnnotations.vue", "src/ui/stores/worldOverlayActions.js", "src/ui/stores/awarenessDirection.js", - "src/ui/views/panels/AwarenessDirection.vue" + "src/ui/views/panels/AwarenessDirection.vue", + "src/ui/stores/hud.js", + "src/ui/views/IntelHudView.vue" ], "external": ["vue", "pinia"] }, @@ -1039,7 +1041,8 @@ "src/ui/stores/missionReplay.js", "src/ui/stores/celestialRing.js", "src/ui/stores/worldOverlayActions.js", - "src/ui/stores/awarenessDirection.js" + "src/ui/stores/awarenessDirection.js", + "src/ui/stores/hud.js" ], "external": ["cesium", "vue", "pinia"] }, @@ -3040,7 +3043,9 @@ "src/ui/stores/celestialRing.js", "src/ui/stores/screenAnnotations.js", "src/ui/stores/worldOverlayActions.js", - "src/ui/stores/awarenessDirection.js" + "src/ui/stores/awarenessDirection.js", + "src/hudReadouts.js", + "src/ui/stores/hud.js" ], "external": [ "hls.js", @@ -3631,7 +3636,8 @@ "src/ui/vueRuntime.js", "src/ui/stores/windInspection.js", "src/layers/wind/inspectionMarker.js", - "src/ui/stores/bhoteKoshiPanel.js" + "src/ui/stores/bhoteKoshiPanel.js", + "src/hudReadouts.js" ], "external": [ "hls.js", diff --git a/src/app/layers/aisLiveVessels.js b/src/app/layers/aisLiveVessels.js index 32dfb8638..cae0bf44c 100644 --- a/src/app/layers/aisLiveVessels.js +++ b/src/app/layers/aisLiveVessels.js @@ -9,6 +9,7 @@ import * as sprites from '../../data/spriteOrder.js'; import * as focus from '../../data/focusDeemphasis.js'; import * as worldFocus from '../../worldFocus.js'; import * as render from '../../renderGovernor.js'; +import * as readouts from '../../hudReadouts.js'; /** Construct one layer using the application scene owners and a supplied source. */ export function createApplicationVessels({ source, options = {} }) { @@ -26,6 +27,7 @@ export function createApplicationVessels({ source, options = {} }) { focus, worldFocus, render, + readouts, }, }); } diff --git a/src/cyberSonar.js b/src/cyberSonar.js index 3e0d1c453..e4148a43c 100644 --- a/src/cyberSonar.js +++ b/src/cyberSonar.js @@ -480,11 +480,16 @@ export function setCyberSonarEnabled( return enabled; } -/** Whether the map-mode sweep should alter flight presentation. */ +/** + * Whether the map-mode sweep should alter flight presentation. `hudActive` + * is the HUD's visibility from its owner, which a caller acting on it in the + * same turn passes; otherwise the rendered `#intel-hud` class answers. + */ export function isCyberSonarActive({ root = globalThis.document?.documentElement, body = globalThis.document?.body, hud, + hudActive, reducedMotion, } = {}) { // Keep optional method calls in the body: production downleveling of nested @@ -498,7 +503,7 @@ export function isCyberSonarActive({ return !!( root?.dataset?.uiTheme === 'cyber' && isCyberSonarEnabled(root) && - hud?.classList?.contains?.('active') && + (hudActive ?? hud?.classList?.contains?.('active')) && !body?.classList?.contains?.('cockpit-mode') && !reducedMotion ); @@ -508,12 +513,13 @@ export function isCyberMapThemeActive({ root = globalThis.document?.documentElement, body = globalThis.document?.body, hud, + hudActive, } = {}) { if (hud === undefined) hud = globalThis.document?.getElementById?.('intel-hud'); return !!( root?.dataset?.uiTheme === 'cyber' && - hud?.classList?.contains?.('active') && + (hudActive ?? hud?.classList?.contains?.('active')) && !body?.classList?.contains?.('cockpit-mode') ); } diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 285ffb08e..050bb0323 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -35,6 +35,10 @@ import { ensureGeoidReady, geoidHeight } from './geoid.js'; import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js'; import { applyVesselOverlayPolicy } from './vesselLabels.js'; import { layerFeedState } from './manager.js'; +import { + presentSelectedVessel, + subscribeSelectedVessel, +} from '../hudReadouts.js'; test('open feed with vessels is healthy (null)', () => { assert.equal(deriveAisFeedError({ status: 'open', lastMessageAt: 1, error: null }, 42), null); @@ -856,15 +860,6 @@ function makeTrailSpy() { }; } -function makeClassList(...initial) { - const classes = new Set(initial); - return { - add(value) { classes.add(value); }, - remove(value) { classes.delete(value); }, - contains(value) { return classes.has(value); }, - }; -} - function makeInteractionHandler() { return { click: null, @@ -934,14 +929,20 @@ function installWireHarness(picked, stateOverrides = {}) { }; const record = makeRecord(); const trail = makeTrailSpy(); + presentSelectedVessel({ text: `AIS: ${record.name}` }); + let vesselReadout; + const stopReadout = subscribeSelectedVessel( + (readout) => (vesselReadout = readout), + ); const hud = { - textContent: `AIS: ${record.name}`, - classList: makeClassList('active'), + get readout() { + return vesselReadout; + }, }; globalThis.window = windowTarget; globalThis.document = { - getElementById: (id) => (id === 'hud-ais-vessel' ? hud : null), + getElementById: () => null, }; _setVesselStateForTest({ records: [record], @@ -966,6 +967,7 @@ function installWireHarness(picked, stateOverrides = {}) { reinstalledHandlers, interactionHandlerFactory, cleanup() { + stopReadout(); aisLiveVesselsLayer.destroy(); if (hadWindow) globalThis.window = priorWindow; else delete globalThis.window; @@ -1091,8 +1093,7 @@ test('vessel interaction wire: id-less 3D Tiles pick deselects and resets the HU harness.handler.click({ position: { x: 10, y: 20 } }); assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null); - assert.equal(harness.hud.textContent, 'AIS: --'); - assert.equal(harness.hud.classList.contains('active'), false); + assert.equal(harness.hud.readout, null); // Clicking away is a deliberate deselect, not the vessel aging out of the // feed. Consumers that keep a readout on screen (the Cockpit Contact // panel) tear down on 'deliberate' and hold last-known on 'evicted'. @@ -1119,8 +1120,7 @@ test('vessel interaction wire: empty pick deselects and emits gev:entity-selecti harness.handler.click({ position: { x: 10, y: 20 } }); assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null); - assert.equal(harness.hud.textContent, 'AIS: --'); - assert.equal(harness.hud.classList.contains('active'), false); + assert.equal(harness.hud.readout, null); // Empty pick is a deliberate deselect — see the note above. assert.deepEqual(cleared, [{ layerId: 'ais-live-vessels', reason: 'deliberate' }]); } finally { @@ -1133,8 +1133,7 @@ test('vessel interaction wire: Escape deselects the selected vessel', () => { try { harness.keyTarget.dispatch({ key: 'Escape' }); assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null); - assert.equal(harness.hud.textContent, 'AIS: --'); - assert.equal(harness.hud.classList.contains('active'), false); + assert.equal(harness.hud.readout, null); } finally { harness.cleanup(); } diff --git a/src/hud.js b/src/hud.js index 6eff65df7..e93cf34d5 100644 --- a/src/hud.js +++ b/src/hud.js @@ -40,6 +40,7 @@ import { import { formatNumber, msg, t } from './i18n/index.js'; import { localizeLayerName } from './ui/layerPanel.js'; import { isEmbeddedInline } from './app/embed.js'; +import { subscribeSelectedVessel } from './hudReadouts.js'; /** Color palettes keyed by shader mode; applied as CSS custom properties. */ const HUD_COLORS = { @@ -179,8 +180,10 @@ export class IntelHUD { summaryPolicy = {}, basemapContext = {}, summaryService = applicationServices.summary, + publish = () => {}, } = {}, ) { + this._publish = publish; this.summaryService = summaryService; this.summaryPolicy = summaryPolicy; this.basemapContext = basemapContext; @@ -189,7 +192,9 @@ export class IntelHUD { this._visible = false; this._autoMode = true; // auto show/hide based on style this._currentStyle = 'normal'; - this._el = null; + /** The presented HUD; see {@link IntelHUD#_initView}. */ + this._view = null; + this._viewSignature = ''; this._variant = DEFAULT_HUD_LAYOUT; this._recBlinkState = true; this._updateInterval = null; @@ -268,79 +273,62 @@ export class IntelHUD { this._orbitNum = 47000 + Math.floor(Math.random() * 1000); this._passNum = 100 + Math.floor(Math.random() * 200); - this._buildDOM(); + this._initView(); + this._vesselUnsubscribe = subscribeSelectedVessel((readout) => + this._present({ + vessel: readout + ? { active: true, text: readout.text } + : { active: false, text: t('AIS: --') }, + }), + ); this.viewer.camera.moveStart.addEventListener(this._onCameraMoveStart); this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd); this._startTimers(); } /** - * Construct the HUD DOM structure inside the existing `#intel-hud` element. - * Populates corner brackets, classification banners, sensor readouts, - * edge metadata strips, and the bottom summary bar. + * Present the HUD's first view: corner brackets, classification banners, + * sensor readouts, edge metadata strips and the bottom summary bar, which + * the Intel HUD view renders in `#intel-hud`. */ - _buildDOM() { - this._el = document.getElementById('intel-hud'); - if (!this._el) return; - + _initView() { // i18n-ignore: sensor codes and a classification banner; the rest uses t() - this._el.innerHTML = ` - + this._view = { + active: false, + variant: this._variant, + colors: null, + classification: 'TOP SECRET // SI-TK // NOFORN', + system: `${this._missionId} ${this._sensorId}`, + mode: t(MODE_LABELS.normal), + summaryLabel: t('SUMMARY'), + summary: t('Awaiting telemetry...'), + rec: t('REC'), + recShown: null, + timestamp: '2026-01-01 00:00:00Z', + orbital: `ORB: ${this._orbitNum} PASS: DESC-${this._passNum}`, + mgrs: 'MGRS: ---', + latlon: `--°--'--"N ---°--'--"W`, + gsd: 'GSD: --m NIIRS: --', + alt: 'ALT: --m SUN: --° EL', + vessel: { active: false, text: t('AIS: --') }, + coll: 'COLL: --:--:--Z', + ona: 'ONA: --°', + sensor: ['BAND: PAN', 'BITS: 11', 'LVL: 1A'], + bottom: 'LAT: -- LON: -- MGRS: ---', + }; + this._present({}); + } -
-
┌
-
-
TOP SECRET // SI-TK // NOFORN
-
${this._missionId} ${this._sensorId}
-
${t(MODE_LABELS.normal)}
-
-
${t('SUMMARY')}
-
${t('Awaiting telemetry...')}
-
-
-
- -
-
-
● ${t('REC')} 2026-01-01 00:00:00Z
-
ORB: ${this._orbitNum} PASS: DESC-${this._passNum}
-
-
┐
-
- -
-
└
-
-
MGRS: ---
-
--°--'--"N ---°--'--"W
-
-
- -
-
-
GSD: --m NIIRS: --
-
ALT: --m SUN: --° EL
-
${t('AIS: --')}
-
-
┘
-
- -
-
COLL: --:--:--Z
-
ONA: --°
-
- -
-
BAND: PAN
-
BITS: 11
-
LVL: 1A
-
- -
- LAT: -- LON: -- MGRS: --- -
- `; - this._el.dataset.variant = this._variant; + /** + * Merge `patch` into the view and publish it when anything changed. + * @param {object} patch + */ + _present(patch) { + Object.assign(this._view, patch); + const signature = JSON.stringify(this._view); + if (signature === this._viewSignature) return; + this._viewSignature = signature; + this._publish(Object.freeze({ ...this._view })); } /** @@ -351,16 +339,13 @@ export class IntelHUD { _startTimers() { // Timestamp — every second this._timestampInterval = setInterval(() => { - const el = document.getElementById('hud-timestamp'); - if (el) el.textContent = this._formatUTC(); + this._present({ timestamp: this._formatUTC() }); }, 1000); // REC blink — every 800ms this._recBlinkInterval = setInterval(() => { this._recBlinkState = !this._recBlinkState; - const dot = document.getElementById('hud-rec-dot'); - if (dot) - dot.style.visibility = this._recBlinkState ? 'visible' : 'hidden'; + this._present({ recShown: this._recBlinkState }); }, 800); // Camera-derived data — 4 updates/second (250ms). Skipped in a hidden @@ -449,23 +434,16 @@ export class IntelHUD { // Format: 18SUJ23370716 → 18S UJ 2337 0716 const formatted = this._formatMGRS(mgrsStr); mgrsLabel = formatted; - const el = document.getElementById('hud-mgrs'); - // i18n-ignore: sensor codes - if (el) el.textContent = `MGRS: ${formatted}`; } catch { - const el = document.getElementById('hud-mgrs'); - // i18n-ignore: sensor codes - if (el) el.textContent = 'MGRS: ---'; + /* the readout keeps its dashes */ } + // i18n-ignore: sensor codes + const readouts = { mgrs: `MGRS: ${mgrsLabel}` }; // Lat/Lon DMS - const llEl = document.getElementById('hud-latlon'); - if (llEl) llEl.textContent = `${latDMS} ${lonDMS}`; - const bottomEl = document.getElementById('hud-bottom-line'); - if (bottomEl) { - // i18n-ignore: sensor codes - bottomEl.textContent = `MGRS: ${mgrsLabel} LAT: ${latDMS} LON: ${lonDMS}`; - } + readouts.latlon = `${latDMS} ${lonDMS}`; + // i18n-ignore: sensor codes + readouts.bottom = `MGRS: ${mgrsLabel} LAT: ${latDMS} LON: ${lonDMS}`; // GSD (Ground Sample Distance): approximate resolution in meters per pixel // derived from camera altitude. NIIRS (National Imagery Interpretability @@ -477,42 +455,35 @@ export class IntelHUD { 0, Math.min(9, 10.25 - 3.32 * Math.log10(gsdInches)), ); - const gsdEl = document.getElementById('hud-gsd'); - if (gsdEl) - // i18n-ignore: sensor codes - gsdEl.textContent = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`; + // i18n-ignore: sensor codes + readouts.gsd = `GSD: ${gsd.toFixed(2)}m NIIRS: ${niirs.toFixed(1)}`; // Altitude — reported as height above MEAN SEA LEVEL. `altM` is the raw // ellipsoidal camera height, which reads far below zero wherever the geoid // sits under the ellipsoid: a cockpit parked on the SFO deck (N ≈ -32 m) // showed "ALT: -15m", and JFK "ALT: -18m". Subtracting N restores the // number a viewer expects without touching the camera or any render path. - const altEl = document.getElementById('hud-alt'); const geoidN = this._geoidUndulationM(latDeg, lonDeg); const altMslM = ellipsoidalToMslDisplayM(altM, geoidN); const sunEl = this._estimateSunElevation(latDeg, lonDeg); - if (altEl) - // i18n-ignore: sensor codes - altEl.textContent = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`; + // i18n-ignore: sensor codes + readouts.alt = `ALT: ${Math.round(altMslM)}m SUN: ${sunEl.toFixed(1)}° EL`; // Collection timestamp - const collEl = document.getElementById('hud-coll'); - if (collEl) { - const now = new Date(); - const h = String(now.getUTCHours()).padStart(2, '0'); - const m = String(now.getUTCMinutes()).padStart(2, '0'); - const s = String(now.getUTCSeconds()).padStart(2, '0'); - // i18n-ignore: sensor codes - collEl.textContent = `COLL: ${h}:${m}:${s}Z`; - } + const now = new Date(); + const h = String(now.getUTCHours()).padStart(2, '0'); + const m = String(now.getUTCMinutes()).padStart(2, '0'); + const s = String(now.getUTCSeconds()).padStart(2, '0'); + // i18n-ignore: sensor codes + readouts.coll = `COLL: ${h}:${m}:${s}Z`; // Off-nadir angle (ONA): camera pitch of -90 deg is nadir (straight down), // so ONA = 90 + pitch gives 0 at nadir and increases toward the horizon. const pitchDeg = Cesium.Math.toDegrees(camera.pitch); const ona = Math.max(0, 90 + pitchDeg); - const onaEl = document.getElementById('hud-ona'); // i18n-ignore: sensor codes - if (onaEl) onaEl.textContent = `ONA: ${ona.toFixed(1)}°`; + readouts.ona = `ONA: ${ona.toFixed(1)}°`; + this._present(readouts); // `altM` stays the raw ellipsoidal camera height the sensor model reads // (GSD/NIIRS, view band). `altMslM` is the ADDITIVE display datum — the @@ -733,8 +704,7 @@ export class IntelHUD { const m = this._latestMetrics; if (!m) return t('Awaiting telemetry...'); - const modeEl = document.getElementById('hud-mode'); - const modeLabel = modeEl?.textContent || t(MODE_LABELS.normal); + const modeLabel = this._view.mode || t(MODE_LABELS.normal); const region = this._regionLabel(m.latDeg, m.lonDeg); const nearest = this._nearestKnownPoint(m.latDeg, m.lonDeg); const band = this._viewBand(m.altM); @@ -796,20 +766,18 @@ export class IntelHUD { * @param {string} text - Full summary string to type out. */ _typeSummary(text) { - const el = document.getElementById('hud-summary'); - if (!el) return; clearInterval(this._summaryTypingInterval); let index = 0; - el.textContent = ''; + this._present({ summary: '' }); this._summaryTypingInterval = setInterval(() => { index += 2; if (index >= text.length) { - el.textContent = text; + this._present({ summary: text }); clearInterval(this._summaryTypingInterval); this._summaryTypingInterval = null; return; } - el.textContent = text.slice(0, index); + this._present({ summary: text.slice(0, index) }); }, 24); } @@ -924,8 +892,7 @@ export class IntelHUD { } // A plain paint replaces any reply still typing. this._stopSummaryTyping(); - const el = document.getElementById('hud-summary'); - if (el) el.textContent = text; + this._present({ summary: text }); } /** @@ -984,20 +951,18 @@ export class IntelHUD { onStyleChange(styleName) { this._currentStyle = styleName; - // Update mode label - const modeEl = document.getElementById('hud-mode'); - if (modeEl) { - modeEl.textContent = MODE_LABELS[styleName] - ? t(MODE_LABELS[styleName]) - : styleName.toUpperCase(); - } - // Update color scheme + // Mode label and color scheme const colors = HUD_COLORS[styleName] || HUD_COLORS._default; - if (this._el) { - this._el.style.setProperty('--hud-color', colors.main); - this._el.style.setProperty('--hud-glow', colors.glow); - this._el.style.setProperty('--hud-border', colors.border); - } + this._present({ + mode: MODE_LABELS[styleName] + ? t(MODE_LABELS[styleName]) + : styleName.toUpperCase(), + colors: { + '--hud-color': colors.main, + '--hud-glow': colors.glow, + '--hud-border': colors.border, + }, + }); // Auto show/hide if (this._autoMode) { @@ -1012,7 +977,7 @@ export class IntelHUD { /** Make the HUD visible and immediately refresh all readouts. */ show() { this._visible = true; - if (this._el) this._el.classList.add('active'); + this._present({ active: true }); this._updateCameraData(); // immediate update this._markSummaryDirty(); void this._updateSummary(false, true); @@ -1021,7 +986,7 @@ export class IntelHUD { /** Hide the HUD overlay. */ hide() { this._visible = false; - if (this._el) this._el.classList.remove('active'); + this._present({ active: false }); } /** Toggle HUD visibility and disable auto-mode (user override). */ @@ -1060,9 +1025,7 @@ export class IntelHUD { */ setVariant(variantName) { this._variant = normalizeHudLayout(variantName); - if (this._el) { - this._el.dataset.variant = this._variant; - } + this._present({ variant: this._variant }); applyHudUiTheme(document.documentElement, this._variant); } @@ -1116,6 +1079,7 @@ export class IntelHUD { this.viewer.camera.moveStart.removeEventListener(this._onCameraMoveStart); this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd); this._dataManagerUnsubscribe?.(); + this._vesselUnsubscribe?.(); this._summaryRequest?.abort(); } } diff --git a/src/hudAltitudeDatum.test.mjs b/src/hudAltitudeDatum.test.mjs index 18ed56f92..543427d0f 100644 --- a/src/hudAltitudeDatum.test.mjs +++ b/src/hudAltitudeDatum.test.mjs @@ -21,6 +21,7 @@ // one file that can construct a real IntelHUD. import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { hudReadouts } from './testSupport/hudReadouts.mjs'; import { readFileSync } from 'node:fs'; import { registerHooks } from 'node:module'; import { ensureGeoidReady } from './data/geoid.js'; @@ -62,19 +63,7 @@ const SFO_ELLIPSOIDAL_M = -15; * text sinks this test reads. */ function installHudEnvironment() { - const elements = new Map( - [ - 'hud-alt', - 'hud-summary', - 'hud-mgrs', - 'hud-latlon', - 'hud-bottom-line', - 'hud-gsd', - 'hud-coll', - 'hud-ona', - 'hud-mode', - ].map((id) => [id, { textContent: '' }]), - ); + const { elements, publish, view } = hudReadouts(); const previousDocument = globalThis.document; const cameraListeners = {}; globalThis.document = { @@ -109,6 +98,8 @@ function installHudEnvironment() { return { elements, viewer, + publish, + view, cameraListeners, restore() { if (previousDocument === undefined) delete globalThis.document; @@ -213,7 +204,7 @@ test('a cold tick paints both readouts uncorrected, and resolving flips both in const env = installHudEnvironment(); let hud; try { - hud = new IntelHUD(env.viewer); + hud = new IntelHUD(env.viewer, { publish: env.publish }); const alt = () => env.elements.get('hud-alt').textContent; const summary = () => env.elements.get('hud-summary').textContent; @@ -267,7 +258,7 @@ test('the corrected readouts are the MSL datum, not a coincidence of the SFO sig env.viewer.camera.positionCartographic.height = 100; let hud; try { - hud = new IntelHUD(env.viewer); + hud = new IntelHUD(env.viewer, { publish: env.publish }); hud._updateCameraData(); // cold: requests the grid, paints uncorrected assert.match(env.elements.get('hud-alt').textContent, /^ALT: 100m/); await ensureGeoidReady(); @@ -302,7 +293,7 @@ test('a summary asked for one view is dropped once the camera moves', async () = }; let hud; try { - hud = new IntelHUD(env.viewer, { summaryService }); + hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish }); let place = 'Yellowstone'; hud._summaryContext = async () => ({ place }); const summary = () => env.elements.get('hud-summary').textContent; @@ -362,7 +353,7 @@ test('a request the camera leaves paints nothing: aborted, failed, or its contex }; let hud; try { - hud = new IntelHUD(env.viewer, { summaryService }); + hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish }); hud._summaryContext = async () => ({ place: 'Yellowstone' }); const element = env.elements.get('hud-summary'); const settle = () => new Promise((resolve) => setImmediate(resolve)); @@ -371,7 +362,7 @@ test('a request the camera leaves paints nothing: aborted, failed, or its contex // The move aborts the request in flight, and the abort paints nothing. let update = hud._updateSummary(false, true); await settle(); - element.textContent = 'shown before the move'; + hud._present({ summary: 'shown before the move' }); env.cameraListeners.moveStart(); assert.equal(pending[0].signal.aborted, true, 'the request is aborted'); await update; @@ -382,7 +373,7 @@ test('a request the camera leaves paints nothing: aborted, failed, or its contex ignoreAbort = true; update = hud._updateSummary(false, true); await settle(); - element.textContent = 'shown before the move'; + hud._present({ summary: 'shown before the move' }); env.cameraListeners.moveStart(); pending[1].reject(new Error('network')); await update; @@ -393,7 +384,7 @@ test('a request the camera leaves paints nothing: aborted, failed, or its contex env.cameraListeners.moveStart(); throw new Error('view target unavailable'); }; - element.textContent = 'shown before the move'; + hud._present({ summary: 'shown before the move' }); await hud._updateSummary(false, true); assert.equal(element.textContent, 'shown before the move'); } finally { @@ -424,7 +415,7 @@ test('a cancelled summary retries the same context but completed answers stay re }; let hud; try { - hud = new IntelHUD(env.viewer, { summaryService }); + hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish }); hud._summaryContext = async () => ({ place: 'San Francisco' }); hud._updateCameraData(); const settle = () => new Promise((resolve) => setImmediate(resolve)); @@ -481,7 +472,7 @@ test('after a dropped or timed-out request the next tick asks again, and a move }; let hud; try { - hud = new IntelHUD(env.viewer, { summaryService }); + hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish }); // The same place before and after: a slow follow camera, a small pan. hud._summaryContext = async () => ({ place: 'Lisboa' }); const element = env.elements.get('hud-summary'); @@ -544,7 +535,7 @@ test('a summary interrupted while typing can retry unchanged context', async (t) }; let hud; try { - hud = new IntelHUD(env.viewer, { summaryService }); + hud = new IntelHUD(env.viewer, { summaryService, publish: env.publish }); hud._summaryContext = async () => ({ place: 'San Francisco' }); hud._updateCameraData(); await hud._updateSummary(true); @@ -585,13 +576,12 @@ test('a retired Operator layout falls back to Tactical in the live HUD', () => { const env = installHudEnvironment(); let hud; try { - hud = new IntelHUD(env.viewer); - hud._el = { dataset: {} }; + hud = new IntelHUD(env.viewer, { publish: env.publish }); hud.setVariant('minimal'); assert.equal(hud.getVariant(), 'minimal'); hud.setVariant('operator'); assert.equal(hud.getVariant(), 'tactical'); - assert.equal(hud._el.dataset.variant, 'tactical'); + assert.equal(env.view().variant, 'tactical'); } finally { hud?.destroy(); env.restore(); diff --git a/src/hudDms.test.mjs b/src/hudDms.test.mjs index 064906b5f..457964072 100644 --- a/src/hudDms.test.mjs +++ b/src/hudDms.test.mjs @@ -9,6 +9,7 @@ // hudAltitudeDatum.test.mjs before importing it. import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { hudReadouts } from './testSupport/hudReadouts.mjs'; import { registerHooks } from 'node:module'; const MGRS_STUB_URL = 'gev-test-stub:mgrs'; @@ -56,19 +57,7 @@ test('ordinary values keep their existing format', () => { }); test('the telemetry tick paints the carried value into both readouts', () => { - const elements = new Map( - [ - 'hud-alt', - 'hud-summary', - 'hud-mgrs', - 'hud-latlon', - 'hud-bottom-line', - 'hud-gsd', - 'hud-coll', - 'hud-ona', - 'hud-mode', - ].map((id) => [id, { textContent: '' }]), - ); + const { elements, publish } = hudReadouts(); const previousDocument = globalThis.document; globalThis.document = { getElementById: (id) => elements.get(id) ?? null, @@ -91,7 +80,7 @@ test('the telemetry tick paints the carried value into both readouts', () => { }; let hud; try { - hud = new IntelHUD(viewer); + hud = new IntelHUD(viewer, { publish }); hud._updateCameraData(); const latlon = elements.get('hud-latlon').textContent; const bottom = elements.get('hud-bottom-line').textContent; diff --git a/src/hudPageVisibility.test.mjs b/src/hudPageVisibility.test.mjs index b205431fc..97e75c689 100644 --- a/src/hudPageVisibility.test.mjs +++ b/src/hudPageVisibility.test.mjs @@ -6,6 +6,7 @@ // setup as hudAltitudeDatum.test.mjs). import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { hudReadouts } from './testSupport/hudReadouts.mjs'; import { registerHooks } from 'node:module'; const MGRS_STUB_URL = 'gev-test-stub:mgrs'; @@ -29,10 +30,7 @@ registerHooks({ const { IntelHUD } = await import('./hud.js'); function installHiddenPage() { - const elements = new Map( - ['hud-alt', 'hud-summary', 'hud-mgrs', 'hud-latlon', 'hud-bottom-line', 'hud-gsd', 'hud-coll', 'hud-ona', 'hud-mode'] - .map((id) => [id, { textContent: '' }]), - ); + const { elements, publish } = hudReadouts(); const previousDocument = globalThis.document; globalThis.document = { hidden: true, @@ -54,6 +52,7 @@ function installHiddenPage() { return { alt: () => elements.get('hud-alt').textContent, viewer, + publish, restore() { if (previousDocument === undefined) delete globalThis.document; else globalThis.document = previousDocument; @@ -66,11 +65,15 @@ test('the HUD readout pauses in a hidden page but keeps updating in an inline pa const page = installHiddenPage(); let hud; try { - hud = new IntelHUD(page.viewer); + hud = new IntelHUD(page.viewer, { publish: page.publish }); hud._visible = true; t.mock.timers.tick(250); - assert.equal(page.alt(), '', 'a hidden page skips the telemetry tick'); + assert.equal( + page.alt(), + 'ALT: --m SUN: --° EL', + 'a hidden page skips the telemetry tick', + ); globalThis.GEV_EMBED_INLINE = true; t.mock.timers.tick(250); diff --git a/src/hudReadouts.js b/src/hudReadouts.js new file mode 100644 index 000000000..ecc7b4662 --- /dev/null +++ b/src/hudReadouts.js @@ -0,0 +1,29 @@ +/** + * Readouts other owners show in the Intel HUD. The vessel layer presents the + * selected vessel here and the HUD subscribes, so neither reaches into the + * other's markup. + */ + +/** @type {?{text: string}} The selected vessel's lines, or null for none. */ +let selectedVessel = null; +const vesselListeners = new Set(); + +/** + * Show the selected vessel's readout, or clear it. + * @param {?{text: string}} readout Its lines joined by newlines, or null. + */ +export function presentSelectedVessel(readout) { + selectedVessel = readout ? Object.freeze({ text: readout.text }) : null; + for (const listener of vesselListeners) listener(selectedVessel); +} + +/** + * Follow the selected vessel's readout; the current one is replayed at once. + * @param {(readout: ?{text: string}) => void} listener + * @returns {() => void} Stop following. + */ +export function subscribeSelectedVessel(listener) { + vesselListeners.add(listener); + listener(selectedVessel); + return () => vesselListeners.delete(listener); +} diff --git a/src/hudSummaryGate.test.mjs b/src/hudSummaryGate.test.mjs index b56141505..a744bc859 100644 --- a/src/hudSummaryGate.test.mjs +++ b/src/hudSummaryGate.test.mjs @@ -6,6 +6,7 @@ // setup as hudPageVisibility.test.mjs). import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { hudReadouts } from './testSupport/hudReadouts.mjs'; import { registerHooks } from 'node:module'; const MGRS_STUB_URL = 'gev-test-stub:mgrs'; @@ -30,19 +31,7 @@ registerHooks({ const { IntelHUD } = await import('./hud.js'); function installPage() { - const elements = new Map( - [ - 'hud-alt', - 'hud-summary', - 'hud-mgrs', - 'hud-latlon', - 'hud-bottom-line', - 'hud-gsd', - 'hud-coll', - 'hud-ona', - 'hud-mode', - ].map((id) => [id, { textContent: '' }]), - ); + const { elements, publish } = hudReadouts(); const previous = { document: globalThis.document, window: globalThis.window }; globalThis.document = { hidden: false, @@ -69,6 +58,7 @@ function installPage() { }; return { viewer, + publish, restore() { for (const [key, value] of Object.entries(previous)) { if (value === undefined) delete globalThis[key]; @@ -82,7 +72,10 @@ function installPage() { function createHud(t, summaryService) { t.mock.timers.enable({ apis: ['setInterval'] }); const page = installPage(); - const hud = new IntelHUD(page.viewer, { summaryService }); + const hud = new IntelHUD(page.viewer, { + summaryService, + publish: page.publish, + }); t.after(() => { hud.destroy(); page.restore(); diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index 64e115637..aa6f06717 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -13,28 +13,23 @@ export function createCards({ options, }) { function updateSelectedVesselHud(record) { - const el = document.getElementById('hud-ais-vessel'); - if (!el) return; - // Pinned vessels missing from recent refreshes get a stale marker const stale = (record.missedRefreshes || 0) > 0; - el.classList.add('active'); - el.textContent = [ - t('AIS: {name}', { name: trimHudValue(record.name, 32) }), - t('{type} SPD: {speed} HDG: {heading}', { - type: trimHudValue(record.type || t('VESSEL'), 24), - speed: formatSpeed(record.speed), - heading: formatHeading(record.heading ?? record.course), - }), - `MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`, - ].join('\n'); + services.readouts?.presentSelectedVessel({ + text: [ + t('AIS: {name}', { name: trimHudValue(record.name, 32) }), + t('{type} SPD: {speed} HDG: {heading}', { + type: trimHudValue(record.type || t('VESSEL'), 24), + speed: formatSpeed(record.speed), + heading: formatHeading(record.heading ?? record.course), + }), + `MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`, + ].join('\n'), + }); } function resetSelectedVesselHud() { - const el = document.getElementById('hud-ais-vessel'); - if (!el) return; - el.classList.remove('active'); - el.textContent = t('AIS: --'); + services.readouts?.presentSelectedVessel(null); } function trimHudValue(value, maxLength) { diff --git a/src/testSupport/hudReadouts.mjs b/src/testSupport/hudReadouts.mjs new file mode 100644 index 000000000..7e44f99d2 --- /dev/null +++ b/src/testSupport/hudReadouts.mjs @@ -0,0 +1,35 @@ +// src/testSupport/hudReadouts.mjs — the Intel HUD's published readouts by the +// element ids its view renders them in, for tests that drive IntelHUD alone. + +const FIELDS = Object.freeze({ + 'hud-alt': 'alt', + 'hud-summary': 'summary', + 'hud-mgrs': 'mgrs', + 'hud-latlon': 'latlon', + 'hud-bottom-line': 'bottom', + 'hud-gsd': 'gsd', + 'hud-coll': 'coll', + 'hud-ona': 'ona', + 'hud-mode': 'mode', + 'hud-timestamp': 'timestamp', +}); + +/** + * A `publish` to hand IntelHUD and each readout's latest text. + * @returns {{publish: (view: object) => void, view: () => ?object, + * elements: Map}} + */ +export function hudReadouts() { + let view = null; + const elements = new Map( + Object.entries(FIELDS).map(([id, field]) => [ + id, + { + get textContent() { + return view?.[field] ?? ''; + }, + }, + ]), + ); + return { publish: (next) => (view = next), view: () => view, elements }; +} diff --git a/src/tooling/applicationHtml.test.mjs b/src/tooling/applicationHtml.test.mjs index ee1294401..0064e2117 100644 --- a/src/tooling/applicationHtml.test.mjs +++ b/src/tooling/applicationHtml.test.mjs @@ -9,7 +9,7 @@ const source = readFileSync(new URL('../../index.html', import.meta.url), 'utf8' test('the standalone document hosts every view once and keeps element ids unique', async () => { const hosts = [...source.matchAll(/data-gev-view="([^"]+)"/g)].map((match) => match[1]); // Order is stacking order for fixed elements that share a z-index. - assert.deepEqual(hosts, ['scene-chrome', 'cockpit', 'display-controls', 'command-dock', 'layer-panels', 'context', 'welcome', 'provider-settings']); + assert.deepEqual(hosts, ['scene-chrome', 'cockpit', 'display-controls', 'command-dock', 'layer-panels', 'context', 'welcome', 'provider-settings', 'intel-hud']); assert.deepEqual([...hosts].sort(), Object.keys(APPLICATION_VIEWS).sort()); const html = await readApplicationHtml(); const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index b9716e741..697521c92 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -58,6 +58,7 @@ import { useMissionReplayStore } from './stores/missionReplay.js'; import { useCelestialRingStore } from './stores/celestialRing.js'; import { useWorldOverlayActionsStore } from './stores/worldOverlayActions.js'; import { useAwarenessDirectionStore } from './stores/awarenessDirection.js'; +import { useHudStore } from './stores/hud.js'; import { useDisplayStore } from './stores/display.js'; import { useLocaleStore } from './stores/locale.js'; import { useLocationStore } from './stores/location.js'; @@ -347,9 +348,11 @@ export class StyleManager extends ShellFacade { this._lastTrafficChipUpdateAt = 0; // Intel HUD + const hudStore = useHudStore(applicationPinia()); this.hud = new IntelHUD(viewer, { placeSearch, summaryService: requestServices?.summary, + publish: (view) => hudStore.publish(view), }); this._recording.hud = this.hud; this._cockpitCoordinator = new CockpitCoordinator({ @@ -1489,7 +1492,9 @@ export class StyleManager extends ShellFacade { hud: { visible: !!this.hud?.visible, layout: this.hud?.getVariant?.() || null, - sonar: getCyberSonarControlState(), + sonar: getCyberSonarControlState(globalThis.document, { + hudActive: this.hud ? !!this.hud.visible : undefined, + }), }, detection: this.getDetectionState(), bloom: { diff --git a/src/ui/cyberSonarControls.js b/src/ui/cyberSonarControls.js index 4e16028c6..e6a2f2731 100644 --- a/src/ui/cyberSonarControls.js +++ b/src/ui/cyberSonarControls.js @@ -13,14 +13,23 @@ const SETTING_FIELDS = Object.freeze({ sectorDeg: ['sector', 8, 60], }); -/** Read configuration separately from whether map effects can currently run. */ -export function getCyberSonarControlState(doc = globalThis.document) { +/** + * Read configuration separately from whether map effects can currently run. + * @param {Document} [doc] + * @param {{hudActive?: boolean}} [state] The HUD's visibility from IntelHUD, + * which the view renders only on its next update. + */ +export function getCyberSonarControlState( + doc = globalThis.document, + { hudActive } = {}, +) { const root = doc?.documentElement; const settings = readCyberSonarSettings(root); const options = { root, body: doc?.body, hud: doc?.getElementById?.('intel-hud') ?? null, + hudActive, }; return { enabled: isCyberSonarEnabled(root), @@ -47,7 +56,7 @@ export function setCyberSonarControls( const fail = (error) => ({ ok: false, error, - sonar: getCyberSonarControlState(doc), + sonar: getCyberSonarControlState(doc, { hudActive: owner?.hud?.visible }), }); if (!request || typeof request !== 'object' || Array.isArray(request)) { return fail('Provide Cyber sonar settings as an object.'); @@ -82,5 +91,8 @@ export function setCyberSonarControls( if (Object.hasOwn(request, 'enabled')) { owner._setCyberSonarEnabled(request.enabled); } - return { ok: true, sonar: getCyberSonarControlState(doc) }; + return { + ok: true, + sonar: getCyberSonarControlState(doc, { hudActive: owner?.hud?.visible }), + }; } diff --git a/src/ui/cyberSonarControls.test.mjs b/src/ui/cyberSonarControls.test.mjs index 4150fc367..e389084b1 100644 --- a/src/ui/cyberSonarControls.test.mjs +++ b/src/ui/cyberSonarControls.test.mjs @@ -161,3 +161,19 @@ test('reports inactive map effects in Cockpit, hidden HUD, unsupported and unini assert.equal(getCyberSonarControlState(f.doc).contactSweepActive, false); } }); + +test('the HUD visibility its owner reports answers before the view re-renders', () => { + const f = fixture(); + f.root.dataset.cyberSonar = 'on'; + // The HUD was just hidden: IntelHUD knows, the rendered class does not yet. + assert.equal(getCyberSonarControlState(f.doc).mapSweepActive, true); + assert.equal( + getCyberSonarControlState(f.doc, { hudActive: false }).mapSweepActive, + false, + ); + f.owner.hud.visible = false; + const result = setCyberSonarControls(f.owner, { rings: 6 }, f.doc); + assert.equal(result.ok, true); + assert.equal(result.sonar.mapSweepActive, false); + assert.equal(result.sonar.contactSweepActive, false); +}); diff --git a/src/ui/cyberTheme.test.mjs b/src/ui/cyberTheme.test.mjs index 1433da97a..832b81e33 100644 --- a/src/ui/cyberTheme.test.mjs +++ b/src/ui/cyberTheme.test.mjs @@ -651,6 +651,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', () const stylesheet = read('../../style.css'); const rightRail = read('./rightPanelRail.js'); const hud = read('../hud.js'); + const hudView = read('./views/IntelHudView.vue'); const detection = read('../data/detection.js'); const civilRendering = read('../layers/flights/rendering.js'); const militaryRendering = read('../layers/military/rendering.js'); @@ -680,7 +681,7 @@ test('the Display selector, voice schema and final stylesheet expose Cyber', () /\.panel-collapsible:not\(#location-bar\):not\(#control-panel\)::after/, ); assert.match(cyberStyles, /clip-path: var\(--cyber-panel-frame\);/); - assert.match(hud, /class="hud-sonar" aria-hidden="true"/); + assert.match(hudView, /class="hud-sonar" aria-hidden="true"/); assert.match(cyberStyles, /\.hud-sonar::before/); assert.match(cyberStyles, /\.hud-sonar::after/); assert.match(cyberStyles, /@keyframes cyber-sonar-sweep/); diff --git a/src/ui/stores/hud.js b/src/ui/stores/hud.js new file mode 100644 index 000000000..9b14c49a8 --- /dev/null +++ b/src/ui/stores/hud.js @@ -0,0 +1,19 @@ +import { defineStore } from 'pinia'; +import { shallowRef } from 'vue'; + +/** + * The Intel HUD as its view renders it. IntelHUD presents its readouts, + * visibility, layout variant and style colors and the application shell + * publishes them here. + */ +export const useHudStore = defineStore('gev-hud', () => { + /** The presented HUD, frozen by IntelHUD; null until it is built. */ + const hud = shallowRef(null); + + /** @param {?object} next A fresh presentation; every publish re-renders. */ + function publish(next) { + hud.value = next; + } + + return { hud, publish }; +}); diff --git a/src/ui/views/IntelHudView.vue b/src/ui/views/IntelHudView.vue new file mode 100644 index 000000000..77cd0c4fd --- /dev/null +++ b/src/ui/views/IntelHudView.vue @@ -0,0 +1,63 @@ + + + diff --git a/src/ui/views/index.js b/src/ui/views/index.js index af2c66746..fa9d6f542 100644 --- a/src/ui/views/index.js +++ b/src/ui/views/index.js @@ -8,6 +8,7 @@ import DisplayControlsView from './DisplayControlsView.vue'; import CockpitView from './CockpitView.vue'; import SceneChromeView from './SceneChromeView.vue'; import ProviderSettingsView from './ProviderSettingsView.vue'; +import IntelHudView from './IntelHudView.vue'; /** * Application views by name. A page places each one with a host element, @@ -23,6 +24,7 @@ export const APPLICATION_VIEWS = Object.freeze({ 'layer-panels': LayerPanelsView, context: ContextView, welcome: WelcomeView, + 'intel-hud': IntelHudView, }); const mounted = new WeakMap(); diff --git a/src/ui/views/intelHudView.test.mjs b/src/ui/views/intelHudView.test.mjs new file mode 100644 index 000000000..de89c952a --- /dev/null +++ b/src/ui/views/intelHudView.test.mjs @@ -0,0 +1,163 @@ +// src/ui/views/intelHudView.test.mjs — the Intel HUD view renders what +// IntelHUD publishes: its readouts, visibility, layout variant and colors. +// +// hud.js imports `mgrs`, which Node's ESM loader cannot read named exports +// from, so a module hook swaps it for a stub before the import (the same +// setup as hudPageVisibility.test.mjs). +import '../../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, nextTick } from 'vue'; +import { createPinia } from 'pinia'; +import { registerHooks } from 'node:module'; +import { presentSelectedVessel } from '../../hudReadouts.js'; +import { useHudStore } from '../stores/hud.js'; +import IntelHudView from './IntelHudView.vue'; + +const MGRS_STUB_URL = 'gev-test-stub:mgrs'; +registerHooks({ + resolve(specifier, context, next) { + if (specifier === 'mgrs') return { url: MGRS_STUB_URL, shortCircuit: true }; + return next(specifier, context); + }, + load(url, context, next) { + if (url === MGRS_STUB_URL) { + return { + format: 'module', + shortCircuit: true, + source: + 'export function forward() { return "10SEG55776339"; }\nexport default { forward };\n', + }; + } + return next(url, context); + }, +}); + +const { IntelHUD } = await import('../../hud.js'); + +const listeners = () => ({ addEventListener() {}, removeEventListener() {} }); + +function mount(t) { + document.body.replaceChildren(); + const pinia = createPinia(); + const store = useHudStore(pinia); + const app = createApp(IntelHudView).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + const hud = new IntelHUD( + { + camera: { + pitch: -Math.PI / 2, + positionCartographic: { latitude: 0.7, longitude: -1.2, height: 500 }, + computeViewRectangle: () => undefined, + moveStart: listeners(), + moveEnd: listeners(), + }, + }, + { + // No AI summary: the readouts are what this test renders. + summaryService: { configured: async () => false }, + publish: (view) => store.publish(view), + }, + ); + t.after(() => { + hud.destroy(); + presentSelectedVessel(null); + app.unmount(); + }); + return { hud, root: document.getElementById('intel-hud') }; +} + +test('the corners are in the document from mount, before IntelHUD presents them', (t) => { + document.body.replaceChildren(); + const pinia = createPinia(); + const app = createApp(IntelHudView).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => app.unmount()); + // The panel layout controllers query these synchronously at registration. + const root = document.getElementById('intel-hud'); + assert.equal(root.querySelectorAll('.hud-corner').length, 4); + assert.equal(root.querySelectorAll('.hud-edge').length, 2); + assert.equal(root.className, ''); + assert.equal(root.hasAttribute('data-variant'), false); + assert.equal(root.getAttribute('style'), null); + assert.equal(document.getElementById('hud-summary').textContent, ''); + assert.equal(root.querySelector('.hud-rec').textContent, '●'); +}); + +test('the first view renders the corners, edges and bottom bar', async (t) => { + const { root } = mount(t); + await nextTick(); + assert.equal(root.className, ''); + assert.equal(root.dataset.variant, 'tactical'); + assert.deepEqual( + [...root.children].map((child) => child.className), + [ + 'hud-sonar', + 'hud-corner hud-top-left', + 'hud-corner hud-top-right', + 'hud-corner hud-bottom-left', + 'hud-corner hud-bottom-right', + 'hud-edge hud-left-edge', + 'hud-edge hud-right-edge', + 'hud-bottom-bar', + ], + ); + const text = (id) => document.getElementById(id).textContent; + assert.equal(text('hud-mode'), 'NORMAL'); + assert.equal(text('hud-summary'), 'Awaiting telemetry...'); + assert.equal(text('hud-mgrs'), 'MGRS: ---'); + assert.equal(text('hud-ais-vessel'), 'AIS: --'); + assert.equal( + root.querySelector('.hud-rec').textContent, + '● REC 2026-01-01 00:00:00Z', + ); + assert.equal( + document.getElementById('hud-rec-dot').getAttribute('style'), + null, + ); + assert.match( + root.querySelector('.hud-system').textContent, + /^KH11-\d+ {2}OPS-\d+$/, + ); + assert.deepEqual( + [...root.querySelectorAll('.hud-right-edge .hud-readout')].map( + (line) => line.textContent, + ), + ['BAND: PAN', 'BITS: 11', 'LVL: 1A'], + ); +}); + +test('visibility, variant, style colors and readouts follow IntelHUD', async (t) => { + const { hud, root } = mount(t); + hud.onStyleChange('surveillance'); + hud.setVariant('minimal'); + hud.setMode('on'); + await nextTick(); + assert.equal(root.classList.contains('active'), true); + assert.equal(root.dataset.variant, 'minimal'); + assert.notEqual(root.style.getPropertyValue('--hud-color'), ''); + assert.equal(document.getElementById('hud-mode').textContent, hud._view.mode); + assert.match(document.getElementById('hud-alt').textContent, /^ALT: \d+m/); + hud._present({ recShown: false }); + await nextTick(); + assert.equal( + document.getElementById('hud-rec-dot').style.visibility, + 'hidden', + ); + hud.setMode('off'); + await nextTick(); + assert.equal(root.classList.contains('active'), false); +}); + +test('the selected vessel readout shows while a vessel is selected', async (t) => { + mount(t); + presentSelectedVessel({ text: 'AIS: TEST\nCARGO' }); + await nextTick(); + const vessel = document.getElementById('hud-ais-vessel'); + assert.equal(vessel.className, 'hud-ais-vessel active'); + assert.equal(vessel.textContent, 'AIS: TEST\nCARGO'); + presentSelectedVessel(null); + await nextTick(); + assert.equal(vessel.className, 'hud-ais-vessel'); + assert.equal(vessel.textContent, 'AIS: --'); +});