From 048293fc2a5923772660322bc1cf89571042558b Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 20:06:33 +0100 Subject: [PATCH] feat(cockpit): ride a tracked satellite in an orbital view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a satellite tracked and no aircraft tracked, Cockpit can ride the satellite. Entry needs only the Satellites layer; debris can be tracked but not entered, and voice stays aircraft-only. Double-clicking the tracked satellite or the entry buttons (reading ORBIT) enter it. The camera sits on the satellite's per-frame SGP4 position and looks straight down, turned to the ground track (north-up for a satellite with none). The optical zoom steps 1x to 8x with a focus pull, the arrows tilt the view up to 45 degrees off nadir, and Home or 0 returns to nadir. Off nadir the tilt reads its total angle first and the swath readout becomes an approximation marked with ≈. While in orbit the map leases flat Esri imagery, the cloud pass stays off and the view opens in the optical style. The orbital HUD names the satellite with its owner's flag (from the flag-icons package, loaded per country) or an organisation badge, its operator, class, NORAD and COSPAR ids and TLE epoch with the element lines on request, altitude, orbital speed and the sub-satellite point. CURRENTLY OVER names the country and its first-level division, or the sea, and from 8x the most prominent populated place nearby, looked up offline from the bundled Natural Earth packs (populated places are a new pack). The satellites layer hides the ridden satellite's own marks, dims the other orbit rings and ignores scene clicks and Escape while held. Exit restores the camera's field of view and the satellite's follow camera. C no longer enters or leaves Cockpit, bridge or orbit: it is left to the app-wide CCTV shortcut, and Escape or the exit button leave first person. --- DATA_SOURCES.md | 11 + README.md | 2 +- docs/CURRENT-STATE.md | 124 +- package-lock.json | 7 + package.json | 1 + scripts/build-admin-packs.mjs | 58 +- scripts/check-package-boundaries.mjs | 9 +- scripts/package-boundaries.json | 23 +- src/cameraHandoff.test.mjs | 2 +- src/cockpitCloudEffects.js | 17 +- src/cockpitCloudEffects.test.mjs | 8 + src/cockpitLook.js | 61 + src/cockpitLook.test.mjs | 58 + src/cockpitMarkup.test.mjs | 13 +- src/cockpitOrbital.js | 413 +++++++ src/cockpitOrbital.test.mjs | 302 +++++ src/contextModePolicy.js | 24 + src/contextModePolicy.test.mjs | 14 + src/data/local_data/natural_earth/README.md | 21 + .../natural_earth/populated_places.json | 1 + src/data/placeBelow.js | 369 ++++++ src/data/placeBelow.test.mjs | 280 +++++ src/i18n/locales/ar.json | 40 +- src/i18n/locales/de.json | 40 +- src/i18n/locales/es.json | 40 +- src/i18n/locales/fr.json | 40 +- src/i18n/locales/it.json | 40 +- src/i18n/locales/ja.json | 40 +- src/i18n/locales/pt.json | 40 +- src/i18n/locales/ru.json | 40 +- src/i18n/locales/uk.json | 40 +- src/i18n/locales/zh-CN.json | 40 +- src/i18n/locales/zh-Hant.json | 40 +- src/i18n/same-as-english.json | 49 +- src/layers/satellites/cockpitSubject.js | 318 +++++ src/layers/satellites/cockpitSubject.test.mjs | 542 ++++++++ src/layers/satellites/controls.js | 8 +- src/layers/satellites/index.js | 3 + src/layers/satellites/ingestion.js | 2 + src/layers/satellites/interaction.js | 5 + src/layers/satellites/lifecycle.js | 4 + src/layers/satellites/orbits.js | 64 +- src/layers/satellites/rendering.js | 23 +- src/layers/satellites/state.js | 11 + src/layers/satellites/tracking.js | 3 + src/ui/applicationShell.js | 10 + src/ui/cockpitBodyClasses.test.mjs | 5 +- src/ui/cockpitBridge.test.mjs | 84 +- src/ui/cockpitBriefing.js | 2 +- src/ui/cockpitCamera.js | 4 + src/ui/cockpitController.js | 84 +- src/ui/cockpitCoordinator.js | 48 +- src/ui/cockpitEntryGate.test.mjs | 110 +- src/ui/cockpitInput.js | 61 +- src/ui/cockpitInstruments.js | 5 + src/ui/cockpitOrbital.test.mjs | 263 ++++ src/ui/cockpitOrbitalView.js | 549 +++++++++ src/ui/cockpitOrbitalView.test.mjs | 1090 +++++++++++++++++ src/ui/cockpitPresentation.js | 18 +- src/ui/cockpitTrackingController.js | 105 +- src/ui/ownerFlag.js | 110 ++ src/ui/ownerFlag.test.mjs | 108 ++ src/ui/stores/cockpit.js | 38 + src/ui/styles/cockpitOrbital.css | 499 ++++++++ src/ui/views/CockpitView.vue | 51 +- src/ui/views/panels/CockpitOrbital.vue | 279 +++++ style.css | 1 + 67 files changed, 6642 insertions(+), 172 deletions(-) create mode 100644 src/cockpitOrbital.js create mode 100644 src/cockpitOrbital.test.mjs create mode 100644 src/data/local_data/natural_earth/populated_places.json create mode 100644 src/data/placeBelow.js create mode 100644 src/data/placeBelow.test.mjs create mode 100644 src/layers/satellites/cockpitSubject.js create mode 100644 src/layers/satellites/cockpitSubject.test.mjs create mode 100644 src/ui/cockpitOrbital.test.mjs create mode 100644 src/ui/cockpitOrbitalView.js create mode 100644 src/ui/cockpitOrbitalView.test.mjs create mode 100644 src/ui/ownerFlag.js create mode 100644 src/ui/ownerFlag.test.mjs create mode 100644 src/ui/styles/cockpitOrbital.css create mode 100644 src/ui/views/panels/CockpitOrbital.vue diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 52f72641c..cde514841 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -233,6 +233,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | **Natural Earth physical regions** (1,046 land + 292 marine named polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | | **DataSF Analysis Neighborhoods** (41 SF neighborhood polygons) | `neighborhoods/` | **PDDL 1.0** (public domain) | ✅ (no restrictions) | "City & County of San Francisco — DataSF" (courtesy — not legally required) | | **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | +| **Natural Earth populated places** (7,342 points: name, position, rank) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | | **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) | | **US Census Bureau places** (32,629 city, town and CDP polygons) | `us_census_places/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) | | **Who's On First neighborhoods** (61,441 polygons) | `wof_neighborhoods/` | Per source: CC BY, ODC-By, Open Government licences, CC0, PDDL, public domain (folder `ATTRIBUTION.md`) | ✅ (attribution) | "Neighborhood boundaries: Who's On First and contributors"; per-source credits in the folder | @@ -317,6 +318,16 @@ https://www.naturalearthdata.com/about/terms-of-use/). We credit anyway: "Made w Earth". Registration in the in-app `dataCredits.js` attribution list ships with the resolver wiring (see below). +### Natural Earth populated places (`natural_earth/populated_places.json`) + +Natural Earth 10m populated places (`ne_10m_populated_places_simple`, the same pinned +`nvkelso/natural-earth-vector` commit and SHA-256 as the admin-1 build reads), reduced to one +row per place: name, latitude and longitude rounded to 3 decimals, `scalerank` and `rank_max` +(7,342 places, 229 KB). From 8x the orbital view names the most prominent place within half +its width ("Sichuan, China · near Chengdu"), offline, with the states and provinces pack giving +the division. Public domain; credited "Made with Natural Earth" with the other packs. Rebuild: +`node scripts/build-admin-packs.mjs --only places`; provenance in the folder README. + ### Sky background (`sky/`) Sources, licences, the changes CC BY asks to state, hashes and build commands diff --git a/README.md b/README.md index b957046ff..d4b53dc24 100644 --- a/README.md +++ b/README.md @@ -340,7 +340,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea 7. **Talk to it** _(needs an OpenAI key)_: _"Take me to LAX and select the nearest airborne aircraft."_ 8. **Come home.** Hit **Reset Globe** — or just say _"zoom out to a globe view."_ -**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` cockpit · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe. +**Keyboard:** `1`–`6` visual styles · `H` HUD · `D` detection · `C` CCTV · `` ` `` frame rate · `Esc` out. Trackpad pinch zooms the globe. --- diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ab92553b2..653ba1d99 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1530,14 +1530,13 @@ With no aircraft tracked, Cockpit can ride the selected AIS vessel instead. Entry needs only the layer of the vessel it rides, Live AIS Vessels, and a selected vessel; Contacts and every other layer may be off, before or during the bridge (`bridgeEntryAllowed` in `src/contextModePolicy.js`). A Context mode -change still in flight blocks entry until it settles. `C` enters it (when -the bridge cannot be entered, `C` stays the CCTV shortcut), and the -entry button reads BRIDGE with a ship icon both in Contacts (`#cockpit-entry`) -and under the selected vessel's Intel HUD readout in the bottom-right corner -(`#cockpit-entry-readout`, shown with the HUD on, in its Tactical and Cyber -layouts). Turning Live AIS Vessels off ends the bridge: the layer then has no -subject (`getCockpitSubject` returns null). The bridge picture uses AIS alone, -so no other feed being off makes it partial. +change still in flight blocks entry until it settles. Double-clicking the +selected vessel enters it, and the entry button reads BRIDGE with a ship icon +both in Contacts (`#cockpit-entry`) and under the selected vessel's Intel HUD +readout in the bottom-right corner (`#cockpit-entry-readout`, shown with the +HUD on, in its Tactical and Cyber layouts). Turning Live AIS Vessels off ends +the bridge: the layer then has no subject (`getCockpitSubject` returns null). +The bridge picture uses AIS alone, so no other feed being off makes it partial. Voice (`control_cockpit`) and the selection entry transaction stay aircraft-only; voice `enter` on a bridge is refused before it touches tracking, with the reason to exit the bridge first. @@ -1605,6 +1604,97 @@ through the layer's own focus. Tracking an aircraft or losing the vessel selection ends the bridge without refocusing the ship. `src/ui/cockpitBridge.test.mjs` covers entry, readouts, refusal and exit. +### Orbital view + +With a satellite tracked and no aircraft tracked, Cockpit can ride the +satellite instead. Entry needs only the Satellites layer +(`orbitEntryAllowed` in `src/contextModePolicy.js`); Contacts and every other +layer may be off. A Context mode change in flight blocks entry until it +settles. Debris (rocket bodies, fragments and dead satellites; see the +satellite class toggles) can be tracked but never entered: no ORBIT entry is +offered and double-click refuses it. A tracked aircraft still takes +entry first, and a tracked satellite +takes it before a selected vessel. Double-clicking the tracked satellite +enters it, and the entry button reads +ORBIT with a satellite icon in Contacts (`#cockpit-entry`) and under the Intel +HUD readout (`#cockpit-entry-readout`). Voice (`control_cockpit`) stays +aircraft-only; its state reports an orbital view in use as a `satellite` +subject. `gev:cockpit-mode-changed` carries layer `satellites` and the NORAD +id. + +The camera sits on the satellite's own per-frame SGP4 position +(`getCockpitPosition`, the sample the dot uses) and looks at the Earth's +centre track-up: the ground track points to the top of the frame, so the +picture scrolls along it (`nadirOrientation` in `src/cockpitOrbital.js`). A +satellite with no ground track (a geostationary one) is shown north-up, and +over a pole the previous frame's up is kept. It updates on every rendered +frame, with no ground clamp. Entry opens in the plain optical vision style +(the other styles stay one click away) and leases the map for flat imagery: +with Google 3D tiles shown, which stay coarse from hundreds of kilometres up, +the map switches to Esri imagery, and exit puts it back unless the operator +picked another map meanwhile; another owner already holding the map (an +imagery comparison) keeps it as it is. The Cockpit cloud pass is off in orbit +(it fades out above 24 km); cloud cover from orbit is the Weather layer's, +and the saved cloud preference stands for the next aircraft or bridge. The layer hides the satellite's dot, docked +companions, its own orbit ring and its detection bracket, and draws every +other orbit ring (the ISS ring included) at 30% of its alpha; other +satellites' points and labels stay. Scene clicks and the layer's Escape are +ignored while it is ridden. The optical zoom steps 1×, 2×, 4× and 8× with `+` +(or `=`) and `-` (or `_`) and the panel's buttons: the 1× frame is 60° +vertical in low orbit and fits the Earth's disk with margin from high orbits, +each step divides the tangent of the half-angle, and Cesium's `frustum.fov` +is set from it for the canvas aspect. A step animates over 400 ms with a +focus-pull blur and a reticle lock tick, and cuts at once under +`prefers-reduced-motion`. The arrows tilt the view off nadir in 5° steps +along the frame's axes (Up/Down tilt forward/aft along the ground track, +Left/Right across it, each axis to ±45°; Shift takes no larger step); Home, +`0` or NADIR return to nadir. While tilted the panel shows the total angle +from nadir, acos(cos along · cos across), before each axis +(`OFF-NADIR 18.0° · FWD 10° · RIGHT 15°`; N, S, E and W for a north-up view) +and a NADIR button, and the swath readout turns approximate +(`SWATH ≈ 26 KM`): an estimate of the ground width at the view's centre, the +nadir footprint scaled by 1/cos² of the tilt across the frame and 1/cos of +the tilt along it (`offNadirSwathScale`, flat Earth). At nadir it reads the +footprint without ≈. After a zoom step lands, the focus pull holds until the +imagery under the view has loaded (at most 2.5 s, `zoomSettleHolding`), then +the reticle locks. The look keys and zoom keys keep the bridge's focus +guards. + +The HUD (`#cockpit-hud[data-subject='satellite']`, body class +`cockpit-orbit`) mounts `CockpitOrbital` over the picture: name, class, +altitude, orbital speed, sub-satellite point, TRACK UP (NORTH UP without a +ground track) with a pointer that turns to keep north marked N on the frame, +zoom and swath. Under +the vision selector a "CURRENTLY OVER" band names what the sub-satellite point +is over, looked up offline from the bundled Natural Earth packs +(`src/data/placeBelow.js`: on land the first-level division as Natural Earth +names it and the country in the reader's language through `Intl.DisplayNames`, +"Sichuan, China", and from 8× the most prominent populated place within half +the view's smaller side, so it is in the picture, "Sichuan, China · near +Chengdu", from a bundled pack of Natural Earth populated places loaded on +first use; else the smallest named sea, gulf, strait, channel, sound or bay; +else the ocean; nothing in a coastline gap), at most every 2 s and only after +20 km of ground travel, or at once when a zoom step changes how much ground +the view shows. A division or populated-places pack that fails to load drops +only that part (the country still shows); a lookup that fails outright hides +the band rather than leave the last place up, and the next HUD beat looks +again. The readout card puts +the owner country's flag (an SVG from `flag-icons`, fetched only for the shown +country) or a text badge for an organisation before the name, the operator +under it, and a NORAD · COSPAR · TLE-epoch line whose TLE button unfolds the +two element lines; each row is left out when the satellites layer does not +know it. The +aircraft and bridge instruments, tapes, compass, radar, look controls, +position readout, route card and Contact card are hidden; the vision-style +switcher, briefing card (Local and Regional for the sub-satellite point; the +regional brief refreshes at most once a minute) and the reset and EXIT ORBIT +buttons stay, square-cornered like the bridge. Exit (`Escape`, EXIT +ORBIT) restores the camera's own field of view exactly and returns to the +satellite's follow camera through the layer's `refocusTrackedById`. Losing +the subject (tracking stopped, Satellites off) or tracking another target +ends the view without refocusing. `src/ui/cockpitOrbitalView.test.mjs` +covers entry, camera, zoom, look, HUD and exit. + ## Context coordination Context controls own Contacts/Space Missions state, entry and exit transactions, @@ -3495,7 +3585,7 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with > It uses the `radar` symbol; both tabs are reachable with Tab and Left/Right arrows switch between them. Its action row > places the single Cockpit entry before Search Nearby Sites. Cockpit removes > the duplicate floating map entry and topline exit; the bottom-center -> `EXIT COCKPIT` control (offset downward by a `-95px` bottom margin) plus `Escape`/`C` own exit, with entry/exit focus +> `EXIT COCKPIT` control (offset downward by a `-95px` bottom margin) plus `Escape` own exit, with entry/exit focus > transfer and failure-safe shortcut routing. The exit control sits at the > bottom-center compass position. A Cockpit-only control strip sits > directly above Live Signals, anchored 12px under the REC readout it shares @@ -5458,7 +5548,8 @@ silently demoting every later lookup for the session. - **Satellite classes** (`src/layers/satellites/classes.js`): every satellite is filed under one of six classes, each with its own point colour, legend swatch and toggle: STATIONS (warm white), IMAGING (cyan), TELECOM (violet; communications and navigation, so GPS, GLONASS, Galileo and BeiDou read as one family), STARLINK (dim slate), PROBES (muted blue-gray) and DEBRIS (receding brown-gray). That module is the single source of truth for class, label and colour, so the dot, the card and the legend swatch cannot disagree. Two deliberate palette rules: no class may sit in the 40–48° amber band, which is the app-wide known-military convention; and STARLINK stays far below PROBES in Rec.601 luminance so the dense shell stays separable when NVG/FLIR collapse the scene to one channel. The ISS keeps its long-standing red hero dot rather than the STATIONS colour — it carries a permanent name label and its card still reads `STATIONS · ISS`; it is a station whichever group ingested it, so a stations-feed outage cannot refile it. `src/data/satelliteClass.js` keeps the group-based labels beneath the classes: the constellation subtype and a group's own label for a satellite with no class. - Class is also a **text field**, not just a color: `TELECOM · GPS` / `STARLINK` / `STATIONS · ISS` leads the tracked card's detail block and replaces the raw CelesTrak tag on the detection-overlay label. Because that canvas composites above the post-FX chain, the class stays readable in NVG/FLIR after the in-scene dot colors are flattened. - The satellites row in DATA LAYERS carries per-layer sub-controls (`DataLayerManager._syncRowControls`, the first consumer of the optional `getRowControls()` layer hook): one chip per class (the STARLINK chip exposes the existing `catalog` param), a swatch legend with live per-class counts, and an info line while SATCAT is stale or unavailable (`Classes may lag: SATCAT is a cached copy` / `Classes may lag: SATCAT unavailable`). Default is the sparse core catalog. The chips are stateless — each declares the params to apply and the manager owns the write — so the Space Missions capture/restore path over the same param stays authoritative. Controls stay hidden while the layer is off. An explicit CORE or DENSE choice participates in versioned local and share-link state; temporary Space Missions overrides do not. -- **Class toggles** (`src/layers/satellites/classes.js`): the layer row carries one chip per class — STATIONS, IMAGING, TELECOM (communications and navigation), PROBES (science, technology development and demonstration, unknown purpose) and DEBRIS (rocket bodies, fragments, unknown objects launched before last year and dead payloads) on by default, so the layer shows what it did before classes; STARLINK off, as DENSE was. Points of a hidden class stay built and are only hidden, so a toggle needs no reload; hidden classes are left out of the detection overlay, the voice/framing position lists and analyst answers (the tracked satellite still answers), and the legend keys only the classes on screen. The class comes, strongest evidence first, from CelesTrak SATCAT (object type and operational status, fetched per group through `/api/celestrak-satcat/`, cached a day by the proxy; the layer keeps a current read six hours rather than asking on every five-minute update), the bundled UCS Satellite Database pack (UCS-Satellite-Database 5-1-2023: operator, owner country and purpose; `src/data/local_data/ucs_satellites/`), name patterns for satellites launched after May 2023, and finally the CelesTrak groups the TLE came in (a mission group such as `weather` before the orbit group `geo`; a UCS purpose of "Unknown" counts as none). Only crewed stations and the craft that visit them, by name, are STATIONS: the CubeSats CelesTrak's stations group also carries after their deployment from the ISS are classed like any other satellite. A payload whose SATCAT record gives no status, with no UCS record and no recognisable name, that launched 20 or more years ago (SATCAT's launch date, else the TLE designator's year) is presumed dead and filed as debris; without a SATCAT record (a failed read) nothing is presumed. To cover imaging launched after the UCS snapshot the core catalog also reads CelesTrak's `resource`, `weather`, `planet`, `sar` and `science` groups. Retired stations and the shuttles have re-entered and have no orbit to show. An explicit class choice is kept in local and share-link state as the `classes` option, one letter per class shown (`sitpd`: stations, imaging, telecom, probes, debris; `0`: none; `src/data/satelliteClassMask.js`), and the layer takes those letters back through `setParams({classes})`; STARLINK maps to the existing `catalog: 'dense'` param instead. +- **Class toggles** (`src/layers/satellites/classes.js`): the layer row carries one chip per class — STATIONS, IMAGING, TELECOM (communications and navigation), PROBES (science, technology development and demonstration, unknown purpose) and DEBRIS (rocket bodies, fragments, unknown objects launched before last year and dead payloads) on by default, so the layer shows what it did before classes; STARLINK off, as DENSE was. Points of a hidden class stay built and are only hidden, so a toggle needs no reload; hidden classes are left out of the detection overlay, the voice/framing position lists and analyst answers (the tracked satellite still answers), and the legend keys only the classes on screen. The class comes, strongest evidence first, from CelesTrak SATCAT (object type and operational status, fetched per group through `/api/celestrak-satcat/`, cached a day by the proxy; the layer keeps a current read six hours rather than asking on every five-minute update), the bundled UCS Satellite Database pack (UCS-Satellite-Database 5-1-2023: operator, owner country and purpose; `src/data/local_data/ucs_satellites/`), name patterns for satellites launched after May 2023, and finally the CelesTrak groups the TLE came in (a mission group such as `weather` before the orbit group `geo`; a UCS purpose of "Unknown" counts as none). Only crewed stations and the craft that visit them, by name, are STATIONS: the CubeSats CelesTrak's stations group also carries after their deployment from the ISS are classed like any other satellite. A payload whose SATCAT record gives no status, with no UCS record and no recognisable name, that launched 20 or more years ago (SATCAT's launch date, else the TLE designator's year) is presumed dead and filed as debris; without a SATCAT record (a failed read) nothing is presumed. To cover imaging launched after the UCS snapshot the core catalog also reads CelesTrak's `resource`, `weather`, `planet`, `sar` and `science` groups. Debris can be tracked but is never ridden: the orbital entry gate (`orbitEntryAllowed`) refuses it, so no ORBIT entry is offered and double-click does nothing for it. Retired stations and the shuttles have re-entered and have no orbit to show. An explicit class choice is kept in local and share-link state as the `classes` option, one letter per class shown (`sitpd`: stations, imaging, telecom, probes, debris; `0`: none; `src/data/satelliteClassMask.js`), and the layer takes those letters back through `setParams({classes})`; STARLINK maps to the existing `catalog: 'dense'` param instead. +- The satellite Cockpit subject (`getCockpitSubject()`) also carries `satelliteClass`, `cosparId` (from SATCAT, else TLE line 1), `tle` (`{line1, line2, epochMs}`), `owner` (`{countryCode, label}`: ISO 3166-1 alpha-2 for a single country, `null` code for organisations such as ESA, EUMETSAT or Intelsat and for joint owners) and `operator` (UCS; SpaceX for Starlink). UCS names the operator's country and wins over the SATCAT owner code; CelesTrak's CIS (former USSR) is filed under Russia. - **The STARLINK chip (formerly DENSE) reports the dense LOAD, not the catalog param.** The param flips synchronously while the Starlink shell takes seconds to arrive over a chunked load, and CelesTrak 502s that feed regularly. So the chip reads `STARLINK ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `STARLINK ✕` with the reason on hover when the load fails — a failure also reverts `catalog` to `core`, drops any partial chunk, and leaves the chip clickable to retry. A load is judged by points added, not by HTTP status: a 200 carrying an empty body, a passed-through HTML error page, or only TLEs the core catalog already owns fails with the same revert semantics as a 502. Any explicit request for `core` clears a latched error even when the mode does not change, so a Space Missions restore of an already-core snapshot never leaves the user with a failure they did not cause. Because the load settles asynchronously, the layer pushes a re-render through the optional `setRowControlsListener()` hook; nothing else would repaint that row before the 5-minute catalog refresh, so the count and legend would otherwise sit stale. - **A dependency owner takes the row with it.** Space Missions borrows this layer for TLE lookup with `showPoints:false`; while points are hidden the layer returns empty row controls, so the legend never describes an empty sky and the chip cannot accept a write that the owner's restore would silently revert. - The detection-overlay record cache (`_detectionObjects`) is cleared with the catalog on every rebuild: it stamps id/class at creation only, and a rebuild can re-tag a satellite when a partial CelesTrak outage changes which group wins dedupe. @@ -6358,15 +6449,16 @@ apply to `public/` assets or built-preview serving. ending, by its own switch or one of its layers going off, restores the pre-Contacts layers but keeps the ridden one and the view; Live Signals then drops the rows Contacts built and shows `CONTEXT STANDBY`. The visible - Cockpit actions, the `C` shortcut and voice use the same gate; voice entry - still opens Contacts first, as its layer-targeted entry navigates through - Contacts. The ship bridge has its own own-layer gate (`bridgeEntryAllowed`; - see [Ship bridge view](#ship-bridge-view)). -- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session. + Cockpit actions, double-click and voice use the same gate; voice entry still + opens Contacts first, as its layer-targeted entry navigates through Contacts. + The ship bridge and the orbital view have their own own-layer gates + (`bridgeEntryAllowed`, `orbitEntryAllowed`; see + [Ship bridge view](#ship-bridge-view) and [Orbital view](#orbital-view)). +- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `Escape` or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session. - **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor. - **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. -- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls replace reliance on the `C` shortcut. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. +- **Cockpit route, vision, and view controls:** visible on-screen `COCKPIT`, `RESET`, and `EXIT COCKPIT` controls are the way in and out; Cockpit claims no `C` shortcut, so `C` keeps toggling CCTV, also inside a first-person view. RESET uses the same canonical globe route as the map and voice actions, exits Cockpit, and releases its camera ownership rather than exposing the hidden map-style top action. When the tracked commercial flight has a plausible ADSBDB route, the top of the right briefing rail shows a compact `FROM → TO` airport strip and the visor shows a centered estimated-destination chevron with its relative bearing; its destination label rounds to whole degrees in 000–359, wrapping rounded north to 000° while the chevron continues to use the relative bearing independently of label rounding; absent or implausible route data hides the strip and cue rather than guessing. The cockpit-local vision control is an interactive `PREV / CURRENT / NEXT` carousel over `NORMAL`, `CRT`, `NVG`, `FLIR`, `ANIME`, and `NOIR`; its previous/next actions wrap, and activating the current value advances to the next style. Cockpit enters on the active map preset, so a Cyber FLIR map opens Cockpit on FLIR while Normal remains a distinct option instead of producing a second FLIR entry. Cockpit choices temporarily activate the existing Cesium post-process stages, while exiting Cockpit through either EXIT or RESET restores the pre-entry map style. The regional-news page uses a free Google News RSS locality query first, with the existing GDELT query retained only as a fail-soft fallback; linked headlines remain reporting, not verified incidents or risk intelligence. - Cockpit wind-direction labels wrap after rounding to whole degrees, so a direction of 359.5° displays as `N · 0°` rather than `N · 360°`. - The transit, ALPR, vessel, CCTV, Local ADS-B, flight and military context, awareness, Cockpit and Street Level heading readouts round through `roundHeadingDegrees` in `src/data/headingDegrees.js` (whole degrees 0–359, `null` when not finite, so each caller keeps its own placeholder); `normalizeHeading` lives there too and `cockpitMath.js` re-exports it. The Cockpit's `TRACK ACQUIRED` signal uses `formatHeadingReadout`, so a 359.6° track reads `COURSE 000°`. - **Cockpit missing observations:** missing local weather observations remain unknown rather than being converted to zero or clear skies, in the briefing and in the cloud effects; a missing temperature drops the whole local weather record, including its wind and condition. diff --git a/package-lock.json b/package-lock.json index dd60d3383..87c2cb80b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@meri-imperiumi/eccodes-wasm": "^2.48.2", "cesium": "^1.124.0", "egm96-universal": "^1.1.1", + "flag-icons": "7.5.0", "hls.js": "^1.7.3", "mapillary-js": "^4.1.2", "mgrs": "^2.1.0", @@ -2411,6 +2412,12 @@ } } }, + "node_modules/flag-icons": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz", + "integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==", + "license": "MIT" + }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", diff --git a/package.json b/package.json index ffc554d7b..f6362281f 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "@meri-imperiumi/eccodes-wasm": "^2.48.2", "cesium": "^1.124.0", "egm96-universal": "^1.1.1", + "flag-icons": "7.5.0", "hls.js": "^1.7.3", "mapillary-js": "^4.1.2", "mgrs": "^2.1.0", diff --git a/scripts/build-admin-packs.mjs b/scripts/build-admin-packs.mjs index d7f647384..9fe319ee0 100644 --- a/scripts/build-admin-packs.mjs +++ b/scripts/build-admin-packs.mjs @@ -7,13 +7,16 @@ * Natural Earth 10m admin-1 states and provinces (public domain). * src/data/local_data/us_census_counties/counties.json * US Census Bureau cartographic boundary counties (public domain). + * src/data/local_data/natural_earth/populated_places.json + * Natural Earth 10m populated places as points, for the orbital view's + * "near " (`src/data/placeBelow.js`; public domain). * * Both sources are pinned by URL and SHA-256, so a rerun reproduces the packs * byte for byte. Downloads are cached outside the repository (default: the * system temp directory) and are never committed. * * Usage: - * node scripts/build-admin-packs.mjs [--cache ] [--only countries|admin1|counties] + * node scripts/build-admin-packs.mjs [--cache ] [--only countries|admin1|counties|places] * * The provenance READMEs beside each pack record these parameters. */ @@ -101,6 +104,9 @@ export const PARAMS = Object.freeze({ elsewhereMinPopulation: 50_000, cityStateMaxKm2: 1_000, }, + // Every populated place, kept as a point: name, position and the two + // prominence ranks the orbital view picks "near " by. + places: { decimals: 3 }, }); const args = process.argv.slice(2); @@ -688,6 +694,47 @@ async function buildAdmin1() { }; } +async function buildPlaces() { + const params = PARAMS.places; + const source = await fetchPinned('places'); + const factor = 10 ** params.decimals; + const round = (value) => Math.round(value * factor) / factor; + const places = JSON.parse(source.bytes.toString('utf8')) + .features.map((f) => f.properties) + .filter( + (p) => + p.name && + Number.isFinite(p.latitude) && + Number.isFinite(p.longitude) && + Number.isFinite(p.scalerank), + ) + .map((p) => [ + p.name, + round(p.latitude), + round(p.longitude), + p.scalerank, + Number.isFinite(p.rank_max) ? p.rank_max : 0, + ]) + .sort((a, b) => a[3] - b[3] || b[4] - a[4] || a[0].localeCompare(b[0])); + return { + meta: { + title: 'Natural Earth populated places', + source: 'ne_10m_populated_places_simple', + url: SOURCES.places.url, + sha256: source.digest, + commit: NE_COMMIT, + license: + 'Public domain (https://www.naturalearthdata.com/about/terms-of-use/)', + schema: + 'places[]: [name, latitude, longitude, scalerank (0 most prominent .. 10), rank_max (population class, 0..14; higher is larger)]', + decimals: params.decimals, + curation: params, + script: 'scripts/build-admin-packs.mjs', + }, + places, + }; +} + async function buildCounties() { const params = PARAMS.counties; const source = await fetchPinned('counties'); @@ -768,4 +815,13 @@ if (isMain) { 'src/data/local_data/us_census_counties/counties.json', await buildCounties(), ); + if (!only || only === 'places') { + const relative = 'src/data/local_data/natural_earth/populated_places.json'; + const pack = await buildPlaces(); + const text = JSON.stringify(pack) + '\n'; + await writeFile(path.join(ROOT, relative), text); + console.log( + `${relative}: ${pack.places.length} places, ${Buffer.byteLength(text)} bytes`, + ); + } } diff --git a/scripts/check-package-boundaries.mjs b/scripts/check-package-boundaries.mjs index 700dc9a1a..ad5d2bdb2 100644 --- a/scripts/check-package-boundaries.mjs +++ b/scripts/check-package-boundaries.mjs @@ -109,7 +109,14 @@ export async function checkPackageBoundaries(root) { // template blocks belong to the component file itself. if (info.id.startsWith('\0')) return; const id = info.id.split('?')[0]; - if (!allowed.has(id)) { + // A declared dependency also owns the asset files it ships (a + // flag-icons SVG imported as a URL) that a glob resolves in place. + const dependencyAsset = group.external.some((external) => + id.startsWith( + `${normalizePath(path.join(root, 'node_modules', external))}/`, + ), + ); + if (!allowed.has(id) && !dependencyAsset) { throw new Error( `Package boundary ${name} imports an unowned module: ${path.relative(root, id)}`, ); diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f5443b2e5..466606b92 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -40,6 +40,8 @@ "src/ui/views/panels/CockpitContext.vue", "src/ui/views/panels/CockpitRoute.vue", "src/ui/views/panels/CockpitRadar.vue", + "src/ui/views/panels/CockpitOrbital.vue", + "src/ui/ownerFlag.js", "src/ui/cockpitPresentation.js", "src/data/headingDegrees.js", "src/data/distanceFormat.js", @@ -158,7 +160,7 @@ "src/ui/i18n.js", "src/ui/surfaceKeyboard.js" ], - "external": ["vue", "pinia", "vue-i18n"] + "external": ["vue", "pinia", "vue-i18n", "flag-icons"] }, "viewer": { "exports": ["./application/viewer"], @@ -891,6 +893,11 @@ "src/ui/cockpitInstrumentState.js", "src/ui/cockpitRollingValue.js", "src/ui/cockpitCamera.js", + "src/ui/cockpitOrbitalView.js", + "src/data/placeBelow.js", + "src/data/adminBoundaries.js", + "src/data/bundledJson.js", + "src/data/retryableLoad.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", "src/ui/cockpitDisplayPortal.js", @@ -908,6 +915,7 @@ "src/cockpitSubjects.js", "src/cockpitRadar.js", "src/cockpitLook.js", + "src/cockpitOrbital.js", "src/geoDistance.js", "src/data/motionModel.js", "src/ui/panelRailGeometry.js", @@ -981,6 +989,7 @@ "src/cockpitSubjects.js", "src/cockpitRadar.js", "src/cockpitLook.js", + "src/cockpitOrbital.js", "src/geoDistance.js", "src/data/motionModel.js", "src/contactsDetectionPolicy.js", @@ -1033,6 +1042,11 @@ "src/ui/cockpitInstrumentState.js", "src/ui/cockpitRollingValue.js", "src/ui/cockpitCamera.js", + "src/ui/cockpitOrbitalView.js", + "src/data/placeBelow.js", + "src/data/adminBoundaries.js", + "src/data/bundledJson.js", + "src/data/retryableLoad.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", "src/ui/cockpitDisplayPortal.js", @@ -1637,6 +1651,7 @@ "src/data/satellitePass.js", "src/sources/tle.js", "src/layers/satellites/catalog.js", + "src/layers/satellites/cockpitSubject.js", "src/layers/satellites/controls.js", "src/layers/satellites/index.js", "src/layers/satellites/ingestion.js", @@ -2411,6 +2426,7 @@ "src/cockpitSubjects.js", "src/cockpitRadar.js", "src/cockpitLook.js", + "src/cockpitOrbital.js", "src/contactsDetectionPolicy.js", "src/contextModePolicy.js", "src/creditKeyboard.js", @@ -2705,6 +2721,7 @@ "src/layers/radio/tuningNoise.js", "src/layers/radio/volume.js", "src/layers/satellites/catalog.js", + "src/layers/satellites/cockpitSubject.js", "src/layers/satellites/controls.js", "src/layers/satellites/index.js", "src/layers/satellites/ingestion.js", @@ -2854,6 +2871,8 @@ "src/ui/cockpitInstrumentState.js", "src/ui/cockpitRollingValue.js", "src/ui/cockpitCamera.js", + "src/ui/cockpitOrbitalView.js", + "src/data/placeBelow.js", "src/ui/cockpitContext.js", "src/ui/cockpitController.js", "src/ui/cockpitDisplayPortal.js", @@ -3675,6 +3694,7 @@ "src/layers/radio/tuningNoise.js", "src/layers/radio/volume.js", "src/layers/satellites/catalog.js", + "src/layers/satellites/cockpitSubject.js", "src/layers/satellites/controls.js", "src/layers/satellites/index.js", "src/layers/satellites/ingestion.js", @@ -4185,6 +4205,7 @@ "src/cockpitSubjects.js", "src/cockpitRadar.js", "src/cockpitLook.js", + "src/cockpitOrbital.js", "src/geoDistance.js", "src/data/motionModel.js", "src/contactsDetectionPolicy.js", diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index a668180fe..1c41139c8 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -42,7 +42,7 @@ function ordered(source, needles, label) { test('Cockpit takeover invalidates deferred work before camera cancellation', () => { const enter = cockpitEnter.toString(); ordered(enter, [ - 'if (!info || (!vessel && !entity?.position)) return false;', + 'if (!info || (!vessel && !satellite && !entity?.position)) return false;', 'this.onCameraTakeover?.();', 'this.viewer.camera.cancelFlight();', 'this.viewer.trackedEntity = undefined;', diff --git a/src/cockpitCloudEffects.js b/src/cockpitCloudEffects.js index c9117b7d3..c1cc796b4 100644 --- a/src/cockpitCloudEffects.js +++ b/src/cockpitCloudEffects.js @@ -170,6 +170,18 @@ export function cockpitWeatherEnabledFromStoredValue(value) { return value === '1'; } +/** + * Whether the cloud pass runs for a Cockpit subject's layer. Not in orbit: + * the pass fades out above 24 km (weatherAltitudeFactors), so a satellite + * would pay for it and see nothing. The saved preference is left as it is + * for the next aircraft or bridge; cloud cover from orbit is the Weather + * layer's. + * @param {string|null|undefined} layerId + */ +export function cockpitCloudsShowFor(layerId) { + return layerId !== 'satellites'; +} + /** * Weather-backed volumetric cloud pass that exists only while cockpit mode is * active. It owns no Cesium fog/post-process stages and cannot affect map mode. @@ -207,10 +219,12 @@ class CockpitCloudEffectsController { this.enabled = this.readEnabledPreference(); this.onResize = () => this.resize(); + this.orbit = false; this.onCockpitMode = (event) => { const active = event?.detail?.active ?? document.body.classList.contains('cockpit-mode'); + this.orbit = active && !cockpitCloudsShowFor(event?.detail?.layerId); if (active && this.enabled) this.start(); else this.stop(); }; @@ -344,7 +358,8 @@ class CockpitCloudEffectsController { this.destroyed || !this.enabled || this.frame !== null || - this.suspended + this.suspended || + this.orbit ) return; if (!this.gl) { diff --git a/src/cockpitCloudEffects.test.mjs b/src/cockpitCloudEffects.test.mjs index 18a0ed394..651b3fb27 100644 --- a/src/cockpitCloudEffects.test.mjs +++ b/src/cockpitCloudEffects.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { cockpitCloudRenderSize, + cockpitCloudsShowFor, cockpitWeatherRefreshDue, cockpitWeatherEnabledFromStoredValue, } from './cockpitCloudEffects.js'; @@ -23,6 +24,13 @@ test('cockpit weather defaults off and enables only from an explicit saved opt-i assert.equal(cockpitWeatherEnabledFromStoredValue('1'), true); }); +test('the cloud pass stays off in orbit and runs for aircraft and bridges', () => { + assert.equal(cockpitCloudsShowFor('satellites'), false); + for (const layerId of ['flights', 'military', 'ais-live-vessels', null]) { + assert.equal(cockpitCloudsShowFor(layerId), true, String(layerId)); + } +}); + test('cockpit weather refreshes after time or meaningful movement', () => { const anchor = { latitude: 30, longitude: -97 }; assert.equal(cockpitWeatherRefreshDue({ diff --git a/src/cockpitLook.js b/src/cockpitLook.js index 8292cde25..e0b133b34 100644 --- a/src/cockpitLook.js +++ b/src/cockpitLook.js @@ -181,3 +181,64 @@ export function lookReadoutParts(look, restPitchDeg) { angleDeg: Math.abs(pitch), }; } + +/** + * The orbital view's off-nadir limit on each axis. The orbital look reuses + * the bridge's `{yawDeg, pitchDeg}` shape over a fixed nadir frame: yaw tilts + * the view east (positive) or west, pitch tilts it north (positive) or + * south. Single 5° steps only; the bridge keeps its own limits. + */ +export const ORBITAL_LOOK_LIMIT_DEG = 45; + +/** Clamp one orbital look axis to ±ORBITAL_LOOK_LIMIT_DEG; NaN rests. */ +function clampOrbitalAxis(deg) { + if (!Number.isFinite(deg)) return 0; + const value = Math.min( + ORBITAL_LOOK_LIMIT_DEG, + Math.max(-ORBITAL_LOOK_LIMIT_DEG, deg), + ); + return value === 0 ? 0 : value; +} + +/** + * Clamp an orbital look to the off-nadir limit on both axes. + * @param {{yawDeg: number, pitchDeg: number}} look East and north tilt. + * @returns {{yawDeg: number, pitchDeg: number}} + */ +export function clampOrbitalLook(look) { + return { + yawDeg: clampOrbitalAxis(look?.yawDeg), + pitchDeg: clampOrbitalAxis(look?.pitchDeg), + }; +} + +/** + * Apply one arrow step to an orbital look: the bridge's stepping on the + * same 5° grid, then the off-nadir clamp. + * @param {{yawDeg: number, pitchDeg: number}} target Current look target. + * @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step From + * {@link lookKeyStep}. + * @returns {{yawDeg: number, pitchDeg: number}} + */ +export function stepOrbitalLook(target, step) { + return clampOrbitalLook(stepLook(clampOrbitalLook(target), step)); +} + +/** + * The parts of an off-nadir readout, for the Cockpit to word: the north or + * south tilt and the east or west tilt, each null at rest, in whole degrees. + * @param {{yawDeg: number, pitchDeg: number}} look Orbital look target. + * @returns {{northSouth: 'N'|'S'|null, northSouthDeg: number, + * eastWest: 'E'|'W'|null, eastWestDeg: number}} + */ +export function orbitalLookReadoutParts(look) { + const { yawDeg, pitchDeg } = clampOrbitalLook(look); + const north = Math.round(pitchDeg); + const east = Math.round(yawDeg); + return { + northSouth: north > 0 ? 'N' : north < 0 ? 'S' : null, + northSouthDeg: Math.abs(north), + eastWest: east > 0 ? 'E' : east < 0 ? 'W' : null, + eastWestDeg: Math.abs(east), + }; +} diff --git a/src/cockpitLook.test.mjs b/src/cockpitLook.test.mjs index f1f4a239c..27f79e319 100644 --- a/src/cockpitLook.test.mjs +++ b/src/cockpitLook.test.mjs @@ -5,12 +5,16 @@ import assert from 'node:assert/strict'; import { LOOK_COARSE_STEP_DEG, LOOK_STEP_DEG, + ORBITAL_LOOK_LIMIT_DEG, clampLookPitch, + clampOrbitalLook, easeLook, lookIsOffset, lookKeyStep, lookReadoutParts, + orbitalLookReadoutParts, stepLook, + stepOrbitalLook, wrapLookYaw, } from './cockpitLook.js'; @@ -135,3 +139,57 @@ test('the readout names the side, the bearing and the elevation', () => { 'astern', ); }); + +test('an orbital look steps 5° per arrow and stops at 45° on each axis', () => { + let look = { yawDeg: 0, pitchDeg: 0 }; + for (let index = 0; index < 3; index += 1) + look = stepOrbitalLook(look, lookKeyStep('ArrowUp')); + assert.deepEqual(look, { yawDeg: 0, pitchDeg: 15 }); + look = stepOrbitalLook(look, lookKeyStep('ArrowRight')); + assert.deepEqual(look, { yawDeg: 5, pitchDeg: 15 }); + for (let index = 0; index < 20; index += 1) { + look = stepOrbitalLook(look, lookKeyStep('ArrowDown')); + look = stepOrbitalLook(look, lookKeyStep('ArrowLeft')); + } + assert.deepEqual(look, { + yawDeg: -ORBITAL_LOOK_LIMIT_DEG, + pitchDeg: -ORBITAL_LOOK_LIMIT_DEG, + }); + assert.deepEqual(clampOrbitalLook({ yawDeg: 90, pitchDeg: NaN }), { + yawDeg: 45, + pitchDeg: 0, + }); + assert.deepEqual(clampOrbitalLook({ yawDeg: 180, pitchDeg: -60 }), { + yawDeg: 45, + pitchDeg: -45, + }); +}); + +test('the orbital readout names north or south and east or west', () => { + assert.deepEqual(orbitalLookReadoutParts({ yawDeg: 15, pitchDeg: 10 }), { + northSouth: 'N', + northSouthDeg: 10, + eastWest: 'E', + eastWestDeg: 15, + }); + assert.deepEqual(orbitalLookReadoutParts({ yawDeg: -5, pitchDeg: 0 }), { + northSouth: null, + northSouthDeg: 0, + eastWest: 'W', + eastWestDeg: 5, + }); + assert.equal( + orbitalLookReadoutParts({ yawDeg: 0, pitchDeg: -45 }).northSouth, + 'S', + ); +}); + +test('the bridge look keeps its own limits beside the orbital clamp', () => { + const look = stepLook({ yawDeg: 45, pitchDeg: 45 }, lookKeyStep('ArrowUp')); + assert.deepEqual(look, { yawDeg: 45, pitchDeg: 50 }); + assert.equal( + stepLook({ yawDeg: 45, pitchDeg: 0 }, lookKeyStep('ArrowRight', true)) + .yawDeg, + 90, + ); +}); diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index aa06193ba..06db3aeff 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -79,7 +79,7 @@ test('Cockpit has one reset action beside its bottom exit path', () => { assert.doesNotMatch(html, /id="cockpit-exit"/); assert.match( html, - /id="map-view-switch"[^>]*aria-label="Exit cockpit view"[^>]*aria-keyshortcuts="Escape C"[\s\S]*?close_fullscreen[\s\S]*?EXIT COCKPIT/, + /id="map-view-switch"[^>]*aria-label="Exit cockpit view"[^>]*aria-keyshortcuts="Escape"[\s\S]*?close_fullscreen[\s\S]*?EXIT COCKPIT/, ); const topCenterActions = html.match( /