mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 15:22:21 +00:00
Merge upstream main into pr/street-level-show-action
Locale conflicts: main's catalogs plus the PR's 10 street-level strings.
This commit is contained in:
commit
7efcf37c62
55 files changed
+2773
-393
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 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'),
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Local, not i18n/marker.js: the token-ledger tests run this file in isolation.
|
||||
const msg = (text) => text;
|
||||
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
|
||||
import { normalizeSatelliteClassMask } from './satelliteClassMask.js';
|
||||
import reservationRows from './layerStateTokenReservations.json' with { type: 'json' };
|
||||
|
||||
const VALID_DISPOSITIONS = new Set([
|
||||
@@ -416,6 +417,16 @@ const OPTION_GROUPS = Object.freeze({
|
||||
dense: 'd',
|
||||
}),
|
||||
integerOption('selectedSatTrackingId', 't', null),
|
||||
// Every class but Starlink shows by default (satellites/classes.js).
|
||||
Object.freeze({
|
||||
key: 'classes',
|
||||
token: 'k',
|
||||
defaultValue: 'sitpd',
|
||||
normalize: normalizeSatelliteClassMask,
|
||||
encode: (value) => value,
|
||||
decode: (value) =>
|
||||
typeof value === 'string' ? normalizeSatelliteClassMask(value) : null,
|
||||
}),
|
||||
]),
|
||||
cctv: Object.freeze([
|
||||
enumOption('coverageMode', 'c', 'on', ['off', 'on', 'viewshed'], {
|
||||
|
||||
@@ -970,6 +970,41 @@ test('a direct Context selection promotes its owned tracker layer before persist
|
||||
coordinator.destroy();
|
||||
});
|
||||
|
||||
test('a satellite class choice is saved, shared and restored', async () => {
|
||||
const storage = memoryStorage();
|
||||
const manager = productionManager();
|
||||
const coordinator = new LayerStateCoordinator(manager, shareSink(), {
|
||||
storage,
|
||||
});
|
||||
await coordinator.start();
|
||||
await manager.setEnabled('satellites', true, { origin: 'user' });
|
||||
assert.equal(
|
||||
coordinator.getDurableState().options.satellites.classes,
|
||||
'sitpd',
|
||||
'every class but Starlink by default',
|
||||
);
|
||||
manager.setLayerParams(
|
||||
'satellites',
|
||||
{ classes: { debris: false } },
|
||||
{ origin: 'user' },
|
||||
);
|
||||
const durable = coordinator.getDurableState();
|
||||
assert.equal(durable.options.satellites.classes, 'sitp');
|
||||
assert.equal(
|
||||
new URLSearchParams(encode(durable)).get('lo')?.includes('s.k.sitp'),
|
||||
true,
|
||||
);
|
||||
coordinator.destroy();
|
||||
|
||||
// A reload hands the layer its letters back (the layer decodes them:
|
||||
// src/data/satelliteClass.test.mjs).
|
||||
const reloaded = productionManager();
|
||||
const next = new LayerStateCoordinator(reloaded, shareSink(), { storage });
|
||||
await next.start();
|
||||
assert.equal(reloaded.getLayerParams('satellites')?.classes, 'sitp');
|
||||
next.destroy();
|
||||
});
|
||||
|
||||
test('explicit Flight to Satellite replacement keeps the new Satellite ID durable', async () => {
|
||||
const manager = productionManager();
|
||||
const coordinator = new LayerStateCoordinator(manager, shareSink(), {
|
||||
|
||||
@@ -102,12 +102,13 @@ const preWildlifeRows = reservationRows.filter(
|
||||
);
|
||||
|
||||
function writeCodecFixture(cwd, rows, entries) {
|
||||
const providerCodec = path.join(cwd, 'src/data/idSwitches.js');
|
||||
mkdirSync(path.dirname(providerCodec), { recursive: true });
|
||||
writeFileSync(
|
||||
providerCodec,
|
||||
readFileSync(new URL('./idSwitches.js', import.meta.url)),
|
||||
);
|
||||
mkdirSync(path.join(cwd, 'src/data'), { recursive: true });
|
||||
// The codec's own imports, beside it.
|
||||
for (const name of ['idSwitches.js', 'satelliteClassMask.js'])
|
||||
writeFileSync(
|
||||
path.join(cwd, 'src/data', name),
|
||||
readFileSync(new URL(`./${name}`, import.meta.url)),
|
||||
);
|
||||
let source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
|
||||
if (!rows.some(([id]) => id === 'wildlife-sightings'))
|
||||
source = source.replace(
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# UCS Satellite Database pack
|
||||
|
||||
Offline purpose records for active satellites, keyed by NORAD catalog number.
|
||||
The satellites layer joins them to CelesTrak's positions to file each
|
||||
satellite under a class (stations, imaging, telecom, probes)
|
||||
(`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 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 column: Purpose, the one the classes read. Everything else
|
||||
(operators, owners, orbits, masses, contractors, launch data, sources) is
|
||||
left out.
|
||||
- Repeated text is stored once in `strings`; each record is
|
||||
`[noradId, purpose]` with a string-table index, `0` meaning empty.
|
||||
- The 3,996 Starlink rows are dropped: every Starlink object is classed by
|
||||
name, so they would only add size.
|
||||
- Rows without a readable, positive NORAD number are dropped and counted apart
|
||||
from duplicate NORAD numbers (the first row of a duplicate is kept); both
|
||||
counts are 0 in this release.
|
||||
@@ -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":{"purpose":"Purpose"}},"recordCount":3564},"fields":["noradId","purpose"],"strings":{"purpose":["","Earth Observation","Technology Development","Communications","Earth Science","Space Science","Space Science/Technology Demonstration","Communications/Technology Development","Communications/Maritime Tracking","Technology Demonstration","Unknown","Navigation/Global Positioning","Earth Observation/Technology Development","Earth Observation/Communications","Earth/Space Observation","Educational","Earth Observation/Earth Science","Platform","Earth Observation/Space Science","Earth Observation/Navigation","Communications/Navigation","Space Observation","Surveillance","Navigation/Regional Positioning","Space Science/Technology Development","Mission Extension Technology","Earth Science/Earth Observation","Earth Observation/Communications/Space Science","Meteorological","Technology Development/Educational","Satellite Positioning"]},"records":[[7530,3],[19548,3],[20253,3],[20580,5],[20776,3],[21639,3],[22049,5],[22314,3],[22491,1],[22787,3],[22988,3],[23223,1],[23467,3],[23553,3],[23613,3],[23628,3],[23855,1],[23893,3],[24674,3],[24753,1],[24812,3],[24876,11],[24916,3],[24920,1],[24936,3],[25019,3],[25034,1],[25071,3],[25112,3],[25113,3],[25114,3],[25115,3],[25116,3],[25117,3],[25118,3],[25119,3],[25153,3],[25159,3],[25258,3],[25336,1],[25338,1],[25414,3],[25415,3],[25416,3],[25418,3],[25419,3],[25420,3],[25462,3],[25475,3],[25477,3],[25481,3],[25504,1],[25544,5],[25639,3],[25652,3],[25682,1],[25757,4],[25867,5],[25924,3],[25933,11],[25967,3],[25980,3],[25981,3],[25982,3],[25984,3],[25985,3],[25989,5],[25991,1],[25994,1],[26038,3],[26052,3],[26107,3],[26356,1],[26360,11],[26369,3],[26388,3],[26390,3],[26407,11],[26410,5],[26411,5],[26451,3],[26463,5],[26464,5],[26469,3],[26473,1],[26483,3],[26554,3],[26575,3],[26580,3],[26590,3],[26605,11],[26608,3],[26624,3],[26626,3],[26638,3],[26690,11],[26694,3],[26702,1],[26715,3],[26719,3],[26766,3],[26824,3],[26853,3],[26880,1],[26900,3],[26958,9],[26959,12],[26998,5],[27004,2],[27056,3],[27057,3],[27168,3],[27378,3],[27380,3],[27389,3],[27399,3],[27400,3],[27414,3],[27424,1],[27426,3],[27434,1],[27438,3],[27445,3],[27460,3],[27464,3],[27465,3],[27509,1],[27513,3],[27528,3],[27540,5],[27566,3],[27603,3],[27606,3],[27607,3],[27612,3],[27640,1],[27651,5],[27663,11],[27691,3],[27704,11],[27711,3],[27715,3],[27811,3],[27825,3],[27831,3],[27843,5],[27848,2],[27854,3],[27858,5],[27868,3],[27869,3],[27875,3],[27937,1],[27939,2],[27954,3],[28051,1],[28054,1],[28058,3],[28089,3],[28117,3],[28158,1],[28184,3],[28187,3],[28190,11],[28220,1],[28252,3],[28358,3],[28366,7],[28369,2],[28370,2],[28371,1],[28372,7],[28376,1],[28378,3],[28384,3],[28413,5],[28414,5],[28419,3],[28420,3],[28446,3],[28470,1],[28474,11],[28485,5],[28492,1],[28493,3],[28526,3],[28537,1],[28541,1],[28542,3],[28626,3],[28628,3],[28646,1],[28649,1],[28654,1],[28659,3],[28702,3],[28707,3],[28737,5],[28786,3],[28790,3],[28810,2],[28868,3],[28874,11],[28884,3],[28885,3],[28888,1],[28890,1],[28899,3],[28903,3],[28908,3],[28912,1],[28924,3],[28935,3],[28945,3],[28946,3],[29045,3],[29047,1],[29048,1],[29050,1],[29051,1],[29052,1],[29055,3],[29079,1],[29107,1],[29108,4],[29155,1],[29162,3],[29236,3],[29249,1],[29252,2],[29268,12],[29270,3],[29272,3],[29273,3],[29349,3],[29479,5],[29486,11],[29494,3],[29495,3],[29505,5],[29506,5],[29520,3],[29522,1],[29526,3],[29601,11],[29643,3],[29644,3],[29658,1],[29670,11],[29671,11],[29709,12],[29710,1],[30580,5],[30776,2],[30794,3],[30797,5],[30798,5],[31102,3],[31118,1],[31119,3],[31121,3],[31124,3],[31125,3],[31127,3],[31304,1],[31306,3],[31307,3],[31571,3],[31573,3],[31574,3],[31576,3],[31598,1],[31601,1],[31698,1],[31701,1],[31708,1],[31789,2],[31792,1],[31797,1],[31800,3],[31862,3],[32018,3],[32019,3],[32060,1],[32252,3],[32253,3],[32258,3],[32260,11],[32263,3],[32264,3],[32265,3],[32275,11],[32276,11],[32283,1],[32293,3],[32294,3],[32299,3],[32376,1],[32378,3],[32382,1],[32384,11],[32388,3],[32393,11],[32395,11],[32404,3],[32476,1],[32478,3],[32487,3],[32706,1],[32711,11],[32729,3],[32750,1],[32763,3],[32767,3],[32768,3],[32779,3],[32783,1],[32789,2],[32790,2],[32791,2],[32794,3],[32951,3],[32954,3],[32955,3],[32956,3],[33051,3],[33053,5],[33055,3],[33056,3],[33153,3],[33154,3],[33207,3],[33244,1],[33274,3],[33275,3],[33278,3],[33320,1],[33321,1],[33331,1],[33373,3],[33376,3],[33396,1],[33401,5],[33408,5],[33409,5],[33412,1],[33433,1],[33434,1],[33436,3],[33446,1],[33453,3],[33459,3],[33460,3],[33490,1],[33492,1],[33496,2],[33499,2],[33591,1],[33595,3],[33749,3],[33750,3],[33751,2],[337Line truncated
|
||||
+34
-59
@@ -1,23 +1,23 @@
|
||||
/**
|
||||
* Satellite classification — CelesTrak source group → operator-legible class.
|
||||
* Satellite labels and colours by CelesTrak source group, beneath the six
|
||||
* classes.
|
||||
*
|
||||
* The catalog already tags every satellite with the CelesTrak group it was
|
||||
* ingested from (`CATALOG_GROUPS` in satellites.js), so classification is a
|
||||
* pure lookup: no extra fetch, no heuristics, no per-frame work. This module is
|
||||
* the single source of truth for the class a satellite belongs to, the label
|
||||
* shown on its card, and the point color it renders in — the render path, the
|
||||
* tracked card, and the row legend all read the same table, so a palette edit
|
||||
* can never leave one surface disagreeing with another.
|
||||
* The class a satellite is filed under (STATIONS, IMAGING, TELECOM,
|
||||
* STARLINK, PROBES or DEBRIS) is decided in src/layers/satellites/classes.js
|
||||
* from SATCAT, the UCS database, names and the group, and that module owns
|
||||
* each class's colour, legend label and toggle. This module keeps the
|
||||
* group-based layer: the constellation subtype a card adds to the class
|
||||
* (`TELECOM · GPS`, `STATIONS · ISS`; satelliteClassText), the group's own
|
||||
* label for a satellite with no class (satelliteClassLabel), and the colour
|
||||
* per group (satelliteClassColor), which the layer's base point styles use
|
||||
* where a satellite has no class (src/layers/satellites/policy.js).
|
||||
*
|
||||
* Deliberately Cesium-free: colors are CSS hex strings that satellites.js
|
||||
* converts once at module load. That keeps this file unit-testable and lets the
|
||||
* legend swatches reuse the exact same strings the points are drawn with.
|
||||
*
|
||||
* Class order below is the legend order: crewed first (the objects people look
|
||||
* for), then the constellations users can actually name, then the grab-bag.
|
||||
* Deliberately Cesium-free: colors are CSS hex strings that the layer
|
||||
* converts once at module load, so this file stays unit-testable.
|
||||
*/
|
||||
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { SATELLITE_CLASS_DISPLAY } from '../layers/satellites/classes.js';
|
||||
|
||||
/**
|
||||
* Class registry: the point color of each class. The card and legend text is
|
||||
@@ -167,50 +167,25 @@ export function satelliteClassLabel(
|
||||
}
|
||||
|
||||
/**
|
||||
* Tally satellites into a class → count record.
|
||||
* Split from the legend builder so the counting rule stays independent of how
|
||||
* the legend is presented. Entries may be bare group tags, or `{ group, isIss }`
|
||||
* descriptors when the caller can identify the ISS — the tally classifies
|
||||
* through satelliteClassOf, so the legend and the card always agree.
|
||||
* @param {Iterable<string|undefined|null|{group?: string, isIss?: boolean}>} entries
|
||||
* One entry per satellite.
|
||||
* @returns {Record<string, number>} Class key → count.
|
||||
* The class a satellite shows on its card, label, overlay and orbital
|
||||
* readout: its class (src/layers/satellites/classes.js) with the CelesTrak group's
|
||||
* subtype where one says more ("TELECOM · GPS", "STATIONS · ISS"). A subtype
|
||||
* that only repeats the class ("STARLINK · STARLINK") is dropped. Without a
|
||||
* class the group's label stands (satelliteClassLabel).
|
||||
* @param {string|null|undefined} satelliteClass Six-class id.
|
||||
* @param {string|undefined|null} group CelesTrak group tag.
|
||||
* @param {{ isIss?: boolean, localize?: boolean }} [options] As for
|
||||
* satelliteClassLabel.
|
||||
* @returns {string} Display label.
|
||||
*/
|
||||
export function tallySatelliteClasses(entries) {
|
||||
const counts = Object.create(null);
|
||||
for (const entry of entries || []) {
|
||||
const descriptor =
|
||||
entry && typeof entry === 'object' ? entry : { group: entry };
|
||||
const { klass } = satelliteClassOf(descriptor.group, {
|
||||
isIss: descriptor.isIss,
|
||||
});
|
||||
counts[klass] = (counts[klass] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the layer-row legend from a class tally.
|
||||
* Classes with no members are omitted so the legend never advertises a class
|
||||
* that is not on screen (COMMS only appears once DENSE is on).
|
||||
* @param {Record<string, number>} counts Class key → count (see tallySatelliteClasses).
|
||||
* @returns {Array<{ klass: string, label: string, color: string, blurb: string, count: number }>}
|
||||
* Present classes in legend order.
|
||||
*/
|
||||
export function satelliteClassLegend(counts) {
|
||||
const result = [];
|
||||
for (const klass of SATELLITE_CLASS_ORDER) {
|
||||
const count = counts?.[klass];
|
||||
if (!(count > 0)) continue;
|
||||
const spec = SATELLITE_CLASSES[klass];
|
||||
const display = CLASS_DISPLAY_KEYS[klass];
|
||||
result.push({
|
||||
klass,
|
||||
label: t(display.label),
|
||||
color: spec.color,
|
||||
blurb: t(display.blurb),
|
||||
count,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
export function satelliteClassText(
|
||||
satelliteClass,
|
||||
group,
|
||||
{ isIss = false, localize = true } = {},
|
||||
) {
|
||||
const display = SATELLITE_CLASS_DISPLAY[satelliteClass];
|
||||
if (!display) return satelliteClassLabel(group, { isIss, localize });
|
||||
const base = localize ? t(display.label) : display.label;
|
||||
const { subtype } = satelliteClassOf(group, { isIss });
|
||||
return subtype && subtype !== display.label ? `${base} · ${subtype}` : base;
|
||||
}
|
||||
@@ -5,15 +5,15 @@ import {
|
||||
SATELLITE_CLASS_ORDER,
|
||||
satelliteClassColor,
|
||||
satelliteClassLabel,
|
||||
satelliteClassLegend,
|
||||
satelliteClassOf,
|
||||
tallySatelliteClasses,
|
||||
satelliteClassText,
|
||||
} from './satelliteClass.js';
|
||||
import satellitesLayer, {
|
||||
_catalogGroupForTest,
|
||||
_clearDenseCatalogStateForTest,
|
||||
_setDenseCatalogStateForTest,
|
||||
} from './satellites.js';
|
||||
import { CATALOG_GROUPS } from '../layers/satellites/policy.js';
|
||||
|
||||
/**
|
||||
* A real, parseable TLE under an arbitrary 5-digit catalog number, so the dense
|
||||
@@ -35,15 +35,19 @@ const ISS_TLE = tleFor('25544', 'ISS (ZARYA)');
|
||||
/** Poll the chip until the async dense load settles (or give up). */
|
||||
async function settleChip(maxTicks = 50) {
|
||||
for (let i = 0; i < maxTicks; i++) {
|
||||
const chip = satellitesLayer.getRowControls().chips[0];
|
||||
const chip = starlinkChip();
|
||||
if (chip && !chip.busy) return chip;
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
}
|
||||
return satellitesLayer.getRowControls().chips[0];
|
||||
return starlinkChip();
|
||||
}
|
||||
|
||||
/** The STARLINK class chip: the dense catalog toggle (id `catalog`). */
|
||||
const starlinkChip = () =>
|
||||
satellitesLayer.getRowControls().chips.find((entry) => entry.id === 'catalog');
|
||||
|
||||
/** Every CelesTrak group the catalog ingests, plus the dense-mode tag. */
|
||||
const INGESTED_GROUPS = ['stations', 'visual', 'gps-ops', 'glonass', 'galileo', 'geo', 'dense'];
|
||||
const INGESTED_GROUPS = [...CATALOG_GROUPS.map((group) => group.tag), 'dense'];
|
||||
|
||||
test('every ingested CelesTrak group resolves to a real class', () => {
|
||||
// Guards against drift: adding a group to CATALOG_GROUPS without classifying
|
||||
@@ -146,69 +150,7 @@ test('the dense shell stays dimmer than the class it sits among', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('tallying groups counts by class, folding the GNSS constellations together', () => {
|
||||
const counts = tallySatelliteClasses([
|
||||
'gps-ops', 'gps-ops', 'glonass', 'galileo',
|
||||
'geo', 'geo', 'geo',
|
||||
'stations',
|
||||
'visual',
|
||||
'dense', 'dense',
|
||||
]);
|
||||
assert.equal(counts.nav, 4, 'all three GNSS groups fold into NAV');
|
||||
assert.equal(counts.geo, 3);
|
||||
assert.equal(counts.station, 1);
|
||||
assert.equal(counts.visual, 1);
|
||||
assert.equal(counts.comms, 2);
|
||||
});
|
||||
|
||||
test('tallying tolerates an empty or missing input', () => {
|
||||
assert.deepEqual({ ...tallySatelliteClasses([]) }, {});
|
||||
assert.deepEqual({ ...tallySatelliteClasses(undefined) }, {});
|
||||
});
|
||||
|
||||
test('the tally files the ISS under STATION even when a partial outage moved it', () => {
|
||||
// The legend and the card must never disagree. With the stations feed down,
|
||||
// the ISS is ingested as `visual`; counting it there would let STATION vanish
|
||||
// from the legend while the tracked card still reads "STATION · ISS".
|
||||
const outage = tallySatelliteClasses([
|
||||
{ group: 'visual', isIss: true },
|
||||
{ group: 'visual' },
|
||||
{ group: 'visual' },
|
||||
]);
|
||||
assert.equal(outage.station, 1, 'the ISS is counted as a STATION');
|
||||
assert.equal(outage.visual, 2, 'and is not double-counted under VISUAL');
|
||||
|
||||
const legend = satelliteClassLegend(outage);
|
||||
assert.equal(legend.some((row) => row.klass === 'station'), true,
|
||||
'STATION stays in the legend, matching the card');
|
||||
|
||||
// Bare group tags still work — the descriptor form is additive.
|
||||
assert.deepEqual({ ...tallySatelliteClasses(['visual', 'visual']) }, { visual: 2 });
|
||||
assert.equal(satelliteClassOf('visual', { isIss: true }).klass, 'station',
|
||||
'one helper owns the rule for every consumer');
|
||||
});
|
||||
|
||||
test('the legend lists present classes in order and omits absent ones', () => {
|
||||
const legend = satelliteClassLegend(tallySatelliteClasses(['geo', 'gps-ops', 'stations']));
|
||||
assert.deepEqual(legend.map((row) => row.klass), ['station', 'nav', 'geo'],
|
||||
'legend follows SATELLITE_CLASS_ORDER, not input order');
|
||||
// COMMS only exists in DENSE mode — the legend must not advertise a class
|
||||
// that has nothing on screen.
|
||||
assert.equal(legend.some((row) => row.klass === 'comms'), false);
|
||||
|
||||
const row = legend.find((entry) => entry.klass === 'nav');
|
||||
assert.equal(row.label, 'NAV');
|
||||
assert.equal(row.color, SATELLITE_CLASSES.nav.color);
|
||||
assert.equal(row.count, 1);
|
||||
assert.ok(row.blurb.length > 0, 'every legend row carries a plain-language gloss');
|
||||
});
|
||||
|
||||
test('the legend drops zero and negative counts', () => {
|
||||
assert.deepEqual(satelliteClassLegend({ nav: 0, geo: -1, station: 2 }).map((r) => r.klass), ['station']);
|
||||
assert.deepEqual(satelliteClassLegend(null), []);
|
||||
});
|
||||
|
||||
test('the DENSE row chip is stateless and declares the params to apply', () => {
|
||||
test('the STARLINK row chip is stateless and declares the params to apply', () => {
|
||||
// The chip never carries its own state: it declares the params to apply, so
|
||||
// whatever else drives the catalog param (Space Missions capture/restore)
|
||||
// stays authoritative. Whether it reads ACTIVE is decided by the dense LOAD,
|
||||
@@ -217,7 +159,7 @@ test('the DENSE row chip is stateless and declares the params to apply', () => {
|
||||
_setDenseCatalogStateForTest({});
|
||||
const chip = satellitesLayer.getRowControls().chips.find((entry) => entry.id === 'catalog');
|
||||
assert.equal(chip.id, 'catalog');
|
||||
assert.equal(chip.label, 'DENSE');
|
||||
assert.equal(chip.label, 'STARLINK');
|
||||
assert.equal(chip.active, false);
|
||||
assert.equal(chip.busy, false);
|
||||
assert.equal(chip.state, 'idle');
|
||||
@@ -236,7 +178,7 @@ test('an invalid catalog mode is rejected without disturbing the chip', () => {
|
||||
assert.equal(chip.active, false);
|
||||
});
|
||||
|
||||
test('DENSE reports loading, then ACTIVE only once the points exist', async () => {
|
||||
test('STARLINK reports loading, then ACTIVE only once the points exist', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const refreshes = [];
|
||||
try {
|
||||
@@ -247,7 +189,7 @@ test('DENSE reports loading, then ACTIVE only once the points exist', async () =
|
||||
satellitesLayer.setParams({ catalog: 'dense' });
|
||||
// The param flips synchronously, but the shell has not arrived yet — the
|
||||
// chip must say BUSY, never ACTIVE.
|
||||
const pending = satellitesLayer.getRowControls().chips[0];
|
||||
const pending = starlinkChip();
|
||||
assert.equal(pending.busy, true);
|
||||
assert.equal(pending.disabled, true);
|
||||
assert.equal(pending.active, false, 'never active before the points exist');
|
||||
@@ -256,21 +198,21 @@ test('DENSE reports loading, then ACTIVE only once the points exist', async () =
|
||||
const settled = await settleChip();
|
||||
assert.equal(settled.active, true, 'active once the dense points are on screen');
|
||||
assert.equal(settled.state, 'active');
|
||||
assert.equal(settled.label, 'DENSE');
|
||||
assert.equal(settled.label, 'STARLINK');
|
||||
assert.deepEqual(settled.params, { catalog: 'core' }, 'a settled chip toggles back off');
|
||||
|
||||
// The completion pushed a refresh, and the legend gained COMMS with it —
|
||||
// The completion pushed a refresh, and the legend gained STARLINK with it —
|
||||
// without that push the row would keep the pre-load counts for 5 minutes.
|
||||
assert.ok(refreshes.length >= 2, 'load start and load completion each pushed a refresh');
|
||||
const legend = satellitesLayer.getRowControls().legend;
|
||||
assert.equal(legend.some((row) => row.klass === 'comms'), true);
|
||||
assert.equal(legend.some((row) => row.klass === 'starlink'), true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
_clearDenseCatalogStateForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test('a failed DENSE load reverts the mode rather than leaving an active chip', async () => {
|
||||
test('a failed STARLINK load reverts the mode rather than leaving an active chip', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const warn = console.warn;
|
||||
console.warn = () => {};
|
||||
@@ -285,14 +227,14 @@ test('a failed DENSE load reverts the mode rather than leaving an active chip',
|
||||
|
||||
assert.equal(settled.active, false, 'a 502 must never present as a live dense catalog');
|
||||
assert.equal(settled.state, 'error');
|
||||
assert.equal(settled.label, 'DENSE ✕');
|
||||
assert.equal(settled.label, 'STARLINK ✕');
|
||||
assert.match(settled.title, /502/, 'the chip explains why');
|
||||
assert.equal(satellitesLayer.getParams().catalog, 'core', 'the mode reverts to reality');
|
||||
assert.deepEqual(settled.params, { catalog: 'dense' }, 'clicking retries');
|
||||
assert.equal(
|
||||
satellitesLayer.getRowControls().legend.some((row) => row.klass === 'comms'),
|
||||
satellitesLayer.getRowControls().legend.some((row) => row.klass === 'starlink'),
|
||||
false,
|
||||
'no COMMS class is advertised when nothing loaded',
|
||||
'no STARLINK class is advertised when nothing loaded',
|
||||
);
|
||||
assert.ok(pushes >= 2, 'the failure pushed its own refresh');
|
||||
|
||||
@@ -365,9 +307,9 @@ test('an explicit return to core clears a failure the user never caused', async
|
||||
|
||||
// A no-op restore of the pre-mission snapshot.
|
||||
satellitesLayer.setParams({ catalog: 'core', showPoints: true, showOrbits: true });
|
||||
const restored = satellitesLayer.getRowControls().chips[0];
|
||||
const restored = starlinkChip();
|
||||
assert.equal(restored.state, 'idle', 'the error does not survive the restore');
|
||||
assert.equal(restored.label, 'DENSE');
|
||||
assert.equal(restored.label, 'STARLINK');
|
||||
assert.doesNotMatch(restored.title, /502/);
|
||||
} finally {
|
||||
console.warn = warn;
|
||||
@@ -376,7 +318,7 @@ test('an explicit return to core clears a failure the user never caused', async
|
||||
}
|
||||
});
|
||||
|
||||
test('a real stations-feed outage keeps STATION in the legend, matching the card', async () => {
|
||||
test('a real stations-feed outage keeps the ISS under STATIONS in the legend', async () => {
|
||||
// End-to-end version of the tally rule: drive a real rebuild in which only
|
||||
// the `visual` group answers, so the ISS is genuinely ingested as `visual`.
|
||||
// The legend must still file it under STATION or it disagrees with the card.
|
||||
@@ -398,11 +340,11 @@ test('a real stations-feed outage keeps STATION in the legend, matching the card
|
||||
'the outage really did ingest the ISS from the visual group');
|
||||
|
||||
const legend = satellitesLayer.getRowControls().legend;
|
||||
assert.equal(legend.find((row) => row.klass === 'station')?.count, 1,
|
||||
'STATION survives the outage because the ISS is counted there');
|
||||
assert.equal(legend.some((row) => row.klass === 'visual'), false,
|
||||
'and it is not also counted under VISUAL');
|
||||
assert.equal(satelliteClassLabel('visual', { isIss: true }), 'STATION · ISS',
|
||||
assert.equal(legend.find((row) => row.klass === 'stations')?.count, 1,
|
||||
'STATIONS survives the outage because the ISS is counted there');
|
||||
assert.equal(legend.length, 1, 'and it is not also counted under another class');
|
||||
const [iss] = satellitesLayer.getAnalystRecords();
|
||||
assert.equal(iss?.satelliteClass, 'STATIONS · ISS',
|
||||
'the card says the same thing the legend does');
|
||||
} finally {
|
||||
console.log = log;
|
||||
@@ -431,15 +373,21 @@ test('a catalog rebuild refreshes the detection overlay class strings', async ()
|
||||
text: async () => (String(url).endsWith(`/${homeGroup}`) ? ALT_TLE : ''),
|
||||
});
|
||||
|
||||
// Every class on: the seeded satellite's class must not hide it.
|
||||
satellitesLayer.setParams({
|
||||
classes: { telecom: true, probes: true, debris: true },
|
||||
});
|
||||
await satellitesLayer.update(viewer);
|
||||
const first = satellitesLayer.getDetectableObjects().find((o) => o.sourceId === 33333);
|
||||
assert.ok(first, 'the seeded satellite is collectable');
|
||||
assert.equal(first.klass, 'GEO');
|
||||
// A payload with no SATCAT record and no recognisable name takes its
|
||||
// group's class (src/layers/satellites/classes.js).
|
||||
assert.equal(first.klass, 'TELECOM');
|
||||
|
||||
homeGroup = 'visual';
|
||||
homeGroup = 'resource';
|
||||
await satellitesLayer.update(viewer);
|
||||
const second = satellitesLayer.getDetectableObjects().find((o) => o.sourceId === 33333);
|
||||
assert.equal(second.klass, 'VISUAL', 'the cached record does not outlive its catalog');
|
||||
assert.equal(second.klass, 'IMAGING', 'the cached record does not outlive its catalog');
|
||||
} finally {
|
||||
console.log = log;
|
||||
console.warn = warn;
|
||||
@@ -451,7 +399,7 @@ test('a catalog rebuild refreshes the detection overlay class strings', async ()
|
||||
test('a dependency owner hiding the points surrenders the whole row', async () => {
|
||||
try {
|
||||
_setDenseCatalogStateForTest({});
|
||||
assert.equal(satellitesLayer.getRowControls().chips.length, 1);
|
||||
assert.equal(satellitesLayer.getRowControls().chips.length, 6);
|
||||
|
||||
// Space Missions borrows this layer for TLE lookup with showPoints:false.
|
||||
satellitesLayer.setParams({ showPoints: false });
|
||||
@@ -460,8 +408,105 @@ test('a dependency owner hiding the points surrenders the whole row', async () =
|
||||
assert.deepEqual(owned.legend, [], 'no legend describing an empty sky');
|
||||
|
||||
satellitesLayer.setParams({ showPoints: true });
|
||||
assert.equal(satellitesLayer.getRowControls().chips.length, 1, 'released on restore');
|
||||
assert.equal(satellitesLayer.getRowControls().chips.length, 6, 'released on restore');
|
||||
} finally {
|
||||
_clearDenseCatalogStateForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test('every class but Starlink shows by default; each class chip turns its class on and off', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const log = console.log;
|
||||
const warn = console.warn;
|
||||
console.log = () => {};
|
||||
console.warn = () => {};
|
||||
const served = {
|
||||
'/api/celestrak/stations': ISS_TLE,
|
||||
'/api/celestrak/resource': tleFor('90002', 'SENTINEL-2A'),
|
||||
'/api/celestrak/gps-ops': tleFor('90001', 'GPS BIII-1'),
|
||||
'/api/celestrak/visual': tleFor('90003', 'SL-16 R/B'),
|
||||
};
|
||||
const shown = () => satellitesLayer.getAllPositions().map((row) => row.id).sort();
|
||||
const chip = (id) =>
|
||||
satellitesLayer.getRowControls().chips.find((entry) => entry.id === `class-${id}`);
|
||||
try {
|
||||
_setDenseCatalogStateForTest({});
|
||||
globalThis.fetch = async (url) => ({
|
||||
ok: true,
|
||||
text: async () => served[String(url)] || '',
|
||||
});
|
||||
await satellitesLayer.update({ scene: { primitives: { add: (p) => p, remove() {} } } });
|
||||
assert.deepEqual(shown(), [25544, 90001, 90002, 90003], 'all but Starlink');
|
||||
assert.deepEqual(
|
||||
satellitesLayer.getRowControls().chips.map((entry) => entry.label),
|
||||
['STATIONS', 'IMAGING', 'TELECOM', 'STARLINK', 'PROBES', 'DEBRIS'],
|
||||
);
|
||||
assert.equal(chip('imaging').active, true);
|
||||
assert.equal(chip('telecom').active, true);
|
||||
assert.equal(
|
||||
satellitesLayer.getRowControls().chips.find((entry) => entry.id === 'catalog').active,
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(chip('telecom').params, { classes: { telecom: false } });
|
||||
assert.deepEqual(
|
||||
satellitesLayer.getRowControls().legend.map((row) => row.klass),
|
||||
['stations', 'imaging', 'telecom', 'debris'],
|
||||
'the legend keys only what is on screen',
|
||||
);
|
||||
|
||||
satellitesLayer.setParams(chip('telecom').params);
|
||||
satellitesLayer.setParams(chip('debris').params);
|
||||
assert.deepEqual(shown(), [25544, 90002], 'telecom and debris off');
|
||||
assert.equal(chip('debris').active, false);
|
||||
assert.deepEqual(satellitesLayer.getParams().classes, {
|
||||
stations: true,
|
||||
imaging: true,
|
||||
telecom: false,
|
||||
starlink: false,
|
||||
probes: true,
|
||||
debris: false,
|
||||
});
|
||||
|
||||
satellitesLayer.setParams(chip('telecom').params);
|
||||
satellitesLayer.setParams(chip('imaging').params);
|
||||
assert.deepEqual(shown(), [25544, 90001], 'telecom back on, imaging off');
|
||||
assert.equal(satellitesLayer.setParams({ classes: 'all' }), false, 'malformed request refused');
|
||||
|
||||
// A saved or shared choice comes back as class letters.
|
||||
assert.equal(satellitesLayer.setParams({ classes: 'sip' }), true);
|
||||
assert.deepEqual(satellitesLayer.getParams().classes, {
|
||||
stations: true,
|
||||
imaging: true,
|
||||
telecom: false,
|
||||
starlink: false,
|
||||
probes: true,
|
||||
debris: false,
|
||||
});
|
||||
assert.deepEqual(shown(), [25544, 90002], 'restored from letters');
|
||||
} finally {
|
||||
console.log = log;
|
||||
console.warn = warn;
|
||||
globalThis.fetch = originalFetch;
|
||||
_clearDenseCatalogStateForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test('the shown class is the six-class one, with a subtype that says more', () => {
|
||||
assert.equal(satelliteClassText('telecom', 'gps-ops'), 'TELECOM · GPS');
|
||||
assert.equal(
|
||||
satelliteClassText('stations', 'visual', { isIss: true }),
|
||||
'STATIONS · ISS',
|
||||
);
|
||||
assert.equal(satelliteClassText('imaging', 'resource'), 'IMAGING');
|
||||
assert.equal(satelliteClassText('imaging', 'visual'), 'IMAGING');
|
||||
// A subtype that only repeats the class is dropped.
|
||||
assert.equal(satelliteClassText('starlink', 'dense'), 'STARLINK');
|
||||
// A dead geostationary comsat is debris, not GEO.
|
||||
assert.equal(satelliteClassText('debris', 'geo'), 'DEBRIS');
|
||||
// Without a class the group's label stands.
|
||||
assert.equal(satelliteClassText(null, 'gps-ops'), 'NAV · GPS');
|
||||
assert.equal(
|
||||
satelliteClassText('telecom', 'gps-ops', { localize: false }),
|
||||
'TELECOM · GPS',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* @file The satellites layer's class choice as share-link and saved-state
|
||||
* letters. Shared by the layer-state codec and the layer, which takes a saved
|
||||
* choice back in this form.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Satellite classes shown, as one letter each in legend order: stations,
|
||||
* imaging, telecom, probes, debris (`sitpd`, every one; `0`, none).
|
||||
* Starlink is the `catalog` option's, so it has no letter here.
|
||||
*/
|
||||
const SATELLITE_CLASS_LETTERS = Object.freeze([
|
||||
['stations', 's'],
|
||||
['imaging', 'i'],
|
||||
['telecom', 't'],
|
||||
['probes', 'p'],
|
||||
['debris', 'd'],
|
||||
]);
|
||||
const SATELLITE_CLASS_MASK = /^(0|s?i?t?p?d?)$/;
|
||||
|
||||
/**
|
||||
* The canonical class letters for a class choice (an object of booleans, as
|
||||
* the layer's `classes` param, or a partial one) or for letters already,
|
||||
* else null.
|
||||
* @param {unknown} value
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function normalizeSatelliteClassMask(value) {
|
||||
if (typeof value === 'string')
|
||||
return value && SATELLITE_CLASS_MASK.test(value) ? value : null;
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
// A class the choice leaves out keeps its default, which is shown: a
|
||||
// partial request (`{debris: false}`) hides only what it names.
|
||||
const letters = SATELLITE_CLASS_LETTERS.filter(([id]) => value[id] !== false)
|
||||
.map(([, letter]) => letter)
|
||||
.join('');
|
||||
return letters || '0';
|
||||
}
|
||||
|
||||
/**
|
||||
* The class choice the letters stand for, without Starlink, or null.
|
||||
* @param {string} mask
|
||||
* @returns {Record<string, boolean>|null}
|
||||
*/
|
||||
export function decodeSatelliteClassMask(mask) {
|
||||
const normalized = normalizeSatelliteClassMask(mask);
|
||||
if (normalized === null || typeof mask !== 'string') return null;
|
||||
return Object.fromEntries(
|
||||
SATELLITE_CLASS_LETTERS.map(([id, letter]) => [
|
||||
id,
|
||||
normalized.includes(letter),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -152,3 +152,52 @@ test('satellite getAnalystRecords: core rows beat dense extras under the cap', (
|
||||
_clearDenseCatalogStateForTest();
|
||||
}
|
||||
});
|
||||
|
||||
test('satellite getAnalystRecords: a hidden class stays out, the tracked one stays in', () => {
|
||||
const satrec = twoline2satrec(ISS_L1, ISS_L2);
|
||||
const at = (lon, alt) => ({
|
||||
position: Cesium.Cartesian3.fromDegrees(lon, 10, alt),
|
||||
});
|
||||
_setTrackedSatelliteRefreshStateForTest({
|
||||
noradId: ISS_NORAD,
|
||||
name: 'ISS (ZARYA)',
|
||||
satrec,
|
||||
entity: {},
|
||||
point: at(-97, 420_000),
|
||||
viewer: {},
|
||||
now: () => Date.now(),
|
||||
satelliteClass: 'stations',
|
||||
classes: {
|
||||
stations: false,
|
||||
imaging: true,
|
||||
telecom: false,
|
||||
starlink: false,
|
||||
probes: true,
|
||||
debris: true,
|
||||
},
|
||||
neighbours: [
|
||||
{
|
||||
noradId: 24876,
|
||||
name: 'GPS BIIR-2',
|
||||
group: 'gps-ops',
|
||||
satelliteClass: 'telecom',
|
||||
point: at(-100, 20_200_000),
|
||||
},
|
||||
{
|
||||
noradId: 90001,
|
||||
name: 'TEST IMAGER',
|
||||
group: 'resource',
|
||||
satelliteClass: 'imaging',
|
||||
point: at(-80, 700_000),
|
||||
},
|
||||
],
|
||||
});
|
||||
try {
|
||||
assert.deepEqual(
|
||||
satellitesLayer.getAnalystRecords(10).map((r) => r.noradId),
|
||||
['25544', '90001'],
|
||||
);
|
||||
} finally {
|
||||
_clearDenseCatalogStateForTest();
|
||||
}
|
||||
});
|
||||
@@ -6,6 +6,14 @@ export function celestrakTleUrl(group) {
|
||||
return url;
|
||||
}
|
||||
|
||||
/** CelesTrak SATCAT records (object type, status, owner) for a GP group, as CSV. */
|
||||
export function celestrakSatcatUrl(group) {
|
||||
const url = new URL('https://celestrak.org/satcat/records.php');
|
||||
url.searchParams.set('GROUP', group);
|
||||
url.searchParams.set('FORMAT', 'CSV');
|
||||
return url;
|
||||
}
|
||||
|
||||
export function launchLibraryRecentUrl(end) {
|
||||
// Stable minute boundaries let different installations share LL2's cache.
|
||||
const roundedEnd = new Date(Math.floor(end.getTime() / 60000) * 60000);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* @file The bundled UCS Satellite Database pack: the purpose of each active
|
||||
* satellite, 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 a satellite is for.
|
||||
* @typedef {{noradId: number, purpose: string|null}} UcsSatellite
|
||||
*/
|
||||
|
||||
/**
|
||||
* Expand the pack's string-table rows into records keyed by NORAD number.
|
||||
* A pack of the wrong shape throws: read as an empty pack it would be kept
|
||||
* for the session, and every satellite would quietly lose its UCS evidence.
|
||||
* A malformed row is skipped.
|
||||
* @param {{fields: string[], strings: Record<string, string[]>,
|
||||
* records: Array<number[]>}} pack Parsed `ucs_satellites.json`.
|
||||
* @returns {Map<number, UcsSatellite>}
|
||||
*/
|
||||
export function indexUcsPack(pack) {
|
||||
const byNorad = new Map();
|
||||
const fields = pack?.fields;
|
||||
if (
|
||||
!Array.isArray(fields) ||
|
||||
fields[0] !== 'noradId' ||
|
||||
!Array.isArray(pack.records)
|
||||
)
|
||||
throw new TypeError('UCS pack has an unexpected shape');
|
||||
for (const row of pack.records) {
|
||||
const noradId = row?.[0];
|
||||
if (!Number.isInteger(noradId) || noradId <= 0) continue;
|
||||
const record = { noradId };
|
||||
for (let i = 1; i < fields.length; i++) {
|
||||
const text = pack.strings?.[fields[i]]?.[row[i]];
|
||||
record[fields[i]] = text ? text : null;
|
||||
}
|
||||
byNorad.set(noradId, record);
|
||||
}
|
||||
return byNorad;
|
||||
}
|
||||
|
||||
/** The pack, loaded and indexed once. */
|
||||
export const loadUcsSatellites = createRetryableLoader(async () =>
|
||||
indexUcsPack(await loadBundledJson(PACK_URL)),
|
||||
);
|
||||
@@ -0,0 +1,66 @@
|
||||
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', 'purpose']);
|
||||
// Small enough to load with the layer.
|
||||
assert.ok(readFileSync(PACK_URL).length < 200_000);
|
||||
});
|
||||
|
||||
test('the pack loads into records keyed by NORAD number', async () => {
|
||||
const records = await loadUcsSatellites();
|
||||
assert.equal(records, await loadUcsSatellites(), 'loaded once');
|
||||
const iss = records.get(25544);
|
||||
assert.ok(iss, 'the ISS is in UCS');
|
||||
assert.equal(iss.noradId, 25544);
|
||||
assert.ok(iss.purpose);
|
||||
});
|
||||
|
||||
test('indexing reads the string tables and turns empty strings into null', () => {
|
||||
const records = indexUcsPack({
|
||||
fields: ['noradId', 'purpose'],
|
||||
strings: { purpose: ['', 'Earth Observation'] },
|
||||
records: [
|
||||
[39634, 1],
|
||||
[40000, 0],
|
||||
['bad', 1],
|
||||
[-5, 1],
|
||||
],
|
||||
});
|
||||
assert.deepEqual([...records.keys()], [39634, 40000]);
|
||||
assert.deepEqual(records.get(39634), {
|
||||
noradId: 39634,
|
||||
purpose: 'Earth Observation',
|
||||
});
|
||||
assert.equal(records.get(40000).purpose, 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} — انقر لإعادة المحاولة",
|
||||
@@ -3540,6 +3538,23 @@
|
||||
"Opening imagery": "جارٍ فتح الصور",
|
||||
"Street View is ready: press {open} to load it": "Street View جاهز: اضغط «{open}» لتحميله",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "صور مستوى الشارع مخفية في العرض الحالي. اخرج منه أولًا.",
|
||||
"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",
|
||||
"World time": "وقت العالم",
|
||||
"Back to live": "العودة إلى البث المباشر",
|
||||
|
||||
@@ -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",
|
||||
@@ -3236,6 +3234,23 @@
|
||||
"Opening imagery": "Bilder werden geöffnet",
|
||||
"Street View is ready: press {open} to load it": "Street View ist bereit: Auf {open} klicken, um es zu laden",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Fotos auf Straßenebene sind in dieser Ansicht ausgeblendet. Zuerst die Ansicht verlassen.",
|
||||
"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",
|
||||
"World time": "Weltzeit",
|
||||
"Back to live": "Zurück zu live",
|
||||
|
||||
@@ -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",
|
||||
@@ -3312,6 +3310,23 @@
|
||||
"Opening imagery": "Abriendo imágenes",
|
||||
"Street View is ready: press {open} to load it": "Street View está listo: pulsa {open} para cargarlo",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Las fotos a pie de calle están ocultas en la vista actual. Sal de ella primero.",
|
||||
"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",
|
||||
"World time": "Hora mundial",
|
||||
"Back to live": "Volver al directo",
|
||||
|
||||
@@ -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}",
|
||||
@@ -3312,6 +3310,23 @@
|
||||
"Opening imagery": "Ouverture des images",
|
||||
"Street View is ready: press {open} to load it": "Street View est prêt : appuyez sur {open} pour le charger",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Les photos au niveau de la rue sont masquées dans la vue actuelle. Quittez-la d’abord.",
|
||||
"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",
|
||||
"World time": "Heure du monde",
|
||||
"Back to live": "Revenir au direct",
|
||||
|
||||
@@ -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}",
|
||||
@@ -3312,6 +3310,23 @@
|
||||
"Opening imagery": "Apertura delle immagini",
|
||||
"Street View is ready: press {open} to load it": "Street View è pronto: premi {open} per caricarlo",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Le foto a livello stradale sono nascoste nella vista attuale. Esci prima da questa vista.",
|
||||
"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",
|
||||
"World time": "Ora del mondo",
|
||||
"Back to live": "Torna alla diretta",
|
||||
|
||||
@@ -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}",
|
||||
@@ -3160,6 +3158,23 @@
|
||||
"Opening imagery": "画像を開いています",
|
||||
"Street View is ready: press {open} to load it": "ストリートビューの準備ができました:{open} を押して読み込みます",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "現在の表示ではストリートレベルの写真は非表示です。先にこの表示を終了してください。",
|
||||
"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",
|
||||
"World time": "世界時刻",
|
||||
"Back to live": "ライブに戻る",
|
||||
|
||||
@@ -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}",
|
||||
@@ -3312,6 +3310,23 @@
|
||||
"Opening imagery": "Abrindo imagens",
|
||||
"Street View is ready: press {open} to load it": "O Street View está pronto: clique em {open} para carregar",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "As fotos ao nível da rua estão ocultas na vista atual. Saia dela primeiro.",
|
||||
"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",
|
||||
"World time": "Hora do mundo",
|
||||
"Back to live": "Voltar ao vivo",
|
||||
|
||||
@@ -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} · Дороги без потока скрыты",
|
||||
@@ -3388,6 +3386,23 @@
|
||||
"Opening imagery": "Открытие снимков",
|
||||
"Street View is ready: press {open} to load it": "Просмотр улиц готов: нажмите {open}, чтобы загрузить",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Уличные снимки скрыты в текущем режиме. Сначала выйдите из него.",
|
||||
"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",
|
||||
"World time": "Мировое время",
|
||||
"Back to live": "Вернуться к прямому эфиру",
|
||||
|
||||
@@ -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}",
|
||||
@@ -3388,6 +3386,23 @@
|
||||
"Opening imagery": "Відкриття знімків",
|
||||
"Street View is ready: press {open} to load it": "Перегляд вулиць готовий: натисніть {open}, щоб завантажити",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Вуличні знімки приховані в поточному режимі. Спершу вийдіть із нього.",
|
||||
"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",
|
||||
"World time": "Світовий час",
|
||||
"Back to live": "Повернутися до прямого ефіру",
|
||||
|
||||
@@ -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}",
|
||||
@@ -3160,6 +3158,23 @@
|
||||
"Opening imagery": "正在打开影像",
|
||||
"Street View is ready: press {open} to load it": "街景已就绪:按 {open} 加载",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "当前视图隐藏了街景照片。请先退出该视图。",
|
||||
"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",
|
||||
"World time": "世界时间",
|
||||
"Back to live": "返回实时",
|
||||
|
||||
@@ -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}",
|
||||
@@ -3160,6 +3158,23 @@
|
||||
"Opening imagery": "正在打開影像",
|
||||
"Street View is ready: press {open} to load it": "街景已就緒:按 {open} 載入",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "目前的檢視隱藏了街景照片。請先離開此檢視。",
|
||||
"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",
|
||||
"World time": "世界時間",
|
||||
"Back to live": "返回即時",
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
"S2 GhostMaps",
|
||||
"SQUAWK {code} · {status}",
|
||||
"SRC {value}",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"Sentinel-2",
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
@@ -154,6 +157,9 @@
|
||||
"SQUAWK {code} · {status}",
|
||||
"SSW",
|
||||
"STANDARD",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"START",
|
||||
"STATION",
|
||||
"STATUS",
|
||||
@@ -308,11 +314,15 @@
|
||||
"SQUAWK {code} · {status}",
|
||||
"SRC {value}",
|
||||
"SSE",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"STD",
|
||||
"San Francisco",
|
||||
"Sector",
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"TELECOM",
|
||||
"Techno",
|
||||
"Trance",
|
||||
"USB {rate} msg/s",
|
||||
@@ -404,8 +414,6 @@
|
||||
"Cyber",
|
||||
"Cyclones · NHC / CPHC",
|
||||
"DENSE",
|
||||
"DENSE ···",
|
||||
"DENSE ✕",
|
||||
"DEST ---°",
|
||||
"DEST {bearing}",
|
||||
"DESTINATION",
|
||||
@@ -493,7 +501,11 @@
|
||||
"SRC {value}",
|
||||
"SSE",
|
||||
"STANDARD",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"STATION",
|
||||
"STATIONS",
|
||||
"STD",
|
||||
"San Francisco",
|
||||
"Satellites",
|
||||
@@ -668,6 +680,9 @@
|
||||
"SQUAWK {code} · {status}",
|
||||
"SSE",
|
||||
"STANDARD",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"STD",
|
||||
"STOP",
|
||||
"SYNC",
|
||||
@@ -676,6 +691,7 @@
|
||||
"Sentinel-2",
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"TELECOM",
|
||||
"TEMP",
|
||||
"TIMEOUT",
|
||||
"Talk",
|
||||
@@ -769,6 +785,9 @@
|
||||
"REC",
|
||||
"S2 GhostMaps",
|
||||
"SRC {value}",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"Sentinel-2",
|
||||
"USB {rate} msg/s",
|
||||
"WHOT/BHOT",
|
||||
@@ -886,6 +905,9 @@
|
||||
"SNAPSHOT",
|
||||
"SQUAWK {code} · {status}",
|
||||
"SSE",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"STATUS",
|
||||
"STATUS · ",
|
||||
"Sentinel-2",
|
||||
@@ -893,6 +915,7 @@
|
||||
"Sonar",
|
||||
"Soul",
|
||||
"TAGS · {tags}",
|
||||
"TELECOM",
|
||||
"TEMP",
|
||||
"Tallinn",
|
||||
"Techno",
|
||||
@@ -951,6 +974,9 @@
|
||||
"REC",
|
||||
"S2 GhostMaps",
|
||||
"S2 · {name}",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"VS",
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
@@ -971,6 +997,9 @@
|
||||
"NVG",
|
||||
"REC",
|
||||
"S2 GhostMaps",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist",
|
||||
@@ -1006,6 +1035,9 @@
|
||||
"S2 GhostMaps",
|
||||
"S2 · {name}",
|
||||
"SRC {value}",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"Sentinel-2",
|
||||
"Techno",
|
||||
"Trance",
|
||||
@@ -1070,6 +1102,9 @@
|
||||
"S2 GhostMaps",
|
||||
"S2 · {name}",
|
||||
"SRC {value}",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"Sentinel-2",
|
||||
"Techno",
|
||||
"Trance",
|
||||
|
||||
@@ -124,6 +124,10 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
satrec,
|
||||
group: 'dense',
|
||||
launchYear: tleLineLaunchYear(entry.line1),
|
||||
// The dense feed is CelesTrak's Starlink group.
|
||||
satelliteClass: 'starlink',
|
||||
ucs: null,
|
||||
satcat: null,
|
||||
});
|
||||
const point = layerState._pointCollection.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
@@ -204,6 +208,14 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
if (token !== layerState._denseLoadToken) return;
|
||||
layerState._params.catalog = 'core';
|
||||
_removeDenseCatalog();
|
||||
// Back on core, Starlink is off: hide the core catalog's own Starlink
|
||||
// points again, and say so in the class choice.
|
||||
if (layerState._params.classes)
|
||||
layerState._params.classes = {
|
||||
...layerState._params.classes,
|
||||
starlink: false,
|
||||
};
|
||||
parts.controls._applyClassVisibility?.();
|
||||
layerState._denseStatus = 'failed';
|
||||
layerState._denseError = reason;
|
||||
parts.controls._notifyRowControls();
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* @file Satellite classes: which of six KSP-style classes an object in orbit
|
||||
* belongs to, who owns it and who operates it.
|
||||
*
|
||||
* Classes, in legend order:
|
||||
* - stations: crewed stations and the craft that visit them (ISS, Tiangong,
|
||||
* Soyuz, Shenzhou, Dragon, Progress, Cygnus…), by name. The CubeSats
|
||||
* CelesTrak's stations group also carries after their deployment from the
|
||||
* ISS are classed like any other satellite. Retired stations and the
|
||||
* shuttles have re-entered, so they have no orbit to show.
|
||||
* - imaging: Earth observation of every kind (optical, radar, multispectral,
|
||||
* hyperspectral, weather, Earth science, surveillance).
|
||||
* - telecom: communications and navigation (GEO comsats, Iridium, OneWeb,
|
||||
* GPS, GLONASS, Galileo, BeiDou…), Starlink excepted.
|
||||
* - starlink: the Starlink constellation.
|
||||
* - probes: other working payloads (space science, technology development and
|
||||
* demonstration, education, unknown purpose).
|
||||
* - debris: rocket bodies, fragments, unknown objects and payloads that no
|
||||
* longer work. Shown and tracked on request, never ridden in the Cockpit.
|
||||
*
|
||||
* The evidence, strongest first: CelesTrak SATCAT's object type and
|
||||
* operational status (current, so it overrules UCS on a satellite that has
|
||||
* died since), the UCS Satellite Database's purpose (May 2023), name patterns
|
||||
* for satellites launched after it, and the CelesTrak group the TLE came in.
|
||||
* Pure: no Cesium, no fetches, so the tables are unit-tested directly.
|
||||
*
|
||||
* @module satelliteClasses
|
||||
*/
|
||||
|
||||
import { msg } from '../../i18n/index.js';
|
||||
|
||||
/** Class ids in legend order. */
|
||||
export const SATELLITE_CLASS_IDS = Object.freeze([
|
||||
'stations',
|
||||
'imaging',
|
||||
'telecom',
|
||||
'starlink',
|
||||
'probes',
|
||||
'debris',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Which classes show when nobody has chosen: everything the layer showed
|
||||
* before classes existed. Starlink stays off, as the DENSE switch was.
|
||||
*/
|
||||
export const DEFAULT_SATELLITE_CLASSES = Object.freeze({
|
||||
stations: true,
|
||||
imaging: true,
|
||||
telecom: true,
|
||||
starlink: false,
|
||||
probes: true,
|
||||
debris: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Point colour and the legend and chip text for each class. Colours reuse the
|
||||
* layer's established palette (src/data/satelliteClass.js); debris recedes.
|
||||
*/
|
||||
export const SATELLITE_CLASS_DISPLAY = Object.freeze({
|
||||
stations: Object.freeze({
|
||||
color: '#fff6e5',
|
||||
label: msg('STATIONS'),
|
||||
blurb: msg('Crewed stations and the craft that visit them'),
|
||||
}),
|
||||
imaging: Object.freeze({
|
||||
color: '#4fd8ff',
|
||||
label: msg('IMAGING'),
|
||||
blurb: msg('Earth observation: optical, radar, weather and science'),
|
||||
}),
|
||||
telecom: Object.freeze({
|
||||
color: '#c89bff',
|
||||
label: msg('TELECOM'),
|
||||
blurb: msg('Communications and navigation satellites, Starlink excepted'),
|
||||
}),
|
||||
starlink: Object.freeze({
|
||||
color: '#54697f',
|
||||
label: msg('STARLINK'),
|
||||
blurb: msg('The Starlink constellation (thousands of extra points)'),
|
||||
}),
|
||||
probes: Object.freeze({
|
||||
color: '#9fb3c4',
|
||||
label: msg('PROBES'),
|
||||
blurb: msg('Space science, technology demonstration and other payloads'),
|
||||
}),
|
||||
debris: Object.freeze({
|
||||
color: '#7d7468',
|
||||
label: msg('DEBRIS'),
|
||||
blurb: msg('Rocket bodies, fragments and dead satellites'),
|
||||
}),
|
||||
});
|
||||
|
||||
/** The ISS, a station whatever else says. */
|
||||
const ISS_NORAD = 25544;
|
||||
|
||||
/** SATCAT object types that are never a working payload. */
|
||||
const DEBRIS_OBJECT_TYPES = new Set(['R/B', 'DEB']);
|
||||
/**
|
||||
* SATCAT files a new launch's objects as UNK until it identifies them, so an
|
||||
* unknown object launched within this many calendar years (2: this year or
|
||||
* last) is not presumed debris. Years, not dates: the TLE designator, the
|
||||
* fallback when SATCAT has no launch date, carries only the year.
|
||||
*/
|
||||
export const UNIDENTIFIED_GRACE_YEARS = 2;
|
||||
/** SATCAT operational status: nonoperational, decayed. */
|
||||
const DEAD_STATUSES = new Set(['-', 'D']);
|
||||
/** SATCAT operational status left blank or unknown. */
|
||||
const UNKNOWN_STATUSES = new Set(['', '?']);
|
||||
/**
|
||||
* A payload whose SATCAT record gives no status, with no UCS record and no
|
||||
* recognisable name, that launched this many years ago is presumed dead (old
|
||||
* weather and science satellites the "visual" group keeps for their
|
||||
* brightness). Without a SATCAT record (a failed read) nothing is presumed.
|
||||
*/
|
||||
export const PRESUMED_DEAD_AFTER_YEARS = 20;
|
||||
|
||||
const DEBRIS_NAME = /\bR\/B\b|\bDEB\b|\bAKM\b|ROCKET BODY/i;
|
||||
const STATION_NAME =
|
||||
/^(ISS\b|TIANGONG|TIANHE|WENTIAN|MENGTIAN|CSS\b|SHENZHOU|TIANZHOU|SOYUZ|PROGRESS|CREW DRAGON|DRAGON|CYGNUS|STARLINER|HTV\b|AXIOM)/i;
|
||||
const STARLINK_NAME = /^STARLINK/i;
|
||||
const IMAGING_NAME =
|
||||
/^(SENTINEL|LANDSAT|WORLDVIEW|GEOEYE|PLEIADES|SPOT[ -]\d|ICEYE|CAPELLA|UMBRA|FLOCK|SKYSAT|PELICAN|SUPERDOVE|DOVE\b|NOAA[ -]?\d|GOES|METEOSAT|METOP|HIMAWARI|FENGYUN|FY-?\d|GAOFEN|JILIN|SUPERVIEW|KOMPSAT|RADARSAT|RCM-|TERRA\b|AQUA\b|SUOMI NPP|JPSS|CARTOSAT|RESOURCESAT|RISAT|EOS-\d|ALOS|COSMO-SKYMED|CSG-|TERRASAR|TANDEM-X|PAZ\b|SAOCOM|KANOPUS|RESURS|METEOR-M|ELEKTRO-L|ARKTIKA-M|YAOGAN|NUSAT|NEWSAT|BLACKSKY|GLOBAL-\d|GHGSAT|LEMUR|HAIYANG|HY-\d|ZIYUAN|ZY-\d|TIANHUI|SWOT|ICESAT|GRACE|CRYOSAT|SMAP|SMOS|PROBA-V|DEIMOS|THEOS|GOKTURK|EROS|OFEQ|OPTSAT|PRISMA|ENMAP|NISAR|BIOMASS|EARTHCARE|FORMOSAT|CBERS|AMAZONIA|SICH|DMSP|ELECTRO)/i;
|
||||
const TELECOM_NAME =
|
||||
/^(ONEWEB|IRIDIUM|GLOBALSTAR|ORBCOMM|INTELSAT|IS-\d|SES[ -]?\d|ASTRA|EUTELSAT|INMARSAT|VIASAT|ECHOSTAR|DIRECTV|SIRIUS|XM-|TDRS|NAVSTAR|GPS\b|GLONASS|GSAT0|GALILEO|BEIDOU|QZS|IRNSS|NVS-|KUIPER|LYNK|BLUEBIRD|O3B|TELESAT|ANIK|NIMIQ|AMAZONAS|HISPASAT|ARABSAT|NILESAT|TURKSAT|YAMAL|EXPRESS-|CHINASAT|ZHONGXING|APSTAR|ASIASAT|THAICOM|MEASAT|JCSAT|SUPERBIRD|OPTUS|SKYNET|WGS|MUOS|AEHF|MILSTAR|QIANFAN|GUOWANG|SPACEMOBILE|GONETS|MERIDIAN|LUCH|RADUGA|SKYTERRA|TERRESTAR|SPACEWAY|HYLAS|ARSAT|AMOS|BADR|KA-SAT|YAHSAT|THURAYA)/i;
|
||||
|
||||
const IMAGING_PURPOSE =
|
||||
/earth observation|earth\/space observation|earth science|meteorolog|surveillance/i;
|
||||
const TELECOM_PURPOSE = /communications|navigation|positioning/i;
|
||||
|
||||
/**
|
||||
* CelesTrak groups whose members are, by default, of one class. Not
|
||||
* stations: it also carries CubeSats deployed from the ISS.
|
||||
*/
|
||||
const GROUP_CLASS = Object.freeze({
|
||||
dense: 'starlink',
|
||||
starlink: 'starlink',
|
||||
resource: 'imaging',
|
||||
weather: 'imaging',
|
||||
planet: 'imaging',
|
||||
sar: 'imaging',
|
||||
'gps-ops': 'telecom',
|
||||
glonass: 'telecom',
|
||||
'glo-ops': 'telecom',
|
||||
galileo: 'telecom',
|
||||
geo: 'telecom',
|
||||
science: 'probes',
|
||||
});
|
||||
|
||||
/** CelesTrak groups named for an orbit, not a mission: the last resort. */
|
||||
const ORBIT_GROUPS = new Set(['geo']);
|
||||
|
||||
/** The year of a SATCAT launch date (`2024-03-15`), or null. */
|
||||
function launchYearOf(launchDate) {
|
||||
const match = /^(\d{4})-/.exec(String(launchDate || ''));
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The class a satellite belongs to.
|
||||
* @param {{noradId?: number, name?: string, group?: string|null,
|
||||
* groups?: string[]|null,
|
||||
* ucs?: {purpose?: string|null}|null,
|
||||
* satcat?: {objectType?: string, opsStatus?: string,
|
||||
* launchDate?: string|null}|null,
|
||||
* launchYear?: number|null, nowYear?: number}} subject `groups` is every
|
||||
* CelesTrak group the TLE came in (`group` is the one it is shown under):
|
||||
* the first of them that names a class gives the last-resort class, so a
|
||||
* satellite in both `visual` and `resource` is still imaging. `launchYear` is
|
||||
* the TLE designator's; SATCAT's launch date wins where it has one (an
|
||||
* object deployed from the ISS carries the station's 1998 designator).
|
||||
* @returns {'stations'|'imaging'|'telecom'|'starlink'|'probes'|'debris'}
|
||||
*/
|
||||
export function classifySatellite({
|
||||
noradId,
|
||||
name = '',
|
||||
group = null,
|
||||
ucs = null,
|
||||
satcat = null,
|
||||
groups = null,
|
||||
launchYear = null,
|
||||
nowYear = new Date().getUTCFullYear(),
|
||||
} = {}) {
|
||||
if (noradId === ISS_NORAD) return 'stations';
|
||||
const label = String(name || '').trim();
|
||||
const objectType = satcat?.objectType || null;
|
||||
const status = satcat?.opsStatus ?? null;
|
||||
const launched = launchYearOf(satcat?.launchDate) ?? launchYear;
|
||||
const recent =
|
||||
Number.isFinite(launched) && nowYear - launched < UNIDENTIFIED_GRACE_YEARS;
|
||||
if (objectType && DEBRIS_OBJECT_TYPES.has(objectType)) return 'debris';
|
||||
if (objectType === 'UNK' && !recent) return 'debris';
|
||||
if (!objectType && DEBRIS_NAME.test(label)) return 'debris';
|
||||
if (status !== null && DEAD_STATUSES.has(status)) return 'debris';
|
||||
|
||||
// CelesTrak's stations group also carries the CubeSats deployed from the
|
||||
// ISS (they share its 1998-067 designator): only a station or a visiting
|
||||
// craft, by name, is a station.
|
||||
if (STATION_NAME.test(label)) return 'stations';
|
||||
if (group === 'dense' || STARLINK_NAME.test(label)) return 'starlink';
|
||||
|
||||
// UCS writes "Unknown" for a purpose it does not know: no evidence.
|
||||
const purpose = /^unknown$/i.test(ucs?.purpose || '')
|
||||
? ''
|
||||
: ucs?.purpose || '';
|
||||
if (IMAGING_PURPOSE.test(purpose)) return 'imaging';
|
||||
if (TELECOM_PURPOSE.test(purpose)) return 'telecom';
|
||||
if (purpose) return 'probes';
|
||||
|
||||
if (IMAGING_NAME.test(label)) return 'imaging';
|
||||
if (TELECOM_NAME.test(label)) return 'telecom';
|
||||
|
||||
// UCS lists working satellites only: a payload it never knew, whose SATCAT
|
||||
// record gives no status and whose launch is decades old, is presumed
|
||||
// dead. Without a SATCAT record (its read failed or has not landed) there
|
||||
// is no evidence either way, so nothing is presumed.
|
||||
if (
|
||||
satcat &&
|
||||
UNKNOWN_STATUSES.has(status ?? '') &&
|
||||
Number.isFinite(launched) &&
|
||||
nowYear - launched >= PRESUMED_DEAD_AFTER_YEARS
|
||||
)
|
||||
return 'debris';
|
||||
|
||||
// A mission group (weather, resource, science…) says more than an orbit
|
||||
// group: a weather satellite in geostationary orbit is in both.
|
||||
const memberships = groups?.length ? groups : [group];
|
||||
for (const membership of [
|
||||
...memberships.filter((m) => !ORBIT_GROUPS.has(m)),
|
||||
...memberships.filter((m) => ORBIT_GROUPS.has(m)),
|
||||
])
|
||||
if (GROUP_CLASS[membership]) return GROUP_CLASS[membership];
|
||||
return 'probes';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** 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,
|
||||
* 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'),
|
||||
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),
|
||||
launchDate: text(col.launch) || null,
|
||||
});
|
||||
}
|
||||
return records;
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
DEFAULT_SATELLITE_CLASSES,
|
||||
PRESUMED_DEAD_AFTER_YEARS,
|
||||
SATELLITE_CLASS_DISPLAY,
|
||||
SATELLITE_CLASS_IDS,
|
||||
classifySatellite,
|
||||
mergeClassVisibility,
|
||||
parseSatcatCsv,
|
||||
} from './classes.js';
|
||||
import { CATALOG_GROUPS } from './policy.js';
|
||||
|
||||
const NOW = 2026;
|
||||
const classify = (subject) => classifySatellite({ nowYear: NOW, ...subject });
|
||||
|
||||
test('six classes, every one but Starlink on by default', () => {
|
||||
assert.deepEqual(SATELLITE_CLASS_IDS, [
|
||||
'stations',
|
||||
'imaging',
|
||||
'telecom',
|
||||
'starlink',
|
||||
'probes',
|
||||
'debris',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
SATELLITE_CLASS_IDS.filter((id) => DEFAULT_SATELLITE_CLASSES[id]),
|
||||
['stations', 'imaging', 'telecom', 'probes', 'debris'],
|
||||
);
|
||||
for (const id of SATELLITE_CLASS_IDS) {
|
||||
const display = SATELLITE_CLASS_DISPLAY[id];
|
||||
assert.match(display.color, /^#[0-9a-f]{6}$/, id);
|
||||
assert.ok(display.label && display.blurb, id);
|
||||
}
|
||||
});
|
||||
|
||||
test('SATCAT object type and status file debris first, overruling UCS', () => {
|
||||
const imaging = { purpose: 'Earth Observation' };
|
||||
for (const objectType of ['R/B', 'DEB', 'UNK'])
|
||||
assert.equal(
|
||||
classify({ name: 'X', satcat: { objectType, opsStatus: '' } }),
|
||||
'debris',
|
||||
objectType,
|
||||
);
|
||||
// A satellite UCS listed in 2023 that SATCAT now calls dead or decayed.
|
||||
for (const opsStatus of ['-', 'D'])
|
||||
assert.equal(
|
||||
classify({
|
||||
name: 'OLDSAT',
|
||||
ucs: imaging,
|
||||
satcat: { objectType: 'PAY', opsStatus },
|
||||
}),
|
||||
'debris',
|
||||
opsStatus,
|
||||
);
|
||||
assert.equal(
|
||||
classify({
|
||||
name: 'OLDSAT',
|
||||
ucs: imaging,
|
||||
satcat: { objectType: 'PAY', opsStatus: '+' },
|
||||
}),
|
||||
'imaging',
|
||||
);
|
||||
// SATCAT calls a new launch's objects UNK until it names them: only an
|
||||
// old unknown object, or one with no launch date, is debris.
|
||||
assert.equal(
|
||||
classify({
|
||||
name: 'OBJECT C',
|
||||
group: 'resource',
|
||||
satcat: { objectType: 'UNK', opsStatus: '', launchDate: '2025-11-02' },
|
||||
}),
|
||||
'imaging',
|
||||
);
|
||||
assert.equal(
|
||||
classify({
|
||||
name: 'OBJECT C',
|
||||
group: 'resource',
|
||||
satcat: { objectType: 'UNK', opsStatus: '', launchDate: '2019-04-01' },
|
||||
}),
|
||||
'debris',
|
||||
);
|
||||
// Debris riding in the stations group (CelesTrak lists FREGAT DEB there).
|
||||
assert.equal(classify({ name: 'FREGAT DEB', group: 'stations' }), 'debris');
|
||||
assert.equal(classify({ name: 'SL-16 R/B', group: 'visual' }), 'debris');
|
||||
});
|
||||
|
||||
test('the ISS is a station whatever else says', () => {
|
||||
assert.equal(
|
||||
classify({
|
||||
noradId: 25544,
|
||||
name: 'ISS (ZARYA)',
|
||||
group: 'visual',
|
||||
satcat: { objectType: 'DEB' },
|
||||
}),
|
||||
'stations',
|
||||
);
|
||||
});
|
||||
|
||||
test('stations: crewed stations and visiting vehicles by name', () => {
|
||||
assert.equal(
|
||||
classify({ name: 'CSS (TIANHE)', group: 'stations' }),
|
||||
'stations',
|
||||
);
|
||||
assert.equal(
|
||||
classify({ name: 'ISS (NAUKA)', group: 'stations' }),
|
||||
'stations',
|
||||
);
|
||||
for (const name of [
|
||||
'SHENZHOU-21',
|
||||
'TIANZHOU-9',
|
||||
'SOYUZ-MS 28',
|
||||
'PROGRESS-MS 31',
|
||||
'CREW DRAGON 11',
|
||||
'CYGNUS NG-23',
|
||||
])
|
||||
assert.equal(classify({ name, group: 'visual' }), 'stations', name);
|
||||
});
|
||||
|
||||
test('UCS purpose decides imaging, telecom and probes', () => {
|
||||
const table = [
|
||||
['Earth Observation', 'imaging'],
|
||||
['Earth Science', 'imaging'],
|
||||
['Meteorological', 'imaging'],
|
||||
['Surveillance', 'imaging'],
|
||||
['Earth Observation/Navigation', 'imaging'],
|
||||
['Earth/Space Observation', 'imaging'],
|
||||
['Communications', 'telecom'],
|
||||
['Navigation/Global Positioning', 'telecom'],
|
||||
['Navigation/Regional Positioning', 'telecom'],
|
||||
['Technology Development', 'probes'],
|
||||
['Technology Demonstration', 'probes'],
|
||||
['Space Science', 'probes'],
|
||||
['Unknown', 'probes'],
|
||||
];
|
||||
for (const [purpose, expected] of table)
|
||||
assert.equal(
|
||||
classify({ name: 'UNNAMED-1', group: 'visual', ucs: { purpose } }),
|
||||
expected,
|
||||
purpose,
|
||||
);
|
||||
});
|
||||
|
||||
test('satellites launched after the UCS snapshot are classed by name', () => {
|
||||
const table = [
|
||||
['ICEYE-X50', 'imaging'],
|
||||
['CAPELLA-17', 'imaging'],
|
||||
['FLOCK 4Y-12', 'imaging'],
|
||||
['PELICAN-3', 'imaging'],
|
||||
['SENTINEL-1C', 'imaging'],
|
||||
['LANDSAT 9', 'imaging'],
|
||||
['GOES 19', 'imaging'],
|
||||
['ONEWEB-0712', 'telecom'],
|
||||
['IRIDIUM 180', 'telecom'],
|
||||
['GPS BIII-8 (PRN 14)', 'telecom'],
|
||||
['KUIPER-00042', 'telecom'],
|
||||
['STARLINK-34012', 'starlink'],
|
||||
];
|
||||
for (const [name, expected] of table)
|
||||
assert.equal(
|
||||
classify({ name, group: 'visual', launchYear: 2025 }),
|
||||
expected,
|
||||
name,
|
||||
);
|
||||
});
|
||||
|
||||
test('the group the TLE came in is the last resort', () => {
|
||||
assert.equal(
|
||||
classify({ name: 'UNNAMED-1', group: 'resource', launchYear: 2024 }),
|
||||
'imaging',
|
||||
);
|
||||
assert.equal(
|
||||
classify({ name: 'UNNAMED-1', group: 'geo', launchYear: 2024 }),
|
||||
'telecom',
|
||||
);
|
||||
assert.equal(
|
||||
classify({ name: 'UNNAMED-1', group: 'science', launchYear: 2024 }),
|
||||
'probes',
|
||||
);
|
||||
assert.equal(classify({ name: 'STARLINK-1', group: 'dense' }), 'starlink');
|
||||
assert.equal(
|
||||
classify({ name: 'UNNAMED-1', group: 'visual', launchYear: 2024 }),
|
||||
'probes',
|
||||
);
|
||||
});
|
||||
|
||||
test('CubeSats deployed from the ISS are not stations', () => {
|
||||
// They ride in CelesTrak's stations group under the ISS's 1998 designator.
|
||||
for (const name of [
|
||||
'DUPLEX',
|
||||
'KNACKSAT-2',
|
||||
'GXIBA-1',
|
||||
'LEOPARD',
|
||||
'HMU-SAT2',
|
||||
]) {
|
||||
assert.equal(
|
||||
classify({ name, group: 'stations', launchYear: 1998 }),
|
||||
'probes',
|
||||
`${name} without SATCAT`,
|
||||
);
|
||||
assert.equal(
|
||||
classify({
|
||||
name,
|
||||
group: 'stations',
|
||||
launchYear: 1998,
|
||||
satcat: { objectType: 'PAY', opsStatus: '', launchDate: '2025-04-21' },
|
||||
}),
|
||||
'probes',
|
||||
`${name}: SATCAT's 2025 launch, not the designator's 1998`,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
classify({
|
||||
name: 'UNNAMED-1',
|
||||
group: 'stations',
|
||||
ucs: { purpose: 'Earth Observation' },
|
||||
}),
|
||||
'imaging',
|
||||
);
|
||||
});
|
||||
|
||||
test('an unknown old payload is presumed dead; a recognisable one is not', () => {
|
||||
const old = NOW - PRESUMED_DEAD_AFTER_YEARS;
|
||||
assert.equal(
|
||||
classify({ name: 'ATLAS CENTAUR 2', group: 'visual', launchYear: 1963 }),
|
||||
'probes',
|
||||
'without a SATCAT record (a failed read) nothing is presumed',
|
||||
);
|
||||
assert.equal(
|
||||
classify({
|
||||
name: 'ATLAS CENTAUR 2',
|
||||
group: 'visual',
|
||||
launchYear: 1963,
|
||||
satcat: { objectType: 'PAY', opsStatus: '' },
|
||||
}),
|
||||
'debris',
|
||||
'a blank SATCAT status is no evidence of life',
|
||||
);
|
||||
assert.equal(
|
||||
classify({ name: 'UNNAMED-1', group: 'visual', launchYear: old + 1 }),
|
||||
'probes',
|
||||
);
|
||||
assert.equal(
|
||||
classify({ name: 'NOAA 15', group: 'weather', launchYear: 1998 }),
|
||||
'imaging',
|
||||
);
|
||||
assert.equal(
|
||||
classify({
|
||||
name: 'UNNAMED-1',
|
||||
group: 'visual',
|
||||
launchYear: 1990,
|
||||
satcat: { objectType: 'PAY', opsStatus: '+' },
|
||||
}),
|
||||
'probes',
|
||||
'SATCAT says it works',
|
||||
);
|
||||
});
|
||||
|
||||
test('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('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: '+',
|
||||
launchDate: '1998-11-20',
|
||||
});
|
||||
assert.equal(records.get(733).objectType, 'R/B');
|
||||
assert.equal(parseSatcatCsv('<html>busy</html>').size, 0);
|
||||
assert.equal(parseSatcatCsv('').size, 0);
|
||||
});
|
||||
|
||||
test('turning Stations off takes the ISS ring and label with its point', async () => {
|
||||
const { createControls } = await import('./controls.js');
|
||||
let overlaySyncs = 0;
|
||||
const ring = { primitive: { show: true } };
|
||||
const state = {
|
||||
_enabled: true,
|
||||
_params: {
|
||||
catalog: 'core',
|
||||
showOrbits: true,
|
||||
classes: { ...DEFAULT_SATELLITE_CLASSES, stations: false },
|
||||
},
|
||||
_catalog: new Map([[25544, { satelliteClass: 'stations' }]]),
|
||||
_points: new Map([[25544, { show: true }]]),
|
||||
_orbitPaths: new Map([[25544, ring]]),
|
||||
_detectionObjects: new Map(),
|
||||
_trackedNorad: null,
|
||||
};
|
||||
const controls = createControls({
|
||||
state,
|
||||
services: { layerState: { isExplicitLayerStateOrigin: () => false } },
|
||||
parts: { labels: { _syncIssOverlay: () => (overlaySyncs += 1) } },
|
||||
source: {},
|
||||
});
|
||||
controls._applyClassVisibility();
|
||||
assert.equal(state._points.get(25544).show, false);
|
||||
assert.equal(ring.primitive.show, false, 'the ring goes with the class');
|
||||
assert.ok(overlaySyncs >= 1, 'the label is re-synced');
|
||||
state._params.classes = { ...DEFAULT_SATELLITE_CLASSES };
|
||||
controls._applyClassVisibility();
|
||||
assert.equal(ring.primitive.show, true, 'and comes back with it');
|
||||
});
|
||||
|
||||
test('a failed Starlink load hides the core catalog Starlink points again', async () => {
|
||||
const { createCatalog } = await import('./catalog.js');
|
||||
let applied = 0;
|
||||
const state = {
|
||||
_params: {
|
||||
catalog: 'dense',
|
||||
classes: { ...DEFAULT_SATELLITE_CLASSES, starlink: true },
|
||||
},
|
||||
_denseLoadToken: 3,
|
||||
_denseIds: [],
|
||||
_catalog: new Map(),
|
||||
_points: new Map(),
|
||||
_detectionObjects: new Map(),
|
||||
_trackedNorad: null,
|
||||
_activeUpdateControllers: new Set(),
|
||||
coverageChanged() {},
|
||||
};
|
||||
const catalog = createCatalog({
|
||||
state,
|
||||
services: {},
|
||||
parts: {
|
||||
controls: {
|
||||
_applyClassVisibility: () => (applied += 1),
|
||||
_notifyRowControls() {},
|
||||
},
|
||||
labels: { _syncIssOverlay() {} },
|
||||
},
|
||||
source: {},
|
||||
});
|
||||
catalog._denseLoadFailed(state._denseLoadToken, 'feed unreachable');
|
||||
assert.equal(state._params.catalog, 'core');
|
||||
assert.equal(state._params.classes.starlink, false, 'the choice says so');
|
||||
assert.ok(applied >= 1, 'class visibility is applied again');
|
||||
});
|
||||
|
||||
test('every ingested group names the class its unnamed members fall back to', () => {
|
||||
// A group added to CATALOG_GROUPS without a class would file its members
|
||||
// under PROBES, the catch-all.
|
||||
const expected = {
|
||||
stations: 'probes',
|
||||
visual: 'probes',
|
||||
'gps-ops': 'telecom',
|
||||
glonass: 'telecom',
|
||||
galileo: 'telecom',
|
||||
geo: 'telecom',
|
||||
resource: 'imaging',
|
||||
weather: 'imaging',
|
||||
planet: 'imaging',
|
||||
sar: 'imaging',
|
||||
science: 'probes',
|
||||
};
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(
|
||||
CATALOG_GROUPS.map(({ tag }) => [
|
||||
tag,
|
||||
classify({ noradId: 99999, name: 'UNNAMED-1', group: tag }),
|
||||
]),
|
||||
),
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
test('enabling the layer leaves a hidden Stations class without its ISS ring', async () => {
|
||||
const { createLifecycle } = await import('./lifecycle.js');
|
||||
const iss = { primitive: { show: false } };
|
||||
const other = { primitive: { show: false } };
|
||||
let issVisible = false;
|
||||
const state = {
|
||||
_params: { showPoints: true, showOrbits: true },
|
||||
_orbitPaths: new Map([
|
||||
[25544, iss],
|
||||
[90001, other],
|
||||
]),
|
||||
_points: new Map(),
|
||||
coverageChanged() {},
|
||||
};
|
||||
const { methods } = createLifecycle({
|
||||
state,
|
||||
services: {
|
||||
focus: { clearFocusTarget() {} },
|
||||
render: { holdContinuousRender() {}, releaseContinuousRender() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
},
|
||||
parts: {
|
||||
controls: {
|
||||
satelliteVisualsVisible: (enabled, on) => enabled && on !== false,
|
||||
_issOrbitVisible: () => issVisible,
|
||||
},
|
||||
labels: { _syncIssOverlay() {} },
|
||||
interaction: { _installClickHandler() {} },
|
||||
tracking: { _applyPendingTrackingRestore() {} },
|
||||
rendering: {},
|
||||
},
|
||||
});
|
||||
methods.enable(null);
|
||||
assert.equal(iss.primitive.show, false, 'the ISS ring follows its class');
|
||||
assert.equal(other.primitive.show, true, 'other rings follow showOrbits');
|
||||
issVisible = true;
|
||||
methods.enable(null);
|
||||
assert.equal(iss.primitive.show, true);
|
||||
});
|
||||
|
||||
test('a mission group outranks an orbit group, and an Unknown UCS purpose is no evidence', () => {
|
||||
assert.equal(
|
||||
classify({ name: 'MTG-X1', group: 'geo', groups: ['geo', 'weather'] }),
|
||||
'imaging',
|
||||
);
|
||||
assert.equal(
|
||||
classify({ name: 'UNNAMED-1', group: 'geo', groups: ['geo'] }),
|
||||
'telecom',
|
||||
);
|
||||
assert.equal(
|
||||
classify({
|
||||
name: 'GPS BIII-9',
|
||||
group: 'visual',
|
||||
ucs: { purpose: 'Unknown' },
|
||||
}),
|
||||
'telecom',
|
||||
);
|
||||
assert.equal(
|
||||
classify({
|
||||
name: 'UNNAMED-1',
|
||||
group: 'resource',
|
||||
ucs: { purpose: 'Unknown' },
|
||||
}),
|
||||
'imaging',
|
||||
);
|
||||
});
|
||||
|
||||
test('a tracked ISS keeps its ring while Stations is hidden', async () => {
|
||||
const { createControls } = await import('./controls.js');
|
||||
const ring = { primitive: { show: true } };
|
||||
const state = {
|
||||
_enabled: true,
|
||||
_params: {
|
||||
catalog: 'core',
|
||||
showOrbits: true,
|
||||
classes: { ...DEFAULT_SATELLITE_CLASSES, stations: false },
|
||||
},
|
||||
_catalog: new Map([[25544, { satelliteClass: 'stations' }]]),
|
||||
_points: new Map([[25544, { show: true }]]),
|
||||
_orbitPaths: new Map([[25544, ring]]),
|
||||
_detectionObjects: new Map(),
|
||||
_trackedNorad: null,
|
||||
};
|
||||
const controls = createControls({
|
||||
state,
|
||||
services: { layerState: { isExplicitLayerStateOrigin: () => false } },
|
||||
parts: { labels: { _syncIssOverlay() {} } },
|
||||
source: {},
|
||||
});
|
||||
controls._syncIssOrbit();
|
||||
assert.equal(ring.primitive.show, false);
|
||||
state._trackedNorad = 25544;
|
||||
controls._syncIssOrbit();
|
||||
assert.equal(ring.primitive.show, true, 'tracked, it keeps its ring');
|
||||
state._params.showOrbits = false;
|
||||
controls._syncIssOrbit();
|
||||
assert.equal(ring.primitive.show, false, 'orbits off still hides it');
|
||||
});
|
||||
Vendored
+230
-79
@@ -1,11 +1,14 @@
|
||||
import {
|
||||
tallySatelliteClasses,
|
||||
satelliteClassLabel,
|
||||
satelliteClassLegend,
|
||||
} from '../../data/satelliteClass.js';
|
||||
import { satelliteClassText } from '../../data/satelliteClass.js';
|
||||
import { decodeSatelliteClassMask } from '../../data/satelliteClassMask.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { ISS_NORAD, POINT_STYLES } from './policy.js';
|
||||
import {
|
||||
DEFAULT_SATELLITE_CLASSES,
|
||||
SATELLITE_CLASS_DISPLAY,
|
||||
SATELLITE_CLASS_IDS,
|
||||
mergeClassVisibility,
|
||||
} from './classes.js';
|
||||
import { ISS_NORAD, POINT_STYLES, classPointStyle } from './policy.js';
|
||||
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
const { isExplicitLayerStateOrigin } = services.layerState;
|
||||
@@ -19,7 +22,83 @@ 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). The ISS's ring and label go
|
||||
* with its class.
|
||||
*/
|
||||
function _applyClassVisibility() {
|
||||
for (const [noradId, point] of layerState._points) {
|
||||
if (noradId === layerState._trackedNorad) continue;
|
||||
point.show = _classVisible(noradId);
|
||||
}
|
||||
_syncIssOrbit();
|
||||
parts.labels?._syncIssOverlay?.();
|
||||
layerState._detectionObjects.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the ISS's ring shows: orbits on, the layer drawing, and its
|
||||
* class on or the ISS tracked (a tracked satellite keeps its ring).
|
||||
*/
|
||||
function _issOrbitVisible() {
|
||||
return (
|
||||
satelliteVisualsVisible(
|
||||
layerState._enabled,
|
||||
layerState._params.showOrbits,
|
||||
) &&
|
||||
(_classVisible(ISS_NORAD) || layerState._trackedNorad === ISS_NORAD)
|
||||
);
|
||||
}
|
||||
|
||||
/** Show or hide the ISS's ring by _issOrbitVisible, if it is built. */
|
||||
function _syncIssOrbit() {
|
||||
const issPath = layerState._orbitPaths.get(ISS_NORAD);
|
||||
if (issPath) issPath.primitive.show = _issOrbitVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's info line while the classes rest on old or missing SATCAT
|
||||
* evidence (`_satcatState`), and an empty one otherwise.
|
||||
* @returns {{info: string, infoTitle: string}}
|
||||
*/
|
||||
function classEvidenceNotice() {
|
||||
const evidence = layerState._satcatState;
|
||||
if (evidence !== 'stale' && evidence !== 'unavailable')
|
||||
return { info: '', infoTitle: '' };
|
||||
return {
|
||||
info:
|
||||
evidence === 'stale'
|
||||
? t('Classes may lag: SATCAT is a cached copy')
|
||||
: t('Classes may lag: SATCAT unavailable'),
|
||||
infoTitle: t(
|
||||
"CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Tell the manager to re-render this layer's row (chip state / legend counts). */
|
||||
@@ -46,14 +125,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 +164,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 +246,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 +260,16 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
sourceId: noradId,
|
||||
id: cat?.name || `SAT-${noradId}`,
|
||||
type: 'SAT',
|
||||
// Human class ("NAV · GPS"), not the raw CelesTrak tag ("GPS-OPS").
|
||||
// The detection canvas composites ABOVE the post-FX chain, so this
|
||||
// is how class survives NVG/FLIR once the dot colors are collapsed.
|
||||
klass: satelliteClassLabel(cat?.group, {
|
||||
isIss: noradId === ISS_NORAD,
|
||||
}),
|
||||
};
|
||||
layerState._detectionObjects.set(noradId, object);
|
||||
}
|
||||
// Human class ("TELECOM · GPS"), not the raw CelesTrak tag
|
||||
// ("GPS-OPS"). The detection canvas composites ABOVE the post-FX
|
||||
// chain, so this is how class survives NVG/FLIR once the dot colors
|
||||
// are collapsed. Read each time: SATCAT can reclassify a satellite.
|
||||
object.klass = satelliteClassText(cat?.satelliteClass, cat?.group, {
|
||||
isIss: noradId === ISS_NORAD,
|
||||
});
|
||||
object.position = point.position;
|
||||
object.skipLabel = isTracked;
|
||||
result.push(object);
|
||||
@@ -225,6 +345,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 &&
|
||||
@@ -383,9 +504,11 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
|
||||
/**
|
||||
* Runtime params (DataLayerManager.setLayerParams path).
|
||||
* catalog: 'core' (default, ~840 sats) | 'dense' (adds the Starlink shell
|
||||
* as points-only extras on a relaxed propagation budget).
|
||||
* @param {{ catalog?: 'core'|'dense', showPoints?: boolean, showOrbits?: boolean, selectedSatTrackingId?: number|null }} [params]
|
||||
* catalog: 'core' (default, about 1,300 satellites from the CelesTrak
|
||||
* groups) | 'dense' (adds the Starlink shell as points-only extras on a
|
||||
* relaxed propagation budget). classes: which of the six classes show
|
||||
* (src/layers/satellites/classes.js); Starlink's toggle is the dense catalog.
|
||||
* @param {{ catalog?: 'core'|'dense', classes?: Record<string, boolean>, showPoints?: boolean, showOrbits?: boolean, selectedSatTrackingId?: number|null }} [params]
|
||||
*/
|
||||
setParams(params = {}, { origin = 'programmatic' } = {}) {
|
||||
if (
|
||||
@@ -394,9 +517,28 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
) {
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
}
|
||||
const catalog = params.catalog;
|
||||
let catalog = params.catalog;
|
||||
if (catalog !== undefined && catalog !== 'core' && catalog !== 'dense')
|
||||
return false;
|
||||
// A saved or shared choice arrives as class letters (layerState.js).
|
||||
const requestedClasses =
|
||||
typeof params.classes === 'string'
|
||||
? decodeSatelliteClassMask(params.classes)
|
||||
: params.classes;
|
||||
if (params.classes !== undefined) {
|
||||
if (!requestedClasses || typeof requestedClasses !== 'object')
|
||||
return false;
|
||||
// Starlink's toggle is the dense catalog: its points load on demand.
|
||||
if (
|
||||
typeof requestedClasses.starlink === 'boolean' &&
|
||||
catalog === undefined
|
||||
)
|
||||
catalog = requestedClasses.starlink ? 'dense' : 'core';
|
||||
layerState._params.classes = mergeClassVisibility(
|
||||
requestedClasses,
|
||||
_classes(),
|
||||
);
|
||||
}
|
||||
const catalogChanged = satelliteCatalogModeChanged(
|
||||
layerState._params.catalog,
|
||||
catalog,
|
||||
@@ -414,11 +556,14 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
if (params.showOrbits !== undefined) {
|
||||
layerState._params.showOrbits = params.showOrbits !== false;
|
||||
for (const path of layerState._orbitPaths.values())
|
||||
path.primitive.show = satelliteVisualsVisible(
|
||||
layerState._enabled,
|
||||
layerState._params.showOrbits,
|
||||
);
|
||||
for (const [noradId, path] of layerState._orbitPaths)
|
||||
path.primitive.show =
|
||||
noradId === ISS_NORAD
|
||||
? _issOrbitVisible()
|
||||
: satelliteVisualsVisible(
|
||||
layerState._enabled,
|
||||
layerState._params.showOrbits,
|
||||
);
|
||||
parts.labels._syncIssOverlay();
|
||||
}
|
||||
if (catalogChanged && catalog === 'dense') {
|
||||
@@ -434,6 +579,13 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
if (catalogChanged)
|
||||
console.log(`[Data:Satellites] Catalog mode: ${catalog}`);
|
||||
if (params.classes !== undefined || catalogChanged) {
|
||||
layerState._params.classes = {
|
||||
..._classes(),
|
||||
starlink: layerState._params.catalog === 'dense',
|
||||
};
|
||||
_applyClassVisibility();
|
||||
}
|
||||
if (Object.hasOwn(params, 'selectedSatTrackingId')) {
|
||||
const requested = parts.tracking._normalizeTrackedNorad(
|
||||
params.selectedSatTrackingId,
|
||||
@@ -465,25 +617,29 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
catalog: layerState._params.catalog,
|
||||
showPoints: layerState._params.showPoints,
|
||||
showOrbits: layerState._params.showOrbits,
|
||||
classes: { ..._classes() },
|
||||
selectedSatTrackingId: layerState._trackedNorad,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Layer-row sub-controls (DataLayerManager row-controls contract): the DENSE
|
||||
* catalog chip plus a class legend so the point colors are learnable without
|
||||
* a new panel.
|
||||
* Layer-row sub-controls (DataLayerManager row-controls contract): one
|
||||
* toggle chip per satellite class (src/layers/satellites/classes.js), and a legend
|
||||
* of the classes on screen so the point colours are learnable.
|
||||
*
|
||||
* The chip is stateless — it declares the params to apply and the manager
|
||||
* Chips are stateless — each declares the params to apply and the manager
|
||||
* owns the write, so the Space Missions snapshot/restore path (which drives
|
||||
* the same `catalog` param) stays the single source of truth and the chip
|
||||
* the same `catalog` param) stays the single source of truth and a chip
|
||||
* always renders whatever the layer actually has.
|
||||
*
|
||||
* The chip reports the dense LOAD state, not the catalog param: the param
|
||||
* flips synchronously while the Starlink shell takes seconds to arrive and
|
||||
* frequently 502s, so ACTIVE means "dense points are on screen" and nothing
|
||||
* less. The legend tally is cached against the catalog revision.
|
||||
* @returns {{ chips: Array<object>, legend: Array<object> }} Row controls.
|
||||
* STARLINK is the dense catalog (chip id `catalog`) and reports the dense
|
||||
* LOAD state, not the param: the param flips synchronously while the shell
|
||||
* takes seconds to arrive and frequently 502s, so ACTIVE means "Starlink
|
||||
* points are on screen" and nothing less. The legend tally is cached
|
||||
* against the catalog revision. While SATCAT is stale or unavailable the
|
||||
* info line says the classes may lag (see `classEvidenceNotice`).
|
||||
* @returns {{ chips: Array<object>, legend: Array<object>, info: string,
|
||||
* infoTitle: string }} Row controls.
|
||||
*/
|
||||
getRowControls() {
|
||||
// A dependency owner (Space Missions) borrows this layer for TLE lookup
|
||||
@@ -492,48 +648,36 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
// restore. Surrender the row rather than lie about it.
|
||||
if (!layerState._params.showPoints) return { chips: [], legend: [] };
|
||||
|
||||
const loading = layerState._denseStatus === 'loading';
|
||||
const failed = layerState._denseStatus === 'failed';
|
||||
const active =
|
||||
layerState._params.catalog === 'dense' &&
|
||||
layerState._denseStatus === 'ready';
|
||||
let title = t(
|
||||
'Add the full Starlink broadband shell (thousands of extra points)',
|
||||
const classes = _classes();
|
||||
const chips = SATELLITE_CLASS_IDS.map((id) =>
|
||||
id === 'starlink'
|
||||
? _starlinkChip()
|
||||
: {
|
||||
id: `class-${id}`,
|
||||
label: t(SATELLITE_CLASS_DISPLAY[id].label),
|
||||
active: classes[id] === true,
|
||||
state: classes[id] === true ? 'active' : 'idle',
|
||||
title: t(SATELLITE_CLASS_DISPLAY[id].blurb),
|
||||
params: { classes: { [id]: classes[id] !== true } },
|
||||
},
|
||||
);
|
||||
if (loading) title = t('Loading the Starlink shell…');
|
||||
else if (failed)
|
||||
title = t('Starlink {detail} — click to retry', {
|
||||
detail: layerState._denseError || t('load failed'),
|
||||
const counts = _classTally();
|
||||
const legend = [];
|
||||
for (const id of SATELLITE_CLASS_IDS) {
|
||||
const shown =
|
||||
id === 'starlink'
|
||||
? layerState._params.catalog === 'dense'
|
||||
: classes[id] === true;
|
||||
if (!shown || !(counts[id] > 0)) continue;
|
||||
legend.push({
|
||||
klass: id,
|
||||
label: t(SATELLITE_CLASS_DISPLAY[id].label),
|
||||
color: SATELLITE_CLASS_DISPLAY[id].color,
|
||||
blurb: t(SATELLITE_CLASS_DISPLAY[id].blurb),
|
||||
count: counts[id],
|
||||
});
|
||||
else if (active)
|
||||
title = t(
|
||||
'Showing the full Starlink shell — click for the core catalog only',
|
||||
);
|
||||
return {
|
||||
chips: [
|
||||
{
|
||||
id: 'catalog',
|
||||
label: loading
|
||||
? t('DENSE ···')
|
||||
: failed
|
||||
? t('DENSE ✕')
|
||||
: t('DENSE'),
|
||||
active,
|
||||
busy: loading,
|
||||
disabled: loading,
|
||||
state: loading
|
||||
? 'loading'
|
||||
: failed
|
||||
? 'error'
|
||||
: active
|
||||
? 'active'
|
||||
: 'idle',
|
||||
title,
|
||||
params: { catalog: active ? 'core' : 'dense' },
|
||||
},
|
||||
],
|
||||
legend: satelliteClassLegend(_classTally()),
|
||||
};
|
||||
}
|
||||
return { chips, legend, ...classEvidenceNotice() };
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -562,6 +706,9 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
? 'stale'
|
||||
: 'nominal',
|
||||
error: layerState._lastError,
|
||||
// SATCAT refines the classes; when it is stale or unavailable the
|
||||
// classes rest on UCS, names and groups alone.
|
||||
classEvidence: layerState._satcatState,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -570,6 +717,10 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
_pointStyleFor,
|
||||
_notifyRowControls,
|
||||
_classTally,
|
||||
_classVisible,
|
||||
_applyClassVisibility,
|
||||
_issOrbitVisible,
|
||||
_syncIssOrbit,
|
||||
satelliteVisualsVisible,
|
||||
satelliteCatalogModeChanged,
|
||||
methods,
|
||||
|
||||
@@ -3,6 +3,21 @@ 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;
|
||||
|
||||
/**
|
||||
* How long a complete, current SATCAT read is kept before the layer asks
|
||||
* again, ms. SATCAT changes daily and the proxy caches it a day, so the
|
||||
* five-minute TLE update does not read all eleven groups each time.
|
||||
*/
|
||||
export const SATCAT_KEEP_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
export function createIngestion({
|
||||
state: layerState,
|
||||
@@ -10,6 +25,141 @@ 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([]);
|
||||
// A complete, current read is kept; a stale or failed one is asked again.
|
||||
if (
|
||||
layerState._satcatState === 'current' &&
|
||||
Date.now() - layerState._satcatReadAtMs < SATCAT_KEEP_MS
|
||||
)
|
||||
return Promise.resolve([]);
|
||||
return Promise.all(
|
||||
CATALOG_GROUPS.map((groupDef) =>
|
||||
source
|
||||
.readSatcat(groupDef.path, { signal })
|
||||
.then((res) =>
|
||||
res?.ok
|
||||
? { records: parseSatcatCsv(res.text), stale: Boolean(res.stale) }
|
||||
: null,
|
||||
)
|
||||
.catch((error) => {
|
||||
if (signal.aborted || error?.name === 'AbortError') throw error;
|
||||
return null;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge SATCAT reads and say how current they are: 'current', 'stale' (a
|
||||
* group came from the proxy's last copy) or 'unavailable' (a group failed;
|
||||
* its earlier records, if any, stay).
|
||||
*/
|
||||
function _mergeSatcat(groups) {
|
||||
let satcatState = 'current';
|
||||
for (const group of groups) {
|
||||
if (!group) {
|
||||
satcatState = 'unavailable';
|
||||
continue;
|
||||
}
|
||||
if (group.stale && satcatState === 'current') satcatState = 'stale';
|
||||
for (const [noradId, record] of group.records) {
|
||||
// A group served from the proxy's last copy never overrules a
|
||||
// current read of the same object, from this merge or an earlier one.
|
||||
const held = layerState._satcat.get(noradId);
|
||||
if (group.stale && held && !held.stale) continue;
|
||||
layerState._satcat.set(
|
||||
noradId,
|
||||
group.stale ? { ...record, stale: true } : record,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!groups.length) return;
|
||||
const changed = layerState._satcatState !== satcatState;
|
||||
layerState._satcatState = satcatState;
|
||||
layerState._satcatReadAtMs = Date.now();
|
||||
if (satcatState !== 'current')
|
||||
console.warn(`[Data:Satellites] SATCAT ${satcatState}: classes may lag`);
|
||||
// The row's info line says so (getRowControls).
|
||||
if (changed) parts.controls._notifyRowControls();
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the catalog again once SATCAT lands after it was built: only
|
||||
* entries whose SATCAT record changed are read again; a changed class
|
||||
* restyles its point and refreshes visibility, the legend and the overlay.
|
||||
*/
|
||||
function _reclassifyFromSatcat() {
|
||||
let changed = false;
|
||||
for (const [noradId, sat] of layerState._catalog) {
|
||||
if (sat.group === 'dense') continue;
|
||||
const satcat = layerState._satcat.get(noradId) || null;
|
||||
if (satcat === sat.satcat) continue;
|
||||
sat.satcat = satcat;
|
||||
const next = classifySatellite({
|
||||
noradId,
|
||||
name: sat.name,
|
||||
group: sat.group,
|
||||
groups: sat.groups,
|
||||
ucs: sat.ucs,
|
||||
satcat,
|
||||
launchYear: sat.launchYear,
|
||||
});
|
||||
if (next === sat.satelliteClass) continue;
|
||||
sat.satelliteClass = next;
|
||||
changed = true;
|
||||
const point = layerState._points.get(noradId);
|
||||
if (point) {
|
||||
const style = parts.controls._pointStyleFor(noradId, sat.group);
|
||||
point.pixelSize = style.pixelSize;
|
||||
point.color = style.color;
|
||||
point.outlineColor = style.outlineColor;
|
||||
point.outlineWidth = style.outlineWidth;
|
||||
}
|
||||
}
|
||||
if (!changed) return;
|
||||
parts.controls._applyClassVisibility();
|
||||
layerState._catalogRevision++;
|
||||
parts.controls._notifyRowControls();
|
||||
}
|
||||
|
||||
/** Resolve null after `ms`, or at once when `signal` aborts. */
|
||||
function _waitOrAbort(ms, signal) {
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(resolve, ms, null);
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
resolve(null);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const methods = {
|
||||
async update(viewer, { signal = null } = {}) {
|
||||
const trackingRefreshEpoch = ++layerState._trackingRefreshEpoch;
|
||||
@@ -27,6 +177,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 +203,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 +232,7 @@ export function createIngestion({
|
||||
);
|
||||
|
||||
// CelesTrak outage guard (H3): if EVERY group failed, bail BEFORE clearing.
|
||||
// Wiping the collection + catalog here would blank all 838 satellites while
|
||||
// Wiping the collection + catalog here would blank every satellite while
|
||||
// the chip still read "just now". Keep the existing (stale) catalog on
|
||||
// screen and surface the outage instead — do NOT stamp _lastUpdate.
|
||||
if (results.every((r) => !r.ok)) {
|
||||
@@ -112,6 +282,18 @@ export function createIngestion({
|
||||
allEntries.push({ ...e, group: r.tag, stale: r.stale });
|
||||
}
|
||||
|
||||
// Every group each TLE came in, keyed by its catalog-number column:
|
||||
// the shown tag is the first, but classification reads them all.
|
||||
const groupsByNumber = new Map();
|
||||
for (const entry of allEntries) {
|
||||
const number = String(entry.line1 || '')
|
||||
.slice(2, 7)
|
||||
.trim();
|
||||
const groups = groupsByNumber.get(number);
|
||||
if (!groups) groupsByNumber.set(number, [entry.group]);
|
||||
else if (!groups.includes(entry.group)) groups.push(entry.group);
|
||||
}
|
||||
|
||||
// Deduplicate by NORAD ID — first (most specific) group tag wins
|
||||
const seen = new Set();
|
||||
let coreStale = false;
|
||||
@@ -125,12 +307,32 @@ export function createIngestion({
|
||||
seen.add(noradId);
|
||||
|
||||
// Store in catalog
|
||||
const launchYear = tleLineLaunchYear(entry.line1);
|
||||
const ucs = layerState._ucs?.get(noradId) || null;
|
||||
const satcat = layerState._satcat.get(noradId) || null;
|
||||
const groups = groupsByNumber.get(
|
||||
String(entry.line1 || '')
|
||||
.slice(2, 7)
|
||||
.trim(),
|
||||
) || [entry.group];
|
||||
layerState._catalog.set(noradId, {
|
||||
name: entry.name,
|
||||
satrec,
|
||||
group: entry.group,
|
||||
groups,
|
||||
// satellite.js does not keep the designator the year match reads.
|
||||
launchYear: tleLineLaunchYear(entry.line1),
|
||||
launchYear,
|
||||
satelliteClass: classifySatellite({
|
||||
noradId,
|
||||
name: entry.name,
|
||||
group: entry.group,
|
||||
groups,
|
||||
ucs,
|
||||
satcat,
|
||||
launchYear,
|
||||
}),
|
||||
ucs,
|
||||
satcat,
|
||||
});
|
||||
|
||||
coreStale ||= entry.stale;
|
||||
@@ -156,6 +358,8 @@ export function createIngestion({
|
||||
scaleByDistance: new Cesium.NearFarScalar(1e6, 1.5, 2e7, 0.6),
|
||||
id: noradId,
|
||||
});
|
||||
// Hidden classes keep their point, so a toggle needs no rebuild.
|
||||
point.show = parts.controls._classVisible(noradId);
|
||||
|
||||
layerState._points.set(noradId, point);
|
||||
}
|
||||
@@ -163,8 +367,7 @@ export function createIngestion({
|
||||
// Show ISS orbital path by default
|
||||
if (layerState._catalog.has(ISS_NORAD)) {
|
||||
parts.rendering._showOrbitPath(ISS_NORAD, POINT_STYLES.iss.color);
|
||||
const issPath = layerState._orbitPaths.get(ISS_NORAD);
|
||||
if (issPath) issPath.primitive.show = layerState._params.showOrbits;
|
||||
parts.controls._syncIssOrbit();
|
||||
|
||||
parts.labels._syncIssOverlay();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseTleText, tleLineLaunchYear } from '../../sources/tle.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import {
|
||||
SATCAT_KEEP_MS,
|
||||
SATCAT_WAIT_MS,
|
||||
createIngestion,
|
||||
} from './ingestion.js';
|
||||
import { createOrbits } from './orbits.js';
|
||||
import { createControls } from './controls.js';
|
||||
|
||||
// The ISS set (1 January 2024) with its catalog field swapped per satellite.
|
||||
const ISS_LINE1 =
|
||||
@@ -18,7 +23,7 @@ function tleSet(name, field) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function ingestionFor(catalogText) {
|
||||
function ingestionFor(catalogText, { source: sourceExtras = {} } = {}) {
|
||||
const state = {
|
||||
_trackingRefreshEpoch: 0,
|
||||
_activeUpdateControllers: new Set(),
|
||||
@@ -33,6 +38,8 @@ function ingestionFor(catalogText) {
|
||||
_denseLoadToken: 0,
|
||||
_catalogRevision: 0,
|
||||
_params: { catalog: 'core', showOrbits: false },
|
||||
_satcat: new Map(),
|
||||
_ucs: null,
|
||||
now: Date.now,
|
||||
coverageChanged() {},
|
||||
};
|
||||
@@ -42,7 +49,17 @@ function ingestionFor(catalogText) {
|
||||
propagatePosition: () => ({ longitude: 0, latitude: 0, altitude: 4e5 }),
|
||||
},
|
||||
labels: { _syncIssOverlay() {} },
|
||||
controls: { _pointStyleFor: () => ({}) },
|
||||
controls: {
|
||||
_pointStyleFor: () => ({}),
|
||||
_classVisible: () => true,
|
||||
_syncIssOrbit() {},
|
||||
_applyClassVisibility() {
|
||||
state.visibilityApplied = (state.visibilityApplied || 0) + 1;
|
||||
},
|
||||
_notifyRowControls() {
|
||||
state.rowNotified = (state.rowNotified || 0) + 1;
|
||||
},
|
||||
},
|
||||
rendering: { _showOrbitPath() {} },
|
||||
tracking: {
|
||||
async _reconcileTrackedSubjectContext() {},
|
||||
@@ -51,9 +68,12 @@ function ingestionFor(catalogText) {
|
||||
catalog: {},
|
||||
};
|
||||
const source = {
|
||||
async readGroup() {
|
||||
return { ok: true, text: catalogText };
|
||||
async readGroup(path) {
|
||||
const text =
|
||||
typeof catalogText === 'function' ? catalogText(path) : catalogText;
|
||||
return { ok: true, text };
|
||||
},
|
||||
...sourceExtras,
|
||||
};
|
||||
const { methods } = createIngestion({ state, services: {}, parts, source });
|
||||
return { state, update: () => methods.update({ scene: {} }) };
|
||||
@@ -121,3 +141,155 @@ test('a satellite with a blank designator keeps matching by name', async (t) =>
|
||||
69728,
|
||||
);
|
||||
});
|
||||
|
||||
test('a hanging SATCAT read neither holds the catalog nor is lost when it lands', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let answer;
|
||||
const satcatAnswer = new Promise((resolve) => {
|
||||
answer = resolve;
|
||||
});
|
||||
const { state, update } = ingestionFor(
|
||||
(path) => (path === 'stations' ? tleSet('OLD STAGE', '40115') : ''),
|
||||
{ source: { readSatcat: () => satcatAnswer } },
|
||||
);
|
||||
state._enabled = true;
|
||||
const settle = async () => {
|
||||
for (let turn = 0; turn < 20; turn += 1)
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
};
|
||||
const rebuilt = update();
|
||||
await settle();
|
||||
t.mock.timers.tick(SATCAT_WAIT_MS);
|
||||
await rebuilt;
|
||||
// Built without SATCAT: neither name nor group says what it is (the
|
||||
// stations group alone makes no station).
|
||||
assert.equal(state._catalog.get(40115).satelliteClass, 'probes');
|
||||
const revision = state._catalogRevision;
|
||||
// SATCAT lands late: a rocket body.
|
||||
answer({
|
||||
ok: true,
|
||||
stale: true,
|
||||
text: 'OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE,OWNER\nOLD STAGE,2014-048B,40115,R/B,,US',
|
||||
});
|
||||
await settle();
|
||||
assert.equal(state._catalog.get(40115).satelliteClass, 'debris');
|
||||
assert.equal(state._catalogRevision, revision + 1, 'the legend refreshes');
|
||||
assert.ok(state.visibilityApplied >= 1 && state.rowNotified >= 1);
|
||||
assert.equal(state._satcatState, 'stale', 'its staleness is kept');
|
||||
});
|
||||
|
||||
test('a satellite in visual and resource is imaging, whichever group shows it', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
// A name no pattern knows, no UCS record and no SATCAT: only its groups.
|
||||
const { state, update } = ingestionFor((path) =>
|
||||
path === 'visual' || path === 'resource'
|
||||
? tleSet('ZZ TESTSAT', '50001')
|
||||
: '',
|
||||
);
|
||||
await update();
|
||||
const sat = state._catalog.get(50001);
|
||||
assert.equal(sat.group, 'visual', 'shown under the first group');
|
||||
assert.deepEqual(sat.groups, ['visual', 'resource']);
|
||||
assert.equal(sat.satelliteClass, 'imaging');
|
||||
});
|
||||
|
||||
test('a stale SATCAT copy never overrules a current read of the same object', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const header =
|
||||
'OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE,OWNER\n';
|
||||
const { state, update } = ingestionFor(tleSet('ZZ TESTSAT', '50002'), {
|
||||
source: {
|
||||
// visual is current and says it died; resource, a cached copy, still
|
||||
// says it works.
|
||||
readSatcat: async (path) =>
|
||||
path === 'visual'
|
||||
? { ok: true, text: `${header}ZZ TESTSAT,2015-001A,50002,PAY,-,US` }
|
||||
: path === 'resource'
|
||||
? {
|
||||
ok: true,
|
||||
stale: true,
|
||||
text: `${header}ZZ TESTSAT,2015-001A,50002,PAY,+,US`,
|
||||
}
|
||||
: { ok: true, text: header },
|
||||
},
|
||||
});
|
||||
await update();
|
||||
assert.equal(state._catalog.get(50002).satelliteClass, 'debris');
|
||||
});
|
||||
|
||||
test('a failed SATCAT read is told on the layer row, not only the console', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const { state, update } = ingestionFor(tleSet('OLD STAGE', '40115'), {
|
||||
source: { readSatcat: async () => ({ ok: false, text: '' }) },
|
||||
});
|
||||
await update();
|
||||
assert.equal(state._satcatState, 'unavailable');
|
||||
assert.ok(state.rowNotified >= 1, 'the row repaints');
|
||||
const controls = createControls({
|
||||
state: {
|
||||
...state,
|
||||
_params: { ...state._params, showPoints: true },
|
||||
_classTallyCache: {},
|
||||
},
|
||||
services: { layerState: { isExplicitLayerStateOrigin: () => false } },
|
||||
parts: {},
|
||||
source: {},
|
||||
});
|
||||
const row = controls.methods.getRowControls();
|
||||
assert.equal(row.info, 'Classes may lag: SATCAT unavailable');
|
||||
assert.match(row.infoTitle, /SATCAT/);
|
||||
const current = createControls({
|
||||
state: {
|
||||
...state,
|
||||
_satcatState: 'current',
|
||||
_params: { ...state._params, showPoints: true },
|
||||
_classTallyCache: {},
|
||||
},
|
||||
services: { layerState: { isExplicitLayerStateOrigin: () => false } },
|
||||
parts: {},
|
||||
source: {},
|
||||
});
|
||||
assert.equal(current.methods.getRowControls().info, '');
|
||||
});
|
||||
|
||||
test('a current SATCAT read is kept between updates; a failed one is asked again', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const header =
|
||||
'OBJECT_NAME,OBJECT_ID,NORAD_CAT_ID,OBJECT_TYPE,OPS_STATUS_CODE\n';
|
||||
let reads = 0;
|
||||
let ok = true;
|
||||
const { state, update } = ingestionFor(tleSet('ZZ TESTSAT', '50003'), {
|
||||
source: {
|
||||
readSatcat: async () => {
|
||||
reads++;
|
||||
return ok
|
||||
? { ok: true, text: `${header}ZZ TESTSAT,2015-001A,50003,PAY,-` }
|
||||
: { ok: false, text: '' };
|
||||
},
|
||||
},
|
||||
});
|
||||
await update();
|
||||
const perUpdate = reads;
|
||||
assert.ok(perUpdate > 0);
|
||||
await update();
|
||||
assert.equal(reads, perUpdate, 'a fresh read is not asked again');
|
||||
assert.equal(
|
||||
state._catalog.get(50003).satelliteClass,
|
||||
'debris',
|
||||
'the held records still class the rebuilt catalog',
|
||||
);
|
||||
|
||||
state._satcatReadAtMs -= SATCAT_KEEP_MS;
|
||||
ok = false;
|
||||
await update();
|
||||
assert.equal(reads, 2 * perUpdate, 'an old read is asked again');
|
||||
assert.equal(state._satcatState, 'unavailable');
|
||||
await update();
|
||||
assert.equal(reads, 3 * perUpdate, 'a failed read is asked again');
|
||||
});
|
||||
@@ -83,6 +83,8 @@ export function createLabels({ state: layerState, services, parts, source }) {
|
||||
function _dockedCompanionNames() {
|
||||
const names = [];
|
||||
for (const noradId of layerState._dockedCompanions) {
|
||||
// A hidden class is not named on the tracked card either.
|
||||
if (parts.controls?._classVisible?.(noradId) === false) continue;
|
||||
names.push(
|
||||
layerState._catalog.get(noradId)?.name?.trim() || `SAT-${noradId}`,
|
||||
);
|
||||
@@ -102,6 +104,7 @@ export function createLabels({ state: layerState, services, parts, source }) {
|
||||
layerState._trackedNorad !== ISS_NORAD &&
|
||||
!layerState._dockedCompanions.has(ISS_NORAD) &&
|
||||
layerState._catalog.has(ISS_NORAD) &&
|
||||
parts.controls?._classVisible?.(ISS_NORAD) !== false &&
|
||||
_issDisplayCached();
|
||||
if (!visible) {
|
||||
layerState._overlayHost.clearSource(ISS_OVERLAY_SOURCE_ID);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { ISS_OVERLAY_SOURCE_ID, SATELLITE_COVERAGE_MS } from './policy.js';
|
||||
import {
|
||||
ISS_NORAD,
|
||||
ISS_OVERLAY_SOURCE_ID,
|
||||
SATELLITE_COVERAGE_MS,
|
||||
} from './policy.js';
|
||||
import { worldClock } from '../../time/worldClock.js';
|
||||
|
||||
export function createLifecycle({
|
||||
@@ -88,11 +92,14 @@ export function createLifecycle({
|
||||
layerState._params.showPoints,
|
||||
);
|
||||
// Orbit ring primitives + persistent ISS host label — show them
|
||||
for (const path of layerState._orbitPaths.values())
|
||||
path.primitive.show = parts.controls.satelliteVisualsVisible(
|
||||
layerState._enabled,
|
||||
layerState._params.showOrbits,
|
||||
);
|
||||
for (const [noradId, path] of layerState._orbitPaths)
|
||||
path.primitive.show =
|
||||
noradId === ISS_NORAD
|
||||
? parts.controls._issOrbitVisible()
|
||||
: parts.controls.satelliteVisualsVisible(
|
||||
layerState._enabled,
|
||||
layerState._params.showOrbits,
|
||||
);
|
||||
parts.labels._syncIssOverlay();
|
||||
// Re-attach input handlers and preRender propagation
|
||||
parts.interaction._installClickHandler(viewer);
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { satelliteClassColor } from '../../data/satelliteClass.js';
|
||||
import { SATELLITE_CLASS_DISPLAY, SATELLITE_CLASS_IDS } from './classes.js';
|
||||
|
||||
/**
|
||||
* Satellite Orbits — Real-time positions via CelesTrak TLE + SGP4 propagation.
|
||||
*
|
||||
* Loads six CelesTrak groups (~840 sats): stations, visual, GPS, GLONASS,
|
||||
* Galileo, and the geosynchronous belt. Optional dense mode (setParams
|
||||
* catalog:'dense') adds the Starlink shell as points-only extras.
|
||||
* Loads eleven CelesTrak groups: stations, visual, GPS, GLONASS, Galileo, the
|
||||
* geosynchronous belt, and the Earth resources, weather, Planet, SAR and
|
||||
* science groups. Each object is filed under one of six classes
|
||||
* (src/layers/satellites/classes.js) whose visibility the layer row toggles; dense mode
|
||||
* (setParams catalog:'dense', the STARLINK toggle) adds the Starlink shell as
|
||||
* points-only extras.
|
||||
* Renders positions via PointPrimitiveCollection, orbital paths as polylines.
|
||||
* Click any satellite to track it with camera follow + orbital path.
|
||||
*
|
||||
@@ -58,6 +62,13 @@ export const CATALOG_GROUPS = [
|
||||
{ tag: 'glonass', path: 'glo-ops' },
|
||||
{ tag: 'galileo', path: 'galileo' },
|
||||
{ tag: 'geo', path: 'geo' },
|
||||
// Earth observation launched after the UCS snapshot, and science probes.
|
||||
// Each object's class comes from src/layers/satellites/classes.js, not its group.
|
||||
{ tag: 'resource', path: 'resource' },
|
||||
{ tag: 'weather', path: 'weather' },
|
||||
{ tag: 'planet', path: 'planet' },
|
||||
{ tag: 'sar', path: 'sar' },
|
||||
{ tag: 'science', path: 'science' },
|
||||
];
|
||||
|
||||
// Dense-catalog mode (setParams({ catalog: 'dense' })): Starlink shell as
|
||||
@@ -166,6 +177,37 @@ export const POINT_STYLES = {
|
||||
},
|
||||
};
|
||||
|
||||
const CLASS_COLORS = Object.fromEntries(
|
||||
SATELLITE_CLASS_IDS.map((id) => [
|
||||
id,
|
||||
Cesium.Color.fromCssColorString(SATELLITE_CLASS_DISPLAY[id].color),
|
||||
]),
|
||||
);
|
||||
const _classStyles = new Map();
|
||||
|
||||
/**
|
||||
* A satellite's point style: its group's size and outline, in its class's
|
||||
* colour (src/layers/satellites/classes.js), so the dots match the class legend and
|
||||
* toggles. Styles are shared per group and class.
|
||||
* @param {string|undefined} group Catalog group tag.
|
||||
* @param {string|undefined} satelliteClass Class id.
|
||||
*/
|
||||
export function classPointStyle(group, satelliteClass) {
|
||||
const base = POINT_STYLES[group] || POINT_STYLES.visual;
|
||||
const color = CLASS_COLORS[satelliteClass];
|
||||
if (!color) return base;
|
||||
const key = `${group}|${satelliteClass}`;
|
||||
let style = _classStyles.get(key);
|
||||
if (!style) {
|
||||
style = Object.freeze({
|
||||
...base,
|
||||
color: group === 'dense' ? color.withAlpha(0.9) : color,
|
||||
});
|
||||
_classStyles.set(key, style);
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
/**
|
||||
* Physically DOCKED vehicles are separate real tracks sharing one position: the
|
||||
* station and everything berthed to it sit within metres of each other. Their
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { satelliteClassLabel } from '../../data/satelliteClass.js';
|
||||
import { satelliteClassText } from '../../data/satelliteClass.js';
|
||||
import { ISS_NORAD } from './policy.js';
|
||||
|
||||
/**
|
||||
@@ -38,7 +38,10 @@ export function mapAnalystRecord(raw) {
|
||||
// must not leak onto a row that has no satellite identity.
|
||||
satelliteClass:
|
||||
noradId || group
|
||||
? satelliteClassLabel(group, { isIss, localize: false })
|
||||
? satelliteClassText(text(raw?.satelliteClass), group, {
|
||||
isIss,
|
||||
localize: false,
|
||||
})
|
||||
: null,
|
||||
group,
|
||||
};
|
||||
@@ -83,6 +86,12 @@ export function createRecords({ state, parts }) {
|
||||
const result = [];
|
||||
const emit = (noradId, sat) => {
|
||||
if (result.length >= limit) return false;
|
||||
// A class the user hid stays out of answers too; the tracked one stays.
|
||||
if (
|
||||
noradId !== state._trackedNorad &&
|
||||
parts.controls?._classVisible?.(noradId) === false
|
||||
)
|
||||
return true;
|
||||
const pos = analystGeo(noradId, sat, now);
|
||||
if (!pos) return true;
|
||||
result.push(
|
||||
@@ -90,6 +99,7 @@ export function createRecords({ state, parts }) {
|
||||
noradId,
|
||||
name: sat?.name,
|
||||
group: sat?.group,
|
||||
satelliteClass: sat?.satelliteClass,
|
||||
lat: pos.lat,
|
||||
lon: pos.lon,
|
||||
altitudeM: pos.altitudeM,
|
||||
|
||||
@@ -152,7 +152,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';
|
||||
import { worldClock, worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
export function createState({ services }) {
|
||||
@@ -14,7 +15,9 @@ export function createState({ services }) {
|
||||
|
||||
state._overlayHost = state.DEFAULT_OVERLAY_HOST;
|
||||
|
||||
// Satellite catalog: { noradId → { name, satrec, group } }
|
||||
// Satellite catalog: { noradId → { name, satrec, group, launchYear,
|
||||
// satelliteClass, ucs, satcat } } — class, UCS and SATCAT records from
|
||||
// src/layers/satellites/classes.js and the sources the update read.
|
||||
|
||||
state._catalog = new Map();
|
||||
|
||||
@@ -43,6 +46,12 @@ export function createState({ services }) {
|
||||
|
||||
// Cache provenance belongs to admitted core and dense records separately.
|
||||
state._coreStale = false;
|
||||
// How current the SATCAT records behind the classes are: null before any
|
||||
// read, then 'current' | 'stale' | 'unavailable' (see ingestion.js).
|
||||
state._satcatState = null;
|
||||
// When _satcatState was last set (Date.now()); a current read is kept
|
||||
// SATCAT_KEEP_MS (src/layers/satellites/ingestion.js).
|
||||
state._satcatReadAtMs = Number.NEGATIVE_INFINITY;
|
||||
state._denseStale = false;
|
||||
|
||||
state._activeUpdateControllers = new Set();
|
||||
@@ -93,9 +102,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
|
||||
|
||||
|
||||
@@ -26,10 +26,14 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
// Extra catalog/point rows so a test can exercise a docked cluster (and a
|
||||
// control satellite that must NOT be treated as part of it).
|
||||
neighbours = [],
|
||||
// Optional class of the tracked satellite and class visibility, e.g.
|
||||
// { telecom: false }.
|
||||
satelliteClass = undefined,
|
||||
classes = null,
|
||||
}) {
|
||||
layerState._viewer = viewer;
|
||||
layerState._catalog = new Map([
|
||||
[noradId, { name, satrec, group: 'stations' }],
|
||||
[noradId, { name, satrec, group: 'stations', satelliteClass }],
|
||||
]);
|
||||
layerState._points = new Map([[noradId, point]]);
|
||||
for (const neighbour of neighbours) {
|
||||
@@ -37,6 +41,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
name: neighbour.name,
|
||||
satrec: neighbour.satrec || satrec,
|
||||
group: neighbour.group || 'stations',
|
||||
satelliteClass: neighbour.satelliteClass,
|
||||
});
|
||||
layerState._points.set(neighbour.noradId, neighbour.point);
|
||||
}
|
||||
@@ -51,6 +56,7 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
catalog: 'core',
|
||||
showPoints: false,
|
||||
showOrbits: false,
|
||||
...(classes ? { classes } : {}),
|
||||
};
|
||||
layerState._enabled = true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { satelliteClassLabel } from '../../data/satelliteClass.js';
|
||||
import { satelliteClassText } from '../../data/satelliteClass.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { formatQuantity, unitLabel } from '../../ui/unitLabels.js';
|
||||
import {
|
||||
@@ -95,7 +95,8 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
||||
layerState._trackedNorad,
|
||||
layerState._catalog.get(layerState._trackedNorad)?.group,
|
||||
);
|
||||
lastPos.show = true;
|
||||
// Back to its class's visibility: a hidden class stays hidden.
|
||||
lastPos.show = parts.controls._classVisible(layerState._trackedNorad);
|
||||
lastPos.pixelSize = style.pixelSize;
|
||||
lastPos.color = style.color;
|
||||
lastPos.outlineColor = style.outlineColor;
|
||||
@@ -118,6 +119,7 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
||||
layerState._trackedEntity = null;
|
||||
}
|
||||
layerState._trackedNorad = null;
|
||||
parts.controls._syncIssOrbit?.();
|
||||
parts.labels._syncIssOverlay();
|
||||
clearTrackedSubjectContext('satellites');
|
||||
layerState._contextRefreshedAtMs = 0;
|
||||
@@ -241,7 +243,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 +368,7 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
||||
// looking at, and it stays readable under the IR styles that flatten the
|
||||
// dot colors to a single channel (the card is painted above post-FX).
|
||||
const details = [
|
||||
satelliteClassLabel(sat?.group, {
|
||||
satelliteClassText(sat?.satelliteClass, sat?.group, {
|
||||
isIss: layerState._trackedNorad === ISS_NORAD,
|
||||
}),
|
||||
detail,
|
||||
@@ -420,6 +422,7 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
||||
|
||||
// Show orbital path
|
||||
parts.rendering._showOrbitPath(noradId, Cesium.Color.YELLOW);
|
||||
if (noradId === ISS_NORAD) parts.controls._syncIssOrbit?.();
|
||||
|
||||
// Tracked entity position propagates per evaluation through the per-frame
|
||||
// cache (WS-D2) — dot, host readout, and camera share one SGP4 epoch per frame.
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -274,7 +274,11 @@ export const SHARE_VIEW_LAYER_OPTION_ABSENT = Object.freeze({
|
||||
selectedFlightsTrackingId: null,
|
||||
selectedMilitaryTrackingId: null,
|
||||
}),
|
||||
satellites: Object.freeze({ catalog: 'core', selectedSatTrackingId: null }),
|
||||
satellites: Object.freeze({
|
||||
catalog: 'core',
|
||||
selectedSatTrackingId: null,
|
||||
classes: 'sitpd',
|
||||
}),
|
||||
cctv: Object.freeze({
|
||||
coverageMode: 'on',
|
||||
showProjection: true,
|
||||
|
||||
@@ -303,6 +303,96 @@ 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('a SATCAT failure never holds off the TLEs, but CelesTrak throttling holds off both', async (t) => {
|
||||
isolateDisk(t);
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const tle =
|
||||
'ISS (ZARYA)\n1 25544U 98067A 24001.00000000 .00016717 00000-0 10270-3 0 9001\n2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.50000000 04\n';
|
||||
let satcatAnswer = () => new Response('<html>not csv</html>');
|
||||
const fetched = [];
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const parsed = new URL(url);
|
||||
fetched.push(parsed.pathname);
|
||||
return parsed.pathname.startsWith('/satcat/')
|
||||
? satcatAnswer()
|
||||
: new Response(tle);
|
||||
});
|
||||
const request = install(celestrakProxy());
|
||||
assert.equal((await request('/api/celestrak-satcat', '/sar')).status, 502);
|
||||
const starlink = await request('/api/celestrak', '/starlink');
|
||||
assert.equal(starlink.status, 200, 'the TLE feed is not cooling down');
|
||||
assert.equal(starlink.body, tle);
|
||||
|
||||
// A 429 is CelesTrak throttling this client: every feed waits it out.
|
||||
satcatAnswer = () =>
|
||||
new Response('slow down', {
|
||||
status: 429,
|
||||
headers: { 'Retry-After': '120' },
|
||||
});
|
||||
const throttled = install(celestrakProxy());
|
||||
await throttled('/api/celestrak-satcat', '/visual');
|
||||
const before = fetched.length;
|
||||
const held = await throttled('/api/celestrak', '/geo');
|
||||
assert.equal(held.status, 502);
|
||||
assert.equal(fetched.length, before, 'nothing went upstream');
|
||||
});
|
||||
|
||||
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