diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 46b608a8d..325f571e2 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5458,7 +5458,7 @@ silently demoting every later lookup for the session. - **Satellite classes** (`src/layers/satellites/classes.js`): every satellite is filed under one of six classes, each with its own point colour, legend swatch and toggle: STATIONS (warm white), IMAGING (cyan), TELECOM (violet; communications and navigation, so GPS, GLONASS, Galileo and BeiDou read as one family), STARLINK (dim slate), PROBES (muted blue-gray) and DEBRIS (receding brown-gray). That module is the single source of truth for class, label and colour, so the dot, the card and the legend swatch cannot disagree. Two deliberate palette rules: no class may sit in the 40–48° amber band, which is the app-wide known-military convention; and STARLINK stays far below PROBES in Rec.601 luminance so the dense shell stays separable when NVG/FLIR collapse the scene to one channel. The ISS keeps its long-standing red hero dot rather than the STATIONS colour — it carries a permanent name label and its card still reads `STATIONS · ISS`; it is a station whichever group ingested it, so a stations-feed outage cannot refile it. `src/data/satelliteClass.js` keeps the group-based labels beneath the classes: the constellation subtype and a group's own label for a satellite with no class. - Class is also a **text field**, not just a color: `TELECOM · GPS` / `STARLINK` / `STATIONS · ISS` leads the tracked card's detail block and replaces the raw CelesTrak tag on the detection-overlay label. Because that canvas composites above the post-FX chain, the class stays readable in NVG/FLIR after the in-scene dot colors are flattened. - The satellites row in DATA LAYERS carries per-layer sub-controls (`DataLayerManager._syncRowControls`, the first consumer of the optional `getRowControls()` layer hook): one chip per class (the STARLINK chip exposes the existing `catalog` param), a swatch legend with live per-class counts, and an info line while SATCAT is stale or unavailable (`Classes may lag: SATCAT is a cached copy` / `Classes may lag: SATCAT unavailable`). Default is the sparse core catalog. The chips are stateless — each declares the params to apply and the manager owns the write — so the Space Missions capture/restore path over the same param stays authoritative. Controls stay hidden while the layer is off. An explicit CORE or DENSE choice participates in versioned local and share-link state; temporary Space Missions overrides do not. -- **Class toggles** (`src/layers/satellites/classes.js`): the layer row carries one chip per class — STATIONS, IMAGING, TELECOM (communications and navigation), PROBES (science, technology development and demonstration, unknown purpose) and DEBRIS (rocket bodies, fragments, unknown objects and dead payloads) on by default, so the layer shows what it did before classes; STARLINK off, as DENSE was. Points of a hidden class stay built and are only hidden, so a toggle needs no reload; hidden classes are left out of the detection overlay and the voice/framing position lists, and the legend keys only the classes on screen. The class comes, strongest evidence first, from CelesTrak SATCAT (object type and operational status, fetched per group through `/api/celestrak-satcat/`, cached a day), the bundled UCS Satellite Database pack (UCS-Satellite-Database 5-1-2023: operator, owner country and purpose; `src/data/local_data/ucs_satellites/`), name patterns for satellites launched after May 2023, and finally the CelesTrak group. Only crewed stations and the craft that visit them, by name, are STATIONS: the CubeSats CelesTrak's stations group also carries after their deployment from the ISS are classed like any other satellite. A payload whose SATCAT record gives no status, with no UCS record and no recognisable name, that launched 20 or more years ago (SATCAT's launch date, else the TLE designator's year) is presumed dead and filed as debris; without a SATCAT record (a failed read) nothing is presumed. To cover imaging launched after the UCS snapshot the core catalog also reads CelesTrak's `resource`, `weather`, `planet`, `sar` and `science` groups. Retired stations and the shuttles have re-entered and have no orbit to show. Class visibility is a runtime param (`classes`) and is not yet part of share links; STARLINK maps to the existing `catalog: 'dense'` param, which is. +- **Class toggles** (`src/layers/satellites/classes.js`): the layer row carries one chip per class — STATIONS, IMAGING, TELECOM (communications and navigation), PROBES (science, technology development and demonstration, unknown purpose) and DEBRIS (rocket bodies, fragments, unknown objects and dead payloads) on by default, so the layer shows what it did before classes; STARLINK off, as DENSE was. Points of a hidden class stay built and are only hidden, so a toggle needs no reload; hidden classes are left out of the detection overlay and the voice/framing position lists, and the legend keys only the classes on screen. The class comes, strongest evidence first, from CelesTrak SATCAT (object type and operational status, fetched per group through `/api/celestrak-satcat/`, cached a day), the bundled UCS Satellite Database pack (UCS-Satellite-Database 5-1-2023: operator, owner country and purpose; `src/data/local_data/ucs_satellites/`), name patterns for satellites launched after May 2023, and finally the CelesTrak group. Only crewed stations and the craft that visit them, by name, are STATIONS: the CubeSats CelesTrak's stations group also carries after their deployment from the ISS are classed like any other satellite. A payload whose SATCAT record gives no status, with no UCS record and no recognisable name, that launched 20 or more years ago (SATCAT's launch date, else the TLE designator's year) is presumed dead and filed as debris; without a SATCAT record (a failed read) nothing is presumed. To cover imaging launched after the UCS snapshot the core catalog also reads CelesTrak's `resource`, `weather`, `planet`, `sar` and `science` groups. Retired stations and the shuttles have re-entered and have no orbit to show. An explicit class choice is kept in local and share-link state as the `classes` option, one letter per class shown (`sitpd`: stations, imaging, telecom, probes, debris; `0`: none; `src/data/satelliteClassMask.js`), and the layer takes those letters back through `setParams({classes})`; STARLINK maps to the existing `catalog: 'dense'` param instead. - **The STARLINK chip (formerly DENSE) reports the dense LOAD, not the catalog param.** The param flips synchronously while the Starlink shell takes seconds to arrive over a chunked load, and CelesTrak 502s that feed regularly. So the chip reads `STARLINK ···` (busy, disabled) while loading, ACTIVE only once dense points are actually on screen, and `STARLINK ✕` with the reason on hover when the load fails — a failure also reverts `catalog` to `core`, drops any partial chunk, and leaves the chip clickable to retry. A load is judged by points added, not by HTTP status: a 200 carrying an empty body, a passed-through HTML error page, or only TLEs the core catalog already owns fails with the same revert semantics as a 502. Any explicit request for `core` clears a latched error even when the mode does not change, so a Space Missions restore of an already-core snapshot never leaves the user with a failure they did not cause. Because the load settles asynchronously, the layer pushes a re-render through the optional `setRowControlsListener()` hook; nothing else would repaint that row before the 5-minute catalog refresh, so the count and legend would otherwise sit stale. - **A dependency owner takes the row with it.** Space Missions borrows this layer for TLE lookup with `showPoints:false`; while points are hidden the layer returns empty row controls, so the legend never describes an empty sky and the chip cannot accept a write that the owner's restore would silently revert. - The detection-overlay record cache (`_detectionObjects`) is cleared with the catalog on every rebuild: it stamps id/class at creation only, and a rebuild can re-tag a satellite when a partial CelesTrak outage changes which group wins dedupe. diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index fbfb9fbf1..f5443b2e5 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -990,6 +990,7 @@ "src/data/idSwitches.js", "src/data/firmsIntensity.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/data/militaryAwarenessEngine.js", @@ -1371,6 +1372,7 @@ "src/data/idSwitches.js", "src/data/firmsIntensity.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", "src/data/aircraftModelLighting.js", @@ -1434,6 +1436,7 @@ "src/data/idSwitches.js", "src/data/firmsIntensity.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", "src/data/aircraftModelLighting.js", @@ -1488,6 +1491,7 @@ "src/data/idSwitches.js", "src/data/firmsIntensity.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/data/modelVisualAnchor.js", @@ -1629,6 +1633,7 @@ "exports": ["./layers/satellites"], "modules": [ "src/data/satelliteClass.js", + "src/data/satelliteClassMask.js", "src/data/satellitePass.js", "src/sources/tle.js", "src/layers/satellites/catalog.js", @@ -2077,6 +2082,7 @@ "src/data/idSwitches.js", "src/data/firmsIntensity.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateTokenReservations.json", "src/data/radioCountry.js", "src/layers/radio/catalogModel.js", @@ -2469,6 +2475,7 @@ "src/data/labelArbiter.js", "src/data/idSwitches.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/data/lifecycle.js", @@ -3466,6 +3473,7 @@ "src/data/labelArbiter.js", "src/data/idSwitches.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateTokenReservations.json", "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js", @@ -4141,6 +4149,7 @@ "src/data/idSwitches.js", "src/data/firmsIntensity.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateCoordinator.js", "src/data/layerStateTokenReservations.json", "src/loadingFeedback.js", @@ -4884,6 +4893,7 @@ "src/view/index.js", "src/data/firmsIntensity.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateTokenReservations.json", "src/data/idSwitches.js", "src/data/detectionPolicy.js", @@ -5009,6 +5019,7 @@ "src/data/idSwitches.js", "src/data/vesselTypes.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateTokenReservations.json", "src/sources/gbfsStations.js", "src/sources/tle.js", @@ -5145,6 +5156,7 @@ "src/data/idSwitches.js", "src/data/firmsIntensity.js", "src/data/layerState.js", + "src/data/satelliteClassMask.js", "src/data/layerStateTokenReservations.json", "src/visualStylePolicy.js" ], diff --git a/src/data/layerState.js b/src/data/layerState.js index ed53abeb1..8e8cdb1e1 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -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'], { diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index b75565285..7deb73e7f 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -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(), { diff --git a/src/data/layerStateTokenLedger.test.mjs b/src/data/layerStateTokenLedger.test.mjs index a6c3390ec..f7453b2c2 100644 --- a/src/data/layerStateTokenLedger.test.mjs +++ b/src/data/layerStateTokenLedger.test.mjs @@ -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( diff --git a/src/data/satelliteClass.test.mjs b/src/data/satelliteClass.test.mjs index ba9e8893b..7792cb765 100644 --- a/src/data/satelliteClass.test.mjs +++ b/src/data/satelliteClass.test.mjs @@ -469,6 +469,18 @@ test('every class but Starlink shows by default; each class chip turns its class satellitesLayer.setParams(chip('imaging').params); assert.deepEqual(shown(), [25544, 40001], '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, 40002], 'restored from letters'); } finally { console.log = log; console.warn = warn; diff --git a/src/data/satelliteClassMask.js b/src/data/satelliteClassMask.js new file mode 100644 index 000000000..95e74602a --- /dev/null +++ b/src/data/satelliteClassMask.js @@ -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|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), + ]), + ); +} diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js index d3d16cbab..0baedfcd8 100644 --- a/src/layers/satellites/controls.js +++ b/src/layers/satellites/controls.js @@ -1,4 +1,5 @@ import { satelliteClassText } from '../../data/satelliteClass.js'; +import { decodeSatelliteClassMask } from '../../data/satelliteClassMask.js'; import * as Cesium from 'cesium'; import { t } from '../../i18n/index.js'; import { @@ -493,16 +494,22 @@ export function createControls({ state: layerState, services, parts, source }) { 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 (!params.classes || typeof params.classes !== 'object') return false; + if (!requestedClasses || typeof requestedClasses !== 'object') + return false; // Starlink's toggle is the dense catalog: its points load on demand. if ( - typeof params.classes.starlink === 'boolean' && + typeof requestedClasses.starlink === 'boolean' && catalog === undefined ) - catalog = params.classes.starlink ? 'dense' : 'core'; + catalog = requestedClasses.starlink ? 'dense' : 'core'; layerState._params.classes = mergeClassVisibility( - params.classes, + requestedClasses, _classes(), ); } diff --git a/src/share/viewCodec.js b/src/share/viewCodec.js index 809e0a57b..af18adcab 100644 --- a/src/share/viewCodec.js +++ b/src/share/viewCodec.js @@ -273,7 +273,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,