mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
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:
1 parent
b96cc37ab5
commit
c03b4a7643
26 files changed
+556
-243
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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')
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
@@ -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: --');
|
||||
});
|
||||
Reference in new issue
Block a user