spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
1/** Visualisation panels for /constellation/[slug]. Server components, pure SVG charts. */2import Link from 'next/link';3import { AreaChart, Bars, Donut, Histogram } from '@/components/charts/charts';4import { Unavailable } from '@/components/ui/unavailable';5import { fmtDeg, fmtInt, fmtKm, num, titleCase } from '@/lib/format';6import { STATUS_COLORS, routes } from '@/lib/site';7import type { ConstellationDetail } from '@/lib/types';8import { Derived, ScrollTable } from './shared';910export function DeploymentTimeline({ growth }: { growth: ConstellationDetail['growth'] }) {11 const monthly = growth.map((g) => ({ x: g.month.slice(0, 7), y: num(g.launched) ?? 0 }));12 const cumulative = growth.map((g) => ({ x: g.month.slice(0, 7), y: num(g.cumulative) ?? 0 }));13 if (!growth.length) return <Unavailable what="Deployment timeline" />;14 return (15 <div className="grid gap-6 lg:grid-cols-2">16 <div>17 <p className="eyebrow mb-2">Cumulative satellites launched</p>18 <AreaChart data={cumulative} title="Cumulative satellites launched by month" height={180} xLabel={(x) => String(x).slice(0, 4)} />19 </div>20 <div>21 <p className="eyebrow mb-2">Launched per month</p>22 <Bars data={monthly} title="Satellites launched per month" height={180} color="var(--series-2)" highlightLast />23 </div>24 </div>25 );26}2728export function StatusDonut({ dist, total }: { dist: ConstellationDetail['status_distribution']; total: number | null }) {29 const data = dist.map((d) => ({ label: titleCase(d.status.toLowerCase()), value: num(d.count) ?? 0, color: STATUS_COLORS[d.status] ?? 'var(--other)' })).filter((d) => d.value > 0).sort((a, b) => b.value - a.value);30 return <Donut data={data} title="Satellites by status" total={total ?? undefined} size={150} />;31}3233export function OrbitalShells({ d }: { d: ConstellationDetail }) {34 const alt = d.altitude_histogram.map((h) => ({ bin: num(h.alt_km) ?? 0, value: num(h.satellites) ?? 0 })).filter((h) => h.bin >= 0);35 const incl = d.inclination_histogram.map((h) => ({ bin: num(h.incl_deg) ?? 0, value: num(h.satellites) ?? 0 }));36 const shells = [...d.shells].sort((a, b) => (num(b.satellites) ?? 0) - (num(a.satellites) ?? 0)).slice(0, 10);37 const nothing = !alt.length && !incl.length && !shells.length;38 if (nothing) return <Unavailable what="Orbital shell data (no current orbital elements for this constellation)" />;39 return (40 <div className="grid gap-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">41 <div className="space-y-6">42 <div>43 <p className="eyebrow mb-2">Perigee altitude · satellites per 25 km</p>44 <Histogram data={alt} title="Perigee altitude histogram" unit=" km" height={150} xTicks={6} />45 </div>46 <div>47 <p className="eyebrow mb-2">Inclination · satellites per 5°</p>48 <Histogram data={incl} title="Inclination histogram" unit="°" height={130} color="var(--series-2)" xTicks={6} />49 </div>50 </div>51 <div>52 <p className="eyebrow mb-2">Largest shells (perigee × inclination)</p>53 {shells.length === 0 ? (54 <Unavailable what="Shell table" compact />55 ) : (56 <ScrollTable>57 <table className="data-table">58 <thead>59 <tr>60 <th className="num">Perigee</th>61 <th className="num">Inclination</th>62 <th className="num">Satellites</th>63 </tr>64 </thead>65 <tbody>66 {shells.map((s, i) => (67 <tr key={i}>68 <td className="num tnum">{fmtKm(s.perigee_km)}</td>69 <td className="num tnum">{fmtDeg(s.inclination_deg)}</td>70 <td className="num tnum font-medium">{fmtInt(s.satellites)}</td>71 </tr>72 ))}73 </tbody>74 </table>75 </ScrollTable>76 )}77 <p className="mt-2 text-xs text-ink-3">Shells bucket current elements to 20 km × 1°. Orbit classes are <Link href={routes.methodology()} className="text-accent hover:underline">derived</Link>.</p>78 </div>79 </div>80 );81}8283export function DecaysByMonth({ rows }: { rows: ConstellationDetail['decays_by_month'] }) {84 const data = rows.map((r) => ({ x: r.month.slice(0, 7), y: num(r.decayed) ?? 0 }));85 const total = data.reduce((s, d) => s + d.y, 0);86 if (!data.length) return null;87 return (88 <div>89 <p className="eyebrow mb-2">{fmtInt(total)} decays recorded · per month</p>90 <Bars data={data} title="Decayed satellites per month" height={150} color="var(--series-8)" />91 </div>92 );93}9495export function MembershipNote({ d }: { d: ConstellationDetail }) {96 const methods = d.membership_methods ?? [];97 return (98 <div className="space-y-3 text-sm text-ink-2">99 <p className="flex flex-wrap items-center gap-2">100 <span>Constellation membership is</span> <Derived /> <span>from curated rules, never from a single upstream field. Rules for this constellation:</span>101 </p>102 <dl className="grid gap-3 sm:grid-cols-2">103 <div>104 <dt className="eyebrow">Name patterns</dt>105 <dd className="mt-1 flex flex-wrap gap-1.5">{d.match_patterns.length ? d.match_patterns.map((p) => <code key={p} className="mono rounded bg-plane-2 px-1.5 py-0.5 text-xs text-ink">{p}</code>) : <span className="text-ink-3">none</span>}</dd>106 </div>107 <div>108 <dt className="eyebrow">CelesTrak groups</dt>109 <dd className="mt-1 flex flex-wrap gap-1.5">{d.celestrak_groups.length ? d.celestrak_groups.map((g) => <code key={g} className="mono rounded bg-plane-2 px-1.5 py-0.5 text-xs text-ink">{g}</code>) : <span className="text-ink-3">none</span>}</dd>110 </div>111 </dl>112 <div>113 <p className="eyebrow">Members by method</p>114 {methods.length ? (115 <ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1">116 {methods.map((m) => (117 <li key={m.method} className="tnum"><code className="mono text-xs text-ink">{m.method}</code> · {fmtInt(m.satellites)}</li>118 ))}119 </ul>120 ) : (121 <p className="mt-1 text-xs text-ink-3">Per-method breakdown unavailable for this constellation.</p>122 )}123 </div>124 <p className="text-xs text-ink-3">125 Full rule set and metric versions on the <Link href={routes.methodology()} className="text-accent hover:underline">methodology page</Link>. Activity score is a derived index of recent launch cadence, not a safety metric.126 </p>127 </div>128 );129}130