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.
This commit is contained in:
Sameh Khamis committed 2026-10-31 22:18:50 -07:00
1 parent b96cc37ab5
commit c03b4a7643
26 files changed
+556 -243

No files matched your search

+9
View File
@@ -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 `<div class="gev-view" data-gev-view="intel-hud">` 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
+4 -2
View File
@@ -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
+7 -1
View File
@@ -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
+1 -1
View File
@@ -38,7 +38,7 @@
<div class="gev-view" data-gev-view="welcome"></div>
<div class="gev-view" data-gev-view="provider-settings"></div>
<!-- Intelligence HUD Overlay -->
<div id="intel-hud"></div>
<div class="gev-view" data-gev-view="intel-hud"></div>
<!-- Loading screen -->
<div id="loading-screen">
+5 -1
View File
@@ -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"
]
+10 -4
View File
@@ -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",
+2
View File
@@ -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,
},
});
}
+9 -3
View File
@@ -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')
);
}
+17 -18
View File
@@ -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();
}
+93 -129
View File
@@ -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 = `
<div class="hud-sonar" aria-hidden="true"></div>
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({});
}
<div class="hud-corner hud-top-left">
<div class="hud-bracket">┌</div>
<div class="hud-content">
<div class="hud-readout"><div class="hud-classification">TOP SECRET // SI-TK // NOFORN</div></div>
<div class="hud-readout"><div class="hud-system">${this._missionId} ${this._sensorId}</div></div>
<div class="hud-readout"><div class="hud-mode" id="hud-mode">${t(MODE_LABELS.normal)}</div></div>
<div class="hud-summary-wrap">
<div class="hud-readout"><div class="hud-summary-label">${t('SUMMARY')}</div></div>
<div class="hud-readout"><div class="hud-summary" id="hud-summary">${t('Awaiting telemetry...')}</div></div>
</div>
</div>
</div>
<div class="hud-corner hud-top-right">
<div class="hud-content" style="text-align:right">
<div class="hud-readout"><div class="hud-rec"><span id="hud-rec-dot">●</span> ${t('REC')} <span id="hud-timestamp">2026-01-01 00:00:00Z</span></div></div>
<div class="hud-readout"><div class="hud-orbital">ORB: ${this._orbitNum} PASS: DESC-${this._passNum}</div></div>
</div>
<div class="hud-bracket">┐</div>
</div>
<div class="hud-corner hud-bottom-left">
<div class="hud-bracket">└</div>
<div class="hud-content">
<div class="hud-readout"><div id="hud-mgrs">MGRS: ---</div></div>
<div class="hud-readout"><div id="hud-latlon">--°--'--"N ---°--'--"W</div></div>
</div>
</div>
<div class="hud-corner hud-bottom-right">
<div class="hud-content" style="text-align:right">
<div class="hud-readout"><div id="hud-gsd">GSD: --m NIIRS: --</div></div>
<div class="hud-readout"><div id="hud-alt">ALT: --m SUN: --° EL</div></div>
<div class="hud-readout"><div id="hud-ais-vessel" class="hud-ais-vessel">${t('AIS: --')}</div></div>
</div>
<div class="hud-bracket">┘</div>
</div>
<div class="hud-edge hud-left-edge">
<div class="hud-readout"><div id="hud-coll">COLL: --:--:--Z</div></div>
<div class="hud-readout"><div id="hud-ona">ONA: --°</div></div>
</div>
<div class="hud-edge hud-right-edge">
<div class="hud-readout"><div>BAND: PAN</div></div>
<div class="hud-readout"><div>BITS: 11</div></div>
<div class="hud-readout"><div>LVL: 1A</div></div>
</div>
<div class="hud-bottom-bar">
<span class="hud-readout"><span id="hud-bottom-line">LAT: -- LON: -- MGRS: ---</span></span>
</div>
`;
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();
}
}
+16 -26
View File
@@ -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();
+3 -14
View File
@@ -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;
+9 -6
View File
@@ -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);
+29
View File
@@ -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);
}
+7 -14
View File
@@ -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();
+12 -17
View File
@@ -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) {
+35
View File
@@ -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<string, {readonly textContent: string}>}}
*/
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 };
}
+1 -1
View File
@@ -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]);
+6 -1
View File
@@ -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: {
+16 -4
View File
@@ -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 }),
};
}
+16
View File
@@ -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);
});
+2 -1
View File
@@ -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/);
+19
View File
@@ -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 };
});
+63
View File
@@ -0,0 +1,63 @@
<script setup>
import { computed } from 'vue';
import { storeToRefs } from 'pinia';
import { useHudStore } from '../stores/hud.js';
// The readouts, visibility, layout variant and colors IntelHUD publishes.
const { hud } = storeToRefs(useHudStore());
// The corners and edges render from mount, before IntelHUD presents them:
// the panel layout controllers register them as obstacles synchronously,
// right after IntelHUD is built.
const EMPTY = Object.freeze({ vessel: {}, sensor: [], recShown: null });
const view = computed(() => hud.value ?? EMPTY);
</script>
<template>
<div id="intel-hud" :class="{ active: hud?.active }" :data-variant="hud?.variant" :style="hud?.colors ?? undefined">
<div class="hud-sonar" aria-hidden="true"></div>
<div class="hud-corner hud-top-left">
<div class="hud-bracket">┌</div>
<div class="hud-content">
<div class="hud-readout"><div class="hud-classification">{{ view.classification }}</div></div>
<div class="hud-readout"><div class="hud-system">{{ view.system }}</div></div>
<div class="hud-readout"><div class="hud-mode" id="hud-mode">{{ view.mode }}</div></div>
<div class="hud-summary-wrap">
<div class="hud-readout"><div class="hud-summary-label">{{ view.summaryLabel }}</div></div>
<div class="hud-readout"><div class="hud-summary" id="hud-summary">{{ view.summary }}</div></div>
</div>
</div>
</div>
<div class="hud-corner hud-top-right">
<div class="hud-content" style="text-align: right">
<div class="hud-readout"><div class="hud-rec"><span id="hud-rec-dot" :style="view.recShown === null ? undefined : { visibility: view.recShown ? 'visible' : 'hidden' }">●</span>{{ view.rec ? ` ${view.rec} ` : '' }}<span id="hud-timestamp">{{ view.timestamp }}</span></div></div>
<div class="hud-readout"><div class="hud-orbital">{{ view.orbital }}</div></div>
</div>
<div class="hud-bracket">┐</div>
</div>
<div class="hud-corner hud-bottom-left">
<div class="hud-bracket">└</div>
<div class="hud-content">
<div class="hud-readout"><div id="hud-mgrs">{{ view.mgrs }}</div></div>
<div class="hud-readout"><div id="hud-latlon">{{ view.latlon }}</div></div>
</div>
</div>
<div class="hud-corner hud-bottom-right">
<div class="hud-content" style="text-align: right">
<div class="hud-readout"><div id="hud-gsd">{{ view.gsd }}</div></div>
<div class="hud-readout"><div id="hud-alt">{{ view.alt }}</div></div>
<div class="hud-readout"><div id="hud-ais-vessel" :class="['hud-ais-vessel', { active: view.vessel.active }]">{{ view.vessel.text }}</div></div>
</div>
<div class="hud-bracket">┘</div>
</div>
<div class="hud-edge hud-left-edge">
<div class="hud-readout"><div id="hud-coll">{{ view.coll }}</div></div>
<div class="hud-readout"><div id="hud-ona">{{ view.ona }}</div></div>
</div>
<div class="hud-edge hud-right-edge">
<div v-for="line in view.sensor" :key="line" class="hud-readout"><div>{{ line }}</div></div>
</div>
<div class="hud-bottom-bar">
<span class="hud-readout"><span id="hud-bottom-line">{{ view.bottom }}</span></span>
</div>
</div>
</template>
+2
View File
@@ -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();
+163
View File
@@ -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: --');
});