refactor(app): share application components and request services

This commit is contained in:
Sameh Khamis committed 2026-09-13 22:41:11 -07:00
1 parent a0a0128a59
commit a67bc60a29
84 files changed
+2798 -1731

No files matched your search

+2
View File
@@ -1,5 +1,7 @@
# Changelog # Changelog
- Split application scene, controls, catalog, tools and HTML into reusable components; configure application request services and sources without changing global fetch. Preserve standalone markup and voice behavior. Explicit annotation navigation may resolve a distant named target.
## Voice component boundaries ## Voice component boundaries
- Separate voice controls, Realtime connection requests and the action runner. - Separate voice controls, Realtime connection requests and the action runner.
+40
View File
@@ -0,0 +1,40 @@
import { readFileSync } from 'node:fs';
export const APPLICATION_TEMPLATES = Object.freeze([
'scene-chrome',
'cockpit',
'display-controls',
'command-dock',
'layer-panels',
'context',
'welcome',
'provider-settings',
'hud-loading',
]);
const allowed = new Set(APPLICATION_TEMPLATES);
/** Expand only known component templates; markers cannot name filesystem paths. */
export function expandApplicationHtml(html) {
return html.replace(
/^[ \t]*<!-- gev:template ([^\s]+) -->\r?\n?/gm,
(_, name) => {
if (!allowed.has(name))
throw new Error(`Unknown application template: ${name}`);
return readFileSync(
new URL(`../src/ui/templates/${name}.html`, import.meta.url),
'utf8',
);
},
);
}
/** Assemble static application markup before Vite processes scripts and assets. */
export function applicationHtmlPlugin() {
return {
name: 'application-component-templates',
transformIndexHtml: {
order: 'pre',
handler: expandApplicationHtml,
},
};
}
+4 -1
View File
@@ -1,15 +1,18 @@
import { applicationHtmlPlugin } from './application-html.js';
import cesium from 'vite-plugin-cesium'; import cesium from 'vite-plugin-cesium';
/** Build browser assets with explicit inputs; never load environment or providers. */ /** Build browser assets with explicit inputs; never load environment or providers. */
export function createBrowserViteConfig({ export function createBrowserViteConfig({
plugins = [], plugins = [],
publicDir,
googleApiKey, googleApiKey,
cesiumToken, cesiumToken,
host = 'localhost', host = 'localhost',
port = 4173, port = 4173,
} = {}) { } = {}) {
return { return {
plugins: [cesium(), ...plugins], plugins: [cesium(), applicationHtmlPlugin(), ...plugins],
...(publicDir === undefined ? {} : { publicDir }),
server: { server: {
host: host || 'localhost', host: host || 'localhost',
port: parseInt(port, 10) || 4173, port: parseInt(port, 10) || 4173,
+24
View File
@@ -172,3 +172,27 @@ existing environment variables remain the default configuration. Model choices
come from server configuration. Keep secret keys in the server adapter. come from server configuration. Keep secret keys in the server adapter.
Tool schemas, model defaults and cost estimates are unchanged. Unknown model Tool schemas, model defaults and cost estimates are unchanged. Unknown model
IDs retain the existing conservative estimate until their rates are registered. IDs retain the existing conservative estimate until their rates are registered.
### Composing the existing application components
`application/scene`, `application/controls`, `application/data` and
`application/tools` expose the existing globe, controls, catalog and tools as
separate constructors. Pass these to `createApplication`; use its `signal` and
`defer` for ownership. The compatibility catalog is still page-scoped: construct
one application per page. Controls, actions and renderers use the same layer
instances. Configure sources with `application/sources` before registration or
state restoration, and release them after the consumers stop.
`application/services` accepts boundary, terrain, regional-context, weather and
summary services. `application/requests` supplies the existing HTTP protocols
with configurable endpoints and a scoped transport. Changing an endpoint works
only for a compatible protocol; another protocol supplies a service adapter.
Cancellation discards late response bodies, and replacing a source invalidates
its pending results. Source disposal does not silently reinstate a default.
`ui/composition` supplies the default engines to the shell. Applications can
supply a HUD implementation or request policy through control services. The
shared chrome owns the welcome/loading transition; standalone composition adds
Provider Settings. `build/html` expands an allowlist of component markers from
`src/ui/templates`; unknown names cannot read arbitrary filesystem paths. The
standalone document expands to the same markup as before this extraction.
+6
View File
@@ -385,3 +385,9 @@ also checks scene ownership after asynchronous work, so late results cannot
replace a newer selection. Supplied tilesets are caller-owned; factory-created replace a newer selection. Supplied tilesets are caller-owned; factory-created
tilesets and provider caches are controller-owned. Construct a fresh controller tilesets and provider caches are controller-owned. Construct a fresh controller
for a new viewer or configuration lifetime. for a new viewer or configuration lifetime.
The application-components group assembles the existing page-scoped catalog and
its engines. Its explicit module graph includes the smaller layer/UI groups;
those independent groups retain their narrower gates. Source adapters and
request services enter through construction, without replacing global fetch.
The Node build group also owns the allowlisted static HTML template assembler.
+2
View File
@@ -1,5 +1,7 @@
# God's Eye View Current State # God's Eye View Current State
Application composition now uses shared scene/control/catalog/tool constructors and allowlisted HTML component templates. The standalone entry supplies settings and the default sources. Terrain, boundaries, weather, regional context and summaries use configurable services; renderer/action owners remain page-scoped. Annotation proximity guards remain on by default and allow distant targets only for explicit navigation.
Voice controls compose a supplied action runner and connection controller. Voice controls compose a supplied action runner and connection controller.
Realtime token and SDP requests live in a configurable backend, with independent Realtime token and SDP requests live in a configurable backend, with independent
transports and cancellation through response parsing. Stop and application transports and cancellation through response parsing. Stop and application
+9 -901
View File
@@ -13,915 +13,23 @@
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet" /> <link href="https://fonts.googleapis.com/icon?family=Material+Icons+Round" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="cesiumContainer"></div> <!-- gev:template scene-chrome -->
<div id="world-overlay-root" aria-hidden="true">
<canvas id="world-overlay-canvas"></canvas>
</div>
<div id="world-overlay-actions" class="world-overlay-accessibility" role="region" aria-label="Visible map targets">
<div id="world-overlay-action-list"></div>
<div id="world-overlay-status" role="status" aria-live="polite" aria-atomic="true"></div>
</div>
<!-- Title --> <!-- gev:template cockpit -->
<div id="title-bar">
<div class="title-glow"></div>
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span>GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
<p class="subtitle">NO PLACE LEFT BEHIND</p>
</div>
<!-- Style indicator --> <!-- gev:template display-controls -->
<div id="style-indicator">
<span class="indicator-label">ACTIVE STYLE</span>
<span class="indicator-value" id="active-style-name">NORMAL</span>
</div>
<!-- Top-center camera and share actions --> <!-- gev:template command-dock -->
<nav id="top-center-actions" aria-label="Globe actions">
<button id="clear-selected-layers" type="button" aria-label="Clear selected data layers" title="Turn off all selected data layers">
<span class="material-symbols-outlined" aria-hidden="true">layers_clear</span>
</button>
<button id="share-btn" type="button" aria-label="Copy share link" title="Copy share link">
<span class="share-icon" aria-hidden="true">&#x1F517;</span>
</button>
<button id="reset-globe-view" type="button" aria-label="Reset to full globe view" title="Reset camera and return to full globe view">
<span class="material-symbols-outlined" aria-hidden="true">public</span>
</button>
</nav>
<div id="global-loading-status" role="status" aria-live="polite" aria-atomic="true" hidden>
<span id="global-loading-label">LOADING LIVE DATA</span>
<span id="global-loading-detail"></span>
</div>
<div id="traffic-sync-chip" aria-live="polite">
<span id="traffic-sync-label">syncing road network</span>
<span id="traffic-sync-progress">0%</span>
</div>
<div id="cctv-sync-chip" aria-live="polite">
<span id="cctv-sync-label">loading frames</span>
<span id="cctv-sync-progress">0/0</span>
</div>
<!-- Toast notification --> <!-- gev:template layer-panels -->
<div id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<!-- Safe frame guides for recording composition --> <!-- gev:template context -->
<div id="safe-frame-overlay">
<div id="safe-frame-box"></div>
</div>
<!-- Aircraft first-person view. It can be entered from the visible view switcher. --> <!-- gev:template welcome -->
<section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden>
<div class="cockpit-visor-shell" aria-hidden="true">
<div class="cockpit-roll-arc">
<span style="--slot:-3;--depth:3">−30</span>
<span style="--slot:-2;--depth:2">−20</span>
<span style="--slot:-1;--depth:1">−10</span>
<span class="active" style="--slot:0;--depth:0">0</span>
<span style="--slot:1;--depth:1">+10</span>
<span style="--slot:2;--depth:2">+20</span>
<span style="--slot:3;--depth:3">+30</span>
</div>
<div class="cockpit-pitch-rail cockpit-pitch-rail-left">
<span>+20</span><span>+10</span><span class="active">00</span><span>−10</span><span>−20</span>
</div>
<div class="cockpit-pitch-rail cockpit-pitch-rail-right">
<span>+20</span><span>+10</span><span class="active">00</span><span>−10</span><span>−20</span>
</div>
<div class="cockpit-horizon-guide">
<span></span><small>LEVEL</small><span></span>
</div>
<div id="cockpit-route-direction" class="cockpit-route-direction" hidden aria-label="Estimated destination direction">
<svg class="cockpit-route-chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 2 4.58 19.93c-.34.83.51 1.63 1.31 1.27L12 18.47l6.11 2.73c.8.36 1.65-.44 1.31-1.27L12 2Z" />
</svg>
<small id="cockpit-route-direction-label">DEST ---°</small>
</div>
<div class="cockpit-visor-status cockpit-visor-status-left">OPTICAL PLANE · 01</div>
<div class="cockpit-visor-status cockpit-visor-status-right">VISOR LOCK · ACTIVE</div>
</div>
<div id="cockpit-speed-rim" class="cockpit-altitude-rim cockpit-speed-rim">
<span class="cockpit-altitude-rim-label">GROUND SPEED · KTS</span>
<div class="cockpit-altitude-rim-rail"></div>
<div class="cockpit-altitude-rim-scale">
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
</div>
<div class="cockpit-altitude-rim-pointer">
<span class="material-symbols-outlined">arrow_right</span>
<strong id="cockpit-speed-rim-value">---</strong>
</div>
</div>
<div id="cockpit-altitude-rim" class="cockpit-altitude-rim">
<span class="cockpit-altitude-rim-label">ALTITUDE · FT</span>
<div class="cockpit-altitude-rim-rail"></div>
<div class="cockpit-altitude-rim-scale">
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
</div>
<div class="cockpit-altitude-rim-pointer">
<strong id="cockpit-altitude-rim-value">-----</strong>
<span class="material-symbols-outlined">arrow_left</span>
</div>
</div>
<div class="cockpit-topline">
<div>
<span class="cockpit-kicker">FIRST PERSON</span>
<strong id="cockpit-callsign">AIRCRAFT</strong>
<span id="cockpit-aircraft-meta" class="cockpit-aircraft-meta">LIVE TRACK · COURSE ALIGNED</span>
</div>
<div class="cockpit-vision-controls" role="group" aria-label="Cockpit vision style">
<button id="cockpit-vision-previous" class="cockpit-vision-nav" type="button" aria-label="Previous cockpit vision style" title="Previous vision style">
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
<small>PREV</small>
</button>
<button id="cockpit-vision-current" class="cockpit-vision-current" type="button" data-cockpit-vision="optical" aria-label="Current cockpit vision style: NORMAL. Activate for next style." title="Current style: NORMAL — click for next">
<small>CURRENT</small>
<strong id="cockpit-vision-current-label" aria-live="polite">NORMAL</strong>
</button>
<button id="cockpit-vision-next" class="cockpit-vision-nav" type="button" aria-label="Next cockpit vision style" title="Next vision style">
<small>NEXT</small>
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
</button>
</div>
</div>
<div class="cockpit-reticle material-symbols-outlined" aria-hidden="true">my_location</div>
<div class="cockpit-compass" aria-label="Current aircraft heading">
<div class="cockpit-instrument-row">
<div class="cockpit-readout cockpit-speed">
<span class="cockpit-readout-label">GROUND SPEED</span>
<strong id="cockpit-speed-value">---</strong>
<span class="cockpit-readout-unit">KTS</span>
</div>
<div class="cockpit-heading-value"><span id="cockpit-heading-value">---</span><small>°</small></div>
<div class="cockpit-readout cockpit-altitude">
<span class="cockpit-readout-label">ALTITUDE</span>
<strong id="cockpit-altitude-value">-----</strong>
<span class="cockpit-readout-unit">FT</span>
</div>
</div>
<span class="cockpit-heading-caret material-symbols-outlined" aria-hidden="true">arrow_drop_down</span>
<div class="cockpit-compass-tape" id="cockpit-compass-tape" aria-hidden="true"></div>
</div>
<div class="cockpit-position-readout">
<span id="cockpit-clock">--:--:--Z</span>
<span id="cockpit-position">--° · --°</span>
</div>
<aside id="cockpit-context" class="cockpit-context-window" aria-label="Contact cockpit summary" hidden>
<header class="cockpit-context-header">
<div>
<span class="cockpit-context-kicker">CONTACT</span>
<strong id="cockpit-context-subject">CONTACTS · 250 KM</strong>
</div>
<nav class="cockpit-context-actions" aria-label="Contact navigation">
<button id="cockpit-context-previous" type="button" aria-label="Previous — prior visited contact in the 250 km window" title="Previous — prior visited contact in the 250 km window">
<span class="material-symbols-outlined" aria-hidden="true">skip_previous</span>
</button>
<button id="cockpit-context-next" type="button" aria-label="Next — nearest unvisited contact in the 250 km window" title="Next — nearest unvisited contact in the 250 km window">
<span class="material-symbols-outlined" aria-hidden="true">skip_next</span>
</button>
<button id="cockpit-context-toggle" type="button" aria-label="Collapse Contact panel" aria-expanded="true" title="Collapse contact panel">
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
</button>
</nav>
<span class="cockpit-context-qualifier">CONTEXT ONLY</span>
</header>
<div class="cockpit-context-cohorts" role="list" aria-label="Nearby cohort counts">
<div role="listitem" data-context-cohort="flights"><span>FLT</span><strong>—</strong><small>OpenSky</small></div>
<div role="listitem" data-context-cohort="military"><span>MIL</span><strong>—</strong><small>adsb.lol</small></div>
<div role="listitem" data-context-cohort="ais-live-vessels"><span>AIS</span><strong>—</strong><small>AISStream</small></div>
<div role="listitem" data-context-cohort="military-installations"><span>SITE</span><strong>—</strong><small>OpenStreetMap</small></div>
</div>
<div class="cockpit-context-nearest">
<span id="cockpit-context-direction" class="material-symbols-outlined" aria-hidden="true">navigation</span>
<div>
<small>NEAREST OBSERVED / MAPPED</small>
<strong id="cockpit-context-nearest-label">NO AVAILABLE EXAMPLE</strong>
</div>
<span id="cockpit-context-bearing">—</span>
<span id="cockpit-context-distance">—</span>
</div>
<footer class="cockpit-context-footer">
<span id="cockpit-context-uncertainty">AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR</span>
<button
id="cockpit-weather-toggle"
class="cockpit-context-weather"
type="button"
aria-label="Enable cockpit weather effects"
aria-pressed="false"
title="Enable cockpit weather effects"
>
<span>WX</span>
<strong id="cockpit-weather-state">OFF</strong>
</button>
<time id="cockpit-context-updated">--:--:--Z</time>
</footer>
</aside>
<aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" hidden>
<section id="cockpit-route" class="cockpit-route-card" aria-label="Estimated flight plan" hidden>
<div class="cockpit-route-heading">
<span class="cockpit-route-kicker">ESTIMATED FLIGHT PLAN</span>
<small id="cockpit-route-status">ROUTE DATA UNAVAILABLE</small>
</div>
<div class="cockpit-route-endpoints">
<div class="cockpit-route-airport">
<span>FROM</span>
<strong id="cockpit-route-from">UNKNOWN</strong>
</div>
<span class="cockpit-route-arrow material-symbols-outlined" aria-hidden="true">east</span>
<div class="cockpit-route-airport">
<span>TO</span>
<strong id="cockpit-route-to">UNKNOWN</strong>
</div>
</div>
</section>
<header class="cockpit-signal-header">
<div class="cockpit-brief-heading">
<span id="cockpit-brief-kicker" class="cockpit-signal-kicker"><i aria-hidden="true"></i> LIVE SIGNALS</span>
<strong id="cockpit-brief-subtitle">OBSERVED / MAPPED PINGS</strong>
</div>
<nav class="cockpit-brief-actions" aria-label="Cockpit briefing controls">
<button id="cockpit-brief-previous" type="button" aria-label="Previous briefing page" title="Previous briefing page">
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
</button>
<span id="cockpit-brief-position" aria-live="polite">1 / 3</span>
<button id="cockpit-brief-next" type="button" aria-label="Next briefing page" title="Next briefing page">
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
</button>
<button
id="cockpit-brief-auto"
type="button"
aria-label="CYCLE OFF"
aria-pressed="false"
title="Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way."
>CYCLE OFF</button>
<button id="cockpit-signal-toggle" type="button" aria-label="Collapse cockpit briefing panel" aria-expanded="true" title="Collapse briefing panel">
<span class="material-symbols-outlined" aria-hidden="true">right_panel_close</span>
</button>
</nav>
</header>
<div class="cockpit-brief-stage">
<section data-cockpit-brief-page="signals" aria-label="Live signals">
<ol id="cockpit-signal-list" class="cockpit-signal-list" aria-live="polite"></ol>
</section>
<section data-cockpit-brief-page="news" aria-label="Latest regional news" hidden>
<div id="cockpit-news-status" class="cockpit-brief-status">ACQUIRING REGIONAL NEWS</div>
<ol id="cockpit-news-list" class="cockpit-news-list" aria-live="polite"></ol>
</section>
<section data-cockpit-brief-page="local" aria-label="Location-based information" hidden>
<div class="cockpit-local-place">
<strong id="cockpit-local-place">RESOLVING REGION</strong>
<small id="cockpit-local-coordinates">--</small>
</div>
<div class="cockpit-local-grid">
<div><span>TEMP</span><strong id="cockpit-local-temperature">—</strong><small>OPEN-METEO</small></div>
<div><span>WIND</span><strong id="cockpit-local-wind">—</strong><small id="cockpit-local-wind-direction">—</small></div>
<div><span>SKY</span><strong id="cockpit-local-condition">UNKNOWN</strong><small id="cockpit-local-cloud">—</small></div>
<div><span>PRECIP</span><strong id="cockpit-local-precipitation">—</strong><small>MM</small></div>
</div>
<a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener">Weather data by Open-Meteo.com</a>
</section>
</div>
<footer class="cockpit-signal-footer">
<span id="cockpit-brief-source">SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS</span>
<nav class="cockpit-brief-tabs" aria-label="Cockpit briefing pages">
<button type="button" data-cockpit-brief-index="0" aria-label="Show Live Signals" aria-current="true">SIG</button>
<button type="button" data-cockpit-brief-index="1" aria-label="Show Regional News">NEWS</button>
<button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info">LOCAL</button>
</nav>
</footer>
</aside>
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" aria-label="Cockpit display and Radio controls">
<div class="cockpit-utility-control">
<div class="cockpit-utility-launcher" data-cockpit-launcher="display">
<span>DISPLAY</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
<button id="cockpit-display-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-display-panel" aria-label="Expand Cockpit display options" title="Expand Cockpit display options">◀</button>
</div>
<div id="cockpit-display-panel" class="cockpit-utility-popover" role="group" aria-label="Cockpit display options" hidden>
<div class="cockpit-display-slot" data-cockpit-display-slot="hud"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="detection"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="parameters"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="models3d"></div>
</div>
</div>
<div class="cockpit-utility-control">
<div class="cockpit-utility-launcher" data-cockpit-launcher="radio">
<span>RADIO</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
<button id="cockpit-radio-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-radio-panel" aria-label="Expand Cockpit Radio controls" title="Expand Cockpit Radio controls">◀</button>
</div>
<div id="cockpit-radio-panel" class="cockpit-utility-popover cockpit-radio-compact" role="group" aria-label="Cockpit compact Radio controls" hidden>
<strong id="cockpit-radio-station" class="cockpit-radio-station">READY</strong>
<button id="cockpit-radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false">ENABLE</button>
<div class="cockpit-radio-transport">
<button id="cockpit-radio-prev-btn" type="button" aria-label="Previous filtered radio station" disabled>◀◀</button>
<button id="cockpit-radio-play-btn" type="button" aria-label="Play selected radio station" disabled>▶</button>
<button id="cockpit-radio-next-btn" type="button" aria-label="Next filtered radio station" disabled>▶▶</button>
</div>
<label class="cockpit-radio-volume" for="cockpit-radio-volume">
<span>VOLUME</span>
<input id="cockpit-radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Cockpit Radio volume" disabled />
<output id="cockpit-radio-volume-value" class="gev-slider-value" for="cockpit-radio-volume">80%</output>
</label>
</div>
</div>
</aside>
<div class="cockpit-help">ESC EXIT <span>·</span> C TOGGLE</div>
</section>
<nav id="view-switcher" aria-label="View switcher"> <!-- gev:template provider-settings -->
<button id="cockpit-reset-globe" type="button" aria-label="Reset cockpit to full globe view" title="Exit cockpit and return to full globe view" hidden>
<span class="material-symbols-outlined" aria-hidden="true">public</span>
<span>RESET</span>
</button>
<button id="map-view-switch" type="button" aria-label="Exit cockpit view" aria-keyshortcuts="Escape C" title="Exit cockpit view" hidden>
<span class="material-symbols-outlined" aria-hidden="true">close_fullscreen</span>
<span>EXIT COCKPIT</span>
</button>
</nav>
<!-- Post-processing toggles (top-right) --> <!-- gev:template hud-loading -->
<div id="pp-toggles" class="panel-collapsible collapsed" data-panel-id="pp-toggles">
<div class="compact pp-header-row">
<span class="pp-header-label">DISPLAY</span>
<span class="panel-divider" aria-hidden="true"></span>
<button class="panel-collapse-btn pp-collapse-btn" data-collapse-target="pp-toggles" title="Collapse panel">&#x25C0;</button>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)">
<span class="pp-icon">&#x25CE;</span>
<span class="pp-label">HUD</span>
</button>
<div class="pp-slider-row" id="hud-layout-row">
<span class="pp-slider-mini-label">Layout</span>
<select id="hud-layout-select" class="pp-select" aria-label="HUD layout">
<option value="tactical">Tactical</option>
<option value="operator">Operator</option>
<option value="minimal">Minimal</option>
</select>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="detection-toggle" title="Detection Overlay (D)" aria-label="Detection overlay" aria-pressed="false">
<span class="pp-icon">&#x25A3;</span>
<span class="pp-label">DETECT</span>
</button>
<div class="pp-slider-row detection-slider-row" id="detection-slider-row">
<label class="pp-slider-mini-label" for="detection-density-slider">Density</label>
<input type="range" class="pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" aria-label="Detection label density" />
<span class="pp-slider-value" id="detection-density-value">50%</span>
</div>
<div class="pp-slider-row detection-allocation-row" id="detection-allocation-row">
<span class="pp-slider-mini-label">Allocation</span>
<div class="pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation">
<button class="pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true">Elastic</button>
<button class="pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false">Weighted</button>
</div>
</div>
<div class="pp-slider-row detection-fade-row" id="detection-fade-row">
<label class="pp-slider-mini-label" for="detection-fade-slider">Fade</label>
<input type="range" class="pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" />
<span class="pp-slider-value" id="detection-fade-value">7%</span>
</div>
<div class="pp-slider-row detection-opacity-row" id="detection-opacity-row">
<label class="pp-slider-mini-label" for="detection-opacity-slider">Outside</label>
<input type="range" class="pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" />
<span class="pp-slider-value" id="detection-opacity-value">1%</span>
</div>
</div>
<!-- The one shared preset-parameter surface is inserted here at runtime. -->
<div id="param-slider-panel">
<div class="param-panel-header">
<span class="param-panel-title">PARAMETERS</span>
<span class="param-panel-divider"></span>
<button class="panel-collapse-btn" id="param-panel-collapse-btn" data-collapse-target="param-slider-panel" title="Collapse panel">−</button>
</div>
<div id="param-sliders"></div>
</div>
<div class="pp-toggle-group">
<!-- 3D aircraft is DEFAULT-ON in Proximity (owner directive 2026-08-22), so the
button ships lit and the mode row ships open — the same markup-carries-the-default
convention as #scope-toggle. ui.js re-syncs both from durable layer state, which
is what flips them back for a share link or a stored session carrying 3D off. -->
<button class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true" title="3D aircraft — flat icons zoomed out, 3D models up close">
<span class="pp-icon">&#x2708;</span>
<span class="pp-label">3D</span>
</button>
<div class="pp-slider-row visible" id="models3d-mode-row">
<span class="pp-slider-mini-label">Models</span>
<div class="pp-mode-seg" role="radiogroup" aria-label="3D model coverage">
<button class="pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true">Proximity</button>
<button class="pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false">All</button>
</div>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn active" id="scope-toggle" title="Scope — the circular viewport mask" aria-pressed="true">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span>
<span class="pp-label">Scope</span>
</button>
<div class="pp-slider-row visible" id="scope-slider-row">
<span class="pp-slider-mini-label">Feather</span>
<!-- Feather starts at an 11% soft scope-mask edge (owner final lock
2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings);
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
softens the black mask edge; label fading is the separate Fade
slider. -->
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" />
<span class="pp-slider-value" id="scope-feather-value">11%</span>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="celestial-toggle" title="Celestial ring — reveal the full globe" aria-pressed="false">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
<span class="pp-label">Celestial</span>
</button>
</div>
<button class="pp-toggle-btn" id="clean-view-toggle" title="Hide UI chrome">
<span class="pp-icon">&#x25A1;</span>
<span class="pp-label">Clean UI</span>
</button>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="bloom-toggle" title="Bloom / Glow">
<span class="pp-icon">&#x2728;</span>
<span class="pp-label">Bloom</span>
</button>
<div class="pp-slider-row" id="bloom-slider-row">
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" aria-label="Bloom intensity" />
<span class="pp-slider-value" id="bloom-intensity-value">0%</span>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="sharpen-toggle" title="Sharpening">
<span class="pp-icon">&#x1F50D;</span>
<span class="pp-label">Sharpen</span>
</button>
<div class="pp-slider-row" id="sharpen-slider-row">
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" aria-label="Sharpen intensity" />
<span class="pp-slider-value" id="sharpen-intensity-value">49%</span>
</div>
</div>
</div>
<button id="clean-view-exit" title="Return UI controls">EXIT CLEAN VIEW</button>
<!-- Unified bottom command dock: locations, voice (injected at runtime), visual presets -->
<div id="command-dock" aria-label="Navigation, voice, and visual preset controls">
<!-- Control panel -->
<div id="control-panel" class="panel-collapsible collapsed">
<div class="panel-glow"></div>
<div class="panel-inner">
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button"
data-dock-toggle-target="control-panel" aria-expanded="false"
aria-controls="control-panel-popover" aria-label="Expand Visual Presets">
<span class="panel-title"><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
<span class="panel-divider"></span>
</button>
<div id="control-panel-popover" class="dock-popover-content">
<button class="dock-pin-btn" data-pin-target="control-panel" type="button" aria-label="Pin visual presets" aria-pressed="false" title="Keep visual presets open"><img src="/pin.svg" alt="" /></button>
<div id="style-buttons" class="button-grid">
<button class="style-btn active" data-style="normal" title="Show the globe without a visual filter.">
<span class="btn-icon">◯</span>
<span class="btn-label">Normal</span>
<span class="btn-key">1</span>
</button>
<button class="style-btn" data-style="retro" title="Emulate a green phosphor CRT with scanlines and screen curvature.">
<span class="btn-icon">▦</span>
<span class="btn-label">CRT</span>
<span class="btn-key">2</span>
</button>
<button class="style-btn" data-style="surveillance" title="Simulate night-vision goggles with green intensification and a tube vignette.">
<span class="btn-icon">🌙</span>
<span class="btn-label">NVG</span>
<span class="btn-key">3</span>
</button>
<button class="style-btn" data-style="thermal" title="Simulate FLIR-style thermal contrast. Turn up Ironbow for color.">
<span class="btn-icon">🌡️</span>
<span class="btn-label">FLIR</span>
<span class="btn-key">4</span>
</button>
<button class="style-btn" data-style="anime" title="Apply bright cel-shaded color and illustrated outlines.">
<span class="btn-icon">✦</span>
<span class="btn-label">Anime</span>
<span class="btn-key">5</span>
</button>
<button class="style-btn" data-style="noir" title="Apply high-contrast monochrome film-noir grading.">
<span class="btn-icon">◐</span>
<span class="btn-label">Noir</span>
<span class="btn-key">6</span>
</button>
<button class="style-btn" data-style="snow" title="Add a cold, snowy whiteout treatment to the scene.">
<span class="btn-icon">❄</span>
<span class="btn-label">Snow</span>
<span class="btn-key">7</span>
</button>
</div>
<section class="map-source-section" aria-labelledby="map-source-label">
<div class="map-source-heading">
<span id="map-source-label">MAP SOURCE</span>
<span id="map-stack-status" class="pp-stack-status" aria-live="polite">3D</span>
</div>
<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source"></div>
</section>
</div>
<div id="style-mini-status" class="panel-mini-status" aria-live="polite">
<span class="panel-mini-label">Style</span>
<span id="style-mini-value" class="panel-mini-value">NORMAL</span>
</div>
</div>
</div>
<!-- Location bar -->
<div id="location-bar" class="panel-collapsible collapsed">
<div class="panel-glow"></div>
<div class="location-inner">
<div class="location-toolbar">
<button id="location-bar-toggle" class="dock-tray-toggle" type="button" data-dock-toggle-target="location-bar" aria-controls="location-bar-popover" aria-expanded="false" aria-label="Expand LOCATION" title="Expand LOCATION">
<span class="location-toolbar-label"><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
</button>
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel">+</button>
</div>
<div id="location-mini-status" class="location-mini-status" aria-live="polite">
<div id="location-mini-city" class="location-mini-city">📍 Location: --</div>
<div id="location-mini-poi" class="location-mini-poi">Landmark: --</div>
</div>
<div id="location-bar-popover" class="dock-popover-content">
<button class="dock-pin-btn" data-pin-target="location-bar" type="button" aria-label="Pin location tray" aria-pressed="false" title="Keep location tray open"><img src="/pin.svg" alt="" /></button>
<div id="poi-row" class="poi-row-container"></div>
<div class="location-bar-divider" id="location-bar-divider"></div>
<div class="location-city-row">
<div id="location-pills" class="location-pills"></div>
<div class="location-search-wrap">
<button id="search-toggle" class="search-toggle-btn" title="Search any location">
<span>&#x1F50E;</span>
</button>
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" />
</div>
</div>
</div>
</div>
</div>
</div>
<div id="left-panel-stack">
<!-- Data Layer Toggle Panel -->
<div id="data-panel" class="panel-collapsible collapsed active" data-panel-id="data-panel">
<div class="panel-glow"></div>
<div class="data-panel-inner">
<div class="panel-header">
<span class="panel-title">DATA LAYERS</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="data-panel" title="Collapse panel">+</button>
</div>
<div id="data-toggles" class="data-toggle-list"></div>
</div>
</div>
<!-- CCTV Coverage Panel -->
<div id="cctv-panel" class="panel-collapsible collapsed" data-panel-id="cctv-panel">
<div class="panel-glow"></div>
<div class="cctv-panel-inner">
<div class="panel-header">
<span class="panel-title">CCTV</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel">+</button>
</div>
<div id="cctv-frame-wrap" class="cctv-frame-wrap">
<img id="cctv-frame" alt="CCTV feed frame" />
</div>
<div id="cctv-source-badge" role="status" aria-live="polite">SOURCE · UNKNOWN</div>
<div id="cctv-meta">Enable CCTV to load camera intersections</div>
<div class="cctv-controls">
<button id="cctv-enable-btn" class="scene-btn">CCTV OFF</button>
<button id="cctv-nearest-btn" class="scene-btn">NEAREST</button>
</div>
<div class="cctv-controls">
<button id="cctv-prev-btn" class="scene-btn">PREV</button>
<select id="cctv-camera-select" aria-label="CCTV camera"></select>
<button id="cctv-next-btn" class="scene-btn">NEXT</button>
</div>
<div class="cctv-controls">
<button id="cctv-focus-btn" class="scene-btn">FOCUS</button>
<button id="cctv-coverage-btn" class="scene-btn">COVERAGE OFF</button>
<button id="cctv-auto-hop-btn" class="scene-btn">AUTO HOP OFF</button>
</div>
<div class="cctv-controls">
<button id="cctv-projection-btn" class="scene-btn">PROJECTION ON</button>
<span id="cctv-quality-chip">CAL · --</span>
</div>
<div class="cctv-calibration-block">
<div class="cctv-calibration-header">
<div class="cctv-summary-label">CALIBRATION</div>
<button id="cctv-adjust-btn" class="scene-btn" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV">ADJUST</button>
</div>
<div id="cctv-cal-readout" class="cctv-cal-readout" aria-label="Camera pose — click a value to type">
<button class="cctv-cal-value" data-cal-field="heading" title="Heading (compass °) — click to type">HDG --</button>
<button class="cctv-cal-value" data-cal-field="pitch" title="Pitch (° up/down) — click to type">PITCH --</button>
<button class="cctv-cal-value" data-cal-field="fov" title="Horizontal FOV (°) — click to type">FOV --</button>
<button class="cctv-cal-value" data-cal-field="range" title="Range / monitor-plane distance (m) — click to type">RANGE --</button>
<button class="cctv-cal-value" data-cal-field="height" title="Mount height above ground (m) — click to type">HGT --</button>
<button class="cctv-cal-value" data-cal-field="north" title="North offset from catalog position (m) — click to type">ΔN --</button>
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type">ΔE --</button>
</div>
<div class="cctv-controls cctv-calibration-actions">
<button id="cctv-calib-save-btn" class="scene-btn">SAVE CAL</button>
<button id="cctv-calib-reset-btn" class="scene-btn">RESET CAL</button>
</div>
</div>
<div class="cctv-summary-label">SCENE SUMMARY</div>
<div id="cctv-summary">Enable CCTV to start camera-linked intelligence summaries.</div>
</div>
</div>
<!-- Scene Director Panel -->
<div id="scene-panel" class="panel-collapsible collapsed" data-panel-id="scene-panel">
<div class="panel-glow"></div>
<div class="scene-panel-inner">
<div class="panel-header">
<span class="panel-title">SCENES</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel">+</button>
</div>
<div class="scene-controls">
<select id="scene-select" aria-label="Scene recipe"></select>
<button id="scene-new-btn" class="scene-btn">NEW</button>
<button id="scene-delete-btn" class="scene-btn scene-btn-danger">DEL</button>
</div>
<div class="scene-controls">
<button id="scene-capture-btn" class="scene-btn">CAPTURE SHOT</button>
<button id="scene-update-shot-btn" class="scene-btn">UPDATE SHOT</button>
</div>
<div id="scene-shot-list" class="scene-shot-list"></div>
<div class="scene-controls">
<button id="scene-start-btn" class="scene-btn">START</button>
<button id="scene-stop-btn" class="scene-btn scene-btn-danger">STOP</button>
<button id="scene-next-btn" class="scene-btn">NEXT</button>
</div>
<div class="scene-controls">
<button id="scene-export-btn" class="scene-btn">EXPORT PRESETS</button>
<button id="scene-import-btn" class="scene-btn">IMPORT</button>
<button id="scene-download-btn" class="scene-btn">RUN LOG</button>
<input type="file" id="scene-import-file" accept="application/json,.json" hidden />
</div>
<div class="scene-progress">
<div id="scene-progress-fill">0%</div>
</div>
<div id="scene-status">Ready</div>
</div>
</div>
</div>
<!-- Global Context is deliberately independent from the left layer accordion. -->
<aside id="right-context-rail">
<div id="global-context-panel" class="panel-collapsible collapsed" data-panel-id="global-context-panel">
<div class="panel-glow"></div>
<div class="global-context-panel-inner">
<div class="panel-header">
<span class="panel-title">CONTEXT</span>
<span class="panel-divider"></span>
<div id="context-radio-dock" class="context-radio-dock">
<button id="context-radio-toggle-btn" class="context-radio-toggle" type="button" aria-expanded="false" aria-controls="context-radio-mini" aria-label="Open compact Radio controls" title="Open compact Radio controls">
<span class="material-symbols-outlined" aria-hidden="true">radio</span>
</button>
<div id="context-radio-mini" class="context-radio-mini" role="group" aria-label="Compact Radio controls" hidden>
<div class="context-radio-mini-heading">
<span>RADIO</span>
<strong id="context-radio-mini-station">RADIO READY</strong>
<span class="context-radio-mini-actions">
<button id="context-radio-details-btn" type="button" aria-label="Open detailed Radio controls" aria-expanded="false" aria-controls="radio-panel" title="Open detailed Radio controls">
<span class="material-symbols-outlined" aria-hidden="true">open_in_full</span>
</button>
<button id="context-radio-mini-close-btn" type="button" aria-label="Close compact Radio controls" title="Close compact Radio controls">
<span class="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</span>
</div>
<button id="context-radio-mini-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false">ENABLE</button>
<div class="context-radio-mini-transport">
<button id="context-radio-mini-prev-btn" type="button" aria-label="Previous filtered radio station" title="Previous station" disabled>◀◀</button>
<button id="context-radio-mini-play-btn" type="button" aria-label="Play selected radio station" title="Play" disabled>▶</button>
<button id="context-radio-mini-next-btn" type="button" aria-label="Next filtered radio station" title="Next station" disabled>▶▶</button>
</div>
<label class="context-radio-mini-volume" for="context-radio-mini-volume">
<span>VOLUME</span>
<input id="context-radio-mini-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Compact Radio volume" disabled />
<output id="context-radio-mini-volume-value" class="gev-slider-value" for="context-radio-mini-volume">80%</output>
</label>
</div>
</div>
<button class="panel-collapse-btn" data-collapse-target="global-context-panel" title="Expand panel">▶</button>
</div>
<div class="global-context-modes" role="tablist" aria-label="Context mode">
<button id="global-context-flights-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-flights-view" aria-label="CONTACTS" title="Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.">
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
<span>CONTACTS</span>
</button>
<button id="global-context-missions-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-missions-view">
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
<span>SPACE MISSIONS</span>
</button>
</div>
<div id="context-mode-standby" class="context-mode-standby">
<strong>SELECT CONTEXT</strong>
<span>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches &amp; orbital assets</span>
</div>
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" hidden>
<div class="global-context-actions" aria-label="Contact Context actions">
<button id="cockpit-entry" type="button" hidden>
<span class="material-symbols-outlined" aria-hidden="true">flight</span>
<span>COCKPIT</span>
</button>
<button id="installations-search-btn" class="panel-layer-toggle" type="button">SEARCH NEARBY SITES</button>
<button id="tr3b-toggle" class="tr3b-toggle" type="button" aria-pressed="false" hidden aria-label="Reclassify tracked contact as TR-3B" title="Reclassify as TR-3B">🛸</button>
</div>
<aside id="military-awareness-panel">
<div class="military-awareness-standby">
<strong>CONTACTS CONTEXT OFF</strong>
<span>SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY</span>
</div>
</aside>
</section>
<section id="context-missions-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-missions-btn" hidden>
<div id="space-mission-panel-host">
<div id="space-mission-roster" class="space-mission-roster" aria-label="Available Space Missions">
<div class="space-mission-roster-header">
<div>
<strong>AVAILABLE MISSIONS</strong>
<span>SELECT A MISSION TO INSPECT</span>
</div>
<output data-mission-roster-count>—</output>
</div>
<div class="space-mission-roster-list" data-mission-roster-list>
<div class="space-mission-roster-empty">LOADING 30-DAY MISSION INDEX</div>
</div>
<p class="space-mission-roster-focus-continuation" tabindex="-1" data-mission-roster-focus-continuation>TAB PREVIEWS · ENTER / SPACE SELECTS</p>
</div>
</div>
</section>
<section id="radio-panel" class="panel-collapsible collapsed" data-panel-id="radio-panel" aria-label="Internet radio companion">
<div class="radio-panel-inner">
<div class="panel-header">
<span class="panel-title">RADIO</span>
<span id="radio-layer-state" class="radio-layer-state">OFF</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="radio-panel" title="Expand Radio" aria-label="Expand Radio section">+</button>
</div>
<div class="radio-directory-row">
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false">ENABLE</button>
<label class="radio-filter-label" for="radio-filter">STATION TAG</label>
<select id="radio-filter" aria-label="Filter stations by station tag" disabled>
<option value="all">All</option>
</select>
</div>
<div id="radio-station-card" class="radio-station-card" aria-live="polite">
<strong id="radio-station-name">NO STATION SELECTED</strong>
<span id="radio-station-meta">Enable Radio, then choose a globe marker or use next.</span>
<span id="radio-station-tags"></span>
</div>
<div id="radio-tuner" class="radio-tuner" hidden>
<div class="radio-tuner-header">
<span id="radio-tuner-band-label">DIRECTORY BAND</span>
<output id="radio-tuner-value" for="radio-tuner-slider">DRAG TO TUNE</output>
</div>
<div class="radio-tuner-dial">
<div class="radio-tuner-scale" aria-hidden="true"></div>
<div id="radio-tuner-needle" class="radio-tuner-needle" aria-hidden="true"></div>
<input id="radio-tuner-slider" type="range" min="0" max="0" step="1" value="0"
aria-label="Tune available internet radio stations" aria-valuetext="No station available" disabled />
</div>
<div class="radio-tuner-readout">
<span id="radio-tuner-station">ALL · DRAG THE NEEDLE</span>
<span>SNAPS TO AVAILABLE STATIONS</span>
</div>
</div>
<div class="radio-transport" role="group" aria-label="Radio playback">
<button id="radio-prev-btn" type="button" aria-label="Previous filtered station" disabled>PREV</button>
<button id="radio-play-btn" type="button" aria-label="Play selected station" disabled>PLAY</button>
<button id="radio-next-btn" type="button" aria-label="Next filtered station" disabled>NEXT</button>
<button id="radio-stop-btn" type="button" aria-label="Stop radio playback" disabled>STOP</button>
</div>
<label class="radio-volume-row" for="radio-volume">
<span>VOLUME</span>
<input id="radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Radio volume" />
<output id="radio-volume-value" class="gev-slider-value" for="radio-volume">80%</output>
</label>
<div id="radio-playback-state" class="radio-playback-state" role="status" aria-live="polite">Radio off</div>
<div class="radio-links">
<a id="radio-station-homepage" href="#" target="_blank" rel="noopener noreferrer" hidden>STATION SITE</a>
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">DIRECTORY: RADIO BROWSER</a>
</div>
<p class="radio-privacy">Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
</div>
</section>
</div>
</div>
</aside>
<div id="scene-runtime"></div>
<!-- Explicit mission choice on a fresh session. Live feeds are not auto-enabled
until the visitor asks; shared links skip this surface entirely. It returns
every session until "Don't show this again" is ticked — see
src/firstRunExperience.js for the full show policy. -->
<aside id="first-run-launcher" role="dialog" aria-labelledby="first-run-title" aria-describedby="first-run-description" hidden>
<div class="first-run-scanline" aria-hidden="true"></div>
<header class="first-run-header">
<span class="first-run-kicker">MISSION CONTROL · FIRST LAUNCH</span>
</header>
<h2 id="first-run-title">Choose your first view</h2>
<!-- The card's one persuasive line, owner-authored and VERBATIM (2026-08-23)
— including the unspaced em dash. The arc it has to carry: this looks
like something you should not have access to → the sources are all
public → the data is real. Tile subcopy stays purely functional. -->
<p id="first-run-description">It feels like a forbidden cockpit—then you realize the sources are public and the data is real.</p>
<div class="first-run-choices">
<button type="button" data-first-run-choice="contacts">
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
<span><strong>LIVE CONTACTS</strong><small>Aircraft, vessels and nearby intelligence</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
</button>
<button type="button" data-first-run-choice="space-missions">
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
<span><strong>SPACE MISSIONS</strong><small>Launches, spacecraft and orbital context</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
</button>
<button type="button" data-first-run-choice="environmental">
<span class="material-symbols-outlined" aria-hidden="true">local_fire_department</span>
<!-- Title text is painted from ENVIRONMENTAL_LABEL_CHOICE at init. -->
<!-- Subcopy names BOTH feeds this tile turns on, because the launcher
optimizes for the fully configured experience (owner ruling,
2026-08-23) — see the mission table in src/firstRunExperience.js. -->
<span><strong data-first-run-environmental-title>ENVIRONMENTAL</strong><small>Live earthquakes and active fires, from USGS and NASA</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
</button>
<button type="button" class="first-run-explore" data-first-run-choice="explore">
<span class="material-symbols-outlined" aria-hidden="true">public</span>
<span><strong>EXPLORE MANUALLY</strong><small>Begin with a clean globe</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
</button>
</div>
<div class="first-run-footer">
<label class="first-run-suppress">
<input type="checkbox" data-first-run-suppress />
<span>Don't show this again</span>
</label>
<span>ESC to dismiss</span>
</div>
<!-- Kept out of the footer row: it swaps to mission progress and errors, and
a live region that also holds layout makes the row jump as it speaks. -->
<p class="first-run-note" data-first-run-status role="status" aria-live="polite">Tip: the GEV MIC button in the dock lets you talk to the map.</p>
</aside>
<!-- POWER UP — in-app key setup. Dev-server only: src/keySetup.js removes
both the chip and this dialog outright when /api/setup/status is not
there to answer (prod builds, LAN visitors). Rows are rendered from
that payload, so the key registry lives in src/keySetupCore.mjs alone. -->
<button id="key-setup-chip" type="button" hidden aria-haspopup="dialog" aria-controls="key-setup">
<span class="material-symbols-outlined" aria-hidden="true">bolt</span>
<span data-key-setup-chip-label>POWER UP</span>
</button>
<aside id="key-setup" role="dialog" aria-labelledby="key-setup-title" aria-describedby="key-setup-description" hidden>
<div class="key-setup-scanline" aria-hidden="true"></div>
<header class="key-setup-header">
<span class="key-setup-kicker">GROUND STATION · PROVIDER SETTINGS</span>
<button type="button" class="key-setup-close" data-key-setup-close aria-label="Close key setup">
<span class="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</header>
<h2 id="key-setup-title">Power up the globe</h2>
<p id="key-setup-description">The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.</p>
<div class="key-setup-rows" data-key-setup-rows></div>
<div class="key-setup-footer">
<button type="button" class="key-setup-apply" data-key-setup-apply>SAVE KEYS</button>
<span class="key-setup-hint">ESC to close</span>
</div>
<p class="key-setup-note" data-key-setup-status role="status" aria-live="polite">The Google Maps key buys the photorealistic planet — everything else stacks on top.</p>
</aside>
<!-- Intelligence HUD Overlay -->
<div id="intel-hud"></div>
<!-- Loading screen -->
<div id="loading-screen">
<div class="loader-content">
<span class="loader-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span>
<h2>GOD'S EYE <span class="title-accent">VIEW</span></h2>
<p class="loader-status">Initializing photorealistic world...</p>
</div>
</div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
+19 -1
View File
@@ -172,6 +172,24 @@
"./voice/control": "./src/voice/control.js", "./voice/control": "./src/voice/control.js",
"./voice/realtime-backend": "./src/voice/realtimeBackend.js", "./voice/realtime-backend": "./src/voice/realtimeBackend.js",
"./voice/commands": "./src/voice/commands.js", "./voice/commands": "./src/voice/commands.js",
"./voice/cost": "./src/voice/voiceCost.js" "./voice/cost": "./src/voice/voiceCost.js",
"./application/scene": "./src/app/scene.js",
"./application/controls": "./src/app/controls.js",
"./application/data": "./src/app/data.js",
"./application/tools": "./src/app/tools.js",
"./application/chrome": "./src/app/startupChrome.js",
"./application/sources": "./src/app/sources.js",
"./application/services": "./src/services/application.js",
"./application/requests": "./src/services/requests.js",
"./application/errors": "./src/app/errors.js",
"./ui/composition": "./src/ui/composition.js",
"./ui/hud": "./src/hud.js",
"./standalone/settings": "./src/keySetup.js",
"./voice/actions": "./src/voice/gevActions.js",
"./annotations": "./src/annotations/index.js",
"./context": "./src/data/contextStore.js",
"./build/html": {
"node": "./build/application-html.js"
}
} }
} }
+2 -2
View File
@@ -245,8 +245,8 @@ if [[ ! -f "src/data/cctv.js" ]]; then
exit 1 exit 1
fi fi
if ! grep -q "dataManager.register(cctvLayer)" src/standalone/data.js; then if ! grep -q "dataManager.register(cctvLayer)" src/app/data.js; then
echo "error: CCTV layer not wired in src/standalone/data.js" echo "error: CCTV layer not wired in src/app/data.js"
exit 1 exit 1
fi fi
+13 -1
View File
@@ -541,5 +541,17 @@
"src/voice/control.js", "src/voice/control.js",
"src/voice/realtimeBackend.js", "src/voice/realtimeBackend.js",
"src/voice/realtimeBackend.test.mjs", "src/voice/realtimeBackend.test.mjs",
"src/voice/layerSummary.js" "src/voice/layerSummary.js",
"build/application-html.js",
"src/app/sourceSlot.js",
"src/app/sources.js",
"src/ui/composition.js",
"src/services/application.js",
"src/services/requests.js",
"src/app/scene.js",
"src/app/controls.js",
"src/app/data.js",
"src/app/tools.js",
"src/app/startupChrome.js",
"src/app/errors.js"
] ]
+487 -3
View File
@@ -24,8 +24,8 @@
}, },
"vite-build": { "vite-build": {
"runtime": "node", "runtime": "node",
"exports": ["./build/vite"], "exports": ["./build/vite", "./build/html"],
"modules": ["build/vite.js"], "modules": ["build/vite.js", "build/application-html.js"],
"external": ["vite-plugin-cesium"] "external": ["vite-plugin-cesium"]
}, },
"live-providers": { "live-providers": {
@@ -221,7 +221,7 @@
"server/providers/regional/query.js", "server/providers/regional/query.js",
"server/providers/regional/weather-effects.js", "server/providers/regional/weather-effects.js",
"server/providers/regional/weather.js", "server/providers/regional/weather.js",
"src/data/regionalBrief.js" "src/data/regionalModel.js"
], ],
"external": [] "external": []
}, },
@@ -1021,5 +1021,489 @@
"src/voice/commands.js" "src/voice/commands.js"
], ],
"external": [] "external": []
},
"application-components": {
"exports": [
"./application/scene",
"./application/controls",
"./application/data",
"./application/tools",
"./application/chrome",
"./application/sources",
"./application/services",
"./application/requests",
"./application/errors",
"./ui/composition",
"./ui/hud",
"./standalone/settings",
"./voice/actions",
"./annotations",
"./context"
],
"modules": [
"src/annotations/annotationEngine.js",
"src/annotations/annotationResolver.js",
"src/annotations/hybridAnnotationRenderer.js",
"src/annotations/index.js",
"src/annotations/screenAnnotationRenderer.js",
"src/annotations/worldAnnotationRenderer.js",
"src/app/controls.js",
"src/app/data.js",
"src/app/errors.js",
"src/app/scene.js",
"src/app/sourceSlot.js",
"src/app/sources.js",
"src/app/startupChrome.js",
"src/app/stateChannel.js",
"src/app/tools.js",
"src/app/viewer.js",
"src/bloom.js",
"src/camera.js",
"src/cameraVerbs.js",
"src/cctvFocusPolicy.js",
"src/cctvFocusRequest.js",
"src/celestialRing.js",
"src/cockpitCloudEffects.js",
"src/cockpitMath.js",
"src/cockpitTracking.js",
"src/cockpitUtilityLayout.js",
"src/cockpitVisionPolicy.js",
"src/contactsDetectionPolicy.js",
"src/contextModePolicy.js",
"src/creditKeyboard.js",
"src/data/aircraftClass.js",
"src/data/aircraftIcons.js",
"src/data/aircraftMeta.js",
"src/data/aircraftNearbyPolicy.js",
"src/data/aircraftRecession.js",
"src/data/aisLiveVessels.js",
"src/data/alprCameras.js",
"src/data/analystEngine.js",
"src/data/bikeshare.js",
"src/data/cctv.js",
"src/data/cctvCards.js",
"src/data/cctvFootprint.js",
"src/data/cctvGizmo.js",
"src/data/cctvLod.js",
"src/data/cctvViewshed.js",
"src/data/cockpitAirLod.js",
"src/data/cockpitContactDot.js",
"src/data/contactMatch.js",
"src/data/contextStore.js",
"src/data/dataCredits.js",
"src/data/detection.js",
"src/data/detectionCohort.js",
"src/data/detectionDraw.js",
"src/data/detectionPolicy.js",
"src/data/detectionPresentation.js",
"src/data/detectionRenderDemand.js",
"src/data/earthquakes.js",
"src/data/fireAnchors.js",
"src/data/firmsAdapt.js",
"src/data/firmsHeatmap.js",
"src/data/firmsLabels.js",
"src/data/flights.js",
"src/data/flowMatch.js",
"src/data/focusDeemphasis.js",
"src/data/geoid.js",
"src/data/groundFloor.js",
"src/data/groundSnap.js",
"src/data/iconOrientation.js",
"src/data/infrastructure.js",
"src/data/installationFeedback.js",
"src/data/issPass.js",
"src/data/labelArbiter.js",
"src/data/layerState.js",
"src/data/localGeojson.js",
"src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js",
"src/data/localLayers.js",
"src/data/local_data/natural_earth/marine.json",
"src/data/local_data/natural_earth/regions.json",
"src/data/local_data/neighborhoods/san-francisco.json",
"src/data/manager.js",
"src/data/meshFloorSampler.js",
"src/data/militaryAwareness.js",
"src/data/militaryAwarenessEngine.js",
"src/data/militaryFlights.js",
"src/data/militaryInstallationData.js",
"src/data/militaryInstallations.js",
"src/data/militaryRegistry.js",
"src/data/modelVisualAnchor.js",
"src/data/motionModel.js",
"src/data/naturalEarthRegions.js",
"src/data/neighborhoodPolygons.js",
"src/data/pickRegistry.js",
"src/data/radio.js",
"src/data/radioCountry.js",
"src/data/regionalBrief.js",
"src/data/regionalModel.js",
"src/data/renderAltitude.js",
"src/data/retryableLoad.js",
"src/data/rocketLaunches.js",
"src/data/routePlausible.js",
"src/data/satelliteClass.js",
"src/data/satellites.js",
"src/data/scenePick.js",
"src/data/spriteOrder.js",
"src/data/telegeographySubmarineCables.js",
"src/data/terrainHeights.js",
"src/data/tomtomTiles.js",
"src/data/tr3bRegistry.js",
"src/data/trackedCamera.js",
"src/data/trackedModelRegime.js",
"src/data/trackedReadout.js",
"src/data/trackingClickGesture.js",
"src/data/traffic.js",
"src/data/trafficBounds.js",
"src/data/trafficFlowStyle.js",
"src/data/trafficPresetStyle.js",
"src/data/trafficQueue.js",
"src/data/trailRenderer.js",
"src/data/vesselLabels.js",
"src/firstRunExperience.js",
"src/hud.js",
"src/hudLocality.js",
"src/hudSummaryResponse.js",
"src/keySetup.js",
"src/keySetupCore.mjs",
"src/keylessGeocoder.js",
"src/layers/aircraft/classification.js",
"src/layers/alpr/index.js",
"src/layers/alpr/model.js",
"src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js",
"src/layers/alpr/source.js",
"src/layers/awareness/controls.js",
"src/layers/awareness/dependencies.js",
"src/layers/awareness/focus.js",
"src/layers/awareness/history.js",
"src/layers/awareness/index.js",
"src/layers/awareness/ingestion.js",
"src/layers/awareness/lifecycle.js",
"src/layers/awareness/model.js",
"src/layers/awareness/navigation.js",
"src/layers/awareness/panel.js",
"src/layers/awareness/policy.js",
"src/layers/awareness/queries.js",
"src/layers/awareness/rendering.js",
"src/layers/awareness/state.js",
"src/layers/awareness/subject.js",
"src/layers/awareness/testing.js",
"src/layers/bikeshare/controls.js",
"src/layers/bikeshare/index.js",
"src/layers/bikeshare/ingestion.js",
"src/layers/bikeshare/lifecycle.js",
"src/layers/bikeshare/model.js",
"src/layers/bikeshare/policy.js",
"src/layers/bikeshare/queries.js",
"src/layers/bikeshare/registry.js",
"src/layers/bikeshare/rendering.js",
"src/layers/bikeshare/selection.js",
"src/layers/bikeshare/source.js",
"src/layers/bikeshare/state.js",
"src/layers/bikeshare/testing.js",
"src/layers/bikeshare/viewport.js",
"src/layers/cctv/calibration.js",
"src/layers/cctv/cards.js",
"src/layers/cctv/catalog.js",
"src/layers/cctv/controls.js",
"src/layers/cctv/frames.js",
"src/layers/cctv/geometry.js",
"src/layers/cctv/geometryQueue.js",
"src/layers/cctv/ground.js",
"src/layers/cctv/health.js",
"src/layers/cctv/hover.js",
"src/layers/cctv/index.js",
"src/layers/cctv/ingestion.js",
"src/layers/cctv/lifecycle.js",
"src/layers/cctv/model.js",
"src/layers/cctv/navigation.js",
"src/layers/cctv/policy.js",
"src/layers/cctv/presentation.js",
"src/layers/cctv/projection.js",
"src/layers/cctv/rendering.js",
"src/layers/cctv/selection.js",
"src/layers/cctv/source.js",
"src/layers/cctv/state.js",
"src/layers/cctv/testing.js",
"src/layers/earthquakes/index.js",
"src/layers/earthquakes/model.js",
"src/layers/earthquakes/source.js",
"src/layers/firms/anchors.js",
"src/layers/firms/cards.js",
"src/layers/firms/index.js",
"src/layers/firms/ingestion.js",
"src/layers/firms/lifecycle.js",
"src/layers/firms/model.js",
"src/layers/firms/policy.js",
"src/layers/firms/queries.js",
"src/layers/firms/rendering.js",
"src/layers/firms/selection.js",
"src/layers/firms/source.js",
"src/layers/firms/state.js",
"src/layers/firms/viewport.js",
"src/layers/flights/controller.js",
"src/layers/flights/enrichment.js",
"src/layers/flights/evidence.js",
"src/layers/flights/index.js",
"src/layers/flights/ingestion.js",
"src/layers/flights/lifecycle.js",
"src/layers/flights/motion.js",
"src/layers/flights/policy.js",
"src/layers/flights/queries.js",
"src/layers/flights/rendering.js",
"src/layers/flights/state.js",
"src/layers/flights/testing.js",
"src/layers/flights/tracking.js",
"src/layers/installations/controls.js",
"src/layers/installations/index.js",
"src/layers/installations/ingestion.js",
"src/layers/installations/lifecycle.js",
"src/layers/installations/model.js",
"src/layers/installations/policy.js",
"src/layers/installations/rendering.js",
"src/layers/installations/selection.js",
"src/layers/installations/source.js",
"src/layers/installations/state.js",
"src/layers/installations/viewport.js",
"src/layers/launches/camera.js",
"src/layers/launches/controls.js",
"src/layers/launches/index.js",
"src/layers/launches/ingestion.js",
"src/layers/launches/launchPad.js",
"src/layers/launches/lifecycle.js",
"src/layers/launches/model.js",
"src/layers/launches/orbitRendering.js",
"src/layers/launches/overlays.js",
"src/layers/launches/panel.js",
"src/layers/launches/paths.js",
"src/layers/launches/policy.js",
"src/layers/launches/policyHelpers.js",
"src/layers/launches/rendering.js",
"src/layers/launches/replay.js",
"src/layers/launches/selection.js",
"src/layers/launches/source.js",
"src/layers/launches/state.js",
"src/layers/launches/testing.js",
"src/layers/military/controller.js",
"src/layers/military/index.js",
"src/layers/military/ingestion.js",
"src/layers/military/lifecycle.js",
"src/layers/military/motion.js",
"src/layers/military/policy.js",
"src/layers/military/queries.js",
"src/layers/military/rendering.js",
"src/layers/military/state.js",
"src/layers/military/testing.js",
"src/layers/military/tracking.js",
"src/layers/radio/catalogModel.js",
"src/layers/radio/categories.js",
"src/layers/radio/clustering.js",
"src/layers/radio/controls.js",
"src/layers/radio/index.js",
"src/layers/radio/ingestion.js",
"src/layers/radio/interaction.js",
"src/layers/radio/labels.js",
"src/layers/radio/lifecycle.js",
"src/layers/radio/model.js",
"src/layers/radio/navigation.js",
"src/layers/radio/playback.js",
"src/layers/radio/policy.js",
"src/layers/radio/presentation.js",
"src/layers/radio/queries.js",
"src/layers/radio/rendering.js",
"src/layers/radio/selection.js",
"src/layers/radio/source.js",
"src/layers/radio/state.js",
"src/layers/radio/tuning.js",
"src/layers/radio/tuningNoise.js",
"src/layers/radio/volume.js",
"src/layers/satellites/catalog.js",
"src/layers/satellites/controls.js",
"src/layers/satellites/index.js",
"src/layers/satellites/ingestion.js",
"src/layers/satellites/interaction.js",
"src/layers/satellites/labels.js",
"src/layers/satellites/lifecycle.js",
"src/layers/satellites/orbits.js",
"src/layers/satellites/policy.js",
"src/layers/satellites/rendering.js",
"src/layers/satellites/source.js",
"src/layers/satellites/state.js",
"src/layers/satellites/testing.js",
"src/layers/satellites/tracking.js",
"src/layers/submarineCables/bundledSource.js",
"src/layers/submarineCables/geometry.js",
"src/layers/submarineCables/index.js",
"src/layers/submarineCables/ingestion.js",
"src/layers/submarineCables/interaction.js",
"src/layers/submarineCables/lifecycle.js",
"src/layers/submarineCables/overlay.js",
"src/layers/submarineCables/policy.js",
"src/layers/submarineCables/rendering.js",
"src/layers/submarineCables/state.js",
"src/layers/submarineCables/surface.js",
"src/layers/traffic/animation.js",
"src/layers/traffic/controls.js",
"src/layers/traffic/flow.js",
"src/layers/traffic/flowDecode.js",
"src/layers/traffic/flowSource.js",
"src/layers/traffic/index.js",
"src/layers/traffic/ingestion.js",
"src/layers/traffic/lifecycle.js",
"src/layers/traffic/model.js",
"src/layers/traffic/policy.js",
"src/layers/traffic/rendering.js",
"src/layers/traffic/source.js",
"src/layers/traffic/state.js",
"src/layers/traffic/style.js",
"src/layers/traffic/timing.js",
"src/layers/traffic/viewport.js",
"src/layers/vessels/cards.js",
"src/layers/vessels/evidence.js",
"src/layers/vessels/index.js",
"src/layers/vessels/ingestion.js",
"src/layers/vessels/lifecycle.js",
"src/layers/vessels/policy.js",
"src/layers/vessels/queries.js",
"src/layers/vessels/rendering.js",
"src/layers/vessels/selection.js",
"src/layers/vessels/state.js",
"src/layers/vessels/store.js",
"src/layers/vessels/testing.js",
"src/layers/vessels/tracking.js",
"src/loadingFeedback.js",
"src/locationStatus.js",
"src/locations.js",
"src/logoGaze.js",
"src/mapStackChips.js",
"src/mapStackController.js",
"src/mapStartup.js",
"src/maps/availability.js",
"src/maps/catalog.js",
"src/maps/controller.js",
"src/maps/credits.js",
"src/maps/defaultSources.js",
"src/maps/google3d.js",
"src/maps/imagery.js",
"src/maps/registry.js",
"src/maps/terrain.js",
"src/navigationPolicy.js",
"src/orbit.js",
"src/overlays/worldOverlay.js",
"src/overlays/worldOverlayDraw.js",
"src/overlays/worldOverlayTokens.js",
"src/panelStackLayout.js",
"src/renderGovernor.js",
"src/rightRailPolicy.js",
"src/scenes/director.js",
"src/scenes/recipes.js",
"src/scenes/scenePolicy.js",
"src/scopeMask.js",
"src/search/defaults.js",
"src/search/geospatial.js",
"src/search/google.js",
"src/search/http.js",
"src/search/placeSearch.js",
"src/services/application.js",
"src/services/requests.js",
"src/sharelink.js",
"src/sources/live/aircraft.js",
"src/sources/live/contract.js",
"src/sources/live/standalone.js",
"src/sources/live/vessels.js",
"src/splitFlap.js",
"src/styles/anime.js",
"src/styles/noir.js",
"src/styles/retro.js",
"src/styles/snow.js",
"src/styles/surveillance.js",
"src/styles/thermal.js",
"src/ui/applicationShell.js",
"src/ui/applicationShortcuts.js",
"src/ui/cctv.js",
"src/ui/cctvBindings.js",
"src/ui/cctvCalibration.js",
"src/ui/cctvControls.js",
"src/ui/cctvFrames.js",
"src/ui/cctvPresentation.js",
"src/ui/clearLayersControl.js",
"src/ui/cockpit.js",
"src/ui/cockpitBriefing.js",
"src/ui/cockpitCamera.js",
"src/ui/cockpitContext.js",
"src/ui/cockpitController.js",
"src/ui/cockpitDisplayPortal.js",
"src/ui/cockpitInput.js",
"src/ui/cockpitInstruments.js",
"src/ui/cockpitLayout.js",
"src/ui/cockpitPresentation.js",
"src/ui/cockpitSignals.js",
"src/ui/cockpitTrackingController.js",
"src/ui/composition.js",
"src/ui/context.js",
"src/ui/contextActions.js",
"src/ui/contextBindings.js",
"src/ui/contextControls.js",
"src/ui/contextLayerChanges.js",
"src/ui/contextPresentation.js",
"src/ui/contextSession.js",
"src/ui/contextSubscriptions.js",
"src/ui/contextTransactions.js",
"src/ui/displayControls.js",
"src/ui/effects.js",
"src/ui/frameRateMonitor.js",
"src/ui/layerPanel.js",
"src/ui/layers.js",
"src/ui/leftPanelRail.js",
"src/ui/location.js",
"src/ui/locationControls.js",
"src/ui/locationSearch.js",
"src/ui/mapSource.js",
"src/ui/mapSourceControls.js",
"src/ui/panelDisclosure.js",
"src/ui/panelLayoutController.js",
"src/ui/panelMeasurement.js",
"src/ui/panelPositionControls.js",
"src/ui/panelRailGeometry.js",
"src/ui/panelRails.js",
"src/ui/radio.js",
"src/ui/radioBindings.js",
"src/ui/radioControls.js",
"src/ui/radioPresentation.js",
"src/ui/radioTunerModel.js",
"src/ui/recordingControls.js",
"src/ui/rightPanelRail.js",
"src/ui/sceneControls.js",
"src/ui/scenePresentation.js",
"src/ui/scenes.js",
"src/ui/shellElements.js",
"src/ui/shellFeedback.js",
"src/ui/styleParameters.js",
"src/ui/surfaceKeyboard.js",
"src/ui/uiLifetime.js",
"src/ui/visualEffects.js",
"src/ui/visualInput.js",
"src/ui/visualPresets.js",
"src/voice/commands.js",
"src/voice/control.js",
"src/voice/gevActions.js",
"src/voice/gevRealtime.js",
"src/voice/layerSummary.js",
"src/voice/realtimeBackend.js",
"src/voice/realtimeController.js",
"src/voice/voiceCost.js",
"src/weatherEffectsMath.js",
"src/worldFocus.js"
],
"external": [
"@mapbox/vector-tile",
"cesium",
"egm96-universal",
"mgrs",
"pbf",
"satellite.js"
]
} }
} }
+7 -4
View File
@@ -85,6 +85,7 @@
* Exits non-zero if ANY invariant fails. DOES NOT COMMIT anything. * Exits non-zero if ANY invariant fails. DOES NOT COMMIT anything.
* *
* Flags: * Flags:
* --source-base <path> Browser module prefix (default /src)
* --url <url> App URL (default http://localhost:4173) * --url <url> App URL (default http://localhost:4173)
* --headful Show the browser (debugging) * --headful Show the browser (debugging)
* --keep-open Leave the browser open after the run (debugging) * --keep-open Leave the browser open after the run (debugging)
@@ -105,6 +106,7 @@ const getOpt = (name, dflt) => {
return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt;
}; };
const SOURCE_BASE = getOpt('--source-base', '/src').replace(/\/$/, '');
const APP_URL = getOpt('--url', 'http://localhost:4173'); const APP_URL = getOpt('--url', 'http://localhost:4173');
const APP_ORIGIN = new URL(APP_URL).origin; const APP_ORIGIN = new URL(APP_URL).origin;
const HEADFUL = getFlag('--headful'); const HEADFUL = getFlag('--headful');
@@ -249,6 +251,7 @@ async function main() {
try { try {
const page = await browser.newPage(); const page = await browser.newPage();
await page.evaluateOnNewDocument((base) => { window.__gevQaSourceBase = base; }, SOURCE_BASE);
await page.setViewport({ width: 1280, height: 800 }); await page.setViewport({ width: 1280, height: 800 });
await page.setRequestInterception(true); await page.setRequestInterception(true);
page.on('request', (request) => { page.on('request', (request) => {
@@ -2655,7 +2658,7 @@ async function main() {
const arrival = await evalPage(async () => { const arrival = await evalPage(async () => {
const v = window.__godsEyeView.viewer; const v = window.__godsEyeView.viewer;
const dm = window.__godsEyeView.dataManager; const dm = window.__godsEyeView.dataManager;
const { screenProjectedRotation } = await import('/src/data/iconOrientation.js'); const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`);
// 3D models OFF for this phase: a model-handed-off billboard is hidden // 3D models OFF for this phase: a model-handed-off billboard is hidden
// and skips rotation updates entirely — the probes need live billboards // and skips rotation updates entirely — the probes need live billboards
// (this is also the app's default state the field report came from). // (this is also the app's default state the field report came from).
@@ -2937,7 +2940,7 @@ async function main() {
// Read the thresholds from the app's own policy module so a later retune // Read the thresholds from the app's own policy module so a later retune
// of the swap distance moves these pins with it rather than stranding // of the swap distance moves these pins with it rather than stranding
// them on stale numbers. // them on stale numbers.
const reg = await import('/src/data/trackedModelRegime.js'); const reg = await import(`${window.__gevQaSourceBase || '/src'}/data/trackedModelRegime.js`);
window.__dfRegime = { window.__dfRegime = {
enter: reg.TRACKED_MODEL_ENTER_ALT_M, enter: reg.TRACKED_MODEL_ENTER_ALT_M,
exit: reg.TRACKED_MODEL_EXIT_ALT_M, exit: reg.TRACKED_MODEL_EXIT_ALT_M,
@@ -2990,7 +2993,7 @@ async function main() {
const seen = performance.getEntriesByType('resource') const seen = performance.getEntriesByType('resource')
.map((e) => e.name) .map((e) => e.name)
.filter((n) => /\/src\/data\/groundFloor\.js(\?|$)/.test(n)); .filter((n) => /\/src\/data\/groundFloor\.js(\?|$)/.test(n));
window.__dfCandidates = [...new Set([...seen.reverse(), '/src/data/groundFloor.js'])]; window.__dfCandidates = [...new Set([...seen.reverse(), `${window.__gevQaSourceBase || '/src'}/data/groundFloor.js`])];
return { candidates: window.__dfCandidates.length }; return { candidates: window.__dfCandidates.length };
}); });
record('display-floor: groundFloor module URL candidates found', dfSetup.candidates > 0, record('display-floor: groundFloor module URL candidates found', dfSetup.candidates > 0,
@@ -3821,7 +3824,7 @@ async function main() {
const urls = [...new Set([ const urls = [...new Set([
...performance.getEntriesByType('resource').map((e) => e.name) ...performance.getEntriesByType('resource').map((e) => e.name)
.filter((n) => /\/src\/data\/flights\.js(\?|$)/.test(n)).reverse(), .filter((n) => /\/src\/data\/flights\.js(\?|$)/.test(n)).reverse(),
'/src/data/flights.js', `${window.__gevQaSourceBase || '/src'}/data/flights.js`,
])]; ])];
for (const url of urls) { for (const url of urls) {
let mod; try { mod = await import(/* @vite-ignore */ url); } catch { continue; } let mod; try { mod = await import(/* @vite-ignore */ url); } catch { continue; }
+1 -1
View File
@@ -1,5 +1,5 @@
import { fetchRegionalText, fetchRegionalJson } from './http.js'; import { fetchRegionalText, fetchRegionalJson } from './http.js';
import { normalizeRegionalArticles } from '../../../src/data/regionalBrief.js'; import { normalizeRegionalArticles } from '../../../src/data/regionalModel.js';
function decodeRssText(value) { function decodeRssText(value) {
return String(value || '') return String(value || '')
+1 -1
View File
@@ -1,5 +1,5 @@
import { fetchRegionalJson } from './http.js'; import { fetchRegionalJson } from './http.js';
import { normalizeRegionalPlace } from '../../../src/data/regionalBrief.js'; import { normalizeRegionalPlace } from '../../../src/data/regionalModel.js';
/** Construct the serialized Nominatim adapter with a trusted endpoint. */ /** Construct the serialized Nominatim adapter with a trusted endpoint. */
export function createRegionalPlaceProvider({ export function createRegionalPlaceProvider({
+1 -1
View File
@@ -1,5 +1,5 @@
import { fetchRegionalJson } from './http.js'; import { fetchRegionalJson } from './http.js';
import { normalizeRegionalWeather } from '../../../src/data/regionalBrief.js'; import { normalizeRegionalWeather } from '../../../src/data/regionalModel.js';
const WEATHER_EFFECTS_MAX_RESPONSE_BYTES = 512 * 1024; const WEATHER_EFFECTS_MAX_RESPONSE_BYTES = 512 * 1024;
+14 -6
View File
@@ -246,7 +246,7 @@ export function createAnnotationEngine({
// allSettled gives per-item error isolation (one failed item never aborts the batch); the // allSettled gives per-item error isolation (one failed item never aborts the batch); the
// mutation pass below then runs in ORDER, so de-dup, the synchronous live-cap check, and output // mutation pass below then runs in ORDER, so de-dup, the synchronous live-cap check, and output
// order are all preserved exactly as the old serial loop had them. // order are all preserved exactly as the old serial loop had them.
const settled = await Promise.allSettled(list.map((spec) => resolveSpec(spec, controller.signal))); const settled = await Promise.allSettled(list.map((spec) => resolveSpec(spec, controller.signal, opts.flyTo === true)));
try { try {
for (let i = 0; i < list.length; i += 1) { for (let i = 0; i < list.length; i += 1) {
@@ -385,7 +385,7 @@ export function createAnnotationEngine({
}; };
} }
async function resolveSpec(spec, signal) { async function resolveSpec(spec, signal, allowDistant = false) {
const type = normalizeType(spec?.type); const type = normalizeType(spec?.type);
if (type === 'route') { if (type === 'route') {
const points = Array.isArray(spec.points) ? spec.points : []; const points = Array.isArray(spec.points) ? spec.points : [];
@@ -395,13 +395,15 @@ export function createAnnotationEngine({
for (const pt of points) { for (const pt of points) {
const name = pt.target ?? pt.name ?? null; const name = pt.target ?? pt.name ?? null;
const r = await resolveTarget({ const r = await resolveTarget({
placeSearch, viewer, placeSearch,
viewer,
target: name, target: name,
latitude: pt.latitude, latitude: pt.latitude,
longitude: pt.longitude, longitude: pt.longitude,
screenX: pt.screenX, screenX: pt.screenX,
screenY: pt.screenY, screenY: pt.screenY,
footprint: false, footprint: false,
allowDistant,
signal, signal,
}); });
if (r) resolvedPts.push(r); if (r) resolvedPts.push(r);
@@ -438,23 +440,27 @@ export function createAnnotationEngine({
} }
if (type === 'arrow') { if (type === 'arrow') {
const from = await resolveTarget({ const from = await resolveTarget({
placeSearch, viewer, placeSearch,
viewer,
target: spec.target, target: spec.target,
latitude: spec.latitude, latitude: spec.latitude,
longitude: spec.longitude, longitude: spec.longitude,
screenX: spec.screenX, screenX: spec.screenX,
screenY: spec.screenY, screenY: spec.screenY,
footprint: false, footprint: false,
allowDistant,
signal, signal,
}); });
const to = await resolveTarget({ const to = await resolveTarget({
placeSearch, viewer, placeSearch,
viewer,
target: spec.toTarget, target: spec.toTarget,
latitude: spec.toLatitude, latitude: spec.toLatitude,
longitude: spec.toLongitude, longitude: spec.toLongitude,
screenX: spec.toScreenX, screenX: spec.toScreenX,
screenY: spec.toScreenY, screenY: spec.toScreenY,
footprint: false, footprint: false,
allowDistant,
signal, signal,
}); });
if (!from || !to) { if (!from || !to) {
@@ -473,7 +479,8 @@ export function createAnnotationEngine({
} }
const wantFootprint = type === 'area' ? spec.footprint !== false : Boolean(spec.footprint); const wantFootprint = type === 'area' ? spec.footprint !== false : Boolean(spec.footprint);
return resolveTarget({ return resolveTarget({
placeSearch, viewer, placeSearch,
viewer,
target: spec.target, target: spec.target,
latitude: spec.latitude, latitude: spec.latitude,
longitude: spec.longitude, longitude: spec.longitude,
@@ -490,6 +497,7 @@ export function createAnnotationEngine({
// timeout — field test 7 logs); deferring them lets the mark appear and the tool // timeout — field test 7 logs); deferring them lets the mark appear and the tool
// result return while the outline resolves, then upgrades the mark in place. // result return while the outline resolves, then upgrades the mark in place.
deferFootprint: wantFootprint, deferFootprint: wantFootprint,
allowDistant,
signal, signal,
}); });
} }
+16
View File
@@ -710,3 +710,19 @@ test('a late annotation resolver cannot redraw after destruction', async (t) =>
assert.equal(calls.add, 0); assert.equal(calls.add, 0);
assert.equal(engine.count(), 0); assert.equal(engine.count(), 0);
}); });
test('only an explicit navigation request permits resolving distant annotation targets', async (t) => {
installAnimationFrameStubs(t);
_resetRenderGovernorForTest();
t.after(() => _resetRenderGovernorForTest());
const { renderer } = throwingRendererHarness();
renderer.destroy = () => {};
const received = [];
const engine = createAnnotationEngine({ viewer: {}, renderer,
resolveTarget: async (options) => { received.push(options); return null; },
});
await engine.annotate([{ type: 'point', target: 'Remote landmark' }]);
await engine.annotate([{ type: 'point', target: 'Remote landmark' }], { flyTo: true });
assert.deepEqual(received.map(options => options.allowDistant), [false, true]);
engine.destroy();
});
+5 -34
View File
@@ -1,3 +1,4 @@
import { applicationServices } from '../services/application.js';
import { defaultGeospatial } from '../search/defaults.js'; import { defaultGeospatial } from '../search/defaults.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { lookupNeighborhoodRing } from '../data/neighborhoodPolygons.js'; import { lookupNeighborhoodRing } from '../data/neighborhoodPolygons.js';
@@ -93,7 +94,7 @@ function linkAbort(controller, externalSignal) {
export async function resolveAnnotationTarget({ export async function resolveAnnotationTarget({
placeSearch = unavailablePlaceSearch, placeSearch = unavailablePlaceSearch,
viewer, target, latitude, longitude, footprint = false, intent = 'the_thing', viewer, target, latitude, longitude, footprint = false, intent = 'the_thing',
entityKind = null, labelHint = null, deferFootprint = false, screenX, screenY, signal, entityKind = null, labelHint = null, deferFootprint = false, allowDistant = false, screenX, screenY, signal,
}) { }) {
let lon = Number(longitude); let lon = Number(longitude);
let lat = Number(latitude); let lat = Number(latitude);
@@ -158,7 +159,7 @@ export async function resolveAnnotationTarget({
// LOOKING AT. If the geocode missed or landed far from the view centre, try a view-biased // LOOKING AT. If the geocode missed or landed far from the view centre, try a view-biased
// Places Text Search; a hit within the trust bound overrides + skips the gate. Local geocodes // Places Text Search; a hit within the trust bound overrides + skips the gate. Local geocodes
// (neighborhoods, nearby buildings) are NOT far, so they keep the geocode + scope/polygon path. // (neighborhoods, nearby buildings) are NOT far, so they keep the geocode + scope/polygon path.
if (center && trace.places === 'skipped' && !bypassNearViewGuards) { if (center && trace.places === 'skipped' && !bypassNearViewGuards && !allowDistant) {
let geocodeFar = source !== 'geocode'; let geocodeFar = source !== 'geocode';
if (source === 'geocode' && approximateDistanceM(center.lat, center.lon, lat, lon) / 1000 > MIN_DRIFT_FLOOR_KM) { if (source === 'geocode' && approximateDistanceM(center.lat, center.lon, lat, lon) / 1000 > MIN_DRIFT_FLOOR_KM) {
geocodeFar = true; geocodeFar = true;
@@ -243,7 +244,7 @@ export async function resolveAnnotationTarget({
const limitKm = Math.max(VIEWPORT_DRIFT_FACTOR * vpGate.radiusKm, MIN_DRIFT_FLOOR_KM); const limitKm = Math.max(VIEWPORT_DRIFT_FACTOR * vpGate.radiusKm, MIN_DRIFT_FLOOR_KM);
return driftKm > limitKm ? { driftKm, limitKm } : null; return driftKm > limitKm ? { driftKm, limitKm } : null;
}; };
if (fromGeocode && !bypassNearViewGuards) { if (fromGeocode && !bypassNearViewGuards && !allowDistant) {
const drift = gateDrift(lat, lon); const drift = gateDrift(lat, lon);
if (drift) { if (drift) {
if (trace.query) { if (trace.query) {
@@ -739,48 +740,18 @@ export function refineScope(scope, entityKind) {
return scope; return scope;
} }
/** True when an HTTP-200 Overpass body actually signals a runtime FAILURE (server-side
* timeout / out-of-memory) via its `remark` — a transient error, not an authoritative
* empty result, so callers must not cache it as a definitive not-found. */
function overpassHasError(data) {
const remark = String(data?.remark || '').toLowerCase();
return remark.includes('runtime error') || remark.includes('timed out') || remark.includes('out of memory');
}
/** A distinct Overpass throttle result that must not enter the ordinary transient ladder. */ /** A distinct Overpass throttle result that must not enter the ordinary transient ladder. */
export function isRateLimitedOutcome(value) { export function isRateLimitedOutcome(value) {
return value?.rateLimited === true; return value?.rateLimited === true;
} }
/** Parse Retry-After seconds or an HTTP date into a non-negative millisecond delay. */
function parseRetryAfterMs(value) {
if (value == null || String(value).trim() === '') return null;
const seconds = Number(value);
if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds * 1000);
const at = Date.parse(String(value));
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
}
/** POST an Overpass QL query and return elements, a transient null, or a throttle object. */ /** POST an Overpass QL query and return elements, a transient null, or a throttle object. */
async function overpassJson(query, timeoutMs = 14000, signal) { async function overpassJson(query, timeoutMs = 14000, signal) {
const controller = new AbortController(); const controller = new AbortController();
const detach = linkAbort(controller, signal); const detach = linkAbort(controller, signal);
const timer = window.setTimeout(() => controller.abort(), timeoutMs); const timer = window.setTimeout(() => controller.abort(), timeoutMs);
try { try {
const res = await fetch('/api/overpass', { return await applicationServices.boundaries.query(query, { signal: controller.signal });
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `data=${encodeURIComponent(query)}`,
signal: controller.signal,
});
const retryAfter = res.headers?.get?.('Retry-After');
if (res.status === 429 || (res.status === 503 && retryAfter != null)) {
return { rateLimited: true, retryAfterMs: parseRetryAfterMs(retryAfter) };
}
if (!res.ok) return null;
const data = await res.json();
if (overpassHasError(data)) return null; // 200 with a body-level timeout/error → transient
return Array.isArray(data?.elements) ? data.elements : null;
} catch { } catch {
return null; return null;
} finally { } finally {
+39
View File
@@ -0,0 +1,39 @@
import { StyleManager } from '../ui/composition.js';
import { flyToAustin } from '../camera.js';
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
/** Construct the existing controls and camera presentation. */
export function createApplicationControls({
scene: { viewer, mapStackController },
loaderStatus,
Controls = StyleManager,
services,
placeSearch,
defer,
}) {
// Initialize the style manager (post-processing, HUD, locations, share links)
const styleManager = new Controls(viewer, {
services,
mapStackController,
placeSearch,
});
defer(() => styleManager.orbitController.stop());
defer(() => styleManager.hud.destroy());
defer(() => styleManager.dispose());
// The previous multi-canvas weather compositor remains disabled. Cockpit
// clouds use a separate, capped low-resolution GPU pass that never attaches
// Cesium fog or post-process stages and is fully stopped in map mode.
const weatherEffects = null;
const cockpitCloudEffects = initCockpitCloudEffects(viewer);
defer(() => cockpitCloudEffects?.destroy());
// If no share link state, do default fly-to Austin
if (!styleManager.hasShareState) {
loaderStatus.textContent = 'Flying to Austin, TX...';
defer(flyToAustin(viewer));
} else {
loaderStatus.textContent = 'Restoring shared view...';
}
return { styleManager, weatherEffects, cockpitCloudEffects };
}
+82
View File
@@ -0,0 +1,82 @@
import { DataLayerManager } from '../data/manager.js';
import flightsLayer from '../data/flights.js';
import militaryFlightsLayer from '../data/militaryFlights.js';
import alprCamerasLayer from '../data/alprCameras.js';
import earthquakesLayer from '../data/earthquakes.js';
import satellitesLayer from '../data/satellites.js';
import rocketLaunchesLayer from '../data/rocketLaunches.js';
import trafficLayer from '../data/traffic.js';
import cctvLayer from '../data/cctv.js';
import radioLayer from '../data/radio.js';
import bikeshareLayer from '../data/bikeshare.js';
import aisLiveVesselsLayer from '../data/aisLiveVessels.js';
import militaryInstallationsLayer from '../data/militaryInstallations.js';
import militaryAwarenessLayer from '../data/militaryAwareness.js';
import localDataLayers from '../data/localLayers.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
/** Register the application layer catalog before allowing state restoration. */
export function createApplicationData({
scene: { viewer },
controls: { styleManager },
allowQaRegistration,
onData,
defer,
}) {
// Initialize data layer manager
const dataManager = new DataLayerManager(viewer, {
allowQaRegistration,
});
defer(async () => {
await dataManager.destroyAll();
if (dataManager.layers.size)
throw new Error(
`Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`,
);
});
onData?.(dataManager);
dataManager.register(flightsLayer);
dataManager.register(militaryFlightsLayer);
dataManager.register(earthquakesLayer);
dataManager.register(alprCamerasLayer);
dataManager.register(satellitesLayer);
dataManager.register(rocketLaunchesLayer);
rocketLaunchesLayer.attachDataManager(dataManager);
dataManager.register(trafficLayer);
dataManager.register(cctvLayer);
dataManager.register(radioLayer);
dataManager.register(bikeshareLayer);
dataManager.register(aisLiveVesselsLayer);
dataManager.register(militaryInstallationsLayer);
dataManager.register(militaryAwarenessLayer);
militaryAwarenessLayer.attachDataManager(dataManager);
for (const layer of localDataLayers) {
dataManager.register(layer);
}
// Restoration starts only after the complete production registry is sealed.
dataManager.finalizeRegistrations(LAYER_STATE_REGISTRY);
if (allowQaRegistration) {
window.__gevQaRegisterLayer = (targetManager, layerModule) => {
if (targetManager !== dataManager)
throw new Error('QA layer manager mismatch');
return dataManager.registerForQa(layerModule);
};
window.__gevQaUnregisterLayer = (targetManager, layerId) => {
if (targetManager !== dataManager)
throw new Error('QA layer manager mismatch');
return dataManager.unregisterForQa(layerId);
};
const register = window.__gevQaRegisterLayer;
const unregister = window.__gevQaUnregisterLayer;
defer(() => {
if (window.__gevQaRegisterLayer === register)
delete window.__gevQaRegisterLayer;
if (window.__gevQaUnregisterLayer === unregister)
delete window.__gevQaUnregisterLayer;
});
}
dataManager.buildTogglePanel(document.getElementById('data-toggles'));
styleManager.attachDataManager(dataManager);
return { dataManager };
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Extract a human-readable error message from any thrown value.
* Handles Error objects, strings, and plain objects with message/error fields.
* @param {*} error — caught exception value
* @returns {string} best-effort error description
*/
export function describeError(error) {
if (!error) return 'Unknown initialization error';
if (error instanceof Error) {
if (error.message && error.message.trim()) return error.message.trim();
return error.name || 'Initialization error';
}
if (typeof error === 'string' && error.trim()) return error.trim();
if (typeof error === 'object') {
const maybeMessage = String(error.message || error.error || '').trim();
if (maybeMessage) return maybeMessage;
try {
const serialized = JSON.stringify(error);
if (serialized && serialized !== '{}') return serialized;
} catch {
// ignore serialization error
}
}
return String(error);
}
+113
View File
@@ -0,0 +1,113 @@
import * as Cesium from 'cesium';
import { createApplicationViewer } from '../app/viewer.js';
import { registerDataCredits } from '../data/dataCredits.js';
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
import { MapStackController } from '../mapStackController.js';
import { loadPhotorealisticTileset } from '../mapStartup.js';
import { initLogoGaze } from '../logoGaze.js';
import {
uninstallRenderGovernor,
governorRequestRender,
} from '../renderGovernor.js';
import { describeError } from './errors.js';
/** Construct the application globe using the caller's local configuration. */
export async function createApplicationScene({
googleApiKey,
cesiumToken,
credits,
MapController = MapStackController,
mapOptions = {},
loaderStatus,
signal,
defer,
}) {
defer(initLogoGaze());
const previousKey = window.__GOOGLE_MAPS_API_KEY__;
if (googleApiKey) {
window.__GOOGLE_MAPS_API_KEY__ = googleApiKey;
defer(() => {
if (window.__GOOGLE_MAPS_API_KEY__ !== googleApiKey) return;
if (previousKey === undefined) delete window.__GOOGLE_MAPS_API_KEY__;
else window.__GOOGLE_MAPS_API_KEY__ = previousKey;
});
}
loaderStatus.textContent = 'Configuring viewer...';
// Provider attribution stays visible, including clean-view and recording.
const creditContainer = document.createElement('div');
creditContainer.id = 'cesium-credits';
document.body.appendChild(creditContainer);
defer(() => creditContainer.remove());
const viewer = createApplicationViewer({
container: 'cesiumContainer',
creditContainer,
});
defer(() => {
uninstallRenderGovernor(viewer);
if (!viewer.isDestroyed()) viewer.destroy();
});
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
loaderStatus.textContent =
googleApiKey || cesiumToken
? 'Loading Google 3D Tiles...'
: 'Loading the keyless globe...';
const photoreal = await loadPhotorealisticTileset(Cesium, {
googleApiKey,
cesiumToken,
});
const tileset = photoreal.tileset;
// A provider can finish after cancellation; retain ownership of its result.
defer(() => {
if (tileset && !tileset.isDestroyed()) {
if (!viewer.scene.primitives.remove(tileset)) tileset.destroy();
}
});
signal.throwIfAborted();
if (tileset) {
viewer.scene.primitives.add(tileset);
// NOTE: Cesium World Terrain intentionally disabled — conflicts with Google 3D Tiles at high zoom.
// Google Photorealistic 3D Tiles provide their own terrain/elevation.
viewer.scene.globe.show = false;
console.info(`[Init] Google 3D Tiles loaded via ${photoreal.route}.`);
} else {
if (photoreal.errors.length) {
const tileError = photoreal.errors.at(-1);
console.warn(
'[Init] Google 3D Tiles unavailable, using the keyless globe:',
tileError,
);
const tileErrorDetail = describeError(tileError);
loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`;
}
viewer.scene.globe.show = true;
}
loaderStatus.textContent = 'Initializing systems...';
const mapStackController = new MapController(viewer, {
requestRender: governorRequestRender,
...mapOptions,
googleTileset: tileset,
cesiumToken,
initialStack: tileset ? 'photoreal' : 'esri-imagery',
// Task 5 (height-datum fix): rebroadcast stack changes as a window
// CustomEvent so data layers (CCTV per-regime ground resolution) can
// react without coupling MapStackController to layer modules. Fires on
// 'switching'/'ready'/'error'; listeners derive the surface regime from
// live scene state, so intermediate emissions are harmless.
onChange: (state) => {
window.dispatchEvent(
new CustomEvent('gev:map-stack-changed', { detail: state }),
);
},
onError: (message) => console.warn('[MapStack]', message),
});
defer(() => mapStackController.destroy());
await mapStackController.setStack(tileset ? 'photoreal' : 'esri-imagery', {
silent: true,
});
signal.throwIfAborted();
return { viewer, tileset, mapStackController };
}
+52
View File
@@ -0,0 +1,52 @@
/** Bind a source contract once for the application without replacing its layer instance. */
export function createSourceSlot(
initial,
methods,
label = 'Source',
optional = {},
) {
let active = initial;
let binding = null;
const source = Object.fromEntries(
[...methods, ...Object.keys(optional)].map((method) => [
method,
(...args) => {
if (typeof active?.[method] !== 'function' && optional[method])
return optional[method](...args);
if (typeof active?.[method] !== 'function')
throw new Error(`${label} is not configured`);
const owner = binding;
const provider = active;
const value = provider[method](...args);
if (value && typeof value.then === 'function')
return value.then((result) => {
if (binding !== owner || active !== provider)
throw new DOMException(`${label} was replaced`, 'AbortError');
return result;
});
return value;
},
]),
);
for (const property of ['label', 'attribution']) {
Object.defineProperty(source, property, {
enumerable: true,
get: () => active?.[property],
});
}
return {
source,
configure(next) {
if (methods.some((method) => typeof next?.[method] !== 'function'))
throw new TypeError(`Invalid ${label.toLowerCase()}`);
const owner = Symbol(label);
active = next;
binding = owner;
return () => {
if (binding !== owner) return;
active = null;
binding = null;
};
},
};
}
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createSourceSlot } from './sourceSlot.js';
test('replacing a source rejects its late result and old cleanup cannot clear the new source', async () => {
let finish;
const slot = createSourceSlot({ read: async () => 'default' }, ['read']);
const old = slot.configure({ read: () => new Promise(resolve => { finish = resolve; }) });
const pending = slot.source.read();
const stop = slot.configure({ read: async () => 'replacement', attribution: { name: 'Replacement' } });
old();
finish('stale');
await assert.rejects(pending, { name: 'AbortError' });
assert.equal(await slot.source.read(), 'replacement');
assert.equal(slot.source.attribution.name, 'Replacement');
stop();
assert.throws(() => slot.source.read(), /not configured/);
});
test('an invalid replacement leaves the working source intact', async () => {
const slot = createSourceSlot({ read: async () => 42 }, ['read']);
assert.throws(() => slot.configure({}), TypeError);
assert.equal(await slot.source.read(), 42);
});
+43
View File
@@ -0,0 +1,43 @@
import flights from '../data/flights.js';
import military from '../data/militaryFlights.js';
import vessels from '../data/aisLiveVessels.js';
import { configureAlprSource } from '../data/alprCameras.js';
import { configureCctvSource } from '../data/cctv.js';
import { configureRadioSource } from '../data/radio.js';
import { configureTrafficSource } from '../data/traffic.js';
import { configureBikeshareSource } from '../data/bikeshare.js';
import { configureInstallationSource } from '../data/militaryInstallations.js';
import { configureSatelliteSource } from '../data/satellites.js';
import { configureLaunchSource } from '../data/rocketLaunches.js';
import { configureFirmsSource } from '../data/firmsHeatmap.js';
import { configureMilitaryRegistrySource } from '../data/militaryRegistry.js';
const configure = {
alpr: configureAlprSource,
cctv: configureCctvSource,
radio: configureRadioSource,
traffic: configureTrafficSource,
bikeshare: configureBikeshareSource,
installations: configureInstallationSource,
satellites: configureSatelliteSource,
launches: configureLaunchSource,
firms: configureFirmsSource,
};
/** Configure sources before any registration or state restoration starts. */
export function configureApplicationSources({
layers = {},
live = {},
signal,
defer,
}) {
for (const [name, source] of Object.entries(layers)) {
if (!configure[name]) throw new TypeError(`Unknown layer source: ${name}`);
defer(configure[name](source));
}
if (live.flights) flights.setSource(live.flights);
if (live.military) {
military.setSource(live.military);
defer(configureMilitaryRegistrySource(live.military, { signal }));
}
if (live.vessels) vessels.setSource(live.vessels);
}
export const liveLayers = Object.freeze({ flights, military, vessels });
+54
View File
@@ -0,0 +1,54 @@
import { initFirstRunExperience } from '../firstRunExperience.js';
/** Reveal welcome controls only after restoration and the loading transition. */
export function startApplicationChrome({
loadingScreen,
styleManager,
dataManager,
signal,
initializeWelcome = initFirstRunExperience,
initializeSettings,
}) {
let disposed = false;
let firstRun;
let revealTimer;
let resolveDelay;
const minimumDelay = new Promise((resolve) => {
resolveDelay = resolve;
});
const delayTimer = setTimeout(resolveDelay, 1000);
const revealFirstRun = () => {
if (disposed || signal.aborted || firstRun) return;
firstRun = initializeWelcome?.({ styleManager, dataManager });
clearTimeout(revealTimer);
loadingScreen.removeEventListener('transitionend', revealFirstRun);
};
void Promise.all([styleManager.initialRestorePromise, minimumDelay])
.catch(() => {
/* Restoration reports its own outcome through the controls. */
})
.then(() => {
if (disposed || signal.aborted) return;
loadingScreen.classList.add('hidden');
loadingScreen.addEventListener('transitionend', revealFirstRun, {
once: true,
});
revealTimer = setTimeout(revealFirstRun, 900);
});
const keySetup = Promise.resolve(
signal.aborted ? null : initializeSettings?.({ signal }),
);
// Own the pending initializer too; it must not reveal a dialog after abort.
void keySetup.catch(() =>
console.error('Provider settings initialization failed'),
);
return async () => {
disposed = true;
clearTimeout(delayTimer);
clearTimeout(revealTimer);
resolveDelay();
loadingScreen.removeEventListener('transitionend', revealFirstRun);
firstRun?.destroy();
(await keySetup.catch(() => null))?.destroy();
};
}
+121
View File
@@ -0,0 +1,121 @@
import { SceneDirector } from '../scenes/director.js';
import { initAnnotations } from '../annotations/index.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import {
installRenderGovernor,
getRenderGovernorDiagnostics,
governorRequestRender,
holdContinuousRender,
releaseContinuousRender,
} from '../renderGovernor.js';
/** Attach scene tools, rendering listeners and the application debug handle. */
export function createApplicationTools({
scene,
controls,
data,
loadingScreen,
placeSearch,
voice = {},
startChrome,
onSceneDirector,
signal,
defer,
}) {
const { viewer, tileset, mapStackController } = scene;
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
const { dataManager } = data;
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager);
defer(() => sceneDirector.destroy());
onSceneDirector?.(sceneDirector);
const annotations = initAnnotations({ viewer, tileset, placeSearch });
defer(() => {
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
annotations.destroy();
});
if (startChrome)
defer(startChrome({ loadingScreen, styleManager, dataManager, signal }));
// Idle render governor: flips the scene into requestRenderMode whenever
// nothing animates per frame. Installed AFTER every module above has had
// its chance to register pre-install holds. (perf wave 2)
installRenderGovernor(viewer);
// Install the explicit scope mask used by the DISPLAY controls.
installScopeMask(viewer);
defer(() => destroyScopeMask());
// The follow camera recomputes the tracked target's dead-reckon position
// every frame — tracking anything is a per-frame animation. (perf wave 2)
const removeTrackingListener = viewer.trackedEntityChanged.addEventListener(
() => {
if (viewer.trackedEntity) holdContinuousRender('tracked-entity');
else releaseContinuousRender('tracked-entity');
},
);
// Hidden-state suspension (perf wave 2): when the window/tab is hidden,
// stop the default render loop outright — a hidden canvas repaints for
// nobody, and browser rAF throttling still lets throttled frames burn
// GPU. Holder/data state is untouched, so return is seamless: restore
// the loop, refresh the one DOM surface we gated, render a frame.
const syncVisibilitySuspension = () => {
const hidden = document.hidden;
viewer.useDefaultRenderLoop = !hidden;
cockpitCloudEffects?.setSuspended?.(hidden);
if (!hidden) {
if (dataManager._panelRefreshPendingOnVisible) {
dataManager._panelRefreshPendingOnVisible = false;
dataManager._refreshTogglePanel();
}
governorRequestRender('visibility-restore');
}
};
document.addEventListener('visibilitychange', syncVisibilitySuspension);
defer(() =>
document.removeEventListener('visibilitychange', syncVisibilitySuspension),
);
defer(() => {
removeTrackingListener();
releaseContinuousRender('tracked-entity');
});
// Apply the CURRENT state too — bootstrap can complete while the tab is
// already hidden, and waiting for the next transition would leave the
// loop burning behind a hidden tab. (perf wave 2 fix)
syncVisibilitySuspension();
window.__godsEyeView = {
viewer,
styleManager,
tileset,
dataManager,
sceneDirector,
mapStackController,
annotations,
weatherEffects,
cockpitCloudEffects,
getRenderGovernorDiagnostics,
requestRender: governorRequestRender,
};
const debug = window.__godsEyeView;
defer(() => {
if (window.__godsEyeView === debug) delete window.__godsEyeView;
});
const voiceCommands = initGevVoiceCommands({
...voice,
signal,
placeSearch,
viewer,
styleManager,
dataManager,
sceneDirector,
annotations,
});
defer(() => {
voiceCommands.stop({ removeUi: true });
if (window.__gevVoiceCommands === voiceCommands)
delete window.__gevVoiceCommands;
});
debug.voiceCommands = voiceCommands;
return { sceneDirector, annotations, voiceCommands };
}
+7 -10
View File
@@ -1,3 +1,4 @@
import { applicationServices } from './services/application.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { deriveWeatherEffectProfile, weatherAltitudeFactors } from './weatherEffectsMath.js'; import { deriveWeatherEffectProfile, weatherAltitudeFactors } from './weatherEffectsMath.js';
@@ -368,14 +369,10 @@ export class CockpitCloudEffectsController {
this.abort?.abort(); this.abort?.abort();
this.abort = new AbortController(); this.abort = new AbortController();
const params = new URLSearchParams({ const pending = applicationServices.weather.getConditions(point.latitude, point.longitude, { signal: this.abort.signal });
latitude: point.latitude.toFixed(5), const request = this.abort;
longitude: point.longitude.toFixed(5), this.pending = pending.then(async (payload) => {
}); if (this.abort !== request || request.signal.aborted) return null;
this.pending = fetch(`/api/weather-effects?${params}`, { signal: this.abort.signal })
.then(async (response) => {
if (!response.ok) throw new Error(`Cloud weather unavailable (${response.status})`);
const payload = await response.json();
if (!payload?.weather) throw new Error('Cloud weather observation unavailable'); if (!payload?.weather) throw new Error('Cloud weather observation unavailable');
this.weather = payload.weather; this.weather = payload.weather;
this.fetchedAt = Date.now(); this.fetchedAt = Date.now();
@@ -385,6 +382,7 @@ export class CockpitCloudEffectsController {
return payload.weather; return payload.weather;
}) })
.catch((error) => { .catch((error) => {
if (request.signal.aborted || this.abort !== request) return;
if (error?.name !== 'AbortError') { if (error?.name !== 'AbortError') {
this.targetStrength = 0; this.targetStrength = 0;
this.canvas.dataset.sourceStatus = 'unavailable'; this.canvas.dataset.sourceStatus = 'unavailable';
@@ -392,8 +390,7 @@ export class CockpitCloudEffectsController {
return null; return null;
}) })
.finally(() => { .finally(() => {
this.pending = null; if (this.abort === request) { this.pending = null; this.abort = null; }
this.abort = null;
}); });
return this.pending; return this.pending;
} }
+2 -1
View File
@@ -1,3 +1,4 @@
import { expandApplicationHtml } from '../build/application-html.js';
import { readLayerSource } from './testSupport/readLayerSource.mjs'; import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { PanelLayoutController } from './ui/panelLayoutController.js'; import { PanelLayoutController } from './ui/panelLayoutController.js';
import { readShellElements } from './ui/shellElements.js'; import { readShellElements } from './ui/shellElements.js';
@@ -26,7 +27,7 @@ import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const html = fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'); const html = expandApplicationHtml(fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'));
const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8'); const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8');
const css = readStylesheet(path.join(ROOT, 'style.css')); const css = readStylesheet(path.join(ROOT, 'style.css'));
const sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8'); const sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8');
+2 -1
View File
@@ -1,3 +1,4 @@
import { expandApplicationHtml } from '../build/application-html.js';
import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { _selectContextMode } from './ui/contextTransactions.js'; import { _selectContextMode } from './ui/contextTransactions.js';
import { _syncContextModeButtons } from './ui/contextPresentation.js'; import { _syncContextModeButtons } from './ui/contextPresentation.js';
@@ -11,7 +12,7 @@ import { readFileSync } from 'node:fs';
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url)); const css = readStylesheet(new URL('../style.css', import.meta.url));
+8 -1
View File
@@ -1,3 +1,4 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import { import {
createAlprCamerasLayer, createAlprCamerasLayer,
createOverpassAlprSource, createOverpassAlprSource,
@@ -7,7 +8,13 @@ import * as context from './contextStore.js';
import * as picking from './pickRegistry.js'; import * as picking from './pickRegistry.js';
import * as groundFloor from './groundFloor.js'; import * as groundFloor from './groundFloor.js';
export * from '../layers/alpr/index.js'; export * from '../layers/alpr/index.js';
const slot = createSourceSlot(
createOverpassAlprSource(),
['fetch'],
'ALPR source',
);
export const configureAlprSource = slot.configure;
export default createAlprCamerasLayer({ export default createAlprCamerasLayer({
source: createOverpassAlprSource(), source: slot.source,
services: { render, context, picking, groundFloor }, services: { render, context, picking, groundFloor },
}); });
+8 -1
View File
@@ -1,3 +1,4 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import { createBikeshareSource } from '../layers/bikeshare/source.js'; import { createBikeshareSource } from '../layers/bikeshare/source.js';
import { createBikeshareLayer } from '../layers/bikeshare/index.js'; import { createBikeshareLayer } from '../layers/bikeshare/index.js';
import * as render from '../renderGovernor.js'; import * as render from '../renderGovernor.js';
@@ -5,8 +6,14 @@ import * as sprites from './spriteOrder.js';
import * as picking from './pickRegistry.js'; import * as picking from './pickRegistry.js';
import * as overlays from '../overlays/worldOverlay.js'; import * as overlays from '../overlays/worldOverlay.js';
const sourceSlot = createSourceSlot(
createBikeshareSource(),
['getStations'],
'Bikeshare source',
);
export const configureBikeshareSource = sourceSlot.configure;
const layer = createBikeshareLayer({ const layer = createBikeshareLayer({
source: createBikeshareSource(), source: sourceSlot.source,
services: { render, sprites, picking, overlays }, services: { render, sprites, picking, overlays },
}); });
export const createBikeshareSelectedOverlayEntry = export const createBikeshareSelectedOverlayEntry =
+8 -1
View File
@@ -1,3 +1,4 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import { createCctvSource } from '../layers/cctv/source.js'; import { createCctvSource } from '../layers/cctv/source.js';
import { createCctvLayer } from '../layers/cctv/index.js'; import { createCctvLayer } from '../layers/cctv/index.js';
import * as sprites from './spriteOrder.js'; import * as sprites from './spriteOrder.js';
@@ -11,8 +12,14 @@ import * as mesh from './meshFloorSampler.js';
import * as focus from './focusDeemphasis.js'; import * as focus from './focusDeemphasis.js';
import * as render from '../renderGovernor.js'; import * as render from '../renderGovernor.js';
const sourceSlot = createSourceSlot(
createCctvSource(),
['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'],
'Cctv source',
);
export const configureCctvSource = sourceSlot.configure;
const layer = createCctvLayer({ const layer = createCctvLayer({
source: createCctvSource(), source: sourceSlot.source,
services: { services: {
sprites, sprites,
activation, activation,
+2 -2
View File
@@ -300,12 +300,12 @@ export function registerDynamicCredit(viewer, credit) {
* present in the "Data attribution" popover. * present in the "Data attribution" popover.
* @param {Cesium.Viewer} viewer — the initialized Cesium viewer * @param {Cesium.Viewer} viewer — the initialized Cesium viewer
*/ */
export function registerDataCredits(viewer) { export function registerDataCredits(viewer, credits = DATA_CREDITS) {
const creditDisplay = viewer?.creditDisplay; const creditDisplay = viewer?.creditDisplay;
if (!creditDisplay || typeof creditDisplay.addStaticCredit !== 'function') { if (!creditDisplay || typeof creditDisplay.addStaticCredit !== 'function') {
return; return;
} }
for (const { html } of DATA_CREDITS) { for (const { html } of credits) {
// showOnScreen=false → lives in the expandable "Data attribution" popover, // showOnScreen=false → lives in the expandable "Data attribution" popover,
// not the on-globe credit line. // not the on-globe credit line.
creditDisplay.addStaticCredit(new Cesium.Credit(html, false)); creditDisplay.addStaticCredit(new Cesium.Credit(html, false));
+2 -1
View File
@@ -1,3 +1,4 @@
import { expandApplicationHtml } from '../../build/application-html.js';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
@@ -18,7 +19,7 @@ import {
} from './detectionPolicy.js'; } from './detectionPolicy.js';
import { KEYHOLE_OUTSIDE_OPACITY_DEFAULT } from '../celestialRing.js'; import { KEYHOLE_OUTSIDE_OPACITY_DEFAULT } from '../celestialRing.js';
const indexHtml = fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8'); const indexHtml = expandApplicationHtml(fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8'));
test('side aircraft brackets stay readable without changing zero-opacity intent', () => { test('side aircraft brackets stay readable without changing zero-opacity intent', () => {
assert.equal(detectionBracketAlpha('AIR', 0), 0); assert.equal(detectionBracketAlpha('AIR', 0), 0);
+8 -1
View File
@@ -1,3 +1,4 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { import {
createFirmsHeatmapLayer as createLayer, createFirmsHeatmapLayer as createLayer,
@@ -20,6 +21,12 @@ const services = {
overlays, overlays,
focus, focus,
}; };
const sourceSlot = createSourceSlot(
createFirmsSource(),
['getSnapshot'],
'Fire source',
);
export const configureFirmsSource = sourceSlot.configure;
const helpers = createFirmsHelpers({ services }); const helpers = createFirmsHelpers({ services });
export const mapAnalystRecord = helpers.mapAnalystRecord; export const mapAnalystRecord = helpers.mapAnalystRecord;
export const fireCullPosition = helpers.fireCullPosition; export const fireCullPosition = helpers.fireCullPosition;
@@ -35,7 +42,7 @@ export function createFirmsHeatmapLayer(options) {
...options, ...options,
icon: options.icon ?? '▲', icon: options.icon ?? '▲',
source: options.source ?? 'NASA FIRMS', source: options.source ?? 'NASA FIRMS',
feed: options.feed ?? createFirmsSource(), feed: options.feed ?? sourceSlot.source,
services, services,
overlayHost: options.overlayHost ?? { overlayHost: options.overlayHost ?? {
setEntries: overlays.setOverlayEntries, setEntries: overlays.setOverlayEntries,
+2 -1
View File
@@ -1,3 +1,4 @@
import { expandApplicationHtml } from '../../build/application-html.js';
import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import { readLayerSource } from '../testSupport/readLayerSource.mjs';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -377,7 +378,7 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an
'ui.js: the DISPLAY rail believes 3D is on before any layer-state sync arrives'); 'ui.js: the DISPLAY rail believes 3D is on before any layer-state sync arrives');
assert.match(ui, /this\.(?:flightState\.)?_models3dMode = 'proximity';/, assert.match(ui, /this\.(?:flightState\.)?_models3dMode = 'proximity';/,
'ui.js: and believes the mode is proximity'); 'ui.js: and believes the mode is proximity');
const html = await readFile(new URL('../../index.html', import.meta.url), 'utf8'); const html = expandApplicationHtml(await readFile(new URL('../../index.html', import.meta.url), 'utf8'));
assert.match(html, /class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true"/, assert.match(html, /class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true"/,
'index.html: the 3D button paints lit on first paint, before ui.js runs — and says so'); 'index.html: the 3D button paints lit on first paint, before ui.js runs — and says so');
assert.match(ui, /this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\.(?:flightState\.)?_models3dEnabled\),?\s*\)/, assert.match(ui, /this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\.(?:flightState\.)?_models3dEnabled\),?\s*\)/,
+8 -1
View File
@@ -1,3 +1,4 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import { import {
createInstallationsLayer, createInstallationsLayer,
createInstallationSource, createInstallationSource,
@@ -8,8 +9,14 @@ import * as ground from './groundFloor.js';
import * as anchors from './fireAnchors.js'; import * as anchors from './fireAnchors.js';
import * as picking from './pickRegistry.js'; import * as picking from './pickRegistry.js';
const sourceSlot = createSourceSlot(
createInstallationSource(),
['getMappedSites', 'searchNearby'],
'Installation source',
);
export const configureInstallationSource = sourceSlot.configure;
const layer = createInstallationsLayer({ const layer = createInstallationsLayer({
source: createInstallationSource(), source: sourceSlot.source,
services: { render, context, ground, anchors, picking }, services: { render, context, ground, anchors, picking },
}); });
export const approximateSurfaceDistanceM = layer.approximateSurfaceDistanceM; export const approximateSurfaceDistanceM = layer.approximateSurfaceDistanceM;
+3
View File
@@ -9,3 +9,6 @@ export const registerMilitaryIcaos = registry.registerMilitaryIcaos;
export const isMilitaryIcao = registry.isMilitaryIcao; export const isMilitaryIcao = registry.isMilitaryIcao;
export const refreshMilitaryRegistryIfStale = export const refreshMilitaryRegistryIfStale =
registry.refreshMilitaryRegistryIfStale; registry.refreshMilitaryRegistryIfStale;
/** Configure classification from the same source used by the aircraft layer. */
export const configureMilitaryRegistrySource = registry.configureSource;
+8 -1
View File
@@ -1,3 +1,4 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import { createRadioSource } from '../layers/radio/source.js'; import { createRadioSource } from '../layers/radio/source.js';
import { createRadioLayer } from '../layers/radio/index.js'; import { createRadioLayer } from '../layers/radio/index.js';
import * as ground from './groundFloor.js'; import * as ground from './groundFloor.js';
@@ -6,8 +7,14 @@ import * as overlays from '../overlays/worldOverlay.js';
import * as globe from '../celestialRing.js'; import * as globe from '../celestialRing.js';
import * as render from '../renderGovernor.js'; import * as render from '../renderGovernor.js';
const sourceSlot = createSourceSlot(
createRadioSource(),
['getDirectory', 'recordClick'],
'Radio source',
);
export const configureRadioSource = sourceSlot.configure;
const layer = createRadioLayer({ const layer = createRadioLayer({
source: createRadioSource(), source: sourceSlot.source,
services: { ground, picking, overlays, globe, render }, services: { ground, picking, overlays, globe, render },
}); });
export const radioGlobeLabel = layer.radioGlobeLabel; export const radioGlobeLabel = layer.radioGlobeLabel;
+3 -127
View File
@@ -1,131 +1,7 @@
const MAX_ARTICLES = 5; import { applicationServices } from '../services/application.js';
export * from './regionalModel.js';
function cleanText(value, maxLength = 180) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, maxLength);
}
function safeHttpUrl(value) {
try {
const parsed = new URL(String(value || ''));
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null;
} catch {
return null;
}
}
/** Normalize a Nominatim reverse-geocode response into cockpit-sized place context. */
export function normalizeRegionalPlace(payload) {
const address = payload?.address || {};
const locality = cleanText(
address.city || address.town || address.village || address.municipality
|| address.hamlet || address.county,
90,
);
const region = cleanText(address.state || address.region || address.county, 90);
const country = cleanText(address.country, 90);
const label = [locality, region].filter((value, index, values) => value && values.indexOf(value) === index)
.join(', ') || country || cleanText(payload?.display_name, 120);
if (!label) return null;
return {
label,
locality: locality || null,
region: region || null,
country: country || null,
countryCode: cleanText(address.country_code, 4).toUpperCase() || null,
};
}
/** Normalize and deduplicate GDELT ArticleList output without trusting article HTML. */
export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
const rows = Array.isArray(payload?.articles) ? payload.articles : [];
const seen = new Set();
const articles = [];
for (const row of rows) {
const url = safeHttpUrl(row?.url || row?.url_mobile);
const title = cleanText(row?.title, 180);
if (!url || !title) continue;
const signature = `${title.toLowerCase()}|${new URL(url).hostname}`;
if (seen.has(signature)) continue;
seen.add(signature);
const rawDate = cleanText(row?.seendate, 32);
const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate);
const publishedAt = compactDate
? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z`
: Number.isNaN(Date.parse(rawDate)) ? null : new Date(rawDate).toISOString();
articles.push({
title,
url,
domain: cleanText(row?.domain || new URL(url).hostname.replace(/^www\./, ''), 80),
publishedAt,
sourceCountry: cleanText(row?.sourcecountry, 60) || null,
});
if (articles.length >= Math.max(1, Math.min(MAX_ARTICLES, limit))) break;
}
return articles;
}
/** Normalize Open-Meteo current conditions into a small source-stamped record. */
export function normalizeRegionalWeather(payload) {
const current = payload?.current;
if (!current || !Number.isFinite(Number(current.temperature_2m))) return null;
const numberOrNull = (value) => {
if (value === null || value === undefined || value === '') return null;
return Number.isFinite(Number(value)) ? Number(value) : null;
};
// Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC,
// but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly.
const observedRaw = typeof current.time === 'string' && !/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(current.time)
? `${current.time}Z`
: current.time;
return {
observedAt: Number.isNaN(Date.parse(observedRaw)) ? null : new Date(observedRaw).toISOString(),
temperatureC: numberOrNull(current.temperature_2m),
apparentTemperatureC: numberOrNull(current.apparent_temperature),
precipitationMm: numberOrNull(current.precipitation),
cloudCoverPct: numberOrNull(current.cloud_cover),
windKph: numberOrNull(current.wind_speed_10m),
windDirectionDeg: numberOrNull(current.wind_direction_10m),
visibilityM: numberOrNull(current.visibility),
weatherCode: numberOrNull(current.weather_code),
};
}
/** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */
export function weatherCodeLabel(code) {
const value = Number(code);
if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN';
if (value === 0) return 'CLEAR';
if ([1, 2].includes(value)) return 'PARTLY CLOUDY';
if (value === 3) return 'OVERCAST';
if ([45, 48].includes(value)) return 'FOG';
if (value >= 51 && value <= 57) return 'DRIZZLE';
if (value >= 61 && value <= 67) return 'RAIN';
if (value >= 71 && value <= 77) return 'SNOW';
if (value >= 80 && value <= 82) return 'RAIN SHOWERS';
if (value >= 85 && value <= 86) return 'SNOW SHOWERS';
if (value >= 95) return 'THUNDERSTORM';
return 'MIXED CONDITIONS';
}
/** Great-circle distance used to avoid refetching a regional brief every animation frame. */
export function regionalDistanceM(from, to) {
if (![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(Number.isFinite)) {
return Infinity;
}
const phi1 = from.latitude * Math.PI / 180;
const phi2 = to.latitude * Math.PI / 180;
const deltaPhi = (to.latitude - from.latitude) * Math.PI / 180;
const deltaLambda = (to.longitude - from.longitude) * Math.PI / 180;
const a = Math.sin(deltaPhi / 2) ** 2
+ Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/** Fetch a bounded regional brief through the same-origin dev/preview proxy. */ /** Fetch a bounded regional brief through the same-origin dev/preview proxy. */
export async function fetchRegionalBrief(latitude, longitude, { signal } = {}) { export async function fetchRegionalBrief(latitude, longitude, { signal } = {}) {
if (![latitude, longitude].every(Number.isFinite)) throw new Error('Valid coordinates are required'); if (![latitude, longitude].every(Number.isFinite)) throw new Error('Valid coordinates are required');
const params = new URLSearchParams({ latitude: latitude.toFixed(5), longitude: longitude.toFixed(5) }); return applicationServices.regional.getBrief(latitude, longitude, { signal });
const response = await fetch(`/api/regional-brief?${params}`, { signal });
if (!response.ok) throw new Error(`Regional brief unavailable (${response.status})`);
return response.json();
} }
+122
View File
@@ -0,0 +1,122 @@
const MAX_ARTICLES = 5;
function cleanText(value, maxLength = 180) {
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, maxLength);
}
function safeHttpUrl(value) {
try {
const parsed = new URL(String(value || ''));
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null;
} catch {
return null;
}
}
/** Normalize a Nominatim reverse-geocode response into cockpit-sized place context. */
export function normalizeRegionalPlace(payload) {
const address = payload?.address || {};
const locality = cleanText(
address.city || address.town || address.village || address.municipality
|| address.hamlet || address.county,
90,
);
const region = cleanText(address.state || address.region || address.county, 90);
const country = cleanText(address.country, 90);
const label = [locality, region].filter((value, index, values) => value && values.indexOf(value) === index)
.join(', ') || country || cleanText(payload?.display_name, 120);
if (!label) return null;
return {
label,
locality: locality || null,
region: region || null,
country: country || null,
countryCode: cleanText(address.country_code, 4).toUpperCase() || null,
};
}
/** Normalize and deduplicate GDELT ArticleList output without trusting article HTML. */
export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) {
const rows = Array.isArray(payload?.articles) ? payload.articles : [];
const seen = new Set();
const articles = [];
for (const row of rows) {
const url = safeHttpUrl(row?.url || row?.url_mobile);
const title = cleanText(row?.title, 180);
if (!url || !title) continue;
const signature = `${title.toLowerCase()}|${new URL(url).hostname}`;
if (seen.has(signature)) continue;
seen.add(signature);
const rawDate = cleanText(row?.seendate, 32);
const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate);
const publishedAt = compactDate
? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z`
: Number.isNaN(Date.parse(rawDate)) ? null : new Date(rawDate).toISOString();
articles.push({
title,
url,
domain: cleanText(row?.domain || new URL(url).hostname.replace(/^www\./, ''), 80),
publishedAt,
sourceCountry: cleanText(row?.sourcecountry, 60) || null,
});
if (articles.length >= Math.max(1, Math.min(MAX_ARTICLES, limit))) break;
}
return articles;
}
/** Normalize Open-Meteo current conditions into a small source-stamped record. */
export function normalizeRegionalWeather(payload) {
const current = payload?.current;
if (!current || !Number.isFinite(Number(current.temperature_2m))) return null;
const numberOrNull = (value) => {
if (value === null || value === undefined || value === '') return null;
return Number.isFinite(Number(value)) ? Number(value) : null;
};
// Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC,
// but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly.
const observedRaw = typeof current.time === 'string' && !/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(current.time)
? `${current.time}Z`
: current.time;
return {
observedAt: Number.isNaN(Date.parse(observedRaw)) ? null : new Date(observedRaw).toISOString(),
temperatureC: numberOrNull(current.temperature_2m),
apparentTemperatureC: numberOrNull(current.apparent_temperature),
precipitationMm: numberOrNull(current.precipitation),
cloudCoverPct: numberOrNull(current.cloud_cover),
windKph: numberOrNull(current.wind_speed_10m),
windDirectionDeg: numberOrNull(current.wind_direction_10m),
visibilityM: numberOrNull(current.visibility),
weatherCode: numberOrNull(current.weather_code),
};
}
/** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */
export function weatherCodeLabel(code) {
const value = Number(code);
if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN';
if (value === 0) return 'CLEAR';
if ([1, 2].includes(value)) return 'PARTLY CLOUDY';
if (value === 3) return 'OVERCAST';
if ([45, 48].includes(value)) return 'FOG';
if (value >= 51 && value <= 57) return 'DRIZZLE';
if (value >= 61 && value <= 67) return 'RAIN';
if (value >= 71 && value <= 77) return 'SNOW';
if (value >= 80 && value <= 82) return 'RAIN SHOWERS';
if (value >= 85 && value <= 86) return 'SNOW SHOWERS';
if (value >= 95) return 'THUNDERSTORM';
return 'MIXED CONDITIONS';
}
/** Great-circle distance used to avoid refetching a regional brief every animation frame. */
export function regionalDistanceM(from, to) {
if (![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(Number.isFinite)) {
return Infinity;
}
const phi1 = from.latitude * Math.PI / 180;
const phi2 = to.latitude * Math.PI / 180;
const deltaPhi = (to.latitude - from.latitude) * Math.PI / 180;
const deltaLambda = (to.longitude - from.longitude) * Math.PI / 180;
const a = Math.sin(deltaPhi / 2) ** 2
+ Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2;
return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
+8 -1
View File
@@ -1,3 +1,4 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import { import {
createRocketLaunchesLayer, createRocketLaunchesLayer,
createLaunchSource, createLaunchSource,
@@ -7,8 +8,14 @@ import * as geometry from '../celestialRing.js';
import * as overlays from '../overlays/worldOverlay.js'; import * as overlays from '../overlays/worldOverlay.js';
import * as render from '../renderGovernor.js'; import * as render from '../renderGovernor.js';
const sourceSlot = createSourceSlot(
createLaunchSource(),
['getLaunches', 'getActiveTle'],
'Launch source',
);
export const configureLaunchSource = sourceSlot.configure;
const layer = createRocketLaunchesLayer({ const layer = createRocketLaunchesLayer({
source: createLaunchSource(), source: sourceSlot.source,
services: { satellites, geometry, overlays, render }, services: { satellites, geometry, overlays, render },
}); });
export const satelliteParamsForSpaceMissions = export const satelliteParamsForSpaceMissions =
+8 -1
View File
@@ -1,3 +1,4 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import { import {
createSatellitesLayer, createSatellitesLayer,
createSatelliteSource, createSatelliteSource,
@@ -10,8 +11,14 @@ import * as context from './contextStore.js';
import * as render from '../renderGovernor.js'; import * as render from '../renderGovernor.js';
import * as layerState from './layerState.js'; import * as layerState from './layerState.js';
const sourceSlot = createSourceSlot(
createSatelliteSource(),
['readGroup'],
'Satellite source',
);
export const configureSatelliteSource = sourceSlot.configure;
const layer = createSatellitesLayer({ const layer = createSatellitesLayer({
source: createSatelliteSource(), source: sourceSlot.source,
services: { picking, focus, readout, overlays, context, render, layerState }, services: { picking, focus, readout, overlays, context, render, layerState },
}); });
export const satelliteVisualsVisible = layer.satelliteVisualsVisible; export const satelliteVisualsVisible = layer.satelliteVisualsVisible;
+3 -5
View File
@@ -1,3 +1,4 @@
import { applicationServices } from '../services/application.js';
// src/data/terrainHeights.js — batched, cached client terrain-height resolver // src/data/terrainHeights.js — batched, cached client terrain-height resolver
// (docs/plans/2026-07-05-entity-height-datum-fix.md Task 3). // (docs/plans/2026-07-05-entity-height-datum-fix.md Task 3).
// //
@@ -101,11 +102,8 @@ export function cachedRealEllipsoidalGround(lat, lon) {
async function fetchChunk(chunk) { async function fetchChunk(chunk) {
// lon,lat order (matches the proxy's documented `points=lon,lat;…` contract // lon,lat order (matches the proxy's documented `points=lon,lat;…` contract
// and Task 2's implementation). // and Task 2's implementation).
const pointsParam = chunk.map(({ lat, lon }) => `${lon.toFixed(5)},${lat.toFixed(5)}`).join(';'); const results = await applicationServices.terrain.getHeights(chunk, { signal: AbortSignal.timeout(30000) });
const url = `/api/terrain/heights?points=${encodeURIComponent(pointsParam)}`; const body = { results };
const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
if (!res.ok) throw new Error(`terrain heights proxy HTTP ${res.status}`);
const body = await res.json();
if (!Array.isArray(body?.results)) throw new Error('malformed terrain heights response (no results array)'); if (!Array.isArray(body?.results)) throw new Error('malformed terrain heights response (no results array)');
if (body.results.length !== chunk.length) { if (body.results.length !== chunk.length) {
throw new Error( throw new Error(
+12 -1
View File
@@ -1,10 +1,21 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import { createTrafficSource } from '../layers/traffic/source.js'; import { createTrafficSource } from '../layers/traffic/source.js';
import { createTrafficLayer } from '../layers/traffic/index.js'; import { createTrafficLayer } from '../layers/traffic/index.js';
import * as credits from './dataCredits.js'; import * as credits from './dataCredits.js';
import * as render from '../renderGovernor.js'; import * as render from '../renderGovernor.js';
const sourceSlot = createSourceSlot(
createTrafficSource(),
['requestRoads', 'getStatus', 'fetchFlowForBounds'],
'Traffic source',
{
getFlowSessionStats: () => ({ tilesFetched: 0 }),
resetFlowTileCache: () => {},
},
);
export const configureTrafficSource = sourceSlot.configure;
const layer = createTrafficLayer({ const layer = createTrafficLayer({
source: createTrafficSource(), source: sourceSlot.source,
services: { credits, render }, services: { credits, render },
}); });
export const getTrafficTimingDiagnostics = layer.getTrafficTimingDiagnostics; export const getTrafficTimingDiagnostics = layer.getTrafficTimingDiagnostics;
+2 -2
View File
@@ -20,8 +20,8 @@ async function launch(overrides = {}, dotenv = '') {
for (const name of ['dev-cctv.sh', 'dev-fresh.sh', 'read-dotenv-value.mjs']) { for (const name of ['dev-cctv.sh', 'dev-fresh.sh', 'read-dotenv-value.mjs']) {
await fs.copyFile(new URL(`../scripts/${name}`, import.meta.url), path.join(root, 'scripts', name)); await fs.copyFile(new URL(`../scripts/${name}`, import.meta.url), path.join(root, 'scripts', name));
} }
await fs.mkdir(path.join(root, 'src', 'standalone'), { recursive: true }); await fs.mkdir(path.join(root, 'src', 'app'), { recursive: true });
await fs.copyFile(new URL('./standalone/data.js', import.meta.url), path.join(root, 'src', 'standalone', 'data.js')); await fs.copyFile(new URL('./app/data.js', import.meta.url), path.join(root, 'src', 'app', 'data.js'));
await fs.mkdir(path.join(root, 'node_modules')); await fs.mkdir(path.join(root, 'node_modules'));
await fs.symlink(fileURLToPath(new URL('../node_modules/vite', import.meta.url)), path.join(root, 'node_modules', 'vite'), 'dir'); await fs.symlink(fileURLToPath(new URL('../node_modules/vite', import.meta.url)), path.join(root, 'node_modules', 'vite'), 'dir');
await fs.writeFile(path.join(root, '.env'), dotenv); await fs.writeFile(path.join(root, '.env'), dotenv);
+4 -3
View File
@@ -1,3 +1,4 @@
import { expandApplicationHtml } from '../build/application-html.js';
import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js'; import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js';
import test from 'node:test'; import test from 'node:test';
@@ -550,8 +551,8 @@ test('the decision table is written down where the next editor will read it', ()
// ── Markup, startup ordering, accessibility ───────────────────────────────── // ── Markup, startup ordering, accessibility ─────────────────────────────────
test('markup, startup ordering and accessibility remain pinned', () => { test('markup, startup ordering and accessibility remain pinned', () => {
const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const html = expandApplicationHtml(fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const startup = fs.readFileSync(new URL('./standalone/startupChrome.js', import.meta.url), 'utf8'); const startup = fs.readFileSync(new URL('./app/startupChrome.js', import.meta.url), 'utf8');
const css = readStylesheet(new URL('../style.css', import.meta.url)); const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match(html, /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/); assert.match(html, /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/);
@@ -584,7 +585,7 @@ test('markup, startup ordering and accessibility remain pinned', () => {
assert.match(startup, /styleManager\.initialRestorePromise/); assert.match(startup, /styleManager\.initialRestorePromise/);
assert.ok(startup.indexOf("loadingScreen.classList.add('hidden')") < startup.indexOf("loadingScreen.addEventListener('transitionend', revealFirstRun")); assert.ok(startup.indexOf("loadingScreen.classList.add('hidden')") < startup.indexOf("loadingScreen.addEventListener('transitionend', revealFirstRun"));
assert.match(startup, /initFirstRunExperience\(\{ styleManager, dataManager \}\)/); assert.match(startup, /initializeWelcome\?\.\(\{ styleManager, dataManager \}\)/);
assert.match(css, /body\.ui-clean-view #first-run-launcher/); assert.match(css, /body\.ui-clean-view #first-run-launcher/);
assert.match(css, /body\.recording-mode #first-run-launcher/); assert.match(css, /body\.recording-mode #first-run-launcher/);
+9 -10
View File
@@ -1,3 +1,4 @@
import { applicationServices } from './services/application.js';
/** /**
* @module hud * @module hud
* @description Intelligence HUD Overlay — NRO/NGA Satellite Aesthetic. * @description Intelligence HUD Overlay — NRO/NGA Satellite Aesthetic.
@@ -35,7 +36,6 @@ const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']);
/** Allowed HUD layout variants. */ /** Allowed HUD layout variants. */
const HUD_VARIANTS = new Set(['tactical', 'operator', 'minimal']); const HUD_VARIANTS = new Set(['tactical', 'operator', 'minimal']);
const HUD_SUMMARY_INTERVAL_MS = 15000; const HUD_SUMMARY_INTERVAL_MS = 15000;
const HUD_SUMMARY_URL = '/api/openai/hud-summary';
/** /**
* Cell size (degrees) for the ALT readout's geoid-undulation cache. N changes * Cell size (degrees) for the ALT readout's geoid-undulation cache. N changes
@@ -66,7 +66,9 @@ export class IntelHUD {
* @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for * @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for
* camera telemetry and coordinate derivation. * camera telemetry and coordinate derivation.
*/ */
constructor(viewer, { placeSearch } = {}) { constructor(viewer, { placeSearch, summaryPolicy = {}, basemapContext = {} } = {}) {
this.summaryPolicy = summaryPolicy;
this.basemapContext = basemapContext;
this.placeSearch = placeSearch; this.placeSearch = placeSearch;
this.viewer = viewer; this.viewer = viewer;
this._visible = false; this._visible = false;
@@ -627,6 +629,7 @@ export class IntelHUD {
return; return;
} }
if (!force && !this._summaryDirty) return; if (!force && !this._summaryDirty) return;
if (this.summaryPolicy.canRequest?.() === false) return;
const revision = this._summaryRevision; const revision = this._summaryRevision;
// Every caller invokes this as `void this._updateSummary(...)`, so nothing // Every caller invokes this as `void this._updateSummary(...)`, so nothing
@@ -660,13 +663,9 @@ export class IntelHUD {
const timeout = window.setTimeout(() => controller.abort(), 5000); const timeout = window.setTimeout(() => controller.abort(), 5000);
this._summaryRequest = controller; this._summaryRequest = controller;
try { try {
const response = await fetch(HUD_SUMMARY_URL, { this.summaryPolicy.onRequest?.();
method: 'POST', const response = await applicationServices.summary.summarize(context, { signal: controller.signal });
headers: { 'Content-Type': 'application/json' }, const data = response.data;
body: JSON.stringify(context),
signal: controller.signal,
});
const data = await response.json().catch(() => null);
if (revision !== this._summaryRevision) return; if (revision !== this._summaryRevision) return;
if (isHudSummaryUnconfigured(response.status, data)) { if (isHudSummaryUnconfigured(response.status, data)) {
this._setSummaryText(fallbackText, animate); this._setSummaryText(fallbackText, animate);
@@ -701,7 +700,7 @@ export class IntelHUD {
} }
async _summaryContext() { async _summaryContext() {
const labels = await getBasemapLabelContext(this.viewer, this.placeSearch); const labels = await getBasemapLabelContext(this.viewer, this.placeSearch, this.basemapContext);
const enabledLayers = this._dataManager?.getAll?.() const enabledLayers = this._dataManager?.getAll?.()
?.filter((layer) => layer.enabled) ?.filter((layer) => layer.enabled)
.map((layer) => layer.name) || []; .map((layer) => layer.name) || [];
+2 -1
View File
@@ -1,8 +1,9 @@
import { expandApplicationHtml } from '../build/application-html.js';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const parameters = readFileSync(new URL('./ui/styleParameters.js', import.meta.url), 'utf8'); const parameters = readFileSync(new URL('./ui/styleParameters.js', import.meta.url), 'utf8');
// Focused markup guards; actual computed names are checked in Chromium. // Focused markup guards; actual computed names are checked in Chromium.
+1 -1
View File
@@ -33,7 +33,6 @@ import { createAlprPresentation } from './presentation.js';
export function createAlprCamerasLayer({ source, services } = {}) { export function createAlprCamerasLayer({ source, services } = {}) {
if (typeof source?.fetch !== 'function') if (typeof source?.fetch !== 'function')
throw new TypeError('ALPR requires a camera source'); throw new TypeError('ALPR requires a camera source');
const creditMarkup = alprCreditMarkup(source.attribution);
const { governorRequestRender } = services.render; const { governorRequestRender } = services.render;
const { registerPickOwner, unregisterPickOwner } = services.picking; const { registerPickOwner, unregisterPickOwner } = services.picking;
@@ -242,6 +241,7 @@ export function createAlprCamerasLayer({ source, services } = {}) {
if (state.viewer) throw new Error('ALPR layer is already initialized'); if (state.viewer) throw new Error('ALPR layer is already initialized');
state.viewer = viewer; state.viewer = viewer;
state.dataSource = new Cesium.CustomDataSource('alpr-cameras'); state.dataSource = new Cesium.CustomDataSource('alpr-cameras');
const creditMarkup = alprCreditMarkup(source.attribution);
state.credit = creditMarkup state.credit = creditMarkup
? new Cesium.Credit(creditMarkup, true) ? new Cesium.Credit(creditMarkup, true)
: null; : null;
+2 -1
View File
@@ -1,3 +1,4 @@
import { expandApplicationHtml } from '../build/application-html.js';
import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { readStylesheet } from './testSupport/readStylesheet.mjs';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -197,7 +198,7 @@ test('replacement, repetition, and hidden-tab elapsed time use the newest fixed
test('universal notice lifecycle clears on dispose and uses the one top-center live region', () => { test('universal notice lifecycle clears on dispose and uses the one top-center live region', () => {
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const disposeStart = ui.indexOf(' async dispose() {'); const disposeStart = ui.indexOf(' async dispose() {');
const disposeEnd = ui.indexOf('\n }\n', disposeStart); const disposeEnd = ui.indexOf('\n }\n', disposeStart);
const dispose = ui.slice(disposeStart, disposeEnd); const dispose = ui.slice(disposeStart, disposeEnd);
+3 -9
View File
@@ -1,3 +1,4 @@
import { applicationServices } from './services/application.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { import {
viewportBias, viewportBias,
@@ -1345,15 +1346,8 @@ async function resolveBuildingBounds(lat, lon, query) {
const controller = new AbortController(); const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), 6000); const timeout = window.setTimeout(() => controller.abort(), 6000);
try { try {
const response = await fetch('/api/overpass', { const elements = await applicationServices.boundaries.query(overpassQuery, { signal: controller.signal });
method: 'POST', return selectBuildingBounds(Array.isArray(elements) ? elements : [], lat, lon, query);
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `data=${encodeURIComponent(overpassQuery)}`,
signal: controller.signal,
});
if (!response.ok) return null;
const data = await response.json();
return selectBuildingBounds(data?.elements || [], lat, lon, query);
} catch { } catch {
return null; return null;
} finally { } finally {
+2 -1
View File
@@ -1,3 +1,4 @@
import { expandApplicationHtml } from '../build/application-html.js';
import { StyleManager } from './ui/applicationShell.js'; import { StyleManager } from './ui/applicationShell.js';
import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js'; import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js';
import test from 'node:test'; import test from 'node:test';
@@ -6,7 +7,7 @@ import { readFileSync } from 'node:fs';
// Exercise the installed event routes and central close method, without WebGL. // Exercise the installed event routes and central close method, without WebGL.
const source = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); const source = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const markup = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const markup = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const locationMarkup = markup.slice(markup.indexOf('<div id="location-bar"'), markup.indexOf('<div id="left-panel-stack"')); const locationMarkup = markup.slice(markup.indexOf('<div id="location-bar"'), markup.indexOf('<div id="left-panel-stack"'));
const locationToggleMarkup = locationMarkup.match(/<button\b([^>]*\bid="location-bar-toggle"[^>]*)>([\s\S]*?)<\/button>/); const locationToggleMarkup = locationMarkup.match(/<button\b([^>]*\bid="location-bar-toggle"[^>]*)>([\s\S]*?)<\/button>/);
const locationToggleAttributes = Object.fromEntries( const locationToggleAttributes = Object.fromEntries(
+4 -1
View File
@@ -1,3 +1,4 @@
import { expandApplicationHtml } from '../build/application-html.js';
import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { readStylesheet } from './testSupport/readStylesheet.mjs';
// MAP STACK chip row — the dropdown's replacement control surface. // MAP STACK chip row — the dropdown's replacement control surface.
// //
@@ -481,7 +482,9 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
}); });
test('the Visual Presets tray owns Map Source and the retired left panel is absent', () => { test('the Visual Presets tray owns Map Source and the retired left panel is absent', () => {
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const html = expandApplicationHtml(
readFileSync(new URL('../index.html', import.meta.url), 'utf8'),
);
const ui = readFileSync( const ui = readFileSync(
new URL('./ui/applicationShell.js', import.meta.url), new URL('./ui/applicationShell.js', import.meta.url),
'utf8', 'utf8',
+2 -1
View File
@@ -1,3 +1,4 @@
import { expandApplicationHtml } from '../../build/application-html.js';
import { readStylesheet } from '../testSupport/readStylesheet.mjs'; import { readStylesheet } from '../testSupport/readStylesheet.mjs';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -463,7 +464,7 @@ test('lifecycle is idempotent and teardown removes listeners, observers, and DOM
assert.deepEqual(root.children, [canvas], assert.deepEqual(root.children, [canvas],
'the overlay root carries only the shared card canvas'); 'the overlay root carries only the shared card canvas');
assert.doesNotMatch( assert.doesNotMatch(
readFileSync(new URL('../../index.html', import.meta.url), 'utf8'), expandApplicationHtml(readFileSync(new URL('../../index.html', import.meta.url), 'utf8')),
/world-overlay-detection-surface/, /world-overlay-detection-surface/,
'the surface is runtime host-owned, not static markup', 'the surface is runtime host-owned, not static markup',
); );
+3 -2
View File
@@ -1,3 +1,4 @@
import { expandApplicationHtml } from '../build/application-html.js';
import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { readStylesheet } from './testSupport/readStylesheet.mjs';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
@@ -262,7 +263,7 @@ test('expanded left panels integrate their headers with the container shell', ()
}); });
test('Map Source uses five compact tiles in the bottom Visual Presets tray', () => { test('Map Source uses five compact tiles in the bottom Visual Presets tray', () => {
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const css = readStylesheet(new URL('../style.css', import.meta.url)); const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.doesNotMatch(html, /id="stack-panel"/); assert.doesNotMatch(html, /id="stack-panel"/);
@@ -275,7 +276,7 @@ test('Map Source uses five compact tiles in the bottom Visual Presets tray', ()
}); });
test('expanded right panels highlight the title divider without changing collapsed launchers', () => { test('expanded right panels highlight the title divider without changing collapsed launchers', () => {
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const css = readStylesheet(new URL('../style.css', import.meta.url)); const css = readStylesheet(new URL('../style.css', import.meta.url));
assert.match( assert.match(
+2 -1
View File
@@ -1,3 +1,4 @@
import { expandApplicationHtml } from '../build/application-html.js';
import { readLayerSource } from './testSupport/readLayerSource.mjs'; import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { readStylesheet } from './testSupport/readStylesheet.mjs';
import { readFileSync as readRadioSource } from 'node:fs'; import { readFileSync as readRadioSource } from 'node:fs';
@@ -9,7 +10,7 @@ import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import test from 'node:test'; import test from 'node:test';
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const radio = ['playback', 'interaction'].map(name => const radio = ['playback', 'interaction'].map(name =>
readFileSync(new URL(`./layers/radio/${name}.js`, import.meta.url), 'utf8') readFileSync(new URL(`./layers/radio/${name}.js`, import.meta.url), 'utf8')
+2 -1
View File
@@ -1,3 +1,4 @@
import { expandApplicationHtml } from '../build/application-html.js';
// src/reasonableDefaults.test.mjs // src/reasonableDefaults.test.mjs
// //
// What the console looks like the FIRST time it opens — before any share link, // What the console looks like the FIRST time it opens — before any share link,
@@ -44,7 +45,7 @@ import { ShareLinkManager } from './sharelink.js';
// Follow the UI wiring and its extracted preset definitions. // Follow the UI wiring and its extracted preset definitions.
const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8') const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8')
+ '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8'); + '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8');
const indexHtml = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); const indexHtml = expandApplicationHtml(fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'));
const shareSource = fs.readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8'); const shareSource = fs.readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8');
/** Slice ui.js between two literal anchors, so a pin reads one method, not the file. */ /** Slice ui.js between two literal anchors, so a pin reads one method, not the file. */
+40
View File
@@ -0,0 +1,40 @@
import { createSourceSlot } from '../app/sourceSlot.js';
import { createApplicationRequestServices } from './requests.js';
// Compatibility owners are page-scoped, like the viewer and its layer registry.
const defaults = createApplicationRequestServices();
const slots = Object.fromEntries(
Object.entries({
boundaries: ['query'],
terrain: ['getHeights'],
regional: ['getBrief'],
weather: ['getConditions'],
summary: ['summarize'],
}).map(([name, methods]) => [
name,
createSourceSlot(defaults[name], methods, `${name} service`),
]),
);
export const applicationServices = Object.freeze(
Object.fromEntries(
Object.entries(slots).map(([name, slot]) => [name, slot.source]),
),
);
/** Supply selected services before constructing the application; release after its consumers. */
export function configureApplicationServices(services) {
const releases = [];
try {
for (const [name, service] of Object.entries(services)) {
if (!slots[name])
throw new TypeError(`Unknown application service: ${name}`);
releases.push(slots[name].configure(service));
}
} catch (error) {
for (const release of releases.reverse()) release();
throw error;
}
return () => {
for (const release of releases.reverse()) release();
};
}
+128
View File
@@ -0,0 +1,128 @@
/** Parse bounded retry information from a service response. */
function retryAfterMs(value) {
if (value == null || String(value).trim() === '') return null;
const seconds = Number(value);
if (Number.isFinite(seconds) && seconds >= 0)
return Math.ceil(seconds * 1000);
const at = Date.parse(String(value));
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
}
/** Construct independent request services for compatible application protocols. */
export function createApplicationRequestServices({
fetchImpl = (...args) => fetch(...args),
signal: lifetime,
endpoints = {},
} = {}) {
const urls = {
boundaries: '/api/overpass',
terrain: '/api/terrain/heights',
regional: '/api/regional-brief',
weather: '/api/weather-effects',
summary: '/api/openai/hud-summary',
...endpoints,
};
async function request(endpoint, { signal, ...init } = {}) {
signal = AbortSignal.any([lifetime, signal].filter(Boolean));
signal.throwIfAborted();
const response = await fetchImpl(endpoint, {
...init,
signal,
redirect: 'error',
});
signal.throwIfAborted();
let data = null;
try {
data = await response.json();
} catch {
/* Status remains authoritative for non-JSON errors. */
}
signal.throwIfAborted();
return {
ok: response.ok,
status: response.status,
headers: response.headers,
data,
};
}
function pointUrl(endpoint, latitude, longitude) {
if (
!Number.isFinite(latitude) ||
latitude < -90 ||
latitude > 90 ||
!Number.isFinite(longitude) ||
longitude < -180 ||
longitude > 180
)
throw new TypeError('Valid coordinates are required');
return `${endpoint}?${new URLSearchParams({ latitude: latitude.toFixed(5), longitude: longitude.toFixed(5) })}`;
}
function requireOk(response, label) {
if (!response.ok)
throw new Error(`${label} unavailable (${response.status})`);
return response.data;
}
return {
boundaries: {
async query(query, { signal } = {}) {
const response = await request(urls.boundaries, {
method: 'POST',
signal,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `data=${encodeURIComponent(query)}`,
});
const retry = response.headers?.get?.('Retry-After');
if (
response.status === 429 ||
(response.status === 503 && retry != null)
)
return { rateLimited: true, retryAfterMs: retryAfterMs(retry) };
if (!response.ok) return null;
const remark = String(response.data?.remark || '').toLowerCase();
if (/runtime error|timed out|out of memory/.test(remark)) return null;
return Array.isArray(response.data?.elements)
? response.data.elements
: null;
},
},
terrain: {
async getHeights(points, { signal } = {}) {
const query = points
.map(({ lat, lon }) => `${lon.toFixed(5)},${lat.toFixed(5)}`)
.join(';');
return requireOk(
await request(`${urls.terrain}?points=${encodeURIComponent(query)}`, {
signal,
}),
'Terrain heights',
)?.results;
},
},
regional: {
async getBrief(latitude, longitude, options) {
return requireOk(
await request(pointUrl(urls.regional, latitude, longitude), options),
'Regional brief',
);
},
},
weather: {
async getConditions(latitude, longitude, options) {
return requireOk(
await request(pointUrl(urls.weather, latitude, longitude), options),
'Weather',
);
},
},
summary: {
async summarize(context, { signal } = {}) {
return request(urls.summary, {
method: 'POST',
signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(context),
});
},
},
};
}
+44
View File
@@ -0,0 +1,44 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createApplicationRequestServices } from './requests.js';
test('independent compatible endpoints receive normalized requests without changing global fetch', async () => {
const requests = [];
const original = globalThis.fetch;
const services = createApplicationRequestServices({
endpoints: { regional: '/custom/region', terrain: '/custom/floors', summary: '/custom/summary' },
fetchImpl: async (url, init) => {
requests.push({ url, init });
return Response.json({ results: [{ ellipsoid: 10 }], summary: 'fixture' });
},
});
await services.regional.getBrief(30, -97);
assert.match(requests[0].url, /^\/custom\/region\?latitude=30.00000&longitude=-97.00000$/);
assert.deepEqual(await services.terrain.getHeights([{ lat: 30, lon: -97 }]), [{ ellipsoid: 10 }]);
const result = await services.summary.summarize({ place: 'fixture' });
assert.equal(result.data.summary, 'fixture');
assert.equal(requests[2].init.body, '{"place":"fixture"}');
assert.equal(requests[2].init.redirect, 'error');
assert.equal(globalThis.fetch, original);
});
test('lifetime cancellation discards a late response body even from an uncooperative transport', async () => {
const lifetime = new AbortController();
let finish;
const services = createApplicationRequestServices({ signal: lifetime.signal,
fetchImpl: async () => ({ ok: true, status: 200, json: () => new Promise(resolve => { finish = resolve; }) }),
});
const pending = services.weather.getConditions(30, -97);
while (!finish) await Promise.resolve();
lifetime.abort();
finish({ weather: { temperatureC: 10 } });
await assert.rejects(pending, { name: 'AbortError' });
await assert.rejects(services.regional.getBrief(30, -97), { name: 'AbortError' });
});
test('boundary throttle status survives empty or invalid error bodies', async () => {
for (const status of [429, 503]) {
const services = createApplicationRequestServices({ fetchImpl: async () => new Response('unavailable', { status, headers: { 'Retry-After': '5' } }) });
assert.deepEqual(await services.boundaries.query('fixture'), { rateLimited: true, retryAfterMs: 5000 });
}
});
+1 -36
View File
@@ -1,36 +1 @@
import { StyleManager } from '../ui.js'; export { createApplicationControls as createStandaloneControls } from '../app/controls.js';
import { flyToAustin } from '../camera.js';
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
/** Construct the existing controls and camera presentation. */
export function createStandaloneControls({
scene: { viewer, mapStackController },
loaderStatus,
placeSearch,
defer,
}) {
// Initialize the style manager (post-processing, HUD, locations, share links)
const styleManager = new StyleManager(viewer, {
mapStackController,
placeSearch,
});
defer(() => styleManager.orbitController.stop());
defer(() => styleManager.hud.destroy());
defer(() => styleManager.dispose());
// The previous multi-canvas weather compositor remains disabled. Cockpit
// clouds use a separate, capped low-resolution GPU pass that never attaches
// Cesium fog or post-process stages and is fully stopped in map mode.
const weatherEffects = null;
const cockpitCloudEffects = initCockpitCloudEffects(viewer);
defer(() => cockpitCloudEffects?.destroy());
// If no share link state, do default fly-to Austin
if (!styleManager.hasShareState) {
loaderStatus.textContent = 'Flying to Austin, TX...';
defer(flyToAustin(viewer));
} else {
loaderStatus.textContent = 'Restoring shared view...';
}
return { styleManager, weatherEffects, cockpitCloudEffects };
}
+1 -80
View File
@@ -1,80 +1 @@
import { DataLayerManager } from '../data/manager.js'; export { createApplicationData as createStandaloneData } from '../app/data.js';
import flightsLayer from '../data/flights.js';
import militaryFlightsLayer from '../data/militaryFlights.js';
import alprCamerasLayer from '../data/alprCameras.js';
import earthquakesLayer from '../data/earthquakes.js';
import satellitesLayer from '../data/satellites.js';
import rocketLaunchesLayer from '../data/rocketLaunches.js';
import trafficLayer from '../data/traffic.js';
import cctvLayer from '../data/cctv.js';
import radioLayer from '../data/radio.js';
import bikeshareLayer from '../data/bikeshare.js';
import aisLiveVesselsLayer from '../data/aisLiveVessels.js';
import militaryInstallationsLayer from '../data/militaryInstallations.js';
import militaryAwarenessLayer from '../data/militaryAwareness.js';
import localDataLayers from '../data/localLayers.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
/** Register the standalone layer catalog before allowing state restoration. */
export function createStandaloneData({
scene: { viewer },
controls: { styleManager },
allowQaRegistration,
defer,
}) {
// Initialize data layer manager
const dataManager = new DataLayerManager(viewer, {
allowQaRegistration,
});
defer(async () => {
await dataManager.destroyAll();
if (dataManager.layers.size)
throw new Error(
`Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`,
);
});
dataManager.register(flightsLayer);
dataManager.register(militaryFlightsLayer);
dataManager.register(earthquakesLayer);
dataManager.register(alprCamerasLayer);
dataManager.register(satellitesLayer);
dataManager.register(rocketLaunchesLayer);
rocketLaunchesLayer.attachDataManager(dataManager);
dataManager.register(trafficLayer);
dataManager.register(cctvLayer);
dataManager.register(radioLayer);
dataManager.register(bikeshareLayer);
dataManager.register(aisLiveVesselsLayer);
dataManager.register(militaryInstallationsLayer);
dataManager.register(militaryAwarenessLayer);
militaryAwarenessLayer.attachDataManager(dataManager);
for (const layer of localDataLayers) {
dataManager.register(layer);
}
// Restoration starts only after the complete production registry is sealed.
dataManager.finalizeRegistrations(LAYER_STATE_REGISTRY);
if (allowQaRegistration) {
window.__gevQaRegisterLayer = (targetManager, layerModule) => {
if (targetManager !== dataManager)
throw new Error('QA layer manager mismatch');
return dataManager.registerForQa(layerModule);
};
window.__gevQaUnregisterLayer = (targetManager, layerId) => {
if (targetManager !== dataManager)
throw new Error('QA layer manager mismatch');
return dataManager.unregisterForQa(layerId);
};
const register = window.__gevQaRegisterLayer;
const unregister = window.__gevQaUnregisterLayer;
defer(() => {
if (window.__gevQaRegisterLayer === register)
delete window.__gevQaRegisterLayer;
if (window.__gevQaUnregisterLayer === unregister)
delete window.__gevQaUnregisterLayer;
});
}
dataManager.buildTogglePanel(document.getElementById('data-toggles'));
styleManager.attachDataManager(dataManager);
return { dataManager };
}
+1 -25
View File
@@ -1,25 +1 @@
/** export { describeError } from '../app/errors.js';
* Extract a human-readable error message from any thrown value.
* Handles Error objects, strings, and plain objects with message/error fields.
* @param {*} error — caught exception value
* @returns {string} best-effort error description
*/
export function describeError(error) {
if (!error) return 'Unknown initialization error';
if (error instanceof Error) {
if (error.message && error.message.trim()) return error.message.trim();
return error.name || 'Initialization error';
}
if (typeof error === 'string' && error.trim()) return error.trim();
if (typeof error === 'object') {
const maybeMessage = String(error.message || error.error || '').trim();
if (maybeMessage) return maybeMessage;
try {
const serialized = JSON.stringify(error);
if (serialized && serialized !== '{}') return serialized;
} catch {
// ignore serialization error
}
}
return String(error);
}
+1 -105
View File
@@ -1,105 +1 @@
import * as Cesium from 'cesium'; export { createApplicationScene as createStandaloneScene } from '../app/scene.js';
import { createApplicationViewer } from '../app/viewer.js';
import { registerDataCredits } from '../data/dataCredits.js';
import { configureCreditKeyboardAccess } from '../creditKeyboard.js';
import { MapStackController } from '../mapStackController.js';
import { loadPhotorealisticTileset } from '../mapStartup.js';
import { initLogoGaze } from '../logoGaze.js';
import { uninstallRenderGovernor } from '../renderGovernor.js';
import { describeError } from './errors.js';
/** Construct the standalone globe using the caller's local configuration. */
export async function createStandaloneScene({
googleApiKey,
cesiumToken,
loaderStatus,
signal,
defer,
}) {
defer(initLogoGaze());
const previousKey = window.__GOOGLE_MAPS_API_KEY__;
if (googleApiKey) {
window.__GOOGLE_MAPS_API_KEY__ = googleApiKey;
defer(() => {
if (window.__GOOGLE_MAPS_API_KEY__ !== googleApiKey) return;
if (previousKey === undefined) delete window.__GOOGLE_MAPS_API_KEY__;
else window.__GOOGLE_MAPS_API_KEY__ = previousKey;
});
}
loaderStatus.textContent = 'Configuring viewer...';
// Provider attribution stays visible, including clean-view and recording.
const creditContainer = document.createElement('div');
creditContainer.id = 'cesium-credits';
document.body.appendChild(creditContainer);
defer(() => creditContainer.remove());
const viewer = createApplicationViewer({
container: 'cesiumContainer',
creditContainer,
});
defer(() => {
uninstallRenderGovernor(viewer);
if (!viewer.isDestroyed()) viewer.destroy();
});
registerDataCredits(viewer);
configureCreditKeyboardAccess(document);
loaderStatus.textContent =
googleApiKey || cesiumToken
? 'Loading Google 3D Tiles...'
: 'Loading the keyless globe...';
const photoreal = await loadPhotorealisticTileset(Cesium, {
googleApiKey,
cesiumToken,
});
const tileset = photoreal.tileset;
// A provider can finish after cancellation; retain ownership of its result.
defer(() => {
if (tileset && !tileset.isDestroyed()) {
if (!viewer.scene.primitives.remove(tileset)) tileset.destroy();
}
});
signal.throwIfAborted();
if (tileset) {
viewer.scene.primitives.add(tileset);
// NOTE: Cesium World Terrain intentionally disabled — conflicts with Google 3D Tiles at high zoom.
// Google Photorealistic 3D Tiles provide their own terrain/elevation.
viewer.scene.globe.show = false;
console.info(`[Init] Google 3D Tiles loaded via ${photoreal.route}.`);
} else {
if (photoreal.errors.length) {
const tileError = photoreal.errors.at(-1);
console.warn(
'[Init] Google 3D Tiles unavailable, using the keyless globe:',
tileError,
);
const tileErrorDetail = describeError(tileError);
loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`;
}
viewer.scene.globe.show = true;
}
loaderStatus.textContent = 'Initializing systems...';
const mapStackController = new MapStackController(viewer, {
googleTileset: tileset,
cesiumToken,
initialStack: tileset ? 'photoreal' : 'esri-imagery',
// Task 5 (height-datum fix): rebroadcast stack changes as a window
// CustomEvent so data layers (CCTV per-regime ground resolution) can
// react without coupling MapStackController to layer modules. Fires on
// 'switching'/'ready'/'error'; listeners derive the surface regime from
// live scene state, so intermediate emissions are harmless.
onChange: (state) => {
window.dispatchEvent(
new CustomEvent('gev:map-stack-changed', { detail: state }),
);
},
onError: (message) => console.warn('[MapStack]', message),
});
defer(() => mapStackController.destroy());
await mapStackController.setStack(tileset ? 'photoreal' : 'esri-imagery', {
silent: true,
});
signal.throwIfAborted();
return { viewer, tileset, mapStackController };
}
+5 -48
View File
@@ -1,51 +1,8 @@
import { initFirstRunExperience } from '../firstRunExperience.js'; import { startApplicationChrome } from '../app/startupChrome.js';
import { initKeySetup } from '../keySetup.js'; import { initKeySetup } from '../keySetup.js';
export function startStandaloneChrome(options) {
/** Reveal welcome controls only after restoration and the loading transition. */ return startApplicationChrome({
export function startStandaloneChrome({ initializeSettings: initKeySetup,
loadingScreen, ...options,
styleManager,
dataManager,
signal,
}) {
let disposed = false;
let firstRun;
let revealTimer;
let resolveDelay;
const minimumDelay = new Promise((resolve) => {
resolveDelay = resolve;
}); });
const delayTimer = setTimeout(resolveDelay, 1000);
const revealFirstRun = () => {
if (disposed || signal.aborted || firstRun) return;
firstRun = initFirstRunExperience({ styleManager, dataManager });
clearTimeout(revealTimer);
loadingScreen.removeEventListener('transitionend', revealFirstRun);
};
void Promise.all([styleManager.initialRestorePromise, minimumDelay])
.catch(() => {
/* Restoration reports its own outcome through the controls. */
})
.then(() => {
if (disposed || signal.aborted) return;
loadingScreen.classList.add('hidden');
loadingScreen.addEventListener('transitionend', revealFirstRun, {
once: true,
});
revealTimer = setTimeout(revealFirstRun, 900);
});
const keySetup = initKeySetup({ signal });
// Own the pending initializer too; it must not reveal a dialog after abort.
void keySetup.catch(() =>
console.error('Provider settings initialization failed'),
);
return async () => {
disposed = true;
clearTimeout(delayTimer);
clearTimeout(revealTimer);
resolveDelay();
loadingScreen.removeEventListener('transitionend', revealFirstRun);
firstRun?.destroy();
(await keySetup.catch(() => null))?.destroy();
};
} }
+3 -2
View File
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
import vm from 'node:vm'; import vm from 'node:vm';
const source = readFileSync( const source = readFileSync(
new URL('./startupChrome.js', import.meta.url), new URL('../app/startupChrome.js', import.meta.url),
'utf8', 'utf8',
) )
.replace(/^import .*;\n/gm, '') .replace(/^import .*;\n/gm, '')
@@ -37,7 +37,8 @@ function fixture() {
}; };
vm.createContext(context); vm.createContext(context);
vm.runInContext(source, context); vm.runInContext(source, context);
const stop = context.startStandaloneChrome({ const stop = context.startApplicationChrome({
initializeSettings: context.initKeySetup,
loadingScreen: { loadingScreen: {
classList: { add: (value) => events.push(value) }, classList: { add: (value) => events.push(value) },
addEventListener: (type, listener) => listeners.set(type, listener), addEventListener: (type, listener) => listeners.set(type, listener),
+5 -117
View File
@@ -1,120 +1,8 @@
import { SceneDirector } from '../scenes/director.js'; import { createApplicationTools } from '../app/tools.js';
import { initAnnotations } from '../annotations/index.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import {
installRenderGovernor,
getRenderGovernorDiagnostics,
governorRequestRender,
holdContinuousRender,
releaseContinuousRender,
} from '../renderGovernor.js';
import { startStandaloneChrome } from './startupChrome.js'; import { startStandaloneChrome } from './startupChrome.js';
export function createStandaloneTools(options) {
/** Attach scene tools, rendering listeners and the standalone debug handle. */ return createApplicationTools({
export function createStandaloneTools({ startChrome: startStandaloneChrome,
scene, ...options,
controls,
data,
loadingScreen,
placeSearch,
voice = {},
signal,
defer,
}) {
const { viewer, tileset, mapStackController } = scene;
const { styleManager, weatherEffects, cockpitCloudEffects } = controls;
const { dataManager } = data;
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager);
defer(() => sceneDirector.destroy());
const annotations = initAnnotations({ viewer, tileset, placeSearch });
defer(() => {
if (window.__gevAnnotations === annotations) delete window.__gevAnnotations;
annotations.destroy();
}); });
defer(
startStandaloneChrome({ loadingScreen, styleManager, dataManager, signal }),
);
// Idle render governor: flips the scene into requestRenderMode whenever
// nothing animates per frame. Installed AFTER every module above has had
// its chance to register pre-install holds. (perf wave 2)
installRenderGovernor(viewer);
// Install the explicit scope mask used by the DISPLAY controls.
installScopeMask(viewer);
defer(() => destroyScopeMask());
// The follow camera recomputes the tracked target's dead-reckon position
// every frame — tracking anything is a per-frame animation. (perf wave 2)
const removeTrackingListener = viewer.trackedEntityChanged.addEventListener(
() => {
if (viewer.trackedEntity) holdContinuousRender('tracked-entity');
else releaseContinuousRender('tracked-entity');
},
);
// Hidden-state suspension (perf wave 2): when the window/tab is hidden,
// stop the default render loop outright — a hidden canvas repaints for
// nobody, and browser rAF throttling still lets throttled frames burn
// GPU. Holder/data state is untouched, so return is seamless: restore
// the loop, refresh the one DOM surface we gated, render a frame.
const syncVisibilitySuspension = () => {
const hidden = document.hidden;
viewer.useDefaultRenderLoop = !hidden;
cockpitCloudEffects?.setSuspended?.(hidden);
if (!hidden) {
if (dataManager._panelRefreshPendingOnVisible) {
dataManager._panelRefreshPendingOnVisible = false;
dataManager._refreshTogglePanel();
}
governorRequestRender('visibility-restore');
}
};
document.addEventListener('visibilitychange', syncVisibilitySuspension);
defer(() =>
document.removeEventListener('visibilitychange', syncVisibilitySuspension),
);
defer(() => {
removeTrackingListener();
releaseContinuousRender('tracked-entity');
});
// Apply the CURRENT state too — bootstrap can complete while the tab is
// already hidden, and waiting for the next transition would leave the
// loop burning behind a hidden tab. (perf wave 2 fix)
syncVisibilitySuspension();
window.__godsEyeView = {
viewer,
styleManager,
tileset,
dataManager,
sceneDirector,
mapStackController,
annotations,
weatherEffects,
cockpitCloudEffects,
getRenderGovernorDiagnostics,
requestRender: governorRequestRender,
};
const debug = window.__godsEyeView;
defer(() => {
if (window.__godsEyeView === debug) delete window.__godsEyeView;
});
const voiceCommands = initGevVoiceCommands({
...voice,
signal,
placeSearch,
viewer,
styleManager,
dataManager,
sceneDirector,
annotations,
});
defer(() => {
voiceCommands.stop({ removeUi: true });
if (window.__gevVoiceCommands === voiceCommands)
delete window.__gevVoiceCommands;
});
debug.voiceCommands = voiceCommands;
return { sceneDirector, annotations, voiceCommands };
} }
+1 -153
View File
@@ -1,153 +1 @@
/** Compose UI controls with the application's existing engines and layer instances. */ export { StyleManager } from '../ui/composition.js';
import { StyleManager as ApplicationShell } from '../ui/applicationShell.js';
import { LocationSearch } from '../ui/location.js';
import {
CITY_POIS,
GLOBE_VIEW,
flyToGlobeView,
flyToPresetLocation,
flyToPOI,
searchAndFlyTo,
} from '../locations.js';
import { interruptCameraMotion } from '../cameraVerbs.js';
import { IntelHUD } from '../hud.js';
import { ShareLinkManager } from '../sharelink.js';
import { OrbitController } from '../orbit.js';
import {
CelestialRing,
getKeyholeFadeTuning,
isCelestialRingStyleSupported,
setKeyholeFadeTuning,
} from '../celestialRing.js';
import {
destroyTrackedReadout,
initTrackedReadout,
} from '../data/trackedReadout.js';
import {
destroyWorldOverlay,
initWorldOverlay,
} from '../overlays/worldOverlay.js';
import {
destroyDetection,
initDetection,
cycleMode as cycleDetectionMode,
getDetectionDiagnostics as readDetectionDiagnostics,
getDetectionTuning,
getMode as getDetectionMode,
setMode as setDetectionModeByLabel,
suspendDetection,
resumeDetection,
setDetectionStyle,
setDetectionTuning,
} from '../data/detection.js';
import trafficLayer from '../data/traffic.js';
import flightsLayer from '../data/flights.js';
import militaryFlightsLayer from '../data/militaryFlights.js';
import { isTr3b, toggleTr3b } from '../data/tr3bRegistry.js';
import satellitesLayer from '../data/satellites.js';
import cctvLayer from '../data/cctv.js';
import radioLayer from '../data/radio.js';
import bikeshareLayer from '../data/bikeshare.js';
import aisLiveVesselsLayer from '../data/aisLiveVessels.js';
import militaryAwarenessLayer from '../data/militaryAwareness.js';
import militaryInstallationsLayer from '../data/militaryInstallations.js';
import rocketLaunchesLayer from '../data/rocketLaunches.js';
import {
cachedGroundFloor,
cachedMeshFloor,
GROUND_FLOOR_LIFT_M,
meshFloorPreferred,
warmGroundFloor,
} from '../data/groundFloor.js';
import { sampleMeshFloorCells } from '../data/meshFloorSampler.js';
import {
holdContinuousRender,
releaseContinuousRender,
governorRequestRender,
} from '../renderGovernor.js';
import {
setScopeMaskEnabled,
isScopeMaskEnabled,
setScopeMaskFeather,
getScopeMaskFeather,
setScopeTerminusOverride,
getScopeTerminusOverride,
clampScopeTerminusPct,
} from '../scopeMask.js';
import {
fetchRegionalBrief,
regionalDistanceM,
weatherCodeLabel,
} from '../data/regionalBrief.js';
export class StyleManager extends ApplicationShell {
constructor(viewer, options = {}) {
super(viewer, {
...options,
services: {
CITY_POIS,
GLOBE_VIEW,
flyToGlobeView,
flyToPresetLocation,
flyToPOI,
searchAndFlyTo,
interruptCameraMotion,
IntelHUD,
ShareLinkManager,
OrbitController,
CelestialRing,
getKeyholeFadeTuning,
isCelestialRingStyleSupported,
setKeyholeFadeTuning,
destroyTrackedReadout,
initTrackedReadout,
destroyWorldOverlay,
initWorldOverlay,
destroyDetection,
initDetection,
cycleDetectionMode,
readDetectionDiagnostics,
getDetectionTuning,
getDetectionMode,
setDetectionModeByLabel,
suspendDetection,
resumeDetection,
setDetectionStyle,
setDetectionTuning,
trafficLayer,
flightsLayer,
militaryFlightsLayer,
isTr3b,
toggleTr3b,
satellitesLayer,
cctvLayer,
radioLayer,
bikeshareLayer,
aisLiveVesselsLayer,
militaryAwarenessLayer,
militaryInstallationsLayer,
rocketLaunchesLayer,
cachedGroundFloor,
cachedMeshFloor,
GROUND_FLOOR_LIFT_M,
meshFloorPreferred,
warmGroundFloor,
sampleMeshFloorCells,
holdContinuousRender,
releaseContinuousRender,
governorRequestRender,
setScopeMaskEnabled,
isScopeMaskEnabled,
setScopeMaskFeather,
getScopeMaskFeather,
setScopeTerminusOverride,
getScopeTerminusOverride,
clampScopeTerminusPct,
fetchRegionalBrief,
regionalDistanceM,
weatherCodeLabel,
LocationSearch,
},
});
}
}
+22
View File
@@ -0,0 +1,22 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { expandApplicationHtml, APPLICATION_TEMPLATES } from '../../build/application-html.js';
test('the standalone document expands every component once and preserves unique element ids', () => {
const source = readFileSync(new URL('../../index.html', import.meta.url), 'utf8');
const html = expandApplicationHtml(source);
assert.equal([...source.matchAll(/gev:template /g)].length, APPLICATION_TEMPLATES.length);
assert.doesNotMatch(html, /gev:template/);
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map(match => match[1]);
assert.equal(new Set(ids).size, ids.length);
assert.match(html, /id="cesiumContainer"/);
assert.match(html, /type="module" src="\/src\/main.js"/);
});
test('component selection includes only requested markup and refuses filesystem traversal', () => {
const html = expandApplicationHtml('<!-- gev:template welcome -->\n');
assert.match(html, /id="first-run-launcher"/);
assert.doesNotMatch(html, /id="cesiumContainer"/);
assert.throws(() => expandApplicationHtml('<!-- gev:template ../../.env -->'), /Unknown application template/);
});
+3 -3
View File
@@ -12,7 +12,7 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
googleApiKey: 'browser-fixture', googleApiKey: 'browser-fixture',
cesiumToken: 'ion-fixture', cesiumToken: 'ion-fixture',
}); });
assert.equal(config.plugins[1], plugin); assert.equal(config.plugins[2], plugin);
assert.equal(config.server.host, 'localhost'); assert.equal(config.server.host, 'localhost');
assert.equal(config.server.port, 4173); assert.equal(config.server.port, 4173);
assert.deepEqual(config.server.allowedHosts, [ assert.deepEqual(config.server.allowedHosts, [
@@ -51,7 +51,7 @@ test('build helper does not discover environment values or construct local provi
config.define['import.meta.env.GOOGLE_MAPS_API_KEY'], config.define['import.meta.env.GOOGLE_MAPS_API_KEY'],
undefined, undefined,
); );
assert.equal(config.plugins.length, 1); assert.equal(config.plugins.length, 2);
} finally { } finally {
if (before === undefined) delete process.env.GOOGLE_MAPS_API_KEY; if (before === undefined) delete process.env.GOOGLE_MAPS_API_KEY;
else process.env.GOOGLE_MAPS_API_KEY = before; else process.env.GOOGLE_MAPS_API_KEY = before;
@@ -63,7 +63,7 @@ test('root config retains existing named exports and standalone provider order',
assert.equal(compatibility[name], value, name); assert.equal(compatibility[name], value, name);
const config = standaloneConfig({ mode: 'test' }); const config = standaloneConfig({ mode: 'test' });
assert.deepEqual( assert.deepEqual(
config.plugins.slice(1, -1).map((plugin) => plugin.name), config.plugins.slice(2, -1).map((plugin) => plugin.name),
providers.localProviderPlugins().map((plugin) => plugin.name), providers.localProviderPlugins().map((plugin) => plugin.name),
); );
assert.equal(config.plugins.at(-2).name, 'gev-key-setup'); assert.equal(config.plugins.at(-2).name, 'gev-key-setup');
+2 -2
View File
@@ -1,2 +1,2 @@
/** Compatibility entry for the standalone application's UI composition. */ /** Compatibility entry for the application's UI composition. */
export { StyleManager } from './standalone/ui.js'; export { StyleManager } from './ui/composition.js';
+154
View File
@@ -0,0 +1,154 @@
/** Compose UI controls with the application's existing engines and layer instances. */
import { StyleManager as ApplicationShell } from './applicationShell.js';
import { LocationSearch } from './location.js';
import {
CITY_POIS,
GLOBE_VIEW,
flyToGlobeView,
flyToPresetLocation,
flyToPOI,
searchAndFlyTo,
} from '../locations.js';
import { interruptCameraMotion } from '../cameraVerbs.js';
import { IntelHUD } from '../hud.js';
import { ShareLinkManager } from '../sharelink.js';
import { OrbitController } from '../orbit.js';
import {
CelestialRing,
getKeyholeFadeTuning,
isCelestialRingStyleSupported,
setKeyholeFadeTuning,
} from '../celestialRing.js';
import {
destroyTrackedReadout,
initTrackedReadout,
} from '../data/trackedReadout.js';
import {
destroyWorldOverlay,
initWorldOverlay,
} from '../overlays/worldOverlay.js';
import {
destroyDetection,
initDetection,
cycleMode as cycleDetectionMode,
getDetectionDiagnostics as readDetectionDiagnostics,
getDetectionTuning,
getMode as getDetectionMode,
setMode as setDetectionModeByLabel,
suspendDetection,
resumeDetection,
setDetectionStyle,
setDetectionTuning,
} from '../data/detection.js';
import trafficLayer from '../data/traffic.js';
import flightsLayer from '../data/flights.js';
import militaryFlightsLayer from '../data/militaryFlights.js';
import { isTr3b, toggleTr3b } from '../data/tr3bRegistry.js';
import satellitesLayer from '../data/satellites.js';
import cctvLayer from '../data/cctv.js';
import radioLayer from '../data/radio.js';
import bikeshareLayer from '../data/bikeshare.js';
import aisLiveVesselsLayer from '../data/aisLiveVessels.js';
import militaryAwarenessLayer from '../data/militaryAwareness.js';
import militaryInstallationsLayer from '../data/militaryInstallations.js';
import rocketLaunchesLayer from '../data/rocketLaunches.js';
import {
cachedGroundFloor,
cachedMeshFloor,
GROUND_FLOOR_LIFT_M,
meshFloorPreferred,
warmGroundFloor,
} from '../data/groundFloor.js';
import { sampleMeshFloorCells } from '../data/meshFloorSampler.js';
import {
holdContinuousRender,
releaseContinuousRender,
governorRequestRender,
} from '../renderGovernor.js';
import {
setScopeMaskEnabled,
isScopeMaskEnabled,
setScopeMaskFeather,
getScopeMaskFeather,
setScopeTerminusOverride,
getScopeTerminusOverride,
clampScopeTerminusPct,
} from '../scopeMask.js';
import {
fetchRegionalBrief,
regionalDistanceM,
weatherCodeLabel,
} from '../data/regionalBrief.js';
export class StyleManager extends ApplicationShell {
constructor(viewer, options = {}) {
super(viewer, {
...options,
services: {
CITY_POIS,
GLOBE_VIEW,
flyToGlobeView,
flyToPresetLocation,
flyToPOI,
searchAndFlyTo,
interruptCameraMotion,
IntelHUD,
ShareLinkManager,
OrbitController,
CelestialRing,
getKeyholeFadeTuning,
isCelestialRingStyleSupported,
setKeyholeFadeTuning,
destroyTrackedReadout,
initTrackedReadout,
destroyWorldOverlay,
initWorldOverlay,
destroyDetection,
initDetection,
cycleDetectionMode,
readDetectionDiagnostics,
getDetectionTuning,
getDetectionMode,
setDetectionModeByLabel,
suspendDetection,
resumeDetection,
setDetectionStyle,
setDetectionTuning,
trafficLayer,
flightsLayer,
militaryFlightsLayer,
isTr3b,
toggleTr3b,
satellitesLayer,
cctvLayer,
radioLayer,
bikeshareLayer,
aisLiveVesselsLayer,
militaryAwarenessLayer,
militaryInstallationsLayer,
rocketLaunchesLayer,
cachedGroundFloor,
cachedMeshFloor,
GROUND_FLOOR_LIFT_M,
meshFloorPreferred,
warmGroundFloor,
sampleMeshFloorCells,
holdContinuousRender,
releaseContinuousRender,
governorRequestRender,
setScopeMaskEnabled,
isScopeMaskEnabled,
setScopeMaskFeather,
getScopeMaskFeather,
setScopeTerminusOverride,
getScopeTerminusOverride,
clampScopeTerminusPct,
fetchRegionalBrief,
regionalDistanceM,
weatherCodeLabel,
LocationSearch,
...options.services,
},
});
}
}
+284
View File
@@ -0,0 +1,284 @@
<!-- Aircraft first-person view. It can be entered from the visible view switcher. -->
<section id="cockpit-hud" aria-label="Aircraft cockpit view" hidden>
<div class="cockpit-visor-shell" aria-hidden="true">
<div class="cockpit-roll-arc">
<span style="--slot:-3;--depth:3">−30</span>
<span style="--slot:-2;--depth:2">−20</span>
<span style="--slot:-1;--depth:1">−10</span>
<span class="active" style="--slot:0;--depth:0">0</span>
<span style="--slot:1;--depth:1">+10</span>
<span style="--slot:2;--depth:2">+20</span>
<span style="--slot:3;--depth:3">+30</span>
</div>
<div class="cockpit-pitch-rail cockpit-pitch-rail-left">
<span>+20</span><span>+10</span><span class="active">00</span><span>−10</span><span>−20</span>
</div>
<div class="cockpit-pitch-rail cockpit-pitch-rail-right">
<span>+20</span><span>+10</span><span class="active">00</span><span>−10</span><span>−20</span>
</div>
<div class="cockpit-horizon-guide">
<span></span><small>LEVEL</small><span></span>
</div>
<div id="cockpit-route-direction" class="cockpit-route-direction" hidden aria-label="Estimated destination direction">
<svg class="cockpit-route-chevron" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M12 2 4.58 19.93c-.34.83.51 1.63 1.31 1.27L12 18.47l6.11 2.73c.8.36 1.65-.44 1.31-1.27L12 2Z" />
</svg>
<small id="cockpit-route-direction-label">DEST ---°</small>
</div>
<div class="cockpit-visor-status cockpit-visor-status-left">OPTICAL PLANE · 01</div>
<div class="cockpit-visor-status cockpit-visor-status-right">VISOR LOCK · ACTIVE</div>
</div>
<div id="cockpit-speed-rim" class="cockpit-altitude-rim cockpit-speed-rim">
<span class="cockpit-altitude-rim-label">GROUND SPEED · KTS</span>
<div class="cockpit-altitude-rim-rail"></div>
<div class="cockpit-altitude-rim-scale">
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
<span data-speed-rim-tick><b></b><i></i></span>
</div>
<div class="cockpit-altitude-rim-pointer">
<span class="material-symbols-outlined">arrow_right</span>
<strong id="cockpit-speed-rim-value">---</strong>
</div>
</div>
<div id="cockpit-altitude-rim" class="cockpit-altitude-rim">
<span class="cockpit-altitude-rim-label">ALTITUDE · FT</span>
<div class="cockpit-altitude-rim-rail"></div>
<div class="cockpit-altitude-rim-scale">
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
<span data-altitude-rim-tick><b></b><i></i></span>
</div>
<div class="cockpit-altitude-rim-pointer">
<strong id="cockpit-altitude-rim-value">-----</strong>
<span class="material-symbols-outlined">arrow_left</span>
</div>
</div>
<div class="cockpit-topline">
<div>
<span class="cockpit-kicker">FIRST PERSON</span>
<strong id="cockpit-callsign">AIRCRAFT</strong>
<span id="cockpit-aircraft-meta" class="cockpit-aircraft-meta">LIVE TRACK · COURSE ALIGNED</span>
</div>
<div class="cockpit-vision-controls" role="group" aria-label="Cockpit vision style">
<button id="cockpit-vision-previous" class="cockpit-vision-nav" type="button" aria-label="Previous cockpit vision style" title="Previous vision style">
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
<small>PREV</small>
</button>
<button id="cockpit-vision-current" class="cockpit-vision-current" type="button" data-cockpit-vision="optical" aria-label="Current cockpit vision style: NORMAL. Activate for next style." title="Current style: NORMAL — click for next">
<small>CURRENT</small>
<strong id="cockpit-vision-current-label" aria-live="polite">NORMAL</strong>
</button>
<button id="cockpit-vision-next" class="cockpit-vision-nav" type="button" aria-label="Next cockpit vision style" title="Next vision style">
<small>NEXT</small>
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
</button>
</div>
</div>
<div class="cockpit-reticle material-symbols-outlined" aria-hidden="true">my_location</div>
<div class="cockpit-compass" aria-label="Current aircraft heading">
<div class="cockpit-instrument-row">
<div class="cockpit-readout cockpit-speed">
<span class="cockpit-readout-label">GROUND SPEED</span>
<strong id="cockpit-speed-value">---</strong>
<span class="cockpit-readout-unit">KTS</span>
</div>
<div class="cockpit-heading-value"><span id="cockpit-heading-value">---</span><small>°</small></div>
<div class="cockpit-readout cockpit-altitude">
<span class="cockpit-readout-label">ALTITUDE</span>
<strong id="cockpit-altitude-value">-----</strong>
<span class="cockpit-readout-unit">FT</span>
</div>
</div>
<span class="cockpit-heading-caret material-symbols-outlined" aria-hidden="true">arrow_drop_down</span>
<div class="cockpit-compass-tape" id="cockpit-compass-tape" aria-hidden="true"></div>
</div>
<div class="cockpit-position-readout">
<span id="cockpit-clock">--:--:--Z</span>
<span id="cockpit-position">--° · --°</span>
</div>
<aside id="cockpit-context" class="cockpit-context-window" aria-label="Contact cockpit summary" hidden>
<header class="cockpit-context-header">
<div>
<span class="cockpit-context-kicker">CONTACT</span>
<strong id="cockpit-context-subject">CONTACTS · 250 KM</strong>
</div>
<nav class="cockpit-context-actions" aria-label="Contact navigation">
<button id="cockpit-context-previous" type="button" aria-label="Previous — prior visited contact in the 250 km window" title="Previous — prior visited contact in the 250 km window">
<span class="material-symbols-outlined" aria-hidden="true">skip_previous</span>
</button>
<button id="cockpit-context-next" type="button" aria-label="Next — nearest unvisited contact in the 250 km window" title="Next — nearest unvisited contact in the 250 km window">
<span class="material-symbols-outlined" aria-hidden="true">skip_next</span>
</button>
<button id="cockpit-context-toggle" type="button" aria-label="Collapse Contact panel" aria-expanded="true" title="Collapse contact panel">
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
</button>
</nav>
<span class="cockpit-context-qualifier">CONTEXT ONLY</span>
</header>
<div class="cockpit-context-cohorts" role="list" aria-label="Nearby cohort counts">
<div role="listitem" data-context-cohort="flights"><span>FLT</span><strong>—</strong><small>OpenSky</small></div>
<div role="listitem" data-context-cohort="military"><span>MIL</span><strong>—</strong><small>adsb.lol</small></div>
<div role="listitem" data-context-cohort="ais-live-vessels"><span>AIS</span><strong>—</strong><small>AISStream</small></div>
<div role="listitem" data-context-cohort="military-installations"><span>SITE</span><strong>—</strong><small>OpenStreetMap</small></div>
</div>
<div class="cockpit-context-nearest">
<span id="cockpit-context-direction" class="material-symbols-outlined" aria-hidden="true">navigation</span>
<div>
<small>NEAREST OBSERVED / MAPPED</small>
<strong id="cockpit-context-nearest-label">NO AVAILABLE EXAMPLE</strong>
</div>
<span id="cockpit-context-bearing">—</span>
<span id="cockpit-context-distance">—</span>
</div>
<footer class="cockpit-context-footer">
<span id="cockpit-context-uncertainty">AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR</span>
<button
id="cockpit-weather-toggle"
class="cockpit-context-weather"
type="button"
aria-label="Enable cockpit weather effects"
aria-pressed="false"
title="Enable cockpit weather effects"
>
<span>WX</span>
<strong id="cockpit-weather-state">OFF</strong>
</button>
<time id="cockpit-context-updated">--:--:--Z</time>
</footer>
</aside>
<aside id="cockpit-signal-stream" class="cockpit-signal-window" aria-label="Cockpit briefing carousel" hidden>
<section id="cockpit-route" class="cockpit-route-card" aria-label="Estimated flight plan" hidden>
<div class="cockpit-route-heading">
<span class="cockpit-route-kicker">ESTIMATED FLIGHT PLAN</span>
<small id="cockpit-route-status">ROUTE DATA UNAVAILABLE</small>
</div>
<div class="cockpit-route-endpoints">
<div class="cockpit-route-airport">
<span>FROM</span>
<strong id="cockpit-route-from">UNKNOWN</strong>
</div>
<span class="cockpit-route-arrow material-symbols-outlined" aria-hidden="true">east</span>
<div class="cockpit-route-airport">
<span>TO</span>
<strong id="cockpit-route-to">UNKNOWN</strong>
</div>
</div>
</section>
<header class="cockpit-signal-header">
<div class="cockpit-brief-heading">
<span id="cockpit-brief-kicker" class="cockpit-signal-kicker"><i aria-hidden="true"></i> LIVE SIGNALS</span>
<strong id="cockpit-brief-subtitle">OBSERVED / MAPPED PINGS</strong>
</div>
<nav class="cockpit-brief-actions" aria-label="Cockpit briefing controls">
<button id="cockpit-brief-previous" type="button" aria-label="Previous briefing page" title="Previous briefing page">
<span class="material-symbols-outlined" aria-hidden="true">chevron_left</span>
</button>
<span id="cockpit-brief-position" aria-live="polite">1 / 3</span>
<button id="cockpit-brief-next" type="button" aria-label="Next briefing page" title="Next briefing page">
<span class="material-symbols-outlined" aria-hidden="true">chevron_right</span>
</button>
<button
id="cockpit-brief-auto"
type="button"
aria-label="CYCLE OFF"
aria-pressed="false"
title="Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way."
>CYCLE OFF</button>
<button id="cockpit-signal-toggle" type="button" aria-label="Collapse cockpit briefing panel" aria-expanded="true" title="Collapse briefing panel">
<span class="material-symbols-outlined" aria-hidden="true">right_panel_close</span>
</button>
</nav>
</header>
<div class="cockpit-brief-stage">
<section data-cockpit-brief-page="signals" aria-label="Live signals">
<ol id="cockpit-signal-list" class="cockpit-signal-list" aria-live="polite"></ol>
</section>
<section data-cockpit-brief-page="news" aria-label="Latest regional news" hidden>
<div id="cockpit-news-status" class="cockpit-brief-status">ACQUIRING REGIONAL NEWS</div>
<ol id="cockpit-news-list" class="cockpit-news-list" aria-live="polite"></ol>
</section>
<section data-cockpit-brief-page="local" aria-label="Location-based information" hidden>
<div class="cockpit-local-place">
<strong id="cockpit-local-place">RESOLVING REGION</strong>
<small id="cockpit-local-coordinates">--</small>
</div>
<div class="cockpit-local-grid">
<div><span>TEMP</span><strong id="cockpit-local-temperature">—</strong><small>OPEN-METEO</small></div>
<div><span>WIND</span><strong id="cockpit-local-wind">—</strong><small id="cockpit-local-wind-direction">—</small></div>
<div><span>SKY</span><strong id="cockpit-local-condition">UNKNOWN</strong><small id="cockpit-local-cloud">—</small></div>
<div><span>PRECIP</span><strong id="cockpit-local-precipitation">—</strong><small>MM</small></div>
</div>
<a class="cockpit-weather-credit" href="https://open-meteo.com/" target="_blank" rel="noopener">Weather data by Open-Meteo.com</a>
</section>
</div>
<footer class="cockpit-signal-footer">
<span id="cockpit-brief-source">SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS</span>
<nav class="cockpit-brief-tabs" aria-label="Cockpit briefing pages">
<button type="button" data-cockpit-brief-index="0" aria-label="Show Live Signals" aria-current="true">SIG</button>
<button type="button" data-cockpit-brief-index="1" aria-label="Show Regional News">NEWS</button>
<button type="button" data-cockpit-brief-index="2" aria-label="Show Local Info">LOCAL</button>
</nav>
</footer>
</aside>
<aside id="cockpit-utility-controls" class="cockpit-utility-controls" aria-label="Cockpit display and Radio controls">
<div class="cockpit-utility-control">
<div class="cockpit-utility-launcher" data-cockpit-launcher="display">
<span>DISPLAY</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
<button id="cockpit-display-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-display-panel" aria-label="Expand Cockpit display options" title="Expand Cockpit display options">◀</button>
</div>
<div id="cockpit-display-panel" class="cockpit-utility-popover" role="group" aria-label="Cockpit display options" hidden>
<div class="cockpit-display-slot" data-cockpit-display-slot="hud"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="detection"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="parameters"></div>
<div class="cockpit-display-slot" data-cockpit-display-slot="models3d"></div>
</div>
</div>
<div class="cockpit-utility-control">
<div class="cockpit-utility-launcher" data-cockpit-launcher="radio">
<span>RADIO</span>
<span class="cockpit-utility-divider" aria-hidden="true"></span>
<button id="cockpit-radio-toggle-btn" class="cockpit-utility-glyph" type="button" aria-expanded="false" aria-controls="cockpit-radio-panel" aria-label="Expand Cockpit Radio controls" title="Expand Cockpit Radio controls">◀</button>
</div>
<div id="cockpit-radio-panel" class="cockpit-utility-popover cockpit-radio-compact" role="group" aria-label="Cockpit compact Radio controls" hidden>
<strong id="cockpit-radio-station" class="cockpit-radio-station">READY</strong>
<button id="cockpit-radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false">ENABLE</button>
<div class="cockpit-radio-transport">
<button id="cockpit-radio-prev-btn" type="button" aria-label="Previous filtered radio station" disabled>◀◀</button>
<button id="cockpit-radio-play-btn" type="button" aria-label="Play selected radio station" disabled>▶</button>
<button id="cockpit-radio-next-btn" type="button" aria-label="Next filtered radio station" disabled>▶▶</button>
</div>
<label class="cockpit-radio-volume" for="cockpit-radio-volume">
<span>VOLUME</span>
<input id="cockpit-radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Cockpit Radio volume" disabled />
<output id="cockpit-radio-volume-value" class="gev-slider-value" for="cockpit-radio-volume">80%</output>
</label>
</div>
</div>
</aside>
<div class="cockpit-help">ESC EXIT <span>·</span> C TOGGLE</div>
</section>
<nav id="view-switcher" aria-label="View switcher">
<button id="cockpit-reset-globe" type="button" aria-label="Reset cockpit to full globe view" title="Exit cockpit and return to full globe view" hidden>
<span class="material-symbols-outlined" aria-hidden="true">public</span>
<span>RESET</span>
</button>
<button id="map-view-switch" type="button" aria-label="Exit cockpit view" aria-keyshortcuts="Escape C" title="Exit cockpit view" hidden>
<span class="material-symbols-outlined" aria-hidden="true">close_fullscreen</span>
<span>EXIT COCKPIT</span>
</button>
</nav>
+97
View File
@@ -0,0 +1,97 @@
<!-- Unified bottom command dock: locations, voice (injected at runtime), visual presets -->
<div id="command-dock" aria-label="Navigation, voice, and visual preset controls">
<!-- Control panel -->
<div id="control-panel" class="panel-collapsible collapsed">
<div class="panel-glow"></div>
<div class="panel-inner">
<button id="control-panel-toggle" class="panel-header dock-tray-toggle" type="button"
data-dock-toggle-target="control-panel" aria-expanded="false"
aria-controls="control-panel-popover" aria-label="Expand Visual Presets">
<span class="panel-title"><span class="dock-label-icon dock-label-icon-presets" aria-hidden="true"></span>VISUAL PRESETS</span>
<span class="panel-divider"></span>
</button>
<div id="control-panel-popover" class="dock-popover-content">
<button class="dock-pin-btn" data-pin-target="control-panel" type="button" aria-label="Pin visual presets" aria-pressed="false" title="Keep visual presets open"><img src="/pin.svg" alt="" /></button>
<div id="style-buttons" class="button-grid">
<button class="style-btn active" data-style="normal" title="Show the globe without a visual filter.">
<span class="btn-icon">◯</span>
<span class="btn-label">Normal</span>
<span class="btn-key">1</span>
</button>
<button class="style-btn" data-style="retro" title="Emulate a green phosphor CRT with scanlines and screen curvature.">
<span class="btn-icon">▦</span>
<span class="btn-label">CRT</span>
<span class="btn-key">2</span>
</button>
<button class="style-btn" data-style="surveillance" title="Simulate night-vision goggles with green intensification and a tube vignette.">
<span class="btn-icon">🌙</span>
<span class="btn-label">NVG</span>
<span class="btn-key">3</span>
</button>
<button class="style-btn" data-style="thermal" title="Simulate FLIR-style thermal contrast. Turn up Ironbow for color.">
<span class="btn-icon">🌡️</span>
<span class="btn-label">FLIR</span>
<span class="btn-key">4</span>
</button>
<button class="style-btn" data-style="anime" title="Apply bright cel-shaded color and illustrated outlines.">
<span class="btn-icon">✦</span>
<span class="btn-label">Anime</span>
<span class="btn-key">5</span>
</button>
<button class="style-btn" data-style="noir" title="Apply high-contrast monochrome film-noir grading.">
<span class="btn-icon">◐</span>
<span class="btn-label">Noir</span>
<span class="btn-key">6</span>
</button>
<button class="style-btn" data-style="snow" title="Add a cold, snowy whiteout treatment to the scene.">
<span class="btn-icon">❄</span>
<span class="btn-label">Snow</span>
<span class="btn-key">7</span>
</button>
</div>
<section class="map-source-section" aria-labelledby="map-source-label">
<div class="map-source-heading">
<span id="map-source-label">MAP SOURCE</span>
<span id="map-stack-status" class="pp-stack-status" aria-live="polite">3D</span>
</div>
<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source"></div>
</section>
</div>
<div id="style-mini-status" class="panel-mini-status" aria-live="polite">
<span class="panel-mini-label">Style</span>
<span id="style-mini-value" class="panel-mini-value">NORMAL</span>
</div>
</div>
</div>
<!-- Location bar -->
<div id="location-bar" class="panel-collapsible collapsed">
<div class="panel-glow"></div>
<div class="location-inner">
<div class="location-toolbar">
<button id="location-bar-toggle" class="dock-tray-toggle" type="button" data-dock-toggle-target="location-bar" aria-controls="location-bar-popover" aria-expanded="false" aria-label="Expand LOCATION" title="Expand LOCATION">
<span class="location-toolbar-label"><span class="dock-label-icon dock-label-icon-location" aria-hidden="true"></span>LOCATION</span>
</button>
<button class="panel-collapse-btn" data-collapse-target="location-bar" title="Collapse panel">+</button>
</div>
<div id="location-mini-status" class="location-mini-status" aria-live="polite">
<div id="location-mini-city" class="location-mini-city">📍 Location: --</div>
<div id="location-mini-poi" class="location-mini-poi">Landmark: --</div>
</div>
<div id="location-bar-popover" class="dock-popover-content">
<button class="dock-pin-btn" data-pin-target="location-bar" type="button" aria-label="Pin location tray" aria-pressed="false" title="Keep location tray open"><img src="/pin.svg" alt="" /></button>
<div id="poi-row" class="poi-row-container"></div>
<div class="location-bar-divider" id="location-bar-divider"></div>
<div class="location-city-row">
<div id="location-pills" class="location-pills"></div>
<div class="location-search-wrap">
<button id="search-toggle" class="search-toggle-btn" title="Search any location">
<span>&#x1F50E;</span>
</button>
<input type="text" id="location-search" placeholder="Search any location..." autocomplete="off" spellcheck="false" aria-label="Search location by name or coordinates" />
</div>
</div>
</div>
</div>
</div>
</div>
+147
View File
@@ -0,0 +1,147 @@
<!-- Global Context is deliberately independent from the left layer accordion. -->
<aside id="right-context-rail">
<div id="global-context-panel" class="panel-collapsible collapsed" data-panel-id="global-context-panel">
<div class="panel-glow"></div>
<div class="global-context-panel-inner">
<div class="panel-header">
<span class="panel-title">CONTEXT</span>
<span class="panel-divider"></span>
<div id="context-radio-dock" class="context-radio-dock">
<button id="context-radio-toggle-btn" class="context-radio-toggle" type="button" aria-expanded="false" aria-controls="context-radio-mini" aria-label="Open compact Radio controls" title="Open compact Radio controls">
<span class="material-symbols-outlined" aria-hidden="true">radio</span>
</button>
<div id="context-radio-mini" class="context-radio-mini" role="group" aria-label="Compact Radio controls" hidden>
<div class="context-radio-mini-heading">
<span>RADIO</span>
<strong id="context-radio-mini-station">RADIO READY</strong>
<span class="context-radio-mini-actions">
<button id="context-radio-details-btn" type="button" aria-label="Open detailed Radio controls" aria-expanded="false" aria-controls="radio-panel" title="Open detailed Radio controls">
<span class="material-symbols-outlined" aria-hidden="true">open_in_full</span>
</button>
<button id="context-radio-mini-close-btn" type="button" aria-label="Close compact Radio controls" title="Close compact Radio controls">
<span class="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</span>
</div>
<button id="context-radio-mini-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false">ENABLE</button>
<div class="context-radio-mini-transport">
<button id="context-radio-mini-prev-btn" type="button" aria-label="Previous filtered radio station" title="Previous station" disabled>◀◀</button>
<button id="context-radio-mini-play-btn" type="button" aria-label="Play selected radio station" title="Play" disabled>▶</button>
<button id="context-radio-mini-next-btn" type="button" aria-label="Next filtered radio station" title="Next station" disabled>▶▶</button>
</div>
<label class="context-radio-mini-volume" for="context-radio-mini-volume">
<span>VOLUME</span>
<input id="context-radio-mini-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Compact Radio volume" disabled />
<output id="context-radio-mini-volume-value" class="gev-slider-value" for="context-radio-mini-volume">80%</output>
</label>
</div>
</div>
<button class="panel-collapse-btn" data-collapse-target="global-context-panel" title="Expand panel">▶</button>
</div>
<div class="global-context-modes" role="tablist" aria-label="Context mode">
<button id="global-context-flights-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-flights-view" aria-label="CONTACTS" title="Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.">
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
<span>CONTACTS</span>
</button>
<button id="global-context-missions-btn" class="context-mode-button" type="button" role="tab" tabindex="0" aria-selected="false" aria-controls="context-missions-view">
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
<span>SPACE MISSIONS</span>
</button>
</div>
<div id="context-mode-standby" class="context-mode-standby">
<strong>SELECT CONTEXT</strong>
<span>CONTACTS — nearest planes · vessels · sites<br>SPACE MISSIONS — launches &amp; orbital assets</span>
</div>
<section id="context-flights-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-flights-btn" hidden>
<div class="global-context-actions" aria-label="Contact Context actions">
<button id="cockpit-entry" type="button" hidden>
<span class="material-symbols-outlined" aria-hidden="true">flight</span>
<span>COCKPIT</span>
</button>
<button id="installations-search-btn" class="panel-layer-toggle" type="button">SEARCH NEARBY SITES</button>
<button id="tr3b-toggle" class="tr3b-toggle" type="button" aria-pressed="false" hidden aria-label="Reclassify tracked contact as TR-3B" title="Reclassify as TR-3B">🛸</button>
</div>
<aside id="military-awareness-panel">
<div class="military-awareness-standby">
<strong>CONTACTS CONTEXT OFF</strong>
<span>SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY</span>
</div>
</aside>
</section>
<section id="context-missions-view" class="context-mode-view" role="tabpanel" aria-labelledby="global-context-missions-btn" hidden>
<div id="space-mission-panel-host">
<div id="space-mission-roster" class="space-mission-roster" aria-label="Available Space Missions">
<div class="space-mission-roster-header">
<div>
<strong>AVAILABLE MISSIONS</strong>
<span>SELECT A MISSION TO INSPECT</span>
</div>
<output data-mission-roster-count>—</output>
</div>
<div class="space-mission-roster-list" data-mission-roster-list>
<div class="space-mission-roster-empty">LOADING 30-DAY MISSION INDEX</div>
</div>
<p class="space-mission-roster-focus-continuation" tabindex="-1" data-mission-roster-focus-continuation>TAB PREVIEWS · ENTER / SPACE SELECTS</p>
</div>
</div>
</section>
<section id="radio-panel" class="panel-collapsible collapsed" data-panel-id="radio-panel" aria-label="Internet radio companion">
<div class="radio-panel-inner">
<div class="panel-header">
<span class="panel-title">RADIO</span>
<span id="radio-layer-state" class="radio-layer-state">OFF</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="radio-panel" title="Expand Radio" aria-label="Expand Radio section">+</button>
</div>
<div class="radio-directory-row">
<button id="radio-enable-btn" class="panel-layer-toggle" type="button" aria-pressed="false">ENABLE</button>
<label class="radio-filter-label" for="radio-filter">STATION TAG</label>
<select id="radio-filter" aria-label="Filter stations by station tag" disabled>
<option value="all">All</option>
</select>
</div>
<div id="radio-station-card" class="radio-station-card" aria-live="polite">
<strong id="radio-station-name">NO STATION SELECTED</strong>
<span id="radio-station-meta">Enable Radio, then choose a globe marker or use next.</span>
<span id="radio-station-tags"></span>
</div>
<div id="radio-tuner" class="radio-tuner" hidden>
<div class="radio-tuner-header">
<span id="radio-tuner-band-label">DIRECTORY BAND</span>
<output id="radio-tuner-value" for="radio-tuner-slider">DRAG TO TUNE</output>
</div>
<div class="radio-tuner-dial">
<div class="radio-tuner-scale" aria-hidden="true"></div>
<div id="radio-tuner-needle" class="radio-tuner-needle" aria-hidden="true"></div>
<input id="radio-tuner-slider" type="range" min="0" max="0" step="1" value="0"
aria-label="Tune available internet radio stations" aria-valuetext="No station available" disabled />
</div>
<div class="radio-tuner-readout">
<span id="radio-tuner-station">ALL · DRAG THE NEEDLE</span>
<span>SNAPS TO AVAILABLE STATIONS</span>
</div>
</div>
<div class="radio-transport" role="group" aria-label="Radio playback">
<button id="radio-prev-btn" type="button" aria-label="Previous filtered station" disabled>PREV</button>
<button id="radio-play-btn" type="button" aria-label="Play selected station" disabled>PLAY</button>
<button id="radio-next-btn" type="button" aria-label="Next filtered station" disabled>NEXT</button>
<button id="radio-stop-btn" type="button" aria-label="Stop radio playback" disabled>STOP</button>
</div>
<label class="radio-volume-row" for="radio-volume">
<span>VOLUME</span>
<input id="radio-volume" class="gev-quantitative-slider" type="range" min="0" max="100" value="80" aria-label="Radio volume" />
<output id="radio-volume-value" class="gev-slider-value" for="radio-volume">80%</output>
</label>
<div id="radio-playback-state" class="radio-playback-state" role="status" aria-live="polite">Radio off</div>
<div class="radio-links">
<a id="radio-station-homepage" href="#" target="_blank" rel="noopener noreferrer" hidden>STATION SITE</a>
<a href="https://www.radio-browser.info/" target="_blank" rel="noopener noreferrer">DIRECTORY: RADIO BROWSER</a>
</div>
<p class="radio-privacy">Audio connects directly to the broadcaster after you press play. Your IP is visible to that broadcaster.</p>
</div>
</section>
</div>
</div>
</aside>
<div id="scene-runtime"></div>
+123
View File
@@ -0,0 +1,123 @@
<!-- Post-processing toggles (top-right) -->
<div id="pp-toggles" class="panel-collapsible collapsed" data-panel-id="pp-toggles">
<div class="compact pp-header-row">
<span class="pp-header-label">DISPLAY</span>
<span class="panel-divider" aria-hidden="true"></span>
<button class="panel-collapse-btn pp-collapse-btn" data-collapse-target="pp-toggles" title="Collapse panel">&#x25C0;</button>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)">
<span class="pp-icon">&#x25CE;</span>
<span class="pp-label">HUD</span>
</button>
<div class="pp-slider-row" id="hud-layout-row">
<span class="pp-slider-mini-label">Layout</span>
<select id="hud-layout-select" class="pp-select" aria-label="HUD layout">
<option value="tactical">Tactical</option>
<option value="operator">Operator</option>
<option value="minimal">Minimal</option>
</select>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="detection-toggle" title="Detection Overlay (D)" aria-label="Detection overlay" aria-pressed="false">
<span class="pp-icon">&#x25A3;</span>
<span class="pp-label">DETECT</span>
</button>
<div class="pp-slider-row detection-slider-row" id="detection-slider-row">
<label class="pp-slider-mini-label" for="detection-density-slider">Density</label>
<input type="range" class="pp-slider" id="detection-density-slider" min="0" max="100" step="25" value="50" aria-label="Detection label density" />
<span class="pp-slider-value" id="detection-density-value">50%</span>
</div>
<div class="pp-slider-row detection-allocation-row" id="detection-allocation-row">
<span class="pp-slider-mini-label">Allocation</span>
<div class="pp-mode-seg detection-allocation-seg" role="radiogroup" aria-label="Detection label allocation">
<button class="pp-mode-btn active" id="detection-allocation-elastic" data-allocation="ELASTIC" role="radio" aria-checked="true">Elastic</button>
<button class="pp-mode-btn" id="detection-allocation-weighted" data-allocation="WEIGHTED" role="radio" aria-checked="false">Weighted</button>
</div>
</div>
<div class="pp-slider-row detection-fade-row" id="detection-fade-row">
<label class="pp-slider-mini-label" for="detection-fade-slider">Fade</label>
<input type="range" class="pp-slider" id="detection-fade-slider" min="0" max="40" step="1" value="7" aria-label="Detection fade distance" title="World-overlay fade distance outside the keyhole as a percentage of its radius" />
<span class="pp-slider-value" id="detection-fade-value">7%</span>
</div>
<div class="pp-slider-row detection-opacity-row" id="detection-opacity-row">
<label class="pp-slider-mini-label" for="detection-opacity-slider">Outside</label>
<input type="range" class="pp-slider" id="detection-opacity-slider" min="0" max="100" step="1" value="1" aria-label="Detection opacity outside the keyhole" title="World-overlay label and card opacity beyond the fade distance" />
<span class="pp-slider-value" id="detection-opacity-value">1%</span>
</div>
</div>
<!-- The one shared preset-parameter surface is inserted here at runtime. -->
<div id="param-slider-panel">
<div class="param-panel-header">
<span class="param-panel-title">PARAMETERS</span>
<span class="param-panel-divider"></span>
<button class="panel-collapse-btn" id="param-panel-collapse-btn" data-collapse-target="param-slider-panel" title="Collapse panel">−</button>
</div>
<div id="param-sliders"></div>
</div>
<div class="pp-toggle-group">
<!-- 3D aircraft is DEFAULT-ON in Proximity (owner directive 2026-08-22), so the
button ships lit and the mode row ships open — the same markup-carries-the-default
convention as #scope-toggle. ui.js re-syncs both from durable layer state, which
is what flips them back for a share link or a stored session carrying 3D off. -->
<button class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true" title="3D aircraft — flat icons zoomed out, 3D models up close">
<span class="pp-icon">&#x2708;</span>
<span class="pp-label">3D</span>
</button>
<div class="pp-slider-row visible" id="models3d-mode-row">
<span class="pp-slider-mini-label">Models</span>
<div class="pp-mode-seg" role="radiogroup" aria-label="3D model coverage">
<button class="pp-mode-btn active" id="models3d-mode-proximity" data-mode="proximity" role="radio" aria-checked="true">Proximity</button>
<button class="pp-mode-btn" id="models3d-mode-all" data-mode="all" role="radio" aria-checked="false">All</button>
</div>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn active" id="scope-toggle" title="Scope — the circular viewport mask" aria-pressed="true">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">adjust</span>
<span class="pp-label">Scope</span>
</button>
<div class="pp-slider-row visible" id="scope-slider-row">
<span class="pp-slider-mini-label">Feather</span>
<!-- Feather starts at an 11% soft scope-mask edge (owner final lock
2026-08-24, superseding the 08-22 hard-crop and 08-23 8% rulings);
mirrors SCOPE_FEATHER_RATIO_DEFAULT in src/scopeMask.js. Feather
softens the black mask edge; label fading is the separate Fade
slider. -->
<input type="range" class="pp-slider" id="scope-feather-slider" min="0" max="100" value="11" aria-label="Scope edge feather" title="Scope edge feather as a percentage of the keyhole radius" />
<span class="pp-slider-value" id="scope-feather-value">11%</span>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="celestial-toggle" title="Celestial ring — reveal the full globe" aria-pressed="false">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
<span class="pp-label">Celestial</span>
</button>
</div>
<button class="pp-toggle-btn" id="clean-view-toggle" title="Hide UI chrome">
<span class="pp-icon">&#x25A1;</span>
<span class="pp-label">Clean UI</span>
</button>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="bloom-toggle" title="Bloom / Glow">
<span class="pp-icon">&#x2728;</span>
<span class="pp-label">Bloom</span>
</button>
<div class="pp-slider-row" id="bloom-slider-row">
<input type="range" class="pp-slider" id="bloom-intensity-slider" min="0" max="200" value="0" aria-label="Bloom intensity" />
<span class="pp-slider-value" id="bloom-intensity-value">0%</span>
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" id="sharpen-toggle" title="Sharpening">
<span class="pp-icon">&#x1F50D;</span>
<span class="pp-label">Sharpen</span>
</button>
<div class="pp-slider-row" id="sharpen-slider-row">
<input type="range" class="pp-slider" id="sharpen-intensity-slider" min="0" max="100" value="49" aria-label="Sharpen intensity" />
<span class="pp-slider-value" id="sharpen-intensity-value">49%</span>
</div>
</div>
</div>
<button id="clean-view-exit" title="Return UI controls">EXIT CLEAN VIEW</button>
+11
View File
@@ -0,0 +1,11 @@
<!-- Intelligence HUD Overlay -->
<div id="intel-hud"></div>
<!-- Loading screen -->
<div id="loading-screen">
<div class="loader-content">
<span class="loader-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span>
<h2>GOD'S EYE <span class="title-accent">VIEW</span></h2>
<p class="loader-status">Initializing photorealistic world...</p>
</div>
</div>
+108
View File
@@ -0,0 +1,108 @@
<div id="left-panel-stack">
<!-- Data Layer Toggle Panel -->
<div id="data-panel" class="panel-collapsible collapsed active" data-panel-id="data-panel">
<div class="panel-glow"></div>
<div class="data-panel-inner">
<div class="panel-header">
<span class="panel-title">DATA LAYERS</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="data-panel" title="Collapse panel">+</button>
</div>
<div id="data-toggles" class="data-toggle-list"></div>
</div>
</div>
<!-- CCTV Coverage Panel -->
<div id="cctv-panel" class="panel-collapsible collapsed" data-panel-id="cctv-panel">
<div class="panel-glow"></div>
<div class="cctv-panel-inner">
<div class="panel-header">
<span class="panel-title">CCTV</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="cctv-panel" title="Collapse panel">+</button>
</div>
<div id="cctv-frame-wrap" class="cctv-frame-wrap">
<img id="cctv-frame" alt="CCTV feed frame" />
</div>
<div id="cctv-source-badge" role="status" aria-live="polite">SOURCE · UNKNOWN</div>
<div id="cctv-meta">Enable CCTV to load camera intersections</div>
<div class="cctv-controls">
<button id="cctv-enable-btn" class="scene-btn">CCTV OFF</button>
<button id="cctv-nearest-btn" class="scene-btn">NEAREST</button>
</div>
<div class="cctv-controls">
<button id="cctv-prev-btn" class="scene-btn">PREV</button>
<select id="cctv-camera-select" aria-label="CCTV camera"></select>
<button id="cctv-next-btn" class="scene-btn">NEXT</button>
</div>
<div class="cctv-controls">
<button id="cctv-focus-btn" class="scene-btn">FOCUS</button>
<button id="cctv-coverage-btn" class="scene-btn">COVERAGE OFF</button>
<button id="cctv-auto-hop-btn" class="scene-btn">AUTO HOP OFF</button>
</div>
<div class="cctv-controls">
<button id="cctv-projection-btn" class="scene-btn">PROJECTION ON</button>
<span id="cctv-quality-chip">CAL · --</span>
</div>
<div class="cctv-calibration-block">
<div class="cctv-calibration-header">
<div class="cctv-summary-label">CALIBRATION</div>
<button id="cctv-adjust-btn" class="scene-btn" title="Drag the camera in the world: rings rotate, arrows move, handles set range/FOV">ADJUST</button>
</div>
<div id="cctv-cal-readout" class="cctv-cal-readout" aria-label="Camera pose — click a value to type">
<button class="cctv-cal-value" data-cal-field="heading" title="Heading (compass °) — click to type">HDG --</button>
<button class="cctv-cal-value" data-cal-field="pitch" title="Pitch (° up/down) — click to type">PITCH --</button>
<button class="cctv-cal-value" data-cal-field="fov" title="Horizontal FOV (°) — click to type">FOV --</button>
<button class="cctv-cal-value" data-cal-field="range" title="Range / monitor-plane distance (m) — click to type">RANGE --</button>
<button class="cctv-cal-value" data-cal-field="height" title="Mount height above ground (m) — click to type">HGT --</button>
<button class="cctv-cal-value" data-cal-field="north" title="North offset from catalog position (m) — click to type">ΔN --</button>
<button class="cctv-cal-value" data-cal-field="east" title="East offset from catalog position (m) — click to type">ΔE --</button>
</div>
<div class="cctv-controls cctv-calibration-actions">
<button id="cctv-calib-save-btn" class="scene-btn">SAVE CAL</button>
<button id="cctv-calib-reset-btn" class="scene-btn">RESET CAL</button>
</div>
</div>
<div class="cctv-summary-label">SCENE SUMMARY</div>
<div id="cctv-summary">Enable CCTV to start camera-linked intelligence summaries.</div>
</div>
</div>
<!-- Scene Director Panel -->
<div id="scene-panel" class="panel-collapsible collapsed" data-panel-id="scene-panel">
<div class="panel-glow"></div>
<div class="scene-panel-inner">
<div class="panel-header">
<span class="panel-title">SCENES</span>
<span class="panel-divider"></span>
<button class="panel-collapse-btn" data-collapse-target="scene-panel" title="Collapse panel">+</button>
</div>
<div class="scene-controls">
<select id="scene-select" aria-label="Scene recipe"></select>
<button id="scene-new-btn" class="scene-btn">NEW</button>
<button id="scene-delete-btn" class="scene-btn scene-btn-danger">DEL</button>
</div>
<div class="scene-controls">
<button id="scene-capture-btn" class="scene-btn">CAPTURE SHOT</button>
<button id="scene-update-shot-btn" class="scene-btn">UPDATE SHOT</button>
</div>
<div id="scene-shot-list" class="scene-shot-list"></div>
<div class="scene-controls">
<button id="scene-start-btn" class="scene-btn">START</button>
<button id="scene-stop-btn" class="scene-btn scene-btn-danger">STOP</button>
<button id="scene-next-btn" class="scene-btn">NEXT</button>
</div>
<div class="scene-controls">
<button id="scene-export-btn" class="scene-btn">EXPORT PRESETS</button>
<button id="scene-import-btn" class="scene-btn">IMPORT</button>
<button id="scene-download-btn" class="scene-btn">RUN LOG</button>
<input type="file" id="scene-import-file" accept="application/json,.json" hidden />
</div>
<div class="scene-progress">
<div id="scene-progress-fill">0%</div>
</div>
<div id="scene-status">Ready</div>
</div>
</div>
</div>
+25
View File
@@ -0,0 +1,25 @@
<!-- POWER UP — in-app key setup. Dev-server only: src/keySetup.js removes
both the chip and this dialog outright when /api/setup/status is not
there to answer (prod builds, LAN visitors). Rows are rendered from
that payload, so the key registry lives in src/keySetupCore.mjs alone. -->
<button id="key-setup-chip" type="button" hidden aria-haspopup="dialog" aria-controls="key-setup">
<span class="material-symbols-outlined" aria-hidden="true">bolt</span>
<span data-key-setup-chip-label>POWER UP</span>
</button>
<aside id="key-setup" role="dialog" aria-labelledby="key-setup-title" aria-describedby="key-setup-description" hidden>
<div class="key-setup-scanline" aria-hidden="true"></div>
<header class="key-setup-header">
<span class="key-setup-kicker">GROUND STATION · PROVIDER SETTINGS</span>
<button type="button" class="key-setup-close" data-key-setup-close aria-label="Close key setup">
<span class="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</header>
<h2 id="key-setup-title">Power up the globe</h2>
<p id="key-setup-description">The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.</p>
<div class="key-setup-rows" data-key-setup-rows></div>
<div class="key-setup-footer">
<button type="button" class="key-setup-apply" data-key-setup-apply>SAVE KEYS</button>
<span class="key-setup-hint">ESC to close</span>
</div>
<p class="key-setup-note" data-key-setup-status role="status" aria-live="polite">The Google Maps key buys the photorealistic planet — everything else stacks on top.</p>
</aside>
+54
View File
@@ -0,0 +1,54 @@
<div id="cesiumContainer"></div>
<div id="world-overlay-root" aria-hidden="true">
<canvas id="world-overlay-canvas"></canvas>
</div>
<div id="world-overlay-actions" class="world-overlay-accessibility" role="region" aria-label="Visible map targets">
<div id="world-overlay-action-list"></div>
<div id="world-overlay-status" role="status" aria-live="polite" aria-atomic="true"></div>
</div>
<!-- Title -->
<div id="title-bar">
<div class="title-glow"></div>
<h1><span class="title-logo brand-logo" data-logo-gaze data-logo-src="/logo.svg" aria-hidden="true"><img src="/logo.svg" alt="" /></span> <span>GOD'S EYE <span class="title-accent">VIEW</span></span></h1>
<p class="subtitle">NO PLACE LEFT BEHIND</p>
</div>
<!-- Style indicator -->
<div id="style-indicator">
<span class="indicator-label">ACTIVE STYLE</span>
<span class="indicator-value" id="active-style-name">NORMAL</span>
</div>
<!-- Top-center camera and share actions -->
<nav id="top-center-actions" aria-label="Globe actions">
<button id="clear-selected-layers" type="button" aria-label="Clear selected data layers" title="Turn off all selected data layers">
<span class="material-symbols-outlined" aria-hidden="true">layers_clear</span>
</button>
<button id="share-btn" type="button" aria-label="Copy share link" title="Copy share link">
<span class="share-icon" aria-hidden="true">&#x1F517;</span>
</button>
<button id="reset-globe-view" type="button" aria-label="Reset to full globe view" title="Reset camera and return to full globe view">
<span class="material-symbols-outlined" aria-hidden="true">public</span>
</button>
</nav>
<div id="global-loading-status" role="status" aria-live="polite" aria-atomic="true" hidden>
<span id="global-loading-label">LOADING LIVE DATA</span>
<span id="global-loading-detail"></span>
</div>
<div id="traffic-sync-chip" aria-live="polite">
<span id="traffic-sync-label">syncing road network</span>
<span id="traffic-sync-progress">0%</span>
</div>
<div id="cctv-sync-chip" aria-live="polite">
<span id="cctv-sync-label">loading frames</span>
<span id="cctv-sync-progress">0/0</span>
</div>
<!-- Toast notification -->
<div id="toast" role="status" aria-live="polite" aria-atomic="true"></div>
<!-- Safe frame guides for recording composition -->
<div id="safe-frame-overlay">
<div id="safe-frame-box"></div>
</div>
+52
View File
@@ -0,0 +1,52 @@
<!-- Explicit mission choice on a fresh session. Live feeds are not auto-enabled
until the visitor asks; shared links skip this surface entirely. It returns
every session until "Don't show this again" is ticked — see
src/firstRunExperience.js for the full show policy. -->
<aside id="first-run-launcher" role="dialog" aria-labelledby="first-run-title" aria-describedby="first-run-description" hidden>
<div class="first-run-scanline" aria-hidden="true"></div>
<header class="first-run-header">
<span class="first-run-kicker">MISSION CONTROL · FIRST LAUNCH</span>
</header>
<h2 id="first-run-title">Choose your first view</h2>
<!-- The card's one persuasive line, owner-authored and VERBATIM (2026-08-23)
— including the unspaced em dash. The arc it has to carry: this looks
like something you should not have access to → the sources are all
public → the data is real. Tile subcopy stays purely functional. -->
<p id="first-run-description">It feels like a forbidden cockpit—then you realize the sources are public and the data is real.</p>
<div class="first-run-choices">
<button type="button" data-first-run-choice="contacts">
<span class="material-symbols-outlined" aria-hidden="true">radar</span>
<span><strong>LIVE CONTACTS</strong><small>Aircraft, vessels and nearby intelligence</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
</button>
<button type="button" data-first-run-choice="space-missions">
<span class="material-symbols-outlined" aria-hidden="true">rocket_launch</span>
<span><strong>SPACE MISSIONS</strong><small>Launches, spacecraft and orbital context</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
</button>
<button type="button" data-first-run-choice="environmental">
<span class="material-symbols-outlined" aria-hidden="true">local_fire_department</span>
<!-- Title text is painted from ENVIRONMENTAL_LABEL_CHOICE at init. -->
<!-- Subcopy names BOTH feeds this tile turns on, because the launcher
optimizes for the fully configured experience (owner ruling,
2026-08-23) — see the mission table in src/firstRunExperience.js. -->
<span><strong data-first-run-environmental-title>ENVIRONMENTAL</strong><small>Live earthquakes and active fires, from USGS and NASA</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
</button>
<button type="button" class="first-run-explore" data-first-run-choice="explore">
<span class="material-symbols-outlined" aria-hidden="true">public</span>
<span><strong>EXPLORE MANUALLY</strong><small>Begin with a clean globe</small></span>
<span class="material-symbols-outlined first-run-arrow" aria-hidden="true">arrow_forward</span>
</button>
</div>
<div class="first-run-footer">
<label class="first-run-suppress">
<input type="checkbox" data-first-run-suppress />
<span>Don't show this again</span>
</label>
<span>ESC to dismiss</span>
</div>
<!-- Kept out of the footer row: it swaps to mission progress and errors, and
a live region that also holds layout makes the row jump as it speaks. -->
<p class="first-run-note" data-first-run-status role="status" aria-live="polite">Tip: the GEV MIC button in the dock lets you talk to the map.</p>
</aside>
+18 -13
View File
@@ -1859,7 +1859,8 @@ function collectTrackedEntities(dataManager) {
return tracked; return tracked;
} }
export async function getBasemapLabelContext(viewer, service = defaultGeospatial) { export async function getBasemapLabelContext(viewer, service = defaultGeospatial, { cachedOnly = false } = {}) {
const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service);
const samples = sampleViewportCartographics(viewer); const samples = sampleViewportCartographics(viewer);
const cameraHeightM = viewer.camera.positionCartographic.height; const cameraHeightM = viewer.camera.positionCartographic.height;
const target = getViewTargetCartographic(viewer); const target = getViewTargetCartographic(viewer);
@@ -1876,12 +1877,12 @@ export async function getBasemapLabelContext(viewer, service = defaultGeospatial
const cachedViewportPlaces = viewportPlacesFromCache(samples, cameraHeightM, service); const cachedViewportPlaces = viewportPlacesFromCache(samples, cameraHeightM, service);
const viewportPromise = cachedViewportPlaces const viewportPromise = cachedViewportPlaces
? Promise.resolve(cachedViewportPlaces) ? Promise.resolve(cachedViewportPlaces)
: reverseGeocodeViewportSamples(samples, cameraHeightM, service); : cachedOnly ? Promise.resolve(null) : reverseGeocodeViewportSamples(samples, cameraHeightM, service);
const placePromise = shouldReverseGeocode(cameraHeightM) const placePromise = shouldReverseGeocode(cameraHeightM)
? reverseGeocode(latitude, longitude, service) ? cachedOnly ? Promise.resolve(reverseGeocodeCache.get(reverseGeocodeKey(latitude, longitude, service)) || null) : reverseGeocode(latitude, longitude, service)
: Promise.resolve(null); : Promise.resolve(null);
const nearbyPromise = shouldFetchNearbyPlaces(cameraHeightM) const nearbyPromise = shouldFetchNearbyPlaces(cameraHeightM)
? fetchNearbyPlaces(latitude, longitude, cameraHeightM, service) ? cachedOnly ? Promise.resolve(nearbyPlacesCache.get(nearbyPlacesCacheKey(latitude, longitude, cameraHeightM, service)) || []) : fetchNearbyPlaces(latitude, longitude, cameraHeightM, service)
: Promise.resolve([]); : Promise.resolve([]);
const [viewportPlaces, place, nearbyPlaces] = await Promise.all([ const [viewportPlaces, place, nearbyPlaces] = await Promise.all([
resolveWithin(viewportPromise, BASEMAP_CONTEXT_WAIT_MS, cachedViewportPlaces), resolveWithin(viewportPromise, BASEMAP_CONTEXT_WAIT_MS, cachedViewportPlaces),
@@ -2625,9 +2626,9 @@ async function getBasemapContext(viewer, viewTarget = null, service = defaultGeo
const knownLandmarks = nearbyKnownLandmarks(latitude, longitude, cameraHeightM); const knownLandmarks = nearbyKnownLandmarks(latitude, longitude, cameraHeightM);
const fallbackPlace = coarseBasemapPlace(viewScale, latitude, longitude, inferredCountry); const fallbackPlace = coarseBasemapPlace(viewScale, latitude, longitude, inferredCountry);
const cachedPlace = shouldReverseGeocode(cameraHeightM) const cachedPlace = shouldReverseGeocode(cameraHeightM)
? reverseGeocodeCache.get(reverseGeocodeKey(latitude, longitude)) || null ? reverseGeocodeCache.get(reverseGeocodeKey(latitude, longitude, service)) || null
: null; : null;
const nearbyCacheKey = nearbyPlacesCacheKey(latitude, longitude, cameraHeightM); const nearbyCacheKey = nearbyPlacesCacheKey(latitude, longitude, cameraHeightM, service);
const cachedNearbyPlaces = shouldFetchNearbyPlaces(cameraHeightM) && nearbyPlacesCache.has(nearbyCacheKey) const cachedNearbyPlaces = shouldFetchNearbyPlaces(cameraHeightM) && nearbyPlacesCache.has(nearbyCacheKey)
? nearbyPlacesCache.get(nearbyCacheKey) ? nearbyPlacesCache.get(nearbyCacheKey)
: null; : null;
@@ -2907,7 +2908,7 @@ function inferCountry(latitude, longitude) {
async function reverseGeocode(latitude, longitude, service) { async function reverseGeocode(latitude, longitude, service) {
const { reverseGeocodeCache, reverseGeocodeInFlight } = cachesFor(service); const { reverseGeocodeCache, reverseGeocodeInFlight } = cachesFor(service);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
const key = reverseGeocodeKey(latitude, longitude); const key = reverseGeocodeKey(latitude, longitude, service);
if (reverseGeocodeCache.has(key)) return reverseGeocodeCache.get(key); if (reverseGeocodeCache.has(key)) return reverseGeocodeCache.get(key);
if (reverseGeocodeInFlight.has(key)) return reverseGeocodeInFlight.get(key); if (reverseGeocodeInFlight.has(key)) return reverseGeocodeInFlight.get(key);
@@ -2957,7 +2958,7 @@ function viewportPlacesFromCache(samples, cameraHeightM, service) {
const { reverseGeocodeCache } = cachesFor(service); const { reverseGeocodeCache } = cachesFor(service);
if (!shouldReverseGeocodeViewport(cameraHeightM) || cameraHeightM <= 10000 || !samples.length) return null; if (!shouldReverseGeocodeViewport(cameraHeightM) || cameraHeightM <= 10000 || !samples.length) return null;
const places = [samples[0], samples[1], samples[2]].filter(Boolean).flatMap((sample) => { const places = [samples[0], samples[1], samples[2]].filter(Boolean).flatMap((sample) => {
const place = reverseGeocodeCache.get(reverseGeocodeKey(sample.latitude, sample.longitude)); const place = reverseGeocodeCache.get(reverseGeocodeKey(sample.latitude, sample.longitude, service));
if (!place) return []; if (!place) return [];
return [{ return [{
latitude: sample.latitude, latitude: sample.latitude,
@@ -2989,7 +2990,7 @@ function summarizeViewportPlaces(places) {
async function fetchNearbyPlaces(latitude, longitude, cameraHeightM, service) { async function fetchNearbyPlaces(latitude, longitude, cameraHeightM, service) {
const { nearbyPlacesCache, nearbyPlacesInFlight } = cachesFor(service); const { nearbyPlacesCache, nearbyPlacesInFlight } = cachesFor(service);
const radiusM = nearbyPlacesRadiusM(cameraHeightM); const radiusM = nearbyPlacesRadiusM(cameraHeightM);
const cacheKey = nearbyPlacesCacheKey(latitude, longitude, cameraHeightM); const cacheKey = nearbyPlacesCacheKey(latitude, longitude, cameraHeightM, service);
if (nearbyPlacesCache.has(cacheKey)) return nearbyPlacesCache.get(cacheKey); if (nearbyPlacesCache.has(cacheKey)) return nearbyPlacesCache.get(cacheKey);
if (nearbyPlacesInFlight.has(cacheKey)) return nearbyPlacesInFlight.get(cacheKey); if (nearbyPlacesInFlight.has(cacheKey)) return nearbyPlacesInFlight.get(cacheKey);
@@ -3035,13 +3036,17 @@ function sanitizeLabel(value) {
return out.replace(/\s+/g, ' ').trim().slice(0, 120); return out.replace(/\s+/g, ' ').trim().slice(0, 120);
} }
function reverseGeocodeKey(latitude, longitude) { function cachePrecision(service) {
return `${latitude.toFixed(4)},${longitude.toFixed(4)}`; return Number.isInteger(service?.cachePrecision) ? Math.max(3, Math.min(6, service.cachePrecision)) : 4;
} }
function nearbyPlacesCacheKey(latitude, longitude, cameraHeightM) { function reverseGeocodeKey(latitude, longitude, service) {
return `${latitude.toFixed(cachePrecision(service))},${longitude.toFixed(cachePrecision(service))}`;
}
function nearbyPlacesCacheKey(latitude, longitude, cameraHeightM, service) {
const radiusM = nearbyPlacesRadiusM(cameraHeightM); const radiusM = nearbyPlacesRadiusM(cameraHeightM);
return `${latitude.toFixed(4)},${longitude.toFixed(4)},${radiusM}`; return `${latitude.toFixed(cachePrecision(service))},${longitude.toFixed(cachePrecision(service))},${radiusM}`;
} }
function nearbyPlacesRadiusM(cameraHeightM) { function nearbyPlacesRadiusM(cameraHeightM) {