fix(satellites): the ISS ring follows its class through re-enable and rebuilds and stays while tracked; a mission group outranks geo; an Unknown UCS purpose is no evidence; the UNK grace is stated in calendar years

This commit is contained in:
Sameh Khamis committed 2026-11-08 15:16:01 -08:00
1 parent 83dd1af22e
commit eaf2b8f5b4
8 files changed
+142 -19

No files matched your search

+16 -3
View File
@@ -96,7 +96,9 @@ const ISS_NORAD = 25544;
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 this recently (years) is not presumed debris.
* 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. */
@@ -143,6 +145,9 @@ const GROUP_CLASS = Object.freeze({
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 || ''));
@@ -192,7 +197,10 @@ export function classifySatellite({
if (STATION_NAME.test(label)) return 'stations';
if (group === 'dense' || STARLINK_NAME.test(label)) return 'starlink';
const purpose = ucs?.purpose || '';
// 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';
@@ -212,8 +220,13 @@ export function classifySatellite({
)
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)
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';
}
+98
View File
@@ -385,3 +385,101 @@ test('every ingested group names the class its unnamed members fall back to', ()
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(),
};
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');
});
+14 -4
View File
@@ -56,22 +56,31 @@ export function createControls({ state: layerState, services, parts, source }) {
if (noradId === layerState._trackedNorad) continue;
point.show = _classVisible(noradId);
}
const issPath = layerState._orbitPaths.get(ISS_NORAD);
if (issPath) issPath.primitive.show = _issOrbitVisible();
_syncIssOrbit();
parts.labels?._syncIssOverlay?.();
layerState._detectionObjects.clear();
}
/** Whether the ISS's ring shows: orbits on, the layer drawing, its class on. */
/**
* 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)
) &&
(_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.
@@ -708,6 +717,7 @@ export function createControls({ state: layerState, services, parts, source }) {
_classVisible,
_applyClassVisibility,
_issOrbitVisible,
_syncIssOrbit,
satelliteVisualsVisible,
satelliteCatalogModeChanged,
methods,
+1 -5
View File
@@ -367,11 +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._classVisible(ISS_NORAD);
parts.controls._syncIssOrbit();
parts.labels._syncIssOverlay();
}
+1
View File
@@ -50,6 +50,7 @@ function ingestionFor(catalogText, { source: sourceExtras = {} } = {}) {
controls: {
_pointStyleFor: () => ({}),
_classVisible: () => true,
_syncIssOrbit() {},
_applyClassVisibility() {
state.visibilityApplied = (state.visibilityApplied || 0) + 1;
},
+9 -6
View File
@@ -1,5 +1,5 @@
import * as Cesium from 'cesium';
import { ISS_OVERLAY_SOURCE_ID } from './policy.js';
import { ISS_NORAD, ISS_OVERLAY_SOURCE_ID } from './policy.js';
export function createLifecycle({
state: layerState,
@@ -72,11 +72,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);
+2
View File
@@ -119,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;
@@ -421,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.