spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
1import Link from 'next/link';2import { MissionLabel, OrbitBadge, StatusBadge, TypeBadge } from '@/components/ui/badges';3import { Unavailable } from '@/components/ui/unavailable';4import { fmt1, fmtAgo, fmtDate, fmtDateTime, fmtDeg, fmtInt, fmtKm, fmtMinutes, titleCase } from '@/lib/format';5import { EVENT_TYPE_LABELS, OBJECT_TYPE_LABELS, routes } from '@/lib/site';6import type { SatelliteDetail, SatelliteHistory } from '@/lib/types';7import { AltitudeChart } from './altitude-chart';8import { OPS_STATUS } from './hero';9import { Block, Chip, DL, Derived, Empty, Head, Note, Row } from './primitives';1011const sci = (v: number | null) => (v === null ? '—' : v === 0 ? '0' : v.toExponential(4));1213export function OrbitSection({ d, history }: { d: SatelliteDetail; history: SatelliteHistory | null }) {14 const os = d.orbital_state;15 const series = history?.altitude_series ?? [];16 return (17 <Block id="orbit">18 <Head eyebrow="Orbit" title="Orbital elements" action={{ href: routes.methodology(), label: 'How orbits are classified' }} />19 {os ? (20 <div className="grid gap-x-10 md:grid-cols-2">21 <DL>22 <Row label="Semi-major axis" value={fmtKm(os.semi_major_axis_km, 1)} />23 <Row label="Eccentricity" value={os.eccentricity.toFixed(7)} />24 <Row label="Inclination" value={fmtDeg(os.inclination)} />25 <Row label="RAAN" hint="Ω" value={fmtDeg(os.raan)} />26 <Row label="Argument of perigee" hint="ω" value={fmtDeg(os.arg_of_perigee)} />27 <Row label="Mean anomaly" hint="M" value={fmtDeg(os.mean_anomaly)} />28 </DL>29 <DL>30 <Row label="Mean motion" value={`${os.mean_motion.toFixed(8)} rev/day`} />31 <Row label="Mean motion derivative" value={sci(os.mean_motion_dot)} />32 <Row label="B* drag term" value={sci(os.bstar)} />33 <Row label="Period" value={`${fmt1(os.period_minutes)} min`} hint={os.period_minutes && os.period_minutes >= 120 ? `(${fmtMinutes(os.period_minutes)})` : undefined} />34 <Row label="Perigee / apogee" value={`${fmt1(os.perigee_km)} / ${fmt1(os.apogee_km)} km`} />35 <Row label={<>Orbit class <Derived /></>} mono={false} value={<OrbitBadge orbitClass={os.orbit_class} />} />36 </DL>37 <p className="mt-3 text-2xs text-ink-3 md:col-span-2">38 Epoch <span className="mono">{fmtDateTime(os.epoch)}</span> · received {fmtAgo(os.updated_at)} from <span className="text-ink-2">{titleCase(os.source_id)}</span>. Mean Keplerian elements (SGP4 / TLE convention, TEME frame); perigee and apogee are altitudes above the WGS-84 equatorial radius.39 </p>40 </div>41 ) : (42 <Note>43 No orbital element set is available for this object{d.status === 'DECAYED' ? ' — it decayed' + (d.decay_date ? ` on ${fmtDate(d.decay_date)}` : '') + ' and is no longer propagated' : ''}. Catalogue values above (if any) come from the SATCAT record.44 </Note>45 )}4647 <h3 className="mt-8 text-sm font-semibold text-ink-2">Orbital history</h3>48 <div className="mt-3">49 {history === null ? (50 <Unavailable what="Orbital history" compact />51 ) : series.length >= 2 ? (52 <AltitudeChart series={series} />53 ) : (54 <Note>55 {series.length === 1 ? '1 daily point on file so far. ' : os ? '' : 'No element sets on file. '}56 History accumulates as element sets are ingested (every 2 h); altitude, period and inclination trends appear here once at least two days are available.57 </Note>58 )}59 </div>60 </Block>61 );62}6364export function MissionSection({ d }: { d: SatelliteDetail }) {65 return (66 <Block id="mission">67 <Head eyebrow="Mission" title="Mission & classification" />68 <div className="grid gap-x-10 md:grid-cols-2">69 <DL>70 <Row label={<>Mission type <Derived /></>} mono={false} value={<MissionLabel mission={d.mission_type} />} />71 <Row label="Object type" mono={false} value={OBJECT_TYPE_LABELS[d.object_type] ?? d.object_type} />72 {d.ops_status_code && <Row label="SATCAT ops status" mono={false} value={<>{OPS_STATUS[d.ops_status_code] ?? 'Code'} <span className="mono text-ink-3">[{d.ops_status_code}]</span></>} />}73 <Row label="Radar cross-section" value={d.rcs_m2 !== null ? `${fmt1(d.rcs_m2)} m²` : '—'} />74 {d.constellation_memberships.length > 0 && (75 <Row label={<>Constellation membership <Derived /></>} mono={false} value={d.constellation_memberships.map((m) => <span key={m.constellation_id}><Link className="link" href={routes.constellation(m.slug)}>{m.name}</Link> <span className="text-2xs text-ink-3">via {m.method}</span></span>)} />76 )}77 </DL>78 <div>79 <p className="eyebrow mt-2 md:mt-0">CelesTrak groups</p>80 {d.tags.length ? (81 <ul className="mt-2 flex flex-wrap gap-1.5">82 {d.tags.map((t) => (83 <li key={t.tag}>84 <Chip href={routes.satellites(`tag=${encodeURIComponent(t.tag)}`)}>{t.tag}</Chip>85 </li>86 ))}87 </ul>88 ) : (89 <Empty>Not listed in any CelesTrak group.</Empty>90 )}91 <p className="eyebrow mt-5">Aliases</p>92 {d.aliases.length ? (93 <ul className="mt-2 space-y-1 text-sm">94 {d.aliases.map((a) => (95 <li key={a.alias} className="flex justify-between gap-3">96 <span className="mono text-ink">{a.alias}</span>97 <span className="text-2xs text-ink-3">{titleCase(a.source_id)}</span>98 </li>99 ))}100 </ul>101 ) : (102 <Empty>No alternative names recorded.</Empty>103 )}104 </div>105 </div>106 </Block>107 );108}109110export function OwnershipSection({ d }: { d: SatelliteDetail }) {111 return (112 <Block id="ownership">113 <Head eyebrow="Ownership" title="Operator & country" />114 <DL>115 <Row label="Operator" mono={false} value={d.operator_slug ? <Link className="link" href={routes.operator(d.operator_slug)}>{d.operator_name}</Link> : '—'} />116 <Row label="SATCAT owner code" mono={false} value={d.owner_code ? <><span className="mono">{d.owner_code}</span>{d.owner_name && <span className="text-ink-2"> · {d.owner_name}</span>}</> : '—'} />117 <Row label="Country" mono={false} value={d.country_slug ? <Link className="link" href={routes.country(d.country_slug)}>{d.country_name} <span className="mono text-ink-3">{d.country_code}</span></Link> : d.owner_code === 'ISS' ? 'International partnership' : '—'} />118 </DL>119 </Block>120 );121}122123export function LaunchSection({ d }: { d: SatelliteDetail }) {124 const sib = d.launch_siblings;125 const shown = sib.slice(0, 12);126 return (127 <Block id="launch">128 <Head eyebrow="Launch" title="Launch" action={d.cospar_launch_id ? { href: routes.launch(d.cospar_launch_id), label: 'Launch page' } : undefined} />129 <DL>130 <Row label="Launch date" value={fmtDate(d.launch_date)} />131 <Row label="COSPAR launch id" value={d.cospar_launch_id ? <Link className="link" href={routes.launch(d.cospar_launch_id)}>{d.cospar_launch_id}</Link> : '—'} />132 <Row label="Launch site" mono={false} value={d.launch_site_slug ? <Link className="link" href={routes.launchSite(d.launch_site_slug)}>{d.launch_site_name}</Link> : d.launch_site_code ?? '—'} />133 </DL>134 <h3 className="mt-6 text-sm font-semibold text-ink-2">Objects from the same launch{sib.length ? <span className="tnum text-ink-3"> · {fmtInt(sib.length)}{sib.length >= 40 ? '+' : ''}</span> : null}</h3>135 {shown.length ? (136 <ul className="mt-2 divide-y divide-[color:var(--rule)] text-sm">137 {shown.map((s) => (138 <li key={s.id} className="flex items-center justify-between gap-3 py-2">139 <Link href={routes.satellite(s.slug)} className="link min-w-0 truncate">{s.name}</Link>140 <span className="flex shrink-0 items-center gap-2">141 <span className="mono text-xs text-ink-3">{s.norad_id ?? '—'}</span>142 <TypeBadge type={s.object_type} />143 <StatusBadge status={s.status} />144 </span>145 </li>146 ))}147 </ul>148 ) : (149 <Empty>No other catalogued object is linked to this launch.</Empty>150 )}151 {d.cospar_launch_id && sib.length > shown.length && (152 <Link href={routes.launch(d.cospar_launch_id)} className="mt-3 inline-block text-sm text-accent hover:underline">153 All objects from launch {d.cospar_launch_id} →154 </Link>155 )}156 </Block>157 );158}159160export function HistorySection({ d }: { d: SatelliteDetail }) {161 return (162 <Block id="history">163 <Head eyebrow="Change log" title="History" />164 {d.history.length ? (165 <table className="data-table stack">166 <thead>167 <tr><th>Field</th><th>Change</th><th>Source</th><th>Date</th></tr>168 </thead>169 <tbody>170 {d.history.map((h, i) => (171 <tr key={i}>172 <td data-label="Field" className="primary">{titleCase(h.field)}</td>173 <td data-label="Change" className="mono text-xs"><span className="text-ink-3">{h.old_value ?? '∅'}</span> → <span className="text-ink">{h.new_value ?? '∅'}</span></td>174 <td data-label="Source">{titleCase(h.source_id)}</td>175 <td data-label="Date" className="mono text-xs">{fmtDateTime(h.changed_at)}</td>176 </tr>177 ))}178 </tbody>179 </table>180 ) : (181 <Note>No changes recorded since first ingestion on {fmtDateTime(d.first_seen_at)}. Status, orbit class, name and ownership changes are logged here as sources are re-ingested.</Note>182 )}183 </Block>184 );185}186187export function RegistrationSection() {188 return (189 <Block id="registration">190 <Head eyebrow="Regulatory" title="UN registration" />191 <Unavailable what="UN Register of Objects Launched into Outer Space (UNOOSA) — connector planned, not yet ingested; registration" compact />192 </Block>193 );194}195196export function SourcesSection({ d }: { d: SatelliteDetail }) {197 const sources = Array.from(new Map(d.sources.map((s) => [s.id, s])).values());198 const byField = new Map<string, SatelliteDetail['provenance']>();199 for (const p of d.provenance) byField.set(p.field_name, [...(byField.get(p.field_name) ?? []), p]);200 return (201 <Block id="sources">202 <Head eyebrow="Transparency" title="Sources & provenance" action={{ href: routes.sources(), label: 'All sources' }} />203 {sources.length ? (204 <ul className="divide-y divide-[color:var(--rule)] text-sm">205 {sources.map((s) => (206 <li key={s.id} className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 py-2.5">207 <div className="min-w-0">208 <p className="font-medium text-ink">209 {s.name}210 <span className={`ml-2 rounded px-1.5 py-px text-[10px] uppercase tracking-wider ${s.official ? 'bg-active-soft text-active' : 'bg-plane-2 text-ink-3'}`}>{s.official ? 'official' : 'community'}</span>211 </p>212 {s.attribution_text && <p className="mt-0.5 text-xs text-ink-3">{s.attribution_text}</p>}213 </div>214 <p className="mono text-xs text-ink-3">last sync {fmtAgo(s.last_success_at)}</p>215 </li>216 ))}217 </ul>218 ) : (219 <Note>No source is linked to this record yet — the catalogue row exists but no provenance entry has been written for it.</Note>220 )}221222 <h3 className="mt-6 text-sm font-semibold text-ink-2">Field provenance</h3>223 {byField.size ? (224 <table className="data-table stack mt-2">225 <thead>226 <tr><th>Field</th><th>Source</th><th>Observed</th><th className="num">Confidence</th></tr>227 </thead>228 <tbody>229 {Array.from(byField.entries()).flatMap(([field, rows]) =>230 rows.map((p, i) => (231 <tr key={`${field}-${p.source_id}-${i}`}>232 <td data-label="Field" className="primary">{i === 0 ? titleCase(field) : <span className="text-ink-3">↳ {titleCase(field)}</span>}</td>233 <td data-label="Source">{p.source_name}</td>234 <td data-label="Observed" className="mono text-xs">{fmtDateTime(p.observed_at)}</td>235 <td data-label="Confidence" className="num mono text-xs">{(p.confidence * 100).toFixed(0)}%</td>236 </tr>237 )),238 )}239 </tbody>240 </table>241 ) : (242 <Empty>No field-level provenance recorded.</Empty>243 )}244245 {d.quality_flags.length > 0 && (246 <>247 <h3 className="mt-6 text-sm font-semibold text-warn">Quality flags</h3>248 <ul className="mt-2 space-y-1.5 text-sm">249 {d.quality_flags.map((f, i) => (250 <li key={i} className="rounded-md border border-warn/30 bg-warn-soft px-3 py-2">251 <span className="mono text-xs text-warn">{f.flag}</span>252 {f.detail && <span className="ml-2 text-ink-2">{f.detail}</span>}253 <span className="ml-2 text-2xs text-ink-3">{fmtDate(f.created_at)}</span>254 </li>255 ))}256 </ul>257 </>258 )}259 </Block>260 );261}262263export function EventsSection({ d }: { d: SatelliteDetail }) {264 return (265 <Block id="events">266 <Head eyebrow="Timeline" title="Events" action={{ href: routes.events(), label: 'All events' }} />267 {d.events.length ? (268 <ol className="divide-y divide-[color:var(--rule)]">269 {d.events.map((e) => (270 <li key={e.id} className="grid gap-1 py-3 sm:grid-cols-[150px_minmax(0,1fr)]">271 <p className="mono text-xs text-ink-3">{fmtDateTime(e.event_time)}</p>272 <div className="min-w-0">273 <p className="text-sm text-ink"><span className="mr-2 rounded bg-plane-2 px-1.5 py-px text-[10px] uppercase tracking-wider text-ink-3">{EVENT_TYPE_LABELS[e.type] ?? titleCase(e.type)}</span>{e.title}</p>274 {e.summary && <p className="mt-1 text-xs leading-relaxed text-ink-2">{e.summary}</p>}275 <p className="mt-1 text-2xs text-ink-3">confidence {(e.confidence * 100).toFixed(0)}%{e.source_name && <> · {e.source_url ? <a className="hover:text-accent" href={e.source_url} rel="noopener noreferrer" target="_blank">{e.source_name}</a> : e.source_name}</>}</p>276 </div>277 </li>278 ))}279 </ol>280 ) : (281 <Empty>No events detected for this object yet. Launches, decays and orbit changes are generated by the derived-analytics connector.</Empty>282 )}283 </Block>284 );285}286287export function RelatedSection({ d }: { d: SatelliteDetail }) {288 return (289 <Block id="related">290 <Head eyebrow="Context" title="Related objects" action={d.constellation_slug ? { href: routes.satellites(`constellation=${d.constellation_slug}`), label: `All ${d.constellation_name} satellites` } : undefined} />291 {d.related.length ? (292 <ul className="grid gap-x-8 sm:grid-cols-2">293 {d.related.map((r) => (294 <li key={r.id} className="flex items-center justify-between gap-3 border-b border-rule py-2 text-sm">295 <Link href={routes.satellite(r.slug)} className="link min-w-0 truncate">{r.name}</Link>296 <span className="flex shrink-0 items-center gap-2">297 <span className="mono text-xs text-ink-3">{r.perigee_km !== null ? fmtKm(r.perigee_km) : ''}</span>298 <StatusBadge status={r.status} />299 </span>300 </li>301 ))}302 </ul>303 ) : (304 <Empty>No related objects (same constellation or operator) found.</Empty>305 )}306 </Block>307 );308}309310export function IdentifiersSection({ d }: { d: SatelliteDetail }) {311 return (312 <Block id="identifiers">313 <Head eyebrow="Reference" title="Identifiers" />314 <DL>315 <Row label="NORAD catalogue number" value={d.norad_id ?? '—'} />316 <Row label="COSPAR / international designator" value={d.cospar_id ?? '—'} />317 <Row label="SatelliteIndex id" value={<span className="break-all text-xs">{d.id}</span>} />318 <Row label="Canonical slug" value={<span className="break-all text-xs">{d.slug}</span>} />319 {d.identifiers.filter((i) => i.identifier_type !== 'norad' && i.identifier_type !== 'cospar').map((i) => (320 <Row key={`${i.identifier_type}-${i.identifier_value}`} label={titleCase(i.identifier_type)} value={<>{i.identifier_value}{i.verified && <span className="ml-1 text-active" title="verified">✓</span>}</>} />321 ))}322 </DL>323 </Block>324 );325}326