` 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('Awaiting telemetry...')}
-
-
-
-
-
-
-
● ${t('REC')} 2026-01-01 00:00:00Z
-
ORB: ${this._orbitNum} PASS: DESC-${this._passNum}
-
-
┐
-
-
-
-
-
-
-
-
-
-
-
- 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 @@
+
+
+
+
+
+
+
┌
+
+
{{ view.classification }}
+
+
+
+
+
+
+
+
●{{ view.rec ? ` ${view.rec} ` : '' }}{{ view.timestamp }}
+
+
+
┐
+
+
+
+
+
+
+ {{ view.bottom }}
+
+
+
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: --');
+});