Files
gods-eye-view/src/data/satelliteClass.js
T
German KhokhlovandClaude Opus 5.5 20f4cf3763 i18n: fix the LGTM review findings and tidy the branch texts
- i18n scanner: lastError sink, no false sinks on defaults and ternaries
- shared src/i18n/marker.js for modules that only mark strings
- wind field messages move to wind/fields.js; CITY_POIS imports from cityPois.js
- drop review IDs, change history and stale references from comments and docs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-16 21:00:28 +04:00

217 lines
8.5 KiB
JavaScript

/**
* Satellite classification — CelesTrak source group → operator-legible class.
*
* 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.
*
* 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.
*/
import { t, msg } from '../i18n/index.js';
/**
* Class registry: the point color of each class. The card and legend text is
* in CLASS_DISPLAY_KEYS.
* @type {Readonly<Record<string, { color: string }>>}
*/
export const SATELLITE_CLASSES = Object.freeze({
station: Object.freeze({
// Warm white — "crewed", and the brightest class in the sky. Deliberately
// NOT amber: 40-48deg amber is the app's known-military convention
// (flights.js MIL_TINT / militaryFlights.js MIL_ICON_COLOR) and must not
// leak into another domain. Slightly warm so it also separates from the
// pure white the civilian air fleet draws in.
color: '#fff6e5',
}),
nav: Object.freeze({
// Cyan — the strongest accent goes to the class users actually name
// ("how many GPS satellites are up?"). Takes over the slot the catch-all
// VISUAL group used to occupy, so this adds no new hue to the app.
color: '#4fd8ff',
}),
geo: Object.freeze({
// Violet, unchanged. Already the app's "space" semantic (the detection
// overlay paints the SAT tier #bda4ff), and the geostationary belt draws
// as one clean equatorial ring at globe scale.
color: '#c89bff',
}),
visual: Object.freeze({
// Muted blue-gray. This is the catch-all bucket, so it gives up the bright
// cyan it used to hold and recedes behind the three classes that mean
// something. Still well clear of the dense shell by luminance.
color: '#9fb3c4',
}),
comms: Object.freeze({
// Dim slate — thousands of these appear in DENSE mode. They must read as
// texture behind the core catalog, never compete with it. Rec.601 luma
// ~0.40 against VISUAL's ~0.69 keeps the two separable even when the
// NVG/FLIR shaders collapse the scene to a single channel.
color: '#54697f',
}),
});
/** Legend/report order for the classes above. */
export const SATELLITE_CLASS_ORDER = Object.freeze([
'station',
'nav',
'geo',
'visual',
'comms',
]);
// What the card and legend show for each class.
const CLASS_DISPLAY_KEYS = Object.freeze({
station: Object.freeze({
label: msg('STATION'),
blurb: msg('Crewed stations and their visiting vehicles'),
}),
nav: Object.freeze({
label: msg('NAV'),
blurb: msg('GNSS navigation — GPS, GLONASS, Galileo'),
}),
geo: Object.freeze({
label: msg('GEO'),
blurb: msg(
'Geostationary belt — comms and weather, fixed over the equator',
),
}),
visual: Object.freeze({
label: msg('VISUAL'),
blurb: msg('Brightest naked-eye objects — CelesTrak visual group'),
}),
comms: Object.freeze({
label: msg('COMMS'),
blurb: msg('Broadband constellation shell — shown only in DENSE mode'),
}),
});
/**
* CelesTrak group tag → { class, subtype }. Subtype names the specific
* constellation so the card can read "NAV · GPS" rather than a bare "NAV".
* Keys must stay in sync with CATALOG_GROUPS + the 'dense' tag in satellites.js.
*/
const GROUP_CLASS = Object.freeze({
stations: Object.freeze({ klass: 'station', subtype: null }),
visual: Object.freeze({ klass: 'visual', subtype: null }),
'gps-ops': Object.freeze({ klass: 'nav', subtype: 'GPS' }),
glonass: Object.freeze({ klass: 'nav', subtype: 'GLONASS' }),
galileo: Object.freeze({ klass: 'nav', subtype: 'GALILEO' }),
geo: Object.freeze({ klass: 'geo', subtype: null }),
dense: Object.freeze({ klass: 'comms', subtype: 'STARLINK' }),
});
/** Unknown groups fall back to the neutral bucket rather than vanishing. */
const FALLBACK = Object.freeze({ klass: 'visual', subtype: null });
/**
* The ISS is always a STATION, whichever group ingested it. CelesTrak lists it
* in `visual` as well as `stations`, and a catalog rebuild survives a partial
* group outage — so a dropped stations feed would otherwise file the ISS under
* VISUAL in both its label and the legend tally.
*/
const ISS_CLASS = Object.freeze({ klass: 'station', subtype: 'ISS' });
/**
* Resolve a satellite to its class key and constellation subtype. This is the
* ONLY place the ISS special case lives; the label and the legend tally both
* route through here so they can never disagree about what the ISS is.
* @param {string|undefined|null} group CelesTrak group tag (see GROUP_CLASS).
* @param {{ isIss?: boolean }} [options] Whether this satellite is the ISS.
* @returns {{ klass: string, subtype: string|null }} Class key and subtype.
*/
export function satelliteClassOf(group, { isIss = false } = {}) {
if (isIss) return ISS_CLASS;
return GROUP_CLASS[group] || FALLBACK;
}
/**
* Point color for a catalog group, as a CSS hex string.
* @param {string|undefined|null} group CelesTrak group tag.
* @returns {string} CSS hex color.
*/
export function satelliteClassColor(group) {
return SATELLITE_CLASSES[satelliteClassOf(group).klass].color;
}
/**
* Operator-facing class label for a satellite — the text field shown on the
* tracked card and the detection overlay ("NAV · GPS", "GEO", "STATION · ISS").
* The ISS is the one object named individually: it is the reason most people
* open this layer, and its dot is already styled apart from its class.
*
* The ISS is always a STATION regardless of which group ingested it — see
* satelliteClassOf, which owns that rule for every consumer.
* @param {string|undefined|null} group CelesTrak group tag.
* @param {{ isIss?: boolean, localize?: boolean }} [options] isIss: the ISS;
* localize: false keeps the English label (data for the AI tools).
* @returns {string} Display label.
*/
export function satelliteClassLabel(
group,
{ isIss = false, localize = true } = {},
) {
const { klass, subtype } = satelliteClassOf(group, { isIss });
const label = CLASS_DISPLAY_KEYS[klass].label;
const base = localize ? t(label) : label;
return subtype ? `${base} · ${subtype}` : base;
}
/**
* 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.
*/
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;
}