server/src/celestrak.ts
63 lines · 2672 bytes
1// CelesTrak GP data. CelesTrak updates every 2 hours and blocks IPs that fetch more
2// often or keep requesting after errors, so poll every 2 hours and stop on any non-200.
3
4/** ISS, Tiangong (CSS Tianhe) and Hubble; all three are in the visual group. */
5export const FEATURED = [25544, 48274, 20580];
6
7/** Median mean motion above which a Starlink launch is still low enough to be seen as a train (about 400 km). */
8const TRAIN_MEAN_MOTION = 15.45;
9
10export interface OMM {
11 OBJECT_NAME: string;
12 NORAD_CAT_ID: number;
13 EPOCH: string;
14 MEAN_MOTION: number;
15 ECCENTRICITY: number;
16 INCLINATION: number;
17 RA_OF_ASC_NODE: number;
18 ARG_OF_PERICENTER: number;
19 MEAN_ANOMALY: number;
20 BSTAR: number;
21}
22
23const KEYS: (keyof OMM)[] = ["OBJECT_NAME", "NORAD_CAT_ID", "EPOCH", "MEAN_MOTION", "ECCENTRICITY", "INCLINATION", "RA_OF_ASC_NODE", "ARG_OF_PERICENTER", "MEAN_ANOMALY", "BSTAR"];
24
25export function pick(rows: Record<string, unknown>[]): OMM[] {
26 return rows.map((r) => Object.fromEntries(KEYS.map((k) => [k, r[k]])) as unknown as OMM);
27}
28
29/**
30 * One element set per recent Starlink launch still low enough to be seen as a train,
31 * the satellite with the median mean motion, named "Starlink train (2026-232)".
32 */
33export function starlinkTrains(rows: Record<string, unknown>[]): OMM[] {
34 const groups = new Map<string, Record<string, unknown>[]>();
35 for (const r of rows) {
36 if (!String(r.OBJECT_NAME).startsWith("STARLINK")) continue;
37 const launch = String(r.OBJECT_ID).slice(0, 8);
38 groups.set(launch, [...(groups.get(launch) ?? []), r]);
39 }
40 const out: OMM[] = [];
41 for (const [launch, sats] of groups) {
42 const sorted = [...sats].sort((a, b) => Number(a.MEAN_MOTION) - Number(b.MEAN_MOTION));
43 const median = sorted[Math.floor(sorted.length / 2)];
44 if (Number(median.MEAN_MOTION) <= TRAIN_MEAN_MOTION) continue;
45 out.push({ ...pick([median])[0], OBJECT_NAME: `Starlink train (${launch})` });
46 }
47 return out;
48}
49
50async function group(name: string): Promise<Record<string, unknown>[]> {
51 const res = await fetch(`https://celestrak.org/NORAD/elements/gp.php?GROUP=${name}&FORMAT=JSON`, {
52 headers: { "user-agent": "moonrise-server (sh.krz.moonrise)" },
53 });
54 if (res.status !== 200) throw new Error(`CelesTrak ${res.status} for ${name}`);
55 return (await res.json()) as Record<string, unknown>[];
56}
57
58/** The visual group (about 150 bright objects) and Starlink trains; throws on the first non-200 so nothing more is requested. */
59export async function fetchElements(): Promise<OMM[]> {
60 const visual = pick(await group("visual"));
61 const recent = await group("last-30-days");
62 return [...visual, ...starlinkTrains(recent)];
63}