SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
16.5 KB · 326 lines tsx
Raw Blame History
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