mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 04:44:01 +00:00
Merge branch 'main' into feat/draw-mark-actions
This commit is contained in:
149 files changed
+20764
-593
No files matched your search
+22
-1
@@ -31,7 +31,7 @@ How to read this:
|
||||
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
|
||||
| **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow |
|
||||
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
|
||||
| **CelesTrak** | Satellite TLEs (SGP4) | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
|
||||
| **CelesTrak** | Satellite TLEs (SGP4) and SATCAT records (object type, operational status, owner) that file each satellite under a class | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
|
||||
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
|
||||
| **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) |
|
||||
| **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" |
|
||||
@@ -229,9 +229,12 @@ 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; 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" |
|
||||
| **Imaging sensor pack** (46 Earth-observation platforms: swath, resolution) | `imaging_sensors/` | Individual specification facts from operator and agency pages, cited per entry | ✅ (facts only) | "Imaging sensor swaths: operator and agency specifications" |
|
||||
| **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 |
|
||||
@@ -293,6 +296,14 @@ transaction quota. Requires a free `FIRMS_MAP_KEY`
|
||||
(https://firms.modaps.eosdis.nasa.gov/api/map_key/); the layer is empty without it.
|
||||
The former bundled 2026-05-25 snapshot was removed 2026-07-16.
|
||||
|
||||
### UCS Satellite Database (`ucs_satellites/`)
|
||||
|
||||
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.
|
||||
|
||||
### Imaging sensor pack (`imaging_sensors/`)
|
||||
|
||||
Swath widths and ground resolutions for 46 Earth-observation platforms (Landsat, Sentinel, MODIS, VIIRS, GOES, Himawari, Gaofen, CBERS, RADARSAT, TerraSAR-X, COSMO-SkyMed, ALOS, KOMPSAT, SPOT, Pléiades, WorldView, SkySat, PlanetScope, ICEYE, Capella, Umbra and others), compiled 2026-11-04 from the operator and agency pages cited per entry. The orbital view draws each satellite's sensor swath to scale and caps its zoom at that swath. Facts only; no text or images copied. A swath is confirmed on an operator, agency (ESA's eoPortal included) or academic page; resellers are not cited. The CEOS MIM Database (CC BY 4.0) is not yet used. Sources and unverified values: the folder README.
|
||||
|
||||
### Natural Earth physical regions (`natural_earth/`)
|
||||
|
||||
Curated from the **Natural Earth 10m physical vectors** (https://www.naturalearthdata.com/ —
|
||||
@@ -312,6 +323,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -436,7 +436,7 @@ Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer ad
|
||||
| 📡 **GNSS Integrity** | Regional navigation accuracy scores; anomalies do not confirm interference | adsb.lol, GPSJAM aggregation | 🟢 |
|
||||
| 🔥 **Fire Perimeters** | Incident boundaries and reported containment | WFIGS / NIFC | 🟢 |
|
||||
| 🚢 **Live Vessels** | Thousands of ships worldwide | AISStream | 🟡 |
|
||||
| 🛰️ **Satellites** | 838-object catalog, color-coded by class with a live legend — the **DENSE** chip drops in the whole Starlink shell | CelesTrak | 🟢 |
|
||||
| 🛰️ **Satellites** | About 1,300 objects in six classes (Stations, Imaging, Telecom, Starlink, Probes, Debris) with a toggle and a live count each; Starlink loads its whole shell | CelesTrak | 🟢 |
|
||||
| 🌍 **Earthquakes** | Global seismic activity, last 24h | USGS | 🟢 |
|
||||
| ◍ **Air quality** | Live AQHI on the published 1–10+ health-risk scale, with a Weather-panel card showing the band legend and the highest readings in view. **Canada only** — ECCC is the first registered provider | ECCC MSC GeoMet | 🟢 |
|
||||
| 🚗 **Traffic** | Simulated vehicles on OSM roads. With TomTom, live flow speeds drive the simulation and congestion colors below ~8 km; individual vehicle positions are not live observations | TomTom + OSM | 🟢 simulation · 🟡 live flow speeds |
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
- Ride the selected AIS vessel from its bridge: an eye height by vessel type,
|
||||
dead reckoning between fixes, a bridge HUD with a 3–24 NM radar, look-around
|
||||
with the arrow keys, and a waterline read from the scene so river boats sit on
|
||||
the river rather than at sea level (Denis Groshev, @ZloyS, #1557).
|
||||
@@ -0,0 +1,4 @@
|
||||
- File satellites under six classes, Stations, Imaging, Telecom, Starlink,
|
||||
Probes and Debris, each with a toggle; all but Starlink show by default.
|
||||
Classes come from CelesTrak SATCAT, the UCS Satellite Database and name rules
|
||||
(Denis Groshev, @ZloyS, #1558).
|
||||
@@ -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).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Zoom imaging satellites past 8×, up to 128×, as far as their sensor's
|
||||
confirmed swath allows at the live altitude, with a to-scale swath strip, a
|
||||
SENSOR step, mouse-wheel zoom, imagery pre-loaded ahead of the ground track and
|
||||
the TLE's age (Denis Groshev, @ZloyS, #1560).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Hold the orbital camera on a mapped site as the satellite passes over it,
|
||||
from a click on its marker or its Live Signals row, and list nearby sites
|
||||
and satellites in orbit's Live Signals, where a row focuses a site or rides
|
||||
a satellite (Denis Groshev, @ZloyS, #1580).
|
||||
@@ -163,7 +163,8 @@ local composition mounts them in the same order. Importing the entry performs
|
||||
no acquisition; factory calls create independent cache state.
|
||||
|
||||
`gods-eye-view/sources/space` owns only fixed upstream URL construction: the
|
||||
CelesTrak group/TLE query and Launch Library's recent 30-day detailed feed.
|
||||
CelesTrak group/TLE and SATCAT record queries and Launch Library's recent
|
||||
30-day detailed feed.
|
||||
Callers supply the group or end date and own validation, credentials, transport,
|
||||
response limits and cache policy. The boundary gate checks this portable entry
|
||||
separately from the Node providers. Satellite rendering and launch replay remain
|
||||
|
||||
+307
-17
@@ -1524,6 +1524,291 @@ retained Cockpit actions cannot restart a disposed controller. Input, subscripti
|
||||
and queued panel work stop before asynchronous layer restoration; final camera
|
||||
and portal cleanup follows that restoration.
|
||||
|
||||
### Ship bridge view
|
||||
|
||||
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. 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 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
|
||||
ignores scene clicks so open water cannot deselect it. The eye sits on the AIS
|
||||
position at a representative bridge height for the AIS type family
|
||||
(`src/layers/vessels/cockpitSubject.js`: cargo and tanker 30 m, passenger 25 m,
|
||||
tug 7 m, fishing 6 m, ferry and AIS "Other" (codes 90–99, or a feed's text
|
||||
type "Other") 4 m, sailing and pleasure 3 m; 12 m when the type names no
|
||||
family, including the reserved and wing-in-ground codes the vessel layer also
|
||||
files under OTHER), pitched 2° down with 2 m clearance over the shared ground floor
|
||||
(`src/cockpitSubjects.js`). The camera faces true heading (course over ground
|
||||
when heading is missing) and moves along course over ground. A ship reporting
|
||||
neither has no direction: the camera keeps the heading it had on entry, the
|
||||
heading readout shows `---`, the visor reads NO HEADING · AIS, the entry signal
|
||||
names the ship without a heading, no bow or stern mark is drawn, and the eye
|
||||
stays on the fix rather than moving along a made-up course. The vessel layer
|
||||
redraws a ship only on its 60-second refresh, so the camera's target is the last
|
||||
fix dead-reckoned along course over ground for up to 180 s, and the existing
|
||||
inertial anchor absorbs each new fix. The fix's age is read without comparing
|
||||
the two clocks (`vesselFixAgeSec`): the time since this client first received
|
||||
the fix, plus how far it trailed that refresh's newest fix, so a client clock
|
||||
running fast or slow neither projects the ship nor marks it stale, and a fix
|
||||
the feed keeps re-serving from its cache goes on ageing into STALE FEED. A
|
||||
stale vessel, or a projection past that limit, holds the camera on the target.
|
||||
The vessel layer floats ships on the geoid, which on a river or lake can lie
|
||||
well below the water (the Seine through Paris is ~27 m up), so the bridge also
|
||||
reads the rendered surface around its ship: the ship's position and a ring of
|
||||
eight probes 30 m out, the lowest probe taken as the water when it is 2 m to
|
||||
4,000 m above sea level. The first read starts from the tiles already loaded, then
|
||||
the full-detail read replaces it; it repeats every 5 s. Only one full-detail
|
||||
read runs at a time: while one is still running, a refresh reads the loaded
|
||||
tiles instead, and the slow read applies whenever it lands. Once a
|
||||
full-detail read has answered, loaded-tile reads no longer change the height;
|
||||
before then they may find raised water but never take it back to the sea.
|
||||
A read that sampled nothing, or failed, keeps the height it had and is
|
||||
tried again on the next refresh.
|
||||
|
||||
The HUD keeps its frame, vision styles, briefing, weather and Display/Radio
|
||||
utilities. It names the ship, reads `{type} · LIVE AIS · EYE {height}`, shows
|
||||
speed over ground in knots, true heading and course over ground in place of
|
||||
altitude, hides the altitude tape, and shows the crew-entered AIS destination as
|
||||
a Reported Voyage card without an origin or direction arrow. The arrow keys
|
||||
look around the bridge in 5° steps (Shift for 45°): Left and Right turn the
|
||||
view all the way round, Up and Down pitch it up to ±90°, and the camera eases
|
||||
there unless the user prefers reduced motion. Home or `0`, or the LOOK AHEAD
|
||||
button shown while the view is turned, bring it back over the bow at the rest
|
||||
pitch. The keys stay with a focused text field, slider, tab, radio group, list
|
||||
or menu. The heading tape is a straight strip that scrolls continuously with
|
||||
the camera, so the caret reads the view's exact heading, while the readout
|
||||
above it keeps the ship's true heading; a ring with a centre dot marks the bow
|
||||
(prograde) and a ring with a cross the stern (retrograde) at their bearings
|
||||
from the caret. The bridge radar
|
||||
shows nearby AIS ships heading-up and opens at 3 NM
|
||||
(`DEFAULT_RADAR_RANGE_NM` in `src/cockpitRadar.js`); its range button steps
|
||||
3, 6, 12 and 24 NM and the chosen range is kept across bridges. Own ship
|
||||
sits where the camera rides (its fix dead-reckoned) and contacts at their
|
||||
last fixes, where the vessel layer draws them, so the scope matches the view
|
||||
out of the window. Contact
|
||||
Previous/Next and Live Signals aircraft handoffs are refused on the bridge and
|
||||
the Contact card stays hidden. Exit returns the camera to the vessel's map view
|
||||
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 by the satellites layer while it is ridden (a click on a mapped
|
||||
site focuses it; see below). The optical zoom steps 1×, 2×, 4× and 8× for
|
||||
every satellite with `+` (or `=`) and `-` (or `_`), the panel's buttons and
|
||||
the mouse wheel (one step per notch; trackpad scrolling is summed to a step):
|
||||
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`. An imaging satellite matched to an entry of
|
||||
the bundled sensor pack (`src/data/local_data/imaging_sensors/`, by NORAD
|
||||
number or name in `src/layers/satellites/sensors.js`) whose narrowest swath
|
||||
is confirmed also gets 16×, 32×, 64× and 128×, each only while the view at
|
||||
the satellite's live altitude and the window's shape is still at least as
|
||||
wide as that swath (`orbitalMaxZoom` in `src/cockpitOrbital.js`); when the
|
||||
cap falls below the zoom shown, the view steps back to it. Unconfirmed
|
||||
swaths add no steps, but are still drawn, read out and offered to SENSOR.
|
||||
The sensor's swath is drawn to scale as a strip along the ground track with
|
||||
a SENSOR SWATH readout, and a SENSOR button zooms to exactly that width when
|
||||
it lies between 1× and the cap. No strip is drawn for a geostationary view
|
||||
(no ground track; its sensors image areas, not strips) or a SAR (it images
|
||||
to one side of nadir, not under it); the readout stays. Tilted off nadir
|
||||
the strip stays (view and swath widen alike), the readout scales as the swath
|
||||
readout does (`SENSOR SWATH ≈ 191 KM`), and SENSOR, a nadir framing, is
|
||||
hidden; tilted past the Earth's limb neither the sensor nor the swath reads. A swath is confirmed only on the operator's, an agency's (ESA's
|
||||
eoPortal included) or an academic page for its platform. From
|
||||
16×, with flat imagery on the globe (not Google 3D tiles) and the view at
|
||||
nadir, every 2 s the view asks the base imagery layer for the tiles it will
|
||||
need 5 to 20 s ahead along the ground track (`prefetchImageryAhead` in
|
||||
`src/ui/cockpitOrbitalView.js`), so the browser has them cached when the
|
||||
view arrives. The level is the one Cesium will draw there
|
||||
(`imageryLevelForView` in `src/cockpitOrbital.js`, following Cesium's own
|
||||
choice: the terrain level whose error spans at most the globe's maximum
|
||||
screen-space error, then the imagery level whose texels are nearest that
|
||||
error, about one to two screen pixels per texel). It asks for at most 12
|
||||
tiles at a time through Cesium's request scheduler, throttled per server and
|
||||
at the lowest priority, so a request is declined while the imagery server's
|
||||
slots are taken by the tiles on screen and is the first dropped when the
|
||||
scheduler is full (a declined or dropped tile is asked for again later, one that failed to load is not); it remembers the last 512 tiles asked for and does not ask
|
||||
for those again, starts over on another base map (cancelling the old map's
|
||||
pending requests), and cancels the requests still pending on exit. Across the track it samples the centre line, a third
|
||||
out and the frame's edges. While tilted off nadir the view looks away from
|
||||
the track, and with the world clock more than a minute from now (paused or
|
||||
played back) it will not pass where the look-ahead points, so nothing is
|
||||
pre-loaded. 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.
|
||||
|
||||
In orbit the briefing's Live Signals page (OBSERVED / MAPPED PINGS) lists
|
||||
what is around the satellite, as the aircraft Cockpit lists the contacts
|
||||
of its Contacts snapshot (`src/ui/cockpitOrbitalSignals.js`): five rows at
|
||||
most, rebuilt once a second with stable keys, so a row keeps its place and
|
||||
time. TRACK ACQUIRED stays first. Then:
|
||||
|
||||
- **Sites**: the Mapped Installations layer's loaded records around the
|
||||
sub-satellite point (`getNearby`, asked only as far as the reach extends
|
||||
over the ground, and never past 3,000 km, so from a high orbit only the
|
||||
sites within 3,000 km of the point below are listed) that face the
|
||||
satellite and lie within the off-nadir reach, nearest to nadir first, the focused site always among them. A row
|
||||
reads the site's name over its class and off-nadir angle; its button
|
||||
(`Keep the camera on …`) focuses the site, and on the focused site's row,
|
||||
marked as the subject is (`FOCUS · …`, `Release the camera from …`), ends
|
||||
the focus. With Mapped Installations off the layer keeps its records, so
|
||||
nothing is listed then: a SITES STANDBY row says to turn the layer on.
|
||||
- **Satellites**: the nearest shown satellites to the ridden one, debris
|
||||
left out (the satellites layer's bounded `getNearestPositions`, measured
|
||||
from the satellite itself), with their bearing from the top of the frame
|
||||
and distance. A row (`Ride …`) rides that satellite. While there is a
|
||||
satellite ridden before this one, a RETURN · *name* row goes back to it; if
|
||||
that satellite has left the catalogue it is dropped and RETURN offers the
|
||||
one before. Riding stays in orbit: the satellites layer tracks the new
|
||||
satellite (`trackById`), the Cockpit takes the camera back at once and
|
||||
marks it as the ridden subject, any ground focus ends with the view easing
|
||||
back to nadir, and the camera hops to the new satellite along the great
|
||||
circle over 1.2 s (cut under reduced motion), keeping the zoom. Until the
|
||||
camera is on the new satellite the page shows only TRACK ACQUIRED and
|
||||
RETURN (and SITES STANDBY with Mapped Installations off), then rebuilds
|
||||
at once. The new satellite's TRACK ACQUIRED carries the time it was
|
||||
ridden. The hop starts from where the camera was, which does not follow
|
||||
the old satellite on.
|
||||
|
||||
Sites take two rows while satellites fill the rest, more when there are
|
||||
fewer satellites. The Contact card stays hidden in orbit.
|
||||
|
||||
The focus (`src/ui/cockpitOrbitalFocus.js`, geometry in
|
||||
`src/cockpitOrbitalFocus.js`) aims the look each frame from the satellite's
|
||||
position to the site's Earth-fixed position in the same track-up nadir
|
||||
frame the arrows tilt, so the site stays at the centre as the satellite
|
||||
moves. The turn onto it (and between sites) eases what is left of the swing
|
||||
with the arrows' easing on a slower clock (250 ms time constant, about
|
||||
0.75 s to 95%), then holds the site exactly, and cuts under
|
||||
`prefers-reduced-motion`. A single click on a site's marker focuses it too:
|
||||
the pick is resolved through the layer's own pick ids (`findByPick`: a named
|
||||
marker's `{ installationId }` or a site entity). A click anywhere else
|
||||
(a site beyond the reach included), the
|
||||
focused site's row, NADIR, Home or `0` ends the focus and the view eases
|
||||
back to nadir on the same clock; an arrow ends it (or the ease back) and
|
||||
tilts on from where the view is. The layer reloads its thinned view as the
|
||||
camera moves (about once a second in orbit), so a site in view can leave
|
||||
its records for a moment: while the layer is on, a site seen in the last
|
||||
10 s stays a candidate (sites do not move), so the rows hold still and a
|
||||
row's site can be focused, and a focused site keeps its last position. The focus ends by
|
||||
itself, easing back, when the site leaves the reach (either tilt axis past
|
||||
±45°, or the site below the satellite's horizon), when the layer is turned
|
||||
off, when another satellite is ridden, and with the orbital view. A site
|
||||
beyond the reach cannot be focused. While focused the readout gives the
|
||||
single off-nadir angle to the site, as image metadata does
|
||||
(`OFF-NADIR 23.4°`, updated on the HUD's beat and not announced), names it
|
||||
in a polite live region (`FOCUS · Camp Alpha`), and the swath reads
|
||||
approximate as for a tilt, scaled by the tilt onto the site. Clicks still
|
||||
reach Cesium's own handler and never untrack the satellite; a click a tool
|
||||
owns (drawing) is ignored, and the draw tool still borrows the click while
|
||||
it draws. A double-click on a site keeps or starts the focus on it and
|
||||
never reaches Cesium's double-click, which would track the site and end the
|
||||
view (the Cockpit's double-click routing in `src/ui/cockpitInput.js`); any
|
||||
other double-click goes on as before. `src/ui/cockpitOrbitalFocus.test.mjs`
|
||||
covers the rows, the easing in and out, the reach release, switching sites
|
||||
and satellites, the hop, the layer turned off, click handling and the
|
||||
readout, against the layers' method shapes.
|
||||
|
||||
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, ending with the element
|
||||
set's age (`<1 H OLD`, then whole hours, then whole days: the gap, either
|
||||
side, between the epoch and the world-clock time the satellite is shown at), 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 (Live Signals as above, 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,
|
||||
@@ -3414,7 +3699,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
|
||||
@@ -4368,7 +4653,7 @@ its criteria cannot be silently ignored.
|
||||
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
|
||||
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
|
||||
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
|
||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||
| Satellites | CelesTrak TLE + SATCAT; bundled UCS pack (purpose) | `src/data/satellites.js` | `/api/celestrak` + `/api/celestrak-satcat` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Iceland (Vegagerðin / IRCA) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
@@ -5373,11 +5658,13 @@ silently demoting every later lookup for the session.
|
||||
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
|
||||
- **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder.
|
||||
- **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them.
|
||||
- **Satellites**: 838-sat core catalog (stations/visual/GPS/GLONASS/Galileo/GEO), tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode.
|
||||
- **Satellite classes** (`src/data/satelliteClass.js`): every satellite is classified from the CelesTrak group it was ingested with — no extra fetch and no heuristics — into STATION (warm white), NAV (cyan; GPS/GLONASS/Galileo deliberately share one color so the GNSS family reads as one thing), GEO (violet), VISUAL (muted blue-gray catch-all), and COMMS (dim slate, dense Starlink shell only). That module is the single source of truth for class, label, and color, 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 COMMS stays far below VISUAL 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 STATION color — it carries a permanent name label and its card still reads `STATION · ISS`. `satelliteClassOf` is the single owner of that ISS rule, so the card label and the legend tally can never disagree: during a stations-feed outage the ISS is ingested as `visual`, and both surfaces still file it under STATION.
|
||||
- Class is also a **text field**, not just a color: `NAV · GPS` / `GEO` / `COMMS · STARLINK` / `STATION · 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): a **DENSE** chip exposing the existing `catalog` param, and a swatch legend with live per-class counts. Default is the sparse core catalog. The chip is stateless — it 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.
|
||||
- **The DENSE chip 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 `DENSE ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `DENSE ✕` 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.
|
||||
- **Satellites**: core catalog from CelesTrak's stations, visual, GPS, GLONASS, Galileo, GEO, resource, weather, planet, SAR and science groups, tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode.
|
||||
- **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: 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.
|
||||
- **Catalog numbers above 99999** can be written in a TLE as Alpha-5: a letter A–Z, skipping I and O, for the first two digits, so `A0000` is 100000 and `Z9999` is 339999. CelesTrak's TLE feed carries no satellite above 99999 (that needs the OMM feed, #1191), so this is compatibility with Alpha-5 TLEs. `src/sources/tle.js` decodes the field through one shared helper: `noradIdFromSatnum()` for the satellites layer, which keys and deduplicates its catalog by that number, and `tleCatalogNumber()` for the satellite tools. Plain digits read as before (`noradIdFromSatnum()` accepts any length, as an OMM satrec carries the number unpadded); a malformed field — a sign, decimal point, hex, exponent, embedded space, stray or lowercase letter, or blank — is rejected rather than partly read, as `NaN` in the layer and `null` in the tools.
|
||||
@@ -6278,19 +6565,22 @@ apply to `public/` assets or built-preview serving.
|
||||
`src/contextModePolicy.js`). Contacts is not required, and the other aircraft
|
||||
layer, Live AIS Vessels and every other layer may be turned on or off before
|
||||
or during a session. A Context mode change still in flight blocks entry until
|
||||
it settles. Turning off the layer the view rides ends it. Riding an aircraft
|
||||
while Contacts is on adopts its layer as the user's own, as a direct selection
|
||||
does (`adoptLayerVisibility`), so Contacts 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.
|
||||
- **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
|
||||
it settles. Turning off the layer the view rides ends it. Riding an aircraft,
|
||||
or a vessel from its bridge, while Contacts is on adopts its layer as the
|
||||
user's own, as a direct selection does (`adoptLayerVisibility`), so Contacts
|
||||
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, 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.
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
||||
names a glyph this list is missing, because a missing glyph does not draw
|
||||
a box — the ligature never forms and the element renders the literal word. -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,directions_boat,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
||||
|
||||
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
'''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 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"
|
||||
'''
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import openpyxl
|
||||
|
||||
LOG = logging.getLogger('build-ucs-satellites')
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
PACK = ROOT / 'src/data/local_data/ucs_satellites'
|
||||
VERSION = 'UCS-Satellite-Database 5-1-2023'
|
||||
DATA_CURRENT_TO = '2023-05-01'
|
||||
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)
|
||||
|
||||
|
||||
def clean(value: object) -> str:
|
||||
'''One cell as tidy text; UCS's placeholders for nothing become ''.'''
|
||||
text = re.sub(r'\s+', ' ', str(value or '')).strip()
|
||||
return '' if text.lower() in ('', 'none', 'n/a') else text
|
||||
|
||||
|
||||
def build(xlsx: Path) -> dict:
|
||||
'''Read the release's first sheet into the pack the layer loads.'''
|
||||
sheet = openpyxl.load_workbook(xlsx, read_only=True, data_only=True).worksheets[0]
|
||||
rows = sheet.iter_rows(values_only=True)
|
||||
header = {str(h).strip(): i for i, h in enumerate(next(rows)) if h}
|
||||
names = [header['Current Official Name of Satellite'], header['Name of Satellite, Alternate Names']]
|
||||
norad_col = header['NORAD Number']
|
||||
tables = {key: [''] for key in COLUMNS}
|
||||
index = {key: {'': 0} for key in COLUMNS}
|
||||
records = {}
|
||||
source_rows = skipped_starlink = invalid_norad = 0
|
||||
for row in rows:
|
||||
if not any(row):
|
||||
continue
|
||||
source_rows += 1
|
||||
if any(STARLINK.search(clean(row[i])) for i in names):
|
||||
skipped_starlink += 1
|
||||
continue
|
||||
try:
|
||||
norad = int(float(row[norad_col]))
|
||||
except (TypeError, ValueError):
|
||||
invalid_norad += 1
|
||||
continue
|
||||
if norad <= 0:
|
||||
invalid_norad += 1
|
||||
continue
|
||||
record = [norad]
|
||||
for key, column in COLUMNS.items():
|
||||
text = clean(row[header[column]])
|
||||
if text not in index[key]:
|
||||
index[key][text] = len(tables[key])
|
||||
tables[key].append(text)
|
||||
record.append(index[key][text])
|
||||
# A duplicate NORAD number keeps the first row, as UCS lists it.
|
||||
records.setdefault(norad, record)
|
||||
ordered = [records[n] for n in sorted(records)]
|
||||
return {
|
||||
'meta': {
|
||||
'source': 'Union of Concerned Scientists Satellite Database',
|
||||
'version': VERSION,
|
||||
'url': URL,
|
||||
'dataCurrentTo': DATA_CURRENT_TO,
|
||||
'license': 'Free, unrestricted use; acknowledgement requested (UCS database manual, section 3)',
|
||||
'curation': {
|
||||
'sourceRows': source_rows,
|
||||
'starlinkRowsDropped': skipped_starlink,
|
||||
'invalidNoradDropped': invalid_norad,
|
||||
'duplicateNoradDropped': source_rows - skipped_starlink - invalid_norad - len(ordered),
|
||||
'columns': COLUMNS,
|
||||
},
|
||||
'recordCount': len(ordered),
|
||||
},
|
||||
'fields': ['noradId', *COLUMNS],
|
||||
'strings': tables,
|
||||
'records': ordered,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
'''Build the pack from the release given on the command line.'''
|
||||
logging.basicConfig(level=logging.INFO, format='%(message)s')
|
||||
parser = argparse.ArgumentParser(description='Build the UCS satellite pack from the UCS Excel release.')
|
||||
parser.add_argument('xlsx', type=Path, help='UCS-Satellite-Database <version>.xlsx')
|
||||
pack = build(parser.parse_args().xlsx)
|
||||
PACK.mkdir(parents=True, exist_ok=True)
|
||||
out = PACK / 'ucs_satellites.json'
|
||||
# LF on every platform: the repository stores text with LF endings.
|
||||
out.write_text(
|
||||
json.dumps(pack, ensure_ascii=False, separators=(',', ':')) + '\n',
|
||||
encoding='utf-8',
|
||||
newline='\n',
|
||||
)
|
||||
LOG.info('%s: %d records, %d bytes', out.relative_to(ROOT), pack['meta']['recordCount'], out.stat().st_size)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -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)}`,
|
||||
);
|
||||
|
||||
@@ -328,6 +328,12 @@
|
||||
"src/ui/styles/responsive.css",
|
||||
"src/ui/styles/voice-cost.css",
|
||||
"src/ui/styles/cockpit.css",
|
||||
"src/ui/styles/cockpitBridge.css",
|
||||
"src/ui/styles/cockpitRadar.css",
|
||||
"src/ui/styles/cockpitOrbital.css",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/layers/satellites/sensors.test.mjs",
|
||||
"src/data/imagingSensors.js",
|
||||
"src/ui/styles/command-dock-compact.css",
|
||||
"src/app/stateChannel.js",
|
||||
"src/app/stateChannel.test.mjs",
|
||||
|
||||
@@ -39,6 +39,9 @@
|
||||
"src/ui/views/panels/CockpitBriefing.vue",
|
||||
"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",
|
||||
@@ -161,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"],
|
||||
@@ -899,6 +902,19 @@
|
||||
"src/ui/cockpitInstrumentState.js",
|
||||
"src/ui/cockpitRollingValue.js",
|
||||
"src/ui/cockpitCamera.js",
|
||||
"src/ui/cockpitOrbitalView.js",
|
||||
"src/ui/cockpitOrbitalFocus.js",
|
||||
"src/ui/cockpitOrbitalSignals.js",
|
||||
"src/ui/cockpitOrbitalSatellites.js",
|
||||
"src/cockpitOrbitalSatellites.js",
|
||||
"src/layers/installations/classLabel.js",
|
||||
"src/data/installationNames.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/data/placeBelow.js",
|
||||
"src/layers/satellites/sensors.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",
|
||||
@@ -913,6 +929,13 @@
|
||||
"src/cockpitMath.js",
|
||||
"src/cockpitUtilityLayout.js",
|
||||
"src/cockpitVisionPolicy.js",
|
||||
"src/cockpitSubjects.js",
|
||||
"src/cockpitRadar.js",
|
||||
"src/cockpitLook.js",
|
||||
"src/cockpitOrbital.js",
|
||||
"src/cockpitOrbitalFocus.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
@@ -981,6 +1004,13 @@
|
||||
"src/cockpitTracking.js",
|
||||
"src/cockpitUtilityLayout.js",
|
||||
"src/cockpitVisionPolicy.js",
|
||||
"src/cockpitSubjects.js",
|
||||
"src/cockpitRadar.js",
|
||||
"src/cockpitLook.js",
|
||||
"src/cockpitOrbital.js",
|
||||
"src/cockpitOrbitalFocus.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/contactsDetectionPolicy.js",
|
||||
"src/contextModePolicy.js",
|
||||
"src/data/detectionPolicy.js",
|
||||
@@ -988,6 +1018,7 @@
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/militaryAwarenessEngine.js",
|
||||
@@ -1030,6 +1061,17 @@
|
||||
"src/ui/cockpitInstrumentState.js",
|
||||
"src/ui/cockpitRollingValue.js",
|
||||
"src/ui/cockpitCamera.js",
|
||||
"src/ui/cockpitOrbitalView.js",
|
||||
"src/ui/cockpitOrbitalFocus.js",
|
||||
"src/ui/cockpitOrbitalSignals.js",
|
||||
"src/ui/cockpitOrbitalSatellites.js",
|
||||
"src/cockpitOrbitalSatellites.js",
|
||||
"src/layers/installations/classLabel.js",
|
||||
"src/data/placeBelow.js",
|
||||
"src/layers/satellites/sensors.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",
|
||||
@@ -1210,6 +1252,8 @@
|
||||
"src/ui/styles/location.css",
|
||||
"src/ui/styles/status.css",
|
||||
"src/ui/styles/cockpit.css",
|
||||
"src/ui/styles/cockpitBridge.css",
|
||||
"src/ui/styles/cockpitRadar.css",
|
||||
"src/ui/styles/overlays.css",
|
||||
"src/ui/styles/layers.css",
|
||||
"src/ui/styles/radio.css",
|
||||
@@ -1368,6 +1412,7 @@
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/aircraftModelLighting.js",
|
||||
@@ -1433,6 +1478,7 @@
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
"src/data/aircraftModelLighting.js",
|
||||
@@ -1489,6 +1535,7 @@
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/modelVisualAnchor.js",
|
||||
@@ -1529,6 +1576,8 @@
|
||||
"src/layers/vessels/records.js",
|
||||
"src/layers/vessels/recordPolicy.js",
|
||||
"src/layers/vessels/snapshotRenderer.js",
|
||||
"src/layers/vessels/cockpitSubject.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
@@ -1628,9 +1677,11 @@
|
||||
"exports": ["./layers/satellites"],
|
||||
"modules": [
|
||||
"src/data/satelliteClass.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"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",
|
||||
@@ -1660,6 +1711,12 @@
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/ui/unitLabels.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js",
|
||||
"src/time/worldClock.js"
|
||||
],
|
||||
"external": ["cesium", "satellite.js"]
|
||||
@@ -2075,6 +2132,7 @@
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/radioCountry.js",
|
||||
"src/layers/radio/catalogModel.js",
|
||||
@@ -2400,6 +2458,11 @@
|
||||
"src/cockpitTracking.js",
|
||||
"src/cockpitUtilityLayout.js",
|
||||
"src/cockpitVisionPolicy.js",
|
||||
"src/cockpitSubjects.js",
|
||||
"src/cockpitRadar.js",
|
||||
"src/cockpitLook.js",
|
||||
"src/cockpitOrbital.js",
|
||||
"src/cockpitOrbitalFocus.js",
|
||||
"src/contactsDetectionPolicy.js",
|
||||
"src/contextModePolicy.js",
|
||||
"src/creditKeyboard.js",
|
||||
@@ -2464,6 +2527,7 @@
|
||||
"src/data/labelArbiter.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/lifecycle.js",
|
||||
@@ -2693,6 +2757,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",
|
||||
@@ -2842,6 +2907,12 @@
|
||||
"src/ui/cockpitInstrumentState.js",
|
||||
"src/ui/cockpitRollingValue.js",
|
||||
"src/ui/cockpitCamera.js",
|
||||
"src/ui/cockpitOrbitalView.js",
|
||||
"src/ui/cockpitOrbitalFocus.js",
|
||||
"src/ui/cockpitOrbitalSignals.js",
|
||||
"src/ui/cockpitOrbitalSatellites.js",
|
||||
"src/cockpitOrbitalSatellites.js",
|
||||
"src/data/placeBelow.js",
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
@@ -2961,6 +3032,7 @@
|
||||
"src/layers/vessels/records.js",
|
||||
"src/layers/vessels/recordPolicy.js",
|
||||
"src/layers/vessels/snapshotRenderer.js",
|
||||
"src/layers/vessels/cockpitSubject.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/annotations/drawMarks.js",
|
||||
"src/annotations/drawMode.js",
|
||||
@@ -3349,7 +3421,11 @@
|
||||
"src/ui/wildlifeSightingPlacement.js",
|
||||
"src/overlays/worldOverlayChrome.js",
|
||||
"src/layers/wildlifeSightings/photoText.js",
|
||||
"src/layers/wildlifeSightings/performance.js"
|
||||
"src/layers/wildlifeSightings/performance.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -3461,6 +3537,7 @@
|
||||
"src/data/labelArbiter.js",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/localGeojsonCore.js",
|
||||
"src/data/localGeojsonLod.js",
|
||||
@@ -3662,6 +3739,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",
|
||||
@@ -3749,6 +3827,7 @@
|
||||
"src/layers/vessels/records.js",
|
||||
"src/layers/vessels/recordPolicy.js",
|
||||
"src/layers/vessels/snapshotRenderer.js",
|
||||
"src/layers/vessels/cockpitSubject.js",
|
||||
"src/data/inputOwnership.js",
|
||||
"src/annotations/worldAnnotationRenderer.js",
|
||||
"src/cameraGroundGuard.js",
|
||||
@@ -4001,7 +4080,11 @@
|
||||
"src/layers/wildlifeSightings/sprites.js",
|
||||
"src/overlays/worldOverlayChrome.js",
|
||||
"src/layers/wildlifeSightings/photoText.js",
|
||||
"src/layers/wildlifeSightings/performance.js"
|
||||
"src/layers/wildlifeSightings/performance.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -4135,6 +4218,7 @@
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateCoordinator.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/loadingFeedback.js",
|
||||
@@ -4168,6 +4252,14 @@
|
||||
"src/ui/radioPanelPlacement.js",
|
||||
"src/bloom.js",
|
||||
"src/cockpitVisionPolicy.js",
|
||||
"src/cockpitSubjects.js",
|
||||
"src/cockpitRadar.js",
|
||||
"src/cockpitLook.js",
|
||||
"src/cockpitOrbital.js",
|
||||
"src/cockpitOrbitalFocus.js",
|
||||
"src/cockpitOrbitalSatellites.js",
|
||||
"src/geoDistance.js",
|
||||
"src/data/motionModel.js",
|
||||
"src/contactsDetectionPolicy.js",
|
||||
"src/data/detectionPolicy.js",
|
||||
"src/styles/anime.js",
|
||||
@@ -4499,7 +4591,15 @@
|
||||
},
|
||||
"satellites-source": {
|
||||
"exports": ["./layers/satellites/source"],
|
||||
"modules": ["src/layers/satellites/source.js"],
|
||||
"modules": [
|
||||
"src/layers/satellites/source.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js",
|
||||
"src/data/bundledJson.js",
|
||||
"src/data/retryableLoad.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"traffic-source-factory": {
|
||||
@@ -4870,6 +4970,7 @@
|
||||
"src/view/index.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/detectionPolicy.js",
|
||||
@@ -4996,6 +5097,7 @@
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/vesselTypes.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/sources/gbfsStations.js",
|
||||
"src/sources/tle.js",
|
||||
@@ -5119,7 +5221,11 @@
|
||||
"src/layers/alpr/records.js",
|
||||
"src/layers/alpr/tileRecords.js",
|
||||
"src/sources/infrastructureData.js",
|
||||
"src/sources/eventPacks.js"
|
||||
"src/sources/eventPacks.js",
|
||||
"src/layers/satellites/classes.js",
|
||||
"src/layers/satellites/sensors.js",
|
||||
"src/data/ucsSatellites.js",
|
||||
"src/data/imagingSensors.js"
|
||||
],
|
||||
"external": ["@mapbox/vector-tile", "pbf"]
|
||||
},
|
||||
@@ -5130,6 +5236,7 @@
|
||||
"src/data/idSwitches.js",
|
||||
"src/data/firmsIntensity.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/satelliteClassMask.js",
|
||||
"src/data/layerStateTokenReservations.json",
|
||||
"src/visualStylePolicy.js"
|
||||
],
|
||||
|
||||
@@ -409,6 +409,15 @@ async function main() {
|
||||
status: 'ready',
|
||||
}));
|
||||
}
|
||||
// CelesTrak SATCAT, which the satellite classes read beside the TLEs.
|
||||
// An empty CSV (header only) keeps the run hermetic: the classes fall
|
||||
// back to names and groups, as they do when SATCAT is unavailable.
|
||||
if (isAppRequest && url.pathname.startsWith('/api/celestrak-satcat/')) {
|
||||
return Promise.resolve(new Response(
|
||||
'OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE,LAUNCH_DATE\n',
|
||||
{ status: 200, headers: { 'Content-Type': 'text/csv' } },
|
||||
));
|
||||
}
|
||||
// CelesTrak TLE groups. Served from a fixed two-satellite catalog so
|
||||
// the satellite assertions are deterministic and the run never depends
|
||||
// on an upstream that rate-limits (it was answering 403 the night this
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import path from 'node:path';
|
||||
import { promises as fsp } from 'node:fs';
|
||||
import { celestrakTleUrl } from '../../../src/data/spaceProviderRequests.js';
|
||||
import {
|
||||
celestrakSatcatUrl,
|
||||
celestrakTleUrl,
|
||||
} from '../../../src/data/spaceProviderRequests.js';
|
||||
import { providerCooldownMs } from './cooldown.js';
|
||||
import { admitSameSiteKeyless } from '../common/same-site.js';
|
||||
import { readResponseTextCapped } from '../common/http.js';
|
||||
@@ -22,84 +25,131 @@ import { readResponseTextCapped } from '../common/http.js';
|
||||
* 6 h; on upstream failure the freshest stale copy is served (a stale TLE
|
||||
* beats an empty satellites layer). Pattern mirrors openSkyProxy's
|
||||
* cache+serve-stale. Adapted from skylight's TleStore (MIT).
|
||||
*
|
||||
* The same plugin serves CelesTrak SATCAT records for the satellites layer's
|
||||
* groups at `/api/celestrak-satcat/<group>` (object type, operational status
|
||||
* and owner, which file each object under a class). The catalog changes
|
||||
* slowly, so SATCAT is cached for a day. CelesTrak throttles the client, not
|
||||
* the endpoint, so a 403 or 429 holds both routes off; any other failure
|
||||
* holds off only the route that failed, so a bad SATCAT answer never stops
|
||||
* TLE refreshes.
|
||||
*/
|
||||
export function celestrakProxy() {
|
||||
const TLE_TTL_MS = 6 * 3600_000;
|
||||
const SATCAT_TTL_MS = 24 * 3600_000;
|
||||
const CACHE_DIR = path.join(process.cwd(), '.gev-cache');
|
||||
const groups = new Set([
|
||||
/** The satellites layer's core groups (src/layers/satellites/policy.js). */
|
||||
const coreGroups = [
|
||||
'stations',
|
||||
'visual',
|
||||
'gps-ops',
|
||||
'glo-ops',
|
||||
'galileo',
|
||||
'geo',
|
||||
'starlink',
|
||||
// Space Missions (`getActiveTle`) reads the full active catalog.
|
||||
'active',
|
||||
]);
|
||||
let cooldownUntil = 0;
|
||||
const mem = new Map(); // group -> { at: epochMs, body: string }
|
||||
const inflight = new Map(); // group -> Promise<{at, body}|null>
|
||||
|
||||
const diskPath = (group) => path.join(CACHE_DIR, `celestrak-${group}.json`);
|
||||
|
||||
async function readDisk(group) {
|
||||
try {
|
||||
const parsed = JSON.parse(await fsp.readFile(diskPath(group), 'utf8'));
|
||||
if (typeof parsed?.body === 'string' && Number.isFinite(parsed?.at))
|
||||
return parsed;
|
||||
} catch {
|
||||
/* no disk cache yet */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function writeDisk(group, entry) {
|
||||
try {
|
||||
await fsp.mkdir(CACHE_DIR, { recursive: true });
|
||||
await fsp.writeFile(diskPath(group), JSON.stringify(entry), 'utf8');
|
||||
} catch (err) {
|
||||
console.warn('[celestrak-proxy] cache write failed');
|
||||
}
|
||||
}
|
||||
'resource',
|
||||
'weather',
|
||||
'planet',
|
||||
'sar',
|
||||
'science',
|
||||
];
|
||||
const feeds = [
|
||||
{
|
||||
route: '/api/celestrak',
|
||||
ttlMs: TLE_TTL_MS,
|
||||
cacheHeader: 'x-tle-cache',
|
||||
cachedAtHeader: 'X-TLE-Cached-At',
|
||||
diskName: (group) => `celestrak-${group}.json`,
|
||||
upstreamUrl: celestrakTleUrl,
|
||||
groups: new Set([
|
||||
...coreGroups,
|
||||
'starlink',
|
||||
// Space Missions (`getActiveTle`) reads the full active catalog.
|
||||
'active',
|
||||
]),
|
||||
// An upstream error page parses to zero TLEs — treat as failure, keep cache.
|
||||
validate: (body) => /^1 /m.test(body),
|
||||
invalid: 'no TLE lines in response',
|
||||
},
|
||||
{
|
||||
route: '/api/celestrak-satcat',
|
||||
ttlMs: SATCAT_TTL_MS,
|
||||
cacheHeader: 'x-satcat-cache',
|
||||
cachedAtHeader: 'X-SATCAT-Cached-At',
|
||||
diskName: (group) => `celestrak-satcat-${group}.json`,
|
||||
upstreamUrl: celestrakSatcatUrl,
|
||||
groups: new Set(coreGroups),
|
||||
// A real answer, even for an empty group, starts with the CSV header.
|
||||
validate: (body) => /^OBJECT_NAME,/.test(body),
|
||||
invalid: 'no SATCAT header in response',
|
||||
},
|
||||
];
|
||||
// CelesTrak throttling (403/429) is per client, so it holds every feed
|
||||
// off; any other failure holds off only the feed that failed, so a bad
|
||||
// SATCAT answer never blocks the TLEs the layer cannot do without.
|
||||
let hostCooldownUntil = 0;
|
||||
|
||||
/** The largest group (`active`) measures ~2.7 MB; this bounds a runaway body. */
|
||||
const CELESTRAK_MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
async function fetchUpstream(group) {
|
||||
const url = celestrakTleUrl(group);
|
||||
const signal = AbortSignal.timeout(20000);
|
||||
const res = await fetch(url.toString(), {
|
||||
signal,
|
||||
// CelesTrak 403s bulk groups (e.g. `active`) unless the request carries a
|
||||
// descriptive User-Agent with a contact point.
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Group refreshes run in parallel: a later failure without Retry-After
|
||||
// must never shorten a longer deadline an earlier one already set.
|
||||
cooldownUntil = Math.max(
|
||||
cooldownUntil,
|
||||
Date.now() + providerCooldownMs(res),
|
||||
);
|
||||
void res.body?.cancel().catch(() => {});
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const body = await readResponseTextCapped(
|
||||
res,
|
||||
CELESTRAK_MAX_RESPONSE_BYTES,
|
||||
signal,
|
||||
);
|
||||
// An upstream error page parses to zero TLEs — treat as failure, keep cache.
|
||||
if (!/^1 /m.test(body)) throw new Error('no TLE lines in response');
|
||||
return { at: Date.now(), body };
|
||||
}
|
||||
function createFeedHandler(feed) {
|
||||
let feedCooldownUntil = 0;
|
||||
const cooldownUntil = () => Math.max(hostCooldownUntil, feedCooldownUntil);
|
||||
const mem = new Map(); // group -> { at: epochMs, body: string }
|
||||
const inflight = new Map(); // group -> Promise<{at, body}|null>
|
||||
const diskPath = (group) => path.join(CACHE_DIR, feed.diskName(group));
|
||||
|
||||
const installMiddleware = (server) => {
|
||||
server.middlewares.use('/api/celestrak', async (req, res) => {
|
||||
async function readDisk(group) {
|
||||
try {
|
||||
const parsed = JSON.parse(await fsp.readFile(diskPath(group), 'utf8'));
|
||||
if (typeof parsed?.body === 'string' && Number.isFinite(parsed?.at))
|
||||
return parsed;
|
||||
} catch {
|
||||
/* no disk cache yet */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function writeDisk(group, entry) {
|
||||
try {
|
||||
await fsp.mkdir(CACHE_DIR, { recursive: true });
|
||||
await fsp.writeFile(diskPath(group), JSON.stringify(entry), 'utf8');
|
||||
} catch (err) {
|
||||
console.warn('[celestrak-proxy] cache write failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUpstream(group) {
|
||||
const url = feed.upstreamUrl(group);
|
||||
const signal = AbortSignal.timeout(20000);
|
||||
const res = await fetch(url.toString(), {
|
||||
signal,
|
||||
// CelesTrak 403s bulk groups (e.g. `active`) unless the request carries a
|
||||
// descriptive User-Agent with a contact point.
|
||||
headers: {
|
||||
'User-Agent':
|
||||
'gods-eye-view-celestrak-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Group refreshes run in parallel: a later failure without Retry-After
|
||||
// must never shorten a longer deadline an earlier one already set.
|
||||
const until = Date.now() + providerCooldownMs(res);
|
||||
if (res.status === 403 || res.status === 429)
|
||||
hostCooldownUntil = Math.max(hostCooldownUntil, until);
|
||||
else feedCooldownUntil = Math.max(feedCooldownUntil, until);
|
||||
void res.body?.cancel().catch(() => {});
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const body = await readResponseTextCapped(
|
||||
res,
|
||||
CELESTRAK_MAX_RESPONSE_BYTES,
|
||||
signal,
|
||||
);
|
||||
if (!feed.validate(body)) throw new Error(feed.invalid);
|
||||
return { at: Date.now(), body };
|
||||
}
|
||||
|
||||
return async (req, res) => {
|
||||
const group = String(req.url || '')
|
||||
.replace(/^\//, '')
|
||||
.split('?')[0];
|
||||
@@ -111,7 +161,7 @@ export function celestrakProxy() {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
if (!groups.has(group)) {
|
||||
if (!feed.groups.has(group)) {
|
||||
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
||||
res.end('invalid group');
|
||||
return;
|
||||
@@ -123,15 +173,15 @@ export function celestrakProxy() {
|
||||
if (res.headersSent) return;
|
||||
res.writeHead(status, {
|
||||
'Content-Type': 'text/plain',
|
||||
'x-tle-cache': cacheStatus,
|
||||
[feed.cacheHeader]: cacheStatus,
|
||||
'Cache-Control': 'no-store',
|
||||
...(entry
|
||||
? { 'X-TLE-Cached-At': new Date(entry.at).toISOString() }
|
||||
? { [feed.cachedAtHeader]: new Date(entry.at).toISOString() }
|
||||
: {}),
|
||||
...(cooldownUntil > 0 && Date.now() < cooldownUntil
|
||||
...(cooldownUntil() > 0 && Date.now() < cooldownUntil()
|
||||
? {
|
||||
'Retry-After': String(
|
||||
Math.ceil((cooldownUntil - Date.now()) / 1000),
|
||||
Math.ceil((cooldownUntil() - Date.now()) / 1000),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
@@ -145,11 +195,11 @@ export function celestrakProxy() {
|
||||
entry = await readDisk(group);
|
||||
if (entry) mem.set(group, entry);
|
||||
}
|
||||
if (entry && now - entry.at < TLE_TTL_MS) {
|
||||
if (entry && now - entry.at < feed.ttlMs) {
|
||||
send(200, entry.body, 'HIT', entry);
|
||||
return;
|
||||
}
|
||||
if (Date.now() < cooldownUntil) {
|
||||
if (Date.now() < cooldownUntil()) {
|
||||
send(
|
||||
entry ? 200 : 502,
|
||||
entry?.body || 'celestrak upstream cooling down',
|
||||
@@ -169,8 +219,8 @@ export function celestrakProxy() {
|
||||
return fresh;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (Date.now() >= cooldownUntil)
|
||||
cooldownUntil = Date.now() + 60000;
|
||||
if (Date.now() >= feedCooldownUntil)
|
||||
feedCooldownUntil = Date.now() + 60000;
|
||||
console.warn(
|
||||
'[celestrak-proxy] refresh failed — serving cache if any',
|
||||
);
|
||||
@@ -191,7 +241,13 @@ export function celestrakProxy() {
|
||||
console.error('[celestrak-proxy] request failed');
|
||||
send(500, 'celestrak proxy error', 'ERROR');
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const handlers = feeds.map((feed) => [feed.route, createFeedHandler(feed)]);
|
||||
const installMiddleware = (server) => {
|
||||
for (const [route, handler] of handlers)
|
||||
server.middlewares.use(route, handler);
|
||||
};
|
||||
return {
|
||||
name: 'celestrak-proxy',
|
||||
|
||||
@@ -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 || !entity?.position) return false;',
|
||||
'if (!info || (!vessel && !satellite && !entity?.position)) return false;',
|
||||
'this.onCameraTakeover?.();',
|
||||
'this.viewer.camera.cancelFlight();',
|
||||
'this.viewer.trackedEntity = undefined;',
|
||||
@@ -106,7 +106,8 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
|
||||
);
|
||||
ordered(transaction, [
|
||||
/if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/,
|
||||
'if (!entryError) entered = Boolean(cockpitView.enter());',
|
||||
// Aircraft-only: a selected vessel must not stand in for a voice target.
|
||||
"if (!entryError) entered = Boolean(cockpitView.enter({ kind: 'aircraft' }));",
|
||||
], 'Cockpit entry transaction');
|
||||
// No direct camera control: every mutation goes through a layer tracker or
|
||||
// the cockpit controller, both of which stamp.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Bridge look-around policy: how the arrow keys turn the bridge camera away
|
||||
* from the bow and back, and how a turned view is described. Yaw is relative
|
||||
* to the ship's heading; pitch is the view's absolute pitch. Pure: no DOM, no
|
||||
* Cesium and no translated text; the Cockpit words the readout.
|
||||
*/
|
||||
|
||||
/** One arrow press turns the view this far. */
|
||||
export const LOOK_STEP_DEG = 5;
|
||||
|
||||
/** One Shift+arrow press turns the view this far: two reach either beam. */
|
||||
export const LOOK_COARSE_STEP_DEG = 45;
|
||||
|
||||
/** The view pitches no further than straight up or straight down. */
|
||||
export const LOOK_PITCH_LIMIT_DEG = 90;
|
||||
|
||||
/**
|
||||
* The eased view's time constant. Three of them (about 180 ms) close 95% of
|
||||
* a step, so a press glides rather than snaps and a held key turns smoothly.
|
||||
*/
|
||||
export const LOOK_EASE_TAU_MS = 60;
|
||||
|
||||
/** Closer than this the eased view lands on its target. */
|
||||
const LOOK_SETTLE_DEG = 0.05;
|
||||
|
||||
/** Arrow key → the axis it turns and the direction it turns it. */
|
||||
const LOOK_ARROWS = Object.freeze({
|
||||
ArrowLeft: Object.freeze({ axis: 'yaw', sign: -1 }),
|
||||
ArrowRight: Object.freeze({ axis: 'yaw', sign: 1 }),
|
||||
ArrowUp: Object.freeze({ axis: 'pitch', sign: 1 }),
|
||||
ArrowDown: Object.freeze({ axis: 'pitch', sign: -1 }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Wrap a yaw to (-180, 180]: negative to port, positive to starboard.
|
||||
* @param {number} deg Yaw in degrees; a non-finite yaw reads as ahead.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function wrapLookYaw(deg) {
|
||||
if (!Number.isFinite(deg)) return 0;
|
||||
const wrapped = (((deg % 360) + 360) % 360) - 360;
|
||||
const yaw = wrapped <= -180 ? wrapped + 360 : wrapped;
|
||||
return yaw === 0 ? 0 : yaw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp a view pitch to [-90, 90].
|
||||
* @param {number} deg Pitch in degrees; a non-finite pitch reads as level.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function clampLookPitch(deg) {
|
||||
if (!Number.isFinite(deg)) return 0;
|
||||
const pitch = Math.min(
|
||||
LOOK_PITCH_LIMIT_DEG,
|
||||
Math.max(-LOOK_PITCH_LIMIT_DEG, deg),
|
||||
);
|
||||
return pitch === 0 ? 0 : pitch;
|
||||
}
|
||||
|
||||
/**
|
||||
* The step an arrow key asks for, or null for any other key.
|
||||
* @param {string} key `KeyboardEvent.key`.
|
||||
* @param {boolean} [coarse] Shift is held: step 45° instead of 5°.
|
||||
* @returns {{axis: 'yaw'|'pitch', sign: 1|-1, stepDeg: number}|null}
|
||||
*/
|
||||
export function lookKeyStep(key, coarse = false) {
|
||||
const arrow = LOOK_ARROWS[key];
|
||||
if (!arrow) return null;
|
||||
return {
|
||||
...arrow,
|
||||
stepDeg: coarse ? LOOK_COARSE_STEP_DEG : LOOK_STEP_DEG,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one value to the next multiple of `stepDeg` in the direction `sign`.
|
||||
* A value off the grid (the bridge's -2° rest pitch) lands on it first, so
|
||||
* the readout shows round angles.
|
||||
*/
|
||||
function nextOnGrid(value, sign, stepDeg) {
|
||||
const cells = value / stepDeg;
|
||||
const next =
|
||||
sign > 0 ? Math.floor(cells + 1e-9) + 1 : Math.ceil(cells - 1e-9) - 1;
|
||||
return next * stepDeg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one key step to a look target.
|
||||
* @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}} The new target: yaw wrapped
|
||||
* to (-180, 180], pitch clamped to [-90, 90].
|
||||
*/
|
||||
export function stepLook(target, step) {
|
||||
const yawDeg = wrapLookYaw(target?.yawDeg ?? 0);
|
||||
const pitchDeg = clampLookPitch(target?.pitchDeg ?? 0);
|
||||
if (!step) return { yawDeg, pitchDeg };
|
||||
if (step.axis === 'yaw')
|
||||
return {
|
||||
yawDeg: wrapLookYaw(nextOnGrid(yawDeg, step.sign, step.stepDeg)),
|
||||
pitchDeg,
|
||||
};
|
||||
return {
|
||||
yawDeg,
|
||||
pitchDeg: clampLookPitch(nextOnGrid(pitchDeg, step.sign, step.stepDeg)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the eased view toward its target. Yaw takes the shorter way
|
||||
* round; with `immediate` (reduced motion) the view jumps to the target.
|
||||
* @param {{yawDeg: number, pitchDeg: number}} current Eased view now.
|
||||
* @param {{yawDeg: number, pitchDeg: number}} target Look target.
|
||||
* @param {number} dtMs Milliseconds since the previous step.
|
||||
* @param {{immediate?: boolean}} [options]
|
||||
* @returns {{yawDeg: number, pitchDeg: number}}
|
||||
*/
|
||||
export function easeLook(current, target, dtMs, { immediate = false } = {}) {
|
||||
const goalYaw = wrapLookYaw(target?.yawDeg ?? 0);
|
||||
const goalPitch = clampLookPitch(target?.pitchDeg ?? 0);
|
||||
if (immediate || !current) return { yawDeg: goalYaw, pitchDeg: goalPitch };
|
||||
const fraction =
|
||||
Number.isFinite(dtMs) && dtMs > 0
|
||||
? 1 - Math.exp(-dtMs / LOOK_EASE_TAU_MS)
|
||||
: 0;
|
||||
const fromYaw = wrapLookYaw(current.yawDeg);
|
||||
const yawError = wrapLookYaw(goalYaw - fromYaw);
|
||||
const pitchError = goalPitch - clampLookPitch(current.pitchDeg);
|
||||
const yawDeg =
|
||||
Math.abs(yawError * (1 - fraction)) < LOOK_SETTLE_DEG
|
||||
? goalYaw
|
||||
: wrapLookYaw(fromYaw + yawError * fraction);
|
||||
const pitchDeg =
|
||||
Math.abs(pitchError * (1 - fraction)) < LOOK_SETTLE_DEG
|
||||
? goalPitch
|
||||
: clampLookPitch(current.pitchDeg + pitchError * fraction);
|
||||
return { yawDeg, pitchDeg };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a look differs from the rest view (ahead at `restPitchDeg`).
|
||||
* @param {{yawDeg: number, pitchDeg: number}} look
|
||||
* @param {number} restPitchDeg The camera profile's pitch.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function lookIsOffset(look, restPitchDeg) {
|
||||
return (
|
||||
wrapLookYaw(look?.yawDeg ?? 0) !== 0 ||
|
||||
clampLookPitch(look?.pitchDeg ?? restPitchDeg) !==
|
||||
clampLookPitch(restPitchDeg)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of a look readout, for the Cockpit to word. A part is null when
|
||||
* that axis rests: yaw ahead, or pitch at the rest pitch.
|
||||
* @param {{yawDeg: number, pitchDeg: number}} look Look target.
|
||||
* @param {number} restPitchDeg The camera profile's pitch.
|
||||
* @returns {{side: 'port'|'starboard'|'astern'|null, bearingDeg: number,
|
||||
* elevation: 'up'|'down'|'level'|null, angleDeg: number}} `bearingDeg` is
|
||||
* the angle off the bow (0–180) and `angleDeg` the angle off level (0–90),
|
||||
* both rounded to whole degrees.
|
||||
*/
|
||||
export function lookReadoutParts(look, restPitchDeg) {
|
||||
const yaw = Math.round(wrapLookYaw(look?.yawDeg ?? 0));
|
||||
const pitch = Math.round(clampLookPitch(look?.pitchDeg ?? restPitchDeg));
|
||||
const pitchOffset =
|
||||
clampLookPitch(look?.pitchDeg ?? restPitchDeg) !==
|
||||
clampLookPitch(restPitchDeg);
|
||||
let side = null;
|
||||
if (Math.abs(yaw) >= 180) side = 'astern';
|
||||
else if (yaw > 0) side = 'starboard';
|
||||
else if (yaw < 0) side = 'port';
|
||||
let elevation = null;
|
||||
if (pitchOffset) elevation = pitch > 0 ? 'up' : pitch < 0 ? 'down' : 'level';
|
||||
return {
|
||||
side,
|
||||
bearingDeg: Math.abs(yaw),
|
||||
elevation,
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// src/cockpitLook.test.mjs — the bridge look-around policy: arrow steps,
|
||||
// yaw wrapping, pitch limits, easing and the readout's parts.
|
||||
import test from 'node:test';
|
||||
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';
|
||||
|
||||
const REST = { yawDeg: 0, pitchDeg: -2 };
|
||||
|
||||
/** Press one key `count` times from `start`. */
|
||||
function press(start, key, count = 1, coarse = false) {
|
||||
let look = start;
|
||||
for (let index = 0; index < count; index += 1)
|
||||
look = stepLook(look, lookKeyStep(key, coarse));
|
||||
return look;
|
||||
}
|
||||
|
||||
test('yaw wraps to (-180, 180] and pitch clamps to [-90, 90]', () => {
|
||||
assert.equal(wrapLookYaw(0), 0);
|
||||
assert.equal(wrapLookYaw(-0), 0);
|
||||
assert.equal(wrapLookYaw(180), 180);
|
||||
assert.equal(wrapLookYaw(-180), 180);
|
||||
assert.equal(wrapLookYaw(185), -175);
|
||||
assert.equal(wrapLookYaw(-185), 175);
|
||||
assert.equal(wrapLookYaw(720 + 45), 45);
|
||||
assert.equal(wrapLookYaw(NaN), 0);
|
||||
assert.equal(clampLookPitch(120), 90);
|
||||
assert.equal(clampLookPitch(-120), -90);
|
||||
assert.equal(clampLookPitch(-0), 0);
|
||||
assert.equal(clampLookPitch(Infinity), 0);
|
||||
});
|
||||
|
||||
test('only the arrows step, 5° plain and 45° with Shift', () => {
|
||||
assert.deepEqual(lookKeyStep('ArrowLeft'), {
|
||||
axis: 'yaw',
|
||||
sign: -1,
|
||||
stepDeg: LOOK_STEP_DEG,
|
||||
});
|
||||
assert.deepEqual(lookKeyStep('ArrowUp', true), {
|
||||
axis: 'pitch',
|
||||
sign: 1,
|
||||
stepDeg: LOOK_COARSE_STEP_DEG,
|
||||
});
|
||||
assert.equal(lookKeyStep('ArrowDown').sign, -1);
|
||||
assert.equal(lookKeyStep('ArrowRight').sign, 1);
|
||||
for (const key of ['Home', '0', 'a', 'Escape', undefined])
|
||||
assert.equal(lookKeyStep(key), null);
|
||||
});
|
||||
|
||||
test('arrows turn the yaw in 5° steps and Shift reaches either beam in two', () => {
|
||||
assert.deepEqual(press(REST, 'ArrowRight'), { yawDeg: 5, pitchDeg: -2 });
|
||||
assert.deepEqual(press(REST, 'ArrowLeft', 3), { yawDeg: -15, pitchDeg: -2 });
|
||||
assert.equal(press(REST, 'ArrowRight', 2, true).yawDeg, 90);
|
||||
assert.equal(press(REST, 'ArrowLeft', 2, true).yawDeg, -90);
|
||||
// A coarse step from off the 45° grid lands on it.
|
||||
assert.equal(press({ ...REST, yawDeg: 5 }, 'ArrowRight', 1, true).yawDeg, 45);
|
||||
// Past astern the yaw wraps to the other side.
|
||||
assert.equal(press(REST, 'ArrowRight', 4, true).yawDeg, 180);
|
||||
assert.equal(press(REST, 'ArrowRight', 5, true).yawDeg, -135);
|
||||
assert.equal(press(REST, 'ArrowLeft', 37).yawDeg, 175);
|
||||
});
|
||||
|
||||
test('pitch steps onto the 5° grid from the rest pitch and stops at ±90°', () => {
|
||||
assert.equal(press(REST, 'ArrowUp').pitchDeg, 0);
|
||||
assert.equal(press(REST, 'ArrowUp', 3).pitchDeg, 10);
|
||||
assert.equal(press(REST, 'ArrowDown').pitchDeg, -5);
|
||||
assert.equal(press(REST, 'ArrowUp', 40).pitchDeg, 90);
|
||||
assert.equal(press(REST, 'ArrowDown', 40).pitchDeg, -90);
|
||||
assert.equal(press(REST, 'ArrowUp', 3, true).pitchDeg, 90);
|
||||
assert.equal(press(REST, 'ArrowDown', 2, true).pitchDeg, -90);
|
||||
assert.equal(press(REST, 'ArrowUp').yawDeg, 0, 'pitch leaves the yaw');
|
||||
});
|
||||
|
||||
test('the eased view closes most of a step in about 180 ms', () => {
|
||||
const target = { yawDeg: 90, pitchDeg: -2 };
|
||||
let view = { ...REST };
|
||||
view = easeLook(view, target, 180);
|
||||
assert.ok(view.yawDeg > 85 && view.yawDeg < 90, `yaw ${view.yawDeg}`);
|
||||
for (let step = 0; step < 20; step += 1) view = easeLook(view, target, 50);
|
||||
assert.deepEqual(view, target, 'it lands exactly on the target');
|
||||
assert.deepEqual(easeLook(REST, target, 0), REST, 'no time, no motion');
|
||||
});
|
||||
|
||||
test('the eased yaw takes the short way across astern', () => {
|
||||
const view = easeLook(
|
||||
{ yawDeg: 170, pitchDeg: 0 },
|
||||
{ yawDeg: -170, pitchDeg: 0 },
|
||||
30,
|
||||
);
|
||||
assert.ok(view.yawDeg > 170 || view.yawDeg < -170, `yaw ${view.yawDeg}`);
|
||||
});
|
||||
|
||||
test('reduced motion jumps straight to the target', () => {
|
||||
const target = { yawDeg: -45, pitchDeg: 30 };
|
||||
assert.deepEqual(easeLook(REST, target, 1, { immediate: true }), target);
|
||||
});
|
||||
|
||||
test('the readout names the side, the bearing and the elevation', () => {
|
||||
assert.equal(lookIsOffset(REST, -2), false);
|
||||
assert.equal(lookIsOffset({ yawDeg: 0, pitchDeg: 0 }, -2), true);
|
||||
assert.equal(lookIsOffset({ yawDeg: 5, pitchDeg: -2 }, -2), true);
|
||||
assert.deepEqual(lookReadoutParts({ yawDeg: 45, pitchDeg: 10 }, -2), {
|
||||
side: 'starboard',
|
||||
bearingDeg: 45,
|
||||
elevation: 'up',
|
||||
angleDeg: 10,
|
||||
});
|
||||
assert.deepEqual(lookReadoutParts({ yawDeg: -90, pitchDeg: -2 }, -2), {
|
||||
side: 'port',
|
||||
bearingDeg: 90,
|
||||
elevation: null,
|
||||
angleDeg: 2,
|
||||
});
|
||||
assert.deepEqual(lookReadoutParts({ yawDeg: 0, pitchDeg: -30 }, -2), {
|
||||
side: null,
|
||||
bearingDeg: 0,
|
||||
elevation: 'down',
|
||||
angleDeg: 30,
|
||||
});
|
||||
assert.equal(
|
||||
lookReadoutParts({ yawDeg: 0, pitchDeg: 0 }, -2).elevation,
|
||||
'level',
|
||||
);
|
||||
assert.equal(
|
||||
lookReadoutParts({ yawDeg: 180, pitchDeg: -2 }, -2).side,
|
||||
'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,
|
||||
);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -176,6 +176,46 @@ export function compassDivisions(heading) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A heading tape that scrolls continuously: the 30-degree divisions within
|
||||
* `reach` divisions of `heading`, each placed by its offset from the exact
|
||||
* heading in division slots, so the centre caret reads the heading itself
|
||||
* rather than the nearest division. `nearest` marks the division closest to
|
||||
* the caret.
|
||||
* @param {number} heading Degrees true.
|
||||
* @param {number} [reach] Divisions each side of the caret.
|
||||
* @returns {Array<{heading: number, slot: number, nearest: boolean}>}
|
||||
*/
|
||||
export function scrollingCompassDivisions(heading, reach = 4) {
|
||||
if (!Number.isFinite(heading)) return [];
|
||||
const exact = normalizeHeading(heading);
|
||||
const centre = Math.round(exact / 30) * 30;
|
||||
const divisions = [];
|
||||
for (let step = -reach; step <= reach; step += 1) {
|
||||
const division = normalizeHeading(centre + step * 30);
|
||||
divisions.push({
|
||||
heading: division,
|
||||
slot: Number((relativeBearing(division, exact) / 30).toFixed(4)),
|
||||
nearest: step === 0,
|
||||
});
|
||||
}
|
||||
return divisions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a heading falls on the `scrollingCompassDivisions(tapeHeading)` tape:
|
||||
* its offset from the caret (the tape's exact heading) in division slots of
|
||||
* 30 degrees, or null when it lies more than three divisions out.
|
||||
* @param {number} heading Degrees true.
|
||||
* @param {number} tapeHeading The heading under the tape's caret.
|
||||
* @returns {?number}
|
||||
*/
|
||||
export function compassMarkerSlot(heading, tapeHeading) {
|
||||
if (!Number.isFinite(heading) || !Number.isFinite(tapeHeading)) return null;
|
||||
const slot = Number((relativeBearing(heading, tapeHeading) / 30).toFixed(4));
|
||||
return Math.abs(slot) <= 3 ? slot : null;
|
||||
}
|
||||
|
||||
/** Format a compass division as a cardinal/intercardinal label or degrees. */
|
||||
export function formatCompassDivision(heading) {
|
||||
const normalized = normalizeHeading(heading);
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
cockpitSurfaceWaitExpired,
|
||||
cockpitUiUpdateDue,
|
||||
compassDivisions,
|
||||
compassMarkerSlot,
|
||||
scrollingCompassDivisions,
|
||||
formatAltitudeRulerTick,
|
||||
formatCockpitContextScope,
|
||||
formatCockpitEmergency,
|
||||
@@ -151,6 +153,46 @@ test('compass divisions remain ordered across north', () => {
|
||||
assert.deepEqual(compassDivisions(359), [270, 300, 330, 0, 30, 60, 90]);
|
||||
});
|
||||
|
||||
test('compass marks sit in division slots off the caret, or off the tape', () => {
|
||||
// Measured from the exact heading under the caret, not the nearest division.
|
||||
assert.equal(compassMarkerSlot(350, 350), 0);
|
||||
assert.equal(compassMarkerSlot(35, 350), 1.5);
|
||||
assert.equal(compassMarkerSlot(260, 350), -3);
|
||||
assert.equal(compassMarkerSlot(80, 350), 3);
|
||||
assert.equal(compassMarkerSlot(90, 350), null);
|
||||
assert.equal(compassMarkerSlot(180, 350), null);
|
||||
assert.equal(compassMarkerSlot(Number.NaN, 0), null);
|
||||
});
|
||||
|
||||
test('a scrolling tape puts the exact heading under the caret', () => {
|
||||
// At 100° the divisions sit a third of a slot off their snapped places,
|
||||
// so the bow mark (also 100°) lines up with the caret, not 10° beside it.
|
||||
const tape = scrollingCompassDivisions(100);
|
||||
assert.deepEqual(
|
||||
tape.map(({ heading }) => heading),
|
||||
[330, 0, 30, 60, 90, 120, 150, 180, 210],
|
||||
);
|
||||
const nearest = tape.find((division) => division.nearest);
|
||||
assert.equal(nearest.heading, 90);
|
||||
assert.equal(nearest.slot, -0.3333);
|
||||
assert.equal(tape.find(({ heading }) => heading === 120).slot, 0.6667);
|
||||
assert.equal(compassMarkerSlot(100, 100), 0);
|
||||
assert.equal(compassMarkerSlot(90, 100), nearest.slot);
|
||||
// Across north the slots stay continuous.
|
||||
assert.deepEqual(
|
||||
scrollingCompassDivisions(355, 1).map(({ heading, slot }) => [
|
||||
heading,
|
||||
slot,
|
||||
]),
|
||||
[
|
||||
[330, -0.8333],
|
||||
[0, 0.1667],
|
||||
[30, 1.1667],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(scrollingCompassDivisions(Number.NaN), []);
|
||||
});
|
||||
|
||||
test('compass labels use cardinals where exact and degrees elsewhere', () => {
|
||||
assert.equal(formatCompassDivision(270), 'W');
|
||||
assert.equal(formatCompassDivision(30), '030');
|
||||
|
||||
@@ -0,0 +1,808 @@
|
||||
/**
|
||||
* 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 whole zoom ladder. Steps past ORBITAL_BASE_MAX_ZOOM belong only to
|
||||
* imaging satellites with a known sensor, each up to the step its sensor's
|
||||
* swath allows (`orbitalMaxZoom`).
|
||||
*/
|
||||
export const ORBITAL_ZOOM_LADDER = Object.freeze([1, 2, 4, 8, 16, 32, 64, 128]);
|
||||
|
||||
/** The tightest step for a satellite without sensor data. */
|
||||
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 (the SENSOR step) moves to the step above it.
|
||||
* @param {number} zoom Current zoom.
|
||||
* @param {number} [maxZoom] The satellite's 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ground width the view spans at a zoom: what the SWATH readout shows.
|
||||
* @param {number} altitudeM Satellite altitude, metres.
|
||||
* @param {number} zoom Magnification.
|
||||
* @param {number} aspect Canvas width / height.
|
||||
* @returns {number|null} Kilometres, or null for an unusable altitude.
|
||||
*/
|
||||
export function viewWidthKm(altitudeM, zoom, aspect) {
|
||||
return footprintWidthKm(
|
||||
altitudeM,
|
||||
fovForZoom(orbitalBaseFovRad(altitudeM), zoom),
|
||||
aspect,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A satellite's tightest zoom step at its live altitude: the tightest ladder
|
||||
* step whose view is still at least as wide as its sensor's narrowest
|
||||
* standard swath, so the view never shows finer ground than the sensor
|
||||
* images. Never below ORBITAL_BASE_MAX_ZOOM; no sensor swath, no extra steps.
|
||||
* @param {number} altitudeM Live altitude, metres.
|
||||
* @param {number|null|undefined} sensorSwathKm Narrowest standard swath.
|
||||
* @param {number} aspect Canvas width / height.
|
||||
* @returns {number} One of ORBITAL_ZOOM_LADDER.
|
||||
*/
|
||||
export function orbitalMaxZoom(altitudeM, sensorSwathKm, aspect) {
|
||||
if (!Number.isFinite(sensorSwathKm) || sensorSwathKm <= 0)
|
||||
return ORBITAL_BASE_MAX_ZOOM;
|
||||
let max = ORBITAL_BASE_MAX_ZOOM;
|
||||
for (const step of ORBITAL_ZOOM_LADDER) {
|
||||
if (step <= ORBITAL_BASE_MAX_ZOOM) continue;
|
||||
const width = viewWidthKm(altitudeM, step, aspect);
|
||||
if (!Number.isFinite(width)) break;
|
||||
if (width * (1 + ZOOM_EPSILON) < sensorSwathKm) break;
|
||||
max = step;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
/**
|
||||
* The zoom at which the view is exactly as wide as the sensor's swath, for
|
||||
* the SENSOR step; null when that lies outside 1x..`maxZoom` (a swath wider
|
||||
* than the 1x view, or narrower than the tightest allowed step).
|
||||
* @param {number} altitudeM Live altitude, metres.
|
||||
* @param {number|null|undefined} sensorSwathKm
|
||||
* @param {number} aspect Canvas width / height.
|
||||
* @param {number} maxZoom The satellite's tightest step.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function sensorSwathZoom(altitudeM, sensorSwathKm, aspect, maxZoom) {
|
||||
if (!Number.isFinite(sensorSwathKm) || sensorSwathKm <= 0) return null;
|
||||
const widest = viewWidthKm(altitudeM, 1, aspect);
|
||||
const tightest = viewWidthKm(altitudeM, maxZoom, aspect);
|
||||
if (!Number.isFinite(widest) || !Number.isFinite(tightest)) return null;
|
||||
if (sensorSwathKm > widest * (1 + ZOOM_EPSILON)) return null;
|
||||
if (sensorSwathKm < tightest * (1 - ZOOM_EPSILON)) return null;
|
||||
// The width falls monotonically with zoom: bisect in log space.
|
||||
let low = 0;
|
||||
let high = Math.log2(maxZoom);
|
||||
for (let i = 0; i < 48; i += 1) {
|
||||
const mid = (low + high) / 2;
|
||||
if (viewWidthKm(altitudeM, 2 ** mid, aspect) > sensorSwathKm) low = mid;
|
||||
else high = mid;
|
||||
}
|
||||
return Math.round(2 ** ((low + high) / 2) * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the view's width a sensor swath covers, for the strip drawn
|
||||
* to scale in the reticle: 0..1, 1 when the swath fills the view or more.
|
||||
* @param {number|null|undefined} sensorSwathKm
|
||||
* @param {number|null|undefined} viewKm The view's current ground width.
|
||||
* @returns {number|null} Null without both widths.
|
||||
*/
|
||||
export function sensorStripFraction(sensorSwathKm, viewKm) {
|
||||
if (!Number.isFinite(sensorSwathKm) || sensorSwathKm <= 0) return null;
|
||||
if (!Number.isFinite(viewKm) || viewKm <= 0) return null;
|
||||
return Math.min(1, sensorSwathKm / viewKm);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) };
|
||||
}
|
||||
|
||||
/** Scroll distance, in pixels, that makes one zoom step. A mouse notch is ~100. */
|
||||
export const ORBITAL_WHEEL_PIXELS_PER_STEP = 100;
|
||||
|
||||
/**
|
||||
* After a step, the rest of that gesture is absorbed for this long, so one
|
||||
* physical notch (which some browsers deliver as several events) is one step.
|
||||
*/
|
||||
export const ORBITAL_WHEEL_COOLDOWN_MS = 200;
|
||||
|
||||
/**
|
||||
* A pause this long ends a scroll gesture: a partial sum left from an earlier
|
||||
* gesture never adds to a later one.
|
||||
*/
|
||||
export const ORBITAL_WHEEL_GESTURE_GAP_MS = 300;
|
||||
|
||||
/** `WheelEvent.deltaMode` line and page units, in pixels. */
|
||||
const WHEEL_LINE_PX = 16;
|
||||
const WHEEL_PAGE_PX = 800;
|
||||
|
||||
/**
|
||||
* Turn wheel events into zoom steps. Trackpads send many small pixel deltas,
|
||||
* so they are summed until they reach a step; a change of direction, or a
|
||||
* pause of ORBITAL_WHEEL_GESTURE_GAP_MS, starts over. Wheeling away from the
|
||||
* viewer (negative deltaY) zooms in.
|
||||
* @param {{sum: number, lastStepMs: number, lastEventMs?: number}|null}
|
||||
* accumulator Previous state.
|
||||
* @param {{deltaY: number, deltaMode?: number}} wheel The event's deltas.
|
||||
* @param {number} nowMs
|
||||
* @returns {{accumulator: {sum: number, lastStepMs: number,
|
||||
* lastEventMs: number}, step: -1|0|1}} The new state and the step to take
|
||||
* (1 = zoom in).
|
||||
*/
|
||||
export function wheelZoomStep(accumulator, wheel, nowMs) {
|
||||
const held = accumulator || { sum: 0, lastStepMs: -Infinity };
|
||||
const previous =
|
||||
nowMs - (held.lastEventMs ?? -Infinity) > ORBITAL_WHEEL_GESTURE_GAP_MS
|
||||
? { ...held, sum: 0 }
|
||||
: held;
|
||||
const unit =
|
||||
wheel?.deltaMode === 1
|
||||
? WHEEL_LINE_PX
|
||||
: wheel?.deltaMode === 2
|
||||
? WHEEL_PAGE_PX
|
||||
: 1;
|
||||
const delta = (Number.isFinite(wheel?.deltaY) ? wheel.deltaY : 0) * unit;
|
||||
if (!delta) return { accumulator: held, step: 0 };
|
||||
if (nowMs - previous.lastStepMs < ORBITAL_WHEEL_COOLDOWN_MS)
|
||||
return {
|
||||
accumulator: { ...previous, sum: 0, lastEventMs: nowMs },
|
||||
step: 0,
|
||||
};
|
||||
const sum =
|
||||
Math.sign(previous.sum) === -Math.sign(delta)
|
||||
? delta
|
||||
: previous.sum + delta;
|
||||
if (Math.abs(sum) < ORBITAL_WHEEL_PIXELS_PER_STEP)
|
||||
return { accumulator: { ...previous, sum, lastEventMs: nowMs }, step: 0 };
|
||||
return {
|
||||
accumulator: { sum: 0, lastStepMs: nowMs, lastEventMs: nowMs },
|
||||
step: sum < 0 ? 1 : -1,
|
||||
};
|
||||
}
|
||||
|
||||
/** From this zoom the view pre-loads imagery ahead along the ground track. */
|
||||
export const ORBITAL_PREFETCH_MIN_ZOOM = 16;
|
||||
|
||||
/** How often the pre-load looks ahead, ms. */
|
||||
export const ORBITAL_PREFETCH_INTERVAL_MS = 2000;
|
||||
|
||||
/** How far ahead it looks, seconds of ground-track travel, nearest first. */
|
||||
export const ORBITAL_PREFETCH_LEAD_S = Object.freeze([5, 10, 15, 20]);
|
||||
|
||||
/**
|
||||
* Where across the track it looks, as fractions of the view's width: the
|
||||
* centre line, a third out and the frame's edges each side, so a row of
|
||||
* tiles a fifth of the view wide or more spans the frame.
|
||||
*/
|
||||
export const ORBITAL_PREFETCH_ACROSS = Object.freeze([
|
||||
0,
|
||||
-1 / 3,
|
||||
1 / 3,
|
||||
-1 / 2,
|
||||
1 / 2,
|
||||
]);
|
||||
|
||||
/**
|
||||
* The points ahead, regrouped by their place across the track: every lead's
|
||||
* centre point first, then each lead's next pair out, so the tile cap trims
|
||||
* the frame's edges before it trims the far leads.
|
||||
* @param {Array<{lat: number, lon: number}>} points From `groundTrackAhead`,
|
||||
* lead by lead.
|
||||
* @param {number} [across] Points per lead.
|
||||
* @returns {Array<{lat: number, lon: number}>}
|
||||
*/
|
||||
export function centreLinesFirst(
|
||||
points,
|
||||
across = ORBITAL_PREFETCH_ACROSS.length,
|
||||
) {
|
||||
const out = [];
|
||||
for (let k = 0; k < across; k += 1)
|
||||
for (let i = k; i < points.length; i += across) out.push(points[i]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** At most this many tile requests per look-ahead. */
|
||||
export const ORBITAL_PREFETCH_MAX_TILES = 12;
|
||||
|
||||
const toRadians = (deg) => (deg * Math.PI) / 180;
|
||||
const toDegrees = (rad) => (rad * 180) / Math.PI;
|
||||
|
||||
/**
|
||||
* The point `distanceM` from a point along an initial bearing, on a sphere.
|
||||
* @returns {{lat: number, lon: number}} Degrees, longitude in -180..180.
|
||||
*/
|
||||
export function destinationPoint(latDeg, lonDeg, bearingDeg, distanceM) {
|
||||
const delta = distanceM / ORBITAL_EARTH_RADIUS_M;
|
||||
const lat1 = toRadians(latDeg);
|
||||
const theta = toRadians(bearingDeg);
|
||||
const lat2 = Math.asin(
|
||||
Math.sin(lat1) * Math.cos(delta) +
|
||||
Math.cos(lat1) * Math.sin(delta) * Math.cos(theta),
|
||||
);
|
||||
const lon2 =
|
||||
toRadians(lonDeg) +
|
||||
Math.atan2(
|
||||
Math.sin(theta) * Math.sin(delta) * Math.cos(lat1),
|
||||
Math.cos(delta) - Math.sin(lat1) * Math.sin(lat2),
|
||||
);
|
||||
const lon = ((((toDegrees(lon2) + 180) % 360) + 360) % 360) - 180;
|
||||
return { lat: toDegrees(lat2), lon };
|
||||
}
|
||||
|
||||
/**
|
||||
* Points the view will pass over: for each lead time, the sub-satellite
|
||||
* point that far along the ground track (orbital speed scaled down to the
|
||||
* ground), and points across the track at `across` fractions of the view's
|
||||
* width. Nearest lead first. Empty without a track, speed or width.
|
||||
* @param {{latitude: number, longitude: number, groundTrackDeg: number,
|
||||
* velocityMps: number, altitudeM: number}} info Satellite subject.
|
||||
* @param {number} viewKm The view's ground width, km.
|
||||
* @returns {Array<{lat: number, lon: number}>}
|
||||
*/
|
||||
export function groundTrackAhead(
|
||||
info,
|
||||
viewKm,
|
||||
{ leadS = ORBITAL_PREFETCH_LEAD_S, across = ORBITAL_PREFETCH_ACROSS } = {},
|
||||
) {
|
||||
const { latitude, longitude, groundTrackDeg, velocityMps, altitudeM } =
|
||||
info || {};
|
||||
if (
|
||||
![latitude, longitude, groundTrackDeg, velocityMps, altitudeM].every(
|
||||
Number.isFinite,
|
||||
) ||
|
||||
velocityMps <= 0 ||
|
||||
altitudeM <= 0 ||
|
||||
!(viewKm > 0)
|
||||
)
|
||||
return [];
|
||||
const R = ORBITAL_EARTH_RADIUS_M;
|
||||
const groundMps = (velocityMps * R) / (R + altitudeM);
|
||||
const points = [];
|
||||
for (const seconds of leadS) {
|
||||
const centre = destinationPoint(
|
||||
latitude,
|
||||
longitude,
|
||||
groundTrackDeg,
|
||||
groundMps * seconds,
|
||||
);
|
||||
for (const fraction of across) {
|
||||
points.push(
|
||||
fraction === 0
|
||||
? centre
|
||||
: destinationPoint(
|
||||
centre.lat,
|
||||
centre.lon,
|
||||
groundTrackDeg + (fraction > 0 ? 90 : -90),
|
||||
Math.abs(fraction) * viewKm * 1000,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/** WGS84's equatorial radius: Cesium sizes terrain and imagery tiles on it. */
|
||||
const WGS84_MAXIMUM_RADIUS_M = 6378137;
|
||||
|
||||
/**
|
||||
* Cesium's geometric error for a heightmap terrain tile at `level`, metres:
|
||||
* its level-zero estimate (TerrainProvider's
|
||||
* getEstimatedLevelZeroGeometricErrorForAHeightmap, heightmap quality 0.25)
|
||||
* for the ellipsoid terrain's 64-sample tiles on two root tiles, halved per
|
||||
* level.
|
||||
* @param {number} level
|
||||
* @returns {number}
|
||||
*/
|
||||
export function ellipsoidTerrainLevelErrorM(level) {
|
||||
return (WGS84_MAXIMUM_RADIUS_M * 2 * Math.PI * 0.25) / (64 * 2) / 2 ** level;
|
||||
}
|
||||
|
||||
/** The deepest terrain level considered. */
|
||||
const MAX_TERRAIN_LEVEL = 30;
|
||||
|
||||
/**
|
||||
* The imagery level Cesium draws under the view, and so the one to pre-load.
|
||||
* Cesium refines the terrain tile under the view while its geometric error
|
||||
* spans more than `maximumScreenSpaceError` pixels (QuadtreePrimitive's
|
||||
* screenSpaceError: error over the ground a pixel covers), then drapes the
|
||||
* imagery level whose texel spacing is nearest that error (ImageryLayer's
|
||||
* getLevelWithMaximumTexelSpacing, rounded): about one to two screen pixels
|
||||
* per texel, not one. Fog, which below 800 km can only coarsen the choice,
|
||||
* is left out. Clamped to the provider's levels; null without a usable input.
|
||||
* @param {{getNumberOfXTilesAtLevel?: (level: number) => number,
|
||||
* rectangle?: {width: number}}} tilingScheme The imagery tiling scheme.
|
||||
* @param {{metresPerPixel: number, latitudeDeg: number,
|
||||
* geographic?: boolean, tileWidthPx?: number, minimumLevel?: number,
|
||||
* maximumLevel?: number, maximumScreenSpaceError?: number,
|
||||
* terrainLevelErrorM?: (level: number) => number}} view `metresPerPixel`
|
||||
* is the ground one CSS pixel covers at the view's centre; `geographic`
|
||||
* marks a scheme in the geographic projection, whose texels do not narrow
|
||||
* with latitude as Web Mercator's do; `terrainLevelErrorM` is the terrain
|
||||
* provider's getLevelMaximumGeometricError.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function imageryLevelForView(
|
||||
tilingScheme,
|
||||
{
|
||||
metresPerPixel,
|
||||
latitudeDeg,
|
||||
geographic = false,
|
||||
tileWidthPx = 256,
|
||||
minimumLevel = 0,
|
||||
maximumLevel = 30,
|
||||
maximumScreenSpaceError = 2,
|
||||
terrainLevelErrorM = ellipsoidTerrainLevelErrorM,
|
||||
},
|
||||
) {
|
||||
if (!(metresPerPixel > 0) || !Number.isFinite(latitudeDeg)) return null;
|
||||
const sse = maximumScreenSpaceError > 0 ? maximumScreenSpaceError : 2;
|
||||
let terrainLevel = 0;
|
||||
while (
|
||||
terrainLevel < MAX_TERRAIN_LEVEL &&
|
||||
!(terrainLevelErrorM(terrainLevel) / metresPerPixel <= sse)
|
||||
)
|
||||
terrainLevel += 1;
|
||||
const errorM = terrainLevelErrorM(terrainLevel);
|
||||
if (!(errorM > 0)) return null;
|
||||
const rootTiles = tilingScheme?.getNumberOfXTilesAtLevel?.(0) || 1;
|
||||
const widthRad = tilingScheme?.rectangle?.width || 2 * Math.PI;
|
||||
const latitudeFactor = geographic
|
||||
? 1
|
||||
: Math.cos(toRadians(Math.min(89, Math.abs(latitudeDeg))));
|
||||
const levelZeroTexelM =
|
||||
(WGS84_MAXIMUM_RADIUS_M * widthRad * latitudeFactor) /
|
||||
((tileWidthPx || 256) * rootTiles);
|
||||
const level = Math.max(0, Math.round(Math.log2(levelZeroTexelM / errorM)));
|
||||
if (!Number.isFinite(level)) return null;
|
||||
const top = Number.isFinite(maximumLevel) ? maximumLevel : 30;
|
||||
const bottom = Number.isFinite(minimumLevel) ? minimumLevel : 0;
|
||||
return Math.min(top, Math.max(bottom, level));
|
||||
}
|
||||
|
||||
/**
|
||||
* The tiles at `level` holding `points`, in order, without repeats or tiles
|
||||
* `skip` reports as already asked for, at most `maxTiles`.
|
||||
* @param {{positionToTileXY: Function}} tilingScheme Cesium tiling scheme.
|
||||
* @param {Array<{lat: number, lon: number}>} points Degrees.
|
||||
* @param {number} level
|
||||
* @param {{maxTiles?: number, skip?: (key: string) => boolean}} [options]
|
||||
* @returns {Array<{x: number, y: number, level: number, key: string}>}
|
||||
*/
|
||||
export function tilesForPoints(
|
||||
tilingScheme,
|
||||
points,
|
||||
level,
|
||||
{ maxTiles = ORBITAL_PREFETCH_MAX_TILES, skip = () => false } = {},
|
||||
) {
|
||||
const tiles = [];
|
||||
if (typeof tilingScheme?.positionToTileXY !== 'function') return tiles;
|
||||
if (!Number.isInteger(level) || level < 0) return tiles;
|
||||
const seen = new Set();
|
||||
for (const point of points) {
|
||||
if (tiles.length >= maxTiles) break;
|
||||
const xy = tilingScheme.positionToTileXY(
|
||||
{ longitude: toRadians(point.lon), latitude: toRadians(point.lat) },
|
||||
level,
|
||||
);
|
||||
if (!xy) continue;
|
||||
const key = `${level}/${xy.x}/${xy.y}`;
|
||||
if (seen.has(key) || skip(key)) continue;
|
||||
seen.add(key);
|
||||
tiles.push({ x: xy.x, y: xy.y, level, key });
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
@@ -0,0 +1,618 @@
|
||||
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,
|
||||
ORBITAL_WHEEL_COOLDOWN_MS,
|
||||
cesiumFrustumFovRad,
|
||||
earthAngularRadiusRad,
|
||||
footprintWidthKm,
|
||||
fovForZoom,
|
||||
interpolateZoomFov,
|
||||
nadirOrientation,
|
||||
nextOrbitalZoom,
|
||||
offNadirAngleDeg,
|
||||
offNadirCentreMeetsEarth,
|
||||
centreLinesFirst,
|
||||
offNadirSwathScale,
|
||||
orbitalMaxZoom,
|
||||
sensorStripFraction,
|
||||
sensorSwathZoom,
|
||||
viewWidthKm,
|
||||
wheelZoomStep,
|
||||
ORBITAL_WHEEL_GESTURE_GAP_MS,
|
||||
offNadirOrientation,
|
||||
orbitalBaseFovRad,
|
||||
previousOrbitalZoom,
|
||||
zoomSettleHolding,
|
||||
zoomTransitionBlurPx,
|
||||
zoomTransitionProgress,
|
||||
zoomTransitionState,
|
||||
ORBITAL_PREFETCH_ACROSS,
|
||||
ORBITAL_PREFETCH_LEAD_S,
|
||||
ORBITAL_PREFETCH_MAX_TILES,
|
||||
destinationPoint,
|
||||
groundTrackAhead,
|
||||
imageryLevelForView,
|
||||
tilesForPoints,
|
||||
} from './cockpitOrbital.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { greatCircleKm } from './geoDistance.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);
|
||||
});
|
||||
|
||||
const WIDE = 16 / 9;
|
||||
|
||||
test("the zoom ladder climbs to a satellite's own cap and steps off a SENSOR zoom", () => {
|
||||
assert.equal(nextOrbitalZoom(8), 8, 'no sensor: 8x is the top');
|
||||
assert.equal(nextOrbitalZoom(8, 64), 16);
|
||||
assert.equal(nextOrbitalZoom(32, 64), 64);
|
||||
assert.equal(nextOrbitalZoom(64, 64), 64, 'the cap holds');
|
||||
assert.equal(nextOrbitalZoom(5.7, 64), 8, 'between steps: the step above');
|
||||
assert.equal(previousOrbitalZoom(5.7), 4, 'between steps: the step below');
|
||||
assert.equal(previousOrbitalZoom(128), 64);
|
||||
assert.equal(previousOrbitalZoom(1), 1);
|
||||
});
|
||||
|
||||
test('a sensor caps the zoom at its swath for the live altitude', () => {
|
||||
// WorldView-3 at 617 km, 13.1 km swath: 64x spans ~20 km, 128x ~10 km.
|
||||
assert.ok(viewWidthKm(617e3, 64, WIDE) >= 13.1);
|
||||
assert.ok(viewWidthKm(617e3, 128, WIDE) < 13.1);
|
||||
assert.equal(orbitalMaxZoom(617e3, 13.1, WIDE), 64);
|
||||
// ICEYE spotlight, 5 km at 570 km.
|
||||
assert.equal(orbitalMaxZoom(570e3, 5, WIDE), 128);
|
||||
// PlanetScope SuperDove, 32.5 km: 32x at 525 km but 16x at 470 km.
|
||||
assert.equal(orbitalMaxZoom(525e3, 32.5, WIDE), 32);
|
||||
assert.equal(orbitalMaxZoom(470e3, 32.5, WIDE), 16);
|
||||
// Landsat's 185 km swath fits inside 8x: no extra steps, never fewer.
|
||||
assert.equal(orbitalMaxZoom(705e3, 185, WIDE), 8);
|
||||
assert.equal(orbitalMaxZoom(705e3, 2330, WIDE), 8);
|
||||
// No sensor, or an unusable swath.
|
||||
assert.equal(orbitalMaxZoom(617e3, null, WIDE), 8);
|
||||
assert.equal(orbitalMaxZoom(617e3, 0, WIDE), 8);
|
||||
// A swath exactly as wide as the 16x view allows 16x.
|
||||
assert.equal(orbitalMaxZoom(617e3, viewWidthKm(617e3, 16, WIDE), WIDE), 16);
|
||||
});
|
||||
|
||||
test('the SENSOR step frames the swath when it lies in range', () => {
|
||||
const landsat = sensorSwathZoom(705e3, 185, WIDE, 8);
|
||||
assert.ok(landsat > 4 && landsat < 8, String(landsat));
|
||||
assert.ok(Math.abs(viewWidthKm(705e3, landsat, WIDE) - 185) < 1);
|
||||
const worldview = sensorSwathZoom(617e3, 13.1, WIDE, 64);
|
||||
assert.equal(worldview, null, 'narrower than the 64x cap allows');
|
||||
assert.equal(sensorSwathZoom(705e3, 2330, WIDE, 8), null, 'wider than 1x');
|
||||
assert.equal(sensorSwathZoom(705e3, null, WIDE, 8), null);
|
||||
});
|
||||
|
||||
test('the sensor strip spans its share of the view', () => {
|
||||
assert.equal(sensorStripFraction(13, 130), 0.1);
|
||||
assert.equal(sensorStripFraction(500, 131), 1, 'clipped to the view');
|
||||
assert.equal(sensorStripFraction(null, 131), null);
|
||||
assert.equal(sensorStripFraction(13.1, null), null);
|
||||
});
|
||||
|
||||
test('the wheel steps once per notch and sums a trackpad swipe', () => {
|
||||
// A mouse notch away from the viewer zooms in.
|
||||
let r = wheelZoomStep(null, { deltaY: -100, deltaMode: 0 }, 1000);
|
||||
assert.equal(r.step, 1);
|
||||
// The rest of that notch, inside the cooldown, is absorbed.
|
||||
r = wheelZoomStep(r.accumulator, { deltaY: -100, deltaMode: 0 }, 1050);
|
||||
assert.equal(r.step, 0);
|
||||
// A notch toward the viewer after the cooldown zooms out.
|
||||
r = wheelZoomStep(
|
||||
r.accumulator,
|
||||
{ deltaY: 120, deltaMode: 0 },
|
||||
1050 + ORBITAL_WHEEL_COOLDOWN_MS + 1,
|
||||
);
|
||||
assert.equal(r.step, -1);
|
||||
// A trackpad's small deltas add up before they step.
|
||||
let pad = null;
|
||||
const steps = [];
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const out = wheelZoomStep(
|
||||
pad,
|
||||
{ deltaY: -30, deltaMode: 0 },
|
||||
5000 + i * 16,
|
||||
);
|
||||
pad = out.accumulator;
|
||||
steps.push(out.step);
|
||||
}
|
||||
assert.deepEqual(steps, [0, 0, 0, 1]);
|
||||
// Turning back starts the sum over.
|
||||
const turned = wheelZoomStep(
|
||||
{ sum: -90, lastStepMs: -Infinity },
|
||||
{ deltaY: 30, deltaMode: 0 },
|
||||
9000,
|
||||
);
|
||||
assert.equal(turned.step, 0);
|
||||
assert.equal(turned.accumulator.sum, 30);
|
||||
// Line-mode wheels (Firefox): three lines make a notch.
|
||||
assert.equal(wheelZoomStep(null, { deltaY: -3, deltaMode: 1 }, 0).step, 0);
|
||||
assert.equal(wheelZoomStep(null, { deltaY: -7, deltaMode: 1 }, 0).step, 1);
|
||||
assert.equal(wheelZoomStep(null, { deltaY: 0 }, 0).step, 0);
|
||||
});
|
||||
|
||||
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('a partial scroll does not carry over into a later gesture', () => {
|
||||
// 90 px: short of a step.
|
||||
let r = wheelZoomStep(null, { deltaY: -90, deltaMode: 0 }, 1000);
|
||||
assert.equal(r.step, 0);
|
||||
// Still the same gesture: 10 px more completes the step.
|
||||
const same = wheelZoomStep(
|
||||
r.accumulator,
|
||||
{ deltaY: -10, deltaMode: 0 },
|
||||
1100,
|
||||
);
|
||||
assert.equal(same.step, 1);
|
||||
// Minutes later a 10 px nudge is a new gesture, not the rest of the old.
|
||||
r = wheelZoomStep(
|
||||
r.accumulator,
|
||||
{ deltaY: -10, deltaMode: 0 },
|
||||
1000 + ORBITAL_WHEEL_GESTURE_GAP_MS + 60_000,
|
||||
);
|
||||
assert.equal(r.step, 0);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
// ── imagery pre-load ahead of the ground track ─────────────────────────
|
||||
|
||||
const PASS = Object.freeze({
|
||||
latitude: 30,
|
||||
longitude: -97,
|
||||
groundTrackDeg: 45,
|
||||
velocityMps: 7_600,
|
||||
altitudeM: 500_000,
|
||||
});
|
||||
|
||||
test('destinationPoint walks a great circle along a bearing', () => {
|
||||
const east = destinationPoint(0, 179.9, 90, 111_195 * 0.2);
|
||||
assert.ok(Math.abs(east.lat) < 1e-9);
|
||||
assert.ok(Math.abs(east.lon - -179.9) < 1e-3, 'wraps the antimeridian');
|
||||
const north = destinationPoint(10, 20, 0, 50_000);
|
||||
assert.ok(Math.abs(greatCircleKm({ lat: 10, lon: 20 }, north) - 50) < 0.1);
|
||||
assert.ok(north.lat > 10 && Math.abs(north.lon - 20) < 1e-9);
|
||||
});
|
||||
|
||||
test('the points ahead run along the ground track at ground speed', () => {
|
||||
const points = groundTrackAhead(PASS, 20);
|
||||
const across = ORBITAL_PREFETCH_ACROSS.length;
|
||||
assert.equal(points.length, ORBITAL_PREFETCH_LEAD_S.length * across);
|
||||
// Ground speed is the orbital speed scaled to the surface: ~7.0 km/s.
|
||||
const groundKmPerS = (7.6 * 6371) / (6371 + 500);
|
||||
const start = { lat: PASS.latitude, lon: PASS.longitude };
|
||||
ORBITAL_PREFETCH_LEAD_S.forEach((seconds, i) => {
|
||||
const centre = points[i * across];
|
||||
const km = greatCircleKm(start, centre);
|
||||
assert.ok(Math.abs(km - groundKmPerS * seconds) < 0.5, `${seconds} s`);
|
||||
// North-east of the start, on a 45° track.
|
||||
assert.ok(centre.lat > PASS.latitude && centre.lon > PASS.longitude);
|
||||
// The side points sit their fraction of the view off the centre line.
|
||||
points.slice(i * across + 1, (i + 1) * across).forEach((side, k) => {
|
||||
const offKm = 20 * Math.abs(ORBITAL_PREFETCH_ACROSS[k + 1]);
|
||||
assert.ok(Math.abs(greatCircleKm(centre, side) - offKm) < 0.05);
|
||||
});
|
||||
});
|
||||
assert.deepEqual(groundTrackAhead({ ...PASS, groundTrackDeg: null }, 20), []);
|
||||
assert.deepEqual(groundTrackAhead(PASS, null), []);
|
||||
});
|
||||
|
||||
test('the tile level is the one Cesium draws for the view', () => {
|
||||
const mercator = new Cesium.WebMercatorTilingScheme();
|
||||
// Worked case: 10 m per CSS pixel at the equator. Cesium refines the
|
||||
// ellipsoid terrain until a tile's error is at most 2 px, 20 m: level 12,
|
||||
// 78,271.5 m / 2^12 = 19.11 m. The Web Mercator texel nearest that is
|
||||
// level 13's (156,543 m / 2^13 = 19.11 m), about 1.9 px each, where a
|
||||
// one-pixel texel would have asked for level 14.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 10, latitudeDeg: 0 }),
|
||||
13,
|
||||
);
|
||||
// 5 m/px: terrain level 13 (9.55 m), imagery level 14.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 5, latitudeDeg: 0 }),
|
||||
14,
|
||||
);
|
||||
// 7 m/px still takes terrain level 13 (9.55 m <= 14 m): level 14, so a
|
||||
// texel spans 1.4 px.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 7, latitudeDeg: 0 }),
|
||||
14,
|
||||
);
|
||||
// At 60° a Mercator texel covers half the ground: one level coarser.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 10, latitudeDeg: 60 }),
|
||||
12,
|
||||
);
|
||||
// A geographic scheme starts from two root tiles and keeps its texels at
|
||||
// every latitude.
|
||||
const geographic = new Cesium.GeographicTilingScheme();
|
||||
for (const latitudeDeg of [0, 60])
|
||||
assert.equal(
|
||||
imageryLevelForView(geographic, {
|
||||
metresPerPixel: 10,
|
||||
latitudeDeg,
|
||||
geographic: true,
|
||||
}),
|
||||
12,
|
||||
`${latitudeDeg}°`,
|
||||
);
|
||||
// A terrain provider's own errors (Cesium World Terrain: 65-sample tiles)
|
||||
// and a looser screen-space error are honoured.
|
||||
const worldTerrain = (level) =>
|
||||
(6378137 * 2 * Math.PI * 0.25) / (65 * 2) / 2 ** level;
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, {
|
||||
metresPerPixel: 10,
|
||||
latitudeDeg: 0,
|
||||
terrainLevelErrorM: worldTerrain,
|
||||
}),
|
||||
13,
|
||||
);
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, {
|
||||
metresPerPixel: 10,
|
||||
latitudeDeg: 0,
|
||||
maximumScreenSpaceError: 4,
|
||||
}),
|
||||
12,
|
||||
);
|
||||
// Clamped to the provider's levels.
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, {
|
||||
metresPerPixel: 5,
|
||||
latitudeDeg: 0,
|
||||
maximumLevel: 12,
|
||||
}),
|
||||
12,
|
||||
);
|
||||
assert.equal(
|
||||
imageryLevelForView(mercator, { metresPerPixel: 0, latitudeDeg: 0 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('the tiles ahead are the ones the points fall in, bounded and new', () => {
|
||||
const mercator = new Cesium.WebMercatorTilingScheme();
|
||||
const points = groundTrackAhead(PASS, 20);
|
||||
const tiles = tilesForPoints(mercator, points, 13);
|
||||
assert.ok(tiles.length > 0 && tiles.length <= ORBITAL_PREFETCH_MAX_TILES);
|
||||
assert.equal(new Set(tiles.map((tile) => tile.key)).size, tiles.length);
|
||||
// Each tile holds its point: the first is the centre line 5 s ahead.
|
||||
const first = mercator.tileXYToRectangle(tiles[0].x, tiles[0].y, 13);
|
||||
assert.ok(
|
||||
Cesium.Rectangle.contains(
|
||||
first,
|
||||
Cesium.Cartographic.fromDegrees(points[0].lon, points[0].lat),
|
||||
),
|
||||
);
|
||||
// Ahead along a north-east track: rows north (smaller y), columns east.
|
||||
const here = mercator.positionToTileXY(
|
||||
Cesium.Cartographic.fromDegrees(PASS.longitude, PASS.latitude),
|
||||
13,
|
||||
);
|
||||
assert.ok(tiles.every((tile) => tile.x >= here.x && tile.y <= here.y));
|
||||
// Bounded, and a tile already asked for is skipped.
|
||||
assert.equal(tilesForPoints(mercator, points, 15, { maxTiles: 3 }).length, 3);
|
||||
const asked = new Set(
|
||||
tilesForPoints(mercator, points, 13, { maxTiles: Infinity }).map(
|
||||
(tile) => tile.key,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(
|
||||
tilesForPoints(mercator, points, 13, { skip: (key) => asked.has(key) }),
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(tilesForPoints({}, points, 13), []);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test('the centre line of every lead comes before the frame edges', () => {
|
||||
const points = groundTrackAhead(PASS, 20);
|
||||
const across = ORBITAL_PREFETCH_ACROSS.length;
|
||||
const ordered = centreLinesFirst(points);
|
||||
assert.equal(ordered.length, points.length);
|
||||
const leads = ORBITAL_PREFETCH_LEAD_S.length;
|
||||
assert.deepEqual(
|
||||
ordered.slice(0, leads),
|
||||
points.filter((_, i) => i % across === 0),
|
||||
);
|
||||
// The 12-tile cap at a level of small tiles still reaches the far lead.
|
||||
const mercator = new Cesium.WebMercatorTilingScheme();
|
||||
const tiles = tilesForPoints(mercator, ordered, 17);
|
||||
const far = mercator.positionToTileXY(
|
||||
Cesium.Cartographic.fromDegrees(
|
||||
ordered[leads - 1].lon,
|
||||
ordered[leads - 1].lat,
|
||||
),
|
||||
17,
|
||||
);
|
||||
assert.ok(tiles.some((tile) => tile.x === far.x && tile.y === far.y));
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Ground focus for the orbital view: the look that keeps a point on the
|
||||
* ground at the centre of the picture as the satellite moves, whether that
|
||||
* look is within the off-nadir reach, and which mapped points the view can
|
||||
* show or be turned to. The look is the orbital view's own
|
||||
* `{yawDeg, pitchDeg}` over its nadir frame (see `offNadirOrientation` in
|
||||
* `src/cockpitOrbital.js`), so a focused view is a tilt like any other.
|
||||
* No DOM, Cesium or i18n dependencies: vectors are plain `{x, y, z}` objects
|
||||
* in Earth-fixed (ECEF) metres.
|
||||
*/
|
||||
import {
|
||||
LOOK_EASE_TAU_MS,
|
||||
ORBITAL_LOOK_LIMIT_DEG,
|
||||
easeLook,
|
||||
} from './cockpitLook.js';
|
||||
|
||||
/**
|
||||
* How long a focus takes to swing the view onto its target, or back to
|
||||
* nadir when it ends: the look's own easing curve, on a slower clock. Three
|
||||
* of them (about 0.75 s) close 95% of the turn.
|
||||
*/
|
||||
export const ORBITAL_FOCUS_EASE_TAU_MS = 250;
|
||||
|
||||
/** How often the orbital Live Signals rows are rebuilt, ms. */
|
||||
export const ORBITAL_REACH_REFRESH_MS = 1000;
|
||||
|
||||
/** WGS84 semi-axes squared, m²: the surface normal for the horizon test. */
|
||||
const WGS84_A2 = 6378137 ** 2;
|
||||
const WGS84_B2 = 6356752.314245179 ** 2;
|
||||
|
||||
/** Slack on the reach limit so a target held exactly at 45° stays. */
|
||||
const REACH_EPSILON_DEG = 1e-6;
|
||||
|
||||
const toDegrees = (rad) => (rad * 180) / Math.PI;
|
||||
|
||||
function dot(a, b) {
|
||||
return a.x * b.x + a.y * b.y + a.z * b.z;
|
||||
}
|
||||
|
||||
function finiteVector(v) {
|
||||
return (
|
||||
Boolean(v) &&
|
||||
Number.isFinite(v.x) &&
|
||||
Number.isFinite(v.y) &&
|
||||
Number.isFinite(v.z)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The look that centres `target` in the picture, in the nadir frame's tilt
|
||||
* (north first, then east; see `offNadirOrientation`): east is the angle off
|
||||
* the frame's top-bottom plane, north the angle along it. Also the single
|
||||
* angle between nadir and the line of sight, the image's off-nadir angle,
|
||||
* and whether the target faces the satellite (above its horizon).
|
||||
* @param {{direction: object, up: object, right: object}} nadir From
|
||||
* `nadirOrientation`.
|
||||
* @param {{x: number, y: number, z: number}} satellite ECEF, metres.
|
||||
* @param {{x: number, y: number, z: number}} target ECEF, metres.
|
||||
* @returns {{yawDeg: number, pitchDeg: number, offNadirDeg: number,
|
||||
* visible: boolean}|null} Null without usable positions.
|
||||
*/
|
||||
export function focusLook(nadir, satellite, target) {
|
||||
if (!nadir || !finiteVector(satellite) || !finiteVector(target)) return null;
|
||||
const line = {
|
||||
x: target.x - satellite.x,
|
||||
y: target.y - satellite.y,
|
||||
z: target.z - satellite.z,
|
||||
};
|
||||
const range = Math.hypot(line.x, line.y, line.z);
|
||||
if (!(range > 1)) return null;
|
||||
const v = { x: line.x / range, y: line.y / range, z: line.z / range };
|
||||
const along = dot(v, nadir.direction);
|
||||
const up = dot(v, nadir.up);
|
||||
const right = Math.min(1, Math.max(-1, dot(v, nadir.right)));
|
||||
// The target's own zenith, the WGS84 surface normal there: the satellite
|
||||
// is above its horizon when the line from the target to it points up.
|
||||
const normal = {
|
||||
x: target.x / WGS84_A2,
|
||||
y: target.y / WGS84_A2,
|
||||
z: target.z / WGS84_B2,
|
||||
};
|
||||
const visible =
|
||||
Math.hypot(normal.x, normal.y, normal.z) > 0 && -dot(line, normal) > 0;
|
||||
const yawDeg = toDegrees(Math.asin(right));
|
||||
const pitchDeg = toDegrees(Math.atan2(up, along));
|
||||
return {
|
||||
yawDeg: yawDeg === 0 ? 0 : yawDeg,
|
||||
pitchDeg: pitchDeg === 0 ? 0 : pitchDeg,
|
||||
offNadirDeg: toDegrees(Math.acos(Math.min(1, Math.max(-1, along)))),
|
||||
visible,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a focus look is within the off-nadir reach: the target faces the
|
||||
* satellite and each axis is within the arrows' ±45° clamp.
|
||||
* @param {{yawDeg: number, pitchDeg: number, visible?: boolean}|null} look
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function focusInReach(look) {
|
||||
if (!look || look.visible === false) return false;
|
||||
const limit = ORBITAL_LOOK_LIMIT_DEG + REACH_EPSILON_DEG;
|
||||
return (
|
||||
Number.isFinite(look.yawDeg) &&
|
||||
Number.isFinite(look.pitchDeg) &&
|
||||
Math.abs(look.yawDeg) <= limit &&
|
||||
Math.abs(look.pitchDeg) <= limit
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ease what is left of the turn onto a focus target toward nothing: the
|
||||
* look's easing (`easeLook`) on the focus's slower clock. The view is the
|
||||
* live target's look plus this remainder, so once it lands the view holds
|
||||
* the target exactly, without the lag of chasing it. Reduced motion cuts.
|
||||
* @param {{yawDeg: number, pitchDeg: number}} remainder View minus target.
|
||||
* @param {number} dtMs Milliseconds since the previous frame.
|
||||
* @param {{immediate?: boolean}} [options]
|
||||
* @returns {{yawDeg: number, pitchDeg: number}}
|
||||
*/
|
||||
export function easeFocusRemainder(
|
||||
remainder,
|
||||
dtMs,
|
||||
{ immediate = false } = {},
|
||||
) {
|
||||
return easeLook(remainder, { yawDeg: 0, pitchDeg: 0 }, focusEaseDtMs(dtMs), {
|
||||
immediate,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A frame's time on the focus's clock, for `easeLook`: its fixed time
|
||||
* constant scaled to ORBITAL_FOCUS_EASE_TAU_MS.
|
||||
* @param {number} dtMs
|
||||
* @returns {number}
|
||||
*/
|
||||
export function focusEaseDtMs(dtMs) {
|
||||
return Number.isFinite(dtMs) && dtMs > 0
|
||||
? (dtMs * LOOK_EASE_TAU_MS) / ORBITAL_FOCUS_EASE_TAU_MS
|
||||
: 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The mapped points the view can be turned to: those facing the satellite
|
||||
* and within the off-nadir reach, nearest to nadir first.
|
||||
* @param {Iterable<{key: string, position: {x: number, y: number,
|
||||
* z: number}}>} candidates
|
||||
* @param {{nadir: object, satellite: object}} frame
|
||||
* @returns {Array<{key: string, offNadirDeg: number, look: object}>}
|
||||
*/
|
||||
export function pointsInReach(candidates, { nadir, satellite }) {
|
||||
const entries = [];
|
||||
if (!nadir || !finiteVector(satellite)) return entries;
|
||||
for (const candidate of candidates) {
|
||||
const look = focusLook(nadir, satellite, candidate?.position);
|
||||
if (!focusInReach(look)) continue;
|
||||
entries.push({ key: candidate.key, offNadirDeg: look.offNadirDeg, look });
|
||||
}
|
||||
return entries.sort(
|
||||
(a, b) =>
|
||||
a.offNadirDeg - b.offNadirDeg || String(a.key).localeCompare(b.key),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// src/cockpitOrbitalFocus.test.mjs — the geometry of a ground focus in the
|
||||
// orbital view: the look that centres a target, its off-nadir angle, the
|
||||
// reach, the easing onto and off a target, and the reach list.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
ORBITAL_FOCUS_EASE_TAU_MS,
|
||||
easeFocusRemainder,
|
||||
focusEaseDtMs,
|
||||
focusInReach,
|
||||
focusLook,
|
||||
pointsInReach,
|
||||
} from './cockpitOrbitalFocus.js';
|
||||
import {
|
||||
ORBITAL_EARTH_RADIUS_M,
|
||||
nadirOrientation,
|
||||
offNadirAngleDeg,
|
||||
offNadirOrientation,
|
||||
} from './cockpitOrbital.js';
|
||||
import { LOOK_EASE_TAU_MS } from './cockpitLook.js';
|
||||
|
||||
const near = (actual, expected, label, tolerance = 1e-9) =>
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) <= tolerance,
|
||||
`${label}: ${actual} not within ${tolerance} of ${expected}`,
|
||||
);
|
||||
|
||||
const unit = (v) => {
|
||||
const n = Math.hypot(v.x, v.y, v.z);
|
||||
return { x: v.x / n, y: v.y / n, z: v.z / n };
|
||||
};
|
||||
const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y, z: a.z - b.z });
|
||||
const angleBetweenDeg = (a, b) => {
|
||||
const u = unit(a);
|
||||
const v = unit(b);
|
||||
const cosine = Math.min(1, Math.max(-1, u.x * v.x + u.y * v.y + u.z * v.z));
|
||||
return (Math.acos(cosine) * 180) / Math.PI;
|
||||
};
|
||||
|
||||
/** A point on a sphere of radius `radiusM`, ECEF. */
|
||||
const onSphere = (latDeg, lonDeg, radiusM) => {
|
||||
const lat = (latDeg * Math.PI) / 180;
|
||||
const lon = (lonDeg * Math.PI) / 180;
|
||||
return {
|
||||
x: radiusM * Math.cos(lat) * Math.cos(lon),
|
||||
y: radiusM * Math.cos(lat) * Math.sin(lon),
|
||||
z: radiusM * Math.sin(lat),
|
||||
};
|
||||
};
|
||||
|
||||
/** A point on the WGS84 ellipsoid, as the globe draws it. */
|
||||
const geodeticToEcef = (latDeg, lonDeg, heightM = 0) =>
|
||||
Cesium.Cartesian3.fromDegrees(lonDeg, latDeg, heightM);
|
||||
|
||||
const ISS_ALT_M = 420_000;
|
||||
const satelliteAt = (latDeg, lonDeg, altitudeM = ISS_ALT_M) =>
|
||||
geodeticToEcef(latDeg, lonDeg, altitudeM);
|
||||
|
||||
test('the focus look turns the nadir frame onto the target exactly', () => {
|
||||
const satellite = satelliteAt(30, -97);
|
||||
for (const heading of [null, 0, 51.6, 200]) {
|
||||
const nadir = nadirOrientation(satellite, { headingDeg: heading });
|
||||
for (const [lat, lon] of [
|
||||
[31.5, -96],
|
||||
[28.2, -99.4],
|
||||
[30, -97],
|
||||
[32.9, -97.1],
|
||||
]) {
|
||||
const target = geodeticToEcef(lat, lon, 300);
|
||||
const look = focusLook(nadir, satellite, target);
|
||||
const view = offNadirOrientation(nadir, {
|
||||
northDeg: look.pitchDeg,
|
||||
eastDeg: look.yawDeg,
|
||||
});
|
||||
near(
|
||||
angleBetweenDeg(view.direction, sub(target, satellite)),
|
||||
0,
|
||||
`centred at ${lat},${lon} heading ${heading}`,
|
||||
1e-5,
|
||||
);
|
||||
// The single angle the readout gives is the tilt's total angle.
|
||||
near(
|
||||
look.offNadirDeg,
|
||||
offNadirAngleDeg({ northDeg: look.pitchDeg, eastDeg: look.yawDeg }),
|
||||
'one off-nadir angle',
|
||||
1e-9,
|
||||
);
|
||||
assert.equal(look.visible, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the off-nadir angle is the angle between nadir and the line of sight', () => {
|
||||
// On a sphere: a target `central` radians from the sub-satellite point is
|
||||
// seen atan(R sin γ / (R + h − R cos γ)) off nadir.
|
||||
const R = ORBITAL_EARTH_RADIUS_M;
|
||||
const satellite = onSphere(10, 20, R + ISS_ALT_M);
|
||||
const nadir = nadirOrientation(satellite);
|
||||
for (const centralDeg of [0, 0.5, 2, 5, 12]) {
|
||||
const target = onSphere(10 + centralDeg, 20, R);
|
||||
const γ = (centralDeg * Math.PI) / 180;
|
||||
const expected =
|
||||
(Math.atan2(R * Math.sin(γ), R + ISS_ALT_M - R * Math.cos(γ)) * 180) /
|
||||
Math.PI;
|
||||
const look = focusLook(nadir, satellite, target);
|
||||
near(look.offNadirDeg, expected, `${centralDeg}° away`, 1e-9);
|
||||
}
|
||||
// Straight below reads zero, a real angle and not a missing one.
|
||||
const below = focusLook(nadir, satellite, onSphere(10, 20, R));
|
||||
near(below.offNadirDeg, 0, 'straight below', 1e-9);
|
||||
near(below.yawDeg, 0, 'no east', 1e-9);
|
||||
near(below.pitchDeg, 0, 'no north', 1e-9);
|
||||
assert.equal(focusInReach(below), true);
|
||||
});
|
||||
|
||||
test('the reach is the arrows’ ±45° on each axis, facing the satellite', () => {
|
||||
assert.equal(focusInReach({ yawDeg: 45, pitchDeg: -45, visible: true }), true);
|
||||
assert.equal(focusInReach({ yawDeg: 45.01, pitchDeg: 0, visible: true }), false);
|
||||
assert.equal(focusInReach({ yawDeg: 0, pitchDeg: -45.01, visible: true }), false);
|
||||
assert.equal(focusInReach({ yawDeg: 0, pitchDeg: 0, visible: false }), false);
|
||||
assert.equal(focusInReach(null), false);
|
||||
assert.equal(focusInReach({ yawDeg: Number.NaN, pitchDeg: 0 }), false);
|
||||
|
||||
// From geostationary height the far side of the Earth lies within a few
|
||||
// degrees of nadir, but it faces away.
|
||||
const satellite = satelliteAt(0, 0, 35_786_000);
|
||||
const nadir = nadirOrientation(satellite);
|
||||
const farSide = geodeticToEcef(0, 100, 0);
|
||||
const look = focusLook(nadir, satellite, farSide);
|
||||
assert.ok(Math.abs(look.yawDeg) < 45 && Math.abs(look.pitchDeg) < 45);
|
||||
assert.equal(look.visible, false);
|
||||
assert.equal(focusInReach(look), false);
|
||||
// A point near the sub-satellite point faces it.
|
||||
assert.equal(focusInReach(focusLook(nadir, satellite, geodeticToEcef(5, 5))), true);
|
||||
});
|
||||
|
||||
test('a target slides out of reach as the satellite passes it', () => {
|
||||
const target = geodeticToEcef(0, 10, 0);
|
||||
const inReach = [];
|
||||
for (let lon = 10; lon >= -5; lon -= 1) {
|
||||
const satellite = satelliteAt(0, lon);
|
||||
inReach.push(focusInReach(focusLook(nadirOrientation(satellite), satellite, target)));
|
||||
}
|
||||
assert.equal(inReach[0], true, 'overhead');
|
||||
assert.equal(inReach.at(-1), false, '15° of longitude behind');
|
||||
// Once out, it stays out: the reach ends once, not on and off.
|
||||
const firstOut = inReach.indexOf(false);
|
||||
assert.ok(firstOut > 0);
|
||||
assert.ok(inReach.slice(firstOut).every((value) => value === false));
|
||||
});
|
||||
|
||||
test('the turn onto a target eases on the slower focus clock and lands', () => {
|
||||
assert.equal(
|
||||
focusEaseDtMs(ORBITAL_FOCUS_EASE_TAU_MS),
|
||||
LOOK_EASE_TAU_MS,
|
||||
'one focus time constant is one look time constant',
|
||||
);
|
||||
assert.equal(focusEaseDtMs(Number.NaN), 0);
|
||||
assert.equal(focusEaseDtMs(-5), 0);
|
||||
let remainder = { yawDeg: -30, pitchDeg: 20 };
|
||||
let previous = Math.hypot(remainder.yawDeg, remainder.pitchDeg);
|
||||
let frames = 0;
|
||||
while ((remainder.yawDeg || remainder.pitchDeg) && frames < 500) {
|
||||
const before = remainder;
|
||||
remainder = easeFocusRemainder(remainder, 16);
|
||||
const size = Math.hypot(remainder.yawDeg, remainder.pitchDeg);
|
||||
assert.ok(size < previous, 'always closer');
|
||||
// No axis moves more than a tenth of the way in a frame, but for the
|
||||
// last few hundredths of a degree it lands on: a turn, not a cut.
|
||||
for (const axis of ['yawDeg', 'pitchDeg']) {
|
||||
const step = Math.abs(before[axis] - remainder[axis]);
|
||||
assert.ok(
|
||||
step <= Math.max(Math.abs(before[axis]) * 0.1, 0.06),
|
||||
`frame ${frames} ${axis}`,
|
||||
);
|
||||
}
|
||||
previous = size;
|
||||
frames += 1;
|
||||
}
|
||||
assert.deepEqual(remainder, { yawDeg: 0, pitchDeg: 0 }, 'lands exactly');
|
||||
// About three time constants close 95%; landing takes a little longer.
|
||||
assert.ok(frames * 16 > 2 * ORBITAL_FOCUS_EASE_TAU_MS, `${frames} frames`);
|
||||
assert.ok(frames * 16 < 12 * ORBITAL_FOCUS_EASE_TAU_MS, `${frames} frames`);
|
||||
assert.deepEqual(
|
||||
easeFocusRemainder({ yawDeg: 10, pitchDeg: -4 }, 16, { immediate: true }),
|
||||
{ yawDeg: 0, pitchDeg: 0 },
|
||||
'reduced motion cuts',
|
||||
);
|
||||
});
|
||||
|
||||
test('the reach list keeps points in reach or in frame, nearest first, bounded', () => {
|
||||
const satellite = satelliteAt(0, 0);
|
||||
const nadir = nadirOrientation(satellite);
|
||||
const candidates = [];
|
||||
// A row of points eastward, 0.1° apart: those up to ~3.7° are within the
|
||||
// 45° tilt, the rest beyond it.
|
||||
for (let i = 0; i < 80; i += 1)
|
||||
candidates.push({ key: `east-${i}`, position: geodeticToEcef(0, i * 0.1) });
|
||||
candidates.push({ key: 'far-side', position: geodeticToEcef(0, 180) });
|
||||
const entries = pointsInReach(candidates, { nadir, satellite });
|
||||
assert.ok(entries.every((entry) => entry.key !== 'far-side'));
|
||||
const angles = entries.map((entry) => entry.offNadirDeg);
|
||||
assert.deepEqual(angles, [...angles].sort((a, b) => a - b), 'nearest first');
|
||||
assert.equal(entries[0].key, 'east-0');
|
||||
assert.equal(entries[0].offNadirDeg, 0);
|
||||
// Only those within the 45° tilt are listed.
|
||||
assert.ok(entries.length > 0 && entries.length < 80);
|
||||
for (const entry of entries) assert.ok(Math.abs(entry.look.yawDeg) <= 45);
|
||||
const beyond = focusLook(nadir, satellite, candidates[entries.length].position);
|
||||
assert.ok(Math.abs(beyond.yawDeg) > 45, 'the next one east is past the reach');
|
||||
assert.deepEqual(pointsInReach([], { nadir, satellite }), []);
|
||||
});
|
||||
|
||||
test('the horizon is the ground’s own: the WGS84 surface normal, not the centre line', () => {
|
||||
const target = geodeticToEcef(45, 0);
|
||||
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(target);
|
||||
const satelliteAtElevation = (elevationDeg) => {
|
||||
const e = Cesium.Math.toRadians(elevationDeg);
|
||||
const local = new Cesium.Cartesian3(0, -Math.cos(e) * 4e7, Math.sin(e) * 4e7);
|
||||
return Cesium.Matrix4.multiplyByPoint(enu, local, new Cesium.Cartesian3());
|
||||
};
|
||||
const below = satelliteAtElevation(-0.1);
|
||||
assert.equal(focusLook(nadirOrientation(below), below, target).visible, false);
|
||||
const above = satelliteAtElevation(0.1);
|
||||
assert.equal(focusLook(nadirOrientation(above), above, target).visible, true);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* The camera's hop from the satellite the orbital view rides to another.
|
||||
* No DOM, Cesium or i18n dependencies: vectors are plain `{x, y, z}` objects
|
||||
* in Earth-fixed (ECEF) metres.
|
||||
*/
|
||||
|
||||
/** The most nearby satellites asked for the orbital rows, nearest first. */
|
||||
export const ORBITAL_SATELLITES_MAX = 8;
|
||||
|
||||
/** How long the camera takes to hop to another satellite, ms. */
|
||||
export const ORBITAL_HOP_MS = 1200;
|
||||
|
||||
function finiteVector(v) {
|
||||
return (
|
||||
Boolean(v) &&
|
||||
Number.isFinite(v.x) &&
|
||||
Number.isFinite(v.y) &&
|
||||
Number.isFinite(v.z)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the camera is part-way through a hop: along the great circle
|
||||
* between the two positions (never through the Earth), its distance from the
|
||||
* centre interpolated linearly. The circle is the one through both ends, so
|
||||
* the path moves smoothly as the destination does, however far round it
|
||||
* lies; only exactly opposite ends, which no single circle joins, turn about
|
||||
* a fixed axis square to them.
|
||||
* @param {{x: number, y: number, z: number}} from
|
||||
* @param {{x: number, y: number, z: number}} to
|
||||
* @param {number} progress Eased, 0..1.
|
||||
* @returns {{x: number, y: number, z: number}|null}
|
||||
*/
|
||||
export function hopPosition(from, to, progress) {
|
||||
if (!finiteVector(from) || !finiteVector(to)) return null;
|
||||
const p = Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 1));
|
||||
const ra = Math.hypot(from.x, from.y, from.z);
|
||||
const rb = Math.hypot(to.x, to.y, to.z);
|
||||
if (!(ra > 0) || !(rb > 0)) return null;
|
||||
const a = { x: from.x / ra, y: from.y / ra, z: from.z / ra };
|
||||
const b = { x: to.x / rb, y: to.y / rb, z: to.z / rb };
|
||||
const cosine = a.x * b.x + a.y * b.y + a.z * b.z;
|
||||
// The direction the circle leaves `a` in: `b` less its part along `a`.
|
||||
let c = {
|
||||
x: b.x - cosine * a.x,
|
||||
y: b.y - cosine * a.y,
|
||||
z: b.z - cosine * a.z,
|
||||
};
|
||||
let rc = Math.hypot(c.x, c.y, c.z);
|
||||
const r = ra + (rb - ra) * p;
|
||||
const omega = Math.atan2(rc, cosine);
|
||||
if (!(rc > 1e-12)) {
|
||||
if (cosine > 0) return { x: a.x * r, y: a.y * r, z: a.z * r };
|
||||
// Opposite ends: set off square to `a`, northward (or, near a pole,
|
||||
// toward the prime meridian).
|
||||
const pole =
|
||||
Math.abs(a.z) < 0.9 ? { x: 0, y: 0, z: 1 } : { x: 1, y: 0, z: 0 };
|
||||
const along = pole.x * a.x + pole.y * a.y + pole.z * a.z;
|
||||
c = {
|
||||
x: pole.x - along * a.x,
|
||||
y: pole.y - along * a.y,
|
||||
z: pole.z - along * a.z,
|
||||
};
|
||||
rc = Math.hypot(c.x, c.y, c.z);
|
||||
}
|
||||
const angle = p * omega;
|
||||
const cos = Math.cos(angle);
|
||||
const sin = Math.sin(angle) / rc;
|
||||
return {
|
||||
x: (a.x * cos + c.x * sin) * r,
|
||||
y: (a.y * cos + c.y * sin) * r,
|
||||
z: (a.z * cos + c.z * sin) * r,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// src/cockpitOrbitalSatellites.test.mjs — the camera's hop from one ridden
|
||||
// satellite to another.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { hopPosition } from './cockpitOrbitalSatellites.js';
|
||||
|
||||
const at = (lat, lon, alt) => Cesium.Cartesian3.fromDegrees(lon, lat, alt);
|
||||
|
||||
test('the hop follows the great circle and lands on the satellite', () => {
|
||||
const from = at(0, 0, 400_000);
|
||||
const to = at(0, 90, 800_000);
|
||||
const start = hopPosition(from, to, 0);
|
||||
for (const axis of ['x', 'y', 'z'])
|
||||
assert.ok(Math.abs(start[axis] - from[axis]) < 1e-6, axis);
|
||||
const end = hopPosition(from, to, 1);
|
||||
for (const axis of ['x', 'y', 'z'])
|
||||
assert.ok(Math.abs(end[axis] - to[axis]) < 1e-6, axis);
|
||||
// Half-way it is above the Earth, between the two heights, not on the
|
||||
// chord through it.
|
||||
const mid = hopPosition(from, to, 0.5);
|
||||
const radius = Math.hypot(mid.x, mid.y, mid.z);
|
||||
const ra = Cesium.Cartesian3.magnitude(from);
|
||||
const rb = Cesium.Cartesian3.magnitude(to);
|
||||
assert.ok(Math.abs(radius - (ra + rb) / 2) < 1e-6);
|
||||
assert.ok(Math.abs(Math.atan2(mid.y, mid.x) - Math.PI / 4) < 1e-9);
|
||||
assert.equal(hopPosition(null, to, 0.5), null);
|
||||
});
|
||||
|
||||
test('a hop to the opposite side goes round the Earth, not through it', () => {
|
||||
const from = at(0, 0, 400_000);
|
||||
const to = at(0, 180, 400_000);
|
||||
const radius = Cesium.Cartesian3.magnitude(from);
|
||||
for (const p of [0.25, 0.5, 0.75]) {
|
||||
const point = hopPosition(from, to, p);
|
||||
assert.ok(
|
||||
Math.abs(Math.hypot(point.x, point.y, point.z) - radius) < 1e-3,
|
||||
`p=${p}`,
|
||||
);
|
||||
}
|
||||
const end = hopPosition(from, to, 1);
|
||||
for (const axis of ['x', 'y', 'z'])
|
||||
assert.ok(Math.abs(end[axis] - to[axis]) < 1e-3, axis);
|
||||
});
|
||||
|
||||
test('a hop to nearly the opposite side moves smoothly as the destination does', () => {
|
||||
const r = 6_778_137;
|
||||
const from = { x: r, y: 0, z: 0 };
|
||||
const near = (delta) => ({
|
||||
x: -r * Math.cos(delta),
|
||||
y: r * Math.sin(delta),
|
||||
z: 0,
|
||||
});
|
||||
const mid = (delta) => hopPosition(from, near(delta), 0.5);
|
||||
const [a, b] = [mid(0.999e-6), mid(1.001e-6)];
|
||||
assert.ok(
|
||||
Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z) < 1,
|
||||
'no jump between neighbours',
|
||||
);
|
||||
for (const delta of [1e-3, 1e-6, 1e-9]) {
|
||||
const to = near(delta);
|
||||
const end = hopPosition(from, to, 1);
|
||||
assert.ok(
|
||||
Math.hypot(end.x - to.x, end.y - to.y, end.z - to.z) < 1e-3,
|
||||
`lands at ${delta}`,
|
||||
);
|
||||
const half = mid(delta);
|
||||
assert.ok(
|
||||
Math.abs(Math.hypot(half.x, half.y, half.z) - r) < 1e-3,
|
||||
`round at ${delta}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Bridge radar policy: the range steps, the range rings and the heading-up
|
||||
* projection of nearby AIS vessels onto the radar's range circle. Pure: no
|
||||
* DOM, no Cesium and no translated text; the Cockpit words the labels.
|
||||
*/
|
||||
import {
|
||||
bearingBetweenCoordinates,
|
||||
normalizeHeading,
|
||||
relativeBearing,
|
||||
} from './cockpitMath.js';
|
||||
import { greatCircleMeters } from './geoDistance.js';
|
||||
|
||||
/** Metres in one international nautical mile. */
|
||||
export const METRES_PER_NM = 1852;
|
||||
|
||||
/** The radar's range steps in nautical miles, shortest first. */
|
||||
export const RADAR_RANGES_NM = Object.freeze([3, 6, 12, 24]);
|
||||
|
||||
/** The range the radar opens on. */
|
||||
export const DEFAULT_RADAR_RANGE_NM = 3;
|
||||
|
||||
/** At most this many contacts are drawn, nearest first. */
|
||||
export const RADAR_MAX_CONTACTS = 60;
|
||||
|
||||
/** Range rings divide the range into this many equal steps. */
|
||||
export const RADAR_RING_COUNT = 4;
|
||||
|
||||
/**
|
||||
* Normalize a range to one of the radar's steps.
|
||||
* @param {number} rangeNm Requested range in nautical miles.
|
||||
* @returns {number} The range when it is a step, else the default range.
|
||||
*/
|
||||
export function radarRangeNm(rangeNm) {
|
||||
return RADAR_RANGES_NM.includes(rangeNm) ? rangeNm : DEFAULT_RADAR_RANGE_NM;
|
||||
}
|
||||
|
||||
/**
|
||||
* The range after this one; the longest range wraps to the shortest.
|
||||
* @param {number} rangeNm Current range in nautical miles.
|
||||
* @returns {number} The next range step.
|
||||
*/
|
||||
export function nextRadarRange(rangeNm) {
|
||||
const index = RADAR_RANGES_NM.indexOf(radarRangeNm(rangeNm));
|
||||
return RADAR_RANGES_NM[(index + 1) % RADAR_RANGES_NM.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Range ring distances at each quarter of the range, innermost first.
|
||||
* @param {number} rangeNm Range in nautical miles.
|
||||
* @returns {number[]} Ring distances in nautical miles; the last is the range.
|
||||
*/
|
||||
export function radarRingsNm(rangeNm) {
|
||||
const range = radarRangeNm(rangeNm);
|
||||
return Array.from(
|
||||
{ length: RADAR_RING_COUNT },
|
||||
(_, index) => (range * (index + 1)) / RADAR_RING_COUNT,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The search radius for a range, in metres.
|
||||
* @param {number} rangeNm Range in nautical miles.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function radarRangeM(rangeNm) {
|
||||
return radarRangeNm(rangeNm) * METRES_PER_NM;
|
||||
}
|
||||
|
||||
const round4 = (value) => Math.round(value * 1e4) / 1e4;
|
||||
|
||||
/**
|
||||
* Project vessel contacts onto a heading-up radar.
|
||||
*
|
||||
* Positions are fractions of the range circle: x to the right (starboard),
|
||||
* y up (ahead), both in [-1, 1]. Contacts outside the range, or without a
|
||||
* position, are left out; the rest come nearest first, at most `maxCount`.
|
||||
* @param {{latitude: number, longitude: number, headingDeg: number}} own
|
||||
* Own ship position and the heading the radar is drawn up from.
|
||||
* @param {Array<{id: string, name?: string, latitude: number,
|
||||
* longitude: number, course?: number|null, color?: string,
|
||||
* stale?: boolean}>} contacts Nearby vessels.
|
||||
* @param {{rangeNm?: number, maxCount?: number}} [options]
|
||||
* @returns {Array<{id: string, name: string, x: number, y: number,
|
||||
* distanceNm: number, relativeBearingDeg: number,
|
||||
* relativeCourseDeg: number|null, color: string, stale: boolean}>}
|
||||
* relativeBearingDeg and relativeCourseDeg are clockwise from the bow in
|
||||
* [-180, 180).
|
||||
*/
|
||||
export function projectRadarContacts(own, contacts, options = {}) {
|
||||
const rangeNm = radarRangeNm(options.rangeNm ?? DEFAULT_RADAR_RANGE_NM);
|
||||
const cap =
|
||||
Number.isFinite(options.maxCount) && options.maxCount > 0
|
||||
? Math.floor(options.maxCount)
|
||||
: RADAR_MAX_CONTACTS;
|
||||
const lat = Number(own?.latitude);
|
||||
const lon = Number(own?.longitude);
|
||||
if (
|
||||
!Number.isFinite(lat) ||
|
||||
!Number.isFinite(lon) ||
|
||||
!Array.isArray(contacts)
|
||||
)
|
||||
return [];
|
||||
const heading = normalizeHeading(own.headingDeg ?? 0);
|
||||
const projected = [];
|
||||
for (const contact of contacts) {
|
||||
const toLat = Number(contact?.latitude);
|
||||
const toLon = Number(contact?.longitude);
|
||||
const distanceNm =
|
||||
greatCircleMeters({ lat, lon }, { lat: toLat, lon: toLon }) /
|
||||
METRES_PER_NM;
|
||||
if (!Number.isFinite(distanceNm) || distanceNm > rangeNm) continue;
|
||||
const bearing = bearingBetweenCoordinates(lat, lon, toLat, toLon);
|
||||
const relativeBearingDeg =
|
||||
bearing === null ? 0 : relativeBearing(bearing, heading);
|
||||
const radians = (relativeBearingDeg * Math.PI) / 180;
|
||||
const fraction = distanceNm / rangeNm;
|
||||
const course =
|
||||
contact.course === null || contact.course === undefined
|
||||
? NaN
|
||||
: Number(contact.course);
|
||||
projected.push({
|
||||
id: String(contact.id),
|
||||
name: String(contact.name || contact.id),
|
||||
x: round4(Math.sin(radians) * fraction),
|
||||
y: round4(Math.cos(radians) * fraction),
|
||||
distanceNm,
|
||||
relativeBearingDeg,
|
||||
relativeCourseDeg: Number.isFinite(course)
|
||||
? relativeBearing(course, heading)
|
||||
: null,
|
||||
color: contact.color || '',
|
||||
stale: contact.stale === true,
|
||||
});
|
||||
}
|
||||
projected.sort((a, b) => a.distanceNm - b.distanceNm);
|
||||
return projected.slice(0, cap);
|
||||
}
|
||||
|
||||
/**
|
||||
* The radar presentation without its worded labels.
|
||||
* @param {{latitude: number, longitude: number, headingDeg: number,
|
||||
* courseDeg?: number|null}} own Own ship; `courseDeg` is course over ground.
|
||||
* @param {Array<object>} contacts See {@link projectRadarContacts}.
|
||||
* @param {number} rangeNm Range in nautical miles.
|
||||
* @returns {{rangeNm: number, ringsNm: number[], headingDeg: number,
|
||||
* ownRelativeCourseDeg: number|null, contacts: Array<object>}}
|
||||
*/
|
||||
export function radarPicture(own, contacts, rangeNm) {
|
||||
const range = radarRangeNm(rangeNm);
|
||||
const headingDeg = normalizeHeading(own?.headingDeg ?? 0);
|
||||
const course =
|
||||
own?.courseDeg === null || own?.courseDeg === undefined
|
||||
? NaN
|
||||
: Number(own.courseDeg);
|
||||
return {
|
||||
rangeNm: range,
|
||||
ringsNm: radarRingsNm(range),
|
||||
headingDeg,
|
||||
ownRelativeCourseDeg: Number.isFinite(course)
|
||||
? relativeBearing(course, headingDeg)
|
||||
: null,
|
||||
contacts: projectRadarContacts({ ...own, headingDeg }, contacts, {
|
||||
rangeNm: range,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// src/cockpitRadar.test.mjs — the bridge radar's range steps, rings and
|
||||
// heading-up contact projection.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DEFAULT_RADAR_RANGE_NM,
|
||||
METRES_PER_NM,
|
||||
RADAR_MAX_CONTACTS,
|
||||
RADAR_RANGES_NM,
|
||||
nextRadarRange,
|
||||
projectRadarContacts,
|
||||
radarPicture,
|
||||
radarRangeM,
|
||||
radarRingsNm,
|
||||
} from './cockpitRadar.js';
|
||||
|
||||
const OWN = Object.freeze({ latitude: 54.5, longitude: 10.2, headingDeg: 0 });
|
||||
/** One nautical mile of latitude, in degrees. */
|
||||
const NM_LAT = 1 / 60;
|
||||
/** One nautical mile of longitude at 54.5°N, in degrees. */
|
||||
const NM_LON = 1 / (60 * Math.cos((54.5 * Math.PI) / 180));
|
||||
|
||||
function contact(id, northNm, eastNm, extra = {}) {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
latitude: OWN.latitude + northNm * NM_LAT,
|
||||
longitude: OWN.longitude + eastNm * NM_LON,
|
||||
course: null,
|
||||
color: '#fff',
|
||||
stale: false,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
test('the range steps 3, 6, 12, 24 NM, opens on 3 and wraps', () => {
|
||||
assert.deepEqual(RADAR_RANGES_NM, [3, 6, 12, 24]);
|
||||
assert.equal(DEFAULT_RADAR_RANGE_NM, 3);
|
||||
assert.equal(nextRadarRange(3), 6);
|
||||
assert.equal(nextRadarRange(6), 12);
|
||||
assert.equal(nextRadarRange(12), 24);
|
||||
assert.equal(nextRadarRange(24), 3);
|
||||
assert.equal(nextRadarRange(7), 6, 'an unknown range steps from the default');
|
||||
assert.equal(radarRangeM(6), 6 * METRES_PER_NM);
|
||||
});
|
||||
|
||||
test('range rings sit at each quarter of the range', () => {
|
||||
assert.deepEqual(radarRingsNm(6), [1.5, 3, 4.5, 6]);
|
||||
assert.deepEqual(radarRingsNm(24), [6, 12, 18, 24]);
|
||||
});
|
||||
|
||||
test('a contact dead ahead is straight up the scope', () => {
|
||||
const [ahead] = projectRadarContacts(OWN, [contact('A', 3, 0)], {
|
||||
rangeNm: 6,
|
||||
});
|
||||
assert.ok(Math.abs(ahead.x) < 0.01, `x ${ahead.x}`);
|
||||
assert.ok(Math.abs(ahead.y - 0.5) < 0.01, `y ${ahead.y}`);
|
||||
assert.ok(Math.abs(ahead.distanceNm - 3) < 0.02);
|
||||
assert.ok(Math.abs(ahead.relativeBearingDeg) < 0.5);
|
||||
});
|
||||
|
||||
test('heading-up turns the picture with the bow', () => {
|
||||
// Heading east: a contact to the north is on the port beam, one to the
|
||||
// south on the starboard beam, one to the east dead ahead.
|
||||
const own = { ...OWN, headingDeg: 90 };
|
||||
const byId = Object.fromEntries(
|
||||
projectRadarContacts(own, [
|
||||
contact('N', 2, 0),
|
||||
contact('S', -2, 0),
|
||||
contact('E', 0, 2),
|
||||
]).map((entry) => [entry.id, entry]),
|
||||
);
|
||||
assert.ok(byId.N.x < -0.3 && Math.abs(byId.N.y) < 0.02, 'north is to port');
|
||||
assert.ok(
|
||||
byId.S.x > 0.3 && Math.abs(byId.S.y) < 0.02,
|
||||
'south is to starboard',
|
||||
);
|
||||
assert.ok(Math.abs(byId.E.x) < 0.02 && byId.E.y > 0.3, 'east is ahead');
|
||||
assert.ok(Math.abs(byId.S.relativeBearingDeg - 90) < 1);
|
||||
assert.ok(Math.abs(byId.N.relativeBearingDeg + 90) < 1);
|
||||
});
|
||||
|
||||
test('a contact to starboard is right of the centre', () => {
|
||||
const [starboard] = projectRadarContacts(OWN, [contact('B', 0, 1.5)]);
|
||||
assert.ok(starboard.x > 0.2, `x ${starboard.x}`);
|
||||
assert.ok(Math.abs(starboard.y) < 0.01);
|
||||
});
|
||||
|
||||
test('course is drawn relative to the bow and stays null when unknown', () => {
|
||||
const own = { ...OWN, headingDeg: 90 };
|
||||
const [crossing, drifting] = projectRadarContacts(own, [
|
||||
contact('A', 1, 0, { course: 180 }),
|
||||
contact('B', 2, 0, { course: null }),
|
||||
]);
|
||||
assert.equal(crossing.relativeCourseDeg, 90);
|
||||
assert.equal(drifting.relativeCourseDeg, null);
|
||||
});
|
||||
|
||||
test('contacts beyond the range are left out', () => {
|
||||
const contacts = projectRadarContacts(
|
||||
OWN,
|
||||
[contact('in', 5.9, 0), contact('out', 6.2, 0), contact('far', 0, 30)],
|
||||
{ rangeNm: 6 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
contacts.map((entry) => entry.id),
|
||||
['in'],
|
||||
);
|
||||
assert.ok(contacts.every((entry) => Math.hypot(entry.x, entry.y) <= 1));
|
||||
});
|
||||
|
||||
test('contacts come nearest first, capped', () => {
|
||||
const many = Array.from({ length: RADAR_MAX_CONTACTS + 15 }, (_, index) =>
|
||||
contact(`V${index}`, 0, 5.5 - index * 0.07),
|
||||
);
|
||||
const contacts = projectRadarContacts(OWN, many, { rangeNm: 6 });
|
||||
assert.equal(contacts.length, RADAR_MAX_CONTACTS);
|
||||
for (let index = 1; index < contacts.length; index += 1)
|
||||
assert.ok(contacts[index - 1].distanceNm <= contacts[index].distanceNm);
|
||||
assert.equal(
|
||||
projectRadarContacts(OWN, many, { rangeNm: 6, maxCount: 3 }).length,
|
||||
3,
|
||||
'a smaller cap is honoured',
|
||||
);
|
||||
});
|
||||
|
||||
test('a picture carries the range, rings, heading and own course over ground', () => {
|
||||
const picture = radarPicture(
|
||||
{ ...OWN, headingDeg: 350, courseDeg: 10 },
|
||||
[contact('A', 1, 0)],
|
||||
12,
|
||||
);
|
||||
assert.equal(picture.rangeNm, 12);
|
||||
assert.deepEqual(picture.ringsNm, [3, 6, 9, 12]);
|
||||
assert.equal(picture.headingDeg, 350);
|
||||
assert.equal(picture.ownRelativeCourseDeg, 20);
|
||||
assert.equal(picture.contacts.length, 1);
|
||||
assert.ok(picture.contacts[0].x > 0, 'north is to starboard of 350°');
|
||||
assert.equal(radarPicture(OWN, [], 6).ownRelativeCourseDeg, null);
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* Camera policy for first-person subjects other than aircraft: where a
|
||||
* vessel's bridge eye sits and how its reported fix advances between feed
|
||||
* refreshes. No DOM or i18n dependencies, so the policy is unit-testable.
|
||||
*/
|
||||
import { projectGroundArcLatLon } from './data/motionModel.js';
|
||||
|
||||
/** Bridge cameras look along the horizon; a ship's eye is already high. */
|
||||
export const VESSEL_BRIDGE_PITCH_DEG = -2;
|
||||
|
||||
/** The AIS antenna usually sits on the bridge, so the eye is not moved forward. */
|
||||
export const VESSEL_BRIDGE_FORWARD_OFFSET_M = 0;
|
||||
|
||||
/** Clearance over the shared ground floor for a camera that floats on water. */
|
||||
export const VESSEL_BRIDGE_MIN_CLEARANCE_M = 2;
|
||||
|
||||
/** Eye height used when the vessel layer knows no height for the type. */
|
||||
export const VESSEL_DEFAULT_EYE_HEIGHT_M = 12;
|
||||
|
||||
/**
|
||||
* A reported fix older than this stops advancing: the vessel holds where the
|
||||
* last projection left it instead of sailing on through a feed outage.
|
||||
*/
|
||||
export const VESSEL_DEAD_RECKON_MAX_S = 180;
|
||||
|
||||
/**
|
||||
* The vessel layer floats every ship on the geoid (mean sea level), but a
|
||||
* river or lake can sit well above it: the Seine through Paris is ~27 m up,
|
||||
* which put a bridge eye under the water. The bridge reads the rendered
|
||||
* surface around its ship instead: probes on a ring this far out, plus one
|
||||
* at the ship itself.
|
||||
*/
|
||||
export const VESSEL_WATERLINE_PROBE_RADIUS_M = 30;
|
||||
|
||||
/** Probes on the ring around the ship. */
|
||||
export const VESSEL_WATERLINE_PROBE_COUNT = 8;
|
||||
|
||||
/** How often the bridge reads the water under its ship again, ms. */
|
||||
export const VESSEL_WATERLINE_REFRESH_MS = 5000;
|
||||
|
||||
/** A surface less than this above sea level is the sea itself, metres. */
|
||||
export const VESSEL_WATERLINE_MIN_RISE_M = 2;
|
||||
|
||||
/** Highest navigable water trusted from a sample (Lake Titicaca ~3,810 m). */
|
||||
export const VESSEL_WATERLINE_MAX_RISE_M = 4000;
|
||||
|
||||
/**
|
||||
* Where to read the surface around a ship: the ship's own position, then a
|
||||
* ring of probes around it.
|
||||
* @param {number} latitude
|
||||
* @param {number} longitude
|
||||
* @returns {Array<{lat: number, lon: number}>}
|
||||
*/
|
||||
export function waterlineProbePoints(
|
||||
latitude,
|
||||
longitude,
|
||||
radiusM = VESSEL_WATERLINE_PROBE_RADIUS_M,
|
||||
count = VESSEL_WATERLINE_PROBE_COUNT,
|
||||
) {
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return [];
|
||||
const points = [{ lat: latitude, lon: longitude }];
|
||||
const metresPerDegLat = 111_320;
|
||||
const metresPerDegLon =
|
||||
metresPerDegLat * Math.max(0.01, Math.cos((latitude * Math.PI) / 180));
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const bearing = (2 * Math.PI * i) / count;
|
||||
points.push({
|
||||
lat: latitude + (radiusM * Math.cos(bearing)) / metresPerDegLat,
|
||||
lon: longitude + (radiusM * Math.sin(bearing)) / metresPerDegLon,
|
||||
});
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* The water surface a bridge floats on, ellipsoidal metres. Quays, bridges,
|
||||
* moored boats and the ship's own model all stand above the water, so the
|
||||
* lowest probe is the water. A lowest probe barely above sea level is the
|
||||
* sea; one implausibly high is a bad sample. Either way the geoid stands.
|
||||
* @param {number} seaSurfaceM The geoid sea surface the layer floats ships on.
|
||||
* @param {Array<number|null|undefined>} sampleHeightsM Rendered-surface probes.
|
||||
* @returns {number}
|
||||
*/
|
||||
export function vesselWaterlineM(seaSurfaceM, sampleHeightsM) {
|
||||
const heights = (sampleHeightsM || []).filter(Number.isFinite);
|
||||
if (!Number.isFinite(seaSurfaceM) || !heights.length) return seaSurfaceM;
|
||||
const lowest = Math.min(...heights);
|
||||
const rise = lowest - seaSurfaceM;
|
||||
return rise >= VESSEL_WATERLINE_MIN_RISE_M &&
|
||||
rise <= VESSEL_WATERLINE_MAX_RISE_M
|
||||
? lowest
|
||||
: seaSurfaceM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Camera placement for a vessel subject, in the shape the Cockpit camera
|
||||
* reads for every subject kind.
|
||||
* @param {{eyeHeightM?: number|null}} info Vessel subject info.
|
||||
* @returns {{forwardM: number, upM: number, pitchDeg: number, minClearanceM: number}}
|
||||
*/
|
||||
export function vesselCameraProfile(info) {
|
||||
return {
|
||||
forwardM: VESSEL_BRIDGE_FORWARD_OFFSET_M,
|
||||
upM: Number.isFinite(info?.eyeHeightM)
|
||||
? info.eyeHeightM
|
||||
: VESSEL_DEFAULT_EYE_HEIGHT_M,
|
||||
pitchDeg: VESSEL_BRIDGE_PITCH_DEG,
|
||||
minClearanceM: VESSEL_BRIDGE_MIN_CLEARANCE_M,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* How old a vessel's fix is now, seconds, or null without a fix time.
|
||||
*
|
||||
* The fix time is on the AIS feed's clock and `nowMs` on this client's, so
|
||||
* comparing them directly would turn a client clock running minutes fast
|
||||
* into minutes of age (every bridge projecting its full limit and reading
|
||||
* STALE FEED). With the time this client first received the fix (client
|
||||
* clock) and the newest fix in that refresh (feed clock), the age is the
|
||||
* time since the fix arrived plus how far it trailed that newest fix; each
|
||||
* part is read on one clock. A fix the feed keeps re-serving keeps its first
|
||||
* receipt, so it ages into STALE FEED. The age leaves out the newest fix's
|
||||
* own delay before the refresh, typically seconds: a feed that was already
|
||||
* stalled when the bridge first saw it ages from then. Without them it falls
|
||||
* back to the clock difference, never negative.
|
||||
* @param {{fixEpochMs: number|null, receivedAtMs?: number|null,
|
||||
* feedEpochMs?: number|null}} fix
|
||||
* @param {number} nowMs Wall-clock time, ms.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
export function vesselFixAgeSec(fix, nowMs) {
|
||||
const { fixEpochMs, receivedAtMs, feedEpochMs } = fix || {};
|
||||
if (!Number.isFinite(fixEpochMs) || !Number.isFinite(nowMs)) return null;
|
||||
if (Number.isFinite(receivedAtMs) && Number.isFinite(feedEpochMs))
|
||||
return (
|
||||
Math.max(0, (nowMs - receivedAtMs) / 1000) +
|
||||
Math.max(0, (feedEpochMs - fixEpochMs) / 1000)
|
||||
);
|
||||
return Math.max(0, (nowMs - fixEpochMs) / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance a vessel's last reported fix along its course over ground.
|
||||
*
|
||||
* The vessel layer redraws a ship only when the feed refreshes, so its icon
|
||||
* stands still between fixes. A camera converging on that still point would
|
||||
* sail backwards against its own motion. Projecting the fix forward gives the
|
||||
* Cockpit anchor a moving target, and the bounded age keeps a silent feed from
|
||||
* carrying the ship away indefinitely.
|
||||
* @param {{latitude: number, longitude: number, courseDeg: number|null,
|
||||
* speedMps: number|null, fixEpochMs: number|null,
|
||||
* receivedAtMs?: number|null, feedEpochMs?: number|null}} fix Last
|
||||
* report; its age is `vesselFixAgeSec`.
|
||||
* @param {number} nowMs Wall-clock time, ms.
|
||||
* @returns {{latitude: number, longitude: number, ageSec: number|null}}
|
||||
*/
|
||||
export function deadReckonVesselFix(fix, nowMs) {
|
||||
const { latitude, longitude, courseDeg, speedMps } = fix || {};
|
||||
const ageSec = vesselFixAgeSec(fix, nowMs);
|
||||
if (ageSec === null) return { latitude, longitude, ageSec: null };
|
||||
const { lat, lon } = projectGroundArcLatLon(
|
||||
latitude,
|
||||
longitude,
|
||||
courseDeg,
|
||||
speedMps,
|
||||
0,
|
||||
Math.min(ageSec, VESSEL_DEAD_RECKON_MAX_S),
|
||||
);
|
||||
return { latitude: lat, longitude: lon, ageSec };
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// src/cockpitSubjects.test.mjs — the vessel camera profile and dead
|
||||
// reckoning of AIS fixes between feed refreshes.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
VESSEL_BRIDGE_MIN_CLEARANCE_M,
|
||||
VESSEL_BRIDGE_PITCH_DEG,
|
||||
VESSEL_DEAD_RECKON_MAX_S,
|
||||
VESSEL_DEFAULT_EYE_HEIGHT_M,
|
||||
VESSEL_WATERLINE_PROBE_RADIUS_M,
|
||||
deadReckonVesselFix,
|
||||
vesselFixAgeSec,
|
||||
vesselCameraProfile,
|
||||
vesselWaterlineM,
|
||||
waterlineProbePoints,
|
||||
} from './cockpitSubjects.js';
|
||||
|
||||
const EARTH_M_PER_DEG_LAT = 111_195;
|
||||
|
||||
test('the vessel camera profile puts the eye at the bridge, looking at the horizon', () => {
|
||||
assert.deepEqual(vesselCameraProfile({ eyeHeightM: 30 }), {
|
||||
forwardM: 0,
|
||||
upM: 30,
|
||||
pitchDeg: VESSEL_BRIDGE_PITCH_DEG,
|
||||
minClearanceM: VESSEL_BRIDGE_MIN_CLEARANCE_M,
|
||||
});
|
||||
});
|
||||
|
||||
test('a vessel with no known eye height uses the default', () => {
|
||||
for (const info of [{}, { eyeHeightM: null }, null]) {
|
||||
assert.equal(vesselCameraProfile(info).upM, VESSEL_DEFAULT_EYE_HEIGHT_M);
|
||||
}
|
||||
});
|
||||
|
||||
test('a fix advances along course over ground for its age', () => {
|
||||
const fixEpochMs = 1_000_000;
|
||||
const advanced = deadReckonVesselFix(
|
||||
{
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
courseDeg: 0,
|
||||
speedMps: 10,
|
||||
fixEpochMs,
|
||||
},
|
||||
fixEpochMs + 60_000,
|
||||
);
|
||||
assert.equal(advanced.ageSec, 60);
|
||||
// Sixty seconds north at 10 m/s.
|
||||
assert.ok(
|
||||
Math.abs(advanced.latitude * EARTH_M_PER_DEG_LAT - 600) < 2,
|
||||
`expected ~600 m north, got ${advanced.latitude * EARTH_M_PER_DEG_LAT}`,
|
||||
);
|
||||
assert.ok(Math.abs(advanced.longitude) < 1e-9);
|
||||
});
|
||||
|
||||
test('projection stops at the dead-reckoning limit', () => {
|
||||
const fixEpochMs = 0;
|
||||
const at = (ageSec) =>
|
||||
deadReckonVesselFix(
|
||||
{ latitude: 0, longitude: 0, courseDeg: 90, speedMps: 5, fixEpochMs },
|
||||
fixEpochMs + ageSec * 1000,
|
||||
);
|
||||
const limit = at(VESSEL_DEAD_RECKON_MAX_S);
|
||||
const later = at(VESSEL_DEAD_RECKON_MAX_S * 4);
|
||||
assert.equal(later.longitude, limit.longitude);
|
||||
assert.equal(later.ageSec, VESSEL_DEAD_RECKON_MAX_S * 4);
|
||||
});
|
||||
|
||||
test('a fix without time, course or speed stays where it was reported', () => {
|
||||
const fix = {
|
||||
latitude: 54.3,
|
||||
longitude: 10.1,
|
||||
courseDeg: null,
|
||||
speedMps: 6,
|
||||
fixEpochMs: 0,
|
||||
};
|
||||
assert.deepEqual(deadReckonVesselFix(fix, 30_000), {
|
||||
latitude: 54.3,
|
||||
longitude: 10.1,
|
||||
ageSec: 30,
|
||||
});
|
||||
assert.deepEqual(
|
||||
deadReckonVesselFix({ ...fix, courseDeg: 90, fixEpochMs: null }, 30_000),
|
||||
{ latitude: 54.3, longitude: 10.1, ageSec: null },
|
||||
);
|
||||
});
|
||||
|
||||
test('waterline probes ring the ship at the probe radius', () => {
|
||||
const points = waterlineProbePoints(48.859, 2.293);
|
||||
assert.equal(points.length, 9);
|
||||
assert.deepEqual(points[0], { lat: 48.859, lon: 2.293 });
|
||||
for (const point of points.slice(1)) {
|
||||
const northM = (point.lat - 48.859) * 111_320;
|
||||
const eastM =
|
||||
(point.lon - 2.293) * 111_320 * Math.cos((48.859 * Math.PI) / 180);
|
||||
assert.ok(
|
||||
Math.abs(Math.hypot(northM, eastM) - VESSEL_WATERLINE_PROBE_RADIUS_M) <
|
||||
0.01,
|
||||
);
|
||||
}
|
||||
assert.deepEqual(waterlineProbePoints(Number.NaN, 2), []);
|
||||
});
|
||||
|
||||
test('the waterline is the lowest probe when it stands clear of the sea', () => {
|
||||
// The Seine at the Eiffel Tower: geoid 44.6 m, water about 71 m.
|
||||
assert.equal(vesselWaterlineM(44.6, [80, 71.2, 75, null, 90]), 71.2);
|
||||
// Swell and tile noise are the sea itself.
|
||||
assert.equal(vesselWaterlineM(44.6, [45.5, 46]), 44.6);
|
||||
// Under the sea surface (a dock, a bad tile) never sinks the ship.
|
||||
assert.equal(vesselWaterlineM(44.6, [30, 71]), 44.6);
|
||||
// Nothing sampled, or implausibly high.
|
||||
assert.equal(vesselWaterlineM(44.6, [undefined, Number.NaN]), 44.6);
|
||||
assert.equal(vesselWaterlineM(44.6, [9000]), 44.6);
|
||||
});
|
||||
|
||||
test("a fix's age is read on each clock, so a fast client clock adds none", () => {
|
||||
const fixEpochMs = 1_800_000_000_000;
|
||||
// The refresh's newest fix is 10 s newer than this one; the client, its
|
||||
// clock five minutes fast, received the refresh 5 s ago.
|
||||
const receivedAtMs = fixEpochMs + 12_000 + 300_000;
|
||||
const fix = {
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
courseDeg: 90,
|
||||
speedMps: 5,
|
||||
fixEpochMs,
|
||||
receivedAtMs,
|
||||
feedEpochMs: fixEpochMs + 10_000,
|
||||
};
|
||||
assert.equal(vesselFixAgeSec(fix, receivedAtMs + 5000), 15);
|
||||
assert.equal(deadReckonVesselFix(fix, receivedAtMs + 5000).ageSec, 15);
|
||||
// Without receive data the clocks are compared, never below zero.
|
||||
const bare = { fixEpochMs };
|
||||
assert.equal(vesselFixAgeSec(bare, fixEpochMs + 60_000), 60);
|
||||
assert.equal(vesselFixAgeSec(bare, fixEpochMs - 60_000), 0);
|
||||
assert.equal(vesselFixAgeSec({ fixEpochMs: null }, fixEpochMs), null);
|
||||
});
|
||||
@@ -65,7 +65,8 @@ export function enterCockpitWithTracking({
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!entryError) entered = Boolean(cockpitView.enter());
|
||||
// This transaction selects aircraft; a selected vessel must not stand in.
|
||||
if (!entryError) entered = Boolean(cockpitView.enter({ kind: 'aircraft' }));
|
||||
} catch (error) {
|
||||
entryThrew = true;
|
||||
entryError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
@@ -445,6 +445,42 @@ export function cockpitEntryAllowed({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the selected vessel's bridge view can be entered. Like aircraft
|
||||
* Cockpit it needs only its own layer, Live AIS Vessels, with a vessel
|
||||
* selected. A Context mode change in flight still owns the camera, so entry
|
||||
* waits it out.
|
||||
* @param {{vesselsEnabled: boolean, contextModeChanging: boolean}} state
|
||||
* @returns {boolean}
|
||||
*/
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
bridgeEntryAllowed,
|
||||
orbitEntryAllowed,
|
||||
cockpitEntryAllowed,
|
||||
contextAllowedLayerIds,
|
||||
contextLayerEnableBlockReason,
|
||||
@@ -426,6 +428,30 @@ test("cockpit entry needs only the tracked aircraft's own layer", () => {
|
||||
subjectLayerEnabled: false,
|
||||
contextModeChanging: false,
|
||||
}), false, 'there is no aircraft to ride');
|
||||
assert.equal(cockpitEntryAllowed({
|
||||
subjectLayerId: 'ais-live-vessels',
|
||||
subjectLayerEnabled: true,
|
||||
contextModeChanging: false,
|
||||
}), false, "a vessel is the bridge gate's subject, not Cockpit's");
|
||||
});
|
||||
|
||||
test('bridge entry needs only Live AIS Vessels', () => {
|
||||
assert.equal(bridgeEntryAllowed({ vesselsEnabled: true, contextModeChanging: false }), true);
|
||||
assert.equal(bridgeEntryAllowed({ vesselsEnabled: false, contextModeChanging: false }), false);
|
||||
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 () => {
|
||||
|
||||
@@ -989,6 +989,22 @@ test('vessel interaction wire: trail pick does not deselect', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel interaction wire: a click on open water keeps the ship the bridge rides', () => {
|
||||
const harness = installWireHarness({ id: 'gev-trailing-contact' });
|
||||
try {
|
||||
aisLiveVesselsLayer.setCockpitSubject(harness.record.mmsi);
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||||
assert.equal(harness.trail.clearCalls, 0);
|
||||
// Released, the same click deselects as it always has.
|
||||
aisLiveVesselsLayer.setCockpitSubject(null);
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel interaction wire: only the gev-trail: namespace receives the trail no-op', () => {
|
||||
const harness = installWireHarness({ id: 'gev-trailing-contact' });
|
||||
try {
|
||||
|
||||
+14
-1
@@ -143,10 +143,23 @@ export const DATA_CREDITS = [
|
||||
{
|
||||
key: 'celestrak',
|
||||
html:
|
||||
'Satellites (TLEs): CelesTrak ' +
|
||||
'Satellites (TLEs and SATCAT): CelesTrak ' +
|
||||
'(<a href="https://celestrak.org" target="_blank" rel="noopener">celestrak.org</a>), ' +
|
||||
'Dr. T.S. Kelso',
|
||||
},
|
||||
{
|
||||
key: 'ucs-satellite-database',
|
||||
html:
|
||||
'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',
|
||||
},
|
||||
{
|
||||
key: 'imaging-sensors',
|
||||
html:
|
||||
'Imaging sensor swaths: operator and agency specifications, ' +
|
||||
'cited per platform',
|
||||
},
|
||||
{
|
||||
key: 'launch-library-2',
|
||||
html:
|
||||
|
||||
@@ -38,6 +38,29 @@ test('adsbdb is credited and carries its published route-data restriction', () =
|
||||
assert.match(credit.html, /href="https:\/\/www\.adsbdb\.com"/);
|
||||
});
|
||||
|
||||
test('the UCS Satellite Database is acknowledged with its version and webpage', () => {
|
||||
// UCS database manual, section 3: references name the version (the Excel
|
||||
// file's name) and link www.ucsusa.org/satellite_database.
|
||||
const credit = DATA_CREDITS.find(
|
||||
(entry) => entry.key === 'ucs-satellite-database',
|
||||
);
|
||||
assert.ok(credit, 'the satellite classes and operators come from UCS');
|
||||
assert.match(credit.html, /UCS-Satellite-Database 5-1-2023/);
|
||||
assert.match(
|
||||
credit.html,
|
||||
/href="https:\/\/www\.ucsusa\.org\/satellite_database"/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the imaging sensor swaths credit operator and agency specifications', () => {
|
||||
// Each sensor-pack entry cites its operator's or agency's own pages;
|
||||
// no aggregator database is credited.
|
||||
const credit = DATA_CREDITS.find((entry) => entry.key === 'imaging-sensors');
|
||||
assert.ok(credit, 'the orbital zoom caps come from the sensor pack');
|
||||
assert.match(credit.html, /operator and agency specifications/);
|
||||
assert.doesNotMatch(credit.html, /OSCAR|wmo\.int/);
|
||||
});
|
||||
|
||||
test('OpenStreetMap has one generic data credit with separate tile and names distributors', () => {
|
||||
const osm = DATA_CREDITS.filter((entry) =>
|
||||
entry.html.includes('openstreetmap.org/copyright'),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @file The bundled imaging-sensor pack: swath and resolution for the main
|
||||
* Earth-observation platforms, matched to satellites by NORAD number or name.
|
||||
*
|
||||
* Data: `local_data/imaging_sensors/imaging_sensors.json`; sources, licences
|
||||
* and curation in that folder's README. Loaded once, on demand, by the
|
||||
* satellites layer; a failed load is retried after a cooldown rather than
|
||||
* remembered for the session.
|
||||
*
|
||||
* @module data/imagingSensors
|
||||
*/
|
||||
|
||||
import { indexImagingSensors } from '../layers/satellites/sensors.js';
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
import { createRetryableLoader } from './retryableLoad.js';
|
||||
|
||||
const PACK_URL = new URL(
|
||||
'./local_data/imaging_sensors/imaging_sensors.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
/** The pack, loaded and indexed once. */
|
||||
export const loadImagingSensors = createRetryableLoader(async () =>
|
||||
indexImagingSensors(await loadBundledJson(PACK_URL)),
|
||||
);
|
||||
@@ -2,6 +2,7 @@
|
||||
// Local, not i18n/marker.js: the token-ledger tests run this file in isolation.
|
||||
const msg = (text) => text;
|
||||
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
|
||||
import { normalizeSatelliteClassMask } from './satelliteClassMask.js';
|
||||
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
||||
|
||||
const VALID_DISPOSITIONS = new Set([
|
||||
@@ -416,6 +417,16 @@ const OPTION_GROUPS = Object.freeze({
|
||||
dense: 'd',
|
||||
}),
|
||||
integerOption('selectedSatTrackingId', 't', null),
|
||||
// Every class but Starlink shows by default (satellites/classes.js).
|
||||
Object.freeze({
|
||||
key: 'classes',
|
||||
token: 'k',
|
||||
defaultValue: 'sitpd',
|
||||
normalize: normalizeSatelliteClassMask,
|
||||
encode: (value) => value,
|
||||
decode: (value) =>
|
||||
typeof value === 'string' ? normalizeSatelliteClassMask(value) : null,
|
||||
}),
|
||||
]),
|
||||
cctv: Object.freeze([
|
||||
enumOption('coverageMode', 'c', 'on', ['off', 'on', 'viewshed'], {
|
||||
|
||||
@@ -970,6 +970,41 @@ test('a direct Context selection promotes its owned tracker layer before persist
|
||||
coordinator.destroy();
|
||||
});
|
||||
|
||||
test('a satellite class choice is saved, shared and restored', async () => {
|
||||
const storage = memoryStorage();
|
||||
const manager = productionManager();
|
||||
const coordinator = new LayerStateCoordinator(manager, shareSink(), {
|
||||
storage,
|
||||
});
|
||||
await coordinator.start();
|
||||
await manager.setEnabled('satellites', true, { origin: 'user' });
|
||||
assert.equal(
|
||||
coordinator.getDurableState().options.satellites.classes,
|
||||
'sitpd',
|
||||
'every class but Starlink by default',
|
||||
);
|
||||
manager.setLayerParams(
|
||||
'satellites',
|
||||
{ classes: { debris: false } },
|
||||
{ origin: 'user' },
|
||||
);
|
||||
const durable = coordinator.getDurableState();
|
||||
assert.equal(durable.options.satellites.classes, 'sitp');
|
||||
assert.equal(
|
||||
new URLSearchParams(encode(durable)).get('lo')?.includes('s.k.sitp'),
|
||||
true,
|
||||
);
|
||||
coordinator.destroy();
|
||||
|
||||
// A reload hands the layer its letters back (the layer decodes them:
|
||||
// src/data/satelliteClass.test.mjs).
|
||||
const reloaded = productionManager();
|
||||
const next = new LayerStateCoordinator(reloaded, shareSink(), { storage });
|
||||
await next.start();
|
||||
assert.equal(reloaded.getLayerParams('satellites')?.classes, 'sitp');
|
||||
next.destroy();
|
||||
});
|
||||
|
||||
test('explicit Flight to Satellite replacement keeps the new Satellite ID durable', async () => {
|
||||
const manager = productionManager();
|
||||
const coordinator = new LayerStateCoordinator(manager, shareSink(), {
|
||||
|
||||
@@ -102,12 +102,13 @@ const preWildlifeRows = reservationRows.filter(
|
||||
);
|
||||
|
||||
function writeCodecFixture(cwd, rows, entries) {
|
||||
const providerCodec = path.join(cwd, 'src/data/idSwitches.js');
|
||||
mkdirSync(path.dirname(providerCodec), { recursive: true });
|
||||
writeFileSync(
|
||||
providerCodec,
|
||||
readFileSync(new URL('./idSwitches.js', import.meta.url)),
|
||||
);
|
||||
mkdirSync(path.join(cwd, 'src/data'), { recursive: true });
|
||||
// The codec's own imports, beside it.
|
||||
for (const name of ['idSwitches.js', 'satelliteClassMask.js'])
|
||||
writeFileSync(
|
||||
path.join(cwd, 'src/data', name),
|
||||
readFileSync(new URL(`./${name}`, import.meta.url)),
|
||||
);
|
||||
let source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
|
||||
if (!rows.some(([id]) => id === 'wildlife-sightings'))
|
||||
source = source.replace(
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Imaging sensor pack
|
||||
|
||||
Swath widths and ground resolutions for 46 Earth-observation platforms and
|
||||
constellations, matched to satellites by NORAD catalog number or by their
|
||||
CelesTrak name. The orbital view draws a satellite's sensor swath to scale and
|
||||
lets it zoom past 8x only as far as that swath (`src/layers/satellites/sensors.js`,
|
||||
`src/data/imagingSensors.js`, `orbitalMaxZoom` in `src/cockpitOrbital.js`).
|
||||
|
||||
| File | Content | Entries |
|
||||
|------|---------|---------|
|
||||
| `imaging_sensors.json` | Platform, match rule, operator, instruments, altitude, imaging modes (swath km, best ground resolution m), narrowest standard swath, sources, unverified fields | 46 |
|
||||
|
||||
**Compiled:** 2026-11-04, by hand from the pages cited per entry (`sources`).
|
||||
Each match pattern was checked against CelesTrak's active list that day.
|
||||
Altitude is the nominal value from a cited source; where none was found it is
|
||||
the median altitude of the matching satellites from CelesTrak GP data, noted in
|
||||
`unverified`. The app zooms on the live altitude, not this one.
|
||||
|
||||
**Narrowest standard swath** is the narrowest continuous standard imaging swath
|
||||
or scene width (cross-track); for a geostationary imager, the narrower side of
|
||||
its smallest standard scan area. Some listed modes are left out of it:
|
||||
|
||||
- Sampling modes: SAR wave vignettes, and Himawari's Landmark Area scans
|
||||
(1000 × 500 km), so Himawari's narrowest is the 1000 km Target Area.
|
||||
- Generations the operator has retired: PlanetScope's Dove Classic and Dove-R
|
||||
(about 25 km scenes, imagery until April 2022 per Planet) stay in `modes`,
|
||||
while its narrowest is the SuperDove's 32.5 km. The `^FLOCK ` name rule
|
||||
would also match those older Doves.
|
||||
|
||||
**Unverified values.** A value not confirmed on an opened page is named in the
|
||||
entry's `unverified` list. `swathConfirmed` is false when the narrowest swath is
|
||||
missing or unverified; such a platform keeps the base 1–8x zoom steps:
|
||||
|
||||
- Landsat 9 (swath inferred from Landsat 8), ResourceSat-2 (LISS-3 swath),
|
||||
SuperView Neo-1 (12 km from search snippets only), BlackSky and Meteosat
|
||||
MTG FCI (no swath found).
|
||||
|
||||
## Sources and licences
|
||||
|
||||
The values are individual specification facts, each cited from the
|
||||
operator's or agency's official pages listed per entry in `sources`. No text or
|
||||
images are copied. The main sources:
|
||||
|
||||
- **Operator and agency pages and spec sheets** (NASA, USGS, NOAA/NESDIS,
|
||||
ESA SentiWiki and Earth Online, EUMETSAT, JAXA, ISRO/NRSC, INPE, CSA, DLR,
|
||||
JMA, ASI, KARI, China's Ministry of Natural Resources (LASAC), Planet,
|
||||
Vantor (formerly Maxar) datasheet pages, L3Harris, China Siwei, BlackSky,
|
||||
Airbus and KOMPSAT-5 via the Disasters Charter, ICEYE, Capella, Umbra,
|
||||
Satellogic, Chang Guang).
|
||||
- **Academic papers and proceedings** (Sensors papers on Gaofen-3, KARI's
|
||||
ACRS 2012 paper on KOMPSAT-3, CAST's IAC-16 abstract on Gaofen-2).
|
||||
- **eoPortal** (ESA's mission directory, an institutional source): a swath
|
||||
read there counts as confirmed. It is cited beside the operator's and
|
||||
agency's pages, and alone for values they do not give (the wider modes of
|
||||
Gaofen-3 and COSMO-SkyMed Second Generation, and a few other entries).
|
||||
Gaofen-1 takes eoPortal's 69 km PMS and 830 km WFV swaths by maintainer
|
||||
choice; a 2019 Chinese lecture and a Sensors paper give 60 and 800 km.
|
||||
- **CEOS MIM Database** (https://database.eohandbook.com): CC BY 4.0 since
|
||||
7 September 2026, with the credit "CEOS MIM Database,
|
||||
https://database.eohandbook.com". No entry relies on it yet; credit it if
|
||||
values are taken from it later.
|
||||
|
||||
Listed in DATA_SOURCES.md and in the app's Data attribution popover.
|
||||
@@ -0,0 +1 @@
|
||||
{"version":1,"generated":"2026-11-04","entries":[{"id":"landsat-8-9","label":"Landsat 8 / Landsat 9","match":{"norad":[39084,49260],"nameRegex":"^LANDSAT [89]$"},"operator":"NASA / USGS","sensor":"OLI / OLI-2 / TIRS / TIRS-2","type":"multispectral / thermal","altitudeKm":705,"modes":[{"name":"OLI / OLI-2 · Standard scene","swathKm":185,"gsdM":15},{"name":"TIRS / TIRS-2 · Standard scene","swathKm":185,"gsdM":100}],"narrowestSwathKm":185,"swathConfirmed":false,"sources":["https://www.usgs.gov/landsat-missions/landsat-8","https://www.usgs.gov/landsat-missions/landsat-9"],"unverified":["Landsat 9 swath (inferred from Landsat 8)"]},{"id":"sentinel-2","label":"Sentinel-2A/B/C","match":{"norad":[40697,42063,60989],"nameRegex":"^SENTINEL-2[A-D]$"},"operator":"ESA / EU Copernicus","sensor":"MSI","type":"multispectral","altitudeKm":786,"modes":[{"name":"MSI · Nominal","swathKm":290,"gsdM":10}],"narrowestSwathKm":290,"swathConfirmed":true,"sources":["https://sentiwiki.copernicus.eu/web/s2-mission"],"unverified":[]},{"id":"sentinel-1","label":"Sentinel-1A/C/D","match":{"norad":[39634,62261,66315],"nameRegex":"^SENTINEL-1[A-D]$"},"operator":"ESA / EU Copernicus","sensor":"C-SAR","type":"SAR","altitudeKm":693,"modes":[{"name":"C-SAR · Stripmap (SM)","swathKm":80,"gsdM":5},{"name":"C-SAR · Interferometric Wide (IW)","swathKm":250,"gsdM":5},{"name":"C-SAR · Extra Wide (EW)","swathKm":400,"gsdM":20},{"name":"C-SAR · Wave (WV)","swathKm":null,"gsdM":5}],"narrowestSwathKm":80,"swathConfirmed":true,"sources":["https://sentiwiki.copernicus.eu/web/s1-mission"],"unverified":[]},{"id":"sentinel-3","label":"Sentinel-3A/B/C","match":{"norad":[41335,43437,100690],"nameRegex":"^SENTINEL-3[A-D]$"},"operator":"ESA / EUMETSAT (Copernicus)","sensor":"OLCI / SLSTR","type":"multispectral / thermal","altitudeKm":814.5,"modes":[{"name":"OLCI · Full resolution","swathKm":1270,"gsdM":300},{"name":"SLSTR · Nadir view","swathKm":1400,"gsdM":500},{"name":"SLSTR · Oblique view","swathKm":740,"gsdM":500}],"narrowestSwathKm":740,"swathConfirmed":true,"sources":["https://sentiwiki.copernicus.eu/web/s3-mission"],"unverified":[]},{"id":"terra-aqua-modis","label":"Terra / Aqua","match":{"norad":[25994,27424],"nameRegex":"^(TERRA|AQUA)$"},"operator":"NASA","sensor":"MODIS","type":"multispectral","altitudeKm":705,"modes":[{"name":"MODIS · Cross-track scan","swathKm":2330,"gsdM":250}],"narrowestSwathKm":2330,"swathConfirmed":true,"sources":["https://ladsweb.modaps.eosdis.nasa.gov/missions-and-measurements/modis/"],"unverified":["Aqua altitude (assumed same as Terra)"]},{"id":"viirs","label":"Suomi NPP / NOAA-20 / NOAA-21","match":{"norad":[37849,43013,54234],"nameRegex":"^(SUOMI NPP|NOAA 2[01] \\(JPSS-[12]\\))$"},"operator":"NASA / NOAA (JPSS)","sensor":"VIIRS","type":"multispectral","altitudeKm":830,"modes":[{"name":"VIIRS · Cross-track scan","swathKm":3000,"gsdM":375}],"narrowestSwathKm":3000,"swathConfirmed":true,"sources":["https://ladsweb.modaps.eosdis.nasa.gov/missions-and-measurements/viirs/","https://www.earthdata.nasa.gov/data/catalog/laads-vj102img-2.1"],"unverified":[]},{"id":"goes-abi","label":"GOES-16/17/18/19 (GOES-R series)","match":{"norad":[41866,43226,51850,60133],"nameRegex":"^GOES 1[6-9]$"},"operator":"NOAA / NASA","sensor":"ABI","type":"multispectral","altitudeKm":35786,"modes":[{"name":"ABI · Full disk","swathKm":null,"gsdM":500},{"name":"ABI · CONUS","swathKm":3000,"gsdM":500},{"name":"ABI · Mesoscale sector","swathKm":1000,"gsdM":500}],"narrowestSwathKm":1000,"swathConfirmed":true,"sources":["https://www.nesdis.noaa.gov/advanced-baseline-imager-scan-mode-information"],"unverified":["altitudeKm (standard GEO value)"]},{"id":"himawari-ahi","label":"Himawari-8 / Himawari-9","match":{"norad":[40267,41836],"nameRegex":"^HIMAWARI-[89]$"},"operator":"JMA","sensor":"AHI","type":"multispectral","altitudeKm":35786,"modes":[{"name":"AHI · Full Disk","swathKm":11000,"gsdM":500},{"name":"AHI · Japan Area","swathKm":2400,"gsdM":500},{"name":"AHI · Target Area","swathKm":1000,"gsdM":500},{"name":"AHI · Landmark Area","swathKm":500,"gsdM":500}],"narrowestSwathKm":1000,"swathConfirmed":true,"sources":["https://www.data.jma.go.jp/mscweb/en/himawari89/space_segment/spsg_ahi.html"],"unverified":["altitudeKm (standard GEO value)"]},{"id":"mtg-fci","label":"Meteosat Third Generation imager (Meteosat-12 / MTG-I2)","match":{"norad":[54743,100530],"nameRegex":"^(METEOSAT-12|MTG-I\\d)"},"operator":"EUMETSAT","sensor":"FCI","type":"multispectral","altitudeKm":35786,"modes":[{"name":"FCI · Full disc","swathKm":null,"gsdM":1000},{"name":"FCI · Rapid scan (Europe, 1/4 disc)","swathKm":null,"gsdM":500}],"narrowestSwathKm":null,"swathConfirmed":false,"sources":["https://www.esa.int/Applications/Observing_the_Earth/Meteorological_missions/Meteosat/About_Meteosat_Third_Generation","https://www-cdn.eumetsat.int/files/2020-04/pdf_mtg_goesr_ws_1.pdf"],"unverified":["narrowestSwathKm","altitudeKm (standard GEO value)"]},{"id":"gaofen-1","label":"GaoLine truncated
|
||||
@@ -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
|
||||
@@ -0,0 +1,50 @@
|
||||
# UCS Satellite Database pack
|
||||
|
||||
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 |
|
||||
|------|----------------|---------|
|
||||
| `ucs_satellites.json` | UCS Satellite Database, release **UCS-Satellite-Database 5-1-2023** (data current through 2023-05-01) | 3,564 |
|
||||
|
||||
**Source:** Union of Concerned Scientists, https://www.ucsusa.org/satellite_database.
|
||||
The same release is mirrored on Hugging Face as
|
||||
`juliensimon/ucs-satellite-database` (identical rows). UCS has paused updates
|
||||
since this release, so satellites launched after May 2023 are not in it; the
|
||||
layer classifies those from CelesTrak SATCAT, CelesTrak groups and name
|
||||
patterns instead.
|
||||
|
||||
**License:** free, unrestricted use. 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."
|
||||
|
||||
**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.
|
||||
|
||||
**Build:** `python scripts/build-ucs-satellites.py "<path>/UCS-Satellite-Database 5-1-2023.xlsx"`
|
||||
(openpyxl). The file's `meta` header records the curation.
|
||||
|
||||
**Curation:**
|
||||
|
||||
- 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, 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.
|
||||
@@ -0,0 +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":{"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
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
+34
-59
@@ -1,23 +1,23 @@
|
||||
/**
|
||||
* Satellite classification — CelesTrak source group → operator-legible class.
|
||||
* Satellite labels and colours by CelesTrak source group, beneath the six
|
||||
* classes.
|
||||
*
|
||||
* The catalog already tags every satellite with the CelesTrak group it was
|
||||
* ingested from (`CATALOG_GROUPS` in satellites.js), so classification is a
|
||||
* pure lookup: no extra fetch, no heuristics, no per-frame work. This module is
|
||||
* the single source of truth for the class a satellite belongs to, the label
|
||||
* shown on its card, and the point color it renders in — the render path, the
|
||||
* tracked card, and the row legend all read the same table, so a palette edit
|
||||
* can never leave one surface disagreeing with another.
|
||||
* The class a satellite is filed under (STATIONS, IMAGING, TELECOM,
|
||||
* STARLINK, PROBES or DEBRIS) is decided in src/layers/satellites/classes.js
|
||||
* from SATCAT, the UCS database, names and the group, and that module owns
|
||||
* each class's colour, legend label and toggle. This module keeps the
|
||||
* group-based layer: the constellation subtype a card adds to the class
|
||||
* (`TELECOM · GPS`, `STATIONS · ISS`; satelliteClassText), the group's own
|
||||
* label for a satellite with no class (satelliteClassLabel), and the colour
|
||||
* per group (satelliteClassColor), which the layer's base point styles use
|
||||
* where a satellite has no class (src/layers/satellites/policy.js).
|
||||
*
|
||||
* Deliberately Cesium-free: colors are CSS hex strings that satellites.js
|
||||
* converts once at module load. That keeps this file unit-testable and lets the
|
||||
* legend swatches reuse the exact same strings the points are drawn with.
|
||||
*
|
||||
* Class order below is the legend order: crewed first (the objects people look
|
||||
* for), then the constellations users can actually name, then the grab-bag.
|
||||
* Deliberately Cesium-free: colors are CSS hex strings that the layer
|
||||
* converts once at module load, so this file stays unit-testable.
|
||||
*/
|
||||
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { SATELLITE_CLASS_DISPLAY } from '../layers/satellites/classes.js';
|
||||
|
||||
/**
|
||||
* Class registry: the point color of each class. The card and legend text is
|
||||
@@ -167,50 +167,25 @@ export function satelliteClassLabel(
|
||||
}
|
||||
|
||||
/**
|
||||
* Tally satellites into a class → count record.
|
||||
* Split from the legend builder so the counting rule stays independent of how
|
||||
* the legend is presented. Entries may be bare group tags, or `{ group, isIss }`
|
||||
* descriptors when the caller can identify the ISS — the tally classifies
|
||||
* through satelliteClassOf, so the legend and the card always agree.
|
||||
* @param {Iterable<string|undefined|null|{group?: string, isIss?: boolean}>} entries
|
||||
* One entry per satellite.
|
||||
* @returns {Record<string, number>} Class key → count.
|
||||
* The class a satellite shows on its card, label, overlay and orbital
|
||||
* readout: its class (src/layers/satellites/classes.js) with the CelesTrak group's
|
||||
* subtype where one says more ("TELECOM · GPS", "STATIONS · ISS"). A subtype
|
||||
* that only repeats the class ("STARLINK · STARLINK") is dropped. Without a
|
||||
* class the group's label stands (satelliteClassLabel).
|
||||
* @param {string|null|undefined} satelliteClass Six-class id.
|
||||
* @param {string|undefined|null} group CelesTrak group tag.
|
||||
* @param {{ isIss?: boolean, localize?: boolean }} [options] As for
|
||||
* satelliteClassLabel.
|
||||
* @returns {string} Display label.
|
||||
*/
|
||||
export function tallySatelliteClasses(entries) {
|
||||
const counts = Object.create(null);
|
||||
for (const entry of entries || []) {
|
||||
const descriptor =
|
||||
entry && typeof entry === 'object' ? entry : { group: entry };
|
||||
const { klass } = satelliteClassOf(descriptor.group, {
|
||||
isIss: descriptor.isIss,
|
||||
});
|
||||
counts[klass] = (counts[klass] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the layer-row legend from a class tally.
|
||||
* Classes with no members are omitted so the legend never advertises a class
|
||||
* that is not on screen (COMMS only appears once DENSE is on).
|
||||
* @param {Record<string, number>} counts Class key → count (see tallySatelliteClasses).
|
||||
* @returns {Array<{ klass: string, label: string, color: string, blurb: string, count: number }>}
|
||||
* Present classes in legend order.
|
||||
*/
|
||||
export function satelliteClassLegend(counts) {
|
||||
const result = [];
|
||||
for (const klass of SATELLITE_CLASS_ORDER) {
|
||||
const count = counts?.[klass];
|
||||
if (!(count > 0)) continue;
|
||||
const spec = SATELLITE_CLASSES[klass];
|
||||
const display = CLASS_DISPLAY_KEYS[klass];
|
||||
result.push({
|
||||
klass,
|
||||
label: t(display.label),
|
||||
color: spec.color,
|
||||
blurb: t(display.blurb),
|
||||
count,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
export function satelliteClassText(
|
||||
satelliteClass,
|
||||
group,
|
||||
{ isIss = false, localize = true } = {},
|
||||
) {
|
||||
const display = SATELLITE_CLASS_DISPLAY[satelliteClass];
|
||||
if (!display) return satelliteClassLabel(group, { isIss, localize });
|
||||
const base = localize ? t(display.label) : display.label;
|
||||
const { subtype } = satelliteClassOf(group, { isIss });
|
||||
return subtype && subtype !== display.label ? `${base} · ${subtype}` : base;
|
||||
}
|
||||
@@ -5,15 +5,15 @@ import {
|
||||
SATELLITE_CLASS_ORDER,
|
||||
satelliteClassColor,
|
||||
satelliteClassLabel,
|
||||
satelliteClassLegend,
|
||||
satelliteClassOf,
|
||||
tallySatelliteClasses,
|
||||
satelliteClassText,
|
||||
} from './satelliteClass.js';
|
||||
import satellitesLayer, {
|
||||
_catalogGroupForTest,
|
||||
_clearDenseCatalogStateForTest,
|
||||
_setDenseCatalogStateForTest,
|
||||
} from './satellites.js';
|
||||
import { CATALOG_GROUPS } from '../layers/satellites/policy.js';
|
||||
|
||||
/**
|
||||
* A real, parseable TLE under an arbitrary 5-digit catalog number, so the dense
|
||||
@@ -35,15 +35,19 @@ const ISS_TLE = tleFor('25544', 'ISS (ZARYA)');
|
||||
/** Poll the chip until the async dense load settles (or give up). */
|
||||
async function settleChip(maxTicks = 50) {
|
||||
for (let i = 0; i < maxTicks; i++) {
|
||||
const chip = satellitesLayer.getRowControls().chips[0];
|
||||
const chip = starlinkChip();
|
||||
if (chip && !chip.busy) return chip;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
return satellitesLayer.getRowControls().chips[0];
|
||||
return starlinkChip();
|
||||
}
|
||||
|
||||
/** The STARLINK class chip: the dense catalog toggle (id `catalog`). */
|
||||
const starlinkChip = () =>
|
||||
satellitesLayer.getRowControls().chips.find((entry) => entry.id === 'catalog');
|
||||
|
||||
/** Every CelesTrak group the catalog ingests, plus the dense-mode tag. */
|
||||
const INGESTED_GROUPS = ['stations', 'visual', 'gps-ops', 'glonass', 'galileo', 'geo', 'dense'];
|
||||
const INGESTED_GROUPS = [...CATALOG_GROUPS.map((group) => group.tag), 'dense'];
|
||||
|
||||
test('every ingested CelesTrak group resolves to a real class', () => {
|
||||
// Guards against drift: adding a group to CATALOG_GROUPS without classifying
|
||||
@@ -146,69 +150,7 @@ test('the dense shell stays dimmer than the class it sits among', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('tallying groups counts by class, folding the GNSS constellations together', () => {
|
||||
const counts = tallySatelliteClasses([
|
||||
'gps-ops', 'gps-ops', 'glonass', 'galileo',
|
||||
'geo', 'geo', 'geo',
|
||||
'stations',
|
||||
'visual',
|
||||
'dense', 'dense',
|
||||
]);
|
||||
assert.equal(counts.nav, 4, 'all three GNSS groups fold into NAV');
|
||||
assert.equal(counts.geo, 3);
|
||||
assert.equal(counts.station, 1);
|
||||
assert.equal(counts.visual, 1);
|
||||
assert.equal(counts.comms, 2);
|
||||
});
|
||||
|
||||
test('tallying tolerates an empty or missing input', () => {
|
||||
assert.deepEqual({ ...tallySatelliteClasses([]) }, {});
|
||||
assert.deepEqual({ ...tallySatelliteClasses(undefined) }, {});
|
||||
});
|
||||
|
||||
test('the tally files the ISS under STATION even when a partial outage moved it', () => {
|
||||
// The legend and the card must never disagree. With the stations feed down,
|
||||
// the ISS is ingested as `visual`; counting it there would let STATION vanish
|
||||
// from the legend while the tracked card still reads "STATION · ISS".
|
||||
const outage = tallySatelliteClasses([
|
||||
{ group: 'visual', isIss: true },
|
||||
{ group: 'visual' },
|
||||
{ group: 'visual' },
|
||||
]);
|
||||
assert.equal(outage.station, 1, 'the ISS is counted as a STATION');
|
||||
assert.equal(outage.visual, 2, 'and is not double-counted under VISUAL');
|
||||
|
||||
const legend = satelliteClassLegend(outage);
|
||||
assert.equal(legend.some((row) => row.klass === 'station'), true,
|
||||
'STATION stays in the legend, matching the card');
|
||||
|
||||
// Bare group tags still work — the descriptor form is additive.
|
||||
assert.deepEqual({ ...tallySatelliteClasses(['visual', 'visual']) }, { visual: 2 });
|
||||
assert.equal(satelliteClassOf('visual', { isIss: true }).klass, 'station',
|
||||
'one helper owns the rule for every consumer');
|
||||
});
|
||||
|
||||
test('the legend lists present classes in order and omits absent ones', () => {
|
||||
const legend = satelliteClassLegend(tallySatelliteClasses(['geo', 'gps-ops', 'stations']));
|
||||
assert.deepEqual(legend.map((row) => row.klass), ['station', 'nav', 'geo'],
|
||||
'legend follows SATELLITE_CLASS_ORDER, not input order');
|
||||
// COMMS only exists in DENSE mode — the legend must not advertise a class
|
||||
// that has nothing on screen.
|
||||
assert.equal(legend.some((row) => row.klass === 'comms'), false);
|
||||
|
||||
const row = legend.find((entry) => entry.klass === 'nav');
|
||||
assert.equal(row.label, 'NAV');
|
||||
assert.equal(row.color, SATELLITE_CLASSES.nav.color);
|
||||
assert.equal(row.count, 1);
|
||||
assert.ok(row.blurb.length > 0, 'every legend row carries a plain-language gloss');
|
||||
});
|
||||
|
||||
test('the legend drops zero and negative counts', () => {
|
||||
assert.deepEqual(satelliteClassLegend({ nav: 0, geo: -1, station: 2 }).map((r) => r.klass), ['station']);
|
||||
assert.deepEqual(satelliteClassLegend(null), []);
|
||||
});
|
||||
|
||||
test('the DENSE row chip is stateless and declares the params to apply', () => {
|
||||
test('the STARLINK row chip is stateless and declares the params to apply', () => {
|
||||
// The chip never carries its own state: it declares the params to apply, so
|
||||
// whatever else drives the catalog param (Space Missions capture/restore)
|
||||
// stays authoritative. Whether it reads ACTIVE is decided by the dense LOAD,
|
||||
@@ -217,7 +159,7 @@ test('the DENSE row chip is stateless and declares the params to apply', () => {
|
||||
_setDenseCatalogStateForTest({});
|
||||
const chip = satellitesLayer.getRowControls().chips.find((entry) => entry.id === 'catalog');
|
||||
assert.equal(chip.id, 'catalog');
|
||||
assert.equal(chip.label, 'DENSE');
|
||||
assert.equal(chip.label, 'STARLINK');
|
||||
assert.equal(chip.active, false);
|
||||
assert.equal(chip.busy, false);
|
||||
assert.equal(chip.state, 'idle');
|
||||
@@ -236,7 +178,7 @@ test('an invalid catalog mode is rejected without disturbing the chip', () => {
|
||||
assert.equal(chip.active, false);
|
||||
});
|
||||
|
||||
test('DENSE reports loading, then ACTIVE only once the points exist', async () => {
|
||||
test('STARLINK reports loading, then ACTIVE only once the points exist', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const refreshes = [];
|
||||
try {
|
||||
@@ -247,7 +189,7 @@ test('DENSE reports loading, then ACTIVE only once the points exist', async () =
|
||||
satellitesLayer.setParams({ catalog: 'dense' });
|
||||
// The param flips synchronously, but the shell has not arrived yet — the
|
||||
// chip must say BUSY, never ACTIVE.
|
||||
const pending = satellitesLayer.getRowControls().chips[0];
|
||||
const pending = starlinkChip();
|
||||
assert.equal(pending.busy, true);
|
||||
assert.equal(pending.disabled, true);
|
||||
assert.equal(pending.active, false, 'never active before the points exist');
|
||||
@@ -256,21 +198,21 @@ test('DENSE reports loading, then ACTIVE only once the points exist', async () =
|
||||
const settled = await settleChip();
|
||||
assert.equal(settled.active, true, 'active once the dense points are on screen');
|
||||
assert.equal(settled.state, 'active');
|
||||
assert.equal(settled.label, 'DENSE');
|
||||
assert.equal(settled.label, 'STARLINK');
|
||||
assert.deepEqual(settled.params, { catalog: 'core' }, 'a settled chip toggles back off');
|
||||
|
||||
// The completion pushed a refresh, and the legend gained COMMS with it —
|
||||
// The completion pushed a refresh, and the legend gained STARLINK with it —
|
||||
// without that push the row would keep the pre-load counts for 5 minutes.
|
||||
assert.ok(refreshes.length >= 2, 'load start and load completion each pushed a refresh');
|
||||
const legend = satellitesLayer.getRowControls().legend;
|
||||
assert.equal(legend.some((row) => row.klass === 'comms'), true);
|
||||
assert.equal(legend.some((row) => row.klass === 'starlink'), true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
_clearDenseCatalogStateForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test('a failed DENSE load reverts the mode rather than leaving an active chip', async () => {
|
||||
test('a failed STARLINK load reverts the mode rather than leaving an active chip', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const warn = console.warn;
|
||||
console.warn = () => {};
|
||||
@@ -285,14 +227,14 @@ test('a failed DENSE load reverts the mode rather than leaving an active chip',
|
||||
|
||||
assert.equal(settled.active, false, 'a 502 must never present as a live dense catalog');
|
||||
assert.equal(settled.state, 'error');
|
||||
assert.equal(settled.label, 'DENSE ✕');
|
||||
assert.equal(settled.label, 'STARLINK ✕');
|
||||
assert.match(settled.title, /502/, 'the chip explains why');
|
||||
assert.equal(satellitesLayer.getParams().catalog, 'core', 'the mode reverts to reality');
|
||||
assert.deepEqual(settled.params, { catalog: 'dense' }, 'clicking retries');
|
||||
assert.equal(
|
||||
satellitesLayer.getRowControls().legend.some((row) => row.klass === 'comms'),
|
||||
satellitesLayer.getRowControls().legend.some((row) => row.klass === 'starlink'),
|
||||
false,
|
||||
'no COMMS class is advertised when nothing loaded',
|
||||
'no STARLINK class is advertised when nothing loaded',
|
||||
);
|
||||
assert.ok(pushes >= 2, 'the failure pushed its own refresh');
|
||||
|
||||
@@ -365,9 +307,9 @@ test('an explicit return to core clears a failure the user never caused', async
|
||||
|
||||
// A no-op restore of the pre-mission snapshot.
|
||||
satellitesLayer.setParams({ catalog: 'core', showPoints: true, showOrbits: true });
|
||||
const restored = satellitesLayer.getRowControls().chips[0];
|
||||
const restored = starlinkChip();
|
||||
assert.equal(restored.state, 'idle', 'the error does not survive the restore');
|
||||
assert.equal(restored.label, 'DENSE');
|
||||
assert.equal(restored.label, 'STARLINK');
|
||||
assert.doesNotMatch(restored.title, /502/);
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
@@ -376,7 +318,7 @@ test('an explicit return to core clears a failure the user never caused', async
|
||||
}
|
||||
});
|
||||
|
||||
test('a real stations-feed outage keeps STATION in the legend, matching the card', async () => {
|
||||
test('a real stations-feed outage keeps the ISS under STATIONS in the legend', async () => {
|
||||
// End-to-end version of the tally rule: drive a real rebuild in which only
|
||||
// the `visual` group answers, so the ISS is genuinely ingested as `visual`.
|
||||
// The legend must still file it under STATION or it disagrees with the card.
|
||||
@@ -398,11 +340,11 @@ test('a real stations-feed outage keeps STATION in the legend, matching the card
|
||||
'the outage really did ingest the ISS from the visual group');
|
||||
|
||||
const legend = satellitesLayer.getRowControls().legend;
|
||||
assert.equal(legend.find((row) => row.klass === 'station')?.count, 1,
|
||||
'STATION survives the outage because the ISS is counted there');
|
||||
assert.equal(legend.some((row) => row.klass === 'visual'), false,
|
||||
'and it is not also counted under VISUAL');
|
||||
assert.equal(satelliteClassLabel('visual', { isIss: true }), 'STATION · ISS',
|
||||
assert.equal(legend.find((row) => row.klass === 'stations')?.count, 1,
|
||||
'STATIONS survives the outage because the ISS is counted there');
|
||||
assert.equal(legend.length, 1, 'and it is not also counted under another class');
|
||||
const [iss] = satellitesLayer.getAnalystRecords();
|
||||
assert.equal(iss?.satelliteClass, 'STATIONS · ISS',
|
||||
'the card says the same thing the legend does');
|
||||
} finally {
|
||||
console.log = log;
|
||||
@@ -431,15 +373,21 @@ test('a catalog rebuild refreshes the detection overlay class strings', async ()
|
||||
text: async () => (String(url).endsWith(`/${homeGroup}`) ? ALT_TLE : ''),
|
||||
});
|
||||
|
||||
// Every class on: the seeded satellite's class must not hide it.
|
||||
satellitesLayer.setParams({
|
||||
classes: { telecom: true, probes: true, debris: true },
|
||||
});
|
||||
await satellitesLayer.update(viewer);
|
||||
const first = satellitesLayer.getDetectableObjects().find((o) => o.sourceId === 33333);
|
||||
assert.ok(first, 'the seeded satellite is collectable');
|
||||
assert.equal(first.klass, 'GEO');
|
||||
// A payload with no SATCAT record and no recognisable name takes its
|
||||
// group's class (src/layers/satellites/classes.js).
|
||||
assert.equal(first.klass, 'TELECOM');
|
||||
|
||||
homeGroup = 'visual';
|
||||
homeGroup = 'resource';
|
||||
await satellitesLayer.update(viewer);
|
||||
const second = satellitesLayer.getDetectableObjects().find((o) => o.sourceId === 33333);
|
||||
assert.equal(second.klass, 'VISUAL', 'the cached record does not outlive its catalog');
|
||||
assert.equal(second.klass, 'IMAGING', 'the cached record does not outlive its catalog');
|
||||
} finally {
|
||||
console.log = log;
|
||||
console.warn = warn;
|
||||
@@ -451,7 +399,7 @@ test('a catalog rebuild refreshes the detection overlay class strings', async ()
|
||||
test('a dependency owner hiding the points surrenders the whole row', async () => {
|
||||
try {
|
||||
_setDenseCatalogStateForTest({});
|
||||
assert.equal(satellitesLayer.getRowControls().chips.length, 1);
|
||||
assert.equal(satellitesLayer.getRowControls().chips.length, 6);
|
||||
|
||||
// Space Missions borrows this layer for TLE lookup with showPoints:false.
|
||||
satellitesLayer.setParams({ showPoints: false });
|
||||
@@ -460,8 +408,105 @@ test('a dependency owner hiding the points surrenders the whole row', async () =
|
||||
assert.deepEqual(owned.legend, [], 'no legend describing an empty sky');
|
||||
|
||||
satellitesLayer.setParams({ showPoints: true });
|
||||
assert.equal(satellitesLayer.getRowControls().chips.length, 1, 'released on restore');
|
||||
assert.equal(satellitesLayer.getRowControls().chips.length, 6, 'released on restore');
|
||||
} finally {
|
||||
_clearDenseCatalogStateForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test('every class but Starlink shows by default; each class chip turns its class on and off', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const log = console.log;
|
||||
const warn = console.warn;
|
||||
console.log = () => {};
|
||||
console.warn = () => {};
|
||||
const served = {
|
||||
'/api/celestrak/stations': ISS_TLE,
|
||||
'/api/celestrak/resource': tleFor('90002', 'SENTINEL-2A'),
|
||||
'/api/celestrak/gps-ops': tleFor('90001', 'GPS BIII-1'),
|
||||
'/api/celestrak/visual': tleFor('90003', 'SL-16 R/B'),
|
||||
};
|
||||
const shown = () => satellitesLayer.getAllPositions().map((row) => row.id).sort();
|
||||
const chip = (id) =>
|
||||
satellitesLayer.getRowControls().chips.find((entry) => entry.id === `class-${id}`);
|
||||
try {
|
||||
_setDenseCatalogStateForTest({});
|
||||
globalThis.fetch = async (url) => ({
|
||||
ok: true,
|
||||
text: async () => served[String(url)] || '',
|
||||
});
|
||||
await satellitesLayer.update({ scene: { primitives: { add: (p) => p, remove() {} } } });
|
||||
assert.deepEqual(shown(), [25544, 90001, 90002, 90003], 'all but Starlink');
|
||||
assert.deepEqual(
|
||||
satellitesLayer.getRowControls().chips.map((entry) => entry.label),
|
||||
['STATIONS', 'IMAGING', 'TELECOM', 'STARLINK', 'PROBES', 'DEBRIS'],
|
||||
);
|
||||
assert.equal(chip('imaging').active, true);
|
||||
assert.equal(chip('telecom').active, true);
|
||||
assert.equal(
|
||||
satellitesLayer.getRowControls().chips.find((entry) => entry.id === 'catalog').active,
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(chip('telecom').params, { classes: { telecom: false } });
|
||||
assert.deepEqual(
|
||||
satellitesLayer.getRowControls().legend.map((row) => row.klass),
|
||||
['stations', 'imaging', 'telecom', 'debris'],
|
||||
'the legend keys only what is on screen',
|
||||
);
|
||||
|
||||
satellitesLayer.setParams(chip('telecom').params);
|
||||
satellitesLayer.setParams(chip('debris').params);
|
||||
assert.deepEqual(shown(), [25544, 90002], 'telecom and debris off');
|
||||
assert.equal(chip('debris').active, false);
|
||||
assert.deepEqual(satellitesLayer.getParams().classes, {
|
||||
stations: true,
|
||||
imaging: true,
|
||||
telecom: false,
|
||||
starlink: false,
|
||||
probes: true,
|
||||
debris: false,
|
||||
});
|
||||
|
||||
satellitesLayer.setParams(chip('telecom').params);
|
||||
satellitesLayer.setParams(chip('imaging').params);
|
||||
assert.deepEqual(shown(), [25544, 90001], 'telecom back on, imaging off');
|
||||
assert.equal(satellitesLayer.setParams({ classes: 'all' }), false, 'malformed request refused');
|
||||
|
||||
// A saved or shared choice comes back as class letters.
|
||||
assert.equal(satellitesLayer.setParams({ classes: 'sip' }), true);
|
||||
assert.deepEqual(satellitesLayer.getParams().classes, {
|
||||
stations: true,
|
||||
imaging: true,
|
||||
telecom: false,
|
||||
starlink: false,
|
||||
probes: true,
|
||||
debris: false,
|
||||
});
|
||||
assert.deepEqual(shown(), [25544, 90002], 'restored from letters');
|
||||
} finally {
|
||||
console.log = log;
|
||||
console.warn = warn;
|
||||
globalThis.fetch = originalFetch;
|
||||
_clearDenseCatalogStateForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test('the shown class is the six-class one, with a subtype that says more', () => {
|
||||
assert.equal(satelliteClassText('telecom', 'gps-ops'), 'TELECOM · GPS');
|
||||
assert.equal(
|
||||
satelliteClassText('stations', 'visual', { isIss: true }),
|
||||
'STATIONS · ISS',
|
||||
);
|
||||
assert.equal(satelliteClassText('imaging', 'resource'), 'IMAGING');
|
||||
assert.equal(satelliteClassText('imaging', 'visual'), 'IMAGING');
|
||||
// A subtype that only repeats the class is dropped.
|
||||
assert.equal(satelliteClassText('starlink', 'dense'), 'STARLINK');
|
||||
// A dead geostationary comsat is debris, not GEO.
|
||||
assert.equal(satelliteClassText('debris', 'geo'), 'DEBRIS');
|
||||
// Without a class the group's label stands.
|
||||
assert.equal(satelliteClassText(null, 'gps-ops'), 'NAV · GPS');
|
||||
assert.equal(
|
||||
satelliteClassText('telecom', 'gps-ops', { localize: false }),
|
||||
'TELECOM · GPS',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @file The satellites layer's class choice as share-link and saved-state
|
||||
* letters. Shared by the layer-state codec and the layer, which takes a saved
|
||||
* choice back in this form.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Satellite classes shown, as one letter each in legend order: stations,
|
||||
* imaging, telecom, probes, debris (`sitpd`, every one; `0`, none).
|
||||
* Starlink is the `catalog` option's, so it has no letter here.
|
||||
*/
|
||||
const SATELLITE_CLASS_LETTERS = Object.freeze([
|
||||
['stations', 's'],
|
||||
['imaging', 'i'],
|
||||
['telecom', 't'],
|
||||
['probes', 'p'],
|
||||
['debris', 'd'],
|
||||
]);
|
||||
const SATELLITE_CLASS_MASK = /^(0|s?i?t?p?d?)$/;
|
||||
|
||||
/**
|
||||
* The canonical class letters for a class choice (an object of booleans, as
|
||||
* the layer's `classes` param, or a partial one) or for letters already,
|
||||
* else null.
|
||||
* @param {unknown} value
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function normalizeSatelliteClassMask(value) {
|
||||
if (typeof value === 'string')
|
||||
return value && SATELLITE_CLASS_MASK.test(value) ? value : null;
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
// A class the choice leaves out keeps its default, which is shown: a
|
||||
// partial request (`{debris: false}`) hides only what it names.
|
||||
const letters = SATELLITE_CLASS_LETTERS.filter(([id]) => value[id] !== false)
|
||||
.map(([, letter]) => letter)
|
||||
.join('');
|
||||
return letters || '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* The class choice the letters stand for, without Starlink, or null.
|
||||
* @param {string} mask
|
||||
* @returns {Record<string, boolean>|null}
|
||||
*/
|
||||
export function decodeSatelliteClassMask(mask) {
|
||||
const normalized = normalizeSatelliteClassMask(mask);
|
||||
if (normalized === null || typeof mask !== 'string') return null;
|
||||
return Object.fromEntries(
|
||||
SATELLITE_CLASS_LETTERS.map(([id, letter]) => [
|
||||
id,
|
||||
normalized.includes(letter),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -152,3 +152,52 @@ test('satellite getAnalystRecords: core rows beat dense extras under the cap', (
|
||||
_clearDenseCatalogStateForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test('satellite getAnalystRecords: a hidden class stays out, the tracked one stays in', () => {
|
||||
const satrec = twoline2satrec(ISS_L1, ISS_L2);
|
||||
const at = (lon, alt) => ({
|
||||
position: Cesium.Cartesian3.fromDegrees(lon, 10, alt),
|
||||
});
|
||||
_setTrackedSatelliteRefreshStateForTest({
|
||||
noradId: ISS_NORAD,
|
||||
name: 'ISS (ZARYA)',
|
||||
satrec,
|
||||
entity: {},
|
||||
point: at(-97, 420_000),
|
||||
viewer: {},
|
||||
now: () => Date.now(),
|
||||
satelliteClass: 'stations',
|
||||
classes: {
|
||||
stations: false,
|
||||
imaging: true,
|
||||
telecom: false,
|
||||
starlink: false,
|
||||
probes: true,
|
||||
debris: true,
|
||||
},
|
||||
neighbours: [
|
||||
{
|
||||
noradId: 24876,
|
||||
name: 'GPS BIIR-2',
|
||||
group: 'gps-ops',
|
||||
satelliteClass: 'telecom',
|
||||
point: at(-100, 20_200_000),
|
||||
},
|
||||
{
|
||||
noradId: 90001,
|
||||
name: 'TEST IMAGER',
|
||||
group: 'resource',
|
||||
satelliteClass: 'imaging',
|
||||
point: at(-80, 700_000),
|
||||
},
|
||||
],
|
||||
});
|
||||
try {
|
||||
assert.deepEqual(
|
||||
satellitesLayer.getAnalystRecords(10).map((r) => r.noradId),
|
||||
['25544', '90001'],
|
||||
);
|
||||
} finally {
|
||||
_clearDenseCatalogStateForTest();
|
||||
}
|
||||
});
|
||||
@@ -6,6 +6,14 @@ export function celestrakTleUrl(group) {
|
||||
return url;
|
||||
}
|
||||
|
||||
/** CelesTrak SATCAT records (object type, status, owner) for a GP group, as CSV. */
|
||||
export function celestrakSatcatUrl(group) {
|
||||
const url = new URL('https://celestrak.org/satcat/records.php');
|
||||
url.searchParams.set('GROUP', group);
|
||||
url.searchParams.set('FORMAT', 'CSV');
|
||||
return url;
|
||||
}
|
||||
|
||||
export function launchLibraryRecentUrl(end) {
|
||||
// Stable minute boundaries let different installations share LL2's cache.
|
||||
const roundedEnd = new Date(Math.floor(end.getTime() / 60000) * 60000);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @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
|
||||
* (https://www.ucsusa.org/satellite_database; free, unrestricted use, with an
|
||||
* acknowledgement). Loaded once, on demand, by the satellites layer; a failed
|
||||
* load is retried after a cooldown rather than remembered for the session.
|
||||
*
|
||||
* @module data/ucsSatellites
|
||||
*/
|
||||
|
||||
import { loadBundledJson } from './bundledJson.js';
|
||||
import { createRetryableLoader } from './retryableLoad.js';
|
||||
|
||||
const PACK_URL = new URL(
|
||||
'./local_data/ucs_satellites/ucs_satellites.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
/**
|
||||
* Expand the pack's string-table rows into records keyed by NORAD number.
|
||||
* A pack of the wrong shape throws: read as an empty pack it would be kept
|
||||
* for the session, and every satellite would quietly lose its UCS evidence.
|
||||
* A malformed row is skipped.
|
||||
* @param {{fields: string[], strings: Record<string, string[]>,
|
||||
* records: Array<number[]>}} pack Parsed `ucs_satellites.json`.
|
||||
* @returns {Map<number, UcsSatellite>}
|
||||
*/
|
||||
export function indexUcsPack(pack) {
|
||||
const byNorad = new Map();
|
||||
const fields = pack?.fields;
|
||||
if (
|
||||
!Array.isArray(fields) ||
|
||||
fields[0] !== 'noradId' ||
|
||||
!Array.isArray(pack.records)
|
||||
)
|
||||
throw new TypeError('UCS pack has an unexpected shape');
|
||||
for (const row of pack.records) {
|
||||
const noradId = row?.[0];
|
||||
if (!Number.isInteger(noradId) || noradId <= 0) continue;
|
||||
const record = { noradId };
|
||||
for (let i = 1; i < fields.length; i++) {
|
||||
const text = pack.strings?.[fields[i]]?.[row[i]];
|
||||
record[fields[i]] = text ? text : null;
|
||||
}
|
||||
byNorad.set(noradId, record);
|
||||
}
|
||||
return byNorad;
|
||||
}
|
||||
|
||||
/** The pack, loaded and indexed once. */
|
||||
export const loadUcsSatellites = createRetryableLoader(async () =>
|
||||
indexUcsPack(await loadBundledJson(PACK_URL)),
|
||||
);
|
||||
@@ -0,0 +1,80 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { indexUcsPack, loadUcsSatellites } from './ucsSatellites.js';
|
||||
|
||||
const PACK_URL = new URL(
|
||||
'./local_data/ucs_satellites/ucs_satellites.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
test('the bundled UCS pack names its release, licence and curation', () => {
|
||||
const pack = JSON.parse(readFileSync(PACK_URL, 'utf8'));
|
||||
assert.equal(pack.meta.version, 'UCS-Satellite-Database 5-1-2023');
|
||||
assert.equal(pack.meta.url, 'https://www.ucsusa.org/satellite_database');
|
||||
assert.equal(pack.meta.dataCurrentTo, '2023-05-01');
|
||||
assert.match(pack.meta.license, /unrestricted/);
|
||||
assert.equal(pack.meta.recordCount, pack.records.length);
|
||||
assert.equal(
|
||||
pack.meta.curation.sourceRows,
|
||||
pack.records.length +
|
||||
pack.meta.curation.starlinkRowsDropped +
|
||||
pack.meta.curation.invalidNoradDropped +
|
||||
pack.meta.curation.duplicateNoradDropped,
|
||||
);
|
||||
assert.deepEqual(pack.fields, [
|
||||
'noradId',
|
||||
'operator',
|
||||
'operatorCountry',
|
||||
'purpose',
|
||||
]);
|
||||
// Small enough to load with the layer.
|
||||
assert.ok(readFileSync(PACK_URL).length < 200_000);
|
||||
});
|
||||
|
||||
test('the pack loads into records keyed by NORAD number', async () => {
|
||||
const records = await loadUcsSatellites();
|
||||
assert.equal(records, await loadUcsSatellites(), 'loaded once');
|
||||
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', 'operator', 'purpose'],
|
||||
strings: { operator: ['', 'ESA'], purpose: ['', 'Earth Observation'] },
|
||||
records: [
|
||||
[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).operator, null);
|
||||
});
|
||||
|
||||
test('a pack of the wrong shape throws, so the loader tries again', () => {
|
||||
// Read as empty, it would be cached for the session as a success.
|
||||
assert.throws(() => indexUcsPack(null), TypeError);
|
||||
assert.throws(
|
||||
() => indexUcsPack({ fields: ['name'], records: [[1]] }),
|
||||
TypeError,
|
||||
);
|
||||
assert.throws(() => indexUcsPack({ fields: ['noradId'] }), TypeError);
|
||||
});
|
||||
+119
-3
@@ -149,11 +149,28 @@
|
||||
"Voice model: {pendingId} — click to switch to {target}; applies next session": "النموذج الصوتي: {pendingId} — انقر للتبديل إلى {target}؛ يسري في الجلسة التالية",
|
||||
"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": "المسار فوق القاع",
|
||||
"ALTITUDE": "الارتفاع",
|
||||
"CONTACT": "الهدف",
|
||||
"CONTACTS · 250 KM": "الأهداف · 250 كم",
|
||||
"RINGS {spacing} NM": "الحلقات كل {spacing} ميل بحري",
|
||||
"{count} CONTACTS": {
|
||||
"zero": "لا أهداف ({count})",
|
||||
"one": "هدف واحد ({count})",
|
||||
"two": "هدفان ({count})",
|
||||
"few": "{count} أهداف",
|
||||
"many": "{count} هدفًا",
|
||||
"other": "{count} هدف"
|
||||
},
|
||||
"Radar range {range}. Select for the next range.": "مدى الرادار {range}. اختر للانتقال إلى المدى التالي.",
|
||||
"Bridge radar": "رادار غرفة القيادة",
|
||||
"LIVE SIGNALS": "إشارات مباشرة",
|
||||
"OBSERVED / MAPPED PINGS": "إشارات مرصودة / مرسومة",
|
||||
"CURRENT": "الحالي",
|
||||
@@ -226,16 +243,32 @@
|
||||
"Landmark: --": "المَعلم: --",
|
||||
"Search any location": "البحث عن أي موقع",
|
||||
"Search any location...": "ابحث عن أي موقع…",
|
||||
"LOOK STBD {bearing}°": "النظر إلى الميمنة {bearing}°",
|
||||
"LOOK PORT {bearing}°": "النظر إلى الميسرة {bearing}°",
|
||||
"LOOK ASTERN": "النظر إلى المؤخرة",
|
||||
"UP {angle}°": "أعلى {angle}°",
|
||||
"DOWN {angle}°": "أسفل {angle}°",
|
||||
"LEVEL": "أفقي",
|
||||
"{direction} · {elevation}": "{direction} · {elevation}",
|
||||
"LOOK {elevation}": "النظر {elevation}",
|
||||
"Estimated destination direction": "اتجاه الوجهة التقديري",
|
||||
"DEST ---°": "الوجهة ---°",
|
||||
"BRIDGE WINDOW · FWD": "نافذة غرفة القيادة · الأمام",
|
||||
"OPTICAL PLANE · 01": "المستوى البصري · 01",
|
||||
"HEADING HOLD · AIS": "تثبيت الاتجاه · AIS",
|
||||
"NO HEADING · AIS": "لا اتجاه · AIS",
|
||||
"VISOR LOCK · ACTIVE": "قفل واقي الخوذة · نشط",
|
||||
"FIRST PERSON": "منظور الشخص الأول",
|
||||
"BRIDGE VIEW": "عرض غرفة القيادة",
|
||||
"LIVE TRACK · COURSE ALIGNED": "تتبّع مباشر · بمحاذاة المسار",
|
||||
"Cockpit vision style": "نمط رؤية القمرة",
|
||||
"Current aircraft heading": "اتجاه الطائرة الحالي",
|
||||
"Look ahead: turn the bridge view back to the bow": "النظر إلى الأمام: إعادة عرض غرفة القيادة نحو مقدمة السفينة",
|
||||
"Look ahead (Home or 0)": "النظر إلى الأمام (Home أو 0)",
|
||||
"LOOK AHEAD": "النظر إلى الأمام",
|
||||
"Current vessel heading": "اتجاه السفينة الحالي",
|
||||
"GROUND SPEED · KTS": "السرعة الأرضية · عقدة",
|
||||
"SPEED OVER GROUND · KTS": "السرعة فوق القاع · عقدة",
|
||||
"ALTITUDE · FT": "الارتفاع · قدم",
|
||||
"PREV": "السابق",
|
||||
"Previous cockpit vision style": "نمط رؤية القمرة السابق",
|
||||
@@ -247,9 +280,17 @@
|
||||
"Exit cockpit and return to full globe view": "الخروج من القمرة والعودة إلى عرض الكرة الأرضية كاملة",
|
||||
"RESET": "إعادة ضبط",
|
||||
"Exit cockpit view": "الخروج من عرض القمرة",
|
||||
"Exit bridge view": "الخروج من عرض غرفة القيادة",
|
||||
"Exit orbital view": "الخروج من العرض المداري",
|
||||
"Estimated flight plan": "خطة الرحلة التقديرية",
|
||||
"Reported voyage": "الرحلة المُبلَّغ عنها",
|
||||
"ESTIMATED FLIGHT PLAN": "خطة الرحلة التقديرية",
|
||||
"REPORTED VOYAGE": "الرحلة المُبلَّغ عنها",
|
||||
"ROUTE DATA UNAVAILABLE": "بيانات المسار غير متاحة",
|
||||
"CONTACT NAVIGATION OFF": "التنقل بين الأهداف متوقف",
|
||||
"THE BRIDGE STAYS WITH THE SELECTED VESSEL": "تبقى غرفة القيادة مع السفينة المحددة",
|
||||
"{range} NM": "{range} ميل بحري",
|
||||
"AIS DESTINATION · CREW REPORTED": "وجهة AIS · أبلغ عنها الطاقم",
|
||||
"FROM": "من",
|
||||
"TO": "إلى",
|
||||
"UNKNOWN": "غير معروف",
|
||||
@@ -292,6 +333,7 @@
|
||||
"SELECT CONTEXT": "اختر السياق",
|
||||
"Contact Context actions": "إجراءات سياق الهدف",
|
||||
"COCKPIT": "القمرة",
|
||||
"BRIDGE": "غرفة القيادة",
|
||||
"SEARCH NEARBY SITES": "البحث عن المواقع القريبة",
|
||||
"Reclassify tracked contact as TR-3B": "إعادة تصنيف الهدف المتتبَّع إلى TR-3B",
|
||||
"Reclassify as TR-3B": "إعادة التصنيف إلى TR-3B",
|
||||
@@ -331,6 +373,9 @@
|
||||
"Current style: {style} — click for next": "النمط الحالي: {style} — انقر للتالي",
|
||||
"TRACK ACQUIRED": "تم التقاط الهدف",
|
||||
"{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": "فعّل السياق العالمي لإشارات العناصر القريبة",
|
||||
@@ -358,6 +403,8 @@
|
||||
"STALE FEED": "مصدر متقادم",
|
||||
"LIVE TRACK": "تتبّع مباشر",
|
||||
"{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · بمحاذاة المسار",
|
||||
"{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ارتفاع العين {height}",
|
||||
"LIVE AIS": "AIS مباشر",
|
||||
"ARROW · ESTIMATED DIRECTION": "سهم · اتجاه تقديري",
|
||||
"DEST {bearing}": "الوجهة {bearing}",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "فُقد الهدف · آخر قراءة معروفة · لا يعني أن الوضع آمن",
|
||||
@@ -757,6 +804,40 @@
|
||||
"RECONSTRUCTED ESTIMATE": "تقدير مُعاد بناؤه",
|
||||
"{value} KM": "{value} كم",
|
||||
"{value} KM/S": "{value} كم/ث",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "انحراف عن النظير {angle}° · {offset}",
|
||||
"SITES STANDBY": "المواقع في وضع الاستعداد",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "فعّل المنشآت المرسومة لإشارات المواقع",
|
||||
"Release the camera from {name}": "تحرير الكاميرا من {name}",
|
||||
"RETURN · {name}": "عودة · {name}",
|
||||
"PREVIOUS SATELLITE": "القمر الصناعي السابق",
|
||||
"Return to {name}": "العودة إلى {name}",
|
||||
"SATELLITE": "قمر صناعي",
|
||||
"Ride {name}": "الانتقال إلى {name}",
|
||||
"Unnamed site": "موقع بلا اسم",
|
||||
"OFF-NADIR {angle}°": "انحراف عن النظير {angle}°",
|
||||
"FOCUS · {name}": "تركيز · {name}",
|
||||
"Keep the camera on {name}": "إبقاء الكاميرا على {name}",
|
||||
"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": "إيقاف إعادة العرض مؤقتًا",
|
||||
@@ -778,8 +859,6 @@
|
||||
"Ascent replay": "إعادة عرض الصعود",
|
||||
"Orbit replay": "إعادة عرض المدار",
|
||||
", paused": "، متوقف مؤقتًا",
|
||||
"DENSE ···": "كثيف ···",
|
||||
"DENSE ✕": "كثيف ✕",
|
||||
"Add the full Starlink broadband shell (thousands of extra points)": "إضافة غلاف Starlink للنطاق العريض كاملًا (آلاف النقاط الإضافية)",
|
||||
"Loading the Starlink shell…": "جارٍ تحميل غلاف Starlink…",
|
||||
"Starlink {detail} — click to retry": "Starlink {detail} — انقر لإعادة المحاولة",
|
||||
@@ -3109,7 +3188,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": "أيام الصور في المنطقة",
|
||||
@@ -3485,6 +3568,39 @@
|
||||
"Refresh map still": "تحديث صورة الخريطة الثابتة",
|
||||
"The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك.",
|
||||
"STATIONS": "المحطات",
|
||||
"Crewed stations and the craft that visit them": "المحطات المأهولة والمركبات التي تزورها",
|
||||
"IMAGING": "التصوير",
|
||||
"Earth observation: optical, radar, weather and science": "رصد الأرض: بصري ورادار وطقس وعلوم",
|
||||
"TELECOM": "الاتصالات",
|
||||
"Communications and navigation satellites, Starlink excepted": "أقمار الاتصالات والملاحة، باستثناء Starlink",
|
||||
"STARLINK": "STARLINK",
|
||||
"The Starlink constellation (thousands of extra points)": "كوكبة Starlink (آلاف النقاط الإضافية)",
|
||||
"PROBES": "المسابير",
|
||||
"Space science, technology demonstration and other payloads": "علوم الفضاء والعروض التقنية وحمولات أخرى",
|
||||
"DEBRIS": "الحطام",
|
||||
"Rocket bodies, fragments and dead satellites": "مراحل الصواريخ والشظايا والأقمار المعطلة",
|
||||
"Classes may lag: SATCAT is a cached copy": "قد تتأخر الفئات: SATCAT نسخة مخزنة",
|
||||
"Classes may lag: SATCAT unavailable": "قد تتأخر الفئات: SATCAT غير متاح",
|
||||
"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 ✕",
|
||||
"SENSOR SWATH {value} KM": "عرض نطاق المستشعر {value} كم",
|
||||
"SENSOR SWATH ≈ {value} KM": "عرض نطاق المستشعر ≈ {value} كم",
|
||||
"SENSOR": "المستشعر",
|
||||
"Zoom to the imaging sensor swath": "التكبير إلى نطاق مستشعر التصوير",
|
||||
"<1 H OLD": "قبل أقل من ساعة",
|
||||
"{value} H OLD": "قبل {value} س",
|
||||
"{value} D OLD": "قبل {value} ي",
|
||||
"FWD": "أمام",
|
||||
"AFT": "خلف",
|
||||
"RIGHT": "يمين",
|
||||
"LEFT": "يسار",
|
||||
"TRACK UP": "المسار للأعلى",
|
||||
"COURSE HOLD · AIS": "تثبيت المسار · AIS",
|
||||
"Zoom in (+)": "تكبير (+)",
|
||||
"Zoom out (−)": "تصغير (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "العرض المداري · انحراف عن النظير",
|
||||
"World time": "وقت العالم",
|
||||
"Back to live": "العودة إلى البث المباشر",
|
||||
"Live": "مباشر",
|
||||
|
||||
+115
-3
@@ -191,6 +191,40 @@
|
||||
"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}",
|
||||
"SITES STANDBY": "ORTE STANDBY",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "KARTIERTE ANLAGEN FÜR ORTS-PINGS AKTIVIEREN",
|
||||
"Release the camera from {name}": "Kamera von {name} lösen",
|
||||
"RETURN · {name}": "ZURÜCK · {name}",
|
||||
"PREVIOUS SATELLITE": "VORHERIGER SATELLIT",
|
||||
"Return to {name}": "Zurück zu {name}",
|
||||
"SATELLITE": "SATELLIT",
|
||||
"Ride {name}": "Mit {name} fliegen",
|
||||
"Unnamed site": "Unbenannter Ort",
|
||||
"OFF-NADIR {angle}°": "NADIRABWEICHUNG {angle}°",
|
||||
"FOCUS · {name}": "FOKUS · {name}",
|
||||
"Keep the camera on {name}": "Kamera auf {name} halten",
|
||||
"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",
|
||||
@@ -278,8 +312,6 @@
|
||||
"Starlink {detail} — click to retry": "Starlink: {detail} – zum erneuten Versuch klicken",
|
||||
"load failed": "Laden fehlgeschlagen",
|
||||
"Showing the full Starlink shell — click for the core catalog only": "Komplette Starlink-Schale sichtbar: klicken, um nur den Kernkatalog zu zeigen",
|
||||
"DENSE ···": "DICHT ···",
|
||||
"DENSE ✕": "DICHT ✕",
|
||||
"DENSE": "DICHT",
|
||||
"Syncing flow · Roads: {source}": "Verkehrsfluss wird synchronisiert · Straßen: {source}",
|
||||
"LIVE · Roads: {source} · Roads without flow hidden": "LIVE · Straßen: {source} · Straßen ohne Verkehrsdaten ausgeblendet",
|
||||
@@ -431,8 +463,14 @@
|
||||
"LIVE TRACK": "LIVE-TRACK",
|
||||
"COMMERCIAL": "ZIVIL",
|
||||
"{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · IN KURSRICHTUNG",
|
||||
"{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · AUGESHÖHE {height}",
|
||||
"LIVE AIS": "LIVE-AIS",
|
||||
"ARROW · ESTIMATED DIRECTION": "PFEIL · GESCHÄTZTE RICHTUNG",
|
||||
"ROUTE DATA UNAVAILABLE": "ROUTENDATEN NICHT VERFÜGBAR",
|
||||
"CONTACT NAVIGATION OFF": "KONTAKTNAVIGATION AUS",
|
||||
"THE BRIDGE STAYS WITH THE SELECTED VESSEL": "DIE BRÜCKE BLEIBT BEIM GEWÄHLTEN SCHIFF",
|
||||
"{range} NM": "{range} SM",
|
||||
"AIS DESTINATION · CREW REPORTED": "AIS-ZIEL · VON DER BESATZUNG GEMELDET",
|
||||
"DEST {bearing}": "DEST {bearing}",
|
||||
"Disable cockpit weather effects": "Cockpit-Wettereffekte deaktivieren",
|
||||
"Enable cockpit weather effects": "Cockpit-Wettereffekte aktivieren",
|
||||
@@ -477,6 +515,9 @@
|
||||
"Reclassify as TR-3B": "Als TR-3B umklassifizieren",
|
||||
"TRACK ACQUIRED": "TRACK ERFASST",
|
||||
"{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": {
|
||||
@@ -663,14 +704,28 @@
|
||||
"Session ended — cost cap {cost}": "Sitzung beendet: Kostenlimit {cost}",
|
||||
"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",
|
||||
"UP {angle}°": "HOCH {angle}°",
|
||||
"DOWN {angle}°": "TIEF {angle}°",
|
||||
"LEVEL": "HORIZONT",
|
||||
"{direction} · {elevation}": "{direction} · {elevation}",
|
||||
"LOOK {elevation}": "BLICK {elevation}",
|
||||
"Estimated destination direction": "Geschätzte Richtung zum Ziel",
|
||||
"DEST ---°": "DEST ---°",
|
||||
"BRIDGE WINDOW · FWD": "BRÜCKENFENSTER · VORAUS",
|
||||
"OPTICAL PLANE · 01": "OPTISCHE EBENE · 01",
|
||||
"HEADING HOLD · AIS": "STEUERKURS FIX · AIS",
|
||||
"NO HEADING · AIS": "KEIN STEUERKURS · AIS",
|
||||
"VISOR LOCK · ACTIVE": "VISIER-LOCK · AKTIV",
|
||||
"GROUND SPEED · KTS": "GESCHW. ÜBER GRUND · KN",
|
||||
"SPEED OVER GROUND · KTS": "FAHRT ÜBER GRUND · KN",
|
||||
"ALTITUDE · FT": "HÖHE · FT",
|
||||
"FIRST PERSON": "EGO-PERSPEKTIVE",
|
||||
"BRIDGE VIEW": "BRÜCKENANSICHT",
|
||||
"LIVE TRACK · COURSE ALIGNED": "LIVE-TRACK · IN KURSRICHTUNG",
|
||||
"Cockpit vision style": "Sichtstil im Cockpit",
|
||||
"Previous cockpit vision style": "Vorheriger Cockpit-Sichtstil",
|
||||
@@ -679,11 +734,24 @@
|
||||
"Next cockpit vision style": "Nächster Cockpit-Sichtstil",
|
||||
"Next vision style": "Nächster Sichtstil",
|
||||
"Current aircraft heading": "Aktueller Flugzeugkurs",
|
||||
"Look ahead: turn the bridge view back to the bow": "Blick voraus: Brückenansicht zurück zum Bug drehen",
|
||||
"Look ahead (Home or 0)": "Blick voraus (Pos1 oder 0)",
|
||||
"LOOK AHEAD": "BLICK VORAUS",
|
||||
"Current vessel heading": "Aktueller Steuerkurs des Schiffs",
|
||||
"GROUND SPEED": "GESCHW. ÜBER GRUND",
|
||||
"SPEED OVER GROUND": "FAHRT ÜBER GRUND",
|
||||
"COURSE OVER GROUND": "KURS ÜBER GRUND",
|
||||
"ALTITUDE": "HÖHE",
|
||||
"Contact cockpit summary": "Cockpit-Zusammenfassung des Kontakts",
|
||||
"CONTACT": "KONTAKT",
|
||||
"CONTACTS · 250 KM": "KONTAKTE · 250 KM",
|
||||
"RINGS {spacing} NM": "RINGE {spacing} SM",
|
||||
"{count} CONTACTS": {
|
||||
"one": "{count} KONTAKT",
|
||||
"other": "{count} KONTAKTE"
|
||||
},
|
||||
"Radar range {range}. Select for the next range.": "Radarreichweite {range}. Auswählen, um zur nächsten Reichweite zu wechseln.",
|
||||
"Bridge radar": "Brückenradar",
|
||||
"Contact navigation": "Kontaktnavigation",
|
||||
"CONTEXT ONLY": "NUR KONTEXT",
|
||||
"Nearby cohort counts": "Anzahl Kontakte in der Nähe",
|
||||
@@ -691,7 +759,9 @@
|
||||
"AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "NUR VERFÜGBARE QUELLEN · KEINE ENTWARNUNG",
|
||||
"Cockpit briefing carousel": "Cockpit-Briefing-Karussell",
|
||||
"Estimated flight plan": "Geschätzter Flugplan",
|
||||
"Reported voyage": "Gemeldete Reise",
|
||||
"ESTIMATED FLIGHT PLAN": "GESCHÄTZTER FLUGPLAN",
|
||||
"REPORTED VOYAGE": "GEMELDETE REISE",
|
||||
"FROM": "VON",
|
||||
"TO": "NACH",
|
||||
"Cockpit briefing controls": "Cockpit-Briefing-Steuerung",
|
||||
@@ -722,7 +792,11 @@
|
||||
"Exit cockpit and return to full globe view": "Cockpit verlassen und zur Vollglobus-Ansicht zurückkehren",
|
||||
"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",
|
||||
@@ -752,6 +826,7 @@
|
||||
"SELECT CONTEXT": "KONTEXT WÄHLEN",
|
||||
"Contact Context actions": "Aktionen für Kontaktkontext",
|
||||
"COCKPIT": "COCKPIT",
|
||||
"BRIDGE": "BRÜCKE",
|
||||
"SEARCH NEARBY SITES": "STANDORTE IN DER NÄHE SUCHEN",
|
||||
"Reclassify tracked contact as TR-3B": "Verfolgten Kontakt als TR-3B umklassifizieren",
|
||||
"CONTACTS CONTEXT OFF": "KONTAKTKONTEXT AUS",
|
||||
@@ -2835,7 +2910,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",
|
||||
@@ -3185,6 +3264,39 @@
|
||||
"Refresh map still": "Kartenstandbild aktualisieren",
|
||||
"The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht.",
|
||||
"STATIONS": "STATIONEN",
|
||||
"Crewed stations and the craft that visit them": "Bemannte Stationen und die Raumfahrzeuge, die sie anfliegen",
|
||||
"IMAGING": "BILDGEBUNG",
|
||||
"Earth observation: optical, radar, weather and science": "Erdbeobachtung: optisch, Radar, Wetter und Wissenschaft",
|
||||
"TELECOM": "TELEKOM",
|
||||
"Communications and navigation satellites, Starlink excepted": "Kommunikations- und Navigationssatelliten, ohne Starlink",
|
||||
"STARLINK": "STARLINK",
|
||||
"The Starlink constellation (thousands of extra points)": "Die Starlink-Konstellation (Tausende zusätzliche Punkte)",
|
||||
"PROBES": "SONDEN",
|
||||
"Space science, technology demonstration and other payloads": "Weltraumforschung, Technologiedemonstration und andere Nutzlasten",
|
||||
"DEBRIS": "TRÜMMER",
|
||||
"Rocket bodies, fragments and dead satellites": "Raketenstufen, Fragmente und ausgefallene Satelliten",
|
||||
"Classes may lag: SATCAT is a cached copy": "Klassen evtl. veraltet: SATCAT ist eine zwischengespeicherte Kopie",
|
||||
"Classes may lag: SATCAT unavailable": "Klassen evtl. veraltet: SATCAT nicht verfügbar",
|
||||
"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 ✕",
|
||||
"SENSOR SWATH {value} KM": "SENSORSTREIFEN {value} KM",
|
||||
"SENSOR SWATH ≈ {value} KM": "SENSORSTREIFEN ≈ {value} KM",
|
||||
"SENSOR": "SENSOR",
|
||||
"Zoom to the imaging sensor swath": "Auf den Streifen des Bildsensors zoomen",
|
||||
"<1 H OLD": "<1 STD ALT",
|
||||
"{value} H OLD": "{value} STD ALT",
|
||||
"{value} D OLD": "{value} T ALT",
|
||||
"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",
|
||||
|
||||
+116
-3
@@ -38,11 +38,25 @@
|
||||
"REFRESHING LIVE DATA": "ACTUALIZANDO DATOS",
|
||||
"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",
|
||||
"ALTITUDE": "ALTITUD",
|
||||
"CONTACT": "CONTACTO",
|
||||
"CONTACTS · 250 KM": "CONTACTOS · 250 KM",
|
||||
"RINGS {spacing} NM": "ANILLOS {spacing} MN",
|
||||
"{count} CONTACTS": {
|
||||
"one": "{count} CONTACTO",
|
||||
"many": "{count} DE CONTACTOS",
|
||||
"other": "{count} CONTACTOS"
|
||||
},
|
||||
"Radar range {range}. Select for the next range.": "Alcance del radar {range}. Selecciona para pasar al siguiente alcance.",
|
||||
"Bridge radar": "Radar del puente",
|
||||
"LIVE SIGNALS": "SEÑALES EN VIVO",
|
||||
"OBSERVED / MAPPED PINGS": "PINGS OBSERVADOS / MAPEADOS",
|
||||
"CURRENT": "ACTUAL",
|
||||
@@ -115,16 +129,32 @@
|
||||
"Landmark: --": "Punto de interés: --",
|
||||
"Search any location": "Buscar un lugar",
|
||||
"Search any location...": "Buscar un lugar…",
|
||||
"LOOK STBD {bearing}°": "VISTA ESTRIBOR {bearing}°",
|
||||
"LOOK PORT {bearing}°": "VISTA BABOR {bearing}°",
|
||||
"LOOK ASTERN": "VISTA A POPA",
|
||||
"UP {angle}°": "ARRIBA {angle}°",
|
||||
"DOWN {angle}°": "ABAJO {angle}°",
|
||||
"LEVEL": "NIVEL",
|
||||
"{direction} · {elevation}": "{direction} · {elevation}",
|
||||
"LOOK {elevation}": "VISTA {elevation}",
|
||||
"Estimated destination direction": "Dirección estimada del destino",
|
||||
"DEST ---°": "DEST ---°",
|
||||
"BRIDGE WINDOW · FWD": "VENTANA DEL PUENTE · AVANTE",
|
||||
"OPTICAL PLANE · 01": "PLANO ÓPTICO · 01",
|
||||
"HEADING HOLD · AIS": "BLOQUEO DE PROA · AIS",
|
||||
"NO HEADING · AIS": "SIN PROA · AIS",
|
||||
"VISOR LOCK · ACTIVE": "BLOQUEO DE VISERA · ACTIVO",
|
||||
"FIRST PERSON": "PRIMERA PERSONA",
|
||||
"BRIDGE VIEW": "VISTA DEL PUENTE",
|
||||
"LIVE TRACK · COURSE ALIGNED": "RASTREO EN VIVO · RUMBO ALINEADO",
|
||||
"Cockpit vision style": "Estilo de visión de la cabina",
|
||||
"Current aircraft heading": "Rumbo actual de la aeronave",
|
||||
"Look ahead: turn the bridge view back to the bow": "Mirar a proa: girar de nuevo la vista del puente hacia la proa",
|
||||
"Look ahead (Home or 0)": "Mirar a proa (Inicio o 0)",
|
||||
"LOOK AHEAD": "MIRAR A PROA",
|
||||
"Current vessel heading": "Proa actual del buque",
|
||||
"GROUND SPEED · KTS": "VEL. SUELO · KN",
|
||||
"SPEED OVER GROUND · KTS": "VEL. SOBRE FONDO · KN",
|
||||
"ALTITUDE · FT": "ALTITUD · FT",
|
||||
"PREV": "ANT.",
|
||||
"Previous cockpit vision style": "Estilo de visión de cabina anterior",
|
||||
@@ -137,9 +167,17 @@
|
||||
"Exit cockpit and return to full globe view": "Salir de la cabina y volver a la vista de globo completo",
|
||||
"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",
|
||||
"REPORTED VOYAGE": "VIAJE NOTIFICADO",
|
||||
"ROUTE DATA UNAVAILABLE": "DATOS DE RUTA NO DISPONIBLES",
|
||||
"CONTACT NAVIGATION OFF": "NAVEGACIÓN DE CONTACTOS DESACTIVADA",
|
||||
"THE BRIDGE STAYS WITH THE SELECTED VESSEL": "EL PUENTE PERMANECE EN EL BUQUE SELECCIONADO",
|
||||
"{range} NM": "{range} MN",
|
||||
"AIS DESTINATION · CREW REPORTED": "DESTINO AIS · NOTIFICADO POR LA TRIPULACIÓN",
|
||||
"FROM": "DESDE",
|
||||
"TO": "HACIA",
|
||||
"UNKNOWN": "DESCONOCIDO",
|
||||
@@ -183,6 +221,7 @@
|
||||
"SELECT CONTEXT": "SELECCIONAR CONTEXTO",
|
||||
"Contact Context actions": "Acciones de contexto de contacto",
|
||||
"COCKPIT": "CABINA",
|
||||
"BRIDGE": "PUENTE",
|
||||
"SEARCH NEARBY SITES": "BUSCAR SITIOS CERCANOS",
|
||||
"Reclassify tracked contact as TR-3B": "Reclasificar el contacto rastreado como TR-3B",
|
||||
"Reclassify as TR-3B": "Reclasificar como TR-3B",
|
||||
@@ -223,6 +262,9 @@
|
||||
"Current style: {style} — click for next": "Estilo actual: {style} — haz clic para el siguiente",
|
||||
"TRACK ACQUIRED": "RASTREO ADQUIRIDO",
|
||||
"{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",
|
||||
@@ -247,6 +289,8 @@
|
||||
"STALE FEED": "FUENTE DESACTUALIZADA",
|
||||
"LIVE TRACK": "RASTREO EN VIVO",
|
||||
"{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · RUMBO ALINEADO",
|
||||
"{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ALT. OJO {height}",
|
||||
"LIVE AIS": "AIS EN VIVO",
|
||||
"ARROW · ESTIMATED DIRECTION": "FLECHA · DIRECCIÓN ESTIMADA",
|
||||
"DEST {bearing}": "DEST {bearing}",
|
||||
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTACTO PERDIDO · ÚLTIMA LECTURA CONOCIDA · NO IMPLICA TODO DESPEJADO",
|
||||
@@ -618,6 +662,40 @@
|
||||
"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}",
|
||||
"SITES STANDBY": "SITIOS EN ESPERA",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ACTIVAR INSTALACIONES CARTOGRAFIADAS PARA PINGS DE SITIOS",
|
||||
"Release the camera from {name}": "Soltar la cámara de {name}",
|
||||
"RETURN · {name}": "VOLVER · {name}",
|
||||
"PREVIOUS SATELLITE": "SATÉLITE ANTERIOR",
|
||||
"Return to {name}": "Volver a {name}",
|
||||
"SATELLITE": "SATÉLITE",
|
||||
"Ride {name}": "Viajar en {name}",
|
||||
"Unnamed site": "Sitio sin nombre",
|
||||
"OFF-NADIR {angle}°": "FUERA DEL NADIR {angle}°",
|
||||
"FOCUS · {name}": "ENFOQUE · {name}",
|
||||
"Keep the camera on {name}": "Mantener la cámara sobre {name}",
|
||||
"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",
|
||||
@@ -639,8 +717,6 @@
|
||||
"Ascent replay": "Repetición del ascenso",
|
||||
"Orbit replay": "Repetición en órbita",
|
||||
", paused": ", en pausa",
|
||||
"DENSE ···": "DENSO ···",
|
||||
"DENSE ✕": "DENSO ✕",
|
||||
"Add the full Starlink broadband shell (thousands of extra points)": "Añade la capa completa de banda ancha de Starlink (miles de puntos extra)",
|
||||
"Loading the Starlink shell…": "Cargando la capa de Starlink…",
|
||||
"Starlink {detail} — click to retry": "Starlink {detail} — clic para reintentar",
|
||||
@@ -1604,7 +1680,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",
|
||||
@@ -3260,6 +3340,39 @@
|
||||
"Refresh map still": "Actualizar imagen fija del mapa",
|
||||
"The map still uses a separate request and may show a different moment.": "La imagen del mapa usa una solicitud independiente y puede mostrar otro momento.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP.",
|
||||
"STATIONS": "ESTACIONES",
|
||||
"Crewed stations and the craft that visit them": "Estaciones tripuladas y las naves que las visitan",
|
||||
"IMAGING": "IMÁGENES",
|
||||
"Earth observation: optical, radar, weather and science": "Observación de la Tierra: óptica, radar, meteorología y ciencia",
|
||||
"TELECOM": "TELECOM",
|
||||
"Communications and navigation satellites, Starlink excepted": "Satélites de comunicaciones y navegación, excepto Starlink",
|
||||
"STARLINK": "STARLINK",
|
||||
"The Starlink constellation (thousands of extra points)": "La constelación Starlink (miles de puntos adicionales)",
|
||||
"PROBES": "SONDAS",
|
||||
"Space science, technology demonstration and other payloads": "Ciencia espacial, demostración tecnológica y otras cargas útiles",
|
||||
"DEBRIS": "DESECHOS",
|
||||
"Rocket bodies, fragments and dead satellites": "Cuerpos de cohete, fragmentos y satélites inactivos",
|
||||
"Classes may lag: SATCAT is a cached copy": "Las clases pueden estar desfasadas: SATCAT es una copia en caché",
|
||||
"Classes may lag: SATCAT unavailable": "Las clases pueden estar desfasadas: SATCAT no disponible",
|
||||
"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 ✕",
|
||||
"SENSOR SWATH {value} KM": "BARRIDO DEL SENSOR {value} KM",
|
||||
"SENSOR SWATH ≈ {value} KM": "BARRIDO DEL SENSOR ≈ {value} KM",
|
||||
"SENSOR": "SENSOR",
|
||||
"Zoom to the imaging sensor swath": "Ampliar al barrido del sensor de imagen",
|
||||
"<1 H OLD": "HACE <1 H",
|
||||
"{value} H OLD": "HACE {value} H",
|
||||
"{value} D OLD": "HACE {value} D",
|
||||
"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",
|
||||
|
||||
+116
-3
@@ -827,6 +827,40 @@
|
||||
"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}",
|
||||
"SITES STANDBY": "SITES EN VEILLE",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ACTIVER LES INSTALLATIONS CARTOGRAPHIÉES POUR LES PINGS DE SITES",
|
||||
"Release the camera from {name}": "Libérer la caméra de {name}",
|
||||
"RETURN · {name}": "RETOUR · {name}",
|
||||
"PREVIOUS SATELLITE": "SATELLITE PRÉCÉDENT",
|
||||
"Return to {name}": "Revenir à {name}",
|
||||
"SATELLITE": "SATELLITE",
|
||||
"Ride {name}": "Suivre {name}",
|
||||
"Unnamed site": "Site sans nom",
|
||||
"OFF-NADIR {angle}°": "HORS NADIR {angle}°",
|
||||
"FOCUS · {name}": "CENTRÉ · {name}",
|
||||
"Keep the camera on {name}": "Garder la caméra sur {name}",
|
||||
"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",
|
||||
@@ -1023,8 +1057,6 @@
|
||||
"Starlink {detail} — click to retry": "Starlink {detail} — cliquer pour réessayer",
|
||||
"load failed": "échec du chargement",
|
||||
"Showing the full Starlink shell — click for the core catalog only": "Constellation Starlink complète affichée — cliquer pour le catalogue de base seulement",
|
||||
"DENSE ···": "DENSE ···",
|
||||
"DENSE ✕": "DENSE ✕",
|
||||
"DENSE": "DENSE",
|
||||
"CelesTrak unreachable": "CelesTrak injoignable",
|
||||
"DOCKED · {name} · +{extra}": "AMARRÉ · {name} · +{extra}",
|
||||
@@ -1700,8 +1732,14 @@
|
||||
"COMMERCIAL": "COMMERCIAL",
|
||||
"{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}",
|
||||
"{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · ALIGNÉ SUR LA ROUTE",
|
||||
"{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · HAUTEUR D'ŒIL {height}",
|
||||
"LIVE AIS": "AIS EN DIRECT",
|
||||
"ARROW · ESTIMATED DIRECTION": "FLÈCHE · DIRECTION ESTIMÉE",
|
||||
"ROUTE DATA UNAVAILABLE": "DONNÉES DE ROUTE INDISPONIBLES",
|
||||
"CONTACT NAVIGATION OFF": "NAVIGATION DES CONTACTS DÉSACTIVÉE",
|
||||
"THE BRIDGE STAYS WITH THE SELECTED VESSEL": "LA PASSERELLE RESTE SUR LE NAVIRE SÉLECTIONNÉ",
|
||||
"{range} NM": "{range} MN",
|
||||
"AIS DESTINATION · CREW REPORTED": "DESTINATION AIS · DÉCLARÉE PAR L'ÉQUIPAGE",
|
||||
"DEST {bearing}": "DEST {bearing}",
|
||||
"Disable cockpit weather effects": "Désactiver les effets météo du cockpit",
|
||||
"Enable cockpit weather effects": "Activer les effets météo du cockpit",
|
||||
@@ -1748,6 +1786,9 @@
|
||||
"Reclassify as TR-3B": "Reclasser en TR-3B",
|
||||
"TRACK ACQUIRED": "PISTE ACQUISE",
|
||||
"{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": {
|
||||
@@ -2697,14 +2738,28 @@
|
||||
"Recent speech": "Propos récents",
|
||||
"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",
|
||||
"UP {angle}°": "HAUT {angle}°",
|
||||
"DOWN {angle}°": "BAS {angle}°",
|
||||
"LEVEL": "HORIZON",
|
||||
"{direction} · {elevation}": "{direction} · {elevation}",
|
||||
"LOOK {elevation}": "VUE {elevation}",
|
||||
"Estimated destination direction": "Direction estimée de la destination",
|
||||
"DEST ---°": "DEST ---°",
|
||||
"BRIDGE WINDOW · FWD": "VITRE PASSERELLE · AVANT",
|
||||
"OPTICAL PLANE · 01": "PLAN OPTIQUE · 01",
|
||||
"HEADING HOLD · AIS": "VERROU CAP · AIS",
|
||||
"NO HEADING · AIS": "SANS CAP · AIS",
|
||||
"VISOR LOCK · ACTIVE": "VERROU VISIÈRE · ACTIF",
|
||||
"GROUND SPEED · KTS": "VITESSE SOL · ND",
|
||||
"SPEED OVER GROUND · KTS": "VITESSE FOND · ND",
|
||||
"ALTITUDE · FT": "ALTITUDE · FT",
|
||||
"FIRST PERSON": "PREMIÈRE PERSONNE",
|
||||
"BRIDGE VIEW": "VUE PASSERELLE",
|
||||
"LIVE TRACK · COURSE ALIGNED": "PISTE EN DIRECT · ALIGNÉ SUR LA ROUTE",
|
||||
"Cockpit vision style": "Style de vision du cockpit",
|
||||
"Previous cockpit vision style": "Style de vision précédent du cockpit",
|
||||
@@ -2713,13 +2768,27 @@
|
||||
"Next cockpit vision style": "Style de vision suivant du cockpit",
|
||||
"Next vision style": "Style de vision suivant",
|
||||
"Current aircraft heading": "Cap actuel de l'aéronef",
|
||||
"Look ahead: turn the bridge view back to the bow": "Vue avant : ramener la vue passerelle vers l'étrave",
|
||||
"Look ahead (Home or 0)": "Vue avant (Home ou 0)",
|
||||
"LOOK AHEAD": "VUE AVANT",
|
||||
"Current vessel heading": "Cap actuel du navire",
|
||||
"GROUND SPEED": "VITESSE SOL",
|
||||
"SPEED OVER GROUND": "VITESSE FOND",
|
||||
"COURSE OVER GROUND": "ROUTE FOND",
|
||||
"KTS": "ND",
|
||||
"ALTITUDE": "ALTITUDE",
|
||||
"FT": "FT",
|
||||
"Contact cockpit summary": "Résumé cockpit du contact",
|
||||
"CONTACT": "CONTACT",
|
||||
"CONTACTS · 250 KM": "CONTACTS · 250 KM",
|
||||
"RINGS {spacing} NM": "CERCLES {spacing} MN",
|
||||
"{count} CONTACTS": {
|
||||
"one": "{count} CONTACT",
|
||||
"many": "{count} DE CONTACTS",
|
||||
"other": "{count} CONTACTS"
|
||||
},
|
||||
"Radar range {range}. Select for the next range.": "Portée radar {range}. Sélectionnez pour passer à la portée suivante.",
|
||||
"Bridge radar": "Radar de passerelle",
|
||||
"Contact navigation": "Navigation des contacts",
|
||||
"CONTEXT ONLY": "CONTEXTE SEULEMENT",
|
||||
"Nearby cohort counts": "Nombre de contacts par groupe à proximité",
|
||||
@@ -2731,7 +2800,9 @@
|
||||
"WX": "MÉTÉO",
|
||||
"Cockpit briefing carousel": "Carrousel de briefing du cockpit",
|
||||
"Estimated flight plan": "Plan de vol estimé",
|
||||
"Reported voyage": "Voyage déclaré",
|
||||
"ESTIMATED FLIGHT PLAN": "PLAN DE VOL ESTIMÉ",
|
||||
"REPORTED VOYAGE": "VOYAGE DÉCLARÉ",
|
||||
"FROM": "DE",
|
||||
"TO": "VERS",
|
||||
"Cockpit briefing controls": "Commandes du briefing du cockpit",
|
||||
@@ -2764,12 +2835,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",
|
||||
@@ -2802,6 +2881,7 @@
|
||||
"SELECT CONTEXT": "CHOISIR UN CONTEXTE",
|
||||
"Contact Context actions": "Actions du contexte Contact",
|
||||
"COCKPIT": "COCKPIT",
|
||||
"BRIDGE": "PASSERELLE",
|
||||
"SEARCH NEARBY SITES": "CHERCHER SITES PROCHES",
|
||||
"Reclassify tracked contact as TR-3B": "Reclasser le contact suivi en TR-3B",
|
||||
"CONTACTS CONTEXT OFF": "CONTEXTE CONTACTS DÉSACTIVÉ",
|
||||
@@ -3260,6 +3340,39 @@
|
||||
"Refresh map still": "Actualiser l’image fixe sur la carte",
|
||||
"The map still uses a separate request and may show a different moment.": "L’image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP.",
|
||||
"STATIONS": "STATIONS",
|
||||
"Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s’y rendent",
|
||||
"IMAGING": "IMAGERIE",
|
||||
"Earth observation: optical, radar, weather and science": "Observation de la Terre : optique, radar, météo et science",
|
||||
"TELECOM": "TÉLÉCOM",
|
||||
"Communications and navigation satellites, Starlink excepted": "Satellites de communication et de navigation, hors Starlink",
|
||||
"STARLINK": "STARLINK",
|
||||
"The Starlink constellation (thousands of extra points)": "La constellation Starlink (des milliers de points supplémentaires)",
|
||||
"PROBES": "SONDES",
|
||||
"Space science, technology demonstration and other payloads": "Sciences spatiales, démonstrations technologiques et autres charges utiles",
|
||||
"DEBRIS": "DÉBRIS",
|
||||
"Rocket bodies, fragments and dead satellites": "Étages de fusée, fragments et satellites hors service",
|
||||
"Classes may lag: SATCAT is a cached copy": "Classes peut-être en retard : SATCAT est une copie en cache",
|
||||
"Classes may lag: SATCAT unavailable": "Classes peut-être en retard : SATCAT indisponible",
|
||||
"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 ✕",
|
||||
"SENSOR SWATH {value} KM": "FAUCHÉE CAPTEUR {value} KM",
|
||||
"SENSOR SWATH ≈ {value} KM": "FAUCHÉE CAPTEUR ≈ {value} KM",
|
||||
"SENSOR": "CAPTEUR",
|
||||
"Zoom to the imaging sensor swath": "Zoomer sur la fauchée du capteur imageur",
|
||||
"<1 H OLD": "IL Y A <1 H",
|
||||
"{value} H OLD": "IL Y A {value} H",
|
||||
"{value} D OLD": "IL Y A {value} J",
|
||||
"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",
|
||||
|
||||
+116
-3
@@ -827,6 +827,40 @@
|
||||
"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}",
|
||||
"SITES STANDBY": "SITI IN ATTESA",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ATTIVA LE INSTALLAZIONI MAPPATE PER I PING DEI SITI",
|
||||
"Release the camera from {name}": "Sblocca la camera da {name}",
|
||||
"RETURN · {name}": "TORNA · {name}",
|
||||
"PREVIOUS SATELLITE": "SATELLITE PRECEDENTE",
|
||||
"Return to {name}": "Torna a {name}",
|
||||
"SATELLITE": "SATELLITE",
|
||||
"Ride {name}": "Segui {name}",
|
||||
"Unnamed site": "Sito senza nome",
|
||||
"OFF-NADIR {angle}°": "FUORI NADIR {angle}°",
|
||||
"FOCUS · {name}": "PUNTAMENTO · {name}",
|
||||
"Keep the camera on {name}": "Mantieni la camera su {name}",
|
||||
"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",
|
||||
@@ -1023,8 +1057,6 @@
|
||||
"Starlink {detail} — click to retry": "Starlink {detail}: clic per riprovare",
|
||||
"load failed": "caricamento non riuscito",
|
||||
"Showing the full Starlink shell — click for the core catalog only": "Shell Starlink completo visualizzato: clic per mostrare solo il catalogo principale",
|
||||
"DENSE ···": "DENSO ···",
|
||||
"DENSE ✕": "DENSO ✕",
|
||||
"DENSE": "DENSO",
|
||||
"CelesTrak unreachable": "CelesTrak irraggiungibile",
|
||||
"DOCKED · {name} · +{extra}": "AGGANCIATO · {name} · +{extra}",
|
||||
@@ -1700,8 +1732,14 @@
|
||||
"COMMERCIAL": "COMMERCIALE",
|
||||
"{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}",
|
||||
"{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · ROTTA ALLINEATA",
|
||||
"{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ALTEZZA OCCHIO {height}",
|
||||
"LIVE AIS": "AIS LIVE",
|
||||
"ARROW · ESTIMATED DIRECTION": "FRECCIA · DIREZIONE STIMATA",
|
||||
"ROUTE DATA UNAVAILABLE": "DATI ROTTA NON DISPONIBILI",
|
||||
"CONTACT NAVIGATION OFF": "NAVIGAZIONE CONTATTI DISATTIVATA",
|
||||
"THE BRIDGE STAYS WITH THE SELECTED VESSEL": "LA PLANCIA RESTA SULLA NAVE SELEZIONATA",
|
||||
"{range} NM": "{range} MN",
|
||||
"AIS DESTINATION · CREW REPORTED": "DESTINAZIONE AIS · DICHIARATA DALL'EQUIPAGGIO",
|
||||
"DEST {bearing}": "DEST {bearing}",
|
||||
"Disable cockpit weather effects": "Disattiva gli effetti meteo del cockpit",
|
||||
"Enable cockpit weather effects": "Attiva gli effetti meteo del cockpit",
|
||||
@@ -1748,6 +1786,9 @@
|
||||
"Reclassify as TR-3B": "Riclassifica come TR-3B",
|
||||
"TRACK ACQUIRED": "TRACCIA ACQUISITA",
|
||||
"{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": {
|
||||
@@ -2697,14 +2738,28 @@
|
||||
"Recent speech": "Parlato recente",
|
||||
"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",
|
||||
"UP {angle}°": "SU {angle}°",
|
||||
"DOWN {angle}°": "GIÙ {angle}°",
|
||||
"LEVEL": "ORIZZONTE",
|
||||
"{direction} · {elevation}": "{direction} · {elevation}",
|
||||
"LOOK {elevation}": "VISTA {elevation}",
|
||||
"Estimated destination direction": "Direzione stimata della destinazione",
|
||||
"DEST ---°": "DEST ---°",
|
||||
"BRIDGE WINDOW · FWD": "FINESTRA PLANCIA · AVANTI",
|
||||
"OPTICAL PLANE · 01": "PIANO OTTICO · 01",
|
||||
"HEADING HOLD · AIS": "BLOCCO PRUA · AIS",
|
||||
"NO HEADING · AIS": "NESSUNA PRUA · AIS",
|
||||
"VISOR LOCK · ACTIVE": "BLOCCO VISIERA · ATTIVO",
|
||||
"GROUND SPEED · KTS": "VEL. AL SUOLO · KTS",
|
||||
"SPEED OVER GROUND · KTS": "VEL. SUL FONDO · KTS",
|
||||
"ALTITUDE · FT": "QUOTA · FT",
|
||||
"FIRST PERSON": "PRIMA PERSONA",
|
||||
"BRIDGE VIEW": "VISTA PLANCIA",
|
||||
"LIVE TRACK · COURSE ALIGNED": "TRACCIA LIVE · ROTTA ALLINEATA",
|
||||
"Cockpit vision style": "Stile di visione del cockpit",
|
||||
"Previous cockpit vision style": "Stile di visione precedente del cockpit",
|
||||
@@ -2713,13 +2768,27 @@
|
||||
"Next cockpit vision style": "Stile di visione successivo del cockpit",
|
||||
"Next vision style": "Stile di visione successivo",
|
||||
"Current aircraft heading": "Prua attuale dell'aereo",
|
||||
"Look ahead: turn the bridge view back to the bow": "Guarda a prua: riporta la vista plancia verso la prua",
|
||||
"Look ahead (Home or 0)": "Guarda a prua (Home o 0)",
|
||||
"LOOK AHEAD": "GUARDA A PRUA",
|
||||
"Current vessel heading": "Prua attuale della nave",
|
||||
"GROUND SPEED": "VEL. AL SUOLO",
|
||||
"SPEED OVER GROUND": "VEL. SUL FONDO",
|
||||
"COURSE OVER GROUND": "ROTTA SUL FONDO",
|
||||
"KTS": "KTS",
|
||||
"ALTITUDE": "ALTITUDINE",
|
||||
"FT": "FT",
|
||||
"Contact cockpit summary": "Riepilogo contatto del cockpit",
|
||||
"CONTACT": "CONTATTO",
|
||||
"CONTACTS · 250 KM": "CONTATTI · 250 KM",
|
||||
"RINGS {spacing} NM": "CERCHI {spacing} MN",
|
||||
"{count} CONTACTS": {
|
||||
"one": "{count} CONTATTO",
|
||||
"many": "{count} DI CONTATTI",
|
||||
"other": "{count} CONTATTI"
|
||||
},
|
||||
"Radar range {range}. Select for the next range.": "Portata radar {range}. Seleziona per passare alla portata successiva.",
|
||||
"Bridge radar": "Radar di plancia",
|
||||
"Contact navigation": "Navigazione contatti",
|
||||
"CONTEXT ONLY": "SOLO CONTESTO",
|
||||
"Nearby cohort counts": "Conteggi dei gruppi vicini",
|
||||
@@ -2731,7 +2800,9 @@
|
||||
"WX": "METEO",
|
||||
"Cockpit briefing carousel": "Carosello briefing del cockpit",
|
||||
"Estimated flight plan": "Piano di volo stimato",
|
||||
"Reported voyage": "Viaggio dichiarato",
|
||||
"ESTIMATED FLIGHT PLAN": "PIANO DI VOLO STIMATO",
|
||||
"REPORTED VOYAGE": "VIAGGIO DICHIARATO",
|
||||
"FROM": "DA",
|
||||
"TO": "A",
|
||||
"Cockpit briefing controls": "Controlli briefing del cockpit",
|
||||
@@ -2764,12 +2835,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",
|
||||
@@ -2802,6 +2881,7 @@
|
||||
"SELECT CONTEXT": "SELEZIONA CONTESTO",
|
||||
"Contact Context actions": "Azioni Contesto contatto",
|
||||
"COCKPIT": "COCKPIT",
|
||||
"BRIDGE": "PLANCIA",
|
||||
"SEARCH NEARBY SITES": "CERCA SITI VICINI",
|
||||
"Reclassify tracked contact as TR-3B": "Riclassifica il contatto tracciato come TR-3B",
|
||||
"CONTACTS CONTEXT OFF": "CONTESTO CONTATTI DISATTIVATO",
|
||||
@@ -3260,6 +3340,39 @@
|
||||
"Refresh map still": "Aggiorna immagine fissa sulla mappa",
|
||||
"The map still uses a separate request and may show a different moment.": "L’immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP.",
|
||||
"STATIONS": "STAZIONI",
|
||||
"Crewed stations and the craft that visit them": "Stazioni con equipaggio e i veicoli che le visitano",
|
||||
"IMAGING": "IMMAGINI",
|
||||
"Earth observation: optical, radar, weather and science": "Osservazione della Terra: ottica, radar, meteo e scienza",
|
||||
"TELECOM": "TELECOM",
|
||||
"Communications and navigation satellites, Starlink excepted": "Satelliti di comunicazione e navigazione, Starlink escluso",
|
||||
"STARLINK": "STARLINK",
|
||||
"The Starlink constellation (thousands of extra points)": "La costellazione Starlink (migliaia di punti in più)",
|
||||
"PROBES": "SONDE",
|
||||
"Space science, technology demonstration and other payloads": "Scienza spaziale, dimostrazioni tecnologiche e altri carichi utili",
|
||||
"DEBRIS": "DETRITI",
|
||||
"Rocket bodies, fragments and dead satellites": "Stadi di razzi, frammenti e satelliti non operativi",
|
||||
"Classes may lag: SATCAT is a cached copy": "Le classi potrebbero essere in ritardo: SATCAT è una copia in cache",
|
||||
"Classes may lag: SATCAT unavailable": "Le classi potrebbero essere in ritardo: SATCAT non disponibile",
|
||||
"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 ✕",
|
||||
"SENSOR SWATH {value} KM": "STRISCIATA SENSORE {value} KM",
|
||||
"SENSOR SWATH ≈ {value} KM": "STRISCIATA SENSORE ≈ {value} KM",
|
||||
"SENSOR": "SENSORE",
|
||||
"Zoom to the imaging sensor swath": "Zoom sulla strisciata del sensore di ripresa",
|
||||
"<1 H OLD": "<1 H FA",
|
||||
"{value} H OLD": "{value} H FA",
|
||||
"{value} D OLD": "{value} G FA",
|
||||
"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",
|
||||
|
||||
+114
-3
@@ -801,6 +801,40 @@
|
||||
"Select {mission}": "{mission}を選択",
|
||||
"{value} KM": "{value} km",
|
||||
"{value} KM/S": "{value} km/s",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "オフナディア {angle}° · {offset}",
|
||||
"SITES STANDBY": "地点待機",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "マップ済み施設を有効にすると地点ピングを表示",
|
||||
"Release the camera from {name}": "{name} からカメラを解放",
|
||||
"RETURN · {name}": "戻る · {name}",
|
||||
"PREVIOUS SATELLITE": "前の衛星",
|
||||
"Return to {name}": "{name} に戻る",
|
||||
"SATELLITE": "衛星",
|
||||
"Ride {name}": "{name} に乗る",
|
||||
"Unnamed site": "名称のない地点",
|
||||
"OFF-NADIR {angle}°": "オフナディア {angle}°",
|
||||
"FOCUS · {name}": "フォーカス · {name}",
|
||||
"Keep the camera on {name}": "カメラを {name} に向け続ける",
|
||||
"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": "すべてのミッションを表示",
|
||||
@@ -991,8 +1025,6 @@
|
||||
"Starlink {detail} — click to retry": "Starlink {detail}:クリックで再試行",
|
||||
"load failed": "読込失敗",
|
||||
"Showing the full Starlink shell — click for the core catalog only": "Starlinkシェル全体を表示中:クリックでコアカタログのみに戻す",
|
||||
"DENSE ···": "高密度 ···",
|
||||
"DENSE ✕": "高密度 ✕",
|
||||
"DENSE": "高密度",
|
||||
"CelesTrak unreachable": "CelesTrakに接続できません",
|
||||
"DOCKED · {name} · +{extra}": "ドッキング中 · {name} · +{extra}",
|
||||
@@ -1650,8 +1682,14 @@
|
||||
"COMMERCIAL": "民間",
|
||||
"{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}",
|
||||
"{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · 針路一致",
|
||||
"{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · 眼高 {height}",
|
||||
"LIVE AIS": "ライブAIS",
|
||||
"ARROW · ESTIMATED DIRECTION": "矢印 · 推定方向",
|
||||
"ROUTE DATA UNAVAILABLE": "経路データなし",
|
||||
"CONTACT NAVIGATION OFF": "コンタクトナビゲーション オフ",
|
||||
"THE BRIDGE STAYS WITH THE SELECTED VESSEL": "ブリッジは選択中の船舶に固定",
|
||||
"{range} NM": "{range}海里",
|
||||
"AIS DESTINATION · CREW REPORTED": "AIS目的地 · 乗組員入力",
|
||||
"DEST {bearing}": "目的地 {bearing}",
|
||||
"Disable cockpit weather effects": "コックピットの天候エフェクトを無効にする",
|
||||
"Enable cockpit weather effects": "コックピットの天候エフェクトを有効にする",
|
||||
@@ -1696,6 +1734,9 @@
|
||||
"Reclassify as TR-3B": "TR-3Bに再分類",
|
||||
"TRACK ACQUIRED": "追跡開始",
|
||||
"{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": {
|
||||
@@ -2555,14 +2596,28 @@
|
||||
"Recent speech": "最近の発話",
|
||||
"HERE": "ここ",
|
||||
"Aircraft cockpit view": "航空機コックピットビュー",
|
||||
"Ship bridge view": "船舶ブリッジビュー",
|
||||
"Satellite orbital view": "衛星軌道ビュー",
|
||||
"LOOK STBD {bearing}°": "視線 右舷 {bearing}°",
|
||||
"LOOK PORT {bearing}°": "視線 左舷 {bearing}°",
|
||||
"LOOK ASTERN": "視線 船尾方向",
|
||||
"UP {angle}°": "上 {angle}°",
|
||||
"DOWN {angle}°": "下 {angle}°",
|
||||
"LEVEL": "水平",
|
||||
"{direction} · {elevation}": "{direction} · {elevation}",
|
||||
"LOOK {elevation}": "視線 {elevation}",
|
||||
"Estimated destination direction": "推定目的地の方向",
|
||||
"DEST ---°": "目的地 ---°",
|
||||
"BRIDGE WINDOW · FWD": "ブリッジ窓 · 前方",
|
||||
"OPTICAL PLANE · 01": "光学面 · 01",
|
||||
"HEADING HOLD · AIS": "船首方位保持 · AIS",
|
||||
"NO HEADING · AIS": "船首方位なし · AIS",
|
||||
"VISOR LOCK · ACTIVE": "バイザーロック · 有効",
|
||||
"GROUND SPEED · KTS": "対地速度 · KT",
|
||||
"SPEED OVER GROUND · KTS": "対地速力 · KT",
|
||||
"ALTITUDE · FT": "高度 · FT",
|
||||
"FIRST PERSON": "一人称視点",
|
||||
"BRIDGE VIEW": "ブリッジビュー",
|
||||
"LIVE TRACK · COURSE ALIGNED": "ライブ追跡 · 針路一致",
|
||||
"Cockpit vision style": "コックピットのビジョンスタイル",
|
||||
"Previous cockpit vision style": "前のコックピットビジョンスタイル",
|
||||
@@ -2571,13 +2626,25 @@
|
||||
"Next cockpit vision style": "次のコックピットビジョンスタイル",
|
||||
"Next vision style": "次のビジョンスタイル",
|
||||
"Current aircraft heading": "現在の機首方位",
|
||||
"Look ahead: turn the bridge view back to the bow": "前方を見る:ブリッジビューを船首方向に戻す",
|
||||
"Look ahead (Home or 0)": "前方を見る(Homeまたは0)",
|
||||
"LOOK AHEAD": "前方を見る",
|
||||
"Current vessel heading": "現在の船首方位",
|
||||
"GROUND SPEED": "対地速度",
|
||||
"SPEED OVER GROUND": "対地速力",
|
||||
"COURSE OVER GROUND": "対地針路",
|
||||
"KTS": "KT",
|
||||
"ALTITUDE": "高度",
|
||||
"FT": "FT",
|
||||
"Contact cockpit summary": "コンタクトのコックピット概要",
|
||||
"CONTACT": "コンタクト",
|
||||
"CONTACTS · 250 KM": "コンタクト · 250 km",
|
||||
"RINGS {spacing} NM": "距離環 {spacing}海里",
|
||||
"{count} CONTACTS": {
|
||||
"other": "コンタクト {count}件"
|
||||
},
|
||||
"Radar range {range}. Select for the next range.": "レーダーレンジ {range}。選択すると次のレンジに切り替わります。",
|
||||
"Bridge radar": "ブリッジレーダー",
|
||||
"Contact navigation": "コンタクトナビゲーション",
|
||||
"CONTEXT ONLY": "参考情報のみ",
|
||||
"Nearby cohort counts": "周辺の種別ごとの件数",
|
||||
@@ -2589,7 +2656,9 @@
|
||||
"WX": "気象",
|
||||
"Cockpit briefing carousel": "コックピットブリーフィングカルーセル",
|
||||
"Estimated flight plan": "推定フライトプラン",
|
||||
"Reported voyage": "申告された航海",
|
||||
"ESTIMATED FLIGHT PLAN": "推定フライトプラン",
|
||||
"REPORTED VOYAGE": "申告された航海",
|
||||
"FROM": "出発",
|
||||
"TO": "到着",
|
||||
"Cockpit briefing controls": "コックピットブリーフィングの操作",
|
||||
@@ -2622,12 +2691,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": "ビジュアルプリセットを固定",
|
||||
@@ -2660,6 +2737,7 @@
|
||||
"SELECT CONTEXT": "コンテキストを選択",
|
||||
"Contact Context actions": "コンタクトコンテキストの操作",
|
||||
"COCKPIT": "コックピット",
|
||||
"BRIDGE": "ブリッジ",
|
||||
"SEARCH NEARBY SITES": "周辺施設を検索",
|
||||
"Reclassify tracked contact as TR-3B": "追跡中のコンタクトをTR-3Bに再分類",
|
||||
"CONTACTS CONTEXT OFF": "コンタクトコンテキスト オフ",
|
||||
@@ -3110,6 +3188,39 @@
|
||||
"Refresh map still": "地図上の静止画を更新",
|
||||
"The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。",
|
||||
"Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。",
|
||||
"STATIONS": "ステーション",
|
||||
"Crewed stations and the craft that visit them": "有人宇宙ステーションと、そこを訪れる宇宙機",
|
||||
"IMAGING": "地球観測",
|
||||
"Earth observation: optical, radar, weather and science": "地球観測:光学、レーダー、気象、科学",
|
||||
"TELECOM": "通信",
|
||||
"Communications and navigation satellites, Starlink excepted": "通信・測位衛星(Starlinkを除く)",
|
||||
"STARLINK": "STARLINK",
|
||||
"The Starlink constellation (thousands of extra points)": "Starlinkコンステレーション(数千個の追加ポイント)",
|
||||
"PROBES": "探査機",
|
||||
"Space science, technology demonstration and other payloads": "宇宙科学、技術実証、その他のペイロード",
|
||||
"DEBRIS": "デブリ",
|
||||
"Rocket bodies, fragments and dead satellites": "ロケット機体、破片、運用を終えた衛星",
|
||||
"Classes may lag: SATCAT is a cached copy": "分類が古い可能性: SATCAT はキャッシュのコピーです",
|
||||
"Classes may lag: SATCAT unavailable": "分類が古い可能性: SATCAT を取得できません",
|
||||
"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 ✕",
|
||||
"SENSOR SWATH {value} KM": "センサー観測幅 {value} km",
|
||||
"SENSOR SWATH ≈ {value} KM": "センサー観測幅 ≈ {value} km",
|
||||
"SENSOR": "センサー",
|
||||
"Zoom to the imaging sensor swath": "撮像センサーの観測幅までズーム",
|
||||
"<1 H OLD": "1時間未満前",
|
||||
"{value} H OLD": "{value}時間前",
|
||||
"{value} D OLD": "{value}日前",
|
||||
"FWD": "前方",
|
||||
"AFT": "後方",
|
||||
"RIGHT": "右",
|
||||
"LEFT": "左",
|
||||
"TRACK UP": "トラックアップ",
|
||||
"COURSE HOLD · AIS": "対地針路保持 · AIS",
|
||||
"Zoom in (+)": "ズームイン (+)",
|
||||
"Zoom out (−)": "ズームアウト (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "軌道ビュー · オフナディア",
|
||||
"World time": "世界時刻",
|
||||
"Back to live": "ライブに戻る",
|
||||
"Live": "ライブ",
|
||||
|
||||
+116
-3
@@ -825,6 +825,40 @@
|
||||
"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}",
|
||||
"SITES STANDBY": "LOCAIS EM ESPERA",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ATIVE AS INSTALAÇÕES MAPEADAS PARA PINGS DE LOCAIS",
|
||||
"Release the camera from {name}": "Soltar a câmera de {name}",
|
||||
"RETURN · {name}": "VOLTAR · {name}",
|
||||
"PREVIOUS SATELLITE": "SATÉLITE ANTERIOR",
|
||||
"Return to {name}": "Voltar para {name}",
|
||||
"SATELLITE": "SATÉLITE",
|
||||
"Ride {name}": "Acompanhar {name}",
|
||||
"Unnamed site": "Local sem nome",
|
||||
"OFF-NADIR {angle}°": "FORA DO NADIR {angle}°",
|
||||
"FOCUS · {name}": "FOCO · {name}",
|
||||
"Keep the camera on {name}": "Manter a câmera em {name}",
|
||||
"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",
|
||||
@@ -1021,8 +1055,6 @@
|
||||
"Starlink {detail} — click to retry": "Starlink {detail} — clique para tentar novamente",
|
||||
"load failed": "falha ao carregar",
|
||||
"Showing the full Starlink shell — click for the core catalog only": "Mostrando toda a camada orbital da Starlink — clique para ver só o catálogo principal",
|
||||
"DENSE ···": "DENSO ···",
|
||||
"DENSE ✕": "DENSO ✕",
|
||||
"DENSE": "DENSO",
|
||||
"CelesTrak unreachable": "CelesTrak inacessível",
|
||||
"DOCKED · {name} · +{extra}": "ACOPLADO · {name} · +{extra}",
|
||||
@@ -1700,8 +1732,14 @@
|
||||
"COMMERCIAL": "COMERCIAL",
|
||||
"{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}",
|
||||
"{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · CURSO ALINHADO",
|
||||
"{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ALT. DO OLHO {height}",
|
||||
"LIVE AIS": "AIS AO VIVO",
|
||||
"ARROW · ESTIMATED DIRECTION": "SETA · DIREÇÃO ESTIMADA",
|
||||
"ROUTE DATA UNAVAILABLE": "DADOS DE ROTA INDISPONÍVEIS",
|
||||
"CONTACT NAVIGATION OFF": "NAVEGAÇÃO DE CONTATOS DESLIGADA",
|
||||
"THE BRIDGE STAYS WITH THE SELECTED VESSEL": "O PASSADIÇO PERMANECE NA EMBARCAÇÃO SELECIONADA",
|
||||
"{range} NM": "{range} MN",
|
||||
"AIS DESTINATION · CREW REPORTED": "DESTINO AIS · INFORMADO PELA TRIPULAÇÃO",
|
||||
"DEST {bearing}": "DEST {bearing}",
|
||||
"Disable cockpit weather effects": "Desativar efeitos de clima na cabine",
|
||||
"Enable cockpit weather effects": "Ativar efeitos de clima na cabine",
|
||||
@@ -1748,6 +1786,9 @@
|
||||
"Reclassify as TR-3B": "Reclassificar como TR-3B",
|
||||
"TRACK ACQUIRED": "RASTRO ADQUIRIDO",
|
||||
"{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": {
|
||||
@@ -2699,14 +2740,28 @@
|
||||
"Recent speech": "Fala recente",
|
||||
"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É",
|
||||
"UP {angle}°": "ACIMA {angle}°",
|
||||
"DOWN {angle}°": "ABAIXO {angle}°",
|
||||
"LEVEL": "NIVELADO",
|
||||
"{direction} · {elevation}": "{direction} · {elevation}",
|
||||
"LOOK {elevation}": "VISTA {elevation}",
|
||||
"Estimated destination direction": "Direção estimada do destino",
|
||||
"DEST ---°": "DEST ---°",
|
||||
"BRIDGE WINDOW · FWD": "JANELA DO PASSADIÇO · VANTE",
|
||||
"OPTICAL PLANE · 01": "PLANO ÓPTICO · 01",
|
||||
"HEADING HOLD · AIS": "TRAVA DE PROA · AIS",
|
||||
"NO HEADING · AIS": "SEM PROA · AIS",
|
||||
"VISOR LOCK · ACTIVE": "TRAVA DO VISOR · ATIVA",
|
||||
"GROUND SPEED · KTS": "VELOC. SOLO · KT",
|
||||
"SPEED OVER GROUND · KTS": "VELOC. NO FUNDO · KT",
|
||||
"ALTITUDE · FT": "ALTITUDE · FT",
|
||||
"FIRST PERSON": "PRIMEIRA PESSOA",
|
||||
"BRIDGE VIEW": "VISTA DO PASSADIÇO",
|
||||
"LIVE TRACK · COURSE ALIGNED": "RASTRO AO VIVO · CURSO ALINHADO",
|
||||
"Cockpit vision style": "Estilo de visão da cabine",
|
||||
"Previous cockpit vision style": "Estilo anterior de visão da cabine",
|
||||
@@ -2715,13 +2770,27 @@
|
||||
"Next cockpit vision style": "Próximo estilo de visão da cabine",
|
||||
"Next vision style": "Próximo estilo de visão",
|
||||
"Current aircraft heading": "Rumo atual da aeronave",
|
||||
"Look ahead: turn the bridge view back to the bow": "Olhar à vante: voltar a vista do passadiço para a proa",
|
||||
"Look ahead (Home or 0)": "Olhar à vante (Home ou 0)",
|
||||
"LOOK AHEAD": "OLHAR À VANTE",
|
||||
"Current vessel heading": "Proa atual da embarcação",
|
||||
"GROUND SPEED": "VELOC. SOLO",
|
||||
"SPEED OVER GROUND": "VELOC. NO FUNDO",
|
||||
"COURSE OVER GROUND": "RUMO NO FUNDO",
|
||||
"KTS": "KT",
|
||||
"ALTITUDE": "ALTITUDE",
|
||||
"FT": "FT",
|
||||
"Contact cockpit summary": "Resumo do contato na cabine",
|
||||
"CONTACT": "CONTATO",
|
||||
"CONTACTS · 250 KM": "CONTATOS · 250 KM",
|
||||
"RINGS {spacing} NM": "ANÉIS {spacing} MN",
|
||||
"{count} CONTACTS": {
|
||||
"one": "{count} CONTATO",
|
||||
"many": "{count} DE CONTATOS",
|
||||
"other": "{count} CONTATOS"
|
||||
},
|
||||
"Radar range {range}. Select for the next range.": "Alcance do radar {range}. Selecione para passar ao próximo alcance.",
|
||||
"Bridge radar": "Radar do passadiço",
|
||||
"Contact navigation": "Navegação de contatos",
|
||||
"CONTEXT ONLY": "SÓ CONTEXTO",
|
||||
"Nearby cohort counts": "Contagens por categoria nas proximidades",
|
||||
@@ -2733,7 +2802,9 @@
|
||||
"WX": "WX",
|
||||
"Cockpit briefing carousel": "Carrossel de briefing da cabine",
|
||||
"Estimated flight plan": "Plano de voo estimado",
|
||||
"Reported voyage": "Viagem informada",
|
||||
"ESTIMATED FLIGHT PLAN": "PLANO DE VOO ESTIMADO",
|
||||
"REPORTED VOYAGE": "VIAGEM INFORMADA",
|
||||
"FROM": "DE",
|
||||
"TO": "PARA",
|
||||
"Cockpit briefing controls": "Controles do briefing da cabine",
|
||||
@@ -2766,12 +2837,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",
|
||||
@@ -2804,6 +2883,7 @@
|
||||
"SELECT CONTEXT": "SELECIONE O CONTEXTO",
|
||||
"Contact Context actions": "Ações de contexto do contato",
|
||||
"COCKPIT": "CABINE",
|
||||
"BRIDGE": "PASSADIÇO",
|
||||
"SEARCH NEARBY SITES": "BUSCAR LOCAIS PRÓXIMOS",
|
||||
"Reclassify tracked contact as TR-3B": "Reclassificar o contato rastreado como TR-3B",
|
||||
"CONTACTS CONTEXT OFF": "CONTEXTO DE CONTATOS DESLIGADO",
|
||||
@@ -3260,6 +3340,39 @@
|
||||
"Refresh map still": "Atualizar imagem fixa no mapa",
|
||||
"The map still uses a separate request and may show a different moment.": "A imagem no mapa usa uma solicitação separada e pode mostrar outro momento.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP.",
|
||||
"STATIONS": "ESTAÇÕES",
|
||||
"Crewed stations and the craft that visit them": "Estações tripuladas e as naves que as visitam",
|
||||
"IMAGING": "IMAGENS",
|
||||
"Earth observation: optical, radar, weather and science": "Observação da Terra: óptica, radar, meteorologia e ciência",
|
||||
"TELECOM": "TELECOM",
|
||||
"Communications and navigation satellites, Starlink excepted": "Satélites de comunicações e navegação, exceto Starlink",
|
||||
"STARLINK": "STARLINK",
|
||||
"The Starlink constellation (thousands of extra points)": "A constelação Starlink (milhares de pontos extras)",
|
||||
"PROBES": "SONDAS",
|
||||
"Space science, technology demonstration and other payloads": "Ciência espacial, demonstração tecnológica e outras cargas úteis",
|
||||
"DEBRIS": "DETRITOS",
|
||||
"Rocket bodies, fragments and dead satellites": "Corpos de foguetes, fragmentos e satélites inativos",
|
||||
"Classes may lag: SATCAT is a cached copy": "As classes podem estar desatualizadas: SATCAT é uma cópia em cache",
|
||||
"Classes may lag: SATCAT unavailable": "As classes podem estar desatualizadas: SATCAT indisponível",
|
||||
"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 ✕",
|
||||
"SENSOR SWATH {value} KM": "FAIXA DO SENSOR {value} KM",
|
||||
"SENSOR SWATH ≈ {value} KM": "FAIXA DO SENSOR ≈ {value} KM",
|
||||
"SENSOR": "SENSOR",
|
||||
"Zoom to the imaging sensor swath": "Aproximar até a faixa do sensor de imagem",
|
||||
"<1 H OLD": "HÁ <1 H",
|
||||
"{value} H OLD": "HÁ {value} H",
|
||||
"{value} D OLD": "HÁ {value} D",
|
||||
"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",
|
||||
|
||||
+117
-3
@@ -187,6 +187,40 @@
|
||||
"Select {mission}": "Выбрать {mission}",
|
||||
"{value} KM": "{value} КМ",
|
||||
"{value} KM/S": "{value} КМ/С",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "ОТКЛОНЕНИЕ ОТ НАДИРА {angle}° · {offset}",
|
||||
"SITES STANDBY": "ОБЪЕКТЫ В ОЖИДАНИИ",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ВКЛЮЧИТЕ ОТМЕЧЕННЫЕ ОБЪЕКТЫ ДЛЯ ПИНГОВ МЕСТ",
|
||||
"Release the camera from {name}": "Освободить камеру от {name}",
|
||||
"RETURN · {name}": "НАЗАД · {name}",
|
||||
"PREVIOUS SATELLITE": "ПРЕДЫДУЩИЙ СПУТНИК",
|
||||
"Return to {name}": "Вернуться к {name}",
|
||||
"SATELLITE": "СПУТНИК",
|
||||
"Ride {name}": "Перейти на {name}",
|
||||
"Unnamed site": "Безымянный объект",
|
||||
"OFF-NADIR {angle}°": "ОТКЛОНЕНИЕ ОТ НАДИРА {angle}°",
|
||||
"FOCUS · {name}": "ФОКУС · {name}",
|
||||
"Keep the camera on {name}": "Удерживать камеру на {name}",
|
||||
"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": "Снять выбор миссии",
|
||||
@@ -247,8 +281,6 @@
|
||||
"Starlink {detail} — click to retry": "Starlink: {detail} — нажмите, чтобы повторить",
|
||||
"load failed": "ошибка загрузки",
|
||||
"Showing the full Starlink shell — click for the core catalog only": "Показана полная оболочка Starlink — нажмите, чтобы оставить только основной каталог",
|
||||
"DENSE ···": "ПЛОТНО ···",
|
||||
"DENSE ✕": "ПЛОТНО ✕",
|
||||
"DENSE": "ПЛОТНЫЙ",
|
||||
"Syncing flow · Roads: {source}": "Синхронизация потока · Дороги: {source}",
|
||||
"LIVE · Roads: {source} · Roads without flow hidden": "ОНЛАЙН · Дороги: {source} · Дороги без потока скрыты",
|
||||
@@ -411,10 +443,16 @@
|
||||
"STALE FEED": "ПОТОК УСТАРЕЛ",
|
||||
"LIVE TRACK": "ТРЕК ОНЛАЙН",
|
||||
"{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · КУРС СОВМЕЩЁН",
|
||||
"{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ВЫСОТА ГЛАЗА {height}",
|
||||
"LIVE AIS": "AIS ОНЛАЙН",
|
||||
"COMMERCIAL": "ГРАЖДАНСКИЙ",
|
||||
"UNKNOWN": "НЕИЗВЕСТНО",
|
||||
"ARROW · ESTIMATED DIRECTION": "СТРЕЛКА · РАСЧЁТНОЕ НАПРАВЛЕНИЕ",
|
||||
"ROUTE DATA UNAVAILABLE": "ДАННЫЕ МАРШРУТА НЕДОСТУПНЫ",
|
||||
"CONTACT NAVIGATION OFF": "НАВИГАЦИЯ ПО КОНТАКТАМ ВЫКЛЮЧЕНА",
|
||||
"THE BRIDGE STAYS WITH THE SELECTED VESSEL": "МОСТИК ОСТАЁТСЯ НА ВЫБРАННОМ СУДНЕ",
|
||||
"{range} NM": "{range} МОР. МИЛЬ",
|
||||
"AIS DESTINATION · CREW REPORTED": "ПУНКТ НАЗНАЧЕНИЯ AIS · УКАЗАН ЭКИПАЖЕМ",
|
||||
"DEST {bearing}": "НАЗН. {bearing}",
|
||||
"Disable cockpit weather effects": "Выключить погодные эффекты кабины",
|
||||
"Enable cockpit weather effects": "Включить погодные эффекты кабины",
|
||||
@@ -459,6 +497,9 @@
|
||||
"Select flight {title}": "Выбрать рейс {title}",
|
||||
"TRACK ACQUIRED": "ЦЕЛЬ ЗАХВАЧЕНА",
|
||||
"{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": "Нет выбранных слоёв данных",
|
||||
@@ -639,11 +680,20 @@
|
||||
"Next filtered radio station": "Следующая отфильтрованная радиостанция",
|
||||
"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",
|
||||
"HEADING HOLD · AIS": "УДЕРЖАНИЕ КУРСА · AIS",
|
||||
"NO HEADING · AIS": "НЕТ КУРСА · AIS",
|
||||
"VISOR LOCK · ACTIVE": "БЛОКИРОВКА ВИЗОРА · АКТИВНА",
|
||||
"Cockpit vision style": "Стиль обзора кабины",
|
||||
"Current aircraft heading": "Текущий курс самолёта",
|
||||
"Look ahead: turn the bridge view back to the bow": "Взгляд по носу: развернуть вид с мостика обратно к носу",
|
||||
"Look ahead (Home or 0)": "Взгляд по носу (Home или 0)",
|
||||
"LOOK AHEAD": "ВЗГЛЯД ПО НОСУ",
|
||||
"Current vessel heading": "Текущий курс судна",
|
||||
"Nearby cohort counts": "Счётчики контактов поблизости",
|
||||
"Cockpit display options": "Параметры экрана кабины",
|
||||
"Cockpit compact Radio controls": "Компактное управление радио кабины",
|
||||
@@ -652,23 +702,38 @@
|
||||
"Cockpit briefing controls": "Управление брифингом кабины",
|
||||
"View switcher": "Переключатель вида",
|
||||
"Aircraft cockpit view": "Вид кабины самолёта",
|
||||
"Ship bridge view": "Вид с ходового мостика",
|
||||
"Satellite orbital view": "Орбитальный вид со спутника",
|
||||
"Estimated flight plan": "Расчётный план полёта",
|
||||
"Reported voyage": "Заявленный рейс",
|
||||
"Live signals": "Сигналы в реальном времени",
|
||||
"Latest regional news": "Последние региональные новости",
|
||||
"Location-based information": "Информация по текущему месту",
|
||||
"LOOK STBD {bearing}°": "ВЗГЛЯД ПР. БОРТ {bearing}°",
|
||||
"LOOK PORT {bearing}°": "ВЗГЛЯД ЛЕВ. БОРТ {bearing}°",
|
||||
"LOOK ASTERN": "ВЗГЛЯД ЗА КОРМУ",
|
||||
"UP {angle}°": "ВВЕРХ {angle}°",
|
||||
"DOWN {angle}°": "ВНИЗ {angle}°",
|
||||
"LEVEL": "ГОРИЗОНТ",
|
||||
"{direction} · {elevation}": "{direction} · {elevation}",
|
||||
"LOOK {elevation}": "ВЗГЛЯД {elevation}",
|
||||
"DEST ---°": "НАЗН. ---°",
|
||||
"CURRENT": "ТЕКУЩИЙ",
|
||||
"NEAREST OBSERVED / MAPPED": "БЛИЖАЙШИЕ НАБЛЮДАЕМЫЕ / НА КАРТЕ",
|
||||
"GROUND SPEED · KTS": "ПУТЕВАЯ СКОРОСТЬ · УЗ",
|
||||
"SPEED OVER GROUND · KTS": "СКОРОСТЬ ПО ГРУНТУ · УЗ",
|
||||
"ALTITUDE · FT": "ВЫСОТА · ФТ",
|
||||
"FIRST PERSON": "ОТ ПЕРВОГО ЛИЦА",
|
||||
"BRIDGE VIEW": "ВИД С МОСТИКА",
|
||||
"LIVE TRACK · COURSE ALIGNED": "ТРЕК ОНЛАЙН · КУРС СОВМЕЩЁН",
|
||||
"GROUND SPEED": "ПУТЕВАЯ СКОРОСТЬ",
|
||||
"SPEED OVER GROUND": "СКОРОСТЬ ПО ГРУНТУ",
|
||||
"COURSE OVER GROUND": "КУРС ПО ГРУНТУ",
|
||||
"ALTITUDE": "ВЫСОТА",
|
||||
"CONTEXT ONLY": "ТОЛЬКО КОНТЕКСТ",
|
||||
"AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "ТОЛЬКО ДОСТУПНЫЕ ДАННЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО",
|
||||
"ESTIMATED FLIGHT PLAN": "РАСЧЁТНЫЙ ПЛАН ПОЛЁТА",
|
||||
"REPORTED VOYAGE": "ЗАЯВЛЕННЫЙ РЕЙС",
|
||||
"FROM": "ОТКУДА",
|
||||
"TO": "КУДА",
|
||||
"TEMP": "ТЕМП.",
|
||||
@@ -679,7 +744,18 @@
|
||||
"VOLUME": "ГРОМКОСТЬ",
|
||||
"RESET": "СБРОС",
|
||||
"EXIT COCKPIT": "ВЫЙТИ ИЗ КАБИНЫ",
|
||||
"EXIT BRIDGE": "ВЫЙТИ С МОСТИКА",
|
||||
"EXIT ORBIT": "ВЫЙТИ С ОРБИТЫ",
|
||||
"CONTACTS · 250 KM": "КОНТАКТЫ · 250 КМ",
|
||||
"RINGS {spacing} NM": "КОЛЬЦА {spacing} МОР. МИЛЬ",
|
||||
"{count} CONTACTS": {
|
||||
"one": "{count} КОНТАКТ",
|
||||
"few": "{count} КОНТАКТА",
|
||||
"many": "{count} КОНТАКТОВ",
|
||||
"other": "{count} КОНТАКТА"
|
||||
},
|
||||
"Radar range {range}. Select for the next range.": "Шкала дальности радара {range}. Выберите, чтобы перейти к следующей шкале.",
|
||||
"Bridge radar": "Радар ходового мостика",
|
||||
"Expand Visual Presets": "Развернуть визуальные пресеты",
|
||||
"Pin visual presets": "Закрепить визуальные пресеты",
|
||||
"Keep visual presets open": "Держать визуальные пресеты открытыми",
|
||||
@@ -735,6 +811,7 @@
|
||||
"CONTEXT": "КОНТЕКСТ",
|
||||
"SPACE MISSIONS": "КОСМИЧЕСКИЕ МИССИИ",
|
||||
"COCKPIT": "КАБИНА",
|
||||
"BRIDGE": "МОСТИК",
|
||||
"SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY": "ВЫБЕРИТЕ КОНТАКТЫ, ЧТОБЫ ЗАГРУЗИТЬ НАБЛЮДАЕМОЕ / КАРТОГРАФИРОВАННОЕ ОКРУЖЕНИЕ",
|
||||
"SELECT A MISSION TO INSPECT": "ВЫБЕРИТЕ МИССИЮ ДЛЯ ПРОСМОТРА",
|
||||
"Enable Radio, then choose a globe marker or use next.": "Включите радио, затем выберите маркер на глобусе или переключите на следующую станцию.",
|
||||
@@ -1631,7 +1708,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": "МЕСТН.",
|
||||
@@ -3335,6 +3416,39 @@
|
||||
"Refresh map still": "Обновить кадр на карте",
|
||||
"The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес.",
|
||||
"STATIONS": "СТАНЦИИ",
|
||||
"Crewed stations and the craft that visit them": "Пилотируемые станции и прилетающие к ним корабли",
|
||||
"IMAGING": "СЪЁМКА",
|
||||
"Earth observation: optical, radar, weather and science": "Наблюдение Земли: оптика, радар, погода и наука",
|
||||
"TELECOM": "СВЯЗЬ",
|
||||
"Communications and navigation satellites, Starlink excepted": "Спутники связи и навигации, кроме Starlink",
|
||||
"STARLINK": "STARLINK",
|
||||
"The Starlink constellation (thousands of extra points)": "Группировка Starlink (тысячи дополнительных точек)",
|
||||
"PROBES": "ЗОНДЫ",
|
||||
"Space science, technology demonstration and other payloads": "Космическая наука, демонстрация технологий и другие полезные нагрузки",
|
||||
"DEBRIS": "МУСОР",
|
||||
"Rocket bodies, fragments and dead satellites": "Ступени ракет, обломки и неработающие спутники",
|
||||
"Classes may lag: SATCAT is a cached copy": "Классы могут отставать: SATCAT — кэшированная копия",
|
||||
"Classes may lag: SATCAT unavailable": "Классы могут отставать: SATCAT недоступен",
|
||||
"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 ✕",
|
||||
"SENSOR SWATH {value} KM": "ПОЛОСА СЕНСОРА {value} КМ",
|
||||
"SENSOR SWATH ≈ {value} KM": "ПОЛОСА СЕНСОРА ≈ {value} КМ",
|
||||
"SENSOR": "СЕНСОР",
|
||||
"Zoom to the imaging sensor swath": "Приблизить до полосы съёмочного сенсора",
|
||||
"<1 H OLD": "<1 Ч НАЗАД",
|
||||
"{value} H OLD": "{value} Ч НАЗАД",
|
||||
"{value} D OLD": "{value} Д НАЗАД",
|
||||
"FWD": "ВПЕРЁД",
|
||||
"AFT": "НАЗАД",
|
||||
"RIGHT": "ВПРАВО",
|
||||
"LEFT": "ВЛЕВО",
|
||||
"TRACK UP": "ТРАССА ВВЕРХУ",
|
||||
"COURSE HOLD · AIS": "УДЕРЖАНИЕ ПУТЕВОГО УГЛА · AIS",
|
||||
"Zoom in (+)": "Приблизить (+)",
|
||||
"Zoom out (−)": "Отдалить (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "ОРБИТАЛЬНЫЙ ВИД · ОТКЛОНЕНИЕ ОТ НАДИРА",
|
||||
"World time": "Мировое время",
|
||||
"Back to live": "Вернуться к прямому эфиру",
|
||||
"Live": "Сейчас",
|
||||
|
||||
+117
-3
@@ -839,6 +839,40 @@
|
||||
"Select {mission}": "Вибрати {mission}",
|
||||
"{value} KM": "{value} КМ",
|
||||
"{value} KM/S": "{value} КМ/С",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "ВІДХИЛЕННЯ ВІД НАДИРА {angle}° · {offset}",
|
||||
"SITES STANDBY": "ОБ'ЄКТИ: ОЧІКУВАННЯ",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "УВІМКНІТЬ ПОЗНАЧЕНІ ОБ'ЄКТИ, ЩОБ БАЧИТИ МІСЦЯ",
|
||||
"Release the camera from {name}": "Звільнити камеру від {name}",
|
||||
"RETURN · {name}": "НАЗАД · {name}",
|
||||
"PREVIOUS SATELLITE": "ПОПЕРЕДНІЙ СУПУТНИК",
|
||||
"Return to {name}": "Повернутися до {name}",
|
||||
"SATELLITE": "СУПУТНИК",
|
||||
"Ride {name}": "Перейти на {name}",
|
||||
"Unnamed site": "Безіменний об'єкт",
|
||||
"OFF-NADIR {angle}°": "ВІДХИЛЕННЯ ВІД НАДИРА {angle}°",
|
||||
"FOCUS · {name}": "ФОКУС · {name}",
|
||||
"Keep the camera on {name}": "Утримувати камеру на {name}",
|
||||
"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": "Показати всі місії",
|
||||
@@ -1038,8 +1072,6 @@
|
||||
"Starlink {detail} — click to retry": "Starlink: {detail} — клацніть, щоб повторити",
|
||||
"load failed": "помилка завантаження",
|
||||
"Showing the full Starlink shell — click for the core catalog only": "Показано повну оболонку Starlink — клацніть, щоб лишити лише основний каталог",
|
||||
"DENSE ···": "ЩІЛЬНО ···",
|
||||
"DENSE ✕": "ЩІЛЬНО ✕",
|
||||
"DENSE": "ЩІЛЬНО",
|
||||
"CelesTrak unreachable": "CelesTrak недоступний",
|
||||
"DOCKED · {name} · +{extra}": "ПРИСТИКОВАНО · {name} · +{extra}",
|
||||
@@ -1724,8 +1756,14 @@
|
||||
"COMMERCIAL": "КОМЕРЦІЙНИЙ",
|
||||
"{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}",
|
||||
"{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · КАМЕРА ЗА КУРСОМ",
|
||||
"{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ВИСОТА ОКА {height}",
|
||||
"LIVE AIS": "AIS ОНЛАЙН",
|
||||
"ARROW · ESTIMATED DIRECTION": "СТРІЛКА · ОРІЄНТОВНИЙ НАПРЯМОК",
|
||||
"ROUTE DATA UNAVAILABLE": "ДАНІ МАРШРУТУ НЕДОСТУПНІ",
|
||||
"CONTACT NAVIGATION OFF": "НАВІГАЦІЮ КОНТАКТАМИ ВИМКНЕНО",
|
||||
"THE BRIDGE STAYS WITH THE SELECTED VESSEL": "МІСТОК ЗАЛИШАЄТЬСЯ НА ВИБРАНОМУ СУДНІ",
|
||||
"{range} NM": "{range} МОР. МИЛЬ",
|
||||
"AIS DESTINATION · CREW REPORTED": "ПРИЗНАЧЕННЯ AIS · ВКАЗАНО ЕКІПАЖЕМ",
|
||||
"DEST {bearing}": "ПРИЗН. {bearing}",
|
||||
"Disable cockpit weather effects": "Вимкнути погодні ефекти кабіни",
|
||||
"Enable cockpit weather effects": "Увімкнути погодні ефекти кабіни",
|
||||
@@ -1773,6 +1811,9 @@
|
||||
"Reclassify as TR-3B": "Перекласифікувати як TR-3B",
|
||||
"TRACK ACQUIRED": "ТРЕК ЗАХОПЛЕНО",
|
||||
"{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": {
|
||||
@@ -2767,14 +2808,28 @@
|
||||
"Recent speech": "Нещодавнє мовлення",
|
||||
"HERE": "ТУТ",
|
||||
"Aircraft cockpit view": "Вигляд кабіни повітряного судна",
|
||||
"Ship bridge view": "Вигляд з ходового містка судна",
|
||||
"Satellite orbital view": "Орбітальний вигляд із супутника",
|
||||
"LOOK STBD {bearing}°": "ПОГЛЯД ПР. БОРТ {bearing}°",
|
||||
"LOOK PORT {bearing}°": "ПОГЛЯД ЛІВ. БОРТ {bearing}°",
|
||||
"LOOK ASTERN": "ПОГЛЯД ЗА КОРМУ",
|
||||
"UP {angle}°": "ВГОРУ {angle}°",
|
||||
"DOWN {angle}°": "ВНИЗ {angle}°",
|
||||
"LEVEL": "ГОРИЗОНТ",
|
||||
"{direction} · {elevation}": "{direction} · {elevation}",
|
||||
"LOOK {elevation}": "ПОГЛЯД {elevation}",
|
||||
"Estimated destination direction": "Орієнтовний напрямок до пункту призначення",
|
||||
"DEST ---°": "ПРИЗН. ---°",
|
||||
"BRIDGE WINDOW · FWD": "ВІКНО МІСТКА · ПО НОСІ",
|
||||
"OPTICAL PLANE · 01": "ОПТИЧНА ПЛОЩИНА · 01",
|
||||
"HEADING HOLD · AIS": "УТРИМАННЯ КУРСУ · AIS",
|
||||
"NO HEADING · AIS": "НЕМАЄ КУРСУ · AIS",
|
||||
"VISOR LOCK · ACTIVE": "ФІКСАЦІЯ ВІЗОРА · АКТИВНА",
|
||||
"GROUND SPEED · KTS": "ШЛЯХОВА ШВИДКІСТЬ · ВУЗ",
|
||||
"SPEED OVER GROUND · KTS": "ШВИДКІСТЬ ЩОДО ҐРУНТУ · ВУЗ",
|
||||
"ALTITUDE · FT": "ВИСОТА · ФТ",
|
||||
"FIRST PERSON": "ВІД ПЕРШОЇ ОСОБИ",
|
||||
"BRIDGE VIEW": "ВИГЛЯД З МІСТКА",
|
||||
"LIVE TRACK · COURSE ALIGNED": "ТРЕК ОНЛАЙН · КАМЕРА ЗА КУРСОМ",
|
||||
"Cockpit vision style": "Візуальний стиль у кабіні",
|
||||
"Previous cockpit vision style": "Попередній візуальний стиль у кабіні",
|
||||
@@ -2783,13 +2838,28 @@
|
||||
"Next cockpit vision style": "Наступний візуальний стиль у кабіні",
|
||||
"Next vision style": "Наступний візуальний стиль",
|
||||
"Current aircraft heading": "Поточний курс повітряного судна",
|
||||
"Look ahead: turn the bridge view back to the bow": "Погляд по носі: повернути вигляд з містка назад до носа",
|
||||
"Look ahead (Home or 0)": "Погляд по носі (Home або 0)",
|
||||
"LOOK AHEAD": "ПОГЛЯД ПО НОСІ",
|
||||
"Current vessel heading": "Поточний курс судна",
|
||||
"GROUND SPEED": "ШЛЯХОВА ШВИДКІСТЬ",
|
||||
"SPEED OVER GROUND": "ШВИДКІСТЬ ЩОДО ҐРУНТУ",
|
||||
"COURSE OVER GROUND": "КУРС ЩОДО ҐРУНТУ",
|
||||
"KTS": "ВУЗ",
|
||||
"ALTITUDE": "ВИСОТА",
|
||||
"FT": "ФТ",
|
||||
"Contact cockpit summary": "Зведення контакту в кабіні",
|
||||
"CONTACT": "КОНТАКТ",
|
||||
"CONTACTS · 250 KM": "КОНТАКТИ · 250 КМ",
|
||||
"RINGS {spacing} NM": "КІЛЬЦЯ {spacing} МОР. МИЛЬ",
|
||||
"{count} CONTACTS": {
|
||||
"one": "{count} КОНТАКТ",
|
||||
"few": "{count} КОНТАКТИ",
|
||||
"many": "{count} КОНТАКТІВ",
|
||||
"other": "{count} КОНТАКТУ"
|
||||
},
|
||||
"Radar range {range}. Select for the next range.": "Шкала дальності радара {range}. Виберіть, щоб перейти до наступної шкали.",
|
||||
"Bridge radar": "Радар ходового містка",
|
||||
"Contact navigation": "Навігація контактами",
|
||||
"CONTEXT ONLY": "ЛИШЕ КОНТЕКСТ",
|
||||
"Nearby cohort counts": "Кількість контактів поблизу за типами",
|
||||
@@ -2801,7 +2871,9 @@
|
||||
"WX": "ПОГОДА",
|
||||
"Cockpit briefing carousel": "Карусель брифінгу кабіни",
|
||||
"Estimated flight plan": "Орієнтовний план польоту",
|
||||
"Reported voyage": "Заявлений рейс",
|
||||
"ESTIMATED FLIGHT PLAN": "ОРІЄНТОВНИЙ ПЛАН ПОЛЬОТУ",
|
||||
"REPORTED VOYAGE": "ЗАЯВЛЕНИЙ РЕЙС",
|
||||
"FROM": "ЗВІДКИ",
|
||||
"TO": "КУДИ",
|
||||
"Cockpit briefing controls": "Керування брифінгом кабіни",
|
||||
@@ -2834,12 +2906,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": "Закріпити візуальні пресети",
|
||||
@@ -2872,6 +2952,7 @@
|
||||
"SELECT CONTEXT": "ВИБЕРІТЬ КОНТЕКСТ",
|
||||
"Contact Context actions": "Дії контексту контактів",
|
||||
"COCKPIT": "КАБІНА",
|
||||
"BRIDGE": "МІСТОК",
|
||||
"SEARCH NEARBY SITES": "ПОШУК НАЙБЛИЖЧИХ ОБʼЄКТІВ",
|
||||
"Reclassify tracked contact as TR-3B": "Перекласифікувати відстежуваний контакт як TR-3B",
|
||||
"CONTACTS CONTEXT OFF": "КОНТЕКСТ КОНТАКТІВ ВИМКНЕНО",
|
||||
@@ -3335,6 +3416,39 @@
|
||||
"Refresh map still": "Оновити кадр на карті",
|
||||
"The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.",
|
||||
"Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу.",
|
||||
"STATIONS": "СТАНЦІЇ",
|
||||
"Crewed stations and the craft that visit them": "Пілотовані станції та кораблі, що до них прилітають",
|
||||
"IMAGING": "ЗЙОМКА",
|
||||
"Earth observation: optical, radar, weather and science": "Спостереження Землі: оптика, радар, погода й наука",
|
||||
"TELECOM": "ЗВ’ЯЗОК",
|
||||
"Communications and navigation satellites, Starlink excepted": "Супутники зв’язку та навігації, крім Starlink",
|
||||
"STARLINK": "STARLINK",
|
||||
"The Starlink constellation (thousands of extra points)": "Угруповання Starlink (тисячі додаткових точок)",
|
||||
"PROBES": "ЗОНДИ",
|
||||
"Space science, technology demonstration and other payloads": "Космічна наука, демонстрація технологій та інші корисні навантаження",
|
||||
"DEBRIS": "УЛАМКИ",
|
||||
"Rocket bodies, fragments and dead satellites": "Ступені ракет, уламки й непрацюючі супутники",
|
||||
"Classes may lag: SATCAT is a cached copy": "Класи можуть відставати: SATCAT — кешована копія",
|
||||
"Classes may lag: SATCAT unavailable": "Класи можуть відставати: SATCAT недоступний",
|
||||
"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 ✕",
|
||||
"SENSOR SWATH {value} KM": "СМУГА СЕНСОРА {value} КМ",
|
||||
"SENSOR SWATH ≈ {value} KM": "СМУГА СЕНСОРА ≈ {value} КМ",
|
||||
"SENSOR": "СЕНСОР",
|
||||
"Zoom to the imaging sensor swath": "Наблизити до смуги знімального сенсора",
|
||||
"<1 H OLD": "<1 ГОД ТОМУ",
|
||||
"{value} H OLD": "{value} ГОД ТОМУ",
|
||||
"{value} D OLD": "{value} ДН ТОМУ",
|
||||
"FWD": "ВПЕРЕД",
|
||||
"AFT": "НАЗАД",
|
||||
"RIGHT": "ПРАВОРУЧ",
|
||||
"LEFT": "ЛІВОРУЧ",
|
||||
"TRACK UP": "ТРАСА УГОРІ",
|
||||
"COURSE HOLD · AIS": "УТРИМАННЯ ШЛЯХОВОГО КУТА · AIS",
|
||||
"Zoom in (+)": "Наблизити (+)",
|
||||
"Zoom out (−)": "Віддалити (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "ОРБІТАЛЬНИЙ ВИГЛЯД · ВІДХИЛЕННЯ ВІД НАДИРА",
|
||||
"World time": "Світовий час",
|
||||
"Back to live": "Повернутися до прямого ефіру",
|
||||
"Live": "Зараз",
|
||||
|
||||
+114
-3
@@ -778,6 +778,40 @@
|
||||
"Select {mission}": "选择 {mission}",
|
||||
"{value} KM": "{value} km",
|
||||
"{value} KM/S": "{value} km/s",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "偏离天底 {angle}° · {offset}",
|
||||
"SITES STANDBY": "地点待机",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "启用已测绘设施以接收地点提醒",
|
||||
"Release the camera from {name}": "让镜头离开{name}",
|
||||
"RETURN · {name}": "返回 · {name}",
|
||||
"PREVIOUS SATELLITE": "上一颗卫星",
|
||||
"Return to {name}": "返回{name}",
|
||||
"SATELLITE": "卫星",
|
||||
"Ride {name}": "乘坐{name}",
|
||||
"Unnamed site": "未命名地点",
|
||||
"OFF-NADIR {angle}°": "偏离天底 {angle}°",
|
||||
"FOCUS · {name}": "聚焦 · {name}",
|
||||
"Keep the camera on {name}": "让镜头持续对准{name}",
|
||||
"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": "显示所有任务",
|
||||
@@ -970,8 +1004,6 @@
|
||||
"Starlink {detail} — click to retry": "Starlink {detail}——点击重试",
|
||||
"load failed": "加载失败",
|
||||
"Showing the full Starlink shell — click for the core catalog only": "正在显示完整的 Starlink 星座——点击仅显示核心目录",
|
||||
"DENSE ···": "密集 ···",
|
||||
"DENSE ✕": "密集 ✕",
|
||||
"DENSE": "密集",
|
||||
"CelesTrak unreachable": "CelesTrak 无法连接",
|
||||
"DOCKED · {name} · +{extra}": "对接 · {name} · +{extra}",
|
||||
@@ -1626,8 +1658,14 @@
|
||||
"COMMERCIAL": "商用",
|
||||
"{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}",
|
||||
"{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · 航向对准",
|
||||
"{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · 眼高 {height}",
|
||||
"LIVE AIS": "实时 AIS",
|
||||
"ARROW · ESTIMATED DIRECTION": "箭头 · 预估方向",
|
||||
"ROUTE DATA UNAVAILABLE": "航线数据不可用",
|
||||
"CONTACT NAVIGATION OFF": "目标导航已关闭",
|
||||
"THE BRIDGE STAYS WITH THE SELECTED VESSEL": "驾驶台保持在所选船舶上",
|
||||
"{range} NM": "{range}海里",
|
||||
"AIS DESTINATION · CREW REPORTED": "AIS 目的地 · 船员申报",
|
||||
"DEST {bearing}": "目的地 {bearing}",
|
||||
"Disable cockpit weather effects": "停用座舱天气效果",
|
||||
"Enable cockpit weather effects": "启用座舱天气效果",
|
||||
@@ -1672,6 +1710,9 @@
|
||||
"Reclassify as TR-3B": "重新归类为 TR-3B",
|
||||
"TRACK ACQUIRED": "已获取航迹",
|
||||
"{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": {
|
||||
@@ -2454,14 +2495,28 @@
|
||||
"DONE": "完成",
|
||||
"HERE": "此处",
|
||||
"Aircraft cockpit view": "飞机座舱视图",
|
||||
"Ship bridge view": "船舶驾驶台视图",
|
||||
"Satellite orbital view": "卫星轨道视图",
|
||||
"LOOK STBD {bearing}°": "视线 右舷 {bearing}°",
|
||||
"LOOK PORT {bearing}°": "视线 左舷 {bearing}°",
|
||||
"LOOK ASTERN": "视线 船尾",
|
||||
"UP {angle}°": "上 {angle}°",
|
||||
"DOWN {angle}°": "下 {angle}°",
|
||||
"LEVEL": "水平",
|
||||
"{direction} · {elevation}": "{direction} · {elevation}",
|
||||
"LOOK {elevation}": "视线 {elevation}",
|
||||
"Estimated destination direction": "预估目的地方向",
|
||||
"DEST ---°": "目的地 ---°",
|
||||
"BRIDGE WINDOW · FWD": "驾驶台窗口 · 前方",
|
||||
"OPTICAL PLANE · 01": "光学平面 · 01",
|
||||
"HEADING HOLD · AIS": "船首向保持 · AIS",
|
||||
"NO HEADING · AIS": "无船首向 · AIS",
|
||||
"VISOR LOCK · ACTIVE": "面罩锁定 · 激活",
|
||||
"GROUND SPEED · KTS": "地速 · kn",
|
||||
"SPEED OVER GROUND · KTS": "对地航速 · kn",
|
||||
"ALTITUDE · FT": "高度 · ft",
|
||||
"FIRST PERSON": "第一人称",
|
||||
"BRIDGE VIEW": "驾驶台视图",
|
||||
"LIVE TRACK · COURSE ALIGNED": "实时航迹 · 航向对准",
|
||||
"Cockpit vision style": "座舱视觉样式",
|
||||
"Previous cockpit vision style": "上一个座舱视觉样式",
|
||||
@@ -2470,13 +2525,25 @@
|
||||
"Next cockpit vision style": "下一个座舱视觉样式",
|
||||
"Next vision style": "下一个视觉样式",
|
||||
"Current aircraft heading": "飞机当前航向",
|
||||
"Look ahead: turn the bridge view back to the bow": "看向前方:将驾驶台视图转回船首",
|
||||
"Look ahead (Home or 0)": "看向前方(Home 或 0)",
|
||||
"LOOK AHEAD": "看向前方",
|
||||
"Current vessel heading": "船舶当前船首向",
|
||||
"GROUND SPEED": "地速",
|
||||
"SPEED OVER GROUND": "对地航速",
|
||||
"COURSE OVER GROUND": "对地航向",
|
||||
"KTS": "kn",
|
||||
"ALTITUDE": "高度",
|
||||
"FT": "ft",
|
||||
"Contact cockpit summary": "目标座舱摘要",
|
||||
"CONTACT": "目标",
|
||||
"CONTACTS · 250 KM": "目标 · 250 km",
|
||||
"RINGS {spacing} NM": "距标圈 {spacing}海里",
|
||||
"{count} CONTACTS": {
|
||||
"other": "{count} 个目标"
|
||||
},
|
||||
"Radar range {range}. Select for the next range.": "雷达量程 {range}。选择以切换到下一量程。",
|
||||
"Bridge radar": "驾驶台雷达",
|
||||
"Contact navigation": "目标导航",
|
||||
"CONTEXT ONLY": "仅供参考",
|
||||
"Nearby cohort counts": "附近各类目标数量",
|
||||
@@ -2488,7 +2555,9 @@
|
||||
"WX": "天气",
|
||||
"Cockpit briefing carousel": "座舱简报轮播",
|
||||
"Estimated flight plan": "预估飞行计划",
|
||||
"Reported voyage": "申报航次",
|
||||
"ESTIMATED FLIGHT PLAN": "预估飞行计划",
|
||||
"REPORTED VOYAGE": "申报航次",
|
||||
"FROM": "出发",
|
||||
"TO": "到达",
|
||||
"Cockpit briefing controls": "座舱简报控件",
|
||||
@@ -2521,12 +2590,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": "固定视觉预设",
|
||||
@@ -2559,6 +2636,7 @@
|
||||
"SELECT CONTEXT": "选择态势",
|
||||
"Contact Context actions": "目标态势操作",
|
||||
"COCKPIT": "座舱",
|
||||
"BRIDGE": "驾驶台",
|
||||
"SEARCH NEARBY SITES": "搜索附近设施",
|
||||
"Reclassify tracked contact as TR-3B": "将跟踪目标重新归类为 TR-3B",
|
||||
"CONTACTS CONTEXT OFF": "目标态势已关闭",
|
||||
@@ -3110,6 +3188,39 @@
|
||||
"Refresh map still": "刷新地图静止画面",
|
||||
"The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。",
|
||||
"Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。",
|
||||
"STATIONS": "空间站",
|
||||
"Crewed stations and the craft that visit them": "载人空间站及来访飞行器",
|
||||
"IMAGING": "成像",
|
||||
"Earth observation: optical, radar, weather and science": "对地观测:光学、雷达、气象与科学",
|
||||
"TELECOM": "通信",
|
||||
"Communications and navigation satellites, Starlink excepted": "通信与导航卫星(不含 Starlink)",
|
||||
"STARLINK": "STARLINK",
|
||||
"The Starlink constellation (thousands of extra points)": "Starlink 星座(数千个额外点位)",
|
||||
"PROBES": "探测器",
|
||||
"Space science, technology demonstration and other payloads": "空间科学、技术验证及其他载荷",
|
||||
"DEBRIS": "碎片",
|
||||
"Rocket bodies, fragments and dead satellites": "火箭残骸、碎片和失效卫星",
|
||||
"Classes may lag: SATCAT is a cached copy": "分类可能滞后:SATCAT 为缓存副本",
|
||||
"Classes may lag: SATCAT unavailable": "分类可能滞后:SATCAT 不可用",
|
||||
"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 ✕",
|
||||
"SENSOR SWATH {value} KM": "传感器幅宽 {value} km",
|
||||
"SENSOR SWATH ≈ {value} KM": "传感器幅宽 ≈ {value} km",
|
||||
"SENSOR": "传感器",
|
||||
"Zoom to the imaging sensor swath": "缩放到成像传感器幅宽",
|
||||
"<1 H OLD": "不到1小时前",
|
||||
"{value} H OLD": "{value}小时前",
|
||||
"{value} D OLD": "{value}天前",
|
||||
"FWD": "前方",
|
||||
"AFT": "后方",
|
||||
"RIGHT": "右",
|
||||
"LEFT": "左",
|
||||
"TRACK UP": "航迹向上",
|
||||
"COURSE HOLD · AIS": "对地航向保持 · AIS",
|
||||
"Zoom in (+)": "放大 (+)",
|
||||
"Zoom out (−)": "缩小 (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "轨道视图 · 偏离天底",
|
||||
"World time": "世界时间",
|
||||
"Back to live": "返回实时",
|
||||
"Live": "实时",
|
||||
|
||||
@@ -773,6 +773,40 @@
|
||||
"Select {mission}": "選擇 {mission}",
|
||||
"{value} KM": "{value} km",
|
||||
"{value} KM/S": "{value} km/s",
|
||||
"{direction} {angle}°": "{direction} {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}": "偏離天底 {angle}° · {offset}",
|
||||
"SITES STANDBY": "地點待機",
|
||||
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "啟用已測繪設施以接收地點提醒",
|
||||
"Release the camera from {name}": "讓鏡頭離開{name}",
|
||||
"RETURN · {name}": "返回 · {name}",
|
||||
"PREVIOUS SATELLITE": "上一顆衛星",
|
||||
"Return to {name}": "返回{name}",
|
||||
"SATELLITE": "衛星",
|
||||
"Ride {name}": "乘坐{name}",
|
||||
"Unnamed site": "未命名地點",
|
||||
"OFF-NADIR {angle}°": "偏離天底 {angle}°",
|
||||
"FOCUS · {name}": "聚焦 · {name}",
|
||||
"Keep the camera on {name}": "讓鏡頭持續對準{name}",
|
||||
"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": "顯示所有任務",
|
||||
@@ -965,8 +999,6 @@
|
||||
"Starlink {detail} — click to retry": "Starlink {detail}——點擊重試",
|
||||
"load failed": "加載失敗",
|
||||
"Showing the full Starlink shell — click for the core catalog only": "正在顯示完整的 Starlink 星座——點擊僅顯示核心目錄",
|
||||
"DENSE ···": "密集 ···",
|
||||
"DENSE ✕": "密集 ✕",
|
||||
"DENSE": "密集",
|
||||
"CelesTrak unreachable": "CelesTrak 無法連接",
|
||||
"DOCKED · {name} · +{extra}": "對接 · {name} · +{extra}",
|
||||
@@ -1616,8 +1648,14 @@
|
||||
"COMMERCIAL": "商用",
|
||||
"{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}",
|
||||
"{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · 航向對準",
|
||||
"{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · 眼高 {height}",
|
||||
"LIVE AIS": "實時 AIS",
|
||||
"ARROW · ESTIMATED DIRECTION": "箭頭 · 預估方向",
|
||||
"ROUTE DATA UNAVAILABLE": "航線數據不可用",
|
||||
"CONTACT NAVIGATION OFF": "目標導航已關閉",
|
||||
"THE BRIDGE STAYS WITH THE SELECTED VESSEL": "駕駛台保持在所選船舶上",
|
||||
"{range} NM": "{range}海里",
|
||||
"AIS DESTINATION · CREW REPORTED": "AIS 目的地 · 船員申報",
|
||||
"DEST {bearing}": "目的地 {bearing}",
|
||||
"Disable cockpit weather effects": "停用座艙天氣效果",
|
||||
"Enable cockpit weather effects": "啟用座艙天氣效果",
|
||||
@@ -1662,6 +1700,9 @@
|
||||
"Reclassify as TR-3B": "重新歸類為 TR-3B",
|
||||
"TRACK ACQUIRED": "已獲取航跡",
|
||||
"{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": {
|
||||
@@ -2442,14 +2483,28 @@
|
||||
"DONE": "完成",
|
||||
"HERE": "此處",
|
||||
"Aircraft cockpit view": "飛機座艙視圖",
|
||||
"Ship bridge view": "船舶駕駛台視圖",
|
||||
"Satellite orbital view": "衛星軌道視圖",
|
||||
"LOOK STBD {bearing}°": "視線 右舷 {bearing}°",
|
||||
"LOOK PORT {bearing}°": "視線 左舷 {bearing}°",
|
||||
"LOOK ASTERN": "視線 船尾",
|
||||
"UP {angle}°": "上 {angle}°",
|
||||
"DOWN {angle}°": "下 {angle}°",
|
||||
"LEVEL": "水平",
|
||||
"{direction} · {elevation}": "{direction} · {elevation}",
|
||||
"LOOK {elevation}": "視線 {elevation}",
|
||||
"Estimated destination direction": "預估目的地方向",
|
||||
"DEST ---°": "目的地 ---°",
|
||||
"BRIDGE WINDOW · FWD": "駕駛台窗口 · 前方",
|
||||
"OPTICAL PLANE · 01": "光學平面 · 01",
|
||||
"HEADING HOLD · AIS": "船首向保持 · AIS",
|
||||
"NO HEADING · AIS": "無船首向 · AIS",
|
||||
"VISOR LOCK · ACTIVE": "面罩鎖定 · 激活",
|
||||
"GROUND SPEED · KTS": "地速 · kn",
|
||||
"SPEED OVER GROUND · KTS": "對地航速 · kn",
|
||||
"ALTITUDE · FT": "高度 · ft",
|
||||
"FIRST PERSON": "第一人稱",
|
||||
"BRIDGE VIEW": "駕駛台視圖",
|
||||
"LIVE TRACK · COURSE ALIGNED": "實時航跡 · 航向對準",
|
||||
"Cockpit vision style": "座艙視覺樣式",
|
||||
"Previous cockpit vision style": "上一個座艙視覺樣式",
|
||||
@@ -2458,13 +2513,25 @@
|
||||
"Next cockpit vision style": "下一個座艙視覺樣式",
|
||||
"Next vision style": "下一個視覺樣式",
|
||||
"Current aircraft heading": "飛機當前航向",
|
||||
"Look ahead: turn the bridge view back to the bow": "看向前方:將駕駛台視圖轉回船首",
|
||||
"Look ahead (Home or 0)": "看向前方(Home 或 0)",
|
||||
"LOOK AHEAD": "看向前方",
|
||||
"Current vessel heading": "船舶當前船首向",
|
||||
"GROUND SPEED": "地速",
|
||||
"SPEED OVER GROUND": "對地航速",
|
||||
"COURSE OVER GROUND": "對地航向",
|
||||
"KTS": "kn",
|
||||
"ALTITUDE": "高度",
|
||||
"FT": "ft",
|
||||
"Contact cockpit summary": "目標座艙摘要",
|
||||
"CONTACT": "目標",
|
||||
"CONTACTS · 250 KM": "目標 · 250 km",
|
||||
"RINGS {spacing} NM": "距標圈 {spacing}海里",
|
||||
"{count} CONTACTS": {
|
||||
"other": "{count} 個目標"
|
||||
},
|
||||
"Radar range {range}. Select for the next range.": "雷達量程 {range}。選擇以切換到下一量程。",
|
||||
"Bridge radar": "駕駛台雷達",
|
||||
"Contact navigation": "目標導航",
|
||||
"CONTEXT ONLY": "僅供參考",
|
||||
"Nearby cohort counts": "附近各類目標數量",
|
||||
@@ -2476,7 +2543,9 @@
|
||||
"WX": "天氣",
|
||||
"Cockpit briefing carousel": "座艙簡報輪播",
|
||||
"Estimated flight plan": "預估飛行計劃",
|
||||
"Reported voyage": "申報航次",
|
||||
"ESTIMATED FLIGHT PLAN": "預估飛行計劃",
|
||||
"REPORTED VOYAGE": "申報航次",
|
||||
"FROM": "出發",
|
||||
"TO": "到達",
|
||||
"Cockpit briefing controls": "座艙簡報控件",
|
||||
@@ -2509,12 +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": "固定視覺預設",
|
||||
@@ -2547,6 +2624,7 @@
|
||||
"SELECT CONTEXT": "選擇態勢",
|
||||
"Contact Context actions": "目標態勢操作",
|
||||
"COCKPIT": "座艙",
|
||||
"BRIDGE": "駕駛台",
|
||||
"SEARCH NEARBY SITES": "搜索附近設施",
|
||||
"Reclassify tracked contact as TR-3B": "將跟蹤目標重新歸類為 TR-3B",
|
||||
"CONTACTS CONTEXT OFF": "目標態勢已關閉",
|
||||
@@ -3110,6 +3188,39 @@
|
||||
"Refresh map still": "重新整理地圖靜態畫面",
|
||||
"The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。",
|
||||
"Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。",
|
||||
"STATIONS": "太空站",
|
||||
"Crewed stations and the craft that visit them": "載人太空站及來訪飛行器",
|
||||
"IMAGING": "成像",
|
||||
"Earth observation: optical, radar, weather and science": "對地觀測:光學、雷達、氣象與科學",
|
||||
"TELECOM": "通訊",
|
||||
"Communications and navigation satellites, Starlink excepted": "通訊與導航衛星(不含 Starlink)",
|
||||
"STARLINK": "STARLINK",
|
||||
"The Starlink constellation (thousands of extra points)": "Starlink 星座(數千個額外點位)",
|
||||
"PROBES": "探測器",
|
||||
"Space science, technology demonstration and other payloads": "太空科學、技術驗證及其他酬載",
|
||||
"DEBRIS": "碎片",
|
||||
"Rocket bodies, fragments and dead satellites": "火箭殘骸、碎片與失效衛星",
|
||||
"Classes may lag: SATCAT is a cached copy": "分類可能落後:SATCAT 為快取副本",
|
||||
"Classes may lag: SATCAT unavailable": "分類可能落後:SATCAT 無法取得",
|
||||
"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 ✕",
|
||||
"SENSOR SWATH {value} KM": "感測器幅寬 {value} km",
|
||||
"SENSOR SWATH ≈ {value} KM": "感測器幅寬 ≈ {value} km",
|
||||
"SENSOR": "感測器",
|
||||
"Zoom to the imaging sensor swath": "縮放到成像感測器幅寬",
|
||||
"<1 H OLD": "不到1小時前",
|
||||
"{value} H OLD": "{value}小時前",
|
||||
"{value} D OLD": "{value}天前",
|
||||
"FWD": "前方",
|
||||
"AFT": "後方",
|
||||
"RIGHT": "右",
|
||||
"LEFT": "左",
|
||||
"TRACK UP": "航跡向上",
|
||||
"COURSE HOLD · AIS": "對地航向保持 · AIS",
|
||||
"Zoom in (+)": "放大 (+)",
|
||||
"Zoom out (−)": "縮小 (−)",
|
||||
"ORBITAL VIEW · OFF-NADIR": "軌道視圖 · 偏離天底",
|
||||
"World time": "世界時間",
|
||||
"Back to live": "返回即時",
|
||||
"Live": "即時",
|
||||
|
||||
@@ -23,18 +23,25 @@
|
||||
"HDG {value}°",
|
||||
"Ironbow",
|
||||
"Landsat 8/9",
|
||||
"NORAD {id}",
|
||||
"NVG",
|
||||
"REC",
|
||||
"S2 GhostMaps",
|
||||
"SQUAWK {code} · {status}",
|
||||
"SRC {value}",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"Sentinel-2",
|
||||
"TLE",
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"dB",
|
||||
"hPa",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{source} HTTP {status}",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
@@ -114,6 +121,7 @@
|
||||
"NEWS",
|
||||
"NNW",
|
||||
"NOIR",
|
||||
"NORAD {id}",
|
||||
"NORMAL",
|
||||
"NVG",
|
||||
"NW",
|
||||
@@ -148,11 +156,15 @@
|
||||
"Rock",
|
||||
"S2 GhostMaps",
|
||||
"SAR",
|
||||
"SENSOR",
|
||||
"SIG",
|
||||
"SNAPSHOT",
|
||||
"SQUAWK {code} · {status}",
|
||||
"SSW",
|
||||
"STANDARD",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"START",
|
||||
"STATION",
|
||||
"STATUS",
|
||||
@@ -172,6 +184,7 @@
|
||||
"TAGS · {tags}",
|
||||
"TANKER",
|
||||
"TEMP",
|
||||
"TLE",
|
||||
"Talk",
|
||||
"Tallinn",
|
||||
"Techno",
|
||||
@@ -187,6 +200,7 @@
|
||||
"Washington DC",
|
||||
"Wind",
|
||||
"acartia.io ↗",
|
||||
"ZOOM {zoom}×",
|
||||
"dB",
|
||||
"feed status|live",
|
||||
"fire behavior|Minimal",
|
||||
@@ -203,6 +217,9 @@
|
||||
"view band|GLOBAL",
|
||||
"{city} CCTV",
|
||||
"{county} County",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{division}, {country}",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
@@ -280,6 +297,7 @@
|
||||
"NE",
|
||||
"NNE",
|
||||
"NOIR",
|
||||
"NORAD {id}",
|
||||
"NORMAL",
|
||||
"NVG",
|
||||
"Noir",
|
||||
@@ -302,15 +320,21 @@
|
||||
"SAR",
|
||||
"SE",
|
||||
"SECTOR {lat} {lon}",
|
||||
"SENSOR",
|
||||
"SIG",
|
||||
"SQUAWK {code} · {status}",
|
||||
"SRC {value}",
|
||||
"SSE",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"STD",
|
||||
"San Francisco",
|
||||
"Sector",
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"TELECOM",
|
||||
"TLE",
|
||||
"Techno",
|
||||
"Trance",
|
||||
"USB {rate} msg/s",
|
||||
@@ -318,6 +342,7 @@
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"WX",
|
||||
"ZOOM {zoom}×",
|
||||
"audio {state}",
|
||||
"dB",
|
||||
"fire confidence filter|Nominal+",
|
||||
@@ -337,6 +362,9 @@
|
||||
"panel",
|
||||
"view band|GLOBAL",
|
||||
"voice|ERROR",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{division}, {country}",
|
||||
"{rate} msg/s",
|
||||
"{speed} kn",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
@@ -401,8 +429,6 @@
|
||||
"Cyber",
|
||||
"Cyclones · NHC / CPHC",
|
||||
"DENSE",
|
||||
"DENSE ···",
|
||||
"DENSE ✕",
|
||||
"DEST ---°",
|
||||
"DEST {bearing}",
|
||||
"DESTINATION",
|
||||
@@ -451,6 +477,7 @@
|
||||
"NE",
|
||||
"NNE",
|
||||
"NOIR",
|
||||
"NORAD {id}",
|
||||
"NORMAL",
|
||||
"NVG",
|
||||
"New York",
|
||||
@@ -490,7 +517,11 @@
|
||||
"SRC {value}",
|
||||
"SSE",
|
||||
"STANDARD",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"STATION",
|
||||
"STATIONS",
|
||||
"STD",
|
||||
"San Francisco",
|
||||
"Satellites",
|
||||
@@ -504,6 +535,7 @@
|
||||
"Suggestions",
|
||||
"TAGS · {tags}",
|
||||
"TEMP",
|
||||
"TLE",
|
||||
"TYPE",
|
||||
"Tallinn",
|
||||
"Techno",
|
||||
@@ -515,6 +547,7 @@
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"ZOOM {zoom}×",
|
||||
"audio {state}",
|
||||
"dB",
|
||||
"ft",
|
||||
@@ -535,9 +568,13 @@
|
||||
"standard",
|
||||
"station",
|
||||
"voice|OFF",
|
||||
"{count} CONTACTS",
|
||||
"{count} MSGS",
|
||||
"{count} SOURCES",
|
||||
"{count} messages",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{division}, {country}",
|
||||
"{n} cyclones",
|
||||
"{n} satellites",
|
||||
"{rate} msg/s",
|
||||
@@ -548,7 +585,8 @@
|
||||
"ΔN",
|
||||
"Ⅱ PAUSE",
|
||||
"≥ {value} MW",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"SATELLITE"
|
||||
],
|
||||
"it": [
|
||||
"1 min/s",
|
||||
@@ -631,6 +669,7 @@
|
||||
"NE",
|
||||
"NNE",
|
||||
"NOIR",
|
||||
"NORAD {id}",
|
||||
"NVG",
|
||||
"New York",
|
||||
"Noir",
|
||||
@@ -663,6 +702,9 @@
|
||||
"SQUAWK {code} · {status}",
|
||||
"SSE",
|
||||
"STANDARD",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"STD",
|
||||
"STOP",
|
||||
"SYNC",
|
||||
@@ -671,8 +713,10 @@
|
||||
"Sentinel-2",
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"TELECOM",
|
||||
"TEMP",
|
||||
"TIMEOUT",
|
||||
"TLE",
|
||||
"Talk",
|
||||
"Tallinn",
|
||||
"Techno",
|
||||
@@ -690,6 +734,7 @@
|
||||
"WHOT/BHOT",
|
||||
"Washington DC",
|
||||
"World",
|
||||
"ZOOM {zoom}×",
|
||||
"audio {state}",
|
||||
"dB",
|
||||
"feed status|live",
|
||||
@@ -711,6 +756,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}",
|
||||
@@ -729,7 +777,8 @@
|
||||
"CC BY 4.0",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
"iNaturalist",
|
||||
"SATELLITE"
|
||||
],
|
||||
"ja": [
|
||||
"1090 MHz",
|
||||
@@ -758,12 +807,17 @@
|
||||
"Ironbow",
|
||||
"Landsat 8/9",
|
||||
"MSG/S",
|
||||
"NORAD {id}",
|
||||
"NVG",
|
||||
"POS: {time}Z",
|
||||
"REC",
|
||||
"S2 GhostMaps",
|
||||
"SRC {value}",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"Sentinel-2",
|
||||
"TLE",
|
||||
"USB {rate} msg/s",
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
@@ -777,6 +831,8 @@
|
||||
"kt",
|
||||
"mph",
|
||||
"{city} CCTV",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{rate} msg/s",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
@@ -856,6 +912,7 @@
|
||||
"NE",
|
||||
"NNE",
|
||||
"NOIR",
|
||||
"NORAD {id}",
|
||||
"NORMAL",
|
||||
"NVG",
|
||||
"Noir",
|
||||
@@ -875,10 +932,14 @@
|
||||
"S2 GhostMaps",
|
||||
"SAR",
|
||||
"SE",
|
||||
"SENSOR",
|
||||
"SIG",
|
||||
"SNAPSHOT",
|
||||
"SQUAWK {code} · {status}",
|
||||
"SSE",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"STATUS",
|
||||
"STATUS · ",
|
||||
"Sentinel-2",
|
||||
@@ -886,7 +947,9 @@
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"TAGS · {tags}",
|
||||
"TELECOM",
|
||||
"TEMP",
|
||||
"TLE",
|
||||
"Tallinn",
|
||||
"Techno",
|
||||
"Trance",
|
||||
@@ -897,6 +960,7 @@
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"WX",
|
||||
"ZOOM {zoom}×",
|
||||
"dB",
|
||||
"fire confidence filter|Nominal+",
|
||||
"fire confidence|nominal",
|
||||
@@ -911,6 +975,9 @@
|
||||
"mph",
|
||||
"nominal",
|
||||
"view band|GLOBAL",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{division}, {country}",
|
||||
"{rate} msg/s",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
@@ -938,16 +1005,24 @@
|
||||
"Ironbow",
|
||||
"Landsat 8/9",
|
||||
"NAV",
|
||||
"NORAD {id}",
|
||||
"NVG",
|
||||
"R&B",
|
||||
"REC",
|
||||
"S2 GhostMaps",
|
||||
"S2 · {name}",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"TLE",
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗"
|
||||
"iNaturalist ↗",
|
||||
"{direction} · {elevation}",
|
||||
"{direction} {angle}°",
|
||||
"{division}, {country}"
|
||||
],
|
||||
"uk": [
|
||||
"AIS: --",
|
||||
@@ -959,13 +1034,21 @@
|
||||
"FLIR",
|
||||
"Ironbow",
|
||||
"Landsat 8/9",
|
||||
"NORAD {id}",
|
||||
"NVG",
|
||||
"REC",
|
||||
"S2 GhostMaps",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"TLE",
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗"
|
||||
"iNaturalist ↗",
|
||||
"{direction} · {elevation}",
|
||||
"{direction} {angle}°",
|
||||
"{division}, {country}"
|
||||
],
|
||||
"zh-CN": [
|
||||
"1090 MHz",
|
||||
@@ -991,12 +1074,17 @@
|
||||
"KTS",
|
||||
"Landsat 8/9",
|
||||
"MM",
|
||||
"NORAD {id}",
|
||||
"NVG",
|
||||
"REC",
|
||||
"S2 GhostMaps",
|
||||
"S2 · {name}",
|
||||
"SRC {value}",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"Sentinel-2",
|
||||
"TLE",
|
||||
"Techno",
|
||||
"Trance",
|
||||
"USB {rate} msg/s",
|
||||
@@ -1014,6 +1102,8 @@
|
||||
"kts",
|
||||
"mph",
|
||||
"{city} CCTV",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{rate} msg/s",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
@@ -1054,12 +1144,17 @@
|
||||
"KTS",
|
||||
"Landsat 8/9",
|
||||
"MM",
|
||||
"NORAD {id}",
|
||||
"NVG",
|
||||
"REC",
|
||||
"SRC {value}",
|
||||
"S2 GhostMaps",
|
||||
"S2 · {name}",
|
||||
"SRC {value}",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"Sentinel-2",
|
||||
"TLE",
|
||||
"Techno",
|
||||
"Trance",
|
||||
"USB {rate} msg/s",
|
||||
@@ -1077,6 +1172,8 @@
|
||||
"kts",
|
||||
"mph",
|
||||
"{city} CCTV",
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{rate} msg/s",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
|
||||
Vendored
+35
@@ -164,6 +164,41 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* A loaded mapped installation by id, as `getNearby` returns it (with its
|
||||
* drawn `position`), or null while the layer is off or the id is not
|
||||
* loaded. Read-only: nothing is selected.
|
||||
* @param {string} id Source-backed installation id.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
getById(id) {
|
||||
if (!layerState.enabled || id === null || id === undefined) return null;
|
||||
const record = layerState.recordById.get(String(id));
|
||||
if (!record || record.kind !== 'installation') return null;
|
||||
return {
|
||||
...record,
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
parts.rendering.installationSurfaceHeightM(record),
|
||||
),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* The mapped installation a `scene.pick()` result landed on: a named
|
||||
* marker point (`{ installationId }`) or a site entity, as this layer's
|
||||
* own click selection reads them. Null for anything else.
|
||||
* @param {object|null|undefined} picked
|
||||
* @returns {object|null} As {@link getById}.
|
||||
*/
|
||||
findByPick(picked) {
|
||||
const id =
|
||||
picked?.id?.installationId ||
|
||||
(typeof picked?.id?.id === 'string' ? picked.id.id : null);
|
||||
return id ? methods.getById(id) : null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Select and frame a mapped installation from another contextual UI.
|
||||
* @param {string} id Source-backed installation id.
|
||||
|
||||
@@ -124,6 +124,13 @@ 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,
|
||||
satcat: null,
|
||||
sensor: null,
|
||||
});
|
||||
const point = layerState._pointCollection.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
@@ -204,6 +211,14 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
if (token !== layerState._denseLoadToken) return;
|
||||
layerState._params.catalog = 'core';
|
||||
_removeDenseCatalog();
|
||||
// Back on core, Starlink is off: hide the core catalog's own Starlink
|
||||
// points again, and say so in the class choice.
|
||||
if (layerState._params.classes)
|
||||
layerState._params.classes = {
|
||||
...layerState._params.classes,
|
||||
starlink: false,
|
||||
};
|
||||
parts.controls._applyClassVisibility?.();
|
||||
layerState._denseStatus = 'failed';
|
||||
layerState._denseError = reason;
|
||||
parts.controls._notifyRowControls();
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
/**
|
||||
* @file Satellite classes: which of six KSP-style classes an object in orbit
|
||||
* belongs to, who owns it and who operates it.
|
||||
*
|
||||
* Classes, in legend order:
|
||||
* - stations: crewed stations and the craft that visit them (ISS, Tiangong,
|
||||
* Soyuz, Shenzhou, Dragon, Progress, Cygnus…), by name. The CubeSats
|
||||
* CelesTrak's stations group also carries after their deployment from the
|
||||
* ISS are classed like any other satellite. Retired stations and the
|
||||
* shuttles have re-entered, so they have no orbit to show.
|
||||
* - imaging: Earth observation of every kind (optical, radar, multispectral,
|
||||
* hyperspectral, weather, Earth science, surveillance).
|
||||
* - telecom: communications and navigation (GEO comsats, Iridium, OneWeb,
|
||||
* GPS, GLONASS, Galileo, BeiDou…), Starlink excepted.
|
||||
* - starlink: the Starlink constellation.
|
||||
* - probes: other working payloads (space science, technology development and
|
||||
* demonstration, education, unknown purpose).
|
||||
* - debris: rocket bodies, fragments, unknown objects and payloads that no
|
||||
* longer work. Shown and tracked on request, never ridden in the Cockpit.
|
||||
*
|
||||
* The evidence, strongest first: CelesTrak SATCAT's object type and
|
||||
* operational status (current, so it overrules UCS on a satellite that has
|
||||
* died since), the UCS Satellite Database's purpose (May 2023), name patterns
|
||||
* for satellites launched after it, and the CelesTrak group the TLE came in.
|
||||
* Pure: no Cesium, no fetches, so the tables are unit-tested directly.
|
||||
*
|
||||
* @module satelliteClasses
|
||||
*/
|
||||
|
||||
import { msg } from '../../i18n/index.js';
|
||||
|
||||
/** Class ids in legend order. */
|
||||
export const SATELLITE_CLASS_IDS = Object.freeze([
|
||||
'stations',
|
||||
'imaging',
|
||||
'telecom',
|
||||
'starlink',
|
||||
'probes',
|
||||
'debris',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Which classes show when nobody has chosen: everything the layer showed
|
||||
* before classes existed. Starlink stays off, as the DENSE switch was.
|
||||
*/
|
||||
export const DEFAULT_SATELLITE_CLASSES = Object.freeze({
|
||||
stations: true,
|
||||
imaging: true,
|
||||
telecom: true,
|
||||
starlink: false,
|
||||
probes: true,
|
||||
debris: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Point colour and the legend and chip text for each class. Colours reuse the
|
||||
* layer's established palette (src/data/satelliteClass.js); debris recedes.
|
||||
*/
|
||||
export const SATELLITE_CLASS_DISPLAY = Object.freeze({
|
||||
stations: Object.freeze({
|
||||
color: '#fff6e5',
|
||||
label: msg('STATIONS'),
|
||||
blurb: msg('Crewed stations and the craft that visit them'),
|
||||
}),
|
||||
imaging: Object.freeze({
|
||||
color: '#4fd8ff',
|
||||
label: msg('IMAGING'),
|
||||
blurb: msg('Earth observation: optical, radar, weather and science'),
|
||||
}),
|
||||
telecom: Object.freeze({
|
||||
color: '#c89bff',
|
||||
label: msg('TELECOM'),
|
||||
blurb: msg('Communications and navigation satellites, Starlink excepted'),
|
||||
}),
|
||||
starlink: Object.freeze({
|
||||
color: '#54697f',
|
||||
label: msg('STARLINK'),
|
||||
blurb: msg('The Starlink constellation (thousands of extra points)'),
|
||||
}),
|
||||
probes: Object.freeze({
|
||||
color: '#9fb3c4',
|
||||
label: msg('PROBES'),
|
||||
blurb: msg('Space science, technology demonstration and other payloads'),
|
||||
}),
|
||||
debris: Object.freeze({
|
||||
color: '#7d7468',
|
||||
label: msg('DEBRIS'),
|
||||
blurb: msg('Rocket bodies, fragments and dead satellites'),
|
||||
}),
|
||||
});
|
||||
|
||||
/** The ISS, a station whatever else says. */
|
||||
const ISS_NORAD = 25544;
|
||||
|
||||
/** SATCAT object types that are never a working payload. */
|
||||
const DEBRIS_OBJECT_TYPES = new Set(['R/B', 'DEB']);
|
||||
/**
|
||||
* SATCAT files a new launch's objects as UNK until it identifies them, so an
|
||||
* unknown object launched within this many calendar years (2: this year or
|
||||
* last) is not presumed debris. Years, not dates: the TLE designator, the
|
||||
* fallback when SATCAT has no launch date, carries only the year.
|
||||
*/
|
||||
export const UNIDENTIFIED_GRACE_YEARS = 2;
|
||||
/** SATCAT operational status: nonoperational, decayed. */
|
||||
const DEAD_STATUSES = new Set(['-', 'D']);
|
||||
/** SATCAT operational status left blank or unknown. */
|
||||
const UNKNOWN_STATUSES = new Set(['', '?']);
|
||||
/**
|
||||
* A payload whose SATCAT record gives no status, with no UCS record and no
|
||||
* recognisable name, that launched this many years ago is presumed dead (old
|
||||
* weather and science satellites the "visual" group keeps for their
|
||||
* brightness). Without a SATCAT record (a failed read) nothing is presumed.
|
||||
*/
|
||||
export const PRESUMED_DEAD_AFTER_YEARS = 20;
|
||||
|
||||
const DEBRIS_NAME = /\bR\/B\b|\bDEB\b|\bAKM\b|ROCKET BODY/i;
|
||||
const STATION_NAME =
|
||||
/^(ISS\b|TIANGONG|TIANHE|WENTIAN|MENGTIAN|CSS\b|SHENZHOU|TIANZHOU|SOYUZ|PROGRESS|CREW DRAGON|DRAGON|CYGNUS|STARLINER|HTV\b|AXIOM)/i;
|
||||
const STARLINK_NAME = /^STARLINK/i;
|
||||
const IMAGING_NAME =
|
||||
/^(SENTINEL|LANDSAT|WORLDVIEW|GEOEYE|PLEIADES|SPOT[ -]\d|ICEYE|CAPELLA|UMBRA|FLOCK|SKYSAT|PELICAN|SUPERDOVE|DOVE\b|NOAA[ -]?\d|GOES|METEOSAT|METOP|HIMAWARI|FENGYUN|FY-?\d|GAOFEN|JILIN|SUPERVIEW|KOMPSAT|RADARSAT|RCM-|TERRA\b|AQUA\b|SUOMI NPP|JPSS|CARTOSAT|RESOURCESAT|RISAT|EOS-\d|ALOS|COSMO-SKYMED|CSG-|TERRASAR|TANDEM-X|PAZ\b|SAOCOM|KANOPUS|RESURS|METEOR-M|ELEKTRO-L|ARKTIKA-M|YAOGAN|NUSAT|NEWSAT|BLACKSKY|GLOBAL-\d|GHGSAT|LEMUR|HAIYANG|HY-\d|ZIYUAN|ZY-\d|TIANHUI|SWOT|ICESAT|GRACE|CRYOSAT|SMAP|SMOS|PROBA-V|DEIMOS|THEOS|GOKTURK|EROS|OFEQ|OPTSAT|PRISMA|ENMAP|NISAR|BIOMASS|EARTHCARE|FORMOSAT|CBERS|AMAZONIA|SICH|DMSP|ELECTRO)/i;
|
||||
const TELECOM_NAME =
|
||||
/^(ONEWEB|IRIDIUM|GLOBALSTAR|ORBCOMM|INTELSAT|IS-\d|SES[ -]?\d|ASTRA|EUTELSAT|INMARSAT|VIASAT|ECHOSTAR|DIRECTV|SIRIUS|XM-|TDRS|NAVSTAR|GPS\b|GLONASS|GSAT0|GALILEO|BEIDOU|QZS|IRNSS|NVS-|KUIPER|LYNK|BLUEBIRD|O3B|TELESAT|ANIK|NIMIQ|AMAZONAS|HISPASAT|ARABSAT|NILESAT|TURKSAT|YAMAL|EXPRESS-|CHINASAT|ZHONGXING|APSTAR|ASIASAT|THAICOM|MEASAT|JCSAT|SUPERBIRD|OPTUS|SKYNET|WGS|MUOS|AEHF|MILSTAR|QIANFAN|GUOWANG|SPACEMOBILE|GONETS|MERIDIAN|LUCH|RADUGA|SKYTERRA|TERRESTAR|SPACEWAY|HYLAS|ARSAT|AMOS|BADR|KA-SAT|YAHSAT|THURAYA)/i;
|
||||
|
||||
const IMAGING_PURPOSE =
|
||||
/earth observation|earth\/space observation|earth science|meteorolog|surveillance/i;
|
||||
const TELECOM_PURPOSE = /communications|navigation|positioning/i;
|
||||
|
||||
/**
|
||||
* CelesTrak groups whose members are, by default, of one class. Not
|
||||
* stations: it also carries CubeSats deployed from the ISS.
|
||||
*/
|
||||
const GROUP_CLASS = Object.freeze({
|
||||
dense: 'starlink',
|
||||
starlink: 'starlink',
|
||||
resource: 'imaging',
|
||||
weather: 'imaging',
|
||||
planet: 'imaging',
|
||||
sar: 'imaging',
|
||||
'gps-ops': 'telecom',
|
||||
glonass: 'telecom',
|
||||
'glo-ops': 'telecom',
|
||||
galileo: 'telecom',
|
||||
geo: 'telecom',
|
||||
science: 'probes',
|
||||
});
|
||||
|
||||
/** CelesTrak groups named for an orbit, not a mission: the last resort. */
|
||||
const ORBIT_GROUPS = new Set(['geo']);
|
||||
|
||||
/** The year of a SATCAT launch date (`2024-03-15`), or null. */
|
||||
function launchYearOf(launchDate) {
|
||||
const match = /^(\d{4})-/.exec(String(launchDate || ''));
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The class a satellite belongs to.
|
||||
* @param {{noradId?: number, name?: string, group?: string|null,
|
||||
* groups?: string[]|null,
|
||||
* ucs?: {purpose?: string|null}|null,
|
||||
* satcat?: {objectType?: string, opsStatus?: string,
|
||||
* launchDate?: string|null}|null,
|
||||
* launchYear?: number|null, nowYear?: number}} subject `groups` is every
|
||||
* CelesTrak group the TLE came in (`group` is the one it is shown under):
|
||||
* the first of them that names a class gives the last-resort class, so a
|
||||
* satellite in both `visual` and `resource` is still imaging. `launchYear` is
|
||||
* the TLE designator's; SATCAT's launch date wins where it has one (an
|
||||
* object deployed from the ISS carries the station's 1998 designator).
|
||||
* @returns {'stations'|'imaging'|'telecom'|'starlink'|'probes'|'debris'}
|
||||
*/
|
||||
export function classifySatellite({
|
||||
noradId,
|
||||
name = '',
|
||||
group = null,
|
||||
ucs = null,
|
||||
satcat = null,
|
||||
groups = null,
|
||||
launchYear = null,
|
||||
nowYear = new Date().getUTCFullYear(),
|
||||
} = {}) {
|
||||
if (noradId === ISS_NORAD) return 'stations';
|
||||
const label = String(name || '').trim();
|
||||
const objectType = satcat?.objectType || null;
|
||||
const status = satcat?.opsStatus ?? null;
|
||||
const launched = launchYearOf(satcat?.launchDate) ?? launchYear;
|
||||
const recent =
|
||||
Number.isFinite(launched) && nowYear - launched < UNIDENTIFIED_GRACE_YEARS;
|
||||
if (objectType && DEBRIS_OBJECT_TYPES.has(objectType)) return 'debris';
|
||||
if (objectType === 'UNK' && !recent) return 'debris';
|
||||
if (!objectType && DEBRIS_NAME.test(label)) return 'debris';
|
||||
if (status !== null && DEAD_STATUSES.has(status)) return 'debris';
|
||||
|
||||
// CelesTrak's stations group also carries the CubeSats deployed from the
|
||||
// ISS (they share its 1998-067 designator): only a station or a visiting
|
||||
// craft, by name, is a station.
|
||||
if (STATION_NAME.test(label)) return 'stations';
|
||||
if (group === 'dense' || STARLINK_NAME.test(label)) return 'starlink';
|
||||
|
||||
// UCS writes "Unknown" for a purpose it does not know: no evidence.
|
||||
const purpose = /^unknown$/i.test(ucs?.purpose || '')
|
||||
? ''
|
||||
: ucs?.purpose || '';
|
||||
if (IMAGING_PURPOSE.test(purpose)) return 'imaging';
|
||||
if (TELECOM_PURPOSE.test(purpose)) return 'telecom';
|
||||
if (purpose) return 'probes';
|
||||
|
||||
if (IMAGING_NAME.test(label)) return 'imaging';
|
||||
if (TELECOM_NAME.test(label)) return 'telecom';
|
||||
|
||||
// UCS lists working satellites only: a payload it never knew, whose SATCAT
|
||||
// record gives no status and whose launch is decades old, is presumed
|
||||
// dead. Without a SATCAT record (its read failed or has not landed) there
|
||||
// is no evidence either way, so nothing is presumed.
|
||||
if (
|
||||
satcat &&
|
||||
UNKNOWN_STATUSES.has(status ?? '') &&
|
||||
Number.isFinite(launched) &&
|
||||
nowYear - launched >= PRESUMED_DEAD_AFTER_YEARS
|
||||
)
|
||||
return 'debris';
|
||||
|
||||
// A mission group (weather, resource, science…) says more than an orbit
|
||||
// group: a weather satellite in geostationary orbit is in both.
|
||||
const memberships = groups?.length ? groups : [group];
|
||||
for (const membership of [
|
||||
...memberships.filter((m) => !ORBIT_GROUPS.has(m)),
|
||||
...memberships.filter((m) => ORBIT_GROUPS.has(m)),
|
||||
])
|
||||
if (GROUP_CLASS[membership]) return GROUP_CLASS[membership];
|
||||
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.
|
||||
* @param {Record<string, unknown>|null|undefined} request
|
||||
* @param {Record<string, boolean>} [current]
|
||||
* @returns {Record<string, boolean>}
|
||||
*/
|
||||
export function mergeClassVisibility(
|
||||
request,
|
||||
current = DEFAULT_SATELLITE_CLASSES,
|
||||
) {
|
||||
const next = {};
|
||||
for (const id of SATELLITE_CLASS_IDS) {
|
||||
const value = request?.[id];
|
||||
next[id] = typeof value === 'boolean' ? value : current[id] === true;
|
||||
}
|
||||
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 = [];
|
||||
let field = '';
|
||||
let quoted = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (quoted) {
|
||||
if (ch === '"' && line[i + 1] === '"') {
|
||||
field += '"';
|
||||
i++;
|
||||
} else if (ch === '"') quoted = false;
|
||||
else field += ch;
|
||||
} else if (ch === '"') quoted = true;
|
||||
else if (ch === ',') {
|
||||
fields.push(field);
|
||||
field = '';
|
||||
} else field += ch;
|
||||
}
|
||||
fields.push(field);
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
* owner: string, cosparId: string|null, launchDate: string|null}>}
|
||||
*/
|
||||
export function parseSatcatCsv(text) {
|
||||
const records = new Map();
|
||||
const lines = String(text || '')
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.trim());
|
||||
if (!lines.length) return records;
|
||||
const header = csvFields(lines[0]).map((name) => name.trim());
|
||||
const at = (name) => header.indexOf(name);
|
||||
const col = {
|
||||
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;
|
||||
for (const line of lines.slice(1)) {
|
||||
const fields = csvFields(line);
|
||||
const noradId = Number(fields[col.norad]);
|
||||
if (!Number.isInteger(noradId) || noradId <= 0) continue;
|
||||
const text = (index) => (index < 0 ? '' : (fields[index] ?? '').trim());
|
||||
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,
|
||||
});
|
||||
}
|
||||
return records;
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
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, with stations and imaging on by default and the rest off', () => {
|
||||
assert.deepEqual(SATELLITE_CLASS_IDS, [
|
||||
'stations',
|
||||
'imaging',
|
||||
'telecom',
|
||||
'starlink',
|
||||
'probes',
|
||||
'debris',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
SATELLITE_CLASS_IDS.filter((id) => DEFAULT_SATELLITE_CLASSES[id]),
|
||||
['stations', 'imaging', 'telecom', 'probes', 'debris'],
|
||||
);
|
||||
for (const id of SATELLITE_CLASS_IDS) {
|
||||
const display = SATELLITE_CLASS_DISPLAY[id];
|
||||
assert.match(display.color, /^#[0-9a-f]{6}$/, id);
|
||||
assert.ok(display.label && display.blurb, id);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
// 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 } }),
|
||||
'debris',
|
||||
opsStatus,
|
||||
);
|
||||
assert.equal(
|
||||
classify({ name: 'OLDSAT', ucs: imaging, satcat: { objectType: 'PAY', opsStatus: '+' } }),
|
||||
'imaging',
|
||||
);
|
||||
// SATCAT calls a new launch's objects UNK until it names them: only an
|
||||
// old unknown object, or one with no launch date, is debris.
|
||||
assert.equal(
|
||||
classify({
|
||||
name: 'OBJECT C',
|
||||
group: 'resource',
|
||||
satcat: { objectType: 'UNK', opsStatus: '', launchDate: '2025-11-02' },
|
||||
}),
|
||||
'imaging',
|
||||
);
|
||||
assert.equal(
|
||||
classify({
|
||||
name: 'OBJECT C',
|
||||
group: 'resource',
|
||||
satcat: { objectType: 'UNK', opsStatus: '', launchDate: '2019-04-01' },
|
||||
}),
|
||||
'debris',
|
||||
);
|
||||
// Debris riding in the stations group (CelesTrak lists FREGAT DEB there).
|
||||
assert.equal(classify({ name: 'FREGAT DEB', group: 'stations' }), 'debris');
|
||||
assert.equal(classify({ name: 'SL-16 R/B', group: 'visual' }), 'debris');
|
||||
});
|
||||
|
||||
test('the ISS is a station whatever else says', () => {
|
||||
assert.equal(
|
||||
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, group: 'visual' }), 'stations', name);
|
||||
});
|
||||
|
||||
test('UCS purpose decides imaging, telecom and probes', () => {
|
||||
const table = [
|
||||
['Earth Observation', 'imaging'],
|
||||
['Earth Science', 'imaging'],
|
||||
['Meteorological', 'imaging'],
|
||||
['Surveillance', 'imaging'],
|
||||
['Earth Observation/Navigation', 'imaging'],
|
||||
['Earth/Space Observation', 'imaging'],
|
||||
['Communications', 'telecom'],
|
||||
['Navigation/Global Positioning', 'telecom'],
|
||||
['Navigation/Regional Positioning', 'telecom'],
|
||||
['Technology Development', 'probes'],
|
||||
['Technology Demonstration', 'probes'],
|
||||
['Space Science', 'probes'],
|
||||
['Unknown', 'probes'],
|
||||
];
|
||||
for (const [purpose, expected] of table)
|
||||
assert.equal(classify({ name: 'UNNAMED-1', group: 'visual', ucs: { purpose } }), expected, purpose);
|
||||
});
|
||||
|
||||
test('satellites launched after the UCS snapshot are classed by name', () => {
|
||||
const table = [
|
||||
['ICEYE-X50', 'imaging'],
|
||||
['CAPELLA-17', 'imaging'],
|
||||
['FLOCK 4Y-12', 'imaging'],
|
||||
['PELICAN-3', 'imaging'],
|
||||
['SENTINEL-1C', 'imaging'],
|
||||
['LANDSAT 9', 'imaging'],
|
||||
['GOES 19', 'imaging'],
|
||||
['ONEWEB-0712', 'telecom'],
|
||||
['IRIDIUM 180', 'telecom'],
|
||||
['GPS BIII-8 (PRN 14)', 'telecom'],
|
||||
['KUIPER-00042', 'telecom'],
|
||||
['STARLINK-34012', 'starlink'],
|
||||
];
|
||||
for (const [name, expected] of table)
|
||||
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: 'STARLINK-1', group: 'dense' }), 'starlink');
|
||||
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']) {
|
||||
assert.equal(
|
||||
classify({ name, group: 'stations', launchYear: 1998 }),
|
||||
'probes',
|
||||
`${name} without SATCAT`,
|
||||
);
|
||||
assert.equal(
|
||||
classify({
|
||||
name,
|
||||
group: 'stations',
|
||||
launchYear: 1998,
|
||||
satcat: { objectType: 'PAY', opsStatus: '', launchDate: '2025-04-21' },
|
||||
}),
|
||||
'probes',
|
||||
`${name}: SATCAT's 2025 launch, not the designator's 1998`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
classify({ name: 'UNNAMED-1', group: 'stations', ucs: { purpose: 'Earth Observation' } }),
|
||||
'imaging',
|
||||
);
|
||||
});
|
||||
|
||||
test('an unknown old payload is presumed dead; a recognisable one is not', () => {
|
||||
const old = NOW - PRESUMED_DEAD_AFTER_YEARS;
|
||||
assert.equal(
|
||||
classify({ name: 'ATLAS CENTAUR 2', group: 'visual', launchYear: 1963 }),
|
||||
'probes',
|
||||
'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: '' } }),
|
||||
'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: 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 });
|
||||
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',
|
||||
'ISS (ZARYA),1998-067A,25544,PAY,+,ISS,1998-11-20',
|
||||
'"THOR AGENA, D R/B",1964-002A,733,r/b,,US,1964-01-19',
|
||||
'BROKEN,,notanumber,PAY,+,US,',
|
||||
'',
|
||||
].join('\r\n');
|
||||
const records = parseSatcatCsv(csv);
|
||||
assert.deepEqual([...records.keys()], [25544, 733]);
|
||||
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');
|
||||
assert.equal(parseSatcatCsv('<html>busy</html>').size, 0);
|
||||
assert.equal(parseSatcatCsv('').size, 0);
|
||||
});
|
||||
|
||||
test('turning Stations off takes the ISS ring and label with its point', async () => {
|
||||
const { createControls } = await import('./controls.js');
|
||||
let overlaySyncs = 0;
|
||||
const ring = { primitive: { show: true } };
|
||||
const state = {
|
||||
_enabled: true,
|
||||
_params: {
|
||||
catalog: 'core',
|
||||
showOrbits: true,
|
||||
classes: { ...DEFAULT_SATELLITE_CLASSES, stations: false },
|
||||
},
|
||||
_catalog: new Map([[25544, { satelliteClass: 'stations' }]]),
|
||||
_points: new Map([[25544, { show: true }]]),
|
||||
_orbitPaths: new Map([[25544, ring]]),
|
||||
_detectionObjects: new Map(),
|
||||
_trackedNorad: null,
|
||||
};
|
||||
const controls = createControls({
|
||||
state,
|
||||
services: { layerState: { isExplicitLayerStateOrigin: () => false } },
|
||||
parts: { labels: { _syncIssOverlay: () => (overlaySyncs += 1) } },
|
||||
source: {},
|
||||
});
|
||||
controls._applyClassVisibility();
|
||||
assert.equal(state._points.get(25544).show, false);
|
||||
assert.equal(ring.primitive.show, false, 'the ring goes with the class');
|
||||
assert.ok(overlaySyncs >= 1, 'the label is re-synced');
|
||||
state._params.classes = { ...DEFAULT_SATELLITE_CLASSES };
|
||||
controls._applyClassVisibility();
|
||||
assert.equal(ring.primitive.show, true, 'and comes back with it');
|
||||
});
|
||||
|
||||
test('a failed Starlink load hides the core catalog Starlink points again', async () => {
|
||||
const { createCatalog } = await import('./catalog.js');
|
||||
let applied = 0;
|
||||
const state = {
|
||||
_params: {
|
||||
catalog: 'dense',
|
||||
classes: { ...DEFAULT_SATELLITE_CLASSES, starlink: true },
|
||||
},
|
||||
_denseLoadToken: 3,
|
||||
_denseIds: [],
|
||||
_catalog: new Map(),
|
||||
_points: new Map(),
|
||||
_detectionObjects: new Map(),
|
||||
_trackedNorad: null,
|
||||
_activeUpdateControllers: new Set(),
|
||||
coverageChanged() {},
|
||||
};
|
||||
const catalog = createCatalog({
|
||||
state,
|
||||
services: {},
|
||||
parts: {
|
||||
controls: {
|
||||
_applyClassVisibility: () => (applied += 1),
|
||||
_notifyRowControls() {},
|
||||
},
|
||||
labels: { _syncIssOverlay() {} },
|
||||
},
|
||||
source: {},
|
||||
});
|
||||
catalog._denseLoadFailed(state._denseLoadToken, 'feed unreachable');
|
||||
assert.equal(state._params.catalog, 'core');
|
||||
assert.equal(state._params.classes.starlink, false, 'the choice says so');
|
||||
assert.ok(applied >= 1, 'class visibility is applied again');
|
||||
});
|
||||
|
||||
test('every ingested group names the class its unnamed members fall back to', () => {
|
||||
// A group added to CATALOG_GROUPS without a class would file its members
|
||||
// under PROBES, the catch-all.
|
||||
const expected = {
|
||||
stations: 'probes',
|
||||
visual: 'probes',
|
||||
'gps-ops': 'telecom',
|
||||
glonass: 'telecom',
|
||||
galileo: 'telecom',
|
||||
geo: 'telecom',
|
||||
resource: 'imaging',
|
||||
weather: 'imaging',
|
||||
planet: 'imaging',
|
||||
sar: 'imaging',
|
||||
science: 'probes',
|
||||
};
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(
|
||||
CATALOG_GROUPS.map(({ tag }) => [
|
||||
tag,
|
||||
classify({ noradId: 99999, name: 'UNNAMED-1', group: tag }),
|
||||
]),
|
||||
),
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
test('enabling the layer leaves a hidden Stations class without its ISS ring', async () => {
|
||||
const { createLifecycle } = await import('./lifecycle.js');
|
||||
const iss = { primitive: { show: false } };
|
||||
const other = { primitive: { show: false } };
|
||||
let issVisible = false;
|
||||
const state = {
|
||||
_params: { showPoints: true, showOrbits: true },
|
||||
_orbitPaths: new Map([
|
||||
[25544, iss],
|
||||
[90001, other],
|
||||
]),
|
||||
_points: new Map(),
|
||||
coverageChanged() {},
|
||||
};
|
||||
const { methods } = createLifecycle({
|
||||
state,
|
||||
services: {
|
||||
focus: { clearFocusTarget() {} },
|
||||
render: { holdContinuousRender() {}, releaseContinuousRender() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
},
|
||||
parts: {
|
||||
controls: {
|
||||
satelliteVisualsVisible: (enabled, on) => enabled && on !== false,
|
||||
_issOrbitVisible: () => issVisible,
|
||||
},
|
||||
labels: { _syncIssOverlay() {} },
|
||||
interaction: { _installClickHandler() {} },
|
||||
tracking: { _applyPendingTrackingRestore() {} },
|
||||
rendering: {},
|
||||
},
|
||||
});
|
||||
methods.enable(null);
|
||||
assert.equal(iss.primitive.show, false, 'the ISS ring follows its class');
|
||||
assert.equal(other.primitive.show, true, 'other rings follow showOrbits');
|
||||
issVisible = true;
|
||||
methods.enable(null);
|
||||
assert.equal(iss.primitive.show, true);
|
||||
});
|
||||
|
||||
test('a mission group outranks an orbit group, and an Unknown UCS purpose is no evidence', () => {
|
||||
assert.equal(
|
||||
classify({ name: 'MTG-X1', group: 'geo', groups: ['geo', 'weather'] }),
|
||||
'imaging',
|
||||
);
|
||||
assert.equal(
|
||||
classify({ name: 'UNNAMED-1', group: 'geo', groups: ['geo'] }),
|
||||
'telecom',
|
||||
);
|
||||
assert.equal(
|
||||
classify({
|
||||
name: 'GPS BIII-9',
|
||||
group: 'visual',
|
||||
ucs: { purpose: 'Unknown' },
|
||||
}),
|
||||
'telecom',
|
||||
);
|
||||
assert.equal(
|
||||
classify({
|
||||
name: 'UNNAMED-1',
|
||||
group: 'resource',
|
||||
ucs: { purpose: 'Unknown' },
|
||||
}),
|
||||
'imaging',
|
||||
);
|
||||
});
|
||||
|
||||
test('a tracked ISS keeps its ring while Stations is hidden', async () => {
|
||||
const { createControls } = await import('./controls.js');
|
||||
const ring = { primitive: { show: true } };
|
||||
const state = {
|
||||
_enabled: true,
|
||||
_params: {
|
||||
catalog: 'core',
|
||||
showOrbits: true,
|
||||
classes: { ...DEFAULT_SATELLITE_CLASSES, stations: false },
|
||||
},
|
||||
_catalog: new Map([[25544, { satelliteClass: 'stations' }]]),
|
||||
_points: new Map([[25544, { show: true }]]),
|
||||
_orbitPaths: new Map([[25544, ring]]),
|
||||
_detectionObjects: new Map(),
|
||||
_trackedNorad: null,
|
||||
};
|
||||
const controls = createControls({
|
||||
state,
|
||||
services: { layerState: { isExplicitLayerStateOrigin: () => false } },
|
||||
parts: { labels: { _syncIssOverlay() {} } },
|
||||
source: {},
|
||||
});
|
||||
controls._syncIssOrbit();
|
||||
assert.equal(ring.primitive.show, false);
|
||||
state._trackedNorad = 25544;
|
||||
controls._syncIssOrbit();
|
||||
assert.equal(ring.primitive.show, true, 'tracked, it keeps its ring');
|
||||
state._params.showOrbits = false;
|
||||
controls._syncIssOrbit();
|
||||
assert.equal(ring.primitive.show, false, 'orbits off still hides it');
|
||||
});
|
||||
@@ -0,0 +1,332 @@
|
||||
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 }),
|
||||
// src/layers/satellites/sensors.js entry, or null: the orbital view's sensor swath.
|
||||
sensor: sat.sensor ?? null,
|
||||
});
|
||||
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, atMs?: number}|null}
|
||||
* fix Per-frame SGP4 sample (orbits.js propagatePosition), at `atMs`.
|
||||
* @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,
|
||||
// The time the position is at (epoch ms), the satellites' world-clock
|
||||
// time; null when the sample does not say.
|
||||
atMs: Number.isFinite(fix.atMs) ? fix.atMs : 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,
|
||||
sensor: identity.sensor,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,604 @@
|
||||
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 { indexImagingSensors } from './sensors.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',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
// One sensor entry, matched by NORAD number, for the orbital zoom cap.
|
||||
const SENSORS = indexImagingSensors({
|
||||
entries: [
|
||||
{
|
||||
id: 'fixture-imager',
|
||||
label: 'Fixture imager',
|
||||
match: { norad: [GPS] },
|
||||
narrowestSwathKm: 50,
|
||||
swathConfirmed: true,
|
||||
sources: ['https://example.test/spec'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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,
|
||||
readSensors: async () => SENSORS,
|
||||
};
|
||||
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);
|
||||
// The sensor pack is matched once, at ingestion.
|
||||
assert.equal(gps.sensor?.id, 'fixture-imager');
|
||||
assert.equal(gps.sensor.narrowestSwathKm, 50);
|
||||
assert.equal(iss.sensor, null, 'no sensor entry, no extra zoom');
|
||||
|
||||
// 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);
|
||||
});
|
||||
Vendored
+300
-79
@@ -1,11 +1,14 @@
|
||||
import {
|
||||
tallySatelliteClasses,
|
||||
satelliteClassLabel,
|
||||
satelliteClassLegend,
|
||||
} from '../../data/satelliteClass.js';
|
||||
import { satelliteClassText } from '../../data/satelliteClass.js';
|
||||
import { decodeSatelliteClassMask } from '../../data/satelliteClassMask.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { ISS_NORAD, POINT_STYLES } from './policy.js';
|
||||
import {
|
||||
DEFAULT_SATELLITE_CLASSES,
|
||||
SATELLITE_CLASS_DISPLAY,
|
||||
SATELLITE_CLASS_IDS,
|
||||
mergeClassVisibility,
|
||||
} from './classes.js';
|
||||
import { ISS_NORAD, POINT_STYLES, classPointStyle } from './policy.js';
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
const { isExplicitLayerStateOrigin } = services.layerState;
|
||||
@@ -19,7 +22,84 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
function _pointStyleFor(noradId, group) {
|
||||
if (noradId === ISS_NORAD) return POINT_STYLES.iss;
|
||||
return POINT_STYLES[group] || POINT_STYLES.visual;
|
||||
return classPointStyle(
|
||||
group,
|
||||
layerState._catalog.get(noradId)?.satelliteClass,
|
||||
);
|
||||
}
|
||||
|
||||
/** The class-visibility choice, defaults filled in. */
|
||||
function _classes() {
|
||||
return layerState._params.classes || DEFAULT_SATELLITE_CLASSES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a satellite's class is shown. Starlink follows the catalog mode:
|
||||
* its points exist only in dense mode. An unclassed entry shows.
|
||||
* @param {number} noradId NORAD catalog number.
|
||||
*/
|
||||
function _classVisible(noradId) {
|
||||
const satelliteClass = layerState._catalog.get(noradId)?.satelliteClass;
|
||||
if (!satelliteClass) return true;
|
||||
if (satelliteClass === 'starlink')
|
||||
return layerState._params.catalog === 'dense';
|
||||
return _classes()[satelliteClass] === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide every point by its class. The tracked satellite's point
|
||||
* 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();
|
||||
parts.labels?._syncIssOverlay?.();
|
||||
layerState._detectionObjects.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the ISS's ring shows: orbits on, the layer drawing, and its
|
||||
* class on or the ISS tracked (a tracked satellite keeps its ring).
|
||||
*/
|
||||
function _issOrbitVisible() {
|
||||
return (
|
||||
satelliteVisualsVisible(
|
||||
layerState._enabled,
|
||||
layerState._params.showOrbits,
|
||||
) &&
|
||||
(_classVisible(ISS_NORAD) || layerState._trackedNorad === ISS_NORAD)
|
||||
);
|
||||
}
|
||||
|
||||
/** Show or hide the ISS's ring by _issOrbitVisible, if it is built. */
|
||||
function _syncIssOrbit() {
|
||||
const issPath = layerState._orbitPaths.get(ISS_NORAD);
|
||||
if (issPath) issPath.primitive.show = _issOrbitVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's info line while the classes rest on old or missing SATCAT
|
||||
* evidence (`_satcatState`), and an empty one otherwise.
|
||||
* @returns {{info: string, infoTitle: string}}
|
||||
*/
|
||||
function classEvidenceNotice() {
|
||||
const evidence = layerState._satcatState;
|
||||
if (evidence !== 'stale' && evidence !== 'unavailable')
|
||||
return { info: '', infoTitle: '' };
|
||||
return {
|
||||
info:
|
||||
evidence === 'stale'
|
||||
? t('Classes may lag: SATCAT is a cached copy')
|
||||
: t('Classes may lag: SATCAT unavailable'),
|
||||
infoTitle: t(
|
||||
"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.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Tell the manager to re-render this layer's row (chip state / legend counts). */
|
||||
@@ -46,14 +126,11 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
) {
|
||||
return layerState._classTallyCache.counts;
|
||||
}
|
||||
const entries = [];
|
||||
// Pass the ISS flag, not just the group: during a stations-feed outage the
|
||||
// ISS is ingested as `visual`, and the legend must file it exactly where its
|
||||
// card does (STATION) rather than letting STATION vanish from the legend.
|
||||
for (const [noradId, sat] of layerState._catalog) {
|
||||
entries.push({ group: sat.group, isIss: noradId === ISS_NORAD });
|
||||
const counts = Object.create(null);
|
||||
for (const sat of layerState._catalog.values()) {
|
||||
const satelliteClass = sat.satelliteClass || 'probes';
|
||||
counts[satelliteClass] = (counts[satelliteClass] || 0) + 1;
|
||||
}
|
||||
const counts = tallySatelliteClasses(entries);
|
||||
layerState._classTallyCache = {
|
||||
revision: layerState._catalogRevision,
|
||||
counts,
|
||||
@@ -88,6 +165,47 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
requestedCatalog !== currentCatalog
|
||||
);
|
||||
}
|
||||
/** The STARLINK chip: the dense catalog, with its load state. */
|
||||
function _starlinkChip() {
|
||||
const loading = layerState._denseStatus === 'loading';
|
||||
const failed = layerState._denseStatus === 'failed';
|
||||
const active =
|
||||
layerState._params.catalog === 'dense' &&
|
||||
layerState._denseStatus === 'ready';
|
||||
let title = t(
|
||||
'Add the full Starlink broadband shell (thousands of extra points)',
|
||||
);
|
||||
if (loading) title = t('Loading the Starlink shell…');
|
||||
else if (failed)
|
||||
title = t('Starlink {detail} — click to retry', {
|
||||
detail: layerState._denseError || t('load failed'),
|
||||
});
|
||||
else if (active)
|
||||
title = t(
|
||||
'Showing the full Starlink shell — click for the core catalog only',
|
||||
);
|
||||
return {
|
||||
id: 'catalog',
|
||||
label: loading
|
||||
? t('STARLINK ···')
|
||||
: failed
|
||||
? t('STARLINK ✕')
|
||||
: t('STARLINK'),
|
||||
active,
|
||||
busy: loading,
|
||||
disabled: loading,
|
||||
state: loading
|
||||
? 'loading'
|
||||
: failed
|
||||
? 'error'
|
||||
: active
|
||||
? 'active'
|
||||
: 'idle',
|
||||
title,
|
||||
params: { catalog: active ? 'core' : 'dense' },
|
||||
};
|
||||
}
|
||||
|
||||
const methods = {
|
||||
id: 'satellites',
|
||||
|
||||
@@ -129,11 +247,16 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
if (!shouldTake) continue;
|
||||
if (!point.position) continue;
|
||||
const isTracked = noradId === layerState._trackedNorad;
|
||||
// A hidden class is not on screen, so nothing is bracketed on it.
|
||||
if (!isTracked && !_classVisible(noradId)) continue;
|
||||
// A docked companion sits at the tracked subject's own position, so its
|
||||
// mark and label would stack underneath the tracked card. It is listed on
|
||||
// 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) {
|
||||
@@ -141,15 +264,16 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
sourceId: noradId,
|
||||
id: cat?.name || `SAT-${noradId}`,
|
||||
type: 'SAT',
|
||||
// Human class ("NAV · GPS"), not the raw CelesTrak tag ("GPS-OPS").
|
||||
// The detection canvas composites ABOVE the post-FX chain, so this
|
||||
// is how class survives NVG/FLIR once the dot colors are collapsed.
|
||||
klass: satelliteClassLabel(cat?.group, {
|
||||
isIss: noradId === ISS_NORAD,
|
||||
}),
|
||||
};
|
||||
layerState._detectionObjects.set(noradId, object);
|
||||
}
|
||||
// Human class ("TELECOM · GPS"), not the raw CelesTrak tag
|
||||
// ("GPS-OPS"). The detection canvas composites ABOVE the post-FX
|
||||
// chain, so this is how class survives NVG/FLIR once the dot colors
|
||||
// are collapsed. Read each time: SATCAT can reclassify a satellite.
|
||||
object.klass = satelliteClassText(cat?.satelliteClass, cat?.group, {
|
||||
isIss: noradId === ISS_NORAD,
|
||||
});
|
||||
object.position = point.position;
|
||||
object.skipLabel = isTracked;
|
||||
result.push(object);
|
||||
@@ -225,6 +349,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
for (const [noradId, point] of layerState._points) {
|
||||
if (result.length >= cap) break;
|
||||
if (!point.position) continue;
|
||||
if (!_classVisible(noradId)) continue;
|
||||
// Dense extras are points-only — keep voice/framing lists to the core catalog.
|
||||
if (
|
||||
layerState._denseIds.length > 0 &&
|
||||
@@ -246,6 +371,72 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* The shown satellites nearest a position, nearest first, from per-point
|
||||
* state (no SGP4 re-propagation): a bounded pass that keeps only the
|
||||
* nearest `maxCount`, and reads latitude and longitude for those alone.
|
||||
* Dense extras are left out, as in `getAllPositions`.
|
||||
* @param {Cesium.Cartesian3} center Earth-fixed position.
|
||||
* @param {number} [maxCount=8] Most entries to return.
|
||||
* @param {{excludeIds?: Array<number|string>,
|
||||
* excludeClasses?: Array<string>}} [options] Satellites, and classes
|
||||
* (e.g. 'debris'), to leave out.
|
||||
* @returns {Array<{ id: number, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, satelliteClass: string|null, distanceM: number }>}
|
||||
*/
|
||||
getNearestPositions(
|
||||
center,
|
||||
maxCount = 8,
|
||||
{ excludeIds = [], excludeClasses = [] } = {},
|
||||
) {
|
||||
const nearest = [];
|
||||
const cap = Number.isFinite(maxCount) ? Math.floor(maxCount) : 8;
|
||||
if (
|
||||
cap < 1 ||
|
||||
!center ||
|
||||
!layerState._points ||
|
||||
layerState._points.size === 0
|
||||
)
|
||||
return nearest;
|
||||
const excluded = new Set(excludeIds.map(String));
|
||||
const classes = new Set(excludeClasses);
|
||||
const dense = layerState._denseIds.length > 0;
|
||||
for (const [noradId, point] of layerState._points) {
|
||||
const p = point.position;
|
||||
if (!p || excluded.has(String(noradId))) continue;
|
||||
if (!_classVisible(noradId)) continue;
|
||||
const sat = layerState._catalog.get(noradId);
|
||||
if (dense && sat?.group === 'dense') continue;
|
||||
if (classes.size && classes.has(sat?.satelliteClass)) continue;
|
||||
const dx = p.x - center.x;
|
||||
const dy = p.y - center.y;
|
||||
const dz = p.z - center.z;
|
||||
const distanceSq = dx * dx + dy * dy + dz * dz;
|
||||
if (nearest.length === cap && distanceSq >= nearest[cap - 1].distanceSq)
|
||||
continue;
|
||||
// Insertion into a short sorted list: no copy or sort of the rest.
|
||||
let at = nearest.length;
|
||||
while (at > 0 && nearest[at - 1].distanceSq > distanceSq) at -= 1;
|
||||
nearest.splice(at, 0, { noradId, point, sat, distanceSq });
|
||||
if (nearest.length > cap) nearest.pop();
|
||||
}
|
||||
const result = [];
|
||||
for (const { noradId, point, sat, distanceSq } of nearest) {
|
||||
const carto = Cesium.Cartographic.fromCartesian(point.position);
|
||||
if (!carto) continue;
|
||||
result.push({
|
||||
id: noradId,
|
||||
label: sat ? sat.name.trim() : String(noradId),
|
||||
position: point.position,
|
||||
latitude: Cesium.Math.toDegrees(carto.latitude),
|
||||
longitude: Cesium.Math.toDegrees(carto.longitude),
|
||||
altitudeM: carto.height,
|
||||
satelliteClass: sat?.satelliteClass ?? null,
|
||||
distanceM: Math.sqrt(distanceSq),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Track a satellite by NORAD id (camera follow + orbit path + highlight),
|
||||
* same path as clicking its point.
|
||||
@@ -383,9 +574,11 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
/**
|
||||
* Runtime params (DataLayerManager.setLayerParams path).
|
||||
* catalog: 'core' (default, ~840 sats) | 'dense' (adds the Starlink shell
|
||||
* as points-only extras on a relaxed propagation budget).
|
||||
* @param {{ catalog?: 'core'|'dense', showPoints?: boolean, showOrbits?: boolean, selectedSatTrackingId?: number|null }} [params]
|
||||
* catalog: 'core' (default, about 1,300 satellites from the CelesTrak
|
||||
* groups) | 'dense' (adds the Starlink shell as points-only extras on a
|
||||
* relaxed propagation budget). classes: which of the six classes show
|
||||
* (src/layers/satellites/classes.js); Starlink's toggle is the dense catalog.
|
||||
* @param {{ catalog?: 'core'|'dense', classes?: Record<string, boolean>, showPoints?: boolean, showOrbits?: boolean, selectedSatTrackingId?: number|null }} [params]
|
||||
*/
|
||||
setParams(params = {}, { origin = 'programmatic' } = {}) {
|
||||
if (
|
||||
@@ -394,9 +587,28 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
) {
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
}
|
||||
const catalog = params.catalog;
|
||||
let catalog = params.catalog;
|
||||
if (catalog !== undefined && catalog !== 'core' && catalog !== 'dense')
|
||||
return false;
|
||||
// A saved or shared choice arrives as class letters (layerState.js).
|
||||
const requestedClasses =
|
||||
typeof params.classes === 'string'
|
||||
? decodeSatelliteClassMask(params.classes)
|
||||
: params.classes;
|
||||
if (params.classes !== undefined) {
|
||||
if (!requestedClasses || typeof requestedClasses !== 'object')
|
||||
return false;
|
||||
// Starlink's toggle is the dense catalog: its points load on demand.
|
||||
if (
|
||||
typeof requestedClasses.starlink === 'boolean' &&
|
||||
catalog === undefined
|
||||
)
|
||||
catalog = requestedClasses.starlink ? 'dense' : 'core';
|
||||
layerState._params.classes = mergeClassVisibility(
|
||||
requestedClasses,
|
||||
_classes(),
|
||||
);
|
||||
}
|
||||
const catalogChanged = satelliteCatalogModeChanged(
|
||||
layerState._params.catalog,
|
||||
catalog,
|
||||
@@ -414,11 +626,14 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
if (params.showOrbits !== undefined) {
|
||||
layerState._params.showOrbits = params.showOrbits !== false;
|
||||
for (const path of layerState._orbitPaths.values())
|
||||
path.primitive.show = satelliteVisualsVisible(
|
||||
layerState._enabled,
|
||||
layerState._params.showOrbits,
|
||||
);
|
||||
for (const [noradId, path] of layerState._orbitPaths)
|
||||
path.primitive.show =
|
||||
noradId === ISS_NORAD
|
||||
? _issOrbitVisible()
|
||||
: satelliteVisualsVisible(
|
||||
layerState._enabled,
|
||||
layerState._params.showOrbits,
|
||||
);
|
||||
parts.labels._syncIssOverlay();
|
||||
}
|
||||
if (catalogChanged && catalog === 'dense') {
|
||||
@@ -434,6 +649,13 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
if (catalogChanged)
|
||||
console.log(`[Data:Satellites] Catalog mode: ${catalog}`);
|
||||
if (params.classes !== undefined || catalogChanged) {
|
||||
layerState._params.classes = {
|
||||
..._classes(),
|
||||
starlink: layerState._params.catalog === 'dense',
|
||||
};
|
||||
_applyClassVisibility();
|
||||
}
|
||||
if (Object.hasOwn(params, 'selectedSatTrackingId')) {
|
||||
const requested = parts.tracking._normalizeTrackedNorad(
|
||||
params.selectedSatTrackingId,
|
||||
@@ -465,25 +687,29 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
catalog: layerState._params.catalog,
|
||||
showPoints: layerState._params.showPoints,
|
||||
showOrbits: layerState._params.showOrbits,
|
||||
classes: { ..._classes() },
|
||||
selectedSatTrackingId: layerState._trackedNorad,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Layer-row sub-controls (DataLayerManager row-controls contract): the DENSE
|
||||
* catalog chip plus a class legend so the point colors are learnable without
|
||||
* a new panel.
|
||||
* Layer-row sub-controls (DataLayerManager row-controls contract): one
|
||||
* toggle chip per satellite class (src/layers/satellites/classes.js), and a legend
|
||||
* of the classes on screen so the point colours are learnable.
|
||||
*
|
||||
* The chip is stateless — it declares the params to apply and the manager
|
||||
* Chips are stateless — each declares the params to apply and the manager
|
||||
* owns the write, so the Space Missions snapshot/restore path (which drives
|
||||
* the same `catalog` param) stays the single source of truth and the chip
|
||||
* the same `catalog` param) stays the single source of truth and a chip
|
||||
* always renders whatever the layer actually has.
|
||||
*
|
||||
* The chip reports the dense LOAD state, not the catalog param: the param
|
||||
* flips synchronously while the Starlink shell takes seconds to arrive and
|
||||
* frequently 502s, so ACTIVE means "dense points are on screen" and nothing
|
||||
* less. The legend tally is cached against the catalog revision.
|
||||
* @returns {{ chips: Array<object>, legend: Array<object> }} Row controls.
|
||||
* STARLINK is the dense catalog (chip id `catalog`) and reports the dense
|
||||
* LOAD state, not the param: the param flips synchronously while the shell
|
||||
* takes seconds to arrive and frequently 502s, so ACTIVE means "Starlink
|
||||
* points are on screen" and nothing less. The legend tally is cached
|
||||
* against the catalog revision. While SATCAT is stale or unavailable the
|
||||
* info line says the classes may lag (see `classEvidenceNotice`).
|
||||
* @returns {{ chips: Array<object>, legend: Array<object>, info: string,
|
||||
* infoTitle: string }} Row controls.
|
||||
*/
|
||||
getRowControls() {
|
||||
// A dependency owner (Space Missions) borrows this layer for TLE lookup
|
||||
@@ -492,48 +718,36 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
// restore. Surrender the row rather than lie about it.
|
||||
if (!layerState._params.showPoints) return { chips: [], legend: [] };
|
||||
|
||||
const loading = layerState._denseStatus === 'loading';
|
||||
const failed = layerState._denseStatus === 'failed';
|
||||
const active =
|
||||
layerState._params.catalog === 'dense' &&
|
||||
layerState._denseStatus === 'ready';
|
||||
let title = t(
|
||||
'Add the full Starlink broadband shell (thousands of extra points)',
|
||||
const classes = _classes();
|
||||
const chips = SATELLITE_CLASS_IDS.map((id) =>
|
||||
id === 'starlink'
|
||||
? _starlinkChip()
|
||||
: {
|
||||
id: `class-${id}`,
|
||||
label: t(SATELLITE_CLASS_DISPLAY[id].label),
|
||||
active: classes[id] === true,
|
||||
state: classes[id] === true ? 'active' : 'idle',
|
||||
title: t(SATELLITE_CLASS_DISPLAY[id].blurb),
|
||||
params: { classes: { [id]: classes[id] !== true } },
|
||||
},
|
||||
);
|
||||
if (loading) title = t('Loading the Starlink shell…');
|
||||
else if (failed)
|
||||
title = t('Starlink {detail} — click to retry', {
|
||||
detail: layerState._denseError || t('load failed'),
|
||||
const counts = _classTally();
|
||||
const legend = [];
|
||||
for (const id of SATELLITE_CLASS_IDS) {
|
||||
const shown =
|
||||
id === 'starlink'
|
||||
? layerState._params.catalog === 'dense'
|
||||
: classes[id] === true;
|
||||
if (!shown || !(counts[id] > 0)) continue;
|
||||
legend.push({
|
||||
klass: id,
|
||||
label: t(SATELLITE_CLASS_DISPLAY[id].label),
|
||||
color: SATELLITE_CLASS_DISPLAY[id].color,
|
||||
blurb: t(SATELLITE_CLASS_DISPLAY[id].blurb),
|
||||
count: counts[id],
|
||||
});
|
||||
else if (active)
|
||||
title = t(
|
||||
'Showing the full Starlink shell — click for the core catalog only',
|
||||
);
|
||||
return {
|
||||
chips: [
|
||||
{
|
||||
id: 'catalog',
|
||||
label: loading
|
||||
? t('DENSE ···')
|
||||
: failed
|
||||
? t('DENSE ✕')
|
||||
: t('DENSE'),
|
||||
active,
|
||||
busy: loading,
|
||||
disabled: loading,
|
||||
state: loading
|
||||
? 'loading'
|
||||
: failed
|
||||
? 'error'
|
||||
: active
|
||||
? 'active'
|
||||
: 'idle',
|
||||
title,
|
||||
params: { catalog: active ? 'core' : 'dense' },
|
||||
},
|
||||
],
|
||||
legend: satelliteClassLegend(_classTally()),
|
||||
};
|
||||
}
|
||||
return { chips, legend, ...classEvidenceNotice() };
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -562,6 +776,9 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
? 'stale'
|
||||
: 'nominal',
|
||||
error: layerState._lastError,
|
||||
// SATCAT refines the classes; when it is stale or unavailable the
|
||||
// classes rest on UCS, names and groups alone.
|
||||
classEvidence: layerState._satcatState,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -570,6 +787,10 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
_pointStyleFor,
|
||||
_notifyRowControls,
|
||||
_classTally,
|
||||
_classVisible,
|
||||
_applyClassVisibility,
|
||||
_issOrbitVisible,
|
||||
_syncIssOrbit,
|
||||
satelliteVisualsVisible,
|
||||
satelliteCatalogModeChanged,
|
||||
methods,
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -3,6 +3,22 @@ import * as Cesium from 'cesium';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { noradIdFromSatnum, tleLineLaunchYear } from '../../sources/tle.js';
|
||||
import { CATALOG_GROUPS, ISS_NORAD, POINT_STYLES } from './policy.js';
|
||||
import { classifySatellite, parseSatcatCsv } from './classes.js';
|
||||
import { matchImagingSensor } from './sensors.js';
|
||||
|
||||
/**
|
||||
* How long a catalog rebuild waits for SATCAT before it classifies without
|
||||
* it, ms. SATCAT can hang for the proxy's whole upstream timeout (20 s); the
|
||||
* late answer reclassifies in place.
|
||||
*/
|
||||
export const SATCAT_WAIT_MS = 3000;
|
||||
|
||||
/**
|
||||
* How long a complete, current SATCAT read is kept before the layer asks
|
||||
* again, ms. SATCAT changes daily and the proxy caches it a day, so the
|
||||
* five-minute TLE update does not read all eleven groups each time.
|
||||
*/
|
||||
export const SATCAT_KEEP_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
export function createIngestion({
|
||||
state: layerState,
|
||||
@@ -10,6 +26,161 @@ export function createIngestion({
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
/**
|
||||
* The local packs the classes and zoom steps are read from: the UCS pack
|
||||
* and the imaging-sensor pack. Neither is needed to render: a failed read
|
||||
* leaves the last pack (or none) and classification falls back to names
|
||||
* and groups.
|
||||
*/
|
||||
async function _readLocalEvidence() {
|
||||
const ucs = layerState._ucs
|
||||
? Promise.resolve(layerState._ucs)
|
||||
: Promise.resolve()
|
||||
.then(() => source.readUcs?.())
|
||||
.then((pack) => (pack instanceof Map ? pack : null))
|
||||
.catch((error) => {
|
||||
console.warn('[Data:Satellites] UCS pack unavailable:', error);
|
||||
return null;
|
||||
});
|
||||
// The sensor pack only adds zoom steps in the orbital view; without it
|
||||
// every satellite keeps the base steps.
|
||||
const sensors = layerState._sensors
|
||||
? Promise.resolve(layerState._sensors)
|
||||
: Promise.resolve()
|
||||
.then(() => source.readSensors?.())
|
||||
.then((index) => (index?.byNorad instanceof Map ? index : null))
|
||||
.catch((error) => {
|
||||
console.warn(
|
||||
'[Data:Satellites] Imaging sensor pack unavailable:',
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
});
|
||||
const [pack, sensorIndex] = await Promise.all([ucs, sensors]);
|
||||
if (pack) layerState._ucs = pack;
|
||||
if (sensorIndex) layerState._sensors = sensorIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* CelesTrak SATCAT for every core group: type and status, which refine the
|
||||
* classes. A group whose read failed gives null.
|
||||
* @returns {Promise<Array<{records: Map, stale: boolean}|null>>}
|
||||
*/
|
||||
function _readSatcat(signal) {
|
||||
if (typeof source.readSatcat !== 'function') return Promise.resolve([]);
|
||||
// A complete, current read is kept; a stale or failed one is asked again.
|
||||
if (
|
||||
layerState._satcatState === 'current' &&
|
||||
Date.now() - layerState._satcatReadAtMs < SATCAT_KEEP_MS
|
||||
)
|
||||
return Promise.resolve([]);
|
||||
return Promise.all(
|
||||
CATALOG_GROUPS.map((groupDef) =>
|
||||
source
|
||||
.readSatcat(groupDef.path, { signal })
|
||||
.then((res) =>
|
||||
res?.ok
|
||||
? { records: parseSatcatCsv(res.text), stale: Boolean(res.stale) }
|
||||
: null,
|
||||
)
|
||||
.catch((error) => {
|
||||
if (signal.aborted || error?.name === 'AbortError') throw error;
|
||||
return null;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge SATCAT reads and say how current they are: 'current', 'stale' (a
|
||||
* group came from the proxy's last copy) or 'unavailable' (a group failed;
|
||||
* its earlier records, if any, stay).
|
||||
*/
|
||||
function _mergeSatcat(groups) {
|
||||
let satcatState = 'current';
|
||||
for (const group of groups) {
|
||||
if (!group) {
|
||||
satcatState = 'unavailable';
|
||||
continue;
|
||||
}
|
||||
if (group.stale && satcatState === 'current') satcatState = 'stale';
|
||||
for (const [noradId, record] of group.records) {
|
||||
// A group served from the proxy's last copy never overrules a
|
||||
// current read of the same object, from this merge or an earlier one.
|
||||
const held = layerState._satcat.get(noradId);
|
||||
if (group.stale && held && !held.stale) continue;
|
||||
layerState._satcat.set(
|
||||
noradId,
|
||||
group.stale ? { ...record, stale: true } : record,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!groups.length) return;
|
||||
const changed = layerState._satcatState !== satcatState;
|
||||
layerState._satcatState = satcatState;
|
||||
layerState._satcatReadAtMs = Date.now();
|
||||
if (satcatState !== 'current')
|
||||
console.warn(`[Data:Satellites] SATCAT ${satcatState}: classes may lag`);
|
||||
// The row's info line says so (getRowControls).
|
||||
if (changed) parts.controls._notifyRowControls();
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the catalog again once SATCAT lands after it was built: only
|
||||
* entries whose SATCAT record changed are read again; a changed class
|
||||
* restyles its point and refreshes visibility, the legend and the overlay.
|
||||
*/
|
||||
function _reclassifyFromSatcat() {
|
||||
let changed = false;
|
||||
for (const [noradId, sat] of layerState._catalog) {
|
||||
if (sat.group === 'dense') continue;
|
||||
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,
|
||||
group: sat.group,
|
||||
groups: sat.groups,
|
||||
ucs: sat.ucs,
|
||||
satcat,
|
||||
launchYear: sat.launchYear,
|
||||
});
|
||||
if (next === sat.satelliteClass) continue;
|
||||
sat.satelliteClass = next;
|
||||
changed = true;
|
||||
const point = layerState._points.get(noradId);
|
||||
if (point) {
|
||||
const style = parts.controls._pointStyleFor(noradId, sat.group);
|
||||
point.pixelSize = style.pixelSize;
|
||||
point.color = style.color;
|
||||
point.outlineColor = style.outlineColor;
|
||||
point.outlineWidth = style.outlineWidth;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
parts.controls._applyClassVisibility();
|
||||
layerState._catalogRevision++;
|
||||
parts.controls._notifyRowControls();
|
||||
}
|
||||
|
||||
/** Resolve null after `ms`, or at once when `signal` aborts. */
|
||||
function _waitOrAbort(ms, signal) {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, ms, null);
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
resolve(null);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const methods = {
|
||||
async update(viewer, { signal = null } = {}) {
|
||||
const trackingRefreshEpoch = ++layerState._trackingRefreshEpoch;
|
||||
@@ -27,6 +198,10 @@ export function createIngestion({
|
||||
updateSignal.throwIfAborted();
|
||||
// Load all core groups in parallel; a failed/empty group degrades
|
||||
// gracefully (parseTLE of an upstream error body yields []).
|
||||
const localEvidence = _readLocalEvidence();
|
||||
const satcat = _readSatcat(updateSignal);
|
||||
// An abort can reject this before the groups settle and it is awaited.
|
||||
satcat.catch(() => {});
|
||||
const results = await Promise.all(
|
||||
CATALOG_GROUPS.map(async (groupDef) => {
|
||||
try {
|
||||
@@ -49,7 +224,23 @@ export function createIngestion({
|
||||
}
|
||||
}),
|
||||
);
|
||||
await localEvidence;
|
||||
// SATCAT only refines classes: wait a moment for it, then classify
|
||||
// with what is held and let a late answer reclassify in place.
|
||||
const satcatInTime = await Promise.race([
|
||||
satcat.then((groups) => ({ groups })),
|
||||
_waitOrAbort(SATCAT_WAIT_MS, updateSignal),
|
||||
]).catch(() => null);
|
||||
updateSignal.throwIfAborted();
|
||||
if (satcatInTime) _mergeSatcat(satcatInTime.groups);
|
||||
else
|
||||
satcat
|
||||
.then((groups) => {
|
||||
if (updateSignal.aborted || !layerState._enabled) return;
|
||||
_mergeSatcat(groups);
|
||||
_reclassifyFromSatcat();
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
const failed = results.filter((r) => !r.ok).map((r) => r.path);
|
||||
if (failed.length > 0) {
|
||||
@@ -62,7 +253,7 @@ export function createIngestion({
|
||||
);
|
||||
|
||||
// CelesTrak outage guard (H3): if EVERY group failed, bail BEFORE clearing.
|
||||
// Wiping the collection + catalog here would blank all 838 satellites while
|
||||
// Wiping the collection + catalog here would blank every satellite while
|
||||
// the chip still read "just now". Keep the existing (stale) catalog on
|
||||
// screen and surface the outage instead — do NOT stamp _lastUpdate.
|
||||
if (results.every((r) => !r.ok)) {
|
||||
@@ -112,6 +303,18 @@ export function createIngestion({
|
||||
allEntries.push({ ...e, group: r.tag, stale: r.stale });
|
||||
}
|
||||
|
||||
// Every group each TLE came in, keyed by its catalog-number column:
|
||||
// the shown tag is the first, but classification reads them all.
|
||||
const groupsByNumber = new Map();
|
||||
for (const entry of allEntries) {
|
||||
const number = String(entry.line1 || '')
|
||||
.slice(2, 7)
|
||||
.trim();
|
||||
const groups = groupsByNumber.get(number);
|
||||
if (!groups) groupsByNumber.set(number, [entry.group]);
|
||||
else if (!groups.includes(entry.group)) groups.push(entry.group);
|
||||
}
|
||||
|
||||
// Deduplicate by NORAD ID — first (most specific) group tag wins
|
||||
const seen = new Set();
|
||||
let coreStale = false;
|
||||
@@ -125,12 +328,38 @@ export function createIngestion({
|
||||
seen.add(noradId);
|
||||
|
||||
// Store in catalog
|
||||
const launchYear = tleLineLaunchYear(entry.line1);
|
||||
const ucs = layerState._ucs?.get(noradId) || null;
|
||||
const satcat = layerState._satcat.get(noradId) || null;
|
||||
const groups = groupsByNumber.get(
|
||||
String(entry.line1 || '')
|
||||
.slice(2, 7)
|
||||
.trim(),
|
||||
) || [entry.group];
|
||||
layerState._catalog.set(noradId, {
|
||||
name: entry.name,
|
||||
satrec,
|
||||
group: entry.group,
|
||||
groups,
|
||||
// satellite.js does not keep the designator the year match reads.
|
||||
launchYear: tleLineLaunchYear(entry.line1),
|
||||
launchYear,
|
||||
line1: entry.line1,
|
||||
line2: entry.line2,
|
||||
satelliteClass: classifySatellite({
|
||||
noradId,
|
||||
name: entry.name,
|
||||
group: entry.group,
|
||||
groups,
|
||||
ucs,
|
||||
satcat,
|
||||
launchYear,
|
||||
}),
|
||||
ucs,
|
||||
satcat,
|
||||
sensor: matchImagingSensor(layerState._sensors, {
|
||||
noradId,
|
||||
name: entry.name,
|
||||
}),
|
||||
});
|
||||
|
||||
coreStale ||= entry.stale;
|
||||
@@ -156,6 +385,8 @@ export function createIngestion({
|
||||
scaleByDistance: new Cesium.NearFarScalar(1e6, 1.5, 2e7, 0.6),
|
||||
id: noradId,
|
||||
});
|
||||
// Hidden classes keep their point, so a toggle needs no rebuild.
|
||||
point.show = parts.controls._classVisible(noradId);
|
||||
|
||||
layerState._points.set(noradId, point);
|
||||
}
|
||||
@@ -163,8 +394,7 @@ export function createIngestion({
|
||||
// Show ISS orbital path by default
|
||||
if (layerState._catalog.has(ISS_NORAD)) {
|
||||
parts.rendering._showOrbitPath(ISS_NORAD, POINT_STYLES.iss.color);
|
||||
const issPath = layerState._orbitPaths.get(ISS_NORAD);
|
||||
if (issPath) issPath.primitive.show = layerState._params.showOrbits;
|
||||
parts.controls._syncIssOrbit();
|
||||
|
||||
parts.labels._syncIssOverlay();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseTleText, tleLineLaunchYear } from '../../sources/tle.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import {
|
||||
SATCAT_KEEP_MS,
|
||||
SATCAT_WAIT_MS,
|
||||
createIngestion,
|
||||
} from './ingestion.js';
|
||||
import { createOrbits } from './orbits.js';
|
||||
import { createControls } from './controls.js';
|
||||
|
||||
// The ISS set (1 January 2024) with its catalog field swapped per satellite.
|
||||
const ISS_LINE1 =
|
||||
@@ -18,7 +23,7 @@ function tleSet(name, field) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function ingestionFor(catalogText) {
|
||||
function ingestionFor(catalogText, { source: sourceExtras = {} } = {}) {
|
||||
const state = {
|
||||
_trackingRefreshEpoch: 0,
|
||||
_activeUpdateControllers: new Set(),
|
||||
@@ -33,6 +38,8 @@ function ingestionFor(catalogText) {
|
||||
_denseLoadToken: 0,
|
||||
_catalogRevision: 0,
|
||||
_params: { catalog: 'core', showOrbits: false },
|
||||
_satcat: new Map(),
|
||||
_ucs: null,
|
||||
now: Date.now,
|
||||
coverageChanged() {},
|
||||
};
|
||||
@@ -42,7 +49,17 @@ function ingestionFor(catalogText) {
|
||||
propagatePosition: () => ({ longitude: 0, latitude: 0, altitude: 4e5 }),
|
||||
},
|
||||
labels: { _syncIssOverlay() {} },
|
||||
controls: { _pointStyleFor: () => ({}) },
|
||||
controls: {
|
||||
_pointStyleFor: () => ({}),
|
||||
_classVisible: () => true,
|
||||
_syncIssOrbit() {},
|
||||
_applyClassVisibility() {
|
||||
state.visibilityApplied = (state.visibilityApplied || 0) + 1;
|
||||
},
|
||||
_notifyRowControls() {
|
||||
state.rowNotified = (state.rowNotified || 0) + 1;
|
||||
},
|
||||
},
|
||||
rendering: { _showOrbitPath() {} },
|
||||
tracking: {
|
||||
async _reconcileTrackedSubjectContext() {},
|
||||
@@ -51,9 +68,12 @@ function ingestionFor(catalogText) {
|
||||
catalog: {},
|
||||
};
|
||||
const source = {
|
||||
async readGroup() {
|
||||
return { ok: true, text: catalogText };
|
||||
async readGroup(path) {
|
||||
const text =
|
||||
typeof catalogText === 'function' ? catalogText(path) : catalogText;
|
||||
return { ok: true, text };
|
||||
},
|
||||
...sourceExtras,
|
||||
};
|
||||
const { methods } = createIngestion({ state, services: {}, parts, source });
|
||||
return { state, update: () => methods.update({ scene: {} }) };
|
||||
@@ -121,3 +141,175 @@ test('a satellite with a blank designator keeps matching by name', async (t) =>
|
||||
69728,
|
||||
);
|
||||
});
|
||||
|
||||
test('a hanging SATCAT read neither holds the catalog nor is lost when it lands', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let answer;
|
||||
const satcatAnswer = new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
});
|
||||
const { state, update } = ingestionFor(
|
||||
(path) => (path === 'stations' ? tleSet('OLD STAGE', '40115') : ''),
|
||||
{ source: { readSatcat: () => satcatAnswer } },
|
||||
);
|
||||
state._enabled = true;
|
||||
const settle = async () => {
|
||||
for (let turn = 0; turn < 20; turn += 1)
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
};
|
||||
const rebuilt = update();
|
||||
await settle();
|
||||
t.mock.timers.tick(SATCAT_WAIT_MS);
|
||||
await rebuilt;
|
||||
// Built without SATCAT: neither name nor group says what it is (the
|
||||
// stations group alone makes no station).
|
||||
assert.equal(state._catalog.get(40115).satelliteClass, 'probes');
|
||||
const revision = state._catalogRevision;
|
||||
// SATCAT lands late: a rocket body.
|
||||
answer({
|
||||
ok: true,
|
||||
stale: true,
|
||||
text: 'OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE,OWNER\nOLD STAGE,2014-048B,40115,R/B,,US',
|
||||
});
|
||||
await settle();
|
||||
assert.equal(state._catalog.get(40115).satelliteClass, 'debris');
|
||||
assert.equal(state._catalogRevision, revision + 1, 'the legend refreshes');
|
||||
assert.ok(state.visibilityApplied >= 1 && state.rowNotified >= 1);
|
||||
assert.equal(state._satcatState, 'stale', 'its staleness is kept');
|
||||
});
|
||||
|
||||
test('a satellite in visual and resource is imaging, whichever group shows it', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// A name no pattern knows, no UCS record and no SATCAT: only its groups.
|
||||
const { state, update } = ingestionFor((path) =>
|
||||
path === 'visual' || path === 'resource'
|
||||
? tleSet('ZZ TESTSAT', '50001')
|
||||
: '',
|
||||
);
|
||||
await update();
|
||||
const sat = state._catalog.get(50001);
|
||||
assert.equal(sat.group, 'visual', 'shown under the first group');
|
||||
assert.deepEqual(sat.groups, ['visual', 'resource']);
|
||||
assert.equal(sat.satelliteClass, 'imaging');
|
||||
});
|
||||
|
||||
test('a stale SATCAT copy never overrules a current read of the same object', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const header =
|
||||
'OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE,OWNER\n';
|
||||
const { state, update } = ingestionFor(tleSet('ZZ TESTSAT', '50002'), {
|
||||
source: {
|
||||
// visual is current and says it died; resource, a cached copy, still
|
||||
// says it works.
|
||||
readSatcat: async (path) =>
|
||||
path === 'visual'
|
||||
? { ok: true, text: `${header}ZZ TESTSAT,2015-001A,50002,PAY,-,US` }
|
||||
: path === 'resource'
|
||||
? {
|
||||
ok: true,
|
||||
stale: true,
|
||||
text: `${header}ZZ TESTSAT,2015-001A,50002,PAY,+,US`,
|
||||
}
|
||||
: { ok: true, text: header },
|
||||
},
|
||||
});
|
||||
await update();
|
||||
assert.equal(state._catalog.get(50002).satelliteClass, 'debris');
|
||||
});
|
||||
|
||||
test('a failed SATCAT read is told on the layer row, not only the console', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const { state, update } = ingestionFor(tleSet('OLD STAGE', '40115'), {
|
||||
source: { readSatcat: async () => ({ ok: false, text: '' }) },
|
||||
});
|
||||
await update();
|
||||
assert.equal(state._satcatState, 'unavailable');
|
||||
assert.ok(state.rowNotified >= 1, 'the row repaints');
|
||||
const controls = createControls({
|
||||
state: {
|
||||
...state,
|
||||
_params: { ...state._params, showPoints: true },
|
||||
_classTallyCache: {},
|
||||
},
|
||||
services: { layerState: { isExplicitLayerStateOrigin: () => false } },
|
||||
parts: {},
|
||||
source: {},
|
||||
});
|
||||
const row = controls.methods.getRowControls();
|
||||
assert.equal(row.info, 'Classes may lag: SATCAT unavailable');
|
||||
assert.match(row.infoTitle, /SATCAT/);
|
||||
const current = createControls({
|
||||
state: {
|
||||
...state,
|
||||
_satcatState: 'current',
|
||||
_params: { ...state._params, showPoints: true },
|
||||
_classTallyCache: {},
|
||||
},
|
||||
services: { layerState: { isExplicitLayerStateOrigin: () => false } },
|
||||
parts: {},
|
||||
source: {},
|
||||
});
|
||||
assert.equal(current.methods.getRowControls().info, '');
|
||||
});
|
||||
|
||||
test('a current SATCAT read is kept between updates; a failed one is asked again', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const header =
|
||||
'OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE\n';
|
||||
let reads = 0;
|
||||
let ok = true;
|
||||
const { state, update } = ingestionFor(tleSet('ZZ TESTSAT', '50003'), {
|
||||
source: {
|
||||
readSatcat: async () => {
|
||||
reads++;
|
||||
return ok
|
||||
? { ok: true, text: `${header}ZZ TESTSAT,2015-001A,50003,PAY,-` }
|
||||
: { ok: false, text: '' };
|
||||
},
|
||||
},
|
||||
});
|
||||
await update();
|
||||
const perUpdate = reads;
|
||||
assert.ok(perUpdate > 0);
|
||||
await update();
|
||||
assert.equal(reads, perUpdate, 'a fresh read is not asked again');
|
||||
assert.equal(
|
||||
state._catalog.get(50003).satelliteClass,
|
||||
'debris',
|
||||
'the held records still class the rebuilt catalog',
|
||||
);
|
||||
|
||||
state._satcatReadAtMs -= SATCAT_KEEP_MS;
|
||||
ok = false;
|
||||
await update();
|
||||
assert.equal(reads, 2 * perUpdate, 'an old read is asked again');
|
||||
assert.equal(state._satcatState, 'unavailable');
|
||||
await update();
|
||||
assert.equal(reads, 3 * perUpdate, 'a failed read is asked again');
|
||||
});
|
||||
|
||||
test('a failed imaging-sensor pack is warned about, as the UCS pack is', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
const warn = t.mock.method(console, 'warn', () => {});
|
||||
const { state, update } = ingestionFor(tleSet('OLD STAGE', '40115'), {
|
||||
source: {
|
||||
readSensors: async () => {
|
||||
throw new Error('chunk failed');
|
||||
},
|
||||
},
|
||||
});
|
||||
await update();
|
||||
assert.ok(
|
||||
warn.mock.calls.some((call) =>
|
||||
/sensor pack unavailable/i.test(call.arguments[0]),
|
||||
),
|
||||
'the failure is not swallowed',
|
||||
);
|
||||
assert.equal(state._catalog.get(40115).sensor, null, 'base zoom steps');
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -83,6 +83,8 @@ export function createLabels({ state: layerState, services, parts, source }) {
|
||||
function _dockedCompanionNames() {
|
||||
const names = [];
|
||||
for (const noradId of layerState._dockedCompanions) {
|
||||
// A hidden class is not named on the tracked card either.
|
||||
if (parts.controls?._classVisible?.(noradId) === false) continue;
|
||||
names.push(
|
||||
layerState._catalog.get(noradId)?.name?.trim() || `SAT-${noradId}`,
|
||||
);
|
||||
@@ -102,6 +104,7 @@ export function createLabels({ state: layerState, services, parts, source }) {
|
||||
layerState._trackedNorad !== ISS_NORAD &&
|
||||
!layerState._dockedCompanions.has(ISS_NORAD) &&
|
||||
layerState._catalog.has(ISS_NORAD) &&
|
||||
parts.controls?._classVisible?.(ISS_NORAD) !== false &&
|
||||
_issDisplayCached();
|
||||
if (!visible) {
|
||||
layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { ISS_OVERLAY_SOURCE_ID, SATELLITE_COVERAGE_MS } from './policy.js';
|
||||
import {
|
||||
ISS_NORAD,
|
||||
ISS_OVERLAY_SOURCE_ID,
|
||||
SATELLITE_COVERAGE_MS,
|
||||
} from './policy.js';
|
||||
import { worldClock } from '../../time/worldClock.js';
|
||||
|
||||
export function createLifecycle({
|
||||
@@ -47,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;
|
||||
@@ -88,11 +96,14 @@ export function createLifecycle({
|
||||
layerState._params.showPoints,
|
||||
);
|
||||
// Orbit ring primitives + persistent ISS host label — show them
|
||||
for (const path of layerState._orbitPaths.values())
|
||||
path.primitive.show = parts.controls.satelliteVisualsVisible(
|
||||
layerState._enabled,
|
||||
layerState._params.showOrbits,
|
||||
);
|
||||
for (const [noradId, path] of layerState._orbitPaths)
|
||||
path.primitive.show =
|
||||
noradId === ISS_NORAD
|
||||
? parts.controls._issOrbitVisible()
|
||||
: parts.controls.satelliteVisualsVisible(
|
||||
layerState._enabled,
|
||||
layerState._params.showOrbits,
|
||||
);
|
||||
parts.labels._syncIssOverlay();
|
||||
// Re-attach input handlers and preRender propagation
|
||||
parts.interaction._installClickHandler(viewer);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// src/layers/satellites/nearestPositions.test.mjs — the satellites nearest a
|
||||
// position: bounded to the nearest few, nearest first, hidden classes,
|
||||
// dense extras and asked-for exclusions left out.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createControls } from './controls.js';
|
||||
|
||||
function controlsWith(satellites, { classes, dense = [] } = {}) {
|
||||
const state = {
|
||||
_points: new Map(),
|
||||
_catalog: new Map(),
|
||||
_denseIds: dense,
|
||||
_params: classes ? { classes } : {},
|
||||
};
|
||||
for (const sat of satellites) {
|
||||
state._points.set(sat.id, {
|
||||
position: Cesium.Cartesian3.fromDegrees(sat.lon, sat.lat, sat.alt),
|
||||
});
|
||||
state._catalog.set(sat.id, {
|
||||
name: `${sat.name} `,
|
||||
satelliteClass: sat.cls,
|
||||
group: sat.group,
|
||||
});
|
||||
}
|
||||
return createControls({
|
||||
state,
|
||||
services: { layerState: { isExplicitLayerStateOrigin: () => false } },
|
||||
parts: {},
|
||||
}).methods;
|
||||
}
|
||||
|
||||
const SATELLITES = [
|
||||
{ id: 1, name: 'NEAR', lat: 30.5, lon: -97, alt: 500_000, cls: 'imaging' },
|
||||
{ id: 2, name: 'MID', lat: 33, lon: -97, alt: 500_000, cls: 'stations' },
|
||||
{ id: 3, name: 'FAR', lat: -40, lon: 80, alt: 500_000, cls: 'imaging' },
|
||||
{ id: 4, name: 'DEB', lat: 30.1, lon: -97, alt: 500_000, cls: 'debris' },
|
||||
{ id: 5, name: 'TELECOM', lat: 30.2, lon: -97, alt: 500_000, cls: 'telecom' },
|
||||
{ id: 6, name: 'SELF', lat: 30, lon: -97, alt: 420_000, cls: 'stations' },
|
||||
{ id: 7, name: 'EXTRA', lat: 30.3, lon: -97, alt: 500_000, cls: 'imaging', group: 'dense' },
|
||||
];
|
||||
|
||||
const CENTRE = Cesium.Cartesian3.fromDegrees(-97, 30, 420_000);
|
||||
|
||||
test('the nearest shown satellites come first, bounded, with their distance', () => {
|
||||
const methods = controlsWith(SATELLITES, {
|
||||
classes: { stations: true, imaging: true, debris: true, telecom: false },
|
||||
dense: [7],
|
||||
});
|
||||
const nearest = methods.getNearestPositions(CENTRE, 3, {
|
||||
excludeIds: [6],
|
||||
excludeClasses: ['debris'],
|
||||
});
|
||||
assert.deepEqual(
|
||||
nearest.map((entry) => entry.label),
|
||||
['NEAR', 'MID', 'FAR'],
|
||||
'no self, debris, hidden telecom or dense extra',
|
||||
);
|
||||
for (let i = 1; i < nearest.length; i += 1)
|
||||
assert.ok(nearest[i].distanceM >= nearest[i - 1].distanceM);
|
||||
const exact = Cesium.Cartesian3.distance(CENTRE, nearest[0].position);
|
||||
assert.ok(Math.abs(nearest[0].distanceM - exact) < 1e-6);
|
||||
assert.ok(Math.abs(nearest[0].latitude - 30.5) < 1e-6);
|
||||
assert.equal(nearest[0].satelliteClass, 'imaging');
|
||||
assert.equal(methods.getNearestPositions(CENTRE, 1, { excludeIds: [6] })[0].label, 'DEB');
|
||||
assert.deepEqual(methods.getNearestPositions(null, 3), []);
|
||||
assert.deepEqual(methods.getNearestPositions(CENTRE, 0), []);
|
||||
assert.deepEqual(methods.getNearestPositions(CENTRE, 0.5), []);
|
||||
assert.equal(methods.getNearestPositions(CENTRE, 2.5).length, 2);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { satelliteClassColor } from '../../data/satelliteClass.js';
|
||||
import { SATELLITE_CLASS_DISPLAY, SATELLITE_CLASS_IDS } from './classes.js';
|
||||
|
||||
/**
|
||||
* Satellite Orbits — Real-time positions via CelesTrak TLE + SGP4 propagation.
|
||||
*
|
||||
* Loads six CelesTrak groups (~840 sats): stations, visual, GPS, GLONASS,
|
||||
* Galileo, and the geosynchronous belt. Optional dense mode (setParams
|
||||
* catalog:'dense') adds the Starlink shell as points-only extras.
|
||||
* Loads eleven CelesTrak groups: stations, visual, GPS, GLONASS, Galileo, the
|
||||
* geosynchronous belt, and the Earth resources, weather, Planet, SAR and
|
||||
* science groups. Each object is filed under one of six classes
|
||||
* (src/layers/satellites/classes.js) whose visibility the layer row toggles; dense mode
|
||||
* (setParams catalog:'dense', the STARLINK toggle) adds the Starlink shell as
|
||||
* points-only extras.
|
||||
* Renders positions via PointPrimitiveCollection, orbital paths as polylines.
|
||||
* Click any satellite to track it with camera follow + orbital path.
|
||||
*
|
||||
@@ -58,6 +62,13 @@ export const CATALOG_GROUPS = [
|
||||
{ tag: 'glonass', path: 'glo-ops' },
|
||||
{ tag: 'galileo', path: 'galileo' },
|
||||
{ tag: 'geo', path: 'geo' },
|
||||
// Earth observation launched after the UCS snapshot, and science probes.
|
||||
// Each object's class comes from src/layers/satellites/classes.js, not its group.
|
||||
{ tag: 'resource', path: 'resource' },
|
||||
{ tag: 'weather', path: 'weather' },
|
||||
{ tag: 'planet', path: 'planet' },
|
||||
{ tag: 'sar', path: 'sar' },
|
||||
{ tag: 'science', path: 'science' },
|
||||
];
|
||||
|
||||
// Dense-catalog mode (setParams({ catalog: 'dense' })): Starlink shell as
|
||||
@@ -166,6 +177,37 @@ export const POINT_STYLES = {
|
||||
},
|
||||
};
|
||||
|
||||
const CLASS_COLORS = Object.fromEntries(
|
||||
SATELLITE_CLASS_IDS.map((id) => [
|
||||
id,
|
||||
Cesium.Color.fromCssColorString(SATELLITE_CLASS_DISPLAY[id].color),
|
||||
]),
|
||||
);
|
||||
const _classStyles = new Map();
|
||||
|
||||
/**
|
||||
* A satellite's point style: its group's size and outline, in its class's
|
||||
* colour (src/layers/satellites/classes.js), so the dots match the class legend and
|
||||
* toggles. Styles are shared per group and class.
|
||||
* @param {string|undefined} group Catalog group tag.
|
||||
* @param {string|undefined} satelliteClass Class id.
|
||||
*/
|
||||
export function classPointStyle(group, satelliteClass) {
|
||||
const base = POINT_STYLES[group] || POINT_STYLES.visual;
|
||||
const color = CLASS_COLORS[satelliteClass];
|
||||
if (!color) return base;
|
||||
const key = `${group}|${satelliteClass}`;
|
||||
let style = _classStyles.get(key);
|
||||
if (!style) {
|
||||
style = Object.freeze({
|
||||
...base,
|
||||
color: group === 'dense' ? color.withAlpha(0.9) : color,
|
||||
});
|
||||
_classStyles.set(key, style);
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
/**
|
||||
* Physically DOCKED vehicles are separate real tracks sharing one position: the
|
||||
* station and everything berthed to it sit within metres of each other. Their
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { satelliteClassLabel } from '../../data/satelliteClass.js';
|
||||
import { satelliteClassText } from '../../data/satelliteClass.js';
|
||||
import { ISS_NORAD } from './policy.js';
|
||||
|
||||
/**
|
||||
@@ -38,7 +38,10 @@ export function mapAnalystRecord(raw) {
|
||||
// must not leak onto a row that has no satellite identity.
|
||||
satelliteClass:
|
||||
noradId || group
|
||||
? satelliteClassLabel(group, { isIss, localize: false })
|
||||
? satelliteClassText(text(raw?.satelliteClass), group, {
|
||||
isIss,
|
||||
localize: false,
|
||||
})
|
||||
: null,
|
||||
group,
|
||||
};
|
||||
@@ -83,6 +86,12 @@ export function createRecords({ state, parts }) {
|
||||
const result = [];
|
||||
const emit = (noradId, sat) => {
|
||||
if (result.length >= limit) return false;
|
||||
// A class the user hid stays out of answers too; the tracked one stays.
|
||||
if (
|
||||
noradId !== state._trackedNorad &&
|
||||
parts.controls?._classVisible?.(noradId) === false
|
||||
)
|
||||
return true;
|
||||
const pos = analystGeo(noradId, sat, now);
|
||||
if (!pos) return true;
|
||||
result.push(
|
||||
@@ -90,6 +99,7 @@ export function createRecords({ state, parts }) {
|
||||
noradId,
|
||||
name: sat?.name,
|
||||
group: sat?.group,
|
||||
satelliteClass: sat?.satelliteClass,
|
||||
lat: pos.lat,
|
||||
lon: pos.lon,
|
||||
altitudeM: pos.altitudeM,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -152,7 +159,8 @@ export function createRendering({
|
||||
|
||||
/**
|
||||
* Propagate all CORE satellite positions and update point primitives.
|
||||
* (~840 sats ≈ 1.6 ms/pass — fine at the 1s/200ms cadence.) Dense extras are
|
||||
* (About 1,300 core satellites: ~840 measured at ≈ 1.6 ms/pass, so ≈ 2.5 ms,
|
||||
* fine at the 1s/200ms cadence.) Dense extras are
|
||||
* excluded: they refresh on the round-robin budget in _propagateDenseChunk.
|
||||
*/
|
||||
|
||||
@@ -217,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
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @file Imaging sensors: which Earth-observation platform a satellite is, and
|
||||
* the swath its sensor images. The orbital view draws that swath to scale and
|
||||
* lets a satellite zoom past 8x only as far as its sensor's narrowest
|
||||
* standard swath (src/cockpitOrbital.js `orbitalMaxZoom`).
|
||||
*
|
||||
* Pure: no Cesium, no fetches; the bundled pack is loaded by
|
||||
* src/data/imagingSensors.js.
|
||||
*
|
||||
* @module imagingSensors
|
||||
*/
|
||||
|
||||
/**
|
||||
* One sensor-pack entry as the app uses it.
|
||||
* @typedef {{id: string, label: string, operator: string|null,
|
||||
* sensor: string, type: string, altitudeKm: number|null,
|
||||
* modes: Array<{name: string, swathKm: number|null, gsdM: number|null}>,
|
||||
* narrowestSwathKm: number|null, swathConfirmed: boolean,
|
||||
* sources: string[], unverified: string[]}} ImagingSensor
|
||||
*/
|
||||
|
||||
/**
|
||||
* Index the pack for matching: exact NORAD numbers first, then name patterns
|
||||
* in pack order. A malformed pattern is skipped, not fatal.
|
||||
* @param {{entries?: Array<object>}} pack Parsed `imaging_sensors.json`.
|
||||
* @returns {{byNorad: Map<number, ImagingSensor>,
|
||||
* patterns: Array<{pattern: RegExp, sensor: ImagingSensor}>}}
|
||||
*/
|
||||
export function indexImagingSensors(pack) {
|
||||
const byNorad = new Map();
|
||||
const patterns = [];
|
||||
for (const entry of pack?.entries || []) {
|
||||
if (!entry?.id) continue;
|
||||
const sensor = Object.freeze({
|
||||
id: entry.id,
|
||||
label: entry.label || entry.id,
|
||||
operator: entry.operator ?? null,
|
||||
sensor: entry.sensor || '',
|
||||
type: entry.type || '',
|
||||
altitudeKm: Number.isFinite(entry.altitudeKm) ? entry.altitudeKm : null,
|
||||
modes: Object.freeze(
|
||||
(entry.modes || []).map((mode) => Object.freeze({ ...mode })),
|
||||
),
|
||||
narrowestSwathKm: Number.isFinite(entry.narrowestSwathKm)
|
||||
? entry.narrowestSwathKm
|
||||
: null,
|
||||
swathConfirmed: entry.swathConfirmed === true,
|
||||
sources: Object.freeze([...(entry.sources || [])]),
|
||||
unverified: Object.freeze([...(entry.unverified || [])]),
|
||||
});
|
||||
for (const noradId of entry.match?.norad || []) {
|
||||
if (Number.isInteger(noradId) && !byNorad.has(noradId))
|
||||
byNorad.set(noradId, sensor);
|
||||
}
|
||||
if (entry.match?.nameRegex) {
|
||||
try {
|
||||
patterns.push({ pattern: new RegExp(entry.match.nameRegex), sensor });
|
||||
} catch {
|
||||
// A bad pattern only loses its name match; NORAD ids still work.
|
||||
}
|
||||
}
|
||||
}
|
||||
return { byNorad, patterns };
|
||||
}
|
||||
|
||||
/**
|
||||
* The sensor entry for a satellite: by NORAD number, else by its CelesTrak
|
||||
* name (upper-cased, trimmed).
|
||||
* @param {ReturnType<typeof indexImagingSensors>|null|undefined} index
|
||||
* @param {{noradId?: number, name?: string}} satellite
|
||||
* @returns {ImagingSensor|null}
|
||||
*/
|
||||
export function matchImagingSensor(index, { noradId, name } = {}) {
|
||||
if (!index) return null;
|
||||
const byId = index.byNorad?.get(noradId);
|
||||
if (byId) return byId;
|
||||
const text = String(name || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (!text) return null;
|
||||
for (const { pattern, sensor } of index.patterns || [])
|
||||
if (pattern.test(text)) return sensor;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The swath the orbital view may zoom to: the narrowest standard swath, only
|
||||
* when it was confirmed on a cited page. An unconfirmed swath keeps the
|
||||
* satellite at the base zoom steps.
|
||||
* @param {ImagingSensor|null|undefined} sensor
|
||||
* @returns {number|null} Kilometres.
|
||||
*/
|
||||
export function zoomableSwathKm(sensor) {
|
||||
const swathKm = sensor?.narrowestSwathKm;
|
||||
return sensor?.swathConfirmed === true &&
|
||||
Number.isFinite(swathKm) &&
|
||||
swathKm > 0
|
||||
? swathKm
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import {
|
||||
indexImagingSensors,
|
||||
matchImagingSensor,
|
||||
zoomableSwathKm,
|
||||
} from './sensors.js';
|
||||
import { loadImagingSensors } from '../../data/imagingSensors.js';
|
||||
|
||||
const PACK_URL = new URL(
|
||||
'../../data/local_data/imaging_sensors/imaging_sensors.json',
|
||||
import.meta.url,
|
||||
);
|
||||
|
||||
const PACK = {
|
||||
entries: [
|
||||
{
|
||||
id: 'worldview-3',
|
||||
label: 'WorldView-3',
|
||||
match: { norad: [40115], nameRegex: '^WORLDVIEW-3' },
|
||||
narrowestSwathKm: 13.1,
|
||||
swathConfirmed: true,
|
||||
},
|
||||
{
|
||||
id: 'iceye',
|
||||
label: 'ICEYE',
|
||||
match: { nameRegex: '^ICEYE-X\\d+' },
|
||||
narrowestSwathKm: 5,
|
||||
swathConfirmed: true,
|
||||
},
|
||||
{
|
||||
id: 'broken',
|
||||
label: 'Broken pattern',
|
||||
match: { norad: [1], nameRegex: '([' },
|
||||
narrowestSwathKm: 9,
|
||||
swathConfirmed: true,
|
||||
},
|
||||
{
|
||||
id: 'blacksky',
|
||||
label: 'BlackSky',
|
||||
match: { nameRegex: '^GLOBAL-\\d+$' },
|
||||
narrowestSwathKm: null,
|
||||
swathConfirmed: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test('a satellite matches its sensor by NORAD number, then by name', () => {
|
||||
const index = indexImagingSensors(PACK);
|
||||
assert.equal(
|
||||
matchImagingSensor(index, { noradId: 40115 })?.id,
|
||||
'worldview-3',
|
||||
);
|
||||
// An id match wins over a name that says otherwise.
|
||||
assert.equal(
|
||||
matchImagingSensor(index, { noradId: 40115, name: 'ICEYE-X9' })?.id,
|
||||
'worldview-3',
|
||||
);
|
||||
assert.equal(
|
||||
matchImagingSensor(index, { noradId: 99999, name: ' iceye-x14 ' })?.id,
|
||||
'iceye',
|
||||
);
|
||||
assert.equal(matchImagingSensor(index, { noradId: 1 })?.id, 'broken');
|
||||
assert.equal(matchImagingSensor(index, { name: 'STARLINK-1007' }), null);
|
||||
assert.equal(matchImagingSensor(null, { noradId: 40115 }), null);
|
||||
});
|
||||
|
||||
test('only a confirmed swath opens zoom steps past 8x', () => {
|
||||
const index = indexImagingSensors(PACK);
|
||||
assert.equal(
|
||||
zoomableSwathKm(matchImagingSensor(index, { noradId: 40115 })),
|
||||
13.1,
|
||||
);
|
||||
assert.equal(
|
||||
zoomableSwathKm(matchImagingSensor(index, { name: 'GLOBAL-21' })),
|
||||
null,
|
||||
);
|
||||
assert.equal(zoomableSwathKm(null), null);
|
||||
assert.equal(
|
||||
zoomableSwathKm({ swathConfirmed: true, narrowestSwathKm: 0 }),
|
||||
null,
|
||||
'no swath to zoom to',
|
||||
);
|
||||
});
|
||||
|
||||
test('the bundled pack is well formed and cites its sources', () => {
|
||||
const pack = JSON.parse(readFileSync(PACK_URL, 'utf8'));
|
||||
assert.ok(pack.entries.length >= 40);
|
||||
assert.ok(
|
||||
readFileSync(PACK_URL).length < 60_000,
|
||||
'small enough for the layer',
|
||||
);
|
||||
const ids = new Set();
|
||||
for (const entry of pack.entries) {
|
||||
assert.ok(entry.id && !ids.has(entry.id), `unique id ${entry.id}`);
|
||||
ids.add(entry.id);
|
||||
assert.ok(entry.match.norad || entry.match.nameRegex, entry.id);
|
||||
if (entry.match.nameRegex) new RegExp(entry.match.nameRegex);
|
||||
assert.ok(entry.sources.length > 0, `${entry.id} cites a source`);
|
||||
if (entry.swathConfirmed) {
|
||||
assert.ok(entry.narrowestSwathKm > 0, entry.id);
|
||||
// The narrowest *standard* swath: sampling modes (Himawari's landmark
|
||||
// area) and older generations (Dove Classic) may be narrower.
|
||||
const swaths = entry.modes
|
||||
.map((mode) => mode.swathKm)
|
||||
.filter(Number.isFinite);
|
||||
assert.ok(
|
||||
swaths.includes(entry.narrowestSwathKm),
|
||||
`${entry.id}: the narrowest swath is one of its modes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the pack cites no reseller, links its datasheets and names its own fields', () => {
|
||||
const pack = JSON.parse(readFileSync(PACK_URL, 'utf8'));
|
||||
// Resellers, Gunter's Space Page, and the Copernicus portal whose terms
|
||||
// forbid reuse.
|
||||
const excluded =
|
||||
/euspaceimaging\.com|apollomapping\.com|landinfo\.com|satimagingcorp\.com|space\.skyrocket\.de|dataspace\.copernicus\.eu/;
|
||||
const fields = new Set(['altitudeKm', 'narrowestSwathKm', 'swathKm', 'gsdM']);
|
||||
for (const entry of pack.entries) {
|
||||
for (const source of entry.sources)
|
||||
assert.doesNotMatch(source, excluded, `${entry.id}: ${source}`);
|
||||
// A bare file-host datasheet is cited beside the operator page that
|
||||
// links it.
|
||||
if (entry.sources.some((source) => /cdn\.sanity\.io/.test(source)))
|
||||
assert.ok(
|
||||
entry.sources.some((source) => /^https:\/\/vantor\.com\//.test(source)),
|
||||
`${entry.id}: the Vantor page for its datasheet`,
|
||||
);
|
||||
// An unverified value is named by the pack's own field.
|
||||
for (const note of entry.unverified) {
|
||||
const [field] = /^[A-Za-z_]+/.exec(note) ?? [''];
|
||||
if (/^[a-z]+(_[a-z]+)+$|^[a-z]+[A-Z]\w*$/.test(field))
|
||||
assert.ok(fields.has(field), `${entry.id}: unknown field ${field}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('the pack loads once and matches real catalog names', async () => {
|
||||
const index = await loadImagingSensors();
|
||||
assert.equal(index, await loadImagingSensors(), 'loaded once');
|
||||
const cases = [
|
||||
[{ noradId: 49260, name: 'LANDSAT 9' }, 'landsat-8-9'],
|
||||
[{ noradId: 40115, name: 'WORLDVIEW-3 (WV-3)' }, 'worldview-3'],
|
||||
[{ noradId: 1, name: 'FLOCK 4Q-12' }, 'planetscope'],
|
||||
[{ noradId: 1, name: 'ICEYE-X40' }, 'iceye'],
|
||||
[{ noradId: 1, name: 'SKYSAT-C19' }, 'skysat'],
|
||||
];
|
||||
for (const [satellite, id] of cases)
|
||||
assert.equal(matchImagingSensor(index, satellite)?.id, id, satellite.name);
|
||||
});
|
||||
@@ -6,11 +6,31 @@ const GROUPS = new Set([
|
||||
'galileo',
|
||||
'geo',
|
||||
'starlink',
|
||||
// Imaging coverage beyond the UCS May 2023 snapshot, and science probes.
|
||||
'resource',
|
||||
'weather',
|
||||
'planet',
|
||||
'sar',
|
||||
'science',
|
||||
]);
|
||||
|
||||
/** The UCS pack, loaded on first use (see src/data/ucsSatellites.js). */
|
||||
async function loadBundledUcs() {
|
||||
const { loadUcsSatellites } = await import('../../data/ucsSatellites.js');
|
||||
return loadUcsSatellites();
|
||||
}
|
||||
|
||||
/** The imaging-sensor pack, loaded on first use (see src/data/imagingSensors.js). */
|
||||
async function loadBundledSensors() {
|
||||
const { loadImagingSensors } = await import('../../data/imagingSensors.js');
|
||||
return loadImagingSensors();
|
||||
}
|
||||
|
||||
/** Read catalog text from the existing group endpoint using a supplied transport. */
|
||||
export function createSatelliteSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
loadUcs = loadBundledUcs,
|
||||
loadSensors = loadBundledSensors,
|
||||
} = {}) {
|
||||
return {
|
||||
async readGroup(group, { signal } = {}) {
|
||||
@@ -24,5 +44,33 @@ export function createSatelliteSource({
|
||||
const stale = response.headers?.get?.('x-tle-cache') === 'STALE-ERROR';
|
||||
return { ok: response.ok, status: response.status, text, stale };
|
||||
},
|
||||
|
||||
/**
|
||||
* CelesTrak SATCAT records (CSV) for one group: object type, operational
|
||||
* status and owner, through the proxy's day-long cache.
|
||||
*/
|
||||
async readSatcat(group, { signal } = {}) {
|
||||
if (!GROUPS.has(group)) throw new TypeError('Unknown satellite group');
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(`/api/celestrak-satcat/${group}`, {
|
||||
signal,
|
||||
});
|
||||
const text = response.ok ? await response.text() : '';
|
||||
signal?.throwIfAborted();
|
||||
// The proxy serves its last copy, marked STALE-ERROR, when CelesTrak
|
||||
// is down.
|
||||
const stale = response.headers?.get?.('x-satcat-cache') === 'STALE-ERROR';
|
||||
return { ok: response.ok, status: response.status, text, stale };
|
||||
},
|
||||
|
||||
/** The UCS Satellite Database pack, keyed by NORAD number. */
|
||||
readUcs() {
|
||||
return loadUcs();
|
||||
},
|
||||
|
||||
/** The imaging-sensor pack, indexed by NORAD number and name pattern. */
|
||||
readSensors() {
|
||||
return loadSensors();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -164,3 +164,24 @@ test('satellite pass prediction methods report no-tle on cold catalog and coerce
|
||||
{ status: 'no-tle' },
|
||||
);
|
||||
});
|
||||
|
||||
test('a SATCAT read says when the proxy served its last copy', async () => {
|
||||
const headers = (cache) => ({
|
||||
get: (name) => (name === 'x-satcat-cache' ? cache : null),
|
||||
});
|
||||
let cache = 'STALE-ERROR';
|
||||
const source = createSatelliteSource({
|
||||
fetchImpl: async (url) => {
|
||||
assert.equal(url, '/api/celestrak-satcat/visual');
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: headers(cache),
|
||||
text: async () => 'OBJECT_NAME,NORAD_CAT_ID,OBJECT_TYPE',
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal((await source.readSatcat('visual')).stale, true);
|
||||
cache = 'HIT';
|
||||
assert.equal((await source.readSatcat('visual')).stale, false);
|
||||
});
|
||||
@@ -24,7 +24,15 @@ const GROUPS = [
|
||||
'galileo',
|
||||
'geo',
|
||||
'starlink',
|
||||
// Added after Starlink so the fixture ids above keep their numbers.
|
||||
'resource',
|
||||
'weather',
|
||||
'planet',
|
||||
'sar',
|
||||
'science',
|
||||
];
|
||||
/** The core catalog's groups, one owned satellite each. */
|
||||
const CORE = GROUPS.filter((group) => group !== 'starlink');
|
||||
|
||||
function tle(id, invalid = false) {
|
||||
// Owned synthetic catalog IDs over one valid element set; never live requests.
|
||||
@@ -143,10 +151,10 @@ test('stale core records surface through actual HTTP, ingestion and feed state,
|
||||
const f = await fixture(t);
|
||||
await f.update();
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
assert.equal(f.stats().count, 6);
|
||||
f.policy.stale = new Set(GROUPS.slice(0, 6));
|
||||
assert.equal(f.stats().count, CORE.length);
|
||||
f.policy.stale = new Set(CORE);
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.stats().count, CORE.length);
|
||||
assert.equal(f.stats().stale, true);
|
||||
assert.equal(f.feed(), 'stale');
|
||||
f.policy.stale = new Set(['visual']);
|
||||
@@ -163,13 +171,13 @@ test('a stale duplicate cannot taint its earlier fresh winning record', async (t
|
||||
f.policy.stale.add('visual');
|
||||
f.policy.ids.visual = 25545;
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 5);
|
||||
assert.equal(f.stats().count, CORE.length - 1);
|
||||
assert.equal(f.state._catalog.get(25545).group, 'stations');
|
||||
assert.equal(f.stats().stale, false);
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
f.policy.stale = new Set(['stations']);
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 5);
|
||||
assert.equal(f.stats().count, CORE.length - 1);
|
||||
assert.equal(
|
||||
f.stats().stale,
|
||||
true,
|
||||
@@ -182,7 +190,7 @@ test('invalid stale orbital elements are not admitted as provenance', async (t)
|
||||
f.policy.stale.add('visual');
|
||||
f.policy.invalid.add('visual');
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 5);
|
||||
assert.equal(f.stats().count, CORE.length - 1);
|
||||
assert.equal(f.state._catalog.has(25546), false);
|
||||
assert.equal(f.stats().stale, false);
|
||||
});
|
||||
@@ -194,17 +202,17 @@ test('failed and aborted core refreshes keep provenance of retained records', as
|
||||
const priorTime = f.stats().lastUpdate;
|
||||
f.policy.status = 503;
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.stats().count, CORE.length);
|
||||
assert.equal(f.stats().stale, true);
|
||||
assert.equal(f.stats().status, 'unavailable');
|
||||
assert.equal(f.stats().lastUpdate, priorTime);
|
||||
f.policy.hold = true;
|
||||
const controller = new AbortController();
|
||||
const work = f.update({ signal: controller.signal });
|
||||
await f.waitHeld(6);
|
||||
await f.waitHeld(CORE.length);
|
||||
controller.abort();
|
||||
await assert.rejects(work, { name: 'AbortError' });
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.stats().count, CORE.length);
|
||||
assert.equal(f.stats().stale, true);
|
||||
assert.equal(f.stats().lastUpdate, priorTime);
|
||||
assert.equal(f.state._activeUpdateControllers.size, 0);
|
||||
@@ -216,11 +224,11 @@ test('dense provenance belongs to its admitted records and clears on removal', a
|
||||
f.policy.stale.add('starlink');
|
||||
f.state._params.catalog = 'dense';
|
||||
assert.equal((await f.parts.catalog._loadDenseCatalog()).status, 'ready');
|
||||
assert.equal(f.stats().count, 7);
|
||||
assert.equal(f.stats().count, CORE.length + 1);
|
||||
assert.equal(f.feed(), 'stale');
|
||||
f.state._params.catalog = 'core';
|
||||
f.parts.catalog._removeDenseCatalog();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.stats().count, CORE.length);
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
f.policy.stale = new Set(['stations']);
|
||||
await f.update();
|
||||
@@ -261,13 +269,13 @@ test('cancelling dense replacement preserves admitted stale extras until rebuild
|
||||
await f.waitHeld(1);
|
||||
controller.abort();
|
||||
assert.equal((await work).status, 'cancelled');
|
||||
assert.equal(f.stats().count, 7);
|
||||
assert.equal(f.stats().count, CORE.length + 1);
|
||||
assert.equal(f.feed(), 'stale');
|
||||
f.policy.hold = false;
|
||||
f.policy.stale.clear();
|
||||
f.state._params.catalog = 'core';
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.stats().count, CORE.length);
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
});
|
||||
|
||||
@@ -277,7 +285,7 @@ test('destroy aborts an in-flight response and initialization starts with new ow
|
||||
await f.update();
|
||||
f.policy.hold = true;
|
||||
const work = f.update();
|
||||
await f.waitHeld(6);
|
||||
await f.waitHeld(CORE.length);
|
||||
const rejected = assert.rejects(work, { name: 'AbortError' });
|
||||
f.parts.lifecycle.methods.destroy(f.viewer);
|
||||
assert.equal(f.stats().count, 0);
|
||||
@@ -288,6 +296,6 @@ test('destroy aborts an in-flight response and initialization starts with new ow
|
||||
f.policy.hold = false;
|
||||
f.policy.stale.clear();
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.stats().count, CORE.length);
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { DEFAULT_SATELLITE_CLASSES } from './classes.js';
|
||||
import { worldClock, worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
export function createState({ services }) {
|
||||
@@ -14,7 +15,9 @@ export function createState({ services }) {
|
||||
|
||||
state._overlayHost = state.DEFAULT_OVERLAY_HOST;
|
||||
|
||||
// Satellite catalog: { noradId → { name, satrec, group } }
|
||||
// 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.
|
||||
|
||||
state._catalog = new Map();
|
||||
|
||||
@@ -43,6 +46,12 @@ export function createState({ services }) {
|
||||
|
||||
// Cache provenance belongs to admitted core and dense records separately.
|
||||
state._coreStale = false;
|
||||
// How current the SATCAT records behind the classes are: null before any
|
||||
// read, then 'current' | 'stale' | 'unavailable' (see ingestion.js).
|
||||
state._satcatState = null;
|
||||
// When _satcatState was last set (Date.now()); a current read is kept
|
||||
// SATCAT_KEEP_MS (src/layers/satellites/ingestion.js).
|
||||
state._satcatReadAtMs = Number.NEGATIVE_INFINITY;
|
||||
state._denseStale = false;
|
||||
|
||||
state._activeUpdateControllers = new Set();
|
||||
@@ -93,9 +102,25 @@ export function createState({ services }) {
|
||||
|
||||
// Runtime params (DataLayerManager.setLayerParams path)
|
||||
|
||||
state._params = { catalog: 'core', showPoints: true, showOrbits: true };
|
||||
state._params = {
|
||||
catalog: 'core',
|
||||
showPoints: true,
|
||||
showOrbits: true,
|
||||
// Which satellite classes show (src/layers/satellites/classes.js). Starlink
|
||||
// follows `catalog`: its points exist only in dense mode.
|
||||
classes: { ...DEFAULT_SATELLITE_CLASSES },
|
||||
};
|
||||
// 'core' | 'dense'
|
||||
|
||||
/** SATCAT records (object type, status, owner) by NORAD id, last update. */
|
||||
state._satcat = new Map();
|
||||
|
||||
/** The UCS pack by NORAD id, once loaded; null until then or if it failed. */
|
||||
state._ucs = null;
|
||||
|
||||
/** The imaging-sensor index, once loaded; null until then or if it failed. */
|
||||
state._sensors = null;
|
||||
|
||||
state._denseIds = [];
|
||||
// norad ids of dense extras, round-robin order
|
||||
|
||||
@@ -152,6 +177,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');
|
||||
|
||||
@@ -26,10 +26,14 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
// Extra catalog/point rows so a test can exercise a docked cluster (and a
|
||||
// control satellite that must NOT be treated as part of it).
|
||||
neighbours = [],
|
||||
// Optional class of the tracked satellite and class visibility, e.g.
|
||||
// { telecom: false }.
|
||||
satelliteClass = undefined,
|
||||
classes = null,
|
||||
}) {
|
||||
layerState._viewer = viewer;
|
||||
layerState._catalog = new Map([
|
||||
[noradId, { name, satrec, group: 'stations' }],
|
||||
[noradId, { name, satrec, group: 'stations', satelliteClass }],
|
||||
]);
|
||||
layerState._points = new Map([[noradId, point]]);
|
||||
for (const neighbour of neighbours) {
|
||||
@@ -37,6 +41,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
name: neighbour.name,
|
||||
satrec: neighbour.satrec || satrec,
|
||||
group: neighbour.group || 'stations',
|
||||
satelliteClass: neighbour.satelliteClass,
|
||||
});
|
||||
layerState._points.set(neighbour.noradId, neighbour.point);
|
||||
}
|
||||
@@ -51,6 +56,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
catalog: 'core',
|
||||
showPoints: false,
|
||||
showOrbits: false,
|
||||
...(classes ? { classes } : {}),
|
||||
};
|
||||
layerState._enabled = true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { satelliteClassLabel } from '../../data/satelliteClass.js';
|
||||
import { satelliteClassText } from '../../data/satelliteClass.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { formatQuantity, unitLabel } from '../../ui/unitLabels.js';
|
||||
import {
|
||||
@@ -95,7 +95,8 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
||||
layerState._trackedNorad,
|
||||
layerState._catalog.get(layerState._trackedNorad)?.group,
|
||||
);
|
||||
lastPos.show = true;
|
||||
// Back to its class's visibility: a hidden class stays hidden.
|
||||
lastPos.show = parts.controls._classVisible(layerState._trackedNorad);
|
||||
lastPos.pixelSize = style.pixelSize;
|
||||
lastPos.color = style.color;
|
||||
lastPos.outlineColor = style.outlineColor;
|
||||
@@ -118,6 +119,10 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
||||
layerState._trackedEntity = null;
|
||||
}
|
||||
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;
|
||||
@@ -150,13 +155,13 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
||||
frameNumber !== layerState._trackedFrameNumber ||
|
||||
layerState._trackedFrameGeo === null
|
||||
) {
|
||||
const pos = parts.orbits.propagatePosition(
|
||||
sat.satrec,
|
||||
layerState._trackedFrameNowForTest
|
||||
? new Date(layerState._trackedFrameNowForTest())
|
||||
: new Date(layerState.now()),
|
||||
);
|
||||
const atMs = layerState._trackedFrameNowForTest
|
||||
? layerState._trackedFrameNowForTest()
|
||||
: layerState.now();
|
||||
const pos = parts.orbits.propagatePosition(sat.satrec, new Date(atMs));
|
||||
if (!pos) return layerState._trackedFrameGeo; // propagation hiccup — keep last good sample
|
||||
// The time the sample is at: the world clock's, not the wall clock's.
|
||||
pos.atMs = atMs;
|
||||
layerState._trackedFrameGeo = pos;
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
pos.longitude,
|
||||
@@ -241,7 +246,7 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
||||
name,
|
||||
operator: '',
|
||||
noradId: String(noradId),
|
||||
class: satelliteClassLabel(sat.group, {
|
||||
class: satelliteClassText(sat.satelliteClass, sat.group, {
|
||||
isIss: noradId === ISS_NORAD,
|
||||
localize: false,
|
||||
}),
|
||||
@@ -366,7 +371,7 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
||||
// looking at, and it stays readable under the IR styles that flatten the
|
||||
// dot colors to a single channel (the card is painted above post-FX).
|
||||
const details = [
|
||||
satelliteClassLabel(sat?.group, {
|
||||
satelliteClassText(sat?.satelliteClass, sat?.group, {
|
||||
isIss: layerState._trackedNorad === ISS_NORAD,
|
||||
}),
|
||||
detail,
|
||||
@@ -420,6 +425,7 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
||||
|
||||
// Show orbital path
|
||||
parts.rendering._showOrbitPath(noradId, Cesium.Color.YELLOW);
|
||||
if (noradId === ISS_NORAD) parts.controls._syncIssOrbit?.();
|
||||
|
||||
// Tracked entity position propagates per evaluation through the per-frame
|
||||
// cache (WS-D2) — dot, host readout, and camera share one SGP4 epoch per frame.
|
||||
|
||||
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user