Files
gods-eye-view/src/hud.js
T
Sameh Khamis c1f60cb7e7 Cut the Google requests the HUD summary makes on its own
The HUD builds place context for its AI summary whenever the camera
settles, and each build paid for up to four reverse geocodes plus a Nearby
Places search, even when no OpenAI key was configured to use them.

- Ask GET /api/openai/hud-summary/status once; with no summary configured,
  or after an unconfigured reply, make no place lookups for the page.
- Reverse-geocode at most two points: the view center and one viewport
  point.
- Reuse a lookup until the view center moves a quarter of the visible
  width, the camera's distance to it doubles or halves, or the camera
  crosses a lookup height. The reused points are asked again, so only a
  timeout, failure or empty answer spends.
- Repaint the last AI reply when a small pan leaves the context unchanged.

Cached-only hosts and voice context lookups are unchanged.
2026-10-28 14:59:33 -07:00

1129 lines
42 KiB
JavaScript

import { applicationServices } from './services/application.js';
/**
* @module hud
* @description Intelligence HUD Overlay — NRO/NGA Satellite Aesthetic.
*
* Renders authentic reconnaissance metadata over the Cesium canvas:
* classification banners, live MGRS/lat-lon coordinates, sensor metrics
* (GSD, NIIRS, ONA), timestamps, and orbital data — all updating in
* real-time at configurable cadences.
*
* The HUD auto-activates when a military-style shader (NVG, FLIR, CRT) is
* selected and supports three layout variants: tactical, operator, minimal.
*
* Color theming is driven by the active shader mode via CSS custom properties.
*/
import * as Cesium from 'cesium';
import { greatCircleKm } from './geoDistance.js';
import { forward as toMGRS } from 'mgrs';
import { CITY_POIS } from './cityPois.js';
import { composeLocalityTag } from './hudLocality.js';
import { hudQuantity, LOCALIZED_LOCALITY } from './hudLocalityFormat.js';
import {
ellipsoidalToMslDisplayM,
ensureGeoidReady,
geoidHeight,
} from './data/geoid.js';
import { getBasemapLabelContext } from './voice/gevActions.js';
import {
hudSummaryMatchesProvenance,
hudSummaryLayerContext,
hudTelemetryProvenance,
isHudSummaryUnconfigured,
} from './hudSummaryResponse.js';
import {
applyHudUiTheme,
DEFAULT_HUD_LAYOUT,
normalizeHudLayout,
} from './hudLayouts.js';
import { formatNumber, msg, t } from './i18n/index.js';
import { localizeLayerName } from './ui/layerPanel.js';
import { isEmbeddedInline } from './app/embed.js';
/** Color palettes keyed by shader mode; applied as CSS custom properties. */
const HUD_COLORS = {
surveillance: {
main: 'rgba(51, 255, 51, 0.8)',
glow: 'rgba(51, 255, 51, 0.5)',
border: 'rgba(51, 255, 51, 0.2)',
},
thermal: {
main: 'rgba(255, 255, 255, 0.7)',
glow: 'rgba(255, 255, 255, 0.4)',
border: 'rgba(255, 255, 255, 0.15)',
},
retro: {
main: 'rgba(255, 170, 0, 0.8)',
glow: 'rgba(255, 170, 0, 0.5)',
border: 'rgba(255, 170, 0, 0.2)',
},
_default: {
main: 'rgba(0, 255, 255, 0.6)',
glow: 'rgba(0, 255, 255, 0.4)',
border: 'rgba(0, 255, 255, 0.15)',
},
};
/**
* Mode names per style: the same English as STYLE_STATUS_LABELS
* (ui/visualPresets.js), so both translate alike.
*/
const MODE_LABELS = Object.freeze({
normal: msg('NORMAL'),
retro: msg('CRT'),
surveillance: msg('NVG'),
thermal: msg('FLIR'),
anime: msg('ANIME'),
noir: msg('NOIR'),
snow: msg('SNOW'),
});
/** Region ids from _regionLabel() and the name shown for each. */
const REGION_LABELS = Object.freeze({
ARCTIC: msg('ARCTIC'),
ANTARCTIC: msg('ANTARCTIC'),
'NORTH AMERICA': msg('NORTH AMERICA'),
'SOUTH AMERICA': msg('SOUTH AMERICA'),
EUROPE: msg('EUROPE'),
AFRICA: msg('AFRICA'),
ASIA: msg('ASIA'),
OCEANIA: msg('OCEANIA'),
'NORTHERN OCEANIC GRID': msg('NORTHERN OCEANIC GRID'),
'SOUTHERN OCEANIC GRID': msg('SOUTHERN OCEANIC GRID'),
});
/** Feed-state ids from hudTelemetryProvenance() and the word shown for each. */
const FEED_STATE_LABELS = Object.freeze({
loading: msg('LOADING'),
degraded: msg('DEGRADED'),
stale: msg('STALE'),
partial: msg('PARTIAL'),
fallback: msg('FALLBACK'),
unavailable: msg('UNAVAILABLE'),
});
/**
* The summary's feed-state suffix in the active language, e.g.
* `UNAVAILABLE: LIVE AIS VESSELS`. State ids and layer names stay English in
* layer data; only this line translates them.
*/
function localizedProvenanceTag(provenance) {
if (!provenance) return null;
const state = FEED_STATE_LABELS[provenance.state]
? t(FEED_STATE_LABELS[provenance.state])
: provenance.state.toUpperCase();
if (!provenance.names.length) return state;
return t('{state} {layers}', {
state,
layers: provenance.names
.map((name) => localizeLayerName(name).toUpperCase())
.join('/'),
});
}
/** The name shown for a _viewBand() id. */
function viewBandLabel(band) {
switch (band) {
case 'STREET':
return t('STREET', { context: 'view band' });
case 'CITY':
return t('CITY', { context: 'view band' });
case 'METRO':
return t('METRO', { context: 'view band' });
case 'REGIONAL':
return t('REGIONAL', { context: 'view band' });
default:
return t('GLOBAL', { context: 'view band' });
}
}
/** Shader modes that automatically show the HUD overlay. */
const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']);
const HUD_SUMMARY_INTERVAL_MS = 15000;
/**
* Cell size (degrees) for the ALT readout's geoid-undulation cache. N changes
* by well under a metre across 0.01° (~1.1 km), so one lookup per cell keeps
* the 4 Hz telemetry tick off the EGM96 grid without a visible step.
*/
const HUD_GEOID_CELL_DEG = 0.01;
/** Flattened list of all city POIs for nearest-point lookups. */
const NEARBY_POINTS = Object.values(CITY_POIS).flatMap((city) =>
city.pois.map((poi) => ({
city: city.name,
poi: poi.name,
lat: poi.lat,
lon: poi.lon,
})),
);
/**
* Full-screen intelligence HUD overlay rendered on top of the Cesium canvas.
*
* Displays classification banners, MGRS/lat-lon readouts, sensor metrics
* (GSD, NIIRS, off-nadir angle), sun elevation, orbital metadata, and a
* rolling semantic summary line. All values derive from the live camera
* position and update on independent timer cadences.
*/
export class IntelHUD {
/**
* @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for
* camera telemetry and coordinate derivation.
*/
constructor(
viewer,
{
placeSearch,
summaryPolicy = {},
basemapContext = {},
summaryService = applicationServices.summary,
} = {},
) {
this.summaryService = summaryService;
this.summaryPolicy = summaryPolicy;
this.basemapContext = basemapContext;
this.placeSearch = placeSearch;
this.viewer = viewer;
this._visible = false;
this._autoMode = true; // auto show/hide based on style
this._currentStyle = 'normal';
this._el = null;
this._variant = DEFAULT_HUD_LAYOUT;
this._recBlinkState = true;
this._updateInterval = null;
this._recBlinkInterval = null;
this._timestampInterval = null;
this._summaryInterval = null;
this._summaryTypingInterval = null;
this._latestMetrics = null;
this._dataManager = null;
this._dataManagerUnsubscribe = null;
this._summaryDirty = true;
this._summaryRequest = null;
this._summaryUnconfigured = false;
// The last place lookup and the view it answered for (see
// getBasemapLabelContext), reused until the view changes enough.
this._placeLabels = null;
this._lastSummarySignature = '';
/** The reply painted for `_lastSummarySignature`, when it was accepted. */
this._lastSummaryText = null;
this._summaryRevision = 0;
// One-shot guards so the very first summary lands immediately instead of
// waiting for the 15s interval tick: B) swap the "Awaiting telemetry..."
// placeholder for the deterministic line as soon as metrics exist, then
// A) kick a real AI summary once the intro fly-to settles.
this._firstMetricsShown = false;
this._firstSummaryKicked = false;
// ALT readout datum: the camera height Cesium reports is ELLIPSOIDAL, the
// number a viewer reads is MSL. N comes from the same lazy ~2.7 MB EGM96
// chunk the flight layers use — requested on the first telemetry tick of a
// VISIBLE HUD, never at construction, so a hidden HUD costs nothing — and
// cached per coarse cell. Until it resolves, or if it never does,
// `ellipsoidalToMslDisplayM` passes the raw height straight through.
this._geoidRequested = false;
this._geoidReady = false;
this._geoidCellKey = null;
this._geoidN = null;
// Whether the LAST painted tick actually had N. The grid resolves mid-
// session, so this flips once — and both altitude readouts have to move
// together when it does (see the repaint in _updateCameraData).
this._geoidCorrectionApplied = false;
// A summary built for one view must not land on the next: a request in
// flight when the camera starts moving is aborted and nothing it ends
// with paints (the revision moves), a reply still typing stops, and the
// summary is marked dirty so the next periodic tick asks about the view
// the camera has moved to.
this._onCameraMoveStart = () => {
this._markSummaryDirty();
if (this._summaryRequest || this._summaryTypingInterval) {
// Cancelled requests and partially typed replies are not reusable answers.
this._lastSummarySignature = null;
}
this._summaryRequest?.abort();
this._stopSummaryTyping();
};
this._onCameraMoveEnd = () => {
this._markSummaryDirty();
// The 250 ms telemetry timer and 15 s semantic-summary timer must not
// leave the prior city on-screen after a long CCTV focus flight.
// Refresh deterministic camera context synchronously on settle; the AI
// summary can still upgrade it on its normal cadence.
if (this._visible) {
this._updateCameraData();
this._setSummaryText(this._composeSummary(), false);
}
// First settled view: request the AI summary now rather than waiting for
// the periodic tick (saves up to ~15s of "Awaiting telemetry...").
if (!this._firstSummaryKicked && this._visible && this._latestMetrics) {
this._firstSummaryKicked = true;
void this._updateSummary(true, true);
}
};
// Session-consistent pseudorandom identifiers (generated once at construction)
this._missionId = `KH11-${4000 + Math.floor(Math.random() * 200)}`;
this._sensorId = `OPS-${4100 + Math.floor(Math.random() * 100)}`;
this._orbitNum = 47000 + Math.floor(Math.random() * 1000);
this._passNum = 100 + Math.floor(Math.random() * 200);
this._buildDOM();
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.
*/
_buildDOM() {
this._el = document.getElementById('intel-hud');
if (!this._el) return;
// i18n-ignore: sensor codes and a classification banner; the rest uses t()
this._el.innerHTML = `
<div class="hud-sonar" aria-hidden="true"></div>
<div class="hud-top-bar">
<span class="hud-readout"><span class="hud-top-bar-left">TOP SECRET // SI-TK // NOFORN</span></span>
<span class="hud-readout"><span class="hud-top-bar-center">${this._missionId}</span></span>
<span class="hud-readout"><span class="hud-top-bar-right">PAGE 1/1</span></span>
</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">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;
}
/**
* Start all periodic update timers (timestamp, REC blink, camera
* telemetry, semantic summary). Timers run independently at different
* cadences and are cleaned up in {@link destroy}.
*/
_startTimers() {
// Timestamp — every second
this._timestampInterval = setInterval(() => {
const el = document.getElementById('hud-timestamp');
if (el) el.textContent = 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';
}, 800);
// Camera-derived data — 4 updates/second (250ms). Skipped in a hidden
// page, except an inline panel, whose host may report it hidden while it
// is on screen (see keepPanelRendering in app/embed.js).
this._updateInterval = setInterval(() => {
if (!this._visible || (document.hidden && !isEmbeddedInline())) return;
this._updateCameraData();
}, 250);
// Semantic summary refresh cadence
this._summaryInterval = setInterval(() => {
if (!this._visible) return;
void this._updateSummary(true);
}, HUD_SUMMARY_INTERVAL_MS);
}
/**
* Format the current wall-clock time as a UTC Zulu string.
* @returns {string} Timestamp in `YYYY-MM-DD HH:MM:SSZ` format.
*/
_formatUTC() {
const now = new Date();
const y = now.getUTCFullYear();
const mo = String(now.getUTCMonth() + 1).padStart(2, '0');
const d = String(now.getUTCDate()).padStart(2, '0');
const h = String(now.getUTCHours()).padStart(2, '0');
const mi = String(now.getUTCMinutes()).padStart(2, '0');
const s = String(now.getUTCSeconds()).padStart(2, '0');
return `${y}-${mo}-${d} ${h}:${mi}:${s}Z`;
}
/**
* Geoid undulation N at the camera subpoint, memoized per coarse cell.
* @param {number} latDeg - Camera latitude in decimal degrees.
* @param {number} lonDeg - Camera longitude in decimal degrees.
* @returns {number|null} N in metres, or null while the grid is unavailable.
*/
_geoidUndulationM(latDeg, lonDeg) {
if (!this._geoidReady) {
if (!this._geoidRequested) {
this._geoidRequested = true;
ensureGeoidReady()
.then(() => {
this._geoidReady = true;
})
.catch(() => {
/* readout falls back to the uncorrected height */
});
}
return null;
}
const key = `${Math.round(latDeg / HUD_GEOID_CELL_DEG)}:${Math.round(lonDeg / HUD_GEOID_CELL_DEG)}`;
if (key !== this._geoidCellKey) {
try {
this._geoidN = geoidHeight(latDeg, lonDeg);
} catch {
this._geoidN = null;
}
this._geoidCellKey = key;
}
return Number.isFinite(this._geoidN) ? this._geoidN : null;
}
/**
* Derive all camera-based telemetry and push values to the DOM.
* Reads the viewer camera's cartographic position and computes MGRS,
* lat/lon DMS, GSD, NIIRS, sun elevation, off-nadir angle, and
* collection timestamp. Stores results in {@link _latestMetrics}.
*/
_updateCameraData() {
const camera = this.viewer.camera;
const cartographic = camera.positionCartographic;
if (!cartographic) return;
const lonDeg = Cesium.Math.toDegrees(cartographic.longitude);
const latDeg = Cesium.Math.toDegrees(cartographic.latitude);
const altM = cartographic.height;
const latDMS = this._toDMS(latDeg, 'lat');
const lonDMS = this._toDMS(lonDeg, 'lon');
let mgrsLabel = '---';
// MGRS
try {
const mgrsStr = toMGRS([lonDeg, latDeg], 4); // 4 = 10m precision
// 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: ---';
}
// 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}`;
}
// GSD (Ground Sample Distance): approximate resolution in meters per pixel
// derived from camera altitude. NIIRS (National Imagery Interpretability
// Rating Scale): 0-9 quality rating computed via the General Image Quality
// Equation (GIQE) simplified form: NIIRS = 10.25 - 3.32 * log10(GSD_inches).
const gsd = Math.max(0.01, altM * 0.000375);
const gsdInches = gsd * 39.37;
const niirs = Math.max(
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)}`;
// 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`;
// 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`;
}
// 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)}°`;
// `altM` stays the raw ellipsoidal camera height the sensor model reads
// (GSD/NIIRS, view band). `altMslM` is the ADDITIVE display datum — the
// only one any readout string should print.
this._latestMetrics = {
latDeg,
lonDeg,
altM,
altMslM,
sunEl,
ona,
};
// First time we have real telemetry: replace the "Awaiting telemetry..."
// placeholder with the deterministic summary line instantly (no network),
// so there's always meaningful context on screen. The AI summary upgrades
// this within a second via the moveEnd kick / periodic refresh.
if (!this._firstMetricsShown) {
this._firstMetricsShown = true;
this._setSummaryText(this._composeSummary(), false);
}
// The EGM96 grid lands mid-session, and the corner readout picks it up on
// the very next telemetry tick. The summary line has no such cadence — it
// repaints on camera settle or its own 15 s retry — so without this the
// corner reads `ALT: 17m` beside a summary still reading `ALT -15M`, for
// up to fifteen seconds. Repaint the deterministic line in the SAME tick
// the correction turns on (or off, if a lookup starts failing), and mark
// the summary dirty so the AI line refreshes on its normal cadence —
// exactly what a camera settle already does.
const geoidCorrectionApplied = Number.isFinite(geoidN);
if (geoidCorrectionApplied !== this._geoidCorrectionApplied) {
this._geoidCorrectionApplied = geoidCorrectionApplied;
this._markSummaryDirty();
this._setSummaryText(this._composeSummary(), false);
}
}
/**
* Insert spaces into a raw MGRS string for human-readable display.
* @param {string} mgrs - Raw MGRS string, e.g. `"18SUJ23370716"`.
* @returns {string} Formatted string, e.g. `"18S UJ 2337 0716"`.
*/
_formatMGRS(mgrs) {
// Regex captures: grid zone designator (1-2 digits + band letter),
// 100km square ID (2 letters), numeric easting+northing (split in half).
const match = mgrs.match(/^(\d{1,2}[A-Z])\s*([A-Z]{2})\s*(\d+)$/);
if (!match) return mgrs;
const [, zone, square, coords] = match;
const half = coords.length / 2;
const easting = coords.slice(0, half);
const northing = coords.slice(half);
return `${zone} ${square} ${easting} ${northing}`;
}
/**
* Convert a decimal-degree value to a degrees-minutes-seconds string.
* @param {number} decimal - Coordinate in decimal degrees.
* @param {'lat'|'lon'} type - Axis selector; controls hemisphere letter
* and zero-padding width (2 digits for lat, 3 for lon).
* @returns {string} Formatted DMS string, e.g. `"38°53'23.10"N"`.
*/
_toDMS(decimal, type) {
// Round once, in hundredths of a second, so a value that rounds up to
// 60.00" carries into the minutes and degrees instead of printing 60.
const hundredths = Math.round(Math.abs(decimal) * 360000);
const deg = Math.floor(hundredths / 360000);
const min = Math.floor(hundredths / 6000) % 60;
const sec = ((hundredths % 6000) / 100).toFixed(2);
let dir;
if (type === 'lat') dir = decimal >= 0 ? 'N' : 'S';
else dir = decimal >= 0 ? 'E' : 'W';
const degStr =
type === 'lon'
? String(deg).padStart(3, '0')
: String(deg).padStart(2, '0');
return `${degStr}°${String(min).padStart(2, '0')}'${String(sec).padStart(5, '0')}"${dir}`;
}
/**
* Estimate current solar elevation angle above the horizon.
*
* Uses a simplified astronomical model: solar declination is approximated
* from the day of year, and elevation is derived from the standard
* sin(elevation) formula involving latitude, declination, and hour angle.
*
* @param {number} lat - Observer latitude in decimal degrees.
* @param {number} lon - Observer longitude in decimal degrees.
* @returns {number} Estimated sun elevation in degrees (negative = below horizon).
*/
_estimateSunElevation(lat, lon) {
const now = new Date();
// Approximate local solar time by shifting UTC hours by longitude offset
const hours = now.getUTCHours() + now.getUTCMinutes() / 60 + lon / 15;
const solarNoon = 12;
const hourAngle = (hours - solarNoon) * 15;
// Solar declination approximation (~23.45 deg amplitude sinusoidal over the year)
const declination =
23.45 *
Math.sin(
Cesium.Math.toRadians(
(360 / 365) * (now.getUTCDate() + 30 * now.getUTCMonth() - 81),
),
);
const latRad = Cesium.Math.toRadians(lat);
const decRad = Cesium.Math.toRadians(declination);
const haRad = Cesium.Math.toRadians(hourAngle);
// Standard formula: sin(el) = sin(lat)*sin(dec) + cos(lat)*cos(dec)*cos(ha)
const sinEl =
Math.sin(latRad) * Math.sin(decRad) +
Math.cos(latRad) * Math.cos(decRad) * Math.cos(haRad);
// Clamp to [-1,1] to guard against floating-point drift before asin
return Cesium.Math.toDegrees(Math.asin(Math.max(-1, Math.min(1, sinEl))));
}
/**
* Classify the camera altitude into a named observation band.
* @param {number} altM - Camera altitude in meters.
* @returns {'STREET'|'CITY'|'METRO'|'REGIONAL'|'GLOBAL'} Band label.
*/
_viewBand(altM) {
if (altM < 1200) return 'STREET';
if (altM < 5000) return 'CITY';
if (altM < 30000) return 'METRO';
if (altM < 250000) return 'REGIONAL';
return 'GLOBAL';
}
/**
* Return a coarse geographic region label based on lat/lon bounding boxes.
* @param {number} lat - Latitude in decimal degrees.
* @param {number} lon - Longitude in decimal degrees.
* @returns {string} Region name (e.g. `"EUROPE"`, `"NORTHERN OCEANIC GRID"`).
*/
_regionLabel(lat, lon) {
if (lat > 72) return 'ARCTIC';
if (lat < -60) return 'ANTARCTIC';
if (lat >= 5 && lat <= 83 && lon >= -170 && lon <= -50)
return 'NORTH AMERICA';
if (lat >= -60 && lat <= 15 && lon >= -90 && lon <= -30)
return 'SOUTH AMERICA';
if (lat >= 34 && lat <= 72 && lon >= -25 && lon <= 45) return 'EUROPE';
if (lat >= -35 && lat <= 38 && lon >= -20 && lon <= 55) return 'AFRICA';
if (lat >= 5 && lat <= 80 && lon >= 45 && lon <= 180) return 'ASIA';
if (lat >= -50 && lat <= 5 && lon >= 110 && lon <= 180) return 'OCEANIA';
return lat >= 0 ? 'NORTHERN OCEANIC GRID' : 'SOUTHERN OCEANIC GRID';
}
/**
* Compute the approximate width and height (in km) of the camera's
* current view rectangle on the ground.
* @param {number} latDeg - Center latitude in decimal degrees (for
* longitude-to-km cosine correction).
* @returns {{ widthKm: number, heightKm: number }|null} View window
* dimensions, or null if the view rectangle cannot be computed.
*/
_viewWindowKm(latDeg) {
const rect = this.viewer.camera.computeViewRectangle();
if (!rect) return null;
const north = Cesium.Math.toDegrees(rect.north);
const south = Cesium.Math.toDegrees(rect.south);
let east = Cesium.Math.toDegrees(rect.east);
let west = Cesium.Math.toDegrees(rect.west);
let lonSpan = Math.abs(east - west);
// Handle antimeridian wrap: if span exceeds 180 deg, take the shorter arc
if (lonSpan > 180) lonSpan = 360 - lonSpan;
const latSpan = Math.abs(north - south);
// 111 km/deg is the approximate surface distance per degree of latitude;
// longitude distance is scaled by cos(lat) to account for meridian convergence.
const widthKm = Math.max(
0,
lonSpan * 111 * Math.cos(Cesium.Math.toRadians(latDeg)),
);
const heightKm = Math.max(0, latSpan * 111);
return { widthKm, heightKm };
}
/**
* Compute the great-circle distance between two geographic points
* using the Haversine formula.
* @param {number} lat1 - Start latitude (decimal degrees).
* @param {number} lon1 - Start longitude (decimal degrees).
* @param {number} lat2 - End latitude (decimal degrees).
* @param {number} lon2 - End longitude (decimal degrees).
* @returns {number} Distance in kilometers.
*/
_haversineKm(lat1, lon1, lat2, lon2) {
return greatCircleKm({ lat: lat1, lon: lon1 }, { lat: lat2, lon: lon2 });
}
/**
* Find the closest known POI to the given coordinates.
* @param {number} latDeg - Latitude in decimal degrees.
* @param {number} lonDeg - Longitude in decimal degrees.
* @returns {{ city: string, poi: string, lat: number, lon: number, distKm: number }|null}
* Nearest point with distance, or null if no POIs are loaded.
*/
_nearestKnownPoint(latDeg, lonDeg) {
let best = null;
for (const point of NEARBY_POINTS) {
const distKm = this._haversineKm(latDeg, lonDeg, point.lat, point.lon);
if (!best || distKm < best.distKm) {
best = { ...point, distKm };
}
}
return best;
}
/**
* Build the one-line semantic summary string from the latest camera metrics.
* Includes mode, observation band, nearest POI or lat/lon sector, region,
* altitude, view window dimensions, sun elevation, ONA, and local timezone.
* @returns {string} Formatted summary line for the HUD summary readout.
*/
_composeSummary() {
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 region = this._regionLabel(m.latDeg, m.lonDeg);
const nearest = this._nearestKnownPoint(m.latDeg, m.lonDeg);
const band = this._viewBand(m.altM);
const window = this._viewWindowKm(m.latDeg);
// Rough local timezone from longitude (15 deg per hour)
const utcOffset = Math.round(m.lonDeg / 15);
const localTag = `UTC${utcOffset >= 0 ? '+' : ''}${utcOffset}`;
// Same MSL datum as the corner ALT readout — the two are on screen
// together, so they must never disagree. The view band above deliberately
// keeps the ellipsoidal height: its thresholds were tuned against it.
const altDisplayM = Number.isFinite(m.altMslM) ? m.altMslM : m.altM;
// Display only: the AI summary request carries its own context, so this
// line is built in the active language.
const altTag =
altDisplayM >= 1000
? hudQuantity(altDisplayM / 1000, 'km', 1)
: hudQuantity(Math.round(altDisplayM), 'm');
const winTag = window
? `${hudQuantity(Math.max(1, Math.round(window.widthKm)), '')}x${hudQuantity(Math.max(1, Math.round(window.heightKm)), 'km')}`
: t('N/A');
// NEAR the nearest catalogued POI at metro range; otherwise the lat/lon sector.
const localityTag = composeLocalityTag(
nearest,
m.latDeg,
m.lonDeg,
LOCALIZED_LOCALITY,
);
const provenance = localizedProvenanceTag(
hudTelemetryProvenance(this._dataManager?.getAll?.() || []),
);
// ALT, SUN and ONA are sensor codes, as in the corner readouts.
const line = t(
'{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}',
{
mode: modeLabel,
band: viewBandLabel(band),
locality: localityTag,
region: REGION_LABELS[region] ? t(REGION_LABELS[region]) : region,
alt: altTag,
window: winTag,
sun: formatNumber(Math.round(m.sunEl)),
ona: formatNumber(Math.round(m.ona)),
zone: localTag,
},
);
return provenance ? `${line} | ${provenance}` : line;
}
/** Stop a summary still typing out, leaving what it has typed so far. */
_stopSummaryTyping() {
clearInterval(this._summaryTypingInterval);
this._summaryTypingInterval = null;
}
/**
* Animate the summary text into the DOM using a typewriter effect
* (2 characters every 24ms).
* @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._summaryTypingInterval = setInterval(() => {
index += 2;
if (index >= text.length) {
el.textContent = text;
clearInterval(this._summaryTypingInterval);
this._summaryTypingInterval = null;
return;
}
el.textContent = text.slice(0, index);
}, 24);
}
/**
* Refresh the summary readout. Optionally animates the text via typewriter.
* @param {boolean} [animate=false] - If true, types the summary character
* by character; otherwise sets it instantly.
*/
async _updateSummary(animate = false, force = false) {
const fallbackText = this._composeSummary();
if (!this._latestMetrics) {
this._setSummaryText(fallbackText, animate);
return;
}
if (!force && !this._summaryDirty) return;
if (this.summaryPolicy.canRequest?.() === false) return;
const revision = this._summaryRevision;
// The place lookups below are paid Google requests made only to feed the
// AI summary, so a server without one must not be asked to make them.
if (!(await this._summaryAvailable())) {
if (revision !== this._summaryRevision) return;
this._summaryDirty = false;
this._setSummaryText(fallbackText, animate);
return;
}
if (revision !== this._summaryRevision) return;
// Every caller invokes this as `void this._updateSummary(...)`, so nothing
// owns the returned promise — a rejection escaping from here lands as an
// unhandled rejection in the console. Building the context walks the live
// scene (the view-target pick, the layer roster), so it belongs INSIDE a
// guard rather than in front of one. A summary we cannot build is a
// fallback line, not a crash.
let context;
try {
context = await this._summaryContext();
} catch (error) {
console.warn('[HUD] summary context unavailable:', error);
// Left dirty on purpose: the next periodic tick retries instead of
// sticking on the fallback line for the rest of the session. The
// fallback was composed for this view; a moved camera keeps its own.
if (revision === this._summaryRevision)
this._setSummaryText(fallbackText, animate);
return;
}
if (revision !== this._summaryRevision) return;
const signature = JSON.stringify(context);
if (!force && signature === this._lastSummarySignature) {
this._summaryDirty = false;
// A settle painted the fallback line over the reply this context
// already has; reused place labels make that common, so restore it.
if (this._lastSummaryText) this._setSummaryText(this._lastSummaryText);
return;
}
if (this._summaryRequest) return;
if (force) this._setSummaryText(fallbackText, false);
this._summaryDirty = false;
this._lastSummarySignature = signature;
this._lastSummaryText = null;
const controller = new AbortController();
let timedOut = false;
const timeout = window.setTimeout(() => {
timedOut = true;
controller.abort();
}, 5000);
this._summaryRequest = controller;
try {
this.summaryPolicy.onRequest?.();
const response = await this.summaryService.summarize(context, {
signal: controller.signal,
});
const data = response.data;
if (revision !== this._summaryRevision) return;
if (isHudSummaryUnconfigured(response.status, data)) {
this._summaryUnconfigured = true;
this._setSummaryText(fallbackText, animate);
return;
}
if (!response.ok || !data?.summary) {
throw new Error(data?.error || `HTTP ${response.status}`);
}
if (hudSummaryMatchesProvenance(data.summary, context.feedProvenance))
this._lastSummaryText = data.summary;
this._setSummaryText(this._lastSummaryText || fallbackText, animate);
} catch (error) {
// The 5 s timeout is a failure like any other; a move's abort (or
// destroy's) is not.
const moved = revision !== this._summaryRevision;
if (error?.name !== 'AbortError' || timedOut) {
console.warn('[HUD] AI summary unavailable:', error);
// Invalidate the committed signature so the next periodic tick
// retries instead of sticking on the fallback line forever.
this._lastSummarySignature = null;
this._summaryDirty = true;
}
if (!moved) this._setSummaryText(fallbackText, animate);
} finally {
window.clearTimeout(timeout);
if (this._summaryRequest === controller) this._summaryRequest = null;
// A request whose answer was dropped (aborted by a move, or a reply
// that came back after one) committed a signature it never painted;
// forget it, or the next tick over the same place would skip asking.
if (revision !== this._summaryRevision) this._lastSummarySignature = null;
}
}
_setSummaryText(text, animate) {
if (animate) {
this._typeSummary(text);
return;
}
// A plain paint replaces any reply still typing.
this._stopSummaryTyping();
const el = document.getElementById('hud-summary');
if (el) el.textContent = text;
}
/**
* False once the summary service reports no AI summary: by its capability
* probe, when it has one, or by a deliberate unconfigured reply. Unknown
* stays available, as before.
*/
async _summaryAvailable() {
if (this._summaryUnconfigured) return false;
try {
if ((await this.summaryService?.configured?.()) === false)
this._summaryUnconfigured = true;
} catch {
/* an unanswered probe leaves the summary as it was */
}
return !this._summaryUnconfigured;
}
async _summaryContext() {
const revision = this._summaryRevision;
const labels = await getBasemapLabelContext(this.viewer, this.placeSearch, {
...this.basemapContext,
previous: this._placeLabels,
});
// A slower build for a view the camera has left must not replace the
// newer view's lookup.
if (revision === this._summaryRevision) this._placeLabels = labels;
const enabledLayers =
this._dataManager
?.getAll?.()
?.filter((layer) => layer.enabled)
.map((layer) => layer.name) || [];
return {
placeLabels: labels.placeLabels,
streetLabels: labels.streetLabels,
nearbyPlaceLabels: labels.nearbyPlaceLabels,
enabledLayerLabels: enabledLayers,
...hudSummaryLayerContext(this._dataManager?.getAll?.() || []),
};
}
_markSummaryDirty() {
this._summaryDirty = true;
this._summaryRevision++;
}
// ── Public API ──────────────────────────
/**
* React to a shader-style change. Updates the mode label, HUD color
* scheme (via CSS custom properties), and auto-shows/hides the overlay
* when in auto mode.
* @param {string} styleName - Active style key (e.g. `'surveillance'`,
* `'thermal'`, `'retro'`, `'normal'`).
*/
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
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);
}
// Auto show/hide
if (this._autoMode) {
if (MILITARY_STYLES.has(styleName)) {
this.show();
} else {
this.hide();
}
}
}
/** Make the HUD visible and immediately refresh all readouts. */
show() {
this._visible = true;
if (this._el) this._el.classList.add('active');
this._updateCameraData(); // immediate update
this._markSummaryDirty();
void this._updateSummary(false, true);
}
/** Hide the HUD overlay. */
hide() {
this._visible = false;
if (this._el) this._el.classList.remove('active');
}
/** Toggle HUD visibility and disable auto-mode (user override). */
toggle() {
if (this._visible) {
this._autoMode = false; // user override
this.hide();
} else {
this._autoMode = false;
this.show();
}
}
/**
* Explicit HUD mode control for scene/recording playback.
* @param {'auto'|'on'|'off'} mode - `'auto'` re-enables style-driven
* show/hide; `'on'`/`'off'` force visibility and disable auto-mode.
*/
setMode(mode) {
if (mode === 'auto') {
this._autoMode = true;
this.onStyleChange(this._currentStyle);
return;
}
this._autoMode = false;
if (mode === 'on') this.show();
else this.hide();
}
/**
* Switch the HUD layout variant. Falls back to `'tactical'` if the
* name is unrecognized. Cyber also applies a coordinated skin to the full
* application shell; leaving Cyber restores the standard shell tokens.
* @param {string} variantName - One of `'tactical'`, `'operator'`, `'minimal'`, `'cyber'`.
*/
setVariant(variantName) {
this._variant = normalizeHudLayout(variantName);
if (this._el) {
this._el.dataset.variant = this._variant;
}
applyHudUiTheme(document.documentElement, this._variant);
}
/**
* @returns {string} The current HUD layout variant name.
*/
getVariant() {
return this._variant;
}
/**
* @returns {'auto'|'on'|'off'} Current HUD mode — `'auto'` when style-driven
* show/hide is active, otherwise the explicit visibility override.
*/
getMode() {
if (this._autoMode) return 'auto';
return this._visible ? 'on' : 'off';
}
/** @returns {boolean} Whether the HUD is currently visible. */
get visible() {
return this._visible;
}
attachDataManager(dataManager) {
if (this._dataManagerUnsubscribe) {
this._dataManagerUnsubscribe();
this._dataManagerUnsubscribe = null;
}
this._dataManager = dataManager || null;
if (typeof this._dataManager?.subscribe === 'function') {
this._dataManagerUnsubscribe = this._dataManager.subscribe((change) => {
if (
['visibility', 'refresh-transition', 'refresh-cancelled'].includes(
change?.type,
)
)
this._markSummaryDirty();
});
}
this._markSummaryDirty();
}
/** Tear down all running intervals. Call when discarding the HUD instance. */
destroy() {
clearInterval(this._updateInterval);
clearInterval(this._recBlinkInterval);
clearInterval(this._timestampInterval);
clearInterval(this._summaryInterval);
clearInterval(this._summaryTypingInterval);
this.viewer.camera.moveStart.removeEventListener(this._onCameraMoveStart);
this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd);
this._dataManagerUnsubscribe?.();
this._summaryRequest?.abort();
}
}