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

+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: --');
});