fix(satellites): class choices survive a reload and travel in share links

Only catalog and the tracked satellite were saved for the layer, so a class
chip lasted until the next reload and no share link carried it. The choice
is now the satellites option `classes`: one letter per class shown
(`sitpd` by default, `0` for none), with Starlink left to `catalog`, and
the layer takes the letters back through setParams. A partial request
(`{debris: false}`) hides only what it names.
This commit is contained in:
Sameh Khamis committed 2026-11-08 15:16:01 -08:00
1 parent 8ae56587c3
commit c84cfcadeb
9 files changed
+148 -12

No files matched your search

+11
View File
@@ -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'], {
+35
View File
@@ -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(), {
+7 -6
View File
@@ -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(
+12
View File
@@ -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;
+54
View File
@@ -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),
]),
);
}
+11 -4
View File
@@ -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(),
);
}
+5 -1
View File
@@ -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,