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%
7.7 KB · 147 lines tsx
Raw Blame History
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