Files
gods-eye-view/src/layers/launches/model.js
T
2026-10-16 15:24:08 -04:00

248 lines
9.3 KiB
JavaScript

import * as Cesium from 'cesium';
import { msg } from '../../i18n/index.js';
import { WINDOW_DAYS } from './policy.js';
/**
* Fallback when Launch Library sends no status. English in the record
* (launchStatusAllowsOrbit, analyst records); the mission panel translates it.
*/
export const UNKNOWN_LAUNCH_STATUS = msg('Unknown');
export function createModel({ state: layerState, services, parts, source }) {
function finiteCoordinate(value) {
// Number(null), Number('') and Number(false) are 0: absent is not zero.
if (value == null || typeof value === 'boolean') return null;
if (typeof value === 'string' && value.trim() === '') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function normalizePayloadFlights(launch) {
const flights =
launch.rocket?.payloads ||
launch.payloads ||
launch.mission?.payloads ||
[];
if (!Array.isArray(flights)) return [];
return flights.map((flight, index) => {
const payload = flight.payload || flight;
return {
id: String(flight.id || payload.id || `payload-${index}`),
name: payload.name || flight.name || 'Unnamed payload',
type: payload.type?.name || flight.type?.name || null,
manufacturer: payload.manufacturer?.name || null,
operator: payload.operator?.name || null,
destination: flight.destination || payload.destination || null,
amount: Number.isFinite(Number(flight.amount))
? Number(flight.amount)
: 1,
massKg:
(typeof payload.mass === 'number' ||
(typeof payload.mass === 'string' && payload.mass.trim() !== '')) &&
Number.isFinite(Number(payload.mass)) &&
Number(payload.mass) >= 0
? Number(payload.mass)
: null,
};
});
}
function normalizeLanding(stage, fallbackName, category, index) {
const landing = stage?.landing;
const location = landing?.landing_location || {};
const launcher = stage?.launcher || stage?.spacecraft || {};
const serial = launcher.serial_number || stage?.serial_number || null;
const stageType = stage?.type?.name || stage?.type || category;
const attempted = landing?.attempt === true;
const success = landing?.success;
const recoveryType = landing?.type?.name || null;
const launcherStatus = launcher.status?.name || null;
const status =
success === true
? 'RECOVERED'
: success === false
? 'LOST'
: attempted
? 'RECOVERY ATTEMPT'
: recoveryType
? recoveryType.toUpperCase()
: launcherStatus
? launcherStatus.toUpperCase()
: 'NO RECOVERY DATA';
return {
id: String(stage?.id || landing?.id || `${category}-${index}`),
category,
name: [stageType, serial].filter(Boolean).join(' · ') || fallbackName,
serial,
reused: stage?.reused === true,
flightNumber: finiteCoordinate(stage?.launcher_flight_number),
status,
attempted,
success: success === true ? true : success === false ? false : null,
recoveryType,
destination:
location.name || landing?.destination || landing?.type?.name || null,
description: landing?.description || null,
downrangeKm: finiteCoordinate(landing?.downrange_distance),
lat: finiteCoordinate(location.latitude ?? landing?.latitude),
lon: finiteCoordinate(location.longitude ?? landing?.longitude),
};
}
function normalizeRecoveryStages(launch, payloads) {
const rocket = launch.rocket || {};
const launcherStages = Array.isArray(rocket.launcher_stage)
? rocket.launcher_stage
: [];
const spacecraftStages = Array.isArray(rocket.spacecraft_stage)
? rocket.spacecraft_stage
: [];
const stages = [
...launcherStages.map((stage, index) =>
normalizeLanding(
stage,
`Launcher stage ${index + 1}`,
'LAUNCHER',
index,
),
),
...spacecraftStages.map((stage, index) =>
normalizeLanding(
stage,
`Spacecraft stage ${index + 1}`,
'SPACECRAFT',
index,
),
),
];
const payloadFlights = rocket.payloads || launch.payloads || [];
if (Array.isArray(payloadFlights)) {
payloadFlights.forEach((flight, index) => {
if (!flight?.landing) return;
stages.push(
normalizeLanding(
{
...flight,
type: payloads[index]?.type || 'Payload',
serial_number: payloads[index]?.name,
},
payloads[index]?.name || `Payload ${index + 1}`,
'PAYLOAD',
index,
),
);
});
}
return stages;
}
/**
* Select a stable marker color from the mission operator and payload name.
* Labels stay cyan so the layer remains visually coherent.
* @param {object} launch Normalized launch record.
* @returns {Cesium.Color}
*/
function missionMarkerColor(launch) {
const identity =
`${launch.provider || ''} ${launch.name || ''} ${launch.missionName || ''}`.toLowerCase();
if (/nasa|national aeronautics/.test(identity))
return Cesium.Color.fromCssColorString('#ff9f43');
if (/starlink|spacex|space exploration/.test(identity))
return Cesium.Color.fromCssColorString('#4cc9f0');
if (/rocket lab/.test(identity))
return Cesium.Color.fromCssColorString('#7bed9f');
if (/isro|indian space/.test(identity))
return Cesium.Color.fromCssColorString('#ff66c4');
if (/cnsa|china national|long march/.test(identity))
return Cesium.Color.fromCssColorString('#ffd166');
if (/blue origin/.test(identity))
return Cesium.Color.fromCssColorString('#a78bfa');
if (/ula|united launch alliance/.test(identity))
return Cesium.Color.fromCssColorString('#f97316');
if (/arianespace|esa|european space/.test(identity))
return Cesium.Color.fromCssColorString('#60a5fa');
if (launch.provider) return Cesium.Color.fromCssColorString('#c084fc');
return Cesium.Color.fromCssColorString('#22e6e6');
}
/**
* Normalize a Launch Library 2 response into records suitable for the layer.
* Trajectory points are retained only when the upstream explicitly supplies
* them; orbital tracks must not be reconstructed from launch metadata.
* @param {object} payload Launch Library 2-compatible response.
* @param {Date} [now] Reference time used for the rolling window.
* @returns {Array<object>}
*/
function normalizeRocketLaunches(payload, now = new Date()) {
const launches = Array.isArray(payload) ? payload : payload?.results;
if (!Array.isArray(launches)) return [];
const cutoff = now.getTime() - WINDOW_DAYS * 86400000;
return launches
.map((launch) => {
const launchTime =
launch.net || launch.window_start || launch.pad?.location?.name;
const date = Date.parse(launchTime);
const pad = launch.pad || {};
const location = pad.location || {};
const coordinates = location.coordinates || '';
const [coordinateLon, coordinateLat] = String(coordinates).split(',');
const lat =
finiteCoordinate(pad.latitude) ?? finiteCoordinate(coordinateLat);
const lon =
finiteCoordinate(pad.longitude) ?? finiteCoordinate(coordinateLon);
const payloads = normalizePayloadFlights(launch);
return {
id: String(
launch.id || launch.slug || launch.name || `launch-${date}`,
),
name: launch.name || 'Unnamed launch',
status: launch.status?.name || UNKNOWN_LAUNCH_STATUS,
launchTime: Number.isFinite(date)
? new Date(date).toISOString()
: null,
launchSite: pad.name || location.name || 'Unknown launch site',
lat: Number.isFinite(lat) ? lat : null,
lon: Number.isFinite(lon) ? lon : null,
provider: launch.launch_service_provider?.name || null,
mission: launch.mission?.description || null,
missionName: launch.mission?.name || null,
satelliteQuery: launch.mission?.name || launch.name || null,
payloads,
recoveryStages: normalizeRecoveryStages(launch, payloads),
trajectory: Array.isArray(launch.trajectory) ? launch.trajectory : [],
timeline: Array.isArray(launch.timeline)
? launch.timeline.map((event) => ({
name:
event.type?.abbrev ||
event.type?.name ||
event.name ||
'Mission event',
relativeTime: event.relative_time || event.relativeTime || null,
offsetSeconds: parts.policyHelpers.parseMissionDurationSeconds(
event.relative_time || event.relativeTime,
),
}))
: [],
orbit: launch.mission?.orbit || launch.orbit || null,
source: 'Launch Library 2',
inWindow:
Number.isFinite(date) && date >= cutoff && date <= now.getTime(),
};
})
.filter(
(launch) =>
launch.inWindow && launch.lat !== null && launch.lon !== null,
);
}
return {
finiteCoordinate,
normalizePayloadFlights,
normalizeLanding,
normalizeRecoveryStages,
missionMarkerColor,
normalizeRocketLaunches,
};
}