mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
refactor(app): share application components and request services
This commit is contained in:
1 parent
a0a0128a59
commit
a67bc60a29
84 files changed
+2798
-1731
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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">🔗</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">◀</button>
|
|
||||||
</div>
|
|
||||||
<div class="pp-toggle-group">
|
|
||||||
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)">
|
|
||||||
<span class="pp-icon">◎</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">▣</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">✈</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">□</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">✨</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">🔍</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>🔎</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 & 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
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
]
|
]
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,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,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,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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
Vendored
+39
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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*\)/,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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();
|
|
||||||
}
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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) || [];
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
Vendored
+1
-36
@@ -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
@@ -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 +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
@@ -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 };
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
@@ -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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>🔎</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>
|
||||||
@@ -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 & 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>
|
||||||
@@ -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">◀</button>
|
||||||
|
</div>
|
||||||
|
<div class="pp-toggle-group">
|
||||||
|
<button class="pp-toggle-btn" id="hud-toggle" title="Intelligence HUD (H)">
|
||||||
|
<span class="pp-icon">◎</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">▣</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">✈</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">□</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">✨</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">🔍</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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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">🔗</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>
|
||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user