feat(satellites): file satellites under six classes with a toggle each

The satellites layer now sorts every object into Stations, Imaging,
Telecom, Starlink, Probes or Debris, each with a chip and a live count on
the layer row. Stations and Imaging show by default; the Starlink chip is
the former DENSE catalog switch. Hidden classes keep their points, so a
toggle needs no reload, and they leave the detection overlay and the voice
position lists.

A class comes, strongest evidence first, from CelesTrak SATCAT (object type
and status, served by a new /api/celestrak-satcat/<group> proxy route and
cached for a day), the bundled UCS Satellite Database pack (operator, owner
country and purpose), name patterns for later launches, and the CelesTrak
group. The core catalog also reads the resource, weather, planet, sar and
science groups. Payloads with no evidence of life launched 20 or more years
ago are filed as debris. The UCS pack, its build script and its data credit
are added.
This commit is contained in:
Denis Groshev committed 2026-11-08 23:34:55 +01:00
1 parent 1b9bb844e6
commit c56f098d07
44 files changed
+2530 -362

No files matched your search

+6 -1
View File
@@ -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,6 +229,7 @@ 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" |
| **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) |
@@ -293,6 +294,10 @@ 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.
### Natural Earth physical regions (`natural_earth/`)
Curated from the **Natural Earth 10m physical vectors** (https://www.naturalearthdata.com/ —
+1 -1
View File
@@ -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 |
+2 -1
View File
@@ -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
+6 -5
View File
@@ -5454,11 +5454,12 @@ 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 and IMAGING on by default; TELECOM (communications and navigation), STARLINK, PROBES (science, technology development and demonstration, unknown purpose) and DEBRIS (rocket bodies, fragments, unknown objects and dead payloads) off. 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 and the voice/framing position lists, 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), 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 group. Only crewed stations and the craft that visit them, by name, are STATIONS: the CubeSats CelesTrak's stations group also carries after their deployment from the ISS are classed like any other satellite. A payload whose SATCAT record gives no status, with no UCS record and no recognisable name, that launched 20 or more years ago (SATCAT's launch date, else the TLE designator's year) is presumed dead and filed as debris; without a SATCAT record (a failed read) nothing is presumed. To cover imaging launched after the UCS snapshot the core catalog also reads CelesTrak's `resource`, `weather`, `planet`, `sar` and `science` groups. Retired stations and the shuttles have re-entered and have no orbit to show. Class visibility is a runtime param (`classes`) and is not yet part of share links; STARLINK maps to the existing `catalog: 'dense'` param, which is.
- **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.
+120
View File
@@ -0,0 +1,120 @@
#!/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',
'detailedPurpose': 'Detailed Purpose',
'users': 'Users',
'orbitClass': 'Class of Orbit',
}
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
record = [norad]
for key, column in COLUMNS.items():
text = clean(row[header[column]])
if key == 'orbitClass':
text = text.upper()
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()
+21 -5
View File
@@ -1660,7 +1660,11 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/unitLabels.js"
"src/ui/unitLabels.js",
"src/layers/satellites/classes.js",
"src/data/ucsSatellites.js",
"src/data/bundledJson.js",
"src/data/retryableLoad.js"
],
"external": ["cesium", "satellite.js"]
},
@@ -3348,7 +3352,9 @@
"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/data/ucsSatellites.js"
],
"external": [
"hls.js",
@@ -3999,7 +4005,9 @@
"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/data/ucsSatellites.js"
],
"external": [
"hls.js",
@@ -4501,7 +4509,13 @@
},
"satellites-source": {
"exports": ["./layers/satellites/source"],
"modules": ["src/layers/satellites/source.js"],
"modules": [
"src/layers/satellites/source.js",
"src/layers/satellites/classes.js",
"src/data/ucsSatellites.js",
"src/data/bundledJson.js",
"src/data/retryableLoad.js"
],
"external": []
},
"traffic-source-factory": {
@@ -5118,7 +5132,9 @@
"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/data/ucsSatellites.js"
],
"external": ["@mapbox/vector-tile", "pbf"]
},
+117 -68
View File
@@ -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,124 @@ 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; both routes share the upstream
* cooldown, since CelesTrak throttles the client, not the endpoint.
*/
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',
]);
'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',
},
];
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');
}
}
/** 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) {
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.
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,
);
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 +154,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,10 +166,10 @@ 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
? {
@@ -145,7 +188,7 @@ 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;
}
@@ -191,7 +234,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',
+8 -1
View File
@@ -143,10 +143,17 @@ 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: 'launch-library-2',
html:
+14
View File
@@ -38,6 +38,20 @@ 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('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,51 @@
# 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, Purpose, Detailed
Purpose, Users, Class of Orbit (upper-cased, which folds the source's one
`LEo`). Everything else (masses, contractors, launch data, sources) is left
out.
- Repeated text is stored once in `strings`; each record is
`[noradId, operator, operatorCountry, purpose, detailedPurpose, users,
orbitClass]` 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 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","detailedPurpose":"Detailed Purpose","users":"Users","orbitClass":"Class of Orbit"}},"recordCount":3564},"fields":["noradId","operator","operatorCountry","purpose","detailedPurpose","users","orbitClass"],"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","DepartmeLine truncated
+34 -59
View File
@@ -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;
}
+115 -89
View File
@@ -5,9 +5,8 @@ import {
SATELLITE_CLASS_ORDER,
satelliteClassColor,
satelliteClassLabel,
satelliteClassLegend,
satelliteClassOf,
tallySatelliteClasses,
satelliteClassText,
} from './satelliteClass.js';
import satellitesLayer, {
_catalogGroupForTest,
@@ -35,13 +34,17 @@ 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'];
@@ -146,69 +149,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 +158,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 +177,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 +188,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 +197,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 +226,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 +306,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 +317,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,10 +339,9 @@ 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(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');
assert.equal(satelliteClassLabel('visual', { isIss: true }), 'STATION · ISS',
'the card says the same thing the legend does');
} finally {
@@ -431,15 +371,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 +397,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 +406,88 @@ 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('stations and imaging show 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('40002', 'SENTINEL-2A'),
'/api/celestrak/gps-ops': tleFor('40001', 'GPS BIII-1'),
'/api/celestrak/visual': tleFor('40003', '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, 40002], 'stations and imaging only');
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, false);
assert.deepEqual(chip('telecom').params, { classes: { telecom: true } });
assert.deepEqual(
satellitesLayer.getRowControls().legend.map((row) => row.klass),
['stations', 'imaging'],
'the legend keys only what is on screen',
);
satellitesLayer.setParams(chip('telecom').params);
satellitesLayer.setParams(chip('debris').params);
assert.deepEqual(shown(), [25544, 40001, 40002, 40003]);
assert.equal(chip('debris').active, true);
assert.deepEqual(satellitesLayer.getParams().classes, {
stations: true,
imaging: true,
telecom: true,
starlink: false,
probes: false,
debris: true,
});
satellitesLayer.setParams(chip('imaging').params);
assert.deepEqual(shown(), [25544, 40001, 40003], 'imaging off');
assert.equal(satellitesLayer.setParams({ classes: 'all' }), false, 'malformed request refused');
} 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',
);
});
+8
View File
@@ -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);
+64
View File
@@ -0,0 +1,64 @@
/**
* @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,
* detailedPurpose: string|null, users: string|null,
* orbitClass: 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)),
);
+84
View File
@@ -0,0 +1,84 @@
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',
'detailedPurpose',
'users',
'orbitClass',
]);
// 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',
);
if (record.orbitClass) assert.match(record.orbitClass, /^(LEO|MEO|GEO|ELLIPTICAL)$/);
}
});
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);
});
+17 -2
View File
@@ -821,8 +821,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} — انقر لإعادة المحاولة",
@@ -3530,5 +3528,22 @@
"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 ✕",
"COURSE HOLD · AIS": "تثبيت المسار · AIS"
}
+17 -2
View File
@@ -278,8 +278,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",
@@ -3226,5 +3224,22 @@
"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 ✕",
"COURSE HOLD · AIS": "KURS FIX · AIS"
}
+17 -2
View File
@@ -679,8 +679,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",
@@ -3302,5 +3300,22 @@
"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 ✕",
"COURSE HOLD · AIS": "BLOQUEO DE RUMBO · AIS"
}
+17 -2
View File
@@ -1023,8 +1023,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}",
@@ -3302,5 +3300,22 @@
"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 ✕",
"COURSE HOLD · AIS": "VERROU ROUTE · AIS"
}
+17 -2
View File
@@ -1023,8 +1023,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}",
@@ -3302,5 +3300,22 @@
"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 ✕",
"COURSE HOLD · AIS": "BLOCCO ROTTA · AIS"
}
+17 -2
View File
@@ -991,8 +991,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}",
@@ -3150,5 +3148,22 @@
"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 ✕",
"COURSE HOLD · AIS": "対地針路保持 · AIS"
}
+17 -2
View File
@@ -1021,8 +1021,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}",
@@ -3302,5 +3300,22 @@
"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 ✕",
"COURSE HOLD · AIS": "TRAVA DE RUMO · AIS"
}
+17 -2
View File
@@ -247,8 +247,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} · Дороги без потока скрыты",
@@ -3378,5 +3376,22 @@
"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 ✕",
"COURSE HOLD · AIS": "УДЕРЖАНИЕ ПУТЕВОГО УГЛА · AIS"
}
+17 -2
View File
@@ -1038,8 +1038,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}",
@@ -3378,5 +3376,22 @@
"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 ✕",
"COURSE HOLD · AIS": "УТРИМАННЯ ШЛЯХОВОГО КУТА · AIS"
}
+17 -2
View File
@@ -970,8 +970,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}",
@@ -3150,5 +3148,22 @@
"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 ✕",
"COURSE HOLD · AIS": "对地航向保持 · AIS"
}
+17 -2
View File
@@ -965,8 +965,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}",
@@ -3150,5 +3148,22 @@
"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 ✕",
"COURSE HOLD · AIS": "對地航向保持 · AIS"
}
+37 -2
View File
@@ -28,6 +28,9 @@
"S2 GhostMaps",
"SQUAWK {code} · {status}",
"SRC {value}",
"STARLINK",
"STARLINK ···",
"STARLINK ✕",
"Sentinel-2",
"WHOT/BHOT",
"acartia.io ↗",
@@ -153,6 +156,9 @@
"SQUAWK {code} · {status}",
"SSW",
"STANDARD",
"STARLINK",
"STARLINK ···",
"STARLINK ✕",
"START",
"STATION",
"STATUS",
@@ -306,11 +312,15 @@
"SQUAWK {code} · {status}",
"SRC {value}",
"SSE",
"STARLINK",
"STARLINK ···",
"STARLINK ✕",
"STD",
"San Francisco",
"Sector",
"Sonar",
"Soul",
"TELECOM",
"Techno",
"Trance",
"USB {rate} msg/s",
@@ -401,8 +411,6 @@
"Cyber",
"Cyclones · NHC / CPHC",
"DENSE",
"DENSE ···",
"DENSE ✕",
"DEST ---°",
"DEST {bearing}",
"DESTINATION",
@@ -490,7 +498,11 @@
"SRC {value}",
"SSE",
"STANDARD",
"STARLINK",
"STARLINK ···",
"STARLINK ✕",
"STATION",
"STATIONS",
"STD",
"San Francisco",
"Satellites",
@@ -664,6 +676,9 @@
"SQUAWK {code} · {status}",
"SSE",
"STANDARD",
"STARLINK",
"STARLINK ···",
"STARLINK ✕",
"STD",
"STOP",
"SYNC",
@@ -672,6 +687,7 @@
"Sentinel-2",
"Sonar",
"Soul",
"TELECOM",
"TEMP",
"TIMEOUT",
"Talk",
@@ -765,6 +781,9 @@
"REC",
"S2 GhostMaps",
"SRC {value}",
"STARLINK",
"STARLINK ···",
"STARLINK ✕",
"Sentinel-2",
"USB {rate} msg/s",
"WHOT/BHOT",
@@ -881,6 +900,9 @@
"SNAPSHOT",
"SQUAWK {code} · {status}",
"SSE",
"STARLINK",
"STARLINK ···",
"STARLINK ✕",
"STATUS",
"STATUS · ",
"Sentinel-2",
@@ -888,6 +910,7 @@
"Sonar",
"Soul",
"TAGS · {tags}",
"TELECOM",
"TEMP",
"Tallinn",
"Techno",
@@ -946,6 +969,9 @@
"REC",
"S2 GhostMaps",
"S2 · {name}",
"STARLINK",
"STARLINK ···",
"STARLINK ✕",
"VS",
"WHOT/BHOT",
"acartia.io ↗",
@@ -966,6 +992,9 @@
"NVG",
"REC",
"S2 GhostMaps",
"STARLINK",
"STARLINK ···",
"STARLINK ✕",
"WHOT/BHOT",
"acartia.io ↗",
"iNaturalist",
@@ -1001,6 +1030,9 @@
"S2 GhostMaps",
"S2 · {name}",
"SRC {value}",
"STARLINK",
"STARLINK ···",
"STARLINK ✕",
"Sentinel-2",
"Techno",
"Trance",
@@ -1065,6 +1097,9 @@
"S2 GhostMaps",
"S2 · {name}",
"SRC {value}",
"STARLINK",
"STARLINK ···",
"STARLINK ✕",
"Sentinel-2",
"Techno",
"Trance",
+6
View File
@@ -124,6 +124,12 @@ 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,
});
const point = layerState._pointCollection.add({
position: Cesium.Cartesian3.fromDegrees(
+725
View File
@@ -0,0 +1,725 @@
/**
* @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. */
export const DEFAULT_SATELLITE_CLASSES = Object.freeze({
stations: true,
imaging: true,
telecom: false,
starlink: false,
probes: false,
debris: false,
});
/**
* 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', 'UNK']);
/** 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 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',
});
/** 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,
* ucs?: {purpose?: string|null}|null,
* satcat?: {objectType?: string, opsStatus?: string,
* launchDate?: string|null}|null,
* launchYear?: number|null, nowYear?: number}} subject `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,
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;
if (objectType && DEBRIS_OBJECT_TYPES.has(objectType)) 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';
const 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.
const launched = launchYearOf(satcat?.launchDate) ?? launchYear;
if (
satcat &&
UNKNOWN_STATUSES.has(status ?? '') &&
Number.isFinite(launched) &&
nowYear - launched >= PRESUMED_DEAD_AFTER_YEARS
)
return 'debris';
return GROUP_CLASS[group] || '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;
}
+260
View File
@@ -0,0 +1,260 @@
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';
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'],
);
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',
);
// 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'],
['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);
});
+190 -74
View File
@@ -1,11 +1,13 @@
import {
tallySatelliteClasses,
satelliteClassLabel,
satelliteClassLegend,
} from '../../data/satelliteClass.js';
import { satelliteClassText } from '../../data/satelliteClass.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 +21,60 @@ 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).
*/
function _applyClassVisibility() {
for (const [noradId, point] of layerState._points) {
if (noradId === layerState._trackedNorad) continue;
point.show = _classVisible(noradId);
}
layerState._detectionObjects.clear();
}
/**
* 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 +101,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 +140,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,6 +222,8 @@ 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 —
@@ -141,15 +236,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);
@@ -222,6 +318,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 &&
@@ -380,9 +477,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 (
@@ -391,9 +490,22 @@ 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;
if (params.classes !== undefined) {
if (!params.classes || typeof params.classes !== 'object') return false;
// Starlink's toggle is the dense catalog: its points load on demand.
if (
typeof params.classes.starlink === 'boolean' &&
catalog === undefined
)
catalog = params.classes.starlink ? 'dense' : 'core';
layerState._params.classes = mergeClassVisibility(
params.classes,
_classes(),
);
}
const catalogChanged = satelliteCatalogModeChanged(
layerState._params.catalog,
catalog,
@@ -431,6 +543,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,
@@ -462,25 +581,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
@@ -489,48 +612,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() };
},
/**
@@ -559,6 +670,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,
};
},
};
@@ -567,6 +681,8 @@ export function createControls({ state: layerState, services, parts, source }) {
_pointStyleFor,
_notifyRowControls,
_classTally,
_classVisible,
_applyClassVisibility,
satelliteVisualsVisible,
satelliteCatalogModeChanged,
methods,
+166 -2
View File
@@ -3,6 +3,14 @@ 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';
/**
* 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;
export function createIngestion({
state: layerState,
@@ -10,6 +18,125 @@ export function createIngestion({
parts,
source,
}) {
/**
* The local pack the classes are read from: the UCS pack. It is not needed
* to render: a failed read leaves the last pack (or none) and
* classification falls back to names and groups.
*/
async function _readLocalEvidence() {
if (layerState._ucs) return;
const pack = await Promise.resolve()
.then(() => source.readUcs?.())
.then((loaded) => (loaded instanceof Map ? loaded : null))
.catch((error) => {
console.warn('[Data:Satellites] UCS pack unavailable:', error);
return null;
});
if (pack) layerState._ucs = pack;
}
/**
* 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([]);
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)
layerState._satcat.set(noradId, record);
}
if (!groups.length) return;
const changed = layerState._satcatState !== satcatState;
layerState._satcatState = satcatState;
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;
const next = classifySatellite({
noradId,
name: sat.name,
group: sat.group,
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 +154,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 +180,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 +209,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)) {
@@ -125,12 +272,27 @@ 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;
layerState._catalog.set(noradId, {
name: entry.name,
satrec,
group: entry.group,
// 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,
ucs,
satcat,
launchYear,
}),
ucs,
satcat,
});
coreStale ||= entry.stale;
@@ -156,6 +318,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);
}
+89 -3
View File
@@ -1,8 +1,9 @@
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_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 +19,7 @@ function tleSet(name, field) {
].join('\n');
}
function ingestionFor(catalogText) {
function ingestionFor(catalogText, { source: sourceExtras = {} } = {}) {
const state = {
_trackingRefreshEpoch: 0,
_activeUpdateControllers: new Set(),
@@ -33,6 +34,8 @@ function ingestionFor(catalogText) {
_denseLoadToken: 0,
_catalogRevision: 0,
_params: { catalog: 'core', showOrbits: false },
_satcat: new Map(),
_ucs: null,
};
const parts = {
orbits: {
@@ -40,7 +43,16 @@ function ingestionFor(catalogText) {
propagatePosition: () => ({ longitude: 0, latitude: 0, altitude: 4e5 }),
},
labels: { _syncIssOverlay() {} },
controls: { _pointStyleFor: () => ({}) },
controls: {
_pointStyleFor: () => ({}),
_classVisible: () => true,
_applyClassVisibility() {
state.visibilityApplied = (state.visibilityApplied || 0) + 1;
},
_notifyRowControls() {
state.rowNotified = (state.rowNotified || 0) + 1;
},
},
rendering: { _showOrbitPath() {} },
tracking: {
async _reconcileTrackedSubjectContext() {},
@@ -52,6 +64,7 @@ function ingestionFor(catalogText) {
async readGroup() {
return { ok: true, text: catalogText };
},
...sourceExtras,
};
const { methods } = createIngestion({ state, services: {}, parts, source });
return { state, update: () => methods.update({ scene: {} }) };
@@ -119,3 +132,76 @@ 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(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 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, '');
});
+45 -3
View File
@@ -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.
*
@@ -49,6 +53,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
@@ -157,6 +168,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
+6 -2
View File
@@ -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,
};
@@ -90,6 +93,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,
+2 -1
View File
@@ -138,7 +138,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.
*/
+36
View File
@@ -6,11 +6,24 @@ 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();
}
/** Read catalog text from the existing group endpoint using a supplied transport. */
export function createSatelliteSource({
fetchImpl = (...args) => globalThis.fetch(...args),
loadUcs = loadBundledUcs,
} = {}) {
return {
async readGroup(group, { signal } = {}) {
@@ -24,5 +37,28 @@ 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();
},
};
}
+21
View File
@@ -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);
});
+23 -15
View File
@@ -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');
});
+21 -2
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { DEFAULT_SATELLITE_CLASSES } from './classes.js';
export function createState({ services }) {
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource } =
@@ -13,7 +14,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();
@@ -42,6 +45,9 @@ 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;
state._denseStale = false;
state._activeUpdateControllers = new Set();
@@ -92,9 +98,22 @@ 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;
state._denseIds = [];
// norad ids of dense extras, round-robin order
+5 -4
View File
@@ -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;
@@ -241,7 +242,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 +367,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,
+3 -2
View File
@@ -45,10 +45,11 @@ function fixture(name, overrides = {}, preview = false) {
resolveTerrainHeightRequest: async () => { throw new Error(detail); },
...overrides,
};
const helpers = ['launchLibraryRequestHeaders', 'celestrakTleUrl', 'launchLibraryRecentUrl'].map(extract).join('\n');
const helpers = ['launchLibraryRequestHeaders', 'celestrakTleUrl', 'celestrakSatcatUrl', 'launchLibraryRecentUrl'].map(extract).join('\n');
const plugin = new Function(...Object.keys(deps), `${helpers}\n${extract(name)}\nreturn ${name}();`)(...Object.values(deps));
let middleware;
plugin[preview ? 'configurePreviewServer' : 'configureServer']({ middlewares: { use(_route, handler) { middleware = handler; } } });
// A plugin with several routes (CelesTrak: TLEs, then SATCAT) is tested on its first.
plugin[preview ? 'configurePreviewServer' : 'configureServer']({ middlewares: { use(_route, handler) { middleware ??= handler; } } });
return {
logs,
async request(url = '/', method = 'GET') {
+56
View File
@@ -303,6 +303,62 @@ test('Space Missions reads the active catalog through the real proxy while unkno
assert.deepEqual(fetched, ['active'], 'rejected groups never reach upstream');
});
test('SATCAT is proxied per core group for a day, separately from TLEs, and served stale on failure', async (t) => {
isolateDisk(t);
let now = Date.now();
t.mock.method(Date, 'now', () => now);
t.mock.method(console, 'warn', () => {});
const csv =
'OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE,OWNER\nSL-16 R/B,1992-093B,22285,R/B,,CIS\n';
const fetched = [];
t.mock.method(globalThis, 'fetch', async (url) => {
const parsed = new URL(url);
fetched.push(`${parsed.pathname}?${parsed.searchParams.get('GROUP')}`);
return new Response(csv);
});
const written = [];
t.mock.method(fsp, 'writeFile', async (file) => {
written.push(String(file).split(/[\\/]/).pop());
});
const request = install(celestrakProxy());
for (const group of ['/starlink', '/active', '/../visual', '/nope'])
assert.equal(
(await request('/api/celestrak-satcat', group)).status,
400,
group,
);
assert.deepEqual(fetched, [], 'rejected groups never reach upstream');
const first = await request('/api/celestrak-satcat', '/visual');
assert.equal(first.status, 200);
assert.equal(first.body, csv);
assert.equal(first.headers['x-satcat-cache'], 'MISS');
assert.deepEqual(fetched, ['/satcat/records.php?visual']);
assert.deepEqual(
written,
['celestrak-satcat-visual.json'],
'its own disk cache',
);
now += 23 * 3600_000;
assert.equal(
(await request('/api/celestrak-satcat', '/visual')).headers[
'x-satcat-cache'
],
'HIT',
'a day-long cache, unlike the 6 h TLEs',
);
now += 2 * 3600_000;
t.mock.method(
globalThis,
'fetch',
async () => new Response('<html>busy</html>'),
);
const stale = await request('/api/celestrak-satcat', '/visual');
assert.equal(stale.body, csv, 'an error page never replaces good records');
assert.equal(stale.headers['x-satcat-cache'], 'STALE-ERROR');
});
test('CelesTrak admits a same-origin request behind the documented reverse proxy and refuses foreign origins', async (t) => {
isolateDisk(t);
let calls = 0;