Merge pull request #1559 from ZloyS/denis/gev-orbital-view

feat(cockpit): ride a tracked satellite in an orbital view
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-08 16:27:41 -08:00
commit a274d10038
78 files changed
+7362 -305

No files matched your search

+13 -2
View File
@@ -229,10 +229,11 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
| **Québec CCTV source catalog** (1,120 locations) | `public/quebec-cameras.json` | Mixed: MTQ locations CC BY 4.0; Montréal camera nodes ODbL 1.0; scenic records retain per-record provenance. No video files are bundled or cached. | Location data is per-record; video remains hosted by Québec 511 and its platform terms apply; scenic source pages may have separate terms | MTQ / Données Québec CC BY 4.0; © OpenStreetMap contributors (ODbL); scenic operator and position source shown in the selected-camera panel |
| **World camera image subset** (2,230 locations) | `config/cctv_sources.world-image-cameras.json` | Mixed: ODOT TripCheck public images (ODOT permits use with credit) and MLIT site content under terms compatible with CC BY 4.0. Frames are not bundled or persisted; the server holds a bounded in-memory cache. | Per-source terms apply; NZTA Journey Planner web-cache rows are excluded; Vegagerðin rows are served by the Iceland pack instead | Oregon Department of Transportation (Camera courtesy of ODOT) and Japan Ministry of Land, Infrastructure, Transport and Tourism (出典:国土交通省 川の防災情報) |
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
| **UCS Satellite Database** UCS-Satellite-Database 5-1-2023 (3,564 records; purpose) | `ucs_satellites/` | **Free, unrestricted use** (UCS database manual, section 3) | ✅ (acknowledgement requested) | "UCS Satellite Database (UCS-Satellite-Database 5-1-2023), https://www.ucsusa.org/satellite_database" |
| **UCS Satellite Database** UCS-Satellite-Database 5-1-2023 (3,564 records; operator, owner country, purpose) | `ucs_satellites/` | **Free, unrestricted use** (UCS database manual, section 3) | ✅ (acknowledgement requested) | "UCS Satellite Database (UCS-Satellite-Database 5-1-2023), https://www.ucsusa.org/satellite_database" |
| **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 |
@@ -296,7 +297,7 @@ The former bundled 2026-05-25 snapshot was removed 2026-07-16.
### UCS Satellite Database (`ucs_satellites/`)
The purpose of each active satellite, keyed by NORAD number, from the Union of Concerned Scientists Satellite Database release **UCS-Satellite-Database 5-1-2023** (data current through 2023-05-01; https://www.ucsusa.org/satellite_database). The satellites layer joins it to CelesTrak positions and SATCAT records to file each satellite as a station, imaging, telecom, Starlink, probe or debris object. Section 3 of the UCS database manual: "The Database is free and its use is unrestricted. We request that its use be acknowledged and referenced in written materials. References should include the version of the Database that was used, which is indicated by the name of the Excel file, and a link to or URL for the webpage www.ucsusa.org/satellite_database." The app acknowledges it in the Data attribution popover. UCS has paused updates since this release; Starlink rows are dropped (Starlink is classed by name). Build: `scripts/build-ucs-satellites.py`; curation in the folder README.
Operator, owner country and purpose for active satellites, keyed by NORAD number, from the Union of Concerned Scientists Satellite Database release **UCS-Satellite-Database 5-1-2023** (data current through 2023-05-01; https://www.ucsusa.org/satellite_database). The satellites layer joins it to CelesTrak positions and SATCAT records to file each satellite as a station, imaging, telecom, Starlink, probe or debris object, and to name its operator and owner. Section 3 of the UCS database manual: "The Database is free and its use is unrestricted. We request that its use be acknowledged and referenced in written materials. References should include the version of the Database that was used, which is indicated by the name of the Excel file, and a link to or URL for the webpage www.ucsusa.org/satellite_database." The app acknowledges it in the Data attribution popover. UCS has paused updates since this release; Starlink rows are dropped (Starlink is classed by name). Build: `scripts/build-ucs-satellites.py`; curation in the folder README.
### Natural Earth physical regions (`natural_earth/`)
@@ -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
+1 -1
View File
@@ -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.
---
+4
View File
@@ -0,0 +1,4 @@
- Ride a tracked satellite in an orbital view: a track-up nadir camera, 1–8×
zoom, off-nadir tilt with an approximate swath, a CURRENTLY OVER line, and the
owner's flag, operator and TLE identity. `C` no longer enters or leaves first
person; double-click and the entry buttons do (Denis Groshev, @ZloyS, #1559).
+113 -18
View File
@@ -1530,17 +1530,16 @@ 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.
voice `enter` on a bridge or in an orbital view is refused before it touches
tracking, with the reason to exit it first.
The vessel layer supplies the subject (`getCockpitSubject`), hides that ship's
sprite, card, detection bracket and trail while it is held
(`setCockpitSubject`), and
@@ -1605,6 +1604,100 @@ 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. While orbit holds the lease, Recent
Imagery cannot take it: turned on in orbit, the layer reports the comparison
in use until, after exit, it is turned off and on again or its shown days
are cleared and picked again. 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 +3588,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 +5551,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/<group>`, 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: each satellite's 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/<group>`, 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.
@@ -6365,15 +6459,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, leavinLine truncated
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 bothLine truncated
- **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.
+7
View File
@@ -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",
+1
View File
@@ -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",
+57 -1
View File
@@ -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 <city>" (`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 <dir>] [--only countries|admin1|counties]
* node scripts/build-admin-packs.mjs [--cache <dir>] [--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 <city>" 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`,
);
}
}
+5 -3
View File
@@ -2,9 +2,9 @@
'''Maintainer-only UCS Satellite Database pack build (openpyxl).
Reads the UCS Excel release and writes the compact pack the satellites layer
loads: one row per satellite, keyed by NORAD number, with a string-table index
for its purpose, the one column the layer's classes read. Starlink rows are
left out: the layer files every STARLINK object under its own class by name.
loads: one row per satellite, keyed by NORAD number, with string-table indexes
for the repeated text columns. Starlink rows are left out: the layer files
every STARLINK object under its own class and operator without them.
Usage: python scripts/build-ucs-satellites.py "<path>/UCS-Satellite-Database 5-1-2023.xlsx"
'''
@@ -26,6 +26,8 @@ URL = 'https://www.ucsusa.org/satellite_database'
# Output column -> spreadsheet header.
COLUMNS = {
'operator': 'Operator/Owner',
'operatorCountry': 'Country of Operator/Owner',
'purpose': 'Purpose',
}
STARLINK = re.compile(r'\bstarlink\b', re.I)
+8 -1
View File
@@ -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)}`,
);
+1
View File
@@ -330,6 +330,7 @@
"src/ui/styles/cockpit.css",
"src/ui/styles/cockpitBridge.css",
"src/ui/styles/cockpitRadar.css",
"src/ui/styles/cockpitOrbital.css",
"src/ui/styles/command-dock-compact.css",
"src/app/stateChannel.js",
"src/app/stateChannel.test.mjs",
+22 -1
View File
@@ -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",
@@ -162,7 +164,7 @@
"src/ui/views/panels/WorldTimeline.vue",
"src/ui/surfaceKeyboard.js"
],
"external": ["vue", "pinia", "vue-i18n"]
"external": ["vue", "pinia", "vue-i18n", "flag-icons"]
},
"time": {
"exports": ["./time"],
@@ -900,6 +902,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",
@@ -917,6 +924,7 @@
"src/cockpitSubjects.js",
"src/cockpitRadar.js",
"src/cockpitLook.js",
"src/cockpitOrbital.js",
"src/geoDistance.js",
"src/data/motionModel.js",
"src/ui/panelRailGeometry.js",
@@ -990,6 +998,7 @@
"src/cockpitSubjects.js",
"src/cockpitRadar.js",
"src/cockpitLook.js",
"src/cockpitOrbital.js",
"src/geoDistance.js",
"src/data/motionModel.js",
"src/contactsDetectionPolicy.js",
@@ -1042,6 +1051,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",
@@ -1651,6 +1665,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",
@@ -2428,6 +2443,7 @@
"src/cockpitSubjects.js",
"src/cockpitRadar.js",
"src/cockpitLook.js",
"src/cockpitOrbital.js",
"src/contactsDetectionPolicy.js",
"src/contextModePolicy.js",
"src/creditKeyboard.js",
@@ -2722,6 +2738,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",
@@ -2871,6 +2888,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",
@@ -3695,6 +3714,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",
@@ -4208,6 +4228,7 @@
"src/cockpitSubjects.js",
"src/cockpitRadar.js",
"src/cockpitLook.js",
"src/cockpitOrbital.js",
"src/geoDistance.js",
"src/data/motionModel.js",
"src/contactsDetectionPolicy.js",
+1 -1
View File
@@ -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;',
+16 -1
View File
@@ -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) {
+8
View File
@@ -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({
+61
View File
@@ -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),
};
}
+58
View File
@@ -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,
);
});
+6 -7
View File
@@ -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(
/<nav id="top-center-actions"[\s\S]*?<\/nav>/,
@@ -163,7 +163,8 @@ test('Cockpit vision cycle exposes every map preset exactly once', () => {
assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/);
assert.match(
cockpitEnter.toString(),
/this\.setVisionMode\(this\.getInitialVisionMode\(\)\)/,
// Orbit alone opens optical (src/ui/cockpitOrbitalView.test.mjs).
/this\.setVisionMode\(\s*satellite \? 'optical' : this\.getInitialVisionMode\(\)\s*,?\s*\)/,
'Cockpit must enter on the mode matching the active map preset',
);
assert.match(
@@ -232,14 +233,12 @@ test('Cockpit Escape handling precedes form-control shortcut suppression and foc
);
});
test('Cockpit shortcut failures do not leak and open utilities own the first Escape', () => {
test('open utilities own the first Escape and Cockpit leaves C alone', () => {
const keydown = cockpitKeyDown.toString();
assert.ok(keydown, 'Cockpit keyboard handler is missing');
assert.match(keydown, /if \(this\.isUtilityDisclosureOpen\?\.\(\)\) return;/);
assert.match(
keydown,
/const cockpitAttempt = !!\(\s*this\.readAircraftInfo\(\) && this\.viewer\.trackedEntity\?\.position\s*\);[\s\S]*?event\.preventDefault\(\);[\s\S]*?event\.stopImmediatePropagation\(\);[\s\S]*?!this\.isEntryAllowed\(\)/,
);
// C is the app-wide CCTV shortcut; Cockpit never claims it.
assert.doesNotMatch(keydown, /=== 'c'/);
});
test('the Contact panel never hides itself out from under its own NEXT button', () => {
+428
View File
@@ -0,0 +1,428 @@
/**
* Camera policy for the orbital (satellite) view: the eye rides the tracked
* satellite and looks straight down at the Earth's centre. Satellites publish
* no attitude, so the picture is turned to the ground track (track-up; see
* nadirOrientation's `headingDeg`), or north-up for a satellite with none
* (geostationary). This module holds the zoom steps,
* the field of view, the nadir orientation, the ground footprint and the zoom
* transition curves. No DOM, Cesium or i18n dependencies: vectors are plain
* `{x, y, z}` objects in Earth-fixed (ECEF) metres, so the policy is
* unit-testable and the Cockpit camera copies them into its own scratch.
*/
/** Mean Earth radius used for footprint and framing geometry, metres. */
export const ORBITAL_EARTH_RADIUS_M = 6371008.8;
/** The optical zoom steps. */
export const ORBITAL_ZOOM_LADDER = Object.freeze([1, 2, 4, 8]);
/** The tightest step. */
export const ORBITAL_BASE_MAX_ZOOM = 8;
/** The zoom an orbital view opens at. */
export const ORBITAL_DEFAULT_ZOOM = 1;
/**
* Vertical field of view at 1x for low orbits. From the ISS (~420 km) a 60°
* frame spans roughly 490 km of ground top to bottom: a regional picture
* with the curvature just out of frame, which 8x narrows to ~60 km.
*/
export const ORBITAL_BASE_FOV_LEO_RAD = (60 * Math.PI) / 180;
/**
* How much wider than the Earth's disk a high orbit's 1x frame is. A fixed
* 60° frame from GEO (~35,786 km) would show a 17° Earth floating in black;
* above about 8,000 km the base frame instead fits the whole disk with this
* margin, so 1x is "the whole Earth" and the steps zoom into it.
*/
export const ORBITAL_DISK_MARGIN = 1.15;
/** Duration of one zoom step's field-of-view change, ms. */
export const ORBITAL_ZOOM_TRANSITION_MS = 400;
/** How long the reticle shows its lock tick after a step settles, ms. */
export const ORBITAL_LOCK_TICK_MS = 220;
/** Peak focus-pull blur at the middle of a zoom step, CSS pixels. */
export const ORBITAL_MAX_BLUR_PX = 6;
/**
* Longest a zoom step holds its focus pull for the new field of view's
* imagery, ms. The camera moves at orbital speed, so the tiles may never
* all arrive; the hold gives them a start, then the reticle locks anyway.
*/
export const ORBITAL_SETTLE_MAX_MS = 2500;
/** The soft blur held while a step waits for its imagery, CSS pixels. */
export const ORBITAL_SETTLE_BLUR_PX = 2.5;
/** Relative slack so a zoom computed in floating point still meets its step. */
const ZOOM_EPSILON = 1e-6;
/**
* The next tighter ladder step, up to `maxZoom`; at the cap the zoom stays.
* A zoom between steps moves to the step above it.
* @param {number} zoom Current zoom.
* @param {number} [maxZoom] The tightest step.
* @returns {number}
*/
export function nextOrbitalZoom(zoom, maxZoom = ORBITAL_BASE_MAX_ZOOM) {
const current = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
const next = ORBITAL_ZOOM_LADDER.find(
(step) => step > current * (1 + ZOOM_EPSILON) && step <= maxZoom,
);
return next ?? current;
}
/**
* The next wider ladder step; 1x stays where it is. A zoom between steps
* moves to the step below it.
* @param {number} zoom Current zoom.
* @returns {number}
*/
export function previousOrbitalZoom(zoom) {
const current = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
const wider = ORBITAL_ZOOM_LADDER.filter(
(step) => step < current * (1 - ZOOM_EPSILON),
);
return wider.length ? wider[wider.length - 1] : ORBITAL_DEFAULT_ZOOM;
}
/**
* Angular radius of the Earth's disk seen from an altitude, radians.
* @param {number} altitudeM Height above the mean sphere, metres.
* @returns {number} Half-angle from nadir to the limb (π/2 at the surface).
*/
export function earthAngularRadiusRad(altitudeM) {
const h = Math.max(0, Number.isFinite(altitudeM) ? altitudeM : 0);
return Math.asin(ORBITAL_EARTH_RADIUS_M / (ORBITAL_EARTH_RADIUS_M + h));
}
/**
* Vertical field of view at 1x for an altitude, radians.
*
* Low orbits get the fixed 60° regional frame. Higher orbits, where 60° would
* leave the Earth small in a black frame, get the Earth's disk plus
* ORBITAL_DISK_MARGIN. The two meet near 8,100 km, so the base never jumps
* as an eccentric orbit climbs: GPS (~20,200 km) opens at ~32°, GEO at ~20°.
* @param {number} altitudeM Satellite altitude, metres.
* @returns {number} Vertical field of view, radians.
*/
export function orbitalBaseFovRad(altitudeM) {
const diskFov = 2 * earthAngularRadiusRad(altitudeM) * ORBITAL_DISK_MARGIN;
return Math.min(ORBITAL_BASE_FOV_LEO_RAD, diskFov);
}
/**
* Field of view at an optical zoom: magnification divides the tangent of
* the half-angle, as a lens's focal length does.
* @param {number} baseFovRad Field of view at 1x, radians.
* @param {number} zoom Magnification (1 leaves the base unchanged).
* @returns {number} Field of view, radians.
*/
export function fovForZoom(baseFovRad, zoom) {
const magnification = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
return 2 * Math.atan(Math.tan(baseFovRad / 2) / magnification);
}
/**
* Cesium's `PerspectiveFrustum.fov` is horizontal on a landscape canvas and
* vertical on a portrait one; convert a vertical field of view to it.
* @param {number} verticalFovRad Vertical field of view, radians.
* @param {number} aspect Canvas width / height.
* @returns {number} The value to assign to `frustum.fov`, radians.
*/
export function cesiumFrustumFovRad(verticalFovRad, aspect) {
if (!(aspect > 1)) return verticalFovRad;
return 2 * Math.atan(Math.tan(verticalFovRad / 2) * aspect);
}
/**
* Ground width covered across the frame, along a great circle through the
* sub-satellite point, km. A ray past the limb ends at the horizon, so a
* frame wider than the disk reports the visible cap's width.
* @param {number} altitudeM Satellite altitude, metres.
* @param {number} fovRad Vertical field of view, radians.
* @param {number} [aspect=1] Frame width / height; the width is measured
* across the wider horizontal extent.
* @returns {number|null} Width in kilometres, or null without a usable input.
*/
export function footprintWidthKm(altitudeM, fovRad, aspect = 1) {
if (!Number.isFinite(altitudeM) || altitudeM <= 0) return null;
if (!Number.isFinite(fovRad) || fovRad <= 0) return null;
const ratio = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
const halfAngle = Math.atan(Math.tan(fovRad / 2) * ratio);
const R = ORBITAL_EARTH_RADIUS_M;
const sinGround = ((R + altitudeM) / R) * Math.sin(halfAngle);
// Central angle from nadir to where the ray meets the sphere, or the limb.
const central =
sinGround >= 1
? Math.acos(R / (R + altitudeM))
: Math.asin(sinGround) - halfAngle;
return (2 * R * central) / 1000;
}
function length(v) {
return Math.hypot(v.x, v.y, v.z);
}
function scale(v, s) {
return { x: v.x * s, y: v.y * s, z: v.z * s };
}
function dot(a, b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
function cross(a, b) {
return {
x: a.y * b.z - a.z * b.y,
y: a.z * b.x - a.x * b.z,
z: a.x * b.y - a.y * b.x,
};
}
/** `v` with its component along unit `d` removed, or null when degenerate. */
function perpendicularUnit(v, d) {
const along = dot(v, d);
const p = {
x: v.x - along * d.x,
y: v.y - along * d.y,
z: v.z - along * d.z,
};
const n = length(p);
return n > 1e-6 ? scale(p, 1 / n) : null;
}
const NORTH_AXIS = Object.freeze({ x: 0, y: 0, z: 1 });
/**
* Camera orientation looking from an Earth-fixed position at the Earth's
* centre, with north at the top of the frame.
*
* `up` is the Earth's rotation axis projected perpendicular to the view, which
* is local north at the sub-satellite point. Directly over a pole north is
* undefined; the previous frame's `up` is kept when given (so a polar pass
* does not spin the picture), otherwise the limit along the prime meridian
* is used: over the north pole the top of the frame faces 180°E, over the
* south pole 0°E.
*
* With `headingDeg` the frame turns so that bearing (degrees clockwise from
* north) is at the top: the ground track, for a view that scrolls along it.
* @param {{x: number, y: number, z: number}} position ECEF position, metres.
* @param {{previousUp?: {x: number, y: number, z: number}|null,
* headingDeg?: number|null}} [options] `previousUp` is a previous frame's
* `north`, not its turned `up`.
* @returns {{direction: {x: number, y: number, z: number},
* up: {x: number, y: number, z: number},
* right: {x: number, y: number, z: number},
* north: {x: number, y: number, z: number}}|null} Unit vectors (right =
* direction × up, as Cesium's camera keeps them), or null at the centre.
*/
export function nadirOrientation(
position,
{ previousUp = null, headingDeg = null } = {},
) {
if (!position) return null;
const r = length(position);
if (!Number.isFinite(r) || r < 1) return null;
const direction = scale(position, -1 / r);
let up = perpendicularUnit(NORTH_AXIS, direction);
if (!up && previousUp && Number.isFinite(length(previousUp)))
up = perpendicularUnit(previousUp, direction);
if (!up) {
// Looking down -z (north pole): north at longitude 0 points along -x.
up = perpendicularUnit(
{ x: direction.z < 0 ? -1 : 1, y: 0, z: 0 },
direction,
);
}
const north = up;
if (Number.isFinite(headingDeg) && headingDeg % 360 !== 0) {
// Looking down, right = direction × north is east: the bearing turns
// north toward it.
const east = cross(direction, north);
const θ = (headingDeg * Math.PI) / 180;
up = {
x: north.x * Math.cos(θ) + east.x * Math.sin(θ),
y: north.y * Math.cos(θ) + east.y * Math.sin(θ),
z: north.z * Math.cos(θ) + east.z * Math.sin(θ),
};
}
return { direction, up, right: cross(direction, up), north };
}
/**
* Ease-in-out progress of a zoom step (cubic), clamped to 0..1.
* @param {number} elapsedMs Time since the step began.
* @param {{durationMs?: number, reducedMotion?: boolean}} [options]
* Reduced motion cuts straight to the end.
* @returns {number} Eased progress, 0..1.
*/
export function zoomTransitionProgress(
elapsedMs,
{ durationMs = ORBITAL_ZOOM_TRANSITION_MS, reducedMotion = false } = {},
) {
if (reducedMotion || !(durationMs > 0)) return 1;
const linear = Math.min(1, Math.max(0, (elapsedMs || 0) / durationMs));
return linear < 0.5 ? 4 * linear ** 3 : 1 - (-2 * linear + 2) ** 3 / 2;
}
/**
* Focus-pull blur for a zoom step: zero at both ends, peaking mid-step.
* @param {number} progress Step progress, 0..1 (linear or eased).
* @param {{maxBlurPx?: number, reducedMotion?: boolean}} [options]
* Reduced motion never blurs.
* @returns {number} Blur radius, CSS pixels.
*/
export function zoomTransitionBlurPx(
progress,
{ maxBlurPx = ORBITAL_MAX_BLUR_PX, reducedMotion = false } = {},
) {
if (reducedMotion || !Number.isFinite(progress)) return 0;
const p = Math.min(1, Math.max(0, progress));
return Math.max(0, maxBlurPx) * Math.sin(Math.PI * p);
}
/**
* Field of view part-way through a zoom step. Interpolates the tangent of
* the half-angle geometrically, so each moment of the step magnifies by the
* same ratio and 1x→2x feels like 4x→8x.
* @param {number} fromFovRad Field of view the step starts at.
* @param {number} toFovRad Field of view the step ends at.
* @param {number} progress Eased progress, 0..1.
* @returns {number} Field of view, radians.
*/
export function interpolateZoomFov(fromFovRad, toFovRad, progress) {
const p = Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 1));
const a = Math.log(Math.tan(fromFovRad / 2));
const b = Math.log(Math.tan(toFovRad / 2));
return 2 * Math.atan(Math.exp(a + (b - a) * p));
}
/**
* The HUD's transition state at a moment of a zoom step, in the store shape
* `{ active, blurPx, lockTick }`: blur while the field of view moves, then a
* brief reticle lock tick once it settles. Reduced motion skips the blur and
* the tick (the cut is instant).
* @param {number} elapsedMs Time since the step began.
* @param {{durationMs?: number, lockTickMs?: number, maxBlurPx?: number,
* reducedMotion?: boolean}} [options]
* @returns {{active: boolean, progress: number, blurPx: number,
* lockTick: boolean}}
*/
export function zoomTransitionState(
elapsedMs,
{
durationMs = ORBITAL_ZOOM_TRANSITION_MS,
lockTickMs = ORBITAL_LOCK_TICK_MS,
maxBlurPx = ORBITAL_MAX_BLUR_PX,
reducedMotion = false,
} = {},
) {
const progress = zoomTransitionProgress(elapsedMs, {
durationMs,
reducedMotion,
});
if (reducedMotion)
return { active: false, progress: 1, blurPx: 0, lockTick: false };
const elapsed = Math.max(0, elapsedMs || 0);
const linear = durationMs > 0 ? Math.min(1, elapsed / durationMs) : 1;
return {
active: elapsed < durationMs,
progress,
blurPx: zoomTransitionBlurPx(linear, { maxBlurPx }),
lockTick: elapsed >= durationMs && elapsed < durationMs + lockTickMs,
};
}
/**
* Whether a zoom step whose field of view has landed still holds its focus
* pull: until the imagery has loaded, at most `maxHoldMs`.
* @param {number} sinceLandedMs Time since the field of view stopped moving.
* @param {boolean} tilesLoaded Whether the scene's imagery has loaded.
* @param {{maxHoldMs?: number}} [options]
*/
export function zoomSettleHolding(
sinceLandedMs,
tilesLoaded,
{ maxHoldMs = ORBITAL_SETTLE_MAX_MS } = {},
) {
return !tilesLoaded && Math.max(0, sinceLandedMs || 0) < maxHoldMs;
}
/**
* The total angle between a tilted view and nadir, degrees:
* acos(cos north · cos east), as `offNadirOrientation` tilts.
* @param {{northDeg?: number, eastDeg?: number}} [offset] Tilt in degrees.
* @returns {number}
*/
export function offNadirAngleDeg({ northDeg = 0, eastDeg = 0 } = {}) {
const n = ((Number.isFinite(northDeg) ? northDeg : 0) * Math.PI) / 180;
const e = ((Number.isFinite(eastDeg) ? eastDeg : 0) * Math.PI) / 180;
const cosine = Math.min(1, Math.max(-1, Math.cos(n) * Math.cos(e)));
return (Math.acos(cosine) * 180) / Math.PI;
}
/**
* Whether the view's centre, tilted off nadir, still meets the Earth: its
* angle from nadir (`offNadirAngleDeg`) is within the Earth's angular radius
* from the satellite, asin(R / (R + altitude)).
* @param {number} altitudeM
* @param {{northDeg?: number, eastDeg?: number}} [offset]
* @returns {boolean}
*/
export function offNadirCentreMeetsEarth(altitudeM, offset = {}) {
if (!Number.isFinite(altitudeM) || altitudeM <= 0) return false;
const R = ORBITAL_EARTH_RADIUS_M;
const limbDeg = (Math.asin(R / (R + altitudeM)) * 180) / Math.PI;
return offNadirAngleDeg(offset) < limbDeg;
}
/**
* Roughly how much wider the ground across the frame is at the centre of a
* tilted view than at nadir, on a flat Earth: tilting across the frame (east,
* the frame's right) stretches its width by 1/cos², tilting along it (north,
* the frame's top) by 1/cos. 1 at nadir; an approximation, so the HUD marks
* the width it scales with ≈.
* @param {{northDeg?: number, eastDeg?: number}} [offset] Tilt in degrees.
* @returns {number} At least 1.
*/
export function offNadirSwathScale({ northDeg = 0, eastDeg = 0 } = {}) {
const n = ((Number.isFinite(northDeg) ? northDeg : 0) * Math.PI) / 180;
const e = ((Number.isFinite(eastDeg) ? eastDeg : 0) * Math.PI) / 180;
const along = Math.abs(Math.cos(n));
const across = Math.cos(e) ** 2;
if (!(along > 1e-6) || !(across > 1e-6)) return 1;
return Math.max(1, 1 / (along * across));
}
/**
* Tilt a nadir orientation off nadir: first toward north (a rotation about
* the nadir frame's right axis), then toward east (about the tilted up
* axis). The base frame is always the nadir one, so a tilt never drifts with
* the orbit; the angle from nadir is acos(cos north · cos east).
* @param {{direction: object, up: object, right: object}} nadir From
* {@link nadirOrientation}.
* @param {{northDeg?: number, eastDeg?: number}} [offset] Tilt in degrees;
* negative is south or west.
* @returns {{direction: object, up: object, right: object}} Unit vectors,
* right = direction × up.
*/
export function offNadirOrientation(nadir, { northDeg = 0, eastDeg = 0 } = {}) {
const n = ((Number.isFinite(northDeg) ? northDeg : 0) * Math.PI) / 180;
const e = ((Number.isFinite(eastDeg) ? eastDeg : 0) * Math.PI) / 180;
if (n === 0 && e === 0) return nadir;
const { direction: d, up: u, right: r } = nadir;
const combine = (a, ka, b, kb) => ({
x: a.x * ka + b.x * kb,
y: a.y * ka + b.y * kb,
z: a.z * ka + b.z * kb,
});
// Toward north: the view swings onto the up axis, up swings off it.
const d1 = combine(d, Math.cos(n), u, Math.sin(n));
const u1 = combine(u, Math.cos(n), d, -Math.sin(n));
// Toward east: the view swings onto the right axis.
const direction = combine(d1, Math.cos(e), r, Math.sin(e));
return { direction, up: u1, right: cross(direction, u1) };
}
+316
View File
@@ -0,0 +1,316 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
ORBITAL_ZOOM_LADDER,
ORBITAL_BASE_FOV_LEO_RAD,
ORBITAL_EARTH_RADIUS_M,
ORBITAL_SETTLE_MAX_MS,
cesiumFrustumFovRad,
earthAngularRadiusRad,
footprintWidthKm,
fovForZoom,
interpolateZoomFov,
nadirOrientation,
nextOrbitalZoom,
offNadirAngleDeg,
offNadirCentreMeetsEarth,
offNadirSwathScale,
offNadirOrientation,
orbitalBaseFovRad,
previousOrbitalZoom,
zoomSettleHolding,
zoomTransitionBlurPx,
zoomTransitionProgress,
zoomTransitionState,
} from './cockpitOrbital.js';
const RAD = Math.PI / 180;
const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
const norm = (v) => Math.hypot(v.x, v.y, v.z);
/** ECEF position on the mean sphere at a latitude, longitude and altitude. */
function ecef(latDeg, lonDeg, altitudeM) {
const r = ORBITAL_EARTH_RADIUS_M + altitudeM;
const lat = latDeg * RAD;
const lon = lonDeg * RAD;
return {
x: r * Math.cos(lat) * Math.cos(lon),
y: r * Math.cos(lat) * Math.sin(lon),
z: r * Math.sin(lat),
};
}
/** Local north unit vector at a latitude and longitude. */
function localNorth(latDeg, lonDeg) {
const lat = latDeg * RAD;
const lon = lonDeg * RAD;
return {
x: -Math.sin(lat) * Math.cos(lon),
y: -Math.sin(lat) * Math.sin(lon),
z: Math.cos(lat),
};
}
function assertOrthonormal(orientation) {
const { direction, up, right } = orientation;
for (const v of [direction, up, right])
assert.ok(Math.abs(norm(v) - 1) < 1e-9);
assert.ok(Math.abs(dot(direction, up)) < 1e-9);
assert.ok(Math.abs(dot(direction, right)) < 1e-9);
assert.ok(Math.abs(dot(up, right)) < 1e-9);
}
test('the nadir view points at the Earth centre with north up', () => {
for (const [lat, lon, alt] of [
[0, 0, 420e3],
[51.6, -97.3, 420e3],
[-33.9, 151.2, 20.2e6],
[0.05, 75, 35.786e6],
]) {
const position = ecef(lat, lon, alt);
const orientation = nadirOrientation(position);
const toCentre = {
x: -position.x / norm(position),
y: -position.y / norm(position),
z: -position.z / norm(position),
};
assert.ok(dot(orientation.direction, toCentre) > 1 - 1e-12);
assert.ok(dot(orientation.up, localNorth(lat, lon)) > 1 - 1e-9);
assertOrthonormal(orientation);
// North up puts east on the right of a picture looking down.
const east = { x: -Math.sin(lon * RAD), y: Math.cos(lon * RAD), z: 0 };
assert.ok(dot(orientation.right, east) > 1 - 1e-9);
}
});
test('over a pole the orientation stays valid and keeps the previous up', () => {
for (const z of [1, -1]) {
const position = { x: 0, y: 0, z: z * (ORBITAL_EARTH_RADIUS_M + 800e3) };
const fallback = nadirOrientation(position);
assertOrthonormal(fallback);
assert.equal(fallback.direction.z, -z);
// The prime-meridian limit of north.
assert.ok(Math.abs(fallback.up.x - -z) < 1e-9);
const kept = nadirOrientation(position, {
previousUp: { x: 0, y: 1, z: 0 },
});
assertOrthonormal(kept);
assert.ok(kept.up.y > 1 - 1e-9);
}
// Just off the pole, north still points over it.
const nearPole = nadirOrientation(ecef(89.9999, 30, 500e3));
assertOrthonormal(nearPole);
assert.ok(dot(nearPole.up, localNorth(89.9999, 30)) > 1 - 1e-6);
});
test('a degenerate position has no orientation', () => {
assert.equal(nadirOrientation(null), null);
assert.equal(nadirOrientation({ x: 0, y: 0, z: 0 }), null);
assert.equal(nadirOrientation({ x: NaN, y: 0, z: 0 }), null);
});
test('each zoom step divides the tangent of the half-angle', () => {
const base = ORBITAL_BASE_FOV_LEO_RAD;
for (const zoom of ORBITAL_ZOOM_LADDER) {
const fov = fovForZoom(base, zoom);
assert.ok(Math.abs(Math.tan(fov / 2) - Math.tan(base / 2) / zoom) < 1e-12);
}
assert.ok(
Math.abs(
Math.tan(fovForZoom(base, 4) / 2) / Math.tan(fovForZoom(base, 2) / 2) -
0.5,
) < 1e-12,
);
assert.equal(fovForZoom(base, 1), base);
assert.equal(fovForZoom(base, 0), base);
});
test('zoom steps clamp at both ends without wrapping', () => {
assert.equal(nextOrbitalZoom(1), 2);
assert.equal(nextOrbitalZoom(4), 8);
assert.equal(nextOrbitalZoom(8), 8);
assert.equal(previousOrbitalZoom(8), 4);
assert.equal(previousOrbitalZoom(2), 1);
assert.equal(previousOrbitalZoom(1), 1);
assert.equal(nextOrbitalZoom(undefined), 2);
});
test('the base frame is regional in low orbit and fits the disk from GEO', () => {
assert.equal(orbitalBaseFovRad(420e3), ORBITAL_BASE_FOV_LEO_RAD);
const geo = orbitalBaseFovRad(35.786e6);
const disk = 2 * earthAngularRadiusRad(35.786e6);
assert.ok(geo > disk && geo < ORBITAL_BASE_FOV_LEO_RAD);
// Continuous across the crossover: no jump as an orbit climbs.
let previous = orbitalBaseFovRad(1e6);
for (let alt = 1e6; alt <= 40e6; alt += 50e3) {
const fov = orbitalBaseFovRad(alt);
assert.ok(fov <= previous + 1e-12);
assert.ok(previous - fov < 0.01);
previous = fov;
}
});
test('the footprint shrinks with zoom and stops at the limb', () => {
const alt = 420e3;
const widths = [1, 2, 4, 8].map((zoom) =>
footprintWidthKm(alt, fovForZoom(orbitalBaseFovRad(alt), zoom)),
);
for (let i = 1; i < widths.length; i++) assert.ok(widths[i] < widths[i - 1]);
assert.ok(widths[0] > 400 && widths[0] < 600, `1x ${widths[0]} km`);
assert.ok(widths[3] > 40 && widths[3] < 80, `8x ${widths[3]} km`);
// A wider frame covers more ground across.
assert.ok(
footprintWidthKm(alt, ORBITAL_BASE_FOV_LEO_RAD, 16 / 9) > widths[0],
);
// From GEO a frame wider than the disk spans the visible cap only.
const cap =
(2 *
ORBITAL_EARTH_RADIUS_M *
Math.acos(ORBITAL_EARTH_RADIUS_M / (ORBITAL_EARTH_RADIUS_M + 35.786e6))) /
1000;
assert.ok(Math.abs(footprintWidthKm(35.786e6, 40 * RAD) - cap) < 1e-6);
assert.equal(footprintWidthKm(0, 1), null);
assert.equal(footprintWidthKm(420e3, NaN), null);
});
test('Cesium frustum fov widens to the horizontal on a landscape canvas', () => {
const vertical = 30 * RAD;
assert.equal(cesiumFrustumFovRad(vertical, 0.75), vertical);
const horizontal = cesiumFrustumFovRad(vertical, 2);
assert.ok(
Math.abs(Math.tan(horizontal / 2) - 2 * Math.tan(vertical / 2)) < 1e-12,
);
});
test('zoom transition curves stay bounded and settle', () => {
assert.equal(zoomTransitionProgress(0), 0);
assert.equal(zoomTransitionProgress(400), 1);
assert.equal(zoomTransitionProgress(9999), 1);
assert.equal(zoomTransitionProgress(-50), 0);
assert.equal(zoomTransitionProgress(0, { reducedMotion: true }), 1);
let previous = 0;
for (let ms = 0; ms <= 500; ms += 10) {
const p = zoomTransitionProgress(ms);
assert.ok(p >= previous && p >= 0 && p <= 1);
previous = p;
const blur = zoomTransitionBlurPx(ms / 400);
assert.ok(blur >= 0 && blur <= 6);
}
assert.equal(zoomTransitionBlurPx(0), 0);
assert.ok(zoomTransitionBlurPx(1) < 1e-9);
assert.ok(Math.abs(zoomTransitionBlurPx(0.5) - 6) < 1e-9);
assert.equal(zoomTransitionBlurPx(0.5, { reducedMotion: true }), 0);
const from = fovForZoom(1, 1);
const to = fovForZoom(1, 2);
assert.equal(interpolateZoomFov(from, to, 0).toFixed(12), from.toFixed(12));
assert.equal(interpolateZoomFov(from, to, 1).toFixed(12), to.toFixed(12));
const mid = interpolateZoomFov(from, to, 0.5);
assert.ok(mid < from && mid > to);
});
test('the transition state blurs, then ticks the lock, then rests', () => {
const moving = zoomTransitionState(200);
assert.equal(moving.active, true);
assert.ok(moving.blurPx > 5);
assert.equal(moving.lockTick, false);
const settled = zoomTransitionState(450);
assert.equal(settled.active, false);
assert.equal(settled.lockTick, true);
assert.equal(settled.progress, 1);
assert.ok(settled.blurPx < 1e-9);
const rest = zoomTransitionState(2000);
assert.equal(rest.lockTick, false);
assert.deepEqual(zoomTransitionState(100, { reducedMotion: true }), {
active: false,
progress: 1,
blurPx: 0,
lockTick: false,
});
});
test('an off-nadir tilt turns the view by its angle from the nadir frame', () => {
const nadir = nadirOrientation(ecef(30, -97, 420_000));
assert.equal(offNadirOrientation(nadir), nadir, 'no tilt is nadir itself');
const angle = (v) => Math.acos(Math.min(1, dot(v, nadir.direction))) / RAD;
const north = offNadirOrientation(nadir, { northDeg: 10 });
assert.ok(Math.abs(angle(north.direction) - 10) < 1e-9);
assert.ok(dot(north.direction, nadir.up) > 0, 'toward north');
assert.ok(Math.abs(dot(north.direction, nadir.right)) < 1e-12);
const west = offNadirOrientation(nadir, { eastDeg: -20 });
assert.ok(Math.abs(angle(west.direction) - 20) < 1e-9);
assert.ok(dot(west.direction, nadir.right) < 0, 'toward west');
const both = offNadirOrientation(nadir, { northDeg: 45, eastDeg: 45 });
const expected = Math.acos(Math.cos(45 * RAD) ** 2) / RAD;
assert.ok(Math.abs(angle(both.direction) - expected) < 1e-9);
for (const view of [north, west, both]) {
assert.ok(Math.abs(norm(view.direction) - 1) < 1e-12);
assert.ok(Math.abs(norm(view.up) - 1) < 1e-12);
assert.ok(Math.abs(dot(view.direction, view.up)) < 1e-12);
}
});
test('a landed zoom step holds for its imagery, but never past the cap', () => {
assert.equal(zoomSettleHolding(0, false), true, 'still loading');
assert.equal(zoomSettleHolding(ORBITAL_SETTLE_MAX_MS - 1, false), true);
assert.equal(zoomSettleHolding(ORBITAL_SETTLE_MAX_MS, false), false, 'cap');
assert.equal(zoomSettleHolding(0, true), false, 'loaded: lock at once');
assert.equal(zoomSettleHolding(Number.NaN, false), true);
});
test('a heading turns the nadir frame, keeping north to fall back on', () => {
// Over the equator at 0°E: north is +z, east is +y.
const position = { x: ORBITAL_EARTH_RADIUS_M + 500_000, y: 0, z: 0 };
const northUp = nadirOrientation(position);
const east = nadirOrientation(position, { headingDeg: 90 });
const close = (a, b) =>
Math.abs(a.x - b.x) < 1e-9 &&
Math.abs(a.y - b.y) < 1e-9 &&
Math.abs(a.z - b.z) < 1e-9;
assert.ok(close(northUp.up, { x: 0, y: 0, z: 1 }));
assert.ok(close(east.up, { x: 0, y: 1, z: 0 }), 'east at the top');
assert.ok(close(east.north, northUp.up), 'north kept for the next frame');
assert.ok(
close(nadirOrientation(position, { headingDeg: 0 }).up, northUp.up),
);
});
test('the total off-nadir angle combines both tilts', () => {
assert.equal(offNadirAngleDeg(), 0);
assert.ok(Math.abs(offNadirAngleDeg({ northDeg: 10 }) - 10) < 1e-9);
// FWD 15° and RIGHT 10° read OFF-NADIR 18.0°.
const total = offNadirAngleDeg({ northDeg: 15, eastDeg: 10 });
assert.equal(total.toFixed(1), '18.0');
assert.equal(offNadirAngleDeg({ northDeg: -15, eastDeg: -10 }), total);
});
test("a tilted view's width grows faster across the frame than along it", () => {
assert.equal(offNadirSwathScale(), 1, 'nadir');
const cos = (deg) => Math.cos((deg * Math.PI) / 180);
assert.ok(
Math.abs(offNadirSwathScale({ northDeg: 30 }) - 1 / cos(30)) < 1e-9,
);
assert.ok(
Math.abs(offNadirSwathScale({ eastDeg: 30 }) - 1 / cos(30) ** 2) < 1e-9,
);
let last = 1;
for (const deg of [5, 15, 25, 35, 45]) {
const scale = offNadirSwathScale({ northDeg: deg, eastDeg: deg });
assert.ok(scale > last, `${deg}°`);
last = scale;
}
assert.equal(offNadirSwathScale({ northDeg: Number.NaN }), 1);
});
test('a view tilted past the limb no longer meets the Earth', () => {
// GEO: the Earth spans about 8.7 degrees either side of nadir.
assert.equal(offNadirCentreMeetsEarth(35_786_000, { northDeg: 5 }), true);
assert.equal(offNadirCentreMeetsEarth(35_786_000, { northDeg: 15 }), false);
// LEO: a 30 degree tilt still lands on the ground.
assert.equal(
offNadirCentreMeetsEarth(420_000, { northDeg: 20, eastDeg: 20 }),
true,
);
assert.equal(offNadirCentreMeetsEarth(420_000, { eastDeg: 80 }), false);
assert.equal(offNadirCentreMeetsEarth(NaN, {}), false);
});
+24
View File
@@ -457,6 +457,30 @@ export function bridgeEntryAllowed({ vesselsEnabled, contextModeChanging }) {
return Boolean(vesselsEnabled) && !contextModeChanging;
}
/**
* Whether the tracked satellite's orbital view can be entered. Like the
* bridge it needs only its own layer, Satellites, with a satellite tracked.
* A Context mode change in flight still owns the camera, so entry waits it
* out. A subject the satellites layer marks as not enterable (debris:
* rocket bodies, fragments, dead satellites; `satelliteClassEnterable` in
* src/layers/satellites/classes.js) can be tracked but is never ridden: it is
* no mission and has no view to offer.
* @param {{satellitesEnabled: boolean, contextModeChanging: boolean,
* subjectEnterable?: boolean}} state
* @returns {boolean}
*/
export function orbitEntryAllowed({
satellitesEnabled,
contextModeChanging,
subjectEnterable = true,
}) {
return (
Boolean(satellitesEnabled) &&
!contextModeChanging &&
subjectEnterable !== false
);
}
/**
* Internal context-mode id → the word the voice tools accept for it.
*
+14
View File
@@ -2,6 +2,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
bridgeEntryAllowed,
orbitEntryAllowed,
cockpitEntryAllowed,
contextAllowedLayerIds,
contextLayerEnableBlockReason,
@@ -440,6 +441,19 @@ test('bridge entry needs only Live AIS Vessels', () => {
assert.equal(bridgeEntryAllowed({ vesselsEnabled: true, contextModeChanging: true }), false);
});
test('orbital entry needs only Satellites', () => {
assert.equal(orbitEntryAllowed({ satellitesEnabled: true, contextModeChanging: false }), true);
assert.equal(orbitEntryAllowed({ satellitesEnabled: false, contextModeChanging: false }), false);
assert.equal(orbitEntryAllowed({ satellitesEnabled: true, contextModeChanging: true }), false);
});
test('debris can be tracked but never ridden in orbit', () => {
const on = { satellitesEnabled: true, contextModeChanging: false };
assert.equal(orbitEntryAllowed({ ...on, subjectEnterable: false }), false);
assert.equal(orbitEntryAllowed({ ...on, subjectEnterable: true }), true);
assert.equal(orbitEntryAllowed(on), true, 'no subject read yet');
});
test('context teardown guard restores coordination state after success and failure', async () => {
const idleOwner = { _contextModeChanging: false };
await runWithContextModeChanging(idleOwner, async () => {
+1 -1
View File
@@ -150,7 +150,7 @@ export const DATA_CREDITS = [
{
key: 'ucs-satellite-database',
html:
'Satellite purposes: ' +
'Satellite operators and purposes: ' +
'<a href="https://www.ucsusa.org/satellite_database" target="_blank" rel="noopener">UCS Satellite Database</a> ' +
'(UCS-Satellite-Database 5-1-2023), Union of Concerned Scientists',
},
@@ -10,6 +10,7 @@ range geometry with no network dependency.
| `marine.json` | `ne_10m_geography_marine_polys` (seas, gulfs, straits, bays, …) | 292 named |
| `countries.json` | `ne_10m_admin_0_countries` (countries) | 256 |
| `states_provinces.json` | `ne_10m_admin_1_states_provinces` (states, provinces and other first-level units) | 4,587 named |
| `populated_places.json` | `ne_10m_populated_places_simple` (cities and towns, as points) | 7,342 |
**Source:** Natural Earth 10m physical vectors, via the canonical
[nvkelso/natural-earth-vector](https://github.com/nvkelso/natural-earth-vector)
@@ -103,3 +104,23 @@ Includes England, Scotland, Wales and Northern Ireland from
`ne_10m_admin_0_map_units.geojson` at the same commit (SHA-256
`57da82be755f4afccd8f3b14251bb2752f5df1395f47d2d86f817470c4a48862`).
Result: 945,843 bytes; 260 features, 2,088 parts, 122,788 vertices.
## Populated places (`populated_places.json`)
The orbital view's "near <city>" (`src/data/placeBelow.js`): from 8x it
names the most prominent place within half the view's width.
**Source:** `geojson/ne_10m_populated_places_simple.geojson` at the pinned
commit `ca96624a56bd078437bca8184e78163e5039ad19` (SHA-256
`fd3fa867a320cbd5c5b6bb5bc550afeec2939fb2cef688e508007282a55ac42f`, the file
the admin-1 build already reads), fetched 2026-11-04. Public domain, as above.
**Regenerate:** `node scripts/build-admin-packs.mjs --only places`.
**Schema:** `meta` (source, URL, hash, license, curation) and `places[]`, each
`[name, latitude, longitude, scalerank, rank_max]`: `scalerank` 0 (most
prominent) to 10, `rank_max` the population class (0 to 14, higher is
larger). Sorted by `scalerank`, then `rank_max` descending, then name.
**Curation:** every place kept; all other attributes dropped; coordinates
rounded to 3 decimals (~110 m). Result: 229,461 bytes (98,444 with gzip -9).
@@ -0,0 +1 @@
{"meta":{"title":"Natural Earth populated places","source":"ne_10m_populated_places_simple","url":"https://raw.githubusercontent.com/nvkelso/natural-earth-vector/ca96624a56bd078437bca8184e78163e5039ad19/geojson/ne_10m_populated_places_simple.geojson","sha256":"fd3fa867a320cbd5c5b6bb5bc550afeec2939fb2cef688e508007282a55ac42f","commit":"ca96624a56bd078437bca8184e78163e5039ad19","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":3,"curation":{"decimals":3},"script":"scripts/build-admin-packs.mjs"},"places":[["Beijing",39.931,116.386,0,14],["Cairo",30.052,31.248,0,14],["Istanbul",41.107,29.008,0,14],["Kolkata",22.497,88.323,0,14],["Los Angeles",33.992,-118.182,0,14],["Mexico City",19.444,-99.133,0,14],["Moscow",55.754,37.614,0,14],["Mumbai",19.019,72.855,0,14],["New York",40.752,-73.982,0,14],["Rio de Janeiro",-22.923,-43.227,0,14],["São Paulo",-23.557,-46.627,0,14],["Shanghai",31.218,121.435,0,14],["Tokyo",35.687,139.749,0,14],["Bogota",4.598,-74.085,0,13],["Hong Kong",22.307,114.183,0,13],["Jakarta",-6.172,106.827,0,13],["Lagos",6.445,3.39,0,13],["London",51.502,-0.119,0,13],["Paris",48.869,2.331,0,13],["Santiago",-33.448,-70.669,0,13],["Singapore",1.295,103.854,0,13],["Cape Town",-33.918,18.433,0,12],["Nairobi",-1.281,36.815,0,12],["Riyadh",24.643,46.771,0,12],["Rome",41.898,12.481,0,12],["Sydney",-33.918,151.183,0,12],["Washington, D.C.",38.901,-77.011,0,12],["Buenos Aires",-34.601,-58.399,1,14],["Manila",14.606,120.98,1,14],["Ōsaka",34.752,135.458,1,14],["Baghdad",33.341,44.392,1,13],["Bangkok",13.752,100.515,1,13],["Bengaluru",12.972,77.558,1,13],["Chicago",41.832,-87.752,1,13],["Kinshasa",-4.328,15.313,1,13],["Lima",-12.046,-77.052,1,13],["Madrid",40.402,-3.685,1,13],["Miami",25.79,-80.226,1,13],["Seoul",37.568,126.998,1,13],["Taipei",25.036,121.568,1,13],["Tehran",35.674,51.422,1,13],["Toronto",43.702,-79.422,1,13],["Addis Ababa",9.035,38.698,1,12],["Amsterdam",52.352,4.915,1,12],["Athens",37.985,23.731,1,12],["Atlanta",33.832,-84.402,1,12],["Auckland",-36.848,174.763,1,12],["Berlin",52.524,13.4,1,12],["Caracas",10.503,-66.919,1,12],["Casablanca",33.602,-7.618,1,12],["Chengdu",30.672,104.068,1,12],["Dakar",14.718,-17.475,1,12],["Denver",39.741,-104.986,1,12],["Dubai",25.232,55.278,1,12],["Geneva",46.21,6.14,1,12],["Houston",29.822,-95.342,1,12],["Johannesburg",-26.168,28.028,1,12],["Kabul",34.519,69.181,1,12],["Kyiv",50.435,30.515,1,12],["Melbourne",-37.818,144.973,1,12],["Monterrey",25.672,-100.332,1,12],["San Francisco",37.769,-122.417,1,12],["Stockholm",59.353,18.095,1,12],["Tashkent",41.314,69.293,1,12],["Ürümqi",43.807,87.573,1,12],["Vancouver",49.275,-123.124,1,12],["Vienna",48.202,16.365,1,12],["New Delhi",28.6,77.2,1,10],["Delhi",28.672,77.228,2,14],["Dhaka",23.725,90.407,2,14],["Karachi",24.872,66.988,2,14],["Ahmedabad",23.032,72.578,2,13],["Chennai",13.092,80.278,2,13],["Chongqing",29.567,106.593,2,13],["Guangzhou",23.147,113.323,2,13],["Ho Chi Minh City",10.782,106.693,2,13],["Hyderabad",17.402,78.478,2,13],["Lahore",31.562,74.348,2,13],["Luanda",-8.836,13.232,2,13],["Philadelphia",40.002,-75.172,2,13],["Shenzhen",22.554,114.12,2,13],["Tianjin",39.132,117.198,2,13],["Wuhan",30.582,114.268,2,13],["Abidjan",5.322,-4.042,2,12],["Accra",5.552,-0.219,2,12],["Aleppo",36.232,37.168,2,12],["Alexandria",31.202,29.948,2,12],["Algiers",36.765,3.049,2,12],["Ankara",39.929,32.862,2,12],["Bandung",-6.948,107.568,2,12],["Baotou",40.654,109.82,2,12],["Barcelona",41.385,2.181,2,12],["Belém",-1.448,-48.482,2,12],["Boston",42.332,-71.072,2,12],["Brasília",-15.781,-47.918,2,12],["Brussels",50.835,4.331,2,12],["Bucharest",44.435,26.098,2,12],["Budapest",47.502,19.081,2,12],["Cali",3.402,-76.502,2,12],["Changchun",43.867,125.338,2,12],["Changsha",28.202,112.968,2,12],["Chattogram",22.332,91.798,2,12],["Curitiba",-25.418,-49.322,2,12],["Dallas",32.822,-96.842,2,12],["Damascus",33.502,36.298,2,12],["Dar es Salaam",-6.798,39.266,2,12],["Detroit",42.332,-83.082,2,12],["Dongguan",23.051,113.743,2,12],["Dublin",53.335,-6.251,2,12],["Durban",-29.863,30.978,2,12],["Edmonton",53.552,-113.502,2,12],["Fortaleza",-3.748,-38.582,2,12],["Frankfurt",50.1,8.675,2,12],["Fuzhou",26.082,119.298,2,12],["Goiânia",-16.718,-49.302,2,12],["Guadalajara",20.672,-103.332,2,12],["Guayaquil",-2.218,-79.922,2,12],["Guiyang",26.582,106.718,2,12],["Hamburg",53.552,9.998,2,12],["Hangzhou",30.252,120.168,2,12],["Hanoi",21.035,105.848,2,12],["Harbin",45.752,126.648,2,12],["Havana",23.134,-82.366,2,12],["Helsinki",60.178,24.932,2,12],["Hiroshima",34.39,132.441,2,12],["Jaipur",26.923,75.808,2,12],["Jeddah",21.519,39.217,2,12],["Jinan",36.677,116.993,2,12],["Kano",12.002,8.518,2,12],["Kanpur",26.462,80.318,2,12],["Khartoum",15.59,32.532,2,12],["København",55.681,12.562,2,12],["Kuala Lumpur",3.169,101.698,2,12],["Kunming",25.072,102.678,2,12],["Kuwait City",29.372,4Line truncated
+15 -10
View File
@@ -1,8 +1,9 @@
# UCS Satellite Database pack
Offline purpose records for active satellites, keyed by NORAD catalog number.
The satellites layer joins them to CelesTrak's positions to file each
satellite under a class (stations, imaging, telecom, probes)
Offline operator, owner-country and purpose records for active satellites,
keyed by NORAD catalog number. The satellites layer joins them to CelesTrak's
positions to file each satellite under a class (stations, imaging, telecom,
probes) and to name its operator and owner country
(`src/data/ucsSatellites.js`, `src/layers/satellites/classes.js`).
| File | Source dataset | Records |
@@ -23,7 +24,7 @@ the version of the Database that was used, which is indicated by the name of
the Excel file, and a link to or URL for the webpage
www.ucsusa.org/satellite_database."
**Acknowledgement:** "Satellite purposes: UCS Satellite Database
**Acknowledgement:** "Satellite operators and purposes: UCS Satellite Database
(UCS-Satellite-Database 5-1-2023), https://www.ucsusa.org/satellite_database".
It is also listed in the app's Data attribution popover and in
DATA_SOURCES.md.
@@ -33,13 +34,17 @@ DATA_SOURCES.md.
**Curation:**
- Kept column: Purpose, the one the classes read. Everything else
(operators, owners, orbits, masses, contractors, launch data, sources) is
left out.
- Kept columns: Operator/Owner, Country of Operator/Owner and Purpose, the
ones the layer reads. Everything else (detailed purpose, users, orbits,
masses, contractors, launch data, sources) is left out.
- Repeated text is stored once in `strings`; each record is
`[noradId, purpose]` with a string-table index, `0` meaning empty.
- The 3,996 Starlink rows are dropped: every Starlink object is classed by
name, so they would only add size.
`[noradId, operator, operatorCountry, purpose]` with string-table indexes,
`0` meaning empty.
- The 3,996 Starlink rows are dropped: every Starlink object is classed and
credited to SpaceX by name, so they would only add size.
- Rows without a readable, positive NORAD number are dropped and counted apart
from duplicate NORAD numbers (the first row of a duplicate is kept); both
counts are 0 in this release.
- Owner countries stay as UCS writes them ("USA", "USA/Japan", "ESA",
"Multinational"); `src/layers/satellites/classes.js` normalises them, together with
CelesTrak SATCAT owner codes, in one table.
@@ -1 +1 @@
{"meta":{"source":"Union of Concerned Scientists Satellite Database","version":"UCS-Satellite-Database 5-1-2023","url":"https://www.ucsusa.org/satellite_database","dataCurrentTo":"2023-05-01","license":"Free, unrestricted use; acknowledgement requested (UCS database manual, section 3)","curation":{"sourceRows":7560,"starlinkRowsDropped":3996,"invalidNoradDropped":0,"duplicateNoradDropped":0,"columns":{"purpose":"Purpose"}},"recordCount":3564},"fields":["noradId","purpose"],"strings":{"purpose":["","Earth Observation","Technology Development","Communications","Earth Science","Space Science","Space Science/Technology Demonstration","Communications/Technology Development","Communications/Maritime Tracking","Technology Demonstration","Unknown","Navigation/Global Positioning","Earth Observation/Technology Development","Earth Observation/Communications","Earth/Space Observation","Educational","Earth Observation/Earth Science","Platform","Earth Observation/Space Science","Earth Observation/Navigation","Communications/Navigation","Space Observation","Surveillance","Navigation/Regional Positioning","Space Science/Technology Development","Mission Extension Technology","Earth Science/Earth Observation","Earth Observation/Communications/Space Science","Meteorological","Technology Development/Educational","Satellite Positioning"]},"records":[[7530,3],[19548,3],[20253,3],[20580,5],[20776,3],[21639,3],[22049,5],[22314,3],[22491,1],[22787,3],[22988,3],[23223,1],[23467,3],[23553,3],[23613,3],[23628,3],[23855,1],[23893,3],[24674,3],[24753,1],[24812,3],[24876,11],[24916,3],[24920,1],[24936,3],[25019,3],[25034,1],[25071,3],[25112,3],[25113,3],[25114,3],[25115,3],[25116,3],[25117,3],[25118,3],[25119,3],[25153,3],[25159,3],[25258,3],[25336,1],[25338,1],[25414,3],[25415,3],[25416,3],[25418,3],[25419,3],[25420,3],[25462,3],[25475,3],[25477,3],[25481,3],[25504,1],[25544,5],[25639,3],[25652,3],[25682,1],[25757,4],[25867,5],[25924,3],[25933,11],[25967,3],[25980,3],[25981,3],[25982,3],[25984,3],[25985,3],[25989,5],[25991,1],[25994,1],[26038,3],[26052,3],[26107,3],[26356,1],[26360,11],[26369,3],[26388,3],[26390,3],[26407,11],[26410,5],[26411,5],[26451,3],[26463,5],[26464,5],[26469,3],[26473,1],[26483,3],[26554,3],[26575,3],[26580,3],[26590,3],[26605,11],[26608,3],[26624,3],[26626,3],[26638,3],[26690,11],[26694,3],[26702,1],[26715,3],[26719,3],[26766,3],[26824,3],[26853,3],[26880,1],[26900,3],[26958,9],[26959,12],[26998,5],[27004,2],[27056,3],[27057,3],[27168,3],[27378,3],[27380,3],[27389,3],[27399,3],[27400,3],[27414,3],[27424,1],[27426,3],[27434,1],[27438,3],[27445,3],[27460,3],[27464,3],[27465,3],[27509,1],[27513,3],[27528,3],[27540,5],[27566,3],[27603,3],[27606,3],[27607,3],[27612,3],[27640,1],[27651,5],[27663,11],[27691,3],[27704,11],[27711,3],[27715,3],[27811,3],[27825,3],[27831,3],[27843,5],[27848,2],[27854,3],[27858,5],[27868,3],[27869,3],[27875,3],[27937,1],[27939,2],[27954,3],[28051,1],[28054,1],[28058,3],[28089,3],[28117,3],[28158,1],[28184,3],[28187,3],[28190,11],[28220,1],[28252,3],[28358,3],[28366,7],[28369,2],[28370,2],[28371,1],[28372,7],[28376,1],[28378,3],[28384,3],[28413,5],[28414,5],[28419,3],[28420,3],[28446,3],[28470,1],[28474,11],[28485,5],[28492,1],[28493,3],[28526,3],[28537,1],[28541,1],[28542,3],[28626,3],[28628,3],[28646,1],[28649,1],[28654,1],[28659,3],[28702,3],[28707,3],[28737,5],[28786,3],[28790,3],[28810,2],[28868,3],[28874,11],[28884,3],[28885,3],[28888,1],[28890,1],[28899,3],[28903,3],[28908,3],[28912,1],[28924,3],[28935,3],[28945,3],[28946,3],[29045,3],[29047,1],[29048,1],[29050,1],[29051,1],[29052,1],[29055,3],[29079,1],[29107,1],[29108,4],[29155,1],[29162,3],[29236,3],[29249,1],[29252,2],[29268,12],[29270,3],[29272,3],[29273,3],[29349,3],[29479,5],[29486,11],[29494,3],[29495,3],[29505,5],[29506,5],[29520,3],[29522,1],[29526,3],[29601,11],[29643,3],[29644,3],[29658,1],[29670,11],[29671,11],[29709,12],[29710,1],[30580,5],[30776,2],[30794,3],[30797,5],[30798,5],[31102,3],[31118,1],[31119,3],[31121,3],[31124,3],[31125,3],[31127,3],[31304,1],[31306,3],[31307,3],[31571,3],[31573,3],[31574,3],[31576,3],[31598,1],[31601,1],[31698,1],[31701,1],[31708,1],[31789,2],[31792,1],[31797,1],[31800,3],[31862,3],[32018,3],[32019,3],[32060,1],[32252,3],[32253,3],[32258,3],[32260,11],[32263,3],[32264,3],[32265,3],[32275,11],[32276,11],[32283,1],[32293,3],[32294,3],[32299,3],[32376,1],[32378,3],[32382,1],[32384,11],[32388,3],[32393,11],[32395,11],[32404,3],[32476,1],[32478,3],[32487,3],[32706,1],[32711,11],[32729,3],[32750,1],[32763,3],[32767,3],[32768,3],[32779,3],[32783,1],[32789,2],[32790,2],[32791,2],[32794,3],[32951,3],[32954,3],[32955,3],[32956,3],[33051,3],[33053,5],[33055,3],[33056,3],[33153,3],[33154,3],[33207,3],[33244,1],[33274,3],[33275,3],[33278,3],[33320,1],[33321,1],[33331,1],[33373,3],[33376,3],[33396,1],[33401,5],[33408,5],[33409,5],[33412,1],[33433,1],[33434,1],[33436,3],[33446,1],[33453,3],[33459,3],[33460,3],[33490,1],[33492,1],[33496,2],[33499,2],[33591,1],[33595,3],[33749,3],[33750,3],[33751,2],[337Line truncated
{"meta":{"source":"Union of Concerned Scientists Satellite Database","version":"UCS-Satellite-Database 5-1-2023","url":"https://www.ucsusa.org/satellite_database","dataCurrentTo":"2023-05-01","license":"Free, unrestricted use; acknowledgement requested (UCS database manual, section 3)","curation":{"sourceRows":7560,"starlinkRowsDropped":3996,"invalidNoradDropped":0,"duplicateNoradDropped":0,"columns":{"operator":"Operator/Owner","operatorCountry":"Country of Operator/Owner","purpose":"Purpose"}},"recordCount":3564},"fields":["noradId","operator","operatorCountry","purpose"],"strings":{"operator":["","Hera Systems","AAC Clyde Space","Aalto University","University of Aalborg","Asia Broadcast Satellite Ltd.","Asher Space Research Institute at Technion/Israeli Aircraft Industries","Austrian Space Forum (OEWF)/Spire","National Reconnaissance Office (NRO)","US Air Force","Department of Homeland Security","European Space Agency (ESA)","Aerospace Corporation","EUTELSAT S.A.","Aurora Insight","Center for Atmospheric Sciences, Hampton University/NASA","Norwegian Coastal Admnistration","AISTech","Al Yah Satellite Communications Co. (YAHSAT)","Al-Farabi Kazakh National University","Algerian Space Agency (ASAL)","Astro Live Experiences","University of Brasilia","US Air Force Institute of Technology","Algerian Space Agency (ASAL)/UK Space Agency","Hispamar (subsidiary of Hispasat - Spain)","National Institute for Space Research (INPE)","SES S.A.","Moscow State University","Space-Communication Ltd.","AMSAT-NA","Agency for Defense Development","ANDESITE - Boston University","Centre National d'Etudes Spatiales (CNES)","AngoSat","Telesat Canada Ltd. (BCE, Inc.)","Aprize Satellite, Argentina","APT Satellite Holdings Ltd.","Arab Satellite Communications Org. (ASCO)","University of South Florida, Institute of Applied Engineering (IAE).","Norwegian Defence Research Establishment","Aoyama Gakuin University in Tokyo","Naval Physical and Oceanographic Laboratory","Planetary Resources","ArSat","Aerospacelab","Istanbul Technical University","Asia Satellite Telecommunications Co. Ltd.","1Worldspace","Japan Space Systems","Chiba Institute of Technology","Astranis","Astrocast","Telemetry Tracking and Command Network (ISTRAC) Centre","DirecTV, Inc.","PointView Tech","Centre National d'Etudes Spatiales (CNES)/Agenzia Spaziale Italiana (ASI)","Microspace Rapid Pvt Ltd.","Azercosmos","Bangladesh Telecommunication Regulatory Commission (BTRC)","Tianyi Research Institute","Ministry of Defense","Space Development Agency","Central European Institute of Technology","Technical University Berlin","Chinese Ministry of National Defense","21st Century Aerospace Technology (21AT)","Beijing Landview Mapping Information Technology Co. Ltd (BLMIT)","Changsha Tianyi space science and Technology Research Institute Co., Ltd. (Spacety)","Belintersat","Institute of Space Sensor Technology and Planetary Exploration","Norwegian Defence Research Establishment (FFI)/","German Aerospace Center (DLR)","Salish Kootenai College","National Academy of Sciences","BlackSky Global","NanoAvionics","AST SpaceMobile","Embratel Star One","SpaceQuest, Ltd.","Bank Rakyat Indonesia (BRI)","University of Toronto, Institute for Aerospace Studies","BRITE Consortium","UnseenLabs","AstroForge","Broadcasting Satellite System Corp.","Broadcasting Satellite System Corp./Sky Perfect JSAT Corporation","Defence Science and Technology Group/University of New South Wales","China Academy of Space Technology (CAST)","Satellogic S.A.","Bulsatcom","Capital Technology University","Centre National d'Etudes Spatiales (CNES)/NASA","University of Louisiana at Lafayette","Capella Space","Indian Space Research Organization (ISRO)","Korea Aerospace Research Institute (KARI)","Canadian Space Agency","China National Space Administration (CNSA)/National Institute for Space Research (Brazil)","Surrey Satellite Technology Ltd.","Earth-i/Surrey Satellite Technology Ltd.","Canon Electronics","Air Force Research Laboratory","Fleet Space Technologies","Beijing Future Navigation Technology Co. Ltd.","NASA Goddard Space Flight Center","Ministry of Armed Forces","Defense Innovation Unit/Cesium Astro","China National Space Administration (CNSA)/National Centre for Space Studies (CNES)","TELNET Holding","National Aeronautics and Space Administration (NASA) Goddard Space Flight Center","Spacety Aerospace Company","Shanghai Engineering Center for Microsatellites","Spacety Co. Ltd.","Nanjing University","Epic Aerospace","University of Florida","Chinese Academy of Sciences","GeoOptics Inc.","GeoOptics, Inc.","Ciel Satellite Group","University of California-Berkeley/Imperial College-London","Laboratory for Atmospheric and Space Physics, University of Colorado, Boulder","Utah State University","Unknown US agency","National Aeronautics and Space Administration (NASA)/Colorado State University","Department of Defense/Missile Defense Agency","FH Aachen","Armed Forces","Plan-S","US Air Force/ US Navy/NASA","Astro Digital","TaLine truncated
+369
View File
@@ -0,0 +1,369 @@
/**
* What a ground point is over, offline: on land the country (Natural Earth
* admin-0) and its first-level division (admin-1: "Sichuan, China"), plus,
* when asked for a radius, the most prominent populated place within it
* ("near Chengdu"); else the smallest named sea, gulf, strait, channel,
* sound or bay, else the ocean (`marine.json` carries the seven oceans).
* Bays narrower than a satellite frame cares about — fjords, lagoons,
* inlets, reefs and rivers — are skipped, so a pass reads "Bay of Bengal",
* not a harbour.
*
* PURE data module: no Cesium, node-testable. Each pack loads lazily on the
* first lookup that needs it (divisions on the first land hit, places on the
* first radius) and is indexed once; a country's division rings are decoded
* the first time a point falls inside it. A failed load is retried later
* (see `createRetryableLoader`). No network.
*/
import { loadBundledJson } from './bundledJson.js';
import { createRetryableLoader } from './retryableLoad.js';
import { containsAnchor, decodeRing } from './adminBoundaries.js';
import { greatCircleKm } from '../geoDistance.js';
/** Marine feature classes named as the place below; oceans come last. */
const MARINE_KINDS = new Set([
'sea',
'gulf',
'strait',
'channel',
'sound',
'bay',
]);
const PACKS = {
countries: new URL(
'./local_data/natural_earth/countries.json',
import.meta.url,
),
marine: new URL('./local_data/natural_earth/marine.json', import.meta.url),
divisions: new URL(
'./local_data/natural_earth/states_provinces.json',
import.meta.url,
),
places: new URL(
'./local_data/natural_earth/populated_places.json',
import.meta.url,
),
};
/** Test seam: count pack loads (lazy-loading contract). */
export const placePackLoads = { divisions: 0, places: 0 };
/** The zoom from which the view names the populated place it shows. */
export const NEAR_PLACE_MIN_ZOOM = 8;
/**
* How far from the sub-satellite point a populated place may lie and still
* be named: half the view's smaller ground dimension from
* NEAR_PLACE_MIN_ZOOM on, so the place is in the picture whichever way it
* lies; null below it or without a size.
* @param {number} zoom Optical zoom.
* @param {number} widthKm The view's ground width, km.
* @param {number} [heightKm] The view's ground height, km; the width when
* not given.
* @returns {number|null}
*/
export function nearPlaceRadiusKm(zoom, widthKm, heightKm = widthKm) {
if (!(zoom >= NEAR_PLACE_MIN_ZOOM)) return null;
const smallerKm = Math.min(widthKm, heightKm);
return Number.isFinite(smallerKm) && smallerKm > 0 ? smallerKm / 2 : null;
}
/** Shortest wait between two lookups, ms. */
export const PLACE_LOOKUP_MIN_MS = 2000;
/** Ground travel that makes a fresh lookup worth it, km. */
export const PLACE_LOOKUP_MIN_MOVE_KM = 20;
function boxOf(parts) {
let west = Infinity;
let south = Infinity;
let east = -Infinity;
let north = -Infinity;
for (const part of parts)
for (const ring of part)
for (const [lon, lat] of ring) {
if (lon < west) west = lon;
if (lon > east) east = lon;
if (lat < south) south = lat;
if (lat > north) north = lat;
}
return [west, south, east, north];
}
function inBox([west, south, east, north], lat, lon) {
return lat >= south && lat <= north && lon >= west && lon <= east;
}
/** Planar ring area in degrees², shrunk by cos(latitude): enough to rank. */
function ringRank(ring) {
let sum = 0;
let latSum = 0;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
sum += (ring[j][0] - ring[i][0]) * (ring[j][1] + ring[i][1]);
latSum += ring[i][1];
}
const meanLat = ring.length ? latSum / ring.length : 0;
return (Math.abs(sum) / 2) * Math.cos((meanLat * Math.PI) / 180);
}
const loadPlaces = createRetryableLoader(async () => {
const [countries, marine] = await Promise.all([
loadBundledJson(PACKS.countries),
loadBundledJson(PACKS.marine),
]);
const decimals = countries.meta?.decimals ?? 3;
const land = [];
for (const feature of countries.features || []) {
const parts = (feature.polygons || []).map((part) =>
part.map((ring) => decodeRing(ring, decimals)),
);
if (!parts.length) continue;
land.push({
name: feature.name,
// Natural Earth writes -99 for units without an ISO code.
countryCode: /^[A-Z]{2}$/.test(feature.iso2 || '') ? feature.iso2 : null,
parts,
box: boxOf(parts),
});
}
const water = [];
for (const feature of marine.features || []) {
const kind = feature.featurecla;
if (kind !== 'ocean' && !MARINE_KINDS.has(kind)) continue;
// Marine rings are stored outer-only and plain [lon, lat].
const parts = (feature.polygons || []).map((ring) => [ring]);
if (!parts.length) continue;
water.push({
name: feature.name,
kind: kind === 'ocean' ? 'ocean' : 'sea',
parts,
box: boxOf(parts),
rank: parts.reduce((sum, [ring]) => sum + ringRank(ring), 0),
});
}
return { land, water };
});
/** Divisions grouped by country code; rings decoded per country on demand. */
const loadDivisions = createRetryableLoader(async () => {
placePackLoads.divisions += 1;
const pack = await loadBundledJson(PACKS.divisions);
const decimals = pack.meta?.decimals ?? 3;
const byCountry = new Map();
for (const feature of pack.features || []) {
if (!feature.name || !/^[A-Z]{2}$/.test(feature.iso2 || '')) continue;
const list = byCountry.get(feature.iso2) || [];
list.push({ feature, decimals: feature.d ?? decimals });
byCountry.set(feature.iso2, list);
}
return { byCountry, decoded: new Map() };
});
/** The first-level division holding a point inside country `code`, or null. */
async function divisionAt(code, lat, lon) {
if (!code) return null;
const { byCountry, decoded } = await loadDivisions();
let units = decoded.get(code);
if (!units) {
units = (byCountry.get(code) || []).map(({ feature, decimals }) => {
const parts = (feature.polygons || []).map((part) =>
part.map((ring) => decodeRing(ring, decimals)),
);
return { name: feature.name, parts, box: boxOf(parts) };
});
decoded.set(code, units);
}
for (const unit of units)
if (inBox(unit.box, lat, lon) && containsAnchor(unit.parts, lat, lon))
return unit.name;
return null;
}
const loadPopulatedPlaces = createRetryableLoader(async () => {
placePackLoads.places += 1;
const pack = await loadBundledJson(PACKS.places);
return (pack.places || []).filter(
(row) =>
Array.isArray(row) &&
row[0] &&
Number.isFinite(row[1]) &&
Number.isFinite(row[2]),
);
});
/**
* The most prominent populated place within `radiusKm` of a point (lowest
* Natural Earth scalerank, then the larger population class, then the
* nearer), or null.
* @returns {Promise<string|null>}
*/
export async function nearPlace(lat, lon, radiusKm) {
if (!(radiusKm > 0) || !Number.isFinite(lat) || !Number.isFinite(lon))
return null;
const places = await loadPopulatedPlaces();
const latSpan = radiusKm / 111;
const cosLat = Math.cos((lat * Math.PI) / 180);
const lonSpan = cosLat > 0.01 ? latSpan / cosLat : 360;
let best = null;
for (const [name, pLat, pLon, scalerank, rankMax] of places) {
if (Math.abs(pLat - lat) > latSpan) continue;
const dLon = Math.abs(((((pLon - lon + 180) % 360) + 360) % 360) - 180);
if (dLon > lonSpan) continue;
const km = greatCircleKm({ lat, lon }, { lat: pLat, lon: pLon });
if (km > radiusKm) continue;
if (
!best ||
scalerank < best.scalerank ||
(scalerank === best.scalerank &&
(rankMax > best.rankMax || (rankMax === best.rankMax && km < best.km)))
)
best = { name, scalerank, rankMax, km };
}
return best ? best.name : null;
}
/** The division and near-place lookups `placeBelow` adds on land. */
const DETAIL_LOOKUPS = Object.freeze({ division: divisionAt, near: nearPlace });
/** A detail lookup's answer, or null when it (its pack) fails. */
async function orNull(lookup) {
try {
return (await lookup()) ?? null;
} catch {
return null;
}
}
/**
* The place under a ground point.
* @param {number} latitude Degrees.
* @param {number} longitude Degrees, any wrap.
* @param {{nearKm?: number|null, detail?: {division: Function,
* near: Function}}} [options] On land, also name the most prominent
* populated place within `nearKm` (`nearPlaceRadiusKm`). `detail` replaces
* the division and near-place lookups (a test seam).
* @returns {Promise<{kind: 'country'|'sea'|'ocean', name: string,
* countryCode: string|null, division?: string|null,
* near?: string|null}|null>} A country carries its first-level `division`
* and the `near` place (each null when none, or when its pack failed to
* load: the country still stands); a sea or ocean neither. Null when no
* feature holds the point (a coastline gap left by the packs'
* simplification) or it is invalid. Rejects only when the country and
* marine packs fail.
*/
export async function placeBelow(
latitude,
longitude,
{ nearKm = null, detail = DETAIL_LOOKUPS } = {},
) {
if (
!Number.isFinite(latitude) ||
!Number.isFinite(longitude) ||
Math.abs(latitude) > 90
)
return null;
const lon = ((((longitude + 180) % 360) + 360) % 360) - 180;
const { land, water } = await loadPlaces();
for (const entry of land) {
if (
inBox(entry.box, latitude, lon) &&
containsAnchor(entry.parts, latitude, lon)
)
return {
kind: 'country',
name: entry.name,
countryCode: entry.countryCode,
division: await orNull(() =>
detail.division(entry.countryCode, latitude, lon),
),
near: await orNull(() => detail.near(latitude, lon, nearKm)),
};
}
let best = null;
for (const entry of water) {
if (best && entry.rank >= best.rank) continue;
if (
inBox(entry.box, latitude, lon) &&
containsAnchor(entry.parts, latitude, lon)
)
best = entry;
}
return best ? { kind: best.kind, name: best.name, countryCode: null } : null;
}
/**
* Whether two radii ask for the same detail: both off, or within 20% (a
* zoom step at least halves the view; altitude drift moves it far less).
*/
function sameRadius(a, b) {
if (a === null || b === null) return a === b;
return Math.max(a, b) / Math.min(a, b) < 1.2;
}
/**
* Keep the place below a moving ground point current without a lookup per
* frame: a new lookup once at least `minIntervalMs` has passed AND the point
* has moved `minMoveKm` (a low orbit refreshes about every 2.6 s, a
* geostationary one never after the first). A new `nearKm` radius (a zoom
* step) looks up again at once. One lookup runs at a time; a result that
* arrives after `reset()` is dropped. A failed lookup reports no place (the
* old one would name ground the view has left) and lets the next update
* look up again at once.
* @param {{onPlace: (place: object|null) => void,
* lookup?: (lat: number, lon: number,
* options: {nearKm: number|null}) => Promise<object|null>,
* minIntervalMs?: number, minMoveKm?: number}} options
*/
export function createPlaceTracker({
onPlace,
lookup = placeBelow,
minIntervalMs = PLACE_LOOKUP_MIN_MS,
minMoveKm = PLACE_LOOKUP_MIN_MOVE_KM,
}) {
let generation = 0;
let inFlight = false;
let last = null;
return {
/**
* Offer the current ground point and the radius to name a place in.
* @returns {boolean} True when a lookup started.
*/
update(lat, lon, nowMs, nearKm = null) {
if (inFlight || !Number.isFinite(lat) || !Number.isFinite(lon))
return false;
const radius = Number.isFinite(nearKm) && nearKm > 0 ? nearKm : null;
if (
last &&
sameRadius(last.nearKm, radius) &&
(nowMs - last.ms < minIntervalMs ||
greatCircleKm(last, { lat, lon }) < minMoveKm)
)
return false;
last = { lat, lon, ms: nowMs, nearKm: radius };
inFlight = true;
const token = generation;
Promise.resolve()
.then(() => lookup(lat, lon, { nearKm: radius }))
.then(
(place) => {
if (token !== generation) return;
inFlight = false;
onPlace(place ?? null);
},
() => {
if (token !== generation) return;
inFlight = false;
last = null;
onPlace(null);
},
);
return true;
},
/** Forget the point and drop any lookup still in flight. */
reset() {
generation += 1;
inFlight = false;
last = null;
},
};
}
+280
View File
@@ -0,0 +1,280 @@
// src/data/placeBelow.test.mjs — what the ground track is over, offline:
// division and country on land (with the populated place in view from 8x),
// the smallest named sea, else the ocean; and the tracker that keeps it
// current without a lookup per frame.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
NEAR_PLACE_MIN_ZOOM,
PLACE_LOOKUP_MIN_MOVE_KM,
PLACE_LOOKUP_MIN_MS,
createPlaceTracker,
nearPlaceRadiusKm,
placeBelow,
placePackLoads,
} from './placeBelow.js';
const PLACES_PACK = new URL(
'./local_data/natural_earth/populated_places.json',
import.meta.url,
);
// Runs first: the counters are module-wide.
test('the division and place packs load only when a lookup needs them', async () => {
assert.deepEqual(placePackLoads, { divisions: 0, places: 0 });
await placeBelow(42, 51, { nearKm: 50 });
assert.deepEqual(placePackLoads, { divisions: 0, places: 0 }, 'at sea');
await placeBelow(30.9, 104.2);
assert.deepEqual(placePackLoads, { divisions: 1, places: 0 }, 'at 1x');
await placeBelow(30.9, 104.2, { nearKm: 60 });
await placeBelow(31.2, 104.5, { nearKm: 30 });
assert.deepEqual(placePackLoads, { divisions: 1, places: 1 }, 'kept');
});
test('the populated-place pack is compact rows of name, position and rank', () => {
const bytes = readFileSync(PLACES_PACK);
assert.ok(bytes.length < 300_000, `${bytes.length} bytes`);
const pack = JSON.parse(bytes);
assert.match(pack.meta.url, /nvkelso\/natural-earth-vector/);
assert.match(pack.meta.license, /^Public domain/);
assert.ok(pack.places.length > 7000);
for (const [name, lat, lon, scalerank, rankMax] of pack.places) {
assert.equal(typeof name, 'string');
assert.ok(Math.abs(lat) <= 90 && Math.abs(lon) <= 180, name);
assert.ok(Number.isInteger(scalerank) && Number.isInteger(rankMax), name);
}
});
test('over land the place is the division and country, with its ISO code', async () => {
assert.deepEqual(await placeBelow(48.85, 2.35), {
kind: 'country',
name: 'France',
countryCode: 'FR',
division: 'Paris',
near: null,
});
// Across the antimeridian, and with a wrapped longitude.
assert.equal((await placeBelow(66, -175)).countryCode, 'RU');
assert.equal((await placeBelow(-17.8, 178)).countryCode, 'FJ');
assert.equal((await placeBelow(48.85, 362.35)).countryCode, 'FR');
});
test('at 1x a point in Sichuan reads Sichuan, China, with no city', async () => {
assert.deepEqual(await placeBelow(30.9, 104.2), {
kind: 'country',
name: 'China',
countryCode: 'CN',
division: 'Sichuan',
near: null,
});
assert.equal(nearPlaceRadiusKm(1, 1400), null);
assert.equal(nearPlaceRadiusKm(NEAR_PLACE_MIN_ZOOM / 2, 300), null);
});
test('from 8x the most prominent populated place in view is named', async () => {
assert.equal(NEAR_PLACE_MIN_ZOOM, 8);
// An 8x view ~120 km wide: Chengdu lies ~30 km off, inside half of it.
const nearKm = nearPlaceRadiusKm(8, 120);
assert.equal(nearKm, 60);
const place = await placeBelow(30.9, 104.2, { nearKm });
assert.equal(place.division, 'Sichuan');
assert.equal(place.near, 'Chengdu');
// Deyang lies nearer this point; Chengdu still outranks it.
assert.equal(
(await placeBelow(31.1, 104.35, { nearKm: 60 })).near,
'Chengdu',
);
});
test('no city is named when none lies within half the view', async () => {
// ~100 km west of Chengdu, in the mountains.
const place = await placeBelow(30.67, 103.0, { nearKm: 30 });
assert.equal(place.division, 'Sichuan');
assert.equal(place.near, null);
assert.equal(nearPlaceRadiusKm(8, Number.NaN), null);
});
test('at sea the smallest named sea wins over its ocean', async () => {
assert.deepEqual(await placeBelow(42, 51), {
kind: 'sea',
name: 'Caspian Sea',
countryCode: null,
});
// A radius changes nothing at sea.
assert.deepEqual(await placeBelow(42, 51, { nearKm: 60 }), {
kind: 'sea',
name: 'Caspian Sea',
countryCode: null,
});
assert.equal((await placeBelow(15, 88)).name, 'Bay of Bengal');
assert.equal((await placeBelow(35, 18)).name, 'Mediterranean Sea');
});
test('open water far from any sea is the ocean', async () => {
assert.deepEqual(await placeBelow(0, -150), {
kind: 'ocean',
name: 'South Pacific Ocean',
countryCode: null,
});
assert.equal((await placeBelow(40, 179.9)).name, 'North Pacific Ocean');
assert.equal((await placeBelow(40, -179.9)).name, 'North Pacific Ocean');
assert.equal((await placeBelow(40, -40)).name, 'North Atlantic Ocean');
});
test('an invalid point has no place', async () => {
assert.equal(await placeBelow(Number.NaN, 0), null);
assert.equal(await placeBelow(91, 0), null);
});
/** A lookup whose answers the test releases by hand. */
function manualLookup() {
const calls = [];
const lookup = (lat, lon, options) =>
new Promise((resolve) => calls.push({ lat, lon, options, resolve }));
return { calls, lookup };
}
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
test('the tracker looks up again only after both time and distance', async () => {
const { calls, lookup } = manualLookup();
const places = [];
const tracker = createPlaceTracker({
lookup,
onPlace: (place) => places.push(place),
});
assert.equal(tracker.update(0, 0, 1000), true, 'the first point');
await flush();
assert.equal(tracker.update(5, 5, 1100), false, 'one lookup at a time');
calls[0].resolve({ kind: 'ocean', name: 'A' });
await flush();
assert.deepEqual(places, [{ kind: 'ocean', name: 'A' }]);
// Far enough, but too soon.
assert.equal(tracker.update(5, 5, 1000 + PLACE_LOOKUP_MIN_MS - 1), false);
// Long enough, but barely moved (a geostationary satellite).
assert.equal(tracker.update(0.01, 0, 1000 + PLACE_LOOKUP_MIN_MS * 5), false);
// Both: a low orbit ~2.6 s on.
const movedDeg = (PLACE_LOOKUP_MIN_MOVE_KM + 1) / 111.2;
assert.equal(
tracker.update(movedDeg, 0, 1000 + PLACE_LOOKUP_MIN_MS * 5),
true,
);
await flush();
calls[1].resolve(null);
await flush();
assert.deepEqual(places.at(-1), null, 'nothing named there is reported');
});
test('a new radius (a zoom step) looks up again at once', async () => {
const { calls, lookup } = manualLookup();
const tracker = createPlaceTracker({ lookup, onPlace: () => {} });
assert.equal(tracker.update(0, 0, 1000), true);
await flush();
assert.deepEqual(calls[0].lat, 0);
calls[0].resolve(null);
await flush();
// Same point, a moment later: only the zoom changed.
assert.equal(tracker.update(0, 0, 1100, 60), true, 'into 8x');
await flush();
calls[1].resolve(null);
await flush();
// Altitude drift barely moves the radius: the gate holds.
assert.equal(tracker.update(0, 0, 1200, 62), false);
assert.equal(tracker.update(0, 0, 1300, 30), true, 'to 16x');
await flush();
calls[2].resolve(null);
await flush();
assert.equal(tracker.update(0, 0, 1400, null), true, 'back below 8x');
await flush();
assert.deepEqual(
calls.map((call) => call.options),
[{ nearKm: null }, { nearKm: 60 }, { nearKm: 30 }, { nearKm: null }],
);
});
test('a lookup that lands after reset is dropped', async () => {
const { calls, lookup } = manualLookup();
const places = [];
const tracker = createPlaceTracker({
lookup,
onPlace: (place) => places.push(place),
});
tracker.update(0, 0, 0);
await flush();
tracker.reset();
// A new session looks up at once.
assert.equal(tracker.update(10, 10, 1), true);
await flush();
calls[0].resolve({ name: 'stale' });
calls[1].resolve({ name: 'fresh' });
await flush();
assert.deepEqual(places, [{ name: 'fresh' }]);
});
test('a failed lookup frees the tracker for the next point', async () => {
let fail = true;
const places = [];
const tracker = createPlaceTracker({
lookup: async () => {
if (fail) throw new Error('pack failed');
return { name: 'ok' };
},
onPlace: (place) => places.push(place),
minIntervalMs: 0,
minMoveKm: 0,
});
tracker.update(0, 0, 0);
await flush();
assert.deepEqual(places, [null]);
fail = false;
assert.equal(tracker.update(0, 0, 1), true);
await flush();
assert.deepEqual(places, [null, { name: 'ok' }]);
});
test('the near-place radius is half the smaller side of the view', () => {
assert.equal(nearPlaceRadiusKm(8, 120, 60), 30, 'landscape');
assert.equal(nearPlaceRadiusKm(8, 60, 120), 30, 'portrait');
assert.equal(nearPlaceRadiusKm(8, 120), 60, 'square without a height');
assert.equal(nearPlaceRadiusKm(8, 120, Number.NaN), null);
});
test('a failed division or place pack still names the country', async () => {
const fail = async () => {
throw new Error('pack failed');
};
const place = await placeBelow(30.9, 104.2, {
nearKm: 60,
detail: { division: fail, near: fail },
});
assert.equal(place.kind, 'country');
assert.equal(place.countryCode, 'CN');
assert.equal(place.division, null);
assert.equal(place.near, null);
});
test('a failed lookup hides the old place and looks again on the next update', async () => {
let fail = false;
const places = [];
const tracker = createPlaceTracker({
lookup: async () => {
if (fail) throw new Error('pack failed');
return { kind: 'sea', name: 'Bay of Biscay' };
},
onPlace: (place) => places.push(place),
});
assert.equal(tracker.update(45, -5, 0), true);
await flush();
assert.deepEqual(places, [{ kind: 'sea', name: 'Bay of Biscay' }]);
// Over France the lookup fails: the bay must not stay on the band.
fail = true;
const movedDeg = (PLACE_LOOKUP_MIN_MOVE_KM + 1) / 111.2;
assert.equal(tracker.update(45 + movedDeg, 0, PLACE_LOOKUP_MIN_MS), true);
await flush();
assert.equal(places.at(-1), null, 'the band hides');
// The next tick tries again at once, even without moving (a GEO view).
fail = false;
assert.equal(tracker.update(45 + movedDeg, 0, PLACE_LOOKUP_MIN_MS + 1), true);
await flush();
assert.deepEqual(places.at(-1), { kind: 'sea', name: 'Bay of Biscay' });
});
+5 -4
View File
@@ -1,6 +1,6 @@
/**
* @file The bundled UCS Satellite Database pack: the purpose of each active
* satellite, keyed by NORAD catalog number.
* @file The bundled UCS Satellite Database pack: operator, owner country and
* purpose for active satellites, keyed by NORAD catalog number.
*
* Data: `local_data/ucs_satellites/ucs_satellites.json`, built by
* `scripts/build-ucs-satellites.py` from UCS-Satellite-Database 5-1-2023
@@ -20,8 +20,9 @@ const PACK_URL = new URL(
);
/**
* One UCS row: what UCS says a satellite is for.
* @typedef {{noradId: number, purpose: string|null}} UcsSatellite
* One UCS row: what UCS says about a satellite's operator and purpose.
* @typedef {{noradId: number, operator: string|null,
* operatorCountry: string|null, purpose: string|null}} UcsSatellite
*/
/**
+22 -8
View File
@@ -22,7 +22,12 @@ test('the bundled UCS pack names its release, licence and curation', () => {
pack.meta.curation.invalidNoradDropped +
pack.meta.curation.duplicateNoradDropped,
);
assert.deepEqual(pack.fields, ['noradId', 'purpose']);
assert.deepEqual(pack.fields, [
'noradId',
'operator',
'operatorCountry',
'purpose',
]);
// Small enough to load with the layer.
assert.ok(readFileSync(PACK_URL).length < 200_000);
});
@@ -33,26 +38,35 @@ test('the pack loads into records keyed by NORAD number', async () => {
const iss = records.get(25544);
assert.ok(iss, 'the ISS is in UCS');
assert.equal(iss.noradId, 25544);
assert.ok(iss.operator);
assert.ok(iss.purpose);
for (const record of records.values()) {
assert.doesNotMatch(
`${record.operator ?? ''} ${record.purpose ?? ''}`,
/starlink/i,
'Starlink rows are dropped',
);
}
});
test('indexing reads the string tables and turns empty strings into null', () => {
const records = indexUcsPack({
fields: ['noradId', 'purpose'],
strings: { purpose: ['', 'Earth Observation'] },
fields: ['noradId', 'operator', 'purpose'],
strings: { operator: ['', 'ESA'], purpose: ['', 'Earth Observation'] },
records: [
[39634, 1],
[40000, 0],
['bad', 1],
[-5, 1],
[39634, 1, 1],
[40000, 0, 1],
['bad', 1, 1],
[-5, 1, 1],
],
});
assert.deepEqual([...records.keys()], [39634, 40000]);
assert.deepEqual(records.get(39634), {
noradId: 39634,
operator: 'ESA',
purpose: 'Earth Observation',
});
assert.equal(records.get(40000).purpose, null);
assert.equal(records.get(40000).operator, null);
});
test('a pack of the wrong shape throws, so the loader tries again', () => {
+37 -1
View File
@@ -150,8 +150,10 @@
"Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "التكلفة التقديرية للجلسة على {modelId} — عدد الاستجابات: {responses}. تنبيه عند {warn}، وتنتهي الجلسة عند {cap}.",
"Aircraft cockpit view": "عرض قمرة قيادة الطائرة",
"Ship bridge view": "عرض غرفة قيادة السفينة",
"Satellite orbital view": "العرض المداري من القمر الصناعي",
"EXIT COCKPIT": "الخروج من القمرة",
"EXIT BRIDGE": "الخروج من غرفة القيادة",
"EXIT ORBIT": "الخروج من المدار",
"GROUND SPEED": "السرعة الأرضية",
"SPEED OVER GROUND": "السرعة فوق القاع",
"COURSE OVER GROUND": "المسار فوق القاع",
@@ -279,6 +281,7 @@
"RESET": "إعادة ضبط",
"Exit cockpit view": "الخروج من عرض القمرة",
"Exit bridge view": "الخروج من عرض غرفة القيادة",
"Exit orbital view": "الخروج من العرض المداري",
"Estimated flight plan": "خطة الرحلة التقديرية",
"Reported voyage": "الرحلة المُبلَّغ عنها",
"ESTIMATED FLIGHT PLAN": "خطة الرحلة التقديرية",
@@ -372,6 +375,7 @@
"{label} · COURSE {heading}°": "{label} · المسار {heading}°",
"{label} · HEADING {heading}°": "{label} · الاتجاه {heading}°",
"{label} · NO HEADING": "{label} · لا اتجاه",
"{label} · NADIR VIEW": "{label} · عرض باتجاه النظير",
"Select flight {title}": "اختيار الرحلة {title}",
"CONTEXT STANDBY": "السياق في وضع الاستعداد",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "فعّل السياق العالمي لإشارات العناصر القريبة",
@@ -800,6 +804,28 @@
"RECONSTRUCTED ESTIMATE": "تقدير مُعاد بناؤه",
"{value} KM": "{value} كم",
"{value} KM/S": "{value} كم/ث",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "انحراف عن النظير {angle}° · {offset}",
"ZOOM {zoom}×": "تكبير {zoom}×",
"SWATH {value} KM": "عرض نطاق التصوير {value} كم",
"SWATH ≈ {value} KM": "عرض نطاق التصوير ≈ {value} كم",
"Orbital view": "العرض المداري",
"ORBITAL VIEW · NADIR": "العرض المداري · النظير",
"ORBITAL VELOCITY": "السرعة المدارية",
"SUB-SATELLITE POINT": "نقطة تحت القمر الصناعي",
"NORTH UP": "الشمال للأعلى",
"CURRENTLY OVER": "يحلّق الآن فوق",
"{division}, {country}": "{division}، {country}",
"{region} · near {place}": "{region} · قرب {place}",
"NORAD {id}": "NORAD {id}",
"EPOCH {time}": "الحقبة {time}",
"TLE": "TLE",
"Show the two-line element set": "إظهار مجموعة العناصر ثنائية السطر",
"Hide the two-line element set": "إخفاء مجموعة العناصر ثنائية السطر",
"Two-line element set": "مجموعة العناصر ثنائية السطر",
"Return the view to nadir": "إعادة العرض إلى النظير",
"Return to nadir (Home or 0)": "العودة إلى النظير (Home أو 0)",
"NADIR": "إلى النظير",
"REPLAY ASCENT": "إعادة عرض الصعود",
"Replay the estimated ascent with a following camera": "إعادة عرض الصعود التقديري بكاميرا مرافقة",
"Pause replay": "إيقاف إعادة العرض مؤقتًا",
@@ -3150,9 +3176,11 @@
"SIG": "إشارات",
"LOCAL": "محلي",
"ESC EXIT": "ESC خروج",
"C TOGGLE": "C تبديل",
"ARROWS LOOK": "الأسهم للنظر",
"HOME RESET": "Home إعادة الضبط",
"+/− ZOOM": "+/− للتكبير",
"ARROWS TILT": "الأسهم للإمالة",
"HOME NADIR": "Home النظير",
"Active weather products": "منتجات الطقس النشطة",
"RECENT IMAGERY": "الصور الحديثة",
"Imagery days in the box": "أيام الصور في المنطقة",
@@ -3545,7 +3573,15 @@
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "يعطي SATCAT من CelesTrak نوع كل جسم وحالته. من دون نسخة حديثة تستند الفئات إلى قاعدة بيانات UCS والأسماء ومجموعات CelesTrak، فقد يظهر قمر صناعي توقف عن العمل منذ ذلك الحين وكأنه يعمل.",
"STARLINK ···": "STARLINK ···",
"STARLINK ✕": "STARLINK ✕",
"FWD": "أمام",
"AFT": "خلف",
"RIGHT": "يمين",
"LEFT": "يسار",
"TRACK UP": "المسار للأعلى",
"COURSE HOLD · AIS": "تثبيت المسار · AIS",
"Zoom in (+)": "تكبير (+)",
"Zoom out (−)": "تصغير (−)",
"ORBITAL VIEW · OFF-NADIR": "العرض المداري · انحراف عن النظير",
"World time": "وقت العالم",
"Back to live": "العودة إلى البث المباشر",
"Live": "مباشر",
+37 -1
View File
@@ -191,6 +191,28 @@
"Select {mission}": "{mission} auswählen",
"{value} KM": "{value} KM",
"{value} KM/S": "{value} KM/S",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "NADIRABWEICHUNG {angle}° · {offset}",
"ZOOM {zoom}×": "ZOOM {zoom}×",
"SWATH {value} KM": "STREIFENBREITE {value} KM",
"SWATH ≈ {value} KM": "STREIFENBREITE ≈ {value} KM",
"Orbital view": "Orbitalansicht",
"ORBITAL VIEW · NADIR": "ORBITALANSICHT · NADIR",
"ORBITAL VELOCITY": "BAHNGESCHWINDIGKEIT",
"SUB-SATELLITE POINT": "SUBSATELLITENPUNKT",
"NORTH UP": "NORDEN OBEN",
"CURRENTLY OVER": "AKTUELL ÜBER",
"{division}, {country}": "{division}, {country}",
"{region} · near {place}": "{region} · nahe {place}",
"NORAD {id}": "NORAD {id}",
"EPOCH {time}": "EPOCHE {time}",
"TLE": "TLE",
"Show the two-line element set": "Zweizeilen-Bahnelemente anzeigen",
"Hide the two-line element set": "Zweizeilen-Bahnelemente ausblenden",
"Two-line element set": "Zweizeilen-Bahnelemente",
"Return the view to nadir": "Ansicht zurück auf den Nadir richten",
"Return to nadir (Home or 0)": "Zurück zum Nadir (Pos1 oder 0)",
"NADIR": "ZUM NADIR",
"SELECTED SPACE MISSION": "GEWÄHLTE RAUMMISSION",
"Show all missions": "Alle Missionen anzeigen",
"Deselect mission": "Mission abwählen",
@@ -483,6 +505,7 @@
"{label} · COURSE {heading}°": "{label} · KURS {heading}°",
"{label} · HEADING {heading}°": "{label} · STEUERKURS {heading}°",
"{label} · NO HEADING": "{label} · KEIN STEUERKURS",
"{label} · NADIR VIEW": "{label} · NADIRANSICHT",
"Context could not restore every layer; try again": "Kontext konnte nicht alle Ebenen wiederherstellen; erneut versuchen",
"No selected data layers": "Keine Datenebenen ausgewählt",
"{count} data layers could not be cleared": {
@@ -670,6 +693,7 @@
"Radio did not start": "Radio wurde nicht gestartet",
"Aircraft cockpit view": "Flugzeug-Cockpitansicht",
"Ship bridge view": "Ansicht von der Schiffsbrücke",
"Satellite orbital view": "Orbitalansicht vom Satelliten",
"LOOK STBD {bearing}°": "BLICK STEUERBORD {bearing}°",
"LOOK PORT {bearing}°": "BLICK BACKBORD {bearing}°",
"LOOK ASTERN": "BLICK ACHTERAUS",
@@ -757,8 +781,10 @@
"RESET": "RESET",
"Exit cockpit view": "Cockpitansicht verlassen",
"Exit bridge view": "Brückenansicht verlassen",
"Exit orbital view": "Orbitalansicht verlassen",
"EXIT COCKPIT": "COCKPIT VERLASSEN",
"EXIT BRIDGE": "BRÜCKE VERLASSEN",
"EXIT ORBIT": "ORBIT VERLASSEN",
"Navigation, voice, and visual preset controls": "Steuerung für Navigation, Sprache und visuelle Presets",
"Expand Visual Presets": "Visuelle Presets ausklappen",
"Pin visual presets": "Visuelle Presets anheften",
@@ -2872,9 +2898,11 @@
"SIG": "SIG",
"LOCAL": "LOKAL",
"ESC EXIT": "ESC BEENDEN",
"C TOGGLE": "C UMSCHALTEN",
"ARROWS LOOK": "PFEILE UMSEHEN",
"HOME RESET": "POS1 ZURÜCKSETZEN",
"+/− ZOOM": "+/− ZOOMEN",
"ARROWS TILT": "PFEILE NEIGEN",
"HOME NADIR": "POS1 NADIR",
"Active weather products": "Aktive Wetterprodukte",
"RECENT IMAGERY": "AKTUELLE BILDDATEN",
"Imagery days in the box": "Bildtage im Bereich",
@@ -3241,7 +3269,15 @@
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT liefert Typ und Status jedes Objekts. Ohne aktuelle Kopie stützen sich die Klassen auf die UCS-Datenbank, Namen und CelesTrak-Gruppen, sodass ein inzwischen ausgefallener Satellit noch als aktiv erscheinen kann.",
"STARLINK ···": "STARLINK ···",
"STARLINK ✕": "STARLINK ✕",
"FWD": "VORAUS",
"AFT": "ACHTERN",
"RIGHT": "RECHTS",
"LEFT": "LINKS",
"TRACK UP": "KURS OBEN",
"COURSE HOLD · AIS": "KURS FIX · AIS",
"Zoom in (+)": "Hineinzoomen (+)",
"Zoom out (−)": "Herauszoomen (−)",
"ORBITAL VIEW · OFF-NADIR": "ORBITALANSICHT · NADIRABWEICHUNG",
"World time": "Weltzeit",
"Back to live": "Zurück zu live",
"Live": "Live",
+37 -1
View File
@@ -39,8 +39,10 @@
"Close data attribution": "Cerrar la atribución de datos",
"Aircraft cockpit view": "Vista de cabina de la aeronave",
"Ship bridge view": "Vista desde el puente del buque",
"Satellite orbital view": "Vista orbital desde el satélite",
"EXIT COCKPIT": "SALIR DE CABINA",
"EXIT BRIDGE": "SALIR DEL PUENTE",
"EXIT ORBIT": "SALIR DE LA ÓRBITA",
"GROUND SPEED": "VEL. SUELO",
"SPEED OVER GROUND": "VEL. SOBRE FONDO",
"COURSE OVER GROUND": "RUMBO SOBRE FONDO",
@@ -166,6 +168,7 @@
"RESET": "RESTABLECER",
"Exit cockpit view": "Salir de la vista de cabina",
"Exit bridge view": "Salir de la vista del puente",
"Exit orbital view": "Salir de la vista orbital",
"Estimated flight plan": "Plan de vuelo estimado",
"Reported voyage": "Viaje notificado",
"ESTIMATED FLIGHT PLAN": "PLAN DE VUELO ESTIMADO",
@@ -261,6 +264,7 @@
"{label} · COURSE {heading}°": "{label} · RUMBO {heading}°",
"{label} · HEADING {heading}°": "{label} · PROA {heading}°",
"{label} · NO HEADING": "{label} · SIN PROA",
"{label} · NADIR VIEW": "{label} · VISTA AL NADIR",
"Select flight {title}": "Seleccionar el vuelo {title}",
"CONTEXT STANDBY": "CONTEXTO EN ESPERA",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVAR CONTEXTO GLOBAL PARA PINGS DE PROXIMIDAD",
@@ -658,6 +662,28 @@
"RECONSTRUCTED ESTIMATE": "ESTIMACIÓN RECONSTRUIDA",
"{value} KM": "{value} KM",
"{value} KM/S": "{value} KM/S",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "FUERA DEL NADIR {angle}° · {offset}",
"ZOOM {zoom}×": "ZOOM {zoom}×",
"SWATH {value} KM": "BARRIDO {value} KM",
"SWATH ≈ {value} KM": "BARRIDO ≈ {value} KM",
"Orbital view": "Vista orbital",
"ORBITAL VIEW · NADIR": "VISTA ORBITAL · NADIR",
"ORBITAL VELOCITY": "VELOCIDAD ORBITAL",
"SUB-SATELLITE POINT": "PUNTO SUBSATELITAL",
"NORTH UP": "NORTE ARRIBA",
"CURRENTLY OVER": "SOBREVOLANDO",
"{division}, {country}": "{division}, {country}",
"{region} · near {place}": "{region} · cerca de {place}",
"NORAD {id}": "NORAD {id}",
"EPOCH {time}": "ÉPOCA {time}",
"TLE": "TLE",
"Show the two-line element set": "Mostrar el conjunto de elementos de dos líneas",
"Hide the two-line element set": "Ocultar el conjunto de elementos de dos líneas",
"Two-line element set": "Conjunto de elementos de dos líneas",
"Return the view to nadir": "Volver la vista al nadir",
"Return to nadir (Home or 0)": "Volver al nadir (Inicio o 0)",
"NADIR": "AL NADIR",
"REPLAY ASCENT": "REPETIR ASCENSO",
"Replay the estimated ascent with a following camera": "Reproduce el ascenso estimado con una cámara de seguimiento",
"Pause replay": "Pausar repetición",
@@ -1642,9 +1668,11 @@
"OVERPASS TEMPORARILY UNAVAILABLE": "OVERPASS TEMPORALMENTE NO DISPONIBLE",
"Weather data by Open-Meteo.com": "Datos meteorológicos de Open-Meteo.com",
"ESC EXIT": "ESC SALIR",
"C TOGGLE": "C ALTERNAR",
"ARROWS LOOK": "FLECHAS MIRAR",
"HOME RESET": "INICIO RESTABLECER",
"+/− ZOOM": "+/− ACERCAR/ALEJAR",
"ARROWS TILT": "FLECHAS INCLINAR",
"HOME NADIR": "INICIO NADIR",
"SIG": "SIG",
"NEWS": "NOTICIAS",
"LOCAL": "LOCAL",
@@ -3317,7 +3345,15 @@
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak da el tipo y el estado de cada objeto. Sin una copia actual, las clases se basan en la base de datos UCS, los nombres y los grupos de CelesTrak, así que un satélite que haya dejado de funcionar puede seguir apareciendo como operativo.",
"STARLINK ···": "STARLINK ···",
"STARLINK ✕": "STARLINK ✕",
"FWD": "ADELANTE",
"AFT": "ATRÁS",
"RIGHT": "DERECHA",
"LEFT": "IZQUIERDA",
"TRACK UP": "TRAYECTORIA ARRIBA",
"COURSE HOLD · AIS": "BLOQUEO DE RUMBO · AIS",
"Zoom in (+)": "Acercar (+)",
"Zoom out (−)": "Alejar (−)",
"ORBITAL VIEW · OFF-NADIR": "VISTA ORBITAL · FUERA DEL NADIR",
"World time": "Hora mundial",
"Back to live": "Volver al directo",
"Live": "En directo",
+37 -1
View File
@@ -827,6 +827,28 @@
"Select {mission}": "Sélectionner {mission}",
"{value} KM": "{value} KM",
"{value} KM/S": "{value} KM/S",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "HORS NADIR {angle}° · {offset}",
"ZOOM {zoom}×": "ZOOM {zoom}×",
"SWATH {value} KM": "FAUCHÉE {value} KM",
"SWATH ≈ {value} KM": "FAUCHÉE ≈ {value} KM",
"Orbital view": "Vue orbitale",
"ORBITAL VIEW · NADIR": "VUE ORBITALE · NADIR",
"ORBITAL VELOCITY": "VITESSE ORBITALE",
"SUB-SATELLITE POINT": "POINT SUBSATELLITE",
"NORTH UP": "NORD EN HAUT",
"CURRENTLY OVER": "SURVOL ACTUEL",
"{division}, {country}": "{division}, {country}",
"{region} · near {place}": "{region} · près de {place}",
"NORAD {id}": "NORAD {id}",
"EPOCH {time}": "ÉPOQUE {time}",
"TLE": "TLE",
"Show the two-line element set": "Afficher le jeu d'éléments à deux lignes",
"Hide the two-line element set": "Masquer le jeu d'éléments à deux lignes",
"Two-line element set": "Jeu d'éléments à deux lignes",
"Return the view to nadir": "Ramener la vue au nadir",
"Return to nadir (Home or 0)": "Retour au nadir (Home ou 0)",
"NADIR": "AU NADIR",
"Selected Space Mission": "Mission spatiale sélectionnée",
"SELECTED SPACE MISSION": "MISSION SPATIALE SÉLECTIONNÉE",
"Show all missions": "Afficher toutes les missions",
@@ -1754,6 +1776,7 @@
"{label} · COURSE {heading}°": "{label} · ROUTE {heading}°",
"{label} · HEADING {heading}°": "{label} · CAP {heading}°",
"{label} · NO HEADING": "{label} · SANS CAP",
"{label} · NADIR VIEW": "{label} · VUE AU NADIR",
"Context could not restore every layer; try again": "Le contexte n'a pas pu restaurer toutes les couches ; réessayez",
"No selected data layers": "Aucune couche de données sélectionnée",
"{count} data layers could not be cleared": {
@@ -2704,6 +2727,7 @@
"HERE": "ICI",
"Aircraft cockpit view": "Vue cockpit de l'aéronef",
"Ship bridge view": "Vue depuis la passerelle du navire",
"Satellite orbital view": "Vue orbitale depuis le satellite",
"LOOK STBD {bearing}°": "VUE TRIBORD {bearing}°",
"LOOK PORT {bearing}°": "VUE BÂBORD {bearing}°",
"LOOK ASTERN": "VUE ARRIÈRE",
@@ -2799,16 +2823,20 @@
"VOLUME": "VOLUME",
"Cockpit Radio volume": "Volume de la Radio du cockpit",
"ESC EXIT": "ESC QUITTER",
"C TOGGLE": "C BASCULER",
"ARROWS LOOK": "FLÈCHES REGARDER",
"HOME RESET": "HOME RECENTRER",
"+/− ZOOM": "+/− ZOOMER",
"ARROWS TILT": "FLÈCHES INCLINER",
"HOME NADIR": "HOME AU NADIR",
"View switcher": "Sélecteur de vue",
"Exit cockpit and return to full globe view": "Quitter le cockpit et revenir à la vue du globe entier",
"RESET": "RÉINIT.",
"Exit cockpit view": "Quitter la vue cockpit",
"Exit bridge view": "Quitter la vue passerelle",
"Exit orbital view": "Quitter la vue orbitale",
"EXIT COCKPIT": "QUITTER LE COCKPIT",
"EXIT BRIDGE": "QUITTER LA PASSERELLE",
"EXIT ORBIT": "QUITTER L'ORBITE",
"Navigation, voice, and visual preset controls": "Commandes de navigation, de voix et de préréglages visuels",
"Expand Visual Presets": "Développer les préréglages visuels",
"Pin visual presets": "Épingler les préréglages visuels",
@@ -3317,7 +3345,15 @@
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak donne le type et l’état de chaque objet. Sans copie à jour, les classes reposent sur la base UCS, les noms et les groupes CelesTrak : un satellite hors service depuis peut encore apparaître comme actif.",
"STARLINK ···": "STARLINK ···",
"STARLINK ✕": "STARLINK ✕",
"FWD": "AVANT",
"AFT": "ARRIÈRE",
"RIGHT": "DROITE",
"LEFT": "GAUCHE",
"TRACK UP": "TRACE EN HAUT",
"COURSE HOLD · AIS": "VERROU ROUTE · AIS",
"Zoom in (+)": "Zoomer (+)",
"Zoom out (−)": "Dézoomer (−)",
"ORBITAL VIEW · OFF-NADIR": "VUE ORBITALE · HORS NADIR",
"World time": "Heure du monde",
"Back to live": "Revenir au direct",
"Live": "Direct",
+37 -1
View File
@@ -827,6 +827,28 @@
"Select {mission}": "Seleziona {mission}",
"{value} KM": "{value} KM",
"{value} KM/S": "{value} KM/S",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "FUORI NADIR {angle}° · {offset}",
"ZOOM {zoom}×": "ZOOM {zoom}×",
"SWATH {value} KM": "STRISCIATA {value} KM",
"SWATH ≈ {value} KM": "STRISCIATA ≈ {value} KM",
"Orbital view": "Vista orbitale",
"ORBITAL VIEW · NADIR": "VISTA ORBITALE · NADIR",
"ORBITAL VELOCITY": "VELOCITÀ ORBITALE",
"SUB-SATELLITE POINT": "PUNTO SUBSATELLITARE",
"NORTH UP": "NORD IN ALTO",
"CURRENTLY OVER": "ATTUALMENTE SOPRA",
"{division}, {country}": "{division}, {country}",
"{region} · near {place}": "{region} · vicino a {place}",
"NORAD {id}": "NORAD {id}",
"EPOCH {time}": "EPOCA {time}",
"TLE": "TLE",
"Show the two-line element set": "Mostra il set di elementi a due righe",
"Hide the two-line element set": "Nascondi il set di elementi a due righe",
"Two-line element set": "Set di elementi a due righe",
"Return the view to nadir": "Riporta la vista al nadir",
"Return to nadir (Home or 0)": "Torna al nadir (Home o 0)",
"NADIR": "AL NADIR",
"Selected Space Mission": "Missione spaziale selezionata",
"SELECTED SPACE MISSION": "MISSIONE SPAZIALE SELEZIONATA",
"Show all missions": "Mostra tutte le missioni",
@@ -1754,6 +1776,7 @@
"{label} · COURSE {heading}°": "{label} · ROTTA {heading}°",
"{label} · HEADING {heading}°": "{label} · PRUA {heading}°",
"{label} · NO HEADING": "{label} · NESSUNA PRUA",
"{label} · NADIR VIEW": "{label} · VISTA AL NADIR",
"Context could not restore every layer; try again": "Il Contesto non ha potuto ripristinare tutti i livelli; riprova",
"No selected data layers": "Nessun livello dati selezionato",
"{count} data layers could not be cleared": {
@@ -2704,6 +2727,7 @@
"HERE": "QUI",
"Aircraft cockpit view": "Vista cockpit dell'aereo",
"Ship bridge view": "Vista dalla plancia della nave",
"Satellite orbital view": "Vista orbitale dal satellite",
"LOOK STBD {bearing}°": "VISTA DRITTA {bearing}°",
"LOOK PORT {bearing}°": "VISTA SINISTRA {bearing}°",
"LOOK ASTERN": "VISTA A POPPA",
@@ -2799,16 +2823,20 @@
"VOLUME": "VOLUME",
"Cockpit Radio volume": "Volume Radio del cockpit",
"ESC EXIT": "ESC ESCI",
"C TOGGLE": "C ALTERNA",
"ARROWS LOOK": "FRECCE GUARDA",
"HOME RESET": "HOME RIPRISTINA",
"+/− ZOOM": "+/− AVVICINA/ALLONTANA",
"ARROWS TILT": "FRECCE INCLINA",
"HOME NADIR": "HOME AL NADIR",
"View switcher": "Selettore vista",
"Exit cockpit and return to full globe view": "Esci dal cockpit e torna alla vista del globo intero",
"RESET": "RESET",
"Exit cockpit view": "Esci dalla vista cockpit",
"Exit bridge view": "Esci dalla vista plancia",
"Exit orbital view": "Esci dalla vista orbitale",
"EXIT COCKPIT": "ESCI DAL COCKPIT",
"EXIT BRIDGE": "ESCI DALLA PLANCIA",
"EXIT ORBIT": "ESCI DALL'ORBITA",
"Navigation, voice, and visual preset controls": "Controlli di navigazione, voce e preset visivi",
"Expand Visual Presets": "Espandi Preset visivi",
"Pin visual presets": "Fissa i preset visivi",
@@ -3317,7 +3345,15 @@
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT di CelesTrak fornisce tipo e stato di ogni oggetto. Senza una copia aggiornata le classi si basano sul database UCS, sui nomi e sui gruppi CelesTrak, quindi un satellite che nel frattempo ha cessato di funzionare può ancora risultare operativo.",
"STARLINK ···": "STARLINK ···",
"STARLINK ✕": "STARLINK ✕",
"FWD": "AVANTI",
"AFT": "INDIETRO",
"RIGHT": "DESTRA",
"LEFT": "SINISTRA",
"TRACK UP": "TRACCIA IN ALTO",
"COURSE HOLD · AIS": "BLOCCO ROTTA · AIS",
"Zoom in (+)": "Avvicina (+)",
"Zoom out (−)": "Allontana (−)",
"ORBITAL VIEW · OFF-NADIR": "VISTA ORBITALE · FUORI NADIR",
"World time": "Ora del mondo",
"Back to live": "Torna alla diretta",
"Live": "Diretta",
+37 -1
View File
@@ -801,6 +801,28 @@
"Select {mission}": "{mission}を選択",
"{value} KM": "{value} km",
"{value} KM/S": "{value} km/s",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "オフナディア {angle}° · {offset}",
"ZOOM {zoom}×": "ズーム {zoom}×",
"SWATH {value} KM": "観測幅 {value} km",
"SWATH ≈ {value} KM": "観測幅 ≈ {value} km",
"Orbital view": "軌道ビュー",
"ORBITAL VIEW · NADIR": "軌道ビュー · 直下",
"ORBITAL VELOCITY": "軌道速度",
"SUB-SATELLITE POINT": "衛星直下点",
"NORTH UP": "ノースアップ",
"CURRENTLY OVER": "現在の上空",
"{division}, {country}": "{division}、{country}",
"{region} · near {place}": "{region} · {place}付近",
"NORAD {id}": "NORAD {id}",
"EPOCH {time}": "元期 {time}",
"TLE": "TLE",
"Show the two-line element set": "2行軌道要素を表示",
"Hide the two-line element set": "2行軌道要素を隠す",
"Two-line element set": "2行軌道要素",
"Return the view to nadir": "ビューを直下に戻す",
"Return to nadir (Home or 0)": "直下に戻る(Homeまたは0)",
"NADIR": "直下に戻す",
"Selected Space Mission": "選択中の宇宙ミッション",
"SELECTED SPACE MISSION": "選択中の宇宙ミッション",
"Show all missions": "すべてのミッションを表示",
@@ -1702,6 +1724,7 @@
"{label} · COURSE {heading}°": "{label} · 対地針路 {heading}°",
"{label} · HEADING {heading}°": "{label} · 船首方位 {heading}°",
"{label} · NO HEADING": "{label} · 船首方位なし",
"{label} · NADIR VIEW": "{label} · 直下視",
"Context could not restore every layer; try again": "コンテキストがすべてのレイヤーを復元できませんでした。もう一度お試しください",
"No selected data layers": "選択中のデータレイヤーはありません",
"{count} data layers could not be cleared": {
@@ -2562,6 +2585,7 @@
"HERE": "ここ",
"Aircraft cockpit view": "航空機コックピットビュー",
"Ship bridge view": "船舶ブリッジビュー",
"Satellite orbital view": "衛星軌道ビュー",
"LOOK STBD {bearing}°": "視線 右舷 {bearing}°",
"LOOK PORT {bearing}°": "視線 左舷 {bearing}°",
"LOOK ASTERN": "視線 船尾方向",
@@ -2655,16 +2679,20 @@
"VOLUME": "音量",
"Cockpit Radio volume": "コックピットラジオの音量",
"ESC EXIT": "Escで終了",
"C TOGGLE": "Cで切替",
"ARROWS LOOK": "矢印キーで見回す",
"HOME RESET": "Homeでリセット",
"+/− ZOOM": "+/−でズーム",
"ARROWS TILT": "矢印キーで傾ける",
"HOME NADIR": "Homeで直下",
"View switcher": "ビュー切替",
"Exit cockpit and return to full globe view": "コックピットを終了して地球全体ビューに戻る",
"RESET": "リセット",
"Exit cockpit view": "コックピットビューを終了",
"Exit bridge view": "ブリッジビューを終了",
"Exit orbital view": "軌道ビューを終了",
"EXIT COCKPIT": "コックピット終了",
"EXIT BRIDGE": "ブリッジ終了",
"EXIT ORBIT": "軌道ビュー終了",
"Navigation, voice, and visual preset controls": "ナビゲーション、音声、ビジュアルプリセットの操作",
"Expand Visual Presets": "ビジュアルプリセットを展開",
"Pin visual presets": "ビジュアルプリセットを固定",
@@ -3165,7 +3193,15 @@
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT は各物体の種別と運用状態を示します。最新のコピーがない間、分類は UCS データベース、名称、CelesTrak のグループのみに基づくため、その後機能を失った衛星がまだ稼働中と表示されることがあります。",
"STARLINK ···": "STARLINK ···",
"STARLINK ✕": "STARLINK ✕",
"FWD": "前方",
"AFT": "後方",
"RIGHT": "右",
"LEFT": "左",
"TRACK UP": "トラックアップ",
"COURSE HOLD · AIS": "対地針路保持 · AIS",
"Zoom in (+)": "ズームイン (+)",
"Zoom out (−)": "ズームアウト (−)",
"ORBITAL VIEW · OFF-NADIR": "軌道ビュー · オフナディア",
"World time": "世界時刻",
"Back to live": "ライブに戻る",
"Live": "ライブ",
+37 -1
View File
@@ -825,6 +825,28 @@
"Select {mission}": "Selecionar {mission}",
"{value} KM": "{value} KM",
"{value} KM/S": "{value} KM/S",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "FORA DO NADIR {angle}° · {offset}",
"ZOOM {zoom}×": "ZOOM {zoom}×",
"SWATH {value} KM": "FAIXA IMAGEADA {value} KM",
"SWATH ≈ {value} KM": "FAIXA IMAGEADA ≈ {value} KM",
"Orbital view": "Vista orbital",
"ORBITAL VIEW · NADIR": "VISTA ORBITAL · NADIR",
"ORBITAL VELOCITY": "VELOCIDADE ORBITAL",
"SUB-SATELLITE POINT": "PONTO SUBSATÉLITE",
"NORTH UP": "NORTE PARA CIMA",
"CURRENTLY OVER": "SOBREVOANDO AGORA",
"{division}, {country}": "{division}, {country}",
"{region} · near {place}": "{region} · perto de {place}",
"NORAD {id}": "NORAD {id}",
"EPOCH {time}": "ÉPOCA {time}",
"TLE": "TLE",
"Show the two-line element set": "Mostrar o conjunto de elementos de duas linhas",
"Hide the two-line element set": "Ocultar o conjunto de elementos de duas linhas",
"Two-line element set": "Conjunto de elementos de duas linhas",
"Return the view to nadir": "Voltar a vista para o nadir",
"Return to nadir (Home or 0)": "Voltar ao nadir (Home ou 0)",
"NADIR": "AO NADIR",
"Selected Space Mission": "Missão espacial selecionada",
"SELECTED SPACE MISSION": "MISSÃO ESPACIAL SELECIONADA",
"Show all missions": "Mostrar todas as missões",
@@ -1754,6 +1776,7 @@
"{label} · COURSE {heading}°": "{label} · RUMO {heading}°",
"{label} · HEADING {heading}°": "{label} · PROA {heading}°",
"{label} · NO HEADING": "{label} · SEM PROA",
"{label} · NADIR VIEW": "{label} · VISTA AO NADIR",
"Context could not restore every layer; try again": "O contexto não conseguiu restaurar todas as camadas; tente novamente",
"No selected data layers": "Nenhuma camada de dados selecionada",
"{count} data layers could not be cleared": {
@@ -2706,6 +2729,7 @@
"HERE": "AQUI",
"Aircraft cockpit view": "Vista da cabine da aeronave",
"Ship bridge view": "Vista do passadiço da embarcação",
"Satellite orbital view": "Vista orbital do satélite",
"LOOK STBD {bearing}°": "VISTA BORESTE {bearing}°",
"LOOK PORT {bearing}°": "VISTA BOMBORDO {bearing}°",
"LOOK ASTERN": "VISTA À RÉ",
@@ -2801,16 +2825,20 @@
"VOLUME": "VOLUME",
"Cockpit Radio volume": "Volume do rádio da cabine",
"ESC EXIT": "ESC SAIR",
"C TOGGLE": "C ALTERNAR",
"ARROWS LOOK": "SETAS OLHAR",
"HOME RESET": "HOME REDEFINIR",
"+/− ZOOM": "+/− APROXIMAR/AFASTAR",
"ARROWS TILT": "SETAS INCLINAR",
"HOME NADIR": "HOME AO NADIR",
"View switcher": "Alternador de vista",
"Exit cockpit and return to full globe view": "Sair da cabine e voltar à vista do globo completo",
"RESET": "REDEFINIR",
"Exit cockpit view": "Sair da vista da cabine",
"Exit bridge view": "Sair da vista do passadiço",
"Exit orbital view": "Sair da vista orbital",
"EXIT COCKPIT": "SAIR DA CABINE",
"EXIT BRIDGE": "SAIR DO PASSADIÇO",
"EXIT ORBIT": "SAIR DA ÓRBITA",
"Navigation, voice, and visual preset controls": "Controles de navegação, voz e predefinições visuais",
"Expand Visual Presets": "Expandir predefinições visuais",
"Pin visual presets": "Fixar predefinições visuais",
@@ -3317,7 +3345,15 @@
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "O SATCAT da CelesTrak dá o tipo e o estado de cada objeto. Sem uma cópia atual, as classes se baseiam no banco de dados UCS, nos nomes e nos grupos da CelesTrak, então um satélite que deixou de funcionar pode ainda aparecer como operacional.",
"STARLINK ···": "STARLINK ···",
"STARLINK ✕": "STARLINK ✕",
"FWD": "FRENTE",
"AFT": "TRÁS",
"RIGHT": "DIREITA",
"LEFT": "ESQUERDA",
"TRACK UP": "TRAJETÓRIA PARA CIMA",
"COURSE HOLD · AIS": "TRAVA DE RUMO · AIS",
"Zoom in (+)": "Aproximar (+)",
"Zoom out (−)": "Afastar (−)",
"ORBITAL VIEW · OFF-NADIR": "VISTA ORBITAL · FORA DO NADIR",
"World time": "Hora do mundo",
"Back to live": "Voltar ao vivo",
"Live": "Ao vivo",
+37 -1
View File
@@ -187,6 +187,28 @@
"Select {mission}": "Выбрать {mission}",
"{value} KM": "{value} КМ",
"{value} KM/S": "{value} КМ/С",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "ОТКЛОНЕНИЕ ОТ НАДИРА {angle}° · {offset}",
"ZOOM {zoom}×": "ПРИБЛИЖЕНИЕ {zoom}×",
"SWATH {value} KM": "ПОЛОСА ОБЗОРА {value} КМ",
"SWATH ≈ {value} KM": "ПОЛОСА ОБЗОРА ≈ {value} КМ",
"Orbital view": "Орбитальный вид",
"ORBITAL VIEW · NADIR": "ОРБИТАЛЬНЫЙ ВИД · НАДИР",
"ORBITAL VELOCITY": "ОРБИТАЛЬНАЯ СКОРОСТЬ",
"SUB-SATELLITE POINT": "ПОДСПУТНИКОВАЯ ТОЧКА",
"NORTH UP": "СЕВЕР ВВЕРХУ",
"CURRENTLY OVER": "СЕЙЧАС НАД",
"{division}, {country}": "{division}, {country}",
"{region} · near {place}": "{region} · поблизости: {place}",
"NORAD {id}": "NORAD {id}",
"EPOCH {time}": "ЭПОХА {time}",
"TLE": "TLE",
"Show the two-line element set": "Показать двухстрочный набор элементов",
"Hide the two-line element set": "Скрыть двухстрочный набор элементов",
"Two-line element set": "Двухстрочный набор элементов",
"Return the view to nadir": "Вернуть вид в надир",
"Return to nadir (Home or 0)": "Вернуться в надир (Home или 0)",
"NADIR": "В НАДИР",
"SELECTED SPACE MISSION": "ВЫБРАННАЯ КОСМИЧЕСКАЯ МИССИЯ",
"Show all missions": "Показать все миссии",
"Deselect mission": "Снять выбор миссии",
@@ -465,6 +487,7 @@
"{label} · COURSE {heading}°": "{label} · ПУТЕВОЙ УГОЛ {heading}°",
"{label} · HEADING {heading}°": "{label} · КУРС {heading}°",
"{label} · NO HEADING": "{label} · НЕТ КУРСА",
"{label} · NADIR VIEW": "{label} · ВИД В НАДИР",
"Restore real aircraft": "Вернуть реальный самолёт",
"Reclassify as TR-3B": "Переклассифицировать как TR-3B",
"No selected data layers": "Нет выбранных слоёв данных",
@@ -646,6 +669,7 @@
"Exit cockpit and return to full globe view": "Выйти из кабины и вернуться к виду всего глобуса",
"Exit cockpit view": "Выйти из кабины",
"Exit bridge view": "Выйти с мостика",
"Exit orbital view": "Выйти из орбитального вида",
"Estimated destination direction": "Расчётное направление на пункт назначения",
"BRIDGE WINDOW · FWD": "ОКНО МОСТИКА · ПО НОСУ",
"OPTICAL PLANE · 01": "ОПТИЧЕСКАЯ ПЛОСКОСТЬ · 01",
@@ -667,6 +691,7 @@
"View switcher": "Переключатель вида",
"Aircraft cockpit view": "Вид кабины самолёта",
"Ship bridge view": "Вид с ходового мостика",
"Satellite orbital view": "Орбитальный вид со спутника",
"Estimated flight plan": "Расчётный план полёта",
"Reported voyage": "Заявленный рейс",
"Live signals": "Сигналы в реальном времени",
@@ -708,6 +733,7 @@
"RESET": "СБРОС",
"EXIT COCKPIT": "ВЫЙТИ ИЗ КАБИНЫ",
"EXIT BRIDGE": "ВЫЙТИ С МОСТИКА",
"EXIT ORBIT": "ВЫЙТИ С ОРБИТЫ",
"CONTACTS · 250 KM": "КОНТАКТЫ · 250 КМ",
"RINGS {spacing} NM": "КОЛЬЦА {spacing} МОР. МИЛЬ",
"{count} CONTACTS": {
@@ -1670,9 +1696,11 @@
"OVERPASS TEMPORARILY UNAVAILABLE": "OVERPASS ВРЕМЕННО НЕДОСТУПЕН",
"Weather data by Open-Meteo.com": "Данные о погоде: Open-Meteo.com",
"ESC EXIT": "ESC ВЫХОД",
"C TOGGLE": "C ПЕРЕКЛ.",
"ARROWS LOOK": "СТРЕЛКИ ОБЗОР",
"HOME RESET": "HOME СБРОС",
"+/− ZOOM": "+/− МАСШТАБ",
"ARROWS TILT": "СТРЕЛКИ НАКЛОН",
"HOME NADIR": "HOME НАДИР",
"SIG": "СИГН.",
"NEWS": "НОВОСТИ",
"LOCAL": "МЕСТН.",
@@ -3393,7 +3421,15 @@
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT сообщает тип и состояние каждого объекта. Без актуальной копии классы опираются на базу UCS, названия и группы CelesTrak, поэтому вышедший с тех пор из строя спутник может по-прежнему показываться работающим.",
"STARLINK ···": "STARLINK ···",
"STARLINK ✕": "STARLINK ✕",
"FWD": "ВПЕРЁД",
"AFT": "НАЗАД",
"RIGHT": "ВПРАВО",
"LEFT": "ВЛЕВО",
"TRACK UP": "ТРАССА ВВЕРХУ",
"COURSE HOLD · AIS": "УДЕРЖАНИЕ ПУТЕВОГО УГЛА · AIS",
"Zoom in (+)": "Приблизить (+)",
"Zoom out (−)": "Отдалить (−)",
"ORBITAL VIEW · OFF-NADIR": "ОРБИТАЛЬНЫЙ ВИД · ОТКЛОНЕНИЕ ОТ НАДИРА",
"World time": "Мировое время",
"Back to live": "Вернуться к прямому эфиру",
"Live": "Сейчас",
+37 -1
View File
@@ -839,6 +839,28 @@
"Select {mission}": "Вибрати {mission}",
"{value} KM": "{value} КМ",
"{value} KM/S": "{value} КМ/С",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "ВІДХИЛЕННЯ ВІД НАДИРА {angle}° · {offset}",
"ZOOM {zoom}×": "НАБЛИЖЕННЯ {zoom}×",
"SWATH {value} KM": "СМУГА ОГЛЯДУ {value} КМ",
"SWATH ≈ {value} KM": "СМУГА ОГЛЯДУ ≈ {value} КМ",
"Orbital view": "Орбітальний вигляд",
"ORBITAL VIEW · NADIR": "ОРБІТАЛЬНИЙ ВИГЛЯД · НАДИР",
"ORBITAL VELOCITY": "ОРБІТАЛЬНА ШВИДКІСТЬ",
"SUB-SATELLITE POINT": "ПІДСУПУТНИКОВА ТОЧКА",
"NORTH UP": "ПІВНІЧ УГОРІ",
"CURRENTLY OVER": "ЗАРАЗ НАД",
"{division}, {country}": "{division}, {country}",
"{region} · near {place}": "{region} · поблизу: {place}",
"NORAD {id}": "NORAD {id}",
"EPOCH {time}": "ЕПОХА {time}",
"TLE": "TLE",
"Show the two-line element set": "Показати дворядковий набір елементів",
"Hide the two-line element set": "Сховати дворядковий набір елементів",
"Two-line element set": "Дворядковий набір елементів",
"Return the view to nadir": "Повернути вигляд у надир",
"Return to nadir (Home or 0)": "Повернутися в надир (Home або 0)",
"NADIR": "У НАДИР",
"Selected Space Mission": "Вибрана космічна місія",
"SELECTED SPACE MISSION": "ВИБРАНА КОСМІЧНА МІСІЯ",
"Show all missions": "Показати всі місії",
@@ -1779,6 +1801,7 @@
"{label} · COURSE {heading}°": "{label} · ШЛЯХОВИЙ КУТ {heading}°",
"{label} · HEADING {heading}°": "{label} · КУРС {heading}°",
"{label} · NO HEADING": "{label} · НЕМАЄ КУРСУ",
"{label} · NADIR VIEW": "{label} · ВИГЛЯД У НАДИР",
"Context could not restore every layer; try again": "Не вдалося відновити всі шари контексту; спробуйте ще раз",
"No selected data layers": "Шари даних не вибрано",
"{count} data layers could not be cleared": {
@@ -2774,6 +2797,7 @@
"HERE": "ТУТ",
"Aircraft cockpit view": "Вигляд кабіни повітряного судна",
"Ship bridge view": "Вигляд з ходового містка судна",
"Satellite orbital view": "Орбітальний вигляд із супутника",
"LOOK STBD {bearing}°": "ПОГЛЯД ПР. БОРТ {bearing}°",
"LOOK PORT {bearing}°": "ПОГЛЯД ЛІВ. БОРТ {bearing}°",
"LOOK ASTERN": "ПОГЛЯД ЗА КОРМУ",
@@ -2870,16 +2894,20 @@
"VOLUME": "ГУЧНІСТЬ",
"Cockpit Radio volume": "Гучність радіо кабіни",
"ESC EXIT": "ESC ВИХІД",
"C TOGGLE": "C ПЕРЕМК.",
"ARROWS LOOK": "СТРІЛКИ ОГЛЯД",
"HOME RESET": "HOME СКИДАННЯ",
"+/− ZOOM": "+/− МАСШТАБ",
"ARROWS TILT": "СТРІЛКИ НАХИЛ",
"HOME NADIR": "HOME НАДИР",
"View switcher": "Перемикач вигляду",
"Exit cockpit and return to full globe view": "Вийти з кабіни та повернутися до вигляду всього глобуса",
"RESET": "СКИНУТИ",
"Exit cockpit view": "Вийти з вигляду кабіни",
"Exit bridge view": "Вийти з вигляду містка",
"Exit orbital view": "Вийти з орбітального вигляду",
"EXIT COCKPIT": "ВИЙТИ З КАБІНИ",
"EXIT BRIDGE": "ВИЙТИ З МІСТКА",
"EXIT ORBIT": "ВИЙТИ З ОРБІТИ",
"Navigation, voice, and visual preset controls": "Керування навігацією, голосом і візуальними пресетами",
"Expand Visual Presets": "Розгорнути візуальні пресети",
"Pin visual presets": "Закріпити візуальні пресети",
@@ -3393,7 +3421,15 @@
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT повідомляє тип і стан кожного об’єкта. Без актуальної копії класи спираються на базу UCS, назви та групи CelesTrak, тож супутник, що відтоді вийшов з ладу, може й далі показуватися робочим.",
"STARLINK ···": "STARLINK ···",
"STARLINK ✕": "STARLINK ✕",
"FWD": "ВПЕРЕД",
"AFT": "НАЗАД",
"RIGHT": "ПРАВОРУЧ",
"LEFT": "ЛІВОРУЧ",
"TRACK UP": "ТРАСА УГОРІ",
"COURSE HOLD · AIS": "УТРИМАННЯ ШЛЯХОВОГО КУТА · AIS",
"Zoom in (+)": "Наблизити (+)",
"Zoom out (−)": "Віддалити (−)",
"ORBITAL VIEW · OFF-NADIR": "ОРБІТАЛЬНИЙ ВИГЛЯД · ВІДХИЛЕННЯ ВІД НАДИРА",
"World time": "Світовий час",
"Back to live": "Повернутися до прямого ефіру",
"Live": "Зараз",
+37 -1
View File
@@ -778,6 +778,28 @@
"Select {mission}": "选择 {mission}",
"{value} KM": "{value} km",
"{value} KM/S": "{value} km/s",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "偏离天底 {angle}° · {offset}",
"ZOOM {zoom}×": "变焦 {zoom}×",
"SWATH {value} KM": "幅宽 {value} km",
"SWATH ≈ {value} KM": "幅宽 ≈ {value} km",
"Orbital view": "轨道视图",
"ORBITAL VIEW · NADIR": "轨道视图 · 天底",
"ORBITAL VELOCITY": "轨道速度",
"SUB-SATELLITE POINT": "星下点",
"NORTH UP": "北向上",
"CURRENTLY OVER": "当前上空",
"{division}, {country}": "{division},{country}",
"{region} · near {place}": "{region} · {place}附近",
"NORAD {id}": "NORAD {id}",
"EPOCH {time}": "历元 {time}",
"TLE": "TLE",
"Show the two-line element set": "显示两行轨道根数",
"Hide the two-line element set": "隐藏两行轨道根数",
"Two-line element set": "两行轨道根数",
"Return the view to nadir": "将视图恢复到天底",
"Return to nadir (Home or 0)": "返回天底(Home 或 0)",
"NADIR": "回到天底",
"Selected Space Mission": "已选太空任务",
"SELECTED SPACE MISSION": "已选太空任务",
"Show all missions": "显示所有任务",
@@ -1678,6 +1700,7 @@
"{label} · COURSE {heading}°": "{label} · 对地航向 {heading}°",
"{label} · HEADING {heading}°": "{label} · 船首向 {heading}°",
"{label} · NO HEADING": "{label} · 无船首向",
"{label} · NADIR VIEW": "{label} · 天底视图",
"Context could not restore every layer; try again": "态势未能恢复所有图层,请重试",
"No selected data layers": "没有已选数据图层",
"{count} data layers could not be cleared": {
@@ -2461,6 +2484,7 @@
"HERE": "此处",
"Aircraft cockpit view": "飞机座舱视图",
"Ship bridge view": "船舶驾驶台视图",
"Satellite orbital view": "卫星轨道视图",
"LOOK STBD {bearing}°": "视线 右舷 {bearing}°",
"LOOK PORT {bearing}°": "视线 左舷 {bearing}°",
"LOOK ASTERN": "视线 船尾",
@@ -2554,16 +2578,20 @@
"VOLUME": "音量",
"Cockpit Radio volume": "座舱电台音量",
"ESC EXIT": "ESC 退出",
"C TOGGLE": "C 切换",
"ARROWS LOOK": "方向键 环视",
"HOME RESET": "HOME 复位",
"+/− ZOOM": "+/− 缩放",
"ARROWS TILT": "方向键 倾斜",
"HOME NADIR": "HOME 天底",
"View switcher": "视图切换",
"Exit cockpit and return to full globe view": "退出座舱并返回完整地球视图",
"RESET": "重置",
"Exit cockpit view": "退出座舱视图",
"Exit bridge view": "退出驾驶台视图",
"Exit orbital view": "退出轨道视图",
"EXIT COCKPIT": "退出座舱",
"EXIT BRIDGE": "退出驾驶台",
"EXIT ORBIT": "退出轨道视图",
"Navigation, voice, and visual preset controls": "导航、语音和视觉预设控件",
"Expand Visual Presets": "展开视觉预设",
"Pin visual presets": "固定视觉预设",
@@ -3165,7 +3193,15 @@
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT 给出每个物体的类型和运行状态。没有最新副本时,分类仅依据 UCS 数据库、名称和 CelesTrak 分组,因此此后已失效的卫星可能仍显示为在轨工作。",
"STARLINK ···": "STARLINK ···",
"STARLINK ✕": "STARLINK ✕",
"FWD": "前方",
"AFT": "后方",
"RIGHT": "右",
"LEFT": "左",
"TRACK UP": "航迹向上",
"COURSE HOLD · AIS": "对地航向保持 · AIS",
"Zoom in (+)": "放大 (+)",
"Zoom out (−)": "缩小 (−)",
"ORBITAL VIEW · OFF-NADIR": "轨道视图 · 偏离天底",
"World time": "世界时间",
"Back to live": "返回实时",
"Live": "实时",
+37 -1
View File
@@ -773,6 +773,28 @@
"Select {mission}": "選擇 {mission}",
"{value} KM": "{value} km",
"{value} KM/S": "{value} km/s",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "偏離天底 {angle}° · {offset}",
"ZOOM {zoom}×": "變焦 {zoom}×",
"SWATH {value} KM": "幅寬 {value} km",
"SWATH ≈ {value} KM": "幅寬 ≈ {value} km",
"Orbital view": "軌道視圖",
"ORBITAL VIEW · NADIR": "軌道視圖 · 天底",
"ORBITAL VELOCITY": "軌道速度",
"SUB-SATELLITE POINT": "星下點",
"NORTH UP": "北向上",
"CURRENTLY OVER": "目前上空",
"{division}, {country}": "{division},{country}",
"{region} · near {place}": "{region} · {place}附近",
"NORAD {id}": "NORAD {id}",
"EPOCH {time}": "曆元 {time}",
"TLE": "TLE",
"Show the two-line element set": "顯示兩行軌道根數",
"Hide the two-line element set": "隱藏兩行軌道根數",
"Two-line element set": "兩行軌道根數",
"Return the view to nadir": "將視圖恢復到天底",
"Return to nadir (Home or 0)": "返回天底(Home 或 0)",
"NADIR": "回到天底",
"Selected Space Mission": "已選太空任務",
"SELECTED SPACE MISSION": "已選太空任務",
"Show all missions": "顯示所有任務",
@@ -1668,6 +1690,7 @@
"{label} · COURSE {heading}°": "{label} · 對地航向 {heading}°",
"{label} · HEADING {heading}°": "{label} · 船首向 {heading}°",
"{label} · NO HEADING": "{label} · 無船首向",
"{label} · NADIR VIEW": "{label} · 天底視圖",
"Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試",
"No selected data layers": "沒有已選數據圖層",
"{count} data layers could not be cleared": {
@@ -2449,6 +2472,7 @@
"HERE": "此處",
"Aircraft cockpit view": "飛機座艙視圖",
"Ship bridge view": "船舶駕駛台視圖",
"Satellite orbital view": "衛星軌道視圖",
"LOOK STBD {bearing}°": "視線 右舷 {bearing}°",
"LOOK PORT {bearing}°": "視線 左舷 {bearing}°",
"LOOK ASTERN": "視線 船尾",
@@ -2542,16 +2566,20 @@
"VOLUME": "音量",
"Cockpit Radio volume": "座艙電臺音量",
"ESC EXIT": "ESC 退出",
"C TOGGLE": "C 切換",
"ARROWS LOOK": "方向鍵 環視",
"HOME RESET": "HOME 復位",
"+/− ZOOM": "+/− 縮放",
"ARROWS TILT": "方向鍵 傾斜",
"HOME NADIR": "HOME 天底",
"View switcher": "視圖切換",
"Exit cockpit and return to full globe view": "退出座艙並返回完整地球視圖",
"RESET": "重置",
"Exit cockpit view": "退出座艙視圖",
"Exit bridge view": "退出駕駛台視圖",
"Exit orbital view": "退出軌道視圖",
"EXIT COCKPIT": "退出座艙",
"EXIT BRIDGE": "退出駕駛台",
"EXIT ORBIT": "退出軌道視圖",
"Navigation, voice, and visual preset controls": "導航、語音和視覺預設控件",
"Expand Visual Presets": "展開視覺預設",
"Pin visual presets": "固定視覺預設",
@@ -3165,7 +3193,15 @@
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT 提供每個物體的類型與運作狀態。沒有最新副本時,分類僅依據 UCS 資料庫、名稱與 CelesTrak 群組,因此此後已失效的衛星可能仍顯示為運作中。",
"STARLINK ···": "STARLINK ···",
"STARLINK ✕": "STARLINK ✕",
"FWD": "前方",
"AFT": "後方",
"RIGHT": "右",
"LEFT": "左",
"TRACK UP": "航跡向上",
"COURSE HOLD · AIS": "對地航向保持 · AIS",
"Zoom in (+)": "放大 (+)",
"Zoom out (−)": "縮小 (−)",
"ORBITAL VIEW · OFF-NADIR": "軌道視圖 · 偏離天底",
"World time": "世界時間",
"Back to live": "返回即時",
"Live": "即時",
+47 -2
View File
@@ -23,6 +23,7 @@
"HDG {value}°",
"Ironbow",
"Landsat 8/9",
"NORAD {id}",
"NVG",
"REC",
"S2 GhostMaps",
@@ -32,12 +33,14 @@
"STARLINK ···",
"STARLINK ✕",
"Sentinel-2",
"TLE",
"WHOT/BHOT",
"acartia.io ↗",
"dB",
"hPa",
"iNaturalist",
"iNaturalist ↗",
"{direction} {angle}°",
"{direction} · {elevation}",
"{source} HTTP {status}",
"▲ {frp} MW"
@@ -118,6 +121,7 @@
"NEWS",
"NNW",
"NOIR",
"NORAD {id}",
"NORMAL",
"NVG",
"NW",
@@ -179,6 +183,7 @@
"TAGS · {tags}",
"TANKER",
"TEMP",
"TLE",
"Talk",
"Tallinn",
"Techno",
@@ -194,6 +199,7 @@
"Washington DC",
"Wind",
"acartia.io ↗",
"ZOOM {zoom}×",
"dB",
"feed status|live",
"fire behavior|Minimal",
@@ -210,7 +216,9 @@
"view band|GLOBAL",
"{city} CCTV",
"{county} County",
"{direction} {angle}°",
"{direction} · {elevation}",
"{division}, {country}",
"{source} HTTP {status}",
"{speed} kn",
"{type} SPD: {speed} HDG: {heading}",
@@ -288,6 +296,7 @@
"NE",
"NNE",
"NOIR",
"NORAD {id}",
"NORMAL",
"NVG",
"Noir",
@@ -323,6 +332,7 @@
"Sonar",
"Soul",
"TELECOM",
"TLE",
"Techno",
"Trance",
"USB {rate} msg/s",
@@ -330,6 +340,7 @@
"VS",
"WHOT/BHOT",
"WX",
"ZOOM {zoom}×",
"audio {state}",
"dB",
"fire confidence filter|Nominal+",
@@ -349,7 +360,9 @@
"panel",
"view band|GLOBAL",
"voice|ERROR",
"{direction} {angle}°",
"{direction} · {elevation}",
"{division}, {country}",
"{rate} msg/s",
"{speed} kn",
"{type} SPD: {speed} HDG: {heading}",
@@ -462,6 +475,7 @@
"NE",
"NNE",
"NOIR",
"NORAD {id}",
"NORMAL",
"NVG",
"New York",
@@ -519,6 +533,7 @@
"Suggestions",
"TAGS · {tags}",
"TEMP",
"TLE",
"TYPE",
"Tallinn",
"Techno",
@@ -530,6 +545,7 @@
"VS",
"WHOT/BHOT",
"acartia.io ↗",
"ZOOM {zoom}×",
"audio {state}",
"dB",
"ft",
@@ -554,7 +570,9 @@
"{count} MSGS",
"{count} SOURCES",
"{count} messages",
"{direction} {angle}°",
"{direction} · {elevation}",
"{division}, {country}",
"{n} cyclones",
"{n} satellites",
"{rate} msg/s",
@@ -648,6 +666,7 @@
"NE",
"NNE",
"NOIR",
"NORAD {id}",
"NVG",
"New York",
"Noir",
@@ -694,6 +713,7 @@
"TELECOM",
"TEMP",
"TIMEOUT",
"TLE",
"Talk",
"Tallinn",
"Techno",
@@ -711,6 +731,7 @@
"WHOT/BHOT",
"Washington DC",
"World",
"ZOOM {zoom}×",
"audio {state}",
"dB",
"feed status|live",
@@ -732,7 +753,9 @@
"{count} MSGS",
"{count} feeds live",
"{count} scenes",
"{direction} {angle}°",
"{direction} · {elevation}",
"{division}, {country}",
"{rate} msg/s",
"{scene} / {pack}: {status}. File: {path}. {attribution} · {license}",
"{source} HTTP {status}",
@@ -780,6 +803,7 @@
"Ironbow",
"Landsat 8/9",
"MSG/S",
"NORAD {id}",
"NVG",
"POS: {time}Z",
"REC",
@@ -789,6 +813,7 @@
"STARLINK ···",
"STARLINK ✕",
"Sentinel-2",
"TLE",
"USB {rate} msg/s",
"WHOT/BHOT",
"acartia.io ↗",
@@ -802,6 +827,7 @@
"kt",
"mph",
"{city} CCTV",
"{direction} {angle}°",
"{direction} · {elevation}",
"{rate} msg/s",
"{source} HTTP {status}",
@@ -882,6 +908,7 @@
"NE",
"NNE",
"NOIR",
"NORAD {id}",
"NORMAL",
"NVG",
"Noir",
@@ -917,6 +944,7 @@
"TAGS · {tags}",
"TELECOM",
"TEMP",
"TLE",
"Tallinn",
"Techno",
"Trance",
@@ -927,6 +955,7 @@
"VS",
"WHOT/BHOT",
"WX",
"ZOOM {zoom}×",
"dB",
"fire confidence filter|Nominal+",
"fire confidence|nominal",
@@ -941,7 +970,9 @@
"mph",
"nominal",
"view band|GLOBAL",
"{direction} {angle}°",
"{direction} · {elevation}",
"{division}, {country}",
"{rate} msg/s",
"{type} SPD: {speed} HDG: {heading}",
"{value} KM",
@@ -969,6 +1000,7 @@
"Ironbow",
"Landsat 8/9",
"NAV",
"NORAD {id}",
"NVG",
"R&B",
"REC",
@@ -977,12 +1009,15 @@
"STARLINK",
"STARLINK ···",
"STARLINK ✕",
"TLE",
"VS",
"WHOT/BHOT",
"acartia.io ↗",
"iNaturalist",
"iNaturalist ↗",
"{direction} · {elevation}"
"{direction} · {elevation}",
"{direction} {angle}°",
"{division}, {country}"
],
"uk": [
"AIS: --",
@@ -994,17 +1029,21 @@
"FLIR",
"Ironbow",
"Landsat 8/9",
"NORAD {id}",
"NVG",
"REC",
"S2 GhostMaps",
"STARLINK",
"STARLINK ···",
"STARLINK ✕",
"TLE",
"WHOT/BHOT",
"acartia.io ↗",
"iNaturalist",
"iNaturalist ↗",
"{direction} · {elevation}"
"{direction} · {elevation}",
"{direction} {angle}°",
"{division}, {country}"
],
"zh-CN": [
"1090 MHz",
@@ -1030,6 +1069,7 @@
"KTS",
"Landsat 8/9",
"MM",
"NORAD {id}",
"NVG",
"REC",
"S2 GhostMaps",
@@ -1039,6 +1079,7 @@
"STARLINK ···",
"STARLINK ✕",
"Sentinel-2",
"TLE",
"Techno",
"Trance",
"USB {rate} msg/s",
@@ -1056,6 +1097,7 @@
"kts",
"mph",
"{city} CCTV",
"{direction} {angle}°",
"{direction} · {elevation}",
"{rate} msg/s",
"{source} HTTP {status}",
@@ -1097,6 +1139,7 @@
"KTS",
"Landsat 8/9",
"MM",
"NORAD {id}",
"NVG",
"REC",
"S2 GhostMaps",
@@ -1106,6 +1149,7 @@
"STARLINK ···",
"STARLINK ✕",
"Sentinel-2",
"TLE",
"Techno",
"Trance",
"USB {rate} msg/s",
@@ -1123,6 +1167,7 @@
"kts",
"mph",
"{city} CCTV",
"{direction} {angle}°",
"{direction} · {elevation}",
"{rate} msg/s",
"{source} HTTP {status}",
+2
View File
@@ -124,6 +124,8 @@ export function createCatalog({ state: layerState, services, parts, source }) {
satrec,
group: 'dense',
launchYear: tleLineLaunchYear(entry.line1),
line1: entry.line1,
line2: entry.line2,
// The dense feed is CelesTrak's Starlink group.
satelliteClass: 'starlink',
ucs: null,
+449 -1
View File
@@ -231,6 +231,13 @@ export function classifySatellite({
return 'probes';
}
/** Whether a class may be ridden in the Cockpit's orbital view. */
export function satelliteClassEnterable(satelliteClass) {
return (
SATELLITE_CLASS_IDS.includes(satelliteClass) && satelliteClass !== 'debris'
);
}
/**
* Merge a partial class-visibility request into the current choice.
* Unknown keys and non-boolean values are ignored.
@@ -250,6 +257,443 @@ export function mergeClassVisibility(
return next;
}
// ISO 3166-1 alpha-2 code → short English name, for every owner below.
const COUNTRY_NAMES = Object.freeze({
AE: 'United Arab Emirates',
AM: 'Armenia',
AO: 'Angola',
AR: 'Argentina',
AT: 'Austria',
AU: 'Australia',
AZ: 'Azerbaijan',
BD: 'Bangladesh',
BE: 'Belgium',
BG: 'Bulgaria',
BH: 'Bahrain',
BM: 'Bermuda',
BO: 'Bolivia',
BR: 'Brazil',
BT: 'Bhutan',
BW: 'Botswana',
BY: 'Belarus',
CA: 'Canada',
CH: 'Switzerland',
CL: 'Chile',
CN: 'China',
CO: 'Colombia',
CR: 'Costa Rica',
CZ: 'Czechia',
DE: 'Germany',
DJ: 'Djibouti',
DK: 'Denmark',
DZ: 'Algeria',
EC: 'Ecuador',
EE: 'Estonia',
EG: 'Egypt',
ES: 'Spain',
ET: 'Ethiopia',
FI: 'Finland',
FR: 'France',
GB: 'United Kingdom',
GH: 'Ghana',
GR: 'Greece',
GT: 'Guatemala',
HR: 'Croatia',
HU: 'Hungary',
ID: 'Indonesia',
IE: 'Ireland',
IL: 'Israel',
IN: 'India',
IQ: 'Iraq',
IR: 'Iran',
IT: 'Italy',
JO: 'Jordan',
JP: 'Japan',
KE: 'Kenya',
KP: 'North Korea',
KR: 'South Korea',
KW: 'Kuwait',
KZ: 'Kazakhstan',
LA: 'Laos',
LK: 'Sri Lanka',
LT: 'Lithuania',
LU: 'Luxembourg',
MA: 'Morocco',
MC: 'Monaco',
MD: 'Moldova',
ME: 'Montenegro',
MM: 'Myanmar',
MN: 'Mongolia',
MU: 'Mauritius',
MX: 'Mexico',
MY: 'Malaysia',
NG: 'Nigeria',
NL: 'Netherlands',
NO: 'Norway',
NP: 'Nepal',
NZ: 'New Zealand',
PE: 'Peru',
PH: 'Philippines',
PK: 'Pakistan',
PL: 'Poland',
PT: 'Portugal',
PY: 'Paraguay',
QA: 'Qatar',
RO: 'Romania',
RU: 'Russia',
RW: 'Rwanda',
SA: 'Saudi Arabia',
SB: 'Solomon Islands',
SD: 'Sudan',
SE: 'Sweden',
SG: 'Singapore',
SI: 'Slovenia',
SN: 'Senegal',
TH: 'Thailand',
TM: 'Turkmenistan',
TN: 'Tunisia',
TR: 'Türkiye',
TW: 'Taiwan',
UA: 'Ukraine',
US: 'United States',
UY: 'Uruguay',
VA: 'Vatican City',
VE: 'Venezuela',
VN: 'Vietnam',
ZA: 'South Africa',
ZW: 'Zimbabwe',
});
// UCS "Country of Operator/Owner" → ISO code. Joint owners ("USA/Japan") and
// organisations are not countries and resolve through ORGANISATIONS or as a
// joint label.
const UCS_COUNTRIES = Object.freeze({
Algeria: 'DZ',
Angola: 'AO',
Argentina: 'AR',
Australia: 'AU',
Austria: 'AT',
Azerbaijan: 'AZ',
Bangladesh: 'BD',
Belarus: 'BY',
Belgium: 'BE',
Bolivia: 'BO',
Brazil: 'BR',
Bulgaria: 'BG',
Canada: 'CA',
Chile: 'CL',
China: 'CN',
Colombia: 'CO',
'Czech Republic': 'CZ',
Denmark: 'DK',
Ecuador: 'EC',
Egypt: 'EG',
Estonia: 'EE',
Ethiopia: 'ET',
Finland: 'FI',
France: 'FR',
Germany: 'DE',
Greece: 'GR',
Hungary: 'HU',
India: 'IN',
Indonesia: 'ID',
Iran: 'IR',
Iraq: 'IQ',
Israel: 'IL',
Italy: 'IT',
Japan: 'JP',
Jordan: 'JO',
Kazakhstan: 'KZ',
Kenya: 'KE',
Kuwait: 'KW',
Laos: 'LA',
Lithuania: 'LT',
Luxembourg: 'LU',
Malaysia: 'MY',
Mexico: 'MX',
Monaco: 'MC',
Morocco: 'MA',
Nepal: 'NP',
Netherlands: 'NL',
'New Zealand': 'NZ',
Nigeria: 'NG',
Norway: 'NO',
Pakistan: 'PK',
Peru: 'PE',
Poland: 'PL',
Russia: 'RU',
'Saudi Arabia': 'SA',
Sinapore: 'SG',
Singapore: 'SG',
Slovenia: 'SI',
'South Africa': 'ZA',
'South Korea': 'KR',
Spain: 'ES',
Sudan: 'SD',
Sweden: 'SE',
Switzerland: 'CH',
Taiwan: 'TW',
Thailand: 'TH',
Tunisia: 'TN',
Turkey: 'TR',
UK: 'GB',
USA: 'US',
Ukraine: 'UA',
'United Arab Emirates': 'AE',
'United Kingdom': 'GB',
Venezuela: 'VE',
Vietnam: 'VN',
});
// CelesTrak SATCAT OWNER code → ISO code (https://celestrak.org/satcat/sources.php).
// CIS (former USSR) is filed under Russia, its continuing state.
const SATCAT_COUNTRIES = Object.freeze({
ALG: 'DZ',
ANG: 'AO',
ARGN: 'AR',
ARM: 'AM',
ASRA: 'AT',
AUS: 'AU',
AZER: 'AZ',
BEL: 'BE',
BELA: 'BY',
BERM: 'BM',
BGD: 'BD',
BHR: 'BH',
BHUT: 'BT',
BOL: 'BO',
BRAZ: 'BR',
BUL: 'BG',
BWA: 'BW',
CA: 'CA',
CHLE: 'CL',
CIS: 'RU',
COL: 'CO',
CRI: 'CR',
CZCH: 'CZ',
DEN: 'DK',
DJI: 'DJ',
ECU: 'EC',
EGYP: 'EG',
EST: 'EE',
ETH: 'ET',
FIN: 'FI',
FR: 'FR',
GER: 'DE',
GHA: 'GH',
GREC: 'GR',
GUAT: 'GT',
HRV: 'HR',
HUN: 'HU',
IND: 'IN',
INDO: 'ID',
IRAN: 'IR',
IRAQ: 'IQ',
IRL: 'IE',
ISRA: 'IL',
ISRO: 'IN',
IT: 'IT',
JPN: 'JP',
KAZ: 'KZ',
KEN: 'KE',
LAOS: 'LA',
LKA: 'LK',
LTU: 'LT',
LUXE: 'LU',
MA: 'MA',
MALA: 'MY',
MCO: 'MC',
MDA: 'MD',
MEX: 'MX',
MMR: 'MM',
MNE: 'ME',
MNG: 'MN',
MUS: 'MU',
NETH: 'NL',
NIG: 'NG',
NKOR: 'KP',
NOR: 'NO',
NPL: 'NP',
NZ: 'NZ',
PAKI: 'PK',
PERU: 'PE',
POL: 'PL',
POR: 'PT',
PRC: 'CN',
PRY: 'PY',
QAT: 'QA',
ROC: 'TW',
ROM: 'RO',
RP: 'PH',
RWA: 'RW',
SAFR: 'ZA',
SAUD: 'SA',
SDN: 'SD',
SEN: 'SN',
SING: 'SG',
SKOR: 'KR',
SLB: 'SB',
SPN: 'ES',
SVN: 'SI',
SWED: 'SE',
SWTZ: 'CH',
THAI: 'TH',
TUN: 'TN',
TURK: 'TR',
UAE: 'AE',
UK: 'GB',
UKR: 'UA',
URY: 'UY',
US: 'US',
VAT: 'VA',
VENZ: 'VE',
VTNM: 'VN',
ZWE: 'ZW',
});
// Organisations, by SATCAT code and by UCS name: no flag, a short label.
const ORGANISATIONS = Object.freeze({
AB: 'Arabsat',
ABS: 'ABS',
AC: 'AsiaSat',
ESA: 'ESA',
ESRO: 'ESRO',
EUME: 'EUMETSAT',
EUTE: 'Eutelsat',
GLOB: 'Globalstar',
IM: 'Inmarsat',
IRID: 'Iridium',
ISS: 'ISS partners',
ITSO: 'Intelsat',
NATO: 'NATO',
NICO: 'New ICO',
O3B: 'O3b',
ORB: 'ORBCOMM',
RASC: 'RascomStar-QAF',
SEAL: 'Sea Launch',
SES: 'SES',
Multinational: 'Multinational',
});
// SATCAT codes for joint ownership, as their parts.
const SATCAT_JOINT = Object.freeze({
CHBZ: ['CN', 'BR'],
CHTU: ['CN', 'TR'],
FGER: ['FR', 'DE'],
FRIT: ['FR', 'IT'],
GRSA: ['GR', 'SA'],
PRES: ['CN', 'ESA'],
SGJP: ['SG', 'JP'],
STCT: ['SG', 'TW'],
TMMC: ['TM', 'MC'],
USBZ: ['US', 'BR'],
});
function country(code) {
return COUNTRY_NAMES[code]
? { countryCode: code, label: COUNTRY_NAMES[code] }
: null;
}
function joint(parts) {
const labels = parts.map(
(part) => COUNTRY_NAMES[part] || ORGANISATIONS[part] || part,
);
return { countryCode: null, label: labels.join(' / ') };
}
/**
* Normalise a UCS owner country ("USA", "China/France", "ESA",
* "Multinational") to `{ countryCode, label }`.
* @param {string|null|undefined} text
* @returns {{countryCode: string|null, label: string}|null}
*/
export function ownerFromUcs(text) {
const value = String(text || '').trim();
if (!value) return null;
if (UCS_COUNTRIES[value]) return country(UCS_COUNTRIES[value]);
if (ORGANISATIONS[value])
return { countryCode: null, label: ORGANISATIONS[value] };
if (value.includes('/'))
return joint(
value
.split('/')
.map((part) => part.trim())
.map((part) => UCS_COUNTRIES[part] || part),
);
return { countryCode: null, label: value };
}
/**
* Normalise a CelesTrak SATCAT OWNER code ("US", "PRC", "CIS", "ITSO") to
* `{ countryCode, label }`; null for TBD, UNK and blanks.
* @param {string|null|undefined} code
* @returns {{countryCode: string|null, label: string}|null}
*/
export function ownerFromSatcat(code) {
const value = String(code || '')
.trim()
.toUpperCase();
if (!value || value === 'TBD' || value === 'UNK') return null;
if (SATCAT_COUNTRIES[value]) return country(SATCAT_COUNTRIES[value]);
if (ORGANISATIONS[value])
return { countryCode: null, label: ORGANISATIONS[value] };
if (SATCAT_JOINT[value]) return joint(SATCAT_JOINT[value]);
return null;
}
/**
* Who owns a satellite: UCS names the operator's country, SATCAT the
* registering owner; UCS wins where both exist. Starlink without either is
* SpaceX's, so United States.
* @returns {{countryCode: string|null, label: string}|null}
*/
export function satelliteOwner({
ucs = null,
satcat = null,
satelliteClass,
} = {}) {
return (
ownerFromUcs(ucs?.operatorCountry) ||
ownerFromSatcat(satcat?.owner) ||
(satelliteClass === 'starlink' ? country('US') : null)
);
}
/** Who operates a satellite: UCS's operator, or SpaceX for Starlink. */
export function satelliteOperator({ ucs = null, satelliteClass } = {}) {
if (ucs?.operator) return ucs.operator;
return satelliteClass === 'starlink' ? 'SpaceX' : null;
}
/**
* The international designator (COSPAR id) from TLE line 1, columns 10–17:
* `98067A` → `1998-067A`. Two-digit years below 57 are 20xx.
* @param {string|null|undefined} line1
* @returns {string|null}
*/
export function cosparIdFromTleLine1(line1) {
const field = String(line1 || '')
.slice(9, 17)
.trim();
const match = /^(\d{2})(\d{3})([A-Z]{1,3})$/.exec(field);
if (!match) return null;
const yy = Number(match[1]);
return `${yy < 57 ? 2000 + yy : 1900 + yy}-${match[2]}${match[3]}`;
}
/**
* The TLE's epoch as epoch milliseconds, from satellite.js's Julian date.
* @param {{jdsatepoch?: number, jdsatepochF?: number}|null|undefined} satrec
* @returns {number|null}
*/
export function tleEpochMs(satrec) {
const jd = Number(satrec?.jdsatepoch) + (Number(satrec?.jdsatepochF) || 0);
return Number.isFinite(jd) && jd > 0
? Math.round((jd - 2440587.5) * 86_400_000)
: null;
}
/** Split one RFC 4180 CSV line (quoted fields may hold commas). */
function csvFields(line) {
const fields = [];
@@ -277,7 +721,7 @@ function csvFields(line) {
* Parse CelesTrak SATCAT CSV into records keyed by NORAD number.
* @param {string} text `records.php?…&FORMAT=CSV` body.
* @returns {Map<number, {objectType: string, opsStatus: string,
* launchDate: string|null}>}
* owner: string, cosparId: string|null, launchDate: string|null}>}
*/
export function parseSatcatCsv(text) {
const records = new Map();
@@ -291,6 +735,8 @@ export function parseSatcatCsv(text) {
norad: at('NORAD_CAT_ID'),
type: at('OBJECT_TYPE'),
status: at('OPS_STATUS_CODE'),
owner: at('OWNER'),
id: at('OBJECT_ID'),
launch: at('LAUNCH_DATE'),
};
if (col.norad < 0 || col.type < 0) return records;
@@ -302,6 +748,8 @@ export function parseSatcatCsv(text) {
records.set(noradId, {
objectType: text(col.type).toUpperCase(),
opsStatus: text(col.status),
owner: text(col.owner),
cosparId: text(col.id) || null,
launchDate: text(col.launch) || null,
});
}
+96 -110
View File
@@ -1,20 +1,28 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { twoline2satrec } from 'satellite.js';
import {
DEFAULT_SATELLITE_CLASSES,
PRESUMED_DEAD_AFTER_YEARS,
SATELLITE_CLASS_DISPLAY,
SATELLITE_CLASS_IDS,
classifySatellite,
cosparIdFromTleLine1,
mergeClassVisibility,
ownerFromSatcat,
ownerFromUcs,
parseSatcatCsv,
satelliteClassEnterable,
satelliteOperator,
satelliteOwner,
tleEpochMs,
} from './classes.js';
import { CATALOG_GROUPS } from './policy.js';
const NOW = 2026;
const classify = (subject) => classifySatellite({ nowYear: NOW, ...subject });
test('six classes, every one but Starlink on by default', () => {
test('six classes, with stations and imaging on by default and the rest off', () => {
assert.deepEqual(SATELLITE_CLASS_IDS, [
'stations',
'imaging',
@@ -37,28 +45,16 @@ test('six classes, every one but Starlink on by default', () => {
test('SATCAT object type and status file debris first, overruling UCS', () => {
const imaging = { purpose: 'Earth Observation' };
for (const objectType of ['R/B', 'DEB', 'UNK'])
assert.equal(
classify({ name: 'X', satcat: { objectType, opsStatus: '' } }),
'debris',
objectType,
);
assert.equal(classify({ name: 'X', satcat: { objectType, opsStatus: '' } }), 'debris', objectType);
// A satellite UCS listed in 2023 that SATCAT now calls dead or decayed.
for (const opsStatus of ['-', 'D'])
assert.equal(
classify({
name: 'OLDSAT',
ucs: imaging,
satcat: { objectType: 'PAY', opsStatus },
}),
classify({ name: 'OLDSAT', ucs: imaging, satcat: { objectType: 'PAY', opsStatus } }),
'debris',
opsStatus,
);
assert.equal(
classify({
name: 'OLDSAT',
ucs: imaging,
satcat: { objectType: 'PAY', opsStatus: '+' },
}),
classify({ name: 'OLDSAT', ucs: imaging, satcat: { objectType: 'PAY', opsStatus: '+' } }),
'imaging',
);
// SATCAT calls a new launch's objects UNK until it names them: only an
@@ -86,33 +82,15 @@ test('SATCAT object type and status file debris first, overruling UCS', () => {
test('the ISS is a station whatever else says', () => {
assert.equal(
classify({
noradId: 25544,
name: 'ISS (ZARYA)',
group: 'visual',
satcat: { objectType: 'DEB' },
}),
classify({ noradId: 25544, name: 'ISS (ZARYA)', group: 'visual', satcat: { objectType: 'DEB' } }),
'stations',
);
});
test('stations: crewed stations and visiting vehicles by name', () => {
assert.equal(
classify({ name: 'CSS (TIANHE)', group: 'stations' }),
'stations',
);
assert.equal(
classify({ name: 'ISS (NAUKA)', group: 'stations' }),
'stations',
);
for (const name of [
'SHENZHOU-21',
'TIANZHOU-9',
'SOYUZ-MS 28',
'PROGRESS-MS 31',
'CREW DRAGON 11',
'CYGNUS NG-23',
])
assert.equal(classify({ name: 'CSS (TIANHE)', group: 'stations' }), 'stations');
assert.equal(classify({ name: 'ISS (NAUKA)', group: 'stations' }), 'stations');
for (const name of ['SHENZHOU-21', 'TIANZHOU-9', 'SOYUZ-MS 28', 'PROGRESS-MS 31', 'CREW DRAGON 11', 'CYGNUS NG-23'])
assert.equal(classify({ name, group: 'visual' }), 'stations', name);
});
@@ -133,11 +111,7 @@ test('UCS purpose decides imaging, telecom and probes', () => {
['Unknown', 'probes'],
];
for (const [purpose, expected] of table)
assert.equal(
classify({ name: 'UNNAMED-1', group: 'visual', ucs: { purpose } }),
expected,
purpose,
);
assert.equal(classify({ name: 'UNNAMED-1', group: 'visual', ucs: { purpose } }), expected, purpose);
});
test('satellites launched after the UCS snapshot are classed by name', () => {
@@ -156,42 +130,20 @@ test('satellites launched after the UCS snapshot are classed by name', () => {
['STARLINK-34012', 'starlink'],
];
for (const [name, expected] of table)
assert.equal(
classify({ name, group: 'visual', launchYear: 2025 }),
expected,
name,
);
assert.equal(classify({ name, group: 'visual', launchYear: 2025 }), expected, name);
});
test('the group the TLE came in is the last resort', () => {
assert.equal(
classify({ name: 'UNNAMED-1', group: 'resource', launchYear: 2024 }),
'imaging',
);
assert.equal(
classify({ name: 'UNNAMED-1', group: 'geo', launchYear: 2024 }),
'telecom',
);
assert.equal(
classify({ name: 'UNNAMED-1', group: 'science', launchYear: 2024 }),
'probes',
);
assert.equal(classify({ name: 'UNNAMED-1', group: 'resource', launchYear: 2024 }), 'imaging');
assert.equal(classify({ name: 'UNNAMED-1', group: 'geo', launchYear: 2024 }), 'telecom');
assert.equal(classify({ name: 'UNNAMED-1', group: 'science', launchYear: 2024 }), 'probes');
assert.equal(classify({ name: 'STARLINK-1', group: 'dense' }), 'starlink');
assert.equal(
classify({ name: 'UNNAMED-1', group: 'visual', launchYear: 2024 }),
'probes',
);
assert.equal(classify({ name: 'UNNAMED-1', group: 'visual', launchYear: 2024 }), 'probes');
});
test('CubeSats deployed from the ISS are not stations', () => {
// They ride in CelesTrak's stations group under the ISS's 1998 designator.
for (const name of [
'DUPLEX',
'KNACKSAT-2',
'GXIBA-1',
'LEOPARD',
'HMU-SAT2',
]) {
for (const name of ['DUPLEX', 'KNACKSAT-2', 'GXIBA-1', 'LEOPARD', 'HMU-SAT2']) {
assert.equal(
classify({ name, group: 'stations', launchYear: 1998 }),
'probes',
@@ -209,11 +161,7 @@ test('CubeSats deployed from the ISS are not stations', () => {
);
}
assert.equal(
classify({
name: 'UNNAMED-1',
group: 'stations',
ucs: { purpose: 'Earth Observation' },
}),
classify({ name: 'UNNAMED-1', group: 'stations', ucs: { purpose: 'Earth Observation' } }),
'imaging',
);
});
@@ -226,53 +174,89 @@ test('an unknown old payload is presumed dead; a recognisable one is not', () =>
'without a SATCAT record (a failed read) nothing is presumed',
);
assert.equal(
classify({
name: 'ATLAS CENTAUR 2',
group: 'visual',
launchYear: 1963,
satcat: { objectType: 'PAY', opsStatus: '' },
}),
classify({ name: 'ATLAS CENTAUR 2', group: 'visual', launchYear: 1963, satcat: { objectType: 'PAY', opsStatus: '' } }),
'debris',
'a blank SATCAT status is no evidence of life',
);
assert.equal(classify({ name: 'UNNAMED-1', group: 'visual', launchYear: old + 1 }), 'probes');
assert.equal(classify({ name: 'NOAA 15', group: 'weather', launchYear: 1998 }), 'imaging');
assert.equal(
classify({ name: 'UNNAMED-1', group: 'visual', launchYear: old + 1 }),
'probes',
);
assert.equal(
classify({ name: 'NOAA 15', group: 'weather', launchYear: 1998 }),
'imaging',
);
assert.equal(
classify({
name: 'UNNAMED-1',
group: 'visual',
launchYear: 1990,
satcat: { objectType: 'PAY', opsStatus: '+' },
}),
classify({ name: 'UNNAMED-1', group: 'visual', launchYear: 1990, satcat: { objectType: 'PAY', opsStatus: '+' } }),
'probes',
'SATCAT says it works',
);
});
test('only debris is refused the Cockpit', () => {
for (const id of SATELLITE_CLASS_IDS)
assert.equal(satelliteClassEnterable(id), id !== 'debris', id);
assert.equal(satelliteClassEnterable('nonsense'), false);
});
test('class visibility merges booleans and ignores everything else', () => {
const next = mergeClassVisibility({
telecom: true,
imaging: false,
debris: 'yes',
bogus: true,
});
assert.deepEqual(next, {
...DEFAULT_SATELLITE_CLASSES,
telecom: true,
imaging: false,
});
assert.deepEqual(mergeClassVisibility(null), {
...DEFAULT_SATELLITE_CLASSES,
});
const next = mergeClassVisibility({ telecom: true, imaging: false, debris: 'yes', bogus: true });
assert.deepEqual(next, { ...DEFAULT_SATELLITE_CLASSES, telecom: true, imaging: false });
assert.deepEqual(mergeClassVisibility(null), { ...DEFAULT_SATELLITE_CLASSES });
assert.equal(Object.hasOwn(next, 'bogus'), false);
});
test('UCS owner countries normalise to ISO codes, organisations and joint owners', () => {
assert.deepEqual(ownerFromUcs('USA'), { countryCode: 'US', label: 'United States' });
assert.deepEqual(ownerFromUcs('United Kingdom'), { countryCode: 'GB', label: 'United Kingdom' });
assert.deepEqual(ownerFromUcs('Sinapore'), { countryCode: 'SG', label: 'Singapore' }, 'UCS typo');
assert.deepEqual(ownerFromUcs('Turkey'), { countryCode: 'TR', label: 'Türkiye' });
assert.deepEqual(ownerFromUcs('ESA'), { countryCode: null, label: 'ESA' });
assert.deepEqual(ownerFromUcs('Multinational'), { countryCode: null, label: 'Multinational' });
assert.deepEqual(ownerFromUcs('China/France'), { countryCode: null, label: 'China / France' });
assert.deepEqual(ownerFromUcs('United Kingdom/ESA'), { countryCode: null, label: 'United Kingdom / ESA' });
assert.equal(ownerFromUcs(''), null);
assert.equal(ownerFromUcs(null), null);
});
test('SATCAT owner codes normalise the same way', () => {
assert.deepEqual(ownerFromSatcat('US'), { countryCode: 'US', label: 'United States' });
assert.deepEqual(ownerFromSatcat('PRC'), { countryCode: 'CN', label: 'China' });
assert.deepEqual(ownerFromSatcat('CIS'), { countryCode: 'RU', label: 'Russia' }, 'former USSR');
assert.deepEqual(ownerFromSatcat('ISRO'), { countryCode: 'IN', label: 'India' });
assert.deepEqual(ownerFromSatcat('itso'), { countryCode: null, label: 'Intelsat' });
assert.deepEqual(ownerFromSatcat('EUME'), { countryCode: null, label: 'EUMETSAT' });
assert.deepEqual(ownerFromSatcat('PRES'), { countryCode: null, label: 'China / ESA' });
assert.deepEqual(ownerFromSatcat('TMMC'), { countryCode: null, label: 'Turkmenistan / Monaco' });
for (const code of ['TBD', 'UNK', '', null, 'ZZZZ']) assert.equal(ownerFromSatcat(code), null, String(code));
});
test('owner prefers UCS, falls back to SATCAT, and Starlink is American', () => {
assert.deepEqual(
satelliteOwner({ ucs: { operatorCountry: 'ESA' }, satcat: { owner: 'FR' } }),
{ countryCode: null, label: 'ESA' },
);
assert.deepEqual(satelliteOwner({ satcat: { owner: 'FR' } }), { countryCode: 'FR', label: 'France' });
assert.deepEqual(satelliteOwner({ satelliteClass: 'starlink' }), { countryCode: 'US', label: 'United States' });
assert.equal(satelliteOwner({}), null);
assert.equal(satelliteOperator({ ucs: { operator: 'Planet Labs, Inc.' } }), 'Planet Labs, Inc.');
assert.equal(satelliteOperator({ satelliteClass: 'starlink' }), 'SpaceX');
assert.equal(satelliteOperator({ satelliteClass: 'imaging' }), null);
});
const ISS_LINE1 = '1 25544U 98067A 24001.50000000 .00016717 00000-0 30370-3 0 9993';
const ISS_LINE2 = '2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579432323';
test('COSPAR ids come from TLE line 1, across the 1957 century pivot', () => {
assert.equal(cosparIdFromTleLine1(ISS_LINE1), '1998-067A');
assert.equal(cosparIdFromTleLine1(ISS_LINE1.replace('98067A ', '24123BC ')), '2024-123BC');
assert.equal(cosparIdFromTleLine1(ISS_LINE1.replace('98067A ', '57001A ')), '1957-001A');
assert.equal(cosparIdFromTleLine1(ISS_LINE1.replace('98067A ', ' ')), null);
assert.equal(cosparIdFromTleLine1(null), null);
});
test('the TLE epoch is read as epoch milliseconds', () => {
const satrec = twoline2satrec(ISS_LINE1, ISS_LINE2);
// Day 001.5 of 2024: 1 January 2024, 12:00 UTC.
assert.ok(Math.abs(tleEpochMs(satrec) - Date.UTC(2024, 0, 1, 12)) < 1000);
assert.equal(tleEpochMs(null), null);
assert.equal(tleEpochMs({}), null);
});
test('SATCAT CSV parses, quoted commas included, keyed by NORAD id', () => {
const csv = [
'OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE,OWNER,LAUNCH_DATE',
@@ -286,6 +270,8 @@ test('SATCAT CSV parses, quoted commas included, keyed by NORAD id', () => {
assert.deepEqual(records.get(25544), {
objectType: 'PAY',
opsStatus: '+',
owner: 'ISS',
cosparId: '1998-067A',
launchDate: '1998-11-20',
});
assert.equal(records.get(733).objectType, 'R/B');
+326
View File
@@ -0,0 +1,326 @@
import * as Cesium from 'cesium';
import { satelliteClassText } from '../../data/satelliteClass.js';
import {
classifySatellite,
cosparIdFromTleLine1,
satelliteClassEnterable,
satelliteOperator,
satelliteOwner,
tleEpochMs,
} from './classes.js';
import { ISS_NORAD } from './policy.js';
/** The layer id a satellite subject carries through Cockpit events and state. */
export const SATELLITE_COCKPIT_LAYER_ID = 'satellites';
/**
* While the Cockpit rides a satellite, every other orbit ring (the ISS ring
* included) draws at this fraction of its normal alpha, so the rings stay
* legible without competing with the nadir picture.
*/
export const COCKPIT_ORBIT_DIM_FACTOR = 0.3;
/**
* Write an orbit ring's alpha as a multiple of the alpha it was built with.
* The ring's per-instance attributes exist only once Cesium has updated the
* primitive; until then nothing is written and the next tick retries.
* @param {{primitive: object, instanceId: string, baseColor: Cesium.Color,
* baseDepthFailColor: Cesium.Color, alphaFactor: number}} path
* @param {number} factor 1 restores the built colours exactly.
* @returns {boolean} True when the ring now carries `factor`.
*/
export function applyOrbitPathAlpha(path, factor) {
if (!path?.primitive || !path.baseColor) return false;
if ((path.alphaFactor ?? 1) === factor) return true;
let attributes;
try {
attributes = path.primitive.getGeometryInstanceAttributes?.(
path.instanceId,
);
} catch {
return false;
}
if (!attributes) return false;
const scaled = (color) => color.withAlpha(color.alpha * factor);
attributes.color = Cesium.ColorGeometryInstanceAttribute.toValue(
factor === 1 ? path.baseColor : scaled(path.baseColor),
);
attributes.depthFailColor = Cesium.ColorGeometryInstanceAttribute.toValue(
factor === 1 ? path.baseDepthFailColor : scaled(path.baseDepthFailColor),
);
path.alphaFactor = factor;
return true;
}
/**
* Who and what a catalog entry is: class, COSPAR id, TLE, owner, operator.
* Fixed for the entry's life, so it is worked out once and kept on it.
* @param {number} noradId
* @param {object} sat Catalog entry.
*/
function satelliteIdentity(noradId, sat) {
if (sat.cockpitIdentity) return sat.cockpitIdentity;
const satelliteClass =
sat.satelliteClass ||
classifySatellite({ noradId, name: sat.name, group: sat.group });
const epochMs = tleEpochMs(sat.satrec);
const identity = Object.freeze({
satelliteClass,
cosparId: sat.satcat?.cosparId || cosparIdFromTleLine1(sat.line1),
tle:
sat.line1 && sat.line2 && epochMs !== null
? Object.freeze({ line1: sat.line1, line2: sat.line2, epochMs })
: null,
owner: freezeOwner(
satelliteOwner({ ucs: sat.ucs, satcat: sat.satcat, satelliteClass }),
),
operator: satelliteOperator({ ucs: sat.ucs, satelliteClass }),
});
sat.cockpitIdentity = identity;
return identity;
}
function freezeOwner(owner) {
return owner
? Object.freeze({
countryCode: owner.countryCode ?? null,
label: owner.label ?? null,
})
: null;
}
/**
* Normalize the tracked satellite and its per-frame fix into Cockpit subject
* info, in the shape every Cockpit subject kind shares.
* @param {number} noradId NORAD catalog number.
* @param {{name?: string, group?: string}|null} sat Catalog entry.
* @param {{latitude: number, longitude: number, altitude: number,
* speedMps?: number|null, groundTrackDeg?: number|null}|null} fix Per-frame
* SGP4 sample (orbits.js propagatePosition).
* @returns {object|null} Subject info, or null without a usable fix.
*/
export function satelliteCockpitInfo(noradId, sat, fix) {
if (!sat || !fix) return null;
const { latitude, longitude, altitude } = fix;
if (![latitude, longitude, altitude].every(Number.isFinite)) return null;
const isIss = noradId === ISS_NORAD;
const identity = satelliteIdentity(noradId, sat);
return {
kind: 'satellite',
layerId: SATELLITE_COCKPIT_LAYER_ID,
id: String(noradId),
noradId,
name: sat.name?.trim() || `SAT-${noradId}`,
// Localized at read time, as the vessel layer's type label is.
classLabel: satelliteClassText(identity.satelliteClass, sat.group, {
isIss,
}),
latitude,
longitude,
altitudeM: altitude,
// SGP4's TEME velocity: the inertial orbital speed, not ground speed.
velocityMps: Number.isFinite(fix.speedMps) ? fix.speedMps : null,
// Degrees clockwise from north; null for a geostationary satellite.
groundTrackDeg: Number.isFinite(fix.groundTrackDeg)
? fix.groundTrackDeg
: null,
stale: false,
// src/layers/satellites/classes.js: 'stations'|'imaging'|'telecom'|'starlink'|
// 'probes'|'debris'.
satelliteClass: identity.satelliteClass,
// Whether its class may be ridden in the orbital view (debris may not);
// the Cockpit's entry gate reads it (orbitEntryAllowed).
enterable: satelliteClassEnterable(identity.satelliteClass),
// International designator, e.g. '1998-067A'.
cosparId: identity.cosparId,
tle: identity.tle,
// { countryCode: ISO 3166-1 alpha-2 or null (organisations, joint
// owners), label: English name } or null.
owner: identity.owner,
operator: identity.operator,
};
}
/**
* The tracked satellite as a Cockpit orbital-view subject, and the hold that
* hides its own marks while the Cockpit camera rides it.
*
* While held, the tracked dot is hidden (it would sit on the lens), as are
* the point primitives of docked companions (they share the position within
* 2 km) and the subject's orbit ring: a nadir camera on the ring sees its far
* side, dimmed by the ring's depth-fail appearance, drawn straight across the
* Earth's disk. The detection overlay skips the subject too. The tracked
* card needs nothing here: its overlay source already hides in Cockpit mode.
* Every other orbit ring is dimmed to COCKPIT_ORBIT_DIM_FACTOR of its alpha
* while a subject is set; other satellites' points and labels stay as they
* are. Scene clicks and the layer's Escape are ignored while held, so the
* picture cannot untrack its own subject. Release restores exactly what the
* hold hid or dimmed.
*/
export function createCockpitSubject({ state: layerState, parts }) {
/** The subject is held only while it is the tracked satellite. */
function _cockpitHeld() {
return (
layerState._cockpitSubjectNorad !== null &&
layerState._cockpitSubjectNorad === layerState._trackedNorad
);
}
function _setPointShown(noradId, show) {
const point = layerState._points.get(noradId);
// Released points go back to their class's visibility.
if (point) point.show = show && parts.controls._classVisible(noradId);
}
/**
* Apply or release the hold. Idempotent, and cheap enough for the preRender
* tick, which re-applies it while held so catalog rebuilds, enable and the
* orbit toggle cannot re-show what the hold hides.
* @returns {void}
*/
function _syncCockpitSubjectVisuals() {
const held = _cockpitHeld();
const tracked = layerState._trackedNorad;
const dot = layerState._trackedEntity?.point;
if (held) {
if (dot) dot.show = false;
layerState._cockpitHiddenDot = true;
} else if (layerState._cockpitHiddenDot) {
if (dot) dot.show = true;
layerState._cockpitHiddenDot = false;
}
const hiddenPoints = layerState._cockpitHiddenPoints;
const companions = held ? layerState._dockedCompanions : new Set();
for (const noradId of [...hiddenPoints]) {
if (companions.has(noradId)) continue;
hiddenPoints.delete(noradId);
// The tracked primitive stays hidden: its entity draws the dot.
if (noradId !== tracked) _setPointShown(noradId, true);
}
// Re-applied every sync: a catalog rebuild replaces the point
// primitives, and the new ones arrive shown.
for (const noradId of companions) {
if (noradId === tracked) continue;
hiddenPoints.add(noradId);
_setPointShown(noradId, false);
}
if (held) {
const trackedPoint = layerState._points.get(tracked);
if (trackedPoint) trackedPoint.show = false;
}
const hiddenPath = layerState._cockpitHiddenPath;
if (hiddenPath !== null && (!held || hiddenPath !== tracked)) {
const path = layerState._orbitPaths.get(hiddenPath);
// The ISS's ring also follows its class (and its tracking).
if (path && hiddenPath === ISS_NORAD) parts.controls._syncIssOrbit();
else if (path)
path.primitive.show = parts.controls.satelliteVisualsVisible(
layerState._enabled,
layerState._params.showOrbits,
);
layerState._cockpitHiddenPath = null;
}
if (held) {
const path = layerState._orbitPaths.get(tracked);
if (path) {
path.primitive.show = false;
layerState._cockpitHiddenPath = tracked;
}
}
// Every other ring is dimmed for the whole session and restored on
// release; rebuilt rings arrive at full alpha and are dimmed again here.
const subject = layerState._cockpitSubjectNorad;
for (const [noradId, path] of layerState._orbitPaths) {
const dim = subject !== null && noradId !== subject;
applyOrbitPathAlpha(path, dim ? COCKPIT_ORBIT_DIM_FACTOR : 1);
}
}
const methods = {
/**
* The tracked satellite as Cockpit subject info, from the same per-frame
* SGP4 sample the dot and the follow camera use.
* @returns {{kind: 'satellite', layerId: 'satellites', id: string,
* noradId: number, name: string, classLabel: string,
* latitude: number, longitude: number, altitudeM: number,
* velocityMps: number|null, stale: false,
* satelliteClass: 'stations'|'imaging'|'telecom'|'starlink'|'probes'|'debris',
* cosparId: string|null,
* tle: {line1: string, line2: string, epochMs: number}|null,
* owner: {countryCode: string|null, label: string|null}|null,
* operator: string|null}|null} Null while the layer is off or nothing
* is tracked.
*/
getCockpitSubject() {
if (!layerState._enabled || layerState._trackedNorad === null)
return null;
const noradId = layerState._trackedNorad;
return satelliteCockpitInfo(
noradId,
layerState._catalog.get(noradId),
parts.tracking._getTrackedFramePosition(),
);
},
/**
* The tracked satellite's Earth-fixed position this frame: the per-frame
* cache the dot and follow camera read, never a separate propagation.
* @param {Cesium.Cartesian3} [result] Receives the position.
* @returns {Cesium.Cartesian3|null} Null while the layer is off, nothing
* is tracked or no sample has propagated.
*/
getCockpitPosition(result) {
if (!layerState._enabled || layerState._trackedNorad === null)
return null;
if (!parts.tracking._getTrackedFramePosition()) return null;
return Cesium.Cartesian3.clone(layerState._trackedFrameCartesian, result);
},
/**
* Mark the satellite whose position the Cockpit camera occupies, or
* release it. The hold applies while that satellite is tracked.
* @param {string|number|null} noradId Subject NORAD id, or null to release.
* @returns {boolean} Always true.
*/
setCockpitSubject(noradId) {
const next =
noradId === null || noradId === undefined
? null
: parts.tracking._normalizeTrackedNorad(noradId);
if (layerState._cockpitSubjectNorad === next) return true;
layerState._cockpitSubjectNorad = next;
_syncCockpitSubjectVisuals();
return true;
},
/**
* Put the follow camera back on the tracked satellite after the Cockpit
* released it, without re-tracking (no new selection or context events).
* @param {string|number} noradId The satellite the Cockpit rode.
* @returns {boolean} True when that satellite is still tracked and the
* follow camera was restored.
*/
refocusTrackedById(noradId) {
const id = parts.tracking._normalizeTrackedNorad(noradId);
const viewer = layerState._viewer;
const entity = layerState._trackedEntity;
if (
id === null ||
id !== layerState._trackedNorad ||
!viewer ||
!entity ||
!viewer.entities?.contains?.(entity)
)
return false;
viewer.camera?.cancelFlight?.();
viewer.trackedEntity = entity;
return true;
},
};
return { _cockpitHeld, _syncCockpitSubjectVisuals, methods };
}
@@ -0,0 +1,584 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createState } from './state.js';
import { createControls } from './controls.js';
import { createOrbits } from './orbits.js';
import { createLabels } from './labels.js';
import { createTracking } from './tracking.js';
import { createRendering } from './rendering.js';
import { createCatalog } from './catalog.js';
import { createLifecycle } from './lifecycle.js';
import { createInteraction } from './interaction.js';
import { createIngestion } from './ingestion.js';
import {
COCKPIT_ORBIT_DIM_FACTOR,
applyOrbitPathAlpha,
createCockpitSubject,
satelliteCockpitInfo,
} from './cockpitSubject.js';
import { groundTrackBearingDeg } from './orbits.js';
import { ISS_NORAD } from './policy.js';
const COMPANION = 25545;
const GPS = 32711;
function tle(name, id, motion = '15.48899044', inclination = ' 51.6326') {
// Owned synthetic element sets; never live requests. The station pair
// share elements, so the companion is docked to the ISS.
return `${name}\n1 ${id}U 98067A 26289.78971908 .00007803 00000+0 15012-3 0 9995\n2 ${id} ${inclination} 58.3278 0006748 271.9059 88.1156 ${motion}590607`;
}
const GROUP_TEXT = {
stations: [
tle('ISS (ZARYA)', ISS_NORAD),
tle('PROGRESS-MS 31', COMPANION),
].join('\n'),
'gps-ops': tle('GPS FIXTURE', GPS, ' 2.00563000', ' 55.0000'),
};
/** Whether the tracked entity draws its dot (an unset show is shown). */
function dotShown(f) {
const show = f.state._trackedEntity.point.show;
return show === undefined ? true : show.getValue();
}
const SATCAT_HEADER =
'OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE,OWNER';
const SATCAT_TEXT = {
stations: [
SATCAT_HEADER,
`ISS (ZARYA),1998-067A,${ISS_NORAD},PAY,+,ISS`,
`PROGRESS-MS 31,2026-001A,${COMPANION},PAY,+,CIS`,
].join('\n'),
// No designator: the TLE's is used.
'gps-ops': [SATCAT_HEADER, `GPS FIXTURE,,${GPS},PAY,+,US`].join('\n'),
};
const UCS = new Map([
[
ISS_NORAD,
{
noradId: ISS_NORAD,
operator: 'NASA/Multinational',
operatorCountry: 'Multinational',
purpose: 'Space Science',
},
],
]);
async function fixture(t) {
const source = {
async readGroup(group) {
const text = GROUP_TEXT[group] || '';
return { ok: true, status: 200, text, stale: false };
},
async readSatcat(group) {
return { ok: true, status: 200, text: SATCAT_TEXT[group] || '' };
},
readUcs: async () => UCS,
};
const noop = () => {};
const services = {
overlays: {
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
clearOverlaySource: noop,
},
layerState: { isExplicitLayerStateOrigin: () => false },
focus: {
clearFocusTarget: noop,
publishFocusTargetFromCachedPosition: noop,
getFocusTarget: () => null,
focusNowMs: (now) => now,
focusPassIsNeeded: () => false,
},
context: {
clearTrackedSubjectContext: noop,
getContextStore: () => ({ entities: new Map() }),
refreshTrackedSubjectContext: noop,
selectTrackedSubjectContext: noop,
},
readout: { refreshTrackedReadout: noop },
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
};
const previousDocument = globalThis.document;
globalThis.document = new EventTarget();
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
for (const [name, factory] of Object.entries({
controls: createControls,
orbits: createOrbits,
labels: createLabels,
tracking: createTracking,
rendering: createRendering,
catalog: createCatalog,
interaction: createInteraction,
lifecycle: createLifecycle,
ingestion: createIngestion,
cockpit: createCockpitSubject,
})) {
parts[name] = factory(context);
}
const viewer = {
trackedEntity: undefined,
entities: new Cesium.EntityCollection(),
scene: {
canvas: new EventTarget(),
primitives: new Cesium.PrimitiveCollection(),
preRender: new Cesium.Event(),
frameState: { frameNumber: 1 },
},
trackedEntityChanged: new Cesium.Event(),
};
const { log, warn } = console;
console.log = noop;
console.warn = noop;
await parts.lifecycle.methods.init(viewer);
await parts.ingestion.methods.update(viewer);
parts.lifecycle.methods.enable(viewer);
// GPS is TELECOM; these tests need its point on screen whatever the default.
parts.controls.methods.setParams({ classes: { telecom: true } });
Object.assign(console, { log, warn });
t.after(() => {
console.log = noop;
if (state._pointCollection) parts.lifecycle.methods.destroy(viewer);
console.log = log;
viewer.scene.primitives.destroy();
if (previousDocument === undefined) delete globalThis.document;
else globalThis.document = previousDocument;
});
const methods = { ...parts.controls.methods, ...parts.cockpit.methods };
return {
state,
parts,
viewer,
methods,
track(id) {
const log = console.log;
console.log = noop;
try {
return methods.trackById(id);
} finally {
console.log = log;
}
},
nextFrame() {
viewer.scene.frameState.frameNumber += 1;
parts.rendering._preRenderTick();
},
ring: (id) => state._orbitPaths.get(id)?.primitive,
detected: () =>
methods.getDetectableObjects().map((object) => object.sourceId),
};
}
test('no subject while the layer is off or nothing is tracked', async (t) => {
const f = await fixture(t);
assert.equal(f.methods.getCockpitSubject(), null);
assert.equal(f.methods.getCockpitPosition(), null);
assert.equal(f.track(ISS_NORAD), true);
f.state._enabled = false;
assert.equal(f.methods.getCockpitSubject(), null);
assert.equal(f.methods.getCockpitPosition(), null);
});
test('the tracked satellite is a subject read from the per-frame sample', async (t) => {
const f = await fixture(t);
f.track(ISS_NORAD);
const propagate = f.parts.orbits.propagatePosition;
let propagations = 0;
f.parts.orbits.propagatePosition = (...args) => {
propagations += 1;
return propagate(...args);
};
f.viewer.scene.frameState.frameNumber += 1;
const subject = f.methods.getCockpitSubject();
const position = f.methods.getCockpitPosition();
f.methods.getCockpitSubject();
assert.equal(propagations, 1, 'one SGP4 sample per frame for every reader');
assert.equal(subject.kind, 'satellite');
assert.equal(subject.layerId, 'satellites');
assert.equal(subject.id, String(ISS_NORAD));
assert.equal(subject.noradId, ISS_NORAD);
assert.equal(subject.name, 'ISS (ZARYA)');
assert.equal(subject.classLabel, 'STATIONS · ISS');
assert.equal(subject.stale, false);
assert.ok(subject.altitudeM > 150e3 && subject.altitudeM < 1000e3);
assert.ok(subject.velocityMps > 7000 && subject.velocityMps < 8000);
// The same point the tracked dot and follow camera use, as a copy.
const cached = f.state._trackedFrameCartesian;
assert.ok(Cesium.Cartesian3.equals(position, cached));
assert.notEqual(position, cached);
const target = new Cesium.Cartesian3();
assert.equal(f.methods.getCockpitPosition(target), target);
const geodetic = Cesium.Cartographic.fromCartesian(position);
assert.ok(
Math.abs(Cesium.Math.toDegrees(geodetic.latitude) - subject.latitude) <
1e-6,
);
f.parts.orbits.propagatePosition = propagate;
});
test('the subject says what it is, which TLE it flies, and who owns and runs it', async (t) => {
const f = await fixture(t);
f.track(ISS_NORAD);
const iss = f.methods.getCockpitSubject();
assert.equal(iss.satelliteClass, 'stations');
assert.equal(iss.cosparId, '1998-067A');
assert.match(iss.tle.line1, /^1 25544U/);
assert.match(iss.tle.line2, /^2 25544 /);
// Day 289.789… of 2026.
assert.ok(
Math.abs(
iss.tle.epochMs - (Date.UTC(2026, 0, 1) + 288.78971908 * 86_400_000),
) < 1000,
);
assert.deepEqual(
iss.owner,
{ countryCode: null, label: 'Multinational' },
'UCS before SATCAT',
);
assert.equal(iss.operator, 'NASA/Multinational');
assert.equal(
f.methods.getCockpitSubject().owner,
iss.owner,
'worked out once',
);
f.track(GPS);
const gps = f.methods.getCockpitSubject();
assert.equal(gps.satelliteClass, 'telecom');
assert.equal(
gps.cosparId,
'1998-067A',
'from TLE line 1 when SATCAT has none',
);
assert.deepEqual(gps.owner, { countryCode: 'US', label: 'United States' });
assert.equal(gps.operator, null);
// A bare catalog entry still answers every field.
const bare = satelliteCockpitInfo(
GPS,
{ name: 'X', group: 'gps-ops' },
{ latitude: 1, longitude: 2, altitude: 3 },
);
assert.equal(bare.satelliteClass, 'telecom');
// An imaging satellite CelesTrak lists among the brightest objects names
// its class, not the 'visual' group it came in.
const landsat = satelliteCockpitInfo(
49260,
{ name: 'LANDSAT 9', group: 'visual', satelliteClass: 'imaging' },
{ latitude: 1, longitude: 2, altitude: 3 },
);
assert.equal(landsat.classLabel, 'IMAGING');
assert.equal(bare.tle, null);
assert.equal(bare.owner, null);
assert.equal(bare.operator, null);
assert.equal(bare.cosparId, null);
});
test('a GNSS satellite carries its class and an orbital speed', async (t) => {
const f = await fixture(t);
f.track(GPS);
const subject = f.methods.getCockpitSubject();
assert.equal(subject.classLabel, 'TELECOM · GPS');
assert.ok(subject.altitudeM > 19e6 && subject.altitudeM < 21.5e6);
assert.ok(subject.velocityMps > 3500 && subject.velocityMps < 4200);
assert.equal(
satelliteCockpitInfo(
GPS,
{ name: 'X', group: 'gps-ops' },
{
latitude: 1,
longitude: 2,
altitude: 3,
},
).velocityMps,
null,
);
assert.equal(satelliteCockpitInfo(GPS, null, { latitude: 0 }), null);
});
test('holding the subject hides its dot, ring, docked companions and bracket', async (t) => {
const f = await fixture(t);
f.track(ISS_NORAD);
f.nextFrame();
assert.ok(f.state._dockedCompanions.has(COMPANION));
assert.equal(dotShown(f), true);
assert.equal(f.ring(ISS_NORAD).show, true);
assert.ok(f.detected().includes(ISS_NORAD));
assert.equal(f.methods.setCockpitSubject(String(ISS_NORAD)), true);
assert.equal(dotShown(f), false);
assert.equal(f.ring(ISS_NORAD).show, false);
assert.equal(f.state._points.get(COMPANION).show, false);
assert.equal(f.state._points.get(GPS).show, true);
assert.ok(!f.detected().includes(ISS_NORAD));
// Toggling orbits on again cannot bring the ring back while held.
f.methods.setParams({ showOrbits: true });
assert.equal(f.ring(ISS_NORAD).show, true);
f.nextFrame();
assert.equal(f.ring(ISS_NORAD).show, false);
f.methods.setCockpitSubject(null);
assert.equal(dotShown(f), true);
assert.equal(f.ring(ISS_NORAD).show, true);
assert.equal(f.state._points.get(COMPANION).show, true);
// The tracked primitive stays hidden: the tracked entity draws the dot.
assert.equal(f.state._points.get(ISS_NORAD).show, false);
assert.ok(f.detected().includes(ISS_NORAD));
});
test('a subject that is not tracked holds nothing', async (t) => {
const f = await fixture(t);
f.track(GPS);
f.methods.setCockpitSubject(ISS_NORAD);
f.nextFrame();
assert.equal(dotShown(f), true);
assert.equal(f.ring(GPS).show, true);
assert.equal(f.ring(ISS_NORAD).show, true);
});
test('untracking a held subject gives its marks back', async (t) => {
const f = await fixture(t);
f.track(ISS_NORAD);
f.nextFrame();
f.methods.setCockpitSubject(ISS_NORAD);
assert.equal(f.ring(ISS_NORAD).show, false);
f.methods.stopTracking();
assert.equal(f.methods.getCockpitSubject(), null);
assert.equal(f.ring(ISS_NORAD).show, true);
assert.equal(f.state._points.get(COMPANION).show, true);
assert.equal(f.state._points.get(ISS_NORAD).show, true);
});
test('a catalog refresh while held keeps the subject and its companions hidden', async (t) => {
const f = await fixture(t);
f.track(ISS_NORAD);
f.nextFrame();
f.methods.setCockpitSubject(ISS_NORAD);
const log = console.log;
console.log = () => {};
try {
await f.parts.ingestion.methods.update(f.viewer);
} finally {
console.log = log;
}
f.nextFrame();
assert.equal(f.state._points.get(COMPANION).show, false, 'companion');
assert.equal(f.state._points.get(ISS_NORAD).show, false, 'tracked point');
f.methods.setCockpitSubject(null);
assert.equal(f.state._points.get(COMPANION).show, true, 'given back');
});
test('untracking a held ISS leaves its ring hidden while Stations is off', async (t) => {
const f = await fixture(t);
f.track(ISS_NORAD);
f.nextFrame();
f.methods.setCockpitSubject(ISS_NORAD);
f.methods.setParams({ classes: 'itpd' });
f.nextFrame();
f.methods.stopTracking();
assert.equal(f.ring(ISS_NORAD).show, false);
f.methods.setParams({ classes: 'sitpd' });
assert.equal(f.ring(ISS_NORAD).show, true);
});
test('tracking without a Cockpit subject keeps its marks', async (t) => {
const f = await fixture(t);
f.track(GPS);
for (let i = 0; i < 3; i++) f.nextFrame();
assert.equal(dotShown(f), true);
assert.equal(f.ring(GPS).show, true);
assert.equal(f.state._points.get(COMPANION).show, true);
assert.equal(f.state._cockpitHiddenPoints.size, 0);
assert.equal(f.state._cockpitHiddenPath, null);
});
/**
* Give a ring the per-instance attributes Cesium creates on its first
* update, starting from the colours it was built with. Rings in this
* fixture are never rendered, so the test stands in for that update.
*/
function readyRing(f, id) {
const path = f.state._orbitPaths.get(id);
const attributes = {
color: Cesium.ColorGeometryInstanceAttribute.toValue(path.baseColor),
depthFailColor: Cesium.ColorGeometryInstanceAttribute.toValue(
path.baseDepthFailColor,
),
};
path.primitive.getGeometryInstanceAttributes = (instanceId) =>
instanceId === path.instanceId ? attributes : undefined;
return attributes;
}
/** A colour attribute's alpha byte. */
const alphaByte = (value) => value[3];
test('a Cockpit subject dims every other orbit ring and restores it exactly', async (t) => {
const f = await fixture(t);
f.track(GPS);
f.nextFrame();
assert.ok(f.ring(ISS_NORAD), 'the ISS ring is drawn beside the tracked one');
const iss = readyRing(f, ISS_NORAD);
const gps = readyRing(f, GPS);
const builtColor = [...iss.color];
const builtDepthFail = [...iss.depthFailColor];
const gpsBuilt = [...gps.color];
f.methods.setCockpitSubject(GPS);
const dimmed = (byte) => Math.round(byte * COCKPIT_ORBIT_DIM_FACTOR);
assert.ok(Math.abs(alphaByte(iss.color) - dimmed(builtColor[3])) <= 1);
assert.ok(
Math.abs(alphaByte(iss.depthFailColor) - dimmed(builtDepthFail[3])) <= 1,
);
assert.deepEqual([...iss.color].slice(0, 3), builtColor.slice(0, 3));
assert.equal(f.ring(ISS_NORAD).show, true, 'other rings stay visible');
assert.equal(f.ring(GPS).show, false, 'the ridden ring stays hidden');
assert.deepEqual([...gps.color], gpsBuilt, 'the ridden ring is not dimmed');
// Other satellites' points stay as they are.
assert.equal(f.state._points.get(COMPANION).show, true);
// A rebuilt ring arrives at full alpha and is dimmed again on the tick.
f.parts.rendering._hideOrbitPath(ISS_NORAD);
f.parts.rendering._showOrbitPath(ISS_NORAD, Cesium.Color.YELLOW);
const rebuilt = readyRing(f, ISS_NORAD);
f.nextFrame();
assert.ok(alphaByte(rebuilt.color) < builtColor[3]);
f.methods.setCockpitSubject(null);
assert.deepEqual(
[...rebuilt.color],
[
...Cesium.ColorGeometryInstanceAttribute.toValue(
f.state._orbitPaths.get(ISS_NORAD).baseColor,
),
],
'released rings get their built colours back exactly',
);
assert.deepEqual(
[...rebuilt.depthFailColor],
[
...Cesium.ColorGeometryInstanceAttribute.toValue(
f.state._orbitPaths.get(ISS_NORAD).baseDepthFailColor,
),
],
);
assert.equal(f.ring(GPS).show, true);
assert.deepEqual([...gps.color], gpsBuilt);
});
test('a ring not yet updated by Cesium is dimmed once it is', async (t) => {
const f = await fixture(t);
f.track(GPS);
f.methods.setCockpitSubject(GPS);
const path = f.state._orbitPaths.get(ISS_NORAD);
assert.equal(path.alphaFactor, 1, 'nothing written before the first update');
const iss = readyRing(f, ISS_NORAD);
f.nextFrame();
assert.equal(path.alphaFactor, COCKPIT_ORBIT_DIM_FACTOR);
assert.ok(alphaByte(iss.color) < 100);
assert.equal(applyOrbitPathAlpha(path, 1), true);
assert.deepEqual(
[...iss.color],
[...Cesium.ColorGeometryInstanceAttribute.toValue(path.baseColor)],
);
});
test('a held subject ignores the layer Escape; refocus restores the follow', async (t) => {
const f = await fixture(t);
f.track(ISS_NORAD);
f.nextFrame();
f.methods.setCockpitSubject(ISS_NORAD);
f.parts.interaction._onKeyDown({ key: 'Escape' });
assert.equal(f.state._trackedNorad, ISS_NORAD, 'the Cockpit owns Escape');
// The Cockpit took the follow camera off the entity; refocus puts it back.
f.viewer.trackedEntity = undefined;
f.methods.setCockpitSubject(null);
assert.equal(f.methods.refocusTrackedById(GPS), false, 'not the tracked one');
assert.equal(f.viewer.trackedEntity, undefined);
assert.equal(f.methods.refocusTrackedById(String(ISS_NORAD)), true);
assert.equal(f.viewer.trackedEntity, f.state._trackedEntity);
assert.equal(f.state._trackedNorad, ISS_NORAD);
f.parts.interaction._onKeyDown({ key: 'Escape' });
assert.equal(f.state._trackedNorad, null, 'released, Escape untracks again');
assert.equal(f.methods.refocusTrackedById(ISS_NORAD), false);
});
test('the ground track bearing comes from the SGP4 velocity, less the Earth turning', () => {
const near = (actual, expected) =>
assert.ok(Math.abs(actual - expected) < 0.2, `${actual} vs ${expected}`);
// An ISS-like orbit (51.6°, 7.66 km/s) crossing the equator northbound at
// 0°E: inertially 38.4° from north, but the ground turns east beneath it,
// so the track runs closer to north.
const inclination = (51.6 * Math.PI) / 180;
const iss = {
position: { x: 6771, y: 0, z: 0 },
velocity: {
x: 0,
y: 7.66 * Math.cos(inclination),
z: 7.66 * Math.sin(inclination),
},
};
near(groundTrackBearingDeg(iss.position, iss.velocity, 0, 0, 0), 35.4);
// The same state given in TEME a quarter sidereal turn on (GMST 90°):
// still over 0°E, so the same ground track.
near(
groundTrackBearingDeg(
{ x: 0, y: 6771, z: 0 },
{ x: -iss.velocity.y, y: 0, z: iss.velocity.z },
Math.PI / 2,
0,
0,
),
35.4,
);
// Geostationary: it turns with the Earth, so there is no ground track.
assert.equal(
groundTrackBearingDeg(
{ x: 42164, y: 0, z: 0 },
{ x: 0, y: 3.0747, z: 0 },
0,
0,
0,
),
null,
);
assert.equal(groundTrackBearingDeg(iss.position, null, 0, 0, 0), null);
// The subject passes the sample's track through.
const sat = { name: 'X', group: 'stations' };
const fix = { latitude: 0, longitude: 0, altitude: 400_000 };
assert.equal(
satelliteCockpitInfo(1, sat, { ...fix, groundTrackDeg: 35.4 })
.groundTrackDeg,
35.4,
);
assert.equal(satelliteCockpitInfo(1, sat, fix).groundTrackDeg, null);
});
test('the subject says whether its class may be ridden', () => {
const fix = { latitude: 0, longitude: 0, altitude: 400_000 };
const subject = (satelliteClass) =>
satelliteCockpitInfo(
1,
{ name: 'X', group: 'visual', satelliteClass },
fix,
);
assert.equal(subject('debris').enterable, false);
for (const satelliteClass of [
'stations',
'imaging',
'telecom',
'starlink',
'probes',
])
assert.equal(subject(satelliteClass).enterable, true, satelliteClass);
});
+6 -2
View File
@@ -48,12 +48,13 @@ export function createControls({ state: layerState, services, parts, source }) {
/**
* Show or hide every point by its class. The tracked satellite's point
* stays hidden (its entity draws the dot). The ISS's ring and label go
* with its class.
* stays hidden (its entity draws the dot), as do points a Cockpit hold hid.
* The ISS's ring and label go with its class.
*/
function _applyClassVisibility() {
for (const [noradId, point] of layerState._points) {
if (noradId === layerState._trackedNorad) continue;
if (layerState._cockpitHiddenPoints?.has(noradId)) continue;
point.show = _classVisible(noradId);
}
_syncIssOrbit();
@@ -253,6 +254,9 @@ export function createControls({ state: layerState, services, parts, source }) {
// that card instead. Only members of the tracked cluster are affected —
// unrelated nearby satellites are never suppressed.
if (!isTracked && layerState._dockedCompanions.has(noradId)) continue;
// The Cockpit camera rides a held subject; a bracket on it would sit
// on the lens.
if (isTracked && parts.cockpit?._cockpitHeld()) continue;
const cat = layerState._catalog.get(noradId);
let object = layerState._detectionObjects.get(noradId);
if (!object) {
+3
View File
@@ -1,5 +1,6 @@
import { createControls } from './controls.js';
import { createCatalog } from './catalog.js';
import { createCockpitSubject } from './cockpitSubject.js';
import { createLabels } from './labels.js';
import { createOrbits } from './orbits.js';
import { createRendering } from './rendering.js';
@@ -29,9 +30,11 @@ export function createSatellitesLayer({ services, source }) {
parts.interaction = createInteraction(context);
parts.lifecycle = createLifecycle(context);
parts.ingestion = createIngestion(context);
parts.cockpit = createCockpitSubject(context);
return Object.assign(
{},
parts.controls.methods,
parts.cockpit.methods,
parts.lifecycle.methods,
parts.ingestion.methods,
{
+4
View File
@@ -118,6 +118,8 @@ export function createIngestion({
const satcat = layerState._satcat.get(noradId) || null;
if (satcat === sat.satcat) continue;
sat.satcat = satcat;
// The Cockpit identity (owner, COSPAR id) reads SATCAT too.
sat.cockpitIdentity = undefined;
const next = classifySatellite({
noradId,
name: sat.name,
@@ -322,6 +324,8 @@ export function createIngestion({
groups,
// satellite.js does not keep the designator the year match reads.
launchYear,
line1: entry.line1,
line2: entry.line2,
satelliteClass: classifySatellite({
noradId,
name: entry.name,
+5
View File
@@ -10,6 +10,8 @@ export function createInteraction({
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
function _onKeyDown(e) {
// The Cockpit owns Escape while it rides the tracked satellite.
if (parts.cockpit?._cockpitHeld()) return;
if (layerState._enabled && e.key === 'Escape' && layerState._trackedNorad) {
parts.tracking._cancelPendingTrackingRestore();
parts.tracking._clearTracking(false, { origin: 'user' });
@@ -50,6 +52,9 @@ export function createInteraction({
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
if (!layerState._enabled) return;
// The Cockpit camera rides the tracked satellite: a click on the
// picture below must not untrack it or switch satellites.
if (parts.cockpit?._cockpitHeld()) return;
const picked = viewer.scene.pick(click.position);
if (picked) {
+4
View File
@@ -51,6 +51,10 @@ export function createLifecycle({
layerState._trackedFrameNumber = -1;
layerState._trackedFrameGeo = null;
layerState._trackedFrameNowForTest = null;
// The new catalog's points and rings carry no hold.
layerState._cockpitHiddenPoints = new Set();
layerState._cockpitHiddenPath = null;
layerState._cockpitHiddenDot = false;
layerState._lastFocusUpdate = 0;
layerState._activeFocusCount = 0;
layerState._enabled = false;
+62 -2
View File
@@ -15,6 +15,57 @@ import {
} from '../../sources/tle.js';
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
/** The Earth's rotation rate, rad/s (WGS84). */
const EARTH_ROTATION_RAD_S = 7.292115e-5;
/** Below this ground speed a satellite holds still over the ground (GEO), m/s. */
const GROUND_TRACK_MIN_SPEED_MPS = 10;
/**
* The ground track's bearing, degrees clockwise from north, from one SGP4
* state: the inertial velocity turned into the Earth-fixed frame, less the
* Earth's rotation under the satellite, read in the east/north plane at the
* sub-satellite point. Null without a velocity, or for a satellite that holds
* still over the ground (geostationary).
* @param {{x: number, y: number, z: number}} positionKm TEME position, km.
* @param {{x: number, y: number, z: number}|null} velocityKmS TEME velocity, km/s.
* @param {number} gmst Greenwich mean sidereal time, radians.
* @param {number} latitudeDeg Sub-satellite latitude.
* @param {number} longitudeDeg Sub-satellite longitude.
* @returns {number|null}
*/
export function groundTrackBearingDeg(
positionKm,
velocityKmS,
gmst,
latitudeDeg,
longitudeDeg,
) {
if (!positionKm || !velocityKmS || !Number.isFinite(gmst)) return null;
const cosG = Math.cos(gmst);
const sinG = Math.sin(gmst);
// TEME to Earth-fixed is a turn about z by GMST.
const rx = cosG * positionKm.x + sinG * positionKm.y;
const ry = -sinG * positionKm.x + cosG * positionKm.y;
const vx =
(cosG * velocityKmS.x + sinG * velocityKmS.y + EARTH_ROTATION_RAD_S * ry) *
1000;
const vy =
(-sinG * velocityKmS.x + cosG * velocityKmS.y - EARTH_ROTATION_RAD_S * rx) *
1000;
const vz = velocityKmS.z * 1000;
const φ = (latitudeDeg * Math.PI) / 180;
const λ = (longitudeDeg * Math.PI) / 180;
const east = -Math.sin(λ) * vx + Math.cos(λ) * vy;
const north =
-Math.sin(φ) * Math.cos(λ) * vx -
Math.sin(φ) * Math.sin(λ) * vy +
Math.cos(φ) * vz;
if (![east, north].every(Number.isFinite)) return null;
if (Math.hypot(east, north) < GROUND_TRACK_MIN_SPEED_MPS) return null;
return ((Math.atan2(east, north) * 180) / Math.PI + 360) % 360;
}
export function createOrbits({ state: layerState, services, parts, source }) {
/**
* Build the rigid ECEF transform that keeps an orbit path baked at one GMST
@@ -68,11 +119,20 @@ export function createOrbits({ state: layerState, services, parts, source }) {
? Math.hypot(velocity.x, velocity.y, velocity.z) * 1000
: null;
const latitude = degreesLat(geo.latitude);
const longitude = degreesLong(geo.longitude);
return {
longitude: degreesLong(geo.longitude),
latitude: degreesLat(geo.latitude),
longitude,
latitude,
altitude: geo.height * 1000, // km → meters
speedMps: Number.isFinite(speedMps) ? speedMps : null,
groundTrackDeg: groundTrackBearingDeg(
posVel.position,
velocity,
gmst,
latitude,
longitude,
),
};
} catch {
return null;
+17 -6
View File
@@ -52,21 +52,23 @@ export function createRendering({
if (basePositions.length < 2) return;
const pathColor = color || Cesium.Color.CYAN;
// Kept so the Cockpit's orbit dim (cockpitSubject.js) restores exactly.
const baseColor = pathColor.withAlpha(0.6);
const baseDepthFailColor = pathColor.withAlpha(0.35);
const instanceId = `satellite-orbit:${noradId}`;
const primitive = new Cesium.Primitive({
geometryInstances: new Cesium.GeometryInstance({
id: instanceId,
geometry: new Cesium.PolylineGeometry({
positions: basePositions,
width: noradId === ISS_NORAD ? 2.5 : 2.0,
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
}),
attributes: {
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
pathColor.withAlpha(0.6),
),
depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
pathColor.withAlpha(0.35),
),
color: Cesium.ColorGeometryInstanceAttribute.fromColor(baseColor),
depthFailColor:
Cesium.ColorGeometryInstanceAttribute.fromColor(baseDepthFailColor),
},
}),
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
@@ -86,6 +88,11 @@ export function createRendering({
// GMST value (gstime is pure), so delta starts at exactly 0 and the
// initial identity modelMatrix is correct until the first ring tick.
gmstAtBake: gstime(bakeDate),
instanceId,
baseColor,
baseDepthFailColor,
// The alpha multiple last written to the instance (1: as built).
alphaFactor: 1,
});
}
@@ -218,6 +225,10 @@ export function createRendering({
}
}
// While the Cockpit rides the tracked satellite, keep its marks hidden.
if (layerState._cockpitSubjectNorad !== null)
parts.cockpit?._syncCockpitSubjectVisuals();
_updatePointFocus(now);
// Hidden standalone orbit primitives do not need GMST matrix writes while
+12 -1
View File
@@ -15,7 +15,7 @@ export function createState({ services }) {
state._overlayHost = state.DEFAULT_OVERLAY_HOST;
// Satellite catalog: { noradId → { name, satrec, group, launchYear,
// Satellite catalog: { noradId → { name, satrec, group, line1, line2,
// satelliteClass, ucs, satcat } } — class, UCS and SATCAT records from
// src/layers/satellites/classes.js and the sources the update read.
@@ -174,6 +174,17 @@ export function createState({ services }) {
state._trackedFrameNowForTest = null;
// Cockpit orbital-view hold (cockpitSubject.js): the NORAD id the Cockpit
// camera rides, and what the hold hid so release restores exactly that.
state._cockpitSubjectNorad = null;
state._cockpitHiddenPoints = new Set();
state._cockpitHiddenPath = null;
state._cockpitHiddenDot = false;
/** The time satellites are propagated to, in epoch ms. */
state.now = worldTimeFor('satellites');
+3
View File
@@ -120,6 +120,9 @@ export function createTracking({ state: layerState, services, parts, source }) {
}
layerState._trackedNorad = null;
parts.controls._syncIssOrbit?.();
// A held Cockpit subject that stops being tracked gets its marks back.
if (layerState._cockpitSubjectNorad !== null)
parts.cockpit?._syncCockpitSubjectVisuals();
parts.labels._syncIssOverlay();
clearTrackedSubjectContext('satellites');
layerState._contextRefreshedAtMs = 0;
+10
View File
@@ -362,6 +362,7 @@ export class StyleManager extends ShellFacade {
flightsLayer: services.flightsLayer,
militaryFlightsLayer: services.militaryFlightsLayer,
aisLiveVesselsLayer: services.aisLiveVesselsLayer,
satellitesLayer: services.satellitesLayer,
isTr3b: services.isTr3b,
toggleTr3b: services.toggleTr3b,
militaryAwarenessLayer: services.militaryAwarenessLayer,
@@ -376,6 +377,13 @@ export class StyleManager extends ShellFacade {
fetchRegionalBrief: services.fetchRegionalBrief,
regionalDistanceM: services.regionalDistanceM,
weatherCodeLabel: services.weatherCodeLabel,
// Orbit reads flat Esri imagery: Google 3D tiles stay coarse from
// hundreds of kilometres up. The lease puts the map back on exit.
acquireOrbitImagery: () =>
this.mapStackController?.acquireImageryComparison?.({
owner: 'cockpit-orbit',
switchPolicy: 'esri-if-photoreal',
}) ?? null,
},
elements: {
_ppToggles: this._ppToggles,
@@ -403,6 +411,8 @@ export class StyleManager extends ShellFacade {
useCockpitStore(applicationPinia()).publishSignals(rows),
radar: (view) =>
useCockpitStore(applicationPinia()).publishRadar(view),
orbital: (view) =>
useCockpitStore(applicationPinia()).publishOrbital(view),
utilityLayout: (layout) =>
useCockpitStore(applicationPinia()).publishUtilityLayout(layout),
},
+3 -2
View File
@@ -1,7 +1,8 @@
// src/ui/cockpitBodyClasses.test.mjs — the classes the Cockpit puts on
// <body> must not also name an element's rule. A bare `.name { … }` written
// for a panel matches <body> the moment <body> carries `name`, and its
// pointer-events, position and overflow then apply to the whole page.
// pointer-events, position and overflow then apply to the whole page (a
// shared `cockpit-orbital` once made every click in orbit fall through).
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
@@ -52,7 +53,7 @@ function cockpitBodyClasses() {
test('the Cockpit adds its subject classes to <body>', () => {
const classes = cockpitBodyClasses();
for (const name of ['cockpit-mode', 'cockpit-bridge']) {
for (const name of ['cockpit-mode', 'cockpit-bridge', 'cockpit-orbit']) {
assert.ok(classes.includes(name), `${name} is not added to <body>`);
}
});
+67 -17
View File
@@ -424,23 +424,33 @@ test("turning off the ridden aircraft's layer ends the cockpit", async (t) => {
assert.equal(cockpit.active, false);
});
test('C enters the selected vessel bridge and aircraft-only entry refuses it', async (t) => {
const { cockpit } = fixture(t);
test('BRIDGE enters the selected vessel bridge and aircraft-only entry refuses it', async (t) => {
const { $, cockpit } = fixture(t);
assert.equal(cockpit.enter({ kind: 'aircraft' }), false);
const event = {
key: 'c',
target: document.body,
defaultPrevented: false,
preventDefault() {
this.defaultPrevented = true;
},
stopImmediatePropagation() {},
};
cockpit.onKeyDown(event);
cockpit.syncEntry();
await settle();
$('#cockpit-entry').click();
await settle();
assert.equal(cockpit.active, true);
assert.equal(cockpit.subjectKind, 'vessel');
assert.equal(event.defaultPrevented, true);
});
test('C neither enters nor leaves the bridge; it stays the CCTV shortcut', async (t) => {
const { cockpit } = fixture(t);
const outside = press(cockpit, 'c');
await settle();
assert.equal(cockpit.active, false, 'C does not enter');
assert.equal(outside.defaultPrevented, false);
assert.equal(outside.propagationStopped, false);
cockpit.enter();
await settle();
assert.equal(cockpit.subjectKind, 'vessel');
for (const key of ['c', 'C']) {
const inside = press(cockpit, key);
assert.equal(cockpit.active, true, `${key} does not exit`);
assert.equal(inside.defaultPrevented, false);
assert.equal(inside.propagationStopped, false);
}
});
test('C stays the CCTV shortcut when the bridge gate is shut', async (t) => {
@@ -501,10 +511,13 @@ function press(
repeat,
target,
defaultPrevented: false,
propagationStopped: false,
preventDefault() {
this.defaultPrevented = true;
},
stopImmediatePropagation() {},
stopImmediatePropagation() {
this.propagationStopped = true;
},
};
cockpit.onKeyDown(event);
return event;
@@ -696,11 +709,9 @@ test('a held arrow keeps turning the bridge view', async (t) => {
assert.equal(event.defaultPrevented, true);
}
assert.equal(cockpit.lookTarget.yawDeg, -30);
// A held Home or C still acts only once.
// A held Home still acts only once.
press(cockpit, 'Home', { repeat: true });
assert.equal(cockpit.lookTarget.yawDeg, -30);
press(cockpit, 'c', { repeat: true });
assert.equal(cockpit.active, true);
});
test('Home, 0 and LOOK AHEAD bring the bridge view back over the bow', async (t) => {
@@ -844,6 +855,41 @@ test('arrows do nothing new in an aircraft cockpit', async (t) => {
);
});
test('C neither enters nor leaves an aircraft cockpit', async (t) => {
const aircraftInfo = {
icao24: 'abc123',
callsign: 'TEST1',
latitude: 54.5,
longitude: 10.2,
altitudeM: 3000,
velocityMps: 200,
track: 45,
onGround: false,
};
const { cockpit, viewer, aircraft } = fixture(t, { aircraftInfo });
viewer.trackedEntity = aircraft;
const outside = press(cockpit, 'c');
await settle();
assert.equal(cockpit.active, false, 'C does not enter');
assert.equal(outside.defaultPrevented, false);
assert.equal(outside.propagationStopped, false);
assert.equal(cockpit.enter({ kind: 'aircraft' }), true);
await settle();
const inside = press(cockpit, 'c');
assert.equal(cockpit.active, true, 'C does not exit');
assert.equal(inside.propagationStopped, false);
assert.doesNotMatch(
document.querySelector('.cockpit-help').textContent,
/C TOGGLE/,
);
assert.doesNotMatch(
document
.querySelector('#map-view-switch')
.getAttribute('aria-keyshortcuts'),
/C/,
);
});
/** The eye's ellipsoidal height in a placed camera, metres. */
const eyeHeightM = (shot) =>
Cesium.Cartographic.fromCartesian(shot.destination).height;
@@ -1162,6 +1208,10 @@ test('both entries name the subject the same way', async () => {
icon: 'directions_boat',
label: 'BRIDGE',
});
assert.deepEqual(cockpitEntryFace('satellite'), {
icon: 'satellite_alt',
label: 'ORBIT',
});
assert.deepEqual(cockpitEntryFace('aircraft'), {
icon: 'flight',
label: 'COCKPIT',
+1 -1
View File
@@ -153,7 +153,7 @@ export function maybeRefreshRegionalBrief(info) {
!Number.isFinite(info.longitude)
)
return;
const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.mmsi || info.registration || info.callsign || 'unknown'}`;
const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.mmsi || info.noradId || info.registration || info.callsign || 'unknown'}`;
if (subjectId !== this.regionalBriefSubjectId) {
this.regionalBriefAbort?.abort();
this.regionalBriefAbort = null;
+4
View File
@@ -57,6 +57,10 @@ export function update() {
updateVessel.call(this, nowMs);
return;
}
if (this.subjectKind === 'satellite') {
this.updateOrbital(nowMs);
return;
}
const info = this.readAircraftInfo();
// Adopt a newly selected track before the camera cadence gate. A context
+82 -2
View File
@@ -1,6 +1,7 @@
/** Coordinate Cockpit lifecycle through supplied aircraft, terrain, rendering and briefing operations. */
import {
readAircraftInfo,
readSatelliteInfo,
readVesselInfo,
readSubjectLayerId,
resolveEntryKind,
@@ -15,6 +16,15 @@ import {
exit,
} from './cockpitTrackingController.js';
import { update } from './cockpitCamera.js';
import {
beginOrbital,
endOrbital,
resetOrbitalLook,
stepOrbitalZoom,
turnOrbitalLook,
updateOrbital,
updateOrbitalHud,
} from './cockpitOrbitalView.js';
import {
publishInstrumentsView,
publishRouteView,
@@ -95,6 +105,7 @@ export class CockpitViewController {
onCameraTakeover = null,
isEntryAllowed = null,
isBridgeEntryAllowed = null,
isOrbitEntryAllowed = null,
isUtilityDisclosureOpen = null,
onEntered = null,
onExited = null,
@@ -108,10 +119,21 @@ export class CockpitViewController {
this.destroyed = false;
this.disposed = false;
this.active = false;
/** 'aircraft' or 'vessel' while active; which subject the camera rides. */
/**
* 'aircraft', 'vessel' or 'satellite' while active; which subject the
* camera rides.
*/
this.subjectKind = null;
/** The vessel subject's MMSI; aircraft are identified by trackedEntity. */
/**
* The vessel subject's MMSI or the satellite's NORAD id; aircraft are
* identified by trackedEntity.
*/
this.subjectId = null;
/**
* The orbital view's zoom, look and saved field of view while a
* satellite is ridden (src/ui/cockpitOrbitalView.js); null otherwise.
*/
this.orbital = null;
this.trackedEntity = null;
this.trackedEntityWasShown = true;
this.heading = null;
@@ -162,6 +184,11 @@ export class CockpitViewController {
typeof isBridgeEntryAllowed === 'function'
? isBridgeEntryAllowed
: () => false;
// Nor a satellite layer to ride without one.
this.isOrbitEntryAllowed =
typeof isOrbitEntryAllowed === 'function'
? isOrbitEntryAllowed
: () => false;
this.isUtilityDisclosureOpen =
typeof isUtilityDisclosureOpen === 'function'
? isUtilityDisclosureOpen
@@ -197,6 +224,7 @@ export class CockpitViewController {
this.publishSignals = publish.signals ?? (() => {});
this.publishUtilityLayout = publish.utilityLayout ?? (() => {});
this.publishRadar = publish.radar ?? (() => {});
this.publishOrbital = publish.orbital ?? (() => {});
/** The bridge radar's range in nautical miles; kept across bridges. */
this.radarRangeNm = DEFAULT_RADAR_RANGE_NM;
this.presence = {
@@ -302,6 +330,15 @@ export class CockpitViewController {
this._listen(document, 'click', (event) => {
if (event.target?.closest?.('#cockpit-look-ahead')) this.lookAhead();
});
// The orbital HUD mounts only in orbit, so its controls are delegated.
this._listen(document, 'click', (event) => {
const target = event.target;
if (target?.closest?.('#cockpit-orbital-zoom-in'))
this.stepOrbitalZoom(1);
else if (target?.closest?.('#cockpit-orbital-zoom-out'))
this.stepOrbitalZoom(-1);
else if (target?.closest?.('#cockpit-orbital-nadir')) this.orbitalNadir();
});
this._listen(this.signalToggle, 'click', () =>
this.setSignalCollapsed(!this.signalCollapsed, { user: true }),
);
@@ -346,6 +383,49 @@ export class CockpitViewController {
readVesselInfo() {
return readVesselInfo.call(this);
}
readSatelliteInfo() {
return readSatelliteInfo.call(this);
}
beginOrbital() {
return beginOrbital.call(this);
}
endOrbital() {
return endOrbital.call(this);
}
updateOrbital(nowMs) {
return updateOrbital.call(this, nowMs);
}
updateOrbitalHud(info, nowMs) {
return updateOrbitalHud.call(this, info, nowMs);
}
/**
* Step the orbital view's optical zoom (the + and − keys and buttons).
* @param {number} direction Positive zooms in, negative out.
* @returns {boolean} True when the zoom changed.
*/
stepOrbitalZoom(direction) {
return stepOrbitalZoom.call(this, direction);
}
/**
* Tilt the orbital view one arrow step off nadir.
* @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step
* @returns {boolean} True in an orbital view.
*/
turnOrbitalLook(step) {
return turnOrbitalLook.call(this, step);
}
/**
* The NADIR control and the Home and 0 keys in orbit: back to pure nadir.
* Focus leaves the button before it hides.
* @returns {boolean} True in an orbital view.
*/
orbitalNadir() {
const button = document.getElementById('cockpit-orbital-nadir');
const hadFocus = !!button && button.contains(document.activeElement);
const reset = resetOrbitalLook.call(this);
if (reset && hadFocus) this.mapViewButton?.focus({ preventScroll: true });
return reset;
}
readSubjectLayerId() {
return readSubjectLayerId.call(this);
}
+43 -11
View File
@@ -4,6 +4,7 @@ import {
COCKPIT_AIRCRAFT_LAYER_IDS,
bridgeEntryAllowed,
cockpitEntryAllowed,
orbitEntryAllowed,
} from '../contextModePolicy.js';
import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js';
import { enterCockpitWithTracking } from '../cockpitTracking.js';
@@ -42,6 +43,7 @@ export class CockpitCoordinator {
flightsLayer,
militaryFlightsLayer,
aisLiveVesselsLayer,
satellitesLayer,
isTr3b,
toggleTr3b,
militaryAwarenessLayer,
@@ -56,6 +58,7 @@ export class CockpitCoordinator {
fetchRegionalBrief,
regionalDistanceM,
weatherCodeLabel,
acquireOrbitImagery,
} = services;
this._cockpitDisplayPortal = null;
this.cockpitView = new CockpitViewController(viewer, {
@@ -63,6 +66,7 @@ export class CockpitCoordinator {
flightsLayer,
militaryFlightsLayer,
aisLiveVesselsLayer,
satellitesLayer,
isTr3b,
toggleTr3b,
militaryAwarenessLayer,
@@ -78,6 +82,7 @@ export class CockpitCoordinator {
fetchRegionalBrief,
regionalDistanceM,
weatherCodeLabel,
acquireOrbitImagery,
// The Contact card and Live Signals say which feeds are off.
isLayerEnabled: (layerId) => this._isLayerEnabled(layerId),
},
@@ -101,6 +106,14 @@ export class CockpitCoordinator {
vesselsEnabled: !!this._dataManager?.isEnabled('ais-live-vessels'),
contextModeChanging: this._contextModeChanging,
}),
isOrbitEntryAllowed: () =>
orbitEntryAllowed({
satellitesEnabled: !!this._dataManager?.isEnabled('satellites'),
contextModeChanging: this._contextModeChanging,
// The layer says whether its class may be ridden.
subjectEnterable:
satellitesLayer?.getCockpitSubject?.()?.enterable !== false,
}),
publish: this.publishCockpit,
isUtilityDisclosureOpen: () => !!this.readUtilityDisclosureOpen?.(),
onEntered: () => this.enterPanels(),
@@ -176,6 +189,12 @@ export class CockpitCoordinator {
// vessel selection before the entry can run, leaving neither.
if (this.cockpitView?.active && this.cockpitView.subjectKind === 'vessel')
return 'Cockpit is on a vessel bridge — exit the bridge first, then track an aircraft to enter Cockpit';
// Likewise in orbit: the aircraft's tracking takes the satellite's.
if (
this.cockpitView?.active &&
this.cockpitView.subjectKind === 'satellite'
)
return 'Cockpit is in an orbital view — exit it first, then track an aircraft to enter Cockpit';
if (!this._aircraftLayerEnabled)
return 'No aircraft layer is on — turn on Flights or Military Flights and track an aircraft to enter Cockpit';
return null;
@@ -190,6 +209,11 @@ export class CockpitCoordinator {
active && this.cockpitView?.subjectKind === 'vessel'
? this.cockpitView.readVesselInfo?.()
: null;
// Likewise an orbital view in use: its satellite, never an aircraft.
const satellite =
active && this.cockpitView?.subjectKind === 'satellite'
? this.cockpitView.readSatelliteInfo?.()
: null;
const gateOpen = Boolean(this.cockpitView?.isEntryAllowed?.());
// "Could Cockpit be ENTERED right now" — so it is false while already
// inside, unconditionally. Cockpit takes the entity off
@@ -218,22 +242,30 @@ export class CockpitCoordinator {
? 'subject-layer-off'
: 'no-tracked-aircraft',
visionMode: this.cockpitView?.visionMode || null,
subject: vessel
subject: satellite
? {
kind: 'vessel',
id: vessel.mmsi,
layerId: vessel.layerId,
kind: 'satellite',
id: satellite.id,
layerId: satellite.layerId,
callsign: null,
name: vessel.name,
name: satellite.name,
}
: info
: vessel
? {
kind: 'aircraft',
id: info.icao24 || info.id || null,
layerId: info.layerId || null,
callsign: info.callsign || null,
kind: 'vessel',
id: vessel.mmsi,
layerId: vessel.layerId,
callsign: null,
name: vessel.name,
}
: null,
: info
? {
kind: 'aircraft',
id: info.icao24 || info.id || null,
layerId: info.layerId || null,
callsign: info.callsign || null,
}
: null,
navigation: snapshot
? {
canPrevious: Boolean(snapshot.navigation?.canPrevious),
+129 -1
View File
@@ -25,6 +25,20 @@ const CIVIL = Object.freeze({
latitude: 30,
longitude: -97,
});
/** A tracked satellite, as the Satellites layer reports it. */
const SATELLITE = Object.freeze({
kind: 'satellite',
layerId: 'satellites',
id: '25544',
noradId: 25544,
name: 'ISS (ZARYA)',
classLabel: 'STATION · ISS',
latitude: 30,
longitude: -97,
altitudeM: 420_000,
velocityMps: 7_660,
stale: false,
});
const MILITARY = Object.freeze({
...CIVIL,
icao24: 'ae0001',
@@ -36,6 +50,7 @@ function fixture(
{
enabled = [],
tracked = null,
satellite = null,
contextMode = null,
changing = false,
dataManager: suppliedManager = null,
@@ -87,6 +102,13 @@ function fixture(
flightsLayer: { getTrackedInfo: trackedFrom('flights') },
militaryFlightsLayer: { getTrackedInfo: trackedFrom('military') },
aisLiveVesselsLayer: { getCockpitSubject: () => null },
// Like the layer, no subject while Satellites is off.
satellitesLayer: {
getCockpitSubject: () =>
satellite && layers.has('satellites') ? { ...satellite } : null,
getCockpitPosition: () => null,
setCockpitSubject: () => true,
},
militaryAwarenessLayer: { getContextSnapshot: () => null },
isTr3b: () => false,
holdContinuousRender() {},
@@ -121,7 +143,11 @@ function fixture(
t.after(() => {
coordinator.stop();
app.unmount();
document.body.classList.remove('cockpit-mode', 'cockpit-bridge');
document.body.classList.remove(
'cockpit-mode',
'cockpit-bridge',
'cockpit-orbit',
);
});
/** Turn a layer off and tell the Context owner, as the data manager does. */
const disable = (layerId) => {
@@ -220,6 +246,26 @@ test('voice entry on a vessel bridge refuses before it touches tracking', (t) =>
assert.equal(cockpit.subjectKind, 'vessel');
});
test('voice entry in an orbital view refuses before it touches tracking', (t) => {
const { coordinator, cockpit } = fixture(t, { enabled: ['flights'] });
let navigated = false;
coordinator.services.militaryAwarenessLayer.navigateNext = () => {
navigated = true;
return true;
};
cockpit.active = true;
cockpit.subjectKind = 'satellite';
t.after(() => {
cockpit.active = false;
cockpit.subjectKind = null;
});
const voice = coordinator.controlCockpit('enter', { targetLayer: 'flights' });
assert.equal(voice.ok, false);
assert.match(voice.error, /orbital view/);
assert.equal(navigated, false, 'no aircraft was tracked');
assert.equal(cockpit.subjectKind, 'satellite');
});
test('with an aircraft layer on but nothing tracked, the reason is the missing aircraft', (t) => {
const { coordinator } = fixture(t, { enabled: ['military'] });
assert.equal(
@@ -520,3 +566,85 @@ test('the readout entry reads BRIDGE for a selected vessel', async (t) => {
assert.match(readoutEntry.textContent, /directions_boat/);
assert.match(readoutEntry.textContent, /BRIDGE/);
});
test('a tracked satellite needs Satellites on for the ORBIT entry', async (t) => {
const { $, cockpit } = fixture(t, { satellite: SATELLITE });
cockpit.syncEntry();
await nextTick();
assert.equal($('#cockpit-entry').hidden, true, 'Satellites off: no entry');
assert.equal(cockpit.enter(), false);
});
test('with Satellites on, the tracked satellite offers ORBIT and reports itself', async (t) => {
const { $, cockpit, coordinator, pinia } = fixture(t, {
enabled: ['satellites'],
satellite: SATELLITE,
});
useHudStore(pinia);
cockpit.syncEntry();
await nextTick();
for (const selector of ['#cockpit-entry', '#cockpit-entry-readout']) {
assert.equal($(selector).hidden, false, selector);
assert.match($(selector).textContent, /satellite_alt/);
assert.match($(selector).textContent, /ORBIT/);
}
// Voice stays aircraft-only: it neither offers nor takes the satellite.
assert.equal(coordinator.getCockpitState().entryAllowed, false);
assert.equal(coordinator.controlCockpit('enter').ok, false);
assert.equal(cockpit.active, false);
$('#cockpit-entry-readout').click();
await nextTick();
assert.equal(cockpit.active, true);
assert.equal(cockpit.subjectKind, 'satellite');
const state = coordinator.getCockpitState();
assert.equal(state.active, true);
assert.deepEqual(state.subject, {
kind: 'satellite',
id: '25544',
layerId: 'satellites',
callsign: null,
name: 'ISS (ZARYA)',
});
});
test('tracked debris offers no ORBIT entry and refuses to be entered', async (t) => {
const { $, cockpit } = fixture(t, {
enabled: ['satellites'],
satellite: {
...SATELLITE,
id: '27386',
noradId: 27386,
name: 'SL-16 R/B',
satelliteClass: 'debris',
enterable: false,
},
});
cockpit.syncEntry();
await nextTick();
for (const selector of ['#cockpit-entry', '#cockpit-entry-readout'])
assert.equal($(selector).hidden, true, selector);
assert.equal(cockpit.enter(), false);
assert.equal(cockpit.enter({ kind: 'satellite' }), false);
assert.equal(cockpit.active, false);
});
test('a Context change in flight blocks the orbital view too', (t) => {
const { cockpit } = fixture(t, {
enabled: ['satellites'],
satellite: SATELLITE,
changing: true,
});
assert.equal(cockpit.enter(), false);
});
test('turning Satellites off ends the orbital view', (t) => {
const { cockpit, disable } = fixture(t, {
enabled: ['satellites', 'flights'],
satellite: SATELLITE,
});
assert.equal(cockpit.enter(), true);
disable('flights');
assert.equal(cockpit.active, true, 'other feeds are free to go off');
disable('satellites');
assert.equal(cockpit.active, false);
});
+32 -31
View File
@@ -39,6 +39,8 @@ function subjectWorldPosition(kind) {
) ?? null
);
}
if (kind === 'satellite')
return this.services.satellitesLayer?.getCockpitPosition?.() ?? null;
const info = this.readVesselInfo();
if (!Number.isFinite(info?.latitude) || !Number.isFinite(info?.longitude))
return null;
@@ -87,7 +89,7 @@ export function onScenePointerDown(nowMs = performance.now()) {
/**
* Enter first person when the followed track itself is double-clicked: the
* same entry, and the same gates, as C and the entry buttons. The click may
* same entry, and the same gates, as the entry buttons. The click may
* land where the track is drawn now or where it was as the gesture began. A
* double-click anywhere else, or one a tool owns (drawing ends a shape with
* one), is left alone.
@@ -194,9 +196,35 @@ function handleLookKey(event) {
return true;
}
/** Zoom keys in orbit: `+` (or `=`, unshifted) in and `-` (or `_`) out. */
const ORBITAL_ZOOM_KEYS = Object.freeze({ '+': 1, '=': 1, '-': -1, _: -1 });
/**
* The Cockpit's capture-phase keydown: Escape and C, and on a bridge the
* look-around keys.
* In an orbital view: tilt off nadir with the arrows (single 5° steps; Shift
* does not take the bridge's 45° step), return to nadir with Home or 0, and
* step the zoom with + and −. The bridge's focus guards apply.
* @returns {boolean} True when the key was taken.
*/
function handleOrbitalKey(event) {
if (!this.active || this.subjectKind !== 'satellite') return false;
if (event.metaKey || event.ctrlKey || event.altKey) return false;
const step = lookKeyStep(event.key);
const reset = !event.repeat && (event.key === 'Home' || event.key === '0');
const zoom = event.repeat ? 0 : ORBITAL_ZOOM_KEYS[event.key] || 0;
if (!step && !reset && !zoom) return false;
if (event.target?.closest?.(LOOK_KEY_OWNERS)) return false;
if (this.isUtilityDisclosureOpen?.()) return false;
event.preventDefault();
if (step) this.turnOrbitalLook(step);
else if (reset) this.orbitalNadir();
else this.stepOrbitalZoom(zoom);
return true;
}
/**
* The Cockpit's capture-phase keydown: Escape, on a bridge the look-around
* keys and in orbit the tilt and zoom keys. C is left to the app-wide CCTV
* shortcut; entry is by double-click or the entry buttons.
* @param {KeyboardEvent} event
*/
export function onKeyDown(event) {
@@ -247,32 +275,5 @@ export function onKeyDown(event) {
if (event.target?.closest?.('input, textarea, select, [contenteditable]'))
return;
if (handleLookKey.call(this, event)) return;
const key = event.key?.toLowerCase();
if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) {
if (!this.active) {
const cockpitAttempt = !!(
this.readAircraftInfo() && this.viewer.trackedEntity?.position
);
// With no tracked aircraft, C takes the selected vessel's bridge, but
// only when the bridge can be entered: otherwise C stays the CCTV
// shortcut, as it is with nothing selected.
const bridgeAttempt =
!cockpitAttempt &&
!!this.readVesselInfo() &&
this.isBridgeEntryAllowed();
if (!cockpitAttempt && !bridgeAttempt) return;
if (bridgeAttempt) {
event.preventDefault();
event.stopImmediatePropagation();
this.enter({ kind: 'vessel' });
return;
}
}
event.preventDefault();
event.stopImmediatePropagation();
if (!this.active && !this.isEntryAllowed()) return;
if (this.active) this.exit();
else this.enter({ kind: 'aircraft' });
return;
}
handleOrbitalKey.call(this, event);
}
+5
View File
@@ -140,6 +140,11 @@ export function updateHud(
updateVesselHud.call(this, info, nowMs, forceContext);
return;
}
// The orbital view keeps its own HUD (CockpitOrbital).
if (info?.kind === 'satellite') {
this.updateOrbitalHud(info, nowMs);
return;
}
this.lastSubjectInfo = info;
const view = this.instruments;
view.kind = 'aircraft';
+263
View File
@@ -0,0 +1,263 @@
// src/ui/cockpitOrbital.test.mjs — the orbital view HUD shows what the
// Cockpit controller publishes to the store, over happy-dom.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPinia } from 'pinia';
import { createApp, nextTick } from 'vue';
import { useCockpitStore } from './stores/cockpit.js';
import CockpitOrbital from './views/panels/CockpitOrbital.vue';
const VIEW = Object.freeze({
name: 'ISS (ZARYA)',
classLabel: 'STATION · ISS',
altitudeM: 418_400,
velocityMps: 7_662,
latitude: 12.3456,
longitude: -45.6789,
zoom: 4,
zoomSteps: [1, 2, 4, 8],
footprintKm: 61.4,
transition: { active: false, blurPx: 0, lockTick: false },
});
function fixture(t) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(CockpitOrbital).use(pinia);
app.mount(host);
t.after(() => app.unmount());
const store = useCockpitStore(pinia);
const section = () => document.getElementById('cockpit-orbital');
const text = (selector) =>
section().querySelector(selector)?.textContent.trim() ?? null;
return { store, section, text };
}
test('the orbital HUD is hidden until a satellite view is published', async (t) => {
const f = fixture(t);
assert.equal(f.section().hidden, true);
assert.equal(f.section().querySelector('#cockpit-orbital-zoom-in'), null);
});
test('a published orbital view renders its readouts and zoom controls', async (t) => {
const f = fixture(t);
f.store.publishOrbital({ ...VIEW });
await nextTick();
assert.equal(f.section().hidden, false);
assert.equal(f.text('.cockpit-orbital-kicker'), 'ORBITAL VIEW · NADIR');
assert.equal(f.text('.cockpit-orbital-name'), 'ISS (ZARYA)');
assert.equal(f.text('.cockpit-orbital-class'), 'STATION · ISS');
assert.equal(f.text('.cockpit-orbital-altitude'), '418 KM');
assert.equal(f.text('.cockpit-orbital-velocity'), '7.66 KM/S');
assert.match(f.text('.cockpit-orbital-subpoint'), /12\.346°N.*45\.679°W/);
assert.equal(f.text('.cockpit-orbital-north'), 'NORTH UP');
assert.equal(f.text('.cockpit-orbital-zoom'), 'ZOOM 4×');
assert.equal(f.text('.cockpit-orbital-swath'), 'SWATH 61 KM');
const zoomIn = document.getElementById('cockpit-orbital-zoom-in');
const zoomOut = document.getElementById('cockpit-orbital-zoom-out');
assert.equal(zoomIn.getAttribute('aria-label'), 'Zoom in');
assert.equal(zoomOut.getAttribute('aria-label'), 'Zoom out');
assert.equal(zoomIn.disabled, false);
assert.equal(zoomOut.disabled, false);
assert.ok(f.section().querySelector('.cockpit-orbital-reticle'));
});
test('zoom buttons stop at the ends of the zoom steps', async (t) => {
const f = fixture(t);
f.store.publishOrbital({ ...VIEW, zoom: 8, footprintKm: 7.25 });
await nextTick();
assert.equal(
document.getElementById('cockpit-orbital-zoom-in').disabled,
true,
);
assert.equal(f.text('.cockpit-orbital-swath'), 'SWATH 7.3 KM');
f.store.publishOrbital({ ...VIEW, zoom: 1 });
await nextTick();
assert.equal(
document.getElementById('cockpit-orbital-zoom-out').disabled,
true,
);
assert.equal(
document.getElementById('cockpit-orbital-zoom-in').disabled,
false,
);
});
test('an unknown velocity leaves its row out', async (t) => {
const f = fixture(t);
f.store.publishOrbital({ ...VIEW, velocityMps: null });
await nextTick();
assert.equal(f.section().querySelector('.cockpit-orbital-velocity'), null);
assert.equal(f.text('.cockpit-orbital-altitude'), '418 KM');
});
test('a zoom step reflects its blur and lock tick', async (t) => {
const f = fixture(t);
f.store.publishOrbital({
...VIEW,
transition: { active: true, blurPx: 4.5, lockTick: false },
});
await nextTick();
assert.ok(f.section().classList.contains('cockpit-orbital-focusing'));
assert.equal(
f.section().style.getPropertyValue('--cockpit-orbital-blur'),
'4.50px',
);
const reticle = f.section().querySelector('.cockpit-orbital-reticle');
assert.equal(
reticle.classList.contains('cockpit-orbital-reticle-lock'),
false,
);
f.store.publishOrbital({
...VIEW,
transition: { active: false, blurPx: 0, lockTick: true },
});
await nextTick();
assert.ok(!f.section().classList.contains('cockpit-orbital-focusing'));
assert.equal(
f.section().style.getPropertyValue('--cockpit-orbital-blur'),
'0px',
);
assert.ok(
f
.section()
.querySelector('.cockpit-orbital-reticle')
.classList.contains('cockpit-orbital-reticle-lock'),
);
});
test('publishing null hides the orbital HUD again', async (t) => {
const f = fixture(t);
f.store.publishOrbital({ ...VIEW });
await nextTick();
assert.equal(f.section().hidden, false);
f.store.publishOrbital(null);
await nextTick();
assert.equal(f.section().hidden, true);
assert.equal(f.section().querySelector('.cockpit-orbital-name'), null);
});
const ISS_TLE = Object.freeze({
line1:
'1 25544U 98067A 26308.34166667 .00016717 00000-0 30306-3 0 9993',
line2:
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.50377579477310',
epochMs: Date.UTC(2026, 10, 4, 8, 12, 0),
});
test('the place below shows at the top once known', async (t) => {
const f = fixture(t);
f.store.publishOrbital({ ...VIEW, place: undefined });
await nextTick();
assert.equal(f.section().querySelector('#cockpit-orbital-over'), null);
const over = () =>
f.section().querySelector('#cockpit-orbital-over strong')?.textContent;
f.store.publishOrbital({
...VIEW,
place: { kind: 'country', name: 'France', countryCode: 'FR' },
});
await nextTick();
assert.match(f.text('#cockpit-orbital-over'), /^CURRENTLY OVER/);
assert.equal(over(), 'France');
// The division as Natural Earth names it, the country localized.
const sichuan = {
kind: 'country',
name: 'China',
countryCode: 'CN',
division: 'Sichuan',
near: null,
};
f.store.publishOrbital({ ...VIEW, place: sichuan });
await nextTick();
assert.equal(over(), 'Sichuan, China');
// From 8x, the populated place in view.
f.store.publishOrbital({ ...VIEW, place: { ...sichuan, near: 'Chengdu' } });
await nextTick();
assert.equal(over(), 'Sichuan, China · near Chengdu');
f.store.publishOrbital({
...VIEW,
place: { kind: 'sea', name: 'Caspian Sea', countryCode: null },
});
await nextTick();
assert.equal(over(), 'Caspian Sea');
// No feature holds the point (a gap at a simplified coastline): the band
// says nothing rather than call land open ocean.
f.store.publishOrbital({ ...VIEW, place: null });
await nextTick();
assert.equal(document.querySelector('#cockpit-orbital-over'), null);
});
test('the readout names the owner, operator and element set', async (t) => {
const f = fixture(t);
f.store.publishOrbital({
...VIEW,
noradId: 25544,
cosparId: '1998-067A',
tle: ISS_TLE,
// Under node there is no flag glob, so a country shows as a badge.
owner: { countryCode: 'US', label: 'USA' },
operator: 'NASA',
});
await nextTick();
assert.equal(f.text('.cockpit-orbital-name'), 'ISS (ZARYA)');
assert.equal(f.text('.cockpit-orbital-owner-badge'), 'USA');
assert.equal(
f.section().querySelector('.cockpit-orbital-owner-badge').title,
'United States',
);
assert.equal(f.text('.cockpit-orbital-operator'), 'NASA');
assert.equal(
f.text('.cockpit-orbital-ident-text'),
'NORAD 25544 · 1998-067A · EPOCH 2026-11-04 08:12Z',
);
const toggle = document.getElementById('cockpit-orbital-tle-toggle');
const tle = document.getElementById('cockpit-orbital-tle');
assert.equal(toggle.getAttribute('aria-expanded'), 'false');
assert.equal(tle.style.display, 'none', 'folded');
toggle.click();
await nextTick();
assert.equal(toggle.getAttribute('aria-expanded'), 'true');
assert.notEqual(tle.style.display, 'none');
assert.equal(tle.textContent, `${ISS_TLE.line1}\n${ISS_TLE.line2}`);
// Another satellite starts folded.
f.store.publishOrbital({ ...VIEW, noradId: 48274, tle: ISS_TLE });
await nextTick();
assert.equal(
document
.getElementById('cockpit-orbital-tle-toggle')
.getAttribute('aria-expanded'),
'false',
);
});
test('unknown identity fields leave their rows out', async (t) => {
const f = fixture(t);
f.store.publishOrbital({
...VIEW,
noradId: null,
cosparId: null,
tle: null,
owner: null,
operator: null,
});
await nextTick();
for (const selector of [
'.cockpit-orbital-flag',
'.cockpit-orbital-owner-badge',
'.cockpit-orbital-operator',
'.cockpit-orbital-ident',
'#cockpit-orbital-tle',
])
assert.equal(f.section().querySelector(selector), null, selector);
// An element set with no NORAD id still dates itself.
f.store.publishOrbital({ ...VIEW, tle: ISS_TLE });
await nextTick();
assert.equal(
f.text('.cockpit-orbital-ident-text'),
'EPOCH 2026-11-04 08:12Z',
);
});
+567
View File
@@ -0,0 +1,567 @@
/** The orbital (satellite) view of the Cockpit controller: camera, zoom, look and HUD. */
import { formatNumber, msg, t } from '../i18n/index.js';
import {
ORBITAL_BASE_MAX_ZOOM,
ORBITAL_DEFAULT_ZOOM,
ORBITAL_SETTLE_BLUR_PX,
ORBITAL_ZOOM_LADDER,
ORBITAL_ZOOM_TRANSITION_MS,
cesiumFrustumFovRad,
footprintWidthKm,
fovForZoom,
interpolateZoomFov,
nadirOrientation,
nextOrbitalZoom,
offNadirAngleDeg,
offNadirOrientation,
offNadirCentreMeetsEarth,
offNadirSwathScale,
orbitalBaseFovRad,
previousOrbitalZoom,
zoomSettleHolding,
zoomTransitionState,
} from '../cockpitOrbital.js';
import {
clampOrbitalLook,
easeLook,
lookIsOffset,
orbitalLookReadoutParts,
stepOrbitalLook,
} from '../cockpitLook.js';
import { cockpitUiUpdateDue } from '../cockpitMath.js';
import { createPlaceTracker, nearPlaceRadiusKm } from '../data/placeBelow.js';
import {
COCKPIT_CONTEXT_UPDATE_MS,
COCKPIT_HUD_UPDATE_MS,
compassPointLabel,
formatCockpitCoordinate,
} from './cockpitPresentation.js';
/**
* How often the orbital view asks for a regional brief. A low orbit crosses
* the briefing's 25 km refresh distance every few seconds, so the sub-satellite
* point is briefed on a clock instead.
*/
export const ORBITAL_BRIEF_REFRESH_MS = 60_000;
/** Whether the user asked for reduced motion: zoom steps and tilts cut. */
function prefersReducedMotion() {
return Boolean(
globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches,
);
}
const IDLE_TRANSITION = Object.freeze({
active: false,
blurPx: 0,
lockTick: false,
});
const NADIR_LOOK = Object.freeze({ yawDeg: 0, pitchDeg: 0 });
/**
* Start an orbital session's state. Keeps the camera's own field of view so
* the exit can put it back exactly.
*/
export function beginOrbital() {
const frustum = this.viewer.camera?.frustum;
const state = {
imagery: acquireOrbitImagery(this.services),
zoom: ORBITAL_DEFAULT_ZOOM,
// A zoom step in flight: the magnification it started from and when.
transition: null,
previousUp: null,
savedFov: Number.isFinite(frustum?.fov) ? frustum.fov : null,
altitudeM: null,
verticalFov: null,
lastBriefMs: 0,
lookTarget: { ...NADIR_LOOK },
lookView: { ...NADIR_LOOK },
lookLastMs: performance.now(),
// What the sub-satellite point is over: undefined until the first
// lookup lands, then a place or null (nothing named there).
place: undefined,
placeTracker: null,
};
state.placeTracker = createPlaceTracker({
lookup: this.services.placeBelow,
onPlace: (place) => {
if (this.orbital !== state) return;
state.place = place;
if (this.lastSubjectInfo?.kind === 'satellite')
publishOrbitalView.call(this, this.lastSubjectInfo);
},
});
this.orbital = state;
}
/** End the orbital session: restore the field of view and clear the HUD. */
export function endOrbital() {
const state = this.orbital;
if (!state) return;
this.orbital = null;
state.placeTracker?.reset();
const frustum = this.viewer.camera?.frustum;
if (frustum && state.savedFov !== null) frustum.fov = state.savedFov;
// Back to the map the operator had, unless they picked another meanwhile.
state.imagery?.release?.()?.catch?.(() => {});
this.publishOrbital(null);
}
/**
* Offer the sub-satellite point to the place tracker, with the radius a
* populated place may lie in to be named (half the settled view's smaller
* ground dimension, from NEAR_PLACE_MIN_ZOOM on), so a zoom step refreshes
* the place at once.
*/
function trackPlace(info, nowMs) {
const state = this.orbital;
if (!state?.placeTracker) return;
let nearKm = null;
if (Number.isFinite(info.altitudeM)) {
const frustum = this.viewer.camera?.frustum;
const aspect = Number.isFinite(frustum?.aspectRatio)
? frustum.aspectRatio
: canvasAspect(this.viewer);
const fov = fovForZoom(orbitalBaseFovRad(info.altitudeM), state.zoom);
// The field of view is vertical: aspect 1 gives the ground height.
nearKm = nearPlaceRadiusKm(
state.zoom,
footprintWidthKm(info.altitudeM, fov, aspect),
footprintWidthKm(info.altitudeM, fov, 1),
);
}
state.placeTracker.update(info.latitude, info.longitude, nowMs, nearKm);
}
/**
* Lease the map for flat imagery while in orbit. Another owner already
* holding it (an imagery comparison) keeps the map as it is.
* @returns {{release: () => Promise<void>}|null}
*/
function acquireOrbitImagery(services) {
try {
const lease = services?.acquireOrbitImagery?.() ?? null;
lease?.ready?.catch?.(() => {});
return lease;
} catch {
return null;
}
}
/** The magnification the view shows at `nowMs`, mid-step included. */
function currentMagnification(state, baseFov, nowMs) {
const fov = verticalFovAt(state, baseFov, nowMs).fov;
return Math.tan(baseFov / 2) / Math.tan(fov / 2);
}
/**
* The vertical field of view at `nowMs` and the step's HUD state. Once the
* field of view lands, the focus pull holds until the imagery has loaded
* (see `zoomSettleHolding`), then the reticle locks. A step that has settled
* (lock tick over) is retired.
*/
function verticalFovAt(state, baseFov, nowMs, tilesLoaded = true) {
const target = fovForZoom(baseFov, state.zoom);
const step = state.transition;
if (!step) return { fov: target, transition: IDLE_TRANSITION, moving: false };
const elapsedMs = nowMs - step.startMs;
const landedMs = elapsedMs - ORBITAL_ZOOM_TRANSITION_MS;
if (landedMs >= 0 && step.heldMs === undefined) {
if (zoomSettleHolding(landedMs, tilesLoaded)) {
return {
fov: target,
transition: {
active: true,
blurPx: ORBITAL_SETTLE_BLUR_PX,
lockTick: false,
},
moving: true,
};
}
step.heldMs = landedMs;
}
// The lock tick runs from the end of the hold, not the end of the move.
const now = zoomTransitionState(elapsedMs - (step.heldMs ?? 0));
const fov = interpolateZoomFov(
fovForZoom(baseFov, step.fromZoom),
target,
now.progress,
);
return {
fov,
transition: {
active: now.active,
// Settled steps carry no residue of the blur curve (sin π ≠ 0).
blurPx: now.active ? now.blurPx : 0,
lockTick: now.lockTick,
},
moving: now.active || now.lockTick,
};
}
/** Apply the field of view for the altitude and time; returns the HUD state. */
function applyFov(state, altitudeM, nowMs) {
const baseFov = orbitalBaseFovRad(altitudeM);
const reading = verticalFovAt(
state,
baseFov,
nowMs,
tilesLoaded(this.viewer),
);
// The step's last frame (its lock tick over) publishes the idle state.
if (state.transition && !reading.moving) {
state.transition = null;
reading.transition = IDLE_TRANSITION;
}
state.verticalFov = reading.fov;
const frustum = this.viewer.camera?.frustum;
if (frustum && 'fov' in frustum) {
const aspect = Number.isFinite(frustum.aspectRatio)
? frustum.aspectRatio
: canvasAspect(this.viewer);
frustum.fov = cesiumFrustumFovRad(reading.fov, aspect);
}
return reading;
}
/**
* Whether the imagery under the view has loaded. Flat imagery drapes the
* globe; with the globe hidden (Google 3D kept) there is nothing to wait for.
*/
function tilesLoaded(viewer) {
const globe = viewer.scene?.globe;
return !globe?.show || globe.tilesLoaded !== false;
}
function canvasAspect(viewer) {
const canvas = viewer.scene?.canvas;
const width = canvas?.clientWidth;
const height = canvas?.clientHeight;
return width > 0 && height > 0 ? width / height : 1;
}
/** Copy a plain vector into a Cesium scratch. */
function intoCartesian(vector, result) {
result.x = vector.x;
result.y = vector.y;
result.z = vector.z;
return result;
}
/**
* Drive the orbital camera for one rendered frame: at the satellite's own
* per-frame position, looking at the Earth's centre with the ground track at
* the top (north at the top for a satellite with none), tilted by the
* off-nadir look. A low orbit moves ~7.6 km/s, so there is no cadence
* gate here; only the HUD and briefing keep theirs.
* @param {number} nowMs Frame time.
*/
export function updateOrbital(nowMs) {
// Tracking anything from orbit (an aircraft, another satellite) ends the
// view and leaves Cesium tracking with that target.
if (this.viewer.trackedEntity?.position) {
this.exit({ restoreTracking: false });
return;
}
const info = this.readSatelliteInfo();
if (!info || info.noradId !== this.subjectId || !this.orbital) {
this.exit({ restoreTracking: false });
return;
}
const position = this.services.satellitesLayer?.getCockpitPosition?.(
this.scratchTarget,
);
if (!position) return;
const state = this.orbital;
// Track-up: the ground track at the top, so the picture scrolls along it.
// A geostationary satellite has no track and stays north-up.
const nadir = nadirOrientation(position, {
previousUp: state.previousUp,
headingDeg: Number.isFinite(info.groundTrackDeg)
? info.groundTrackDeg
: null,
});
if (!nadir) return;
state.previousUp = nadir.north;
state.altitudeM = info.altitudeM;
state.lookView = easeLook(
state.lookView,
state.lookTarget,
nowMs - (state.lookLastMs || nowMs),
{ immediate: prefersReducedMotion() },
);
state.lookLastMs = nowMs;
const view = offNadirOrientation(nadir, {
northDeg: state.lookView.pitchDeg,
eastDeg: state.lookView.yawDeg,
});
const zoomStepping = Boolean(state.transition);
const reading = applyFov.call(this, state, info.altitudeM, nowMs);
this.viewer.camera.setView({
destination: position,
orientation: {
direction: intoCartesian(view.direction, this.scratchForward),
up: intoCartesian(view.up, this.scratchUp),
},
});
this.lastSubjectInfo = info;
if (
zoomStepping ||
cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS)
) {
this.lastHudUpdateMs = nowMs;
trackPlace.call(this, info, nowMs);
publishOrbitalView.call(this, info, reading.transition);
}
if (
cockpitUiUpdateDue(
nowMs,
this.lastContextUpdateMs,
COCKPIT_CONTEXT_UPDATE_MS,
)
) {
this.lastContextUpdateMs = nowMs;
updateOrbitalContext.call(this, info, nowMs);
}
}
/**
* Brief the sub-satellite point: the local page every context beat, the
* regional brief on its own slower clock.
*/
function updateOrbitalContext(info, nowMs, force = false) {
this.updateLocalPosition(info);
const state = this.orbital;
if (
state &&
(force || nowMs - state.lastBriefMs >= ORBITAL_BRIEF_REFRESH_MS)
) {
state.lastBriefMs = nowMs;
this.maybeRefreshRegionalBrief(info);
}
}
/**
* A track-up view tilts along and across the ground track, not toward the
* compass: the look's north/east axes are the frame's top and right.
*/
const TRACK_LOOK_LABELS = Object.freeze({
N: msg('FWD'),
S: msg('AFT'),
E: msg('RIGHT'),
W: msg('LEFT'),
});
function lookDirectionLabel(code, trackUp) {
return trackUp ? t(TRACK_LOOK_LABELS[code]) : compassPointLabel(code);
}
/**
* Word the off-nadir look with its total angle from nadir first, or '' at
* nadir: `OFF-NADIR 18.0° · FWD 15° · RIGHT 10°`.
*/
function wordOffNadir(look, { trackUp = false } = {}) {
const parts = orbitalLookReadoutParts(look);
const words = [];
if (parts.northSouth)
words.push(
t('{direction} {angle}°', {
direction: lookDirectionLabel(parts.northSouth, trackUp),
angle: formatNumber(parts.northSouthDeg),
}),
);
if (parts.eastWest)
words.push(
t('{direction} {angle}°', {
direction: lookDirectionLabel(parts.eastWest, trackUp),
angle: formatNumber(parts.eastWestDeg),
}),
);
if (!words.length) return '';
return t('OFF-NADIR {angle}° · {offset}', {
angle: formatNumber(offNadirAngleDeg(lookTilt(parts)), {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}),
offset: words.join(' · '),
});
}
/** The look's tilt as worded: whole degrees, signed north and east. */
function lookTilt(parts) {
return {
northDeg: (parts.northSouth === 'S' ? -1 : 1) * parts.northSouthDeg,
eastDeg: (parts.eastWest === 'W' ? -1 : 1) * parts.eastWestDeg,
};
}
/**
* Publish the orbital HUD. The swath is the nadir footprint, so it is left
* out while the view is tilted off nadir.
* @param {object} info Satellite subject (`readSatelliteInfo`).
* @param {{active: boolean, blurPx: number, lockTick: boolean}} transition
*/
export function publishOrbitalView(info, transition = IDLE_TRANSITION) {
const state = this.orbital;
if (!state || !info) return;
const offset = lookIsOffset(state.lookTarget, 0);
const aspect = Number.isFinite(this.viewer.camera?.frustum?.aspectRatio)
? this.viewer.camera.frustum.aspectRatio
: canvasAspect(this.viewer);
const settledFov = fovForZoom(orbitalBaseFovRad(info.altitudeM), state.zoom);
const footprintKm = footprintWidthKm(info.altitudeM, settledFov, aspect);
const trackUp = Number.isFinite(info.groundTrackDeg);
const tilt = offset
? lookTilt(orbitalLookReadoutParts(state.lookTarget))
: null;
const swathScale = offset ? offNadirSwathScale(tilt) : 1;
// Tilted past the limb, the view's centre looks into space: no swath. The
// camera eases toward the target, so both must meet the Earth; the HUD
// republishes on its cadence, so the swath appears once the view arrives.
// Exact angles here, not the whole degrees the readout rounds to.
const target = clampOrbitalLook(state.lookTarget);
const seesEarth =
!offset ||
(offNadirCentreMeetsEarth(info.altitudeM, {
northDeg: target.pitchDeg,
eastDeg: target.yawDeg,
}) &&
offNadirCentreMeetsEarth(info.altitudeM, {
northDeg: state.lookView?.pitchDeg ?? 0,
eastDeg: state.lookView?.yawDeg ?? 0,
}));
this.publishOrbital({
name: info.name,
classLabel: info.classLabel || '',
altitudeM: info.altitudeM,
velocityMps: Number.isFinite(info.velocityMps) ? info.velocityMps : null,
latitude: info.latitude,
longitude: info.longitude,
zoom: state.zoom,
zoomSteps: [...ORBITAL_ZOOM_LADDER],
maxZoom: ORBITAL_BASE_MAX_ZOOM,
footprintKm: offset ? null : footprintKm,
transition: { ...transition },
look: {
offset,
readout: offset ? wordOffNadir(state.lookTarget, { trackUp }) : '',
// Off nadir, the ground width at the view's centre, approximated from
// the nadir footprint (`offNadirSwathScale`); 1 and null at nadir.
swathScale,
swathKm:
offset && seesEarth && Number.isFinite(footprintKm)
? footprintKm * swathScale
: null,
},
// Track-up turns the frame to the ground track; north then sits at
// `northDeg` clockwise from the top. A geostationary view stays north-up.
trackUp,
northDeg: trackUp ? (360 - info.groundTrackDeg) % 360 : 0,
place: state.place,
noradId: info.noradId ?? null,
cosparId: info.cosparId ?? null,
tle: info.tle ?? null,
owner: info.owner ?? null,
operator: info.operator ?? null,
});
}
/**
* Present the orbital view on entry and after a language switch: the HUD,
* the local page and a regional brief for the sub-satellite point.
* @param {object} info Satellite subject.
*/
export function updateOrbitalHud(info, nowMs = performance.now()) {
this.lastSubjectInfo = info;
// A satellite has no flight plan and no Contact card.
this.routeView.visible = false;
this.clearPredictiveRoute();
this.showContextCard(false);
trackPlace.call(this, info, nowMs);
publishOrbitalView.call(this, info);
updateOrbitalContext.call(this, info, nowMs, true);
}
/** Re-apply the field of view and publish after a zoom or look change. */
function presentNow() {
const state = this.orbital;
const info = this.lastSubjectInfo;
if (!state || info?.kind !== 'satellite') return;
const nowMs = performance.now();
const reading = Number.isFinite(state.altitudeM ?? info.altitudeM)
? applyFov.call(this, state, state.altitudeM ?? info.altitudeM, nowMs)
: null;
publishOrbitalView.call(this, info, reading?.transition ?? IDLE_TRANSITION);
}
/**
* Step the optical zoom one step in (`direction` > 0) or out. The step
* animates over ORBITAL_ZOOM_TRANSITION_MS with a focus-pull blur and a
* lock tick, or cuts at once under reduced motion. The ends stay put.
* @param {number} direction Positive to zoom in, negative to zoom out.
* @returns {boolean} True when the zoom changed.
*/
export function stepOrbitalZoom(direction) {
if (!this.active || this.subjectKind !== 'satellite' || !this.orbital)
return false;
const state = this.orbital;
const next =
direction > 0
? nextOrbitalZoom(state.zoom)
: previousOrbitalZoom(state.zoom);
return goToZoom.call(this, next);
}
/**
* Move the zoom to `next`, animated from the magnification shown now unless
* reduced motion is on.
* @returns {boolean} True when the zoom changed.
*/
function goToZoom(next) {
const state = this.orbital;
if (!Number.isFinite(next) || next === state.zoom) return false;
const nowMs = performance.now();
const altitudeM = state.altitudeM ?? this.lastSubjectInfo?.altitudeM;
const fromZoom = Number.isFinite(altitudeM)
? currentMagnification(state, orbitalBaseFovRad(altitudeM), nowMs)
: state.zoom;
state.zoom = next;
state.transition = prefersReducedMotion()
? null
: { fromZoom, startMs: nowMs };
presentNow.call(this);
return true;
}
/**
* Tilt the orbital view one arrow step off nadir (see `lookKeyStep`); each
* axis stops at ±45°. The view eases there unless reduced motion is on.
* @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step
* @returns {boolean} True in an orbital view.
*/
export function turnOrbitalLook(step) {
if (!this.active || this.subjectKind !== 'satellite' || !this.orbital)
return false;
if (!step) return false;
const state = this.orbital;
state.lookTarget = stepOrbitalLook(state.lookTarget, step);
if (prefersReducedMotion()) state.lookView = { ...state.lookTarget };
presentNow.call(this);
return true;
}
/**
* Bring the orbital view back to pure nadir (Home, 0 or the NADIR button).
* @param {{immediate?: boolean}} [options] Jump instead of easing.
* @returns {boolean} True in an orbital view.
*/
export function resetOrbitalLook({ immediate = false } = {}) {
if (!this.active || this.subjectKind !== 'satellite' || !this.orbital)
return false;
const state = this.orbital;
state.lookTarget = { ...NADIR_LOOK };
if (immediate || prefersReducedMotion()) state.lookView = { ...NADIR_LOOK };
presentNow.call(this);
return true;
}
File diff suppressed because it is too large. Load diff
+15 -3
View File
@@ -146,18 +146,30 @@ export function formatCockpitWindDirection(value) {
}
/**
* The Cockpit entry for each subject it can ride: an aircraft's cockpit or a
* vessel's bridge. Both entry buttons (Contacts and
* A TLE epoch as compact UTC, `2026-11-04 08:12Z`, or '' when unknown.
* @param {number|null|undefined} epochMs
*/
export function formatTleEpochUtc(epochMs) {
// Beyond ±8.64e15 ms a Date is invalid and toISOString throws.
if (!Number.isFinite(epochMs) || Math.abs(epochMs) > 8.64e15) return '';
const iso = new Date(epochMs).toISOString();
return `${iso.slice(0, 10)} ${iso.slice(11, 16)}Z`;
}
/**
* The Cockpit entry for each subject it can ride: an aircraft's cockpit, a
* vessel's bridge or a satellite's orbit. Both entry buttons (Contacts and
* the Intel HUD readout) show it.
*/
const COCKPIT_ENTRY_FACES = Object.freeze({
aircraft: Object.freeze({ icon: 'flight', label: msg('COCKPIT') }),
vessel: Object.freeze({ icon: 'directions_boat', label: msg('BRIDGE') }),
satellite: Object.freeze({ icon: 'satellite_alt', label: msg('ORBIT') }),
});
/**
* The entry's glyph and localized label for the subject it would ride.
* @param {'aircraft'|'vessel'|null|undefined} kind
* @param {'aircraft'|'vessel'|'satellite'|null|undefined} kind
* @returns {{icon: string, label: string}}
*/
export function cockpitEntryFace(kind) {
+79 -26
View File
@@ -28,15 +28,22 @@ export function readVesselInfo() {
return this.services.aisLiveVesselsLayer?.getCockpitSubject?.() || null;
}
/** The tracked satellite the orbital view would ride, or rides; null without one. */
export function readSatelliteInfo() {
return this.services.satellitesLayer?.getCockpitSubject?.() || null;
}
/**
* The layer the active view rides: Live AIS Vessels on a bridge, else the
* aircraft's own layer. It is read from the tracked entity's id once the
* layer has dropped its tracking, so a layer turned off still names itself.
* @returns {'flights'|'military'|'ais-live-vessels'|null}
* The layer the active view rides: Live AIS Vessels on a bridge, Satellites
* in orbit, else the aircraft's own layer. It is read from the tracked
* entity's id once the layer has dropped its tracking, so a layer turned off
* still names itself.
* @returns {'flights'|'military'|'ais-live-vessels'|'satellites'|null}
*/
export function readSubjectLayerId() {
if (!this.active) return null;
if (this.subjectKind === 'vessel') return 'ais-live-vessels';
if (this.subjectKind === 'satellite') return 'satellites';
const layerId = this.readAircraftInfo()?.layerId;
if (layerId) return layerId;
const [prefix] = String(this.trackedEntity?.gevTrackedId || '').split(':');
@@ -44,26 +51,29 @@ export function readSubjectLayerId() {
}
/**
* The subject entry would take: a Cesium-tracked aircraft first, else the
* selected vessel. Vessels never take Cesium tracking, so an aircraft and a
* vessel cannot be live through the same owner.
* @returns {'aircraft'|'vessel'|null}
* The subject entry would take: a Cesium-tracked aircraft first, then the
* tracked satellite, else the selected vessel. Vessels never take Cesium
* tracking, so an aircraft and a vessel cannot be live through the same owner.
* @returns {'aircraft'|'satellite'|'vessel'|null}
*/
export function resolveEntryKind() {
if (this.readAircraftInfo() && this.viewer.trackedEntity?.position)
return 'aircraft';
if (this.readSatelliteInfo()) return 'satellite';
return this.readVesselInfo() ? 'vessel' : null;
}
export function dispatchCockpitModeChanged(active, info = null) {
const subjectId = active
? String(info?.icao24 || info?.mmsi || '')
? String(info?.icao24 || info?.mmsi || info?.noradId || '')
.trim()
.toLowerCase() || null
: null;
const layerId =
active &&
['flights', 'military', 'ais-live-vessels'].includes(info?.layerId)
['flights', 'military', 'ais-live-vessels', 'satellites'].includes(
info?.layerId,
)
? info.layerId
: null;
window.dispatchEvent(
@@ -118,9 +128,13 @@ export function syncEntry() {
const info = this.readAircraftInfo();
const trackedContact = !!(info && this.viewer.trackedEntity?.position);
this.syncTr3bToggle(trackedContact ? info : null);
const bridgeContact = !trackedContact && !!this.readVesselInfo();
const orbitContact = !trackedContact && !!this.readSatelliteInfo();
const bridgeContact =
!trackedContact && !orbitContact && !!this.readVesselInfo();
let entryKind = null;
if (trackedContact) entryKind = this.isEntryAllowed() ? 'aircraft' : null;
else if (orbitContact)
entryKind = this.isOrbitEntryAllowed() ? 'satellite' : null;
else if (bridgeContact)
entryKind = this.isBridgeEntryAllowed() ? 'vessel' : null;
const available = entryKind !== null;
@@ -139,8 +153,10 @@ export function syncEntry() {
export function navigateContext(direction, options = {}) {
if (this.destroyed) return false;
// Contact navigation moves between aircraft; a bridge does not jump ship.
if (this.subjectKind === 'vessel') return false;
// Contact navigation moves between aircraft; a bridge does not jump ship
// and an orbital view stays with its satellite.
if (this.subjectKind === 'vessel' || this.subjectKind === 'satellite')
return false;
const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';
const wasActive = this.active;
if (wasActive) this.contextNavigationDeadlineMs = performance.now() + 1500;
@@ -159,9 +175,10 @@ export function navigateContext(direction, options = {}) {
}
export function _adoptTrackedEntity(nowMs, suppliedInfo = null) {
// An aircraft tracked from the bridge ends it (see the vessel camera
// update); it is never adopted as the bridge's subject.
if (this.subjectKind === 'vessel') return false;
// An aircraft tracked from the bridge or from orbit ends that view (see the
// vessel and orbital camera updates); it is never adopted as its subject.
if (this.subjectKind === 'vessel' || this.subjectKind === 'satellite')
return false;
const nextEntity = this.viewer.trackedEntity;
if (
!this.active ||
@@ -192,8 +209,8 @@ export function _adoptTrackedEntity(nowMs, suppliedInfo = null) {
/**
* Enter first person on a tracked aircraft or, with none tracked, the
* selected vessel's bridge.
* @param {{kind?: 'aircraft'|'vessel'|null}} [options] Restrict
* tracked satellite's orbital view or the selected vessel's bridge.
* @param {{kind?: 'aircraft'|'satellite'|'vessel'|null}} [options] Restrict
* entry to one subject kind; voice entry is aircraft-only.
* @returns {boolean} True when the view was entered.
*/
@@ -202,12 +219,21 @@ export function enter({ kind = null } = {}) {
if (this.active) return false;
const subjectKind = kind ?? this.resolveEntryKind() ?? 'aircraft';
const vessel = subjectKind === 'vessel';
const allowed = vessel ? this.isBridgeEntryAllowed() : this.isEntryAllowed();
const satellite = subjectKind === 'satellite';
const allowed = vessel
? this.isBridgeEntryAllowed()
: satellite
? this.isOrbitEntryAllowed()
: this.isEntryAllowed();
if (!allowed) return false;
const info = vessel ? this.readVesselInfo() : this.readAircraftInfo();
const info = vessel
? this.readVesselInfo()
: satellite
? this.readSatelliteInfo()
: this.readAircraftInfo();
// Only an aircraft is ridden through its Cesium entity.
const entity = vessel ? null : this.viewer.trackedEntity;
if (!info || (!vessel && !entity?.position)) return false;
const entity = vessel || satellite ? null : this.viewer.trackedEntity;
if (!info || (!vessel && !satellite && !entity?.position)) return false;
this.entryFocusOrigin =
document.activeElement instanceof HTMLElement
? document.activeElement
@@ -219,11 +245,15 @@ export function enter({ kind = null } = {}) {
// Each session reads its own water: never a height left from the last.
this.vesselWaterline = null;
this.vesselFix = null;
this.subjectId = vessel ? info.mmsi : null;
this.subjectId = vessel ? info.mmsi : satellite ? info.noradId : null;
this.trackedEntity = entity;
if (entity) {
this.trackedEntityWasShown = entity.show;
entity.show = false;
} else if (satellite) {
// The layer hides the satellite's own marks and dims the other orbits.
this.services.satellitesLayer?.setCockpitSubject?.(info.noradId);
this.beginOrbital();
} else {
this.services.aisLiveVesselsLayer?.setCockpitSubject?.(info.mmsi);
}
@@ -259,6 +289,7 @@ export function enter({ kind = null } = {}) {
document.body.classList.add('cockpit-mode');
// Bridge styling reaches surfaces outside the HUD (exit row, side panels).
if (vessel) document.body.classList.add('cockpit-bridge');
if (satellite) document.body.classList.add('cockpit-orbit');
// Activation hides the entry and TR-3B chip; clearing the chip's signature
// lets the exit-path syncEntry publish it again.
this._tr3bSignature = null;
@@ -277,7 +308,14 @@ export function enter({ kind = null } = {}) {
this.showBriefPage(0);
this.startBriefRotation();
const trackHeading = formatHeadingReadout(info.track ?? 0);
if (vessel) {
if (satellite) {
this.pushCockpitSignal(
'track',
'track',
t('TRACK ACQUIRED'),
t('{label} · NADIR VIEW', { label: info.name }),
);
} else if (vessel) {
this.pushCockpitSignal(
'track',
'track',
@@ -310,7 +348,9 @@ export function enter({ kind = null } = {}) {
);
}
this.updateHud(info, performance.now(), true);
this.setVisionMode(this.getInitialVisionMode());
// Orbit always opens in the plain optical picture; the vision styles stay
// one click away.
this.setVisionMode(satellite ? 'optical' : this.getInitialVisionMode());
this.scheduleContextLayout();
// The exit button shows when Vue flushes the presence; focus it after.
Promise.resolve().then(() => {
@@ -327,6 +367,8 @@ export function exit({ restoreTracking = true } = {}) {
if (!this.active) return false;
const entity = this.trackedEntity;
const vesselSubject = this.subjectKind === 'vessel' ? this.subjectId : null;
const satelliteSubject =
this.subjectKind === 'satellite' ? this.subjectId : null;
this.active = false;
this.subjectKind = null;
this.vesselWaterline = null;
@@ -348,7 +390,13 @@ export function exit({ restoreTracking = true } = {}) {
this.regionalBriefAbort = null;
this.regionalBriefRequestToken += 1;
this.regionalBriefSubjectId = null;
document.body.classList.remove('cockpit-mode', 'cockpit-bridge');
document.body.classList.remove(
'cockpit-mode',
'cockpit-bridge',
'cockpit-orbit',
);
// The camera's own field of view returns before any follow resumes.
this.endOrbital();
this.onExited?.();
this.publishUtilityLayout(null);
this.presence.active = false;
@@ -364,6 +412,8 @@ export function exit({ restoreTracking = true } = {}) {
this.trackedEntityWasShown = true;
if (vesselSubject)
this.services.aisLiveVesselsLayer?.setCockpitSubject?.(null);
if (satelliteSubject !== null)
this.services.satellitesLayer?.setCockpitSubject?.(null);
this.dispatchCockpitModeChanged(false);
if (restoreTracking && entity && this.viewer.entities.contains(entity)) {
this.viewer.trackedEntity = entity;
@@ -371,6 +421,9 @@ export function exit({ restoreTracking = true } = {}) {
} else if (restoreTracking && vesselSubject) {
// Back to the selected vessel's map view, through the layer's own focus.
this.services.aisLiveVesselsLayer?.focusSelectedVessel?.();
} else if (restoreTracking && satelliteSubject !== null) {
// Back to the satellite's follow camera, through the layer's own refocus.
this.services.satellitesLayer?.refocusTrackedById?.(satelliteSubject);
}
this.syncEntry();
// Back to whichever entry was used: the Contacts button or the readout's.
+110
View File
@@ -0,0 +1,110 @@
/**
* The owner mark beside a satellite's name: the owner country's flag (an SVG
* from flag-icons — Windows draws no flag emoji), or a short text badge for
* an organisation without one (ESA, EUMETSAT, "Multinational"). Each flag is
* its own lazily fetched asset; only the shown one ever loads.
*/
/**
* Flag URL loaders by path; empty under node:test, which has no glob. The
* glob is eager so the bundle carries only the URL strings: a lazy glob would
* emit a JS stub chunk per flag (~270), two requests for each flag shown.
* The SVG itself is still fetched only when an <img> shows it.
*/
let FLAG_LOADERS = {};
try {
const urls = import.meta.glob('/node_modules/flag-icons/flags/4x3/*.svg', {
query: '?url&no-inline',
import: 'default',
eager: true,
// flag-icons lives in node_modules, which globs skip by default.
exhaustive: true,
});
FLAG_LOADERS = Object.fromEntries(
Object.entries(urls).map(([path, url]) => [path, () => url]),
);
} catch {
/* Not under Vite. */
}
const FLAG_PATH = (code) =>
`/node_modules/flag-icons/flags/4x3/${code.toLowerCase()}.svg`;
/** An ISO 3166-1 alpha-2 code, upper case, or null. */
function isoCode(value) {
const code = String(value ?? '').trim();
return /^[A-Za-z]{2}$/.test(code) ? code.toUpperCase() : null;
}
/**
* A region's name in the locale, from the browser's own table.
* @param {string} code ISO 3166-1 alpha-2.
* @param {string} locale BCP 47 tag.
* @returns {string|null}
*/
export function regionName(code, locale) {
try {
const name = new Intl.DisplayNames([locale || 'en'], {
type: 'region',
}).of(code);
return name && name !== code ? name : null;
} catch {
return null;
}
}
/**
* How to mark a satellite's owner.
* @param {{countryCode: string|null, label: string|null}|null|undefined} owner
* @param {{locale?: string, loaders?: object}} [options]
* @returns {{kind: 'flag', code: string, label: string}
* | {kind: 'badge', label: string, title: string}
* | null} A flag (with the country's localized name as its label), a text
* badge for an owner with no flag to show, or nothing when unknown.
*/
export function ownerMark(
owner,
{ locale = 'en', loaders = FLAG_LOADERS } = {},
) {
const code = isoCode(owner?.countryCode);
const label = String(owner?.label ?? '').trim();
if (code) {
const name = regionName(code, locale) || label || code;
if (loaders[FLAG_PATH(code)]) return { kind: 'flag', code, label: name };
return { kind: 'badge', label: label || code, title: name };
}
return label ? { kind: 'badge', label, title: label } : null;
}
/**
* What the readout shows for an owner once its flag has loaded or failed: the
* flag, or a badge (the country code when the flag would not load, so the
* owner never disappears), or nothing for an unknown owner.
* @param {ReturnType<typeof ownerMark>} mark
* @param {{url?: string|null, failed?: boolean}} [flag] The flag's load state.
* @returns {{kind: 'flag', url: string, label: string}
* | {kind: 'badge', label: string, title: string} | null}
*/
export function shownOwnerMark(mark, { url = null, failed = false } = {}) {
if (!mark) return null;
if (mark.kind === 'badge') return mark;
if (failed) {
return { kind: 'badge', label: mark.code.toUpperCase(), title: mark.label };
}
return url ? { kind: 'flag', url, label: mark.label } : null;
}
/**
* Fetch a flag's URL.
* @param {string} code ISO 3166-1 alpha-2.
* @returns {Promise<string|null>}
*/
export async function loadFlagUrl(code, loaders = FLAG_LOADERS) {
const load = loaders[FLAG_PATH(String(code))];
if (!load) return null;
try {
return (await load()) || null;
} catch {
return null;
}
}
+108
View File
@@ -0,0 +1,108 @@
// src/ui/ownerFlag.test.mjs — a satellite owner's mark: a country's flag with
// its localized name, a text badge for an organisation, nothing when unknown.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
loadFlagUrl,
ownerMark,
regionName,
shownOwnerMark,
} from './ownerFlag.js';
const FLAG = (code) => `/node_modules/flag-icons/flags/4x3/${code}.svg`;
const LOADERS = Object.freeze({
[FLAG('us')]: async () => '/assets/us-abc.svg',
[FLAG('fr')]: async () => '/assets/fr-def.svg',
});
test('an owner country shows its flag, named in the reader language', () => {
assert.deepEqual(
ownerMark({ countryCode: 'us', label: 'USA' }, { loaders: LOADERS }),
{ kind: 'flag', code: 'US', label: 'United States' },
);
assert.equal(
ownerMark(
{ countryCode: 'FR', label: null },
{ locale: 'de', loaders: LOADERS },
).label,
'Frankreich',
);
});
test('an organisation without a country shows a text badge', () => {
assert.deepEqual(
ownerMark({ countryCode: null, label: 'ESA' }, { loaders: LOADERS }),
{ kind: 'badge', label: 'ESA', title: 'ESA' },
);
});
test('a country with no flag to show falls back to a badge', () => {
assert.deepEqual(
ownerMark({ countryCode: 'DE', label: 'Germany' }, { loaders: LOADERS }),
{ kind: 'badge', label: 'Germany', title: 'Germany' },
);
// Natural Earth and UCS write -99 or names, never a flag code.
assert.deepEqual(
ownerMark(
{ countryCode: '-99', label: 'Multinational' },
{ loaders: LOADERS },
),
{ kind: 'badge', label: 'Multinational', title: 'Multinational' },
);
});
test('an unknown owner shows nothing', () => {
assert.equal(ownerMark(null, { loaders: LOADERS }), null);
assert.equal(
ownerMark({ countryCode: null, label: ' ' }, { loaders: LOADERS }),
null,
);
});
test('only the flag asked for loads', async () => {
assert.equal(await loadFlagUrl('US', LOADERS), '/assets/us-abc.svg');
assert.equal(await loadFlagUrl('DE', LOADERS), null);
assert.equal(
await loadFlagUrl('FR', {
[FLAG('fr')]: async () => {
throw new Error('offline');
},
}),
null,
);
});
test('a region name comes from Intl, never the bare code', () => {
assert.equal(regionName('JP', 'en'), 'Japan');
assert.equal(regionName('XX', 'en'), null);
});
test('a flag that will not load falls back to its country code, never to nothing', () => {
const flag = { kind: 'flag', code: 'us', label: 'United States' };
assert.equal(shownOwnerMark(flag), null, 'nothing while it loads');
assert.deepEqual(shownOwnerMark(flag, { url: '/flags/us.svg' }), {
kind: 'flag',
url: '/flags/us.svg',
label: 'United States',
});
assert.deepEqual(shownOwnerMark(flag, { failed: true }), {
kind: 'badge',
label: 'US',
title: 'United States',
});
const badge = { kind: 'badge', label: 'ESA', title: 'ESA' };
assert.equal(shownOwnerMark(badge, { failed: true }), badge);
assert.equal(shownOwnerMark(null), null);
});
test('flags ship as a map of URLs, not one JS chunk per flag', async () => {
const { readFileSync } = await import('node:fs');
const source = readFileSync(
new URL('./ownerFlag.js', import.meta.url),
'utf8',
);
// A lazy glob emits a stub chunk for each of ~270 flags; eager URLs ship
// only strings, and each SVG still loads when an <img> shows it.
assert.match(source, /eager:\s*true/);
assert.match(source, /query:\s*'\?url&no-inline'/);
});
+38
View File
@@ -87,6 +87,42 @@ export const useCockpitStore = defineStore('gev-cockpit', () => {
radar.value = next && markRaw(next);
}
/**
* The orbital view's HUD: null outside a satellite subject. Shape:
* `{ name, classLabel, altitudeM, velocityMps|null, latitude, longitude,
* zoom, zoomSteps: number[], maxZoom, footprintKm|null,
* transition: { active, blurPx, lockTick },
* look: { offset, readout, swathScale, swathKm|null }, trackUp, northDeg,
* place: { kind: 'country'|'sea'|'ocean', name, countryCode,
* division?|null, near?|null }|null|undefined,
* noradId|null, cosparId|null, tle: { line1, line2, epochMs }|null,
* owner: { countryCode|null, label|null }|null, operator|null }` where
* `place` is what the sub-satellite point is over (undefined until the
* first offline lookup lands, null when nothing named holds it; see
* src/data/placeBelow.js), the identity fields come from the satellites
* layer and are null when unknown, `classLabel` is
* already localized (the satellite layer's class label, e.g. "NAV · GPS"),
* `velocityMps` is the orbital (inertial) speed, latitude/longitude are the
* sub-satellite point in degrees, `zoom` is one of `zoomSteps` (1, 2, 4
* and 8, the tightest being `maxZoom`), `footprintKm` is the nadir ground width across the frame at that zoom
* (null while the view is tilted off nadir), `transition` is a zoom step in
* flight: `active` while the field of view moves, `blurPx` the focus-pull
* blur for the picture, `lockTick` true briefly once the step settles (see
* src/cockpitOrbital.js), `look` says whether the view is tilted off
* nadir and words the tilt (`readout`, '' at nadir), with the approximate
* ground width at the tilted view's centre (`swathKm`, null at nadir) and
* its ratio to the nadir footprint (`swathScale`, 1 at nadir), `trackUp`
* whether the view is turned to the ground track (false for a satellite with none,
* which stays north-up) and `northDeg` where north sits, in degrees
* clockwise from the top (0 when north-up).
*/
const orbital = shallowRef(null);
/** @param {?object} next A fresh presentation of the orbital view HUD. */
function publishOrbital(next) {
orbital.value = next && markRaw(next);
}
/**
* The utility strip's measured layout: its top and height limits as CSS
* lengths (null leaves the stylesheet's), whether only the expanded control
@@ -124,6 +160,8 @@ export const useCockpitStore = defineStore('gev-cockpit', () => {
publishSignals,
radar,
publishRadar,
orbital,
publishOrbital,
utilityLayout,
publishUtilityLayout,
};
+499
View File
@@ -0,0 +1,499 @@
/* Orbital view HUD (src/ui/views/panels/CockpitOrbital.vue). The section
covers the picture it is placed over: the reticle sits on its centre, the
readout top-left and the optics bottom-centre. Square-cornered like the
bridge view. The Cockpit controller publishes the zoom step's blur as
--cockpit-orbital-blur and its lock tick as a reticle class. */
.cockpit-orbital {
--orbital-line: color-mix(
in srgb,
var(--cockpit-accent, #00dcff) 42%,
transparent
);
--orbital-faint: color-mix(
in srgb,
var(--cockpit-accent, #00dcff) 18%,
transparent
);
--orbital-reticle-size: min(38vmin, 320px);
position: absolute;
inset: 0;
overflow: hidden;
color: rgba(244, 251, 255, 0.86);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.08em;
pointer-events: none;
}
.cockpit-orbital[hidden] {
display: none;
}
/* Focus pull: blurs the picture beneath only while a step is in flight. */
.cockpit-orbital-lens {
position: absolute;
inset: 0;
}
.cockpit-orbital-focusing .cockpit-orbital-lens {
backdrop-filter: blur(var(--cockpit-orbital-blur, 0px));
-webkit-backdrop-filter: blur(var(--cockpit-orbital-blur, 0px));
}
.cockpit-orbital-reticle {
position: absolute;
top: 50%;
left: 50%;
width: var(--orbital-reticle-size);
height: var(--orbital-reticle-size);
transform: translate(-50%, -50%);
}
.cockpit-orbital-bracket {
position: absolute;
width: 18%;
height: 18%;
border: 0 solid var(--orbital-line);
transition:
border-color 160ms ease-out,
transform 160ms ease-out;
}
.cockpit-orbital-bracket-tl {
top: 0;
left: 0;
border-top-width: 1px;
border-left-width: 1px;
}
.cockpit-orbital-bracket-tr {
top: 0;
right: 0;
border-top-width: 1px;
border-right-width: 1px;
}
.cockpit-orbital-bracket-bl {
bottom: 0;
left: 0;
border-bottom-width: 1px;
border-left-width: 1px;
}
.cockpit-orbital-bracket-br {
right: 0;
bottom: 0;
border-right-width: 1px;
border-bottom-width: 1px;
}
/* Nadir cross: a gapped plus, so the sub-satellite point stays visible. */
.cockpit-orbital-cross {
position: absolute;
top: 50%;
left: 50%;
width: 28px;
height: 28px;
transform: translate(-50%, -50%);
background:
linear-gradient(var(--orbital-line), var(--orbital-line)) left center / 10px
1px no-repeat,
linear-gradient(var(--orbital-line), var(--orbital-line)) right center /
10px 1px no-repeat,
linear-gradient(var(--orbital-line), var(--orbital-line)) center top / 1px
10px no-repeat,
linear-gradient(var(--orbital-line), var(--orbital-line)) center bottom /
1px 10px no-repeat;
}
/* North on the frame: the pointer spans the reticle and turns about its
centre, carrying the tick (and, track-up, an N) round the rim. */
.cockpit-orbital-north-pointer {
position: absolute;
inset: 0;
}
.cockpit-orbital-north-letter {
position: absolute;
top: -26px;
left: 50%;
color: var(--cockpit-accent, #00dcff);
font-weight: 700;
transform: translateX(-50%);
}
/* North sits at the top of a north-up frame. */
.cockpit-orbital-north-tick {
position: absolute;
top: -10px;
left: 50%;
width: 1px;
height: 7px;
background: var(--cockpit-accent, #00dcff);
}
.cockpit-orbital-reticle-lock .cockpit-orbital-bracket {
border-color: var(--cockpit-accent, #00dcff);
animation: cockpit-orbital-lock 220ms ease-out;
}
.cockpit-orbital-reticle-lock .cockpit-orbital-cross {
--orbital-line: var(--cockpit-accent, #00dcff);
}
@keyframes cockpit-orbital-lock {
0% {
transform: scale(1.12);
opacity: 0.4;
}
60% {
transform: scale(0.97);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* Bottom-left, where the aircraft Contact card sits (hidden in orbit): clear
of the Intel HUD's top-left classification block and above its MGRS and
coordinate lines. */
.cockpit-orbital-readout {
position: absolute;
bottom: clamp(150px, 19vh, 190px);
left: clamp(16px, 4vw, 48px);
max-width: min(320px, calc(100% - 32px));
padding: 8px 10px;
border: 1px solid var(--orbital-faint);
border-radius: 2px;
background: rgba(0, 9, 14, 0.62);
}
.cockpit-orbital-readout p,
.cockpit-orbital-data,
.cockpit-orbital-data dd {
margin: 0;
}
.cockpit-orbital-kicker {
color: var(--cockpit-accent, #00dcff);
font-weight: 700;
letter-spacing: 0.14em;
}
.cockpit-orbital-name {
margin-top: 4px;
overflow: hidden;
color: #f4fbff;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.06em;
text-overflow: ellipsis;
white-space: nowrap;
}
.cockpit-orbital-class {
margin-top: 2px;
color: rgba(244, 251, 255, 0.66);
}
/* The name with its owner's flag (or text badge) in front. */
.cockpit-orbital-title {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
margin-top: 4px;
}
.cockpit-orbital-title .cockpit-orbital-name {
min-width: 0;
margin-top: 0;
}
.cockpit-orbital-flag {
flex: none;
width: 20px;
height: 15px;
border: 1px solid var(--orbital-faint);
border-radius: 1px;
object-fit: cover;
}
.cockpit-orbital-owner-badge {
flex: none;
max-width: 96px;
overflow: hidden;
padding: 1px 4px;
border: 1px solid var(--orbital-line);
border-radius: 2px;
color: var(--cockpit-accent, #00dcff);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.1em;
text-overflow: ellipsis;
white-space: nowrap;
}
.cockpit-orbital-operator {
margin-top: 2px;
overflow: hidden;
color: #f4fbff;
text-overflow: ellipsis;
white-space: nowrap;
}
/* NORAD · COSPAR · epoch, and the element set on request. */
.cockpit-orbital-ident {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 6px;
color: rgba(244, 251, 255, 0.66);
font-variant-numeric: tabular-nums;
}
/* Wraps between its parts rather than truncating, so the epoch and the
element set's age always show in full. */
.cockpit-orbital-ident-text {
min-width: 0;
}
.cockpit-orbital-ident-part {
white-space: nowrap;
}
.cockpit-orbital-tle-toggle {
flex: none;
padding: 1px 6px;
border: 1px solid var(--orbital-line);
border-radius: 2px;
background: rgba(0, 7, 12, 0.6);
color: #f4fbff;
font: inherit;
font-weight: 700;
letter-spacing: 0.12em;
cursor: pointer;
pointer-events: auto;
}
.cockpit-orbital-tle-toggle[aria-expanded='true'],
.cockpit-orbital-tle-toggle:hover,
.cockpit-orbital-tle-toggle:focus-visible {
border-color: var(--cockpit-accent, #00dcff);
color: #fff;
outline: none;
}
/* 69 characters a line: small enough to fit the card, and selectable. */
.cockpit-orbital-tle {
margin: 6px 0 0;
padding: 5px 6px;
overflow-x: auto;
border: 1px solid var(--orbital-faint);
border-radius: 2px;
background: rgba(0, 4, 8, 0.7);
color: #f4fbff;
font: 8px/1.5 var(--font-mono);
letter-spacing: 0;
white-space: pre;
user-select: text;
pointer-events: auto;
}
/* Under the vision selector (topline top + its ~38px height + a gap), on
the centre line the reticle keeps clear below; the Intel HUD blocks and
Display/Radio sit at the sides. */
.cockpit-orbital-over {
position: absolute;
top: calc(clamp(18px, 3vh, 34px) + 50px);
left: 50%;
display: flex;
align-items: baseline;
gap: 8px;
max-width: min(560px, calc(100% - 32px));
margin: 0;
padding: 4px 10px;
border: 1px solid var(--orbital-faint);
border-radius: 2px;
background: rgba(0, 9, 14, 0.62);
transform: translateX(-50%);
white-space: nowrap;
}
.cockpit-orbital-over span {
flex: none;
color: var(--cockpit-accent, #00dcff);
font-weight: 700;
letter-spacing: 0.14em;
}
.cockpit-orbital-over strong {
min-width: 0;
overflow: hidden;
color: #f4fbff;
font-size: 12px;
letter-spacing: 0.08em;
text-overflow: ellipsis;
text-transform: uppercase;
}
.cockpit-orbital-data {
display: grid;
gap: 4px;
margin-top: 8px;
}
.cockpit-orbital-datum {
display: flex;
justify-content: space-between;
gap: 12px;
}
.cockpit-orbital-datum dt {
color: rgba(244, 251, 255, 0.56);
}
.cockpit-orbital-datum dd {
color: #f4fbff;
font-variant-numeric: tabular-nums;
text-align: end;
}
.cockpit-orbital-north {
display: inline-block;
margin-top: 8px;
padding: 1px 5px;
border: 1px solid var(--orbital-line);
border-radius: 2px;
color: var(--cockpit-accent, #00dcff);
font-weight: 700;
}
/* Above RESET / EXIT ORBIT: #view-switcher sits at clamp(128px, 15vh, 150px)
less its -95px margin, and its buttons are about 30px tall. */
.cockpit-orbital-optics {
position: absolute;
bottom: calc(clamp(128px, 15vh, 150px) - 95px + 44px);
left: 50%;
display: flex;
align-items: center;
gap: 6px;
padding: 4px;
border: 1px solid var(--orbital-faint);
border-radius: 2px;
background: rgba(0, 9, 14, 0.62);
transform: translateX(-50%);
white-space: nowrap;
}
.cockpit-orbital-zoom-button {
width: 28px;
height: 24px;
padding: 0;
border: 1px solid var(--orbital-line);
border-radius: 2px;
background: rgba(0, 7, 12, 0.6);
color: #f4fbff;
font: inherit;
font-size: 14px;
font-weight: 700;
line-height: 1;
cursor: pointer;
pointer-events: auto;
}
.cockpit-orbital-zoom-button:hover:not(:disabled),
.cockpit-orbital-zoom-button:focus-visible {
border-color: var(--cockpit-accent, #00dcff);
color: #fff;
outline: none;
}
.cockpit-orbital-zoom-button:disabled {
opacity: 0.35;
cursor: default;
}
.cockpit-orbital-zoom {
min-width: 64px;
color: var(--cockpit-accent, #00dcff);
font-weight: 700;
letter-spacing: 0.12em;
text-align: center;
}
.cockpit-orbital-swath {
padding-inline: 4px 2px;
border-inline-start: 1px solid var(--orbital-faint);
font-variant-numeric: tabular-nums;
}
@media (max-width: 560px) {
.cockpit-orbital-readout {
max-width: calc(100% - 32px);
}
.cockpit-orbital-name {
font-size: 13px;
}
/* The phone shell lifts RESET / EXIT ORBIT (its row ends ~106px up) and
sets the DISPLAY / RADIO row above it (to ~194px up). The optics wrap to
two rows and sit above that row, where the zoom buttons can be tapped;
the card sits above the optics. */
.cockpit-orbital-optics {
bottom: 200px;
}
.cockpit-orbital-readout {
bottom: 284px;
}
}
/* A short window: the card stops below the left rail (DATA LAYERS,
UTILITIES, which end ~340px down) and scrolls its own content, the TLE
block included, instead of running into the rail. */
@media (max-height: 760px) and (min-width: 561px) {
.cockpit-orbital-readout {
max-height: max(120px, calc(100vh - clamp(150px, 19vh, 190px) - 350px));
overflow-y: auto;
}
}
/* An instant cut: no focus pull and no lock motion. */
@media (prefers-reduced-motion: reduce) {
.cockpit-orbital-focusing .cockpit-orbital-lens {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.cockpit-orbital-bracket {
transition: none;
}
.cockpit-orbital-reticle-lock .cockpit-orbital-bracket {
animation: none;
}
}
/* Off-nadir look: the tilt readout and the way back, tucked under the
readout card at the side of the picture. Shown only while tilted. */
.cockpit-orbital-look {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--orbital-faint);
}
.cockpit-orbital-look-readout {
color: var(--cockpit-accent, #00dcff);
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.cockpit-orbital-nadir {
padding: 3px 8px;
border: 1px solid var(--orbital-line);
border-radius: 2px;
background: rgba(0, 7, 12, 0.6);
color: #f4fbff;
font: inherit;
font-weight: 700;
letter-spacing: 0.12em;
cursor: pointer;
pointer-events: auto;
}
.cockpit-orbital-nadir:hover,
.cockpit-orbital-nadir:focus-visible {
border-color: var(--cockpit-accent, #00dcff);
color: #fff;
outline: none;
}
/* The Cockpit HUD in orbit (#cockpit-hud[data-subject='satellite']): the
orbital readout names the satellite, so the topline keeps only the
vision selector, and panels and buttons stay square like the bridge. */
#cockpit-hud[data-subject='satellite'] .cockpit-topline > div:first-child {
visibility: hidden;
}
:root[data-ui-theme='cyber'] #cockpit-hud[data-subject='satellite']::before {
clip-path: none;
}
:root:not([data-ui-theme='cyber']) body.cockpit-orbit {
--panel-radius: 2px;
--btn-radius: 2px;
}
#cockpit-hud[data-subject='satellite']
:is(
.cockpit-vision-controls,
.cockpit-vision-controls button,
.cockpit-card-actions button,
.cockpit-utility-glyph,
.cockpit-radio-transport button,
#cockpit-brief-auto
) {
border-radius: 2px;
}
body.cockpit-orbit #view-switcher button {
border-radius: 2px;
}
+35 -16
View File
@@ -12,6 +12,7 @@ import { useCockpitStore } from '../stores/cockpit.js';
import { useGlobeActionsStore } from '../stores/globeActions.js';
import CockpitBriefing from './panels/CockpitBriefing.vue';
import CockpitContext from './panels/CockpitContext.vue';
import CockpitOrbital from './panels/CockpitOrbital.vue';
import CockpitRadar from './panels/CockpitRadar.vue';
import CockpitRollingValue from './panels/CockpitRollingValue.vue';
import CockpitUtilityControls from './panels/CockpitUtilityControls.vue';
@@ -26,15 +27,31 @@ const { presence, instruments, route, vision, utilityLayout } =
const active = computed(() => Boolean(presence.value?.active));
// A vessel subject swaps the aviation readouts for bridge ones.
const bridge = computed(() => presence.value?.subjectKind === 'vessel');
const subject = computed(() => (bridge.value ? 'vessel' : 'aircraft'));
// A satellite subject keeps only its orbital HUD over the nadir picture.
const orbital = computed(() => presence.value?.subjectKind === 'satellite');
const subject = computed(() =>
bridge.value ? 'vessel' : orbital.value ? 'satellite' : 'aircraft',
);
const hudLabel = computed(() =>
bridge.value ? t('Ship bridge view') : t('Aircraft cockpit view'),
bridge.value
? t('Ship bridge view')
: orbital.value
? t('Satellite orbital view')
: t('Aircraft cockpit view'),
);
const exitLabel = computed(() =>
bridge.value ? t('Exit bridge view') : t('Exit cockpit view'),
bridge.value
? t('Exit bridge view')
: orbital.value
? t('Exit orbital view')
: t('Exit cockpit view'),
);
const exitText = computed(() =>
bridge.value ? t('EXIT BRIDGE') : t('EXIT COCKPIT'),
bridge.value
? t('EXIT BRIDGE')
: orbital.value
? t('EXIT ORBIT')
: t('EXIT COCKPIT'),
);
const globe = useGlobeActionsStore();
const { resetBusy } = storeToRefs(globe);
@@ -74,8 +91,10 @@ const tickStyle = (tick) =>
</script>
<template>
<!-- Aircraft first-person view or a vessel's bridge. It can be entered from the visible view switcher. -->
<section id="cockpit-hud" :aria-label="hudLabel" :hidden="!active" :data-subject="subject" :style="utilityStyle" v-class-toggle="{ 'signals-active': active, 'context-active': Boolean(presence?.context) }" :data-layer="hud.layer ?? undefined">
<!-- Aircraft first-person view, a vessel's bridge or a satellite's nadir view. It can be entered from the visible view switcher. -->
<section id="cockpit-hud" :aria-label="hudLabel" :hidden="!active" :data-subject="subject" :style="utilityStyle" v-class-toggle="{ 'signals-active': active, 'context-active': Boolean(presence?.context) }" :data-layer="orbital ? 'satellites' : hud.layer ?? undefined">
<!-- In orbit the picture keeps only the nadir reticle, readout and zoom. -->
<CockpitOrbital v-if="orbital" />
<!-- One optical coordinate system: every targeting mark shares this center.
Decorative only; telemetry and interactive panels remain separate.
A bridge looks out through straight window mullions instead: a header
@@ -87,7 +106,7 @@ const tickStyle = (tick) =>
<path d="M0 600H324 M876 600H1200"/>
</g>
</svg>
<svg v-else class="cockpit-frame-lines" viewBox="0 0 1200 720" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<svg v-else-if="!orbital" class="cockpit-frame-lines" viewBox="0 0 1200 720" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<g fill="none" stroke="currentColor">
<path d="M42 106H234L266 126H334 M866 126H934L966 106H1158 M42 114H226L258 136H318 M882 136H942L974 114H1158"/>
<path d="M42 616H226L254 598H320 M880 598H946L974 616H1158 M42 624H238L266 606H330 M870 606H934L962 624H1158"/>
@@ -97,7 +116,7 @@ const tickStyle = (tick) =>
<path class="cockpit-frame-warning" d="M338 645H476L490 654H710L724 645H862" fill="none"/>
<path class="cockpit-frame-meter" d="M372 642H418 M782 642H828"/>
</svg>
<div v-if="!bridge" class="cockpit-scan-array" aria-hidden="true">
<div v-if="!bridge && !orbital" class="cockpit-scan-array" aria-hidden="true">
<svg class="cockpit-target-geometry" viewBox="0 0 600 600" fill="none" focusable="false">
<g class="cockpit-target-hairline">
<circle cx="300" cy="300" r="276"/>
@@ -129,7 +148,7 @@ const tickStyle = (tick) =>
</g>
</svg>
</div>
<div class="cockpit-visor-shell" aria-hidden="true">
<div v-if="!orbital" class="cockpit-visor-shell" aria-hidden="true">
<!-- A ship's bridge keeps no roll arc or pitch ladder. -->
<div v-if="!bridge" class="cockpit-roll-arc">
<span style="--slot:-3;--depth:3">−30</span>
@@ -158,7 +177,7 @@ const tickStyle = (tick) =>
<div class="cockpit-visor-status cockpit-visor-status-left">{{ bridge ? t('BRIDGE WINDOW · FWD') : t('OPTICAL PLANE · 01') }}</div>
<div class="cockpit-visor-status cockpit-visor-status-right">{{ bridge ? (hud.trackSource === 'course' ? t('COURSE HOLD · AIS') : hud.trackSource === 'heading' ? t('HEADING HOLD · AIS') : t('NO HEADING · AIS')) : t('VISOR LOCK · ACTIVE') }}</div>
</div>
<div id="cockpit-speed-rim" class="cockpit-altitude-rim cockpit-speed-rim" v-class-toggle="{ unavailable: hud.speedRim.unavailable }">
<div v-if="!orbital" id="cockpit-speed-rim" class="cockpit-altitude-rim cockpit-speed-rim" v-class-toggle="{ unavailable: hud.speedRim.unavailable }">
<span class="cockpit-altitude-rim-label">{{ bridge ? t('SPEED OVER GROUND · KTS') : t('GROUND SPEED · KTS') }}</span>
<div class="cockpit-altitude-rim-rail"></div>
<div class="cockpit-altitude-rim-scale">
@@ -169,7 +188,7 @@ const tickStyle = (tick) =>
<strong id="cockpit-speed-rim-value">{{ hud.speedRim.value }}</strong>
</div>
</div>
<div id="cockpit-altitude-rim" class="cockpit-altitude-rim" :hidden="bridge" v-class-toggle="{ unavailable: hud.altitudeRim.unavailable }">
<div v-if="!orbital" id="cockpit-altitude-rim" class="cockpit-altitude-rim" :hidden="bridge" v-class-toggle="{ unavailable: hud.altitudeRim.unavailable }">
<span class="cockpit-altitude-rim-label">{{ t('ALTITUDE · FT') }}</span>
<div class="cockpit-altitude-rim-rail"></div>
<div class="cockpit-altitude-rim-scale">
@@ -201,7 +220,7 @@ const tickStyle = (tick) =>
</button>
</div>
</div>
<div class="cockpit-reticle material-symbols-outlined" aria-hidden="true">my_location</div>
<div v-if="!orbital" class="cockpit-reticle material-symbols-outlined" aria-hidden="true">my_location</div>
<!-- A bridge view turned off the bow says where it looks and offers the way back. -->
<div v-if="bridge" class="cockpit-look" :hidden="!look.offset">
<span id="cockpit-look-readout" class="cockpit-look-readout" aria-live="polite">{{ look.readout }}</span>
@@ -209,7 +228,7 @@ const tickStyle = (tick) =>
<span>{{ t('LOOK AHEAD') }}</span>
</button>
</div>
<div class="cockpit-compass" :aria-label="bridge ? t('Current vessel heading') : t('Current aircraft heading')">
<div v-if="!orbital" class="cockpit-compass" :aria-label="bridge ? t('Current vessel heading') : t('Current aircraft heading')">
<div class="cockpit-instrument-row">
<div class="cockpit-readout cockpit-speed">
<span class="cockpit-readout-label">{{ bridge ? t('SPEED OVER GROUND') : t('GROUND SPEED') }}</span>
@@ -231,7 +250,7 @@ const tickStyle = (tick) =>
<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" :style="bridge ? { '--tape-shift': tapeShift } : undefined"><span v-for="division in hud.compass" :key="division.heading ?? division.slot" :class="(division.active ?? division.slot === 0) ? 'active' : ''" :style="{ '--slot': division.slot, '--depth': Math.abs(division.slot) }">{{ division.label }}</span><template v-if="bridge"><i v-if="hud.compassMarkers?.prograde != null" class="cockpit-compass-marker cockpit-compass-prograde" aria-hidden="true" :style="{ '--slot': hud.compassMarkers.prograde }"></i><i v-if="hud.compassMarkers?.retrograde != null" class="cockpit-compass-marker cockpit-compass-retrograde" aria-hidden="true" :style="{ '--slot': hud.compassMarkers.retrograde }"></i></template></div>
</div>
<div class="cockpit-position-readout">
<div v-if="!orbital" class="cockpit-position-readout">
<span id="cockpit-clock">{{ hud.clock }}</span>
<span id="cockpit-position">{{ hud.position }}</span>
</div>
@@ -242,7 +261,7 @@ const tickStyle = (tick) =>
<CockpitRadar />
</div>
<CockpitUtilityControls />
<div class="cockpit-help"><bdi>{{ t('ESC EXIT') }}</bdi> <span>·</span> <bdi>{{ t('C TOGGLE') }}</bdi><template v-if="bridge"> <span>·</span> <bdi>{{ t('ARROWS LOOK') }}</bdi> <span>·</span> <bdi>{{ t('HOME RESET') }}</bdi></template></div>
<div class="cockpit-help"><bdi>{{ t('ESC EXIT') }}</bdi><template v-if="bridge"> <span>·</span> <bdi>{{ t('ARROWS LOOK') }}</bdi> <span>·</span> <bdi>{{ t('HOME RESET') }}</bdi></template><template v-if="orbital"> <span>·</span> <bdi>{{ t('+/− ZOOM') }}</bdi> <span>·</span> <bdi>{{ t('ARROWS TILT') }}</bdi> <span>·</span> <bdi>{{ t('HOME NADIR') }}</bdi></template></div>
</section>
<nav id="view-switcher" :aria-label="t('View switcher')">
@@ -250,7 +269,7 @@ const tickStyle = (tick) =>
<span class="material-symbols-outlined" aria-hidden="true">public</span>
<span>{{ t('RESET') }}</span>
</button>
<button id="map-view-switch" type="button" :aria-label="exitLabel" aria-keyshortcuts="Escape C" :title="exitLabel" :hidden="!active">
<button id="map-view-switch" type="button" :aria-label="exitLabel" aria-keyshortcuts="Escape" :title="exitLabel" :hidden="!active">
<span class="material-symbols-outlined" aria-hidden="true">close_fullscreen</span>
<span>{{ exitText }}</span>
</button>
+279
View File
@@ -0,0 +1,279 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed, nextTick, ref, watch } from 'vue';
import { formatNumber, getLocale, t } from '../../../i18n/index.js';
import {
formatCockpitCoordinate,
formatTleEpochUtc,
} from '../../cockpitPresentation.js';
import {
loadFlagUrl,
ownerMark,
regionName,
shownOwnerMark,
} from '../../ownerFlag.js';
import { useCockpitStore } from '../../stores/cockpit.js';
// The orbital view's HUD: a nadir reticle over the picture, the satellite's
// identity and orbit, and the optical zoom. The Cockpit controller presents
// it, drives the zoom step's blur and lock tick, and keeps the zoom buttons'
// listeners; this view only reflects the store.
const { orbital } = storeToRefs(useCockpitStore());
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
const altitudeText = computed(() => {
const altitudeM = orbital.value?.altitudeM;
return finite(altitudeM)
? t('{value} KM', {
value: formatNumber(Math.round(altitudeM / 1000)),
})
: '—';
});
const velocityText = computed(() => {
const velocityMps = orbital.value?.velocityMps;
return finite(velocityMps)
? t('{value} KM/S', {
value: formatNumber(velocityMps / 1000, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}),
})
: null;
});
const subPointText = computed(() => {
const { latitude, longitude } = orbital.value || {};
if (!finite(latitude) || !finite(longitude)) return '—';
return `${formatCockpitCoordinate(latitude, 'N', 'S')} ${formatCockpitCoordinate(longitude, 'E', 'W')}`;
});
const zoomSteps = computed(() => orbital.value?.zoomSteps || [1]);
const zoom = computed(() => orbital.value?.zoom ?? zoomSteps.value[0]);
const zoomText = computed(() =>
t('ZOOM {zoom}×', { zoom: formatNumber(zoom.value) }),
);
const maxZoom = computed(
() => orbital.value?.maxZoom ?? Math.max(...zoomSteps.value),
);
const atWidest = computed(() => zoom.value <= Math.min(...zoomSteps.value));
const atTightest = computed(() => zoom.value >= maxZoom.value);
// Track-up turns the frame to the ground track; north's pointer turns back.
const trackUp = computed(() => orbital.value?.trackUp === true);
const northPointerStyle = computed(() => {
const northDeg = orbital.value?.northDeg;
return finite(northDeg) && northDeg !== 0
? { transform: `rotate(${northDeg.toFixed(1)}deg)` }
: null;
});
// The N itself stays upright as its pointer turns.
const northLetterStyle = computed(() => {
const northDeg = orbital.value?.northDeg;
return finite(northDeg) && northDeg !== 0
? { transform: `translateX(-50%) rotate(${(-northDeg).toFixed(1)}deg)` }
: null;
});
// A focused zoom button that turns off at the end of its range hands focus
// on instead of dropping it to the page. The watchers run before the
// re-render, while it still has focus.
const zoomInButton = ref(null);
const zoomOutButton = ref(null);
const handFocus = (from, to) => {
if (!from || document.activeElement !== from) return;
nextTick(() =>
(to() ?? document.getElementById('map-view-switch'))?.focus({
preventScroll: true,
}),
);
};
watch(
atTightest,
(now) => now && handFocus(zoomInButton.value, () => zoomOutButton.value),
{ flush: 'pre' },
);
watch(
atWidest,
(now) => now && handFocus(zoomOutButton.value, () => zoomInButton.value),
{ flush: 'pre' },
);
// At nadir the footprint; tilted, the approximate width at the view's
// centre, marked ≈.
const swathText = computed(() => {
const tilted = orbital.value?.look?.offset === true;
const km = tilted ? orbital.value?.look?.swathKm : orbital.value?.footprintKm;
if (!finite(km)) return '';
const digits = km < 10 ? 1 : 0;
const value = formatNumber(km, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
return tilted
? t('SWATH ≈ {value} KM', { value })
: t('SWATH {value} KM', { value });
});
// What the sub-satellite point is over: nothing until the first lookup
// lands; on land the division as Natural Earth names it and the country in
// the reader's language, with the populated place in view from 8x; at sea
// the Natural Earth name.
const placeText = computed(() => {
const place = orbital.value?.place;
if (place === undefined) return '';
// No feature holds the point (a gap at a simplified coastline): say
// nothing rather than call land open ocean.
if (!place) return '';
if (place.kind !== 'country') return place.name || '';
const country =
(place.countryCode && regionName(place.countryCode, getLocale())) ||
place.name ||
'';
const region = place.division
? t('{division}, {country}', { division: place.division, country })
: country;
return place.near
? t('{region} · near {place}', { region, place: place.near })
: region;
});
// The owner's flag, or a text badge for an owner without one.
const mark = computed(() =>
ownerMark(orbital.value?.owner, { locale: getLocale() }),
);
const flagUrl = ref(null);
// A flag that would not load (offline, a stale chunk after a deploy, a
// missing file) falls back to the code as a badge, never to no owner at all.
const flagFailed = ref(false);
let flagRequest = 0;
watch(
() => (mark.value?.kind === 'flag' ? mark.value.code : null),
async (code) => {
const request = ++flagRequest;
flagUrl.value = null;
flagFailed.value = false;
if (!code) return;
let url = null;
try {
url = await loadFlagUrl(code);
} catch {
url = null;
}
if (request !== flagRequest) return;
flagUrl.value = url;
flagFailed.value = !url;
},
{ immediate: true },
);
const shownMark = computed(() =>
shownOwnerMark(mark.value, {
url: flagUrl.value,
failed: flagFailed.value,
}),
);
// NORAD · COSPAR · TLE epoch, and the two element lines on request.
const identParts = computed(() => {
const o = orbital.value;
if (!o) return [];
const parts = [];
if (o.noradId !== null && o.noradId !== undefined && o.noradId !== '')
parts.push(t('NORAD {id}', { id: String(o.noradId) }));
if (o.cosparId) parts.push(o.cosparId);
const epoch = formatTleEpochUtc(o.tle?.epochMs);
if (epoch) parts.push(t('EPOCH {time}', { time: epoch }));
return parts;
});
const identText = computed(() => identParts.value.join(' · '));
const tleText = computed(() => {
const tle = orbital.value?.tle;
return tle?.line1 && tle?.line2 ? `${tle.line1}\n${tle.line2}` : '';
});
const tleOpen = ref(false);
// Another satellite starts with its element set folded.
watch(
() => orbital.value?.noradId ?? null,
() => {
tleOpen.value = false;
},
);
// The off-nadir tilt; an untilted view shows no readout or reset.
const look = computed(() => orbital.value?.look || { offset: false });
const transition = computed(() => orbital.value?.transition || {});
const lensStyle = computed(() => {
const blurPx = transition.value.blurPx;
return {
'--cockpit-orbital-blur': `${finite(blurPx) && blurPx > 0 ? blurPx.toFixed(2) : 0}px`,
};
});
</script>
<template>
<section id="cockpit-orbital" class="cockpit-orbital" :class="{ 'cockpit-orbital-focusing': transition.active }" :style="lensStyle" :aria-label="t('Orbital view')" :hidden="!orbital">
<template v-if="orbital">
<div class="cockpit-orbital-lens" aria-hidden="true"></div>
<div class="cockpit-orbital-reticle" :class="{ 'cockpit-orbital-reticle-lock': transition.lockTick }" aria-hidden="true">
<span class="cockpit-orbital-bracket cockpit-orbital-bracket-tl"></span>
<span class="cockpit-orbital-bracket cockpit-orbital-bracket-tr"></span>
<span class="cockpit-orbital-bracket cockpit-orbital-bracket-bl"></span>
<span class="cockpit-orbital-bracket cockpit-orbital-bracket-br"></span>
<span class="cockpit-orbital-cross"></span>
<!-- North on the frame: at the top while north-up, turned with the ground track while track-up. -->
<span class="cockpit-orbital-north-pointer" :style="northPointerStyle">
<span class="cockpit-orbital-north-tick"></span>
<span v-if="trackUp" class="cockpit-orbital-north-letter" :style="northLetterStyle">{{ t('N') }}</span>
</span>
</div>
<!-- Under the vision selector: where the ground track is. -->
<p v-if="placeText" id="cockpit-orbital-over" class="cockpit-orbital-over"><span>{{ t('CURRENTLY OVER') }}</span> <strong>{{ placeText }}</strong></p>
<div class="cockpit-orbital-readout">
<p class="cockpit-orbital-kicker">{{ look.offset ? t('ORBITAL VIEW · OFF-NADIR') : t('ORBITAL VIEW · NADIR') }}</p>
<div class="cockpit-orbital-title">
<!-- i18n-ignore: @error is the image's load-failure event, not a status message. -->
<img v-if="shownMark?.kind === 'flag'" class="cockpit-orbital-flag" :src="shownMark.url" :alt="shownMark.label" :title="shownMark.label" width="20" height="15" @error="flagFailed = true" />
<span v-else-if="shownMark?.kind === 'badge'" class="cockpit-orbital-owner-badge" :title="shownMark.title">{{ shownMark.label }}</span>
<p class="cockpit-orbital-name">{{ orbital.name }}</p>
</div>
<p v-if="orbital.operator" class="cockpit-orbital-operator">{{ orbital.operator }}</p>
<p v-if="orbital.classLabel" class="cockpit-orbital-class">{{ orbital.classLabel }}</p>
<div v-if="identText" class="cockpit-orbital-ident">
<!-- Wraps between parts, never inside a date. -->
<span class="cockpit-orbital-ident-text"><template v-for="(part, index) in identParts" :key="index"><template v-if="index"> · </template><span class="cockpit-orbital-ident-part">{{ part }}</span></template></span>
<button v-if="tleText" id="cockpit-orbital-tle-toggle" type="button" class="cockpit-orbital-tle-toggle" :aria-expanded="String(tleOpen)" aria-controls="cockpit-orbital-tle" :aria-label="tleOpen ? t('Hide the two-line element set') : t('Show the two-line element set')" :title="tleOpen ? t('Hide the two-line element set') : t('Show the two-line element set')" @click="tleOpen = !tleOpen">{{ t('TLE') }}</button>
</div>
<pre v-if="tleText" v-show="tleOpen" id="cockpit-orbital-tle" class="cockpit-orbital-tle" :aria-label="t('Two-line element set')">{{ tleText }}</pre>
<dl class="cockpit-orbital-data">
<div class="cockpit-orbital-datum">
<dt>{{ t('ALTITUDE') }}</dt>
<dd class="cockpit-orbital-altitude">{{ altitudeText }}</dd>
</div>
<div v-if="velocityText" class="cockpit-orbital-datum">
<dt>{{ t('ORBITAL VELOCITY') }}</dt>
<dd class="cockpit-orbital-velocity">{{ velocityText }}</dd>
</div>
<div class="cockpit-orbital-datum">
<dt>{{ t('SUB-SATELLITE POINT') }}</dt>
<dd class="cockpit-orbital-subpoint">{{ subPointText }}</dd>
</div>
</dl>
<p class="cockpit-orbital-north">{{ trackUp ? t('TRACK UP') : t('NORTH UP') }}</p>
<!-- Shown only while the view is tilted off nadir: where it looks and the way back. -->
<div v-if="look.offset" class="cockpit-orbital-look">
<span id="cockpit-orbital-look-readout" class="cockpit-orbital-look-readout" aria-live="polite">{{ look.readout }}</span>
<button id="cockpit-orbital-nadir" type="button" class="cockpit-orbital-nadir" :aria-label="t('Return the view to nadir')" :title="t('Return to nadir (Home or 0)')" aria-keyshortcuts="Home 0">{{ t('NADIR') }}</button>
</div>
</div>
<div class="cockpit-orbital-optics">
<button id="cockpit-orbital-zoom-out" ref="zoomOutButton" type="button" class="cockpit-orbital-zoom-button" :disabled="atWidest" :aria-label="t('Zoom out')" :title="t('Zoom out (−)')" aria-keyshortcuts="- _">−</button>
<span class="cockpit-orbital-zoom" aria-live="polite">{{ zoomText }}</span>
<button id="cockpit-orbital-zoom-in" ref="zoomInButton" type="button" class="cockpit-orbital-zoom-button" :disabled="atTightest" :aria-label="t('Zoom in')" :title="t('Zoom in (+)')" aria-keyshortcuts="+ =">+</button>
<span v-if="swathText" class="cockpit-orbital-swath">{{ swathText }}</span>
</div>
</template>
</section>
</template>
+1
View File
@@ -10,6 +10,7 @@
@import './src/ui/styles/cockpit.css';
@import './src/ui/styles/cockpitBridge.css';
@import './src/ui/styles/cockpitRadar.css';
@import './src/ui/styles/cockpitOrbital.css';
@import './src/ui/styles/overlays.css';
@import './src/ui/styles/layers.css';
@import './src/ui/styles/radio.css';