spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { ChipRow, Derived, entityMetadata, first, ScrollTable, EmptyRow, type Params } from '@/components/entities/shared';4import { OrbitBadge } from '@/components/ui/badges';5import { Pagination } from '@/components/ui/pagination';6import { Container, PageHeader } from '@/components/ui/section';7import { Unavailable } from '@/components/ui/unavailable';8import { api, safe } from '@/lib/api';9import { fmt1, fmtDate, fmtInt, fmtKm, num, titleCase } from '@/lib/format';10import { routes } from '@/lib/site';11import type { ConstellationRow } from '@/lib/types';1213type Search = Record<string, string | string[] | undefined>;1415const SORTS: { value: string | undefined; label: string }[] = [16 { value: undefined, label: 'Active' },17 { value: 'total', label: 'Total' },18 { value: 'growth', label: 'Growth 365 d' },19 { value: 'activity', label: 'Activity' },20 { value: 'name', label: 'Name' },21];22const SORT_NOUN: Record<string, string> = { '': 'active satellites', total: 'total satellites launched', growth: 'satellites launched in the last 365 days', activity: 'activity score', name: 'name' };23const SERVICES = ['communications', 'earth-observation', 'navigation', 'iot', 'weather', 'military', 'technology', 'station', 'science'];24const ORBITS = ['LEO', 'MEO', 'GEO', 'HEO', 'MIXED'];2526function parse(sp: Search): Params {27 return { sort: first(sp.sort), service: first(sp.service), orbit: first(sp.orbit), page: first(sp.page) };28}2930export async function generateMetadata({ searchParams }: { searchParams: Promise<Search> }): Promise<Metadata> {31 const p = parse(await searchParams);32 const bits = [p.service && titleCase(p.service), p.orbit].filter(Boolean).join(' · ');33 return entityMetadata({34 title: bits ? `${bits} satellite constellations` : 'Satellite constellations — ranked by active satellites',35 description: 'Every tracked satellite constellation ranked by active satellites, fleet size, 365-day growth and activity score, with orbit class, operator, median altitude and launch history.',36 path: routes.constellations(),37 });38}3940export default async function ConstellationsPage({ searchParams }: { searchParams: Promise<Search> }) {41 const params = parse(await searchParams);42 const page = Math.max(1, Number(params.page) || 1);43 const res = await safe(api.constellations({ sort: params.sort, service: params.service, orbit: params.orbit, page }));44 const rows = res?.data ?? [];45 const total = res?.pagination.total ?? null;46 const base = routes.constellations();47 const activeSum = rows.reduce((s, r) => s + (num(r.active) ?? 0), 0);4849 return (50 <Container wide>51 <PageHeader52 eyebrow="Constellations"53 title="Satellite constellations"54 lede={55 total !== null ? (56 <>57 {fmtInt(total)} constellations tracked{params.service || params.orbit ? ' in this selection' : ''}, ranked by {SORT_NOUN[params.sort ?? ''] ?? 'active satellites'}.58 {rows.length > 0 && <> The {fmtInt(rows.length)} shown on this page account for {fmtInt(activeSum)} active satellites.</>} Membership is derived from curated name patterns and CelesTrak groups — see the <Link href={routes.methodology()} className="text-accent hover:underline">methodology</Link>.59 </>60 ) : (61 'Constellation rankings are temporarily unavailable.'62 )63 }64 />6566 <div className="flex flex-col gap-2 border-y border-rule py-2">67 <ChipRow label="Sort" paramKey="sort" base={base} params={params} current={params.sort} options={SORTS} />68 <ChipRow label="Service" paramKey="service" base={base} params={params} current={params.service} options={[{ value: undefined, label: 'All' }, ...SERVICES.map((s) => ({ value: s, label: titleCase(s) }))]} />69 <ChipRow label="Orbit" paramKey="orbit" base={base} params={params} current={params.orbit} options={[{ value: undefined, label: 'All' }, ...ORBITS.map((o) => ({ value: o, label: o }))]} />70 </div>7172 <div className="py-6">73 {!res ? <Unavailable what="Constellation rankings" /> : <ConstellationTable rows={rows} offset={(page - 1) * (res.pagination.page_size || 100)} />}74 {res && (75 <Pagination76 className="mt-4"77 page={res.pagination.page}78 pages={res.pagination.pages}79 total={res.pagination.total}80 pageSize={res.pagination.page_size}81 makeHref={(p) => {82 const q = new URLSearchParams();83 for (const [k, v] of Object.entries(params)) if (v && k !== 'page') q.set(k, v);84 if (p > 1) q.set('page', String(p));85 const s = q.toString();86 return s ? `${base}?${s}` : base;87 }}88 />89 )}90 </div>91 </Container>92 );93}9495function ConstellationTable({ rows, offset }: { rows: ConstellationRow[]; offset: number }) {96 return (97 <ScrollTable>98 <table className="data-table stack">99 <thead>100 <tr>101 <th className="num max-md:hidden!">#</th>102 <th>Constellation</th>103 <th>Operator</th>104 <th>Service</th>105 <th>Orbit</th>106 <th className="num">Active</th>107 <th className="num max-md:hidden!">On orbit</th>108 <th className="num">Total</th>109 <th className="num">365 d</th>110 <th className="num max-md:hidden!">30 d</th>111 <th className="num">112 <span className="inline-flex items-center gap-1">Activity <Derived /></span>113 </th>114 <th className="num max-md:hidden!">Median perigee</th>115 <th className="max-md:hidden!">First launch</th>116 <th>Last launch</th>117 </tr>118 </thead>119 <tbody>120 {rows.length === 0 && <EmptyRow colSpan={14}>No constellation matches these filters.</EmptyRow>}121 {rows.map((c, i) => (122 <tr key={c.id}>123 <td data-label="Rank" className="num mono text-xs text-ink-3 max-md:hidden!">{offset + i + 1}</td>124 <td className="primary" data-label="Constellation">125 <Link href={routes.constellation(c.slug)} className="link font-medium">{c.name}</Link>126 {c.country_code && <span className="mono ml-2 text-xs text-ink-3">{c.country_code}</span>}127 </td>128 <td data-label="Operator" className="text-ink-2">{c.operator_slug ? <Link href={routes.operator(c.operator_slug)} className="link">{c.operator_name}</Link> : c.operator_name ?? '—'}</td>129 <td data-label="Service" className="text-ink-2">{titleCase(c.service_type)}</td>130 <td data-label="Orbit"><OrbitBadge orbitClass={c.orbit_class} /></td>131 <td data-label="Active" className="num tnum font-medium text-active">{fmtInt(c.active)}</td>132 <td data-label="On orbit" className="num tnum max-md:hidden!">{fmtInt(c.on_orbit)}</td>133 <td data-label="Total" className="num tnum">{fmtInt(c.total)}</td>134 <td data-label="Launched 365 d" className="num tnum">{fmtInt(c.launched_last_365d)}</td>135 <td data-label="Launched 30 d" className="num tnum max-md:hidden!">{fmtInt(c.launched_last_30d)}</td>136 <td data-label="Activity score" className="num tnum text-accent-2">{fmt1(c.activity_score)}</td>137 <td data-label="Median perigee" className="num tnum max-md:hidden!">{fmtKm(c.median_perigee_km)}</td>138 <td data-label="First launch" className="tnum text-ink-2 max-md:hidden!">{fmtDate(c.first_launch)}</td>139 <td data-label="Last launch" className="tnum text-ink-2">{fmtDate(c.last_launch)}</td>140 </tr>141 ))}142 </tbody>143 </table>144 </ScrollTable>145 );146}147