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%
10.6 KB · 185 lines tsx
Raw Blame History
1import type { Metadata } from 'next';2import Link from 'next/link';3import { Bars, StackedBars } from '@/components/charts/charts';4import { ChartBlock, Chips, Disclaimer, Note, StatGrid } from '@/components/stats/shared';5import { TypeBadge } from '@/components/ui/badges';6import { Pagination } from '@/components/ui/pagination';7import { Container, PageHeader, Section, Stat } from '@/components/ui/section';8import { Unavailable } from '@/components/ui/unavailable';9import { api, safe } from '@/lib/api';10import { fmt2, fmtAgo, fmtDate, fmtInt, fmtKm, num } from '@/lib/format';11import { OBJECT_TYPE_LABELS, routes, SITE_NAME, SITE_URL } from '@/lib/site';1213export const revalidate = 300;1415type Search = Promise<{ page?: string | string[]; object_type?: string | string[]; days?: string | string[] }>;16const PAGE_SIZE = 50;17const DAYS = [30, 90, 365, 3650] as const;18const TYPES = ['PAYLOAD', 'DEBRIS', 'ROCKET_BODY', 'UNKNOWN'] as const;19const pick = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);2021const TITLE = 'Reentries — recently decayed objects and low-perigee watch';22const DESC = 'Objects that have reentered the atmosphere according to published SATCAT decay dates: last 7/30/365 days, monthly history, filterable table by object type, and a low-perigee watch list of objects under 250 km. No reentry predictions.';2324export async function generateMetadata(): Promise<Metadata> {25  const url = `${SITE_URL}${routes.reentries()}`;26  return {27    title: TITLE,28    description: DESC,29    alternates: { canonical: url },30    openGraph: { title: `${TITLE} | ${SITE_NAME}`, description: DESC, url, type: 'website', siteName: SITE_NAME },31    twitter: { card: 'summary_large_image', title: `${TITLE} | ${SITE_NAME}`, description: DESC },32  };33}3435function href(params: { page?: number; object_type?: string; days?: number }): string {36  const p = new URLSearchParams();37  if (params.object_type) p.set('object_type', params.object_type);38  if (params.days && params.days !== 90) p.set('days', String(params.days));39  if (params.page && params.page > 1) p.set('page', String(params.page));40  const s = p.toString();41  return s ? `${routes.reentries()}?${s}` : routes.reentries();42}43const daysLabel = (d: number) => (d === 30 ? 'Last 30 d' : d === 90 ? 'Last 90 d' : d === 365 ? 'Last year' : 'Last 10 years');4445export default async function ReentriesPage({ searchParams }: { searchParams: Search }) {46  const sp = await searchParams;47  const page = Math.max(1, Number(pick(sp.page) ?? 1) || 1);48  const object_type = TYPES.find((t) => t === pick(sp.object_type)) ?? undefined;49  const daysRaw = Number(pick(sp.days) ?? 90);50  const days = (DAYS as readonly number[]).includes(daysRaw) ? daysRaw : 90;5152  const res = await safe(api.reentries({ page, page_size: PAGE_SIZE, object_type, days }));53  const monthly = res ? [...res.monthly].sort((a, b) => a.month.localeCompare(b.month)).slice(-24) : [];54  const monthlyBars = monthly.map((m) => ({ x: m.month.slice(0, 7), y: num(m.decayed) ?? 0 }));55  const monthlyStack = monthly.map((m) => ({ x: m.month.slice(0, 7), payloads: num(m.payloads) ?? 0, debris: num(m.debris) ?? 0, rocket_bodies: num(m.rocket_bodies) ?? 0 }));5657  const typeChips = [{ href: href({ days }), label: 'All types', active: !object_type }, ...TYPES.map((t) => ({ href: href({ object_type: t, days }), label: OBJECT_TYPE_LABELS[t] ?? t, active: object_type === t }))];58  const dayChips = DAYS.map((d) => ({ href: href({ object_type, days: d }), label: daysLabel(d), active: days === d }));5960  return (61    <Container>62      <PageHeader eyebrow="Reentries" title="Recent reentries" lede="Objects whose decay date has been published in the CelesTrak SATCAT, i.e. confirmed atmospheric reentries. SatelliteIndex reports what the catalogue says — it does not forecast when or where anything will come down.">63        {res && <p className="mono mt-4 text-xs text-ink-3">Generated {fmtAgo(res.meta.generated_at)}</p>}64      </PageHeader>6566      {!res ? (67        <div className="py-6">68          <Unavailable what="Reentry statistics" />69        </div>70      ) : (71        <>72          <Section eyebrow="Summary" title="Confirmed decays" className="pt-0 md:pt-0">73            <StatGrid cols={3}>74              <Stat label="Last 7 days" value={fmtInt(res.summary.last_7d)} accent hint="Objects with a published decay date" />75              <Stat label="Last 30 days" value={fmtInt(res.summary.last_30d)} />76              <Stat label="Last 365 days" value={fmtInt(res.summary.last_365d)} />77            </StatGrid>78          </Section>7980          <Section eyebrow="Monthly" title="Decays by month" action={{ href: routes.debris(), label: 'Debris history' }}>81            <div className="grid gap-10 lg:grid-cols-2 lg:gap-12">82              <ChartBlock title="Objects decayed per month" hint={monthly.length ? `${monthly[0]?.month.slice(0, 7)} → ${monthly[monthly.length - 1]?.month.slice(0, 7)}` : undefined}>83                <Bars data={monthlyBars} title="Objects decayed per month, last 24 months" height={190} xTicks={6} color="var(--series-4)" highlightLast />84                <Note className="mt-2">The last bar is the current, incomplete month.</Note>85              </ChartBlock>86              <ChartBlock title="By object type" hint="Payloads · debris · rocket bodies">87                <StackedBars data={monthlyStack} keys={['payloads', 'debris', 'rocket_bodies']} labels={{ payloads: 'Payloads', debris: 'Debris', rocket_bodies: 'Rocket bodies' }} title="Objects decayed per month by object type" height={190} xTicks={6} />88              </ChartBlock>89            </div>90          </Section>9192          <Section eyebrow="Table" title={`Reentries — ${daysLabel(days).toLowerCase()}${object_type ? ` · ${OBJECT_TYPE_LABELS[object_type] ?? object_type}` : ''}`}>93            <div className="space-y-3 pb-5">94              <Chips items={typeChips} ariaLabel="Object type" />95              <Chips items={dayChips} ariaLabel="Time window" />96            </div>97            {res.data.length === 0 ? (98              <Unavailable what="Reentries matching these filters" />99            ) : (100              <table className="data-table stack text-sm">101                <thead>102                  <tr>103                    <th>Decay date</th>104                    <th>Object</th>105                    <th>NORAD</th>106                    <th>Type</th>107                    <th>Country</th>108                    <th className="num">RCS (m²)</th>109                    <th>Constellation</th>110                  </tr>111                </thead>112                <tbody>113                  {res.data.map((r) => (114                    <tr key={r.id}>115                      <td data-label="Decay date" className="mono text-xs">{fmtDate(r.decay_date)}</td>116                      <td data-label="Object" className="primary">117                        <Link href={routes.satellite(r.slug)} className="link font-medium">{r.name}</Link>118                        {r.cospar_id && <span className="mono ml-2 text-xs text-ink-3">{r.cospar_id}</span>}119                      </td>120                      <td data-label="NORAD" className="mono text-xs">{r.norad_id ?? '—'}</td>121                      <td data-label="Type"><TypeBadge type={r.object_type} /></td>122                      <td data-label="Country">{r.country_name ?? r.country_code ?? '—'}</td>123                      <td data-label="RCS (m²)" className="num tnum">{fmt2(r.rcs_m2)}</td>124                      <td data-label="Constellation">{r.constellation_slug ? <Link href={routes.constellation(r.constellation_slug)} className="link">{r.constellation_name}</Link> : r.constellation_name ?? '—'}</td>125                    </tr>126                  ))}127                </tbody>128              </table>129            )}130            <Pagination className="mt-6" page={res.pagination.page} pages={res.pagination.pages} total={res.pagination.total} pageSize={res.pagination.page_size} makeHref={(p) => href({ page: p, object_type, days })} />131          </Section>132133          <Section eyebrow="Low-perigee watch" title="Objects currently under 250 km perigee">134            <div className="mb-5 space-y-3">135              <Disclaimer text={res.disclaimer} label="Not a prediction" />136            </div>137            {res.low_perigee_watch.length === 0 ? (138              <Unavailable what="Low-perigee watch" />139            ) : (140              <table className="data-table stack text-sm">141                <thead>142                  <tr>143                    <th>Object</th>144                    <th className="num">Perigee</th>145                    <th className="num">Apogee</th>146                    <th>Element epoch</th>147                    <th>Type</th>148                    <th>Country</th>149                    <th>Constellation</th>150                  </tr>151                </thead>152                <tbody>153                  {res.low_perigee_watch.map((o) => (154                    <tr key={o.id}>155                      <td data-label="Object" className="primary">156                        <Link href={routes.satellite(o.slug)} className="link font-medium">{o.name}</Link>157                        {o.norad_id && <span className="mono ml-2 text-xs text-ink-3">#{o.norad_id}</span>}158                      </td>159                      <td data-label="Perigee" className="num tnum">{fmtKm(o.perigee_km, 1)}</td>160                      <td data-label="Apogee" className="num tnum">{fmtKm(o.apogee_km, 1)}</td>161                      <td data-label="Element epoch" className="mono text-xs">{fmtAgo(o.epoch)}</td>162                      <td data-label="Type"><TypeBadge type={o.object_type} /></td>163                      <td data-label="Country" className="mono text-xs">{o.country_code ?? '—'}</td>164                      <td data-label="Constellation">{o.constellation_name ?? '—'}</td>165                    </tr>166                  ))}167                </tbody>168              </table>169            )}170            <Note className="mt-3">Perigee and apogee come from the latest element set (SGP4 mean elements, km). Actively manoeuvring satellites (e.g. during orbit raising) routinely sit under 250 km without decaying.</Note>171          </Section>172173          <Section eyebrow="Predicted" title="Predicted / upcoming reentries">174            <Unavailable what="Predicted reentries — no reentry-prediction source connected yet;" />175            <Note className="mt-3">176              SatelliteIndex does not compute reentry windows or ground tracks itself and will only show predictions once an authoritative source is connected, with its own attribution. See{' '}177              <Link href={routes.methodology()} className="text-accent hover:underline">methodology</Link> and <Link href={routes.sources()} className="text-accent hover:underline">sources</Link>.178            </Note>179          </Section>180        </>181      )}182    </Container>183  );184}185