mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 14:22:21 +00:00
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:
1 parent
1b9bb844e6
commit
c56f098d07
44 files changed
+2530
-362
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
@@ -1,23 +1,23 @@
|
||||
/**
|
||||
* Satellite classification — CelesTrak source group → operator-legible class.
|
||||
* Satellite labels and colours by CelesTrak source group, beneath the six
|
||||
* classes.
|
||||
*
|
||||
* The catalog already tags every satellite with the CelesTrak group it was
|
||||
* ingested from (`CATALOG_GROUPS` in satellites.js), so classification is a
|
||||
* pure lookup: no extra fetch, no heuristics, no per-frame work. This module is
|
||||
* the single source of truth for the class a satellite belongs to, the label
|
||||
* shown on its card, and the point color it renders in — the render path, the
|
||||
* tracked card, and the row legend all read the same table, so a palette edit
|
||||
* can never leave one surface disagreeing with another.
|
||||
* The class a satellite is filed under (STATIONS, IMAGING, TELECOM,
|
||||
* STARLINK, PROBES or DEBRIS) is decided in src/layers/satellites/classes.js
|
||||
* from SATCAT, the UCS database, names and the group, and that module owns
|
||||
* each class's colour, legend label and toggle. This module keeps the
|
||||
* group-based layer: the constellation subtype a card adds to the class
|
||||
* (`TELECOM · GPS`, `STATIONS · ISS`; satelliteClassText), the group's own
|
||||
* label for a satellite with no class (satelliteClassLabel), and the colour
|
||||
* per group (satelliteClassColor), which the layer's base point styles use
|
||||
* where a satellite has no class (src/layers/satellites/policy.js).
|
||||
*
|
||||
* Deliberately Cesium-free: colors are CSS hex strings that satellites.js
|
||||
* converts once at module load. That keeps this file unit-testable and lets the
|
||||
* legend swatches reuse the exact same strings the points are drawn with.
|
||||
*
|
||||
* Class order below is the legend order: crewed first (the objects people look
|
||||
* for), then the constellations users can actually name, then the grab-bag.
|
||||
* Deliberately Cesium-free: colors are CSS hex strings that the layer
|
||||
* converts once at module load, so this file stays unit-testable.
|
||||
*/
|
||||
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { SATELLITE_CLASS_DISPLAY } from '../layers/satellites/classes.js';
|
||||
|
||||
/**
|
||||
* Class registry: the point color of each class. The card and legend text is
|
||||
@@ -167,50 +167,25 @@ export function satelliteClassLabel(
|
||||
}
|
||||
|
||||
/**
|
||||
* Tally satellites into a class → count record.
|
||||
* Split from the legend builder so the counting rule stays independent of how
|
||||
* the legend is presented. Entries may be bare group tags, or `{ group, isIss }`
|
||||
* descriptors when the caller can identify the ISS — the tally classifies
|
||||
* through satelliteClassOf, so the legend and the card always agree.
|
||||
* @param {Iterable<string|undefined|null|{group?: string, isIss?: boolean}>} entries
|
||||
* One entry per satellite.
|
||||
* @returns {Record<string, number>} Class key → count.
|
||||
* The class a satellite shows on its card, label, overlay and orbital
|
||||
* readout: its class (src/layers/satellites/classes.js) with the CelesTrak group's
|
||||
* subtype where one says more ("TELECOM · GPS", "STATIONS · ISS"). A subtype
|
||||
* that only repeats the class ("STARLINK · STARLINK") is dropped. Without a
|
||||
* class the group's label stands (satelliteClassLabel).
|
||||
* @param {string|null|undefined} satelliteClass Six-class id.
|
||||
* @param {string|undefined|null} group CelesTrak group tag.
|
||||
* @param {{ isIss?: boolean, localize?: boolean }} [options] As for
|
||||
* satelliteClassLabel.
|
||||
* @returns {string} Display label.
|
||||
*/
|
||||
export function tallySatelliteClasses(entries) {
|
||||
const counts = Object.create(null);
|
||||
for (const entry of entries || []) {
|
||||
const descriptor =
|
||||
entry && typeof entry === 'object' ? entry : { group: entry };
|
||||
const { klass } = satelliteClassOf(descriptor.group, {
|
||||
isIss: descriptor.isIss,
|
||||
});
|
||||
counts[klass] = (counts[klass] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the layer-row legend from a class tally.
|
||||
* Classes with no members are omitted so the legend never advertises a class
|
||||
* that is not on screen (COMMS only appears once DENSE is on).
|
||||
* @param {Record<string, number>} counts Class key → count (see tallySatelliteClasses).
|
||||
* @returns {Array<{ klass: string, label: string, color: string, blurb: string, count: number }>}
|
||||
* Present classes in legend order.
|
||||
*/
|
||||
export function satelliteClassLegend(counts) {
|
||||
const result = [];
|
||||
for (const klass of SATELLITE_CLASS_ORDER) {
|
||||
const count = counts?.[klass];
|
||||
if (!(count > 0)) continue;
|
||||
const spec = SATELLITE_CLASSES[klass];
|
||||
const display = CLASS_DISPLAY_KEYS[klass];
|
||||
result.push({
|
||||
klass,
|
||||
label: t(display.label),
|
||||
color: spec.color,
|
||||
blurb: t(display.blurb),
|
||||
count,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
export function satelliteClassText(
|
||||
satelliteClass,
|
||||
group,
|
||||
{ isIss = false, localize = true } = {},
|
||||
) {
|
||||
const display = SATELLITE_CLASS_DISPLAY[satelliteClass];
|
||||
if (!display) return satelliteClassLabel(group, { isIss, localize });
|
||||
const base = localize ? t(display.label) : display.label;
|
||||
const { subtype } = satelliteClassOf(group, { isIss });
|
||||
return subtype && subtype !== display.label ? `${base} · ${subtype}` : base;
|
||||
}
|
||||
@@ -5,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',
|
||||
);
|
||||
});
|
||||
@@ -6,6 +6,14 @@ export function celestrakTleUrl(group) {
|
||||
return url;
|
||||
}
|
||||
|
||||
/** CelesTrak SATCAT records (object type, status, owner) for a GP group, as CSV. */
|
||||
export function celestrakSatcatUrl(group) {
|
||||
const url = new URL('https://celestrak.org/satcat/records.php');
|
||||
url.searchParams.set('GROUP', group);
|
||||
url.searchParams.set('FORMAT', 'CSV');
|
||||
return url;
|
||||
}
|
||||
|
||||
export function launchLibraryRecentUrl(end) {
|
||||
// Stable minute boundaries let different installations share LL2's cache.
|
||||
const roundedEnd = new Date(Math.floor(end.getTime() / 60000) * 60000);
|
||||
|
||||
@@ -0,0 +1,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)),
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Vendored
+190
-74
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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, '');
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -164,3 +164,24 @@ test('satellite pass prediction methods report no-tle on cold catalog and coerce
|
||||
{ status: 'no-tle' },
|
||||
);
|
||||
});
|
||||
|
||||
test('a SATCAT read says when the proxy served its last copy', async () => {
|
||||
const headers = (cache) => ({
|
||||
get: (name) => (name === 'x-satcat-cache' ? cache : null),
|
||||
});
|
||||
let cache = 'STALE-ERROR';
|
||||
const source = createSatelliteSource({
|
||||
fetchImpl: async (url) => {
|
||||
assert.equal(url, '/api/celestrak-satcat/visual');
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: headers(cache),
|
||||
text: async () => 'OBJECT_NAME,NORAD_CAT_ID,OBJECT_TYPE',
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal((await source.readSatcat('visual')).stale, true);
|
||||
cache = 'HIT';
|
||||
assert.equal((await source.readSatcat('visual')).stale, false);
|
||||
});
|
||||
@@ -24,7 +24,15 @@ const GROUPS = [
|
||||
'galileo',
|
||||
'geo',
|
||||
'starlink',
|
||||
// Added after Starlink so the fixture ids above keep their numbers.
|
||||
'resource',
|
||||
'weather',
|
||||
'planet',
|
||||
'sar',
|
||||
'science',
|
||||
];
|
||||
/** The core catalog's groups, one owned satellite each. */
|
||||
const CORE = GROUPS.filter((group) => group !== 'starlink');
|
||||
|
||||
function tle(id, invalid = false) {
|
||||
// Owned synthetic catalog IDs over one valid element set; never live requests.
|
||||
@@ -143,10 +151,10 @@ test('stale core records surface through actual HTTP, ingestion and feed state,
|
||||
const f = await fixture(t);
|
||||
await f.update();
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
assert.equal(f.stats().count, 6);
|
||||
f.policy.stale = new Set(GROUPS.slice(0, 6));
|
||||
assert.equal(f.stats().count, CORE.length);
|
||||
f.policy.stale = new Set(CORE);
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.stats().count, CORE.length);
|
||||
assert.equal(f.stats().stale, true);
|
||||
assert.equal(f.feed(), 'stale');
|
||||
f.policy.stale = new Set(['visual']);
|
||||
@@ -163,13 +171,13 @@ test('a stale duplicate cannot taint its earlier fresh winning record', async (t
|
||||
f.policy.stale.add('visual');
|
||||
f.policy.ids.visual = 25545;
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 5);
|
||||
assert.equal(f.stats().count, CORE.length - 1);
|
||||
assert.equal(f.state._catalog.get(25545).group, 'stations');
|
||||
assert.equal(f.stats().stale, false);
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
f.policy.stale = new Set(['stations']);
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 5);
|
||||
assert.equal(f.stats().count, CORE.length - 1);
|
||||
assert.equal(
|
||||
f.stats().stale,
|
||||
true,
|
||||
@@ -182,7 +190,7 @@ test('invalid stale orbital elements are not admitted as provenance', async (t)
|
||||
f.policy.stale.add('visual');
|
||||
f.policy.invalid.add('visual');
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 5);
|
||||
assert.equal(f.stats().count, CORE.length - 1);
|
||||
assert.equal(f.state._catalog.has(25546), false);
|
||||
assert.equal(f.stats().stale, false);
|
||||
});
|
||||
@@ -194,17 +202,17 @@ test('failed and aborted core refreshes keep provenance of retained records', as
|
||||
const priorTime = f.stats().lastUpdate;
|
||||
f.policy.status = 503;
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.stats().count, CORE.length);
|
||||
assert.equal(f.stats().stale, true);
|
||||
assert.equal(f.stats().status, 'unavailable');
|
||||
assert.equal(f.stats().lastUpdate, priorTime);
|
||||
f.policy.hold = true;
|
||||
const controller = new AbortController();
|
||||
const work = f.update({ signal: controller.signal });
|
||||
await f.waitHeld(6);
|
||||
await f.waitHeld(CORE.length);
|
||||
controller.abort();
|
||||
await assert.rejects(work, { name: 'AbortError' });
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.stats().count, CORE.length);
|
||||
assert.equal(f.stats().stale, true);
|
||||
assert.equal(f.stats().lastUpdate, priorTime);
|
||||
assert.equal(f.state._activeUpdateControllers.size, 0);
|
||||
@@ -216,11 +224,11 @@ test('dense provenance belongs to its admitted records and clears on removal', a
|
||||
f.policy.stale.add('starlink');
|
||||
f.state._params.catalog = 'dense';
|
||||
assert.equal((await f.parts.catalog._loadDenseCatalog()).status, 'ready');
|
||||
assert.equal(f.stats().count, 7);
|
||||
assert.equal(f.stats().count, CORE.length + 1);
|
||||
assert.equal(f.feed(), 'stale');
|
||||
f.state._params.catalog = 'core';
|
||||
f.parts.catalog._removeDenseCatalog();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.stats().count, CORE.length);
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
f.policy.stale = new Set(['stations']);
|
||||
await f.update();
|
||||
@@ -261,13 +269,13 @@ test('cancelling dense replacement preserves admitted stale extras until rebuild
|
||||
await f.waitHeld(1);
|
||||
controller.abort();
|
||||
assert.equal((await work).status, 'cancelled');
|
||||
assert.equal(f.stats().count, 7);
|
||||
assert.equal(f.stats().count, CORE.length + 1);
|
||||
assert.equal(f.feed(), 'stale');
|
||||
f.policy.hold = false;
|
||||
f.policy.stale.clear();
|
||||
f.state._params.catalog = 'core';
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.stats().count, CORE.length);
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
});
|
||||
|
||||
@@ -277,7 +285,7 @@ test('destroy aborts an in-flight response and initialization starts with new ow
|
||||
await f.update();
|
||||
f.policy.hold = true;
|
||||
const work = f.update();
|
||||
await f.waitHeld(6);
|
||||
await f.waitHeld(CORE.length);
|
||||
const rejected = assert.rejects(work, { name: 'AbortError' });
|
||||
f.parts.lifecycle.methods.destroy(f.viewer);
|
||||
assert.equal(f.stats().count, 0);
|
||||
@@ -288,6 +296,6 @@ test('destroy aborts an in-flight response and initialization starts with new ow
|
||||
f.policy.hold = false;
|
||||
f.policy.stale.clear();
|
||||
await f.update();
|
||||
assert.equal(f.stats().count, 6);
|
||||
assert.equal(f.stats().count, CORE.length);
|
||||
assert.equal(f.feed(), 'nominal');
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { DEFAULT_SATELLITE_CLASSES } from './classes.js';
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user